From 6df69d881134b5ad38337e677786aa5e86bfb7e7 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 4 Sep 2026 11:43:43 +0700 Subject: [PATCH] feat(lanes): give every pipeline node its own driver command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pipeline template named ONE command for the whole run (`skill`), so the only way to know what actually moves a given node was to read that node's `detect` regexes. Each node now carries its own `command`: a slash command (`/superpowers:writing-plans`) or a shell one (`ccam lanes hook ci-gate`), whichever its detect rules already watch for. The values are derived from each node's own detect rules, not invented — every `ccam …` string was checked against bin/ccam.js and every `/superpowers:*` against the installed plugin. Nodes reaching which is a declaration rather than an action (`done`, `merged`, `reported`, `e2e-feature-passed`, `qc-plan`) are deliberately left empty. `pipelines.js` normalizes a missing or non-string value to "", so a custom template written before this field keeps loading rather than rendering "undefined". `nodeStates()` and GET /api/lanes/pipeline-stats pass it through. Never executed by the dashboard — display text, the same contract `skill` already had. Surfaced where each view has room for it: the Workflows pass-rate panel shows it under the node label and copies it on click; PipelineMap folds it into the node tooltip, because a 16-node pipeline has to fit across one card and there is no width for a second line. `command?: string` is optional in the client type only to spare four unrelated test fixtures — the server always sends the key, and the UI has to branch on the empty string regardless. Verified: npm run test:server (1373, +3), npm run test:client (459, +2), tsc --noEmit, prettier and check-headers clean. docs/LANES.md § Custom template format documents the field. openapi.yaml needs no change: the lane payload and pipeline-stats were never schematized there. --- client/src/components/lanes/PipelineMap.tsx | 12 +++- .../workflows/LanePipelinePanel.tsx | 21 +++++- .../__tests__/LanePipelinePanel.test.tsx | 40 ++++++++++- client/src/i18n/locales/en/workflows.json | 1 + client/src/i18n/locales/vi/workflows.json | 1 + client/src/lib/types.ts | 10 +++ docs/LANES.md | 11 ++-- server/__tests__/lanes-lib.test.js | 66 +++++++++++++++++++ server/data/pipelines/default.json | 6 ++ server/data/pipelines/ship-feature.json | 11 ++++ server/lib/pipelines.js | 12 +++- server/routes/lanes.js | 1 + 12 files changed, 177 insertions(+), 15 deletions(-) diff --git a/client/src/components/lanes/PipelineMap.tsx b/client/src/components/lanes/PipelineMap.tsx index c5b3b777..431ad538 100644 --- a/client/src/components/lanes/PipelineMap.tsx +++ b/client/src/components/lanes/PipelineMap.tsx @@ -49,11 +49,17 @@ export default function PipelineMap({ data-testid={`pipeline-node-${n.id}`} data-state={n.state} data-detected={n.detected ? "true" : undefined} - title={ + // The node's driver command rides along in the tooltip rather than + // on the pill: the map has to fit a 16-node pipeline across one + // card, so there is no width for a second line of text. + title={[ n.detected && detectedSignal ? `${n.label} ← ${detectedSignal}` - : `${n.label} — ${n.state}` - } + : `${n.label} — ${n.state}`, + n.command ? `→ ${n.command}` : null, + ] + .filter(Boolean) + .join("\n")} className={`flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-full border px-2 py-1.5 text-[11px] ${n.detected ? DETECTED_CLASS : STATE_CLASS[n.state]}`} > {n.icon} diff --git a/client/src/components/workflows/LanePipelinePanel.tsx b/client/src/components/workflows/LanePipelinePanel.tsx index 22571cc3..b739e695 100644 --- a/client/src/components/workflows/LanePipelinePanel.tsx +++ b/client/src/components/workflows/LanePipelinePanel.tsx @@ -139,9 +139,24 @@ export function LanePipelinePanel() { data-testid={`pipeline-stat-${n.id}`} className="flex items-center gap-2 text-xs" > - - {n.icon} - {n.label} + {/* Label over the command that drives this node — the pass rate + to the right answers "how often", this answers "by what". */} + + + {n.icon} + {n.label} + + {n.command && ( + + )} {/* Bars are scaled against the busiest node, not against each diff --git a/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx b/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx index 3e9bc007..d2fcff7c 100644 --- a/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx +++ b/client/src/components/workflows/__tests__/LanePipelinePanel.test.tsx @@ -3,7 +3,8 @@ * row per node of the busiest pipeline, distinguishes a proved pass from a * merely claimed one, and switches templates through the picker. Conflating * evidence with a bare claim is the failure that matters here — it would make - * an undocumented stage look verified. + * an undocumented stage look verified. Also covers the per-node driver command: + * shown and copyable when the template names one, absent when it does not. * @author Nguyễn Ngọc Trí Vĩ */ @@ -18,11 +19,12 @@ vi.mock("../../../lib/api", async (importOriginal) => { return { ...actual, api: { lanes: { pipelineStats: (id?: number | null) => statsMock(id) } } }; }); -const node = (id: string, over: Record = {}) => ({ +const node = (id: string, over: Record = {}) => ({ id, label: id, icon: "🧭", gate: false, + command: "", entered: 0, passed: 0, withEvidence: 0, @@ -37,7 +39,12 @@ const STATS = { name: "Default feature pipeline", runs: 4, nodes: [ - node("plan", { entered: 4, passed: 4, withEvidence: 4 }), + node("plan", { + entered: 4, + passed: 4, + withEvidence: 4, + command: "/superpowers:writing-plans", + }), node("implement", { entered: 4, passed: 4, withEvidence: 0 }), node("tests", { entered: 3, passed: 2, withEvidence: 2, failed: 1 }), node("ship"), @@ -51,6 +58,33 @@ const STATS = { ], }; +describe("LanePipelinePanel per-node command", () => { + it("shows the driver command under a node that declares one, and nothing for a node that does not", async () => { + statsMock.mockResolvedValue(STATS); + render(); + + const plan = await screen.findByTestId("pipeline-stat-command-plan"); + expect(plan).toHaveTextContent("/superpowers:writing-plans"); + // `tests` carries no command in the fixture: an empty string must render + // nothing at all, never an empty clickable row. + expect(screen.queryByTestId("pipeline-stat-command-tests")).toBeNull(); + }); + + it("copies the command to the clipboard on click", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + statsMock.mockResolvedValue(STATS); + render(); + + fireEvent.click(await screen.findByTestId("pipeline-stat-command-plan")); + + expect(writeText).toHaveBeenCalledWith("/superpowers:writing-plans"); + }); +}); + describe("LanePipelinePanel", () => { it("shows pass count, entries and rate per node of the busiest pipeline", async () => { statsMock.mockResolvedValue(STATS); diff --git a/client/src/i18n/locales/en/workflows.json b/client/src/i18n/locales/en/workflows.json index 7a408298..af84f8e4 100644 --- a/client/src/i18n/locales/en/workflows.json +++ b/client/src/i18n/locales/en/workflows.json @@ -254,6 +254,7 @@ "legendClaimed": "passed, no evidence", "legendFailed": "failed", "never": "never reached", + "copyCommand": "Copy: {{command}}", "empty": "No lane has recorded a stage yet. Create one with", "emptyLane": "This lane has not recorded a stage yet." }, diff --git a/client/src/i18n/locales/vi/workflows.json b/client/src/i18n/locales/vi/workflows.json index 6fbb3404..96edd3d6 100644 --- a/client/src/i18n/locales/vi/workflows.json +++ b/client/src/i18n/locales/vi/workflows.json @@ -254,6 +254,7 @@ "legendClaimed": "pass, không bằng chứng", "legendFailed": "thất bại", "never": "chưa từng tới", + "copyCommand": "Sao chép: {{command}}", "empty": "Chưa lane nào ghi nhận giai đoạn. Tạo lane bằng", "emptyLane": "Lane này chưa ghi nhận giai đoạn nào." }, diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 78b68a50..d43832f5 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2256,6 +2256,13 @@ export interface LaneNode { label: string; icon: string; gate: boolean; + /** The one command that drives this node — a slash command + * (`/superpowers:writing-plans`) or a shell one (`ccam lanes hook ci-gate`), + * from the pipeline template. Empty string for a node with no single driver + * (`done`, or a gate another agent declares). Display text only; the + * dashboard never runs it. Optional in the type only because the archived + * fixtures and older snapshots predate it; the server always sends the key. */ + command?: string; state: "done" | "current" | "passed-no-evidence" | "failed" | "pending"; /** Whether the server's stage-detection heuristic saw evidence of this stage * from ingested tool events, independent of `state` (which is only set by an @@ -2485,6 +2492,9 @@ export interface PipelineNodeStat { label: string; icon: string; gate: boolean; + /** Same per-node driver command as {@link LaneNode.command}; empty when the + * template names none for this node. */ + command?: string; entered: number; passed: number; withEvidence: number; diff --git a/docs/LANES.md b/docs/LANES.md index 8dd876c9..295a2c55 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -744,11 +744,11 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.ccam/pipelines/m "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"] }, - { "id": "train", "label": "train", "icon": "🧠", "gate": false, "aliases": ["training", "fit"] }, - { "id": "eval", "label": "eval", "icon": "📈", "gate": true, "aliases": ["evaluate", "metrics"] }, - { "id": "deploy", "label": "deploy", "icon": "🚀", "gate": false, "aliases": ["ship", "release"] } + { "id": "data", "label": "data", "icon": "📊", "gate": false, "command": "dvc pull", "aliases": ["intake"] }, + { "id": "preprocess","label": "preprocess","icon": "⚙️", "gate": false, "command": "make preprocess", "aliases": ["prepare", "clean"] }, + { "id": "train", "label": "train", "icon": "🧠", "gate": false, "command": "/my-training-skill", "aliases": ["training", "fit"] }, + { "id": "eval", "label": "eval", "icon": "📈", "gate": true, "command": "make eval", "aliases": ["evaluate", "metrics"] }, + { "id": "deploy", "label": "deploy", "icon": "🚀", "gate": false, "aliases": ["ship", "release"] } ] } ``` @@ -763,6 +763,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`. - **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/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index f2e21752..cca70b88 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -126,6 +126,72 @@ describe("pipelines", () => { const lanes = require("../lib/lanes"); const features = require("../lib/lane-features"); +describe("per-node driver command", () => { + it("gives every built-in template's nodes a command field, and carries it into the render states", () => { + for (const pipeline of listPipelines()) { + for (const node of pipeline.nodes) { + assert.equal( + typeof node.command, + "string", + `${pipeline.id}/${node.id} must declare a command string` + ); + } + const states = nodeStates(pipeline, { stage: pipeline.nodes[0].id, stages: {} }); + assert.deepEqual( + states.map((n) => n.command), + pipeline.nodes.map((n) => n.command), + `${pipeline.id}: nodeStates must pass each node's command through` + ); + } + }); + + it("names a real driver for the stages an agent actually drives", () => { + const ship = getPipeline("ship-feature"); + const byId = Object.fromEntries(ship.nodes.map((n) => [n.id, n.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, ""); + }); + + it("defaults a template node that declares no command to the empty string", () => { + // A custom template written before this field existed must keep loading - + // an undefined command would render as "undefined" in the UI. + const fs = require("node:fs"); + const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "pipelines-nocmd-")); + fs.writeFileSync( + pathMod.join(dir, "legacy.json"), + JSON.stringify({ + id: "legacy-nocmd", + name: "Legacy", + nodes: [ + { id: "start", label: "Start" }, + { id: "done", command: 42 }, + ], + }) + ); + const prev = process.env.DASHBOARD_PIPELINES_DIR; + process.env.DASHBOARD_PIPELINES_DIR = dir; + const { reload } = require("../lib/pipelines"); + reload(); + try { + const p = getPipeline("legacy-nocmd"); + assert.deepEqual( + p.nodes.map((n) => n.command), + ["", ""], + "a missing or non-string command must normalize to empty, never leak through" + ); + } finally { + if (prev === undefined) delete process.env.DASHBOARD_PIPELINES_DIR; + else process.env.DASHBOARD_PIPELINES_DIR = prev; + reload(); + } + }); +}); + describe("lanes lib", () => { it("creates, lists, updates and deletes a lane", () => { const l = lanes.createLane({ title: "Feature A", cwd: "/tmp/wt/a", branch: "feat/a" }); diff --git a/server/data/pipelines/default.json b/server/data/pipelines/default.json index 943f4591..249c6b02 100644 --- a/server/data/pipelines/default.json +++ b/server/data/pipelines/default.json @@ -25,6 +25,7 @@ "label": "Plan", "icon": "🧭", "gate": false, + "command": "/superpowers:writing-plans", "aliases": [ "planning", "brainstorm", @@ -53,6 +54,7 @@ "label": "Implement", "icon": "🛠", "gate": false, + "command": "/superpowers:test-driven-development", "aliases": [ "implementing", "coding", @@ -77,6 +79,7 @@ "label": "Tests", "icon": "🧪", "gate": true, + "command": "npm test", "aliases": [ "testing", "unit", @@ -99,6 +102,7 @@ "label": "Review", "icon": "👀", "gate": true, + "command": "/superpowers:requesting-code-review", "aliases": [ "reviewing", "code-review", @@ -124,6 +128,7 @@ "label": "Gate", "icon": "🚦", "gate": true, + "command": "/superpowers:verification-before-completion", "aliases": [ "verify", "verification", @@ -146,6 +151,7 @@ "label": "Ship", "icon": "🔀", "gate": false, + "command": "/superpowers:finishing-a-development-branch", "aliases": [ "pr", "pr-open", diff --git a/server/data/pipelines/ship-feature.json b/server/data/pipelines/ship-feature.json index dde095c4..a1ce504b 100644 --- a/server/data/pipelines/ship-feature.json +++ b/server/data/pipelines/ship-feature.json @@ -8,6 +8,7 @@ "label": "Intake", "icon": "📝", "gate": false, + "command": "/superpowers:brainstorming", "aliases": [ "assigned", "claimed", @@ -34,6 +35,7 @@ "label": "Plan", "icon": "🧭", "gate": false, + "command": "/superpowers:writing-plans", "aliases": [ "planning", "brainstorm", @@ -62,6 +64,7 @@ "label": "Implement (TDD)", "icon": "🛠", "gate": false, + "command": "/superpowers:test-driven-development", "aliases": [ "implement", "coding", @@ -87,6 +90,7 @@ "label": "CI gates + preflight", "icon": "🧪", "gate": true, + "command": "ccam lanes hook ci-gate", "aliases": [ "pre-push-gate", "tests", @@ -109,6 +113,7 @@ "label": "E2e on feature branch", "icon": "🧪", "gate": false, + "command": "ccam lanes up --qc", "aliases": [ "e2e", "e2e-scoped", @@ -136,6 +141,7 @@ "label": "Code review", "icon": "👀", "gate": true, + "command": "/superpowers:requesting-code-review", "aliases": [ "reviewing", "code-review", @@ -164,6 +170,7 @@ "label": "Browser QC", "icon": "🔍", "gate": true, + "command": "ccam lanes proof-link", "aliases": [], "detect": [ { @@ -181,6 +188,7 @@ "label": "Senior GO/NO-GO gate", "icon": "🚦", "gate": true, + "command": "/superpowers:verification-before-completion", "aliases": [ "sr-gate", "verify", @@ -203,6 +211,7 @@ "label": "Publish PR", "icon": "🔀", "gate": false, + "command": "/superpowers:finishing-a-development-branch", "aliases": [ "push" ], @@ -222,6 +231,7 @@ "label": "PR open", "icon": "🔀", "gate": false, + "command": "gh pr create --base development", "aliases": [ "ship", "pr", @@ -247,6 +257,7 @@ "label": "Watching PR", "icon": "👁", "gate": false, + "command": "ccam lanes ci status", "aliases": [ "pr-comment-fix" ], diff --git a/server/lib/pipelines.js b/server/lib/pipelines.js index 952f1f52..28254a64 100644 --- a/server/lib/pipelines.js +++ b/server/lib/pipelines.js @@ -40,6 +40,16 @@ function loadAll() { label: n.label || n.id, icon: n.icon || "", gate: !!n.gate, + // The one command that drives THIS node — a slash command + // (`/superpowers:writing-plans`) or a shell one (`ccam lanes hook + // ci-gate`), mirroring whatever the node's own `detect` rules watch + // for. `doc.skill` above names the driver for the whole pipeline; + // this narrows it to the step, so a reader of the pipeline map can + // see what actually moves each node instead of inferring it from a + // regex. Empty for a node with no single driver (`done`, or a gate + // another agent declares). Never executed by the dashboard — it is + // display text, same contract as `doc.skill`. + command: typeof n.command === "string" ? n.command : "", aliases: Array.isArray(n.aliases) ? n.aliases : [], detect: Array.isArray(n.detect) ? n.detect : [], })); @@ -122,7 +132,7 @@ function nodeStates(pipeline, lane) { else if (rec) state = rec.evidence ? "done" : "passed-no-evidence"; else if (cur > -1 && i < cur) state = "passed-no-evidence"; else state = "pending"; - return { id: n.id, label: n.label, icon: n.icon, gate: n.gate, state }; + return { id: n.id, label: n.label, icon: n.icon, gate: n.gate, command: n.command, state }; }); } diff --git a/server/routes/lanes.js b/server/routes/lanes.js index ec7c8bb8..1bc1d7d8 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -192,6 +192,7 @@ router.get("/pipeline-stats", (req, res) => { label: n.label, icon: n.icon, gate: n.gate, + command: n.command, entered: 0, passed: 0, withEvidence: 0,