feat(lanes): make the pipeline map track a skill's real progress

A lane's pipeline map only ever moved when a skill remembered to call
`ccam stage`, and the ship-feature template shipped with no detection rules
at all — so a lane driven by Superpowers skills sat at whatever stage it
last declared, and the `gates` node was never declared by anything.

Detection (`detect` rules on each node) now covers the Superpowers skill
invocations and the `ccam`/`gh` commands the ship-feature-lane skill
actually runs. It stays a safety net, not the mechanism: forward-only,
never `done`, never overriding a declaration. Two rules were deliberately
left out — `git diff` on `review` (this repo's own tests record it pinning
a lane at `review` on a real session) and anything on `merged`/`done`.

Stage vocabulary grows to 50 names over the same 16 nodes, following
Shipyard's PHASES shape: sub-states like `migration-collision`,
`e2e-scoped` and `gate-blocked` say WHY a lane sits on a node without the
map growing a node per reason. Every alias has a source — the skill
declares it, `default.json` uses it, or Shipyard's PHASES lists it.

Two silent failures fixed along the way:

- `lane.stages` is keyed by the raw declared string, so a stage declared
  under an alias lost its `--evidence` and rendered amber instead of
  green. `stageRecords` resolves each key onto its node.
- `ccam stage <typo>` stored fine and then rendered nowhere. It now warns
  on stderr while still exiting 0.

`ccam lanes pipeline` closes the gap that made all of this invisible: a
lane could only be assigned a template at creation, and no screen in the
web UI offers the choice, so every lane added from "+ Add lane" was stuck
on `default`'s 8 nodes. An unknown template id is now refused rather than
silently falling back to `default` on read.

Also merges the repo's own `ship-feature` skill into the Superpowers
workflow: it delegates planning/TDD/review/verification instead of
restating them, and declares a stage at each phase.
This commit is contained in:
2026-08-07 09:34:20 +07:00
parent 87b5e1c3db
commit 67edda77eb
13 changed files with 827 additions and 52 deletions
+54
View File
@@ -186,6 +186,31 @@ describe("ccam stage", () => {
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
});
it("warns, but still records, a stage name matching no pipeline node", async () => {
// setStage stores the string verbatim, so a typo'd stage is accepted and
// then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how
// a lane ends up looking unstarted for an entire pipeline run.
const r = await cli(["stage", "revieww"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /revieww/);
assert.match(r.stderr, /matches no node/i);
assert.match(r.stderr, /revieww/);
});
it("does not warn for a stage declared by alias", async () => {
const r = await cli(["stage", "planning"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.doesNotMatch(r.stderr, /matches no node/i);
});
it("does not warn for a valid stage reported as failed", async () => {
// --result fail paints the node `failed`, not `current`; the warning must
// not read that as an unknown stage.
const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.doesNotMatch(r.stderr, /matches no node/i);
});
});
describe("ccam lanes add", () => {
@@ -248,6 +273,35 @@ describe("ccam lanes add", () => {
}
});
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
// `--pipeline` was creation-only, so every lane added from the dashboard
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
const show = await cli(["lanes", "pipeline"], LANE_DIR);
assert.equal(show.status, 0, show.stderr);
assert.match(show.stdout, /default/);
assert.match(show.stdout, /available:.*ship-feature/);
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
assert.equal(set.status, 0, set.stderr);
assert.match(set.stdout, /ship-feature/);
assert.match(set.stdout, /16 nodes/);
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
assert.equal(back.status, 0, back.stderr);
assert.match(back.stdout, /8 nodes/);
});
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
// getPipeline() returns the default template for an unknown id, so without
// a write-side check a typo would store, render `default`, and look fine.
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
const after = await cli(["lanes", "pipeline"], LANE_DIR);
assert.match(after.stdout, /default/);
});
it("provisions a managed worktree lane and reports it ready", async () => {
const r = await cli(
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
+38
View File
@@ -77,6 +77,44 @@ describe("pipelines", () => {
assert.equal(byId.plan, "passed-no-evidence");
});
it("keeps the evidence of a stage declared under an alias", () => {
// `lane.stages` is keyed by the raw declared string, so `ccam stage
// planning --evidence x` files the record under "planning" while the node
// is "plan". Looking it up by node id alone loses the evidence and paints
// a `done` node amber — silently, and only for agents that use an alias.
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: { planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" } },
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
});
it("prefers a node-id record over an alias record for the same node", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: {
planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: null },
plan: { enteredAt: "2026-07-27T00:30:00Z", evidence: "docs/plan.md" },
},
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
});
it("ignores a recorded stage matching no node instead of shifting the others", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: { "totally-unknown": { enteredAt: "x", evidence: "y" } },
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "passed-no-evidence");
assert.equal(byId.review, "current");
});
it("computes progress from node position, 0 for an unknown stage", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
assert.equal(progressPct(p, { stage: p.nodes[0].id, stages: {} }), 0);
+134
View File
@@ -482,4 +482,138 @@ test.describe("ship-feature pipeline template", () => {
const ids = pipeline.nodes.map((n) => n.id);
assert.deepEqual(ids, [...new Set(ids)]);
});
test.it("no alias collides with another node's id or with a second node's alias", () => {
// phaseIdx() takes the FIRST node whose id or alias matches, so a duplicate
// silently resolves a declaration onto the wrong node — the failure mode
// aliases are supposed to prevent.
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
const seen = new Map();
for (const n of pipeline.nodes) {
for (const name of [n.id, ...n.aliases]) {
const key = name.toLowerCase();
assert.equal(
seen.has(key),
false,
`"${name}" claimed by both ${seen.get(key)} and ${n.id}`
);
seen.set(key, n.id);
}
}
});
// Same discipline as default.json's end-to-end rule sweep: a rule pinned only
// against a fixture can ship inert, and these fire on a live lane's hooks.
test.it("every shipped ship-feature.json rule fires end-to-end via getPipeline", () => {
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
const cases = [
["intake", "Skill", { skill: "superpowers:brainstorming" }],
["intake", "Bash", { command: "ccam feature activate lane-stage-detect" }],
["plan", "Skill", { skill: "superpowers:writing-plans" }],
["plan", "Write", { file_path: "docs/superpowers/specs/lane-foo.md" }],
["implementing", "Skill", { skill: "superpowers:test-driven-development" }],
["implementing", "Skill", { skill: "superpowers:systematic-debugging" }],
["implementing", "Edit", { file_path: "server/lib/app.js" }],
["implementing", "Write", { file_path: "server/lib/app.js" }],
["gates", "Bash", { command: "ccam lanes hook ci-gate" }],
["gates", "Bash", { command: "ccam lanes sync-base --check feat/foo" }],
["e2e-feature", "Bash", { command: "ccam lanes up --qc" }],
["e2e-feature", "Bash", { command: "ccam lanes hook e2e" }],
["review", "Skill", { skill: "code-review" }],
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
["qc", "Agent", { subagent_type: "qc-local" }],
["qc", "Bash", { command: "ccam lanes proof-link" }],
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
["publishing", "Bash", { command: "git push -u origin feat/foo" }],
["pr-open", "Bash", { command: "gh pr create --base development --fill" }],
["watching-pr", "Bash", { command: "gh pr view https://x/pull/1 --json state" }],
];
for (const [nodeId, tool_name, tool_input] of cases) {
const got = detect(pipeline, { tool_name, tool_input });
assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
}
});
test.it("every sub-state the skill declares resolves onto its node", () => {
// These names exist so a skill can say WHY a lane sits on a node without
// the map growing a node per reason (Shipyard's PHASES shape: ~35 stage
// names over 13 nodes). An unresolvable one is worse than no alias: it
// records, renders nowhere, and only warns.
const { reload, phaseIdx } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
const subStates = {
bootstrapping: "intake",
"migration-collision": "gates",
"sync-conflict": "gates",
booting: "e2e-feature",
"e2e-scoped": "e2e-feature",
live: "e2e-feature",
"gate-blocked": "gate",
"push-conflict": "pr-open",
"push-revalidate": "pr-open",
"pr-comment-fix": "watching-pr",
};
for (const [declared, nodeId] of Object.entries(subStates)) {
assert.equal(
phaseIdx(pipeline, declared),
pipeline.nodes.findIndex((n) => n.id === nodeId),
`${declared} must resolve to ${nodeId}`
);
}
});
test.it("does not stamp implementing for an edit to the lane spec", () => {
// Stage 5 appends the QC Plan to docs/superpowers/specs/lane-<slug>.md.
// Without the exclusion that edit reads as code work, and because
// `implementing` sits early in the pipeline the mis-read is only invisible
// by luck (forward-only) — on a fix-loop re-entry it would be the live stage.
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
for (const tool_name of ["Edit", "Write"]) {
const r = detect(pipeline, {
tool_name,
tool_input: { file_path: "/lanes/lane1/docs/superpowers/specs/lane-foo.md" },
});
assert.notEqual(r && r.nodeId, "implementing", tool_name);
}
});
test.it("never infers a terminal stage — merged and done are declaration-only", () => {
// CLAUDE.md: an inferred node never renders `done`. Shipping a detect rule
// for these nodes would be the one way to break that from the data side.
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
for (const id of ["merged", "done", "e2e-feature-passed", "reported", "qc-plan"]) {
const node = pipeline.nodes.find((n) => n.id === id);
assert.deepEqual(node.detect, [], `${id} must carry no detect rule`);
}
});
test.it("does not read the word push, or an unrelated ccam call, as a later stage", () => {
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
for (const command of [
'git commit -m "do not push this yet"',
"ccam lanes logs 3 e2e",
"ccam stage gates --status running",
"gh pr diff 42",
]) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.equal(
["publishing", "pr-open", "watching-pr", "e2e-feature"].includes(r && r.nodeId),
false,
`${command}${r && r.nodeId}`
);
}
});
});