# 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/<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:

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 <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:

```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 `<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 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/<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 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 <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 `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.
