Stop / StopFailure

フック
Stop / StopFailure
Claude Code のフック2種。Stop は Claude が応答を書き終わったタイミングで走り、decision: "block" を返せば会話を強制続行させられる介入型。StopFailure は API エラー(rate_limit など)で会話が切れたタイミングで走るが、結末への介入はできない観察専用。どちらも settings.json の hooks ブロックに登録する。

Claude の応答完了をフックして自動テスト・ログ・通知を回したい中級開発者向け

Stop は、Claude のターン終わりに npm test を走らせて落ちていたら自動で書き直しを継続させたい・transcript をログ化したい場面で、settings.json の hooks に「Stop」イベントとして script を登録して使う。StopFailure は、rate limit や billing error で会話が落ちた事実を Slack や PagerDuty に通知したい場面で、error_type を matcher にして該当 script を登録する。

StopStopFailure は、Claude Code のフックの中で「ターンが終わった瞬間」を捕まえる2つの兄弟イベントです。Stop は Claude が普通に応答を書き終わったとき、StopFailure は API 側のエラーで会話が強制終了したときに走ります。

同じ「ターン終わり」でも役目が全く違う。Stop は会話を止めずに「もう一回やり直して」と差し戻せる介入型フック、StopFailure は止まった事実をログに残すだけの観察型フックです。混同するとフック script は動いてるのに何の効果も出ない、という詰みパターンになります。

噛み砕くと

会議で議事録係がやる仕事に似ています。Stop は「会議が円満に終わった瞬間」に手を挙げて、議題が解決してないと判断したら「ちょっと待って、もう1ラウンド議論して」と止められる役。StopFailure は「会議室の電気が落ちて強制中断した瞬間」に駆けつけて、何時に何が起きたかをノートに書き残すだけの役。再開させる権限はない。

ここを揃えて覚えると、設定の意図が後から分かりやすくなります。

大事な前提:Stop は matcher 非対応、全ターンで毎回発火する

同じフック仲間でも、PreToolUsePostToolUsematcher: "Bash" のように「このツールを使ったときだけ走る」と絞れます。

Stop はこの仕組みを持ちません。公式 docs にも 「Stop does not support matchers and fires on every occurrence」 と明記されていて、Claude が応答を終えるたびに無条件で走ります。テストが絡む会話だけ動かしたい、みたいな絞り込みは hook script の中で transcript を読んで自分で判定する必要があります。

一方 StopFailureerror_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要件を StopStopFailure の同時設定で組みます。

ステップ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_typeerror_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分単位で待ち時間が積み上がる。StopFailurematcher: "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 側が「短時間に呼び過ぎ」と判断して一時的に拒否してくる状態

関連項目

公式ドキュメント

https://code.claude.com/docs/en/hooks

-

← 戻る