omp-conductor 0.18.1 → 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.
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";
@@ -55,6 +55,104 @@ import {
55
55
 
56
56
  const USAGE = renderUsage(COMMAND_MANIFEST);
57
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
+
58
156
  /** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */
59
157
  function flag(argv: string[], name: string): string | undefined {
60
158
  const i = argv.indexOf(`--${name}`);
@@ -254,6 +352,29 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
254
352
  );
255
353
  process.exit(2);
256
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
+ }
257
378
  // A "none"-scope verb does no per-project work, so --project cannot change
258
379
  // what it does: refuse it with a reason instead of silently ignoring it.
259
380
  if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) {
@@ -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" },
@@ -418,6 +417,11 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
418
417
  "watch list [--project NAME] [--json]",
419
418
  "watch withdraw <id> [--reason TEXT] [--project NAME]",
420
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.`,
421
425
  subcommands: [
422
426
  { name: "add", description: "record a watch" },
423
427
  { name: "list", description: "list open watches" },
@@ -443,6 +447,12 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
443
447
  "intake dismiss <id> [--project NAME]",
444
448
  "intake groomed <id> --issue <url> [--project NAME]",
445
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.`,
446
456
  subcommands: [
447
457
  { name: "list", description: "list pending intake items" },
448
458
  { name: "dismiss", description: "dismiss an intake item" },
@@ -456,7 +466,6 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
456
466
  description: "print command usage (also --help, -h)",
457
467
  scope: "none",
458
468
  usage: ["help"],
459
- details: COMMAND_HELP_TAIL,
460
469
  flags: [],
461
470
  },
462
471
  {
@@ -480,9 +489,11 @@ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_
480
489
  const commands = manifest.map(
481
490
  (command) => ` ${command.name.padEnd(width)} ${command.description}`,
482
491
  );
483
- const details = manifest.flatMap((command) =>
484
- command.details === undefined ? [] : [...command.details.split("\n"), ""],
485
- );
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"), ""];
486
497
  return [
487
498
  "omp-conductor — dispatch ready issues to omp coding sessions",
488
499
  "",
@@ -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
@@ -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 {
@@ -24,19 +24,6 @@ import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
24
24
  import { shellQuote } from "../shell.ts";
25
25
  import { dbPath, openStore } from "../store.ts";
26
26
 
27
- const WATCH_USAGE = `omp-conductor watch — set a condition or carry note for the orchestrator itself.
28
-
29
- usage:
30
- omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
31
- omp-conductor watch list [--project NAME] [--json]
32
- omp-conductor watch withdraw <id> [--reason TEXT] [--project NAME]
33
-
34
- add records a row the daemon checks for you and the next tick reads, with no
35
- operator answer needed. list shows open watches, oldest first. withdraw ends
36
- one with a recorded reason — the verb that creates a watch is the verb that
37
- ends it. A watch whose PR condition can no longer be observed (the PR merged
38
- or closed first) is withdrawn by the daemon itself.`;
39
-
40
27
  /** The flags each watch subcommand accepts, after the subcommand itself.
41
28
  * `add`'s positional note and `withdraw`'s positional id are validated by
42
29
  * the branches below, not through this table. */
@@ -76,11 +63,11 @@ function assertKnownWatchArgs(ctx: CommandContext, sub: string, from: number): v
76
63
  }
77
64
 
78
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.
79
70
  const sub = ctx.argv[1];
80
- if (sub === "--help" || sub === "-h") {
81
- process.stdout.write(WATCH_USAGE);
82
- return;
83
- }
84
71
  const project = findProject(loadConfig(), ctx.projectFlag);
85
72
  const store = openStore(dbPath());
86
73
  try {
@@ -31,6 +31,7 @@ import {
31
31
  DEFAULT_AUTHORITY,
32
32
  DEFAULT_CAPS,
33
33
  DEFAULT_PROJECT_POLICY,
34
+ DEFAULT_REVIEW_ADJUDICATOR_ROLE,
34
35
  DEFAULT_REVIEW_MAX_ROUNDS,
35
36
  DEFAULT_REVIEW_STRICTNESS,
36
37
  DRAFT_POLICIES,
@@ -41,6 +42,7 @@ import {
41
42
  RELEASE_REQUIREMENTS,
42
43
  RELEASE_SHAPES,
43
44
  REPORT_SCOPES,
45
+ REVIEW_ADJUDICATOR_RE,
44
46
  REVIEW_MAX_ROUNDS_MAX,
45
47
  REVIEW_MAX_ROUNDS_MIN,
46
48
  REVIEW_STRICTNESS,
@@ -265,11 +267,15 @@ const armSchema = z
265
267
 
266
268
  /**
267
269
  * The per-project review policy (#678): how strictly green PRs are reviewed
268
- * and returned, and the hard ceiling on review rounds per PR lifecycle. Absent
269
- * or a legacy config without the key loads as `medium` with
270
- * {@link DEFAULT_REVIEW_MAX_ROUNDS} rounds the recommended default for a new
271
- * project, materialised deterministically for every existing one, with the
272
- * schema bounds and the runtime defaults read from the same constants.
270
+ * and returned, the hard ceiling on review rounds per PR lifecycle, and which
271
+ * OMP model role carries the terminal review-ceiling adjudication (#875).
272
+ * Absent or a legacy config without the keys loads as `medium` with
273
+ * {@link DEFAULT_REVIEW_MAX_ROUNDS} rounds and
274
+ * {@link DEFAULT_REVIEW_ADJUDICATOR_ROLE} as adjudicator the recommended
275
+ * defaults for a new project, materialised deterministically for every
276
+ * existing one, with the schema bounds and the runtime defaults read from the
277
+ * same constants. The adjudicator names a role only; a provider/model is
278
+ * refused here, because OMP owns exact provider selection.
273
279
  */
274
280
  const reviewSchema = z
275
281
  .object({
@@ -280,9 +286,16 @@ const reviewSchema = z
280
286
  .min(REVIEW_MAX_ROUNDS_MIN)
281
287
  .max(REVIEW_MAX_ROUNDS_MAX)
282
288
  .default(DEFAULT_REVIEW_MAX_ROUNDS),
289
+ adjudicator: z
290
+ .string()
291
+ .regex(
292
+ REVIEW_ADJUDICATOR_RE,
293
+ "must name one OMP model role — a single role token like \"task\" (never a provider/model, which omp owns)",
294
+ )
295
+ .default(DEFAULT_REVIEW_ADJUDICATOR_ROLE),
283
296
  })
284
297
  .strict()
285
- .describe("Review strictness and round ceiling for green PRs");
298
+ .describe("Review strictness, round ceiling and adjudicator role for green PRs");
286
299
 
287
300
  const releasePolicySchema = z.union([
288
301
  releasePolicyLegacyEnum,
package/src/config.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  DEFAULT_PROJECT_POLICY,
29
29
  DEFAULT_REPORT_POLICY,
30
30
  DEFAULT_REPORT_SCOPE,
31
+ DEFAULT_REVIEW_ADJUDICATOR_ROLE,
31
32
  DEFAULT_REVIEW_MAX_ROUNDS,
32
33
  DEFAULT_REVIEW_POLICY,
33
34
  DEFAULT_REVIEW_STRICTNESS,
@@ -349,12 +350,20 @@ export function resolveArmProof(p: ProjectConfig): ArmProof {
349
350
  * The loader always materialises `review` (finalizeProject), so this is only
350
351
  * ever the fallback for a `ProjectConfig` that never went through it — a
351
352
  * hand-built one in a test, or a config written before the key existed.
352
- * Absent resolves to {@link DEFAULT_REVIEW_POLICY}, the documented migration
353
- * default every existing install deterministically upgrades to. A fresh object
354
- * each call, so mutating the result cannot reach the config it came from.
353
+ * Absent (or a legacy config that stopped at strictness and maxRounds)
354
+ * resolves to {@link DEFAULT_REVIEW_POLICY}, the documented migration default
355
+ * every existing install deterministically upgrades to the adjudicator role
356
+ * included, so no consumer ever reads a policy without one (#875). A fresh
357
+ * object each call, so mutating the result cannot reach the config it came
358
+ * from.
355
359
  */
356
360
  export function resolveReview(p: ProjectConfig): ReviewPolicy {
357
- return { ...(p.review ?? DEFAULT_REVIEW_POLICY) };
361
+ const base = p.review ?? DEFAULT_REVIEW_POLICY;
362
+ return {
363
+ strictness: base.strictness ?? DEFAULT_REVIEW_STRICTNESS,
364
+ maxRounds: base.maxRounds ?? DEFAULT_REVIEW_MAX_ROUNDS,
365
+ adjudicator: base.adjudicator ?? DEFAULT_REVIEW_ADJUDICATOR_ROLE,
366
+ };
358
367
  }
359
368
 
360
369
  /**
@@ -889,11 +898,14 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
889
898
  return `policy must be an object with "merge" and "release" sections`;
890
899
  }
891
900
  if (rel.length === 1 && relStr === "review") {
892
- return `review must be an object with "strictness" and "maxRounds"`;
901
+ return `review must be an object with "strictness", "maxRounds" and "adjudicator"`;
893
902
  }
894
903
  if (rel.length === 2 && rel[0] === "review" && rel[1] === "maxRounds") {
895
904
  return `review.maxRounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX}, found ${found()}`;
896
905
  }
906
+ if (rel.length === 2 && rel[0] === "review" && rel[1] === "adjudicator") {
907
+ return `review.adjudicator must be a string naming one OMP model role, found ${found()}`;
908
+ }
897
909
  if (rel.length === 2 && relStr === "policy") return `${pathStr} must be an object`;
898
910
  if (rel.length === 1 && relStr === "caps") return `caps must be an object`;
899
911
  if (rel.length === 2 && relStr === "caps" && rel[1] === "planUsage") {
@@ -944,7 +956,7 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
944
956
  if (issue.code === "unrecognized_keys") {
945
957
  const keys = (issue.keys ?? []).join(", ");
946
958
  if (pathStr === "policy") return `policy has unknown key(s): ${keys} — expected "merge" or "release"`;
947
- if (pathStr === "review") return `review has unknown key(s): ${keys} — expected "strictness" and "maxRounds"`;
959
+ if (pathStr === "review") return `review has unknown key(s): ${keys} — expected "strictness", "maxRounds" and "adjudicator"`;
948
960
  if (pathStr === "policy.merge") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_MERGE_KEYS}`;
949
961
  if (pathStr === "policy.release") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_RELEASE_KEYS}`;
950
962
  if (pathStr === "caps.planUsage") {
@@ -1320,14 +1332,17 @@ function finalizeArm(parsed: Raw | undefined): ProjectConfig["arm"] {
1320
1332
  * {@link DEFAULT_REVIEW_POLICY}, the documented migration default, so an
1321
1333
  * upgrade changes no existing project's behaviour beyond what its operators
1322
1334
  * then choose in setup. zod has already rejected anything outside the
1323
- * strictness vocabulary or the validated round range, so this only fills the
1324
- * absent-case default.
1335
+ * strictness vocabulary, the validated round range or the adjudicator role
1336
+ * token grammar, so this only fills the absent-case defaults — the
1337
+ * adjudicator included (#875), deterministically the same role a new
1338
+ * project's setup starts from.
1325
1339
  */
1326
1340
  function finalizeReview(parsed: Raw | undefined): ProjectConfig["review"] {
1327
1341
  if (parsed === undefined) return { ...DEFAULT_REVIEW_POLICY };
1328
1342
  return {
1329
1343
  strictness: (parsed["strictness"] as ReviewStrictness | undefined) ?? DEFAULT_REVIEW_STRICTNESS,
1330
1344
  maxRounds: (parsed["maxRounds"] as number | undefined) ?? DEFAULT_REVIEW_MAX_ROUNDS,
1345
+ adjudicator: (parsed["adjudicator"] as string | undefined) ?? DEFAULT_REVIEW_ADJUDICATOR_ROLE,
1331
1346
  };
1332
1347
  }
1333
1348