PreToolUse / PostToolUse の使い分け
これが最も使う頻度の高いペアです。
PreToolUse の役割 :ツール実行前に「本当にこのコマンド、実行しても大丈夫か」を判定します。
実装例:git コマンド実行前に、ステージされていない変更がないかを確認します。
{
"hook_type" : "PreToolUse" ,
"event" : "git" ,
"validation" : {
"check_unstaged_changes" : true ,
"allow_force_push" : false ,
"require_commit_message" : true
},
"action" : "block_if_unstaged"
}
このフックを設定すると、Claude Code が git push をしようとした時に、自動的に git status を先に実行してくれます。未ステージの変更があれば、Claude Code に「これらの変更はコミットされていません。続行しますか?」と確認させられます。
PostToolUse の役割 :ツール実行後に「このコマンド、本当に成功したか」を判定します。
npm install は通ったのに、依存関係の競合が残っておりました。git merge も終わったはずが、コンフリクトの痕跡が残っていました。そうした「表面上は成功、実際は未解決」という状況を拾うのが PostToolUse です。
{
"hook_type" : "PostToolUse" ,
"event" : "npm" ,
"validation" : {
"check_for_warnings" : true ,
"verify_package_lock" : true ,
"run_audit_on_install" : true
},
"action" : "report_and_ask"
}
このフックを設定すると、npm install の後に自動的に npm audit を実行して、セキュリティ脆弱性がないかを確認します。脆弱性があれば、Claude Code に「パッチ推奨」のアドバイスをさせます。
使い分けのポイント :
「実行してほしくない状況を防ぎたい」→ PreToolUse
「実行した結果を確認したい」→ PostToolUse
多くの人は、この判定で迷って「両方設定しちゃえ」となり、結果として「同じチェックが 2 回走る」という無駄が生じます。
UserPromptSubmit でできること(実例)
ユーザーがメッセージを送信した直後、Claude Code が処理を開始する「直前」に何かしたい場合、このフックです。
典型的なユースケース:ユーザーが手動でコードをペーストした時に、それが本当に実行可能な形かどうかを判定したい。
{
"hook_type" : "UserPromptSubmit" ,
"validation" : {
"detect_code_paste" : true ,
"parse_and_validate" : true ,
"suggest_fixes" : true
},
"action" : {
"if_invalid_syntax" : "ask_user_to_fix" ,
"if_missing_imports" : "auto_inject_imports" ,
"if_dependencies_missing" : "suggest_npm_install"
}
}
このフックを設定すると、ユーザーがコードをペーストした時に自動的に以下が起きます:
コードの構文をパースして、SyntaxError がないか確認
import/require が足りていないか確認
依存パッケージが node_modules にあるか確認
実際の例:ユーザーが stackoverflow のコード例をペーストしました。でも、そのコードは lodash の _.groupBy() を使ってます。でも package.json に lodash がありません。
通常なら、Claude Code はこのコードを実行しようとして「Error: Cannot find module 'lodash'」で失敗します。でも UserPromptSubmit フックがあれば「このコードは lodash が必要なので、npm install lodash を実行してもいいですか?」とユーザーに聞いてくれます。
もう1つ、セキュリティの観点から重要な使い方:ユーザー入力に SQL インジェクションの可能性がないか自動検出します。
{
"hook_type" : "UserPromptSubmit" ,
"security" : {
"detect_sql_injection_patterns" : true ,
"detect_command_injection" : true ,
"sanitize_shell_inputs" : true
},
"action" : {
"if_injection_risk_high" : "block_and_warn" ,
"if_injection_risk_medium" : "ask_confirmation"
}
}
Stop / SubagentStop の違いと適切な配置
この2つは混同されやすく、しかも私は向きを取り違えたまま数週間を無駄にしました。
先に、いちばん誤解しやすい点をお伝えします。 Stop フックは「セッションを終わらせるためのフック」ではありません。Claude が応答を終えようとする直前に呼ばれて、その終了を差し止められる フックです。向きが逆なのです。「テストが全部通ったら自動で終わってほしい」は Stop の仕事ではなく、「テストが落ちているのに終わろうとしたら、終わらせずに続けさせる」が Stop の仕事になります。
私は当初、Stop に「目標を達成したら止める」判定を書こうとして、どう書いても効かず、ドキュメントの decision に取れる値が "block" の一つしかないことに気づいて、ようやく向きの取り違えを理解しました。
{
"hooks" : {
"Stop" : [
{
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/stop-if-untested.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# stop-if-untested.sh — テストが落ちたまま終わろうとしたら、終了を差し止める
input = $( cat )
# すでに Stop フックが原因で継続中なら、もう止めない(無限ループ防止)
if [ "$( printf '%s' " $input " | jq -r '.stop_hook_active // false')" = "true" ]; then
exit 0
fi
if ! npm test --silent > /dev/null 2>&1 ; then
printf '%s' '{"decision":"block","reason":"テストが落ちています。失敗しているケースを直してから終了してください。"}'
exit 0
fi
exit 0
このスクリプトで押さえていただきたいのは、stop_hook_active を最初に見ている ところです。差し止めた結果として Claude が処理を続け、また終了しようとして、またフックが差し止める——この往復は放っておくと止まりません。入力 JSON の stop_hook_active は「いま Stop フックが原因で継続している最中かどうか」を教えてくれるので、ここで抜ければ一往復で済みます。
念のための保険も用意されていて、Claude Code 側が連続 8 回の差し止めで打ち切り、ターンを終了します。ただしこれは最後の砦であって、設計として頼るものではないと考えています。差し止めるフックには、必ず自分で降りる条件を書く。 この一点だけは、どんなに急いでいても省かないようにしています。
もう1つ、Stop は「ユーザーが自分で中断したとき」には走りません。API 側のエラーで終わった場合も Stop ではなく StopFailure という別のイベントへ回ります。「終了時に必ず何かしたい」という要件であれば、Stop ではなく SessionEnd を選ぶほうが確実です。
SubagentStop :サブエージェントが終了しようとする直前に発火します。責務は Stop と同じで、スコープだけがサブエージェント単位に変わります。こちらは matcher が使え、しかも一致対象はツール名ではなくエージェントの種類 です。
{
"hooks" : {
"SubagentStop" : [
{
"matcher" : "Explore" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/require-explore-report.sh"
}
]
}
]
}
}
Stop と SubagentStop を取り違えると、「セッション全体を止めたかったのにサブエージェントだけが粘る」という、原因の見えにくい挙動になります。迷ったときは、差し止めたい相手が親なのか子なのかだけを先に決めると、選択はすぐに定まります。
Session 系フックの活用
SessionStart :セッションが始まった直後に自動で実行される初期化フックです。
実装例:新しいプロジェクトを始める度に、自動的に .env.local を生成し、開発環境変数を埋め込みたい。
{
"hook_type" : "SessionStart" ,
"initialization" : {
"check_node_version" : "18.0.0" ,
"install_dependencies" : true ,
"create_env_files" : {
".env.local" : {
"NODE_ENV" : "development" ,
"DEBUG" : "true"
}
},
"run_initial_checks" : [ "git-status" , "npm-audit" ]
}
}
このフックを設定すると、セッション開始時に以下が自動で実行されます:
Node.js バージョンが 18 以上か確認
npm install を実行
.env.local ファイルの生成と、開発環境変数の埋め込み
git status と npm audit を実行
SessionEnd :セッションが終了する直前に自動で実行されるクリーンアップフックです。
実装例:セッション終了時に、生成されたコードのサマリーレポートを自動生成し、メールで送信したい。
{
"hook_type" : "SessionEnd" ,
"cleanup" : {
"generate_final_report" : true ,
"include_in_report" : [ "files_modified" , "tests_run" , "time_spent" ],
"send_report_to_email" : "developer@example.com" ,
"clean_temp_files" : true ,
"archive_session_logs" : true
}
}
Notification フックの正しい使い方
ここは私がいちばん長く誤解していた箇所です。名前から「好きなタイミングで外部へ通知を飛ばすフック」だと思い込み、「コミットしたら Slack へ」という設定を書こうとして、発火条件をいくら探しても見つかりませんでした。
Notification は、Claude Code 自身が通知を出すときに発火するフックです。 通知を出す主体は Claude Code の側であって、こちらが任意の出来事を通知イベントに仕立てられるわけではありません。matcher に書くのはツール名ではなく通知の種類 で、permission_prompt(許可を求めている)、idle_prompt(入力待ちで手が止まっている)、agent_needs_input、agent_completed などが並びます。
つまりこのフックの本領は、「Claude がこちらの返事を待って止まっている」という状態を、手元の画面の外へ運ぶことにあります。私は長時間のバッチを回している間、席を外していても許可待ちに気づけるよう、この2種類だけを転送しています。
{
"hooks" : {
"Notification" : [
{
"matcher" : "permission_prompt|idle_prompt" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/notify-me.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# notify-me.sh — 許可待ち・入力待ちだけを手元の外へ転送する
input = $( cat )
message = $( printf '%s' " $input " | jq -r '.message // "Claude Code が応答を待っています"' )
curl -sS -X POST " $SLACK_WEBHOOK_URL " \
-H 'Content-Type: application/json' \
-d "$( jq -n --arg t " $message " '{text:$t}')" > /dev/null
exit 0
Webhook の URL はスクリプトにも設定ファイルにも書かず、環境変数から読みます。フックはこちらのユーザー権限でそのまま走りますので、設定ファイルへ資格情報を置くのは避けたいところです。
なお Notification は終了コードを見てもらえないイベントです。exit 2 を返しても何も止まらず、標準エラーも無視されます。ここで判定や差し止めをしようとすると、黙って素通りします。知らせるだけの場所に、判定は置かないようにしています。 通知は通知に徹させるのが結局いちばん壊れにくい、というのが半年運用しての実感です。
では「コミットしたら Slack へ」はどう書くのか。答えは Notification ではなく PostToolUse です。matcher を Bash にして、スクリプトの中で .tool_input.command が git commit かどうかを見ます。具体形は後半のテンプレート集に載せました。
2026年8月時点の全体像 — 8イベントから33イベントへ
この記事を最初に公開した時点では、本文で扱ってきた8種類がフックの主要イベントでした。その後の更新でカタログは大きく広がり、2026年8月末の公式フックリファレンス には33種類のイベントが並んでいます。数だけ見ると圧倒されますが、読み解いてみるとほとんどは「中核8フックの隙間を埋める細分化」で、設計思想そのものは変わっていません。
公式ドキュメントは発火の頻度で3つの層に整理しています。セッションに1回(SessionStart / SessionEnd)、ターンごとに1回(UserPromptSubmit / Stop / StopFailure)、そしてツール呼び出しのたび(PreToolUse / PostToolUse)。この3層を押さえたうえで、追加されたイベントを「どの層の細分化か」という目で見ると、覚えるべき地図はかなり小さくなります。
実務の判断としては、まず中核8フックで設計し、「観測したい瞬間が8つの発火点のどれにも当たらない」と気づいたときに初めてこの表へ戻る、という順番をおすすめします。私自身、増えたイベントのうち日常的に触っているのは次節のモデル切り替え系と PostToolUseFailure くらいで、それで困っていません。全部を使おうとしないことが、フック運用を長く続けるコツだと感じています。
PreModelSwitch / PostModelSwitch — モデル切り替えを運用ルールにする
増えたイベントの中でいちばん「運用が変わった」と感じているのが、v2.1.251(2026年8月28日リリース)で追加されたモデル切り替えフックです。
仕様は素直です。PreModelSwitch はユーザーやクライアントがモデル切り替えを要求した「直前」に発火し、exit 2 で切り替え自体をブロックできます。PostModelSwitch は切り替えの「後」に発火し、セッション再開時に Claude Code が自動でモデルを復元したケースまで拾います。他のイベントと違い、stdin の JSON には model ではなく from_model と to_model が入ります。matcher は to_model から導かれる正規化名に対して評価されるので、「切り替え先が特定のモデルのときだけ」という絞り込みが設定側でできます。
なぜこれが要るのか。私は個人開発の定型作業をスケジュールタスクで回しており、工程ごとに使うモデルを変えています。この配分は長らく「プロンプトに書いた約束」でしかなく、破られても気づけませんでした。週次の利用上限は改定の告知が入る性質のものですから、高価なモデルへの切り替えは約束ではなく仕組みで縛っておきたいのです。フックであれば、それが設定1つとスクリプト2本で済みます。
{
"hooks" : {
"PreModelSwitch" : [
{
"hooks" : [
{
"type" : "command" ,
"command" : "$CLAUDE_PROJECT_DIR/.claude/hooks/model-policy.sh"
}
]
}
],
"PostModelSwitch" : [
{
"hooks" : [
{
"type" : "command" ,
"command" : "$CLAUDE_PROJECT_DIR/.claude/hooks/log-model-switch.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# model-policy.sh — 定型バッチ実行中は上位モデルへの切り替えを止める
input = $( cat )
from = $( echo " $input " | jq -r '.from_model // ""' )
to = $( echo " $input " | jq -r '.to_model // ""' )
if [ -f ".claude/routine-batch.lock" ] && echo " $to " | grep -qi 'opus' ; then
echo "定型バッチ実行中のため Opus への切り替えを保留しました(現在: ${ from })。ロック解除後に再度お試しください。" >&2
exit 2
fi
exit 0
#!/usr/bin/env bash
# log-model-switch.sh — 切り替えの履歴を残す
input = $( cat )
from = $( echo " $input " | jq -r '.from_model // ""' )
to = $( echo " $input " | jq -r '.to_model // ""' )
echo "$( date -Iseconds ) ${ from } -> ${ to }" >> .claude/model-switch.log
exit 0
押さえておきたい非対称性がひとつあります。ブロックできるのは PreModelSwitch だけです。PostModelSwitch の時点ではもう切り替わっているため、exit 2 を返しても stderr がエラー通知として表示されるだけで、巻き戻りません。一方で PostModelSwitch の stdout は、次のリクエストのタイミングで Claude が読めるコンテキストとして注入されます。「モデルが変わったので、このセッションの残りは出力を切り詰めてほしい」といった申し送りを自動で渡す用途に向いています。止めるのは Pre、知らせるのは Post。この分担は PreToolUse / PostToolUse とちょうど同じ形なので、中核8フックの感覚がそのまま流用できます。
私が実際にハマった3つの落とし穴
落とし穴 1:フックの発火順序の誤解
最初、私は「全てのフックは同時に発火する」と思っていました。でも実は、順序があります:
SessionStart
UserPromptSubmit
PreToolUse
PostToolUse
Notification(並列で発火)
Stop(判定)
SessionEnd
この順序を無視すると、例えば「PreToolUse で環境変数をセットしたのに、PostToolUse ではその環境変数がない」という現象が起きます。
実際のバグ例:git コマンド実行前に、GPG_KEY という環境変数をセットしたい。PreToolUse で export GPG_KEY=... を実行しました。でも、PostToolUse でそのキーを参照しようとすると「undefined」が返ってきます。
理由:PreToolUse は「シェルスクリプトの中」で実行されるので、そのスクリプトのプロセス内でのみ環境変数が有効です。PostToolUse は「別のプロセス」で実行されるため、前のスクリプトで設定した環境変数は引き継がれません。
解決策としては、状態をプロセスではなくファイルへ預けます。SessionStart には CLAUDE_ENV_FILE という専用の受け皿もありますが、ツール単位で受け渡すなら素朴なファイルで十分でした。
{
"hooks" : {
"PreToolUse" : [
{
"matcher" : "Bash" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/stash-registry.sh"
}
]
}
],
"PostToolUse" : [
{
"matcher" : "Bash" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-registry.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# .claude/hooks/stash-registry.sh — 次のプロセスへ渡したい値をファイルへ置く
printf 'NPM_REGISTRY=%s\n' "https://registry.example.internal" > " $CLAUDE_PROJECT_DIR /.claude/.hook-state"
exit 0
#!/usr/bin/env bash
# .claude/hooks/verify-registry.sh — 別プロセスなので、ファイルから読み直す
state = " $CLAUDE_PROJECT_DIR /.claude/.hook-state"
[ -f " $state " ] || exit 0
# shellcheck disable=SC1090
. " $state "
echo "registry= $NPM_REGISTRY "
exit 0
置き場所は .claude/ の下にして、.gitignore に入れておくのを忘れないようにしています。フックは自分のユーザー権限でそのまま走りますので、受け渡しファイルに資格情報を書くのは避けたいところです。
落とし穴 2:非同期処理の待機タイムアウト
git push のあとに CI のビルド完了を待ちたくなって、ポーリングするフックを書いた夜がありました。手元の検証では CI が 30 秒で緑になり、気持ちよく動きました。ところが実際の案件で走らせると、ビルドに 20 分かかるリポジトリがあり、セッションごと巻き込んで待たされました。
ここで効いてくるのが timeout の既定値です。コマンドフックの既定は 600 秒、つまり 10 分あります。「どうせすぐ死ぬだろう」と思っていると、10 分間しっかり待たされるのです。しかも既定値はイベントによって違います。
そして、ここに見落としやすい非対称があります。タイムアウトしたときの扱いが、イベントによって逆を向きます。 PreToolUse のコマンドフックが時間切れになっても、ツール呼び出しは止まりません。そのまま通ります。一方 PreModelSwitch のフックが時間切れになると、モデル切り替えのほうはブロックされます 。前者は「フックが遅いだけで作業を止めない」、後者は「判断がつかないなら切り替えない」という思想の違いで、どちらも筋は通っていますが、同じつもりで書くと片方で裏切られます。
長く待つ処理を挟みたいのであれば、同期で粘るのをやめて async: true に逃がすのが正解でした。非同期のコマンドフックには timeout が効きません。裏で走らせて、結果は通知で受け取ります。
{
"hooks" : {
"PostToolUse" : [
{
"matcher" : "Bash" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/watch-ci.sh" ,
"timeout" : 20 ,
"async" : true
}
]
}
]
}
}
待つ処理は、待たせない場所へ置く。 CI の結果は 20 分後に分かれば十分で、そのあいだ手を止める理由はどこにもなかったのです。
なお command timed out そのものの調整(timeout 値と分割実行)については、Hook が command timed out で失敗するときの timeout 設定と分割実行のコツ で個別に掘り下げています。長時間処理を挟むなら、あわせて目を通していただければと思います。
落とし穴 3:差し止めが差し止めを呼ぶ
フックを複数組み合わせると、片方の出した結果がもう片方の入力になり、そこに輪ができることがあります。私が踏んだのは、Stop と PostToolUse の二種類でした。
Stop の往復 。「テストが落ちているなら終わらせない」という Stop フックを置きます。Claude は続きを始め、修正を試み、また終わろうとします。テストがまだ赤ければ、フックはまた差し止めます。手が入っていないまま同じ判定を繰り返す構図で、こちらが見ていなければ延々と回り続けます。
止め方は前述の stop_hook_active です。差し止めの結果として継続している最中はフックが自分から降りる、という条件を一行入れるだけで、往復は一回で収まります。Claude Code 側にも連続 8 回で打ち切る上限がありますが、8 回分の無駄な試行はそのまま時間と費用になります。
PostToolUse の自己発火 。整形フックが PostToolUse でファイルを書き換え、その書き換えがまた PostToolUse を呼ぶ形です。私の場合は、整形スクリプトが「変更があったときだけ書き戻す」ようにしたことで収束しました。同じ内容で上書きしないだけで輪が切れます。
書いていて気づいたのは、循環するフックにはどれも「降りる条件が書かれていない」という共通点があることでした。発火条件だけを考えて、停止条件を考えていないのです。
差し止めるフックを書いたら、そのフックが自分で降りる条件を必ず同じファイルに書く。 順序としては停止条件を先に書き、判定ロジックを後から足すくらいで、ちょうどよいと感じています。
PostToolUse が自分自身の出力に反応して再発火してしまう典型パターンの止め方は、Claude Code Hooks が無限ループする問題 — PostToolUse が自己発火するときの止め方 にも実例をまとめています。
そのまま動くテンプレート集
ここからは擬似スキーマではありません。.claude/settings.json にそのまま貼れる設定と、.claude/hooks/ に置く実行ファイルの組です。どれも実体はシェルスクリプトですので、設定を触る前にスクリプト単体で叩いて確かめられます。置いたら chmod +x を忘れずに。
テンプレート 1:main への force push を止める
{
"hooks" : {
"PreToolUse" : [
{
"matcher" : "Bash" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-force-push.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# .claude/hooks/guard-force-push.sh
input = $( cat )
cmd = $( printf '%s' " $input " | jq -r '.tool_input.command // ""' )
case " $cmd " in
* "git push" * )
if printf '%s' " $cmd " | grep -qE -- '(-f|--force)([[:space:]]|$)' ; then
echo "force push は禁止しています。--force-with-lease をご検討ください。" >&2
exit 2
fi
;;
esac
exit 0
--force-with-lease を素通りさせているのは意図的です。--force だけを弾き、安全側の書き方は残しておくと、止められた側が次に何をすればよいか分かります。禁止だけを告げるフックは、結局あとで外されてしまうのです。
テンプレート 2:コミットの成功だけを手元の外へ知らせる
Notification の節でお伝えした「コミットしたら Slack へ」は、PostToolUse で書きます。
{
"hooks" : {
"PostToolUse" : [
{
"matcher" : "Bash" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/notify-commit.sh" ,
"async" : true
}
]
}
]
}
}
#!/usr/bin/env bash
# .claude/hooks/notify-commit.sh
input = $( cat )
cmd = $( printf '%s' " $input " | jq -r '.tool_input.command // ""' )
printf '%s' " $cmd " | grep -q 'git commit' || exit 0
# 失敗したコミットまで知らせない
code = $( printf '%s' " $input " | jq -r '.tool_response.exitCode // 0' )
[ " $code " = "0" ] || exit 0
subject = $( git log -1 --pretty=%s 2> /dev/null )
branch = $( git rev-parse --abbrev-ref HEAD 2> /dev/null )
curl -sS -X POST " $SLACK_WEBHOOK_URL " \
-H 'Content-Type: application/json' \
-d "$( jq -n --arg t "[ $branch ] $subject " '{text:$t}')" > /dev/null
exit 0
async: true を付けているのは、ネットワークの都合で手が止まるのを避けるためです。通知は届くのが数秒遅れても困りません。
テンプレート 3:編集したファイルだけを整形し、結果を Claude へ返す
PostToolUse の値打ちは、実は「止めること」ではなく「Claude に事実を返せること」にあります。標準出力に書いた文字列は、大半のイベントではデバッグログへ流れるだけで Claude には届きません。届けたいときは hookSpecificOutput.additionalContext を使います。
{
"hooks" : {
"PostToolUse" : [
{
"matcher" : "Edit|Write" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-edited.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# .claude/hooks/format-edited.sh
input = $( cat )
file = $( printf '%s' " $input " | jq -r '.tool_input.file_path // ""' )
[ -f " $file " ] || exit 0
case " $file " in
* .ts |* .tsx |* .js |* .jsx |* .json ) ;;
*) exit 0 ;;
esac
before = $( shasum " $file " | cut -d ' ' -f1 )
npx --no-install prettier --write " $file " > /dev/null 2>&1 || exit 0
after = $( shasum " $file " | cut -d ' ' -f1 )
# 変わっていないなら黙る(再発火と無駄な文脈を避ける)
[ " $before " = " $after " ] && exit 0
jq -n --arg f " $file " '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: ("\($f) を prettier で整形しました。以降の編集は整形後の内容を前提にしてください。")
}
}'
exit 0
matcher が Edit|Write になっている点と、変化がなければ何も出さない点が肝です。.tool_input.file_path は Write・Edit・Read では常に絶対パスで渡りますので、cd の位置を気にせず扱えます。
テンプレート 4:セッション開始時にリポジトリの状態を渡す
SessionStart の標準出力は、例外的にそのまま Claude の文脈へ入ります。毎回こちらが口頭で説明していた前提を、開始時に一度だけ渡してしまうと、初手のずれがはっきり減りました。
{
"hooks" : {
"SessionStart" : [
{
"matcher" : "startup|resume" ,
"hooks" : [
{
"type" : "command" ,
"command" : "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# .claude/hooks/session-context.sh
branch = $( git rev-parse --abbrev-ref HEAD 2> /dev/null )
dirty = $( git status --porcelain 2> /dev/null | head -20 )
jq -n --arg b " $branch " --arg d " $dirty " '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: ("現在のブランチ: \($b)\n未コミットの変更:\n\($d)")
}
}'
exit 0
matcher に書いているのはセッションの始まり方 で、startup / resume / clear / compact / fork から選びます。新規起動と再開だけに絞ると、コンパクト直後に同じ文脈を二重で渡してしまう無駄がなくなります。
ここで渡す文章は、事実の記述にとどめるのがおすすめです。「必ず〜せよ」といった命令形で書くと、外部から注入された指示のように扱われて、かえって通らなくなることがあります。additionalContext は 10,000 文字で頭打ちになる点も、長いログを丸ごと渡そうとして気づきました。
実際の設定ファイルはどう書くのか
ここまでの JSON は、責務を説明するための概念図でした。ここからは、私の環境(Claude Code v2 系)で実際に動いている設定の形をお見せします。公式ドキュメントも版によってキー名が変わるため、細部は必ずお手元のバージョンで確認していただきたいのですが、骨格は次の通りです。
フックは .claude/settings.json(プロジェクト単位)または ~/.claude/settings.json(ユーザー単位)の hooks キーに、イベント名ごとの配列として書きます。ポイントは、フックの実体が「JSON の宣言」ではなく「シェルコマンド」だということ。判定ロジックは自分のスクリプトの中に書きます。
{
"hooks" : {
"PreToolUse" : [
{
"matcher" : "Bash" ,
"hooks" : [
{
"type" : "command" ,
"command" : "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-git-push.sh"
}
]
}
]
}
}
matcher はツール名にかかる正規表現です。Bash だけ、Edit|Write のように複数、あるいは空文字で全ツール、といった指定ができます。ハイフンを含むツール名の一致挙動はバージョンで厳密化された経緯があるので、アップデート後にフックが沈黙したらハイフン入り matcher の厳密一致化(v2.1.195) を先に疑ってください。
呼ばれたスクリプトは、標準入力から JSON を受け取ります。ツール名や引数はそこに入っています。
#!/usr/bin/env bash
# guard-git-push.sh
input = $( cat ) # stdin の JSON を丸ごと受け取る
cmd = $( echo " $input " | jq -r '.tool_input.command // ""' )
if echo " $cmd " | grep -qE 'git push .*(-f|--force)' ; then
echo "main への force push は禁止です。--force-with-lease を検討してください。" >&2
exit 2 # exit 2 = このツール実行をブロックし、理由を Claude に返す
fi
exit 0 # exit 0 = そのまま実行を許可
宣言型スキーマとの一番の違いは「ブロックの伝え方」です。exit 2 で終了すると、標準エラーに書いた文字列がそのまま Claude へフィードバックされ、ツール実行は止まります。exit 0 なら素通り。この二値さえ押さえれば、前半で責務として説明した検証・ゲート・通知は、すべて普通のシェルスクリプトとして書けます。逆に言えば、フックのデバッグは「そのスクリプトを単体で叩けるか」に尽きます。設定ファイルを疑う前に、手元でスクリプトへ JSON を流し込み、挙動を確かめるのが近道です。
# 手元での叩き方。設定を書く前にこれで確かめます
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \\
| .claude/hooks/guard-force-push.sh ; echo "exit= $? "
終了コードの意味 — exit 1 では止まりません
半年運用して、いちばん高くついた誤解がこれでした。
判定に失敗したフックは exit 1 を返せば止まってくれる、と私は思い込んでいました。Unix の作法としてはそのほうが自然です。ところが Claude Code のフックでは、止まるのは exit 2 だけ です。exit 1 は「ブロックしないエラー」として扱われ、注意書きが残るだけで、ツール呼び出しはそのまま通ります。
つまり、守っているつもりのゲートが、実際には何も守っていない状態になり得ます。しかも通っているので、気づく手掛かりがありません。私がこれに気づいたのは、止めているつもりのコマンドが履歴に残っているのを見つけたときでした。
例外は worktree 系の2イベントで、こちらは非ゼロならどの値でも失敗として扱われます。逆に exit 2 を返しても無視されるイベントもあり、Notification・PermissionDenied・Setup・InstructionsLoaded がそれにあたります。PostToolUse も止まりません——ツールはもう走り終えているためで、これは仕様として筋が通っています。
もう一つ、標準出力が Claude に届くイベントは限られます 。素の標準出力がそのまま文脈に入るのは UserPromptSubmit・UserPromptExpansion・SessionStart・PostModelSwitch の4つだけで、それ以外のイベントではデバッグログへ流れて終わります。PostToolUse から Claude へ何か伝えたいときに echo で済ませようとして届かず、hookSpecificOutput.additionalContext に切り替えてようやく通った、というのが私の実際の順序でした。
止めるなら 2、伝えるなら additionalContext。 フックが効かないと感じたときは、ロジックを疑う前にこの二つを確かめると、たいてい原因はどちらかに入っています。
フックのオーバーヘッドを実測する
見落とされがちですが、フックは一件ごとに別プロセスを起動します。つまり、ツールを呼ぶたびに小さな起動コストが積み上がります。普段は気にならなくても、PostToolUse に重い処理を挟むと、体感がはっきり変わります。
私自身が個人開発している壁紙アプリ(App Store と Google Play で配信しています)の CI 連携で、この差を実測しました。同じ git commit に対して、フックなし・軽量な検証スクリプト・npm audit を毎回走らせる構成の三通りで、一件あたりの追加時間を計測した結果が次の表です(手元のマシンでの中央値・参考値)。
数十ミリ秒のプロセス起動コストは、対話の中ではまず気になりません。問題になるのは、ネットワークやディスクを触る処理を「毎回」走らせたときです。上の npm audit は install の直後だけに絞れば十分で、全コマンドに掛ける必要はありませんでした。 実際、install の直後だけに限定したことで、監査フックの発火回数を約90%削減できました。
私の運用上の目安は「同期フックの追加時間は 200 ms 以内に収める」です。それを超える処理は、matcher と条件で発火頻度を絞るか、非同期に逃がして通知だけ受け取る設計へ寄せます。フックは速いほど「常に効いていても邪魔にならない」。この静かさこそが、フックを外さずに使い続けられる条件だと考えています。
運用上の注意点
フックのテスト
フックを本番環境に入れる前に、テスト環境で試してください。特に git / npm といった「実際のプロジェクト状態を変更する」ツールに関連するフックはテスト必須です。
テスト方法:
テスト用のダミープロジェクトを作成
フック設定を .claude/settings.json に定義
Claude Code のセッションをスタート
意図通りに動いたか確認
ログとモニタリング
本番環境に入れたフックは、ログを記録してください。何か問題が起きた時に「あ、このフックが変な動きをしてた」と判定できるようにするためです。
{
"logging" : {
"enable_hook_logs" : true ,
"log_level" : "info" ,
"log_output" : "/var/log/claude-code-hooks.log" ,
"rotate_logs" : {
"max_size_mb" : 100 ,
"keep_days" : 30
}
}
}
バージョン管理
フック設定も git で管理してください。「あ、このフック設定いつから入ってたっけ」という質問に答えられるようにするためです。
git add .claude/settings.json
git commit -m "feat: Add security gate for git push to main branch"
これで、Git の歴史をたどることで「いつ、どんなフック設定が導入されたか」が追跡できます。
フックは一度マスターすると、Claude Code の自動化レベルがぐっと上がります。最初は複雑に見えるかもしれませんが、「どのフックが、どのタイミングで、何を責務とするのか」という設計を明確にすれば、実務で強い武器になります。