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/README.md +253 -84
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +95 -28
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +226 -37
- package/src/config.ts +123 -7
- package/src/daemon.ts +1018 -145
- package/src/diff-flags.ts +77 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +39 -3
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +471 -39
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +166 -14
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +202 -5
- package/src/session-host.ts +4 -3
- package/src/setup.ts +197 -35
- package/src/store.ts +785 -112
- package/src/tracker/github.ts +299 -56
- package/src/types.ts +289 -37
- package/src/verbs/actions.ts +245 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +94 -13
- package/src/worker.ts +51 -11
- package/src/worktree.ts +5 -0
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
|
-
/**
|
|
96
|
-
|
|
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
|
|
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.
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
|
|
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
|
-
*
|
|
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`
|
|
674
|
-
*
|
|
675
|
-
*
|
|
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
|
|
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
|
|
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} — ${
|
|
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,
|
|
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),
|
|
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
|
|
1269
|
-
|
|
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: {
|