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

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

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

前作 Claude Codeのhook watchdogをlaunchdで常時監視する でhookの死活監視を仕込んだあと、次の疑問が出ました。「hookが生きていてもプロダクトが落ちていたら意味がない。毎朝5分見るだけで全部わかる1枚を作れないか」。

それが daily-brief.sh です。launchdが1日3回(8:00・10:30・ログイン時)起動し、本番HTTPプローブ・hook latency p95・launchd終了コード・7日間モデル別APIコスト・プロジェクトgit状態を1枚のMarkdownにまとめてObsidianへ追記します。

困りごと:5つの場所を毎朝手動で確認していた

自動化が進むほど「どこかで黙って壊れている」リスクが上がります。以前は毎朝これを手動で開いていました。

  • Vercelダッシュボード(プロダクト死活)
  • launchctlのログ(定期ジョブ失敗)
  • Claude Codeのコスト使用量
  • 各プロジェクトのgit status
  • hook latencyのJSONL

開くだけで3〜5分。気づかず放置した事故が2件あり(2026-06-11: GitHub Scoutが無言で空白化・autolikeライセンスAPIでサーバー設定エラー)、1枚に集約して自動で届けば見落としが物理的に起きなくなります。

全体設計:3トリガー → 1枚MD → Obsidian追記

launchd
  ├─ StartCalendarInterval: 8:00
  ├─ StartCalendarInterval: 10:30
  └─ RunAtLoad: true(ログイン時)
        ↓
  ~/.claude/scripts/daily-brief.sh
        ↓
  ~/.claude/logs/daily-brief-YYYYMMDD.md        ← 正本ログ
  ~/.claude/logs/daily-brief-latest.md          ← 最新コピー
  ~/Desktop/Daily Brief/today-brief-YYYYMMDD.md
  ~/Documents/claude-obsidian/wiki/briefs/daily/today-brief-YYYYMMDD.md

10:30の2回目が走ってもマーカー `` で重複を防ぎます(詳細は後述)。スクリプト冒頭にこう書いてあります。

#!/usr/bin/env bash
# launchd で毎日 8:00 / 10:30 / ログイン時 実行(再実行してもマーカーで二重追記しない)。
# 注意: Desktop / ~/Documents(vault) は TCC 保護領域 → plist は /bin/bash 直起動(FDA付与済み)。
#       /bin/zsh 経由だと FDA 未付与で書き込みに失敗する。

set -uo pipefail

DATE=$(date +%Y%m%d)
TS=$(date '+%Y-%m-%d %H:%M')
OUT="$HOME/.claude/logs/daily-brief-${DATE}.md"

本番HTTPプローブ:3リトライ+15秒タイムアウト

Vercelはコールドスタートで初回リクエストが遅れ、1回限りのcurlだと 000(タイムアウト)の誤報になります。probe_url() の実装はこうなっています。

probe_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
}

--max-time 15sleep 2・3回リトライが揃って初めて誤報なしになりました。今日(2026-07-24 08:10)の実測では就活トラッカー・就活ナビ・卒業プランナー・AETHERIA RPGの4本が全部200 OKでした。

probe_autolike() はGumroadの課金フローまで突き抜けます。ダミーキーで叩いて「ライセンスが見つかりません」が返れば正常(APIは生きている)、「サーバー設定エラー」が返ればenv未設定=Pro課金が死んでいる、と判定します。

probe_autolike() {
  local product=$1 resp
  resp=$(curl -s --max-time 8 -X POST https://autolike-license-server.vercel.app/api/verify \
    -H "Content-Type: application/json" \
    -d "{\"key\":\"HEALTHCHECK-DUMMY\",\"product\":\"${product}\"}" 2>/dev/null)
  if echo "$resp" | grep -q "サーバー設定エラー"; then
    echo "- ❌ **autolike/${product}**: サーバー設定エラー(env未設定→Pro課金死)"
    flag_red "autolike/${product} がサーバー設定エラー"
  elif echo "$resp" | grep -qE "ライセンスが見つかりません|valid"; then
    echo "- ✅ **autolike/${product}**: 課金フロー稼働(Gumroad到達OK)"
  fi
}

RED判定が出たものは冒頭の「🚨 要対応」セクションに集約されます。今日は GitHub Scout の採点失敗とアフィリ自動投稿の監査ログ欠落が2件引っかかり、朝一番で気づけました。

hook latency p95:JSONLからPythonで集計

hookが実行されるたびに hook-latency.jsonl{"ts":..., "hook":..., "elapsed_ms":..., "exit_code":...} を1行追記し、hook-latency-report.sh が集計します。

python3 - "$JSONL" "$DAYS" <<'PY'
stats = collections.defaultdict(list)
...
for hook, vals in stats.items():
    vals_sorted = sorted(vals)
    n = len(vals_sorted)
    p95 = vals_sorted[min(n-1, int(n*0.95))]
    rows.append((hook, n, sum(vals_sorted)//n, p95, vals_sorted[-1], fail.get(hook, 0)))
rows.sort(key=lambda r: -r[3])  # p95 降順(遅いものを上に)

flag = " ⚠" if p95 > 1500 else ""
print(f"{hook:<32} {n:>5} {mean:>6}ms {p95:>6}ms {mx:>6}ms {fl:>5}{flag}")
PY

p95 > 1500ms に⚠を付け、遅い順でソートするので「何が重いか」が一目でわかります。今日(直近24h・356レコード)の実測値がこれです。

=== hook latency (last 1d, 356 records) ===
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 ⚠
user_prompt_submit.sh               70   5516ms   6426ms 338132ms     0 ⚠
model_routing_reminder.sh           70    901ms   5703ms   6386ms     0 ⚠

obsidian_context.sh のp95が15679ms(約16秒)、meanが61835ms(約1分)に見えますが、meanはmax外れ値に引っ張られているのでp95で判断します。カラムがp95降順なので「上にあるもの=重いもの」として一目で読めます。

launchd終了コード:全ジョブを1行で確認

launchctl list | grep com.shun | awk '{printf "- %s (last exit=%s)\n", $3, $2}' | head -15

今日の出力(抜粋)です。

- com.shun.daily-brief (last exit=0)
- com.shun.vault-auto-ingest (last exit=0)
- com.shun.autopilot (last exit=1)
- com.shun.self-repair (last exit=0)

autopilot だけ exit=1 になっているのが即座に見えます。他の13本はすべてexit=0でした。ログの詳細は ~/.claude/logs/com.shun.<name>.log を見ますが、ブリーフ段階で「どこが落ちているか」が絞れます。

7日間モデル別APIコスト

run_to 60 ~/.claude/scripts/cost-summary.sh 7 2>&1 | head -10

今日(直近7日)の実測値です。

=== 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

7日で$11012はMax契約の定額内ですが、モデル別のメッセージ数でどこに偏っているかが可視化されます。fable-5が56617件でopus-4-8の43282件を上回っており、モデルルーティングの調整材料になります。

run_to は子スクリプトのハングがブリーフ全体を止めないためのラッパーです。

TIMEOUT_BIN="/opt/homebrew/bin/timeout"
run_to() { local s=$1; shift; if [ -n "$TIMEOUT_BIN" ]; then "$TIMEOUT_BIN" --kill-after=15 "$s" "$@"; else "$@"; fi; }

hook latencyレポートもコストサマリも run_to 60 で包んでいます。

個人プロジェクトgit状態巡回

for repo in \
  ~/dev/shukatsu-tracker \
  ~/dev/seo-affiliate-site \
  ~/Projects/autolike-license-server \
  ...; do
  [ -d "$repo/.git" ] || continue
  branch=$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null)
  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マルチバイトをバイト境界で切ることがあり、その後の grep がバイナリ扱いしてマーカー検出を壊すfootgunを踏みました。今日の実測では lead-finder が75未コミット・3週間前のコミットで止まっているのが即わかりました(意図的なPAUSE状態です)。

冪等設計:コメントマーカーで二重追記を防ぐ

daily-brief.sh は8:00と10:30の2回走り、かつ vault-auto-ingest.sh が先にファイルを作っていることもあります。どの順序で走っても1回だけ追記する設計が必要でした。

MARK=""

for f in \
  "$HOME/Desktop/Daily Brief/today-brief-${DATE}.md" \
  "$VAULT/wiki/briefs/daily/today-brief-${DATE}.md"; do
  d=$(dirname "$f")
  [ -d "$d" ] || mkdir -p "$d" 2>/dev/null || { echo "WARN: $d 作成不可(TCC?)"; continue; }
  # ファイルがなければ placeholder を作る
  if [ ! -f "$f" ]; then
    { echo "# 今日のブリーフ — $(date +%F)"
      echo ""
      echo "_(Claude生成のブリーフ本文は auto-ingest 完了時にこの上へ差し替わる)_"
    } > "$f" 2>/dev/null || { echo "WARN: $f 作成不可(TCC?)"; continue; }
  fi
  # 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

LC_ALL=C でバイトモードのgrepにしているのは、ファイルに不正バイトが混ざったときに grep がバイナリとして扱い -- を返して見落とすケースを防ぐためです。今日のvaultファイルには `` が1箇所だけ入っており、10:30の再実行ではスキップされていることが確認できました。

vault-auto-ingest.sh にも同じ合流ロジックが入っています。どちらが先に走ってもマーカーが一致すれば追記をスキップするため、二重になりません。コメントマーカーは処理の「領収書」として機能します。

TCC罠:/usr/bin/env bash では失敗する

macOS Ventura以降、~/Desktop~/Documents はTCC(Transparency, Consent, and Control)で保護されています。launchdからスクリプトを起動するとき、インタープリタのパスによってFull Disk Accessが引き継がれません


<key>ProgramArguments</key>
<array>
  <string>/usr/bin/env</string>
  <string>bash</string>
  <string>~/.claude/scripts/daily-brief.sh</string>
</array>

<key>ProgramArguments</key>
<array>
  <string>/bin/bash</string>
  <string>~/.claude/scripts/daily-brief.sh</string>
</array>

/usr/bin/env 経由にするとFDAが付与されていないプロセスとして扱われ、mkdir -p も書き込みもサイレントに失敗します。エラーメッセージは出ず、ファイルも作られず、ログだけが空になります。この罠で1週間ブリーフが届かなかった経験があります。

スクリプトのshebang #!/usr/bin/env bash はそのままで構いません。plistの ProgramArguments の第1要素だけを /bin/bash に変えれば直ります。

plist設計ポイント

実ファイルから要点を抜粋します。

<key>Label</key>
<string>com.shun.daily-brief</string>

<key>ProgramArguments</key>
<array>
  <string>/bin/bash</string>
  <string>~/.claude/scripts/daily-brief.sh</string>
</array>

<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>

<key>RunAtLoad</key>
<true/>

<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/path/to/.nvm/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>

StartCalendarInterval を配列にすると複数時刻を1つのplistで表現できます。macがスリープ中にスケジュール時刻を過ぎた場合、次の起動時にジョブは実行されません(launchdの仕様)。RunAtLoad: true がログイン時の起動を兼ねて補完しています。

plist内のパスは ~ が展開されません。絶対パスか、スクリプト内で $HOME を使います。

踏んだ落とし穴

  • /usr/bin/env 経由でFDA未付与 → plistの1要素目を /bin/bash 直起動に変更
  • Vercelコールドスタートで000誤報 → 3リトライ + --max-time 15 + sleep 2 で解消
  • grep -c|| echo 0 を付けて二重出力grep -c は0件でも「0」を返すので || echo 0 は不要
  • LC_ALL 未設定でgrep がバイナリ扱いしてマーカー見落としLC_ALL=C grep -qF でバイトモード確定
  • cut -c でUTF-8が割れ後続の grep が壊れるiconv -f UTF-8 -t UTF-8 -c でサニタイズ
  • 子スクリプトのハングがブリーフ全体を止めるtimeout --kill-after=15 60 <cmd> でラップ
  • macスリープ中にスケジュール時刻を過ぎるとジョブがスキップRunAtLoad で補完

まとめ

  • 本番HTTPプローブは3リトライ+--max-time 15でVercelコールドスタートの誤報を防ぐ
  • hook latency p95はJSONLをPythonで集計し、1500ms超に⚠をフラグして遅い順でソート
  • launchd終了コードlaunchctl list | grep com.shunの1行で全ジョブを横断確認
  • 7日間コストはモデル別メッセージ数まで出してルーティング調整の材料にする
  • 冪等設計は``マーカーでどの順序・何回走っても二重追記しない
  • TCC制限はplistを/bin/bash直起動にすることで回避する

次回は、このブリーフに「昨日のClaude Code作業サマリ」を自動追記するassistant hookの設計を書きます。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

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