omp-conductor 0.4.2 → 0.4.3
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 +189 -50
- package/package.json +1 -1
- package/src/board.ts +6 -1
- package/src/brief-upgrade.ts +42 -105
- package/src/briefs/orchestrator.md +51 -13
- package/src/briefs/policy.md +6 -2
- package/src/cli.ts +147 -30
- package/src/config.ts +23 -52
- package/src/confinement.ts +22 -472
- package/src/daemon.ts +344 -3
- package/src/decisions.ts +164 -0
- package/src/failure-class.ts +172 -0
- package/src/fleet.ts +192 -51
- package/src/omp.ts +8 -54
- package/src/orchestrator-tick.ts +33 -0
- package/src/plugin.ts +9 -20
- package/src/session-host.ts +10 -22
- package/src/setup.ts +22 -4
- package/src/store.ts +220 -4
- package/src/tracker/github.ts +167 -1
- package/src/types.ts +179 -13
- package/src/upgrade.ts +27 -3
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ runtime acts on:
|
|
|
79
79
|
| --- | --- | --- |
|
|
80
80
|
| `material` (default) | Escalations, plus every material event: a run reaching a green PR, a run that failed twice, an issue pulled off the queue, a cap that stopped the fleet. | `Report material events per your brief.` |
|
|
81
81
|
| `escalations` | Escalations when they happen, plus one daily digest. Silent otherwise. | `Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.` |
|
|
82
|
+
| `decisions` | Tier-2 decisions and fleet-stopping conditions immediately; every other material event is held and ships with the next tick report, as one message. | `Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event accumulates and ships as ONE message with this tick's report via omp-conductor report -- a merge, a green PR, a pulled issue wait for the tick; nothing between ticks.` |
|
|
82
83
|
|
|
83
84
|
**What the scope does:** the [orchestrator heartbeat](#orchestrator-tick) appends
|
|
84
85
|
that line to every tick it sends, so the reporting contract arrives with the
|
|
@@ -247,7 +248,7 @@ So the skill does the part a dialog cannot:
|
|
|
247
248
|
question that makes a delegated release safe: *where does the agent's leg end?*
|
|
248
249
|
The answer becomes the [`authority`](#configuration) pair `/conductor setup`
|
|
249
250
|
records, and the brief is worded from it. Plus escalation taste, and which of
|
|
250
|
-
the
|
|
251
|
+
the three [`reporting.scope`](#your-workflow-vs-the-package)
|
|
251
252
|
values your answer actually maps to.
|
|
252
253
|
- **Reads your repos instead of asking about them.** It opens each routing repo's
|
|
253
254
|
CI workflows, `package.json` scripts and `Makefile`/`justfile`, then *proposes*
|
|
@@ -357,7 +358,13 @@ omp-conductor brief-upgrade --retrofit --apply
|
|
|
357
358
|
`~/.omp/conductor/backups/briefs/`), named with their source filename and
|
|
358
359
|
timestamp.
|
|
359
360
|
- **Hand-written brief** (no banner): `--retrofit` inserts the banner before the first Releases / Project context / Reporting / Amendments heading; then `--migrate`.
|
|
360
|
-
- **
|
|
361
|
+
- **The legacy single-file merge is gone** (0.4.3). A bare `--apply` exits `2`
|
|
362
|
+
naming the two paths that remain, rather than rewriting a brief nobody asked
|
|
363
|
+
it to. `--migrate` is the cross-version ABI: an upgrade keeps calling it, and
|
|
364
|
+
an overlay fleet tolerates its absence because the floor recomposes each tick.
|
|
365
|
+
- **`status` names the layout**, so a fleet still on a legacy brief is visible
|
|
366
|
+
where an operator already looks: `brief overlay (package floor + POLICY.md)`,
|
|
367
|
+
or `brief legacy-bannered — run omp-conductor brief-upgrade`.
|
|
361
368
|
- **Existing sidecars**: a composed refresh relocates conductor-generated
|
|
362
369
|
`ORCHESTRATOR.md.bak-<timestamp>` and `POLICY.md.bak-<timestamp>` files into
|
|
363
370
|
that backup directory. Other `.bak` files stay untouched.
|
|
@@ -1302,6 +1309,129 @@ The tier-2 escalation ledger (`notifications`) is untouched by all of this. It i
|
|
|
1302
1309
|
a bare dedupe key by design — its primary key *is* the key — which is exactly why
|
|
1303
1310
|
reports needed a separate table rather than an extension of that one.
|
|
1304
1311
|
|
|
1312
|
+
## The decision ledger (#136)
|
|
1313
|
+
|
|
1314
|
+
The outbox above fixed reports the orchestrator sends. This fixes the ones it is
|
|
1315
|
+
**waiting on**. A question put to you — an amendment, a tier-2 decision, "do I
|
|
1316
|
+
ship this tonight?" — lived in exactly one place: the model's context. A
|
|
1317
|
+
compaction, a restart, or a tick that ran long lost the question *and* the fact
|
|
1318
|
+
that one was owed, after which the session either asked again (you answer twice)
|
|
1319
|
+
or dropped it silently (the decision never lands, and nothing anywhere says one
|
|
1320
|
+
is outstanding).
|
|
1321
|
+
|
|
1322
|
+
So questions are written down, and every tick's prompt carries what is still
|
|
1323
|
+
open — read from the store, never from what the session remembers asking:
|
|
1324
|
+
|
|
1325
|
+
```bash
|
|
1326
|
+
omp-conductor decision open --question "ship 0.4.3 tonight?" \
|
|
1327
|
+
--blocks "the release" --resolves-when issue-closed:132
|
|
1328
|
+
omp-conductor decision list
|
|
1329
|
+
omp-conductor decision resolve <id> --answer "yes, after #132 lands"
|
|
1330
|
+
omp-conductor decision withdraw <id> --reason "the release slipped a week"
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
**`--resolves-when` is the part that makes a parked question wake up.** Three
|
|
1334
|
+
conditions, each one something this package can check without asking you:
|
|
1335
|
+
|
|
1336
|
+
| Condition | Met when |
|
|
1337
|
+
| --- | --- |
|
|
1338
|
+
| `pr-merged:<https url>` | `gh` reports that pull request merged. |
|
|
1339
|
+
| `issue-closed:<number>` | That issue is closed on the tracker. |
|
|
1340
|
+
| `npm-version:<pkg>@<version>` | `npm view <pkg>@<version> version` succeeds — the version is published. |
|
|
1341
|
+
|
|
1342
|
+
The daemon evaluates them beside each tick, fire-and-forget: a hanging registry
|
|
1343
|
+
costs one unevaluated condition, never the tick. A met row is flagged in the next
|
|
1344
|
+
digest as `[CONDITION MET — act on this now]`, so the session acts on the answer
|
|
1345
|
+
at the moment it becomes actionable rather than when it happens to remember.
|
|
1346
|
+
|
|
1347
|
+
Anything else exits `2` and lists the three forms. An unparseable condition on an
|
|
1348
|
+
existing row is *listed and never treated as met*: a grammar a future release
|
|
1349
|
+
adds must not make an old row unloadable, and a question must never be hidden by
|
|
1350
|
+
a condition nobody can check.
|
|
1351
|
+
|
|
1352
|
+
**Expiry is enforced, not remembered.** An unanswered question closes itself
|
|
1353
|
+
after seven days — the deadline the floor's parked-amendment protocol already
|
|
1354
|
+
promised — so the digest stays a list of live questions instead of a graveyard.
|
|
1355
|
+
Answering or withdrawing is explicit, and a second resolution of the same id is
|
|
1356
|
+
refused rather than overwriting the first answer.
|
|
1357
|
+
|
|
1358
|
+
`omp-conductor status` carries one row, `decisions`, reported whether or not
|
|
1359
|
+
anything is open: `decisions 2 open (oldest 26h)`, or `decisions none open`. A
|
|
1360
|
+
row that appeared only when something was outstanding would leave "it forgot to
|
|
1361
|
+
record the question" and "there genuinely is none" looking identical, which is
|
|
1362
|
+
the ambiguity this table exists to remove.
|
|
1363
|
+
|
|
1364
|
+
## Failure classes and recovery by class (#132)
|
|
1365
|
+
|
|
1366
|
+
Every run that did not reach a merged PR used to end at a human. The
|
|
1367
|
+
orchestrator re-derived the same triage on each tick — read the row, read the
|
|
1368
|
+
PR's checks, decide whether to requeue, re-run, settle or escalate — and then
|
|
1369
|
+
threw the conclusion away. Measured on this fleet's own history: **half the
|
|
1370
|
+
spend produced no merged PR**, and a large share of it was not implementation
|
|
1371
|
+
failure at all but daemon restarts, cancelled runners and a base branch moving
|
|
1372
|
+
under a green PR.
|
|
1373
|
+
|
|
1374
|
+
So the daemon classifies each terminal non-success before the next dispatch,
|
|
1375
|
+
persists the class on the row, and performs the one recovery that class names.
|
|
1376
|
+
|
|
1377
|
+
| Class | Signals | Recovery | Budget |
|
|
1378
|
+
| --- | --- | --- | --- |
|
|
1379
|
+
| `settlement-stuck` | a row carrying a PR that has since merged | settle: release the label, mark the row merged | none |
|
|
1380
|
+
| `merge-conflict` | `pushed-green`, PR open, GitHub reports conflicting | requeue for a rebase continuation | continuation |
|
|
1381
|
+
| `question` | the worker stopped to ask something (`blocked`) | escalate, carrying the worker's own report as evidence | none |
|
|
1382
|
+
| `orphan-dirty` | orphaned with a failed salvage and no operator ack | hold — recorded only; the tree is the only copy | none |
|
|
1383
|
+
| `orphan-clean` | orphaned with nothing uncommitted | requeue | continuation |
|
|
1384
|
+
| `turn-cap-progress` | at the turn ceiling **with** a PR, head or salvage commit | continue from the branch | continuation |
|
|
1385
|
+
| `turn-cap-spinning` | at the ceiling with no PR and no commits | escalate with the last tool calls the transcript recorded | none |
|
|
1386
|
+
| `admin-kill` | killed *below* its own ceiling — a restart or a drain | requeue | none |
|
|
1387
|
+
| `ci-infra` | PR open, every unresolved check cancelled / timed out / stale | re-run the failed jobs | none |
|
|
1388
|
+
| `ci-deterministic` | PR open, a check genuinely reports `FAILURE` | escalate with the failing check names and links | failed attempt |
|
|
1389
|
+
| `unknown` | anything unrecognised | escalate | as recorded |
|
|
1390
|
+
|
|
1391
|
+
**Unknown escalates; it never silently retries.** A shape this table does not
|
|
1392
|
+
recognise is a gap in the table, and a quiet requeue would spend a budget on a
|
|
1393
|
+
cause nobody has named — the behaviour this exists to end.
|
|
1394
|
+
|
|
1395
|
+
### The budgets follow the cause
|
|
1396
|
+
|
|
1397
|
+
`failuresFor` (implementation attempts) excludes `ci-infra` and
|
|
1398
|
+
`settlement-stuck`; `continuationsFor` excludes `admin-kill` and
|
|
1399
|
+
`settlement-stuck`. A merge conflict *is* charged as a continuation, because a
|
|
1400
|
+
rebase is real work — just never as a failed implementation attempt.
|
|
1401
|
+
|
|
1402
|
+
An **unclassified** row (every row written before 0.4.3) counts exactly as it
|
|
1403
|
+
did before classification existed. Upgrading therefore changes no existing
|
|
1404
|
+
budget: the columns are additive and nullable, and a pre-0.4.3 `conductor.db`
|
|
1405
|
+
opens unchanged.
|
|
1406
|
+
|
|
1407
|
+
### Stale labels are reconciled
|
|
1408
|
+
|
|
1409
|
+
On 2026-08-09 four issues carried `agent:failed` while every one of them was
|
|
1410
|
+
already complete — residue of a turns-cap kill two days earlier that nothing in
|
|
1411
|
+
the loop ever revisited. The board counted four phantom failures while the
|
|
1412
|
+
genuinely stuck issues were invisible.
|
|
1413
|
+
|
|
1414
|
+
Each tick now reconciles the three state labels against the tracker:
|
|
1415
|
+
|
|
1416
|
+
- A **closed** issue never keeps an `agent:*` label.
|
|
1417
|
+
- An **open** issue carrying `failed` whose sub-issues have *all* closed loses
|
|
1418
|
+
the label and gets one comment naming them, deduplicated through the same
|
|
1419
|
+
notifications ledger escalations use.
|
|
1420
|
+
|
|
1421
|
+
Positive evidence only: a tracker that cannot list answers empty, and an empty
|
|
1422
|
+
answer removes nothing — the label is the interlock that keeps two workers off
|
|
1423
|
+
one issue.
|
|
1424
|
+
|
|
1425
|
+
### Where you see it
|
|
1426
|
+
|
|
1427
|
+
- `omp-conductor status` grows a `failure classes (unrecovered)` block, counting
|
|
1428
|
+
only rows whose recovery has *not* run. Classes rather than row states,
|
|
1429
|
+
because a row state is not an issue state.
|
|
1430
|
+
- The board appends `[<class>]` to a card whose newest run carries one.
|
|
1431
|
+
- The tick prompt carries one line — `Auto-recovered since last tick: 3
|
|
1432
|
+
(merge-conflict #365, admin-kill #82, …) — already handled, do not re-triage
|
|
1433
|
+
these.` — so the orchestrator stops writing that paragraph by re-deriving it.
|
|
1434
|
+
|
|
1305
1435
|
## Configuration
|
|
1306
1436
|
|
|
1307
1437
|
The config lives at `$OMP_CONDUCTOR_HOME/config.json`, or
|
|
@@ -1451,8 +1581,8 @@ Field notes:
|
|
|
1451
1581
|
| `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. |
|
|
1452
1582
|
| `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. |
|
|
1453
1583
|
| `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. |
|
|
1454
|
-
| `reporting.scope` | Optional; `"material"` (default) or `"escalations"`. Every orchestrator tick appends the matching constraint line to its prompt, re-read from this file each tick — see [Your workflow vs. the package](#your-workflow-vs-the-package). It constrains what the session is told to report; it is not an outbound filter. A config written without the key keeps reporting material events. Any other value is an error, never folded to the default. |
|
|
1455
|
-
| `orchestratorReadPaths` |
|
|
1584
|
+
| `reporting.scope` | Optional; `"material"` (default), `"decisions"` or `"escalations"`. Every orchestrator tick appends the matching constraint line to its prompt, re-read from this file each tick — see [Your workflow vs. the package](#your-workflow-vs-the-package). It constrains what the session is told to report; it is not an outbound filter. A config written without the key keeps reporting material events — a release never turns an existing fleet's volume down on its own. Any other value is an error, never folded to the default. |
|
|
1585
|
+
| `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). |
|
|
1456
1586
|
| `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). |
|
|
1457
1587
|
| `workspaceRoot` / `mirrorRoot` | Optional; default to `worktrees/` and `mirrors/` under the state directory. `~` is expanded. |
|
|
1458
1588
|
|
|
@@ -1748,6 +1878,10 @@ omp-conductor extend <issue> --turns N [--project NAME]
|
|
|
1748
1878
|
omp-conductor unblock <issue> [--force] [--project NAME]
|
|
1749
1879
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
1750
1880
|
omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
|
|
1881
|
+
omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
1882
|
+
omp-conductor decision resolve <id> --answer TEXT [--project NAME]
|
|
1883
|
+
omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
|
|
1884
|
+
omp-conductor decision list [--project NAME]
|
|
1751
1885
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
1752
1886
|
omp-conductor pause
|
|
1753
1887
|
omp-conductor resume
|
|
@@ -1762,7 +1896,7 @@ omp-conductor help
|
|
|
1762
1896
|
| `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. |
|
|
1763
1897
|
| `restart` | Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
|
|
1764
1898
|
| `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
|
|
1765
|
-
| `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / optional `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, and bounded hold groups; API failures are marked `DEGRADED` so queue starvation cannot look idle. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. A `reports` block lists everything the outbox has not delivered, with its age, and prints `pending` (nobody has it) differently from `SENDING` (outcome unknown, it may already have arrived) — see [Report delivery](#report-delivery-the-outbox). The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
|
|
1899
|
+
| `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / `boundary` / `brief` / `decisions` / optional `failure classes` and `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, and bounded hold groups; API failures are marked `DEGRADED` so queue starvation cannot look idle. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. A `reports` block lists everything the outbox has not delivered, with its age, and prints `pending` (nobody has it) differently from `SENDING` (outcome unknown, it may already have arrived) — see [Report delivery](#report-delivery-the-outbox). The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
|
|
1766
1900
|
| `ledger [--issue N] [--limit N]` | Every [mediated-verb](#the-mediated-verbs-126) call and how the daemon decided it: the verb, the arguments it was given, allow or refuse, the named refusal reason, and any resulting SHA. Sessions cannot push, open a PR, merge, label or release except through those verbs, so this is the record of what they *tried* as well as what they did — the thing an escalation cites. Reads (`conductor_pr_status`) are deliberately absent: a poll every thirty seconds would bury the refusals. `--issue` narrows to one issue's run; `--limit` defaults to 50. The newest few also appear in `status`. |
|
|
1767
1901
|
| `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status`, plus the tracker's current labels: Queue, Claimed, Running, Green, Blocked, Failed, Orphaned, and the last 24 hours of Merged and History. Columns are mutually exclusive and describe current state, not the newest run row, so a requeued issue is queued rather than failed and a closed issue is neither. Refreshes run/spend/turn values every second, and health plus the label read every ten seconds. `Enter` follows the selected transcript in place; `u` invokes the existing unblock workflow on a Blocked, Failed, or Orphaned card; `i` / `p` open the issue / PR; `r` refreshes health; `?` shows all keys. Requires an interactive terminal of at least 50×20. |
|
|
1768
1902
|
| `hold [--project NAME]` | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this over `pause` when the intent is "stop the conductor" without killing processes. See [Stop the conductor](#stop-the-conductor-hold--halt). |
|
|
@@ -1775,6 +1909,10 @@ omp-conductor help
|
|
|
1775
1909
|
| `unblock <issue> [--force]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label, and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. **Refuses, clearing nothing and exiting `3`, when the newest attempt's work could not be committed and its worktree is the only copy** — re-claiming removes that tree. `--force` records the operator's acceptance on the run row and then clears; the salvage failure stays in history. Exits `2` when the issue number is missing or malformed. |
|
|
1776
1910
|
| `friction <kind> --detail TEXT [--issue N]` | Record one bounded judgment the daemon cannot infer: an escalation belonged in a digest, or a tick report was noise/surprising. The detail is limited to 160 characters. One event never changes policy; three observations inside seven days make the aggregate eligible for one Learning-loop prompt, followed by a seven-day cooldown. |
|
|
1777
1911
|
| `report --text TEXT [--kind material|digest]` | Hand a rendered report to the daemon's durable outbox. The text is persisted **before** anything is sent and the command prints a report id; the daemon then owns delivery, retries on a bounded backoff, and records the Telegram message id it actually got back. Delivery is [at-least-once](#report-delivery-the-outbox) — a crash mid-send is retried and the retry says it may be a repeat — so `delivered` is never proof of exactly one message. `--kind digest` is accepted at most once per local day, decided from the ledger rather than from what the session remembers sending; an unknown `--kind` exits `2` rather than quietly becoming a material report. Anything undelivered shows in `status` with its age. |
|
|
1912
|
+
| `decision open --question TEXT [--blocks TEXT] [--resolves-when COND]` | Record a question the orchestrator has put to you, and print its id. A question that lives only in a session's context is lost at the next compaction — after which it is either asked twice or dropped silently. `--resolves-when` attaches a machine-checkable condition: `pr-merged:<https url>`, `issue-closed:<n>`, or `npm-version:<pkg>@<version>`; anything else exits `2` listing the three forms. See [The decision ledger](#the-decision-ledger-136). |
|
|
1913
|
+
| `decision resolve <id> --answer TEXT` | Record what you decided. Exits `1` naming the id when it is unknown or no longer open, so a second answer cannot overwrite the first. |
|
|
1914
|
+
| `decision withdraw <id> [--reason TEXT]` | Close a question the session stopped needing, with why. Same guard as `resolve`. |
|
|
1915
|
+
| `decision list` | Open questions, oldest first: id, age, what each blocks, whether its condition is met, and the question. Prints `no open decisions` when there are none. |
|
|
1778
1916
|
| `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. |
|
|
1779
1917
|
| `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. |
|
|
1780
1918
|
| `--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. |
|
|
@@ -1787,7 +1925,7 @@ omp-conductor help
|
|
|
1787
1925
|
| `brief-upgrade` | Inspect the package-floor + `POLICY.md` overlay. Reports by default; see [Keeping a brief current](#keeping-a-brief-current). |
|
|
1788
1926
|
| `--migrate` | Only for `brief-upgrade`. Lift a bannered `ORCHESTRATOR.md` owned half into `POLICY.md` and recompose. Dry-run unless `--apply`. |
|
|
1789
1927
|
| `--retrofit` | Only for `brief-upgrade`. Propose (or with `--apply`, write) a `YOURS TO EDIT` banner before the first owned-topic heading on a hand-written brief. |
|
|
1790
|
-
| `--apply` | Only for `brief-upgrade`. Confirms `--migrate` / `--retrofit
|
|
1928
|
+
| `--apply` | Only for `brief-upgrade`. Confirms `--migrate` / `--retrofit`. On its own it exits `2`: the legacy single-file merge was removed in 0.4.3. |
|
|
1791
1929
|
| `--file PATH` | Only for `brief-upgrade`. Check a brief that is not where the wizard would have put it, on a host that may have no config at all. |
|
|
1792
1930
|
| `help`, `--help`, `-h` | Print usage. An unknown or missing verb prints it too, and exits `2`. |
|
|
1793
1931
|
|
|
@@ -1935,46 +2073,47 @@ Until that uid exists, a root-or-operator daemon still has a mechanical
|
|
|
1935
2073
|
worktree gate on structured tools and an integrity tripwire on its own package —
|
|
1936
2074
|
but `bash` plus host credentials remain a prompt-and-deploy problem.
|
|
1937
2075
|
|
|
1938
|
-
###
|
|
2076
|
+
### The orchestrator is unconfined, deliberately
|
|
2077
|
+
|
|
2078
|
+
There is **no mechanical file gate on the orchestrator session**, and that is an
|
|
2079
|
+
operator decision rather than an omission (#143).
|
|
2080
|
+
|
|
2081
|
+
A previous release jailed it to an allowlist. That gate could only ever be
|
|
2082
|
+
installed by `createLocalSession`, so it existed exactly in the sessions this
|
|
2083
|
+
daemon spawns — and the supported shape for a heartbeat orchestrator is an
|
|
2084
|
+
`omp` session the operator starts themselves, which never had it. A boundary
|
|
2085
|
+
present in one deployment out of two is not a boundary, and the brief asserting
|
|
2086
|
+
it was absolute was the worse half of the bug: a session that believes it is
|
|
2087
|
+
gated stops checking itself.
|
|
1939
2088
|
|
|
1940
|
-
|
|
1941
|
-
read the state directory, the composed `ORCHESTRATOR.md`, `POLICY.md` and the
|
|
1942
|
-
config. So it gets the other shape — **an allowlist, for reads as well as
|
|
1943
|
-
writes, where anything unmatched is refused.** A deny-list would only stop the
|
|
1944
|
-
paths somebody thought to name and would leave the structured tools pointed at
|
|
1945
|
-
the rest of the filesystem.
|
|
2089
|
+
What holds the orchestrator instead:
|
|
1946
2090
|
|
|
1947
|
-
| |
|
|
2091
|
+
| | |
|
|
1948
2092
|
| --- | --- |
|
|
1949
|
-
| **
|
|
1950
|
-
| **
|
|
1951
|
-
| **
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
is
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
the
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
boundary is an OS principal for the orchestrator with no filesystem access to
|
|
1974
|
-
any run checkout; until that exists, what this buys is real but narrower:
|
|
1975
|
-
accidents are prevented, refusals are legible, and it holds on hosts where no
|
|
1976
|
-
uid split will ever be deployed. Package-source edits that get around it
|
|
1977
|
-
(through `bash`) remain covered by the integrity tripwire above, one tick late.
|
|
2093
|
+
| **The brief** | `ORCHESTRATOR.md`'s hard boundaries — never read or edit a worker's checkout or the mirror cache; when you need a run's code, read its PR. |
|
|
2094
|
+
| **The verb ledger** | Every `conductor_*` call records what was asked for and what the dispatcher decided, refusals included. `omp-conductor ledger` is the audit. |
|
|
2095
|
+
| **The dispatcher** | Merge, label and release authority are checked in the daemon against the operator's grant, across a process boundary, never in the prompt. |
|
|
2096
|
+
|
|
2097
|
+
Unconfined means auditable, not licensed. `orchestratorReadPaths` is retired: it
|
|
2098
|
+
is still accepted in a config and ignored, so a fleet carrying it upgrades
|
|
2099
|
+
without editing anything.
|
|
2100
|
+
|
|
2101
|
+
**One conditional exception, and it is a credential boundary rather than a tool
|
|
2102
|
+
gate.** When the *daemon* starts the orchestrator (`escalation.orchestrator:
|
|
2103
|
+
"embedded"`) on a fleet whose `credentials.isolation` resolves to an isolating
|
|
2104
|
+
mechanism, that session also runs as its own OS principal with
|
|
2105
|
+
`workspaceRoot`, `mirrorRoot` and the host credential denied — a syscall-level
|
|
2106
|
+
refusal `bash` cannot talk its way around, which is why it is worth keeping
|
|
2107
|
+
where it applies. It does not apply to an external orchestrator, and it does not
|
|
2108
|
+
apply on a host `status` reports as `unprotected`. The installed package is
|
|
2109
|
+
readable either way: reading your own dispatcher's source to file an accurate
|
|
2110
|
+
bug is exactly what should never be refused (#143).
|
|
2111
|
+
|
|
2112
|
+
The workers are the sessions that are gated — a mechanical worktree confinement
|
|
2113
|
+
on structured tools, plus their own OS principal under
|
|
2114
|
+
`credentials.isolation: "per-run"`. That asymmetry is the design: a worker is
|
|
2115
|
+
dispatched code it did not write and runs unattended, while the orchestrator is
|
|
2116
|
+
the session the operator is reading.
|
|
1978
2117
|
|
|
1979
2118
|
## The mediated verbs (#126)
|
|
1980
2119
|
|
|
@@ -2357,13 +2496,13 @@ Known and deliberate in this version:
|
|
|
2357
2496
|
- **Workers stop at green PRs.** They never merge, release or deploy. Those
|
|
2358
2497
|
actions default to a human, but setup may grant either to the orchestrator;
|
|
2359
2498
|
`authority` never grants them to a worker or the dispatch daemon.
|
|
2360
|
-
- **
|
|
2361
|
-
`
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2499
|
+
- **The worker gate is partial, and the orchestrator has none.** A worker's
|
|
2500
|
+
structured `write` / `edit` / `read` / `grep` / `glob` calls are gated to its
|
|
2501
|
+
worktree by an inline harness extension; `bash` is not, so a shell one-liner
|
|
2502
|
+
can still leave the tree, and no claim in this README says otherwise. The
|
|
2503
|
+
orchestrator is [unconfined on purpose](#the-orchestrator-is-unconfined-deliberately)
|
|
2504
|
+
— its boundaries are its brief and the verb ledger. Prefer a
|
|
2505
|
+
[least-privilege worker uid](#least-privilege-worker-uid-deploy); the
|
|
2367
2506
|
[integrity tripwire](#integrity-tripwire-package-self-hash) still pages if the
|
|
2368
2507
|
installed package itself changes under a live daemon.
|
|
2369
2508
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/board.ts
CHANGED
|
@@ -415,7 +415,12 @@ function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
|
|
|
415
415
|
const endedAt = run.endedAt ?? snapshot.now;
|
|
416
416
|
const duration = humanDuration(endedAt - run.startedAt);
|
|
417
417
|
const lines = [
|
|
418
|
-
|
|
418
|
+
// The class, when the sweep has attached one and nothing has recovered it
|
|
419
|
+
// yet (#132): a row state says "failed", which four completed issues on this
|
|
420
|
+
// fleet also said. The class says which of the two this is.
|
|
421
|
+
run.failureClass === undefined || run.recoveredAt !== undefined
|
|
422
|
+
? `#${run.issue} · ${run.repo}`
|
|
423
|
+
: `#${run.issue} · ${run.repo} [${run.failureClass}]`,
|
|
419
424
|
`attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
|
|
420
425
|
`$${run.spendUsd.toFixed(2)} · ${duration}`,
|
|
421
426
|
];
|
package/src/brief-upgrade.ts
CHANGED
|
@@ -103,13 +103,6 @@ export function isPhraseChromeLine(line: string): boolean {
|
|
|
103
103
|
return BANNER_CHROME_MARKERS.some((marker) => lower.includes(marker.toLowerCase()));
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
107
|
-
* @deprecated Prefer {@link isPhraseChromeLine}. Separators alone are not chrome.
|
|
108
|
-
*/
|
|
109
|
-
export function isKnownBannerChromeLine(line: string): boolean {
|
|
110
|
-
return isPhraseChromeLine(line);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
106
|
/**
|
|
114
107
|
* Drop a leading **known package footer fragment** from an owned half / POLICY.md.
|
|
115
108
|
*
|
|
@@ -156,9 +149,6 @@ export function stripLeadingBannerCrumbs(text: string): string {
|
|
|
156
149
|
return lines.slice(i).join("\n");
|
|
157
150
|
}
|
|
158
151
|
|
|
159
|
-
/** @deprecated Use {@link stripLeadingBannerCrumbs}. Kept for call-site stability. */
|
|
160
|
-
export const stripLeadingHtmlComments = stripLeadingBannerCrumbs;
|
|
161
|
-
|
|
162
152
|
/**
|
|
163
153
|
* Splits on the banner, or returns `undefined` when there is none.
|
|
164
154
|
*
|
|
@@ -253,53 +243,6 @@ export function renderBriefTemplate(template: string, vars: Record<string, strin
|
|
|
253
243
|
});
|
|
254
244
|
}
|
|
255
245
|
|
|
256
|
-
/** What a check found, and what a caller may do about it. */
|
|
257
|
-
export type BriefStatus =
|
|
258
|
-
| { kind: "current" }
|
|
259
|
-
/** Banner present and the shipped half differs: a merge is exact. */
|
|
260
|
-
| { kind: "mergeable"; merged: string; liveShipped: string; freshShipped: string }
|
|
261
|
-
/** No banner, so the boundary is unknown and only reporting is honest. */
|
|
262
|
-
| { kind: "unsplittable"; missing: string[] }
|
|
263
|
-
/** Template never rendered, so merging it would write `{{PROJECT}}` into a brief. */
|
|
264
|
-
| { kind: "unrendered"; missing: string[] }
|
|
265
|
-
/** Overlay already active: floor refreshes from package; policy is POLICY.md. */
|
|
266
|
-
| { kind: "overlay"; policyPath: string; orchestratorPath: string };
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Compares a live brief against the freshly rendered template.
|
|
270
|
-
*
|
|
271
|
-
* Prefer {@link inspectBriefLayout} once a fleet has `POLICY.md`. This path
|
|
272
|
-
* remains for pre-overlay single-file briefs.
|
|
273
|
-
*/
|
|
274
|
-
export function checkBrief(live: string, rendered: string): BriefStatus {
|
|
275
|
-
const liveHalves = splitBrief(live);
|
|
276
|
-
const freshHalves = splitBrief(rendered);
|
|
277
|
-
|
|
278
|
-
// A template without the banner is expected in the overlay world (floor-only).
|
|
279
|
-
// When the *live* brief still has a banner, compare using composed rendered text
|
|
280
|
-
// that includes the compose banner so migrate remains available.
|
|
281
|
-
if (liveHalves === undefined) {
|
|
282
|
-
return { kind: "unsplittable", missing: missingSections(live, rendered) };
|
|
283
|
-
}
|
|
284
|
-
if (freshHalves === undefined) {
|
|
285
|
-
// Floor-only rendered template: live bannered brief wants migrate, not merge.
|
|
286
|
-
return { kind: "unsplittable", missing: missingSections(live, rendered) };
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (PLACEHOLDER_PATTERN.test(freshHalves.shipped)) {
|
|
290
|
-
return { kind: "unrendered", missing: missingSections(live, rendered) };
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (liveHalves.shipped === freshHalves.shipped) return { kind: "current" };
|
|
294
|
-
|
|
295
|
-
return {
|
|
296
|
-
kind: "mergeable",
|
|
297
|
-
merged: freshHalves.shipped + liveHalves.owned,
|
|
298
|
-
liveShipped: liveHalves.shipped,
|
|
299
|
-
freshShipped: freshHalves.shipped,
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
246
|
/** Layout of brief files under a workspace root. */
|
|
304
247
|
export type BriefLayout =
|
|
305
248
|
| { kind: "overlay"; policyPath: string; orchestratorPath: string }
|
|
@@ -437,17 +380,6 @@ export function writeWithBackup(
|
|
|
437
380
|
return backup;
|
|
438
381
|
}
|
|
439
382
|
|
|
440
|
-
/**
|
|
441
|
-
* Writes the merged brief, storing the previous one under conductor state.
|
|
442
|
-
*
|
|
443
|
-
* @deprecated Prefer {@link migrateToPolicy} / overlay refresh. Kept for
|
|
444
|
-
* pre-overlay `--apply` on bannered single-file briefs.
|
|
445
|
-
*/
|
|
446
|
-
export function writeMergedBrief(path: string, merged: string, backupRoot?: string): string {
|
|
447
|
-
const backup = writeWithBackup(path, merged, backupRoot);
|
|
448
|
-
return backup ?? `${path}.bak-missing`;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
383
|
/** Result of migrating a bannered ORCHESTRATOR.md into POLICY.md. */
|
|
452
384
|
export interface MigrateResult {
|
|
453
385
|
policyPath: string;
|
|
@@ -647,67 +579,72 @@ export function applyRetrofit(path: string, proposal: RetrofitProposal, backupRo
|
|
|
647
579
|
return backup ?? `${path}.bak-missing`;
|
|
648
580
|
}
|
|
649
581
|
|
|
650
|
-
/**
|
|
651
|
-
|
|
652
|
-
|
|
582
|
+
/**
|
|
583
|
+
* The layout rendered for a terminal, including what to do next.
|
|
584
|
+
*
|
|
585
|
+
* One report for the four layouts `inspectBriefLayout` can return, because that
|
|
586
|
+
* is now the only classifier. The pre-overlay pair it replaced described two
|
|
587
|
+
* further states ("mergeable", "current") that only the deleted single-file
|
|
588
|
+
* merge could act on (#131).
|
|
589
|
+
*/
|
|
590
|
+
export function formatBriefReport(path: string, layout: BriefLayout, missing: readonly string[]): string {
|
|
591
|
+
if (layout.kind === "overlay") {
|
|
653
592
|
return [
|
|
654
593
|
`brief overlay active`,
|
|
655
594
|
"",
|
|
656
|
-
` floor package template → recomposed into ${
|
|
657
|
-
` policy ${
|
|
595
|
+
` floor package template → recomposed into ${layout.orchestratorPath} each tick`,
|
|
596
|
+
` policy ${layout.policyPath} (Learning loop / operator edits)`,
|
|
658
597
|
"",
|
|
659
598
|
"Protocol updates: upgrade omp-conductor in this host's existing install root (same package manager), then restart the daemon — no brief-upgrade --apply.",
|
|
660
599
|
"Legacy migrate: omp-conductor brief-upgrade --migrate",
|
|
661
600
|
].join("\n");
|
|
662
601
|
}
|
|
663
602
|
|
|
664
|
-
if (
|
|
665
|
-
return [
|
|
603
|
+
if (layout.kind === "missing") {
|
|
604
|
+
return [
|
|
605
|
+
`brief ${path}`,
|
|
606
|
+
"",
|
|
607
|
+
"No brief here yet. Run /conductor setup and say yes to writing ORCHESTRATOR.md;",
|
|
608
|
+
"it writes POLICY.md beside it, which is the half you then own.",
|
|
609
|
+
].join("\n");
|
|
666
610
|
}
|
|
667
611
|
|
|
668
|
-
if (
|
|
612
|
+
if (layout.kind === "legacy-bannered") {
|
|
669
613
|
return [
|
|
670
614
|
`brief ${path}`,
|
|
671
615
|
"",
|
|
672
|
-
"Legacy single-file brief:
|
|
673
|
-
"
|
|
616
|
+
"Legacy single-file brief: one file with a YOURS TO EDIT banner, from before the",
|
|
617
|
+
"floor and the policy were separate files.",
|
|
674
618
|
"",
|
|
675
|
-
|
|
619
|
+
"Migrate: omp-conductor brief-upgrade --migrate --apply",
|
|
676
620
|
"",
|
|
677
|
-
"
|
|
678
|
-
"
|
|
679
|
-
"The previous file is kept
|
|
621
|
+
"That lifts everything below the banner into POLICY.md and recomposes",
|
|
622
|
+
"ORCHESTRATOR.md from the package floor plus that policy — after which the floor",
|
|
623
|
+
"tracks the installed package on its own. The previous file is kept under",
|
|
624
|
+
`${briefBackupDir()}.`,
|
|
680
625
|
].join("\n");
|
|
681
626
|
}
|
|
682
627
|
|
|
683
|
-
const lines = [
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
"",
|
|
697
|
-
"Retrofit a banner at the first Releases/Project context/Reporting/Amendments",
|
|
698
|
-
"heading, then migrate:",
|
|
699
|
-
" omp-conductor brief-upgrade --retrofit",
|
|
700
|
-
" omp-conductor brief-upgrade --migrate",
|
|
701
|
-
]),
|
|
702
|
-
);
|
|
703
|
-
if (status.missing.length === 0) {
|
|
628
|
+
const lines = [
|
|
629
|
+
`brief ${path}`,
|
|
630
|
+
"",
|
|
631
|
+
"This brief has no YOURS TO EDIT banner, so it was written by hand or predates",
|
|
632
|
+
"the template split. There is no way to tell which lines are yours, so nothing",
|
|
633
|
+
"will be rewritten automatically.",
|
|
634
|
+
"",
|
|
635
|
+
"Retrofit a banner at the first Releases/Project context/Reporting/Amendments",
|
|
636
|
+
"heading, then migrate:",
|
|
637
|
+
" omp-conductor brief-upgrade --retrofit --apply",
|
|
638
|
+
" omp-conductor brief-upgrade --migrate --apply",
|
|
639
|
+
];
|
|
640
|
+
if (missing.length === 0) {
|
|
704
641
|
lines.push("", "It already has a heading for every section the template ships.");
|
|
705
642
|
return lines.join("\n");
|
|
706
643
|
}
|
|
707
644
|
lines.push(
|
|
708
645
|
"",
|
|
709
|
-
`Sections the shipped template has and this brief does not (${
|
|
710
|
-
...
|
|
646
|
+
`Sections the shipped template has and this brief does not (${missing.length}):`,
|
|
647
|
+
...missing.map((h) => ` - ${h}`),
|
|
711
648
|
"",
|
|
712
649
|
"Merge the ones you want by hand, or ask the session running from this brief to",
|
|
713
650
|
"propose them through its own amendment protocol.",
|