Claude Code環境の「毎朝の健康診断」をlaunchdで全自動化する
前回、インジェクトバイトのwatchdogを書いた話を書きました。今回はその健康診断バージョンです。本番4サービスへのHTTP疎通・hookレイテンシp95(最悪3,215,081ms)・7日間$11,012のAPIコスト・~/.claude 5,088MBのディスク使用量を、launchdが1日3回叩く1本のシェルスクリプトで1枚のMarkdownに集約しています。
この記事は daily-brief.sh の実コードを軸に、①本番プローブとログ監査の設計、②同じマーカーで二重追記を防ぐ冪等性、③macOS TCCの壁を/bin/bash直起動で越える罠、の3点を書きます。
困りごと:ブリーフを見るまで壊れているのに気づかない
自動化基盤を増やすほど「動いているはず」が増えます。GitHub Scoutが採点に失敗していても、アフィリ自動投稿の監査が走ってなくても、見に行くまで気づけません。しかも見に行く先がバラバラ(ログファイル、launchctl、cost dashboard、各リポジトリのgit status)だと、そもそも見に行く気力が続きません。
なので「1枚のMarkdownに全部集める」「それを1日3回勝手に作る」を先にやりました。
全体像:launchdが1日3回、1本のスクリプトを叩く
plistのスケジュールはこうです。
<key>ProgramArguments</key>
<array>
<string>~/.claude/scripts/claude-quota-guard.py</string>
<string>--job</string>
<string>com.shun.daily-brief</string>
<string>--</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/daily-brief.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>30</integer></dict>
</array>
StartCalendarInterval で8:00と10:30、RunAtLoad でログイン時(Macを開いた瞬間)にも走ります。実行本体は claude-quota-guard.py が薄くラップしていて、その先で /bin/bash daily-brief.sh を直接呼んでいます。この「なぜ/bin/bash直起動なのか」は後述の落とし穴で説明します。
設計1:本番HTTPプローブ ― 「主張」と「実態」を突き合わせる
ブリーフの目的の半分は、Vaultやメモリの「直したはず」ステータスと本番の実態を照合することです。
probe_url() { # name url
local name=$1 url=$2 code=000 attempt
# Vercel等のコールドスタートで初回が遅い→単発だと000/タイムアウトの誤報。最大3回リトライ+timeout延長で潰す。
for attempt in 1 2 3; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "$url" 2>/dev/null || echo 000)
if [ "$code" = "200" ] || [ "${code:0:1}" = "3" ]; then break; fi
sleep 2
done
if [ "$code" = "200" ] || [ "${code:0:1}" = "3" ]; then
echo "- ✅ **${name}**: ${code} (${url})"
else
echo "- ❌ **${name}**: ${code:-無応答} (${url})"
flag_red "${name} 本番が ${code:-無応答}"
fi
}
最大3回リトライ・1回15秒timeout・間に2秒sleepという地味な数字ですが、これはコールドスタートで単発probeが000を返して誤報を出した実績を踏まえた値です。同じ設計思想で、GitHub Scoutの死活は「ファイルの有無」「当日更新か」「候補件数」「SCOUT_DEGRADEDマーカー」の4段階でチェックしています。
elif [ "$mdate" != "$DATE" ]; then
echo "- ❌ **GitHub Scout**: 出力が当日更新でない(${mdate:-不明})。今朝の巡回が走ってない疑い"
flag_red "GitHub Scout の出力が当日(${DATE})更新でない→巡回が落ちてる疑い"
実際にこのRED判定が刺さったブリーフがあります(2026-07-24分)。
## 🚨 要対応(ライブ疎通でRED)
- ❌ GitHub Scout が degraded(claude -p 採点失敗)→ /scout-github 再実行
- ❌ アフィリ自動投稿の監査ログが本日(2026-07-24)分なし→launchd未実行の疑い
「壊れている」がタイトルに出るので、Obsidianを開いた瞬間に気づけます。
設計2:hook latency p95 ― 平均だけでは見えない詰まり
hook-latency-report.sh は hook-latency.jsonl を集計し、hookごとにn・mean・p95・max・fail数を出します。
vals_sorted = sorted(vals)
n = len(vals_sorted)
p95 = vals_sorted[min(n-1, int(n*0.95))]
...
flag = " ⚠" if p95 > 1500 else ""
同じ日のブリーフに出た実測です。
hook n mean p95 max fail
----------------------------------------------------------------------
skills-auto-update.sh 8 2030404ms 3215081ms 3215081ms 0 ⚠
obsidian_context.sh 71 61835ms 15679ms 3194281ms 0 ⚠
message_display_filter.sh 69 23740ms 9106ms 1469891ms 0 ⚠
cost_guard.sh 68 16719ms 6789ms 1019530ms 0 ⚠
skills-auto-update.sh はn=8しか回っていないのにp95が3,215,081ms(約53分)。平均だけ見ていたら気づけない「たまに詰まって長時間ブロックしている」パターンで、しきい値1500msを1発でも超えたら⚠が付く単純な設計がそのまま効いています。
設計3:7日間コストとlaunchd稼働状況を並べる
cost-summary.sh 7の出力とlaunchctlのexit codeを同じブリーフに並べます。
=== cost summary (last 7d) ===
sessions: 1323
total: $11012.43
avg/sess: $8.324
per model:
claude-fable-5: 56617 messages
claude-opus-4-8: 43282 messages
claude-sonnet-5: 1882 messages
claude-sonnet-4-6: 632 messages
echo "## 🔧 launchd 稼働状況"
launchctl list | grep com.shun | awk '{printf "- %s (last exit=%s)\n", $3, $2}' | head -15
このブリーフでは com.shun.autopilot (last exit=1) が混ざっていて、コストと稼働状況を並べたことで「autopilotが失敗しているのにコストは食っている」を1画面で判断できます。
同じ発想で、個人プロジェクトのgit statusもループで巡回します。
for repo in ~/Documents/projects/closet-os ~/dev/shukatsu-tracker ... ~/lead-finder; do
[ -d "$repo/.git" ] || continue
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 | cut -c1-70 | /usr/bin/iconv -f UTF-8 -t UTF-8 -c)
echo "- **${name}** [${branch}]: ${uncommitted} 未コミット — _${last_commit}_"
done
iconv -f UTF-8 -t UTF-8 -c を挟んでいるのは、cut -cがバイト単位でUTF-8を切って不正バイトを作り、それが後続処理を壊すのを防ぐためです。実測ではこのループで lead-finder [main]: 75 未コミット が拾われていて、放置プロジェクトの発見にそのまま使えています。
冪等設計:どちらが先に走っても二重追記しない
ブリーフはdaily-brief.sh本体に加えて、vault-auto-ingest.shという別スクリプトからもVaultへ合流されます。実行順が保証できない2経路が同じファイルに書き込むので、コメントマーカーで重複を止める設計にしています。
# 出力: ~/.claude/logs/daily-brief-YYYYMMDD.md と daily-brief-latest.md
# さらに ~/Desktop/Daily Brief/today-brief-YYYYMMDD.md と vault正本アーカイブへ合流追記。
# (vault-auto-ingest.sh 側にも同じ合流ロジックがあり、マーカー で
# 重複追記を防ぐ。どちらが先に走っても最終的に1つのブリーフに揃う。)
MARK=""
for f in "$DESKD/today-brief-${DATE}.md" "$VAULT/wiki/briefs/daily/today-brief-${DATE}.md"; do
...
# LC_ALL=C: ファイルに不正バイトが混ざっても grep がバイナリ扱いで見落とさないように
LC_ALL=C grep -qF -- "$MARK" "$f" 2>/dev/null && continue
{ echo ""; echo "---"; echo ""; echo "$MARK"; cat "$OUT"; } >> "$f" 2>/dev/null \
|| echo "WARN: 合流追記失敗: $f"
done
実際のVaultファイルを見ると、この設計どおりに動いています。
# 今日のブリーフ — 2026-07-24
_(Claude生成のブリーフ本文は auto-ingest 完了時にこの上へ差し替わる)_
---
# Daily Brief — 2026-07-24 08:10
...
先にingest側がplaceholderで骨組みを作り、daily-brief.shが来た時点でgrep -qFがマーカー不在を確認して本文を差し込む。逆順でdaily-brief.shが先でも、次にingestが来た時点で同じマーカーが既に存在するので追記をスキップします。判定を「実行順」ではなく「マーカーの有無」だけに寄せたのが、この設計が両方向で壊れない理由です。
冪等性を「片方を先に走らせる」「排他ロックを取る」で作ると、実行順が入れ替わった瞬間に壊れます。ここでは判定条件を「そのマーカー文字列がファイルに存在するか」という状態の有無だけにしたので、どちらが先でも、何度再実行されても結果は同じです。
踏んだ落とし穴:TCCがzsh経由の書き込みを黙って握りつぶす
このスクリプトの冒頭にはこう書いてあります。
# 注意: Desktop / ~/Documents(vault) は TCC 保護領域 → plist は /bin/bash 直起動(FDA付与済み)。
# /bin/zsh 経由だと FDA 未付与で書き込みに失敗する。
macOSのTCC(Transparency, Consent, and Control)はDesktopや Documents 配下への書き込みをプロセス単位で許可制にしています。Full Disk Access(FDA)はバイナリのパス単位で付与されるので、「/bin/bashにFDAを与えた」からといって「/bin/zsh経由で呼ばれたbashスクリプト」に権限が引き継がれるわけではありません。launchdのplistで/bin/zsh -c "bash script.sh"のような間接呼び出しにしてしまうと、TCCダイアログも出ないままmkdirやcpが黙って失敗し、ブリーフだけがDesktop/Vaultに反映されない事故になります。
- TCC保護領域への書き込みはFDA付与済みバイナリから直接起動 → plistの
ProgramArgumentsは/bin/bashを最初の実行ファイルにする grep -cは0件でもexit 1を返す →|| echo 0を付けると0が二重出力されて整数比較が壊れる。単体で使う- 単発HTTPプローブはVercelのコールドスタートで誤報を出す → 3回リトライ+2秒sleepで吸収
cut -cはバイト単位でUTF-8を切って不正バイトを作る →iconv -cで除去してから後続のgrep/表示に渡す- 子スクリプトのハングがブリーフ全体を巻き込む → 全レポータ呼び出しを
timeout --kill-after=15で包む
まとめ
- launchdは8:00/10:30/RunAtLoadの1日3回、
/bin/bash直起動でdaily-brief.shを叩く - 本番プローブはリトライ+タイムアウト、ログ監査はファイル有無・当日更新・マーカーの多段判定で「主張」と「実態」を突き合わせる
- hook latencyは平均でなくp95で見る。n=8でもp95が53分の詰まりを検知できた
- 2経路が同じファイルに書き込む冪等性は、実行順ではなくマーカー文字列の有無で判定する
- TCC保護領域への書き込みはFDA付与済みバイナリを直接
ProgramArgumentsに置く。間接呼び出しは黙って失敗する
次回は、この健康診断を支えるコスト集計とquota-guardの中身を書く予定です。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!