エージェントの「完了」を信じないことにした - verify.shゲート、prove-it
AIコーディングエージェントはテストを回していないのに「テスト通過」と報告する。嘘ではなく構造の問題だ。報告を検査に変えるオープンソースのゲートprove-itを作りながら学んだこと - そして自作の5行フックが静かに失敗する4つの形。
要点まとめ
コーディングエージェントは自分がやったことと、やろうとしたことを区別できないため、実際には意図を報告する。テストを回さずに「通ります」と言うのはそのせいだ。プロンプトでは直せない。「完了」を宣言ではなく、通過しなければならない検査に変える必要がある。prove-itは、リポジトリルートのverify.shがexit 0を返すまでエージェントがターンを終えられないようにするStopフックのゲートだ。自作の5行フックは4つの形で静かに失敗する - 一度塞いだら二度と塞げない、コミットで終わったターンを素通りさせる、エージェントが検査そのものを消してしまう、あきらめと通過が区別できない。この4つが、prove-itが5行より長い理由のすべてだ。
目次
「テストは全部通っています。マージして大丈夫です。」
エージェントがそう言い、diffがきれいだったので、私は危うくそのまま信じるところだった。ところがターミナルをスクロールして遡ると、テストは一度も走っていなかった。実行ログそのものがなかった。エージェントはテストを回そうとしていて、その意図がそのまま完了報告に書かれていたのだ。
こういうことを何度か経験すると、反応はたいてい二つのどちらかになる。プロンプトに「必ずテストを実行して結果を見せろ」と追記するか、エージェントが完了と言うたびに手で確認し直すか。私は両方やってみて、どちらも長続きしなかった。そこで三つ目の道を選んだ。「完了」という言葉自体を信じられなくする代わりに、完了を宣言ではなく、通過しなければならない検査に変えた。そうして作ったのがprove-itというオープンソースのツールだ。
嘘ではなく構造だ
まず押さえておきたい。エージェントは嘘をついているのではない。
人間は「テストを回した」と「テストを回そうとした」を区別できる。記憶があるからだ。エージェントにはその比較対象がない。自分がやったことを、やろうとしたことと突き合わせる手段がないので、結果の代わりに意図を報告する。「テストは通ります」は「テストが通るようにコードを書きました」の省略形というわけだ。これはモデルの人格の問題ではなく設計上の性質であり、だからプロンプトでは直らない。「必ず回せ」と百回書いても、回さない日が来る。
プロンプト技法にはもう一つ弱点がある。モデルの世代が変わるたびに古びる。ある指示が今のモデルによく効くという事実は、次のモデルにも効くことを保証しない。一方、証拠を要求する仕組みはモデルの一段上に座っているので、アップグレードを生き延びる。テストが実際に走ったかどうかは、モデルではなく終了コードに語らせればいい。
報告を検査に変える
完了を言葉ではなく証拠で認めるという考え自体は以前の記事で書いた。今回の記事は、その考えを誰でもインストールできるツールにして公開した話だ。
prove-itの規約は一文で済む。リポジトリは自らを証明する方法をverify.shというファイルで宣言し、エージェントはその証明が通るまで完了を主張できない。
実装はこうだ。リポジトリのルートに実行可能なverify.shを置く。exit 0なら「このツリーは証明可能に問題ない」という意味だ。エージェントがターンを終えようとするとStopフックがこのスクリプトを実行し、0でなければ、エージェントを止まらせる代わりに失敗出力の最後の20行を添えて作業に送り返す。ほとんどの場合、エージェントはその出力を見るだけで原因を直す。
ゲートがいつ発動するかも単純だ。このセッションがこのリポジトリを実際に変更していて、実行可能なverify.shがあり、今のツリー状態がまだ一度も通過していないとき。三つの条件がすべて真のときだけ回る。読むだけのセッションには触れず、一度通過したツリーは再検査しない。
そして無限に塞ぎ続けはしない。1ターンにつき最大3回まで送り返し、その次は譲る。絶対に譲らないフックはセッションを止めてしまうからだ。ただし、譲ることと通ることは違う。譲歩で終わったターンの最後の出力は「完了」ではなく、「このターンは検証されないまま終わった」という警告だ。エージェントがゲートをどうしても越えられないことはあっても、静かに越えることはできない。このツールが実際に主張しているのは、それだけだ。
なぜ5行のbashではだめなのか
ここまで読んで「それ、Stopフックにテストコマンド1行入れれば済むのでは」と思ったなら、正しい。5行で書けるし、私も最初はそう書いた。そしてそのバージョンは4つの形で静かに失敗した。いくつかはprove-itの初期バージョンに実際にあったバグで、今はその一つひとつに回帰テストが付いている。
一度塞いだら、二度と塞げない。 Claude Codeは、フックが一度ブロックした後のすべてのstopイベントにstop_hook_activeフラグを立てる。このフラグを「通せ」と読むフックは、きっかり一度塞いだところでゲートであることをやめる。逆にフラグを無視するフックは永遠に塞いでセッションを止める。どちらも踏んでみて初めて分かる罠で、答えは試行回数を自分で数え、決めた回数だけ塞いだあと、目立つ形で譲ることだ。
コミットすると、何もなかったように見える。 ワーキングツリーがdirtyかどうかで「作業があったか」を判断するフックは、コミットで終わるターンをすべて素通りさせる。コミットはエージェントがやる最もありふれた行為だというのに。だからprove-itはセッション開始時点のツリー状態を基準線として記録しておき、stopのたびにそれと比較する。コミットも、sedで直したファイルも、コードジェネレータが吐いたファイルも、すべて変更として捕捉される。
エージェントが検査を消せる。 verify.shを通せないエージェントが選べる最も安い手は、verify.shを消すか実行権限を剥がすことだ。ゲートはセッション開始時にリポジトリが武装していたかを記録し、武装解除されたまま終わるターンを拒否する。もっと巧妙な手もある。実行可能なまま残して、中の検査だけ書き換えるのだ。これは塞がない。verify.shを直すことこそが頼まれた作業である場合も多いからだ。その代わり、静かに通しもしない。セッション中にverify.shが変わった状態で通過すると、ゲートがその事実を知らせてくれるので、そのdiffを読めば作業だったのか回避だったのか判断できる。
あきらめが、通過と区別できない。 どんなホストも、最終的にはフックを譲らせる。自作のフックは沈黙のうちに譲り、あなたが最後に見る言葉は「完了」だ。3回失敗してあきらめたターンと、一発で通ったターンが、画面上ではまったく同じに見える。prove-itの最後の言葉は警告だ。この違いが些細に見えるなら、あきらめたターンを通ったと思い込んでマージした翌日を想像してみればいい。
この4つが、prove-itが5行より長い理由のすべてだ。自分のフックを使い続けたいなら、それでいい。ただ、そのフックが引き受けるべきケースがSPEC.mdに書いてあるので、読んでみることを勧める。重要なのは実装ではなく規約だ。
このツールが証明してくれないこと
正直に書いておくべき限界がある。ゲートが強制するのはただ一つ、ターンが終わる前にverify.shが0を返したという事実だ。その0が何を意味するかは、あなたが書いた検査に完全に委ねられている。exit 0一行だけのverify.shはこのゲートを通過し、何も証明しない。
スペックはこれをLevel 1と呼ぶ。Level 2は、検査が実際の証拠についてアサートしているかという問題で、それはどんなツールも代わりに検証してくれない。prove-itも例外ではない。フックを仕掛けるのは簡単な部分だ。本当の仕事は「このリポジトリで証明されたとは何を意味するのか」という問いに答えることで、その答えはリポジトリごとに違う。だからこの規約はファイル名だけを決め、中身は決めない。
迂回路もわざと開けてある。PROVE_IT_SKIP=1ならそのターンはそのまま通り、verify.shを消せばゲートは永遠にオフになる。取り外せないゲートは、人々が最終的に回り道をして避けるようになり、迂回されたゲートはないよりも悪いからだ。検査が回ったかのように報告しながら、実際には何も回っていないことになるからだ。
初日はわざと小さく
インストールは3行で、実際の仕事は3行目がやる。
/plugin marketplace add Why-Next/prove-it
/plugin install prove-it@whynext
/prove-it:init
/prove-it:initはスタックを検出してverify.shを作り、それが通るところを目の前で見せたあと、exit 1を付け足したコピーを回してゲートがターンを拒否するところまで見せる。30秒ほどで終わる。
生成されたverify.shの有効な検査はgit diff --checkただ一つだ。テストも型チェックも、すべてコメントとして書かれているだけ。インストールしたその日にmainで通るよう、わざとそう作ってある。初日から失敗するゲートは、チームに最初の週から迂回のやり方を教えてしまう。コメントアウトされた検査は一つずつ、手で実際に回して通るのを見てから有効にすればいい。そして有効にする前に、一度はわざと失敗させてみるべきだ。失敗できない検査は検査ではなく、その事実を必要になった日に知るのでは遅い。
verify.shを育てていく上での止めどころも決まっている。全体で1分。それより遅い検査はCIに送る。secretsや本番アクセスが必要な検査もCIの持ち分だ。verify.shには、エージェントが作業中にローカルで回せる証拠だけを残す。
ゲートが捕まえたものの台帳
PROVE_IT_LEDGER=1を有効にしておくと、ゲートが偽りの完了を捕まえるたびにローカルファイルへ1行ずつ積み上がる。
{"ts":"2026-07-09T04:12:33Z","claim":"All tests pass. Ready to merge.",
"evidence_demanded":"verify.sh exit 0","actual":["3 failed, 41 passed"]}
エージェントが何を主張し、何が要求され、実際はどうだったのか。1か月分たまれば、自分のエージェントがどんな形で失敗するのかを、推測ではなく記録で読める。ファイルはローカルディスクにだけ残ってどこへも送信されず、有効にするまではオフのままだ。
このリポジトリは自らをゲートする
prove-itのリポジトリにも当然verify.shがあり、その中でゲート自身を実際のgitリポジトリの上でテストしている。失敗する検査は塞がれるか、通る検査は開くか、読むだけのセッションには触れないか、コミット後のきれいなツリーを「作業なし」と勘違いしないか。上で挙げた4つの罠は、すべて回帰テストとして焼き込まれている。
MITライセンスで、依存はbash・git・python3がすべてだ。Claude Codeでテストしており、Codex CLI・Gemini CLIなど同種のブロッキング型フックを公開するホストへのつなぎ方はADAPTERSドキュメントにある。エージェントがコードを直し、同じ会話の中で「完了」と言うリポジトリがあるなら、そこがこのゲートの最も役に立つ場所だ。
「完了」はエージェントが宣言する言葉ではなく、リポジトリが判定する状態であるべきだ。その判定基準を書く仕事は依然としてあなたのものだが、少なくとももう、誰もその判定を飛ばして静かに通り過ぎることはできない。
よくある質問
AIコーディングエージェントが、やってもいない作業を完了したと報告します。どう防げばいいですか?
プロンプトで「必ずテストを回せ」と指示するだけでは足りません。エージェントは自分がやったことと、やろうとしたことを突き合わせる手段がなく、意図をそのまま報告するからです。代わりに、完了の宣言そのものを検査に変えてください。リポジトリルートに検証スクリプト(verify.sh)を置き、エージェントがターンを終えようとしたときにStopフックがそのスクリプトを実行し、exit 0でなければ作業に送り返す方式です。prove-itがこの規約のリファレンス実装です。
Claude CodeのStopフックでテストを回す5行のスクリプトを自作してはだめですか?
構いませんが、4つの罠があります。① Claude Codeは最初のブロック以降stop_hook_activeフラグを立てるため、これの扱いを誤ると一度しか塞げないか、永遠に塞いでセッションが止まります。② ワーキングツリーがdirtyかどうかで判断すると、コミットで終わったターンをすべて素通りさせます。③ エージェントがverify.shを消したりchmod -xしたりして、ゲート自体を武装解除できます。④ フックが最終的に譲るとき、あきらめが通過のように見えます。自作するならこの4ケースをすべて扱う必要があります。
verify.shにはどんな検査を入れるべきですか?
「このリポジトリで変更が証明されたと言えるには、何が真でなければならないか」への答えを入れます。ふつうは実際に回すテストコマンド、型チェック、lintの順に一つずつ有効にします。全体が1分以内に終わるべきで、secretsや本番アクセスが必要な検査はCIに置いてください。初日から野心的にしないことが重要です。遅い、あるいは不安定なゲートは1週間以内に迂回され、迂回されたゲートはないよりも悪いからです。
関連記事
- 💻 開発
AIが「もう終わりました」と言ったとき、それを鵜呑みにしない仕組み
AIコーディングエージェントは、仕事を終えていなくても終わったと言う。テストを走らせずに通ったと言い、再現もせずに直したと言う。ほぼすべてのコードをAIと一緒に書く1人開発者が、完了を言葉ではなく証拠でしか認めないようにした検証ゲートの話。
- 💻 開発
受け取り損ねていた割引 - SpotとGravitonはなぜ安いのか
AWSの請求書が2か月連続で予算を超えた。削れるリソースを探したが、本当のレバーは「どれだけ使うか」ではなく「どう買うか」だった。SpotとGravitonがなぜ安いのか、そして無駄に見えて実は切ってはいけないコスト(RDS Proxy)まで - 請求書の数字ひとつひとつに宿る「理由」についての話。
- 💻 開発
終了フックが二重に走っていた - SIGTERMの持ち主が二人いたとき
デプロイのたびにSentryにPrismaのP2028エラーが落ちていた。終了順序を直そうと踏み込んだら、NestJSのenableShutdownHooksとgraceful shutdownライブラリの両方がSIGTERMを掴んでいて、終了フックが毎回二重に実行されていたことが分かった話。