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
+118
View File
@@ -2452,6 +2452,116 @@ async function cmdLanesCi(args) {
process.exitCode = result.state === "success" ? 0 : result.state === "pending" ? 3 : 1;
}
/**
* `ccam lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
* lane's pipeline instead of the whole thing.
*
* Every node already carries the one command that drives it (`command`, added
* so the map could stop hiding it behind its detect regexes), but that string
* was display-only: the dashboard rendered a copy button and the operator
* pasted it into a session by hand. This runs it, in the lane's own cwd:
*
* - a slash command (`/superpowers:brainstorming`) starts `claude` with it
* as the prompt, exactly like `ccam ship-full` does with the whole skill;
* - anything else (`npm test`, `ccam lanes hook ci-gate`) is spawned directly.
*
* With no node id it lists the nodes and their commands, which is also the
* error path's answer. `--print` shows the argv and runs nothing.
*
* The node is matched by id OR alias, the same way `ccam stage` matches one:
* the two shipped templates name the same step `implement` and `implementing`,
* and `/ccam:implement` must not depend on which one the lane happens to be on.
*
* The command is split on whitespace and spawned WITHOUT a shell: a template
* is data, and templates in `DASHBOARD_PIPELINES_DIR` are files anyone can
* drop in, so `command` must never be interpreted as shell. A node needing a
* pipe or a redirect belongs in a script the node then names.
*
* It does NOT declare the stage. Running a node's command is not proof the
* node passed — same boundary as the console never writing a lane's stage.
* Declare with `ccam stage <node>` when the work is actually done.
*/
async function cmdLanesNode(args) {
const { flags, positional } = parseArgs(args);
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
// the template does. Merge, so the listing can still mark the current node.
const { pipelines } = await get("/api/lanes/pipelines");
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
...n,
state: stateOf.get(n.id),
}));
const rest = positional.filter((p) => p !== String(resolved.laneId));
const key = rest[0];
const listNodes = () => {
console.log(`lane #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
for (const n of nodes) {
const mark = n.state === "current" ? c.cyan("→") : " ";
console.log(
`${mark} ${n.id.padEnd(20)} ${n.command ? c.dim(n.command) : c.dim("(no command)")}`
);
}
};
if (!key) {
listNodes();
console.error(`\nusage: ccam lanes node <node-id> [extra args…] [<id>] [--print]`);
process.exitCode = 1;
return;
}
const wanted = key.toLowerCase();
const node = nodes.find(
(n) =>
n.id.toLowerCase() === wanted || (n.aliases || []).some((a) => a.toLowerCase() === wanted)
);
if (!node) {
console.error(c.red(`✖ no node "${key}" in pipeline "${lane.pipeline}"`));
listNodes();
process.exitCode = 1;
return;
}
if (!node.command) {
console.error(
c.red(`✖ node "${node.id}" declares no command`) +
` — add a "command" to it in the pipeline template (see docs/LANES.md)`
);
process.exitCode = 1;
return;
}
const extra = rest.slice(1).join(" ").trim();
const argv = node.command.startsWith("/")
? ["claude", extra ? `${node.command} ${extra}` : node.command]
: [...node.command.split(/\s+/), ...rest.slice(1)];
if (flags.print) {
console.log(`cd ${lane.cwd} && ${argv.map((a) => JSON.stringify(a)).join(" ")}`);
return;
}
console.log(
`${c.dim(`lane #${lane.id} · node ${node.id} (${node.label})`)}\n` +
`${c.cyan("→")} ${argv.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}`
);
const child = spawn(argv[0], argv.slice(1), { 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();
});
});
}
/**
* `ccam lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `ccam lanes add --pipeline` could only set
@@ -2786,6 +2896,11 @@ const COMMAND_GROUPS = [
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a lane renders against",
],
[
"lanes node",
"[<node-id>] [extra…] [<id>] [--print]",
"Run ONE pipeline node's own command in the lane's cwd (no id: list every node and its command)",
],
[
"ship-full",
'"<requirement>" [<id>] [--print]',
@@ -3694,6 +3809,9 @@ async function runCommand(argv) {
if (rest[0] === "pipeline") {
return cmdLanesPipeline(rest.slice(1));
}
if (rest[0] === "node") {
return cmdLanesNode(rest.slice(1));
}
if (rest[0] === "shell") {
return cmdLanesShell(rest.slice(1));
}