omp-conductor 0.13.0 → 0.14.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
@@ -75,34 +75,31 @@ Reporting is the one half of that the config also knows about, because the wizar
75
75
  has to ask something in order to seed the brief, and it is the one half the
76
76
  runtime acts on:
77
77
 
78
- | `reporting.scope` | What the orchestrator says unprompted | The line every tick carries |
78
+ | Setup choice | What may interrupt | What waits |
79
79
  | --- | --- | --- |
80
- | `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.` |
81
- | `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.` |
82
- | `decisions` | Tier-2 decisions and fleet-stopping conditions immediately; every other material event is held and ships with the next tick report, as one message. | `Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event accumulates and ships as ONE message with this tick's report via omp-conductor report -- a merge, a green PR, a pulled issue wait for the tick; nothing between ticks.` |
80
+ | `material` | Every configured material event, when operator availability permits. | Non-bypass events outside configured hours wait for the next configured digest or opening. |
81
+ | `escalations` | Tier-2 escalations, when operator availability permits. | Everything else waits for the configured digest. |
82
+ | `decisions` (recommended) | Tier-2 decisions and fleet-stopping conditions, when operator availability permits. | Every other material event is recorded durably and ships with the configured digest as one message. |
83
+ | `quiet` | Tier-2 escalations, fleet stops, and confirmed failures, when operator availability permits. | Everything else waits for one daily rollup. |
83
84
 
84
85
  **What the scope does:** the [orchestrator heartbeat](#orchestrator-tick) appends
85
- that line to every tick it sends, so the reporting contract arrives with the
86
- prompt instead of only in a brief the session read hours ago. It is re-read from
87
- `~/.omp/conductor/config.json` on **every** tick, so turning the volume up or
88
- down `/conductor setup` again, or an edit to the file — binds the next tick
89
- without restarting the session. Three cases fall back to `material`: no config
90
- yet, an unreadable or invalid one, and several projects with none named (the same
91
- ambiguity `status` refuses to guess through). Stopping the heartbeat over a
92
- reporting preference would be the worse trade, so it ticks on the default and
93
- logs the reason once.
94
-
95
- **What it does not do:** there is no hard outbound filter. Nothing inspects the
96
- orchestrator's messages and drops the ones your scope did not ask for, so a
97
- session that ignores its constraint line still reaches you. Scope is a
98
- constraint the model is handed each turn. It is not a gate the model is held to.
99
- The enforcement roadmap (a tool-call tripwire, and config-versus-behaviour drift
100
- in the daily digest) is
101
- [issue #11](https://github.com/TerrifiedBug/conductor/issues/11).
102
-
103
- Changing the key later does not rewrite an `ORCHESTRATOR.md` you already have:
104
- the tick line changes, the brief does not. Edit its Reporting section too, or the
105
- session is carrying two versions of your policy.
86
+ the current constraint to every tick it sends, so the reporting contract arrives
87
+ with the prompt instead of only in a brief the session read hours ago. Explicit
88
+ policies name their actual interrupt categories and digest cadence. The policy is
89
+ re-read from `~/.omp/conductor/config.json` on **every** tick, and escalation,
90
+ direct Telegram, and durable report paths apply it mechanically. Turning the
91
+ volume up or down `/conductor setup` again, or an edit to the file — therefore
92
+ binds the next tick without restarting the session.
93
+
94
+ No config, an unreadable or invalid config, or several unnamed projects fall
95
+ back to the legacy `material` scope for the heartbeat and log the reason once.
96
+ An invalid live availability policy blocks autonomous Telegram fail-closed; it
97
+ does not guess that the operator is awake.
98
+
99
+ Changing the key later does not rewrite an `ORCHESTRATOR.md` you already have.
100
+ The generated `POLICY.md` describes every scope without pinning the current
101
+ choice; the tick constraint remains derived from live config. Keep any
102
+ operator-owned reporting additions in `ORCHESTRATOR.md` consistent with it.
106
103
 
107
104
  ## Where issues come from
108
105
 
@@ -808,6 +805,10 @@ every check succeeded or was skipped. Missing or nonterminal checks become
808
805
  `pushed-pending` and are rechecked on later ticks; red or cancelled checks become
809
806
  `failed` with a bounded job/log digest. Only verified evidence becomes
810
807
  `pushed-green`.
808
+ If a failed report mentions PRs only in prose, the daemon retains the last URL
809
+ whose `owner/repo` matches the run's repository; links to other repositories are
810
+ ignored. This preserves the continuation target without trusting an unrelated
811
+ PR mentioned in the same report.
811
812
 
812
813
  What happens after verification is a human's decision, taken minutes to days
813
814
  later and never announced to the daemon — so every tick asks the tracker about
@@ -816,14 +817,15 @@ every pushed PR it is still holding:
816
817
  | PR | Row becomes | Why |
817
818
  | --- | --- | --- |
818
819
  | merged | `merged` | The work landed. This is the state `merged` was reserved for. |
819
- | closed without merging | `failed`, with the PR in `lastError` | A human read the work and said no. Leaving it `pushed-green` strands the issue forever behind a PR nobody will merge, and calling it `merged` is a lie about code that is not on the base branch. `failed` is true, and it releases the issue so a re-queue can be attempted again. |
820
+ | closed without merging | `failed`, class `returned-for-revision`, with the PR in `lastError` | A human read the work and asked for another pass. Leaving it `pushed-green` strands the issue forever behind a PR nobody will merge, and calling it `merged` is a lie about code that is not on the base branch. `failed` is the honest row state; the class preserves the review decision and releases the issue so a re-queue can be attempted again. |
820
821
  | still open | unchanged | The normal steady state. Its issue must stay occupied, or a second attempt lands on the live PR. |
821
822
  | could not be determined | unchanged | A flaky network, a revoked token, a deleted PR. An unknown answer never settles a row; the next tick asks again for free. |
822
823
 
823
- Run history is untouched. A PR closed without merging becomes a concrete failed
824
- attempt; a merge does not spend failure or continuation budget. A settled row also
825
- loses `agent:in-progress` from its issue: the row transition and the label are one
826
- fact, and a terminal answer about the PR proves no worker process owns the issue,
824
+ Run history is untouched. A PR closed without merging consumes one continuation,
825
+ not a failed implementation attempt; a merge spends neither budget. A settled
826
+ row also loses `agent:in-progress` from its issue: the row transition and label
827
+ removal are one fact, and a terminal answer about the PR proves no worker
828
+ process owns the issue,
827
829
  so the duplicate-dispatch guard it exists for is spent. The removal is *enqueued
828
830
  on the label projection outbox in the same breath as the terminal write* — a
829
831
  durable local write that cannot fail on the tracker — so the row settles at once
@@ -848,6 +850,30 @@ issues carrying `agent:in-progress`, which eligibility reads as "a worker owns
848
850
  this" — with the brief forbidding the orchestrator from editing a state label and
849
851
  `unblock` declining to clear that one, neither issue could ever be claimed again.
850
852
 
853
+ ### Base-branch health after merge
854
+
855
+ The daemon records two different facts after a merge:
856
+
857
+ - The **post-merge audit** attributes a regression to one merge. For up to 24
858
+ hours, it checks only push-triggered workflow runs for the exact merge SHA and
859
+ base branch. A new red result adds `base-branch-red` evidence and escalates.
860
+ - **Current health** drives `status` and release policy. On every sweep, the
861
+ daemon resolves the live head of each branch it merged into during the last
862
+ seven days, then reads only push-triggered runs for that head and branch. The
863
+ status row includes the head SHA and run count.
864
+
865
+ Current health is `green` only when every observed run completed successfully,
866
+ neutrally, or skipped. A failing conclusion is `red`; an in-progress or
867
+ unrecognised conclusion is `pending`; and a head with no push-triggered run is
868
+ `unknown`, never green. Pending and unknown heads are rechecked. Green and red
869
+ heads are read again when the branch moves, so an old verdict cannot describe a
870
+ new commit. If GitHub cannot return the head or its runs, the daemon keeps the
871
+ last honest row instead of replacing evidence with a network failure.
872
+
873
+ The `base-branch-green` release requirement reads this current live-head row for
874
+ the repository being released. Red, pending, unknown, and absent evidence all
875
+ refuse the release.
876
+
851
877
  ### The settlement audit
852
878
 
853
879
  Verifying `state: pushed-green` left two lines of the same report still taken on
@@ -868,6 +894,7 @@ change a run's state, hold a merge, or spend an attempt.
868
894
  | `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. |
869
895
  | `changed-line-missing` | The report had no usable `changed:` line at all. One flag, not one per file. |
870
896
  | `unmatched-claim` | `changed:` named a path the PR never touched. The weaker direction, and reported as such. |
897
+ | `report-format-unparsed` | The same file appeared as both claimed-but-untouched and touched-but-unclaimed, so the `changed:` line's format defeated the parser. Read the diff directly; this is not a trust signal against the worker. |
871
898
  | `test-file-deleted` | A test file left the tree with no rename to account for it. |
872
899
  | `test-disabled` | A `.skip` / `.only` / `xit` / `@pytest.mark.skip` / `t.Skip` marker appears on a line the PR added. |
873
900
  | `assertions-removed` | An assertion was commented out, or a test file lost more assertions than it gained. |
@@ -1298,20 +1325,72 @@ a report reached you only if the model remembered to call `telegram_send`. On
1298
1325
  none of the three arrived, and nothing anywhere recorded that fact — an undelivered
1299
1326
  report and a quiet tick look identical.
1300
1327
 
1328
+ ### Material events survive the session
1329
+
1330
+ A deferred digest does not use the session transcript as its source of truth.
1331
+ Record each ordinary outcome when it happens:
1332
+
1333
+ ```bash
1334
+ omp-conductor event record \
1335
+ --category merge \
1336
+ --summary "#42 merged" \
1337
+ --evidence "https://github.com/acme/api/pull/42"
1338
+ ```
1339
+
1340
+ `--category` is a short lowercase slug. `--summary` states the outcome, and
1341
+ `--evidence` names the issue, PR, release, run, commit, or URL that proves it.
1342
+ Use `--occurred-at <ISO timestamp>` when the event happened earlier; otherwise,
1343
+ the command uses the current time. The command writes one row to SQLite and
1344
+ sends nothing. The row survives later ticks, session compaction, session
1345
+ replacement, and daemon restarts.
1346
+
1347
+ When a digest is due, its tick prompt lists a bounded, oldest-first set of
1348
+ owed material events and deferred escalations. Each line includes its ledger id.
1349
+ The prompt gives the exact handoff shape:
1350
+
1351
+ ```bash
1352
+ omp-conductor report \
1353
+ --kind digest \
1354
+ --events EVENT_ID_1,EVENT_ID_2 \
1355
+ --notices NOTICE_ID_1,NOTICE_ID_2 \
1356
+ --text "<the whole digest>"
1357
+ ```
1358
+
1359
+ Remove the id of any row you did not use. Omitted rows stay owed. The report row
1360
+ and the named ledger rows are associated in one SQLite transaction. If the
1361
+ handoff fails, no row is consumed. If a daily report deduplicates against a
1362
+ daily report already queued that day, newly named rows also stay owed. If
1363
+ delivery exhausts its retry budget and the report becomes `failed`, its rows
1364
+ return to the owed backlog, where a replacement digest can claim them.
1365
+ `omp-conductor status` always shows the
1366
+ material-event and held-escalation backlog counts, including the age of the
1367
+ oldest row when one exists.
1368
+
1369
+ This accumulator does not poll GitHub and does not infer outcomes from tracker
1370
+ state. The orchestrator still decides what is material and records the evidence.
1371
+ The mechanism only makes that decision durable until a non-failed digest owns it.
1372
+
1301
1373
  Authorship still needs judgement the daemon does not have, so it stays with the
1302
1374
  model. Delivery does not, so it moved:
1303
1375
 
1304
1376
  ```bash
1305
- omp-conductor report --text "<the whole report>" # one material event
1306
- omp-conductor report --text "<the whole digest>" --kind digest # the daily digest
1377
+ omp-conductor report --text "<the whole report>" # immediate report, when policy permits
1378
+ omp-conductor report \
1379
+ --text "<the whole digest>" --kind digest \
1380
+ --events EVENT_IDS --notices NOTICE_IDS # use row ids from its tick
1307
1381
  ```
1308
1382
 
1309
- The command writes the text into a `reports` table in the store and prints a
1310
- report id. Nothing has been sent at that point, and that is the guarantee: the
1311
- report survives the session being compacted, interrupted or restarted, and it
1312
- survives the daemon being restarted under it. The daemon then delivers it over
1313
- the same bot token tier 2 uses, with bounded retries, and `omp-conductor status`
1314
- lists anything that has not landed.
1383
+ The command persists the text before anything is sent and prints a durable
1384
+ handoff id. An immediate report admitted during quiet hours is stored as a held
1385
+ notice and prints that id; the daemon includes it in the next digest or in a
1386
+ catch-up report when the configured window opens. Otherwise it writes a
1387
+ `reports` row and prints its report id. Both survive the session being
1388
+ compacted, interrupted or restarted,
1389
+ and the daemon being restarted under it. The daemon delivers over the same bot
1390
+ token tier 2 uses, with bounded retries, and `omp-conductor status` lists
1391
+ anything it still owes. If availability closes after a material report was
1392
+ queued but before its first attempt, the outbox atomically converts that row to
1393
+ the same held-notice path instead of leaking the update through quiet hours.
1315
1394
 
1316
1395
  ### Delivery is at-least-once, and the docs will not pretend otherwise
1317
1396
 
@@ -1374,14 +1453,16 @@ then the only surface — a report has no tracker issue, so there is no issue
1374
1453
  comment to fall back to. The page goes through the ordinary escalation ledger and
1375
1454
  carries the report id, so one undeliverable report pages exactly once.
1376
1455
 
1377
- ### The daily digest is deduplicated from the ledger
1456
+ ### Daily digests are deduplicated from the ledger
1378
1457
 
1379
- `--kind digest` is accepted at most once per **local** day, per project. The
1380
- second hand-over on the same day is refused and told which report already holds
1381
- the slot, including when that report has already been delivered. This is decided
1382
- from the `reports` table, not from the model's memory of the last tick — a
1383
- restarted or compacted session cannot send a second digest by forgetting the
1384
- first. Material reports carry no such key: two events in a day are two events.
1458
+ With `digest.cadence: "daily"`, `--kind digest` is accepted at most once per
1459
+ **local** day, per project. The second hand-over on the same day is refused and
1460
+ told which report already holds the slot, including when that report has already
1461
+ been delivered. This is decided from the `reports` table, not from the model's
1462
+ memory of the last tick — a restarted or compacted session cannot send a second
1463
+ daily digest by forgetting the first. A `per-tick` digest carries no daily key,
1464
+ so later ticks can hand off newly accumulated rows. Material reports carry no
1465
+ dedupe key either: two events in a day are two events.
1385
1466
 
1386
1467
  ### What `status` shows
1387
1468
 
@@ -1477,6 +1558,7 @@ persists the class on the row, and performs the one recovery that class names.
1477
1558
  | --- | --- | --- | --- |
1478
1559
  | `env-start-failure` | turn 0 plus an explicit harness start error (`No model selected`, a rejected key) | escalate — the session never read the issue | none |
1479
1560
  | `settlement-stuck` | a row carrying a PR that has since merged | settle: release the label, mark the row merged | none |
1561
+ | `returned-for-revision` | a `pushed-green` or `pushed-pending` PR was closed without merging | none — preserve the review decision for a human re-queue | continuation |
1480
1562
  | `merge-conflict` | `pushed-green`, PR open, GitHub reports conflicting | requeue for a rebase continuation | continuation |
1481
1563
  | `question` | the worker stopped to ask something (`blocked`) | escalate, carrying the worker's own report as evidence | none |
1482
1564
  | `orphan-dirty` | orphaned with a failed salvage and no operator ack | hold — recorded only; the tree is the only copy | none |
@@ -1486,7 +1568,9 @@ persists the class on the row, and performs the one recovery that class names.
1486
1568
  | `admin-kill` | killed *below* its own ceiling — a restart or a drain | requeue | none |
1487
1569
  | `ci-infra` | PR open, every unresolved check cancelled / timed out / stale | re-run the failed jobs | none |
1488
1570
  | `ci-deterministic` | PR open, a check genuinely reports `FAILURE` | escalate with the failing check names and links | failed attempt |
1571
+ | `dispatch-infra` | the conductor's own Git path failed before the worker's first turn | requeue, bounded by per-class strikes | none |
1489
1572
  | `provider-credit` | the provider refused the run for credit (HTTP 402, or its own out-of-credit text read off the transcript) | pause the fleet and require `omp-conductor resume` once the provider has credit | none |
1573
+ | `provider-transient` | the provider aborted a request stream before the run produced a verdict | requeue, bounded by per-class strikes | none |
1490
1574
  | `unknown` | anything unrecognised | escalate | as recorded |
1491
1575
 
1492
1576
  **Unknown escalates; it never silently retries.** A shape this table does not
@@ -1496,13 +1580,14 @@ cause nobody has named — the behaviour this exists to end.
1496
1580
  ### The budgets follow the cause
1497
1581
 
1498
1582
  `failuresFor` (implementation attempts) excludes `ci-infra`, `settlement-stuck`,
1499
- `env-start-failure` and `provider-credit`; `continuationsFor` excludes
1500
- `admin-kill`, `settlement-stuck`, `env-start-failure` and `provider-credit`. An
1501
- environment fault charges neither: the session never started, so nothing about
1502
- the issue was attempted, and nor does a provider-credit refusal, which is a
1503
- billing state rather than an implementation failure. A merge conflict *is*
1504
- charged as a continuation, because a rebase is real work just never as a
1505
- failed implementation attempt.
1583
+ `env-start-failure`, `dispatch-infra`, `provider-credit`, `provider-transient`
1584
+ and `returned-for-revision`. `continuationsFor` excludes `admin-kill`,
1585
+ `settlement-stuck`, `env-start-failure`, `dispatch-infra`, `provider-credit`
1586
+ and `provider-transient`, but explicitly counts a failed
1587
+ `returned-for-revision` row. Environment, dispatch and provider faults charge
1588
+ neither budget because the issue did not receive a valid implementation
1589
+ attempt. A merge conflict and a returned review both charge a continuation:
1590
+ each asks for more work, but neither is a failed implementation attempt.
1506
1591
 
1507
1592
  An **unclassified** row (every row written before 0.4.3) counts exactly as it
1508
1593
  did before classification existed. Upgrading therefore changes no existing
@@ -1686,7 +1771,7 @@ Field notes:
1686
1771
  | `escalation.orchestrator` | Optional; `"embedded"` (default) or `"external"`. `external` means an orchestrator session already runs elsewhere: the daemon starts none, and tier-1 escalations post as issue comments for that session to drain. Any other value is an error. |
1687
1772
  | `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. |
1688
1773
  | `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. |
1689
- | `reporting` | Optional; a **legacy scope preset** (`reporting.scope` — `"material"` default, `"decisions"`, `"escalations"`) or the **explicit form** `{ "interruptOn": [...], "digest": { ... } }`. The preset writes which categories may page the operator (`interruptOn`) and when the rollup happens (`digest.cadence`); the explicit form sets both directly and the two forms are mutually exclusive in one config. See [Reporting policy](#reporting-policy-reporting). |
1774
+ | `reporting` | Optional; a **legacy scope preset** (`reporting.scope` — `"material"` default, `"decisions"`, `"escalations"`) or the **explicit form** `{ "interruptOn": [...], "digest": { ... }, "availability": { ... } }`. The preset writes which categories may page the operator (`interruptOn`) and when the rollup happens (`digest.cadence`); the explicit form sets both directly and may add a weekly operator-availability window. The two forms are mutually exclusive in one config. See [Reporting policy](#reporting-policy-reporting). |
1690
1775
  | `orchestratorReadPaths` | **Retired in 0.4.3.** Still accepted in a config and ignored, so a fleet carrying it upgrades without an edit. It widened the orchestrator's file-tool allowlist; there is no allowlist any more — the orchestrator is [unconfined by design](#the-orchestrator-is-unconfined-deliberately). |
1691
1776
  | `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). |
1692
1777
  | `workspaceRoot` / `mirrorRoot` | Optional; default to `worktrees/` and `mirrors/` under the state directory. `~` is expanded. |
@@ -1708,13 +1793,66 @@ spellings, mutually exclusive in one config (the loader rejects a `scope` next t
1708
1793
  - **Explicit** — `reporting: { "interruptOn": ["tier2", "fleet-stopped", ...], "digest": { "cadence": "none" | "per-tick" | "daily" } }`.
1709
1794
  `interruptOn` must be a non-empty array of known categories (`tier2`, `decision-needed`, `fleet-stopped`, `confirmed-failure`, `material`), each an escalation's tier-2 category. `daily` may add `at` (`HH:MM`, 24h) and `timezone` (a known IANA zone, defaulting to the host zone) — both only valid with `daily`.
1710
1795
 
1796
+ The explicit form may add a weekly local-time window:
1797
+
1798
+ ```json
1799
+ {
1800
+ "reporting": {
1801
+ "interruptOn": ["tier2", "fleet-stopped"],
1802
+ "digest": { "cadence": "daily", "at": "17:00", "timezone": "Europe/London" },
1803
+ "availability": {
1804
+ "timezone": "Europe/London",
1805
+ "days": ["mon", "tue", "wed", "thu", "fri"],
1806
+ "start": "09:00",
1807
+ "end": "17:00",
1808
+ "bypass": ["fleet-stopped"]
1809
+ }
1810
+ }
1811
+ }
1812
+ ```
1813
+
1814
+ `timezone` must be a known IANA zone. For a daily digest, its timezone defaults
1815
+ to this value and must match it when both are set.
1816
+
1817
+ `days` is a non-empty set of `mon` through `sun`; `start` is inclusive and
1818
+ `end` is exclusive. A start later than the end defines an overnight window on
1819
+ the day it opens. `bypass` is an explicit list of known interrupt categories
1820
+ that may still page outside the window; it may be empty. For ordinary notices,
1821
+ a bypass has no effect on a category omitted from `interruptOn`. Urgent recovery
1822
+ notices may bypass category batching when the digest loop itself is unavailable,
1823
+ but they still require the configured availability bypass outside the window.
1824
+
1825
+ The setup wizard offers this as **Weekly availability window** and asks for the
1826
+ zone, days, start/end, bypass categories, and digest schedule: every tick,
1827
+ model-timed daily, disabled, or a fixed daily `HH:MM`. Re-running setup or
1828
+ amending reporting preselects and preserves the configured `none`, `per-tick`,
1829
+ or `daily` cadence. Choosing **Continuous (24-hour interrupts)** is the explicit
1830
+ opt-out and preserves the behavior of every existing config; an absent
1831
+ `availability` key also means continuous operation.
1832
+
1833
+ Outside the window, an otherwise interruptible escalation is stored durably
1834
+ instead of sent. A daily digest may consume it first. Otherwise the daemon
1835
+ atomically queues one working-hours catch-up report when the window opens,
1836
+ including after downtime; associating the held rows before delivery prevents a
1837
+ later tick from authoring a duplicate. Each heartbeat prompt names the
1838
+ mechanically computed current mode and next transition. `status` shows the same
1839
+ state plus the next digest opportunity (`due now`, every tick, disabled, or its
1840
+ next operator-local timestamp). Config, escalation routing, and report transport
1841
+ are re-read at tick or send time, so changing the window or Telegram target
1842
+ does not require a daemon restart.
1843
+
1844
+ Attachment-bearing autonomous Telegram sends cannot be replayed by the text
1845
+ digest, so they are blocked with an explicit “nothing sent or held” error rather
1846
+ than silently dropping their files.
1847
+
1711
1848
  A tier-2 escalation whose category is **not** in `interruptOn` is not dropped: it
1712
1849
  is held (`held_notices`) and the next accepted digest is its delivery authority.
1713
- The digest itself is at-most-once per local day (`digest:<YYYY-MM-DD>` in the
1714
- configured zone), which remains the delivery authority across restarts. A
1715
- scheduled `daily` digest is only sent on a day it has not already run, once the
1716
- local clock has passed `at`; a restart after `at` still sends today's (one
1717
- catch-up), and a fully missed day is skipped, never sent late.
1850
+ A `daily` digest is at-most-once per local day (`digest:<YYYY-MM-DD>` in the
1851
+ configured zone), which remains the delivery authority across restarts.
1852
+ `per-tick` digests are not daily-deduplicated, so a later tick can claim newly
1853
+ accumulated rows. A scheduled `daily` digest is only sent on a day it has not
1854
+ already run, once the local clock has passed `at`; a restart after `at` still
1855
+ sends today's (one catch-up), and a fully missed day is skipped, never sent late.
1718
1856
 
1719
1857
  ### Merge and release preconditions (`policy`)
1720
1858
 
@@ -1737,7 +1875,7 @@ policy instead of restating it — no threshold lives in two places.
1737
1875
 
1738
1876
  | Field | Values | Default | Means |
1739
1877
  | --- | --- | --- | --- |
1740
- | `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `base-branch-green` requires the newest observed post-merge workflow verdict for that routed repository to be green; pending, unknown, red, or no observation refuses release. Order and duplicates do not matter; the loader canonicalises. |
1878
+ | `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `base-branch-green` requires the current live head's push-triggered workflow verdict for that routed repository to be green; pending, unknown, red, or no observation refuses release. Order and duplicates do not matter; the loader canonicalises. |
1741
1879
  | `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
1742
1880
  | `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
1743
1881
  | `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
@@ -1797,13 +1935,14 @@ writes this file for external orchestration. A manual configuration has this for
1797
1935
  | `budgetSeconds` | no | `600` | Seconds a turn may run before the tick guard refuses its remaining tool calls (#189), and before a queued operator message preempts them. An integer ≥ 60; anything else degrades to the default. |
1798
1936
  | `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`. |
1799
1937
  | `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. |
1800
- | `message` | no | `Tick <ISO timestamp>: re-read <workspaceRoot>/ORCHESTRATOR.md from disk, then run your standing loop from it.`, then the `reporting.scope` line, then the delivery rule | Sent verbatim when set and then it owns the whole contract: neither the scope line nor the delivery rule is appended to a prompt you wrote yourself. Re-read from disk on **every** tick, so rewording it binds the next heartbeat instead of waiting for a session restart; a re-read that fails — caught mid-edit, removed, or invalid — keeps the value read at session start rather than stopping the heartbeat. `intervalSeconds` is *not* re-read: rescheduling a live timer still needs a restart. The default *orders* the session to re-read its brief, naming the path resolved from the project's `workspaceRoot`, because a standing prompt drifts out of a long-lived session's context while the file on disk does not. |
1938
+ | `message` | no | `Tick <ISO timestamp>: re-read <workspaceRoot>/ORCHESTRATOR.md from disk, then run your standing loop from it.`, then the reporting-policy line, delivery rule, and mechanical availability state | When set, this text replaces the ordinary reporting-policy line and delivery rule, but the runtime-owned availability state is still appended: a custom prompt cannot infer whether the operator may be interrupted. Re-read from disk on **every** tick, so rewording it binds the next heartbeat instead of waiting for a session restart; a re-read that fails — caught mid-edit, removed, or invalid — keeps the value read at session start rather than stopping the heartbeat. `intervalSeconds` is *not* re-read: rescheduling a live timer still needs a restart. The default *orders* the session to re-read its brief, naming the path resolved from the project's `workspaceRoot`, because a standing prompt drifts out of a long-lived session's context while the file on disk does not. |
1801
1939
  | `agentName` | no | `fleet` | The herdr agent name the orchestrator's pane is registered under. Under herdr this is the whole of the identity check below, and the default matches `AGENT_NAME=${AGENT_NAME:-fleet}` in the recovery plugin's `recover.sh`, so both halves key on one name. Rename the agent and set this to match. |
1802
1940
 
1803
- A tick sends one message (`customType` `omp-conductor.tick`, attributed to the
1804
- user): the standing-loop prompt, the one constraint line the project's
1805
- [`reporting.scope`](#your-workflow-vs-the-package) resolves to, re-read from the
1806
- conductor config on every tick, and a delivery rule. That last line is there
1941
+ A default tick sends one message (`customType` `omp-conductor.tick`, attributed
1942
+ to the user): the standing-loop prompt, the reporting-policy constraint re-read
1943
+ from conductor config on every tick, the delivery rule, and the mechanically
1944
+ computed operator-availability state. A configured `message` replaces the first
1945
+ three parts but not that clock state. The delivery rule is there
1807
1946
  because end-of-turn text reaches the operator's Telegram only on a turn that
1808
1947
  *began* as an inbound Telegram message: a tick is injected locally, so anything
1809
1948
  the session merely writes at the end of one is read by nobody, and a reportable
@@ -2008,7 +2147,8 @@ omp-conductor worker stop <issue> --reason TEXT [--project NAME]
2008
2147
  omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
2009
2148
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
2010
2149
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
2011
- omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
2150
+ omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
2151
+ omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
2012
2152
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
2013
2153
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
2014
2154
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
@@ -2042,7 +2182,7 @@ omp-conductor help
2042
2182
  | `unblock <issue> [--force] [--no-requeue]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again, and restore the project queue label by default so the dispatcher actually sees it. `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 the queue label stays off until that run settles), 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. The label changes go through the [label projection outbox](#the-tick): they are applied inline before the command returns, but **a tracker that refuses them (403, rate limit) no longer fails the verb** — it exits `0`, the intended label state is durable and the daemon retries it, and the output says `label sync queued (N pending) — the daemon retries` instead of claiming the labels were restored. Safety is preserved, but the issue is only claimable once the queue label itself lands: the queue read asks GitHub for issues carrying that label, so a refused queue-label add keeps the issue out of dispatch until projection succeeds. `--no-requeue` clears the state labels only, leaving the queue label untouched — the case where you are about to close the issue. **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. |
2043
2183
  | `verb <conductor_*> [--arg k=v ...]` | Run one [mediated verb](#the-mediated-verbs-126) as the orchestrator, from the CLI — the external-orchestrator half of the verb surface. Every argument goes in as a `--arg k=v` string; an orchestrator can merge (`conductor_pr_merge`), label (`conductor_label`), release (`conductor_release`), update a branch (`conductor_pr_update_branch`) or title/body (`conductor_pr_update`), or read PR state (`conductor_pr_status`). The daemon applies the same checks and writes the same ledger rows a session's call would; a missing `--arg` is refused exactly as a missing tool argument is, worker-only verbs (`conductor_push`, `conductor_pr_create`) are refused with `role-not-allowed`, and a refusal exits `3`. An unknown verb exits `2`. |
2044
2184
  | `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. |
2045
- | `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. |
2185
+ | `report --text TEXT [--kind material|digest]` | Hand a rendered report to the daemon's durable outbox. The command persists the text **before** anything can send and prints a durable handoff id. A material report submitted during quiet hours becomes a held notice until the window opens; otherwise it becomes a report whose delivery the daemon owns, retries with bounded backoff, and records. Delivery is [at-least-once](#report-delivery-the-outbox), so a crash mid-send is retried as a possible repeat and `delivered` never proves exactly one message. `--kind digest` is accepted at most once per local day, decided from the ledger; an unknown `--kind` exits `2`. Anything still owed appears in `status` with its age. |
2046
2186
  | `decision open --question TEXT [--blocks TEXT] [--resolves-when COND]` | Record a question the orchestrator has put to you, and print its id. A question that lives only in a session's context is lost at the next compaction — after which it is either asked twice or dropped silently. `--resolves-when` attaches a machine-checkable condition: `pr-merged:<https url>`, `pr-checks-green:<https url>`, `pr-mergeable:<https url>`, `issue-closed:<n>`, `npm-version:<pkg>@<version>`, or `rate-limit-reset:github`; anything else exits `2` listing the six forms. See [The decision ledger](#the-decision-ledger-resolves-when). |
2047
2187
  | `decision resolve <id> --answer TEXT` | Record what you decided. Exits `1` naming the id when it is unknown or no longer open, so a second answer cannot overwrite the first. |
2048
2188
  | `decision withdraw <id> [--reason TEXT]` | Close a question the session stopped needing, with why. Same guard as `resolve`. |
@@ -2261,6 +2401,14 @@ role must equal the configured holder.** `authority` has exactly two values, so
2261
2401
  a `!== "human"` test would have let a *worker* release. A worker is refused
2262
2402
  every release shape under the most permissive config there is.
2263
2403
 
2404
+ For Git-backed releases, `git-tag` is idempotent when the named tag exists
2405
+ locally but has not been pushed: it re-points the tag to the verified live
2406
+ default-branch head. `git-push-tags` performs the same re-point immediately
2407
+ before pushing if the default branch moved between the two calls. A tag already
2408
+ published on origin is immutable: an identical tag is accepted as already
2409
+ complete, while a different published target is refused and must use a new tag
2410
+ name.
2411
+
2264
2412
  ### The transport
2265
2413
 
2266
2414
  Identity is never an argument. `project`, `run`, `issue` and the caller's role
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Mechanical operator availability in an IANA timezone (#273).
3
+ *
4
+ * This module owns the clock decision. Callers supply `now`; neither a model nor
5
+ * process-local memory decides whether an interruption is allowed. UTC-minute
6
+ * scanning for the next transition is intentional: evaluating the same local
7
+ * predicate across real instants handles skipped and repeated DST wall-clock
8
+ * minutes without inventing an offset conversion of our own.
9
+ */
10
+
11
+ import { WEEKDAYS, type InterruptCategory, type ReportingPolicy, type Weekday, type WeeklyAvailability } from "./types.ts";
12
+
13
+ const MINUTE_MS = 60_000;
14
+ const TRANSITION_HORIZON_MS = 15 * 24 * 60 * MINUTE_MS;
15
+
16
+ const formatters = new Map<string, Intl.DateTimeFormat>();
17
+ interface CachedTransition {
18
+ from: number;
19
+ until: number;
20
+ open: boolean;
21
+ nextTransitionAt?: number;
22
+ }
23
+
24
+ const transitions = new WeakMap<WeeklyAvailability, CachedTransition>();
25
+
26
+
27
+ function formatter(timezone: string): Intl.DateTimeFormat {
28
+ const existing = formatters.get(timezone);
29
+ if (existing !== undefined) return existing;
30
+ const created = new Intl.DateTimeFormat("en-GB", {
31
+ timeZone: timezone,
32
+ weekday: "short",
33
+ year: "numeric",
34
+ month: "2-digit",
35
+ day: "2-digit",
36
+ hour: "2-digit",
37
+ minute: "2-digit",
38
+ hourCycle: "h23",
39
+ });
40
+ formatters.set(timezone, created);
41
+ return created;
42
+ }
43
+
44
+ interface LocalMinute {
45
+ day: Weekday;
46
+ date: string;
47
+ clock: string;
48
+ }
49
+
50
+ function localMinute(at: number, timezone: string): LocalMinute {
51
+ const parts = formatter(timezone).formatToParts(new Date(at));
52
+ const get = (type: Intl.DateTimeFormatPartTypes): string =>
53
+ parts.find((part) => part.type === type)?.value ?? "";
54
+ return {
55
+ day: get("weekday").slice(0, 3).toLowerCase() as Weekday,
56
+ date: `${get("year")}-${get("month")}-${get("day")}`,
57
+ clock: `${get("hour")}:${get("minute")}`,
58
+ };
59
+ }
60
+
61
+ function previousDay(day: Weekday): Weekday {
62
+ const index = WEEKDAYS.indexOf(day);
63
+ return WEEKDAYS[(index + WEEKDAYS.length - 1) % WEEKDAYS.length]!;
64
+ }
65
+
66
+ /** Whether `at` falls inside the configured local weekly window. */
67
+ export function availabilityOpen(window: WeeklyAvailability | undefined, at: number): boolean {
68
+ if (window === undefined) return true;
69
+ const local = localMinute(at, window.timezone);
70
+ if (window.start < window.end) {
71
+ return window.days.includes(local.day) && local.clock >= window.start && local.clock < window.end;
72
+ }
73
+ // Overnight: a selected day opens at `start` and remains open on the next
74
+ // local day until `end`.
75
+ return (
76
+ (window.days.includes(local.day) && local.clock >= window.start) ||
77
+ (window.days.includes(previousDay(local.day)) && local.clock < window.end)
78
+ );
79
+ }
80
+
81
+ export type InterruptDisposition = "interrupt" | "digest" | "availability";
82
+
83
+ export function availabilityDisposition(
84
+ window: WeeklyAvailability | undefined,
85
+ category: InterruptCategory,
86
+ at: number,
87
+ ): Exclude<InterruptDisposition, "digest"> {
88
+ if (window === undefined || availabilityOpen(window, at) || window.bypass.includes(category)) {
89
+ return "interrupt";
90
+ }
91
+ return "availability";
92
+ }
93
+
94
+ /**
95
+ * Decide one tier-2 category. `digest` means the category policy itself defers
96
+ * it; `availability` means it was otherwise interruptible and may be released
97
+ * when the configured window next opens.
98
+ */
99
+ export function interruptDisposition(
100
+ policy: ReportingPolicy | undefined,
101
+ category: InterruptCategory,
102
+ at: number,
103
+ ): InterruptDisposition {
104
+ if (policy !== undefined && !policy.interruptOn.includes(category)) return "digest";
105
+ return availabilityDisposition(policy?.availability, category, at);
106
+ }
107
+
108
+ export interface AvailabilityState {
109
+ mode: "always" | "working" | "quiet";
110
+ timezone?: string;
111
+ nextTransitionAt?: number;
112
+ bypass: InterruptCategory[];
113
+ }
114
+
115
+ /** Current mode plus the first real instant at which that mode changes. */
116
+ export function availabilityState(policy: ReportingPolicy | undefined, now: number): AvailabilityState {
117
+ const window = policy?.availability;
118
+ if (window === undefined) return { mode: "always", bypass: [] };
119
+
120
+ const from = Math.floor(now / MINUTE_MS) * MINUTE_MS;
121
+ let cached = transitions.get(window);
122
+ if (cached === undefined || from < cached.from || from >= cached.until) {
123
+ const open = availabilityOpen(window, now);
124
+ const end = now + TRANSITION_HORIZON_MS;
125
+ let cursor = from + MINUTE_MS;
126
+ while (cursor <= end && availabilityOpen(window, cursor) === open) cursor += MINUTE_MS;
127
+ cached = {
128
+ from,
129
+ until: cursor,
130
+ open,
131
+ ...(cursor > end ? {} : { nextTransitionAt: cursor }),
132
+ };
133
+ transitions.set(window, cached);
134
+ }
135
+
136
+ const { open, nextTransitionAt } = cached;
137
+ return {
138
+ mode: open ? "working" : "quiet",
139
+ timezone: window.timezone,
140
+ ...(nextTransitionAt === undefined ? {} : { nextTransitionAt }),
141
+ bypass: [...window.bypass],
142
+ };
143
+ }
144
+
145
+ /** Stable operator-facing local timestamp for status and tick prompts. */
146
+ export function formatZonedMinute(at: number, timezone: string): string {
147
+ const local = localMinute(at, timezone);
148
+ return `${local.date} ${local.clock} ${timezone} (${new Date(at).toISOString()})`;
149
+ }
150
+
151
+ /** One prompt sentence; the runtime gate, not this prose, owns the decision. */
152
+ export function availabilityPrompt(policy: ReportingPolicy | undefined, now: number): string {
153
+ const state = availabilityState(policy, now);
154
+ if (state.mode === "always") {
155
+ return "Operator availability (mechanical): 24-hour interrupts; no working-hours window is configured.";
156
+ }
157
+ const until =
158
+ state.nextTransitionAt === undefined || state.timezone === undefined
159
+ ? "the next configured transition"
160
+ : formatZonedMinute(state.nextTransitionAt, state.timezone);
161
+ const bypass = state.bypass.length === 0 ? "none" : state.bypass.join(", ");
162
+ return state.mode === "working"
163
+ ? `Operator availability (mechanical): working until ${until}; outside-hours bypass: ${bypass}.`
164
+ : `Operator availability (mechanical): quiet until ${until}; only these categories bypass quiet hours: ${bypass}.`;
165
+ }