omp-conductor 0.7.1 → 0.9.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
@@ -524,6 +524,18 @@ Four control planes used to answer "stop" differently. The package verbs:
524
524
 
525
525
  `status` prints a layered header (`dispatch` / `ticks` / next tick time / `pane` / `recovery` / `herdr` / `telegram` / `daemon`) so a paused fleet cannot hide an armed orchestrator still spending turns. The Telegram line calls the official `getMe` endpoint to prove the token and API are usable without sending a message, then separately reports whether the inbound bridge is configured.
526
526
 
527
+ When the tracker is behind what the store decided, `status` adds a
528
+ `labels projection N pending (oldest …)` row: those are label
529
+ changes the dispatcher committed to and the [projector](#the-tick) has not been
530
+ able to apply yet (a 403, a rate limit). The intended label state is durable, so
531
+ nothing is lost and safety is preserved: for an issue the queue read already
532
+ returns, a pending *state-label removal* is overlaid as applied, so a stale
533
+ label cannot block redispatch. A pending *queue-label add* is different — the
534
+ queue read asks GitHub for issues carrying the queue label, so that issue stays
535
+ invisible to dispatch until the add actually lands. The row is the lag to
536
+ watch: it should drain, and while it does not, work waiting on a queue-label
537
+ add waits with it.
538
+
527
539
  `omp-conductor board [--project NAME]` opens the same facts as a live terminal
528
540
  kanban instead of a scrolling wall of status text. Its columns are Queue,
529
541
  Claimed, Running, Green, Blocked, Failed, Orphaned, Merged, and History.
@@ -649,14 +661,17 @@ Per tick, for the daemon's project:
649
661
 
650
662
  Then, per admitted issue:
651
663
 
652
- 1. **Apply the `agent:in-progress` label — before any worktree or session exists.**
653
- This ordering is the whole crash-safety story: the label, not the local
654
- database, is the guard against dispatching the same issue twice. If the process
655
- dies at any later point, the next daemon sees the label, eligibility filters the
656
- issue out, and the orchestrator's drain duty triages the orphan (see below). A
657
- store that is lost can be rebuilt from the tracker; a label that was written too
658
- late cannot undo a duplicate PR.
659
- 2. Create the run row (`claimed`).
664
+ 1. **Create the run row (`claimed`) — before any worktree or session exists.**
665
+ This ordering is the whole crash-safety story: the *row*, not a label, is the
666
+ guard against dispatching the same issue twice. It is local and written before
667
+ anything that can fail; if the process dies at any later point, the startup
668
+ orphan sweep marks the left-behind row `orphaned` and the orchestrator's drain
669
+ duty triages it (see below). The `agent:in-progress` label is a write-behind
670
+ projection of that row enqueued in the same breath and applied to the tracker
671
+ by the projector with retry — so even a tracker that refuses the write cannot
672
+ recreate a duplicate PR while the guard is unavailable.
673
+ 2. Run the tick's post-admission projection pass, which applies freshly enqueued
674
+ label ops on the healthy path.
660
675
  3. Clear any stale tree for this issue, then add a fresh worktree at
661
676
  `<workspaceRoot>/<issue>` cut from the bare mirror at `<mirrorRoot>/<repo>.git`,
662
677
  on the run's branch off the repo's default branch.
@@ -679,9 +694,17 @@ Then, per admitted issue:
679
694
  settles gives up its `agent:in-progress` label. See
680
695
  [what settles a green PR](#what-settles-a-green-pr).
681
696
 
682
- Label swaps add the new label before removing the old one: the reverse order
683
- leaves a window in which the issue carries no state label at all, which is
684
- exactly the shape eligibility reads as fresh work.
697
+ Every label write the dispatcher makes goes through the label projection
698
+ outbox and is applied to the tracker by the projector with retry, strictly in
699
+ the order it was enqueued per issue — a later op for one issue never lands
700
+ before an earlier one that is still owed. A state-label swap on a dispatch
701
+ outcome enqueues the new label ahead of the old one's removal, so the issue
702
+ is never briefly bare (the shape eligibility reads as fresh work), while a
703
+ requeue (`swapToQueue`, `unblock`) enqueues its removals ahead of the queue
704
+ add for the same reason in reverse: the issue must not look claimable before
705
+ its stale state label is gone. A refused write is deferred with backoff
706
+ instead of dropped, and `status` shows any un-applied lag on a
707
+ `labels projection` row.
685
708
 
686
709
  **Every continuable end salvages the tree first.** A turns-cap kill, a
687
710
  wall-clock kill, a crash and a graceful block all leave a tree the next
@@ -793,15 +816,16 @@ Run history is untouched. A PR closed without merging becomes a concrete failed
793
816
  attempt; a merge does not spend failure or continuation budget. A settled row also
794
817
  loses `agent:in-progress` from its issue: the row transition and the label are one
795
818
  fact, and a terminal answer about the PR proves no worker process owns the issue,
796
- so the duplicate-dispatch guard it exists for is spent. The label comes off
797
- *before* the row is written terminal, because this sweep is the only thing that
798
- revisits a `pushed-*` row: a tracker that refuses the removal therefore leaves the
799
- row pending and the next tick tries again, rather than stranding the label on an
800
- issue nothing will look at twice. The issue stays in the busy set while it waits,
801
- so nothing is dispatched onto it in between. Anything beyond that one release — a
802
- re-queue, a `blocked` marker is still the orchestrator's drain-duty judgement.
803
- One unreachable PR costs its own row and nothing else; the rest of the sweep
804
- still settles.
819
+ so the duplicate-dispatch guard it exists for is spent. The removal is *enqueued
820
+ on the label projection outbox in the same breath as the terminal write* — a
821
+ durable local write that cannot fail on the tracker so the row settles at once
822
+ and the projector applies the label with retry. A tracker that refuses the
823
+ removal can no longer strand it: the op stays owed, eligibility overlays the
824
+ pending removal so the issue is not held back by a label that is already decided
825
+ gone, and `status` shows the lag. Anything beyond that one release — a re-queue,
826
+ a `blocked` marker is still the orchestrator's drain-duty judgement. One
827
+ unreachable PR costs its own row and nothing else; the rest of the sweep still
828
+ settles.
805
829
 
806
830
  Until this existed, nothing ever revisited a `pushed-green` row: the startup
807
831
  reconciler only settles rows that held a process, and `merged` went unwritten. On
@@ -954,6 +978,7 @@ rest. `0` is a real value (a hard stop), not "unset".
954
978
  | Cap | Default | What it protects |
955
979
  | --- | --- | --- |
956
980
  | `maxConcurrentWorkers` | `2` (setup may write `1` on &lt;16 GiB hosts) | Parallel omp sessions, each a child process of the daemon and all inside its cgroup. 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. |
981
+ | `maxConcurrentWorkersPerRepo` | `1` | Max live workers in the **same repo**. The mirror, branch-protection staleness and shared CI egress are all per-repo collision domains, so extra slots should land on other repos. Raise it only when a repo genuinely needs two workers at once. |
957
982
  | `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`. |
958
983
  | `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. |
959
984
  | `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. |
@@ -1373,21 +1398,24 @@ omp-conductor decision resolve <id> --answer "yes, after #132 lands"
1373
1398
  omp-conductor decision withdraw <id> --reason "the release slipped a week"
1374
1399
  ```
1375
1400
 
1376
- **`--resolves-when` is the part that makes a parked question wake up.** Three
1401
+ **`--resolves-when` is the part that makes a parked question wake up.** Six
1377
1402
  conditions, each one something this package can check without asking you:
1378
1403
 
1379
1404
  | Condition | Met when |
1380
1405
  | --- | --- |
1381
1406
  | `pr-merged:<https url>` | `gh` reports that pull request merged. |
1407
+ | `pr-checks-green:<https url>` | Every check on that pull request has a green verdict (a non-empty list, all `success`/`neutral`); a failing or still-pending check is not met. |
1408
+ | `pr-mergeable:<https url>` | The pull request is mergeable (`clean`, not `unknown` or conflicting). |
1382
1409
  | `issue-closed:<number>` | That issue is closed on the tracker. |
1383
1410
  | `npm-version:<pkg>@<version>` | `npm view <pkg>@<version> version` succeeds — the version is published. |
1411
+ | `rate-limit-reset:github` | GraphQL quota on `github` has any remaining capacity again. |
1384
1412
 
1385
1413
  The daemon evaluates them beside each tick, fire-and-forget: a hanging registry
1386
1414
  costs one unevaluated condition, never the tick. A met row is flagged in the next
1387
1415
  digest as `[CONDITION MET — act on this now]`, so the session acts on the answer
1388
1416
  at the moment it becomes actionable rather than when it happens to remember.
1389
1417
 
1390
- Anything else exits `2` and lists the three forms. An unparseable condition on an
1418
+ Anything else exits `2` and lists the six forms. An unparseable condition on an
1391
1419
  existing row is *listed and never treated as met*: a grammar a future release
1392
1420
  adds must not make an old row unloadable, and a question must never be hidden by
1393
1421
  a condition nobody can check.
@@ -1613,6 +1641,7 @@ Field notes:
1613
1641
  | `defaults` | Every `Caps` field. Anything omitted falls back to the built-in default. |
1614
1642
  | `tracker.repo` | `owner/repo`. `tracker.kind` may be omitted; `"github"` is the only accepted value. |
1615
1643
  | `queueLabel` | The one label meaning "a human has signed this off as agent-ready". Matched exactly, case-sensitively. |
1644
+ | `groomBelow` | Optional; default `4`. Routable candidates below this count make the orchestrator's tick prompt say the queue is running low and to groom it (Duty 2). An integer ≥ 1; anything else degrades to the default. |
1616
1645
  | `stateLabels` | Optional; defaults to `agent:in-progress`, `agent:blocked`, `agent:failed`. |
1617
1646
  | `routing.labelPrefix` | Optional; defaults to `repo:`. |
1618
1647
  | `routing.repos` | At least one entry, or nothing can be routed. `name` defaults to the map key, `defaultBranch` to `main`. |
@@ -1710,6 +1739,7 @@ writes this file for external orchestration. A manual configuration has this for
1710
1739
  | Key | Required | Default | Notes |
1711
1740
  | --- | --- | --- | --- |
1712
1741
  | `intervalSeconds` | yes | — | Whole seconds between ticks, minimum `60`. A tick costs a full turn of a frontier model, so a sub-minute period is refused rather than obeyed. |
1742
+ | `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. |
1713
1743
  | `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`. |
1714
1744
  | `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. |
1715
1745
  | `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. |
@@ -1905,7 +1935,7 @@ least of all on a fleet whose session lives somewhere else.
1905
1935
  omp-conductor start [--port N] [--project NAME]
1906
1936
  omp-conductor --version
1907
1937
  omp-conductor stop
1908
- omp-conductor restart [--port N] [--project NAME]
1938
+ omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
1909
1939
  omp-conductor upgrade [--to VERSION] [--project NAME]
1910
1940
  omp-conductor status [--project NAME]
1911
1941
  omp-conductor ledger [--issue N] [--limit N] [--project NAME]
@@ -1917,7 +1947,7 @@ omp-conductor disarm [--project NAME]
1917
1947
  omp-conductor release-pane [--project NAME]
1918
1948
  omp-conductor tail <issue> [--project NAME]
1919
1949
  omp-conductor extend <issue> --turns N [--project NAME]
1920
- omp-conductor unblock <issue> [--force] [--project NAME]
1950
+ omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
1921
1951
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
1922
1952
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
1923
1953
  omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
@@ -1926,7 +1956,7 @@ omp-conductor decision resolve <id> --answer TEXT [--project NAME]
1926
1956
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
1927
1957
  omp-conductor decision list [--project NAME]
1928
1958
  omp-conductor daemon [--once] [--port N] [--project NAME]
1929
- omp-conductor pause
1959
+ omp-conductor pause [--reason TEXT]
1930
1960
  omp-conductor resume
1931
1961
  omp-conductor graph-setup [--project NAME] [--write]
1932
1962
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
@@ -1937,9 +1967,9 @@ omp-conductor help
1937
1967
  | --- | --- |
1938
1968
  | `start` | Start `herdr-fleet.service` when that optional unit is installed, clearing a previous pane-recovery pin, then spawn the dispatch loop in the background and wait until it answers `GET /healthz` on `:8787`. Without systemd or that unit it keeps the standalone daemon behaviour. It never clears pause or arms ticks. Refuses if a daemon is already live, naming its pid; if the process dies or never serves, it cleans up and quotes the tail of `daemon.log`. |
1939
1969
  | `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. |
1940
- | `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). |
1970
+ | `restart [--now] [--timeout SECONDS] [--port N] [--project NAME]` | Drains the fleet by default: pause new claims, wait until live workers reach `0 / N` (bounded by `--timeout SECONDS`, default 1800 = 30 min), restart, then restore the prior dispatch state. 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. `--now` skips the drain and restarts immediately, orphaning any live runs (old behaviour). A drain that hits `--timeout` restarts nothing and leaves dispatch paused — `omp-conductor resume` lifts it, or re-run `restart` to keep waiting. 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). |
1941
1971
  | `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. |
1942
- | `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / `brief` / `decisions` / optional `failure classes` and `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. |
1972
+ | `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / `brief` / `decisions` / optional `failure classes` and `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, bounded hold groups, and the GitHub API budget (`graphql` / `core` remaining and reset, in the caps block); 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. |
1943
1973
  | `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`. |
1944
1974
  | `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. |
1945
1975
  | `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). |
@@ -1949,11 +1979,11 @@ omp-conductor help
1949
1979
  | `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
1950
1980
  | `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
1951
1981
  | `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. |
1952
- | `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. |
1982
+ | `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. |
1953
1983
  | `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`. |
1954
1984
  | `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. |
1955
1985
  | `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. |
1956
- | `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>`, `issue-closed:<n>`, or `npm-version:<pkg>@<version>`; anything else exits `2` listing the three forms. See [The decision ledger](#the-decision-ledger-136). |
1986
+ | `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). |
1957
1987
  | `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. |
1958
1988
  | `decision withdraw <id> [--reason TEXT]` | Close a question the session stopped needing, with why. Same guard as `resolve`. |
1959
1989
  | `decision list` | Open questions, oldest first: id, age, what each blocks, whether its condition is met, and the question. Prints `no open decisions` when there are none. |
@@ -1961,7 +1991,7 @@ omp-conductor help
1961
1991
  | `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. |
1962
1992
  | `--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. |
1963
1993
  | `--project NAME` | Pick the project to service. One daemon process serves exactly one project; with several configured projects the name is required. |
1964
- | `pause` | Stop claiming new work only. The running daemon notices on its next tick; runs already in flight finish. The orchestrator heartbeat keeps ticking if armed — its gate is the arm marker, not this flag. Prefer `hold` to silence both. |
1994
+ | `pause [--reason TEXT]` | Stop claiming new work only. The running daemon notices on its next tick; runs already in flight finish. The orchestrator heartbeat keeps ticking if armed — its gate is the arm marker, not this flag. Prefer `hold` to silence both. `--reason TEXT` is recorded in the pause sentinel, which `status` shows as the pause provenance. |
1965
1995
  | `resume` | Clear pause only — does **not** re-arm. Run `arm` after an inbound Telegram proof to resume ticks. |
1966
1996
  | `--version`, `-V`, `version` | Print the installed `omp-conductor` package version and exit `0`. Works from the global binary and npm/plugin install because it reads the package metadata beside the shipped CLI. |
1967
1997
  | `graph-setup` | Print how to set up the code-graph indexes workers query instead of grepping: a `git clone` for every index-only clone that does not exist yet, the one-shot index command per repo, and a `cbm-reindex.service` + `cbm-reindex.timer` pair generated from the project's own repos and branches. Reads only, so it is safe on a host where you are not root. Exits `1` when no repo in the project has [`graphProject`](#configuration) set, because the fix is a wizard answer rather than a flag. See [Code-graph discovery](#code-graph-discovery). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.7.1",
3
+ "version": "0.9.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.",
package/src/backups.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Durable file backups under conductor state.
3
+ *
4
+ * Both the brief writer and the config writer keep a copy of every previous
5
+ * version of the file they replace, so a bad write — or a process dying
6
+ * mid-upgrade — never destroys the only copy of operator-owned bytes.
7
+ * Retention is intentionally unbounded, matching the brief backups.
8
+ */
9
+
10
+ import { randomUUID } from "node:crypto";
11
+ import {
12
+ constants,
13
+ copyFileSync,
14
+ linkSync,
15
+ mkdirSync,
16
+ unlinkSync,
17
+ } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ /** A timestamp stem safe to embed in a file name. */
21
+ export function backupTimestamp(): string {
22
+ return new Date().toISOString().replace(/[:.]/g, "-");
23
+ }
24
+
25
+ /** Publishes `source` under `backupRoot` as `stem`, suffixing on collision. */
26
+ export function copyToUniqueBackup(source: string, backupRoot: string, stem: string): string {
27
+ mkdirSync(backupRoot, { recursive: true });
28
+ const temporary = join(backupRoot, `.${stem}.${process.pid}.${randomUUID()}.tmp`);
29
+ copyFileSync(source, temporary, constants.COPYFILE_EXCL);
30
+ try {
31
+ for (let suffix = 0; ; suffix += 1) {
32
+ const destination = join(backupRoot, suffix === 0 ? stem : `${stem}-${suffix}`);
33
+ try {
34
+ // Linking a complete temp file publishes the backup atomically without
35
+ // overwriting a backup created concurrently at the same millisecond.
36
+ linkSync(temporary, destination);
37
+ return destination;
38
+ } catch (err) {
39
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") continue;
40
+ throw err;
41
+ }
42
+ }
43
+ } finally {
44
+ unlinkSync(temporary);
45
+ }
46
+ }