omp-conductor 0.18.0 → 0.18.2

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 (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
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";
@@ -18,6 +18,7 @@ import { dashboardCommand } from "./commands/dashboard.ts";
18
18
  import { decisionCommand } from "./commands/decision.ts";
19
19
  import { disarmCommand } from "./commands/disarm.ts";
20
20
  import { doctorCommand } from "./commands/doctor.ts";
21
+ import { drainCommand } from "./commands/drain.ts";
21
22
  import { eventCommand } from "./commands/event.ts";
22
23
  import { extendCommand } from "./commands/extend.ts";
23
24
  import { frictionCommand } from "./commands/friction.ts";
@@ -54,6 +55,104 @@ import {
54
55
 
55
56
  const USAGE = renderUsage(COMMAND_MANIFEST);
56
57
 
58
+ /**
59
+ * Verbs that define every `--help`/`-h` shape themselves, so the dispatch
60
+ * gate below must not pre-empt them: `help` and the version aliases print
61
+ * their own text, `setup` accepts help only as the first trailing token
62
+ * (`setup bogus --help` must reject `bogus`, never print a help read), and
63
+ * the hand-rolled usage verbs print help at `argv[1]` or refuse a trailing
64
+ * unknown token with exit 2 — every one of them already before any side
65
+ * effect. `intake` and `watch` are deliberately absent: they validate their
66
+ * tails only after `loadConfig`/`openStore`, so an unconsumed trailing help
67
+ * must be answered by the gate before the handler can create the store
68
+ * (#863).
69
+ */
70
+ const HELP_OWNED_VERBS: Record<string, true> = {
71
+ "--help": true,
72
+ "-h": true,
73
+ help: true,
74
+ "--version": true,
75
+ "-V": true,
76
+ version: true,
77
+ setup: true,
78
+ dashboard: true,
79
+ doctor: true,
80
+ stats: true,
81
+ drain: true,
82
+ "restore-db": true,
83
+ };
84
+
85
+ /**
86
+ * `setup`'s install subcommands — the one `setup` shape the wizard does not
87
+ * safely own: help is recognized only at the first trailing token, so
88
+ * `setup host --help` would reach the privileged install planner (creating
89
+ * the state db and staging the host install) instead of printing help. The
90
+ * dispatch gate answers real help requests in their tail instead.
91
+ */
92
+ const SETUP_INSTALL_SUBCOMMANDS: Record<string, true> = {
93
+ host: true,
94
+ graph: true,
95
+ };
96
+
97
+ /**
98
+ * Whether an invocation carries an actual `--help`/`-h` request — a token the
99
+ * verb's own grammar does not consume. A token immediately after a
100
+ * value-taking flag is that flag's value (`report --text --help` sends the
101
+ * literal text "--help", exactly as before), and a bare `--` makes everything
102
+ * after it positional; only an unconsumed help token is really a request.
103
+ */
104
+ function helpRequested(argv: readonly string[], entry: CommandManifestEntry): boolean {
105
+ let consumeNext = false;
106
+ for (const token of argv.slice(1)) {
107
+ if (consumeNext) {
108
+ consumeNext = false;
109
+ continue;
110
+ }
111
+ if (token === "--") {
112
+ // A bare `--` ends option parsing for a verb whose manifest actually
113
+ // declares positionals: after it, `--help` is positional data, not a
114
+ // request. A flag-only verb has no positional grammar for `--` to
115
+ // protect, so `stop --all -- --help` (#863) must stay a help read.
116
+ if ((entry.positionals?.length ?? 0) > 0) return false;
117
+ continue;
118
+ }
119
+ if (token === "--help" || token === "-h") return true;
120
+ // `--flag VALUE` consumes the next token as the flag's value, but
121
+ // `--flag=VALUE` binds the value inline — so only the split form can
122
+ // swallow a following `--help`. `stop --project=conductor --help` is a
123
+ // help read (#863), and `--project conductor --help` must read the next
124
+ // token as the value, exactly as `report --text --help` sends literal
125
+ // text. A token without `=` is a separate argument: when it names a
126
+ // value-taking flag, its value is the next token.
127
+ consumeNext =
128
+ token.startsWith("--") &&
129
+ !token.includes("=") &&
130
+ entry.flags.some((flag) => flag.name === token && flag.takesValue);
131
+ }
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * One verb's usage block from the manifest — what `VERB --help` prints. The
137
+ * manifest's `details` is that verb's long-form semantics, and rendering it
138
+ * here is what keeps the generated read complete: the dispatch gate above
139
+ * answers help for every verb that does not own it, so a verb whose prose
140
+ * lived only in its own hand-rolled usage string would silently lose it
141
+ * (#863 — `watch`'s no-answer/auto-withdraw semantics and `intake`'s
142
+ * durability/groomed no-op semantics).
143
+ */
144
+ function renderCommandHelp(entry: CommandManifestEntry): string {
145
+ const lines = [`omp-conductor ${entry.name} — ${entry.description}`, "", "usage:"];
146
+ for (const usage of entry.usage) lines.push(` omp-conductor ${usage}`);
147
+ if (entry.flags.length > 0) {
148
+ lines.push("", "flags:");
149
+ const width = Math.max(...entry.flags.map((flag) => flag.name.length));
150
+ for (const flag of entry.flags) lines.push(` ${flag.name.padEnd(width)} ${flag.description}`);
151
+ }
152
+ if (entry.details !== undefined) lines.push("", entry.details);
153
+ return lines.join("\n");
154
+ }
155
+
57
156
  /** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
58
157
  function flag(argv: string[], name: string): string | undefined {
59
158
  const i = argv.indexOf(`--${name}`);
@@ -178,6 +277,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
178
277
  board: () => boardCommand(ctx),
179
278
  dashboard: () => dashboardCommand(ctx),
180
279
  hold: () => holdCommand(ctx),
280
+ drain: () => drainCommand(ctx),
181
281
  arm: () => armCommand(ctx),
182
282
  disarm: () => disarmCommand(ctx),
183
283
  tail: () => tailCommand(ctx),
@@ -252,6 +352,29 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
252
352
  );
253
353
  process.exit(2);
254
354
  }
355
+ // A real `--help`/`-h` (one no declared flag consumes) is a help read and
356
+ // must never reach a mutating command: `stop --all --help` is how #863
357
+ // disarmed the fleet. Recognized here — before project-selection
358
+ // (`targetProjects` is a lazy closure; no config is loaded) and before
359
+ // handler execution — so a verb that does not validate its own tail can
360
+ // no longer treat the request as an argument.
361
+ const entry = cmd === undefined ? undefined : COMMAND_MANIFEST.find((entry) => entry.name === cmd);
362
+ let help = false;
363
+ if (entry !== undefined) {
364
+ if (cmd === "setup") {
365
+ // `setup` owns its help (only at the first trailing token, which is
366
+ // what keeps `setup bogus --help` rejecting `bogus`). The install
367
+ // subcommands are the exception: a help token in their tail must be
368
+ // answered here, before `loadConfig` and the install plan (#863).
369
+ help = SETUP_INSTALL_SUBCOMMANDS[argv[1] ?? ""] === true && helpRequested(argv, entry);
370
+ } else if (HELP_OWNED_VERBS[cmd ?? ""] !== true) {
371
+ help = helpRequested(argv, entry);
372
+ }
373
+ }
374
+ if (help && entry !== undefined) {
375
+ process.stdout.write(`${renderCommandHelp(entry)}\n`);
376
+ return;
377
+ }
255
378
  // A "none"-scope verb does no per-project work, so --project cannot change
256
379
  // what it does: refuse it with a reason instead of silently ignoring it.
257
380
  if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) {
@@ -87,6 +87,17 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
87
87
  hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
88
88
  This is "stop the conductor overnight" without killing processes.
89
89
  Use --all to target every configured project.
90
+ drain start, inspect, or cancel the project's self-expiring admission
91
+ drain — the durable, bounded alternative to queue-label churn
92
+ before a release. New claims pause while active runs settle, and
93
+ admission resumes at the absolute deadline on its own, even if the
94
+ orchestrator dies. start takes --until (an ISO instant or a
95
+ relative duration such as 90s/45m/2h/1d, always bounded and in the
96
+ future) and an optional --reason; status reports the active drain's
97
+ creation time, expiry, reason and remaining active runs; cancel
98
+ removes it and is idempotent. The drain never touches the pause
99
+ sentinel, the arm marker, or any queue label — it is a file record,
100
+ not a timer or a hold.
90
101
  arm proof-gated: send a Telegram challenge and write the arm marker only
91
102
  after your reply appears as a user turn in the orchestrator transcript.
92
103
  Never auto-armed by resume/hold. Use --all for every project.
@@ -63,7 +63,6 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
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
67
  { name: "host", description: "stage and install the host services" },
69
68
  { name: "graph", description: "install and seed code-graph indexes" },
@@ -205,6 +204,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
205
204
  usage: ["hold [--keep-ticks] [--project NAME | --all]"],
206
205
  flags: [toggle("--keep-ticks", "pause claims without disarming ticks"), project(), all()],
207
206
  },
207
+ {
208
+ name: "drain",
209
+ description: "start, inspect, or cancel the project's self-expiring admission drain",
210
+ scope: "project",
211
+ usage: [
212
+ "drain start --until ISO|DURATION [--reason TEXT] [--project NAME]",
213
+ "drain status [--project NAME]",
214
+ "drain cancel [--project NAME]",
215
+ ],
216
+ subcommands: [
217
+ { name: "start", description: "record a bounded drain intent" },
218
+ { name: "status", description: "report the drain and its remaining active runs" },
219
+ { name: "cancel", description: "remove the project's drain" },
220
+ ],
221
+ flags: [
222
+ value("--until", "absolute ISO instant or relative duration (90s, 45m, 2h, 1d)"),
223
+ value("--reason", "purpose, persisted on the drain record"),
224
+ project(),
225
+ ],
226
+ positionals: [{ name: "action" }],
227
+ },
208
228
  {
209
229
  name: "arm",
210
230
  description: "prove Telegram delivery and arm scheduled ticks",
@@ -397,6 +417,11 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
397
417
  "watch list [--project NAME] [--json]",
398
418
  "watch withdraw <id> [--reason TEXT] [--project NAME]",
399
419
  ],
420
+ details: `add records a row the daemon checks for you and the next tick reads, with no
421
+ operator answer needed. list shows open watches, oldest first. withdraw ends
422
+ one with a recorded reason — the verb that creates a watch is the verb that
423
+ ends it. A watch whose PR condition can no longer be observed (the PR merged
424
+ or closed first) is withdrawn by the daemon itself.`,
400
425
  subcommands: [
401
426
  { name: "add", description: "record a watch" },
402
427
  { name: "list", description: "list open watches" },
@@ -422,6 +447,12 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
422
447
  "intake dismiss <id> [--project NAME]",
423
448
  "intake groomed <id> --issue <url> [--project NAME]",
424
449
  ],
450
+ details: `Captures one raw idea into the local store and prints its id. list shows what
451
+ is still pending (id, age, text), oldest first; dismiss drops one by id. The
452
+ capture is durable — it lives in the sqlite store, not in a session — so it
453
+ survives daemon restarts. The orchestrator files the idea as an issue and then
454
+ marks that provenance with groomed: an id already resolved is a no-op with a
455
+ message, never an error, because ticks retry.`,
425
456
  subcommands: [
426
457
  { name: "list", description: "list pending intake items" },
427
458
  { name: "dismiss", description: "dismiss an intake item" },
@@ -435,7 +466,6 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
435
466
  description: "print command usage (also --help, -h)",
436
467
  scope: "none",
437
468
  usage: ["help"],
438
- details: COMMAND_HELP_TAIL,
439
469
  flags: [],
440
470
  },
441
471
  {
@@ -459,9 +489,11 @@ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_
459
489
  const commands = manifest.map(
460
490
  (command) => ` ${command.name.padEnd(width)} ${command.description}`,
461
491
  );
462
- const details = manifest.flatMap((command) =>
463
- command.details === undefined ? [] : [...command.details.split("\n"), ""],
464
- );
492
+ // The fleet-wide operator reference, printed once. It is *not* harvested
493
+ // from `details`: that field is one verb's own long-form help, which
494
+ // `VERB --help` renders, so collecting it here would print `setup`'s
495
+ // carrier copy of this very blob and repeat each verb's prose twice.
496
+ const details = [...COMMAND_DETAILS.split("\n"), "", ...COMMAND_HELP_TAIL.split("\n"), ""];
465
497
  return [
466
498
  "omp-conductor — dispatch ready issues to omp coding sessions",
467
499
  "",
@@ -475,6 +507,7 @@ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_
475
507
  ...details,
476
508
  "recipes:",
477
509
  " hold no claims, no tick sends (inspectable)",
510
+ " drain start bounded release window, self-expiring (no hold)",
478
511
  " stop hold + stop dispatch daemon",
479
512
  " stop --pane stop + pin conductor-pane recovery off",
480
513
  " resume clear pause and pane pin (ticks stay disarmed)",
@@ -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
@@ -82,6 +82,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
82
82
  // project — exactly one project; findProject demands --project when several
83
83
  "brief-upgrade": "project",
84
84
  decision: "project",
85
+ drain: "project",
85
86
  event: "project",
86
87
  extend: "project",
87
88
  friction: "project",
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `drain` — start, inspect, or cancel the project's self-expiring admission fence (#484).
3
+ *
4
+ * The bounded drain state machine landed in #776 (`createDrain`/`readDrain`/
5
+ * `cancelDrain` on daemon.ts, re-exported from fleet.ts); this verb is the
6
+ * operator surface over it. A drain is the release-window sibling of `hold`:
7
+ * the same admission boundary — settlement above it, nothing claimed below —
8
+ * but recorded with an absolute deadline, so admission resumes on its own even
9
+ * if the orchestrator dies. The only state it touches is the project's drain
10
+ * record: never the pause sentinel, the arm marker, or any queue label.
11
+ */
12
+
13
+ import type { CommandContext } from "./context.ts";
14
+ import { findProject, loadConfig } from "../config.ts";
15
+ import { statusSnapshot } from "../daemon.ts";
16
+ import { cancelDrain, createDrain, readDrain } from "../fleet.ts";
17
+ import { dim, ok } from "../ui/style.ts";
18
+
19
+ const DRAIN_USAGE = `omp-conductor drain — start, inspect, or cancel the project's admission drain.
20
+
21
+ usage:
22
+ omp-conductor drain start --until ISO|DURATION [--reason TEXT] [--project NAME]
23
+ omp-conductor drain status [--project NAME]
24
+ omp-conductor drain cancel [--project NAME]
25
+
26
+ A drain is a durable, self-expiring admission fence: new claims pause while
27
+ existing runs settle, and admission resumes automatically at the absolute
28
+ deadline — even if the orchestrator crashes. start replaces any prior drain of
29
+ the project; --until takes an ISO instant or a relative duration (90s, 45m,
30
+ 2h, 1d) that must be bounded and in the future. status reports the active
31
+ drain's creation time, absolute expiry, reason, and remaining active runs.
32
+ cancel removes the project's drain and is idempotent. A drain never touches
33
+ the pause sentinel, the arm marker, or any queue label — it is the file record
34
+ that expires on its own.`;
35
+
36
+ /** The flags each drain subcommand accepts, after the subcommand itself. */
37
+ const DRAIN_FLAGS: Readonly<Record<string, readonly string[]>> = {
38
+ start: ["--project", "--until", "--reason"],
39
+ status: ["--project"],
40
+ cancel: ["--project"],
41
+ };
42
+
43
+ /** Relative durations accepted by `drain start --until`. */
44
+ const DURATION_RE = /^(\d+)([smhd])$/;
45
+ const DURATION_UNIT_MS: Readonly<Record<string, number>> = {
46
+ s: 1_000,
47
+ m: 60_000,
48
+ h: 3_600_000,
49
+ d: 86_400_000,
50
+ };
51
+
52
+ /**
53
+ * `--until` value → epoch-ms deadline, or undefined when the input is not a
54
+ * bounded expiry. A duration is relative to now; an ISO instant is absolute.
55
+ * Both forms must land on a finite safe-integer timestamp — the same bound
56
+ * `createDrain` persists as an absolute ISO instant, so a value beyond the
57
+ * Date range (e.g. 99999999999999999999d) is refused here instead of blowing
58
+ * up inside the record write. The absolute form must look like a calendar
59
+ * date: a bare number such as `3000` parses as a year in some engines, and an
60
+ * operator typing that almost certainly meant a duration.
61
+ */
62
+ function parseExpiry(raw: string, now: number): number | undefined {
63
+ const duration = DURATION_RE.exec(raw);
64
+ if (duration !== null) {
65
+ const expiresAt = now + Number.parseInt(duration[1]!, 10) * DURATION_UNIT_MS[duration[2]!]!;
66
+ return Number.isSafeInteger(expiresAt) ? expiresAt : undefined;
67
+ }
68
+ if (!/^\d{4}-\d{2}-\d{2}/.test(raw)) return undefined;
69
+ const absolute = Date.parse(raw);
70
+ return Number.isSafeInteger(absolute) ? absolute : undefined;
71
+ }
72
+
73
+ /**
74
+ * Rejects trailing tokens a drain subcommand does not declare, so a typo'd
75
+ * flag cannot be silently ignored — the same exit-2 scan `intake` and `stats`
76
+ * run. Both `--flag VALUE` and `--flag=VALUE` are accepted, matching the
77
+ * shared `flag()` parser.
78
+ */
79
+ function assertKnownArgs(ctx: CommandContext, sub: string): void {
80
+ const allowed = DRAIN_FLAGS[sub] ?? [];
81
+ for (let i = 2; i < ctx.argv.length; i++) {
82
+ const token = ctx.argv[i];
83
+ if (token === undefined) continue;
84
+ const eq = token.startsWith("--") ? token.indexOf("=") : -1;
85
+ const name = eq < 0 ? token : token.slice(0, eq);
86
+ if (allowed.includes(name)) {
87
+ if (eq < 0) i += 1; // consume the flag's value token
88
+ continue;
89
+ }
90
+ process.stderr.write(`omp-conductor: drain ${sub}: unexpected argument "${token}"\n`);
91
+ process.exit(2);
92
+ }
93
+ }
94
+
95
+ export async function drainCommand(ctx: CommandContext): Promise<void> {
96
+ const sub = ctx.argv[1];
97
+ if (sub === "--help" || sub === "-h") {
98
+ process.stdout.write(DRAIN_USAGE);
99
+ return;
100
+ }
101
+ if (sub !== "start" && sub !== "status" && sub !== "cancel") {
102
+ process.stderr.write("omp-conductor: drain needs start, status, or cancel\n");
103
+ process.exit(2);
104
+ }
105
+ assertKnownArgs(ctx, sub);
106
+ const project = findProject(loadConfig(), ctx.projectFlag);
107
+
108
+ if (sub === "start") {
109
+ const rawUntil = ctx.flag("until");
110
+ if (rawUntil === undefined || rawUntil.length === 0) {
111
+ process.stderr.write(
112
+ "omp-conductor: drain start needs --until with an ISO instant or a duration (90s, 45m, 2h, 1d)\n",
113
+ );
114
+ process.exit(2);
115
+ }
116
+ const now = Date.now();
117
+ const expiresAt = parseExpiry(rawUntil, now);
118
+ if (expiresAt === undefined) {
119
+ process.stderr.write(
120
+ `omp-conductor: drain start: "${rawUntil}" is not a bounded expiry — ` +
121
+ "use an ISO instant (e.g. 2026-08-20T10:00:00Z) or a duration (90s, 45m, 2h, 1d)\n",
122
+ );
123
+ process.exit(2);
124
+ }
125
+ if (expiresAt <= now) {
126
+ process.stderr.write(`omp-conductor: drain start: --until "${rawUntil}" must be in the future\n`);
127
+ process.exit(2);
128
+ }
129
+ const rawReason = ctx.flag("reason")?.trim().replace(/\s+/g, " ");
130
+ if (rawReason !== undefined && (rawReason === "" || rawReason.length > 500)) {
131
+ process.stderr.write("omp-conductor: drain start --reason needs 1-500 characters\n");
132
+ process.exit(2);
133
+ }
134
+ createDrain(project.name, {
135
+ expiresAt,
136
+ ...(rawReason === undefined ? {} : { reason: rawReason }),
137
+ });
138
+ process.stdout.write(
139
+ ok(
140
+ `drain started for ${project.name} — claiming paused until ${new Date(expiresAt).toISOString()}` +
141
+ (rawReason === undefined ? "" : ` (${rawReason})`),
142
+ ) + "\n",
143
+ );
144
+ process.stdout.write(
145
+ dim(
146
+ "the drain is a durable record: it survives crashes and resumes admission at the deadline on its own",
147
+ ) + "\n",
148
+ );
149
+ return;
150
+ }
151
+
152
+ if (sub === "status") {
153
+ const drain = statusSnapshot(project.name).drain;
154
+ if (drain === undefined) {
155
+ process.stdout.write(`drain: ${project.name} inactive — admission is open\n`);
156
+ return;
157
+ }
158
+ const lines = [
159
+ `drain: ${project.name} active`,
160
+ ` since ${new Date(drain.since).toISOString()}`,
161
+ ` expires at ${new Date(drain.expiresAt).toISOString()}`,
162
+ ...(drain.reason === undefined ? [] : [` reason ${drain.reason}`]),
163
+ ` remaining ${drain.remainingRuns} active run${drain.remainingRuns === 1 ? "" : "s"}`,
164
+ ];
165
+ process.stdout.write(`${lines.join("\n")}\n`);
166
+ return;
167
+ }
168
+
169
+ const had = readDrain(project.name);
170
+ cancelDrain(project.name);
171
+ process.stdout.write(
172
+ had.kind === "active"
173
+ ? ok(`drain cancelled for ${project.name} — admission resumes on the next dispatch pass`) + "\n"
174
+ : `drain: ${project.name} had no active drain — nothing to cancel\n`,
175
+ );
176
+ }
@@ -2,25 +2,21 @@
2
2
  * `extend` — raise a live run's turn ceiling, or set a bounded one-shot ceiling after a failed, killed, orphaned or blocked run.
3
3
  *
4
4
  * Moved out of cli.ts's switch by the per-verb module split (#462);
5
- * only the case wrapper, the injected `ctx` lookups and the imports
6
- * changed from the original bodies.
5
+ * the daemon discovery resolves through {@link requireDaemonControl}, the
6
+ * same run-control target `worker` uses, so a live systemd-run daemon
7
+ * stays reachable when its pidfile is missing (#811) and the refusal
8
+ * answers cannot drift between the two verbs.
7
9
  */
8
10
 
9
11
  import type { CommandContext } from "./context.ts";
10
12
  import { findProject, loadConfig } from "../config.ts";
11
- import { livingDaemon } from "../lifecycle.ts";
13
+ import { requireDaemonControl } from "../lifecycle.ts";
12
14
 
13
15
  export async function extendCommand(ctx: CommandContext): Promise<void> {
14
16
  const issue = ctx.issueArg("extend", ctx.argv[1]);
15
17
  const maxTurns = ctx.turnsFlag();
16
18
  const project = findProject(loadConfig(), ctx.projectFlag);
17
- const daemon = livingDaemon();
18
- if (daemon === undefined) throw new Error("daemon is not running");
19
- if (daemon.project !== undefined && daemon.project !== project.name) {
20
- throw new Error(
21
- `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
22
- );
23
- }
19
+ const daemon = await requireDaemonControl(project.name);
24
20
  const response = await fetch(
25
21
  `http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
26
22
  {
@@ -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 {
@@ -47,7 +47,6 @@ function styleStatus(text: string): string {
47
47
  .map((line) => {
48
48
  if (
49
49
  line === "caps" ||
50
- line === "daemon" ||
51
50
  line === "active runs" ||
52
51
  line === "active runs (none)" ||
53
52
  line.startsWith("project ")
@@ -56,6 +55,11 @@ function styleStatus(text: string): string {
56
55
  if (line.includes("STALLED")) return fail(line);
57
56
  if (/^(dispatch|ticks|pane|herdr|telegram| healthz)\s+.*\b(running|healthy|ok)\b/.test(line))
58
57
  return ok(line);
58
+ // The daemon headline carries its own three states (#685): green while
59
+ // serving, amber when the probe timed out or the pid is gone — a bare
60
+ // `daemon` heading line no longer exists to style.
61
+ if (/^daemon\s+.*\b(not running|unresponsive)\b/.test(line)) return warn(line);
62
+ if (/^daemon\s+running\b/.test(line)) return ok(line);
59
63
  if (/^(dispatch|ticks|daemon)\s+.*\b(paused|stopped|not running|overdue)\b/.test(line))
60
64
  return warn(line);
61
65
  return line;
@@ -21,31 +21,58 @@
21
21
  import type { CommandContext } from "./context.ts";
22
22
  import { findProject, loadConfig } from "../config.ts";
23
23
  import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
24
+ import { shellQuote } from "../shell.ts";
24
25
  import { dbPath, openStore } from "../store.ts";
25
26
 
26
- const WATCH_USAGE = `omp-conductor watch set a condition or carry note for the orchestrator itself.
27
+ /** The flags each watch subcommand accepts, after the subcommand itself.
28
+ * `add`'s positional note and `withdraw`'s positional id are validated by
29
+ * the branches below, not through this table. */
30
+ const WATCH_FLAGS: Readonly<Record<string, readonly string[]>> = {
31
+ add: ["--blocks", "--note", "--project", "--resolves-when"],
32
+ list: ["--json", "--project"],
33
+ withdraw: ["--project", "--reason"],
34
+ };
27
35
 
28
- usage:
29
- omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
30
- omp-conductor watch list [--project NAME] [--json]
31
- omp-conductor watch withdraw <id> [--reason TEXT] [--project NAME]
36
+ /** Flags that take no value — `list --json` must not consume the token after it. */
37
+ const WATCH_BOOL_FLAGS: Readonly<Record<string, true>> = { "--json": true };
32
38
 
33
- add records a row the daemon checks for you and the next tick reads, with no
34
- operator answer needed. list shows open watches, oldest first. withdraw ends
35
- one with a recorded reason the verb that creates a watch is the verb that
36
- ends it. A watch whose PR condition can no longer be observed (the PR merged
37
- or closed first) is withdrawn by the daemon itself.`;
39
+ /**
40
+ * Rejects any token after the subcommand that its surface does not declare,
41
+ * so a typo'd flag can never be silently ignored the defect where
42
+ * `--condition` created a watch with no condition at all. Both `--flag VALUE`
43
+ * and `--flag=VALUE` are accepted, matching the shared `flag()` parser; a
44
+ * value flag consumes the next token, a `--flag=VALUE` form or a boolean flag
45
+ * never does. The same exit-2 scan `drain` and `stats` run (#462).
46
+ */
47
+ function assertKnownWatchArgs(ctx: CommandContext, sub: string, from: number): void {
48
+ const allowed = WATCH_FLAGS[sub] ?? [];
49
+ for (let i = from; i < ctx.argv.length; i++) {
50
+ const token = ctx.argv[i];
51
+ if (token === undefined) continue;
52
+ const eq = token.startsWith("--") ? token.indexOf("=") : -1;
53
+ const name = eq < 0 ? token : token.slice(0, eq);
54
+ if (allowed.includes(name)) {
55
+ if (eq < 0 && WATCH_BOOL_FLAGS[name] !== true) i += 1; // consume the flag's value token
56
+ continue;
57
+ }
58
+ process.stderr.write(
59
+ `omp-conductor: watch ${sub}: unexpected argument "${token}" (known: ${allowed.join(" ")}) — nothing was recorded\n`,
60
+ );
61
+ process.exit(2);
62
+ }
63
+ }
38
64
 
39
65
  export async function watchCommand(ctx: CommandContext): Promise<void> {
66
+ // `--help`/`-h` never arrives here: the CLI dispatch gate answers a real
67
+ // help request from the manifest before this handler runs (#863), which is
68
+ // why the long-form prose lives on the manifest's `details` field rather
69
+ // than in a usage string this module would print itself.
40
70
  const sub = ctx.argv[1];
41
- if (sub === "--help" || sub === "-h") {
42
- process.stdout.write(WATCH_USAGE);
43
- return;
44
- }
45
71
  const project = findProject(loadConfig(), ctx.projectFlag);
46
72
  const store = openStore(dbPath());
47
73
  try {
48
74
  if (sub === "add") {
75
+ assertKnownWatchArgs(ctx, "add", 2);
49
76
  const note = ctx.flag("note")?.trim();
50
77
  if (note === undefined || note.length === 0 || note.startsWith("--")) {
51
78
  process.stderr.write("omp-conductor: watch add needs --note with what the next tick should know\n");
@@ -68,13 +95,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
68
95
  at: Date.now(),
69
96
  });
70
97
  const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
98
+ // The verb that ends the row names the project it was created in: this
99
+ // command is copied, and on a host with several projects the bare form is
100
+ // ambiguous (#810).
71
101
  process.stdout.write(
72
- `watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id}\n`,
102
+ `watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)}\n`,
73
103
  );
74
104
  return;
75
105
  }
76
106
 
77
107
  if (sub === "list" || sub === undefined) {
108
+ assertKnownWatchArgs(ctx, "list", 2);
78
109
  const open = store.openDecisions(project.name).filter((d) => d.kind === "watch");
79
110
  const now = Date.now();
80
111
  const watches = open.map((d) => ({
@@ -95,13 +126,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
95
126
  for (const watch of watches) {
96
127
  process.stdout.write(
97
128
  `${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
98
- `condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id})\n`,
129
+ // The listing already resolved the project, so the cleanup command
130
+ // it displays must carry it: an operator copying a command from
131
+ // this output should get one that runs as written (#810).
132
+ `condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)})\n`,
99
133
  );
100
134
  }
101
135
  return;
102
136
  }
103
137
 
104
138
  if (sub === "withdraw") {
139
+ assertKnownWatchArgs(ctx, "withdraw", 3);
105
140
  const id = ctx.argv[2];
106
141
  if (id === undefined || id.startsWith("--")) {
107
142
  process.stderr.write("omp-conductor: watch withdraw needs the watch id\n");