Claude Code の PreToolUse フックに「同じ種類の作業は4時間で3件まで」という上限を付けている。自分が同じ作業を機械的に繰り返すのを止めるためのものだ。
2026年8月28日、その上限に当たった。窓は4時間、上限は3件、最後に該当の作業をしたのは15時台。素直に引けば19時台に開く。ところが手元のメモには「18時16分以降に開く」と書いてあった。
実装を読んで数え直したら、正しいのは19時08分だった。そして両者の差は待ち方ではなく、数え方から出ていた。
上限つきのフックは、たいていこの形になる
epoch=$(date +%s)
printf '%s %s\n' "$epoch" "$category" >> "$LOG_FILE" # ← 記録
count_recent() {
local cutoff=$((epoch - 14400)) # 4時間
awk -v cat="$1" -v cutoff="$cutoff" \
'$1 >= cutoff && $2 == cat { c++ } END { print c + 0 }' "$LOG_FILE"
}
recent=$(count_recent "$category")
if [ "$recent" -gt "$limit" ]; then # ← 判定
echo "ブロック: 直近4時間で上限超え(${recent}/${limit})。" >&2
exit 2
fi
読む分には自然な順番だ。まず起きたことを記録し、それから数える。
ただしこの順番は、ひとつの仕様を決めている。弾かれた試行も1件として数える、という仕様だ。
exit 2 で拒否する時点で、>> "$LOG_FILE" はすでに走り終わっている。ブロックされた試行が窓の中に1件残るので、次に試すと窓の中の件数はさらに増える。
何が起きるか
上限3件・窓4時間として、こう進む。
| 時刻 | 何をしたか | 窓の中の件数 | 結果 |
|---|---|---|---|
| 10:00 | 実行 | 1 | 通る |
| 10:05 | 実行 | 2 | 通る |
| 10:10 | 実行 | 3 | 通る(ここで上限) |
| 10:15 | 実行 | 4 | 弾かれる。だが記録は残る |
| 10:20 | 再試行 | 5 | 弾かれる。記録が増える |
| 10:25 | 再試行 | 6 | 弾かれる |
10:10 の時点では「14:00 に開く」と読める。ところが弾かれた3回が窓に入るので、実際に開くのは 14:25 だ。再試行するたびに、開く時刻が後ろへ動く。
上限は実行の回数の上限のつもりだったが、実装は試行の回数の上限になっていた。
試さずに計算する
自分のフックがどちらなのかは、記録を数えれば分かる。
cat /tmp/business-focus-guard/events.log | python3 -c "
import sys,time
now=int(time.time()); cut=now-14400 # 窓は4時間
rows=[l.split() for l in sys.stdin if l.strip()]
pub=sorted(int(r[0]) for r in rows if r[1]=='publish' and int(r[0])>=cut)
print('窓の中の件数:', len(pub))
print('開く時刻:', time.strftime('%H:%M', time.localtime(pub[len(pub)-3]+14400)) if len(pub)>2 else 'いま開いている')
"
len(pub)-3 は「上限が3件なので、自分の1件を足して3以下に収めるには、古いほうから何件が窓を出ればよいか」だ。上限が N なら len(pub)-(N-1) になる。
手元では窓の中に10件あった。3件の上限に対して10件だ。古いほうから8件が抜けるのを待つ必要があり、8件目が窓を出るのが19時08分。メモの「18時16分」は、件数を数え直さずに書いた見積もりだった。
この計算を先にやれば「試して弾かれる」を一度も踏まずに済む。そして踏まないことが、そのまま開く時刻を後ろへ動かさないことになる。
どちらの設計が正しいのか
弾かれた試行を数えないほうが直感には合う。上限は実行の上限だったはずだからだ。直すなら記録を判定の後ろへ動かすだけでよい。
recent=$(( $(count_recent "$category") + 1 )) # 自分の分を仮に足して数える
if [ "$recent" -gt "$limit" ]; then
exit 2 # 記録せずに拒否
fi
printf '%s %s\n' "$epoch" "$category" >> "$LOG_FILE"
ただし、いまの形にも理由がある。弾かれてもコストが増えないなら、上限に当たったあと機械的に叩き続けるのが最適な戦略になってしまう。弾かれた試行を数えるのは、その乱打に値段を付ける仕組みでもある。
つまり不具合というより、決めていなかった設計の分岐だ。決めるべき点はひとつしかない。
この上限は「実行を制限する」のか、「試行そのものを抑止する」のか。
前者なら記録を判定の後ろへ動かす。後者ならいまのままで正しく、代わりにブロックの文へ「弾かれた試行も数える」と書くべきだ。書いていないと、読んだ人は待てば開くと思って叩き、開く時刻を自分で後ろへ動かしてしまう。
自分は後者を選んだ。この上限は、同じ作業を機械的に繰り返すのを止めるために置いたものだからだ。乱打に値段が付くのは、この上限にとっては仕様のうちに入る。代わりに、開く時刻を先に計算する手順のほうを手元の手順書へ書いた。
自分のフックを確かめる
grep -n '>>' ~/.claude/hooks/your-hook.sh # 記録は何行目か
grep -n 'exit 2' ~/.claude/hooks/your-hook.sh # 拒否は何行目か
記録の行番号が拒否の行番号より小さければ、そのフックは弾いた試行を数えている。
上限つきのフックを書いているなら、一度見ておく価値がある。自分は書いておきながら、上限に当たって初めて読み返した。
まとめ
- 上限つきのフックは「記録してから数える」形になりやすく、その順番が弾かれた試行も上限に数えることを決めている
- 上限に当たってから再試行するたび、開く時刻が後ろへ動く
- 開く時刻は記録を数えれば試さずに出せる。上限が N なら「窓の中の件数 −(N−1)」番目の記録が窓を出る時刻
- どちらの設計にも理由がある。決めるべきは実行の上限か試行の上限かで、試行の上限だと決めたならブロックの文にそう書く