@dev-loops/core 1.0.1 → 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`, consolidate-fanin.mjs's
291
- * `buildAngleMarker`, 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,20 +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;
309
- * buildAngleMarker tallies consolidateFanin's own output), so this guard is a
310
- * defensive floor against future drift, not an escape hatch for accepting
311
- * unvalidated severities. `findings` and its entries are NOT nullish-tolerant:
312
- * a nullish `findings` argument throws (not iterable), and a nullish
313
- * individual entry throws reading `.severity` — no routed caller passes
314
- * either shape, so a caller that does gets a loud failure instead of a
315
- * 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.
316
235
  * @param {Iterable<{severity: unknown}>} findings
317
236
  * @returns {Record<string, number>}
318
237
  */
@@ -327,9 +246,8 @@ export function tallySeverities(findings) {
327
246
 
328
247
  /**
329
248
  * Merge a severity→count map's legacy-spelled keys into their canonical keys
330
- * (summing counts) so both the CLI parser and direct programmatic callers of
331
- * the verdict poster share ONE merge rule. Values pass through unvalidated
332
- * the caller keeps its own integer/shape checks.
249
+ * (summing) so CLI and programmatic callers share one merge rule. Values pass
250
+ * through unvalidated.
333
251
  * @param {Record<string, number>} counts
334
252
  * @returns {Record<string, number>} null-prototype object with canonical keys
335
253
  */
@@ -343,43 +261,46 @@ export function normalizeSeverityCounts(counts) {
343
261
  }
344
262
 
345
263
  /**
346
- * A finding is LOCATABLE-SHAPED when it names a real file (via `file` or
347
- * `files[0]`) and a positive-integer `line` the ONE shared shape check
348
- * every producer/consumer of the locatable/non-locatable distinction keys
349
- * on, whether the finding is the raw per-angle `{file, line}` shape
350
- * (consolidateFanin's own input) or the ledger's `{files, line}` shape
351
- * (write-gate-findings-log.mjs / post-gate-findings.mjs). This is NECESSARY
352
- * but not SUFFICIENT for a thread-locatable finding: `isLocatableFinding`
353
- * (scripts/github/_gate-finding-surface.mjs) additionally requires the
354
- * file:line to fall inside the reviewed diff, which only that caller
355
- * (holding the diff's commentable-line set) can check — this function is
356
- * its shared shape floor, not a replacement for it.
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.
270
+ * @param {{ file?: unknown, files?: unknown }} finding
271
+ * @returns {string|undefined}
272
+ */
273
+ export function resolveFindingFile(finding) {
274
+ if (typeof finding?.file === "string" && finding.file.trim().length > 0) return finding.file.trim();
275
+ if (Array.isArray(finding?.files) && typeof finding.files[0] === "string" && finding.files[0].trim().length > 0) {
276
+ return finding.files[0].trim();
277
+ }
278
+ return undefined;
279
+ }
280
+
281
+ /**
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.
357
287
  * @param {{ file?: unknown, files?: unknown, line?: unknown }} finding
358
288
  * @returns {boolean}
359
289
  */
360
290
  export function hasLocatableShape(finding) {
361
- const file = typeof finding?.file === "string"
362
- ? finding.file
363
- : (Array.isArray(finding?.files) ? finding.files[0] : undefined);
291
+ const file = resolveFindingFile(finding);
364
292
  return typeof file === "string" && file.trim().length > 0
365
293
  && Number.isInteger(finding?.line) && /** @type {number} */ (finding.line) >= 1;
366
294
  }
367
295
 
368
296
  /**
369
- * Derive the ledger disposition for a finding at `severity` — the ONE rule
370
- * every producer (consolidateFanin, write-gate-findings-log.mjs,
371
- * post-gate-findings.mjs) shares, so the three can never drift on what a
372
- * severity/locatability combination resolves to. A LOCATABLE `question` is
373
- * answered, never fixed or deferred it gets its own disposition
374
- * ("needs-answer") regardless of `isBlocking` (a question can never be
375
- * blocking in practice blockCleanOnFindingSeverities is restricted to
376
- * defect severities — but this stays severity-first rather than
377
- * isBlocking-first so that invariant is enforced here too, not just at the
378
- * config boundary). A NON-LOCATABLE question has no resolvable thread to
379
- * answer through — it is body-filed and deferred by construction, exactly
380
- * like every other non-`high` body-filed finding
381
- * (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`
382
- * 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.
383
304
  * @param {string} severity — already normalized
384
305
  * @param {{ isBlocking?: boolean, locatable?: boolean }} [options]
385
306
  * @returns {"accepted-for-fix"|"deferred"|"needs-answer"}
@@ -390,19 +311,13 @@ export function deriveDisposition(severity, { isBlocking = false, locatable = fa
390
311
  }
391
312
 
392
313
  /**
393
- * Does `severity` (already normalized) have a default disposition that
394
- * `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and
395
- * "nit" always defer regardless of any gate's `blockCleanOnFindingSeverities`
396
- * config, and "question" resolves off `locatable` alone so a caller with no
397
- * `isBlocking` context (write-gate-findings-log.mjs / post-gate-findings.mjs's
398
- * CLI validators, which accept a bare `--findings` array with no config in
399
- * scope) can still fill in a default disposition for these three, and only
400
- * these three, when the caller left it unset. "high" and "medium" are
401
- * excluded: whether either blocks a clean verdict depends on config, which
402
- * only a caller holding `blockCleanOnFindingSeverities` can know — guessing
403
- * "deferred" for one of those here would be wrong for a repo that configures
404
- * it as blocking. Shared by both CLI validators (see `deriveDisposition`) so
405
- * 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.
406
321
  * @param {string} severity — already normalized
407
322
  * @returns {boolean}
408
323
  */
@@ -414,21 +329,19 @@ const VALID_VERDICTS = new Set(["clean", "findings_present"]);
414
329
 
415
330
  /**
416
331
  * Canonical fail-closed signal for when a child/agent cannot perform real
417
- * parallel fan-out (e.g. the harness does not honor the subagent tool at child
418
- * depth). The flow MUST fail closed with this message and route the gate review
419
- * to the conductor rather than silently degrading to a single-agent inline
420
- * review (which requireFanoutProvenance is designed to reject). Documented as a
421
- * 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.
422
335
  */
423
336
  export const FANOUT_UNAVAILABLE_MESSAGE = "fan-out unavailable — route to conductor";
424
337
 
425
338
  /**
426
- * Build a fail-closed Error carrying the route-to-conductor contract signal.
427
- * Callers throw this (or check `.routeToConductor === true`) when real fan-out
428
- * cannot be performed. `detail` is appended for diagnostics but the stable,
429
- * 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}.
430
343
  *
431
- * @param {string} [detail] — optional diagnostic suffix (e.g. why fan-out failed)
344
+ * @param {string} [detail] — optional diagnostic suffix
432
345
  * @returns {Error & { routeToConductor: true, code: "FANOUT_UNAVAILABLE" }}
433
346
  */
434
347
  export function fanoutUnavailableError(detail) {
@@ -438,10 +351,8 @@ export function fanoutUnavailableError(detail) {
438
351
  }
439
352
 
440
353
  /**
441
- * Count DISTINCT reviewer identities actually recorded in a `perAngle` array.
442
- * An entry contributes an identity via `reviewer` (preferred) or `dispatchId`;
443
- * entries carrying neither are not countable reviewers (a bare `{angle}` proves
444
- * 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.
445
356
  *
446
357
  * @param {unknown} perAngle
447
358
  * @returns {number}
@@ -460,9 +371,9 @@ export function countDistinctReviewers(perAngle) {
460
371
  /**
461
372
  * The single identity-selection rule for a perAngle entry: a non-empty
462
373
  * `reviewer` wins, else a non-empty `dispatchId`, else no identity. Returns
463
- * `{ id, label }` (label = which field carried the identity, for error
464
- * messages) or null. Shared by countDistinctReviewers and
465
- * 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.
466
377
  *
467
378
  * @param {object} entry — a perAngle entry
468
379
  * @returns {{ id: string, label: "reviewer"|"dispatchId" }|null}
@@ -478,22 +389,18 @@ function reviewerIdentity(entry) {
478
389
  }
479
390
 
480
391
  /**
481
- * Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an error
482
- * string when the provenance is malformed or self-inconsistent, or null when it
483
- * is well-formed and consistent. Shared by the write path (write-gate-findings-log)
484
- * 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.
485
395
  *
486
- * Consistency rule (documented in skills/docs/gate-review-sub-loop-contract.md):
487
- * - `distinctReviewers` must be a non-negative integer.
488
- * - `perAngle` must be an array, and non-empty when `distinctReviewers > 0`.
489
- * - `distinctReviewers` must be <= the count of DISTINCT reviewer identities
490
- * actually recorded in `perAngle` — you cannot claim more reviewers than you
491
- * 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`.
492
400
  *
493
- * HONEST CAVEAT: this makes recorded provenance internally consistent and raises
494
- * the bar, but the provenance is self-reported (written by the same agent whose
495
- * independence it claims), so it remains forgeable by a determined single agent.
496
- * 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.
497
404
  *
498
405
  * @param {unknown} prov
499
406
  * @returns {string|null}
@@ -521,16 +428,13 @@ export function provenanceConsistencyError(prov) {
521
428
  }
522
429
 
523
430
  /**
524
- * Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle`
525
- * array — a valid object entry naming a non-blank `angle` and carrying no
526
- * `carriedFromHead` (a carried angle's clean verdict was reused from a prior
527
- * head's review, see @dev-loops/core/loop/gate-carry-forward, not freshly
528
- * reviewed here). `group` is the entry's normalized, non-blank `group`
529
- * string, or `null`. This is the ONE definition of "fresh" and "declared
530
- * group" {@link freshAngleNames}, {@link countFreshDispatchUnits}, and
531
- * {@link fanoutReviewerPairingError} all derive from it so the write-time
532
- * floor and the pairing check can never silently drift apart on what either
533
- * 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.
534
438
  * @param {unknown} perAngle
535
439
  * @returns {Generator<{ entry: object, angle: string, group: string|null }>}
536
440
  */
@@ -547,10 +451,8 @@ function* freshEntries(perAngle) {
547
451
  }
548
452
 
549
453
  /**
550
- * Names of DISTINCT "fresh" angles in a `perAngle` array see
551
- * {@link freshEntries}. Used by callers that need the names themselves (e.g.
552
- * resolving this round's dispatch groups via `resolveFanoutGroups` for
553
- * {@link fanoutReviewerPairingError}'s cross-check). Pure.
454
+ * Names of DISTINCT "fresh" angles in a `perAngle` array (see
455
+ * {@link freshEntries}). Pure.
554
456
  *
555
457
  * @param {unknown} perAngle
556
458
  * @returns {string[]}
@@ -563,18 +465,11 @@ export function freshAngleNames(perAngle) {
563
465
 
564
466
  /**
565
467
  * Count distinct FRESH dispatch units in a `perAngle` array: a fresh angle
566
- * that declares a `group` counts once per DISTINCT group name (its whole
567
- * group is one reviewer's dispatch), and a fresh angle with no `group`
568
- * counts as its own dispatch unit (today's one-reviewer-per-angle shape).
569
- * This is the grouping-aware generalization of counting distinct fresh
570
- * angle names via {@link freshAngleNames} for an ungrouped ledger the two
571
- * are identical; for a grouped ledger this is <= the ungrouped count, since
572
- * one group of N angles is one dispatch unit, not N. Shared by the write
573
- * path (write-gate-findings-log.mjs) and the
574
- * requireFanoutProvenance read path (detect-checkpoint-evidence.mjs) so the
575
- * `distinctReviewers` floor scales with what was actually DISPATCHED, not
576
- * with the angle count a grouped round deliberately dispatches fewer
577
- * 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.
578
473
  *
579
474
  * @param {unknown} perAngle
580
475
  * @returns {number}
@@ -590,49 +485,30 @@ export function countFreshDispatchUnits(perAngle) {
590
485
  }
591
486
 
592
487
  /**
593
- * Validate the one-scoped-reviewer-per-fresh-angle contract (fanout_fanin
594
- * execution mandates one independent reviewer per resolved angle; #1431): no
595
- * two FRESH angles (angles without `carriedFromHead` see
596
- * {@link freshEntries}) may share one reviewer identity (`reviewer`,
597
- * else `dispatchId` matching {@link countDistinctReviewers}'s identity
598
- * rule), UNLESS every entry sharing that identity declares the SAME `group`
599
- * name (grouped fan-out dispatch, AC6/AC7 see resolveFanoutGroups). The
600
- * recorded `group` is self-attested at write time; when `resolvedGroups` is
601
- * supplied (both call sites always supply it) it is also checked against
602
- * the CURRENT `gates.fanout.groups` table, so an edit to that table between
603
- * the round and a later read (e.g. a merge-evidence check) can invalidate a
604
- * ledger's group claim that was honest when written — see the
605
- * `resolvedGroups` paragraph below. Two
606
- * fresh angles sharing a reviewer with differing or missing `group` values
607
- * still violate the contract. Carried angles keep their prior reviewer and
608
- * are exempt. Pure; shared by the write path (write-gate-findings-log.mjs,
609
- * always-on) and the merge-evidence read path (detect-checkpoint-evidence.mjs,
610
- * 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.
611
495
  *
612
- * Returns an actionable error string naming the offending angle(s) when the
613
- * contract is violated (an ungrouped reviewer covering >1 fresh angle, angles
614
- * sharing a reviewer under inconsistent `group` values, or a fresh angle
615
- * recording no reviewer identity at all — which also silently lowers the
616
- * distinct-reviewer count below the fresh-angle count), or `null` when it
617
- * 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).
618
500
  *
619
- * The recorded `group` is self-attested (any non-empty string the writer
620
- * chooses), so the grouped exception above is only as strong as the caller
621
- * lets it be. An optional `resolvedGroups` (the round's `resolveFanoutGroups`
622
- * output, `{ name, angles }[]`) closes that: a shared identity is only
623
- * honored when every fresh angle it covers is a member of the SAME
624
- * configured dispatch unit — a fabricated `group` label spanning angles the
625
- * table splits apart (or never groups at all) no longer passes.
626
- * `resolveFanoutGroups` itself emits one-angle-per-unit singletons for
627
- * `gates.fanout.mode: per-angle` (bypasses configured groups), so passing its
628
- * output here rejects ANY shared identity in that mode — no separate mode flag
629
- * needed. As of #1601 (ADR 0048) `gate:full` dispatches GROUPED (fullLabel is a
630
- * no-op for dispatch shape), so a shared identity within an auto-chunked
631
- * dispatch unit is honored exactly as for a configured group.
632
- * Omitting `resolvedGroups` entirely keeps today's fully permissive behavior (any one
633
- * shared non-null `group` value is accepted, unchecked against config) — both
634
- * call sites already load config, so they should always supply it; this
635
- * 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.
636
512
  *
637
513
  * @param {unknown} perAngle
638
514
  * @param {{name: string, angles: string[]}[]|null} [resolvedGroups]
@@ -667,19 +543,17 @@ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
667
543
  const details = [];
668
544
  for (const [id, { angles, label, groups }] of anglesByIdentity) {
669
545
  if (angles.size <= 1) continue;
670
- // One shared, non-null `group` across every entry for this identity is
671
- // the grouped-dispatch exception: a single reviewer legitimately covers
672
- // its whole declared group. Differing or missing `group` values fall
673
- // 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.
674
549
  const sameGroup = groups.size === 1 && [...groups][0] !== null;
675
550
  if (!sameGroup) {
676
551
  details.push(`${label} "${id}" is recorded for fresh angles: ${[...angles].join(", ")}`);
677
552
  continue;
678
553
  }
679
- // resolvedGroups supplied: the claimed group is only honest when every
680
- // angle it covers is a member of the SAME configured group — a claimed
681
- // group spanning angles the table splits apart (or never groups) fails
682
- // 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.
683
557
  if (configuredGroupOf.size > 0) {
684
558
  const configuredGroups = new Set([...angles].map((a) => configuredGroupOf.get(a) ?? null));
685
559
  if (configuredGroups.size !== 1 || configuredGroups.has(null)) {
@@ -695,10 +569,9 @@ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
695
569
  }
696
570
 
697
571
  /**
698
- * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
699
- * e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
700
- * the current head's delta still counts toward its base angle for both
701
- * 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.
702
575
  *
703
576
  * @param {string} angle
704
577
  * @returns {string}
@@ -708,20 +581,16 @@ export function baseAngleName(angle) {
708
581
  }
709
582
 
710
583
  /**
711
- * Validate a recorded fan-out angle list against a gate's configured angle
712
- * contract: every mandatory angle must be represented, and — when a pool is
713
- * supplied — every recorded angle must be a member of it or of
714
- * {@link FANIN_SYNTHETIC_ANGLES} (delta-suffixed angles count toward their
715
- * {@link baseAngleName}). Pure; shared by the write
716
- * path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
717
- * `--findings-json` per-angle results) and the merge-evidence read path
718
- * (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
719
- * 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.
720
589
  *
721
- * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
590
+ * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries
722
591
  * @param {object} [gateAngleContract]
723
592
  * @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
724
- * @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
725
594
  * @returns {{ missingMandatory: string[], foreignAngles: string[] }}
726
595
  */
727
596
  export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
@@ -742,33 +611,23 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
742
611
 
743
612
  /**
744
613
  * Angles the fan-in itself mandates and may synthesize (consolidate-fanin's
745
- * `--pr-checklist-matrix clean` upsert) without them appearing in any gate's
746
- * configured `angles` pool. Always legal in the foreign-angle check above
747
- * requiring every consumer repo to also list them per-gate would make the two
748
- * 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.
749
616
  */
750
- export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist-matrix"]);
617
+ export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist"]);
751
618
 
752
619
  /**
753
620
  * Validate a round's RESOLVED angle set — the full angle list the round
754
- * targeted, independent of any single gate's configured MANDATORY subset —
755
- * against the evidence actually recorded for it: every resolved angle must
756
- * have either a per-angle artifact in `recordedAngles` (matched by
757
- * {@link baseAngleName} plus a case-insensitive compare same base+lowercase
758
- * rule consolidate-fanin.mjs applies to its own angle keys) or be
759
- * named in `carriedAngles` (angle names a caller has already PROVEN carried
760
- * forward from a prior clean head — never a bare, unverified name; the
761
- * consolidate-fanin CLI's own `--carried-angles` is only ever populated after
762
- * its `--carry-forward-plan` proof check, so passing it straight through here
763
- * 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).
764
626
  *
765
- * This closes a gap {@link checkFanoutAngleCoverage} leaves open: that check
766
- * only protects a CALLER-SUPPLIED mandatory subset, so a wrong carry-forward
767
- * declaration naming only NON-mandatory angles under-dispatches with no
768
- * mechanical refusal — visible only in the ledger's own carried-angle
769
- * provenance (see the Gate Review Sub-Loop Contract's Phase 3 backstop
770
- * paragraph). This function protects every resolved angle, not just the
771
- * 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.
772
631
  *
773
632
  * @param {unknown} resolvedAngles — the round's full resolved angle-name list
774
633
  * @param {object} [evidence]
@@ -789,11 +648,9 @@ export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, car
789
648
  .map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
790
649
  .filter((a) => a.length > 0)
791
650
  : [];
792
- // Matched base+lowercase, same as checkFanoutAngleCoverage's callers
793
- // (consolidate-fanin's realAngleKeys/exemptCarriedKeys) and
794
- // reviewerBudgetPreflight's normalizeAngleKey: per-angle artifacts are
795
- // independently authored, so a case difference between a resolved angle
796
- // 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.
797
654
  const normalizeAngleBase = (a) => baseAngleName(a).toLowerCase();
798
655
  const recordedBases = new Set(recorded.map(normalizeAngleBase));
799
656
  const carriedBases = new Set(
@@ -807,22 +664,17 @@ export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, car
807
664
  }
808
665
 
809
666
  /**
810
- * Default cap on parallel fan-out reviewers when a caller does not supply one.
811
- * Mirrors the config default (gates.maxFanoutReviewers).
667
+ * Default cap on parallel fan-out reviewers when a caller supplies none. Mirrors
668
+ * gates.maxFanoutReviewers.
812
669
  */
813
670
  export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
814
671
 
815
- // Every sanctioned angle name is a short, hand-authored slug (e.g.
816
- // "contradiction-lens", "pr-checklist-matrix"); nothing legitimate ever
817
- // approaches this length. Bounding it here, at the trust boundary this
818
- // function already owns, fails a pathological artifact closed as malformed —
819
- // the same place every other angle-result defect is caught instead of
820
- // leaving an unbounded reviewer-supplied string to reach the render path,
821
- // where consolidate-fanin.mjs's per-angle budget marking cannot compress it.
822
- // This is a malformed-artifact guard, not a comment-budget guarantee: several
823
- // angles each right at this cap can still exceed the render budget on their
824
- // headers alone and force the withheld tier — that outcome is the render
825
- // 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.
826
678
  const MAX_ANGLE_NAME_LENGTH = 200;
827
679
 
828
680
  /**
@@ -949,9 +801,8 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
949
801
  verdict = "clean";
950
802
  }
951
803
 
952
- // `findings` already carries each entry's normalized severity, so tallying
953
- // it directly (rather than incrementing a running map inside the loop
954
- // 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.
955
806
  return {
956
807
  verdict,
957
808
  findings,
@@ -967,21 +818,19 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
967
818
 
968
819
  /**
969
820
  * The judge's relevance-based disposition vocabulary — distinct from the
970
- * severity-based `disposition` (accepted-for-fix/deferred/needs-answer) that
971
- * `deriveDisposition` owns. The judge decides *where* a finding is acted on
972
- * (this PR or a follow-up), never *whether* it is real: a `reject` is a
973
- * relevance verdict (out-of-scope against a named non-goal or scope
974
- * boundary), not a reproduction verdict. The fixer retains reproduction-based
975
- * 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.
976
826
  */
977
827
  export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
978
828
 
979
829
  /**
980
- * Validate a judge verdict artifact shape (the dedicated `judge` agent's only
981
- * write). Pure; throws on a malformed verdict rather than silently enriching
982
- * findings with garbage. The judge is the designated memory across rounds, so
983
- * its artifact is the authoritative relevance record a malformed one fails
984
- * 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.
985
834
  *
986
835
  * Shape:
987
836
  * ```
@@ -1043,8 +892,8 @@ export function validateJudgeVerdict(verdict) {
1043
892
  if (typeof entry.rationale !== "string" || entry.rationale.trim().length === 0) {
1044
893
  throw new Error(`judge verdict.dispositions[${i}].rationale must be a non-empty string naming the criterion, non-goal, or scope boundary`);
1045
894
  }
1046
- // followUpDraft is REQUIRED on a defer disposition (soft-cap contract: a
1047
- // 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.
1048
897
  if (entry.disposition === "defer") {
1049
898
  if (!entry.followUpDraft || typeof entry.followUpDraft !== "object" || Array.isArray(entry.followUpDraft)) {
1050
899
  throw new Error(`judge verdict.dispositions[${i}].followUpDraft is required on a defer disposition`);
@@ -1059,31 +908,20 @@ export function validateJudgeVerdict(verdict) {
1059
908
  }
1060
909
 
1061
910
  /**
1062
- * Merge the judge's relevance-based dispositions into the consolidated findings
1063
- * array (the flat per-finding shape `consolidateFanin` / `toFindingsLogShape`
1064
- * produce). The judge runs AFTER fan-in and BEFORE the fix pass (#1525): it
1065
- * receives the consolidated ledger, the issue's AC/DoD/non-goals, the PR's
1066
- * declared scope, and prior-round ledgers, and emits a per-finding disposition
1067
- * (`act` / `defer` / `reject`) plus a scope-drift verdict on the PR as a whole.
1068
- *
1069
- * This function enriches each finding with `judgeDisposition`, `judgeRationale`,
1070
- * and (for `defer`) `followUpDraft` so the disposition ledger and posted findings
1071
- * comment carry what was consciously not acted on and why. The severity-based
1072
- * `disposition` (accepted-for-fix/deferred/needs-answer) is LEFT INTACT — the
1073
- * judge's relevance axis is complementary, not a replacement (a real defect
1074
- * stays a real defect; the judge decides *where* it is fixed, not *whether* it
1075
- * 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.
1076
915
  *
1077
- * The fix pass consumes only the `act` list; the fixer retains reproduction-
1078
- * based rejection (a finding that does not reproduce is dead regardless of the
1079
- * 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.
1080
920
  *
1081
921
  * Pure. Fails closed (throws) when a disposition references an out-of-range
1082
- * index a judge verdict that names a finding that is not in the ledger is a
1083
- * mismatch, never a silent enrichment and when the dispositions do not
1084
- * cover every finding: an undisposed finding must never be silently dropped
1085
- * from the fixer's act list. An empty findings array with an empty
1086
- * 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.
1087
925
  *
1088
926
  * @param {Array<object>} findings — the flat consolidated findings array
1089
927
  * @param {object} judgeVerdict — the validated judge verdict artifact
@@ -1098,11 +936,9 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
1098
936
  throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`);
1099
937
  }
1100
938
  const target = enriched[d.index];
1101
- // Reset judge-owned fields before the re-merge: a pre-enriched finding
1102
- // (already-enriched from a prior round, re-disposed by THIS verdict)
1103
- // must not let stale judgeCriterion/followUpDraft survive a
1104
- // defer -> act/reject re-disposition — the merged copy carries only
1105
- // 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.
1106
942
  delete target.judgeCriterion;
1107
943
  delete target.followUpDraft;
1108
944
  target.judgeDisposition = d.disposition;
@@ -1115,10 +951,9 @@ export function applyJudgeDispositions(findings, judgeVerdict) {
1115
951
  }
1116
952
  }
1117
953
  // Coverage is judged against THIS verdict's disposed-index set, not field
1118
- // presence on the merged copy an already-enriched ledger (a finding that
1119
- // already carries judgeDisposition from a prior round) must not let a
1120
- // verdict that disposes nothing pass silently. validateJudgeVerdict already
1121
- // 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.
1122
957
  const disposed = new Set(validated.dispositions.map((d) => d.index));
1123
958
  const uncovered = enriched.reduce((positions, _f, i) => {
1124
959
  if (!disposed.has(i)) positions.push(i);
@@ -1163,9 +998,8 @@ export function toFindingsLogShape(findings) {
1163
998
  if (Number.isInteger(f.line) && f.line > 0) {
1164
999
  entry.line = f.line;
1165
1000
  }
1166
- // Carry the judge's relevance-based dispositions through (#1525) so the
1167
- // durable ledger and posted findings comment show what was consciously not
1168
- // 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.
1169
1003
  if (typeof f.judgeDisposition === "string" && f.judgeDisposition.trim().length > 0) {
1170
1004
  entry.judgeDisposition = f.judgeDisposition.trim();
1171
1005
  }
@@ -1185,16 +1019,13 @@ export function toFindingsLogShape(findings) {
1185
1019
  /**
1186
1020
  * Plan how a resolved angle set fans out across the reviewer cap. Pure.
1187
1021
  *
1188
- * SUPERSEDED by `scheduleFanoutWaves` (#1601, ADR 0048): the gate fan-out
1189
- * conductor now dispatches wave-by-wave at most `gates.fanout.maxConcurrent`
1190
- * (M) dispatch units per wave, using the wave plan emitted by
1191
- * `write-gate-context.mjs`. This helper is kept only for back-compat (zero
1192
- * 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.
1193
1025
  *
1194
- * When `angles.length <= maxReviewers`, all reviewers run in a single parallel
1195
- * batch (no degradation). When it exceeds the cap, the overflow is split into
1196
- * sequential batches of at most `maxReviewers` each, and `degraded` is true so
1197
- * 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.
1198
1029
  *
1199
1030
  * @param {string[]} angles
1200
1031
  * @param {number} [maxReviewers] — default DEFAULT_MAX_FANOUT_REVIEWERS (8)