feat(lanes): run ONE pipeline node, from the terminal or a slash command

A pipeline was all-or-nothing: `ccam ship-full` / `ship-quick` hand the whole
thing to a skill, and a node's own `command` — added so the map would stop
hiding the driver behind its detect regexes — was display-only. Re-running just
the CI gate, or redoing the brainstorm, meant clicking copy on the node and
pasting the string into a session by hand.

`ccam lanes node [<id>] [extra…] [--print]` runs it, in the lane's own cwd. A
slash-command node spawns `claude` with it as the prompt (same shape as
`ccam ship-full`); anything else is spawned directly. No id lists every node
and its command, which is also what the two error paths print.

The node is matched by id OR alias, the same way `ccam stage` matches one:
`default` calls the step `implement` and `ship-feature` calls it `implementing`,
and one command name must not depend on which template the lane happens to be
on. That needs the template's `aliases`, which the lane payload's
`pipeline_nodes` does not carry — so the two are merged, template for the
definition, lane for the `current` marker.

Both shipped templates now fill EVERY node's `command`. The seven that were
empty are the nodes whose only work IS the declaration — a terminal `done`, a
gate another agent declares — so they get `ccam stage <id>`, which keeps all 16
(and all 8) runnable instead of leaving holes a caller has to know about.

Slash commands for the same thing, because a session should not shell out to a
nested `claude`: `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`,
`/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`,
`/reported`, `/watching-pr`, `/merged`, `/done`, plus `/ship-full` and
`/ship-quick`. Each resolves its node through `ccam lanes node <id> --print` and
runs the result IN the current session — invoking the slash command itself when
that is what the node names, otherwise running the shell argv. Resolving at call
time rather than baking the string into the file is what stops the 18 command
files from drifting when a template changes.

Two limits are deliberate:
  - The command is split on whitespace and spawned WITHOUT a shell. A template
    is data — anyone can drop a file into DASHBOARD_PIPELINES_DIR — so its
    `command` must never be interpreted as shell. A node needing a pipe names a
    script that contains one.
  - Running a node does not declare its stage. A command executing is not proof
    the node passed, same boundary as the console never writing a lane's stage.

Verified: npm run test:server (1403, +3 covering alias matching, full command
coverage of both templates, and every command file being listed in the
manifest), npm run test:client (459), prettier and check-headers clean. Docs:
docs/LANES.md § Running ONE node, docs/PLUGINS.md commands table, README +
README.vi CLI blocks, CLAUDE.md marker.
This commit is contained in:
2026-09-04 15:14:53 +07:00
parent 58774f211a
commit 3223c8edac
30 changed files with 675 additions and 6 deletions
+54
View File
@@ -697,6 +697,60 @@ describe("ccam ship-full / ccam ship-quick", () => {
});
});
describe("ccam lanes node", () => {
it("with no node id, lists every node and its command, and exits non-zero with usage", async () => {
const r = await cli(["lanes", "node"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /\bintake\b/);
assert.match(r.stdout, /\bdone\b/);
assert.match(r.stderr, /usage: ccam lanes node/);
});
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
await cli(["lanes", "pipeline", "default"], LANE_DIR);
const r = await cli(["lanes", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /"claude" "\/superpowers:writing-plans the export button"/);
});
it("--print shows a shell node split into argv, never a shell string", async () => {
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const r = await cli(["lanes", "node", "gates", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /"ccam" "lanes" "hook" "ci-gate"/);
});
it("every node of both shipped templates declares a runnable command", async () => {
const { body } = await get("/api/lanes/pipelines");
for (const p of body.pipelines.filter((x) => ["default", "ship-feature"].includes(x.id))) {
for (const n of p.nodes) {
assert.ok(n.command, `${p.id}/${n.id} has no command`);
}
}
});
it("matches a node by alias, so one slash command works on either template", async () => {
// `default` calls the step `implement`, `ship-feature` calls it
// `implementing` — /ccam:implement must not depend on which one is active.
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const ship = await cli(["lanes", "node", "implement", "--print"], LANE_DIR);
assert.equal(ship.status, 0, ship.stderr);
assert.match(ship.stdout, /test-driven-development/);
await cli(["lanes", "pipeline", "default"], LANE_DIR);
const def = await cli(["lanes", "node", "implementing", "--print"], LANE_DIR);
assert.equal(def.status, 0, def.stderr);
assert.match(def.stdout, /test-driven-development/);
});
it("names the pipeline's nodes when asked for one that is not in it", async () => {
const r = await cli(["lanes", "node", "not-a-node", "--print"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /no node "not-a-node"/);
assert.match(r.stdout, /\bintake\b/);
});
});
describe("ccam lanes ci status", () => {
let restorePath;
let bin;
+5 -3
View File
@@ -152,9 +152,11 @@ describe("per-node driver command", () => {
assert.equal(byId.intake, "/superpowers:brainstorming");
assert.equal(byId.implementing, "/superpowers:test-driven-development");
assert.equal(byId.gates, "ccam lanes hook ci-gate");
// A terminal node has no driver of its own: reaching `done` is a
// declaration, not something a command performs.
assert.equal(byId.done, "");
// A terminal or gate node has no work of its own to run: reaching it IS
// the declaration, so its command is the declaration - which keeps
// `ccam lanes node <id>` runnable for every node in the template.
assert.equal(byId.done, "ccam stage done");
assert.equal(byId["e2e-feature-passed"], "ccam stage e2e-feature-passed");
});
it("defaults a template node that declares no command to the empty string", () => {
@@ -229,6 +229,31 @@ describe("plugin marketplace", () => {
}
});
it("lists every command file that exists — an unlisted one never loads", () => {
// `commands` is an explicit array, not a directory scan: a file added to
// plugins/ccam/commands/ and forgotten here is invisible to every user.
const dir = path.join(REPO_ROOT, "plugins/ccam/commands");
const onDisk = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
const listed = new Set(manifest.commands.map((rel) => path.basename(rel)));
for (const f of onDisk) assert.ok(listed.has(f), `${f} is not in plugin.json commands`);
});
it("gives every pipeline node of the shipped templates its own slash command", () => {
// /intake, /qc, /gate … are how a node is run from inside a session; a
// node with no command file can only be reached from a terminal.
const { listPipelines } = require("../lib/pipelines");
const listed = new Set(manifest.commands.map((rel) => path.basename(rel, ".md")));
for (const p of listPipelines().filter((x) => ["default", "ship-feature"].includes(x.id))) {
for (const n of p.nodes) {
const names = [n.id, ...(n.aliases || [])];
assert.ok(
names.some((name) => listed.has(name)),
`${p.id}/${n.id} has no slash command (tried ${names.join(", ")})`
);
}
}
});
it("declares an MCP config that exists and resolves through CLAUDE_PLUGIN_ROOT", () => {
const mcpFile = path.join(REPO_ROOT, manifest.mcpServers);
assert.ok(fs.existsSync(mcpFile), `${manifest.mcpServers} does not exist`);
+2
View File
@@ -8,6 +8,7 @@
"label": "Intake",
"icon": "📝",
"gate": false,
"command": "ccam stage intake",
"aliases": [
"assigned",
"claimed",
@@ -175,6 +176,7 @@
"label": "Done",
"icon": "✅",
"gate": false,
"command": "ccam stage done",
"aliases": [
"complete",
"completed",
+5
View File
@@ -132,6 +132,7 @@
"label": "E2e passed",
"icon": "🧪",
"gate": true,
"command": "ccam stage e2e-feature-passed",
"aliases": [
"e2e-passed"
]
@@ -163,6 +164,7 @@
"label": "QC plan",
"icon": "📋",
"gate": false,
"command": "ccam stage qc-plan",
"aliases": []
},
{
@@ -250,6 +252,7 @@
"label": "Reported",
"icon": "📣",
"gate": false,
"command": "ccam stage reported",
"aliases": []
},
{
@@ -273,6 +276,7 @@
"label": "Merged — post-verify",
"icon": "🔗",
"gate": false,
"command": "ccam stage merged",
"aliases": []
},
{
@@ -280,6 +284,7 @@
"label": "Done",
"icon": "✅",
"gate": false,
"command": "ccam stage done",
"aliases": [
"complete",
"completed"