# 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 ` (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ĩ `. 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ĩ */ 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ĩ */ 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ĩ */ 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: }` 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ĩ */ 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ĩ */ 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 [--lane ] [--cwd ] [--status ] [--evidence ] [--note ] [--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ĩ */ 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 --title "); 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 [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 [--lane ] [--cwd ] [--status ] [--evidence ] [--note ] [--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 [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; stages: Record; 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?)` - ``, ` 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ĩ */ 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(); 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(); 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(); 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ĩ */ import type { LaneNode } from "../../lib/api"; const STATE_CLASS: Record = { 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
no pipeline
; return (
{nodes.map((n, i) => (
{n.icon} {n.label}
{i < nodes.length - 1 &&
}
))}
); } ``` - [ ] **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) => 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 = {}) => 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ĩ */ import type { Lane } from "../../lib/api"; const LIVENESS_DOT: Record = { 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) => void; }) { return (
Lane {lane.id} {lane.liveness === "dead" ? "DEAD" : lane.status.toUpperCase()}

{lane.title || lane.cwd}

{lane.stage}
{lane.progress}% {since(lane.stage_seconds)}
{lane.needs_action && (
⚠ {lane.needs_action}
)}
{lane.branch &&
⑂ {lane.branch}
} {lane.ci_status &&
CI {lane.ci_status}
}
{lane.cwd}
{(["start", "stop", "clear"] as const).map((a) => ( ))}
); } ``` - [ ] **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ĩ */ 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([]); const [counts, setCounts] = useState({ total: 0, running: 0, needs_you: 0, dead: 0 }); const [selected, setSelected] = useState(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) => { await api.lanes.action(id, action, body); if (action === "remove") await refresh(); }; const current = lanes.find((l) => l.id === selected) || null; return (

Lanes

{counts.total} lanes {counts.running} running {counts.needs_you > 0 && ( {counts.needs_you} need you )} {counts.dead > 0 && ( {counts.dead} dead )}
{current && (
Lane {current.id} · {current.title || current.cwd} · {current.pipeline_name}
)}
{lanes.map((l) => ( ))}
{!lanes.length && (

No lanes yet. Create one from a working directory: ccam lanes add --cwd $(pwd)

)}
); } ``` `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 } /> ``` 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.