In my previous post, Monitoring Claude Code hook watchdogs with launchd, I set up liveness monitoring for hooks — and immediately ran into the next question: a healthy hook means nothing if the product behind it is down. What I really wanted was a single page I could skim in five minutes every morning and know that everything is fine.
That page is daily-brief.sh
. launchd runs it three times a day (8:00, 10:30, and at login), and it compiles production HTTP probes, hook latency p95, launchd exit codes, 7-day API costs broken down by model, and per-project git status into one Markdown file appended to Obsidian.
The more you automate, the higher the risk that something breaks silently. I used to open all of these manually every morning:
git status
for each projectJust opening them took 3–5 minutes. Two incidents slipped through unnoticed (2026-06-11: GitHub Scout silently going blank, and a server configuration error in the autolike license API). Consolidating everything into one automatically delivered page makes missing things physically impossible.
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
Even when the second run fires at 10:30, the marker <!-- daily-brief YYYYMMDD -->
prevents duplicate appends (details below). The script opens like this:
#!/usr/bin/env bash
set -uo pipefail
DATE=$(date +%Y%m%d)
TS=$(date '+%Y-%m-%d %H:%M')
OUT="$HOME/.claude/logs/daily-brief-${DATE}.md"
Vercel cold starts can delay the first request, so a single one-shot curl produces false alarms as 000
(timeout). Here's the implementation of probe_url()
:
probe_url() {
local name=$1 url=$2 code=000 attempt
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
}
Only the combination of --max-time 15
, sleep 2
, and 3 retries finally eliminated the false alarms. In today's run (2026-07-24 08:10), all four production apps — the job-hunting tracker, the job-hunting navigator, the graduation planner, and AETHERIA RPG — came back 200 OK.
probe_autolike()
goes all the way through the Gumroad billing flow. It hits the API with a dummy key: if the response is "license not found," things are healthy (the API is alive); if it's "server configuration error," the env vars are missing, which means Pro billing is dead.
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
}
Anything flagged RED gets collected into a "🚨 Needs attention" section at the top of the brief. Today it caught two items — a GitHub Scout scoring failure and a missing audit log in the affiliate auto-poster — and I noticed both first thing in the morning.
Every hook execution appends one line — {"ts":..., "hook":..., "elapsed_ms":..., "exit_code":...}
— to hook-latency.jsonl
, and hook-latency-report.sh
aggregates it.
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
It flags anything with p95 > 1500ms with a ⚠ and sorts slowest-first, so you can tell at a glance what's heavy. Here are today's actual numbers (last 24h, 356 records):
=== 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
shows a p95 of 15679ms (~16 seconds) and a mean of 61835ms (~1 minute), but the mean is dragged up by max outliers, so p95 is what I judge by. Since the columns are sorted by p95 descending, "higher up = heavier" reads at a glance.
launchctl list | grep com.shun | awk '{printf "- %s (last exit=%s)\n", $3, $2}' | head -15
Today's output (excerpt):
- 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)
You can immediately see that only autopilot
is at exit=1. The other 13 jobs were all exit=0. For details I go to ~/.claude/logs/com.shun.<name>.log
, but the brief already narrows down where things are failing.
run_to 60 ~/.claude/scripts/cost-summary.sh 7 2>&1 | head -10
Today's actual numbers (last 7 days):
=== 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
$11,012 over 7 days is within my Max plan's flat rate, but the per-model message counts show where usage is skewed. fable-5's 56,617 messages exceed opus-4-8's 43,282, which is useful input for tuning model routing.
run_to
is a wrapper that keeps a hung child script from stalling the entire brief:
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; }
Both the hook latency report and the cost summary are wrapped in run_to 60
.
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
There's a reason for sanitizing with iconv -f UTF-8 -t UTF-8 -c
: cut -c
operates on bytes, so it can split UTF-8 multibyte characters mid-sequence, after which grep
treats the file as binary and marker detection breaks — a footgun I hit myself. In today's run I could immediately see that lead-finder
was sitting at 75 uncommitted changes with its last commit three weeks ago (it's in an intentional state).
daily-brief.sh
runs twice, at 8:00 and 10:30, and sometimes vault-auto-ingest.sh
has already created the file first. I needed a design that appends exactly once no matter the execution order.
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; }
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 -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
forces grep into byte mode, which prevents the case where invalid bytes in the file make grep
treat it as binary, return --
, and miss the marker. In today's vault file, <!-- daily-brief 20260724 -->
appears exactly once, and I confirmed the 10:30 re-run was skipped.
Note:vault-auto-ingest.sh
contains the same merge logic. Whichever runs first, a matching marker means the append is skipped, so nothing gets duplicated. The comment marker works as a "receipt" for the operation.
/usr/bin/env bash
fails
Since macOS Ventura, ~/Desktop
and ~/Documents
are protected by TCC (Transparency, Consent, and Control). When launchd starts your script, Full Disk Access is not inherited depending on the interpreter path.
<!-- ❌ /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>
Going through /usr/bin/env
makes the process count as one without FDA, and both mkdir -p
and file writes fail silently. No error message, no files created — just empty logs. This trap once cost me a full week of missing briefs.
Note:The script's shebang#!/usr/bin/env bash
can stay as is. Changing only the first element of the plist'sProgramArguments
to/bin/bash
fixes it.
Here are the key parts extracted from the actual file:
<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>
Making StartCalendarInterval
an array lets you express multiple times in a single plist. If the Mac is asleep when a scheduled time passes, the job does not run at the next wake (that's just how launchd works). RunAtLoad: true
compensates by also firing at login.
Note:~
is not expanded inside a plist. Use absolute paths, or use$HOME
inside the script.
/usr/bin/env
/bin/bash
directly000
alarms from Vercel cold starts--max-time 15
sleep 2
|| echo 0
to grep -c
grep -c
already prints "0" on zero matches, so || echo 0
is unnecessaryLC_ALL
unsetLC_ALL=C grep -qF
guarantees byte modecut -c
splitting UTF-8 and breaking downstream grep
iconv -f UTF-8 -t UTF-8 -c
timeout --kill-after=15 60 <cmd>
RunAtLoad
--max-time 15
to prevent false alarms from Vercel cold startslaunchctl list | grep com.shun
<!-- daily-brief YYYYMMDD -->
marker means no duplicate appends regardless of order or run count/bin/bash
directly in the plistNext time, I'll cover the design of an assistant hook that automatically appends "yesterday's Claude Code work summary" to this brief.
*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*