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
+24 -2
View File
@@ -77,6 +77,27 @@ function phaseIdx(pipeline, stage) {
);
}
/**
* Index every recorded stage by the node it resolves to, so a stage declared
* under an ALIAS keeps its record — and therefore its evidence. Keying the
* records by node id alone (what `lane.stages` is keyed by, verbatim from the
* declaration) silently drops `--evidence` the moment an agent says `e2e`
* instead of `e2e-feature`, which is exactly what aliases exist to allow.
* A record stored under the node's own id always wins over an alias record for
* the same node; a key matching no node is skipped.
*/
function stageRecords(pipeline, stages) {
const byIdx = new Map();
for (const [key, rec] of Object.entries(stages || {})) {
const i = phaseIdx(pipeline, key);
if (i === -1) continue;
const isCanonical = key.toLowerCase() === pipeline.nodes[i].id.toLowerCase();
if (byIdx.has(i) && !isCanonical) continue;
byIdx.set(i, rec);
}
return byIdx;
}
/**
* Render state per node:
* failed — the stage recorded result "fail"
@@ -86,10 +107,10 @@ function phaseIdx(pipeline, stage) {
* pending — not reached
*/
function nodeStates(pipeline, lane) {
const stages = lane.stages || {};
const records = stageRecords(pipeline, lane.stages);
const cur = phaseIdx(pipeline, lane.stage);
return pipeline.nodes.map((n, i) => {
const rec = stages[n.id];
const rec = records.get(i);
let state;
if (rec && rec.result === "fail") state = "failed";
else if (i === cur) state = "current";
@@ -111,6 +132,7 @@ module.exports = {
listPipelines,
getPipeline,
phaseIdx,
stageRecords,
nodeStates,
progressPct,
reload,