Files
Claude-Code-Monitor/docs/LANES.md
T
nntrivi2001 4f84d2d7e2 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 13:49:16 +07:00

644 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (0100%)
- **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.
### 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 `1800000`, i.e. 30
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.
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)\b` |
| `review` | `Skill` matching `code-review\|requesting-code-review`; `Bash` matching `git diff\|gh pr diff` |
| `gate` | none — see below |
| `ship` | `Bash` matching `git push\|gh pr create` |
| `done` | none — see below |
`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