第1幕: 現場 ── 42ファイルと、税込の端数
売店の番号表示を直してくれと AI に頼んだら、決済の計算まで書き換えた PR が返ってきた。開幕まで、あと5日だった。
「だから、全部読むまではマージしません」
槙村さんは、ノートパソコンの画面から目を離さずに言った。三塁側スタンドの前から五列目。俺たちは座席を三つ使って、真ん中にパソコンを置いていた。グラウンドでは、整備の車がゆっくり内野を回っている。土をならす音が、がらがらの客席に跳ね返ってくる。
「全部って、42ファイルですよ。開幕、土曜ですよ」
「知ってます」
「テストは全部通ってるんです。赤いの、一個も無い」
「それも知ってます」
槙村さんは画面を指で叩いた。とん、と乾いた音がした。指の先に、PR の変更ファイルの一覧がある。その中の一行。
src/payment/tax-rounding.ts
俺は、その名前を知らなかった。
うちは地方の独立リーグの球団で、俺は球団フロントでただ一人のエンジニアだ。といっても、エンジニアの仕事だけをしているわけじゃない。チケットの問い合わせの電話も取るし、売店の紙コップの発注もする。売店のモバイルオーダーは、お客さんが座席からスマホで注文して、売店の受け取り口の画面に番号が出たら取りに行く、という仕組みだ。2年前に外注の業者が作って、納品して、それきりになっている。業者との契約はもう切れていて、中身を知っている人は社内にいない。
先週、売店のおばちゃんに言われた。番号が3桁になると、画面の数字がずれて読めない。99番までは綺麗に並ぶのに、100番から右にはみ出す。去年の開幕戦は、それで受け取り口に人だかりができたらしい。
だから俺は、コーディングエージェントに頼んだ。コーディングエージェントは、やってほしいことを文章で渡すと、自分でファイルを開いて読み、書き換え、テストまで走らせる AI だ。俺は Claude Code を使っている。頼んだ文は、短かった。
受け取り番号の表示がおかしい。いい感じに直して
1時間後に PR が上がってきた。テストは全部緑。俺はそれを、そのままマージするつもりだった。経理の槙村さんにレビューを頼んだのは、決まりだからだ。決済が絡みそうな変更は、槙村さんに見せる。うちの数少ない決まりの一つ。
番号表示に決済は絡まない。だから形だけのつもりだった。
俺は画面を下へ送った。42ファイル。売店のメニュー画面、カート画面、注文履歴の画面。どれも、字下げや並び順がまとめて直されている。使われていない関数がいくつか消されている。fmt.ts というファイルが format.ts に名前を変えられている。そして、決済の端数処理。
「これ、何が変わってるんです」
俺が聞くと、槙村さんは差分の一か所を指した。
「税込の金額の、1円未満の端数。今までは切り捨てだったのが、四捨五入になってる」
「1円未満でしょう」
「1杯ごとならね。でも、試合が終わったら、モバイルオーダーの売上の合計と、売店のレジの現金を突き合わせるの。毎試合、私が。そのとき、切り捨てで計算してるレジと、四捨五入で計算してるアプリが、1円ずつずれていく。3000杯売れたら、どうなると思う」
答えられなかった。俺はパソコンの蓋を半分だけ閉じて、頭をかいた。
槙村さんは前の会社で、決済まわりのテストをやっていた人だ。外注の業者と決済の仕様を詰めたのも、この人だった。端数の丸め方を決めたのも、たぶんこの人だ。
そのときだった。内野の階段を、下から一段ずつ上ってくる人影が見えた。
俺より若い女の人だった。風で毛先があちこちに跳ねた短い髪。ウインドブレーカーの袖をまくった左の前腕に、茶色い革の手袋。その拳の上に、鷹がいた。こげ茶色の羽で、尾の先にだけ白い帯が一本入っている。
鷹は、飛ばなかった。
女の人は、数段上っては立ち止まり、手袋の腕の角度を少し変えた。鷹はそのたびに首を回して、売店の屋根を見て、照明塔を見て、外野のフェンスを見た。また数段上る。止まる。腕の角度を変える。
朝の打ち合わせで、運営部の人が言っていたのを思い出した。開幕前に、ハト対策の業者を頼んだ。客席の手すりと売店の屋根が、糞でひどいことになっているから。鷹を使う業者だそうです、と。
それにしても、飛ばしもせずに歩いているだけに見えた。ハトなら、売店の屋根の上に何十羽もいる。こっちは開幕まで5日で、42ファイルで、決済の端数で、それどころじゃないのに、あの人はのんびり階段を上っている。少しだけ、腹が立った。
女の人は、俺たちの数列上の通路で立ち止まった。鷹が首を回して、こっちを見た。
「声、外野まで届いてた」
責める口調ではなかった。風向きを言うみたいな言い方だった。
「すみません」
槙村さんが頭を下げた。俺は下げなかった。
頼んだのは番号の表示だ。決済に指一本触れてくれ、とは言っていない。言っていないのに、触られた。なぜそこまで手が伸びたのか、その理由には、まだ手が届いていなかった。
第2幕: 再現 ── テストは、範囲を語らない
女の人は、通路から一段下りて、俺たちの三列上の座席に腰を下ろした。鷹は手袋の上にとまったまま、グラウンドの整備の車を目で追っている。
「鳥の業者の人ですよね」
「鷹宮」
それが名字だと気づくまで、一拍かかった。鷹宮さんは、それ以上は名乗らなかった。
聞かれてもいないのに、俺は話していた。たぶん、槙村さん以外の誰かに、こっちの言い分を聞いてほしかったんだと思う。番号表示がずれること。AI に頼んだこと。42ファイルの PR が来たこと。テストは全部通っていること。
「なんて頼んだの」
「受け取り番号の表示がおかしい、いい感じに直して、って」
鷹宮さんは、少しだけ首を傾けた。
「笛の吹き方が、ぼやけてたんじゃない」
「でしょう」
槙村さんがすぐに言った。腕を組んでいる。
「だから最初から、何をどう直すか、はっきり書いてって言ったの。いい感じに、なんて書くから、いい感じに全部やられるのよ」
二対一だった。俺はむっとして、言い返した。
「何を直したいかは、はっきり言いましたよ。番号の表示です。3桁でずれる。それ以上はっきりしようがない」
「じゃあ、どこを直せって書けばよかったの」
「それが分かんないんですって」
自分の声が大きくなったのが分かった。
「どのファイルを触ればいいか、俺も知らないんです。外注が作ったコードで、俺、中をちゃんと読んだことないんですよ。番号を出してるのがどのファイルかも知らない。知らないから、AI に頼んだんです。知ってたら自分で直してます」
槙村さんが黙った。
鷹宮さんも、すぐには何も言わなかった。グラウンドを一周してきた整備の車が、三塁ベースの横を通りすぎるくらいの間があった。
「……違った。今の、なし」
それだけだった。言い訳もしなかったし、言い直しもしなかった。年下の業者の人が、あっさり自分の見立てを引っ込めたのが意外で、俺は拍子抜けした。言い返す先が、急に無くなった。
俺はパソコンの蓋を開け直した。腹立ちの行き場が無くなったので、代わりに、昨日までの俺たちが何をしていたのかを書き出してみることにした。俺たちの目は、どこに向いていて、どこに向いていなかったのか。
AI が出してきた変更を受け取るときに、俺たちが見ていたものは二つある。テストが通ったかどうか。それと、変わったファイルの一覧。一覧のほうは、git diff --name-status で出せる。
git diff --name-status は、変更されたファイルの名前と、変更の種類を、一行に一つずつ出すコマンドだ。行の頭の一文字が種類で、M は中身の変更、A は追加、D は削除。種類のあとにタブ(\t)を一つ挟んで、ファイルのパスが続く。PR の画面に出ていた42行の一覧は、これと同じものだった。
まず、受け取るものの形を types.ts に決めた。
| |
ChangeSet は、AI が出した変更のひとまとまり。テストが通ったかどうかと、ファイルの一覧の文字列を持つ。--no-renames については、あとで書く。Verdict は結論で、通すか、理由を添えて止めるかの二通り。| は「どちらかの形」という意味の TypeScript の書き方で、止めるときには必ず理由がつく。
それから、昨日までの俺たちを before.ts に。
| |
先頭の import type で読み込んでいるのは型の定義だけ。読み込み先に .ts の拡張子まで付けているのは、変換の手間を挟まずに Node へ直接渡すからだ。ふつう TypeScript は JavaScript に変換してから動かすけれど、Node 24 は型の書き込みを取り除くだけで .ts をそのまま走らせられる(型が正しいかどうかの検査まではしない。enum のように、取り除くだけでは動かない書き方は使えない)。
書いてみると、本当にこれだけだった。テストが通っていれば受け入れる。一覧は受け取っているのに、見ていない。42ファイルの PR を入れたら、テストは緑だから、受け入れる。決済の端数が変わっていても、受け入れる。
テストを書いて走らせた。頼んでいない決済のファイルを含む変更を止めるかどうか、を確かめるテストだ。昨日までの俺たちの関数は、止めなかった。テストは赤くなった。
「ひどいでしょう」
自分で書いておいて、自分で言った。槙村さんは首を振った。
「ひどくはないよ。私だって、昨日まではこれでいいと思ってたもの」
それは、たぶん本当だった。外注の業者は、テストをちゃんと残していってくれた。売店の画面にも、注文にも、決済にも、テストがある。何か壊れたら、どれかが落ちるはずだった。42ファイルを毎回全部読む人手は、うちには無い。それに AI は、たいていは頼んだところを直す。今までの小さな修正では、一度も余計なことはしなかった。
「でも、端数が変わったのに、なんでテストは通ったの」
槙村さんが聞いた。決済の人の顔になっていた。
俺は tax-rounding.ts のテストを開いた。税込の金額が計算できるか、マイナスにならないか、は確かめていた。でも、1円未満の端数をどちらに丸めるかを確かめるテストは、無かった。
「丸め方のテストが、無いです」
槙村さんは小さく息を吐いた。外注の業者と決済の仕様を詰めたのは、この人だ。視線が一瞬だけ画面から外れて、膝の上のペンに落ちた。口には、何も出さなかった。
そのとき、俺はようやく気づいた。
テストが通ったということは、壊れていないということだ。少なくとも、テストが見ている範囲では。でもそれは、触ったファイルが、頼んだ範囲に収まっていたかどうかとは関係が無い。テストは、範囲については何も言わない。番号表示を直してくれと頼んで、決済まで書き換えられても、決済のテストに穴があれば、全部が緑のまま通ってくる。
上を見ると、鷹宮さんが立ち上がっていた。また鷹を手袋に据えたまま、通路を数段上っていく。売店の屋根の上では、ハトの群れが日なたに並んでいる。
「あの」
俺は声をかけた。
「なんで飛ばさないんですか。ハト、あそこにいるのに」
鷹宮さんは立ち止まって、振り返った。
「ここ、この子には初めての場所」
「はあ」
「知らないとこで放すと、見えたものを追う」
それで終わりかと思った。でも俺は、結論が欲しかった。
「それで、歩いて何になるんです。歩いてたって、ハトは減らないでしょう」
鷹宮さんは、手袋の腕を少し持ち上げただけだった。
「まだ、放さない」
それだけ言って、また階段を上っていった。
鷹は拳の上で、首を回し続けていた。屋根を見て、照明塔を見て、レフトのポールを見て、また屋根を見る。何をしているのか、俺にはまだ分からなかった。分かったのは、あの人が急いでいないことだけだった。
第3幕: 手順を整える ── 放す前に、範囲を決める
AI も、このコードを知らなかったんだ。
俺はパソコンの画面を見たまま、そう思った。
俺が知らなかったように、AI も、外注の業者が書いたこのコードを知らなかった。知らないまま、番号表示を直せと言われて、書き始めた。書きながらファイルを開いて、開いた先で、字下げが揃っていないのを見つけて揃えた。使われていない関数を見つけて消した。金額の表示の書き方が番号表示と違うのを見つけて、揃えようとして、端数の処理まで手を入れた。一つ一つは、たぶん親切のつもりだった。
触る範囲を決めていたのは、誰だったのか。俺じゃない。俺は、どこを触ればいいかを知らないまま頼んだ。範囲を決めていたのは、書いている最中の AI だった。書きながら、「ついでに」が一つずつ増えていった。
じゃあ、範囲を書く前に決めればいい。でも、俺はどこを触るべきかを知らない。知らない人が、書く前に範囲を決められるのか。
調べると、この手順にはちゃんと名前があった。探索 → 計画 → 実装。いきなり AI に書かせずに、まずコードを読ませる(探索)。次に、どのファイルを、なぜ、どう変えるかを、計画として書かせる(計画)。人がその計画を読んで承認してから、初めて書かせる(実装)。Claude Code には、このための plan mode がある。plan mode は、AI にコードを読むことと計画を書くことだけをさせて、人が承認するまでソースコードを編集させないモードだ。
Claude Code の公式ドキュメントの Best practices には、この手順がそのまま節の名前になっている。
Explore first, then plan, then code
その下に、理由が一文で書いてあった。
Letting Claude jump straight to coding can produce code that solves the wrong problem.
(Claude Code docs「Best practices」、2026年10月14日参照)
いきなり書かせると、間違った問題を解くコードになりうる。昨日の PR は、まさにそれだった。番号表示を直すはずが、決済の端数まで「直して」いた。
ただ、同じページには、逆のことも書いてあった。
If you could describe the diff in one sentence, skip the plan.
差分を一文で言えるなら、計画は要らない。打ち間違いを直すとか、ログを1行足すとかいった、範囲がはっきりした小さな修正なら、計画を立てる手間のほうが無駄になる、と。
番号表示の修正は、一文で言える。3桁でずれるのを直す。だから俺は、計画なんて大げさだと思っていた。でも、一文で言えたのは、何を直したいか、のほうだった。どこを触るか、は一文どころか、一語も言えなかった。
公式のページは、計画がいちばん役に立つ場面も挙げていた。その一つが、これだった。
when you’re unfamiliar with the code being modified
変更するコードに不慣れなとき。外注のコードを一度もちゃんと読んだことのない俺は、まさにそこにいた。
鷹宮さんが鷹を飛ばさないのも、同じことなんだと思う。初めての球場で、どこに屋根があって、どこに照明塔があって、どこまでが仕事の場所かを知らないまま放したら、鷹は見えたものを追う。だから先に、拳に乗せたまま場所を見せて回る。放すのは、そのあとだ。
俺は、その場で作業用のブランチを切り直した。昨日の PR はいったん脇に置いて、同じ依頼を、今度は plan mode でやり直す。
頼む文に、一つ決まりを足した。計画の最後に、変更するファイルの節を作らせる。
| |
頭の /plan は、この一回の依頼を plan mode で始める、という指定だ。この節の形は、俺が決めた取り決めだ。plan mode の計画は Markdown の文章で返ってくるけれど、どういう章立てにするかの決まりは無い。ただ、公式のページにある計画の例でも、AI に What files need to change? と、どのファイルを変えるかを聞いている。その問いの答えを、あとで機械が読める形に固めておくことにした。
AI は、しばらくファイルを読んでいた。画面に、開いたファイルの名前が次々に流れる。売店の画面、注文、受け取り口の表示、決済。何も書き換えずに、読むだけ。やがて計画が返ってきた。
番号を表示している部分の説明。3桁で桁がずれる原因。番号を桁揃えする小さな関数を新しく作ること。テストの追加。そして最後の節。
| |
4行目を見て、また出た、と思った。
正直に言うと、計画を見ても、俺に何かが判断できるとは思っていなかった。どのファイルが要るのか分からないから頼んだのに、計画を読んで判断しろと言われても、と。
でも、計画の本文に、4行目の理由が書いてあった。金額の表示の書き方を、番号表示と揃えるため。
手が止まった。
番号の表示に、税込の端数は要らない。それは、コードを知らない俺にも分かる。番号は番号で、お金じゃない。
「これ」
槙村さんが、身を乗り出して4行目を指した。
「外して。決済は、今回は触らない」
コードを知らなくても、計画なら読める。どのファイルを、なぜ触るのかが並んでいれば、その理由がおかしいことには気づける。決済の行は、決済の持ち主が見れば一目で分かる。昨日の PR では、42行の一覧の中に埋もれていた一行が、計画では、4行のうちの一行として、理由つきで目の前に出てきた。
俺は Ctrl+G を押して計画をエディタで開き、本文の決済の段落と、一覧の4行目を消した。一覧の行だけ消して、本文に端数の書き換えの方針が残っていたら、AI はそっちに引っ張られるかもしれない。それから承認した。槙村さんは、膝に載せていた売上集計表の束から一枚抜いて、その裏に、残った3行を書き写した。
鷹宮さんが、階段を下りてきた。一周を終えたらしい。
売店の屋根の下に立って、鷹宮さんは、空いているほうの手で屋根の上を指した。屋根の端から端まで、指でなぞるように。それから、手袋の腕を前に突き出した。
鷹が飛んだ。
まっすぐだった。売店の屋根の上のハトの群れに向かって、低く、一直線に。群れが一斉に飛び立って、外野の向こうへ散っていく。鷹は群れを追わずに、屋根の上で大きく弧を描いた。それで、屋根の上には一羽もいなくなった。
鷹が戻ってくるまでには、少し間があった。その間、俺は画面に目を落として、承認しました、という表示を見ていた。呼び笛が一度、短く鳴った。顔を上げたときには、鷹はもう手袋の上にいた。屋根のハトは、もういない。
「これで、承認したから」
俺は顔を上げて、鷹宮さんに言った。
「AI は、もう計画の外には出ないってことですよね。決済の行は消したし、承認したのは3ファイルだけだし」
鷹宮さんは、すぐには答えなかった。手袋の上の鷹から目を離して、レフトのほうを見た。ファウルポールのてっぺんを。
「放したあとは、どこにとまったか、見てる?」
意味が分からなかった。ポールに何があるのか。俺は鷹宮さんの視線を追ってポールを見たけれど、そこにはもう何もいない。
「さっき、ポールにとまったの」
槙村さんが言った。鷹宮さんではなく、槙村さんが。
「戻ってくる途中で。屋根の仕事が終わったあと、レフトのポールに一回とまって、笛で呼ばれて戻ってきた。私、見てた」
「とまったって、それが何なんです」
槙村さんは、俺の画面の、承認しました、の表示を指した。
「承認で縛れるのは、承認する前の編集だけでしょう。承認したら、AI は書き始める。書いている最中に計画にないファイルを触っても、承認はそこまでは見てくれない」
それから、レフトのポールのほうへ顎を向けた。
「さっきのも同じ。屋根を指で示してから放しても、帰り道にポールにとまるときは、とまる。放す前に決めたとおりに、放したあとも飛ぶとは限らない」
俺は黙った。言われてみれば、そうだった。調べた plan mode の説明にも、承認したら編集できるモードに切り替わる、としか書いていなかった。承認は、放す前の出来事だった。
「だから」
槙村さんは続けた。
「最後に出てきた差分を、承認した計画と突き合わせればいいのよ。計画にないファイルが一個でも混ざってたら、止める。それなら、決済の行を消した意味が、最後まで残る」
それは、全部読むまでマージしない、と言っていた人の言い分だった。でも、全部読む、とはもう言っていなかった。
それで、俺にも分かった。
「それなら、もしかして、読むのは計画に載ったファイルだけで済みます? 42ファイル全部じゃなくて」
計画にないファイルを機械が止めてくれるなら、人が読むのは計画に載った分の中身だけになるはずだ。槙村さんは、すぐには返事をしなかった。腕を組んだまま、書き写した3行を見ていた。
鷹宮さんは何も言わなかった。手袋の上の鷹の、脚の紐を指で確かめていた。
俺は、突き合わせる関数を書いた。先に、計画と git の一覧を入れておく型を types.ts に足した。
| |
'modify' | 'add' | 'delete' は、この三つの文字列のどれかしか入らない、という意味だ。
それから、計画の Markdown から、変更するファイルの節だけを読み取る。
| |
文字列の中から、決まった形の部分だけを取り出すことを、パースと呼ぶ。parsePlan は、計画を1行ずつ見ていって、## 変更するファイル の見出しの下にいる間だけ、- 変更: パス の形の行を拾う。ほかの節に同じ形の行があっても、読まない。Record<string, ...> は「文字列から値を引く表」の型で、変更 を modify に、git の M を同じ modify に、というふうに、計画の言葉と git の文字を、同じ値に揃えている。PlannedFile['kind'] は、PlannedFile の kind に入りうる値、つまり 'modify' | 'add' | 'delete' のことだ。
正規表現の丸括弧で囲んだ部分は、一致したときに取り出せる。1つ目の括弧(変更・新規・削除のどれか)が m[1] に、2つ目の括弧(空白を含まないパス)が m[2] に入る。空白を含むパスは計画の行として読まれないので、そのファイルを触った差分は、計画にない変更として止まる側に倒れる。inSection && ... は、節の外にいる間は false になって、何も拾わない。
次に、git の一覧を読む関数と、突き合わせる関数。
| |
parseNameStatus の const [status, ...rest] = line.split('\t') は、タブで切った先頭を status に、残りを rest に受け取る書き方だ。残りをタブでつなぎ直しているのは、万一パスの中にタブが入っていても途中で切れないようにするためだ。
buildScopeGate は、承認した計画を受け取って、判定の関数を返す。関数を返す関数なので、buildScopeGate(plan)(change) のように、括弧を二つ続けて呼ぶ。昨日までの acceptChange の形は変えていない。新しく必要になった計画は、buildScopeGate のほうが受け取る。返ってくる関数は acceptChange と同じく ChangeSet を受けて Verdict を返すので、呼ぶ側は acceptChange をこれに差し替えるだけで済む。
判定は、上から順に一段ずつ足していった。足すたびに、槙村さんが、その段は何を止めるのかと聞いてきた。決済の人は、取りこぼしの向きを気にする。
一段目は、テストが通っていなければ止める。これは昨日までの関数と同じだ。
二段目は、差分の一行ずつについて、計画に同じパスで同じ種類の行があるかを見る。some は「一つでも条件に合うものがあるか」を調べる配列の関数で、filter は条件に合うものだけを残す。だから outside には、計画に一つも合う行が無かった変更だけが残る。
「パスだけじゃなくて、種類まで見るのはなぜ」
槙村さんが聞いた。
「計画で変更って書いたファイルを、AI が消したら、パスだけ見てたら通っちゃうんです。同じ名前のファイルだから」
「消すのと直すのは、全然違うものね」
三段目は、計画にない変更が一つでもあれば止める。そのとき、計画の外だったファイルを全部並べて理由にする。どこがはみ出したのかが分かれば、どこを見ればいいかも分かる。
M・A・D 以外の文字が来たら、計画のどの種類にも合わないので、止まる。たとえば T は、ふつうのファイルがシンボリックリンクに変わったような、ファイルの種類の変化を表す。計画に書く言葉が無いものは、止める側に倒しておく。
それから、--no-renames のこと。
git は、ファイルの名前が変わったことを見つけると、ふつうは R100 src/util/fmt.ts src/util/format.ts のように、一行に旧い名前と新しい名前を並べて出す。これだと、旧い名前と新しい名前のどちらを計画と比べればいいのか、という分岐が要る。--no-renames を付けると、git は名前の変更を探さなくなる。その結果、名前の変更は「旧い名前の削除」と「新しい名前の追加」の2行で出てくる。
| |
(git 2.56.0 で確かめた出力)
こうしておけば、名前を変えるときも、計画に削除と新規の2行が両方書いてあるかを見るだけで済む。分岐が要らない。計画を書かせる文に、名前を変えるファイルは2行で書くこと、と入れておいたのは、このためだ。それに、名前の変更を探すかどうかは、使う人の git の設定でも変わる。--no-renames を明示しておけば、誰のパソコンで走らせても同じ一覧が出る。
一つだけ、割り切ったところがある。一覧は改行で区切って読んでいる。git は、改行のような特殊な文字を含むファイル名を、引用符で囲んでエスケープした形で出す。設定によっては、日本語のファイル名もそうなる。その行は計画の行と一致しないから、止まる。見逃す側ではなく、間違って止める側に倒れる。止まったら、人が見ればいい。
次は、これを本物の計画ファイルと git につなぐところだ。ここは道具の設定の話なので、日付を書いて分けておく。
2026年10月14日時点の Claude Code の設定(公式ドキュメントの permission-modes・settings-reference・best-practices で確認)。plan mode には、
claude --permission-mode planで起動するか、プロンプトの頭に/planを付けて入る。承認の前にCtrl+Gを押すと、計画をエディタで開いて直接直せる。計画はファイルとして保存され、既定の保存先は~/.claude/plans。リポジトリの中に置くには、.claude/settings.jsonにplansDirectoryを書く。承認すると plan mode を抜けて、編集できるモードに切り替わる。確かめたページの範囲では、承認したあとの実装が計画どおりかを組み込みで照合する機能の記述は見つからなかった。公式が示していたのは、別の AI に差分と計画を読ませるレビュー用のプロンプトの例(nothing outside the task’s scope changed、という一文を含むもの)までだ。
| |
これで、承認した計画がリポジトリの plans/ に置かれる。ただし plans/ は .gitignore に入れて、コミットしないようにしておく。計画ファイル自体がコミットされると、差分に A plans/… の行が出て、計画にない追加として止まってしまうからだ。判定には、plans/ の中から、いま承認した計画のファイルを選んで渡す。あとは、計画ファイルと git の一覧を読んで、判定にかけるだけだ。マージの前に走らせる小さなスクリプト check-scope.ts の中身にあたる関数を、wiring.ts に置いた。
| |
spawnSync を使うと、Node の中から別のコマンドを起動して、それが終わるのを待ってから結果を受け取れる。process.execPath は、いま動いている node 本体の場所で、同じ node でテストを走らせるために使っている。
関数の頭で process.env(環境変数の一覧)から NODE_TEST_CONTEXT だけを抜いているのは、この関数をテストの中から呼んだときのためだ。外側で動いている node --test は、自分用の印をこの環境変数に入れる。それがそのまま内側の node --test に渡ると、内側の出力の形が変わってしまう。{ NODE_TEST_CONTEXT: _ctx, ...env } は、その一項目を _ctx という使わない名前で受け取って、残り全部を env に集める書き方だ。テストは node --test に任せて、戻ってきた終了コードを見る。終了コードはコマンドが最後に返す番号で、0 なら何も失敗しなかったという意味になる。差分は git から取る。判定の中身には手を入れていない。入力を、手で書いた文字列から、本物の計画ファイルと本物の git の出力に差し替えただけだ。
main...HEAD と点を三つ書くのには理由がある。点が三つだと、作業ブランチが main から分かれたところから、作業ブランチで起きた変更だけが出る。点が二つの main..HEAD だと、分かれたあとで main のほうに入ったファイルまで、作業ブランチでは「削除された」ように見えてしまう。たとえば俺が作業している間に、main に src/news/banner.ts が足されたとする。点が二つだと、作業ブランチの差分に D src/news/banner.ts が出て、何もしていないのに計画外の削除として止まる。
もう一つ。main...HEAD は、コミットとコミットを比べている。作業ツリーに残っている、まだコミットしていない変更は、新しいファイルも書き換えも削除も、ここには一切出てこない。AI の作業を見逃さないように、これは AI の作業をコミットしたあと、マージの前に走らせる。走らせるのは、今は俺の手だ。毎回自動で走らせる仕組みは、また別の話になる。それに、止めると言っても、マージのボタンが押せなくなるわけじゃない。判定の結果と理由が画面に出るだけで、それを見てマージしないのは、俺たちの判断だ。
俺は、変わったものを並べてみた。
「昨日までは、どこを触るかを、書いてる最中の AI が決めてたんです」
槙村さんに言った。鷹宮さんは、いつの間にか数列上の通路に移っていて、鷹と一緒にバックネットのほうを見ていた。
「書く前に計画として出させて、俺たちが決める。で、最後に出てきた差分で、その決めたとおりだったかを確かめる。変わったのは、そこです」
槙村さんは返事の代わりに、計画を書き写した紙の余白に、ペンで何か書いた。書く前に決める、差分で確かめる。そう読めた。
余白の二つの言葉を、俺は自分の手順に並べ直してみた。誰がどこを決めていて、どこから先が AI 任せなのかを、線の種類で分けた。

承認の矢印は、AI が書き換え始めるところで切れている。その先で俺たちの手に残っているのは、承認した計画ファイルと git の一覧、それに二つを突き合わせる関数だけだ。
第4幕: 検める ── 読むのは、計画に載ったファイルだけ
やり直した AI の作業は、20分ほどで上がってきた。
差分は、計画に載せた3ファイルだけだった。番号を表示するファイルの変更、桁を揃える関数の新しいファイル、テストの変更。決済のファイルも、売店の画面の字下げも、名前の変わったユーティリティも無い。checkScope は、受け入れた。
ただし、これは今回こうなった、というだけの一例だ。次に頼んだときも AI が計画の中に収まるとは限らない。収まらなかったときに止まるかどうかは、テストで確かめる。
今度は、さっき書いた突き合わせの関数のほうを試すテストに取りかかった。テストで使う計画と git の一覧は、手で書いた固定の文字列だ。こういう、テストのために固定して用意する入力のことを fixture と呼ぶ。
| |
計画の fixture には、わざと ## 備考 という別の節を作って、そこに決済の行を置いた。変更するファイルの節の外にある行は読まない、ということも、ついでに確かめるためだ。42ファイルの PR の一覧は、全部ではなく、決済の端数処理、書式だけ直された画面、消された関数のファイル、名前の変わったユーティリティ、という主なものを抜き出して作った。
| |
ok(...) はテスト通過済みの変更をこしらえるだけの手伝い役。assertRejected は、結論が「止める」で、その理由に狙った文字列が入っているかを見る手伝い役。assert.deepEqual は、二つの値を中身まで比べて、食い違えばその場でテストを赤くする(node:assert の関数)。
一本目は、計画からはみ出さずに AI が仕事を終えた場合。テストは緑で、差分には計画にあるファイルと種類しか出てこない。この入力には、昨日の判断も今日の判断も、同じく「通す」と答える。正しい仕事は止めない。違いが出るのは、計画の外にはみ出したときだけだ。
二本目が、昨日の PR だ。昨日の PR は、この計画の後に出てきたものじゃない。計画なしで書かれたものだから、これは代役になる。承認したあとで AI がまた同じことをしたら、ここで止まるか。それを確かめたかった。昨日までの俺たちは受け入れ、今日の関数は止める。理由に、決済のファイルの名前が入る。
ほかにも、テストが落ちたら両方とも止めること。計画で変更と書いたファイルを消したら止めること。T のような計画に書けない種類の変更は止めること。名前の変更は、計画に削除と新規の両方があれば通り、片方しか無ければ止まること。計画に変更するファイルの節が無ければ、どんな差分も止まること。引用符付きで出る特殊なパスは、止まる側に倒れること。それぞれをテストにした。
それから一本だけ、手で書いた文字列ではなく、本物の git を呼ぶテストを置いた。一時的なフォルダに小さなリポジトリを作って、ファイルの名前を変えてコミットし、--no-renames を付けた git diff が、本当に削除と追加の2行を出すかを確かめる。判定の関数は、この出力の形を前提にしているからだ。
| |
mkdtempSync(join(tmpdir(), 'scope-gate-')) で、OS の一時置き場に、テストが終われば用のない作業フォルダを作っている。HEAD~1 は、一つ前のコミットのこと。.repeat(5) でファイルに同じ行を5行入れているのは、中身の同じファイルの名前を変えた、と git が見分けられるだけの中身を持たせるためだ。-c diff.renames=true で、わざと名前の変更を探す設定にしておいて、それでも --no-renames が勝つことを確かめている。
テスト一式を流した。
| |
12本、赤は一つも出なかった。
それでも安心しきれなくて、判定のほうに一か所ずつ穴を開けてみた。種類を見ずにパスだけを比べるようにすると、計画で変更と書いたファイルを消すテストが落ちる。計画外の検出を常に通すようにすると、昨日の PR のテストが落ちる。一段目を外すと、テストが失敗した変更を止めるはずのテストが赤くなる。穴を開けるたびに、どれかのテストが赤くなった。緑だったのは、中身のないテストだからではなかった。
槙村さんは、計画に載った3ファイルを、ペンを片手に読んでいた。番号を表示するファイルを読み、桁を揃える関数を読み、テストを読む。ときどきペンの先で画面の一行を追って、止まって、また進む。42ファイルのときは、一覧を下へ送るだけで手が止まっていた。今は、読んでいる手が、ちゃんと最後の行まで動いていた。
「マージしていいよ」
槙村さんが言った。3ファイルなら、昼前に読み終わった。42ファイルだったら、開幕までに終わったかどうか分からない。槙村さんが欲しかった確かさと、俺が欲しかった速さが、ここで初めて同じほうを向いた。
槙村さんはペンを置いて、俺のほうを見た。
「ねえ。計画に載ったファイルの中で、関係ないところを書き換えられたら、どうなるの。number-board.ts の中で、番号と関係ない関数を書き換えられたら」
「通ります」
俺は正直に答えた。
この関数が見ているのは、ファイルの名前と種類だけだ。中身は見ていない。計画に載ったファイルの中で何が起きても、ファイル単位では計画どおりだから、通る。だから中身は、今日の槙村さんみたいに、人が読むしかない。それでも、読む範囲は42ファイルから3ファイルに縮んだ。
ほかにも、この関数が保証しないものがある。俺は、一つずつ挙げていった。
計画そのものが間違っていたら、止まらない。今日、槙村さんが決済の行に気づかずに承認していたら、決済の変更は計画どおりとして通っていた。計画を読むのは、人の仕事のままだ。
逆向きのはみ出しも見ていない。計画に載せたファイルを AI が触り忘れていても、この関数は止めない。余計な変更を止める門番であって、やり残しを数える仕組みではないからだ。やり残しは、テストと、計画に載った分を読む人の目で見つける。
変更が正しいかどうかも、見ていない。それはテストと、人の目の仕事だ。
plan mode が、計画を書いている間の編集を必ず止めるとも限らない。公式のドキュメントにも、環境によっては、計画だけにして、という指示で動いているだけで、編集そのものはブロックされない場合があると書いてあった。だからこの関数は、途中で何が起きたかではなく、最後に出てきた差分で見る。
それから、このテストが確かめているのは、俺が書いた fixture に対して、判定が正しく動くことまでだ。Claude Code の計画の保存先や、git の出力の形が変われば、配線は黙って効かなくなるかもしれない。そのときテストは緑のままでも、本物の計画は読めていないことがありうる。だからテストには、どのバージョンの git で確かめた形かを書いておいた。特殊な文字のファイル名で間違って止まることがあるのは、さっき書いたとおりだ。
挙げたものを、判定の段の図の上に置き直した。止まる道と通る道、それから止める段のどこにも当たらずに通ってしまう道を、一枚に描いた。

橙の破線の二本は、どちらも止める札を通らずに受け入れへ届く。その二本を受け止めるのは関数ではなく、計画を読む槙村さんと、計画に載った3ファイルを読む人の目だ。
「次にまた、計画の外に出てきたら」
俺は、パソコンの画面を見たまま聞いた。
「止まったあと、どうすればいいんですかね」
「中身を見て、決めるのよ」
槙村さんは、きっぱり言った。
「要る変更なら、計画に書き足して、もう一回承認する。要らない変更なら、外させる。どっちにするかを決めるのは、機械じゃなくて私たち。この関数は、外に出たことを知らせてくれるだけ」
俺はうなずいた。AI が計画の外に出なくなるわけじゃない。出たときに、こっちで気づけるようになった。そこまでだ。
鷹宮さんが、鷹を手袋に戻して、通路を歩いてきた。今度は一塁側に向かうらしい。反対側のスタンドも、同じように拳に乗せて見せて回るんだろう。
俺たちの座席の横を通りすぎるとき、鷹宮さんは立ち止まって、照明塔を見上げた。
「夜の試合、照明は何時に点くの」
「え。六時です。試合は六時半から」
鷹宮さんは、一度うなずいただけだった。なぜ聞いたのかは、言わなかった。
通りすぎるついでに、鷹宮さんはウインドブレーカーの胸ポケットから細長いカードを抜いて、槙村さんに差し出した。俺にではなく、槙村さんに。槙村さんはそれを受け取って、黙って読んだ。それから、何も言わずに俺に回してきた。
カードには、鷹宮スズ、という名前と、その下に四つの項目。五つ目の項目には、見出しだけがあって、中身が書かれていなかった。
俺は、槙村さんが計画の3行を書き写したあの紙を借りた。3行と、余白の書き込みの下に、カードの四つの項目を書き写して、見出しだけの五つ目に、自分の言葉を埋めた。
放鷹カード
- 合図: 探索 → 計画 → 実装。いきなり書かせない。読ませて、変更するファイルの節つきの計画を書かせて、承認してから書かせる
- 検め方: 最後の差分を
git diff --name-status --no-renames main...HEADで出して、承認した計画と突き合わせる。計画にないファイルと、計画と違う種類の変更は止める - 止めないもの: テストが通っていて、差分が承認した計画に載ったファイルと種類だけで出来ている変更
- 鷹のせいじゃなかった: どこを触るかを、書いている最中の AI に決めさせていた
- 自分の言葉で: 承認するのは放す前。差分を見るのは放したあと。読むのは、計画に載った分だけでいい
