Files
Claude-Code-Monitor/docs/superpowers/specs/2026-08-03-lane-profile-scaffolding-design.md
T
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

14 KiB

Lane profile scaffolding (A3, v1: Node preset) — design

Source of truth for "Shipyard": ~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/. This is the third phase of the Shipyard-parity roadmap (docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md, phase A3), scoped down to exactly the piece that phase's own text asked for: detect a Node.js project and scaffold a working .ccam/profile/, so adopting a repo becomes one command instead of hand-writing nine hook scripts. Python/Go/Ruby detection, named in the long-range plan, is explicitly out of scope for this design — see Non-goals.

Problem

Today, using CCAM's runtime/data-isolation features (A1: slots+ports, A2: database/Redis/.env) on a new repo means a human reads docs/LANES.md and hand-writes .ccam/profile/profile.env plus every hook script from scratch. That's the adoption barrier this removes.

Scope (v1)

Detect and scaffold for one preset: Node.js, in exactly two supported layouts:

  1. Monorepo: <repo>/backend/package.json AND <repo>/frontend/package.json both present.
  2. Single-service: a package.json at <repo> root, and layout 1 didn't match.

Anything else (no package.json anywhere, or a backend/ without a matching frontend/, or a pnpm/yarn/turborepo workspace layout, or frontend/backend under different names like client//apps/web/) is not detectedprofile init refuses cleanly rather than guessing. This mirrors the project's own rule for A3 in the plan doc: "a wrong default that boots something is worse than a refusal."

Non-goals (explicit)

  • Detecting Python (Django/FastAPI), Go, Ruby, or any non-Node stack. That is real, planned work (see the parent plan's A3 section) but a separate preset, added later, informed by whatever this Node preset's implementation actually looks like once it exists.
  • Detecting ORMs/migration tools (Prisma, Knex, TypeORM, Sequelize, alembic, …). migrate/seed are always scaffolded as an explicit TODO stub when a database is detected — never a guessed command.
  • pnpm/yarn/npm workspaces or turborepo-style (apps/, packages/) monorepo layouts. Only the flat backend/ + frontend/ layout already documented as CCAM's convention (docs/LANES.md's BACKEND_DIR/FRONTEND_DIR example) is detected.
  • Any HTTP API route. profile init/profile check are CLI-only, local filesystem actions against the source repo — no dashboard surface needs them.
  • A --fix mode for profile check. It is read-only/diagnostic only.

Architecture

One new library module, server/lib/lane-detect.js, plus two new CLI subcommands. It does not touch the database, does not require a lane row to exist, and does not execute anything in the target repo — it only reads files and writes files.

server/lib/lane-detect.js
  detectNode(repoPath)   -> { layout: "monorepo"|"single-service", ... facts } | null
  scaffoldProfile(repoPath, facts, { force }) -> writes .ccam/profile/**, returns { written: [...], todos: [...] }
  checkProfile(dir) -> { ok: boolean, errors: [...], warnings: [...] }

bin/ccam.js gains:

ccam lanes profile init <repo> [--force]
ccam lanes profile check [<path>]     # defaults to cwd

Both are pure local actions on whatever machine runs the CLI — no dashboard server round trip needed for the write itself (unlike lanes add --repo, this never creates a worktree or a lane row; it just prepares a repo so that a later lanes add --repo on it gets a working profile for free, because resolveProfile already reads .ccam/profile/ from the lane's own working copy or its source repo — nothing downstream needs to change).

Detection rules (detectNode)

Reads only; never executes npm, never shells out.

  1. Layout (monorepo checked first, single-service is the fallback):
    • backend/package.json and frontend/package.json both exist → monorepo.
    • Else a root package.json exists → single-service.
    • Else → null (nothing detected).
  2. Script per service (reads each package.json's scripts object, does not parse the script's shell value — only its name):
    • Backend-ish package.json (the monorepo's backend/package.json, or the single-service root one): prefer scripts.start, else scripts.dev.
    • Frontend-ish package.json (monorepo's frontend/package.json only — single-service has no separate frontend): prefer scripts.preview, else scripts.dev.
    • Neither candidate present for a role → that role's boot.sh line becomes a literal # TODO: no start/dev script found in <path> — edit this line comment instead of a runnable command.
    • Every script/service name interpolated into generated shell-script TEXT (not a profile.env value) must pass ^[\w.:-]+$ first. A name that fails this check is treated as "not found" for that line (falls to the TODO comment) — this is what keeps a hostile package.json/docker-compose.yml from injecting shell syntax into a generated .sh file. (A profile.env KEY=VALUE line is safe regardless — that file is parsed, never sourced, per the existing contract; this rule is only about literal script text.)
  3. Ports: never discoverable from package.json, so always the existing system defaults — PORT_BASE_api=8000/PORT_BASE_fe=3000 (monorepo) or PORT_BASE_app=3000 (single-service) — written with a comment telling the user to adjust if their dev server uses something else. A wrong value here only makes health fail loudly later; it never corrupts data, so a placeholder is acceptable (unlike a guessed database URL).
  4. Database: docker-compose.yml (or .yaml) at the repo root, parsed as YAML, with a services entry whose key matches /postgres/iDB_PREFIX set (to <sanitized-repo-name>_l), DB_KIND=postgres, DB_URL_SCHEME=postgresql, COMPOSE_FILE=docker-compose.yml, DB_SERVICE=<the matched service name> (passed through the same ^[\w.:-]+$ validator; a compose file with a service name that fails it is treated as "no database detected" for safety), and hooks/db-create.sh/hooks/db-drop.sh copied verbatim from the existing server/data/profile-templates/postgres-compose/hooks/ template. No compose file, or no service matching → DB_PREFIX is not written at all (A2's "empty = feature off", not a half-configured placeholder).
    • Same compose file additionally checked for a service matching /redis/iREDIS=1. Independent of the Postgres check (a repo could have one, the other, both, or neither).
  5. .env seeding (A2 wiring) — only attempted when step 4 found something (a database and/or Redis): look for <backend-dir>/.env or <backend-dir>/.env.example (monorepo: backend/; single-service: repo root). If found, set ENV_FILES/ENV_SOURCE to that path (relative to the lane), and ENV_REWRITE to whichever of DATABASE_URL/REDIS_URL apply. No .env/.env.example found → ENV_FILES stays unset (nothing to seed — not a TODO, since "no .env file in this repo" is a normal, valid state, not a detection failure).
  6. migrate/seed: scaffolded only when step 4 found a database. Body is always:
    #!/usr/bin/env bash
    echo "TODO: no migration tool detected — add your migration command here"
    exit 0
    
    Deliberately never guessing an ORM, and deliberately exits 0 — an unresolved TODO must not make a fresh scaffold impossible to boot; it is a visible reminder, not a hard failure baked into the hook itself (the hard failure is profile check's job — see below).
  7. bootstrap: always scaffolded — npm install at the repo root (single-service) or in both backend/ and frontend/ (monorepo), via harness_spawn-free plain synchronous commands (bootstrap doesn't need detachment; it's expected to finish and exit before the hook returns, unlike boot).
  8. boot: harness_spawn <name> "$LANE_DIR/<dir>" npm run <script> per service (or the TODO comment from step 2 if no script was found).
  9. health: a curl-retry loop against the frontend's port in monorepo layout, PORT_BASE_app in single-service layout — path /, matching the exact pattern already documented in docs/LANES.md's worked example:
    #!/usr/bin/env bash
    set -euo pipefail
    curl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$<PORT_VAR>/" >/dev/null
    

scaffoldProfile

Writes <repo>/.ccam/profile/profile.env and <repo>/.ccam/profile/hooks/*.sh (mode 0755) from the facts detectNode returned. Refuses if .ccam/profile/profile.env already exists, unless --force. Returns which files it wrote and which lines are TODO placeholders, so the CLI can print a clear "N things need your attention" summary instead of silently declaring success.

checkProfile (validator, read-only)

Given a directory (a repo root, or a lane's cwd — the same two places resolveProfile already searches):

  • profile.env parses without throwing (lane-profile.js:parseEnvFile).
  • Every hook referenced (bootstrap, boot, health, plus db-create/db-drop when DB_PREFIX is set) exists on disk and is executable (fs.statSync(...).mode & 0o111).
  • No literal string TODO: remains anywhere in profile.env or any hook file's contents. This is expected to fail immediately after profile init scaffolds a repo whose detection left any TODO (no start/dev script, or a database with unconfigured migrate/seed) — that is the intended signal to finish the manual step, not a bug.
  • If DB_PREFIX is set: db-create.sh/db-drop.sh both exist (redundant with the hook check above, but reported as its own line so the message reads as "database configuration" rather than a generic missing-hook error).
  • If DB_PREFIX is set and ~/.ccam/secrets.env (server/lib/secrets.js:SECRETS_PATH) does not exist: a warning (not a failure) — "no secrets.env; database will use built-in defaults until you create one."
  • Every declared PORT_BASE_<name> port is currently free (server/lib/ports.js:isListening, reused as-is).

Exits non-zero and prints every failure found (not just the first) when any of the hard checks fail; warnings never affect the exit code.

CLI

ccam lanes profile init <repo> [--force]
  • <repo> must be an absolute, existing, git-repository path (same validation POST /api/lanes/worktree already applies to sourceRepo).
  • On success: prints what was scaffolded, and — if anything was left as a TODO — a clear "N item(s) need manual attention before this profile is usable: " block, plus the suggested next command (ccam lanes profile check <repo>).
  • On "nothing detected": a clear, actionable refusal — not a bare error:
    ✖ No detectable Node.js project at <repo> (looked for backend/package.json +
      frontend/package.json, or a root package.json).
      Auto-scaffolding currently supports Node.js repos in that layout only.
      Write .ccam/profile/ by hand — see docs/LANES.md.
    
ccam lanes profile check [<path>]
  • <path> defaults to process.cwd(). Deliberately not lane-id/cwd-resolved the way lanes up/lanes logs/etc. are — profile check is meant to run against a bare repo right after profile init, before any lane/worktree exists for it.
  • Prints every failure and warning found; exits 0 only when there are zero failures (warnings are fine).

Testing (Verify)

Two fixture repos under server/__tests__/fixtures/ (or generated into a temp dir at test time, matching the existing lane-runtime.test.js/lane-data.test.js pattern of building fixtures in SUITE_ROOT):

  1. Single-service, no database — root package.json with a start script that runs a tiny real HTTP server (same pattern as lane-runtime.test.js's python3 -m http.server, or an equivalent one-line Node script). No docker-compose.yml.
    • profile init → exactly profile.env, hooks/bootstrap.sh, hooks/boot.sh, hooks/health.sh written; zero TODOs.
    • profile check → passes (exit 0).
    • runtime.upLane on a real lane built from this fixture → actually boots, actually becomes healthy (the same end-to-end proof A1/A2 used — not just a file-content assertion).
  2. Monorepo with a Postgres+Redis docker-composebackend/package.json (start script) + frontend/package.json (dev script only, no preview — deliberately, to prove the fallback-to-dev path) + docker-compose.yml with postgres/redis services + backend/.env.example.
    • profile initDB_PREFIX/DB_KIND/REDIS=1/ENV_FILES/ENV_REWRITE all set correctly; db-create.sh/db-drop.sh copied from the template; migrate.sh/seed.sh scaffolded as TODO stubs; the frontend line of boot.sh runs npm run dev (the fallback, since no preview exists).
    • profile check → fails with exactly the expected errors (migrate/seed still TODO) — this fixture is not booted for real; its job is to prove init and check cooperate correctly, not to prove the runtime boots (that's fixture 1's job, already proven, and this fixture has no real backend/frontend server to boot anyway).
  3. Unit-level tests on lane-detect.js directly (no full fixture needed): layout precedence (both root package.json and backend/+frontend/ present → monorepo wins), the true no-script-found TODO-fallback line (a package.json with neither start/dev nor preview/dev), the shell-identifier validator rejecting a malicious compose service name / script name, no-package.json → null, backend/ without frontend/null (single-service check on repo root, which also has none → null), --force allowing an overwrite, refusing without it.

Risks

  • A detected name embedded in generated shell text. Mitigated by the ^[\w.:-]+$ validator on any script/service name written into .sh file text (not a profile.env value, which is provably inert already — lane-profile.js's existing test proves $(...)/backticks stay literal there). Review this validator's regex and its use sites twice.
  • A profile that "looks scaffolded" but silently doesn't wire A2. Mitigated by always scaffolding ENV_REWRITE alongside DB_PREFIX when a .env/.env.example exists — a database with no .env rewiring was flagged as a real gap in review and is why this step exists at all, not an afterthought.
  • A TODO stub that fails a boot instead of just failing a check. Mitigated by every TODO hook body being exit 0 — the hard gate is profile check, run explicitly by the human, not a silent failure the first time someone tries lanes up.