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
+123 -3
View File
@@ -420,6 +420,15 @@ The dashboard web UI shows each lane as a card in a grid, with the selected lane
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
**A lane is created on `default` unless told otherwise, and nothing in the web UI changes that** — the "+ Add lane" flow has no template picker, so a lane that should render the 16-node `ship-feature` map will show `default`'s 8 nodes until it is switched:
```bash
ccam lanes pipeline # which template this lane uses, and what else exists
ccam lanes pipeline ship-feature # switch it
```
Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the command warns when that happens, and the next `ccam stage` fixes it.
The dashboard renders every node in the pipeline in one of five **states**:
| State | Color | Meaning |
@@ -471,6 +480,20 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text
- `ship` / `pr` / `pr-open` / `publishing` / `commit` / `push`
- `done` / `complete` / `completed` / `merged`
Aliases are **per template** — the `ship-feature` pipeline has its own set
(see "Pipeline template: ship-feature" below). A name matching no node and no
alias is still recorded verbatim, but `phaseIdx` then resolves it to nothing:
no node renders `current` and progress reads `0`. The CLI prints a warning to
stderr and still exits `0` in that case — a typo must not break a
declaration the lane can record, but it must not pass silently either:
```
! "revieww" matches no node in pipeline "default" — recorded, but the pipeline map won't show it. Nodes: intake, plan, implement, …
```
The check is skipped for `--result fail`, which paints the node `failed`
rather than `current`.
- `--lane <id>` (optional): the numeric lane ID. If omitted, the command resolves the lane by `cwd`.
- `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
@@ -626,7 +649,11 @@ proof would let a lane's pipeline map lie about what actually happened.
The node the agent *declared* itself on is never flagged as detected, so it
keeps its blue `current` ring — including when the declaration came in through
an alias (`ccam stage coding` resolves to the `implement` node, and `stages` is
keyed by the raw declared word, not the node id).
keyed by the raw declared word, not the node id). Past nodes get the same
protection: `stageRecords` (`server/lib/pipelines.js`) resolves every recorded
key back onto its node, so a stage declared by alias keeps its record — and its
`--evidence` — instead of reading as an inference or losing its `done`. Where a
node has both a canonical record and an alias record, the canonical one wins.
The same rule governs `ccam lanes`: the inferred stage is printed only when it
leads the declared one (see "Viewing lanes" below), and `LaneCard.tsx`'s
@@ -772,8 +799,43 @@ accepts `git push`, `git -c core.hooksPath=/dev/null push` and the credential
-helper form, while rejecting `git log … "push"`, `git commit -m "don't push"`,
`docker push`, and `npm run push-docs`.
`intake`, `gate`, and `done` deliberately have no rules. `intake` is where a
lane starts — there is no tool event that means "just claimed," so there is
The `ship-feature` template (`server/data/pipelines/ship-feature.json`) carries
its own rules, aimed at the commands and Superpowers skills its driving skill
actually runs:
| Node | Detect rules |
|---|---|
| `intake` | `Skill` matching `brainstorming`; `Bash` matching `ccam … feature activate` |
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/superpowers/specs/lane-.*\.md` |
| `implementing` | `Skill` matching `test-driven-development\|executing-plans\|subagent-driven-development\|systematic-debugging`; `Edit`/`Write` to any path NOT under `docs/superpowers/specs/` (resp. not under `docs/`) |
| `gates` | `Bash` matching `ccam … hook ci-gate` or `ccam … sync-base --check` |
| `e2e-feature` | `Bash` matching `ccam … up --qc` or `ccam … hook e2e` |
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review` |
| `qc` | `Agent` matching `qc-local`; `Bash` matching `ccam … lanes proof-link` |
| `gate` | `Agent` matching `senior-gate-reviewer`; `Skill` matching `verification-before-completion` |
| `publishing` | `Skill` matching `finishing-a-development-branch`; `Bash` matching a `git push` **invocation** (the same pattern `default.json`'s `ship` uses) |
| `pr-open` | `Bash` matching `gh … pr create` |
| `watching-pr` | `Bash` matching `gh … pr view` |
| `e2e-feature-passed`, `qc-plan`, `reported`, `merged`, `done` | none — declaration-only |
Two rules are deliberately absent from this template. There is **no `git diff`
rule on `review`**, for the reason the `default` template learned the hard way
below; the `code-review` skill invocation is the honest signal. And `merged` /
`done` carry no rule at all, because inference must never reach a terminal
state — a test pins that.
Detection here is a **safety net, not the mechanism**: the `ship-feature-lane`
skill declares every one of these stages with `ccam stage` itself. What
detection adds is the stage an agent forgot after a context compaction, the
heartbeat that keeps a working lane from reading STALLED, and coverage for a
lane running Superpowers skills without the driving skill at all. It cannot
substitute for the skill's own declarations: because `recordDetection` is
forward-only AND never overrides a higher declared stage, a fix-loop re-entry
that drops back to `gates` is invisible to detection — only the skill's
`ccam stage gates` moves the lane back down.
`intake`, `gate`, and `done` in `default.json` deliberately have no rules.
`intake` is where a lane starts — there is no tool event that means "just claimed," so there is
nothing to detect. `gate` and `done` are explicitly out of scope for
inference (see the task's "Out of scope" list): a gate's pass/fail is a human
or skill decision, and `done` is the one state detection must never reach on
@@ -1151,6 +1213,64 @@ The skill uses the `ship-feature` pipeline template, which defines the following
The Workspace page (`/run`) displays this template with nodes rendered in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped), and `pending` (not reached).
### More stage names than nodes
Each node carries aliases, and they do two different jobs. Some absorb a
near-miss (`implement` for `implementing`). Others are **sub-states**: a name
the skill declares to say *why* the lane is sitting on a node, without adding a
node to the map.
This is the shape Shipyard converged on — its dashboard renders 13 nodes while
its `PHASES` table folds roughly 35 stage names onto them. A pipeline map is
read at a glance across many lanes at once, so it stays coarse; the stage name
is read one lane at a time, so it can be specific.
| Node | Near-miss aliases | Sub-states the skill declares |
|---|---|---|
| `intake` | `assigned`, `claimed`, `start` | `bootstrapping` |
| `plan` | `planning`, `brainstorm`, `design` | — |
| `implementing` | `implement`, `coding`, `build` | — |
| `gates` | `pre-push-gate`, `tests` | `migration-collision`, `sync-conflict` |
| `e2e-feature` | `e2e`, `live` | `booting`, `e2e-scoped` |
| `e2e-feature-passed` | `e2e-passed` | — |
| `review` | `reviewing`, `code-review`, `self-review` | — |
| `qc-plan` | — | — |
| `qc` | — | — |
| `gate` | `sr-gate`, `verify`, `verification` | `gate-blocked` |
| `publishing` | `push` | — |
| `pr-open` | `ship`, `pr`, `push-conflict` | `push-revalidate` |
| `reported` | — | — |
| `watching-pr` | — | `pr-comment-fix` |
| `merged` | — | — |
| `done` | `complete`, `completed` | — |
50 names over 16 nodes. Every one of them has a **source**: the skill declares
it, or `default.json` uses it (an agent moving between pipelines will type
what the other one taught it), or Shipyard's `PHASES` lists it. An alias with
no source is not "flexibility" — it is a synonym someone imagined, and the
`ccam stage` warning already catches a name that resolves to nothing, which is
better feedback than silently absorbing every plausible spelling. Twelve
sourceless aliases were written and then cut for exactly this reason.
Two constraints on this:
- **No alias may collide** with another node's id or alias. `phaseIdx` takes the
FIRST match, so a duplicate would silently resolve a declaration onto the
wrong node. A test asserts the whole template is collision-free.
- **Aliases of one node share one record slot.** `lane.stages` is keyed by the
declared string, and `stageRecords` resolves each key onto its node — so
declaring `migration-collision` and then `gates` leaves ONE record for that
node (the canonical `gates` one wins). A sub-state that needs to keep its own
`--evidence` separately has to be a real node, not an alias.
`integrate`, `dev-gates`, `e2e-on-dev` and `push-dev` are deliberately **not**
nodes here. Shipyard had them and retired them on 2026-07-16 (see the comment
above `PHASES` in its `dashboard/src/lib/constants.js`); it now folds those
names into the surviving phases so old feature cards still render. CCAM never
shipped them, so there is nothing to fold.
These nodes also carry `detect` rules; see "Stage detection → Where the rules live".
## Orchestration: what CCAM does NOT do
**CCAM does not chain, queue, retry, or evaluate gates.**