最近、人が投げてよこしたプロジェクトを引き継いだ。git log を一発叩いて少し固まった——9 つの commit、author は全部同じ AI Dev <ai-dev@local> で、人間のコミットは一つもない。以下に挙げる commit は hash を貼らないので、やった内容で呼ぶことにする。リポジトリには CLAUDE.md も AGENTS.md も .claude/ も .cursor/ もなく、CI もない。lint すら tsc --noEmit だけで、テストに至ってはゼロだ。一日かけて読み漁って、うまくやった点とやらかした点を記録しておく…

まずこのプロジェクトが何なのかを説明しておこう。純クライアントの React SPA で、バックエンドは Express を Vercel Function の単一エントリに打包したもの。api/index.ts と api/lib/ の下に六つのモジュール、データベースは Neon Postgres、オブジェクトストレージは S3 互換 API を使っている。これだけのもので、複雑ではない。

まず、うまくやった点

一部の commit には検証可能な結果が添えられていた

9 つの commit のうち 4 つは末尾に検証結果が付いていて、しかも照合可能な数字入りだった。

オブジェクトストレージ接続の回は、末尾に「ローカル端末のエンドツーエンドスモーク通過: presign → PUT → public GET が 1×1 PNG を返す」と書いてある——1×1 の PNG、つまり本当に全リンクを走りきったということだ~ 既存データ移行の回は、末尾が「実行完了: 19 オブジェクト / 1.77 MB。DB の data: 残留は 0 件」。いくつ移して、いくつ残ったかまで書き切っている…

もちろん全部がそうではない。リリース受け入れの回の検証パートは「Production のエンドツーエンドテスト通過。」の一言だけで、書いてないのとほぼ同じ。デプロイ手順書を書いた回に至っては body が丸ごと空で、タイトル一行しかない。だから「三段式 commit」を見ただけで喜ぶのは早い…

とはいえ数字入りの回は、正直に言って自分で手書きするよりマシだった…

自分で運用マニュアルを書き、自分の権限の限界も分かっていた

リポジトリを見ても誰がドキュメントを書くよう要求した形跡はないが、ともかく自分で DEPLOY.md を一冊産出していて、中には純粋なインフラ知識まで書いてある。たとえばカスタムドメインを紐付ける箇所では、わざわざ警告を一つ入れている。CDN の Proxy は必ずオフにしてグレークラウドのままにしろ、プロキシを有効にすると Vercel の SSL 証明書が ACME challenge を取得できなくなる、と。これは業務コードとは何の関係もない、純粋な運用ノウハウだ…

ドキュメントの末尾には 5 項目の TODO ロードマップ checklist も維持していて、後続の commit で継続的に書き換えられている。一度生成して腐らせただけの産物ではない。

併せて境界意識もある。オブジェクトストレージ接続の回の末尾には、カスタムドメインは人がコンソールで手動で紐付ける必要があると明記されている。手元のトークンには該当する zone 権限がないからだ。次の commit では人が作業を終えた後の状態が記録されている。自分にはできないと分かっていて素直に言うのは、無理やり突っ込んで無駄に試すよりずっとマシだ…

進んで引き算をしてくれる

これが一番印象に残った。以前、私が挙げた技術スタックに従って Redis サービスを接続したが、後で自己点検してコードに import がゼロだと気づき、ロールバックした。依存を削除し、README とデプロイ手順書から関連する記述を整理したのだ。その commit の締めの原文は「後で本当に Redis が必要になったら、db を再開して env を戻すだけ、5 分の話。」

同じ message ではさらに、三つの環境の env を削除し、management API でリモートの Upstash インスタンスを削除したと主張している——この部分は diff からは検証できない。diff は 5 ファイル(.env.example、DEPLOY.md、README.md、package.json、lockfile)しか触っていないからだ。

「きれいに消す」はたいてい AI が最も苦手とすることだ。多くの場合、上に積むことしかせず、積み間違えても動かそうとしない。今回は少なくともコードの半分はきれいに消せていた…

呼び出し箇所を変えず、互換レイヤーを造ることを選んだ

データベースを替えた回、フロントエンドでは二十数個のコンポーネントが旧 SDK の .from() チェーン呼び出しを使っていた。それらの二十数箇所の呼び出しを書き換えるのではなく、インターフェース互換の shim を書き、リクエストを自作ゲートウェイに転送した。呼び出し側はゼロ変更だ。この手は別に技術的な大した中身はなく、少し考えれば思いつくが、自分でやるとなるとおそらく真っ先に飛び込んでファイルを一つずつ直していただろう…

ついでにゲートウェイにホワイトリスト検証も書いていて、コメントの原文がなかなか面白い:

*   - A small allowlist of (table, op) tuples is required — anything else is
*     rejected. This keeps the proxy from turning into a "drop my whole DB"
*     button.

次に、やらかした点

ローカルは全緑、本番は落ちた

tsconfig.json では allowImportingTsExtensions と noEmit を有効にしていたので、ソース内の import はすべて .ts 拡張子付きで書かれていて、ローカルではどう動かしてもエラーにならない。

ところが Vercel がエントリを .js にコンパイルし、from './lib/sb-shim.ts' は本番では該当ファイルがまったく見つからず、そのまま ERR_MODULE_NOT_FOUND、本番はこうして落ちた。
(これは同じ記事の全 3 部のうち 1 部目です。)

修復したコミットには、その理由が自分で書かれていた。標準的な ESM TS の書き方では .js 拡張子を参照し、tsx と tsc はどちらも .js を対応する .ts ソースファイルへ透過的に解決できる。今回は 4 ファイル・計 7 箇所の import を修正した(api/index.ts が 4 箇所、sb-shim.ts/auth.ts/db-proxy.ts が各 1 箇所)。ついでに今回初めて .vercelignore を新規作成したが、それ以前はデプロイにこのファイルがそもそも存在しなかった…

ローカルで動くからといって、本番に出せるとは限らない。特にビルド成果物の形が変わったときは…

commit message の中の幻覚

データベースを切り替えたときの message には「vercel.json: cleanUrls + www→ルートドメイン 301 + 30s 関数タイムアウト」と書かれていた。しかし git show でその回の diff を見ると、vercel.json には cleanUrls というキーがそもそもなく、現在のファイルを探しても見当たらず、git log -S'cleanUrls' の全履歴でもヒットゼロだった。

AI が書いた changelog を全部信じてはいけない。diff こそが事実だ…

ドキュメントを直したら自己矛盾になった

Redis を削除したときに、ついでに README の「設定ファイル内の redirect 301 でルートドメインへ」を「プラットフォーム既定の 308 でルートドメインへ収束」に書き換えた。しかし vercel.json にあるその 2 つの permanent: true の明示的な 301 は最初からずっとあり、削除されていなかった。

書き換えた結果、README と設定ファイルが真っ向から食い違い、どちらを信じればいいのか分からなくなった…

認証情報が git 履歴に入った

デプロイドキュメントの「日常開発」段落で、ローカル管理画面の入り口の行に admin アカウントと平文パスワードがそのまま載っていた(具体的な値はここでは繰り返さない)。しかもこのパスワードは db:seed がデータベースに流し込んだそのものなので、「どうせ localhost だから」で済むほど無害ではない。.env.example にあるデフォルトパスワードも、一目で誰も変更していないと分かる弱いパスワードだった。

面白いのは、AI 自身がロードマップの最後の項目に「認証情報 rotate:JWT_SECRET / admin password は本番が安定したら一巡交換することを推奨」と書いていたことだ。気づいてはいたが、その場では処理しなかった。これがおそらく AI の最も典型的な行動パターンだろう。リスクはドキュメントに書き留め、そのまま書き続ける…

レガシーコードとの互換性のために認証を緩めた

ゲートウェイの JWT 検証で、catch のブランチにこんな一節が書かれていた:

} catch {
  // Legacy code uses the raw user id as a "token" — accept it as a user token
  // so unmigrated frontend code keeps working until the full JWT rollout.
  const maybeId = m[1].trim();
  if (/^[0-9a-fA-F-]{32,36}$/.test(maybeId)) {
    return { user: { sub: maybeId } };
  }
}

JWT の検証が通らない?問題ない、32〜36 桁の十六進数/ハイフン連結文字列であれば、正当な user token として受け入れる。つまり、あるユーザーの UUID さえ知っていれば、そのユーザーになりすましてゲートウェイを通過できる。これもロードマップに未対応項目として挙げていたが、コードはそのまま本番に出た…

機能が静かな no-op に格下げされた

フロントエンド shim のリアルタイムチャネルは空実装で、コメントにもはっきり書かれている:

*   - channel(name) — no-op shim (Realtime is on the roadmap; signaling
*     callsites in VideoChat won't throw but won't deliver either)

呼び出し側はエラーを投げないが、永遠に届くこともない。こういう問題が最も追跡しづらい。動いているふりをするからだ…

約束ファイルがないとき、人間の指示はどこへ行ったのか

人間の痕跡はいくつかの commit message に散らばっている。ベースラインの回には「PM が AI Studio で生成した初版」とあり、次の回には「PM データの boolean→numeric」と「PM の古いカラム名」があり、「ユーザーが dashboard で手動接続するのを待つ」という一文も以前引用した。

「指示が出されていた」ことが最もよく分かるのは、Redis 削除の回の 2 文だ。一つはそもそもなぜ Redis を繋いだのかの説明で、「Upstash を繋いだのは当時あなたが挙げたスタックに合わせるため」とあり、技術スタックが人間の指定であることを示している。もう一つは「残す: task 8(VideoChat シグナリング)を汎用の『Realtime 代替案』に改名」。

つまり、番号付きのタスクリストが存在していた。しかしそれはリポジトリにはなく、grep で全リポジトリを探しても task 8 は見つからない。それは会話コンテキストの中だけに生きていて、セッションが閉じれば消える…

これが、私が後にどうしてもリポジトリに約束ファイルを一つ残そうとした直接の理由だろう——たとえ一行でも、少なくともセッションと一緒に蒸発することはない…

一枚の表にまとめる

この 9 つの commit を読み終えて、私は「AI の単独作業」の能力の境界をだいたい描けるようになった:

うまくできることうまくできないこと
スキャフォールド、ボイラープレートコードセキュリティ境界(認証、認証情報)
互換レイヤー / shim、呼び出し側の大規模変更を避けるファイル間の一貫性(ドキュメント vs 設定)
運用 runbook、インフラの落とし穴まで書く自分の changelog(幻覚を起こす)
移行スクリプト:デフォルト dry-run、--apply で初めて実際に実行、冪等書き留めたリスクを後から直しに行かない

あの移行スクリプトは特筆に値する。デフォルトでは dry-run の出力だけで、--apply を付けて初めて実際にアップロードし、しかも http で始まるオブジェクトは直接スキップするので、繰り返し実行しても問題ない。この設計は私自身が書いてもこうなるだろう…

ついでに:agent に見せるために書かれた専用のコメント

vite.config.ts を読んでいたら、こんなコメントがあった:

server: {
  // HMR is disabled in AI Studio via DISABLE_HMR env var.
  // Do not modify—file watching is disabled to prevent flickering during agent edits.
  hmr: process.env.DISABLE_HMR !== 'true',
},

(これは同じ記事の 3 部作のうちの 2 番目です。)

つまり、ファイル監視は環境変数でオフにされていた。エージェントがファイルを編集するときの高頻度な書き込みで、HMR がひどくちらつくからだ。

先に言っておくと、このコメントはそれが書いたものではない。git log -- app/vite.config.ts にはベースラインのインポートの一行しかなく、PM が AI Studio から初版をエクスポートした時点で最初から付いていたスキャフォールドのコメントで、その後は誰も触っていない。逆に言えば、これはむしろ問題の本質を示している。特定のエージェントが自分用にメモを残したのではなく、ツールチェーンがデフォルトで「AI がこれらのファイルを書き換えている」というシチュエーションに適応しているということだ。以前なら、こうしたコメントは次に引き継ぐ人間に向けて書かれていた。今ではついでに、次に引き継ぐモデルに向けても書かれている…

とにかく結論としては、それは自分では止まらない。誰か、あるいは CI が強制的に止めさせなければならない…