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 +99 -27
- package/package.json +1 -1
- package/schema/config.schema.json +39 -0
- package/src/board.ts +6 -28
- package/src/briefs/orchestrator.md +34 -10
- package/src/briefs/policy.md +8 -3
- package/src/cli.ts +61 -1
- package/src/config-schema.ts +20 -0
- package/src/config.ts +43 -0
- package/src/fleet.ts +168 -51
- package/src/lifecycle.ts +164 -21
- package/src/orchestrator-tick.ts +96 -7
- package/src/reports.ts +47 -0
- package/src/setup-discover.ts +425 -0
- package/src/setup-host.ts +21 -2
- package/src/setup-wizard.ts +106 -10
- package/src/setup.ts +25 -1
- package/src/store.ts +5 -1
- package/src/types.ts +34 -0
- package/src/verbs/actions.ts +407 -4
- package/src/verbs/protocol.ts +2 -2
- package/src/verbs/server.ts +141 -27
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
|
-
|
|
330
|
-
|
|
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,
|
|
344
|
-
tool it was not given is refused rather than allowed. It is not a sandbox —
|
|
345
|
-
as your own user and reads what you can read — which is why it is pointed
|
|
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
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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; `"
|
|
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
|
|
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;
|
|
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` |
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
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.
|
|
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
|
-
|
|
935
|
-
|
|
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
|
|
277
|
-
|
|
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
|
|
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
|
|
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,
|
|
505
|
-
|
|
506
|
-
|
|
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 `
|
|
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`:
|
package/src/briefs/policy.md
CHANGED
|
@@ -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.
|
|
114
|
-
|
|
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
|
|
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 {
|
|
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);
|
package/src/config-schema.ts
CHANGED
|
@@ -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.
|