0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の Stop hook で「テストが通るまで終わらせない」自己修正ループを実装する手順 — stop_hook_active を見ないと無限ループ・exit 2 と decision:block の違い・設定が反映されない、3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

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 に同じものを登録する
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?