Losing your job costs you a paycheck. What I didn't expect was to spend that same afternoon discovering that half the automation propping up my side income had quietly stopped running — and nobody, including me, had noticed.
Why this setup works
Once your personal automation crosses about 20 jobs, "silent death" becomes routine.
Human attention has a ceiling. With 3 launchd jobs, you can eyeball them every morning. At 10, it becomes weekly. At 26 — you stop checking altogether. Days go by on a vague feeling that "it's probably running." And when you finally notice, it's been dead for three weeks.
That is exactly the reality I ran into right after the layoff. Back when my side income was ¥600,000/month, the machinery holding it up was dying quietly. Nobody noticed, because there was still a salary coming in. It took revenue hitting zero for the question to surface: "wait, how long has that job been down?"
launchd breaks quietly.
macOS launchd (the daemon-management layer) responds to a crashing script by saying nothing and waiting for the next scheduled run. Even if it's returning exit 78, nobody finds out unless you run launchctl list yourself. In my own environment, com.shun.agentmemory sat in a crash loop for six days in June 2026. Worse was the half-alive state: the port was open but no worker was behind it — every HTTP request returned 404 while the process still existed. The usual liveness check (just confirming the process exists) misses this completely.
Experiences like that are why automation-health.sh is designed not just to check, but to fix what it finds, immediately.
It's the environment, not the task
Automation failures aren't like task failures. When a task fails, somebody gets angry. When your environment rots, nobody gets angry. Your productivity just slowly drains away.
What supports ¥1.2M/month isn't any single script — it's the "environment" where all of them stay interlocked and running. skill-harvest generates auto-skills, conversation logs accumulate into the Knowledge Base, that syncs into the Obsidian Vault, agentmemory shares memory across Claude instances. Break one link in that chain and, weeks later, you get a vague sense that "Claude's suggestions have felt thin lately." Understanding why comes even later.
What automation-health.sh inspects is precisely each link in that chain. Not just the scheduled launchd jobs, but hook script permissions, skill-harvest log freshness, the last update of conversation logs, the Obsidian Vault's auto-update marker, agentmemory's HTTP reachability — all of it swept in one command, exiting 1 if even one item is RED.
[開発者] bash ~/.claude/scripts/automation-health.sh
↓
exit 0 → ALL GREEN / WARN
exit 1 → RED あり → StopHookが捕捉
That is: run the script, and exit 0 means ALL GREEN or WARN only, while exit 1 means at least one RED — which the StopHook picks up.
The exit-1 design is the important part. Wire this script into the StopHook that fires when a Claude session ends, and "verify automation health every time I close Claude" becomes the default. Put it in cron and you verify it every morning. Neither depends on you deciding to check.
The overall flow
automation-health.sh inspects nine sections in order. Every section uses the same output style:
✓ 緑 → 正常
⚠ 黄 → 警告(致命的ではないがケア必要)
✗ 赤 → 失敗(exit 1の原因になる)
Green ✓ means healthy, yellow ⚠ means a warning (not fatal, but needs care), and red ✗ means failure (the thing that causes exit 1).
Here's the whole structure as an ASCII diagram:
bash automation-health.sh
│
├─ [1] launchd ジョブ (com.shun.* / com.lily.*)
│ 全plistをループ → launchctl list で照合
│ 未ロード? → launchctl bootstrap で即自動再投入
│ 再投入失敗 → ✗ RED
│ 前回 exit ≠ 0 → ✗ RED
│
├─ [2] hooks (8本のシェルスクリプト)
│ pre_git_guard / pre_secrets_check / pre_env_guard
│ post_audit_log / post_format / post_tsc_check
│ stop_notify / user_prompt_submit
│ 不在 → ✗ RED / 実行権限なし → ⚠ WARN
│
├─ [3] skill-harvest
│ .harvest.log の最終更新が48h以内か
│
├─ [4] 会話ログ (Stop hook → 長期記憶)
│ ~/Documents/my-knowledge-base/raw/conversations/
│ INDEX.md の鮮度が24h以内か
│
├─ [5] Obsidian Vault 連携
│ hot.md の自動更新マーカー存在確認
│ index.md のカバレッジ(実ファイル数と記載ページ数の一致)
│
├─ [5.5] agentmemory サーバ
│ launchctl 状態 AND http://localhost:3111/agentmemory/health → 200
│ どちらかが欠けていても ✗ RED
│
├─ [6] remember 記憶層
│ now.md / recent.md / archive.md の存在確認
│ now.md の重複バーストを検知(consolidate 遅延の兆候)
│
├─ [7] ディスク / 残骸
│ ~/.claude 実効サイズ(>5GB で ✗)
│ security_warnings_state_*.json の残骸数
│
├─ [8] 週次/月次バッチ (7本)
│ ログファイルの最終更新時刻 vs 許容時間予算
│
└─ [9] cron ↔ launchd 重複
同一スクリプトが両系統に登録されていないかを確認
(移行後の二重実行バグを防ぐ)
↓
fail > 0 → exit 1(RED あり)
warn > 0 → exit 0(致命的問題なし)
fail = 0, warn = 0 → exit 0(ALL GREEN)
In short: [1] loops every plist and cross-checks launchctl list, auto-bootstrapping anything not loaded; [2] checks 8 hook shell scripts for existence and the executable bit; [3] checks that .harvest.log was updated within 48h; [4] checks conversation-log INDEX.md freshness within 24h; [5] checks the Obsidian Vault's auto-update marker and index coverage; [5.5] checks agentmemory via launchd state and an HTTP 200; [6] checks the remember memory layers and duplicate bursts in now.md; [7] checks disk size and leftover junk; [8] checks 7 weekly/monthly batch jobs against a time budget; [9] checks for the same script registered in both cron and launchd. Any failure means exit 1; warnings alone still exit 0.
Section [1] is the core — automatic self-healing
The most important piece is the implementation of section [1]. Writing a script that "checks and reports" is easy. But that leaves the human chore of "see RED, fix it by hand."
The launchd section of automation-health.sh re-bootstraps an unloaded job the moment it finds one:
# 全 com.shun.* / com.lily.* plist を監視。未ロードを見つけたら冪等に自動再bootstrap
# (これが無いと、ジョブがlaunchdから外れてもサイレントに発火しなくなる)
uid_num=$(id -u)
for plist in "$HOME_DIR"/Library/LaunchAgents/com.shun.*.plist \
"$HOME_DIR"/Library/LaunchAgents/com.lily.*.plist; do
[ -e "$plist" ] || continue
job=$(basename "$plist" .plist)
line=$(launchctl list 2>/dev/null | grep -E "\b${job}\b")
if [ -z "$line" ]; then
if launchctl bootstrap "gui/${uid_num}" "$plist" 2>/dev/null; then
ok "$job: 未ロード → 自動で再ロードした"
else
ng "$job: 未ロード・再ロード失敗 (手動 launchctl bootstrap 要)"
fi
else
exitc=$(echo "$line" | awk '{print $2}')
if [ "$exitc" = "0" ] || [ "$exitc" = "-" ]; then
ok "$job: ロード済 / last exit=$exitc"
else
ng "$job: last exit=$exitc (前回失敗)"
fi
fi
done
The second column of launchctl list is the exit code. 0 is a clean exit, - means "currently running or never started yet," and any other number is a failed previous run. Jobs quietly returning exit 78 (configuration error) or exit 1 (in-script error) are all caught by this single loop.
When a job isn't loaded, launchctl bootstrap gui/${uid_num} <plist> re-registers it immediately. The gui/${uid_num} target specifier is the key part — it's the current API from macOS 10.15 onward (the old launchctl load is deprecated). Only when the re-bootstrap itself fails does it record ng (RED) and move on to the next check.
Section [5.5] — why a process check alone isn't enough
There's a reason the agentmemory check is two-stage. The reason is left in a comment in the code itself:
# 2026-06-11 監査の教訓: launchd の exit 78 クラッシュループが6日間誰にも気づかれず、
# さらに「ポートは開くが worker 不在で全API 404」の半生状態は死活監視では見えない。
# launchd 状態 + /agentmemory/health の HTTP 200 の両方を見る。
The actual check logic looks like this:
am_code=$(curl -s -o /dev/null -w '%{http_code}' -m 3 \
http://localhost:3111/agentmemory/health 2>/dev/null || echo 000)
if [ "$am_code" = "200" ]; then ok "稼働中 (pid=$am_pid / health 200)"
elif [ "$am_code" = "000" ]; then ng "プロセスは居るが port 3111 無応答"
else ng "port 3111 は開くが /agentmemory/health=$am_code — worker 不在の半生状態"
fi
By checking the HTTP status code as well, you can detect the half-alive state where the process is up but the API is dead. The same idea generalizes far beyond agentmemory — web servers, AI model API proxies, anything that serves HTTP.
Section [8] — time-based liveness checks
Liveness of weekly and monthly batches is judged by "when was the log last written." Each of the 7 batch jobs gets its own time budget:
declare -a CRON_JOBS=(
"weekly cleanup-misc:~/.claude/logs/cleanup-misc.log:192" # 週次→8日許容
"weekly env-audit:~/.claude/logs/env-audit-latest.md:192"
"monthly plugin-purge:~/.claude/logs/plugin-purge.log:744" # 月次→31日
"weekly plugin-auto-disable:~/.claude/logs/plugin-auto-disable.log:192"
"weekly dotfiles-snapshot:~/.claude/logs/dotfiles-snapshot.log:192"
"weekly agents-index:~/.claude/logs/agents-index.log:192"
"daily plugin-usage:~/.claude/scripts/plugin-audit-latest.md:48" # 毎日→2日
)
Weekly batches allow 192 hours (8 days), monthly 744 hours (31 days), and daily 48 hours. If a log's mtime exceeds the budget, it's reported as ⚠ WARN. The only evidence that a job actually fired is a write to its log, so looking at the log is the most reliable signal.
As a concrete example, here's part of the plist for com.shun.plugin-auto-disable:
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key> <integer>6</integer>
<key>Minute</key> <integer>45</integer>
<key>Weekday</key> <integer>0</integer>
</dict>
It runs plugin-auto-disable.sh apply every Sunday at 06:45 and appends to ~/.claude/logs/plugin-auto-disable.log. Even if this plist is loaded into launchd correctly, the log stops if the script itself exits with an error. Section [1] verifies the load state, section [8] verifies log freshness — only with both layers can you say the job is actually running.
Implementation details
Why use launchctl bootstrap — avoiding the "old API"
macOS launchctl has two APIs, old and new. The old one is launchctl load <plist>; the new one is launchctl bootstrap <target> <plist>.
launchctl load has been deprecated since around Catalina. It sometimes still works, but it prints nothing useful to the log and behavior can change after a reboot. That's why section [1] of automation-health.sh uses bootstrap:
uid_num=$(id -u)
if launchctl bootstrap "gui/${uid_num}" "$plist" 2>/dev/null; then
ok "$job: 未ロード → 自動で再ロードした"
else
ng "$job: 未ロード・再ロード失敗 (手動 launchctl bootstrap 要)"
fi
The string gui/${uid_num} is the target. id -u gets the logged-in user's UID (usually 501), and the plist is submitted against the gui/501 target. Without that target specifier, a job meant to run in a logged-in GUI session (a script calling Homebrew-installed tools, say) gets registered as a daemon and runs in an environment with a completely different PATH.
Why 2>/dev/null matters, too: running bootstrap against an already-loaded job produces an error. To keep the call idempotent, the design throws away stderr and only looks at the return value.
Why a plist's EnvironmentVariables is a liveness concern
Look at the top of com.shun.plugin-auto-disable.plist and you'll find PATH written out explicitly:
<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>
launchd does not read .zshrc or .bashrc. A bare launchd environment's PATH is only /usr/bin:/bin:/usr/sbin:/sbin. node, brew, claude — none of them are visible.
This PATH problem burned me twice. The first time, a node-based script was silently dying with "command not found: node." The second time, a script calling Homebrew's jq failed silently for the same reason. Nothing wrong with the script itself, and running it by hand in a terminal works fine — that's the hardest symptom to diagnose.
The only solution is writing PATH into the plist. The downside is you end up with version-pinned paths like ~/.nvm/versions/node/v24.13.0/bin, but that's far better than failing silently on an implicit PATH. I once forgot to update this after changing node versions, and the job didn't run for three days. Since then I put a # nvm version: X.X.X comment in the plists of node-based jobs as a trigger to remember the plist update.
Section [6] — detecting duplicate bursts in now.md
The remember plugin's now.md gets one summary line appended per conversation. Periodically, Haikus promote entries via the consolidate command: now.md → recent.md → archive.md. When that promotion is delayed, dozens of identical summaries pile up in now.md.
The script detects this "burst":
dup=$(grep -vE '^\s*$|^##|^#' "$nowf" 2>/dev/null \
| sort | uniq -c | sort -rn | head -1 | awk '{print $1}')
if [ "${dup:-0}" -ge 3 ]; then
wn "now.md に同一要約が ${dup} 回 (consolidate 遅延の兆候)"
else
ok "now.md 重複なし (consolidate 健全)"
fi
Strip blank lines and headings, count occurrences with sort | uniq -c, take the highest — three or more identical lines is a WARN. For a while I assumed "Consolidate runs asynchronously via Haikus, so it's fine," but if the delegation to Haikus stops for any reason, now.md grows to hundreds of lines, and the next Consolidate wrongly promotes them as "old memory." I added this check after experiencing exactly that once: a summary from two weeks earlier being promoted into today's important memory.
Section [9] — flushing out double registration with comm -12
cron_sh=$(crontab -l 2>/dev/null | grep -vE '^[[:space:]]*#' \
| grep -oE '/[^ ]+\.sh' | xargs -n1 basename 2>/dev/null | sort -u)
launchd_sh=$(grep -hoE '/[^<> ]+\.sh' \
"$HOME"/Library/LaunchAgents/com.shun.*.plist 2>/dev/null \
| xargs -n1 basename 2>/dev/null | sort -u)
dup=$(comm -12 <(printf '%s\n' "$cron_sh") <(printf '%s\n' "$launchd_sh") \
| grep -vE '^[[:space:]]*$')
The comm command takes two sorted lists and returns the lines common to both (what's left after -12 suppresses columns 1 and 2). That gives you the list of script names registered in both cron and launchd.
The comparison uses basename (filename only) rather than absolute paths because cron and launchd sometimes have subtly different paths. ~/scripts/foo.sh and $HOME/scripts/foo.sh are the same file but different strings.
Section [7] — a two-part check on disk and junk files
The disk check measures "total size of ~/.claude" and "the plugin disabled cache" separately:
cdir_total_mb=$(du -sm "$CLAUDE" 2>/dev/null | awk '{print $1}')
disabled_mb=$(du -sm "$CLAUDE/plugins/.disabled-cache" 2>/dev/null | awk '{print $1}')
cdir_mb=$(( ${cdir_total_mb:-0} - ${disabled_mb:-0} ))
.disabled-cache is the reversible cache of plugins temporarily set aside by plugin-auto-disable.sh. It can reach 1.2GB, but since it's "tidied junk" recoverable via apply → restore, it's excluded from the effective size. Without that exclusion, ~/.claude would sit in permanent WARN, and the noise would bury the warnings that matter.
Where I got stuck
① "Obsidian coverage stuck in permanent WARN" — the hidden-directory trap
The Obsidian check in section [5] contains this code:
real=$(find "$VAULT" -name '*.md' -not -path '*/.*' 2>/dev/null | wc -l | tr -d ' ')
stated=$(grep -oE '総ページ数:[0-9]+' "$vidx" 2>/dev/null \
| grep -oE '[0-9]+' | head -1)
if [ -n "$stated" ] && [ "$stated" = "$real" ]; then
ok "index.md カバレッジ一致 (${real}p)"
else
wn "index.md 記載 ${stated:-?}p ≠ 実 ${real}p (index.md の更新が必要)"
fi
Note the -not -path '*/.*' — it wasn't there originally. Obsidian's internal files live in .obsidian/, and my automatic backup output lands in .backup/. Both are hidden directories (leading dot), but find picks them up by default.
index.md says "total pages: 47." But find counts 63, including old snapshots inside .backup/. No matter how correctly you update index.md, stated and real will never match.
The symptom was "a WARN every time, and I don't really know why." It took me four days to see the cause. While debugging the script I ran find "$VAULT" -name '*.md' | head -20, saw a line reading .backup/2026-06-10/some-page.md, and went "ah, of course." One -not -path '*/.*' flag fixed it — but the lesson is that correct check logic with the wrong data scope yields a permanent WARN.
② "Six weeks of double execution after the cron → launchd migration"
This one is left verbatim as a comment in the script:
# 2026-06-01: cron→launchd 移行で「cron を消し忘れて両方に登録=毎サイクル二重実行」
# が発生していたため、同一スクリプトが両系統に存在しないかを常時監視する。
I migrated agents-index.sh from cron to launchd. Wrote the plist, loaded it, verified it worked — seemed perfect. Except I forgot to delete the old cron entry. Every Sunday, at around the same time, agents-index.sh ran twice.
The reason it went unnoticed for six weeks is that the damage was hard to see. agents-index.sh is idempotent — running it twice produces the same result. The index doesn't break, and no error log appears. But every week, extra CPU time and extra log appends were happening.
That experience is why I added the comm -12 check in section [9]. A problem I could have spotted in one minute by running the check right after the migration instead lasted six weeks, because the check didn't exist.
③ "env-audit's log path changed every run, so it couldn't be detected"
The CRON_JOBS array in section [8] has this special case:
if [[ "$path" == *"env-audit-latest.md" ]]; then
real=$(ls -t "$CLAUDE/logs/"env-audit-*.md 2>/dev/null | head -1)
[ -n "$real" ] && path="$real"
fi
env-audit.sh generates a date-stamped file like env-audit-2026-06-10.md on each run. Since it has no fixed path, the array holds a "placeholder path" of env-audit-latest.md, with a special branch that finds the newest file via ls -t.
At first it kept reporting "no such file, so no log output → NG." Looking at the CRON_JOBS definition, sure enough it listed a path that doesn't exist. I nearly concluded "the script has never run," but checking reality with ls ~/.claude/logs/ showed env-audit-2026-06-11.md sitting right there.
A check designed around fixed paths couldn't handle a dynamically named script. You have to unify one way or the other: either make the script's output path fixed, or make the checker search dynamically. I chose the latter, though the ideal design is "every job writes its log to a fixed path." I just couldn't afford the effort to rewrite every existing script, so the special branch is the stopgap.
④ "agentmemory was crash-looping for six days" — discovering the half-alive state
This is the direct cause of making section [5.5] a two-stage check. The comment even records the date:
# 2026-06-11 監査の教訓: launchd の exit 78 クラッシュループが6日間誰にも気づかれず、
# さらに「ポートは開くが worker 不在で全API 404」の半生状態は死活監視では見えない。
# launchd 状態 + /agentmemory/health の HTTP 200 の両方を見る。
The symptom was "Claude's memory isn't being shared." Something learned in conversation A wouldn't surface in conversation B. But Claude itself worked fine. The agentmemory process was visible in ps aux (it existed). curl against port 3111 wasn't "connection refused" — it was "no response."
Running launchctl list | grep agentmemory showed a PID of - and exit code 78. Exit 78 in launchd convention means "configuration file error." Try to start, crash, try to start, crash — repeated for six days.
Liveness monitoring based only on process existence can never catch this. A process visible in ps aux can be "a zombie mid-crash-loop." More precisely, when launchd detects consecutive crashes it enters "throttling" and delays the next restart by tens of seconds. Run your process check inside that delay window and it reports "present."
curl against http://localhost:3111/agentmemory/health tells the truth:
am_code=$(curl -s -o /dev/null -w '%{http_code}' -m 3 \
http://localhost:3111/agentmemory/health 2>/dev/null || echo 000)
if [ "$am_code" = "200" ]; then ok "稼働中 (pid=$am_pid / health 200)"
elif [ "$am_code" = "000" ]; then ng "プロセスは居るが port 3111 無応答"
else ng "port 3111 は開くが /agentmemory/health=$am_code — worker 不在の半生状態"; fi
000 means "the connection itself doesn't establish" (port closed, timeout); 404 or 500 means "the port is open but the worker isn't functioning." Distinguishing those three patterns is what catches the half-alive state of "started but not working."
Since that experience, I've rolled this two-stage check out to every service that serves HTTP. A launchd state check plus a curl against an HTTP health endpoint — only with both together can you say something is truly running.
⑤ "now.md hit 1,200 lines and Consolidate jammed"
Separately from ④, at some point I started feeling that "Claude's suggestions are somehow thin." Concretely: it forgot what we discussed last week, and I had to explain the same thing twice.
I ran wc -l on ~/.remember/now.md and got 1,247 lines. Normal is somewhere around 30–50. Consolidate had stopped, and over 1,000 summaries had piled up in now.md.
The cause was that the task delegated to Haikus was failing. Consolidate runs asynchronously in the background, so nobody notices when it fails. There was an error in the task queue, and new Consolidate work just kept stacking up without a single item being processed.
I cleared it by manually running claude --model haiku -p "~/.remember/now.md を読んでconsolidateしてください", but detection was a week late. That experience is the direct reason the duplicate-burst detection in section [6] exists.
Three or more duplicate lines is a sign that "the same conversation loop got summarized repeatedly." In normal use, the same summary is never written to now.md twice. The threshold of three was chosen by rule of thumb, to separate "coincidental duplication" from "Consolidate jam."
Once you stack up this many checks, you realize how rare a day of "everything green, ALL GREEN" actually is. In my environment, roughly 1–2 WARNs show up every morning. That is normal. Rather than a day with zero WARNs, I consider "there are detectable WARNs every day and I'm able to handle them" the healthier state for an automation environment.
Trusting a system that says nothing is like reading the absence of code as proof that there are no bugs.
Gotchas
Above, the "where I got stuck" section covered five stories. Here I'll enumerate the unglamorous traps that never became stories. I've hit every one of them for real.
One mistake in a plist's XML syntax and
launchctl bootstrapsays nothing but "Error: 125". You learn nothing about the cause. Runplutil -lint ~/Library/LaunchAgents/com.shun.xxx.plistand you get something concrete like "Unexpected character '/' at line 18," with a line number. You need to burn "edit a plist → runplutil -lint" into muscle memory as a pre-save step.Double writing between a redirect inside
ProgramArgumentsandStandardOutPath. Look atcom.shun.plugin-auto-disable.plist: it writes>> ~/.claude/logs/plugin-auto-disable.log 2>&1insideProgramArguments, yetStandardOutPath/StandardErrorPathpoint at the same path. This is intentional — launchd's own startup failures (plist read errors, etc.) happen before the redirect insideProgramArgumentsand are only recorded toStandardErrorPath. Delete one of the two without knowing that, and launchd startup failures go silent.StartCalendarIntervalis skipped while macOS is asleep.com.shun.plugin-auto-disablefires every Sunday at 06:45, but if the Mac is closed at that moment, it doesn't fire. With Power Nap enabled some jobs do start, but there's no guarantee. The only evidence a script fired is its log's mtime — which is exactly why the "was the log updated within 192 hours" check exists in section [8] ofautomation-health.sh. "Registered with launchd = guaranteed to run" is false.Put
set -uin a script launched from launchd and a missing.zshrc-defined variable exits 1. The symptom: works fine when run by hand from a terminal, dies instantly when called by launchd.$EDITORand$NVM_DIRdon't exist in the launchd environment. Two options: enumerate every required variable in the plist'sEnvironmentVariables, or dropset -uin launchd-targeted scripts and defend with the${VAR:-default}pattern.Editing a plist leaves the old settings live inside launchd. The auto re-bootstrap in section [1] of
automation-health.shonly kicks in for "not loaded." A job already loaded with stale settings won't be restarted for you. After changing a plist, you must remove it withlaunchctl bootout gui/$(id -u) <label>and re-submit withlaunchctl bootstrap gui/$(id -u) <plist>. Not knowing this procedure, I burned an hour on "I rewrote the plist but nothing changed."A mismatch between the job label (the
<key>Label</key>string) and the plist filename makes the automatic check misbehave. For example, ifcom.shun.foo.plistcontains<string>com.shun.bar</string>, it registers inlaunchctl listascom.shun.bar.automation-health.shinfers the label withbasename "$plist" .plistand greps for it, so a mismatch produces a false verdict: "not loaded → auto re-bootstrap → error because it's already loaded → RED." Always keep the label and the filename identical.Throttling during a crash loop makes
ps auxresults depend on timing. When launchd detects consecutive crashes, it delays restarts by ThrottleInterval (10 seconds by default). Check for the process during the delay and it's "absent"; check during the next start attempt and it's "present."ps aux | grep agentmemoryalone can't show you the truth. The right answer islaunchctl list | grep com.shun.agentmemory, which shows the PID and exit code together.Reading the
-1returned byage_has true in an arithmetic comparison.age_hreturns-1when the file doesn't exist. The comparison[ "$a" -le 48 ]evaluates-1 -le 48as true, giving the false verdict "no log file = freshness OK." The real code avoids the trap with a two-part comparison,[ "$a" -ge 0 ] && [ "$a" -le 48 ]— "≥ 0 and ≤ X." When you copy this pattern, copy the two-part comparison with it.The
:IFS separator in theCRON_JOBSarray breaks if a log path contains:. SinceIFS=":" read -r name path budget <<< "$entry"splits on:, a log path containing:would split$pathand$budgetin unintended places. macOS paths don't normally contain:, but copy-pasting without thinking will break it.A hook script without the executable bit is skipped by Claude Code in complete silence. Configure a hook in
settings.jsonand it still won't run if it hasn't beenchmod +x'd. No error, no log, nothing. That's why section [2] ofautomation-health.shemits a WARN via[ ! -x "$f" ]. When you add a new hook, alwayschmod +x, then runautomation-health.shand confirm green before trusting it.comm -12assumes sorted input; unsorted input silently misses duplicates. Section [9] usescomm -12for cron/launchd duplicate detection, which presupposes both inputs aresort -u'd. Forgetsort -uin the pipeline producingcron_shorlaunchd_shand duplicates go undetected. The real code puts| sort -uat the end of each variable.
Best practices
After half a year of running 26 launchd jobs, here are 10-plus design guidelines I'm confident would have prevented that six-week silent failure had they been in place from the start.
1. Always match the job label to the plist filename
Keep the label in <key>Label</key><string>com.shun.hoge</string> identical to the plist filename com.shun.hoge.plist. Mismatches multiply debugging cost. Maintain a state where the labels visible in launchctl list | grep com.shun map one-to-one to the listing from ls ~/Library/LaunchAgents/com.shun*.plist.
2. Set PATH explicitly via EnvironmentVariables in every job
launchd reads neither .zshrc nor .bashrc. A bare launchd environment's PATH is only /usr/bin:/bin:/usr/sbin:/sbin. node, brew, claude — all invisible. After updating your node version, don't forget to update the plist's PATH too. I write a # nvm version: v24.13.0 comment in the plist on the line right before ProgramArguments as a reminder for version changes.
3. Drop launchctl load and use launchctl bootstrap gui/$(id -u)
launchctl load has been deprecated since macOS Catalina. It sometimes works, but behavior can change with nothing printed to the log. launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.shun.xxx.plist is the current API. Get the UID with id -u (numeric only) and assemble a target like gui/501.
4. After editing a plist, always do the three steps plutil -lint → bootout → bootstrap
plutil -lint ~/Library/LaunchAgents/com.shun.xxx.plist
launchctl bootout "gui/$(id -u)" com.shun.xxx
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.shun.xxx.plist
Make these three steps a fixed procedure. Changes to plist contents are not automatically reflected in launchd.
5. Route arguments passed to shell scripts through /bin/zsh -c "..." inside ProgramArguments
Shell redirects like >> and 2>&1 don't work when written directly in ProgramArguments, because launchd executes the program without going through a shell. Wrap them in the /bin/zsh -c "command >> log 2>&1" pattern. com.shun.plugin-auto-disable.plist actually uses this pattern.
6. Design check scripts to "fix what they find," not just "report"
Section [1] of automation-health.sh re-submits via launchctl bootstrap the instant it detects an unloaded job, precisely to remove the human chore of "see RED, fix it by hand." If your automation-monitoring script presupposes human intervention, that's an abdication of monitoring responsibility. Auto-repair what can be auto-repaired, and notify a human with RED only for what can't (the re-load itself failing).
7. For HTTP services, insist on a two-stage check: process plus health endpoint
Even if ps aux | grep <process> shows the process exists, that process may be mid-crash-loop or in the "port open but worker absent" half-alive state.
am_code=$(curl -s -o /dev/null -w '%{http_code}' -m 3 \
http://localhost:3111/agentmemory/health 2>/dev/null || echo 000)
This pattern generalizes beyond agentmemory to every service that serves HTTP. Distinguish three levels: 000 = unreachable, 2xx = healthy, anything else = "open but broken."
8. Use exit-1 design so cron/StopHook can pick it up mechanically
automation-health.sh ends with exit 1 if even one item is RED. That's the point, because you can then chain it into follow-on processing:
bash ~/.claude/scripts/automation-health.sh || say "自動化に問題があります"
Wire it into the StopHook and you get an automatic inspection every time you close Claude Code. Put it in cron and you get a check every morning. The core of a design that doesn't depend on "deciding to check" is exit 1.
9. Design jobs to write logs to fixed paths
The real pain in section [8] of automation-health.sh (env-audit-*.md being uncheckable due to dynamic naming) has one root cause: jobs that don't write logs to a fixed path. When writing a new batch job, make redirecting to a fixed path — >> ~/.claude/logs/<jobname>.log 2>&1 inside ProgramArguments — the standard pattern. With writes going to a fixed path, the monitoring side can confirm liveness just by taking the mtime.
10. Set separate time budgets for weekly, monthly, and daily jobs
When judging liveness by whether a log's mtime is within budget_hour, applying the same threshold to every job is wrong. Give a daily batch a 192-hour (8-day) allowance and it can be down for seven days without a WARN. automation-health.sh defines three budgets: daily=48h / weekly=192h / monthly=744h.
11. Right after a cron ↔ launchd migration, run the comm -12 duplicate check immediately
On the very day you migrate from cron to launchd, run automation-health.sh and confirm there's no RED in section [9]. Skip that, and you can end up like me with six weeks of double execution and no idea. Include "automation-health.sh is GREEN" in your definition of done for "migration complete."
12. Eyeball the line count of now.md once a week
Just running wc -l ~/.remember/now.md once a week catches a Consolidate jam early. Healthy is around 30–50 lines. Over 100 warrants attention; over 300 means Consolidate has stopped. Section [6] of automation-health.sh does duplicate-burst detection (the same summary three or more times), but that's "detection after the symptom has progressed." Checking the line count is faster.
13. Don't make ALL GREEN the goal
A state where 1–2 WARNs appear every morning is normal. "WARNs appear = the detection layer is alive," and a day with zero WARNs is, if anything, the unnatural one. The goal is "keep RED at zero," not "keep WARN at zero." Trying to eliminate every WARN tempts you toward loosening thresholds and hiding warnings. Trusting a system that says nothing is the same act as reading the absence of code as proof that there are no bugs.
Wrap-up
I opened by saying half my automation was dead on the day I was laid off. Had automation-health.sh existed then, I would have known it stopped three weeks before the layoff, and those three days spent investigating causes while my income was zero would have evaporated.
What supports ¥1.2M/month isn't any single script — it's the "environment" where 26 of them stay interlocked and running. Sweep that environment's health in one command, auto-repair what breaks, and let cron/StopHook pick it up via exit 1 — the benefit of this design grows with the number of automations you have.
launchd says nothing when it breaks. So all we can do is build something that asks the machine, once every morning.
I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and a 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*
Top comments (0)