検査は、最後まで正しく動いていました。壊れていたのは会話のほうでした。
編集のたびに静的検査を走らせる hook を足した日のことです。個人開発で複数のリポジトリを行き来していると、検査は手で思い出して走らせるより、編集に紐づけて自動で挟むほうが確実に効きます。そう考えて入れたものでした。検査そのものは意図どおりに動き、違反も正しく拾っていました。ところが数十回の編集を挟んだあたりから、応答が目に見えて鈍くなり、やがて長いプロンプトを受け付けなくなりました。
原因を探して、私はしばらく検査スクリプトの中身を見ておりました。無駄な処理はないか、遅い正規表現はないか。けれど問題は処理時間ではありませんでした。検査が返していた文字列が、そのまま毎回の会話に積み上がっていたのです。
hook のタイムアウトについてはhook が command timed out で止まるときの切り分けで扱いました。今回はそれとは別の軸、つまり「時間ではなく量」の話になります。
走査した数ではなく、返した数が文脈になります
まず、自分の手元で実際に測った数字を置きます。同じリポジトリ(MDX 833ファイル)に対して、性格の違う検査をそれぞれ1回ずつ走らせ、標準出力のバイト数と行数を記録しました。
| 処理 | 走査対象 | 出力バイト | 行数 | 概算トークン |
| フロントマター整合検査(合格) | 833ファイル | 58 B | 1 | 約26 |
| 逐語重複スキャン(合格) | 833ファイル | 109 B | 1 | 約50 |
| リダイレクト整合検査(合格) | リポジトリ全体 | 126 B | 1 | 約57 |
| 単一ファイル検査(違反あり) | 2ファイル | 893 B | 18 | 約406 |
| 全件列挙型の grep | 833ファイル | 42,280 B | 398 | 約19,218 |
※ トークン換算は日本語混在のテキストを 1トークン ≈ 2.2バイトとして概算したものです。実際の値はモデルとトークナイザによって変わります。
ここで目を引くのは、上から3つと下の1つの落差です。同じ833ファイルを走査していながら、返す量は58バイトと42,280バイト、およそ728倍の開きがあります。走査した対象の規模は、出力量をまったく決めていません。決めているのは、書いた人がどう返すことにしたか、それだけです。
私はこの表を作るまで、無意識に「重い検査ほど出力も重い」と思い込んでおりました。実際には逆のことが起きます。よく設計された全件検査は合格時に1行しか返さず、雑に書いた grep -rn は一致した行を全部並べます。後者のほうがはるかに軽い処理なのに、会話に対する負荷は3桁大きいのです。
その出力は、そもそも会話へ届いていますか
ここで一度、前提を確かめ直します。私は長いあいだ「hook が出力すれば、それはすべて会話に入る」と思い込んでおりました。公式のhooks リファレンスを読み直して、その理解が粗かったことに気づいたのは、この記事の初稿を書き終えたあとでした。
実際には、経路によって扱いが違います。
| 返し方 | Claude に届くか | 補足 |
| exit 0 の stdout(多くのイベント) | 届きません | デバッグログに書かれるだけで、トランスクリプトには出ません |
exit 0 の stdout(SessionStart / UserPromptSubmit / UserPromptExpansion / PostModelSwitch) | 届きます | 素のテキストがそのまま文脈に加わります |
| exit 0 の stderr | 届きません | デバッグログのみ。自分で有効化しない限り読めません |
exit 2 の stderr(PostToolUse など) | 届きます | ツールは実行済みのまま、内容が Claude に渡ります |
hookSpecificOutput.additionalContext | 届きます | system reminder として会話に差し込まれます |
そして、届く経路には上限があります。additionalContext・systemMessage・素の stdout はいずれも 10,000文字で打ち切られ、超えた分はファイルに保存されてプレビューとパスに置き換わる、と明記されています。私が最初の版で「cat を一度書き間違えれば 5.7MB が会話に流れ込む」と書いたのは、正確ではありませんでした。一度の暴発では、そこまで到達しません。
では危険がないのかというと、そうではないのです。効いてくるのは一度の量ではなく、届く経路を毎回細く使い続けることのほうでした。 編集のたびに走る hook が 400バイトを返せば、300回の編集で12万バイト。一度の上限には一度も触れないまま、会話の容量は着実に削られていきます。私が踏んだのは、まさにこちらでした。
もう一つ、終了コードの扱いも見落としやすいところです。ポリシーを守らせたいときに使うのは exit 2 で、stdout に妥当な JSON がない exit 1 は非ブロッキングエラーとして扱われ、処理はそのまま先へ進みます。Unix の慣習で 1 を返していると、検査は違反を見つけているのに何も止まらない、という状態になります。
出力量と終了コードは独立した軸で、組み合わせは4通りあります。
| 出力が短い | 出力が長い |
| exit 0 | 合格した検査。既定にすべき形 | 要注意。合格しているのに、届く経路なら文脈を消費します |
| exit 2 | 原因を1行で言い切れる失敗。理想的 | 予算内なら妥当。超えるなら要約して逃がします |
見落とされやすいのは右上、つまり**「成功しているのに長い」**の枠です。ここは失敗していないので誰も調べません。エラーも出ません。それでいて、静かにコンテキストを削っていきます。私自身、応答が鈍くなった原因を探して失敗ログばかり見ていた時間がありました。犯人は一度も失敗していなかったのです。
成功したときこそ、何も言わないほうがよいのです
そう考えると、設計の指針はかなりはっきりします。
- 合格したときは、合格したという事実だけ(あるいは何も返さないこと)
- 違反したときだけ、直すのに必要な最小限
- 全文・全件・生ログは会話へ返さず、ファイルへ書くこと
先ほどの表の上から3行は、まさにこの形です。833ファイルを検査して「クリーン」と1行返します。読み手にとって必要な情報はそれで足りています。どのファイルが合格したかを列挙しても、誰の判断も変わりません。
逆に、違反したときは話が別です。単一ファイル検査の 893バイト・18行は、違反の項目名と該当箇所を含んでいます。この程度の量なら、その場で直すために必要な情報として十分に釣り合います。
なぜ「全部見せておけば安全」が裏目に出るのか
情報は多いほうが安全に思えます。実際、人間が読むログならその感覚は正しいことが多いのです。
しかし相手が有限の文脈を持つモデルである場合、判断材料の総量には天井があります。天井まで埋めてしまえば、後から来る本当に重要な情報が入る場所がなくなります。しかも埋めた中身の大半は「問題がなかったファイルの名前」です。
判断を変えない情報は、載せた瞬間から純粋な負債になります。 これは検査に限らず、hook から何かを返すときの共通原則として置いておく価値があります。
出力予算を先に決めてから、中身を書きます
具体的な運用としては、hook ごとに「返してよいバイト数」を先に決めてしまうのが確実でした。私は当初これを感覚でやろうとして失敗したので、数字で持つ形に変えております。
目安として使っている値を挙げます。
| hook の性格 | 成功時の予算 | 失敗時の予算 |
| 編集ごとに走る検査(高頻度) | 0 B | 1,500 B |
| コミット前など節目で走る検査 | 0〜200 B | 4,000 B |
| セッション開始時に一度だけ走るもの | 2,000 B まで | 4,000 B |
高頻度の hook ほど成功時の予算を絞る、という一点さえ守れていれば、細かい数値は運用に合わせて動かして構いません。
予算を強制するラッパーを一枚挟みます
方針を決めても、検査スクリプトの側は放っておくと饒舌になります。ツールを入れ替えたり、--verbose が既定になったりするだけで、前提は言葉もなく崩れます。ですので、予算はスクリプトの善意ではなく、外側のラッパーで機械的に守らせています。
#!/usr/bin/env bash
# hook-guard.sh — 検査の出力を、会話へ届く経路でだけ予算内に収める
# 使い方: hook-guard.sh <予算バイト> <証拠ログの保存先> -- <検査コマンド...>
set -uo pipefail
BUDGET="$1"; shift
EVIDENCE_DIR="$1"; shift
[ "${1:-}" = "--" ] && shift
mkdir -p "$EVIDENCE_DIR"
EVIDENCE="${EVIDENCE_DIR}/$(date +%Y%m%d-%H%M%S)-$$.log"
# 標準出力・標準エラーをまとめて証拠ファイルへ落とし、会話へは1バイトも流さない
"$@" > "$EVIDENCE" 2>&1
STATUS=$?
TOTAL_BYTES=$(wc -c < "$EVIDENCE" | tr -d ' ')
TOTAL_LINES=$(awk 'END{print NR}' "$EVIDENCE") # wc -l は末尾改行なしを1行少なく数えます
# 合格時は 1 バイトも返しません
[ "$STATUS" -eq 0 ] && exit 0
NOTICE=$(printf '\n--- %s of %s bytes, %s lines total / full log: %s ---\n' \
"$BUDGET" "$TOTAL_BYTES" "$TOTAL_LINES" "$EVIDENCE")
NOTICE_BYTES=$(printf '%s' "$NOTICE" | wc -c | tr -d ' ')
BODY_BUDGET=$(( BUDGET - NOTICE_BYTES ))
[ "$BODY_BUDGET" -lt 0 ] && BODY_BUDGET=0
# 行の途中で切らない = 文字の途中でも切れない。LC_ALL=C で length をバイト数にします
LC_ALL=C awk -v max="$BODY_BUDGET" '
{ n = length($0) + 1; if (used + n > max) exit; used += n; print }
' "$EVIDENCE" >&2
printf '%s' "$NOTICE" >&2
exit 2 # 1 では止まりません。ポリシーを守らせるなら 2
呼び出し側はこうなります。
# settings.json の hook から呼ぶコマンド例
~/bin/hook-guard.sh 1500 ~/.cache/hook-evidence -- \
python3 tools/frontmatter_check.py "$CLAUDE_FILE_PATH"
前の版を測り直したら、三つ外しておりました
このラッパーは、以前の版では違う書き方をしておりました。今回あらためて、日本語の違反メッセージを60行(7,302バイト)返す検査に対して、予算300バイトで実際に走らせ直しました。結果は次のとおりです。
| 観点 | 前の版 | いまの版 |
| 終了コード | 1(非ブロッキング扱い・処理は続行) | 2(ブロック/stderr が Claude に渡る) |
| 返した先 | stdout(多くのイベントで Claude に届かない) | stderr(exit 2 なら届く) |
| 実際に返したバイト数 | 412 B(予算300 Bの1.37倍) | 215 B(予算内) |
| UTF-8 として妥当か | 不正(299バイト目で 0xE3 が孤立) | 妥当 |
| 合格時の出力 | 30 B を毎回返す | 0 B |
いちばん堪えたのは3行目と4行目です。head -c はバイト単位で切るため、日本語の3バイト文字のまんなかで刃が入ります。実測では 299バイト目に 0xE3 だけが残り、受け取り側のデコードがそこで失敗しました。そして予算300バイトと書いたつもりで、切り詰めの予告行を足した実際の送出量は412バイトでした。予算は「本文に使える量」ではなく「返す総量」で数えなければ、宣言した意味がありません。
いまの版は本文を行単位で積み、予算に届いたところで止めます。行を割らない以上、文字も割れません。予算800バイトなら696バイト・6行、300バイトなら215バイト・2行と、どちらも予算の内側で収まりました。ただし予告行そのものが94バイト前後(証拠ログのパス長で前後します)あるため、100バイトを下回る予算は指定しても意味を持ちません。ここは割り切って、下限を先に決めておくのがよいかもしれません。
なぜこう書いているのか
出力を一度ファイルへ落としてから判定していること。 パイプで直接切り詰めると、切られた側のプロセスが SIGPIPE で落ちて終了コードが実態とずれます。検査は合格しているのに失敗として扱われる、あるいはその逆が起きます。この落とし穴には実際にはまり、原因の切り分けに時間を使いました。対処としては凝ったことをせず、素直に一度ディスクへ書くのが確実です。
合格時に、証拠ファイルのパスすら出していないこと。 最初はパスを出しておりました。親切のつもりでしたが、高頻度で走る hook では、そのパス1行が毎回積み上がります。しかも合格しているのですから、誰もそのファイルを開きません。読まれないことが分かっている情報を毎回返すのは、単なる負債の定期購入でした。
切り詰めたときに必ず総量を書き添えていること。 本文だけを切ると、受け取る側は「これで全部なのか、途中なのか」を区別できません。区別できないまま、部分的な情報から誤った結論に進む余地が生まれます。「300 of 7302 bytes, 60 lines total」と書いてあれば、続きがあることは明白です。切ること自体より、切った事実を隠さないことのほうが重要でした。
何を残し、何を捨てるかの優先順位
予算を超えたときに何から捨てるか。ここも先に決めておくと、実装が迷いません。私が使っている優先順位は次のとおりです。
- 違反の総件数 — 1件なのか200件なのかで、取るべき行動が根本的に変わります
- 代表的な違反の先頭3〜5件 — 直し方の見当をつけるのに要る最小限
- 違反の種類の内訳 — 「同じ原因が200件」なのか「200種類の別問題」なのかの判別
- 証拠ファイルのパス — 全件が必要になったときの入り口
- 残りの全件明細 — 返しません
多くの場合、1と2だけで次の一手は決まります。違反が二桁を超える運用では、3までを既定で返す形をお勧めします。内訳が分かるかどうかで、修正の設計がまるごと変わるためです。
逆に言えば、5を返している hook は、ほぼ確実に予算を無駄遣いしています。200件の違反が全部同じ原因なら、199件は最初の1件と同じことしか語っていません。
自分の hook が何バイト返しているかを、1週間だけ測ります
最後に、いま自分の環境で何が起きているかを知るための最小の計測を置きます。設計を変える前に、まず現状を数字にしてしまうのが早道でした。
#!/usr/bin/env bash
# hook-meter.sh — 既存の hook をこれ経由で呼び、出力量だけを記録する
# 使い方: hook-meter.sh <hook名> -- <元のコマンド...>
set -uo pipefail
NAME="$1"; shift
[ "${1:-}" = "--" ] && shift
LEDGER="$HOME/.cache/hook-meter.tsv"
mkdir -p "$(dirname "$LEDGER")"
# コマンド置換(OUT="$(...)") は末尾の改行を落とすため、計測にも通過にも使いません
TMP="$(mktemp)"
"$@" > "$TMP" 2>&1
STATUS=$?
printf '%s\t%s\t%s\t%s\t%s\n' \
"$(date +%Y-%m-%dT%H:%M:%S)" \
"$NAME" \
"$STATUS" \
"$(wc -c < "$TMP" | tr -d ' ')" \
"$(awk 'END{print NR}' "$TMP")" \
>> "$LEDGER"
cat "$TMP"
rm -f "$TMP"
exit "$STATUS"
計測器を OUT="$("$@" 2>&1)" で書いていた時期があり、これも測り直して取り替えました。31バイト・1行を返す検査を通したところ、台帳には 30バイト・0行 と記録されます。コマンド置換が末尾の改行を落とし、wc -l が改行の個数を数えるため、1行の出力が「0行」になるのです。6バイト・3行の検査は 5バイト・2行と記録されました。合計値を見るための道具が、1回ごとに1バイトと1行を取りこぼしていたことになります。通過させる出力からも末尾の改行が消えるため、下流の見え方まで変わります。
1週間ためたら、hook ごとの合計を出します。
awk -F'\t' '
{ bytes[$2] += $4; runs[$2] += 1; if ($3 != 0) fails[$2] += 1 }
END {
printf "%-28s %8s %10s %10s %8s\n", "hook", "runs", "total_B", "avg_B", "fails"
for (h in bytes)
printf "%-28s %8d %10d %10.1f %8d\n", h, runs[h], bytes[h], bytes[h]/runs[h], fails[h]+0
}
' ~/.cache/hook-meter.tsv | sort -k3 -nr
見るべきは平均バイト数ではなく、合計バイト数の順位です。1回あたり200バイトでも、1日に300回走れば6万バイトになります。逆に1回2,000バイトでも、セッション開始時の1回きりなら気にする必要はありません。頻度と量の積が、実際に会話から奪っている領域です。
私の場合、この集計で上位に来たのは、いちばん「軽い」と思っていた高頻度の検査でした。1回あたりの数字だけを見て安心していたわけです。
次の一歩
やることは1つで足ります。いま設定している hook のうち、いちばん頻繁に走るものを1つ選び、成功したときの出力を空にしてください。
削れるかどうかを判断する問いはシンプルです。その行は、次に取る行動を変えますか。変えないのであれば、それは会話に置く必要のない情報です。
検査の網を細かくすることには、私も長く時間を使ってまいりました。けれど網が細かくても、報告が長ければ、その報告自体が次の判断を鈍らせます。合格したときに黙っていられる検査だけが、長く使い続けられます。 掲載したスクリプトを自分で測り直して外していた三点を書き足したのも、同じ理由からでした。
お読みいただきありがとうございました。手元の hook を測ってみて、思いがけない順位が出たなら、そこが最初の一手になるはずです。