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

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

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

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

74 KiB

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.jslanes 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.jsccam stage and ccam lanes subcommands.
  • client/src/lib/api.tsapi.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:

/**
 * @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:

{
  "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:

/**
 * @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.

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_SECNumber(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"):

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:

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 declaresworkflows(ends just beforeCREATE INDEX IF NOT EXISTS idx_workflows_session`), append:

  -- 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:

/**
 * @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
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:

/**
 * @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:

/**
 * @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:

const lanesRouter = require("./routes/lanes");

and directly after line 101 (app.use("/api/run", runRouter);) add:

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

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)

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:

const lanesLib = require("../lib/lanes");
const { broadcastLane } = require("./lanes");

Add this helper below the other module-level helpers in the same file:

/**
 * 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:

  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

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-192get(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:

/**
 * @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:

/**
 * `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:

    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
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.
    • stopkillRun(lane.run_id), sets status: "idle".
    • message body {text}sendInput(lane.run_id, text); also clears needs_action.
    • clearclearLane(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)

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:

// 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:

const runs = require("../lib/run-spawner");
const { sameOriginGuard } = require("./run");

Add the route AFTER router.post("/:id/stage", …) and BEFORE module.exports:

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

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:

/**
 * @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:

/**
 * @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):

  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:

/**
 * @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:

/**
 * @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:

import Lanes from "./pages/Lanes";
        <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.

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

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.