先に結論です。hook は、Claude Code とあなたのスクリプトの間の契約で、面は 3 つあります。 いつ走るか(イベントと matcher)、何を受け取るか(stdin の JSON)、何を返すか(exit code と stdout・stderr)。そして、返したものがどこへ届くかは、イベントごとに決まっています。手元の hook 11 本が使っている契約はごく一部で、exit 2 と stderr、decision: "block"reasonUserPromptSubmit の平文 stdout、stop_hook_active の 4 つでほぼ全部です。本編が「止めて案内する」「保存の後に突きつける」「一度だけ引き戻す」と書いたことは、この 4 つの契約が、そのイベントでどこへ届くかの話です。

付録 A.2 は、1 ターンの中で hook が割り込む位置を図にしました。A.4 は、hook の stdout が会話層に入るイベントが限られていることを表にしました。この章は契約の全体を並べます。33 種のイベントのどれが止められるか、exit 2 の stderr が AI に届くイベントと、あなたにしか届かないイベント、JSON の書き方がイベントで違う理由、状態をどこに持つか、案内文に何を書くか、hook 自身をどう自動テストで守るか。読み終えたら、本編の 9 本が「なぜその exit code で、なぜそのイベントで」動いているかを、契約の側から言い直せるはずです。

C.1 契約の 3 つの面

flowchart LR
  E["イベントが起きる<br/>33 種"] --> M{"matcher<br/>tool_name など"}
  M -- 一致しない --> S["何もしない"]
  M -- 一致 --> I{"if<br/>Bash(git *) など"}
  I -- 一致 --> H["handler<br/>stdin ← JSON"]
  H --> X["exit code<br/>+ stdout + stderr"]
  X --> D{"イベントごとの行き先"}
  D --> C1["AI に届く<br/>deny の理由 / block の reason / additionalContext"]
  D --> C2["あなたに届く<br/>systemMessage / 一部の stderr"]
  D --> C3["デバッグログだけ<br/>exit 0 の stdout と stderr(多くのイベント)"]

hook はモデルの外で動きます。Claude Code がイベントのたびに handler を起動し、JSON を stdin に流し、終了を待って、exit code と出力を読みます。モデルは hook を呼びません。呼ばれたことも、handler の中で何が起きたかも知らず、届いた文字列だけを読みます。第 1 部 第 6 回の「読まれなくても落ちる」は、この構造の言い換えです。

イベントは 33 種あります(2026-09-05、Claude Code 2.1.260)。本編が使ったのは 6 種で、残りは本編の外です。exit 2 で止められるかどうかを列にして並べます。

いつイベントexit 2 で止められるか本編
セッションSessionStart / Setup / SessionEndいいえ(stderr はあなたにだけ)SessionStart(第 1 部 第 7 回の基準の撮り直し)
ターンUserPromptSubmit / UserPromptExpansionはい(プロンプトを消す)UserPromptSubmit(メモリの索引を流す)
ターンStop / StopFailureStop は はい(続けさせる)。StopFailure は出力ごと無視Stop(一度だけ引き戻す)
ツール呼び出しPreToolUseはい(ツールを走らせない)入口の hook 4 本
ツール呼び出しPermissionRequest / PermissionDeniedいいえ
ツール呼び出しPostToolUse / PostToolUseFailureいいえ(ツールは済んでいる。stderr は AI に届く)PostToolUse(保存後の差分)
ツール呼び出しPostToolBatchはい(次のモデル呼び出しの前で止める)
サブエージェント・タスクSubagentStart / SubagentStopSubagentStop だけ はいSubagentStop(完了の目印)
サブエージェント・タスクTaskCreated / TaskCompleted / TeammateIdleはい
環境InstructionsLoaded / ConfigChange / CwdChanged / DirectoryAdded / FileChangedConfigChange だけ はい
環境WorktreeCreate / WorktreeRemove / PreCompact / PostCompactWorktreeCreate(0 以外全部)と PreCompact は はい
環境PreModelSwitch / PostModelSwitchPreModelSwitch だけ はい
表示・MCPNotification / MessageDisplay / Elicitation / ElicitationResultElicitation 系だけ はい

表の読み方を 1 つ。止められるのは「まだ起きていないこと」だけです。PreToolUse はツールがまだ走っていないので止められ、PostToolUse は済んでいるので止められません。第 1 部 第 7 回が保存の「後」に置いた hook は、止める hook ではなく読ませる hook です。止められないイベントで exit 2 を返しても、ツールは戻りません。戻るのは stderr だけで、それが AI に届くか、あなたにしか届かないかは、次の C.3 の表で分かれます。

C.2 入力 —— stdin に何が載っているか

handler は stdin から JSON を 1 つ受け取ります。全イベントに共通の欄と、イベントごとの欄があります。

何か本編での使い道
session_idセッションの識別子起動の記録をセッションごとに区切る(第 1 部 第 8 回の状態ファイル)
prompt_idいま処理しているプロンプトの UUID(v2.1.196 以降)
transcript_path会話の JSONL。非同期に書かれるので、hook が走った時点で最新の発言が無いことがある
cwdhook が呼ばれた時点の作業ディレクトリ。worktree に入ると変わる
permission_modedefault / plan / acceptEdits / auto / dontAsk / bypassPermissions
effort{ "level": "..." }。ツール呼び出しの文脈で走るイベントだけ
hook_event_name走らせたイベントの名前同じスクリプトを PreToolUseSubagentStop に登録して分岐する(第 1 部 第 5 回)
agent_id / agent_typeサブエージェントの中で hook が走ったときだけ agent_id が付くサブエージェントからのテスト実行を止める(「サブエージェントにテストを走らせない」)

ツール系のイベント(PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest / PermissionDenied)は tool_nametool_inputtool_use_id を持ちます。tool_input の中身はツールごとに違い、本編の hook が読んだのは 2 種類です。

ツールtool_input の欄本編の hook が読んだもの
Bashcommand / description / timeout / run_in_backgroundcommand の文字列(入口の hook 3 本)
Agentprompt / description / subagent_type / model(+ 手元の版では name全部(用途カタログの hook)
Write / Edit / Readfile_path常に絶対パス~ も相対も展開済み)
AskUserQuestionquestions の配列—(C.11 で触れる)

PostToolUse にはさらに tool_response(ツールが返したもの)と duration_ms が付きます。Agent ツールが前景で完了したときの tool_response には、サブエージェントの最終テキストと、totalTokenstotalToolUseCountresolvedModel などの数が入ります。第 2 部 第 4 回の「ツール呼び出し 68〜97 回」は、この経路で数えられる数です。

Stopstop_hook_activelast_assistant_messagebackground_taskssession_crons を持ちます。stop_hook_active は「いま続いているのは Stop hook が続けさせたからだ」という目印で、true なら hook は退くべきです。last_assistant_message は AI の最後の応答の本文で、transcript を読むより確実です。SubagentStop は同じ 4 つに加えて agent_idagent_typeagent_transcript_path を持ちます。UserPromptSubmitprompt(あなたが打った文字列)を、SessionStartsourcestartup / resume / clear / compact / fork)を持ちます。

本編の hook が JSON をどう読んでいるかは、1 行で足りています。

command=$(cat | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))')

jq でも同じですが、hook を配る先に jq があるとは限らないので、私は python3 で読んでいます。JSON が壊れていたら空文字を返して exit 0 に落とす(|| exit 0)のも、本編の hook 全部に共通の形です。hook 自身の事故で作業を止めないためです。

C.3 出力 —— exit code と、stdout・stderr の行き先

exit code は 3 通りに読まれます。

exit code意味stdoutstderr
0異議なし。JSON で細かく制御するときもこの code{ で始まり } で終われば JSON として読む。平文なら、UserPromptSubmit / UserPromptExpansion / SessionStart / PostModelSwitch の 4 つでだけ会話層に足す。他のイベントではデバッグログ行きデバッグログだけ。AI は見ない
2止める。JSON の allow でも覆せない唯一の code有効な JSON なら読む止めた理由として使う。届く先はイベントごと(下の表)
それ以外(1、127 など)止めない。非ブロッキングのエラー有効な JSON なら code を無視して JSON だけで決める。平文か空なら、action は進み、transcript に <hook 名> hook error と stderr の 1 行目が出る1 行目だけ通知に出る

exit 1 は止めません。 Unix の慣習では失敗ですが、hook の契約では非ブロッキングです。方針を守らせる hook は exit 2 でなければ、方針は守られません。公式ドキュメントはこれを Warning の枠で書いています。

起動できなかった hook も止めません。 settings.json のパスを打ち間違えると、シェルが 127 で終わり、Failed with non-blocking status code: /bin/sh: ...: No such file or directory の通知が 1 度出て、ツールはそのまま走ります。公式は「方針の hook を置いたら最初の 1 回でこの通知を見張れ。タイプミス 1 つで門は黙って開いたままになる」と書いています。第 1 部 第 4 回の「止める仕組みは、止まらない方向に壊れる」は、比喩ではなく契約の仕様です。

timeout も止めません。 command hook の既定は 600 秒で、UserPromptSubmit では 30 秒に下がります。時間切れの hook は出力ごと捨てられ、PreToolUse ではツールが通常の permission の流れへ進みます。止める hook を重くしてはいけない理由がここにあります。

exit 2 の stderr がどこへ届くかは、イベントごとに 4 つに分かれます。

届く先イベント
AI に届く(止めた理由として)PreToolUse(ツールは走らない)/ StopSubagentStop(続ける理由として)/ TaskCreatedTaskCompletedTeammateIdle
AI に届く(止められないが、読ませる)PostToolUse / PostToolUseFailure(ツールは済んでいる)
あなたにだけ届くUserPromptSubmit(プロンプトは消える)/ SessionStart / SubagentStart / SessionEnd / CwdChanged / FileChanged / PostCompact / PreModelSwitch / PostModelSwitch
無視されるPermissionRequest / PermissionDenied / StopFailure / Notification / Setup / InstructionsLoaded / MessageDisplayDirectoryAddedWorktreeRemove はデバッグログ)

本編の入口の hook 4 本が exit 2 と stderr だけで書けているのは、PreToolUse が 1 行目にいるからです。第 1 部 第 7 回の PostToolUse は 2 行目にいて、exit 2 の stderr でも AI に届くので、JSON は必須ではありませんでした。JSON を選んだ理由は C.4 に書きます。UserPromptSubmitexit 2 を返すとプロンプトが消え、理由はあなたにしか届きません。3 行目のイベントに「AI への案内」を書いても、AI は読みません。

出力には上限があります。hook の出力文字列は 10,000 字までで、additionalContextsystemMessage、平文 stdout の全部に同じ上限が効きます。超えた分はファイルに退避され、AI には先頭の抜粋とファイルのパスが渡ります。付録 A.5 のツール出力と同じ扱いです。この上限に、私の hook が 1 本掛かっていました(C.11)。

C.4 JSON —— decision の書き方は、イベントで違う

exit code は「止める」か「黙る」かの 2 択です。それより細かい制御は、exit 0 で JSON を stdout に出します。書き方は 1 つではなく、イベントが 3 つの型に分かれています。

イベント鍵になる欄
トップレベルの decisionUserPromptSubmit / PostToolUse / PostToolUseFailure / PostToolBatch / Stop / SubagentStop / ConfigChange / PreCompact"decision": "block"reason。値は block だけで、通すなら欄を省く
hookSpecificOutput.permissionDecisionPreToolUse / PreModelSwitchallow / deny / ask / deferpermissionDecisionReasonupdatedInputadditionalContext
hookSpecificOutput の個別の欄PermissionRequestdecision.behavior)/ PermissionDeniedretry)/ SessionStartSubagentStartadditionalContext だけ。止められない)

どの型でも使える欄が 4 つあります。continue: false は AI を完全に止め、stopReason をあなたに見せます。systemMessageあなたに見せる警告で、AI には届きません。suppressOutput は受け付けられますが、何もしません。terminalSequence は端末への通知です。第 1 部 第 7 回の「画面向けの通知はモデルに届かない」は、systemMessage のことで、公式の記述と私の実測(2026-08-28)が一致しています。

additionalContext は、文字列を system reminder として会話に差し込む欄です。差し込まれる位置はイベントで決まっています。SessionStartSubagentStart は会話の先頭、UserPromptSubmit は打ったプロンプトの隣、PreToolUse / PostToolUse はツール結果の隣、Stop はターンの末尾です。複数の hook が返せば全部届き、10,000 字を超えた分はファイルに退避されます。公式は書き方についても線を引いています。命令文ではなく事実の文で書け。「デプロイ先は production です」は文脈として読まれ、「system: 直ちに〜せよ」の形は prompt injection の防御に掛かって、AI が文面をあなたに見せに来ます。

イベントごとに、本編が使った欄を確かめます。

PreToolUse —— deny はツールを走らせず、permissionDecisionReasonAI に渡します。allowask の理由はあなたにだけ見えます。allow は permission のプロンプトを飛ばしますが、settings の deny ルールは飛ばせません。ask は auto モードでもプロンプトを強制します。updatedInput はツールの入力を丸ごと置き換えます(変えない欄も含めて返す)。複数の hook が違う答えを返したら、deny > defer > ask > allow の順に厳しいほうが勝ちます。exit 2 の stderr は deny の理由と同じ経路です。かつてトップレベルの decision: "approve" | "block" でしたが、このイベントでは非推奨で、allow / deny に読み替えられます。

PostToolUse —— "decision": "block" は、ツール結果の隣に reason を足します。AI は元の出力もそのまま見ます。 出力を差し替えたいなら updatedToolOutput(ツールの出力の形に合わせる。Bash なら stdout / stderr / interrupted / isImage)。第 1 部 第 7 回の hook が decision: block + reason + systemMessage の 3 つを返しているのは、AI に読ませる文(reason)と、あなたに見せる 1 行(systemMessage)を 1 つの出力で分けるためです。exit 2 + stderr でも AI には届きます。

Stop / SubagentStop —— "decision": "block" は止めさせず、reason(必須)を「続ける理由」として AI に渡します。hookSpecificOutput.additionalContext でも続けさせられ、こちらは transcript に「hook feedback」として出て、エラーの通知が出ません。どちらも同じループ防止が効きます。stop_hook_active と、8 回連続で止めたら Claude Code が hook を無視してターンを終える上限です(CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変えられる)。

UserPromptSubmit —— 平文 stdout と additionalContext のどちらでも文脈を足せます。どちらも transcript には見えない system reminder で、hook の名前から始まります。decision: block はプロンプトを処理させず、会話から消しreason はあなたに見えます。sessionTitle でセッションに名前を付けられます。

SessionStart —— 平文 stdout が会話層に足されます。additionalContextinitialUserMessage-p で最初のターンを作る)、watchPathsFileChanged の監視対象)、reloadSkills(hook が置いたスキルを同じセッションで使う)。

SubagentStart —— 止められませんが、additionalContextサブエージェントの会話の先頭に差し込めます。付録 B.5 の「載らないもの」を、hook で載せる経路です。親の MEMORY.md の索引をここで流せば、サブエージェントも索引を持って動きます。私は置いていません。用途カタログの requires で渡すものを縛るほうを選んだので、載せるものを増やす経路は作らなかった、というのが理由です(付録 E)。

C.5 matcher・if・置き場所・種類

matcher は、含む文字で評価の仕方が変わります。英数字と _ - 空白 , | だけなら完全一致(|, で並べれば OR)、それ以外の文字を含めば JavaScript の正規表現で、アンカー無しです。Edit.*NotebookEdit にも当たります。"""*" か省略なら全部に当たります。matcher が何を見るかはイベントで違い、ツール系は tool_nameSessionStartsourceSubagentStart / SubagentStopagent_typePreCompactmanual / autoUserPromptSubmitStopPostToolBatch などは matcher を持たず、書いても黙って無視されます。

if は handler ごとの絞り込みで、permission ルールの構文(Bash(git *)Edit(*.ts))を使います。ツール系のイベントでだけ効き、他のイベントに書くと その handler は一度も走りません。Bash の if は best-effort です。先頭の VAR=value は剥がしてから照合し、&& で繋いだ各コマンドと $() の中身も見ますが、$TOOL git push のようにコマンド名が展開しないと分からない形では照合できないので走らせる側に寄ります。公式は「硬い allow / deny は hook ではなく permission システムで」と書いています。本編の入口の hook が command の文字列を自分で grep しているのは、if を使っていないからで、grep も同じ best-effort です(C.11)。

置き場所は 7 つあります。

場所効く範囲配れるか
~/.claude/settings.jsonあなたの全プロジェクトいいえ
.claude/settings.jsonそのプロジェクトはい。コミットしてチームに配る
.claude/settings.local.jsonそのプロジェクトいいえ(gitignore)
管理者の managed settings組織全体はい
プラグインの hooks/hooks.jsonプラグインが有効な間はい
スキルの frontmatter hooks:呼ばれてからセッションの終わりまでonce: true で最初の成功で外れるはい
サブエージェントの frontmatter hooks:そのサブエージェントが走っている間。StopSubagentStop に読み替えはい

同じ handler が複数の settings に書かれていれば 1 回だけ走ります。プラグインとスキルの写しは別に走ります。settings の hook は、サブエージェントの中でも走ります。 サブエージェントが Bash を呼べば、同じ PreToolUse の hook が同じ 3 本走り、入力に agent_id が付きます。「サブエージェントにテストを走らせない」は、この性質が無ければ hook では書けません。

種類は 5 つ。command(シェル)、http(POST)、mcp_toolprompt(Claude モデルに 1 回問う。既定は Haiku)、agent(ツールを持つサブエージェントに検証させる。実験的)。本編は全部 command です。promptagent{ "ok": true | false, "reason": ... } を返す型で、Stop で「全部終わったか」をモデルに判定させる例が公式にあります。第 1 部 第 12 回が「問いを手順に組み込まない」と言った案は、契約の上ではこの prompt hook で書けます(C.11)。

command hook には exec form と shell form があります。args を書くと exec form で、シェルを通さず command を直接 spawn します。書かなければ shell form で、sh -c に渡されます。${CLAUDE_PROJECT_DIR} のような置き場所の変数を使う hook は exec form か、shell form なら二重引用符で囲むのが公式の推奨です。私の settings.json は shell form で引用符を付けていません。パスに空白が無いので動いていますが、推奨の形ではありません。

並列です。一致した hook は全部並列に走り、全部の完了を待ってから結果を合成します。deny を返した hook が隣の hook を止めることはなく、隣の hook の副作用(ログへの追記など)は起きます。PreToolUse の permission の答えは厳しいほうが勝ち、additionalContext は全部届き、updatedInput最後に終わった hook のものが効く(順序は不定なので、同じツールの入力を 2 本の hook で書き換えてはいけない)。

async: true は背景で走らせ、decision などの制御は効きません。結果の additionalContextsystemMessage は次のターンに届き、asyncRewake なら exit 2 で AI を起こせます。

信頼の線引きを 1 つ。対話セッションでは、フォルダの workspace trust を受け入れるまで settings の hook は走りません。-p と SDK ではダイアログを出さずに信頼済みとして扱うので、リポジトリの .claude/settings.json に書かれた hook は、あなたが一度も信頼していないフォルダでも走ります。他人のリポジトリで claude -p を回す前に .claude/ を読むか、--settings '{"disableAllHooks": true}' を付けるのが公式の助言です。hook を配る側から言えば、.claude/settings.json の hook は、clone した全員のマシンで、その人の権限で走るということです。

C.6 手元の 11 本

本編の 3 作で置いた hook を、2026-09-05 の .claude/settings.json の形で並べます。契約のどの欄を読み、どの経路で返しているかの一覧です。

#イベントmatcherhandler読むもの返すもの
1UserPromptSubmitcat .../memory/MEMORY.md(stdin は読まない)平文 stdout → 会話層。⚠️ 10,000 字の上限に注意(C.11)
2PreToolUseBashblock_raw_rails_test.shtool_input.commandagent_idexit 2 + stderr。逃げ道 SKIP_TEST_GUARD=1
3PreToolUseBashrequire_git_hooks_installed.shtool_input.commandgit rev-parse --git-path hooksexit 2 + stderr。逃げ道 --no-verify / SKIP_HOOK_CHECK=1
4PreToolUseBashrequire_visual_verification_declaration.shtool_input.commandtmp/visual_verification.md の存在・Q1 / Q2・禁句・mtimeexit 2 + stderr。判定を tsv に 1 行追記。逃げ道 SKIP_VISUAL_GUARD=1
5PreToolUseAgentSendMessageguard_subagent_launch.pytool_nametool_input.prompt / name / model / subagent_type / tosession_id、用途カタログ、起動の記録exit 2 + stderr。通したら記録に 1 行
6PostToolUseWrite Edit Bash の 3 つaudit_memory_write.py実ファイルとスナップショットの差(stdin は stop_hook_active 以外読まない)JSON decision: block + reason + systemMessage。未報告の差分を pending.json に積む
7PostToolUseBashrequire_verbatim_numbers.pytool_response の中の文字列(印の付いた行だけ)出力なし。観測の行を積むだけ
8SubagentStopguard_subagent_launch.pyhook_event_namesession_id出力なし。起動の記録に完了の目印
9SessionStartaudit_memory_write.py --prime出力なし。スナップショットを撮り直し、pending.json を消す
10Stopaudit_memory_write.py --stopstop_hook_activepending.jsonblocked.flagJSON decision: block + reason一度だけ
11Stoprequire_verbatim_numbers.py --stoplast_assistant_message、積んだ観測の行JSON decision: block + reason一度だけ。行が逐語で入っていなければ引き戻す

11 本のうち、7 と 8 と 9 は状態を進めるだけで AI に何も返しません。1 は文脈を流すだけで止めません。入口で止めるのは 2〜5 の 4 本で、6 と 10 と 11 は止めずに読ませます。止める 4 本は、全部「command の文字列」か「ファイルの実在」か「記録の数」で決めていて、AI の言葉を解釈する行がありません。第 2 部 第 4 回の表で「回避できない」に目印が付いた判定は、ファイルの実在と model の許可リストで、どちらも入力の欄をそのまま見ています。

C.7 状態の持ち方 —— hook は毎回、新しいプロセス

handler は呼ばれるたびに新しいプロセスとして起動し、終われば消えます。前回の呼び出しで見たものは覚えていません。「同じ名前で 2 度目の起動」「保存の前と後の差」「一度だけ引き戻す」は、どれも 1 回の呼び出しの中では判定できず、disk に状態を持って初めて書けます。本編の hook が持っている状態は 4 つです。

状態置き場所区切り何のため
起動の記録tmp/subagent_launches.tsvsession_id ごとに行を持ち、読むときも自分の session_id の行だけ同名の再起動・並列数・総数(第 1 部 第 5・8 回)
メモリのスナップショット~/.claude/.memory-audit/<path の sha256>メモリのファイルごと保存後の差分(第 1 部 第 7 回)
未報告の差分同じディレクトリの pending.jsonセッションをまたがない(SessionStart--prime で消す)終わり際の引き戻し
引き戻した目印同じディレクトリの blocked.flag同上引き戻しを一度だけにする

置き場所で 2 度、踏みました。1 度目は TMPDIR です。macOS では対話シェルの TMPDIR/var/folders/... で、hook のプロセスの環境と食い違い、基準を作ったつもりのディレクトリと hook が読むディレクトリが別になって、状態が 2 か所に割れて黙って壊れました(2026-08-28)。hook の環境は「親の環境を継いだもの」で、あなたの端末の環境と同じとは限りません。状態の置き場所は $HOME からの固定パスにしています。2 度目は監視対象との重なりで、スナップショットを memory の glob(~/.claude/projects/*/memory/*.md)の中に置くと、スナップショット自身が差分に出ます。監視対象の外に置くのは、第 1 部 第 7 回のコードの # ⚠️ 監視対象の外に置く の 1 行です。

session_id で区切る理由は、同じディレクトリで 2 セッションが並列実行するからです。起動の記録を 1 ファイルで共有しても、行に session_id があれば互いの起動を数えません。逆に、区切らないと前のセッションの起動が残っていて、正当な起動を「再起動」として止めます。第 1 部 第 8 回の「状態ファイルがセッションをまたいで残ると偽陽性」がこれで、session_id は共通の欄なので、どのイベントでも同じ鍵が使えます。メモリのスナップショットは session_id で区切っていません。メモリは複数のセッションが同じファイルを書くので、差分に別のセッションの書き込みが混ざるのは仕様として受け入れ、案内文に「自分が書いたものでなければそう言え」を入れています。

完了の信号は自己申告ではなく、Claude Code が出すイベントで取ります。並列数を「起動した時刻の窓」で数えると、10 分で終わった 1 体が 45 分ぶん枠を塞ぎます。SubagentStop で記録に完了の目印を付ければ「まだ走っている数」で数えられ、信号を取りこぼしたときの保険として時間の窓を残しています。取りこぼすと止まる側に寄る、という向きです。

一度だけは、2 つの目印の OR で書いています。自分で立てた blocked.flag と、Claude Code が入れてくる stop_hook_active。どちらかが立っていれば退きます。公式の 8 回の上限だけに頼ると、8 ターンぶん引き戻してから終わることになり、第 1 部 第 7 回が「毎回止めるとセッションが終われなくなる」と書いた状態が 8 回まで続きます。stop_hook_active は「hook が続けさせた直後」の目印なので、hook が引き戻した次のターンで true になります。blocked.flag は、その前に自分で確かめる目印です。

C.8 案内文の設計 —— 止めた画面に、次の一手を書く

PreToolUsedeny で AI が受け取るのは、理由の文字列だけです。ツール呼び出しは失敗として返り、AI は次の応答をその文字列から組み立てます(付録 A.2)。案内文が hook の本体だと言ってよく、入口で止める hook(C.6 の 2〜5)の stderr は、同じ 5 つの要素でできています。

  1. 何を止めたか、1〜2 行。 「素の rails test は使わないでください」。理由は 2〜3 行まで(数字と日付があると、AI は一般論ではなく個別の事故として読みます)
  2. 置き換え先。 止めた操作の代わりに何を打つか、コマンドの形で。scripts/rails_test.sh <同じ引数>。宣言 hook が足したのは、TODO の書き換え先まで書くことでした。「撮影で確認」を「grep / 単体テストで確認」に書き換えて消化する。置き換え先の無い禁止は、止めた瞬間に検証ごと放棄されます
  3. 逃げ道と、その条件。 SKIP_TEST_GUARD=1--no-verify。案内した逃げ道は hook が必ず通します。条件を添えるのは、逃げ道を「先を急ぐ AI が使う」形にしないためで、テストの並走ロックは PID の生存を確かめてから消し方を出します
  4. 偽陽性の出口。 「このメッセージはコマンド文字列に含まれているだけでも出ます(ドキュメントを cat したときなど)。その場合はファイル編集ツールで扱ってください」。文字列の grep は必ず誤作動するので、誤作動したときに読める場所に出口を書いておきます。宣言 hook で私自身が止められた話がこれです
  5. 短く。 案内文は全部が会話層に入り、10,000 字で切られます。手元の案内文は、長いもので 20 行あまりです

ask の理由はあなたにしか届かないので、AI への案内は denyexit 2 にだけ書きます。PostToolUsereason は「ツールは成功している」ことを先に書きます。書かないと AI は失敗と読み、同じ保存をやり直します。第 1 部 第 7 回の案内文の 2 行目「保存は済んでいます」は、そのための行です。

案内文を hook に直書きせず cat する形(第 1 部 第 8 回)は、方針の更新を hook に触らずに済ませるためです。ただし cat する先も 10,000 字の上限の内側です。

C.9 hook を自動テストで検証する

hook は自動テストの外側にいるので、テストスイートは hook の壊れを教えてくれません。hook 自身の自動テストは、手元に 4 本あります(scripts/tests/test_visual_verification_guard.shtest_subagent_guard.shtest_memory_audit.sh.claude/hooks/audit_memory_write_test.py)。型は 1 つです。

stdin の JSON を偽造して、exit code と stderr の両方を見る。 hook は stdin と環境変数と disk しか見ないので、Claude Code 無しで走らせられます。

run() { printf '{"tool_input":{"command":"%s"}}' "$1" | bash "$HOOK" 2>"$WORK/err.txt"; }
run "ls -la";                                 check "非対象は通る" 0 $?
run "bash scripts/screenshot.sh /";           check "宣言なしは止まる" 2 $? "書き換えて"
write_decl "SP 表示の崩れの検証" "computed style は実描画でしか分からないため"
run "bash scripts/screenshot.sh /";           check "正しい宣言は通る(陽性対照)" 0 $?
run "SKIP_VISUAL_GUARD=1 bash scripts/screenshot.sh /"; check "逃げ道は通る" 0 $?

急所は 4 つです。止まった理由の文字列まで見るcheck の 4 つ目の引数)。exit 2 だけ見ると、別の理由で止まった hook が合格になり、狙った判定が空回りしていることに気づけません。陽性対照を必ず併置する。止まることしか確かめない自動テストは、hook が全部を止める壊れ方を映せません。逃げ道が通ることも確かめる。案内した逃げ道が通らない hook は、誤作動の日にガードレールごと壊されます。変異注入を 1 本入れるsed で禁句の正規表現を潰した写しを作り、その写しでは素通りすることを見る。これで「その行を見ている」ことが確かめられます。宣言 hook で、この自動テストが hook 自身の bash のバイト数の bug を先に見つけました。

環境は CLAUDE_PROJECT_DIRHOME で差し替えます。hook は ${CLAUDE_PROJECT_DIR:-$(pwd)} で root を決めるので、自動テストは mktemp -d の砂場を root にして、用途カタログの写しと空の requires を置いて走らせます。メモリの監査は HOME を偽の木に向けて、本物のメモリに触れずに走らせます。Stop の hook は {"stop_hook_active": true} を流して退くことを、{} を流して引き戻すことを、両方見ます。

自動テストが見られないものが 1 つあります。settings.json の配線です。パスの打ち間違いは hook を 127 で落とし、C.3 のとおり止めません。hook の自動テストは hook のファイルを直接呼ぶので、配線の間違いは通ります。配線は別に見ます(C.12 の 5)。

C.10 本編との対応

本編契約の側から言い直すと
第 1 部 第 1・4 回「入口の hook」PreToolUse + exit 2。stderr は AI に「止めた理由」として届き、ツールは走らない。案内文が hook の本体
第 1 部 第 5 回・第 2 部 第 4 回「用途カタログ」Agent ツールの tool_inputprompt / model / subagent_type)を読む PreToolUseSubagentStop で完了を取る
第 1 部 第 7 回「保存の後に突きつける」PostToolUse は止められない。decision: blockreason はツール結果の隣に届き、元の出力も残る。systemMessage は AI に届かない
第 1 部 第 8 回「瞬間に差し込む」状態は disk、session_id で区切る。deny の理由に方針を cat する
第 1 部 第 7 回・第 12 回「一度だけ引き戻す」Stopdecision: block + stop_hook_active + 8 回の上限
テストの並走ロックと宣言 hook(本書の章には無い。次の連載で扱う)settings の hook はサブエージェントの中でも走り、agent_id で区別できる。hook 自身の自動テストは stdin の偽造 + 陽性対照 + 変異注入

C.11 本編が言っていない注意

hook の平文 stdout は、10,000 字で切られます。 超えた分はファイルに退避され、AI に届くのは「Output too large … Full output saved to: …」の 1 行と、先頭 2KB の抜粋と、退避先のパスだけです。残りは AI が退避先を自分で開かない限り読まれません。同じ退避は、ツールの出力にも起きます —— PostToolUsetool_response に載るのは先頭の抜粋だけで、末尾に書いた行はそこにありません。⚠️ 出力の末尾を見る hook は、退避先のパスから辿らないと空振りします(第 1 部 第 10 回の require_verbatim_numbers.py は「saved to: の行にパスがあれば開く」を持っています)。⚠️ もう 1 段あります —— 実行ラッパーが画面に要約だけを出力する作りなら、その要約に載らない行は退避以前に消えます。数えることと、数えた行が人に届くことは別です。auto memory の索引が 200 行 / 25KB で切られる話(付録 D)とは別の上限で、UserPromptSubmit で索引を流す構成に効きます。私の索引(87 行・27,908 字)がこれに当たっていて、付録 B.8 で「毎ターン索引を流している」と書いた実態は先頭の十数行でした(この章を書いた 2026-09-05 に気づきました)。守りは 2 つ。索引を 10,000 字の内側に保つか、hook で流すのをやめて auto memory の経路(autoMemoryEnabled)に戻すか。(2026-09-11 追記)3 つ目を取りました。 全文を流すのをやめ、hook が出すのは行動ルールの節だけにして、残りは「作業に入る前に Read すること」とパスを言わせています。そして末尾に目印の 1 行を置きました —— 切られたことは、切られた側からは見えません。目印が無ければ、届いていないと分かります。

その断り書きは、PostToolUsetool_response には入っていません。 退避の 1 行(「Output too large … saved to: …」)は hook の後にモデル向けへ付くもので、hook が受け取るのは {"stdout": …, "stderr": …} の形です。⚠️ だから「切られたか」を文字列で判定する hook は、一度も鳴りません。2026-09-11 に置いた require_full_read.py がこれでした —— 自己検査は緑のまま、本物の経路で 0 回。payload をダンプして初めて分かりました。hook の側から判定できるのは大きさだけです(len(stdout) が表示の上限に届いていれば、モデルが見るのは抜粋だと分かる)。⚠️ ここは第 1 部 第 3 回の「落ちた変異は何も保証しない」と同じ形で、検査が本物の入力の形を見ていなければ、緑は何も意味しません

SessionStart は、圧縮のたびにも走ります。 matcher を付けなければ startup / resume / clear / compact / fork の全部で走り、私の --primepending.json を消します。つまり、未報告のメモリの差分を抱えたまま自動圧縮が走ると、終わり際の引き戻し(C.7)は起きません。第 1 部 第 7 回の「一度だけ引き戻す」は、圧縮をまたぐと 0 回になります。matcher を startup|resume|clear に絞れば塞がります。これも、この章を書きながら契約の表を読んで気づいた抜け穴です。

hook は permission mode の外にいます。 PreToolUse はどの permission mode でも、bypassPermissions--dangerously-skip-permissions でも走り、deny は通ります。「自律運転にすると hook が外れる」ことはありません。逆は成り立たず、hook の allow は settings の deny ルールを飛ばせません。hook は締められますが、緩められません。本編が「hook の正しさが全体の上限になる」(第 1 部 第 9 回の波及)と書いたのは、この非対称の上に立っています。

文字列の grep は、if と同じ best-effort です。 bash -c '...'docker compose run web rails test、エイリアス、変数経由は、command の文字列を grep する hook をすり抜けます。私の hook はコメントに「正しさは L1(テストプロセス側のロック)が担保する」と書いていて、hook は一番外側の網です。第 2 部 第 3 回の「範囲の隙間に善意の迂回路が通る」は、hook の matcher にも当てはまります。matcher が Bash だけなら、Write / Edit で同じファイルを書く経路は通ります。逆に PostToolUseEdit|Write は、Bash が同じファイルを書き換えたときには走りません。第 1 部 第 7 回の hook が Write|Edit|Bash の 3 つに掛けて実ファイルを比べる形なのは、この隙間を埋めるためです。2.1.260 には FileChanged(誰が書いたかによらず、disk 上の変化で走る。止められない)があり、同じ隙間を別の向きから塞げます。

hook は、サブエージェントの 1 呼び出しごとにも走ります。 テストの並走ロックの hook がサブエージェントを止められるのは、settings の hook がサブエージェントの中でも走るからですが、同じ理由で、13 体が並列実行すれば BashPreToolUse 3 本が 13 体ぶん走ります。hook を重くすれば、サブエージェントの 1 手ごとに遅れが乗ります。

Stop hook は、人の一言と同じ位置には立ちません。 第 1 部 第 12 回の「不足点はないですか」を Stop hook で機械的に出す案を、本編は推測のまま退けました。契約の側から見えることを 3 つ足します。Stop は「AI が応答を終えるたび」に走り、作業の完了で走るのではありません。人が「終わり」と判定してから打つ一言と違い、短い答えのたびに問いが刺さります。届く形も違い、hook の reasonadditionalContext はターンの末尾に足される文字列で、人の一言は新しい user ターンです(付録 A.4 の部分の違い。重みの違いは付録 I)。一方で、Stop hook にしかできないことがあります。last_assistant_message を読んで、完了報告の中の「確認しました」の類を機械で照合できること、git diff --stat HEADgit status --porcelaintmp/test_last.log の件数を reason数字のまま書けることです。第 1 部 第 9 回の観測点は、Stop hook で出せば AI の要約を経由しません。私は置いていません。問いの代わりではなく、数字の運び手としてなら置く価値があると考えています。

AskUserQuestion はツール呼び出しで、permission prompt は別物です。 第 3 部 第 10 回の「AI から飛んでくる問い」は、Claude Code では AskUserQuestion ツールの呼び出しで、PreToolUsetool_input.questions と一緒に走ります。契約の上では、hook がこの呼び出しを deny して理由を返す(「引き受け先を先に見よ」の一覧を cat する)ことも、-pupdatedInputanswers を入れて答えることも、defer で呼び出し元に渡すこともできます。permission prompt は別の経路で、PermissionRequest が走り、auto モードの拒否は PermissionDeniedretry で返せます。hook の ask は auto モードでもプロンプトを強制します。第 3 部 第 10 回が「置くものはありません」と書いた回なので、私はどれも置いていません。

UserPromptSubmitexit 2 は、AI に届きません。 プロンプトが消え、理由はあなたに見えるだけです。打った言葉を機械で検閲する hook は書けますが、AI に何かを教える hook にはなりません。

resume は hook を走らせ直しません。 PostToolUseUserPromptSubmit が足した文脈は transcript に保存され、--resume では保存された文字列が再生されます。hook が出した日時や commit の SHA は古いままです。SessionStart だけが source: resume で走り直します。transcript の側では、hook の出力は attachment の行("type": "hook_success"hookNamecontent)として残ります。この章の 10,000 字の観測も、その行から取りました。

バージョン依存。 上の挙動は 2026-09-05 に Claude Code 2.1.260 のドキュメントと手元で確認したものです。exit 2 と不正な JSON の組み合わせが止めるようになったのは v2.1.214、exit 0 以外での JSON の解析エラーが通知されるのは v2.1.248、matcher のハイフンが完全一致になったのは v2.1.195、ifEdit(src/**) の意味が変わったのは v2.1.214 です。10,000 字の上限、8 回の上限、既定の timeout も版で変わりうる値です。

C.12 検証手順

止まるべきものが止まり、通るべきものが通る。両方向を 1 度ずつ確かめます。

1. exit code の 3 通りを対照する。 環境変数で exit code を変える hook を 1 本置き、BashPreToolUse に掛けます。

mkdir -p .claude/hooks
cat > .claude/hooks/probe_exit.sh <<'EOF'
#!/bin/bash
cat >/dev/null                                  # stdin の JSON は読み捨てる
echo "MARKER_${1:-EXIT}_${PROBE_EXIT:-0}" >&2   # $1 = 登録ごとの印、PROBE_EXIT = 返す exit code
exit "${PROBE_EXIT:-0}"
EOF
chmod +x .claude/hooks/probe_exit.sh

settings.jsonPreToolUse{"matcher": "Bash", "hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/probe_exit.sh PRE"}]} を足し、PROBE_EXIT=0 claudePROBE_EXIT=1 claudePROBE_EXIT=2 claude の 3 回起動して、AI に ls を 1 回打たせ、「いま実行した ls は成功したか、止められたか。MARKER_ で始まる語が見えていれば書き出して」と頼みます。期待するのは、0 と 1 では ls が走り(1 では transcript に hook error の通知が出る)、MARKER_ は見えないこと。2 では ls が走らず、MARKER_PRE_2 を AI が書き出すことです。1 で ls が止まったら、あなたの版は exit 1 を止める側に変えています。

2. stderr の行き先を、イベントで対照する。 同じ 1 本を PostToolUseBash、引数 POST)と SessionStart(引数 START)にも登録し、手順 1 の PreToolUse の登録は外して、PROBE_EXIT=2 claude で起動して ls を打たせ、MARKER_ を全部書き出させます。期待するのは、MARKER_POST_2 は AI が書き出し、しかも ls は走っていること(止められないが、読ませる)。MARKER_START_2 はあなたの画面に hook error として出るだけで、AI は書き出さないことです。

3. 10,000 字の上限を踏む。 UserPromptSubmit に、先頭と末尾に目印を持つ 12,000 字を出す hook を掛けます。

cat > .claude/hooks/probe_cap.sh <<'EOF'
#!/bin/bash
cat >/dev/null
echo MARKER_CAP_TOP_3a1f
python3 -c 'print(("この行は上限を踏むための埋め草です。" * 10 + "\n") * 60)'   # ≈ 12,000 字
echo MARKER_CAP_END_8c4e
EOF
chmod +x .claude/hooks/probe_cap.sh

登録して 1 ターン打ち、「MARKER_CAP_ で始まる語を、見えているものだけ書き出して。退避先のファイルは開かないで」と頼みます。期待するのは、TOP だけが書き出され、END は退避先のパスと一緒に「見えていない」側に回ることです。両方出たら、あなたの版は上限が違うか、埋め草が足りていません。次に「退避先を開いて END を探して」と頼めば見つかります。届かなかったのであって、失われたのではありません。

4. stop_hook_active と 8 回の上限を踏む。 必ず止める Stop hook を置きます。

cat > .claude/hooks/probe_stop.sh <<'EOF'
#!/bin/bash
input=$(cat)
active=$(printf '%s' "$input" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("stop_hook_active", False))')
[ "$active" = "True" ] && [ -n "${PROBE_RESPECT:-}" ] && exit 0
echo "MARKER_STOP_$(date +%S): continue once more" >&2
exit 2
EOF
chmod +x .claude/hooks/probe_stop.sh

PROBE_RESPECT 無しで起動して 1 ターン打つと、AI は 8 回続けてから「Stop hook blocked too many consecutive times」の警告と一緒に終わります。PROBE_RESPECT=1 で起動すると、1 回引き戻されて終わります。2 回目の起動で 8 回続いたら、あなたの版は stop_hook_active を入れていません。

5. 配線を機械で確かめる。 hook の自動テスト(C.9)が見ない部分です。settings.jsoncommand が実在して実行可能かを、テストスイートの 1 本にします。

python3 - <<'EOF'
import json, os, shlex, sys
root = os.getcwd()
s = json.load(open(".claude/settings.json"))
bad = []
for event, groups in s.get("hooks", {}).items():
    for g in groups:
        for h in g.get("hooks", []):
            if h.get("type") != "command": continue
            first = shlex.split(h["command"].replace("$CLAUDE_PROJECT_DIR", root).replace("${CLAUDE_PROJECT_DIR}", root))[0]
            if "/" in first and not os.access(first, os.X_OK):
                bad.append(f"{event}: {h['command']}")
print("\n".join(bad) or "all hook commands exist and are executable")
sys.exit(1 if bad else 0)
EOF

陽性対照として、settings.json の 1 本のパスをわざと 1 文字変えて、上のスクリプトが落ちることと、その状態で AI に対象の操作をさせると Failed with non-blocking status code の通知と一緒に操作が通ってしまうことを見ます。門が黙って開く壊れ方を、一度は目で見ておく手順です。

6. matcher の外側を踏む。 Bash だけに掛けた PreToolUse hook が、Edit ツールで同じ結果を作る経路を通すことを見ます。

cat > .claude/hooks/probe_guard.sh <<'EOF'
#!/bin/bash
command=$(cat | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))')
case "$command" in *probe_secret.txt*) echo "MARKER_GUARD: probe_secret.txt is protected" >&2; exit 2 ;; esac
exit 0
EOF
chmod +x .claude/hooks/probe_guard.sh
echo "line 1" > probe_secret.txt

PreToolUseBash に登録し、AI に「Bash で probe_secret.txt に 1 行追記して」と頼むと止まります。次に「Edit ツールで同じファイルに 1 行追記して」と頼むと通ります。matcher が tool_name しか見ていないので、Edittool_input.file_path は誰も見ていません。逆向きも踏みます。PostToolUseEdit|Writeecho MARKER_POST_EDIT >&2; exit 2 の hook を掛け、AI に「Bash の echo >> probe_secret.txt で追記して」と頼むと、この hook は走りません。最後に FileChanged"matcher": "probe_secret.txt" で同じ echo MARKER_FILE_CHANGED を掛け、Bash と Edit のどちらで書いても走る(ただし止められない)ことを見ます。宣言 hook が command の文字列を見ている限り、塞いでいるのは Bash の入口だけです。

7. hook 自身を、砂場で壊す。 C.9 の型を、あなたの hook 1 本に当てます。stdin の JSON を偽造して exit code と stderr の文字列を見る自動テストを 4 項目(止まる・通る・逃げ道・変異)書き、hook の判定行を 1 行コメントアウトして自動テストが失敗になること、戻して合格になることを見ます。失敗にならなければ、その自動テストは hook を見ていません。

C.13 出典(2026-09-05 確認)

  • Hooks reference —— 33 種のイベント、共通の入力欄、exit code の意味、exit 2 のイベント別の表、JSON の 3 つの型、additionalContext の位置、matcher の評価、if の best-effort、置き場所 7 つ、exec form と shell form、並列と合成、async、workspace trust、10,000 字の上限、版ごとの変更(v2.1.195 / 214 / 248)
  • Automate actions with hooks —— 「LLM が選ぶことに頼らず必ず起きる」、複数 hook の合成(deny は隣を止めない)、permission mode との関係(bypassPermissions でも deny は通る)、stop_hook_active と 8 回の上限、CLAUDE_CODE_STOP_HOOK_BLOCK_CAP、shell の profile が JSON を壊す話
  • Permission modes —— auto モードで hook の ask がプロンプトを強制すること、classifier の位置
  • Tools available to Claude —— 出力の上限と退避(付録 A.5 と同じ仕組みが hook の出力にも効く)
  • Skills / Subagents —— frontmatter の hooks: の寿命、once、サブエージェントの StopSubagentStop
  • Run Claude Code programmatically —— -pAskUserQuestion が止まること、defer--resume
  • Environment variables —— CLAUDE_CODE_STOP_HOOK_BLOCK_CAPCLAUDE_CODE_SUBPROCESS_ENV_SCRUB
  • 手元の実測 —— .claude/settings.json の hook 11 本(C.6)と、transcript の attachment 行(hook_success。10,000 字の退避の観測もここから)