p4ni.

研究紹介

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 行が contentstdout の両方に入っている。編集のたびに走るフックに echo を 1 つ置くと、編集 1 回あたり 2 回分を払うことになる。

計測はプレフィルの差分で取った

トークン数はプレフィルの差分から出した。空のディレクトリで --strict-mcp-config と空の MCP 設定を渡し、claude -p "hi" --output-format json --model haiku を叩くと自分の使用量を報告してくる。input_tokenscache_creation_input_tokenscache_read_input_tokens を足すと、空の CLAUDE.md27,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 件627308368
エラー 8 件1,136812368

エラー 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 2635 文字339
.claude/hooks/check.sh、exit 2265 文字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 にシェルの一行を貼り付けたときの文字数で決まっている。