From 2103dde889fbe8890df3168e2f576318f495ec82 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 24 Aug 2026 16:34:55 +0700 Subject: [PATCH 1/3] feat(lanes): drive runs from the pipeline template's own skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pipeline template may now declare the slash command that drives it (`skill`), so the choice of pipeline is the choice of workflow rather than just a picture on the dashboard: - `ccam ship-full ""` runs the 16-node `ship-feature` template's skill (`/ccam:ship-feature-lane`) — through gates, QC and the senior gate to a published PR, watched until a human merges it. - `ccam ship-quick ""` runs the 8-node `default` template's skill (`/ccam:ship-feature`) — in-repo feature work that stops at the gate. Each command puts the lane on its own template before spawning, so the dashboard renders the right node count from the first stage instead of after the skill self-corrects. `--print` shows the command, switches nothing and runs nothing. The prompt reaches `claude` as one argv element, never through a shell. The same field feeds the Workspace run console, which pre-fills its prompt with the lane's driving skill and never overwrites a prompt the user has already edited. --- bin/ccam.js | 96 +++++++++++++++++++ client/src/components/run/LaneConsolePane.tsx | 18 ++++ .../run/__tests__/LaneConsolePane.test.tsx | 18 ++++ client/src/lib/types.ts | 4 + docs/CLI.md | 4 + docs/LANES.md | 30 ++++++ server/__tests__/lanes-cli.test.js | 31 ++++++ server/__tests__/lanes-lib.test.js | 3 + server/data/pipelines/default.json | 1 + server/data/pipelines/ship-feature.json | 1 + server/lib/lanes.js | 1 + server/lib/pipelines.js | 5 + 12 files changed, 212 insertions(+) diff --git a/bin/ccam.js b/bin/ccam.js index 189647c1..f96e687e 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -2278,6 +2278,7 @@ async function cmdLanesPipeline(args) { const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { pipelines } = await get("/api/lanes/pipelines"); console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`); + if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} `); console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`); return; } @@ -2325,6 +2326,88 @@ async function cmdLanesShell(args) { }); } +/** + * Start a `claude` session in this lane driven by a pipeline's own skill. + * Two commands, two depths of work: + * + * ccam ship-full → the 16-node `ship-feature` template, `/ccam:ship-feature-lane` + * — autonomous to a published PR, then watched until a + * human merges it. + * ccam ship-quick → the 8-node `default` template, `/ccam:ship-feature` + * — in-repo feature work that stops at review/gate. + * + * Each command puts the lane on its own template, so the choice is the command + * you type; the skill string itself lives in the template's `skill` field and + * is never duplicated here. + * + * The prompt is passed as ONE argv element to `claude`, never through a shell — + * a requirement containing quotes, `$`, or `;` is text, not code. `--print` + * shows the exact command, switches nothing, and runs nothing. + */ +const SHIP_TEMPLATES = { + "ship-full": "ship-feature", + "ship-quick": "default", +}; + +async function cmdShip(command, args) { + const templateId = SHIP_TEMPLATES[command]; + const { flags, positional } = parseArgs(args); + const resolved = await resolveLaneArg(args); + if (!resolved) return; + let { lane } = await get(`/api/lanes/${resolved.laneId}`); + + const { pipelines } = await get("/api/lanes/pipelines"); + const skill = pipelines.find((p) => p.id === templateId)?.skill; + if (!skill) { + console.error( + `✖ pipeline template "${templateId}" declares no driving skill — ` + + `add a "skill" field to it (see docs/LANES.md § Custom template format)` + ); + process.exitCode = 1; + return; + } + + // `resolveLaneArg` only strips a leading bare lane id; the rest of the + // positionals are the requirement, joined back into one prompt line. + const requirement = positional + .filter((p) => p !== String(resolved.laneId)) + .join(" ") + .trim(); + const prompt = requirement ? `${skill} ${requirement}` : skill; + + if (flags.print || !requirement) { + // `--print` shows what would run, so it switches nothing. + if (!requirement) console.error(`usage: ccam ${command} "" [] [--print]`); + console.log(`cd ${lane.cwd} && claude ${JSON.stringify(prompt)}`); + if (!requirement) process.exitCode = 1; + return; + } + + // The command picks the pipeline, not the lane's current setting: put the + // lane on the matching template first so the dashboard renders the right + // node count from the start rather than after the skill self-corrects. + if (lane.pipeline !== templateId) { + ({ lane } = await patch(`/api/lanes/${lane.id}`, { pipeline: templateId })); + } + + console.log( + `${c.dim(`lane #${lane.id} · pipeline ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`)}\n` + + `${c.cyan("→")} claude ${JSON.stringify(prompt)}` + ); + const child = spawn("claude", [prompt], { stdio: "inherit", cwd: lane.cwd }); + await new Promise((resolve) => { + child.on("close", (code) => { + if (code) process.exitCode = code; + resolve(); + }); + child.on("error", (err) => { + console.error(c.red(`✖ ${err?.message || err}`)); + process.exitCode = 1; + resolve(); + }); + }); +} + async function cmdFeatureShow(args) { const slug = args.find((arg) => !arg.startsWith("--")); if (!slug) { @@ -2509,6 +2592,16 @@ const COMMAND_GROUPS = [ "[] []", "Show, or switch, which pipeline template a lane renders against", ], + [ + "ship-full", + '"" [] [--print]', + "Run the 16-stage lane pipeline: implement → gates → e2e → review → QC → senior gate → PR, watched until a human merges (--print: show the command, run nothing)", + ], + [ + "ship-quick", + '"" [] [--print]', + "Run the 8-stage in-repo pipeline: plan → implement → tests → review → gate → ship. No PR, no browser QC", + ], [ "lanes shell", "[]", @@ -3418,6 +3511,9 @@ async function runCommand(argv) { } case "stage": return cmdStage(rest); + case "ship-full": + case "ship-quick": + return cmdShip(cmd, rest); case "feature": { const sub = rest[0]; if (sub === "list") return cmdFeatureList(rest.slice(1)); diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index 3023d2a5..f42cabd7 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -121,6 +121,7 @@ export function LaneConsolePane({ useEffect(() => { const { activeRuns: runs } = latest.current; setPrompt(""); + lastPrefill.current = ""; setResumeSession(null); setError(null); setBusy(null); @@ -129,6 +130,23 @@ export function LaneConsolePane({ refreshList(); }, [laneId, refreshList]); + // Pre-fill the prompt with the slash command the lane's pipeline template + // names as its driver, so picking the 16-node pipeline starts the 16-step + // skill and the 8-node one the 8-step skill. Only overwrites a prompt the + // user hasn't touched (still exactly the previous prefill), so switching + // templates never eats a typed requirement. Must be declared AFTER the + // lane-switch effect: on a lane change that one clears both first. + const lastPrefill = useRef(""); + const pipelineSkill = currentLane?.pipeline_skill || ""; + useEffect(() => { + const next = pipelineSkill ? `${pipelineSkill} ` : ""; + setPrompt((p) => (p === lastPrefill.current ? next : p)); + lastPrefill.current = next; + // `laneId` is a dep too: switching between two lanes on the SAME template + // leaves `pipelineSkill` unchanged, and the switch effect just cleared the + // prompt — without it the new lane would open with an empty box. + }, [pipelineSkill, laneId]); + // The switch effect above only sees whatever `activeRuns` the page already // had loaded at that instant. Remounting this page (navigating away and // back) starts `activeRuns` at null again, so a lane with a live run would diff --git a/client/src/components/run/__tests__/LaneConsolePane.test.tsx b/client/src/components/run/__tests__/LaneConsolePane.test.tsx index f32a7413..17fcd2b9 100644 --- a/client/src/components/run/__tests__/LaneConsolePane.test.tsx +++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx @@ -188,6 +188,24 @@ describe("LaneConsolePane", () => { expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument(); }); + it("pre-fills the prompt with the pipeline's driving skill, and keeps a typed prompt when the template changes", async () => { + const lane = { ...LANE, pipeline_skill: "/ccam:ship-feature" }; + const props = { ...baseProps(), lanes: [lane] }; + const { rerender } = render(); + const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement; + await waitFor(() => expect(prompt.value).toBe("/ccam:ship-feature ")); + + // Untouched prefill follows the template switch. + const laneShip = { ...lane, pipeline_skill: "/ccam:ship-feature-lane" }; + rerender(); + await waitFor(() => expect(prompt.value).toBe("/ccam:ship-feature-lane ")); + + // A typed requirement is never eaten by a later template switch. + fireEvent.change(prompt, { target: { value: "/ccam:ship-feature-lane add a button" } }); + rerender(); + await waitFor(() => expect(prompt.value).toBe("/ccam:ship-feature-lane add a button")); + }); + it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => { render(); expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 18d6b7a2..b0c50121 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2303,6 +2303,10 @@ export interface Lane { stages: Record; notes: string | null; pipeline_name: string; + /** Slash command the lane's pipeline template declares as its driver, e.g. + * `/ccam:ship-feature-lane`. The run console pre-fills its prompt with it. + * Empty when the template names no skill. */ + pipeline_skill?: string; pipeline_nodes: LaneNode[]; progress: number; stage_seconds: number | null; diff --git a/docs/CLI.md b/docs/CLI.md index 3b275004..42a08cdd 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -238,11 +238,15 @@ A lane is a durable unit of parallel agent work — one working directory, many | `ccam lanes pipeline [] []` | Show which pipeline template a lane renders against, plus the available templates and their node counts; pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns | | `ccam lanes profile check []` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `` defaults to the current directory (not a lane id) | | `ccam lanes reset\|remove\|purge [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database | +| `ccam ship-full "" [] [--print]` | **The 16-stage lane pipeline.** Puts the lane on the `ship-feature` template and starts a `claude` session running its skill (`/ccam:ship-feature-lane`): implement → gates → e2e → review → local QC → senior GO/NO-GO → push branch + open PR, then watches the PR until a human merges it. Use when the work should end in a reviewable PR | +| `ccam ship-quick "" [] [--print]` | **The 8-stage in-repo pipeline.** Puts the lane on the `default` template and runs its skill (`/ccam:ship-feature`): plan → implement → tests → review → gate → ship. No PR, no browser QC. Use for work that stays in the working copy | | `ccam stage [--evidence ] [--note ] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it | | `ccam feature list []` | List every feature this lane has activated, archived or live | | `ccam feature activate [--title text] []` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first | | `ccam feature show []` | Show one feature's saved pipeline — works on an archived one too | +`ccam ship-full` / `ccam ship-quick` both resolve the lane from the working directory (or take a lane id), pass the requirement to `claude` as one argv element — never through a shell — and accept `--print`, which shows the exact command, switches no template and runs nothing. The skill string itself lives in the template's `skill` field; a template without one makes the command refuse rather than guess. + **Runtime** — the lane's own application stack, as opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one lane id. Each requires the repository to declare a profile at `/.ccam/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing. diff --git a/docs/LANES.md b/docs/LANES.md index 717e6d45..38dfc5e6 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -434,6 +434,34 @@ ccam lanes pipeline ship-feature # switch it or from the dashboard: the pipeline-template select next to the lane's title in the Workspace detail panel does the same `PATCH /api/lanes/:id` the CLI uses, live (disabled while viewing an archived feature snapshot). +**The template names the skill that drives it.** `"skill": "/ccam:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/ccam:ship-feature"` on the 8-node `default` one. It rides along on every lane payload as `pipeline_skill`, and from a terminal there is one command per depth of work — the command you type picks the pipeline, so no `ccam lanes pipeline` step first: + +```bash +cd ~/lanes/myrepo__feat-export + +# 16 stages: ends in a published PR, watched until a human merges it +ccam ship-full "add an export button" +# → lane switched to `ship-feature`, then: claude "/ccam:ship-feature-lane add an export button" + +# 8 stages: in-repo feature work, stops at review/gate — no PR, no browser QC +ccam ship-quick "fix the CSV separator" +# → lane switched to `default`, then: claude "/ccam:ship-feature fix the CSV separator" + +ccam ship-full "add an export button" --print # show the command, switch nothing, run nothing +``` + +| | `ccam ship-full` | `ccam ship-quick` | +|---|---|---| +| Template | `ship-feature` (16 nodes) | `default` (8 nodes) | +| Skill | `/ccam:ship-feature-lane` | `/ccam:ship-feature` | +| Ends at | PR open on `development`, watched until merged | `ship` in the working copy | +| Includes | unit-test agent, CI gates, e2e, code review, local browser QC, senior GO/NO-GO | plan, implement, tests, review, gate | +| Use when | the work should reach reviewers | the work stays local | + +The lane is resolved from the working directory, so no id is needed (pass one to target another lane). Each command puts the lane on its own template *before* spawning, so the dashboard renders the right node count from the first stage rather than after the skill self-corrects; `--print` switches nothing. The requirement is handed to `claude` as ONE argv element — never through a shell — so quotes, `$` and `;` in it are text. A template with no `skill` field makes the command refuse rather than guess. + +**The template also decides what the Workspace run console pre-fills.** A template may name the slash command that drives it — `"skill": "/ccam:ship-feature-lane"` on the 16-node `ship-feature` template, `"skill": "/ccam:ship-feature"` on the 8-node `default` one. That string rides along on every lane payload as `pipeline_skill`, and the run console starts its prompt box with it (plus a trailing space) so you only type the requirement: pick the 16-node template and Run starts the 16-step skill, pick the 8-node one and it starts the 8-step skill. The dashboard never executes it — it is text in a prompt box, exactly as if you had typed it. A prompt you have already edited is never overwritten by a later template switch, and a template with no `skill` field pre-fills nothing. + Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `ccam stage` fixes it. ## Attaching a real terminal to a lane @@ -559,6 +587,7 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.ccam/pipelines/m { "id": "ml-training", "name": "ML Training Pipeline", + "skill": "/my-training-skill", "nodes": [ { "id": "data", "label": "data", "icon": "📊", "gate": false, "aliases": ["intake"] }, { "id": "preprocess","label": "preprocess","icon": "⚙️", "gate": false, "aliases": ["prepare", "clean"] }, @@ -573,6 +602,7 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.ccam/pipelines/m - **id** (string, required): unique identifier for this template - **name** (string, optional): display name shown in the UI +- **skill** (string, optional): the slash command that drives this pipeline (e.g. `"/ccam:ship-feature-lane"`). The Workspace run console pre-fills its prompt with it for lanes on this template; omit it and the prompt starts empty. Never executed by the dashboard. - **nodes** (array, required): list of pipeline stages, in order - **id** (string): stage identifier (used in `ccam stage`) - **label** (string, optional): display name in the UI; defaults to `id` if omitted diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index f3fa85b1..44d1f026 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -542,3 +542,34 @@ describe("ccam lanes — inferred stage detection", () => { } }); }); + +describe("ccam ship-full / ccam ship-quick", () => { + it("ship-quick prints the 8-stage pipeline's skill, ship-full the 16-stage one", async () => { + const quick = await cli(["ship-quick", "add", "an", "export", "button", "--print"], LANE_DIR); + assert.equal(quick.status, 0, quick.stderr); + assert.match(quick.stdout, /claude "\/ccam:ship-feature add an export button"/); + + const full = await cli(["ship-full", "add", "an", "export", "button", "--print"], LANE_DIR); + assert.equal(full.status, 0, full.stderr); + assert.match(full.stdout, /claude "\/ccam:ship-feature-lane add an export button"/); + }); + + it("--print switches nothing: the lane keeps whatever template it was on", async () => { + const before = await get(`/api/lanes`); + const lane = before.body.lanes.find((l) => l.cwd === LANE_DIR); + const r = await cli(["ship-full", "anything", "--print"], LANE_DIR); + assert.equal(r.status, 0, r.stderr); + const after = await get(`/api/lanes`); + assert.equal(after.body.lanes.find((l) => l.id === lane.id).pipeline, lane.pipeline); + }); + + it("exits non-zero with the command's own usage when no requirement is given", async () => { + const r = await cli(["ship-full"], LANE_DIR); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /usage: ccam ship-full/); + + const q = await cli(["ship-quick"], LANE_DIR); + assert.notEqual(q.status, 0); + assert.match(q.stderr, /usage: ccam ship-quick/); + }); +}); diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index ee440042..b25601b5 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -231,6 +231,9 @@ describe("lanes lib", () => { assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current"); assert.ok(p.progress > 0 && p.progress < 100); assert.equal(p.liveness, "idle"); + // The template's driving slash command rides along so the run console can + // pre-fill its prompt with it. + assert.equal(p.pipeline_skill, "/ccam:ship-feature"); lanes.deleteLane(l.id); }); }); diff --git a/server/data/pipelines/default.json b/server/data/pipelines/default.json index d11a84af..5991dd4a 100644 --- a/server/data/pipelines/default.json +++ b/server/data/pipelines/default.json @@ -1,6 +1,7 @@ { "id": "default", "name": "Default feature pipeline", + "skill": "/ccam:ship-feature", "nodes": [ { "id": "intake", diff --git a/server/data/pipelines/ship-feature.json b/server/data/pipelines/ship-feature.json index 9f537c1b..215a6849 100644 --- a/server/data/pipelines/ship-feature.json +++ b/server/data/pipelines/ship-feature.json @@ -1,6 +1,7 @@ { "id": "ship-feature", "name": "Ship feature (lane pipeline)", + "skill": "/ccam:ship-feature-lane", "nodes": [ { "id": "intake", diff --git a/server/lib/lanes.js b/server/lib/lanes.js index ca9be498..e1d8308b 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -490,6 +490,7 @@ function lanePayload(lane, ageSec = null) { return { ...lane, pipeline_name: pipeline.name, + pipeline_skill: pipeline.skill || "", pipeline_nodes: withDetected(nodeStates(pipeline, lane), pipeline, lane), progress: progressPct(pipeline, lane), stage_seconds: Number.isNaN(since) diff --git a/server/lib/pipelines.js b/server/lib/pipelines.js index 007f79f0..952f1f52 100644 --- a/server/lib/pipelines.js +++ b/server/lib/pipelines.js @@ -30,6 +30,11 @@ function loadAll() { try { const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue; + // The slash command that drives this pipeline. The Run console + // pre-fills its prompt with it, so a lane on the 16-node template + // starts the 16-step skill and one on the 8-node template the 8-step + // one. Never executed by the dashboard — it is text in a prompt box. + doc.skill = typeof doc.skill === "string" ? doc.skill : ""; doc.nodes = doc.nodes.map((n) => ({ id: n.id, label: n.label || n.id, -- 2.52.0 From 895ff558ef3cd3bb8b8ea6a89b3857154522f325 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 08:31:44 +0700 Subject: [PATCH 2/3] docs(spec): design ccam ci status for Stage 13 CI-wait --- .../specs/2026-08-25-ccam-ci-status-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-ccam-ci-status-design.md diff --git a/docs/superpowers/specs/2026-08-25-ccam-ci-status-design.md b/docs/superpowers/specs/2026-08-25-ccam-ci-status-design.md new file mode 100644 index 00000000..40cde29d --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-ccam-ci-status-design.md @@ -0,0 +1,129 @@ +# `ccam ci status` — unblock Stage 13's CI-wait half + +**Status:** approved 2026-08-25. + +## Problem + +`ship-feature-lane`'s Stage 13 (post-merge verification) is hardcoded +skipped. Two integrations gate it: dev-CI-wait (watch CI on `development` +after a PR merges, before QCing the deployed site) and dev-QC (QC the +deployed site). Neither exists yet. + +This spec covers only the CI-wait half. `ccam lanes integration ci_wait` +already exists (`server/lib/lane-profile.js:isIntegrationEnabled`, +`bin/ccam.js:cmdLanesIntegration`) and reports whether a profile *wants* the +integration on — but nothing acts on it. `ccam ci` doesn't exist +(`skills/ship-feature-lane/SKILL.md:167` notes this explicitly at Stage 10, +where the skill falls back to raw `gh pr checks`/`gh run view`). + +Dev-QC (QCing the deployed site) is a separate, later task — out of scope +here. Stage 13's dev-QC half stays noted as unavailable until that lands. + +## Approach + +**A read-only status snapshot, not a wait loop.** Per the project's +orchestration doctrine (`docs/LANES.md` § "Orchestration: what CCAM does NOT +do" — no chaining, no queue, no retry logic), `ccam ci status` reports a +fact once; it does not poll, block, or retry. The driving skill owns the +poll loop and the fix/escalate decision, the same shape Stage 12 already +uses for watching a PR (`sleep 300` backgrounded, re-invoked per iteration). + +This also means `ccam ci status` needs no flake classification. Stage 10 +already does that triage (infra/flake signature vs. real failure) reading +`gh` output directly — Stage 13 reuses the same triage, now reading this +command's structured JSON instead of raw `gh`. + +**Server-backed, following the `sync-base` pattern**, not a CLI-local `gh` +spawn. Every existing `ccam` command is a thin HTTP client — no command in +`bin/ccam.js` spawns a child process directly (`worktree.js`'s `git()` +helper is the only child-process boundary CCAM has today, always +server-side). A new `server/lib/gh.js` mirrors that helper: + +```js +async function gh(cwd, args) { /* execFile("gh", args, { cwd }) */ } +``` + +New route: `GET /api/lanes/:id/ci/status?ref=` + +- Resolves the lane's `cwd` (same lookup every other lane route uses). +- Runs `gh run list --branch --json databaseId,conclusion,status,url --limit 1` + to find the latest run for that ref, then `gh run view --json jobs` + for per-job conclusions. (A `sha` ref: `gh run list` accepts a commit SHA + via `--commit`, not `--branch` — the route tries `--branch` first and + falls back to `--commit` if the ref doesn't look like a branch name a + lane would use, i.e. it's a 7-40 char hex string.) +- No run found for the ref at all → `state: "pending"` (CI hasn't started + yet, not an error). +- `gh` missing or not authenticated → `{ error: { message } }`, HTTP 500. + +New CLI: `ccam lanes ci status []` + +- GETs the route above, prints the JSON, sets `process.exitCode`. +- Resolves the lane the same way every other `ccam lanes ` command + does (`resolveLaneArg`), so it works with an implicit lane-from-cwd or an + explicit id, matching `ccam lanes integration`. + +## Response shape + +```json +{ + "state": "success", + "ref": "a1b2c3d", + "checks": [ + { "name": "server-tests", "conclusion": "failure", "url": "https://github.com/.../runs/123" } + ] +} +``` + +`state` is derived, never taken from a single field: any job still +`in_progress`/`queued` → `pending`; else any `failure`/`cancelled`/ +`timed_out` → `failure`; else (`success`/`skipped` only) → `success`. + +## Exit codes + +CLI mirrors the response into a process exit code, same convention as +`sync-base`'s distinct exit codes per outcome: + +- `0` — `state: "success"` +- `1` — `state: "failure"`, OR any execution error (`gh` missing, lane not + found, route error) — printed as `✖ ci status → ` on stderr +- `3` — `state: "pending"` (covers both "checks running" and "no run found + yet for this ref") + +## Skill changes + +`skills/ship-feature-lane/SKILL.md` Stage 13: replace the hardcoded-skip +note with a poll loop mirroring Stage 12's: + +- Get the merge SHA: `gh pr view --json mergeCommit -q .mergeCommit.oid`. +- Loop (`sleep 300`, `run_in_background: true`, bump heartbeat each + iteration): `ccam lanes ci status `. + - `pending` → keep waiting. + - `failure` → triage like Stage 10 (`gh run view` for job logs on the + same run id returned in `checks[].url`), fix + re-enter from Stage 2, + or escalate. + - `success` → dev-QC half is still unavailable (separate task) — note + that explicitly and proceed to Stage 14, same as today but now honest + that CI-wait specifically passed. +- Update `docs/LANES.md`: the "Stages currently hardcoded skipped" list + (Stage 13 becomes "CI-wait works; dev-QC still off"), the + `ccam lanes integration` section's claim that `ci_wait`'s consumer isn't + built, and add `ccam ci status` to the command reference. + +## Non-goals + +- No flake auto-classification or auto-rerun inside `ccam ci status` — + stays a dumb fact-reporter; the skill keeps owning that judgment, per + CCAM's no-retry-logic rule. +- No change to Stage 10 (PR CI watch) — it keeps using raw `gh` directly. + Reusing this command there is a plausible future cleanup, not required + now. +- No dev-QC implementation — separate task, tracked independently. + +## Testing + +- `server/tests` — new test for the `/api/lanes/:id/ci/status` route, + mocking `execFile`/`gh` output for: pending (no run yet), pending + (in-progress run), success, failure, and `gh`-not-found — pattern after + the existing `sync-base` route tests. +- `npm run test:server` before considering this done. -- 2.52.0 From ff095a84ab8d74766584ef03fb761ebe1e156efd Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 08:38:04 +0700 Subject: [PATCH 3/3] docs(plan): implementation plan for ccam ci status --- .../plans/2026-08-25-ccam-ci-status.md | 769 ++++++++++++++++++ 1 file changed, 769 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-25-ccam-ci-status.md diff --git a/docs/superpowers/plans/2026-08-25-ccam-ci-status.md b/docs/superpowers/plans/2026-08-25-ccam-ci-status.md new file mode 100644 index 00000000..1980160d --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-ccam-ci-status.md @@ -0,0 +1,769 @@ +# `ccam ci status` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `ccam lanes ci status ` — a one-shot GitHub Actions status snapshot backed by a new server route — and wire it into `ship-feature-lane` Stage 13 so the CI-wait half of post-merge verification stops being hardcoded-skipped. + +**Architecture:** A `server/lib/gh.js` execFile wrapper (mirrors `worktree.js`'s `git()`), a `server/lib/ci-status.js` pure-logic module that turns `gh run list`/`gh run view` output into `{state, ref, checks}`, a thin `GET /:id/ci/status` route (mirrors `GET /:id/git`'s try/available shape), and a thin CLI command (mirrors `cmdLanesIntegration`) that maps the response onto exit codes 0/1/3. No polling, no retry, no flake classification anywhere in this stack — the skill keeps that judgment, per the project's no-orchestration rule. + +**Tech Stack:** Node.js (`node:child_process` execFile), Express, `node:test` + `node:assert/strict`, GitHub CLI (`gh`) as the actual data source. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-08-25-ccam-ci-status-design.md` — read it before starting; every task below implements one section of it. +- Every new/edited source file needs the repo's copyright header (file overview + `@author Nguyễn Ngọc Trí Vĩ `) — see `skills/file-headers/`. +- No flake auto-classification or auto-rerun logic anywhere in this stack — `state` is a derived fact, nothing more. +- Route never writes lane `stage`/`status`/`notes` (same boundary every other read-only lane route keeps). +- `npm run test:server` must pass before this is done. +- Server tests use real fixtures (a fake `gh` shell script placed on `PATH`), never module mocking — no mocking library exists in this codebase's server tests, follow `stubClaudeBinary` in `server/__tests__/lane-lifecycle.test.js` as the pattern. + +--- + +### Task 1: `server/lib/gh.js` — the `gh` execFile wrapper + +**Files:** +- Create: `server/lib/gh.js` +- Test: `server/__tests__/gh.test.js` + +**Interfaces:** +- Produces: `async function gh(cwd, args) => { stdout: string, stderr: string }`. Throws `Error` with `.code` set to `"ENOGH"` when the `gh` binary isn't found on `PATH`, or the process's own exit code otherwise; `.gh = { args, code, stderr }` always set on failure. + +- [ ] **Step 1: Write the failing test** + +```js +// server/__tests__/gh.test.js +/** + * @file Tests for server/lib/gh.js's execFile wrapper against real fake `gh` + * scripts on PATH — the same fixture style server/__tests__/lane-lifecycle.test.js + * uses for a fake `claude` binary, since this codebase's server tests use real + * subprocesses instead of mocking child_process. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gh-")); +const { gh } = require("../lib/gh"); + +/** Puts a fake `gh` script on PATH for the duration of one test. */ +function stubGh(scriptBody) { + const bin = path.join(ROOT, `bin-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(bin, { recursive: true }); + const bin_gh = path.join(bin, "gh"); + fs.writeFileSync(bin_gh, `#!/usr/bin/env bash\n${scriptBody}\n`); + fs.chmodSync(bin_gh, 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + return () => { + process.env.PATH = originalPath; + }; +} + +after(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +describe("gh()", () => { + it("returns stdout on success", async () => { + const restore = stubGh('echo "$@" >&2\necho \'{"ok":true}\''); + try { + const result = await gh(ROOT, ["run", "list"]); + assert.match(result.stdout, /"ok":true/); + } finally { + restore(); + } + }); + + it("throws with .gh details on a non-zero exit", async () => { + const restore = stubGh('echo "boom" >&2\nexit 7'); + try { + await assert.rejects( + () => gh(ROOT, ["run", "list"]), + (err) => err.gh.code === 7 && /boom/.test(err.gh.stderr) + ); + } finally { + restore(); + } + }); + + it("throws ENOGH when gh is not on PATH", async () => { + const originalPath = process.env.PATH; + process.env.PATH = ROOT; // a directory with no gh binary in it + try { + await assert.rejects(() => gh(ROOT, ["run", "list"]), { code: "ENOGH" }); + } finally { + process.env.PATH = originalPath; + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/gh.test.js` +Expected: FAIL — `Cannot find module '../lib/gh'` + +- [ ] **Step 3: Write the implementation** + +```js +// server/lib/gh.js +/** + * @file Thin execFile wrapper around the GitHub CLI (`gh`), used by + * server/lib/ci-status.js to read Actions run state. Mirrors worktree.js's + * git() helper's shape (cwd-scoped, throws with structured .gh details on + * failure) but has no env-scrubbing to do — gh doesn't read GIT_* variables. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); + +/** + * Run `gh ` in `cwd`. Resolves `{ stdout, stderr }` on a zero exit. + * On failure, throws an Error with `.gh = { args, code, stderr }` — `.code` + * is `"ENOGH"` when the binary itself is missing, otherwise the process's + * exit code. + * + * @param {string} cwd + * @param {string[]} args + * @returns {Promise<{stdout: string, stderr: string}>} + */ +async function gh(cwd, args) { + try { + const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; + } catch (err) { + const error = new Error(`gh ${args[0]} failed`); + error.code = err.code === "ENOENT" ? "ENOGH" : err.code; + error.gh = { args, code: err.code, stderr: err.stderr || "" }; + throw error; + } +} + +module.exports = { gh }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/gh.test.js` +Expected: PASS (3 tests) + +- [ ] **Step 5: Verify the file header and commit** + +```bash +bash skills/file-headers/scripts/check-headers.sh +git add server/lib/gh.js server/__tests__/gh.test.js +git commit -m "feat(server): add gh execFile wrapper for CI status" +``` + +--- + +### Task 2: `server/lib/ci-status.js` — the run-list → `{state, checks}` logic + +**Files:** +- Create: `server/lib/ci-status.js` +- Test: `server/__tests__/ci-status.test.js` + +**Interfaces:** +- Consumes: `gh(cwd, args)` from Task 1 (`require("./gh")`). +- Produces: `async function ciStatus(cwd, ref) => { state: "success"|"failure"|"pending", ref: string, checks: Array<{name: string, conclusion: string, url: string|null}> }`. Throws whatever `gh()` throws (propagated, not caught) — the route maps that to an HTTP error. + +- [ ] **Step 1: Write the failing test** + +```js +// server/__tests__/ci-status.test.js +/** + * @file Tests for server/lib/ci-status.js against a fake `gh` binary that + * returns canned `run list`/`run view` JSON for each scenario this module + * has to classify: no run yet, an in-progress run, a green run, and a red + * run — plus branch-vs-sha ref detection. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-")); +const { ciStatus } = require("../lib/ci-status"); + +/** + * Fake `gh` that branches on the subcommand: `run list` returns `listJson`, + * `run view` returns `viewJson`. Also records every invocation's args (one + * line per call, space-joined) to `$CALLS_LOG` so a test can assert which + * flag (--branch vs --commit) was used. + */ +function stubGh(listJson, viewJson) { + const bin = path.join(ROOT, `bin-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(bin, { recursive: true }); + const callsLog = path.join(bin, "calls.log"); + fs.writeFileSync(callsLog, ""); + const script = `#!/usr/bin/env bash +echo "$@" >> "${callsLog}" +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + cat <<'EOF' +${JSON.stringify(listJson)} +EOF + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + cat <<'EOF' +${JSON.stringify(viewJson)} +EOF + exit 0 +fi +echo "unexpected gh invocation: $@" >&2 +exit 1 +`; + fs.writeFileSync(path.join(bin, "gh"), script); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + return { + calls: () => fs.readFileSync(callsLog, "utf8").trim().split("\n").filter(Boolean), + restore: () => { + process.env.PATH = originalPath; + }, + }; +} + +after(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +describe("ciStatus()", () => { + it("reports pending when no run exists yet for the ref", async () => { + const stub = stubGh([], {}); + try { + const result = await ciStatus(ROOT, "development"); + assert.deepEqual(result, { state: "pending", ref: "development", checks: [] }); + } finally { + stub.restore(); + } + }); + + it("reports pending when the latest run is still in progress", async () => { + const stub = stubGh( + [{ databaseId: 1, status: "in_progress", conclusion: null }], + { jobs: [{ name: "server-tests", status: "in_progress", conclusion: null, url: null }] } + ); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "pending"); + } finally { + stub.restore(); + } + }); + + it("reports success when the latest run completed clean", async () => { + const stub = stubGh( + [{ databaseId: 2, status: "completed", conclusion: "success" }], + { + jobs: [ + { name: "server-tests", status: "completed", conclusion: "success", url: "https://x/1" }, + { name: "client-tests", status: "completed", conclusion: "skipped", url: "https://x/2" }, + ], + } + ); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "success"); + assert.equal(result.checks.length, 2); + } finally { + stub.restore(); + } + }); + + it("reports failure when any job failed", async () => { + const stub = stubGh( + [{ databaseId: 3, status: "completed", conclusion: "failure" }], + { + jobs: [ + { name: "server-tests", status: "completed", conclusion: "failure", url: "https://x/3" }, + ], + } + ); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "failure"); + assert.equal(result.checks[0].conclusion, "failure"); + } finally { + stub.restore(); + } + }); + + it("uses --commit for a sha-shaped ref and --branch otherwise", async () => { + const stub = stubGh([], {}); + try { + await ciStatus(ROOT, "a1b2c3d"); + await ciStatus(ROOT, "feat/some-slug"); + const calls = stub.calls().filter((line) => line.startsWith("run list")); + assert.match(calls[0], /--commit a1b2c3d/); + assert.match(calls[1], /--branch feat\/some-slug/); + } finally { + stub.restore(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/ci-status.test.js` +Expected: FAIL — `Cannot find module '../lib/ci-status'` + +- [ ] **Step 3: Write the implementation** + +```js +// server/lib/ci-status.js +/** + * @file Turns a GitHub Actions run for one ref into a single derived fact — + * {state, checks} — for ship-feature-lane's Stage 13 CI-wait. Deliberately a + * snapshot, not a poll loop: the driving skill owns the wait/retry decision + * (docs/LANES.md "Orchestration: what CCAM does NOT do"), this module only + * reports what gh already knows. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { gh } = require("./gh"); + +/** A 7-40 char hex string reads as a commit sha; anything else is a branch name. */ +const SHA_RE = /^[0-9a-f]{7,40}$/i; + +/** + * @param {string} cwd - The lane's repo directory (gh resolves the GitHub + * repo from this directory's git remote). + * @param {string} ref - A branch name or commit sha. + * @returns {Promise<{state: "success"|"failure"|"pending", ref: string, checks: Array}>} + */ +async function ciStatus(cwd, ref) { + const refFlag = SHA_RE.test(ref) ? "--commit" : "--branch"; + const { stdout: listOut } = await gh(cwd, [ + "run", + "list", + refFlag, + ref, + "--json", + "databaseId,status,conclusion", + "--limit", + "1", + ]); + const runs = JSON.parse(listOut); + if (runs.length === 0) { + return { state: "pending", ref, checks: [] }; + } + const run = runs[0]; + if (run.status !== "completed") { + return { state: "pending", ref, checks: [] }; + } + + const { stdout: viewOut } = await gh(cwd, [ + "run", + "view", + String(run.databaseId), + "--json", + "jobs", + ]); + const { jobs } = JSON.parse(viewOut); + const checks = jobs.map((job) => ({ + name: job.name, + conclusion: job.conclusion || job.status, + url: job.url || null, + })); + + const FAILING = new Set(["failure", "cancelled", "timed_out", "action_required"]); + const state = checks.some((c) => FAILING.has(c.conclusion)) ? "failure" : "success"; + return { state, ref, checks }; +} + +module.exports = { ciStatus }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/ci-status.test.js` +Expected: PASS (5 tests) + +- [ ] **Step 5: Verify the file header and commit** + +```bash +bash skills/file-headers/scripts/check-headers.sh +git add server/lib/ci-status.js server/__tests__/ci-status.test.js +git commit -m "feat(server): derive CI state from a single gh run snapshot" +``` + +--- + +### Task 3: `GET /:id/ci/status` route + +**Files:** +- Modify: `server/routes/lanes.js` — add the route near `GET /:id/git` (both are read-only, no-`sameOriginGuard` lane facts routes). +- Modify: `server/__tests__/lanes-api.test.js` — add an HTTP-level describe block. + +**Interfaces:** +- Consumes: `ciStatus(cwd, ref)` from Task 2 (`require("../lib/ci-status")`), `laneOr404`/`sendRuntimeError` already defined in `server/routes/lanes.js`. +- Produces: `GET /api/lanes/:id/ci/status?ref=` → `200 {state, ref, checks}` on success, `400 {error:{code:"EBADREF",...}}` when `ref` is missing, `404` for an unknown lane, `500 {error:{code, message}}` (via `sendRuntimeError`) on a `gh` failure. + +- [ ] **Step 1: Write the failing test** + +Add this block to `server/__tests__/lanes-api.test.js`, after the existing `describe("/api/lanes", ...)` block (same file, so it shares the `request`/`BASE`/`path`/`os` helpers already defined there — only `fs` needs adding to that file's top-of-file requires): + +```js +// --- add near the top of the file, alongside the other requires --- +const fs = require("fs"); + +// --- add as its own describe block --- +describe("GET /:id/ci/status", () => { + let laneId; + let laneCwd; + let restorePath; + + before(async () => { + laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-route-")); + const r = await request("POST", "/api/lanes", { title: "CI status lane", cwd: laneCwd }); + laneId = r.body.lane.id; + + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-bin-")); + fs.writeFileSync( + path.join(bin, "gh"), + `#!/usr/bin/env bash +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]' + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}' + exit 0 +fi +exit 1 +` + ); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + restorePath = () => { + process.env.PATH = originalPath; + }; + }); + + after(() => { + if (restorePath) restorePath(); + fs.rmSync(laneCwd, { recursive: true, force: true }); + }); + + it("404s for an unknown lane", async () => { + const r = await request("GET", "/api/lanes/999999/ci/status?ref=development"); + assert.equal(r.status, 404); + }); + + it("400s when ref is missing", async () => { + const r = await request("GET", `/api/lanes/${laneId}/ci/status`); + assert.equal(r.status, 400); + assert.equal(r.body.error.code, "EBADREF"); + }); + + it("returns the derived state for a real ref", async () => { + const r = await request("GET", `/api/lanes/${laneId}/ci/status?ref=development`); + assert.equal(r.status, 200); + assert.equal(r.body.state, "success"); + assert.equal(r.body.checks[0].name, "tests"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/lanes-api.test.js` +Expected: FAIL — 404 on `/ci/status` (route doesn't exist yet, Express falls through to the `/:id/:action` catch-all or 404s) + +- [ ] **Step 3: Add the route** + +In `server/routes/lanes.js`, add near the top requires: + +```js +const { ciStatus } = require("../lib/ci-status"); +``` + +Add the route directly after the existing `GET /:id/git` route (after its closing `});`, so it sits with the other read-only lane-facts routes): + +```js +/** + * A single GitHub Actions status snapshot for one ref (branch or commit sha) + * in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns + * the wait/retry decision (docs/LANES.md "Orchestration: what CCAM does NOT + * do"); this route only reports what `gh` currently knows. Read-only, no + * same-origin guard, same reasoning as GET /:id/git. + */ +router.get("/:id/ci/status", async (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + const ref = typeof req.query.ref === "string" ? req.query.ref.trim() : ""; + if (!ref) { + return res.status(400).json({ error: { code: "EBADREF", message: "ref is required" } }); + } + try { + res.json(await ciStatus(lane.cwd, ref)); + } catch (err) { + sendRuntimeError(res, err); + } +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/lanes-api.test.js` +Expected: PASS (all tests in the file, including the 3 new ones) + +- [ ] **Step 5: Verify the file header (unchanged file, header already present) and commit** + +```bash +git add server/routes/lanes.js server/__tests__/lanes-api.test.js +git commit -m "feat(api): add GET /:id/ci/status" +``` + +--- + +### Task 4: `ccam lanes ci status` CLI command + +**Files:** +- Modify: `bin/ccam.js` — add `cmdLanesCi`, wire the dispatch, add the help table row. +- Modify: `server/__tests__/lanes-cli.test.js` — add a describe block. + +**Interfaces:** +- Consumes: `GET /api/lanes/:id/ci/status?ref=...` from Task 3; `resolveLaneArg`, `get`, `c` (chalk-like color helper already used elsewhere in `bin/ccam.js`). +- Produces: `ccam lanes ci status []` — prints the JSON response to stdout, sets `process.exitCode` to `0` (success), `1` (failure or any error), or `3` (pending). + +- [ ] **Step 1: Write the failing test** + +Add to `server/__tests__/lanes-cli.test.js`, in a new `describe` block (reusing that file's existing `cli()`, `git()`, `BASE` helpers and `SOURCE_REPO`/`LANE_DIR` fixtures already set up in its `before()`): + +```js +describe("ccam lanes ci status", () => { + let restorePath; + + before(() => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-cli-bin-")); + fs.writeFileSync( + path.join(bin, "gh"), + `#!/usr/bin/env bash +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + if [ "$CI_STATUS_FIXTURE" = "failure" ]; then + echo '[{"databaseId":1,"status":"completed","conclusion":"failure"}]' + elif [ "$CI_STATUS_FIXTURE" = "pending" ]; then + echo '[]' + else + echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]' + fi + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + if [ "$CI_STATUS_FIXTURE" = "failure" ]; then + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"failure","url":"https://x/1"}]}' + else + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}' + fi + exit 0 +fi +exit 1 +` + ); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + restorePath = () => { + process.env.PATH = originalPath; + }; + }); + + after(() => restorePath && restorePath()); + + it("exits 0 and prints success state", async () => { + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR); + assert.equal(r.status, 0); + assert.match(r.stdout, /"state": "success"/); + }); + + it("exits 1 on a failed run", async () => { + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, { + CI_STATUS_FIXTURE: "failure", + }); + assert.equal(r.status, 1); + assert.match(r.stdout, /"state": "failure"/); + }); + + it("exits 3 when no run exists yet", async () => { + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, { + CI_STATUS_FIXTURE: "pending", + }); + assert.equal(r.status, 3); + assert.match(r.stdout, /"state": "pending"/); + }); +}); +``` + +`cli()` in this file only takes `(args, cwd)` today — extend its signature to accept an optional third `extraEnv` argument (backward compatible, every existing call site keeps working unchanged): + +```js +function cli(args, cwd, extraEnv = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CLI, ...args], { + cwd, + env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port), ...extraEnv }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c) => (stdout += c)); + child.stderr.on("data", (c) => (stderr += c)); + child.on("error", reject); + child.on("close", (status) => resolve({ status, stdout, stderr })); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/lanes-cli.test.js` +Expected: FAIL — `ccam lanes ci` prints a "no such subcommand" usage error, exit code 1 (not the expected 0/1/3 from real data), and no `"state"` in stdout + +- [ ] **Step 3: Add the CLI command** + +In `bin/ccam.js`, add near `cmdLanesIntegration` (after its closing `}`): + +```js +/** `ccam lanes ci status []` — one-shot GitHub + * Actions status snapshot for a ref, via GET /:id/ci/status. Never polls; + * the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code + * mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */ +async function cmdLanesCi(args) { + if (args[0] !== "status") { + console.error("usage: ccam lanes ci status []"); + process.exitCode = 1; + return; + } + const ref = args[1]; + if (!ref) { + console.error("usage: ccam lanes ci status []"); + process.exitCode = 1; + return; + } + const resolved = await resolveLaneArg(args.slice(2)); + if (!resolved) return; + const result = await get( + `/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`, + undefined, + { allowError: true } + ); + if (result.status) { + console.error(`✖ ci status → ${result.data?.error?.message || result.status}`); + process.exitCode = 1; + return; + } + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.state === "success" ? 0 : result.state === "pending" ? 3 : 1; +} +``` + +Wire the dispatch — in the `case "lanes":` block, next to the existing `if (rest[0] === "integration") ...`: + +```js + if (rest[0] === "ci") { + return cmdLanesCi(rest.slice(1)); + } +``` + +Add the help table row, next to the `"lanes integration"` row: + +```js + [ + "lanes ci status", + " []", + "One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending", + ], +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/lanes-cli.test.js` +Expected: PASS (all tests in the file, including the 3 new ones) + +- [ ] **Step 5: Commit** + +```bash +git add bin/ccam.js server/__tests__/lanes-cli.test.js +git commit -m "feat(cli): add ccam lanes ci status" +``` + +--- + +### Task 5: Wire Stage 13's CI-wait half + update docs + +**Files:** +- Modify: `skills/ship-feature-lane/SKILL.md` (Stage 13 section, and the "Current status" summary near the top) +- Modify: `docs/LANES.md` ("Stages currently hardcoded skipped", the `ccam lanes integration` section's claim about `ci_wait`'s consumer, and the command reference) + +No test for this task — it's documentation/skill-text only, verified by reading it back and by the `update-project-docs` checklist. This task has no code, so it isn't gated behind `npm run test:server`; run that suite once at the very end of the plan instead (see Task 5 Step 4). + +- [ ] **Step 1: Rewrite Stage 13 in `skills/ship-feature-lane/SKILL.md`** + +Replace the existing Stage 13 section (currently: `### 13 — Post-merge verification *(currently SKIPPED — CI-wait and dev-QC integrations are off)*` through its two-line body) with: + +```markdown +### 13 — Post-merge verification *(CI-wait works; dev-QC still unavailable)* +- `ccam stage merged --status running --note "watching development CI post-merge"` +- Get the merge commit: `gh pr view --json mergeCommit -q .mergeCommit.oid`. +- Loop every ~5 minutes, paced by a **backgrounded** wait (same shape as Stage 12): `sleep 300` with `run_in_background: true`. Each iteration, bump the heartbeat and check: + - `ccam lanes ci status ` — exit `3` (pending) → keep waiting, background another `sleep 300`, end the turn. + - Exit `1` (failure) → triage exactly like Stage 10 (read the failing job via `gh run view ` — the run id is embedded in each failing check's `url` in the command's JSON output; distinguish real failure from shared-runner flake the same way). A real failure on `development` post-merge is a regression the fix-loop doesn't reach (the PR is already merged) — `ccam stage merged --status blocked --note " red on development after merge — human decision"` and STOP; this is not something the pipeline auto-fixes on a shared branch. + - Exit `0` (success) → CI-wait passed. Dev-QC (QCing the deployed site) is a separate, not-yet-built integration — `ccam stage merged --status passed --note "CI green on development; dev-QC unavailable (separate task)"` and proceed to Stage 14. +- If `ccam lanes integration ci_wait` reads off for this profile, skip the loop entirely and go straight to Stage 14 with `--note "ci_wait integration off for this profile"` — the toggle means the profile doesn't want this check, not that the command is unavailable. +``` + +- [ ] **Step 2: Update the skill's front-matter description and "Current status" summary** + +In `skills/ship-feature-lane/SKILL.md`, the YAML front-matter `description` field currently ends with: +> `NOTE: ticket-filing and post-merge dev-site QC are currently OFF (F's integrations aren't built yet) — this pipeline stops at Stage 14 once dev CI/dev-QC support lands.` + +Change to: +> `NOTE: ticket-filing (Stage 9) and post-merge dev-site QC (Stage 13's second half) are currently OFF (F's integrations aren't built yet) — Stage 13's CI-wait half now runs via ccam lanes ci status.` + +Find the "Current status" bullet list (`**Stages currently hardcoded skipped**`) and change the Stage 13 line from: +> `**13 (Post-merge verification)**: Dev-CI-wait and dev-QC integrations are off — the stage transitions immediately to `done` with a note explaining the gap.` + +to a line under a renamed heading that separates the two: + +```markdown +**Stages currently hardcoded skipped** (pending F's integrations): +- **9 (Ticket)**: Ticket-filing integration not yet built — this stage is skipped entirely. + +**Partially wired:** +- **13 (Post-merge verification)**: CI-wait half now runs for real via `ccam lanes ci status`. The dev-QC half (QCing the deployed site) is still unavailable — Stage 13 always ends noting that gap even when CI-wait passes clean. +``` + +- [ ] **Step 3: Update `docs/LANES.md`** + +1. In the "Stages currently hardcoded skipped" list (around line 1137-1139), change the Stage 13 bullet to match the SKILL.md wording from Step 2 above (CI-wait works, dev-QC doesn't). +2. In the `ccam lanes integration` section (around line 1224), change: + > `The agents that would act on an enabled toggle (`ticketer`, `dev-qc`) aren't built yet; `ci_wait`'s consumer (`ccam ci`) isn't either. `ship-feature-lane`'s Stage 9/13/10 stay skipped/fallback regardless of what the toggle reads, until those land.` + + to: + > `The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `ccam lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real.` +3. Add `ccam lanes ci status` to wherever `docs/LANES.md` lists `ccam lanes` subcommands (same table/section `ccam lanes sync-base`, `ccam lanes integration` etc. already appear in) — one row: `ccam lanes ci status []` — one-shot GitHub Actions status for a ref (exit 0/1/3), used by Stage 13's CI-wait. +4. Update the `**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet" line (around line 1198) to drop `ccam ci`/`ci_wait` framing if it's still described there as unbuilt — cross-check by re-reading that paragraph once Step 1-3 are done, since it may already only be about the two agents. + +- [ ] **Step 4: Full verification pass** + +```bash +npm run test:server +bash skills/file-headers/scripts/check-headers.sh +``` + +Both must pass clean — this is the first point every file this plan touched (code and docs) has been checked together. + +- [ ] **Step 5: Commit** + +```bash +git add skills/ship-feature-lane/SKILL.md docs/LANES.md +git commit -m "docs(lanes): wire Stage 13 CI-wait to ccam lanes ci status" +``` -- 2.52.0