# omp-conductor

A 24/7 dispatcher that takes `ready-for-agent` GitHub issues to green, mergeable
PRs using omp coding sessions. When a run cannot finish on its own it escalates in
tiers: first to an orchestrator session that can re-brief the worker, then to you.

## What it is

You label an issue. Within one tick the conductor claims it on the tracker, cuts a
worktree, hands one omp worker a self-contained brief, and watches it to a green
PR. The worker then stops: it does not merge, tag, publish or deploy.

### Scope

One issue, one green PR. That is the worker's whole remit. A change that needs a
merge or release is reported to the orchestrator, whose `merge` and `release`
authority are chosen during setup and default to `human`. Granting either action
to the orchestrator never grants it to a worker or the dispatch daemon. Releases
remain batched from coherent groups of merged work, never cut one per worker PR.

Code counts every limit that decides whether work starts: concurrency, dollars
per day, turns and wall clock per worker, failed implementation attempts, and
operational continuations per issue. None is left to the model. The dispatcher
enforces these budgets before anything is claimed and kills workers that exceed
their per-run limits.

When a run does get stuck, the first responder is not you. A tier-1 escalation is
injected into a long-lived **orchestrator session** that can read the issue and the
run's transcript and then either re-brief the worker or decide the problem genuinely
needs a human. It never edits product code and never pushes a branch; whether it
may merge or release is a setup answer (`authority`), and both default to no. Only
tier 2 pages you directly.

The package ships two deployables:

| Deployable | Entry | What it is for |
| --- | --- | --- |
| Everything an operator does | `omp-conductor` binary | The only operator surface: setup, inspect, control, and the dispatch loop itself as a background process (`start` / `stop` / `restart`) with a `/healthz` endpoint for a supervisor. |
| Orchestrator heartbeat | omp extension, activated by `.conductor-tick.json` | Prompts a 24/7 orchestrator session on a fixed interval so its standing loop actually runs, and marks the session stalled when its prompts stop being consumed. Inert in every other session — including a second session opened in the fleet's own directory. See [Orchestrator tick](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#orchestrator-tick). |

**There is no slash command and no skill.** Earlier releases shipped a
`/conductor` command and a `skill://conductor-onboarding`; both are gone. The
interview they wrapped now lives in the binary as `omp-conductor setup`, which is
the documented path for every operator and agent — see [Onboarding](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#onboarding).
An omp chat session that wants conductor state or wants to change it **shells out
to `omp-conductor`**, which is why the verb ledger records who asked: a session
that ran a verb is indistinguishable from an operator who ran the same verb, and
that is the point.

Claiming is gated by the pause flag; tick sends are gated by the arm marker — they
are not the same switch. Prefer `hold` when you want both quiet.

## Your workflow vs. the package

**This package stops at green PRs.** The boundary is in the worker brief and in
the loop itself: no worker merges, tags, pins, deploys or publishes, and neither
does the orchestrator. Everything past a green PR — when to merge, what to batch
into a release, what to deploy — is *your* workflow, and the package deliberately
holds no opinion about it that it could act on.

Your opinion goes in `ORCHESTRATOR.md`, the standing prompt for the long-lived
session that supervises the fleet. That file is yours: the conductor renders it
once, on request, and then never reads it back, never rewrites it and never
enforces a word of it. What you write there binds your orchestrator session and
nothing else in this package.

`omp-conductor setup` offers to render the shipped template
(`src/briefs/orchestrator.md`) to `<workspaceRoot>/ORCHESTRATOR.md` with your
project's coordinates filled in, and never replaces an existing file without a
second, explicit confirmation. It is a starting point rather than a contract:

| Section | Whose |
| --- | --- |
| Duties (drain, groom, report), escalation tiers, hard boundaries | **Fixed** — they describe how this package already behaves. |
| Releases | **Yours.** Its opening paragraph is rendered from the `authority` you answered in setup — by default "humans release, and you do not merge". Everything under it is the procedure, and a delegated session is told not to cut a release until you have written one: what, when, on what proof, and what stays permanently forbidden. |
| Reporting | **Yours**, seeded from the scope you chose in setup. |

Reporting is the one half of that the config also knows about, because the wizard
has to ask something in order to seed the brief, and it is the one half the
runtime acts on:

| Setup choice | What may interrupt | What waits |
| --- | --- | --- |
| `material` | Every configured material event, when operator availability permits. | Non-bypass events outside configured hours wait for the next configured digest or opening. |
| `escalations` | Tier-2 escalations, when operator availability permits. | Everything else waits for the configured digest. |
| `decisions` (recommended) | Tier-2 decisions and fleet-stopping conditions, when operator availability permits. | Every other material event is recorded durably and ships with the configured digest as one message. |
| `quiet` | Tier-2 escalations, fleet stops, and confirmed failures, when operator availability permits. | Everything else waits for one daily rollup. |

## Install

```bash
omp plugin install omp-conductor
```

That installs the `omp-conductor` binary and the orchestrator heartbeat extension.
It registers **no slash command and no skills** — everything an operator does is a
verb on the binary, starting with `omp-conductor setup`.

After install — and after every `omp-conductor upgrade` — run
`omp-conductor doctor` once: it checks the deployment faults that have
previously cost debugging sessions (gh auth/scopes, exact-case labels, systemd
unit drift and dir ownership, config + backup freshness, sqlite integrity,
spend telemetry, reporting timezones, Telegram health) and exits 0 only when
nothing failed.

From a checkout of the monorepo, `./setup.sh` checks both plugins. It preserves
an existing npm-managed `omp-conductor` and links only the Herdr half, so running
setup on a release-based fleet cannot silently switch omp to mutable source.
`./setup.sh install --force-link` is the explicit opt-in to link both checkout
directories.

For what the host needs before anything runs — `bun`, an authenticated `gh`,
`git`, a model credential the daemon's own account can reach, and
`omp-telegram` for the escalation channel — see the
[Install prerequisites](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#install-prerequisites) in the reference.

### Shell completions

`omp-conductor complete` generates completions for zsh, bash, fish, and
PowerShell. Load them for the current shell:

```bash
source <(omp-conductor complete zsh)
# or
source <(omp-conductor complete bash)
```

For a persistent zsh install:

```bash
mkdir -p ~/.omp/conductor
omp-conductor complete zsh > ~/.omp/conductor/completions.zsh
echo '[ -f ~/.omp/conductor/completions.zsh ] && source ~/.omp/conductor/completions.zsh' >> ~/.zshrc
```

Use `bash` and `~/.bashrc` for the equivalent bash install. In fish, run
`omp-conductor complete fish | source`; in PowerShell, run
`omp-conductor complete powershell | Out-String | Invoke-Expression`.
After a successful interactive setup, the wizard offers to install zsh or bash
completions this way and adds the rc source line only when it is absent.

## Quick start

1. Install `omp-conductor` and `omp-telegram`. Pair the Telegram bot and enable its bridge.
2. Open the long-lived omp session in the fleet workspace.

   The default workspace is `~/.omp/conductor/worktrees`. The wizard shows the actual path before it writes files.

3. Run the wizard:

   ```text
   omp-conductor setup
   ```

   The wizard reads the tracker with the same routing code as the daemon. It shows every issue that the next tick can route.

   On an interactive TTY, setup uses the styled Clack interface. Piped setup
   keeps the stable line protocol for automation. Set
   `OMP_CONDUCTOR_PLAIN_UI=1` to use the readline interface on a TTY and replace
   Clack progress in other verbs with their stable plain messages.

   To make an interactive run replayable, save every accepted answer by its
   stable key:

   ```bash
   omp-conductor setup --save-answers ./setup-answers.json
   omp-conductor setup --answers ./setup-answers.json
   ```

   An answers file is a JSON object whose values are strings or booleans.
   Confirmations require booleans, text prompts require strings, and select
   prompts require the exact displayed label. `--answers` never falls back to
   an interactive prompt: a missing key or wrong value fails with the key and
   question name. Piped line input remains supported, but an exhausted pipe
   fails with the `--answers` alternative instead of waiting indefinitely.

   Nothing changes before the consent step. After consent, setup does these actions:

   - creates the required labels;
   - writes `config.json` and the selected brief;
   - writes `.conductor-tick.json` for external orchestration;
   - stages `omp-conductor.service` under the conductor state directory;
   - runs `daemon --once` while dispatch is paused;
   - starts a temporary daemon and proves `/healthz` and stored status;
   - stops the temporary daemon;
   - proves the current inbound Telegram path before it arms or resumes an external heartbeat;
   - clears the dispatch pause only after all required gates pass.

   If the arm proof cannot complete, setup keeps dispatch paused. The result shows the exact recovery commands.

4. On a systemd host, run the install commands that setup prints.

   The commands install the staged unit, reload systemd, enable the unit, and restart it. The generated unit contains the current user, paths, project, port, and memory ceiling.

   On a host without systemd, start the daemon directly:

   ```bash
   omp-conductor start
   ```

5. Read the layered status:

   ```bash
   omp-conductor status
   ```

   The result must show a running daemon, a healthy `/healthz`, armed ticks for external orchestration, and the configured project.

6. Verify the deployment mechanically before trusting it:

   ```bash
   omp-conductor doctor
   ```

   `doctor` runs read-only and exits 0 only when nothing failed: gh auth and
   scopes, exact-case labels, installed-systemd-unit drift, runtime-dir
   ownership, config + backup freshness, sqlite integrity, spend telemetry,
   reporting timezones, and Telegram health. Run it again after every
   `omp-conductor upgrade` — every one of these failures has cost a debugging
   session silently, and each has a one-line fix in its finding.

### First worker drill

Use a disposable target repository for this drill. Replace the values below with labels that the wizard showed.

```bash
TRACKER=acme/planning
QUEUE=ready-for-agent
ROUTE=repo:api

gh issue create --repo \"$TRACKER\" \
  --title \"Conductor setup drill: add a marker file\" \
  --body $'Add `conductor-smoke.txt` to the target repository.\\n\\nAcceptance: the file contains `setup path verified` and the pull request checks pass.' \
  --label \"$ROUTE\" \
  --label \"$QUEUE\"
```

The daemon claims the issue, creates a worktree, runs the configured gates, and opens a pull request. Follow it with:

```bash
omp-conductor status
omp-conductor tail <issue-number>
```

Merge the green pull request. Then make sure that status moves the run to `merged` and frees its worker slot.

The loop ticks every 5 minutes while workers remain active. Merge settlement and capacity checks do not wait for the longest worker.

`omp-conductor stop` drains active workers before it stops. On systemd, it uses `systemctl stop` to prevent an automatic restart.

The package also ships a generic unit at
[`systemd/omp-conductor.service.example`](systemd/omp-conductor.service.example).


## Operating the fleet

The fleet is a long-lived orchestrator session plus the dispatch daemon behind
it. These are the verbs you reach for day to day.

### The fleet host

Both halves point at the same 24/7 omp session on your always-on host. The
session's working directory is configured as `FLEET_CWD`; it contains
`.conductor-tick.json`, whose relative `armedFile` also resolves from that
directory. Conductor state defaults to `~/.omp/conductor`, while `workspaceRoot`
defaults to its `worktrees/` directory. Setup composes the package floor and the
fleet-owned `POLICY.md` into `ORCHESTRATOR.md` there. No `/root` layout is built
into a new deployment.

The Herdr half owns recovery, not dispatch or policy: it restores the exact
session identity, requests an immediate heartbeat, or reports through Telegram
and a Herdr notification that the fleet is down.

### What confines a worker session

Worker sessions run as the fleet account — the same account the daemon runs as.
An earlier release ran them under a dedicated `omp-worker` UID with your install
bind-mounted read-only for them to resolve; that design did not survive contact
with Bun's install, cache and native-addon resolution (a worker could not load
the harness at all), and it was withdrawn. Exposing package files is not a
self-contained runnable runtime.

What confines a worker is unchanged, and none of it needed a separate account:

- **its worktree.** Dispatch hands each run an isolated git worktree and session
  directory; that is its writable world for the attempt.
- **its verbs.** A worker session may push and open a pull request through the
  mediated verbs and nothing else: `conductor_pr_merge`, `conductor_label` and
  `conductor_release` refuse a worker mechanically, whatever any config says.
- **its budget.** Turn, wall-clock, spend and attempt caps bound every run, and
  a cap kill is recorded rather than retried silently.
- **its review.** A green pull request is where a worker stops; merging is the
  orchestrator's, behind the exact-head, green-checks and single-flight gates.

`setup host` no longer provisions that account, its ACL projection or the mount
unit, and a host that carries them from an earlier release is converged the next
time `setup host` or `upgrade` runs: the mount is disabled, unmounted and
removed, before anything else in the transaction. The account itself is left
alone — an unused Unix account is inert, and deleting one to tidy up is
destructive.

OS-user isolation is a design worth revisiting, and the next attempt needs an
immutable self-contained worker runtime rather than a bind of the operator's
install, a real end-to-end session preflight before any switch, and atomic
rollback. Until then, `omp-conductor` still has to be *installed* (not run from a
source checkout) for a worker to resolve its harness peer.

### Stop the conductor (hold / stop)

Two words, and one of them takes a flag:

| Verb | Claiming | Tick sends | Dispatch daemon | Conductor pane |
| --- | --- | --- | --- | --- |
| `hold` | paused | disarmed | left running | left running |
| `hold --keep-ticks` | paused | **left armed** | left running | left running |
| `stop` | paused | disarmed | stopped (systemctl-aware) | left running |
| `stop --pane` | paused | disarmed | stopped | stopped + recovery pinned off |

`resume` clears pause **and** any `stop --pane` recovery pin, and **never re-arms**. `arm` is proof-gated: it sends a Telegram challenge and writes the arm marker only after the orchestrator's inbound adapter has acknowledged your reply in conductor's own state — no transcript is read, so where (or whether) a session file lives does not matter (#614). `stop --pane` targets the configured conductor agent only — it does **not** run `systemctl stop herdr-fleet`. To bounce the daemon without stopping the fleet, use `restart`.

**Removed in 0.15.0**, each exiting `2` with a pointer: `halt` (now `stop`), `pause` (use `hold`), `release-pane` (now part of `resume`), and `graph-setup` (now [`setup graph`](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#code-graph-discovery)). Dropping `pause` cost one real capability, "stop claiming but keep ticking", which is now `hold --keep-ticks` rather than a fifth verb. It matters because disarming is the expensive half of a hold: re-arming sends a Telegram challenge and blocks until you answer it in the chat, so stopping claims for ten minutes otherwise costs a manual round trip to get the heartbeat back — while the workers a hold deliberately leaves running have nothing shepherding them.

`status` prints a layered header (`dispatch` / `ticks` / next tick time / `pane` / `recovery` / `herdr` / `telegram` / `daemon`) so a paused fleet cannot hide an armed orchestrator still spending turns. The Telegram line calls the official `getMe` endpoint to prove the token and API are usable without sending a message, then separately reports whether the inbound bridge is configured.

When the tracker is behind what the store decided, `status` adds a
`labels             projection N pending (oldest …)` row: those are label
changes the dispatcher committed to and the [projector](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#how-one-tick-works) has not been
able to apply yet (a 403, a rate limit). The intended label state is durable, so
nothing is lost and safety is preserved: for an issue the queue read already
returns, a pending *state-label removal* is overlaid as applied, so a stale
label cannot block redispatch. A pending *queue-label add* is different — the
queue read asks GitHub for issues carrying the queue label, so that issue stays
invisible to dispatch until the add actually lands. The row is the lag to
watch: it should drain, and while it does not, work waiting on a queue-label
add waits with it.

`omp-conductor board [--project NAME]` opens the same facts as a live terminal
kanban instead of a scrolling wall of status text. Its columns are Queue,
Claimed, Running, Green, Blocked, Failed, Orphaned, Merged, Settled, and
Parked.

Every column is a claim about an issue's state **now**, and each issue appears
in exactly one of them. Claimed, Running, and Green come from the store, because
a live worker or an open pull request is the most current fact there is. Every
stopped column comes from the labels the tracker carries right now: Queue is the
queue label plus the latest tick's admission holds, Failed is the failed state
label, Blocked is the blocked state label, and Orphaned is an issue the
in-progress label still claims with no worker running it — a protected orphan
whose worktree is kept because it may hold uncommitted work. So a requeued issue
is queue work even when its last attempt failed, and a closed issue is not
active work at all. Merged and Settled are the two backward-looking columns,
both bounded to the last 24 hours and neither one an action list: Settled holds
recently finished runs that no current label claims, so the board stays
operational rather than becoming an analytics archive. Parked is the issue's
last word when the tracker has **not** confirmed it closed — a terminal run
whose issue wears no state or queue label (still open, or a label read that
failed), so nothing dispatches it until a human labels it. Unlike Merged and
Settled it is not bounded to 24 hours, because parked work does not stop being
the issue's only remaining record; Settled alone asserts the issue is closed,
so an unreadable probe parks rather than letting a run masquerade as finished
work.

The board refreshes run, spend, turn, and dispatch values from SQLite every
second. It refreshes the slower daemon, Herdr, Telegram, code-graph, and tracker
label layer every ten seconds or immediately with `r`; the header reports
`tracker ok`, or how stale the label read is and why, so an empty Queue is never
mistaken for an empty queue. Use arrow keys or `h/j/k/l` to select a card,
`Enter` to inspect and follow its worker transcript, `u` to run the normal
unblock workflow on a Blocked, Failed, or Orphaned card, `i` / `p` to open the
issue / pull request, `?` for help, and `q`, `Esc`, or `Ctrl-C` to go back and
quit. Narrow terminals show a sliding subset of columns around the selection;
terminals below 50×20 get a single resize instruction instead of a broken
layout. The board is read-only except for the explicit `u` action: it never
claims work or changes stages.

`halt --pane` is **fail-closed**: it exits `0` only when the conductor agent is
*proven* gone. It writes the recovery pin first, so a failed stop still cannot be
undone by `herdr-conductor` respawning the agent, and then refuses (nonzero exit,
message on stderr) on every uncertainty:

- no tick config exists at all — `recover.sh` reads only
  `$FLEET_CWD/.conductor-pane-halted`, and without that file the pane's own
  directory is unknown, so the pin would land somewhere recovery never looks and
  the agent would be respawned seconds later. `resume` refuses for the
  same reason, and `status` shows `recovery unpinnable` rather than `clear`
- the tick config does not parse — the agent name would be a guess
- `herdr agent list` is unreachable, prints nothing, or prints output with no
  explicit `agents` array; only a real `agents: []` means "no agents"
- an agent row is unreadable — a missing `pane_id`, a non-string non-null
  `name`, or an `agent` field present with a non-string value. An absent or
  `null` `name` is an unnamed pane; an absent or `null` `agent` is the sticky
  claim herdr reports after the agent exits, and both stay normal answers
- the configured agent name is not unique, or the claimed pane runs some other agent
- `pane process-info` fails, or the claim is live `omp` but names no recognizable
  omp foreground PID — "cannot see it" is never reported as "it is stopped"
- a signal cannot be delivered, or liveness cannot be probed — only `ESRCH`
  ("no such process") proves death, so `EPERM` reads as "exists, not ours",
  never as "stopped"
- the process is still alive after `SIGTERM` then `SIGKILL`

The pin is written to the pane's own directory (the one holding
`.conductor-tick.json`, which is `FLEET_CWD` — the only place `recover.sh` looks),
including when that tick config is the thing that failed to parse.

Clear the pin with `omp-conductor resume` when you want recovery again.


### See the fleet in a browser: `dashboard`

`omp-conductor dashboard` serves the same fleet facts `status` and `board`
render, in a browser, and — since #295 — lets you *act* on them without a shell
on the host. It is a separate process from the dispatch daemon: the read surface
(`GET /api/projects`, `/api/overview`, and per-project `board`, `status`,
`ledger`, `reports`, `runs/:issue`) reads fleet state exactly as `status` does,
and the control surface (`POST`) routes every action to the same code the CLI
verb runs.

| action | what it does |
|---|---|
| `pause` / `resume` | stop and restart new claims, **keeping the heartbeat** — the cheap, reversible control |
| `hold` | stop claims *and* disarm ticks; re-arming costs a Telegram challenge, so the UI confirms it by name |
| `disarm` | take the heartbeat down without touching dispatch |
| `runs/:issue/extend` | raise a run's turn ceiling, **proxied to the owning daemon** so its own bounds and refusals apply verbatim |
| `runs/:issue/worker/{pause,resume,stop}` | drive one live worker, proxied for the same reason — the daemon holds the session controller |
| `runs/:issue/unblock` | the same `unblock` the CLI runs, `force` included and confirmed by name |
| `decisions/:id/answer` | resolve one open decision; a second answer is a `409`, never a silent overwrite |

Three properties are worth knowing before you use it:

- **Nothing here is a second implementation.** Where an action already lives
  behind the daemon's HTTP surface, the dashboard *calls that endpoint* rather
  than reaching past it to the store. `extend` is the instructive case: the
  daemon's `turn-limit` route owns the ceiling bound, the live-controller check
  and the not-an-increase refusal, so its `409`/`422` answers pass through
  unchanged and the browser cannot come to disagree with `omp-conductor extend`.
- **A dead daemon is a `502` naming it**, never a `200` that pretends a worker
  was paused.
- **`arm` is deliberately absent.** It requires an inbound Telegram proof that a
  human answered a challenge, and a browser button cannot supply one — a
  dashboard `arm` would either weaken that proof or lie about having it. Arming
  stays CLI/Telegram.

**Watching a run.** The run detail view streams the transcript live
(`GET .../runs/:issue/transcript/stream`, Server-Sent Events). It is the same
follow `omp-conductor tail` runs — the same 1s re-stat cadence, reading from byte
zero so attaching to a run ten turns in shows all ten, and the same advisor
transcripts alongside. Nothing here touches the daemon: two browsers on one run
are two read-only file follows, and a fleet with no daemon running at all still
streams. A run with no transcript yet gets one explicit `error` event saying so
rather than an empty stream that reads as a quiet worker; a finished run streams
its tail, writes `run ended: <state>`, and closes.

One wrinkle worth knowing: `EventSource` cannot set an `Authorization` header, so
this endpoint — and only this endpoint — also accepts `?token=`. A URL can reach
a server log or a browser history entry, which is why the acceptance is scoped to
that exact path and stated rather than buried.

**Analytics.** The Analytics tab answers *what did the fleet accomplish, and at
what cost* — merges per week, USD per merged PR, lead-time p50/p90, attempts per
merge, and a failure-class histogram, over a 7d/30d/custom window. It is the
same aggregation `omp-conductor stats` prints, computed by the same module: one
implementation, two consumers, so the page and the CLI cannot disagree about a
number. Store-only — rendering it makes no GitHub API calls.

Two things it deliberately will not do. A window in which nothing settled
renders an explicit *nothing settled* rather than a chart of zeros, because a
zero drawn on an axis reads as a measurement. And runs whose harness reported no
spend (`spendUsd = 0`) are counted as **unmetered**, never averaged in as free:
cost-per-merge divides by the merged issues that actually metered, and the
unmetered remainder is named beside it.

**Answering a question.** Open decisions render on the Reports tab with an
answer box, resolving through the same `PUT /decisions/:id/answer` the CLI's
`decision resolve` uses — so a second answer to the same question is a 409, not
a silent overwrite. Watches are listed but never offered an answer box: a watch
is the orchestrator's own condition, not a question anyone asked you.

Every mutation attributes itself with source `dashboard`, so `status` can say
*who* paused the fleet rather than only that it is paused. The operator's own
sentence rides in the sentinel's `reason=` field, because `source=` is a
space-free token the pause fence matches on.

Binds `127.0.0.1:8788` by default. First start mints a bearer token at
`<stateDir()>/dashboard-token` (mode `0600`, next to `config.json` under
`$OMP_CONDUCTOR_HOME`) and later starts reuse it; the page asks for the token
once and keeps it in its own `localStorage`. Every `/api/*` request must carry
`Authorization: Bearer <token>`; static assets are unauthenticated by design.

**Tailnet posture.** Loopback is the default because it is the safe one: any
local user could otherwise read your project list. To look at the dashboard
from another machine on your tailnet, run
`omp-conductor dashboard --host <tailnet-ip>`; the server still binds, but
prints a one-line warning naming the token file — the token is then the only
thing between anyone on that network and the fleet.

### See the fleet: `status` and `board`

`omp-conductor status` and `omp-conductor board` are the two windows onto the
fleet, and both are covered above under
[Stop the conductor](#stop-the-conductor-hold--stop) because that is where the
paused-fleet failure hides. `tail`, `extend`, `unblock` and the `worker`
controls are the day-to-day levers:

For automation, `status --json` emits the same project, layer, daemon,
Telegram, code-graph, run, cap, report, and sibling data as the text report.
The other read-only ledgers have stable JSON forms too:

```bash
omp-conductor ledger --json
omp-conductor decision list --json
omp-conductor watch list --json
```

JSON output is never styled. Empty ledgers return their normal top-level object
with an empty `entries`, `decisions`, or `watches` array.

### Outcomes and cost: `stats`

`omp-conductor stats [--since 7d | 30d | YYYY-MM-DD] [--project NAME] [--json]`
answers the operator's basic product question — is this fleet earning its keep
— from the local store only, with zero GitHub calls. Over a bounded window
(default: the last 7 days) it reports, per repo and in total: issues merged,
issues settled (merged or terminally failed/blocked), merge rate, queue→merge
lead time (median and p90), attempts per merged issue, metered spend per merged
issue, the failure-class breakdown of everything that did not merge, and the
tracked GitHub API calls consumed over the window's days.

Continuation chains collapse into one journey: an issue that took six attempts
is one merged outcome with six runs and one lead time, measured from its first
queue-label claim to the tick's merge settlement. A chain that began before the
window still counts its whole lead time and all its attempts.

Runs whose spend reads `$0.00` — harness telemetry absent, see
[Limitations](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#limitations)
— are counted separately as **unmetered** and never averaged into cost as if
they were free; a merged issue the harness never metered reports its cost as
unknown rather than zero.

`--json` prints the stable report shape. It is a contract: keys never move, and
the human rendering shows the same numbers:

```json
{
  "project": "demo",
  "window": { "sinceDay": "2026-08-09", "untilDay": "2026-08-16",
              "sinceEpochMs": 1754697600000, "untilEpochMs": 1755302400000 },
  "ghCalls": 41,
  "empty": false,
  "total": {
    "repo": "(all)",
    "merged": 3, "settled": 4, "mergeRate": 0.75,
    "runsPerMerged": 4, "leadTimeMedianMs": 7560000, "leadTimeP90Ms": 34200000,
    "spendUsd": 1.23, "spendPerMerged": 0.41,
    "unmeteredRuns": 2, "unmeteredMerged": 1,
    "failureClasses": { "unknown": 3 }
  },
  "repos": [ { "repo": "acme/api", "merged": 3, "settled": 3, "mergeRate": 1,
               "runsPerMerged": 2, "leadTimeMedianMs": 7560000,
               "leadTimeP90Ms": 34200000, "spendUsd": 1.23,
               "spendPerMerged": 0.41, "unmeteredRuns": 2, "unmeteredMerged": 1,
               "failureClasses": {} } ]
}
```

`empty: true` means nothing settled in the window — a fresh store or an idle
fleet, not zero measurements. `mergeRate`, the lead-time fields,
`runsPerMerged` and `spendPerMerged` are `null` when there is nothing to
measure them over, never a fabricated 0. Lead times are settlement
timestamps (the tick that confirmed the merge), so they carry up to one tick
of sweep latency.

### Watch a run: `tail`

`omp-conductor tail <issue>` follows the newest run for an issue — its
`assistant:` text and every `tool:` call as they land — from the top of the
transcript, and prints `run ended: <state>` when it finishes. Workers are omp
sessions inside the daemon, not terminals, so this is the only way to watch one
live. See [`tail`](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#cli-reference).

### Intervene: `extend`, `unblock`, `worker`

- `omp-conductor extend <issue> --turns N` raises a live worker's effective turn
  ceiling, or stores a one-shot ceiling for the issue's next attempt when its
  latest run is terminal.
- `omp-conductor unblock <issue>` clears an issue's `blocked` / `failed` labels —
  and `agent:in-progress` when the newest run is terminal — so an answered
  escalation can be claimed again. It is the way back after you answer a tier-1.
- `omp-conductor worker pause/resume/stop <issue>` parks or ends one live worker
  without touching the fleet; `worker stop --reason TEXT` terminates, salvages
  and gives up its in-progress label.

Each is a verb on the binary and takes `--project NAME`. Full semantics are in
the [CLI reference](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#cli-reference).

### Answering a decision

A question the orchestrator put to you — an amendment, a tier-2 decision, "do I
ship this tonight?" — arrives on your phone and is written to the **decision
ledger**, where it survives compaction, restarts and long ticks instead of living
only in a session's memory. You answer with the `omp-conductor decision` verbs
(`open`, `list`, `resolve`, `withdraw`), and a `--resolves-when` condition lets a
parked question wake up by itself when a PR merges, an issue closes or a check
goes green. See [the decision ledger](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#the-decision-ledger-136).

### Capture an idea: `intake`

An idea that is not an issue yet has nowhere to live. `omp-conductor intake`
gives it one, durably — the sqlite store, not a session, so a thought captured
at 02:00 is still there after a restart:

```bash
omp-conductor intake "ship the intake command"     # prints an id
omp-conductor intake list                          # id, age, text — oldest first
omp-conductor intake dismiss 3f9c2a1b7e04          # drops one by id
```

`status` shows an `intake N pending idea(s)` row only while the backlog is
nonzero, so an empty intake stays invisible instead of becoming noise; grooming
a pending idea into an actual issue happens later, not on this surface.

## Multi-project operation

One daemon serves every configured project on a host. Each project keeps its own
queue, caps, reporting scope, tick config and arm marker under
`~/.omp/conductor/projects/<name>/`. The lifecycle verbs below take
`--project NAME`, and a few take `--all` for when you mean the whole host at once.

### Adding another project

To put a second fleet on the same host without touching the first, run the wizard
for the new name only:

```bash
omp-conductor setup --project second
# or pick "Add another project" from the re-run chooser
```

That is a full interview for the new name only. Defaults land under
`~/.omp/conductor/projects/<name>/{worktrees,mirrors}`, so two fleets never share
a cwd. After apply, setup provisions labels, brief, tick config, topic binding,
smoke and arm for that project only, then prints the two follow-ups:

- `omp-conductor restart --now` — the running daemon picks the new project up
  only after a reload (printed, not auto-run while workers are live);
- a copy-pasteable **herdr handoff** that starts the new fleet's orchestrator in
  its own empty pane, never into a live one.

Every other area still amends only that project (`setup gates --project second`),
and a `workspaceRoot` that collides with another project is refused. The full
walkthrough — what `--project` means for each verb, amendment rules and the
`--all` caveats — is in [Onboarding](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#adding-another-project).

### Hold, resume, arm — per project, and `--all`

The lifecycle verbs are per project, so one fleet can be quiet while another
keeps dispatching:

| Verb | `--project NAME` | `--all` |
| --- | --- | --- |
| `hold [--keep-ticks]` | pause claiming + disarm that project's ticks | every project |
| `stop [--pane]` | stop that project (daemon, optionally the pane) | every project |
| `arm` / `disarm` | that project's arm marker | every project's marker |
| `resume` | clear pause + any `stop --pane` recovery pin | every project |

`setup host` writes one arm marker per project (`armed-<project>`), so arming one
fleet never arms another; `hold --project A` writes `paused-<name>` for that
project only, while a bare `paused` sentinel pauses every project. Per-project
tick identity and the shared-marker upgrade are in
[Orchestrator tick](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#orchestrator-tick).

## CLI at a glance

Every verb hangs off the single `omp-conductor` binary; there is no in-session
command. Each takes an optional `--project NAME`, and
`hold` / `stop` / `arm` / `disarm` / `resume` also take `--all`. The full
reference — usage strings, flags and per-command behaviour — is in the
[CLI reference](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#cli-reference).

| Verb | What it does |
| --- | --- |
| `setup [area]` | The wizard: interview + probes. `--no-ai`, `--answers FILE`, `--save-answers FILE`, per-area amend, `host`, `graph`. |
| `start` / `stop` / `restart` | Run the dispatch daemon. `restart` drains first; `stop --pane` also halts the pane. |
| `upgrade [--to VERSION]` | Pin one published npm release across CLI, omp plugin, Herdr plugin and brief. |
| `status [--json]` | Layered fleet report: dispatch, ticks, pane, recovery, telegram, daemon, then the project body. |
| `stats [--since 7d|30d|YYYY-MM-DD] [--json]` | What the fleet accomplished and at what cost, from the local store only: merges, merge rate, lead time, attempts and metered cost per merged issue, failure classes, gh calls. |
| `doctor [--json] [--probe-telegram]` | Read-only deployment health — gh auth, exact-case labels, systemd drift, config backup, sqlite integrity, spend telemetry, timezones, Telegram. Run after install and after every upgrade; exit 0 only when nothing failed. |
| `ledger [--json]` | The action audit — every mediated verb, refusal and turn budget. |
| `board` | Live terminal kanban from Queue to Settled. |
| `dashboard [--port N] [--host ADDR]` | Browser UI plus a bearer-authenticated API: read the fleet (`/api/projects`, `overview`, `board`, `status`, `ledger`, `reports`, `runs`, `stats`) and drive it (`pause`/`resume`/`hold`/`disarm`, `extend`, `unblock`, worker control, decision answering), plus a live SSE transcript stream per run and an analytics view over the same aggregation `stats` prints. `arm` is deliberately absent. Loopback by default; the token lives at `<state>/dashboard-token`. |
| `hold [--keep-ticks]` | Pause claims and disarm ticks — the soft stop. |
| `arm` / `disarm` | Gate / clear the orchestrator tick's arm marker. |
| `tail <issue>` | Follow a live worker's transcript. |
| `extend <issue> --turns N` | Raise one run's, or one next attempt's, turn ceiling. |
| `worker pause/resume/stop <issue>` | Park or end one live worker. |
| `unblock <issue>` | Clear blocked/failed/`in-progress` so a settled issue can be re-claimed. |
| `verb <conductor_*>` | Run a mediated verb from the CLI (external orchestration). |
| `friction <kind> --detail TEXT` | Record a bounded operator judgment — an escalation that belonged in a digest, or a report that was noise/surprising — feeding the learning loop. |
| `event record` / `report` / `message` | Record and deliver reports and messages through the durable outbox. |
| `decision open/resolve/withdraw/list [--json]` | Read and answer questions in the decision ledger; JSON applies to `list`. |
| `watch add/list [--json]` | Record or list orchestrator-only conditions and carry notes; JSON applies to `list`. |
| `intake "<text>"` / `intake list` / `intake dismiss <id>` | Capture a raw idea durably (it lives in the store and survives restarts), list what is still pending, dismiss what turned out to be nothing. |
| `daemon [--once]` | Run the loop in the foreground — systemd's entry point. |
| `resume` | Clear pause and any `stop --pane` recovery pin; never re-arms. |
| `brief-upgrade` | Report / migrate / retrofit the `ORCHESTRATOR.md` ↔ `POLICY.md` overlay. |

## Where issues come from

**GitHub Issues is the only supported tracker in v1.** `tracker.kind` accepts
exactly one value, `"github"`, and every tracker operation shells out to your
already-authenticated `gh` CLI — the conductor never stores a token of its own.
Gitea, Jira, and file-based trackers are not supported yet; the seam for them is
`src/tracker/github.ts`, which implements the whole nine-method `Tracker`
interface in `src/types.ts` (`listReady`, `addLabel`, `removeLabel`, `comment`,
`close`, `linkParent`, `parentOf`, `openCloserFor`, `prState`) that a future
backend would swap in.

You tell the conductor where to look with three keys, all in
`~/.omp/conductor/config.json` (the [Configuration](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#configuration) section has
the full annotated example, and `omp-conductor setup` will interview you for these
and create any missing labels):

| Key | Meaning |
| --- | --- |
| `tracker.repo` | The **one** `owner/repo` whose issue list is the queue. This is your planning repo — it does not have to contain any code. |
| `queueLabel` | Open issues in `tracker.repo` carrying this label are the work queue. Nothing else is ever read. Required: the wizard pre-fills `ready-for-agent`, but a config that omits the key is rejected, not defaulted. |
| `routing.repos` + `repo:<name>` labels | Each queued issue must also carry exactly one routing label naming which code repo the work lands in. The conductor cuts the worktree and PR there, from `routing.repos[name].cloneUrl`. An issue with zero or two routing labels is reported as unroutable and skipped — never guessed. |

So: one tracker repo supplies the queue, routing labels fan issues out to any
number of code repos, and both label names are yours to configure.

## The reference

The guide stops at "operate the fleet". Everything deeper moves unchanged to
[`REFERENCE.md`](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md):

| Subject | In REFERENCE.md |
| --- | --- |
| The upgrade lifecycle (`omp-conductor upgrade`) | [Updating](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#updating) |
| The wizard internals, editing one setting, the brief overlay | [Onboarding](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#onboarding) |
| What one dispatch tick actually does | [How one tick works](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#how-one-tick-works) |
| Routing rules, and why unroutable never guesses | [Routing](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#routing) |
| Host sizing and memory | [Host sizing and memory](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#host-sizing-and-memory) |
| Caps and the plan allowance | [Caps](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#caps) |
| The worker model | [Worker model](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#worker-model) |
| Code-graph discovery | [Code-graph discovery](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#code-graph-discovery) |
| Escalation tiers | [Escalation tiers](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#escalation-tiers) |
| Report delivery (the outbox) | [Report delivery](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#report-delivery-the-outbox) |
| The decision ledger | [The decision ledger](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#the-decision-ledger-136) |
| Failure classes and recovery | [Failure classes](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#failure-classes-and-recovery-by-class-132) |
| Configuration (`config.json`) key by key | [Configuration](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#configuration) |
| The orchestrator tick internals | [Orchestrator tick](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#orchestrator-tick) |
| The full CLI reference and health endpoint | [CLI reference](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#cli-reference) |
| The mediated verbs | [The mediated verbs](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#the-mediated-verbs-126) |
| Known limitations | [Limitations](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#limitations) |
| The licence | [License](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#license) |

