mcpField
C
claude-code-hooks

claude-code-hooks

github.com/daymadeエージェントワークフロー1.5k268 回インストール今日更新
リポジトリ

claude-code-hooks について

Claude Code のツール呼び出しやセッション境界でルールを適用するフックの設計スキルです。イベントや終了コードの仕様、マッチング、ループ対策を説明し、呼び出しのブロックやフィードバックに使う実装パターンを示します。

主な機能

  1. 01フックイベントと終了コードの説明
  2. 02PreToolUse ガードとマッチングの案内
  3. 03呼び出しブロックとコンテキスト注入の区別
  4. 04Stop フックのループ対策
  5. 05実行可能なフックパターン

活用シーン

  1. 01繰り返される危険なツール呼び出しの阻止
  2. 02ツール実行後の検証済みコンテキスト注入
  3. 03セッション開始時の範囲を限定したチェック

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/daymade/claude-code-skills --skill claude-code-hooks

インストール後、Claude Code で/claude-code-hooksを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
hook_patterns.md
hook_pitfalls.md
scripts/
test_hook.group-name-guard.sh
test_hook.sh

詳細

翻訳後に原文が更新されています。最新の内容は原文をご確認ください。

Claude Code Hooks

Claude Code は、ツール呼び出しの境界でフックを実行します。フックは、標準入力で JSON イベントを受け取り、ブロッキングフックの場合は終了コードによってツール呼び出しを続行するかどうかを決定するシェルコマンドです。これは、動作を構造的に止める唯一の仕組みです。CLAUDE.md に書かれた文章によるルールは提案にすぎず、完了への衝動によって覆されることがありますが、フックは壁となります。

フックが適切な場合(適切でない場合)

すでに書いてあるルールに繰り返し違反してしまう場合は、フックを作成してください。判断の目安は、文章によるルールを追加し、明確に書けているのに、同じ動作がまた起きたかどうかです。行動する瞬間には、意識のすべてが「作業を完了させること」に向いており、注意書きは後回しにされてしまうためです。繰り返しが見られたら、ルールを文章による助言から、強制力のあるフックに移す合図です。ガバナンスの経験則は次のとおりです。Tier-0 の不可逆なアクションに対して文章によるルールしかなく、フックがない場合は、フックにすべきです。 (ここでの Tier-0 とは、セッション内から元に戻せない損害をもたらすアクションを指します。未コミットの作業の破棄、リモートへのシークレットのプッシュ、ファイルの削除、外部への公開などです。基準となるのは深刻さではなく、可逆性です。)

次の場合は、フックを使わないでください。ルールに実際に違反したことが一度もない場合(仮定上のミスを防ぐ仕組みを先回りして作らないでください。効果が証明されていないのにコストがかかります)、または「ルール」が機械的に判別できない判断を伴う場合(フックで照合できるのはトークンやパターンのみであり、設計が優れているかどうかは判断できません)。

症状が「レビュー/待機/再試行を繰り返す」ことであっても、別のフックを追加すれば解決すると決めつけないでください。まずルール 7 のループ契約を完了し、落とし穴 #36を読んでください。エージェントが文章によるルールを繰り返し適用するだけで、ループが生じている可能性があります。

フックの種類と終了コードの意味

種類実行タイミング終了コード 0終了コード 2その他
PreToolUseツールの実行前JSON で allow/deny/ask が指定されていない限り、通常の権限フローに従うJSON で allow と指定されていても、呼び出しをブロックします。有効な JSON フィールドを読み取ります(v2.1.214 以降)。JSON のブロック理由または stderr → モデルスキーマに適合する JSON がない場合、その他の 0 以外の終了コードはブロックしないエラーとなり、呼び出しは続行されます。有効な JSON がある場合は、サポート対象のフィールドで動作が決まり、終了コードは無視されます。構造化制御には終了コード 0 を使用してください。公式の終了コード仕様に従ってください
PostToolUseツールの実行後stdout に hookSpecificOutput JSON を出力しない限り、何も行いません。コンテキスト注入はこの方法で行い、終了コード 0 で実行しますモデルへのフィードバック(ツールの実行を取り消すことはできません)—
SessionStartセッション開始時続行しますブロックできません。stderr にフックエラーの通知が表示されますが、Claude には見えず、セッションはそのまま開始します終了コード 0 を使用してください。0 以外の終了コードでもブロックできないからではなく、0 以外を返すたびに、セッション開始のたびにユーザーのトランスクリプトへ <hook> hook error が表示されるためです。セッションの開始方法を示す matcher を受け取ります。値は startup、resume、clear、compact、fork です
Stop(および SubagentStop)モデルが応答を終えようとしているとき停止させます停止をブロックし、モデルに続行を強制します(stderr は理由としてフィードバックされます)ループの安全性:フックは stop_hook_active をチェックします(必要ですが、十分ではありません。ルール 7 を参照してください)。ハーネスの連続ブロック上限(デフォルト 8)は、一般的な安全策ではありません。ツールを実行して続行するとカウンターがリセットされるため、ツール呼び出しを伴う修正を行うフックでは上限に達しません。そのようなフックが大半です(#27)。独自に上限を設けてください。1 つのイベントのすべての Stop フックは並列に実行されます。1 回のブロックで複数のフックのフィードバックを返せます
  • PreToolUse は ツール呼び出し を止める主力です。この表にある型は、このファイルで説明しているものであり、ブロック可能なイベントの全種類ではありません。また、公式のフックリファレンス(docs.claude.com / code.claude.com。バンドル内の references/ ファイルではありません。そちらで扱っているのは上記の型のみです)には、UserPromptSubmit、PreCompact、TeammateIdle、タスクイベントや設定イベントなど、ブロック可能なイベントがさらに多数掲載されています。制御したい対象がツール呼び出しでない場合は、無理に PreToolUse に当てはめる前に、そちらを確認してください。 matcher はツール(Bash、Agent、WebFetch など)を選びます。ただし、マッチ方法は使用する文字によって異なります。文字、数字、_、-、スペース、,、| のみを含む matcher は、完全一致の文字列(または |/, で区切られた完全一致文字列のリスト)として比較されます。それ以外の文字を含む場合は、アンカーなしの JavaScript 正規表現として扱われます。どちらの方向にも気づきにくい落とし穴があります。Edit.* は NotebookEdit にもマッチします(^Edit$ とアンカーを付けてください)。一方、mcp__memory は完全一致文字だけで構成されており、その名前と完全一致するツールがないため、何にもマッチしません(必要なのは mcp__memory__.* です)。大文字と小文字は区別されます。終了コード 2 はブロックを意味し、フックの stderr がモデルに表示するメッセージになります。そのため、単に「ブロックしました」ではなく、理由 と 正しい代替案 を記載してください。
  • PostToolUse は取り消しには使えませんが、信頼できるコンテキストを注入することで、後からの幻覚を防げます(例:コミット後に実際の git HEAD を再読み込みして提示すれば、注入された事実に反してモデルが「コミットした」と「信じる」ことはできません)。
  • SessionStart は、ガードレール自体の範囲を限定したデプロイ確認と稼働確認に使います。正常時は何も出さず、異常時は警告し、常に終了コード 0 を返します。その理由に注意してください。非ゼロの終了コードではセッションをブロックできない(ブロックできません)からではなく、誰かが修正するまで、セッション開始のたびにフックエラーの通知が表示されるためです。起動のたびに誤警報を出すチェックは、いずれ見過ごされるようになります。編集後の完全な回帰テストは、セッション開始時ではなく、ビルド/コミットのチェックで実行してください。起動時に呼び出すのは、オーナーが宣言した範囲限定のオフライン稼働確認モードだけにしてください。モードが見つからない場合は、--selftest にフォールバックせず、ロジックが不明または不完全なままにします(ビルド順序のステップ 4、落とし穴 #50)。
  • set -euo pipefail と set -uo pipefail ― 契約に応じて選び、常に終了コード 0 を返す契約を守る方法が 2 つあることを理解してください。 ブロックする可能性があるフック(PreToolUse)では -e が適しています。予期しないエラーでスクリプトが中断しても、呼び出し側は終了コード 0/2 以外を「続行」として扱うため、問題ありません。契約が 必ず終了コード 0 を返す フック(PostToolUse のインジェクター、SessionStart のチェック)には、正しい方法が 2 つあります。(a)-e を外し、リスクのあるすべてのコマンドに || ガードを付ける。-e を有効にしたままでは、正当に何も見つからない grep ひとつでフックが途中終了し、CLI に Failed with non-blocking status code だけが表示されます(落とし穴 #8、パターン E の形式)。(b)-e を維持して trap 'exit 0' ERR を追加する。これにより、-e で処理の安全性を保ちつつ、エラーが起きても終了コード 0 に変換できます(git-commit-headcheck の本番向け形式、パターン D)。どちらも正しい方法です。トラップも || ガードもない状態で -e だけを使うのは避けてください。目安は次のとおりです。判断を下すフックには -e を使い、報告するフックでは -e を外すか、トラップを設定します(落とし穴 #8)。
  • Stop は例外的なイベントであり、誤って選ばれることも最も多いイベントです。モデルがたった今自分で生成した内容(自分自身の返信テキスト)に反応できるのは、このフック型だけです。「何を発言するか」を監視する場所として妥当に思える UserPromptSubmit を含め、他のすべてのフック型が見るのは常にユーザーの入力だけです。構造上、モデル自身の現在のターンの出力を見ることはできません(この主張は正しく、ルールを Stop に振り分ける理由として今も適切です)。ただし、この保証は .prompt フィールドが常にキー入力から生成されたものであることまでは保証しません。バックグラウンドタスクの通知テキスト自体がこのフィールドに設定されることもありますし、チームメイトや別のセッションからのメッセージが設定されることもあります。stdin JSON のフィールドにはその違いを示すものはなく、テキストの冒頭に付くラッパータグだけが手がかりです — #30。「検証していないものに対して、モデルが略称を作ってはならない」のようなルールは Stop に設定します。代わりに UserPromptSubmit に設定すると、(a)対象のテキストがそのイベントを通らないため、目的とした内容を一度も検出できず、(b)ユーザーの無関係な入力にたまたまトリガーパターンが含まれていると、誤ってブロックします。これはカテゴリの取り違えであり、調整の問題ではありません。誤ったイベントに設定したルールは、正規表現をいくら調整しても解決しません。完全な契約(last_assistant_message と transcript_path の違い、ループ防止チェック)は、パターン E の references/hook_patterns.md を参照してください。
  • Stop には同じループ防止策を持つ 2 つのブロック経路があります。意図に応じて選び、最初の(唯一の)ブロックに必要な内容をすべて含めてください。 decision: "block" と reason の組み合わせ、または終了コード 2 と stderr のみを使うと、フックのエラーとして表示されます。これはハードゲート(「これは許容できません」)向けです。hookSpecificOutput.additionalContext を使うと、エラー通知なしの中立的な「Stop hook feedback」として表示されます。こちらは、モデルに考慮してほしいコーチングやリマインダー向けで、ゲート用途ではありません。どちらも、上の表にある連続ブロックの上限に算入されるため、違いは安全性ではなくトーンです。メッセージの設計にあたっては、ブロックされた再試行ラウンド(stop_hook_active: true)は、残っている違反があっても通過する点に注意してください。そのため、Stop ガードが情報を伝えられる機会は1 回だけです。(修正方法が「返信を書き直す」場合に限り、この上限もそれを補強します。ツール呼び出しを伴う場合はカウンターがリセットされ、上限に達しません — #27。いずれにしても、1 回限りという結論は上限ではなくラッチに基づくため変わりません。)その 1 回のブロックですべての検出結果を報告してください(最初のものだけを表示するガードでは、残りは永久に失われます — 落とし穴 #17)。また、判定を下すのではなく、許容される具体的な修正方法を示す脱出手順としてメッセージを書いてください。そうすれば、モデルは 1 ラウンドで修正できます。できなければ上限まで推測を続けることになります。v2.1.145+ の background_tasks/session_crons 入力を使うと、ブロックするフックが「セッションは終了している」のか「バックグラウンド処理を待つため一時停止しているだけ」なのかを区別できます。一時停止をブロックすると、無意味な処理の継続を強制し、同じ上限を無駄に消費します。 完全に実行可能なスケルトン:references/hook_patterns.md。

スケルトン(PreToolUse Bash ガード)

#!/usr/bin/env bash
set -euo pipefail
IFS= read -rd '' INPUT || true                 # builtin; NOT $(cat) — see below
# 0-fork fast path: a builtin `case` on the raw JSON, BEFORE paying for python3.
# Your guard runs on EVERY matching tool call, so the irrelevant path is the one
# that has to be cheap. Keep this filter BROADER than what you actually block and
# never flag-level — it answers "is this even about X", nothing finer (#22).
case "$INPUT" in *TRIGGER*) ;; *) exit 0 ;; esac
TOOL=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null||echo "")
[ "$TOOL" != "Bash" ] && exit 0                # only guard the tool you mean to
CMD=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null||echo "")
[ -z "$CMD" ] && exit 0
printf '%s' "$CMD" | grep -qw 'TRIGGER' || exit 0   # precise relevance check
# ... precise detection here ...
if <command actually does the banned thing>; then
  echo "BLOCKED: ... WHY ... USE INSTEAD: ..." >&2   # stderr = the guidance shown
  exit 2
fi
exit 0

最初の2行が単なるスタイル上の問題ではない理由。 INPUT=$(cat) と、それぞれの printf … | python3 -c … は、このフックに一致する呼び出しのたびに、つまり何も通知する必要がない場合も含めて、プロセスのフォークを発生させます。Bash マッチャーを使うフック約13個 × 並列セッション × 1秒未満のツール実行間隔が、純粋なガード処理だけで毎秒40~200回のフォークを継続的に発生させ、暴走プロセスがどこにもないのに、1日中の CPU 使用率ランキングで Gatekeeper がトップになりました。フック群は問題なく、バグは 無関係な処理経路 の呼び出しごとのコストにありました(#22には、ガードごとの変換手順と計測した下限値があります)。

case の行をコピーする前に、以下の注意点を確認してください。最初の項目は、高速パスとバイパスを分けるものです。

  • 粗いフィルターはブロック対象のスーパーセットでなければならず、生の部分文字列テストはそれに該当しません。 TRIG''GER -x は TRIGGER を実行します。実行前に bash が引用符を取り除くためです。しかし、生のイベントテキストには TRIGGER の部分文字列が含まれないため、単純な case "$INPUT" in *TRIGGER*) は終了コード0で終了し、ガードは検知できません。実測:この行をそのまま同梱の Pattern A に追加すると、TRIG''GER -x の終了コードが2から0に変わり、完全なバイパスになります。一方、scripts/test_hook.sh は21件成功/0件失敗のままです。トリガーが分割されたケースを含むテスト行がないためです。Pattern A にはすでに修正と理由が含まれています(「分割を解消して、引用符とバックスラッシュを取り除き、もう一度確認する。偽陰性は完全なバイパスになる」)。この分割解消より前に粗いフィルターを置くと、修正箇所に到達できません。安全な形は、優先度順に2つあります。分割の影響を受けないものを基準にフィルターする(JSON キーやツール名など。例:case "$INPUT" in *'"tool_name":"Bash"'*))。引用符の分割は コマンド テキスト内で起きるため、イベント自体の構造は書き換えられません。あるいは、テスト前にフィルター内で分割を解消する(入力のコピーから "、'、\ を取り除いてから照合する)。1つ目を推奨します。エスケープ処理に悩む必要がなく、フィルター自体の引用符を正しく記述しなければならないようなフィルターは、気付かないうちに誤りを招く可能性があります。上記のスケルトンがそのままで安全なのは、検知処理も単純な単語照合だからです。フィルターの後にあるガードのほうがフィルターより賢くなった時点で、フィルターが結果を左右します。

  • このスケルトンは無関係な入力に対してフェイルオープンです(grep -qw … || exit 0)。そのため、手前に粗いフィルターを置いても変わるのはコストであり、動作の意味ではありません。フェイルクローズのガードは別です。単純な部分文字列フィルターを置くと、契約が「不明な入力をブロック」から「不明な入力を許可」へ、気付かないうちに変わります(実測では、この修正の最初の試作を 'not json' がそのまま通過しました)。また、*tool_name* だけを目印にすると、反対側から同じ穴が再び開きます。不正な入力でもブロックするガードに高速パスを追加する前に、#22のゲート要件を読んでください。

  • スクリプトより上位に、さらに低コストのレイヤーがあります。 フックハンドラーの登録には、"Bash(git *)" のような権限ルール構文を使った if フィールドを設定できます。一致しない場合、フックコマンドはまったく実行されません。プロセスが起動しないため、フォークもゼロです。これは設計上、ベストエフォートです(ドキュメントには、Bash コマンドを解析できない場合はフックを実行する、つまりフェイル オープン すると記載されています)。そのため、これはコスト最適化として扱い、ゲートとして使わないでください。最終的な判定は引き続きスクリプト内のチェックで行います。制限も確認してください。ルールは1つだけ指定でき(&&/|| は使えません)、ツールイベントでのみ評価されます。また、ツール以外のイベントに if を設定したフックは、まったく実行されません。

正常に動作するガードと、セッションを損なうガードを分けるルール

スタイル上の好みではありません。それぞれ、実際に出荷し、原因を追跡した特定の障害に基づいています。

1. 生の文字列を awk で分割せず、shlex を使ってトークン単位で照合する

正常なコマンドを誤ってブロックするガードは、見逃すガードよりも悪いです。回避せざるを得ないガードは反射的に回避されるようになり、結果として何も守れなくなります(中核となる原則:正常な入力を誤って拒否するほうが、見逃すよりも悪い)。誤ブロックが繰り返し発生する原因は、生のコマンド文字列に対して照合することです。

  • 誤り:セグメントに分割するために awk '{gsub(/&&|\|\||;|\|/,"\n")}' を使うことです。awk はシェルのクォートを理解しないため、grep -E "a|TRIGGER|b" はクォートされた正規表現の中にある | で分割され、TRIGGER が実在しないコマンドとして扱われ、通常の grep がガードにブロックされます。(2026-07-21 にリリース。このガードが実際に初めて使われたとき、自分の grep が誤ってブロックされました。)
  • 正解:shlex.split() 関数ではなく、shlex.shlex クラスでコマンド全体をトークン化します。split() は | ; & < > がスペースで区切られている場合にしか区切り文字として扱わないため、ls|TRIGGER x は ['ls|TRIGGER', 'x'] とトークン化され、コマンド位置のチェックでは TRIGGER をまったく検出できません(実測済みです。punctuation_chars=True を指定したクラスは ['ls', '|', 'TRIGGER', 'x'] を生成します)。ワンライナーに頼るのではなく、リリース済みのウォーカーをそのままコピーしてください。ただし、コピーするのは scripts/test_hook.sh に合格するもので、これは Pattern A です。ウォーカーのセクションは簡易版であることを明記しています。ラッパーごとの値を取るフラグのテーブルが省略されているため、値を取るフラグを持つラッパー経由の対象を見逃します。実測では、timeout 5 TRIGGER、sudo -u root TRIGGER、nice -n 10 TRIGGER に対し「コマンド位置にない」と判定しますが、Pattern A のバージョンは3つすべてを検出します。(引数を取るラッパー自身のフラグを簡易版のテーブルがスキップできないのが原因で、裸の sudo TRIGGER はどちらでも問題ありません。)リリース済みのハーネスに含まれるのはこの形式のうち1つだけなので、実際に確認できる実行結果は、Pattern A の 21/0 に対して wrapper-timeout が 20 pass / 1 fail です。残りの2つは該当する行がないため、失敗しても表面化しません。クォートされた "a|TRIGGER|b" は1つのトークンとして扱われるため、正規表現の引数がコマンドと誤認されることはありません。次に、対象がコマンド位置(token[0]、または ;/&&/||/| の区切り文字の直後で、VAR=val 形式の環境変数代入プレフィックスは読み飛ばす)にあるかどうかを確認します。コマンド位置ウォーカーは references/hook_patterns.md を参照してください。
  • 系:echo "…TRIGGER…"、grep TRIGGER、# TRIGGER、man TRIGGER はすべて許可されなければなりません。テストセットには、これらの実行ではなく単なる言及のケースを必ず含めてください。
  • 系 — git の書き込みセグメントは、ウォーカーに渡す前に除外してください。 コミットメッセージは任意のデータであり、その全文が疑似コマンドテキストとしてコマンド位置の走査対象になります。git commit -F - <<EOF の本文に foo|TRIGGER が含まれていると、TRIGGER がコマンド位置に置かれ、ガードが自分の修正コミットをブロックします(落とし穴 #7 はまさにこれが原因で、すでにリリース済みです)。コマンド文字列を調べる Bash ガードでは、git + commit/rebase/tag/am/cherry-pick を先頭に持つセグメントをスキップする必要があります。行単位に分割する前に、コマンド全体のレベルで行ってください(Pattern A に順序が示されています。本番版では lib-git-commit-detect の隣接チェックが使われています)。
  • 系 — ウォーカーが2段階なのには理由があります。 whitespace_split=True は改行を通常の空白として扱うため、複数行のブロック(cd /x\ngit add\nTRIGGER -y)が先頭を cd とする1つのセグメントにまとまり、トリガーはコマンド位置に現れません。実際のトランスクリプトを再生した際のトリガー検出率は 0 でした(落とし穴 #11)。まず、シェルを考慮して行に分割します(クォート状態とバックスラッシュによる継続を考慮し、クォート内の複数行文字列を分断しないようにします)。その後、各行を shlex で走査します。Pattern A とウォーカーのセクションは、どちらもこの分割処理を実装しています(split_shell_lines。qlmanage-guard で本番実績があります)。これでも解析できないのはヒアドキュメントの本文(クォート構文ではないため)です。この残余リスクを受け入れるかどうかは、#11 で判断してください。
  • ただし、shlex は万能ではなく、何を検出するかによって fail-open が安全かどうかも変わります。 shlex.split() 自体は、閉じていないクォートがあると ValueError を送出します。# や閉じていないクォートを含む複数行の git commit -m "… メッセージは、よくある原因です。そこで except ValueError: cmd.split() のフォールバックを行うと、禁止された修飾子の検出(--no-verify が含まれているか?――見逃しても安全側に倒れるため、Rule 1 の方針に沿います)では許可することになり、適切です。しかし、コマンドがそもそも対象かどうかの検出(これは git commit か?)では危険です。そこで ValueError が発生すると、ガードはコミットを認識できず、警告なしに処理されてしまいます。実際に、別ドメインへのコミットが確認ダイアログなしでこの方法によりリリースされたことがあります。これが対象のコマンドかという判定には、複数行のクォート破損の影響を受けない、git と commit を別々の単語として扱い、その間に任意のフラグトークンを許す簡潔な正規表現を優先してください。shlex ウォーカーは、コマンド位置/修飾子のチェックに使います。そこでは fail-open が安全側に倒れます。(使い分けの目安は、条件が「これは特定の一般的なコマンドか」――git commit、git push など、そのコマンド自身のメッセージや引数がトークン化を壊す場合――なら正規表現を使い、条件が「禁止されたコマンドまたは修飾子がコマンド位置にあるか」ならウォーカーを使うことです。後者では禁止対象がまれであり、ValueError 時に fail-open とするほうが安全側に倒れるため、Rule 1 の方針に沿います。)
2. 登録前に、bash -n と実際の JSON イベントでエンドツーエンドにテストする

破損した、またはロジックに誤りのある PreToolUse フックは、セッション全体を汚染します — 後続の Bash 呼び出しがすべて切り詰められたり、重複したり、誤って失敗したり、実行されたように見えて実際には実行されなかったりします。その原因はインストールしたフックなのに、「環境」のせいだと思い込んでしまいます。(2026-07-05:ある編集で [^;&|] 正規表現が壊れ、;& が bash の case フォールスルートークンになり、bash -n で見つかるまでセッションの半分が汚染されました。) 「デプロイ時にテストが通った」だけでは不十分です — 後の編集でファイルが壊れることもあります。

フックを登録する前に必ず次を確認してください:

bash -n hook.sh                                # syntax
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"<trigger case>"}}'    | ./hook.sh; echo "exit=$?"  # want 2
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"<healthy lookalike>"}}'| ./hook.sh; echo "exit=$?"  # want 0

登録時に使うそのままの形式で、エンドツーエンドのケースを実行してください — settings.json に $HOME/.claude/hooks/x.sh と記述するなら、その文字列をそのまま実行します。 インタープリターを明示する形式(bash hook.sh、python3 hook.py、および自身を bash 経由で再実行するフックの --selftest)では、実行ビットを迂回するため、登録後すぐに動かなくなる状態を構造上検出できません。症状の一覧:インストール後に PreToolUse hook error: … Permission denied がブロックせず繰り返し発生する場合、パスを直接指定して登録したフックの実行ビットが失われています — 登録時点からチェックは機能していません。セルフテストが成功していても、登録時の形式を実際に試していないため、これを否定する根拠にはなりません。 ハーネスをまとめてください:scripts/test_hook.sh はトリガーと許可のケースをひと通り実行します。自己ブロックの落とし穴: セッション内でフックが有効になった後、自分の Bash コマンドにトリガー文字列を含めてテストすることはできません — 有効なフックがテストコマンドをブロックするためです。ケースをスクリプトファイルに記述し、bash test_hook.sh を実行してください。外側のコマンドにトリガーが含まれないため、自己ブロックされません。

フックが実際のインシデントを一度でも引き起こしたら(誤ブロックまたは見逃し)、コードを一人で読み直すだけでは不十分です — Stop-hook のガードを書き直したその日に、最初のバグを修正する過程で、作者自身がさらに二度壊しました(Python コメント内の引用符が、読み直しても気づかれず、実際に失敗した JSON ケースを実行して初めて判明しました)。次の段階は、複数の視点を持つエージェントチームによるレビューです。各指摘は、コードを読んで同意するのではなく、実際のペイロードをライブスクリプトに対して実行して再現する必要があります — これは、スキルではなくフックに適用した一般的な Counter Review 手法です(skill-creator の skill-development-methodology リファレンス、Phase 6)。あるレビューでは、3 つの視点(マッチングロジック/シェル埋め込みの安全性/イベント契約の堅牢性)から、再現性が個別に確認されたバグが 13 件、さらに引用された根拠自体が捏造されたドキュメントの引用だった指摘が 1 件見つかりました。検証担当者には引用を信頼するのではなく、元のソースを curl し、完全一致する文字列を grep するよう求められていたため、これだけが判明しました。

3. インストーラーがソース、インストール、復旧を管理する

エントリーポイントとそのルールモジュールは、バージョン管理されたソースに保持してください。インストールと復旧には、管理元インストーラーの現在のエントリーマップを使用します。ルールモジュール自体にリンクや登録は必要ありません。インストーラーがシンボリックリンクを使用する場合、構成は次のとおりです:

~/scripts/claude-hooks/<name>.sh      # SSOT (this setup: a private git repo)
~/.claude/hooks/<name>.sh             # symlink → SSOT

再インストール後は、現在のインストーラーが有効と宣言しているエントリーだけを復元してください。古いファイル名から登録を再構成したり、廃止されたエイリアスを再インストールしたりしないでください。エントリーを移行する際は、独立したルール状態と認可の書き込み元を維持してください。実際に読み込まれるエントリーポイント、モジュール、有効な設定を検証した後、新しいネイティブセッションで実際にマッチするイベントを試してください。リンク切れによりガードが気づかれないまま無効になることがあります。上限を設けたデプロイ/稼働確認を使用してください(rule 4、references/hook_patterns.md の Pattern C)。

4. 登録はプロファイルごとに行う — すべてのプロファイルを統一し、人間のゲートを通してリリースする
  • 登録サーフェスは 2 つあり、ヘルスチェックはおそらくそのうち 1 つしか 監視していません。 以下のプロファイル設定とは別に、リポジトリ自身の .claude/settings.json(および settings.local.json)が、そのリポジトリの セッション用にフックを登録します。これらは ~/.claude/hooks/ の外に存在するため、 そのディレクトリを走査するガードレールチェックでは、それらを一切カバーできません — 構文も、パスも、--selftest も、何もです。まさにそれを防ぐために作られた SessionStart ヘルスチェックの下で、あるファイルで 2 件のサイレント障害が 出荷され、2 か月間生き延びました(#38、#39)。プロジェクトフックは絶対パスで 登録してください — 相対パスはセッションの cwd に対して解決されるため、誰かが サブディレクトリから claude を起動した最初の時点で壊れます — そして ヘルスチェックを拡張して、イベントの cwd からプロジェクト設定を求めて 上位へたどるようにしてください。
  • ~/.claude/hooks/ 内のフックは、アクティブなプロファイルの settings.json がそれを呼び出さなければ、何も保護しません。マルチプロファイルの ユーザーは、すべてのプロファイルが収束するまでガードゼロで実行していました。所有元の インストーラーを使ってメインプロファイルの設定を更新してください (このセットアップでは ~/.claude/settings.json。references/hook_patterns.md の Registration セクションに正確な jsonc の形があります)。既存エンジンのマッチャーは、 そのカバレッジが変わるときにのみ更新してください。各ルールのより狭い内部セレクターは 維持してください。このセットアップでは、コンバージャーは引数なしの SessionStart フックとして登録されており(sync-profile-settings.py、所有元は claude-switch-models-setup スキル)、次にセッションを開始するプロファイルが、その登録をすべてのプロファイルへ 持ち込みます。--all は明示的な収束モードです。即時のプロファイル同期が必要なときに 実行しますが、その書き込みをアクティベーションの証明として扱わないでください。 SessionStart ヘルスチェックは、ドリフトを検出するために各プロファイルの Tier-0 ガードを grep します。新規または変更された登録については、実際にマッチするイベントと、新しい ネイティブセッションでのデバッグ証跡を検証してください。現在のセッションのホットリロードは、 そのセッションが変更された登録を呼び出すのを観察した後にのみ主張してください。設定の 書き込み成功や起動時のヘルス行だけでは不十分です。現在の ConfigChange コントラクト では、設定変更を実行中のセッションに適用できますが、この特定の登録が発火したことは 証明されません。
  • ガードにリリースバルブが必要なら、それを環境変数ではなく人間のゲートにしてください。 静的な GUARD_OK=1 の脱出ハッチはゲートではありません — モデル自身がその環境変数を 設定できてしまいます。ネイティブの macOS ダイアログ(osascript — モデルはクリック できません)を使用してください。拒否/キャンセル/タイムアウト = ハード NO。すべての プロンプト/バイパスを監査ファイルに記録してください。パターンは references/hook_patterns.md にあります。
    • ⚠️ /dev/tty は第 2 のチャネルではありません — ドキュメントにはフックがそれを 開けないと書かれています。 このファイルは以前、ダイアログと並んで /dev/tty への 型付き YES を規定していました。公式リファレンスは明示しています。フックは 「独自のセッションで制御端末なしで実行される」、そして「フックプロセスとその 子プロセスは /dev/tty を開けない」(terminalSequence が、そこに書き込む ための文書化された代替手段です)。したがって、そのように構築された「2 チャネル」の ゲートは、1 チャネル + デッドコードであり、GUI セッションのないマシンでは、その ゲートは誰にも承認できません。これはローカルの観測と一致しますが、境界を注意深く 読んでください。あるセットアップの共有監査ログでは — 1,801 エントリ、そこに書き込む 複数のガード、そのうち 3 つが tty チャネルを実装 — 360 行がチャネルタグを持ち (236 件のダイアログ確認、124 件の辞退またはタイムアウト)、tty チャネルに言及する 行はどの種類も 1 行もありません。 つまり、tty ブランチは一度も進入されておらず、 macOS ではこれはいずれにせよ予測されるとおりです。なぜならダイアログが先に応答し、 それをショートサーキットするからです。したがって、ログはここで tty に依存したものが これまで一度もなかったことを示しています。tty がまったく動作できないことを確立するのは、 この測定ではなく、ドキュメントです。ダイアログは維持してください。macOS 以外のゲートが 必要な場合は、このファイルにはまだ検証済みの答えがないチャネルが必要です。
    • ⚠️ フックのタイムアウトを超えて生き残る人間のゲートはフェイルオープンします。 フックの command タイムアウトのデフォルトは 600 秒(UserPromptSubmit では 30 秒)で、 タイムアウトしたフックはツール呼び出しをブロックしません — つまり、応答のない ダイアログは「no」にはならず、「allow」になります。待機時間はタイムアウトより十分に 短く設定し、ハーネスがあなたの代わりに解決する前に、無応答があなた自身を ブロックするよう解決するようにしてください。
    • ⚠️ 人間のゲートは、そのダイアログが判断を運べる場所でのみ発動する価値があります。 人はフックがそのボックスに入れたものだけを見て、それ以外は何も見えません — つまり、 フック自身の証跡が空であるようなパス(コマンドの実行前に状態を読み取り、そのコマンドが その状態を作り出す)では、ダイアログも空になり、返ってくるのは反射的なクリックであり、 判断ではありません。そのようなパスでは機械的にブロックし、状態が観察可能になるように コマンドを再構成する方法をモデルに伝えてください。ダイアログは、対象・コマンド・ オブジェクトを名指しできるパスに対して維持してください。ダイアログが表示する モデル作成のテキスト(コマンド、パス)は、まず空白が畳み込まれます。症状、修正、 そしてレコーダースタブのキャリブレーション: references/hook_pitfalls.md #44。
    • ⚠️ 代わりに表示する救済策は実行可能でなければなりません — 実行して証明してください。 フックが機械的にブロックする箇所では、stderr の指示がそのインターセプトの成果物のすべてであり、 表示用に畳み込まれた文字列(上記)はモデルが貼り戻せるものではありません。表示される各救済策は、 意味のある唯一の方法でテストしてください。正確なバイトを組み立て、それを実行し、同じイベントを フックへもう一度通し、今度は通ることをアサートします。別のパーサーがその文字列をデコードできる ことは証拠になりません — チェックポイントはゲート自身のトークナイザーです。 references/hook_pitfalls.md #45。
    • ドキュメントには UI 内チャネルもあります — PreToolUse の hookSpecificOutput permissionDecision: "ask" で、Claude Code 自身のインターフェースを通じてプロンプトを 出します。知っておく価値はありますが、ここでは bypassPermissions / 自動承認の下で 未検証であり、まさにそれが Tier-0 ゲートが生き残らなければならないモードです。 ダイアログが規定されているのは、それが権限モードに依存しないからです。
    • Tier-0 より下で、モデルが対処できる脱出が許される場合は、それをバイパスフラグではなく 正しい使用法にしてください。 上記のルールは Tier-0 では絶対であり、ここでは曲がりません — これはそれに届かない正確性ガードについてであり、検出器が区別できない正当なケースのために 依然として出口が必要です。問題は、その出口を何にするかです。SKIP=1 / --force の環境脱出は、ルール 1 が警告するまさにその反射を訓練し、締め切りの下では バイパスと区別がつきません。そもそもやってもらいたかったことである脱出を優先してください。 そうすれば、それを使うことはガードを解除するのではなくコマンドを改善します: pipe-fallback-guard は、コマンドが pipefail / PIPESTATUS / pipestatus に言及した 瞬間に終了コード 0 で終了します。なぜなら、それらのいずれかを書いた作者は、パイプラインの 終了コードを理解していることをすでに示しているからです — ガードには教えることが何も残って いません。適用すべきテスト:もし誰かが私の脱出ハッチを使ったら、結果のコマンドはより良くなるか、 それとも単にブロックが解除されるだけか? 正直な答えが「単にブロックが解除されるだけ」なら、 それはより良い名前のついたバイパスフラグです。Tier-0 のケースの姉妹原則(そこではオーバーライドは ゲートをテストできるようにするためだけに存在します):脱出ハッチはゲートをより厳格にすることしか 許されない — references/hook_patterns.md の GIT_GUARD_TEST の 議論を参照してください。
5. 失敗の方向を決め、それをテストします。正常系だけでは不十分です

ルール 1 では、検出の調整に関するエラーを比較しました。ガードが実行された場合、まれな不正コマンドを見逃すより、正常なコマンドを誤ってブロックするほうが問題です。ガードを回避しなければならない状況が続けば、人は反射的に回避するようになるからです。このルールが扱うのは別の軸、つまりガードの仕組み自体がまったく実行されないことです。そのため、ここで「どちらが悪いか」の順位が逆転するわけではありません。2 つの順位はそもそも交わりません。調整ミスの損失は 1 つのケースですが、こちらの損失は、その形の入力すべてでガードが気づかれないまま機能しなくなることです。

失敗とは、ガードが判定に使うものを取得できないことです。パースで例外が発生する、パスを解決できない、依存関係が欠けている、サブプロセスがタイムアウトする、といったケースがあります。そして、フックのクラッシュを防ぐ 2>/dev/null || true が、*「確認できなかった」を「報告することは何もない」*へと、ひそかに変換してしまいます。フックは 0 で終了します。この出力は正常に通過した場合と同じなので、何週間も問題に気づかないことがあります。

そのため、フックが何かを取得する箇所(コマンドのパース、ステージされたファイルの読み込み、サービスへの問い合わせなど)では、必ず明示的に決めてください。**結果が空だった場合、許可するのかブロックするのか?**そして、その答えを分岐の横に書いてください。修飾子のチェック(--no-verify が付いているか? これを見逃す損失は 1 つのケースです)では、フェイルオープンが適切なことがよくあります。一方、そもそも対象に該当するかのチェック(クロスドメインのコミットか?)では、フェイルクローズが通常は適切です。結果が空なら、ガードがまったく発動していないことを意味するからです。

そして、正常系ではなく失敗の方向をテストしてください。わざと解決できないパスやパースできないコマンドを渡し、決めたとおりに動作することをアサートします。フックがひそかにすべてを許可したために全行が通るテストスイートは、正常に通るテストスイートと区別できません。

結果は慎重に確認してください。同じ入力でも、ガードの種類によって正しい答えは逆になります。 cd ~/no-such-dir && TRIGGER を例にします。

ガードの種類判定対象正しい終了コード理由
トークンマッチャー(禁止されたコマンド形式か?)コマンドテキストのみ2、ブロックTRIGGER はテキストに明記されています。cd を解決できなくても、それがトリガーではなくなるわけではありません。ここでガードが黙ってしまうなら、cd ~/real-dir && TRIGGER でも黙ってしまいます
状態導出器(リポジトリのステージ済みファイル群が複数のドメインにまたがっているか?)ディスクから読み取った状態0、許可cd が失敗し、&& によって短絡評価されるため、コミットは実行されません。ガードすべきものはありません
終了状態リーダー(修正対応はすでに行われたか?)レシート/カウンターファイル(ルール 7)0、許可 — 状態ファイル自体が終了条件である場合レシートを読み取れないと、フックはすでに発動済みかどうかを判断できません。ここでフェイルクローズにすると、修正対応が不可能で、人にも原因が見えないまま、いつまでもブロックされます。それこそがループであり、ケースを 1 つ見逃すより悪い唯一の失敗です。逆のサブケース — この行をコピーする前に読んでください:状態が、独立した述語に重ねた予算にすぎない場合(作業を行えばブロックは解除される)、読み取り不能時に許可すると、フック全体が気づかれないまま無効化されます。書き込み不能なディレクトリが 1 つあるだけで、あらゆる入力に対して永遠に無反応になります。これが最悪の失敗パターンです。その場合は、沈黙させるのではなく、予算が存在する前の動作に戻り、述語の評価を続けます。次の質問で 2 つを区別してください。状態がなくなっても、修正対応は可能ですか? いいえ → レシートのケースなので許可します。はい → 予算のケースなのでチェックを続けます。再考しなくて済むように、具体例を示します。ルール 7 の仕組み 2(レシート)と仕組み 3(セッションごとのカウンター)は、どちらもレシートのケースなので許可です。仕組み 3 は R が行われたかどうかを意図的に確認しないため、カウンターだけが終了手段です。カウンターを無効にすると、そのターンが行き詰まります。予算のケースとは、ユーザー自身が単独で満たせる述語にカウンターを重ねたものです
したがって、行を書く前に自分のフックがどのクラスに属するかを決めてください。そして、ハーネスの
unresolvable path テンプレート行は 2 を期待します。これはそのテンプレートが
トークンマッチャークラスを対象としているためです。これを逆にすると、正しいガードに対して
自信満々の FAIL が生成されます。状態導出型のガードの場合、あなたが探している失敗とは:そのコマンドが実際に実行されていたはずなのに、ガードがそれを見なかったということです — 引用符の不均衡でトークン化が例外を投げ、フォールバックが許可し、真のクロスドメインコミットが何のダイアログもなく出荷されます(ルール 1 の ValueError に関する注記)。許可されたすべての行に対して問いかけてください:このコマンドは実際にその行為をしていただろうか? もしそうでなければ、その許可は正しいです。

この正確なプローブを実際の状態導出型ガードに対して実行すると、最初のパスで 2 つの許可が返されました。1 つは正しく(上記のショートサーキット)、もう 1 つは真のフェイルオープンでした。プローブは物事を見つけますが、見つけたものを分類するのは依然としてあなたの仕事です — これが、上記のクラステーブルが行よりも先に来る理由です。

実際の事例(2026-07-22):あるスコープガードが、git -C "$REPO_DIR" を介してステージされたファイルを読み取り、REPO_DIR はコマンドテキストから解析されていました — そのため cd ~/repo && git commit はリテラルの ~/repo を渡し、git -C が失敗し、ステージされたものが空で返り、ガードは「クロスドメインファイルなし、許可」と結論づけました。すべてのクロスリポジトリコミットがガードされないままになり、何も問題に見えることはありませんでした。解剖と共有ライブラリのひねり:落とし穴 #10。

そのパーサーには 2 つ目の失敗方向があり、それがより厄介なものです。フェイルオープンをやめるためにフォールバックを追加すると、そのフォールバックはある理由で正しく、別の理由で誤りになります — そして両方とも同じ行を出力します。git push(明示的なターゲットなし)は正当にイベントの cwd にフォールバックします。git -C "$R" push はフックが解決できないターゲットを名指しし、同じ cwd にフォールバックし、そして別のリポジトリについて自信満々の ✅ をレンダリングします。これら 2 つのケースはバイト単位で同一にレンダリングされ(測定済み、MD5 が等しい)、そのためフックも読者も正直な判定と誤って束縛された判定を区別できません。フォールバック値はそれが選ばれた理由を運ばなければなりません、そして「明示的なターゲットなし」だけが判定を得る資格があります。完全な解剖、混乱した代理の枠組み、そしてリテラルパスを持つフィクスチャがなぜそれを決して捕まえないのか:落とし穴 #28。

6. 世界が答えられる事実で判定する — 自分の表示や命名の慣習では決して判定しない

ルール 1 と 5 は、どのように照合するか、そしてどちらの方向に失敗するかについてのルールです。このルールは、照合対象の情報源についてのルールであり、どちらもひそかに失敗する 2 つのパターンがあります。

  • 人間向けに整形した文字列を条件分岐に使ってはいけません。 フックがレポートを作成し(ソートして結合し、先頭 N 件までに切り詰めて末尾に (+M more) を付けるなど)、そのレポートを自分自身の判定にパターンマッチさせると、分岐は表示による情報の欠落を引き継ぎます。上限を超えた項目は分岐からは存在しないも同然です。そのため、小さなフィクスチャでは常に動作する一方、まさにこの処理が必要な大規模セッションでは動作しなくなります。機械向けの事実は独立したチャネルで出力し(切り詰めのない KINDS:a,b,c の 1 行など)、それを照合してください。表示は出力であり、データソースではありません(落とし穴 #12)。
  • 命名規則より、検証可能な事実を優先してください。 パスの形状(/skills?/[^/]+/references/)による分類は、特定のディレクトリ構成を前提としています。別の構成のリポジトリは、ひそかに、そしていつまでも None と分類されます。解決策はパターンを広げることではありません。それでは、見逃しをマシン全体にわたる誤検知に置き換えることになり、ルール 1 がまさにその交換を禁じています。代わりに、ファイルシステムが答えられる質問をしてください。この references/ ディレクトリの隣に SKILL.md はあるか? 事実は構成が変わっても変わりませんが、慣習は変わります。(候補自体が SKILL.md の場合、隣に確認できるものはありません。ベース名で分類してください。#13 では、それがこのルールで警告する命名の慣習ではなく、仕様で定義された事実である理由を説明しています。) 検証可能な事実でも、間違った事実である場合があります。質問の対象を特定し、型で絞り込んでください。 test -f SKILL.md はダウンロードフォルダでも真になります。あるガードは祖先ディレクトリをたどって同名のファイルを探した結果、ホームディレクトリ全体を誤って対象にし、実際のセッションに、その名前では存在し得ないスキルを読み込むよう指示しました(ルール 9 の事例)。特定のディレクトリの隣、または既知のインストールパスを基準にしてください。基準のない祖先ディレクトリの探索は、事実を装った慣習です。

どちらの場合も、兆候は同じです。テストは成功しているのに、本番では一度も動作したことのない分岐です。整形前の分類結果を出力すれば、どちらの問題かが分かります。

7. フックが修正を要求するなら、ループが終了することを証明する

X が完了するまでブロックするフック(終了コード 2)、特に Stop フックは、その後の停止ごとに再実行されるため、単なるチェックではなく、フィードバックループです。 (要求を注入するだけで終了コード 0 を返すフックには、ループはありません。再評価されないからです。これは以下のメカニズム 0 にあたり、多くの人が選びがちな方法よりも、適切なデフォルトです。)

condition T is true → hook demands remediation R → model performs R → T checked again

最初のサイクルの前に、ループの契約を作成してください。フックで強制するループと、エージェント主導のレビュー/待機/再試行ループの両方が対象です。

LOOP KEY: immutable logical target / lineage + one failure axis
FIRE T: the condition that starts another cycle
REMEDIATION R: the exact action one cycle performs
VARIANT V: the well-founded quantity that strictly decreases for this key
BUDGET: maximum cycles, fixed before cycle 1
SUCCESS EXIT: the observable that proves the axis is clear
CAPPED EXIT: what is left blocked / unshipped / pending when the budget ends

契約が完成していない場合、Stop フックをブロックしたり、レビュアーやポーリングのループを繰り返したりしてはいけません。サイクル 1 の前にキーを固定してください。修正のスナップショット、コミット、またはレビュアー名は、同じ系譜の中にとどまり、新たな予算を生み出すことはできません。新たに見つかった無関係な指摘は新しいキーです。別途記録し、このループの予算をリセットすることはありません。新たな反証実験も、より小さな V も提示できないサイクルは、証拠を追加しないため、終了します。

エージェント主導の独立レビューのループでは、デフォルトの予算は最初のレビュー 1 回と、実質的な修正の後に行う、範囲を限定した再レビュー 1 回です。3 人目のレビュアーが自動的に必要になるわけではありません。再レビューでも同じ軸で BLOCKER または MAJOR が再現する場合は、フックの登録や成果物の出荷を行わず、ブロックされている状態を報告してください。そのうえで、サイクル 1 の前に予算を明記したループの契約を含む、新たなユーザー承認済みタスクが必要です。エージェントが宣言した予算を、そのエージェント自身が承認することはできません。承認済みのタスク内では、ここで停止した場合に生じる具体的な安全上またはビジネス上の失敗を明記してください。任意の仕上げは該当しません。

記入済みのレビュー・ループの例:

LOOP KEY: <initial frozen commit>'s review lineage + termination-contract fidelity
FIRE T: fresh review reports a same-axis BLOCKER / MAJOR
REMEDIATION R: reproduce that finding, apply one bounded fix, run its narrow check
VARIANT V: 2 - completed review cycles
BUDGET: 2 cycles total (initial review + one re-review)
SUCCESS EXIT: no same-axis BLOCKER / MAJOR
CAPPED EXIT: artifact stays unregistered / unshipped; report remaining findings

初期の固定コミットから派生したすべての修正は、このキーを引き継ぎます。レビュー担当者が修正を確認できるよう、現在のスナップショットは変わりますが、系譜と残りの予算は変わりません。

このフックなしの予算を機械的に強制するものはありません。これは、エージェントがスキルに従う間だけ保たれます。この制約があるため、上限に達した際の終了は明確に示す必要があり、「完了」と報告してはいけません。

R の完了によって T が再び真になり得るなら、このループは収束しません。 エラーもクラッシュも起きません。人間が中断するまで、何度もラウンドを繰り返して消耗します。通常そうなるのは、各ラウンドが安価な再試行ではなく、(ディスパッチ、待機、採用、編集からなる)完全な修正サイクルだからです。そして、同じ性質があるからこそ、ハーネスの連続 8 回ブロック上限では防げません。ツールを実行した継続のたびにカウンターがリセットされるため、ツール呼び出しで構成された修正サイクルでは、カウンターは永遠に 1 のままです(実測済み — #27)。上限が機能する場合でも、それは暴走するセッションに対する最後の防護策であり、設計上の解決策ではありません。違反が残ったままターンが終了し、ハーネスはそのターンを reason:"completed" と報告します。本当に完了した場合と区別がつきません。「いずれ止まる」ことは、望ましい意味での停止ではありませんし、ここではいずれ止まることすらありません。ここで stop_hook_active が助けになることはありません。このフィールドが対象とするのは、まさに1 層の再入(「直前にブロックした停止が再試行されている」)だけです。モデルが実際に R を実行し(実作業を多数のツール呼び出しで行い)、その後自然に停止するというターンをまたぐケースについては何も示しません。それはまったく新しい Stop であり、フィールドは false になり、同じ条件でフックが再び実行されます。

プログラム検証における停止性の証明から借りたテスト — ループ変量/ランキング関数です。整礎順序(通常は ℕ)に値を取る量 V を定義し、すべての trigger → remediate → re-check サイクルで V が厳密に減少することを示します。V がなければ停止性の証明もありません。フックを登録してはいけません。

V は設計時の義務であり、コードではありません。フック内で計算することはありません。実際に実装するのは 述語(以下の仕組み)です。V は、その述語が収束することを示す根拠です。次に読む人が必ず目にする場所、つまりスクリプトのヘッダーに記載してください。

# TERMINATION: V = 1 - exists(<receipt path>)
# decreased by: R writes the receipt; nothing R does afterwards can remove it.

次の質問に答えて、V が減少することを示してください。回答はそのコメントに記載します。

  1. R は何を変えますか? 正確なファイル、フィールド、タイムスタンプを挙げてください。
  2. その対象は T のオペランドですか? そうであり、R によってそれが「発火」する方向へ戻るなら → V は存在しません。
  3. R の後、T を再び真にする最小の入力は何ですか? 答えが「今発火したのと同じ入力」であれば → V は存在しません。しきい値を調整するのではなく、述語を再設計してください。

実際の反例。 Stop フックは、成果物を重ねて追加する(ルールファイル、スキル、その他のフック)変更をプッシュする前に、独立したレビューを必須としていました。

  • T(フックを発火させる条件)=「レビューでカバーされていない編集がある」。これはタイムスタンプ比較 last_edit > last_review として実装されていました(last_edit = 成果物一式における最新の mtime、last_review = レビューレコードの mtime。どちらも単一の数値であり、比較が安全そうに感じられる理由もまさにそこにあります)
  • R = 独立したレビュー担当者をディスパッチする しかし、実行する価値のあるレビューには出力があります。その所見は同じエージェントによって、即座に、次に停止しようとする前に採用される → それが新たな編集を生む → last_edit が last_review を追い越す → T が再び真になる。(人間が後から、帯域外で所見を採用したなら、ループは生じない — ループには是正と再チェックが同一エージェントのターン内で起きる必要があり、まさにそれを Stop フックが保証します。)V は存在しません — 是正は量を減らすのではなく、量をリセットします。唯一の脱出路は「レビューして、何も変えない」であり、それはまさにレビュアーを派遣することが無意味だったケースです。観測された事実: 3 回連続のラウンドが、それぞれ完全なレビュー・採用サイクルであり、ユーザーが停止を告げることによってのみ終了しました。

このことを見えにくくしている要素が 2 つあります。この比較は単独で見ると完全に合理的に見えます — 「レビューは最後の編集より新しい必要がある」はまさにあなたが書くであろう内容です。そしてその文はパス条件です — T はその否定です。それをそのまま、否定を付けずに頭にコピーしてしまうと、以降の分析全体で誤ったオペランドについて推論することになります。T は発火条件として向きを保ってください。(コードを早期リターンのガード節として書くこと — … && exit 0 — は通常のシェルスタイルであり、ここで言っていることではありません。規律は、どちらの向きで推論するかについてです。そして等価性に注意してください: 同一秒の mtime はパス側に着地します。つまり fail-open であり、これはこのルールが以下の状態読み取りに要求するものと一致します。)上記のチェックリストを実行すれば、機械的に導かれます: R は last_edit を変更する(Q1)。last_edit は T のオペランドである(Q2)。T を再発火させる最小の入力は是正自身の出力である(Q3)→ V は存在しない。

2 つ目の失敗形態: 述語が是正をまったく見ることができない(可観測性ギャップ)。 上記の反例は、是正が動かす時間的述語でした。同じ系統のより静かな失敗: 是正は起きているが、述語がそれを読むチャネルがこの環境に存在しない。実際のケース(2026-07-26、全フリートのループ監査で発見): Stop フックが「独立したレビューが行われた」ことを、tool_results を agentId: <hex> で走査し subagents/agent-<hex>.jsonl を読むことで検出していました — メインプロファイルでは正しい。チームモードのセッションはまったく異なるスキーマを使います(spawn レシートは agent_id: <name>@session-<uuid>、配信は teammate_id のチームメイトメッセージ、ファイルは agent-a<name>-<hex>.jsonl)— 一致はゼロ、常に。そのため last_review は永遠に None のままで、複合編集∧プッシュのすべてのターンが要求を再発火させました: 偽陽性ループであり、1 つの停止シーケンスにつき 1 ブロックに制限されるものの、ターンをまたぐと無制限で、そのセッションの「2/2 発火」はどちらも完全にレビュー済みの作業に対してでした。同じ系統、異なる薬: 時間的ループにはより良い述語が必要であり、可観測性ループにはより良いチャネルが必要です。チェックリストに 4 つ目の問いを追加してください — Q4: このフックが実行されるあらゆる環境で、述語は実際に R が起きるのを「見る」ことができるか? トランスクリプトを読むフックにとって、それは各プロファイル/モードの実際のセッションを解析することを意味し、1 つのスキーマをフィクスチャでテストすることではありません。(上記のケースの修正: マルチスキーマ検出 + チームメイト配信をターン境界から除外して、検出ウィンドウを切り詰められないようにする — 落とし穴 #20。)

まず軸で選び、次に順序で選びます。 0 はそもそもブロックするかどうかを決めます。1 はどのイベントに引っ掛けるかを決めます。2〜4 は述語の形です(1 を選んでも 2〜4 のいずれかが必要です)。0→4 の順序は「ループがどれだけ完全に除去されるか」であり、それはあなたが強制できる量と逆になります — ですから、単に最初のものではなく、実際に必要な強制力をもたらす最初のものを選んでください。 0. ブロックせず、注入します。 要求が助言的なもの(モデルに R を 検討してほしいだけで、R がなければ処理を完了できないわけではない)なら、それを出力して 0 で終了します。 再評価は行われないため、終了することを証明すべきループはありません。Tier-0 未満のあらゆるものに適したデフォルトです。ただし、代償も明確です。リマインダーは無視されることがあるため、ヘッダーにフェイルオープンであることを記載してください。ルール 4 の要点は変わりません。対象が通り抜けられるゲートはゲートではありません。ゲートが必要なら、2 を使い、記録を残すためのコストを負担してください。注入チャネル:Pattern D。 ⚠️ Stop ではこのオプションは使えません — そしてルール 7 の主な対象は Stop なので、これを使う前にお読みください。Stop では、exit 0 は「ターンを終了させる」という意味なので、テキストが届く後続の推論ステップはありません。また、hookSpecificOutput.additionalContext は exit 2 と同じ 8 ブロックの上限にカウントされます(hook-types セクションを参照)。つまり、これもブロックです。Stop にはゲートか沈黙かの 2 つのモードしかありません。そのため、Stop でメカニズム 0 を選ぶことは、イベントを変更することを意味します。つまり、アーティファクトを生成したツール呼び出しに注入をひも付ける(PostToolUse、Pattern D)か、やはりゲートが必要だったと認めて、メカニズム 2 に進みます。 繰り返し行う助言は、セッション全体で利用できる状態にしてください。 経過時間、アクティビティのしきい値、各プロンプトまたは配信後のリセットで、その頻度を制限します。セッションごとの生涯回数は設定しないでください。生涯回数を設定しても、間隔制御で頻度がすでに下がった後に、バースト頻度がさらに下がるわけではありません。リマインダーが最も必要とされることの多い最長のセッションで、最終的な利用可能性が定期的なものから永久に沈黙するものへと変わってしまいます。以下のメカニズム 3 は、ブロッキングする修正ループの終了予算であり、一般的なスパム対策パターンではありません。両方の境界を一連の流れとしてテストしてください。どちらかの頻度しきい値に達しない間は何もせず、5 回目、10 回目、またはそれ以降の、間隔が完全に満了したウィンドウでは配信されることを確認します。 ⚠️ 「ループなし」が成り立つのは、R が独自のマッチャーの対象ではない場合だけです。 Bash に、モデルに git ls-remote を実行するよう伝えるインジェクターを設定すると、そのコマンド自体で再び起動し、再注入します。同じ構造ですが、より穏やかです。モデルは無視できるため、強制的な反復はありませんが、何も起こらないのではなく、繰り返すたびにブロードキャストされます。推奨する R が、このフックにマッチするアクションではないことを確認してください。

  1. チェックをアクションの境界に移動します。 ゲートしたいものがアクション(プッシュ、パブリッシュ、削除)である場合、Stop でターンをガードするのではなく、PreToolUse でそのアクションをガードします。Stop フックの是正ループは、しばしば誤ったイベントに取り付けられたアクションゲートです。これは、フックタイプのセクションにある「Stop は異端であり、最もよく誤って手を伸ばされるもの」の具体的なケースです。 これで何が得られるかを正確に述べておきます。 PreToolUse が再発火するのは、モデルが自発的にゲートされたアクションを再試行したときだけであり、モデルはいつでも拒否してターンを正常に終了できます。したがって、これが保証するのはターンが終了することです——最悪のケースは「ターンが終了できない」から「このアクションが発生しない」に変わります。ただし、収束しない述語を収束させるわけではありません。上述の反例を取り上げ、そのまま PreToolUse に移しても、ループはそのまま生き残ります(プッシュ → ブロック → レビュー → 指摘の採用 → 新たな編集 → 再試行 → last_edit が再び先頭 → ブロック)。このケースは述語が病んでいるのであり、イベントが病んでいるのではありません。そのため、2〜4 の中から形を選ぶ必要があります。また、PreToolUse にはハーネスのバックストップがありません——フックタイプの表にある 8 ブロックの上限は Stop 専用です——そのため、自己リセットする述語をここに移すと、安全網は増えるどころか減ります。 ⚠️ このメカニズムが誤った答えとなる 2 つの形:R がゲートしたまさにそのツールで実行される必要がある場合(Edit のガードがまずファイルヘッダーを修正することを要求する——デッドロックで、何もそれを満たせません)、および1 つのセッション内で再発するアクション(5 つのリポジトリをプッシュするセッションでの git push ゲート = 5 回の完全な要求。これが以下の戦訓にある密度の問題であり、メカニズム 1 はそこから免除してくれません)。

  2. 「すでに是正済み」を時間的な事実ではなく存在の事実にし、是正が必要だった対象にキーを付けます。 R に成果物を残させ、それが存在するかをテストします。この仕組みを機能させるのがキーです:

    KEY=$(git rev-parse HEAD 2>/dev/null || printf 'nogit')   # or a hash of the
    RECEIPT="${TMPDIR:-/tmp}/my-guard.${KEY}.ok"              # reviewed content
    [ -f "$RECEIPT" ] && exit 0            # V = 1 - exists, for THIS key
    

    V = 1 - exists はキーごとです:キーごとにちょうど 1 回減少し、そのキーに対して押し戻されることは決してありません。新しい作業は新しいキーを生み出します——それは再武装ではなく、新しい要求です。どちらの素朴なキー付けも失敗します。1 つのグローバルパスでは、フックがマシンごとに 1 回発火し、その後はシグナルゼロのまま永久に死んだ状態になります。時間ベースのキーは、このルールが禁止するために存在する時間的述語です。時間的述語はほぼ常に誤った形です。なぜなら、あなたが要求した是正こそが、比較対象のオペランドを動かすものだからです。 このコンテンツ SHA キーは、ワンショットのレシートゲートには正しいです。ただし、エージェントレビューの系列を再定義するものではありません:その系列の是正によって作成されたコミットは、元のキーと元の予算の下にとどまります。 ⚠️ モデルがレシートを作成できる場合、これはルール 4 の廃止された GUARD_OK=1 脱出ハッチが新しい姿をまとったものです。モデルが駆動しないもの(レビュー担当サブエージェント自身の出力ファイル、git ノート)に書かせるか、あるいはフックが助言的なものであることを認め、そのヘッダーにそう明記してください。

  3. ブロックする是正サイクルには上限を設けます——再発する助言的な配信には決して設けません。 フックが有界のループを強制しており、上限に達した終了が明示的にアクションをブロックされたまま、未出荷のまま、または保留のままにする場合にのみ、セッションごと・ターゲットごとに最大 N 回の要求を使用します。session_id はその終了予算にとって安定したキーです(すべてのイベントに含まれます。パターン参照の JSON 契約を参照):

    SID=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('session_id','nosid'))" 2>/dev/null || echo nosid)
    CNT="${TMPDIR:-/tmp}/my-guard.${SID}.count"
    N=$(cat "$CNT" 2>/dev/null || echo 0); N=$((N+1)); printf '%s' "$N" > "$CNT"
    if [ "$N" -gt 3 ]; then
      CAPPED_REASON='Loop budget exhausted; the blocked condition remains unresolved. Do not report completed.'
      python3 - "$CAPPED_REASON" <<'PY'
    import json, sys
    print(json.dumps({"continue": False, "stopReason": sys.argv[1]}))
    PY
      exit 0                              # explicit capped stop, not silent success
    fi
    

    粗雑で、R が実際に起きたかどうかを意図的に無視しますが、有限です。 これが欠けていた性質です。$$ や $PPID に置き換えてはいけません。 フックの実行ごとに新しいプロセスが起動するため、これらは呼び出しごとに 変わり、カウンターが累積しません。カウントを出力します(「リマインダー 2/3」)。 この表現がなぜ重要なのかは、以下の実話をご覧ください。上限に達したときの出力には、 ドキュメントに記載されている汎用の continue:false / stopReason JSON フィールド を使います。これにより、区別のつかない正常な Stop ではなく、上限に達して停止したことを ユーザーに示せます。これでセッションは停止しますが、公開アクションは保護されません。 上限に達して終了した時点で成果物が未公開だと示される場合は、PreToolUse を使って アクションの境界で別途強制してください。

    このカウンターを、継続的な可用性を目的とするインジェクターにコピーしないでください。 リマインダーの間隔が短すぎる場合は、実際の利用状況を観察して頻度やヒステリシスを 調整してください。上限に達した状態が「条件はまだ存在するが、フックは永久に沈黙する」 となるなら、カウンターに正当な終端状態はなく、メカニズム 3 は不適切な設計です。

  4. ヒステリシス/クールダウン期間( アラートのフラッピング に対する制御理論上の解決策です):発火後、一定期間は再評価を抑制します。スタンプファイルと [ $(( $(date +%s) - <stamp mtime> )) -lt 900 ] && exit 0 を使います(mtime は BSD/macOS では stat -L -f %m、GNU では stat -L -c %Y です。ここにある他の スニペットも同様です。インストーラーがシンボリックリンクを使う場合は -L が 不可欠です。これを付けないとリンク自体の mtime を読み取り、SSOT を編集しても スタンプが更新されません — #41)。これはしきい値付近で揺れ動く条件に適しています。 是正措置によって条件がリセットされる場合は不適切です。その場合は 2 または 3 が 必要です。 ⚠️ ヒステリシスは V を提供しません。これはレート制限であり、終了の証明では ありません。 ループが終わるのは、条件が自然に収まる場合に限られます。そして、そのとき ループを終わらせるのはフックではなく世界の側です。そのため、# TERMINATION: 行には 外部の事実を明記する必要があります(「スタンプの期限が切れるまでに、X は <誰によって> 解決されている」)。その行を正直に書けないのであれば、必要なのは 2 または 3 です。 クールダウンはアドバイザリーが発言する頻度を制御しますが、繰り返されるアドバイザリーを、 メカニズム 3 が制御する上限付きの是正ループに変えるものではありません。

状態そのものの障害方向:単語に反応せず、ルール 5 の問いを適用してください。 状態を読み書きできない場合(書き込み不可の TMPDIR、サンドボックス、ディスク容量不足)は、 ルール 5 のガードクラス表で判断します。判断基準は、「この状態が消えても、是正措置は 可能か?」です。メカニズム 2 と 3 では、答えはいいえです。レシートは R が起きたことを 記録する唯一の手段であり、メカニズム 3 は R が起きたかどうかを意図的に無視するため、 カウンターだけが唯一の終了手段です。したがって、停止を許可してください。自身の状態を 読み取れないのに処理をブロックする終了メカニズムは、それ自体がループとなり、しかも人間に 原因が見えなくなります。 ⚠️ メカニズム3を、どちらも「カウンター」と書かれているという理由だけで、ルール5の「逆のサブケース」に振り分けないでください。 このサブケースが対象とするのは、ユーザーが独立して満たせる述語に加えて、カウンターが 注意喚起の回数だけを制限する ケースです。ここでは、読み取れないカウンターを無視すると、ほかにも解除方法があるフックの通知を抑えてしまうため、述語の評価は続けます。メカニズム3には、そのように代わりに使える述語はありません。実測でも、この組み合わせでは次の失敗が発生します。 メカニズム3のスニペットを Pattern E のひな形(-e とトラップに関する箇条書きにあるとおり、set -uo pipefail を含みます)に貼り付け、書き込み不可のディレクトリを TMPDIR に指定すると、5 回連続で終了コード 2 が返されます。N は 1 を超えて保持されず、上限に達することはありません。また、修正にツール呼び出しが含まれる場合の代替策としてハーネスの上限を使うことは、すでに #27 で除外されています。ここでの失敗の方向を決めるのは、スニペットに含まれない set 行だけです。したがって、実際に失敗するステップ、つまり書き込みにガードを置き、読み取りには決して置かないでください。

printf '%s' "$N" > "$CNT" 2>/dev/null || exit 0   # can't persist ⇒ can't terminate

読み取りはすでにガードされています(cat … 2>/dev/null || echo 0)。この形は維持する必要があります。 カウンターファイルがないのは通常の初回実行時の状態です。そのため、読み取りに || exit 0 を付けると、環境がまったく正常でもフックが永久に無効になります。実測では、各バリアントを5回連続で実行した結果、書き込みをガードした場合は、書き込み可能な TMPDIR で 2,2,2,0,0、書き込み不可の環境で 0,0,0,0,0 となり、どちらも正しい結果でした。一方、読み取りをガードした場合は、どちらの環境でも 0,0,0,0,0 となり、ガードが一度も発動しません。

要求文の説明だけでは、収束する述語の代わりになりません。 メッセージに「不要だと判断したら、そのまま再度完了してください」と書かれていても、モデルには通常、X を実行するよう指示すると実行するため、毎回、修正サイクル全体が発生します。抜け道は助言ではなく、述語に用意する必要があります。

テスト要件、そしてここで最も見落としやすい点は、自己テストに「修正後」のケースが必要なことです。「想定どおりに発火する」だけではなく、「R の完了後は発火しなくなる」ことも確認します。これがないと、非終了は構造上検出できません。各フィクスチャが独立した一点の判定にすぎない一方で、非終了は一連の処理に関する性質だからです。一点だけを確認するテストスイートは、ケース数がいくら多くても収束をまったくカバーできません。そのため、自己テストが成功していても、フックが実際の初回遭遇時にループする状態でリリースされることがあります。これを検出できる行のペア(受領証なし → 発火、受領証あり → 発火しない。通常の run 行では表現できないセットアップ/ティアダウンを伴います)は、scripts/test_hook.sh の「AFTER-REMEDIATION ROWS」にひな形があります。症状 → 原因 → 修正については、落とし穴 #16 を参照してください。 終了が証明された ≠ 終了したと感じられる(2026-07-25 の体験談)。 正しい存在事実 V を持つ Stop フックが、1 回のセッションで 3 回発火しました。それぞれの発火は異なる完了済みタスクからの正当な新規プッシュであり、メカニズムは設計どおりに正確に動作していました。それでもユーザーの体験は「なぜこれはループから抜け出せないのか?」というままでした。(メカニズム 2 の「R が行うことは何もそれを押し戻せない」と矛盾しません。V はキーごとです。3 つの異なるキー、3 つの別々の一方向の減少です。これは、自分がどの状況にいるのか判別できないときの診断にもなります。各発火が新しいキーを伴うなら、メカニズムは正しく、問題は密度です。繰り返しの発火が同じキーを共有している場合 — あるいは述語がキーをまったく持たない(タイムスタンプを比較している)場合 — は、上記の反例の状況であり、述語を置き換える必要があります。)3 つの独立した是正サイクルが連続すると、外から見るとループと区別がつきません。変種証明はメカニズムを確定させますが、1 回のセッションがどれだけ多くの異なるブロッキング是正を要求しうるかについては何も語りません。ブロッキング Stop フックがそのような密度を生む場合(ここでは複合的な成果物が 1 日に数回出荷されます)、メカニズム 2(存在事実)とメカニズム 3(セッションスコープの終了予算)を組み合わせることを検討するか、あるいはその見え方を意図的に受け入れて、フックの出力でその旨を明示してください —「ブロッキング要求 2 / 最大 N」は進捗として読めますが、装飾のない繰り返しはループとして読めます。反復的な勧告インジェクターの場合、密度はケイデンス/ヒステリシスによってのみ解決してください。ライフタイム上限は、セッションの途中で製品を黙って引退させることになります。 (2026-07-26 の続報:同じフックの発火で、この密度問題のように見えたものは 100% 誤検知であることが判明しました — チームモードでレビューチャネルがスキーマを認識していませんでした。上記の可観測性の形式を参照してください。密度を「正当」と受け入れる前に、発火がそもそも証拠に基づいているかを確認してください。)

終了と価値は別々のゲートです。 V はループが終わることを証明します。Loop Contract の予算と上限付き終了は、別のサイクルに支払う価値があるかどうかを決めます。この区別を明らかにした、フックのないレビューループの失敗は、なぜスコープのドリフトが黙って無限の「新しい」作業を生み出したかを含め、落とし穴 #36 にあります。

8. 待機にも同じ証明が必要です — 通知はあくまで参考であり、ポーリングには予算を設ける必要があります

ルール 7 は、フック が作るループを対象としています。同じパターンは、フックが関与しない場合にも繰り返されます。非同期の結果をポーリングするエージェント、たとえばサブエージェントの報告、バックグラウンドタスクの完了通知、CI のステータスです。実際のセッション(2026-07-25)では、サブエージェントの完了通知が、遅延または消失する可能性のあるメールボックスを通じて届きました。通知が未配信のまま、3 つの別々のエージェントが作業を終え、待機していたエージェントは、人間から何をしているのか尋ねられるまで、約 40 分にわたって sleep 240 と催促を十数回繰り返しました。エラーは何も起きていません。ループに変化の余地がなかっただけです。

ルール 7 の仕組みはそのまま応用できます。最初の 2 つは直接当てはまります。ヒステリシスには対応するものがありません(待機は振動しません)。その代わり、待機に特有の落とし穴に対応します。

  1. 通知ではなく、成果物をポーリングします。 本当に必要なのが結果(ファイル、git ref、API の状態、DB の行)であるなら、「完了したと言ったかどうか」ではなく、その結果 を待ちます。通知はヒントであり、成果物が事実です。存在確認なら、メッセージが届いたかどうかに関係なく、事実が到着した時点で終了します。
  2. すべての待機に、ループを書く時点で決めた予算を設定します。 最大ラウンド数 × 間隔(例:3 × 4 分)に加え、最初のスリープより前に、劣化時の対応を用意します。自分で実行する、ユーザーに尋ねる、または保留として印を付けて別の作業に進む、などです。「無期限に待って様子を見る」は劣化時の対応ではありません。それ自体がループです。
  3. 配信プロトコルは参考情報であり、仕組みではありません。 「完了したら SendMessage で報告してください。応答がなければ未完了とみなします」と書く価値はありますが、これはエージェントが送信するかどうかを定めるものであり、メールボックスが配信するかどうかを定めるものではありません。そのセッションでは、プロンプトにこのプロトコルを含めた 3 つのエージェントすべてが沈黙しました。通知が届かない可能性があるものとして待機を設計してください。実際に届かないこともあるからです。

同じセッションで実際に問題となった、関連する落とし穴が 2 つあります。実際には作業の途中である「停止した」エージェントを TaskStopping してしまうことです。メールボックスの遅延は、アイドル状態を意味しません。実際のコーパステストを 20 分間行っていたレビュアーが、成果を届ける数分前に「停止した」として終了させられました。もう 1 つは、待機そのものを --dry-run 形式で調べることです。相手側が沈黙していると結論づける前に、自分側の観測チャネルが機能しているかを確認してください(そのセッションでは、System Events のウィンドウ数取得が、画面に表示されているダイアログに対して自信ありげに 0 を返しました。権限エラーが証拠のように見えていたのです)。

9. フィクスチャでは誤検知率はわかりません — 登録前に実際のコマンドコーパスを再生してください

ルール 1 は、どちらのエラーがより深刻かを定めています(ガードを回避する必要があると、人は反射的に回避するため、誤ブロックは見逃しよりも問題です)。ルール 2 は、登録前にテストすることを求めています。しかし、どちらも実際の誤ブロック範囲がどれほど広いかは教えてくれません。テスト表ではわかりません。なぜなら、検出器を生み出したものと同じメンタルモデルから、その入力を作ったからです。テストケースには思いついた形が含まれていますが、思いつかなかった形は、構造上、含まれていません。これは行を追加しても埋められないカバレッジの不足です。

2026-08-06 に測定。 PreToolUse/Bash ガードは、5 つの変更を加えて実行した 26 ケースのテスト表に合格し、登録されました。その後、直近のセッショントランスクリプト 60 件から収集した重複排除済みの実際のコマンド 11,903 件に対して再生したところ、143 件が候補となり、実際のフックは 46 件をブロックしました。その 46 件すべてを手作業で確認した結果、10 件が誤りであり、ブロックした全件の 21.7%を占めていました(境界上の 2 件は正しいものとして数えました。別の判定をすれば約 30% です)。39 分以内に実際のセッションを 3 件ブロックし、そのうち 1 件には存在し得ないスキルを読み込むよう指示していました。下記の 3 つ目の欠陥が、実体のない修正ガイダンスとして表面化したのです。同じ時間帯のうちに削除されました。3 つの根本的な欠陥はすべて解析層にありました。行継続トークンを区切りとして解釈していたこと、セグメントのスキャンでヒアドキュメントの本文やデータ引数を実行として数えていたこと、パス分類器に型フィルターがなかったことです。 あの話が決して示唆しない 2 つの結論があります。それは 「ミューテーションテストは機能しない」 ではありません。同じスイートを後日監査したところ、フックのロジックのうち 5 か所は削除しても 26 行すべてが依然としてグリーンであることが判明し、ある行はその作者が「装飾的から修正済み」と注記していたにもかかわらず依然として装飾的なままでした——「ミューテーションテストを実行した」は「そのミューテーションテストが正しかった」ではない、そして両方を暴いたのはリプレイです。また 「知りようがなかった」 でもありません。それらの欠陥のうち 3 つは、このファイルがすでに出荷しているコードの手書きの再実装でした(split_shell_lines、コマンド位置の走査、is_git_write の -C とその引数の扱い)。ルール 1 のウォーカーをそのまま使うは、飛ばされたより安価な修正でした。リプレイは最後の砦であり、第一線ではありません。

手法——特にステップ 2 に注意してください:

  1. 収集。 セッショントランスクリプトは ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl にあり、加えて ~/.claude/history-sources.json に登録された任意のアーカイブがあります。コマンドは .message.content[] のエントリで、 type == "tool_use" かつ name == "Bash"、フィールドは .input.command です——not フックイベントの .tool_input.command ではありません。これは異なる形です。重複を排除し、あなた自身の最近の作業形状がそこに含まれるだけのトランスクリプトを取得してください(その実行では 60 を使用しました)。
  2. 出荷済みの検出器をそのまま切り出して前処理フィルタに使う——決して手書きの equivalent ではなく、さもなければあなた自身の推測 2 つの一致度を測ることになります。前処理フィルタはコストのためだけに存在します(11,903 → 143)。決定するのはステップ 3 です。検出器が持ち出し可能なブロックでない場合——シェル、またはソースされたライブラリにまたがって分割されている——それを 1 つにしてください。単体で実行できない検出器は、単体テストもできない検出器でもあります。
  3. 各候補を、それ自身の実際のトランスクリプトと session_id とともに、本物のフックに供給します。 セッション状態を読むガードは、捏造されたコンテキストの下では異なる答えを返します。イベント契約:references/hook_patterns.md。2 つのことが噛みつきます——スクラッチの TMPDIR の下で実行してください。さもなければルール 7 のレシートとセッションごとのカウンターが実際のセッションに書き込まれ、かつあなた自身の測定の途中でガードを沈黙させます。そしてフックに人間のゲートがある場合、143 個のダイアログに答えるのではなく、パターン B の強制拒否パスを通して駆動してください。
  4. すべてのブロックを手作業で確認してください——ブロックリストが偽陽性の測定です。 許可リストは代わりにルール 5 の問いに答えます(何もなかったから静かになったのか、それとも見えなかったからか?)。そしてゼロ個のブロックを返すリプレイは、クリーンなお墨付きではなくハーネスを容疑者にします——ピットフォール #11 は、発火不足の方向にこの同じ計器を処方しています。

その数値をどう扱うか。 修復ガイダンスが誤っているか不可能な偽ブロック → まったく登録しないでください。その形状は、ルール 1 が防ぐために存在する反射的バイパスルールの製造工程です。そうでなければ、ブロックリストを修正リストとして扱い、再リプレイしてください。偽陽性は、ガードを書いている間にあなたが何をしていたかに集中することを予想してください——その実行の半分はフック開発ファイルに着地しました。なぜならその作者はその週にフックを構築していたからです。そしてブロックリストを特に ops アクション(hooks ディレクトリ下の編集、フックに対する bash -n、ガード自身の SSOT)についてスキャンしてください。自分自身の削除をブロックするガードは、セッションの内側からオフにできません。最も近い記録された事例は #25 で、ガードが読み取り専用の git config core.hooksPath クエリをブロックしました——自己ロックアウトまであと一歩の同じ盲点です。ガードが一度あなたをロックアウトしたら、脱出経路は #3 のリストにあります(Edit/Write ツールで settings.json を編集する。これは決して Bash マッチャーを発火しません。または異なる CLAUDE_CONFIG_DIR でセッションを開始する)。 そのクラスタリングは傾向にすぎません。検出器がテキストパターンであるガードの場合、その一族の一つはむしろ確実です。アンチパターンを文書化することは、それ自身のトリガーを再現してしまうのです。ガードを説明するコミットメッセージ、ドキュメントの例、自分のナレッジベースに書き込むメモ — そのいずれもが、禁止された形をデータとしてそのまま運び、そしてそれぞれがコーパスに紛れ込みます。2026-08-30 に 852 コマンドのリプレイで測定したところ、ガードの主要な形に一致した 4 コマンドのうち、3 は正常でした — ガードについてのコミットメッセージ、パターンを埋め込んだドキュメント書き込み、そして違いを示すために良い形の隣で意図的に悪い形を実行するキャリブレーションコマンドです。パターンを額面通りに受け取る検出器は、自身のシグネチャ形状に対して 75% 誤っていたことになり、それらブロックのすべては、ガードを書き留めている最中の著者に、文の途中で突き刺さっていたでしょう。2 つの除外がこの一族を退場させ、そのどちらも特殊ケースではありません。データシンクのヒアドキュメント本体(references/hook_patterns.md、コマンド位置ウォーカーのヒアドキュメント制限下 — シンクを判別するストリッパー)で、これはコミットメッセージとドキュメント書き込みをカバーします。そして同じコマンド内に正しい形が存在することで、これはキャリブレーションをカバーします — バグの隣に修正を書いた著者は、それをコミットしているのではなく、実演しているのです。2 つ目は、パイプフォールバックガードが使う pipefail のエスケープハッチと同じ形です。コマンドが、その著者がすでに知っているという証拠を運んでいるのだから、彼らと議論するのはやめましょう。そのメカニズムはもう一つのリテラルテストで、検出器の前に実行され、それをショートサーキットします — pipe-fallback-guard にとってそれは部分文字列セット pipefail/PIPESTATUS/pipestatus(ルール 4)であり、正規の修正を持つ検出器にとってそれは、その修正の特徴的な断片です。狭くリテラルに保ちましょう。正しい形のパターンは推測問題のすべてを再び開いてしまいますが、著者が意図的に入力しなければならなかった固定文字列は、偶然に一致しにくく、その失敗方向はミスです。

規模について、これが研究プロジェクトのように読まれないように: 1 回の収集と 1 回のループで、実時間は数分です。

すでに出荷されているゲートの変更、とりわけ allow ブランチの追加は、逆方向に失敗します。新しいブランチは危険なコマンドを通してしまう可能性があり、偽陽性の数はそれを決して示しません。コーパスを旧バージョンと新バージョンでリプレイし、新ブランチのトリガーをすべての必須ブロック行に組み込み、それらの行が赤になるまでブランチを変異させてください: hook_patterns.md。

10. 正当な作業をブロックするガードには同意チャネルが必要です — 同意シグナルはプロンプトを確認できるフックから取得する必要があります

障害パターンをブロックするために作られたガードは、いずれ同じパターンに当てはまる正当で、ユーザーが許可したケースもブロックするようになります。ユーザーは会話の中で許可しますが、ガードにはそれがわかりません。home-scan-guard(個人用の保管ディレクトリの列挙をブロックする)は、ユーザーが初めて「ディスクを整理するために、Downloads のスキャンを許可します」と言ったときに、この問題に直面します。

コマンドテキストから推測するのではなく、プロンプトの境界で口頭の許可を取得します。明示的な UserPromptSubmit と PreToolUse の登録を維持し、イベントごとのハンドラーでは、許可ロジックとガードロジックを分離したまま、エンジンを共有できます。

UserPromptSubmit granter: reads the user's prompt → matches an explicit
    authorization phrase (consent verb + action noun + target) → writes a
    time-boxed, path-scoped consent file (mtime = grant time)
PreToolUse guard: before blocking, reads the consent file — fresh (≤TTL) and
    covering the target → allow; otherwise block as before

このチャネルがバイパスにならないようにする設計上の制約(すべて2026-09-19の実装で検証済みです。対象のインスタンスは ~/scripts/claude-hooks/ にある home-scan-guard.sh と home-scan-consent-granter.sh です):

  • 必ず有効期限を設けます。 有効期限のない同意ファイルは、ガードを永久に無効化することになります。日単位ではなく、時間単位にします。
  • デフォルトでは包括的な許可を設けず、パスを指定します。 ~/Downloads を許可しても、~/Pictures のロックを解除してはなりません。ワイルドカードの項目は、明示的に別途表現された操作に限ります。
  • 影響範囲が最大のルールは、引き続きハードブロックします。 home-scan-guard ではルールA(ホームディレクトリ全体の再帰処理)が該当し、どのようなフレーズでもロックを解除できません。ガードごとに、どのルールを同意対象にできるかを決めます。通常は「範囲の狭いルールのみ」です。
  • エージェントが同意ファイルを直接書き込むことは、絶対に認めてはなりません。 許可付与スクリプトの入力は、ユーザーが実際に入力したプロンプトです。これは、エージェントが偽造できない唯一のものです。ファイルを直接書き込む(または、曖昧な口頭での「OK」に合うように許可付与スクリプトへの入力を合成する)と、監査証跡が失われ、ガードが形骸化します。この禁止事項を両方のフックヘッダーに記載してください。
  • 曖昧なフレーズでは許可しません。 「我给你授权」のみ(動作を表す語も対象もなし)では、何もロック解除してはなりません。動詞に加えて、動作を表す名詞または指定された対象を必須とします。許可付与スクリプトの失敗時は、誤って許可するのではなく、許可を見逃す方向にします。これはガードとは逆です。
  • フレーズによる取り消し(「撤销 home-scan 授权」)と、ファイルの削除に対応します。

有効化の証拠: すでに登録されているスクリプトを呼び出すと、現在のファイルが読み込まれます。解決されたパスと依存関係を確認してください。許可付与スクリプトは、その所有者であるインストーラー(上記のセットアップでは register-hook.sh)を使って登録または復旧し、その後、ルール4の新規セッションでのイベント確認を適用します。許可を受け取るセッションで許可付与スクリプトが実行されたことを確認するまでは、所有者が文書化した、ユーザー自身が操作する代替手段のみを使用してください。利用できる代替手段がない場合は、許可が確認されるまでガード対象の操作を保留します。設定の書き込みから同意の取得を推測したり、登録内容の変更には再起動が必要、または即時にホットリロードされると決めつけたりしないでください。

重要なのはキャリブレーションです: 許可付与スクリプトのセルフテストでは、両方の方向を検証する必要があります。許可のケースが通り、曖昧なケースや否定のケースでは許可されないことを確認します(許可付与スクリプトは誤許可、ガードは誤ブロックが失敗モードです。それぞれについて、両方向のテストが必要です)。ガードのセルフテストでは、次のケースも検証します。同意なし → ブロック、新しい対象限定の同意 → そのパスのみ許可、有効期限切れ → ブロック、同意がハードブロックされたルールを解除しないこと。同意ファイルを作成する状態を変更するセルフテストでは、その前後に実際の同意ファイルをバックアップして復元する必要があります。

構築順序(順番どおり)

別のフックを作成または登録する前に、既存のエンジンを仕組みとホストイベントごとに調査します。入力解析と遅延ファクトクエリを共有する、プロセス内のルールモジュールを優先します。各ルールのツールセレクター、許可の証拠、状態の名前空間、実行間隔、失敗時のポリシーは、それぞれ独立させます。これらの境界だけを理由に、プロセスを分ける必要はありません。マッチャーを統合するときは、Bash 以外のツールも含め、内部セレクターを使って元の適用範囲を維持します。また、状態を書き込む処理は元のライフサイクルイベントに残します。 結果を組み合わせる際は、ホストプロトコル全体を保持してください。exit 0/1/2、構造化された拒否、補足コンテキスト、エラー診断を維持します。あるルールの許可によって、別のルールの拒否を解除してはいけません。ホスト/イベント/ランタイムの契約を統合できない場合や、独立した人間の承認待ちを統合すると順次処理になったり、既存の制限時間が短縮されたりする場合は、別々のエントリーを使用してください。インタラクティブな認可ゲートには、短い共通タイムアウトを適用しないでください。

代表的なネイティブホストのタスクで、固有のハンドラーと起動されたインタープリターの両方を検証してください。古いフックをすべて子プロセスとして起動するディスパッチャーでは、登録数は減っても、呼び出しごとの処理はなくなりません。モジュール固有のテストと、観測可能な障害の識別情報を維持してください。無関係な判定を、単一の承認や「すでに通知済み」フラグにまとめてはいけません。

  1. 実際に繰り返し発生することを確認してください。仮定上の話なら、作成しないでください。 フックが単にブロックするのではなく、修正を要求する場合は、ロジックに着手する前に完全な Loop Contract(key / axis / T / R / V / budget / 二つの終了条件)を記述し、V をスクリプトヘッダーに # TERMINATION: 行として記載してください(ルール 7)。まず、ゲート対象が アクション かどうかを確認してください。その場合、そのアクションに対する PreToolUse ガードでループを取り除けるため、抑え込む必要はありません。 trigger → remediate → re-check のサイクルごとに厳密に減少する量を名指しできませんか? その設計は停止しません。正規表現ではなく設計を修正してください。繰り返しカウンターを追加する前に、上限を設けた積状態を名指ししてください。有効なループ予算は、ブロック済み、未出荷、保留中、その他の明示的な終端状態で終了します。「セッションは続いているのに、アドバイザリーが永久に表示されない」は終端状態ではありません。アドバイザリーの配信は有効期間で上限を設けず、複数の期限到来済みウィンドウ後も長期的な活性が保たれることを証明してください。
  2. ルールモジュールは既存エンジンの SSOT に記述してください。別のエントリーポイントを作成するのは、上記の契約で必要な場合に限ります。実行可能モードを設定するのは、直接呼び出されるファイルだけにしてください。
  3. 検出には shlex のトークン単位の照合を使用し(ルール 1)、独自の表示形式や命名規則ではなく、実際の状態について答えられる事実をキーにしてください(ルール 6)。 Skill エントリーのバイパスが繰り返されるケースでは、読み込みと準拠した実行は別々の述語です。所有対象の操作は、実際の書き込み先または転送先で検出してください。読み込み済みマーカー、一時ディレクトリ、繰り返されたリクエストでは解除できません。 元のバイパスに加え、正常なソース保守、フィクスチャ、読み取り専用プローブを使って調整してください。一部のアクションしか検査できない場合は、その適用範囲を明示してください。インストールやユニットテストだけでは、ネイティブなインターセプトが証明されたことにはなりません。
    • まず ShellCheck がすでに判定できるか確認し、その答えを記録してください。次の担当者も同じことを尋ねるからです。 シェルのアンチパターンに対する事実上の標準なので、レビュー担当者がまず尋ねるのは「なぜ shellcheck を使わなかったのか」です。2026-08-15 に 0.11.0 で find . -name x | head -5 || echo "no" を対象に測定しました。これは、|| がパイプラインの最後の段階に結びつき、head は入力が空でも 0 で終了するため、フォールバックが決して実行されないことが証明できる例です。デフォルト設定では何も報告せず、終了コードは 0 です。--enable=all では SC2312(check-extra-masked-returns)が検出されますが、最後の段階が実際に失敗する可能性があり、フォールバックに意味がある cmd | jq . || echo bad に対しても検出されます。 ゲートとして不適格な理由は、いずれも一般化できます。デフォルトで無効(つまり、今は誰も保護していない)、機能しないフォールバックと機能するフォールバックを区別できない(無差別に検出するとルール 1 の誤ブロック悪循環になる)、そして推奨される修正が「無視するために || true を使う」という、意図と正反対のものだからです。また、ツール呼び出しイベントではなくファイルを lint します。 一般的な答えの形はこうです。標準的なリンターは確認には適切ですが、ブロックゲートを委ねる先としては通常不適切です。リンターは幅広い注意喚起を目的に調整され、ゲートには高い精度が必要だからです。フックが提供するのは、その精度です。出荷済みの例は pipe-fallback-guard です。精度を保つために、上流の終了コードを実際に握りつぶす最終段階のコマンド(head/tail/wc/cat/sort/…)を少数列挙し、実際に失敗する grep/jq/awk/sed は意図的に除外しています。
  4. トリガーに該当するケースと正常に見える類似ケースの両方で、モジュールと実際のエントリーポイントを検証してください(ルール 2)。シェルのエントリーポイントでは bash -n と test_hook.sh を実行してください。成功するまでは登録しないでください。展開されていないパスを含むパターン(cd ~/elsewhere && …、ルール 5)を含めてください。フックに人間の判定ゲートがある場合は、判定を強制的に拒否する行(パターン B、「ゲートをテスト可能にする」)も含めてください。修正を要求する場合は、修正後の行のペアも含めてください。つまり、適用証跡がない場合は検出し、それがある場合は何も通知しないことを確認します(テンプレートは scripts/test_hook.sh にあります。ルール 7 — 一時点のフィクスチャでは構造上、停止しない問題を検出できません)。
    • 編集と保守のために双方向のセルフテストを用意してください。 構文と登録のチェックでは、フックが恒久的な no-op に劣化したことを検出できません。ガードが一度も発火しなければ、報告対象が何もないセッションと同じ出力になります。起動時には、所有者が宣言した、上限付きのオフライン --liveness モードを独立して用意してください。そのようなモードが宣言されていない場合は、ロジックを未検証、カバレッジを不明/不完全として報告してください。代替手段として、旧式の --selftest、一式のテスト、ネットワーク呼び出し、マシン監査を試みないでください。カバレッジの境界を正しく保ってください。双方向テストが検証するのはロジックです。実行ビット、シンボリックリンク、登録済みパスが解決されることはデプロイに関する事実であり、ヘルスチェック独自の実行可能性/登録スキャンで確認します。セルフテストが成功しても配線については何もわからず、配線スキャンからロジックについてわかることもありません。どちらも互いの代わりにはなりません。フィクスチャは二つが最低ラインであり、目標ではありません。必ずブロックすべきサンプルと、必ず通過すべきサンプルを一つずつ用意し、「検出しなくなった」と「誤ブロックするようになった」の両方を検出してください。どちらか一方だけでは不十分です。フィクスチャ数ではなく、検出できたミュータント数を基準に規模を決めてください。スイートの調整と同じ方法で、検出器を意図的に壊し、--selftest がゼロ以外で終了することを確認してください。失敗したことのないセルフテストは、exit 0 と見分けがつきません。出荷済みの compounding-edit-review で測定したところ、最初のバージョンのフィクスチャ二つで検出できたのは、14 個中わずか 4 個のミュータントでした。コメントで重要と宣言されていた振る舞いはすべて、セルフテストが OK と表示する一方でアンチループチェックを短絡する変更も含め、カバレッジがゼロでした。その測定で改訂版のスイートは 58 ケースを実行しました。 実際の制約はフィクスチャ数ではなく、セッション開始時の実行時間です。これは毎回のセッションで発生します。この 58 ケースの所要時間は約 5.3 秒で、二つのプローブによる liveness チェックの約 140 ミリ秒に対して長くなります。ミュータントの検出で予算を超える場合は、縮小するのではなく分割してください。宣言された --liveness モードで実行する固定サイズの安価なオフラインプローブと、編集/保守チェックで実行する完全な回帰テスト一式に分けます。 ミュータント検出の基準を下回るまで縮小すると、ガードが停止しているのに成功するセルフテストに逆戻りするだけです。 分割後の完全版には実行トリガーを設定してください。そうしないと完全版が実行されません。 「ビルド時に」は仕組みではありません。変更後に完全なテスト一式を実行することというコメントは、このファイル全体がなくそうとしている「文章による指示と強制措置の乖離」と同じで、同じように機能しません。shared-repo-head-drift で測定した、これを解決する方法(21 ケース/キャッシュなしで 17.8 秒かかるテストを、SessionStart のヘルスチェックでは 9 件のアサーションからなるプローブ、2.2 秒に短縮)は次のとおりです。起動時の liveness チェックを、--selftest や --selftest-full などの保守モードから分離します。所有するビルド/コミットチェックでは検証 ID が変わったときに完全なテスト一式を実行し、SessionStart では上限付きのプローブだけを実行します。完全なテストの合格スタンプがない場合は、ビルド/コミット時に完全な検証が必要だと判断します。起動時に実行する指示ではありません。測定による根拠(2026-10-04、61 フックを運用する環境):完全なテスト一式はキャッシュなしで 1 分 44 秒かかり、セッション開始が同時に発生すると 5〜10 分の停止にまで増幅されます。そのため、ビルド/コミットゲートのセルフテスト対象は、コミットでステージされたファイルに限定してください。そうしないと、別のガードの不具合が原因で、それを修正する別のコミットまでデッドロック状態でブロックされます。「フックのコード」とは、登録されたファイルと、それが実行およびインポートするものです。 ほとんどのガードは、隣接する .py にある分類器を呼び出す薄いラッパーです。そのため、ラッパーだけから取ったシグネチャはロジックを編集しても変わらず、テスト一式はまさに必要な変更が行われた後に実行されなくなります(#49 — 依存関係のルールと、単一プロセスでの実装方法も参照してください)。 フックとその依存関係に加え、解決後のインタープリターとそのバージョン、テストハーネス、関連する設定も、その ID に含めてください。各プローブと起動時スキャン全体に明示的な期限を設定し、タイムアウトまたはキャンセル時には、それぞれが起動した子孫プロセスだけをクリーンアップしてください。登録済みフックと同じインストーラー所有のランタイムから起動し、イベントを消費しないテストでは stdin を閉じてください。子プロセスの出力をすべて破棄せず、上限付きの失敗診断を保持してください。タイムアウト、キャンセル、読み取り不能な ID、不完全なカバレッジは、落とし穴 #53 に従って不明として記録してください。合格スタンプを記録するのは、意図したテストが実際に正常終了した後だけにしてください。この分割は、フックに変更がない場合、ヘルパーが変更された場合、プローブが遅い場合または失敗する場合に検証してください。どの場合でも、SessionStart で完全なテスト一式が実行されることがあってはなりません。スケジューラー自体のソースに対する構造上のガードを一つ設けてください。プログラム本体がコマンド置換内の引用符付きヒアドキュメントにある場合、本文中のコメントに余分な引用符があるだけで、macOS 標準の bash ではファイル全体が壊れます。#57 に従ってヒアドキュメントを外に出すか、スケジューラー自体が監視対象のガードに加わることになります。 プローブ対象のケースを選ぶ際に「最初の N 件」としてはいけません。必ず発火するケースと、何も通知しないケースを一つずつ含めなければ、劣化する二つの方向を両方ともカバーできません。実は何も検証できていない「何も通知しない」ケースにも注意してください。アドバイザリー専用フックは常に 0 で終了するため、run 形式の終了コード行では誤検出を検証できません。says 形式のアサーションが必要です(#40、#14)。
  5. 実際のコマンドコーパスを再生し、すべてのブロックを人手で確認してください(ルール 9)。これは誤ブロックの対象範囲を測るもので、ステップ 4 のフィクスチャ表では構造上確認できません。出荷済みの検出器をそのまま切り出して事前フィルターとして使い、各候補を、実際のトランスクリプトと session_id を付けて、スクラッチの TMPDIR の下で実際のフックに渡してください。
  6. エントリーポイントは所有者を通じてインストールまたは復旧してください(ルール 3)。モジュールを変更する場合は現在のエントリーを再利用してください。シンボリックリンクを作成するのは、そのインストーラーのアクティブエントリー契約で必要な場合に限ります。廃止されたエイリアスは登録しないでください。
  7. エントリーポイントまたはカバレッジが変更された場合に限り登録を調整し、その後プロファイルを収束させ、新しいネイティブセッションで有効なエントリーを検証してください(ルール 4)。モジュールのみの変更ではハンドラーを追加しません。
  8. Tier-0/不可逆なアクションには、人間による確認を解除条件とするゲートを追加してください(ルール 4)。
  9. ソースはリポジトリの通常のフローを通じて永続化してください。 ワークフローが変更されたら、影響を受けるオペレーター向け SSOT と CLAUDE.md のルートを更新してください。既存のルートを再利用し、実装上の事実を重複させたり、恒常的な指示にインシデントの項目を追加したりしないでください。

既知の落とし穴(フックの誤動作をデバッグする前にお読みください)

症状 → 原因 → 修正の完全な一覧:references/hook_pitfalls.md。

ハーネスは隠れた変数です。手作りせず、scripts/test_hook.sh を使ってください。 以下に挙げる手作りテストの失敗要因は、どれも正常に通った場合と同じ出力になるため、成功したように見えてしまいます(2026-07-22、Stop フックのホワイトリストを修正している最中に、1度に3つも発生しました)。

  1. イベントの形式が違う。 Stop フックが読み取るのは last_assistant_message / transcript_path であり、tool_name / tool_input ではありません。PreToolUse 形式のイベントを渡すと、テキストが見つからず → 終了コード 0 → 「誤ブロックなし!」となります。
  2. JSON のクォート。 シングルクォート内の '{\"a\":1}' は、バックスラッシュ付きの引用符をそのまま出力します。json.loads が例外を発生させても、フックの 2>/dev/null || exit 0 がそれを握りつぶすため、すべてのケースが「通過」します。
  3. ルールによって正当に除外されるテストケース。 ベースライン文字列に、ルールが意図的に除外する文字列を使っていました(ガードは <name> Group 形式の造語のニックネームを検出しますが、in the group / group chat という一般的な表現は除外していました。ベースラインの行には、たまたまその一方が使われていました)。ガードが依然として機能することを証明するはずの行が機能せず、テストスイート全体が成功しているように見えました。

また、フックの役目がメッセージを伝えることなら、終了コードではテストできません。 ブロックするフックの契約は主に終了コードで決まるため、run 行でテストできます。しかし、何かを伝えるために存在するフック(正しい代替案を説明する PreToolUse や、注意を促す Stop など)には、終了コードでは見えない第2の出力経路があります。文言を壊したり、条件付きの段落を反転させたり、ヒアドキュメントでセクションを飲み込ませたりしても、終了コードは 2 のままで、すべての行が通過してしまいます。scripts/test_hook.sh の says <label> <event> <pattern> <yes|no> 行を追加し、2つのフィクスチャで両方の極性を検証してください(対象とする入力では表示され、対象外とする類似入力では表示されないこと)。want=no の行だけでは、フックが何も出力しなくても自明に通過してしまうため、フックがメッセージを出すことを証明する want=yes の行と併せて初めて意味を持ちます。正規表現ではなく、固定文字列に一致させてください。検証すべきフレーズには角括弧が含まれていることが多く、BRE の [skill] は s、k、i、l のいずれかを含むテキストに一致する文字クラスだからです。次に、行が失敗することを確認するため、わざと不具合を入れてください。フックをコピーし、各行が検出するはずの正確なバグを仕込み、その行が赤になることを確認します。正しい理由で失敗する様子を実際に確認するまでは、緑色のテストスイートからは何も分かりません。終了コードしか見ていなかったために、24ケースすべてが緑だったテストスイートで、実際に2つのバグが見逃されていました(落とし穴 #14)。

よくあるパターンは、すべてのケースが同じ結果になるのは成功の合図ではなく、異常の兆候だということです。test_hook.sh は、構造上、上記の1と2のパターンを検出します。各 run 行に明示的な expected-exit を指定させ(「何か出力したか」ではなく)、トリガーとなる行と正常な類似ケースの行を併せて実行するため、トリガー行が 0 を返せば、他に埋もれず明確に失敗します。一方、#3 は検出できません。行の内容が意図せず除外対象になっているかどうかは、記述内容に左右される性質であり、どのハーネスにもルールの意図は分かりません。これを防ぐには、習慣で対処するしかありません。まず、確実にトリガーされると分かっているケースを検証し、反応しない場合はフックではなく、その行を疑ってください。

参考資料

  • references/hook_patterns.md — ここで扱うすべてのフックタイプの実行可能なスケルトン、shlex によるコマンド位置の走査処理、JSON イベントの契約です。
  • references/hook_pitfalls.md — 実際に発生したすべての障害モードを、症状 → 原因 → 対処法の形式でまとめています。
  • scripts/test_hook.sh — エンドツーエンドのテストハーネスです。新しいフックを作成したら、その隣にコピーしてください。
  • scripts/test_hook.group-name-guard.sh — 実際の Stop ガードを対象にした、完成済みのハーネス例です。Stop イベントの形式と、免除対象とトリガー対象の行セットが含まれています。フックのパスを $1 として受け取ります(bash test_hook.group-name-guard.sh ~/scripts/claude-hooks/<hook>.sh)。引数なしで実行すると HOOK not found: …/CHANGE-ME.sh と表示されます。このファイルが Stop ガードに求めている2つの要件については、モデルとして使えません。says 行も stop_hook_active の無限ループ防止行も含まれていません。これらについては scripts/test_hook.sh を参照してください。

メンテナンス — 新しい内容の追加先

新しいインシデントのバックポートは、このファイルではなく、該当する種類の内容をすでに扱っている場所に追加します。新たな落とし穴や障害の詳細は references/hook_pitfalls.md に、再利用可能な スケルトンやパターンは references/hook_patterns.md に、テストスクリプトなどの完成済みハーネス例は scripts/ に追加します。このファイルに追加するのは、契約レベルのルールだけです。つまり、ブロッキングフックがすべて適用する内容(新しいフックタイプ、終了コードの契約変更、## Rules that separate a working guard from a session-poisoning one の一連のルールへの新しいルール)です。トリガー時に読み込まれる内容の範囲は安定させつつ、ナレッジベースは拡充し続けます。詳細はポインターを1つたどった先に置きます。

これを記録している理由(2026-08-02):バックポートはこれまでも常に references に追加されてきました。このファイルが1週間で10k文字から50k文字に増えたのは、ルールの説明(ルール5〜8が本文に追加されたもの)によるものであり、ここで定める方針では意図的にこのファイルに残します。この方針の目的は、新しいインシデントを抱えた次のセッションに既定の扱いを明確に示し、今後の拡大を契約レベルのルールに限定することです。当時存在していた8つのルールを構造的に分割する案との比較検討(コスト/SSOT/アーキテクチャ/根拠の4つの観点から反対尋問を実施)の結果、この案が選ばれました。次に誰かが分割を提案したときに、分割を再検討する基準は次のとおりです。測定結果(印象ではなく)により、メインファイルのサイズがルール遵守を損なうと示された場合、またはスキル全体の変更が落ち着いた場合(ルールやバックポートがどこにも追加されない状態が連続30日続いた場合)です。

コメント