# 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.
