検査は、最後まで正しく動いていました。壊れていたのは会話のほうでした。
編集のたびに静的検査を走らせる 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ディレクトリ | 5,748,215 B | — | 約261万 |
※ トークン換算は日本語混在のテキストを 1トークン ≈ 2.2バイトとして概算したものです。実際の値はモデルとトークナイザによって変わります。
ここで目を引くのは、上から3つと下から2つの落差です。同じ833ファイルを走査していながら、返す量は58バイトと42,280バイト、およそ728倍の開きがあります。走査した対象の規模は、出力量をまったく決めていません。決めているのは、書いた人がどう返すことにしたか、それだけです。
私はこの表を作るまで、無意識に「重い検査ほど出力も重い」と思い込んでいました。実際には逆のことが起きます。よく設計された全件検査は合格時に1行しか返さず、雑に書いた grep -rn は一致した行を全部並べます。後者のほうがはるかに軽い処理なのに、会話に対する負荷は3桁大きい。
最下段の 5.7MB は、比較のために置いた上限です。この量をそのまま返せば、100万トークンの文脈でも到底収まりません。「hook の出力でセッションが応答しなくなる」という現象は、抽象的な危険ではなく、cat を一度書き間違えるだけで届く距離にあります。
成功したときこそ、何も言わないほうがよいのです
hook が返した文字列は、種類によっては次のターンのコンテキストへ入ります。ここが通常のシェル作業と決定的に違うところです。ターミナルで長い出力が流れても、それは画面をスクロールして消えるだけ。けれど hook の出力は消えません。残り、積み上がり、以降のすべてのやり取りの原価になります。
そう考えると、設計の指針はかなりはっきりします。
- 合格したときは、合格したという事実だけを返す
- 違反したときだけ、直すのに必要な最小限を返す
- 全文・全件・生ログは、返さずにファイルへ書く
先ほどの表の上から3行は、まさにこの形です。833ファイルを検査して「クリーン」と1行返す。読み手にとって必要な情報はそれで足りています。どのファイルが合格したかを列挙しても、誰の判断も変わりません。
逆に、違反したときは話が別です。単一ファイル検査の 893バイト・18行は、違反の項目名と該当箇所を含んでいます。この程度の量なら、その場で直すために必要な情報として十分に釣り合います。
なぜ「全部見せておけば安全」が裏目に出るのか
情報は多いほうが安全に思えます。実際、人間が読むログならその感覚は正しいことが多い。
しかし相手が有限の文脈を持つモデルである場合、判断材料の総量には天井があります。天井まで埋めてしまえば、後から来る本当に重要な情報が入る場所がなくなる。しかも埋めた中身の大半は「問題がなかったファイルの名前」です。
判断を変えない情報は、載せた瞬間から純粋な負債になります。 これは検査に限らず、hook から何かを返すときの共通原則として置いておく価値があります。
出力予算を先に決めてから、中身を書きます
具体的な運用としては、hook ごとに「返してよいバイト数」を先に決めてしまうのが確実でした。私は当初これを感覚でやろうとして失敗したので、数字で持つ形に変えています。
目安として使っている値を挙げます。
| hook の性格 | 成功時の予算 | 失敗時の予算 |
| 編集ごとに走る検査(高頻度) | 0〜120 B | 1,500 B |
| コミット前など節目で走る検査 | 0〜200 B | 4,000 B |
| セッション開始時に一度だけ走るもの | 2,000 B まで | 4,000 B |
高頻度の hook ほど成功時の予算を絞る、という一点さえ守れていれば、細かい数値は運用に合わせて動かして構いません。編集ごとに走るものが毎回100バイト返すなら、100回編集しても1万バイト。この程度なら気づかないうちに詰まることはありません。
予算を強制するラッパーを一枚挟みます
方針を決めても、検査スクリプトの側は放っておくと饒舌になります。ツールを入れ替えたり、--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"
STAMP="$(date +%Y%m%d-%H%M%S)-$$"
EVIDENCE="${EVIDENCE_DIR}/${STAMP}.log"
# 標準出力・標準エラーをまとめて証拠ファイルへ落とし、会話へは1バイトも流さない
"$@" > "$EVIDENCE" 2>&1
STATUS=$?
TOTAL_BYTES=$(wc -c < "$EVIDENCE" | tr -d ' ')
TOTAL_LINES=$(wc -l < "$EVIDENCE" | tr -d ' ')
if [ "$STATUS" -eq 0 ]; then
# 合格時は事実だけ。証拠ファイルのパスすら出さない
echo "check ok (${TOTAL_LINES} lines suppressed)"
exit 0
fi
# 失敗時のみ、予算の範囲で本文を返す
head -c "$BUDGET" "$EVIDENCE"
if [ "$TOTAL_BYTES" -gt "$BUDGET" ]; then
printf '\n--- truncated: %s of %s bytes shown, %s lines total ---\n' \
"$BUDGET" "$TOTAL_BYTES" "$TOTAL_LINES"
printf 'full log: %s\n' "$EVIDENCE"
fi
exit "$STATUS"
呼び出し側はこうなります。
# settings.json の hook から呼ぶコマンド例
~/bin/hook-guard.sh 1500 ~/.cache/hook-evidence -- \
python3 tools/frontmatter_check.py "$CLAUDE_FILE_PATH"
なぜこう書いているのか
書き方の意図を、実際に踏んだ順に補足します。
まず、出力を一度ファイルへ落としてから判定していること。 パイプで直接切り詰めると、切られた側のプロセスが SIGPIPE で落ちて終了コードが実態とずれます。検査は合格しているのに失敗として扱われる、あるいはその逆が起きる。この落とし穴には実際にはまり、原因の切り分けに時間を使いました。対処としては凝ったことをせず、素直に一度ディスクへ書くのが確実です。本番運用に載せる前に、ここだけは確かめておく価値があります。
次に、合格時に証拠ファイルのパスを出していないこと。 最初はパスを出していました。親切のつもりでしたが、高頻度で走る hook では、そのパス1行が毎回積み上がります。しかも合格しているのですから、誰もそのファイルを開きません。読まれないことが分かっている情報を毎回返すのは、単なる負債の定期購入でした。
そして、切り詰めたときに必ず総量を書き添えていること。 head -c だけで切ると、受け取る側は「これで全部なのか、途中なのか」を区別できません。ここを区別できないと、部分的な情報から誤った結論に進む余地が生まれます。「1,500バイト中の1,500バイトを表示、全体は42,280バイト・398行」と書いてあれば、続きがあることは明白です。切ること自体より、切った事実を隠さないことのほうが重要でした。
何を残し、何を捨てるかの優先順位
予算を超えたときに何から捨てるか。ここも先に決めておくと、実装が迷いません。私が使っている優先順位は次のとおりです。
- 違反の総件数 — 1件なのか200件なのかで、取るべき行動が根本的に変わります
- 代表的な違反の先頭3〜5件 — 直し方の見当をつけるのに要る最小限
- 違反の種類の内訳 — 「同じ原因が200件」なのか「200種類の別問題」なのかの判別
- 証拠ファイルのパス — 全件が必要になったときの入り口
- 残りの全件明細 — 返さない
多くの場合、1と2だけで次の一手は決まります。3が要るのは、件数が多くて一括で直したいときです。違反が二桁を超える運用では、この場合は3までを既定で返す形をお勧めします。内訳が分かるかどうかで、修正の設計がまるごと変わるためです。
逆に言えば、5を返している hook は、ほぼ確実に予算を無駄遣いしています。200件の違反が全部同じ原因なら、199件は最初の1件と同じことしか語っていません。
終了コードと出力量は、別々に設計します
もう一つ、混同しやすい点を書いておきます。終了コードと出力量は独立した設計軸です。組み合わせは4通りあり、そのすべてに正当な使い道があります。
| 出力が短い | 出力が長い |
| 成功(exit 0) | 合格した検査。既定にすべき形 | 要注意。合格しているのに文脈を消費している |
| 失敗(非ゼロ) | 原因が1行で言い切れる失敗。理想的 | 予算内なら妥当。超えるなら要約して逃がす |
見落とされやすいのは右上、つまり**「成功しているのに長い」**の枠です。ここは失敗していないので誰も調べません。エラーも出ない。それでいて、毎回確実にコンテキストを削っていきます。詰まったときに真っ先に疑うべきなのは、失敗した hook ではなく、静かに成功し続けている hook のほうでした。
私自身、応答が鈍くなった原因を探して失敗ログばかり見ていた時間があります。犯人は一度も失敗していませんでした。
自分の 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="$("$@" 2>&1)"
STATUS=$?
printf '%s\t%s\t%s\t%s\t%s\n' \
"$(date +%Y-%m-%dT%H:%M:%S)" \
"$NAME" \
"$STATUS" \
"$(printf '%s' "$OUT" | wc -c | tr -d ' ')" \
"$(printf '%s' "$OUT" | wc -l | tr -d ' ')" \
>> "$LEDGER"
printf '%s' "$OUT"
exit "$STATUS"
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つ選び、成功したときの出力を1行に削ってください。
削れるかどうかを判断する問いはシンプルです。その行は、次に取る行動を変えますか。変えないのであれば、それは会話に置く必要のない情報です。
検査の網を細かくすることには、私も長く時間を使ってきました。けれど網が細かくても、報告が長ければ、その報告自体が次の判断を鈍らせます。静かに合格し続ける検査こそが、いちばん頼りになる。今はそう考えています。
お読みいただきありがとうございました。手元の hook を測ってみて、思いがけない順位が出たなら、そこが最初の一手になるはずです。