6/18 に Gemini CLI と Gemini Code Assist の IDE 拡張がリクエスト処理を終え、Go 製の Antigravity CLI へ移行します。手元の自動化が Gemini CLI を前提に組まれている方にとって、本当に怖いのは「コマンドが動くかどうか」ではありません。動くことは数分で確かめられます。怖いのは、動いた上で少しだけ違う成果物が出ることです。
私自身、個人開発で運用している複数サイトの記事生成やリリース準備を CLI 経由のエージェントに任せています。切り替えの検証として agy --version が通ることを確認しても、それは何も保証しません。生成された原稿の構成が変わっていないか、リリースノートのフォーマットが崩れていないか、画像アセットの命名規則が同じか——確かめたいのはそこです。
そこで切り替えの数日前に、旧CLIの出力を「ゴールデン(正解)」として固定し、新CLIの出力と機械的に照合するゲートを組みました。以下では、その照合ハーネスの設計と、非決定的な出力をどう正規化したか、そして「止めるか進めるか」をどう判断したかを、実際のコードとともに残しておきます。
「動く」ことの確認と「同じ結果」の確認は別物です
移行作業のチェックリストはたいてい起動確認で終わります。バイナリが入ったか、認証が通るか、サブコマンドが存在するか。これらは前提条件の確認であって、退行の検知ではありません。
エージェント CLI の出力は、内部のモデルもプロンプト解釈も実行計画も変わり得ます。Antigravity CLI は Antigravity 2.0 デスクトップと同じエージェントハーネスを共有し、動力は Gemini 3.5 Flash です。競合フロンティアモデルの約4倍速とされる一方で、速いことと「以前と同じ判断を下すこと」は無関係です。速度が上がったからこそ、人間が結果を読み返す前に大量の成果物が積み上がる、という新しいリスクも生まれます。
ですから検証の単位は、コマンドの成否ではなく最終成果物に置くべきだと考えています。原稿そのもの、ビルド生成物、コミットされるファイル群。読者であり運用者である自分が最後に手にするものを基準にします。
照合対象は最終成果物に絞る
最初に犯した失敗が、標準出力(stdout)の全文を比較対象にしたことでした。エージェントの stdout には進捗ログ・思考の要約・実行時間が混ざっており、差分が常時数百行出ます。これでは退行が埋もれて意味を成しません。
照合対象は次の3種類に限定すると安定しました。
比較する3つの軸
- 生成ファイルの内容: エージェントが書き出す原稿・設定・コードそのもの。これが本丸です。
- 終了コードと副作用の有無: 想定したファイルが「作られたか/作られなかったか」の集合。出力が空でも終了コード 0 で素通りする無音失敗を捕まえます。
- 構造メタデータ: 原稿なら見出し数・コードブロック数・フロントマターのキー集合。本文の一字一句ではなく骨格を見ます。
stdout のログは比較対象から外し、デバッグ用に保存だけしておきます。本番の自動投稿でも、過去に「出力が空のページが終了コード 0 で公開された」事故を経験しているので、副作用の有無を独立して検証する2番目の軸は外せません。
旧CLIで先にゴールデンを固定する
切り替え前にしかできない作業がこれです。Gemini CLI が動くうちに、代表的なタスクを走らせて成果物を保存します。
#!/usr/bin/env bash
# capture_golden.sh — 旧CLIの成果物をゴールデンとして固定する
set -euo pipefail
GOLDEN_DIR="golden/$(date +%Y%m%d)"
TASKS_DIR="parity_tasks" # 代表タスクのプロンプトを1ファイル1タスクで置く
mkdir -p "$GOLDEN_DIR"
for task in "$TASKS_DIR"/*.txt; do
name="$(basename "$task" .txt)"
out="$GOLDEN_DIR/$name"
mkdir -p "$out/files"
# 旧CLI(gemini)を非対話・固定温度で実行。生成物を out/files へ隔離して書かせる
gemini run --prompt-file "$task" --workdir "$out/files" \
> "$out/stdout.log" 2> "$out/stderr.log" || true
echo "$?" > "$out/exit_code"
# 生成ファイルの一覧と内容ハッシュを記録
( cd "$out/files" && find . -type f | sort > "../manifest.txt" )
( cd "$out/files" && find . -type f -exec sha256sum {} \; | sort > "../hashes.txt" )
echo "captured: $name"
done
echo "golden fixed at $GOLDEN_DIR"
ここで重要なのは、代表タスク(parity_tasks/)を普段の運用に近い実タスクで選ぶことです。トイ例では退行が出ません。私の場合は「記事1本の下書き生成」「リリースノートの整形」「アセットのリネーム提案」の3系統を選びました。多すぎると切り替え当日に回しきれないので、効くものを数本に絞るのが現実的です。
非決定的な差分を正規化する
ここがハーネスの心臓部です。LLM の出力はバイト単位で一致しません。タイムスタンプ、実行ID、わずかな語順、句読点の揺れ——こうした「意味のない差分」を消さないと、退行の信号がノイズに沈みます。
正規化を3段階に分けると見通しがよくなりました。
# normalize.py — 成果物を比較可能な正規形に落とす
import re
import json
from pathlib import Path
# (1) 機械的に必ず変わるトークンを伏字化する
VOLATILE = [
(re.compile(r"\d{4}-\d{2}-\d{2}T[\d:]+(?:Z|[+\-]\d{2}:?\d{2})?"), "<TS>"),
(re.compile(r"\brun[-_][0-9a-f]{6,}\b", re.I), "<RUN_ID>"),
(re.compile(r"\b[0-9a-f]{40}\b"), "<SHA>"),
(re.compile(r"\b\d+(?:\.\d+)?\s*(ms|s|秒)\b"), "<DUR>"),
]
def normalize_text(text: str) -> str:
for pat, repl in VOLATILE:
text = pat.sub(repl, text)
# (2) 行末空白と連続空行をたたむ
text = "\n".join(line.rstrip() for line in text.splitlines())
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def structural_signature(md: str) -> dict:
# (3) 本文ではなく骨格を取り出す。語の言い回しが揺れても骨格は安定する
body = re.sub(r"^---\n.*?\n---\n", "", md, flags=re.DOTALL)
return {
"h2": len(re.findall(r"^##\s", body, re.M)),
"h3": len(re.findall(r"^###\s", body, re.M)),
"code_blocks": body.count("```") // 2,
"links": len(re.findall(r"\]\(", body)),
"frontmatter_keys": sorted(
re.findall(r"^(\w+):", md.split("---")[1], re.M)
) if "---" in md else [],
}
if __name__ == "__main__":
import sys
p = Path(sys.argv[1])
raw = p.read_text(encoding="utf-8")
print(json.dumps({
"normalized_sha": __import__("hashlib").sha256(
normalize_text(raw).encode()).hexdigest()[:16],
"structure": structural_signature(raw),
}, ensure_ascii=False, indent=2))
伏字化のリスト(VOLATILE)は、実際に差分を眺めながら育てるのが正解でした。最初から完璧を狙うと、本物の退行まで伏字で消してしまいます。私は最小限から始め、「これは毎回必ず変わる」と確信したものだけを足していきました。
機械的な差分と意味的な差分を切り分ける
正規化しても、新旧で完全一致はしません。Gemini 3.5 Flash と旧モデルでは言い回しが変わるからです。そこで差分を2層に分けて扱います。
骨格(structural_signature)が一致していれば、本文の語句が多少違っても実用上は同等と判断します。逆に、見出し数やコードブロック数やフロントマターのキー集合が変わったら、それは構成の退行で、止める理由になります。
# parity_gate.py — go / no-go を判定するゲート
import json, sys, subprocess
from pathlib import Path
def sig(path):
out = subprocess.check_output(["python3", "normalize.py", str(path)])
return json.loads(out)
def compare(golden_dir: Path, candidate_dir: Path) -> list[str]:
findings = []
for gfile in (golden_dir / "files").rglob("*"):
if not gfile.is_file():
continue
rel = gfile.relative_to(golden_dir / "files")
cfile = candidate_dir / "files" / rel
if not cfile.exists():
findings.append(f"BLOCK 欠落: {rel} が新CLIで生成されていません")
continue
g, c = sig(gfile), sig(cfile)
if g["structure"] != c["structure"]:
findings.append(f"BLOCK 構成差分: {rel} {g['structure']} -> {c['structure']}")
elif g["normalized_sha"] != c["normalized_sha"]:
findings.append(f"REVIEW 文面差分: {rel}(骨格は一致・人の確認推奨)")
return findings
if __name__ == "__main__":
findings = compare(Path(sys.argv[1]), Path(sys.argv[2]))
blocks = [f for f in findings if f.startswith("BLOCK")]
for f in findings:
print(f)
# BLOCK が1件でもあれば切り替えを止める
sys.exit(1 if blocks else 0)
BLOCK は構成の退行と成果物の欠落だけに限定し、文面の揺れは REVIEW として人間に回します。すべてをブロック扱いにすると、切り替え当日にゲートが永遠に赤のままになり、結局「えいや」で素通しする羽目になります。止めるべきものを厳しく絞ることが、ゲートを機能させ続ける条件だと考えています。
切り替え当日はカナリアから始める
ゲートが揃ったら、当日の段取りは段階的にします。
- カナリア: 代表タスクのうち最も壊れたら痛い1本だけを新CLIで回し、ゲートにかけます。
- 全タスク照合: カナリアが緑なら、残りの代表タスクを一括で回して
parity_gate.py を通します。
- 本番投入:
REVIEW 差分を目視で確認し、許容できると判断したら自動化を新CLIへ向けます。
- ロールバック線の確保: 旧CLIのバイナリと設定は、提供終了後もしばらく手元に残しておきます。6/18 を過ぎるとリクエストは通らなくなりますが、ゴールデンの再取得ではなく「以前どう動いていたか」の参照用として価値があります。
私はこの段取りを、まずトラフィックの小さい題材で試してから、AdMob 収益に関わるリリース準備や App Store 提出物の生成へ広げました。壊れて困る順とは逆の順、つまり「壊れても被害が小さい順」に広げるのが安全です。
一度きりの作業に見えて、資産になります
このハーネスは 6/18 の移行のためだけのものに見えますが、実際には残ります。エージェント CLI もモデルも今後も更新され続けます。そのたびに「前と同じ仕事をしているか」を問う仕組みが手元にあれば、アップデートを怖がらずに受け入れられます。
次の一歩として、まず parity_tasks/ に普段の実タスクを2〜3本だけ置き、旧CLIが動くうちに capture_golden.sh を一度走らせてみてください。ゴールデンさえ取れていれば、切り替えはいつでも検証つきで踏み出せます。締切に追われて素通しするのではなく、静かに照合してから進む——その余裕を作っておくことが、個人開発で複数の自動化を抱える者にとっての保険になります。