diff --git a/BACKLOG.md b/BACKLOG.md index a55238d..f164803 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -75,6 +75,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-436 | 🔵 active | codex-host PreToolUse payload 驗證 probe(唯讀,驗證 CC-381 guard binding 可行性;umbrella: CC-333) | arch/install | 2026-07-02 | — | P2 | spike | | CC-437 | 🔵 active | doctor 擴充切片:host-aware capability check(`doctor.sh` 拆出 host module 介面;umbrella: CC-333,承接 CC-381) | arch/install | 2026-07-02 | — | P2 | design | | CC-438 | 🔵 active | host manifest schema v1:codex-host 設定面宣告化(`hosts/codex/host.yaml` + format handler;依賴 CC-436;umbrella: CC-333,承接 CC-381) | arch/install | 2026-07-02 | — | P2 | design | +| CC-439 | 🔵 active | `/ship ` command:明確票直接實作到開 PR,pre-flight 一致性檢查 + gate 迴圈收斂 | process/DX | 2026-07-02 | — | P2 | design | --- @@ -176,6 +177,22 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed --- +## CC-439 — `/ship ` command:明確票直接實作到開 PR 🔵 active + +**Problem**: 目前「拿到明確 backlog 票 → 直接實作 → 派 pr-gate → 修到 GO → 開 PR」這條路徑,只存在於 memory 與 `agents/project-pm.md` 的 Rules A/B 散落文字裡,主線程每次都要自己記得拼起來完整流程,且完全沒有「開工前先檢查跟已定案決策有沒有衝突」這一步。 + +**Why**: 參考 [ai-night-shift](https://github.com/JudyaiLab/ai-night-shift) 的自動化紀律(非其架構):把「implement → gate → fix → PR」收斂成一個可重複呼叫的 command,讓「丟一張明確的票」到「開出 PR」變成單一動作;同時把唯一合法卡點(票跟 BACKLOG/DECISIONS 已定案內容根本性矛盾)做成明確、可執行的第一步檢查,而不是模糊的自我判斷。 + +**Requirement**: +- 新增 `commands/ship.md`(`/ship CC-NNN` 呼叫),依 `commands/pm.md`/`commands/spike.md` 既有格式撰寫,步驟:(0) pre-flight 一致性檢查:讀該票 `BACKLOG.md` body + grep `DECISIONS.md` `**Constraints introduced**`,若根本性矛盾或 `Dependencies` 未滿足,停止並回報,不開分支;(1) 開 `feat/CC-NNN` 分支;(2) 主線程直接 Read/Edit/Write 實作,不 dispatch codex 做實作;(3) gate 迴圈:`pmctl gate run --executor codex` → 讀 `Final:` → NO-GO 時交給 `project-pm` agent 依既有 Rule A/B synthesis → 修全部 finding → 重跑,直到 GO;停止條件只有兩種(根本性不一致、或 3-strike 審查後同批 diff-caused blocker 完全原地打轉);(4) `git push` + `gh pr create`(title/body 模板:票號/摘要/跑幾輪 gate/最終 verdict);(5) 收尾報告,GO 後不自動 merge。 +- `scripts/test-commands.sh` 補結構斷言:pre-flight 段落存在、gate 迴圈段落引用 `Final:`/`pmctl gate run --executor codex`、停止條件段落明確列出兩種且只有兩種、PR 模板段落存在。 +- 不新增 `open-pr.sh` 或 DECISIONS.md 解析腳本(一致性判斷是 LLM 語意工作,不做機械化);不建背景 daemon/cron supervisor(維持互動 session 內執行);不做批次掃描 BACKLOG 自動挑票。 + +**Dependencies**: 無阻塞依賴。 +**See**: — + +--- + ## CC-393 — design: portable-skill-substrate — CLI-agnostic skill 控制層 🟢 someday **Type**: design seed(想法捕捉;非 milestone 承諾) diff --git a/commands/ship.md b/commands/ship.md new file mode 100644 index 0000000..b4a7af8 --- /dev/null +++ b/commands/ship.md @@ -0,0 +1,171 @@ +--- +description: Take one explicit backlog ticket from implementation through pr-gate to an open PR, without stopping for step-by-step confirmation. +argument-hint: "" +--- + +Run a single, explicitly named ticket end-to-end: implement → gate → fix → gate +→ open PR. This is the main-thread's own default operating discipline made +runnable as one command, not a new background/unattended supervisor — the +session stays open and you stay reachable the whole time. + +**Scope**: one ticket per invocation, named in `$ARGUMENTS`. Do not scan +`BACKLOG.md` for other candidates or batch multiple tickets in one run. + +## The one legal stopping point + +Everywhere in this flow, the only reason to stop and ask the user for a +substantive discussion instead of continuing is: **the ticket's premise +fundamentally conflicts with something already decided** — its approach +contradicts a `DECISIONS.md` entry's `**Constraints introduced**`, a named +`Dependencies` ticket is not actually done, or (discovered mid-implementation) +the ticket's own assumption turns out to be wrong. Ordinary reviewer findings +— hard gate or advisory, however many rounds it takes — are not a stopping +point; fix them and continue. This mirrors `agents/project-pm.md`'s PR-gate +verdict table and Rules A/B, which this command invokes rather than +re-implements. + +This is distinct from Step 0's plain input validation and Step 1's dirty-tree +precondition below — those are deterministic fail-fast/fail-safe checks with +one predetermined outcome each, not a negotiated decision. "The one legal +stopping point" refers only to cases where the *next action is genuinely +ambiguous* and needs the user's judgment. + +## Step 0 — Validate the ticket id, then check consistency + +**Ticket-id validation** (fail fast, not a discussion point): `/ship` only +ever acts on an active `BACKLOG.md` ticket — `BACKLOG-ARCHIVE.md` is +consulted solely to produce a precise error message, never as a source to +implement from. + +- If `$ARGUMENTS` is empty or does not match this repo's ticket-id shape + (`-` per `pm/schema.md`): stop and report "empty argument" or + "malformed shape". +- If there is no matching `## ` heading in `BACKLOG.md`: check + `BACKLOG-ARCHIVE.md`. A match there means the ticket is already terminal + (done/closed/dropped/superseded) — stop and report "ticket already + archived", not "no such ticket". No match in either file: stop and report + "no such ticket". + +Either way this is a plain input error, resolved by the caller supplying a +valid, currently-active ticket id, not something to deliberate about. + +**Consistency check**: once the ticket id resolves to an active `BACKLOG.md` +heading, read its full body (`grep -n '^## '` then `sed -n` the +section — do not full-file Read). Extract `Problem` / `Requirement` / +`Dependencies`. + +- **Dependencies**: for every ticket referenced in `Dependencies`, confirm its + status in `BACKLOG.md`'s index table (or `BACKLOG-ARCHIVE.md` if terminal) + is actually a terminal state (`✅ done`/`✅ closed`) when the requirement + reads as a hard blocker. A dependency that is merely "related" (not a + blocker) does not stop the run. +- **Decisions**: `grep -n '\*\*Constraints introduced\*\*' DECISIONS.md` and + read the handful of entries whose `Context`/`Decision` mentions the same + subsystem, files, or concept as the ticket. Judge — as PM-level judgment, + not string matching — whether the ticket's `Requirement` asks for something + a constraint explicitly rules out, or whose premise a later decision has + superseded. `DECISIONS.md` is a human/PM-only audit record and stays that + way here: read it yourself, do not paste it into any dispatch brief. + +If either check finds a real conflict: **stop here.** Do not create a branch, +do not implement. Report the ticket id, the conflicting `DECISIONS.md` entry +or unmet dependency, and wait for the user's direction. + +If clear: continue to Step 1. + +## Step 1 — Branch + +**Dirty-tree precondition** (fail fast, not a discussion point — same bucket +as Step 0's ticket-id validation): run `git status` first. If the tree is +dirty with changes unrelated to this ticket, stop immediately and report that +the tree must be clean before `/ship` will branch — do not stash, commit, or +otherwise mutate the caller's uncommitted work on their behalf. This has one +predetermined resolution (the caller commits or stashes it themselves and +re-invokes `/ship`), so it is not the negotiated stop this command reserves +for genuine ambiguity, and it is not an automatic mutation either — never +branch over uncommitted work silently, and never take a repo-mutating action +the caller did not ask for. + +```bash +git checkout -b feat/ +``` + +## Step 2 — Implement + +Implement directly with Read/Edit/Write/Bash in this session — do not dispatch +implementation to codex/claude/opencode. `pmctl dispatch run` is not part of +this flow; the only executor dispatch in `/ship` is the gate's own reviewer +dispatch in Step 3. + +## Step 3 — Gate loop + +Run `pmctl gate run --executor codex --cd "$PWD" --lifecycle foreground` (the +`/pr-gate` command is the orchestration wrapper around this exact invocation +— either entry point is acceptable, but the underlying gate call is always +this one, never `bash scripts/pr-gate.sh` directly). +`--lifecycle foreground` is required here: the default `--lifecycle detached` +returns only a `gate_id` immediately and the gate keeps running in the +background — reading `Final:` right after that call would read a stale or +missing result. `/ship` is already a long-running autonomous loop with +nothing else for the main thread to do while it waits, so there is no reason +to pay the detached/`pmctl gate wait` two-call complexity that `/pr-gate` +uses to keep the main thread free for other work; run `foreground` and read +the resulting `Final: GO|NO-GO` verdict directly from the gate result file +once the call returns. + +- **GO** → go to Step 4. +- **NO-GO** → invoke the `project-pm` agent to synthesize the gate result + against the verdict table and Rules A/B in `agents/project-pm.md` (source-first + read of every cited diff file, discovery sweep of all call sites of a + flagged helper, minimum-list is a floor not a ceiling). Fix **every** finding + it returns — high, medium, and low, hard gate and advisory alike, not only + the blocking ones. Re-run `pmctl gate run --executor codex --cd "$PWD" + --lifecycle foreground --reviewers ` (the `/pr-gate` + `--targeted` flag maps to this same `--reviewers` option) for the reviewers + whose territory the fix touched. Repeat. + +**Stop the loop only when**: +1. Step 0's check would have caught this but didn't — implementation revealed + the ticket's own assumption is wrong, or a fix genuinely requires + contradicting a `DECISIONS.md` constraint; or +2. Rule A's 3-strike audit (`agents/project-pm.md`) has already run, the + remaining blockers are confirmed diff-caused (not the pre-existing issues + Rule A downgrades to `advise`+separate issue), and a further round produces + no new progress — same blockers, same state, nothing fixed. This is "no + fix is being found," not "this is taking many rounds": a real gate has + already needed 7 normal rounds to converge, and round count alone was not + a stop signal that time. + +Any other NO-GO, at any round count, gets fixed and re-gated without asking. + +## Step 4 — Open the PR + +```bash +git push -u origin feat/ +gh pr create --title "(): " --body "$(cat <<'EOF' +## Summary +- + +## Gate +- Rounds: +- Final verdict: GO +- Result file: + +Ticket: +EOF +)" +``` + +Do not merge. GO is not merge authorization — merge only when the user +explicitly says so. + +## Step 5 — Close-out report + +Report one of four outcomes: (1) invalid ticket id — the exact problem +(empty argument / malformed shape / no such ticket / already archived); (2) +dirty tree — that `/ship` aborted without touching the caller's uncommitted +work, and that a clean tree is required to re-invoke; (3) consistency-check +stop — the ticket id, the conflicting `DECISIONS.md` entry or unmet +dependency, and what decision is needed from the user; or (4) PR opened — +ticket id, what changed, how many gate rounds it took, the final verdict, and +the PR URL. diff --git a/scripts/test-commands.sh b/scripts/test-commands.sh index d84dd8d..44bafa7 100755 --- a/scripts/test-commands.sh +++ b/scripts/test-commands.sh @@ -369,6 +369,86 @@ should_run "using-git-worktrees: documents orphan recovery via gc" && assert_fil should_run "using-git-worktrees: excludes --parallel gate reviewer isolation from scope" && assert_file_contains "using-git-worktrees: excludes --parallel gate reviewer isolation from scope" "$USING_GIT_WORKTREES" "does not touch the \`--parallel\` PR gate" && pass "using-git-worktrees: excludes --parallel gate reviewer isolation from scope" should_run "using-git-worktrees: no CC ticket references" && assert_not_contains "using-git-worktrees: no CC ticket references" "$USING_GIT_WORKTREES" "CC-" +# ── ship.md contract ───────────────────────────────────────────────────────── + +SHIP="$COMMANDS_DIR/ship.md" + +assert_frontmatter "ship: frontmatter valid" "$SHIP" +should_run "ship: scoped to a single named ticket per invocation" && assert_file_contains "ship: scoped to a single named ticket per invocation" "$SHIP" "one ticket per invocation" && pass "ship: scoped to a single named ticket per invocation" +should_run "ship: does not batch-scan BACKLOG for candidates" && assert_file_contains "ship: does not batch-scan BACKLOG for candidates" "$SHIP" "Do not scan" && pass "ship: does not batch-scan BACKLOG for candidates" +# Step 0 pre-flight consistency check: the one legal stopping point +should_run "ship: has Step 0 pre-flight consistency check" && assert_file_contains "ship: has Step 0 pre-flight consistency check" "$SHIP" "Step 0" && pass "ship: has Step 0 pre-flight consistency check" +should_run "ship: checks DECISIONS.md Constraints introduced" && assert_file_contains "ship: checks DECISIONS.md Constraints introduced" "$SHIP" "Constraints introduced" && pass "ship: checks DECISIONS.md Constraints introduced" +should_run "ship: checks unmet Dependencies before starting" && assert_file_contains "ship: checks unmet Dependencies before starting" "$SHIP" "Dependencies" && pass "ship: checks unmet Dependencies before starting" +should_run "ship: conflict stops before branching or implementing" && assert_file_contains "ship: conflict stops before branching or implementing" "$SHIP" "Do not create a branch" && pass "ship: conflict stops before branching or implementing" +should_run "ship: keeps DECISIONS.md out of dispatch briefs" && assert_file_contains "ship: keeps DECISIONS.md out of dispatch briefs" "$SHIP" "do not paste it into any dispatch brief" && pass "ship: keeps DECISIONS.md out of dispatch briefs" +# ticket-id validation: empty / malformed / nonexistent must fail fast, distinct from the discussion stop +should_run "ship: validates ticket id before any other step" && assert_file_contains "ship: validates ticket id before any other step" "$SHIP" "Ticket-id validation" && pass "ship: validates ticket id before any other step" +should_run "ship: handles empty argument" && assert_file_contains "ship: handles empty argument" "$SHIP" "stop and report \"empty argument\"" && pass "ship: handles empty argument" +should_run "ship: handles malformed ticket-id shape" && assert_file_contains "ship: handles malformed ticket-id shape" "$SHIP" "does not match this repo's ticket-id shape" && pass "ship: handles malformed ticket-id shape" +should_run "ship: distinguishes already-archived ticket from no-such-ticket" && assert_file_contains "ship: distinguishes already-archived ticket from no-such-ticket" "$SHIP" "the ticket is already terminal" && pass "ship: distinguishes already-archived ticket from no-such-ticket" +should_run "ship: consistency check only ever reads active BACKLOG.md" && assert_file_contains "ship: consistency check only ever reads active BACKLOG.md" "$SHIP" "resolves to an active \`BACKLOG.md\`" && pass "ship: consistency check only ever reads active BACKLOG.md" +should_run "ship: BACKLOG-ARCHIVE.md is error-message-only, never a source to implement from" && assert_file_contains "ship: BACKLOG-ARCHIVE.md is error-message-only, never a source to implement from" "$SHIP" "never as a source to" && pass "ship: BACKLOG-ARCHIVE.md is error-message-only, never a source to implement from" +should_run "ship: distinguishes fail-fast validation from the discussion stop" && assert_file_contains "ship: distinguishes fail-fast validation from the discussion stop" "$SHIP" "not a discussion point" && pass "ship: distinguishes fail-fast validation from the discussion stop" +# dirty-tree precondition is deterministic fail-safe, not a second ask path +should_run "ship: dirty tree aborts fail-fast, does not auto-mutate" && assert_file_contains "ship: dirty tree aborts fail-fast, does not auto-mutate" "$SHIP" "do not stash, commit, or" && pass "ship: dirty tree aborts fail-fast, does not auto-mutate" +should_run "ship: dirty-tree abort is not the negotiated stop" && assert_file_contains "ship: dirty-tree abort is not the negotiated stop" "$SHIP" "not the negotiated stop this command reserves" && pass "ship: dirty-tree abort is not the negotiated stop" +# implementation stays main-thread, not dispatched +should_run "ship: implementation is not dispatched to an executor" && assert_file_contains "ship: implementation is not dispatched to an executor" "$SHIP" "to codex/claude/opencode" && pass "ship: implementation is not dispatched to an executor" +# gate loop contract +should_run "ship: invokes pmctl gate run --executor codex for review" && assert_file_contains "ship: invokes pmctl gate run --executor codex for review" "$SHIP" "pmctl gate run --executor codex" && pass "ship: invokes pmctl gate run --executor codex for review" +should_run "ship: never invokes pr-gate.sh directly" && assert_file_contains "ship: never invokes pr-gate.sh directly" "$SHIP" "never \`bash scripts/pr-gate.sh\` directly" && pass "ship: never invokes pr-gate.sh directly" +if should_run "ship: every gate invocation uses --lifecycle foreground"; then + ship_flat=$(tr '\n' ' ' < "$SHIP" | tr -s ' ') + ship_gate_calls=$(grep -oE 'pmctl gate run --executor codex' <<< "$ship_flat" | wc -l) + ship_foreground_calls=$(grep -oE 'pmctl gate run --executor codex[^`]*--lifecycle foreground' <<< "$ship_flat" | wc -l) + if [[ "$ship_gate_calls" -gt 0 && "$ship_gate_calls" -eq "$ship_foreground_calls" ]]; then + pass "ship: every gate invocation uses --lifecycle foreground" + else + fail "ship: every gate invocation uses --lifecycle foreground" "found $ship_gate_calls occurrence(s) of the gate call but only $ship_foreground_calls paired with --lifecycle foreground in $SHIP" + fi +fi +should_run "ship: explains why detached+wait is unnecessary here" && assert_file_contains "ship: explains why detached+wait is unnecessary here" "$SHIP" "nothing else for the main thread to do while it waits" && pass "ship: explains why detached+wait is unnecessary here" +should_run "ship: reads Final GO/NO-GO verdict" && assert_file_contains "ship: reads Final GO/NO-GO verdict" "$SHIP" "Final:" && pass "ship: reads Final GO/NO-GO verdict" +should_run "ship: NO-GO fixes every finding not only blocking ones" && assert_file_contains "ship: NO-GO fixes every finding not only blocking ones" "$SHIP" "the blocking ones" && pass "ship: NO-GO fixes every finding not only blocking ones" +should_run "ship: re-runs gate with --reviewers targeting" && assert_file_contains "ship: re-runs gate with --reviewers targeting" "$SHIP" "--reviewers " && pass "ship: re-runs gate with --reviewers targeting" +should_run "ship: references project-pm Rules A/B synthesis" && assert_file_contains "ship: references project-pm Rules A/B synthesis" "$SHIP" "Rules A/B" && pass "ship: references project-pm Rules A/B synthesis" +# exactly two stop conditions, no more +should_run "ship: stop condition heading enumerates the loop's halt cases" && assert_file_contains "ship: stop condition heading enumerates the loop's halt cases" "$SHIP" "Stop the loop only when" && pass "ship: stop condition heading enumerates the loop's halt cases" +if should_run "ship: exactly one genuine wait-for-user-direction path"; then + ship_wait_count=$(grep -c "wait for the user's direction" "$SHIP") + if [[ "$ship_wait_count" -eq 1 ]]; then + pass "ship: exactly one genuine wait-for-user-direction path" + else + fail "ship: exactly one genuine wait-for-user-direction path" "expected exactly 1 wait-for-user-direction occurrence, found $ship_wait_count in $SHIP" + fi +fi +if should_run "ship: stop-condition list has exactly two numbered cases"; then + ship_stop_count=$(awk '/^\*\*Stop the loop only when\*\*:/{in_sec=1; next} in_sec && /^## /{exit} in_sec && /^[0-9]+\. /{c++} END{print c+0}' "$SHIP") + if [[ "$ship_stop_count" -eq 2 ]]; then + pass "ship: stop-condition list has exactly two numbered cases" + else + fail "ship: stop-condition list has exactly two numbered cases" "expected exactly 2 numbered items in the 'Stop the loop only when' section, found $ship_stop_count in $SHIP" + fi +fi +should_run "ship: round count alone is not a stop signal" && assert_file_contains "ship: round count alone is not a stop signal" "$SHIP" "this is taking many rounds" && pass "ship: round count alone is not a stop signal" +should_run "ship: any other NO-GO continues without asking" && assert_file_contains "ship: any other NO-GO continues without asking" "$SHIP" "gets fixed and re-gated without asking" && pass "ship: any other NO-GO continues without asking" +# PR template +should_run "ship: opens PR via gh pr create" && assert_file_contains "ship: opens PR via gh pr create" "$SHIP" "gh pr create" && pass "ship: opens PR via gh pr create" +should_run "ship: PR body template records gate rounds and verdict" && assert_file_contains "ship: PR body template records gate rounds and verdict" "$SHIP" "Final verdict" && pass "ship: PR body template records gate rounds and verdict" +should_run "ship: GO is not merge authorization" && assert_file_contains "ship: GO is not merge authorization" "$SHIP" "GO is not merge authorization" && pass "ship: GO is not merge authorization" +should_run "ship: no CC ticket references" && assert_not_contains "ship: no CC ticket references" "$SHIP" "CC-[0-9]" +# git publication path: branch creation and push are the only side effects before PR creation +should_run "ship: creates the feature branch via git checkout -b" && assert_file_contains "ship: creates the feature branch via git checkout -b" "$SHIP" "git checkout -b feat/" && pass "ship: creates the feature branch via git checkout -b" +should_run "ship: pushes the branch before opening the PR" && assert_file_contains "ship: pushes the branch before opening the PR" "$SHIP" "git push -u origin feat/" && pass "ship: pushes the branch before opening the PR" +# Step 5 close-out report: pins all four named outcomes so the reporting contract cannot silently erode +should_run "ship: has Step 5 close-out report" && assert_file_contains "ship: has Step 5 close-out report" "$SHIP" "## Step 5 — Close-out report" && pass "ship: has Step 5 close-out report" +should_run "ship: close-out report has exactly four named outcomes" && assert_file_contains "ship: close-out report has exactly four named outcomes" "$SHIP" "Report one of four outcomes" && pass "ship: close-out report has exactly four named outcomes" +should_run "ship: close-out outcome 1 covers invalid ticket id" && assert_file_contains "ship: close-out outcome 1 covers invalid ticket id" "$SHIP" "(1) invalid ticket id" && pass "ship: close-out outcome 1 covers invalid ticket id" +should_run "ship: close-out outcome 2 covers dirty-tree abort" && assert_file_contains "ship: close-out outcome 2 covers dirty-tree abort" "$SHIP" "(2)" && assert_file_contains "ship: close-out outcome 2 covers dirty-tree abort" "$SHIP" "dirty tree — that \`/ship\` aborted" && pass "ship: close-out outcome 2 covers dirty-tree abort" +should_run "ship: close-out outcome 3 covers consistency-check stop" && assert_file_contains "ship: close-out outcome 3 covers consistency-check stop" "$SHIP" "(3) consistency-check" && pass "ship: close-out outcome 3 covers consistency-check stop" +should_run "ship: close-out outcome 4 covers PR opened with URL" && assert_file_contains "ship: close-out outcome 4 covers PR opened with URL" "$SHIP" "or (4) PR opened" && assert_file_contains "ship: close-out outcome 4 covers PR opened with URL" "$SHIP" "the PR URL." && pass "ship: close-out outcome 4 covers PR opened with URL" + # ── summary ────────────────────────────────────────────────────────────────── th_summary