{"slug": "automating-a-daily-morning-health-check-for-your-claude-code-setup-with-launchd", "title": "Automating a Daily Morning Health Check for Your Claude Code Setup with launchd", "summary": "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.", "body_md": "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.\n\nThat page is `daily-brief.sh`\n\n. 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.\n\nThe more you automate, the higher the risk that something breaks silently. I used to open all of these manually every morning:\n\n`git status`\n\nfor 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.\n\n```\nlaunchd\n  ├─ StartCalendarInterval: 8:00\n  ├─ StartCalendarInterval: 10:30\n  └─ RunAtLoad: true（ログイン時）\n        ↓\n  ~/.claude/scripts/daily-brief.sh\n        ↓\n  ~/.claude/logs/daily-brief-YYYYMMDD.md        ← 正本ログ\n  ~/.claude/logs/daily-brief-latest.md          ← 最新コピー\n  ~/Desktop/Daily Brief/today-brief-YYYYMMDD.md\n  ~/Documents/claude-obsidian/wiki/briefs/daily/today-brief-YYYYMMDD.md\n```\n\nEven when the second run fires at 10:30, the marker `<!-- daily-brief YYYYMMDD -->`\n\nprevents duplicate appends (details below). The script opens like this:\n\n``` bash\n#!/usr/bin/env bash\n# launchd で毎日 8:00 / 10:30 / ログイン時 実行（再実行してもマーカーで二重追記しない）。\n# 注意: Desktop / ~/Documents(vault) は TCC 保護領域 → plist は /bin/bash 直起動（FDA付与済み）。\n#       /bin/zsh 経由だと FDA 未付与で書き込みに失敗する。\n\nset -uo pipefail\n\nDATE=$(date +%Y%m%d)\nTS=$(date '+%Y-%m-%d %H:%M')\nOUT=\"$HOME/.claude/logs/daily-brief-${DATE}.md\"\n```\n\nVercel cold starts can delay the first request, so a single one-shot curl produces false alarms as `000`\n\n(timeout). Here's the implementation of `probe_url()`\n\n:\n\n```\nprobe_url() {\n  local name=$1 url=$2 code=000 attempt\n  # Vercel等のコールドスタートで初回が遅い→単発だと000/タイムアウトの誤報。\n  # 最大3回リトライ＋timeout延長で潰す。\n  for attempt in 1 2 3; do\n    code=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 15 \"$url\" 2>/dev/null || echo 000)\n    if [ \"$code\" = \"200\" ] || [ \"${code:0:1}\" = \"3\" ]; then break; fi\n    sleep 2\n  done\n  if [ \"$code\" = \"200\" ] || [ \"${code:0:1}\" = \"3\" ]; then\n    echo \"- ✅ **${name}**: ${code} (${url})\"\n  else\n    echo \"- ❌ **${name}**: ${code:-無応答} (${url})\"\n    flag_red \"${name} 本番が ${code:-無応答}\"\n  fi\n}\n```\n\nOnly the combination of `--max-time 15`\n\n, `sleep 2`\n\n, 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.\n\n`probe_autolike()`\n\ngoes 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.\n\n```\nprobe_autolike() {\n  local product=$1 resp\n  resp=$(curl -s --max-time 8 -X POST https://autolike-license-server.vercel.app/api/verify \\\n    -H \"Content-Type: application/json\" \\\n    -d \"{\\\"key\\\":\\\"HEALTHCHECK-DUMMY\\\",\\\"product\\\":\\\"${product}\\\"}\" 2>/dev/null)\n  if echo \"$resp\" | grep -q \"サーバー設定エラー\"; then\n    echo \"- ❌ **autolike/${product}**: サーバー設定エラー（env未設定→Pro課金死）\"\n    flag_red \"autolike/${product} がサーバー設定エラー\"\n  elif echo \"$resp\" | grep -qE \"ライセンスが見つかりません|valid\"; then\n    echo \"- ✅ **autolike/${product}**: 課金フロー稼働（Gumroad到達OK）\"\n  fi\n}\n```\n\nAnything 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.\n\nEvery hook execution appends one line — `{\"ts\":..., \"hook\":..., \"elapsed_ms\":..., \"exit_code\":...}`\n\n— to `hook-latency.jsonl`\n\n, and `hook-latency-report.sh`\n\naggregates it.\n\n```\npython3 - \"$JSONL\" \"$DAYS\" <<'PY'\nstats = collections.defaultdict(list)\n...\nfor hook, vals in stats.items():\n    vals_sorted = sorted(vals)\n    n = len(vals_sorted)\n    p95 = vals_sorted[min(n-1, int(n*0.95))]\n    rows.append((hook, n, sum(vals_sorted)//n, p95, vals_sorted[-1], fail.get(hook, 0)))\nrows.sort(key=lambda r: -r[3])  # p95 降順（遅いものを上に）\n\nflag = \" ⚠\" if p95 > 1500 else \"\"\nprint(f\"{hook:<32} {n:>5} {mean:>6}ms {p95:>6}ms {mx:>6}ms {fl:>5}{flag}\")\nPY\n```\n\nIt 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):\n\n```\n=== hook latency (last 1d, 356 records) ===\nhook                                 n    mean     p95     max  fail\n----------------------------------------------------------------------\nskills-auto-update.sh                8 2030404ms 3215081ms 3215081ms     0 ⚠\nobsidian_context.sh                 71  61835ms  15679ms 3194281ms     0 ⚠\nmessage_display_filter.sh           69  23740ms   9106ms 1469891ms     0 ⚠\ncost_guard.sh                       68  16719ms   6789ms 1019530ms     0 ⚠\nuser_prompt_submit.sh               70   5516ms   6426ms 338132ms     0 ⚠\nmodel_routing_reminder.sh           70    901ms   5703ms   6386ms     0 ⚠\n```\n\n`obsidian_context.sh`\n\nshows 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.\n\n```\nlaunchctl list | grep com.shun | awk '{printf \"- %s (last exit=%s)\\n\", $3, $2}' | head -15\n```\n\nToday's output (excerpt):\n\n```\n- com.shun.daily-brief (last exit=0)\n- com.shun.vault-auto-ingest (last exit=0)\n- com.shun.autopilot (last exit=1)\n- com.shun.self-repair (last exit=0)\n```\n\nYou can immediately see that only `autopilot`\n\nis at exit=1. The other 13 jobs were all exit=0. For details I go to `~/.claude/logs/com.shun.<name>.log`\n\n, but the brief already narrows down *where* things are failing.\n\n```\nrun_to 60 ~/.claude/scripts/cost-summary.sh 7 2>&1 | head -10\n```\n\nToday's actual numbers (last 7 days):\n\n```\n=== cost summary (last 7d) ===\n  sessions: 1323\n  total:    $11012.43\n  avg/sess: $8.324\n\n  per model:\n    claude-fable-5: 56617 messages\n    claude-opus-4-8: 43282 messages\n    claude-sonnet-5: 1882 messages\n    claude-sonnet-4-6: 632 messages\n```\n\n$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.\n\n`run_to`\n\nis a wrapper that keeps a hung child script from stalling the entire brief:\n\n```\nTIMEOUT_BIN=\"/opt/homebrew/bin/timeout\"\nrun_to() { local s=$1; shift; if [ -n \"$TIMEOUT_BIN\" ]; then \"$TIMEOUT_BIN\" --kill-after=15 \"$s\" \"$@\"; else \"$@\"; fi; }\n```\n\nBoth the hook latency report and the cost summary are wrapped in `run_to 60`\n\n.\n\n```\nfor repo in \\\n  ~/dev/shukatsu-tracker \\\n  ~/dev/seo-affiliate-site \\\n  ~/Projects/autolike-license-server \\\n  ...; do\n  [ -d \"$repo/.git\" ] || continue\n  branch=$(git -C \"$repo\" rev-parse --abbrev-ref HEAD 2>/dev/null)\n  uncommitted=$(git -C \"$repo\" status --porcelain 2>/dev/null | wc -l | tr -d ' ')\n  last_commit=$(git -C \"$repo\" log -1 --format='%cr %s' 2>/dev/null \\\n    | cut -c1-70 | /usr/bin/iconv -f UTF-8 -t UTF-8 -c)\n  echo \"- **${name}** [${branch}]: ${uncommitted} 未コミット — _${last_commit}_\"\ndone\n```\n\nThere's a reason for sanitizing with `iconv -f UTF-8 -t UTF-8 -c`\n\n: `cut -c`\n\noperates on bytes, so it can split UTF-8 multibyte characters mid-sequence, after which `grep`\n\ntreats the file as binary and marker detection breaks — a footgun I hit myself. In today's run I could immediately see that `lead-finder`\n\nwas sitting at 75 uncommitted changes with its last commit three weeks ago (it's in an intentional PAUSE state).\n\n`daily-brief.sh`\n\nruns twice, at 8:00 and 10:30, and sometimes `vault-auto-ingest.sh`\n\nhas already created the file first. I needed a design that appends exactly once no matter the execution order.\n\n``` php\nMARK=\"<!-- daily-brief ${DATE} -->\"\n\nfor f in \\\n  \"$HOME/Desktop/Daily Brief/today-brief-${DATE}.md\" \\\n  \"$VAULT/wiki/briefs/daily/today-brief-${DATE}.md\"; do\n  d=$(dirname \"$f\")\n  [ -d \"$d\" ] || mkdir -p \"$d\" 2>/dev/null || { echo \"WARN: $d 作成不可（TCC?）\"; continue; }\n  # ファイルがなければ placeholder を作る\n  if [ ! -f \"$f\" ]; then\n    { echo \"# 今日のブリーフ — $(date +%F)\"\n      echo \"\"\n      echo \"_（Claude生成のブリーフ本文は auto-ingest 完了時にこの上へ差し替わる）_\"\n    } > \"$f\" 2>/dev/null || { echo \"WARN: $f 作成不可（TCC?）\"; continue; }\n  fi\n  # LC_ALL=C: 不正バイトがあっても grep がバイナリ扱いで見落とさないように\n  LC_ALL=C grep -qF -- \"$MARK\" \"$f\" 2>/dev/null && continue\n  { echo \"\"; echo \"---\"; echo \"\"; echo \"$MARK\"; cat \"$OUT\"; } >> \"$f\" 2>/dev/null \\\n    || echo \"WARN: 合流追記失敗: $f\"\ndone\n```\n\n`LC_ALL=C`\n\nforces grep into byte mode, which prevents the case where invalid bytes in the file make `grep`\n\ntreat it as binary, return `--`\n\n, and miss the marker. In today's vault file, `<!-- daily-brief 20260724 -->`\n\nappears exactly once, and I confirmed the 10:30 re-run was skipped.\n\nNote:`vault-auto-ingest.sh`\n\ncontains 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.\n\n`/usr/bin/env bash`\n\nfails\nSince macOS Ventura, `~/Desktop`\n\nand `~/Documents`\n\nare protected by TCC (Transparency, Consent, and Control). When launchd starts your script, **Full Disk Access is not inherited depending on the interpreter path**.\n\n``` php\n<!-- ❌ /usr/bin/env 経由 → FDA未付与 → Desktop/Documentsへの書き込みがサイレントに失敗 -->\n<key>ProgramArguments</key>\n<array>\n  <string>/usr/bin/env</string>\n  <string>bash</string>\n  <string>~/.claude/scripts/daily-brief.sh</string>\n</array>\n\n<!-- ✅ /bin/bash 直起動 → FDA付与済みで動く -->\n<key>ProgramArguments</key>\n<array>\n  <string>/bin/bash</string>\n  <string>~/.claude/scripts/daily-brief.sh</string>\n</array>\n```\n\nGoing through `/usr/bin/env`\n\nmakes the process count as one without FDA, and both `mkdir -p`\n\nand file writes fail silently. No error message, no files created — just empty logs. This trap once cost me a full week of missing briefs.\n\nNote:The script's shebang`#!/usr/bin/env bash`\n\ncan stay as is. Changing only the first element of the plist's`ProgramArguments`\n\nto`/bin/bash`\n\nfixes it.\n\nHere are the key parts extracted from the actual file:\n\n```\n<key>Label</key>\n<string>com.shun.daily-brief</string>\n\n<key>ProgramArguments</key>\n<array>\n  <string>/bin/bash</string>\n  <string>~/.claude/scripts/daily-brief.sh</string>\n</array>\n\n<!-- 8:00 と 10:30 の2回 -->\n<key>StartCalendarInterval</key>\n<array>\n  <dict>\n    <key>Hour</key><integer>8</integer>\n    <key>Minute</key><integer>0</integer>\n  </dict>\n  <dict>\n    <key>Hour</key><integer>10</integer>\n    <key>Minute</key><integer>30</integer>\n  </dict>\n</array>\n\n<!-- ログイン時にも即実行 -->\n<key>RunAtLoad</key>\n<true/>\n\n<!-- バックグラウンドジョブがClaude Codeの応答を遅らせないために -->\n<key>LowPriorityIO</key>\n<true/>\n<key>Nice</key>\n<integer>10</integer>\n<key>ProcessType</key>\n<string>Background</string>\n\n<!-- PATH を明示しないと homebrew/nvm/git が見つからない -->\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>/path/to/.nvm/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>\n</dict>\n```\n\nMaking `StartCalendarInterval`\n\nan 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`\n\ncompensates by also firing at login.\n\nNote:`~`\n\nis not expanded inside a plist. Use absolute paths, or use`$HOME`\n\ninside the script.\n\n`/usr/bin/env`\n\n`/bin/bash`\n\ndirectly`000`\n\nalarms from Vercel cold starts`--max-time 15`\n\n+ `sleep 2`\n\n`|| echo 0`\n\nto `grep -c`\n\n`grep -c`\n\nalready prints \"0\" on zero matches, so `|| echo 0`\n\nis unnecessary`LC_ALL`\n\nunset`LC_ALL=C grep -qF`\n\nguarantees byte mode`cut -c`\n\nsplitting UTF-8 and breaking downstream `grep`\n\n`iconv -f UTF-8 -t UTF-8 -c`\n\n`timeout --kill-after=15 60 <cmd>`\n\n`RunAtLoad`\n\n`--max-time 15`\n\nto prevent false alarms from Vercel cold starts`launchctl list | grep com.shun`\n\n`<!-- daily-brief YYYYMMDD -->`\n\nmarker means no duplicate appends regardless of order or run count`/bin/bash`\n\ndirectly 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.\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/automating-a-daily-morning-health-check-for-your-claude-code-setup-with-launchd", "canonical_source": "https://dev.to/bokuwalily/automating-a-daily-morning-health-check-for-your-claude-code-setup-with-launchd-5f2d", "published_at": "2026-07-25 00:00:14+00:00", "updated_at": "2026-07-25 00:31:26.796442+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Claude Code", "launchd", "Obsidian", "Vercel", "GitHub Scout", "Gumroad", "AETHERIA RPG"], "alternates": {"html": "https://wpnews.pro/news/automating-a-daily-morning-health-check-for-your-claude-code-setup-with-launchd", "markdown": "https://wpnews.pro/news/automating-a-daily-morning-health-check-for-your-claude-code-setup-with-launchd.md", "text": "https://wpnews.pro/news/automating-a-daily-morning-health-check-for-your-claude-code-setup-with-launchd.txt", "jsonld": "https://wpnews.pro/news/automating-a-daily-morning-health-check-for-your-claude-code-setup-with-launchd.jsonld"}}