Files
Claude-Code-Monitor/docs/superpowers/plans/2026-08-03-lane-profile-scaffolding.md
nntrivi2001 e783a2a946 docs(lanes): clarify --no-build is up-only; add A3 spec+plan docs
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.
2026-08-04 10:06:01 +07:00

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 with bash .claude/skills/file-headers/scripts/check-headers.sh.
  • Layout precedence: backend/package.json AND frontend/package.json both present → monorepo, checked before falling back to a root package.jsonsingle-service.
  • Shell-identifier validator: any detected name (npm script name, docker-compose service name) written into generated shell-script text (not a profile.env KEY=VALUE line — 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; checkProfile is 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 --fix mode. If you find yourself building any of those, stop — it's out of scope, see the spec's Non-goals.
  • Run npm run test:server before considering any task done — this repo's whole server/__tests__/*.test.js suite must stay green (there are 3 pre-existing, unrelated ccam-cli.test.js color-detection failures in this sandbox — confirmed via git 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, or null when 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, detectNode from Task 1.

  • Produces: detectNode's database and env fields, 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.source are paths relative to the repo root (e.g. env.file = "backend/.env"). env.rewrite is ["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) and IDENTIFIER_RE.

  • Produces: scaffoldProfile(repoPath, facts, options = {}){ written: string[], todos: string[] }. options.force (boolean) allows overwriting an existing .ccam/profile/profile.env. Throws Object.assign(new Error(...), { code: "EPROFILEEXISTS" }) when one exists and force is not true.

  • 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[] }. dir is a directory that may contain .ccam/profile/ directly, OR may already point at a .ccam/profile/ directory itself — check .ccam/profile/profile.env first, then profile.env directly, so checkProfile(repoPath) and checkProfile(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's detectNode, scaffoldProfile, checkProfile (all synchronous except checkProfile).

  • 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 pattern server/__tests__/lane-runtime.test.js already 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)"