AIにアプリを作らせる話は、たいてい「作らせた」で終わります。動いた画面を見せて、おしまい。 でも実際に困るのはその先で、「動きました」と返ってきたコードを、動かしてみる以外に確かめる方法がない。 この記事では順番をひっくり返します。アプリを1行も書く前に、合格の条件だけを先に置く。 そのうえで Claude Code と Codex(GPT-6 Astra)に同じ課題を渡し、 同じゲートを2つとも通れる環境を組み立てます。
同じAIです。同じ依頼文です。違うのは、作らせる前に 「何ができたら合格か」を置いたかどうかだけ。 左はあとで開き直すと画像が消えていて、右は残っています。
AIそのものを賢くするのではなく、AIの「周り」に仕組みを置いて、失敗しにくくする考え方のことです。 上の右側を作ったのは、賢いAIではありません。周りに置いた仕組みです。 この記事では、その仕組みを実際に組み立てます。
手順の前に、ひとつだけ。AIに「注意してください」と書いても、守られないことがあります。 悪意でも能力不足でもなく、毎回まっさらな状態で来るので、前回の指摘を覚えていないだけです。
この記事を書いている最中にも、それが起きました。完了条件に 「テストは修正しないこと」という1行を書き忘れたら、 AIはテストのほうを書き換えて緑にしてきたのです。 指示が足りないところは、素直にそこを埋めてきます。
だからやることは1つだけです。品質を、AIの記憶や注意力ではなく、環境と仕組みの側に持たせる。 AIを賢くするのではなく、AIの「周り」を整える —— これがハーネス(馬具)と呼ばれているものの中身です。
AIが何十分もひとりで走るようになったからです。 人が見ていない時間ができた。その時間に効くのは、 その場にいなくても働く仕組みだけです。
各論に入る前に、全体像を先に置いておきます。今日つくるのは①②③で、 ④は最後にもう一度出てきます。
| # | 部品 | 何をするか | この記事のどこ |
|---|---|---|---|
| ① | 受入条件 | 何ができたら合格か、作らせる前に決める | 2〜3章 |
| ② | 検証ループ | 合否が自動で返る。赤を読んで直して、また回す | 7章 |
| ③ | 権限とルール | 触っていい範囲・やってはいけないこと | 8章 |
| ④ | フィードバック | 出来を、作った本人以外が見る | — |
ここが今日いちばん大事なところです。 テストを書くのも、サンドボックスの設定をいじるのも、やっていることは同じです。 ぜんぶ AIの「周り」の仕組みを設計している。バラバラの小技ではありません。
4つに共通しているのは、「気をつける」ではなく「通れない」形にしていることです。 人の注意力に預けたものは、いつか抜けます。
ふつうの流れはこうです。作らせる → 動かしてみる → たぶん大丈夫。 最後の判定を人間の目がやっているので、見落としがそのまま残ります。
今回はこうします。合格の条件を置く → 作らせる → 機械が合否を出す。 順番を入れ替えただけですが、決定的に違うのは合否を人が決めなくなることです。
まっさらなフォルダで最初に打つのは、アプリのコードではなくこれです。
mkdir -p ~/project/develop/"20260908 pixel-harness" && cd $_ npm init playwright@latest -- --quiet --browser=chromium --lang=ts
ひとつ注意。npm init playwright@latest はサンプルのテストも一緒に作ります。
そのままだと外部サイトへ通信するテストまで走ってしまうので、消しておきます。
rm -rf tests-examples tests/example.spec.ts
つづけて、アプリを置く app/ を作って、空のページだけ置いておきます
(これが無いとサーバーが404を返して、テストが始まりません)。
mkdir -p app echo '<!doctype html><meta charset="utf-8"><title>pixel</title>' > app/index.html
そして配信の設定。依存パッケージを足したくないので、Python の標準モジュールで app/ を配ります。
設定側にも testMatch を入れて、消し忘れても3本だけが走るようにしておきます。
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
testMatch: /pixel\.spec\.ts$/,
use: { baseURL: 'http://localhost:5174' },
webServer: {
command: 'python3 -m http.server 5174 --directory app',
url: 'http://localhost:5174',
reuseExistingServer: true,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
テストを書くというのは、実は名前を決めることでもあります。
下のテストは4つの data-testid を要求していて、AIはこの名前で作るしかありません。
日本語で「保存ボタンを付けてください」と書くより、はるかに強い指示になります。
| 名前 | 何を指すか |
|---|---|
pixel-grid | 16×16 のマス目全体 |
pixel-cell | マス1つ(data-i で 0〜255) |
color-swatch | 色パレットの1色(data-color) |
pixel-save | 保存ボタン |
これが受入条件の本体です。tests/pixel.spec.ts に置きます。
5本あります——「閉じて開き直しても残るか」の3本に加えて、
16個ごとに行が折り返しているか・8色とも違う色か・ドラッグでなぞって塗れるかも見ています
(数を数えるだけだと、256個を1列に並べた実装でも通ってしまいます)。
import { test, expect, type Page, type BrowserContext } from '@playwright/test';
// 契約: pixel-grid / pixel-cell / color-swatch / pixel-save
const RED = 'rgb(231, 76, 60)';
const BLUE = 'rgb(52, 152, 219)';
const WHITE = 'rgb(255, 255, 255)';
async function paint(page: Page, color: string, cells: number[]) {
await page.locator(`[data-testid="color-swatch"][data-color="${color}"]`).click();
for (const i of cells) {
await page.locator(`[data-testid="pixel-cell"][data-i="${i}"]`).click();
}
}
// ★★ここが受入条件の肝★★
// 同じタブの再読み込みでは、壊れた実装でも絵が残ってしまう(実測)。
// さらに、元のタブを開いたままだと「新しいタブ」でも残ってしまう。
// ユーザーが実際にやるのは「閉じて、あとで開き直す」。だからそれを再現する。
async function closeAndReopen(ctx: BrowserContext, page: Page) {
await page.close(); // ← 作った本人(元の文書)が居なくなる
const fresh = await ctx.newPage();
await fresh.goto('/');
await fresh.waitForTimeout(400);
return fresh;
}
test('塗った色が、閉じて開き直しても同じマスに残る', async ({ context }) => {
const page = await context.newPage();
await page.goto('/');
await paint(page, '#e74c3c', [0, 17, 34]);
await page.getByTestId('pixel-save').click();
await page.waitForTimeout(300);
const fresh = await closeAndReopen(context, page);
for (const i of [0, 17, 34]) {
await expect(fresh.locator(`[data-testid="pixel-cell"][data-i="${i}"]`))
.toHaveCSS('background-color', RED);
}
});
test('複数の色を使っても、全部そのまま残る', async ({ context }) => {
const page = await context.newPage();
await page.goto('/');
await paint(page, '#e74c3c', [5, 6]);
await paint(page, '#3498db', [20, 21]);
await page.getByTestId('pixel-save').click();
await page.waitForTimeout(300);
const fresh = await closeAndReopen(context, page);
await expect(fresh.locator('[data-testid="pixel-cell"][data-i="5"]')).toHaveCSS('background-color', RED);
await expect(fresh.locator('[data-testid="pixel-cell"][data-i="21"]')).toHaveCSS('background-color', BLUE);
});
test('塗っていないマスは、白のままになっている', async ({ context }) => {
const page = await context.newPage();
await page.goto('/');
await paint(page, '#e74c3c', [0]);
await page.getByTestId('pixel-save').click();
await page.waitForTimeout(300);
const fresh = await closeAndReopen(context, page);
await expect(fresh.locator('[data-testid="pixel-cell"][data-i="255"]')).toHaveCSS('background-color', WHITE);
});
test('マス目と色パレットが、仕様どおり並んでいる', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('pixel-grid')).toBeVisible();
await expect(page.getByTestId('pixel-cell')).toHaveCount(256); // 16×16
await expect(page.getByTestId('color-swatch')).toHaveCount(8);
await expect(page.getByTestId('pixel-save')).toBeVisible();
// ★数だけ数えても足りない。「16個で1行」に並んでいるかまで見る
// (256個を1列に並べた実装でも、数えるだけなら通ってしまう)
const box = async (i: number) =>
(await page.locator(`[data-testid="pixel-cell"][data-i="${i}"]`).boundingBox())!;
const [c0, c15, c16] = [await box(0), await box(15), await box(16)];
expect(c15.y).toBeCloseTo(c0.y, 0); // 0 と 15 は同じ行
expect(c16.y).toBeGreaterThan(c0.y); // 16 は次の行
expect(c16.x).toBeCloseTo(c0.x, 0); // 16 は行の先頭に戻る
// ★色も「8個あるか」ではなく「8色とも違うか」を見る(赤・青・白×6 でも数は合う)
const colors = await page.getByTestId('color-swatch').evaluateAll(
els => els.map(e => (e as HTMLElement).dataset.color));
expect(new Set(colors).size).toBe(8);
});
test('ドラッグでなぞると、通った道が続けて塗れる', async ({ page }) => {
await page.goto('/');
await page.locator('[data-testid="color-swatch"][data-color="#3498db"]').click();
// 横に3マスなぞる(押したまま動かして離す)
const cells = [100, 101, 102].map(i => page.locator(`[data-testid="pixel-cell"][data-i="${i}"]`));
const first = await cells[0].boundingBox();
await page.mouse.move(first!.x + first!.width / 2, first!.y + first!.height / 2);
await page.mouse.down();
for (const c of cells.slice(1)) {
const b = await c.boundingBox();
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
}
await page.mouse.up();
for (const c of cells) await expect(c).toHaveCSS('background-color', BLUE);
});
この状態で npx playwright test を走らせると、3本とも赤になります。
当然です。app/ にはまだ何も無いので。
でもここが大事で、アプリのコードが0行の時点で、もう合否が出ている。
この赤が、これから作るものの仕様書です。
「マス目を塗れるドット絵エディタを作って」と日本語で頼むと、AIはだいたい
「絵なんだから、画像として保存しよう」と考えます。
canvas に描いて、URL.createObjectURL() で保存する ——
そしてその場ではちゃんと動きます。塗って、保存して、開き直せば絵が戻ってくる。
実際に走らせて、はじめて分かったことがあります。
| 確かめ方 | 壊れた実装の判定 |
|---|---|
| 描いた直後に見る | 絵が出ている → 通る |
| 同じタブで再読み込み | 通ってしまう |
| 別のタブで開く(元のタブは開いたまま) | 通ってしまう |
| タブを閉じて、開き直す | 絵が消える → 落ちる |
blob: URL は、それを作った画面が生きているあいだは使えてしまいます。
ユーザーが実際にやるのは「閉じて、あとで戻ってくる」。
テストもそれを再現しないと、意味がありません。
浅さには2段あります。
テストがあるかどうかではなく、テストが何を、どうやって見ているかが全てです。
渡す依頼文はこれです。2つのAIに、一字一句同じものを渡します。
書ける場所: このフォルダの中だけ。それ以外には書かない 触らないもの: 保存や履歴の操作/設定ファイルを開いて読む/頼んでいない変更や削除 迷ったとき: 止まらずに進めて、置いた前提を最後に書き残す 報告の形式: 何をしたか・変更したファイル・実行した検証コマンドと結果 app/ に、ドット絵エディタを作ってください。 要件: - 16×16 のマス目を並べる - 色のパレットから色を選べる(8色) - マスをクリック、またはドラッグでなぞると、その色で塗れる - 「保存」ボタンで、描いた絵を保存できる - ブラウザを閉じて開き直しても、描いた絵がそのまま残っている - 依存パッケージは追加しない。app/index.html だけで完結させる(素の HTML / CSS / JS) - 保存先は localStorage を使う(受入テストがそれを前提にしている) 完了条件: - `npx playwright test` が全部緑になること - テストは tests/pixel.spec.ts に既にある。テストは修正しないこと。テストに合わせて実装すること
「テストは修正しないこと」を書き忘れると、AIはテストのほうを書き換えて緑にしてきます。 笑い話のようですが、完了条件の書き方ひとつで結果が変わるという意味では、けっこう本質的な話です。
あとは走らせるだけです。AIが実装し、自分で npx playwright test を回し、
赤を読んで、直して、緑にする。ブラウザが実際に動くところを見たいときは --headed を付けます。
npx playwright test --headed
無人のブラウザが立ち上がり、色を選び、マスを塗り、保存して、閉じて、開き直す。 誰も触っていないのに全部動いて、最後に緑が出ます。 「動きました」は自己申告ですが、この緑は事実です。
次は同じ依頼文・同じテストを Codex(GPT-6 Astra)へ渡します。 ただしその前に、非対話で走るAIには質問ができないという前提を押さえておく必要があります。
| 決めること | 具体 |
|---|---|
| 書ける場所 | 作業ルートと、追加で許可したフォルダだけ。それ以外には一切書かない |
| 触らないもの | コミットやブランチの操作/鍵・トークンを開いて読む/頼んでいない変更や削除 |
| 迷ったとき | 止まらずに進めて、置いた前提を書き残す |
| 報告の形式 | 決まった項目だけを返させる(status / changed_files / verify / notes) |
やり方は簡単で、依頼文の先頭にこの4つを書いておくだけです。ただのテキストなので、 コピーして使えます。毎回書くのが面倒になったら、自分でスクリプトにまとめればいい —— 私はそうしていますが、最初は手で書いて十分です。
そのうえで、2つ目のAIに同じ依頼文を渡します。まず受入条件だけをコピーした別フォルダを作ります
(node_modules は持っていきません。あとで出てくる「ネットワークの壁」を再現するためです)。
rsync -a --exclude node_modules --exclude 'app/index.html' \ ~/project/develop/"20260908 pixel-harness"/ \ ~/project/develop/"20260908 pixel-harness-codex"/ cd ~/project/develop/"20260908 pixel-harness-codex" echo '<!doctype html><meta charset="utf-8"><title>pixel</title>' > app/index.html # ★配信ポートを 5175 へ変える(これを忘れると、さっきのフォルダを検証してしまう) sed -i '' 's/5174/5175/g' playwright.config.ts
reuseExistingServer がそれを再利用してしまい、
こちらのフォルダが空でもテストが通ってしまいます。私はここで一度ひっかかりました。
次に、渡す依頼文をファイルにしておきます(先頭に、さっきの4項目を書き足しておきます)。
cat > 依頼文.md <<'EOF' 書ける場所: このフォルダの中だけ。それ以外には書かない 触らないもの: 保存や履歴の操作/設定ファイルを開いて読む/頼んでいない変更や削除 迷ったとき: 止まらずに進めて、置いた前提を最後に書き残す 報告の形式: 何をしたか・変更したファイル・実行した検証コマンドと結果 app/ に、ドット絵エディタを作ってください。 要件: - 16×16 のマス目を並べる - 色のパレットから色を選べる(8色) - マスをクリック、またはドラッグでなぞると、その色で塗れる - 「保存」ボタンで、描いた絵を保存できる - ブラウザを閉じて開き直しても、描いた絵がそのまま残っている - 依存パッケージは追加しない。app/index.html だけで完結させる(素の HTML / CSS / JS) - 保存先は localStorage を使う(受入テストがそれを前提にしている) 完了条件: - `npx playwright test` が全部緑になること - テストは tests/pixel.spec.ts に既にある。テストは修正しないこと。テストに合わせて実装すること EOF
準備ができたら、実行します。
# ① まず既定のまま渡す(ネットワークの指定をしない) cd ~/project/develop/"20260908 pixel-harness-codex" codex exec --skip-git-repo-check -m gpt-6-astra \ --sandbox workspace-write \ "$(cat 依頼文.md)"
--skip-git-repo-check を忘れると、git 管理外のフォルダでは
Not inside a trusted directory… と出て依頼を読む前に止まります。
デモ用のフォルダは git 管理外なので、これが要ります。
この状態で委譲すると、失敗して返ってきます。
Codex の既定のサンドボックスは workspace-write で、
作業フォルダには書けますがネットワークは既定で無効だからです。
npm install がネットに出られず、そこで止まります。
ここが今回いちばん見てほしいところです。人間なら「ネット使っていいですか」と聞く場面で、 非対話のAIは聞けない。だから質問ではなく失敗として返ってくる。 つまり、渡す側が先に決めておくしかありません。直し方は許可を1つ足すだけです。
# ② ネットワークを開けて、同じ依頼をもう一度(足すのはこの1行だけ) codex exec --skip-git-repo-check -m gpt-6-astra \ --sandbox workspace-write \ -c sandbox_workspace_write.network_access=true \ "$(cat 依頼文.md)"
もう一度走らせると、今度は最後まで進んで緑になります。 同じテストで、同じ緑が出ました。それだけです。 落ちた理由は能力ではなく環境で、環境は渡す側が決められます。
ここまでが「1つのアプリを作らせるための最小構成」です。 参考までに、私が実際に毎日回している環境がどうなっているかを出しておきます。 作りこんだというより、事故るたびに1つずつ足していったら、こうなったという形です。
| いつ | 本数 | 何をしているか |
|---|---|---|
| 開くとき | 16 | 最新を取る/作業の現在地を出す/残りのタスクを出す |
| 書くとき | 2 | 保存した瞬間に検査が走る |
| 通すとき | 2 | 条件を満たさないと、そもそも進めない |
| 出すとき | 1 | 外に出す前に、名前や個人情報が混ざっていないか読む |
| 閉じるとき | 4 | 自動で保存・記録・同期 |
| 人がいない間 | 13 | 10分ごとの同期、毎朝の自動収集 |
仕組みを足していくと、今度は仕組み自体が壊れていないかが心配になります。 なので、まとめて確かめるコマンドを持っています(2本です。 最初は1本のつもりでしたが、調べたら片方は別コマンドでした)。
| 見るもの | 規模 |
|---|---|
| 配線ぜんぶ(参照先が実在するか・同期がずれていないか) | 10章 |
| 同時に動かしても壊れないか | 22項目 |
| 作業中に情報が溢れない配線になっているか | 28項目 |
| この記事をつくる間の記録 | |
|---|---|
| 別のAIに、この原稿を読ませた回数 | 15回 |
| 突き返された | 2回(重い指摘 3件) |
| 記録を止められた | 3回 |
この3件のうち1件が、この記事でお見せしているコマンドそのものでした。 引数が1つ足りず、そのまま公開していたら、真似した人の手元で動きませんでした。
品質を、AIの注意力に預けない。環境と仕組みの側に持たせる —— やっていることは、この記事の受入条件とまったく同じです。規模が違うだけで。
ここまでの内容を、自分のプロジェクトでそのまま使える形にまとめておきました。 【 】を自分の題材に置き換えて、上から順に貼るだけです。 今日の回で説明した範囲(①受入条件・②検証ループ・③権限とルール)に対応しています。
STEP 1 → STEP 2 の順に、別々に渡してください。 まとめて渡すと、AIは受入条件とアプリを同時に作ってしまい、 「条件を先に置く」という今日の肝が消えます。
書ける場所: このフォルダの中だけ。それ以外には書かない 触らないもの: 保存や履歴の操作/設定ファイルを開いて読む/頼んでいない変更や削除 迷ったとき: 止まらずに進めて、置いた前提を最後に書き残す 報告の形式: 何をしたか・変更したファイル・実行した検証コマンドと結果
これから【 作りたいもの 】を作ります。 ただし今回は、アプリのコードは1行も書かないでください。 先に「何ができたら合格か」だけを、実行できるテストとして書いてください。 書き方のルール: - 画面の要素には data-testid を付ける前提で書く。テストからはその名前だけで触る - テスト名は日本語で「何ができたら合格か」がそのまま読める文にする - 次の3種類を必ず入れる: 1. 見た目や構造が仕様どおりか(数だけでなく、並び方や重複も見る) 2. ユーザーの主な操作が実際にできるか 3. 【 いったん閉じて開き直しても / 再起動しても 】、状態が残っているか 満たしてほしいこと: - 「要素が存在する」だけで通らないようにする。中身まで確かめる - 一度きりの確認ではなく、本当にユーザーがやる手順を再現する - テストの前提(保存先など)が決まっているなら、コメントに明記する できたら、テストを実行して「全部赤になること」を報告してください。 アプリがまだ無いので、赤になるのが正しい状態です。
【 STEP 0 の4行をここに貼る 】 【 作りたいもの 】を作ってください。 要件: - 【 箇条書きで。テストと矛盾しないように書く 】 - 依存パッケージは追加しない(増やすなら理由を報告に書く) - 【 保存先・使う技術の指定があればここに書く 】 完了条件: - 受入テストが全部緑になること - テストは既にある。テストは修正しないこと。テストに合わせて実装すること - 赤が出たら、出力を読んで自分で直してから、もう一度実行すること
別のフォルダにコピーして、同じ STEP 2 をそのまま渡すだけです。 ただし2つ注意があります。
1回きりの作業・判断が要ること・人にしか決められないことには効きません。 効くのは「繰り返す」「機械で確かめられる」「人の着手を待っている」が 揃うところだけです。全部を仕組みにしようとすると、だいたい失敗します。
最後に、この記事が何の一部なのかを置いておきます。私は普段、3つをセットで考えています。
| 何を決めるか | この記事 | |
|---|---|---|
| ハーネス | AIの周りの仕組み(受入条件・検証ループ・権限とルール) | これをやった |
| ループ | 回し方。実行 → 観測 → 修正。1回直して終わりにしない | 触れるだけ |
| グラフ | 置き場所。同じことを2か所に書かない | 触れるだけ |
ループで大事なのは、直したら効果を確かめる手段もセットで作ること。 そして作った本人に採点させないこと。 この記事の「検証ループ」も「フィードバック」も、元をたどればこの考え方です。
グラフは、同じ設定をあちこちに手で書くと必ずどこかでズレるから、 正本を1つ決めて、ほかは参照にするという話です。 設定ファイルをどこに置くか、という次回の話は、まさにこれにあたります。
2つとも通りました。環境としては成功です。 ただ、ここで満足すると足をすくわれます。
テストが保証したのは「動くこと」だけです。 読みやすいか、危ない書き方をしていないか、境界の条件が抜けていないか、設計として妥当か—— そこは1ミリも見ていません。
この2つが書いたコードの「出来」は、誰が採点するのか?
答えは、別のモデルに採点させることです。 作った本人に、合否を出させない。
——ただ、それを「仕組み」にするには、設定ファイルに何をどう書くかが要ります。 その中身は、次回。