Merge pull request 'feat(lanes): drive runs from the pipeline template's own skill' (#9) from feat/pipeline-driven-ship-commands into main
Reviewed-on: Smartgift-AI/Claude-Code-Monitor#9
This commit was merged in pull request #9.
This commit is contained in:
+96
@@ -2310,6 +2310,7 @@ async function cmdLanesPipeline(args) {
|
|||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||||
const { pipelines } = await get("/api/lanes/pipelines");
|
const { pipelines } = await get("/api/lanes/pipelines");
|
||||||
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||||
|
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2357,6 +2358,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} "<requirement>" [<id>] [--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) {
|
async function cmdFeatureShow(args) {
|
||||||
const slug = args.find((arg) => !arg.startsWith("--"));
|
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -2541,6 +2624,16 @@ const COMMAND_GROUPS = [
|
|||||||
"[<template-id>] [<id>]",
|
"[<template-id>] [<id>]",
|
||||||
"Show, or switch, which pipeline template a lane renders against",
|
"Show, or switch, which pipeline template a lane renders against",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"ship-full",
|
||||||
|
'"<requirement>" [<id>] [--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",
|
||||||
|
'"<requirement>" [<id>] [--print]',
|
||||||
|
"Run the 8-stage in-repo pipeline: plan → implement → tests → review → gate → ship. No PR, no browser QC",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"lanes shell",
|
"lanes shell",
|
||||||
"[<id>]",
|
"[<id>]",
|
||||||
@@ -3458,6 +3551,9 @@ async function runCommand(argv) {
|
|||||||
}
|
}
|
||||||
case "stage":
|
case "stage":
|
||||||
return cmdStage(rest);
|
return cmdStage(rest);
|
||||||
|
case "ship-full":
|
||||||
|
case "ship-quick":
|
||||||
|
return cmdShip(cmd, rest);
|
||||||
case "feature": {
|
case "feature": {
|
||||||
const sub = rest[0];
|
const sub = rest[0];
|
||||||
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export function LaneConsolePane({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { activeRuns: runs } = latest.current;
|
const { activeRuns: runs } = latest.current;
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
lastPrefill.current = "";
|
||||||
setResumeSession(null);
|
setResumeSession(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
@@ -129,6 +130,23 @@ export function LaneConsolePane({
|
|||||||
refreshList();
|
refreshList();
|
||||||
}, [laneId, 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
|
// The switch effect above only sees whatever `activeRuns` the page already
|
||||||
// had loaded at that instant. Remounting this page (navigating away and
|
// 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
|
// back) starts `activeRuns` at null again, so a lane with a live run would
|
||||||
|
|||||||
@@ -188,6 +188,24 @@ describe("LaneConsolePane", () => {
|
|||||||
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
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(<LaneConsolePane {...props} />);
|
||||||
|
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(<LaneConsolePane {...props} lanes={[laneShip]} />);
|
||||||
|
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(<LaneConsolePane {...props} lanes={[lane]} />);
|
||||||
|
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)", () => {
|
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
||||||
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
||||||
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -2303,6 +2303,10 @@ export interface Lane {
|
|||||||
stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>;
|
stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
pipeline_name: string;
|
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[];
|
pipeline_nodes: LaneNode[];
|
||||||
progress: number;
|
progress: number;
|
||||||
stage_seconds: number | null;
|
stage_seconds: number | null;
|
||||||
|
|||||||
@@ -238,11 +238,15 @@ A lane is a durable unit of parallel agent work — one working directory, many
|
|||||||
| `ccam lanes pipeline [<template-id>] [<id>]` | 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 pipeline [<template-id>] [<id>]` | 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 [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
||||||
| `ccam lanes reset\|remove\|purge <id> [--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 lanes reset\|remove\|purge <id> [--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 "<requirement>" [<id>] [--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 "<requirement>" [<id>] [--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 <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` 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 stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` 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 [<id>]` | List every feature this lane has activated, archived or live |
|
| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
|
||||||
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
|
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
|
||||||
| `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
|
| `ccam feature show <slug> [<id>]` | 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 `<repo>/.ccam/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing.
|
**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 `<repo>/.ccam/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
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.
|
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
|
## 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",
|
"id": "ml-training",
|
||||||
"name": "ML Training Pipeline",
|
"name": "ML Training Pipeline",
|
||||||
|
"skill": "/my-training-skill",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{ "id": "data", "label": "data", "icon": "📊", "gate": false, "aliases": ["intake"] },
|
{ "id": "data", "label": "data", "icon": "📊", "gate": false, "aliases": ["intake"] },
|
||||||
{ "id": "preprocess","label": "preprocess","icon": "⚙️", "gate": false, "aliases": ["prepare", "clean"] },
|
{ "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
|
- **id** (string, required): unique identifier for this template
|
||||||
- **name** (string, optional): display name shown in the UI
|
- **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
|
- **nodes** (array, required): list of pipeline stages, in order
|
||||||
- **id** (string): stage identifier (used in `ccam stage`)
|
- **id** (string): stage identifier (used in `ccam stage`)
|
||||||
- **label** (string, optional): display name in the UI; defaults to `id` if omitted
|
- **label** (string, optional): display name in the UI; defaults to `id` if omitted
|
||||||
|
|||||||
@@ -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 <branch-or-sha>` — 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ĩ <vinnt@smartgift.vn>`) — 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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execFile } = require("node:child_process");
|
||||||
|
const { promisify } = require("node:util");
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `gh <args>` 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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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=<branch-or-sha>` → `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 <branch-or-sha> [<lane-id>]` — 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 <branch-or-sha> [<lane-id>]` — 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 <branch-or-sha> [<lane-id>]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ref = args[1];
|
||||||
|
if (!ref) {
|
||||||
|
console.error("usage: ccam lanes ci status <branch-or-sha> [<lane-id>]");
|
||||||
|
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",
|
||||||
|
"<branch-or-sha> [<id>]",
|
||||||
|
"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 <pr_url> --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 <mergeSha>` — 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 <run-id>` — 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 "<job> 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 <branch-or-sha> [<id>]` — 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"
|
||||||
|
```
|
||||||
@@ -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=<branch-or-sha>`
|
||||||
|
|
||||||
|
- Resolves the lane's `cwd` (same lookup every other lane route uses).
|
||||||
|
- Runs `gh run list --branch <ref> --json databaseId,conclusion,status,url --limit 1`
|
||||||
|
to find the latest run for that ref, then `gh run view <id> --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 <branch-or-sha> [<lane-id>]`
|
||||||
|
|
||||||
|
- GETs the route above, prints the JSON, sets `process.exitCode`.
|
||||||
|
- Resolves the lane the same way every other `ccam lanes <verb>` 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 → <message>` 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 <pr_url> --json mergeCommit -q .mergeCommit.oid`.
|
||||||
|
- Loop (`sleep 300`, `run_in_background: true`, bump heartbeat each
|
||||||
|
iteration): `ccam lanes ci status <sha>`.
|
||||||
|
- `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.
|
||||||
@@ -543,6 +543,37 @@ 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ccam lanes ci status", () => {
|
describe("ccam lanes ci status", () => {
|
||||||
let restorePath;
|
let restorePath;
|
||||||
let bin;
|
let bin;
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ describe("lanes lib", () => {
|
|||||||
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
|
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
|
||||||
assert.ok(p.progress > 0 && p.progress < 100);
|
assert.ok(p.progress > 0 && p.progress < 100);
|
||||||
assert.equal(p.liveness, "idle");
|
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);
|
lanes.deleteLane(l.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "default",
|
"id": "default",
|
||||||
"name": "Default feature pipeline",
|
"name": "Default feature pipeline",
|
||||||
|
"skill": "/ccam:ship-feature",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "intake",
|
"id": "intake",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "ship-feature",
|
"id": "ship-feature",
|
||||||
"name": "Ship feature (lane pipeline)",
|
"name": "Ship feature (lane pipeline)",
|
||||||
|
"skill": "/ccam:ship-feature-lane",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "intake",
|
"id": "intake",
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ function lanePayload(lane, ageSec = null) {
|
|||||||
return {
|
return {
|
||||||
...lane,
|
...lane,
|
||||||
pipeline_name: pipeline.name,
|
pipeline_name: pipeline.name,
|
||||||
|
pipeline_skill: pipeline.skill || "",
|
||||||
pipeline_nodes: withDetected(nodeStates(pipeline, lane), pipeline, lane),
|
pipeline_nodes: withDetected(nodeStates(pipeline, lane), pipeline, lane),
|
||||||
progress: progressPct(pipeline, lane),
|
progress: progressPct(pipeline, lane),
|
||||||
stage_seconds: Number.isNaN(since)
|
stage_seconds: Number.isNaN(since)
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ function loadAll() {
|
|||||||
try {
|
try {
|
||||||
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
||||||
if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue;
|
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) => ({
|
doc.nodes = doc.nodes.map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
label: n.label || n.id,
|
label: n.label || n.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user