イントロ: 完了を拒むゲート
Shopifyの言うゲートとは、AIが書いた変更が次へ進む前に通らなければならないチェックのことです。これをClaude Codeで作り直したところ、効果があったのは最初の1つ、受け入れテストだけでした。Shopifyは4つのゲートを公開しましたが、数字は出していません。そこでこの実験では、実在のリポジトリ上に4つのゲートを組み、ゲートを1つずつ外しながら50件のリクエストを実行し、それぞれが何を捕まえ、いくらかかったかを数えました。
Helixは2026年9月21日に公開されました。すべてのチェックポイントを、テスト、設計レビュー、2人のレビュアー、エンジニアの順に4つのゲートへ通します。投稿に合格率は書かれていません。レビュアーは多くの人が真似する部分ですが、この50回の実行で合格率が動いたのは、テストがループに入っているときだけでした。レビュアーは何も上乗せせず、コストはおよそ3倍でした。
同じベンチから、ほかに2つのことが分かりました。上書きできないはずのhookには、公式ドキュメントの中に3つの出口があります。そして、テストとレビュアーの意見が食い違ったとき、エージェントは誰も想定していなかった行動を取りました。数え始めるのは、ゲートがまったくない場合からです。
4つのゲート、数字はゼロ、ベンチは1つ
ベンチのリポジトリは実在のドキュメントデータベースtinydbで、使ったコミットではテスト226件がすべて通っています。
HelixはShopifyがモバイルアプリをネイティブコードへ移行するための社内ツールです。投稿はルールをはっきり書いています。各チェックポイントは、4つのゲートを順に通ります。
| ゲート | Shopifyの投稿での役割 |
|---|---|
| Behaviour | 関係するすべてのケースが通らなければならないテストを実行する |
| Design | 2つ目のモデルに、完璧主義のデザインレビュアーを演じさせる |
| Review | 互いに独立し、コンテキストも分離された2つのレビュアーagent。両方が承認するまでループを繰り返す |
| Engineer | 人間が最後を締める |
この設計を売り込む一文は、「An attempt is allowed to be wrong. It is not allowed to ship until it isn't.」です。ライブラリではデザインゲートは不要になり、投稿もロジックだけの変更ならこのゲートは単にスキップされると述べています。
投稿が一度も出さないのが数字です。Shop appは12週間で作り直され、Shopifyアプリには300を超える画面がある。台帳に載っているのはそれだけです。合格率も、コストも、すり抜けた欠陥の数もありません。
ベンチは、開発者が普段打つ形のリクエスト5つです。push/pull操作、betweenクエリ、テーブル名の変更、ソート付き検索、書き込みキャッシュサイズ。それぞれのリクエストの裏に、エージェントには見えない隠し要件が5つあります。経験のあるシニアエンジニアなら当然期待する内容で、合計25の要件になります。
ワーカーはSonnet 5.5で、ヘッドレス実行です。5つの構成に5つのタスクと2回の反復を掛けて、50回の実行になります。注意点が1つあります。これらのリクエストを明示的に書いた版は一発で通るため、プロンプトは人が実際に打つ長さまで短くしました。
ゲートがまったくない場合、50の要件のうち35が通ります。70%で、完全にクリーンだった実行は10回中3回です。
ソートのタスクを見ると、失敗がどういうものか分かります。エージェントはすべてのドキュメントをソート用フィールドでインデックスしており、そのフィールドを持たないドキュメントが1つでもあると検索全体がクラッシュします。5つの要件のうち通ったのはゼロで、原因はキー欠落が1つだけでした。
完了を拒むhookと、その3つの出口
Stop hookは、エージェントがターンを終えようとするたびにClaude Codeが実行するスクリプトです。Anthropic自身のガイドも、この仕組みが対処する問題を挙げています。Claudeは、作業が終わったように見えた時点で止まる。実行できるチェックがなければ、「終わったように見える」が唯一の手がかりで、あなた自身が検証ループになります。
hookはプロジェクトの設定ファイルのhooks、Stopの下に、タイムアウト付きのコマンドとして置きます。ここではゲートのスクリプトを呼ぶ1行のラッパーです。契約は終了コードです。0ならターンを終えられ、2なら止められ、スクリプトが標準エラーに出力した内容は理由としてエージェントに返ります。
実際にはこう動きます。エージェントが変更を要約して止まる。hookが受け入れテストを実行し、1件失敗して、ターンは終わらない。エージェントは失敗をhookのフィードバックとして読み、今回は型エラーを期待したテストがエラーを得られなかったケースでしたが、コードを直して再び止まる。今度はゲートは静かです。
プロンプトの中のルールは助言にすぎません。hookは違います。それでも、同じドキュメントのページには出口が3つ載っています。
| 出口 | ドキュメントの記述 |
|---|---|
| 上限 | stop hookが連続8回ターンを継続させると、Claude Codeは次のブロックを無視してターンを終える。カウントはエージェントがツールを呼ぶたびにリセットされ、環境変数で上限を上げられる。 |
| タイムアウト | commandフックの既定は600秒で、タイムアウトしたhookは判断を返さない。遅いテストスイートは、それだけでゲートを開けてしまう。 |
| フラグ | hookの入力にはstop_hook_activeが含まれ、セッションがすでにstop hookのために継続している場合はtrueになる。解決できない条件でブロックし続けないよう、これを確認することがドキュメントで勧められている。 |
ここで作ったゲートは、このフラグをログに残したうえで無視します。ループを数えられるように、ブロック6回で自分から諦めます。
ドキュメントにない出口がもう1つあります。一度ブロックされたあと、エージェントはテストを探しに行きました。hookのスクリプトにcatをかけ、ディスク全体からテストファイルを検索したのです。止めたのはallow listだけでした。そのため受け入れテストはリポジトリのツリーの外に置き、設定ではhookと隠しテストの読み取りを拒否しています。エージェントには失敗が見えても、ファイルは見えません。
ゲート1: 受け入れテスト
受け入れテストのゲートは、実装者とは独立に書かれた一連のテストで、「完了」の意味を定義します。Shopifyの最初のゲートは、エージェント自身のテストではありません。サブエージェントがチェックポイントごとに統合テスト風のケースを生成し、ハッピーパスの外にあるエッジケースまで攻めます。そのテストが、何をもって証明済みとするかを決めます。
そのためここでの書き手は、別のヘッドレスSonnetです。変更前のリクエストとリポジトリを読み取り専用で見られ、コードは実行できず、経験のあるシニアが期待する振る舞いごとに1つテストを書くよう指示されます。対象は、欠落フィールド、誤った型、境界値、永続化、キャッシュ、既存の挙動が変わらないことです。書かれるテストは23から53件、平均39件で、所要時間は約40秒です。ファイルはリポジトリの外に置かれるので、テストが失敗したときエージェントが読むのはアサーションだけで、テストそのものは読めません。
テストだけで合格率は70%から84%になります。50要件のうち42が通り、完全にクリーンな実行は3回から6回に増えました。
| タスク | テストなし | テストあり |
|---|---|---|
| ソート付き検索 | 10回中5 | 10回中10 |
| 書き込みキャッシュサイズ | 10回中8 | 10回中10 |
| push/pull | 10回中4 | 10回中4 |
ソートのタスクにあったフィールド欠落のクラッシュは、まさに書き手が尋ねるタイプのケースです。キャッシュのタスクでは、書き手はキャッシュサイズが1未満なら値エラーにすることを求めました。
push/pullのタスクは動きません。満たせない要件が3つあり、そのうち2つはフィールドを持たないドキュメントに関するもので、リクエストにはそれについて何も書かれていませんでした。リクエストが沈黙している部分で、書き手は曖昧にします。そのテストは例外も黙って何もしないことも受け入れるので、誰も述べていない要件はゲートでは回収できません。
テスト付きの30回のゲート実行を通して、ゲートがエージェントを差し戻したのは28回が実際に要件が失敗していたとき、5回がすべて通っていたときでした。その5回のうち1件はリネームのタスクです。書き手は文字列でない名前を拒否することを求めましたが、誰もそんなことは頼んでおらず、エージェントはそのせいで1ラウンドを失いました。
このゲートの最初の版は、実装者自身のテストを再実行していました。隠し要件が失敗しているのに、すべての実行が最初のstopで許可されました。エージェントの書いたテストは、エージェントが作ったものをテストするからです。
ゲートの料金は、1回あたり$0.085ではなく$0.20、33秒ではなく60秒です。
ゲート3: Shopifyの書いた2人のレビュアー
レビューのゲートは、それぞれ新しいコンテキストで動くレビュアーagentのペアです。Shopifyの言い回しはこうです。互いに独立し、コンテキストが分離された2つのレビュアーagentが新しいコードを確認し、すべての指摘を直す必要があり、両方のレビュアーが承認するまでループを繰り返す。分離がなぜ重要かは、Anthropicのガイドが説明しています。新しいコンテキストのレビュアーが見るのは、diffと与えられた基準だけで、変更を生んだ思考過程は見えません。
ここでの各レビュアーは、読み取り専用ツールを持つヘッドレスSonnetで、最大15ターンです。判定は構造化JSONで返します。approveフラグと指摘の一覧で、各指摘の重大度はblockingかminorです。2人のレビュアーはリクエストとdiff全体を対象に並列で動き、指摘は統合され、どちらかがblockingを出せばゲートはブロックします。ペアのコストは1回のstopあたり3から6セントです。
| 構成 | 通った要件 | 合格率 | ブロック |
|---|---|---|---|
| ゲートなし | 50中35 | 70% | なし |
| レビュアーのみ | 50中34 | 68% | 10回中1回 |
| テストのみ | 50中42 | 84% | 10回中6回 (正しい3、誤り3) |
| テストのあとにレビュアー | 50中41 | 82% | 10回中2回、どちらも正しい |
レビュアーのみの構成はゲートなしより2ポイント低く、テストのあとにレビュアーを置いた構成はテストのみより2ポイント低くなりました。
この数字はソートのタスクで説明がつきます。ある実行では、両方のレビュアーがブロックしました。ソートはドキュメントをインデックスしており、そのフィールドを持たないドキュメントが1つでもあればキーエラーが出て、検索全体が失敗する、という指摘です。エージェントは直し、5つ中5つが通りました。
別の2回の実行では、両方のレビュアーがまったく同じクラッシュを見て、minorと評価しました。ソート用フィールドのないドキュメントでは素のキーエラーが出ますが、docstringにはそれが書かれています。両方のレビュアーが承認し、どちらの実行も5つ中ゼロで終わりました。同じモデル、同じdiff、同じバグで、重大度の評価は2通りでした。レビュアーがブロックしたとき、間違っていたことは一度もありません。問題は、彼らが通してしまうものです。
8月の論文が、公開ベンチマークで同じ形を測っています。ゼロショットは77%、レビュアー1人は77%、独立した2人のレビュアーは75%で、すべてレビューなしと誤差の範囲内でした。
| LiveCodeBenchでの構成 | スコア |
|---|---|
| ゼロショット | 77% |
| レビュアー1人 | 77% |
| 独立した2人のレビュアー | 75% |
| レビュアーと批評役 (敵対的レビュー) | 87% |
レビュアーと批評役、そして終わらないループ
敵対的レビューとは、レビュアーと、そのレビューを構造化された反論で監査する批評役を組み合わせ、メインのエージェントが編集する前に通す方式です。同じ論文は、これがうまく機能する形だと報告しています。2人のレビュアーの75%に対し87%を記録し、最強の5エージェントのベースラインの82%も上回りました。
| ベンチマーク | ゼロショット | 敵対的レビューあり | コスト |
|---|---|---|---|
| SWE-bench | 71.6 | 75.2 | トークンが約4.5倍 |
そこで、批評役をゲートに入れました。レビュアーAがレビューします。批評役Bは指摘とdiffを受け取り、各指摘をupheld(支持)かrejected(却下)かで判定します。ゲートを閉じるのは、支持されたblockingの指摘だけです。
このリポジトリでの結果は50中39、つまり78%でした。批評役のペアは、10回の実行で一度もブロックしませんでした。検証の段階では、2人のレビュアー方式ならブロックしていたはずの誤った指摘を、確かに却下しています。
批評役でも直せなかったのは、ループです。3回の実行が同じ受け入れテストで6回ブロックされ、本物の要件がどのstopでも失敗したまま、ゲートが諦めました。そのうち2回では、ブロックしたテストは書き手自身の間違いで、エージェントもそう言いました。読めないテストのためにコードは変更しない、と。
3つ目が覚えておくべきケースです。受け入れテストは、失敗した更新がすべてのドキュメントに触れないことを求めていたので、エージェントはテーブルのupdateメソッドを書き換えました。レビュアーはそれをブロックしました。updateメソッドの変更は頼まれておらず、タスクは新しい関数2つだったからです。エージェントは元に戻しました。最後のメッセージは、受け入れテストとコードレビューが正反対のことを求めているので、何も変更せずに止まった、というものでした。6回のブロックのあと、カウンターが外に出しました。
ここでの諦めのラインは6回です。Claude Code自身の上限は連続8回のブロックで、ツール呼び出しのたびにリセットされるため、50回の実行で一度も発動しませんでした。
ゲート4はあなた: 人間に届いたもの、コスト、キット
4つ目のゲートはエンジニアです。Shopifyの投稿は、誰も見ていないときにゲートが緩くならないことを約束しています。エンジニアに届くのは、ほかのゲートが通したものです。下の台帳は、どのゲートが元を取ったかに答えます。
| 構成 | 50中の合格数 | 合格率 | 1回あたりのコスト | 人間に残ったもの |
|---|---|---|---|---|
| ゲートなし | 35 | 70% | $0.085 | 15 |
| 受け入れテスト | 42 | 84% | $0.20 | 8 |
| レビュアーのみ | 34 | 68% | $0.15 | 16 |
| テストとレビュアー | 41 | 82% | $0.28 | 9 |
| テストとレビュアーと批評役 | 39 | 78% | $0.28 | 11 |
ゲートありの行はどれも1回あたり54秒から86秒かかり、ゲートなしは33秒です。
50回すべてのブロックの内訳は次のとおりです。テストはエージェントを正しく28回、誤って5回差し戻しました。レビュアーは3回ブロックし、誤りは一度もなく、3つのループが諦めました。マトリクス全体の費用は、CLIが報告する定価換算のAPI費用で$9.95、ワーカー時間の合計は約3,136秒、つまり52分でした。
このようなリポジトリへの結論は、テストのゲートを残すこと。書き手は実装者とは別にし、テストはエージェントが編集できない場所に置きます。誤ったブロックは6回に1回程度を見込み、ループにはカウンターを付けてください。Shopifyが書いたとおりの2人レビュアーのゲートは、どちらの形でもここでは合格率を動かしませんでした。残すなら、基準としてではなく、フィードバックの中の助言として残してください。
途中の罠が1つ。ドキュメントには、スクリプト実行向けにhook、skill、plugin、メモリをスキップする素のフラグが載っています。サブスクリプションのログインではAPIキーしか読まず、エラーを返します。そのため各レビュアーは、空の設定ソースとhook無効化で隔離しました。
限界は現実のものです。リポジトリは1つ、ワーカーモデルは1つ、タスクは5つ、各セルの反復は2回。行同士の2ポイントの差はノイズです。ゲートなしとテストのゲートの間の14ポイントは違います。デザインゲートは作っておらず、エンジニアの所要時間も計っていません。
Shopifyの4つのゲートのうち、このリポジトリで元が取れたのはテストだけでした。キット一式は無料パックに入っています。hookと拒否リストを含む設定ファイル、stopスクリプト、ゲート、書き手とレビュアーのコマンドとそのスキーマ、そして台帳のテンプレートです。
AIDive