Files
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

32 KiB

Worktree-backed Lanes 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: Let a lane own a git worktree that CCAM creates, resets, removes and purges, with every destructive action gated on counted facts and on three independent safety checks.

Architecture: Design doc: docs/superpowers/specs/2026-07-28-worktree-lanes-design.md — read it once before Task 1. All git work goes through one module (server/lib/worktree.js) that shells out with execFile and an argv array, never a shell string, and re-verifies its own safety preconditions. Lanes gain a kind of adopted (pointer at a directory the user already had — never destroyable) or managed (a worktree CCAM created — destroyable). Destructive actions are serialised per lane and preceded by a preflight endpoint that returns counts, which the confirmation UI renders and the server re-checks before acting.

Tech Stack: Node 18+, Express, better-sqlite3, node:child_process.execFile, real git against temp-directory fixtures, node:test (server), React 18 + TypeScript + Vitest (client).

Global Constraints

  • Branch: create feat/worktree-lanes off the current head of feat/lanes-pipeline. Never work on master.
  • Every .js/.ts/.tsx file created or modified MUST start with a file overview comment plus the exact line @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>. Verify with bash .claude/skills/file-headers/scripts/check-headers.sh (must exit 0).
  • Schema changes are additive only and migration-safe on an existing database: try { SELECT col } catch { ALTER TABLE … ADD COLUMN }, the pattern at server/db.js:412-418. Existing rows must migrate to kind='adopted'.
  • Never rm -rf a lane directory. Removal goes through git worktree remove; if git refuses, surface git's error unchanged.
  • Never build a shell command string. execFile("git", [...args]) only. No shell: true, no template-literal commands.
  • Destructive routes stay behind the existing same-origin guard exported from server/routes/run.js.
  • Preserve existing behavior: no existing route, response shape, WebSocket type, or CLI command changes meaning. lane_update stays the only lane WS type.
  • Server is CommonJS. No new npm dependencies. Server tests use node:test + node:assert/strict; client tests use Vitest + Testing Library.
  • The pre-commit hook runs Prettier and the full server suite; a commit takes minutes. Do not disable it.
  • Baseline before this plan: 790 server tests, 279 client tests, all passing.

File Structure

Create

  • server/lib/worktree.js — every git invocation, plus the three-check safety guard. No Express, no DB.
  • server/lib/lane-preflight.js — counts for reset / remove / purge. Reads git and the DB; mutates nothing.
  • server/lib/lane-lock.js — per-lane async mutex.
  • server/__tests__/worktree.test.js — git behaviour against a real temp repo.
  • server/__tests__/lane-lifecycle.test.js — HTTP: add / preflight / reset / remove / purge.
  • client/src/components/lanes/DestructiveLaneModal.tsx — preflight table inside the existing ConfirmModal.

Modify

  • server/db.js — four additive columns.
  • server/lib/lanes.jskind/source_repo/base_branch/slug in create/patch/payload; purgeLaneSessions.
  • server/routes/lanes.jsPOST /worktree, GET /:id/preflight, reset + purge actions, lock usage.
  • bin/ccam.jsccam lanes add --repo, ccam lanes reset|remove|purge.
  • client/src/lib/api.ts, client/src/lib/types.ts — preflight + worktree types and calls.
  • client/src/components/lanes/LaneCard.tsx — kind badge; destructive buttons only for managed.
  • docs/LANES.md, CLAUDE.md — the new lifecycle.

Task 1: server/lib/worktree.js — git plumbing and the safety guard

Files:

  • Create: server/lib/worktree.js
  • Test: server/__tests__/worktree.test.js

Interfaces:

  • Consumes: nothing from earlier tasks.

  • Produces:

    • LANES_ROOTprocess.env.LANES_ROOT || path.join(os.homedir(), ".claude", "ccam-lanes")
    • git(cwd, args): Promise<{stdout, stderr}> — rejects with err.git = {args, code, stderr} on non-zero
    • isGitRepo(dir): Promise<boolean>
    • resolveBase(sourceRepo, wanted): Promise<string>origin/<wanted><wanted> → current HEAD
    • slugify(text): string — lowercase, non-alphanumerics to -, collapsed, trimmed, max 40 chars
    • listWorktrees(sourceRepo): Promise<Array<{path, branch, locked}>> — parses --porcelain
    • branchCheckedOutAt(sourceRepo, branch): Promise<string|null>
    • addWorktree({sourceRepo, dir, branch, base}): Promise<{dir, branch, created: boolean}>
    • assertDestroyable(lane): Promise<void> — the three checks; throws err.code = "ENOTMANAGED" | "EOUTSIDEROOT" | "ENOTWORKTREE"
    • resetWorktree(lane): Promise<void>
    • removeWorktree(lane): Promise<void>
    • statusCounts(dir): Promise<{dirty, untracked, head}>
    • unpushedCount(dir): Promise<number>
  • Step 1: Write the failing test

Create server/__tests__/worktree.test.js. It builds a real repository in a temp directory — mocks would test nothing that matters here.

/**
 * @file Tests for server/lib/worktree.js against a REAL git repository created
 * in a temp directory. Every behaviour worth testing here is git's own — branch
 * collisions, what `clean -fd` spares, what `worktree list` reports — so mocking
 * git would only test our idea of git.
 * @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 fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");

const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-wt-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes");

const wt = require("../lib/worktree");

const SRC = path.join(ROOT, "src-repo");
const g = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" });

before(() => {
  fs.mkdirSync(SRC, { recursive: true });
  g(SRC, "init", "-b", "main");
  g(SRC, "config", "user.email", "t@example.com");
  g(SRC, "config", "user.name", "Test");
  fs.writeFileSync(path.join(SRC, "README.md"), "hello\n");
  fs.writeFileSync(path.join(SRC, ".gitignore"), "node_modules/\n.env\n");
  g(SRC, "add", "-A");
  g(SRC, "commit", "-m", "init");
});

after(() => fs.rmSync(ROOT, { recursive: true, force: true }));

function laneFor(dir, branch, over = {}) {
  return { id: 1, kind: "managed", cwd: dir, branch, source_repo: SRC, base_branch: "main", ...over };
}

describe("worktree", () => {
  it("slugifies a title into a safe single segment", () => {
    assert.equal(wt.slugify("Rename Metric → Rule!"), "rename-metric-rule");
    assert.equal(wt.slugify("  a//b  "), "a-b");
    assert.ok(wt.slugify("x".repeat(80)).length <= 40);
  });

  it("resolves the base branch, falling back when origin has none", async () => {
    assert.equal(await wt.resolveBase(SRC, "main"), "main");
    assert.equal(await wt.resolveBase(SRC, "does-not-exist"), "main");
  });

  it("creates a worktree on a new branch and lists it", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    const r = await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/alpha", base: "main" });
    assert.equal(r.created, true);
    assert.ok(fs.existsSync(path.join(dir, "README.md")));
    const list = await wt.listWorktrees(SRC);
    assert.ok(list.some((w) => w.path === dir && w.branch === "feat/alpha"));
  });

  it("refuses a branch already checked out in another worktree", async () => {
    const dir2 = path.join(process.env.LANES_ROOT, "src-repo__alpha2");
    await assert.rejects(
      () => wt.addWorktree({ sourceRepo: SRC, dir: dir2, branch: "feat/alpha", base: "main" }),
      (e) => e.code === "EBRANCHBUSY" && typeof e.checkedOutAt === "string",
    );
  });

  it("counts dirty, untracked and unpushed work", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    fs.appendFileSync(path.join(dir, "README.md"), "edit\n");
    fs.writeFileSync(path.join(dir, "scratch.txt"), "untracked\n");
    fs.mkdirSync(path.join(dir, "node_modules"), { recursive: true });
    fs.writeFileSync(path.join(dir, "node_modules", "dep.js"), "x\n");
    const s = await wt.statusCounts(dir);
    assert.equal(s.dirty, 1);
    assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
    assert.match(s.head, /^[0-9a-f]{7,40}$/);
    assert.equal(await wt.unpushedCount(dir), 0); // no upstream yet
  });

  it("reset restores base, drops untracked files, and spares gitignored ones", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    await wt.resetWorktree(laneFor(dir, "feat/alpha"));
    assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hello\n");
    assert.equal(fs.existsSync(path.join(dir, "scratch.txt")), false);
    assert.equal(fs.existsSync(path.join(dir, "node_modules", "dep.js")), true);
    const s = await wt.statusCounts(dir);
    assert.equal(s.dirty, 0);
  });

  it("refuses to destroy an adopted lane, a path outside LANES_ROOT, or a non-worktree", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    await assert.rejects(
      () => wt.assertDestroyable(laneFor(dir, "feat/alpha", { kind: "adopted" })),
      (e) => e.code === "ENOTMANAGED",
    );
    await assert.rejects(
      () => wt.assertDestroyable(laneFor("/tmp", "feat/alpha")),
      (e) => e.code === "EOUTSIDEROOT",
    );
    const ghost = path.join(process.env.LANES_ROOT, "src-repo__ghost");
    fs.mkdirSync(ghost, { recursive: true });
    await assert.rejects(
      () => wt.assertDestroyable(laneFor(ghost, "feat/ghost")),
      (e) => e.code === "ENOTWORKTREE",
    );
  });

  it("removes the worktree and its branch, leaving git's list clean", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
    await wt.removeWorktree(laneFor(dir, "feat/alpha"));
    assert.equal(fs.existsSync(dir), false);
    const list = await wt.listWorktrees(SRC);
    assert.equal(list.some((w) => w.path === dir), false);
    const branches = g(SRC, "branch", "--list", "feat/alpha").trim();
    assert.equal(branches, "");
  });

  it("never deletes the base branch even if a lane claims it", async () => {
    const dir = path.join(process.env.LANES_ROOT, "src-repo__beta");
    await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/beta", base: "main" });
    await wt.removeWorktree(laneFor(dir, "main")); // lane lies about its branch
    assert.match(g(SRC, "branch", "--list", "main"), /main/);
  });
});
  • Step 2: Run test to verify it fails

Run: node --test server/__tests__/worktree.test.js Expected: FAIL — Cannot find module '../lib/worktree'.

  • Step 3: Implement the module

Create server/lib/worktree.js. Key requirements the tests pin, restated so nothing is inferred:

  • git(cwd, args) wraps execFile("git", args, {cwd, maxBuffer: 8 * 1024 * 1024}) promisified. On failure throw an Error carrying err.git = { args, code, stderr }.

  • slugifytext.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40), and if the result is empty throw err.code = "EBADSLUG".

  • resolveBase(sourceRepo, wanted) — try git rev-parse --verify --quiet origin/<wanted>, then <wanted>, then git rev-parse --abbrev-ref HEAD. Return the first that resolves.

  • listWorktrees — parse git worktree list --porcelain: records separated by blank lines, worktree <path>, branch refs/heads/<name>, bare locked line. Return {path, branch, locked} with branch null for a detached worktree.

  • branchCheckedOutAt(sourceRepo, branch) — the path from listWorktrees whose branch matches, else null.

  • addWorktree({sourceRepo, dir, branch, base}):

    • if branchCheckedOutAt returns a path, throw err.code = "EBRANCHBUSY", err.checkedOutAt = thatPath
    • fs.mkdirSync(path.dirname(dir), {recursive: true})
    • if git rev-parse --verify --quiet <branch> succeeds, run worktree add <dir> <branch> and return {created: false}; otherwise worktree add -b <branch> <dir> <base> and return {created: true}
  • assertDestroyable(lane) — in order: kind !== "managed"ENOTMANAGED; fs.realpathSync(lane.cwd) not inside fs.realpathSync(LANES_ROOT) on a path boundary → EOUTSIDEROOT (a non-existent path fails this check too, which is correct — it cannot be a live worktree); not present in listWorktrees(lane.source_repo)ENOTWORKTREE.

  • PROTECTED_BRANCHES = new Set(["main", "master"]), plus the lane's own base_branch: deleteBranchSafely(sourceRepo, branch, baseBranch) returns without acting when the branch is protected or falsy.

  • resetWorktree(lane)assertDestroyable first, then, all in lane.cwd: fetch origin --prune (tolerate failure when there is no remote), checkout <base> (creating it from origin/<base> if absent), reset --hard <base>, clean -fd (never -x), then in source_repo deleteBranchSafely(lane.branch), then back in the worktree checkout -b <lane.branch> <base>.

  • removeWorktree(lane)assertDestroyable, then in source_repo: worktree unlock <dir> (ignore failure), worktree remove --force <dir>, worktree prune, deleteBranchSafely(lane.branch, lane.base_branch). If worktree remove fails, rethrow git's error untouched — do not fall back to filesystem deletion.

  • statusCounts(dir) — parse git status --porcelain=v1 --untracked-files=normal: lines starting ?? are untracked, others dirty. head from git rev-parse --short HEAD.

  • unpushedCount(dir)git rev-list --count @{u}..HEAD; when there is no upstream, git exits non-zero — return 0.

  • Step 4: Run test to verify it passes

Run: node --test server/__tests__/worktree.test.js Expected: PASS, 8 tests.

  • Step 5: Header audit and commit

Run: bash .claude/skills/file-headers/scripts/check-headers.sh

git add server/lib/worktree.js server/__tests__/worktree.test.js
git commit -m "feat(lanes): git worktree plumbing with a three-check destroy guard"

Task 2: Schema, lane fields, and per-lane locking

Files:

  • Modify: server/db.js (the lanes block)
  • Modify: server/lib/lanes.js
  • Create: server/lib/lane-lock.js
  • Test: server/__tests__/lanes-lib.test.js (append a describe)

Interfaces:

  • Consumes: nothing from Task 1 (kept independent so both can be reviewed alone).

  • Produces:

    • four columns on lanes: kind (NOT NULL DEFAULT 'adopted'), source_repo, base_branch, slug
    • createLane accepts and stores kind, source_repo, base_branch, slug; unknown values of kind are rejected with err.code = "EBADKIND"
    • PATCHABLE gains kind, source_repo, base_branch, slug
    • purgeLaneSessions(id): {sessions, events, tokenRows} — deletes the lane's sessions (never the one in lanes.session_id), their events, and token_usage rows left orphaned
    • server/lib/lane-lock.js: withLaneLock(id, fn): Promise<any> — serialises per lane id, releases on throw
  • Step 1: Write the failing test (append to server/__tests__/lanes-lib.test.js)

const { withLaneLock } = require("../lib/lane-lock");

describe("lane kind, worktree fields and purge", () => {
  it("defaults to adopted and stores worktree fields when given", () => {
    const a = lanes.createLane({ cwd: "/tmp/wt-kind-a" });
    assert.equal(a.kind, "adopted");
    const m = lanes.createLane({
      cwd: "/tmp/wt-kind-b", kind: "managed",
      source_repo: "/tmp/src", base_branch: "main", slug: "b",
    });
    assert.equal(m.kind, "managed");
    assert.equal(m.source_repo, "/tmp/src");
    assert.equal(m.base_branch, "main");
    assert.equal(m.slug, "b");
    lanes.deleteLane(a.id);
    lanes.deleteLane(m.id);
  });

  it("rejects an unknown kind", () => {
    assert.throws(() => lanes.createLane({ cwd: "/tmp/wt-kind-c", kind: "gremlin" }),
      (e) => e.code === "EBADKIND");
  });

  it("purges a lane's sessions, their events and orphaned token rows, sparing the live one", () => {
    const l = lanes.createLane({ cwd: "/tmp/wt-purge" });
    const { db } = require("../db");
    db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run("purge-1", "/tmp/wt-purge/sub");
    db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run("purge-live", "/tmp/wt-purge");
    db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'PostToolUse')").run("purge-1");
    db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 5)").run("purge-1");
    lanes.updateLane(l.id, { session_id: "purge-live" });

    const counts = lanes.purgeLaneSessions(l.id);
    assert.equal(counts.sessions, 1);
    assert.equal(counts.events, 1);
    assert.equal(counts.tokenRows, 1);
    assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='purge-live'").get().c, 1);
    assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='purge-1'").get().c, 0);
    assert.equal(db.prepare("SELECT COUNT(*) c FROM token_usage WHERE session_id='purge-1'").get().c, 0);
    lanes.deleteLane(l.id);
  });

  it("serialises work per lane and releases the lock when the body throws", async () => {
    const order = [];
    const slow = withLaneLock(7, async () => { order.push("a-start"); await new Promise((r) => setTimeout(r, 50)); order.push("a-end"); });
    const fast = withLaneLock(7, async () => { order.push("b"); });
    await Promise.all([slow, fast]);
    assert.deepEqual(order, ["a-start", "a-end", "b"]);
    await assert.rejects(() => withLaneLock(7, async () => { throw new Error("boom"); }));
    await withLaneLock(7, async () => order.push("c"));
    assert.equal(order[order.length - 1], "c");
  });
});
  • Step 2: Run test to verify it fails

Run: node --test server/__tests__/lanes-lib.test.js Expected: FAIL — Cannot find module '../lib/lane-lock'.

  • Step 3: Add the columns

In server/db.js, after the lanes table and its index, following the probe pattern at server/db.js:412-418:

// Managed lanes own a git worktree CCAM created and may be destroyed; adopted
// lanes merely point at a directory the user already had and never may be.
// Existing rows default to 'adopted', so no lane gains a destructive path by
// upgrading.
try {
  db.prepare("SELECT kind FROM lanes LIMIT 1").get();
} catch {
  db.prepare("ALTER TABLE lanes ADD COLUMN kind TEXT NOT NULL DEFAULT 'adopted'").run();
  db.prepare("ALTER TABLE lanes ADD COLUMN source_repo TEXT").run();
  db.prepare("ALTER TABLE lanes ADD COLUMN base_branch TEXT").run();
  db.prepare("ALTER TABLE lanes ADD COLUMN slug TEXT").run();
}
  • Step 4: Extend server/lib/lanes.js and write the lock

createLane gains the four fields (validating kind against new Set(["adopted", "managed"])), PATCHABLE gains them, and purgeLaneSessions(id) runs inside one db.transaction:

  • select the lane's sessions: WHERE (cwd = ? OR cwd LIKE ? || '/%') against lane.cwd, excluding lanes.session_id and any session whose status = 'active'
  • count and delete their events, then their token_usage, then the sessions themselves
  • return {sessions, events, tokenRows}
  • run db.pragma("optimize") after the transaction commits — never VACUUM, which locks the whole database

Create server/lib/lane-lock.js — a Map<laneId, Promise> chain:

const chains = new Map();

function withLaneLock(id, fn) {
  const key = String(id);
  const prev = chains.get(key) || Promise.resolve();
  const run = prev.then(fn, fn); // run regardless of how the previous holder settled
  // Keep the chain alive but never let a rejection poison the next waiter.
  chains.set(key, run.then(() => {}, () => {}));
  return run;
}
  • Step 5: Run tests to verify they pass

Run: node --test server/__tests__/lanes-lib.test.js Expected: PASS — the four new tests plus every earlier one.

  • Step 6: Full suite and commit

Run: npm run test:server

git add server/db.js server/lib/lanes.js server/lib/lane-lock.js server/__tests__/lanes-lib.test.js
git commit -m "feat(lanes): managed/adopted kinds, worktree fields, session purge, per-lane lock"

Task 3: Preflight — counted facts before anything destructive

Files:

  • Create: server/lib/lane-preflight.js
  • Test: server/__tests__/lane-lifecycle.test.js (new file; later tasks append to it)

Interfaces:

  • Consumes: statusCounts, unpushedCount, listWorktrees (Task 1); getLane (Task 2).

  • Produces: preflight(lane, action): Promise<object> where action ∈ "reset" | "remove" | "purge".

    • reset / remove{action, lane, kind, branch, dirty, untracked, unpushed, head, blocked: string[], warnings: string[]}
    • purge{action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped: boolean}
    • blocked contains "adopted" when the lane is not managed, "missing" when the directory is gone, and "unpushed-commits" when unpushed > 0. It is advisory data, not an exception — the route decides.
  • Step 1: Write the failing test

Create server/__tests__/lane-lifecycle.test.js with the standard harness (temp DASHBOARD_DB_PATH, DASHBOARD_REMOTE_SYNC_MS=0, DASHBOARD_LIVENESS_PROBE=0, LANES_ROOT pointed at a temp dir, startServer(createApp(), 0); copy the request helper from server/__tests__/lanes-api.test.js), plus a real git fixture repo as in Task 1. Tests:

it("preflight on an adopted lane blocks and counts nothing", async () => { /* create adopted lane, GET preflight?action=reset, expect blocked includes "adopted" */ });
it("preflight counts dirty, untracked and unpushed for a managed lane", async () => { /* dirty the worktree, expect dirty:1 untracked:1 and a head sha */ });
it("preflight for purge counts only this lane's non-live sessions", async () => { /* two sessions, one bound live, expect sessions:1 and activeSessionSkipped:true */ });
it("preflight 404s for an unknown lane and 400s for an unknown action", async () => {});
  • Step 2: Run to verify it fails

Run: node --test server/__tests__/lane-lifecycle.test.js Expected: FAIL — the route does not exist yet (404 with an HTML body).

  • Step 3: Implement server/lib/lane-preflight.js and the route

The module is read-only. bytesEstimate is (events + tokenRows) * 512 — label it in docs/LANES.md as a rough estimate, because a real per-row size needs dbstat, which is not compiled in by default.

In server/routes/lanes.js, add before the /:id/:action route so it is not swallowed:

router.get("/:id/preflight", async (req, res) => {
  const lane = lanesLib.getLane(req.params.id);
  if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
  const action = String(req.query.action || "");
  if (!["reset", "remove", "purge"].includes(action)) {
    return res.status(400).json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
  }
  try {
    res.json(await preflight(lane, action));
  } catch (err) {
    res.status(500).json({ error: { code: err.code, message: err.message } });
  }
});
  • Step 4: Run to verify it passes

Run: node --test server/__tests__/lane-lifecycle.test.js Expected: PASS, 4 tests.

  • Step 5: Commit
git add server/lib/lane-preflight.js server/routes/lanes.js server/__tests__/lane-lifecycle.test.js
git commit -m "feat(lanes): preflight counts for reset, remove and purge"

Task 4: add — provision a worktree in the background

Files:

  • Modify: server/routes/lanes.js
  • Test: server/__tests__/lane-lifecycle.test.js (append)

Interfaces:

  • Consumes: addWorktree, resolveBase, slugify, LANES_ROOT (Task 1); createLane, updateLane (Task 2); broadcastLane, sameOriginGuard (existing).

  • Produces: POST /api/lanes/worktree with body {sourceRepo, title, base?, slug?}202 {lane} with status: "provisioning", then a background lane_update when the worktree is ready or status: "failed" with the git error in notes.

  • Step 1: Write the failing tests (append)

it("creates a managed lane, returns 202 provisioning, then flips to idle when the worktree lands", async () => {});
it("rejects a sourceRepo that is not an absolute path or not a git repo", async () => {});
it("suffixes the slug when the directory already exists", async () => {});
it("marks the lane failed with git's message when provisioning fails", async () => {});

Poll GET /api/lanes/:id until status !== "provisioning" with a bounded deadline (2 s, 50 ms interval) — never a bare sleep.

  • Step 2: Run to verify they fail

Run: node --test server/__tests__/lane-lifecycle.test.js Expected: FAIL — POST /api/lanes/worktree 404s.

  • Step 3: Implement

Registered before /:id/:action, behind sameOriginGuard. Validate: sourceRepo absolute, exists, isGitRepo. Compute slug = slugify(req.body.slug || req.body.title), dir = path.join(LANES_ROOT, ${path.basename(sourceRepo)}__${slug}), suffixing -2, -3… while the directory exists. Create the lane row kind: "managed", status: "provisioning", respond 202, then in the background — wrapped in withLaneLock(lane.id, …) — resolve the base, addWorktree, and updateLane to status: "idle" (or "failed" with notes set to err.git?.stderr || err.message), broadcasting either way.

Provisioning must never leave a half-state: if addWorktree throws, the lane row stays with kind: "managed" and status: "failed" so the user can remove it, and no directory is left behind that git does not know about.

  • Step 4: Run to verify they pass — Expected: PASS, 8 tests total in the file.

  • Step 5: Commit

git add server/routes/lanes.js server/__tests__/lane-lifecycle.test.js
git commit -m "feat(lanes): provision a git worktree for a managed lane"

Task 5: reset, remove, purge actions

Files:

  • Modify: server/routes/lanes.js
  • Test: server/__tests__/lane-lifecycle.test.js (append)

Interfaces:

  • Consumes: everything from Tasks 1-4.

  • Produces: reset and purge join the ACTIONS set; remove gains worktree teardown. All three require {confirm: true}; reset and remove additionally require {force: true} when preflight reports unpushed > 0, and accept {expect: {head, dirty, untracked, unpushed}} — a mismatch returns 409 ESTALE.

  • Step 1: Write the failing tests (append)

it("reset requires confirm, restores the branch from base and clears lane state", async () => {});
it("reset refuses with 409 when the worktree has unpushed commits, and proceeds with force", async () => {});
it("reset returns 409 ESTALE when the head moved since preflight", async () => {});
it("remove tears down the worktree and the branch, and deletes the lane row", async () => {});
it("reset and remove refuse an adopted lane with 400 ENOTMANAGED", async () => {});
it("purge deletes the lane's sessions and reports the counts", async () => {});

The adopted-lane refusal is the single most important test in this plan: it is what stands between a mis-click and a user's real project directory.

  • Step 2: Run to verify they fail — Expected: FAIL, the actions are unknown or non-destructive.

  • Step 3: Implement

Inside the existing /:id/:action handler, all three branches run within withLaneLock(lane.id, async () => …), and each begins by killing the lane's run and awaiting its exit (poll runs.getRun(lane.run_id) until it is no longer running/spawning, bounded, then clear run_id).

Map the guard errors to HTTP: ENOTMANAGED / EOUTSIDEROOT / ENOTWORKTREE400 with the code intact; ESTALE409; EUNPUSHED409; git failures → 500 carrying err.git.stderr.

  • Step 4: Run to verify they pass — Expected: PASS, 14 tests in the file.

  • Step 5: Full suite and commit

Run: npm run test:server

git add server/routes/lanes.js server/__tests__/lane-lifecycle.test.js
git commit -m "feat(lanes): reset, remove and purge with preflight and stale-state guards"

Task 6: CLI

Files:

  • Modify: bin/ccam.js
  • Test: server/__tests__/lanes-cli.test.js (append)

Interfaces:

  • Consumes: the routes from Tasks 3-5, via the existing get / post helpers (bin/ccam.js:191-192).

  • Produces: ccam lanes add --repo <path> [--title <t>] [--base <branch>] (worktree mode; the existing --cwd form still adopts); ccam lanes reset|remove|purge <id> [--force], each printing the preflight table and refusing without --yes.

  • Step 1: Write the failing tests (append) — worktree add via CLI lands a managed lane; reset without --yes exits non-zero and changes nothing; --yes performs it.

  • Step 2: Run to verify they fail.

  • Step 3: Implement, reusing the async cli() harness and the existing flag reader. Print the preflight counts as a small aligned table before asking for --yes, so the terminal path has the same "confirm against numbers" property as the UI.

  • Step 4: Run to verify they pass.

  • Step 5: Commitfeat(lanes): ccam lanes add --repo, reset, remove, purge


Task 7: UI and docs

Files:

  • Create: client/src/components/lanes/DestructiveLaneModal.tsx
  • Modify: client/src/components/lanes/LaneCard.tsx, client/src/lib/api.ts, client/src/lib/types.ts, client/src/i18n/locales/*/lanes.json
  • Modify: docs/LANES.md, CLAUDE.md
  • Test: client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx

Interfaces:

  • Consumes: api.lanes.preflight(id, action) and api.lanes.action(id, action, body).

  • Produces: <DestructiveLaneModal lane action onClose onConfirm> — fetches preflight on open, renders the counts, disables the confirm button while loading or when blocked contains anything other than unpushed-commits, and exposes a "Force" checkbox only for unpushed-commits.

  • Step 1: Write the failing test — the modal renders the counts it was given; the confirm button is disabled for an adopted lane; ticking Force enables confirm when the only blocker is unpushed commits; confirming passes back the expect block it displayed.

  • Step 2: Run to verify it fails.

  • Step 3: Implement, wrapping the repo's existing ConfirmModal. LaneCard shows a managed/adopted badge and renders reset/remove/purge only for managed lanes. Every string goes through i18n in all four locales.

  • Step 4: Run npm run test:client and npm run build. Review the screens snapshot diff before accepting it.

  • Step 5: Docsdocs/LANES.md gains a Lifecycle section covering the two kinds, the three safety checks, each verb with what it destroys and what it spares (clean -fd keeps gitignored files), the preflight contract, the env vars, and the fresh-worktree-has-no-dependencies limitation. CLAUDE.md's Lanes section gains the rule: never rm -rf a lane; never build a git command as a shell string; adopted lanes are not destroyable.

  • Step 6: Header audit and commitfeat(lanes): destructive-action modal with preflight counts, lifecycle docs


Out of scope

  • Dependency bootstrap for a fresh worktree (node_modules, .env) — Shipyard's profile-hook subsystem. A separate sub-project if wanted.
  • VACUUM as part of purge — it locks the whole database; if disk reclamation is wanted it becomes its own maintenance action.
  • Per-lane ports, databases, Docker services.
  • Stage auto-detection (sub-project B) and the merged Workspace page (sub-project A) — separate specs.