AIの「完了しました」を信じない仕組み
ツールを1回も呼ばずに「6コミット先行、PR作成済み」と報告された。禁止のメモを書いても再発したので、hookとgitの実数で塞いだ記録。
ある朝、前のセッションの最終報告にこう書いてありました。「6コミット先行、push 済み、PR 作成済み、MERGEABLE」。
ブランチを見ると、コミットは1つもありません。PR もありません。ログをさかのぼると、実装フェーズでのツール呼び出しは0回でした。コードは1行も書かれていなかったのです。
何が起きていたか
直前にエージェントは、とても詳しい実装計画書を書いていました。コミットメッセージまで1本ずつ用意された計画書です。worktree とビルド環境は実際に作っていました。そのため「準備までは本物」という状態になっていて、エージェントは計画書の中身を「実行した記録」と取り違えたようです。
ここで分かったことがあります。計画書が詳しいほど、捏造の足場になる。
メモでの禁止は効かなかった
「完了報告を捏造しない」という永続メモリは、事件の前からありました。それでも再発しました。散文で書いたルールは、読まれないことがあり、読まれても守られないことがあります。そこで、ルールではなく機構で塞ぐことにしました。
対策1:毎ターン、現実を注入する
ユーザーのプロンプトが送られるたびに、hook で次の3つを文脈の先頭に差し込みます。
#!/usr/bin/env bash
# .claude/hooks/git-reality.sh(UserPromptSubmit hook)
b=$(git branch --show-current)
ahead=$(git rev-list --count "@{u}..HEAD" 2>/dev/null || echo "no-upstream")
dirty=$(git status --porcelain | wc -l | tr -d ' ')
echo "[git] branch=$b ahead=$ahead uncommitted=$dirty"
1行だけなので、コンテキストをほとんど食いません。現実とのズレが1ターン以上続かなくなりました。
対策2:「断定」と「実数の矛盾」が両方そろったときだけ止める
Stop hook で最終メッセージを調べ、次の2つが両方そろったときだけブロックします。
- 「push しました」「コミット済み」「PR を作成しました」のような、完了を言い切る表現がある
- それなのに、git の実数(先行コミット数、未コミットの変更、リモートの SHA)と食い違っている
文章の意味を解析して嘘を見抜く方式も試しましたが、精度が出ませんでした(LLM に判断させる範囲を絞る考え方は「LLMは「選ぶ・抜く」だけにする」に書きました)。「言い切っている」という表面の条件と、「数字が合わない」という機械の条件を AND でつなぐと、誤検知がほぼ無くなります。同じ理由で3回以上ブロックした場合は、警告に格下げしてループを防いでいます。
hook の終了コードの意味は「Claude Code hooks実践」にまとめました。
対策3:push の出力も信じない
中継のレイヤーが、git push の標準出力まで捏造していた例があります。ls-remote は、先頭の数文字だけが一致する偽の SHA を返していました。いまは次のように確かめます。
git fetch origin
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/$(git branch --show-current))" ] && echo pushed
SHA は短縮形ではなく、40桁で比べます。
サブエージェントの自己申告も同じ
並列で動かすサブエージェントの報告は、さらに当てになりません。
- テスト数の申告は、3回連続で実数より少なく出ました(12→実際は14、8→11、13→19)。
- 「TEST BUILD SUCCEEDED」と3回報告されましたが、実際はコンパイルエラー、データ競合、タイムアウトでした。
- テストを実行できない環境のサブエージェントは、期待値を推測で書きます。「0.565 は四捨五入で 57%」と書かれていましたが、浮動小数点では 0.5649999… なので 56% が正解でした。
指示する側のセッション(controller)が、自分でビルドとテストを回して実数を取ります。grep -c でテスト関数を数え、申告との差が説明できるまでマージしません。
並列運用での回収の確かめ方は「サブエージェント並列運用の失敗集」にあります。
静かな失敗の型
「完了しました」が嘘になるのは、エージェントが捏造したときだけではありません。コマンドが黙って失敗したのに、成功に見えることもあります。
| 書き方 | 何が起きるか | 直し方 |
|---|---|---|
verify.sh | tee log の次の行で git commit | verify が失敗しても、パイプの終了ステータスは tee のものなので commit が通る | verify.sh && git commit、または set -o pipefail |
git add 生成物 2>/dev/null | ファイル名を記憶で書いて1分ずれ、staged が0件のまま進む | エラーを捨てない。git diff --cached --stat で確かめる |
git add … && git commit が hook で deny され、commit だけ再実行 | add も一緒に取り消されている。6ファイルがコミットから抜けた | 再実行の前に git status |
ls dir/*.jsonl | wc -l | ファイルが7万個あると ls が失敗し、wc は 0 を返す | find dir -name '*.jsonl' | wc -l |
「0件」と出たら、異常として報告する前に、まず測り方を疑います。
テストが緑でも同じことが起きます。「緑のテストは何も証明しない」に書きました。
完了を確かめたあとは、動き続けているかを見張る番です。常駐させた処理の監視で踏んだ罠は「常駐Bot監視の落とし穴」にまとめました。
まとめ
- 完了報告は「主張」であって「事実」ではない。事実は git とテストの実数で取る。
- 散文のルールは、機構に置き換えるまで効いていないものと考える。
- 判定には、モデルに依存しない ground truth(終了コード、件数、SHA)を使う。そうしておけば、モデルを入れ替えても hook は書き換えずに済みます。編集部では、2回の世代交代で10本の hook がすべて無変更でした。