omp-conductor 0.12.0 → 0.14.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.
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * process lifecycle in ./lifecycle.ts, so the CLI and the `/conductor` plugin
6
6
  * cannot drift apart.
7
7
  */
8
+ import { randomUUID } from "node:crypto";
8
9
  import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
9
10
  import { userInfo } from "node:os";
10
11
  import { dirname, join } from "node:path";
@@ -21,6 +22,7 @@ import {
21
22
  proposeRetrofit,
22
23
  repairPolicyBannerCrumbs,
23
24
  } from "./brief-upgrade.ts";
25
+ import { interruptDisposition } from "./availability.ts";
24
26
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
25
27
  import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
26
28
  import { isPaused, pausedAt, runDaemon, setPaused } from "./daemon.ts";
@@ -106,6 +108,7 @@ usage:
106
108
  omp-conductor extend <issue> --turns N [--project NAME]
107
109
  omp-conductor worker pause <issue> [--project NAME]
108
110
  omp-conductor worker resume <issue> [--project NAME]
111
+ omp-conductor worker stop <issue> --reason TEXT [--project NAME]
109
112
  omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
110
113
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
111
114
  omp-conductor daemon [--once] [--port N] [--project NAME]
@@ -114,7 +117,8 @@ usage:
114
117
  omp-conductor graph-setup [--project NAME] [--write]
115
118
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
116
119
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
117
- omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
120
+ omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
121
+ omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
118
122
  omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
119
123
  omp-conductor decision resolve <id> --answer TEXT [--project NAME]
120
124
  omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
@@ -172,8 +176,9 @@ usage:
172
176
  the daemon rather than terminals, so this is the only way to watch
173
177
  one live. Runs until Ctrl-C, or until the run has finished and its
174
178
  transcript has stopped growing.
175
- extend monotonically raise a live run's turn ceiling without restarting its
176
- session. Refuses settled runs and values at or below its current cap.
179
+ extend raise a live run's turn ceiling, or set a bounded one-shot ceiling
180
+ after a failed, killed, orphaned or blocked run. Refuses values outside
181
+ configured bounds.
177
182
  worker cooperatively pause one live worker at harness idle, then resume the
178
183
  same session with a continuation prompt. Its wall clock is frozen
179
184
  while parked. Distinct from fleet-level pause/resume.
@@ -195,12 +200,17 @@ usage:
195
200
  strings, one per --arg (e.g. --arg prUrl=https://x --arg headSha=y).
196
201
  A refusal exits 3. See conductor_pr_merge/conductor_label/
197
202
  conductor_release/conductor_pr_update_branch in the brief.
203
+ event persist one ordinary material outcome without sending it. Category
204
+ is a short lowercase slug; summary and evidence name what happened
205
+ and where it can be verified. --occurred-at defaults to now.
198
206
  report hand a rendered report to the daemon's durable outbox. The report is
199
207
  persisted before anything is sent, delivered by the daemon with
200
208
  bounded retries, and shown by status until it lands. Delivery is
201
209
  at-least-once: a crash mid-send is retried and the retry says it may
202
210
  be a repeat. --kind digest is accepted at most once per local day,
203
211
  decided from the ledger rather than from what you remember sending.
212
+ A digest associates the comma-separated --events and --notices rows
213
+ atomically; omitted rows stay owed.
204
214
  decision record, list and close the questions you have put to your operator.
205
215
  A question that lives only in a session's context is lost to the next
206
216
  compaction, so \`decision open\` writes it down and every tick's prompt
@@ -263,6 +273,26 @@ function flag(argv: string[], name: string): string | undefined {
263
273
  return prefixed?.slice(name.length + 3);
264
274
  }
265
275
 
276
+ function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] {
277
+ const raw = flag(argv, name);
278
+ if (raw === undefined) {
279
+ if (argv.includes(`--${name}`)) {
280
+ process.stderr.write(`omp-conductor: report --${name} needs comma-separated row ids\n`);
281
+ process.exit(2);
282
+ }
283
+ return [];
284
+ }
285
+ const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))];
286
+ if (
287
+ ids.length === 0 ||
288
+ ids.some((id) => !/^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(id))
289
+ ) {
290
+ process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`);
291
+ process.exit(2);
292
+ }
293
+ return ids;
294
+ }
295
+
266
296
  /**
267
297
  * `--port` for the three verbs that take one. Exits 2 rather than defaulting,
268
298
  * because silently ignoring a typo'd port would leave the operator probing an
@@ -612,14 +642,11 @@ try {
612
642
  }
613
643
 
614
644
  /**
615
- * The action ledger (#126): every conductor-verb call, what it asked for,
616
- * and how the daemon decided it.
617
- *
618
- * Its own command as well as a block in `status`, because the two questions
619
- * are different sizes. `status` answers "is anything being refused right
620
- * now"; this answers "what did run 3 actually try to do", which is the
621
- * question an escalation about a run asks, and it needs the whole record
622
- * rather than the newest five lines of every run at once.
645
+ * The action ledger: mediated conductor verbs plus durable per-issue budget
646
+ * changes. Its own command as well as a block in `status`, because the two
647
+ * questions are different sizes. `status` answers "what is pending or being
648
+ * refused now"; this answers "what did run 3 actually try, and what budget
649
+ * did the operator assign", which needs history rather than only live state.
623
650
  */
624
651
  case "ledger": {
625
652
  const cfg = loadConfig();
@@ -641,18 +668,38 @@ try {
641
668
  ...(issue === undefined ? {} : { issue }),
642
669
  limit,
643
670
  });
644
- if (entries.length === 0) {
671
+ const overrides = store.turnOverrideLedger(p.name, {
672
+ ...(issue === undefined ? {} : { issue }),
673
+ limit,
674
+ });
675
+ if (entries.length === 0 && overrides.length === 0) {
645
676
  process.stdout.write(
646
- `no conductor-verb calls recorded for ${p.name}` +
677
+ `no conductor actions recorded for ${p.name}` +
647
678
  `${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
648
679
  );
649
680
  break;
650
681
  }
651
- const refused = entries.filter((e) => e.decision === "refused").length;
652
- process.stdout.write(
653
- `${p.name} ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
654
- `${entries.flatMap(formatVerbLedgerEntry).join("\n")}\n`,
655
- );
682
+ const blocks: string[] = [];
683
+ if (entries.length > 0) {
684
+ const refused = entries.filter((e) => e.decision === "refused").length;
685
+ blocks.push(
686
+ `${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
687
+ entries.flatMap(formatVerbLedgerEntry).join("\n"),
688
+ );
689
+ }
690
+ if (overrides.length > 0) {
691
+ blocks.push(
692
+ `${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
693
+ overrides
694
+ .map(
695
+ (entry) =>
696
+ ` ${new Date(entry.setAt).toISOString()} #${entry.issue} ` +
697
+ `${entry.maxTurns} turns`,
698
+ )
699
+ .join("\n"),
700
+ );
701
+ }
702
+ process.stdout.write(`${blocks.join("\n\n")}\n`);
656
703
  } finally {
657
704
  store.close();
658
705
  }
@@ -774,14 +821,30 @@ try {
774
821
  );
775
822
  const payload = (await response.json()) as {
776
823
  error?: unknown;
824
+ kind?: unknown;
777
825
  runId?: unknown;
778
826
  maxTurns?: unknown;
827
+ issue?: unknown;
828
+ nextAttemptMaxTurns?: unknown;
829
+ baseMaxTurns?: unknown;
779
830
  };
780
831
  if (!response.ok) {
781
832
  throw new Error(
782
833
  typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
783
834
  );
784
835
  }
836
+ if (
837
+ payload.kind === "next-attempt" &&
838
+ payload.issue === issue &&
839
+ typeof payload.nextAttemptMaxTurns === "number" &&
840
+ typeof payload.baseMaxTurns === "number"
841
+ ) {
842
+ process.stdout.write(
843
+ `#${issue} next attempt turn ceiling set to ${payload.nextAttemptMaxTurns} ` +
844
+ `(project base ${payload.baseMaxTurns})\n`,
845
+ );
846
+ break;
847
+ }
785
848
  if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
786
849
  throw new Error("daemon returned an invalid turn-extension response");
787
850
  }
@@ -793,13 +856,21 @@ try {
793
856
 
794
857
  case "worker": {
795
858
  const sub = argv[1];
796
- if (sub !== "pause" && sub !== "resume") {
859
+ if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
797
860
  process.stderr.write(
798
- "omp-conductor: worker needs pause or resume, then an issue number\n",
861
+ "omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
799
862
  );
800
863
  process.exit(2);
801
864
  }
802
865
  const issue = issueArg("worker", argv[2]);
866
+ const rawReason = sub === "stop" ? flag(argv, "reason") : undefined;
867
+ const reason = rawReason?.trim().replace(/\s+/g, " ");
868
+ if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
869
+ process.stderr.write(
870
+ "omp-conductor: worker stop needs --reason with 1-500 characters\n",
871
+ );
872
+ process.exit(2);
873
+ }
803
874
  const project = findProject(loadConfig(), flag(argv, "project"));
804
875
  const daemon = livingDaemon();
805
876
  if (daemon === undefined) throw new Error("daemon is not running");
@@ -813,25 +884,63 @@ try {
813
884
  {
814
885
  method: "PUT",
815
886
  headers: { "content-type": "application/json" },
816
- body: JSON.stringify({ project: project.name }),
887
+ body: JSON.stringify({
888
+ project: project.name,
889
+ ...(reason === undefined ? {} : { reason }),
890
+ }),
817
891
  },
818
892
  );
819
893
  const payload = (await response.json()) as {
820
894
  error?: unknown;
821
895
  runId?: unknown;
822
896
  phase?: unknown;
897
+ outcome?: unknown;
898
+ state?: unknown;
899
+ reason?: unknown;
900
+ salvageSha?: unknown;
901
+ salvageError?: unknown;
902
+ worktree?: unknown;
823
903
  };
824
904
  if (!response.ok) {
825
905
  throw new Error(
826
906
  typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
827
907
  );
828
908
  }
909
+ if (sub === "stop") {
910
+ if (
911
+ typeof payload.runId !== "string" ||
912
+ typeof payload.state !== "string" ||
913
+ (payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
914
+ ) {
915
+ throw new Error("daemon returned an invalid worker-stop response");
916
+ }
917
+ if (payload.outcome === "already-terminal") {
918
+ process.stdout.write(
919
+ `#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
920
+ );
921
+ break;
922
+ }
923
+ if (typeof payload.reason !== "string") {
924
+ throw new Error("daemon returned an invalid worker-stop response");
925
+ }
926
+ process.stdout.write(
927
+ `#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
928
+ );
929
+ if (typeof payload.salvageSha === "string") {
930
+ process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
931
+ }
932
+ if (typeof payload.salvageError === "string") {
933
+ process.stdout.write(
934
+ `WIP SALVAGE FAILED: ${payload.salvageError}\n` +
935
+ `worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
936
+ );
937
+ }
938
+ break;
939
+ }
829
940
  if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
830
941
  throw new Error("daemon returned an invalid worker-control response");
831
942
  }
832
- process.stdout.write(
833
- `#${issue} worker ${payload.phase} (run ${payload.runId})\n`,
834
- );
943
+ process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
835
944
  break;
836
945
  }
837
946
 
@@ -938,6 +1047,58 @@ try {
938
1047
  break;
939
1048
  }
940
1049
 
1050
+ case "event": {
1051
+ if (argv[1] !== "record") {
1052
+ process.stderr.write("omp-conductor: event needs the record subcommand\n");
1053
+ process.exit(2);
1054
+ }
1055
+ const category = flag(argv, "category");
1056
+ const summary = flag(argv, "summary")?.replace(/\s+/g, " ").trim();
1057
+ const evidence = flag(argv, "evidence")?.replace(/\s+/g, " ").trim();
1058
+ if (
1059
+ category === undefined ||
1060
+ !/^[a-z0-9][a-z0-9-]{0,31}$/.test(category) ||
1061
+ summary === undefined ||
1062
+ summary.length === 0 ||
1063
+ summary.length > 240 ||
1064
+ summary.startsWith("--") ||
1065
+ evidence === undefined ||
1066
+ evidence.length === 0 ||
1067
+ evidence.length > 500 ||
1068
+ evidence.startsWith("--")
1069
+ ) {
1070
+ process.stderr.write(
1071
+ "omp-conductor: event record needs --category with a lowercase slug, --summary (1-240 chars), and --evidence (1-500 chars)\n",
1072
+ );
1073
+ process.exit(2);
1074
+ }
1075
+ const rawOccurredAt = flag(argv, "occurred-at");
1076
+ const recordedAt = Date.now();
1077
+ const occurredAt = rawOccurredAt === undefined ? recordedAt : Date.parse(rawOccurredAt);
1078
+ if (!Number.isFinite(occurredAt)) {
1079
+ process.stderr.write("omp-conductor: event record --occurred-at needs an ISO timestamp\n");
1080
+ process.exit(2);
1081
+ }
1082
+ const project = findProject(loadConfig(), flag(argv, "project"));
1083
+ const store = openStore(dbPath());
1084
+ try {
1085
+ const event = store.recordMaterialEvent({
1086
+ project: project.name,
1087
+ category,
1088
+ summary,
1089
+ evidence,
1090
+ occurredAt,
1091
+ recordedAt,
1092
+ });
1093
+ process.stdout.write(
1094
+ `event ${event.id} recorded for ${project.name} (${event.category}, ${new Date(event.occurredAt).toISOString()}) — nothing sent\n`,
1095
+ );
1096
+ } finally {
1097
+ store.close();
1098
+ }
1099
+ break;
1100
+ }
1101
+
941
1102
  /**
942
1103
  * The handover point. Authorship stays with the model; from here the daemon
943
1104
  * owns delivery, so "I sent the report" stops being a claim the model makes
@@ -962,6 +1123,12 @@ try {
962
1123
  process.exit(2);
963
1124
  }
964
1125
  const kind = rawKind as ReportKind;
1126
+ if (kind !== "digest" && (argv.includes("--events") || argv.includes("--notices"))) {
1127
+ process.stderr.write(
1128
+ "omp-conductor: report --events and --notices are valid only with --kind digest\n",
1129
+ );
1130
+ process.exit(2);
1131
+ }
965
1132
  const project = findProject(loadConfig(), flag(argv, "project"));
966
1133
  const store = openStore(dbPath());
967
1134
  try {
@@ -970,8 +1137,8 @@ try {
970
1137
  // policy defers is refused here rather than silently turning into a
971
1138
  // page, or a digest going out off-schedule.
972
1139
  const policy = project.reporting;
1140
+ const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
973
1141
  if (kind === "digest") {
974
- const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
975
1142
  if (digestPolicy.cadence === "none") {
976
1143
  process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
977
1144
  process.exit(2);
@@ -989,32 +1156,54 @@ try {
989
1156
  }
990
1157
  }
991
1158
  if (kind === "material") {
992
- const interruptOn = policy?.interruptOn;
993
- if (interruptOn !== undefined && !interruptOn.includes("material")) {
1159
+ const disposition = interruptDisposition(policy, "material", at);
1160
+ if (disposition === "digest") {
994
1161
  process.stderr.write(
995
1162
  "omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
996
1163
  );
997
1164
  process.exit(2);
998
1165
  }
1166
+ if (disposition === "availability") {
1167
+ const noticeId = randomUUID();
1168
+ store.addHeldNotice({
1169
+ id: noticeId,
1170
+ project: project.name,
1171
+ category: "material",
1172
+ summary: body.split("\n", 1)[0]!.slice(0, 240),
1173
+ detail: body,
1174
+ createdAt: at,
1175
+ releaseOnAvailable: true,
1176
+ });
1177
+ process.stdout.write(
1178
+ `held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` +
1179
+ "the daemon will include it in the next digest or working-hours catch-up\n",
1180
+ );
1181
+ break;
1182
+ }
999
1183
  }
1000
- const { report, deduped } = store.enqueueReport({
1184
+ const draft = {
1001
1185
  project: project.name,
1002
1186
  kind,
1003
1187
  body,
1004
- // Only the digest is at-most-once. A material report describes one
1005
- // event as it happens, and two of those in a day are two events.
1006
- ...(kind === "digest"
1007
- ? { dedupeKey: digestDedupeKey(at, project.reporting?.digest?.timezone) }
1188
+ // Only a daily digest is at-most-once. Per-tick digests and material
1189
+ // reports describe new outcomes each time, so they carry no daily key.
1190
+ ...(kind === "digest" && digestPolicy.cadence === "daily"
1191
+ ? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) }
1008
1192
  : {}),
1009
1193
  at,
1010
- });
1011
- // The held notices an accepted digest re-surfaces are now owed by it,
1012
- // whatever the model goes on to write.
1013
- if (kind === "digest") store.markNoticesDigested(project.name, at);
1194
+ };
1195
+ const { report, deduped } =
1196
+ kind === "digest"
1197
+ ? store.enqueueDigestReport(
1198
+ draft,
1199
+ digestIdsFlag(argv, "events"),
1200
+ digestIdsFlag(argv, "notices"),
1201
+ )
1202
+ : store.enqueueReport(draft);
1014
1203
  process.stdout.write(
1015
1204
  deduped
1016
1205
  ? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
1017
- "the ledger decides this, not your memory of the last tick; use --kind material for a second event\n"
1206
+ "the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n"
1018
1207
  : `report ${report.id} queued for ${project.name} (${kind})\n` +
1019
1208
  "the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
1020
1209
  );
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,6 +50,8 @@ 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";
@@ -72,6 +75,7 @@ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
72
75
  const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
73
76
  const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES);
74
77
  const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES);
78
+ const WEEKDAY_LIST = quoteList(WEEKDAYS);
75
79
  const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
76
80
  const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
77
81
  const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
@@ -227,12 +231,16 @@ export function writeConfigRaw(text: string): void {
227
231
  */
228
232
  export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
229
233
  const o: Partial<Caps> = p.caps ?? {};
234
+ const workerMaxTurns = o.workerMaxTurns ?? defaults.workerMaxTurns;
230
235
  return {
231
236
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
232
237
  maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo,
233
238
  dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
234
239
  planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
235
- workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
240
+ workerMaxTurns,
241
+ workerMaxTurnsCeiling:
242
+ o.workerMaxTurnsCeiling ??
243
+ (o.workerMaxTurns === undefined ? defaults.workerMaxTurnsCeiling : workerMaxTurns * 2),
236
244
  workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
237
245
  maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
238
246
  maxContinuationsPerIssue:
@@ -366,9 +374,13 @@ function validate(parsed: unknown, path: string): ConductorConfig {
366
374
  );
367
375
  }
368
376
 
377
+ const configuredDefaults = coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps);
378
+ const defaultWorkerMaxTurns = configuredDefaults.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns;
369
379
  const defaults: Caps = {
370
380
  ...DEFAULT_CAPS,
371
- ...coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps),
381
+ ...configuredDefaults,
382
+ workerMaxTurnsCeiling:
383
+ configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
372
384
  };
373
385
 
374
386
  const rawProjects = root["projects"];
@@ -536,15 +548,17 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
536
548
  const keys = Object.keys(raw);
537
549
  // `scopePreset` is written by a fully-normalised policy (the setup wizard
538
550
  // saves presets materialised); `scope` is the legacy form. Both are known.
539
- const known = ["scope", "interruptOn", "digest", "scopePreset"];
551
+ const known = ["scope", "interruptOn", "digest", "availability", "scopePreset"];
540
552
  const unknownKeys = keys.filter((k) => !known.includes(k));
541
553
  if (unknownKeys.length > 0) {
542
554
  problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
543
555
  }
544
556
  const hasScope = raw["scope"] !== undefined;
545
- const hasExplicit = keys.includes("interruptOn") || keys.includes("digest");
557
+ const hasExplicit = keys.includes("interruptOn") || keys.includes("digest") || keys.includes("availability");
546
558
  if (hasScope && hasExplicit) {
547
- problems.push(`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest explicitly`);
559
+ problems.push(
560
+ `${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`,
561
+ );
548
562
  return defaultReporting();
549
563
  }
550
564
  if (hasScope || (!hasExplicit && keys.length === 0)) {
@@ -573,9 +587,23 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
573
587
  ? (storedPreset as ReportScope)
574
588
  : undefined;
575
589
 
590
+ const interruptOn = normalizeInterruptOn(raw["interruptOn"], label, problems);
591
+ const digest = normalizeDigest(raw["digest"], label, problems);
592
+ const availability = normalizeAvailability(raw["availability"], label, problems);
593
+ // One setup answer supplies both clocks. Hand-written configs may omit the
594
+ // digest copy; the availability zone then becomes its daily clock too.
595
+ if (availability !== undefined && digest.cadence === "daily") {
596
+ if (digest.timezone === undefined) digest.timezone = availability.timezone;
597
+ else if (digest.timezone !== availability.timezone) {
598
+ problems.push(
599
+ `${label}: reporting.digest.timezone must match reporting.availability.timezone`,
600
+ );
601
+ }
602
+ }
576
603
  return {
577
- interruptOn: normalizeInterruptOn(raw["interruptOn"], label, problems),
578
- digest: normalizeDigest(raw["digest"], label, problems),
604
+ interruptOn,
605
+ digest,
606
+ ...(availability === undefined ? {} : { availability }),
579
607
  ...(scopePreset === undefined ? {} : { scopePreset }),
580
608
  };
581
609
  }
@@ -657,6 +685,94 @@ function normalizeDigest(parsed: unknown, label: string, problems: string[]): Re
657
685
  return digest;
658
686
  }
659
687
 
688
+ function normalizeAvailability(
689
+ parsed: unknown,
690
+ label: string,
691
+ problems: string[],
692
+ ): WeeklyAvailability | undefined {
693
+ if (parsed === undefined) return undefined;
694
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
695
+ problems.push(`${label}: reporting.availability must be an object`);
696
+ return undefined;
697
+ }
698
+ const raw = parsed as Raw;
699
+ const unknownKeys = Object.keys(raw).filter(
700
+ (key) => !["timezone", "days", "start", "end", "bypass"].includes(key),
701
+ );
702
+ if (unknownKeys.length > 0) {
703
+ problems.push(`${label}: reporting.availability has unknown key(s): ${unknownKeys.join(", ")}`);
704
+ }
705
+
706
+ let timezone: string | undefined;
707
+ if (typeof raw["timezone"] !== "string" || raw["timezone"].trim() === "") {
708
+ problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`);
709
+ } else {
710
+ try {
711
+ new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
712
+ timezone = raw["timezone"];
713
+ } catch {
714
+ problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`);
715
+ }
716
+ }
717
+
718
+ const days: Weekday[] = [];
719
+ if (!Array.isArray(raw["days"]) || raw["days"].length === 0) {
720
+ problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`);
721
+ } else {
722
+ for (const item of raw["days"]) {
723
+ if (typeof item !== "string" || !(WEEKDAYS as readonly string[]).includes(item)) {
724
+ problems.push(
725
+ `${label}: reporting.availability.days has unknown day ${JSON.stringify(item)} — one of ${WEEKDAY_LIST}`,
726
+ );
727
+ continue;
728
+ }
729
+ const day = item as Weekday;
730
+ if (!days.includes(day)) days.push(day);
731
+ }
732
+ }
733
+
734
+ const start = raw["start"];
735
+ const end = raw["end"];
736
+ if (typeof start !== "string" || !DIGEST_AT.test(start)) {
737
+ problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`);
738
+ }
739
+ if (typeof end !== "string" || !DIGEST_AT.test(end)) {
740
+ problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`);
741
+ }
742
+ if (typeof start === "string" && typeof end === "string" && start === end) {
743
+ problems.push(`${label}: reporting.availability.start and end must differ`);
744
+ }
745
+
746
+ const bypass: InterruptCategory[] = [];
747
+ if (!Array.isArray(raw["bypass"])) {
748
+ problems.push(
749
+ `${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`,
750
+ );
751
+ } else {
752
+ for (const item of raw["bypass"]) {
753
+ if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
754
+ problems.push(
755
+ `${label}: reporting.availability.bypass has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`,
756
+ );
757
+ continue;
758
+ }
759
+ const category = item as InterruptCategory;
760
+ if (!bypass.includes(category)) bypass.push(category);
761
+ }
762
+ }
763
+
764
+ return timezone === undefined ||
765
+ days.length === 0 ||
766
+ typeof start !== "string" ||
767
+ !DIGEST_AT.test(start) ||
768
+ typeof end !== "string" ||
769
+ !DIGEST_AT.test(end) ||
770
+ start === end ||
771
+ !Array.isArray(raw["bypass"])
772
+ ? undefined
773
+ : { timezone, days, start, end, bypass };
774
+ }
775
+
660
776
  /**
661
777
  * Who triages escalations, and how they are delivered when nobody answers.
662
778
  *