Files
Claude-Code-Monitor/docs/superpowers/plans/2026-08-04-lane-feature-state.md
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

50 KiB

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

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

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

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

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

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 <the test file> 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:

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 <the test file> 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
git add server/lib/lanes.js <the test file you modified>
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: <same shape GET /:id returns>, 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:

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

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

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

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

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 [<id>] [--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 <slug>");
    return;
  }
  for (const f of features) console.log(fmtFeatureRow(f));
}

/** `ccam feature activate <slug> [--title X] [<id>] [--cwd path]`. */
async function cmdFeatureActivate(args) {
  const slug = args.find((arg) => !arg.startsWith("--"));
  if (!slug) {
    console.error("usage: ccam feature activate <slug> [--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 <slug> [<id>] [--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 <slug>");
    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):

    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 <slug> [--title text] | ccam feature show <slug>");
      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 <stage> [flags] row:

      ["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
      [
        "feature activate",
        "<slug> [--title text] [<id>]",
        "Switch to a feature by slug, archiving the current one first (echoes the canonicalized slug)",
      ],
      ["feature show", "<slug> [<id>]", "Show one feature's saved pipeline (works on an archived one too)"],
  • Step 4: Manual smoke test
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
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:

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

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:

const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
const [features, setFeatures] = useState<LaneFeature[]>([]);
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(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:

{features.length > 0 && (
  <select
    data-testid="feature-picker"
    className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
    value={viewedFeatureSlug ?? ""}
    onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
  >
    <option value="">{tLanes("features.live")}</option>
    {features.map((f) => (
      <option key={f.slug} value={f.slug}>
        {f.slug}
        {f.archived_at ? ` (${tLanes("features.archived")})` : ""}
      </option>
    ))}
  </select>
)}

And where PipelineMap currently renders (search for <PipelineMap), swap its nodes/detectedSignal props to the viewed feature's when one is selected:

<PipelineMap
  nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
  detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
/>
{viewedFeature && (
  <p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
    {tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
  </p>
)}

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

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

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
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 activateclearLane 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 <stage> [flags] row:

| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
| `ccam feature show <slug> [<id>]` | 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:

| `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** <today's date>, 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 .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 per-feature state and archive (B)"