diff --git a/.github/cross-review/run.sh b/.github/cross-review/run.sh
new file mode 100755
index 0000000..6921b44
--- /dev/null
+++ b/.github/cross-review/run.sh
@@ -0,0 +1,187 @@
+#!/bin/bash
+# cross-review: 差分を Antigravity CLI(agy)にレビューさせ、結果を検査して出力する。
+# 使い方: run.sh [git diff の引数...] 省略時は HEAD(コミットしていない変更すべて。未追跡のファイルも含む)
+# 出力の1行目: CROSS_REVIEW_OK / CROSS_REVIEW_EMPTY / CROSS_REVIEW_FAILED
+# HEAD(作業ツリー)のレビューに成功したときは、その状態を .git/cross-review-ok に記録する。
+# コミット前の hook(gate.py)は、この記録と今の状態を比べる。
+#
+# agy 1.2.2 の headless で確かめた挙動(2026-09-12〜13):
+# - 許可のない操作は拒否され、その場で打ち切られる。それでも status=SUCCESS・exit 0 で応答は空になり、
+# denied_actions にだけ残る。カレントディレクトリの読み取りも --add-dir を付けないと拒否される。
+# - --print-timeout を超えると途中までの応答で status=SUCCESS・exit 0 になる(stderr に "print timeout")。
+# - ツールを使う回で、拒否もタイムアウトもないのに応答が空のまま SUCCESS で終わることがある
+# (obd2 の 1ac4459 で3回中1回。会話の記録にも最終回答が残っていない)。
+# なので終了コードと status では判定せず、denied_actions・stderr・空の応答を見る。
+# 打ち切りと空の応答のときは、ツールを使わない指示で1回だけ再実行する。
+set -u
+
+here=$(cd "$(dirname "$0")" && pwd)
+MODEL="${CROSS_REVIEW_MODEL:-gemini-3.1-pro-high}"
+MAX_BYTES=400000
+BUDGET=540 # 秒。Claude Code の Bash ツールの上限(10分)に収める
+
+fail() { echo "CROSS_REVIEW_FAILED: $*"; exit 1; }
+# 作業ツリーの状態ハッシュ。state.py がないとき(リポジトリに写した run.sh など)や、計算できないときは空
+state_of() {
+ [ -f "$here/state.py" ] || return 0
+ local out
+ out=$(python3 "$here/state.py" "$root" 2>/dev/null) || return 0
+ printf '%s' "${out%% *}"
+}
+
+command -v agy >/dev/null || fail "agy が見つかりません(~/.local/bin/agy)"
+root=$(git rev-parse --show-toplevel 2>/dev/null) || fail "git リポジトリの中で実行してください"
+cd "$root" || fail "cd $root に失敗しました"
+
+[ $# -eq 0 ] && set -- HEAD
+worktree=0
+[ $# -eq 1 ] && [ "$1" = "HEAD" ] && worktree=1
+
+diff=$(git diff "$@") || fail "git diff $* が失敗しました"
+untracked=$(git ls-files --others --exclude-standard)
+state_before=""
+if [ "$worktree" = 1 ]; then
+ # 作業ツリーのレビューでは、未追跡のファイルも新規ファイルとして差分に含める(コミットされうるので)
+ while IFS= read -r -d '' f; do
+ diff="${diff}
+$(git diff --no-index -- /dev/null "$f")"
+ done < <(git ls-files --others --exclude-standard -z)
+ untracked=""
+ state_before=$(state_of)
+fi
+if [ -z "$diff" ]; then
+ echo "CROSS_REVIEW_EMPTY: 差分がありません(git diff $*)"
+ exit 0
+fi
+bytes=$(printf '%s' "$diff" | wc -c | tr -d ' ')
+[ "$bytes" -le "$MAX_BYTES" ] || fail "差分が ${bytes} バイトあり大きすぎます。パスかコミット範囲を絞るか、生成物を .gitignore に入れてください"
+
+intro="あなたはコードレビュアーです。下の git diff をレビューし、バグ・回帰・セキュリティ問題だけを重大度順に挙げてください。スタイルや好みの指摘は不要です。各指摘には file:line と根拠を付けてください。問題がなければ「指摘なし」とだけ答えてください。"
+with_reads="周辺のコードを確かめたいときは、このリポジトリ内のファイルの読み取りだけを使ってください。シェルコマンドは実行できません(実行しようとすると、その場でレビューが打ち切られます)。ファイルは変更しないでください。"
+no_tools="ツールは一切使わず、diff の内容だけから判断してください。"
+body="
+${diff}
+"
+
+out=$(mktemp)
+err=$(mktemp)
+trap 'rm -f "$out" "$err"' EXIT
+
+run_agy() { # $1 = プロンプト, $2 = 制限時間(秒)
+ agy -p "$1" --add-dir "$root" --model "$MODEL" --output-format json --print-timeout "${2}s" >"$out" 2>"$err"
+ rc=$?
+}
+# 直前の結果が「打ち切り」か「空の応答」なら、理由(denied / empty)と会話 ID を出す
+retry_reason() {
+ python3 - "$out" "$err" <<'PY' 2>/dev/null
+import json, sys
+try:
+ d = json.load(open(sys.argv[1], encoding="utf-8"))
+except Exception:
+ sys.exit()
+if d.get("status") != "SUCCESS" or "print timeout" in open(sys.argv[2], encoding="utf-8", errors="replace").read():
+ sys.exit()
+if d.get("denied_actions"):
+ print("denied", d.get("conversation_id") or "-")
+elif not (d.get("response") or "").strip():
+ print("empty", d.get("conversation_id") or "-")
+PY
+}
+
+start=$(date +%s)
+note=""
+run_agy "${intro}
+${with_reads}
+
+${body}" "$BUDGET"
+read -r reason first_id <<<"$(retry_reason)"
+if [ -n "${reason:-}" ]; then
+ case "$reason" in
+ denied) why="agy が許可のない操作を試みて打ち切られた" ;;
+ *) why="agy の応答が空だった" ;;
+ esac
+ left=$(( BUDGET - ($(date +%s) - start) ))
+ if [ "$left" -ge 60 ]; then
+ note="1回目は ${why}ため、ツールを使わない指示で再実行した(1回目の会話 ID: ${first_id})"
+ run_agy "${intro}
+${no_tools}
+
+${body}" "$left"
+ else
+ note="1回目は ${why}が、残り時間が足りないため再実行しなかった(1回目の会話 ID: ${first_id})"
+ fi
+fi
+
+python3 - "$out" "$err" "$rc" "$MODEL" "$untracked" "$note" <<'PY'
+import json, sys
+
+out, err, rc, model, untracked, note = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4], sys.argv[5], sys.argv[6]
+stderr = open(err, encoding="utf-8", errors="replace").read().strip()
+cid = ""
+
+
+def fail(msg):
+ print("CROSS_REVIEW_FAILED:", msg)
+ if cid:
+ print(f"会話 ID: {cid}(agy の記録は ~/.gemini/antigravity-cli/brain/ と conversations/ にある)")
+ if note:
+ print("注: " + note)
+ if stderr:
+ print("--- agy stderr ---")
+ print(stderr)
+ sys.exit(1)
+
+
+try:
+ d = json.load(open(out, encoding="utf-8"))
+except Exception as e:
+ fail(f"agy の出力を JSON として読めません(exit={rc}, {e})")
+cid = d.get("conversation_id") or ""
+if d.get("status") != "SUCCESS":
+ fail(f"status={d.get('status')} error={d.get('error')}")
+if "print timeout" in stderr:
+ fail("制限時間内に終わらず、途中までの応答しかありません。範囲を絞ってください")
+if d.get("denied_actions"):
+ fail("agy が許可のない操作を試みて打ち切られました: " + json.dumps(d["denied_actions"], ensure_ascii=False))
+resp = (d.get("response") or "").strip()
+if not resp:
+ fail("agy の応答が空です")
+if rc != 0:
+ fail(f"exit={rc}")
+
+u = d.get("usage") or {}
+print(f"CROSS_REVIEW_OK: model={model} duration={d.get('duration_seconds')}s tokens={u.get('total_tokens')}")
+if note:
+ print("注: " + note)
+files = [f for f in untracked.splitlines() if f.strip()]
+if files:
+ print(f"未追跡のファイル {len(files)} 件は今回のレビュー対象外:")
+ for f in files[:20]:
+ print(" " + f)
+ if len(files) > 20:
+ print(f" ...ほか {len(files) - 20} 件")
+print("--- agy review ---")
+print(resp)
+PY
+st=$?
+if [ "$st" -eq 0 ]; then
+ if [ "$worktree" = 1 ]; then
+ state_after=$(state_of)
+ if [ ! -f "$here/state.py" ]; then
+ echo "注: state.py がないので、クロスレビュー済みの記録はしなかった(コミット前の確認には使えない)"
+ elif [ -n "$state_before" ] && [ "$state_before" = "$state_after" ]; then
+ printf '%s %s\n' "$state_before" "$(date +%s)" > "$(git rev-parse --git-dir)/cross-review-ok"
+ echo "記録: この作業ツリーの状態をクロスレビュー済みとして記録した(コミット前の確認に使う)"
+ else
+ echo "注: レビューの途中で作業ツリーが変わったか、状態を計算できなかったので、クロスレビュー済みとして記録しなかった。コミットするには、もう一度実行が必要"
+ fi
+ fi
+ echo "--- 対象の差分(git diff $*) ---"
+ if [ "$bytes" -le 50000 ]; then
+ printf '%s\n' "$diff"
+ else
+ git diff --stat "$@"
+ echo "(差分が大きいので --stat だけ表示。中身は必要な箇所を読んで確かめる)"
+ fi
+fi
+exit "$st"
diff --git a/.github/cross-review/verify.md b/.github/cross-review/verify.md
new file mode 100644
index 0000000..492208b
--- /dev/null
+++ b/.github/cross-review/verify.md
@@ -0,0 +1,12 @@
+あなたは、この PR のクロスレビューの確認役です。作業ディレクトリのルートにある agy-review.txt に、agy(Gemini)がこの PR の差分をレビューした結果と、差分そのもの(`--- 対象の差分` より後)があります。
+
+1. agy-review.txt を読む。
+2. `--- agy review ---` と `--- 対象の差分` の間にある agy の指摘を1件ずつ、実際のコードと突き合わせる。該当箇所・呼び出し元・テストは Read と Grep で確かめる。
+3. PR に載せるコメントの本文だけを、日本語の Markdown で出力する。前置きや作業の経過は書かない。形式は次のとおり。
+ - **確認済み**: 実際のコードで問題を確かめたもの。根拠の file:line を付ける。
+ - **誤り**: agy の読み違い。なぜ違うかを一言添える。
+ - **判断保留**: 確かめきれなかったもの。何が分かれば判断できるかを書く。
+ - **agy が挙げていない点**: 自分で気づいた問題があれば書く。なければこの節は省く。
+
+ 該当がない節は「なし」と書く。agy の指摘がなかったときは、その旨と、自分で差分を確かめた結果を短く書く。
+4. agy の指摘を、確かめないまま事実として書かない。ファイルは変更しない。
diff --git a/.github/workflows/cross-review.yml b/.github/workflows/cross-review.yml
new file mode 100644
index 0000000..ec9a160
--- /dev/null
+++ b/.github/workflows/cross-review.yml
@@ -0,0 +1,129 @@
+name: Cross review
+
+# PR ごとに、agy(Antigravity CLI。中のモデルは Gemini)が差分をレビューし、
+# Claude がその指摘を実際のコードで確かめて、結果を PR にコメントする。
+# ジョブが失敗するのは、レビューを実行できなかったときだけ(指摘があっても失敗にはしない)。
+#
+# 必要な Secret: GEMINI_API_KEY(Google AI Studio のキー)、CLAUDE_CODE_OAUTH_TOKEN(claude setup-token)
+# モデルを変えるときは、リポジトリの Variable CROSS_REVIEW_MODEL に agy のモデル名を入れる。
+# 無料枠のキーでは Pro が使えないので、Flash(例: gemini-3.8-flash-high)を入れる。
+# .github/cross-review/run.sh は ~/.claude/skills/cross-review/run.sh の写し。
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: cross-review-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ review:
+ # fork からの PR には Secret が渡らないので対象外(メンテナが手元で /cross-review を実行する)
+ if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }}
+ runs-on: ubuntu-latest
+ # 混雑で断られると、agy は1つのモデルで9分ほど再試行してから諦める。次のモデルに切り替える分の余裕を見ておく
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 2 # マージコミットと、その親(base 側)を取るため
+
+ - name: Install agy and Claude Code
+ run: |
+ curl -fsSL https://antigravity.google/cli/install.sh | bash
+ curl -fsSL https://claude.ai/install.sh | bash
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ mkdir -p ~/.gemini/antigravity-cli
+ echo '{"modelProvider":"gemini"}' > ~/.gemini/antigravity-cli/settings.json
+ # curl | bash は、ダウンロードに失敗しても成功扱いで進むので、ここで入ったことを確かめる
+ test -x "$HOME/.local/bin/agy" && test -x "$HOME/.local/bin/claude" \
+ || { echo "::error::agy か Claude Code のインストールに失敗しました"; exit 1; }
+
+ - name: List the models the Gemini API key can use
+ env:
+ GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
+ run: agy models || true
+
+ - name: Review with agy
+ id: agy
+ env:
+ GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
+ CROSS_REVIEW_MODEL: ${{ vars.CROSS_REVIEW_MODEL || 'gemini-3.1-pro-high' }}
+ run: |
+ # チェックアウトしたのは PR を base に重ねたマージコミットなので、親との差分が PR の変更になる。
+ # 出力を先に作業ツリーへ書くと未追跡ファイルとして数えられるので、一時ディレクトリに書いてから写す。
+ # 無料枠のキーは Pro の割り当てが 0 で(429)、Flash も混雑で断られることがある(503)。
+ # そのとき(と、混雑で制限時間を使い切ったとき)だけ、次のモデルで試す。
+ # Variable に複数のモデルを空白区切りで書くと、その順に試す。
+ set +e
+ out="$RUNNER_TEMP/agy-review.txt"
+ tried=" "
+ for m in $CROSS_REVIEW_MODEL gemini-3.8-flash-high gemini-3.7-flash-high gemini-3.6-flash-high; do
+ case "$tried" in *" $m "*) continue ;; esac
+ tried="$tried$m "
+ echo "::group::agy ($m)"
+ CROSS_REVIEW_MODEL="$m" .github/cross-review/run.sh HEAD^1 HEAD > "$out" 2>&1
+ cat "$out"
+ echo "::endgroup::"
+ # 結果の行は1行目とは限らない(git の警告などが先に出ることがある)ので、探して判定する
+ result=$(grep -m 1 '^CROSS_REVIEW_' "$out")
+ case "$result" in CROSS_REVIEW_OK*|CROSS_REVIEW_EMPTY*) break ;; esac
+ grep -qE 'Error (429|503)|RESOURCE_EXHAUSTED|UNAVAILABLE|制限時間内に終わらず' "$out" || break
+ done
+ cp "$out" agy-review.txt
+ case "$result" in
+ CROSS_REVIEW_OK*) echo "result=ok" >> "$GITHUB_OUTPUT" ;;
+ CROSS_REVIEW_EMPTY*) echo "result=empty" >> "$GITHUB_OUTPUT" ;;
+ *) echo "::error::agy のレビューを実行できませんでした(試したモデル:${tried}): ${result:-結果の行がありません}"; exit 1 ;;
+ esac
+
+ - name: Verify with Claude
+ if: steps.agy.outputs.result == 'ok'
+ env:
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ run: |
+ claude -p "$(cat .github/cross-review/verify.md)" \
+ --allowedTools "Read,Grep,Glob" --max-turns 40 \
+ --output-format json < /dev/null > claude.json || true
+ python3 - <<'PY'
+ import json, sys
+ try:
+ d = json.load(open("claude.json"))
+ except Exception as e:
+ print(f"::error::Claude の出力を読めません: {e}")
+ sys.exit(1)
+ r = (d.get("result") or "").strip()
+ if d.get("is_error") or not r:
+ print(f"::error::Claude の確認に失敗しました: {d.get('subtype')} {r[:300]}")
+ sys.exit(1)
+ open("comment.md", "w").write(r + "\n")
+ PY
+
+ - name: Post the result to the PR
+ if: steps.agy.outputs.result == 'ok'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR: ${{ github.event.pull_request.number }}
+ run: |
+ # 同じ PR には1つのコメントを上書きし続ける(目印は HTML コメント)
+ marker=''
+ {
+ echo "$marker"
+ echo "### クロスレビュー(agy → Claude)"
+ echo
+ cat comment.md
+ echo
+ echo "$(grep -m 1 '^CROSS_REVIEW_OK' agy-review.txt | sed 's/^CROSS_REVIEW_OK: //') / Cross review workflow による自動投稿"
+ } > body.md
+ id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \
+ --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$marker\"))) | .id" | tail -n 1)
+ if [ -n "$id" ]; then
+ gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@body.md > /dev/null
+ else
+ gh pr comment "$PR" --body-file body.md
+ fi