0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Posted at

前作 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回目が走ってもマーカー <!-- daily-brief YYYYMMDD --> で重複を防ぎます(詳細は後述)。スクリプト冒頭にこう書いてあります。

#!/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="<!-- daily-brief ${DATE} -->"

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ファイルには <!-- daily-brief 20260724 --> が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が引き継がれません

<!-- ❌ /usr/bin/env 経由 → FDA未付与 → Desktop/Documentsへの書き込みがサイレントに失敗 -->
<key>ProgramArguments</key>
<array>
  <string>/usr/bin/env</string>
  <string>bash</string>
  <string>~/.claude/scripts/daily-brief.sh</string>
</array>

<!-- ✅ /bin/bash 直起動 → FDA付与済みで動く -->
<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>

<!-- 8:00 と 10:30 の2回 -->
<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/>

<!-- バックグラウンドジョブがClaude Codeの応答を遅らせないために -->
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>

<!-- PATH を明示しないと homebrew/nvm/git が見つからない -->
<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日間コストはモデル別メッセージ数まで出してルーティング調整の材料にする
  • 冪等設計<!-- daily-brief YYYYMMDD -->マーカーでどの順序・何回走っても二重追記しない
  • TCC制限はplistを/bin/bash直起動にすることで回避する

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


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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?