壊れた自動化をAIに自力で直させる ― 無人ウォッチドッグの作り方
「Claude Codeの記憶を4層に分けた話」「Claude CodeとCodexを1台で協業させる」に続く「個人開発を量産するための自動化基盤」シリーズです。今回は、壊れた自動化スクリプトをAIに検知・修復・検証させ、検証が通った時だけ承認なしで本番反映する無人ウォッチドッグの設計と実装を紹介します。ガードレールをどう積めば「AIが無制限に暴れる」ことなく安全に動くか、実コードを軸に説明します。
「壊れたら自分で直す」が必要になった背景
個人開発を量産していると、常時稼働している自動化スクリプトが増えていきます。アフィリ記事の自動投稿、スクレイパー、Discordへのブリーフ配信、iOSアプリの審査状態チェック。それぞれは launchd や cron で動いているので、壊れても誰も気づかないまま数日経つことがあります。
手動で監視し手動で直す、という運用はすぐ限界が来ます。かといって「AIに直させる」だけでは、コードの意図しない書き換え、secretのコミット、修復したはずが実は壊れたまま本番反映、といったリスクがあります。
そこで設計したのが self-repair.sh です。考え方はシンプルです。
検知 → Claudeが原因特定して直す → ウォッチドッグが独立に verify を回す
→ 通った時だけ commit/push/反映
→ 落ちたら編集を巻き戻して人間に通知
Claudeの自己申告を信じないのがポイントです。「直しました」とClaudeが言っても、ウォッチドッグが自分で検証スクリプトを実行し、exit 0になった時だけ反映します。
ウォッチドッグの全体構成
ファイル配置はこうなっています。
~/.claude/self-repair/
├── registry.tsv # 監視対象プロジェクトの一覧
├── checks/
│ ├── <slug>-health.sh # 健全性チェック(exit 0=正常, 非0=異常)
│ └── <slug>-verify.sh # 修復後の検証(exit 0=合格, 非0=不合格)
├── state/
│ └── <slug>-YYYYMMDD.count # 本日の試行回数
└── (スクリプト本体は ~/.claude/scripts/self-repair.sh)
~/.claude/logs/
└── self-repair.log
メインループは registry.tsv を読み込み、プロジェクトごとに process() を呼ぶだけです。
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 < "$REG"
registry.tsv のフォーマットは slug\tdir\tmode の3列です。
# slug dir mode
affiliate ~/dev/affiliate-factory full
brief-discord ~/dev/brief detect
article-pub ~/dev/article-publisher full
mode には full(検知+修復)と detect(検知と通知のみ)があります。git管理されていないプロジェクトや、自動修復が危険なものは detect にしておきます。
健全性チェックと検証スクリプトの書き方
ポイントは、health.sh と verify.sh を完全に分離することです。
health.sh は「今壊れているか」を判定します。異常を検知したら非0で終了し、標準出力にエラーの文脈を出します(この出力がClaudeへの修復依頼に使われます)。
# 例: affiliate-factory-health.sh
#!/usr/bin/env bash
# 過去24時間に記事が生成されていなければ異常
LAST=$(find ~/dev/affiliate-factory/output -name '*.md' -newer ~/dev/affiliate-factory/output -mmin -1440 | wc -l | tr -d ' ')
if [ "$LAST" -eq 0 ]; then
echo "過去24時間の記事生成数=0。generate.sh が失敗している可能性"
exit 1
fi
verify.sh は「修復後に本当に動くか」を判定します。health.sh より厳密にしてもいいですし、同じでも構いません。重要なのはClaudeの修復とは独立したプロセスが実行することです。
# 例: affiliate-factory-verify.sh
#!/usr/bin/env bash
# 実際に1本生成してみて0秒で終われば合格
cd ~/dev/affiliate-factory
timeout 120 bash generate.sh --dry-run
ガードレール7本:無人修復が安全に成り立つ理由
スクリプト冒頭のコメントに「安全の本体はガードレール」と書いてあります。7本のガードをすべて積むことで、無人修復が成り立ちます。
ガード1:独立検証(Claudeの自己申告は信用しない)
最重要です。process() の中で、Claude呼び出し後にウォッチドッグ自身が bash "$verify" を実行します。
# Claudeを呼んだ後...
local vrc=0
run_capped "$VERIFY_TIMEOUT" bash "$verify" >/tmp/sr-$slug-verify.log 2>&1 || vrc=$?
if [ "$vrc" -eq 0 ]; then
# 直った → secret走査 → commit → push
else
# ❌ 検証失敗 → 編集を巻き戻し
restore "$dir" "$baseline"
fi
run_capped は gtimeout があればタイムアウト付きで実行するラッパーです。検証が無限に走り続けることを防ぎます。
run_capped() { # run_capped <timeout_sec> <cmd...>
local t="$1"; shift
if [ -n "$TIMEOUT_BIN" ]; then "$TIMEOUT_BIN" "$t" "$@"; else "$@"; fi
}
ガード2:失敗で即巻き戻し
verify が非0なら restore() でベースラインに戻します。
restore() { # restore <dir> <baseline_sha>
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
}
git reset --hard でCommit前の状態に戻し、git clean -fdq でClaudeが新規追加したファイルを消し、stashに退避していたものもdropします。gitリポが前提なので、非gitプロジェクトは detect モードにする必要があります(スクリプト内でもgit rev-parseで確認し、非gitなら自動修復をskipします)。
ガード3:secret走査
push前にstagingされたdiffを正規表現で走査します。
secret_in_staged() { # secret_in_staged <repo>
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
}
.env がトラッキングに入っていれば即アウト。それ以外もStripeの pk_/sk_live_、AWSのAKIA、GitHub Personal Access Token(ghp_)、Slack token(xox)、秘密鍵のヘッダー、Google APIキー(AIza)を検出します。
このsecret走査は launchd実行時にClaudeのhookが発火しないため、スクリプト内に自前で組み込んでいます。hookに頼らず自力で走査するのが要点です。
実際にcommit前に走査しているのは secret_in_staged_after_add() です。git add -A してからsecret走査し、クリーンならそのままcommitします。
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
}
ガード4:試行上限
同一slugは1日 MAX_ATTEMPTS(デフォルト2)回まで。状態ファイルをカウンタとして使います。
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"
}
上限に達したら修復をskipし、Discord経由で「人間の確認が要ります」と通知します。修復ループが無限に回ってトークンを溶かすことを防ぎます。
ガード5:コスト上限
5時間ブロックのoutput tokenが閾値を超えていれば、その実行サイクルをまるごとskipします。
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
}
BUDGET_ADVISOR は別スクリプトでClaude Codeの使用量をJSON形式で返します。課金暴走の最終防衛線です。
ガード6:スコープ制限(allowedToolsで壁を作る)
Claudeには --allowedTools で触れるツールを制限し、プロジェクトディレクトリ内のみ編集させます。
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=$?
--skip-permissions は使いません。--allowedTools で道具を絞り、プロンプト側でスコープを明示することで壁を作ります。
ガード7:push先owner検証
push先のGitHub remoteが自分のアカウントでなければpushしません。第三者の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
}
AIへの修復依頼の作り方
Claudeへのプロンプトは process() 内で動的に組み立てます。health.shの出力と直近ログを文脈として渡し、何をしてはいけないかを制約として明記するのがポイントです。
local PROMPT="あなたは自律修復エージェントです。自動化『${slug}』(ディレクトリ: ${dir})が壊れています。原因を特定し、**最小の変更**で直してください。
# 制約(厳守)
- 触ってよいのは $dir 配下のファイルのみ。スコープを広げない。新機能を足さない。
- .env や秘密情報を読み出して出力・コミットしない。
- 直したら必ず自分で検証コマンド \`bash $verify\` を実行し、exit 0 になることを確認してから終了する。
- 直せない/原因が $dir 外にあるなら、推測で書き換えず『UNFIXABLE: <理由>』とだけ述べて終了する。
# 健全性チェックの出力
$hctx
# 最近のログ(末尾)
$rlog
直して、検証まで通してください。"
重要なのは UNFIXABLE: という終了キーワードです。直せない・直してはいけないケースで「とりあえず何か書き換える」という暴走を防ぎます。原因が $dir 外(依存APIの障害、cronの設定など)にあるときは手を出させない、という設計です。
健全性チェックの出力は head -40 に絞り、ログも tail -60 に絞って渡しています。コンテキストが大きすぎると判断がブレるためです。
ベースライン記録と未コミット変更の退避
修復前に、現在のHEAD(ベースライン)を記録します。未コミットの変更があると復元が汚れるため、先に 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
-u はuntracked filesもstashするオプションです。Claudeが修復に失敗した場合は restore() で git reset --hard baseline に戻した後、stashもdropします。
「検知のみ」モード
mode=detect にすると、Claudeを呼ばずに「壊れていることを検知して通知するだけ」になります。1日1回だけ通知するよう 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
git管理されていないプロジェクト、修復に副作用がある(外部APIへの書き込みが必要)プロジェクト、verify.shが未整備なプロジェクトは detect にしておくのが安全です。スクリプト内でも verify.sh が存在しない場合は自動的に detect 相当の動作(通知のみ)にフォールバックします。
[ -x "$verify" ] || {
log "$slug: verify無し→無人修復は危険なのでskip(人間へ)"
notify alerts "🛠 $slug が異常だが verify 未整備のため自動修復せず。要確認。"
return
}
launchdから回す
実運用では launchd の plist で定期実行します。最小PATH問題があるため、スクリプト内で明示的に PATH を通しています。
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.lily.self-repair</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/<you>/.claude/scripts/self-repair.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Minute</key>
<integer>30</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/<you>/.claude/logs/self-repair-launchd.log</string>
<key>StandardErrorPath</key>
<string>/Users/<you>/.claude/logs/self-repair-launchd.err</string>
</dict>
</plist>
StartCalendarInterval を Minute: 30 にすると毎時30分に実行されます。30分刻みで十分なケースがほとんどです。
launchd経由では
nvmがロードされないため、claudeコマンドのフルパスをCLAUDE環境変数で渡すか、スクリプト冒頭でexport PATHを明示する必要があります。
踏んだ落とし穴
git stash -u を忘れると復元が汚れる
未コミットの作業途中ファイルがある状態でClaudeに修復させると、git reset --hard しても未追跡ファイルは残ります。stashで退避→修復→失敗時にdropする流れを守らないと、手動で掃除する手間が生まれます。
verify.sh を health.sh と同じにすると意味がない
health.sh と verify.sh に同じスクリプトを使うと、「修復直後にたまたまpassした」という状況でも合格扱いになります。verify.sh には実際に処理を流すテストを入れるべきで、--dry-run でも一通りのコードパスを通るようにします。
secret走査の正規表現は必ず自前で組む
GitHubのpush protectionに頼るとpush後に検知になります。push前に自前で走査するのがポイントです。また、環境変数への代入は通常の正規表現では引っかからないため、git diff --cached をそのまま走査して危険なリテラルを検出する方式にしています。
gtimeoutがないとタイムアウトが効かない
macOSの timeout コマンドはGNU coreutilsの gtimeout として Homebrew で入ります。command -v gtimeout で存在確認し、なければラッパーをパススルーにしています。gtimeout なしでClaudeが応答しなくなると、launchdが次の実行を積み上げていきます。
TIMEOUT_BIN="$(command -v gtimeout 2>/dev/null || true)"
[ -z "$TIMEOUT_BIN" ] && [ -x /opt/homebrew/bin/gtimeout ] && TIMEOUT_BIN=/opt/homebrew/bin/gtimeout
「秘密値・作業dir」はスクリプトの射程外
コメントにも書いてありますが、このウォッチドッグが面倒を見られるのはgitリポとして管理されているプロジェクトのコードの壊れ方だけです。
.envに入っているsecretが失効した → Claudeは直せない(スコープ外)- launchd plist 自体が壊れた → gitリポ外なので射程外
- DBのスキーマが壊れた → verify.sh 次第だが、マイグレは危険なのでdetectモード推奨
「射程外」の判断をClaudeがプロンプト上で正しく行うために、「直せない時は UNFIXABLE: とだけ述べて終了する」という脱出口を明示しています。
モード判定ミスで全プロジェクトがfullになる
registry.tsv の3列目を書き忘れると mode が空になり、${mode:-full} でfullにフォールバックします。detect にしたいプロジェクトで書き忘れると意図しない自動修復が走るため、tsv を書く時は必ずmodeを明示します。
まとめ
registry.tsvにslug・dir・modeを登録するだけで、あとはウォッチドッグが全自動で監視する- Claudeの自己申告は信用せず、ウォッチドッグが独立に
verify.shを実行する - 検証が通った時だけ commit→secret走査→push。失敗なら
git reset --hardで即巻き戻し - 7本のガードレール(独立検証・巻き戻し・secret走査・試行上限・コスト上限・スコープ制限・owner検証)が無人修復の安全の本体
- gitリポ外・verify未整備・副作用ありのケースは
detectモードで検知+通知に留める - 「直せない時は
UNFIXABLE:と言って終了する」という脱出口をプロンプトに入れておくと余計な書き換えを防げる
自動化が増えれば増えるほど、「壊れた時の検知・修復コスト」も比例して増えます。ウォッチドッグを一度組んでおくと、サイレント失敗が激減し、夜中に壊れても翌朝Discordに「自動修復しました」と来ている状態になります。全プロジェクトに health.sh と verify.sh を書くコストは最初だけで、ランニングコストはほぼゼロです。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 作ったアプリは ポートフォリオ にまとめています📱
- 新着・開発の裏側は X @bokuwalily で発信しています🌍
- OSS: github.com/bokuwalily 🐙
皆さんの ❤️ やシェアが励みになります!