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:
@@ -83,17 +83,18 @@ Do NOT jump to code. Understand the requirement first.
|
||||
- `ccam stage implementing`
|
||||
|
||||
### 2 — Pre-push CI gates + dev preflight (on the feature branch)
|
||||
- `ccam stage gates --status running` — declare it FIRST. This is also the fix-loop's re-entry point, and the declaration is what moves the lane BACK down the pipeline: detection alone can't (`recordDetection` is forward-only and never overrides a higher declared stage), so a re-entry that skips this line leaves the dashboard showing the stage you already left.
|
||||
- `ccam lanes hook ci-gate` — runs the profile's CI gate (lint / test / contract checks) against an isolated per-lane test DB. On failure: read the output, fix on the feature branch, commit, re-run. Loop until green.
|
||||
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
|
||||
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: declare `ccam stage migration-collision --status running` (an alias of `gates` — the map stays put, the lane's stage names WHY it is sitting there), rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
|
||||
|
||||
### 3 — E2E on the feature branch
|
||||
The e2e hook doesn't run migrations itself — it tests the already-running stack. To exercise the feature's code and any new schema, boot the lane stack with the feature branch first:
|
||||
|
||||
- `ccam stage e2e-feature --status running`
|
||||
- `ccam stage booting --status running` — an alias of `e2e-feature`; the boot below can take minutes and this says which minutes they are.
|
||||
- `ccam lanes up --qc` — boots with the profile's QC env (mock/stub flags so QC is deterministic, from `QC_BOOT_ENV`), applies the feature branch's own migrations, and reboots the stack. Idempotent; safe to re-run. (The branch was cut from `origin/development`, so this stack IS development + your feature as of the branch point.)
|
||||
- `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it.
|
||||
- `ccam stage e2e-feature --status running`, then `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it.
|
||||
- On failure: fix on the feature branch, commit, re-run from Stage 2.
|
||||
- **Iterating on a failing spec:** use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules).
|
||||
- **Iterating on a failing spec:** declare `ccam stage e2e-scoped` (alias of `e2e-feature`) so the dashboard shows this is a narrowed run, not the gate, and use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules).
|
||||
- On success: `ccam stage e2e-feature-passed --status running`
|
||||
|
||||
### 4 — Code review *(no open PR yet — use local diff)*
|
||||
@@ -119,12 +120,12 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
|
||||
- Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer` — installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions, the Stage-6 `qc-local` report (the user-flow review), and confirmation that gates/e2e/review/QC passed. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet).
|
||||
- Parse its final line:
|
||||
- `VERDICT: GO` → proceed to Stage 8.
|
||||
- `VERDICT: NO-GO — <fixes>` → fix on the feature branch, re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
|
||||
- `VERDICT: NO-GO — <fixes>` → `ccam stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then fix on the feature branch and re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
|
||||
- `ccam stage gate --evidence "GO"` (or `NO-GO — <reason>`)
|
||||
|
||||
### 8 — Publish: push branch + open/update PR *(GATED — only on GO)*
|
||||
- `ccam stage publishing --status running`
|
||||
- Re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
|
||||
- `ccam stage push-revalidate` (alias of `pr-open`) — then re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
|
||||
- `git push -u origin feat/<slug>` — first push of the feature branch to remote. All gates have passed before this point; the PR is finalized before reviewers see it.
|
||||
- Open or update the PR **based on and targeting `development`**: `gh pr create --base development --fill` (or `gh pr edit` / the push itself if a prior run already created it). Capture the URL.
|
||||
- `ccam stage pr-open --evidence "<pr-url>"` — the dashboard shows the PR link from here (via `--evidence` in `ccam feature show`/`ccam lanes`).
|
||||
@@ -151,12 +152,12 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
|
||||
- Check PR state: `gh pr view <pr_url> --json state,mergeable -q '.state + " " + (.mergeable|tostring)'`, and bump the heartbeat (`ccam stage watching-pr`).
|
||||
- `MERGED` → a human merged it: go to **Stage 13** (post-merge verification).
|
||||
- `CLOSED` (unmerged) → the human rejected/abandoned it: `ccam stage done --status passed --note "PR closed unmerged by human"` → STOP.
|
||||
- `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, resolve it as real work:
|
||||
- `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, declare `ccam stage sync-conflict --status running` (an alias of `gates`, which is also where the re-entry below lands — one declaration, honest about both) and resolve it as real work:
|
||||
- `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step.
|
||||
- **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit).
|
||||
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
|
||||
- For each new PR comment (list with `gh pr view <pr_url> --json comments`, tracking which you've already handled by comment id in your own notes), triage AND **always reply on its thread** (every comment gets a response — no silent handling, so reviewers see it was considered):
|
||||
- **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching.
|
||||
- **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — `ccam stage pr-comment-fix --status running` (alias of `watching-pr`, so the lane reads as *acting on a comment* rather than idly polling), then apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching.
|
||||
- **Question / discussion**: answer it via `gh pr comment <pr_url> --body-file <path>` (same file-not-inline rule) — no code change.
|
||||
- **Not worth fixing** (out of scope, working as intended, deferred): **reply with the reasoning** so the reviewer knows why it wasn't actioned (don't just skip it).
|
||||
- **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`.
|
||||
|
||||
@@ -1,28 +1,99 @@
|
||||
---
|
||||
name: ship-feature
|
||||
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates.
|
||||
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `ccam stage` so the dashboard's pipeline map follows along.
|
||||
---
|
||||
|
||||
# Ship Feature
|
||||
|
||||
Use this workflow for medium or large implementation tasks.
|
||||
Use this workflow for medium or large implementation tasks. It does not restate
|
||||
how to plan, test, or review — the Superpowers skills own that. What lives here
|
||||
is the phase order, this repository's own rules, and the stage declaration at
|
||||
each boundary.
|
||||
|
||||
## Steps
|
||||
- Explore impacted modules first.
|
||||
- Write a short implementation plan before editing.
|
||||
- Implement smallest coherent diff that satisfies requirements.
|
||||
- Run relevant verification commands.
|
||||
- Update docs when commands, paths, architecture, or behavior changed.
|
||||
For a feature inside a CCAM **lane**, use `ship-feature-lane` instead: it adds
|
||||
the branch/e2e/QC/senior-gate/PR half this skill deliberately leaves out.
|
||||
|
||||
## Required quality checks
|
||||
- Keep API and websocket contracts stable unless intentionally changed.
|
||||
- Keep destructive operations behind explicit guardrails.
|
||||
- Avoid broad refactors in feature tickets unless requested.
|
||||
## Declaring the stage
|
||||
|
||||
## Finish checklist
|
||||
- Tests/build/typecheck completed or explicitly reported as not run.
|
||||
- Changed file set is scoped and intentional.
|
||||
- User-facing docs updated if behavior changed.
|
||||
Each phase below starts with `ccam stage <node>`, which is what puts the phase
|
||||
on the lane's pipeline map. The nodes are the `default` template's:
|
||||
`intake → plan → implement → tests → review → gate → ship → done`.
|
||||
|
||||
`ccam stage` needs a lane owning the current directory. If it reports no lane,
|
||||
this repo was never adopted (`ccam lanes add --cwd $(pwd)` fixes it) — carry on
|
||||
with the workflow and skip the stage calls; they are reporting, not control
|
||||
flow. Never skip a phase because its stage call failed.
|
||||
|
||||
Declaring beats detection. The dashboard also *infers* these stages from the
|
||||
Superpowers skill invocations below, but an inference never renders `done` and
|
||||
never overrides a declaration — and detection cannot move a lane BACKWARD past
|
||||
a declared stage, so a rework loop is invisible unless you re-declare the phase
|
||||
you dropped back to.
|
||||
|
||||
## Phases
|
||||
|
||||
### intake — understand before touching anything
|
||||
- `ccam stage intake --status running`
|
||||
- Restate the requirement and its success criteria.
|
||||
- Explore the impacted modules: `Explore` agent for breadth, then read the key
|
||||
files yourself. Identify which layers are hit (server / client / mcp / docs /
|
||||
scripts) — `repo-onboarding` if the area is unfamiliar.
|
||||
- Requirement fuzzy or open to more than one reading? **superpowers:brainstorming**.
|
||||
|
||||
### plan — a written plan, challenged before it is code
|
||||
- `ccam stage plan`
|
||||
- **superpowers:writing-plans**: approach, files to change, the test strategy
|
||||
(which behavior each test pins), and how each success criterion is met.
|
||||
- If the task is a bug rather than a feature: **superpowers:systematic-debugging**
|
||||
first. Root cause, not symptom — and grep every caller of the function you
|
||||
are about to change, not just the path the report names.
|
||||
|
||||
### implement — smallest coherent diff
|
||||
- `ccam stage implementing`
|
||||
- **superpowers:test-driven-development**: failing test → minimal code → green.
|
||||
- Smallest diff that satisfies the requirement. Every changed line traces to it.
|
||||
- **file-headers** applies to every source file you create or edit.
|
||||
|
||||
### tests — this repo's verification, not a claim
|
||||
- `ccam stage tests --status running`
|
||||
- Backend changed → `npm run test:server`. Frontend → `npm run test:client`.
|
||||
MCP → `npm run mcp:typecheck` + `npm run mcp:build`.
|
||||
- A UI snapshot diff is reviewed, never blindly regenerated
|
||||
(`cd client && npx vitest run -u` only after you have read the diff).
|
||||
- Record the outcome: `ccam stage tests --evidence "<what passed>"`, or
|
||||
`--result fail` with what failed. A step you could not run is reported as not
|
||||
run, never as passed.
|
||||
|
||||
### review — a real review pass, not a re-read
|
||||
- `ccam stage review`
|
||||
- Run the **code-review** skill on the working diff, or
|
||||
**superpowers:requesting-code-review** when handing it to an agent.
|
||||
- Apply what is worth applying with **superpowers:receiving-code-review**
|
||||
judgment: verify each point, neither blind agreement nor blind rejection.
|
||||
- Changed code in response? Re-run the `tests` phase.
|
||||
|
||||
### gate — evidence before the completion claim
|
||||
- **superpowers:verification-before-completion**. Commands actually run, output
|
||||
actually read. This is the phase that stops "should work" from shipping.
|
||||
- `ccam stage gate --evidence "<what was verified>"`
|
||||
|
||||
### ship — docs, then the user's call
|
||||
- `ccam stage ship`
|
||||
- **update-project-docs** — mandatory for any change to behavior, config,
|
||||
interfaces, events, schema, CLI commands, or features. Not optional, not
|
||||
deferred, not "if asked".
|
||||
- Commit / push / PR **only when the user asks in this turn**. Finishing an
|
||||
implementation is not authorization to commit.
|
||||
- `ccam stage done --status passed --evidence "<what shipped>"`
|
||||
|
||||
## This repository's own rules
|
||||
|
||||
Not restated here. `CLAUDE.md` is loaded in every session and already binds
|
||||
them — backward-compatible API/WebSocket contracts, fail-safe hooks,
|
||||
migration-safe schema changes, the destructive-lane and git-argv guards, and
|
||||
the lane boundaries (the console never writes a stage; the runtime never writes
|
||||
`stage`/`status`/`notes`). A second copy here would only drift out of sync with
|
||||
the first. Read `CLAUDE.md` and `.claude/rules/` for the area you are touching.
|
||||
|
||||
## References
|
||||
- Checklist template: `references/feature-checklist.md`
|
||||
|
||||
+62
@@ -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));
|
||||
}
|
||||
|
||||
+10
@@ -159,6 +159,16 @@ PATCH /api/lanes/:id
|
||||
Partially updates a lane. This route is same-origin guarded because its patch
|
||||
can set `run_id`.
|
||||
|
||||
`pipeline` is patchable, which is how a lane moves between templates after
|
||||
creation (`ccam lanes pipeline <template-id>`). An id no template defines is
|
||||
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
|
||||
to the default template when READING, so an unvalidated write would be
|
||||
accepted and then silently render the wrong map forever. Same 400 treatment as
|
||||
`EBADKIND`. Switching a template re-resolves the lane's existing declared
|
||||
`stage` string against the new node list — a stage the old template knew may
|
||||
resolve to nothing in the new one, leaving `progress: 0` and no `current` node
|
||||
until the next `ccam stage`.
|
||||
|
||||
#### Provision a managed worktree
|
||||
|
||||
```http
|
||||
|
||||
+2
-1
@@ -227,9 +227,10 @@ A lane is a durable unit of parallel agent work — one working directory, many
|
||||
| `ccam lanes add --cwd <path> --title <text>` | Adopt an existing directory as a lane |
|
||||
| `ccam lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]` | Provision a dashboard-managed git worktree as a new lane |
|
||||
| `ccam lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.ccam/profile/`. Refuses to overwrite an existing one without `--force` |
|
||||
| `ccam lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts; pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
|
||||
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
||||
| `ccam lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
|
||||
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary |
|
||||
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it |
|
||||
| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
|
||||
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
|
||||
| `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
|
||||
|
||||
+123
-3
@@ -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.**
|
||||
|
||||
@@ -186,6 +186,31 @@ describe("ccam stage", () => {
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
|
||||
});
|
||||
|
||||
it("warns, but still records, a stage name matching no pipeline node", async () => {
|
||||
// setStage stores the string verbatim, so a typo'd stage is accepted and
|
||||
// then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how
|
||||
// a lane ends up looking unstarted for an entire pipeline run.
|
||||
const r = await cli(["stage", "revieww"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /revieww/);
|
||||
assert.match(r.stderr, /matches no node/i);
|
||||
assert.match(r.stderr, /revieww/);
|
||||
});
|
||||
|
||||
it("does not warn for a stage declared by alias", async () => {
|
||||
const r = await cli(["stage", "planning"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /matches no node/i);
|
||||
});
|
||||
|
||||
it("does not warn for a valid stage reported as failed", async () => {
|
||||
// --result fail paints the node `failed`, not `current`; the warning must
|
||||
// not read that as an unknown stage.
|
||||
const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /matches no node/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ccam lanes add", () => {
|
||||
@@ -248,6 +273,35 @@ describe("ccam lanes add", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
|
||||
// `--pipeline` was creation-only, so every lane added from the dashboard
|
||||
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
|
||||
const show = await cli(["lanes", "pipeline"], LANE_DIR);
|
||||
assert.equal(show.status, 0, show.stderr);
|
||||
assert.match(show.stdout, /default/);
|
||||
assert.match(show.stdout, /available:.*ship-feature/);
|
||||
|
||||
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
||||
assert.equal(set.status, 0, set.stderr);
|
||||
assert.match(set.stdout, /ship-feature/);
|
||||
assert.match(set.stdout, /16 nodes/);
|
||||
|
||||
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
||||
assert.equal(back.status, 0, back.stderr);
|
||||
assert.match(back.stdout, /8 nodes/);
|
||||
});
|
||||
|
||||
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
|
||||
// getPipeline() returns the default template for an unknown id, so without
|
||||
// a write-side check a typo would store, render `default`, and look fine.
|
||||
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
|
||||
|
||||
const after = await cli(["lanes", "pipeline"], LANE_DIR);
|
||||
assert.match(after.stdout, /default/);
|
||||
});
|
||||
|
||||
it("provisions a managed worktree lane and reports it ready", async () => {
|
||||
const r = await cli(
|
||||
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
|
||||
|
||||
@@ -77,6 +77,44 @@ describe("pipelines", () => {
|
||||
assert.equal(byId.plan, "passed-no-evidence");
|
||||
});
|
||||
|
||||
it("keeps the evidence of a stage declared under an alias", () => {
|
||||
// `lane.stages` is keyed by the raw declared string, so `ccam stage
|
||||
// planning --evidence x` files the record under "planning" while the node
|
||||
// is "plan". Looking it up by node id alone loses the evidence and paints
|
||||
// a `done` node amber — silently, and only for agents that use an alias.
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: { planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" } },
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "done");
|
||||
});
|
||||
|
||||
it("prefers a node-id record over an alias record for the same node", () => {
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: {
|
||||
planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: null },
|
||||
plan: { enteredAt: "2026-07-27T00:30:00Z", evidence: "docs/plan.md" },
|
||||
},
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "done");
|
||||
});
|
||||
|
||||
it("ignores a recorded stage matching no node instead of shifting the others", () => {
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
const lane = {
|
||||
stage: "review",
|
||||
stages: { "totally-unknown": { enteredAt: "x", evidence: "y" } },
|
||||
};
|
||||
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
|
||||
assert.equal(byId.plan, "passed-no-evidence");
|
||||
assert.equal(byId.review, "current");
|
||||
});
|
||||
|
||||
it("computes progress from node position, 0 for an unknown stage", () => {
|
||||
const p = getPipeline(DEFAULT_PIPELINE_ID);
|
||||
assert.equal(progressPct(p, { stage: p.nodes[0].id, stages: {} }), 0);
|
||||
|
||||
@@ -482,4 +482,138 @@ test.describe("ship-feature pipeline template", () => {
|
||||
const ids = pipeline.nodes.map((n) => n.id);
|
||||
assert.deepEqual(ids, [...new Set(ids)]);
|
||||
});
|
||||
|
||||
test.it("no alias collides with another node's id or with a second node's alias", () => {
|
||||
// phaseIdx() takes the FIRST node whose id or alias matches, so a duplicate
|
||||
// silently resolves a declaration onto the wrong node — the failure mode
|
||||
// aliases are supposed to prevent.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const seen = new Map();
|
||||
for (const n of pipeline.nodes) {
|
||||
for (const name of [n.id, ...n.aliases]) {
|
||||
const key = name.toLowerCase();
|
||||
assert.equal(
|
||||
seen.has(key),
|
||||
false,
|
||||
`"${name}" claimed by both ${seen.get(key)} and ${n.id}`
|
||||
);
|
||||
seen.set(key, n.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Same discipline as default.json's end-to-end rule sweep: a rule pinned only
|
||||
// against a fixture can ship inert, and these fire on a live lane's hooks.
|
||||
test.it("every shipped ship-feature.json rule fires end-to-end via getPipeline", () => {
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const cases = [
|
||||
["intake", "Skill", { skill: "superpowers:brainstorming" }],
|
||||
["intake", "Bash", { command: "ccam feature activate lane-stage-detect" }],
|
||||
["plan", "Skill", { skill: "superpowers:writing-plans" }],
|
||||
["plan", "Write", { file_path: "docs/superpowers/specs/lane-foo.md" }],
|
||||
["implementing", "Skill", { skill: "superpowers:test-driven-development" }],
|
||||
["implementing", "Skill", { skill: "superpowers:systematic-debugging" }],
|
||||
["implementing", "Edit", { file_path: "server/lib/app.js" }],
|
||||
["implementing", "Write", { file_path: "server/lib/app.js" }],
|
||||
["gates", "Bash", { command: "ccam lanes hook ci-gate" }],
|
||||
["gates", "Bash", { command: "ccam lanes sync-base --check feat/foo" }],
|
||||
["e2e-feature", "Bash", { command: "ccam lanes up --qc" }],
|
||||
["e2e-feature", "Bash", { command: "ccam lanes hook e2e" }],
|
||||
["review", "Skill", { skill: "code-review" }],
|
||||
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
|
||||
["qc", "Agent", { subagent_type: "qc-local" }],
|
||||
["qc", "Bash", { command: "ccam lanes proof-link" }],
|
||||
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
|
||||
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
|
||||
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
|
||||
["publishing", "Bash", { command: "git push -u origin feat/foo" }],
|
||||
["pr-open", "Bash", { command: "gh pr create --base development --fill" }],
|
||||
["watching-pr", "Bash", { command: "gh pr view https://x/pull/1 --json state" }],
|
||||
];
|
||||
for (const [nodeId, tool_name, tool_input] of cases) {
|
||||
const got = detect(pipeline, { tool_name, tool_input });
|
||||
assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("every sub-state the skill declares resolves onto its node", () => {
|
||||
// These names exist so a skill can say WHY a lane sits on a node without
|
||||
// the map growing a node per reason (Shipyard's PHASES shape: ~35 stage
|
||||
// names over 13 nodes). An unresolvable one is worse than no alias: it
|
||||
// records, renders nowhere, and only warns.
|
||||
const { reload, phaseIdx } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
const subStates = {
|
||||
bootstrapping: "intake",
|
||||
"migration-collision": "gates",
|
||||
"sync-conflict": "gates",
|
||||
booting: "e2e-feature",
|
||||
"e2e-scoped": "e2e-feature",
|
||||
live: "e2e-feature",
|
||||
"gate-blocked": "gate",
|
||||
"push-conflict": "pr-open",
|
||||
"push-revalidate": "pr-open",
|
||||
"pr-comment-fix": "watching-pr",
|
||||
};
|
||||
for (const [declared, nodeId] of Object.entries(subStates)) {
|
||||
assert.equal(
|
||||
phaseIdx(pipeline, declared),
|
||||
pipeline.nodes.findIndex((n) => n.id === nodeId),
|
||||
`${declared} must resolve to ${nodeId}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("does not stamp implementing for an edit to the lane spec", () => {
|
||||
// Stage 5 appends the QC Plan to docs/superpowers/specs/lane-<slug>.md.
|
||||
// Without the exclusion that edit reads as code work, and because
|
||||
// `implementing` sits early in the pipeline the mis-read is only invisible
|
||||
// by luck (forward-only) — on a fix-loop re-entry it would be the live stage.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const tool_name of ["Edit", "Write"]) {
|
||||
const r = detect(pipeline, {
|
||||
tool_name,
|
||||
tool_input: { file_path: "/lanes/lane1/docs/superpowers/specs/lane-foo.md" },
|
||||
});
|
||||
assert.notEqual(r && r.nodeId, "implementing", tool_name);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("never infers a terminal stage — merged and done are declaration-only", () => {
|
||||
// CLAUDE.md: an inferred node never renders `done`. Shipping a detect rule
|
||||
// for these nodes would be the one way to break that from the data side.
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const id of ["merged", "done", "e2e-feature-passed", "reported", "qc-plan"]) {
|
||||
const node = pipeline.nodes.find((n) => n.id === id);
|
||||
assert.deepEqual(node.detect, [], `${id} must carry no detect rule`);
|
||||
}
|
||||
});
|
||||
|
||||
test.it("does not read the word push, or an unrelated ccam call, as a later stage", () => {
|
||||
const { reload } = require("../lib/pipelines");
|
||||
reload();
|
||||
const pipeline = getPipeline("ship-feature");
|
||||
for (const command of [
|
||||
'git commit -m "do not push this yet"',
|
||||
"ccam lanes logs 3 e2e",
|
||||
"ccam stage gates --status running",
|
||||
"gh pr diff 42",
|
||||
]) {
|
||||
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
|
||||
assert.equal(
|
||||
["publishing", "pr-open", "watching-pr", "e2e-feature"].includes(r && r.nodeId),
|
||||
false,
|
||||
`${command} → ${r && r.nodeId}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,257 @@
|
||||
"id": "ship-feature",
|
||||
"name": "Ship feature (lane pipeline)",
|
||||
"nodes": [
|
||||
{ "id": "intake", "label": "intake", "icon": "📝", "gate": false, "aliases": [] },
|
||||
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false, "aliases": [] },
|
||||
{ "id": "implementing", "label": "implement (TDD)", "icon": "🛠", "gate": false, "aliases": [] },
|
||||
{ "id": "gates", "label": "CI gates + preflight", "icon": "🧪", "gate": true, "aliases": [] },
|
||||
{ "id": "e2e-feature", "label": "e2e on feature branch", "icon": "🧪", "gate": false, "aliases": [] },
|
||||
{ "id": "e2e-feature-passed", "label": "e2e passed", "icon": "🧪", "gate": true, "aliases": [] },
|
||||
{ "id": "review", "label": "code review", "icon": "👀", "gate": true, "aliases": [] },
|
||||
{ "id": "qc-plan", "label": "QC plan", "icon": "📋", "gate": false, "aliases": [] },
|
||||
{ "id": "qc", "label": "browser QC", "icon": "🔍", "gate": true, "aliases": [] },
|
||||
{ "id": "gate", "label": "senior GO/NO-GO gate", "icon": "🚦", "gate": true, "aliases": [] },
|
||||
{ "id": "publishing", "label": "publish PR", "icon": "🔀", "gate": false, "aliases": [] },
|
||||
{ "id": "pr-open", "label": "PR open", "icon": "🔀", "gate": false, "aliases": ["ship"] },
|
||||
{ "id": "reported", "label": "reported", "icon": "📣", "gate": false, "aliases": [] },
|
||||
{ "id": "watching-pr", "label": "watching PR", "icon": "👁", "gate": false, "aliases": [] },
|
||||
{ "id": "merged", "label": "merged — post-verify", "icon": "🔗", "gate": false, "aliases": [] },
|
||||
{ "id": "done", "label": "done", "icon": "✅", "gate": false, "aliases": ["complete", "completed"] }
|
||||
{
|
||||
"id": "intake",
|
||||
"label": "intake",
|
||||
"icon": "📝",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"assigned",
|
||||
"claimed",
|
||||
"start",
|
||||
"bootstrapping"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "brainstorming"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*\\bfeature activate\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "plan",
|
||||
"label": "plan",
|
||||
"icon": "🧭",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"planning",
|
||||
"brainstorm",
|
||||
"design"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "writing-plans"
|
||||
},
|
||||
{
|
||||
"tool": "Write",
|
||||
"match": "docs/superpowers/specs/lane-.*\\.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "implementing",
|
||||
"label": "implement (TDD)",
|
||||
"icon": "🛠",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"implement",
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "test-driven-development|executing-plans|subagent-driven-development|systematic-debugging"
|
||||
},
|
||||
{
|
||||
"tool": "Edit",
|
||||
"match": "^(?!.*docs/superpowers/specs/)"
|
||||
},
|
||||
{
|
||||
"tool": "Write",
|
||||
"match": "^(?!.*(?:^|/)docs/)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gates",
|
||||
"label": "CI gates + preflight",
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"pre-push-gate",
|
||||
"tests",
|
||||
"migration-collision",
|
||||
"sync-conflict"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*(\\bhook ci-gate\\b|\\bsync-base --check\\b)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e2e-feature",
|
||||
"label": "e2e on feature branch",
|
||||
"icon": "🧪",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"e2e",
|
||||
"e2e-scoped",
|
||||
"booting",
|
||||
"live"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*(\\bup --qc\\b|\\bhook e2e\\b)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e2e-feature-passed",
|
||||
"label": "e2e passed",
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"e2e-passed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"label": "code review",
|
||||
"icon": "👀",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"reviewing",
|
||||
"code-review",
|
||||
"self-review"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "code-review|requesting-code-review|receiving-code-review"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qc-plan",
|
||||
"label": "QC plan",
|
||||
"icon": "📋",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "qc",
|
||||
"label": "browser QC",
|
||||
"icon": "🔍",
|
||||
"gate": true,
|
||||
"aliases": [],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Agent",
|
||||
"match": "qc-local"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bccam\\b[^;&|]*\\blanes proof-link\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gate",
|
||||
"label": "senior GO/NO-GO gate",
|
||||
"icon": "🚦",
|
||||
"gate": true,
|
||||
"aliases": [
|
||||
"sr-gate",
|
||||
"verify",
|
||||
"verification",
|
||||
"gate-blocked"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Agent",
|
||||
"match": "senior-gate-reviewer"
|
||||
},
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "verification-before-completion"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "publishing",
|
||||
"label": "publish PR",
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"push"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Skill",
|
||||
"match": "finishing-a-development-branch"
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pr-open",
|
||||
"label": "PR open",
|
||||
"icon": "🔀",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"ship",
|
||||
"pr",
|
||||
"push-conflict",
|
||||
"push-revalidate"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgh\\b[^;&|]*\\bpr create\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "reported",
|
||||
"label": "reported",
|
||||
"icon": "📣",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "watching-pr",
|
||||
"label": "watching PR",
|
||||
"icon": "👁",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"pr-comment-fix"
|
||||
],
|
||||
"detect": [
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bgh\\b[^;&|]*\\bpr view\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "merged",
|
||||
"label": "merged — post-verify",
|
||||
"icon": "🔗",
|
||||
"gate": false,
|
||||
"aliases": []
|
||||
},
|
||||
{
|
||||
"id": "done",
|
||||
"label": "done",
|
||||
"icon": "✅",
|
||||
"gate": false,
|
||||
"aliases": [
|
||||
"complete",
|
||||
"completed"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+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",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -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,
|
||||
|
||||
@@ -268,9 +268,10 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
lane = lanesLib.updateLane(req.params.id, req.body || {});
|
||||
} catch (err) {
|
||||
// A bad `kind` is invalid input, not a server fault — every sibling route
|
||||
// answers 400 here, so this one must too instead of throwing into Express.
|
||||
if (err.code === "EBADKIND") {
|
||||
// A bad `kind` or `pipeline` is invalid input, not a server fault — every
|
||||
// sibling route answers 400 here, so this one must too instead of throwing
|
||||
// into Express.
|
||||
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
|
||||
Reference in New Issue
Block a user