From 3223c8edac25b5e632db20124e1c6f5ea174efb3 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 4 Sep 2026 15:14:53 +0700 Subject: [PATCH] feat(lanes): run ONE pipeline node, from the terminal or a slash command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [] [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 `, 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 --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. --- .claude-plugin/plugin.json | 20 +++- CLAUDE.md | 2 +- README.md | 2 + README.vi.md | 2 + bin/ccam.js | 118 +++++++++++++++++++ docs/LANES.md | 25 +++- docs/PLUGINS.md | 4 + plugins/ccam/commands/done.md | 23 ++++ plugins/ccam/commands/e2e-passed.md | 23 ++++ plugins/ccam/commands/e2e.md | 23 ++++ plugins/ccam/commands/gate.md | 23 ++++ plugins/ccam/commands/implement.md | 23 ++++ plugins/ccam/commands/intake.md | 23 ++++ plugins/ccam/commands/merged.md | 23 ++++ plugins/ccam/commands/plan.md | 23 ++++ plugins/ccam/commands/pr-open.md | 23 ++++ plugins/ccam/commands/publishing.md | 23 ++++ plugins/ccam/commands/qc-plan.md | 23 ++++ plugins/ccam/commands/qc.md | 23 ++++ plugins/ccam/commands/reported.md | 23 ++++ plugins/ccam/commands/review.md | 23 ++++ plugins/ccam/commands/ship-full.md | 23 ++++ plugins/ccam/commands/ship-quick.md | 23 ++++ plugins/ccam/commands/tests.md | 23 ++++ plugins/ccam/commands/watching-pr.md | 23 ++++ server/__tests__/lanes-cli.test.js | 54 +++++++++ server/__tests__/lanes-lib.test.js | 8 +- server/__tests__/plugins-marketplace.test.js | 25 ++++ server/data/pipelines/default.json | 2 + server/data/pipelines/ship-feature.json | 5 + 30 files changed, 675 insertions(+), 6 deletions(-) create mode 100644 plugins/ccam/commands/done.md create mode 100644 plugins/ccam/commands/e2e-passed.md create mode 100644 plugins/ccam/commands/e2e.md create mode 100644 plugins/ccam/commands/gate.md create mode 100644 plugins/ccam/commands/implement.md create mode 100644 plugins/ccam/commands/intake.md create mode 100644 plugins/ccam/commands/merged.md create mode 100644 plugins/ccam/commands/plan.md create mode 100644 plugins/ccam/commands/pr-open.md create mode 100644 plugins/ccam/commands/publishing.md create mode 100644 plugins/ccam/commands/qc-plan.md create mode 100644 plugins/ccam/commands/qc.md create mode 100644 plugins/ccam/commands/reported.md create mode 100644 plugins/ccam/commands/review.md create mode 100644 plugins/ccam/commands/ship-full.md create mode 100644 plugins/ccam/commands/ship-quick.md create mode 100644 plugins/ccam/commands/tests.md create mode 100644 plugins/ccam/commands/watching-pr.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3dc7fab6..56da6e4c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -12,7 +12,25 @@ "commands": [ "./plugins/ccam/commands/ccam-doctor.md", "./plugins/ccam/commands/ccam-update.md", - "./plugins/ccam/commands/ccam-open.md" + "./plugins/ccam/commands/ccam-open.md", + "./plugins/ccam/commands/intake.md", + "./plugins/ccam/commands/plan.md", + "./plugins/ccam/commands/implement.md", + "./plugins/ccam/commands/tests.md", + "./plugins/ccam/commands/e2e.md", + "./plugins/ccam/commands/e2e-passed.md", + "./plugins/ccam/commands/review.md", + "./plugins/ccam/commands/qc-plan.md", + "./plugins/ccam/commands/qc.md", + "./plugins/ccam/commands/gate.md", + "./plugins/ccam/commands/publishing.md", + "./plugins/ccam/commands/pr-open.md", + "./plugins/ccam/commands/reported.md", + "./plugins/ccam/commands/watching-pr.md", + "./plugins/ccam/commands/merged.md", + "./plugins/ccam/commands/done.md", + "./plugins/ccam/commands/ship-full.md", + "./plugins/ccam/commands/ship-quick.md" ], "mcpServers": "./plugins/ccam/.mcp.json", "hooks": { diff --git a/CLAUDE.md b/CLAUDE.md index 890f8c7c..e2e88cf1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: e109d28 (2026-09-04) + Workspace single console render site + skill-plugin auto-install._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: 58774f2 (2026-09-04) + per-node run command (`ccam lanes node`) and its slash commands._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. diff --git a/README.md b/README.md index 7b48a02f..1a0ce5ec 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ ccam service install # auto-start on boot via systemd --user (Linux) ccam sessions # recent sessions ccam lanes # lanes with stage and progress ccam lanes pipeline # this lane's pipeline template, or switch it +ccam lanes node # run ONE pipeline node's own command (no id: list them) +# (in a session: /intake, /qc, /ship-full, /ship-quick …) ccam stage # declare the current lane's stage ccam tail # live event feed ``` diff --git a/README.vi.md b/README.vi.md index 92ed1d6b..139d108f 100644 --- a/README.vi.md +++ b/README.vi.md @@ -103,6 +103,8 @@ ccam service install # tự khởi động khi boot máy qua systemd --user ccam sessions # session gần đây ccam lanes # danh sách lane kèm stage và progress ccam lanes pipeline # pipeline template của lane này, hoặc đổi nó +ccam lanes node # chạy riêng command của MỘT node (không có id: liệt kê) +# (trong session: /intake, /qc, /ship-full, /ship-quick …) ccam stage # khai báo stage hiện tại của lane ccam tail # xem live event feed ``` diff --git a/bin/ccam.js b/bin/ccam.js index 1c01e94c..33a7d73e 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -2452,6 +2452,116 @@ async function cmdLanesCi(args) { process.exitCode = result.state === "success" ? 0 : result.state === "pending" ? 3 : 1; } +/** + * `ccam lanes node [] [extra…] [] [--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 ` 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 [extra args…] [] [--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 [] []` — show or switch which pipeline * template a lane renders against. `ccam lanes add --pipeline` could only set @@ -2786,6 +2896,11 @@ const COMMAND_GROUPS = [ "[] []", "Show, or switch, which pipeline template a lane renders against", ], + [ + "lanes node", + "[] [extra…] [] [--print]", + "Run ONE pipeline node's own command in the lane's cwd (no id: list every node and its command)", + ], [ "ship-full", '"" [] [--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)); } diff --git a/docs/LANES.md b/docs/LANES.md index 295a2c55..a1fab634 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -519,6 +519,29 @@ ccam ship-full "add an export button" --print # show the command, switch nothi 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. +### Running ONE node + +`ccam ship-full` / `ccam ship-quick` hand the whole pipeline to a skill. When you only want one step — re-run the CI gate, redo the brainstorm, open the PR by hand — run that node's own `command`: + +```bash +ccam lanes node # list every node of this lane's pipeline and its command +ccam lanes node intake # → claude "/superpowers:brainstorming" +ccam lanes node intake the export button # extra words are appended to the prompt +ccam lanes node gates # → ccam lanes hook ci-gate +ccam lanes node pr-open --print # show the argv, run nothing +``` + +Both shipped templates give **every** node a command, so any of the 16 (or 8) is runnable. A node starting with `/` is spawned as `claude " "`; anything else is spawned directly. The lane comes from the working directory as usual, and the command always runs in the lane's own `cwd`. + +The node is matched by **id or alias**, so one name works on either template: `default` calls the step `implement` and `ship-feature` calls it `implementing`, and `ccam lanes node implement` finds it on both. + +**From inside a Claude session, use the slash commands instead.** The plugin ships one per node — `/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` for the whole pipeline. Each resolves the node's command through `ccam lanes node --print` and runs it *in the current session*: a slash-command node is invoked directly (never as a nested `claude` process), anything else runs as a shell argv in the lane's cwd. Extra words are appended to the prompt: `/intake the export button`. + +Two deliberate limits: + +- **The command is split on whitespace and spawned without a shell.** A template is data — files anyone can drop into `DASHBOARD_PIPELINES_DIR` — so its `command` is never interpreted as shell. A node needing a pipe or a redirect names a script that contains them. +- **Running a node does not declare its stage.** A command executing is not proof the node passed — the same reason the run console never writes a lane's stage. Declare with `ccam stage ` when the work is actually done. (The exception is a node whose command *is* `ccam stage `: there the declaration is the whole step.) + **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. @@ -763,7 +786,7 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.ccam/pipelines/m - **label** (string, optional): display name in the UI; defaults to `id` if omitted - **icon** (string, optional): unicode emoji or single character shown before the stage name - **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false` - - **command** (string, optional): the one command that drives THIS stage — a slash command (`"/superpowers:writing-plans"`) or a shell one (`"ccam lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver — a terminal `done`, or a gate another agent declares. Surfaced in the node's tooltip on a lane card and as copyable text per node on the Workflows page; **never executed by the dashboard**, same contract as `skill`. + - **command** (string, optional): the one command that drives THIS stage — a slash command (`"/superpowers:writing-plans"`) or a shell one (`"ccam lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; both shipped templates fill every node, using `"ccam stage "` for the nodes whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a lane card and as copyable text per node on the Workflows page, and run by `ccam lanes node ` (below); **never executed by the dashboard**, same contract as `skill`. - **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `ccam stage training`) Then set the environment variable: diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 68f1de58..2728157a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -88,6 +88,7 @@ checkout unnecessary. | The MCP tools | `plugins/ccam/.mcp.json`, pointing at the committed `mcp/build/index.js` | | The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts | | `/ccam-doctor`, `/ccam-update`, `/ccam-open` | `plugins/ccam/commands/` | +| `/ship-full`, `/ship-quick` and one command per pipeline node (`/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/review`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/done`, …) | `plugins/ccam/commands/`, one file per name | | The skills (`ship-feature-lane`, `ship-feature`, `repo-onboarding`, `debug-live-issue`, `mcp-operations`, `update-project-docs`, `file-headers`) | `skills/` at the repo root — which IS the plugin root, so they are auto-discovered as plugin skills | | The agents (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`, `frontend-reviewer`, `mcp-reviewer`) | `agents/` at the repo root, auto-discovered the same way — exposed as `ccam:` | @@ -179,6 +180,9 @@ reboot, same as always. | `/ccam-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **skill plugins** (see below), MCP build freshness, UI bundle | | `/ccam-update` | Pull the latest `ccam` release via `claude plugin marketplace update` + `claude plugin update ccam@ -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. | | `/ccam-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes | +| `/ship-full ` | Put the lane on the 16-node `ship-feature` template, then run `/ccam:ship-feature-lane` **in this session**. The in-session twin of `ccam ship-full` (which spawns its own `claude` from a terminal) | +| `/ship-quick ` | The same for the 8-node `default` template and `/ccam:ship-feature` — stops at review/gate, no PR, no browser QC | +| One per pipeline node: `/intake`, `/plan`, `/implement`, `/tests`, `/e2e`, `/e2e-passed`, `/review`, `/qc-plan`, `/qc`, `/gate`, `/publishing`, `/pr-open`, `/reported`, `/watching-pr`, `/merged`, `/done` | Run just that node's own `command`, resolved through `ccam lanes node --print` against whichever template the lane is on (matched by id **or alias**, so `/implement` works on `default`'s `implement` and `ship-feature`'s `implementing`). A slash-command node is invoked in the current session, never as a nested `claude`; anything else runs as a shell argv in the lane's cwd. None of them declares a stage | #### The `Skill plugins` row diff --git a/plugins/ccam/commands/done.md b/plugins/ccam/commands/done.md new file mode 100644 index 00000000..103a2ce7 --- /dev/null +++ b/plugins/ccam/commands/done.md @@ -0,0 +1,23 @@ +--- +description: Declare the lane done +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `done` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node done $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/e2e-passed.md b/plugins/ccam/commands/e2e-passed.md new file mode 100644 index 00000000..2c9fc7cc --- /dev/null +++ b/plugins/ccam/commands/e2e-passed.md @@ -0,0 +1,23 @@ +--- +description: Declare e2e passed (ship-feature only) +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `e2e-passed` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node e2e-passed $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/e2e.md b/plugins/ccam/commands/e2e.md new file mode 100644 index 00000000..f651125f --- /dev/null +++ b/plugins/ccam/commands/e2e.md @@ -0,0 +1,23 @@ +--- +description: E2e on the feature branch (ship-feature only) +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `e2e` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node e2e $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/gate.md b/plugins/ccam/commands/gate.md new file mode 100644 index 00000000..3a5f1d7c --- /dev/null +++ b/plugins/ccam/commands/gate.md @@ -0,0 +1,23 @@ +--- +description: Senior GO/NO-GO gate +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `gate` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node gate $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/implement.md b/plugins/ccam/commands/implement.md new file mode 100644 index 00000000..27fa2075 --- /dev/null +++ b/plugins/ccam/commands/implement.md @@ -0,0 +1,23 @@ +--- +description: Implement (TDD) +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `implement` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node implement $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/intake.md b/plugins/ccam/commands/intake.md new file mode 100644 index 00000000..96c38a24 --- /dev/null +++ b/plugins/ccam/commands/intake.md @@ -0,0 +1,23 @@ +--- +description: Intake — brainstorm the requirement +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `intake` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node intake $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/merged.md b/plugins/ccam/commands/merged.md new file mode 100644 index 00000000..d462037d --- /dev/null +++ b/plugins/ccam/commands/merged.md @@ -0,0 +1,23 @@ +--- +description: Declare merged (ship-feature only) +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `merged` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node merged $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/plan.md b/plugins/ccam/commands/plan.md new file mode 100644 index 00000000..4a3e6e82 --- /dev/null +++ b/plugins/ccam/commands/plan.md @@ -0,0 +1,23 @@ +--- +description: Plan — write the implementation plan +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `plan` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node plan $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/pr-open.md b/plugins/ccam/commands/pr-open.md new file mode 100644 index 00000000..54605a21 --- /dev/null +++ b/plugins/ccam/commands/pr-open.md @@ -0,0 +1,23 @@ +--- +description: Open the PR +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `pr-open` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node pr-open $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/publishing.md b/plugins/ccam/commands/publishing.md new file mode 100644 index 00000000..33d4bca9 --- /dev/null +++ b/plugins/ccam/commands/publishing.md @@ -0,0 +1,23 @@ +--- +description: Publish the branch +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `publishing` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node publishing $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/qc-plan.md b/plugins/ccam/commands/qc-plan.md new file mode 100644 index 00000000..27313372 --- /dev/null +++ b/plugins/ccam/commands/qc-plan.md @@ -0,0 +1,23 @@ +--- +description: Declare the QC plan (ship-feature only) +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `qc-plan` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node qc-plan $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/qc.md b/plugins/ccam/commands/qc.md new file mode 100644 index 00000000..6334bbae --- /dev/null +++ b/plugins/ccam/commands/qc.md @@ -0,0 +1,23 @@ +--- +description: Browser QC + proof gallery (ship-feature only) +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `qc` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node qc $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/reported.md b/plugins/ccam/commands/reported.md new file mode 100644 index 00000000..a890ed04 --- /dev/null +++ b/plugins/ccam/commands/reported.md @@ -0,0 +1,23 @@ +--- +description: Declare the run reported (ship-feature only) +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `reported` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node reported $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/review.md b/plugins/ccam/commands/review.md new file mode 100644 index 00000000..93c6c95b --- /dev/null +++ b/plugins/ccam/commands/review.md @@ -0,0 +1,23 @@ +--- +description: Code review +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `review` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node review $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/ship-full.md b/plugins/ccam/commands/ship-full.md new file mode 100644 index 00000000..1ac093e0 --- /dev/null +++ b/plugins/ccam/commands/ship-full.md @@ -0,0 +1,23 @@ +--- +description: Run the full 16-stage lane pipeline (implement → gates → e2e → review → QC → senior gate → PR, watched until merged) +argument-hint: +allowed-tools: Bash(ccam lanes pipeline:*) +--- + +Put this lane on the 16-node `ship-feature` template first, so the dashboard +renders the right node count from the first stage rather than after the skill +self-corrects: + +!`ccam lanes pipeline ship-feature` + +Then run the pipeline **in this session** by invoking `/ccam:ship-feature-lane` +with this requirement: + +$ARGUMENTS + +- No requirement given → ask for one. Never guess what to build. +- The switch above failed (no lane owns this directory) → say so and stop; the + skill has no lane to report stages against. + +This is the in-session twin of `ccam ship-full ""`, which does the +same two steps from a terminal by spawning its own `claude`. diff --git a/plugins/ccam/commands/ship-quick.md b/plugins/ccam/commands/ship-quick.md new file mode 100644 index 00000000..199c0edb --- /dev/null +++ b/plugins/ccam/commands/ship-quick.md @@ -0,0 +1,23 @@ +--- +description: Run the 8-stage in-repo pipeline (plan → implement → tests → review → gate → ship). No PR, no browser QC +argument-hint: +allowed-tools: Bash(ccam lanes pipeline:*) +--- + +Put this lane on the 8-node `default` template first, so the dashboard renders +the right node count from the first stage: + +!`ccam lanes pipeline default` + +Then run the pipeline **in this session** by invoking `/ccam:ship-feature` with +this requirement: + +$ARGUMENTS + +- No requirement given → ask for one. Never guess what to build. +- The switch above failed (no lane owns this directory) → say so and stop; the + skill has no lane to report stages against. + +This is the in-session twin of `ccam ship-quick ""`. It stops at +review/gate: no PR is opened and no browser QC runs — use `/ship-full` when the +work should reach reviewers. diff --git a/plugins/ccam/commands/tests.md b/plugins/ccam/commands/tests.md new file mode 100644 index 00000000..593d322c --- /dev/null +++ b/plugins/ccam/commands/tests.md @@ -0,0 +1,23 @@ +--- +description: Tests / pre-push CI gates +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `tests` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node tests $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/plugins/ccam/commands/watching-pr.md b/plugins/ccam/commands/watching-pr.md new file mode 100644 index 00000000..6e0c5a1c --- /dev/null +++ b/plugins/ccam/commands/watching-pr.md @@ -0,0 +1,23 @@ +--- +description: Watch the PR / CI (ship-feature only) +argument-hint: [extra prompt words] +allowed-tools: Bash(ccam lanes node:*) +--- + +The command this lane pipeline template declares for its `watching-pr` node, resolved +against whichever template the lane is actually on (matched by id or alias): + +!`ccam lanes node watching-pr $ARGUMENTS --print` + +Run that here, in THIS session - never start a nested `claude` process: + +- Line reads `cd && "claude" ""` -> invoke that slash + command yourself, passing any extra words as its argument. +- Anything else is a shell argv -> run it with Bash from ``. +- Line is an error (`no node ...`, `declares no command`, no lane owns this + directory) -> print it verbatim and stop. Do not guess a substitute. + +Do NOT call `ccam stage` afterwards: a command running is not proof the node +passed. Declare the stage when the work is actually done. (The exception is a +node whose command already IS `ccam stage ` - there the declaration is the +whole step.) diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index f240a3b8..9afa0d30 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -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; diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index cca70b88..80c77561 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -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 ` 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", () => { diff --git a/server/__tests__/plugins-marketplace.test.js b/server/__tests__/plugins-marketplace.test.js index 45c93bb7..64cd3936 100644 --- a/server/__tests__/plugins-marketplace.test.js +++ b/server/__tests__/plugins-marketplace.test.js @@ -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`); diff --git a/server/data/pipelines/default.json b/server/data/pipelines/default.json index 249c6b02..6e9bb622 100644 --- a/server/data/pipelines/default.json +++ b/server/data/pipelines/default.json @@ -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", diff --git a/server/data/pipelines/ship-feature.json b/server/data/pipelines/ship-feature.json index a1ce504b..097e3a2a 100644 --- a/server/data/pipelines/ship-feature.json +++ b/server/data/pipelines/ship-feature.json @@ -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"