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
+62
View File
@@ -192,6 +192,7 @@ async function api(method, pathname, body, options = {}) {
}
const get = (p, b, options) => api("GET", p, undefined, options);
const post = (p, b, options) => api("POST", p, b, options);
const patch = (p, b, options) => api("PATCH", p, b, options);
/**
* Print the standard "server is not running" indicator and exit 1. Every
@@ -2262,6 +2263,43 @@ async function cmdLanesIntegration(args) {
process.exitCode = enabled ? 0 : 1;
}
/**
* `ccam lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `ccam lanes add --pipeline` could only set
* this at creation time, so every lane added from the dashboard's "+ Add lane"
* was stuck on `default` with no way back to a 16-node template.
*/
async function cmdLanesPipeline(args) {
const target = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
const resolved = await resolveLaneArg(args.filter((a) => a !== target));
if (!resolved) return;
if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
console.log(`lane #${lane.id}${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return;
}
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
console.log(
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
);
// Switching templates re-resolves the SAME declared stage string against a
// different node list, so a stage that meant something in the old pipeline
// can land nowhere in the new one. Same warning as `ccam stage`, same reason.
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
)
);
}
}
async function cmdFeatureShow(args) {
const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) {
@@ -2315,6 +2353,22 @@ async function cmdStage(args) {
result: flag("result"),
});
console.log(`lane #${lane.id}${lane.stage} (${lane.progress}%)`);
// A stage name matching no node (nor alias) still stores — setStage takes the
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
// `current` and progress reads 0. Warn, never fail: a typo must not break a
// declaration the pipeline can still record, but it must not pass silently.
// Skipped for `--result fail`, which paints the node `failed` rather than
// `current` and would otherwise look identical to an unknown stage.
const nodes = lane.pipeline_nodes || [];
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
)
);
}
}
// ── Command catalog ─────────────────────────────────────────────────────────
@@ -2425,6 +2479,11 @@ const COMMAND_GROUPS = [
"[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)",
],
[
"lanes pipeline",
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a lane renders against",
],
[
"lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
@@ -3300,6 +3359,9 @@ async function runCommand(argv) {
if (rest[0] === "integration") {
return cmdLanesIntegration(rest.slice(1));
}
if (rest[0] === "pipeline") {
return cmdLanesPipeline(rest.slice(1));
}
if (rest[0] === "gc") {
return cmdLanesGc(rest.slice(1));
}