57dc91585d
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.
693 lines
39 KiB
Markdown
693 lines
39 KiB
Markdown
# 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`:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
ccam lanes reset <id> [--force] --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`; `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. 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 and its contents untouched.
|
||
|
||
## Lifecycle
|
||
|
||
### The two lane kinds
|
||
|
||
- **`managed`** — a git worktree the dashboard itself created (`ccam lanes add --repo`), rooted under `LANES_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:
|
||
|
||
1. **`lane.kind === "managed"`.** An adopted lane fails immediately with `ENOTMANAGED` — this check alone is what keeps a directory you own off the destroy path.
|
||
2. **The lane's `cwd`, resolved with `fs.realpathSync`, must sit inside the resolved `LANES_ROOT` on a path boundary.** `path.relative(root, cwd)` must not start with `..` or be absolute — `EOUTSIDEROOT` otherwise. This guards against a lane row that was hand-edited or drifted to point somewhere else.
|
||
3. **The resolved path must actually appear in `git worktree list --porcelain`** for the lane's `source_repo` — `ENOTWORKTREE` otherwise. A directory that merely happens to sit under `LANES_ROOT` but 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`, kind `adopted`) or provisions a new managed worktree (`--repo`, kind `managed`, created under `LANES_ROOT` on branch `feat/<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 the `lanes` row; no git operation, no session/event data is deleted.
|
||
- **`reset`** (managed lanes only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits.
|
||
- **`remove`** — for a `managed` lane whose directory exists: `git worktree remove --force`, `git worktree prune`, then a safe branch delete, then the lane row is deleted. For a `managed` lane 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) — a `missing` worktree does NOT make a lane unremovable. For an `adopted` lane: only the lane row is deleted (`removeWorktree` is never invoked for `adopted`, 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 no `force` even with unpushed commits, since nothing on disk is at risk.
|
||
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and 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, 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.
|
||
|
||
`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`** (default `300`) — seconds of silence before a lane whose stage matches `/watch|poll/i` flips from `active` to `dead` liveness.
|
||
- **`LANE_BASE_BRANCH`** (default `main`) — the base branch a new managed worktree resolves against when a request omits `base`. An explicit `--base <branch>` (or request body `base`) always wins over this default.
|
||
- **`LANE_BRANCH_PREFIX`** (default `feat/`) — 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 — bootstrapping that automatically is out of scope for this feature (see `docs/superpowers/specs/2026-07-28-worktree-lanes-design.md`).
|
||
|
||
## 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. The `needs you` and `dead` counters 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`, `RunConsole` and `RunHistory` behind 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 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /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:
|
||
|
||
```bash
|
||
curl -X POST http://localhost:4820/api/lanes/ensure \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"cwd": "/absolute/path/to/work", "title": "My Project"}'
|
||
```
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{
|
||
"lane": { "id": 5, "cwd": "/absolute/path/to/work", "title": "My Project", ... },
|
||
"created": false
|
||
}
|
||
```
|
||
|
||
- If a lane already owns the `cwd`, returns that lane with `created: false` and HTTP `200`.
|
||
- If the `cwd` is 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 with `created: true` and HTTP `201`.
|
||
- A relative or missing `cwd` returns `400 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:
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/lanes/5/git
|
||
```
|
||
|
||
```json
|
||
{
|
||
"available": true,
|
||
"branch": "feat/rename-metric",
|
||
"head": "9b3e74a",
|
||
"subject": "free-text rule mode in the form",
|
||
"dirty": 2,
|
||
"untracked": 1
|
||
}
|
||
```
|
||
|
||
- A `cwd` that 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 `HEAD` that 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`:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/run/history?laneId=5
|
||
```
|
||
|
||
## Viewing lanes
|
||
|
||
List all lanes with their current status:
|
||
|
||
```bash
|
||
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), or `provisioning`
|
||
- **liveness**: `active` (recently updated), `dead` (silent for too long), or `idle` (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:
|
||
|
||
1. **Declared but no artifact left.** A skill called `ccam stage review` without `--evidence`, claiming the stage happened but leaving no proof that work occurred.
|
||
2. **Implicitly skipped.** The lane jumped from `plan` to `implement` (to stage index 2), leaving `intake` (index 0) in `passed-no-evidence` state.
|
||
|
||
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`:
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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` / `start`
|
||
- `plan` / `planning` / `brainstorm` / `design`
|
||
- `implement` / `implementing` / `coding` / `build`
|
||
- `tests` / `testing` / `unit` / `gates` / `pre-push-gate`
|
||
- `review` / `reviewing` / `code-review` / `self-review`
|
||
- `gate` / `verify` / `verification` / `sr-gate` / `gate-blocked`
|
||
- `ship` / `pr` / `pr-open` / `publishing` / `commit` / `push`
|
||
- `done` / `complete` / `completed` / `merged`
|
||
|
||
- `--lane <id>` (optional): the numeric lane ID. If omitted, the command resolves the lane by `cwd`.
|
||
|
||
- `--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 from `passed-no-evidence` to `done`.
|
||
|
||
- `--note <text>` (optional): a longer note or comment, separate from evidence.
|
||
|
||
- `--result pass|fail` (optional): explicitly mark the stage as passed or failed. Ignored if `result` was already set; set once, never overwritten by a re-report.
|
||
|
||
### Example: a skill reporting progress
|
||
|
||
A `build` skill might call:
|
||
|
||
```bash
|
||
ccam stage implement --evidence "Built and ran tests, 42 tests passing" --status running
|
||
```
|
||
|
||
Output:
|
||
```
|
||
lane #5 → implement (66%)
|
||
```
|
||
|
||
A later call reports completion:
|
||
|
||
```bash
|
||
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
|
||
|
||
1. **Built-in:** `server/data/pipelines/default.json` (always available)
|
||
2. **User override:** directory specified by the `DASHBOARD_PIPELINES_DIR` environment 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`):
|
||
|
||
```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 `id` if omitted
|
||
- **icon** (string, optional): unicode emoji or single character shown before the stage name
|
||
- **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false`
|
||
- **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `ccam stage training`)
|
||
|
||
Then set the environment variable:
|
||
|
||
```bash
|
||
export DASHBOARD_PIPELINES_DIR=~/.ccam/pipelines
|
||
```
|
||
|
||
When you create a new lane, you can optionally specify which pipeline to use:
|
||
|
||
```bash
|
||
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.)
|
||
|
||
## 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
|
||
`cd`s 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:
|
||
|
||
- `command`
|
||
- `file_path`
|
||
- `skill`
|
||
- `prompt`
|
||
- `pattern`
|
||
- `description`
|
||
- `subagent_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 `review` still refuses an `implement` detection, 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, because
|
||
`git -c credential.helper='!f() { … }; f' push` puts a `;` between the two
|
||
inside a quoted value. Six real pushes went undetected that way.
|
||
- `git[\s\S]*push` (anywhere after `git`) **over-matches**, and did so on a live
|
||
lane within minutes: `git log --oneline -1 && echo "… push …"` pinned the lane
|
||
at `ship`. Since `ship` sits near the end of the pipeline and detection is
|
||
forward-only, that single over-read stuck — the same failure `git diff` caused
|
||
for `review`.
|
||
|
||
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:
|
||
|
||
```json
|
||
{ "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_stage` when its
|
||
node index is strictly greater than the current `detected_stage`'s index —
|
||
reading a file after editing it must not drag a lane back to `plan`.
|
||
- **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 `review` ignores an `implement` detection outright.
|
||
- **Write-on-change only.** The database write (and the `lane_update`
|
||
WebSocket broadcast) happens only when `recordDetection` reports an actual
|
||
change. A real install saw 29,470 `Bash` events 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|poll` regex?)
|
||
- **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:
|
||
|
||
```bash
|
||
export LANE_DEAD_SEC=600 # 10 minutes instead of 5
|
||
```
|
||
|
||
## 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`.
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
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).
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
curl -X POST http://localhost:4820/api/lanes/5/clear
|
||
```
|
||
|
||
### remove
|
||
|
||
Delete the lane entirely, including all its history. **Requires confirmation** (`confirm: true`):
|
||
|
||
```bash
|
||
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.
|
||
|
||
## 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: true` in 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:
|
||
|
||
1. A skill calls `ccam stage tests --result fail --evidence "5 tests failing"`
|
||
2. The UI shows `tests` in red (failed state)
|
||
3. The next session runs and a human/skill must explicitly decide: re-run tests, or skip to ship?
|
||
4. If re-running: `ccam stage tests --evidence "2 tests still failing"`
|
||
5. 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
|
||
|
||
```bash
|
||
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 lifecycle
|
||
- `server/lib/worktree.js` — git worktree creation/reset/removal and the three-check destroy guard
|
||
- `server/lib/lane-preflight.js` — the read-only preflight facts behind reset/remove/purge
|
||
- `server/lib/pipelines.js` — pipeline templates and node state derivation
|
||
- `server/lib/stage-detect.js` — the stage-detection matcher, field allowlist, and signal cap
|
||
- `server/routes/lanes.js` — REST API surface
|
||
- `bin/ccam.js` — CLI commands for lanes and stage reporting
|
||
- `server/routes/hooks.js` — hook-to-lane binding by cwd, and where detection is invoked
|