研究紹介
Claude Code のフックは、失敗するたびに自分のシェルスクリプトを2回送り返す
· 読了まで約9分
目次
先週このリポジトリに PostToolUse フックを入れた。src/content/blog/ 配下を編集すると記事の規約チェッカーが走り、違反があればエラーを stderr に書いて exit 2 で返す。エージェントの手番が終わらず、直すまで差し戻される仕掛けだ。動機はトークンではなく、チェッカーを叩くのを毎回忘れることだった。
ただ「フックを使えばトークンが減る」という主張はあちこちで見る。起動時のプレフィルを分解したときと、MCP のツール読み込みを計測したときと同じ手が使えるので、自分のフックで測った。
フックはトークンを減らす。ただし減る場所は宣伝されている場所と違うし、失敗したときは固定費がかかる。その固定費は、あなたがコマンドを何文字で書いたかで決まる。
何が context に入ったかは JSONL に全部残っている
Claude Code はセッションを ~/.claude/projects/<slug>/<session-id>.jsonl に 1 行 1 オブジェクトで書き出す。フックの結果は attachment として残り、種類は 2 つある。
hook_success— exit 0 で、かつ何か出力したときhook_blocking_error— exit 2 で返したとき
exit 0 で何も出力しなかったフックは、レコードそのものが作られない。これが最初の実測で、言い方を変えると成功経路が無料なのは黙っているあいだだけだ。
検証用のプロジェクトで、94 文字を 1 行だけ出力して exit 0 したフックの記録が次のものになる。
{"type": "hook_success", "hookName": "PostToolUse:Write",
"toolUseID": "toolu_018SGuBQ9VnChXH3tDJjn3pg", "hookEvent": "PostToolUse",
"content": "check passed: 62 posts, 4 scheduled, 0 errors. This line is stdout on a successful hook run.",
"stdout": "check passed: 62 posts, 4 scheduled, 0 errors. This line is stdout on a successful hook run.\n",
"stderr": "", "exitCode": 0, "command": ".claude/hooks/ok-loud.sh", "durationMs": 421}
同じ 1 行が content と stdout の両方に入っている。編集のたびに走るフックに echo を 1 つ置くと、編集 1 回あたり 2 回分を払うことになる。
計測はプレフィルの差分で取った
トークン数はプレフィルの差分から出した。空のディレクトリで --strict-mcp-config と空の MCP 設定を渡し、claude -p "hi" --output-format json --model haiku を叩くと自分の使用量を報告してくる。input_tokens と cache_creation_input_tokens と cache_read_input_tokens を足すと、空の CLAUDE.md で 27,833 になる。測りたいテキストを CLAUDE.md に置いて叩き直せば、その差分がテキストの値段になる。
このベースラインはセッション中に 5 回測ってすべて 27,833 だったので、以下の差分はトークン単位で安定している。
先に断っておくことが 2 つある。テキストは CLAUDE.md を経由して測っており、本来の attachment の経路そのものではない。出るのはテキストの値で、周囲の構造まで含めた実際の形ではない。もう 1 つ、テキストを分割して測った値の合計は、まとめて測った値と一致しない。トークナイザは私が引いた節の境界で切ってくれないので、後半に出てくる内訳は互いの近似であって帳簿ではない。
環境は Claude Code 2.1.235、macOS、pnpm のプロジェクト。
差し戻しはコマンド文字列を2回運ぶ
8月19日のセッションに残っていた、実際の差し戻しが次のものになる。中略してある。
{"type": "hook_blocking_error", "hookName": "PostToolUse:Bash",
"blockingError": {
"blockingError": "[p=$(jq -r '[.tool_input.file_path, .tool_response.filePath, .tool_input.command] | map(select(type == \"string\")) | join(\" \")'); case \"$p\" in *src/content/blog/*) o=$(cd \"${CLAUDE_PROJECT_DIR:-.}\" && pnpm -s check 2>&1) || { printf '%s\\n' \"$o\" >&2; exit 2; };; esac]: error: ...エラー8行...",
"command": "p=$(jq -r '[.tool_input.file_path, ... ;; esac"}}
コマンド文字列がエラー本文の頭に角括弧付きで置かれ、そのあと command フィールドにもう一度そのまま入っている。私のコマンドは 264 文字で、プレフィルで測ると 184 トークン。つまり差し戻しは、チェッカーの出力 1 行を運ぶ前に 368 トークンを払っている。
このリポジトリに記録されている差し戻しは今のところ 2 件ある。
| attachment 全体 | エラー本文だけ | コマンド文字列 ×2 | |
|---|---|---|---|
| エラー 2 件 | 627 | 308 | 368 |
| エラー 8 件 | 1,136 | 812 | 368 |
エラー 2 件のほうでは、フック自身のソースコードが、届けようとしたメッセージより高くついている。
最小構成で切り分ける
上の数字には本物のチェッカーの出力が混ざっているので、オーバーヘッドだけを取り出す最小版を作った。検証用のプロジェクトに Write を拾う PostToolUse フックを置き、フックの中身は error: demo.md:1 boom の 22 文字を出して exit 2 するだけにする。そのうえで、同じ処理をファイルに移してもう一度走らせた。
# 版A: settings.json に直書き(200 文字)
p=$(jq -r '[.tool_input.file_path, .tool_response.filePath, .tool_input.command] | map(select(type == "string")) | join(" ")'); case "$p" in *demo*) printf 'error: demo.md:1 boom\n' >&2; exit 2;; esac
# 版B: 同じ動作をファイルに置く
.claude/hooks/check.sh
エラーも終了コードも同一で、結果はこうなった。
| フックの書き方 | attachment | トークン |
|---|---|---|
| コマンド直書き、exit 2 | 635 文字 | 339 |
.claude/hooks/check.sh、exit 2 | 265 文字 | 184 |
| exit 0、stdout 1 行 | 434 文字 | 236 |
| exit 0、無出力 | 記録なし | 0 |
コマンドをファイルに移すだけで、動作を変えずに差し戻しのコストが 46% 落ちた。2 回運ばれること自体は変わらない([.claude/hooks/check.sh]: と "command" に出る)。短いパスなら 2 回でも安く、jq のパイプラインだと高い、という差でしかない。
フックが実際に消したのは往復ではなかった
導入の前後を並べると、予想と違うものが見えた。
フックを入れる前の 8月17日、1 つのセッションで記事 2 本を英語と日本語で書いている。src/content/blog/ に触れたツール呼び出しは 41 回、チェッカーを手で叩いたのは 12 回だった。この 12 回のコマンドと結果を合わせると 10,694 文字、5,660 トークンになる。
ただし 12 回のうち、チェッカー単独で叩いたのは 2 回だけだった。残る 10 回はもともと走らせるコマンドに相乗りしている(wc -w post.mdx && pnpm check、Python の書き換えスクリプトのあとに繋ぐ、など)。しかもほとんどが | tail -20 や | grep '^error' で入口を絞ってあった。往復はすでに償却されていたし、出力も手で切り詰められていた。
フックを入れたあと、8月19日の 2 セッションでは 31 回の編集がフックを起動し、差し戻されたのは 1 回。context に入った合計は 627 トークンだった。
つまりフックが消したのは往復ではない。消せるほどの単独往復は最初から無かった。消えたのは成功したときの出力で、31 回のうち 30 回、黙って exit 0 したぶんがそのまま削減になっている。
そう考えると助言の形も変わる。フックが節約する量は、そのフックがどれだけ高い頻度で通るかに比例する。通らないチェックをフックで包むと、自分で叩くより高くつく。フックの出力は tail に通せない。
直すべきものが3つ出た
効果の大きい順に並べる。
成功時は黙って exit 0 する。「✓ 問題なし」のような状態表示や、走査した本数のような情報は、起動のたびに 2 回ぶん課金される。私のフックが成功時に何も出さないおかげで、31 回起動したセッションが数千トークンではなく 627 トークンで済んでいる。
コマンドはファイルに出す。command フィールドを .claude/hooks/check.sh の 1 行にしても動作は変わらず、差し戻し 1 回あたり 46% が消える。これは私が間違えていたところで、jq のパイプラインを settings.json に直書きしたまま、差し戻しのたびに 368 トークンを払い続けていた。
失敗経路の出力も削る。私のチェッカーは成功でも失敗でも予約公開待ちの一覧と言語別の本数を出す。460 文字、291 トークンが、違反と関係なく差し戻しに毎回くっついてくる。エージェントに要るのはエラー行であって、強調記号の閉じ方を直すのに公開カレンダーは要らない。
これでフックが割に合わない道具になるわけではない。627 トークンで 31 回の自動検証が付くなら私は同じ買い物をするし、そもそもフックを入れた理由——思い出さないと走らないチェックは、いつか走らなくなる——はここまでの数字と無関係に生きている。ただし節約はすべて沈黙する経路にあり、失敗経路のほうには自分で決めた下限がある。その下限は、settings.json にシェルの一行を貼り付けたときの文字数で決まっている。