個人開発で複数のサイトを無人運用している私自身、Claude Code を自動で回すようになって最初に痛い目を見たのは、自分で仕込んだ PostToolUse フックが原因でセッションが止まったときでした。フォーマッタを「ファイルを書いたら自動で整える」つもりで PostToolUse に載せたところ、整形でファイルが書き換わり、それがまた次の編集として扱われ、フォーマッタが再び走る、という往復に近い挙動になったのです。
Hooks は「AI にお願いする」のではなく「必ずこうなる」を担保する仕組みなので、便利な反面、設計を誤ると自動運用そのものを壊します。ここでは公式ドキュメントに載っている挙動を踏まえつつ、自動投稿パイプラインで実際に運用して分かった「壊さないための勘所」を、コードと一緒に整理していきます。
まず押さえるべきは「終了コードと標準出力の契約」
Hooks の挙動を理解するうえで最初に固めておきたいのは、フックが Claude Code に何を返すか、という契約です。ここが曖昧なまま複雑なスクリプトを書くと、ブロックしたいのに素通りしたり、逆に通したいのに止まったりします。
フックは大きく二つの方法で結果を返します。ひとつは終了コード、もうひとつは標準出力に流す JSON です。
終了コードの意味は次の三つに整理できます。
exit 0 — 正常終了。標準出力の内容は、フックの種類によって扱いが変わります(後述)。
exit 2 — ブロッキングエラー。標準エラー出力の内容が Claude にフィードバックされ、操作が止められます。
それ以外(exit 1 など)— 非ブロッキングエラー。警告としてユーザーに表示されますが、処理は続行します。
この三分法のうち、自動化で最も重要なのは exit 2 です。「危険なコマンドを止める」「規約違反の編集を差し戻す」といったゲートは、すべて exit 2 と標準エラー出力の組み合わせで成立します。逆に言えば、ブロックしたいのに exit 1 を返していると、警告は出るのに処理は通ってしまうので、ゲートとして機能しません。
#!/usr/bin/env bash
# block-force-push.sh — PreToolUse(Bash) で危険な push を止める
input = $( cat ) # フックは JSON を stdin で受け取る
cmd = $( echo " $input " | jq -r '.tool_input.command // empty' )
if echo " $cmd " | grep -qiE 'git +push.*(--force|-f)\b' ; then
# stderr に理由を書いて exit 2 → Claude にフィードバックされ操作が止まる
echo "force push はこのリポジトリでは禁止です。--force-with-lease を検討してください。" >&2
exit 2
fi
exit 0
ここでのポイントは三つあります。フック入力は引数ではなく標準入力に JSON で渡ること、ブロック理由は標準出力ではなく標準エラー出力に書くこと、そして判定に使うフィールド(tool_input.command)を正確に取り出すことです。私は最初、理由を標準出力に書いてしまい、ブロックはされるのにフィードバックが Claude に届かない、という分かりにくい状態に陥りました。
JSON 出力で「止める/続ける」を明示的に制御する
終了コードによる制御はシンプルで頑健ですが、表現力には限界があります。「止めるが、こういう理由で」「続けるが、追加のコンテキストを渡す」といった細かい制御をしたいときは、標準出力に JSON を流す方法が向いています。
PreToolUse の場合、次のような JSON を返すと、終了コードに頼らずに許可・拒否を表現できます。
#!/usr/bin/env bash
# guard-writes.sh — PreToolUse(Write|Edit) で保護パスへの書き込みを拒否
input = $( cat )
path = $( echo " $input " | jq -r '.tool_input.file_path // empty' )
case " $path " in
* .env |* /secrets/ *|* /.git/ * )
cat << JSON
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "保護対象のパスです( $path )。意図的な変更なら手動で行ってください。"
}
}
JSON
exit 0
;;
esac
exit 0
JSON 方式の利点は、終了コードを 0 に保ったまま「拒否」を表現できる点です。これにより、フック自体のエラー(スクリプトのバグで非ゼロ終了)と、業務ロジックとしての拒否を切り分けられます。運用していると、この切り分けが後でとても効いてきます。「フックが落ちたのか」「ルールで弾いたのか」がログ上で区別できるからです。
Stop フックでは decision フィールドが使えます。"decision": "block" を返すと Claude の停止を差し止め、reason に書いた内容に従って作業を続行させられます。たとえば「テストが緑になるまで止まるな」というゲートはこう書けます。
#!/usr/bin/env bash
# require-green-tests.sh — Stop フックでテスト未通過なら続行を強制
input = $( cat )
# 無限ループ防止: 既にこのフックが止めた状態なら、もう止めない
if [ "$( echo " $input " | jq -r '.stop_hook_active // false')" = "true" ]; then
exit 0
fi
if ! npm test --silent > /tmp/test.log 2>&1 ; then
cat << JSON
{"decision": "block", "reason": "テストが失敗しています。/tmp/test.log を確認し、修正してから完了してください。"}
JSON
exit 0
fi
exit 0
このスクリプトで最も重要なのは stop_hook_active のチェックです。これが無いと、Stop フックがブロック→作業→また Stop→またブロック、という終わらない往復を生みます。後述しますが、自動運用での事故の多くはこの「ループの口」が塞がれていないことに起因します。
フォーマッタを Hooks に載せるときの落とし穴
冒頭の失敗談に戻ります。PostToolUse でフォーマッタを走らせる構成は便利ですが、二つの落とし穴があります。ひとつは整形による再編集ループ、もうひとつはフックの遅さがセッション全体を重くする問題です。
まず再編集についてですが、PostToolUse はツールが完了した後に走るフックなので、ここでフォーマッタがファイルを書き換えても、それが新しい Edit ツール呼び出しとして再帰するわけではありません。実際に問題になりやすいのは、フォーマッタが失敗したときに非ゼロ終了して PostToolUse がブロッキング扱いになり、Claude に「整形に失敗した」というフィードバックが繰り返し渡されて、同じ修正を何度も試みるパターンです。対策はシンプルで、整形は「失敗してもセッションを止めない」方針にし、必ず exit 0 で抜けることです。
#!/usr/bin/env bash
# auto-format.sh — PostToolUse(Write|Edit|MultiEdit)
input = $( cat )
file = $( echo " $input " | jq -r '.tool_input.file_path // empty' )
[ -z " $file " ] && exit 0
[ -f " $file " ] || exit 0
case "${ file ##* .}" in
ts | tsx | js | jsx ) npx prettier --write " $file " > /dev/null 2>&1 ;;
py ) ruff format " $file " > /dev/null 2>&1 ;;
rs ) rustfmt " $file " > /dev/null 2>&1 ;;
esac
# 整形の成否に関わらず常に正常終了。フォーマッタの失敗でセッションを止めない
exit 0
ここで >/dev/null 2>&1 と末尾の exit 0 をセットにしているのは意図的です。整形ツールが入っていない環境や、構文エラーで整形できないファイルに当たっても、フックがセッションを巻き込んで止めないようにするためです。整形はあくまで「できれば気持ちよくしておく」程度の補助に留め、品質の番人は別の lint ゲートに任せる、という役割分担にしています。
二つ目の「遅さ」も実運用では効いてきます。フックはツール呼び出しのたびに同期的に走るため、毎回 2〜3 秒かかる整形を全ファイルに掛けると、体感速度がはっきり落ちます。私は対象を変更ファイルだけに絞り、重い型チェックは PostToolUse ではなく Stop フック(応答完了時に一度だけ)へ寄せることで、編集ループの軽さと最終品質の両立を図っています。
matcher の指定で「無関係なツールにまで走らせない」
Hooks の設定は .claude/settings.json に書きます。プロジェクト共有のルールはここに置き、Git にコミットしてチーム全員へ同じゲートを配ります。matcher にツール名(正規表現)を指定することで、対象を絞り込めます。
{
"hooks" : {
"PreToolUse" : [
{
"matcher" : "Bash" ,
"hooks" : [
{ "type" : "command" , "command" : "bash .claude/hooks/block-force-push.sh" }
]
},
{
"matcher" : "Write|Edit|MultiEdit" ,
"hooks" : [
{ "type" : "command" , "command" : "bash .claude/hooks/guard-writes.sh" }
]
}
],
"PostToolUse" : [
{
"matcher" : "Write|Edit|MultiEdit" ,
"hooks" : [
{ "type" : "command" , "command" : "bash .claude/hooks/auto-format.sh" }
]
}
],
"Stop" : [
{
"hooks" : [
{ "type" : "command" , "command" : "bash .claude/hooks/require-green-tests.sh" }
]
}
]
}
}
matcher を省略すると、そのイベントのすべての対象に対してフックが走ります。Stop のようにツールを伴わないイベントは matcher を付けません。逆に PreToolUse・PostToolUse でツールを絞らずに重い処理を載せると、Read や Grep のような軽い操作にまで余計なスクリプトが走り、体感が一気に重くなります。「どのイベントで、どのツールのときだけ」を最初に決めるのが、軽快さを保つ第一歩です。
設定の優先順位は、ユーザーレベル(~/.claude/settings.json)とプロジェクトレベル(.claude/settings.json)の両方が読み込まれ、同じイベントに複数のフックがあれば順番にすべて実行されます。チーム共通の安全弁はプロジェクトに、個人の通知設定などはユーザーレベルに、という置き分けが扱いやすいです。
暴発したフックを後から追える観測ログ
無人運用で一番こわいのは、「いつの間にかフックが全部の編集をブロックしていて、夜間のタスクが何も進んでいなかった」という静かな事故です。フックは黙って効くぶん、効きすぎたときに気づきにくいのです。そこで私は、すべてのフックの発火を 1 行ずつ JSONL に追記する観測ラッパーを噛ませています。
#!/usr/bin/env bash
# hook-observe.sh <name> <target-script> — フックの発火・所要時間・終了コードを記録
name = " $1 " ; shift
target = " $1 " ; shift
log = "${ CLAUDE_PROJECT_DIR :- .}/.claude/hook-metrics.jsonl"
input = $( cat )
start = $( date +%s.%N )
# 実体スクリプトに stdin を渡して実行し、出力と終了コードを捕捉
out = $( printf '%s' " $input " | bash " $target " )
code = $?
end = $( date +%s.%N )
dur = $( awk "BEGIN{printf \" %.3f \" , $end - $start }" )
ts = $( date -u +%Y-%m-%dT%H:%M:%SZ )
# ブロック(exit 2 または decision/permissionDecision が deny/block)を記録
blocked = "false"
{ [ " $code " = "2" ] || echo " $out " | grep -qE '"(deny|block)"' ; } && blocked = "true"
printf '{"ts":"%s","hook":"%s","code":%s,"dur":%s,"blocked":%s}\n' \
" $ts " " $name " " $code " " $dur " " $blocked " >> " $log "
# 実体の出力と終了コードはそのまま Claude Code へ引き渡す
printf '%s' " $out "
exit $code
このラッパーを通すと、たとえば「過去 24 時間でどのフックが何回ブロックしたか」を後から集計できます。
# 直近のブロック発火だけを抜き出して頻度を見る
jq -c 'select(.blocked==true)' .claude/hook-metrics.jsonl \
| jq -r '.hook' | sort | uniq -c | sort -rn
私はこれを週次で眺めて、「想定外に多くブロックしているフック」を早期に見つけるようにしています。ある週、保護パスのガードが想定の 10 倍ブロックしていたことがあり、調べると新しく追加したディレクトリ名がうっかり保護パターンに引っかかっていました。観測ログが無ければ、その分の作業が静かに止まり続けていたはずです。数値で見えるようにしておくと、こうした「効きすぎ」を事故になる前に拾えます。
所要時間(dur)も合わせて記録しておくと、フックが遅くなっていないかを継続的に監視できます。整形フックの中央値が 0.3 秒から 1.5 秒に伸びていたら、依存が増えたか対象ファイルが大きくなった兆候なので、対象の絞り込みを見直す合図にしています。
最小構成で始める導入手順
いきなり全部を載せると、どのフックが原因で止まったのか切り分けられなくなります。私自身は次の順番で一つずつ足していくのを推奨しています。
まず block-force-push.sh だけを PreToolUse(Bash) に載せ、exit 2 でのブロックが意図どおり効くかを手元で確認します。ここで終了コードの契約に慣れておきます。
次に auto-format.sh を PostToolUse に追加し、必ず exit 0 で抜ける設計になっているかを、わざと整形失敗するファイルで試します。失敗がセッションを止めない挙動を回避できているか確認するのが狙いです。
最後に require-green-tests.sh を Stop に載せ、stop_hook_active のループ回避が効いているかを、テストをわざと赤にして検証します。本番運用に投入するのはこの3段が安定してからです。
この順序の利点は、各段で「壊れ方」を一つずつ観察できる点です。いきなり5本のフックを載せて夜間タスクが全滅するより、1本ずつ育てるほうが、結果的に早く安定します。
どこまでをフックに任せ、どこからは任せないか
最後に、運用してたどり着いた線引きを共有します。フックに向いているのは、「決定論的で、速く、失敗してもリカバリ可能」な処理です。危険コマンドのブロック、保護パスのガード、軽い整形、完了前のテスト確認はこの条件に合います。
逆にフックに載せないほうがよいのは、ネットワークに出る重い処理、外部サービスへの破壊的な書き込み、そして「判断が状況依存で揺れる」たぐいの処理です。たとえば「この変更はデプロイしてよいか」のような判断は、決定論的なルールに落としきれないことが多く、フックで無理に止めると誤ブロックの温床になります。こうしたものは Claude 側の指示(CLAUDE.md やプロンプト)に委ね、フックは「絶対に超えてはいけない最後の一線」だけを守らせる、というのが今の私の方針です。
Hooks は強力ですが、強力だからこそ、止める力の設計と、効きすぎを観測する仕組みをセットで持っておくことが、無人運用を安心して回す条件になります。同じように自動化を組んでいる方の、転ばぬ先の杖になれば幸いです。