Files
Claude-Code-Monitor/docs/superpowers/plans/2026-08-04-proof-gallery.md
T
nntrivi2001 e9193ae4be docs(lanes): add plan docs for B, D, C (per-feature state, named locks, proof gallery)
B and D plans were written and executed but never staged. C is new, not yet implemented.
2026-08-04 15:47:18 +07:00

45 KiB

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.realpathSyncs + 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 <lane.cwd>/.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/<slug> 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 (<lane.cwd>/.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:

/**
 * @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ĩ <vinnt@smartgift.vn>
 */

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"), "<html></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"), "<html></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"), "<html></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"), "<html></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.

/**
 * @file Proof gallery (C): the screenshots a QC agent captures live at
 * `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/*.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ĩ <vinnt@smartgift.vn>
 */

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

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:

/**
 * @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ĩ <vinnt@smartgift.vn>
 */

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:

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:

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

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

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:

/** `ccam lanes proof-link [<id>] [--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:

      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:

      ["lanes proof-link", "[<id>]", "Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)"],

Step 4: Manual smoke test

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

git add bin/ccam.js
git commit -m "feat(lanes): add ccam lanes proof-link CLI (C)"

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

export interface ProofFeature {
  slug: string;
  groups: Record<string, string[]>;
  ticket_report: string;
  mtime: number;
}

In client/src/lib/api.ts, add to the lanes API object, alongside features:

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:

const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);

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

{proofFeature && (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
  <div data-testid="proof-gallery" className="mt-2">
    {proofFeature.ticket_report && (
      <a
        href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
        target="_blank"
        rel="noreferrer"
        className="text-xs text-fg-muted"
      >
        {tLanes("proof.ticketReport")}
      </a>
    )}
    {Object.entries(proofFeature.groups).map(([group, images]) => (
      <div key={group} className="mt-1">
        <span className="text-xs text-fg-muted">{group} · {images.length}</span>
        <div className="flex flex-wrap gap-1">
          {images.slice(0, 8).map((img) => (
            <img
              key={img}
              loading="lazy"
              className="h-16 w-16 rounded object-cover"
              src={api.lanes.proof.imageUrl(currentLane.id, proofFeature.slug, group, img)}
              alt={img}
            />
          ))}
          {images.length > 8 && (
            <span className="text-xs text-fg-muted">+{images.length - 8}</span>
          )}
        </div>
      </div>
    ))}
  </div>
)}

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

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

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

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: <lane.cwd>/.playwright-mcp/proof/<slug>/{<phase-group>,ticket}/.
  • ensure_proof_link's purpose and that it is never automaticccam 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:

| `ccam lanes proof-link [<id>]` | 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:

| `lib/proof.js`            | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. 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** <today's date>, 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 .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
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)"