Files
Claude-Code-Monitor/docs/superpowers/plans/2026-07-27-lanes-pipeline.md
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

1883 lines
74 KiB
Markdown

# Lanes + Pipeline Phase View Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give CCAM a durable "lane" entity (one per working directory / agent) plus a Shipyard-style pipeline map that shows which stage and which gates each Claude Code agent has finished, with per-lane liveness and start/stop/resume control.
**Architecture:** Approach A — CCAM stays a monitor + control panel; it does NOT orchestrate. The Claude session itself drives its pipeline and *declares* its stage through `ccam stage <name>` (a new CLI subcommand hitting `POST /api/lanes/:id/stage`), exactly the way Shipyard's skills call `bin/state.sh N set stage=…`. A lane is bound to a session automatically by longest-prefix match on the hook payload's `cwd`, so un-instrumented sessions still appear (stage inferred from `workflows.phases` / TodoWrite, never invented). Pipeline shape is a JSON template on disk, not hardcoded coordinates.
**Tech Stack:** Node 18+, Express, better-sqlite3, `node:test` (server), React 18 + TypeScript + Vite + Tailwind, Vitest + Testing Library (client), existing `server/websocket.js` broadcast.
## Global Constraints
- Fork lives at `~/MyDrive/Projects/ResearchAndDevelopment/ccam-lanes`, remote `upstream` = `https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor`, forked at `94bfdde` (2026-07-26). Keep the fork private; never push to `upstream`.
- Every `.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css` file created or modified MUST start with a file overview block plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. This is enforced by `bash .claude/skills/file-headers/scripts/check-headers.sh` whose `AUTHOR_MARK` was rewritten to this name on 2026-07-27 (fork is internal, never published). Do not substitute a different author line. - Preserve existing behavior. Additive schema only: new tables and `ALTER TABLE … ADD COLUMN` guarded by a `try { SELECT col } catch { ALTER }` probe, matching `server/db.js:412-418`.
- WebSocket message types are append-only. Add `lane_update`; do not rename or repurpose existing types.
- The hook ingest path must stay fail-safe and non-blocking: any lane logic added to `server/routes/hooks.js` runs inside `try { … } catch { /* never block a hook */ }`.
- Destructive lane actions (`reset`, `remove`) require an explicit confirmation flag in the request body; they never run implicitly.
- Route handlers that spawn processes reuse the loopback/same-origin guard. Do not weaken it.
- Server tests: `npm run test:server`. Client tests: `npm run test:client`. Per-screen snapshots live in `client/src/pages/__tests__/screens.snapshot.test.tsx`; regenerate intentionally with `cd client && npx vitest run -u`, never blindly.
- User-visible strings go through i18n (`client/src/i18n`), not string literals in components.
---
## File Structure
**Create**
- `server/lib/pipelines.js` — pipeline templates + stage→node mapping. Pure functions, no DB.
- `server/lib/lanes.js` — lane CRUD, stage transitions, cwd resolution, liveness. Owns all SQL for `lanes`.
- `server/routes/lanes.js` — HTTP surface over `server/lib/lanes.js` + the action layer.
- `server/data/pipelines/default.json` — the built-in pipeline template.
- `server/__tests__/lanes-lib.test.js` — unit tests for `lib/pipelines.js` + `lib/lanes.js`.
- `server/__tests__/lanes-api.test.js` — HTTP tests for `/api/lanes`, hook binding, actions.
- `client/src/components/lanes/PipelineMap.tsx` — the stage graph.
- `client/src/components/lanes/LaneCard.tsx` — one lane's card.
- `client/src/pages/Lanes.tsx` — page: header counters + map + card grid.
- `client/src/components/lanes/__tests__/PipelineMap.test.tsx` — node-state rendering test.
**Modify**
- `server/db.js``lanes` table + migration probe only. Lane SQL is prepared inside `server/lib/lanes.js` (that module "owns all SQL for `lanes`"), NOT added to the shared `stmts` dictionary.
- `server/index.js` — mount `app.use("/api/lanes", lanesRouter)`.
- `server/routes/hooks.js` — bind session→lane by `cwd`; set `needs_action` on `Notification`.
- `server/routes/run.js` — export the same-origin guard for reuse (no behavior change).
- `bin/ccam.js``ccam stage` and `ccam lanes` subcommands.
- `client/src/lib/api.ts``api.lanes.*` + `Lane` / `PipelineNode` / `LaneNodeState` types.
- `client/src/App.tsx``/lanes` route.
- `client/src/components/Sidebar.tsx` — nav entry.
- `client/src/i18n/*.json` — lane strings.
---
## Task 1: Pipeline template + node-state derivation
Pure logic first — no DB, no HTTP. Everything downstream reads node state from here.
**Files:**
- Create: `server/data/pipelines/default.json`
- Create: `server/lib/pipelines.js`
- Test: `server/__tests__/lanes-lib.test.js`
**Interfaces:**
- Consumes: nothing.
- Produces:
- `DEFAULT_PIPELINE_ID = "default"`
- `listPipelines(): Array<{id, name, nodes}>`
- `getPipeline(id): {id, name, nodes: Array<{id,label,icon,aliases:string[],gate:boolean}>}` — falls back to the default template for an unknown id, never throws.
- `phaseIdx(pipeline, stage): number` — index of the node whose `id` or `aliases` contains `stage`; `-1` if unknown.
- `nodeStates(pipeline, lane): Array<{id,label,icon,gate,state}>` where `state ∈ "done" | "current" | "passed-no-evidence" | "failed" | "pending"`.
- `progressPct(pipeline, lane): number` — 0-100 integer.
- [ ] **Step 1: Write the failing test**
Create `server/__tests__/lanes-lib.test.js`:
```js
/**
* @file Unit tests for the lane pipeline template helpers (server/lib/pipelines.js):
* stage→node resolution through aliases, the five node states rendered by the
* pipeline map, and progress percentage.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_PIPELINE_ID,
getPipeline,
listPipelines,
phaseIdx,
nodeStates,
progressPct,
} = require("../lib/pipelines");
describe("pipelines", () => {
it("exposes a default template and falls back to it for unknown ids", () => {
const def = getPipeline(DEFAULT_PIPELINE_ID);
assert.ok(def.nodes.length > 3);
assert.equal(getPipeline("does-not-exist").id, DEFAULT_PIPELINE_ID);
assert.ok(listPipelines().some((p) => p.id === DEFAULT_PIPELINE_ID));
});
it("resolves a stage through node id and through aliases", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
assert.equal(phaseIdx(p, "plan"), p.nodes.findIndex((n) => n.id === "plan"));
assert.equal(phaseIdx(p, "planning"), phaseIdx(p, "plan"));
assert.equal(phaseIdx(p, "totally-unknown-stage"), -1);
});
it("marks the current stage current, recorded-with-evidence done, recorded-without amber", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: {
plan: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" },
implement: { enteredAt: "2026-07-27T01:00:00Z", evidence: null },
review: { enteredAt: "2026-07-27T02:00:00Z", evidence: null },
},
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
assert.equal(byId.implement, "passed-no-evidence");
assert.equal(byId.review, "current");
assert.equal(byId.done, "pending");
});
it("marks a failed stage failed even when it is the current stage", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = { stage: "gate", stages: { gate: { enteredAt: "x", result: "fail" } } };
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.gate, "failed");
});
it("treats skipped earlier nodes as passed-without-evidence, not done", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = { stage: "review", stages: { review: { enteredAt: "x" } } };
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "passed-no-evidence");
});
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);
assert.equal(progressPct(p, { stage: p.nodes[p.nodes.length - 1].id, stages: {} }), 100);
assert.equal(progressPct(p, { stage: "nope", stages: {} }), 0);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/lanes-lib.test.js`
Expected: FAIL — `Cannot find module '../lib/pipelines'`.
- [ ] **Step 3: Write the template**
Create `server/data/pipelines/default.json`:
```json
{
"id": "default",
"name": "Default feature pipeline",
"nodes": [
{ "id": "intake", "label": "intake", "icon": "📝", "gate": false, "aliases": ["assigned", "claimed", "start"] },
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false, "aliases": ["planning", "brainstorm", "design"] },
{ "id": "implement", "label": "implement", "icon": "🛠", "gate": false, "aliases": ["implementing", "coding", "build"] },
{ "id": "tests", "label": "tests", "icon": "🧪", "gate": true, "aliases": ["testing", "unit", "gates", "pre-push-gate"] },
{ "id": "review", "label": "review", "icon": "👀", "gate": true, "aliases": ["reviewing", "code-review", "self-review"] },
{ "id": "gate", "label": "gate", "icon": "🚦", "gate": true, "aliases": ["verify", "verification", "sr-gate", "gate-blocked"] },
{ "id": "ship", "label": "ship", "icon": "🔀", "gate": false, "aliases": ["pr", "pr-open", "publishing", "commit", "push"] },
{ "id": "done", "label": "done", "icon": "✅", "gate": false, "aliases": ["complete", "completed", "merged"] }
]
}
```
- [ ] **Step 4: Write the module**
Create `server/lib/pipelines.js`:
```js
/**
* @file Pipeline templates for lanes. A template is a plain JSON list of nodes
* (`server/data/pipelines/*.json` plus any override dropped in
* `DASHBOARD_PIPELINES_DIR`); this module resolves a lane's declared stage onto
* a node through per-node `aliases`, and derives the five render states the
* pipeline map draws. Pure functions — no DB, no I/O beyond the one-time
* template load, so it stays trivially testable.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const DEFAULT_PIPELINE_ID = "default";
const BUILTIN_DIR = path.join(__dirname, "..", "data", "pipelines");
/** Load every template once. A malformed file is skipped, never fatal. */
function loadAll() {
const dirs = [BUILTIN_DIR];
if (process.env.DASHBOARD_PIPELINES_DIR) dirs.push(process.env.DASHBOARD_PIPELINES_DIR);
const out = new Map();
for (const dir of dirs) {
let files = [];
try {
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
} catch {
continue; // dir absent — fine
}
for (const f of files) {
try {
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue;
doc.nodes = doc.nodes.map((n) => ({
id: n.id,
label: n.label || n.id,
icon: n.icon || "",
gate: !!n.gate,
aliases: Array.isArray(n.aliases) ? n.aliases : [],
}));
out.set(doc.id, doc); // later dir wins — user override beats builtin
} catch {
/* skip malformed template */
}
}
}
return out;
}
let cache = null;
function templates() {
if (!cache) cache = loadAll();
return cache;
}
/** Test/dev helper: forget the cached templates so a new file is picked up. */
function reload() {
cache = null;
}
function listPipelines() {
return [...templates().values()];
}
/** Never throws: an unknown id yields the default template. */
function getPipeline(id) {
const t = templates();
return t.get(id) || t.get(DEFAULT_PIPELINE_ID);
}
/** Index of the node matching `stage` by id or alias; -1 when unknown. */
function phaseIdx(pipeline, stage) {
if (!stage) return -1;
const s = String(stage).toLowerCase();
return pipeline.nodes.findIndex((n) => n.id.toLowerCase() === s || n.aliases.some((a) => a.toLowerCase() === s));
}
/**
* Render state per node:
* failed — the stage recorded result "fail"
* current — the lane's current stage
* done — recorded AND carries evidence (an artifact, not a claim)
* passed-no-evidence — recorded without evidence, or implicitly skipped past
* pending — not reached
*/
function nodeStates(pipeline, lane) {
const stages = lane.stages || {};
const cur = phaseIdx(pipeline, lane.stage);
return pipeline.nodes.map((n, i) => {
const rec = stages[n.id];
let state;
if (rec && rec.result === "fail") state = "failed";
else if (i === cur) state = "current";
else if (rec) state = rec.evidence ? "done" : "passed-no-evidence";
else if (cur > -1 && i < cur) state = "passed-no-evidence";
else state = "pending";
return { id: n.id, label: n.label, icon: n.icon, gate: n.gate, state };
});
}
function progressPct(pipeline, lane) {
const i = phaseIdx(pipeline, lane.stage);
if (i < 0) return 0;
return Math.round((i / (pipeline.nodes.length - 1)) * 100);
}
module.exports = {
DEFAULT_PIPELINE_ID,
listPipelines,
getPipeline,
phaseIdx,
nodeStates,
progressPct,
reload,
};
```
- [ ] **Step 5: Run test to verify it passes**
Run: `node --test server/__tests__/lanes-lib.test.js`
Expected: PASS, 6 tests.
- [ ] **Step 6: Header audit + commit**
Run: `bash .claude/skills/file-headers/scripts/check-headers.sh`
Expected: no failures for the new files.
```bash
git add server/lib/pipelines.js server/data/pipelines/default.json server/__tests__/lanes-lib.test.js
git commit -m "feat(lanes): pipeline templates and node-state derivation"
```
---
## Task 2: `lanes` table + lane library
**Files:**
- Modify: `server/db.js` (append to the schema block that ends near `server/db.js:400`, and to `stmts` at `server/db.js:953`)
- Create: `server/lib/lanes.js`
- Test: `server/__tests__/lanes-lib.test.js` (append a second `describe`)
**Interfaces:**
- Consumes: `getPipeline`, `nodeStates`, `progressPct` from Task 1; `{ db }` from `server/db.js`.
- Produces:
- `createLane({title, cwd, branch, pipeline}): Lane`
- `listLanes(): Lane[]`, `getLane(id): Lane | null`
- `updateLane(id, patch): Lane` — patch keys limited to `title, branch, pipeline, status, gate_decision, ci_status, needs_action, links, notes, session_id, run_id`
- `deleteLane(id): boolean`
- `setStage(id, {stage, status, evidence, note, result}): Lane` — bumps `stage_since` only when `stage` actually changes; writes the `stages` record `{enteredAt, evidence, result}`
- `resolveLaneByCwd(cwd): Lane | null` — longest matching `cwd` prefix on a path boundary
- `clearLane(id): Lane` — resets stage/status/gate/ci/needs_action/stages, keeps title/cwd/branch
- `classifyLiveness({status, stage, ageSec}, deadSec): "active" | "idle" | "dead"`
- `lanePayload(lane, ageSec): object` — the row plus `pipeline_nodes`, `progress`, `liveness`, `stage_seconds`
- `DEAD_SEC``Number(process.env.LANE_DEAD_SEC || 300)`
- [ ] **Step 1: Write the failing test (append to `server/__tests__/lanes-lib.test.js`)**
Add at the top of the file, before the existing `require("../lib/pipelines")`:
```js
const os = require("node:os");
const pathMod = require("node:path");
process.env.DASHBOARD_DB_PATH = pathMod.join(
os.tmpdir(),
`dashboard-lanes-lib-${Date.now()}-${process.pid}.db`,
);
```
Append at the end of the file:
```js
const lanes = require("../lib/lanes");
describe("lanes lib", () => {
it("creates, lists, updates and deletes a lane", () => {
const l = lanes.createLane({ title: "Feature A", cwd: "/tmp/wt/a", branch: "feat/a" });
assert.equal(l.title, "Feature A");
assert.equal(l.stage, "idle");
assert.equal(lanes.getLane(l.id).cwd, "/tmp/wt/a");
assert.ok(lanes.listLanes().length >= 1);
assert.equal(lanes.updateLane(l.id, { ci_status: "green" }).ci_status, "green");
assert.equal(lanes.deleteLane(l.id), true);
assert.equal(lanes.getLane(l.id), null);
});
it("bumps stage_since only when the stage actually changes", async () => {
const l = lanes.createLane({ cwd: "/tmp/wt/b" });
const a = lanes.setStage(l.id, { stage: "plan" });
await new Promise((r) => setTimeout(r, 1100));
const b = lanes.setStage(l.id, { stage: "plan", note: "still planning" });
assert.equal(a.stage_since, b.stage_since);
const c = lanes.setStage(l.id, { stage: "implement" });
assert.notEqual(c.stage_since, b.stage_since);
lanes.deleteLane(l.id);
});
it("records evidence per stage so the map can tell done from amber", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/c" });
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
const after = lanes.setStage(l.id, { stage: "implement" });
assert.equal(after.stages.plan.evidence, "docs/plan.md");
assert.ok(after.stages.plan.enteredAt);
lanes.deleteLane(l.id);
});
it("resolves a lane from a session cwd by longest path-boundary prefix", () => {
const outer = lanes.createLane({ cwd: "/tmp/wt" });
const inner = lanes.createLane({ cwd: "/tmp/wt/inner" });
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/inner/src").id, inner.id);
assert.equal(lanes.resolveLaneByCwd("/tmp/wt/other").id, outer.id);
assert.equal(lanes.resolveLaneByCwd("/tmp/wt-sibling"), null);
assert.equal(lanes.resolveLaneByCwd(null), null);
lanes.deleteLane(inner.id);
lanes.deleteLane(outer.id);
});
it("classifies liveness: silent watcher is dead, silent idle lane is not", () => {
const d = 300;
assert.equal(lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 10 }, d), "active");
assert.equal(lanes.classifyLiveness({ status: "running", stage: "implement", ageSec: 999 }, d), "dead");
assert.equal(lanes.classifyLiveness({ status: "idle", stage: "watching-pr", ageSec: 999 }, d), "dead");
assert.equal(lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: 99999 }, d), "idle");
assert.equal(lanes.classifyLiveness({ status: "idle", stage: "done", ageSec: null }, d), "idle");
});
it("payload carries node states and progress", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/d" });
lanes.setStage(l.id, { stage: "review" });
const p = lanes.lanePayload(lanes.getLane(l.id), 5);
assert.equal(p.pipeline_nodes.find((n) => n.id === "review").state, "current");
assert.ok(p.progress > 0 && p.progress < 100);
assert.equal(p.liveness, "idle");
lanes.deleteLane(l.id);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/lanes-lib.test.js`
Expected: FAIL — `Cannot find module '../lib/lanes'`.
- [ ] **Step 3: Add the schema**
In `server/db.js`, inside the same `db.exec(\`…\`)` block that declares `workflows` (ends just before `CREATE INDEX IF NOT EXISTS idx_workflows_session`), append:
```sql
-- A lane is a durable unit of parallel work: one working directory driven by
-- (over time) many Claude Code sessions. Sessions are ephemeral and keyed by
-- session_id; a lane survives restarts, which is what makes a pipeline view
-- possible. `stages` is a JSON map stageId -> {enteredAt, evidence, result}:
-- one column instead of a history table, because the map only ever reads the
-- latest record per stage.
CREATE TABLE IF NOT EXISTS lanes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL UNIQUE,
branch TEXT,
pipeline TEXT NOT NULL DEFAULT 'default',
session_id TEXT,
run_id TEXT,
stage TEXT NOT NULL DEFAULT 'idle',
stage_since TEXT,
status TEXT NOT NULL DEFAULT 'idle',
gate_decision TEXT,
ci_status TEXT,
needs_action TEXT,
links TEXT NOT NULL DEFAULT '{}',
stages TEXT NOT NULL DEFAULT '{}',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_lanes_session ON lanes(session_id);
```
- [ ] **Step 4: Write the lane library**
Create `server/lib/lanes.js`:
```js
/**
* @file Lane storage and lifecycle. A lane is a durable unit of parallel agent
* work — one working directory, many sessions over time — so the dashboard can
* show a pipeline that survives session restarts. This module owns every SQL
* statement touching the `lanes` table, resolves an incoming hook's `cwd` onto a
* lane, records stage transitions (with `stage_since` semantics), and classifies
* liveness the way Shipyard does: a silent watcher is dead, a silent idle lane
* is merely at rest.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { db } = require("../db");
const { getPipeline, nodeStates, progressPct } = require("./pipelines");
const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300);
/** Stages whose whole job is to wait — silence here means the loop died. */
const WATCH_STAGE_RE = /watch|poll/i;
const PATCHABLE = new Set([
"title", "branch", "pipeline", "status", "gate_decision",
"ci_status", "needs_action", "links", "notes", "session_id", "run_id",
]);
const nowIso = () => new Date().toISOString();
function hydrate(row) {
if (!row) return null;
let stages = {};
let links = {};
try { stages = JSON.parse(row.stages || "{}"); } catch { /* corrupt blob -> empty */ }
try { links = JSON.parse(row.links || "{}"); } catch { /* corrupt blob -> empty */ }
return { ...row, stages, links };
}
function createLane({ title = "", cwd, branch = null, pipeline = "default" } = {}) {
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
}
const info = db
.prepare("INSERT INTO lanes (title, cwd, branch, pipeline, stage_since) VALUES (?, ?, ?, ?, ?)")
.run(title, cwd.replace(/\/+$/, ""), branch, pipeline, nowIso());
return getLane(info.lastInsertRowid);
}
function listLanes() {
return db.prepare("SELECT * FROM lanes ORDER BY id ASC").all().map(hydrate);
}
function getLane(id) {
return hydrate(db.prepare("SELECT * FROM lanes WHERE id = ?").get(id));
}
function updateLane(id, patch = {}) {
const cols = [];
const vals = [];
for (const [k, v] of Object.entries(patch)) {
if (!PATCHABLE.has(k)) continue;
cols.push(`${k} = ?`);
vals.push(k === "links" && typeof v === "object" ? JSON.stringify(v) : v);
}
if (cols.length) {
cols.push("updated_at = ?");
vals.push(nowIso(), id);
db.prepare(`UPDATE lanes SET ${cols.join(", ")} WHERE id = ?`).run(...vals);
}
return getLane(id);
}
function deleteLane(id) {
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
}
/**
* Record a stage transition. `stage_since` moves ONLY when the stage value
* actually changes, so the UI's time-on-phase is real; a re-report of the same
* stage (a heartbeat, an added note) leaves it alone.
*/
function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage;
const stages = { ...lane.stages };
const prev = stages[next] || {};
stages[next] = {
enteredAt: next === lane.stage && prev.enteredAt ? prev.enteredAt : nowIso(),
evidence: evidence !== undefined ? evidence : prev.evidence || null,
result: result !== undefined ? result : prev.result || null,
};
db.prepare(
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?
WHERE id = ?`,
).run(
next,
next === lane.stage ? lane.stage_since || nowIso() : nowIso(),
status || lane.status,
JSON.stringify(stages),
note !== undefined ? note : lane.notes,
nowIso(),
id,
);
return getLane(id);
}
function clearLane(id) {
db.prepare(
`UPDATE lanes SET stage = 'idle', stage_since = ?, status = 'idle', gate_decision = NULL,
ci_status = NULL, needs_action = NULL, stages = '{}', notes = NULL, run_id = NULL,
updated_at = ? WHERE id = ?`,
).run(nowIso(), nowIso(), id);
return getLane(id);
}
/**
* Longest path-boundary prefix match. `/tmp/wt` must NOT capture
* `/tmp/wt-sibling`, and a nested lane must beat its parent.
*/
function resolveLaneByCwd(cwd) {
if (!cwd || typeof cwd !== "string") return null;
const target = cwd.replace(/\/+$/, "");
let best = null;
for (const lane of listLanes()) {
const base = lane.cwd.replace(/\/+$/, "");
if (target === base || target.startsWith(`${base}/`)) {
if (!best || base.length > best.cwd.length) best = lane;
}
}
return best;
}
function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
const expectLive = status === "running" || status === "provisioning" || WATCH_STAGE_RE.test(stage || "");
if (!expectLive) return "idle";
if (ageSec !== null && ageSec !== undefined && ageSec > deadSec) return "dead";
return "active";
}
function lanePayload(lane, ageSec = null) {
const pipeline = getPipeline(lane.pipeline);
const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN;
return {
...lane,
pipeline_name: pipeline.name,
pipeline_nodes: nodeStates(pipeline, lane),
progress: progressPct(pipeline, lane),
stage_seconds: Number.isNaN(since) ? null : Math.max(0, Math.round((Date.now() - since) / 1000)),
last_event_seconds: ageSec,
liveness: classifyLiveness({ status: lane.status, stage: lane.stage, ageSec }, DEAD_SEC),
};
}
module.exports = {
DEAD_SEC,
createLane,
listLanes,
getLane,
updateLane,
deleteLane,
setStage,
clearLane,
resolveLaneByCwd,
classifyLiveness,
lanePayload,
};
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `node --test server/__tests__/lanes-lib.test.js`
Expected: PASS, 12 tests.
- [ ] **Step 6: Commit**
```bash
git add server/db.js server/lib/lanes.js server/__tests__/lanes-lib.test.js
git commit -m "feat(lanes): lanes table and lane lifecycle library"
```
---
## Task 3: REST surface + WebSocket broadcast
**Files:**
- Create: `server/routes/lanes.js`
- Modify: `server/index.js` (mount next to `app.use("/api/run", runRouter)` at `server/index.js:101`)
- Test: `server/__tests__/lanes-api.test.js`
**Interfaces:**
- Consumes: everything exported by `server/lib/lanes.js`; `broadcast` from `server/websocket.js`; `listPipelines` from `server/lib/pipelines.js`.
- Produces:
- `GET /api/lanes` → `{ lanes: LanePayload[], counts: {total, running, needs_you, dead} }`
- `GET /api/lanes/pipelines` → `{ pipelines: [{id, name, nodes}] }`
- `GET /api/lanes/:id` → `{ lane: LanePayload }`
- `POST /api/lanes` `{title, cwd, branch?, pipeline?}` → 201 `{ lane }`
- `PATCH /api/lanes/:id` → `{ lane }`
- `POST /api/lanes/:id/stage` `{stage, status?, evidence?, note?, result?}` → `{ lane }`
- `DELETE /api/lanes/:id` → `{ ok: true }`
- WS `lane_update` on every mutation. Payload is `{ lane: LanePayload }` for create/update/stage, and `{ removed: <id> }` for delete — a deleted lane has no payload to send, and `Lanes.tsx` (Task 7) branches on `removed` to refetch the counters. The asymmetry is deliberate.
- Exported helper `broadcastLane(id)` for reuse by `routes/hooks.js` (Task 4).
- [ ] **Step 1: Write the failing test**
Create `server/__tests__/lanes-api.test.js`:
```js
/**
* @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
* counters the header badges read, and the last-event age that drives liveness.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function request(method, urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {},
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try { parsed = JSON.parse(data); } catch { /* non-JSON body */ }
resolve({ status: res.statusCode, body: parsed });
});
},
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => server && server.close());
describe("/api/lanes", () => {
let laneId;
it("creates a lane", async () => {
const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.title, "Lane A");
assert.equal(r.body.lane.stage, "idle");
laneId = r.body.lane.id;
});
it("rejects a relative cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
assert.equal(r.status, 400);
});
it("rejects a duplicate cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 409);
});
it("reports a stage and returns node states", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
stage: "review",
status: "running",
evidence: null,
});
assert.equal(r.status, 200);
const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "current");
assert.ok(r.body.lane.progress > 0);
});
it("404s on an unknown lane", async () => {
const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
assert.equal(r.status, 404);
});
it("lists lanes with counters", async () => {
const r = await request("GET", "/api/lanes");
assert.equal(r.status, 200);
assert.ok(r.body.lanes.length >= 1);
assert.equal(r.body.counts.total, r.body.lanes.length);
assert.equal(typeof r.body.counts.running, "number");
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("exposes pipeline templates", async () => {
const r = await request("GET", "/api/lanes/pipelines");
assert.equal(r.status, 200);
assert.ok(r.body.pipelines.some((p) => p.id === "default"));
});
it("patches and deletes", async () => {
const p = await request("PATCH", `/api/lanes/${laneId}`, { ci_status: "green" });
assert.equal(p.body.lane.ci_status, "green");
const d = await request("DELETE", `/api/lanes/${laneId}`);
assert.equal(d.status, 200);
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/lanes-api.test.js`
Expected: FAIL — the create call 404s because no router is mounted.
- [ ] **Step 3: Write the router**
Create `server/routes/lanes.js`:
```js
/**
* @file Express router for lanes — the durable per-working-directory unit of
* parallel agent work. Read endpoints join each lane with its most recent event
* timestamp so liveness can be computed without a separate heartbeat, and every
* mutation re-broadcasts the lane over the existing WebSocket as `lane_update`.
* Orchestration is deliberately absent: the driving Claude session declares its
* own stage (`POST /:id/stage`); the dashboard never guesses a transition.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { listPipelines } = require("../lib/pipelines");
const { broadcast } = require("../websocket");
const router = Router();
/** Seconds since this lane's session last emitted an event; null if never. */
function lastEventAge(lane) {
if (!lane.session_id) return null;
const row = db
.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?")
.get(lane.session_id);
if (!row || !row.last) return null;
const t = Date.parse(row.last);
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
}
function payload(lane) {
return lanesLib.lanePayload(lane, lastEventAge(lane));
}
/** Push the current state of one lane to every connected client. */
function broadcastLane(id) {
const lane = lanesLib.getLane(id);
if (lane) broadcast("lane_update", { lane: payload(lane) });
}
router.get("/", (_req, res) => {
const lanes = lanesLib.listLanes().map(payload);
res.json({
lanes,
counts: {
total: lanes.length,
running: lanes.filter((l) => l.status === "running").length,
needs_you: lanes.filter((l) => l.needs_action).length,
dead: lanes.filter((l) => l.liveness === "dead").length,
},
});
});
// Registered before "/:id" so the literal path is not swallowed by the param.
router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() }));
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
res.json({ lane: payload(lane) });
});
router.post("/", (req, res) => {
try {
const lane = lanesLib.createLane(req.body || {});
broadcastLane(lane.id);
res.status(201).json({ lane: payload(lane) });
} catch (err) {
if (err.code === "EBADCWD") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (String(err.message).includes("UNIQUE")) {
return res
.status(409)
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
}
res.status(500).json({ error: { message: err.message } });
}
});
router.patch("/:id", (req, res) => {
if (!lanesLib.getLane(req.params.id)) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
}
const lane = lanesLib.updateLane(req.params.id, req.body || {});
broadcastLane(lane.id);
res.json({ lane: payload(lane) });
});
router.post("/:id/stage", (req, res) => {
if (!lanesLib.getLane(req.params.id)) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
}
const lane = lanesLib.setStage(req.params.id, req.body || {});
broadcastLane(lane.id);
res.json({ lane: payload(lane) });
});
router.delete("/:id", (req, res) => {
const ok = lanesLib.deleteLane(req.params.id);
if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
broadcast("lane_update", { removed: Number(req.params.id) });
res.json({ ok: true });
});
module.exports = router;
module.exports.broadcastLane = broadcastLane;
```
- [ ] **Step 4: Mount the router**
In `server/index.js`, next to the other `require` calls for routers add:
```js
const lanesRouter = require("./routes/lanes");
```
and directly after line 101 (`app.use("/api/run", runRouter);`) add:
```js
app.use("/api/lanes", lanesRouter);
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `node --test server/__tests__/lanes-api.test.js`
Expected: PASS, 8 tests.
- [ ] **Step 6: Full server suite + commit**
Run: `npm run test:server`
Expected: all suites pass (no regression in `api.test.js`).
```bash
git add server/routes/lanes.js server/index.js server/__tests__/lanes-api.test.js
git commit -m "feat(lanes): REST surface and lane_update websocket event"
```
---
## Task 4: Bind sessions to lanes from the hook stream
**Files:**
- Modify: `server/routes/hooks.js` (session upsert around `server/routes/hooks.js:126-142`; hook dispatch that already reads `hook_type` near `server/routes/hooks.js:1055`)
- Test: `server/__tests__/lanes-api.test.js` (append a `describe`)
**Interfaces:**
- Consumes: `resolveLaneByCwd`, `updateLane` from `server/lib/lanes.js`; `broadcastLane` from `server/routes/lanes.js`.
- Produces: no new exports. Side effects only — `lanes.session_id` is set when a hook arrives from a path under a lane's `cwd`; `lanes.needs_action` is set on a `Notification` hook and cleared by the next non-Notification hook **from the session currently bound to that lane** (evaluated before any rebinding). A hook from a different session rebinds the lane but does not clear a flag it did not raise — two agents sharing one worktree must not cancel each other's "needs you".
- [ ] **Step 1: Write the failing test (append to `server/__tests__/lanes-api.test.js`)**
```js
describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-hook-a", title: "Hooked" });
const id = created.body.lane.id;
await request("POST", "/api/hooks", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a/sub/dir" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-lane-1");
await request("POST", "/api/hooks", {
hook_type: "Notification",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", message: "needs permission" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "needs permission");
await request("POST", "/api/hooks", {
hook_type: "PostToolUse",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("ignores a hook whose cwd is under no lane", async () => {
const before = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-orphan", cwd: "/tmp/not-a-lane" },
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/lanes-api.test.js`
Expected: FAIL — `session_id` is `null`, no binding happens.
- [ ] **Step 3: Add the binding**
At the top of `server/routes/hooks.js`, next to the existing requires:
```js
const lanesLib = require("../lib/lanes");
const { broadcastLane } = require("./lanes");
```
Add this helper below the other module-level helpers in the same file:
```js
/**
* Attach an incoming hook to the lane that owns its cwd. Lanes are optional and
* this is best-effort: the hook path must never fail because of lane
* bookkeeping, so everything here is inside one try/catch.
*
* `needs_action` mirrors Claude Code's Notification hook (a permission prompt or
* an idle nudge). The next non-Notification hook from the same session means the
* agent is moving again, so the flag clears itself — no user click required.
*/
function touchLaneFromHook(hookType, data) {
try {
if (!data || !data.cwd) return;
const lane = lanesLib.resolveLaneByCwd(data.cwd);
if (!lane) return;
const patch = {};
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
if (hookType === "Notification") {
patch.needs_action = data.message || "needs you";
} else if (lane.needs_action) {
patch.needs_action = null;
}
if (!Object.keys(patch).length) return;
lanesLib.updateLane(lane.id, patch);
broadcastLane(lane.id);
} catch {
/* lane bookkeeping is never allowed to block a hook */
}
}
```
In the main hook handler, immediately after `hook_type` and `data` are destructured and the `MISSING_SESSION` guard has passed (near `server/routes/hooks.js:1036`), call:
```js
touchLaneFromHook(hook_type, data);
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `node --test server/__tests__/lanes-api.test.js`
Expected: PASS, 10 tests.
- [ ] **Step 5: Commit**
Run: `npm run test:server`
```bash
git add server/routes/hooks.js server/__tests__/lanes-api.test.js
git commit -m "feat(lanes): bind sessions to lanes by cwd and surface needs-you"
```
---
## Task 5: `ccam stage` / `ccam lanes` CLI
This is what a skill or a `CLAUDE.md` rule actually calls. A CLI subcommand — not an MCP tool — because `bin/ccam.js` already exists, needs no TypeScript build, and any agent can run it with Bash.
**Files:**
- Modify: `bin/ccam.js` (command switch at `bin/ccam.js:2272-2330`, help text at `bin/ccam.js:1589`)
- Test: `server/__tests__/lanes-cli.test.js`
**Interfaces:**
- Consumes: `GET/POST /api/lanes` from Task 3, through the helpers `bin/ccam.js` already defines at `bin/ccam.js:191-192` — `get(pathname)` and `post(pathname, body)`, both wrapping `api()` (`bin/ccam.js:166`). Do not add a second HTTP client. `api()` already exits 1 with a red `✖ METHOD path → message` line on a non-2xx, so error paths below only handle the cases it does not cover.
- Produces:
- `ccam lanes` — table of lanes: id, title, stage, status, liveness, progress.
- `ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]` — resolves the lane from `--lane`, else `--cwd`, else `process.cwd()`; exits 1 with a clear message when no lane owns that path.
- [ ] **Step 1: Write the failing test**
Create `server/__tests__/lanes-cli.test.js`:
```js
/**
* @file Tests for the `ccam stage` / `ccam lanes` CLI subcommands: lane
* resolution from the current directory, the stage round-trip through the HTTP
* API, and the non-zero exit when the cwd belongs to no lane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const fs = require("fs");
const http = require("http");
const { spawn } = require("child_process");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-cli-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-cli-"));
const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js");
let server;
let BASE;
function post(urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = JSON.stringify(body);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
},
(res) => {
let d = "";
res.on("data", (c) => (d += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(d || "{}") }));
},
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
// MUST be async: the test server runs in THIS process, so a blocking
// spawnSync would stall the event loop and the CLI child's request to
// 127.0.0.1 would never be served — a deadlock that looks exactly like a
// sandbox blocking loopback. Use the async spawn and await the exit.
function cli(args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI, ...args], {
cwd,
env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port) },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => (stdout += c));
child.stderr.on("data", (c) => (stderr += c));
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
});
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await post("/api/lanes", { cwd: LANE_DIR, title: "CLI lane" });
});
after(() => {
if (server) server.close();
fs.rmSync(LANE_DIR, { recursive: true, force: true });
});
describe("ccam stage", () => {
it("reports a stage for the lane owning the current directory", () => {
const r = cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /review/);
const list = cli(["lanes"], LANE_DIR);
assert.match(list.stdout, /review/);
});
it("exits non-zero when no lane owns the cwd", () => {
const r = cli(["stage", "review"], os.tmpdir());
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/lanes-cli.test.js`
Expected: FAIL — `ccam stage` is an unknown command.
- [ ] **Step 3: Implement the subcommands**
In `bin/ccam.js`, add two command functions alongside `cmdOpen` (`bin/ccam.js:1450`), using the file's existing request helper and output formatter rather than a new one:
```js
/**
* `ccam lanes` — one row per lane: what it is, where it is in its pipeline, and
* whether the driving session is still breathing.
*/
async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes");
if (!lanes.length) {
console.log("no lanes yet — create one with: ccam lanes add --cwd <path> --title <text>");
return;
}
for (const l of lanes) {
const needs = l.needs_action ? ` ⚠ ${l.needs_action}` : "";
console.log(
`#${l.id} ${(l.title || l.cwd).padEnd(38).slice(0, 38)} ` +
`${String(l.stage).padEnd(12)} ${String(l.status).padEnd(9)} ` +
`${String(l.liveness).padEnd(6)} ${String(l.progress).padStart(3)}%${needs}`,
);
}
console.log(
`\n${counts.total} lanes · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`,
);
}
/**
* `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the
* dashboard shows a declared stage instead of an inferred one.
*/
async function cmdStage(args) {
const stage = args[0];
if (!stage || stage.startsWith("--")) {
console.error("usage: ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]");
process.exitCode = 1;
return;
}
const flag = (name) => {
const i = args.indexOf(`--${name}`);
return i > -1 ? args[i + 1] : undefined;
};
let laneId = flag("lane");
if (!laneId) {
const cwd = require("path").resolve(flag("cwd") || process.cwd());
const { lanes } = await get("/api/lanes");
const match = lanes
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
if (!match) {
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
process.exitCode = 1;
return;
}
laneId = match.id;
}
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
stage,
status: flag("status"),
evidence: flag("evidence"),
note: flag("note"),
result: flag("result"),
});
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
}
```
Wire them into the command switch (`bin/ccam.js:2272`), matching the surrounding `case` style:
```js
case "lanes":
return cmdLanes(rest);
case "stage":
return cmdStage(rest);
```
Add both to `cmdHelp()` (`bin/ccam.js:1589`) in the existing help layout:
```
lanes List lanes with stage, liveness and progress
stage <stage> [flags] Report the current pipeline stage for a lane
```
`get` and `post` are the module-level helpers already defined at `bin/ccam.js:191-192`; both are in scope for any command function in that file.
- [ ] **Step 4: Run tests to verify they pass**
Run: `node --test server/__tests__/lanes-cli.test.js`
Expected: PASS, 2 tests.
- [ ] **Step 5: Commit**
```bash
git add bin/ccam.js server/__tests__/lanes-cli.test.js
git commit -m "feat(lanes): ccam stage and ccam lanes subcommands"
```
---
## Task 6: Action layer — start / stop / resume / message / clear / remove
**Files:**
- Modify: `server/routes/run.js` (extract the same-origin guard so it can be shared — export only, no behavior change)
- Modify: `server/routes/lanes.js` (add the action route)
- Test: `server/__tests__/lanes-api.test.js` (append a `describe`)
**Interfaces:**
- Consumes: `spawnRun`, `sendInput`, `killRun`, `getRun` from `server/lib/run-spawner.js`; `sameOriginGuard` from `server/routes/run.js`.
- Produces: `POST /api/lanes/:id/:action` where `action ∈ start | stop | message | clear | remove`.
- `start` body `{prompt, model?, permissionMode?, effort?, resumeSessionId?}` → spawns in `lane.cwd`, stores `run_id`, sets `status: "running"`. `resumeSessionId` (or `resume: true`, meaning the lane's own `session_id`) is how "resume" is expressed — no separate action.
- `stop` → `killRun(lane.run_id)`, sets `status: "idle"`.
- `message` body `{text}` → `sendInput(lane.run_id, text)`; also clears `needs_action`.
- `clear` → `clearLane(id)`.
- `remove` body `{confirm: true}` → 400 without the flag, else deletes.
- [ ] **Step 1: Write the failing test (append to `server/__tests__/lanes-api.test.js`)**
```js
describe("lane actions", () => {
it("refuses remove without an explicit confirm flag", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-a" });
const id = c.body.lane.id;
assert.equal((await request("POST", `/api/lanes/${id}/remove`, {})).status, 400);
assert.equal((await request("POST", `/api/lanes/${id}/remove`, { confirm: true })).status, 200);
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 404);
});
it("rejects an unknown action", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-b" });
const r = await request("POST", `/api/lanes/${c.body.lane.id}/frobnicate`, {});
assert.equal(r.status, 400);
await request("POST", `/api/lanes/${c.body.lane.id}/remove`, { confirm: true });
});
it("clear resets stage state but keeps the lane", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-c", title: "Keep me" });
const id = c.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "review", status: "running" });
const r = await request("POST", `/api/lanes/${id}/clear`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.stage, "idle");
assert.equal(r.body.lane.title, "Keep me");
assert.deepEqual(r.body.lane.stages, {});
await request("POST", `/api/lanes/${id}/remove`, { confirm: true });
});
it("stop on a lane with no run is a no-op, not a 500", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-d" });
const r = await request("POST", `/api/lanes/${c.body.lane.id}/stop`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.status, "idle");
await request("POST", `/api/lanes/${c.body.lane.id}/remove`, { confirm: true });
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test server/__tests__/lanes-api.test.js`
Expected: FAIL — the action routes 404.
- [ ] **Step 3: Export the guard from `server/routes/run.js`**
At the bottom of `server/routes/run.js`, beside the existing `module.exports = router;`, add:
```js
// Shared with routes/lanes.js: lane actions spawn processes through the same
// run-spawner, so they must sit behind the same loopback/same-origin check.
module.exports.sameOriginGuard = sameOriginGuard;
```
- [ ] **Step 4: Add the action route to `server/routes/lanes.js`**
Add the requires at the top of the file:
```js
const runs = require("../lib/run-spawner");
const { sameOriginGuard } = require("./run");
```
Add the route AFTER `router.post("/:id/stage", …)` and BEFORE `module.exports`:
```js
const ACTIONS = new Set(["start", "stop", "message", "clear", "remove"]);
/**
* Lane control. Deliberately thin: every action maps onto one existing
* run-spawner call. There is no queue, no chaining, no gate evaluation — the
* dashboard drives a lane, it does not orchestrate a pipeline.
*/
router.post("/:id/:action", sameOriginGuard, (req, res) => {
const { action } = req.params;
if (!ACTIONS.has(action)) {
return res.status(400).json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
}
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
const body = req.body || {};
try {
switch (action) {
case "start": {
const handle = runs.spawnRun({
mode: "conversation",
prompt: body.prompt || "",
cwd: lane.cwd,
model: body.model,
permissionMode: body.permissionMode,
effort: body.effort,
resumeSessionId: body.resumeSessionId || (body.resume ? lane.session_id : undefined),
});
lanesLib.updateLane(lane.id, { run_id: handle.id, status: "running" });
break;
}
case "stop": {
// A lane with no live run is already stopped — say so, don't 500.
if (lane.run_id) {
try { runs.killRun(lane.run_id); } catch { /* already gone */ }
}
lanesLib.updateLane(lane.id, { status: "idle", run_id: null });
break;
}
case "message": {
if (!lane.run_id) {
return res.status(409).json({ error: { code: "ENORUN", message: "lane has no live run" } });
}
runs.sendInput(lane.run_id, String(body.text || ""));
lanesLib.updateLane(lane.id, { needs_action: null });
break;
}
case "clear":
lanesLib.clearLane(lane.id);
break;
case "remove": {
if (body.confirm !== true) {
return res
.status(400)
.json({ error: { code: "ECONFIRM", message: "remove requires confirm: true" } });
}
lanesLib.deleteLane(lane.id);
broadcast("lane_update", { removed: lane.id });
return res.json({ ok: true });
}
default:
break;
}
} catch (err) {
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
broadcastLane(lane.id);
res.json({ lane: payload(lanesLib.getLane(lane.id)) });
});
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `node --test server/__tests__/lanes-api.test.js`
Expected: PASS, 14 tests.
- [ ] **Step 6: Full server suite + commit**
Run: `npm run test:server`
```bash
git add server/routes/lanes.js server/routes/run.js server/__tests__/lanes-api.test.js
git commit -m "feat(lanes): per-lane start/stop/message/clear/remove actions"
```
---
## Task 7: Lanes page — pipeline map, cards, live updates
**Files:**
- Create: `client/src/components/lanes/PipelineMap.tsx`
- Create: `client/src/components/lanes/LaneCard.tsx`
- Create: `client/src/pages/Lanes.tsx`
- Create: `client/src/components/lanes/__tests__/PipelineMap.test.tsx`
- Modify: `client/src/lib/api.ts` (add `Lane`, `LaneNode`, `LaneCounts` types + the `api.lanes` group, following the existing group style)
- Modify: `client/src/lib/types.ts:1648` (add `"lane_update"` to the `WSMessage["type"]` union and its payload to the `data` union)
- Modify: `client/src/App.tsx` (route)
- Modify: `client/src/components/Sidebar.tsx` (nav entry)
- Modify: `client/src/i18n/*.json` (strings)
**Interfaces:**
- Consumes: `GET /api/lanes`, `POST /api/lanes/:id/:action`, WS `lane_update` from Tasks 3 and 6; `eventBus` (`client/src/lib/eventBus.ts`) as used by the other pages.
- Produces:
- `export interface LaneNode { id: string; label: string; icon: string; gate: boolean; state: "done" | "current" | "passed-no-evidence" | "failed" | "pending" }`
- `export interface Lane { id: number; title: string; cwd: string; branch: string | null; pipeline: string; session_id: string | null; run_id: string | null; stage: string; stage_since: string | null; status: string; gate_decision: string | null; ci_status: string | null; needs_action: string | null; links: Record<string, string>; stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>; notes: string | null; pipeline_name: string; pipeline_nodes: LaneNode[]; progress: number; stage_seconds: number | null; last_event_seconds: number | null; liveness: "active" | "idle" | "dead" }`
- `export interface LaneCounts { total: number; running: number; needs_you: number; dead: number }`
- `api.lanes.list(): Promise<{lanes: Lane[]; counts: LaneCounts}>`, `api.lanes.get(id)`, `api.lanes.create(body)`, `api.lanes.update(id, patch)`, `api.lanes.stage(id, body)`, `api.lanes.action(id, action, body?)`
- `<PipelineMap nodes={LaneNode[]} />`, `<LaneCard lane={Lane} onAction={(action, body?) => void} />`
- [ ] **Step 1: Write the failing test**
Create `client/src/components/lanes/__tests__/PipelineMap.test.tsx`:
```tsx
/**
* @file Rendering tests for the lane pipeline map: every node renders with a
* state-specific class so "done", "current" and "passed without evidence" stay
* visually distinguishable, and the amber state is never conflated with done.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import PipelineMap from "../PipelineMap";
import type { LaneNode } from "../../../lib/api";
const nodes: LaneNode[] = [
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "passed-no-evidence" },
{ id: "review", label: "review", icon: "👀", gate: true, state: "current" },
{ id: "gate", label: "gate", icon: "🚦", gate: true, state: "failed" },
{ id: "done", label: "done", icon: "✅", gate: false, state: "pending" },
];
describe("PipelineMap", () => {
it("renders one element per node, labelled by state", () => {
render(<PipelineMap nodes={nodes} />);
expect(screen.getAllByTestId(/^pipeline-node-/)).toHaveLength(5);
expect(screen.getByTestId("pipeline-node-plan")).toHaveAttribute("data-state", "done");
expect(screen.getByTestId("pipeline-node-implement")).toHaveAttribute(
"data-state",
"passed-no-evidence",
);
expect(screen.getByTestId("pipeline-node-review")).toHaveAttribute("data-state", "current");
expect(screen.getByTestId("pipeline-node-gate")).toHaveAttribute("data-state", "failed");
expect(screen.getByTestId("pipeline-node-done")).toHaveAttribute("data-state", "pending");
});
it("gives amber nodes a different class from done nodes", () => {
render(<PipelineMap nodes={nodes} />);
const done = screen.getByTestId("pipeline-node-plan").className;
const amber = screen.getByTestId("pipeline-node-implement").className;
expect(done).not.toEqual(amber);
});
it("renders nothing but an empty hint when there are no nodes", () => {
render(<PipelineMap nodes={[]} />);
expect(screen.queryAllByTestId(/^pipeline-node-/)).toHaveLength(0);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npx vitest run src/components/lanes/__tests__/PipelineMap.test.tsx`
Expected: FAIL — cannot resolve `../PipelineMap`.
- [ ] **Step 3: Write `PipelineMap.tsx`**
Create `client/src/components/lanes/PipelineMap.tsx`:
```tsx
/**
* @file The lane pipeline map: a horizontal chain of stage nodes coloured by
* state. Layout is computed from the node list (flex + connectors), never from
* hardcoded coordinates, so a lane can use a longer or shorter template without
* touching this component. "passed without evidence" is deliberately its own
* colour: a stage the agent claimed but left no artifact for is not the same as
* a stage that is genuinely done.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { LaneNode } from "../../lib/api";
const STATE_CLASS: Record<LaneNode["state"], string> = {
done: "border-emerald-500 text-emerald-400 bg-emerald-500/10",
current: "border-blue-400 text-blue-300 bg-blue-500/20 ring-2 ring-blue-400/40",
"passed-no-evidence": "border-amber-500 text-amber-400 bg-amber-500/10",
failed: "border-red-500 text-red-400 bg-red-500/10",
pending: "border-neutral-700 text-neutral-500 bg-transparent",
};
export default function PipelineMap({ nodes }: { nodes: LaneNode[] }) {
if (!nodes.length) return <div className="text-xs text-neutral-500">no pipeline</div>;
return (
<div className="flex items-center gap-1 overflow-x-auto py-2">
{nodes.map((n, i) => (
<div key={n.id} className="flex items-center gap-1">
<div
data-testid={`pipeline-node-${n.id}`}
data-state={n.state}
title={`${n.label} — ${n.state}`}
className={`flex h-14 w-14 shrink-0 flex-col items-center justify-center rounded-full border text-[10px] ${STATE_CLASS[n.state]}`}
>
<span className="text-base leading-none">{n.icon}</span>
<span className="mt-0.5 max-w-[52px] truncate px-1">{n.label}</span>
</div>
{i < nodes.length - 1 && <div className="h-px w-4 shrink-0 bg-neutral-700" />}
</div>
))}
</div>
);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && npx vitest run src/components/lanes/__tests__/PipelineMap.test.tsx`
Expected: PASS, 3 tests.
- [ ] **Step 5: Add the API client group**
In `client/src/lib/api.ts`, add the three interfaces from the **Interfaces** block above next to the other exported types, then add this group to the `api` object, matching the surrounding group style (same `request` helper, same scoped-GET convention):
```ts
lanes: {
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`),
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
stage: (id: number, body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }) =>
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
request<{ lane?: Lane; ok?: boolean }>(`/lanes/${id}/${action}`, {
method: "POST",
body: JSON.stringify(body),
}),
},
```
- [ ] **Step 6: Write `LaneCard.tsx`**
Create `client/src/components/lanes/LaneCard.tsx`:
```tsx
/**
* @file One lane's card: title, stage badge with time-on-phase, progress bar,
* branch/CI/PR facts, the "needs you" banner sourced from Claude Code's
* Notification hook, and the control row. A dead lane (its driving session went
* silent while it should have been working) is called out loudly — that is the
* failure this view exists to catch.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { Lane } from "../../lib/api";
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-emerald-400",
idle: "bg-neutral-500",
dead: "bg-red-500",
};
function since(sec: number | null): string {
if (sec === null) return "—";
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}
export default function LaneCard({
lane,
onAction,
}: {
lane: Lane;
onAction: (action: string, body?: Record<string, unknown>) => void;
}) {
return (
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs uppercase tracking-wide text-neutral-500">Lane {lane.id}</span>
<span className="flex items-center gap-1.5 text-xs">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
{lane.liveness === "dead" ? "DEAD" : lane.status.toUpperCase()}
</span>
</div>
<h3 className="mb-2 text-sm font-semibold text-neutral-100">{lane.title || lane.cwd}</h3>
<div className="mb-2 flex items-center gap-2 text-xs">
<span className="rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">{lane.stage}</span>
<div className="h-1.5 flex-1 rounded bg-neutral-800">
<div className="h-1.5 rounded bg-blue-500" style={{ width: `${lane.progress}%` }} />
</div>
<span className="text-neutral-500">{lane.progress}%</span>
<span className="text-neutral-500">{since(lane.stage_seconds)}</span>
</div>
{lane.needs_action && (
<div className="mb-2 rounded border border-amber-600/50 bg-amber-500/10 px-2 py-1 text-xs text-amber-300">
⚠ {lane.needs_action}
</div>
)}
<dl className="mb-3 space-y-0.5 text-xs text-neutral-400">
{lane.branch && <div>⑂ {lane.branch}</div>}
{lane.ci_status && <div>CI {lane.ci_status}</div>}
<div className="truncate" title={lane.cwd}>
{lane.cwd}
</div>
</dl>
<div className="flex flex-wrap gap-1.5">
{(["start", "stop", "clear"] as const).map((a) => (
<button
key={a}
type="button"
onClick={() => onAction(a)}
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
>
{a}
</button>
))}
<button
type="button"
onClick={() => onAction("remove", { confirm: true })}
className="rounded border border-red-800 px-2 py-1 text-xs text-red-400 hover:bg-red-950"
>
remove
</button>
</div>
</div>
);
}
```
- [ ] **Step 7: Write `Lanes.tsx`**
Create `client/src/pages/Lanes.tsx`:
```tsx
/**
* @file The Lanes page: header counters, the selected lane's pipeline map, and
* a card grid of every lane. Data arrives two ways — one `GET /api/lanes` on
* mount, then incremental `lane_update` messages off the shared event bus — so
* the page stays live without polling.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { api, type Lane, type LaneCounts } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard from "../components/lanes/LaneCard";
export default function Lanes() {
const [lanes, setLanes] = useState<Lane[]>([]);
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
const [selected, setSelected] = useState<number | null>(null);
const refresh = useCallback(async () => {
const r = await api.lanes.list();
setLanes(r.lanes);
setCounts(r.counts);
setSelected((cur) => (cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)));
}, []);
useEffect(() => {
void refresh();
// Incremental: a lane_update carries the whole lane, so patch in place and
// only refetch when a lane disappears (counters must stay truthful).
return eventBus.subscribe((msg: WSMessage) => {
if (msg.type !== "lane_update") return;
const payload = msg.data as { lane?: Lane; removed?: number };
if (payload.removed !== undefined) return void refresh();
const lane = payload.lane;
if (!lane) return;
setLanes((cur) => {
const i = cur.findIndex((l) => l.id === lane.id);
if (i === -1) return [...cur, lane];
const next = [...cur];
next[i] = lane;
return next;
});
});
}, [refresh]);
const act = async (id: number, action: string, body?: Record<string, unknown>) => {
await api.lanes.action(id, action, body);
if (action === "remove") await refresh();
};
const current = lanes.find((l) => l.id === selected) || null;
return (
<div className="space-y-4 p-4">
<header className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-neutral-100">Lanes</h1>
<span className="rounded bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
{counts.total} lanes
</span>
<span className="rounded bg-blue-500/20 px-2 py-0.5 text-xs text-blue-300">
{counts.running} running
</span>
{counts.needs_you > 0 && (
<span className="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-300">
{counts.needs_you} need you
</span>
)}
{counts.dead > 0 && (
<span className="rounded bg-red-500/20 px-2 py-0.5 text-xs text-red-300">
{counts.dead} dead
</span>
)}
</header>
{current && (
<section className="rounded-lg border border-neutral-800 bg-neutral-900/40 p-4">
<div className="mb-1 text-sm text-neutral-300">
Lane {current.id} · {current.title || current.cwd} · {current.pipeline_name}
</div>
<PipelineMap nodes={current.pipeline_nodes} />
</section>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{lanes.map((l) => (
<button key={l.id} type="button" className="text-left" onClick={() => setSelected(l.id)}>
<LaneCard lane={l} onAction={(a, b) => act(l.id, a, b)} />
</button>
))}
</div>
{!lanes.length && (
<p className="text-sm text-neutral-500">
No lanes yet. Create one from a working directory: <code>ccam lanes add --cwd $(pwd)</code>
</p>
)}
</div>
);
}
```
`eventBus.subscribe(handler)` (`client/src/lib/eventBus.ts:109`) receives EVERY inbound `WSMessage` and returns its own unsubscribe function — hence the `msg.type !== "lane_update"` filter and returning the disposer straight out of the effect, matching `client/src/pages/Dashboard.tsx:1082`. Import both `eventBus` and the `WSMessage` type (`client/src/lib/types.ts:1648`).
Because `WSMessage["type"]` is a closed union, add `"lane_update"` to it in `client/src/lib/types.ts` and extend the `data` union with `{ lane?: Lane; removed?: number }` — otherwise the filter above does not typecheck.
- [ ] **Step 8: Route and nav**
In `client/src/App.tsx`, add the import beside the other page imports and the route beside the others inside `Layout`:
```tsx
import Lanes from "./pages/Lanes";
```
```tsx
<Route path="lanes" element={<Lanes />} />
```
In `client/src/components/Sidebar.tsx`, add a nav item pointing at `/lanes` with the label pulled from i18n, matching the existing item structure. Add the string key `nav.lanes` (value `"Lanes"`) to every locale file under `client/src/i18n/`.
- [ ] **Step 9: Run the client suite**
Run: `npm run test:client`
Expected: PASS. The per-screen snapshot suite (`client/src/pages/__tests__/screens.snapshot.test.tsx`) may fail solely because a new nav entry exists — review the diff, confirm it is only the added Lanes item, then regenerate with `cd client && npx vitest run -u`.
- [ ] **Step 10: Header audit, build, commit**
Run: `bash .claude/skills/file-headers/scripts/check-headers.sh`
Run: `npm run build`
Expected: both clean.
```bash
git add client/src/components/lanes client/src/pages/Lanes.tsx client/src/lib/api.ts client/src/App.tsx client/src/components/Sidebar.tsx client/src/i18n
git commit -m "feat(lanes): lanes page with pipeline map and live updates"
```
---
## Task 8: Docs
The repo's own rules require docs to move with behavior. This is one task, not a step folded into each of the above, because it is a single coherent write-up.
**Files:**
- Create: `docs/LANES.md`
- Modify: `CLAUDE.md` (a `## Lanes` section: what a lane is, that CCAM does not orchestrate, and that stage reporting is the agent's job via `ccam stage`)
- Modify: `README.md` (one paragraph + the two new CLI commands in the command list)
**Interfaces:**
- Consumes: the behavior shipped in Tasks 1-7.
- Produces: no code.
- [ ] **Step 1: Write `docs/LANES.md`**
It must cover, with exact paths and runnable commands: what a lane is and why it is keyed by `cwd` rather than `session_id`; the five node states and specifically why `passed-no-evidence` is amber rather than green; how a skill reports a stage (`ccam stage review --evidence "3 findings"`); how to add a custom pipeline template (`DASHBOARD_PIPELINES_DIR`, the JSON shape from `server/data/pipelines/default.json`); the liveness rule (`LANE_DEAD_SEC`, why a silent watcher is dead but a silent idle lane is not); and an explicit statement that CCAM does not chain, queue, retry, or gate — the driving Claude session does.
- [ ] **Step 2: Update `CLAUDE.md` and `README.md`**
- [ ] **Step 3: Commit**
```bash
git add docs/LANES.md CLAUDE.md README.md
git commit -m "docs(lanes): document lanes, pipeline templates and stage reporting"
```
---
## Out of scope (deliberately)
Named here so a later reader knows these were decided, not forgotten:
- **Worktree / port / database provisioning per lane.** Shipyard's `bin/lane-up.sh` territory. A lane here is just a directory that already exists.
- **Any orchestrator** — no queue, no step chaining, no retry, no gate evaluation. Approach A: adding one would create a second scheduler competing with the Claude session that is already driving.
- **An MCP `report_stage` tool.** `ccam stage` covers it with no TypeScript build step. Add the MCP tool later only if a session turns out to reach for tools more reliably than Bash.
- **Stage inference from `TodoWrite` / `workflows.phases` for un-instrumented sessions.** Worth doing, but it is a distinct feature (inferred vs declared stage, with the UI distinguishing them) and belongs in its own plan once declared stages are proven.