Claude の応答完了をフックして自動テスト・ログ・通知を回したい中級開発者向け
Stop は、Claude のターン終わりに npm test を走らせて落ちていたら自動で書き直しを継続させたい・transcript をログ化したい場面で、settings.json の hooks に「Stop」イベントとして script を登録して使う。StopFailure は、rate limit や billing error で会話が落ちた事実を Slack や PagerDuty に通知したい場面で、error_type を matcher にして該当 script を登録する。
Stop と StopFailure は、Claude Code のフックの中で「ターンが終わった瞬間」を捕まえる2つの兄弟イベントです。Stop は Claude が普通に応答を書き終わったとき、StopFailure は API 側のエラーで会話が強制終了したときに走ります。
同じ「ターン終わり」でも役目が全く違う。Stop は会話を止めずに「もう一回やり直して」と差し戻せる介入型フック、StopFailure は止まった事実をログに残すだけの観察型フックです。混同するとフック script は動いてるのに何の効果も出ない、という詰みパターンになります。
噛み砕くと
会議で議事録係がやる仕事に似ています。Stop は「会議が円満に終わった瞬間」に手を挙げて、議題が解決してないと判断したら「ちょっと待って、もう1ラウンド議論して」と止められる役。StopFailure は「会議室の電気が落ちて強制中断した瞬間」に駆けつけて、何時に何が起きたかをノートに書き残すだけの役。再開させる権限はない。
ここを揃えて覚えると、設定の意図が後から分かりやすくなります。
大事な前提:Stop は matcher 非対応、全ターンで毎回発火する
同じフック仲間でも、PreToolUse や PostToolUse は matcher: "Bash" のように「このツールを使ったときだけ走る」と絞れます。
Stop はこの仕組みを持ちません。公式 docs にも 「Stop does not support matchers and fires on every occurrence」 と明記されていて、Claude が応答を終えるたびに無条件で走ります。テストが絡む会話だけ動かしたい、みたいな絞り込みは hook script の中で transcript を読んで自分で判定する必要があります。
一方 StopFailure は error_type で絞れます。公式が用意している値は次の8種類で、エラー種別ごとに別 script を呼べる側です。
rate_limit / authentication_failed / oauth_org_not_allowed /
billing_error / invalid_request / server_error /
max_output_tokens / unknown
「cooking-blog で npm test を回す」を例に、実際の手順を見る
料理ブログ用の Next.js プロジェクトを Claude Code に育てさせているとします。コードを書かせるたびにユニットテストを走らせて、落ちていたら Claude に「テストが落ちた、直して」と差し戻したい。同時に rate limit エラーで会話が切れたら Slack に通知したい。この2要件を Stop と StopFailure の同時設定で組みます。
ステップ1: settings.json に hook ブロックを追加する
プロジェクト直下の .claude/settings.json を開いて、hooks セクションに2つのイベントを並べます。
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/stop-run-tests.sh" }
]
}
],
"StopFailure": [
{
"matcher": "rate_limit",
"hooks": [
{ "type": "command", "command": ".claude/hooks/notify-slack.sh" }
]
}
]
}
}
注意点が2つあります。Stop 側に matcher を書いても無視されます。書いて動かないわけではなく、書いても効かないだけ。StopFailure 側の matcher は "rate_limit" 以外にも "billing_error"、"max_output_tokens" など8種類から選べます。
ステップ2: stop-run-tests.sh を書く
Stop フックの script は、Claude のターン終了情報を JSON で標準入力から受け取ります。中身はこの形です。
{
"session_id": "abc123",
"transcript_path": "/Users/you/.claude/projects/cooking-blog/abc.jsonl",
"cwd": "/Users/you/cooking-blog",
"permission_mode": "default",
"hook_event_name": "Stop",
"effort": {
"level": "medium"
},
"stop_reason": "end_turn",
"stop_hook_active": false
}
script の中で npm test を走らせ、失敗したら decision: "block" を返します。ここで Claude は「テストが通っていない」と認識して、ターンを終わらせずに修正を続けます。
#!/bin/bash
INPUT=$(cat)
ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active')
# すでに block で再ループ中なら追い打ちしない(無限ループ防止)
if [ "$ACTIVE" = "true" ]; then
exit 0
fi
if npm test > /tmp/test.log 2>&1; then
exit 0
else
RESULT=$(tail -n 20 /tmp/test.log)
jq -n --arg r "$RESULT" '{
"decision": "block",
"reason": "Tests failed. Please fix them before stopping.",
"hookSpecificOutput": {
"hookEventName": "Stop",
"additionalContext": $r
}
}'
fi
ステップ3: 無限ループ防止の stop_hook_active を必ず見る
ここで初心者がやりがちな勘違いがあります。decision: "block" を返すと Claude は次のターンを回します。そのターンが終わるとまた Stop フックが走ります。判定をそのまま繰り返すと、テストが直らない限り永遠にループします。
公式 docs はこの罠を見越して、フックに渡る JSON の中に stop_hook_active という真偽値を仕込んでいます。公式の説明をそのまま引くとこうです。「The stop_hook_active field prevents infinite loops: it is false on the first Stop hook in a turn, and true on subsequent ones, so you can detect and avoid re-blocking if your logic might loop.」。block で差し戻された結果のターンでは true になるので、そのときは追加 block を諦めて素直に exit する、というガードを script の冒頭に必ず置きます。
ステップ4: notify-slack.sh を書く(StopFailure 用)
こちらは error_type と error_message を Slack の webhook に投げるだけ。StopFailure は 「output and exit code are ignored」 なので、何を返しても会話の結末は変わりません。観察記録に徹します。
#!/bin/bash
INPUT=$(cat)
ERR_TYPE=$(echo "$INPUT" | jq -r '.error_type')
ERR_MSG=$(echo "$INPUT" | jq -r '.error_message')
SID=$(echo "$INPUT" | jq -r '.session_id')
curl -X POST -H 'Content-Type: application/json' \
-d "{\"text\":\"[Claude Code] ${ERR_TYPE} on session ${SID}: ${ERR_MSG}\"}" \
"$SLACK_WEBHOOK_URL"
ステップ5: 動作確認
Claude Code を起動して、わざとテストが落ちる変更を投げます。「src/utils.ts の関数を壊して」と頼んで応答を待つと、ターン終わりに Stop フックが npm test を回し、失敗を検知して block を返し、Claude が「テストが落ちたので戻して修正します」と次のターンを始めます。
2回目のターン終わりは stop_hook_active=true で渡るので block しない、というガードが正しく効くか目視確認します。
ステップ6: subagent の完了は別フックで取る
ここがもう一つの罠です。/agents でカスタム subagent を呼んでメインから処理を切り出しているとき、subagent の応答が終わっても Stop は発火しません。公式は 「For subagents, Stop hooks are automatically converted to SubagentStop」 と書いていて、別イベント扱いになります。subagent の完了をフックしたければ SubagentStop を別途登録します。
つまり Stop と StopFailure は何をしてくれるのか
- Stop がやってくれる: Claude のターン終わりに任意の script を走らせる、結果次第で会話を続行させる、追加コンテキストを次のターンに渡す
- Stop がやってくれない: 特定ツールが使われたターンだけ絞ること。matcher 非対応のため、絞り込みは script 内で transcript を読んで自前判定する。subagent の完了検知も別イベント担当
- StopFailure がやってくれる: API エラーで会話が切れた事実をログ・通知に流す、
error_type単位で別 script に振り分ける - StopFailure がやってくれない: エラーをリトライさせる、会話を再開させる、エラーをユーザーから隠すこと。出力も exit code も完全に無視される
- 意味が薄い場面: 単発の質問用途・短いセッションでしか使わないなら、フック設定のメンテコストの方が高い
使いどころ3シナリオ(具体題材で再現)
シナリオ1: cooking-blog の自動テスト強制ループ
Next.js + Vitest で組んだ料理ブログを Claude に育てさせるケース。Claude はテストを書かせると合格を主張して終わろうとしますが、本当に通っているか不安です。Stop フックで毎ターン npm test を走らせ、落ちていたら block で差し戻す。stop_hook_active ガードを入れて2周まで、3周目は諦めて人間に渡す、という上限ロジックも組み込めます。Claude の「できました」を構造的に検証させる定番パターンです。
シナリオ2: 家計簿アプリ開発中の rate limit 監視
家計簿アプリの開発で Claude Code を1日5時間くらい回していると、稀に rate limit に当たります。気づかず放置すると30分単位で待ち時間が積み上がる。StopFailure に matcher: "rate_limit" で Slack 通知 script を仕込んでおくと、エラーが出た瞬間にスマホに飛びます。billing_error 用に PagerDuty を別フックで設定すれば、支払い系の事故だけ即エスカレートできます。
シナリオ3: OSS clone 直後のセッションログ全自動収集
新しい OSS を clone して Claude に読み解かせるとき、後で「あのとき何を聞いたっけ」が分からなくなります。Stop フックで transcript_path から .jsonl を読み、ターン終わりのタイミングで Markdown 要約を生成して ./session-log/ に追記する script を組むと、セッションが終わる頃には全質問・全回答のサマリが完成しています。block は使わず exit 0 で素通りさせるだけのログ運用パターンです。
初心者が踏みやすい落とし穴
- Stop に matcher を書いて絞ったつもりになる。Stop は matcher 非対応で、書いても無視されます。
tool_nameや transcript を script の中で自前判定するしかない。 stop_hook_activeを見ずに block を返す。無限ループ確定パターン。script の冒頭で「trueなら exit 0」のガードを必ず入れる。- StopFailure に
decision: "block"を返して再開を期待する。完全に観察専用なので、何を返しても会話の結末は変わりません。リトライしたいなら別途 supervisor script を外側に組む必要があります。 - subagent の完了に Stop を期待する。subagent では Stop が SubagentStop に自動変換されて、メインの Stop は発火しません。SubagentStop を別途設定する。
- script を実行可能にし忘れる。
chmod +x .claude/hooks/stop-run-tests.shを忘れると、フックは設定されているのに静かに失敗します。claude --debugで起動するとフック実行ログが見えるので、動かない時はまずそこを確認。 - 長時間の処理を Stop フックに置く。npm test に1分かかると、Claude の応答が終わるたびに1分待たされます。重いビルドや E2E テストは
SessionEnd側に逃すか、フック内で並列実行して即 exit 0 する設計にする。 - exit code 2 と
decision: "block"の使い分けを混乱する。Stop では exit code 2 と JSON の block はどちらも「停止を防ぐ」効果。StopFailure ではどちらも完全に無視。覚えるなら「介入したいなら Stop、観察だけなら StopFailure」の1行で十分。
書き方
// .claude/settings.json
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": ".claude/hooks/stop-run-tests.sh" } ] }
],
"StopFailure": [
{ "matcher": "rate_limit", "hooks": [ { "type": "command", "command": ".claude/hooks/notify-slack.sh" } ] }
]
}
}
やってみるとこうなる
入力
# Stop フックに渡る JSON(標準入力)
{
"session_id": "abc123",
"transcript_path": "/Users/you/.claude/projects/cooking-blog/abc.jsonl",
"cwd": "/Users/you/cooking-blog",
"permission_mode": "default",
"hook_event_name": "Stop",
"effort": { "level": "medium" },
"stop_reason": "end_turn",
"stop_hook_active": false
}
# StopFailure フックに渡る JSON
{
"session_id": "abc123",
"transcript_path": "/Users/you/.claude/projects/cooking-blog/abc.jsonl",
"cwd": "/Users/you/cooking-blog",
"hook_event_name": "StopFailure",
"error_type": "rate_limit",
"error_message": "Rate limit exceeded. Please try again later."
}
出力例
# Stop が会話を強制続行させたいときに返す JSON(標準出力)
{
"decision": "block",
"reason": "Tests failed. Please fix them before stopping.",
"hookSpecificOutput": {
"hookEventName": "Stop",
"additionalContext": "FAIL src/utils.test.ts > formatPrice rounds to 2 digits"
}
}
Claude 側の挙動: 上記を受け取ると会話を終了せず、additionalContext を次ターンの文脈に追加して修正を続ける。stop_hook_active が true で渡されたターンでは block を返さないようガードを script の冒頭に入れる。
StopFailure 側の出力は無視される(観察専用)。exit 0 で素通りさせて Slack 通知だけ実行する形が定石。
このページに出てきた言葉
- フック
- Claude Code が動く途中の特定タイミング(ターン開始時/ツール実行前/応答終了時など)に、自分で書いた script を自動で走らせる仕組み
- ターン
- 人間が1回プロンプトを送って、Claude が応答を返し終わるまでの一往復のこと
- matcher
- フック設定の中で「特定の条件のときだけこの script を走らせる」と絞り込むためのフィルタ。Stop は非対応、StopFailure は <code>error_type</code> で絞れる
- transcript
- そのセッションでやり取りされた会話の全文ログ。<code>.claude/projects/...</code> 配下の <code>.jsonl</code> ファイルで自動保存され、フックには絶対場所が JSON で渡される
- stop_hook_active
- フックに渡る JSON 内の真偽値。初回の Stop は <code>false</code>、block で差し戻された後の Stop では <code>true</code>。無限ループ防止のガード値
- agentic loop
- Claude が「思考 → ツール実行 → 観察 → 次の思考」を自律的に何周も回す動き。Stop で block を返すとこのループを強制的にもう1周回せる
- subagent
- メインの会話とは別に、特定の用途(コードレビュー、ドキュメント作成など)に特化したサブセッションとして起動される Claude のこと。完了時は SubagentStop が発火する
- exit code
- shell script が終了するときに OS に返す数字。<code>0</code> が正常終了、<code>2</code> は Stop フックで「block と同じ意味」として扱われる特別な値
- rate_limit
- StopFailure の <code>error_type</code> の1つで、API 側が「短時間に呼び過ぎ」と判断して一時的に拒否してくる状態