omp-conductor 0.13.0 → 0.15.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 (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
package/src/config.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  REPORT_SCOPES,
38
38
  INTERRUPT_CATEGORIES,
39
39
  DIGEST_CADENCES,
40
+ WEEKDAYS,
40
41
  type Caps,
41
42
  type ConductorConfig,
42
43
  type DigestCadence,
@@ -49,38 +50,43 @@ import {
49
50
  type ReleaseRequirement,
50
51
  type ReportScope,
51
52
  type ReportingPolicy,
53
+ type Weekday,
54
+ type WeeklyAvailability,
52
55
  type RepoTarget,
53
56
  type ResolvedGrants,
54
57
  } from "./types.ts";
58
+ import {
59
+ AUTHORITY_HOLDER_LIST,
60
+ BASE_FRESHNESS_LIST,
61
+ BEHIND_BASE_ACTION_LIST,
62
+ ConfigSchema,
63
+ DIGEST_CADENCE_LIST,
64
+ DRAFT_POLICY_LIST,
65
+ INTERRUPT_CATEGORY_LIST,
66
+ LEGACY_RELEASE_POLICY_LIST,
67
+ ORCHESTRATOR_MODE_LIST,
68
+ RELEASE_REQUIREMENT_LIST,
69
+ RELEASE_SHAPE_LIST,
70
+ REPORT_SCOPE_LIST,
71
+ WEEKDAY_LIST,
72
+ quoteList,
73
+ } from "./config-schema.ts";
55
74
 
56
75
  /**
57
76
  * A JSON node whose fields are all still unproven. Reading a field off a
58
77
  * non-object (string, number, null) yields `undefined` at runtime, so every
59
- * field read below is safe and the `typeof` checks on the values do the real
60
- * validating — no structural guard needed.
78
+ * field read below is safe.
61
79
  *
62
- * ponytail: this is hand-rolled validation, not a schema. It stays honest only
63
- * because `ConductorConfig` is small; the upgrade path when it grows is to
64
- * parse with zod/valibot at this one boundary and delete `validate` below.
80
+ * ponytail: this is no longer the validator `ConfigSchema` in
81
+ * `config-schema.ts` owns every shape/type/enum/required-optional decision, and
82
+ * the normalisers below read already-validated values and apply only what zod
83
+ * cannot express.
65
84
  */
66
85
  type Raw = { readonly [key: string]: unknown };
67
86
 
68
87
  /** Derived from the data so a new `Caps` field cannot be silently ignored. */
69
88
  const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
70
89
 
71
- /** Quoted for error messages, from the same data the guards below read. */
72
- const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
73
- const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES);
74
- const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES);
75
- const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
76
- const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
77
- const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
78
- const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
79
- const BASE_FRESHNESS_LIST = quoteList(BASE_FRESHNESS);
80
- const DRAFT_POLICY_LIST = quoteList(DRAFT_POLICIES);
81
- const BEHIND_BASE_ACTION_LIST = quoteList(BEHIND_BASE_ACTIONS);
82
- const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
83
-
84
90
  /** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
85
91
  const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
86
92
 
@@ -142,7 +148,7 @@ export function loadConfig(): ConductorConfig {
142
148
  const path = configPath();
143
149
 
144
150
  if (!existsSync(path)) {
145
- throw new Error(`No conductor config at ${path} — run /conductor setup to create one.`);
151
+ throw new Error(`No conductor config at ${path} — run \`omp-conductor setup\` to create one.`);
146
152
  }
147
153
 
148
154
  let raw: string;
@@ -182,9 +188,23 @@ export function saveConfig(c: ConductorConfig): void {
182
188
  * rewrote a live fleet's config into a dialect the previous release rejected.
183
189
  */
184
190
  export function writeConfigFile(c: unknown): void {
185
- writeConfigRaw(`${JSON.stringify(c, null, 2)}\n`);
191
+ const withSchema =
192
+ typeof c === "object" && c !== null && !Array.isArray(c) && "version" in c && !("$schema" in c)
193
+ ? { ...(c as Record<string, unknown>), $schema: SCHEMA_URI }
194
+ : c;
195
+ writeConfigRaw(`${JSON.stringify(withSchema, null, 2)}\n`);
186
196
  }
187
197
 
198
+ /**
199
+ * Where the shipped `config.schema.json` is installed, referenced from the top
200
+ * of every config `saveConfig` writes. It is derived from this module's own
201
+ * location (`import.meta.dir` is the installed package's `src/`), so the URI
202
+ * points at the actual installed copy rather than a dangling relative path —
203
+ * the config lives at `$OMP_CONDUCTOR_HOME/config.json`, which has no
204
+ * `node_modules` beside it.
205
+ */
206
+ const SCHEMA_URI = join(dirname(import.meta.dir), "schema", "config.schema.json");
207
+
188
208
  /**
189
209
  * Atomic write of exact bytes.
190
210
  *
@@ -349,20 +369,621 @@ export function findProject(c: ConductorConfig, name?: string): ProjectConfig {
349
369
  }
350
370
 
351
371
  // ---------------------------------------------------------------------------
352
- // validation / normalisation
372
+ // load boundary: zod parse + residue normalisation
353
373
  // ---------------------------------------------------------------------------
354
374
 
375
+ /**
376
+ * Validates and normalises an on-disk config into a `ConductorConfig`.
377
+ *
378
+ * Per-field shape, type, enum, numeric-bound and required/optional validity is
379
+ * enforced entirely by `ConfigSchema` (zod) at this boundary — the hand-rolled
380
+ * field guards that used to live below are gone. What zod cannot express is
381
+ * done here as a thin residue layer, in today's exact wording:
382
+ *
383
+ * - cross-field coherence (reporting scope-vs-explicit, digest/availability
384
+ * times, `start !== end`);
385
+ * - the version-keyed cap migration (v1 drops a retired key, v2 rejects it);
386
+ * - the clone-URL credential rejection; and
387
+ * - legacy-key / path normalisation (defaults, presets, `~` and default roots).
388
+ */
355
389
  function validate(parsed: unknown, path: string): ConductorConfig {
356
390
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
357
- throw new Error(`Conductor config at ${path} must be a JSON object — run /conductor setup to recreate it.`);
391
+ throw new Error(`Conductor config at ${path} must be a JSON object — run \`omp-conductor setup\` to recreate it.`);
392
+ }
393
+ const shape = ConfigSchema.safeParse(parsed);
394
+ if (!shape.success) {
395
+ // Shape faults from zod, plus the cross-field/credential complaints that
396
+ // only the residue layer can voice. For the nested objects below, zod stops
397
+ // collecting member faults once any sibling fails, so zod's issues for
398
+ // those objects are folded into a single accumulated pass that reports
399
+ // every faulty member (matching the envelope's "whole list" promise).
400
+ const expanded = shape.error.issues.flatMap(expandIssue);
401
+ const problems = [
402
+ ...expanded.filter((issue) => !inAccumulatedObject(issue.path)).map((issue) => issueToProblem(issue, parsed)),
403
+ ...crossFieldProblems(parsed),
404
+ ...nestedObjectProblems(parsed),
405
+ ];
406
+ throw new Error(problemEnvelope(path, problems));
407
+ }
408
+ return finalize(shape.data as unknown, path);
409
+ }
410
+
411
+ /**
412
+ * True when a zod issue path lives inside one of the objects whose member
413
+ * faults must all be reported together (availability, digest, caps, authority,
414
+ * per-repo gates). zod skips those once a sibling fails, so the failure path
415
+ * drops zod's issues for them and lets {@link nestedObjectProblems} own them.
416
+ */
417
+ function inAccumulatedObject(path: readonly PropertyKey[]): boolean {
418
+ if (path[0] !== "projects" || typeof path[1] !== "number") return false;
419
+ const rel = path.slice(2).map(String);
420
+ if (rel[0] === "caps" || rel[0] === "authority") return true;
421
+ if (rel[0] === "reporting" && (rel[1] === "digest" || rel[1] === "availability")) return true;
422
+ if (rel[0] === "routing" && rel[1] === "repos" && rel[3] === "gates") return true;
423
+ return false;
424
+ }
425
+
426
+ /**
427
+ * Reports every faulty member of the nested objects in one pass, in today's
428
+ * wording, so an operator hears the whole list the first time. zod cannot do
429
+ * this (it abandons an object's remaining members once one fails), so the
430
+ * failure path substitutes this for zod's issues inside those objects.
431
+ * Cross-field items (scope-vs-explicit, at-only-with-daily, timezone match,
432
+ * start != end, clone URLs) are {@link crossFieldProblems}' job, not this one's.
433
+ */
434
+ function nestedObjectProblems(parsed: unknown): string[] {
435
+ const problems: string[] = [];
436
+ const root = parsed as Raw | undefined;
437
+ const list = root?.["projects"];
438
+ if (!Array.isArray(list)) return problems;
439
+ const legacyCaps = root?.["version"] === 1;
440
+
441
+ list.forEach((p: unknown, i) => {
442
+ const proj = p as Raw;
443
+ if (typeof proj !== "object" || proj === null) return;
444
+ const name = proj["name"];
445
+ const label = typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${i}]`;
446
+
447
+ // availability — every member, even when days already failed.
448
+ const reporting = typeof proj["reporting"] === "object" && proj["reporting"] !== null && !Array.isArray(proj["reporting"])
449
+ ? (proj["reporting"] as Raw)
450
+ : undefined;
451
+ const availability = reporting !== undefined && typeof reporting["availability"] === "object" && reporting["availability"] !== null && !Array.isArray(reporting["availability"])
452
+ ? (reporting["availability"] as Raw)
453
+ : undefined;
454
+ if (availability !== undefined) checkAvailability(availability, label, problems);
455
+
456
+ // digest — every member even when cadence already failed.
457
+ if (reporting !== undefined && typeof reporting["digest"] === "object" && reporting["digest"] !== null && !Array.isArray(reporting["digest"])) {
458
+ checkDigest(reporting["digest"] as Raw, label, problems);
459
+ }
460
+
461
+ // authority
462
+ const authority = proj["authority"];
463
+ if (typeof authority === "object" && authority !== null && !Array.isArray(authority)) {
464
+ checkAuthority(authority as Raw, label, problems);
465
+ }
466
+
467
+ // caps
468
+ reconcileCaps(proj["caps"], `${label}: caps`, problems, legacyCaps);
469
+
470
+ // per-repo gates (two bad gates must both surface)
471
+ const routing = typeof proj["routing"] === "object" && proj["routing"] !== null && !Array.isArray(proj["routing"])
472
+ ? (proj["routing"] as Raw)
473
+ : undefined;
474
+ const repos = routing !== undefined && typeof routing["repos"] === "object" && routing["repos"] !== null && !Array.isArray(routing["repos"])
475
+ ? (routing["repos"] as Record<string, unknown>)
476
+ : undefined;
477
+ if (repos !== undefined) {
478
+ for (const [key, entry] of Object.entries(repos)) {
479
+ const e = entry as Raw | undefined;
480
+ if (e === undefined || typeof e !== "object") continue;
481
+ const gates = e["gates"];
482
+ if (gates === undefined) continue;
483
+ const base = `${label}: routing.repos.${key}`;
484
+ if (!Array.isArray(gates)) {
485
+ problems.push(`${base}.gates must be an array of { cmd, cwd }`);
486
+ continue;
487
+ }
488
+ gates.forEach((g: unknown, gi: number) => {
489
+ const cmd = (g as Raw | undefined)?.["cmd"];
490
+ if (typeof cmd !== "string" || cmd.trim() === "") {
491
+ problems.push(`${base}.gates[${gi}] must be { cmd, cwd } with a non-empty cmd`);
492
+ }
493
+ });
494
+ }
495
+ }
496
+ });
497
+ return problems;
498
+ }
499
+
500
+ function checkAvailability(a: Raw, label: string, problems: string[]): void {
501
+ if (typeof a["timezone"] !== "string" || a["timezone"].trim() === "") {
502
+ problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`);
503
+ } else {
504
+ try {
505
+ new Intl.DateTimeFormat("en-GB", { timeZone: a["timezone"] });
506
+ } catch {
507
+ problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`);
508
+ }
509
+ }
510
+ const days = a["days"];
511
+ if (!Array.isArray(days) || days.length === 0) {
512
+ problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`);
513
+ } else {
514
+ for (const d of days) {
515
+ if (typeof d !== "string" || !(WEEKDAYS as readonly string[]).includes(d)) {
516
+ problems.push(`${label}: reporting.availability.days has unknown day ${JSON.stringify(d)} — one of ${WEEKDAY_LIST}`);
517
+ }
518
+ }
519
+ }
520
+ if (typeof a["start"] !== "string" || !DIGEST_AT.test(a["start"])) {
521
+ problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`);
522
+ }
523
+ if (typeof a["end"] !== "string" || !DIGEST_AT.test(a["end"])) {
524
+ problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`);
525
+ }
526
+ if (!Array.isArray(a["bypass"])) {
527
+ problems.push(`${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`);
528
+ } else {
529
+ for (const b of a["bypass"]) {
530
+ if (typeof b !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(b)) {
531
+ problems.push(`${label}: reporting.availability.bypass has unknown category ${JSON.stringify(b)} — one of ${INTERRUPT_CATEGORY_LIST}`);
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ function checkDigest(d: Raw, label: string, problems: string[]): void {
538
+ if (typeof d["cadence"] !== "string" || !(DIGEST_CADENCES as readonly string[]).includes(d["cadence"])) {
539
+ problems.push(`${label}: reporting.digest.cadence must be ${DIGEST_CADENCE_LIST}`);
540
+ }
541
+ if (d["at"] !== undefined && (typeof d["at"] !== "string" || !DIGEST_AT.test(d["at"]))) {
542
+ problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
543
+ }
544
+ if (d["timezone"] !== undefined) {
545
+ if (typeof d["timezone"] !== "string") {
546
+ problems.push(`${label}: reporting.digest.timezone must be a string`);
547
+ } else {
548
+ try {
549
+ new Intl.DateTimeFormat("en-GB", { timeZone: d["timezone"] });
550
+ } catch {
551
+ problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ function checkAuthority(a: Raw, label: string, problems: string[]): void {
558
+ const merge = a["merge"];
559
+ const release = a["release"];
560
+ if (merge !== undefined && typeof merge === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(merge)) {
561
+ problems.push(`${label}: authority.merge must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(merge)}`);
562
+ }
563
+ if (release !== undefined && typeof release === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(release)) {
564
+ problems.push(`${label}: authority.release must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(release)}`);
565
+ }
566
+ const unknown = Object.keys(a).filter((k) => k !== "merge" && k !== "release");
567
+ if (unknown.length > 0) problems.push(`${label}: authority has unknown key(s): ${unknown.join(", ")}`);
568
+ }
569
+
570
+ /**
571
+ * The cross-field and credential complaints zod cannot express, stated against
572
+ * the raw document so they are reported even when a sibling key fails its
573
+ * shape. On the success path the same checks run inside `finalize`; this is the
574
+ * failure-path twin, keeping the multi-problem envelope cumulative.
575
+ */
576
+ function crossFieldProblems(parsed: unknown): string[] {
577
+ const problems: string[] = [];
578
+ const list = (parsed as Raw | undefined)?.["projects"];
579
+ if (!Array.isArray(list)) return problems;
580
+ list.forEach((p: unknown, i) => {
581
+ const proj = p as Raw;
582
+ if (typeof proj !== "object" || proj === null) return;
583
+ const name = proj["name"];
584
+ const label = typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${i}]`;
585
+
586
+ const reporting = proj["reporting"];
587
+ if (typeof reporting === "object" && reporting !== null && !Array.isArray(reporting)) {
588
+ const r = reporting as Raw;
589
+ const hasScope = r["scope"] !== undefined;
590
+ const hasExplicit =
591
+ r["interruptOn"] !== undefined || r["digest"] !== undefined || r["availability"] !== undefined;
592
+ if (hasScope && hasExplicit) {
593
+ problems.push(
594
+ `${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`,
595
+ );
596
+ }
597
+ const digest = typeof r["digest"] === "object" && r["digest"] !== null && !Array.isArray(r["digest"])
598
+ ? (r["digest"] as Raw)
599
+ : undefined;
600
+ if (digest !== undefined && digest["cadence"] !== undefined && digest["cadence"] !== "daily") {
601
+ if (digest["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
602
+ if (digest["timezone"] !== undefined) {
603
+ problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
604
+ }
605
+ }
606
+ const availability = typeof r["availability"] === "object" && r["availability"] !== null && !Array.isArray(r["availability"])
607
+ ? (r["availability"] as Raw)
608
+ : undefined;
609
+ if (availability !== undefined) {
610
+ const start = availability["start"];
611
+ const end = availability["end"];
612
+ if (typeof start === "string" && typeof end === "string" && start === end) {
613
+ problems.push(`${label}: reporting.availability.start and end must differ`);
614
+ }
615
+ if (
616
+ digest !== undefined &&
617
+ digest["cadence"] === "daily" &&
618
+ digest["timezone"] !== undefined &&
619
+ availability["timezone"] !== undefined &&
620
+ digest["timezone"] !== availability["timezone"]
621
+ ) {
622
+ problems.push(`${label}: reporting.digest.timezone must match reporting.availability.timezone`);
623
+ }
624
+ }
625
+ }
626
+
627
+ const routing = proj["routing"];
628
+ const repos = typeof routing === "object" && routing !== null && !Array.isArray(routing)
629
+ ? (routing as Raw)["repos"]
630
+ : undefined;
631
+ if (typeof repos === "object" && repos !== null && !Array.isArray(repos)) {
632
+ for (const [key, entry] of Object.entries(repos as Record<string, unknown>)) {
633
+ const cloneUrl = (entry as Raw | undefined)?.["cloneUrl"];
634
+ if (typeof cloneUrl === "string") {
635
+ const credential = cloneUrlCredentialProblem(cloneUrl);
636
+ if (credential !== undefined) {
637
+ problems.push(
638
+ `${label}: routing.repos.${key}.cloneUrl ${credential}. Use an SSH URL, or an https URL ` +
639
+ `backed by the daemon's own credential helper.`,
640
+ );
641
+ }
642
+ }
643
+ }
644
+ }
645
+ });
646
+ return problems;
647
+ }
648
+
649
+ /**
650
+ * Expands a zod `invalid_union` into its most specific branch — the one with
651
+ * the deepest path — so `releasePolicy: { deploy: "worker" }` reports the
652
+ * offending holder (and its field) rather than a bare "Invalid input". The
653
+ * legacy-literal branch of the same union always errors for an object and is
654
+ * the one we discard.
655
+ */
656
+ type LooseIssue = {
657
+ path: PropertyKey[];
658
+ code: string;
659
+ message: string;
660
+ keys?: readonly string[];
661
+ values?: readonly unknown[];
662
+ received?: unknown;
663
+ };
664
+
665
+ function expandIssue(issue: unknown): LooseIssue[] {
666
+ const raw = issue as { code?: string; path?: PropertyKey[]; errors?: unknown; message?: string };
667
+ const base = { ...(issue as object) } as LooseIssue;
668
+ if (raw.code !== "invalid_union" || !Array.isArray(raw.errors)) {
669
+ return [{ ...base, path: raw.path ?? [], code: raw.code ?? "invalid_type", message: raw.message ?? "Invalid input" }];
670
+ }
671
+ let best: { path?: PropertyKey[]; code?: string; message?: string } | undefined;
672
+ let bestDepth = -1;
673
+ for (const branch of raw.errors) {
674
+ for (const e of Array.isArray(branch) ? branch : [branch]) {
675
+ const er = e as { path?: PropertyKey[] };
676
+ const depth = (raw.path?.length ?? 0) + (er.path?.length ?? 0);
677
+ if (depth > bestDepth) {
678
+ bestDepth = depth;
679
+ best = { path: er.path, code: (e as { code?: string }).code, message: (e as { message?: string }).message };
680
+ }
681
+ }
682
+ }
683
+ if (best === undefined) {
684
+ return [{ ...base, path: raw.path ?? [], code: raw.code, message: raw.message ?? "Invalid input" }];
685
+ }
686
+ return [
687
+ { ...base, path: [...(raw.path ?? []), ...(best.path ?? [])], code: best.code ?? "invalid_type", message: best.message ?? "Invalid input" },
688
+ ];
689
+ }
690
+
691
+ function problemEnvelope(path: string, problems: string[]): string {
692
+ return (
693
+ `Invalid conductor config at ${path}:\n${problems.map((p) => ` - ${p}`).join("\n")}\n` +
694
+ `Fix the file or run \`omp-conductor setup\`.`
695
+ );
696
+ }
697
+
698
+ /** The operator-facing label of a project from its issue path, or undefined. */
699
+ function projectLabel(root: unknown, index: number): string | undefined {
700
+ const list = (root as Raw | undefined)?.["projects"];
701
+ if (!Array.isArray(list)) return undefined;
702
+ const p = list[index] as Raw | undefined;
703
+ const name = p?.["name"];
704
+ return typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${index}]`;
705
+ }
706
+
707
+ /** Joins zod's issue path segments into the dotted config path an operator sees. */
708
+ function dottedPath(segments: readonly PropertyKey[]): string {
709
+ return segments
710
+ .map((seg) => (typeof seg === "number" ? `[${seg}]` : `.${String(seg)}`))
711
+ .join("")
712
+ .replace(/^\./, "");
713
+ }
714
+
715
+ /** Reads a value out of the original parsed document at a zod issue path. */
716
+ function rawAt(root: unknown, path: readonly PropertyKey[]): unknown {
717
+ let cur: unknown = root;
718
+ for (const seg of path) {
719
+ if (cur === null || typeof cur !== "object") return undefined;
720
+ cur = (cur as Record<string, unknown>)[String(seg)];
721
+ }
722
+ return cur;
723
+ }
724
+
725
+ const FOUND = (v: unknown): string => (v === "" ? '""' : JSON.stringify(v));
726
+
727
+ /**
728
+ * Renders a zod issue the way the loader has always worded faults — the dotted
729
+ * config path, the exact alternatives, the offending value — so moving the
730
+ * validation to zod changes how a problem is *found*, never how it reads.
731
+ */
732
+ function issueToProblem(issue: {
733
+ path: readonly PropertyKey[];
734
+ code: string;
735
+ message: string;
736
+ keys?: readonly string[];
737
+ values?: readonly unknown[];
738
+ }, root: unknown): string {
739
+ const path = issue.path;
740
+
741
+ // Top-level version / projects faults carry no project label.
742
+ if (path.length === 1 && (path[0] === "version" || path[0] === "projects")) {
743
+ if (path[0] === "version") {
744
+ return `"version" must be 1 or 2, found ${JSON.stringify(rawAt(root, path))} — this config was written by a different conductor`;
745
+ }
746
+ return `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`;
747
+ }
748
+
749
+ if (path[0] === "projects" && typeof path[1] === "number") {
750
+ const rel = path.slice(2);
751
+ // The project's own name is invalid precisely when *its* field fails, and
752
+ // that fault has always been voiced as `projects[N].name …`.
753
+ if (rel.length === 1 && rel[0] === "name") {
754
+ return `projects[${path[1]}].name must be a non-empty string`;
755
+ }
756
+ const label = projectLabel(root, path[1]) ?? `projects[${path[1]}]`;
757
+ return `${label}: ${clauseFor(rel, issue, root, path)}`;
758
+ }
759
+
760
+ return clauseFor(path, issue, root, path);
761
+ }
762
+
763
+ /** The clause after the project label — `<dotted-path> <finding>`. */
764
+ function clauseFor(rel: readonly PropertyKey[], issue: {
765
+ code: string;
766
+ message: string;
767
+ keys?: readonly string[];
768
+ values?: readonly unknown[];
769
+ }, root: unknown, fullPath: readonly PropertyKey[]): string {
770
+ const pathStr = dottedPath(rel);
771
+ const found = (at?: readonly PropertyKey[]): string => FOUND(rawAt(root, at ?? fullPath));
772
+
773
+ if (rel.length === 1 && rel[0] === "releasePolicy") {
774
+ return `releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${found()}`;
775
+ }
776
+ if (rel[0] === "routing" && (rel.length === 1 || (rel.length === 2 && rel[1] === "repos"))) {
777
+ return `routing.repos needs at least one repo entry, or no issue can be routed`;
778
+ }
779
+ if (rel.length >= 2 && rel[rel.length - 1] === "dir" && rel.includes("migrations")) {
780
+ return `${pathStr} must be a non-empty string`;
781
+ }
782
+ const NAME_LIST_FIELDS = new Set(["requiredChecks", "artefacts", "environments"]);
783
+ // A whole name-list field of the wrong shape.
784
+ if (
785
+ rel.length >= 2 &&
786
+ NAME_LIST_FIELDS.has(String(rel[rel.length - 1])) &&
787
+ (rel[rel.length - 2] === "merge" || rel[rel.length - 2] === "release")
788
+ ) {
789
+ return `${pathStr} must be an array of non-empty strings`;
790
+ }
791
+ // One malformed element of a name-list.
792
+ if (
793
+ typeof rel[rel.length - 1] === "number" &&
794
+ rel.length >= 3 &&
795
+ NAME_LIST_FIELDS.has(String(rel[rel.length - 2]))
796
+ ) {
797
+ return `${pathStr} must be a non-empty string, found ${found()}`;
798
+ }
799
+
800
+ // Schema-authored findings (`.superRefine` / `.message` carried on the node
801
+ // with a `custom` code) assemble as `<dotted-path> <finding>`.
802
+ if (issue.code === "custom") return `${pathStr} ${issue.message}`;
803
+
804
+ // Presence of an object-looking field where a singular scalar belongs, or a
805
+ // whole object/array of the wrong type — the "must be an object" family.
806
+ if (issue.code === "invalid_type") {
807
+ const relStr = (rel[0] as string) ?? "";
808
+ if (rel.length === 1 && relStr === "reporting") {
809
+ return `reporting must be an object with a "scope" preset or explicit "interruptOn"/"digest"`;
810
+ }
811
+ if (rel.length === 2 && relStr === "reporting" && rel[1] === "digest") {
812
+ return `reporting.digest must be an object with a "cadence" of ${DIGEST_CADENCE_LIST}`;
813
+ }
814
+ if (rel.length === 2 && relStr === "reporting" && rel[1] === "availability") {
815
+ return `reporting.availability must be an object`;
816
+ }
817
+ if (rel.length === 1 && relStr === "escalation") return `escalation must be an object`;
818
+ if (rel.length === 1 && relStr === "authority") {
819
+ return `authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`;
820
+ }
821
+ if (rel.length === 1 && relStr === "policy") {
822
+ return `policy must be an object with "merge" and "release" sections`;
823
+ }
824
+ if (rel.length === 2 && relStr === "policy") return `${pathStr} must be an object`;
825
+ if (rel.length === 1 && relStr === "caps") return `caps must be an object`;
826
+ if (rel.length === 2 && relStr === "caps" && rel[1] === "planUsage") {
827
+ return `caps.planUsage must be { windowId, maxUsedFraction } or null (unmetered), found ${found()}`;
828
+ }
829
+ if (rel.length === 1 && relStr === "releasePolicy") {
830
+ return `releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${found()}`;
831
+ }
832
+ if (rel.length === 3 && rel[0] === "routing" && rel[1] === "repos") {
833
+ return `routing.repos needs at least one repo entry, or no issue can be routed`;
834
+ }
835
+ if (rel.length === 1 && relStr === "tracker") {
836
+ return `tracker.repo must look like "owner/repo", found undefined`;
837
+ }
838
+ // A numeric cap field of the wrong shape (zod union over number|null).
839
+ if (rel.length === 2 && relStr === "caps") {
840
+ return capProblem(rel[1] as string, found());
841
+ }
842
+ // A missing/wrong name-list element.
843
+ if (typeof rel[rel.length - 1] === "number") {
844
+ return `${pathStr} must be a non-empty string, found ${found()}`;
845
+ }
846
+ // Every remaining require/missing-property case is authored here; never
847
+ // fall through to zod's own rendering.
848
+ if (rel[0] === "tracker") return `tracker.repo must look like "owner/repo", found undefined`;
849
+ if (pathStr === "reporting.digest.cadence") return `reporting.digest.cadence must be ${DIGEST_CADENCE_LIST}`;
850
+ if (pathStr === "reporting.digest.at") return `reporting.digest.at must be a 24h HH:MM time`;
851
+ if (pathStr === "reporting.digest.timezone") return `reporting.digest.timezone must be a string`;
852
+ if (pathStr === "reporting.availability.timezone") return `reporting.availability.timezone must be a known IANA timezone`;
853
+ if (pathStr === "reporting.availability.start" || pathStr === "reporting.availability.end") {
854
+ return `${pathStr} must be a 24h HH:MM time`;
855
+ }
856
+ if (pathStr === "reporting.availability.days") {
857
+ return `reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`;
858
+ }
859
+ if (pathStr === "reporting.availability.bypass") {
860
+ return `reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`;
861
+ }
862
+ if (pathStr === "reporting.interruptOn") return `reporting.interruptOn must name at least one category`;
863
+ if (rel.length >= 2 && String(rel[rel.length - 1]).endsWith("graphProject")) {
864
+ return `${pathStr} must be a non-empty absolute path, found ${found()}`;
865
+ }
866
+ if (rel.length >= 2 && String(rel[rel.length - 1]) === "gates") return `${pathStr} must be an array of { cmd, cwd }`;
867
+ if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found());
868
+ return `${pathStr} is invalid`;
869
+ }
870
+
871
+ if (issue.code === "unrecognized_keys") {
872
+ const keys = (issue.keys ?? []).join(", ");
873
+ if (pathStr === "policy") return `policy has unknown key(s): ${keys} — expected "merge" or "release"`;
874
+ if (pathStr === "policy.merge") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_MERGE_KEYS}`;
875
+ if (pathStr === "policy.release") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_RELEASE_KEYS}`;
876
+ if (pathStr === "caps.planUsage") {
877
+ return `caps.planUsage has unknown key(s): ${keys} — expected windowId and maxUsedFraction`;
878
+ }
879
+ if (pathStr === "releasePolicy") {
880
+ return `releasePolicy has unknown release shape(s): ${keys} — expected ${RELEASE_SHAPE_LIST}`;
881
+ }
882
+ return `${pathStr} has unknown key(s): ${keys}`;
883
+ }
884
+
885
+ // Enum membership (invalid_value). The wording depends on the field.
886
+ if (issue.code === "invalid_value") {
887
+ const tail = rel[rel.length - 1];
888
+ const parent = dottedPath(rel.slice(0, rel.length - 1));
889
+ // Array-element vocabularies stated with their own phrasing.
890
+ if (parent === "reporting.interruptOn") {
891
+ return `reporting.interruptOn has unknown category ${found()} — one of ${INTERRUPT_CATEGORY_LIST}`;
892
+ }
893
+ if (parent === "reporting.availability.bypass") {
894
+ return `reporting.availability.bypass has unknown category ${found()} — one of ${INTERRUPT_CATEGORY_LIST}`;
895
+ }
896
+ if (parent === "reporting.availability.days") {
897
+ return `reporting.availability.days has unknown day ${found()} — one of ${WEEKDAY_LIST}`;
898
+ }
899
+ if (parent === "policy.release.requires") {
900
+ return `${pathStr} must be ${RELEASE_REQUIREMENT_LIST}, found ${found()}`;
901
+ }
902
+ if (rel.length >= 2 && rel[0] === "releasePolicy") {
903
+ return `releasePolicy.${String(tail)} must be ${AUTHORITY_HOLDER_LIST}, found ${found()}`;
904
+ }
905
+ if (pathStr === "tracker.kind") return `tracker.kind must be "github", found ${found()}`;
906
+ if (pathStr === "reporting.scope" || pathStr === "reporting.scopePreset") {
907
+ return `${pathStr} must be ${REPORT_SCOPE_LIST}, found ${found()}`;
908
+ }
909
+ if (pathStr === "reporting.digest.cadence") return `${pathStr} must be ${DIGEST_CADENCE_LIST}, found ${found()}`;
910
+ if (pathStr === "escalation.orchestrator") return `${pathStr} must be ${ORCHESTRATOR_MODE_LIST}, found ${found()}`;
911
+ if (pathStr === "authority.merge" || pathStr === "authority.release") {
912
+ return `${pathStr} must be ${AUTHORITY_HOLDER_LIST}, found ${found()}`;
913
+ }
914
+ if (pathStr === "policy.merge.baseFreshness") return `${pathStr} must be ${BASE_FRESHNESS_LIST}, found ${found()}`;
915
+ if (pathStr === "policy.merge.drafts") return `${pathStr} must be ${DRAFT_POLICY_LIST}, found ${found()}`;
916
+ if (pathStr === "policy.merge.whenBehindBase") return `${pathStr} must be ${BEHIND_BASE_ACTION_LIST}, found ${found()}`;
917
+ const values = (issue.values ?? []).map(String);
918
+ return `${pathStr} must be ${quoteList(values)}, found ${found()}`;
919
+ }
920
+
921
+ // Regex / custom-message failures carry the finding in issue.message.
922
+ if (issue.code === "too_small") {
923
+ // Empty array (reporting.interruptOn, availability.days) has its own line.
924
+ if (parentOf(rel) === "reporting.interruptOn") return `reporting.interruptOn must name at least one category`;
925
+ if (pathStr === "reporting.availability.days") return `reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`;
926
+ if (pathStr === "reporting.availability.timezone") return `reporting.availability.timezone must be a known IANA timezone`;
927
+ if (pathStr === "routing.repos" ) return `routing.repos needs at least one repo entry, or no issue can be routed`;
928
+ if (cloneUrlPath(rel) ) return `${pathStr} must be a non-empty string`;
929
+ if (gatesCmdPath(rel)) return `${pathStr} must be { cmd, cwd } with a non-empty cmd`;
930
+ if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found());
931
+ if (pathStr === "caps.planUsage.maxUsedFraction") {
932
+ return `caps.planUsage.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${found()}`;
933
+ }
934
+ return `${pathStr} ${finding(issue.message)}`;
935
+ }
936
+
937
+ if (issue.code === "too_big") {
938
+ if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found());
939
+ if (pathStr === "caps.planUsage.maxUsedFraction") {
940
+ return `caps.planUsage.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${found()}`;
941
+ }
942
+ return `${pathStr} ${finding(issue.message)}`;
943
+ }
944
+
945
+ // invalid_format (the repo / HH:MM regexes) and anything else.
946
+ if (pathStr === "reporting.digest.at" || pathStr === "reporting.availability.start" || pathStr === "reporting.availability.end") {
947
+ return `${pathStr} must be a 24h HH:MM time`;
358
948
  }
359
- const root = parsed as Raw;
949
+ if (pathStr === "tracker.repo") return `tracker.repo must look like "owner/repo", found ${found()}`;
950
+ return `${pathStr} ${finding(issue.message)}`;
951
+ }
952
+
953
+ /** zod's own phrasing, which must never reach an operator-facing problem line. */
954
+ const LEAKY = /(Invalid input|expected |received |Unrecognized key|Too small|Too big)/;
955
+ function finding(message: string): string {
956
+ return LEAKY.test(message) ? "is not valid" : message;
957
+ }
958
+
959
+ function parentOf(rel: readonly PropertyKey[]): string {
960
+ return dottedPath(rel.slice(0, rel.length - 1));
961
+ }
962
+ function cloneUrlPath(rel: readonly PropertyKey[]): boolean {
963
+ return rel.length >= 4 && rel[rel.length - 1] === "cloneUrl";
964
+ }
965
+ function gatesCmdPath(rel: readonly PropertyKey[]): boolean {
966
+ return rel.some((s, i) => s === "gates" && typeof rel[i + 1] === "number" && rel[rel.length - 1] === "cmd");
967
+ }
968
+ function capProblem(key: string, found: string): string {
969
+ return key === "dailySpendUsd"
970
+ ? `caps.dailySpendUsd must be a non-negative finite number or null (no cap), found ${found}`
971
+ : `caps.${key} must be a non-negative finite number, found ${found}`;
972
+ }
973
+
974
+ /** Quted fallback key lists for policy unknown-key errors. */
975
+ const POLICY_MERGE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).merge));
976
+ const POLICY_RELEASE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).release));
977
+
978
+ // ---------------------------------------------------------------------------
979
+ // residue: cross-field coherence, migrations, defaults, path expansion
980
+ // ---------------------------------------------------------------------------
981
+
982
+ function finalize(data: unknown, path: string): ConductorConfig {
983
+ const root = data as Raw;
360
984
  const problems: string[] = [];
361
985
 
362
986
  const version = root["version"];
363
- // A v1 file predates the retirement of a cap key, so its caps are read
364
- // leniently and the result is normalised up to v2. Any other version is a
365
- // config this build cannot honestly claim to understand.
366
987
  const legacyCaps = version === 1;
367
988
  if (!READABLE_CONFIG_VERSIONS.some((v) => v === version)) {
368
989
  problems.push(
@@ -370,13 +991,12 @@ function validate(parsed: unknown, path: string): ConductorConfig {
370
991
  );
371
992
  }
372
993
 
373
- const configuredDefaults = coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps);
994
+ const configuredDefaults = reconcileCaps(root["defaults"], `"defaults"`, problems, legacyCaps);
374
995
  const defaultWorkerMaxTurns = configuredDefaults.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns;
375
996
  const defaults: Caps = {
376
997
  ...DEFAULT_CAPS,
377
998
  ...configuredDefaults,
378
- workerMaxTurnsCeiling:
379
- configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
999
+ workerMaxTurnsCeiling: configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
380
1000
  };
381
1001
 
382
1002
  const rawProjects = root["projects"];
@@ -385,94 +1005,58 @@ function validate(parsed: unknown, path: string): ConductorConfig {
385
1005
  problems.push(`"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`);
386
1006
  } else {
387
1007
  rawProjects.forEach((p: unknown, i) => {
388
- const project = normalizeProject(p, i, problems, legacyCaps);
1008
+ const project = finalizeProject(p, i, problems, legacyCaps);
389
1009
  if (project !== undefined) projects.push(project);
390
1010
  });
391
1011
  }
392
1012
 
393
- if (problems.length > 0) {
394
- throw new Error(
395
- `Invalid conductor config at ${path}:\n${problems.map((p) => ` - ${p}`).join("\n")}\n` +
396
- `Fix the file or run /conductor setup.`,
397
- );
398
- }
399
-
400
- // Always v2 out: a loaded v1 config is migrated in memory, and the next
401
- // `saveConfig` is what persists the migration. `loadConfig` stays read-only.
1013
+ if (problems.length > 0) throw new Error(problemEnvelope(path, problems));
402
1014
  return { version: CONFIG_VERSION, defaults, projects };
403
1015
  }
404
1016
 
405
- /** Returns `undefined` when the project was too broken to shape; problems are appended. */
406
- function normalizeProject(
1017
+ function finalizeProject(
407
1018
  parsed: unknown,
408
1019
  index: number,
409
1020
  problems: string[],
410
1021
  legacyCaps: boolean,
411
1022
  ): ProjectConfig | undefined {
412
1023
  const at = `projects[${index}]`;
413
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
414
- problems.push(`${at} must be an object`);
415
- return undefined;
416
- }
417
- const raw = parsed as Raw;
418
1024
  const before = problems.length;
1025
+ const p = parsed as Raw;
1026
+
1027
+ // zod guarantees name/repo/queueLabel/kind are present and well-formed.
1028
+ const name = p["name"] as string;
1029
+ const label = `project "${name}"`;
1030
+ const trackerRepo = ((p["tracker"] as Raw | undefined)?.["repo"]) as string;
419
1031
 
420
- const rawName = raw["name"];
421
- let name = "";
422
- if (nonEmptyString(rawName)) name = rawName;
423
- else problems.push(`${at}.name must be a non-empty string`);
424
- const label = name === "" ? at : `project "${name}"`;
425
-
426
- const tracker = raw["tracker"] as Raw | undefined;
427
- const rawRepo = tracker?.["repo"];
428
- let trackerRepo = "";
429
- if (nonEmptyString(rawRepo) && REPO_RE.test(rawRepo)) trackerRepo = rawRepo;
430
- else problems.push(`${label}: tracker.repo must look like "owner/repo", found ${JSON.stringify(rawRepo)}`);
431
- const rawKind = tracker?.["kind"];
432
- if (rawKind !== undefined && rawKind !== "github") {
433
- problems.push(`${label}: tracker.kind must be "github", found ${JSON.stringify(rawKind)}`);
434
- }
435
-
436
- const rawQueueLabel = raw["queueLabel"];
437
- let queueLabel = "";
438
- if (nonEmptyString(rawQueueLabel)) queueLabel = rawQueueLabel;
439
- else problems.push(`${label}: queueLabel must be a non-empty string — it is the human sign-off gate`);
440
-
441
- // Tolerant like the other optional fields: an integer >= 1 is a grooming
442
- // trigger, and anything else degrades to absent (the tick default) rather
443
- // than invalidating the whole project.
444
- const rawGroomBelow = raw["groomBelow"];
1032
+ const rawGroomBelow = p["groomBelow"];
445
1033
  const groomBelow =
446
1034
  typeof rawGroomBelow === "number" && Number.isInteger(rawGroomBelow) && rawGroomBelow >= 1
447
1035
  ? rawGroomBelow
448
1036
  : undefined;
449
1037
 
450
- const routing = raw["routing"] as Raw | undefined;
451
- const rawPrefix = routing?.["labelPrefix"];
1038
+ const rawRouting = p["routing"] as Raw | undefined;
1039
+ const rawPrefix = rawRouting?.["labelPrefix"];
452
1040
  const labelPrefix = typeof rawPrefix === "string" ? rawPrefix : DEFAULT_LABEL_PREFIX;
453
- const repos = normalizeRepos(routing?.["repos"], label, problems);
454
-
455
- const stateLabels = raw["stateLabels"] as Raw | undefined;
456
-
457
- const escalation = normalizeEscalation(raw["escalation"], label, problems);
458
- const authority = normalizeAuthority(raw["authority"], label, problems);
459
- const releasePolicy = normalizeReleasePolicy(raw["releasePolicy"], label, problems);
460
- const policy = normalizeProjectPolicy(raw["policy"], label, problems);
461
-
462
- const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
463
- const reporting = normalizeReporting(raw["reporting"], label, problems);
464
- // A hint passed to the harness, not a budget guard: an unusable value is
465
- // dropped rather than reported, and the session's own model-fallback notice
466
- // (logged by `runWorker`) is what tells the operator the pattern missed.
467
- const rawWorkerModel = raw["workerModel"];
468
- const workerModel = nonEmptyString(rawWorkerModel) ? rawWorkerModel : undefined;
1041
+ const repos = finalizeRepos(rawRouting?.["repos"], label, problems);
1042
+
1043
+ const stateLabels = p["stateLabels"] as Raw | undefined;
1044
+ const escalation = finalizeEscalation(p["escalation"] as Raw | undefined);
1045
+ const authority = finalizeAuthority(p["authority"] as Raw | undefined);
1046
+ const releasePolicy = finalizeReleasePolicy(p["releasePolicy"], label, problems);
1047
+ const policy = finalizePolicy(p["policy"], label, problems);
1048
+ const caps = reconcileCaps(p["caps"], `${label}: caps`, problems, legacyCaps);
1049
+ const reporting = finalizeReporting(p["reporting"], label, problems);
1050
+ const rawWorkerModel = p["workerModel"];
1051
+ const workerModel =
1052
+ typeof rawWorkerModel === "string" && rawWorkerModel.trim() !== "" ? rawWorkerModel : undefined;
469
1053
 
470
1054
  if (problems.length > before) return undefined;
471
1055
 
472
1056
  return {
473
1057
  name,
474
1058
  tracker: { kind: "github", repo: trackerRepo },
475
- queueLabel,
1059
+ queueLabel: p["queueLabel"] as string,
476
1060
  ...(groomBelow === undefined ? {} : { groomBelow }),
477
1061
  stateLabels: {
478
1062
  inProgress: pickString(stateLabels?.["inProgress"], DEFAULT_STATE_LABELS.inProgress),
@@ -487,508 +1071,137 @@ function normalizeProject(
487
1071
  releasePolicy,
488
1072
  policy,
489
1073
  reporting,
490
- workspaceRoot: expandHome(pickString(raw["workspaceRoot"], defaultWorkspaceRoot())),
491
- mirrorRoot: expandHome(pickString(raw["mirrorRoot"], defaultMirrorRoot())),
1074
+ workspaceRoot: expandHome(pickString(p["workspaceRoot"], defaultWorkspaceRoot())),
1075
+ mirrorRoot: expandHome(pickString(p["mirrorRoot"], defaultMirrorRoot())),
492
1076
  };
493
1077
  }
494
1078
 
495
- /**
496
- * Reporting scope decides whether the orchestrator speaks up or stays quiet, so
497
- * a typo is rejected rather than folded to the default: a misspelt `"materal"`
498
- * that silently resolved to `"material"` would read as configured on the day the
499
- * operator meant to turn the volume down, and the config would keep lying.
500
- */
501
- /** The legacy `reporting.scope` presets, materialised as explicit policies.
502
- * Kept separate from {@link DEFAULT_REPORT_POLICY} (the "no key on disk"
503
- * default, which must stay `material`): each preset records `scopePreset` so
504
- * the tick prompt can keep saying the exact legacy words (#229). Exported so
505
- * the setup wizard and the config validator agree on one mapping. */
506
- export const SCOPE_PRESETS: Record<ReportScope, ReportingPolicy> = {
507
- material: {
508
- interruptOn: [...INTERRUPT_CATEGORIES],
509
- digest: { cadence: "per-tick" },
510
- scopePreset: "material",
511
- },
512
- decisions: {
513
- interruptOn: ["tier2", "decision-needed", "fleet-stopped"],
514
- digest: { cadence: "per-tick" },
515
- scopePreset: "decisions",
516
- },
517
- escalations: {
518
- interruptOn: ["tier2", "fleet-stopped"],
519
- digest: { cadence: "daily" },
520
- scopePreset: "escalations",
521
- },
522
- };
523
-
524
- function defaultReporting(): ReportingPolicy {
525
- return { ...DEFAULT_REPORT_POLICY, interruptOn: [...DEFAULT_REPORT_POLICY.interruptOn] };
1079
+ function finalizeEscalation(parsed: Raw | undefined): ProjectConfig["escalation"] {
1080
+ if (parsed === undefined) {
1081
+ return { fallbackToIssueComment: true, orchestrator: "embedded" };
1082
+ }
1083
+ const escalation = {
1084
+ fallbackToIssueComment: parsed["fallbackToIssueComment"] !== false,
1085
+ orchestrator: (parsed["orchestrator"] as ProjectConfig["escalation"]["orchestrator"]) ?? "embedded",
1086
+ } as ProjectConfig["escalation"];
1087
+ const chatId = parsed["telegramChatId"];
1088
+ if (typeof chatId === "string" && chatId.trim() !== "") escalation.telegramChatId = chatId;
1089
+ // Admit a finite integer topic id; numeric strings that parse cleanly count.
1090
+ // Non-integers are omitted rather than rejected so a hand-edit never bricks load.
1091
+ const rawTopic = parsed["telegramTopicId"];
1092
+ if (typeof rawTopic === "number") {
1093
+ if (Number.isFinite(rawTopic) && Number.isSafeInteger(rawTopic)) {
1094
+ escalation.telegramTopicId = rawTopic;
1095
+ }
1096
+ } else if (typeof rawTopic === "string") {
1097
+ const trimmed = rawTopic.trim();
1098
+ if (trimmed !== "") {
1099
+ const n = Number(trimmed);
1100
+ if (Number.isFinite(n) && Number.isSafeInteger(n) && String(n) === trimmed) {
1101
+ escalation.telegramTopicId = n;
1102
+ }
1103
+ }
1104
+ }
1105
+ return escalation;
526
1106
  }
527
1107
 
528
- /** The 24-hour `HH:MM` shape `digest.at` must take. */
529
- const DIGEST_AT = /^([01]\d|2[0-3]):[0-5]\d$/;
1108
+ function finalizeAuthority(parsed: Raw | undefined): ProjectConfig["authority"] {
1109
+ if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
1110
+ return {
1111
+ merge: (parsed["merge"] as ProjectConfig["authority"]["merge"]) ?? DEFAULT_AUTHORITY.merge,
1112
+ release: (parsed["release"] as ProjectConfig["authority"]["release"]) ?? DEFAULT_AUTHORITY.release,
1113
+ };
1114
+ }
530
1115
 
531
- /**
532
- * The reporting policy: a legacy `scope` preset, or the explicit
533
- * `interruptOn` + `digest` form. The two forms are mutually exclusive — a
534
- * preset IS a policy, so configuring alongside it says one thing and means
535
- * another (#229).
536
- */
537
- function normalizeReporting(parsed: unknown, label: string, problems: string[]): ReportingPolicy {
538
- if (parsed === undefined) return defaultReporting();
1116
+ function finalizeReleasePolicy(parsed: unknown, label: string, problems: string[]): ResolvedGrants {
1117
+ if (parsed === undefined || parsed === "none") return { ...DENIED_RELEASE_GRANTS };
1118
+ if (parsed === "operator-brief") return { ...OPERATOR_BRIEF_GRANTS };
539
1119
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
540
- problems.push(`${label}: reporting must be an object with a "scope" preset or explicit "interruptOn"/"digest"`);
541
- return defaultReporting();
1120
+ problems.push(
1121
+ `${label}: releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ` +
1122
+ `${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${JSON.stringify(parsed)}`,
1123
+ );
1124
+ return { ...DENIED_RELEASE_GRANTS };
542
1125
  }
543
1126
  const raw = parsed as Raw;
544
- const keys = Object.keys(raw);
545
- // `scopePreset` is written by a fully-normalised policy (the setup wizard
546
- // saves presets materialised); `scope` is the legacy form. Both are known.
547
- const known = ["scope", "interruptOn", "digest", "scopePreset"];
548
- const unknownKeys = keys.filter((k) => !known.includes(k));
1127
+ const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k));
549
1128
  if (unknownKeys.length > 0) {
550
- problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
551
- }
552
- const hasScope = raw["scope"] !== undefined;
553
- const hasExplicit = keys.includes("interruptOn") || keys.includes("digest");
554
- if (hasScope && hasExplicit) {
555
- problems.push(`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest explicitly`);
556
- return defaultReporting();
557
- }
558
- if (hasScope || (!hasExplicit && keys.length === 0)) {
559
- const scope = pickLiteral(
560
- raw["scope"],
561
- REPORT_SCOPES,
562
- DEFAULT_REPORT_SCOPE,
563
- `${label}: reporting.scope`,
564
- REPORT_SCOPE_LIST,
565
- problems,
1129
+ problems.push(
1130
+ `${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`,
566
1131
  );
567
- const preset = SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
568
- return {
569
- interruptOn: [...preset.interruptOn],
570
- digest: { ...preset.digest },
571
- scopePreset: preset.scopePreset,
572
- };
573
1132
  }
574
-
575
- // A fully-normalised policy (what the setup wizard writes and saveConfig
576
- // round-trips) has interruptOn/digest and may carry scopePreset; keep that
577
- // back-annotation so the tick prompt can still speak the legacy words.
578
- const storedPreset = raw["scopePreset"];
579
- const scopePreset =
580
- typeof storedPreset === "string" && (REPORT_SCOPES as readonly string[]).includes(storedPreset)
581
- ? (storedPreset as ReportScope)
582
- : undefined;
583
-
584
- return {
585
- interruptOn: normalizeInterruptOn(raw["interruptOn"], label, problems),
586
- digest: normalizeDigest(raw["digest"], label, problems),
587
- ...(scopePreset === undefined ? {} : { scopePreset }),
588
- };
589
- }
590
-
591
- function normalizeInterruptOn(parsed: unknown, label: string, problems: string[]): InterruptCategory[] {
592
- const fallback = [...INTERRUPT_CATEGORIES];
593
- if (parsed === undefined) {
594
- problems.push(`${label}: reporting.interruptOn is required in the explicit form (or use reporting.scope)`);
595
- return fallback;
596
- }
597
- if (!Array.isArray(parsed)) {
598
- problems.push(`${label}: reporting.interruptOn must be an array of ${INTERRUPT_CATEGORY_LIST}`);
599
- return fallback;
600
- }
601
- const out: InterruptCategory[] = [];
602
- for (const item of parsed) {
603
- if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
604
- problems.push(`${label}: reporting.interruptOn has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`);
605
- continue;
606
- }
607
- const category = item as InterruptCategory;
608
- if (!out.includes(category)) out.push(category);
609
- }
610
- if (out.length === 0) {
611
- problems.push(`${label}: reporting.interruptOn must name at least one category`);
612
- return fallback;
613
- }
614
- return out;
615
- }
616
-
617
- function normalizeDigest(parsed: unknown, label: string, problems: string[]): ReportingPolicy["digest"] {
618
- const fallback: ReportingPolicy["digest"] = { cadence: "per-tick" };
619
- if (parsed === undefined) {
620
- problems.push(`${label}: reporting.digest is required in the explicit form (or use reporting.scope)`);
621
- return fallback;
622
- }
623
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
624
- problems.push(`${label}: reporting.digest must be an object with a "cadence" of ${DIGEST_CADENCE_LIST}`);
625
- return fallback;
626
- }
627
- const raw = parsed as Raw;
628
- const unknownKeys = Object.keys(raw).filter((k) => !["cadence", "at", "timezone"].includes(k));
629
- if (unknownKeys.length > 0) {
630
- problems.push(`${label}: reporting.digest has unknown key(s): ${unknownKeys.join(", ")}`);
631
- }
632
- const cadence = pickLiteral(
633
- raw["cadence"],
634
- DIGEST_CADENCES,
635
- "per-tick",
636
- `${label}: reporting.digest.cadence`,
637
- DIGEST_CADENCE_LIST,
638
- problems,
639
- );
640
- const digest: ReportingPolicy["digest"] = { cadence };
641
- if (cadence !== "daily") {
642
- if (raw["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
643
- if (raw["timezone"] !== undefined) problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
644
- return digest;
645
- }
646
- if (raw["at"] !== undefined) {
647
- if (typeof raw["at"] !== "string" || !DIGEST_AT.test(raw["at"])) {
648
- problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
649
- } else {
650
- digest.at = raw["at"];
651
- }
652
- }
653
- if (raw["timezone"] !== undefined) {
654
- if (typeof raw["timezone"] !== "string") {
655
- problems.push(`${label}: reporting.digest.timezone must be a string`);
656
- } else {
657
- try {
658
- new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
659
- digest.timezone = raw["timezone"];
660
- } catch {
661
- problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
662
- }
663
- }
664
- }
665
- return digest;
666
- }
667
-
668
- /**
669
- * Who triages escalations, and how they are delivered when nobody answers.
670
- *
671
- * `orchestrator` is validated rather than folded to the default for the reason
672
- * `reporting.scope` is: a misspelt `"externl"` that quietly resolved to
673
- * `"embedded"` would start a second brain beside the operator's own session,
674
- * and both of them would triage the same issue from different transcripts.
675
- */
676
- function normalizeEscalation(parsed: unknown, label: string, problems: string[]): ProjectConfig["escalation"] {
677
- let raw: Raw = {};
678
- if (parsed !== undefined) {
679
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) raw = parsed as Raw;
680
- else problems.push(`${label}: escalation must be an object`);
681
- }
682
-
683
- const escalation: ProjectConfig["escalation"] = {
684
- // Absent means "yes, still tell me": a silently stuck run is the worst case.
685
- fallbackToIssueComment: raw["fallbackToIssueComment"] !== false,
686
- orchestrator: pickLiteral(
687
- raw["orchestrator"],
688
- ORCHESTRATOR_MODES,
689
- "embedded",
690
- `${label}: escalation.orchestrator`,
691
- ORCHESTRATOR_MODE_LIST,
692
- problems,
693
- ),
694
- };
695
- const chatId = raw["telegramChatId"];
696
- if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
697
- return escalation;
698
- }
699
-
700
- /**
701
- * Who lands PRs and who cuts releases. Both default to the human: this is the
702
- * one config value that decides whether an unattended session may write to a
703
- * main branch, so it is granted explicitly or not at all.
704
- *
705
- * Unknown keys are rejected outright, as in `reporting` and for the same
706
- * reason: the object has exactly two members, so an unrecognised one is a typo
707
- * every time — and a `authority: { merges: "orchestrator" }` that loaded
708
- * cleanly would read as delegated while the orchestrator was still told to keep
709
- * its hands off.
710
- */
711
- function normalizeAuthority(parsed: unknown, label: string, problems: string[]): ProjectConfig["authority"] {
712
- if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
713
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
714
- problems.push(`${label}: authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`);
715
- return { ...DEFAULT_AUTHORITY };
716
- }
717
- const raw = parsed as Raw;
718
-
719
- const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
720
- if (unknownKeys.length > 0) {
721
- problems.push(`${label}: authority has unknown key(s): ${unknownKeys.join(", ")}`);
722
- }
723
-
724
- return {
725
- merge: pickLiteral(
726
- raw["merge"],
727
- AUTHORITY_HOLDERS,
728
- DEFAULT_AUTHORITY.merge,
729
- `${label}: authority.merge`,
730
- AUTHORITY_HOLDER_LIST,
731
- problems,
732
- ),
733
- release: pickLiteral(
734
- raw["release"],
735
- AUTHORITY_HOLDERS,
736
- DEFAULT_AUTHORITY.release,
737
- `${label}: authority.release`,
738
- AUTHORITY_HOLDER_LIST,
739
- problems,
740
- ),
741
- };
742
- }
743
-
744
- /**
745
- * Per-shape release grants, and the migration off the two legacy strings.
746
- *
747
- * Normalised to a *complete* map at load, so everything downstream — the gate,
748
- * `status`, the standing orders, the wizard — reads the same five answers and
749
- * none of them has to know which spelling was on disk.
750
- *
751
- * `"operator-brief"` migrates to every shape except `deploy` (#122). That is the
752
- * safe reading of what an operator believed the binary gate opened: a stale
753
- * `operator-brief` was enough for an orchestrator session to invoke Komodo
754
- * `DeployStack`, which is the grant nobody knowingly gave.
755
- *
756
- * An unknown shape key is rejected rather than ignored, as in `authority` and
757
- * for the same reason: the set is closed, so `{ "deploy-prod": "orchestrator" }`
758
- * is a typo every time — and a config that loaded it cleanly would read as
759
- * granted in the file while the gate denied every call.
760
- */
761
- function normalizeReleasePolicy(parsed: unknown, label: string, problems: string[]): ResolvedGrants {
762
- if (parsed === undefined || parsed === "none") return { ...DENIED_RELEASE_GRANTS };
763
- if (parsed === "operator-brief") return { ...OPERATOR_BRIEF_GRANTS };
764
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
765
- problems.push(
766
- `${label}: releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ` +
767
- `${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${JSON.stringify(parsed)}`,
768
- );
769
- return { ...DENIED_RELEASE_GRANTS };
770
- }
771
- const raw = parsed as Raw;
772
-
773
- const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k));
774
- if (unknownKeys.length > 0) {
775
- problems.push(
776
- `${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`,
777
- );
778
- }
779
-
780
1133
  const grants = { ...DENIED_RELEASE_GRANTS };
781
1134
  for (const shape of RELEASE_SHAPES) {
782
- // An absent shape stays denied; a present-but-malformed one is reported and
783
- // also stays denied, never folded to the value the operator asked for.
784
- grants[shape] = pickLiteral(
785
- raw[shape],
786
- AUTHORITY_HOLDERS,
787
- "human",
788
- `${label}: releasePolicy.${shape}`,
789
- AUTHORITY_HOLDER_LIST,
790
- problems,
791
- );
1135
+ grants[shape] = (AUTHORITY_HOLDERS as readonly string[]).includes(raw[shape] as string)
1136
+ ? (raw[shape] as ResolvedGrants[typeof shape])
1137
+ : "human";
792
1138
  }
793
1139
  return grants;
794
1140
  }
795
1141
 
796
- /**
797
- * The merge and release gating conditions (#129).
798
- *
799
- * These used to be sentences in the operator's POLICY.md, which meant a verb
800
- * could only honour them by asking a model to read prose. Typed and normalised
801
- * here to a *complete* value, so the gate, the wizard, the plan summary and the
802
- * brief all read the same answers and none of them has to know which fields
803
- * were spelled on disk.
804
- *
805
- * Fail-closed throughout, as in `authority` and for the same reason: both
806
- * sections have a closed key set, so an unrecognised key is a typo every time —
807
- * and a `policy: { merge: { requiredCheck: [...] } }` that loaded cleanly would
808
- * read as configured in the file while the gate went on requiring every check.
809
- * A malformed value takes the documented default and is reported; it is never
810
- * folded to whatever the operator asked for.
811
- */
812
- function normalizeProjectPolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy {
1142
+ function finalizePolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy {
813
1143
  if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY);
814
1144
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
815
1145
  problems.push(`${label}: policy must be an object with "merge" and "release" sections`);
816
1146
  return clonePolicy(DEFAULT_PROJECT_POLICY);
817
1147
  }
818
1148
  const raw = parsed as Raw;
819
-
820
- const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
821
- if (unknownKeys.length > 0) {
822
- problems.push(`${label}: policy has unknown key(s): ${unknownKeys.join(", ")} — expected "merge" or "release"`);
823
- }
824
-
825
1149
  return {
826
- merge: normalizeMergePreconditions(raw["merge"], `${label}: policy.merge`, problems),
827
- release: normalizeReleasePreconditions(raw["release"], `${label}: policy.release`, problems),
1150
+ merge: finalizeMergePreconditions(raw["merge"], problems),
1151
+ release: finalizeReleasePreconditions(raw["release"], problems),
828
1152
  };
829
1153
  }
830
1154
 
831
- /** When a pull request may be merged. Absent members take {@link DEFAULT_PROJECT_POLICY}. */
832
- function normalizeMergePreconditions(parsed: unknown, at: string, problems: string[]): MergePreconditions {
833
- const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge;
834
- if (parsed === undefined) return fallback;
835
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
836
- problems.push(`${at} must be an object`);
837
- return fallback;
838
- }
1155
+ function finalizeMergePreconditions(parsed: unknown, problems: string[]): MergePreconditions {
1156
+ if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY).merge;
839
1157
  const raw = parsed as Raw;
840
-
841
- const known = new Set(Object.keys(fallback));
842
- const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
843
- if (unknownKeys.length > 0) {
844
- problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
845
- }
846
-
1158
+ const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge;
847
1159
  return {
848
- requiredChecks: normalizeNameList(raw["requiredChecks"], `${at}.requiredChecks`, problems),
849
- baseFreshness: pickLiteral(
850
- raw["baseFreshness"],
851
- BASE_FRESHNESS,
852
- fallback.baseFreshness,
853
- `${at}.baseFreshness`,
854
- BASE_FRESHNESS_LIST,
855
- problems,
856
- ),
857
- drafts: pickLiteral(raw["drafts"], DRAFT_POLICIES, fallback.drafts, `${at}.drafts`, DRAFT_POLICY_LIST, problems),
858
- whenBehindBase: pickLiteral(
859
- raw["whenBehindBase"],
860
- BEHIND_BASE_ACTIONS,
861
- fallback.whenBehindBase,
862
- `${at}.whenBehindBase`,
863
- BEHIND_BASE_ACTION_LIST,
864
- problems,
865
- ),
1160
+ requiredChecks: trimNames(raw["requiredChecks"]),
1161
+ baseFreshness: (raw["baseFreshness"] as MergePreconditions["baseFreshness"]) ?? fallback.baseFreshness,
1162
+ drafts: (raw["drafts"] as MergePreconditions["drafts"]) ?? fallback.drafts,
1163
+ whenBehindBase: (raw["whenBehindBase"] as MergePreconditions["whenBehindBase"]) ?? fallback.whenBehindBase,
866
1164
  };
867
1165
  }
868
1166
 
869
- /** When a release may be cut, what it produces, and where it may go. */
870
- function normalizeReleasePreconditions(parsed: unknown, at: string, problems: string[]): ReleasePreconditions {
871
- const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release;
872
- if (parsed === undefined) return fallback;
873
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
874
- problems.push(`${at} must be an object`);
875
- return fallback;
876
- }
1167
+ function finalizeReleasePreconditions(parsed: unknown, problems: string[]): ReleasePreconditions {
1168
+ if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY).release;
877
1169
  const raw = parsed as Raw;
878
-
879
- const known = new Set(Object.keys(fallback));
880
- const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
881
- if (unknownKeys.length > 0) {
882
- problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
883
- }
884
-
1170
+ const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release;
885
1171
  return {
886
- requires: normalizeReleaseRequirements(raw["requires"], `${at}.requires`, fallback.requires, problems),
887
- requiredChecks: normalizeNameList(raw["requiredChecks"], `${at}.requiredChecks`, problems),
888
- artefacts: normalizeNameList(raw["artefacts"], `${at}.artefacts`, problems),
889
- environments: normalizeNameList(raw["environments"], `${at}.environments`, problems),
1172
+ requires: canonicalRequirements(raw["requires"]),
1173
+ requiredChecks: trimNames(raw["requiredChecks"]),
1174
+ artefacts: trimNames(raw["artefacts"]),
1175
+ environments: trimNames(raw["environments"]),
890
1176
  };
891
1177
  }
892
1178
 
893
- /**
894
- * The `requires` set, in the vocabulary's own order rather than the file's.
895
- *
896
- * Canonical order and de-duplication because this list is rendered into a
897
- * refusal and into the plan summary: two configs that require the same three
898
- * things must read identically, or an operator diffing them sees a change that
899
- * is not one.
900
- */
901
- function normalizeReleaseRequirements(
902
- parsed: unknown,
903
- at: string,
904
- fallback: readonly ReleaseRequirement[],
905
- problems: string[],
906
- ): ReleaseRequirement[] {
907
- if (parsed === undefined) return [...fallback];
908
- if (!Array.isArray(parsed)) {
909
- problems.push(`${at} must be an array of ${RELEASE_REQUIREMENT_LIST}`);
910
- return [...fallback];
911
- }
912
-
1179
+ /** The `requires` set in vocabulary order, deduplicated (#129's promise). */
1180
+ function canonicalRequirements(parsed: unknown): ReleaseRequirement[] {
913
1181
  const chosen = new Set<string>();
914
- parsed.forEach((entry: unknown, i) => {
915
- if (!RELEASE_REQUIREMENTS.some((r) => r === entry)) {
916
- problems.push(`${at}[${i}] must be ${RELEASE_REQUIREMENT_LIST}, found ${JSON.stringify(entry)}`);
917
- return;
918
- }
919
- chosen.add(entry as ReleaseRequirement);
920
- });
1182
+ if (!Array.isArray(parsed)) return [];
1183
+ for (const entry of parsed) chosen.add(entry as string);
921
1184
  return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
922
1185
  }
923
1186
 
924
- /**
925
- * A list of names an operator wrote: check names, artefacts, environments.
926
- *
927
- * Open-ended by necessity — this package cannot know what a fleet publishes —
928
- * but a malformed entry still rejects the whole config rather than being
929
- * dropped. A silently dropped environment name is an operator debugging a
930
- * refusal that reads exactly like a correctly-denied one.
931
- */
932
- function normalizeNameList(parsed: unknown, at: string, problems: string[]): string[] {
933
- if (parsed === undefined) return [];
934
- if (!Array.isArray(parsed)) {
935
- problems.push(`${at} must be an array of non-empty strings`);
936
- return [];
937
- }
938
-
939
- const names: string[] = [];
940
- parsed.forEach((entry: unknown, i) => {
941
- if (!nonEmptyString(entry)) {
942
- problems.push(`${at}[${i}] must be a non-empty string, found ${JSON.stringify(entry)}`);
943
- return;
944
- }
945
- names.push(entry.trim());
946
- });
947
- return names;
1187
+ /** Trims name-list entries (validation is zod's; this only canonises). */
1188
+ function trimNames(parsed: unknown): string[] {
1189
+ if (!Array.isArray(parsed)) return [];
1190
+ return parsed.map((n) => (n as string).trim());
948
1191
  }
949
1192
 
950
- /**
951
- * A clone URL carrying a password or token is rejected at load.
952
- *
953
- * `git clone` persists whatever is in the URL into the mirror's config, and the
954
- * run repository inherits `origin` from it — so a `https://<pat>@github.com/…`
955
- * writes the operator's credential into a file on disk that every later run
956
- * reads, where nobody is looking for it. This used to be a `ponytail` note on
957
- * `worktree.ts`'s `ensureMirror`; a warning nobody reads is not a check, so it
958
- * is now rejected at load with the field named.
959
- *
960
- * An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`)
961
- * is not a credential and is left alone — that is how nearly every fleet is
962
- * configured, and rejecting it would be a migration nobody asked for.
963
- */
964
- export function cloneUrlCredentialProblem(url: string): string | undefined {
965
- const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/]*)@/.exec(url);
966
- if (m === null) return undefined;
967
- const scheme = (m[1] ?? "").toLowerCase();
968
- const userinfo = m[2] ?? "";
969
- if (userinfo.includes(":")) {
970
- return "embeds a user:password — git persists it into the mirror config, which hands every session the credential";
971
- }
972
- if (scheme === "http" || scheme === "https") {
973
- return "embeds userinfo in an http(s) URL, which is how a personal access token is spelled — git persists it into the mirror config";
974
- }
975
- return undefined;
976
- }
977
-
978
- function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
1193
+ function finalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
979
1194
  const repos: Record<string, RepoTarget> = {};
980
-
981
1195
  const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
982
1196
  const entries = raw === undefined ? [] : Object.entries(raw);
983
1197
  if (entries.length === 0) {
984
1198
  problems.push(`${label}: routing.repos needs at least one repo entry, or no issue can be routed`);
985
1199
  return repos;
986
1200
  }
987
-
988
1201
  for (const [key, entry] of entries) {
989
1202
  const value = entry as Raw | undefined;
990
- const cloneUrl = value?.["cloneUrl"];
991
- if (!nonEmptyString(cloneUrl)) {
1203
+ const cloneUrl = value?.["cloneUrl"] as string | undefined;
1204
+ if (typeof cloneUrl !== "string" || cloneUrl.trim() === "") {
992
1205
  problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`);
993
1206
  continue;
994
1207
  }
@@ -1004,31 +1217,41 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
1004
1217
  name: pickString(value?.["name"], key),
1005
1218
  cloneUrl,
1006
1219
  defaultBranch: pickString(value?.["defaultBranch"], "main"),
1007
- gates: normalizeGates(value?.["gates"], `${label}: routing.repos.${key}`, problems),
1220
+ gates: finalizeGates(value?.["gates"], `${label}: routing.repos.${key}`, problems),
1008
1221
  };
1009
- const graph = normalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
1222
+ const graph = finalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
1010
1223
  if (graph !== undefined) target.graphProject = graph;
1011
- const migrations = normalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems);
1224
+ const migrations = finalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems);
1012
1225
  if (migrations !== undefined) target.migrations = { dir: migrations };
1013
1226
  repos[key] = target;
1014
1227
  }
1015
-
1016
1228
  return repos;
1017
1229
  }
1018
1230
 
1019
- /**
1020
- * The ordered-migration-chain directory (#227), or `undefined` when the repo
1021
- * opts out of the chain check.
1022
- *
1023
- * Repo-relative, no leading `/`, and no `..` segment — the value is read in one
1024
- * process and used in another against the base branch's tree, so anything that
1025
- * is not plainly a directory name is a guess the guard must not make.
1026
- */
1027
- function normalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined {
1231
+ function finalizeGates(parsed: unknown, label: string, problems: string[]): { cmd: string; cwd: string }[] {
1232
+ if (parsed === undefined) return [];
1233
+ if (!Array.isArray(parsed)) {
1234
+ problems.push(`${label}.gates must be an array of { cmd, cwd }`);
1235
+ return [];
1236
+ }
1237
+ const gates: { cmd: string; cwd: string }[] = [];
1238
+ parsed.forEach((entry: unknown, i) => {
1239
+ const gate = entry as Raw | undefined;
1240
+ const cmd = gate?.["cmd"];
1241
+ if (typeof cmd !== "string" || cmd.trim() === "") {
1242
+ problems.push(`${label}.gates[${i}] must be { cmd, cwd } with a non-empty cmd`);
1243
+ return;
1244
+ }
1245
+ gates.push({ cmd, cwd: pickString(gate?.["cwd"], ".") });
1246
+ });
1247
+ return gates;
1248
+ }
1249
+
1250
+ function finalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined {
1028
1251
  if (parsed === undefined) return undefined;
1029
1252
  const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
1030
1253
  const dir = raw?.["dir"];
1031
- if (!nonEmptyString(dir)) {
1254
+ if (typeof dir !== "string" || dir.trim() === "") {
1032
1255
  problems.push(`${label}.migrations.dir must be a non-empty string`);
1033
1256
  return undefined;
1034
1257
  }
@@ -1039,24 +1262,12 @@ function normalizeMigrationsDir(parsed: unknown, label: string, problems: string
1039
1262
  return dir;
1040
1263
  }
1041
1264
 
1042
- /**
1043
- * The path of the index-only clone whose code graph this repo's workers query,
1044
- * or `undefined` when the repo has none.
1045
- *
1046
- * A relative path is rejected rather than resolved, and that rejection is the
1047
- * whole reason this is validated here: the value is written in one process and
1048
- * *used* in another, by a session whose cwd is its own throwaway worktree. So
1049
- * `../graph/api` would name a different directory for every reader, and none of
1050
- * them the one that was indexed. There is no cwd this file could honestly
1051
- * resolve it against, so it says so rather than guessing.
1052
- */
1053
- function normalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
1265
+ function finalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
1054
1266
  if (parsed === undefined) return undefined;
1055
- if (!nonEmptyString(parsed)) {
1267
+ if (typeof parsed !== "string" || parsed.trim() === "") {
1056
1268
  problems.push(`${label}.graphProject must be a non-empty absolute path, found ${JSON.stringify(parsed)}`);
1057
1269
  return undefined;
1058
1270
  }
1059
-
1060
1271
  const path = expandHome(parsed.trim());
1061
1272
  if (isAbsolute(path)) return path;
1062
1273
  problems.push(
@@ -1067,53 +1278,16 @@ function normalizeGraphProject(parsed: unknown, label: string, problems: string[
1067
1278
  }
1068
1279
 
1069
1280
  /**
1070
- * `orchestratorReadPaths` was the allowlist extension for the orchestrator's
1071
- * file gate (#127). The gate is gone the orchestrator is unconfined by
1072
- * operator ruling (#143) and with it every reader of this key.
1073
- *
1074
- * It is deliberately *not* validated, and not rejected either: project-level
1075
- * keys this build does not know are ignored, so a live fleet whose config still
1076
- * carries it keeps loading. A retired key that failed validation would brick
1077
- * exactly the fleets that adopted it, which is the 0.4.1→0.4.2 outage repeated
1078
- * on purpose.
1079
- */
1080
-
1081
- /**
1082
- * Gates are the pre-push CI equivalent, so a malformed entry is an error, not
1083
- * something to drop quietly: a skipped gate is exactly how a lint failure
1084
- * reaches the runners unattended.
1085
- */
1086
- function normalizeGates(parsed: unknown, label: string, problems: string[]): { cmd: string; cwd: string }[] {
1087
- if (parsed === undefined) return [];
1088
- if (!Array.isArray(parsed)) {
1089
- problems.push(`${label}.gates must be an array of { cmd, cwd }`);
1090
- return [];
1091
- }
1092
-
1093
- const gates: { cmd: string; cwd: string }[] = [];
1094
- parsed.forEach((entry: unknown, i) => {
1095
- const gate = entry as Raw | undefined;
1096
- const cmd = gate?.["cmd"];
1097
- if (!nonEmptyString(cmd)) {
1098
- problems.push(`${label}.gates[${i}] must be { cmd, cwd } with a non-empty cmd`);
1099
- return;
1100
- }
1101
- gates.push({ cmd, cwd: pickString(gate?.["cwd"], ".") });
1102
- });
1103
- return gates;
1104
- }
1105
-
1106
- /**
1107
- * Keeps only well-formed numeric caps; a value of the wrong shape is always
1108
- * reported, because a ceiling the daemon cannot read is worth stopping for.
1109
- *
1110
- * `legacy` decides what an *unrecognised* key means. In a v1 file it is a cap
1111
- * this version retired, so it is dropped and the config still loads — refusing
1112
- * would strand a fleet on upgrade. In a v2 file every key this build writes is
1113
- * current, so an unknown one is a typo and is reported: otherwise a mistyped
1114
- * `dailySpendUsd` reads as configured while the real ceiling is the default.
1281
+ * Reconciles a caps object against the active key set, version-aware. zod has
1282
+ * already typed the *known* cap values; this drop-versus-reject is the one
1283
+ * version-keyed decision that impossibly lives in a value schema.
1115
1284
  */
1116
- function coerceCaps(parsed: unknown, label: string, problems: string[], legacy: boolean): Partial<Caps> {
1285
+ function reconcileCaps(
1286
+ parsed: unknown,
1287
+ label: string,
1288
+ problems: string[],
1289
+ legacy: boolean,
1290
+ ): Partial<Caps> {
1117
1291
  const out: Partial<Caps> = {};
1118
1292
  if (parsed === undefined) return out;
1119
1293
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
@@ -1125,29 +1299,24 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
1125
1299
  for (const key of CAP_KEYS) {
1126
1300
  const v = raw[key];
1127
1301
  if (v === undefined) continue;
1128
- // The plan-allowance guard is the only cap that is an object rather than a
1129
- // number, so it validates its own shape before the numeric rule below can
1130
- // reject it wholesale.
1131
1302
  if (key === "planUsage") {
1132
1303
  const cap = coercePlanUsage(v, `${label}.planUsage`, problems);
1133
1304
  if (cap !== undefined) out.planUsage = cap;
1134
1305
  continue;
1135
1306
  }
1136
- // Spend is the only cap that may be null (= no gate). Every other ceiling
1137
- // is a non-negative number; 0 remains a hard stop where it already was.
1138
- if (key === "dailySpendUsd" && v === null) {
1139
- out.dailySpendUsd = null;
1307
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
1308
+ (out as Record<string, unknown>)[key as string] = v;
1140
1309
  continue;
1141
1310
  }
1142
- if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
1143
- problems.push(
1144
- key === "dailySpendUsd"
1145
- ? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}`
1146
- : `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
1147
- );
1311
+ if (key === "dailySpendUsd" && v === null) {
1312
+ out.dailySpendUsd = null;
1148
1313
  continue;
1149
1314
  }
1150
- out[key] = v;
1315
+ problems.push(
1316
+ key === "dailySpendUsd"
1317
+ ? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}`
1318
+ : `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
1319
+ );
1151
1320
  }
1152
1321
 
1153
1322
  if (!legacy) {
@@ -1158,28 +1327,9 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
1158
1327
  );
1159
1328
  }
1160
1329
  }
1161
-
1162
1330
  return out;
1163
1331
  }
1164
1332
 
1165
- /**
1166
- * `{ windowId, maxUsedFraction }`, or `null` for unmetered.
1167
- *
1168
- * Fail-closed for the same reason every other guard here is: a plan-allowance
1169
- * cap the daemon cannot read is a ceiling the operator believes they have. Two
1170
- * mistakes in particular are rejected by name rather than folded:
1171
- *
1172
- * - **`maxUsedFraction: 85`.** The threshold is a fraction, so `85` compares
1173
- * as "hold at 8500% consumed" — a guard that can never fire, and one that
1174
- * reads in the config file exactly like a deliberate 85% ceiling.
1175
- * - **A misspelled key.** `windowID`, `window`, `maxUsedPercent` and friends
1176
- * would leave a cap with a missing half, which is the same silent
1177
- * never-fires outcome.
1178
- *
1179
- * A `windowId` no provider reports cannot be caught here — only a live reading
1180
- * knows what exists — so that case is caught at admission instead, where it
1181
- * holds dispatch and names the window (see `planUsageStatus` in `usage.ts`).
1182
- */
1183
1333
  function coercePlanUsage(
1184
1334
  v: unknown,
1185
1335
  label: string,
@@ -1193,15 +1343,14 @@ function coercePlanUsage(
1193
1343
  return undefined;
1194
1344
  }
1195
1345
  const raw = v as Raw;
1346
+ const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction");
1196
1347
  const rawId = raw["windowId"];
1348
+ const windowId = typeof rawId === "string" && rawId.trim() !== "" ? rawId.trim() : undefined;
1197
1349
  const rawFraction = raw["maxUsedFraction"];
1198
- const windowId = nonEmptyString(rawId) ? rawId.trim() : undefined;
1199
1350
  const maxUsedFraction =
1200
1351
  typeof rawFraction === "number" && Number.isFinite(rawFraction) && rawFraction >= 0 && rawFraction <= 1
1201
1352
  ? rawFraction
1202
1353
  : undefined;
1203
- const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction");
1204
-
1205
1354
  if (windowId === undefined) {
1206
1355
  problems.push(
1207
1356
  `${label}.windowId must be a non-empty allowance id such as "anthropic:7d", found ${JSON.stringify(rawId)}`,
@@ -1219,51 +1368,260 @@ function coercePlanUsage(
1219
1368
  return { windowId, maxUsedFraction };
1220
1369
  }
1221
1370
 
1222
- function nonEmptyString(v: unknown): v is string {
1223
- return typeof v === "string" && v.trim().length > 0;
1371
+ /**
1372
+ * Reporting scope decides whether the orchestrator speaks up or stays quiet, so
1373
+ * a typo is rejected rather than folded to the default: a misspelt `"materal"`
1374
+ * that silently resolved to `"material"` would read as configured on the day the
1375
+ * operator meant to turn the volume down, and the config would keep lying.
1376
+ */
1377
+ /** The legacy `reporting.scope` presets, materialised as explicit policies.
1378
+ * Kept separate from {@link DEFAULT_REPORT_POLICY} (the "no key on disk"
1379
+ * default, which must stay `material`): each preset records `scopePreset` so
1380
+ * the tick prompt can keep saying the exact legacy words (#229). Exported so
1381
+ * the setup wizard and the config validator agree on one mapping. */
1382
+ export const SCOPE_PRESETS: Record<ReportScope, ReportingPolicy> = {
1383
+ material: {
1384
+ interruptOn: [...INTERRUPT_CATEGORIES],
1385
+ digest: { cadence: "per-tick" },
1386
+ scopePreset: "material",
1387
+ },
1388
+ decisions: {
1389
+ interruptOn: ["tier2", "decision-needed", "fleet-stopped"],
1390
+ digest: { cadence: "per-tick" },
1391
+ scopePreset: "decisions",
1392
+ },
1393
+ escalations: {
1394
+ interruptOn: ["tier2", "fleet-stopped"],
1395
+ digest: { cadence: "daily" },
1396
+ scopePreset: "escalations",
1397
+ },
1398
+ };
1399
+
1400
+ function defaultReporting(): ReportingPolicy {
1401
+ return { ...DEFAULT_REPORT_POLICY, interruptOn: [...DEFAULT_REPORT_POLICY.interruptOn] };
1224
1402
  }
1225
1403
 
1226
- /** One rule for "a usable string, else the documented default", used throughout. */
1227
- function pickString(v: unknown, fallback: string): string {
1228
- return nonEmptyString(v) ? v : fallback;
1404
+ /** The 24-hour `HH:MM` shape `digest.at` must take. */
1405
+ const DIGEST_AT = /^([01]\d|2[0-3]):[0-5]\d$/;
1406
+
1407
+ /**
1408
+ * The reporting policy: a legacy `scope` preset, or the explicit
1409
+ * `interruptOn` + `digest` form. The two forms are mutually exclusive — a
1410
+ * preset IS a policy, so configuring alongside it says one thing and means
1411
+ * another (#229). zod validates the shapes; this enforces the mutual exclusion,
1412
+ * materialises a preset, and reconciles the availability/digest clocks.
1413
+ */
1414
+ function finalizeReporting(parsed: unknown, label: string, problems: string[]): ReportingPolicy {
1415
+ if (parsed === undefined) return defaultReporting();
1416
+ const raw = parsed as Raw;
1417
+ const hasScope = raw["scope"] !== undefined;
1418
+ const hasExplicit = raw["interruptOn"] !== undefined || raw["digest"] !== undefined || raw["availability"] !== undefined;
1419
+ if (hasScope && hasExplicit) {
1420
+ problems.push(
1421
+ `${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`,
1422
+ );
1423
+ return defaultReporting();
1424
+ }
1425
+ if (hasScope || (!hasExplicit && Object.keys(raw).length === 0)) {
1426
+ const scope = (raw["scope"] as ReportScope) ?? DEFAULT_REPORT_SCOPE;
1427
+ const preset = SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
1428
+ return {
1429
+ interruptOn: [...preset.interruptOn],
1430
+ digest: { ...preset.digest },
1431
+ scopePreset: preset.scopePreset,
1432
+ };
1433
+ }
1434
+
1435
+ const storedPreset = raw["scopePreset"];
1436
+ const scopePreset =
1437
+ typeof storedPreset === "string" && (REPORT_SCOPES as readonly string[]).includes(storedPreset)
1438
+ ? (storedPreset as ReportScope)
1439
+ : undefined;
1440
+
1441
+ const interruptOn = finalizeInterruptOn(raw["interruptOn"], label, problems);
1442
+ const digest = finalizeDigest(raw["digest"], label, problems);
1443
+ const availability = finalizeAvailability(raw["availability"], label, problems);
1444
+ if (availability !== undefined && digest.cadence === "daily") {
1445
+ if (digest.timezone === undefined) digest.timezone = availability.timezone;
1446
+ else if (digest.timezone !== availability.timezone) {
1447
+ problems.push(`${label}: reporting.digest.timezone must match reporting.availability.timezone`);
1448
+ }
1449
+ }
1450
+ return {
1451
+ interruptOn,
1452
+ digest,
1453
+ ...(availability === undefined ? {} : { availability }),
1454
+ ...(scopePreset === undefined ? {} : { scopePreset }),
1455
+ };
1229
1456
  }
1230
1457
 
1231
- /** Quoted alternatives for an error message, from the same data the guard reads. */
1232
- function quoteList(values: readonly string[]): string {
1233
- return values.map((v) => `"${v}"`).join(" or ");
1458
+ function finalizeInterruptOn(
1459
+ parsed: unknown,
1460
+ label: string,
1461
+ problems: string[],
1462
+ ): InterruptCategory[] {
1463
+ if (parsed === undefined) {
1464
+ problems.push(`${label}: reporting.interruptOn is required in the explicit form (or use reporting.scope)`);
1465
+ return [...INTERRUPT_CATEGORIES];
1466
+ }
1467
+ const out: InterruptCategory[] = [];
1468
+ for (const item of parsed as readonly unknown[]) {
1469
+ const category = item as InterruptCategory;
1470
+ if (!out.includes(category)) out.push(category);
1471
+ }
1472
+ if (out.length === 0) {
1473
+ problems.push(`${label}: reporting.interruptOn must name at least one category`);
1474
+ return [...INTERRUPT_CATEGORIES];
1475
+ }
1476
+ return out;
1477
+ }
1478
+
1479
+ function finalizeDigest(parsed: unknown, label: string, problems: string[]): ReportingPolicy["digest"] {
1480
+ const fallback: ReportingPolicy["digest"] = { cadence: "per-tick" };
1481
+ if (parsed === undefined) {
1482
+ problems.push(`${label}: reporting.digest is required in the explicit form (or use reporting.scope)`);
1483
+ return fallback;
1484
+ }
1485
+ const raw = parsed as Raw;
1486
+ const cadence = (raw["cadence"] as DigestCadence) ?? "per-tick";
1487
+ const digest: ReportingPolicy["digest"] = { cadence };
1488
+ if (cadence !== "daily") {
1489
+ if (raw["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
1490
+ if (raw["timezone"] !== undefined) {
1491
+ problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
1492
+ }
1493
+ return digest;
1494
+ }
1495
+ if (raw["at"] !== undefined) {
1496
+ if (typeof raw["at"] !== "string" || !DIGEST_AT.test(raw["at"])) {
1497
+ problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
1498
+ } else {
1499
+ digest.at = raw["at"];
1500
+ }
1501
+ }
1502
+ if (raw["timezone"] !== undefined) {
1503
+ const tz = raw["timezone"];
1504
+ if (typeof tz !== "string") {
1505
+ problems.push(`${label}: reporting.digest.timezone must be a string`);
1506
+ } else {
1507
+ try {
1508
+ new Intl.DateTimeFormat("en-GB", { timeZone: tz });
1509
+ digest.timezone = tz;
1510
+ } catch {
1511
+ problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
1512
+ }
1513
+ }
1514
+ }
1515
+ return digest;
1516
+ }
1517
+
1518
+ function finalizeAvailability(
1519
+ parsed: unknown,
1520
+ label: string,
1521
+ problems: string[],
1522
+ ): WeeklyAvailability | undefined {
1523
+ if (parsed === undefined) return undefined;
1524
+ const raw = parsed as Raw;
1525
+
1526
+ let timezone: string | undefined;
1527
+ if (typeof raw["timezone"] !== "string" || raw["timezone"].trim() === "") {
1528
+ problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`);
1529
+ } else {
1530
+ try {
1531
+ new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
1532
+ timezone = raw["timezone"];
1533
+ } catch {
1534
+ problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`);
1535
+ }
1536
+ }
1537
+
1538
+ const days: Weekday[] = [];
1539
+ if (!Array.isArray(raw["days"]) || raw["days"].length === 0) {
1540
+ problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`);
1541
+ } else {
1542
+ for (const item of raw["days"] as readonly unknown[]) {
1543
+ const day = item as Weekday;
1544
+ if (!days.includes(day)) days.push(day);
1545
+ }
1546
+ }
1547
+
1548
+ const start = raw["start"];
1549
+ const end = raw["end"];
1550
+ if (typeof start !== "string" || !DIGEST_AT.test(start)) {
1551
+ problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`);
1552
+ }
1553
+ if (typeof end !== "string" || !DIGEST_AT.test(end)) {
1554
+ problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`);
1555
+ }
1556
+ if (typeof start === "string" && typeof end === "string" && start === end) {
1557
+ problems.push(`${label}: reporting.availability.start and end must differ`);
1558
+ }
1559
+
1560
+ const bypass: InterruptCategory[] = [];
1561
+ if (!Array.isArray(raw["bypass"])) {
1562
+ problems.push(
1563
+ `${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`,
1564
+ );
1565
+ } else {
1566
+ for (const item of raw["bypass"] as readonly unknown[]) {
1567
+ const category = item as InterruptCategory;
1568
+ if (!bypass.includes(category)) bypass.push(category);
1569
+ }
1570
+ }
1571
+
1572
+ return timezone === undefined ||
1573
+ days.length === 0 ||
1574
+ typeof start !== "string" ||
1575
+ !DIGEST_AT.test(start) ||
1576
+ typeof end !== "string" ||
1577
+ !DIGEST_AT.test(end) ||
1578
+ start === end ||
1579
+ !Array.isArray(raw["bypass"])
1580
+ ? undefined
1581
+ : { timezone, days, start, end, bypass };
1234
1582
  }
1235
1583
 
1236
1584
  /**
1237
- * One rule for "a declared literal out of a closed set, else the documented
1238
- * default", used by every such field here.
1585
+ * A clone URL carrying a password or token is rejected at load.
1239
1586
  *
1240
- * Absent takes the default silently; a value outside the set is always
1241
- * reported and never folded. Each of these sets decides something the operator
1242
- * would otherwise believe they had configured who merges, who triages, how
1243
- * loud the fleet is and a typo that resolves to the default reads exactly
1244
- * like a deliberate choice in the file afterwards.
1587
+ * `git clone` persists whatever is in the URL into the mirror's config, and the
1588
+ * run repository inherits `origin` from it so a `https://<pat>@github.com/…`
1589
+ * writes the operator's credential into a file on disk that every later run
1590
+ * reads, where nobody is looking for it. This used to be a `ponytail` note on
1591
+ * `worktree.ts`'s `ensureMirror`; a warning nobody reads is not a check, so it
1592
+ * is now rejected at load with the field named.
1593
+ *
1594
+ * An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`)
1595
+ * is not a credential and is left alone — that is how nearly every fleet is
1596
+ * configured, and rejecting it would be a migration nobody asked for.
1245
1597
  */
1246
- function pickLiteral<T extends string>(
1247
- v: unknown,
1248
- allowed: readonly T[],
1249
- fallback: T,
1250
- field: string,
1251
- quoted: string,
1252
- problems: string[],
1253
- ): T {
1254
- if (v === undefined) return fallback;
1255
- const hit = allowed.find((a) => a === v);
1256
- if (hit === undefined) {
1257
- problems.push(`${field} must be ${quoted}, found ${JSON.stringify(v)}`);
1258
- return fallback;
1598
+ export function cloneUrlCredentialProblem(url: string): string | undefined {
1599
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/]*)@/.exec(url);
1600
+ if (m === null) return undefined;
1601
+ const scheme = (m[1] ?? "").toLowerCase();
1602
+ const userinfo = m[2] ?? "";
1603
+ if (userinfo.includes(":")) {
1604
+ return "embeds a user:password — git persists it into the mirror config, which hands every session the credential";
1259
1605
  }
1260
- return hit;
1606
+ if (scheme === "http" || scheme === "https") {
1607
+ return "embeds userinfo in an http(s) URL, which is how a personal access token is spelled — git persists it into the mirror config";
1608
+ }
1609
+ return undefined;
1610
+ }
1611
+
1612
+ function nonEmptyString(v: unknown): v is string {
1613
+ return typeof v === "string" && v.trim().length > 0;
1614
+ }
1615
+
1616
+ /** One rule for "a usable string, else the documented default", used throughout. */
1617
+ function pickString(v: unknown, fallback: string): string {
1618
+ return nonEmptyString(v) ? v : fallback;
1261
1619
  }
1262
1620
 
1263
1621
  /**
1264
1622
  * `~/x` in a hand-written config must not create a literal `~` directory.
1265
1623
  *
1266
- * Exported because the wizard and `graph-setup` derive paths the operator may
1624
+ * Exported because the wizard and `setup graph` derive paths the operator may
1267
1625
  * have typed with a `~` in them, and one spelling of this rule in the package
1268
1626
  * is the only way a path shown in a plan matches the path a validator accepts.
1269
1627
  */