omp-conductor 0.15.5 → 0.15.7

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
@@ -326,8 +326,16 @@ you decide. It is your fleet.
326
326
 
327
327
  ### What setup reads for you
328
328
 
329
- Rather than asking about your repos, setup reads them. Each probe is a short,
330
- confined session in a throwaway shallow clone, and **every answer is a proposal**:
329
+ Setup discovers factual defaults before it asks for them. `git remote get-url
330
+ origin` supplies the tracker repo and single-repo routing key; bounded `gh` calls
331
+ supply the default branch, existing queue/state labels, branch-protection checks,
332
+ environments, an unambiguous npm package name, and GitHub Projects/open milestones.
333
+ Each discovered value is shown with its evidence and remains editable at the same
334
+ prompt. Discovery only seeds a fresh interview: a re-run starts from the saved
335
+ project, so an operator-edited value is never guessed again.
336
+
337
+ Judgment and prose still belong to the existing confined model probes, and **every
338
+ answer is a proposal**:
331
339
 
332
340
  - **Gates.** Reads each routing repo's CI workflows, `package.json` scripts and
333
341
  `Makefile`/`justfile`, then pre-fills the [gates](#configuration) prompt with the
@@ -340,15 +348,15 @@ confined session in a throwaway shallow clone, and **every answer is a proposal*
340
348
  confirm**. `POLICY.md` is re-read on every tick, so a paragraph you never read
341
349
  would become an instruction the orchestrator follows all week.
342
350
 
343
- A probe has **no shell, no editor and no verbs**: it reads files and answers, and a
344
- tool it was not given is refused rather than allowed. It is not a sandbox — it runs
345
- as your own user and reads what you can read — which is why it is pointed only at
346
- repos you configured yourself.
351
+ A model probe has **no shell, no editor and no verbs**: it reads files and answers,
352
+ and a tool it was not given is refused rather than allowed. It is not a sandbox —
353
+ it runs as your own user and reads what you can read — which is why it is pointed
354
+ only at repos you configured yourself.
347
355
 
348
- **Setup never fails because a probe did.** No omp peer on the host, a repo it cannot
349
- clone, a cap reached, prose where JSON was asked for: each costs one warning and
350
- leaves the shipped stub, which says what belongs there and how to fill it in. Every
351
- question is still asked.
356
+ **Setup never fails because discovery or a probe did.** No `gh`, no auth or
357
+ network, a private repo, no omp peer, a clone failure, a timeout, or a malformed
358
+ reply each produces a warning and leaves the typed default in place. Every question
359
+ is still asked.
352
360
 
353
361
  Skip the reading half entirely with `--no-ai`:
354
362
 
@@ -1480,6 +1488,32 @@ anything it still owes. If availability closes after a material report was
1480
1488
  queued but before its first attempt, the outbox atomically converts that row to
1481
1489
  the same held-notice path instead of leaking the update through quiet hours.
1482
1490
 
1491
+ ### Answering a person, in the thread they wrote in
1492
+
1493
+ A report is an update; an answer is a conversation, and it goes back where the
1494
+ question came from. `telegram_send` keeps the active forum topic **only while it
1495
+ names no chat** — `thread_id` defaults to the active topic when `chat_id` is
1496
+ omitted — so an orchestrator that helpfully supplied `chat_id` (and nothing
1497
+ else) answered three topic messages in the main chat instead (#366). The
1498
+ orchestrator floor now says to name **neither** target or **both**, and the
1499
+ dispatcher refuses `chat_id` without `thread_id` for a project that configured
1500
+ `escalation.telegramTopicId`. A flat-chat project is unaffected.
1501
+
1502
+ A locally injected tick has no inbound message to inherit a topic from, so a
1503
+ bare `telegram_send` there has nothing to preserve. That turn addresses the
1504
+ operator from config instead:
1505
+
1506
+ ```bash
1507
+ omp-conductor message --text "<the message>" # this project's chat and topic
1508
+ omp-conductor message --text "QUESTION: cut 0.16.0 tonight?" # carries the decision category
1509
+ ```
1510
+
1511
+ It is not a bypass of the interrupt policy: the same availability decision an
1512
+ autonomous Telegram tool call gets is applied, so a message the policy defers is
1513
+ durably held for the digest or the working-hours catch-up and the command prints
1514
+ that held-notice id instead of claiming delivery. It is also not a report — it
1515
+ leaves no `reports` row, and nothing retries it.
1516
+
1483
1517
  ### Delivery is at-least-once, and the docs will not pretend otherwise
1484
1518
 
1485
1519
  The Telegram Bot API accepts no client-supplied idempotency key and offers the
@@ -1804,7 +1838,8 @@ A complete, valid config for one project with two target repos:
1804
1838
  { "cmd": "bun test", "cwd": "." }
1805
1839
  ],
1806
1840
  "graphProject": "~/.cache/conductor-graph/acme/api",
1807
- "migrations": { "dir": "backend/alembic/versions" }
1841
+ "migrations": { "dir": "backend/alembic/versions" },
1842
+ "release": { "versionFile": "omp/package.json" }
1808
1843
  },
1809
1844
  "worker": {
1810
1845
  "name": "worker",
@@ -1833,6 +1868,7 @@ A complete, valid config for one project with two target repos:
1833
1868
  "release": "human"
1834
1869
  },
1835
1870
  "releasePolicy": {
1871
+ "version-bump-pr": "human",
1836
1872
  "git-tag": "human",
1837
1873
  "git-push-tags": "human",
1838
1874
  "package-publish": "human",
@@ -1853,6 +1889,13 @@ A complete, valid config for one project with two target repos:
1853
1889
  "environments": ["staging"]
1854
1890
  }
1855
1891
  },
1892
+ "recoveryMerges": [
1893
+ {
1894
+ "prUrl": "https://github.com/acme/api/pull/381",
1895
+ "headSha": "9a783d8f17071d63f2d5d764d43a29837c365920",
1896
+ "reason": "operator-instructed"
1897
+ }
1898
+ ],
1856
1899
  "reporting": {
1857
1900
  "scope": "material"
1858
1901
  },
@@ -1871,6 +1914,7 @@ Field notes:
1871
1914
  | `defaults` | Every `Caps` field. Anything omitted falls back to the built-in default. |
1872
1915
  | `tracker.repo` | `owner/repo`. `tracker.kind` may be omitted; `"github"` is the only accepted value. |
1873
1916
  | `queueLabel` | The one label meaning "a human has signed this off as agent-ready". Matched exactly, case-sensitively. |
1917
+ | `release.versionFile` | Optional, per repo: a repo-relative JSON file with a top-level string `version`, such as `omp/package.json`. Declares that tags must match the version already landed on the live default branch. A delegated `git-tag` for such a repo requires delegated `version-bump-pr` too; otherwise config loading fails with the missing preparation path instead of granting an impossible release. Absolute paths and `..` are refused. |
1874
1918
  | `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. |
1875
1919
  | `stateLabels` | Optional; defaults to `agent:in-progress`, `agent:blocked`, `agent:failed`. |
1876
1920
  | `routing.labelPrefix` | Optional; defaults to `repo:`. |
@@ -1882,7 +1926,8 @@ Field notes:
1882
1926
  | `escalation.fallbackToIssueComment` | Defaults to `true`. Absent means "yes, still tell me". |
1883
1927
  | `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. |
1884
1928
  | `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. |
1885
- | `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. |
1929
+ | `releasePolicy` | Optional; a per-shape map whose values are `"human"` (default) or `"orchestrator"`. Shapes are `version-bump-pr`, `git-tag`, `git-push-tags`, `package-publish`, `github-release`, and `deploy`. The legacy `"none"` denies every shape; legacy `"operator-brief"` grants the artifact-producing shapes, including reviewed version preparation, but keeps deploy human-owned. The in-session tripwire blocks recognised raw release/deploy calls before execution. Every rejection is written to `release-policy-blocks.jsonl`; the heartbeat carries that day's count into the daily digest. This is the mechanical gate; `authority.release` still says who owns the decision. |
1930
+ | `recoveryMerges` | Optional, hand-edited recovery authority for a PR that has no conductor run record. Each entry is an exact `{ prUrl, headSha, reason: "operator-instructed" }` tuple. When all three values match, `conductor_pr_merge` may merge that one PR even while the fleet is held and even when standing merge authority is `"human"`. It still requires a routed project repo, an open PR at that exact live head, green checks, the migration-chain guard, and the single-flight lock—the same safety path as an ordinary merge. Duplicate PR URLs and malformed values make config loading fail closed. Setup preserves entries but never creates them. Remove an entry after the recovery is complete. |
1886
1931
  | `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). |
1887
1932
  | `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). |
1888
1933
  | `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). |
@@ -2326,6 +2371,7 @@ omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
2326
2371
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
2327
2372
  omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
2328
2373
  omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
2374
+ omp-conductor message --text TEXT [--project NAME]
2329
2375
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
2330
2376
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
2331
2377
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
@@ -2341,9 +2387,9 @@ omp-conductor help
2341
2387
  | `setup [area] [--no-ai] [--project NAME]` | The deterministic interview, in a plain terminal — the same prompts, the same one-writer apply sequence, and the same single consent gate as `omp-conductor setup`, which is now one dialog implementation of the shared surface rather than the only way in. Bare is a full first run, or — when the project already exists — a chooser of which area to amend. Naming an area positionally skips that chooser and amends only that area: `tracker`, `gates`, `caps`, `code-graph`, `authority`, `policy`, `escalation`, `reporting`, `brief`. `host` and `graph` are install subcommands rather than areas and are matched first; anything else exits `2` listing both vocabularies. Every prompt shows its current value as the default, and Enter accepts what you see; `Ctrl-C` at any prompt abandons the run and writes nothing. Setup also **reads your repos to propose answers**: the gates prompt is pre-filled from what CI actually runs, and the brief's `## Project context` and release procedure are drafted from every routing repo and shown for confirmation before anything is written. Each probe is a short session with **no shell, no editor and no verbs** in a throwaway shallow clone, and every answer is a proposal you edit or decline — a probe that cannot clone, cannot reach a model, or answers unusably costs you one warning and the shipped stub. `--no-ai` asks every question with the reading half removed. |
2342
2388
  | `setup host [--project NAME]` | Re-render and stage the systemd unit, then **run** the install: `install -m 0644` into `/etc/systemd/system`, `daemon-reload`, `enable`, `restart`. Every command is shown with its exact argv, one confirm covers the batch, and `sudo` asks for your password once before the first step — or is skipped entirely on a fleet that genuinely runs as root. The first failure stops the rest and prints the un-run remainder verbatim so you can finish by hand. Refuses an *escalated* invocation (`sudo`, or `sudo -i`/`su -` detected by the invoking account disagreeing with the fleet's) before writing anything, naming both accounts, because staging derives the unit's `User=`/`HOME=` from whoever ran it. On a non-Linux host the files are still staged and only the `systemctl` steps are refused. |
2343
2389
  | `setup graph [--no-seed] [--print] [--project NAME]` | The code-graph install end to end, in one preview and one confirm: check the prerequisites read-only and stop before installing anything when `codebase-memory-mcp` is absent or no MCP entry mounts it (printing the entry to add); `git clone` each missing index-only checkout **as you, never through sudo**; install and enable `cbm-reindex.timer` as root; then seed one indexing run so the first fetch happens while you watch, and verify with the same probe `status` uses. A repo that does not verify is a failure with the remediation, not a success — staged-but-not-trusted is how you discover months later that no worker read an index. `--no-seed` enables the timer without the seeding run and says plainly the graph is unusable until it first fires; it never skips the prerequisite or clone steps. `--print` changes nothing. Exits `1` when no repo has [`graphProject`](#configuration). |
2344
- | `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`. |
2390
+ | `start` | Start `herdr-fleet.service` when that optional unit is installed, clearing a previous pane-recovery pin, then start the dispatch daemon and wait until it answers `GET /healthz`. When `omp-conductor.service` is installed, systemd is the only start path: even `start --project NAME` restores the shared unit and uses the name only to verify that `/healthz` serves the requested project. A detached daemon is allowed only when the unit is proven absent. It never clears pause or arms ticks. Refuses if a daemon is already live, naming its pid; manager refusal or unprovable ownership is an error rather than a detached fallback. |
2345
2391
  | `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. |
2346
- | `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). |
2392
+ | `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. A daemon serving multiple configured projects makes restart host-wide: `--project` is rejected because draining one queue and restarting the shared process would kill another project's workers. Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; only a host proven not to have the installed unit may fall back to the standalone stop/start path. `--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). |
2347
2393
  | `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. Host-wide by default: one daemon serves every configured project, so a bare run drains all of them and refreshes every brief. `--project` is rejected when the live daemon serves several projects — draining one queue and restarting the shared daemon would kill another's workers. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
2348
2394
  | `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. Active-run lines overlay cooperative worker `paused`/`pausing` from `/healthz` without changing SQLite `running` state or the live worker count. 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. |
2349
2395
  | `ledger [--issue N] [--limit N]` | The action audit: every [mediated-verb](#the-mediated-verbs-126) mutation and every next-attempt turn budget. Verb entries include the arguments, decision, named refusal, and resulting SHA. Turn-budget entries remain after an override is replaced or consumed. Reads (`conductor_pr_status`) are absent so polling cannot bury the signal. `--issue` narrows both histories; `--limit` defaults to 50. Recent verb refusals and pending turn overrides also appear in `status`. |
@@ -2367,7 +2413,7 @@ omp-conductor help
2367
2413
  | `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. |
2368
2414
  | `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. |
2369
2415
  | `--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. |
2370
- | `--project NAME` | Optional filter for `start`, `restart`, and `daemon`. Omit it to serve every configured project from one process (the unit path). Pass it only when you deliberately want a single-project daemon. |
2416
+ | `--project NAME` | Selects a project for project-scoped commands and foreground `daemon`. On an installed shared service, `start --project NAME` still starts the host-wide unit and uses the name only to verify `/healthz`; a draining `restart --project NAME` is rejected when that daemon serves multiple projects. A project-only daemon is available only through an explicit foreground `daemon --project NAME` or standalone start on a host proven not to have the unit. |
2371
2417
  | `pause [--reason TEXT]` | Stop new claims and work-starting mutations only. The running daemon notices on its next tick; runs already in flight finish. The orchestrator may still merge, update, or label runs admitted before the pause, and may release when the release policy's own preconditions hold. The orchestrator heartbeat keeps ticking if armed — its gate is the arm marker, not this flag. Per-worker pause is separate. Prefer `hold` to silence both. `--reason TEXT` is recorded in the pause sentinel, which `status` shows as the pause provenance. |
2372
2418
  | `resume [--project NAME]` | Clear pause and any `stop --pane` recovery pin — does **not** re-arm. Run `arm` after an inbound Telegram proof to resume ticks. |
2373
2419
  | `--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. |
@@ -2575,22 +2621,48 @@ daemon, across a process boundary, not in a prompt the model can rewrite.
2575
2621
  | `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`. |
2576
2622
  | `conductor_pr_status` | worker or orchestrator | Read-only. A worker reads only its own run's PR; an orchestrator may name any open PR in a routed project repo. |
2577
2623
  | `conductor_pr_update_branch` | orchestrator, or the worker owning the run | The PR belongs to this project and is open. A worker may only name its own run's PR. |
2578
- | `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. |
2624
+ | `conductor_pr_merge` | **orchestrator only** | Ordinarily, `authority.merge` equals the caller. A hand-edited `recoveryMerges` entry may instead authorize one exact unrecorded PR/head/reason while held. In both paths, `headSha` equals the live head *at execution time*; checks are green at that same SHA; the project route and migration chain are valid; the project's single merge slot is free. |
2579
2625
  | `conductor_label` | **orchestrator only** | The label is in the project's own vocabulary. Lifecycle labels stay the daemon's. |
2580
- | `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. |
2626
+ | `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. `version-bump-pr` creates or re-validates one deterministic version-only PR and, on a later call, merges only its exact green head through the project's single merge slot. |
2581
2627
 
2582
- Merge and release are the same rule written twice on purpose: **the caller's
2628
+ Standing merge and release authority use the same exact rule: **the caller's
2583
2629
  role must equal the configured holder.** `authority` has exactly two values, so
2584
2630
  a `!== "human"` test would have let a *worker* release. A worker is refused
2585
- every release shape under the most permissive config there is.
2586
-
2587
- For Git-backed releases, `git-tag` is idempotent when the named tag exists
2588
- locally but has not been pushed: it re-points the tag to the verified live
2589
- default-branch head. `git-push-tags` performs the same re-point immediately
2590
- before pushing if the default branch moved between the two calls. A tag already
2591
- published on origin is immutable: an identical tag is accepted as already
2592
- complete, while a different published target is refused and must use a new tag
2593
- name.
2631
+ every release shape under the most permissive config there is. The exact
2632
+ operator-authored recovery tuple below is the sole authority exception inside
2633
+ `conductor_pr_merge`; a reviewed version bump is instead a `conductor_release`
2634
+ operation governed throughout by release authority.
2635
+
2636
+ `recoveryMerges` is deliberately narrower than standing merge authority. It is
2637
+ an operator-authored, one-PR escape hatch for a recovery branch that cannot have
2638
+ a run row—for example, a conflict repair created after the fleet was held. It
2639
+ does not admit new work, unpause the fleet, widen repository routing, bypass
2640
+ live-head or check validation, or make a general class of PRs mergeable.
2641
+ Authorizations are re-read from config on every call and every attempted merge
2642
+ is written to the ordinary verb ledger, including refusals.
2643
+
2644
+ For a repo with `release.versionFile`, call `conductor_release` with
2645
+ `shape=version-bump-pr` and the intended `v<semver>` tag. The requested version
2646
+ must be newer than the live semantic version. The first call creates
2647
+ `conductor/release-<version>` from the live default branch, changes only the
2648
+ declared JSON `version`, and opens a normal PR. Call it again after CI: the daemon
2649
+ re-reads that exact PR head, verifies the PR contains only the semantic version
2650
+ change, requires green checks, and merges with GitHub's exact-head guard. The
2651
+ ordinary action ledger records both calls. A raw source push is never delegated,
2652
+ and a worker makes no release decision.
2653
+
2654
+ For Git-backed releases, a repo that declares `release.versionFile` refuses both
2655
+ tag creation and a new tag push until the live default branch's version matches
2656
+ the requested tag. `git-tag` is idempotent when the named tag exists locally but
2657
+ has not been pushed: it re-points the tag to the verified live default-branch
2658
+ head. `git-push-tags` performs the same re-point immediately before pushing if
2659
+ the default branch moved between the two calls. A tag already published on
2660
+ origin is immutable: an identical tag is accepted as already complete, while a
2661
+ different published target is refused and must use a new tag name.
2662
+
2663
+ A `github-release` for the same repo likewise requires that reviewed tag to be
2664
+ present on origin and verifies the tag's version file before creating the
2665
+ release; it never lets GitHub synthesize the missing tag.
2594
2666
 
2595
2667
  ### The transport
2596
2668
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
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.",
@@ -188,6 +188,19 @@
188
188
  "dir"
189
189
  ],
190
190
  "additionalProperties": false
191
+ },
192
+ "release": {
193
+ "type": "object",
194
+ "properties": {
195
+ "versionFile": {
196
+ "type": "string",
197
+ "minLength": 1
198
+ }
199
+ },
200
+ "required": [
201
+ "versionFile"
202
+ ],
203
+ "additionalProperties": false
191
204
  }
192
205
  },
193
206
  "required": [
@@ -463,6 +476,32 @@
463
476
  },
464
477
  "additionalProperties": false
465
478
  },
479
+ "recoveryMerges": {
480
+ "type": "array",
481
+ "items": {
482
+ "type": "object",
483
+ "properties": {
484
+ "prUrl": {
485
+ "type": "string",
486
+ "pattern": "^https:\\/\\/github\\.com\\/[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+\\/pull\\/\\d+$"
487
+ },
488
+ "headSha": {
489
+ "type": "string",
490
+ "pattern": "^[0-9a-fA-F]{40}$"
491
+ },
492
+ "reason": {
493
+ "type": "string",
494
+ "const": "operator-instructed"
495
+ }
496
+ },
497
+ "required": [
498
+ "prUrl",
499
+ "headSha",
500
+ "reason"
501
+ ],
502
+ "additionalProperties": false
503
+ }
504
+ },
466
505
  "reporting": {
467
506
  "type": "object",
468
507
  "properties": {
package/src/board.ts CHANGED
@@ -4,6 +4,7 @@ import { emitKeypressEvents } from "node:readline";
4
4
  import { findProject, loadConfig, resolveCaps } from "./config.ts";
5
5
  import { statusSnapshotFromStore, type StatusSnapshot } from "./daemon.ts";
6
6
  import {
7
+ classifyDaemonProjectHealth,
7
8
  codeGraphFromHealthz,
8
9
  fleetLayers,
9
10
  probeTelegramHealth,
@@ -926,39 +927,16 @@ export function renderBoard(
926
927
  async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProbe> {
927
928
  const layers = fleetLayers(project.name);
928
929
  const record = livingDaemon();
930
+ // Same gate as status (#379): never health-check a record pinned to another
931
+ // project, and never invent a second membership convention beside
932
+ // classifyDaemonProjectHealth.
929
933
  const wrongRecord = record?.project !== undefined && record.project !== project.name;
930
934
  const [telegram, health] = await Promise.all([
931
935
  probeTelegramHealth(project.name),
932
936
  record === undefined || wrongRecord ? undefined : healthCheck(record.port),
933
937
  ]);
934
- let daemon: DaemonBoardState;
935
- if (record === undefined) daemon = "stopped";
936
- else if (wrongRecord) daemon = "other-project";
937
- else if (health?.ok !== true) daemon = "unreachable";
938
- else {
939
- try {
940
- const payload = JSON.parse(health.body ?? "null") as unknown;
941
- if (payload === null || typeof payload !== "object") {
942
- daemon = "unreachable";
943
- } else if (Reflect.get(payload, "project") === project.name) {
944
- daemon = "ok";
945
- } else {
946
- const projects = Reflect.get(payload, "projects");
947
- daemon =
948
- Array.isArray(projects) &&
949
- projects.some(
950
- (entry) =>
951
- entry !== null &&
952
- typeof entry === "object" &&
953
- Reflect.get(entry, "project") === project.name,
954
- )
955
- ? "ok"
956
- : "other-project";
957
- }
958
- } catch {
959
- daemon = "unreachable";
960
- }
961
- }
938
+ const classified = classifyDaemonProjectHealth(record, health, project.name);
939
+ const daemon: DaemonBoardState = classified.kind;
962
940
  const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined;
963
941
  return {
964
942
  health: {
@@ -273,8 +273,10 @@ never make one call per question, and never type a numbered menu into a plain
273
273
  message. The tool shows each question on the terminal and Telegram, then accepts
274
274
  the first answer from either surface. A returned answer proves an answer, not
275
275
  Telegram delivery. If the question must demonstrably reach the operator through
276
- Telegram, send it separately with `telegram_send` and prefix its text
277
- `QUESTION:` so the autonomous-tick gate applies the decision category. Still
276
+ Telegram, send it separately: `omp-conductor message --text "QUESTION: …"` on a
277
+ locally injected tick, or a `telegram_send` whose text begins `QUESTION:` while
278
+ you are answering a live message in its own topic. Either way the marker is what
279
+ makes the autonomous-tick gate apply the decision category. Still
278
280
  open a `decision` row for anything you ask: the ask collects the answer, and the
279
281
  row stops it from being forgotten. In both directions, the delivery
280
282
  contract is explicit: a message you did not explicitly send is a message that
@@ -289,6 +291,23 @@ text you merely write reaches nobody — if you do not call `telegram_send`, the
289
291
  person gets silence. While handling any turn, produce no visible commentary
290
292
  between tool calls — reasoning stays in thinking, actions stay in tools.
291
293
 
294
+ **Reply where the message arrived.** `thread_id` defaults to the active topic
295
+ *only while `chat_id` is omitted*, so a fleet whose Telegram is a forum topic
296
+ loses the thread the moment you name a chat: omit **both** `chat_id` and
297
+ `thread_id` and the reply lands in the topic the message came from, or pass
298
+ **both** together. Passing `chat_id` alone is refused by the dispatcher — it
299
+ would answer in the main chat, in front of everyone, while the person waits in
300
+ the thread they wrote in.
301
+
302
+ On a **locally injected tick** there is no inbound message and therefore no
303
+ active topic to keep. When such a turn must reach your operator directly rather
304
+ than through a report, run
305
+ `omp-conductor message --text "<the message>"`: it resolves this project's own
306
+ chat and topic from config, applies the same availability policy an autonomous
307
+ Telegram call gets, and prints either the delivery or the held-notice id. Begin
308
+ the text `QUESTION:` when you are asking for something, so it carries the
309
+ decision category. Reports still go through `omp-conductor report`.
310
+
292
311
  If the answer needs a decision from the operator (a choice, a yes/no, or an
293
312
  approval), ask it with `telegram_ask`. Never send numbered options through
294
313
  `telegram_send`, and never use the generic `ask` UI. The tool returns the first
@@ -297,7 +316,8 @@ Telegram delivery. A cancelled or errored `telegram_ask` is not an answer.
297
316
 
298
317
  A message may also reach you **mid-tick** (delivery is steering: it arrives
299
318
  between two of your tool calls). Treat it as an interrupt, not a new tick:
300
- `telegram_send` the answer immediately, then return to the duty you were in the
319
+ `telegram_send` the answer immediately in that message's own topic, by naming
320
+ neither target — then return to the duty you were in the
301
321
  middle of and finish it. Never abandon or restart the tick because a message
302
322
  arrived, and never batch the answer "for the report" — the person is waiting now.
303
323
 
@@ -389,6 +409,7 @@ the same checks and the same ledger rows, through the CLI:
389
409
  omp-conductor verb conductor_pr_merge --arg prUrl=<url> --arg headSha=<sha> --arg reason=<reason>
390
410
  omp-conductor verb conductor_label --arg issueUrl=<url> --arg label=<name> --arg action=add --arg reason=<reason>
391
411
  omp-conductor verb conductor_release --arg shape=git-tag --arg repo=<name> --arg reason=<reason> --arg tag=<tag>
412
+ omp-conductor verb conductor_release --arg shape=version-bump-pr --arg repo=<name> --arg reason=<reason> --arg tag=<tag>
392
413
  omp-conductor verb conductor_pr_status --arg prUrl=<url>
393
414
  omp-conductor verb conductor_pr_update_branch --arg prUrl=<url>
394
415
  omp-conductor verb conductor_pr_update --arg prUrl=<url> --arg title=<title>
@@ -396,8 +417,10 @@ omp-conductor verb conductor_pr_update --arg prUrl=<url> --arg title=<title>
396
417
 
397
418
  Each `--arg k=v` is one argument, read as a string. Worker-only verbs
398
419
  (`conductor_push`, `conductor_pr_create`) are refused with `role-not-allowed` —
399
- an external orchestrator never pushes or opens a PR, exactly as this floor's
400
- sessions do not.
420
+ an external orchestrator never pushes or opens a PR directly, exactly as this
421
+ floor's sessions do not. A granted `version-bump-pr` asks the daemon to open the
422
+ declared version-only PR; call the same shape again after its checks are green
423
+ to land that exact head, then cut the tag.
401
424
 
402
425
  | Tool | Yours when | What the dispatcher checks before acting |
403
426
  | --- | --- | --- |
@@ -405,7 +428,7 @@ sessions do not.
405
428
  | `conductor_pr_update_branch` | always | The PR belongs to this project and is open. |
406
429
  | `conductor_pr_merge` | `authority.merge` is yours | You are the configured holder; `headSha` still equals the live head *at execution time*; checks green at that same SHA; the project's single merge slot is free. |
407
430
  | `conductor_label` | always | The label is one this project declared. Lifecycle labels (`agent:in-progress`, `agent:blocked`, `agent:failed`) are refused — those stay the dispatcher's, and `omp-conductor unblock` is how you clear them. |
408
- | `conductor_release` | `authority.release` is yours | You are the configured holder, the shape is granted, the artefact or environment was declared, and the release preconditions hold. |
431
+ | `conductor_release` | `authority.release` is yours | You are the configured holder, the shape is granted, the artefact or environment was declared, and the release preconditions hold. `version-bump-pr` is the source-change exception: first call opens only the declared version-file change; a later call re-validates and merges its exact green head through the project's single merge slot. |
409
432
 
410
433
  ### Stopping and pausing
411
434
 
@@ -501,9 +524,10 @@ The protocol, in order:
501
524
  call `telegram_ask`; never use the generic `ask` UI. The tool shows the
502
525
  question on the terminal and Telegram, then returns the first answer from
503
526
  either surface. A returned answer proves an answer, not Telegram delivery.
504
- If the proposal must demonstrably reach the operator through Telegram, send
505
- the compact question separately with `telegram_send`. If `telegram_ask` is
506
- unavailable, send the same single yes/no question with `telegram_send`.
527
+ If the proposal must demonstrably reach the operator through Telegram, or
528
+ `telegram_ask` is unavailable, send the compact yes/no question separately
529
+ with `omp-conductor message --text "QUESTION: …"` on a tick that is the
530
+ only path that reaches this project's own topic.
507
531
  Wait for the operator's later reply, and never assume one. Telegram renders
508
532
  none of your markdown, so asterisks and backticks arrive as literal characters:
509
533
  - Lead with one plain sentence: what changes, and why, in your own words.
@@ -519,7 +543,7 @@ The protocol, in order:
519
543
  file is regenerated from the floor + `POLICY.md`). **On explicit no, drop
520
544
  it** forever and do not re-ask that amendment. A cancelled or errored
521
545
  `telegram_ask` is a delivery failure, not an operator answer. Re-deliver the
522
- question with `telegram_send`, or report the channel as broken. Never infer
546
+ question with `omp-conductor message`, or report the channel as broken. Never infer
523
547
  rejection or “not now” from failed delivery. On an explicit “not now”, park
524
548
  it in the ledger rather than in your head — see below.
525
549
  4. **Log it.** Append one line to **Amendments** at the bottom of `POLICY.md`:
@@ -110,12 +110,17 @@ deliberate, and a duplicate you can spot by its report id is the cheaper of the
110
110
  two mistakes. `omp-conductor status` lists anything still undelivered.
111
111
 
112
112
  `telegram_send` is still the right call for direct delivery to a person who is
113
- waiting — an answer to their message, or a question of your own. It is not a
114
- report: it leaves no record that anything went out. `telegram_ask` is the right
113
+ waiting — an answer to their message, or a question of your own. Answer in the
114
+ topic the message arrived in: name neither `chat_id` nor `thread_id`, or name
115
+ both; naming the chat alone drops a forum reply into the main chat and is
116
+ refused. A locally injected tick has no such message to answer, so its direct
117
+ delivery is `omp-conductor message --text "…"`, which resolves this project's
118
+ own chat and topic. Neither is a report: they leave no record that anything went
119
+ out. `telegram_ask` is the right
115
120
  call for a decision. It shows the question on the terminal and Telegram, then
116
121
  returns the first answer from either surface. A returned answer proves an
117
122
  answer, not Telegram delivery. A cancelled or errored `telegram_ask` is not an
118
- answer: re-deliver the question with `telegram_send`, prefixing its text
123
+ answer: re-deliver the question with text beginning
119
124
  `QUESTION:` so an autonomous tick applies the decision category, or report the
120
125
  channel as broken. It is never "asked once, no reply, dropped".
121
126
 
package/src/cli.ts CHANGED
@@ -50,7 +50,11 @@ import {
50
50
  type RestartResult,
51
51
  } from "./lifecycle.ts";
52
52
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
53
- import { digestDedupeKey } from "./reports.ts";
53
+ import {
54
+ deliverOperatorMessage,
55
+ digestDedupeKey,
56
+ type OperatorMessageOutcome,
57
+ } from "./reports.ts";
54
58
  import { digestDue } from "./digest-schedule.ts";
55
59
  import {
56
60
  AMEND_AREA_IDS,
@@ -121,6 +125,7 @@ usage:
121
125
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
122
126
  omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
123
127
  omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
128
+ omp-conductor message --text TEXT [--project NAME]
124
129
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
125
130
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
126
131
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
@@ -219,6 +224,14 @@ usage:
219
224
  decided from the ledger rather than from what you remember sending.
220
225
  A digest associates the comma-separated --events and --notices rows
221
226
  atomically; omitted rows stay owed.
227
+ message deliver one direct Telegram message to this project's own chat and
228
+ forum topic, resolved from config rather than from whichever chat
229
+ last wrote to the session. This is how a locally injected tick
230
+ answers or asks something directly: telegram_send keeps the active
231
+ topic only while it names no chat, and a tick has no active topic to
232
+ keep. Text beginning "QUESTION:" carries the decision category; the
233
+ operator's availability policy still decides between sending now and
234
+ holding a notice, exactly as it does for an autonomous tick.
222
235
  decision record, list and close the questions you have put to your operator.
223
236
  A question that lives only in a session's context is lost to the next
224
237
  compaction, so \`decision open\` writes it down and every tick's prompt
@@ -1316,6 +1329,53 @@ try {
1316
1329
  break;
1317
1330
  }
1318
1331
 
1332
+ /**
1333
+ * One direct message to the operator, on the project's own Telegram target.
1334
+ *
1335
+ * `telegram_send` inherits the chat of the last inbound message, and its
1336
+ * `thread_id` defaults to the active topic *only* while `chat_id` is
1337
+ * omitted — so a locally injected tick, which has no inbound message to
1338
+ * inherit from, had no way to reach a project's forum topic and its answers
1339
+ * landed in the main chat (#366). This resolves chat and topic from the
1340
+ * project config instead of from session memory, and applies the same
1341
+ * availability decision the autonomous tick gate applies, so the CLI is not
1342
+ * a way around the operator's own interrupt policy.
1343
+ */
1344
+ case "message": {
1345
+ const rawText = flag(argv, "text");
1346
+ const body = rawText?.trim();
1347
+ if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
1348
+ process.stderr.write("omp-conductor: message needs --text with the message to deliver\n");
1349
+ process.exit(2);
1350
+ }
1351
+ const project = findProject(loadConfig(), projectFlag);
1352
+ const store = openStore(dbPath());
1353
+ let outcome: OperatorMessageOutcome;
1354
+ try {
1355
+ outcome = await deliverOperatorMessage(project, body, {
1356
+ store,
1357
+ at: Date.now(),
1358
+ noticeId: randomUUID(),
1359
+ });
1360
+ } catch (err) {
1361
+ process.stderr.write(
1362
+ `omp-conductor: message: ${err instanceof Error ? err.message : String(err)}\n`,
1363
+ );
1364
+ process.exit(2);
1365
+ } finally {
1366
+ store.close();
1367
+ }
1368
+ process.stdout.write(
1369
+ outcome.kind === "sent"
1370
+ ? `message delivered to ${project.name}'s configured Telegram ` +
1371
+ `${project.escalation.telegramTopicId === undefined ? "chat" : "project topic"} (${outcome.category})\n`
1372
+ : `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
1373
+ `${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
1374
+ "nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
1375
+ );
1376
+ break;
1377
+ }
1378
+
1319
1379
  case "decision": {
1320
1380
  const sub = argv[1];
1321
1381
  const project = findProject(loadConfig(), projectFlag);
@@ -203,6 +203,21 @@ const projectPolicySchema = z
203
203
  })
204
204
  .strict();
205
205
 
206
+ const recoveryMergesSchema = z.array(
207
+ z
208
+ .object({
209
+ prUrl: z
210
+ .string()
211
+ .regex(
212
+ /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+$/,
213
+ "must be a full https://github.com/<owner>/<repo>/pull/<n> URL",
214
+ ),
215
+ headSha: z.string().regex(/^[0-9a-fA-F]{40}$/, "must be an exact 40-character commit SHA"),
216
+ reason: z.literal("operator-instructed"),
217
+ })
218
+ .strict(),
219
+ );
220
+
206
221
  // ---------------------------------------------------------------------------
207
222
  // Authority / escalation / release policy
208
223
  // ---------------------------------------------------------------------------
@@ -256,6 +271,10 @@ const repoTargetSchema = z
256
271
  .object({ dir: z.string().min(1, "must be a non-empty string") })
257
272
  .strict()
258
273
  .optional(),
274
+ release: z
275
+ .object({ versionFile: z.string().min(1, "must be a non-empty string") })
276
+ .strict()
277
+ .optional(),
259
278
  })
260
279
  .strict();
261
280
 
@@ -300,6 +319,7 @@ const projectSchema = z
300
319
  authority: authoritySchema.optional(),
301
320
  releasePolicy: releasePolicySchema.optional(),
302
321
  policy: projectPolicySchema.optional(),
322
+ recoveryMerges: recoveryMergesSchema.optional(),
303
323
  reporting: reportingSchema.optional(),
304
324
  // Roots silently fall back to the default when unusable, so the schema
305
325
  // admits any shape and the normaliser picks the usable path.