diff --git a/docs/superpowers/plans/2026-08-04-cross-lane-named-locks.md b/docs/superpowers/plans/2026-08-04-cross-lane-named-locks.md new file mode 100644 index 0000000..a7815ad --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-cross-lane-named-locks.md @@ -0,0 +1,960 @@ +# Cross-Lane Named Locks (D) 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:** serialize machine-thrashing steps (builds, e2e runs) across ALL lanes with a named lock — independent of `server/lib/lane-lock.js`'s per-lane serialization, which is a different axis (one lane, one process) and must not be touched. + +**Architecture:** A new `server/lib/named-lock.js` (mkdir-based, filesystem-atomic, no `flock` dependency) holding lock directories under `LANES_ROOT/.locks//`; a thin `server/routes/locks.js` exposing single-shot acquire/release/status (the server never blocks — "CCAM does not orchestrate" — the CLI does the waiting loop); `ccam lock status|acquire|release` CLI subcommands; a lane-card UI badge polling the same endpoint `LaneCard.tsx`'s runtime hook already polls. + +**Tech Stack:** Node.js `fs` (mkdir/rmdir for atomicity — no new dependency), existing Express/route conventions, existing React hook + WebSocket broadcast conventions. + +## Global Constraints + +- Every applicable source file MUST start with the project's authorship header (file overview + `@author Nguyễn Ngọc Trí Vĩ `) — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`. +- `named-lock.js` is a **separate module** from `server/lib/lane-lock.js`. Do not touch `lane-lock.js`, do not import it, do not conflate the two axes (in-process-per-lane serialization vs cross-lane named locks). +- **Time-based staleness with a floor.** `LOCK_MAX_HOLD` env var, default `2700` (45 min) seconds. The *effective* break threshold is `Math.max(LOCK_MAX_HOLD, 300)` — a holder can never be force-broken by setting `LOCK_MAX_HOLD` below 300 seconds. This floor is the whole point: it stops an impatient env-var edit from breaking a genuinely live holder. +- Atomicity comes from `fs.mkdirSync` throwing `EEXIST` when the directory already exists — never a check-then-create race (`existsSync` then `mkdirSync` is NOT atomic and must not be used for the acquire decision). +- The server-side route layer is a single-shot primitive (try once, answer immediately) — it never loops or blocks waiting for a lock to free up. The CLI's `acquire` command owns the polling loop. This mirrors the project's standing rule: CCAM offers primitives, the caller sequences them. +- Run `npm run test:server` (full suite) and `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit — both must stay green/passing. This repo's pre-commit hook enforces the full suite (backend + frontend) already; a clean run here avoids a blocked commit. +- Never use `git add -A`. Stage exactly the files each task names. + +--- + +### Task 1: `server/lib/named-lock.js` — core lock primitives + +**Files:** +- Create: `server/lib/named-lock.js` +- Test: `server/__tests__/named-lock.test.js` + +**Interfaces:** +- Produces: `LOCK_MAX_HOLD_FLOOR_SEC` (const, `300`), `locksRoot()` → `string` (`/.locks`), `effectiveMaxHoldSec()` → `number` (reads `process.env.LOCK_MAX_HOLD` per call, floors at 300), `tryAcquire(name, holder)` → `{acquired: true} | {acquired: false, holder: string, since: number, ageSec: number}`, `release(name, holder)` → `void`, throws `Object.assign(new Error(...), {code: "ENOTHOLDER"})` when `holder` doesn't match the current owner, `Object.assign(new Error(...), {code: "ENOLOCK"})` when the lock doesn't exist at all, `status(name)` → `{held: false} | {held: true, holder: string, since: number, ageSec: number}`, `listLocks()` → `Array<{name: string, holder: string, since: number, ageSec: number}>` (every currently-held lock under `locksRoot()`). +- `since` is a Unix seconds integer (`Math.floor(Date.now() / 1000)`), matching the plan's `lane ` owner-file format. + +- [ ] **Step 1: Write the failing test** + +Create `server/__tests__/named-lock.test.js`: + +```js +/** + * @file Tests for server/lib/named-lock.js: cross-lane named locks — mkdir + * atomicity, the LOCK_MAX_HOLD staleness floor, holder-checked release, and + * status/listing. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); +const { describe, it, after, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-named-lock-")); +process.env.LANES_ROOT = SUITE_ROOT; +after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); + +const namedLock = require("../lib/named-lock"); + +beforeEach(() => { + fs.rmSync(namedLock.locksRoot(), { recursive: true, force: true }); + delete process.env.LOCK_MAX_HOLD; +}); + +describe("tryAcquire / release", () => { + it("acquires a free lock and reports it held afterward", () => { + const result = namedLock.tryAcquire("build", "lane1"); + assert.deepEqual(result, { acquired: true }); + const status = namedLock.status("build"); + assert.equal(status.held, true); + assert.equal(status.holder, "lane1"); + }); + + it("a second acquire on the same name reports not-acquired with the current holder", () => { + namedLock.tryAcquire("build", "lane1"); + const second = namedLock.tryAcquire("build", "lane2"); + assert.equal(second.acquired, false); + assert.equal(second.holder, "lane1"); + }); + + it("a second acquire succeeds after release", () => { + namedLock.tryAcquire("build", "lane1"); + namedLock.release("build", "lane1"); + const second = namedLock.tryAcquire("build", "lane2"); + assert.equal(second.acquired, true); + assert.equal(namedLock.status("build").holder, "lane2"); + }); + + it("release from a non-holder is refused and leaves the lock held", () => { + namedLock.tryAcquire("build", "lane1"); + assert.throws(() => namedLock.release("build", "lane2"), (err) => err.code === "ENOTHOLDER"); + assert.equal(namedLock.status("build").held, true); + assert.equal(namedLock.status("build").holder, "lane1"); + }); + + it("releasing a lock that doesn't exist throws ENOLOCK", () => { + assert.throws(() => namedLock.release("never-held", "lane1"), (err) => err.code === "ENOLOCK"); + }); +}); + +describe("staleness with a floor", () => { + /** Back-date a lock's owner file by writing it directly, bypassing tryAcquire's "now". */ + function backdate(name, holder, ageSec) { + const dir = path.join(namedLock.locksRoot(), name); + fs.mkdirSync(dir, { recursive: true }); + const since = Math.floor(Date.now() / 1000) - ageSec; + fs.writeFileSync(path.join(dir, "owner"), `${holder} ${since}`); + } + + it("a holder older than LOCK_MAX_HOLD is broken on the next acquire", () => { + backdate("stale", "lane1", 2701); // just past the 2700s default + const result = namedLock.tryAcquire("stale", "lane2"); + assert.equal(result.acquired, true); + assert.equal(namedLock.status("stale").holder, "lane2"); + }); + + it("a holder backdated within the 300s floor is NOT broken, even with LOCK_MAX_HOLD=1", () => { + process.env.LOCK_MAX_HOLD = "1"; + backdate("floor-test", "lane1", 100); // within the 300s floor + const result = namedLock.tryAcquire("floor-test", "lane2"); + assert.equal(result.acquired, false); + assert.equal(result.holder, "lane1"); + }); + + it("a holder older than a LOCK_MAX_HOLD set above the floor is broken", () => { + process.env.LOCK_MAX_HOLD = "400"; + backdate("above-floor", "lane1", 401); + const result = namedLock.tryAcquire("above-floor", "lane2"); + assert.equal(result.acquired, true); + }); +}); + +describe("listLocks", () => { + it("lists every currently-held lock", () => { + namedLock.tryAcquire("build", "lane1"); + namedLock.tryAcquire("e2e", "lane2"); + const locks = namedLock.listLocks().sort((a, b) => a.name.localeCompare(b.name)); + assert.deepEqual( + locks.map((l) => [l.name, l.holder]), + [ + ["build", "lane1"], + ["e2e", "lane2"], + ] + ); + }); + + it("returns an empty array when no locks exist", () => { + assert.deepEqual(namedLock.listLocks(), []); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/named-lock.test.js` +Expected: FAIL — `Cannot find module '../lib/named-lock'` + +- [ ] **Step 3: Write minimal implementation** + +Create `server/lib/named-lock.js`: + +```js +/** + * @file Cross-lane named locks: serialize a machine-thrashing step (a build, + * an e2e run) across ALL lanes, as opposed to `lane-lock.js`'s per-lane, + * in-process serialization (a different axis — a separate module on purpose, + * so the two are never conflated). + * + * `mkdir` is the atomicity primitive: it throws `EEXIST` when the directory + * already exists, so "is it free" and "claim it" are one syscall, never a + * check-then-create race. No `flock` dependency, unlike Shipyard's original + * (`brew install flock` on macOS). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const { LANES_ROOT } = require("./worktree"); + +/** + * However low `LOCK_MAX_HOLD` is set, a holder younger than this can never be + * broken. This is what stops an impatient env-var edit from force-breaking a + * genuinely live holder — the floor is the actual safety property, not the + * configurable default. + */ +const LOCK_MAX_HOLD_FLOOR_SEC = 300; + +/** Where every named lock's directory lives. */ +function locksRoot() { + return path.join(LANES_ROOT, ".locks"); +} + +/** `Math.max(LOCK_MAX_HOLD env, the 300s floor)`, read per call so a test (or an + * operator) can change it without a restart. */ +function effectiveMaxHoldSec() { + const raw = Number(process.env.LOCK_MAX_HOLD); + const configured = Number.isFinite(raw) && raw > 0 ? raw : 2700; + return Math.max(configured, LOCK_MAX_HOLD_FLOOR_SEC); +} + +function ownerPath(name) { + return path.join(locksRoot(), name, "owner"); +} + +/** Parse an owner file's `" "` contents. */ +function readOwner(name) { + let text; + try { + text = fs.readFileSync(ownerPath(name), "utf8"); + } catch { + return null; + } + const match = /^(\S+) (\d+)$/.exec(text.trim()); + if (!match) return null; + const since = Number(match[2]); + return { holder: match[1], since, ageSec: Math.floor(Date.now() / 1000) - since }; +} + +/** + * Try to acquire a named lock once. Never blocks, never retries — the caller + * (the CLI) owns the polling loop; CCAM's server-side primitives are always + * single-shot. + * + * A stale holder (older than `effectiveMaxHoldSec()`) is broken automatically: + * its lock directory is removed and the acquire is retried once before + * answering. + * + * @param {string} name - Lock name. + * @param {string} holder - Identity to record as the owner (e.g. `"lane3"`). + * @returns {{acquired: true} | {acquired: false, holder: string, since: number, ageSec: number}} + */ +function tryAcquire(name, holder) { + const dir = path.join(locksRoot(), name); + fs.mkdirSync(locksRoot(), { recursive: true }); + + try { + fs.mkdirSync(dir); + } catch (err) { + if (err.code !== "EEXIST") throw err; + const owner = readOwner(name); + if (!owner || owner.ageSec <= effectiveMaxHoldSec()) { + return owner + ? { acquired: false, holder: owner.holder, since: owner.since, ageSec: owner.ageSec } + : { acquired: false, holder: "unknown", since: 0, ageSec: Infinity }; + } + // Stale — break it and retry once. + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir); + } + + fs.writeFileSync(path.join(dir, "owner"), `${holder} ${Math.floor(Date.now() / 1000)}`); + return { acquired: true }; +} + +/** + * Release a lock. Refuses (throws `ENOTHOLDER`) when `holder` does not match + * the recorded owner — a release never trusts its caller, the same rule every + * other destructive path in this project follows. + * + * @param {string} name - Lock name. + * @param {string} holder - Must match the current owner. + */ +function release(name, holder) { + const owner = readOwner(name); + if (!owner) { + throw Object.assign(new Error(`no such lock: ${name}`), { code: "ENOLOCK" }); + } + if (owner.holder !== holder) { + throw Object.assign( + new Error(`lock "${name}" is held by ${owner.holder}, not ${holder}`), + { code: "ENOTHOLDER", currentHolder: owner.holder } + ); + } + fs.rmSync(path.join(locksRoot(), name), { recursive: true, force: true }); +} + +/** Current state of one named lock, read-only. */ +function status(name) { + const owner = readOwner(name); + return owner ? { held: true, ...owner } : { held: false }; +} + +/** Every currently-held lock. */ +function listLocks() { + let entries = []; + try { + entries = fs.readdirSync(locksRoot()); + } catch { + return []; + } + return entries + .map((name) => ({ name, ...status(name) })) + .filter((lock) => lock.held) + .map(({ held, ...rest }) => rest); +} + +module.exports = { + LOCK_MAX_HOLD_FLOOR_SEC, + locksRoot, + effectiveMaxHoldSec, + tryAcquire, + release, + status, + listLocks, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/named-lock.test.js` +Expected: PASS (11 tests) + +- [ ] **Step 5: Verify the header audit passes** + +Run: `bash .claude/skills/file-headers/scripts/check-headers.sh` + +- [ ] **Step 6: Commit** + +```bash +git add server/lib/named-lock.js server/__tests__/named-lock.test.js +git commit -m "feat(locks): add cross-lane named locks (mkdir-atomic, staleness floor) (D)" +``` + +--- + +### Task 2: `GET/POST /api/locks` routes + +**Files:** +- Create: `server/routes/locks.js` +- Modify: `server/index.js` (mount the router) +- Test: `server/__tests__/locks-api.test.js` + +**Interfaces:** +- Consumes: `server/lib/named-lock.js`'s `tryAcquire`, `release`, `status`, `listLocks` (Task 1, already committed). +- Produces: the three HTTP routes below, mounted at `/api/locks`. + +Routes: +``` +GET /api/locks -> { locks: [{name, holder, since, ageSec}, ...] } +POST /api/locks/:name/acquire body: { holder: string } + -> 200 { acquired: true } + -> 409 { acquired: false, holder, since, ageSec } + -> 400 { error: { code: "EBADHOLDER", message } } when holder is missing/empty +POST /api/locks/:name/release body: { holder: string } + -> 200 { ok: true } + -> 409 { error: { code: "ENOTHOLDER", message, currentHolder } } + -> 404 { error: { code: "ENOLOCK", message } } +``` + +Mutating routes (`acquire`, `release`) require the same same-origin guard every other mutating lane route uses — reuse `sameOriginGuard` from `server/routes/run.js` (already imported this way in `server/routes/lanes.js`: `const { sameOriginGuard } = require("./run");`). `GET /api/locks` is read-only, no guard, matching `GET /api/lanes/branches`'s precedent. + +- [ ] **Step 1: Write the failing test** + +Create `server/__tests__/locks-api.test.js`: + +```js +/** + * @file Tests for GET/POST /api/locks — the HTTP surface over + * server/lib/named-lock.js. Boots a real Express app + in-memory-ish SQLite + * (temp file) like the other route test files in this suite. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); +const http = require("node:http"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-locks-api-")); +process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const { createApp } = require("../index"); +const namedLock = require("../lib/named-lock"); + +let server; +let PORT; + +before(async () => { + const app = createApp(); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + PORT = server.address().port; +}); +after(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(SUITE_ROOT, { recursive: true, force: true }); +}); + +function request(method, urlPath, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const req = http.request( + { + method, + hostname: "127.0.0.1", + port: PORT, + path: urlPath, + headers: { + "Content-Type": "application/json", + ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}), + }, + }, + (res) => { + let raw = ""; + res.on("data", (chunk) => (raw += chunk)); + res.on("end", () => { + let json = null; + try { + json = JSON.parse(raw); + } catch { + /* empty body is fine for some responses */ + } + resolve({ status: res.statusCode, body: json }); + }); + } + ); + req.on("error", reject); + if (data) req.write(data); + req.end(); + }); +} + +describe("GET /api/locks", () => { + it("lists currently-held locks", async () => { + namedLock.tryAcquire("api-build", "lane1"); + const res = await request("GET", "/api/locks"); + assert.equal(res.status, 200); + assert.ok(res.body.locks.some((l) => l.name === "api-build" && l.holder === "lane1")); + namedLock.release("api-build", "lane1"); + }); +}); + +describe("POST /api/locks/:name/acquire", () => { + it("acquires a free lock", async () => { + const res = await request("POST", "/api/locks/api-acquire/acquire", { holder: "lane1" }); + assert.equal(res.status, 200); + assert.deepEqual(res.body, { acquired: true }); + namedLock.release("api-acquire", "lane1"); + }); + + it("409s with the current holder when already held", async () => { + namedLock.tryAcquire("api-busy", "lane1"); + const res = await request("POST", "/api/locks/api-busy/acquire", { holder: "lane2" }); + assert.equal(res.status, 409); + assert.equal(res.body.acquired, false); + assert.equal(res.body.holder, "lane1"); + namedLock.release("api-busy", "lane1"); + }); + + it("400s when holder is missing", async () => { + const res = await request("POST", "/api/locks/api-nobody/acquire", {}); + assert.equal(res.status, 400); + assert.equal(res.body.error.code, "EBADHOLDER"); + }); +}); + +describe("POST /api/locks/:name/release", () => { + it("releases a held lock", async () => { + namedLock.tryAcquire("api-release", "lane1"); + const res = await request("POST", "/api/locks/api-release/release", { holder: "lane1" }); + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(namedLock.status("api-release").held, false); + }); + + it("409s when the holder doesn't match", async () => { + namedLock.tryAcquire("api-release-2", "lane1"); + const res = await request("POST", "/api/locks/api-release-2/release", { holder: "lane2" }); + assert.equal(res.status, 409); + assert.equal(res.body.error.code, "ENOTHOLDER"); + namedLock.release("api-release-2", "lane1"); + }); + + it("404s when the lock doesn't exist", async () => { + const res = await request("POST", "/api/locks/never-held/release", { holder: "lane1" }); + assert.equal(res.status, 404); + assert.equal(res.body.error.code, "ENOLOCK"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/locks-api.test.js` +Expected: FAIL — 404s across the board (route not mounted). + +- [ ] **Step 3: Write minimal implementation** + +Create `server/routes/locks.js`: + +```js +/** + * @file HTTP surface for cross-lane named locks (`server/lib/named-lock.js`). + * Every route is single-shot — this layer never blocks waiting for a lock to + * free up. The CLI (`ccam lock acquire`) owns the polling loop; CCAM's + * server-side primitives stay non-orchestrating, same as every other lane + * route in this project. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { Router } = require("express"); +const namedLock = require("../lib/named-lock"); +const { sameOriginGuard } = require("./run"); + +const router = Router(); + +router.get("/", (_req, res) => { + res.json({ locks: namedLock.listLocks() }); +}); + +router.post("/:name/acquire", sameOriginGuard, (req, res) => { + const holder = typeof req.body?.holder === "string" ? req.body.holder.trim() : ""; + if (!holder) { + return res + .status(400) + .json({ error: { code: "EBADHOLDER", message: "holder is required" } }); + } + const result = namedLock.tryAcquire(req.params.name, holder); + if (result.acquired) return res.json(result); + res.status(409).json(result); +}); + +router.post("/:name/release", sameOriginGuard, (req, res) => { + const holder = typeof req.body?.holder === "string" ? req.body.holder.trim() : ""; + if (!holder) { + return res + .status(400) + .json({ error: { code: "EBADHOLDER", message: "holder is required" } }); + } + try { + namedLock.release(req.params.name, holder); + res.json({ ok: true }); + } catch (err) { + if (err.code === "ENOLOCK") { + return res.status(404).json({ error: { code: err.code, message: err.message } }); + } + if (err.code === "ENOTHOLDER") { + return res.status(409).json({ + error: { code: err.code, message: err.message, currentHolder: err.currentHolder }, + }); + } + res.status(500).json({ error: { code: err.code, message: err.message } }); + } +}); + +module.exports = router; +``` + +In `server/index.js`, find the block of `app.use("/api/...", ...Router)` lines (search for `app.use("/api/remote-sources", remoteSourcesRouter);` — the last one in the list) and add, right after the `lanesRouter` require line (search for `const lanesRouter = require("./routes/lanes");`): + +```js +const locksRouter = require("./routes/locks"); +``` + +And right after `app.use("/api/lanes", lanesRouter);`: + +```js + app.use("/api/locks", locksRouter); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/locks-api.test.js` +Expected: PASS (7 tests) + +- [ ] **Step 5: Run the full suite and header audit** + +Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh` + +- [ ] **Step 6: Commit** + +```bash +git add server/routes/locks.js server/index.js server/__tests__/locks-api.test.js +git commit -m "feat(locks): expose GET/POST /api/locks over named-lock.js (D)" +``` + +--- + +### Task 3: `ccam lock status|acquire|release` CLI + +**Files:** +- Modify: `bin/ccam.js` + +**Interfaces:** +- Consumes: `GET /api/locks`, `POST /api/locks/:name/acquire`, `POST /api/locks/:name/release` (Task 2, already committed) via the existing `get`/`post` helpers in `bin/ccam.js`. +- Consumes: `resolveLaneArg` (already defined in `bin/ccam.js`, used by `cmdLanesRuntime`/`cmdStage`) to resolve which lane is acting, when no explicit `--holder` is given. + +- [ ] **Step 1: Add the command implementations** + +In `bin/ccam.js`, near `cmdLanesRuntime` (search for that function), add: + +```js +/** + * The default lock holder identity for the calling lane: `lane` when + * the lane has one allocated, else `lane` — a lane's own row id — as a + * fallback for a lane that has never brought its runtime up. `--holder` + * always overrides both. + * + * @param {string[]} argsAfterName - Args AFTER the lock name has already been + * consumed by the caller (mirrors `cmdStage`'s `resolveLaneArg(args.slice(1))` + * call) — `resolveLaneArg` treats a leading all-digits positional as a lane + * id, so the lock name itself must never reach it (a lock literally named + * e.g. "3" would otherwise be misread as lane 3). + */ +async function defaultHolder(argsAfterName) { + const explicit = (() => { + const i = argsAfterName.indexOf("--holder"); + return i > -1 ? argsAfterName[i + 1] : undefined; + })(); + if (explicit) return explicit; + + const resolved = await resolveLaneArg(argsAfterName); + if (!resolved) return null; + const { lane } = await get(`/api/lanes/${resolved.laneId}`); + return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`; +} + +function fmtLockRow(lock) { + const mins = Math.floor(lock.ageSec / 60); + return `${lock.name.padEnd(20)} held by ${lock.holder.padEnd(10)} for ${mins}m`; +} + +/** `ccam lock status []` — one lock, or every held lock. */ +async function cmdLockStatus(args) { + const name = args.find((arg) => !arg.startsWith("--")); + if (name) { + const { locks } = await get("/api/locks"); + const lock = locks.find((l) => l.name === name); + console.log(lock ? fmtLockRow(lock) : `${name}: free`); + return; + } + const { locks } = await get("/api/locks"); + if (!locks.length) { + console.log("no locks held"); + return; + } + for (const lock of locks) console.log(fmtLockRow(lock)); +} + +/** + * `ccam lock acquire [--holder X] [--timeout N]` — polls until the + * lock is free (or `--timeout` seconds elapse). Prints a status line every + * ~60s of continued waiting so a long wait never reads as a hung command — + * this is the CLI-side "heartbeat" the design calls for; it is terminal + * output, not a dashboard liveness signal. + */ +async function cmdLockAcquire(args) { + const name = args.find((arg) => !arg.startsWith("--")); + if (!name) { + console.error("usage: ccam lock acquire [--holder X] [--timeout seconds]"); + process.exitCode = 1; + return; + } + // Strip the lock name before handing args to defaultHolder/resolveLaneArg — + // see defaultHolder's doc comment for why the name must never reach it. + const holder = await defaultHolder(args.filter((a) => a !== name)); + if (!holder) return; // resolveLaneArg already printed an error + + const timeoutIdx = args.indexOf("--timeout"); + const timeoutMs = + timeoutIdx > -1 && args[timeoutIdx + 1] ? Number(args[timeoutIdx + 1]) * 1000 : null; + const deadline = timeoutMs ? Date.now() + timeoutMs : null; + const startedAt = Date.now(); + let lastPrinted = 0; + + for (;;) { + const result = await post(`/api/locks/${encodeURIComponent(name)}/acquire`, { holder }, { + allowError: true, + }); + if (result.status === undefined || result.data?.acquired) { + console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`); + return; + } + if (Date.now() - lastPrinted >= 60_000) { + const waited = Math.floor((Date.now() - startedAt) / 1000); + console.log( + `… still waiting for lock "${name}" (held by ${result.data?.holder ?? "unknown"}, waited ${waited}s)` + ); + lastPrinted = Date.now(); + } + if (deadline && Date.now() >= deadline) { + console.error(`✖ timed out waiting for lock "${name}"`); + process.exitCode = 1; + return; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +} + +/** `ccam lock release [--holder X]`. */ +async function cmdLockRelease(args) { + const name = args.find((arg) => !arg.startsWith("--")); + if (!name) { + console.error("usage: ccam lock release [--holder X]"); + process.exitCode = 1; + return; + } + const holder = await defaultHolder(args.filter((a) => a !== name)); + if (!holder) return; + const result = await post(`/api/locks/${encodeURIComponent(name)}/release`, { holder }, { + allowError: true, + }); + if (result.status) { + console.error(`✖ release lock "${name}" → ${result.data?.error?.message || result.status}`); + process.exitCode = 1; + return; + } + console.log(`${c.green("✔")} released lock "${name}"`); +} +``` + +- [ ] **Step 2: Wire the subcommand dispatch** + +In `bin/ccam.js`'s `runCommand` switch (search for `case "stage":`), add a new case right after the `case "lanes":` block: + +```js + case "lock": { + const sub = rest[0]; + if (sub === "status") return cmdLockStatus(rest.slice(1)); + if (sub === "acquire") return cmdLockAcquire(rest.slice(1)); + if (sub === "release") return cmdLockRelease(rest.slice(1)); + console.error("usage: ccam lock status [] | ccam lock acquire [--holder X] [--timeout N] | ccam lock release [--holder X]"); + process.exitCode = 1; + return; + } +``` + +- [ ] **Step 3: Add the help-table entry** + +In `bin/ccam.js`'s `COMMAND_GROUPS`, in the `"Lanes"` group, add right after the `lanes hook` row: + +```js + [ + "lock status|acquire|release", + "[] [--holder X] [--timeout N]", + "Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)", + ], +``` + +- [ ] **Step 4: Manual smoke test** + +```bash +ccam lock status +ccam lock acquire smoke-test --holder test-a +ccam lock status smoke-test +ccam lock acquire smoke-test --holder test-b --timeout 2 # should time out, exit 1 +ccam lock release smoke-test --holder test-a +ccam lock status smoke-test +``` + +Expected: first acquire succeeds; status shows it held by `test-a`; second acquire (different holder, 2s timeout) prints a timeout error and exits 1; release succeeds; final status shows free. + +- [ ] **Step 5: Commit** + +```bash +git add bin/ccam.js +git commit -m "feat(locks): add ccam lock status/acquire/release CLI (D)" +``` + +--- + +### Task 4: Lane card lock indicator (client) + +**Files:** +- Modify: `client/src/lib/api.ts` +- Modify: `client/src/lib/types.ts` +- Modify: `client/src/components/lanes/LaneCard.tsx` +- Modify: `client/src/i18n/locales/en/lanes.json`, `client/src/i18n/locales/vi/lanes.json` +- Test: `client/src/components/lanes/__tests__/LaneCard.test.tsx` + +**Interfaces:** +- Consumes: `GET /api/locks` (Task 2, already committed), returning `{ locks: NamedLock[] }` where `NamedLock = { name: string, holder: string, since: number, ageSec: number }`. +- Produces: a small badge on `LaneCard` shown when any lock's `holder` matches this lane's identity (`lane${lane.slot}` — read `lane.slot` off the existing `Lane` type, already present from A1). + +- [ ] **Step 1: Add the type and API client method** + +In `client/src/lib/types.ts`, find the `LaneRuntime` type definitions (search for `export type LaneRuntime`) and add nearby: + +```ts +export interface NamedLock { + name: string; + holder: string; + since: number; + ageSec: number; +} +``` + +In `client/src/lib/api.ts`, find the `lanes` API object (search for `runtime: (id: number` inside it, the method `cmdLanesRuntime`'s client-side twin) and add a sibling top-level export near it: + +```ts +export const locks = { + list: (): Promise<{ locks: NamedLock[] }> => apiFetch("/api/locks"), +}; +``` + +(Match the exact `apiFetch` helper name and import style already used by neighboring exports in this file — read the file to confirm the helper's real name before writing this, it may not be literally `apiFetch`.) + +- [ ] **Step 2: Add a polling hook and the badge** + +In `client/src/components/lanes/LaneCard.tsx`, near the existing `useLaneRuntime` hook (search for `RUNTIME_REFRESH_MS`), add a sibling hook following the exact same pattern (state, `useEffect` with `setInterval`, cleanup): + +```tsx +const LOCKS_REFRESH_MS = 30_000; + +/** Locks held by THIS lane, polled the same way runtime/git facts are. */ +function useLaneLocks(laneSlot: number | null) { + const [locks, setLocks] = useState([]); + useEffect(() => { + if (!laneSlot) return; + let cancelled = false; + const holder = `lane${laneSlot}`; + const read = () => { + api.locks + .list() + .then((data) => { + if (!cancelled) setLocks(data.locks.filter((l) => l.holder === holder)); + }) + .catch(() => { + /* fails silently, same contract as the git/runtime pollers */ + }); + }; + read(); + const timer = setInterval(read, LOCKS_REFRESH_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [laneSlot]); + return locks; +} +``` + +Then, inside the `LaneCard` component function, call `const locks = useLaneLocks(lane.slot);` and render a small badge — find where the existing runtime badge renders (search for `data-testid={\`lane-runtime-${lane.id}\`}`) and add, as a sibling conditional block right after it: + +```tsx +{locks.length > 0 && ( +
`${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")} + > + 🔒 {t("locks.held", { count: locks.length })} +
+)} +``` + +Read the file first to match the exact className tokens (`text-status-warning` may not be the real token name — use whatever this file already uses for an "attention" color elsewhere, e.g. the same class the `needs_action` banner uses) and the exact `t()` i18n call convention already used in this file. + +- [ ] **Step 3: Add the i18n strings** + +In `client/src/i18n/locales/en/lanes.json`, find the `"runtime"` key's object and add a sibling `"locks"` key: + +```json +"locks": { + "held": "{{count}} lock held" +} +``` + +(Add the pluralized `"locks_other"` or `"held_other"` form too, matching whatever pluralization convention this repo's i18next setup already uses elsewhere in the same file — check an existing count-based string for the exact key suffix convention before adding this.) + +In `client/src/i18n/locales/vi/lanes.json`, add the Vietnamese translation at the same key path: `"locks": { "held": "Đang giữ {{count}} khóa" }` (plus its plural-form key if Vietnamese needs one per this repo's i18next config — Vietnamese has no grammatical plural, so it may only need the base key; check the convention). + +- [ ] **Step 4: Write a test** + +In `client/src/components/lanes/__tests__/LaneCard.test.tsx`, find an existing test that mocks `api.lanes.runtime` and follow the same mocking pattern for `api.locks.list`. Add: + +```tsx +it("shows a lock badge when this lane holds a named lock", async () => { + vi.mocked(api.locks.list).mockResolvedValue({ + locks: [{ name: "build", holder: "lane3", since: 0, ageSec: 120 }], + }); + // render a lane with slot: 3, matching the mocked lock's holder + // ... follow this file's existing render + waitFor pattern for the runtime badge test ... + // assert screen.getByTestId(`lane-locks-${lane.id}`) is present +}); + +it("shows no lock badge when locks belong to a different lane", async () => { + vi.mocked(api.locks.list).mockResolvedValue({ + locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }], + }); + // render a lane with slot: 3 (does not match "lane99") + // assert screen.queryByTestId(`lane-locks-${lane.id}`) is null +}); +``` + +Read the existing runtime-badge test in this same file first and mirror its exact render/mock/waitFor structure — don't guess at the test utilities this file imports. + +- [ ] **Step 5: Run the client test suite** + +Run: `npm run test:client` +Expected: green, including the two new tests. + +- [ ] **Step 6: Commit** + +```bash +git add client/src/lib/api.ts client/src/lib/types.ts client/src/components/lanes/LaneCard.tsx client/src/i18n/locales/en/lanes.json client/src/i18n/locales/vi/lanes.json client/src/components/lanes/__tests__/LaneCard.test.tsx +git commit -m "feat(locks): show a lock indicator on the lane card (D)" +``` + +--- + +### Task 5: Documentation + +**Files:** +- Modify: `docs/LANES.md` +- Modify: `docs/CLI.md` +- Modify: `docs/API.md` +- Modify: `ARCHITECTURE.md` +- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md` (mark D done) + +**Interfaces:** none — documentation only. + +- [ ] **Step 1: `docs/LANES.md`** + +Add a new top-level section (after the "Lane actions" section, before "Orchestration: what CCAM does NOT do" — search for both headings to place it precisely) titled `## Cross-lane named locks`, covering: +- What they're for (serializing a machine-thrashing step like a build or e2e run across every lane, not just within one lane — the other axis from the per-lane `withLaneLock` used internally by destructive actions). +- The `LOCK_MAX_HOLD` env var (default 2700s / 45 min) and its 300s floor — a holder can never be force-broken by setting `LOCK_MAX_HOLD` below 300, even to `1`. +- The CLI: `ccam lock status []`, `ccam lock acquire [--holder X] [--timeout N]`, `ccam lock release [--holder X]` — holder defaults to `lane` for the calling lane. +- **The etiquette text, verbatim in spirit from Shipyard**: waiting for a lock is normal, not a failure; never kill a holder to "fix" a stuck wait; never delete the lock directory (`LANES_ROOT/.locks//`) by hand; never shrink `LOCK_MAX_HOLD` to force through a wait — the 300s floor exists specifically to stop that. +- The lane card's lock badge (client-side, polled every 30s like the git/runtime facts). + +- [ ] **Step 2: `docs/CLI.md`** + +Add to the `### Lanes` table, after the `lanes hook` row: + +```markdown +| `ccam lock status []` | Show one lock's holder, or every currently-held lock | +| `ccam lock acquire [--holder X] [--timeout N]` | Acquire a cross-lane named lock, polling until free (or `--timeout` seconds elapse). Holder defaults to the calling lane (`lane`) | +| `ccam lock release [--holder X]` | Release a lock. Refused (409) when `--holder` doesn't match the current owner | +``` + +- [ ] **Step 3: `docs/API.md`** + +Add a new section documenting `GET /api/locks`, `POST /api/locks/:name/acquire`, `POST /api/locks/:name/release` — request/response bodies and status codes exactly as specified in Task 2 above. Follow this doc's existing style (a `#### ` heading, an ` ```http ` block, then prose, matching the pattern used for the lanes runtime routes just above it). + +- [ ] **Step 4: `ARCHITECTURE.md`** + +Add a new row to the module responsibility table (search for the `lib/lane-lock.js` row if one exists, or place alphabetically near the other `lib/lane-*` rows): + +```markdown +| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `ccam lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive | +``` + +- [ ] **Step 5: Mark D done in the parent plan** + +In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, update the status table row for `**D**` to `✅ **done** `, and update the `## Order` diagram/prose the same way A1/A2/A3 were marked done (search for `| **D** |` and for the `## Order` section's ASCII diagram). + +- [ ] **Step 6: Verify and commit** + +```bash +bash .claude/skills/file-headers/scripts/check-headers.sh +npm run test:server +``` + +```bash +git add docs/LANES.md docs/CLI.md docs/API.md ARCHITECTURE.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md +git commit -m "docs(locks): document cross-lane named locks (D)" +``` diff --git a/docs/superpowers/plans/2026-08-04-lane-feature-state.md b/docs/superpowers/plans/2026-08-04-lane-feature-state.md new file mode 100644 index 0000000..78d9573 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-lane-feature-state.md @@ -0,0 +1,1175 @@ +# Per-Feature State and Archive (B) 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:** a lane's history survives switching features. Today `clearLane` erases the live row's bookkeeping; this makes `clearLane` archive it first into a new `lane_features` table, and adds `activate`/`list`/`show` so a lane can carry many features across its lifetime, each independently browsable after the lane has moved on. + +**Architecture:** One new table (`lane_features`) plus one new nullable column on `lanes` (`active_feature_id`). One new library module, `server/lib/lane-features.js`, owning slug canonicalization, archive-on-switch, and read access — the `lanes` row itself stays the single live view (nothing downstream that already reads a lane's `stage`/`status`/etc. needs to change). Routes are added inline to the existing `server/routes/lanes.js` (matching how `/:id/git`, `/:id/preflight`, `/:id/runtime` are already sub-resources of that same file, not separate routers). CLI gains `ccam feature list|activate|show`. The Workspace UI gains a read-only feature picker that swaps the detail panel to an archived snapshot — it never mutates anything, matching the standing rule that the console never writes a lane's stage. + +**Tech Stack:** better-sqlite3 (existing), Express (existing), the existing `server/lib/pipelines.js` node-state renderer reused verbatim for archived snapshots. + +## Global Constraints + +- Every applicable source file MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`. +- **Slug canonicalization is a separate function from `worktree.js:slugify`.** That function lowercases and replaces every non-alphanumeric run (including `.` and `_`) with a dash — it exists for git branch names. This plan's slug keeps `[A-Za-z0-9._-]`, strips a leading `feat/`, and turns `/`/whitespace into `-`, **without lowercasing**. The two must never be conflated or one silently swapped for the other. +- **The canonicalized slug is always echoed back** by every endpoint/CLI command that accepts one, so a caller stores what the server actually stored, never what it typed. +- **The `lanes` row stays the live view.** Nothing that already reads `lane.stage`/`lane.status`/etc. changes shape or meaning. `lane_features` is purely additive. +- **Archiving only happens when there is an active feature to archive.** A lane that never calls `activate` keeps `clearLane`'s exact pre-existing behavior (reset, no archive row) — this feature is opt-in, not a breaking change to every lane's `clear` action. +- **The UI feature viewer is read-only.** It calls `GET /:id/features` and `GET /:id/features/:slug` only, never `POST /:id/features/activate` — the console never writes a lane's stage, and viewing an archived feature must not be able to switch the live one. +- Run `npm run test:server` (full suite) and `npm run test:client` (when a task touches `client/`) plus `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit. This repo's pre-commit hook already enforces both suites; a clean run here avoids a blocked commit. +- Never use `git add -A`. Stage exactly the files each task names. + +--- + +### Task 1: Schema + `server/lib/lane-features.js` core + +**Files:** +- Modify: `server/db.js` (migration) +- Create: `server/lib/lane-features.js` +- Test: `server/__tests__/lane-features.test.js` + +**Interfaces:** +- Produces: `canonicalizeSlug(input)` → `string`, throws `Object.assign(new Error(...), {code: "EBADSLUG"})` on an empty result; `listFeatures(laneId)` → `Array` (most recently touched first); `getFeature(laneId, slug)` → `FeatureRow | null`; `activateFeature(laneId, slug, {title} = {})` → `{lane: LaneRow, feature: FeatureRow}`; `archiveActiveFeature(laneId)` → `FeatureRow | null` (the archived row, or `null` when there was no active feature — used by Task 2's `clearLane` change). +- `FeatureRow` shape (hydrated, matching the `lanes` row hydration convention): `{id, lane_id, slug, title, branch, pipeline, stage, stage_since, status, gate_decision, ci_status, qc_dev, stages: object, links: object, notes, archived_at, created_at, updated_at}`. + +**Schema** (add to `server/db.js`, following the file's existing migration convention — `CREATE TABLE IF NOT EXISTS` inside the main `db.exec` block near the `lanes` table definition, since this is a new table with no legacy rows to migrate): + +```sql +CREATE TABLE IF NOT EXISTS lane_features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lane_id INTEGER NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + branch TEXT, + pipeline TEXT NOT NULL DEFAULT 'default', + stage TEXT NOT NULL DEFAULT 'idle', + stage_since TEXT, + status TEXT NOT NULL DEFAULT 'idle', + gate_decision TEXT, + ci_status TEXT, + qc_dev TEXT, + stages TEXT NOT NULL DEFAULT '{}', + links TEXT NOT NULL DEFAULT '{}', + notes TEXT, + archived_at 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')), + UNIQUE (lane_id, slug), + FOREIGN KEY (lane_id) REFERENCES lanes(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_lane_features_lane ON lane_features(lane_id); +``` + +Then, as a **separate, additive migration block** right after the existing lane-runtime migration block (search for `idx_lanes_slot` — the A1 slot/ports migration — and add this immediately after it, following the exact same "probe one column, ALTER if missing" pattern every other lanes-table migration in this file already uses): + +```js +// Migrate: per-feature state (B). `active_feature_id` points at the +// lane_features row currently "live" (unarchived) for this lane — null for a +// lane that has never called `ccam feature activate`, which is why this +// column is nullable and every downstream reader of a lane row is unaffected +// by its addition. ON DELETE SET NULL, not CASCADE: deleting the ACTIVE +// feature row (which normally only happens via cascade when the LANE itself +// is deleted, at which point this column is moot anyway) must never leave a +// dangling id on a lane row that still exists. +try { + db.prepare("SELECT active_feature_id FROM lanes LIMIT 1").get(); +} catch { + db.prepare( + "ALTER TABLE lanes ADD COLUMN active_feature_id INTEGER REFERENCES lane_features(id) ON DELETE SET NULL" + ).run(); +} +``` + +- [ ] **Step 1: Write the failing test** + +Create `server/__tests__/lane-features.test.js`: + +```js +/** + * @file Tests for server/lib/lane-features.js: slug canonicalization, + * activate/archive semantics, and read access to a lane's feature history. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-features-")); +process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); + +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const lanesLib = require("../lib/lanes"); +const features = require("../lib/lane-features"); + +after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); + +let laneSeq = 0; +function makeLane() { + laneSeq += 1; + const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); + fs.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); +} + +describe("canonicalizeSlug", () => { + it("drops a leading feat/ prefix", () => { + assert.equal(features.canonicalizeSlug("feat/my-thing"), "my-thing"); + }); + + it("turns slashes and spaces into a single flat dash-separated segment", () => { + assert.equal(features.canonicalizeSlug("feat/some thing/ nested"), "some-thing-nested"); + }); + + it("keeps dots, underscores, and case as-is (unlike worktree.js:slugify)", () => { + assert.equal(features.canonicalizeSlug("My_Feature.v2"), "My_Feature.v2"); + }); + + it("collapses repeated separators and trims leading/trailing dashes", () => { + assert.equal(features.canonicalizeSlug("feat//too many///slashes/"), "too-many-slashes"); + }); + + it("refuses an empty result", () => { + assert.throws(() => features.canonicalizeSlug("feat/"), (err) => err.code === "EBADSLUG"); + assert.throws(() => features.canonicalizeSlug(" "), (err) => err.code === "EBADSLUG"); + }); +}); + +describe("activateFeature / archiveActiveFeature", () => { + it("activating a brand-new slug creates a live (unarchived) feature row and points the lane at it", () => { + const lane = makeLane(); + const { lane: updated, feature } = features.activateFeature(lane.id, "feat/one"); + assert.equal(feature.slug, "one"); + assert.equal(feature.archived_at, null); + assert.equal(updated.active_feature_id, feature.id); + }); + + it("activating a second slug archives the first with its final stage intact", () => { + const lane = makeLane(); + features.activateFeature(lane.id, "one"); + lanesLib.setStage(lane.id, { stage: "review", evidence: "looks good" }); + + const { feature: second } = features.activateFeature(lane.id, "two"); + assert.equal(second.slug, "two"); + assert.equal(second.archived_at, null); + + const first = features.getFeature(lane.id, "one"); + assert.notEqual(first.archived_at, null); + assert.equal(first.stage, "review"); + assert.deepEqual(first.stages.review.evidence, "looks good"); + }); + + it("re-activating an archived slug restores its saved stage onto the live lane row", () => { + const lane = makeLane(); + features.activateFeature(lane.id, "one"); + lanesLib.setStage(lane.id, { stage: "implement" }); + features.activateFeature(lane.id, "two"); // archives "one" at stage=implement + + const { lane: reactivated } = features.activateFeature(lane.id, "one"); + assert.equal(reactivated.stage, "implement"); + assert.equal(features.getFeature(lane.id, "one").archived_at, null); + assert.notEqual(features.getFeature(lane.id, "two").archived_at, null); + }); + + it("re-activating the CURRENTLY active slug is a no-op, not a self-archive", () => { + const lane = makeLane(); + features.activateFeature(lane.id, "one"); + lanesLib.setStage(lane.id, { stage: "review" }); + const { lane: updated } = features.activateFeature(lane.id, "one"); + assert.equal(updated.stage, "review"); + assert.equal(features.getFeature(lane.id, "one").archived_at, null); + }); + + it("archiveActiveFeature returns null and touches nothing when no feature is active", () => { + const lane = makeLane(); + assert.equal(features.archiveActiveFeature(lane.id), null); + }); + + it("echoes back the canonicalized slug, not the caller's raw input", () => { + const lane = makeLane(); + const { feature } = features.activateFeature(lane.id, "feat/Weird Input/"); + assert.equal(feature.slug, "Weird-Input"); + }); +}); + +describe("listFeatures / getFeature", () => { + it("lists every feature for a lane, most recently touched first", () => { + const lane = makeLane(); + features.activateFeature(lane.id, "one"); + features.activateFeature(lane.id, "two"); + const list = features.listFeatures(lane.id); + assert.deepEqual(list.map((f) => f.slug), ["two", "one"]); + }); + + it("getFeature returns null for an unknown slug", () => { + const lane = makeLane(); + assert.equal(features.getFeature(lane.id, "never-activated"), null); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/lane-features.test.js` +Expected: FAIL — `Cannot find module '../lib/lane-features'` + +- [ ] **Step 3: Apply the schema migration** + +Make the two schema edits to `server/db.js` shown above (the `CREATE TABLE`/`CREATE INDEX` inside the main `db.exec(...)` block near the `lanes` table, and the separate `active_feature_id` ALTER-probe block right after the A1 slot/ports migration). + +- [ ] **Step 4: Write `server/lib/lane-features.js`** + +```js +/** + * @file Per-feature state and archive (B). A lane's `stage`/`status`/etc. is + * the LIVE view of whichever feature it's currently working on; this module + * lets a lane carry many features across its lifetime by snapshotting the + * live row into `lane_features` whenever the lane switches (or is cleared), + * and restoring a feature's saved state when it's switched back to. + * + * The `lanes` row itself never changes shape — every existing reader of a + * lane keeps working unmodified. Only `lanes.active_feature_id` (nullable) + * is new there, pointing at the currently-live (unarchived) feature row, or + * null for a lane that has never called `activate`. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { db } = require("../db"); +const lanesLib = require("./lanes"); + +const nowIso = () => new Date().toISOString(); + +/** + * Canonicalize a feature slug: drop a leading `feat/`, turn `/` and + * whitespace runs into a single `-`, keep only `[A-Za-z0-9._-]`, collapse + * repeated `-`, trim leading/trailing `-`. Deliberately does NOT lowercase — + * a separate function from `worktree.js:slugify` (that one exists for git + * branch names and lowercases everything), never reused here, never let the + * two drift onto the same rule by accident. + * + * @param {string} input + * @returns {string} + * @throws {Error} EBADSLUG when the result is empty. + */ +function canonicalizeSlug(input) { + const withoutPrefix = String(input || "").replace(/^feat\//, ""); + const result = withoutPrefix + .replace(/[\s/]+/g, "-") + .replace(/[^A-Za-z0-9._-]/g, "") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!result) { + throw Object.assign(new Error("slug is empty"), { code: "EBADSLUG" }); + } + return result; +} + +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 }; +} + +/** Every feature a lane has ever activated, most recently touched first. */ +function listFeatures(laneId) { + return db + .prepare("SELECT * FROM lane_features WHERE lane_id = ? ORDER BY updated_at DESC") + .all(laneId) + .map(hydrate); +} + +/** One feature by slug, or null. */ +function getFeature(laneId, slug) { + return hydrate( + db.prepare("SELECT * FROM lane_features WHERE lane_id = ? AND slug = ?").get(laneId, slug) + ); +} + +function getFeatureById(id) { + return hydrate(db.prepare("SELECT * FROM lane_features WHERE id = ?").get(id)); +} + +/** + * Snapshot a lane's CURRENT live bookkeeping into its active feature row + * (if it has one) and mark that row archived. Returns the archived row, or + * null when the lane has no active feature — archiving is opt-in, so a lane + * that never called `activate` is untouched. + * + * Does NOT reset the live `lanes` row — that stays the caller's job + * (`clearLane` resets after archiving; `activateFeature` overwrites the live + * row with the newly-activated feature's saved state instead of resetting). + * + * @param {number} laneId + * @returns {object|null} The archived feature row. + */ +function archiveActiveFeature(laneId) { + const lane = lanesLib.getLane(laneId); + if (!lane || !lane.active_feature_id) return null; + const active = getFeatureById(lane.active_feature_id); + if (!active) return null; + + db.prepare( + `UPDATE lane_features SET + title = ?, branch = ?, pipeline = ?, stage = ?, stage_since = ?, status = ?, + gate_decision = ?, ci_status = ?, qc_dev = ?, stages = ?, links = ?, notes = ?, + archived_at = ?, updated_at = ? + WHERE id = ?` + ).run( + lane.title, + lane.branch, + lane.pipeline, + lane.stage, + lane.stage_since, + lane.status, + lane.gate_decision, + lane.ci_status, + active.qc_dev, // qc_dev has no equivalent on `lanes` — carried over from the feature row itself, untouched by the live lane + JSON.stringify(lane.stages || {}), + JSON.stringify(lane.links || {}), + lane.notes, + nowIso(), + nowIso(), + active.id + ); + return getFeatureById(active.id); +} + +/** + * Activate a feature by slug: archive the currently-active feature (if any, + * and if it isn't this same slug), find-or-create the target feature row, + * copy ITS saved bookkeeping onto the live `lanes` row (so switching back to + * a past feature resumes where it left off — a brand-new slug copies in + * fresh defaults), and point `lanes.active_feature_id` at it. + * + * Re-activating the CURRENTLY active slug is a no-op on the archive step — + * the live row already IS that feature's state, so there's nothing to + * restore and nothing to archive. + * + * @param {number} laneId + * @param {string} rawSlug - Canonicalized internally; the caller's raw input is never stored. + * @param {{title?: string}} [options] + * @returns {{lane: object, feature: object}} + */ +function activateFeature(laneId, rawSlug, options = {}) { + const slug = canonicalizeSlug(rawSlug); + const lane = lanesLib.getLane(laneId); + if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" }); + + const current = lane.active_feature_id ? getFeatureById(lane.active_feature_id) : null; + if (current && current.slug === slug) { + return { lane, feature: current }; + } + + if (current) archiveActiveFeature(laneId); + + let target = getFeature(laneId, slug); + if (!target) { + const info = db + .prepare( + `INSERT INTO lane_features (lane_id, slug, title, branch, pipeline, stage, stage_since, status, stages, links, notes) + VALUES (?, ?, ?, ?, ?, 'idle', ?, 'idle', '{}', '{}', NULL)` + ) + .run(laneId, slug, options.title || slug, lane.branch, lane.pipeline, nowIso()); + target = getFeatureById(info.lastInsertRowid); + } else { + // Un-archive it — it's about to become the live view again. + db.prepare("UPDATE lane_features SET archived_at = NULL, updated_at = ? WHERE id = ?").run( + nowIso(), + target.id + ); + target = getFeatureById(target.id); + } + + db.prepare( + `UPDATE lanes SET + stage = ?, stage_since = ?, status = ?, gate_decision = ?, ci_status = ?, + stages = ?, notes = ?, active_feature_id = ?, updated_at = ? + WHERE id = ?` + ).run( + target.stage, + target.stage_since, + target.status, + target.gate_decision, + target.ci_status, + JSON.stringify(target.stages || {}), + target.notes, + target.id, + nowIso(), + laneId + ); + + return { lane: lanesLib.getLane(laneId), feature: getFeatureById(target.id) }; +} + +module.exports = { + canonicalizeSlug, + listFeatures, + getFeature, + activateFeature, + archiveActiveFeature, +}; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `node --test server/__tests__/lane-features.test.js` +Expected: PASS (14 tests) + +- [ ] **Step 6: Run the full suite and header audit** + +Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh` + +- [ ] **Step 7: Commit** + +```bash +git add server/db.js server/lib/lane-features.js server/__tests__/lane-features.test.js +git commit -m "feat(lanes): add per-feature state + archive core (lane_features) (B)" +``` + +--- + +### Task 2: Wire archiving into `clearLane` + +**Files:** +- Modify: `server/lib/lanes.js` +- Test: `server/__tests__/lanes.test.js` (this repo already has lane tests under this or a similarly-named file — search `server/__tests__/` for the existing `clearLane` test with `grep -rn "clearLane" server/__tests__/*.test.js` and add to that same file; do not create a new one if `clearLane` is already covered somewhere) + +**Interfaces:** +- Consumes: `server/lib/lane-features.js`'s `archiveActiveFeature` (Task 1, already committed). +- Modifies: `lanesLib.clearLane(id)` — same signature and return value (`getLane(id)`) as before; behavior changes ONLY for a lane with `active_feature_id` set. + +- [ ] **Step 1: Find `clearLane` and the existing test(s) covering it** + +Run: `grep -n "function clearLane" server/lib/lanes.js` and `grep -rln "clearLane" server/__tests__/*.test.js` + +Read the current `clearLane` implementation and its existing test coverage before changing anything — this task must not remove or weaken any existing assertion about what `clearLane` resets. + +- [ ] **Step 2: Write the failing test** + +Add to whichever existing test file covers `clearLane` (or `server/__tests__/lanes.test.js` if `clearLane` has no dedicated test yet): + +```js +describe("clearLane archives the active feature first", () => { + it("archives the active feature with its final stage before resetting the live row", () => { + const lanesLib = require("../lib/lanes"); + const features = require("../lib/lane-features"); + const lane = lanesLib.createLane({ title: "t", cwd: makeLaneCwd(), kind: "managed" }); // use this file's existing lane-fixture helper + features.activateFeature(lane.id, "one"); + lanesLib.setStage(lane.id, { stage: "review", evidence: "e" }); + + lanesLib.clearLane(lane.id); + + const archived = features.getFeature(lane.id, "one"); + assert.notEqual(archived.archived_at, null); + assert.equal(archived.stage, "review"); + const cleared = lanesLib.getLane(lane.id); + assert.equal(cleared.stage, "idle"); + assert.equal(cleared.active_feature_id, null); + }); + + it("is unchanged for a lane that never activated a feature (no archive row created)", () => { + const lanesLib = require("../lib/lanes"); + const lane = lanesLib.createLane({ title: "t2", cwd: makeLaneCwd(), kind: "managed" }); + lanesLib.setStage(lane.id, { stage: "review" }); + lanesLib.clearLane(lane.id); + const cleared = lanesLib.getLane(lane.id); + assert.equal(cleared.stage, "idle"); + }); +}); +``` + +Adapt the lane-creation calls to whatever cwd-fixture helper the target test file already uses (do not invent a new one — read the file first). + +- [ ] **Step 3: Run test to verify it fails** + +Run: `node --test ` +Expected: FAIL — the active feature is never archived (still shows `archived_at: null`). + +- [ ] **Step 4: Modify `clearLane`** + +In `server/lib/lanes.js`, find `function clearLane(id) {` and add the archive step immediately before the existing `UPDATE lanes SET stage = 'idle', ...` statement, and add `active_feature_id = NULL` to that same UPDATE's column list: + +```js +function clearLane(id) { + // Opt-in: only a lane that has activated a feature has anything to archive. + // Requiring the module here (not at file top) avoids a require cycle — + // lane-features.js itself requires this file for lanesLib.getLane/setStage. + require("./lane-features").archiveActiveFeature(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, + detected_stage = NULL, detected_signal = NULL, detected_at = NULL, + active_feature_id = NULL, + updated_at = ? WHERE id = ?` + ).run(nowIso(), nowIso(), id); + return getLane(id); +} +``` + +(Read the exact current SQL text first with `grep -n -A6 "function clearLane" server/lib/lanes.js` — the snippet above must be merged into whatever that statement's exact current column list is, not overwrite unrelated columns.) + +- [ ] **Step 5: Run test to verify it passes** + +Run: `node --test ` +Expected: PASS + +- [ ] **Step 6: Run the full suite and header audit** + +Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh` + +- [ ] **Step 7: Commit** + +```bash +git add server/lib/lanes.js +git commit -m "feat(lanes): clearLane archives the active feature before resetting (B)" +``` + +--- + +### Task 3: `GET/POST /api/lanes/:id/features…` routes + +**Files:** +- Modify: `server/routes/lanes.js` +- Test: `server/__tests__/lane-features-api.test.js` + +**Interfaces:** +- Consumes: `server/lib/lane-features.js`'s `listFeatures`, `getFeature`, `activateFeature` (Task 1, already committed). +- Consumes: `server/lib/pipelines.js`'s `getPipeline`, `nodeStates`, `progressPct` (already exported — reused to compute a `pipeline_nodes`/`progress` view on each feature row, the same shape `lanePayload()` already computes for the live lane, so the client's `PipelineMap` component can render an archived feature identically to a live one). + +Routes (register in the same file, same style, near the other `/:id/*` sub-resources — search for `router.get("/:id/git"` and add these nearby, **before** the `/:id/:action` catch-all so `features` is never swallowed as an unknown action, same reasoning already documented above that catch-all for `up`/`down`/etc.): + +``` +GET /api/lanes/:id/features -> { features: [FeaturePayload, ...] } +GET /api/lanes/:id/features/:slug -> { feature: FeaturePayload } (404 ENOFEATURE if absent) +POST /api/lanes/:id/features/activate -> { lane: , feature: FeaturePayload } + body: { slug: string, title?: string } +``` + +`FeaturePayload` = the hydrated `lane_features` row plus `pipeline_nodes` and `progress`, computed the same way `lanePayload()` computes them for a live lane (`getPipeline(feature.pipeline)`, then `nodeStates`/`progressPct` against `{stage: feature.stage, stages: feature.stages}`). + +- [ ] **Step 1: Write the failing test** + +Create `server/__tests__/lane-features-api.test.js`: + +```js +/** + * @file Tests for GET/POST /api/lanes/:id/features… — the HTTP surface over + * server/lib/lane-features.js. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); +const http = require("node:http"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-features-api-")); +process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const { createApp } = require("../index"); +const lanesLib = require("../lib/lanes"); + +let server; +let PORT; + +before(async () => { + const app = createApp(); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + PORT = server.address().port; +}); +after(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(SUITE_ROOT, { recursive: true, force: true }); +}); + +function request(method, urlPath, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const req = http.request( + { + method, + hostname: "127.0.0.1", + port: PORT, + path: urlPath, + headers: { + "Content-Type": "application/json", + ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}), + }, + }, + (res) => { + let raw = ""; + res.on("data", (chunk) => (raw += chunk)); + res.on("end", () => { + let json = null; + try { + json = JSON.parse(raw); + } catch { + /* empty body ok */ + } + resolve({ status: res.statusCode, body: json }); + }); + } + ); + req.on("error", reject); + if (data) req.write(data); + req.end(); + }); +} + +let laneSeq = 0; +function makeLane() { + laneSeq += 1; + const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); + fs.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); +} + +describe("POST /api/lanes/:id/features/activate", () => { + it("activates a new feature and echoes the canonicalized slug", async () => { + const lane = makeLane(); + const res = await request("POST", `/api/lanes/${lane.id}/features/activate`, { + slug: "feat/My Thing", + }); + assert.equal(res.status, 200); + assert.equal(res.body.feature.slug, "My-Thing"); + assert.equal(res.body.lane.active_feature_id, res.body.feature.id); + }); +}); + +describe("GET /api/lanes/:id/features", () => { + it("lists every feature with computed pipeline_nodes", async () => { + const lane = makeLane(); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" }); + const res = await request("GET", `/api/lanes/${lane.id}/features`); + assert.equal(res.status, 200); + assert.equal(res.body.features.length, 1); + assert.ok(Array.isArray(res.body.features[0].pipeline_nodes)); + }); +}); + +describe("GET /api/lanes/:id/features/:slug", () => { + it("returns one feature by slug", async () => { + const lane = makeLane(); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" }); + const res = await request("GET", `/api/lanes/${lane.id}/features/one`); + assert.equal(res.status, 200); + assert.equal(res.body.feature.slug, "one"); + }); + + it("404s ENOFEATURE for an unknown slug", async () => { + const lane = makeLane(); + const res = await request("GET", `/api/lanes/${lane.id}/features/never`); + assert.equal(res.status, 404); + assert.equal(res.body.error.code, "ENOFEATURE"); + }); + + it("shows an archived feature's final stage after the lane moves on", async () => { + const lane = makeLane(); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" }); + await request("POST", `/api/lanes/${lane.id}/stage`, { stage: "review" }); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "two" }); + + const res = await request("GET", `/api/lanes/${lane.id}/features/one`); + assert.equal(res.body.feature.stage, "review"); + assert.notEqual(res.body.feature.archived_at, null); + }); +}); + +describe("DELETE /api/lanes/:id cascades to its features", () => { + it("removes every lane_features row for a deleted lane", async () => { + const lane = makeLane(); + await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" }); + await request("DELETE", `/api/lanes/${lane.id}`); + const { db } = require("../db"); + const rows = db.prepare("SELECT * FROM lane_features WHERE lane_id = ?").all(lane.id); + assert.equal(rows.length, 0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/lane-features-api.test.js` +Expected: FAIL — 404s across the board (routes not registered). + +- [ ] **Step 3: Add the routes** + +In `server/routes/lanes.js`, add near the top imports: + +```js +const laneFeatures = require("../lib/lane-features"); +const { getPipeline, nodeStates, progressPct } = require("../lib/pipelines"); +``` + +(If `getPipeline`/`nodeStates`/`progressPct` are already imported under different names in this file, e.g. via `require("../lib/pipelines")` as a namespace — check the top of the file first with `grep -n "require(\"../lib/pipelines\")" server/routes/lanes.js` — reuse the existing import instead of adding a second one.) + +Add this helper near `payload()` (search for `function payload(lane)`): + +```js +/** A feature row's pipeline view, computed the same way payload() computes + * it for a live lane — lets the client render an archived feature with the + * exact same PipelineMap component, no special-casing on the frontend. */ +function featurePayload(feature) { + const pipeline = getPipeline(feature.pipeline); + return { + ...feature, + pipeline_name: pipeline.name, + pipeline_nodes: nodeStates(pipeline, feature), + progress: progressPct(pipeline, feature), + }; +} +``` + +Add the routes right before the `/:id/git` route (search for `router.get("/:id/git"` — these must land **before** it is fine since Express matches literal-then-param paths in registration order and `/:id/features` vs `/:id/git` don't collide, but placing them together keeps every `/:id/*` read sub-resource grouped): + +```js +router.get("/:id/features", (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({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) }); +}); + +router.get("/:id/features/:slug", (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); + const feature = laneFeatures.getFeature(lane.id, req.params.slug); + if (!feature) { + return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } }); + } + res.json({ feature: featurePayload(feature) }); +}); + +router.post("/:id/features/activate", sameOriginGuard, (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); + const slug = typeof req.body?.slug === "string" ? req.body.slug : ""; + if (!slug) { + return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } }); + } + try { + const { lane: updated, feature } = laneFeatures.activateFeature(lane.id, slug, { + title: req.body?.title, + }); + broadcastLane(updated.id); + res.json({ lane: payload(updated), feature: featurePayload(feature) }); + } catch (err) { + if (err.code === "EBADSLUG") { + return res.status(400).json({ error: { code: err.code, message: err.message } }); + } + res.status(500).json({ error: { code: err.code, message: err.message } }); + } +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/lane-features-api.test.js` +Expected: PASS (7 tests). The `DELETE /:id` cascade test relies on the `FOREIGN KEY ... ON DELETE CASCADE` from Task 1's migration and `PRAGMA foreign_keys = ON` (already set globally in `server/db.js` — confirm with `grep -n "foreign_keys" server/db.js` rather than assuming). + +- [ ] **Step 5: Run the full suite and header audit** + +Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh` + +- [ ] **Step 6: Commit** + +```bash +git add server/routes/lanes.js server/__tests__/lane-features-api.test.js +git commit -m "feat(lanes): expose GET/POST /api/lanes/:id/features over lane-features.js (B)" +``` + +--- + +### Task 4: `ccam feature list|activate|show` CLI + +**Files:** +- Modify: `bin/ccam.js` + +**Interfaces:** +- Consumes: `GET /api/lanes/:id/features`, `GET /api/lanes/:id/features/:slug`, `POST /api/lanes/:id/features/activate` (Task 3, already committed) via the existing `get`/`post` helpers. +- Consumes: `resolveLaneArg` (already defined in `bin/ccam.js`) to resolve which lane a command targets. + +- [ ] **Step 1: Add the command implementations** + +In `bin/ccam.js`, near `cmdStage` (search for that function), add: + +```js +function fmtFeatureRow(f) { + const marker = f.archived_at ? " " : "▶ "; + return `${marker}${f.slug.padEnd(24)} ${String(f.stage).padEnd(12)} ${f.progress}%${ + f.archived_at ? ` (archived ${fmtTime(f.archived_at)})` : "" + }`; +} + +/** `ccam feature list [] [--cwd path]` — every feature this lane has activated. */ +async function cmdFeatureList(args) { + const resolved = await resolveLaneArg(args); + if (!resolved) return; + const { features } = await get(`/api/lanes/${resolved.laneId}/features`); + if (!features.length) { + console.log("no features activated yet — start one with: ccam feature activate "); + return; + } + for (const f of features) console.log(fmtFeatureRow(f)); +} + +/** `ccam feature activate [--title X] [] [--cwd path]`. */ +async function cmdFeatureActivate(args) { + const slug = args.find((arg) => !arg.startsWith("--")); + if (!slug) { + console.error("usage: ccam feature activate [--title text]"); + process.exitCode = 1; + return; + } + const flag = (name) => { + const i = args.indexOf(`--${name}`); + return i > -1 ? args[i + 1] : undefined; + }; + const resolved = await resolveLaneArg(args.filter((a) => a !== slug)); + if (!resolved) return; + const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, { + slug, + title: flag("title"), + }); + console.log( + `${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)` + ); +} + +/** `ccam feature show [] [--cwd path]` — one feature's saved pipeline. */ +async function cmdFeatureShow(args) { + const slug = args.find((arg) => !arg.startsWith("--")); + if (!slug) { + console.error("usage: ccam feature show "); + process.exitCode = 1; + return; + } + const resolved = await resolveLaneArg(args.filter((a) => a !== slug)); + if (!resolved) return; + const result = await get( + `/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`, + undefined, + { allowError: true } + ); + if (result.status) { + console.error(`✖ feature "${slug}" → ${result.data?.error?.message || result.status}`); + process.exitCode = 1; + return; + } + const f = result.feature; + console.log(`${f.slug} ${f.archived_at ? "(archived)" : "(active)"}`); + console.log(` stage: ${f.stage} status: ${f.status} progress: ${f.progress}%`); + for (const node of f.pipeline_nodes) console.log(` ${node.state.padEnd(18)} ${node.label}`); +} +``` + +Check whether `get()` in this file already supports a third `options` argument (`{allowError: true}`) the way `post()` does — search `function get\b` / `const get =`. If it doesn't, extend it the same way `post`/`api` already handle `allowError` (read `async function api(method, pathname, body, options = {})` first — it already accepts `options` uniformly for every verb, so `get` likely just needs its own thin wrapper updated to pass a third argument through, matching how `const post = (p, b, options) => api("POST", p, b, options);` already does). + +- [ ] **Step 2: Wire the subcommand dispatch** + +In `bin/ccam.js`'s `runCommand` switch, add a new case (placement: anywhere among the other top-level cases, e.g. right after the `case "lock":` block added in the previous plan): + +```js + case "feature": { + const sub = rest[0]; + if (sub === "list") return cmdFeatureList(rest.slice(1)); + if (sub === "activate") return cmdFeatureActivate(rest.slice(1)); + if (sub === "show") return cmdFeatureShow(rest.slice(1)); + console.error("usage: ccam feature list | ccam feature activate [--title text] | ccam feature show "); + process.exitCode = 1; + return; + } +``` + +- [ ] **Step 3: Add the help-table entries** + +In `bin/ccam.js`'s `COMMAND_GROUPS`, in the `"Lanes"` group, add after the `stage [flags]` row: + +```js + ["feature list", "[]", "List every feature this lane has activated, archived or live"], + [ + "feature activate", + " [--title text] []", + "Switch to a feature by slug, archiving the current one first (echoes the canonicalized slug)", + ], + ["feature show", " []", "Show one feature's saved pipeline (works on an archived one too)"], +``` + +- [ ] **Step 4: Manual smoke test** + +```bash +ccam lanes add --cwd $(pwd) --title "smoke" +ccam feature activate one --title "First thing" +ccam stage review --evidence "looks fine" +ccam feature activate two --title "Second thing" +ccam feature list +ccam feature show one +``` + +Expected: `feature list` shows `two` marked active (`▶`) and `one` marked archived with a timestamp; `feature show one` prints `stage: review` and its saved pipeline nodes, not `two`'s. + +- [ ] **Step 5: Commit** + +```bash +git add bin/ccam.js +git commit -m "feat(lanes): add ccam feature list/activate/show CLI (B)" +``` + +--- + +### Task 5: Workspace UI — read-only feature picker + +**Files:** +- Modify: `client/src/lib/api.ts` +- Modify: `client/src/lib/types.ts` +- Modify: `client/src/pages/Workspace.tsx` +- Modify: `client/src/i18n/locales/en/lanes.json`, `client/src/i18n/locales/vi/lanes.json` +- Test: `client/src/pages/__tests__/Workspace.test.tsx` + +**Interfaces:** +- Consumes: `GET /api/lanes/:id/features` and `GET /api/lanes/:id/features/:slug` (Task 3, already committed) — **never** `POST .../activate`. The UI is read-only: viewing an archived feature must never be able to switch the live one, matching the standing rule that the console never writes a lane's stage. + +**Read `client/src/lib/api.ts`, `client/src/lib/types.ts`, and the "lane-detail" section of `client/src/pages/Workspace.tsx` FIRST** (the section rendering `currentLane`'s header, `LaneCard`, and `PipelineMap` — search for `data-testid="lane-detail"`) to match this codebase's real conventions before writing anything below. The sketches here show the SHAPE of what's needed, not necessarily exact tokens (fetch helper name, CSS classes, i18n key style) — verify each against the real files. + +- [ ] **Step 1: Add types and API client methods** + +In `client/src/lib/types.ts`, add near the other lane payload types: + +```ts +export interface LaneFeature { + id: number; + lane_id: number; + slug: string; + title: string; + stage: string; + status: string; + archived_at: string | null; + pipeline_nodes: PipelineNode[]; // reuse whatever the existing lane payload's node type is called + progress: number; +} +``` + +(`PipelineNode` — or whatever this codebase actually calls the shape `pipeline_nodes` elements already have on the live lane type — reuse that type, don't redefine it.) + +In `client/src/lib/api.ts`, add to the `lanes` API object (same object `runtime`/`up`/`down` live on): + +```ts +features: { + list: (laneId: number): Promise<{ features: LaneFeature[] }> => + /* the real fetch helper */(`/api/lanes/${laneId}/features`), + show: (laneId: number, slug: string): Promise<{ feature: LaneFeature }> => + /* the real fetch helper */(`/api/lanes/${laneId}/features/${encodeURIComponent(slug)}`), +}, +``` + +- [ ] **Step 2: Add the picker and archived-snapshot view to `Workspace.tsx`** + +Inside the `Workspace` component, near `currentLane` (search for `const currentLane =`), add: + +```tsx +const [viewedFeatureSlug, setViewedFeatureSlug] = useState(null); +const [features, setFeatures] = useState([]); +const [viewedFeature, setViewedFeature] = useState(null); + +// Feature list follows the selected lane, resets the viewer on lane switch. +useEffect(() => { + setViewedFeatureSlug(null); + setViewedFeature(null); + if (currentLane === null || currentLane === undefined) { + setFeatures([]); + return; + } + api.lanes.features + .list(currentLane.id) + .then((data) => setFeatures(data.features)) + .catch(() => setFeatures([])); +}, [currentLane?.id]); + +// Fetch the archived snapshot when the picker selects one — read-only, never +// touches the live lane. +useEffect(() => { + if (!currentLane || !viewedFeatureSlug) { + setViewedFeature(null); + return; + } + let cancelled = false; + api.lanes.features + .show(currentLane.id, viewedFeatureSlug) + .then((data) => { + if (!cancelled) setViewedFeature(data.feature); + }) + .catch(() => { + if (!cancelled) setViewedFeature(null); + }); + return () => { + cancelled = true; + }; +}, [currentLane?.id, viewedFeatureSlug]); +``` + +Then, in the `lane-detail` section's header row (next to the existing `pipeline_name`/stage badges), add a picker that only renders when there's more than the trivial one-feature case: + +```tsx +{features.length > 0 && ( + +)} +``` + +And where `PipelineMap` currently renders (search for ` +{viewedFeature && ( +

+ {tLanes("features.viewingArchived", { slug: viewedFeature.slug })} +

+)} +``` + +Match this file's real conditional-rendering and prop-naming conventions — read the surrounding JSX first rather than transcribing this verbatim if it doesn't fit. + +- [ ] **Step 3: Add i18n strings** + +Add to both `client/src/i18n/locales/en/lanes.json` and `vi/lanes.json`, under whatever key grouping convention this file already uses (check an existing small group like `"runtime"` for the pattern): + +```json +"features": { + "live": "Live", + "archived": "archived", + "viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot." +} +``` + +(Vietnamese translation for the third string, matching this repo's existing tone in `vi/lanes.json`.) + +- [ ] **Step 4: Write a test** + +In `client/src/pages/__tests__/Workspace.test.tsx`, find how this file already mocks `api.lanes.*` calls for the selected-lane detail panel and follow the same pattern to mock `api.lanes.features.list`/`.show`. Add: + +```tsx +it("shows a feature picker and swaps the pipeline map to an archived snapshot without touching the live lane", async () => { + vi.mocked(api.lanes.features.list).mockResolvedValue({ + features: [ + { id: 1, lane_id: 1, slug: "one", title: "One", stage: "review", status: "idle", archived_at: "2026-01-01T00:00:00Z", pipeline_nodes: [], progress: 60 }, + { id: 2, lane_id: 1, slug: "two", title: "Two", stage: "plan", status: "idle", archived_at: null, pipeline_nodes: [], progress: 10 }, + ], + }); + vi.mocked(api.lanes.features.show).mockResolvedValue({ + feature: { id: 1, lane_id: 1, slug: "one", title: "One", stage: "review", status: "idle", archived_at: "2026-01-01T00:00:00Z", pipeline_nodes: [], progress: 60 }, + }); + // ... render, select the lane, then select "one" from the feature picker ... + // assert screen.getByTestId("feature-viewer-banner") appears + // assert api.lanes.action / any mutating lane call was NEVER called as a result of the selection +}); +``` + +Read this file's existing render/selection helpers first and mirror them rather than guessing at the render setup. + +- [ ] **Step 5: Run the client test suite** + +Run: `npm run test:client` +Expected: green, including the new test. + +- [ ] **Step 6: Commit** + +```bash +git add client/src/lib/api.ts client/src/lib/types.ts client/src/pages/Workspace.tsx client/src/i18n/locales/en/lanes.json client/src/i18n/locales/vi/lanes.json client/src/pages/__tests__/Workspace.test.tsx +git commit -m "feat(lanes): add a read-only feature picker to the Workspace page (B)" +``` + +--- + +### Task 6: Documentation + +**Files:** +- Modify: `docs/LANES.md` +- Modify: `docs/CLI.md` +- Modify: `docs/API.md` +- Modify: `ARCHITECTURE.md` +- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md` (mark B done) + +**Interfaces:** none — documentation only. + +- [ ] **Step 1: `docs/LANES.md`** + +Add a new top-level section (after "Pipeline stages and the five node states", before "Stage detection" — search for both headings) titled `## Per-feature state and archive`, covering: +- Why: `clearLane` used to erase; now a lane can carry many features across its lifetime. +- The opt-in model: nothing changes for a lane that never calls `ccam feature activate` — `clearLane` behaves exactly as before. +- Slug canonicalization rule, stated exactly: drops a leading `feat/`, turns `/` and whitespace into `-`, keeps `[A-Za-z0-9._-]`, does **not** lowercase — a deliberately different rule from `worktree.js:slugify`'s branch-name slugification, and every endpoint/CLI command echoes back the canonicalized form. +- `activate` semantics: archives the current active feature (if any and if different), restores the target's saved stage onto the live lane row (so switching back to a past feature resumes where it left off), creates a fresh feature row for a never-seen slug. +- The CLI: `ccam feature list|activate|show`. +- The Workspace picker is **read-only** — selecting an archived feature shows its saved pipeline; it never changes the live lane, matching the standing "console never writes a lane's stage" rule. + +- [ ] **Step 2: `docs/CLI.md`** + +Add to the `### Lanes` table, after the `stage [flags]` row: + +```markdown +| `ccam feature list []` | List every feature this lane has activated, archived or live | +| `ccam feature activate [--title text] []` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first | +| `ccam feature show []` | Show one feature's saved pipeline — works on an archived one too | +``` + +- [ ] **Step 3: `docs/API.md`** + +Add a `### Lane features` section documenting `GET /api/lanes/:id/features`, `GET /api/lanes/:id/features/:slug`, `POST /api/lanes/:id/features/activate` — request/response bodies and status codes exactly as specified in Task 3. Place it as its own subsection under the existing `### Lanes` section (search for where `#### Read a lane's runtime` lives and add after the lane lifecycle routes, before `### Sessions`) — **do not** split an existing heading and its content the way a prior task in this same session accidentally did; read the surrounding structure first and confirm the insertion point with `grep -n "^### \|^#### " docs/API.md` before writing. + +- [ ] **Step 4: `ARCHITECTURE.md`** + +Add a new row to the module responsibility table, near the other `lib/lane-*` rows: + +```markdown +| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected | +``` + +- [ ] **Step 5: Mark B done in the parent plan** + +In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, update the status table row for `**B**` to `✅ **done** `, and update the `## Order` diagram/prose (search for `| **B** |` and the `## Order` section) the same way A1/A2/A3/D were marked done. Note that **C** (Proof gallery) depends on B and can now move from "planned" to whatever its own next step is — do not mark C done, just confirm its dependency line still reads correctly. + +- [ ] **Step 6: Verify and commit** + +```bash +bash .claude/skills/file-headers/scripts/check-headers.sh +npm run test:server +``` + +```bash +git add docs/LANES.md docs/CLI.md docs/API.md ARCHITECTURE.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md +git commit -m "docs(lanes): document per-feature state and archive (B)" +``` diff --git a/docs/superpowers/plans/2026-08-04-proof-gallery.md b/docs/superpowers/plans/2026-08-04-proof-gallery.md new file mode 100644 index 0000000..a3639ec --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-proof-gallery.md @@ -0,0 +1,1142 @@ +# Proof Gallery (C) 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:** the screenshots QC agents capture become visible in the dashboard, grouped by feature (B's `lane_features` slug) and phase. Ported from Shipyard's real implementation (`~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/dashboard/server/{routes,services}/proof.js`, `src/components/ProofGallery.jsx`, `src/hooks/useProof.js`), adapted from Shipyard's flat `state/laneN/*.json` file model to CCAM's `lane_features` DB rows (Task B, already shipped). + +**Architecture:** One new library module `server/lib/proof.js` (list/file/delete/link, all containment-checked against the lane's own proof root — never `LANES_ROOT`, since an adopted lane's `cwd` need not live under it). Routes added inline to `server/routes/lanes.js`, grouped with the other `/:id/*` sub-resources, same placement rule B's `/:id/features` used. CLI gains one primitive, `ccam lanes proof-link`, which is never auto-invoked — matching "CCAM does not orchestrate," a session or hook calls it explicitly. The Workspace UI adds a `ProofGallery` panel keyed off the **same `viewedFeatureSlug`** state Task B's picker already introduced — no second picker. + +**Tech Stack:** Node `fs`/`path` (realpath-based containment, no new dependency), existing Express/route conventions, existing React state from Task B. + +## Global Constraints + +- Every applicable source file MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`. +- **Path containment is the entire security surface.** Every one of `proofFile`/`deleteProof`/`listProof` resolves + `fs.realpathSync`s + asserts the result is inside the lane's proof root (`base === p || p.startsWith(base + path.sep)`) before any read/write/delete reaches `fs`. No request path — slug, group, file, image name — ever reaches `fs` unresolved. Port Shipyard's `proofFile`/`deleteProof`/`badName` check nearly verbatim; do not "clean up" or abstract this logic, it is the one part of C worth reviewing line-by-line. +- **`deleteProof` never touches `ticket/`.** Whole-feature delete iterates every subdirectory except `ticket`; group delete refuses `group === "ticket"` outright. +- **The proof root is `/.playwright-mcp/proof`, not anything under `LANES_ROOT`.** An adopted lane's `cwd` can be anywhere the user can read; proof storage follows the lane, same as Shipyard's `.playwright-mcp/proof` living inside the worktree. +- **Grouping comes from `lane_features` (Task B), not from re-deriving "current" via a branch regex.** Shipyard's `useProof.js` parses `feat/` out of the git branch because it has no DB; CCAM already has `getFeature`/`active_feature_id` as the source of truth, so that derivation is deliberately dropped, not ported. +- **`ccam lanes proof-link` is a primitive, not automatic.** It runs `ensureProofLink` once, on request. Nothing in this plan calls it from `feature activate`, `lanes up`, or any hook — a session or a project's own hook script decides when to call it, same reasoning as every other "CCAM does not orchestrate" primitive. +- `DELETE /api/lanes/:id/proof/:slug` takes `{group?, images?}` in the **JSON body** (`express.json()` is already mounted globally in `server/index.js`), matching Shipyard's `deleteProof(n, req.body || {})` — not query params. +- Run `npm run test:server` (full suite) and `npm run test:client` (when a task touches `client/`) plus `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit. +- Never use `git add -A`. Stage exactly the files each task names. + +--- + +### Task 1: `server/lib/proof.js` core + +**Files:** +- Create: `server/lib/proof.js` +- Test: `server/__tests__/proof.test.js` + +**Interfaces:** +- Produces: `proofRoot(lane)` → `string` (`/.playwright-mcp/proof`, NOT realpath'd — the directory may not exist yet); `ensureProofLink(lane)` → `{linked: boolean}` (idempotent; `false` when there was nothing to link, e.g. clone-root `proof/` already the correct symlink or absent); `listProof(laneId)` → `Array<{slug, groups: {[group]: string[]}, ticket_report: string, mtime: number}>` (mtime = latest file mtime under that slug's dir, seconds; sorted most-recently-touched first); `proofFile(laneId, slug, group, file)` → `string | null` (absolute realpath, or null if missing/outside root/wrong extension); `deleteProof(laneId, {slug, group, images} = {})` → `{deleted: number}`, throws `Object.assign(new Error(...), {code: "EBADPATH"})` for a bad slug/group/image name, `{code: "ENOFEATURE"}` when the slug's directory doesn't exist. +- `listProof` merges in every slug from `lane-features.js`'s `listFeatures(laneId)` that has no proof directory yet (so a just-activated feature with zero screenshots still appears, with empty `groups`) — the CCAM-native replacement for Shipyard's `_pending`/`_active` special-casing, since B's `lane_features` row already exists the moment `activate` is called. + +**Step 1: Write the failing test** + +Create `server/__tests__/proof.test.js`: + +```js +/** + * @file Tests for server/lib/proof.js: proof-directory listing, containment- + * checked file resolution, delete granularities, and the clone-root symlink + * convergence (ensureProofLink). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-proof-")); +process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); + +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const lanesLib = require("../lib/lanes"); +const features = require("../lib/lane-features"); +const proof = require("../lib/proof"); + +after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); + +let laneSeq = 0; +function makeLane() { + laneSeq += 1; + const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); + fs.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); +} + +function writeShot(lane, slug, group, name) { + const dir = path.join(lane.cwd, ".playwright-mcp", "proof", slug, group); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), "fake-png"); +} + +describe("listProof", () => { + it("groups screenshots by slug then phase group", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + writeShot(lane, "one", "qc-local", "b.png"); + writeShot(lane, "one", "qc-dev", "c.png"); + const list = proof.listProof(lane.id); + const one = list.find((f) => f.slug === "one"); + assert.deepEqual(one.groups["qc-local"].sort(), ["a.png", "b.png"]); + assert.deepEqual(one.groups["qc-dev"], ["c.png"]); + }); + + it("surfaces the ticket report path when present", () => { + const lane = makeLane(); + const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket"); + fs.mkdirSync(ticketDir, { recursive: true }); + fs.writeFileSync(path.join(ticketDir, "REPORT.html"), ""); + const one = proof.listProof(lane.id).find((f) => f.slug === "one"); + assert.equal(one.ticket_report, "one/ticket/REPORT.html"); + }); + + it("never lists the ticket dir as a screenshot group", () => { + const lane = makeLane(); + const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket"); + fs.mkdirSync(ticketDir, { recursive: true }); + fs.writeFileSync(path.join(ticketDir, "REPORT.html"), ""); + const one = proof.listProof(lane.id).find((f) => f.slug === "one"); + assert.equal(one.groups.ticket, undefined); + }); + + it("surfaces an activated feature with zero screenshots yet, groups empty", () => { + const lane = makeLane(); + features.activateFeature(lane.id, "brand-new"); + const list = proof.listProof(lane.id); + const f = list.find((x) => x.slug === "brand-new"); + assert.ok(f); + assert.deepEqual(f.groups, {}); + }); +}); + +describe("proofFile", () => { + it("resolves a real screenshot inside the proof root", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const p = proof.proofFile(lane.id, "one", "qc-local", "a.png"); + assert.ok(p); + assert.ok(fs.existsSync(p)); + }); + + it("refuses a traversal attempt via ..", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + assert.equal(proof.proofFile(lane.id, "one", "..", "..%2fsecret"), null); + assert.equal(proof.proofFile(lane.id, "../../etc", "x", "passwd"), null); + }); + + it("refuses a symlink that escapes the proof root", () => { + const lane = makeLane(); + const dir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local"); + fs.mkdirSync(dir, { recursive: true }); + const outside = path.join(SUITE_ROOT, "outside-secret.png"); + fs.writeFileSync(outside, "secret"); + fs.symlinkSync(outside, path.join(dir, "escape.png")); + assert.equal(proof.proofFile(lane.id, "one", "qc-local", "escape.png"), null); + }); + + it("refuses a non-image, non-html extension", () => { + const lane = makeLane(); + const dir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "a.txt"), "not an image"); + assert.equal(proof.proofFile(lane.id, "one", "qc-local", "a.txt"), null); + }); + + it("returns null for a missing lane", () => { + assert.equal(proof.proofFile(999999, "one", "qc-local", "a.png"), null); + }); +}); + +describe("deleteProof", () => { + it("deletes listed images and prunes the group dir if it becomes empty", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const result = proof.deleteProof(lane.id, { slug: "one", group: "qc-local", images: ["a.png"] }); + assert.equal(result.deleted, 1); + assert.equal( + fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")), + false + ); + }); + + it("deletes a whole group, leaves siblings intact", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + writeShot(lane, "one", "qc-dev", "b.png"); + proof.deleteProof(lane.id, { slug: "one", group: "qc-local" }); + assert.equal( + fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")), + false + ); + assert.ok( + fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-dev", "b.png")) + ); + }); + + it("refuses to delete the ticket group by name", () => { + const lane = makeLane(); + const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket"); + fs.mkdirSync(ticketDir, { recursive: true }); + fs.writeFileSync(path.join(ticketDir, "REPORT.html"), ""); + assert.throws( + () => proof.deleteProof(lane.id, { slug: "one", group: "ticket" }), + (err) => err.code === "EBADPATH" + ); + }); + + it("whole-feature delete removes every group but keeps ticket/", () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket"); + fs.mkdirSync(ticketDir, { recursive: true }); + fs.writeFileSync(path.join(ticketDir, "REPORT.html"), ""); + proof.deleteProof(lane.id, { slug: "one" }); + assert.equal( + fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")), + false + ); + assert.ok(fs.existsSync(path.join(ticketDir, "REPORT.html"))); + }); + + it("refuses a slug containing a path separator or ..", () => { + const lane = makeLane(); + assert.throws( + () => proof.deleteProof(lane.id, { slug: "../escape" }), + (err) => err.code === "EBADPATH" + ); + }); + + it("throws ENOFEATURE for a slug with no proof directory", () => { + const lane = makeLane(); + assert.throws( + () => proof.deleteProof(lane.id, { slug: "never-had-proof" }), + (err) => err.code === "ENOFEATURE" + ); + }); +}); + +describe("ensureProofLink", () => { + it("creates the symlink when neither clone-root proof/ nor the canonical dir exist yet", () => { + const lane = makeLane(); + const result = proof.ensureProofLink(lanesLib.getLane(lane.id)); + assert.equal(result.linked, true); + const strayPath = path.join(lane.cwd, "proof"); + assert.equal(fs.lstatSync(strayPath).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(strayPath), path.join(".playwright-mcp", "proof")); + }); + + it("merges a pre-existing real proof/ dir into the canonical root, then links it", () => { + const lane = makeLane(); + const strayDir = path.join(lane.cwd, "proof", "stranded", "qc-local"); + fs.mkdirSync(strayDir, { recursive: true }); + fs.writeFileSync(path.join(strayDir, "old.png"), "stranded shot"); + + proof.ensureProofLink(lanesLib.getLane(lane.id)); + + const merged = path.join( + lane.cwd, + ".playwright-mcp", + "proof", + "stranded", + "qc-local", + "old.png" + ); + assert.ok(fs.existsSync(merged)); + assert.equal(fs.lstatSync(path.join(lane.cwd, "proof")).isSymbolicLink(), true); + }); + + it("is a no-op (linked: false) when the symlink already points at the right place", () => { + const lane = makeLane(); + proof.ensureProofLink(lanesLib.getLane(lane.id)); + const second = proof.ensureProofLink(lanesLib.getLane(lane.id)); + assert.equal(second.linked, false); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/proof.test.js` +Expected: FAIL — `Cannot find module '../lib/proof'` + +**Step 3: Write `server/lib/proof.js`** + +Read `server/lib/lane-features.js` first (already committed) to match its hydration/error-code conventions, and Shipyard's `dashboard/server/services/proof.js` (`~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/dashboard/server/services/proof.js`) for the exact containment/delete logic being ported — line up `badName`, `inBase`, `proofFile`, `deleteProof` against it directly rather than re-deriving them from scratch. + +```js +/** + * @file Proof gallery (C): the screenshots a QC agent captures live at + * `/.playwright-mcp/proof///*.png` (plus a + * `ticket/REPORT.html`), grouped by feature slug — the same slug Task B's + * `lane_features` already tracks. Ported from Shipyard's + * `dashboard/server/services/proof.js`; the "current feature" special-casing + * that file needed (it has no DB, so it parsed a branch regex) is dropped — + * CCAM's `lane_features` row already IS that source of truth. + * + * Path containment is the entire security surface here: every path this + * module touches is resolved, realpath'd, and checked to be inside the + * lane's own proof root before any read/write/delete reaches `fs`. Ported + * near-verbatim from Shipyard rather than "cleaned up" — this is the one part + * of C worth reviewing line-by-line, not abstracting. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const lanesLib = require("./lanes"); +const laneFeatures = require("./lane-features"); + +const IMAGE_RE = /\.(png|jpg|jpeg)$/i; +const SERVEABLE_RE = /\.(png|jpg|jpeg|html)$/i; + +/** Where a lane's proof lives — inside its OWN cwd, not LANES_ROOT (an + * adopted lane's cwd need not be under LANES_ROOT at all). */ +function proofRoot(lane) { + return path.join(lane.cwd, ".playwright-mcp", "proof"); +} + +const badName = (s) => + !s || typeof s !== "string" || s.includes("/") || s.includes("\\") || s.includes(".."); + +function countFiles(dir) { + let c = 0; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) c += countFiles(path.join(dir, e.name)); + else c++; + } + return c; +} + +function latestMtime(dir) { + let mt = 0; + const walk = (d) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, e.name); + if (e.isDirectory()) walk(full); + else { + const s = fs.statSync(full); + if (s.mtimeMs > mt) mt = s.mtimeMs; + } + } + }; + try { + walk(dir); + } catch { + /* best effort */ + } + return mt / 1000; +} + +/** + * Every feature slug with proof (or an activated-but-not-yet-shot feature + * from `lane_features`), most recently touched first. + * + * @param {number} laneId + * @returns {Array<{slug: string, groups: object, ticket_report: string, mtime: number}>} + */ +function listProof(laneId) { + const lane = lanesLib.getLane(laneId); + if (!lane) return []; + const base = proofRoot(lane); + const feats = []; + + if (fs.existsSync(base) && fs.statSync(base).isDirectory()) { + for (const slug of fs.readdirSync(base).sort()) { + const d = path.join(base, slug); + try { + if (!fs.statSync(d).isDirectory()) continue; + } catch { + continue; + } + + const groups = {}; + for (const grp of fs.readdirSync(d).sort()) { + if (grp === "ticket") continue; + const g = path.join(d, grp); + try { + if (!fs.statSync(g).isDirectory()) continue; + } catch { + continue; + } + const imgs = fs.readdirSync(g).filter((f) => IMAGE_RE.test(f)).sort(); + if (imgs.length) groups[grp] = imgs; + } + + let ticketReport = ""; + if (fs.existsSync(path.join(d, "ticket", "REPORT.html"))) { + ticketReport = `${slug}/ticket/REPORT.html`; + } + + feats.push({ slug, groups, ticket_report: ticketReport, mtime: latestMtime(d) }); + } + } + + // Surface an activated feature with no proof dir yet. + const have = new Set(feats.map((f) => f.slug)); + for (const f of laneFeatures.listFeatures(laneId)) { + if (!have.has(f.slug)) { + feats.push({ slug: f.slug, groups: {}, ticket_report: "", mtime: 0 }); + } + } + + feats.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); + return feats; +} + +/** + * Resolve one proof file, containment-checked. Returns the absolute realpath + * or null on any failure — missing lane, missing file, traversal, symlink + * escape, or wrong extension. + */ +function proofFile(laneId, slug, group, file) { + const lane = lanesLib.getLane(laneId); + if (!lane) return null; + + let base; + try { + base = fs.realpathSync(proofRoot(lane)); + } catch { + return null; + } + + let p; + try { + p = fs.realpathSync(path.join(base, slug, group, file)); + } catch { + return null; + } + + if ( + (p === base || p.startsWith(base + path.sep)) && + fs.existsSync(p) && + fs.statSync(p).isFile() && + SERVEABLE_RE.test(p) + ) { + return p; + } + return null; +} + +/** + * Delete proof screenshots at one of three granularities: + * { slug, group, images: [...] } -> unlink those files, prune the group dir if now empty + * { slug, group } -> remove the whole group dir + * { slug } -> remove every group dir EXCEPT ticket/ + * Never touches `ticket/`, never reaches outside the lane's proof root. + * + * @returns {{deleted: number}} + */ +function deleteProof(laneId, { slug, group, images } = {}) { + const lane = lanesLib.getLane(laneId); + if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" }); + if (badName(slug)) throw Object.assign(new Error("invalid slug"), { code: "EBADPATH" }); + + let base; + try { + base = fs.realpathSync(proofRoot(lane)); + } catch { + throw Object.assign(new Error("no proof for this lane"), { code: "ENOFEATURE" }); + } + const inBase = (p) => p === base || p.startsWith(base + path.sep); + + let featDir; + try { + featDir = fs.realpathSync(path.join(base, slug)); + } catch { + throw Object.assign(new Error("feature not found"), { code: "ENOFEATURE" }); + } + if (!inBase(featDir) || !fs.statSync(featDir).isDirectory()) { + throw Object.assign(new Error("invalid feature dir"), { code: "EBADPATH" }); + } + + let deleted = 0; + + if (group !== undefined) { + if (badName(group) || group === "ticket") { + throw Object.assign(new Error("invalid group"), { code: "EBADPATH" }); + } + let grpDir; + try { + grpDir = fs.realpathSync(path.join(featDir, group)); + } catch { + throw Object.assign(new Error("group not found"), { code: "ENOFEATURE" }); + } + if (!inBase(grpDir) || !fs.statSync(grpDir).isDirectory()) { + throw Object.assign(new Error("invalid group dir"), { code: "EBADPATH" }); + } + + if (Array.isArray(images) && images.length) { + for (const img of images) { + if (badName(img) || !IMAGE_RE.test(img)) { + throw Object.assign(new Error("invalid image"), { code: "EBADPATH" }); + } + let f; + try { + f = fs.realpathSync(path.join(grpDir, img)); + } catch { + continue; + } + if (inBase(f) && fs.statSync(f).isFile() && IMAGE_RE.test(f)) { + fs.unlinkSync(f); + deleted++; + } + } + try { + if (!fs.readdirSync(grpDir).length) fs.rmdirSync(grpDir); + } catch { + /* ok */ + } + } else { + deleted = countFiles(grpDir); + fs.rmSync(grpDir, { recursive: true, force: true }); + } + } else { + for (const e of fs.readdirSync(featDir, { withFileTypes: true })) { + if (!e.isDirectory() || e.name === "ticket") continue; + const g = path.join(featDir, e.name); + deleted += countFiles(g); + fs.rmSync(g, { recursive: true, force: true }); + } + } + + return { deleted }; +} + +/** + * Port of Shipyard's `ensure_proof_link`: converge the clone-root `proof/` + * onto `.playwright-mcp/proof` so every proof write lands in one place + * regardless of an MCP's `--output-dir` convention. A pre-existing REAL + * `proof/` dir is merged into the canonical root first (no clobber), then + * replaced with a relative symlink. Idempotent — a no-op when the symlink + * already points at the right place. + * + * Never auto-invoked by this codebase; exposed only as the explicit + * `ccam lanes proof-link` primitive. + * + * @param {object} lane - A hydrated lane row (must have `cwd`). + * @returns {{linked: boolean}} `linked: false` when nothing changed. + */ +function ensureProofLink(lane) { + const canon = proofRoot(lane); + const stray = path.join(lane.cwd, "proof"); + const relTarget = path.join(".playwright-mcp", "proof"); + + let strayStat = null; + try { + strayStat = fs.lstatSync(stray); + } catch { + /* doesn't exist yet */ + } + + if (strayStat && strayStat.isSymbolicLink()) { + if (fs.readlinkSync(stray) === relTarget) return { linked: false }; + fs.rmSync(stray, { force: true }); + } else if (strayStat && strayStat.isDirectory()) { + fs.mkdirSync(canon, { recursive: true }); + mergeDirInto(stray, canon); + fs.rmSync(stray, { recursive: true, force: true }); + } else if (strayStat) { + // A stray file named `proof` — refuse to clobber; leave it alone. + return { linked: false }; + } + + fs.mkdirSync(canon, { recursive: true }); + fs.symlinkSync(relTarget, stray); + return { linked: true }; +} + +/** Recursively copy `src`'s contents into `dest`, never overwriting an + * existing file at the destination (no-clobber merge). */ +function mergeDirInto(src, dest) { + for (const e of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, e.name); + const d = path.join(dest, e.name); + if (e.isDirectory()) { + fs.mkdirSync(d, { recursive: true }); + mergeDirInto(s, d); + } else if (!fs.existsSync(d)) { + fs.copyFileSync(s, d); + } + } +} + +module.exports = { + proofRoot, + listProof, + proofFile, + deleteProof, + ensureProofLink, +}; +``` + +**Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/proof.test.js` +Expected: PASS (17 tests) + +**Step 5: Run the full suite and header audit** + +Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh` + +**Step 6: Commit** + +```bash +git add server/lib/proof.js server/__tests__/proof.test.js +git commit -m "feat(lanes): add proof gallery core (list/file/delete/link) (C)" +``` + +--- + +### Task 2: `GET/DELETE /api/lanes/:id/proof…` + `POST /:id/proof-link` routes + +**Files:** +- Modify: `server/routes/lanes.js` +- Test: `server/__tests__/proof-api.test.js` + +**Interfaces:** +- Consumes: `server/lib/proof.js`'s `listProof`, `proofFile`, `deleteProof`, `ensureProofLink` (Task 1, already committed). + +Routes (register near the other `/:id/*` sub-resources — after `/:id/features/...`, before `/:id/git`, same file): +``` +GET /api/lanes/:id/proof -> { features: [ProofFeature, ...] } +GET /api/lanes/:id/proof/:slug/:group/:file -> image or REPORT.html (Content-Type by extension), 404 if not found/contained +DELETE /api/lanes/:id/proof/:slug body: { group?: string, images?: string[] } + -> { deleted: N } + -> 404 { error: { code: "ENOFEATURE", message } } + -> 400 { error: { code: "EBADPATH", message } } +POST /api/lanes/:id/proof-link -> { linked: boolean } +``` +`ProofFeature` = `{slug, groups: {[group]: string[]}, ticket_report: string, mtime: number}`, exactly `listProof`'s shape — no extra computed fields needed (unlike B's `featurePayload`, there's no pipeline to compute here). + +**Step 1: Write the failing test** + +Create `server/__tests__/proof-api.test.js`: + +```js +/** + * @file Tests for GET/DELETE /api/lanes/:id/proof… and POST + * /api/lanes/:id/proof-link — the HTTP surface over server/lib/proof.js. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); +const http = require("node:http"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-proof-api-")); +process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const { createApp } = require("../index"); +const lanesLib = require("../lib/lanes"); + +let server; +let PORT; + +before(async () => { + const app = createApp(); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + PORT = server.address().port; +}); +after(async () => { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(SUITE_ROOT, { recursive: true, force: true }); +}); + +function request(method, urlPath, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const req = http.request( + { + method, + hostname: "127.0.0.1", + port: PORT, + path: urlPath, + headers: { + "Content-Type": "application/json", + ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}), + }, + }, + (res) => { + let raw = ""; + res.on("data", (chunk) => (raw += chunk)); + res.on("end", () => { + let json = null; + try { + json = JSON.parse(raw); + } catch { + /* binary body (image) is fine */ + } + resolve({ status: res.statusCode, headers: res.headers, body: json, raw }); + }); + } + ); + req.on("error", reject); + if (data) req.write(data); + req.end(); + }); +} + +let laneSeq = 0; +function makeLane() { + laneSeq += 1; + const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`); + fs.mkdirSync(cwd, { recursive: true }); + return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" }); +} + +function writeShot(lane, slug, group, name) { + const dir = path.join(lane.cwd, ".playwright-mcp", "proof", slug, group); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), "fake-png-bytes"); +} + +describe("GET /api/lanes/:id/proof", () => { + it("lists grouped screenshots", async () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const res = await request("GET", `/api/lanes/${lane.id}/proof`); + assert.equal(res.status, 200); + const one = res.body.features.find((f) => f.slug === "one"); + assert.deepEqual(one.groups["qc-local"], ["a.png"]); + }); +}); + +describe("GET /api/lanes/:id/proof/:slug/:group/:file", () => { + it("serves an existing screenshot", async () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const res = await request("GET", `/api/lanes/${lane.id}/proof/one/qc-local/a.png`); + assert.equal(res.status, 200); + assert.equal(res.raw, "fake-png-bytes"); + }); + + it("404s a traversal attempt", async () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const res = await request( + "GET", + `/api/lanes/${lane.id}/proof/one/qc-local/${encodeURIComponent("../../../etc/passwd")}` + ); + assert.equal(res.status, 404); + }); +}); + +describe("DELETE /api/lanes/:id/proof/:slug", () => { + it("deletes listed images", async () => { + const lane = makeLane(); + writeShot(lane, "one", "qc-local", "a.png"); + const res = await request("DELETE", `/api/lanes/${lane.id}/proof/one`, { + group: "qc-local", + images: ["a.png"], + }); + assert.equal(res.status, 200); + assert.equal(res.body.deleted, 1); + }); + + it("400s EBADPATH for a slug containing ..", async () => { + const lane = makeLane(); + const res = await request( + "DELETE", + `/api/lanes/${lane.id}/proof/${encodeURIComponent("../escape")}`, + {} + ); + assert.equal(res.status, 400); + assert.equal(res.body.error.code, "EBADPATH"); + }); + + it("404s ENOFEATURE for a slug with no proof dir", async () => { + const lane = makeLane(); + const res = await request("DELETE", `/api/lanes/${lane.id}/proof/never-had-proof`, {}); + assert.equal(res.status, 404); + assert.equal(res.body.error.code, "ENOFEATURE"); + }); +}); + +describe("POST /api/lanes/:id/proof-link", () => { + it("creates the symlink and reports linked: true", async () => { + const lane = makeLane(); + const res = await request("POST", `/api/lanes/${lane.id}/proof-link`); + assert.equal(res.status, 200); + assert.equal(res.body.linked, true); + assert.equal( + fs.lstatSync(path.join(lane.cwd, "proof")).isSymbolicLink(), + true + ); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/proof-api.test.js` +Expected: FAIL — 404s across the board (routes not registered). + +**Step 3: Add the routes** + +In `server/routes/lanes.js`, add near the top imports: + +```js +const proofLib = require("../lib/proof"); +``` + +Add the routes right after the `/:id/features/activate` route (search for that, added in Task B) and before `/:id/git`: + +```js +router.get("/:id/proof", (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({ features: proofLib.listProof(lane.id) }); +}); + +router.get("/:id/proof/:slug/:group/:file", (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) return res.status(404).send("not found"); + const p = proofLib.proofFile(lane.id, req.params.slug, req.params.group, req.params.file); + if (!p) return res.status(404).send("not found"); + const ext = path.extname(p).toLowerCase(); + const contentType = + ext === ".html" ? "text/html; charset=utf-8" : ext === ".png" ? "image/png" : "image/jpeg"; + res.type(contentType).sendFile(p); +}); + +router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); + try { + const result = proofLib.deleteProof(lane.id, { + slug: req.params.slug, + group: req.body?.group, + images: req.body?.images, + }); + res.json(result); + } catch (err) { + if (err.code === "ENOFEATURE") { + return res.status(404).json({ error: { code: err.code, message: err.message } }); + } + if (err.code === "EBADPATH") { + return res.status(400).json({ error: { code: err.code, message: err.message } }); + } + res.status(500).json({ error: { code: err.code, message: err.message } }); + } +}); + +router.post("/:id/proof-link", sameOriginGuard, (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(proofLib.ensureProofLink(lane)); +}); +``` + +`path` (node:path) is already imported at the top of `server/routes/lanes.js` for the `/worktree` route — confirm with `grep -n "require(\"node:path\")\|require('path')" server/routes/lanes.js` before adding a second import. + +**Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/proof-api.test.js` +Expected: PASS (7 tests) + +**Step 5: Run the full suite and header audit** + +Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh` + +**Step 6: Commit** + +```bash +git add server/routes/lanes.js server/__tests__/proof-api.test.js +git commit -m "feat(lanes): expose GET/DELETE /api/lanes/:id/proof + proof-link over proof.js (C)" +``` + +--- + +### Task 3: `ccam lanes proof-link` CLI + +**Files:** +- Modify: `bin/ccam.js` + +**Interfaces:** +- Consumes: `POST /api/lanes/:id/proof-link` (Task 2, already committed) via the existing `post` helper. +- Consumes: `resolveLaneArg` (already defined in `bin/ccam.js`). + +**Step 1: Add the command implementation** + +In `bin/ccam.js`, near `cmdFeatureShow` (search for that function, added in Task B), add: + +```js +/** `ccam lanes proof-link [] [--cwd path]` — converge the clone-root + * `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically + * by anything else in this codebase; a session or hook calls it explicitly. */ +async function cmdLanesProofLink(args) { + const resolved = await resolveLaneArg(args); + if (!resolved) return; + const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`); + console.log( + linked + ? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof` + : "proof/ already linked, nothing to do" + ); +} +``` + +**Step 2: Wire the subcommand dispatch** + +In `bin/ccam.js`'s `case "lanes":` block (search for other `lanes` subcommands like `up`/`down`/`hook`), add: + +```js + if (sub === "proof-link") return cmdLanesProofLink(rest.slice(1)); +``` + +**Step 3: Add the help-table entry** + +In `bin/ccam.js`'s `COMMAND_GROUPS`, in the `"Lanes"` group, add after the `lanes hook` row: + +```js + ["lanes proof-link", "[]", "Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)"], +``` + +**Step 4: Manual smoke test** + +```bash +ccam lanes add --cwd $(pwd) --title "smoke" +ccam lanes proof-link +ls -la proof # should be a symlink -> .playwright-mcp/proof +ccam lanes proof-link # second run: "already linked, nothing to do" +``` + +**Step 5: Commit** + +```bash +git add bin/ccam.js +git commit -m "feat(lanes): add ccam lanes proof-link CLI (C)" +``` + +--- + +### Task 4: Workspace UI — proof gallery panel + +**Files:** +- Modify: `client/src/lib/api.ts` +- Modify: `client/src/lib/types.ts` +- Modify: `client/src/pages/Workspace.tsx` +- Modify: `client/src/i18n/locales/en/lanes.json`, `client/src/i18n/locales/vi/lanes.json` +- Test: `client/src/pages/__tests__/Workspace.test.tsx` + +**Interfaces:** +- Consumes: `GET /api/lanes/:id/proof`, `DELETE /api/lanes/:id/proof/:slug` (Task 2, already committed). Image `src` attributes point directly at `GET /api/lanes/:id/proof/:slug/:group/:file` (no client fetch needed for the images themselves — the `` tag hits it). +- Keys off the **same `viewedFeatureSlug`** state Task B's `Workspace.tsx` already introduced — no second picker, no separate "current feature" derivation. + +**Read `client/src/lib/api.ts`, `client/src/lib/types.ts`, and Task B's feature-picker code in `client/src/pages/Workspace.tsx` FIRST** (search for `viewedFeatureSlug`) before writing anything below — this task is additive to that state, not a parallel selector. Also skim Shipyard's `ProofGallery.jsx` (`~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/dashboard/src/components/ProofGallery.jsx`) for the rendering shape (grouped thumbnails, `+N` overflow at `CAP = 8`, cleanup-mode multi-select) — port its JSX structure, not its `_active`/`useProof.js` state bookkeeping, which Task B's picker already replaces. + +**Step 1: Add types and API client methods** + +In `client/src/lib/types.ts`, add near `LaneFeature` (Task B): + +```ts +export interface ProofFeature { + slug: string; + groups: Record; + ticket_report: string; + mtime: number; +} +``` + +In `client/src/lib/api.ts`, add to the `lanes` API object, alongside `features`: + +```ts +proof: { + list: (laneId: number): Promise<{ features: ProofFeature[] }> => + /* the real fetch helper */(`/api/lanes/${laneId}/proof`), + imageUrl: (laneId: number, slug: string, group: string, file: string): string => + `/api/lanes/${laneId}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`, + delete: ( + laneId: number, + slug: string, + body: { group?: string; images?: string[] } + ): Promise<{ deleted: number }> => + /* the real fetch helper, DELETE method, JSON body */(`/api/lanes/${laneId}/proof/${encodeURIComponent(slug)}`, body), +}, +``` + +Match the exact fetch-helper name/signature this file already uses for a DELETE-with-body call (check how an existing DELETE-with-body client method is written, e.g. search for `method: "DELETE"` in this file) rather than guessing at the helper's calling convention. + +**Step 2: Add the gallery panel to `Workspace.tsx`** + +Inside the `Workspace` component, alongside Task B's `viewedFeatureSlug`/`features`/`viewedFeature` state, add: + +```tsx +const [proofFeatures, setProofFeatures] = useState([]); + +useEffect(() => { + if (!currentLane) { + setProofFeatures([]); + return; + } + api.lanes.proof + .list(currentLane.id) + .then((data) => setProofFeatures(data.features)) + .catch(() => setProofFeatures([])); +}, [currentLane?.id, viewedFeatureSlug]); + +const activeSlug = viewedFeatureSlug ?? currentLane?.active_feature_slug ?? null; +const proofFeature = activeSlug ? proofFeatures.find((f) => f.slug === activeSlug) : null; +``` + +(`currentLane.active_feature_slug` — verify the exact field name the live lane payload uses for "which feature slug is currently active"; Task B's `featurePayload`/`payload` may expose it under a different key such as `active_feature_id` requiring a lookup into `features` by id instead. Read `payload()` in `server/routes/lanes.js` to confirm before writing this line.) + +Render the gallery below `PipelineMap` (near Task B's `feature-viewer-banner`): + +```tsx +{proofFeature && (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && ( +
+ {proofFeature.ticket_report && ( + + {tLanes("proof.ticketReport")} + + )} + {Object.entries(proofFeature.groups).map(([group, images]) => ( +
+ {group} · {images.length} +
+ {images.slice(0, 8).map((img) => ( + {img} + ))} + {images.length > 8 && ( + +{images.length - 8} + )} +
+
+ ))} +
+)} +``` + +Match this file's real conditional-rendering, className tokens, and `tLanes()` conventions — read the surrounding JSX first rather than transcribing this verbatim. Delete/cleanup-mode UI (multi-select + `api.lanes.proof.delete`) is a `ponytail`-eligible follow-up, not required for this task's verify step below — a viewer with no delete affordance is still a complete, useful C; add delete once the read path is confirmed working, same increment-by-increment approach the rest of this plan takes. + +**Step 3: Add i18n strings** + +Add to both `client/src/i18n/locales/en/lanes.json` and `vi/lanes.json`, under the `"features"` group added in Task B (or a sibling `"proof"` group, matching whichever this file's existing convention prefers for a related-but-distinct concern): + +```json +"proof": { + "ticketReport": "Task report" +} +``` + +(Vietnamese translation matching this repo's existing tone in `vi/lanes.json`.) + +**Step 4: Write a test** + +In `client/src/pages/__tests__/Workspace.test.tsx`, find how Task B's test mocks `api.lanes.features.list`/`.show` and follow the same pattern for `api.lanes.proof.list`: + +```tsx +it("shows the proof gallery for the selected feature", async () => { + vi.mocked(api.lanes.proof.list).mockResolvedValue({ + features: [ + { slug: "one", groups: { "qc-local": ["a.png", "b.png"] }, ticket_report: "", mtime: 0 }, + ], + }); + // ... render, select the lane (and "one" via Task B's picker if it isn't already active) ... + // assert screen.getByTestId("proof-gallery") appears with 2 images +}); + +it("shows no gallery panel when the selected feature has no proof", async () => { + vi.mocked(api.lanes.proof.list).mockResolvedValue({ features: [] }); + // ... render, select the lane ... + // assert screen.queryByTestId("proof-gallery") is null +}); +``` + +Read this file's existing render/selection helpers first and mirror them rather than guessing at the render setup. + +**Step 5: Run the client test suite** + +Run: `npm run test:client` +Expected: green, including the two new tests. + +**Step 6: Commit** + +```bash +git add client/src/lib/api.ts client/src/lib/types.ts client/src/pages/Workspace.tsx client/src/i18n/locales/en/lanes.json client/src/i18n/locales/vi/lanes.json client/src/pages/__tests__/Workspace.test.tsx +git commit -m "feat(lanes): show proof gallery panel in the Workspace page (C)" +``` + +--- + +### Task 5: Documentation + +**Files:** +- Modify: `docs/LANES.md` +- Modify: `docs/CLI.md` +- Modify: `docs/API.md` +- Modify: `ARCHITECTURE.md` +- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md` (mark C done) + +**Interfaces:** none — documentation only. + +**Step 1: `docs/LANES.md`** + +Add a new top-level section (after "Per-feature state and archive", before "Cross-lane named locks" — search for both headings) titled `## Proof gallery`, covering: +- Why: QC screenshots land on disk during a lane's work; this makes them visible in the dashboard, grouped by feature (Task B's slug) and phase. +- Storage layout: `/.playwright-mcp/proof//{,ticket}/`. +- `ensure_proof_link`'s purpose and that it is **never automatic** — `ccam lanes proof-link` is an explicit primitive, same "CCAM does not orchestrate" reasoning as every other primitive in this project. +- The security invariant stated exactly: every proof route resolves + realpaths + checks containment before touching `fs`; `deleteProof` never removes `ticket/`. +- The Workspace panel reuses Task B's feature picker — no second selector. + +**Step 2: `docs/CLI.md`** + +Add to the `### Lanes` table, after the `lanes hook` row: + +```markdown +| `ccam lanes proof-link []` | Converge clone-root `proof/` onto `.playwright-mcp/proof` (idempotent, never run automatically) | +``` + +**Step 3: `docs/API.md`** + +Add a `### Lane proof gallery` section documenting `GET /api/lanes/:id/proof`, `GET /api/lanes/:id/proof/:slug/:group/:file`, `DELETE /api/lanes/:id/proof/:slug`, `POST /api/lanes/:id/proof-link` — request/response bodies and status codes exactly as specified in Task 2. Place it as its own subsection under `### Lanes`, near the "Lane features" section Task B added (confirm the insertion point with `grep -n "^### \|^#### " docs/API.md` first). + +**Step 4: `ARCHITECTURE.md`** + +Add a new row to the module responsibility table, near the `lib/lane-features.js` row: + +```markdown +| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `/.playwright-mcp/proof///`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `ccam lanes proof-link` | +``` + +**Step 5: Mark C done in the parent plan** + +In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, update the status table row for `**C**` to `✅ **done** `, and confirm `**E**`'s dependency line (`A2·B·C·D`) still reads correctly now that C is done too — do not mark E done, it is unstarted. + +**Step 6: Verify and commit** + +```bash +bash .claude/skills/file-headers/scripts/check-headers.sh +npm run test:server +``` + +```bash +git add docs/LANES.md docs/CLI.md docs/API.md ARCHITECTURE.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md +git commit -m "docs(lanes): document proof gallery (C)" +```