omp-conductor 0.2.1 → 0.3.1

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/config.ts CHANGED
@@ -16,9 +16,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
16
16
  import { homedir } from "node:os";
17
17
  import { dirname, join } from "node:path";
18
18
  import {
19
+ AUTHORITY_HOLDERS,
19
20
  CONFIG_VERSION,
21
+ DEFAULT_AUTHORITY,
20
22
  DEFAULT_CAPS,
21
23
  DEFAULT_REPORT_SCOPE,
24
+ ORCHESTRATOR_MODES,
22
25
  READABLE_CONFIG_VERSIONS,
23
26
  REPORT_SCOPES,
24
27
  type Caps,
@@ -43,8 +46,10 @@ type Raw = { readonly [key: string]: unknown };
43
46
  /** Derived from the data so a new `Caps` field cannot be silently ignored. */
44
47
  const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
45
48
 
46
- /** Quoted for error messages, from the same data the guard below reads. */
47
- const REPORT_SCOPE_LIST = REPORT_SCOPES.map((s) => `"${s}"`).join(" or ");
49
+ /** Quoted for error messages, from the same data the guards below read. */
50
+ const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
51
+ const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
52
+ const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
48
53
 
49
54
  /** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
50
55
  const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
@@ -267,13 +272,8 @@ function normalizeProject(
267
272
 
268
273
  const stateLabels = raw["stateLabels"] as Raw | undefined;
269
274
 
270
- const escalationIn = raw["escalation"] as Raw | undefined;
271
- const chatId = escalationIn?.["telegramChatId"];
272
- const escalation: ProjectConfig["escalation"] = {
273
- // Absent means "yes, still tell me": a silently stuck run is the worst case.
274
- fallbackToIssueComment: escalationIn?.["fallbackToIssueComment"] !== false,
275
- };
276
- if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
275
+ const escalation = normalizeEscalation(raw["escalation"], label, problems);
276
+ const authority = normalizeAuthority(raw["authority"], label, problems);
277
277
 
278
278
  const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
279
279
  const reporting = normalizeReporting(raw["reporting"], label, problems);
@@ -298,6 +298,7 @@ function normalizeProject(
298
298
  caps,
299
299
  ...(workerModel === undefined ? {} : { workerModel }),
300
300
  escalation,
301
+ authority,
301
302
  reporting,
302
303
  workspaceRoot: expandHome(pickString(raw["workspaceRoot"], join(stateDir(), "worktrees"))),
303
304
  mirrorRoot: expandHome(pickString(raw["mirrorRoot"], join(stateDir(), "mirrors"))),
@@ -326,14 +327,92 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
326
327
  problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
327
328
  }
328
329
 
329
- const declared = raw["scope"];
330
- if (declared === undefined) return { scope: DEFAULT_REPORT_SCOPE };
331
- const scope = REPORT_SCOPES.find((s) => s === declared);
332
- if (scope === undefined) {
333
- problems.push(`${label}: reporting.scope must be ${REPORT_SCOPE_LIST}, found ${JSON.stringify(declared)}`);
334
- return { scope: DEFAULT_REPORT_SCOPE };
330
+ return {
331
+ scope: pickLiteral(
332
+ raw["scope"],
333
+ REPORT_SCOPES,
334
+ DEFAULT_REPORT_SCOPE,
335
+ `${label}: reporting.scope`,
336
+ REPORT_SCOPE_LIST,
337
+ problems,
338
+ ),
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Who triages escalations, and how they are delivered when nobody answers.
344
+ *
345
+ * `orchestrator` is validated rather than folded to the default for the reason
346
+ * `reporting.scope` is: a misspelt `"externl"` that quietly resolved to
347
+ * `"embedded"` would start a second brain beside the operator's own session,
348
+ * and both of them would triage the same issue from different transcripts.
349
+ */
350
+ function normalizeEscalation(parsed: unknown, label: string, problems: string[]): ProjectConfig["escalation"] {
351
+ let raw: Raw = {};
352
+ if (parsed !== undefined) {
353
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) raw = parsed as Raw;
354
+ else problems.push(`${label}: escalation must be an object`);
355
+ }
356
+
357
+ const escalation: ProjectConfig["escalation"] = {
358
+ // Absent means "yes, still tell me": a silently stuck run is the worst case.
359
+ fallbackToIssueComment: raw["fallbackToIssueComment"] !== false,
360
+ orchestrator: pickLiteral(
361
+ raw["orchestrator"],
362
+ ORCHESTRATOR_MODES,
363
+ "embedded",
364
+ `${label}: escalation.orchestrator`,
365
+ ORCHESTRATOR_MODE_LIST,
366
+ problems,
367
+ ),
368
+ };
369
+ const chatId = raw["telegramChatId"];
370
+ if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
371
+ return escalation;
372
+ }
373
+
374
+ /**
375
+ * Who lands PRs and who cuts releases. Both default to the human: this is the
376
+ * one config value that decides whether an unattended session may write to a
377
+ * main branch, so it is granted explicitly or not at all.
378
+ *
379
+ * Unknown keys are rejected outright, as in `reporting` and for the same
380
+ * reason: the object has exactly two members, so an unrecognised one is a typo
381
+ * every time — and a `authority: { merges: "orchestrator" }` that loaded
382
+ * cleanly would read as delegated while the orchestrator was still told to keep
383
+ * its hands off.
384
+ */
385
+ function normalizeAuthority(parsed: unknown, label: string, problems: string[]): ProjectConfig["authority"] {
386
+ if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
387
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
388
+ problems.push(`${label}: authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`);
389
+ return { ...DEFAULT_AUTHORITY };
335
390
  }
336
- return { scope };
391
+ const raw = parsed as Raw;
392
+
393
+ const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
394
+ if (unknownKeys.length > 0) {
395
+ problems.push(`${label}: authority has unknown key(s): ${unknownKeys.join(", ")}`);
396
+ }
397
+
398
+ return {
399
+ merge: pickLiteral(
400
+ raw["merge"],
401
+ AUTHORITY_HOLDERS,
402
+ DEFAULT_AUTHORITY.merge,
403
+ `${label}: authority.merge`,
404
+ AUTHORITY_HOLDER_LIST,
405
+ problems,
406
+ ),
407
+ release: pickLiteral(
408
+ raw["release"],
409
+ AUTHORITY_HOLDERS,
410
+ DEFAULT_AUTHORITY.release,
411
+ `${label}: authority.release`,
412
+ AUTHORITY_HOLDER_LIST,
413
+ problems,
414
+ ),
415
+ };
337
416
  }
338
417
 
339
418
  function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
@@ -439,6 +518,38 @@ function pickString(v: unknown, fallback: string): string {
439
518
  return nonEmptyString(v) ? v : fallback;
440
519
  }
441
520
 
521
+ /** Quoted alternatives for an error message, from the same data the guard reads. */
522
+ function quoteList(values: readonly string[]): string {
523
+ return values.map((v) => `"${v}"`).join(" or ");
524
+ }
525
+
526
+ /**
527
+ * One rule for "a declared literal out of a closed set, else the documented
528
+ * default", used by every such field here.
529
+ *
530
+ * Absent takes the default silently; a value outside the set is always
531
+ * reported and never folded. Each of these sets decides something the operator
532
+ * would otherwise believe they had configured — who merges, who triages, how
533
+ * loud the fleet is — and a typo that resolves to the default reads exactly
534
+ * like a deliberate choice in the file afterwards.
535
+ */
536
+ function pickLiteral<T extends string>(
537
+ v: unknown,
538
+ allowed: readonly T[],
539
+ fallback: T,
540
+ field: string,
541
+ quoted: string,
542
+ problems: string[],
543
+ ): T {
544
+ if (v === undefined) return fallback;
545
+ const hit = allowed.find((a) => a === v);
546
+ if (hit === undefined) {
547
+ problems.push(`${field} must be ${quoted}, found ${JSON.stringify(v)}`);
548
+ return fallback;
549
+ }
550
+ return hit;
551
+ }
552
+
442
553
  /** `~/x` in a hand-written config must not create a literal `~` directory. */
443
554
  function expandHome(p: string): string {
444
555
  if (p === "~") return homedir();