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");