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
+28 -3
View File
@@ -10,7 +10,14 @@
*/
const { db } = require("../db");
const { getPipeline, phaseIdx, nodeStates, progressPct } = require("./pipelines");
const {
listPipelines,
getPipeline,
phaseIdx,
stageRecords,
nodeStates,
progressPct,
} = require("./pipelines");
const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300);
/**
@@ -88,6 +95,18 @@ function validateKind(kind) {
}
}
/**
* `getPipeline` falls back to the default template for an unknown id — correct
* when READING (a lane must always render something), wrong when WRITING: a
* typo'd id would be accepted, stored, and then silently draw the default map
* forever. Reject it at the write, where the caller can still be told.
*/
function validatePipeline(id) {
if (!listPipelines().some((p) => p.id === id)) {
throw Object.assign(new Error(`unknown pipeline: ${id}`), { code: "EBADPIPELINE" });
}
}
function hydrate(row) {
if (!row) return null;
let stages = {};
@@ -125,6 +144,7 @@ function createLane({
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
}
validateKind(kind);
validatePipeline(pipeline);
const info = db
.prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -156,6 +176,9 @@ function updateLane(id, patch = {}) {
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
validateKind(patch.kind);
}
if ("pipeline" in patch && patch.pipeline !== null && patch.pipeline !== undefined) {
validatePipeline(patch.pipeline);
}
const cols = [];
const vals = [];
for (const [k, v] of Object.entries(patch)) {
@@ -441,14 +464,16 @@ function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
* its own id: declaring by ALIAS (`ccam stage coding` → the `implement` node)
* keys `stages` by the raw declared string, so the node the agent says it is on
* would otherwise render as an inference instead of the blue `current` ring.
* Past nodes go through `stageRecords`, which resolves those alias keys — a
* node the agent DECLARED must never be painted as merely detected.
*/
function withDetected(states, pipeline, lane) {
const detectedIdx = phaseIdx(pipeline, lane.detected_stage);
if (detectedIdx === -1) return states.map((n) => ({ ...n, detected: false }));
const stages = lane.stages || {};
const records = stageRecords(pipeline, lane.stages);
return states.map((n, i) => ({
...n,
detected: i <= detectedIdx && !stages[n.id] && n.state !== "current",
detected: i <= detectedIdx && !records.has(i) && n.state !== "current",
}));
}
+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,