omp-conductor 0.18.1 → 0.19.0

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 (69) hide show
  1. package/README.md +106 -41
  2. package/REFERENCE.md +866 -31
  3. package/agents/to-spec.md +6 -2
  4. package/package.json +1 -1
  5. package/schema/config.schema.json +32 -1
  6. package/src/admission.ts +212 -26
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +288 -1
  9. package/src/briefs/orchestrator.md +27 -13
  10. package/src/briefs/to-spec.md +6 -2
  11. package/src/cli.ts +127 -2
  12. package/src/command-help.ts +9 -1
  13. package/src/command-manifest.ts +52 -8
  14. package/src/commands/arm.ts +6 -2
  15. package/src/commands/context.ts +2 -0
  16. package/src/commands/intake.ts +4 -19
  17. package/src/commands/message.ts +26 -2
  18. package/src/commands/reconcile-units.ts +104 -0
  19. package/src/commands/release-composition.ts +232 -0
  20. package/src/commands/resume.ts +2 -27
  21. package/src/commands/setup.ts +101 -16
  22. package/src/commands/stats.ts +11 -30
  23. package/src/commands/tail.ts +31 -1
  24. package/src/commands/upgrade.ts +20 -3
  25. package/src/commands/verb.ts +2 -1
  26. package/src/commands/watch.ts +4 -17
  27. package/src/config-schema.ts +38 -6
  28. package/src/config.ts +103 -8
  29. package/src/credential-class.ts +366 -0
  30. package/src/daemon.ts +1368 -529
  31. package/src/dashboard/app.js +504 -2
  32. package/src/dashboard/controls.ts +336 -0
  33. package/src/dashboard/index.html +30 -0
  34. package/src/dashboard/server.ts +271 -30
  35. package/src/dashboard/style.css +116 -0
  36. package/src/dashboard/transcript.ts +173 -0
  37. package/src/decisions.ts +19 -11
  38. package/src/doctor.ts +431 -148
  39. package/src/escalate.ts +22 -11
  40. package/src/failure-class.ts +59 -0
  41. package/src/fleet.ts +587 -230
  42. package/src/host.ts +6 -455
  43. package/src/omp-settings.ts +19 -0
  44. package/src/omp.ts +40 -56
  45. package/src/orchestrator-tick.ts +564 -121
  46. package/src/pause.ts +233 -0
  47. package/src/session-host.ts +6 -41
  48. package/src/settlement.ts +159 -2
  49. package/src/setup-answers.ts +97 -0
  50. package/src/setup-host.ts +343 -1160
  51. package/src/setup-install.ts +204 -27
  52. package/src/setup-wizard.ts +252 -51
  53. package/src/setup.ts +87 -4
  54. package/src/spend-telemetry.ts +117 -0
  55. package/src/stats.ts +35 -0
  56. package/src/status-render.ts +485 -19
  57. package/src/store.ts +1229 -55
  58. package/src/telegram-freshness.ts +269 -0
  59. package/src/to-spec.ts +50 -2
  60. package/src/types.ts +759 -10
  61. package/src/unblock.ts +22 -0
  62. package/src/unit-reconcile.ts +303 -0
  63. package/src/upgrade-verify.ts +8 -1
  64. package/src/upgrade.ts +299 -12
  65. package/src/verbs/actions.ts +124 -10
  66. package/src/verbs/protocol.ts +70 -2
  67. package/src/verbs/server.ts +485 -11
  68. package/src/wake.ts +48 -0
  69. package/src/worker.ts +401 -14
@@ -238,11 +238,12 @@ Keep the queue worth draining.
238
238
  When the queue is below the grooming trigger, the tick's **bounded to-spec
239
239
  batch** block (#777) carries the mechanical contract: it names the launch
240
240
  token, the candidates the conductor selected mechanically from the live
241
- open-issue snapshot (parked, parent/epic, already-groomed, in-flight,
242
- lane/dependency-blocked and dispatched issues were excluded by code, never
243
- by your judgement), the exclusions that selection applied, and the item
244
- shape. Do exactly what it says — those candidates are the only batch this
245
- tick authorizes: render `omp/src/briefs/to-spec.md` for each of them with
241
+ open-issue snapshot (parked, parent/epic, already-groomed, refused inside
242
+ their retry cooldown, in-flight, lane/dependency-blocked and dispatched
243
+ issues were excluded by code, never by your judgement), the exclusions that
244
+ selection applied, and the item shape. Do exactly what it says — those
245
+ candidates are the only batch this tick authorizes: render
246
+ `omp/src/briefs/to-spec.md` for each of them with
246
247
  the current source head, launch exactly one `task` batch through the
247
248
  `to-spec` agent with the listed items and no substitutes, and persist every
248
249
  returned result with the `conductor_to_spec_result` tool. The `tool_call`
@@ -254,18 +255,31 @@ Keep the queue worth draining.
254
255
  source/ref/freshness is refused as blocked, never groomed. The quality bar
255
256
  above does not move.
256
257
  - **Every to-spec agent answers the same return contract**, or its output is
257
- refused: the strict schema carries
258
- - verdict `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
258
+ refused: the strict schema carries exactly these fields — the generated task
259
+ schema and the persistence parser are that one contract, so an extra or
260
+ missing field is refused as malformed:
261
+ - `verdict` — `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
259
262
  `NEEDS PRODUCT DECISION`
260
- - routing — exactly one repo, or `MULTI` with the split
261
- - evidencethe file or symbol proving `ALREADY DONE`, never a title match
262
- - entry points — the 3-6 files to change or read first
263
- - existing tests covering the behaviour, by path
264
- - the one thing most likely to be silently faked
265
- - source — where the code was read: the clone/ref and how fresh it is. A
263
+ - `routing` — exactly one `owner/repo`, or `MULTI`
264
+ - `routingSplit`required iff `routing` is `MULTI`: what each slice goes to
265
+ - `source`where the code was read: the clone/ref and how fresh it is. A
266
266
  scout that cannot reach a source it trusts returns `BLOCKED` and says so;
267
267
  silent fallback to an unnamed source is the failure mode of delegated
268
268
  research — stale evidence reads exactly like good evidence.
269
+ - `evidence` — the files/symbols proving the verdict; required for
270
+ `ALREADY DONE` (the file or symbol that already does the work, never a
271
+ title match)
272
+ - `laterWorkInvalidates` — whether later work retired the premise
273
+ - `laterWorkNote` — what that check searched and found
274
+ - `entryPoints` — the 3-6 files to change or read first
275
+ - `existingTests` — existing tests covering the behaviour, by path
276
+ - `likelySilentFake` — the one thing most likely to be silently faked
277
+ - `proofCommands` — the focused commands that prove the work
278
+ - `fileLane` — the files and directories this slice writes
279
+ - `dependencies` — open prerequisite issue numbers, bare (`875`) or string
280
+ (`"875"`); `[]` when none
281
+ - `proposedBrief` — required iff `verdict` is `PROMOTABLE`
282
+ - `reasonNotToPromote` — required for every other verdict
269
283
  - **Disqualifying an issue is a successful grooming outcome.** Measured on this
270
284
  package's own fleet: four scouts over sixteen backlog issues promoted four and
271
285
  *disqualified six* that looked promotable from their titles — four written
@@ -44,6 +44,9 @@ required field and no extra keys. The fields are:
44
44
  the exact ref, and `freshAt` = epoch milliseconds when you actually observed
45
45
  it. Conductor refuses results whose source is older than 24 hours or missing
46
46
  name/ref/freshAt — an unsourced verdict is not grooming, it is prose.
47
+ - `evidence` — the files/symbols that prove the verdict. Required for
48
+ `ALREADY DONE`: name the symbol/file that already does the work, never a
49
+ title match. Welcome on every other verdict.
47
50
  - `laterWorkInvalidates` — boolean: did later work (an epic or issue committed
48
51
  after this candidate was filed) retire its premise?
49
52
  - `laterWorkNote` — what you searched for that check and what you found. Even
@@ -57,8 +60,9 @@ required field and no extra keys. The fields are:
57
60
  implementing, and how to prove it is not.
58
61
  - `proofCommands` — the focused commands that prove the work, each with its
59
62
  `cwd` when it matters.
60
- - `fileLane` — the files and directories this slice writes; `dependencies` —
61
- open prerequisites, `[]` when none.
63
+ - `fileLane` — the files and directories this slice writes.
64
+ - `dependencies` — open prerequisite issue numbers, each a bare number
65
+ (`875`) or a string (`"875"`); `[]` when none.
62
66
  - `proposedBrief` — required iff `verdict` is `PROMOTABLE`: the brief a worker
63
67
  would be dispatched with, including the silent fake and the proof commands.
64
68
  - `reasonNotToPromote` — required for every other verdict: why this must not
package/src/cli.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { userInfo } from "node:os";
11
11
  import { loadConfig } from "./config.ts";
12
- import { COMMAND_MANIFEST, renderUsage } from "./command-manifest.ts";
12
+ import { COMMAND_MANIFEST, renderUsage, type CommandManifestEntry } from "./command-manifest.ts";
13
13
  import { armCommand } from "./commands/arm.ts";
14
14
  import { boardCommand } from "./commands/board.ts";
15
15
  import { briefUpgradeCommand } from "./commands/brief-upgrade.ts";
@@ -28,6 +28,7 @@ import { intakeCommand } from "./commands/intake.ts";
28
28
  import { ledgerCommand } from "./commands/ledger.ts";
29
29
  import { messageCommand } from "./commands/message.ts";
30
30
  import { reportCommand } from "./commands/report.ts";
31
+ import { releaseCompositionCommand } from "./commands/release-composition.ts";
31
32
  import { restartCommand } from "./commands/restart.ts";
32
33
  import { restoreDbCommand } from "./commands/restore-db.ts";
33
34
  import { resumeCommand } from "./commands/resume.ts";
@@ -40,6 +41,7 @@ import { tailCommand } from "./commands/tail.ts";
40
41
  import { unblockCommand } from "./commands/unblock.ts";
41
42
  import { unfreezeCommand } from "./commands/unfreeze.ts";
42
43
  import { upgradeInstallCommand } from "./commands/upgrade-install.ts";
44
+ import { reconcileUnitsCommand } from "./commands/reconcile-units.ts";
43
45
  import { upgradeRollbackCommand } from "./commands/upgrade-rollback.ts";
44
46
  import { upgradeCommand } from "./commands/upgrade.ts";
45
47
  import { verbCommand } from "./commands/verb.ts";
@@ -55,6 +57,104 @@ import {
55
57
 
56
58
  const USAGE = renderUsage(COMMAND_MANIFEST);
57
59
 
60
+ /**
61
+ * Verbs that define every `--help`/`-h` shape themselves, so the dispatch
62
+ * gate below must not pre-empt them: `help` and the version aliases print
63
+ * their own text, `setup` accepts help only as the first trailing token
64
+ * (`setup bogus --help` must reject `bogus`, never print a help read), and
65
+ * the hand-rolled usage verbs print help at `argv[1]` or refuse a trailing
66
+ * unknown token with exit 2 — every one of them already before any side
67
+ * effect. `intake` and `watch` are deliberately absent: they validate their
68
+ * tails only after `loadConfig`/`openStore`, so an unconsumed trailing help
69
+ * must be answered by the gate before the handler can create the store
70
+ * (#863).
71
+ */
72
+ const HELP_OWNED_VERBS: Record<string, true> = {
73
+ "--help": true,
74
+ "-h": true,
75
+ help: true,
76
+ "--version": true,
77
+ "-V": true,
78
+ version: true,
79
+ setup: true,
80
+ dashboard: true,
81
+ doctor: true,
82
+ stats: true,
83
+ drain: true,
84
+ "restore-db": true,
85
+ };
86
+
87
+ /**
88
+ * `setup`'s install subcommands — the one `setup` shape the wizard does not
89
+ * safely own: help is recognized only at the first trailing token, so
90
+ * `setup host --help` would reach the privileged install planner (creating
91
+ * the state db and staging the host install) instead of printing help. The
92
+ * dispatch gate answers real help requests in their tail instead.
93
+ */
94
+ const SETUP_INSTALL_SUBCOMMANDS: Record<string, true> = {
95
+ host: true,
96
+ graph: true,
97
+ };
98
+
99
+ /**
100
+ * Whether an invocation carries an actual `--help`/`-h` request — a token the
101
+ * verb's own grammar does not consume. A token immediately after a
102
+ * value-taking flag is that flag's value (`report --text --help` sends the
103
+ * literal text "--help", exactly as before), and a bare `--` makes everything
104
+ * after it positional; only an unconsumed help token is really a request.
105
+ */
106
+ function helpRequested(argv: readonly string[], entry: CommandManifestEntry): boolean {
107
+ let consumeNext = false;
108
+ for (const token of argv.slice(1)) {
109
+ if (consumeNext) {
110
+ consumeNext = false;
111
+ continue;
112
+ }
113
+ if (token === "--") {
114
+ // A bare `--` ends option parsing for a verb whose manifest actually
115
+ // declares positionals: after it, `--help` is positional data, not a
116
+ // request. A flag-only verb has no positional grammar for `--` to
117
+ // protect, so `stop --all -- --help` (#863) must stay a help read.
118
+ if ((entry.positionals?.length ?? 0) > 0) return false;
119
+ continue;
120
+ }
121
+ if (token === "--help" || token === "-h") return true;
122
+ // `--flag VALUE` consumes the next token as the flag's value, but
123
+ // `--flag=VALUE` binds the value inline — so only the split form can
124
+ // swallow a following `--help`. `stop --project=conductor --help` is a
125
+ // help read (#863), and `--project conductor --help` must read the next
126
+ // token as the value, exactly as `report --text --help` sends literal
127
+ // text. A token without `=` is a separate argument: when it names a
128
+ // value-taking flag, its value is the next token.
129
+ consumeNext =
130
+ token.startsWith("--") &&
131
+ !token.includes("=") &&
132
+ entry.flags.some((flag) => flag.name === token && flag.takesValue);
133
+ }
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * One verb's usage block from the manifest — what `VERB --help` prints. The
139
+ * manifest's `details` is that verb's long-form semantics, and rendering it
140
+ * here is what keeps the generated read complete: the dispatch gate above
141
+ * answers help for every verb that does not own it, so a verb whose prose
142
+ * lived only in its own hand-rolled usage string would silently lose it
143
+ * (#863 — `watch`'s no-answer/auto-withdraw semantics and `intake`'s
144
+ * durability/groomed no-op semantics).
145
+ */
146
+ function renderCommandHelp(entry: CommandManifestEntry): string {
147
+ const lines = [`omp-conductor ${entry.name} — ${entry.description}`, "", "usage:"];
148
+ for (const usage of entry.usage) lines.push(` omp-conductor ${usage}`);
149
+ if (entry.flags.length > 0) {
150
+ lines.push("", "flags:");
151
+ const width = Math.max(...entry.flags.map((flag) => flag.name.length));
152
+ for (const flag of entry.flags) lines.push(` ${flag.name.padEnd(width)} ${flag.description}`);
153
+ }
154
+ if (entry.details !== undefined) lines.push("", entry.details);
155
+ return lines.join("\n");
156
+ }
157
+
58
158
  /** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
59
159
  function flag(argv: string[], name: string): string | undefined {
60
160
  const i = argv.indexOf(`--${name}`);
@@ -167,6 +267,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
167
267
  setup: () => setupCommand(ctx),
168
268
  upgrade: () => upgradeCommand(ctx),
169
269
  "upgrade-install": () => upgradeInstallCommand(ctx),
270
+ "reconcile-units": () => reconcileUnitsCommand(ctx),
170
271
  "upgrade-rollback": () => upgradeRollbackCommand(ctx),
171
272
  daemon: () => daemonCommand(ctx),
172
273
  start: () => startCommand(ctx),
@@ -186,8 +287,9 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
186
287
  extend: () => extendCommand(ctx),
187
288
  worker: () => workerCommand(ctx),
188
289
  unblock: () => unblockCommand(ctx),
189
- unfreeze: () => unfreezeCommand(ctx),
290
+ "release-composition": () => releaseCompositionCommand(ctx),
190
291
  verb: () => verbCommand(ctx),
292
+ unfreeze: () => unfreezeCommand(ctx),
191
293
  event: () => eventCommand(ctx),
192
294
  report: () => reportCommand(ctx),
193
295
  message: () => messageCommand(ctx),
@@ -254,6 +356,29 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
254
356
  );
255
357
  process.exit(2);
256
358
  }
359
+ // A real `--help`/`-h` (one no declared flag consumes) is a help read and
360
+ // must never reach a mutating command: `stop --all --help` is how #863
361
+ // disarmed the fleet. Recognized here — before project-selection
362
+ // (`targetProjects` is a lazy closure; no config is loaded) and before
363
+ // handler execution — so a verb that does not validate its own tail can
364
+ // no longer treat the request as an argument.
365
+ const entry = cmd === undefined ? undefined : COMMAND_MANIFEST.find((entry) => entry.name === cmd);
366
+ let help = false;
367
+ if (entry !== undefined) {
368
+ if (cmd === "setup") {
369
+ // `setup` owns its help (only at the first trailing token, which is
370
+ // what keeps `setup bogus --help` rejecting `bogus`). The install
371
+ // subcommands are the exception: a help token in their tail must be
372
+ // answered here, before `loadConfig` and the install plan (#863).
373
+ help = SETUP_INSTALL_SUBCOMMANDS[argv[1] ?? ""] === true && helpRequested(argv, entry);
374
+ } else if (HELP_OWNED_VERBS[cmd ?? ""] !== true) {
375
+ help = helpRequested(argv, entry);
376
+ }
377
+ }
378
+ if (help && entry !== undefined) {
379
+ process.stdout.write(`${renderCommandHelp(entry)}\n`);
380
+ return;
381
+ }
257
382
  // A "none"-scope verb does no per-project work, so --project cannot change
258
383
  // what it does: refuse it with a reason instead of silently ignoring it.
259
384
  if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) {
@@ -10,6 +10,11 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
10
10
  brief as one pinned release. Pauses only new claims, drains live
11
11
  workers, reloads, verifies twice, and restores the prior dispatch
12
12
  state. Run it from a shell outside the target Herdr session.
13
+ --bootstrap SHA --source PATH installs an exact commit instead of a
14
+ published version, for when the installed conductor is what is
15
+ broken: the source must be a checkout AT that sha, the package's own
16
+ checks run there first, and a check failure refuses rather than
17
+ installing a partly verified tree.
13
18
  upgrade-install
14
19
  the detached executor half of the fleet-installs-itself request:
15
20
  the same transaction, journaled per surface, leaving verification
@@ -207,7 +212,10 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
207
212
  re-stage the systemd unit and run the install behind one confirm.
208
213
  The units are host-global; NAME (or --project NAME) says which
209
214
  project's per-project tail (tick config, brief link) to write, and
210
- is required on a host with several configured projects.
215
+ is required on a host with several configured projects. On a Herdr
216
+ host it restarts the session unit, so it must be run from a shell
217
+ outside that session: from a pane inside it, it refuses before
218
+ staging or installing anything.
211
219
  setup graph
212
220
  set up the code-graph indexes workers query instead of grepping, end
213
221
  to end: check prerequisites, clone any missing index-only clone as
@@ -59,13 +59,15 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
59
59
  description: "interview, configure the fleet, or install host and graph surfaces",
60
60
  scope: "project",
61
61
  usage: [
62
- "setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]",
62
+ "setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--resume FILE] [--project NAME]",
63
63
  "setup host [NAME] [--project NAME]",
64
64
  "setup graph [--no-seed] [--print] [--project NAME]",
65
65
  ],
66
- details: COMMAND_DETAILS,
67
66
  subcommands: [
68
- { name: "host", description: "stage and install the host services" },
67
+ {
68
+ name: "host",
69
+ description: "stage and install the host services (run it from a shell outside the Herdr session it restarts)",
70
+ },
69
71
  { name: "graph", description: "install and seed code-graph indexes" },
70
72
  ],
71
73
  flags: [
@@ -118,7 +120,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
118
120
  name: "upgrade",
119
121
  description: "upgrade all installed conductor surfaces as one transaction",
120
122
  scope: "host",
121
- usage: ["upgrade [--to VERSION] [--project NAME]"],
123
+ usage: ["upgrade [--to VERSION] [--bootstrap SHA --source PATH] [--project NAME]"],
122
124
  flags: [value("--to", "package version to install"), project()],
123
125
  },
124
126
  {
@@ -135,6 +137,18 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
135
137
  usage: ["upgrade-rollback"],
136
138
  flags: [],
137
139
  },
140
+ {
141
+ name: "reconcile-units",
142
+ description: "install the host unit files this version renders (units only)",
143
+ scope: "host",
144
+ usage: ["reconcile-units [--yes] [--dry-run] [--protect PATH]... [--project NAME]"],
145
+ flags: [
146
+ toggle("--yes", "skip the confirm (the upgrade transaction's own consent)"),
147
+ toggle("--dry-run", "print what would be installed and run nothing"),
148
+ value("--protect", "a destination to leave alone (repeatable)"),
149
+ project(),
150
+ ],
151
+ },
138
152
  {
139
153
  name: "board",
140
154
  description: "open the live keyboard-driven fleet board",
@@ -293,6 +307,24 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
293
307
  flags: [value("--reason", "reason for clearing the freeze"), project()],
294
308
  positionals: [{ name: "repo" }],
295
309
  },
310
+ {
311
+ name: "release-composition",
312
+ description: "declare, override, complete or cancel the active release",
313
+ scope: "project",
314
+ usage: [
315
+ "release-composition declare --campaign ID [--pr URL ...] [--reason TEXT] [--project NAME]",
316
+ "release-composition override --pr URL [--reason TEXT] [--project NAME]",
317
+ "release-composition complete [--reason TEXT] [--project NAME]",
318
+ "release-composition cancel [--reason TEXT] [--project NAME]",
319
+ "release-composition status [--project NAME]",
320
+ ],
321
+ flags: [
322
+ value("--campaign", "campaign identifier for the release being assembled"),
323
+ value("--pr", "full PR URL allowed into (or overridden into) the release"),
324
+ value("--reason", "reason recorded with the transition"),
325
+ project(),
326
+ ],
327
+ },
296
328
  {
297
329
  name: "verb",
298
330
  description: "run a gated conductor_* verb as the orchestrator",
@@ -418,6 +450,11 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
418
450
  "watch list [--project NAME] [--json]",
419
451
  "watch withdraw <id> [--reason TEXT] [--project NAME]",
420
452
  ],
453
+ details: `add records a row the daemon checks for you and the next tick reads, with no
454
+ operator answer needed. list shows open watches, oldest first. withdraw ends
455
+ one with a recorded reason — the verb that creates a watch is the verb that
456
+ ends it. A watch whose PR condition can no longer be observed (the PR merged
457
+ or closed first) is withdrawn by the daemon itself.`,
421
458
  subcommands: [
422
459
  { name: "add", description: "record a watch" },
423
460
  { name: "list", description: "list open watches" },
@@ -443,6 +480,12 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
443
480
  "intake dismiss <id> [--project NAME]",
444
481
  "intake groomed <id> --issue <url> [--project NAME]",
445
482
  ],
483
+ details: `Captures one raw idea into the local store and prints its id. list shows what
484
+ is still pending (id, age, text), oldest first; dismiss drops one by id. The
485
+ capture is durable — it lives in the sqlite store, not in a session — so it
486
+ survives daemon restarts. The orchestrator files the idea as an issue and then
487
+ marks that provenance with groomed: an id already resolved is a no-op with a
488
+ message, never an error, because ticks retry.`,
446
489
  subcommands: [
447
490
  { name: "list", description: "list pending intake items" },
448
491
  { name: "dismiss", description: "dismiss an intake item" },
@@ -456,7 +499,6 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
456
499
  description: "print command usage (also --help, -h)",
457
500
  scope: "none",
458
501
  usage: ["help"],
459
- details: COMMAND_HELP_TAIL,
460
502
  flags: [],
461
503
  },
462
504
  {
@@ -480,9 +522,11 @@ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_
480
522
  const commands = manifest.map(
481
523
  (command) => ` ${command.name.padEnd(width)} ${command.description}`,
482
524
  );
483
- const details = manifest.flatMap((command) =>
484
- command.details === undefined ? [] : [...command.details.split("\n"), ""],
485
- );
525
+ // The fleet-wide operator reference, printed once. It is *not* harvested
526
+ // from `details`: that field is one verb's own long-form help, which
527
+ // `VERB --help` renders, so collecting it here would print `setup`'s
528
+ // carrier copy of this very blob and repeat each verb's prose twice.
529
+ const details = [...COMMAND_DETAILS.split("\n"), "", ...COMMAND_HELP_TAIL.split("\n"), ""];
486
530
  return [
487
531
  "omp-conductor — dispatch ready issues to omp coding sessions",
488
532
  "",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `arm` — proof-gated: send a Telegram challenge and write the arm marker only after the reply appears in the orchestrator transcript.
2
+ * `arm` — proof-gated: send a Telegram challenge and write the arm marker only after the orchestrator's inbound adapter acknowledges the reply in conductor state (#614).
3
3
  *
4
4
  * Moved out of cli.ts's switch by the per-verb module split (#462);
5
5
  * only the case wrapper, the injected `ctx` lookups and the imports
@@ -15,10 +15,14 @@ for (const project of ctx.targetProjects()) {
15
15
  // Proof-neutral wording: `claim-only` performs no Telegram send, so the
16
16
  // progress line cannot promise a challenge that never goes out (#613). The
17
17
  // result line names the proof that actually armed it.
18
+ // The pending-proof heartbeat goes straight to stdout (#861): a challenge
19
+ // waits on a human for up to five minutes, and `withProgress`'s single step
20
+ // line cannot say how much of that window is left. Written unconditionally —
21
+ // a piped or plain-UI run needs it more than a TTY does, not less.
18
22
  const r = await withProgress(
19
23
  "arm: verifying the arming proof…",
20
24
  "Arming proof verified",
21
- () => armTicks(project.name),
25
+ () => armTicks(project.name, { progress: (line) => process.stdout.write(`${line}\n`) }),
22
26
  { plainMessage: true },
23
27
  );
24
28
  process.stdout.write(
@@ -89,6 +89,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
89
89
  intake: "project",
90
90
  ledger: "project",
91
91
  message: "project",
92
+ "release-composition": "project",
92
93
  report: "project",
93
94
  setup: "project", // the wizard and `setup graph` configure one project
94
95
  stats: "project",
@@ -106,6 +107,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
106
107
  upgrade: "host",
107
108
  "upgrade-install": "host",
108
109
  "upgrade-rollback": "host",
110
+ "reconcile-units": "host",
109
111
  // fleet — every project by default, --project narrows
110
112
  arm: "fleet",
111
113
  board: "fleet",
@@ -12,21 +12,6 @@ import type { CommandContext } from "./context.ts";
12
12
  import { findProject, loadConfig } from "../config.ts";
13
13
  import { dbPath, openStore } from "../store.ts";
14
14
 
15
- const INTAKE_USAGE = `omp-conductor intake — capture raw ideas durably.
16
-
17
- usage:
18
- omp-conductor intake "<text>" [--project NAME]
19
- omp-conductor intake list [--project NAME]
20
- omp-conductor intake dismiss <id> [--project NAME]
21
- omp-conductor intake groomed <id> --issue <url> [--project NAME]
22
-
23
- Captures one raw idea into the local store and prints its id. list shows what
24
- is still pending (id, age, text), oldest first; dismiss drops one by id. The
25
- capture is durable — it lives in the sqlite store, not in a session — so it
26
- survives daemon restarts. The orchestrator files the idea as an issue and then
27
- marks that provenance with groomed: an id already resolved is a no-op with a
28
- message, never an error, because ticks retry.`;
29
-
30
15
  /** Flags the intake surface understands. `--project` is consumed by
31
16
  * {@link CommandContext.projectFlag}; the value token stays in argv. */
32
17
  const INTAKE_FLAGS: Record<string, true> = { "--project": true, "--issue": true };
@@ -49,11 +34,11 @@ function assertKnownArgs(ctx: CommandContext, from: number): void {
49
34
  }
50
35
 
51
36
  export async function intakeCommand(ctx: CommandContext): Promise<void> {
37
+ // `--help`/`-h` never arrives here: the CLI dispatch gate answers a real
38
+ // help request from the manifest before this handler runs (#863), which is
39
+ // why the long-form prose lives on the manifest's `details` field rather
40
+ // than in a usage string this module would print itself.
52
41
  const sub = ctx.argv[1];
53
- if (sub === "--help" || sub === "-h") {
54
- process.stdout.write(INTAKE_USAGE);
55
- return;
56
- }
57
42
  const project = findProject(loadConfig(), ctx.projectFlag);
58
43
  const store = openStore(dbPath());
59
44
  try {
@@ -101,6 +101,28 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
101
101
  );
102
102
  }
103
103
 
104
+ /**
105
+ * A hold reaches nobody now, so it is the wrong path for an answer somebody is
106
+ * waiting for (#882).
107
+ *
108
+ * 2026-08-21T20:48Z: a tick was interrupted by an operator message, the mandated
109
+ * targetless `telegram_send` failed, and the session fell back to this command.
110
+ * It behaved correctly — the category was non-bypass, so the answer was held for
111
+ * the 23:30 digest — and the operator sat waiting in the topic they had written
112
+ * in. "nothing was sent" was already printed and was already true; what was
113
+ * missing is that the caller is *mid-conversation* and a live path exists.
114
+ *
115
+ * So every hold names it. Conditionally, because this process cannot know
116
+ * whether an inbound message is waiting: it is a separate process from the
117
+ * session, and inventing a guess here would either nag every legitimate
118
+ * digest-only note or, worse, quietly bypass availability policy on a hunch.
119
+ * The caller knows, and one sentence is enough to make holding a choice rather
120
+ * than a default.
121
+ */
122
+ const LIVE_REPLY_HINT =
123
+ "if you are answering an operator who is waiting now, this is not the path: " +
124
+ "reply in their own topic with telegram_send (telegram_ask for a choice) — a held notice reaches nobody until then\n";
125
+
104
126
  /** The held-notice line names the future that releases it, in words an
105
127
  * operator can act on: the working-hours window opening, or the daily digest.
106
128
  * The earlier spelling — "the next digest or working-hours catch-up" — read
@@ -118,7 +140,8 @@ function heldLine(
118
140
  return (
119
141
  `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
120
142
  `held until the working-hours window opens${opening})\n` +
121
- "nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n"
143
+ "nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n" +
144
+ LIVE_REPLY_HINT
122
145
  );
123
146
  }
124
147
  const digestAt = project.reporting?.digest.at;
@@ -126,6 +149,7 @@ function heldLine(
126
149
  return (
127
150
  `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
128
151
  `digest-only — held until ${when})\n` +
129
- `nothing was sent; the daemon releases it with ${when}\n`
152
+ `nothing was sent; the daemon releases it with ${when}\n` +
153
+ LIVE_REPLY_HINT
130
154
  );
131
155
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `reconcile-units` — install the host unit files this version renders, and
3
+ * nothing else (#905).
4
+ *
5
+ * Scope: `"host"`. The units are host-global (one daemon serves every
6
+ * configured project), so this verb takes no project and reconciles the shared
7
+ * destinations. The per-project tail — tick config, brief link — stays
8
+ * `setup host`'s.
9
+ *
10
+ * It exists as its own verb because the render is code, not data: the upgrade
11
+ * transaction replaces the package underneath itself, so only a freshly
12
+ * spawned process can render what the new release ships. `upgrade` runs this
13
+ * verb with `--yes` after its install phase; an operator can run it by hand
14
+ * any time `doctor` reports `systemd-unit` drift.
15
+ */
16
+
17
+ import type { CommandContext } from "./context.ts";
18
+ import { findProject, loadConfig, resolveCaps } from "../config.ts";
19
+ import { telegramStateDir } from "../fleet.ts";
20
+ import {
21
+ planHostRuntime,
22
+ RECOVER_SCRIPT_INSTALL_PATH,
23
+ SYSTEMD_UNIT_DIR,
24
+ totalConfiguredWorkers,
25
+ } from "../setup-host.ts";
26
+ import {
27
+ formatUnitReconcile,
28
+ planUnitReconcile,
29
+ reconcileUnits,
30
+ UNIT_RECONCILE_ANSWER_KEY,
31
+ } from "../unit-reconcile.ts";
32
+ import { answersUi } from "../setup-answers.ts";
33
+ import { terminalUi } from "../wizard-ui.ts";
34
+
35
+ /** Repeatable `--protect PATH`: destinations this run must not overwrite. */
36
+ function protectedPaths(argv: readonly string[]): string[] {
37
+ const paths: string[] = [];
38
+ for (const [index, arg] of argv.entries()) {
39
+ if (arg === "--protect") {
40
+ const value = argv[index + 1];
41
+ if (value !== undefined && !value.startsWith("--")) paths.push(value);
42
+ } else if (arg.startsWith("--protect=")) {
43
+ paths.push(arg.slice("--protect=".length));
44
+ }
45
+ }
46
+ return paths;
47
+ }
48
+
49
+ export async function reconcileUnitsCommand(ctx: CommandContext): Promise<void> {
50
+ const cfg = loadConfig();
51
+ // The units are host-global. A `--project` narrows only the per-project tail
52
+ // this verb deliberately does not write, so it is accepted (the recovery
53
+ // unit encodes one project's name on a single-project host) and ignored
54
+ // otherwise.
55
+ const project = ctx.projectFlag === undefined && cfg.projects.length !== 1
56
+ ? undefined
57
+ : findProject(cfg, ctx.projectFlag);
58
+ const plan = planHostRuntime(
59
+ project,
60
+ project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults),
61
+ telegramStateDir(),
62
+ undefined,
63
+ totalConfiguredWorkers(cfg),
64
+ SYSTEMD_UNIT_DIR,
65
+ RECOVER_SCRIPT_INSTALL_PATH,
66
+ cfg.projects.length > 1,
67
+ );
68
+ const protect = protectedPaths(ctx.argv);
69
+ // `--no-refresh` retires without refreshing (#895). The upgrade transaction
70
+ // uses it when it could not read a pre-install drift baseline: there a
71
+ // refresh cannot tell the operator's own edits from the upgrade's, but the
72
+ // retirement is not a comparison against any render and must still happen —
73
+ // otherwise a host that cannot be baselined keeps its obsolete mount forever.
74
+ const refresh = ctx.argv.includes("--no-refresh") === false;
75
+
76
+ if (ctx.argv.includes("--dry-run")) {
77
+ const preview = planUnitReconcile(plan, protect, refresh);
78
+ process.stdout.write(
79
+ preview.steps.length === 0
80
+ ? `${formatUnitReconcile({ kind: "current", protectedDrift: preview.protectedDrift, outOfScope: preview.outOfScope })}\n`
81
+ : `${[
82
+ "would reconcile:",
83
+ ...preview.retire.map((path) => ` retire ${path}`),
84
+ ...preview.refresh.map((d) => ` ${d.installed} (from ${d.source})`),
85
+ ...preview.steps.map((s) => ` $ ${s.argv.join(" ")}`),
86
+ ].join("\n")}\n`,
87
+ );
88
+ return;
89
+ }
90
+
91
+ // `--yes` is the non-interactive consent the upgrade transaction carries:
92
+ // its operator already authorised the upgrade, and the detached installer
93
+ // has no terminal to prompt on. Interactive runs get the real confirm.
94
+ const ui = ctx.argv.includes("--yes")
95
+ ? answersUi({ [UNIT_RECONCILE_ANSWER_KEY]: true })
96
+ : terminalUi();
97
+ try {
98
+ const outcome = await reconcileUnits(plan, ui, { protect, refresh });
99
+ process.stdout.write(`${formatUnitReconcile(outcome)}\n`);
100
+ if (outcome.kind === "failed") process.exitCode = 1;
101
+ } finally {
102
+ ui.close();
103
+ }
104
+ }