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/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",
@@ -251,6 +266,7 @@ export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]:
251
266
  "runs-settled": "every run this release covers actually merged, not merely reached a green PR",
252
267
  "no-open-prs": "no pull request is still open against the branch being released",
253
268
  "queue-drained": "nothing still carries the queue label",
269
+ "base-branch-green": "the newest observed post-merge base-branch workflows are green",
254
270
  "epic-children-closed": "the epic this release closes has no open children",
255
271
  };
256
272
 
@@ -509,6 +525,88 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
509
525
  return created;
510
526
  }
511
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
+
512
610
  /**
513
611
  * The single `ProjectConfig` the answers describe.
514
612
  *
@@ -575,24 +673,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
575
673
  // and a release require without anyone having to know a default (#129).
576
674
  policy: clonePolicy(a.policy),
577
675
  // Written out even when it is the default, so an operator amending the
578
- // volume has a line in the file to point at. A `scope` answer is
579
- // materialised as its preset policy (with `scopePreset` set) so the file
580
- // and the prompt agree with no migration rule; `quiet` is the explicit
581
- // form — a curated interrupt list and a scheduled digest (#229).
582
- reporting:
583
- a.reportScope === "quiet"
584
- ? {
585
- interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
586
- digest: {
587
- cadence: "daily",
588
- ...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
589
- },
590
- }
591
- : {
592
- interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
593
- digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
594
- scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
595
- },
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),
596
680
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
597
681
  // uninstall, and neither can land in a repo the daemon then tries to commit.
598
682
  workspaceRoot: defaultWorkspaceRoot(),
@@ -666,19 +750,60 @@ export function defaultAnswers(projectName: string): SetupAnswers {
666
750
  * changing an unrelated one. `buildProject(answersFromProject(p))` is pinned to
667
751
  * `p` by a test for exactly that reason.
668
752
  *
669
- * Three fields cannot be a straight copy:
753
+ * Four fields cannot be a straight copy:
670
754
  *
671
755
  * - `writeOrchestratorBrief` is a decision rather than a value, and it starts
672
756
  * `false` so an amend that never visits the brief area leaves that file alone.
673
- * - `reportScope` reads through {@link DEFAULT_REPORT_SCOPE}, because the key is
674
- * optional on disk. A config written before it existed gains it explicitly on
675
- * 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.
676
761
  * - `graphRoot` is one answer for a whole project while the config stores one
677
762
  * path per repo, so it comes back from whichever repo already has one. Repos
678
763
  * that disagree — only a hand-edit can produce that — widen to all of them on
679
764
  * the next write exactly as a full re-run would, and the plan summary names
680
765
  * every clone before anything is written.
681
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
+
682
807
  export function answersFromProject(p: ProjectConfig): SetupAnswers {
683
808
  const answers: SetupAnswers = {
684
809
  projectName: p.name,
@@ -698,14 +823,31 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
698
823
  releaseGrants: resolveReleaseGrants(p),
699
824
  policy: resolvePolicy(p),
700
825
  orchestratorMode: p.escalation.orchestrator,
701
- reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
826
+ reportScope: reportScopeFromPolicy(p.reporting),
702
827
  writeOrchestratorBrief: false,
703
828
  };
829
+ if (p.reporting !== undefined) {
830
+ answers.preservedReporting = cloneReportingPolicy(p.reporting);
831
+ answers.digestCadence = p.reporting.digest.cadence;
832
+ }
704
833
 
705
834
  // Set only when present, never as an explicit `undefined`: an absent key is
706
835
  // what keeps the rewritten config identical to the one that was read.
707
836
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
708
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
+ }
709
851
  const graphed = graphRepos(p)[0];
710
852
  if (graphed !== undefined) answers.graphRoot = dirname(graphed.graphProject);
711
853
 
@@ -735,7 +877,6 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
735
877
  QUEUE_LABEL: p.queueLabel,
736
878
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
737
879
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
738
- REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
739
880
  POLICY_SOURCE: policySourceLine(p),
740
881
  };
741
882
  }
@@ -930,6 +1071,12 @@ function pairedOwner(accessPath: string): string | undefined {
930
1071
  return undefined;
931
1072
  }
932
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
+
933
1080
  /**
934
1081
  * Everything that would change, as plain text, with no side effects at all.
935
1082
  *
@@ -1082,12 +1229,27 @@ export function summarisePlan(
1082
1229
  ` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`,
1083
1230
  );
1084
1231
 
1085
- const chosen = REPORT_SCOPE_CHOICES.find((c) => c.scope === a.reportScope);
1232
+ const reporting = project.reporting as ReportingPolicy;
1086
1233
  const briefPath = orchestratorBriefPath(a);
1087
1234
  lines.push(
1088
1235
  "",
1089
1236
  "reporting",
1090
- ` 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
+ : ""),
1091
1253
  );
1092
1254
  if (a.writeOrchestratorBrief) {
1093
1255
  const policyPath = briefPath.replace(/ORCHESTRATOR\.md$/, "POLICY.md");
@@ -1191,14 +1353,15 @@ export const AMEND_AREAS: {
1191
1353
  // The model rides with the caps because it is the other per-worker knob, and
1192
1354
  // an area no menu offers is a setting only a full re-interview can reach.
1193
1355
  name: "caps & worker model",
1194
- asks: "concurrency, spend, turns, wall clock, failed attempts, continuations — then the worker model",
1356
+ asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model",
1195
1357
  describe: (p) => {
1196
1358
  const c = resolveCaps(p, DEFAULT_CAPS);
1197
1359
  const answered = Object.keys(p.caps).length > 0;
1198
1360
  const spend =
1199
1361
  c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
1200
1362
  return (
1201
- `${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo), ${c.workerMaxTurns} turns, ` +
1363
+ `${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo), ` +
1364
+ `${c.workerMaxTurns} base/${c.workerMaxTurnsCeiling} max turns, ` +
1202
1365
  `${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
1203
1366
  `${c.maxAttemptsPerIssue} failed attempt${c.maxAttemptsPerIssue === 1 ? "" : "s"}, ` +
1204
1367
  `${c.maxContinuationsPerIssue} continuation${c.maxContinuationsPerIssue === 1 ? "" : "s"}` +
@@ -1265,9 +1428,8 @@ export const AMEND_AREAS: {
1265
1428
  name: "reporting scope",
1266
1429
  asks: "how much the orchestrator says unprompted",
1267
1430
  describe: (p) => {
1268
- const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
1269
- const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
1270
- return `${scope} — ${choice?.description ?? "unknown scope"}`;
1431
+ const scope = reportScopeFromPolicy(p.reporting);
1432
+ return `${scope} ${reportScopeDescriptionForPolicy(p.reporting)}`;
1271
1433
  },
1272
1434
  },
1273
1435
  brief: {