第1幕: 現場 ── 三回目
三回目です。
今朝、社長が僕の PR に書いたのは、それだけだった。予約一覧の画面で、予約の時刻が九時間ずれて表示されている。日付の処理は決まった関数を通すこと。その規則は、AI に渡している指示書に、ちゃんと書いてある。書いてあるのに、AI は三回、同じところで間違えた。そして僕は、三回とも同じ指摘を受けた。
アーケードの屋根を、雨が叩いていた。
駅から五分の商店街で、通りの全部に屋根がかかっている。雨の日でも傘が要らないので、昼どきは人が多い。その中ほどにある美容院の前で、僕はノートパソコンと、新しいタブレットの箱を抱えて立っていた。店頭で予約を受け付けるタブレットを、新しいものに入れ替える。それが今日の仕事だ。社長に、行ってこい、と言われた。今朝の PR の件では何も言われなかった。言われなかったぶん、行ってこい、の一言が長く聞こえた。
店のテントの上に、ハトが並んでいた。六羽、いや七羽。雨を避けて、屋根の下に入ってきたらしい。テントの縁から、白いものが一筋垂れていた。
「早瀬さん、雨の中すみませんねえ」
店長が、ガラス戸を開けて迎えてくれた。前に一度、この店長から電話をもらったことがある。予約の確定メールが一通も届いていない、という電話だった。僕は屋上で、すみません、を何度も言った。
あのとき、僕は AI の「完了しました。テスト12件すべて通過」を信じてマージしていた。屋上で、カラスを追い払いに来ていた鷹匠の人に会って、報告を読むだけでは何も確かめていないことを教わった。それから、完了の報告に書かれた件数を、僕の側で走らせたテストの件数と突き合わせる仕組みを入れた。指示書にも、一行足した。完了の報告には、通ったテストの件数を書くこと。
その一行は、よく効いた。
僕は受付の脇にしゃがみ込んで、古いタブレットの配線を外しはじめた。手を動かしながら、今朝のことを順に思い出す。
予約一覧の画面を少し直してほしい、と AI に頼んだ。コーディングエージェント、と呼ばれる種類の AI だ。こちらが文章で頼むと、リポジトリの中を自分で読んで、コードを書いて、テストまで走らせてくれる。うちで使っているのは Claude Code だ。AI は画面を直して、予約の時刻を出すところで、日時の値をそのまま toLocaleString() に渡していた。サーバーの時計は世界標準時で動いているので、店の時刻に直す処理を通さないと、九時間ずれる。うちでは、日時の表示は必ず src/lib/datetime.ts の関数を通す決まりにしている。その関数が、店のタイムゾーンに直してくれる。
社長は、差分を開く前に気づいたらしかった。コメントが付いたのは、PR を出して一分後だった。差分を読むには短すぎる。題名と説明の最初の数行で、分かったのだと思う。社長が見つけたのは、差分の中身ではなかった。回数だった。
それが、いちばんこたえた。
新しいタブレットの電源を入れると、初期設定の画面が出た。しばらく待たされる。僕は膝の上でノートパソコンを開き、指示書を表示した。
指示書というのは、AI が仕事にかかる前に必ず目を通すメモのことだ。Claude Code なら、リポジトリのいちばん上に置いた CLAUDE.md というファイルを読んでから仕事にかかる。うちの CLAUDE.md は、僕が書いてきた。
スクロールした。まだ続く。もっとスクロールした。
最後まで行って、行番号を見た。三百十二行あった。
見出しが十四個ある。日付の扱い。ログの残し方。ファイルの置き場所。テストの書き方。環境変数。エラーの文言。コミットのメッセージ。最初の一行を足したあの日から、社長に PR で何か言われるたびに、僕はそれを一行にして、ここに足してきた。言われたことを、二度と言われないように。
足すたびに、ちゃんと仕事をした気がしていた。指摘をそのまま規則にして、AI に渡す。それは、言われたことを直すより一段上の手当てだと思っていた。直すのは今回だけだけれど、規則にすれば次からも効く。最初の一行が効いたように。
店長が、タオルで手を拭きながら戻ってきた。
「表のハト、見ました? この雨で、みんな屋根の下に入っちゃって。テントが汚れるし、お客さんの頭の上だし。商店街の組合で、鷹の業者さんを頼んだんですよ。今日、来てくれてるはずなんだけど」
鷹、と聞いて、僕は顔を上げた。
アーケードの奥のほうから、小さな電子音が聞こえていた。ピッ、ピッ、と、一定の間隔で鳴っている。何の音かは分からなかった。誰かの携帯かと思った。
僕は指示書に目を戻した。今朝の規則を探す。百八十三行目にあった。
日付の表示は、src/lib/datetime.ts の関数を通すこと。
書いてある。たしかに、書いてある。
なのに、と思ったとき、電子音がすぐ近くで鳴った。
第2幕: 再現 ── 百八十三行目
ガラス戸の外に、若い女の人が立っていた。
前に見たときと同じ、風に跳ねた髪。革の手袋は、今日も左腕だけだった。右手で、胸の前に小さな箱を持っている。箱の上から、細い棒が三本、魚の骨のように横へ突き出していた。電子音は、その箱から出ていた。
僕は、その人を知っていた。名前も知っている。あの日、屋上の手すりに置いてあったカードに、鷹宮スズ、と書いてあった。
鷹宮さんも、こちらを見た。少しだけ目を細めて、それから言った。
「屋上の」
「はい。あの、早瀬です。前に、予約のメールの件で」
覚えていてくれたことが嬉しくて、声が少し上ずった。前回のお礼を言おうと思った。言おうと思ったのに、口から出たのは別のことだった。
「実は今朝、また AI のことで。指示書に書いてある規則を、三回も破られて。書いてあるんですよ。ちゃんと書いてあるのに」
鷹宮さんは、うん、とだけ言った。そして箱を持ったまま、アーケードの通りへ出ていった。
僕はノートパソコンを閉じて、あとを追った。タブレットの初期設定は、まだ終わりそうになかった。
鷹宮さんは、箱から突き出た棒を、ゆっくり左右に振りながら歩いていた。棒を右に向けると、ピッという音が少し強くなる。左に向けると、弱くなる。鷹宮さんは音の強いほうへ、数歩ずつ進んだ。それから、上を見た。
つられて、僕も見上げた。
アーケードの天井には、屋根を支える鉄骨の梁が、何本も渡してある。その一本に、鷹がとまっていた。屋上で見た、あの鷹だった。焦げ茶の背と、肩の栗色。尾羽の白い帯が、鉄骨の影の中で少し浮いて見えた。下を歩く人たちは、誰も気づいていなかった。
「それ、何ですか。その箱」
「受信機。この子に、発信機を付けてる」
鷹宮さんは、鷹の脚のあたりを顎で示した。目を凝らすと、脚に小さな黒いものが付いていて、そこから細い線が一本垂れていた。
「発信機が付いてるなら、居場所は分かってるんですよね。なのに、どうして毎回そうやって、鳴らして歩き回るんですか。付けてあるんだから、それで十分じゃないんですか」
聞きながら、僕は少し得意だった。発信機を付けてあるなら、確かめるまでもない。仕組みを置いたら、あとは任せればいい。それが、僕があれからやってきたことだったからだ。
鷹宮さんは、棒の向きを少し変えた。
「この子の音か、聞いてる」
それだけだった。
この子の音。僕はその言葉を、鷹の道具の話として聞いた。専門の人には専門の確かめ方があるのだろう。それ以上は考えなかった。
鷹宮さんが、革の手袋の腕を上げた。首にかけた呼び笛を、ごく短く鳴らす。鷹が梁から降りてきた。羽を広げて、美容院のテントの縁をかすめるように低く飛ぶ。テントの上のハトが、ばらばらに跳ね上がった。羽音がアーケードの屋根に反響して、一瞬、雨の音がかき消えた。ハトたちは、屋根の切れ目から雨の外へ散っていった。
鷹は大きく弧を描いて、鷹宮さんの手袋に戻った。鷹宮さんは、すぐには餌をやらなかった。最初に見たのは、鷹の脚だった。屋上のときと、同じ順番だった。
僕は店の軒先のベンチに戻って、ノートパソコンを開き直した。
三百十二行が、本当はどうなっているのかを確かめたかった。ターミナルを開いて、指示書の行数と、規則の行を数える。
| |
wc -l はファイルの行数を数えるコマンドで、grep -c は、指定した文字の並びに合う行の数を出す。'^- ' は「行の先頭が、ハイフンと空白で始まる」という意味の書き方で、指示書の規則は全部、箇条書きの一行として書いてあるから、これで規則の数になる。grep -n は、合った行を、行番号つきで出す。
| |
規則は百四十七行あった。日付のことは、三か所に、違う言い方で書いてあった。四十一行目と、百十八行目と、百八十三行目。言われるたびに、その日の言葉で足したからだ。今朝の規則は、百八十三行目。指示書の、ちょうど真ん中より少し下だった。
行末に (hook: …) と付いた行が二つある。あれは僕が書いた。指示書を一度整理したとき、hook で守らせている規則に、目印として hook の名前を書き添えておいた。
hooks というのは、決まった時点で必ず走るスクリプトだ。AI がファイルを書き換える直前などに、自分で書いたプログラムを走らせることができる。プログラムは、AI がこれから何をしようとしているかを JSON という形式の文字で受け取り、終了コードで返事をする。終了コードは、プログラムが終わるときに返す一つの数で、hooks の約束では、0 なら「通す」、2 なら「止める」になる。登録は、プロジェクトの .claude/settings.json に書く。そのファイルを読むのは AI ではなく、Claude Code という道具の側で、AI が書き込もうとすると、道具が先にスクリプトを起動する。AI には、走らせるかどうかを選べない。
僕は少し前に、ネットの記事で hooks を知って、二本だけ置いていた。.env という、パスワードなどの秘密の値を書いておくファイルを、AI が書き換えないようにする guard-env。デバッグ用に画面へ値を書き出す console.log を、コードに残させない no-console-log。
今朝の規則には、hook が無い。
それは分かっていた。でも、本当に何も止めないのかを、自分の手で確かめておきたかった。
AI がファイルに書き込もうとするとき、hook に届く JSON の形を、ドキュメントで確かめた。AI は、ファイルを丸ごと書くときは Write という道具を、ファイルの一部を書き換えるときは Edit という道具を呼ぶ。どちらのときも、hook には次のような JSON が届く。
| |
| |
hook_event_name の PreToolUse は、道具を使う直前、という時点の名前だ。tool_name が道具の名前で、tool_input が、その道具に渡そうとしている中身。file_path は、書き込もうとしているファイルの場所で、コンピューターのいちばん上からの絶対パスで届く。ここに載せた形は、Claude Code の公式ドキュメントの表(2026年10月16日参照)と、実際の Claude Code 2.1.300 で届いた入力の記録から作ったもので、値の一部を短くしてある。
今朝 AI がしたのは、src/booking/list.ts の一行を書き換える Edit だった。その JSON をファイルに書いて、二本の hook に、手で流してみた。
| |
< は、ファイルの中身を、プログラムの標準入力に流し込む書き方だ。標準入力は、プログラムが外から文字を受け取る入口で、Claude Code が hook を起動するときも、JSON はここから届く。echo $? は、直前のコマンドの終了コードを表示する。
| |
どちらも 0。通した。
当たり前だった。時刻の hook なんて、書いていない。でも、画面の二つの 0 を見て、時刻の違反を止めるものが、指示書の外にはどこにも無いことを、自分の目で確かめた。
それを、テストの形にした。こうなっていてほしい、という形で。
| |
二本の hook は、止めるかどうかを決める判定の部分を blocks という関数にして、外から読み込めるようにしてある。import { blocks as guardEnv } は、blocks という名前の関数を、guardEnv という別の名前で読み込む書き方だ。二本とも同じ blocks という名前なので、別の名前を付けて区別している。edit(...) は、さっきの Edit の JSON を作る小さな関数で、テストに渡すための固定のサンプル、fixture を作るのに使う。assert.ok は、中身が正しくなければテストを落とす。|| は「どちらか」だ。どちらかの hook が止めてくれれば、緑になる。
node --test で走らせた。
| |
赤だった。
指示書は、AI が次に何をするかを選ぶ材料だ。AI は、たいていはそれを読んで、そのとおりにする。だから最初の一行は効いた。でも、守るかどうかを決めているのは、毎回 AI の側だ。今朝の規則には、指示書の外に確かめるものが何も無い。そしてその一行は、三百十二行の中の百八十三行目にある。
それでも、まだ分からなかった。書いてあることは、書いてある。読まれているなら、どうして三回も破られるのか。
僕は Claude Code の公式のベストプラクティスのページを開いて、指示書の節を読んだ。ちょうど、僕のことが書いてあった(https://code.claude.com/docs/en/best-practices 、2026年10月16日参照)。
If Claude keeps doing something you don’t want despite having a rule against it, the file is probably too long and the rule is getting lost.
それを禁じる規則があるのに、Claude が望まないことをし続けるなら、たぶんファイルが長すぎて、その規則が埋もれている。
社長の三回目と、百八十三行目が、そこでつながった。
規則が読まれていないわけではなかった。読まれているものが多すぎた。言われるたびに足した三百十二行の中で、今朝の一行は、ほかの百四十六の規則と同じ重さで並んでいた。日付のことだけでも、三か所に、少しずつ違う言い方で。足せば足すほど、一行ずつは薄くなる。僕は、効かせたくて足していた。足したぶんだけ、効かなくしていた。
顔を上げると、鷹宮さんは軒先に立って、手帳に何か書き込んでいた。小さな、薄い手帳だった。僕の指示書の、何分の一だろう、と思った。何も聞かなかった。
第3幕: 手順を整える ── 鳴っているのは、どの子か
ベストプラクティスのページには、続きがあった。長すぎる指示書を、どうすればいいか。
Ruthlessly prune. If Claude already does something correctly without the instruction, delete it or convert it to a hook.
容赦なく刈り込め。指示が無くても Claude が既に正しくやっていることなら、消すか、hook に変えろ。
指示書の刈り込み。指示書から、AI が言われなくても正しくやれることと、hook やテストで確かめられることを消して、短くすることだ。短くすれば、残った一行ずつが埋もれにくくなる。
同じドキュメントの別のページには、こうも書いてあった(https://code.claude.com/docs/en/features-overview 、2026年10月16日参照)。
An instruction like “never edit
.env” in CLAUDE.md or a skill is a request, not a guarantee. APreToolUsehook that blocks the edit is enforcement.
.env を書き換えるな、という指示書の一行は頼みごとであって、保証ではない。書き込みを止める PreToolUse の hook が、強制になる。
.env だった。僕の七十一行目と、同じ例だった。思わず、指示書のその行を指でなぞった。
公式は、hook で守らせている規則なら消してよい、とまでは書いていない。書いてあるのは、消すか、hook に変えるか、だ。でも、hook に変えた規則なら、指示書からは消せるはずだ。少なくとも僕は、そう読んだ。
それなら、話は早い。行末に hook の名前を書いておいたのは、このためだったんじゃないかとさえ思った。
僕は、指示書の一行を表す型から書いた。
| |
HookInput は、さっきの JSON の形だ。tool_input を Record<string, unknown> にしたのは、Write と Edit で中身のキーが違うからで、これは「文字列のキーに、何かの値が入っている入れ物」という意味の型になる。unknown は、中身の型をまだ決めていない、という印で、使う前に確かめないと使えない。Rule は、指示書の一行。id が名前、text が規則の文、hookName? が行末に書いた hook の名前で、? は書いていない行もある、という印。violations は、あとで使う。
そして、最初の判定を before.ts に書いた。
| |
filter は、配列の中から条件に合うものだけを残す。map は、残ったものを一つずつ別の形に変える。ここでは、行末に名前があって、その名前が登録されている hook の一覧に入っている規則だけを残し、id を並べて返す。import type は型だけを読み込む書き方で、読み込み先に .ts まで書くのは、Node 24 が TypeScript のファイルを型の記述を取り除いてそのまま走らせるからだ(型が正しいかの検査まではしない。それには tsc が要る)。
登録されている hook の名前は、.claude/settings.json に書いたスクリプトのファイル名から、.ts を除いて作ることにした。guard-env.ts なら guard-env。
僕の指示書に通すと、こう返ってきた。
| |
二行、消せる。三百十二行のうちの二行だけれど、残りの行にも全部 hook を書いて、名前を付けていけば、どんどん短くできる。
僕はノートパソコンを抱えて、軒先の鷹宮さんのところへ行った。誰かに言いたかった。
「さっきの発信機の話、僕のやってることと同じなんです。AI に、hook っていう、毎回必ず働く装具を付けてあって。それで、装具が付いてる規則には、名前を書いてあるんです。だから、名前が書いてある行は、指示書から消していい。発信機を付けてある鷹のことは、手帳に書いておかなくていいのと同じで」
鷹宮さんは、答えなかった。
受信機を、僕に差し出した。
受け取ると、思ったより軽かった。ピッ、ピッ、と鳴っている。
「あの子のほうへ、向けてみて」
鷹は、また天井の梁の上にいた。僕は、魚の骨のような棒を、梁のほうへ向けた。
音は、変わらなかった。
反対へ向けた。変わらない。床へ向けても、店のほうへ向けても、同じ強さで、ピッ、ピッ、と鳴りつづける。さっき鷹宮さんが振ったときは、向きで強さが変わっていたのに。
「あれ。これ、壊れてます?」
鷹宮さんは、肩にかけていたバッグを開けた。中から、鷹の脚に付いていたのと同じ、小さな黒いものを取り出した。指先で、何かを押す。
音が、やんだ。
少しのあいだ、何も鳴らなかった。それから、さっきより少し高い音が、ピッ、と鳴りはじめた。棒を梁のほうへ向けると、強くなる。外すと、弱くなる。
「この子の音」
鷹宮さんが、梁を見上げて言った。
予備の発信機が、バッグの中で点いていたらしい。僕がさっきまで聞いていたのは、鷹の音ではなかった。鷹宮さんのバッグの音だった。ずっと鳴っていたから、鷹から鳴っているのだと思っていた。
僕は、受信機を持ったまま、しばらく動けなかった。
付けてある。鳴っている。でも、向けてみるまで、それがどこから鳴っているのかは分からなかった。
行末に hook の名前を書いたのは、僕だ。今朝の時刻の違反は、さっき自分の手で hook に流して確かめた。なのに、名前の付いた二行は、流そうともしなかった。名前を見た時点で、確かめるのをやめていた。その hook が、その規則を破る操作で本当に止まるのかを、僕はいつ確かめただろう。hook を書いたその日に、一度。それも、一つの例で一度だけだ。それから先、名前は、確かめたことの代わりになっていた。名前が書いてあるから、止まるはずだと思っていた。
それが本当に危ないのかどうかは、まだ知らなかった。二本とも、書いた日にはちゃんと止まった。でも、名前で消そうとしていた二行のどちらについても、僕は今日、何も確かめていなかった。
「……一行ずつ、流してみます」
受信機を返すと、鷹宮さんは、うん、と言った。
ベンチに戻って、消す根拠を、名前から、実際に流した結果に変えることにした。
規則ごとに、その規則を破る操作の例を書く。違反例と呼ぶことにした。hook に届く JSON の形で書いた fixture だ。Rule の violations は、そのための欄だった。
.env の規則には、二つ書いた。.env を書き換える Edit と、.env.local に丸ごと書く Write。console.log の規則にも、二つ。予約一覧のファイルに console.log を含む中身を丸ごと書く Write と、同じファイルの一行を console.log に書き換える Edit。AI がファイルを書くときに使う道具は、Write と Edit の二つだから、両方入れた。今朝の時刻の規則には、今朝の Edit を一つ。
完了の報告に件数を書く、あの最初の一行には、違反例を書けなかった。完了の報告は、ファイルへの書き込みではない。AI が会話の中で返す文章だから、hook に届く JSON の形にならない。流す違反例が無い。
流す違反例が無い規則は、判定できない。判定できないものを、消してよいとは言えない。残す。
判定の結果は、三通りにした。
| |
HookEntry は、登録された hook の一件だ。matcher は、どの道具のときにその hook を走らせるかの指定で、command と args は、起動するコマンドと、渡す引数。Verdict が判定の結果で、| で区切った三つのうちのどれか一つの形になる。prune は刈り込み候補。keep は残す規則で、止まらなかった違反例を passed に添える。untested は、違反例が無いので判定していない規則。Runner は、hook を一件起動して、その終了コードを返す役目だ。前に完了の報告を検める関数を書いたときも、テストを走らせる役目を外から渡せるようにした。同じ考え方で、テストでは本物の hook を起動する代わりに、決まった振る舞いをする偽物を渡せる。
ここで一つ、気をつけることがあった。違反例を、登録された hook の全部に流してはいけない。Claude Code は、matcher が道具の名前に合う hook しか起動しない。全部に流すと、本番では起動されない hook が止めたことになってしまう。
matcher の読み方は、公式ドキュメントの表に三通り書いてあった(https://code.claude.com/docs/en/hooks 、2026年10月16日参照)。書いていないか、空か、* なら、どの道具にも合う。英数字と _・-・空白・,・| だけでできていれば、| か , で区切った名前の、どれかと完全に一致したときに合う。それ以外の文字が入っていれば、正規表現、つまり文字の並びのパターンとして、道具の名前のどこかに合えば合う。僕の Edit|Write は、二つ目の読み方になる。名前の一致なので、たとえば MultiEdit という名前には合わない。もし Edit.* と書いていたら、. と * が入るので正規表現として読まれ、NotebookEdit という名前にも合う。
判定を after.ts に書いた。
| |
matches の二つ目の if の /^[A-Za-z0-9_\- ,|]+$/ は、matcher が、英数字と _・-・空白・,・| だけでできているかを調べる正規表現だ。[...] は「中のどれか一文字」、\- は文字としてのハイフン、+ は「一文字以上続く」、^ と $ は先頭と末尾で、合わせて「最初から最後まで、その文字だけでできている」になる。そうなら、split(/[|,]/) で | か , のところで切り分け、trim() で前後の空白を落とし、some で、どれか一つでも道具の名前と同じかを見る。some は、配列の中に一つでも条件に合うものがあれば true を返す。それ以外の文字が入っていれば、new RegExp(matcher) で正規表現にして、test で道具の名前に合うかを見る。
buildPruneCheck は、登録された hook の一覧と走らせ役を受け取って、判定の関数を返す。だから呼ぶときは、buildPruneCheck(hook の一覧, 走らせ役)(規則の一覧) と、括弧が二つ続く。中身は、規則を一つずつ見ていく。違反例が無ければ untested。あれば、違反例を一つずつ見る。passed を作るところは、内側から読む。内側の entries.filter(...) は、その違反例の道具に matcher が合う hook だけを選ぶ。続く .some(...) は、選んだ hook のどれかが終了コード 2 を返したか、つまり止めたかを、true か false で返す。頭の ! は「ではない」で、entries だけでなく、その true か false の全体をひっくり返す。だから外側の rule.violations.filter(...) には、どの hook にも止められなかった違反例だけが残る。それが passed だ。止まらなかった違反例が一つも無ければ、prune。一つでもあれば、keep。
hookName は、どこにも出てこない。行末に何と書いてあっても、この関数は読まない。決めるのは、流した結果だけだ。
本物の走らせ役と、設定ファイルを読む部分は wiring.ts に書いた。
| |
readEntries は、.claude/settings.json の文字列を受け取って、hook の一覧にする。設定ファイルの中では、hook は matcher ごとのまとまりに入っているので、flatMap でそのまとまりをほどいて、一列に並べ直す。flatMap は、一つずつ配列に変えたものを、つないで一本の配列にしてくれる。?. は、手前が無いときはそこで止めて undefined にする書き方で、?? は、左が null や undefined のときだけ右の値を使う。設定に hooks が一つも書かれていなくても、空の一覧になる。h.command as string の as string は、直前の filter で文字列だと確かめてあるので、型の上でも文字列として扱わせる印だ。
hookNamesOf は、最初の名前の表のために、登録された hook のスクリプトのファイル名から .ts を除いた名前を並べる。split('/').pop() で、パスを / で切った最後、つまりファイル名を取り、replace(/\.ts$/, '') で末尾の .ts を消している。
spawnHook は、hook を本物の子プロセスとして起動する走らせ役だ。子プロセスは、今動いているプログラムから、別に起動したプログラムのこと。spawnSync で起動して、input に渡した JSON を、標準入力として流し込む。Claude Code が hook を起動するときと、同じ受け取り方になる。設定の args に書いた ${CLAUDE_PROJECT_DIR} は、Claude Code がプロジェクトのいちばん上の場所に置き換えてくれる印なので、ここでは replaceAll で、自分で同じ置き換えをする。'${CLAUDE_PROJECT_DIR}' は単一引用符で囲んであるので、${...} は値の埋め込みではなく、その文字の並びそのものとして探される。command が node なら、process.execPath、つまり今動いている Node そのものの場所を使う。const { NODE_TEST_CONTEXT: _ctx, ...env } = process.env; は、環境変数(プログラムに外から渡す設定値)の一覧から、NODE_TEST_CONTEXT だけを _ctx という使わない名前で取り出して捨て、残りを全部 env にまとめる書き方だ。テストの中からこの関数を呼んだとき、起動した hook がテストの印を受け継いでしまわないようにするための手当てになる。
この関数は、hook として登録するものではない。指示書の行を消す前に、僕が手で一度走らせるための道具だ。毎回必ず走る仕掛けではなく、消す前に確かめる仕掛け。
四つの規則と違反例を書いた fixture を、buildPruneCheck に通した。走らせ役には、テスト用の偽物を渡す(偽物の中身は、あとで書く)。四つの規則の判定を、一つずつ見た。
env は prune。console-log は keep で、passed に Edit の一件が付いている。datetime は keep で、passed に今朝の Edit。count は untested。
指が、キーの上で動かなくなった。
env は、刈り込み候補。datetime は、hook が無いので残る。count は、違反例が無いので残る。そこまでは思ったとおりだった。
console-log が、keep になっている。止まらなかった違反例として、Edit の一件が出ている。
no-console-log は、僕が書いた hook だ。書いたその日に試して、ちゃんと止まるのを見た。
no-console-log.ts を開いた。
| |
const { content } = input.tool_input; は、tool_input の中の content を取り出して、同じ名前の変数に入れる書き方だ。typeof content === 'string' は、その値が文字列かを調べる。文字列で、しかも console.log( を含んでいれば、止める。下の if (import.meta.main) は、このファイルがスクリプトとして直接起動されたときだけ中を実行する、という印で、テストから blocks だけを読み込んだときは、標準入力を読みにいかない。直接起動されたら、標準入力の JSON を読み(readFileSync(0, 'utf8') の 0 は標準入力の番号だ)、止めるなら理由を標準エラーに書いて、終了コードを 2 にする。
content。
今朝の違反を流す前に並べた、二つの JSON を見直した。
Write の tool_input には、file_path と content がある。content に、ファイルの中身が丸ごと入っている。
Edit の tool_input には、file_path と old_string と new_string と replace_all がある。content は、無い。
公式の表のとおりだった。Edit は、ファイルの一部を書き換える道具だから、ファイルの全文は持っていない。持っているのは、書き換える前の文字(old_string)と、書き換えたあとの文字(new_string)だけだ。Edit で書き込まれる console.log は、new_string の中にある。
Edit のとき、input.tool_input.content には何も入っていない。無いキーを読むと、JavaScript では undefined という「値が無い」という印が返る。typeof undefined は 'string' ではないので、typeof content === 'string' は false になる。判定は、「書かれていないので、問題なし」に倒れる。hook は、Edit のたびに、何も無いところを調べて、何も無いので通していた。
書いた日に試したのは、Write だった。新しいファイルを作る例で試して、止まるのを見て、それで安心した。でも、AI がふだん既にあるファイルを直すとき、僕の手元の記録では、ほとんどが Edit だった。
名前の表で引いていたら、僕はこの行を消していた。消したら、Edit で書かれる console.log は、指示書からも、hook からも見られずに通る。行末の (hook: no-console-log) は、確かめた結果ではなく、確かめたつもりの印だった。名前は付いていた。でも、この違反では止まらなかった。さっきの受信機と同じで、向けてみるまで分からなかった。
console-log の行は、残す。
それを、テストにした。今わかったことを、二度と名前の表に戻らないように。
| |
rules は、四つの規則と違反例を書いた fixture の配列。current は、今の .claude/settings.json を readEntries で読んだ hook の一覧。fakeRun はテスト用の偽物の走らせ役。kindOf は、判定の一覧から id で一件を探す小さな関数で、?.kind は、見つからなければ undefined、見つかればその kind になる。assert.notEqual は、二つが等しければテストを落とす。console-log の判定が prune でなければ、緑だ。
同じことを、最初の名前の表に聞くテストも書いた。
| |
entries は、current と同じ、今の設定から読んだ hook の一覧だ。includes は、配列にその値が入っているかを調べる。こちらは、赤になった。名前で引けば、console-log は消してよい側に入る。
行末の名前は、消した。代わりに、何がまだ確かめられていないかを書いた。(hook: no-console-log は Write だけ。Edit は未担保)。hook を直すのは、別の仕事にした。
それから、変わったことを、指示書の冒頭のコメントに書いておいた。
規則を足すときは、指摘から一行で済んだ。消すときは、一行ずつ、違反例を流して、止まるところを見てから。消す根拠を、行末に書いた名前から、流して止まったという結果に変えた。
雨が、少し弱くなっていた。アーケードの屋根を叩く音が、細かくなっている。鷹宮さんは、鷹を拳に据えて、通りの向かいの、次の店の軒先へ移っていった。受信機の音が、少しずつ遠ざかった。
第4幕: 検める ── 手帳の薄さ
テストを全部走らせた。
| |
十六本、全部緑になった。今朝の時刻の違反のテストは、赤のまま残してはいない。datetime が keep になる、というテストに置き換えた。止める hook が無いことを、そのまま確かめる形だ。
テストで使う走らせ役は、本物の hook を起動する代わりに、hook の判定の関数 blocks を直接呼ぶ偽物にした。entry.args のファイル名から、どの hook の blocks かを選んで呼び、true なら 2、false なら 0 を返す。止める、通す、を表に手で書いておく偽物にすると、content しか見ない、という肝心の振る舞いまで、僕が表に書き写すことになる。それでは、テストは何も確かめない。偽物が呼ぶのは本物の判定の関数なので、Edit を通してしまうなら、それは hook の中身のせいだ。本物の子プロセスで hook を起動するのは、いちばん下の一本だけだ。Write の違反で 2、Edit の違反で 0。一本だけでも、本物で同じことが起きると分かる。
いちばん上の一本は、no-console-log を直した版を登録した設定で、最初の名前の表と、流して見る判定が、同じ二行を候補に出すことを確かめている。直した版は、このテストのために先に no-console-log-fixed.ts という別のファイル名で書いておいた(中身はあとで載せる)。名前の表は、行末の名前と登録された名前を突き合わせるので、行末の名前も新しいファイル名に揃えた指示書を使った。揃えないと、名前の表のほうで候補に出ないからだ。hook がちゃんと止めるなら、名前の表でも同じ答えになる。名前の表を書いた僕は、間違っていたのではなかった。確かめていなかっただけだ。
念のため、判定をわざと一か所ずつ壊してみた。名前で判定するようにすると、console-log を確かめる二本を含む五本が赤になる。違反例のどれか一つが止まれば候補にするようにすると、console-log のテストを含む四本が赤になる。matcher を見ずに全部の hook に流すと、Write だけの matcher のテストが赤になる。違反例の無い規則を候補にすると、count のテストが赤になる。終了コードが 1 でも止まったことにすると、終了コード2以外の非0のテストが赤になる。終了コード 1 は、hook のエラーだ。あとで載せる設定のように onFailure を止める側にしておけば、本番では落ちた hook も操作を止める。でも、それは規則を見て止めたのではなく、壊れて止めただけで、どの操作でも同じように止まる。それを数えたら、壊れた hook が付いた規則を、守らせている規則として消してしまう。だから数えない。どこを壊しても、どれかが赤になった。
ノートパソコンを閉じて、通りを渡った。
鷹宮さんは、向かいの店の軒先で、また手帳に何か書いていた。鷹は拳の上にいた。この店のテントには、もうハトはいなかった。
「その手帳、薄いですよね」
鷹宮さんが、顔を上げた。
「僕の指示書、三百十二行あるんです。鷹宮さんの手帳は、それよりずっと少ないですよね。書いてないことは、どうやって覚えてるんですか」
軒先で、最初にあの手帳を見たときから、ずっと気になっていたことだった。
鷹宮さんは、受信機を少し持ち上げた。
「これが鳴らしてくれることは、書かない」
それから、手帳を開いて、一ページだけ見せてくれた。細い字で、数行。この店、テントの縁が低い。雨の日、屋根の下に入る。店長さん、朝は九時から。
「鳴らないところだけ、書く」
僕は、そのページを見ていた。
発信機が教えてくれることを、手帳には書かない。鷹がどこにいるかは、受信機を向ければ分かるからだ。ただし、向けて、鳴らして、その音がこの鷹の音だと確かめてから。手帳に書くのは、装具では分からないことだけ。この店の軒の高さ。今日の雨。店の人の頼み。
僕の指示書に残すのは、hook で止められないことだ。
今朝の時刻の規則は、違反例を書けても、それを止める hook が無い。完了の報告に件数を書く規則は、そもそも hook に届く形にならない。どちらも、守らせているのは指示書の一行だけだ。だから、そういう行こそ、埋もれさせてはいけない。
僕は、その二行を、指示書のいちばん上のほうへ移すことにした。それで AI が守るようになるかは、分からない。並べ替えが効くと、どこにも保証されてはいない。でも、百八十三行目に置いておくよりは、ましだと思った。日付の三か所も、一行にまとめた。
ベンチに戻って、指示書の冒頭のコメントに、この刈り込みの関数が確かめていないことを書き足した。
一つ目。公式が言う、消してよい規則のもう一方。指示が無くても AI が既に正しくやっていること。同じページには、一行ごとに自分に問え、とも書いてあった。
Would removing this cause Claude to make mistakes?
この行を消したら、Claude は間違えるだろうか。それを機械的に確かめる手順は、公式にも書いていない。消してみて、AI の振る舞いが変わるかを見ろ、とあるだけだ。僕のテストは本物の AI を呼ばないので、これは測れない。だから、言われなくても AI がやっていそうな行、たとえば「TypeScript で書く」は、この関数の外で、消して様子を見ることにした。どの行を、いつ消して、どの PR で何を見たかを、指示書の冒頭に書き残す。
二つ目。違反例を書いた範囲までしか、確かめていない。僕が思いつかなかった違反の形は、流していない。たとえば、AI が Bash という、コマンドを打つ道具で .env を書き換えたら、僕の hook はそもそも起動されない。matcher が Edit|Write だからだ。そういう違反例を入れれば、keep に出るはずだ。ノートブック形式のファイルを編集する NotebookEdit のような別の道具からの書き込みも、同じで、入れていない。テストに出てくる MultiEdit は、matcher の読み方を確かめるための名前の例だ。入れていない違反は、確かめていない。
三つ目。この関数が読んでいるのは、このリポジトリの .claude/settings.json だけだ。Claude Code は、自分のパソコン全体の設定や、手元だけの設定に書いた hook も合わせて走らせるけれど、それは見ていない。全部の hook を無効にする設定が入っていても、気づかない。
四つ目。テストが確かめているのは、僕が作った fixture と、僕が書いた hook に対して、この関数が思ったとおりに動くことまでだ。Claude Code の側で、hook に届く JSON の形や、matcher の読み方が変われば、この関数は何も言わずに食い違うかもしれない。テストは緑のまま。だから fixture の頭には、どの版の、いつのドキュメントから作ったかを書いておいた。
ここに、今日の時点の僕の設定を残しておく。2026年10月16日時点の Claude Code の設定で、キーや読み方は、Claude Code の改訂で変わることがある。
| |
置き場所は、プロジェクトの .claude/settings.json。PreToolUse の欄に、matcher が Edit|Write のまとまりとして、二本の hook が並んでいる。onFailure は、hook が起動できなかったり途中で落ちたりしたときに、通すか止めるかの設定で、"block" は止める側だ。刈り込みの関数は、この hooks.PreToolUse を読んで、matcher ごとに hook を選ぶ。
指示書が長すぎるときの目安として、公式のメモリのページには、一つの CLAUDE.md は二百行未満を目指せ、とも書いてあった(https://code.claude.com/docs/en/memory 、2026年10月16日参照)。でも、僕はそれを判定には使わなかった。二百行を切っても、残った一行ずつが正しいかは、行数では分からない。
先に別の名前で書いておいた、直した版の中身は、こうだ。
| |
content と new_string の二つを配列に並べて、some で、どちらか一つでも console.log( を含む文字列なら止める。直した版で流し直すと、console-log の行も、刈り込み候補に出た。
それでも、すぐには消さなかった。直した hook を .claude/settings.json に登録して、もう一度流して、止まるのを見てから消す。それを、指示書の冒頭の手順に書いた。
アーケードの出口のほうが、明るくなっていた。雨が上がったらしい。屋根の切れ目から、薄い光が差し込んでいる。美容院のテントの上には、ハトは戻ってきていなかった。
向かいの店の軒先で、鷹宮さんはまだ受信機を鳴らしていた。次の店のテントを見上げている。僕は、声をかけるかどうか迷って、かけた。
「あの、前回のこと。ちゃんとお礼を言ってなかったので。ありがとうございました。今日も」
鷹宮さんは、受信機から目を上げて、短く頷いた。
それから、美容院の店頭に置いてある予約カードの束から、一枚抜き取った。店の名前と、次回の予約日を書く欄が印刷された、名刺くらいのカードだ。鷹宮さんはそれを裏返して、胸ポケットのペンで、何か書きはじめた。
細い字だった。一行ずつ、ゆっくり書く。僕は、書き終えるまで待った。四行書いたところで、ペンが紙から離れた。その下が、一行空いていた。
鷹宮さんは、カードを僕に渡すと、拳の上の鷹を少し持ち上げて、また次の店のほうへ歩いていった。
美容院に戻ると、新しいタブレットの初期設定が、ちょうど終わっていた。予約の画面を出して、時刻の表示を確かめる。店の時刻で出ている。古いタブレットを箱にしまってから、僕は受付のカウンターに置いてあったペンを借りて、カードの空いた一行に書いた。
店長が、カウンターの横から、それを覗き込んだ。何も言わなかった。少しだけ笑って、予約の電話を取りにいった。
次の週、指示書を百九十行まで短くした。減った百二十行あまりの大半は、言い方を変えて何度も書いていた重複をまとめた分と、「TypeScript で書く」のような、言われなくても AI がやっていそうな行を、消して様子を見ている分だ。その指示書で出した PR で、AI は予約の時刻の表示に datetime.ts の関数を使っていた。短くしたからそうなったのかは、分からない。今回はそうなった、というだけだ。社長は、何も書かずに承認した。
放鷹カード
- 合図: 指示書の刈り込み。消すのは、違反例を流して、hook で止まるところを見た行だけ。行末の名前では消さない
- 検め方: 規則ごとに違反例を書いて、matcher どおりに選んだ hook に流す。全部止まれば候補。一件でも通れば残す。違反例が書けない行も残す
- 止めないもの: 違反例を全部流して、通るものが一件も無かった規則を、候補に出すこと
- 鷹のせいじゃなかった: 指摘のたびに一行ずつ足して、今朝の一行が百八十三行目に埋もれていた。hook の名前を書いて、確かめたつもりになっていた
- 自分の言葉で: 足すのは一行で済む。消すのは、一行ずつ止まるところを見てから。残すのは、hook で止められない行
