omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -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 +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  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 +49 -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 +98 -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 +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -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 +23 -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 +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/cli.ts CHANGED
@@ -1,101 +1,49 @@
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";
12
- 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
- }
11
+ import { findProject, 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";
46
+ import type { CommandContext, CommandHandler } from "./commands/context.ts";
99
47
 
100
48
  const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
101
49
 
@@ -106,8 +54,13 @@ usage:
106
54
  omp-conductor stop [--pane] [--project NAME | --all]
107
55
  omp-conductor restart [--now] [--timeout SECONDS] [--port N] [--project NAME]
108
56
  omp-conductor upgrade [--to VERSION] [--project NAME]
57
+ omp-conductor upgrade-install --to VERSION [--project NAME]
58
+ omp-conductor upgrade-rollback [--project NAME]
109
59
  omp-conductor board [--project NAME] [--json]
60
+ omp-conductor dashboard [--port N] [--host ADDR]
110
61
  omp-conductor status [--project NAME]
62
+ omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]
63
+ omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
111
64
  omp-conductor ledger [--issue N] [--limit N] [--project NAME]
112
65
  omp-conductor hold [--keep-ticks] [--project NAME | --all]
113
66
  omp-conductor arm [--project NAME | --all]
@@ -124,12 +77,15 @@ usage:
124
77
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
125
78
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
126
79
  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]
80
+ omp-conductor report --text TEXT [--kind material|digest|tier2|decision-needed|fleet-stopped|confirmed-failure] [--events IDS] [--notices IDS] [--project NAME]
128
81
  omp-conductor message --text TEXT [--project NAME]
129
82
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
130
83
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
131
84
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
132
85
  omp-conductor decision list [--project NAME]
86
+ omp-conductor intake "<text>" [--project NAME]
87
+ omp-conductor intake list [--project NAME]
88
+ omp-conductor intake dismiss <id> [--project NAME]
133
89
  omp-conductor help
134
90
 
135
91
  setup interview, then write config.json, the labels, the briefs and the
@@ -141,6 +97,15 @@ usage:
141
97
  brief as one pinned release. Pauses only new claims, drains live
142
98
  workers, reloads, verifies twice, and restores the prior dispatch
143
99
  state. Run it from a shell outside the target Herdr session.
100
+ upgrade-install
101
+ the detached executor half of the fleet-installs-itself request:
102
+ the same transaction, journaled per surface, leaving verification
103
+ and dispatch restore to the first tick after the restart. Run by
104
+ the transient unit, never by hand in a Herdr session.
105
+ upgrade-rollback
106
+ the detached rollback unit: restore every surface the failed
107
+ upgrade-install touched, from the durable pre-install snapshot in
108
+ the upgrade journal, and report through the durable outbox.
144
109
  start start the installed herdr-fleet.service when present, then run the
145
110
  dispatch loop in the background and wait until it answers GET
146
111
  /healthz on :8787 (override with --port). Refuses if one is running.
@@ -166,11 +131,34 @@ usage:
166
131
  status layered fleet report: dispatch (running|paused|stopped), ticks and
167
132
  next due time, pane, herdr, Telegram bot/API health, daemon, caps
168
133
  and active runs.
134
+ stats what the fleet accomplished and at what cost, read wholly from the
135
+ local store: issues merged, merge rate, queue-to-merge lead time
136
+ (median/p90), attempts per merged issue, metered spend per merged
137
+ issue, the failure-class breakdown of what did not merge, and
138
+ tracked gh calls over the window. Continuation chains collapse into
139
+ one journey; $0.00 runs are reported as unmetered, never averaged in
140
+ as free. --json prints the stable {project, window, ghCalls, empty,
141
+ total, repos} shape.
142
+ doctor read-only deployment health, one finding per past failure mode:
143
+ gh auth/scopes, exact-case configured labels, systemd unit drift
144
+ and runtime-dir ownership, config + backup freshness, sqlite
145
+ integrity, spend telemetry, reporting timezones, Telegram health.
146
+ The only write anywhere is the self-identified probe message that
147
+ --probe-telegram sends through the report transport. Exit code 0
148
+ only when nothing failed; --json prints the stable CI shape. Run
149
+ it after install and after every upgrade.
169
150
  board open the live keyboard-driven fleet board. It renders queue holds,
170
151
  every run lifecycle stage, recent merges, spend and health; Enter
171
152
  follows a selected transcript without leaving the board. --json (or
172
153
  a non-interactive stdin/stdout) prints a one-shot JSON snapshot of
173
154
  the same lanes instead.
155
+ dashboard
156
+ serve the fleet dashboard in a browser: the UI shell and GET
157
+ /api/projects, bearer-authenticated with the token minted 0600 at
158
+ <state>/dashboard-token on first start. Binds 127.0.0.1:8788 by
159
+ default; a non-loopback --host still starts but prints a warning
160
+ naming the token file. A separate process — never a route on the
161
+ daemon port.
174
162
  ledger every conductor-verb call and how the daemon decided it: the verb,
175
163
  the arguments, allow or refuse, the named refusal reason, and the
176
164
  resulting sha. Sessions cannot push, open, merge, label or release
@@ -223,7 +211,12 @@ usage:
223
211
  be a repeat. --kind digest is accepted at most once per local day,
224
212
  decided from the ledger rather than from what you remember sending.
225
213
  A digest associates the comma-separated --events and --notices rows
226
- atomically; omitted rows stay owed.
214
+ atomically; omitted rows stay owed. The other --kind values (tier2,
215
+ decision-needed, fleet-stopped, confirmed-failure) declare the
216
+ report's interrupt category: the reporting policy decides between
217
+ sending now and holding, exactly as for a daemon escalation. An
218
+ identical retry is refused only while the earlier handoff is still
219
+ undelivered, and admitted again once it lands (#453).
227
220
  message deliver one direct Telegram message to this project's own chat and
228
221
  forum topic, resolved from config rather than from whichever chat
229
222
  last wrote to the session. This is how a locally injected tick
@@ -243,6 +236,11 @@ usage:
243
236
  decision resolve <id> --answer TEXT
244
237
  decision withdraw <id> [--reason TEXT]
245
238
  decision list
239
+ intake keep a raw idea durably before it becomes anything: record it now
240
+ with \`omp-conductor intake "<text>"\`, list what is still pending,
241
+ dismiss what turned out to be nothing. Backed by the sqlite store,
242
+ not a session, so ideas survive restarts; grooming one into an issue
243
+ happens behind the scenes, not here.
246
244
  friction record a bounded observation the daemon cannot classify itself:
247
245
  an escalation that belonged in a digest, or a tick report that was
248
246
  noise/surprising. Repeated observations feed the existing Learning
@@ -287,7 +285,7 @@ Stop the conductor:
287
285
  stop --pane stop + pin conductor-pane recovery off
288
286
  resume && arm clear pause and any pane pin, then prove inbound Telegram`;
289
287
 
290
- /** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
288
+ /** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
291
289
  function flag(argv: string[], name: string): string | undefined {
292
290
  const i = argv.indexOf(`--${name}`);
293
291
  if (i >= 0) return argv[i + 1];
@@ -305,9 +303,19 @@ function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] {
305
303
  return [];
306
304
  }
307
305
  const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))];
306
+ // The three shapes the runtime actually mints as ledger row ids: 12-hex
307
+ // material event ids, UUID held-notice ids (quiet-hours material holds,
308
+ // operator messages), and 64-hex sha256 held-notice ids (escalate and the
309
+ // orchestrator tick). Anything else is a typo; refusing it is what keeps a
310
+ // mistyped id from silently claiming nothing.
308
311
  if (
309
312
  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))
313
+ ids.some(
314
+ (id) =>
315
+ !/^(?:[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(
316
+ id,
317
+ ),
318
+ )
311
319
  ) {
312
320
  process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`);
313
321
  process.exit(2);
@@ -352,53 +360,6 @@ function humanDuration(ms: number): string {
352
360
  return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`;
353
361
  }
354
362
 
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
363
  /**
403
364
  * The `<issue>` positional, for the two verbs that take one. Exits 2 rather
404
365
  * than following run #NaN or clearing the labels of issue #0; `verb` is named
@@ -414,86 +375,6 @@ function issueArg(verb: string, raw: string | undefined): number {
414
375
  }
415
376
 
416
377
 
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
378
  const argv = process.argv.slice(2);
498
379
  const cmd = argv[0];
499
380
  /**
@@ -503,6 +384,22 @@ const cmd = argv[0];
503
384
  * `ProjectConfig` under that name.
504
385
  */
505
386
  const projectFlag = flag(argv, "project");
387
+ /**
388
+ * The parsed essentials every command receives. Argument parsing lives above
389
+ * (and behind) this object; a command module reads what it needs and never
390
+ * imports this entry point, so no verb depends on another's edits here beyond
391
+ * the table line that names it.
392
+ */
393
+ const ctx: CommandContext = {
394
+ argv,
395
+ projectFlag,
396
+ flag: (name) => flag(argv, name),
397
+ portFlag: () => portFlag(argv),
398
+ turnsFlag: () => turnsFlag(argv),
399
+ digestIdsFlag: (name) => digestIdsFlag(argv, name),
400
+ issueArg: (verb, raw) => issueArg(verb, raw),
401
+ targetProjects: () => targetProjects(),
402
+ };
506
403
 
507
404
  function targetProjects(): ProjectConfig[] {
508
405
  const cfg = loadConfig();
@@ -515,1177 +412,79 @@ function targetProjects(): ProjectConfig[] {
515
412
  return [findProject(cfg, projectFlag)];
516
413
  }
517
414
 
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
- }
415
+ /**
416
+ * Removed verbs keep an entry rather than falling through to usage: an operator
417
+ * who typed the old word gets the new one, not a wall of help text. Exit 2 is
418
+ * the same code an unknown verb uses. Each is a two-line pointer with its own
419
+ * fixed text and nothing to grow, so they stay as entries here instead of a
420
+ * file per alias — the #462 acceptance carve-out for two-line cases.
421
+ */
422
+ const REMOVED: Record<string, string> = {
423
+ halt: '"halt" is now "stop" — see help',
424
+ pause: '"pause" is gone — "hold" pauses claiming AND disarms ticks; see help',
425
+ "release-pane": '"release-pane" is now part of "resume" — see help',
426
+ "graph-setup": '"graph-setup" is now "setup graph" — see help',
427
+ };
1676
428
 
1677
- case "help":
1678
- case "--help":
1679
- case "-h":
1680
- process.stdout.write(`${USAGE}\n`);
1681
- break;
429
+ /**
430
+ * The registration table — verb → handler. Adding an operator subcommand is one
431
+ * module in ./commands/ plus one line here; nothing else about dispatch
432
+ * changes. Version and help are tiny enough that their handlers are single
433
+ * statements, but they still ride the table so every surface is nameable.
434
+ */
435
+ const COMMANDS: Record<string, CommandHandler> = {
436
+ "--version": () => versionCommand(),
437
+ "-V": () => versionCommand(),
438
+ version: () => versionCommand(),
439
+ setup: () => setupCommand(ctx),
440
+ upgrade: () => upgradeCommand(ctx),
441
+ "upgrade-install": () => upgradeInstallCommand(ctx),
442
+ "upgrade-rollback": () => upgradeRollbackCommand(ctx),
443
+ daemon: () => daemonCommand(ctx),
444
+ start: () => startCommand(ctx),
445
+ stop: () => stopCommand(ctx),
446
+ restart: () => restartCommand(ctx),
447
+ status: () => statusCommand(ctx),
448
+ stats: () => statsCommand(ctx),
449
+ doctor: () => doctorCommand(ctx),
450
+ ledger: () => ledgerCommand(ctx),
451
+ board: () => boardCommand(ctx),
452
+ dashboard: () => dashboardCommand(ctx),
453
+ hold: () => holdCommand(ctx),
454
+ arm: () => armCommand(ctx),
455
+ disarm: () => disarmCommand(ctx),
456
+ tail: () => tailCommand(ctx),
457
+ extend: () => extendCommand(ctx),
458
+ worker: () => workerCommand(ctx),
459
+ unblock: () => unblockCommand(ctx),
460
+ verb: () => verbCommand(ctx),
461
+ event: () => eventCommand(ctx),
462
+ report: () => reportCommand(ctx),
463
+ message: () => messageCommand(ctx),
464
+ decision: () => decisionCommand(ctx),
465
+ intake: () => intakeCommand(ctx),
466
+ friction: () => frictionCommand(ctx),
467
+ resume: () => resumeCommand(ctx),
468
+ "brief-upgrade": () => briefUpgradeCommand(ctx),
469
+ help: () => helpCommand(USAGE),
470
+ "--help": () => helpCommand(USAGE),
471
+ "-h": () => helpCommand(USAGE),
472
+ };
1682
473
 
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);
474
+ try {
475
+ const removed = cmd === undefined ? undefined : REMOVED[cmd];
476
+ if (removed !== undefined) {
477
+ process.stderr.write(`omp-conductor: ${removed}\n`);
478
+ process.exit(2);
479
+ }
480
+ const handler = cmd === undefined ? undefined : COMMANDS[cmd];
481
+ if (handler === undefined) {
482
+ process.stderr.write(
483
+ `${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`,
484
+ );
485
+ process.exit(2);
1688
486
  }
487
+ await handler(ctx);
1689
488
  } catch (err) {
1690
489
  // Config, lifecycle and `gh` errors are written to be read by a human, so
1691
490
  // surface the message rather than a stack.