74 KiB
Lanes: Parallel Agent Work Units
A lane is a durable unit of parallel agent work — one working directory, many Claude Code sessions over time. This document describes what lanes are, how to use them, and how the dashboard displays pipeline progress.
What is a lane?
A lane persists across session restarts, which is what makes a pipeline view possible. When you run a Claude Code session, the dashboard binds it to the lane that owns its working directory (cwd) through longest path-boundary prefix matching. This allows:
- Multiple sessions on the same directory to feed into one visible lane
- Nested working directories to own their own nested lanes (deeper paths take precedence)
- A session to migrate from one directory to another without losing its lane relationship
Lanes are keyed by cwd, not `session_id. A session is ephemeral (you run it, it finishes); a lane survives restarts and shows the cumulative progress through a declared pipeline.
Creating a lane
Create a lane bound to a working directory with ccam lanes add:
ccam lanes add --cwd /path/to/repo --title "My Feature"
The lane is now ready to track work. When you run a Claude Code session in that directory (or a subdirectory), the dashboard will bind the session to the lane automatically by matching the cwd.
Note: The directory must exist and be an absolute path. A lane with a cwd of /tmp/wt will NOT capture /tmp/wt-sibling (no partial prefix match) but WILL capture sessions in /tmp/wt/subdir/ (subdirectories are owned by the parent lane unless a deeper lane exists).
To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository:
ccam lanes add --repo /path/to/repo --title "My Feature" --base main --slug my-feature
--title, --base, and --slug are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes.
Destructive lane actions
Reset a managed worktree, remove one, or purge the lane's eligible session history with the CLI:
ccam lanes reset <id> [--force] [--keep-db] --yes
ccam lanes remove <id> [--force] --yes
ccam lanes purge <id> --yes
Each command first fetches and prints its preflight counts. reset and remove show head, dirty, untracked, and unpushed (plus database, the name a data-isolated lane's reset/remove will drop — see "Data isolation" below); purge shows sessions, events, and tokenRows. The action refuses to run without --yes, and sends those exact facts back as its confirmation. Use --force only when the preflight reports unpushed commits. --keep-db (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to reset it. remove IS allowed for an adopted lane: it drops only the dashboard's record and leaves the directory, its contents, and its database (if any) untouched.
Lifecycle
The two lane kinds
managed— a git worktree the dashboard itself created (ccam lanes add --repo), rooted underLANES_ROOT. The dashboard owns its full lifecycle: it may reset the worktree to its base branch or remove it entirely.adopted— an existing directory you already had (ccam lanes add --cwd). The dashboard only ever tracks it; it never resets or deletes that worktree. "Removing" an adopted lane drops the dashboard's record of it and leaves the directory untouched.
The three independent safety checks
Before reset or remove touches a worktree, assertDestroyable (server/lib/worktree.js) runs three checks, all of which must pass:
lane.kind === "managed". An adopted lane fails immediately withENOTMANAGED— this check alone is what keeps a directory you own off the destroy path.- The lane's
cwd, resolved withfs.realpathSync, must sit inside the resolvedLANES_ROOTon a path boundary.path.relative(root, cwd)must not start with..or be absolute —EOUTSIDEROOTotherwise. This guards against a lane row that was hand-edited or drifted to point somewhere else. - The resolved path must actually appear in
git worktree list --porcelainfor the lane'ssource_repo—ENOTWORKTREEotherwise. A directory that merely happens to sit underLANES_ROOTbut was never registered as a worktree of that repo is not destroyed.
These checks run unconditionally inside resetWorktree/removeWorktree themselves, not only when a confirmation is present — there is no code path to a git-destructive operation that skips them.
One documented exception, which does not weaken them: when a managed lane's directory has already been deleted by hand, removeWorktree takes a prune path instead. Checks 1 and 2 still hold (an adopted lane is refused outright; the recorded cwd must still be inside LANES_ROOT, verified lexically because a vanished path cannot be resolved), and check 3 is what the prune replaces — a worktree git no longer lists needs no removal. That path runs git worktree prune plus a safe branch delete against the source repo's bookkeeping and touches no directory at all.
Each verb
add— either adopts an existing directory (--cwd, kindadopted) or provisions a new managed worktree (--repo, kindmanaged, created underLANES_ROOTon branchfeat/<slug>).clear(UI/API action only, no CLI verb) — resets the lane's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, including the detected columns (detected_stage/detected_signal/detected_at), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only thelanesrow; no git operation, no session/event data is deleted.reset(managed lanes only) — checks out the base branch,git reset --hardto it, thengit clean -fd(no-x, so gitignored files such asnode_modulesand.envsurvive; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requiresforce: truewhen the preflight reports unpushed commits.remove— for amanagedlane whose directory exists:git worktree remove --force,git worktree prune, then a safe branch delete, then the lane row is deleted. For amanagedlane whose directory was deleted by hand: the prune path above (git's stale record and the branch are cleaned up, then the row is deleted) — amissingworktree does NOT make a lane unremovable. For anadoptedlane: only the lane row is deleted (removeWorktreeis never invoked foradopted, so the directory is never touched). The unpushed-commits force-gate only applies when the worktree is actually touched, i.e.action === "remove" && kind === "managed"— forgetting an adopted lane needs noforceeven with unpushed commits, since nothing on disk is at risk.purge— deletes the lane's ownsessions/events/token_usagerows (excluding the lane's currently-bound or any still-active session), for its owncwdand subdirectories. No git operation, no worktree change; not gated on unpushed commits.
The preflight contract
GET /api/lanes/:id/preflight?action=reset|remove|purge (server/lib/lane-preflight.js) is read-only and returns:
reset/remove:{ action, lane, kind, branch, head, dirty, untracked, unpushed, database, blocked[], warnings[] }purge:{ action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped }
blocked[] can contain: adopted (not a managed worktree), missing (the directory is gone), unreadable (directory exists but git failed against it), and unpushed-commits (unpushed count > 0 — the only one a force: true can override).
Which entries actually prevent the action depends on the action, and the server is the authority. All of them block reset. None of them blocks remove — the server supports removing an adopted lane (forget the row), a hand-deleted worktree (prune path), and an unreadable one: it attempts git worktree remove --force, and if git itself refuses (e.g. a corrupt worktree .git pointer, which git validates and rejects even with a second --force), it deregisters the worktree directly from the source repo's bookkeeping instead — the directory itself is never touched either way. A surface that greys out confirmation on any blocked entry regardless of action makes a documented capability unreachable; client/src/components/lanes/DestructiveLaneModal.tsx encodes the per-action set in HARD_BLOCKERS and renders the rest as context.
unpushed is the count of commits the action would really discard: with remotes, commits on no remote; without remotes, the commits ahead of the lane's base_branch, which is this lane's own work. A freshly provisioned worktree in a local-only repository reports 0, not that repository's whole history — the no-remote warning, not an inflated count, is what tells the user nothing is backed up. A lane with no base_branch at all (an adopted one) falls back to the total commit count. warnings[] holds purely informational facts that never gate the action: currently just no-remote (no git remote configured at all — nothing here is backed up remotely, but the action proceeds normally). Both arrays are data, not an exception.
database is the database name a reset (unless --keep-db) or remove will drop — null when the lane has no slot yet or its profile declares no DB_PREFIX. It is derived, not stored, the same way slot/ports are.
bytesEstimate is a rough estimate, not a measured disk size: it is derived as (events + tokenRows) * 512, a flat per-row byte guess, purely to give the confirmation dialog an order-of-magnitude sense of what purging will reclaim.
Every destructive POST /api/lanes/:id/:action must echo back an expect object with exactly the fields listed above for that action. The server re-runs preflight at execution time and rejects the request (400 EEXPECT if incomplete, 409 ESTALE if any field has changed since the client read it) rather than trusting a client-supplied count.
Environment variables
LANES_ROOT(default~/.claude/ccam-lanes) — the directory managed worktrees are provisioned under; also the boundary that safety check 2 resolves paths against.LANE_DEAD_SEC(default300) — seconds of silence before a lane whose stage matches/watch|poll/iflips fromactivetodeadliveness.LANE_BASE_BRANCH(defaultmain) — the base branch a new managed worktree resolves against when a request omitsbase. An explicit--base <branch>(or request bodybase) always wins over this default.LANE_BRANCH_PREFIX(defaultfeat/) — the prefix used to build a new managed worktree's feature branch name (<prefix><slug>).
Limitation: a fresh worktree has no dependencies
ccam lanes add --repo runs git worktree add only. It does not run npm install (or any other package manager), and it does not copy the source repo's untracked local files (.env, IDE config, etc.) into the new worktree. A managed lane is ready for git immediately but needs its own dependency install and local env setup before a session can run the project inside it.
A repository can close the dependency half of that gap itself by declaring a bootstrap hook in its profile (below) and running ccam lanes hook bootstrap. Seeding a lane's .env and its database is handled automatically once the profile opts in — see "Data isolation" below.
Lane runtime: running a lane's own stack
A lane isolates code. Without more, two lanes running their project at the same time collide on everything else — the same ports, the same directories. The runtime layer gives each lane its own slot, and derives its ports and per-lane directories from it.
This is resource namespacing on the host, not a container: lanes run as the same user, share the network, and can read any file you can. If you need stronger isolation than that, this is not it.
Declaring a profile
Everything stack-specific lives in the repository, at <repo>/.ccam/profile/. CCAM is multi-repo, so there is no global profile setting — a profile travels with the repo it describes, and therefore already exists in every worktree cut from it.
myapp/
.ccam/profile/
profile.env
hooks/
bootstrap.sh boot.sh health.sh
migrate.sh seed.sh ci-gate.sh e2e.sh regen.sh
db-create.sh db-drop.sh
profile.env is a KEY=VALUE declaration file. It is parsed, never sourced — $(…), backticks and ${VAR} are kept literally, because sourcing arbitrary shell from a repository into the dashboard process would be a code-execution path. Hooks are executed on purpose; config is only read.
# .ccam/profile/profile.env
PORTS="api fe worker" # names; each gets a <NAME>_PORT in the hook environment
PORT_BASE_api=8000 # lane in slot 3 prefers :8003
PORT_BASE_fe=3000
PORT_BASE_worker=9000
LANE_DIRS="uploads .cache" # directories created per lane, inside its working copy
BACKEND_DIR=backend
FRONTEND_DIR=frontend
Every key has a default, so a missing one never breaks a lane: PORTS="api fe", PORT_BASE_api=8000, PORT_BASE_fe=3000, LANE_DIRS="", BACKEND_DIR=backend, FRONTEND_DIR=frontend, API_PATH=/api. A port name with no declared base falls back to 8000.
CCAM resolves the profile from the lane's own working copy first, then its source repository. A branch that changes a boot command must boot with the command it changed; the source-repo fallback exists for a profile kept gitignored, which never reaches a worktree through git.
Scaffolding a profile automatically
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.
Slots and ports
A slot is the small integer every runtime fact derives from. Slots are allocated lazily — a lane that is only ever watched never takes one — from 1..LANE_MAX_SLOTS (default 9), lowest free first, and are freed when the lane is removed. A reset keeps the slot: moving a lane's ports out from under a session mid-feature would be a silent, confusing failure rather than a fresh start.
Ports prefer PORT_BASE_<name> + slot. When that number is already in use, the allocator steps aside by +100, +200, … up to ten times, which keeps the last digit equal to the slot so a stepped-aside port still reads as "lane 3". The number a lane actually got is recorded in lanes.ports and reused on the next boot, so a lane's URL does not move once it has one. A number is rejected when something is listening on it, when another lane has recorded it (a lane whose stack is down still owns its number), or when an earlier port name in the same boot took it.
If every candidate is busy the boot fails with EPORTBUSY, naming the process holding the preferred port.
The hook contract
Before running a hook, CCAM exports:
| Variable | Value |
|---|---|
LANE |
the slot number (not the lane id) |
LANE_ID |
the lane id |
LANE_DIR |
the lane's working copy |
SOURCE_REPO |
the repository it was cut from |
PROFILE_DIR |
the resolved .ccam/profile |
RUN_DIR / LOG_DIR |
pid files and logs, under $LANES_ROOT/.state/lane<slot>/ |
<NAME>_PORT |
one per declared port, upper-cased (PORTS="api fe" → API_PORT, FE_PORT) |
everything in profile.env |
verbatim |
Two shell helpers are injected: die <msg>, and harness_spawn <name> <workdir> <cmd…> which backgrounds a long-lived service with fully detached stdio and records its pid at $RUN_DIR/<name>.pid. Use harness_spawn in boot — a child that inherits the caller's stdout holds that pipe open, and the hook never returns.
Inherited GIT_* variables are scrubbed exactly as server/lib/worktree.js scrubs them, so a hook that shells out to git cannot inherit a git context pointing at the dashboard's own repository.
# .ccam/profile/hooks/boot.sh
#!/usr/bin/env bash
set -euo pipefail
harness_spawn api "$LANE_DIR/$BACKEND_DIR" npm run start -- --port "$API_PORT"
harness_spawn fe "$LANE_DIR/$FRONTEND_DIR" npm run preview -- --port "$FE_PORT"
# .ccam/profile/hooks/health.sh
#!/usr/bin/env bash
set -euo pipefail
curl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$FE_PORT/" >/dev/null
Runnable hook names are a fixed allowlist: bootstrap, boot, health, migrate, seed, ci-gate, e2e, regen, db-create, db-drop. A name from a request is never turned into a path.
Up, down, and surviving a restart
ccam lanes up # boot the lane owning this directory
ccam lanes up 3 --no-build # boot lane 3, reusing an existing build
ccam lanes runtime # slot, ports, service liveness, last boot error
ccam lanes logs api # tail one service or hook log
ccam lanes down # stop the stack
ccam lanes hook ci-gate # run any allowlisted hook
up runs boot then health. It deliberately does not run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running: their logs are what tell you which service never came up, and killing them to report a tidy failure destroys the evidence.
Services are fully detached, so a lane's stack outlives the dashboard. Restarting or updating CCAM does not touch a running lane. Nothing has to re-adopt them either: whether a stack is up is recomputed from pid files and port probes on every read, never cached, because a process can die to OOM or a stray kill without telling anyone.
down kills each recorded pid tree bottom-up (a parent killed first reparents its children to init, where nothing knows to look for them), then — only when there was a pid file — sweeps any listener still on the lane's ports. That condition matters: a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server you started there yourself.
While a hook runs, each output line is broadcast as a lane_hook_output WebSocket message ({ laneId, hook, stream, line }) and also appended to $LOG_DIR/<hook>.log. A boot ends with lane_runtime (fresh facts, or an error); a hook run started through POST /api/lanes/:id/hook/:name ends with lane_hook_result carrying its exit code. That is what lets a lane card show live progress through a multi-minute build instead of a disabled button that reads as a hang.
What the runtime does and does not write
The runtime writes slot and ports on the lane row, and nothing else. It never writes stage, status or notes.
In CCAM those describe the agent's work, not the stack's state: status=running means a session is working, and a booted server is not a session. Conflating them would corrupt lane liveness. This is the same boundary as "the console never writes a lane's stage". Boot failures live in $LANES_ROOT/.state/lane<slot>/last-error.json and surface through GET /api/lanes/:id/runtime.
Adopted lanes may be brought up and down — up only runs what you could run yourself — but every path that writes into their working copy is refused.
Runtime environment variables
LANE_MAX_SLOTS(default9) — how many lanes may hold a runtime at once. Nine keepsbase + slotreadable as a single digit; raising it costs that readability.LANE_BOOT_TIMEOUT_MS(default900000) — hard limit on theboothook.LANE_HEALTH_TIMEOUT_MS(default180000) — hard limit on thehealthhook; a health check that never returns is a failed boot, not an eternal wait.LANE_PORT_PROBE_MS(default300) — connect timeout when probing whether a port is in use.
Data isolation: database, Redis and .env (A2)
Two lanes running their stack at once need more than separate ports — they need separate data, or one lane's migration corrupts the other's session. This is the other half of runtime isolation, and every piece of it is off by default: a profile that never declares DB_PREFIX gets no database, REDIS=1 gets no Redis index, and no ENV_FILES gets no .env writes at all.
Profile declarations (.ccam/profile/profile.env, all optional):
DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB
DB_KIND="postgres" # informational
DB_URL_SCHEME="postgresql" # DATABASE_URL scheme
REDIS=1 # 1 = allocate a logical Redis index = slot
# (stock Redis ships 16 logical DBs, 0-15 — keep
# LANE_MAX_SLOTS <= 15 if REDIS=1 is declared)
ENV_FILES="backend/.env" # file(s) to seed, relative to the lane
ENV_SOURCE="backend/.env" # source path in the source repo (defaults to ENV_FILES)
ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys CCAM overwrites per lane
ENV_PRESERVE="JWT_SECRET" # keys kept from the lane's OWN file on a --force refresh
UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR
A ready-to-copy template (including db-create.sh/db-drop.sh for a Postgres-in-docker-compose stack) lives at server/data/profile-templates/postgres-compose/.
Machine-level credentials live at ~/.ccam/secrets.env (mode 0600), never in the repo:
PG_HOST=127.0.0.1
PG_PORT=5432
PG_USER=postgres
PG_PASS=postgres
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Parsed with the same literal KEY=VALUE reader as profile.env — never sourced. A missing file falls back to those same defaults (with a one-time warning); a file readable by group or world is refused outright rather than trusted. No route ever returns a value from this file — GET /api/lanes/:id/runtime reports a database's name and a Redis index, never a connection string.
Who creates the database? A db-create.sh / db-drop.sh hook (already in the allowlist) — CCAM stays stack-agnostic, since createdb vs mysqladmin create vs touch foo.db genuinely differ. Who rewrites .env? CCAM itself: mechanical and identical across stacks, so pushing it into every repo would duplicate the same ~30 lines.
Hook environment additions, present only when their owning declaration is (a db-create.sh that forgot to check DB_PREFIX fails loudly on an unset variable instead of touching a database named "undefined"):
| Variable | Present when | Value |
|---|---|---|
DB_NAME |
DB_PREFIX set |
<prefix><slot> |
DATABASE_URL / TEST_DATABASE_URL |
DB_PREFIX set |
full connection string to DB_NAME / DB_NAME_test |
PG_HOST / PG_PORT / PG_USER |
DB_PREFIX set |
from secrets.env (PG_PASS is deliberately withheld — nothing in the ported hooks needs it) |
REDIS_URL |
REDIS=1 |
redis://<host>:<port>/<slot> |
REDIS_HOST / REDIS_PORT |
REDIS=1 |
from secrets.env |
UPLOAD_DIR |
UPLOAD_SUBDIR set |
<lane>/<UPLOAD_SUBDIR> |
When each step runs:
- Provisioning a new managed worktree (
ccam lanes add --repo) — after the worktree is created: seed.env, runbootstrap, create the database,migrate,seed. Runs once. up— repair.env(a hand-edited or never-seeded file gets fixed), ensure the database exists (cheap when it already does),migrateon every boot (a lane's schema drifts while it sits idle), andseedonly on the boot that actually created the database.reset— refresh.envwith--force(preservingENV_PRESERVEkeys from the lane's own file — swapping in the source'sJWT_SECRETwould 401 a running lane's tokens until reboot), re-runbootstrap(a reset can land on a branch with new dependencies), clear the declaredLANE_DIRS, then drop, recreate, migrate and reseed the database — unless--keep-db, which skips that whole block.remove— drops the database and its_testsibling before the rest of teardown. Best-effort: a failed drop is logged, never blocks removing the lane's record. Never runs for an adopted lane — its data was never CCAM's to create, so it is never CCAM's to destroy, the same invariant that protects an adopted lane's worktree.
A missing source .env falls back to .env.example with a loud warning, never a silent success. And any hook output that echoes a value from secrets.env (the password, specifically) is redacted before it reaches $LOG_DIR/<hook>.log or the lane_hook_output WebSocket message — that stream reaches a browser tab, and a hook debugging its own environment must not publish a database password to everyone watching.
The Workspace page (/run)
The dashboard web UI merges lanes and runs into a single Workspace page accessible at /run (the old /lanes route now redirects here). Top to bottom:
- Header — the page title and four counters (
lanes,running,needs you,dead), plus Add lane. Theneeds youanddeadcounters appear only when they are non-zero, so a quiet header means nothing is waiting on a human. - Detail panel — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
- Console —
RunSetup,RunConsoleandRunHistorybehind a disclosure that starts collapsed. Watching lanes is the default posture; driving one is the exception. Collapsing hides the console with CSS and never unmounts it, so a live run keeps its rendered history and scroll position. - Lane grid — one card per lane, 1 column, 2 at
md, 3 atxl. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, theauto:chip when detection leads, the kind and CI tags, the working-copy facts fromGET /api/lanes/:id/git, the needs-you banner, and the action row.
Run history is per lane, queryable via GET /api/run/history?laneId=<n>.
The UI operates on a working directory (cwd), not a lane id. Starting a run in a cwd that no lane owns calls POST /api/lanes/ensure first, to idempotently find or adopt a lane for that path; a cwd an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through POST /api/lanes/:id/start rather than directly through POST /api/run.
Finding or adopting a lane: POST /api/lanes/ensure
The Workspace page opens on a cwd and needs a lane to exist for that directory without ever creating a duplicate. The ensure endpoint handles this:
curl -X POST http://localhost:4820/api/lanes/ensure \
-H "Content-Type: application/json" \
-d '{"cwd": "/absolute/path/to/work", "title": "My Project"}'
Response:
{
"lane": { "id": 5, "cwd": "/absolute/path/to/work", "title": "My Project", ... },
"created": false
}
- If a lane already owns the
cwd, returns that lane withcreated: falseand HTTP200. - If the
cwdis a subdirectory of an existing lane, returns the parent lane that owns it. - If no lane owns the
cwd, creates an adopted lane and returns it withcreated: trueand HTTP201. - A relative or missing
cwdreturns400 EBADCWD.
Two concurrent calls for the same path always yield one lane: the database's UNIQUE constraint on lanes.cwd decides the winner, and the loser re-reads and returns the winning lane.
Reading a lane's working copy: GET /api/lanes/:id/git
Each lane card shows the branch its working copy is actually on, the short HEAD, that commit's subject, and how much is uncommitted:
curl http://localhost:4820/api/lanes/5/git
{
"available": true,
"branch": "feat/rename-metric",
"head": "9b3e74a",
"subject": "free-text rule mode in the form",
"dirty": 2,
"untracked": 1
}
- A
cwdthat is missing, is not a git repository, or makes git fail returns{"available": false}with HTTP 200. A lane pointing at a plain directory is a normal state, not a fault, and the card simply renders without a git row. - A detached checkout reports the literal
HEADthat git returns, rather than a prettier invention. - An unknown lane id is a
404.
This is not folded into GET /api/lanes on purpose: it shells out to git
three times, and the lane list is polled and re-broadcast on every hook-driven
lane_update. Putting it there would fire a burst of subprocesses behind every
tool call an agent makes. The browser fetches it per card instead, on mount and
every 30 seconds, and a failed fetch is silent.
Running and releasing lanes
The Workspace page starts runs through POST /api/lanes/:id/start, which accepts the same mode and effort parameters as POST /api/run:
curl -X POST http://localhost:4820/api/lanes/5/start \
-H "Content-Type: application/json" \
-d '{"mode": "conversation", "effort": "medium"}'
A finished run releases its lane. When a spawned Claude Code process exits (normally, non-zero, killed, or never spawned), the lane's run_id is cleared, its status returns to idle, and the change is broadcast as a lane_update WebSocket message. This prevents lanes from sitting at running with a dead child.
Runs started through a lane are recorded with the lane's id and are history-queryable:
curl http://localhost:4820/api/run/history?laneId=5
Viewing lanes
List all lanes with their current status:
ccam lanes
This shows each lane's:
- stage: the declared stage (e.g.,
plan,implement,review,done) - status:
running(a session is active),idle(no active session), orprovisioning - liveness:
active(recently updated),dead(silent for too long), oridle(not expected to be running) - progress: percentage of the pipeline completed (0–100%)
- needs_action: a notification if the session is blocked waiting for input
A lane row also gets a ⇢ detected:<stage> suffix when the server's inferred
stage (see "Stage detection" below) leads the declared stage shown in the
row — the same lead/behind comparison the web UI's auto: <stage> chip uses,
so the terminal and the browser never disagree about which one is the
headline. Nothing is printed when the declaration already leads or matches.
The dashboard web UI shows each lane as a card in a grid, with the selected lane's pipeline map visualized above.
Pipeline stages and the five node states
A lane moves through stages defined in a pipeline template (see "Custom pipeline templates" below). The default pipeline has eight stages: intake, plan, implement, tests, review, gate, ship, and done.
The dashboard renders every node in the pipeline in one of five states:
| State | Color | Meaning |
|---|---|---|
| failed | Red | The stage recorded result "fail" — work was attempted and rejected |
| current | Blue | The lane's current declared stage |
| done | Green | The stage was recorded with evidence (an artifact, not just a claim) |
| passed-no-evidence | Amber | The stage was recorded without evidence, OR was skipped (a later stage was reached) |
| pending | Gray | Not reached yet |
Why passed-no-evidence is amber, not green
A stage in this state was claimed but not proven. Two scenarios:
- Declared but no artifact left. A skill called
ccam stage reviewwithout--evidence, claiming the stage happened but leaving no proof that work occurred. - Implicitly skipped. The lane jumped from
plantoimplement(to stage index 2), leavingintake(index 0) inpassed-no-evidencestate.
In both cases, the stage is not done — it's incomplete or untested. Amber signals that the agent claimed to have done this work but the dashboard has no evidence to show it was actually completed. This matters in a pipeline: if your gate checks done not passed-no-evidence, a lane stuck in amber will block until evidence is provided.
Reporting a stage
When a skill reaches a significant milestone, it reports the current stage to the dashboard using ccam stage:
ccam stage implement --evidence "Schema created, migrations run"
This command:
- Declares the stage the work is now at
- Records evidence (optional, but highly encouraged) — a short text description of what was completed
- Moves the progress bar forward in the pipeline
- Broadcasts to the UI so connected dashboards see the update in real-time
Full command signature
ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
Parameters:
-
<stage>(required): the pipeline stage name or alias. The default pipeline aliases are:intake/assigned/claimed/startplan/planning/brainstorm/designimplement/implementing/coding/buildtests/testing/unit/gates/pre-push-gatereview/reviewing/code-review/self-reviewgate/verify/verification/sr-gate/gate-blockedship/pr/pr-open/publishing/commit/pushdone/complete/completed/merged
-
--lane <id>(optional): the numeric lane ID. If omitted, the command resolves the lane bycwd. -
--cwd <path>(optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo. -
--status <s>(optional): the lane's overall status (running,idle,provisioning). Allows bundling a status update with the stage report. -
--evidence <text>(optional): a short description of what was completed at this stage. Moves the node frompassed-no-evidencetodone. -
--note <text>(optional): a longer note or comment, separate from evidence. -
--result pass|fail(optional): explicitly mark the stage as passed or failed. Ignored ifresultwas already set; set once, never overwritten by a re-report.
Example: a skill reporting progress
A build skill might call:
ccam stage implement --evidence "Built and ran tests, 42 tests passing" --status running
Output:
lane #5 → implement (66%)
A later call reports completion:
ccam stage tests --evidence "Pre-push gate: all linters passing" --result pass
Custom pipeline templates
The default pipeline is suitable for feature work: intake → plan → implement → tests → review → gate → ship → done. For different workflows (data pipelines, research phases, deployment stages), you can provide custom templates.
Where templates come from
- Built-in:
server/data/pipelines/default.json(always available) - User override: directory specified by the
DASHBOARD_PIPELINES_DIRenvironment variable
If both exist, files in the user directory override built-ins with the same id.
Custom template format
Create a JSON file in your custom pipeline directory (e.g., ~/.ccam/pipelines/ml-training.json):
{
"id": "ml-training",
"name": "ML Training Pipeline",
"nodes": [
{ "id": "data", "label": "data", "icon": "📊", "gate": false, "aliases": ["intake"] },
{ "id": "preprocess","label": "preprocess","icon": "⚙️", "gate": false, "aliases": ["prepare", "clean"] },
{ "id": "train", "label": "train", "icon": "🧠", "gate": false, "aliases": ["training", "fit"] },
{ "id": "eval", "label": "eval", "icon": "📈", "gate": true, "aliases": ["evaluate", "metrics"] },
{ "id": "deploy", "label": "deploy", "icon": "🚀", "gate": false, "aliases": ["ship", "release"] }
]
}
Schema:
- id (string, required): unique identifier for this template
- name (string, optional): display name shown in the UI
- nodes (array, required): list of pipeline stages, in order
- id (string): stage identifier (used in
ccam stage) - label (string, optional): display name in the UI; defaults to
idif omitted - icon (string, optional): unicode emoji or single character shown before the stage name
- gate (boolean, optional):
trueif this stage blocks unless explicitly passed; defaults tofalse - aliases (array, optional): alternative names for the stage (e.g.,
["training", "fit"]lets you callccam stage training)
- id (string): stage identifier (used in
Then set the environment variable:
export DASHBOARD_PIPELINES_DIR=~/.ccam/pipelines
When you create a new lane, you can optionally specify which pipeline to use:
ccam lanes add --cwd /path/to/ml-repo --title "Training run #1" --pipeline ml-training
(The pipeline is selected at creation time. Lanes created without --pipeline default to the default pipeline.)
Per-feature state and archive
A lane can carry many features (identified by slug) across its lifetime. Each feature has its own saved pipeline state (stage, status, notes) that persists separately from the live lane — the one every session and the Workspace console observe.
Why: clearLane used to erase the entire lane on each new feature. Now a lane can accumulate many features' saved states, letting you switch between them (via ccam feature activate) and keep their pipeline histories separate. This matters for proof galleries, parallel task batches, and any flow where one lane runs many intentional phases.
The opt-in model
Nothing changes for a lane that never calls ccam feature activate — clearLane still erases the live stage and status rows exactly as before. Only when you opt in to features does a lane start archiving. At that point:
clearLanearchives the current active feature (if any) before clearing the live lane row — its saved stage goes into the archive- A lane that has never activated a feature is unaffected by this change and works exactly as it did before feature support
Slug canonicalization
Every feature is identified by a canonicalized slug. The rule is:
- Drop a leading
feat/prefix if present - Replace
/and whitespace with- - Keep
[A-Za-z0-9._-]only - Do NOT lowercase — slugs preserve case
This is a deliberately different rule from worktree.js:slugify's branch-name slugification (which lowercases). The two must never be conflated. Every endpoint and CLI command echoes back the canonicalized form so the caller knows the exact slug that was stored.
Activate semantics
ccam feature activate <slug> (or the API's POST /api/lanes/:id/features/activate):
- Archives the current active feature (if any and if different from the target slug) — copies its live stage/status/notes to the archive
- Restores the target slug's saved pipeline onto the live lane row — so switching back to a past feature resumes its pipeline exactly where it left off
- Creates a fresh feature row for a never-seen slug (empty stage/status/notes)
This means activating an archived feature twice resumes the same pipeline both times.
The Workspace feature picker
The Workspace page's feature picker (read-only) shows every archived and active feature for the lane. Selecting one displays its saved pipeline. It never changes the live lane — a read-only view matching the standing rule that "the console never writes a lane's stage". Changing the active feature requires the CLI or the API route, not a UI button.
CLI commands
ccam feature list [<id>] # List every feature (archived or live)
ccam feature activate <slug> [--title text] [<id>] # Switch to a feature (echoes canonicalized slug)
ccam feature show <slug> [<id>] # Show one feature's saved pipeline
Omit <id> to address the lane owning the current directory.
Stage detection
Besides the stage a skill explicitly declares with ccam stage, the dashboard
also infers a stage from the tool events every hook already delivers
(POST /api/hooks/event). This is a best-effort heuristic for
un-instrumented sessions that never call ccam stage — it fills in a lane's
pipeline map without requiring every skill to be rewritten.
The evidence boundary
An inferred stage is never evidence and never renders as done. Green
(done) requires a stage the agent explicitly declared with evidence; an
inference can reach dashed amber at most — visually distinct from both solid
green done and solid amber passed-no-evidence
(client/src/components/lanes/PipelineMap.tsx). This boundary exists because
detection is a guess from tool names and a handful of string fields, not
proof that the agent actually finished the stage — a Bash call matching
npm run test could be a flaky retry, not a passing suite. Treating it as
proof would let a lane's pipeline map lie about what actually happened.
The node the agent declared itself on is never flagged as detected, so it
keeps its blue current ring — including when the declaration came in through
an alias (ccam stage coding resolves to the implement node, and stages is
keyed by the raw declared word, not the node id).
The same rule governs ccam lanes: the inferred stage is printed only when it
leads the declared one (see "Viewing lanes" below), and LaneCard.tsx's
detectionLeadsDeclaration shows its auto: <stage> chip under the identical
condition — the terminal and the browser never disagree about which one is
the headline.
Which lane a signal is credited to
A hook's cwd is the session's directory, not the directory the command ran
in. Measured on a real install: 325 of 400 events carried the session's cwd
while the edits and test runs happened in another repo reached with
cd <other> && …. The lane doing the work detected nothing; the lane the
terminal started in absorbed all of it.
So resolveLaneForWork (server/lib/lanes.js) prefers a lane named by the
tool's own input — the file_path being edited, an absolute path a command
cds into — and falls back to the session's cwd when the input names no other
lane. Deepest match wins, same rule as resolveLaneByCwd.
Only the stage inference follows the work. session_id and needs_action stay
on the session's own lane, because those really are session-scoped facts: the
session is bound to the directory it started in even while it operates
elsewhere.
What signals are read
server/lib/stage-detect.js inspects every incoming hook's tool_name and
tool_input (touchLaneFromHook in server/routes/hooks.js calls detect()
on each hook). Detection runs ahead of the lane's needs_action/session_id
bookkeeping — that block returns early when there is nothing to patch, so
detection could not live after it — and therefore sits in a try/catch of its
own: a throwing recordDetection (ENOLANE on a lane deleted mid-hook,
SQLITE_BUSY) must never cost the bookkeeping that predates it. Only these
tool_input fields are read — never old_string/new_string or other editor payloads that can
contain whole code blocks:
commandfile_pathskillpromptpatterndescriptionsubagent_type
The signal shown to the user is the span the rule's regex actually matched,
plus 24 characters of context on each side, with an ellipsis marking either end
that was trimmed. A shell line like
cd /home/very/long/path && npm run test:server 2>&1 therefore reports
`…path && npm run test:server 2>&1` rather than the whole command. A rule
with no match fired on the tool name alone and has no span, so it falls back
to the flattened input. Either way the result is capped at 120 characters
(collapsing whitespace first) and stored as detected_signal — this is why a
lane's tooltip sometimes shows a file path or a skill name rather than a shell
command: it's whichever of the fields above the matching rule's tool carried.
Detection expires
Forward-only would otherwise park a lane at the highest stage it ever touched:
a session that ran the test suite reaches tests and can never show
implement again, even while the agent is back editing code.
So a standing detection holds the forward-only floor only while it is fresh.
Once detected_at is older than DETECTION_TTL_MS (default 300000, i.e. 5
minutes), the comparison against detected_stage is skipped and the new signal
wins regardless of direction. A detected_at that is NULL or unparseable counts
as stale — an unknown age cannot be proven fresh.
Five minutes, not thirty. A session cycles implement → tests → ship → implement
→ tests within one sitting, and a thirty-minute hold pinned the lane at the
furthest stage it ever touched: one push left a lane reading ship while the
agent was demonstrably back to running tests. Five minutes is still far longer
than a burst of tool calls, so the anti-flap property is unaffected.
Two things the expiry deliberately does NOT do:
- It does not weaken declared-wins. An agent's own claim never expires: a
lane declared at
reviewstill refuses animplementdetection, stale standing detection or not. - It does not let inference render
done. Everything in "An inferred stage is never evidence" above still holds. The TTL changes which detection is current, never what a detection is worth.
Set DETECTION_TTL_MS in the server's environment to change the window; it is
read per call, so no restart is needed for a test harness that sets it.
Where the rules live
Detection rules are not code — they live in the pipeline template itself, as
a detect array on each node. The built-in default.json
(server/data/pipelines/default.json) ships these rules today, node by node:
| Node | Detect rules |
|---|---|
intake |
none — see below |
plan |
Skill matching brainstorming|writing-plans; Write matching docs/.*plan.*\.md |
implement |
Edit (any); Write to any path NOT under a docs/ directory (^(?!.*(?:^|/)docs/)) |
tests |
Bash matching \b(npm (run )?test|pytest|vitest|jest|go test|cargo test|node\s+--test)\b |
review |
Skill matching code-review|requesting-code-review; Bash matching \bgh\b[^;&|]*\bpr diff\b |
gate |
none — see below |
ship |
Bash matching a git push invocation — git, then option groups, then push as the first non-flag token — or gh … pr create |
done |
none — see below |
git diff is deliberately NOT a review signal. It was, and measured on a
real session four git diff --stat runs — used to verify a generated file —
outranked 81 test runs and 30 edits and pinned the lane at review, because
detect() takes the LAST matching node and recordDetection is forward-only.
Reading a diff is constant background activity, so it carries no stage
information. gh pr diff stays: that one really is a review act.
The ship pattern matches a git invocation, not the word push. Two
weaker forms were tried and both failed against real commands:
git[^;&|]*push(same shell segment) misses this repo's own push, becausegit -c credential.helper='!f() { … }; f' pushputs a;between the two inside a quoted value. Six real pushes went undetected that way.git[\s\S]*push(anywhere aftergit) over-matches, and did so on a live lane within minutes:git log --oneline -1 && echo "… push …"pinned the lane atship. Sinceshipsits near the end of the pipeline and detection is forward-only, that single over-read stuck — the same failuregit diffcaused forreview.
So the pattern consumes option groups explicitly, treating a quoted option value
as one unit, and then requires push to be the first non-flag token. That
accepts git push, git -c core.hooksPath=/dev/null push and the credential
-helper form, while rejecting git log … "push", git commit -m "don't push",
docker push, and npm run push-docs.
intake, gate, and done deliberately have no rules. intake is where a
lane starts — there is no tool event that means "just claimed," so there is
nothing to detect. gate and done are explicitly out of scope for
inference (see the task's "Out of scope" list): a gate's pass/fail is a human
or skill decision, and done is the one state detection must never reach on
its own — inferring either would let a heuristic assert an outcome instead of
observing activity.
To add a rule, drop a template into the directory named by the
DASHBOARD_PIPELINES_DIR environment variable (see "Custom pipeline
templates" above) with a detect array on the node you want to match:
{ "id": "implement", "label": "implement", "detect": [
{ "tool": "Bash", "match": "docker build" }
] }
match is a JavaScript regular expression tested against the flattened
tool_input string; omit match entirely to match on tool name alone. An
invalid regex is skipped rather than blocking hook ingestion.
Forward-only, write-on-change
Detection never rewinds a lane and never fights a declaration:
- Forward-only. A detection only overwrites
detected_stagewhen its node index is strictly greater than the currentdetected_stage's index — reading a file after editing it must not drag a lane back toplan. - Declared beats detected. A detection is written only when the lane's
declared stage index is strictly less than the detection's. A lane
already declared at
reviewignores animplementdetection outright. - Write-on-change only. The database write (and the
lane_updateWebSocket broadcast) happens only whenrecordDetectionreports an actual change. A real install saw 29,470Bashevents alone from one project — writing to the DB and broadcasting on every matching hook, rather than only on a genuine advance, would turn routine ingestion into a DB write and a WS frame per tool call.
Detection writes only detected_stage, detected_signal, and detected_at
(server/lib/lanes.js's recordDetection) — it never writes lanes.stage,
the declared field. The agent's own declaration is the only path to a stage
being recorded as done.
Liveness: detecting dead lanes
The dashboard watches for silent sessions using two rules:
The liveness rule
A lane's liveness state (shown as active, idle, or dead) depends on:
- Lane status (
running,idle,provisioning) - Stage name (does it match
watch|pollregex?) - Age (how long since the last event?)
Idle lanes are never dead. If a lane's status is idle, it's at rest by design — no session is expected to be running. A session might connect later.
Watching sessions die after silence. When a lane's stage matches the pattern /watch|poll/i (e.g., stage watch-for-updates), the session is expected to emit heartbeat events. If it goes silent for longer than LANE_DEAD_SEC (default 300 seconds), the dashboard marks it as dead. A watcher with no events is a dead process; an idle session with no events is just idle.
Configuring the timeout
Set the environment variable to change when a watcher is considered dead:
export LANE_DEAD_SEC=600 # 10 minutes instead of 5
Housekeeping: ccam lanes gc
Runs machine-wide, not against one lane — safe to run any time, running lanes and live sessions are unaffected:
ccam lanes gc [--dry-run]
Two things, both scoped to what's actually stale:
- Reaps orphaned Playwright MCP processes. A
.playwright-mcp-scoped process whose owning Claude Code session died gets reparented to pid 1 — a live session's MCP keeps its real parent and is left alone. Kills the whole process tree so a spawned browser doesn't leak too. - Caps oversized hook logs. Any
LANES_ROOT/.state/lane<N>/logs/*.logover 10MB is truncated in place to its last 2MB (same inode — a concurrent writer's file descriptor stays valid).
--dry-run prints what would happen without doing it. This is a deliberately narrower port of Shipyard's lane-gc.sh — it does NOT auto-remove stale worktrees by age (that would violate this repo's own never-automatic-destroy rule; use ccam lanes reset|remove|purge explicitly instead), archive feature state (CCAM's lane/feature state lives in the database, not flat files), or sweep scratch debris (CCAM doesn't generate the files Shipyard's own scripts did). See docs/superpowers/specs/2026-08-05-lane-gc-design.md for the full scoping rationale.
Lane actions
The web UI and CLI provide these actions on a lane:
start
Launch a new Claude Code session bound to the lane. If the lane has a recorded session_id from a previous run, you can resume it instead with --resume.
curl -X POST http://localhost:4820/api/lanes/5/start \
-H "Content-Type: application/json" \
-d '{"prompt": "continue the work", "resume": true}'
mode accepts the same two values as POST /api/run — "conversation" (the
default: multi-turn, message keeps working) or "headless" (one shot, the
prompt goes in argv and the process exits when the turn finishes). Unlike
POST /api/run, an unknown value is refused with 400 EBADMODE rather than
silently treated as a conversation.
A finished run releases its lane. When the child truly exits — normally,
non-zero, killed, or never spawned at all — the lane's run_id is cleared, its
status returns to idle, and the change is broadcast as lane_update. So a
lane never sits at running behind a dead run, and message reports
409 ENORUN instead of targeting one. Runs started this way are recorded with
the lane's id and are listable via GET /api/run/history?laneId=<n>.
Currently the prompt field does not populate the input field in the UI (see "Known limitations" below).
message
Send input to a running session. The lane must have a live run_id (active session). The needs_action flag clears automatically when the message is delivered.
curl -X POST http://localhost:4820/api/lanes/5/message \
-H "Content-Type: application/json" \
-d '{"text": "approved, proceed"}'
stop
Terminate the running session. If the lane has no live run, this is a no-op (no error).
curl -X POST http://localhost:4820/api/lanes/5/stop
clear
Reset the lane's pipeline progress, stages, notes, and inferred stage. The lane itself persists; only its recorded history is cleared.
curl -X POST http://localhost:4820/api/lanes/5/clear
remove
Delete the lane entirely, including all its history. Requires confirmation (confirm: true):
curl -X POST http://localhost:4820/api/lanes/5/remove \
-H "Content-Type: application/json" \
-d '{"confirm": true}'
Actions gated behind confirmation: remove requires the confirm flag to prevent accidental deletion.
Proof gallery
A lane's proof gallery is a collection of QC screenshots captured during active work, grouped by feature (the same slug from Per-feature state and archive above) and phase (e.g. desktop, mobile, e2e, visual). A QC agent running an MCP server like playwright-mcp captures screenshots to disk at <lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/ and generates an optional ticket report at <slug>/ticket/REPORT.html. The Workspace gallery panel displays these files grouped by feature, using the same feature picker as the per-feature state section.
Storage
Proof files live inside the lane's own cwd, not under LANES_ROOT:
<lane.cwd>/.playwright-mcp/proof/
├── auth-redesign/
│ ├── desktop/
│ │ ├── 01-login.png
│ │ └── 02-signup.png
│ ├── mobile/
│ │ ├── 01-login.png
│ │ └── 02-signup.png
│ └── ticket/
│ └── REPORT.html
├── another-feature/
│ ├── e2e/
│ │ └── 01-user-flow.png
│ └── ticket/
│ └── REPORT.html
└── …
Linking proof directories (the ensure_proof_link primitive)
An MCP server's --output-dir flag may point to different locations across runs. To converge all proof onto one canonical path regardless, CCAM provides the ensure_proof_link operation, exposed only as the explicit ccam lanes proof-link CLI command — it is never automatic. This matches the "CCAM does not orchestrate" rule: a session decides when to link, not the dashboard.
ensure_proof_link works as follows:
- If a stray
proof/directory exists at<lane.cwd>/proof, merge its contents into the canonical.playwright-mcp/proof(no-clobber — existing files are never overwritten) and replace it with a symlink. - If a
proof/symlink already points to.playwright-mcp/proof, the operation is a no-op. - If a file named
proofexists (neither directory nor symlink), it is left untouched to avoid clobbering a user's file.
The operation is idempotent and always safe — running it multiple times on the same lane has no side effects.
Security
Every path the proof module touches — reading, serving, or deleting files — is resolved, realpath'd, and checked to be inside the lane's proof root before any filesystem operation. Symlink escapes, path traversal (../), and bad characters (/, \, .. in segment names) are all rejected with EBADPATH errors. deleteProof never removes the ticket/ directory, only the phase-group subdirectories and their images, so a lane's ticket report persists even when phase proofs are pruned.
Gallery panel and feature selection
The Workspace page's gallery panel reuses the feature picker from Per-feature state and archive — there is only one selector, not a separate one for proofs. Selecting a feature displays that feature's saved pipeline (from per-feature state) and its proof gallery (if any proofs exist for that feature).
Cross-lane named locks
Cross-lane named locks serialize work that thrashes a shared machine — builds, e2e runs, database migrations — across all lanes, not just within one lane. This is a separate axis from withLaneLock, the in-process per-lane lock in server/lib/lane-lock.js: lane-lock is about when a lane runs internal operations; named-lock is about which other lanes must wait.
How they work
- Atomicity via
mkdir. Lock ownership is declared via anownermarker file insideLANES_ROOT/.locks/<lock-name>/.mkdiratomically creates the lock directory or fails withEEXIST— no race between check and create. - Owner file format:
<holder> <acquired-epoch-seconds>— the holder identity (defaults tolane<slot>for the calling lane) and a Unix seconds timestamp. When a new acquire finds the directory already exists but the holder is stale (older thanLOCK_MAX_HOLDseconds, default 2700s / 45 minutes), the old holder is considered dead, its directory is removed, and the acquire is retried once before answering. - The
LOCK_MAX_HOLDfloor. The default is 2700 seconds, but it has a hard floor of 300 seconds. No caller can force-break a live holder by settingLOCK_MAX_HOLD=1— the floor prevents that mistake. The lowest possible break threshold is 300 seconds, even ifLOCK_MAX_HOLDis overridden to something smaller. - Waiting and polling.
ccam lock acquireblocks the calling lane (polling the server every ~2 seconds via a single-shotPOST /:name/acquire— the server itself never blocks or queues) until the lock is free or--timeoutexpires. While waiting, it prints a status line every ~60 seconds, so a long wait reads as "still waiting", not a hung command — this is terminal output for whoever is watching, not a dashboard liveness signal.
The CLI
ccam lock status [<name>]
Show one named lock's current holder, or every currently-held lock. <name> is optional — without it, lists all locks.
ccam lock acquire <name> [--holder X] [--timeout N]
Acquire a cross-lane named lock, polling until it is free or the timeout expires. --holder defaults to lane<slot> (the calling lane's identifier based on its slot). --timeout is in seconds; without it, the command waits indefinitely.
ccam lock release <name> [--holder X]
Release a lock. Refused with status 409 if the --holder does not match the current owner. The --holder default is the same as acquire: lane<slot>.
The lane card
The lane card shows a lock badge when the lane holds a named lock (its lane<slot> identity matches a held lock's holder) — polled every 30 seconds, the same interval as git and runtime facts. There is no server-side concept of "who is waiting" to display; only who currently holds.
Etiquette
Waiting is normal. A lane that sits at a lock for a few minutes while another finishes a build is expected behavior, not a failure. Do not interrupt or force-break a lock.
- Never kill a holder. If a lane is stuck holding a lock, do not kill the process or the dashboard. Investigate why the holder is not releasing it.
- Never delete the lock directory by hand. A lock that outlives its holder (e.g. after a hard reboot) is cleaned up automatically — the next
acquireon that name finds it stale (older thanLOCK_MAX_HOLD) and breaks it. DeletingLANES_ROOT/.locks/<name>/yourself only races whoever else'sacquireis about to do the same check safely. - Never shrink
LOCK_MAX_HOLDto force through a wait. The 300-second floor exists specifically to stop this mistake. If the true holder is gone, the 300-second minimum wait is the price of safety. If the true holder is still running (e.g., a build with a network stall), shortening the timeout from 2700 to 300 does not help — it converts a slow success into a premature timeout, leaving the lock held and every other lane blocked forever.
The ship-feature-lane skill (E1)
The ship-feature-lane skill is the ported Shipyard driving skill for autonomous end-to-end feature pipelines in a single CCAM lane. It coordinates the entire development flow: implementation (TDD) → pre-push CI gates + dev preflight → e2e testing → code review → local QC → senior gate authorization → PR push and publication → CI watch → final report.
Installing the skill (once per machine)
/ship-feature-lane only works once Claude Code can discover the skill from any lane's working directory — Claude Code auto-discovers .claude/skills/ only from the repo that owns it, and this skill lives in ccam-lanes' own checkout, not in any lane's repo. Install it globally first:
ccam skills install
Copies .claude/skills/ship-feature-lane/ into ~/.claude/skills/ship-feature-lane/ (overwrites on reinstall — run it again after pulling an update to the skill). One-time per machine, same as ccam lanes agents install is one-time per lane.
Invoking the skill
# Inside a lane's working directory:
/ship-feature-lane <requirement>
The skill frontloads all clarifying questions once, then runs unattended. The only interactive touchpoints are Stage 0 (Q&A intake) and merging the PR on GitHub; everything else runs to completion or escalates with a blocked status visible on the dashboard. Progress is declared via ccam stage commands at every step.
Current status: stages 0–8, 10–12, 14 work; stages 9, 13 deferred; stages 6–7 agent-gated
Stages implemented now:
- 0-8: Intake & frontloaded Q&A → plan → implementation → gates+preflight → e2e on feature branch → code review → QC plan → publish PR
- 10-12: CI watch on the PR → report → watch PR until merge (gated fix-loop re-entries on review feedback)
- 14: Done
Stages currently hardcoded skipped (pending F's integrations):
- 9 (Ticket): Ticket-filing integration not yet built — this stage is skipped entirely.
- 13 (Post-merge verification): Dev-CI-wait and dev-QC integrations are off — the stage transitions immediately to
donewith a note explaining the gap.
Stages 6 and 7 are now unblocked — run ccam lanes agents install once per lane (see "Installing the QC/gate agents" below) before a lane's first run through Stage 6. A lane that reaches Stage 6/7 without having installed the agents will fail to find the qc-local/senior-gate-reviewer subagent type; install and re-run.
See the skill text for the full pipeline definition.
QC boot flag and profile integration
When the lane stack needs to run under QC conditions (deterministic, mocked externals), use the --qc flag:
ccam lanes up --qc
This flag activates the lane's QC_BOOT_ENV profile declaration. Define it in the profile's profile.env file as space-separated KEY=value pairs (the same format as the PORTS declaration):
# .ccam/profile/profile.env
PORTS="app"
PORT_BASE_app=3000
QC_BOOT_ENV="MOCK_PAYMENTS=1 STUB_EMAIL=1"
These environment variables are passed to the profile's boot hook during ccam lanes up --qc, ensuring QC test runs are deterministic.
Dev preflight and merge safety: sync-base
ccam lanes sync-base is the ONE sanctioned merge in the pipeline — origin/development into a feature branch — used at Stages 2, 8, and 12. Three modes:
ccam lanes sync-base --check feat/<slug> # read-only preflight: fetch + collision check + DEV_DELTA/DEV_OVERLAP
ccam lanes sync-base feat/<slug> # merge origin/development into the feature branch
ccam lanes sync-base --continue feat/<slug> # finish after a manually resolved conflict
Exit codes: 0 clean, 4 merge conflict (left in place — resolve, commit, then --continue), 5 migration-number collision (nothing merged — rename the printed file, re-run).
Two profile declarations control it, both empty (off) by default:
# .ccam/profile/profile.env
MIGRATIONS_DIR="db/migrations"
GENERATED_MERGE_PATHS="api/openapi.json api/client.ts"
MIGRATIONS_DIR enables the collision preflight against a numbered-migrations directory. GENERATED_MERGE_PATHS gives the listed files a keep-ours merge driver (never hand-merged) and folds the profile's regen hook output into the sync commit — the single most common cross-lane conflict, for a repo that generates an API contract/client.
Installing the QC/gate agents: agents install
Stage 6 (browser QC) and Stage 7 (senior GO/NO-GO gate) run as subagents — qc-local and senior-gate-reviewer — that must exist in the lane's own .claude/agents/ before the skill can launch them:
ccam lanes agents install
Writes both agent templates into <lane>/.claude/agents/ and adds that directory to the lane's local .git/info/exclude (never the tracked .gitignore — this is a per-clone runtime concern, not an app-repo change). Idempotent and never automatic, the same shape as ccam lanes proof-link — a session installs the agents explicitly, once, before a lane's first run through Stage 6/7 (or after a CCAM upgrade ships updated templates — reinstalling overwrites, it doesn't merge).
Credentials are not embedded. Unlike Shipyard's original per-lane agent generation, these templates carry no QA account credentials — this repo has no seeded-QA-account system yet. qc-local degrades gracefully: if it hits a login page with no credentials block present, it reports LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile rather than guessing.
ticketer, dev-qc, and pr-reviewer are not ported yet — the first two are invoked only by Stages 9/13, which are hardcoded-skipped pending F's integrations; the third isn't referenced anywhere in this skill's text.
Syncing MCP servers: mcp sync
A lane needs the same MCP servers (Playwright, a local-QC server) as its source repo to run Stage 3/6. ccam lanes mcp sync gives it those:
ccam lanes mcp sync
Reads the source repo's already-configured mcpServers from ~/.claude.json (normal Claude Code project-scope config — set this up for the source repo once, the same way you would for any project), relocates any absolute path under the source repo to the lane's own directory, pins a @playwright/mcp server's --output-dir to the lane's .playwright-mcp (so proof screenshots land where the proof gallery reads them), and writes <lane>/.mcp.json. Also seeds the lane's Chromium browser profiles from the source repo's own — preserves saved logins, and never overwrites a profile that already exists at the destination.
No permission/settings changes. Unlike Shipyard's original lane-mcp-sync.sh, this command never writes to <lane>/.claude/settings.local.json — no auto-approval rules, no autoMode bypass entries. A session's gh/git push commands go through the normal permission prompt like any other command.
A lane whose source repo has no mcpServers configured gets a clear ENOMCPCONFIG error, not a silently-empty .mcp.json — configure the source repo's MCP servers first, then re-run.
Restart the lane's Claude session after syncing — MCP config is read at session start.
Checking integration toggles: ccam lanes integration
A profile can declare .ccam/profile/integrations.env with TRACKER_ENABLED, DEV_QC_ENABLED, CI_WAIT_ENABLED flags (all off by default — see profiles/_template/integrations.env-style declarations in a profile's own docs). Check one:
ccam lanes integration tracker # exit 0 = on, 1 = off
This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agents that would act on an enabled toggle (ticketer, dev-qc) aren't built yet; ci_wait's consumer (ccam ci) isn't either. ship-feature-lane's Stage 9/13/10 stay skipped/fallback regardless of what the toggle reads, until those land.
Pipeline template: ship-feature (16 node stages)
The skill uses the ship-feature pipeline template, which defines the following 16 stages (node IDs):
- intake — frontloaded Q&A and feature activation
- plan — investigation and plan debate
- implementing — TDD implementation
- gates — CI gates + preflight check
- e2e-feature — run e2e on feature branch
- e2e-feature-passed — e2e result gate
- review — code review
- qc-plan — bound QC scope
- qc — browser QC (agent-gated until qc-local lands)
- gate — senior GO/NO-GO gate (agent-gated until senior-gate-reviewer lands)
- publishing — push branch + open/update PR
- pr-open — PR open and published
- reported — report posted
- watching-pr — watch for comments, conflicts, merge
- merged — PR merged, post-verify transition
- done — pipeline complete
The Workspace page (/run) displays this template with nodes rendered in five states: failed (rejected), current (now), done (with evidence), passed-no-evidence (claimed or skipped), and pending (not reached).
Orchestration: what CCAM does NOT do
CCAM does not chain, queue, retry, or evaluate gates.
The driving Claude Code session is in control. Here's what that means:
- No automatic chaining: The lane doesn't automatically move from one stage to the next. A skill declares the stage explicitly with
ccam stage. - No queue: There is no work queue. When you run a session, it runs immediately; no waiting for dependencies or prior stages.
- No retry logic: If a stage fails (marked with
--result fail), the lane stays at that stage. The next session must be told to reattempt it (the skill logic decides). - No gate evaluation: A gate stage (marked
gate: truein the template) doesn't block the UI, stop a session, or prevent a transition. It's just a visual marker. The skill must check the gate condition and decide whether to proceed.
Example: In a feature pipeline, if the tests stage is a gate, and tests fail:
- A skill calls
ccam stage tests --result fail --evidence "5 tests failing" - The UI shows
testsin red (failed state) - The next session runs and a human/skill must explicitly decide: re-run tests, or skip to ship?
- If re-running:
ccam stage tests --evidence "2 tests still failing" - If skipping:
ccam stage ship --evidence "manual gate override"
The dashboard shows the history; the driving session makes the decisions.
Known limitations
Lanes page snapshot
The Lanes screen currently shows only the empty state in the dashboard's design. Full design and implementation of the populated view (multiple lanes, interactions, pipeline map) is deferred to a follow-up.
Lane card UI
The lane card UI currently has no prompt/message input field. When you click start, it opens a promptless conversation run in Claude Code (no initial input). The message action exists server-side and via the API but is not exposed in the web UI yet. To send messages, use the API directly or the CLI.
Related commands
ccam lanes # List all lanes
ccam lanes add --cwd <path> --title <text> # Adopt an existing directory
ccam lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]
ccam lanes reset|remove|purge <id> [--force] --yes
ccam stage <stage> [--evidence "..."] # Report the current stage
See also
server/lib/lanes.js— lane storage and lifecycleserver/lib/worktree.js— git worktree creation/reset/removal and the three-check destroy guardserver/lib/lane-preflight.js— the read-only preflight facts behind reset/remove/purgeserver/lib/pipelines.js— pipeline templates and node state derivationserver/lib/stage-detect.js— the stage-detection matcher, field allowlist, and signal capserver/routes/lanes.js— REST API surfacebin/ccam.js— CLI commands for lanes and stage reportingserver/routes/hooks.js— hook-to-lane binding by cwd, and where detection is invoked