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:
+28
-3
@@ -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",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user