Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
8.9 KiB
Worktree-backed lanes + Shipyard-style lifecycle — design
Status: approved 2026-07-28. Sub-project C of three (B = stage auto-detection, A = merged Workspace page) — each gets its own spec, plan and execution cycle. C is being built first because it fixes the lane data model that the other two build on.
Problem
A lane today is a pointer at a directory that already exists. Two agents working in parallel therefore work in the same checkout and collide — the exact failure Shipyard solves by giving every lane its own clone. CCAM has no provisioning at all: no way to create a lane's working copy, no way to reset it between features, no way to remove it, and no way to reclaim the database a finished lane leaves behind (a real install reached 121 MB).
Goals
- A lane can own a git worktree that CCAM creates, resets and removes.
- The lifecycle verbs mirror Shipyard's, because that vocabulary is proven:
add,clear,reset,remove, pluspurgeas CCAM's analogue of Shipyard's per-lanedropdb. - Every destructive action is confirmed against counted facts, not adjectives.
- Directories the user already had must be impossible for CCAM to destroy.
Non-goals
- No dependency bootstrap. A fresh worktree has no
node_modules, no.env, no.claude/settings.local.json— all gitignored. Shipyard solves this with per-projectbootstrap/migrate/seedhooks, which is a whole subsystem. Out of scope; documented as a limitation. - No per-lane ports, databases, or Docker services.
- No orchestration. Unchanged from the existing feature: the driving Claude session declares its own stage.
VACUUMis not part ofpurge(see Database reclamation).
Data model
Additive columns on lanes, each guarded by the repo's try { SELECT col } catch { ALTER } probe:
| column | meaning |
|---|---|
kind |
adopted | managed. Default adopted, so every pre-existing row migrates into the safe class. |
source_repo |
absolute path of the checkout a managed worktree was created from |
base_branch |
the branch the worktree was cut from, e.g. development |
slug |
sanitised from the title; used for both the directory and the branch name |
branch, cwd, stage, stages and the rest keep their current meaning. cwd stays UNIQUE.
Environment: LANES_ROOT (default ~/.claude/ccam-lanes), LANE_BASE_BRANCH, LANE_BRANCH_PREFIX (default feat/).
Layout: worktree at $LANES_ROOT/<repo-basename>__<slug>, branch <prefix><slug>. Numbered lane1..lane9 slots were considered and rejected — CCAM is multi-repo, and slot numbers carry no meaning without Shipyard's per-lane ports.
Safety model
adopted lanes expose no destructive verb. No reset, no remove-with-worktree, no branch deletion. The UI hides those controls; the API refuses them.
A managed lane may be destroyed only when all three independent checks pass:
kind === 'managed'- the lane's
cwd, fully resolved (symlinks included), lies insideLANES_ROOT git worktree list --porcelainrun insource_repoactually lists that path
Shipyard gets away with one check (case "$DIR" in */lane$N) because its directory names are fixed. Dropping numbered slots costs that guarantee, so three cheaper checks replace it. Every destructive function in server/lib/worktree.js re-runs the three checks itself rather than trusting its caller.
CCAM never runs rm -rf on a lane. Removal goes through git worktree remove; if git refuses, the error surfaces unchanged.
Verbs
| verb | steps | destructive |
|---|---|---|
add |
resolve base → git worktree add -b <prefix><slug> <dir> <base> → insert lane row kind=managed |
no |
adopt |
today's POST /api/lanes — point a lane at an existing directory, kind=adopted |
no |
clear |
reset stage/status fields only (already implemented) | no |
reset |
kill and await the run → git fetch origin --prune → checkout base → reset --hard <base> → clean -fd → delete the feature branch → recreate it from base → clear lane state |
yes |
remove |
kill and await the run → unlock if locked → git worktree remove --force → git worktree prune → delete the branch → delete the lane row |
yes |
purge |
delete the lane's sessions and their events plus the orphan token_usage rows |
yes |
clean -fd deliberately omits -x, exactly as Shipyard does: gitignored files (node_modules, .env) survive a reset, untracked-but-not-ignored files do not.
Branch deletion never touches main, master, or the lane's base_branch, and only runs after the worktree holding that branch is gone.
Preflight
GET /api/lanes/:id/preflight?action=reset|remove|purge returns counted facts, never prose:
{
"action": "reset",
"lane": 3, "branch": "feat/criteria-form", "kind": "managed",
"dirty": 4, "untracked": 11, "unpushed": 2,
"head": "9b3e74a",
"blocked": ["unpushed-commits"],
"warnings": ["no-remote"]
}
For purge: sessions, events, tokenRows, bytesEstimate, and activeSessionSkipped.
The confirmation modal renders those numbers. The action then re-verifies: the client echoes back the head and counts it was shown, and the server returns 409 if they moved. unpushed > 0 blocks reset, and remove when a managed worktree is actually at risk, unless the request carries {force: true}. unpushed counts what the action would really discard — with no remote that is <base_branch>..HEAD, this lane's own work, not the repository's whole history.
Concurrency
One mutex per lane serialises destructive actions, mirroring repo_lock() in the AutomaticWorkflow bot — where concurrent git operations on a shared checkout produced a real git checkout exit 128, not a theoretical one. A destructive action first kills the lane's run and awaits its exit before touching git.
add can take seconds on a large repo, so it returns 202 with status=provisioning and finishes in the background, broadcasting lane_update on completion — the same pattern Shipyard uses to drive its spinner.
Edge cases and their resolutions
- Branch already exists: if unused,
git worktree addwithout-b; if checked out elsewhere, refuse and name the other path. Slug collisions get a-2suffix. - Base branch missing on origin: resolve
origin/<base>→ local<base>→ the source repo's current HEAD. - Source repo is itself a worktree, or bare: works; git resolves through
--git-common-dir. - Repo with no commits:
worktree addfails and the lane landsfailedwith git's stderr innotes. Preflight does not pre-empt it — the lane is created first, then provisioning reports the git failure, and the row is forgotten withDELETE /api/lanes/:id. - Worktree directory deleted by hand: the lane reports
missingand onlyremoveis offered, taking the prune path. cwduniqueness:removedeletes the row, so re-adding the same slug is clean;resetkeeps the path.
Database reclamation
events cascades from sessions, but token_usage has no foreign key — purge must delete those rows explicitly or leave orphans. The currently-live session is never purged.
SQLite does not shrink on DELETE. purge runs DELETE plus PRAGMA optimize and reports the reclaimable size; VACUUM is a separate, explicitly-labelled maintenance action because it locks the whole database for seconds. Hiding a database-wide lock inside a button labelled "clean up" would be a trap.
Surfaces
API: POST /api/lanes/worktree (add), GET /api/lanes/:id/preflight, and reset / purge joining the existing POST /api/lanes/:id/:action set, all behind the existing same-origin guard.
CLI: ccam lanes add --repo <path> [--title <t>] [--base <branch>], ccam lanes reset|remove|purge <id> [--force].
UI: a managed / adopted badge on the lane card; destructive buttons rendered only for managed; the existing ConfirmModal showing the preflight table.
Testing
Against a real git repository fixture created in a temp directory — no mocks, because every bug worth catching here lives in git's actual behaviour:
- worktree created, listed, removed, pruned clean
- each of the three safety refusals: an
adoptedlane, a path outsideLANES_ROOT, a path git does not list as a worktree resetkeeps gitignored files and removes untracked ones- the unpushed-commit guard blocks, and
forceoverrides it - preflight's counts equal what the action actually changes
purgeremoves sessions, events and token rows, and skips the live sessionaddreturns 202 and broadcastslane_updatewhen provisioning finishes
Known limitations
- A fresh worktree has no installed dependencies or local env files (see Non-goals).
- Nine worktrees of a large repository cost nine working trees of disk; the
addpreflight estimates the size first. --repomay point anywhere the user can read. That is their own machine; validation is limited to "absolute, exists, is a git repo", and every route stays behind the loopback guard.