# Automating a Daily Morning Health Check for Your Claude Code Setup with launchd

> Source: <https://dev.to/bokuwalily/automating-a-daily-morning-health-check-for-your-claude-code-setup-with-launchd-5f2d>
> Published: 2026-07-25 00:00:14+00:00

In my previous post, [Monitoring Claude Code hook watchdogs with launchd](https://zenn.dev/bokuwalily/articles/inject-bytes-watchdog), 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:

``` bash
#!/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"
```

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
  # 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
}
```

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

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

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

``` php
<!-- ❌ /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's`ProgramArguments`

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`

directly`000`

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 unnecessary`LC_ALL`

unset`LC_ALL=C grep -qF`

guarantees byte mode`cut -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 starts`launchctl 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](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
