omp-conductor 0.3.25 → 0.4.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 CHANGED
@@ -568,31 +568,36 @@ Per tick, for the daemon's project:
568
568
  its issue but not a slot: its worker is finished, and counting pushed PRs
569
569
  would let two completed workers stop the fleet.
570
570
  If no slot is free, the tick logs and returns.
571
- 8. **Admit issues** up to the free slots, skipping any issue that already has
571
+ 8. **Check the plan allowance.** If `caps.planUsage` names a window, the daemon
572
+ reads it (cached, see [Caps](#caps)) and holds *every* candidate under
573
+ `plan-usage-cap` when the window is at or over its threshold. Unlike the
574
+ spend cap this does **not** pause the daemon: the window resets on the
575
+ provider's clock, so dispatch resumes by itself.
576
+ 9. **Admit issues** up to the free slots, skipping any issue that already has
572
577
  an active run — including a pending or green PR, so a second attempt cannot
573
578
  land on a live PR. Repeated implementation failures consume
574
579
  `maxAttemptsPerIssue`; cap kills, daemon orphans and answered blocks consume
575
580
  the independent `maxContinuationsPerIssue`. Exhausting either escalates.
576
- 9. **Ask the tracker whether the work already exists.** For each candidate that
577
- survived step 8 — so at most one API call per free slot, never one per queued
578
- issue — the daemon asks whether an **open** PR already closes it. An open PR
579
- normally holds the issue. One narrow exception permits a routed continuation:
580
- the latest run must be terminal, and the open PR must be that run's retained
581
- work — either the PR URL it recorded or a PR opened on the branch it retained.
582
- The branch half matters because a run can be cap-killed before its worker ever
583
- opens a PR, leaving a retained branch and no recorded URL; a PR pushed to that
584
- branch afterwards is still the continuation target. Drafts count because their
585
- branch can hold the only copy of the work.
586
- The tracker also finds work missing from a new, moved, restored, or cleared
587
- store. If the check fails, the candidate is **held**, not admitted, and
588
- retried next tick: the cost of holding is five minutes, the cost of admitting
589
- on an unknown is a burned attempt and a duplicate PR. Only that candidate is
590
- held, so a flaky API cannot stall the rest of the queue.
591
- 10. **Record the pass.** Persist ready/routed/admitted counts and group every hold
581
+ 10. **Ask the tracker whether the work already exists.** For each candidate that
582
+ survived step 9 — so at most one API call per free slot, never one per queued
583
+ issue — the daemon asks whether an **open** PR already closes it. An open PR
584
+ normally holds the issue. One narrow exception permits a routed continuation:
585
+ the latest run must be terminal, and the open PR must be that run's retained
586
+ work — either the PR URL it recorded or a PR opened on the branch it retained.
587
+ The branch half matters because a run can be cap-killed before its worker ever
588
+ opens a PR, leaving a retained branch and no recorded URL; a PR pushed to that
589
+ branch afterwards is still the continuation target. Drafts count because their
590
+ branch can hold the only copy of the work.
591
+ The tracker also finds work missing from a new, moved, restored, or cleared
592
+ store. If the check fails, the candidate is **held**, not admitted, and
593
+ retried next tick: the cost of holding is five minutes, the cost of admitting
594
+ on an unknown is a burned attempt and a duplicate PR. Only that candidate is
595
+ held, so a flaky API cannot stall the rest of the queue.
596
+ 11. **Record the pass.** Persist ready/routed/admitted counts and group every hold
592
597
  under a stable reason code with at most five sample issue numbers. Tracker
593
598
  failures mark the summary `DEGRADED`; capacity, sibling, open-PR and budget
594
599
  holds remain normal policy state.
595
- 11. **Dispatch** the admitted issues concurrently.
600
+ 12. **Dispatch** the admitted issues concurrently.
596
601
 
597
602
  Then, per admitted issue:
598
603
 
@@ -763,6 +768,54 @@ issues carrying `agent:in-progress`, which eligibility reads as "a worker owns
763
768
  this" — with the brief forbidding the orchestrator from editing a state label and
764
769
  `unblock` declining to clear that one, neither issue could ever be claimed again.
765
770
 
771
+ ### The settlement audit
772
+
773
+ Verifying `state: pushed-green` left two lines of the same report still taken on
774
+ faith. The worker brief asks for them and, until this existed, nothing read them:
775
+
776
+ ```
777
+ gates: <exact commands run and their results>
778
+ changed: <files touched, one line>
779
+ ```
780
+
781
+ So at settlement the daemon fetches the pull request's diff and checks the report
782
+ against it. What it finds is a **settlement audit flag** — advisory, never a
783
+ gate. A flagged run settles exactly as an unflagged one does; nothing here can
784
+ change a run's state, hold a merge, or spend an attempt.
785
+
786
+ | Flag | Raised when |
787
+ | --- | --- |
788
+ | `undisclosed-file` | The PR touched a file the `changed:` line never named. Lockfiles are exempt — they are derived from a manifest the report did disclose. |
789
+ | `changed-line-missing` | The report had no usable `changed:` line at all. One flag, not one per file. |
790
+ | `unmatched-claim` | `changed:` named a path the PR never touched. The weaker direction, and reported as such. |
791
+ | `test-file-deleted` | A test file left the tree with no rename to account for it. |
792
+ | `test-disabled` | A `.skip` / `.only` / `xit` / `@pytest.mark.skip` / `t.Skip` marker appears on a line the PR added. |
793
+ | `assertions-removed` | An assertion was commented out, or a test file lost more assertions than it gained. |
794
+ | `test-timeout-raised` | A named timeout in a test file went up — compared against its own previous value, so a brand-new timeout is not a finding. |
795
+
796
+ A flag on a test file the dispatching issue never names is marked
797
+ `[unattributed]`: that is the "don't weaken tests you didn't write" case, and it
798
+ is the one worth reading first.
799
+
800
+ Flags reach you three ways: appended to the settlement report, stored on the run
801
+ row and shown under the run in `omp-conductor status` for as long as its PR is
802
+ open, and — once per flagged settlement — as a tier-1 escalation to the
803
+ orchestrator, whose brief says what judgement each flag invites.
804
+
805
+ **A clean, accurately reported PR produces nothing.** That is a design
806
+ constraint, not an aspiration: an audit that fires on honest work gets muted, and
807
+ a muted audit is worse than none because the fleet still believes it is being
808
+ checked. Every rule resolves ambiguity towards silence, and each accepts a named
809
+ blind spot to stay quiet — a renamed test file is not a deleted one (even when
810
+ git did not detect the rename, matched by basename), a `.skip` inside a string
811
+ literal or a recorded fixture is not a skip, and a rewritten test that keeps its
812
+ coverage is not a weakening.
813
+
814
+ The analyser is pure: it takes a parsed diff, the report and the issue text, and
815
+ returns flags. Only `Tracker.prDiff` touches the network, and a diff it cannot
816
+ read produces no flags *and says so* — silence about a diff nobody read is not a
817
+ clean bill.
818
+
766
819
  ### Continuation runs
767
820
 
768
821
  When a worktree is provisioned onto a branch that already exists in the mirror
@@ -852,6 +905,7 @@ rest. `0` is a real value (a hard stop), not "unset".
852
905
  | --- | --- | --- |
853
906
  | `maxConcurrentWorkers` | `2` (setup may write `1` on &lt;16 GiB hosts) | Parallel in-process omp sessions inside the daemon PID. 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. On hosts under ~16 GiB RAM, prefer `1` so the unit stays out of swap ([host sizing](#host-sizing-and-memory)). Raise it only if you actually have the runners *and* the RAM. |
854
907
  | `dailySpendUsd` | `25` | Rolling-day spend ceiling in USD, or `null` for no spend gate. `0` is a hard stop. Metered from assistant `usage.cost.total`. |
908
+ | `planUsage` | `null` (unmetered) | Subscription/plan allowance guard: `{ "windowId": "anthropic:7d", "maxUsedFraction": 0.85 }`, or `null` for no plan gate. Independent of `dailySpendUsd` — see [Plan allowance](#plan-allowance-planusage) below. |
855
909
  | `workerMaxTurns` | `120` | Startup ceiling for each new worker. Catches a session looping without converging; use `omp-conductor extend` to raise one live run without changing this default. |
856
910
  | `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. |
857
911
  | `maxAttemptsPerIssue` | `2` | Failed implementation or CI attempts before escalation. Operational stops do not consume this budget, so salvage can continue without stealing the retry needed for a real failure. |
@@ -875,6 +929,83 @@ effective value is persisted and shown beside that active run in `status`; editi
875
929
  flight. A cap that fires aborts the run, records it as `killed`, and names the
876
930
  ceiling in the escalation.
877
931
 
932
+ ### Plan allowance (`planUsage`)
933
+
934
+ `dailySpendUsd` meters money, which is the only thing an API-billed account can
935
+ run out of. A fixed-price subscription cannot be expressed that way: the real
936
+ ceiling is a **provider allowance** — a weekly token window whose marginal
937
+ dollar cost is zero and whose exhaustion stops every session on the host.
938
+ Pricing that into the dollar meter would mean inventing a number.
939
+
940
+ `planUsage` is the second, independent guard. It reads `omp usage --json` — the
941
+ structured form of the harness `/usage` view — and holds new claims while the
942
+ named window is at or over its threshold. Running workers finish normally, and
943
+ **the daemon is not paused**: the window resets on the provider's clock, so
944
+ dispatch resumes by itself once a fresh reading is below the threshold. Nothing
945
+ estimates a plan quota from conductor's own transcript token counts.
946
+
947
+ ```json
948
+ "caps": {
949
+ "planUsage": { "windowId": "anthropic:7d", "maxUsedFraction": 0.85 }
950
+ }
951
+ ```
952
+
953
+ **Naming the window.** `limits` in the payload is a *list*, not a single
954
+ number: one Anthropic account reports `anthropic:5h`, `anthropic:7d` and the
955
+ tier-scoped `anthropic:7d:fable` at the same time, and other providers add
956
+ their own. So the cap names its window rather than taking whichever entry came
957
+ first. Run this on the fleet host and copy an `id`:
958
+
959
+ ```bash
960
+ omp usage --json | jq -r '.reports[].limits[] | "\(.id) \(.amount.usedFraction) \(.amount.unit)"'
961
+ ```
962
+
963
+ A bare window key (`"7d"`) also works, but **only** when exactly one reported
964
+ allowance carries it. On an Anthropic account `7d` matches two, and the guard
965
+ refuses to guess.
966
+
967
+ **`maxUsedFraction` is a fraction, not a percentage.** `0.85` holds at 85%.
968
+ A value outside `0`–`1` is rejected at config load, because `85` would mean
969
+ "hold at 8500% consumed" — a guard that reads as configured and can never fire.
970
+ Comparison always goes through the provider's `usedFraction`, never a raw
971
+ count: `unit` is `percent` for Anthropic and `unknown` with raw counts for
972
+ `xai-oauth`, so a threshold compared against `used` misreads any non-percent
973
+ provider by orders of magnitude.
974
+
975
+ **Availability policy.** The guard never displays a number it did not read, and
976
+ never shows a fabricated `0% used`. What each situation does:
977
+
978
+ | Situation | `status` / `board` | New claims |
979
+ | --- | --- | --- |
980
+ | `planUsage: null` | `unmetered` | admitted |
981
+ | Window below threshold | `5% / 85% of anthropic:7d used · resets in 6d 2h` | admitted |
982
+ | Window at or over threshold | same, plus `holding new claims` | **held** (`plan-usage-cap`, Tier 1) |
983
+ | No provider reports a readable allowance, or `omp usage --json` fails | `unavailable — <reason>` | admitted for up to 30 minutes, then **held** and paged at Tier 2 |
984
+ | `windowId` names a window the reading does not contain | `window "<id>" is not in this reading — Reported: …` | **held**, Tier 2 |
985
+ | `windowId` matches more than one allowance | `window "<id>" matches …` | **held**, Tier 2 |
986
+ | The window reports nothing a fraction can be derived from | `window "<id>" reports no comparable fraction …` | **held**, Tier 2 |
987
+
988
+ The split is deliberate. A *read error* is transient — a token refresh, a
989
+ provider 502, `omp` briefly absent mid-upgrade — and stalling a fleet on one
990
+ would cost more than admitting through it, since spend, turns, wall clock and
991
+ concurrency are all still enforced. Half an hour of continuous failure is not
992
+ an outage, it is a broken meter, and a plan-capped fleet running on a broken
993
+ meter is how the allowance gets spent to zero unnoticed. A *successful* read
994
+ that does not contain the configured window is not a read error at all: the
995
+ source answered, and it says the config names something that is not there. That
996
+ fails closed immediately, like every other config fault in this package, and
997
+ recovers by itself as soon as a reading contains the window again.
998
+
999
+ Readings are cached for 60 seconds (15 for a failure) so one tick costs one
1000
+ provider call rather than one per candidate, and a cached reading is dropped
1001
+ the moment its own `resetsAt` passes — that is what makes admission resume at
1002
+ the rollover instead of a TTL later. `omp usage invalidate` clears omp's own
1003
+ cache; conductor picks the change up at its next read.
1004
+
1005
+ Both controls are shown separately, never folded together — `status` prints a
1006
+ `spend today` row and a `plan usage` row, and the board's admission line ends
1007
+ with `spend $2.40/$25.00 | plan 5%/85%`.
1008
+
878
1009
  ## Worker model
879
1010
 
880
1011
  `workerModel` on a project pins the model its workers run on, as a pattern in
@@ -1054,6 +1185,123 @@ If `fallbackToIssueComment` is off and no Telegram transport is configured,
1054
1185
  delivery throws instead of dropping silently. The failure is logged and retried,
1055
1186
  because a swallowed escalation looks exactly like a healthy fleet.
1056
1187
 
1188
+ ## Report delivery (the outbox)
1189
+
1190
+ Escalations are the daemon's. **Reports** — the material events and the daily
1191
+ digest your [`reporting.scope`](#your-workflow-vs-the-package) asks for — are
1192
+ written by the orchestrator, and until v0.3.26 they were also *delivered* by it:
1193
+ a report reached you only if the model remembered to call `telegram_send`. On
1194
+ 2026-08-06 a suite release and two tier-2 escalations were written that way and
1195
+ none of the three arrived, and nothing anywhere recorded that fact — an undelivered
1196
+ report and a quiet tick look identical.
1197
+
1198
+ Authorship still needs judgement the daemon does not have, so it stays with the
1199
+ model. Delivery does not, so it moved:
1200
+
1201
+ ```bash
1202
+ omp-conductor report --text "<the whole report>" # one material event
1203
+ omp-conductor report --text "<the whole digest>" --kind digest # the daily digest
1204
+ ```
1205
+
1206
+ The command writes the text into a `reports` table in the store and prints a
1207
+ report id. Nothing has been sent at that point, and that is the guarantee: the
1208
+ report survives the session being compacted, interrupted or restarted, and it
1209
+ survives the daemon being restarted under it. The daemon then delivers it over
1210
+ the same bot token tier 2 uses, with bounded retries, and `omp-conductor status`
1211
+ lists anything that has not landed.
1212
+
1213
+ ### Delivery is at-least-once, and the docs will not pretend otherwise
1214
+
1215
+ The Telegram Bot API accepts no client-supplied idempotency key and offers the
1216
+ bot no readable record of what it has already sent. There is nothing to replay a
1217
+ request against and nothing to reconcile with, so **exactly-once delivery cannot
1218
+ be built on this transport** and this package does not claim it. `delivered` is
1219
+ proof that Telegram accepted *an* attempt, never proof that exactly one message
1220
+ exists.
1221
+
1222
+ What it does instead is make the ambiguity explicit and always resolve it in the
1223
+ direction of the duplicate:
1224
+
1225
+ | State | Meaning | What you do |
1226
+ | --- | --- | --- |
1227
+ | `pending` | Nothing is in flight. Never attempted, or the last attempt failed **definitively** — see below. Nobody has this report. | Nothing. It retries on a bounded backoff (30s doubling to a 15-minute floor) and `status` shows the error. |
1228
+ | `sending` | A request left this host and its outcome was never learned: the daemon died, or the request was cut off after the bytes went out. Telegram may be holding the message. | Nothing, but expect a possible duplicate. Check the chat if you want to know now. |
1229
+ | `delivered` | Telegram answered `ok: true`. The row records the message id from the response **body**, not the HTTP status. | Nothing. |
1230
+ | `failed` | The retry budget ran out — six attempts, roughly half an hour. | Fix the transport. This state pages tier 2 in its own right — see below. |
1231
+
1232
+ The row is written `sending`, with the id of the attempt about to run, **before**
1233
+ the request is made. A crash in that window therefore leaves an explicitly
1234
+ ambiguous row rather than a silently lost one. The next daemon start sweeps every
1235
+ `sending` row, retries it, and the retried message carries the report id and a
1236
+ plain-English line saying it may already be in the chat. A duplicate you can spot
1237
+ by its report id is much the cheaper of the two mistakes; a silently dropped
1238
+ report is the entire reason this exists.
1239
+
1240
+ #### Two kinds of failure, and only one of them is quiet
1241
+
1242
+ A failed send is classified where the socket is watched, not by the caller, and
1243
+ the two classes are treated differently on purpose:
1244
+
1245
+ | Outcome | What happened | Row | Retry says |
1246
+ | --- | --- | --- | --- |
1247
+ | **Definitive** — nobody has it | Telegram answered and refused it (`{"ok":false}` under any status, or a non-2xx status), or the connection never opened at all (refused, DNS failure) so the request provably never left. | back to `pending`, backoff, attempt counted | nothing special — it *is* a first attempt |
1248
+ | **Outcome unknown** — Telegram might have it | The request was cut off after it left: timeout, abort, socket reset, `EPIPE`. Or the POST came back `200` and the **response body could not be read** — Telegram had already decided and the answer was lost coming back. | stays `sending`, flagged as a possible repeat | `POSSIBLE REPEAT`, with the report id to compare against |
1249
+
1250
+ Anything that cannot be classified confidently is treated as **outcome unknown**.
1251
+ That default is deliberate and is the safe direction: the worst case is a
1252
+ duplicate you were warned about, against a delivered report re-posted as though
1253
+ it were new, with nothing anywhere saying it might be a second copy.
1254
+
1255
+ The half of "never double-post" that *is* achievable is enforced: a report cannot
1256
+ be **concurrently** in flight twice. Claiming a report is a conditional update,
1257
+ so only one attempt can move a `pending` row, and every terminal transition names
1258
+ the attempt it is settling — a request that answers after its row was reclaimed
1259
+ is discarded rather than allowed to overwrite a newer attempt's outcome. That is
1260
+ what stops a retry storm.
1261
+
1262
+ ### A report nobody can deliver is itself news
1263
+
1264
+ A report that exhausts its retries is marked `failed` **and escalates as tier 2**.
1265
+ This rides the transport that just failed, which is deliberate and accepted: the
1266
+ common failure is a wrong chat id or a bot kicked from the chat, not a global
1267
+ Telegram outage, and in both of those the page reaches an operator who is
1268
+ otherwise being told nothing at all. If the whole channel is down the page
1269
+ degrades to a line in `daemon.log` and the `reports` block in `status`, which is
1270
+ then the only surface — a report has no tracker issue, so there is no issue
1271
+ comment to fall back to. The page goes through the ordinary escalation ledger and
1272
+ carries the report id, so one undeliverable report pages exactly once.
1273
+
1274
+ ### The daily digest is deduplicated from the ledger
1275
+
1276
+ `--kind digest` is accepted at most once per **local** day, per project. The
1277
+ second hand-over on the same day is refused and told which report already holds
1278
+ the slot, including when that report has already been delivered. This is decided
1279
+ from the `reports` table, not from the model's memory of the last tick — a
1280
+ restarted or compacted session cannot send a second digest by forgetting the
1281
+ first. Material reports carry no such key: two events in a day are two events.
1282
+
1283
+ ### What `status` shows
1284
+
1285
+ ```text
1286
+ reports 1 pending · 1 sending · 0 failed (delivery is at-least-once — a retry may duplicate)
1287
+ 9f2c1ab0d3e4 pending material 12m old attempt 2/6, retry in 1m (telegram sendMessage rejected: {"ok":false,…)
1288
+ 4b7c1ad9e001 SENDING digest 3m old attempt 1, outcome unknown — the process that sent it never said; a daemon start retries it and the message will say it may be a repeat
1289
+ ```
1290
+
1291
+ `pending` and `sending` are printed differently because they ask different things
1292
+ of you, and every row carries its age — "1 report pending since 08:15Z" is the
1293
+ signal that was missing when the reports went nowhere. Delivered reports leave
1294
+ the block: it is a list of what you are still owed, not a log.
1295
+
1296
+ Delivery keeps running while the fleet is **paused**. Pause stops claiming, not
1297
+ your right to hear about work that already happened. It runs on its own
1298
+ thirty-second timer rather than the five-minute dispatch tick, so a report does
1299
+ not sit in the outbox for the length of a poll interval.
1300
+
1301
+ The tier-2 escalation ledger (`notifications`) is untouched by all of this. It is
1302
+ a bare dedupe key by design — its primary key *is* the key — which is exactly why
1303
+ reports needed a separate table rather than an extension of that one.
1304
+
1057
1305
  ## Configuration
1058
1306
 
1059
1307
  The config lives at `$OMP_CONDUCTOR_HOME/config.json`, or
@@ -1097,6 +1345,7 @@ A complete, valid config for one project with two target repos:
1097
1345
  "defaults": {
1098
1346
  "maxConcurrentWorkers": 2,
1099
1347
  "dailySpendUsd": 25,
1348
+ "planUsage": { "windowId": "anthropic:7d", "maxUsedFraction": 0.85 },
1100
1349
  "workerMaxTurns": 120,
1101
1350
  "workerWallClockMs": 5400000,
1102
1351
  "maxAttemptsPerIssue": 2,
@@ -1150,7 +1399,30 @@ A complete, valid config for one project with two target repos:
1150
1399
  "merge": "human",
1151
1400
  "release": "human"
1152
1401
  },
1153
- "releasePolicy": "none",
1402
+ "releasePolicy": {
1403
+ "git-tag": "human",
1404
+ "git-push-tags": "human",
1405
+ "package-publish": "human",
1406
+ "github-release": "human",
1407
+ "deploy": "human"
1408
+ },
1409
+ "credentials": {
1410
+ "isolation": "per-run"
1411
+ },
1412
+ "policy": {
1413
+ "merge": {
1414
+ "requiredChecks": ["build", "lint"],
1415
+ "baseFreshness": "up-to-date",
1416
+ "drafts": "block",
1417
+ "whenBehindBase": "update-branch"
1418
+ },
1419
+ "release": {
1420
+ "requires": ["runs-settled", "no-open-prs"],
1421
+ "requiredChecks": ["release"],
1422
+ "artefacts": ["@acme/sdk"],
1423
+ "environments": ["staging"]
1424
+ }
1425
+ },
1154
1426
  "reporting": {
1155
1427
  "scope": "material"
1156
1428
  },
@@ -1180,12 +1452,69 @@ Field notes:
1180
1452
  | `authority` | Optional; `{ "merge": …, "release": … }`, each `"human"` (default) or `"orchestrator"`. It grants nothing to the daemon — it words the orchestrator's standing orders and the Releases paragraph of the rendered brief, so the config and the prompt cannot disagree about who holds the merge button. Unknown keys and any other value are errors, never folded to the default. |
1181
1453
  | `releasePolicy` | Optional; `"none"` (default) or `"operator-brief"`. `none` installs a pre-tool-call tripwire in worker, embedded-orchestrator and external-orchestrator sessions. It blocks `git tag`, tag pushes, package publishing, GitHub release creation and recognised deploy commands before execution. `operator-brief` opens that gate only for the procedure in the operator-owned brief. Unknown values are errors. Every rejection is written to `release-policy-blocks.jsonl`; the heartbeat carries that day's count into the daily digest so configured intent and observed behaviour cannot drift silently. This is the mechanical gate; `authority.release` still says who owns the decision. |
1182
1454
  | `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. |
1455
+ | `orchestratorReadPaths` | Optional; an array of absolute (or `~`-prefixed) paths the orchestrator session may additionally **read**. The orchestrator's structured file tools are default-refusal — see [Orchestrator confinement](#orchestrator-confinement-default-refusal) — and this is the only way to widen that allowlist. It grants reads, never writes, and it cannot re-open a denied root: an entry pointing at `workspaceRoot`, `mirrorRoot` or the installed package is still refused. A relative entry is an error rather than something resolved against whichever cwd happened to read the file. |
1456
+ | `policy` | Optional; the gating conditions a merge or a release must satisfy, in two sections — `policy.merge` and `policy.release`. Any member may be omitted and the loader fills it from the strict default; an unknown key in either section, or a value outside its vocabulary, is an error naming the field, never a silent downgrade. See [Merge and release preconditions](#merge-and-release-preconditions-policy). |
1183
1457
  | `workspaceRoot` / `mirrorRoot` | Optional; default to `worktrees/` and `mirrors/` under the state directory. `~` is expanded. |
1184
1458
 
1185
1459
  Prefer an SSH `cloneUrl`, or an https URL backed by a credential helper. A clone URL
1186
1460
  with credentials embedded is persisted into the mirror's git config, exactly as it
1187
1461
  would be for a hand-run clone.
1188
1462
 
1463
+ ### Merge and release preconditions (`policy`)
1464
+
1465
+ These used to be sentences in your `POLICY.md`: when a PR may be merged, what
1466
+ must be green, what a release requires. Prose cannot be checked, so every tick
1467
+ re-decided them by reading and interpreting them again. They are configuration
1468
+ now, `POLICY.md` keeps only judgement, and the rendered brief *describes* the
1469
+ policy instead of restating it — no threshold lives in two places.
1470
+
1471
+ `policy.merge`:
1472
+
1473
+ | Field | Values | Default | Means |
1474
+ | --- | --- | --- | --- |
1475
+ | `requiredChecks` | any check names | `[]` | Checks that must have concluded successfully. **Empty is the strict answer** — it means every check the PR reports, not "no checks". |
1476
+ | `baseFreshness` | `up-to-date`, `any` | `up-to-date` | Whether the head must be level with the base branch. `any` accepts a verdict produced against an older base. |
1477
+ | `drafts` | `block`, `allow` | `block` | Whether a draft PR can be merged at all. |
1478
+ | `whenBehindBase` | `update-branch`, `hold`, `escalate` | `update-branch` | What to do with a green PR that fell behind. `update-branch` runs `gh pr update-branch` and waits for the fresh run. Closing it and an admin bypass are not spellable. |
1479
+
1480
+ `policy.release`:
1481
+
1482
+ | Field | Values | Default | Means |
1483
+ | --- | --- | --- | --- |
1484
+ | `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. Order and duplicates do not matter; the loader canonicalises. |
1485
+ | `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
1486
+ | `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
1487
+ | `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
1488
+
1489
+ A project with no `policy` block loads as the whole default above, which is the
1490
+ strictest reading of the prose it replaced. `/conductor setup` asks for all of
1491
+ it under the **merge & release preconditions** area, so changing one condition
1492
+ costs eight prompts rather than a hand-edit — see
1493
+ [Changing one setting](#changing-one-setting).
1494
+
1495
+ This key grants nothing. Who *may* merge or release is
1496
+ [`authority`](#configuration), and which release tool calls are mechanically
1497
+ permitted is [`releasePolicy`](#configuration). `policy` says what must be true
1498
+ before the act, whoever is doing it.
1499
+
1500
+ #### Reasons are a closed vocabulary
1501
+
1502
+ Where an automated verb takes a `reason`, the argument is one value out of a
1503
+ fixed set, not free text — a reason a rule matches on is a reason that decides,
1504
+ and a decision made out of a model's own wording is one no two runs spell the
1505
+ same way. A reason outside its set is refused, and the refusal names every
1506
+ accepted value.
1507
+
1508
+ | Verb | Accepted reasons |
1509
+ | --- | --- |
1510
+ | merge | `preconditions-met`, `behind-base-refreshed`, `operator-instructed`, `release-blocking` |
1511
+ | release | `batch-complete`, `epic-closed`, `hotfix`, `operator-instructed` |
1512
+ | label change | `promoted-to-queue`, `re-briefed`, `needs-human`, `duplicate`, `superseded`, `out-of-scope` |
1513
+
1514
+ Free-form rationale still has a home: it rides alongside as a separate
1515
+ `rationale` field, is written into the audit trail verbatim, and is never
1516
+ parsed or matched by anything.
1517
+
1189
1518
  ## Orchestrator tick
1190
1519
 
1191
1520
  The escalation path above assumes an orchestrator session that is actually
@@ -1407,6 +1736,7 @@ omp-conductor stop
1407
1736
  omp-conductor restart [--port N] [--project NAME]
1408
1737
  omp-conductor upgrade [--to VERSION] [--project NAME]
1409
1738
  omp-conductor status [--project NAME]
1739
+ omp-conductor ledger [--issue N] [--limit N] [--project NAME]
1410
1740
  omp-conductor board [--project NAME]
1411
1741
  omp-conductor hold [--project NAME]
1412
1742
  omp-conductor halt [--pane] [--project NAME]
@@ -1417,6 +1747,7 @@ omp-conductor tail <issue> [--project NAME]
1417
1747
  omp-conductor extend <issue> --turns N [--project NAME]
1418
1748
  omp-conductor unblock <issue> [--force] [--project NAME]
1419
1749
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
1750
+ omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
1420
1751
  omp-conductor daemon [--once] [--port N] [--project NAME]
1421
1752
  omp-conductor pause
1422
1753
  omp-conductor resume
@@ -1431,7 +1762,8 @@ omp-conductor help
1431
1762
  | `stop` | Prefer `systemctl stop omp-conductor.service` when that unit's MainPID is the live daemon — systemd then owns the stop and will not schedule a restart for the exit it just requested. Otherwise `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop, and tags the confirmation with `(via systemctl)` when the unit path was used. |
1432
1763
  | `restart` | Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
1433
1764
  | `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
1434
- | `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / optional `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, and bounded hold groups; API failures are marked `DEGRADED` so queue starvation cannot look idle. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
1765
+ | `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / optional `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, and bounded hold groups; API failures are marked `DEGRADED` so queue starvation cannot look idle. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. A `reports` block lists everything the outbox has not delivered, with its age, and prints `pending` (nobody has it) differently from `SENDING` (outcome unknown, it may already have arrived) — see [Report delivery](#report-delivery-the-outbox). The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
1766
+ | `ledger [--issue N] [--limit N]` | Every [mediated-verb](#the-mediated-verbs-126) call and how the daemon decided it: the verb, the arguments it was given, allow or refuse, the named refusal reason, and any resulting SHA. Sessions cannot push, open a PR, merge, label or release except through those verbs, so this is the record of what they *tried* as well as what they did — the thing an escalation cites. Reads (`conductor_pr_status`) are deliberately absent: a poll every thirty seconds would bury the refusals. `--issue` narrows to one issue's run; `--limit` defaults to 50. The newest few also appear in `status`. |
1435
1767
  | `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status`, plus the tracker's current labels: Queue, Claimed, Running, Green, Blocked, Failed, Orphaned, and the last 24 hours of Merged and History. Columns are mutually exclusive and describe current state, not the newest run row, so a requeued issue is queued rather than failed and a closed issue is neither. Refreshes run/spend/turn values every second, and health plus the label read every ten seconds. `Enter` follows the selected transcript in place; `u` invokes the existing unblock workflow on a Blocked, Failed, or Orphaned card; `i` / `p` open the issue / PR; `r` refreshes health; `?` shows all keys. Requires an interactive terminal of at least 50×20. |
1436
1768
  | `hold [--project NAME]` | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this over `pause` when the intent is "stop the conductor" without killing processes. See [Stop the conductor](#stop-the-conductor-hold--halt). |
1437
1769
  | `halt [--pane] [--project NAME]` | `hold`, then stop the dispatch daemon (systemctl-aware). Pane stays up unless `--pane` is passed. `halt --pane` also pins herdr-conductor recovery off for the conductor agent only — it does **not** stop `herdr-fleet.service` or any other herdr session. Fail-closed: exits nonzero unless the agent is proven gone. |
@@ -1442,6 +1774,7 @@ omp-conductor help
1442
1774
  | `extend <issue> --turns N [--project NAME]` | Monotonically raise that live worker's effective turn ceiling through its owning daemon. The current omp session keeps running; no restart or continuation is created. The daemon persists the new ceiling for `status` and rejects missing, settled, cap-killed, equal, or lower requests instead of implying that an immutable session changed. |
1443
1775
  | `unblock <issue> [--force]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label, and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. **Refuses, clearing nothing and exiting `3`, when the newest attempt's work could not be committed and its worktree is the only copy** — re-claiming removes that tree. `--force` records the operator's acceptance on the run row and then clears; the salvage failure stays in history. Exits `2` when the issue number is missing or malformed. |
1444
1776
  | `friction <kind> --detail TEXT [--issue N]` | Record one bounded judgment the daemon cannot infer: an escalation belonged in a digest, or a tick report was noise/surprising. The detail is limited to 160 characters. One event never changes policy; three observations inside seven days make the aggregate eligible for one Learning-loop prompt, followed by a seven-day cooldown. |
1777
+ | `report --text TEXT [--kind material|digest]` | Hand a rendered report to the daemon's durable outbox. The text is persisted **before** anything is sent and the command prints a report id; the daemon then owns delivery, retries on a bounded backoff, and records the Telegram message id it actually got back. Delivery is [at-least-once](#report-delivery-the-outbox) — a crash mid-send is retried and the retry says it may be a repeat — so `delivered` is never proof of exactly one message. `--kind digest` is accepted at most once per local day, decided from the ledger rather than from what the session remembers sending; an unknown `--kind` exits `2` rather than quietly becoming a material report. Anything undelivered shows in `status` with its age. |
1445
1778
  | `daemon` | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. Admitted workers run in a tracked background pool, so settlement and capacity checks remain periodic while they work; shutdown drains the pool before closing the store. This is what `start` launches and what a systemd unit should call. |
1446
1779
  | `daemon --once` | Run a single tick, wait for workers admitted by that tick, and exit. No HTTP server or pidfile — a drill must not register itself as the daemon, or the next reader believes it and the real daemon's in-flight runs get reconciled as orphans. |
1447
1780
  | `--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. |
@@ -1534,9 +1867,9 @@ dispatcher. The brief is explicit about the boundary:
1534
1867
  | 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. |
1535
1868
  | Add or update tests for behaviour it introduced. | Suppress a warning, delete an assertion, or special-case an input to make a check pass. |
1536
1869
  | 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. |
1537
- | 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. |
1538
- | Open a PR that links the issue, and watch CI to a verdict with `gh pr checks --watch`. | Run `gh pr merge`. **A worker never merges** — that one is absolute, whoever else holds the authority — so PRs land one at a time with a freshness re-check; two workers merging concurrently is how agent PRs clobber each other. Who *may* merge is the [`authority`](#configuration) answer, and it is never the worker. |
1539
- | 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. | **Under the default `releasePolicy: "none"`:** cut a release, push a tag, publish to npm, edit a deployment pin, deploy, or touch infrastructure or secrets. The harness blocks recognised release/deploy tool calls before they run and audits the attempt. A project may deliberately set `operator-brief` only after its operator-owned brief contains the exact release procedure; that opens the tool gate but does not change `authority.release`. |
1870
+ | Review its whole diff, then commit and publish once with `conductor_push`. One corrective push if CI is red. | Force-push, `git add -f`, or add AI/co-author attribution. There is no force path to reach: `conductor_push` publishes that run's branch fast-forward only and takes no other ref. Red twice means stop and report, not push a third time. |
1871
+ | Open a PR with `conductor_pr_create`, and poll CI to a verdict with `conductor_pr_status`. | Run `gh pr merge` — or reach `conductor_pr_merge`, which refuses a worker session mechanically. **A worker never merges** — absolute, whoever else holds the authority — so PRs land one at a time with a freshness re-check; two workers merging concurrently is how agent PRs clobber each other. Who *may* merge is the [`authority`](#configuration) answer, and it is never the worker. |
1872
+ | 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. `conductor_release` and `conductor_label` refuse a worker whatever `releasePolicy` says, because the check compares the caller against the configured holder rather than ruling one value out. The in-session tripwire still blocks recognised release/deploy tool calls early and audits the attempt, but it is [defence in depth](#the-mediated-verbs-126), not the gate. |
1540
1873
 
1541
1874
  The worker ends with a seven-line evidence report (issue, PR, observed head SHA,
1542
1875
  state, gates, changed, next). A textual `pushed-green` claim is not success: the
@@ -1545,7 +1878,7 @@ daemon repeats the PR/head/check verification before it records that state.
1545
1878
  ### Worker confinement and the integrity tripwire
1546
1879
 
1547
1880
  A worker session is rooted at its worktree `cwd`. **Structured file tools are
1548
- gated mechanically:** `runWorker` asks `createSession({ confineToCwd: true })`,
1881
+ gated mechanically:** `runWorker` asks `createSession({ role: "worker" })`,
1549
1882
  which installs an inline harness extension that blocks `write` / `edit` /
1550
1883
  `read` / `grep` / `glob` when the tool's path resolves outside that worktree
1551
1884
  (symlink-aware). Target selection was already mechanical — only a repo in
@@ -1602,6 +1935,353 @@ Until that uid exists, a root-or-operator daemon still has a mechanical
1602
1935
  worktree gate on structured tools and an integrity tripwire on its own package —
1603
1936
  but `bash` plus host credentials remain a prompt-and-deploy problem.
1604
1937
 
1938
+ ### Orchestrator confinement (default refusal)
1939
+
1940
+ The orchestrator cannot be jailed to its `cwd` the way a worker is: it has to
1941
+ read the state directory, the composed `ORCHESTRATOR.md`, `POLICY.md` and the
1942
+ config. So it gets the other shape — **an allowlist, for reads as well as
1943
+ writes, where anything unmatched is refused.** A deny-list would only stop the
1944
+ paths somebody thought to name and would leave the structured tools pointed at
1945
+ the rest of the filesystem.
1946
+
1947
+ | | Paths |
1948
+ | --- | --- |
1949
+ | **Readable** | The state directory (store, logs, claim files, session transcripts), the composed brief, `POLICY.md`, the config, and the heartbeat's own `.conductor-tick*.json` files beside the briefs — plus every root in [`orchestratorReadPaths`](#configuration). |
1950
+ | **Writable** | The state directory, and `POLICY.md` (the Learning loop amends it after you say yes). The composed `ORCHESTRATOR.md` is regenerated from the package floor each tick, so it is deliberately not writable. |
1951
+ | **Denied, outranking every allow entry** | `workspaceRoot/**` — every worker checkout — the mirror cache, the installed `omp-conductor` package, and *writes* to `config.json` itself: the config declares the session's own jail, grants and authority, so it is readable but only an operator changes it. |
1952
+
1953
+ Denial is checked *after* the allowlist is consulted and *wins over it*, which
1954
+ is the point: an `orchestratorReadPaths` entry pointing at `workspaceRoot` or at
1955
+ the package buys nothing. Reading another run's tree is denied rather than
1956
+ merely un-writable on purpose — it is how the orchestrator ends up reasoning
1957
+ about uncommitted work whose provenance it cannot see, and it has the PR for
1958
+ the parts that matter.
1959
+
1960
+ Every root is computed from your config, never hardcoded: move the state
1961
+ directory with `$OMP_CONDUCTOR_HOME`, or point `workspaceRoot` somewhere else,
1962
+ and the jail moves with it. A refusal names the path and the roots that *are*
1963
+ allowed — a worktree refusal names the individual run's checkout — so a session
1964
+ can correct itself in one turn instead of probing. Refusals are appended to
1965
+ `orchestrator-confinement-refusals.jsonl` in the state directory and today's
1966
+ count appears in `omp-conductor status` as a `confine` line; an orchestrator
1967
+ being refused repeatedly is misbriefed, and that should be visible rather than
1968
+ silent.
1969
+
1970
+ **This is defence in depth, not the boundary.** `bash` is deliberately not
1971
+ parsed here — matching shell strings is false security — so this gate does not
1972
+ contain a determined session, and nothing in this package claims it does. The
1973
+ boundary is an OS principal for the orchestrator with no filesystem access to
1974
+ any run checkout; until that exists, what this buys is real but narrower:
1975
+ accidents are prevented, refusals are legible, and it holds on hosts where no
1976
+ uid split will ever be deployed. Package-source edits that get around it
1977
+ (through `bash`) remain covered by the integrity tripwire above, one tick late.
1978
+
1979
+ ## The mediated verbs (#126)
1980
+
1981
+ With the credential boundary in place a session can no longer mutate GitHub by
1982
+ accident or by malice — and can no longer do its job either. These verbs are the
1983
+ only sanctioned way back, and the point of them is *where the checks run*: in
1984
+ the daemon, across a process boundary, not in a prompt the model can rewrite.
1985
+
1986
+ ### The verbs
1987
+
1988
+ | Verb | Allowed caller | What the daemon checks before acting |
1989
+ | --- | --- | --- |
1990
+ | `conductor_push` | the worker owning the run | The ref is exactly `refs/heads/<that run's branch>`. Fast-forward only; there is no force argument to reject because none is declared. |
1991
+ | `conductor_pr_create` | the worker owning the run | The run has no open PR (the same guard admission uses); head is the run branch; base is the repo's configured `defaultBranch`. |
1992
+ | `conductor_pr_status` | worker or orchestrator | Read-only. A worker reads only its own run's PR. |
1993
+ | `conductor_pr_update_branch` | orchestrator, or the worker owning the run | The PR belongs to this run and project, and is open. |
1994
+ | `conductor_pr_merge` | **orchestrator only** | `authority.merge` equals the caller; `headSha` equals the live head *at execution time*; checks green at that same SHA; the project's single merge slot is free. |
1995
+ | `conductor_label` | **orchestrator only** | The label is in the project's own vocabulary. Lifecycle labels stay the daemon's. |
1996
+ | `conductor_release` | **orchestrator only** | `authority.release` equals the caller; the per-shape grant permits it; the artefact or environment was declared; the release preconditions hold; the `reason` is in the closed enum. |
1997
+
1998
+ Merge and release are the same rule written twice on purpose: **the caller's
1999
+ role must equal the configured holder.** `authority` has exactly two values, so
2000
+ a `!== "human"` test would have let a *worker* release. A worker is refused
2001
+ every release shape under the most permissive config there is.
2002
+
2003
+ ### The transport
2004
+
2005
+ Identity is never an argument. `project`, `run`, `issue` and the caller's role
2006
+ come from **which socket the call arrived on** plus the verified peer uid, and a
2007
+ request carrying any of those field names is refused outright, named. A worker
2008
+ on run X asking to merge run Y's PR is not merely wrong, it is unexpressible.
2009
+
2010
+ ```
2011
+ <state dir>/verbs/ daemon-owned, mode 0711
2012
+ run-7-9a783d877d422b9e.sock 0600, owned by run 7's principal
2013
+ run-9-1c40e2a5b6d3f018.sock 0600, owned by run 9's principal
2014
+ orchestrator-4b1f...c2.sock 0600, a third, distinct one
2015
+ ```
2016
+
2017
+ `0711` is load-bearing and the obvious `0700` does not work: *connecting* needs
2018
+ **search** (`+x`) on every path component, not read. Searchable by run
2019
+ principals, listable by none, and writable by none but the daemon — that last
2020
+ one is what stops a run unlinking a sibling's socket or binding an impostor in
2021
+ its place. A per-run *directory* owned by the run principal would hand exactly
2022
+ that power back, and is deliberately not used.
2023
+
2024
+ Before binding, the daemon verifies every component of the path is owned by
2025
+ itself (or root), free of symlinks, and unwritable by anyone else; a failed
2026
+ check **refuses dispatch** rather than degrading. Where the run has its own
2027
+ principal it also verifies the chain is traversable, and says which directory to
2028
+ `chmod o+x` when it is not. Paths are unguessably suffixed, and only the daemon
2029
+ ever unlinks one.
2030
+
2031
+ Peer credentials are asserted server-side — `getpeereid` on macOS, `SO_PEERCRED`
2032
+ on Linux — against the uid allocated for that run. A mismatch closes the
2033
+ connection with no reply and logs an impersonation attempt. Where the host has
2034
+ no per-run principals there is no distinct uid to compare, and the daemon says
2035
+ so at startup rather than implying a guarantee it is not making:
2036
+
2037
+ ```
2038
+ verb transport: verb sockets in ~/.omp/conductor/verbs (mode 711); each socket
2039
+ 0600 under the daemon's own uid (no per-run principals on this host); peer uid
2040
+ asserted with getpeereid
2041
+ ```
2042
+
2043
+ **No mutation route exists on the HTTP port**, and none may be added. That
2044
+ surface is unauthenticated loopback TCP reachable by any local user; a `PUT` or
2045
+ `POST` at any verb path answers 404, pinned by a test.
2046
+
2047
+ The child-side tool handler is a thin client only. It forwards arguments and
2048
+ renders the answer — no policy branch, no credential, no local fallback. With no
2049
+ socket it fails closed and says so, rather than reaching for `git push`.
2050
+
2051
+ ### The ledger
2052
+
2053
+ Every mutating call is recorded with its arguments, the decision, the named
2054
+ refusal reason and any resulting SHA. Reads are not: a status poll every thirty
2055
+ seconds would bury the refusals the record exists to surface.
2056
+
2057
+ ```console
2058
+ $ omp-conductor ledger --issue 7
2059
+ acme — 3 verb call(s), 1 refused (newest first)
2060
+ 2026-08-09 11:04:12 REFUSE conductor_pr_merge worker #7 [role-not-allowed]
2061
+ prUrl=https://github.com/acme/api/pull/7 headSha=9a783d8… reason=preconditions-met
2062
+ refused: merge authority is the orchestrator's, never a worker session's.
2063
+ 2026-08-09 10:58:03 ALLOW conductor_pr_create worker #7
2064
+ title=fix: settle the head check body=Closes acme/tracker#7
2065
+ opened https://github.com/acme/api/pull/7 (conductor/issue-7 → main).
2066
+ 2026-08-09 10:57:41 ALLOW conductor_push worker #7 9a783d877d42
2067
+ (no arguments)
2068
+ pushed refs/heads/conductor/issue-7 at 9a783d877d42….
2069
+ ```
2070
+
2071
+ The newest few also appear in `omp-conductor status`, because a refused merge is
2072
+ news: it means a session tried to do something the config does not permit.
2073
+
2074
+ `release-policy.ts` stays installed as defence in depth — it refuses early, in
2075
+ the session, with an explanation the model can act on in the same turn, and it
2076
+ leaves a durable record that something tried. It is no longer what *stops* a
2077
+ release. Treat a block there as evidence about a session's intentions; the
2078
+ daemon is what prevented it.
2079
+
2080
+ ## The credential boundary (`credentials`)
2081
+
2082
+ Every prose rule about what a worker "may not" do reduces to one fact: until
2083
+ this existed, **model-executed code held the operator's own GitHub write
2084
+ credential.** Sessions ran inside the daemon process, as the same OS user, with
2085
+ the same `$HOME` and the same filesystem view — and the daemon authenticates by
2086
+ shelling out to the logged-in `gh`, so a worker's `bash` reached the same
2087
+ credential by running the same binary.
2088
+
2089
+ Scrubbing `GH_TOKEN` does not fix that, and this package will not describe it as
2090
+ if it did. Code running as the same uid defeats an environment variable in one
2091
+ line (`GH_CONFIG_DIR=$HOME/.config/gh gh pr merge …`), and the login keychain,
2092
+ `~/.ssh` and `~/.git-credentials` need no environment at all. **The boundary is
2093
+ an OS principal.** The environment scrubbing that ships alongside it is
2094
+ accident-prevention: it makes the common mistakes fail immediately and legibly,
2095
+ and it stops nothing that is trying.
2096
+
2097
+ ### What actually changed
2098
+
2099
+ - Worker and orchestrator sessions are **child processes**, launched under a
2100
+ principal scoped to one run. They are still supervised exactly as before —
2101
+ turn caps, wall-clock caps, spend accounting, salvage, transcript paths — and
2102
+ a session that outlives its daemon is not possible: the child exits when its
2103
+ control socket closes.
2104
+ - A run's checkout is **its own git repository**, borrowing objects read-only
2105
+ from the shared mirror through git alternates. It used to be a linked worktree
2106
+ of that mirror, which needs write access to the mirror's refs and therefore
2107
+ handed every run write access to every other run's.
2108
+ - **Publishing is the daemon's.** The worker commits locally; the daemon fetches
2109
+ the run branch out of the run's repository into the mirror and pushes it to
2110
+ GitHub, fast-forward only. There is no force path anywhere in this package. A
2111
+ rejected push settles the run `failed` with git's stderr verbatim.
2112
+ - One function, `credentialedEnv()`, is the only construction site of credential
2113
+ material in the daemon. `gh`, the mirror's clone and fetch, the daemon-side
2114
+ push and the mediated verbs all go through it, so "what can reach the
2115
+ credential" is answered by its call sites rather than by an audit.
2116
+
2117
+ ### `credentials.isolation`
2118
+
2119
+ | Value | Meaning |
2120
+ |---|---|
2121
+ | `"per-run"` | Every session runs as its own OS principal. The daemon refuses to dispatch if this host cannot provide one. |
2122
+ | `"none"` | Sessions run as the daemon's user. `status` reports the fleet as **unprotected**, every time. |
2123
+
2124
+ There is deliberately no third value. The degraded `group-mode` mechanism below
2125
+ is something the *host probe* can report, never something a config can ask for,
2126
+ so an operator cannot believe they configured the full boundary and get the
2127
+ weaker one.
2128
+
2129
+ **A config written before this key existed is migrated, not rejected.** On the
2130
+ first start it resolves to `"none"`, the key is written into the file
2131
+ explicitly, and the daemon logs the migration once by project name. The answer
2132
+ then lives on disk rather than being inherited from a default nobody chose — and
2133
+ no running fleet stops dispatching on upgrade. A fleet that upgrades and does
2134
+ nothing else is unprotected and says so in `status`; turning that into a refusal
2135
+ would have been an outage caused by a security feature.
2136
+
2137
+ ### `credentials.readToken`
2138
+
2139
+ Optional, and absent by default. The boundary removes GitHub *reads* too, so a
2140
+ worker with no token works from its dispatch brief — which already carries the
2141
+ issue body and the acceptance criteria — and from the mediated verbs. Set this
2142
+ to a **read-scoped** token to give sessions `gh` reads back.
2143
+
2144
+ This package cannot verify the scope. A write-scoped token here re-opens exactly
2145
+ the hole the rest of this section closes.
2146
+
2147
+ ### Mechanisms, and what each one does not protect
2148
+
2149
+ The daemon probes the host at startup, states the mechanism, and proves it by
2150
+ running the composed launcher against a trivial child before reporting it
2151
+ available. A mechanism whose argv the host rejects resolves *down* at probe time
2152
+ rather than failing every dispatch at run time.
2153
+
2154
+ | Mechanism | Where | Contains a determined escape? |
2155
+ |---|---|---|
2156
+ | `uid-pool` | Linux, with the capabilities and accounts below | **Yes** |
2157
+ | `sandbox-exec` | macOS | **Yes**, within a deprecated Apple interface |
2158
+ | `group-mode` | Linux, groups but no capabilities | **No** — cross-run separation only |
2159
+ | `none` | anywhere else | **No** |
2160
+
2161
+ `group-mode` exists so an operator who cannot grant capabilities still gets
2162
+ cross-run checkout separation. It runs sessions as the daemon's own uid, so it
2163
+ bounds accidents and does not stop a determined same-uid escape. `status` says
2164
+ which one is live, and lists what it does not close.
2165
+
2166
+ ### Deploying `per-run` on a Linux fleet host
2167
+
2168
+ See `systemd/omp-conductor.service.example` for the copy-pasteable version. In
2169
+ summary:
2170
+
2171
+ 1. **Capabilities on the existing unprivileged account.** The unit grants
2172
+ `CAP_SETUID CAP_SETGID CAP_CHOWN CAP_SETPCAP`. They exist to be *dropped
2173
+ into* run children, never inherited by them. A capability grant on the
2174
+ `fleet` account is a narrower blast radius than running as root or shipping a
2175
+ setuid binary.
2176
+
2177
+ `CAP_SETPCAP` is there solely so the launcher can empty the child's
2178
+ capability **bounding** set, which `PR_CAPBSET_DROP` requires. A host that
2179
+ withholds it takes a documented fallback: the launcher omits
2180
+ `--bounding-set=-all`, the child still ends with every other set empty behind
2181
+ `NoNewPrivs`, the leftover `CapBnd` is inert, and `status` reports it as a
2182
+ named residual.
2183
+
2184
+ 2. **`setpriv` (util-linux) must be on `PATH`.** Every run child is launched
2185
+ through it: group list, then gid, then uid, then empty the
2186
+ permitted/effective/inheritable/ambient sets, drop the bounding set, set
2187
+ `PR_SET_NO_NEW_PRIVS`, then `exec`. Without `setpriv` the probe reports
2188
+ `none`. There is deliberately no `spawn({uid,gid})` fallback — ambient
2189
+ capabilities survive `execve` for ordinary binaries, so a child launched that
2190
+ way holds `CAP_SETUID` itself and can `setuid()` back to a sibling run or to
2191
+ the daemon, voiding the boundary while appearing to work.
2192
+
2193
+ 3. **Provision with the shipped command, not by hand.**
2194
+ `omp-conductor boundary-setup --slots <n>` prints the exact idempotent root
2195
+ commands, generated from the same constants the startup probe checks, so the
2196
+ instructions cannot drift from what the daemon then demands.
2197
+
2198
+ ```bash
2199
+ omp-conductor boundary-setup --slots 2 # read it first
2200
+ omp-conductor boundary-setup --slots 2 | sudo bash
2201
+ sudo systemctl restart omp-conductor.service
2202
+ ```
2203
+
2204
+ It needs no config and must run **before** `setup`: setup writes worktree and
2205
+ mirror paths into the shared root this creates. What it establishes:
2206
+
2207
+ 4. **One account per run slot**, `conductor-agent-<n>` for each concurrent
2208
+ worker plus `conductor-agent-orch` for the orchestrator. `maxConcurrentWorkers`
2209
+ is 2 by default, so the pool is small. The orchestrator's account is distinct
2210
+ precisely so it has no read or write access to any run checkout, and it is
2211
+ launched with no supplementary group at all.
2212
+
2213
+ 5. **Two groups**, and the split between them is the whole filesystem model:
2214
+
2215
+ | Group | Members | Purpose |
2216
+ |---|---|---|
2217
+ | `conductor-daemon` | the daemon account **only** | lets it reach every run repo — fetch the branch, salvage, reclaim the tree |
2218
+ | `conductor-runs` | every slot principal | read-only access to the shared mirror |
2219
+
2220
+ A slot principal must **never** be in `conductor-daemon`. That membership is
2221
+ the only thing keeping sibling runs out of each other's checkouts, and its
2222
+ absence is asserted from a live session by the probe suite.
2223
+
2224
+ Supplementary group membership is fixed when a process starts, so this
2225
+ **requires a service restart**. Without it the daemon's live credentials lack
2226
+ the group even though `getent` shows it, and it would chown every run repo to
2227
+ a group it cannot itself use — the probe checks the running process's own
2228
+ credentials for exactly this reason, and every run additionally write-tests
2229
+ its tree after the ownership handoff.
2230
+
2231
+ 6. **Layout.** Run repos are `conductor-agent-<slot>:conductor-daemon`,
2232
+ directories `2770` (setgid, so what the worker creates inherits the group)
2233
+ and files `0660` with execute bits preserved. The shared mirror is
2234
+ daemon-owned, group `conductor-runs`, `0750`/`0640`.
2235
+
2236
+ Everything a slot must reach lives under **`/var/lib/omp-conductor`** (mode
2237
+ `0711`): worktrees, mirrors, per-run session transcripts and per-run boundary
2238
+ homes. It is deliberately outside the state directory, which stays `0700`
2239
+ because it holds `conductor.db` and the WAL files SQLite recreates at
2240
+ runtime — making that searchable would publish fleet history to every local
2241
+ account. Override with `$OMP_CONDUCTOR_SHARED`. Uninstall is therefore two
2242
+ paths, not one.
2243
+
2244
+ 7. **The daemon's home is `0711`, with its credential leaves closed**
2245
+ (`.ssh`, `.config/gh` at `0700`; `.npmrc`, `.git-credentials` at `0600`).
2246
+ Searchable because the runtime and the installed package live in it — a
2247
+ `0700` home kills every worker before it connects, and no shell-based probe
2248
+ notices — and closed at the leaves because that is where the boundary
2249
+ actually rests. The daemon re-checks this empirically at dispatch, asking the
2250
+ slot principal itself whether it can read any credential path, and refuses
2251
+ the run if it can.
2252
+
2253
+ Verify with `omp-conductor status`: the `boundary` row names the live mechanism
2254
+ and lists its residuals.
2255
+
2256
+ ### macOS dev hosts
2257
+
2258
+ `sandbox-exec` is Apple-deprecated and is still the only thing on a stock host
2259
+ that denies a *read*, which is what this needs. The generated profile makes the
2260
+ run's checkout the only writable path and denies `~/.ssh`, `~/.config/gh`,
2261
+ `~/.git-credentials`, `~/.npmrc`, the other run checkouts, and mach lookups of
2262
+ the keychain services. Verified on darwin 25.5.0 against the full probe list.
2263
+
2264
+ A dev host may also simply run `credentials.isolation: "none"`, in which case
2265
+ `status` reports it unprotected. That is a legitimate choice for a machine that
2266
+ is not a fleet host; it is not a default.
2267
+
2268
+ ### The residual, stated plainly
2269
+
2270
+ A run can **read** another run's git objects out of the shared mirror. That is
2271
+ the price of not cloning the repository per run, and it is bounded: same source,
2272
+ no write path, no credential. It is asserted as *permitted* by the probe suite,
2273
+ so nobody can quietly "fix" it by weakening the mirror's modes.
2274
+
2275
+ ### MCP servers are inside the blast radius
2276
+
2277
+ A GitHub MCP server carrying its own PAT re-opens the hole. Sessions discover
2278
+ MCP configuration from the **agent principal's** config root — the redirected
2279
+ `$HOME` this package creates per session, not the operator's — and dispatch
2280
+ **refuses with a named error** when a credential-bearing GitHub server is found
2281
+ there or in the run's checkout. Put servers you want sessions to have in the
2282
+ agent principal's root, without credentials.
2283
+
2284
+
1605
2285
  ## Limitations
1606
2286
 
1607
2287
  Known and deliberate in this version:
@@ -1628,6 +2308,18 @@ Known and deliberate in this version:
1628
2308
  - **No cross-process lock on the mirrors.** Two dispatch loops fetching the same
1629
2309
  repo at the same instant can collide on git's ref locks; the run fails and is
1630
2310
  retried rather than corrupted.
2311
+ - **A run can read another run's git objects.** The shared mirror is one object
2312
+ store, so the isolation is on writes and credentials, not reads. See
2313
+ [The residual, stated plainly](#the-residual-stated-plainly).
2314
+ - **`group-mode` does not contain a determined escape.** It separates run
2315
+ checkouts by group and mode while sessions still run as the daemon's uid. Only
2316
+ `uid-pool` and `sandbox-exec` make the stronger claim, and `status` says which
2317
+ one is live.
2318
+ - **A fleet that upgrades and changes nothing is unprotected.** The
2319
+ `credentials` migration resolves to `"none"` and writes it down rather than
2320
+ refusing to dispatch, because an outage caused by a security feature is worse
2321
+ than the day before it shipped. `status` reports it every tick until an
2322
+ operator provisions the host and sets `"per-run"`.
1631
2323
  - **Uniquely local mirror branches are retained.** Terminal runs are reaped
1632
2324
  automatically only after every commit exists on a remote ref. A failed salvage
1633
2325
  push deliberately leaves its branch and tree for an operator rather than
@@ -1654,15 +2346,26 @@ Known and deliberate in this version:
1654
2346
  `slots = maxConcurrentWorkers - live workers`, admits at most that many issues
1655
2347
  per tick, and dispatches them together. To see them, read `omp-conductor
1656
2348
  status`, which lists every occupied issue, or follow `daemon.log`.
2349
+ - **Report delivery is at-least-once, never exactly-once.** The Telegram Bot API
2350
+ takes no client-supplied idempotency key, so the window between "Telegram
2351
+ accepted it" and "SQLite recorded that" is irreducible. The daemon resolves it
2352
+ toward a duplicate — the report is retried and the retry says it may be a
2353
+ repeat — because a duplicate you can recognise by its report id is cheaper
2354
+ than a silently dropped page. `delivered` means Telegram accepted an attempt,
2355
+ not that exactly one message exists. See
2356
+ [Report delivery](#report-delivery-the-outbox).
1657
2357
  - **Workers stop at green PRs.** They never merge, release or deploy. Those
1658
2358
  actions default to a human, but setup may grant either to the orchestrator;
1659
2359
  `authority` never grants them to a worker or the dispatch daemon.
1660
- - **Worker confinement is partial.** Structured `write` / `edit` / `read` /
1661
- `grep` / `glob` calls are blocked outside the worktree by an inline harness
1662
- extension (`confineToCwd`). `bash` is not: a shell one-liner can still leave
1663
- the tree. Prefer a [least-privilege worker uid](#least-privilege-worker-uid-deploy);
1664
- the [integrity tripwire](#integrity-tripwire-package-self-hash) still pages if
1665
- the installed package itself changes under a live daemon.
2360
+ - **Both confinement gates are partial.** Structured `write` / `edit` / `read` /
2361
+ `grep` / `glob` calls are gated by an inline harness extension — a worker to
2362
+ its worktree, [the orchestrator to an allowlist](#orchestrator-confinement-default-refusal).
2363
+ `bash` is gated in neither: a shell one-liner can still leave the tree, and no
2364
+ claim in this README says otherwise. Prefer a
2365
+ [least-privilege worker uid](#least-privilege-worker-uid-deploy) and a separate
2366
+ OS principal for the orchestrator; the
2367
+ [integrity tripwire](#integrity-tripwire-package-self-hash) still pages if the
2368
+ installed package itself changes under a live daemon.
1666
2369
 
1667
2370
 
1668
2371
  ## License