omp-conductor 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,732 @@
1
+ # omp-conductor
2
+
3
+ A 24/7 dispatcher that takes `ready-for-agent` GitHub issues to green, mergeable
4
+ PRs using omp coding sessions. When a run cannot finish on its own it escalates in
5
+ tiers: first to an orchestrator session that can re-brief the worker, then to you.
6
+
7
+ ## What it is
8
+
9
+ You label an issue. Within one tick the conductor claims it on the tracker, cuts a
10
+ worktree, hands one omp session a self-contained brief, watches it to a green PR —
11
+ and then stops. Merging is a human act; the conductor never performs it.
12
+
13
+ ### Scope
14
+
15
+ One issue, one green PR. That is the whole remit.
16
+
17
+ Merging is a human act, and so is releasing. Releases are **batched**: cut from a
18
+ coherent group of merged work by a human-supervised decision, never one per PR. So
19
+ nothing in this package tags, pins, deploys or publishes, and no worker is ever
20
+ asked to. A worker whose change needs releasing reports that and stops.
21
+
22
+ Code counts every limit that decides whether work starts: concurrency, dollars per
23
+ day, turns and wall clock per worker, attempts per issue. None of it is left to the
24
+ model. A worker asked to respect a budget eventually talks itself out of it, so the
25
+ dispatcher enforces the budget before anything is claimed and kills anything over
26
+ the line.
27
+
28
+ When a run does get stuck, the first responder is not you. A tier-1 escalation is
29
+ injected into a long-lived **orchestrator session** that can read the issue and the
30
+ run's transcript and then either re-brief the worker or decide the problem genuinely
31
+ needs a human. It never edits product code, pushes or merges. Only tier 2 pages you
32
+ directly.
33
+
34
+ The package ships three deployables, plus one skill:
35
+
36
+ | Deployable | Entry | What it is for |
37
+ | --- | --- | --- |
38
+ | omp plugin | `/conductor` slash command | Inspect and arm the conductor from inside an omp session: dry-run the queue, read status, pause, resume. |
39
+ | Standalone daemon | `omp-conductor` binary | The dispatch loop, managed as a background process (`start` / `stop` / `restart`) with a `/healthz` endpoint for a supervisor. |
40
+ | 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. Inert in every other session. See [Orchestrator tick](#orchestrator-tick). |
41
+ | Onboarding skill | `skill://conductor-onboarding` | Directs an omp session to interview you, read your repos for real CI gates, and tailor `ORCHESTRATOR.md` — then finish through the wizard. Discovered automatically once the plugin is installed. See [Onboarding](#onboarding). |
42
+
43
+ The first two are thin wrappers over the same `daemon.ts`, so the plugin and the
44
+ CLI cannot disagree about what a cap means or where the state lives. The heartbeat
45
+ reads the same pause flag both of them write.
46
+
47
+ ## Your workflow vs. the package
48
+
49
+ **This package stops at green PRs.** The boundary is in the worker brief and in
50
+ the loop itself: no worker merges, tags, pins, deploys or publishes, and neither
51
+ does the orchestrator. Everything past a green PR — when to merge, what to batch
52
+ into a release, what to deploy — is *your* workflow, and the package deliberately
53
+ holds no opinion about it that it could act on.
54
+
55
+ Your opinion goes in `ORCHESTRATOR.md`, the standing prompt for the long-lived
56
+ session that supervises the fleet. That file is yours: the conductor renders it
57
+ once, on request, and then never reads it back, never rewrites it and never
58
+ enforces a word of it. What you write there binds your orchestrator session and
59
+ nothing else in this package.
60
+
61
+ `/conductor setup` offers to render the shipped template
62
+ (`src/briefs/orchestrator.md`) to `<workspaceRoot>/ORCHESTRATOR.md` with your
63
+ project's coordinates filled in, and never replaces an existing file without a
64
+ second, explicit confirmation. It is a starting point rather than a contract:
65
+
66
+ | Section | Whose |
67
+ | --- | --- |
68
+ | Duties (drain, groom, report), escalation tiers, hard boundaries | **Fixed** — they describe how this package already behaves. |
69
+ | Releases | **Yours.** Ships defaulting to "humans release; the conductor and its workers never tag, pin, deploy, or publish". Replace it only if you are deliberately delegating releases to that session — and then be specific about what, when, on what proof, and what stays permanently forbidden. |
70
+ | Reporting | **Yours**, seeded from the scope you chose in setup. |
71
+
72
+ Reporting is the one half of that the config also knows about, because the wizard
73
+ has to ask something in order to seed the brief, and it is the one half the
74
+ runtime acts on:
75
+
76
+ | `reporting.scope` | What the orchestrator says unprompted | The line every tick carries |
77
+ | --- | --- | --- |
78
+ | `material` (default) | Escalations, plus every material event: a run reaching a green PR, a run that failed twice, an issue pulled off the queue, a cap that stopped the fleet. | `Report material events per your brief.` |
79
+ | `escalations` | Escalations when they happen, plus one daily digest. Silent otherwise. | `Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.` |
80
+
81
+ **What the scope does:** the [orchestrator heartbeat](#orchestrator-tick) appends
82
+ that line to every tick it sends, so the reporting contract arrives with the
83
+ prompt instead of only in a brief the session read hours ago. It is re-read from
84
+ `~/.omp/conductor/config.json` on **every** tick, so turning the volume up or
85
+ down — `/conductor setup` again, or an edit to the file — binds the next tick
86
+ without restarting the session. Three cases fall back to `material`: no config
87
+ yet, an unreadable or invalid one, and several projects with none named (the same
88
+ ambiguity `status` refuses to guess through). Stopping the heartbeat over a
89
+ reporting preference would be the worse trade, so it ticks on the default and
90
+ logs the reason once.
91
+
92
+ **What it does not do:** there is no hard outbound filter. Nothing inspects the
93
+ orchestrator's messages and drops the ones your scope did not ask for, so a
94
+ session that ignores its constraint line still reaches you. Scope is a
95
+ constraint the model is handed each turn. It is not a gate the model is held to.
96
+ The enforcement roadmap (a tool-call tripwire, and config-versus-behaviour drift
97
+ in the daily digest) is
98
+ [issue #4](https://github.com/TerrifiedBug/conductor/issues/4).
99
+
100
+ Changing the key later does not rewrite an `ORCHESTRATOR.md` you already have:
101
+ the tick line changes, the brief does not. Edit its Reporting section too, or the
102
+ session is carrying two versions of your policy.
103
+
104
+ ## Where issues come from
105
+
106
+ **GitHub Issues is the only supported tracker in v1.** `tracker.kind` accepts
107
+ exactly one value, `"github"`, and every tracker operation shells out to your
108
+ already-authenticated `gh` CLI — the conductor never stores a token of its own.
109
+ Gitea, Jira, and file-based trackers are not supported yet; the seam for them is
110
+ `src/tracker/github.ts`, which implements the whole six-method `Tracker`
111
+ interface in `src/types.ts` (`listReady`, `addLabel`, `removeLabel`, `comment`,
112
+ `close`, `linkParent`) that a future backend would swap in.
113
+
114
+ You tell the conductor where to look with three keys, all in
115
+ `~/.omp/conductor/config.json` (the [Configuration](#configuration) section has
116
+ the full annotated example, and `/conductor setup` will interview you for these
117
+ and create any missing labels):
118
+
119
+ | Key | Meaning |
120
+ | --- | --- |
121
+ | `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. |
122
+ | `queueLabel` | Open issues in `tracker.repo` carrying this label (default `ready-for-agent`) are the work queue. Nothing else is ever read. |
123
+ | `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. |
124
+
125
+ So: one tracker repo supplies the queue, routing labels fan issues out to any
126
+ number of code repos, and both label names are yours to configure.
127
+
128
+ ## Install
129
+
130
+ ```bash
131
+ omp plugin install omp-conductor
132
+ ```
133
+
134
+ From a checkout of the monorepo, `./setup.sh` links this plugin and the Herdr half
135
+ after checking everything below.
136
+
137
+ ### Prerequisites
138
+
139
+ `@oh-my-pi/pi-coding-agent` (`>=17.1.4`) is a **peer dependency** and must already
140
+ be present. If you run omp, it is.
141
+
142
+ Also required on the host:
143
+
144
+ - `bun`: the CLI and the daemon run on it (`Bun.serve` backs `/healthz`).
145
+ - `gh`, already authenticated: every tracker operation shells out to it, so the
146
+ daemon never handles a GitHub token itself.
147
+ - `git`: mirrors and worktrees.
148
+ - **[omp-telegram](https://www.npmjs.com/package/omp-telegram)**, for the
149
+ escalation channel. It is a separate package and is not vendored here.
150
+
151
+ Two different things depend on it, and they need different amounts of it:
152
+
153
+ - **Tier-2 paging** needs only its bot token. This package reads
154
+ `TELEGRAM_BOT_TOKEN` out of `$OMP_TELEGRAM_STATE_DIR/.env` (default
155
+ `~/.omp/agent/telegram/.env`) and posts to the chat id you configure. No
156
+ pairing required, and no token ever passes through this package's own config.
157
+ - **The interactive channel** — replying to an escalation, approving a brief
158
+ amendment from your phone — needs omp-telegram actually paired, which is what
159
+ writes `access.json`. The fleet heartbeat also reads that file and refuses to
160
+ tick unless exactly one owner is paired, on the grounds that unattended
161
+ dispatch is only defensible while a tier-2 page can reach a person.
162
+
163
+ With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
164
+ that configuration: it is supported, just slower to reach you.
165
+
166
+ ## Onboarding
167
+
168
+ Onboarding this package has two layers, and installing it gives you both.
169
+
170
+ | Layer | What it is | What it owns |
171
+ | --- | --- | --- |
172
+ | **`/conductor setup`** | The deterministic wizard. Closed questions, a label plan, a dry run, one confirm. | **Mechanical config.** It is the only thing that writes `config.json`, and it mutates nothing before you confirm. |
173
+ | **`skill://conductor-onboarding`** | A skill bundled in this package (`skills/conductor-onboarding/SKILL.md`), discovered automatically by any omp session once the plugin is installed. | **Brief authoring.** The judgement the wizard cannot prompt for. |
174
+
175
+ The split exists because the two halves fail differently. A wrong config value is
176
+ a run that errors on the next tick; a wrong release boundary is a fleet that
177
+ publishes something at 03:00. The first is worth a text prompt with validation.
178
+ The second is worth an interview.
179
+
180
+ So the skill does the part a dialog cannot:
181
+
182
+ - **Interviews you** on release policy — humans release (the default), the agent
183
+ releases to a named boundary, or the agent releases fully — pressing on the one
184
+ question that makes a delegated release safe: *where does the agent's leg end?*
185
+ Plus escalation taste, and which of the two [`reporting.scope`](#your-workflow-vs-the-package)
186
+ values your answer actually maps to.
187
+ - **Reads your repos instead of asking about them.** It opens each routing repo's
188
+ CI workflows, `package.json` scripts and `Makefile`/`justfile`, then *proposes*
189
+ the exact pre-push [gates](#configuration) with the `cwd` each runs from, so the
190
+ gates match what CI runs.
191
+ - **Tailors `ORCHESTRATOR.md`** from the shipped template. The template is the
192
+ floor: it rewrites the Releases and Reporting sections from your answers, adds
193
+ the hard boundaries only you know about (infra directories, off-limits repos),
194
+ leaves the fixed sections alone, and shows you the diff before writing.
195
+ - **Verifies the worker brief's assumptions** against reality: default branch per
196
+ repo, whether the branch names the conductor cuts survive your branch
197
+ protection, whether each proposed gate exists and exits 0 on a clean checkout,
198
+ and whether `pull_request` actually fires — a workflow that never triggers on a
199
+ PR gives a worker no checks to watch and no verdict to reach.
200
+ - **Then finishes through the wizard**, so the dry run and the consent gate still
201
+ do the writing.
202
+
203
+ From an omp session with the plugin installed, just say what you want: "help me
204
+ set up conductor", "onboard me", "configure the fleet" all reach it, because that
205
+ is what the skill's description matches on. With `skills.enableSkillCommands`
206
+ turned on you can also invoke it directly:
207
+
208
+ ```text
209
+ /skill:conductor-onboarding
210
+ ```
211
+
212
+ Nothing about the wizard changes: `/conductor setup` on its own remains a
213
+ complete, supported path, and the brief it renders is safe unedited.
214
+
215
+ ## Quick start
216
+
217
+ 1. Write a config (see [Configuration](#configuration)) at
218
+ `~/.omp/conductor/config.json`.
219
+ 2. From an omp session:
220
+
221
+ ```text
222
+ /conductor setup
223
+ ```
224
+
225
+ This is a **dry run first**. It reads the tracker through the same routing code
226
+ the loop uses and prints exactly what the next tick would pick up, which repo
227
+ each issue routes to, the branch it would cut, and every issue that cannot be
228
+ routed. **Nothing is mutated** — no label written, no run row, no worktree —
229
+ until you answer the "Arm omp-conductor?" confirmation. Arming does exactly two
230
+ things: create the state database, and clear the pause flag. Declining leaves
231
+ the machine untouched.
232
+
233
+ Two of its questions are about you rather than the fleet: how loud the
234
+ orchestrator should be (`reporting.scope`), and whether to write an
235
+ `ORCHESTRATOR.md` you then own. See
236
+ [Your workflow vs. the package](#your-workflow-vs-the-package). If you would
237
+ rather be interviewed through those two, and have the brief tailored and your
238
+ gates read out of your CI config, start from
239
+ [Onboarding](#onboarding) instead.
240
+
241
+ 3. Start the daemon in the background:
242
+
243
+ ```bash
244
+ omp-conductor start
245
+ ```
246
+
247
+ `start` does not report success until the daemon actually answers
248
+ `GET /healthz`. Spawning is not starting: a daemon whose config is broken, whose
249
+ port is taken or whose database is locked exits within a second, and a `start`
250
+ that printed "started" for it would hand you a lie you discover only when work
251
+ silently fails to be picked up. On failure the error quotes the tail of
252
+ `daemon.log`. It refuses to start a second daemon, naming the pid of the live one.
253
+
254
+ For a first run, take a single tick in the foreground and watch it:
255
+
256
+ ```bash
257
+ omp-conductor daemon --once
258
+ ```
259
+
260
+ The loop ticks every 5 minutes. `omp-conductor stop` sends `SIGTERM`, and the loop
261
+ shuts down after the current tick rather than mid-run.
262
+
263
+ ## How one tick works
264
+
265
+ Per tick, for the daemon's project:
266
+
267
+ 1. **Paused?** If the pause sentinel exists, the tick claims nothing and returns.
268
+ Pause is checked first, so `omp-conductor pause` takes effect on the next tick
269
+ without signalling the process.
270
+ 2. **List the queue.** Open issues in `tracker.repo` labelled `queueLabel`.
271
+ 3. **Filter and route.** An issue is eligible only if it carries the queue label
272
+ and none of the three state labels (`inProgress`, `blocked`, `failed`). Eligible
273
+ issues are partitioned into routable and unroutable.
274
+ 4. **Escalate the unroutable** at Tier 1, quoting the repo labels actually seen and
275
+ the configured repo names. These are never dispatched.
276
+ 5. **Check spend.** If spend since local midnight has reached `dailySpendUsd`, the
277
+ daemon **pauses itself**, pages at Tier 2, and returns.
278
+ 6. **Check capacity.** `maxConcurrentWorkers` minus active runs gives the free
279
+ slots. If none are free, the tick logs and returns.
280
+ 7. **Admit issues** up to the free slots, skipping any issue that already has
281
+ an active run. An issue that has used `maxAttemptsPerIssue` escalates at Tier 1
282
+ instead of being admitted.
283
+ 8. **Dispatch** the admitted issues concurrently.
284
+
285
+ Then, per admitted issue:
286
+
287
+ 1. **Apply the `agent:in-progress` label — before any worktree or session exists.**
288
+ This ordering is the whole crash-safety story: the label, not the local
289
+ database, is the guard against dispatching the same issue twice. If the process
290
+ dies at any later point, the next daemon sees the label, eligibility filters the
291
+ issue out, and a human decides what to do with the orphan. A store that is lost
292
+ can be rebuilt from the tracker; a label that was written too late cannot undo a
293
+ duplicate PR.
294
+ 2. Create the run row (`claimed`).
295
+ 3. Clear any stale tree for this issue, then add a fresh worktree at
296
+ `<workspaceRoot>/<issue>` cut from the bare mirror at `<mirrorRoot>/<repo>.git`,
297
+ on the run's branch off the repo's default branch.
298
+ 4. Allocate a session transcript under `<state dir>/sessions/`, one per attempt,
299
+ and move the run to `running`. The run record keeps the exact path and a
300
+ failure escalation quotes it, so you can read what the worker actually did.
301
+ 5. Run one omp session with the rendered brief, under the turn and wall-clock caps.
302
+ 6. Record the outcome:
303
+
304
+ | Outcome | Labels | Worktree | Escalation |
305
+ | --- | --- | --- | --- |
306
+ | `pushed-green` | `agent:in-progress` stays until the merge closes the issue | removed | none |
307
+ | `blocked` | swapped to `agent:blocked` | removed | Tier 1 |
308
+ | `failed` / `killed` | swapped to `agent:failed` | **kept** as evidence | Tier 1 |
309
+ | unexpected error | swapped to `agent:failed` | kept | Tier 1 |
310
+
311
+ Label swaps add the new label before removing the old one: the reverse order
312
+ leaves a window in which the issue carries no state label at all, which is
313
+ exactly the shape eligibility reads as fresh work.
314
+
315
+ ### Branch names
316
+
317
+ `<type>/<slug>`, where the type is `fix` when any label's last segment (after `:`
318
+ or `/`) is `bug`, and `feat` otherwise. The slug is the issue title folded to
319
+ `[a-z0-9-]`, and the whole ref is capped at 60 characters. It is computed from the
320
+ issue alone, so a retried run recomputes the same branch and finds its own work
321
+ instead of forking a second one.
322
+
323
+ ## Routing
324
+
325
+ An issue must carry **exactly one** `repo:<name>` label naming a repo in
326
+ `routing.repos`. The prefix is `routing.labelPrefix` and defaults to `repo:`.
327
+
328
+ Routing never guesses. An issue it cannot resolve to a single configured checkout
329
+ is handed back as unroutable:
330
+
331
+ | Reason | Condition |
332
+ | --- | --- |
333
+ | `no-repo-label` | The issue carries no label starting with the prefix. |
334
+ | `multiple-repo-labels` | It carries two or more distinct prefixed labels. A repeated identical label is deduplicated, not treated as an ambiguity. |
335
+ | `unknown-repo` | Its single prefixed label names a repo that is not in `routing.repos`. |
336
+
337
+ In all three cases the issue is **escalated at Tier 1 and never dispatched**. The
338
+ fix is always the same, and the escalation says so: put exactly one
339
+ `repo:<name>` label on the issue.
340
+
341
+ This is deliberate. A request that spans two repos, taken whole by one worker, is
342
+ the precise failure this guard exists to prevent: the worker cannot open a PR
343
+ against two checkouts, so it improvises — it vendors a copy, edits the wrong repo,
344
+ or produces a PR that cannot be merged without the other half. Splitting a
345
+ multi-repo request is a human decision about contracts; it is not something to
346
+ infer from a label. Sending the issue back costs a label edit; guessing costs a
347
+ bad merge.
348
+
349
+ ## Caps
350
+
351
+ Caps resolve per project: the global `defaults` block, then the project's own
352
+ `caps` layered on field by field, so a project that pins one cap still inherits the
353
+ rest. `0` is a real value (a hard stop), not "unset".
354
+
355
+ | Cap | Default | What it protects |
356
+ | --- | --- | --- |
357
+ | `maxConcurrentWorkers` | `2` | Parallel omp sessions. Two, because **CI runner slots, not model tokens, are the usual throughput ceiling** — a third worker would starve its own PR checks on a small self-hosted runner pool. Raise it only if you actually have the runners. |
358
+ | `dailySpendUsd` | `25` | Rolling-day spend ceiling. The one cap that stops the fleet rather than deferring work. |
359
+ | `workerMaxTurns` | `120` | Turn ceiling for one worker. Catches a session looping without converging. |
360
+ | `workerWallClockMs` | `5400000` (90 minutes) | Wall-clock ceiling for one worker. A session that is merely stuck spends no turns, so turns alone cannot detect it. |
361
+ | `maxAttemptsPerIssue` | `2` | Retries per issue before it escalates. One clean retry recovers from flaky CI; a third attempt almost always means the issue itself is underspecified. |
362
+
363
+ Days are counted from **local midnight**, matching how a human reads "today".
364
+
365
+ Hitting `dailySpendUsd` is not the same as hitting the other caps. A concurrency
366
+ limit simply defers work to a later tick. The spend cap **pauses the daemon and
367
+ pages at Tier 2**: a loop that is burning money has to halt itself, because
368
+ waiting for someone to notice tomorrow is how a runaway becomes expensive.
369
+ Work resumes only after `omp-conductor resume` (or `/conductor resume`).
370
+
371
+ `workerMaxTurns` and `workerWallClockMs` are enforced inside the session driver: the
372
+ run is aborted, recorded as `killed`, and the escalation names which ceiling fired.
373
+
374
+ ## Worker model
375
+
376
+ `workerModel` on a project pins the model its workers run on, as a pattern in
377
+ omp's own model/role syntax (whatever `/model` accepts). It sits beside `caps`
378
+ rather than inside them, because it is not a ceiling:
379
+
380
+ ```json
381
+ "workerModel": "smol"
382
+ ```
383
+
384
+ Omit it and the harness picks, which is the right answer until you have a reason.
385
+ The pattern is passed through unresolved: omp resolves it after its extensions
386
+ load, so a name this package has never heard of still works. If the harness cannot
387
+ honour the pattern it says so, and the daemon logs that per run:
388
+
389
+ ```text
390
+ #412 model fallback: <what the harness substituted>
391
+ ```
392
+
393
+ Worth reading the log for. A run that quietly used a weaker model than you chose
394
+ otherwise looks like a run that was merely unlucky.
395
+
396
+ ## Escalation tiers
397
+
398
+ | Tier | Meaning | Raised by | Delivered to |
399
+ | --- | --- | --- | --- |
400
+ | 1 | "Not a human's problem yet" — the run is parked and safe. | Unroutable issue, blocked run, failed or killed run, dispatch error, attempts exhausted. | The orchestrator session, as an injected prompt. Falls back to an issue comment when no orchestrator is running, or when it will not accept the injection. |
401
+ | 2 | "The fleet is stopped until you look." | Daily spend cap reached; the project is already paused. | Telegram, when `escalation.telegramChatId` is set and a bot token is readable; otherwise it falls back to the issue comment. |
402
+
403
+ **The orchestrator** is one persistent, file-backed session per daemon run, resumed
404
+ across restarts so it remembers what it has already handled. Its `cwd` is the state
405
+ directory, deliberately not a checkout. Delivery resolves when the harness *accepts*
406
+ the prompt, not when the model answers it, so a tick never parks behind a model; an
407
+ injection arriving mid-thought queues as a follow-up instead of interrupting the
408
+ turn in flight. Its standing orders are explicit: re-brief the worker, file or
409
+ comment on issues, or promote to tier 2, and never edit product code, push a branch
410
+ or merge a PR. If it fails to start, the daemon logs a warning and runs on, with
411
+ tier-1 escalations degraded to issue comments.
412
+
413
+ Tier 2 borrows the bot token that `omp-telegram` already owns, at
414
+ `~/.omp/agent/telegram/.env` (or `$OMP_TELEGRAM_STATE_DIR/.env`). If you run that
415
+ bot, Tier 2 needs no extra configuration beyond the chat id. If the token is
416
+ absent, Tier 2 degrades to the issue comment instead of failing. The token is
417
+ never logged, and it is redacted out of any error text that could reach a public
418
+ issue comment.
419
+
420
+ **Escalations are deduplicated.** The dispatcher re-notices the same unroutable
421
+ issue on every poll, so a ledger in the store — keyed by project, issue, tier and
422
+ summary — makes a recurring condition page **once** and suppresses the five-minute
423
+ repeats. The marker is recorded only on successful delivery, so a page that could
424
+ not be delivered is retried on the next tick instead of being written off as sent.
425
+ The spend-cap summary carries the date, so the same cap pages again tomorrow but
426
+ only once per day.
427
+
428
+ If `fallbackToIssueComment` is off and no Telegram transport is configured,
429
+ delivery throws instead of dropping silently. The failure is logged and retried,
430
+ because a swallowed escalation looks exactly like a healthy fleet.
431
+
432
+ ## Configuration
433
+
434
+ The config lives at `$OMP_CONDUCTOR_HOME/config.json`, or
435
+ `~/.omp/conductor/config.json` when that variable is unset. It is written with mode
436
+ `0600` in a directory created `0700`, because it carries chat ids and clone URLs.
437
+ That same directory holds the SQLite store (`conductor.db`), the `paused` sentinel,
438
+ the `sessions/` worker transcripts and the `orchestrator/` session directory.
439
+
440
+ Runtime state lives elsewhere, under `$OMP_CONDUCTOR_RUNTIME_DIR` (default
441
+ `~/.omp/run/daemons/omp-conductor`): `daemon.json`, a mode-`0600` pidfile written
442
+ atomically, and `daemon.log`, appended across every boot so the previous failure is
443
+ still there when you go looking. It is kept apart from the config directory because
444
+ it is meaningless after a reboot, and the pidfile's liveness is probed on every
445
+ read — a stale one never blocks a `start`.
446
+
447
+ The file is validated on every read. A malformed config produces one readable error
448
+ listing every fault, and the daemon refuses to start rather than running with half
449
+ a project.
450
+
451
+ `version` is `2`. A `version: 1` file still loads: caps it names that this build no
452
+ longer enforces are dropped rather than treated as typos, and the next save writes
453
+ it back as `2`. In a `version: 2` file an unrecognised cap key **is** an error,
454
+ because there is nothing left to retire — a mistyped `dailySpendUSD` would
455
+ otherwise read as configured while the real ceiling stayed the default.
456
+
457
+ A complete, valid config for one project with two target repos:
458
+
459
+ ```json
460
+ {
461
+ "version": 2,
462
+ "defaults": {
463
+ "maxConcurrentWorkers": 2,
464
+ "dailySpendUsd": 25,
465
+ "workerMaxTurns": 120,
466
+ "workerWallClockMs": 5400000,
467
+ "maxAttemptsPerIssue": 2
468
+ },
469
+ "projects": [
470
+ {
471
+ "name": "demo",
472
+ "tracker": { "kind": "github", "repo": "acme/planning" },
473
+ "queueLabel": "ready-for-agent",
474
+ "stateLabels": {
475
+ "inProgress": "agent:in-progress",
476
+ "blocked": "agent:blocked",
477
+ "failed": "agent:failed"
478
+ },
479
+ "routing": {
480
+ "labelPrefix": "repo:",
481
+ "repos": {
482
+ "api": {
483
+ "name": "api",
484
+ "cloneUrl": "git@github.com:acme/api.git",
485
+ "defaultBranch": "main",
486
+ "gates": [
487
+ { "cmd": "bun run lint", "cwd": "." },
488
+ { "cmd": "bun test", "cwd": "." }
489
+ ]
490
+ },
491
+ "worker": {
492
+ "name": "worker",
493
+ "cloneUrl": "git@github.com:acme/worker.git",
494
+ "defaultBranch": "main",
495
+ "gates": [
496
+ { "cmd": "ruff check .", "cwd": "." },
497
+ { "cmd": "pytest -q", "cwd": "backend" }
498
+ ]
499
+ }
500
+ }
501
+ },
502
+ "caps": {
503
+ "maxConcurrentWorkers": 1,
504
+ "dailySpendUsd": 15
505
+ },
506
+ "workerModel": "smol",
507
+ "escalation": {
508
+ "telegramChatId": "123456789",
509
+ "fallbackToIssueComment": true
510
+ },
511
+ "reporting": {
512
+ "scope": "material"
513
+ },
514
+ "workspaceRoot": "~/.omp/conductor/worktrees",
515
+ "mirrorRoot": "~/.omp/conductor/mirrors"
516
+ }
517
+ ]
518
+ }
519
+ ```
520
+
521
+ Field notes:
522
+
523
+ | Field | Notes |
524
+ | --- | --- |
525
+ | `version` | Must be `2`. A `version: 1` file still loads, drops the caps this build no longer enforces, and is rewritten as `2` on the next save. Present from day one so a format change can be migrated instead of silently misread. |
526
+ | `defaults` | Every `Caps` field. Anything omitted falls back to the built-in default. |
527
+ | `tracker.repo` | `owner/repo`. `tracker.kind` may be omitted; `"github"` is the only accepted value. |
528
+ | `queueLabel` | The one label meaning "a human has signed this off as agent-ready". Matched exactly, case-sensitively. |
529
+ | `stateLabels` | Optional; defaults to `agent:in-progress`, `agent:blocked`, `agent:failed`. |
530
+ | `routing.labelPrefix` | Optional; defaults to `repo:`. |
531
+ | `routing.repos` | At least one entry, or nothing can be routed. `name` defaults to the map key, `defaultBranch` to `main`. |
532
+ | `gates` | The exact cheap commands CI also runs, each with the `cwd` it runs from (`cwd` defaults to `.`). Running the real gate locally is what makes an unattended push safe — a subset lets an error outside the source dir reach the runners. |
533
+ | `caps` | Per-project overrides; omit it or pin only the fields you want to change. |
534
+ | `escalation.fallbackToIssueComment` | Defaults to `true`. Absent means "yes, still tell me". |
535
+ | `reporting.scope` | Optional; `"material"` (default) or `"escalations"`. Every orchestrator tick appends the matching constraint line to its prompt, re-read from this file each tick — see [Your workflow vs. the package](#your-workflow-vs-the-package). It constrains what the session is told to report; it is not an outbound filter. A config written without the key keeps reporting material events. Any other value is an error, never folded to the default. |
536
+ | `workspaceRoot` / `mirrorRoot` | Optional; default to `worktrees/` and `mirrors/` under the state directory. `~` is expanded. |
537
+
538
+ Prefer an SSH `cloneUrl`, or an https URL backed by a credential helper. A clone URL
539
+ with credentials embedded is persisted into the mirror's git config, exactly as it
540
+ would be for a hand-run clone.
541
+
542
+ ## Orchestrator tick
543
+
544
+ The escalation path above assumes an orchestrator session that is actually
545
+ running its loop. A 24/7 omp session with a standing brief and nobody typing into
546
+ it never gets prompted, so it never runs anything. Installing
547
+ `omp plugin install omp-conductor` also installs a heartbeat that prompts it.
548
+
549
+ The heartbeat is **inert unless the session's cwd contains
550
+ `.conductor-tick.json`**, so it costs an ordinary session nothing. Drop the file
551
+ in the orchestrator's working directory (on the fleet host, `/root/fleet`):
552
+
553
+ ```json
554
+ {
555
+ "intervalSeconds": 900,
556
+ "armedFile": "state/armed",
557
+ "accessFile": "/root/.omp/agent/telegram/access.json",
558
+ "message": "Run your standing loop from ORCHESTRATOR.md now."
559
+ }
560
+ ```
561
+
562
+ | Key | Required | Default | Notes |
563
+ | --- | --- | --- | --- |
564
+ | `intervalSeconds` | yes | — | Whole seconds between ticks, minimum `60`. A tick costs a full turn of a frontier model, so a sub-minute period is refused rather than obeyed. |
565
+ | `armedFile` | no | none — the gate passes | Path to the arm marker. A tick does nothing while the file is missing. Relative paths resolve against the session cwd, so `state/armed` means `<cwd>/state/armed`. |
566
+ | `accessFile` | no | none — the gate passes | Path to the Telegram bridge's `access.json`. Every tick re-reads it and requires `enabled: true` with exactly one entry in `allowFrom`. Relative paths resolve against the session cwd. **Configure this on any fleet deploy** — see below. |
567
+ | `message` | no | `Tick <ISO timestamp>: run your standing loop from ORCHESTRATOR.md now.` followed by the `reporting.scope` line | Sent verbatim when set — and then it owns the whole contract: no scope line is appended to a prompt you wrote yourself. The default carries the timestamp, which is what makes two consecutive ticks distinguishable in the session log. |
568
+
569
+ A tick sends one message (`customType` `omp-conductor.tick`, attributed to the
570
+ user): the standing-loop prompt, plus the one constraint line the project's
571
+ [`reporting.scope`](#your-workflow-vs-the-package) resolves to, re-read from the
572
+ conductor config on every tick. It starts a turn if the session is idle; while a
573
+ turn is streaming it is queued as a follow-up and consumed when that turn ends.
574
+ It sends **nothing** when:
575
+
576
+ - `/conductor pause` (or `omp-conductor pause`) holds the pause flag, the same
577
+ flag the dispatch loop reads, so pausing the fleet pauses its heartbeat;
578
+ - `armedFile` is configured and missing;
579
+ - `accessFile` is configured and the escalation channel is not verifiably up;
580
+ - an earlier tick is still queued. Ticks coalesce rather than stack, so a slow
581
+ turn cannot leave a backlog of heartbeats behind it.
582
+
583
+ ### The escalation channel is a gate, and it fails closed
584
+
585
+ Unattended dispatch is only defensible while a tier-2 escalation can reach a
586
+ person. So `accessFile` is checked on **every** tick and never cached at session
587
+ start: the bridge is reconfigured out-of-band, and a heartbeat that trusted a
588
+ startup snapshot would keep dispatching for days after the channel went away. A
589
+ stale arm marker must not outlive the channel that makes running unattended safe.
590
+
591
+ The check passes only when the file parses to an object with `enabled: true` and
592
+ exactly one `allowFrom` entry. Everything else stops the heartbeat: file missing,
593
+ unreadable or truncated; not JSON, or JSON that is not an object; `enabled`
594
+ absent or false; zero owners paired (nobody to page) or more than one (ambiguous:
595
+ the conductor refuses to guess which human is on the hook). Failure modes are
596
+ deliberately not distinguished in the decision: each one means a page lands
597
+ nowhere.
598
+
599
+ Leaving `accessFile` unset passes the gate, because an ordinary developer session
600
+ that happens to have a `.conductor-tick.json` has no bridge to check. It is not an
601
+ off switch for the check: **a fleet deploy always sets it.**
602
+
603
+ Every tick — sent or skipped — is logged with its reason (`paused`, `not armed`,
604
+ `escalation channel down`, `tick already pending`) to the omp log. Skips are
605
+ deliberately silent in the UI: a paused fleet would otherwise raise a notification
606
+ every interval, forever. The one exception is a malformed `.conductor-tick.json`,
607
+ which notifies once at session start and leaves the heartbeat off; silent failure
608
+ there is the failure mode the heartbeat exists to prevent. A conductor config that
609
+ cannot supply a reporting scope logs `tick reporting scope: using material` once
610
+ per session. The interval does not re-log it, because the file is unlikely to fix
611
+ itself between two ticks.
612
+
613
+ ## CLI reference
614
+
615
+ ```bash
616
+ omp-conductor start [--port N] [--project NAME]
617
+ omp-conductor stop
618
+ omp-conductor restart [--port N] [--project NAME]
619
+ omp-conductor status [--project NAME]
620
+ omp-conductor daemon [--once] [--port N] [--project NAME]
621
+ omp-conductor pause
622
+ omp-conductor resume
623
+ omp-conductor help
624
+ ```
625
+
626
+ | Command | Behaviour |
627
+ | --- | --- |
628
+ | `start` | Spawn the loop in the background, detached, and wait until it answers `GET /healthz` on `:8787`. Refuses if one is already live, naming its pid. If the process dies or never serves, `start` cleans up after it and quotes the tail of `daemon.log`. |
629
+ | `stop` | `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop. |
630
+ | `restart` | `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them — a restart that quietly moved to the default port would leave every existing health check pointing at nothing. |
631
+ | `status [--project NAME]` | Pause state, config and state paths, resolved caps, active runs and today's usage, plus a `daemon` block: pid, uptime, port, project, `/healthz` result and log path. Reads while a daemon in another process writes. |
632
+ | `daemon` | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. This is what `start` launches. |
633
+ | `daemon --once` | Run a single tick and exit. No HTTP server. |
634
+ | `--port N` | Accepted by `start`, `restart` and `daemon`. Both `--port 9000` and `--port=9000` work; missing or out of range exits `2` rather than falling back to the default, because probing the wrong endpoint is worse than a hard failure. |
635
+ | `--project NAME` | Pick the project to service. One daemon process serves exactly one project; with several configured projects the name is required. |
636
+ | `pause` | Stop claiming new work. The running daemon notices on its next tick; runs already in flight finish. |
637
+ | `resume` | Allow claiming again. |
638
+ | `help`, `--help`, `-h` | Print usage. An unknown or missing verb prints it too, and exits `2`. |
639
+
640
+ Pause is a flag file under the state directory, so it applies to every project and
641
+ survives a daemon restart.
642
+
643
+ Four of these are available in-session as `/conductor setup`, `/conductor status`,
644
+ `/conductor pause` and `/conductor resume`, each taking an optional project name as
645
+ a second word. Background-process management is CLI-only: the plugin does not
646
+ start, stop or restart the daemon.
647
+
648
+ ### Health endpoint
649
+
650
+ ```bash
651
+ curl -s localhost:8787/healthz
652
+ ```
653
+
654
+ ```json
655
+ { "ok": true, "paused": false, "activeRuns": 1, "project": "demo" }
656
+ ```
657
+
658
+ Any other path or method returns `404`. Note that `ok` reports only that the
659
+ process is serving. It does not report that the fleet is doing work: read
660
+ `paused` to tell those apart.
661
+
662
+ ## What a worker may and may not do
663
+
664
+ Each worker gets one brief, one worktree, one branch, and no knowledge of the
665
+ dispatcher. The brief is explicit about the boundary:
666
+
667
+ | It may | It must not |
668
+ | --- | --- |
669
+ | Read the issue and the repo's own guidance (`AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, relevant ADRs) before writing anything. | Touch any path outside its worktree, or switch branches. |
670
+ | Edit code inside its own worktree. | Weaken, skip, delete or loosen **any test it did not write** — that is a design question to escalate, and it is checked by diff review before the push. |
671
+ | Add or update tests for behaviour it introduced. | Suppress a warning, delete an assertion, or special-case an input to make a check pass. |
672
+ | Run the repo's configured cheap gates, each from its listed `cwd`, over the whole tree. | Run docker or image builds, production builds, browser/e2e suites, or the full test suite on the shared host — CI owns the heavy gates. |
673
+ | Review its whole diff, then commit and **push once**. One corrective push if CI is red. | Force-push, `git add -f`, or add AI/co-author attribution. Red twice means stop and report, not push a third time. |
674
+ | Open a PR that links the issue, and watch CI to a verdict with `gh pr checks --watch`. | Run `gh pr merge`. **Merge authority is a human's alone**, so PRs land one at a time with a freshness re-check — two workers merging concurrently is how agent PRs clobber each other. |
675
+ | Escalate: ambiguity, a cross-repo contract, a needed credential, a product or data-migration decision, a blocking existing test, CI red twice, or most of the wall-clock budget burned. | **Cut a release**, push a tag, publish to npm, edit a deployment pin, deploy, or touch infrastructure or secrets — permanently out of scope. Releases are batched and decided outside this loop, so "this needs releasing" is a thing to report, never a task to take on. |
676
+
677
+ The worker ends with a six-line evidence report (issue, pr, state, gates, changed,
678
+ next). `pushed-green` means it watched the checks go green rather than expecting
679
+ them to.
680
+
681
+ ## Limitations
682
+
683
+ Known and deliberate in this version:
684
+
685
+ - **`gh` is shelled out to.** Every tracker operation spawns a process and does its
686
+ own TLS handshake (roughly 200-400 ms each), and failures are classified by
687
+ matching human-readable stderr rather than a status code. The upside is that no
688
+ token is ever handled, stored or logged by the daemon.
689
+ - **`listReady` fetches a single page of 100 issues.** A queue deeper than 100
690
+ ready issues truncates silently. A backlog that size is a staffing problem before
691
+ it is a paging one.
692
+ - **Spend accounting depends on harness telemetry.** Cost arrives only when the
693
+ harness run carries it; without it `spendUsd` reads `0`, `status` shows `$0.00`,
694
+ and the daily-spend cap never fires. The turn and wall-clock ceilings are what
695
+ actually bound a runaway in that case. Do not treat `$0.00` as proof that nothing
696
+ was spent.
697
+ - **GitHub is the only tracker.** The internal `Tracker` port is deliberately
698
+ provider-neutral, but `tracker.kind` accepts only `"github"` today.
699
+ - **One project per daemon process.** Several projects means several processes,
700
+ each with `--project` and its own `--port`.
701
+ - **Labels are matched exactly and case-sensitively.** `Ready-For-Agent` is not
702
+ `ready-for-agent`, and the mismatch is silent: the issue is simply never picked
703
+ up.
704
+ - **No cross-process lock on the mirrors.** Two dispatch loops fetching the same
705
+ repo at the same instant can collide on git's ref locks; the run fails and is
706
+ retried rather than corrupted.
707
+ - **Mirrors grow one branch ref per run.** Unpushed work is never discarded, so
708
+ refs accumulate until you reap them.
709
+ - **`stop` is a deadline, not a clean drain.** `SIGTERM` asks the loop to finish the
710
+ tick it is on, and a tick with a worker in flight can run for that worker's whole
711
+ wall clock; after 10 seconds it is `SIGKILL`. There is no "stop once the current
712
+ worker lands".
713
+ - **A failed orchestrator degrades quietly.** The daemon logs a warning and keeps
714
+ running, but tier-1 escalations then land in issue comments — which is exactly the
715
+ "nobody reads it until morning" path the orchestrator exists to avoid. The warning
716
+ is in `daemon.log`; nothing pages you about it.
717
+ - **Workers are not terminal panes, so you cannot watch them.** A worker is an
718
+ in-process omp session inside the daemon, started by `createSession` and driven
719
+ concurrently via `Promise.allSettled`. Herdr therefore shows exactly one pane
720
+ (the orchestrator's) no matter how many workers are running, and no amount of
721
+ `maxConcurrentWorkers` changes that.
722
+
723
+ The cap does work. The admission loop (`src/daemon.ts:405-445`) computes
724
+ `slots = maxConcurrentWorkers - active runs`, admits at most that many issues per
725
+ tick, and dispatches them together. To see them, read `omp-conductor status`,
726
+ which lists every active run, or follow `daemon.log`.
727
+ - **Merges, releases and deploys are human-only, by design.** The conductor
728
+ produces green PRs and stops.
729
+
730
+ ## License
731
+
732
+ MIT