前作 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 15・sleep 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サービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!