@dev-loops/core 1.0.2-pre.0 → 1.0.2-slim.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.
@@ -1,55 +1,44 @@
1
1
  /**
2
2
  * gate-fanin.mjs — pure fan-in consolidation + cap/batch planning for the
3
- * gate-review fork sub-loop (epic #867, Phase 3 / #878).
3
+ * gate-review fork sub-loop.
4
4
  *
5
- * IMPORTANT: this module is PURE. It performs no I/O and never spawns agents.
6
- * Spawning the per-angle scoped `review` subagents is an agent-orchestrated
7
- * skill procedure (a node script cannot spawn Claude subagents). This module
8
- * only consolidates the structured per-angle findings artifacts the fan-out
9
- * produced, decides the gate verdict, plans the parallel/sequential batching of
10
- * the fan-out, and maps consolidated findings into the `--findings` JSON shape
11
- * understood by scripts/github/write-gate-findings-log.mjs.
5
+ * PURE: no I/O, never spawns agents. It consolidates the per-angle findings
6
+ * artifacts the fan-out produced, decides the gate verdict, plans the
7
+ * parallel/sequential batching, and maps consolidated findings into the
8
+ * `--findings` JSON shape understood by
9
+ * scripts/github/write-gate-findings-log.mjs.
12
10
  *
13
11
  * Per-angle review artifact shape (produced by the scoped `review` agent):
14
12
  * {
15
13
  * angle: string,
16
14
  * verdict: "clean" | "findings_present",
17
- * headSha: string, // reviewed head; consolidate-fanin --head-sha enforces it (GATE-EXEC-ARTIFACT-HEAD-STAMP)
15
+ * headSha: string, // reviewed head; consolidate-fanin --head-sha enforces it
18
16
  * findings: [{ severity, file?, line?, summary, recommendation? }]
19
17
  * }
20
18
  *
21
19
  * Severity vocabulary (owned here; consumers import SEVERITY_ORDER /
22
- * VALID_SEVERITIES / normalizeSeverity), aligned to the Copilot review
23
- * severity scale:
20
+ * VALID_SEVERITIES / normalizeSeverity):
24
21
  * "high" | "medium" | "low" (defects) | "question" | "nit" (non-defects)
25
- * Severity is the reviewer's advisory weight only. Deferral is a DISPOSITION
26
- * (derived at fan-in for non-blocking findings, finalized per thread by the
27
- * fix cycle / gate close), never a severity — the pre-rename severity
28
- * spellings ("must-fix", "worth-fixing-now", "nice-to-have", "defer") are
29
- * accepted on read and normalized to their canonical replacement (see
30
- * LEGACY_SEVERITY_ALIASES / normalizeSeverity). "question" and "nit" are
31
- * non-defect categories: a question is answered (never deferred) and an
32
- * unanswered one blocks gate-close like any unresolved thread; a nit is
33
- * deferred immediately, with no fixer cycle.
22
+ * Severity is the reviewer's advisory weight. Deferral is a DISPOSITION
23
+ * (derived at fan-in, finalized by the fix cycle / gate close), never a
24
+ * severity; pre-rename spellings are accepted on read and normalized (see
25
+ * LEGACY_SEVERITY_ALIASES / normalizeSeverity). A question is answered (never
26
+ * deferred) and an unanswered one blocks gate-close; a nit defers immediately,
27
+ * with no fixer cycle.
34
28
  */
35
29
 
36
30
  import { scheduleParallelWaves } from "./queue-parallel.mjs";
37
31
  import { trimmedOrNull } from "./normalize.mjs";
38
32
 
39
33
  /**
40
- * Schedule fan-out dispatch units into bounded-concurrency waves (issue #1601).
34
+ * Schedule fan-out dispatch units into bounded-concurrency waves: each wave
35
+ * holds at most `maxConcurrent` units, dispatched wave-by-wave (await a free
36
+ * slot before the next) instead of fire-all-then-retry. Replaces the unbounded
37
+ * concurrent fan-out that 429-stormed multi-angle gate rounds.
41
38
  *
42
- * Reuses the existing wave scheduler `scheduleParallelWaves`
43
- * (packages/core/src/loop/queue-parallel.mjs, originally the queue-mode parallel
44
- * scheduler): each wave holds at most `maxConcurrent` dispatch units, and the
45
- * conductor dispatches wave-by-wave — awaiting a free slot (wave completion)
46
- * before launching the next — instead of fire-all-then-retry. This replaces
47
- * the unbounded concurrent fan-out that 429-stormed multi-angle gate rounds
48
- * (issue #1588 drive: 5–6 reviewers 429'd per round).
49
- *
50
- * Pure: same input always yields the same wave plan (deterministic order, so
51
- * the wave plan a reviewer's gate-context artifact records is byte-stable
52
- * across fresh reviewer spawns for the same head+config).
39
+ * Pure and deterministic: same input yields the same wave plan (stable order),
40
+ * so a reviewer's recorded wave plan is byte-stable across fresh spawns for the
41
+ * same head+config.
53
42
  *
54
43
  * @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output
55
44
  * @param {number} [maxConcurrent] — `gates.fanout.maxConcurrent` (default 4, min 1)
@@ -63,19 +52,14 @@ export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
63
52
  }
64
53
 
65
54
  /**
66
- * Adaptive concurrency backoff (issue #1601; retry discipline refined by #1907):
67
- * halve the active batch before escalating to foreground one-at-a-time fallback.
68
- * A transient dispatch failure (429/5xx) is first retried on the SAME unit with
69
- * exponential backoff — safe because a reviewer's findings artifact is an
70
- * idempotent single-write at a deterministic path — and the conductor reduces
71
- * concurrency ONLY after that unit's retries are exhausted (~3 failed attempts),
72
- * recomputing the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
73
- * retrying the reduced wave; if a single-unit wave still fails, it falls back to
74
- * foreground (one-at-a-time) dispatch. This "retry the unit before reducing
75
- * concurrency" ordering is owned by GATE-EXEC-DISPATCH-RETRY-BACKOFF in
76
- * skills/docs/gate-review-sub-loop-contract.md; the backoff is recorded in the
77
- * round's provenance. Pure; never returns 0 (a backoff from 1 stays 1 →
78
- * foreground fallback owns that path).
55
+ * Adaptive concurrency backoff: halve the active batch before escalating to
56
+ * foreground one-at-a-time fallback. A transient dispatch failure is first
57
+ * retried on the SAME unit (idempotent single-write artifact); concurrency
58
+ * drops ONLY after that unit's retries are exhausted. This "retry the unit
59
+ * before reducing concurrency" ordering is owned by
60
+ * GATE-EXEC-DISPATCH-RETRY-BACKOFF in
61
+ * skills/docs/gate-review-sub-loop-contract.md. Pure; never returns 0 (a backoff
62
+ * from 1 stays 1 → foreground fallback owns that path).
79
63
  * @param {number} maxConcurrent
80
64
  * @returns {number}
81
65
  */
@@ -85,45 +69,29 @@ export function backoffMaxConcurrent(maxConcurrent) {
85
69
  }
86
70
 
87
71
  /**
88
- * Reviewer-budget preflight for a gate fan-out (issue #1507).
89
- *
90
- * Before the conductor dispatches any reviewer, it derives how many reviewers
91
- * the round needs (one per dispatch unit — fresh angles + re-verifications) and
92
- * compares against the harness's remaining reviewer budget. When the budget
93
- * cannot cover the dispatch, the preflight reports the shortfall BEFORE any
94
- * reviewer spawns, naming the shortfall; the shortfall is a recorded, resumable
95
- * state (completed per-angle artifacts stay valid for their head, so a later
96
- * session resumes the fan-out instead of restarting it). A budget shortfall
97
- * NEVER downgrades a required gate to `inline_single_agent` and NEVER produces a
98
- * clean verdict — no new gate-exemption path (#1507 AC4).
72
+ * Reviewer-budget preflight for a gate fan-out.
99
73
  *
100
- * Pure: takes the dispatch plan + available budget, returns the decision. The
101
- * conductor reads `artifact.fanout.preflight` (emitted by `write-gate-context`)
102
- * and dispatches wave-by-wave only when `dispatch === true`; on `false` it
103
- * records the shortfall (the artifact itself is the resumable record) and
104
- * stops without spawning a single reviewer. `availableReviewers` is `null` when
105
- * the harness does not expose a budget — no shortfall can be proven, so the
106
- * preflight proceeds (today's behavior); it only blocks on a PROVEN shortfall.
74
+ * Derives how many reviewers the round needs (one per dispatch unit) and
75
+ * compares against the harness's remaining budget. On a PROVEN shortfall it
76
+ * reports the shortfall BEFORE any reviewer spawns; the shortfall is a recorded,
77
+ * resumable state (completed per-angle artifacts stay valid, so a later session
78
+ * resumes rather than restarts). A shortfall NEVER downgrades a required gate to
79
+ * `inline_single_agent` and NEVER produces a clean verdict.
107
80
  *
108
- * The returned `verdict` and `executionMode` are ALWAYS `null`: a shortfall is
109
- * not a verdict. `buildPreMergeGateCheck` / `evaluateInlineFanoutMode` reject a
110
- * gate with no clean current-head marker and a non-`fanout_fanin` execution
111
- * mode, so a shortfall state fails closed at merge rather than yielding a clean
112
- * or inline verdict (#1507 DoD).
81
+ * Pure. `availableReviewers` null = harness exposes no budget → no shortfall
82
+ * provable → preflight proceeds; it blocks only on a proven shortfall. The
83
+ * returned `verdict` and `executionMode` are ALWAYS null: a shortfall is not a
84
+ * verdict, and it fails closed at merge rather than yielding a clean/inline one.
113
85
  *
114
86
  * @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output (fresh angles + re-verifications)
115
87
  * @param {number|null} [availableReviewers] — harness remaining reviewer budget; null/non-finite = unknown/unexposed
116
- * @param {{ completedAngles?: Iterable<string>, carriedAngles?: Iterable<string> }} [options] — `completedAngles`:
117
- * angle names that already have a clean per-angle findings artifact stamped for THIS head.
118
- * `carriedAngles`: angle names the fail-closed carry-forward seam (resolve-angle-carry-forward.mjs)
119
- * has proven carried from a prior clean head, so no reviewer re-runs them this round either — that
120
- * carry-forward resolution runs AFTER this preflight, so a head-bump re-gate must feed its result
121
- * back in to avoid over-counting. A dispatch unit (group) whose angles are ALL complete-or-carried
122
- * is excluded from the required count and from `pendingGroups`, so a later session resumes the
123
- * fan-out instead of restarting it: it re-runs the preflight and dispatches only the groups not
124
- * already resolved at this head. Membership is matched trim+lowercase (mirrors
125
- * consolidate-fanin.mjs's own carried-key normalization) so a config/plan case difference in an
126
- * angle name still excludes the right group instead of silently spending a reviewer on it.
88
+ * @param {{ completedAngles?: Iterable<string>, carriedAngles?: Iterable<string> }} [options] — angle names
89
+ * already resolved for THIS head: `completedAngles` have a clean per-angle artifact stamped for it,
90
+ * `carriedAngles` are proven carried forward from a prior clean head (that carry-forward runs AFTER this
91
+ * preflight, so a head-bump re-gate must feed its result back in). A group whose angles are ALL
92
+ * complete-or-carried is excluded from the required count and `pendingGroups`, so a later session
93
+ * dispatches only unresolved groups. Membership is matched trim+lowercase (mirrors consolidate-fanin.mjs's
94
+ * carried-key normalization) so a case difference still excludes the right group.
127
95
  * @returns {{ ok: boolean, dispatch: boolean, requiredReviewers: number, availableReviewers: number|null, shortfall: number|null, reason: string, verdict: null, executionMode: null, pendingGroups: { name: string, angles: string[] }[], skippedGroups: { name: string, angles: string[] }[], completedAngles: string[], carriedAngles: string[] }}
128
96
  */
129
97
  export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles, carriedAngles } = {}) {
@@ -132,17 +100,13 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
132
100
  new Set(Array.isArray(iterable) ? iterable : iterable == null ? [] : [...iterable]);
133
101
  const completedSet = toSet(completedAngles);
134
102
  const carriedSet = toSet(carriedAngles);
135
- // Same-head resume + head-bump carry-forward: one reviewer per dispatch unit
136
- // (a group of N angles is one reviewer's scoped dispatch — see
137
- // resolveFanoutGroups / countFreshDispatchUnits), but a group already
138
- // RESOLVED for this round — every one of its angles either has a clean
139
- // artifact stamped for this head OR is proven carried forward from a prior
140
- // clean head — needs no reviewer and is excluded from the required count
141
- // and the pending plan. The conductor dispatches only `pendingGroups`.
142
- // Membership is matched trim+lowercase (`normalizeAngleKey`) — the same
143
- // normalization consolidate-fanin.mjs applies to its own carried keys — so a
144
- // config/plan case difference in an angle name still excludes the group
145
- // instead of leaving it (and its exempted sibling) silently disagreeing.
103
+ // A group already RESOLVED for this round — every angle either clean-stamped
104
+ // for this head or proven carried forward — needs no reviewer and drops from
105
+ // the required count and the pending plan (the conductor dispatches only
106
+ // `pendingGroups`). Membership is matched trim+lowercase (`normalizeAngleKey`),
107
+ // the same normalization consolidate-fanin.mjs applies to its carried keys, so
108
+ // a case difference still excludes the group instead of leaving it and its
109
+ // exempted sibling silently disagreeing.
146
110
  const normalizeAngleKey = (a) => String(a).trim().toLowerCase();
147
111
  const completedKeys = new Set([...completedSet].map(normalizeAngleKey));
148
112
  const carriedKeys = new Set([...carriedSet].map(normalizeAngleKey));
@@ -165,8 +129,7 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
165
129
  if (typeof availableReviewers !== "number" || !Number.isFinite(availableReviewers)) {
166
130
  return { ok: true, dispatch: true, requiredReviewers, availableReviewers: null, shortfall: null, reason: "budget_unknown", verdict, executionMode, ...resume };
167
131
  }
168
- // A negative/over-spent budget clamps to 0 (budget exhausted → shortfall for
169
- // any non-empty round); a fractional budget truncates to the integer floor.
132
+ // A negative/over-spent budget clamps to 0; a fractional budget truncates.
170
133
  const available = Math.max(0, Math.trunc(availableReviewers));
171
134
  if (requiredReviewers === 0) {
172
135
  return { ok: true, dispatch: true, requiredReviewers: 0, availableReviewers: available, shortfall: null, reason: "no_reviewers_needed", verdict, executionMode, ...resume };
@@ -187,55 +150,35 @@ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { co
187
150
  };
188
151
  }
189
152
 
190
- // Exported so other tools (e.g. scripts/loop/consolidate-fanin.mjs,
191
- // scripts/github/upsert-checkpoint-verdict.mjs) sort/rank/validate against
192
- // this single ordered copy of the severity vocabulary instead of each
193
- // hand-copying its own list (and its own load-time drift guard) — ORDER is
194
- // part of the contract here, not just membership, so a consumer that only
195
- // checked membership against a Set could accept a silently reordered copy.
196
- // Ranked by gate-close urgency, not just defect-severity: "question" sits
197
- // right after "high" because BOTH force gate-close to stay blocked (a high
198
- // finding via the fix loop, a question via never being auto-deferred) — it
199
- // outranks "medium"/"low", which both eventually defer. "nit" trails last:
200
- // it defers immediately, with no fixer cycle at all.
153
+ // Exported as the single ordered copy of the severity vocabulary so consumers
154
+ // (consolidate-fanin.mjs, upsert-checkpoint-verdict.mjs) rank against it rather
155
+ // than each re-listing — ORDER is part of the contract, not just membership.
156
+ // Ranked by gate-close urgency: "question" sits right after "high" because BOTH
157
+ // force gate-close to stay blocked; "medium"/"low" eventually defer. "nit"
158
+ // trails last: it defers immediately, with no fixer cycle.
201
159
  export const SEVERITY_ORDER = Object.freeze(["high", "question", "medium", "low", "nit"]);
202
160
 
203
- // The non-defect subset of SEVERITY_ORDER: a "question" is answered (never
204
- // fixed or deferred like a defect — see deriveDisposition), and a "nit"
205
- // always defers regardless of any gate's blockCleanOnFindingSeverities
206
- // config (see isDefaultDeferrableSeverity) — neither belongs in a
207
- // defect-only blocking vocabulary. Exported as the single source for that
208
- // partition so a consumer (e.g. config.mjs's BLOCKING_SEVERITY_SPELLINGS
209
- // vocabulary contract test) derives "defect severities" as
210
- // SEVERITY_ORDER minus this set, rather than re-hand-listing "question"/"nit".
211
- // Object.freeze on a Set only locks its OWN properties, not the add/delete
212
- // methods that mutate its internal collection — freezing is still applied
213
- // here for consistency with SEVERITY_ORDER and this file's other frozen
214
- // exports (GATE_CONFIG_KEY, LEGACY_SEVERITY_ALIASES, etc.), and it does stop
215
- // a caller from attaching a stray own property to the Set object itself.
161
+ // The non-defect subset of SEVERITY_ORDER: a "question" is answered (see
162
+ // deriveDisposition) and a "nit" always defers regardless of any gate's
163
+ // blockCleanOnFindingSeverities (see isDefaultDeferrableSeverity) — neither
164
+ // belongs in a defect-only blocking vocabulary. The single source for that
165
+ // partition, so a consumer derives "defect severities" as SEVERITY_ORDER minus
166
+ // this set. Object.freeze on a Set locks only its OWN properties, not the
167
+ // add/delete that mutate its collection; it is applied for consistency with the
168
+ // other frozen exports and does stop a stray own property on the Set object.
216
169
  export const NON_DEFECT_SEVERITIES = Object.freeze(new Set(["question", "nit"]));
217
170
 
218
171
  // Marker gate name → gates.<key> config key. Owned here so every caller of
219
172
  // resolveFanoutGroups maps the same way; passing the marker name verbatim
220
173
  // resolves no groups and silently downgrades pairing enforcement.
221
174
  export const GATE_CONFIG_KEY = Object.freeze({ draft_gate: "draft", pre_approval_gate: "preApproval" });
222
- // Object.freeze on a Set only locks its OWN properties, not the add/delete
223
- // methods that mutate its internal collection — freezing is still applied
224
- // here for consistency with SEVERITY_ORDER and this file's other frozen
225
- // exports (GATE_CONFIG_KEY, LEGACY_SEVERITY_ALIASES, etc.), and it does stop
226
- // a caller from attaching a stray own property to the Set object itself.
227
175
  export const VALID_SEVERITIES = Object.freeze(new Set(SEVERITY_ORDER));
228
176
 
229
177
  // Pre-rename spellings. Old ledgers, markers, and configs still carry them;
230
- // every read boundary normalizes through this map. Every SANCTIONED producer
231
- // (consolidateFanin, write-gate-findings-log.mjs, post-gate-findings.mjs)
232
- // normalizes before a severity reaches a marker/ledger, so a freshly posted
233
- // marker carries only a canonical spelling in practice — but this map is a
234
- // read-side normalizer, not a write-side enforcement boundary:
235
- // buildFindingMarker (_gate-finding-surface.mjs) is a thin text builder that
236
- // emits whatever severity string it is given, verbatim (a legacy-spelled
237
- // marker built directly, e.g. for round-trip test fixtures, still parses
238
- // correctly via normalizeSeverity on read).
178
+ // every read boundary normalizes through this map. This is a read-side
179
+ // normalizer, not a write-side enforcement boundary: buildFindingMarker emits
180
+ // whatever severity string it is given verbatim, so a legacy-spelled marker
181
+ // built directly still parses correctly via normalizeSeverity on read.
239
182
  export const LEGACY_SEVERITY_ALIASES = Object.freeze({
240
183
  "must-fix": "high",
241
184
  "worth-fixing-now": "medium",
@@ -245,19 +188,12 @@ export const LEGACY_SEVERITY_ALIASES = Object.freeze({
245
188
 
246
189
  /**
247
190
  * Map a legacy severity spelling to its canonical name; unknown values pass
248
- * through trimmed (the caller's validation still rejects them) — a
249
- * non-string passes through unchanged. Trimming BEFORE the alias lookup
250
- * (rather than requiring every caller to do it first) is what keeps every
251
- * call site of this function agreeing on the same value for the same
252
- * incidentally-whitespace-varied input: consolidate-fanin.mjs's own floor
253
- * validation trims before calling this, while gate-fanin's `consolidateFanin`
254
- * does not — two call sites trimming inconsistently is exactly how an
255
- * untrimmed "high " passed one gate's validation and then failed the
256
- * other's. Deliberately case-SENSITIVE (no lowercasing): every sanctioned
257
- * writer (slugForMarker, config authoring, this module's own producers)
258
- * already emits lowercase, so a forged/hand-edited mixed-case value (e.g.
259
- * "NIT") must fail VALID_SEVERITIES validation and dangle fail-closed rather
260
- * than being silently coerced into a real severity that then auto-defers.
191
+ * through trimmed (the caller's validation still rejects them), a non-string
192
+ * unchanged. Trims BEFORE the alias lookup so every call site agrees on the
193
+ * same value for a whitespace-varied input. Deliberately case-SENSITIVE: every
194
+ * sanctioned writer emits lowercase, so a forged mixed-case value (e.g. "NIT")
195
+ * must fail VALID_SEVERITIES validation and dangle fail-closed rather than be
196
+ * silently coerced into a real severity that then auto-defers.
261
197
  * @param {unknown} severity
262
198
  * @returns {unknown}
263
199
  */
@@ -269,13 +205,9 @@ export function normalizeSeverity(severity) {
269
205
 
270
206
  /**
271
207
  * Map a (possibly legacy-spelled/untrimmed) severity to its SEVERITY_ORDER
272
- * index — the ONE rank rule every sort/ordering consumer
273
- * (consolidate-fanin.mjs's `angleWorstSeverityRank`,
274
- * upsert-checkpoint-verdict.mjs's severity-grouped rendering) shares, so the
275
- * two can never drift on how an unknown severity ranks. An unrecognized
276
- * severity (after normalization) ranks LAST (`SEVERITY_ORDER.length`, never
277
- * -1) so it always sorts after every known severity instead of floating
278
- * above "high" the way a raw, unmapped `indexOf` would.
208
+ * index — the one rank rule every sort/ordering consumer shares. An
209
+ * unrecognized severity ranks LAST (`SEVERITY_ORDER.length`, never -1) so it
210
+ * sorts after every known severity instead of floating above "high".
279
211
  * @param {unknown} severity
280
212
  * @returns {number}
281
213
  */
@@ -285,13 +217,9 @@ export function severityRank(severity) {
285
217
  }
286
218
 
287
219
  /**
288
- * A zero-initialized severity→count map, one key per SEVERITY_ORDER entry, in
289
- * SEVERITY_ORDER's order. The shared starting point every severity tally in
290
- * this codebase (consolidateFanin's own `bySeverity`,
291
- * reconcile-draft-gate.mjs's no-findings placeholder) used
292
- * to hand-roll separately via `Object.fromEntries(SEVERITY_ORDER.map((s) =>
293
- * [s, 0]))` — one copy here means a severity added to SEVERITY_ORDER is
294
- * zero-initialized everywhere at once.
220
+ * A zero-initialized severity→count map, one key per SEVERITY_ORDER entry in
221
+ * order. Single source so a severity added to SEVERITY_ORDER is zero-initialized
222
+ * everywhere at once.
295
223
  * @returns {Record<string, number>}
296
224
  */
297
225
  export function zeroSeverityCounts() {
@@ -299,19 +227,11 @@ export function zeroSeverityCounts() {
299
227
  }
300
228
 
301
229
  /**
302
- * Tally `findings` by (normalized) severity into a {@link zeroSeverityCounts}
303
- * map. Each finding's severity is normalized through `normalizeSeverity`
304
- * before counting, so a legacy spelling still lands on its canonical key. A
305
- * finding whose normalized severity is not a recognized SEVERITY_ORDER member
306
- * is silently excluded from the tally rather than inflating an unknown key —
307
- * every routed call site here counts already-validated findings in practice
308
- * (consolidateFanin validates every result's severity before this runs), so
309
- * this guard is a defensive floor against future drift, not an escape hatch
310
- * for accepting unvalidated severities. `findings` and its entries are NOT nullish-tolerant:
311
- * a nullish `findings` argument throws (not iterable), and a nullish
312
- * individual entry throws reading `.severity` — no routed caller passes
313
- * either shape, so a caller that does gets a loud failure instead of a
314
- * silently wrong all-zero tally.
230
+ * Tally `findings` by normalized severity into a {@link zeroSeverityCounts}
231
+ * map. A finding whose normalized severity is not a SEVERITY_ORDER member is
232
+ * excluded (defensive floor; consolidateFanin validates before this runs). NOT
233
+ * nullish-tolerant: a nullish `findings` or a nullish entry throws — fail-loud
234
+ * rather than a silently wrong all-zero tally.
315
235
  * @param {Iterable<{severity: unknown}>} findings
316
236
  * @returns {Record<string, number>}
317
237
  */
@@ -326,9 +246,8 @@ export function tallySeverities(findings) {
326
246
 
327
247
  /**
328
248
  * Merge a severity→count map's legacy-spelled keys into their canonical keys
329
- * (summing counts) so both the CLI parser and direct programmatic callers of
330
- * the verdict poster share ONE merge rule. Values pass through unvalidated —
331
- * the caller keeps its own integer/shape checks.
249
+ * (summing) so CLI and programmatic callers share one merge rule. Values pass
250
+ * through unvalidated.
332
251
  * @param {Record<string, number>} counts
333
252
  * @returns {Record<string, number>} null-prototype object with canonical keys
334
253
  */
@@ -342,18 +261,12 @@ export function normalizeSeverityCounts(counts) {
342
261
  }
343
262
 
344
263
  /**
345
- * Resolve a finding's effective file path from EITHER shape the shared floor
346
- * accepts: `file` (singular string, hand-authored / future-producer ledgers) or
347
- * `files[0]` (the array shape consolidate-fanin / write-gate-findings-log emit).
348
- * The ONE resolver `hasLocatableShape` and every downstream consumer keys on,
349
- * so a consumer reading `finding.files[0]` directly can never crash on a
350
- * singular-`file` finding that already passed the floor. The value is TRIMMED
351
- * and a whitespace-only `file` is treated as ABSENT (it falls back to
352
- * `files[0]`, not shadows it), so the resolved path both matches the trimmed
353
- * commentable-line-set keys and is a valid GitHub review-comment `path` even
354
- * for an untrimmed hand-authored ledger entry (`readGateFindingsLedger` trims
355
- * `files[]` but not a singular `file`). Returns `undefined` when neither shape
356
- * names a non-blank string path.
264
+ * Resolve a finding's file path from either shape the floor accepts: `file`
265
+ * (singular string) or `files[0]` (array shape). The value is TRIMMED and a
266
+ * whitespace-only `file` is treated as ABSENT (falls back to `files[0]`), so the
267
+ * path matches the trimmed commentable-line keys and is a valid GitHub
268
+ * review-comment `path`. Returns `undefined` when neither shape names a
269
+ * non-blank string path.
357
270
  * @param {{ file?: unknown, files?: unknown }} finding
358
271
  * @returns {string|undefined}
359
272
  */
@@ -366,17 +279,11 @@ export function resolveFindingFile(finding) {
366
279
  }
367
280
 
368
281
  /**
369
- * A finding is LOCATABLE-SHAPED when it names a real file (via `file` or
370
- * `files[0]`) and a positive-integer `line` — the ONE shared shape check
371
- * every producer/consumer of the locatable/non-locatable distinction keys
372
- * on, whether the finding is the raw per-angle `{file, line}` shape
373
- * (consolidateFanin's own input) or the ledger's `{files, line}` shape
374
- * (write-gate-findings-log.mjs / post-gate-findings.mjs). This is NECESSARY
375
- * but not SUFFICIENT for a thread-locatable finding: `isLocatableFinding`
376
- * (scripts/github/_gate-finding-surface.mjs) additionally requires the
377
- * file:line to fall inside the reviewed diff, which only that caller
378
- * (holding the diff's commentable-line set) can check — this function is
379
- * its shared shape floor, not a replacement for it.
282
+ * A finding is LOCATABLE-SHAPED when it names a real file (via
283
+ * {@link resolveFindingFile}) and a positive-integer `line`. NECESSARY but not
284
+ * SUFFICIENT for a thread-locatable finding: `isLocatableFinding`
285
+ * (scripts/github/_gate-finding-surface.mjs) additionally requires the file:line
286
+ * to fall inside the reviewed diff, which only that caller can check.
380
287
  * @param {{ file?: unknown, files?: unknown, line?: unknown }} finding
381
288
  * @returns {boolean}
382
289
  */
@@ -387,20 +294,13 @@ export function hasLocatableShape(finding) {
387
294
  }
388
295
 
389
296
  /**
390
- * Derive the ledger disposition for a finding at `severity` — the ONE rule
391
- * every producer (consolidateFanin, write-gate-findings-log.mjs,
392
- * post-gate-findings.mjs) shares, so the three can never drift on what a
393
- * severity/locatability combination resolves to. A LOCATABLE `question` is
394
- * answered, never fixed or deferred — it gets its own disposition
395
- * ("needs-answer") regardless of `isBlocking` (a question can never be
396
- * blocking in practice — blockCleanOnFindingSeverities is restricted to
397
- * defect severities — but this stays severity-first rather than
398
- * isBlocking-first so that invariant is enforced here too, not just at the
399
- * config boundary). A NON-LOCATABLE question has no resolvable thread to
400
- * answer through — it is body-filed and deferred by construction, exactly
401
- * like every other non-`high` body-filed finding
402
- * (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`
403
- * entirely: `isBlocking` alone decides accepted-for-fix vs deferred.
297
+ * Derive the ledger disposition for a finding at `severity` (already
298
+ * normalized) — the one rule every producer shares. A LOCATABLE `question` is
299
+ * answered ("needs-answer") regardless of `isBlocking`; severity-first so that
300
+ * invariant holds here, not just at the config boundary. A NON-LOCATABLE
301
+ * question is body-filed and deferred, like every other non-`high` body-filed
302
+ * finding (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`:
303
+ * `isBlocking` alone decides accepted-for-fix vs deferred.
404
304
  * @param {string} severity — already normalized
405
305
  * @param {{ isBlocking?: boolean, locatable?: boolean }} [options]
406
306
  * @returns {"accepted-for-fix"|"deferred"|"needs-answer"}
@@ -411,19 +311,13 @@ export function deriveDisposition(severity, { isBlocking = false, locatable = fa
411
311
  }
412
312
 
413
313
  /**
414
- * Does `severity` (already normalized) have a default disposition that
415
- * `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and
416
- * "nit" always defer regardless of any gate's `blockCleanOnFindingSeverities`
417
- * config, and "question" resolves off `locatable` alone — so a caller with no
418
- * `isBlocking` context (write-gate-findings-log.mjs / post-gate-findings.mjs's
419
- * CLI validators, which accept a bare `--findings` array with no config in
420
- * scope) can still fill in a default disposition for these three, and only
421
- * these three, when the caller left it unset. "high" and "medium" are
422
- * excluded: whether either blocks a clean verdict depends on config, which
423
- * only a caller holding `blockCleanOnFindingSeverities` can know — guessing
424
- * "deferred" for one of those here would be wrong for a repo that configures
425
- * it as blocking. Shared by both CLI validators (see `deriveDisposition`) so
426
- * the two can never restate this guard out of sync.
314
+ * Does `severity` (already normalized) have a default disposition
315
+ * `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and "nit"
316
+ * always defer, and "question" resolves off `locatable` alone — so a CLI
317
+ * validator with no config in scope can fill a default for these three only.
318
+ * "high" and "medium" are excluded: whether either blocks a clean verdict
319
+ * depends on config, so guessing "deferred" here would be wrong for a repo that
320
+ * configures it as blocking.
427
321
  * @param {string} severity — already normalized
428
322
  * @returns {boolean}
429
323
  */
@@ -435,21 +329,19 @@ const VALID_VERDICTS = new Set(["clean", "findings_present"]);
435
329
 
436
330
  /**
437
331
  * Canonical fail-closed signal for when a child/agent cannot perform real
438
- * parallel fan-out (e.g. the harness does not honor the subagent tool at child
439
- * depth). The flow MUST fail closed with this message and route the gate review
440
- * to the conductor rather than silently degrading to a single-agent inline
441
- * review (which requireFanoutProvenance is designed to reject). Documented as a
442
- * contract in skills/docs/gate-review-sub-loop-contract.md.
332
+ * parallel fan-out. The flow MUST fail closed with this message and route the
333
+ * gate review to the conductor rather than silently degrading to a single-agent
334
+ * inline review. Contract: skills/docs/gate-review-sub-loop-contract.md.
443
335
  */
444
336
  export const FANOUT_UNAVAILABLE_MESSAGE = "fan-out unavailable — route to conductor";
445
337
 
446
338
  /**
447
- * Build a fail-closed Error carrying the route-to-conductor contract signal.
448
- * Callers throw this (or check `.routeToConductor === true`) when real fan-out
449
- * cannot be performed. `detail` is appended for diagnostics but the stable,
450
- * matchable prefix is always {@link FANOUT_UNAVAILABLE_MESSAGE}.
339
+ * Build a fail-closed Error carrying the route-to-conductor signal. Callers
340
+ * throw it (or check `.routeToConductor === true`) when real fan-out cannot be
341
+ * performed. `detail` is appended for diagnostics; the matchable prefix is
342
+ * always {@link FANOUT_UNAVAILABLE_MESSAGE}.
451
343
  *
452
- * @param {string} [detail] — optional diagnostic suffix (e.g. why fan-out failed)
344
+ * @param {string} [detail] — optional diagnostic suffix
453
345
  * @returns {Error & { routeToConductor: true, code: "FANOUT_UNAVAILABLE" }}
454
346
  */
455
347
  export function fanoutUnavailableError(detail) {
@@ -459,10 +351,8 @@ export function fanoutUnavailableError(detail) {
459
351
  }
460
352
 
461
353
  /**
462
- * Count DISTINCT reviewer identities actually recorded in a `perAngle` array.
463
- * An entry contributes an identity via `reviewer` (preferred) or `dispatchId`;
464
- * entries carrying neither are not countable reviewers (a bare `{angle}` proves
465
- * nothing about who reviewed it). Pure.
354
+ * Count DISTINCT reviewer identities recorded in a `perAngle` array (identity
355
+ * via {@link reviewerIdentity}); a bare `{angle}` contributes none. Pure.
466
356
  *
467
357
  * @param {unknown} perAngle
468
358
  * @returns {number}
@@ -481,9 +371,9 @@ export function countDistinctReviewers(perAngle) {
481
371
  /**
482
372
  * The single identity-selection rule for a perAngle entry: a non-empty
483
373
  * `reviewer` wins, else a non-empty `dispatchId`, else no identity. Returns
484
- * `{ id, label }` (label = which field carried the identity, for error
485
- * messages) or null. Shared by countDistinctReviewers and
486
- * fanoutReviewerPairingError so the two can never diverge.
374
+ * `{ id, label }` (label names the carrying field, for error messages) or null.
375
+ * Shared by countDistinctReviewers and fanoutReviewerPairingError so the two
376
+ * never diverge.
487
377
  *
488
378
  * @param {object} entry — a perAngle entry
489
379
  * @returns {{ id: string, label: "reviewer"|"dispatchId" }|null}
@@ -499,22 +389,18 @@ function reviewerIdentity(entry) {
499
389
  }
500
390
 
501
391
  /**
502
- * Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an error
503
- * string when the provenance is malformed or self-inconsistent, or null when it
504
- * is well-formed and consistent. Shared by the write path (write-gate-findings-log)
505
- * and the enforcement read path (buildPreMergeGateCheck) so both agree.
392
+ * Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an
393
+ * error string when malformed/self-inconsistent, else null. Shared by the write
394
+ * path and the enforcement read path (buildPreMergeGateCheck) so both agree.
506
395
  *
507
- * Consistency rule (documented in skills/docs/gate-review-sub-loop-contract.md):
508
- * - `distinctReviewers` must be a non-negative integer.
509
- * - `perAngle` must be an array, and non-empty when `distinctReviewers > 0`.
510
- * - `distinctReviewers` must be <= the count of DISTINCT reviewer identities
511
- * actually recorded in `perAngle` — you cannot claim more reviewers than you
512
- * recorded dispatch entries for.
396
+ * Consistency rules (skills/docs/gate-review-sub-loop-contract.md):
397
+ * - `distinctReviewers` a non-negative integer.
398
+ * - `perAngle` an array, non-empty when `distinctReviewers > 0`.
399
+ * - `distinctReviewers` <= distinct reviewer identities recorded in `perAngle`.
513
400
  *
514
- * HONEST CAVEAT: this makes recorded provenance internally consistent and raises
515
- * the bar, but the provenance is self-reported (written by the same agent whose
516
- * independence it claims), so it remains forgeable by a determined single agent.
517
- * Un-forgeable recording is the Pi-harness bridge (subagent tool at child depth).
401
+ * HONEST CAVEAT: this raises the bar but the provenance is self-reported (written
402
+ * by the same agent whose independence it claims), so it stays forgeable by a
403
+ * determined single agent. Un-forgeable recording is the Pi-harness bridge.
518
404
  *
519
405
  * @param {unknown} prov
520
406
  * @returns {string|null}
@@ -542,16 +428,13 @@ export function provenanceConsistencyError(prov) {
542
428
  }
543
429
 
544
430
  /**
545
- * Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle`
546
- * array — a valid object entry naming a non-blank `angle` and carrying no
547
- * `carriedFromHead` (a carried angle's clean verdict was reused from a prior
548
- * head's review, see @dev-loops/core/loop/gate-carry-forward, not freshly
549
- * reviewed here). `group` is the entry's normalized, non-blank `group`
550
- * string, or `null`. This is the ONE definition of "fresh" and "declared
551
- * group" — {@link freshAngleNames}, {@link countFreshDispatchUnits}, and
552
- * {@link fanoutReviewerPairingError} all derive from it so the write-time
553
- * floor and the pairing check can never silently drift apart on what either
554
- * term means. Pure.
431
+ * Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle` array:
432
+ * a valid object naming a non-blank `angle` and carrying no `carriedFromHead`
433
+ * (a carried angle's clean verdict was reused from a prior head, not reviewed
434
+ * here). `group` is the normalized non-blank `group` string or `null`. The ONE
435
+ * definition of "fresh" and "declared group" that {@link freshAngleNames},
436
+ * {@link countFreshDispatchUnits}, and {@link fanoutReviewerPairingError} all
437
+ * derive from, so the write-time floor and pairing check never drift. Pure.
555
438
  * @param {unknown} perAngle
556
439
  * @returns {Generator<{ entry: object, angle: string, group: string|null }>}
557
440
  */
@@ -568,10 +451,8 @@ function* freshEntries(perAngle) {
568
451
  }
569
452
 
570
453
  /**
571
- * Names of DISTINCT "fresh" angles in a `perAngle` array — see
572
- * {@link freshEntries}. Used by callers that need the names themselves (e.g.
573
- * resolving this round's dispatch groups via `resolveFanoutGroups` for
574
- * {@link fanoutReviewerPairingError}'s cross-check). Pure.
454
+ * Names of DISTINCT "fresh" angles in a `perAngle` array (see
455
+ * {@link freshEntries}). Pure.
575
456
  *
576
457
  * @param {unknown} perAngle
577
458
  * @returns {string[]}
@@ -584,18 +465,11 @@ export function freshAngleNames(perAngle) {
584
465
 
585
466
  /**
586
467
  * Count distinct FRESH dispatch units in a `perAngle` array: a fresh angle
587
- * that declares a `group` counts once per DISTINCT group name (its whole
588
- * group is one reviewer's dispatch), and a fresh angle with no `group`
589
- * counts as its own dispatch unit (today's one-reviewer-per-angle shape).
590
- * This is the grouping-aware generalization of counting distinct fresh
591
- * angle names via {@link freshAngleNames} — for an ungrouped ledger the two
592
- * are identical; for a grouped ledger this is <= the ungrouped count, since
593
- * one group of N angles is one dispatch unit, not N. Shared by the write
594
- * path (write-gate-findings-log.mjs) and the
595
- * requireFanoutProvenance read path (detect-checkpoint-evidence.mjs) so the
596
- * `distinctReviewers` floor scales with what was actually DISPATCHED, not
597
- * with the angle count a grouped round deliberately dispatches fewer
598
- * reviewers than. Pure.
468
+ * declaring a `group` counts once per DISTINCT group name (its group is one
469
+ * reviewer's dispatch), an ungrouped fresh angle counts as its own unit. Shared
470
+ * by the write path and the requireFanoutProvenance read path so the
471
+ * `distinctReviewers` floor scales with what was DISPATCHED, not the angle count
472
+ * a grouped round deliberately dispatches fewer reviewers than. Pure.
599
473
  *
600
474
  * @param {unknown} perAngle
601
475
  * @returns {number}
@@ -611,49 +485,30 @@ export function countFreshDispatchUnits(perAngle) {
611
485
  }
612
486
 
613
487
  /**
614
- * Validate the one-scoped-reviewer-per-fresh-angle contract (fanout_fanin
615
- * execution mandates one independent reviewer per resolved angle; #1431): no
616
- * two FRESH angles (angles without `carriedFromHead` — see
617
- * {@link freshEntries}) may share one reviewer identity (`reviewer`,
618
- * else `dispatchId` — matching {@link countDistinctReviewers}'s identity
619
- * rule), UNLESS every entry sharing that identity declares the SAME `group`
620
- * name (grouped fan-out dispatch, AC6/AC7 — see resolveFanoutGroups). The
621
- * recorded `group` is self-attested at write time; when `resolvedGroups` is
622
- * supplied (both call sites always supply it) it is also checked against
623
- * the CURRENT `gates.fanout.groups` table, so an edit to that table between
624
- * the round and a later read (e.g. a merge-evidence check) can invalidate a
625
- * ledger's group claim that was honest when written — see the
626
- * `resolvedGroups` paragraph below. Two
627
- * fresh angles sharing a reviewer with differing or missing `group` values
628
- * still violate the contract. Carried angles keep their prior reviewer and
629
- * are exempt. Pure; shared by the write path (write-gate-findings-log.mjs,
630
- * always-on) and the merge-evidence read path (detect-checkpoint-evidence.mjs,
631
- * scaling the `requireFanoutProvenance` floor) so both agree.
488
+ * Validate the one-scoped-reviewer-per-fresh-angle contract: no two
489
+ * FRESH angles (see {@link freshEntries}) may share one reviewer identity
490
+ * (matching {@link countDistinctReviewers}'s rule), UNLESS every entry sharing
491
+ * that identity declares the SAME `group` name (grouped fan-out dispatch). Two
492
+ * fresh angles sharing a reviewer with differing or missing `group` still
493
+ * violate; carried angles keep their prior reviewer and are exempt. Pure; shared
494
+ * by the write path and the merge-evidence read path so both agree.
632
495
  *
633
- * Returns an actionable error string naming the offending angle(s) when the
634
- * contract is violated (an ungrouped reviewer covering >1 fresh angle, angles
635
- * sharing a reviewer under inconsistent `group` values, or a fresh angle
636
- * recording no reviewer identity at all — which also silently lowers the
637
- * distinct-reviewer count below the fresh-angle count), or `null` when it
638
- * holds (including when `perAngle` has no fresh angles).
496
+ * Returns an actionable error string naming the offending angle(s) — an
497
+ * ungrouped reviewer covering >1 fresh angle, inconsistent `group` values, or a
498
+ * fresh angle recording no reviewer identity — or `null` when the contract holds
499
+ * (including when there are no fresh angles).
639
500
  *
640
- * The recorded `group` is self-attested (any non-empty string the writer
641
- * chooses), so the grouped exception above is only as strong as the caller
642
- * lets it be. An optional `resolvedGroups` (the round's `resolveFanoutGroups`
643
- * output, `{ name, angles }[]`) closes that: a shared identity is only
644
- * honored when every fresh angle it covers is a member of the SAME
645
- * configured dispatch unit — a fabricated `group` label spanning angles the
646
- * table splits apart (or never groups at all) no longer passes.
647
- * `resolveFanoutGroups` itself emits one-angle-per-unit singletons for
648
- * `gates.fanout.mode: per-angle` (bypasses configured groups), so passing its
649
- * output here rejects ANY shared identity in that mode — no separate mode flag
650
- * needed. As of #1601 (ADR 0048) `gate:full` dispatches GROUPED (fullLabel is a
651
- * no-op for dispatch shape), so a shared identity within an auto-chunked
652
- * dispatch unit is honored exactly as for a configured group.
653
- * Omitting `resolvedGroups` entirely keeps today's fully permissive behavior (any one
654
- * shared non-null `group` value is accepted, unchecked against config) — both
655
- * call sites already load config, so they should always supply it; this
656
- * default only preserves callers (and old ledgers) that don't.
501
+ * The recorded `group` is self-attested, so the grouped exception is only as
502
+ * strong as the caller allows. An optional `resolvedGroups` (the round's
503
+ * `resolveFanoutGroups` output) closes that: a shared identity is honored only
504
+ * when every fresh angle it covers is a member of the SAME configured unit, so a
505
+ * fabricated `group` label spanning angles the table splits apart no longer
506
+ * passes. `resolveFanoutGroups` emits one-angle singletons for
507
+ * `gates.fanout.mode: per-angle`, so passing its output rejects any shared
508
+ * identity in that mode. Per ADR 0048, `gate:full` dispatches GROUPED, so a
509
+ * shared identity within an auto-chunked unit is honored as for a configured
510
+ * group. Omitting `resolvedGroups` keeps the permissive behavior (any one shared
511
+ * non-null `group` accepted) for callers that don't load config.
657
512
  *
658
513
  * @param {unknown} perAngle
659
514
  * @param {{name: string, angles: string[]}[]|null} [resolvedGroups]
@@ -688,19 +543,17 @@ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
688
543
  const details = [];
689
544
  for (const [id, { angles, label, groups }] of anglesByIdentity) {
690
545
  if (angles.size <= 1) continue;
691
- // One shared, non-null `group` across every entry for this identity is
692
- // the grouped-dispatch exception: a single reviewer legitimately covers
693
- // its whole declared group. Differing or missing `group` values fall
694
- // back to the one-reviewer-per-angle rule.
546
+ // One shared, non-null `group` across every entry is the grouped-dispatch
547
+ // exception: a single reviewer legitimately covers its whole declared group.
548
+ // Differing or missing `group` falls back to one-reviewer-per-angle.
695
549
  const sameGroup = groups.size === 1 && [...groups][0] !== null;
696
550
  if (!sameGroup) {
697
551
  details.push(`${label} "${id}" is recorded for fresh angles: ${[...angles].join(", ")}`);
698
552
  continue;
699
553
  }
700
- // resolvedGroups supplied: the claimed group is only honest when every
701
- // angle it covers is a member of the SAME configured group — a claimed
702
- // group spanning angles the table splits apart (or never groups) fails
703
- // closed even though the audit record itself is internally consistent.
554
+ // resolvedGroups supplied: the claimed group is honest only when every angle
555
+ // it covers is a member of the SAME configured group — a claimed group
556
+ // spanning angles the table splits apart fails closed.
704
557
  if (configuredGroupOf.size > 0) {
705
558
  const configuredGroups = new Set([...angles].map((a) => configuredGroupOf.get(a) ?? null));
706
559
  if (configuredGroups.size !== 1 || configuredGroups.has(null)) {
@@ -716,10 +569,9 @@ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
716
569
  }
717
570
 
718
571
  /**
719
- * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
720
- * e.g. `pr-checklist-delta-at-current-head`): a re-review scoped to only
721
- * the current head's delta still counts toward its base angle for both
722
- * mandatory-angle coverage and pool-membership checks.
572
+ * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`):
573
+ * a re-review scoped to only the current head's delta still counts toward its
574
+ * base angle for both mandatory-angle coverage and pool-membership checks.
723
575
  *
724
576
  * @param {string} angle
725
577
  * @returns {string}
@@ -729,20 +581,16 @@ export function baseAngleName(angle) {
729
581
  }
730
582
 
731
583
  /**
732
- * Validate a recorded fan-out angle list against a gate's configured angle
733
- * contract: every mandatory angle must be represented, and — when a pool is
734
- * supplied — every recorded angle must be a member of it or of
735
- * {@link FANIN_SYNTHETIC_ANGLES} (delta-suffixed angles count toward their
736
- * {@link baseAngleName}). Pure; shared by the write
737
- * path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
738
- * `--findings-json` per-angle results) and the merge-evidence read path
739
- * (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
740
- * so all three enforce identically.
584
+ * Validate a recorded fan-out angle list against a gate's angle contract: every
585
+ * mandatory angle must be represented, and — when a pool is supplied — every
586
+ * recorded angle must be a member of it or of {@link FANIN_SYNTHETIC_ANGLES}
587
+ * (delta-suffixed angles count toward their {@link baseAngleName}). Pure; shared
588
+ * by the write path and the merge-evidence read path so all enforce identically.
741
589
  *
742
- * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
590
+ * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries
743
591
  * @param {object} [gateAngleContract]
744
592
  * @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
745
- * @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted/empty skips the foreign-angle check; {@link FANIN_SYNTHETIC_ANGLES} are unioned in before membership is checked
593
+ * @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted/empty skips the foreign-angle check; {@link FANIN_SYNTHETIC_ANGLES} are unioned in first
746
594
  * @returns {{ missingMandatory: string[], foreignAngles: string[] }}
747
595
  */
748
596
  export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
@@ -763,33 +611,23 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
763
611
 
764
612
  /**
765
613
  * Angles the fan-in itself mandates and may synthesize (consolidate-fanin's
766
- * `--pr-checklist clean` upsert) without them appearing in any gate's
767
- * configured `angles` pool. Always legal in the foreign-angle check above —
768
- * requiring every consumer repo to also list them per-gate would make the two
769
- * tools contradict the shared contract they implement.
614
+ * `--pr-checklist clean` upsert) without appearing in any gate's configured
615
+ * pool. Always legal in the foreign-angle check above.
770
616
  */
771
617
  export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist"]);
772
618
 
773
619
  /**
774
620
  * Validate a round's RESOLVED angle set — the full angle list the round
775
- * targeted, independent of any single gate's configured MANDATORY subset —
776
- * against the evidence actually recorded for it: every resolved angle must
777
- * have either a per-angle artifact in `recordedAngles` (matched by
778
- * {@link baseAngleName} plus a case-insensitive compare — same base+lowercase
779
- * rule consolidate-fanin.mjs applies to its own angle keys) or be
780
- * named in `carriedAngles` (angle names a caller has already PROVEN carried
781
- * forward from a prior clean head — never a bare, unverified name; the
782
- * consolidate-fanin CLI's own `--carried-angles` is only ever populated after
783
- * its `--carry-forward-plan` proof check, so passing it straight through here
784
- * keeps that same guarantee).
621
+ * targeted, independent of any gate's mandatory subset — against recorded
622
+ * evidence: every resolved angle must have a per-angle artifact in
623
+ * `recordedAngles` (matched by {@link baseAngleName} + case-insensitive compare)
624
+ * or be named in `carriedAngles` (names a caller has already PROVEN carried
625
+ * forward, e.g. after consolidate-fanin's `--carry-forward-plan` proof).
785
626
  *
786
- * This closes a gap {@link checkFanoutAngleCoverage} leaves open: that check
787
- * only protects a CALLER-SUPPLIED mandatory subset, so a wrong carry-forward
788
- * declaration naming only NON-mandatory angles under-dispatches with no
789
- * mechanical refusal — visible only in the ledger's own carried-angle
790
- * provenance (see the Gate Review Sub-Loop Contract's Phase 3 backstop
791
- * paragraph). This function protects every resolved angle, not just the
792
- * mandatory ones. Pure.
627
+ * Closes a gap {@link checkFanoutAngleCoverage} leaves open: that check protects
628
+ * only a caller-supplied mandatory subset, so a wrong carry-forward naming only
629
+ * NON-mandatory angles under-dispatches with no refusal. This protects every
630
+ * resolved angle. Pure.
793
631
  *
794
632
  * @param {unknown} resolvedAngles — the round's full resolved angle-name list
795
633
  * @param {object} [evidence]
@@ -810,11 +648,9 @@ export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, car
810
648
  .map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
811
649
  .filter((a) => a.length > 0)
812
650
  : [];
813
- // Matched base+lowercase, same as checkFanoutAngleCoverage's callers
814
- // (consolidate-fanin's realAngleKeys/exemptCarriedKeys) and
815
- // reviewerBudgetPreflight's normalizeAngleKey: per-angle artifacts are
816
- // independently authored, so a case difference between a resolved angle
817
- // name and its recorded/carried evidence must not read as missing.
651
+ // Matched base+lowercase (per-angle artifacts are independently authored, so a
652
+ // case difference must not read as missing), same rule as
653
+ // checkFanoutAngleCoverage's callers and reviewerBudgetPreflight.
818
654
  const normalizeAngleBase = (a) => baseAngleName(a).toLowerCase();
819
655
  const recordedBases = new Set(recorded.map(normalizeAngleBase));
820
656
  const carriedBases = new Set(
@@ -828,22 +664,17 @@ export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, car
828
664
  }
829
665
 
830
666
  /**
831
- * Default cap on parallel fan-out reviewers when a caller does not supply one.
832
- * Mirrors the config default (gates.maxFanoutReviewers).
667
+ * Default cap on parallel fan-out reviewers when a caller supplies none. Mirrors
668
+ * gates.maxFanoutReviewers.
833
669
  */
834
670
  export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
835
671
 
836
- // Every sanctioned angle name is a short, hand-authored slug (e.g.
837
- // "contradiction-lens", "pr-checklist"); nothing legitimate ever
838
- // approaches this length. Bounding it here, at the trust boundary this
839
- // function already owns, fails a pathological artifact closed as malformed —
840
- // the same place every other angle-result defect is caught — instead of
841
- // leaving an unbounded reviewer-supplied string to reach the render path,
842
- // where consolidate-fanin.mjs's per-angle budget marking cannot compress it.
843
- // This is a malformed-artifact guard, not a comment-budget guarantee: several
844
- // angles each right at this cap can still exceed the render budget on their
845
- // headers alone and force the withheld tier — that outcome is the render
846
- // budget's degradation ladder doing its job, not something this cap prevents.
672
+ // Every sanctioned angle name is a short hand-authored slug; nothing legitimate
673
+ // approaches this length. Bounding it at this trust boundary fails a
674
+ // pathological artifact closed as malformed — where every other angle-result
675
+ // defect is caught. A malformed-artifact guard, not a comment-budget guarantee:
676
+ // several angles each at this cap can still exceed the render budget, which the
677
+ // render budget's degradation ladder handles.
847
678
  const MAX_ANGLE_NAME_LENGTH = 200;
848
679
 
849
680
  /**
@@ -970,9 +801,8 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
970
801
  verdict = "clean";
971
802
  }
972
803
 
973
- // `findings` already carries each entry's normalized severity, so tallying
974
- // it directly (rather than incrementing a running map inside the loop
975
- // above) reproduces the same counts via the one shared tally rule.
804
+ // findings already carries each entry's normalized severity, so tallying it
805
+ // directly reproduces the same counts via the one shared tally rule.
976
806
  return {
977
807
  verdict,
978
808
  findings,
@@ -988,21 +818,19 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
988
818
 
989
819
  /**
990
820
  * The judge's relevance-based disposition vocabulary — distinct from the
991
- * severity-based `disposition` (accepted-for-fix/deferred/needs-answer) that
992
- * `deriveDisposition` owns. The judge decides *where* a finding is acted on
993
- * (this PR or a follow-up), never *whether* it is real: a `reject` is a
994
- * relevance verdict (out-of-scope against a named non-goal or scope
995
- * boundary), not a reproduction verdict. The fixer retains reproduction-based
996
- * rejection; the judge owns relevance (#1525).
821
+ * severity-based `disposition` that `deriveDisposition` owns. The judge decides
822
+ * *where* a finding is acted on (this PR or a follow-up), never *whether* it is
823
+ * real: a `reject` is a relevance verdict (out-of-scope), not a reproduction
824
+ * verdict. The fixer retains reproduction-based rejection; the judge owns
825
+ * relevance.
997
826
  */
998
827
  export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
999
828
 
1000
829
  /**
1001
- * Validate a judge verdict artifact shape (the dedicated `judge` agent's only
1002
- * write). Pure; throws on a malformed verdict rather than silently enriching
1003
- * findings with garbage. The judge is the designated memory across rounds, so
1004
- * its artifact is the authoritative relevance record — a malformed one fails
1005
- * closed rather than degrading to severity-only disposition.
830
+ * Validate a judge verdict artifact shape (the `judge` agent's only write).
831
+ * Pure; throws on a malformed verdict rather than enriching findings with
832
+ * garbage. The judge is the designated memory across rounds, so a malformed
833
+ * artifact fails closed rather than degrading to severity-only disposition.
1006
834
  *
1007
835
  * Shape:
1008
836
  * ```
@@ -1064,8 +892,8 @@ export function validateJudgeVerdict(verdict) {
1064
892
  if (typeof entry.rationale !== "string" || entry.rationale.trim().length === 0) {
1065
893
  throw new Error(`judge verdict.dispositions[${i}].rationale must be a non-empty string naming the criterion, non-goal, or scope boundary`);
1066
894
  }
1067
- // followUpDraft is REQUIRED on a defer disposition (soft-cap contract: a
1068
- // deferred finding carries a fileable follow-up draft). Optional otherwise.
895
+ // followUpDraft is REQUIRED on a defer disposition (a deferred finding
896
+ // carries a fileable follow-up draft). Optional otherwise.
1069
897
  if (entry.disposition === "defer") {
1070
898
  if (!entry.followUpDraft || typeof entry.followUpDraft !== "object" || Array.isArray(entry.followUpDraft)) {
1071
899
  throw new Error(`judge verdict.dispositions[${i}].followUpDraft is required on a defer disposition`);
@@ -1080,31 +908,20 @@ export function validateJudgeVerdict(verdict) {
1080
908
  }
1081
909
 
1082
910
  /**
1083
- * Merge the judge's relevance-based dispositions into the consolidated findings
1084
- * array (the flat per-finding shape `consolidateFanin` / `toFindingsLogShape`
1085
- * produce). The judge runs AFTER fan-in and BEFORE the fix pass (#1525): it
1086
- * receives the consolidated ledger, the issue's AC/DoD/non-goals, the PR's
1087
- * declared scope, and prior-round ledgers, and emits a per-finding disposition
1088
- * (`act` / `defer` / `reject`) plus a scope-drift verdict on the PR as a whole.
1089
- *
1090
- * This function enriches each finding with `judgeDisposition`, `judgeRationale`,
1091
- * and (for `defer`) `followUpDraft` so the disposition ledger and posted findings
1092
- * comment carry what was consciously not acted on and why. The severity-based
1093
- * `disposition` (accepted-for-fix/deferred/needs-answer) is LEFT INTACT — the
1094
- * judge's relevance axis is complementary, not a replacement (a real defect
1095
- * stays a real defect; the judge decides *where* it is fixed, not *whether* it
1096
- * is real).
911
+ * Merge the judge's relevance-based dispositions into the flat consolidated
912
+ * findings array. The judge runs AFTER fan-in and BEFORE the fix pass:
913
+ * it emits a per-finding disposition (`act`/`defer`/`reject`) plus a scope-drift
914
+ * verdict on the PR as a whole.
1097
915
  *
1098
- * The fix pass consumes only the `act` list; the fixer retains reproduction-
1099
- * based rejection (a finding that does not reproduce is dead regardless of the
1100
- * judge's verdict) but stops deciding relevance.
916
+ * Enriches each finding with `judgeDisposition`, `judgeRationale`, and (for
917
+ * `defer`) `followUpDraft`. The severity-based `disposition` is LEFT INTACT —
918
+ * the judge's relevance axis is complementary, not a replacement. The fix pass
919
+ * consumes only the `act` list.
1101
920
  *
1102
921
  * Pure. Fails closed (throws) when a disposition references an out-of-range
1103
- * index — a judge verdict that names a finding that is not in the ledger is a
1104
- * mismatch, never a silent enrichment — and when the dispositions do not
1105
- * cover every finding: an undisposed finding must never be silently dropped
1106
- * from the fixer's act list. An empty findings array with an empty
1107
- * dispositions array is vacuously covered and returns without error.
922
+ * index, and when the dispositions do not cover every finding — an undisposed
923
+ * finding must never be silently dropped from the fixer's act list. An empty
924
+ * findings + empty dispositions pair is vacuously covered.
1108
925
  *
1109
926
  * @param {Array<object>} findings — the flat consolidated findings array
1110
927
  * @param {object} judgeVerdict — the validated judge verdict artifact
@@ -1119,11 +936,9 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
1119
936
  throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`);
1120
937
  }
1121
938
  const target = enriched[d.index];
1122
- // Reset judge-owned fields before the re-merge: a pre-enriched finding
1123
- // (already-enriched from a prior round, re-disposed by THIS verdict)
1124
- // must not let stale judgeCriterion/followUpDraft survive a
1125
- // defer -> act/reject re-disposition — the merged copy carries only
1126
- // what the current disposition provides, never prior-round residue.
939
+ // Reset judge-owned fields before the re-merge so a re-disposed finding
940
+ // (defer -> act/reject) carries only what the current disposition provides,
941
+ // never stale judgeCriterion/followUpDraft from a prior round.
1127
942
  delete target.judgeCriterion;
1128
943
  delete target.followUpDraft;
1129
944
  target.judgeDisposition = d.disposition;
@@ -1136,10 +951,9 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
1136
951
  }
1137
952
  }
1138
953
  // Coverage is judged against THIS verdict's disposed-index set, not field
1139
- // presence on the merged copy — an already-enriched ledger (a finding that
1140
- // already carries judgeDisposition from a prior round) must not let a
1141
- // verdict that disposes nothing pass silently. validateJudgeVerdict already
1142
- // rejects duplicate indexes, so the Set is exact.
954
+ // presence on the merged copy, so an already-enriched ledger can't let a
955
+ // verdict that disposes nothing pass silently. validateJudgeVerdict rejects
956
+ // duplicate indexes, so the Set is exact.
1143
957
  const disposed = new Set(validated.dispositions.map((d) => d.index));
1144
958
  const uncovered = enriched.reduce((positions, _f, i) => {
1145
959
  if (!disposed.has(i)) positions.push(i);
@@ -1184,9 +998,8 @@ export function toFindingsLogShape(findings) {
1184
998
  if (Number.isInteger(f.line) && f.line > 0) {
1185
999
  entry.line = f.line;
1186
1000
  }
1187
- // Carry the judge's relevance-based dispositions through (#1525) so the
1188
- // durable ledger and posted findings comment show what was consciously not
1189
- // acted on and why.
1001
+ // Carry the judge's relevance-based dispositions through so the ledger and
1002
+ // posted findings comment show what was consciously not acted on.
1190
1003
  if (typeof f.judgeDisposition === "string" && f.judgeDisposition.trim().length > 0) {
1191
1004
  entry.judgeDisposition = f.judgeDisposition.trim();
1192
1005
  }
@@ -1206,16 +1019,13 @@ export function toFindingsLogShape(findings) {
1206
1019
  /**
1207
1020
  * Plan how a resolved angle set fans out across the reviewer cap. Pure.
1208
1021
  *
1209
- * SUPERSEDED by `scheduleFanoutWaves` (#1601, ADR 0048): the gate fan-out
1210
- * conductor now dispatches wave-by-wave at most `gates.fanout.maxConcurrent`
1211
- * (M) dispatch units per wave, using the wave plan emitted by
1212
- * `write-gate-context.mjs`. This helper is kept only for back-compat (zero
1213
- * non-test callers) and no longer participates in the dispatch path.
1022
+ * SUPERSEDED by `scheduleFanoutWaves` (ADR 0048): the conductor now dispatches
1023
+ * wave-by-wave. Kept for back-compat only (zero non-test callers); no longer in
1024
+ * the dispatch path.
1214
1025
  *
1215
- * When `angles.length <= maxReviewers`, all reviewers run in a single parallel
1216
- * batch (no degradation). When it exceeds the cap, the overflow is split into
1217
- * sequential batches of at most `maxReviewers` each, and `degraded` is true so
1218
- * the skill can record the sequential degradation in the gate evidence.
1026
+ * When `angles.length <= maxReviewers`, all reviewers run in one parallel batch;
1027
+ * otherwise the overflow splits into sequential batches of at most `maxReviewers`
1028
+ * and `degraded` is true.
1219
1029
  *
1220
1030
  * @param {string[]} angles
1221
1031
  * @param {number} [maxReviewers] — default DEFAULT_MAX_FANOUT_REVIEWERS (8)