Help text for "lanes up|down" implied --no-build applied to both; it's only read by up. Also commits the A3 profile-scaffolding design spec and implementation plan that weren't yet in git.
50 KiB
Lane Profile Scaffolding (A3, Node preset) 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: ccam lanes profile init <repo> detects a Node.js project (single-service or backend+frontend monorepo) and scaffolds a working .ccam/profile/; ccam lanes profile check [<path>] validates one before it's trusted.
Architecture: One new library module (server/lib/lane-detect.js) with three pure functions — detectNode (read-only filesystem inspection), scaffoldProfile (writes .ccam/profile/**), checkProfile (read-only validator) — plus two new CLI subcommands in bin/ccam.js. No new HTTP route, no database, no lane row required.
Tech Stack: Node.js, js-yaml (already a project dependency, used here for the first time to parse docker-compose.yml), node:test + node:assert/strict (matches every other server/__tests__/*.test.js in this repo).
Global Constraints
- Spec:
docs/superpowers/specs/2026-08-03-lane-profile-scaffolding-design.md— read it before Task 1, it has the full rationale for every rule below. - Every applicable source file MUST start with the project's authorship header (file overview +
@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>) — see.claude/skills/file-headers/, verify withbash .claude/skills/file-headers/scripts/check-headers.sh. - Layout precedence:
backend/package.jsonANDfrontend/package.jsonboth present →monorepo, checked before falling back to a rootpackage.json→single-service. - Shell-identifier validator: any detected name (npm script name, docker-compose service name) written into generated shell-script text (not a
profile.envKEY=VALUEline — those are already safe, parsed-never-sourced) must match/^[\w.:-]+$/first. A name that fails is treated as "not found" for that line. - TODO hook bodies always
exit 0. A scaffold with an unresolved TODO must still be bootable;checkProfileis the hard gate, not the hook itself. - Node.js single preset only. No Python/Go/Ruby, no ORM/migration-tool detection, no pnpm/yarn/turborepo workspace layouts, no HTTP route, no
--fixmode. If you find yourself building any of those, stop — it's out of scope, see the spec's Non-goals. - Run
npm run test:serverbefore considering any task done — this repo's wholeserver/__tests__/*.test.jssuite must stay green (there are 3 pre-existing, unrelatedccam-cli.test.jscolor-detection failures in this sandbox — confirmed viagit stash— don't chase those).
Task 1: detectNode — layout and script detection
Files:
- Create:
server/lib/lane-detect.js - Test:
server/__tests__/lane-detect.test.js
Interfaces:
- Produces:
IDENTIFIER_RE(RegExp, exported for Task 3's reuse),readPackageJson(dir)→object|null,pickScript(pkg, candidates)→string|null,detectNode(repoPath)→ the facts object below, ornullwhen nothing is detected.
Facts object shape (grow it further in Task 2 — this task only fills layout/services):
{
layout: "monorepo" | "single-service",
services: {
// monorepo:
backend: { dir: "backend", script: "start" | "dev" | null },
frontend: { dir: "frontend", script: "preview" | "dev" | null },
// OR single-service:
app: { dir: ".", script: "start" | "dev" | null },
},
database: null, // Task 2 fills this
env: null, // Task 2 fills this
}
- Step 1: Write the failing test
Create server/__tests__/lane-detect.test.js:
/**
* @file Tests for Node.js project detection and profile scaffolding
* (server/lib/lane-detect.js): layout precedence, script selection, the
* shell-identifier validator, scaffold output, and the profile checker.
* @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 { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-detect-"));
// Must be set before the first require of anything that transitively loads
// server/lib/secrets.js (lane-detect.js does, for checkProfile) — SECRETS_PATH
// is bound once at that module's first load, and without this a test run on a
// machine with a real ~/.ccam/secrets.env would read that file instead of a
// deterministic, isolated one.
process.env.CCAM_SECRETS_PATH = path.join(SUITE_ROOT, "secrets.env");
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
const detect = require("../lib/lane-detect");
let repoSeq = 0;
function makeRepo() {
repoSeq += 1;
const dir = path.join(SUITE_ROOT, `repo-${repoSeq}`);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function writePkg(dir, scripts) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts }, null, 2));
}
describe("detectNode: layout", () => {
it("returns null when nothing is detected", () => {
const repo = makeRepo();
assert.equal(detect.detectNode(repo), null);
});
it("detects single-service from a root package.json", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
const facts = detect.detectNode(repo);
assert.equal(facts.layout, "single-service");
assert.equal(facts.services.app.script, "start");
});
it("prefers monorepo when both backend/ and frontend/ package.json exist, even with a root one too", () => {
const repo = makeRepo();
writePkg(repo, { start: "node root.js" });
writePkg(path.join(repo, "backend"), { start: "node server.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite" });
const facts = detect.detectNode(repo);
assert.equal(facts.layout, "monorepo");
assert.equal(facts.services.backend.script, "start");
assert.equal(facts.services.frontend.script, "dev");
});
it("does not detect a backend/ with no matching frontend/", () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node server.js" });
assert.equal(detect.detectNode(repo), null);
});
});
describe("detectNode: script selection", () => {
it("backend prefers start over dev", () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node a.js", dev: "node b.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite" });
assert.equal(detect.detectNode(repo).services.backend.script, "start");
});
it("frontend prefers preview over dev", () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node a.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite", preview: "vite preview" });
assert.equal(detect.detectNode(repo).services.frontend.script, "preview");
});
it("falls back to null (not a guess) when no candidate script exists", () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { test: "jest" });
writePkg(path.join(repo, "frontend"), { build: "vite build" });
const facts = detect.detectNode(repo);
assert.equal(facts.services.backend.script, null);
assert.equal(facts.services.frontend.script, null);
});
});
describe("IDENTIFIER_RE", () => {
it("accepts plain identifiers", () => {
for (const ok of ["start", "dev:watch", "build.prod", "api-server"]) {
assert.ok(detect.IDENTIFIER_RE.test(ok), ok);
}
});
it("rejects shell metacharacters", () => {
for (const bad of ["start; rm -rf /", "$(id)", "a b", "a|b", "a`b`"]) {
assert.ok(!detect.IDENTIFIER_RE.test(bad), bad);
}
});
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/lane-detect.test.js
Expected: FAIL — Cannot find module '../lib/lane-detect'
- Step 3: Write minimal implementation
Create server/lib/lane-detect.js:
/**
* @file Node.js project detection and `.ccam/profile/` scaffolding (A3).
* `detectNode` reads a repository's filesystem (package.json layout,
* docker-compose.yml) without executing anything; `scaffoldProfile` turns
* those facts into a working profile; `checkProfile` validates one. Only a
* single preset (Node.js, single-service or a flat backend/+frontend/
* monorepo) is detected — anything else is refused rather than guessed, per
* `docs/superpowers/specs/2026-08-03-lane-profile-scaffolding-design.md`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
/**
* A name safe to embed as literal text inside a generated shell script (an
* npm script name, a docker-compose service name). Deliberately NOT applied
* to profile.env `KEY=VALUE` values — those are parsed, never sourced, and
* already safe by construction (see `lane-profile.js:parseEnvFile`). This
* guard is only for names that get spliced into `.sh` file TEXT.
*/
const IDENTIFIER_RE = /^[\w.:-]+$/;
/** Read and parse a package.json, or null if it doesn't exist or is invalid JSON. */
function readPackageJson(dir) {
try {
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
} catch {
return null;
}
}
/**
* First candidate script name present in `pkg.scripts` AND safe to embed in
* shell text. Returns null (never a guess) when no candidate qualifies.
*
* @param {object|null} pkg - Parsed package.json.
* @param {string[]} candidates - Script names in preference order.
* @returns {string|null}
*/
function pickScript(pkg, candidates) {
if (!pkg || typeof pkg.scripts !== "object" || !pkg.scripts) return null;
for (const name of candidates) {
if (typeof pkg.scripts[name] === "string" && IDENTIFIER_RE.test(name)) return name;
}
return null;
}
/**
* Detect a Node.js project's layout and, for each service, which npm script
* to run. Returns null when neither supported layout matches — the caller
* must refuse rather than guess.
*
* @param {string} repoPath - Absolute path to the repository root.
* @returns {object|null}
*/
function detectNode(repoPath) {
const backendPkg = readPackageJson(path.join(repoPath, "backend"));
const frontendPkg = readPackageJson(path.join(repoPath, "frontend"));
if (backendPkg && frontendPkg) {
return {
layout: "monorepo",
services: {
backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) },
frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) },
},
database: null,
env: null,
};
}
const rootPkg = readPackageJson(repoPath);
if (rootPkg) {
return {
layout: "single-service",
services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } },
database: null,
env: null,
};
}
return null;
}
module.exports = { IDENTIFIER_RE, readPackageJson, pickScript, detectNode };
- Step 4: Run test to verify it passes
Run: node --test server/__tests__/lane-detect.test.js
Expected: PASS (11 tests)
- Step 5: Verify the header audit passes
Run: bash .claude/skills/file-headers/scripts/check-headers.sh
Expected: ✔ All applicable files carry the authorship header.
- Step 6: Commit
git add server/lib/lane-detect.js server/__tests__/lane-detect.test.js
git commit -m "feat(lanes): detect a Node.js project's layout and boot scripts (A3)"
Task 2: detectNode — database, Redis, and .env wiring
Files:
- Modify:
server/lib/lane-detect.js - Test:
server/__tests__/lane-detect.test.js
Interfaces:
-
Consumes:
IDENTIFIER_RE,detectNodefrom Task 1. -
Produces:
detectNode'sdatabaseandenvfields, now filled in:database: { dbPrefix: string, dbService: string, dbKind: "postgres" } | null, redis: boolean, env: { dir: string, file: string, source: string, fromExample: boolean, rewrite: string[] } | null,env.dir/env.file/env.sourceare paths relative to the repo root (e.g.env.file = "backend/.env").env.rewriteis["DATABASE_URL"],["DATABASE_URL", "REDIS_URL"], or["REDIS_URL"]depending on what was detected. -
Step 1: Write the failing test
Append to server/__tests__/lane-detect.test.js:
function writeCompose(repo, servicesYaml) {
fs.writeFileSync(
path.join(repo, "docker-compose.yml"),
`services:\n${servicesYaml}\n`
);
}
describe("detectNode: database + Redis + env wiring", () => {
it("detects a postgres service in docker-compose.yml", () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node server.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite" });
writeCompose(repo, " postgres:\n image: postgres:16\n");
const facts = detect.detectNode(repo);
assert.equal(facts.database.dbKind, "postgres");
assert.equal(facts.database.dbService, "postgres");
assert.match(facts.database.dbPrefix, /_l$/);
assert.equal(facts.redis, false);
});
it("detects redis independently of postgres", () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node server.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite" });
writeCompose(repo, " cache_redis:\n image: redis:7\n");
const facts = detect.detectNode(repo);
assert.equal(facts.database, null);
assert.equal(facts.redis, true);
});
it("finds no database when there is no docker-compose.yml", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
const facts = detect.detectNode(repo);
assert.equal(facts.database, null);
assert.equal(facts.redis, false);
assert.equal(facts.env, null);
});
it("wires ENV_REWRITE when a database is found and backend/.env.example exists", () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node server.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite" });
writeCompose(repo, " postgres:\n image: postgres:16\n redis:\n image: redis:7\n");
fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n");
const facts = detect.detectNode(repo);
assert.equal(facts.env.file, "backend/.env");
assert.equal(facts.env.source, "backend/.env.example");
assert.equal(facts.env.fromExample, true);
assert.deepEqual(facts.env.rewrite, ["DATABASE_URL", "REDIS_URL"]);
});
it("leaves env null when a database is found but there is no .env or .env.example", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
writeCompose(repo, " postgres:\n image: postgres:16\n");
assert.equal(detect.detectNode(repo).env, null);
});
it("rejects a docker-compose service name that fails the shell-identifier check", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
writeCompose(repo, " \"postgres; rm -rf /\":\n image: postgres:16\n");
assert.equal(detect.detectNode(repo).database, null);
});
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/lane-detect.test.js
Expected: FAIL — facts.database is null in the postgres/redis tests (not yet implemented), and writeCompose tests throw or mismatch.
- Step 3: Write minimal implementation
js-yaml is already a project dependency (package.json) — do not add a new one. Add to server/lib/lane-detect.js:
const yaml = require("js-yaml");
/** A repo-name-derived DB_PREFIX: lowercase alnum + underscore, "_l" suffix
* (lane N's actual name becomes "<prefix>N"), matching the convention in
* the postgres-compose profile template's example. */
function dbPrefixFor(repoPath) {
const base = path
.basename(repoPath)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return `${base || "app"}_l`;
}
/** The first docker-compose service name matching `pattern`, or null. Reads
* `docker-compose.yml`/`.yaml` at the repo root; never executes it. */
function findComposeService(repoPath, pattern) {
for (const name of ["docker-compose.yml", "docker-compose.yaml"]) {
let doc;
try {
doc = yaml.load(fs.readFileSync(path.join(repoPath, name), "utf8"));
} catch {
continue;
}
const services = doc && typeof doc.services === "object" ? doc.services : {};
for (const serviceName of Object.keys(services)) {
if (pattern.test(serviceName) && IDENTIFIER_RE.test(serviceName)) return serviceName;
}
}
return null;
}
/** Where a lane's .env lives for this layout: backend/.env in a monorepo,
* .env at the root for single-service. Returns the source to seed FROM
* (real .env, else .env.example) or null when neither exists. */
function findEnvSource(repoPath, envDir) {
const dir = path.join(repoPath, envDir);
const real = path.join(dir, ".env");
const example = path.join(dir, ".env.example");
const relFile = path.join(envDir, ".env").replace(/\\/g, "/").replace(/^\.\//, "");
if (fs.existsSync(real)) return { file: relFile, source: relFile, fromExample: false };
if (fs.existsSync(example)) {
return { file: relFile, source: `${relFile}.example`, fromExample: true };
}
return null;
}
function detectDatabaseAndEnv(repoPath, envDir) {
const dbService = findComposeService(repoPath, /postgres/i);
const redis = findComposeService(repoPath, /redis/i) !== null;
const database = dbService
? { dbPrefix: dbPrefixFor(repoPath), dbService, dbKind: "postgres" }
: null;
let env = null;
if (database || redis) {
const found = findEnvSource(repoPath, envDir);
if (found) {
const rewrite = [];
if (database) rewrite.push("DATABASE_URL");
if (redis) rewrite.push("REDIS_URL");
env = { dir: envDir, ...found, rewrite };
}
}
return { database, redis, env };
}
Then wire it into detectNode — replace the two return { ... database: null, env: null } blocks:
if (backendPkg && frontendPkg) {
const { database, redis, env } = detectDatabaseAndEnv(repoPath, "backend");
return {
layout: "monorepo",
services: {
backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) },
frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) },
},
database,
redis,
env,
};
}
const rootPkg = readPackageJson(repoPath);
if (rootPkg) {
const { database, redis, env } = detectDatabaseAndEnv(repoPath, ".");
return {
layout: "single-service",
services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } },
database,
redis,
env,
};
}
Update the module exports:
module.exports = {
IDENTIFIER_RE,
readPackageJson,
pickScript,
dbPrefixFor,
findComposeService,
findEnvSource,
detectNode,
};
- Step 4: Run test to verify it passes
Run: node --test server/__tests__/lane-detect.test.js
Expected: PASS (17 tests)
- Step 5: Commit
git add server/lib/lane-detect.js server/__tests__/lane-detect.test.js
git commit -m "feat(lanes): detect docker-compose database/Redis and wire ENV_REWRITE (A3)"
Task 3: scaffoldProfile — write .ccam/profile/
Files:
- Modify:
server/lib/lane-detect.js - Test:
server/__tests__/lane-detect.test.js - Reuse (read, don't modify):
server/data/profile-templates/postgres-compose/hooks/db-create.sh,db-drop.sh
Interfaces:
-
Consumes:
detectNode's facts object (Task 1+2 shape) andIDENTIFIER_RE. -
Produces:
scaffoldProfile(repoPath, facts, options = {})→{ written: string[], todos: string[] }.options.force(boolean) allows overwriting an existing.ccam/profile/profile.env. ThrowsObject.assign(new Error(...), { code: "EPROFILEEXISTS" })when one exists andforceis nottrue. -
Step 1: Write the failing test
Append to server/__tests__/lane-detect.test.js:
describe("scaffoldProfile", () => {
it("writes a single-service profile with no database: bootstrap/boot/health only, zero TODOs", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
const facts = detect.detectNode(repo);
const result = detect.scaffoldProfile(repo, facts);
const profileDir = path.join(repo, ".ccam", "profile");
const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8");
assert.match(env, /PORTS="app"/);
assert.match(env, /PORT_BASE_app=3000/);
assert.doesNotMatch(env, /DB_PREFIX/);
assert.doesNotMatch(env, /TODO/);
for (const hook of ["bootstrap.sh", "boot.sh", "health.sh"]) {
const hookPath = path.join(profileDir, "hooks", hook);
assert.ok(fs.existsSync(hookPath), hook);
assert.ok(fs.statSync(hookPath).mode & 0o111, `${hook} must be executable`);
}
assert.match(
fs.readFileSync(path.join(profileDir, "hooks", "boot.sh"), "utf8"),
/npm run start/
);
assert.equal(result.todos.length, 0);
});
it("writes a monorepo profile with a database: DB_PREFIX, ENV_REWRITE, copied db hooks, TODO migrate/seed", () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node server.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite" }); // no preview -> exercises dev fallback
writeCompose(repo, " postgres:\n image: postgres:16\n redis:\n image: redis:7\n");
fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n");
const facts = detect.detectNode(repo);
const result = detect.scaffoldProfile(repo, facts);
const profileDir = path.join(repo, ".ccam", "profile");
const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8");
assert.match(env, /DB_PREFIX=/);
assert.match(env, /REDIS=1/);
assert.match(env, /ENV_FILES="backend\/\.env"/);
assert.match(env, /ENV_REWRITE="DATABASE_URL REDIS_URL"/);
for (const hook of ["db-create.sh", "db-drop.sh", "migrate.sh", "seed.sh"]) {
assert.ok(fs.existsSync(path.join(profileDir, "hooks", hook)), hook);
}
assert.match(
fs.readFileSync(path.join(profileDir, "hooks", "migrate.sh"), "utf8"),
/exit 0/
);
assert.match(
fs.readFileSync(path.join(profileDir, "hooks", "boot.sh"), "utf8"),
/npm run dev/ // frontend fallback, no preview script
);
assert.ok(result.todos.length >= 2, "migrate and seed are both TODO");
});
it("writes a TODO boot line (never a guess) when no start/dev script was found", () => {
const repo = makeRepo();
writePkg(repo, { test: "jest" }); // no start, no dev
const facts = { layout: "single-service", services: { app: { dir: ".", script: null } }, database: null, redis: false, env: null };
const result = detect.scaffoldProfile(repo, facts);
const boot = fs.readFileSync(path.join(repo, ".ccam", "profile", "hooks", "boot.sh"), "utf8");
assert.match(boot, /# TODO: no start\/dev script found/);
assert.equal(result.todos.length, 1);
});
it("refuses to overwrite an existing profile without force", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
const facts = detect.detectNode(repo);
detect.scaffoldProfile(repo, facts);
assert.throws(() => detect.scaffoldProfile(repo, facts), (err) => err.code === "EPROFILEEXISTS");
});
it("overwrites when force is true", () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
const facts = detect.detectNode(repo);
detect.scaffoldProfile(repo, facts);
assert.doesNotThrow(() => detect.scaffoldProfile(repo, facts, { force: true }));
});
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/lane-detect.test.js
Expected: FAIL — detect.scaffoldProfile is not a function
- Step 3: Write minimal implementation
Add to server/lib/lane-detect.js:
const TEMPLATE_DB_HOOKS_DIR = path.join(
__dirname,
"..",
"data",
"profile-templates",
"postgres-compose",
"hooks"
);
function writeHook(profileDir, name, body) {
const file = path.join(profileDir, "hooks", `${name}.sh`);
fs.writeFileSync(file, body);
fs.chmodSync(file, 0o755);
}
/**
* A single service's boot.sh line: a real harness_spawn call, or a TODO
* comment (never a guessed command) when no script was found.
*
* Passes the port via a `PORT` environment variable, not a `--port` argv
* flag: framework CLI flags for the port genuinely differ (`-p` for Next.js,
* `--port` for Vite, nothing at all for a plain Express app), but reading
* `process.env.PORT` is the one convention broad enough to default to. It
* will not work for every framework — same disclaimer as the hardcoded
* `PORT_BASE_*` values themselves — but it's the safest single default.
*/
function bootLine(name, service) {
if (!service.script) {
return `# TODO: no start/dev script found in ${service.dir}/package.json — edit this line`;
}
const portVar = `${name.toUpperCase()}_PORT`;
const workdir = service.dir === "." ? '"$LANE_DIR"' : `"$LANE_DIR/${service.dir}"`;
return `harness_spawn ${name} ${workdir} env PORT="$${portVar}" npm run ${service.script}`;
}
/**
* Scaffold `.ccam/profile/` under `repoPath` from `detectNode`'s facts.
* Refuses to overwrite an existing `profile.env` unless `options.force`.
*
* @param {string} repoPath - Absolute repository path.
* @param {object} facts - From `detectNode()`.
* @param {{force?: boolean}} [options]
* @returns {{written: string[], todos: string[]}}
*/
function scaffoldProfile(repoPath, facts, options = {}) {
const profileDir = path.join(repoPath, ".ccam", "profile");
const envPath = path.join(profileDir, "profile.env");
if (fs.existsSync(envPath) && !options.force) {
throw Object.assign(new Error(`profile already exists at ${envPath}`), {
code: "EPROFILEEXISTS",
});
}
fs.mkdirSync(path.join(profileDir, "hooks"), { recursive: true });
const written = [];
const todos = [];
const lines = [];
const bootLines = [];
if (facts.layout === "monorepo") {
lines.push('PORTS="api fe"', "PORT_BASE_api=8000", "PORT_BASE_fe=3000");
bootLines.push(bootLine("api", facts.services.backend));
bootLines.push(bootLine("fe", facts.services.frontend));
} else {
lines.push('PORTS="app"', "PORT_BASE_app=3000");
bootLines.push(bootLine("app", facts.services.app));
}
for (const line of bootLines) if (line.startsWith("# TODO")) todos.push(line);
if (facts.database) {
lines.push(
`DB_PREFIX=${facts.database.dbPrefix}`,
"DB_KIND=postgres",
"DB_URL_SCHEME=postgresql",
"COMPOSE_FILE=docker-compose.yml",
`DB_SERVICE=${facts.database.dbService}`
);
}
if (facts.redis) lines.push("REDIS=1");
if (facts.env) {
lines.push(
`ENV_FILES="${facts.env.file}"`,
`ENV_SOURCE="${facts.env.source}"`,
`ENV_REWRITE="${facts.env.rewrite.join(" ")}"`
);
}
fs.writeFileSync(envPath, `${lines.join("\n")}\n`);
written.push("profile.env");
writeHook(
profileDir,
"bootstrap",
facts.layout === "monorepo"
? '#!/usr/bin/env bash\nset -euo pipefail\nnpm install --prefix "$LANE_DIR/backend"\nnpm install --prefix "$LANE_DIR/frontend"\n'
: '#!/usr/bin/env bash\nset -euo pipefail\nnpm install --prefix "$LANE_DIR"\n'
);
writeHook(profileDir, "boot", `#!/usr/bin/env bash\nset -euo pipefail\n${bootLines.join("\n")}\n`);
const healthPortVar = facts.layout === "monorepo" ? "FE_PORT" : "APP_PORT";
writeHook(
profileDir,
"health",
`#!/usr/bin/env bash\nset -euo pipefail\ncurl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$${healthPortVar}/" >/dev/null\n`
);
written.push("hooks/bootstrap.sh", "hooks/boot.sh", "hooks/health.sh");
if (facts.database) {
for (const name of ["db-create", "db-drop"]) {
fs.copyFileSync(
path.join(TEMPLATE_DB_HOOKS_DIR, `${name}.sh`),
path.join(profileDir, "hooks", `${name}.sh`)
);
fs.chmodSync(path.join(profileDir, "hooks", `${name}.sh`), 0o755);
written.push(`hooks/${name}.sh`);
}
const todoBody = (label) =>
`#!/usr/bin/env bash\necho "TODO: no ${label} tool detected — add your ${label} command here"\nexit 0\n`;
writeHook(profileDir, "migrate", todoBody("migration"));
writeHook(profileDir, "seed", todoBody("seed"));
written.push("hooks/migrate.sh", "hooks/seed.sh");
todos.push("hooks/migrate.sh", "hooks/seed.sh");
}
return { written, todos };
}
module.exports = {
IDENTIFIER_RE,
readPackageJson,
pickScript,
dbPrefixFor,
findComposeService,
findEnvSource,
detectNode,
scaffoldProfile,
};
- Step 4: Run test to verify it passes
Run: node --test server/__tests__/lane-detect.test.js
Expected: PASS (22 tests)
- Step 5: Run the file-header audit
Run: bash .claude/skills/file-headers/scripts/check-headers.sh
Expected: pass (no new applicable files created this task — db-create.sh/db-drop.sh are copied, and the template source already carries its header)
- Step 6: Commit
git add server/lib/lane-detect.js server/__tests__/lane-detect.test.js
git commit -m "feat(lanes): scaffold .ccam/profile/ from detected Node facts (A3)"
Task 4: checkProfile — read-only validator
Files:
- Modify:
server/lib/lane-detect.js - Test:
server/__tests__/lane-detect.test.js
Interfaces:
-
Consumes:
server/lib/lane-profile.js:parseEnvFile(already exported),server/lib/ports.js:isListening(already exported, async),server/lib/secrets.js:SECRETS_PATH(already exported). -
Produces:
async checkProfile(dir)→{ ok: boolean, errors: string[], warnings: string[] }.diris a directory that may contain.ccam/profile/directly, OR may already point at a.ccam/profile/directory itself — check.ccam/profile/profile.envfirst, thenprofile.envdirectly, socheckProfile(repoPath)andcheckProfile(profileDir)both work. -
Step 1: Write the failing test
Append to server/__tests__/lane-detect.test.js:
describe("checkProfile", () => {
it("passes a freshly-scaffolded no-database profile with a real script", async () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
detect.scaffoldProfile(repo, detect.detectNode(repo));
const result = await detect.checkProfile(repo);
assert.deepEqual(result.errors, []);
assert.equal(result.ok, true);
});
it("fails on a database profile with unresolved migrate/seed TODOs", async () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node server.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite" });
writeCompose(repo, " postgres:\n image: postgres:16\n");
detect.scaffoldProfile(repo, detect.detectNode(repo));
const result = await detect.checkProfile(repo);
assert.equal(result.ok, false);
assert.ok(result.errors.some((e) => /TODO/.test(e)));
});
it("fails when a declared port is already in use", async () => {
const net = require("node:net");
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
detect.scaffoldProfile(repo, detect.detectNode(repo));
const server = net.createServer(() => {});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(3000, "127.0.0.1", resolve);
});
try {
const result = await detect.checkProfile(repo);
assert.equal(result.ok, false);
assert.ok(result.errors.some((e) => /3000/.test(e)));
} finally {
server.close();
}
});
it("fails when a referenced hook is missing", async () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
detect.scaffoldProfile(repo, detect.detectNode(repo));
fs.rmSync(path.join(repo, ".ccam", "profile", "hooks", "health.sh"));
const result = await detect.checkProfile(repo);
assert.equal(result.ok, false);
assert.ok(result.errors.some((e) => /health/.test(e)));
});
it("warns (does not fail) when secrets.env is missing for a database profile", async () => {
const repo = makeRepo();
writePkg(repo, { start: "node index.js" });
writeCompose(repo, " postgres:\n image: postgres:16\n");
detect.scaffoldProfile(repo, detect.detectNode(repo));
// Remove the migrate/seed TODO lines so only the secrets warning is left to check.
const hooksDir = path.join(repo, ".ccam", "profile", "hooks");
fs.writeFileSync(path.join(hooksDir, "migrate.sh"), "#!/usr/bin/env bash\nexit 0\n");
fs.writeFileSync(path.join(hooksDir, "seed.sh"), "#!/usr/bin/env bash\nexit 0\n");
const result = await detect.checkProfile(repo);
assert.ok(result.warnings.some((w) => /secrets\.env/.test(w)));
});
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/lane-detect.test.js
Expected: FAIL — detect.checkProfile is not a function
- Step 3: Write minimal implementation
Add to server/lib/lane-detect.js:
const { parseEnvFile, HOOKS } = require("./lane-profile");
const { isListening } = require("./ports");
const { SECRETS_PATH } = require("./secrets");
/** Every hook name a scaffolded Node profile might reference, given what
* profile.env declares — mirrors the same "only what's declared" gating
* scaffoldProfile itself uses. */
function referencedHooks(env) {
const hooks = ["bootstrap", "boot", "health"];
if (env.DB_PREFIX) hooks.push("db-create", "db-drop", "migrate", "seed");
return hooks;
}
function containsTodo(text) {
return text.includes("TODO:");
}
/**
* Validate a scaffolded (or hand-written) profile. Read-only: never mutates
* anything, never spawns a hook. `dir` may be a repo root (profile at
* `.ccam/profile/`) or a `.ccam/profile/` directory itself.
*
* @param {string} dir
* @returns {Promise<{ok: boolean, errors: string[], warnings: string[]}>}
*/
async function checkProfile(dir) {
const errors = [];
const warnings = [];
const candidates = [path.join(dir, ".ccam", "profile"), dir];
const profileDir = candidates.find((p) => fs.existsSync(path.join(p, "profile.env")));
if (!profileDir) {
return { ok: false, errors: [`no profile.env found under ${dir}`], warnings: [] };
}
const envPath = path.join(profileDir, "profile.env");
const envText = fs.readFileSync(envPath, "utf8");
let env;
try {
env = parseEnvFile(envText);
} catch (err) {
return { ok: false, errors: [`profile.env does not parse: ${err.message}`], warnings: [] };
}
if (containsTodo(envText)) errors.push("profile.env still has an unresolved TODO");
for (const name of referencedHooks(env)) {
const hookPath = path.join(profileDir, "hooks", `${name}.sh`);
if (!fs.existsSync(hookPath)) {
errors.push(`missing hook: ${name}.sh`);
continue;
}
if (!(fs.statSync(hookPath).mode & 0o111)) errors.push(`hook not executable: ${name}.sh`);
const body = fs.readFileSync(hookPath, "utf8");
if (containsTodo(body)) errors.push(`${name}.sh still has an unresolved TODO`);
}
if (env.DB_PREFIX && !fs.existsSync(SECRETS_PATH)) {
warnings.push(`no secrets.env at ${SECRETS_PATH} — database will use built-in defaults`);
}
for (const key of Object.keys(env)) {
const match = /^PORT_BASE_(.+)$/.exec(key);
if (!match) continue;
const port = Number(env[key]);
if (Number.isInteger(port) && (await isListening(port))) {
errors.push(`port ${port} (PORT_BASE_${match[1]}) is already in use`);
}
}
return { ok: errors.length === 0, errors, warnings };
}
module.exports = {
IDENTIFIER_RE,
readPackageJson,
pickScript,
dbPrefixFor,
findComposeService,
findEnvSource,
detectNode,
scaffoldProfile,
checkProfile,
};
- Step 4: Run test to verify it passes
Run: node --test server/__tests__/lane-detect.test.js
Expected: PASS (27 tests)
- Step 5: Commit
git add server/lib/lane-detect.js server/__tests__/lane-detect.test.js
git commit -m "feat(lanes): validate a scaffolded profile with checkProfile (A3)"
Task 5: CLI — ccam lanes profile init / ccam lanes profile check
Files:
- Modify:
bin/ccam.js - Test: manual (this repo's CLI tests spawn the real binary against a live/offline server; profile init/check are pure-filesystem and don't need the server — verified by hand per this task's steps, folded into Task 6's fixtures for automated coverage)
Interfaces:
-
Consumes:
server/lib/lane-detect.js'sdetectNode,scaffoldProfile,checkProfile(all synchronous exceptcheckProfile). -
Step 1: Add the command implementations
In bin/ccam.js, near cmdLanesAdd (search for that function), add:
/**
* `ccam lanes profile init <repo>` — detect a Node.js project and scaffold
* `.ccam/profile/`. Pure filesystem action against the SOURCE repo; does not
* talk to the dashboard server at all.
*/
function cmdLanesProfileInit(args) {
const repo = args.find((arg) => !arg.startsWith("--"));
const force = args.includes("--force");
if (!repo) {
console.error("usage: ccam lanes profile init <repo> [--force]");
process.exitCode = 1;
return;
}
const resolved = path.resolve(repo);
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
console.error(`✖ not a directory: ${resolved}`);
process.exitCode = 1;
return;
}
const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
const facts = laneDetect.detectNode(resolved);
if (!facts) {
console.error(
`✖ No detectable Node.js project at ${resolved} (looked for backend/package.json +\n` +
" frontend/package.json, or a root package.json).\n" +
" Auto-scaffolding currently supports Node.js repos in that layout only.\n" +
" Write .ccam/profile/ by hand — see docs/LANES.md."
);
process.exitCode = 1;
return;
}
let result;
try {
result = laneDetect.scaffoldProfile(resolved, facts, { force });
} catch (err) {
if (err.code === "EPROFILEEXISTS") {
console.error(`✖ ${err.message} — pass --force to overwrite it.`);
process.exitCode = 1;
return;
}
throw err;
}
console.log(`${c.green("✔")} Scaffolded .ccam/profile/ at ${resolved} (${facts.layout})`);
for (const file of result.written) console.log(` wrote ${file}`);
if (result.todos.length) {
console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`);
for (const todo of result.todos) console.log(` ${todo}`);
}
console.log(`\nNext: ccam lanes profile check ${repo}`);
}
/**
* `ccam lanes profile check [<path>]` — validate a profile without needing a
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
* resolution (unlike every other `lanes` subcommand) — this is meant to run
* against a bare repo right after `profile init`.
*/
async function cmdLanesProfileCheck(args) {
const target = args.find((arg) => !arg.startsWith("--")) || process.cwd();
const resolved = path.resolve(target);
const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
const result = await laneDetect.checkProfile(resolved);
if (result.errors.length === 0) {
console.log(`${c.green("✔")} profile at ${resolved} looks good`);
} else {
console.log(`${c.red(`✖ ${result.errors.length} problem(s) at ${resolved}:`)}`);
for (const error of result.errors) console.log(` ${error}`);
}
for (const warning of result.warnings) console.log(`${c.yellow("⚠")} ${warning}`);
process.exitCode = result.ok ? 0 : 1;
}
- Step 2: Wire the subcommand dispatch
In bin/ccam.js, the dispatcher (const [cmd, ...rest] = argv; around line 2695) has this exact case "lanes": block around line 2755 — find it with grep -n 'case "lanes"' bin/ccam.js:
case "lanes":
if (rest[0] === "add") {
return cmdLanesAdd(rest.slice(1));
}
if (["reset", "remove", "purge"].includes(rest[0])) {
return cmdLanesLifecycle(rest[0], rest.slice(1));
}
if (["up", "down", "runtime", "logs", "hook"].includes(rest[0])) {
return cmdLanesRuntime(rest[0], rest.slice(1));
}
return cmdLanes();
Replace it with (adds one if block for profile, everything else byte-identical):
case "lanes":
if (rest[0] === "add") {
return cmdLanesAdd(rest.slice(1));
}
if (rest[0] === "profile") {
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
console.error(
"usage: ccam lanes profile init <repo> [--force] | ccam lanes profile check [<path>]"
);
process.exitCode = 1;
return;
}
if (["reset", "remove", "purge"].includes(rest[0])) {
return cmdLanesLifecycle(rest[0], rest.slice(1));
}
if (["up", "down", "runtime", "logs", "hook"].includes(rest[0])) {
return cmdLanesRuntime(rest[0], rest.slice(1));
}
return cmdLanes();
- Step 3: Add the help-table entries
In bin/ccam.js's COMMAND_GROUPS, in the "Lanes" group (search for ["lanes runtime"), add two rows right after the lanes add --repo row:
[
"lanes profile init",
"<repo> [--force]",
"Detect a Node.js project and scaffold .ccam/profile/",
],
[
"lanes profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)",
],
- Step 4: Manual smoke test
mkdir -p /tmp/ccam-smoke/backend /tmp/ccam-smoke/frontend
echo '{"scripts":{"start":"node -e \"require(\\\"http\\\").createServer((_,r)=>r.end(\\\"ok\\\")).listen(process.env.PORT||8000)\"}}' > /tmp/ccam-smoke/backend/package.json
echo '{"scripts":{"dev":"true"}}' > /tmp/ccam-smoke/frontend/package.json
node bin/ccam.js lanes profile init /tmp/ccam-smoke
node bin/ccam.js lanes profile check /tmp/ccam-smoke
Expected: init prints ✔ Scaffolded ..., no TODOs listed (both packages have runnable scripts, no database detected); check prints ✔ profile at ... looks good, exit 0.
node bin/ccam.js lanes profile init /tmp/ccam-smoke
Expected (run again without --force): ✖ profile already exists ... — pass --force to overwrite it., exit 1.
rm -rf /tmp/ccam-smoke
- Step 5: Commit
git add bin/ccam.js
git commit -m "feat(lanes): add ccam lanes profile init/check CLI (A3)"
Task 6: End-to-end fixtures — real boot, and init+check cooperation
Files:
- Modify:
server/__tests__/lane-detect.test.js
Interfaces:
-
Consumes:
server/lib/lane-detect.js(Tasks 1-4),server/lib/lanes.js:createLane,server/lib/lane-runtime.js:upLane/downLane(same patternserver/__tests__/lane-runtime.test.jsalready uses). -
Step 1: Write the failing tests
DASHBOARD_DB_PATH/LANES_ROOT are already set at the top of server/__tests__/lane-detect.test.js from Task 1 (needed there for checkProfile's isolation, reused here for createLane/upLane). Append this new describe block at the end of the file:
describe("end to end: init -> check -> real boot", () => {
it("a single-service, no-database repo boots for real after init", async () => {
const lanesLib = require("../lib/lanes");
const runtime = require("../lib/lane-runtime");
const repo = makeRepo();
// A plain Node script reading process.env.PORT — the exact convention
// bootLine's `env PORT="$..." npm run <script>` targets.
fs.writeFileSync(
path.join(repo, "package.json"),
JSON.stringify({
scripts: {
start:
"node -e \"require('http').createServer((_,res)=>res.end('ok')).listen(process.env.PORT)\"",
},
})
);
const facts = detect.detectNode(repo);
detect.scaffoldProfile(repo, facts);
const checked = await detect.checkProfile(repo);
assert.equal(checked.ok, true, JSON.stringify(checked.errors));
const lane = lanesLib.createLane({ title: "e2e", cwd: repo, kind: "managed" });
try {
const runtimeFacts = await runtime.upLane(lanesLib.getLane(lane.id));
assert.equal(runtimeFacts.up, true);
assert.equal(runtimeFacts.healthy, true);
} finally {
await runtime.downLane(lanesLib.getLane(lane.id));
}
});
it("a monorepo with a database scaffolds correctly but check fails until migrate/seed are finished", async () => {
const repo = makeRepo();
writePkg(path.join(repo, "backend"), { start: "node server.js" });
writePkg(path.join(repo, "frontend"), { dev: "vite" });
writeCompose(repo, " postgres:\n image: postgres:16\n redis:\n image: redis:7\n");
fs.writeFileSync(path.join(repo, "backend", ".env.example"), "SOMETHING=1\n");
const facts = detect.detectNode(repo);
const scaffolded = detect.scaffoldProfile(repo, facts);
assert.ok(scaffolded.todos.includes("hooks/migrate.sh"));
assert.ok(scaffolded.todos.includes("hooks/seed.sh"));
const checked = await detect.checkProfile(repo);
assert.equal(checked.ok, false);
assert.ok(checked.errors.some((e) => /migrate\.sh/.test(e)));
assert.ok(checked.errors.some((e) => /seed\.sh/.test(e)));
});
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/lane-detect.test.js
Expected: FAIL — the two new it() blocks don't exist yet before this step, or (if run after Step 1's edit) fail on a missing/incorrect behavior, not on an environment error. If it fails with a better-sqlite3/database-path error instead, DASHBOARD_DB_PATH/LANES_ROOT from Task 1's setup aren't actually before every require in the file — fix that first.
- Step 3: Run test to verify it passes
Run: node --test server/__tests__/lane-detect.test.js
Expected: PASS (29 tests total)
- Step 4: Run the full server suite
Run: npm run test:server
Expected: green, aside from the 3 pre-existing unrelated ccam-cli.test.js failures noted in Global Constraints.
- Step 5: Commit
git add server/__tests__/lane-detect.test.js
git commit -m "test(lanes): prove profile scaffolding end to end with a real boot (A3)"
Task 7: Documentation
Files:
- Modify:
docs/LANES.md - Modify:
docs/CLI.md - Modify:
ARCHITECTURE.md - Modify:
docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md(mark A3 done)
Interfaces: none — documentation only.
- Step 1:
docs/LANES.md
Find the ### Declaring a profile section (search for that heading). Immediately after it (before ### Slots and ports), add:
### Scaffolding a profile automatically
```bash
ccam lanes profile init <repo> [--force] # detect + write .ccam/profile/
ccam lanes profile check [<path>] # validate one (path defaults to cwd)
profile init currently detects Node.js only, in exactly two layouts: a root package.json (single-service), or backend/package.json and frontend/package.json both present (monorepo, checked first). Anything else — Python/Go/Ruby, a pnpm/yarn/turborepo workspace, non-standard directory names — is refused with an actionable message rather than guessed at; write .ccam/profile/ by hand for those, following the reference above.
Detection reads package.json (which of start/dev/preview exists) and docker-compose.yml (a service matching /postgres/i or /redis/i), never executes anything, and never guesses a migration tool: a detected database gets migrate.sh/seed.sh scaffolded as an explicit, always-exit 0 TODO stub, not a guessed Prisma/Knex/TypeORM command. profile check is the hard gate — it fails (non-zero exit, every problem listed) on any leftover TODO:, any missing or non-executable hook, or a declared port already in use; it only warns when ~/.ccam/secrets.env doesn't exist yet for a database-declaring profile.
profile check takes a path, not a lane id or --lane/--cwd flag like every other lanes subcommand — it is meant to run against a bare repository right after init, before any lane or worktree exists for it.
- [ ] **Step 2: `docs/CLI.md`**
Find the `### Lanes` table (search for `ccam lanes reset`). Add two rows right after the `ccam lanes add --repo ...` row:
```markdown
| `ccam lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.ccam/profile/`. Refuses to overwrite an existing one without `--force` |
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
- Step 3:
ARCHITECTURE.md
Find the lib/lane-slots.js row in the module responsibility table (search for that exact string) and add a new row directly after the lib/lane-runtime.js row:
| `lib/lane-detect.js` | (A3) Node.js project detection and `.ccam/profile/` scaffolding. `detectNode` reads `package.json` layout (root, or `backend/`+`frontend/`) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`) — read-only, never executes anything. Any detected name written into GENERATED SHELL TEXT (an npm script name, a compose service name) must pass a strict identifier check first; a `profile.env` value is already safe regardless, since that file is parsed, never sourced. `scaffoldProfile` writes the profile, always leaving `migrate`/`seed` as an `exit 0` TODO stub when a database is found rather than guessing a migration tool. `checkProfile` is the read-only hard gate: unresolved `TODO:`, a missing/non-executable hook, or a port already in use all fail it; a missing `~/.ccam/secrets.env` only warns |
- Step 4: Mark A3 done in the parent plan
In docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md, update the status table row and the ## Order section the same way A1/A2 were marked done (search for | **A3** | and for A3 next).
- Step 5: Verify docs and tests together
bash .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
Expected: header check passes; full suite green apart from the 3 known-unrelated ccam-cli.test.js failures.
- Step 6: Commit
git add docs/LANES.md docs/CLI.md ARCHITECTURE.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
git commit -m "docs(lanes): document ccam lanes profile init/check (A3)"