はじめに / 対象と前提
Claude Code に「テストを通してから終わって」と毎回言うのが面倒だったので、応答を終えるタイミングで自動的にテストを走らせ、落ちていたら Claude に差し戻す仕組みを Stop hook で組んだ。この記事はその動かし方と、自分が実際にハマった 3 点をまとめる。
- 想定読者:Claude Code を業務で使っていて、hooks の設定(
settings.json)を一度は触ったことがある人 - 前提環境:Claude Code v2.x(2026 年 9 月時点)/ macOS 15 / Node.js 22 / jq 1.7
- Stop hook は「Claude がメインの応答を終えようとした瞬間」に発火する hook。PreToolUse / PostToolUse と違い、ツール実行ではなく ターン終了 がトリガーになる
TL;DR
- Stop hook のスクリプトが stdout に
{"decision":"block","reason":"..."}を返すと、Claude は終了せずreasonを読んで作業を続ける - 入力 JSON の
stop_hook_activeがtrueのときは即exit 0で抜けないと、hook が自分自身を呼び続けて無限ループになる -
exit 2+ stderr でも差し戻せるが、JSON のdecision方式のほうが「続行させたい」と「本当に止めたい」を明示的に書き分けられる
手順 / 動かし方
1. hook スクリプトを書く
.claude/hooks/stop-test-gate.sh を作る。stdin に JSON が流れてくるので jq で読む。
#!/usr/bin/env bash
set -u
input=$(cat)
# 2周目以降(hook 由来の続行中)は何もしない = 無限ループ防止
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
# 変更が無いターン(質問への回答だけ等)は素通し
if git diff --quiet HEAD -- 2>/dev/null; then
exit 0
fi
# テスト実行。出力は末尾だけ Claude に返す
if ! out=$(npm test --silent 2>&1); then
tail_out=$(printf '%s' "$out" | tail -n 40)
jq -n --arg r "テストが失敗している。修正してから終了すること。
--- npm test 末尾 ---
$tail_out" '{"decision":"block","reason":$r}'
exit 0
fi
exit 0
ポイントは 3 つ。stop_hook_active の判定を最初に置くこと、差分が無いときは何もしないこと、block を返すときも exit code は 0 にすること(理由は後述)。
chmod +x .claude/hooks/stop-test-gate.sh
2. settings.json に登録する
プロジェクト直下の .claude/settings.json に書く。Stop にはツール名で絞る対象が無いので、matcher は省略でよい。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-test-gate.sh",
"timeout": 120
}
]
}
]
}
}
timeout のデフォルトは 60 秒。テストが長いプロジェクトでは伸ばしておかないと、hook がタイムアウトして素通りする。
3. 動作確認
わざとテストが落ちる状態を作ってから、Claude に「この関数の戻り値の型を変えて」と頼む。
Claude が編集を終えて応答を締めようとした瞬間に hook が走り、Claude が「テストが失敗しているので修正します」と自分から続けて動き出せば成功。/hooks を開くと登録済みの Stop hook が見えるので、そもそも認識されているかはそこで確認できる。
hook に何が渡っているか見たいときは、スクリプト冒頭で stdin をファイルに落とす。
echo "$input" > /tmp/stop-hook-input.json
中身はこういう形になる。
{
"session_id": "xxxx",
"transcript_path": "/Users/me/.claude/projects/.../xxxx.jsonl",
"cwd": "/Users/me/repo",
"hook_event_name": "Stop",
"stop_hook_active": false
}
ハマりどころ
1. stop_hook_active を見ずに無限ループさせた
最初のバージョンでは stop_hook_active の判定を入れていなかった。すると、テストが落ちる → block → Claude が修正 → また Stop → hook がまたテスト → まだ落ちている → block…と、Claude が直せない種類の失敗(環境依存で落ちるテスト等)に当たった瞬間に 永遠に終わらなくなった。
stop_hook_active は「この Stop が、直前の Stop hook の block によって続行させられたターンの終了か」を表すフラグ。true のときは 2 周目以降なので、そこで exit 0 すれば「1 回だけ差し戻す」動きになる。もっと粘らせたい場合は、/tmp にカウンタファイルを置いて回数で打ち切る。
count_file="/tmp/stop-gate-$(echo "$input" | jq -r '.session_id')"
n=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$count_file"
[ "$n" -gt 3 ] && exit 0
2. exit 2 と decision: block を混ぜて挙動が読めなくなった
hook が Claude に何かを伝える方法は複数ある。整理するとこうなる。
| 方法 | Claude 側の挙動 | 用途 |
|---|---|---|
exit 2 + stderr |
stderr の内容が Claude に渡り、続行する | 手っ取り早い差し戻し |
exit 0 + stdout に {"decision":"block","reason":"..."}
|
reason が Claude に渡り、続行する |
続行理由を構造化して返したい |
exit 0 + stdout に {"continue":false,"stopReason":"..."}
|
Claude を強制終了。stopReason はユーザーに表示 |
危険な状態で完全に止めたい |
自分は最初、block の JSON を出しつつ exit 1 で終わらせていた。すると hook が「エラー扱い」になって stdout の JSON は読まれず、Claude は普通に終了していた。JSON で制御するときは exit 0 固定。exit 1 は「hook 自体の失敗」であり、ブロックにはならない(stderr がユーザーに表示されるだけ)。
3. settings.json を書き換えたのに発火しない
hooks の設定は セッション開始時にスナップショット される。セッション中に settings.json を編集しても、そのセッションでは反映されない。/hooks メニューを開いて変更を確認するか、claude を再起動すると読み込まれる。自分は 20 分ほど「なぜ動かない」で溶かした。
加えて、command に相対パスを書くと cwd 依存で見つからないことがある。$CLAUDE_PROJECT_DIR を使ってプロジェクト直下からの絶対パスに展開させるのが安全。
背景・補足
Stop hook が便利なのは「Claude の判断に頼らず、外部の決定論的なチェックで終了を差し止められる」点にある。CLAUDE.md に「テストを通してから終われ」と書いても守られないことがあるが、hook は必ず走る。
ただし SubagentStop は別イベント で、サブエージェント(Task ツール)の終了には Stop hook は反応しない。サブエージェントにも同じゲートをかけたいなら SubagentStop に同じスクリプトを登録する。入力 JSON の構造は同じで、stop_hook_active も入ってくる。
また、ユーザーが Ctrl+C で中断したときは Stop hook は発火しない。「中断時のクリーンアップ」用途には使えないので注意。
まとめ
- Stop hook はターン終了をトリガーに外部チェックを差し込める。テストゲートに最適
-
stop_hook_activeを最初に判定してexit 0。これが無いと無限ループ - JSON(
decision: block)で制御するなら exit code は 0 固定。exit 2は stderr 方式で別系統 -
settings.jsonの変更はセッション再起動か/hooksで反映。commandは$CLAUDE_PROJECT_DIRで絶対パスに - サブエージェントには効かない。必要なら SubagentStop に同じものを登録する