📋 Claude Code環境の「毎朝の健康診断」をlaunchdで全自動化する — リーダー×
📋

Claude Code環境の「毎朝の健康診断」をlaunchdで全自動化する

#claudecode#automation#launchd#obsidian2026-07-31 · 約9

前回、インジェクトバイトの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.shhook-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ダイアログも出ないままmkdircpが黙って失敗し、ブリーフだけが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サービスを量産しています

皆さんの ❤️ やシェアが励みになります!