Claude Code のテストゲートが編集ゼロで12分!?Stop hook が「黙って効かなくなる」まで

はじめに

こんにちは!サイオステクノロジーのなーがです。

前回、Claude Code のサブエージェントが勝手に多段委譲してトークンを溶かす問題を、hooks で機械的に止めた話を書きました。「プロンプトでのお願いは守られないことがあるので、破られると困るルールは hook に落とす」という教訓ですね。

その味をしめて、同じ個人開発の Python プロジェクトで、今度はテストに同じ手を使いました。Stop hook で「編集した領域のテストが通るまでセッションを終わらせない」ゲートを作ったんです。

しばらくは満足していたのですが、あるとき「テストゲートが過剰に発生して、トークンと実装時間を無駄にしている」という指摘を受けました。それで腰を据えて調査したところ、自分が想定していたのとまったく違う形で壊れていたことが分かりました。

今回は、そのテストゲートを作った動機と仕組みから、調査で出てきた原因、そして「公式ドキュメントを最初に読んでいれば防げた」という一番痛い教訓までを、実測値つきで共有します。

なお、この記事で紹介する hook 一式は最小構成サンプルとして GitHub で公開しています。

なぜ Stop hook でテストゲートを作ったのか

「テストは通っています」が検証の代わりにならない

AI にコードを書かせていると、次の2つがけっこうな頻度で起きます。

起きること何が困るか
「テストを書きました」「テストは通っています」と報告するが、実際にはテストを実行していない報告が検証の代わりにならない。結局こちらが毎回自分で回すことになる
ファイルを編集したまま、何も検証せずに応答を終える壊れたまま次のタスクに進み、後段で原因の切り分けが難しくなる

CLAUDE.md に「実装したら必ずテストを実行してください」と書く方法は、もちろん効くときもあります。ただ、守られるときと守られないときがあって、再現性がありません前回の記事で書いた多段委譲とまったく同じ構図ですね。

そこで、判断を AI 側に委ねるのをやめました。hook 側でテストコマンドの終了ステータスだけを見て判定し、未検証の編集が残っている状態では停止させない。これなら「テストを実行したかどうか」は自己申告ではなく事実になります。

Stop hook とは

Claude Code の hooks の中でも、今回の主役は Stop です。Claude が応答を終えてターンを閉じようとする直前に発火するイベントで、ここでフックが decision: "block" を返すと、Claude は停止できずに作業を続行します。reason に書いた文面はそのまま Claude へのフィードバックになります。

出力する JSON はこんな形です。

{
  "decision": "block",
  "reason": "テストが失敗しています。以下の失敗を確認し、全テストが成功するまで実装またはテストを修正してください。"
}

つまり Stop は、「終わってよいかどうかを外から審査する」ための場所です。テストゲートにはうってつけでした。

参考: Claude Code の hooks(公式ドキュメント) / hooks ガイド(公式ドキュメント)

テストゲートの仕組み

構成は hook が2つだけです。編集を記録する側と、停止時に検証する側に分かれています。

.claude/
├── hooks/
│   ├── mark-tests-pending.sh   # PostToolUse(Edit|Write): 編集ファイルから対象ラベルを記録
│   ├── stop-test-gate.sh       # Stop: 未検証の領域があればテストを実行し、失敗ならブロック
│   └── test-gate.conf          # 対象パス → ラベル → テストコマンドの対応表
├── tests/                      # hook 自体のテスト
└── settings.json               # 上記2つの hook の登録

正常系の流れは次のとおりです。

編集されたファイルからラベルを積む

PostToolUse(matcher は Edit|Write)で発火する mark-tests-pending.sh が、編集されたファイルのパスを設定ファイルと照合し、対応するラベルとテストコマンドを状態ファイル pending に追記します。

ここでのポイントは、記録するのがファイル名ではなくラベルだということです。src/ 配下を10ファイル編集しても、積まれるのは python というラベル1つ。同じラベルは1回の停止につき1度しか実行されません。

停止時にラベル分のテストを実行する

Stop で発火する stop-test-gate.shpending を読み、残っているラベルのコマンドを順に実行します。全部成功すれば pending を消して exit 0、つまり停止を許可します。1つでも失敗すれば decision: "block" を返して作業を続けさせます。

ブロック時の reason には、失敗したラベル名・終了ステータス・出力の末尾30行が入ります。実際の文面はこんな感じです。

テストが失敗しています(テストゲート 1/3回目)。以下の失敗を確認し、全テストが成功するまで実装またはテストを修正してください。各領域のテストコマンドは .claude/hooks/test-gate.conf を参照。

--- python 失敗 (exit 1, 出力末尾30行) ---
(テストコマンドの出力)

Claude はこれを読んで、そのまま修正作業に入ります。無限ループを避けるため上限は3回で、そこに達したら警告を出して停止を許可します。この3回という上限は、Claude Code 組み込みの停止ブロック上限(連続8回。CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変更可)より厳しいので、組み込み側の強制解除には到達しません。

設定ファイルで対応づける

プロジェクト固有なのは test-gate.conf だけです。<パスglob>|<ラベル>|<テストコマンド> の3列で、上から順に照合して最初にマッチした1行だけを適用します。

# --- 対象外(先に除外する) ---
node_modules/*|skip|
*/dist/*|skip|
.venv/*|skip|
.claude/*|skip|

# --- ドキュメント: リンク切れ検査 ---
docs/*|docs-links|python3 -m unittest discover -s tests -t . -k links
*.md|docs-links|python3 -m unittest discover -s tests -t . -k links

# --- ブラウザテスト (web/* より前に置く) ---
web/e2e/*|web-e2e|cd web &amp;&amp; npm run test:e2e:only

# --- フロントエンド (npm workspace) ---
web/dashboard/*|web-app-dashboard|cd web &amp;&amp; npm test -w dashboard
web/*|web-workspace|cd web &amp;&amp; npm test --workspaces --if-present

# --- Python: フルスイート (unittest) ---
src/*|python|python3 -m unittest discover -s tests -t .
tests/*|python|python3 -m unittest discover -s tests -t .

hook の登録は .claude/settings.json にこう書きます(timeout の値は後で痛い目を見るので、あえて修正後の値を載せています)。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/mark-tests-pending.sh\"",
            "timeout": 10
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-test-gate.sh\"",
            "timeout": 900,
            "statusMessage": "テストゲート: 変更領域のテストを実行中..."
          }
        ]
      }
    ]
  }
}

これで「編集したのに検証していない領域があるまま応答を終える」ことが構造的にできなくなりました。……できなくなったのは事実なのですが、代償が思ったより大きかったのです。

参考: Claude Code の settings(公式ドキュメント)

「トークンを無駄にしている」と言われて調べた

指摘を受けたとき、私の頭にあった仮説は「ブロックされる回数が多すぎるのだろう」でした。ブロックされるたびに Claude は修正作業を続けるので、そのぶんトークンを食う。分かりやすい話です。

そこで、手元に残っていたトランスクリプト166本を全部解析してみました。

ブロックの回数は主犯ではなかった

結果はこうでした。

指標
編集があったセッション95
ゲートが1回以上ブロックしたセッション20
ブロック注入の総数91(内訳: 1回目 56、2回目 18)
推定ゲート実行回数244(編集セッションあたり平均 2.6)

ブロックしたのは95セッション中20だけでした。仮説は外れです。体感の悪さは「ブロックが多いこと」ではなく、1回あたりの実行内容が重く、しかも重複していたことに由来していました。

自分で作ったものだけに「たぶんこれだろう」で直しにいかなくて本当によかったと思います。実際の実行結果をきちんと数えてみないと、どこに原因があるのかは分からないものですね。

1回あたりの実行内容が重かった

各テストコマンドの実測値がこちらです。

コマンド実測
Python の全テスト(unittest 1448件)236秒
ブラウザでのレンダリングテスト(54件)199秒
全ワークスペースのビルド + 単体テスト60秒
lint 一式(リポジトリ全体)32秒
単一アプリの型チェック + 単体テスト17.6秒
ドキュメント整合性チェック単体0.5秒

上2つが飛び抜けて重いことが分かります。そして、この重いものが1回の停止で同時に、しかも重複して積まれていたのが問題の本体でした。

見つかった原因

調査で出てきた原因を、ひとつずつ紹介します。どれも「動いているように見えるのに壊れている」タイプで、特に1つ目と5つ目はゲートが黙って効かなくなるという一番まずい壊れ方をしていました。

原因1: 状態ファイルがセッションをまたいで残る

まず、いちばん怖かったものからいきます。

pending全ラベルが成功したときにしか消えません。ここまでは設計どおりです。問題は、公式仕様上 Stop フックがユーザーの割り込みでは発火しないことでした。私はこれを知りませんでした。

つまり、Esc で止めたとき・/clear したとき・コンテキストが尽きて終わったときは、Stop が走らないので pending が残ったままセッションが終わります。そして当時の状態ファイルは全セッション共有だったので、この残骸が次のセッションの最初の停止で実行されるわけです。

実際に、1ファイルも編集していないセッションで約12.3分のテストが走る状態を観測しました。ユーザーから見ると「何も触っていないのに、応答を終えようとしたら数分固まる」です。これは相当に不気味な挙動でした。

そして pending 以上に危険だったのが、リトライカウンタ retries の残存です。中断で古いカウンタが2まで進んだまま残っていると、次に起きた本物の失敗が1回ブロックしただけで打ち切り(3回)に達します。ゲートは何も言わずに停止を許可するので、効いていないことに誰も気付けません

ガードレールが誤作動して開発を止めるのは、少なくともすぐ気付けます。でも「効かなくなる」のは気付けない。ここが決定的に違うところでした。

対策として、状態ファイルを pending-<session_id> / retries-<session_id> のようにセッション単位で分け、使われなくなったものは7日で GC するようにしました。あわせて、新しい検証サイクルの開始時(pending がまだ存在しないとき)に前サイクルのカウンタを捨てます。

# 新しい検証サイクルの開始(pending がまだ無い)なら、前サイクルのリトライ
# カウンタを捨てる。中断で retries だけ残ると、次に起きた本物の失敗が1回
# ブロックしただけで打ち切り(MAX_RETRIES)に達し、ゲートが黙って効かなくなる。
[ -f "$PENDING" ] || rm -f "$RETRIES_FILE"

なお、session_id が取得できなかった場合は、全セッション分の pending を取り込んでまとめて検証する安全弁を置いています。「取りこぼして黙って無反応になる」より「余計に走る」側に倒す、という判断です。異常系で迷ったら常にこちらに倒す、というのが今回の調査で身についた原則です。

原因2: ラベルの重複排除が完全一致でしか効かない

次は、体感の重さに直結していた原因です。

同じラベルの二重登録は防いでいたのですが、それは完全一致の話でした。実行内容が包含関係にあるラベル、たとえば「フルスイート」と「その部分集合」が同時に積まれても、名前が違うので間引けません。

実際に観測された5ラベルのキューがこちらです(ラベル名は公開リポジトリの記録に合わせた一般名です)。

ラベル内容コスト重複
web-shared共有UIパッケージ。全ワークスペースのビルド + 単体テスト + レンダリングテスト259秒
pythonPython の全テスト236秒
web-e2eレンダリングテスト約245秒レンダリングテストを2回目(しかも無駄なリビルド付き)
web-app-dashboard単一アプリの型チェック + 単体テスト17.6秒全ワークスペースのテストに完全に内包
docs-linksドキュメント整合性チェック0.5秒Python の全テストに完全に内包

合計は約12.3分。そのうち約4.4分(36%)が純粋な重複でした。

図にすると分かりやすいのですが、これは「テストが多い」問題ではなく、同じテストを2回走らせている問題です。人間なら「いま全部回したから、そっちはいいや」と判断するところを、ラベル名の一致だけで見ていたので判断できませんでした。

対策は、設定ファイルにラベルの包含関係を宣言する指令行を足すことでした。

# 形式: @supersedes|&lt;上位ラベル>|&lt;下位ラベル…(スペース区切り)>
@supersedes|python|docs-links
@supersedes|web-workspace|web-app-dashboard

「上位ラベルが対象に含まれているなら、下位ラベルは実行せずに捨てる」という宣言です。ここで気をつけたのは、「実行時間が長いほうが上位」ではなく「コマンドの検証内容が下位を完全に含んでいるか」で判断することでした。たとえばレンダリングテストは実行時間こそ長いですが、単体テストを含まないのでどのラベルの上位にもなりません。

もうひとつ、この仕組みには前提があります。ラベルとコマンドが1対1でなければならないことです。同じラベルに違うコマンドがぶら下がっていると、どちらが登録されるかが編集順に依存し、さらに上位ラベルで下位を間引いた結果実際には検証されない領域が生まれます。しかもテストは緑のまま通るので気付けません。実運用ではこの不変条件が崩れていたラベルが2つあったので、そこも分離しました。今はテストで機械的に検査しています。

原因3: 失敗すると成功済みのラベルまで再実行する

失敗したときに pending を丸ごと残していたので、1つの領域が落ちている間、リトライのたびに無関係な領域まで再実行していました。この構成なら 12.3分 × 最大3回です。

対策は単純で、検証が済んだ(成功した、または内包により省略した)ラベルを pending から行単位で落とすようにしました。落ちた領域を直す過程で他の領域を編集すれば、mark-tests-pending.sh がそのラベルを積み直すので、カバレッジは落ちません。

原因4: pending の更新に排他制御がない

ここからの2つは、公式ドキュメントと照らし合わせて初めて見つかったものです。

pending への追記は「grep で存在確認 → 無ければ追記」という read-modify-write でした。1メッセージで複数の Edit が発行されると PostToolUse も同時に走るので、その隙間に別プロセスが割り込むと同じラベルが二重登録されます。結果、1回の停止でフルスイートが2回走ります。

正直「理屈上は起きうるけど、実際にはめったに踏まないのでは」と思ったので、再現実験をしてみました。flock バリアで16並列を同時解放し、40ラウンド回します。

重複が出たラウンド
修正前4 / 40
修正後0 / 40(最終確認 0 / 30)

普通に起きていました。10回に1回です。対策は flock による排他ですが、ひとつ重要な注意点があります。テスト実行中にロックを保持しないことです。テストは数分かかるので、握ったままだとその間の PostToolUse が全部待たされます。実行対象をスナップショットしてからロックを解放し、実行後に取り直して行単位で間引く、という順序にしました。

# フックは並列に実行される。1メッセージで複数の Edit が発行されると
# PostToolUse も同時に走るため、下の grep(存在確認)と追記の間に別プロセスが
# 割り込むと同じラベルが二重登録され、1回の停止でフルスイートが2回走る。
exec 9>"$LOCK" 2>/dev/null || exit 0
command -v flock >/dev/null 2>&amp;1 &amp;&amp; flock -x 9

ガードレールの回帰テストは「ロックを取っているつもりで取れていない」実装を検出したかったので、外部でロックを保持している間フックが待たされることを直接確認する behavioral テストと、ロック取得記述の静的検査の二本立てにしました。

原因5: Stop の timeout が既定値のままだった

最後がこれです。個人的には、原因1と並ぶ怖さでした。

settings.jsonStoptimeout を、私は明示していたつもりで既定値と同じ600秒のままにしていました。「明示したから大丈夫」と思っていたわけです。

ところが公式仕様では、タイムアウト超過は非ブロッキングエラー扱いになります。つまり、テストが時間内に終わらなかった場合、失敗を検出しないまま停止が通ります

実測の最悪ケース(重いラベルが2つ積まれた場合)は約500秒でした。600秒まで、あと100秒。しかもマシンの負荷次第で簡単に前後する範囲です。ここを超えていたら、ゲートは何のエラーも出さずに「今日はテストしませんでした」と静かに素通りしていたことになります。

原因1のリトライカウンタと、まったく同じ壊れ方です。ガードレールは、効かなくなったことを自分では教えてくれません。

対策として timeout を900秒に引き上げました。数字そのものより、「自分のテストスイートの実測の最悪ケースを知った上で、余裕を持たせて設定する」という手順のほうが大事だと思っています。

参考: Claude Code の settings(公式ドキュメント)

公式ドキュメントを後から読んだら書いてあった

さて、ここが今回いちばん恥ずかしい話です。

原因4と原因5は、いずれも公式ドキュメントに明記されていました

  • 「フックは並列に実行される。ファイル競合を避けよ」という注意書きと、その対策としての flock の例示
  • 無限ループ防止に stop_hook_active を使うこと
  • タイムアウト超過が非ブロッキングエラー扱いになること

そして白状すると、初回の実装時、私はこれらのドキュメントを参照していませんでした。読んだのは全部、インシデントの調査中です。

なぜ読まなかったのかを考えてみると、「何度か hook を作成したことがあったから」でした。前回の記事でも hooks を書いていましたし、これまでの実行結果をClaude Codeに調査させることでStop の入力と出力の形さえ分かれば動くものは作れます。実際に動きましたし、ちゃんとブロックもしました。動いてしまったことが、仕様を確認しない理由になっていたわけです。

事後に公式ドキュメントを照合してみると、幸い仕様に沿っていた点もありました。

  • Stopdecision: "block" + reason を返す形式
  • 常に exit 0 して JSON で制御する方式(公式にも「exit 2 は JSON を無視する。構造化された制御には exit 0 + JSON」とあります)
  • jq が無い環境向けの python3 フォールバック(公式のトラブルシューティングが挙げる jq: command not found 対策)
  • $CLAUDE_PROJECT_DIR の利用
  • ブロック上限を3回にしていたこと(組み込みの停止ブロック上限である連続8回より厳しいので、強制解除には達しません)

……なのですが、これは部分的な仕様が「たまたま合っていた」だけです。合っていた項目と外していた項目を分けたのは、私の理解の深さではなく運でした。

なお stop_hook_active については、公式サンプルどおり「true なら常に exit 0」にするとブロックが実質1回に制限されてゲートが弱くなるので、そのままは採用しませんでした。自前の有界な3回制カウンタで同じ目的を満たしているため、置き換えではなく多重化しています。stop_hook_activetrue かつ自前のカウンタが読めない(=進捗を追跡できない)ときに限ってループを打ち切る、という使い方です。

ここで言いたいのは、自作のガードレールほど、土台の仕様確認を飛ばしがちだということです。ガードレールは普段は何も言わずに黙っています。黙っているのが正常なのか、壊れて黙っているのかは、外からは区別がつきません。だからこそ、その土台になっているイベントが「いつ発火して、いつ発火しないのか」「異常時にどちらへ倒れるのか」は、最初に確認しておくべきでした。

参考: Claude Code の hooks(公式ドキュメント) / hooks ガイド(公式ドキュメント)

直した内容と結果

実施した変更をまとめます。

変更内容
状態のセッション分離pending-<session_id> / retries-<session_id>session_id が取れない場合は全セッション分をまとめて検証する安全弁つき。7日で GC
リトライカウンタのリセット新しい検証サイクル開始時に前サイクルのカウンタを捨てる
成功ラベルの間引き失敗時、検証が済んだラベルを pending から行単位で落とす
@supersedes設定ファイルでラベルの包含関係を宣言し、上位が対象なら下位を捨てる
ラベルとコマンドの1対1化同じラベルに2種類のコマンドがぶら下がっていた2ラベルを分離(@supersedes の前提)
flock 排他両フックが状態ディレクトリの .lock を取る。テスト実行中は保持しない
stop_hook_activeカウンタ追跡不能時の保険として多重化
timeout600 → 900秒

効果を確かめるため、原因2で紹介した5ラベルのキュー(web-shared / python / web-e2e / web-app-dashboard / docs-links)を、修正後の設定ファイルでそのままリプレイしてみました。@supersedes の判定で、次の3つは「検証内容が上位ラベルに含まれている」として実行前に捨てられます。

捨てられるラベル上位ラベル捨ててよい理由
docs-linkspythonドキュメント整合性チェックは Python のフルスイートの中で実行される
web-app-dashboardweb-shared単一アプリの型チェック + 単体テストは、全ワークスペースのビルド + 単体テストに含まれる
web-e2eweb-sharedレンダリングテストは共有UIパッケージ側でも走る。削減量が一番大きいのはここ

結果、実際に実行されるのは python(Python のフルスイート)と web-shared(共有UIパッケージ: 全ワークスペースのビルド + 単体テスト + レンダリングテスト)の2つだけになりました。この2つは検証範囲が互いに重ならないので、これ以上は削れません。捨てられた3ラベル分(約4.4分)がそのまま消えた形です。

さらに、2ラベルとも成功すれば pendingretries は削除されるので、テストが終わったあとに状態ディレクトリへ残るのはロックファイル(.lock)だけになります。原因1で書いた「次のセッションへ持ち越される残骸」が発生しない状態です。

修正前修正後
5ラベル同時の1回約12.3分約7.9分(-36%)
編集ゼロのセッション約12.3分0秒
失敗1件のリトライ全ラベル再実行 × 最大3回失敗ラベルのみ

重複が消えたぶんがそのまま36%の短縮になり、いちばん不気味だった「編集していないのに走る」は 0秒になりました。

もうひとつ、記事の本筋からは少し外れますが、前回の記事で紹介した実装担当のサブエージェント(implementer)にも手を入れています。エージェント定義が編集内容に関わらず毎回フルスイート(236秒)と lint を実行しており、その後にゲートが同じものを再実行していたためです。TypeScript だけを編集した場合も Python の全テストを回していました。

実測では72回・35セッションで使われていたので、これも地味に効いていたはずです。変更領域に対応する範囲だけを検証するよう縮小し、implementer 経由の Python 変更でフルスイートが2回走っていたのを1回にしました。

さいごに

Claude Code の Stop hook で作ったテストゲートが、過剰実行と「黙って効かなくなる」壊れ方を同時に起こしていた話でした。要点をまとめます。

  • 体感の悪さの原因は思い込みで当てにいかない。ブロックの回数は主犯ではなかった(95セッション中20だけ)。重かったのは1回あたりの実行内容で、その36%は純粋な重複だった
  • 状態ファイルはセッションをまたぐStop hook はユーザーの割り込み(Esc・/clear)では発火しないので、残骸が次のセッションで実行される。編集ゼロで約12.3分走っていた
  • 重複排除の単位をラベルの完全一致から包含関係へ広げる。ただし「実行時間が長いほう」ではなく「検証内容が下位を完全に含むほう」を上位にする
  • フックは並列に実行されるので、状態ファイルの read-modify-write は flock で排他する。ただしテスト実行中はロックを保持しない
  • Stoptimeout 超過は非ブロッキングエラー扱い。実測の最悪ケースを知った上で余裕を持たせる
  • 異常系で迷ったら、無反応になる側ではなく余計に走る側へ倒す

そして一番の教訓は、自作のガードレールほど、土台の仕様確認を飛ばしがちだということです。私は「動いたから正しい」と思い込んで公式ドキュメントを読まず、結果として flock もタイムアウトの挙動も、そこに書いてあったものを事後に読むことになりました。

ガードレールは、効かなくなったことを自分では教えてくれません。 誤検知して開発を止めるタイプの故障はすぐ気付けますが、黙って素通りするタイプの故障は、次に本当のバグが漏れるまで誰も気付けません。だからこそ、作った本人が定期的に「これ、まだ効いてる?」と疑いに行く必要があります。

今回の hook 一式は、プロジェクト固有の内容を設定ファイルに閉じ込めた最小構成サンプルとして公開しています。hook 本体・設定・テスト・動作確認用の最小サンプルが入っているので、.claude/ にコピーして test-gate.conf を自分のパスとコマンドに書き換えれば動きます。

いきなりブロックまで作り込まなくても、まずは PostToolUse で「編集されたファイルをログに残す」だけでも、自分がどれだけ検証せずに進んでいるかが見えて面白いと思います。ぜひ試してみてください!それでは!

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。
エンジニア募集中!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です