If you ship enough side projects, something in your automation is always quietly on fire — and you usually find out days too late. This is the next installment in my "automation foundation for mass-producing side projects" series, following "How I split Claude Code's memory into four layers" and "Running Claude Code and Codex together on one machine." This time I'll walk through the design and implementation of an unattended watchdog that lets an AI detect, repair, and verify broken automation scripts, and pushes changes to production without approval only when verification passes. Using real code as the anchor, I'll explain how to stack guardrails so the AI runs safely instead of "rampaging without limits." Why "fix itself when it breaks" became necessary When you mass-produce side projects, the number of always-on automation scripts keeps growing. Auto-posting affiliate articles, scrapers, delivering briefs to Discord, checking the review status of iOS apps. Each runs under launchd or cron, so when one breaks, days can pass without anyone noticing. Monitoring and fixing everything by hand hits its limit quickly. But just "letting the AI fix it" carries risks: unintended code rewrites, committing secrets, or reporting a repair as done when it's actually still broken and pushing that to production. So I designed self-repair.sh. The idea is simple. Detect → Claude identifies the cause and fixes it → the watchdog independently runs verify → commit/push/deploy only when it passes → roll back the edits and notify a human if it fails Enter fullscreen mode Exit fullscreen mode Don't trust Claude's self-report is the key point. Even if Claude says "I fixed it," the watchdog runs the verification script itself, and reflects the change only when it exits 0. Overall structure of the watchdog The files are laid out like this. ~/.claude/self-repair/ ├── registry.tsv # list of monitored projects ├── checks/ │ ├── -health.sh # health check (exit 0=healthy, non-0=broken) │ └── -verify.sh # post-repair verification (exit 0=pass, non-0=fail) ├── state/ │ └── -YYYYMMDD.count # today's attempt count └── (the main script lives at ~/.claude/scripts/self-repair.sh) ~/.claude/logs/ └── self-repair.log Enter fullscreen mode Exit fullscreen mode The main loop simply reads registry.tsv and calls process() for each project. while IFS=$'\t' read -r slug dir mode _rest; do case "$slug" in ''|\#*) continue ;; esac [ -n "$ONLY" ] && [ "$slug" != "$ONLY" ] && continue dir="${dir/#\~/$HOME}" process "$slug" "$dir" "${mode:-full}" done /tmp/sr-$slug-verify.log 2>&1 || vrc=$? if [ "$vrc" -eq 0 ]; then # fixed → secret scan → commit → push else # ❌ verification failed → roll back the edits restore "$dir" "$baseline" fi Enter fullscreen mode Exit fullscreen mode run_capped is a wrapper that runs with a timeout if gtimeout is available. It prevents verification from running forever. run_capped() { # run_capped local t="$1"; shift if [ -n "$TIMEOUT_BIN" ]; then "$TIMEOUT_BIN" "$t" "$@"; else "$@"; fi } Enter fullscreen mode Exit fullscreen mode Guard 2: Immediate rollback on failure If verify is non-0, restore() returns to the baseline. restore() { # restore local dir="$1" base="$2" (cd "$dir" && { git reset -q --hard "${base:-HEAD}" 2>/dev/null || true git clean -fdq 2>/dev/null || true git stash list 2>/dev/null | grep -q . && git stash drop -q 2>/dev/null || true }) || true } Enter fullscreen mode Exit fullscreen mode git reset --hard returns to the pre-commit state, git clean -fdq removes files Claude newly added, and anything stashed away is dropped too. This assumes a git repo, so non-git projects need to be in detect mode (the script also checks with git rev-parse and skips auto-repair if it's not a git repo). Guard 3: Secret scanning Before pushing, it scans the staged diff with regular expressions. secret_in_staged() { # secret_in_staged local repo="$1" if (cd "$repo" && git ls-files --cached | grep -qE '(^|/)\.env$'); then echo ".env がコミット対象"; return 0 fi local hits hits=$(cd "$repo" && git diff --cached | grep -nE \ 'pk_[A-Za-z0-9]{6,}|sk_live_[A-Za-z0-9]|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20}|xox[baprs]-[A-Za-z0-9]|-----BEGIN [A-Z ]*PRIVATE KEY-----|AIza[0-9A-Za-z_-]{20}' \ 2>/dev/null | head -3) if [ -n "$hits" ]; then echo "$hits"; return 0; fi return 1 } Enter fullscreen mode Exit fullscreen mode If .env is under tracking, it's an immediate fail. Beyond that it detects Stripe's pk_/sk_live_, AWS's AKIA, GitHub Personal Access Tokens (ghp_), Slack tokens (xox), private key headers, and Google API keys (AIza). This secret scan is built into the script itself because Claude's hooks don't fire when running under launchd. The point is to scan yourself instead of relying on hooks. What actually scans before commit is secret_in_staged_after_add(). It runs git add -A, then scans for secrets, and if clean, commits as-is. secret_in_staged_after_add() { local dir="$1" reason (cd "$dir" && git add -A) || true if reason=$(secret_in_staged "$dir"); then echo "$reason"; return 0; fi (cd "$dir" && git commit -q -m "fix(self-repair): $(date +%F) 自己修復による自動修正" 2>>"$LOG") || true return 1 } Enter fullscreen mode Exit fullscreen mode Guard 4: Attempt cap The same slug can be attempted up to MAX_ATTEMPTS (default 2) times per day. The state file is used as a counter. MAX_ATTEMPTS="${SELF_REPAIR_MAX_ATTEMPTS:-2}" attempts_today() { cat "$STATE/$1-$TODAY.count" 2>/dev/null || echo 0; } bump_attempts() { local n; n=$(attempts_today "$1"); n=$((n+1)) echo "$n" > "$STATE/$1-$TODAY.count"; echo "$n" } Enter fullscreen mode Exit fullscreen mode When the cap is reached, it skips repair and notifies via Discord that "a human needs to check this." This prevents a repair loop from spinning forever and melting tokens. Guard 5: Cost cap If the 5-hour block's output tokens exceed the threshold, it skips the entire run cycle. BUDGET_CAP_TOK="${SELF_REPAIR_BUDGET_CAP:-380000}" budget_ok() { local tok tok=$("$BUDGET_ADVISOR" 2>/dev/null | python3 -c 'import sys,json try: print(int(json.load(sys.stdin).get("5h_output_tokens",0))) except Exception: print(0)' 2>/dev/null) tok="${tok:-0}" if [ "$tok" -gt "$BUDGET_CAP_TOK" ]; then log "SKIP(budget): 5h_output_tokens=$tok > cap=$BUDGET_CAP_TOK" return 1 fi return 0 } Enter fullscreen mode Exit fullscreen mode BUDGET_ADVISOR is a separate script that returns Claude Code's usage in JSON format. It's the last line of defense against a billing runaway. Guard 6: Scope restriction (build a wall with allowedTools) Claude is restricted with --allowedTools on which tools it can touch, and made to edit only within the project directory. out=$(cd "$dir" && printf '%s' "$PROMPT" | run_capped "$REPAIR_TIMEOUT" "$CLAUDE" -p \ --model "$MODEL" --output-format text \ --allowedTools "Read,Write,Edit,Bash,Grep,Glob" \ --max-turns 40 2>&1) || rc=$? Enter fullscreen mode Exit fullscreen mode I don't use --skip-permissions. The wall is built by narrowing the tools with --allowedTools and making the scope explicit in the prompt. Guard 7: Push destination owner verification It won't push unless the destination GitHub remote is your own account. This prevents unintentionally writing to a third party's fork. ALLOWED_OWNERS="bokuwalily" push_if_safe() { local repo="$1" url owner url=$(cd "$repo" && git remote get-url origin 2>/dev/null || true) [ -z "$url" ] && { log " push skip: origin未設定(ローカルcommitのみ)"; return 0; } owner=$(printf '%s' "$url" | sed -E 's#.*github.com[:/]+([^/]+)/.*#\1#') case " $ALLOWED_OWNERS " in *" $owner "*) ;; *) log " push skip: owner=$owner は許可外(第三者repo保護)"; return 0 ;; esac if (cd "$repo" && git push -q origin HEAD 2>>"$LOG"); then log " pushed → $owner" else log " push失敗(commitは保持)" fi } Enter fullscreen mode Exit fullscreen mode How to craft the repair request to the AI The prompt to Claude is assembled dynamically inside process(). The key is to pass health.sh's output and recent logs as context, and explicitly state what must not be done as constraints. local PROMPT="あなたは自律修復エージェントです。自動化『${slug}』(ディレクトリ: ${dir})が壊れています。原因を特定し、**最小の変更**で直してください。 # 制約(厳守) - 触ってよいのは $dir 配下のファイルのみ。スコープを広げない。新機能を足さない。 - .env や秘密情報を読み出して出力・コミットしない。 - 直したら必ず自分で検証コマンド \`bash $verify\` を実行し、exit 0 になることを確認してから終了する。 - 直せない/原因が $dir 外にあるなら、推測で書き換えず『UNFIXABLE: 』とだけ述べて終了する。 # 健全性チェックの出力 $hctx # 最近のログ(末尾) $rlog 直して、検証まで通してください。" Enter fullscreen mode Exit fullscreen mode The important part is the UNFIXABLE: exit keyword. In cases that can't or shouldn't be fixed, it prevents the runaway behavior of "just rewrite something anyway." When the cause is outside $dir (a dependency API outage, a cron config, etc.), the design keeps Claude from touching it. The health check output is trimmed to head -40, and the logs to tail -60, before being passed. If the context is too large, the judgment wobbles. Recording the baseline and stashing uncommitted changes Before repairing, it records the current HEAD (the baseline). Because uncommitted changes would make the restore dirty, it first stashes them with git stash -u. local baseline; baseline=$(cd "$dir" && git rev-parse HEAD 2>/dev/null || echo "") (cd "$dir" && git stash -u -q 2>/dev/null) || true Enter fullscreen mode Exit fullscreen mode -u is the option that also stashes untracked files. If Claude fails to repair, restore() returns to git reset --hard baseline and then drops the stash too. "Detect only" mode Setting mode=detect makes it "just detect that something is broken and notify," without calling Claude. To notify only once per day, it creates a flag file under state/. if [ "$mode" = detect ]; then if [ ! -f "$STATE/$slug-$TODAY.detected" ]; then local hd; hd=$(head -3 /tmp/sr-$slug-health.log 2>/dev/null | tr '\n' ' ' | cut -c1-200) notify alerts "🩺 $slug がサイレント失敗(本日成功の形跡なし)。自動修復対象外=人間の確認が要ります。$hd" touch "$STATE/$slug-$TODAY.detected" fi return fi Enter fullscreen mode Exit fullscreen mode Projects not under git management, projects where repair has side effects (requiring writes to external APIs), and projects with no verify.sh in place are safest left as detect. The script also automatically falls back to detect-equivalent behavior (notify only) when verify.sh doesn't exist. [ -x "$verify" ] || { log "$slug: verify無し→無人修復は危険なのでskip(人間へ)" notify alerts "🛠 $slug が異常だが verify 未整備のため自動修復せず。要確認。" return } Enter fullscreen mode Exit fullscreen mode Running it from launchd In actual operation, it runs periodically via a launchd plist. Because of the minimal-PATH problem, the script explicitly sets PATH internally. Label com.lily.self-repair ProgramArguments /bin/bash /Users//.claude/scripts/self-repair.sh StartCalendarInterval Minute 30 StandardOutPath /Users//.claude/logs/self-repair-launchd.log StandardErrorPath /Users//.claude/logs/self-repair-launchd.err Enter fullscreen mode Exit fullscreen mode Setting StartCalendarInterval to Minute: 30 runs it at 30 minutes past every hour. A 30-minute cadence is enough in most cases. Because nvm isn't loaded when running via launchd, you need to either pass the full path to the claude command via the CLAUDE environment variable, or explicitly export PATH at the top of the script. Pitfalls I hit Forgetting git stash -u makes the restore dirty If you let Claude repair while there are uncommitted work-in-progress files, untracked files remain even after git reset --hard. Unless you follow the flow of stash → repair → drop on failure, you'll create manual cleanup work for yourself. Making verify.sh the same as health.sh is pointless If you use the same script for health.sh and verify.sh, even a situation where "it happened to pass right after the repair" gets treated as a pass. verify.sh should include a test that actually runs the processing, so that even with --dry-run it exercises the full set of code paths. Always build the secret-scanning regex yourself Relying on GitHub's push protection means detection happens after the push. The point is to scan yourself before pushing. Also, since assignments to environment variables don't get caught by ordinary regexes, I scan git diff --cached directly to detect dangerous literals. Without gtimeout, the timeout doesn't work macOS's timeout command comes in via Homebrew as GNU coreutils' gtimeout. I check for its existence with command -v gtimeout, and if it's absent I make the wrapper a pass-through. Without gtimeout, if Claude stops responding, launchd keeps piling up the next runs. TIMEOUT_BIN="$(command -v gtimeout 2>/dev/null || true)" [ -z "$TIMEOUT_BIN" ] && [ -x /opt/homebrew/bin/gtimeout ] && TIMEOUT_BIN=/opt/homebrew/bin/gtimeout Enter fullscreen mode Exit fullscreen mode "Secrets and working dirs" are out of the script's reach As the comments note, this watchdog can only take care of the ways code breaks in projects managed as git repos. A secret in .env expired → Claude can't fix it (out of scope) The launchd plist itself broke → out of reach because it's outside the git repo The DB schema broke → depends on verify.sh, but migrations are dangerous, so detect mode is recommended To let Claude correctly judge "out of reach" from the prompt, I explicitly provide the escape hatch: "when you can't fix it, state only UNFIXABLE: and exit." A mode-detection mistake makes every project full If you forget the third column in registry.tsv, mode becomes empty and falls back to full via ${mode:-full}. If you forget it on a project you wanted as detect, unintended auto-repair runs, so always state the mode explicitly when writing the tsv. Summary Just register slug, dir, and mode in registry.tsv, and the watchdog monitors everything fully automatically Don't trust Claude's self-report; the watchdog independently runs verify.sh commit → secret scan → push only when verification passes. On failure, roll back immediately with git reset --hard The seven guardrails (independent verification, rollback, secret scanning, attempt cap, cost cap, scope restriction, owner verification) are the real safety of unattended repair For cases that are outside git, have no verify in place, or have side effects, stay in detect mode limited to detect + notify Putting the escape hatch "when you can't fix it, say UNFIXABLE: and exit" in the prompt prevents unnecessary rewrites The more automation you have, the more the "cost of detecting and repairing when it breaks" grows in proportion. Once you set up a watchdog, silent failures drop dramatically, and even when things break in the middle of the night, you wake up to a "self-repaired it" message in Discord. The cost of writing health.sh and verify.sh for every project is a one-time thing, and the running cost is nearly zero. Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio · X · GitHub*
Let Your AI Fix Its Own Broken Automation: Building an Unattended Watchdog
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.