cd /news/developer-tools/automating-a-daily-morning-health-ch… · home topics developer-tools article
[ARTICLE · art-72838] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer automated their daily morning health check for a Claude Code setup using launchd, consolidating production HTTP probes, hook latency, launchd exit codes, API costs, and git status into a single Markdown file appended to Obsidian. The script runs three times a day and includes retry logic to eliminate false alarms from Vercel cold starts.

read9 min views1 publishedJul 25, 2026

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*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/automating-a-daily-m…] indexed:0 read:9min 2026-07-25 ·