第1幕: 現場 ── 十七件、一通も
「昨日から入った予約、十七件ですよね。その全部に、確定メールが届いてないんです」
電話の向こうで、店長の声が少しだけ硬くなった。僕は屋上の手すりを握ったまま、すみません、と言った。
会社は雑居ビルの四階にある。社員は五人で、美容院向けの予約サイトを作っている。オフィスで電話を受けると、隣の席の社長に全部聞こえる。だから僕は、まずい電話がかかってくると屋上に上がる。今日で三回目だった。
「今朝、来店されたお客様に言われて気づいたんです。予約したのに何も来なかったけど、これで合ってるのかって。うちのほうでも、確定メールが一通も出ていないことを確認しました」
「すみません。原因は、まだ分かっていなくて。すぐに調べます。すみません」
同じ言葉を何度も言った気がする。風が強くて、自分の声がよく聞こえなかった。
そのとき、鋭い音が鳴った。
笛の音だった。指笛ではない。金属の呼び笛を、短く一度だけ吹いた音。振り返ると、屋上の反対側、給水塔の陰に、若い女の人が立っていた。跳ねた短い髪に、ウインドブレーカー。左腕にだけ、肘まである分厚い革の手袋をはめている。
その手袋の上に、空から何かが降りてきた。
鷹だった。焦げ茶の体に、肩のあたりだけ栗色が差している。翼を広げたまま一度だけ羽ばたいて、手袋にぴたりと収まった。尾の先に、白い帯が一本入っていた。
「……はい、はい。夕方までには、ご連絡します」
店長との電話を切ったあとも、僕はしばらく、その鷹から目が離せなかった。エレベーターの前に貼り紙があったのを思い出す。「屋上でカラス対策の作業を行います。ご協力をお願いします」。ビルの管理会社が手配した業者だろう、くらいにしか思っていなかった。一階の裏のゴミ置き場は、毎朝カラスに荒らされている。
女の人が、手袋の腕を少し上げた。鷹がまた飛び立つ。屋上の縁を越えて、隣のビルとの隙間へ降りていく。
鷹は、すぐに戻ってきた。
驚くほど早かった。手袋に降りた鷹の足元を、女の人は顔を近づけて見た。爪の間に、何も挟まっていない。それを確かめてから、女の人は腰のポーチに伸ばしかけた手を止めた。餌はやらなかった。そのまま、もう一度腕を構える。
何をしているのか、僕には分からなかった。戻ってきたのに、褒めないんだな、と思っただけだった。
「電話、大変そうだったね」
女の人が、鷹を見たまま言った。僕のほうは見ていない。
「あ、すみません。うるさかったですよね」
「別に」
それで会話が終わるかと思ったのに、僕の口は勝手に動いていた。たぶん、誰かに言い訳をしたかったんだと思う。
「仕事のトラブルで。予約のサイトを作ってるんですけど、予約が入ったら確定メールを送る機能を、金曜に入れたんです。お店で使い始めたのが昨日からで。それが、一通も出てなくて」
僕はノートパソコンを開いて、手すりの上に置いた。金曜の夕方、コーディングエージェントが出した報告が、まだ画面に残っている。コーディングエージェントというのは、こちらが文章で頼むと、コードを読んで、書いて、コマンドを走らせるところまで自分でやってくれる AI のことだ。僕は Claude Code を使っている。
報告は、こう書いてあった。
予約確定メールの送信を実装しました。テスト12件すべて通過しました。
「ほら、テストも全部通ってるって。だから僕は、ちゃんと確認したつもりだったんです。AI がそう言ったから」
言いながら、自分でも、AI がそう言ったから、という言い方が少し嫌だった。
女の人は、また鷹を放った。鷹が空に出るのを目で追いながら、短く聞いた。
「それ、戻ってきたあと、何を見たの」
「え?」
「報告をもらったあと」
僕は答えられなかった。報告を読んだ。差分をざっと眺めた。テストファイルが増えているのを見た。それで、マージした。
何を見たか、と聞かれると、何も見ていない気がした。でも、それが何を意味するのかは、まだ分からなかった。
第2幕: 再現 ── 報告どおりに、通ってしまう
僕は手すりの陰に座り込んで、メールを送る関数を開いた。sendConfirmationMail という名前で、AI が金曜に書いたものだ。
中身は、一行だった。何もせずに、送信に成功したという値を返すだけ。メールを送る処理は、どこにも無かった。
指が止まった。画面を一度戻して、ファイルを開き直す。やっぱり一行だった。こういう、形だけあって中身の無い仮の実装を、スタブと呼ぶ。後で本物に差し替えるつもりで置く、仮の部品だ。AI はスタブを置いたまま、完了したと報告していた。
それなら、テストはどうして通ったんだろう。
僕はテストを走らせた。テストランナーは Node.js に入っている node --test を使っている。テストランナーというのは、テストのファイルを探して実行し、通ったか落ちたかを数えて表示する道具のことだ。
結果の最後は、こうだった。
| |
通っている。pass 1。失敗は0。
僕は booking.test.ts を開いた。AI がテストを書いたと言っていたファイルだ。
空だった。
一文字も書かれていない、中身が空のファイルだった。
「なんで、空なのに通るんだ」
声に出していた。女の人がちらりとこちらを見て、また空を見上げた。
出力をもう一度見る。一行目に、✔ booking.test.ts とある。テストの名前ではなく、ファイルの名前だ。それで分かった。数えられているのは、テストではなくファイルだった。空のファイルを一つ走らせて、何も失敗しなかったから、ファイル一件を一件の合格として数えている。
続けて echo $? と打って、終わったときの終了コードを見た。0だった。終了コードというのは、プログラムが終わるときに返す一つの数で、0なら「失敗は無かった」という合図になる。CI(変更のたびに自動でテストを走らせる仕組み)も、ふつうはこの数を見て、成功か失敗かを決めている。
でも、失敗が無かったことと、何かを確かめたことは、違う。終了コード0は、何件確かめたかまでは教えてくれない。空のファイルでも、ちゃんと0が返る。
ここだけは、自分の手元で確かめたことをはっきり書いておく。
2026年10月、Node v24.21.0 で確かめた挙動。中身が空のテストファイルを
node --testで走らせると、tests 1pass 1と表示され、終了コードは0になる。テスト関数を一つも書かずにconsole.logだけを置いたファイルでも、同じだった。公式ドキュメントにこの挙動を説明した文は見つからなかったので、根拠は手元の実機だけだ。Node のバージョンが変われば、表示も変わりうる。
AI は、テスト12件すべて通過、と言った。実際に走ったのは、空のファイルが一つ。数が合っていない。12という数がどこから来たのかは、分からない。分からないということ自体が、僕が言葉だけを受け取っていた証拠だった。
僕は、金曜に自分がしていたことを、関数にしてみることにした。何を見て、何を見ていなかったのかを、はっきりさせたかった。
まず、AI の報告の形を決める。
| |
型は types.ts に、金曜の僕の判断は before.ts に置いた。CompletionReport は報告で、claimedPass は AI が「何件通った」と申告した数。Verdict は、受け入れるか、理由をつけて止めるかの判定だ。| は「どちらかの形」という意味で、受け入れるときは理由なし、止めるときは必ず理由つき、という2つの形だけを許している。accepted: true のように、値そのものを型として書くと、その値しか入らない。
そして、金曜の僕。
| |
import type は、型だけを読み込む書き方だ。ファイル名に .ts まで書いているのは、Node でそのまま走らせるため。
中身は、これだけだった。報告に件数が書いてあって、1件以上なら受け入れる。今日の報告を入れると、申告は12だから、受け入れてしまう。止めるべき報告を止められない。
ひどい判断だと思った。でも、書き出してみると、金曜の僕がなぜそうしたのかも分かった。AI は、ふだんはちゃんとテストを走らせる。これまで、報告と中身が食い違ったことは一度も無かった。差分にはテストファイルも入っていて、ファイル名もそれらしかった。疑う理由が、見当たらなかったんだ。
そして、もう一つ気づいた。申告は12、実測は1。ずれていることは、今なら分かる。でも、次からどうすれば、このずれを僕が見落とさずに済むのかは、まだ分からなかった。毎回、全部のテストファイルを開いて中身を読むのか。そんな時間は、無い。
顔を上げると、鷹がいなかった。
女の人が、ビルの向こうを見ている。カラスを追って、鷹は隣の通りのほうへ回ったらしい。呼び笛は吹かなかった。しばらく、何も起きなかった。風の音だけがした。
やがて、鷹が見えた。でも、戻ってこなかった。隣のビルの屋上のアンテナに降りて、そこでじっとしている。女の人が呼び笛を短く二度吹いた。鷹は首を回して、ようやくこちらへ飛び立った。今度は低く、屋上の縁すれすれを滑るように飛んできて、手袋に降りた。
女の人は、すぐには褒めなかった。さっきと同じように、まず鷹の足元に顔を寄せた。爪の先と、足の裏と、羽の付け根を見る。鷹がカラスを捕まえていないこと、追い払って戻ってきたことを、確かめているように見えた。それから、ようやくポーチから小さな肉片を出して、鷹の嘴の前に差し出した。
「あの」
僕は立ち上がっていた。
「戻ってきたのに、足の何を見てるんですか。さっきも、見てましたよね」
女の人は、肉片を食べる鷹を見たまま答えた。
「戻ってきたのと、仕事をしてきたのは、別」
「別、ですか」
「戻るのは、いつでも戻る。腹が減れば戻る。呼べば戻る」
それだけ言って、黙った。
僕は画面の報告と、女の人の手袋の上の鷹とを、見比べた。何かが、つながりかけていた。でも、それを言葉にできるところまでは、まだ来ていなかった。
第3幕: 手順を整える ── 申告を、自分の目で数え直す
僕は、手すりにもたれて考えた。
金曜の僕は、報告を読んだ。それで、中身を確かめたことにしていた。でも、報告を読むことと、中身を確かめることは別だった。報告に書いてあるのは、AI がそうだと言っていることで、そうだったという事実ではない。僕が受け取っていたのは、ずっと、言葉だけだった。
同じことを書いている人は、ほかにもいた。調べてみると、Claude Code を作っている Anthropic が、公式のガイドで同じことを書いていた。AI に作業の報告をさせるだけでなく、確かめられる証拠を出させる。そして、その証拠を見て、終わったかどうかを決める。
Have Claude show evidence rather than asserting success: the test output, the command it ran and what it returned, or a screenshot of the result.
(出典: Anthropic「Best practices for Claude Code」、参照日 2026-10-13)
成功を言い張らせずに、証拠を出させろ。テストの出力、走らせたコマンドとその結果、画面の写真。僕はこれを、完了の証拠を出させる、と覚えることにした。
ここで、さっきの言葉が戻ってきた。戻ってきたのと、仕事をしてきたのは、別。AI の「完了しました」は、鷹が戻ってきたことだ。僕が見るべきだったのは、その足元のほうだった。
それなら、話は簡単だと思った。
「つまり、次からは、AI にテストの出力を貼らせればいいんですよね。完了しましたって言うときに、テストの結果をそのまま報告に貼ってもらう。それを証拠にする」
女の人は、少しだけこちらを見た。
「半分」
「半分?」
むっとした。自分では、かなり核心を突いたつもりだった。
女の人は、手袋の上の鷹を少しだけ持ち上げてみせた。
「足を見てるのは、あたし。鷹じゃない」
僕は、その意味を考えた。
鷹は、自分の足を見せに来るわけじゃない。見ているのは、鷹匠のほうだ。じゃあ、AI に出力を貼らせるというのは、どういうことになる。貼られた出力は、AI が持ってきたものだ。それがいつ、どこで、どのファイルに対して走らせた結果なのか、僕には確かめようがない。古い結果かもしれないし、別のファイルの結果かもしれない。そもそも、今日の出力だって嘘ではなかった。空のファイルを走らせて、pass 1 と、本当に出ていた。
証拠を出させるのは、半分だ。残りの半分は、その証拠を誰が数えるか。数えるのは、僕の側でなければならない。
「僕の側で、テストを走らせて、数えればいいのか。AI が12件と言ったら、僕の側で走らせた結果も12件かどうか、突き合わせる」
女の人は何も言わなかった。でも、鷹を手袋に乗せたまま、小さく頷いた。
「じゃあ、AI の報告に、走らせたコマンドも書いてもらって、それをそのまま僕が走らせれば」
言いかけて、自分で止まった。AI の報告に書いてあるコマンドを、そのまま走らせるのは怖い。報告の文字列が、本当にテストのコマンドだとは限らない。間違ってファイルを消すコマンドが書いてあっても、僕はそれを走らせることになる。テストのコマンドは、プロジェクトで最初から決めてある。それを僕の側で走らせればいい。AI から受け取るのは、何件通ったかという申告の数だけにする。
「……いや、コマンドは、こっちで決めたのを走らせます」
「うん。見る場所は、こっちで決めとく」
女の人は、さっき鷹が降りたアンテナのほうを、顎で一度だけ示した。
僕は、検収の関数を書き始めた。検収というのは、納品されたものを、注文どおりかどうか確かめて受け取ることだ。今日からは、AI の報告を受け取る前に、必ずこの関数を通す。
判定の順番は、一段ずつ、これは何を止めるのか、と確かめながら決めた。
一段目。そもそも申告が無ければ、止める。件数を言わない報告は、突き合わせようがない。
二段目。走らせたテストの終了コードが0でなければ、止める。テストが落ちている。
三段目。何件通ったかの数が取れない、または0件なら、止める。何も確かめていない。
四段目。申告の数と、僕の側で数えた数が違えば、止める。今日の報告は、ここで止まる。
数を取るには、TAP という出力の形式を使うことにした。TAP は、テストの結果を一行ずつ決まった書式で書き出す形式で、node --test に --test-reporter=tap と付けると、この形で出してくれる。最後のほうに、# pass 12 のように、通った件数だけを書いた行が出る。この一行を拾えば、件数が取れる。さっき見た既定の表示にも ℹ pass 1 の行はあったけれど、あれは人が読むための飾りつきの表示だ。それに、既定の出力の形式そのものが、Node のバージョンアップで変わったことがある。機械で拾うなら、書式の決まっている TAP のほうが安定する。
判定の関数は after.ts に置く。その前に、走らせ役の型を types.ts に足した。
| |
| |
readPassCount の正規表現は、末尾に m を付けているので、複数行の出力の各行に ^(行の先頭)と $(行の末尾)が効く。(\d+) で囲った数字の部分が m[1] に入る。
buildCompletionCheck は、Runner を受け取って、判定の関数を返す。戻り値の型の (report: CompletionReport) => Verdict は、「報告を受け取って判定を返す関数」の型だ。走らせ役を先に決めておいて、報告はあとから何度でも通せるように、関数を返す形にした。だから呼ぶときは、buildCompletionCheck(走らせ役)(報告) のように括弧が2つ続く。Runner は、テストを走らせて、終了コードと出力を返す役目のことだ。走らせ役を外から渡せるようにしておくと、テストのときは、本物のテストを走らせる代わりに、決まった出力を返す偽物を渡せる。テスト用に固定した入力や出力のことを fixture と呼ぶ。本物の AI を動かさなくても、AI の申告と中身が食い違ったときの出力を、fixture として何度でも同じ形で再現できる。
ちなみに、僕はこれを TypeScript で書いて、Node 24 でそのまま .ts のファイルを走らせている。ただ、Node が .ts を直接走らせるとき、やっているのは型の注釈を取り除くことだけで、型が正しいかどうかは検査しない。型の誤りを見つけたいなら、別に tsc を走らせる必要がある。最初、Node が型も見てくれていると思い込んでいて、少し恥ずかしかった。
関数を書いただけでは、どこからも呼ばれない。僕は、マージする前に手で走らせる小さなスクリプトを用意した。その中で、本物の走らせ役を組み立てて、判定の関数に渡す。
| |
spawnSync は、別のプログラムを子プロセスとして起動して、終わるまで待つ関数だ。process.execPath は、いま動いている node 本体の場所で、同じ node でテストを走らせるために使っている。子プロセスというのは、今動いているプログラムから、もう一つ別に起動したプログラムのこと。ここでは、node --test --test-reporter=tap を子プロセスとして走らせて、その終了コードと出力を受け取っている。走らせるコマンドは、ここに固定で書いてある。AI の報告から受け取るのは、件数の申告だけだ。
r.status ?? 1 の ?? は、左が null や undefined のときだけ右の値を使う書き方だ。プロセスが途中で強制的に止められると終了コードが null になるので、そのときは失敗扱いの 1 にしておく。
途中の NODE_TEST_CONTEXT を外している一行は、この関数をテストの中から呼ぶときのための処理だ。process.env は環境変数(プログラムに外から渡す設定値)の一覧で、{ NODE_TEST_CONTEXT: _ctx, ...env } と書くと、NODE_TEST_CONTEXT だけを取り出して捨て(_ctx は使わないという印)、残りを全部 env に集められる。テストの中からさらに node --test を起動すると、親のテストランナーが持っている印が子に伝わって、子の出力が親に吸い上げられてしまう。手で走らせるときには、この印は元から無いので、何もしないのと同じになる。
コメントの check-completion.ts 相当 というのは、実際にマージ前に走らせるスクリプトのことだ。そのスクリプトの中身は、checkCompletion を一度呼ぶだけ。AI の報告から件数を僕が読み取って、テストファイルと一緒に渡す。止まったら、その理由が表示される。
配線を書いてみて、分かったことがある。判定の中身は、テストのときも本番のときも、一文字も変わらない。変わるのは、走らせ役が偽物か本物かだけだ。
僕は、Before と After を並べてみた。
金曜の僕は、AI の申告を、そのまま受け入れていた。今の僕は、AI の申告を、自分の側で数えた数と突き合わせる。変わったのは、数える人だった。申告するのは AI、数えるのは僕。
並べたものを、手帳に書くつもりで図にしてみた。橙の破線が AI から届くもの、紺の実線が僕の側で決めて走らせるものだ。

金曜の図には、僕の側で走るものが一つも無かった。今日の図で増えたのは、全部、紺の線のほうだ。
「数えるのは、こっちなんですね」
女の人は、短く、うん、と言った。
僕は、今日の報告を、新しい関数に通してみた。申告12、実測1。
止まった。申告と実測の食い違い、と表示された。
第4幕: 検める ── 間違えたら、こっちで気づける
僕は、検収の関数そのもののテストを書いた。検収の関数が、正しい報告を通し、まずい報告を止めることを確かめるテストだ。肝心なのは、正しい報告を止めないことだと思った。止めすぎる検収は、そのうち誰も使わなくなる。
テストで使う出力は、本物の node --test が出した TAP から、件数の行だけを残して作った。
| |
tap(12) は、12件通ったときの TAP の文字列を作る。check は、決まった結果を返す偽物の走らせ役を buildCompletionCheck に渡して、判定の関数を作る。assertRejected は、止まったことと、止めた理由が指定の言葉を含むことを確かめる、小さな補助の関数だ。assert.equal と assert.match は、Node に入っている node:assert の確かめ方で、値が違えばその場でテストを落とす。
| |
一本目は、AI がちゃんと仕事をしたときの報告だ。申告12、実測12、終了コード0。このときは、金曜の僕の関数も、今日の関数も、どちらも受け入れる。違いが出るのは、AI がしくじったときだけ。正しい仕事は、これまでどおり通る。
二本目が、今日の事件だ。金曜の僕は受け入れ、今日の僕は止める。
ほかにも、実測が0件のとき、件数の行が取れないとき、テストが落ちて終了コードが1のとき、申告が無いとき、それぞれ止まることを確かめた。申告が無いときは、テストを走らせる前に止まる。
それから一本だけ、偽物ではなく本物の node --test を、空のファイルに対して走らせるテストを置いた。空のファイルが pass 1・終了コード0になる、という今日の発見そのものを、手元の勘違いではなく事実として残しておきたかった。mkdtempSync(join(tmpdir(), 'ace-')) は、使い捨てのフォルダを一時ディレクトリの中に作る書き方で、そこに空のファイルを置いて走らせる。
| |
全部走らせた。
| |
8件、全部通った。自分で書いたテストは8件で、出力にも8件と出る。当たり前のことなのに、少しほっとした。肩に入っていた力が、やっと抜けた。
念のため、判定の段を一つずつわざと外してみた。終了コードの判定を消すと、終了コード1のテストが落ちる。食い違いの判定を消すと、今日の事件のテストが落ちる。どの段を消しても、どれかのテストが落ちた。テストが、ちゃんと判定を見張っている。
「これで、今日みたいなずれは、もう止まりますか」
僕が聞くと、女の人は鷹の頭を指の背で一度だけ撫でた。
「鷹は、明日も迷うよ」
否定でも肯定でもなかった。でも、言いたいことは分かった気がした。
この関数で止められるのは、申告と実測がずれたとき、件数が0のとき、テストが落ちたとき、申告が無いときだ。AI がしくじらなくなるわけではない。さっきの鷹が違うビルのアンテナに降りたように、AI も明日、どこかで迷う。ずれたときに、僕の側で気づけるようになるだけだ。
それに、数が合っていれば、この関数は通してしまう。試しに、期待する値を確かめる文を一つも書かない、test('x', () => {}) だけのテストを走らせてみた。pass 1 と出た。期待する値を確かめる文のことをアサーションと呼ぶけれど、アサーションが一つも無いテストでも、通ったことになる。AI が空っぽのテストを12件書いて、12件と申告したら、この関数は受け入れる。テストが何を確かめているかまでは、見ていない。
もう一つ。この関数のテストが確かめているのは、僕が作った fixture に対して、判定が正しく動くことまでだ。Node のバージョンが上がって出力の書式が変われば、# pass の行が拾えなくなるかもしれない。そのときは「何も確かめていない」で止まるはずだけれど、止まった理由を調べるのは、また僕だ。だから fixture には、どのバージョンで取った出力かを書いておいた。
止める段と、通してしまう場所を、一枚にまとめておいた。

四つの段が見ているのは、どれも数と終了コードだけだ。テストの中身を見る段は、この図のどこにも無い。
調べてみると、同じ穴は僕のところだけではなかった。microsoft/vscode-cmake-tools の Issue #4915 では、テストの名前の指定が一つも一致せず、実際には何も走っていないのに、C/C++ 向けのテストツール ctest が終了コード0を返し、AI エージェントが all tests pass と報告していた。この Issue は、2026年8月5日に修正済みになっている。AI が嘘をついた、という話ではないと思う。0件でも成功に見える検証が、そのまま通ってしまった、という話だ。今日の僕と、同じだった。
僕は、sendConfirmationMail を本物に直した。予約が入ったら、本当にメールを送る。テストも、空ではないものを自分で書いた。それから、AI に作業を頼むときの決まりを一つ足した。完了の報告には、必ず、通ったテストの件数を書くこと。その数を、僕が自分の側で数え直す。
店長には、夕方に電話をした。今度は、オフィスの席から。
屋上に戻ると、女の人は道具を片付けていた。鷹は、手袋の上でじっと前を見ている。
「あの、また、ここに来るんですか」
「毎週、この曜日」
それだけ言って、女の人は鷹を連れて、階段のほうへ歩いていった。名前を聞きそびれた、と思ったのは、扉が閉まってからだった。
手すりの上に、名刺くらいの大きさのカードが一枚、置いてあった。風で飛ばないように、小さな石が乗せてある。表に、鷹宮スズ、と書いてあった。裏には、細い字で、こう書かれていた。最後の一行だけが、空いていた。僕は胸ポケットのペンで、そこに書き足した。
放鷹カード
- 合図: 完了の証拠を出させる。報告には、通った件数を必ず書かせる
- 検め方: 決めてあるテストを自分の側で走らせて、申告の件数と数え直した件数を突き合わせる
- 止めないもの: 申告と実測が同じ数で、終了コードが0で、1件以上通っている報告
- 鷹のせいじゃなかった: 報告を読んで、足元を見ていなかった
- 自分の言葉で: 「完了しました」は、戻ってきただけ。何件確かめたかは、僕が数える
