feat(lanes): add pipeline-template picker to the Workspace lane header

Lets a lane's pipeline template be switched live from the dashboard
(the same PATCH /api/lanes/:id the CLI's `ccam lanes pipeline` uses),
so lanes created before the picker shipped don't need the terminal.
Both ship-feature skills now force their own template before their
first `ccam stage` call, so the human never has to pick correctly at
lane creation.
This commit is contained in:
2026-08-11 12:15:16 +07:00
parent f6946d72f4
commit f0ae876472
9 changed files with 91 additions and 6 deletions
@@ -13,6 +13,7 @@ You are running the autonomous feature pipeline for **one CCAM lane**. The human
LANE_DIR="$(pwd)" # the lane clone IS your cwd — CCAM resolves the lane from this, never a hardcoded path LANE_DIR="$(pwd)" # the lane clone IS your cwd — CCAM resolves the lane from this, never a hardcoded path
``` ```
- CCAM resolves your lane from `cwd` automatically (longest path-boundary prefix match) — there is no marker file to check and no separate assign step. If `ccam stage` or `ccam feature activate` ever fails with "no lane found", you are not inside a lane's working directory; stop and tell the human. - CCAM resolves your lane from `cwd` automatically (longest path-boundary prefix match) — there is no marker file to check and no separate assign step. If `ccam stage` or `ccam feature activate` ever fails with "no lane found", you are not inside a lane's working directory; stop and tell the human.
- **Pipeline template is this skill's contract, not whatever the lane started on.** Before your first `ccam stage` call, run `ccam lanes pipeline ship-feature` (no id needed — resolves from `cwd` like `ccam stage`; idempotent, a no-op if already set) so every stage below resolves against the 16-node `ship-feature` map. The human is never expected to pick this in Add Lane or anywhere else — you force it to match the skill actually running.
- All stage updates go through `ccam stage <stage> [--status <s>] [--evidence "..."]`**call it at the start of every stage** (this is also the heartbeat, visible on the dashboard). - All stage updates go through `ccam stage <stage> [--status <s>] [--evidence "..."]`**call it at the start of every stage** (this is also the heartbeat, visible on the dashboard).
- **Integration toggles: check, don't assume.** `ccam lanes integration tracker`, `ccam lanes integration dev_qc`, `ccam lanes integration ci_wait` each exit 0 (on) or 1 (off), reading the profile's `integrations.env`. No agent exists yet to actually FILE a ticket or run dev-QC even when a toggle reads on (`ticketer`/`dev-qc` are a later task) — so regardless of the check's result, Stage 9 (ticket) stays skipped, Stage 13's dev-QC and dev-CI-wait halves stay skipped, and Stage 10's CI watch keeps using the plain `gh pr checks` path (`ccam ci` doesn't exist yet). Check the toggle where noted below anyway, so the evidence you record is honest about whether the PROFILE wants the integration on, distinct from whether CCAM can act on it yet. - **Integration toggles: check, don't assume.** `ccam lanes integration tracker`, `ccam lanes integration dev_qc`, `ccam lanes integration ci_wait` each exit 0 (on) or 1 (off), reading the profile's `integrations.env`. No agent exists yet to actually FILE a ticket or run dev-QC even when a toggle reads on (`ticketer`/`dev-qc` are a later task) — so regardless of the check's result, Stage 9 (ticket) stays skipped, Stage 13's dev-QC and dev-CI-wait halves stay skipped, and Stage 10's CI watch keeps using the plain `gh pr checks` path (`ccam ci` doesn't exist yet). Check the toggle where noted below anyway, so the evidence you record is honest about whether the PROFILE wants the integration on, distinct from whether CCAM can act on it yet.
- **Heartbeat during long stages.** Implementing (Stage 1), CI waits (Stage 10), and the watch/post-merge polls (Stages 1213) can run many minutes between stage transitions — bump the heartbeat with `ccam stage <same-stage>` after each commit and on each poll iteration, so the dashboard doesn't false-flag a working lane as stalled. - **Heartbeat during long stages.** Implementing (Stage 1), CI waits (Stage 10), and the watch/post-merge polls (Stages 1213) can run many minutes between stage transitions — bump the heartbeat with `ccam stage <same-stage>` after each commit and on each poll iteration, so the dashboard doesn't false-flag a working lane as stalled.
+7
View File
@@ -19,6 +19,13 @@ 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: on the lane's pipeline map. The nodes are the `default` template's:
`intake → plan → implement → tests → review → gate → ship → done`. `intake → plan → implement → tests → review → gate → ship → done`.
Set the template yourself, don't rely on how the lane was created: before your
first `ccam stage` call, run `ccam lanes pipeline default` (no id needed —
resolves from `cwd`; a no-op if the lane is already on it). This is what makes
the choice invisible to the human — whichever of `ship-feature` or
`ship-feature-lane` actually runs is what decides the template, not a picker
they have to get right in advance.
`ccam stage` needs a lane owning the current directory. If it reports no lane, `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 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 with the workflow and skip the stage calls; they are reporting, not control
+2
View File
@@ -94,6 +94,8 @@
"locks.held_one": "{{count}} lock held", "locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held", "locks.held_other": "{{count}} locks held",
"moreActions": "More actions", "moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
"preflightError": "Could not load the current lane facts.", "preflightError": "Could not load the current lane facts.",
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}", "preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
"runtime.boot": "▶ up", "runtime.boot": "▶ up",
+2
View File
@@ -94,6 +94,8 @@
"locks.held_one": "Đang giữ {{count}} khóa", "locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa", "locks.held_other": "Đang giữ {{count}} khóa",
"moreActions": "Thêm hành động", "moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
"preflightError": "Không thể tải trạng thái làn đường hiện tại.", "preflightError": "Không thể tải trạng thái làn đường hiện tại.",
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}", "preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
"runtime.boot": "▶ chạy", "runtime.boot": "▶ chạy",
+55 -2
View File
@@ -132,6 +132,9 @@ export function Workspace() {
const [addLaneOpen, setAddLaneOpen] = useState(false); const [addLaneOpen, setAddLaneOpen] = useState(false);
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null); const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]); const [features, setFeatures] = useState<LaneFeature[]>([]);
const [pipelineTemplates, setPipelineTemplates] = useState<
{ id: string; name: string; nodes: { id: string }[] }[]
>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null); const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]); const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
@@ -202,7 +205,10 @@ export function Workspace() {
api.run api.run
.binary() .binary()
.then(setBinaryStatus) .then(setBinaryStatus)
.catch(() => setBinaryStatus({ found: false, path: null })); // Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
// `claude` is missing from PATH — leave the probe unresolved rather than
// showing a misleading "claude missing" banner for an unrelated fault.
.catch(() => undefined);
api.run api.run
.list() .list()
.then(setActiveRuns) .then(setActiveRuns)
@@ -211,6 +217,10 @@ export function Workspace() {
.history(50) .history(50)
.then((r) => setRunHistory(r.items)) .then((r) => setRunHistory(r.items))
.catch(() => undefined); .catch(() => undefined);
api.lanes
.pipelines()
.then((r) => setPipelineTemplates(r.pipelines))
.catch(() => undefined);
void refreshLanes(); void refreshLanes();
api.run api.run
.cwds() .cwds()
@@ -856,6 +866,28 @@ export function Workspace() {
} }
}; };
// Mirrors `ccam lanes pipeline <template> <id>`: same PATCH, same
// stage-mismatch warning when the current declared stage matches no node
// in the newly chosen template.
const handlePipelineChange = async (id: number, pipeline: string) => {
setLaneActionError(null);
try {
const { lane } = await api.lanes.update(id, { pipeline });
await refreshLanes();
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
setLaneActionError(
tLanes("pipelinePicker.stageMismatch", {
stage: lane.stage,
pipeline: lane.pipeline,
nodes: lane.pipeline_nodes.map((n) => n.id).join(", "),
})
);
}
} catch (err) {
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
}
};
const consoleSection = ( const consoleSection = (
<> <>
{/* Always attached under the pipeline - no header, no collapse. The {/* Always attached under the pipeline - no header, no collapse. The
@@ -1036,7 +1068,28 @@ export function Workspace() {
<span className="truncate text-sm font-semibold text-fg-primary"> <span className="truncate text-sm font-semibold text-fg-primary">
{currentLane.title || currentLane.cwd} {currentLane.title || currentLane.cwd}
</span> </span>
<span className="text-[11px] text-fg-muted">{currentLane.pipeline_name}</span> <select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
value={currentLane.pipeline}
disabled={!!viewedFeature}
title={
viewedFeature
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
: undefined
}
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
>
{(pipelineTemplates.length
? pipelineTemplates
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
).map((p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
))}
</select>
{/* `stage` defaults to the DB sentinel "idle" until the driving {/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `ccam stage` — that string collides with session ever calls `ccam stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is `status`'s own "idle"/"running" vocabulary, so a lane that is
@@ -41,6 +41,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
send: r({ messageId: "m-1" }), send: r({ messageId: "m-1" }),
kill: r({ ok: true }), kill: r({ ok: true }),
}, },
lanes: {
list: r({ lanes: [], counts: { total: 0, running: 0, needs_you: 0, dead: 0 } }),
pipelines: r({ pipelines: [] }),
},
ccConfig: { ccConfig: {
commands: r({ items: [] }), commands: r({ items: [] }),
plugins: r({ plugins: [] }), plugins: r({ plugins: [] }),
@@ -105,6 +105,15 @@ vi.mock("../../lib/api", async (importOriginal) => {
recordCall("POST", `/api/lanes/stage`); recordCall("POST", `/api/lanes/stage`);
return { ok: true }; return { ok: true };
}), }),
pipelines: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes/pipelines");
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
}),
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
recordCall("PATCH", `/api/lanes/${id}`);
const lane = lanesToReturn.find((l) => l.id === id);
return { lane: { ...lane, ...patch } };
}),
features: { features: {
list: vi.fn().mockImplementation(async (id: number) => { list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/features`); recordCall("GET", `/api/lanes/${id}/features`);
@@ -387,6 +387,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
create: r({ lane: {} }), create: r({ lane: {} }),
update: r({ lane: {} }), update: r({ lane: {} }),
stage: r({ lane: {} }), stage: r({ lane: {} }),
pipelines: r({ pipelines: [] }),
// A realistic shape: `{}` would crash blockingReason on `blocked` if any // A realistic shape: `{}` would crash blockingReason on `blocked` if any
// screen ever opened the destructive modal. // screen ever opened the destructive modal.
preflight: r({ preflight: r({
+10 -4
View File
@@ -420,16 +420,20 @@ 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 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.** The "+ Add lane" modal has a *Pipeline template* select listing every template the server reports, so this is chosen at creation; `ccam lanes add --pipeline <id>` is the same choice from the terminal. Creation is the point that matters — a lane born on `default` renders 8 nodes for a 16-node workflow. **A lane is created on `default` unless told otherwise.** The "+ Add lane" modal has a *Pipeline template* select listing every template the server reports, so this is chosen at creation; `ccam lanes add --pipeline <id>` is the same choice from the terminal.
For a lane that already exists (including every lane created before the picker shipped): For a lane that already exists, switch it from the terminal:
```bash ```bash
ccam lanes pipeline # which template this lane uses, and what else exists ccam lanes pipeline # which template this lane uses, and what else exists
ccam lanes pipeline ship-feature # switch it 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. or from the dashboard: the pipeline-template select next to the lane's title in the Workspace detail panel does the same `PATCH /api/lanes/:id` the CLI uses, live (disabled while viewing an archived feature snapshot).
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 switch warns when that happens (both CLI and UI), and the next `ccam stage` fixes it.
**In practice you never have to pick correctly at creation.** The template only has to match whichever skill is actually driving the lane, and the skill enforces that itself: `ship-feature-lane` runs `ccam lanes pipeline ship-feature` and `ship-feature` runs `ccam lanes pipeline default` before their first `ccam stage` call — a no-op if the lane is already on that template, a self-correction if it isn't. A human chatting with the lane never needs to open the picker; whichever skill gets invoked decides the template.
The dashboard renders every node in the pipeline in one of five **states**: The dashboard renders every node in the pipeline in one of five **states**:
@@ -452,7 +456,9 @@ In both cases, the stage is not **done** — it's incomplete or untested. Amber
## Reporting a stage ## Reporting a stage
When a skill reaches a significant milestone, it reports the current stage to the dashboard using `ccam stage`: `ccam stage` is not skill-only. Any Claude session working inside an adopted lane's cwd — including a plain chat session where no skill was ever invoked — should call it on real stage transitions. Tool-event detection (see "Inferred vs. declared" below) can only paint the amber "detected" badge; it never moves the blue `current` ring. A lane worked entirely through plain chat that never calls `ccam stage` will keep showing whatever stage it was last declared at (often `intake`), regardless of how much real work happens.
When a skill — or a plain chat session — reaches a significant milestone, it reports the current stage to the dashboard using `ccam stage`:
```bash ```bash
ccam stage implement --evidence "Schema created, migrations run" ccam stage implement --evidence "Schema created, migrations run"