5 Morning Repo 감사의 함정: macOS에서 launchd, bash 3.2 및 Silent Failures

작성자

카테고리:

← 피드로
DEV Community · Lily · 2026-08-18 개발(SW)

I got laid off, and my monthly revenue went to zero overnight. All I had left was a handful of personal repositories. Six months later that number was back up to ¥1.2M/month — and not because I started writing more code. It was because I fixed my environment before I touched the code. This post is about the core of that environment: a script that audits multiple repositories automatically every morning.

Why this works

When you run several side projects in parallel, the bottleneck is always human attention. In my case I was running shukatsu-tracker (a job-hunting tracker) and seo-affiliate-site (an SEO affiliate site) at the same time, and while I was focused on one, small rot kept piling up in the other.

Here’s honestly what went wrong.

A mountain of uncommitted files. I once left temporary debug code from a feature branch uncommitted for two weeks. Later I couldn’t reconstruct “wait, why did I add this change?” and burned 30 minutes on a zero-return investigation. The current script raises a 🟡 warning above 50 uncommitted files (UNCOMMITTED_WARN=50). You might think 50 is too lenient, but the point is to make it work as an indicator of how much change should have been committed, set at a level that doesn’t over-trigger on everyday WIP work.

The fear of stale commits. 14 days since the last commit turns the row 🟡 (STALE_COMMIT_DAYS=14). I don’t use this as a “this project is dead” signal, but as a “I haven’t consciously touched this” signal. A staleness you can’t explain even after digging through two weeks of logs is directly tied to the risk of dependencies going out of date. With side projects you sometimes stop deliberately because of the day job, so the script only reports facts — it never pushes or opens issues automatically. The responsibility for monitoring belongs to the human who reads it, by design.

Accidentally tracking .env. This is an immediate 🔴. UNCOMMITTED_WARN and STALE_COMMIT_DAYS run on loose thresholds, but a tracked .env is an instant fail. git ls-files picks up every .env* pattern except .env.example. If you’ve ever slipped a .env into a git add ., you know this particular fear. Early on, while working on seo-affiliate-site, I put off updating .gitignore and came within a hair of committing a .env containing API keys. I’ve never turned this check off since.

node_modules bloat. An early warning fires at 820MiB (80% of 1GiB), and 🟡 at 1GiB.

NODE_MODULES_WARN_BYTES=$((1024 * 1024 * 1024))      # 1 GiB
NODE_MODULES_NEAR_BYTES=$((1024 * 1024 * 1024 * 80 / 100))  # 820 MiB (80% 事前警告)

Enter fullscreen mode Exit fullscreen mode

Why two stages? If free SSD space unexpectedly drops below 1GiB, builds and Docker start behaving strangely. Considering npm prune at the 820MiB mark is far cheaper than melting an hour tracking down “why did everything suddenly get slow?”

What all of these have in common is the structure of “by the time you notice, it’s too late.” A person writing code cannot actively keep checking the health of that code. Working memory is always fully allocated to whatever you’re building right now. That’s exactly why the checking has to live outside the human.

And there’s one more design principle that gets overlooked: if the monitoring script breaks, it must not stop the main work.

trap 'exit 0' ERR

Enter fullscreen mode Exit fullscreen mode

This single line at the top of the script is the foundation of the design. It keeps set -u (undefined variables are errors) enabled while trapping the ERR signal and exiting with exit 0. In other words, even if an error occurs somewhere in the script, launchd sees a “normal exit.”

Why does that matter? launchd may retry or raise alerts when a job fails. A monitoring tool that keeps throwing errors, which then cascades into notifications and log bloat — call it a “secondary monitoring disaster” — is fatal for solo development. What supports ¥1.2M/month is the main product code, not the monitoring script. A monitoring tool is allowed to fail quietly. More precisely: it must absorb its own failures and never propagate them to the main system.

A fail-open design (let things through on failure) is frowned upon in a security context, but for an availability-first monitoring tool it’s the right call. Far better for monitoring to go silent than for this script to malfunction and halt the shukatsu-tracker build.

The overall flow

Here’s the actual wiring as a diagram.

毎朝 7:30(launchd が起動)
       │
       ▼
 ~/.claude/scripts/project-health.sh
       │
       ├──[ shukatsu-tracker ]
       │       ├─ 未コミット件数(git status --porcelain)
       │       ├─ 最終コミット経過日(git log -1 --format='%ct')
       │       ├─ .env* tracking(git ls-files)
       │       └─ node_modules サイズ(du -sk)
       │
       └──[ seo-affiliate-site ]
               ├─ 同上 4 軸
               └─ package.json の依存数(python3 で JSON パース)
                       │
                       ▼
         ~/.claude/logs/project-health-YYYYMMDD.md  ← 日付別アーカイブ
         ~/.claude/logs/project-health-latest.md    ← 常に最新(cp で上書き)

Enter fullscreen mode Exit fullscreen mode

(The diagram reads: launchd fires at 7:30 every morning → project-health.sh runs → for each repo it checks uncommitted count, days since last commit, .env* tracking, and node_modules size, plus the dependency count parsed from package.json with python3 → output goes to a date-stamped archive and to a latest file that is always overwritten with cp.)

The output is a Markdown table. You can read it directly from a Claude Code dashboard or any editor.

| Project | Uncommitted | Last commit | Deps | node_modules | Status |
|---------|-------------|-------------|------|--------------|--------|
| `shukatsu-tracker` | 3 | 2 days ago - feat: ... | 12+8 | 234MiB | 🟢 |
| `seo-affiliate-site` | 0 | 16 days ago - chore: ... | 9+5 | 187MiB | 🟡 |

Enter fullscreen mode Exit fullscreen mode

The Deps column shows the number of dependencies and devDependencies in package.json in 12+8 form. I use python3 here because it’s more reliable than parsing JSON with bash alone.

DEPS=$(python3 -c "
import json, sys
try:
    p = json.load(open('$PKG'))
    d = len(p.get('dependencies', {}) or {})
    dd = len(p.get('devDependencies', {}) or {})
    print('%d+%d' % (d, dd))
except Exception:
    print('?')
" 2>/dev/null)

Enter fullscreen mode Exit fullscreen mode

Swallowing the error with except Exception: print('?') is deliberate. There’s no reason to bring down the whole script because package.json failed to parse. If a ? shows up, I just check that one repository individually.

Wiring it up with launchd

No matter how correct the script is on its own, it’s meaningless unless it keeps running. I chose macOS-native launchd over cron for two reasons: guaranteed re-execution after a system restart, and persisting standard error to a file.

Here are the core parts of the plist.

<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>7</integer>
  <key>Minute</key>
  <integer>30</integer>
</dict>

<key>LowPriorityIO</key>
<true/>

<key>Nice</key>
<integer>10</integer>

<key>ProcessType</key>
<string>Background</string>

<key>StandardErrorPath</key>
<string>~/.claude/logs/com.shun.project-health.log</string>

<key>StandardOutPath</key>
<string>~/.claude/logs/com.shun.project-health.log</string>

Enter fullscreen mode Exit fullscreen mode

The combination of LowPriorityIO: true and Nice: 10 is the key point. du -sk involves disk reads, so run normally it competes with builds and IDE index updates. LowPriorityIO tells the I/O scheduler “this can wait,” and Nice: 10 tells the CPU scheduler the same thing. 7:30 in the morning is before serious work starts, but Xcode indexing or an npm build often kicks off the moment I open the MacBook. I don’t grant the monitoring script the authority to delay the build that pays the bills.

Spelling out PATH in EnvironmentVariables matters just as much.

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/Users/.../.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...</string>
</dict>

Enter fullscreen mode Exit fullscreen mode

launchd starts jobs without loading shell profiles (.zshrc and friends). To use node installed via nvm or git from Homebrew, you have to hard-code PATH into the plist. This accounts for most cases of “it works when I run it manually but not under launchd.” Even python3 inside the script behaves differently depending on whether /usr/bin/python3 (bundled with macOS) or the Homebrew python is used. Including /opt/homebrew/bin in the plist’s PATH eliminates the gap between the development environment and the automated one.

Duplicating the log

OUT="$LOG_DIR/project-health-${DATE_TAG}.md"     # 例: project-health-20260801.md
LATEST="$LOG_DIR/project-health-latest.md"
...
cp "$OUT" "$LATEST" 2>/dev/null || true

Enter fullscreen mode Exit fullscreen mode

I keep both a date-stamped file and a latest file because they serve different purposes. The dated file is an archive for going back and asking “what state was this in last Tuesday?” The latest file is a shortcut for “tell me this morning’s state right now.” When Claude Code memory or another script references it, it always reads latest. The || true after cp is an extension of the same fail-open thinking — it prevents a failed copy from making the script exit non-zero.

mkdir -p "$LOG_DIR" sits at the very top so that file writes don’t fail on the first run when the directory doesn’t exist yet. Utility scripts like this often die on “the first run in a completely clean environment.” Since the directory already exists on your own machine you never notice — and then it breaks the moment you port it to another machine. This closes that trap.

bash 3.2 compatibility

A comment in the script reads bash 3.2 互換 (mapfile / assoc array 禁止) — bash 3.2 compatible, no mapfile or associative arrays. The default /bin/bash bundled with macOS is still bash 3.2 because of GPL v2 (installing bash 5 via Homebrew doesn’t change /bin/bash). As long as the plist’s ProgramArguments uses /bin/bash -c, the script has to run under bash 3.2.

That rules out bash 4+ features like mapfile (piping into an array) and declare -A (associative arrays). Instead I append to strings (WARN_LINES="${WARN_LINES}...") and expand the newlines with printf "%b".

WARN_LINES=""
...
WARN_LINES="${WARN_LINES}- ${STATUS_FLAG} \`${NAME}\`: ${REPO_WARNS}\n"
...
printf "%b" "$WARN_LINES"

Enter fullscreen mode Exit fullscreen mode

I expand literal \n with printf "%b" because echo -e behaves inconsistently across bash/sh implementations. printf is POSIX-compliant and safe.

Implementation details

git -C prevents “current directory” pollution inside the loop

Did you notice the script never uses cd "$REPO" even once? Every git command specifies its target directory directly with the -C flag.

UNCOMMITTED=$(git -C "$REPO" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
LAST_COMMIT=$(git -C "$REPO" log -1 --format='%cr - %s' 2>/dev/null | LC_ALL=C tr '|' '/' | LC_ALL=C tr -d '\r\n' | cut -c1-80)
LAST_COMMIT_TS=$(git -C "$REPO" log -1 --format='%ct' 2>/dev/null)
TRACKED_ENV=$(git -C "$REPO" ls-files 2>/dev/null | grep -E '(^|/)\.env($|\.)' | grep -v '\.env\.example$' | head -5)

Enter fullscreen mode Exit fullscreen mode

The reason I avoid cd is that a cd inside a loop pollutes the “current directory” across iterations. If cd fails for any reason while processing the first repository, every subsequent iteration runs git commands in an unintended directory. Even with set -u and trap 'exit 0' ERR in place, you can’t detect the state of “operating on the wrong repository.” With -C, each command carries its own target directory, so cross-iteration side effects can’t occur by construction.

One-way updates to STATUS_FLAG

STATUS_FLAG="🟢"
...
if [ "$UNCOMMITTED" -gt "$UNCOMMITTED_WARN" ]; then
    STATUS_FLAG="🟡"
fi
...
if [ "$COMMIT_AGE_DAYS" -gt "$STALE_COMMIT_DAYS" ]; then
    [ "$STATUS_FLAG" = "🟢" ] && STATUS_FLAG="🟡"   # ← 🟡 昇格は 🟢 のときだけ
fi
...
if [ -n "$TRACKED_ENV" ]; then
    STATUS_FLAG="🔴"   # ← 🔴 昇格は無条件
fi

Enter fullscreen mode Exit fullscreen mode

Look at the update rules for STATUS_FLAG. Promotion to 🟡 is conditional — “only when currently 🟢.” Promotion to 🔴, on the other hand, is unconditional.

This is a design that prevents regression toward lower severity. If a repo already went 🟡 from too many uncommitted files and the stale-commit check comes next, the state doesn’t change, because the rule says to switch to 🟡 only when 🟢. Only .env tracking (🔴) holds the top alert no matter what.

The reason this matters in practice for side projects is that I want to judge instantly by looking only at the rightmost Status column. If the most severe alert isn’t preserved when multiple problems overlap, the ten seconds I spend glancing at the table will produce a miss.

Extra logic for the uncommitted count

# uncommitted が0超かつ閾値以下でも、他の警告がある場合は件数を付記
if [ "$UNCOMMITTED" -gt 0 ] && [ "$UNCOMMITTED" -le "$UNCOMMITTED_WARN" ] && [ -n "$REPO_WARNS" ]; then
    REPO_WARNS="${REPO_WARNS}uncommitted=${UNCOMMITTED} (ok); "
fi

Enter fullscreen mode Exit fullscreen mode

This branch encodes a compound judgment: “an uncommitted count under the threshold isn’t a warning on its own, but it gets noted when combined with another problem.” For example, if a repository shows a stale commit (over 14 days) and also has 23 uncommitted files, the combination “untouched for 14 days + 23 uncommitted” stays in the Warnings section. A number that wouldn’t trigger 🟡 alone becomes important information in the context of another problem.

GiB display for node_modules and integer arithmetic

NM_KB=$(du -sk "$NM_DIR" 2>/dev/null | awk '{print $1}')
[ -z "$NM_KB" ] && NM_KB=0
NM_BYTES=$((NM_KB * 1024))
NM_MIB=$((NM_KB / 1024))
if [ "$NM_MIB" -ge 1024 ]; then
    NM_DISPLAY="$((NM_MIB / 1024)).$((NM_MIB % 1024 * 10 / 1024))GiB"
else
    NM_DISPLAY="${NM_MIB}MiB"
fi

Enter fullscreen mode Exit fullscreen mode

du -sk returns KB. I multiply by 1024 to get NM_BYTES and compare against thresholds in bytes (integer arithmetic gives better precision here). The GiB display’s $((NM_MIB % 1024 * 10 / 1024)) takes the remainder below 1024, multiplies it by 10, then divides by 1024 — obtaining one decimal digit as an integer. bash 3.2 has no floating-point arithmetic, so this integer math stands in for it. The output looks like 1.2GiB.

The grep pattern for .env tracking

TRACKED_ENV=$(git -C "$REPO" ls-files 2>/dev/null \
    | grep -E '(^|/)\.env($|\.)' \
    | grep -v '\.env\.example$' \
    | head -5)

Enter fullscreen mode Exit fullscreen mode

grep -E '(^|/)\.env($|\.)' picks up either “.env itself” or “files starting with .env.” (.env.local, .env.production, and so on). The (^|/) is there so that a .env inside a subdirectory (config/.env, etc.) is caught too. grep -v '\.env\.example$' excludes only .env.example.

The | head -5 is there so the output doesn’t explode if some accident leaves a huge number of .env* files tracked. The Warnings section shows only the first one via FIRST_ENV=$(echo "$TRACKED_ENV" | head -1). I prioritize “show one and make it register immediately” over “how many are there.”

Two-tier output: the table and the Warnings section

echo "| \`${NAME}\` | ${UNCOMMITTED} | ${LAST_COMMIT} | ${DEPS} | ${NM_DISPLAY} | ${STATUS_FLAG} |" >> "$OUT"
...
{
  echo "## Warnings (${TOTAL_WARN})"
  ...
  printf "%b" "$WARN_LINES"
} >> "$OUT"

Enter fullscreen mode Exit fullscreen mode

The table handles “grasp everything at a glance”; the Warnings section handles “details of the problem.” The table stays readable at a fixed width even as the number of repositories grows, but cramming warning details (which file is tracked, how many days stale) into cells makes them far too long. So the details are split out into the Warnings section below.

TOTAL_WARN counts per repository (multiple problems in one repo still count as +1). When you compare ## Warnings (0) against ## Warnings (2), you immediately see how many repositories have problems. The granularity I manage is “two projects have problems,” not “there are two problems.”

Where I got stuck

Stuck #1: launchd failed silently with “command not found”

When I first created the plist and ran launchctl load, the job failed silently even though the script itself existed. Checking the log file set in StandardErrorPath:

/bin/bash: git: command not found
/bin/bash: python3: command not found

Enter fullscreen mode Exit fullscreen mode

The cause was PATH. When I run it manually in a terminal, .zshrc is loaded and Homebrew’s PATH is set. But launchd starts jobs without reading any shell profile. At the point /bin/bash -c is invoked, PATH is only about /usr/bin:/bin:/usr/sbin:/sbin.

The fix is hard-coding PATH into the plist. Here’s what the actual file contains.

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/Users/.../.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/.../.local/bin</string>
</dict>

Enter fullscreen mode Exit fullscreen mode

By putting the nvm-managed node and Homebrew first, the python3 inside the script resolves to the Homebrew Python and matches terminal behavior exactly.

It took me two days to figure this out. If I hadn’t set a log file via StandardErrorPath, there wouldn’t even have been a trace of the failure. Since then, whenever I create a new launchd job, the first two things I do are configure the log file and spell out PATH.

Stuck #2: I wrote syntax that doesn’t run on bash 3.2

The first version tried to manage multiple repositories with an associative array.

# NG(bash 4 以上でしか動かない)
declare -A REPO_MAP
REPO_MAP["shukatsu-tracker"]="$HOME/dev/shukatsu-tracker"
REPO_MAP["seo-affiliate-site"]="$HOME/dev/seo-affiliate-site"

Enter fullscreen mode Exit fullscreen mode

Running it manually in a terminal works fine. But running it through launchd (/bin/bash) gives:

/bin/bash: declare: -A: invalid option

Enter fullscreen mode Exit fullscreen mode

macOS’s /bin/bash is bash 3.2.57. declare -A (associative arrays) was added in bash 4.0. Installing bash 5 via Homebrew doesn’t change /bin/bash. As long as the plist’s ProgramArguments points at /bin/bash, the script has to run under bash 3.2.

The current script holds the repositories as a newline-separated string.

REPOS="
$HOME/dev/shukatsu-tracker
$HOME/dev/seo-affiliate-site
"

for REPO in $REPOS; do
    ...
done

Enter fullscreen mode Exit fullscreen mode

The unquoted $REPOS in for REPO in $REPOS is word-split. Both newlines and spaces split on the default IFS, so this works as long as repository paths contain no spaces. No mapfile, no associative arrays — it runs on bash 3.2 as-is. When you hit a constraint that stops you from writing something the simple way, looking for a simpler alternative usually turns one up.

Stuck #3: Japanese in git log broke the table via tr

Getting LAST_COMMIT includes the git log commit message.

LAST_COMMIT=$(git -C "$REPO" log -1 --format='%cr - %s' 2>/dev/null \
    | LC_ALL=C tr '|' '/' | LC_ALL=C tr -d '\r\n' | cut -c1-80)

Enter fullscreen mode Exit fullscreen mode

The early version of this pipeline had no LC_ALL=C. When a Japanese commit message got mixed in (something like feat: ログイン画面を追加), tr couldn’t handle the multibyte characters correctly and injected mojibake resembling the table’s pipe character |. Extra | characters in a Markdown table shift the entire set of columns and make the report unreadable.

LC_ALL=C tr '|' '/' converts pipe characters to slashes and prevents column collapse. Adding LC_ALL=C puts tr into byte-wise processing mode, which eliminates multibyte misinterpretation.

Combined with export LANG=en_US.UTF-8 at the top of the script, git log output itself is retrieved as UTF-8 while only tr‘s character handling is switched to byte mode.

Stuck #4: set -u plus trap produced a silent exit

set -u makes referencing an undefined variable an error. trap 'exit 0' ERR exits with 0 immediately on error. That combination initially produced unintended silent skips.

Here was the problematic code.

# NG だった初期バージョン
LAST_COMMIT_TS=$(git -C "$REPO" log -1 --format='%ct' 2>/dev/null)
COMMIT_AGE_DAYS=$(( ($(date +%s) - LAST_COMMIT_TS) / 86400 ))
# ↑ LAST_COMMIT_TS が空文字のとき、算術展開が構文エラー → ERR 発火 → exit 0

Enter fullscreen mode Exit fullscreen mode

When LAST_COMMIT_TS is an empty string (a repository with no commits), the $(( ... - )) arithmetic expansion becomes a syntax error. It’s outside the scope of set -u, but an arithmetic error makes bash return a non-zero status, so trap 'exit 0' ERR fires immediately and the whole script terminates. The remaining repositories are never checked, and a truncated report gets written over latest.md.

The fix is an empty-string guard. Here’s the current code.

LAST_COMMIT_TS=$(git -C "$REPO" log -1 --format='%ct' 2>/dev/null)
if [ -n "$LAST_COMMIT_TS" ] && [ "$LAST_COMMIT_TS" -gt 0 ] 2>/dev/null; then
    COMMIT_AGE_DAYS=$(( ($(date +%s) - LAST_COMMIT_TS) / 86400 ))
    if [ "$COMMIT_AGE_DAYS" -gt "$STALE_COMMIT_DAYS" ]; then
        [ "$STATUS_FLAG" = "🟢" ] && STATUS_FLAG="🟡"
        REPO_WARNS="${REPO_WARNS}stale-commit=${COMMIT_AGE_DAYS}d (>${STALE_COMMIT_DAYS}d); "
    fi
fi

Enter fullscreen mode Exit fullscreen mode

The trailing 2>/dev/null in [ "$LAST_COMMIT_TS" -gt 0 ] 2>/dev/null keeps the “integer expression expected” message that [ emits for a non-numeric LAST_COMMIT_TS out of the log. If the condition is false, the arithmetic expansion is never reached and ERR never fires.

When you use trap 'exit 0' ERR, you have to keep in mind at all times that any non-zero status exits immediately. The growing pile of defensive guard conditions looks redundant, but in a fail-open script I accept it as the cost of preventing “a code path that should never be reached emits ERR and kills the script.” A monitoring script quietly exiting partway through is just as dangerous as the monitoring script crashing.

Stuck #5: StandardOutPath and StandardErrorPath pointing at separate files got overwritten

Initially I set StandardOutPath and StandardErrorPath to separate files.

<!-- 初期版(別ファイル) -->
<key>StandardOutPath</key>
<string>~/.claude/logs/com.shun.project-health-out.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/com.shun.project-health-err.log</string>

Enter fullscreen mode Exit fullscreen mode

But launchd overwrites these files every time it runs the job (it does not append). stdout should be empty since the script writes its own output (the Markdown report) directly to $OUT, but the debug information going to stderr gets wiped every morning along with the previous run’s.

The fix is pointing stdout and stderr at the same file. That’s how the current file looks.

<key>StandardErrorPath</key>
<string>/Users/.../.claude/logs/com.shun.project-health.log</string>
<key>StandardOutPath</key>
<string>/Users/.../.claude/logs/com.shun.project-health.log</string>

Enter fullscreen mode Exit fullscreen mode

Pointing them at the same file writes stdout and stderr interleaved into one place. Since it’s overwritten once a day, the previous day’s content is lost — but that’s enough to check “how did this morning’s job behave?” If longer retention were needed, you could call the script through a wrapper using >>, but for now one day’s worth of morning debugging is sufficient.

When chasing launchd job logs, tail -f on the file set in StandardErrorPath is more practical than log show --predicate 'subsystem == "com.apple.launchd"'. The former gives you launchd daemon-side metadata; the latter gives you the script’s actual output. I’ve confused the two and gotten lost thinking “I can’t find the logs,” but for verifying script behavior, almost all you need is the latter.

Gotchas

Beyond the five detailed above (PATH not being set / bash 3.2 associative arrays / mojibake from Japanese commit messages / the interaction of set -u and trap / StandardOutPath overwrites), here’s a comprehensive list of things I actually hit or could plausibly hit.

  • ~ is not expanded inside a plist. If you write ~/.claude/logs/... in StandardErrorPath, launchd interprets it as a literal string. It tries to create a directory named ~ at the filesystem root and quietly fails. The real file uses absolute /Users/<username>/... paths everywhere. The same goes for the PATH in EnvironmentVariables$HOME variable expansion doesn’t work. This is the second most common cause of “it works manually but not under launchd.”

  • launchctl load is deprecated as of macOS Ventura. launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/...plist is the current form. load still works, but there’s a risk of behavior changing with macOS upgrades. It’s safer to get used to bootstrap / bootout from the start when creating something new.

  • Putting a repository with no commits (an empty repo) in the REPOS list fires ERR. git log -1 --format='%ct' returns an empty string for a repository with zero commits. The current script absorbs this through the [ -n "$LAST_COMMIT_TS" ] && [ "$LAST_COMMIT_TS" -gt 0 ] 2>/dev/null guard. The absence of a stale check on the first run right after adding a new repository to REPOS is evidence the guard is working. It’s not a malfunction.

  • The python3 bundled with macOS pops up the Xcode tools installation dialog. In a clean environment without Homebrew, the first invocation of /usr/bin/python3 opens a GUI dialog. Under launchd there’s no screen to show it on, so the command hangs. Including /opt/homebrew/bin at the front of the plist’s PATH gives priority to the Homebrew python3, so this doesn’t happen. If you use Homebrew, no extra work is required.

  • for REPO in $REPOS splits into two tokens if the path contains a space. The script relies on word splitting to process a newline-separated list, so a path with a space like ~/dev/my project is split into ~/dev/my and project, and both go 🔴 with “directory not found.” The repositories I currently manage, shukatsu-tracker and seo-affiliate-site, have no spaces, so it’s fine — but be careful with directory names when porting this.

  • A scheduled time that passes while the machine is asleep is skipped until the next occurrence. StartCalendarInterval behaves the same as cron: if the Mac was asleep, that run is skipped. latest.md stays dated from the previous day until 7:30 the next morning. If you think “today’s report didn’t come out,” check sleep first. The alternative is StartInterval (every N seconds), but that has the downside of firing at unexpected moments while you’re working. If you’re in the habit of opening your machine in the morning, StartCalendarInterval is fine in practice.

  • If latest.md is stuck on an old date, read it as a sign of a mid-run exit. A silent termination via trap 'exit 0' ERR can happen before reaching cp "$OUT" "$LATEST". In that case latest.md stays at the previous day’s content. When you detect “latest is dated yesterday,” checking com.shun.project-health.log is the shortest diagnostic route.

  • du -sk output can come back empty. Even if node_modules exists, du -sk returns nothing when you lack permissions. The script places [ -z "$NM_KB" ] && NM_KB=0 after NM_KB=$(... 2>/dev/null), so the empty case is treated as 0. If you see displayed when node_modules clearly exists, check permissions.

  • Writing the filter so .env.example isn’t excluded produces noise. The two-stage filter grep -E '(^|/)\.env($|\.)' | grep -v '\.env\.example$' is the correct form. If you write only grep -E '\.env' at first, .env.example matches too and template files keep raising 🔴 warnings. I did exactly that in the first version and left every repository permanently 🔴 for a full day.

  • It’s normal for launchctl list | grep com.shun.project-health to show no PID. This job is schedule-triggered and isn’t resident except while running. A - in the PID column is fine. It’s more useful to confirm that LastExitStatus is 0. launchctl list com.shun.project-health prints details in JSON form, so check LastExitStatus and LastExitDate to confirm normal operation.

Best practices

1. Design monitoring scripts to fail open

Put trap 'exit 0' ERR at the top. Even if the script breaks, it shouldn’t stop your main build or set off a cascade of launchd alerts. Fail-open is the right answer for an availability-first monitoring tool. It’s the opposite judgment from a security context.

2. Use git -C and never cd inside the loop

With cd "$REPO", a failed cd or an unexpected state carries over before you move to the next repository. If each command carries its own target, as in git -C "$REPO" status, cross-iteration side effects can’t occur by construction.

3. Write every path in the plist as an absolute path

Neither ~ nor $HOME is expanded by launchd. Write both the PATH in EnvironmentVariables and StandardErrorPath as absolute /Users/<username>/... paths. Half of all “works manually, not under launchd” cases come from here.

4. Spell out PATH in nvm → Homebrew → system order

Because launchd doesn’t read shell profiles, it runs with a bare PATH of roughly /usr/bin:/bin. Writing the same PATH as your development environment into the plist’s EnvironmentVariables makes python3, git, and node behave exactly as they do in the terminal. It also makes the script work on machines other than the author’s.

5. Always pair LowPriorityIO: true with Nice: 10

du -sk involves disk reads. LowPriorityIO tells the I/O scheduler and Nice: 10 tells the CPU scheduler that this can wait. A monitoring script must not delay your main build by even a second.

6. Point StandardOutPath and StandardErrorPath at the same file

Separate files get overwritten on every run, and correlating stdout with stderr becomes a chore. Pointing them at the same file means “how did this morning’s job behave?” is answered by one file. One day of retention is plenty.

7. Only ever move STATUS_FLAG toward higher severity

Promotion to 🟡 only when currently 🟢; promotion to 🔴 unconditionally. A severe flag, once set, never gets softened by a later check. When multiple problems overlap, having the most severe alert survive at the right edge of the table is what makes at-a-glance judgment possible.

8. Make the output two-tier: a table plus Warnings

The table stays comprehensible at a fixed width as the number of repositories grows. Cramming warning details (how many days stale, which file is tracked) into cells breaks the columns. Splitting details into a ## Warnings section separates “glance at it for one second” from “dig into it.”

9. Write it to run on bash 3.2

macOS’s /bin/bash is still bash 3.2. As long as the plist uses /bin/bash -c, neither declare -A (associative arrays) nor mapfile is available. A newline-separated string + word splitting + printf "%b" for newline expansion is a simple substitute that runs on bash 3.2 as-is. A good example of “the more constraints, the simpler it gets.”

10. Prefix tr and sed with LC_ALL=C

When multibyte characters (Japanese commit messages, say) get mixed in, tr misreads bytes. Switching to byte mode with LC_ALL=C tr '|' '/' prevents pipe characters in the Markdown table from multiplying through mojibake.

11. Always guard against empty strings and non-numbers before arithmetic expansion

if [ -n "$LAST_COMMIT_TS" ] && [ "$LAST_COMMIT_TS" -gt 0 ] 2>/dev/null; then
    COMMIT_AGE_DAYS=$(( ($(date +%s) - LAST_COMMIT_TS) / 86400 ))
fi

Enter fullscreen mode Exit fullscreen mode

Under set -u, undefined variables are errors — and arithmetic expansion on an empty string is an error too. Combined with trap 'exit 0' ERR, an unguarded arithmetic expansion silently terminates the script. The guard looks redundant, but it’s a necessary cost in a fail-open script.

12. Split references between a date-stamped log and latest

OUT="$LOG_DIR/project-health-${DATE_TAG}.md"
LATEST="$LOG_DIR/project-health-latest.md"
cp "$OUT" "$LATEST" 2>/dev/null || true

Enter fullscreen mode Exit fullscreen mode

The date-stamped file is for looking back; latest is a shortcut for “read this morning’s state right now.” Claude Code context and other scripts always read latest. Guarding a failed cp with || true is another extension of fail-open design.

13. Always bound output with head -N

The | head -5 in the .env check is the example. It prevents an accident where an abnormal state returns a huge number of lines and the output balloons the log file to hundreds of MB. Set a limit on each command with the mindset of “this should be 0 lines when healthy, but what if it returns a lot?”

14. Run mkdir -p at the top of the script

Even when the log directory doesn’t exist — a first run, or a port to a clean environment — the initial mkdir -p "$LOG_DIR" protects you. It eliminates the classic cause of “it worked on my machine but not when I moved it to another one.”

Summary

The core of this setup, in one line: decouple problem discovery from human attention.

I’ve been able to hold ¥1.2M/month not because I write more code. What I learned from being laid off and dropping to zero is a simple fact: projects running in parallel will always rot. While a human is focused on one product, nobody is looking at the other. With an accidentally tracked .env, or a stale commit left for more than 14 days, by the time you notice, the time spent tracing “how did this happen?” is a bigger loss than the problem itself.

project-health.sh runs at 7:30 every morning and drops a Markdown table into ~/.claude/logs/project-health-latest.md. One glance at that file when I start working tells me the state of every repository. If the script breaks, trap 'exit 0' ERR protects the main work, and launchd’s LowPriorityIO and Nice: 10 keep it from competing with builds. Monitoring must not get in the way of the real work — that’s the whole design policy.

The most important judgment in the implementation was continuing to defend simplicity. Not being able to use associative arrays, thanks to the bash 3.2 constraint, ended up pushing me toward the simpler implementation of a newline-separated string. Using python3 only for JSON parsing and completing everything else with POSIX-compliant shell commands means the only real environmental dependency is whether Homebrew is present. For a solo developer running multiple repositories in parallel, “being able to grasp the whole picture in five seconds every morning” is the foundation of productivity.

I’ve written up the full picture of this setup, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート

Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

원문에서 계속 ↗