omp-conductor 0.15.10 → 0.15.12

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.
Files changed (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
package/src/cli.ts CHANGED
@@ -1,101 +1,54 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * Standalone entry point — the only operator surface after #309. Everything here
4
- * is argument handling and printing: the loop, the caps and the state live in
4
+ * is argument handling, the shared usage text, and the registration table that
5
+ * dispatches each verb to its own module in ./commands/<verb>.ts; the per-verb
6
+ * bodies moved there by the module split (#462), so a new operator subcommand
7
+ * is one file plus one table line. The loop, the caps and the state live in
5
8
  * ./daemon.ts and the background process lifecycle in ./lifecycle.ts.
6
9
  */
7
- import { randomUUID } from "node:crypto";
8
- import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
9
10
  import { userInfo } from "node:os";
10
- import { dirname, join } from "node:path";
11
- import { boardJson, boardSnapshotOnce, runBoard } from "./board.ts";
11
+ import { loadConfig } from "./config.ts";
12
+ import { AMEND_AREA_IDS } from "./setup.ts";
13
+ import type { ProjectConfig } from "./types.ts";
14
+ import { armCommand } from "./commands/arm.ts";
15
+ import { boardCommand } from "./commands/board.ts";
16
+ import { briefUpgradeCommand } from "./commands/brief-upgrade.ts";
17
+ import { daemonCommand } from "./commands/daemon.ts";
18
+ import { dashboardCommand } from "./commands/dashboard.ts";
19
+ import { decisionCommand } from "./commands/decision.ts";
20
+ import { disarmCommand } from "./commands/disarm.ts";
21
+ import { doctorCommand } from "./commands/doctor.ts";
22
+ import { eventCommand } from "./commands/event.ts";
23
+ import { extendCommand } from "./commands/extend.ts";
24
+ import { frictionCommand } from "./commands/friction.ts";
25
+ import { helpCommand } from "./commands/help.ts";
26
+ import { holdCommand } from "./commands/hold.ts";
27
+ import { intakeCommand } from "./commands/intake.ts";
28
+ import { ledgerCommand } from "./commands/ledger.ts";
29
+ import { messageCommand } from "./commands/message.ts";
30
+ import { reportCommand } from "./commands/report.ts";
31
+ import { restartCommand } from "./commands/restart.ts";
32
+ import { resumeCommand } from "./commands/resume.ts";
33
+ import { setupCommand } from "./commands/setup.ts";
34
+ import { startCommand } from "./commands/start.ts";
35
+ import { statsCommand } from "./commands/stats.ts";
36
+ import { statusCommand } from "./commands/status.ts";
37
+ import { stopCommand } from "./commands/stop.ts";
38
+ import { tailCommand } from "./commands/tail.ts";
39
+ import { unblockCommand } from "./commands/unblock.ts";
40
+ import { upgradeInstallCommand } from "./commands/upgrade-install.ts";
41
+ import { upgradeRollbackCommand } from "./commands/upgrade-rollback.ts";
42
+ import { upgradeCommand } from "./commands/upgrade.ts";
43
+ import { verbCommand } from "./commands/verb.ts";
44
+ import { versionCommand } from "./commands/version.ts";
45
+ import { workerCommand } from "./commands/worker.ts";
12
46
  import {
13
- applyRetrofit,
14
- formatBriefReport,
15
- formatMigrateResult,
16
- formatRetrofitProposal,
17
- formatRetrofitRefusal,
18
- inspectBriefLayout,
19
- migrateToPolicy,
20
- missingSections,
21
- proposeRetrofit,
22
- repairPolicyBannerCrumbs,
23
- } from "./brief-upgrade.ts";
24
- import { interruptDisposition } from "./availability.ts";
25
- import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
26
- import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
27
- import { isPaused, pausedAt, runDaemon, setPaused } from "./daemon.ts";
28
- import {
29
- armTicks,
30
- clearPaneHaltIfResolvable,
31
- disarmTicks,
32
- hold,
33
- pinPaneHalt,
34
- releaseHold,
35
- renderStatus,
36
- startHerdrFleet,
37
- stopConductorPane,
38
- telegramStateDir,
39
- } from "./fleet.ts";
40
- import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
41
- import { readBaseChain } from "./gitops.ts";
42
- import {
43
- clearRecord,
44
- DEFAULT_PORT,
45
- livingDaemon,
46
- restartDaemon,
47
- startDaemon,
48
- stopDaemon,
49
- writeRecord,
50
- type RestartResult,
51
- } from "./lifecycle.ts";
52
- import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
53
- import {
54
- deliverOperatorMessage,
55
- digestDedupeKey,
56
- type OperatorMessageOutcome,
57
- } from "./reports.ts";
58
- import { digestDue } from "./digest-schedule.ts";
59
- import {
60
- AMEND_AREA_IDS,
61
- briefPathForProject,
62
- policyPathForProject,
63
- renderBriefForProject,
64
- renderFloorForProject,
65
- shippedBriefTemplate,
66
- type AmendAreaId,
67
- } from "./setup.ts";
68
- import { runGraphInstall, runHostInstall } from "./setup-install.ts";
69
- import { DEFAULT_PROBES, NO_PROBES, setup } from "./setup-wizard.ts";
70
- import { terminalUi } from "./wizard-ui.ts";
71
- import { dbPath, LIVE_STATES, openStore } from "./store.ts";
72
- import { formatTranscriptLine } from "./transcript.ts";
73
- import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
74
- import { makeTracker } from "./tracker/github.ts";
75
- import { githubVerbActions } from "./verbs/actions.ts";
76
- import { handleVerbCall, type VerbChannel } from "./verbs/server.ts";
77
- import { REPORT_KINDS, DEFAULT_REPORT_POLICY, VERB_NAMES } from "./types.ts";
78
- import type { ProjectConfig, ReportKind } from "./types.ts";
79
- import { formatUnblock, unblockIssue } from "./unblock.ts";
80
- import { DEFAULT_DEPS, drainAndRestart, upgradeConductor, type UpgradeDeps } from "./upgrade.ts";
81
-
82
- const FRICTION_FEEDBACK_KINDS = {
83
- "escalation-digest": "feedback:escalation-should-digest",
84
- "report-noise": "feedback:report-noise",
85
- "report-surprise": "feedback:report-surprise",
86
- } as const;
87
-
88
- type FrictionFeedbackName = keyof typeof FRICTION_FEEDBACK_KINDS;
89
-
90
- function packageVersion(): string {
91
- const parsed = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as {
92
- version?: unknown;
93
- };
94
- if (typeof parsed.version !== "string" || parsed.version.length === 0) {
95
- throw new Error("installed package.json has no version");
96
- }
97
- return parsed.version;
98
- }
47
+ COMMAND_SCOPES,
48
+ resolveProjectsByScope,
49
+ type CommandContext,
50
+ type CommandHandler,
51
+ } from "./commands/context.ts";
99
52
 
100
53
  const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
101
54
 
@@ -106,8 +59,13 @@ usage:
106
59
  omp-conductor stop [--pane] [--project NAME | --all]
107
60
  omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
108
61
  omp-conductor upgrade [--to VERSION] [--project NAME]
62
+ omp-conductor upgrade-install --to VERSION [--project NAME]
63
+ omp-conductor upgrade-rollback
109
64
  omp-conductor board [--project NAME] [--json]
65
+ omp-conductor dashboard [--port N] [--host ADDR]
110
66
  omp-conductor status [--project NAME]
67
+ omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]
68
+ omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
111
69
  omp-conductor ledger [--issue N] [--limit N] [--project NAME]
112
70
  omp-conductor hold [--keep-ticks] [--project NAME | --all]
113
71
  omp-conductor arm [--project NAME | --all]
@@ -124,12 +82,16 @@ usage:
124
82
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
125
83
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
126
84
  omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
127
- omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
128
- omp-conductor message --text TEXT [--project NAME]
85
+ omp-conductor report --text TEXT [--kind material|digest|tier2|decision-needed|fleet-stopped|confirmed-failure] [--events IDS] [--notices IDS] [--project NAME]
86
+ omp-conductor message --text TEXT [--category CATEGORY] [--blocks TEXT] [--project NAME]
129
87
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
130
88
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
131
89
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
132
90
  omp-conductor decision list [--project NAME]
91
+ omp-conductor intake "<text>" [--project NAME]
92
+ omp-conductor intake list [--project NAME]
93
+ omp-conductor intake dismiss <id> [--project NAME]
94
+ omp-conductor intake groomed <id> --issue <url> [--project NAME]
133
95
  omp-conductor help
134
96
 
135
97
  setup interview, then write config.json, the labels, the briefs and the
@@ -141,13 +103,23 @@ usage:
141
103
  brief as one pinned release. Pauses only new claims, drains live
142
104
  workers, reloads, verifies twice, and restores the prior dispatch
143
105
  state. Run it from a shell outside the target Herdr session.
106
+ upgrade-install
107
+ the detached executor half of the fleet-installs-itself request:
108
+ the same transaction, journaled per surface, leaving verification
109
+ and dispatch restore to the first tick after the restart. Run by
110
+ the transient unit, never by hand in a Herdr session.
111
+ upgrade-rollback
112
+ the detached rollback unit: restore every surface the failed
113
+ upgrade-install touched, from the durable pre-install snapshot in
114
+ the upgrade journal, and report through the durable outbox.
144
115
  start start the installed herdr-fleet.service when present, then run the
145
116
  dispatch loop in the background and wait until it answers GET
146
117
  /healthz on :8787 (override with --port). Refuses if one is running.
147
118
  stop stop the conductor: pause claiming, disarm ticks, then stop the
148
- dispatch daemon (systemctl-aware, so Restart=on-failure cannot bring
149
- it back). Pane stays up unless --pane is passed. To bounce the daemon
150
- without stopping the fleet, use restart.
119
+ dispatch daemon (systemctl-aware: an explicit stop is recorded by
120
+ systemd, so Restart=always does not undo it). Pane stays up unless
121
+ --pane is passed. To bounce the daemon without stopping the fleet,
122
+ use restart.
151
123
  stop --pane
152
124
  also stop the conductor agent's pane and pin herdr-conductor recovery
153
125
  off for that agent only — it does NOT stop herdr-fleet.service or any
@@ -166,11 +138,34 @@ usage:
166
138
  status layered fleet report: dispatch (running|paused|stopped), ticks and
167
139
  next due time, pane, herdr, Telegram bot/API health, daemon, caps
168
140
  and active runs.
141
+ stats what the fleet accomplished and at what cost, read wholly from the
142
+ local store: issues merged, merge rate, queue-to-merge lead time
143
+ (median/p90), attempts per merged issue, metered spend per merged
144
+ issue, the failure-class breakdown of what did not merge, and
145
+ tracked gh calls over the window. Continuation chains collapse into
146
+ one journey; $0.00 runs are reported as unmetered, never averaged in
147
+ as free. --json prints the stable {project, window, ghCalls, empty,
148
+ total, repos} shape.
149
+ doctor read-only deployment health, one finding per past failure mode:
150
+ gh auth/scopes, exact-case configured labels, systemd unit drift
151
+ and runtime-dir ownership, config + backup freshness, sqlite
152
+ integrity, spend telemetry, reporting timezones, Telegram health.
153
+ The only write anywhere is the self-identified probe message that
154
+ --probe-telegram sends through the report transport. Exit code 0
155
+ only when nothing failed; --json prints the stable CI shape. Run
156
+ it after install and after every upgrade.
169
157
  board open the live keyboard-driven fleet board. It renders queue holds,
170
158
  every run lifecycle stage, recent merges, spend and health; Enter
171
159
  follows a selected transcript without leaving the board. --json (or
172
160
  a non-interactive stdin/stdout) prints a one-shot JSON snapshot of
173
161
  the same lanes instead.
162
+ dashboard
163
+ serve the fleet dashboard in a browser: the UI shell and GET
164
+ /api/projects, bearer-authenticated with the token minted 0600 at
165
+ <state>/dashboard-token on first start. Binds 127.0.0.1:8788 by
166
+ default; a non-loopback --host still starts but prints a warning
167
+ naming the token file. A separate process — never a route on the
168
+ daemon port.
174
169
  ledger every conductor-verb call and how the daemon decided it: the verb,
175
170
  the arguments, allow or refuse, the named refusal reason, and the
176
171
  resulting sha. Sessions cannot push, open, merge, label or release
@@ -223,15 +218,25 @@ usage:
223
218
  be a repeat. --kind digest is accepted at most once per local day,
224
219
  decided from the ledger rather than from what you remember sending.
225
220
  A digest associates the comma-separated --events and --notices rows
226
- atomically; omitted rows stay owed.
221
+ atomically; omitted rows stay owed. The other --kind values (tier2,
222
+ decision-needed, fleet-stopped, confirmed-failure) declare the
223
+ report's interrupt category: the reporting policy decides between
224
+ sending now and holding, exactly as for a daemon escalation. An
225
+ identical retry is refused only while the earlier handoff is still
226
+ undelivered, and admitted again once it lands (#453).
227
227
  message deliver one direct Telegram message to this project's own chat and
228
228
  forum topic, resolved from config rather than from whichever chat
229
229
  last wrote to the session. This is how a locally injected tick
230
230
  answers or asks something directly: telegram_send keeps the active
231
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.
232
+ keep. A question — text beginning "QUESTION:", or a --category that
233
+ is not material — records an open decision row before delivery
234
+ (parked on silence: still pending in every tick until answered or
235
+ the seven-day expiry, so no one has to remember a separate
236
+ \`decision open\`), and --category carries the escalation category
237
+ directly instead of a text prefix. The operator's availability
238
+ policy still decides between sending now and holding a notice,
239
+ exactly as it does for an autonomous tick.
235
240
  decision record, list and close the questions you have put to your operator.
236
241
  A question that lives only in a session's context is lost to the next
237
242
  compaction, so \`decision open\` writes it down and every tick's prompt
@@ -243,6 +248,12 @@ usage:
243
248
  decision resolve <id> --answer TEXT
244
249
  decision withdraw <id> [--reason TEXT]
245
250
  decision list
251
+ intake keep a raw idea durably before it becomes anything: record it now
252
+ with \`omp-conductor intake "<text>"\`, list what is still pending,
253
+ dismiss what turned out to be nothing. Backed by the sqlite store,
254
+ not a session, so ideas survive restarts; the orchestrator grooms
255
+ one into an issue as its #300 duty and records that provenance with
256
+ \`omp-conductor intake groomed <id> --issue <url>\`.
246
257
  friction record a bounded observation the daemon cannot classify itself:
247
258
  an escalation that belonged in a digest, or a tick report that was
248
259
  noise/surprising. Repeated observations feed the existing Learning
@@ -251,8 +262,11 @@ usage:
251
262
  and exits. This is what \`start\` launches.
252
263
  resume clear pause and any pane-recovery pin. Does NOT re-arm: run arm after
253
264
  an inbound Telegram proof to bring ticks back. Use --all for every project.
254
- setup host
265
+ setup host [NAME]
255
266
  re-stage the systemd unit and run the install behind one confirm.
267
+ The units are host-global; NAME (or --project NAME) says which
268
+ project's per-project tail (tick config, brief link) to write, and
269
+ is required on a host with several configured projects.
256
270
  setup graph
257
271
  set up the code-graph indexes workers query instead of grepping, end
258
272
  to end: check prerequisites, clone any missing index-only clone as
@@ -287,7 +301,7 @@ Stop the conductor:
287
301
  stop --pane stop + pin conductor-pane recovery off
288
302
  resume && arm clear pause and any pane pin, then prove inbound Telegram`;
289
303
 
290
- /** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
304
+ /** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
291
305
  function flag(argv: string[], name: string): string | undefined {
292
306
  const i = argv.indexOf(`--${name}`);
293
307
  if (i >= 0) return argv[i + 1];
@@ -305,9 +319,19 @@ function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] {
305
319
  return [];
306
320
  }
307
321
  const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))];
322
+ // The three shapes the runtime actually mints as ledger row ids: 12-hex
323
+ // material event ids, UUID held-notice ids (quiet-hours material holds,
324
+ // operator messages), and 64-hex sha256 held-notice ids (escalate and the
325
+ // orchestrator tick). Anything else is a typo; refusing it is what keeps a
326
+ // mistyped id from silently claiming nothing.
308
327
  if (
309
328
  ids.length === 0 ||
310
- ids.some((id) => !/^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(id))
329
+ ids.some(
330
+ (id) =>
331
+ !/^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{64})$/.test(
332
+ id,
333
+ ),
334
+ )
311
335
  ) {
312
336
  process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`);
313
337
  process.exit(2);
@@ -352,53 +376,6 @@ function humanDuration(ms: number): string {
352
376
  return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
353
377
  }
354
378
 
355
-
356
- /**
357
- * The orchestrator half, and the one thing `status` has ever known about the
358
- * supervising session: the stall marker its heartbeat writes when its own
359
- * prompts stop being consumed (see {@link STALL_MARKER_FILE}).
360
- *
361
- * The marker is written in the *session's* cwd, which this process has no way
362
- * to discover — so this reads the state directory, on the reference deploy's
363
- * convention that the orchestrator session runs from exactly there. That makes
364
- * the reading one-directional: a line printed here is proof of a wedge, and no
365
- * line is proof of nothing at all. On a fleet whose session lives elsewhere the
366
- * check is simply inert, which is why it never prints a reassuring "healthy".
367
- */
368
- function stallLine(): string | undefined {
369
- let raw: string;
370
- try {
371
- raw = readFileSync(join(stateDir(), STALL_MARKER_FILE), "utf8").trim();
372
- } catch {
373
- return undefined;
374
- }
375
- // "<ISO timestamp> <one-line diagnosis>". A file truncated by something else
376
- // still gets reported: that the marker exists at all is the news.
377
- const cut = raw.indexOf(" ");
378
- const since = cut < 0 ? raw : raw.slice(0, cut);
379
- const diagnosis = cut < 0 ? "" : ` — ${raw.slice(cut + 1)}`;
380
- return `orchestrator STALLED since ${since === "" ? "an unrecorded time" : since}${diagnosis}`;
381
- }
382
-
383
- /**
384
- * `DaemonRecord.logFile` for a daemon nobody spawned. The field is required and
385
- * `status` prints it, so it has to say something true: a foreground daemon
386
- * opened no log of its own — whoever started it owns its stdout, be that
387
- * systemd's journal, a terminal, or a pane.
388
- */
389
- const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
390
-
391
- /** How often `tail` re-stats the transcript it is following. */
392
- const TAIL_POLL_MS = 1_000;
393
-
394
- /**
395
- * How long the transcript must stay unchanged, after its run has left the live
396
- * states, before `tail` calls it over. The state flips from the daemon's thread
397
- * while the harness may still be flushing its last message, so exiting on the
398
- * state alone truncates the ending an operator ran this command to watch.
399
- */
400
- const TAIL_QUIET_MS = 5_000;
401
-
402
379
  /**
403
380
  * The `<issue>` positional, for the two verbs that take one. Exits 2 rather
404
381
  * than following run #NaN or clearing the labels of issue #0; `verb` is named
@@ -414,86 +391,6 @@ function issueArg(verb: string, raw: string | undefined): number {
414
391
  }
415
392
 
416
393
 
417
- /**
418
- * Follow one run's transcript the way `tail -f` follows a log.
419
- *
420
- * Reads from byte zero rather than from the end: attaching to a worker that is
421
- * already ten turns in and then showing nothing until turn eleven is not
422
- * watching the run. Polls `stat` instead of taking a file watcher because the
423
- * transcript is a plain append-only file that may sit on a filesystem where
424
- * change events are a polite fiction, and one stat a second costs nothing.
425
- *
426
- * SIGINT is deliberately left to its default, which is immediate exit. Nothing
427
- * here is buffered, and a handler could only add a poll interval of latency to
428
- * every Ctrl-C.
429
- */
430
- async function tailRun(project: string, issue: number): Promise<void> {
431
- // Read-only in practice: the store is opened WAL with a busy timeout, so this
432
- // never contends with the daemon writing the same rows.
433
- const store = openStore(dbPath());
434
- try {
435
- const run = store.latestRun(project, issue);
436
- if (run === undefined) throw new Error(`no run recorded for #${issue}`);
437
- const path = run.sessionFile;
438
- // Claimed but not yet started, or an attempt whose session never opened one.
439
- if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
440
-
441
- const fd = openSync(path, "r");
442
- try {
443
- let offset = 0;
444
- let pending = Buffer.alloc(0);
445
- let lastChange = Date.now();
446
-
447
- for (;;) {
448
- let size = offset;
449
- try {
450
- size = statSync(path).size;
451
- } catch {
452
- // A transcript that vanishes mid-follow is not worth crashing over.
453
- // The run's own state, below, is what decides when this command ends.
454
- }
455
- // Shorter than what we have already read means truncated or replaced;
456
- // resuming from the old offset would read the middle of another file.
457
- if (size < offset) {
458
- offset = 0;
459
- pending = Buffer.alloc(0);
460
- }
461
- if (size > offset) {
462
- const chunk = Buffer.allocUnsafe(size - offset);
463
- const read = readSync(fd, chunk, 0, chunk.length, offset);
464
- offset += read;
465
- // Split on newlines as bytes, not as text: a UTF-8 sequence straddling
466
- // a read boundary would be mangled by decoding each chunk on its own.
467
- pending = Buffer.concat([pending, chunk.subarray(0, read)]);
468
- for (;;) {
469
- const nl = pending.indexOf(0x0a);
470
- if (nl < 0) break;
471
- const rendered = formatTranscriptLine(pending.subarray(0, nl).toString("utf8"));
472
- pending = pending.subarray(nl + 1);
473
- if (rendered !== undefined) process.stdout.write(`${rendered}\n`);
474
- }
475
- if (read > 0) lastChange = Date.now();
476
- }
477
-
478
- // Re-read this exact run every poll — not `latestRun`, which would jump
479
- // to a retry started meanwhile and report its state against the wrong
480
- // transcript. The daemon writes the row from another process, so looking
481
- // is the only way to notice the run finished.
482
- const state = store.getRun(run.id)?.state ?? run.state;
483
- if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= TAIL_QUIET_MS) {
484
- process.stdout.write(`run ended: ${state}\n`);
485
- return;
486
- }
487
- await new Promise<void>((resolve) => setTimeout(resolve, TAIL_POLL_MS));
488
- }
489
- } finally {
490
- closeSync(fd);
491
- }
492
- } finally {
493
- store.close();
494
- }
495
- }
496
-
497
394
  const argv = process.argv.slice(2);
498
395
  const cmd = argv[0];
499
396
  /**
@@ -503,1189 +400,111 @@ const cmd = argv[0];
503
400
  * `ProjectConfig` under that name.
504
401
  */
505
402
  const projectFlag = flag(argv, "project");
403
+ /**
404
+ * The parsed essentials every command receives. Argument parsing lives above
405
+ * (and behind) this object; a command module reads what it needs and never
406
+ * imports this entry point, so no verb depends on another's edits here beyond
407
+ * the table line that names it.
408
+ */
409
+ const ctx: CommandContext = {
410
+ argv,
411
+ projectFlag,
412
+ flag: (name) => flag(argv, name),
413
+ portFlag: () => portFlag(argv),
414
+ turnsFlag: () => turnsFlag(argv),
415
+ digestIdsFlag: (name) => digestIdsFlag(argv, name),
416
+ issueArg: (verb, raw) => issueArg(verb, raw),
417
+ targetProjects: () => targetProjects(),
418
+ };
506
419
 
507
420
  function targetProjects(): ProjectConfig[] {
508
- const cfg = loadConfig();
509
- if (argv.includes("--all")) return cfg.projects;
510
- if (projectFlag === undefined && cfg.projects.length > 1) {
511
- throw new Error(
512
- `config has ${cfg.projects.length} projects; use --project NAME or --all`,
513
- );
514
- }
515
- return [findProject(cfg, projectFlag)];
421
+ // The fleet resolution, delegated to the scope machinery (#514) so the
422
+ // arm/disarm/hold/resume/stop "every project, or the named one" shape is
423
+ // the same code the classification describes it as.
424
+ return resolveProjectsByScope(loadConfig(), "fleet", projectFlag, argv.includes("--all"));
516
425
  }
517
426
 
518
- try {
519
- switch (cmd) {
520
- case "--version":
521
- case "-V":
522
- case "version":
523
- process.stdout.write(`${packageVersion()}\n`);
524
- break;
525
- case "setup": {
526
- const sub = argv[1];
527
- const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined;
528
- // The terminal surface owns stdin for whichever path runs, and is released
529
- // in a finally: a throw with readline still attached leaves the operator's
530
- // shell without an echo.
531
- const ui = terminalUi();
532
- try {
533
- // `host` and `graph` are install subcommands, checked BEFORE the amend
534
- // areas. They are not areas — routing them through the area validation
535
- // is how `setup host` came to exit 2 as an "unknown setup area".
536
- if (positional === "host" || positional === "graph") {
537
- const cfg = loadConfig();
538
- const project = findProject(cfg, projectFlag);
539
- const outcome =
540
- positional === "host"
541
- ? await runHostInstall(project, resolveCaps(project, cfg.defaults), telegramStateDir(), ui)
542
- : await runGraphInstall(project, ui, {
543
- noSeed: argv.includes("--no-seed"),
544
- print: argv.includes("--print"),
545
- });
546
- // `staged` is a success on a host that has no systemd: the files are
547
- // real, only the enable step is impossible.
548
- if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
549
- break;
550
- }
551
- let area: AmendAreaId | undefined;
552
- if (positional !== undefined) {
553
- if (!(AMEND_AREA_IDS as readonly string[]).includes(positional)) {
554
- process.stderr.write(
555
- `omp-conductor: unknown setup argument "${positional}" — ` +
556
- `install subcommands are host, graph; amend areas are ${AMEND_AREA_IDS.join(", ")}\n`,
557
- );
558
- process.exit(2);
559
- }
560
- area = positional as AmendAreaId;
561
- }
562
- // `--no-ai` is the interview with its reading half removed: every question
563
- // is still asked, nothing is proposed. For a host with no omp peer, a
564
- // private repo no probe can clone, or an operator who would rather type the
565
- // gates than review a model's reading of their CI.
566
- await setup(ui, projectFlag, area, argv.includes("--no-ai") ? NO_PROBES : DEFAULT_PROBES);
567
- } finally {
568
- ui.close();
569
- }
570
- break;
571
- }
572
- case "upgrade": {
573
- const result = await upgradeConductor({
574
- version: flag(argv, "to"),
575
- project: projectFlag,
576
- });
577
- process.stdout.write(
578
- `${result.alreadyCurrent ? "already current" : "upgrade complete"}:\n` +
579
- ` Bun-global CLI omp-conductor@${result.version}\n` +
580
- ` omp plugin omp-conductor@${result.version}\n` +
581
- ` Herdr plugin herdr-conductor@${result.gitHead}\n` +
582
- ` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
583
- ` dispatch ${result.dispatch}${result.alreadyCurrent ? "" : " restored"}\n`,
584
- );
585
- break;
586
- }
587
- case "daemon": {
588
- // Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
589
- // pidfile, which left a daemon started in the foreground (which is how
590
- // systemd runs it) invisible twice over: `omp-conductor status` reported
591
- // no daemon at all, and a `daemon --once` drill run beside it saw
592
- // `livingDaemon() === undefined`, concluded nothing else was dispatching,
593
- // and reconciled the live daemon's in-flight runs as orphans. Writing the
594
- // record here closes both holes.
595
- const once = argv.includes("--once");
596
- const port = portFlag(argv);
597
- const project = projectFlag;
598
-
599
- // `--once` registers nothing, on purpose. It is precisely the single-tick
600
- // drill the orphan guard exists to protect, so a drill that announced
601
- // itself as the daemon would be the process that misleads the next reader
602
- // — and would clear the real daemon's record on its way out.
603
- if (once) {
604
- await runDaemon({ once, port, project });
605
- break;
606
- }
607
-
608
- const running = livingDaemon();
609
- if (running !== undefined && running.pid !== process.pid) {
610
- process.stderr.write(`omp-conductor: another daemon is alive (pid ${running.pid}); stop it first\n`);
611
- process.exit(1);
612
- }
613
-
614
- // A living record that already names this pid was written by the `start`
615
- // that spawned us, and it knows the log file our stdout is really going
616
- // to. Replacing it with a guess would be a downgrade.
617
- if (running === undefined) {
618
- writeRecord({
619
- pid: process.pid,
620
- port: port ?? DEFAULT_PORT,
621
- startedAt: Date.now(),
622
- logFile: FOREGROUND_LOG,
623
- ...(project === undefined ? {} : { project }),
624
- });
625
- }
626
-
627
- try {
628
- await runDaemon({ once, port, project });
629
- } finally {
630
- // The record names a pid that is about to stop existing. Leaving it
631
- // behind makes the next reader probe a ghost before believing us.
632
- clearRecord();
633
- }
634
- break;
635
- }
636
-
637
- case "start": {
638
- const project = projectFlag;
639
- const herdr = startHerdrFleet(project);
640
- const rec = await startDaemon({ port: portFlag(argv), project });
641
- process.stdout.write(
642
- `started — pid ${rec.pid}, /healthz on :${rec.port}` +
643
- `${rec.project === undefined ? "" : `, project ${rec.project}`}\n` +
644
- `herdr ${herdr.kind === "active" ? `active (${herdr.unit})${herdr.recoveryReleased ? "; recovery pin cleared" : ""}` : `unmanaged (${herdr.reason})`}\n` +
645
- `log ${rec.logFile}\n`,
646
- );
647
- break;
648
- }
649
-
650
- // `stop` absorbs the old `halt`: one word for "stop the conductor", instead of
651
- // an operator holding in their head that `halt` was `hold` plus stopping the
652
- // daemon while `stop` killed only the process. Bouncing the daemon is
653
- // `restart`, which is what the removed kill-only `stop` was reached for.
654
- case "stop": {
655
- const withPane = argv.includes("--pane");
656
- const targets = targetProjects().map((project) => {
657
- // `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a
658
- // `hold` affordance. Assert the invariant instead of printing a path that
659
- // might not exist.
660
- const held = hold(project.name, "halt");
661
- if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
662
- return {
663
- project,
664
- hold: { ...held, disarmed: held.disarmed },
665
- pin: withPane ? pinPaneHalt(project.name).path : undefined,
666
- };
667
- });
668
- const stop = await stopDaemon();
669
- const stopLine =
670
- stop.kind === "not-running"
671
- ? "daemon was not running"
672
- : `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
673
- for (const target of targets) {
674
- if (target.pin !== undefined) {
675
- const pane = await stopConductorPane(target.project.name);
676
- process.stdout.write(
677
- `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
678
- `${stopLine}\n` +
679
- `pane recovery pinned at ${target.pin}\n` +
680
- `pane stop: ${pane.stopped} — ${pane.detail}\n` +
681
- ` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
682
- ` resume clears the pin when you want recovery again)\n`,
683
- );
684
- } else {
685
- process.stdout.write(
686
- `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
687
- `${stopLine}\n` +
688
- `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
689
- );
690
- }
691
- }
692
- break;
693
- }
694
-
695
- case "restart": {
696
- if (argv.includes("--now")) {
697
- // Inherit the running daemon's port and project: a restart that quietly
698
- // moved to the default port would leave every existing health check
699
- // pointing at nothing. When the unit owns the live pid, restartDaemon
700
- // goes through systemctl so the replacement stays supervised; a failed
701
- // installed unit is reset and started through systemd the same way and
702
- // is never replaced by an unmanaged daemon. Success is only reported
703
- // after the manager is proven to own the reported pid.
704
- const { previous, record, via } = await restartDaemon({
705
- port: portFlag(argv),
706
- project: projectFlag,
707
- });
708
- if (previous !== undefined) {
709
- process.stdout.write(
710
- `stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
711
- );
712
- }
713
- process.stdout.write(
714
- `started — pid ${record.pid}, /healthz on :${record.port}` +
715
- `${record.project === undefined ? "" : `, project ${record.project}`}` +
716
- `${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
717
- );
718
- break;
719
- }
720
-
721
- // Default: drain the fleet before restarting — pause new claims, wait for
722
- // live workers to reach 0/N (bounded by --timeout), restart, then restore
723
- // the prior dispatch state. A timed-out drain restarts nothing and leaves
724
- // dispatch paused, so it is safe to run while the daemon is wedged.
725
- const timeoutRaw = flag(argv, "timeout");
726
- const timeoutSeconds =
727
- timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
728
- const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
729
- let result!: RestartResult;
730
- const deps: UpgradeDeps = {
731
- ...DEFAULT_DEPS,
732
- setPaused: (v, project) =>
733
- setPaused(v, { source: "restart", reason: "restart, draining" }, project),
734
- restartDaemon: async () => {
735
- result = await restartDaemon({
736
- port: portFlag(argv),
737
- project: projectFlag,
738
- });
739
- },
740
- };
741
- try {
742
- await drainAndRestart(deps, { project: projectFlag, timeoutMs });
743
- } catch (err) {
744
- process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
745
- process.exit(1);
746
- }
747
- if (result.previous !== undefined) {
748
- process.stdout.write(
749
- `stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
750
- );
751
- }
752
- process.stdout.write(
753
- `started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
754
- `${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
755
- `${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
756
- );
757
- break;
758
- }
759
-
760
- case "status": {
761
- const project = projectFlag;
762
- const text = await renderStatus(project);
763
- const stalled = stallLine();
764
- process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
765
- break;
766
- }
767
-
768
- /**
769
- * The action ledger: mediated conductor verbs plus durable per-issue budget
770
- * changes. Its own command as well as a block in `status`, because the two
771
- * questions are different sizes. `status` answers "what is pending or being
772
- * refused now"; this answers "what did run 3 actually try, and what budget
773
- * did the operator assign", which needs history rather than only live state.
774
- */
775
- case "ledger": {
776
- const cfg = loadConfig();
777
- const p = findProject(cfg, projectFlag);
778
- const issueFlag = flag(argv, "issue");
779
- const issue = issueFlag === undefined ? undefined : issueArg("ledger", issueFlag);
780
- const limitFlag = flag(argv, "limit");
781
- const limit = limitFlag === undefined ? 50 : Number.parseInt(limitFlag, 10);
782
- if (!Number.isSafeInteger(limit) || limit < 1) {
783
- process.stderr.write(`omp-conductor: ledger --limit needs a positive integer, got "${limitFlag ?? ""}"\n`);
784
- process.exitCode = 1;
785
- break;
786
- }
787
- // Read-only, WAL, same as `tail`: this never contends with the daemon
788
- // writing the rows it is printing.
789
- const store = openStore(dbPath());
790
- try {
791
- const entries = store.verbLedger(p.name, {
792
- ...(issue === undefined ? {} : { issue }),
793
- limit,
794
- });
795
- const overrides = store.turnOverrideLedger(p.name, {
796
- ...(issue === undefined ? {} : { issue }),
797
- limit,
798
- });
799
- if (entries.length === 0 && overrides.length === 0) {
800
- process.stdout.write(
801
- `no conductor actions recorded for ${p.name}` +
802
- `${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
803
- );
804
- break;
805
- }
806
- const blocks: string[] = [];
807
- if (entries.length > 0) {
808
- const refused = entries.filter((e) => e.decision === "refused").length;
809
- blocks.push(
810
- `${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
811
- entries.flatMap(formatVerbLedgerEntry).join("\n"),
812
- );
813
- }
814
- if (overrides.length > 0) {
815
- blocks.push(
816
- `${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
817
- overrides
818
- .map(
819
- (entry) =>
820
- ` ${new Date(entry.setAt).toISOString()} #${entry.issue} ` +
821
- `${entry.maxTurns} turns`,
822
- )
823
- .join("\n"),
824
- );
825
- }
826
- process.stdout.write(`${blocks.join("\n\n")}\n`);
827
- } finally {
828
- store.close();
829
- }
830
- break;
831
- }
832
-
833
- case "board": {
834
- const project = projectFlag;
835
- // Headless one-shot mode: `--json` asks for it explicitly, and a
836
- // non-interactive stdin or stdout (pipe, redirect, cron, script) cannot
837
- // host the key-driven board anyway. The TTY throw inside runBoard stays
838
- // as a backstop for direct callers.
839
- if (argv.includes("--json") || !process.stdout.isTTY || !process.stdin.isTTY) {
840
- process.stdout.write(`${boardJson(await boardSnapshotOnce(project))}\n`);
841
- break;
842
- }
843
- await runBoard(project);
844
- break;
845
- }
846
-
847
- case "hold": {
848
- const keepTicks = argv.includes("--keep-ticks");
849
- for (const project of targetProjects()) {
850
- const r = hold(project.name, "hold", keepTicks ? { keepTicks: true } : {});
851
- process.stdout.write(
852
- `held — claiming paused` +
853
- `${r.wasPaused ? " (already paused)" : ""}` +
854
- (r.disarmed === undefined
855
- ? "; ticks left armed — resume restores the fleet with no new arm challenge"
856
- : `; ticks disarmed at ${r.disarmed.path}` +
857
- `${r.disarmed.wasArmed ? "" : " (was already disarmed)"}`) +
858
- `\ndaemon and pane left running; stop to stop the daemon too\n`,
859
- );
860
- }
861
- break;
862
- }
863
-
864
- // Removed verbs keep a case rather than falling through to usage: an operator
865
- // who typed the old word gets the new one, not a wall of help text. Exit 2 is
866
- // the same code an unknown verb uses.
867
- case "halt":
868
- process.stderr.write('omp-conductor: "halt" is now "stop" — see help\n');
869
- process.exit(2);
870
- break;
871
-
872
- case "pause":
873
- process.stderr.write(
874
- 'omp-conductor: "pause" is gone — "hold" pauses claiming AND disarms ticks; see help\n',
875
- );
876
- process.exit(2);
877
- break;
878
-
879
- case "release-pane":
880
- process.stderr.write('omp-conductor: "release-pane" is now part of "resume" — see help\n');
881
- process.exit(2);
882
- break;
883
-
884
- case "graph-setup":
885
- process.stderr.write('omp-conductor: "graph-setup" is now "setup graph" — see help\n');
886
- process.exit(2);
887
- break;
888
-
889
- case "arm": {
890
- for (const project of targetProjects()) {
891
- process.stdout.write("arm: sending inbound Telegram challenge…\n");
892
- const r = await armTicks(project.name);
893
- process.stdout.write(
894
- `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
895
- `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
896
- );
897
- }
898
- break;
899
- }
900
-
901
- case "disarm": {
902
- for (const project of targetProjects()) {
903
- const r = disarmTicks(project.name);
904
- process.stdout.write(
905
- `disarmed — ticks will be skipped` +
906
- `${r.wasArmed ? "" : " (was already disarmed)"}\n` +
907
- `marker ${r.path}\n`,
908
- );
909
- }
910
- break;
911
- }
912
-
913
- case "tail": {
914
- const issue = issueArg("tail", argv[1]);
915
- await tailRun(findProject(loadConfig(), projectFlag).name, issue);
916
- break;
917
- }
918
-
919
- case "extend": {
920
- const issue = issueArg("extend", argv[1]);
921
- const maxTurns = turnsFlag(argv);
922
- const project = findProject(loadConfig(), projectFlag);
923
- const daemon = livingDaemon();
924
- if (daemon === undefined) throw new Error("daemon is not running");
925
- if (daemon.project !== undefined && daemon.project !== project.name) {
926
- throw new Error(
927
- `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
928
- );
929
- }
930
- const response = await fetch(
931
- `http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
932
- {
933
- method: "PUT",
934
- headers: { "content-type": "application/json" },
935
- body: JSON.stringify({ project: project.name, maxTurns }),
936
- },
937
- );
938
- const payload = (await response.json()) as {
939
- error?: unknown;
940
- kind?: unknown;
941
- runId?: unknown;
942
- maxTurns?: unknown;
943
- issue?: unknown;
944
- nextAttemptMaxTurns?: unknown;
945
- baseMaxTurns?: unknown;
946
- };
947
- if (!response.ok) {
948
- throw new Error(
949
- typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
950
- );
951
- }
952
- if (
953
- payload.kind === "next-attempt" &&
954
- payload.issue === issue &&
955
- typeof payload.nextAttemptMaxTurns === "number" &&
956
- typeof payload.baseMaxTurns === "number"
957
- ) {
958
- process.stdout.write(
959
- `#${issue} next attempt turn ceiling set to ${payload.nextAttemptMaxTurns} ` +
960
- `(project base ${payload.baseMaxTurns})\n`,
961
- );
962
- break;
963
- }
964
- if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
965
- throw new Error("daemon returned an invalid turn-extension response");
966
- }
967
- process.stdout.write(
968
- `#${issue} turn ceiling extended to ${payload.maxTurns} (run ${payload.runId})\n`,
969
- );
970
- break;
971
- }
972
-
973
- case "worker": {
974
- const sub = argv[1];
975
- if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
976
- process.stderr.write(
977
- "omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
978
- );
979
- process.exit(2);
980
- }
981
- const issue = issueArg("worker", argv[2]);
982
- const rawReason = sub === "stop" ? flag(argv, "reason") : undefined;
983
- const reason = rawReason?.trim().replace(/\s+/g, " ");
984
- if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
985
- process.stderr.write(
986
- "omp-conductor: worker stop needs --reason with 1-500 characters\n",
987
- );
988
- process.exit(2);
989
- }
990
- const project = findProject(loadConfig(), projectFlag);
991
- const daemon = livingDaemon();
992
- if (daemon === undefined) throw new Error("daemon is not running");
993
- if (daemon.project !== undefined && daemon.project !== project.name) {
994
- throw new Error(
995
- `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
996
- );
997
- }
998
- const response = await fetch(
999
- `http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
1000
- {
1001
- method: "PUT",
1002
- headers: { "content-type": "application/json" },
1003
- body: JSON.stringify({
1004
- project: project.name,
1005
- ...(reason === undefined ? {} : { reason }),
1006
- }),
1007
- },
1008
- );
1009
- const payload = (await response.json()) as {
1010
- error?: unknown;
1011
- runId?: unknown;
1012
- phase?: unknown;
1013
- outcome?: unknown;
1014
- state?: unknown;
1015
- reason?: unknown;
1016
- salvageSha?: unknown;
1017
- salvageError?: unknown;
1018
- worktree?: unknown;
1019
- };
1020
- if (!response.ok) {
1021
- throw new Error(
1022
- typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
1023
- );
1024
- }
1025
- if (sub === "stop") {
1026
- if (
1027
- typeof payload.runId !== "string" ||
1028
- typeof payload.state !== "string" ||
1029
- (payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
1030
- ) {
1031
- throw new Error("daemon returned an invalid worker-stop response");
1032
- }
1033
- if (payload.outcome === "already-terminal") {
1034
- process.stdout.write(
1035
- `#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
1036
- );
1037
- break;
1038
- }
1039
- if (typeof payload.reason !== "string") {
1040
- throw new Error("daemon returned an invalid worker-stop response");
1041
- }
1042
- process.stdout.write(
1043
- `#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
1044
- );
1045
- if (typeof payload.salvageSha === "string") {
1046
- process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
1047
- }
1048
- if (typeof payload.salvageError === "string") {
1049
- process.stdout.write(
1050
- `WIP SALVAGE FAILED: ${payload.salvageError}\n` +
1051
- `worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
1052
- );
1053
- }
1054
- break;
1055
- }
1056
- if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
1057
- throw new Error("daemon returned an invalid worker-control response");
1058
- }
1059
- process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
1060
- break;
1061
- }
1062
-
1063
- case "unblock": {
1064
- const issue = issueArg("unblock", argv[1]);
1065
- const cfg = loadConfig();
1066
- const project = findProject(cfg, projectFlag);
1067
- const store = openStore(dbPath());
1068
- try {
1069
- const outcome = await unblockIssue(project, makeTracker(project), store, issue, {
1070
- force: argv.includes("--force"),
1071
- requeue: !argv.includes("--no-requeue"),
1072
- });
1073
- process.stdout.write(`${formatUnblock(issue, outcome, project, resolveCaps(project, cfg.defaults))}\n`);
1074
- // A refusal must not read as success to a script or a board keypress.
1075
- if (outcome.refused !== undefined) process.exitCode = 3;
1076
- } finally {
1077
- store.close();
1078
- }
1079
- break;
1080
- }
1081
-
1082
- /**
1083
- * The external-orchestrator half of the verbs (#167): run any
1084
- * `conductor_*` verb from the CLI, through the daemon's own checks and
1085
- * ledger. An orchestrator that merges, labels or releases through this
1086
- * gets exactly the same refusals a session would and writes the same
1087
- * ledger rows — the raw `gh pr merge` a session reaches for instead is
1088
- * invisible to both.
1089
- *
1090
- * Worker-only verbs (conductor_push, conductor_pr_create) refuse with
1091
- * `role-not-allowed` — correct, and deliberately not special-cased here.
1092
- *
1093
- * Accepted limitation: the daemon's in-process one-merge-per-project slot
1094
- * does not span the daemon and a concurrent CLI merge. The execution-time
1095
- * head re-read (`--match-head-commit` in prMergeVerb) is the cross-process
1096
- * guard, so a CLI merge races a daemon merge exactly as two daemon merges
1097
- * would.
1098
- */
1099
- case "verb": {
1100
- const name = argv[1];
1101
- if (name === undefined || !VERB_NAMES.some((n) => n === name)) {
1102
- process.stderr.write(
1103
- `omp-conductor: unknown verb "${name ?? ""}". Known verbs: ${VERB_NAMES.join(", ")}\n`,
1104
- );
1105
- process.exit(2);
1106
- }
1107
- const cfg = loadConfig();
1108
- const project = findProject(cfg, projectFlag);
1109
- const args: Record<string, string> = {};
1110
- for (let i = 2; i < argv.length; i++) {
1111
- const token = argv[i];
1112
- if (token === "--arg") {
1113
- const pair = argv[i + 1];
1114
- if (pair === undefined || !pair.includes("=")) {
1115
- process.stderr.write(`omp-conductor: verb --arg needs k=v, got "${pair ?? ""}"\n`);
1116
- process.exit(2);
1117
- }
1118
- const eq = pair.indexOf("=");
1119
- args[pair.slice(0, eq)] = pair.slice(eq + 1);
1120
- i++;
1121
- } else if (token === "--project" || token?.startsWith("--project=") === true) {
1122
- // Consumed by `projectFlag` above; both spellings skip here.
1123
- if (token === "--project") i++;
1124
- } else {
1125
- process.stderr.write(
1126
- `omp-conductor: verb: unexpected argument "${token ?? ""}" (pass verb arguments as --arg k=v)\n`,
1127
- );
1128
- process.exit(2);
1129
- }
1130
- }
1131
- const store = openStore(dbPath());
1132
- try {
1133
- const channel: VerbChannel = {
1134
- kind: "orchestrator",
1135
- path: "cli",
1136
- project: project.name,
1137
- role: "orchestrator",
1138
- };
1139
- const reply = await handleVerbCall(
1140
- {
1141
- project: () => findProject(loadConfig(), project.name),
1142
- store,
1143
- tracker: makeTracker(project),
1144
- actions: githubVerbActions(project),
1145
- fleetStop: () =>
1146
- isPaused(project.name)
1147
- ? "claiming is paused for this fleet (omp-conductor hold or stop)"
1148
- : undefined,
1149
- pausedAt: () => pausedAt(project.name),
1150
- log: (m) => process.stderr.write(`${m}\n`),
1151
- now: () => Date.now(),
1152
- chain: { readBaseChain },
1153
- },
1154
- channel,
1155
- { verb: name, args },
1156
- );
1157
- process.stdout.write(`${reply.text}\n`);
1158
- // A refusal must not read as success to a script calling this.
1159
- if (!reply.ok) process.exitCode = 3;
1160
- } finally {
1161
- store.close();
1162
- }
1163
- break;
1164
- }
1165
-
1166
- case "event": {
1167
- if (argv[1] !== "record") {
1168
- process.stderr.write("omp-conductor: event needs the record subcommand\n");
1169
- process.exit(2);
1170
- }
1171
- const category = flag(argv, "category");
1172
- const summary = flag(argv, "summary")?.replace(/\s+/g, " ").trim();
1173
- const evidence = flag(argv, "evidence")?.replace(/\s+/g, " ").trim();
1174
- if (
1175
- category === undefined ||
1176
- !/^[a-z0-9][a-z0-9-]{0,31}$/.test(category) ||
1177
- summary === undefined ||
1178
- summary.length === 0 ||
1179
- summary.length > 240 ||
1180
- summary.startsWith("--") ||
1181
- evidence === undefined ||
1182
- evidence.length === 0 ||
1183
- evidence.length > 500 ||
1184
- evidence.startsWith("--")
1185
- ) {
1186
- process.stderr.write(
1187
- "omp-conductor: event record needs --category with a lowercase slug, --summary (1-240 chars), and --evidence (1-500 chars)\n",
1188
- );
1189
- process.exit(2);
1190
- }
1191
- const rawOccurredAt = flag(argv, "occurred-at");
1192
- const recordedAt = Date.now();
1193
- const occurredAt = rawOccurredAt === undefined ? recordedAt : Date.parse(rawOccurredAt);
1194
- if (!Number.isFinite(occurredAt)) {
1195
- process.stderr.write("omp-conductor: event record --occurred-at needs an ISO timestamp\n");
1196
- process.exit(2);
1197
- }
1198
- const project = findProject(loadConfig(), projectFlag);
1199
- const store = openStore(dbPath());
1200
- try {
1201
- const event = store.recordMaterialEvent({
1202
- project: project.name,
1203
- category,
1204
- summary,
1205
- evidence,
1206
- occurredAt,
1207
- recordedAt,
1208
- });
1209
- process.stdout.write(
1210
- `event ${event.id} recorded for ${project.name} (${event.category}, ${new Date(event.occurredAt).toISOString()}) — nothing sent\n`,
1211
- );
1212
- } finally {
1213
- store.close();
1214
- }
1215
- break;
1216
- }
1217
-
1218
- /**
1219
- * The handover point. Authorship stays with the model; from here the daemon
1220
- * owns delivery, so "I sent the report" stops being a claim the model makes
1221
- * about a tool call it may never have run and becomes a row anyone can read
1222
- * back out of the store (#123).
1223
- */
1224
- case "report": {
1225
- const rawText = flag(argv, "text");
1226
- const body = rawText?.trim();
1227
- if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
1228
- process.stderr.write("omp-conductor: report needs --text with the rendered report\n");
1229
- process.exit(2);
1230
- }
1231
- const rawKind = flag(argv, "kind") ?? "material";
1232
- // Fail closed on an unknown kind rather than defaulting to `material`: a
1233
- // typo'd `--kind diggest` that silently became a material report would
1234
- // bypass the daily-digest guard, which is the one thing the kind is for.
1235
- if (!(REPORT_KINDS as readonly string[]).includes(rawKind)) {
1236
- process.stderr.write(
1237
- `omp-conductor: report --kind must be one of: ${REPORT_KINDS.join(", ")}\n`,
1238
- );
1239
- process.exit(2);
1240
- }
1241
- const kind = rawKind as ReportKind;
1242
- if (kind !== "digest" && (argv.includes("--events") || argv.includes("--notices"))) {
1243
- process.stderr.write(
1244
- "omp-conductor: report --events and --notices are valid only with --kind digest\n",
1245
- );
1246
- process.exit(2);
1247
- }
1248
- const project = findProject(loadConfig(), projectFlag);
1249
- const store = openStore(dbPath());
1250
- try {
1251
- const at = Date.now();
1252
- // #229: the policy decides what may interrupt the phone. A kind the
1253
- // policy defers is refused here rather than silently turning into a
1254
- // page, or a digest going out off-schedule.
1255
- const policy = project.reporting;
1256
- const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
1257
- if (kind === "digest") {
1258
- if (digestPolicy.cadence === "none") {
1259
- process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
1260
- process.exit(2);
1261
- }
1262
- if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) {
1263
- const lastKey = store.lastDigestDedupeKey(project.name);
1264
- const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
1265
- if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) {
1266
- process.stderr.write(
1267
- `omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` +
1268
- `${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`,
1269
- );
1270
- process.exit(2);
1271
- }
1272
- }
1273
- }
1274
- if (kind === "material") {
1275
- const disposition = interruptDisposition(policy, "material", at);
1276
- if (disposition === "digest") {
1277
- process.stderr.write(
1278
- "omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
1279
- );
1280
- process.exit(2);
1281
- }
1282
- if (disposition === "availability") {
1283
- const noticeId = randomUUID();
1284
- store.addHeldNotice({
1285
- id: noticeId,
1286
- project: project.name,
1287
- category: "material",
1288
- summary: body.split("\n", 1)[0]!.slice(0, 240),
1289
- detail: body,
1290
- createdAt: at,
1291
- releaseOnAvailable: true,
1292
- });
1293
- process.stdout.write(
1294
- `held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` +
1295
- "the daemon will include it in the next digest or working-hours catch-up\n",
1296
- );
1297
- break;
1298
- }
1299
- }
1300
- const draft = {
1301
- project: project.name,
1302
- kind,
1303
- body,
1304
- // Only a daily digest is at-most-once. Per-tick digests and material
1305
- // reports describe new outcomes each time, so they carry no daily key.
1306
- ...(kind === "digest" && digestPolicy.cadence === "daily"
1307
- ? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) }
1308
- : {}),
1309
- at,
1310
- };
1311
- const { report, deduped } =
1312
- kind === "digest"
1313
- ? store.enqueueDigestReport(
1314
- draft,
1315
- digestIdsFlag(argv, "events"),
1316
- digestIdsFlag(argv, "notices"),
1317
- )
1318
- : store.enqueueReport(draft);
1319
- process.stdout.write(
1320
- deduped
1321
- ? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
1322
- "the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n"
1323
- : `report ${report.id} queued for ${project.name} (${kind})\n` +
1324
- "the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
1325
- );
1326
- } finally {
1327
- store.close();
1328
- }
1329
- break;
1330
- }
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
- ? // Not "into the topic": a stale topic degrades to the flat chat with
1371
- // its own warning on stderr, and this line must not contradict it.
1372
- `message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
1373
- : `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
1374
- `${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
1375
- "nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
1376
- );
1377
- break;
1378
- }
1379
-
1380
- case "decision": {
1381
- const sub = argv[1];
1382
- const project = findProject(loadConfig(), projectFlag);
1383
- const store = openStore(dbPath());
1384
- try {
1385
- if (sub === "open") {
1386
- const question = flag(argv, "question")?.trim();
1387
- if (question === undefined || question.length === 0 || question.startsWith("--")) {
1388
- process.stderr.write(
1389
- "omp-conductor: decision open needs --question with the question you sent\n",
1390
- );
1391
- process.exit(2);
1392
- }
1393
- const condition = flag(argv, "resolves-when")?.trim();
1394
- if (condition !== undefined && parseCondition(condition) === undefined) {
1395
- process.stderr.write(
1396
- `omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`,
1397
- );
1398
- process.exit(2);
1399
- }
1400
- const blocks = flag(argv, "blocks")?.trim();
1401
- const decision = store.createDecision({
1402
- project: project.name,
1403
- question,
1404
- ...(blocks === undefined || blocks.length === 0 ? {} : { blocks }),
1405
- ...(condition === undefined ? {} : { condition }),
1406
- at: Date.now(),
1407
- });
1408
- process.stdout.write(
1409
- `decision ${decision.id} recorded — resolve with: omp-conductor decision resolve ${decision.id} --answer "..."\n`,
1410
- );
1411
- break;
1412
- }
1413
-
1414
- if (sub === "resolve" || sub === "withdraw") {
1415
- const id = argv[2];
1416
- if (id === undefined || id.startsWith("--")) {
1417
- process.stderr.write(`omp-conductor: decision ${sub} needs the decision id\n`);
1418
- process.exit(2);
1419
- }
1420
- const answered = sub === "resolve";
1421
- const text = answered ? flag(argv, "answer")?.trim() : flag(argv, "reason")?.trim();
1422
- if (answered && (text === undefined || text.length === 0)) {
1423
- process.stderr.write("omp-conductor: decision resolve needs --answer with what was decided\n");
1424
- process.exit(2);
1425
- }
1426
- const ok = store.resolveDecision(
1427
- id,
1428
- answered ? "answered" : "withdrawn",
1429
- text === undefined || text.length === 0 ? "withdrawn" : text,
1430
- Date.now(),
1431
- );
1432
- if (!ok) {
1433
- // A decision that is not open is a different mistake from an id that
1434
- // never existed, and the operator can only act on one of them.
1435
- process.stderr.write(
1436
- `omp-conductor: no open decision ${id} for ${project.name} — it was answered, withdrawn or expired, or the id is wrong\n`,
1437
- );
1438
- process.exit(1);
1439
- }
1440
- process.stdout.write(`decision ${id} ${answered ? "answered" : "withdrawn"}\n`);
1441
- break;
1442
- }
1443
-
1444
- if (sub === "list" || sub === undefined) {
1445
- const open = store.openDecisions(project.name);
1446
- if (open.length === 0) {
1447
- process.stdout.write("no open decisions\n");
1448
- break;
1449
- }
1450
- const now = Date.now();
1451
- for (const d of open) {
1452
- const condition =
1453
- d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
1454
- const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
1455
- process.stdout.write(
1456
- `${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
1457
- );
1458
- }
1459
- break;
1460
- }
1461
-
1462
- process.stderr.write(
1463
- `omp-conductor: unknown decision subcommand "${sub}" — expected open, resolve, withdraw or list\n`,
1464
- );
1465
- process.exit(2);
1466
- } finally {
1467
- store.close();
1468
- }
1469
- break;
1470
- }
1471
-
1472
- case "friction": {
1473
- const name = argv[1] as FrictionFeedbackName | undefined;
1474
- if (name === undefined || !Object.hasOwn(FRICTION_FEEDBACK_KINDS, name)) {
1475
- process.stderr.write(
1476
- "omp-conductor: friction needs one of: escalation-digest, report-noise, report-surprise\n",
1477
- );
1478
- process.exit(2);
1479
- }
1480
- const rawDetail = flag(argv, "detail");
1481
- const detail = rawDetail?.replace(/\s+/g, " ").trim();
1482
- if (
1483
- detail === undefined ||
1484
- detail.length === 0 ||
1485
- detail.length > 160 ||
1486
- rawDetail?.startsWith("--") === true
1487
- ) {
1488
- process.stderr.write("omp-conductor: friction needs --detail with 1-160 characters\n");
1489
- process.exit(2);
1490
- }
1491
- const issueText = flag(argv, "issue");
1492
- const issue = issueText === undefined ? undefined : issueArg("friction --issue", issueText);
1493
- const project = findProject(loadConfig(), projectFlag);
1494
- const store = openStore(dbPath());
1495
- try {
1496
- store.recordFriction(project.name, {
1497
- kind: FRICTION_FEEDBACK_KINDS[name],
1498
- occurrences: 1,
1499
- ...(issue === undefined ? {} : { issue }),
1500
- sample: detail,
1501
- at: Date.now(),
1502
- });
1503
- } finally {
1504
- store.close();
1505
- }
1506
- process.stdout.write(`friction recorded for ${project.name}: ${name} — ${detail}\n`);
1507
- break;
1508
- }
1509
-
1510
- case "resume": {
1511
- for (const project of targetProjects()) {
1512
- releaseHold(project.name);
1513
- const pin = clearPaneHaltIfResolvable(project.name);
1514
- process.stdout.write(
1515
- "resumed — claiming allowed on the next tick\n" +
1516
- (pin.wasHalted
1517
- ? `pane recovery pin cleared — ${pin.path}\n`
1518
- : "no pane recovery pin to clear\n") +
1519
- "ticks stay disarmed — run `omp-conductor arm`\n",
1520
- );
1521
- }
1522
- break;
1523
- }
1524
-
1525
- case "brief-upgrade": {
1526
- const override = flag(argv, "file");
1527
- let project: ProjectConfig | undefined;
1528
- let path: string;
1529
- if (override === undefined) {
1530
- project = findProject(loadConfig(), projectFlag);
1531
- path = briefPathForProject(project);
1532
- } else {
1533
- path = override;
1534
- try {
1535
- project = findProject(loadConfig(), projectFlag);
1536
- } catch {
1537
- project = undefined;
1538
- }
1539
- }
1540
-
1541
- const workspaceRoot = project?.workspaceRoot ?? dirname(path);
1542
- const rendered =
1543
- project === undefined ? shippedBriefTemplate() : renderBriefForProject(project);
1544
- const floor = project === undefined ? shippedBriefTemplate() : renderFloorForProject(project);
1545
- const layout = inspectBriefLayout(workspaceRoot, rendered);
1546
-
1547
- if (argv.includes("--retrofit")) {
1548
- let live: string;
1549
- try {
1550
- live = readFileSync(path, "utf8");
1551
- } catch {
1552
- process.stderr.write(`omp-conductor: no brief at ${path}.\n`);
1553
- process.exit(1);
1554
- }
1555
- const result = proposeRetrofit(live);
1556
- if (result.kind === "no-cut") {
1557
- process.stdout.write(
1558
- `brief ${path}\n\nNo Releases / Project context / Reporting / Amendments heading found — cannot classify a cut.\n`,
1559
- );
1560
- process.exit(1);
1561
- }
1562
- if (result.kind === "interleaved") {
1563
- process.stdout.write(`${formatRetrofitRefusal(path, result)}\n`);
1564
- process.exit(1);
1565
- }
1566
- process.stdout.write(`${formatRetrofitProposal(path, result.proposal)}\n`);
1567
- if (argv.includes("--apply")) {
1568
- const backup = applyRetrofit(path, result.proposal);
1569
- process.stdout.write(`\napplied retrofit — previous brief kept at ${backup}\n`);
1570
- }
1571
- break;
1572
- }
1573
-
1574
- if (argv.includes("--migrate")) {
1575
- if (layout.kind === "overlay") {
1576
- if (!argv.includes("--apply")) {
1577
- process.stdout.write(
1578
- `${formatBriefReport(path, layout, [])}\n\n` +
1579
- "POLICY.md already present. --migrate --apply will strip any leading\n" +
1580
- "banner-comment crumbs from POLICY.md and recompose ORCHESTRATOR.md.\n",
1581
- );
1582
- break;
1583
- }
1584
- if (project === undefined) {
1585
- process.stderr.write(
1586
- "omp-conductor: repairing an overlay needs --project (or a config) so the floor renders.\n",
1587
- );
1588
- process.exit(1);
1589
- }
1590
- const repaired = repairPolicyBannerCrumbs({
1591
- orchestratorPath: layout.orchestratorPath,
1592
- policyPath: layout.policyPath,
1593
- floor: renderFloorForProject(project),
1594
- });
1595
- if (repaired === undefined) {
1596
- process.stdout.write(
1597
- `${formatBriefReport(path, layout, [])}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
1598
- );
1599
- } else {
1600
- process.stdout.write(`${formatMigrateResult(repaired)}\n`);
1601
- }
1602
- break;
1603
- }
1604
- if (layout.kind === "missing") {
1605
- process.stderr.write(`omp-conductor: no brief at ${path} to migrate.\n`);
1606
- process.exit(1);
1607
- }
1608
- if (layout.kind === "legacy-handwritten") {
1609
- process.stdout.write(`${formatBriefReport(path, layout, layout.missing)}\n`);
1610
- process.exit(1);
1611
- }
1612
- if (project === undefined && /\{\{[A-Za-z0-9_]+\}\}/.test(floor)) {
1613
- process.stderr.write(
1614
- "omp-conductor: --migrate needs --project (or a config) so the floor renders without {{PLACEHOLDER}}s.\n",
1615
- );
1616
- process.exit(1);
1617
- }
1618
- if (!argv.includes("--apply")) {
1619
- process.stdout.write(
1620
- [
1621
- `migrate ${layout.orchestratorPath}`,
1622
- "",
1623
- "Would write POLICY.md from the owned half below YOURS TO EDIT,",
1624
- "then recompose ORCHESTRATOR.md from the package floor + that policy.",
1625
- "",
1626
- "Apply: omp-conductor brief-upgrade --migrate --apply",
1627
- ].join("\n") + "\n",
1628
- );
1629
- break;
1630
- }
1631
- const policyPath = project ? policyPathForProject(project) : join(workspaceRoot, "POLICY.md");
1632
- const result = migrateToPolicy({
1633
- orchestratorPath: layout.orchestratorPath,
1634
- policyPath,
1635
- floor: project ? renderFloorForProject(project) : floor,
1636
- owned: layout.owned,
1637
- });
1638
- process.stdout.write(`${formatMigrateResult(result)}\n`);
1639
- break;
1640
- }
1641
-
1642
- // No `--migrate` / `--retrofit`: report the layout and what to run. The
1643
- // legacy single-file merge that used to live here is gone (#131) — a bare
1644
- // `--apply` now says so rather than silently doing nothing.
1645
- if (argv.includes("--apply")) {
1646
- process.stderr.write(
1647
- "brief-upgrade: the legacy single-file merge was removed in 0.4.3 — use --migrate --apply (bannered) or --retrofit --apply (hand-written)\n",
1648
- );
1649
- process.exit(2);
1650
- }
1651
-
1652
- let missing: string[] = [];
1653
- if (layout.kind !== "overlay" && layout.kind !== "missing") {
1654
- let live: string;
1655
- try {
1656
- live = readFileSync(path, "utf8");
1657
- } catch {
1658
- process.stderr.write(
1659
- `omp-conductor: no brief at ${path}` +
1660
- `${override === undefined ? " — run `omp-conductor setup brief` and say yes to writing ORCHESTRATOR.md." : "."}\n`,
1661
- );
1662
- process.exit(1);
1663
- }
1664
- missing = missingSections(live, rendered);
1665
- }
1666
-
1667
- process.stdout.write(`${formatBriefReport(path, layout, missing)}\n`);
1668
- if (layout.kind !== "overlay" && project === undefined) {
1669
- process.stdout.write(
1670
- "\nnote: no conductor config resolved on this host, so the template's\n" +
1671
- "{{PLACEHOLDER}} coordinates are unsubstituted. Section names are unaffected.\n",
1672
- );
1673
- }
1674
- break;
1675
- }
427
+ /**
428
+ * Removed verbs keep an entry rather than falling through to usage: an operator
429
+ * who typed the old word gets the new one, not a wall of help text. Exit 2 is
430
+ * the same code an unknown verb uses. Each is a two-line pointer with its own
431
+ * fixed text and nothing to grow, so they stay as entries here instead of a
432
+ * file per alias — the #462 acceptance carve-out for two-line cases.
433
+ */
434
+ const REMOVED: Record<string, string> = {
435
+ halt: '"halt" is now "stop" — see help',
436
+ pause: '"pause" is gone — "hold" pauses claiming AND disarms ticks; see help',
437
+ "release-pane": '"release-pane" is now part of "resume" — see help',
438
+ "graph-setup": '"graph-setup" is now "setup graph" — see help',
439
+ };
1676
440
 
1677
- case "help":
1678
- case "--help":
1679
- case "-h":
1680
- process.stdout.write(`${USAGE}\n`);
1681
- break;
441
+ /**
442
+ * The registration table — verb → handler. Adding an operator subcommand is one
443
+ * module in ./commands/ plus one line here; nothing else about dispatch
444
+ * changes. Version and help are tiny enough that their handlers are single
445
+ * statements, but they still ride the table so every surface is nameable.
446
+ */
447
+ const COMMANDS: Record<string, CommandHandler> = {
448
+ "--version": () => versionCommand(),
449
+ "-V": () => versionCommand(),
450
+ version: () => versionCommand(),
451
+ setup: () => setupCommand(ctx),
452
+ upgrade: () => upgradeCommand(ctx),
453
+ "upgrade-install": () => upgradeInstallCommand(ctx),
454
+ "upgrade-rollback": () => upgradeRollbackCommand(ctx),
455
+ daemon: () => daemonCommand(ctx),
456
+ start: () => startCommand(ctx),
457
+ stop: () => stopCommand(ctx),
458
+ restart: () => restartCommand(ctx),
459
+ status: () => statusCommand(ctx),
460
+ stats: () => statsCommand(ctx),
461
+ doctor: () => doctorCommand(ctx),
462
+ ledger: () => ledgerCommand(ctx),
463
+ board: () => boardCommand(ctx),
464
+ dashboard: () => dashboardCommand(ctx),
465
+ hold: () => holdCommand(ctx),
466
+ arm: () => armCommand(ctx),
467
+ disarm: () => disarmCommand(ctx),
468
+ tail: () => tailCommand(ctx),
469
+ extend: () => extendCommand(ctx),
470
+ worker: () => workerCommand(ctx),
471
+ unblock: () => unblockCommand(ctx),
472
+ verb: () => verbCommand(ctx),
473
+ event: () => eventCommand(ctx),
474
+ report: () => reportCommand(ctx),
475
+ message: () => messageCommand(ctx),
476
+ decision: () => decisionCommand(ctx),
477
+ intake: () => intakeCommand(ctx),
478
+ friction: () => frictionCommand(ctx),
479
+ resume: () => resumeCommand(ctx),
480
+ "brief-upgrade": () => briefUpgradeCommand(ctx),
481
+ help: () => helpCommand(USAGE),
482
+ "--help": () => helpCommand(USAGE),
483
+ "-h": () => helpCommand(USAGE),
484
+ };
1682
485
 
1683
- default:
1684
- process.stderr.write(
1685
- `${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`,
1686
- );
1687
- process.exit(2);
486
+ try {
487
+ const removed = cmd === undefined ? undefined : REMOVED[cmd];
488
+ if (removed !== undefined) {
489
+ process.stderr.write(`omp-conductor: ${removed}\n`);
490
+ process.exit(2);
491
+ }
492
+ const handler = cmd === undefined ? undefined : COMMANDS[cmd];
493
+ if (handler === undefined) {
494
+ process.stderr.write(
495
+ `${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`,
496
+ );
497
+ process.exit(2);
498
+ }
499
+ // A "none"-scope verb does no per-project work, so --project cannot change
500
+ // what it does: refuse it with a reason instead of silently ignoring it
501
+ // (#514). Host-scoped verbs keep the flag because each one has a documented
502
+ // per-project tail (or already refuses to narrow, #389).
503
+ if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) {
504
+ process.stderr.write(`omp-conductor: ${cmd} does not take a project — it needs no --project\n`);
505
+ process.exit(2);
1688
506
  }
507
+ await handler(ctx);
1689
508
  } catch (err) {
1690
509
  // Config, lifecycle and `gh` errors are written to be read by a human, so
1691
510
  // surface the message rather than a stack.