# 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`, plus `purge` as CCAM's analogue of Shipyard's per-lane `dropdb`. - 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-project `bootstrap`/`migrate`/`seed` hooks, 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. - `VACUUM` is not part of `purge` (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/__`, branch ``. 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: 1. `kind === 'managed'` 2. the lane's `cwd`, fully resolved (symlinks included), lies inside `LANES_ROOT` 3. `git worktree list --porcelain` run in `source_repo` actually 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 ` → 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 ` → `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: ```json { "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 `..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 add` without `-b`; if checked out elsewhere, refuse and name the other path. Slug collisions get a `-2` suffix. - **Base branch missing on origin:** resolve `origin/` → local `` → 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 add` fails and the lane lands `failed` with git's stderr in `notes`. Preflight does not pre-empt it — the lane is created first, then provisioning reports the git failure, and the row is forgotten with `DELETE /api/lanes/:id`. - **Worktree directory deleted by hand:** the lane reports `missing` and only `remove` is offered, taking the prune path. - **`cwd` uniqueness:** `remove` deletes the row, so re-adding the same slug is clean; `reset` keeps 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 [--title ] [--base ]`, `ccam lanes reset|remove|purge [--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 `adopted` lane, a path outside `LANES_ROOT`, a path git does not list as a worktree - `reset` keeps gitignored files and removes untracked ones - the unpushed-commit guard blocks, and `force` overrides it - preflight's counts equal what the action actually changes - `purge` removes sessions, events and token rows, and skips the live session - `add` returns 202 and broadcasts `lane_update` when 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 `add` preflight estimates the size first. - `--repo` may 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.