omp-conductor 0.13.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/setup.ts CHANGED
@@ -52,18 +52,22 @@ import {
52
52
  DEFAULT_REPORT_SCOPE,
53
53
  DENIED_RELEASE_GRANTS,
54
54
  RELEASE_SHAPES,
55
+ WEEKDAYS,
55
56
  type BaseFreshness,
56
57
  type BehindBaseAction,
57
58
  type Caps,
58
59
  type ConductorConfig,
60
+ type DigestCadence,
59
61
  type DraftPolicy,
60
62
  type OrchestratorMode,
63
+ type InterruptCategory,
61
64
  type ProjectConfig,
62
65
  type ProjectPolicy,
63
66
  type ReleaseRequirement,
64
- type ReportScope,
65
67
  type ReportScopeChoice,
66
68
  type ReportingPolicy,
69
+ type Weekday,
70
+ type WeeklyAvailability,
67
71
  type RepoTarget,
68
72
  type ResolvedGrants,
69
73
  } from "./types.ts";
@@ -92,8 +96,19 @@ export interface SetupAnswers {
92
96
  fallbackToIssueComment: boolean;
93
97
  /** How loud the supervising orchestrator session should be. */
94
98
  reportScope: ReportScopeChoice;
95
- /** The daily digest wall-clock (`HH:MM`) for the `quiet` choice, blank = model-timed. */
96
- quietDigestAt?: string;
99
+ /** One IANA zone shared by the configured window and its daily digest. */
100
+ reportingTimezone?: string;
101
+ /** Daily digest wall-clock (`HH:MM`) when an explicit daily policy is chosen. */
102
+ dailyDigestAt?: string;
103
+ /** Digest cadence when preserving or explicitly editing a reporting policy. */
104
+ digestCadence?: DigestCadence;
105
+ /** Weekly window without its shared timezone; absent keeps 24-hour interrupts. */
106
+ availability?: Omit<WeeklyAvailability, "timezone">;
107
+ /**
108
+ * Exact policy read from disk. Unrelated amend areas carry this through
109
+ * untouched; the reporting interview deletes it before applying new answers.
110
+ */
111
+ preservedReporting?: ReportingPolicy;
97
112
  /**
98
113
  * Mechanical gate for release/deploy-shaped tool calls, per shape. Always
99
114
  * complete: the wizard asks about every shape, so an answer object can never
@@ -189,7 +204,7 @@ export const REPORT_SCOPE_CHOICES: readonly {
189
204
  scope: "decisions",
190
205
  label: "Decisions interrupt, rest batches",
191
206
  description:
192
- "tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the tick report",
207
+ "tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the configured digest",
193
208
  },
194
209
  {
195
210
  scope: "material",
@@ -510,6 +525,88 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
510
525
  return created;
511
526
  }
512
527
 
528
+ const QUIET_INTERRUPT_ON: InterruptCategory[] = [
529
+ "tier2",
530
+ "fleet-stopped",
531
+ "confirmed-failure",
532
+ ];
533
+
534
+ function interruptCategories(scope: ReportScopeChoice): InterruptCategory[] {
535
+ return scope === "quiet"
536
+ ? [...QUIET_INTERRUPT_ON]
537
+ : [...(SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn];
538
+ }
539
+
540
+ /** Copy policy arrays so a setup amend cannot mutate the config it was seeded from. */
541
+ function cloneReportingPolicy(policy: ReportingPolicy): ReportingPolicy {
542
+ return {
543
+ interruptOn: [...policy.interruptOn],
544
+ digest: { ...policy.digest },
545
+ ...(policy.availability === undefined
546
+ ? {}
547
+ : {
548
+ availability: {
549
+ ...policy.availability,
550
+ days: [...policy.availability.days],
551
+ bypass: [...policy.availability.bypass],
552
+ },
553
+ }),
554
+ ...(policy.scopePreset === undefined ? {} : { scopePreset: policy.scopePreset }),
555
+ };
556
+ }
557
+
558
+ /** Turn the wizard's local notification answers into the runtime policy. */
559
+ function reportingFromAnswers(a: SetupAnswers): ReportingPolicy {
560
+ if (a.preservedReporting !== undefined) return cloneReportingPolicy(a.preservedReporting);
561
+
562
+ const digest = (fallback: DigestCadence): ReportingPolicy["digest"] => {
563
+ const cadence = a.digestCadence ?? fallback;
564
+ if (cadence !== "daily") return { cadence };
565
+ const at = a.dailyDigestAt?.trim();
566
+ const timezone = a.reportingTimezone?.trim();
567
+ return {
568
+ cadence,
569
+ ...(at === undefined || at.length === 0 ? {} : { at }),
570
+ ...(timezone === undefined || timezone.length === 0 ? {} : { timezone }),
571
+ };
572
+ };
573
+
574
+ const timezone = a.reportingTimezone?.trim();
575
+ if (a.availability !== undefined) {
576
+ if (timezone === undefined || timezone.length === 0) {
577
+ throw new Error("operator availability requires an IANA reporting timezone");
578
+ }
579
+ return {
580
+ interruptOn: interruptCategories(a.reportScope),
581
+ digest: digest("daily"),
582
+ availability: {
583
+ timezone,
584
+ days: [...a.availability.days],
585
+ start: a.availability.start,
586
+ end: a.availability.end,
587
+ bypass: [...a.availability.bypass],
588
+ },
589
+ };
590
+ }
591
+ if (a.reportScope === "quiet") {
592
+ return {
593
+ interruptOn: [...QUIET_INTERRUPT_ON],
594
+ digest: digest("daily"),
595
+ };
596
+ }
597
+ const preset = SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
598
+ const resolvedDigest = digest(preset.digest.cadence);
599
+ const presetDigest =
600
+ resolvedDigest.cadence === preset.digest.cadence &&
601
+ resolvedDigest.at === preset.digest.at &&
602
+ resolvedDigest.timezone === preset.digest.timezone;
603
+ return {
604
+ interruptOn: [...preset.interruptOn],
605
+ digest: resolvedDigest,
606
+ ...(presetDigest ? { scopePreset: preset.scopePreset } : {}),
607
+ };
608
+ }
609
+
513
610
  /**
514
611
  * The single `ProjectConfig` the answers describe.
515
612
  *
@@ -576,24 +673,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
576
673
  // and a release require without anyone having to know a default (#129).
577
674
  policy: clonePolicy(a.policy),
578
675
  // Written out even when it is the default, so an operator amending the
579
- // volume has a line in the file to point at. A `scope` answer is
580
- // materialised as its preset policy (with `scopePreset` set) so the file
581
- // and the prompt agree with no migration rule; `quiet` is the explicit
582
- // form — a curated interrupt list and a scheduled digest (#229).
583
- reporting:
584
- a.reportScope === "quiet"
585
- ? {
586
- interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
587
- digest: {
588
- cadence: "daily",
589
- ...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
590
- },
591
- }
592
- : {
593
- interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
594
- digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
595
- scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
596
- },
676
+ // volume has a line in the file to point at. Opting into availability makes
677
+ // the schedule explicit and daily; omitting it preserves the preset's
678
+ // legacy 24-hour behaviour (#273).
679
+ reporting: reportingFromAnswers(a),
597
680
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
598
681
  // uninstall, and neither can land in a repo the daemon then tries to commit.
599
682
  workspaceRoot: defaultWorkspaceRoot(),
@@ -667,19 +750,60 @@ export function defaultAnswers(projectName: string): SetupAnswers {
667
750
  * changing an unrelated one. `buildProject(answersFromProject(p))` is pinned to
668
751
  * `p` by a test for exactly that reason.
669
752
  *
670
- * Three fields cannot be a straight copy:
753
+ * Four fields cannot be a straight copy:
671
754
  *
672
755
  * - `writeOrchestratorBrief` is a decision rather than a value, and it starts
673
756
  * `false` so an amend that never visits the brief area leaves that file alone.
674
- * - `reportScope` reads through {@link DEFAULT_REPORT_SCOPE}, because the key is
675
- * optional on disk. A config written before it existed gains it explicitly on
676
- * the next write, saying what it already meant.
757
+ * - `reportScope` recognizes both preset metadata and the explicit quiet
758
+ * category set, because an availability schedule intentionally has no preset.
759
+ * - The reporting timezone is shared by availability and the daily digest in
760
+ * the wizard even though runtime config stores it in both places.
677
761
  * - `graphRoot` is one answer for a whole project while the config stores one
678
762
  * path per repo, so it comes back from whichever repo already has one. Repos
679
763
  * that disagree — only a hand-edit can produce that — widen to all of them on
680
764
  * the next write exactly as a full re-run would, and the plan summary names
681
765
  * every clone before anything is written.
682
766
  */
767
+ function sameInterruptCategories(
768
+ actual: readonly InterruptCategory[],
769
+ expected: readonly InterruptCategory[],
770
+ ): boolean {
771
+ return (
772
+ actual.length === expected.length &&
773
+ actual.every((category) => expected.includes(category))
774
+ );
775
+ }
776
+
777
+ export function reportScopeFromPolicy(policy: ReportingPolicy | undefined): ReportScopeChoice {
778
+ if (policy?.scopePreset !== undefined) return policy.scopePreset;
779
+ const actual = policy?.interruptOn ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE].interruptOn;
780
+ if (sameInterruptCategories(actual, QUIET_INTERRUPT_ON)) return "quiet";
781
+ for (const scope of ["material", "decisions", "escalations"] as const) {
782
+ if (sameInterruptCategories(actual, SCOPE_PRESETS[scope].interruptOn)) return scope;
783
+ }
784
+ return DEFAULT_REPORT_SCOPE;
785
+ }
786
+
787
+ function reportScopeDescriptionForPolicy(policy: ReportingPolicy | undefined): string {
788
+ const scope = reportScopeFromPolicy(policy);
789
+ const choice = REPORT_SCOPE_CHOICES.find((candidate) => candidate.scope === scope);
790
+ const expectedCadence =
791
+ scope === "quiet" ? "daily" : SCOPE_PRESETS[scope].digest.cadence;
792
+ const configured = policy?.digest ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE].digest;
793
+ if (configured.cadence === expectedCadence) {
794
+ return choice?.description ?? "unknown scope";
795
+ }
796
+ const digest =
797
+ configured.cadence === "none"
798
+ ? "disabled"
799
+ : configured.cadence === "per-tick"
800
+ ? "every tick"
801
+ : `daily at ${configured.at ?? "(model-timed)"}${
802
+ configured.timezone === undefined ? "" : ` ${configured.timezone}`
803
+ }`;
804
+ return `${choice?.label ?? scope}; configured digest ${digest}`;
805
+ }
806
+
683
807
  export function answersFromProject(p: ProjectConfig): SetupAnswers {
684
808
  const answers: SetupAnswers = {
685
809
  projectName: p.name,
@@ -699,14 +823,31 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
699
823
  releaseGrants: resolveReleaseGrants(p),
700
824
  policy: resolvePolicy(p),
701
825
  orchestratorMode: p.escalation.orchestrator,
702
- reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
826
+ reportScope: reportScopeFromPolicy(p.reporting),
703
827
  writeOrchestratorBrief: false,
704
828
  };
829
+ if (p.reporting !== undefined) {
830
+ answers.preservedReporting = cloneReportingPolicy(p.reporting);
831
+ answers.digestCadence = p.reporting.digest.cadence;
832
+ }
705
833
 
706
834
  // Set only when present, never as an explicit `undefined`: an absent key is
707
835
  // what keeps the rewritten config identical to the one that was read.
708
836
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
709
837
  if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
838
+ if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
839
+ if (p.reporting?.digest.timezone !== undefined) {
840
+ answers.reportingTimezone = p.reporting.digest.timezone;
841
+ }
842
+ if (p.reporting?.availability !== undefined) {
843
+ answers.reportingTimezone = p.reporting.availability.timezone;
844
+ answers.availability = {
845
+ days: [...p.reporting.availability.days],
846
+ start: p.reporting.availability.start,
847
+ end: p.reporting.availability.end,
848
+ bypass: [...p.reporting.availability.bypass],
849
+ };
850
+ }
710
851
  const graphed = graphRepos(p)[0];
711
852
  if (graphed !== undefined) answers.graphRoot = dirname(graphed.graphProject);
712
853
 
@@ -736,7 +877,6 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
736
877
  QUEUE_LABEL: p.queueLabel,
737
878
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
738
879
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
739
- REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
740
880
  POLICY_SOURCE: policySourceLine(p),
741
881
  };
742
882
  }
@@ -931,6 +1071,12 @@ function pairedOwner(accessPath: string): string | undefined {
931
1071
  return undefined;
932
1072
  }
933
1073
 
1074
+ function describeAvailabilityDays(days: readonly Weekday[]): string {
1075
+ return days.length === WEEKDAYS.length && WEEKDAYS.every((day) => days.includes(day))
1076
+ ? "every day"
1077
+ : days.join(",");
1078
+ }
1079
+
934
1080
  /**
935
1081
  * Everything that would change, as plain text, with no side effects at all.
936
1082
  *
@@ -1083,12 +1229,27 @@ export function summarisePlan(
1083
1229
  ` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`,
1084
1230
  );
1085
1231
 
1086
- const chosen = REPORT_SCOPE_CHOICES.find((c) => c.scope === a.reportScope);
1232
+ const reporting = project.reporting as ReportingPolicy;
1087
1233
  const briefPath = orchestratorBriefPath(a);
1088
1234
  lines.push(
1089
1235
  "",
1090
1236
  "reporting",
1091
- ` scope ${a.reportScope} — ${chosen?.description ?? "unknown scope"}`,
1237
+ ` scope ${a.reportScope} — ${reportScopeDescriptionForPolicy(reporting)}`,
1238
+ );
1239
+ if (reporting.availability === undefined) {
1240
+ lines.push(" availability continuous — interrupt categories may page 24 hours a day");
1241
+ } else {
1242
+ lines.push(
1243
+ ` availability ${describeAvailabilityDays(reporting.availability.days)} ` +
1244
+ `${reporting.availability.start}-${reporting.availability.end} ${reporting.availability.timezone}`,
1245
+ ` bypass ${reporting.availability.bypass.join(", ") || "none"}`,
1246
+ );
1247
+ }
1248
+ lines.push(
1249
+ ` digest ${reporting.digest.cadence}` +
1250
+ (reporting.digest.cadence === "daily"
1251
+ ? ` at ${reporting.digest.at ?? "(model-timed)"}${reporting.digest.timezone ? ` ${reporting.digest.timezone}` : ""}`
1252
+ : ""),
1092
1253
  );
1093
1254
  if (a.writeOrchestratorBrief) {
1094
1255
  const policyPath = briefPath.replace(/ORCHESTRATOR\.md$/, "POLICY.md");
@@ -1267,9 +1428,8 @@ export const AMEND_AREAS: {
1267
1428
  name: "reporting scope",
1268
1429
  asks: "how much the orchestrator says unprompted",
1269
1430
  describe: (p) => {
1270
- const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
1271
- const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
1272
- return `${scope} — ${choice?.description ?? "unknown scope"}`;
1431
+ const scope = reportScopeFromPolicy(p.reporting);
1432
+ return `${scope} ${reportScopeDescriptionForPolicy(p.reporting)}`;
1273
1433
  },
1274
1434
  },
1275
1435
  brief: {