Featured image of post キャンセルされた注文が工場への指示に残り、要らない部品を300個作った。AIに先に質問させて仕様書を書く【仕様インタビュー】

キャンセルされた注文が工場への指示に残り、要らない部品を300個作った。AIに先に質問させて仕様書を書く【仕様インタビュー】

キャンセルされた注文が工場への作業指示に残り、要らない部品を300個作った。AI に先に質問させて仕様書を書かせ、要件ごとに受け入れテストがあるかを確かめる関数(仕様インタビュー)を TypeScript と node --test で書きます。

第1幕: 現場 ── 検品台の300個

キャンセルされた注文が、工場への作業指示に残っていた。夜勤の職人さんはそれを見て、要らない部品を300個削った。その作業指示を出したのは、私が AI に作らせたシステムだった。

朝の七時前に、自転車で工場に着いた。通用口を開けると、切削油の匂いがした。夜勤が終わったばかりの旋盤は、まだ少し温かい音を立てている。

検品台の前に、兄が立っていた。

台の上に、浅い箱が六つ並んでいた。中には、指の先くらいの小さな部品がびっしり詰まっている。ステンレスのスリーブ。外側を削って、内側に穴を通した、筒の形の部品だ。一箱に五十個。六箱で三百個。

兄は、箱の縁を指でなぞっていた。端から端まで、ゆっくり。

「おはよう。それ、何」

「昨日キャンセルになったやつ」

兄は箱から目を上げなかった。

「計測器のとこの。昨日の夕方、電話があった。発注した担当者が数を間違えたんで、いったん取り消して出し直したいって」

「出し直した注文は」

「まだ来てない。数が決まったら、また入れるってさ」

私は検品台の端に鞄を置いた。うちは沢井製作所という、社員十二人の町工場だ。ステンレスの小さな部品を削っている。父が始めた工場で、今は兄が現場と営業を見ていて、私は事務をしている。二年前までは東京の Web 制作会社にいた。帰ってきてからは、事務のほかに、取引先からの注文を Web で受ける仕組みを作っている。

以前は、注文はみんな電話と FAX で兄が受けていた。兄が受けた注文を紙の作業指示書に書いて、現場の壁の板に掛ける。職人さんは、その札を見て削る。取り消しが来たら、兄が板から札を抜く。それで済んでいた。

今は、取引先が Web の画面から自分で注文を入れる。工場で確定した注文は、毎晩二十二時に、システムが作業指示として現場のタブレットに出す。夜勤の職人さんは、タブレットに出た順に削っていく。先月から本番で使い始めて、紙の札は無くなった。

「メモ、机に置いといたんだけど」

兄が言った。

私は自分の机を見に行った。キーボードの上に、付箋が一枚貼ってあった。兄の字で、取引先の名前と、品番と、キャンセル、とだけ書いてある。昨日、私は定時で帰った。付箋を見たのは、今が初めてだった。

でも、たとえ昨日のうちに見ていたとしても、と私は考えた。私はシステムの画面を頭の中で開いてみた。注文の一覧。確定のボタン。作業指示の一覧。どこにも、取り消すボタンは無い。注文の状態は「登録」と「確定」の二つしかない。一度確定した注文を、作業指示から外す方法が、そもそも無かった。

できるとしたら、データベースを直接いじることくらいだ。私が夜にパソコンを開いて、手でそれをやる。それはもう、システムではない。

検品台に戻ると、兄はまだ箱の縁をなぞっていた。

「紙の頃なら、俺が札を抜けば済んだんだよな」

兄は言った。

「メモじゃなくて、電話すりゃよかった」

「電話もらっても、私も消せなかったと思う」

「は?」

「キャンセルっていう状態が、無いの。システムに」

兄は顔を上げて、私を見た。それから、また箱を見た。

夜勤の職人さんは、何も間違えていない。タブレットに出た作業指示どおりに、二台の旋盤で七時間かけて三百個を削った。寸法もきっちり出ている。兄は電話を受けて、メモを残した。誰も手を抜いていない。

私は頭の中で数えた。材料のステンレスの丸棒が、三百個分でおよそ六万円。旋盤二台を七時間。その間に削れたはずの、ほかの注文の分。三百個は、同じ品番で数を直した注文が来れば使えるかもしれない。でも来なければ、ただの在庫だ。

責める先を探して、見つからなかった。

そのとき、開け放した通用口の向こうに、人がいるのに気づいた。

搬入口のシャッターは、まだ閉まっている。その前のコンクリートの上に、若い女の人が立っていた。私より年下に見えた。毛先のあちこち跳ねた髪を、風が揺らしている。左の腕には、肘まで覆う茶色い革の手袋。

手袋の上に、鳥がいた。

鷹だった。こげ茶の羽の中で、肩口だけが赤みのある栗色をしている。尾羽の先を、白い線が横切っていた。鷹は飛ばずに、首をぐいと上げて、シャッターの上の梁のあたりを見ていた。

「ああ、来てたのか」

兄が言った。

「組合の紹介で頼んだ、ハトの業者。鷹で追っ払うんだと」

そういえば先週、兄がそんなことを言っていた。搬入口に置いた製品の箱が、ハトの糞でやられて、取引先から一度返されたことがある。工業団地の組合に相談したら、鷹を使う業者を紹介された、と。

女の人がこちらを振り向いて、兄を手で呼んだ。

「ちょっと行ってくる」

兄は通用口から出ていった。三百個のことは、まだ何も決まっていなかった。

検品台からは、二人のやりとりが半分くらい聞こえた。

女の人は、小さな手帳を開いて持っていた。鷹の乗った左腕を下げずに、右手だけでページを押さえている。質問は短かった。

「シャッター、何時に開けるの」

「七時」

「ハトは、いつ来る」

「開けたらすぐ。隙間から入ってくる」

「どこにとまる」

「あの梁の上。ずっと奥まで」

「ここに、餌になるもの置いてる?」

「置いてない。けど、裏の弁当屋の残りを、カラスが散らかすことはある」

兄はどの質問にも、間を置かずに答えていた。女の人は、答えを聞くたびに手帳に何か書き込む。書くたびに、まず行の頭に何かを書き足しているように見えた。数字のようだった。

私は検品台に寄りかかって、それを見ていた。正直に言えば、少し苛立っていた。三百個の話の途中で、兄を取られた。

答えの中身は、ほとんど耳に入っていなかった。

私が AI に頼んだのは、注文を受けて、作業指示に出すことだった。それは、確かに動いていた。注文は入り、確定した分は二十二時にタブレットに出た。昨日の分も、ちゃんと出た。だから三百個ができた。

頼んだとおりに動いて、それで三百個ができた。足りなかったものの名前は、まだ出てこなかった。

第2幕: 再現 ── テストは、頼んだことしか確かめない

私は鞄からパソコンを出して、検品台の端に置いた。

半年前、このシステムを作り始めたとき、AI に何と頼んだのか。それを確かめたかった。私はコーディングエージェントを使っている。コーディングエージェントは、やってほしいことを文章で渡すと、自分でファイルを読んで、コードを書き、テストまで走らせる AI だ。私が使っているのは Claude Code で、そのときのやりとりの記録が残っている。

最初の依頼は、短かった。

1
2
取引先が Web から注文できるようにして。
工場で確定した注文は、毎晩22時に作業指示として現場のタブレットに出して。

二行。キャンセルという言葉は、どこにも無い。

頼んだときは、これで全部のつもりだった。取引先が注文を入れる。工場が確定する。作業指示に出る。私の頭の中にあった工場の流れは、この三つで閉じていた。

AI は、この二行から、画面とデータベースと、夜の作業指示を出す処理を作った。テストも書いてきた。全部通っていた。私は画面で注文を入れて、確定して、二十二時を待ってタブレットに出るのを、自分の目で確かめた。出た。それで受け入れた。

兄が、通用口から戻ってきた。

「業者さん、何だって」

「七時に開けたら放すって」

兄は検品台の反対側に立って、私の画面を覗き込んだ。

「テストっての、あるんだろ。AI が書いたやつ」

「ある」

「何を確かめてんの」

私はテストのファイルを開いた。AI が書いたテストを、一本ずつ名前で読むのは、実は初めてだった。全部が緑だったから、名前まで読む理由が無かった。

上から順に、声に出して読んだ。

「注文を登録できる」

「品番が空なら登録できない」

「数量が0なら登録できない」

「確定した注文だけが作業指示に出る」

「作業指示は22時に出る」

「作業指示に品番と数量が出る」

「納期の順に並ぶ」

七本。読み終わってから、私は少しの間、画面を見ていた。

これは、私の依頼文だ。二行の依頼を、AI が言い換えて、ばらして並べたものだ。注文を登録する。確定したものを出す。二十二時に出す。登録できない場合が二つ。作業指示に出るものが二つ。依頼文に書いたことが、ほとんど一つずつテストになっている。

「キャンセルは?」

兄が聞いた。

「無い」

「なんで」

「頼んでないから」

兄は、少し笑ったような顔をした。困ったときの顔だった。

「頼まなくたって分かるだろ。キャンセルなんて、月に何べんもあるぞ。数の間違い、納期の都合、向こうの設計変更。電話受けてるの、俺だけどさ」

「AI は、この工場に来たことがないから」

自分でも、思ったより静かな声が出た。兄に言い返したのではなかった。自分に言い聞かせていた。

兄は黙って、頭の後ろをかいた。

月に何べんも、と兄は言った。毎日電話を受けている人の、当たり前のことを言う口ぶりだった。私は知らなかった。二年前に戻ってきてから事務の机に座っているが、取り消しの電話を受けたことは一度も無かった。だから依頼文にも書かなかった。AI は、私の依頼文と、私のリポジトリのコードしか読んでいない。

半年前の私は、AI が出した変更を、どうやって受け入れていたのか。それを、そのままコードに書き写してみることにした。

まず、受け取るものの形を types.ts に決めた。

1
2
3
4
// skipped は、飛ばされたテスト(skip・todo)の印
export type TestResult = { name: string; passed: boolean; skipped: boolean };
export type TestReport = TestResult[];
export type Verdict = { accepted: true } | { accepted: false; reason: string };

TestResult は、テスト一本の結果。名前と、通ったかどうかと、飛ばされたかどうか。最後の一つはあとで使う。TestReport は、その一覧だ。[] は「その型の配列」という意味になる。Verdict は結論で、受け入れるか、理由を添えて止めるかのどちらかになる。| は TypeScript で「このうちのどれか」を表す書き方だ。受け入れの形には理由が無く、止める形には reason が必須になっている。

それから、半年前の私を before.ts に書いた。

1
2
3
4
5
6
7
import type { TestReport, Verdict } from './types.ts';

// 昨日までの判断: 落ちたテストが無ければ受け入れる
export function acceptReport(report: TestReport): Verdict {
  if (report.every((t) => t.passed)) return { accepted: true };
  return { accepted: false, reason: 'テストが失敗した' };
}

every は、配列の全部の要素が条件を満たすかを調べる関数だ。全部のテストが通っていれば受け入れる。一本でも落ちていれば止める。import type は型の定義だけを読み込む書き方で、読み込み先に .ts まで書いているのは、Node 24 が TypeScript のファイルを、型の記述(型注釈)を取り除くだけでそのまま走らせるからだ。間にファイル名を書き換える変換の段階が無いので、import に書いた名前のファイルがそのまま探される(型が正しいかの検査まではしない)。

書いてみると、これだけだった。

私は、三百個を作ったときのテストの結果を、そのまま入れてみることにした。名前は、さっき読み上げた七本。全部、通っている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 元の PR: 7本とも通っているが、要件 ID は付いていない
export const originalPr: TestReport = [
  t('注文を登録できる'),
  t('品番が空なら登録できない'),
  t('数量が0なら登録できない'),
  t('確定した注文だけが作業指示に出る'),
  t('作業指示は22時に出る'),
  t('作業指示に品番と数量が出る'),
  t('納期の順に並ぶ'),
];

originalPr は、さっきの七本の結果を、手で書き写した配列だ。t(...) は、名前を渡すと「通った・飛ばされていない」結果を一本こしらえる、小さな手伝い役。テストの中で本物の AI は呼ばない。AI が返した結果を、決まった形のサンプルとして渡す。こういう固定のサンプルを fixture と呼ぶ。

それから、テストを一本書いた。名前は「キャンセルを確かめるテストが無い変更は止める」。中身は、acceptReport(originalPr) の結論が「止める」であることを確かめるだけ。七本のどれもキャンセルに触れていないのだから、この結果では止まってほしい。

走らせると、赤くなった。半年前の私の関数は、七本が全部通っているので、受け入れる。止めない。

このとき私が受け入れたのには、理由があった。テストは七本とも通っていた。頼んだことは、画面で自分で確かめてあった。ちゃんと動いていた。AI に任せる前、自分で書いていた頃だって、テストが緑で、画面で動けば、それでよしにしていた。

読み上げた七本の名前を、もう一度頭の中で並べ直した。

テストは、書いた人が確かめようと思ったことしか確かめない。AI がテストを書くとき、確かめようと思える材料は、渡された依頼文と、読んだコードしかない。依頼文に無いことは、コードにも無く、テストにも出てこない。キャンセルは、私の依頼文に無かった。だから AI のコードにも無く、テストにも無かった。七本が全部通ったことは、頼んだことが動いていることを教えてくれた。頼まなかったことが抜けていないかについては、何も教えてくれなかった。

「赤くなった」

私が言うと、兄は画面を覗き込んだ。

「それ、直すと緑になるのか」

「今はまだ、直し方が分からない」

私はパソコンを閉じないまま、搬入口のほうへ歩いた。兄を取られたことへの苛立ちが、まだ少し残っていた。それに、あの人がなぜあんなに兄に質問していたのかが、気になっていた。

女の人は、閉まったシャッターの前にいた。鷹はやっぱり飛んでいない。手袋の上で、梁を見上げている。女の人は手帳を開いて、書き込んだ行を指でたどっていた。

「あの」

私が声をかけると、女の人は手帳から目を上げた。

「兄にずいぶん聞いてましたね。ハトのこと」

「聞いた」

「見れば分かるんじゃないですか。どこにとまるとか」

女の人は、梁を見上げた。

「下見で見えるのは、今日の屋根だけ」

それだけだった。

「今日だけ、ですか」

「今日、ハトが何時に来るかは、今日待ってれば分かる。でも、いつもそうなのかは、分からない」

それなら、と私は思った。

「何日か通って見れば、分かるんじゃないですか。毎朝来れば」

女の人は、顎で通用口のほうを示した。その先に、検品台の前で箱の数を数え直している兄がいた。

「毎朝ここを開けてる人がいる」

それで、話は終わったらしかった。女の人は手帳に目を戻した。

私は、顎の先の兄を見た。兄は箱の数を数え直していて、こちらには気づいていなかった。ハト対策には、そういうやり方があるのだろう、と思っただけだった。

背後で、モーターの音がした。兄が搬入口のボタンを押したのだ。時計を見ると、七時ちょうどだった。

シャッターがゆっくり上がっていく。上がりきる前に、隙間から灰色のものが何羽か滑り込んできた。ハトだった。ハトはまっすぐ天井へ上がって、梁の上に並んだ。奥まで。

兄が言ったとおりの時刻に、言ったとおりの場所へ、ハトは来た。

私は梁を見上げていた。

第3幕: 手順を整える ── 答えるのは、毎朝開けている人

女の人が、左腕を少し引いた。

鷹が手袋を蹴った。羽ばたきは二回か三回で、あとは梁に向かってまっすぐ上がった。梁の上のハトが、一斉に飛び立つ。羽音が工場の天井に跳ね返った。ハトはシャッターの開いた口から、外の明るいほうへ散っていった。

鷹は梁の端で一度向きを変えて、女の人の手袋に戻った。女の人は、手袋の上の鷹の足元を確かめてから、腰のポーチから何かを出して与えた。

梁の上は、空になっていた。

きれいだった。兄に聞いた時刻に待って、兄に聞いた場所へ放った。それだけで、梁の上の仕事は一度で済んだ。

私は、検品台の前で兄が質問に答えていた様子を、思い出していた。兄は、どの質問にも即答していた。私は、そのどれも知らなかった。シャッターを開けるのはいつも兄で、私はいつもその時間、まだ自転車に乗っている。

あの人が兄に聞いていたことと、キャンセルのことが、頭の中で重なった。

キャンセルを知っていたのは兄だった。兄は聞かれなかったから、言わなかった。さっきの口ぶりは、わざわざ言うことではない、という調子だった。私も、兄に聞かなかった。依頼文を書く前に、知っている人に聞くという段が、どこにも無かった。AI のほうにも、私に聞き返す段が無かった。二行を渡されて、二行のとおりに作った。

じゃあ、どうすればよかったのか。

私は、以前どこかで読んだ、Claude Code の使い方の公式ドキュメントを開き直した。ベストプラクティスのページに、AI に先に質問させる、という節があった。

作り始める前に、AI に質問させる。こちらがどんな機能が欲しいかを短く伝えて、AI のほうから、実装のこと、画面のこと、例外的な場合のこと、気になる点、どちらを取るかの選択を、順に聞いてもらう。答え終わったら、その答えをもとに、AI に仕様書を書かせる。仕様書は、何を作るのかを文章にしたものだ。そして、作らせるのは、仕様書ができたあと、新しいセッションで。セッションは、AI との一回分の会話のことで、新しいセッションの AI は、質問のやりとりを覚えていない。手元にあるのは、書かれた仕様書だけになる。

公式が載せているプロンプトは、こうだった。

1
2
3
4
5
I want to build [brief description]. Interview me in detail using the AskUserQuestion tool.

Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs. Don't ask obvious questions, dig into the hard parts I might not have considered.

Keep interviewing until we've covered everything, then write a complete spec to SPEC.md.

(出典: Claude Code docs「Best practices」の節「Let Claude interview you」、https://code.claude.com/docs/en/best-practices 、2026年10月15日参照)

何を作りたいかを一行で書いて、詳しく質問してくれと頼む。当たり前の質問はしなくていい、こちらがまだ考えていなさそうな難しいところを掘ってくれ、とも頼む。全部聞き終わったら、SPEC.md という名前のファイルに仕様書を書かせる。同じ節の続きには、こう書いてあった。

Once the spec is complete, start a fresh session to execute it.

The most useful specs are self-contained: they name the files and interfaces involved, state what is out of scope, and end with an end-to-end verification step that proves the feature works.

仕様書ができたら、新しいセッションで実装させる。いちばん役に立つ仕様書は、それだけで完結している。関わるファイルや部品の名前を出して、何をやらないか(スコープ外)を書いて、最後は、その機能が端から端まで動くことを確かめる手順で終わる。

あの人は、ハトを追う前に、毎朝シャッターを開けている人に聞いた。私は、作らせる前に、AI に聞かせればよかった。聞かれた質問に、知っている人が答えればよかった。

私はパソコンを持って、検品台に戻った。三百個の箱の横に置いた。

キャンセルの機能を作るのに、今度は先に質問させてみることにした。ここから先は Claude Code という道具に固有の話で、道具は改訂される。いつ時点の話かを書き添えて、本文とは分けておく。

2026年10月15日時点の Claude Code の使い方(公式ドキュメント best-practices で確認)。上に引いた公式プロンプトの [brief description] に作りたいものを書いて渡すと、AI は AskUserQuestion というツールを使って、選択肢付きの質問を一問ずつ出してくる。選択肢に無い答えは、自分で書いて返せる。聞き終わると SPEC.md を書く。仕様書の中の一つ一つの条件(作るものが満たすべきこと)を、要件と呼ぶ。実装は、仕様書ができたあとに新しいセッションを始めて、そこで頼む。公式のプロンプトそのものに要件の番号の振り方の指定は無いので、下の一行は私が足した取り決めだ。

私が渡したのは、公式のプロンプトに、仕様書の書き方の取り決めを一行足したものだ。

1
2
3
4
5
6
7
I want to build 注文のキャンセル(受注から作業指示までの流れの中で、注文を取り消せるようにする). Interview me in detail using the AskUserQuestion tool.

Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs. Don't ask obvious questions, dig into the hard parts I might not have considered.

Keep interviewing until we've covered everything, then write a complete spec to SPEC.md.

SPEC.md の要件は「## 要件」の節に、1行1要件で「- R1: 」「- R2: 」のように番号を振って書いて。

最初の何問かは、私が答えた。キャンセルのボタンを取引先の画面に出すか、工場側の画面だけにするか。キャンセルした注文を一覧から消すか、残して印を付けるか。どちらも、私に答えられる質問だった。工場の画面だけ。一覧には残して、キャンセルと出す。

四問目で、手が止まった。

画面には、こう出ていた。注文が確定したあとに、取引先から変更や取り消しが来ることはありますか。作業指示がタブレットに出たあとにも来ますか。来るとしたら、どの時点までなら止めたいですか。選択肢が四つ並んでいる。確定前だけ。作業指示が出る前まで。削り始める前まで。それ以外。

私は答えられなかった。どの時点で、どれくらいキャンセルが来るのか。私は知らない。

「兄さん」

私は顔を上げた。兄は、箱の数を数え終えたところだった。

「これ、答えて」

私はパソコンを、兄のほうへ向けた。

兄は軍手を外して、作業着の尻ポケットに押し込んだ。それから、画面に顔を近づけて、質問を声に出さずに読んだ。

「確定したあとでも、月に二、三回は来る。作業指示が出たあとも来る。昨日のがそれだ」

兄は、画面の選択肢を指でたどった。

「削り始める前なら止めたい。削り始めたら、もう途中で止めても材料は戻らないから、そこからは向こうと相談。だから、これ」

兄は、削り始める前まで、の選択肢を指した。私はそれを選んだ。

次の質問は、作業指示が出たあとにキャンセルされた注文を、現場にどう知らせるか、だった。兄は、タブレットに出ている作業指示の上に、中止と大きく出してくれ、と言った。職人さんは、タブレットしか見ていないから。選択肢には無い答えだったので、兄が口で言い、私が打ち込んだ。

その次は、数量の変更をどう扱うか。兄は、数量の変更はキャンセルして出し直してもらっている、と答えた。昨日の電話も、結局そういう話だった。私は、今回は数量の変更は作らない、と書き足した。

兄の答えは、どれも速かった。さっき搬入口で、ハトの時刻を聞かれていたときと同じ速さだった。

質問が終わると、AI が SPEC.md を書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 注文受付と作業指示

## 要件
- R1: 取引先は Web の画面から注文を登録できる
- R2: 登録した注文は、工場が確定するまで作業指示に載らない
- R3: 確定した注文は、毎晩22時に作業指示として出る
- R4: 作業指示には、品番・数量・納期が出る
- R5: キャンセルした注文は、作業指示に載らない
- R6: 作業指示が出たあとにキャンセルされた注文は、現場のタブレットに「中止」と出る

## スコープ外
- 数量の変更(キャンセルと再注文で扱う)
- 取引先への自動のメール通知

## 検証
- check-spec.ts で、全要件に通ったテストがあることを確かめる

(本文に載せるため、AI が書いたものから、関わるファイルの名前の節などを省いて短くした。検証の節は、このあと作った check-spec.ts に合わせて私が書き直したもの)

R1 から R4 は、半年前に作ったものの言い直しだった。R5 と R6 が、新しかった。兄の答えが、そのまま二行になっていた。

兄と二人で、画面を覗き込んだ。

「これ、ここに書いてありゃ、AI はちゃんと作るのか」

兄が聞いた。

「書いてあっても、作ったかどうかは、確かめないと分からない」

AI は、作り終えると、作りました、と報告する。でも、報告と、本当にできていることは、こちらで確かめないと結びつかない。報告を読んだだけでは、確かめたことにならない。

「じゃあ、確かめりゃいい」

「全部?」

私は、六つの行を見た。今日は六つ。でも、仕様書は、これから機能を足すたびに長くなる。そのたびに、全部の要件について、テストがあるか、ちゃんと確かめているかを、目で追うのか。半年前の私は、七本のテストの名前すら読んでいなかった。

そのとき、検品台の横を、あの人が通りかかった。手袋の上に、鷹を据えている。梁の仕事を終えて、換気のために開けた窓の様子でも見に行くところらしかった。

女の人は、私の画面にちらりと目をやった。R1 から R6 の行の頭を、指先で上から下へなぞった。

「番号、振ってある」

それだけ言って、搬入口のほうへ戻っていった。

兄が、あ、と言った。

「あの人、手帳に番号振ってたんだよ。俺に聞いたこと、一個ずつ。一、時刻。二、場所、みたいに」

兄は、作業着の胸ポケットからボールペンを抜くと、検品台の下の棚から図面を一枚引っぱり出した。三百個のスリーブの図面だった。

「図面ってさ、寸法に全部番号が振ってあるんだよ。ほら」

兄は、図面の上の数字を指した。外径の寸法の横に、丸で囲んだ一。長さの横に二。内径の横に三。

「検査成績書には、同じ番号で、測った値を書く。一番、外径、何ミリ。二番、長さ、何ミリ。取引先に出す書類だ」

兄は、図面の一番から、検品台の上に置いてあった検査成績書の一番の欄へ、指を往復させた。二番から二番へ。三番から三番へ。

「番号が一個でも空いてたら、出荷しない。測ってない寸法があるってことだから。測り忘れは、番号を見りゃすぐ分かる。全部の寸法を、毎回いちいち図面と見比べなくてもいい」

私は、図面と、画面の SPEC.md を見比べた。

要件に番号を振る。テストの名前に、同じ番号を書かせる。そうすれば、どの要件にテストがあって、どの要件に無いかが、番号を見るだけで分かる。テストの無い番号があれば、止める。全部の要件を毎回目で追わなくても、番号の空きだけを見ればいい。

「それ、作れる」

私は言った。

まず、取り決めを作った。インタビューのプロンプトには、さっき足した一行がある。要件は - R1: の形で、一行に一つ。実装を頼むプロンプトには、もう一行足す。テストの名前の頭に、そのテストが確かめる要件の番号を書くこと。たとえば R5: キャンセルした注文は作業指示に載らない。一本のテストに書く番号は一つだけにした。決まりを簡単にしておいたほうが、AI も守りやすいし、私も読みやすい。これは私の取り決めで、Claude Code や公式が決めている形ではない。

次に、SPEC.md から要件の番号を読み出す関数を書いた。今日の判断は、まとめて after.ts に置く。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import type { TestReport, Verdict } from './types.ts';

// 「- R数字: 」で始まる行だけを要件として読む(スコープ外・検証の節の箇条は R で始まらないので読まれない)
export function parseSpec(markdown: string): string[] {
  const ids: string[] = [];
  for (const line of markdown.split('\n')) {
    const m = line.match(/^- (R\d+): /);
    if (m) ids.push(m[1]);
  }
  return ids;
}

文字列から必要なところだけを取り出すことを、パースと呼ぶ。split('\n') で仕様書を一行ずつに分けて、行ごとに match で正規表現に当てる。正規表現 /^- (R\d+): / は、行の頭(^)が - で始まり、R と数字が一つ以上(\d+)続き、コロンと空白が来る行に当たる。丸かっこで囲んだ部分は取り出せて、m[1] に R5 のような番号が入る。当たらない行では m が null になるので、if (m) で飛ばす。スコープ外や検証の節の箇条は - R で始まらないから、何もしなくても読まれない。

それから、判定の本体を書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 名前の先頭が「ID: 」であることを見る。コロンと空白まで比べるので、「R1: 」は「R10: 」の先頭に一致しない
const claims = (id: string, name: string) => name.startsWith(`${id}: `);

export function buildAcceptance(requirementIds: string[]): (report: TestReport) => Verdict {
  return (report) => {
    if (!report.every((t) => t.passed)) return { accepted: false, reason: 'テストが失敗した' };
    const missing = requirementIds.filter(
      (id) => !report.some((t) => t.passed && !t.skipped && claims(id, t.name)),
    );
    if (missing.length > 0) {
      return { accepted: false, reason: `検査の無い要件がある: ${missing.join(', ')}` };
    }
    return { accepted: true };
  };
}

buildAcceptance は、要件の番号の一覧を受け取って、判定する関数を返す関数だ。戻り値の型 (report: TestReport) => Verdict は、「テストの結果を受け取って結論を返す関数」という意味になる。半年前の acceptReport は、テストの結果だけを受け取る形のまま、変えていない。新しく要る番号の一覧は、こちらの関数が先に受け取っておく。こうしておくと、半年前の判断と今日の判断を、同じテストの結果で並べて比べられる。

判定は三段ある。私が段を読み上げると、兄が一段ずつ、検査成績書のどこに当たるかを確かめた。

一段目は、落ちたテストがあれば止める。半年前の関数と同じだ。

「測った値が、図面の公差に入ってなかったってことだな。不合格」

兄が言った。

二段目は、要件の番号ごとに、その番号を名乗る通ったテストが一本でもあるかを見る。some は、配列の中に条件に合うものが一つでもあるかを調べる関数で、filter は、条件に合うものだけを残す関数だ。だから missing には、名乗るテストが一本も無かった番号だけが残る。

「番号の欄が空いてるってことか」

「そう考えていい」

claims は、テストの名前がその番号を名乗っているかを見る小さな関数だ。startsWith は、文字列が指定した文字で始まるかを調べる。バッククォート(`)で囲んだ文字列の中の ${id} には、変数 id の中身が入る。

番号を比べるところで、私は番号だけの前方一致にはしなかった。名前が R1 で始まるかではなく、R1: で始まるか、と、コロンと空白まで含めて見る。そうしないと、R10: で始まるテストが、R1 のテストとして数えられてしまう。

「図面で一番と十番を取り違えたら、事故だよ」

兄は、図面を叩いた。

三段目は、番号の空きが一つでもあれば止める。そのとき、空いている番号を全部並べて理由にする。どこが空いているかが分かれば、何を確かめ足せばいいかも分かる。

番号の付いていないテストは、どの要件の担保にも数えない。止めもしない。仕様書に無い番号を名乗るテストも、止めない。止めたいのは検査の抜けで、余分な検査があっても害にはならない。

あとは、テストの結果を集める部分だ。

最初、私はテストのファイルそのものを正規表現で読もうとした。ファイルの中から、test('R5: のように書いてある部分を探せば、番号が取れる。試しに、手元のファイルで探してみた。番号は取れた。でも、昔コメントアウトしておいたテストの番号まで、一緒に取れてしまった。行の頭に // を付けて止めてある、// test('R9: 古い仕様のテスト', ...) のような行だ。

先頭に // を付けて止めてあるテストは、走らない。でも、文字としてはファイルに残っている。文字を読む方法だと、走らないテストも番号を名乗れてしまう。それに、ファイルを読むだけでは、そのテストが通ったのか落ちたのかが分からない。この方法は捨てた。

代わりに、テストを実際に走らせて、走ったテストの名前と結果を受け取ることにした。Node のテストの仕組み node:test には run() という関数がある。テストを走らせると、テストが一本終わるたびに、その結果をイベントとして知らせてくる。イベントは、テストの名前や結果をひとまとめにしたオブジェクトで届く。通ったテストは test:pass、落ちたテストは test:fail という種類のイベントになる。画面に出る文字を読んで名前と結果を取り出すより、最初から名前と結果が分かれた形で届くので、取り違えが起きにくい。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { readFileSync } from 'node:fs';
import { run } from 'node:test';
import { buildAcceptance, parseSpec } from './after.ts';
import type { TestReport, Verdict } from './types.ts';

// Node v24.21.0 で実測。run({ files }) の test:pass / test:fail イベントを集める。
// describe 自身のイベントは details.type === 'suite' なので除く
export async function collectReport(files: string[]): Promise<TestReport> {
  // 親の NODE_TEST_CONTEXT が子のテストランナーの挙動を変えるので外す
  const saved = process.env.NODE_TEST_CONTEXT;
  delete process.env.NODE_TEST_CONTEXT;
  const report: TestReport = [];
  try {
    const stream = run({ files });
    stream.on('test:pass', (e) => {
      if (e.details.type === 'suite') return;
      report.push({ name: e.name, passed: true, skipped: e.skip !== undefined || e.todo !== undefined });
    });
    stream.on('test:fail', (e) => {
      if (e.details.type === 'suite') return;
      report.push({ name: e.name, passed: false, skipped: false });
    });
    // イベントを購読しただけでは流れ切らない。最後まで読み切る
    for await (const _ of stream) { /* drain */ }
  } finally {
    if (saved !== undefined) process.env.NODE_TEST_CONTEXT = saved;
  }
  return report;
}

// check-spec.ts 相当: AI の実装が上がってきたあと、マージの前に手で走らせる
export async function checkSpec(specFile: string, testFiles: string[]): Promise<Verdict> {
  const ids = parseSpec(readFileSync(specFile, 'utf8'));
  return buildAcceptance(ids)(await collectReport(testFiles));
}

async の付いた関数は、時間のかかる処理の終わりを待つ書き方を使える関数で、結果は Promise(あとで届く値)として返る。stream.on('test:pass', ...) は、「test:pass のイベントが届いたら、この処理をする」という登録だ。届いたイベントから、名前と結果を一本ずつ report に積んでいく。e.skip !== undefined は、イベントに skip の印が付いているかを見ている。印の無いイベントでは、その項目は undefined(値が無い)になる。for await の行は、イベントの流れを最後まで読み切るためのものだ。run() が返す流れは、誰かが読み進めないと先へ進まない作りになっている。ここを抜けたときには、全部のテストが走り終わっている。

いくつか、実際に走らせて分かったことを手当てしてある。

テストをまとめる箱 describe も、それ自身のイベントを一つ出す。箱の名前がテストの名前のように混ざるので、details.type が 'suite'(箱)のものは除く。

skip と todo の印が付いたテストのことは、あとで書く。

関数の頭で NODE_TEST_CONTEXT という環境変数を一度外しているのは、この関数をテストの中から呼んだときのためだ。環境変数は、動いているプログラムに外から渡す、名前の付いた設定の値のことだ。あとで、この関数そのものをテストの中で走らせて確かめる。そのとき外側でテストを走らせている node --test(Node でテストのファイルを走らせるコマンド)は、自分用の印をこの環境変数に入れる。それが内側の run() にまで届くと、内側のテストの走り方が変わってしまう。外すのは try の中の処理の間だけで、finally に書いた戻しは、途中でエラーが起きても必ず走る。

checkSpec は、check-spec.ts というコマンドの本体にする関数だ。SPEC.md を読んで番号の一覧を作り、テストを走らせて結果を集め、判定にかける。最後の行の buildAcceptance(ids)(...) は、buildAcceptance(ids) で判定の関数を作り、続くかっこでそれにすぐ結果を渡している。await は、結果が届くまで待つ印だ。check-spec.ts のほうは、この関数を呼んで、止まったら理由を表示し、終了コード 1 で終わるだけの数行のファイルにした。node check-spec.ts SPEC.md src/orders/orders.test.ts のように打って走らせる。終了コードは、コマンドが最後に返す番号で、0 なら成功の意味になる。

テストの名前に番号を書く代わりに、テストに tags という札を付けて番号を持たせる方法も、Node にはある。でもそれは Node v24.19.0 で入ったばかりの、まだ試験中の機能だった。名前の頭に書くほうが、どのバージョンでも動くし、画面で読める。

私は、三百個を作ったときのテストの結果を、新しい判定に通してみた。

1
検査の無い要件がある: R1, R2, R3, R4, R5, R6

全部、止まった。キャンセルの R5 だけではなかった。半年前のテストには、番号が一つも付いていない。番号の無いテストは、どの要件の検査にも数えない。

「番号の無い検査は、どの寸法の検査か分からないもんな」

兄が言った。

検査成績書に、番号を書かずに、測った値だけが並んでいる。外径を測ったのか、長さを測ったのか、分からない。そういう書類は、兄なら出荷に使わない。

朝、三百個のときの結果で赤くなったテストは、新しい判定に向けて書き直した。この結果では止まってほしい、という期待は変えていない。止める役を今日の判定に移し、半年前の判断が受け入れてしまう事実も、並べて記録に残した。今日の判断は R1 から R6 を並べて止める。理由の中に、キャンセルの R5 がある。

私はパソコンを持って、搬入口へ行った。あの人に、見せたかったのだと思う。あなたの手帳の番号から、ここまで来た、と。

女の人は、搬入口の外のコンクリートに立って、屋根のほうを見ていた。鷹は手袋の上。

「仕様書の要件に番号を振って、テストの名前と突き合わせれば」

私は画面を女の人のほうへ向けた。

「AI に聞かせて、番号で突き合わせれば、もう抜けは出ませんよね」

女の人は、画面を見なかった。

代わりに、ウインドブレーカーのポケットから手帳を出して、開いて、私のほうへ向けた。

番号の振られた行が、四つ並んでいた。一、開けるのは七時。二、開けたらすぐ入る。三、梁の上、奥まで。四、餌は置いていない、裏の弁当屋。兄に聞いたことが、兄の答えの短い形で、一行ずつ書いてある。四の下には、何も書かれていなかった。

女の人は、顔を上げて、屋根を見た。

私も見た。工場の屋根の上に、換気塔が二本立っている。その片方の陰に、ハトの群れがとまっていた。二十羽はいる。さっき梁から追い出されたハトとは、たぶん別の群れだ。朝、兄が答えた質問の中に、換気塔は一度も出てこなかった。私も、知らなかった。

鷹が、首を換気塔のほうへ向けていた。羽を少し膨らませている。女の人は、腕を上げなかった。

「そこは、聞いてない」

女の人が言った。

それで、分かった。

質問は、聞いたことにしか答えを連れてこない。今朝の兄への質問に、換気塔が無かったのと同じように。誰も聞かず、誰も答えなかったことには、仕様書の番号が振られない。番号が無ければ、さっき作った突き合わせにも、空きとしてすら出てこない。

質問させれば抜けは出なくなる、と思ったのは、質問を買いかぶりすぎていた。質問は抜けを減らす。でも、聞かなかったことは、質問にも突き合わせにも引っかからない。

女の人は、それ以上何も言わなかった。手帳を閉じずに、検品台の前の兄のほうへ歩いていった。換気塔のハトのことを聞きに行ったのだ。詫びる様子も、言い訳をする様子も無かった。聞いていない場所を、聞きに行く。そういう足取りに見えた。

兄が、換気塔を見上げて何か答えるのが見えた。女の人が手帳に書き込む。五の行が、書き足されたのだろう。

私は検品台に戻って、兄に言った。

「半年前の私は、何を確かめるかを、自分の依頼文だけで決めてたんだと思う」

兄は、図面を棚に戻しかけて、手を止めた。

「依頼文は私の頭の中のことしか書けないから、私が知らないことは、確かめる対象にすら入らなかった。今日からは、AI に、知ってる人に聞かせる。聞いたことを、仕様書に番号つきで書かせる。で、その番号ごとに、検査が付いてるかを、機械に確かめさせる」

兄は少し考えて、図面をもう一度広げた。

「図面に番号を振るやつと、測るやつを、分けてるのと同じだな」

「どういうこと」

「図面は設計が描いて、番号を振る。測るのは検査の担当だ。測るやつが自分で番号を決めたら、測りやすいとこしか測らないだろ。だから分けてる」

番号を振る人と、測る人。仕様書の番号は、質問に答えた人の言葉から振られる。テストは、その番号を名乗って書かれる。テストを書く側が、自分で何を確かめるかを決めていた半年前とは、そこが違う。

兄の言う分け方を、今日の手順に当てはめて、検品台の上で書き出してみた。紺の線は、人が答えたことと、人が書いた判定だ。橙の破線は、AI が動くところになる。換気塔のハトのような、誰も聞かなかったことは、どこにもつながらない。

番号の流れ図。橙の破線の札「AIが作る前に質問を出す。」から、紺の札「知っている人が質問に答える。」、紺の札「仕様書の要件にR1からR6の番号が並ぶ。」へ橙の破線矢印が続き、そこから橙の破線の札「AIがテスト名の頭に番号を書く。」へ進む。仕様書の札とテスト名の札から紺の矢印が「判定が番号ごとに通ったテストを探す。」に集まり、「全部の番号にテストがあれば受け入れる。」と、紺で塗った「空いた番号があれば止める。」に分かれる。灰の点線の札「聞かなかった要件は番号が無く、判定にも出ない。」は、どこにもつながっていない

私は、SPEC.md を添えて、新しいセッションで実装を頼んだ。テストの名前の頭に要件の番号を書くこと、という一行を足して。AI が作業を始めた。

待っている間に、私は検品台の三百個の箱に、ガムテープを貼って、キャンセル分、とマジックで書いた。

第4幕: 検める ── 飛ばしたテストは、通ったことにならない

昼前に、新しい PR(変更を本体に取り込む前に、確かめてもらうための申請)が上がってきた。

AI の報告は長かった。何を作ったか、どのファイルを変えたか、どう確かめたか。その中ほどに、一行だけ、R5 のテストは保留にした、と書いてあった。私はそこまで読む前に、check-spec.ts を走らせていた。

止まった。

1
検査の無い要件がある: R5

R5 だけだった。テストのファイルを開くと、R5 のテストはあった。名前の頭に R5 も付いている。ただ、書き出しが it.skip('R5: キャンセルした注文は作業指示に載らない', ...) になっていて、その上に、作業指示が出る前のキャンセルと出たあとのキャンセルの区別が R5 と R6 の文面からは決めきれないため保留、というコメントが添えてあった。

it.skip は、テストを書いておきながら、走らせずに飛ばす印だ。it は test と同じ働きをする別の名前で、.skip を付けると、そのテストは中身が走らない。it.todo という印もあって、こちらは、まだ書いていないテストの置き場所として名前だけを残す。

テストを普通に走らせると、こう出た。

1
2
3
﹣ R5: キャンセルした注文は作業指示に載らない (0.05675ms) # SKIP
ℹ fail 0
ℹ skipped 1

fail 0。落ちたテストは一本も無い。テスト全体の終了コードも 0 だった。飛ばしたテストは、失敗として扱われない。

そして、run() のイベントでも、飛ばされたテストは test:pass の種類で届く。違うのは、skip(it.todo なら todo)という印が付いていることだけだ。さっき collectReport で、その印を見て skipped を立てておいたのは、このためだった。

半年前の acceptReport は、passed しか見ない。飛ばされた R5 も、通ったテストとして数える。もし今日、私がまだ半年前のやり方だったら、この PR は通っていた。キャンセルした注文が作業指示に載らないことは、誰にも確かめられないまま。

新しい判定は、passed かつ skipped でないテストだけを、番号の担保に数える。飛ばしたテストは、R5 を名乗っていても、R5 の検査にはならない。

昼に上がってきた PR の結果を、兄の検査成績書のように、番号ごとに一行ずつ並べ直してみた。R5 の行には名前が載っているが、その欄は埋まっていない。同じ結果を見て、半年前の判断は通し、今日の判断は止める。

番号の台帳の図。左の列「SPEC.md の番号」のR1からR6が、右の列「AIが出したテスト結果」の同じ行へ紺の矢印で結ばれ、R1からR4とR6は橙の破線の札で「テストが通った。」と書かれている。R5だけは灰の点線と×で「R5のテストは走っていない。」につながる。右の列からは別々に2本の矢印が下り、灰の矢印は「半年前の判断は、落ちたテストが無いので受け入れる。」へ、紺の矢印は紺で塗った「今日の判断は、空いた番号R5で止める。」へ届く。横に「テストの中身は人が読む。」の札が添えてある

AI が保留にした理由は、読めば分かった。R5 は、キャンセルした注文は作業指示に載らない。R6 は、作業指示が出たあとにキャンセルされたら、中止と出る。じゃあ、確定して、まだ二十二時の前にキャンセルされた注文は、どちらなのか。兄の答えでは、削り始める前なら止めたい、だった。作業指示が出る前のキャンセルなら、そもそも作業指示に載せない。それが R5 だ。私の仕様書の文面が、そこまで書き分けていなかった。

私は SPEC.md の R5 を書き直した。作業指示が出る前にキャンセルされた注文は、その夜の作業指示に載らない。それから、AI に R5 のテストを仕上げさせた。

もう一度、check-spec.ts を走らせた。

今度は、止まらなかった。そして、半年前の判断と今日の判断を同じ結果で並べるために、ここからは、半年前の acceptReport を Before、今日の判定を After と呼んで、二つを同じ結果の上に並べてみる。

それから、今日書いた判定のテストを、一式走らせた。テストの中で使っているのは、一本を除いて、どれも私が書いた fixture だ。R1 から R6 まで全部に通ったテストがある結果(allCovered)。三百個のときの、番号の無い七本(originalPr)。R5 が飛ばされた結果(r5Skipped)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
test('全要件に通ったテストがある結果は Before も After も受け入れる', () => {
  assert.deepEqual(acceptReport(allCovered), { accepted: true });
  assert.deepEqual(accept(allCovered), { accepted: true });
});

test('元の PR(ID の無い7本・全部通過)は Before が受け入れ After が R1〜R6 を並べて止める', () => {
  assert.deepEqual(acceptReport(originalPr), { accepted: true });
  const v = accept(originalPr);
  assertRejected(v, /R1, R2, R3, R4, R5, R6/);
});

test('新しい PR(R5 が skip)は Before が受け入れ After が R5 だけで止める', () => {
  assert.deepEqual(acceptReport(r5Skipped), { accepted: true });
  const v = accept(r5Skipped);
  assertRejected(v, /R5$/);
});

test は node:test の関数で、テストの名前と中身を渡して一本登録する。assert は Node に付いてくる、値を確かめるためのモジュールだ。accept は、SPEC.md の fixture から読んだ R1 から R6 の一覧を buildAcceptance に渡して返ってきた、判定の関数だ。assert.deepEqual は、二つの値を中身まで比べる。assertRejected は、結論が「止める」で、理由に狙った文字列が入っているかを見る、テストの中の手伝い役。/R5$/ は「R5 で終わる」という正規表現で、理由に並んだ番号が R5 だけであることを確かめている。

一本目が、私がいちばん確かめたかったものだ。AI が仕様書の全部の要件にテストを付けて、全部が通っているとき、半年前の判断も今日の判断も、同じく受け入れる。正しい仕事は、止めない。違いが出るのは、番号に検査が無いときと、あっても飛ばされているときだけだ。

残りのテストで押さえたのは、次のようなことだ。R5 が todo でも止まる。テストが一本落ちていれば、半年前の判断も今日の判断も止める。R10 のテストがあっても、R1 のテストが無ければ R1 で止まる。番号の無いテストや、仕様書に無い番号のテストでは止まらない。parseSpec は、スコープ外や検証の箇条を読まない。

一本だけ、fixture ではなく、本物の run() を走らせるテストも置いた。一時的なフォルダに小さなテストのファイルを書き出して、collectReport に渡す。ファイルの中身は、describe の箱に入れた三本。R1 は通る、R2 は it.skip、R3 は it.todo。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
test('本物の run(): skip と todo が skipped: true で届き、describe は結果に混ざらない', async () => {
  const dir = mkdtempSync(join(tmpdir(), 'interview-spec-'));
  mkdirSync(join(dir, 'src/orders'), { recursive: true });
  const file = join(dir, 'src/orders/orders.test.ts');
  writeFileSync(
    file,
    `import { describe, it } from 'node:test';
describe('注文', () => {
  it('R1: 登録できる', () => {});
  it.skip('R2: 確定前は載らない', () => {});
  it.todo('R3: 22時に出る');
});
`,
  );
  const report = await collectReport([file]);
  const byName = Object.fromEntries(report.map((r) => [r.name, r]));
  assert.equal(report.length, 3);
  assert.equal(byName['注文'], undefined);
  assert.deepEqual(byName['R1: 登録できる'], { name: 'R1: 登録できる', passed: true, skipped: false });
  assert.equal(byName['R2: 確定前は載らない'].skipped, true);
  assert.equal(byName['R3: 22時に出る'].skipped, true);
  // 実際の結果を After に通すと R2・R3 で止まる
  assertRejected(buildAcceptance(['R1', 'R2', 'R3'])(report), /R2, R3/);
});

mkdtempSync(join(tmpdir(), 'interview-spec-')) は、OS の一時置き場に、使い捨ての作業フォルダを作る。join はフォルダの名前をつないでパスにする関数で、mkdirSync の recursive: true は、途中のフォルダもまとめて作る指定だ。writeFileSync に渡しているバッククォートの文字列が、書き出すテストのファイルの中身そのものになる。map は配列の要素を一つずつ別の形に変える関数だ。Object.fromEntries は、[名前, 結果] の組の並びから、名前で結果を引ける表を作る。箱の名前の「注文」が結果に混ざっていないこと、飛ばした二本に skipped が立っていること、それを判定に通すと R2 と R3 で止まることを確かめている。判定の関数は、run() のイベントがこの形で届くことを前提にしているからだ。確かめたのは Node v24.21.0 だった。

1
2
3
4
5
6
7
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

十本とも、通った。

通ったから安心、とはしなかった。番号の空きを探す二段目を、わざと何も見つけないように壊してみた。三百個のときの結果と、R5 が飛ばされた結果のテストが、赤くなった。飛ばしたテストも担保に数えるように変えると、skip と todo のテストが赤くなった。番号だけの前方一致で比べるように変えると、R10 のテストが赤くなった。落ちたテストを見る一段目を外すと、テストが一本落ちている場合のテストが赤くなった。壊すたびに、どれかが赤くなった。中身の無いテストだから緑だったわけではない。

兄は、私の横で画面を見ていた。

「なあ。番号さえ合ってりゃ、中身はどうでもいいのか」

兄が聞いた。

「検査成績書に、番号と数字だけ書いてさ。本当は測ってなかったら、どうなる」

私は、試してみることにした。R5 のテストの中身を、空にした。名前は R5: キャンセルした注文は作業指示に載らない のまま。中身は、何も確かめない。

走らせると、そのテストは通った。何も確かめていないのだから、落ちる理由が無い。check-spec.ts も、通した。R5 を名乗る、通ったテストがあるからだ。

「通っちゃう」

兄は、だろうな、という顔をした。

この判定が見ているのは、テストの名前と、通ったか、飛ばされたか、だけだ。テストの中身は見ていない。番号が付いていても、その中身が本当にその要件を確かめているかは、人が読むしかない。ただ、読むのは、要件ごとに一本ずつでよくなった。どの番号のテストを読めばいいかが、はっきりしている。

番号の中身のほかにも、この判定が引き受けないものがある。兄に向かって、指を折りながら数えた。

一つ目は、換気塔のぶんだ。仕様書に書かれていない要件は、そもそも数に入らない。

二つ目。仕様書が間違っていたら、間違ったまま通る。兄が間違った答えをしていたら、間違った要件に、ちゃんとテストが付いて、通る。今日の R5 みたいに、仕様書の文面があいまいなところも、人が読んで直すしかない。

三つ目。今日の十本が保証しているのは、私が手で作ったサンプルを渡したときに、判定が思ったとおりに答えることだけだ。Node の run() が届けるイベントの形が次のバージョンで変わったり、Claude Code の質問の仕組みが変わったりすれば、判定の外側のつなぎ目が、何も言わずに外れるかもしれない。十本は緑のまま、本物の結果だけが読めていない、ということが起こりうる。それに気づけるように、確かめた Node のバージョンをコードの中に書き残した。

四つ目。この判定は、ひとりでには走らない。AI の実装が上がってきたら、マージする前に、私がコマンドを打って走らせる。止まったときに出るのは、空いている番号の一覧だけだ。マージを差し止めるのは、それを読んだ私になる。

止めるのは、番号に検査が無いところまで。番号の中は人が読む。番号を振る前のところは、聞いて、答えるしかない。

ついでに、調べたことも一つ書いておく。仕様書から作業を進める道具は、ほかにもある。GitHub の Spec Kit には、仕様書の要件の番号ごとに作業が割り当てられているかを AI に分析させるコマンドがあるし、AWS の Kiro は要件とそれを確かめるテストのつながりを追えるとしている。OpenSpec にも、実装が仕様に合っているかを AI に調べさせるコマンドがある。Claude Code の公式ドキュメントにも、別の AI にレビューさせて、全部の要件が実装されてテストがあるかを確かめさせるプロンプトの例が載っていた(2026年10月15日に、それぞれの公式ドキュメントで確かめた範囲で)。考え方は、もうある。私の判定がしているのは、そのうちのいちばん小さなところだけだ。番号の文字が一致するかだけを見るので、同じ入力からは必ず同じ結論が出る。依存も無い。そして、止める。

兄は、図面をたたんで、棚に戻した。

「次にさ」

兄が言った。

「AI に何か聞かれたら、俺が答えりゃいいのか」

私は少し考えた。

「まず私が質問を受ける。答えられないところで、兄さんを呼ぶ」

全部を兄に任せたら、私は工場の流れを知らないままだ。全部を私が答えたら、今日の四問目で答えに詰まる。分からない質問が来たところで、知っている人を呼ぶ。

「呼ばれたら、すぐ答える」

兄は、軍手をはめ直した。

搬入口のほうから、足音がした。あの人が戻ってきた。換気塔の分を済ませたあとも、ハトが戻ってこないか、敷地の周りを見て回っていたらしい。屋根の上には、もう何もとまっていなかった。鷹は手袋の上で、羽を整えていた。

女の人は、手帳を閉じて、ウインドブレーカーのポケットにしまった。それから、同じポケットから細長いカードを一枚抜いて、私に差し出した。

受け取ると、手書きの字が並んでいた。いちばん上に、鷹宮スズ、と名前。その下に四つの項目。五つ目は、項目の名前のあとが空白のままだった。

鷹宮さんは、何も言わなかった。帰ろうともしなかった。手袋の上の鷹と一緒に、私の前に立ったまま、待っていた。

私は検品台の上でボールペンを取って、カードの五つ目の行を書いた。三百個の箱の横で、少し考えてから書いた。

書き終えて、カードを鷹宮さんのほうへ向けた。鷹宮さんは、それを読んだ。読み終えると、カードを私の手に戻して、何も言わずに、開いたシャッターの口から外へ出ていった。鷹は、拳の上で首を回して、一度だけこちらを見た。

放鷹カード

  • 合図: 仕様インタビュー(AI に質問させて仕様書を書かせる)。作らせる前に聞かせる。答えるのは、知っている人。要件には番号を振る
  • 検め方: テストの名前の頭に要件の番号を書かせ、番号ごとに、通ったテスト(飛ばしていないもの)があるかを突き合わせる。無い番号があれば止める
  • 止めないもの: 全部のテストが通り、仕様書のどの番号にも、その番号を名乗る通ったテストがある実装
  • 鷹のせいじゃなかった: 何を確かめるかを、自分の依頼文だけが決めていた。知っている人に、誰も聞いていなかった
  • 自分の言葉で: 番号の空きは機械が見る。番号を振る前の、聞かなかったことは、誰も止めない。答えられない質問が来たら、兄を呼ぶ
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource へ
Hugo で構築されています。
テーマ Stack は Jimmy によって設計されています。