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,