Files
Claude-Code-Monitor/docs/superpowers/specs/2026-07-28-worktree-lanes-design.md
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
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.
2026-07-30 14:39:03 +07:00

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, 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 --forcegit 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 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.