From 2103dde889fbe8890df3168e2f576318f495ec82 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 24 Aug 2026 16:34:55 +0700 Subject: [PATCH] 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,