diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..bfed37a
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,11 @@
+{
+ "name": "claude-gate",
+ "owner": { "name": "taniguchi-kyoichi" },
+ "plugins": [
+ {
+ "name": "claude-gate",
+ "source": "./",
+ "description": "AI 開発の品質ゲート: ビルドID と出所照合で「できました」に証拠を義務づける。ローカル常駐デーモンへの MCP 接続 + gate-loop スキル + 公式化ガード hook"
+ }
+ ]
+}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 8cf4565..807f406 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "claude-gate",
- "version": "0.7.1",
+ "version": "0.8.0",
"description": "AI 開発の品質ゲート: ビルドID と出所照合で「できました」に証拠を義務づける。ローカル常駐デーモン(claude-gate)への MCP 接続と使い方スキルを提供",
"skills": "./skills/"
}
diff --git a/README.md b/README.md
index 2414199..a345d15 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ AI との iOS 開発で、エージェントの「できました」に証拠を
- **証拠の受理**: 観測(スクショ等)は、シミュレータ内の実物とビルドID を照合して一致したときだけ証拠になる
- **判定**: 宣言した全動作が証拠で覆われているかを、ゲートが決定論で判定する(エージェントは自己判定できない)
-- **提出**: 合格した報告の、検証されたそのソース(HEAD 一致)だけが `git push` できる
+- **提出**: 合格した報告の、検証されたそのソース(HEAD = PR 先頭)の下書きPR だけがレビュー可能にできる。共有(feature ブランチへの push・下書きPR の作成)は自由、取り込み(マージ)は人間だけ
完了報告は1本の状態機械で進む: **下書き → 証拠あり → 合格 / 不合格 / 確認できず → 提出済み**。仕組みの説明書はダッシュボードの「ガイド」画面にある。
@@ -18,7 +18,7 @@ AI との iOS 開発で、エージェントの「できました」に証拠を
|---|---|---|
| デーモン | `src/` → `dist/` | HTTP MCP サーバ(127.0.0.1:7350・マシンに1プロセス)+ CLI(`claude-gate`) |
| ダッシュボード | `dashboard/` | 人間向けの状態表示 + ガイド。デーモンが `/` で配信(読み取り専用) |
-| Claude Code プラグイン | `.claude-plugin/` + `.mcp.json` + `skills/` + `hooks/` | MCP 接続定義 + gate-loop スキル + git push 遮断 hook |
+| Claude Code プラグイン | `.claude-plugin/` + `.mcp.json` + `skills/` + `hooks/` | MCP 接続定義 + gate-loop スキル + 公式化ガード hook(ドラフト解除・マージ・デフォルトブランチ直 push の遮断) |
## セットアップ(初回だけ)
@@ -29,10 +29,11 @@ npm install && npm run build && npm link # claude-gate コマンドを PATH
claude-gate install # launchd 常駐 + 稼働確認
```
-**Claude Code 側**(どこからでも・user スコープ):
+**Claude Code 側**(どこからでも・user スコープ)。このリポジトリ自体が marketplace(`.claude-plugin/marketplace.json`)なので:
```bash
-claude plugin install claude-gate@taniguchi-kyoichi
+claude plugin marketplace add <このリポジトリのクローン先パス or no-problem-dev/claude-gate>
+claude plugin install claude-gate@claude-gate
```
これで全ディレクトリ・全セッションで gate ツールと gate-loop スキルが使える。プロジェクト側に置くファイルはゼロ。ゲート運用にするリポジトリだけ、ルートに宣言ファイル `gate.yaml`(env / worksite / checks / passline / cannot_see。全セクション任意)を置く。
@@ -59,7 +60,7 @@ claude-gate install # 直すのはこれ一発(べき等)
| 変えたもの | 反映コマンド |
|---|---|
| デーモン・ダッシュボード(`src/` `dashboard/`) | `npm run build && claude-gate install` |
-| プラグイン(`skills/` `hooks/` `.mcp.json` `.claude-plugin/`) | plugin.json の version を上げて push → `claude plugin update claude-gate@taniguchi-kyoichi` → `/reload-plugins`(ツール一覧は `/mcp reconnect`) |
+| プラグイン(`skills/` `hooks/` `.mcp.json` `.claude-plugin/`) | plugin.json の version を上げる → `claude plugin update claude-gate@claude-gate` → `/reload-plugins`(ツール一覧は `/mcp reconnect`) |
プラグインの実体は marketplace キャッシュで、ローカルリポの編集は update を回すまで配布されない。
diff --git a/dashboard/src/Diagrams.tsx b/dashboard/src/Diagrams.tsx
index 4ecdad9..dd7a3b4 100644
--- a/dashboard/src/Diagrams.tsx
+++ b/dashboard/src/Diagrams.tsx
@@ -153,7 +153,7 @@ export function ReportStateDiagram() {
提出する
-
+
diff --git a/dashboard/src/GuideView.tsx b/dashboard/src/GuideView.tsx
index a907764..1df7693 100644
--- a/dashboard/src/GuideView.tsx
+++ b/dashboard/src/GuideView.tsx
@@ -107,8 +107,10 @@ export function GuideView() {
証拠が複数ビルドやソースにまたがる報告は合格にならない(確認できず)。
- 合格した報告の、検証されたそのソース(HEAD が判定時の sha と一致)だけを
- git push できる。検証後にコミットが動いていたら拒否 — 「別物を見て OK」の提出版を締める。
+ 共有(feature ブランチへの push・下書きPR の作成)は自由。合格した報告の、
+ 検証されたそのソース(HEAD = 判定時の sha = PR の先頭)だけが
+ 下書きPR をレビュー可能にできる。検証後にコミットが動いていたら拒否 —
+ 「別物を見て OK」の提出版を締める。取り込み(マージ)は人間だけの操作。
提出済みの報告は終着(証拠の追加も不可)。
@@ -164,6 +166,10 @@ export function GuideView() {
受理を判断する常駐デーモン(この仕組みの実行実体)
worktree + ビルド置き場 + 専用シミュレータの一式
どのコミットの成果物か確定できない状態
+ feature ブランチへの push・下書きPR の作成。可逆なのでエージェントの自由領域(前提: デフォルトブランチは GitHub 側のブランチ保護で守る)
+ 共有の置き場。レビュー依頼は飛ばず、閉じれば戻る
+ 合格した報告の下書きPR をレビュー可能にする(ドラフト解除)。ゲートだけの遷移
+ 不可逆の採用。人間だけの操作 — エージェントの語彙に入れない
@@ -192,8 +198,9 @@ export function GuideView() {
見えないこと台帳。見えない動作への OK は「確認できず」に変換され、人間に渡る
稼働中} name="提出の一本化(スライス3)+ 掃除(2c)">
- 合格した報告の検証済みソースだけが push できる(submit)。PR 作成は次の実タスクで。
- 記録の掃除は人間の CLI(claude-gate forget)— エージェントは記録を消せない
+ 共有(push・下書きPR)は自由、合格した報告の検証済みソースだけがレビュー依頼できる(submit =
+ ドラフト解除)、取り込みは人間だけ。記録の掃除は人間の CLI(claude-gate forget)—
+ エージェントは記録を消せない
diff --git a/dashboard/src/ReportsTab.tsx b/dashboard/src/ReportsTab.tsx
index 45cf3b7..3766fdc 100644
--- a/dashboard/src/ReportsTab.tsx
+++ b/dashboard/src/ReportsTab.tsx
@@ -122,9 +122,22 @@ function ReportCard({
{report.submission !== undefined && (
- {report.submission.remote}/{report.submission.branch} へ{" "}
- {report.submission.sha.slice(0, 7)} を push 済み(
- {formatTime(report.submission.pushedAt)})。検証したソースと同一であることをゲートが照合した上での提出。
+ {report.submission.prNumber !== undefined ? (
+ <>
+
+ PR #{report.submission.prNumber}
+
+ (先頭 {report.submission.sha.slice(0, 7)})をレビュー可能にした(
+ {formatTime(report.submission.readiedAt ?? "")})。検証したソース = HEAD = PR
+ 先頭であることをゲートが照合した上での提出。取り込みは人間の操作。
+ >
+ ) : (
+ <>
+ {report.submission.remote}/{report.submission.branch} へ{" "}
+ {report.submission.sha.slice(0, 7)} を push 済み(
+ {formatTime(report.submission.pushedAt ?? "")})。旧形式(提出 = push)の記録。
+ >
+ )}
)}
diff --git a/dashboard/src/lib.ts b/dashboard/src/lib.ts
index dc02db3..091dcbf 100644
--- a/dashboard/src/lib.ts
+++ b/dashboard/src/lib.ts
@@ -15,6 +15,7 @@ export interface GateEvent {
verdict?: string;
sha?: string;
branch?: string;
+ prNumber?: number;
alreadyRegistered?: boolean;
alreadyAttached?: boolean;
alreadyOpened?: boolean;
@@ -92,7 +93,10 @@ export interface Submission {
sha: string;
branch: string;
remote: string;
- pushedAt: string;
+ prNumber?: number; // レビュー可能にした PR。旧形式(提出 = push)の記録には無い
+ prUrl?: string;
+ readiedAt?: string;
+ pushedAt?: string; // 旧形式(提出 = push)の記録のみ
}
export interface Report {
@@ -282,7 +286,10 @@ export function eventSentence(event: GateEvent): string {
}
if (event.tool === "submit") {
if (event.result !== "ok") return "提出を拒否";
- return event.alreadySubmitted ? "提出(既提出の返却)" : `提出した — ${event.branch ?? ""} を push`;
+ if (event.alreadySubmitted) return "提出(既提出の返却)";
+ return event.prNumber !== undefined
+ ? `提出した — PR #${event.prNumber} をレビュー可能にした`
+ : `提出した — ${event.branch ?? ""} を push`;
}
if (event.tool === "forget") {
return "記録を掃除(人間の操作)";
diff --git a/docs/architecture.md b/docs/architecture.md
index 4ba73b3..9934299 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -7,7 +7,7 @@
```
Claude Code セッション(対話 / 並列ワーカー / cron)… N 個
├── XcodeBuildMCP ──── ビルド・テスト・シミュレータ操作(ゲートは関与しない)
- ├── claude-gate プラグイン ── MCP 接続 + gate-loop スキル + PreToolUse hook(git push 遮断)
+ ├── claude-gate プラグイン ── MCP 接続 + gate-loop スキル + PreToolUse hook(公式化ガード)
└── claude-gate デーモン(HTTP MCP・127.0.0.1:7350・マシンに1プロセス・launchd 常駐)
├─ tools: ping / open_report / register_build / attach_evidence / run_check / judge / submit
├─ kernel: store(状態)+ audit(監査)+ api(ダッシュボード読み取りモデル)
@@ -21,6 +21,7 @@ Claude Code セッション(対話 / 並列ワーカー / cron)… N 個
- **申告を信用しない**: ビルドID はゲートが .app の中身から計算する。証拠の受理時はシミュレータ内の実物から計算し直して照合する。テスト系の確かめはエージェントの自己申告ではなく、ゲート自身が gate.yaml 宣言のコマンドを実行する(run_check)
- **実機は Mach-O UUID で照合する**: 実機からは .app を取り出せない(コンテナが取れない)ので中身ハッシュでは照合できない。代わりに register_build 時に .app 内の**全 Mach-O**(メイン実行ファイル + `.debug.dylib` + Frameworks 内バイナリ)の LC_UUID を集合で記録し、実機で走ったアプリが自分の LC_UUID を print したセルフレポート(device_report・`buildUUID=` 行)を集合と照合する。keychain 復元・課金・通知配信・上書き更新 E2E など実機でしか検証できない動作の正式な証拠経路。※ Xcode 16+ Debug は実コードを `.debug.dylib` に置きメイン実行ファイルは不変スタブになるので、アプリ側は `#dsohandle`(自分のコードが載っている image)の UUID を報告する — メイン実行ファイルだけ見ると内容の違うビルドが同じ UUID になり古いビルドを受理してしまう(実測)
- **判定は決定論**: judge は pure function(報告 + 証拠 + builds + gate.yaml + 見えないこと台帳 → 判定)。LLM なし。ゴールデンテストで固定
+- **共有は自由、公式化はゲート、取り込みは人間**: feature ブランチへの push と下書きPR の作成は縛らない。ドラフト解除(提出)は submit が sourceSha = HEAD = PR 先頭の三点照合を通してから行い、マージはエージェントの語彙に入れない(forget と同格)。hook は入口の誘導であって壁ではない(パターン照合は破れる)— 破れない壁は GitHub 側のデフォルトブランチ保護に置く。この境界線は事故由来ではなく人間の設計判断(2026-07、下書きPR 運用 + ブランチ保護を前提に feature ブランチへの push はリスク極小と判断)
- **全操作べき等**: ID は乱数ではなく中身・対象から決める。同じ呼び出しを2回しても状態は1つ。再実行・リトライ・スケジュール実行が安全
- **セッション状態を持たない**: 全ツールが対象(worksitePath 等)を明示引数で受ける。並列で何セッション繋がっても混線しない
- **silent fallback 禁止**: 失敗は必ず rejected(reason + fix)として表面化する
@@ -51,7 +52,8 @@ src/
tools/ # MCP ツールの本体(open_report / register_build / attach_evidence /
# run_check / judge / submit)
dashboard/ # React ダッシュボード(HeroUI v3 + Tailwind v4。設計 = docs/dashboard-design.md)
-hooks/ # PreToolUse hook(guard-push.sh: gate.yaml のあるリポでエージェントの git push を遮断)
+hooks/ # PreToolUse hook(guard-official.sh: gate.yaml のあるリポで公式化操作 —
+ # ドラフト解除・マージ・非ドラフト PR 作成・デフォルトブランチ直 push — を遮断)
skills/gate-loop/ # 使い方スキル(いつ・どの順でツールを呼ぶか)
.claude-plugin/ # Claude Code プラグイン定義(バージョンはデーモンと独立)
.mcp.json # プラグインの MCP 接続定義(→ http://127.0.0.1:7350/mcp)
@@ -97,4 +99,5 @@ rejected は「何がダメか(reason)」と「どうすれば通るか(fix)」
- 中心は **A1 再現テスト**(attach_evidence: 古いビルドのスクショは拒否される)と **judge のゴールデンテスト**(入力組合せ → 期待判定。「覆いは動作ごとに最新の適合証拠1件」— 証拠の積み上がりが同一ソース要件を永久に破る回帰を含む)
- **実機レポート**は照合ロジックを分けてテスト: dwarfdump 出力・セルフレポートの UUID パース(`macho_uuid.test.ts`・実バイナリ非依存)と、attach_evidence(device_report)の受理/拒否(`device_report.test.ts`・register_build に UUID を注入)
-- submit はローカル bare リモートへの実 push で検証(全拒否経路 + べき等)
+- submit はローカル bare リモートへの実 push + PATH に注入した偽 gh(pr view / pr ready を状態ファイルで再現)で検証(全拒否経路 + べき等)
+- 公式化ガード hook は bash をそのまま起動し、JSON 入力 → 終了コードのテーブルで検証(`guard_official.test.ts`)
diff --git a/hooks/guard-official.sh b/hooks/guard-official.sh
new file mode 100755
index 0000000..012fc7e
--- /dev/null
+++ b/hooks/guard-official.sh
@@ -0,0 +1,119 @@
+#!/bin/bash
+# ゲート運用リポジトリ(gate.yaml あり)で、作業を「公式化」する操作だけを遮断する。
+# 境界線(src/ios/words.ts): 共有(feature ブランチへの push・下書きPR の作成)は自由 /
+# 提出(ドラフト解除)はゲートの submit だけ / 取り込み(merge)は人間だけ。
+# - この hook は入口の誘導であって壁ではない(パターン照合は破れる)。破れない壁は
+# GitHub 側のデフォルトブランチ保護に置く(docs/architecture.md)
+# - スコープ: gate.yaml をルートに置いたリポジトリだけ(宣言と強制が一致。他リポは素通し)
+# - 縛るのはエージェントのツール実行だけ。人間がターミナルから操作する自由はそのまま
+# - 判定できないときは通す(このスクリプトの失敗で無関係な作業を壊さない)
+set -u
+
+INPUT=$(cat)
+COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0
+[ -n "$COMMAND" ] || exit 0
+
+# git / gh を含まないコマンドは対象外
+printf '%s' "$COMMAND" | grep -qE '(^|[;&|[:space:]])(git|gh)[[:space:]]' || exit 0
+
+CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
+
+# 実行されうるディレクトリの候補: git -C / cd / セッションの cwd
+CANDIDATES=()
+C_PATH=$(printf '%s' "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p' | head -1)
+[ -n "$C_PATH" ] && CANDIDATES+=("$C_PATH")
+CD_PATH=$(printf '%s' "$COMMAND" | sed -nE 's/.*(^|[;&|[:space:]])cd[[:space:]]+([^[:space:];&|]+).*/\2/p' | head -1)
+[ -n "$CD_PATH" ] && CANDIDATES+=("$CD_PATH")
+[ -n "$CWD" ] && CANDIDATES+=("$CWD")
+[ ${#CANDIDATES[@]} -gt 0 ] || exit 0
+
+ROOT=""
+WORKDIR=""
+for DIR in "${CANDIDATES[@]}"; do
+ DIR="${DIR/#\~/$HOME}"
+ [ -d "$DIR" ] || continue
+ R=$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null) || continue
+ if [ -f "$R/gate.yaml" ]; then
+ ROOT="$R"
+ WORKDIR="$DIR"
+ break
+ fi
+done
+[ -n "$ROOT" ] || exit 0 # ゲート運用リポジトリでなければ素通し
+
+deny() {
+ echo "$1" >&2
+ exit 2
+}
+
+has() { printf '%s' "$COMMAND" | grep -qE "$1"; }
+
+# --- 取り込み(merge)は人間だけ(エージェントの語彙に無い操作) ---
+if has '(^|[;&|[:space:]])gh[[:space:]]+pr[[:space:]]+merge([[:space:]]|$)'; then
+ deny "このリポジトリ($ROOT)はゲート運用。取り込み(merge)は人間だけの操作 — エージェントは PR をマージできない。合格した報告を submit でレビュー可能にし、取り込みは人間に依頼する。"
+fi
+if has '(^|[;&|[:space:]])gh[[:space:]]+api[[:space:]]' && has '(pulls/[^[:space:]]*/merge|mergePullRequest|enablePullRequestAutoMerge)'; then
+ deny "このリポジトリ($ROOT)はゲート運用。API 経由の取り込み(merge)も人間だけの操作 — 合格した報告を submit でレビュー可能にし、取り込みは人間に依頼する。"
+fi
+
+# --- 提出(ドラフト解除)はゲートの submit だけ ---
+if has '(^|[;&|[:space:]])gh[[:space:]]+pr[[:space:]]+ready([[:space:]]|$)' || has 'markPullRequestReadyForReview'; then
+ deny "このリポジトリ($ROOT)はゲート運用。ドラフト解除(提出)は gate の submit で行う — 合格した報告の、検証したソース = HEAD = PR 先頭の照合を通ったものだけがレビュー可能になる。報告が未判定なら judge、未合格なら証拠を集め直す。人間がターミナルから操作する自由はそのまま。"
+fi
+
+# --- PR の作成は下書きだけ(レビュー依頼が飛ぶ非ドラフト作成は提出と同じ公式化) ---
+if has '(^|[;&|[:space:]])gh[[:space:]]+pr[[:space:]]+create([[:space:]]|$)'; then
+ if ! has '(^|[[:space:]])(--draft|-d)([[:space:]]|$|=)'; then
+ deny "このリポジトリ($ROOT)はゲート運用。PR は --draft を付けて下書きで作る(共有は自由)。レビュー依頼(ドラフト解除)は合格した報告の submit だけが行える。"
+ fi
+fi
+
+# --- デフォルトブランチへの直接 push は取り込み相当(feature ブランチへの push は自由) ---
+if has '(^|[;&|[:space:]])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+([^[:space:]]+[[:space:]]+)*push([[:space:]]|$)'; then
+ DEFAULT=$(git -C "$ROOT" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
+ # origin/HEAD 未設定なら慣例名で判定する(それでも判定できない refspec は通す)
+ DEFAULTS="${DEFAULT:-main master develop}"
+
+ # push 以降・最初のコマンド区切りまでの引数を取り出す
+ ARGS=$(printf '%s' "$COMMAND" | sed -nE 's/.*[[:space:]]push([[:space:]]+(.*))?$/\2/p' | sed -E 's/[;&|].*$//')
+ REMOTE_SEEN=0
+ SKIP_NEXT=0
+ DESTS=""
+ for W in $ARGS; do
+ if [ "$SKIP_NEXT" = 1 ]; then
+ SKIP_NEXT=0
+ continue
+ fi
+ case "$W" in
+ --repo | --push-option | -o) SKIP_NEXT=1 ;;
+ -*) ;; # その他のオプションは refspec ではない
+ *)
+ if [ "$REMOTE_SEEN" = 0 ]; then
+ REMOTE_SEEN=1 # 最初の非オプションは remote 名
+ else
+ DESTS="$DESTS ${W##*:}" # refspec の宛先(コロンが無ければ全体)
+ fi
+ ;;
+ esac
+ done
+ # refspec が無い push は現在のブランチに向かう
+ if [ -z "${DESTS// /}" ]; then
+ DESTS=$(git -C "$WORKDIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
+ fi
+
+ for DST in $DESTS; do
+ DST="${DST#+}"
+ DST="${DST#refs/heads/}"
+ if [ "$DST" = "HEAD" ]; then
+ DST=$(git -C "$WORKDIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
+ fi
+ [ -n "$DST" ] || continue
+ for DEF in $DEFAULTS; do
+ if [ "$DST" = "$DEF" ]; then
+ deny "このリポジトリ($ROOT)はゲート運用。デフォルトブランチ($DST)への直接 push は取り込み相当 — エージェントは行えない。feature ブランチへ push して下書きPR を作り(共有は自由)、合格した報告を submit でレビュー可能にする。取り込みは人間に依頼する。"
+ fi
+ done
+ done
+fi
+
+exit 0
diff --git a/hooks/guard-push.sh b/hooks/guard-push.sh
deleted file mode 100755
index 6561111..0000000
--- a/hooks/guard-push.sh
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/bin/bash
-# ゲート運用リポジトリ(gate.yaml あり)でのエージェントの git push を遮断する。
-# 入口の構造化(お願いではなく決定論): 提出はゲートの submit に一本化する。
-# - スコープ: gate.yaml をルートに置いたリポジトリだけ(宣言と強制が一致。他リポは素通し)
-# - 縛るのはエージェントのツール実行だけ。人間がターミナルから push する自由はそのまま
-# - 判定できないときは通す(このスクリプトの失敗で無関係な作業を壊さない)
-set -u
-
-INPUT=$(cat)
-COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0
-[ -n "$COMMAND" ] || exit 0
-
-# git push を含まないコマンドは対象外
-printf '%s' "$COMMAND" | grep -qE '(^|[;&|[:space:]])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+([^[:space:]]+[[:space:]]+)*push([[:space:]]|$)' || exit 0
-
-CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
-
-# push が実行されうるディレクトリの候補: git -C / cd / セッションの cwd
-CANDIDATES=()
-C_PATH=$(printf '%s' "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p' | head -1)
-[ -n "$C_PATH" ] && CANDIDATES+=("$C_PATH")
-CD_PATH=$(printf '%s' "$COMMAND" | sed -nE 's/.*(^|[;&|[:space:]])cd[[:space:]]+([^[:space:];&|]+).*/\2/p' | head -1)
-[ -n "$CD_PATH" ] && CANDIDATES+=("$CD_PATH")
-[ -n "$CWD" ] && CANDIDATES+=("$CWD")
-
-for DIR in "${CANDIDATES[@]}"; do
- DIR="${DIR/#\~/$HOME}"
- [ -d "$DIR" ] || continue
- ROOT=$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null) || continue
- if [ -f "$ROOT/gate.yaml" ]; then
- echo "このリポジトリ($ROOT)はゲート運用(gate.yaml あり)。エージェントの git push は使えない — 提出は gate の submit で行う(合格した報告と、検証したソース == HEAD の照合を通ったものだけが push される)。報告が未判定なら judge、未合格なら証拠を集め直す。人間がターミナルから push するのは自由。" >&2
- exit 2
- fi
-done
-
-exit 0
diff --git a/hooks/hooks.json b/hooks/hooks.json
index 21f4a91..e8cd526 100644
--- a/hooks/hooks.json
+++ b/hooks/hooks.json
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
- "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/guard-push.sh"
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/guard-official.sh"
}
]
}
diff --git a/package-lock.json b/package-lock.json
index 32cb80c..a2156dd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "claude-gate",
- "version": "0.8.0",
+ "version": "0.11.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-gate",
- "version": "0.8.0",
+ "version": "0.11.3",
"dependencies": {
"@heroui/react": "^3.2.2",
"@heroui/styles": "^3.2.2",
diff --git a/package.json b/package.json
index 1ba1d90..d3376fe 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "claude-gate",
- "version": "0.11.3",
+ "version": "0.12.0",
"private": true,
"type": "module",
"engines": {
diff --git a/skills/gate-loop/SKILL.md b/skills/gate-loop/SKILL.md
index 3177154..703002f 100644
--- a/skills/gate-loop/SKILL.md
+++ b/skills/gate-loop/SKILL.md
@@ -187,19 +187,21 @@ judge(worksitePath: <同上>, reportId: <手順0の reportId>)
- 判定後に証拠を足すと判定は無効になり 証拠あり に戻る(再 judge が必要)
- 証拠が複数ビルド・複数ソースにまたがると合格しない。**最後のビルドで全証拠を取り直すのが正道**
-### 6. 提出はゲートで行う
+### 6. 共有は自由、提出はゲートで、取り込みは人間
-合格した報告を外に出す(push)ときは、自分で `git push` せず:
+**共有(feature ブランチへの `git push`・下書きPR の作成 `gh pr create --draft`)は自由領域** — ゲートを通さずいつでもできる(前提: デフォルトブランチは GitHub 側のブランチ保護で守られている)。公式化だけがゲートと人間の領分:
```
submit(worksitePath: <同上>, reportId: <合格した報告>)
-→ 検証したソース(judge 時の sha)と HEAD の一致を照合した上で git push origin HEAD
+→ 検証したソース(judge 時の sha)= HEAD = PR 先頭 の三点照合を通した上で、
+ git push origin HEAD + 下書きPR をレビュー可能にする(ドラフト解除)
```
- **検証後にコミットが動いていたら拒否される**(積んだ/戻した)→ いまの HEAD で証拠を取り直して judge → submit し直す
- dirty なソースで検証された報告は提出できない(クリーンで検証し直す)
+- **このブランチの PR が無ければ拒否される** → `gh pr create --draft` で下書きPR を作ってから submit し直す
- 提出済みの報告は終着。証拠の追加もできない — 続きは新しい作業名で開く
-- PR 作成は現時点ではゲート外(人間と相談)。**ゲートを通らない git push はしない**
+- **取り込み(マージ)は人間だけの操作**。エージェントは `gh pr ready` / `gh pr merge` / デフォルトブランチへの直接 push / 非ドラフトの PR 作成を行わない(hook が遮断する)— 提出後は人間にレビューと取り込みを依頼する
### 7. nextSteps と note を読む
@@ -211,7 +213,8 @@ submit(worksitePath: <同上>, reportId: <合格した報告>)
**Do:**
- 作業のはじめに gate.yaml を読み、open_report で動作一覧(変更の種類 + 確かめ方)を宣言する
- ビルドし直したら register_build し直す(ビルドID はビルドごとに変わる)
-- テスト系の証拠は run_check(ゲートが実行)、判定は judge(ゲートが照合)、push は submit(ゲートが照合して実行)に任せる
+- テスト系の証拠は run_check(ゲートが実行)、判定は judge(ゲートが照合)、レビュー依頼は submit(ゲートが照合してドラフト解除)に任せる
+- 共有は自由に使う: feature ブランチへの push・下書きPR の作成はゲートを通さなくてよい
- 「確認できず」はそのまま人間に渡す(reason と代わりの確認手段を添えて)
- 拒否も含めて起きたことをそのまま報告する(証拠が受理できなかったなら「確認できていない」と言う)
- 記録の掃除(テスト痕跡・プローブの削除)が必要になったら人間に依頼する(`claude-gate forget` は人間の CLI)
@@ -223,4 +226,4 @@ submit(worksitePath: <同上>, reportId: <合格した報告>)
- 「確認できず」を OK・完了に言い換える
- rejected の後、原因を直さずに再試行だけ繰り返す
- ゲートを通さずに「見た感じ大丈夫」で済ませる
-- ゲートを通さずに git push する(提出は submit だけ)
+- ゲートを通さずに公式化する: `gh pr ready`・`gh pr merge`・非ドラフトの PR 作成・デフォルトブランチへの直接 push はしない(提出は submit、取り込みは人間)
diff --git a/src/cli.ts b/src/cli.ts
index b77fbde..975234e 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -184,7 +184,7 @@ async function doctor(): Promise {
list.includes("claude-gate@"),
"Claude Code プラグイン",
list.includes("claude-gate@") ? "導入済み(gate MCP + gate-loop スキル)" : "未導入",
- "claude plugin install claude-gate@taniguchi-kyoichi を実行してください",
+ "claude plugin marketplace add <このリポジトリのパス> && claude plugin install claude-gate@claude-gate を実行してください",
);
} catch {
check(false, "Claude Code プラグイン", "claude CLI が見つからない", "Claude Code をインストールしてください");
diff --git a/src/ios/github.ts b/src/ios/github.ts
new file mode 100644
index 0000000..dcda53f
--- /dev/null
+++ b/src/ios/github.ts
@@ -0,0 +1,64 @@
+import { execFileSync } from "node:child_process";
+
+// GitHub CLI(gh)の呼び出し。デーモンは launchd 常駐で PATH が最小(/usr/bin:/bin 等)のため、
+// PATH で見つからなければ Homebrew の定位置を探す。失敗は呼び出し側が rejected にする(silent fallback 禁止)
+
+const GH_CANDIDATES = ["gh", "/opt/homebrew/bin/gh", "/usr/local/bin/gh"];
+
+export function resolveGh(): string | null {
+ for (const gh of GH_CANDIDATES) {
+ try {
+ execFileSync(gh, ["--version"], { stdio: "ignore" });
+ return gh;
+ } catch {
+ // 次の候補へ
+ }
+ }
+ return null;
+}
+
+// 下書きPR(共有の置き場)の照合に使う最小の形
+export interface PullRequest {
+ number: number;
+ url: string;
+ isDraft: boolean;
+ state: string; // OPEN / CLOSED / MERGED
+ headRefOid: string;
+}
+
+export type PrLookup =
+ | { status: "found"; pr: PullRequest }
+ | { status: "none" } // このブランチの PR が無い
+ | { status: "error"; detail: string };
+
+export function prView(gh: string, worksitePath: string): PrLookup {
+ try {
+ const out = execFileSync(gh, ["pr", "view", "--json", "number,url,isDraft,state,headRefOid"], {
+ cwd: worksitePath,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ return { status: "found", pr: JSON.parse(out) as PullRequest };
+ } catch (error) {
+ const stderr = ((error as { stderr?: string }).stderr ?? String(error)).trim();
+ if (stderr.includes("no pull requests found")) return { status: "none" };
+ return { status: "error", detail: stderr };
+ }
+}
+
+export function prReady(
+ gh: string,
+ worksitePath: string,
+ prNumber: number,
+): { ok: true } | { ok: false; detail: string } {
+ try {
+ execFileSync(gh, ["pr", "ready", String(prNumber)], {
+ cwd: worksitePath,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ return { ok: true };
+ } catch (error) {
+ return { ok: false, detail: ((error as { stderr?: string }).stderr ?? String(error)).trim() };
+ }
+}
diff --git a/src/ios/tools/submit.ts b/src/ios/tools/submit.ts
index 8d4f230..babfd58 100644
--- a/src/ios/tools/submit.ts
+++ b/src/ios/tools/submit.ts
@@ -3,12 +3,14 @@ import { join } from "node:path";
import { appendEvent } from "../../kernel/audit.js";
import { repoDirOf, writeJson } from "../../kernel/store.js";
import { gitDirty, gitSha } from "../git.js";
+import { prReady, prView, resolveGh } from "../github.js";
import { readReport } from "../report_link.js";
import type { Reply, Report } from "../words.js";
-// 提出する: 合格した報告の、検証されたそのソースだけが push できる(A7 / K-5)。
-// 合格だけでは足りない — 判定が検証したソース(sourceSha)と HEAD の機械照合を通す。
-// 検証後に積んだ・巻き戻したソースの push は「別物を見て OK」の提出版として拒否する。
+// 提出する: 合格した報告の、検証されたそのソースの下書きPR だけがレビュー可能にできる(A7 / K-5)。
+// 合格だけでは足りない — 判定が検証したソース(sourceSha)と HEAD と PR 先頭の三点照合を通す。
+// 検証後に積んだ・巻き戻したソースの提出は「別物を見て OK」の提出版として拒否する。
+// 共有(feature ブランチへの push・下書きPR の作成)は自由領域、取り込み(merge)は人間だけ(words.ts の境界線)。
export interface SubmitArgs {
worksitePath: string;
@@ -38,7 +40,7 @@ export function submit(args: SubmitArgs): Reply {
return {
status: "ok",
state: report,
- note: `既提出の報告(提出: ${report.submission?.pushedAt})。push はし直さない`,
+ note: `既提出の報告(提出: ${report.submission?.readiedAt ?? report.submission?.pushedAt})。提出はし直さない`,
nextSteps: [],
};
}
@@ -97,15 +99,77 @@ export function submit(args: SubmitArgs): Reply {
]);
}
+ const gh = resolveGh();
+ if (gh === null) {
+ return reject(
+ "GitHub CLI(gh)が見つからない",
+ "gh をインストールし gh auth login を済ませてから提出し直してください",
+ ["submit"],
+ );
+ }
+ const lookup = prView(gh, args.worksitePath);
+ if (lookup.status === "none") {
+ return reject(
+ `このブランチ(${branch})の PR が無い`,
+ "gh pr create --draft で下書きPR を作ってから submit し直してください(下書きPR の作成は自由領域)",
+ ["submit"],
+ );
+ }
+ if (lookup.status === "error") {
+ return reject(
+ `gh の実行に失敗した: ${lookup.detail}`,
+ "gh auth status で認証を確認してから提出し直してください",
+ ["submit"],
+ );
+ }
+ const pr = lookup.pr;
+ if (pr.state !== "OPEN") {
+ return reject(
+ `PR #${pr.number} が${pr.state === "MERGED" ? "取り込み済み" : "閉じられている"}`,
+ "続きの作業は新しいブランチ + 下書きPR で開き直してください",
+ ["open_report"],
+ );
+ }
+ if (pr.headRefOid !== sourceSha) {
+ return reject(
+ `PR #${pr.number} の先頭(${pr.headRefOid.slice(0, 7)})が検証したソース(${sourceSha.slice(0, 7)})と違う`,
+ "PR のブランチと作業ブランチが一致しているか確認し、push の反映後に submit し直してください",
+ ["submit"],
+ );
+ }
+ let note: string;
+ if (pr.isDraft) {
+ const readied = prReady(gh, args.worksitePath, pr.number);
+ if (!readied.ok) {
+ return reject(
+ `ドラフト解除に失敗した: ${readied.detail}`,
+ "gh auth status と PR への権限を確認してから提出し直してください",
+ ["submit"],
+ );
+ }
+ note = `提出した: PR #${pr.number} をレビュー可能にした(検証したソース ${sourceSha.slice(0, 7)} = HEAD = PR 先頭)。この報告は終着 — 続きは新しい作業名で`;
+ } else {
+ note = `PR #${pr.number} は既にレビュー可能だった(照合は通っているので記録だけ残す)。この報告は終着 — 続きは新しい作業名で`;
+ }
+
report.state = "submitted";
- report.submission = { sha: sourceSha, branch, remote, pushedAt: new Date().toISOString() };
+ report.submission = {
+ sha: sourceSha,
+ branch,
+ remote,
+ prNumber: pr.number,
+ prUrl: pr.url,
+ readiedAt: new Date().toISOString(),
+ };
writeJson(join(gateDir, "reports", `${report.reportId}.json`), report);
- appendEvent(gateDir, { tool: "submit", result: "ok", reportId: report.reportId, sha: sourceSha, branch });
+ appendEvent(gateDir, {
+ tool: "submit",
+ result: "ok",
+ reportId: report.reportId,
+ sha: sourceSha,
+ branch,
+ prNumber: pr.number,
+ });
appendEvent(gateDir, { tool: "report_state", result: "ok", reportId: report.reportId, state: "submitted" });
- return {
- status: "ok",
- state: report,
- note: `提出した: ${remote}/${branch} へ ${sourceSha.slice(0, 7)} を push(検証したソースと同一)。この報告は終着 — 続きは新しい作業名で`,
- nextSteps: [],
- };
+ return { status: "ok", state: report, note, nextSteps: [] };
}
diff --git a/src/ios/words.ts b/src/ios/words.ts
index e3498bd..85abb1b 100644
--- a/src/ios/words.ts
+++ b/src/ios/words.ts
@@ -134,12 +134,21 @@ export interface Judgment {
judgedAt: string;
}
+// 外に出す行為の境界線(2026-07 の再定義。人間の設計判断 — 事故由来ではなく、
+// 「下書きPR 運用 + デフォルトブランチ保護を前提に、feature ブランチへの push のリスクは極小」という判断):
+// - 共有(share): feature ブランチへの push・下書きPR(draft PR)の作成。可逆なのでエージェントの自由領域
+// - 提出(submit): 検証された報告の下書きPR をレビュー可能にする(ドラフト解除)。ゲートだけの遷移
+// - 取り込み(merge): 不可逆の採用。人間だけの操作 — エージェントの語彙に入れない(掃除 forget と同格)
+
// 提出の記録: submit が報告に保存する(FSM の終着)
export interface Submission {
sha: string;
branch: string;
remote: string;
- pushedAt: string;
+ prNumber?: number; // レビュー可能にした PR。旧形式(提出 = push)の記録には無い
+ prUrl?: string;
+ readiedAt?: string; // ドラフト解除の時刻
+ pushedAt?: string; // 旧形式(提出 = push)の記録のみ
}
// 完了報告: エージェントの「できました」の型。動作一覧はオープン時に固定(変えたいなら別の作業名で開く)
diff --git a/src/kernel/server.ts b/src/kernel/server.ts
index 85c1bf9..f5739c4 100644
--- a/src/kernel/server.ts
+++ b/src/kernel/server.ts
@@ -149,7 +149,7 @@ function newServer(): McpServer {
"submit",
{
description:
- "提出する。合格した報告の、検証されたそのソース(sourceSha = HEAD)だけを git push origin HEAD する。合格していない報告・検証後にコミットが動いた状態では提出できない。提出済みの報告は終着(証拠の追加も不可)",
+ "提出する。合格した報告の、検証されたそのソース(sourceSha = HEAD = PR 先頭)の下書きPR をレビュー可能にする(git push origin HEAD + ドラフト解除)。共有(feature ブランチへの push・下書きPR の作成)は自由、取り込み(merge)は人間だけ。合格していない報告・検証後にコミットが動いた状態・PR が無い状態では提出できない。提出済みの報告は終着(証拠の追加も不可)",
inputSchema: {
worksitePath: z.string().describe("作業場(worktree)のパス"),
reportId: z.string().describe("提出する報告(合格している必要がある)"),
diff --git a/test/guard_official.test.ts b/test/guard_official.test.ts
new file mode 100644
index 0000000..8b24c73
--- /dev/null
+++ b/test/guard_official.test.ts
@@ -0,0 +1,122 @@
+import { execFileSync, spawnSync } from "node:child_process";
+import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { beforeEach, describe, expect, it } from "vitest";
+
+// 公式化ガード hook: 共有(feature ブランチへの push・下書きPR の作成)は通し、
+// 公式化(ドラフト解除・マージ・非ドラフト PR 作成・デフォルトブランチ直 push)だけを遮断する。
+// bash をそのまま起動し、JSON 入力 → 終了コード(0 = 通す / 2 = 遮断)のテーブルで検証する
+
+const hookPath = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks", "guard-official.sh");
+
+let worksite: string;
+
+function git(...args: string[]): string {
+ return execFileSync("git", ["-C", worksite, ...args], { encoding: "utf8" }).trim();
+}
+
+function runHook(command: string, cwd: string = worksite): { status: number; stderr: string } {
+ const input = JSON.stringify({ tool_input: { command }, cwd });
+ const result = spawnSync("bash", [hookPath], { input, encoding: "utf8" });
+ return { status: result.status ?? -1, stderr: result.stderr };
+}
+
+beforeEach(() => {
+ worksite = mkdtempSync(join(tmpdir(), "gate-guard-"));
+ const bare = mkdtempSync(join(tmpdir(), "gate-guard-origin-")) + "/origin.git";
+ execFileSync("git", ["init", "-q", "--bare", bare]);
+ execFileSync("git", ["-C", worksite, "init", "-q", "-b", "develop"]);
+ git("remote", "add", "origin", bare);
+ writeFileSync(join(worksite, "gate.yaml"), "checks: {}\n");
+ git("add", "-A");
+ execFileSync("git", ["-C", worksite, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "init"]);
+ // origin/HEAD = develop(デフォルトブランチの解決に使う)
+ git("update-ref", "refs/remotes/origin/develop", "HEAD");
+ git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/develop");
+});
+
+describe("共有は通す", () => {
+ it("feature ブランチへの push", () => {
+ expect(runHook("git push origin feature/foo").status).toBe(0);
+ });
+
+ it("feature ブランチ上の refspec なし push", () => {
+ git("checkout", "-q", "-b", "feature/foo");
+ expect(runHook("git push").status).toBe(0);
+ });
+
+ it("feature ブランチ上の git push origin HEAD", () => {
+ git("checkout", "-q", "-b", "feature/foo");
+ expect(runHook("git push origin HEAD").status).toBe(0);
+ });
+
+ it("下書きPR の作成", () => {
+ expect(runHook('gh pr create --draft --title "t" --body "b"').status).toBe(0);
+ });
+
+ it("タグの push", () => {
+ expect(runHook("git push origin dev.1.22.2-test").status).toBe(0);
+ });
+
+ it("git / gh を含まないコマンド", () => {
+ expect(runHook("ls -la").status).toBe(0);
+ });
+});
+
+describe("公式化は遮断する", () => {
+ it("デフォルトブランチへの直接 push", () => {
+ const result = runHook("git push origin develop");
+ expect(result.status).toBe(2);
+ expect(result.stderr).toContain("デフォルトブランチ");
+ });
+
+ it("refspec 経由(HEAD:develop)", () => {
+ expect(runHook("git push origin HEAD:develop").status).toBe(2);
+ });
+
+ it("デフォルトブランチ上の refspec なし push", () => {
+ expect(runHook("git push").status).toBe(2); // beforeEach の HEAD は develop
+ });
+
+ it("force push でも同じ", () => {
+ expect(runHook("git push --force origin develop").status).toBe(2);
+ });
+
+ it("非ドラフトの PR 作成", () => {
+ const result = runHook('gh pr create --title "t" --body "b"');
+ expect(result.status).toBe(2);
+ expect(result.stderr).toContain("--draft");
+ });
+
+ it("ドラフト解除", () => {
+ const result = runHook("gh pr ready 12");
+ expect(result.status).toBe(2);
+ expect(result.stderr).toContain("submit");
+ });
+
+ it("API 経由のドラフト解除", () => {
+ expect(runHook("gh api graphql -f query='mutation { markPullRequestReadyForReview... }'").status).toBe(2);
+ });
+
+ it("マージ", () => {
+ const result = runHook("gh pr merge 12 --squash");
+ expect(result.status).toBe(2);
+ expect(result.stderr).toContain("取り込み");
+ });
+
+ it("API 経由のマージ", () => {
+ expect(runHook("gh api repos/o/r/pulls/12/merge -X PUT").status).toBe(2);
+ });
+});
+
+describe("スコープ", () => {
+ it("gate.yaml の無いリポジトリは全部素通し", () => {
+ unlinkSync(join(worksite, "gate.yaml"));
+ execFileSync("git", ["-C", worksite, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-am", "rm"]);
+ expect(runHook("git push origin develop").status).toBe(0);
+ expect(runHook("gh pr merge 12").status).toBe(0);
+ expect(runHook("gh pr ready 12").status).toBe(0);
+ });
+});
diff --git a/test/submit.test.ts b/test/submit.test.ts
index 929c028..c4bcc31 100644
--- a/test/submit.test.ts
+++ b/test/submit.test.ts
@@ -10,12 +10,38 @@ import { registerBuild } from "../src/ios/tools/register_build.js";
import { runCheck } from "../src/ios/tools/run_check.js";
import { submit } from "../src/ios/tools/submit.js";
-// 提出の一本化(A7 / K-5): 合格した報告の、検証されたそのソースだけが push できる
+// 提出の一本化(A7 / K-5): 合格した報告の、検証されたそのソースの下書きPR だけがレビュー可能にできる。
+// gh は PATH に注入した偽物(pr.json を状態にする)で再現する — 実 GitHub 非依存
let worksite: string;
let bare: string;
let app: string;
let screenshot: string;
+let prFile: string;
+
+const ORIGINAL_PATH = process.env.PATH ?? "";
+
+// 偽 gh: GH_FAKE_PR(pr.json)が無ければ「PR なし」。pr ready は isDraft を false にする
+const FAKE_GH = `#!/usr/bin/env node
+const { readFileSync, writeFileSync, existsSync } = require("node:fs");
+const args = process.argv.slice(2);
+const prFile = process.env.GH_FAKE_PR;
+if (args[0] === "--version") { console.log("gh fake"); process.exit(0); }
+if (args[0] === "pr" && args[1] === "view") {
+ if (!prFile || !existsSync(prFile)) { console.error("no pull requests found for branch"); process.exit(1); }
+ console.log(readFileSync(prFile, "utf8"));
+ process.exit(0);
+}
+if (args[0] === "pr" && args[1] === "ready") {
+ if (!prFile || !existsSync(prFile)) { console.error("no pull requests found"); process.exit(1); }
+ const pr = JSON.parse(readFileSync(prFile, "utf8"));
+ pr.isDraft = false;
+ writeFileSync(prFile, JSON.stringify(pr));
+ process.exit(0);
+}
+console.error("fake gh: unsupported: " + args.join(" "));
+process.exit(1);
+`;
function git(...args: string[]): string {
return execFileSync("git", ["-C", worksite, ...args], { encoding: "utf8" }).trim();
@@ -26,6 +52,25 @@ function commitAll(message: string): void {
execFileSync("git", ["-C", worksite, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", message]);
}
+// 下書きPR の状態を偽 gh に置く(headSha 省略時は HEAD = 検証済みソースと一致)
+function putPr(overrides: Partial> = {}): void {
+ writeFileSync(
+ prFile,
+ JSON.stringify({
+ number: 12,
+ url: "https://github.com/example/repo/pull/12",
+ isDraft: true,
+ state: "OPEN",
+ headRefOid: git("rev-parse", "HEAD"),
+ ...overrides,
+ }),
+ );
+}
+
+function readPr(): { isDraft: boolean } {
+ return JSON.parse(readFileSync(prFile, "utf8")) as { isDraft: boolean };
+}
+
beforeEach(() => {
process.env.GATE_HOME = mkdtempSync(join(tmpdir(), "gate-home-"));
worksite = mkdtempSync(join(tmpdir(), "gate-worksite-"));
@@ -42,6 +87,11 @@ beforeEach(() => {
writeFileSync(join(app, "Sample"), "binary");
screenshot = join(artifacts, "screen.png");
writeFileSync(screenshot, "png-bytes");
+ const fakeBin = mkdtempSync(join(tmpdir(), "gate-fakebin-"));
+ writeFileSync(join(fakeBin, "gh"), FAKE_GH, { mode: 0o755 });
+ prFile = join(fakeBin, "pr.json");
+ process.env.PATH = `${fakeBin}:${ORIGINAL_PATH}`;
+ process.env.GH_FAKE_PR = prFile;
});
// 合格まで通す(スクショ + ゲート実行のテスト)
@@ -87,24 +137,66 @@ describe("judge — sourceSha(検証したソース)", () => {
});
describe("submit", () => {
- it("合格した報告の検証済みソースが push され、提出済みになる", () => {
+ it("合格した報告の検証済みソースが push され、下書きPR がレビュー可能になり、提出済みになる", () => {
const reportId = passedReport();
+ putPr();
const result = submit({ worksitePath: worksite, reportId });
if (result.status !== "ok") throw new Error(`expected ok: ${JSON.stringify(result)}`);
expect(result.state.state).toBe("submitted");
expect(result.state.submission?.sha).toBe(git("rev-parse", "HEAD"));
+ expect(result.state.submission?.prNumber).toBe(12);
+ expect(result.state.submission?.readiedAt).toBeDefined();
const pushed = execFileSync("git", ["-C", bare, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
expect(pushed).toBe(git("rev-parse", "HEAD")); // bare remote に実際に届いている
+ expect(readPr().isDraft).toBe(false); // ドラフトが解除されている
});
- it("再 submit はべき等(push し直さず既提出を返す)", () => {
+ it("再 submit はべき等(提出し直さず既提出を返す)", () => {
const reportId = passedReport();
+ putPr();
submit({ worksitePath: worksite, reportId });
const again = submit({ worksitePath: worksite, reportId });
if (again.status !== "ok") throw new Error("expected ok");
expect(again.note).toContain("既提出");
});
+ it("このブランチの PR が無ければ提出できない(fix は下書きPR の作成)", () => {
+ const reportId = passedReport();
+ const result = submit({ worksitePath: worksite, reportId });
+ expect(result.status).toBe("rejected");
+ if (result.status !== "rejected") throw new Error("expected rejected");
+ expect(result.reason).toContain("PR が無い");
+ expect(result.fix).toContain("--draft");
+ });
+
+ it("PR の先頭が検証したソースと違えば提出できない", () => {
+ const reportId = passedReport();
+ putPr({ headRefOid: "0000000000000000000000000000000000000000" });
+ const result = submit({ worksitePath: worksite, reportId });
+ expect(result.status).toBe("rejected");
+ if (result.status !== "rejected") throw new Error("expected rejected");
+ expect(result.reason).toContain("PR");
+ expect(result.reason).toContain("検証したソース");
+ });
+
+ it("取り込み済み・閉じられた PR には提出できない", () => {
+ const reportId = passedReport();
+ putPr({ state: "MERGED" });
+ const result = submit({ worksitePath: worksite, reportId });
+ expect(result.status).toBe("rejected");
+ if (result.status !== "rejected") throw new Error("expected rejected");
+ expect(result.reason).toContain("取り込み済み");
+ });
+
+ it("既にレビュー可能な PR は照合だけ通して記録する(べき等)", () => {
+ const reportId = passedReport();
+ putPr({ isDraft: false });
+ const result = submit({ worksitePath: worksite, reportId });
+ if (result.status !== "ok") throw new Error(`expected ok: ${JSON.stringify(result)}`);
+ expect(result.state.state).toBe("submitted");
+ expect(result.note).toContain("既にレビュー可能");
+ });
+
it("合格していない報告は提出できない", () => {
const report = openReport({
worksitePath: worksite,
@@ -139,6 +231,7 @@ describe("submit", () => {
it("提出済みの報告には証拠を足せない(終着)", () => {
const reportId = passedReport();
+ putPr();
submit({ worksitePath: worksite, reportId });
const result = runCheck({ worksitePath: worksite, check: "unit_test", reportId, behaviorIndex: 2 });
expect(result.status).toBe("rejected");