@dev-loops/core 1.0.0-rc.3 → 1.0.0-rc.5

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.
@@ -14,14 +14,318 @@
14
14
  * {
15
15
  * angle: string,
16
16
  * verdict: "clean" | "findings_present",
17
+ * headSha: string, // reviewed head; consolidate-fanin --head-sha enforces it (GATE-EXEC-ARTIFACT-HEAD-STAMP)
17
18
  * findings: [{ severity, file?, line?, summary, recommendation? }]
18
19
  * }
19
20
  *
20
- * Severity vocabulary (mirrors write-gate-findings-log.mjs):
21
- * "must-fix" | "worth-fixing-now" | "defer"
21
+ * Severity vocabulary (owned here; consumers import SEVERITY_ORDER /
22
+ * VALID_SEVERITIES / normalizeSeverity), aligned to the Copilot review
23
+ * severity scale:
24
+ * "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
34
  */
23
35
 
24
- const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
36
+ import { scheduleParallelWaves } from "./queue-parallel.mjs";
37
+
38
+ /**
39
+ * Schedule fan-out dispatch units into bounded-concurrency waves (issue #1601).
40
+ *
41
+ * Reuses the existing wave scheduler `scheduleParallelWaves`
42
+ * (packages/core/src/loop/queue-parallel.mjs, originally the queue-mode parallel
43
+ * scheduler): each wave holds at most `maxConcurrent` dispatch units, and the
44
+ * conductor dispatches wave-by-wave — awaiting a free slot (wave completion)
45
+ * before launching the next — instead of fire-all-then-retry. This replaces
46
+ * the unbounded concurrent fan-out that 429-stormed multi-angle gate rounds
47
+ * (issue #1588 drive: 5–6 reviewers 429'd per round).
48
+ *
49
+ * Pure: same input always yields the same wave plan (deterministic order, so
50
+ * the wave plan a reviewer's gate-context artifact records is byte-stable
51
+ * across fresh reviewer spawns for the same head+config).
52
+ *
53
+ * @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output
54
+ * @param {number} [maxConcurrent] — `gates.fanout.maxConcurrent` (default 4, min 1)
55
+ * @returns {{ name: string, angles: string[] }[][]} waves of dispatch units (at most `maxConcurrent` per wave)
56
+ */
57
+ export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
58
+ const groups = Array.isArray(dispatchGroups) ? dispatchGroups : [];
59
+ const cap = Number.isInteger(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 4;
60
+ if (groups.length === 0) return [];
61
+ return scheduleParallelWaves(groups, cap);
62
+ }
63
+
64
+ /**
65
+ * Adaptive 429-backoff concurrency (issue #1601): halve the active batch before
66
+ * escalating to foreground one-at-a-time fallback. On a 429, the conductor
67
+ * recomputes the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
68
+ * retries the failed wave; if a single-unit wave still 429s, it falls back to
69
+ * foreground (one-at-a-time) dispatch. The backoff is recorded in the round's
70
+ * provenance (see skills/docs/gate-review-sub-loop-contract.md). Pure; never
71
+ * returns 0 (a backoff from 1 stays 1 → foreground fallback owns that path).
72
+ * @param {number} maxConcurrent
73
+ * @returns {number}
74
+ */
75
+ export function backoffMaxConcurrent(maxConcurrent) {
76
+ const cap = Number.isInteger(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 4;
77
+ return Math.max(1, Math.floor(cap / 2));
78
+ }
79
+
80
+ /**
81
+ * Reviewer-budget preflight for a gate fan-out (issue #1507).
82
+ *
83
+ * Before the conductor dispatches any reviewer, it derives how many reviewers
84
+ * the round needs (one per dispatch unit — fresh angles + re-verifications) and
85
+ * compares against the harness's remaining reviewer budget. When the budget
86
+ * cannot cover the dispatch, the preflight reports the shortfall BEFORE any
87
+ * reviewer spawns, naming the shortfall; the shortfall is a recorded, resumable
88
+ * state (completed per-angle artifacts stay valid for their head, so a later
89
+ * session resumes the fan-out instead of restarting it). A budget shortfall
90
+ * NEVER downgrades a required gate to `inline_single_agent` and NEVER produces a
91
+ * clean verdict — no new gate-exemption path (#1507 AC4).
92
+ *
93
+ * Pure: takes the dispatch plan + available budget, returns the decision. The
94
+ * conductor reads `artifact.fanout.preflight` (emitted by `write-gate-context`)
95
+ * and dispatches wave-by-wave only when `dispatch === true`; on `false` it
96
+ * records the shortfall (the artifact itself is the resumable record) and
97
+ * stops without spawning a single reviewer. `availableReviewers` is `null` when
98
+ * the harness does not expose a budget — no shortfall can be proven, so the
99
+ * preflight proceeds (today's behavior); it only blocks on a PROVEN shortfall.
100
+ *
101
+ * The returned `verdict` and `executionMode` are ALWAYS `null`: a shortfall is
102
+ * not a verdict. `buildPreMergeGateCheck` / `evaluateInlineFanoutMode` reject a
103
+ * gate with no clean current-head marker and a non-`fanout_fanin` execution
104
+ * mode, so a shortfall state fails closed at merge rather than yielding a clean
105
+ * or inline verdict (#1507 DoD).
106
+ *
107
+ * @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output (fresh angles + re-verifications)
108
+ * @param {number|null} [availableReviewers] — harness remaining reviewer budget; null/non-finite = unknown/unexposed
109
+ * @param {{ completedAngles?: Iterable<string> }} [options] — `completedAngles`: angle names that
110
+ * already have a clean per-angle findings artifact stamped for THIS head. A dispatch unit (group)
111
+ * whose angles are ALL complete is excluded from the required count and from `pendingGroups`, so a
112
+ * later session resumes the fan-out instead of restarting it (issue #1507 AC3): it re-runs the
113
+ * preflight and dispatches only the groups not already complete at this head.
114
+ * @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[] }}
115
+ */
116
+ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles } = {}) {
117
+ const groups = Array.isArray(dispatchGroups) ? dispatchGroups : [];
118
+ const completedSet = new Set(
119
+ Array.isArray(completedAngles)
120
+ ? completedAngles
121
+ : completedAngles == null
122
+ ? []
123
+ : [...completedAngles],
124
+ );
125
+ // #1507 AC3: resume instead of restart. One reviewer per dispatch unit (a
126
+ // group of N angles is one reviewer's scoped dispatch — see resolveFanoutGroups /
127
+ // countFreshDispatchUnits), but a group already COMPLETE at this head — every
128
+ // one of its angles has a clean artifact stamped for this head — needs no
129
+ // reviewer and is excluded from the required count and the pending plan. The
130
+ // conductor dispatches only `pendingGroups`.
131
+ const groupIsComplete = (g) =>
132
+ Array.isArray(g?.angles) && g.angles.length > 0 && g.angles.every((a) => completedSet.has(a));
133
+ const pendingGroups = groups.filter((g) => !groupIsComplete(g));
134
+ const skippedGroups = groups.filter((g) => groupIsComplete(g));
135
+ // One reviewer per dispatch unit: a group of N angles is one reviewer's
136
+ // scoped dispatch, so the reviewer count is the pending dispatch-unit count,
137
+ // not the raw angle count.
138
+ const requiredReviewers = pendingGroups.length;
139
+ const verdict = null;
140
+ const executionMode = null;
141
+ const resume = { pendingGroups, skippedGroups, completedAngles: [...completedSet] };
142
+ if (typeof availableReviewers !== "number" || !Number.isFinite(availableReviewers)) {
143
+ return { ok: true, dispatch: true, requiredReviewers, availableReviewers: null, shortfall: null, reason: "budget_unknown", verdict, executionMode, ...resume };
144
+ }
145
+ // A negative/over-spent budget clamps to 0 (budget exhausted → shortfall for
146
+ // any non-empty round); a fractional budget truncates to the integer floor.
147
+ const available = Math.max(0, Math.trunc(availableReviewers));
148
+ if (requiredReviewers === 0) {
149
+ return { ok: true, dispatch: true, requiredReviewers: 0, availableReviewers: available, shortfall: null, reason: "no_reviewers_needed", verdict, executionMode, ...resume };
150
+ }
151
+ if (available >= requiredReviewers) {
152
+ return { ok: true, dispatch: true, requiredReviewers, availableReviewers: available, shortfall: null, reason: "budget_sufficient", verdict, executionMode, ...resume };
153
+ }
154
+ return {
155
+ ok: false,
156
+ dispatch: false,
157
+ requiredReviewers,
158
+ availableReviewers: available,
159
+ shortfall: requiredReviewers - available,
160
+ reason: "budget_shortfall",
161
+ verdict,
162
+ executionMode,
163
+ ...resume,
164
+ };
165
+ }
166
+
167
+ // Exported so other tools (e.g. scripts/loop/consolidate-fanin.mjs,
168
+ // scripts/github/upsert-checkpoint-verdict.mjs) sort/rank/validate against
169
+ // this single ordered copy of the severity vocabulary instead of each
170
+ // hand-copying its own list (and its own load-time drift guard) — ORDER is
171
+ // part of the contract here, not just membership, so a consumer that only
172
+ // checked membership against a Set could accept a silently reordered copy.
173
+ // Ranked by gate-close urgency, not just defect-severity: "question" sits
174
+ // right after "high" because BOTH force gate-close to stay blocked (a high
175
+ // finding via the fix loop, a question via never being auto-deferred) — it
176
+ // outranks "medium"/"low", which both eventually defer. "nit" trails last:
177
+ // it defers immediately, with no fixer cycle at all.
178
+ export const SEVERITY_ORDER = ["high", "question", "medium", "low", "nit"];
179
+
180
+ // Marker gate name → gates.<key> config key. Owned here so every caller of
181
+ // resolveFanoutGroups maps the same way; passing the marker name verbatim
182
+ // resolves no groups and silently downgrades pairing enforcement.
183
+ export const GATE_CONFIG_KEY = Object.freeze({ draft_gate: "draft", pre_approval_gate: "preApproval" });
184
+ export const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
185
+
186
+ // Pre-rename spellings. Old ledgers, markers, and configs still carry them;
187
+ // every read boundary normalizes through this map. Every SANCTIONED producer
188
+ // (consolidateFanin, write-gate-findings-log.mjs, post-gate-findings.mjs)
189
+ // normalizes before a severity reaches a marker/ledger, so a freshly posted
190
+ // marker carries only a canonical spelling in practice — but this map is a
191
+ // read-side normalizer, not a write-side enforcement boundary:
192
+ // buildFindingMarker (_gate-finding-surface.mjs) is a thin text builder that
193
+ // emits whatever severity string it is given, verbatim (a legacy-spelled
194
+ // marker built directly, e.g. for round-trip test fixtures, still parses
195
+ // correctly via normalizeSeverity on read).
196
+ export const LEGACY_SEVERITY_ALIASES = Object.freeze({
197
+ "must-fix": "high",
198
+ "worth-fixing-now": "medium",
199
+ "nice-to-have": "low",
200
+ defer: "low",
201
+ });
202
+
203
+ /**
204
+ * Map a legacy severity spelling to its canonical name; unknown values pass
205
+ * through trimmed (the caller's validation still rejects them) — a
206
+ * non-string passes through unchanged. Trimming BEFORE the alias lookup
207
+ * (rather than requiring every caller to do it first) is what keeps every
208
+ * call site of this function agreeing on the same value for the same
209
+ * incidentally-whitespace-varied input: consolidate-fanin.mjs's own floor
210
+ * validation trims before calling this, while gate-fanin's `consolidateFanin`
211
+ * does not — two call sites trimming inconsistently is exactly how an
212
+ * untrimmed "high " passed one gate's validation and then failed the
213
+ * other's. Deliberately case-SENSITIVE (no lowercasing): every sanctioned
214
+ * writer (slugForMarker, config authoring, this module's own producers)
215
+ * already emits lowercase, so a forged/hand-edited mixed-case value (e.g.
216
+ * "NIT") must fail VALID_SEVERITIES validation and dangle fail-closed rather
217
+ * than being silently coerced into a real severity that then auto-defers.
218
+ * @param {unknown} severity
219
+ * @returns {unknown}
220
+ */
221
+ export function normalizeSeverity(severity) {
222
+ if (typeof severity !== "string") return severity;
223
+ const normalized = severity.trim();
224
+ return Object.hasOwn(LEGACY_SEVERITY_ALIASES, normalized) ? LEGACY_SEVERITY_ALIASES[normalized] : normalized;
225
+ }
226
+
227
+ /**
228
+ * Map a (possibly legacy-spelled/untrimmed) severity to its SEVERITY_ORDER
229
+ * index — the ONE rank rule every sort/ordering consumer
230
+ * (consolidate-fanin.mjs's `angleWorstSeverityRank`,
231
+ * upsert-checkpoint-verdict.mjs's severity-grouped rendering) shares, so the
232
+ * two can never drift on how an unknown severity ranks. An unrecognized
233
+ * severity (after normalization) ranks LAST (`SEVERITY_ORDER.length`, never
234
+ * -1) so it always sorts after every known severity instead of floating
235
+ * above "high" the way a raw, unmapped `indexOf` would.
236
+ * @param {unknown} severity
237
+ * @returns {number}
238
+ */
239
+ export function severityRank(severity) {
240
+ const idx = SEVERITY_ORDER.indexOf(/** @type {string} */ (normalizeSeverity(severity)));
241
+ return idx === -1 ? SEVERITY_ORDER.length : idx;
242
+ }
243
+
244
+ /**
245
+ * Merge a severity→count map's legacy-spelled keys into their canonical keys
246
+ * (summing counts) so both the CLI parser and direct programmatic callers of
247
+ * the verdict poster share ONE merge rule. Values pass through unvalidated —
248
+ * the caller keeps its own integer/shape checks.
249
+ * @param {Record<string, number>} counts
250
+ * @returns {Record<string, number>} null-prototype object with canonical keys
251
+ */
252
+ export function normalizeSeverityCounts(counts) {
253
+ const normalized = Object.create(null);
254
+ for (const [key, value] of Object.entries(counts)) {
255
+ const canonicalKey = /** @type {string} */ (normalizeSeverity(key));
256
+ normalized[canonicalKey] = (normalized[canonicalKey] ?? 0) + value;
257
+ }
258
+ return normalized;
259
+ }
260
+
261
+ /**
262
+ * A finding is LOCATABLE-SHAPED when it names a real file (via `file` or
263
+ * `files[0]`) and a positive-integer `line` — the ONE shared shape check
264
+ * every producer/consumer of the locatable/non-locatable distinction keys
265
+ * on, whether the finding is the raw per-angle `{file, line}` shape
266
+ * (consolidateFanin's own input) or the ledger's `{files, line}` shape
267
+ * (write-gate-findings-log.mjs / post-gate-findings.mjs). This is NECESSARY
268
+ * but not SUFFICIENT for a thread-locatable finding: `isLocatableFinding`
269
+ * (scripts/github/_gate-finding-surface.mjs) additionally requires the
270
+ * file:line to fall inside the reviewed diff, which only that caller
271
+ * (holding the diff's commentable-line set) can check — this function is
272
+ * its shared shape floor, not a replacement for it.
273
+ * @param {{ file?: unknown, files?: unknown, line?: unknown }} finding
274
+ * @returns {boolean}
275
+ */
276
+ export function hasLocatableShape(finding) {
277
+ const file = typeof finding?.file === "string"
278
+ ? finding.file
279
+ : (Array.isArray(finding?.files) ? finding.files[0] : undefined);
280
+ return typeof file === "string" && file.trim().length > 0
281
+ && Number.isInteger(finding?.line) && /** @type {number} */ (finding.line) >= 1;
282
+ }
283
+
284
+ /**
285
+ * Derive the ledger disposition for a finding at `severity` — the ONE rule
286
+ * every producer (consolidateFanin, write-gate-findings-log.mjs,
287
+ * post-gate-findings.mjs) shares, so the three can never drift on what a
288
+ * severity/locatability combination resolves to. A LOCATABLE `question` is
289
+ * answered, never fixed or deferred — it gets its own disposition
290
+ * ("needs-answer") regardless of `isBlocking` (a question can never be
291
+ * blocking in practice — blockCleanOnFindingSeverities is restricted to
292
+ * defect severities — but this stays severity-first rather than
293
+ * isBlocking-first so that invariant is enforced here too, not just at the
294
+ * config boundary). A NON-LOCATABLE question has no resolvable thread to
295
+ * answer through — it is body-filed and deferred by construction, exactly
296
+ * like every other non-`high` body-filed finding
297
+ * (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`
298
+ * entirely: `isBlocking` alone decides accepted-for-fix vs deferred.
299
+ * @param {string} severity — already normalized
300
+ * @param {{ isBlocking?: boolean, locatable?: boolean }} [options]
301
+ * @returns {"accepted-for-fix"|"deferred"|"needs-answer"}
302
+ */
303
+ export function deriveDisposition(severity, { isBlocking = false, locatable = false } = {}) {
304
+ if (severity === "question") return locatable ? "needs-answer" : "deferred";
305
+ return isBlocking ? "accepted-for-fix" : "deferred";
306
+ }
307
+
308
+ /**
309
+ * Does `severity` (already normalized) have a default disposition that
310
+ * `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and
311
+ * "nit" always defer regardless of any gate's `blockCleanOnFindingSeverities`
312
+ * config, and "question" resolves off `locatable` alone — so a caller with no
313
+ * `isBlocking` context (write-gate-findings-log.mjs / post-gate-findings.mjs's
314
+ * CLI validators, which accept a bare `--findings` array with no config in
315
+ * scope) can still fill in a default disposition for these three, and only
316
+ * these three, when the caller left it unset. "high" and "medium" are
317
+ * excluded: whether either blocks a clean verdict depends on config, which
318
+ * only a caller holding `blockCleanOnFindingSeverities` can know — guessing
319
+ * "deferred" for one of those here would be wrong for a repo that configures
320
+ * it as blocking. Shared by both CLI validators (see `deriveDisposition`) so
321
+ * the two can never restate this guard out of sync.
322
+ * @param {string} severity — already normalized
323
+ * @returns {boolean}
324
+ */
325
+ export function isDefaultDeferrableSeverity(severity) {
326
+ return severity === "low" || severity === "nit" || severity === "question";
327
+ }
328
+
25
329
  const VALID_VERDICTS = new Set(["clean", "findings_present"]);
26
330
 
27
331
  /**
@@ -63,16 +367,32 @@ export function countDistinctReviewers(perAngle) {
63
367
  const ids = new Set();
64
368
  for (const e of perAngle) {
65
369
  if (!e || typeof e !== "object" || Array.isArray(e)) continue;
66
- const id = typeof e.reviewer === "string" && e.reviewer.trim().length > 0
67
- ? e.reviewer.trim()
68
- : typeof e.dispatchId === "string" && e.dispatchId.trim().length > 0
69
- ? e.dispatchId.trim()
70
- : null;
71
- if (id) ids.add(id);
370
+ const identity = reviewerIdentity(e);
371
+ if (identity) ids.add(identity.id);
72
372
  }
73
373
  return ids.size;
74
374
  }
75
375
 
376
+ /**
377
+ * The single identity-selection rule for a perAngle entry: a non-empty
378
+ * `reviewer` wins, else a non-empty `dispatchId`, else no identity. Returns
379
+ * `{ id, label }` (label = which field carried the identity, for error
380
+ * messages) or null. Shared by countDistinctReviewers and
381
+ * fanoutReviewerPairingError so the two can never diverge.
382
+ *
383
+ * @param {object} entry — a perAngle entry
384
+ * @returns {{ id: string, label: "reviewer"|"dispatchId" }|null}
385
+ */
386
+ function reviewerIdentity(entry) {
387
+ if (typeof entry.reviewer === "string" && entry.reviewer.trim().length > 0) {
388
+ return { id: entry.reviewer.trim(), label: "reviewer" };
389
+ }
390
+ if (typeof entry.dispatchId === "string" && entry.dispatchId.trim().length > 0) {
391
+ return { id: entry.dispatchId.trim(), label: "dispatchId" };
392
+ }
393
+ return null;
394
+ }
395
+
76
396
  /**
77
397
  * Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an error
78
398
  * string when the provenance is malformed or self-inconsistent, or null when it
@@ -116,6 +436,180 @@ export function provenanceConsistencyError(prov) {
116
436
  return null;
117
437
  }
118
438
 
439
+ /**
440
+ * Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle`
441
+ * array — a valid object entry naming a non-blank `angle` and carrying no
442
+ * `carriedFromHead` (a carried angle's clean verdict was reused from a prior
443
+ * head's review, see @dev-loops/core/loop/gate-carry-forward, not freshly
444
+ * reviewed here). `group` is the entry's normalized, non-blank `group`
445
+ * string, or `null`. This is the ONE definition of "fresh" and "declared
446
+ * group" — {@link freshAngleNames}, {@link countFreshDispatchUnits}, and
447
+ * {@link fanoutReviewerPairingError} all derive from it so the write-time
448
+ * floor and the pairing check can never silently drift apart on what either
449
+ * term means. Pure.
450
+ * @param {unknown} perAngle
451
+ * @returns {Generator<{ entry: object, angle: string, group: string|null }>}
452
+ */
453
+ function* freshEntries(perAngle) {
454
+ if (!Array.isArray(perAngle)) return;
455
+ for (const entry of perAngle) {
456
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
457
+ if (typeof entry.carriedFromHead === "string" && entry.carriedFromHead.trim().length > 0) continue;
458
+ const angle = typeof entry.angle === "string" ? entry.angle.trim() : "";
459
+ if (!angle) continue;
460
+ const group = typeof entry.group === "string" && entry.group.trim().length > 0 ? entry.group.trim() : null;
461
+ yield { entry, angle, group };
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Names of DISTINCT "fresh" angles in a `perAngle` array — see
467
+ * {@link freshEntries}. Used by callers that need the names themselves (e.g.
468
+ * resolving this round's dispatch groups via `resolveFanoutGroups` for
469
+ * {@link fanoutReviewerPairingError}'s cross-check). Pure.
470
+ *
471
+ * @param {unknown} perAngle
472
+ * @returns {string[]}
473
+ */
474
+ export function freshAngleNames(perAngle) {
475
+ const angles = new Set();
476
+ for (const { angle } of freshEntries(perAngle)) angles.add(angle);
477
+ return [...angles];
478
+ }
479
+
480
+ /**
481
+ * Count distinct FRESH dispatch units in a `perAngle` array: a fresh angle
482
+ * that declares a `group` counts once per DISTINCT group name (its whole
483
+ * group is one reviewer's dispatch), and a fresh angle with no `group`
484
+ * counts as its own dispatch unit (today's one-reviewer-per-angle shape).
485
+ * This is the grouping-aware generalization of counting distinct fresh
486
+ * angle names via {@link freshAngleNames} — for an ungrouped ledger the two
487
+ * are identical; for a grouped ledger this is <= the ungrouped count, since
488
+ * one group of N angles is one dispatch unit, not N. Shared by the write
489
+ * path (write-gate-findings-log.mjs) and the
490
+ * requireFanoutProvenance read path (detect-checkpoint-evidence.mjs) so the
491
+ * `distinctReviewers` floor scales with what was actually DISPATCHED, not
492
+ * with the angle count a grouped round deliberately dispatches fewer
493
+ * reviewers than. Pure.
494
+ *
495
+ * @param {unknown} perAngle
496
+ * @returns {number}
497
+ */
498
+ export function countFreshDispatchUnits(perAngle) {
499
+ const groups = new Set();
500
+ const ungroupedAngles = new Set();
501
+ for (const { angle, group } of freshEntries(perAngle)) {
502
+ if (group) groups.add(group);
503
+ else ungroupedAngles.add(angle);
504
+ }
505
+ return groups.size + ungroupedAngles.size;
506
+ }
507
+
508
+ /**
509
+ * Validate the one-scoped-reviewer-per-fresh-angle contract (fanout_fanin
510
+ * execution mandates one independent reviewer per resolved angle; #1431): no
511
+ * two FRESH angles (angles without `carriedFromHead` — see
512
+ * {@link freshEntries}) may share one reviewer identity (`reviewer`,
513
+ * else `dispatchId` — matching {@link countDistinctReviewers}'s identity
514
+ * rule), UNLESS every entry sharing that identity declares the SAME `group`
515
+ * name (grouped fan-out dispatch, AC6/AC7 — see resolveFanoutGroups). The
516
+ * recorded `group` is self-attested at write time; when `resolvedGroups` is
517
+ * supplied (both call sites always supply it) it is also checked against
518
+ * the CURRENT `gates.fanout.groups` table, so an edit to that table between
519
+ * the round and a later read (e.g. a merge-evidence check) can invalidate a
520
+ * ledger's group claim that was honest when written — see the
521
+ * `resolvedGroups` paragraph below. Two
522
+ * fresh angles sharing a reviewer with differing or missing `group` values
523
+ * still violate the contract. Carried angles keep their prior reviewer and
524
+ * are exempt. Pure; shared by the write path (write-gate-findings-log.mjs,
525
+ * always-on) and the merge-evidence read path (detect-checkpoint-evidence.mjs,
526
+ * scaling the `requireFanoutProvenance` floor) so both agree.
527
+ *
528
+ * Returns an actionable error string naming the offending angle(s) when the
529
+ * contract is violated (an ungrouped reviewer covering >1 fresh angle, angles
530
+ * sharing a reviewer under inconsistent `group` values, or a fresh angle
531
+ * recording no reviewer identity at all — which also silently lowers the
532
+ * distinct-reviewer count below the fresh-angle count), or `null` when it
533
+ * holds (including when `perAngle` has no fresh angles).
534
+ *
535
+ * The recorded `group` is self-attested (any non-empty string the writer
536
+ * chooses), so the grouped exception above is only as strong as the caller
537
+ * lets it be. An optional `resolvedGroups` (the round's `resolveFanoutGroups`
538
+ * output, `{ name, angles }[]`) closes that: a shared identity is only
539
+ * honored when every fresh angle it covers is a member of the SAME
540
+ * configured dispatch unit — a fabricated `group` label spanning angles the
541
+ * table splits apart (or never groups at all) no longer passes.
542
+ * `resolveFanoutGroups` itself emits one-angle-per-unit singletons for
543
+ * `gates.fanout.mode: per-angle` (bypasses configured groups), so passing its
544
+ * output here rejects ANY shared identity in that mode — no separate mode flag
545
+ * needed. As of #1601 (ADR 0048) `gate:full` dispatches GROUPED (fullLabel is a
546
+ * no-op for dispatch shape), so a shared identity within an auto-chunked
547
+ * dispatch unit is honored exactly as for a configured group.
548
+ * Omitting `resolvedGroups` entirely keeps today's fully permissive behavior (any one
549
+ * shared non-null `group` value is accepted, unchecked against config) — both
550
+ * call sites already load config, so they should always supply it; this
551
+ * default only preserves callers (and old ledgers) that don't.
552
+ *
553
+ * @param {unknown} perAngle
554
+ * @param {{name: string, angles: string[]}[]|null} [resolvedGroups]
555
+ * @returns {string|null}
556
+ */
557
+ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
558
+ if (!Array.isArray(perAngle)) return null;
559
+ const configuredGroupOf = new Map();
560
+ for (const g of Array.isArray(resolvedGroups) ? resolvedGroups : []) {
561
+ for (const a of Array.isArray(g?.angles) ? g.angles : []) configuredGroupOf.set(a, g.name);
562
+ }
563
+ const freshAngles = new Set();
564
+ const anglesByIdentity = new Map();
565
+ const anonymousAngles = [];
566
+ for (const { entry, angle, group } of freshEntries(perAngle)) {
567
+ freshAngles.add(angle);
568
+ const identity = reviewerIdentity(entry);
569
+ if (identity) {
570
+ if (!anglesByIdentity.has(identity.id)) anglesByIdentity.set(identity.id, { angles: new Set(), label: identity.label, groups: new Set() });
571
+ const record = anglesByIdentity.get(identity.id);
572
+ record.angles.add(angle);
573
+ record.groups.add(group);
574
+ } else {
575
+ anonymousAngles.push(angle);
576
+ }
577
+ }
578
+ const freshAngleCount = freshAngles.size;
579
+ const distinctFreshReviewers = anglesByIdentity.size;
580
+ // Enforce the relation itself, not its cardinality shadow: a padded ledger
581
+ // (duplicate-angle entries) can satisfy distinctReviewers >= freshAngleCount
582
+ // while one identity still covers two fresh angles.
583
+ const details = [];
584
+ for (const [id, { angles, label, groups }] of anglesByIdentity) {
585
+ if (angles.size <= 1) continue;
586
+ // One shared, non-null `group` across every entry for this identity is
587
+ // the grouped-dispatch exception: a single reviewer legitimately covers
588
+ // its whole declared group. Differing or missing `group` values fall
589
+ // back to the one-reviewer-per-angle rule.
590
+ const sameGroup = groups.size === 1 && [...groups][0] !== null;
591
+ if (!sameGroup) {
592
+ details.push(`${label} "${id}" is recorded for fresh angles: ${[...angles].join(", ")}`);
593
+ continue;
594
+ }
595
+ // resolvedGroups supplied: the claimed group is only honest when every
596
+ // angle it covers is a member of the SAME configured group — a claimed
597
+ // group spanning angles the table splits apart (or never groups) fails
598
+ // closed even though the audit record itself is internally consistent.
599
+ if (configuredGroupOf.size > 0) {
600
+ const configuredGroups = new Set([...angles].map((a) => configuredGroupOf.get(a) ?? null));
601
+ if (configuredGroups.size !== 1 || configuredGroups.has(null)) {
602
+ details.push(`${label} "${id}" declares group "${[...groups][0]}" for fresh angles: ${[...angles].join(", ")}, but the configured gates.fanout.groups table does not place all of them in one group`);
603
+ }
604
+ }
605
+ }
606
+ if (anonymousAngles.length > 0) {
607
+ details.push(`fresh angle(s) with no recorded reviewer identity: ${anonymousAngles.join(", ")}`);
608
+ }
609
+ if (details.length === 0) return null;
610
+ return `fan-out provenance violates the one-scoped-reviewer-per-angle contract (${distinctFreshReviewers} distinct reviewer(s) for ${freshAngleCount} fresh angle(s)): ${details.join("; ")} — use executionMode inline_single_agent + --inline-reason for a sanctioned single-reviewer run`;
611
+ }
612
+
119
613
  /**
120
614
  * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
121
615
  * e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
@@ -125,15 +619,16 @@ export function provenanceConsistencyError(prov) {
125
619
  * @param {string} angle
126
620
  * @returns {string}
127
621
  */
128
- function baseAngleName(angle) {
622
+ export function baseAngleName(angle) {
129
623
  return angle.replace(/-delta-at-.+$/, "");
130
624
  }
131
625
 
132
626
  /**
133
627
  * Validate a recorded fan-out angle list against a gate's configured angle
134
628
  * contract: every mandatory angle must be represented, and — when a pool is
135
- * supplied — every recorded angle must be a member of it (delta-suffixed
136
- * angles count toward their {@link baseAngleName}). Pure; shared by the write
629
+ * supplied — every recorded angle must be a member of it or of
630
+ * {@link FANIN_SYNTHETIC_ANGLES} (delta-suffixed angles count toward their
631
+ * {@link baseAngleName}). Pure; shared by the write
137
632
  * path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
138
633
  * `--findings-json` per-angle results) and the merge-evidence read path
139
634
  * (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
@@ -142,7 +637,7 @@ function baseAngleName(angle) {
142
637
  * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
143
638
  * @param {object} [gateAngleContract]
144
639
  * @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
145
- * @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted skips the foreign-angle check
640
+ * @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
146
641
  * @returns {{ missingMandatory: string[], foreignAngles: string[] }}
147
642
  */
148
643
  export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
@@ -155,18 +650,40 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
155
650
  const missingMandatory = mandatoryAngles.filter((a) => !recordedBases.has(a));
156
651
  let foreignAngles = [];
157
652
  if (Array.isArray(pool) && pool.length > 0) {
158
- const poolSet = new Set(pool);
653
+ const poolSet = new Set([...pool, ...FANIN_SYNTHETIC_ANGLES]);
159
654
  foreignAngles = [...new Set(recorded.filter((a) => !poolSet.has(baseAngleName(a))))];
160
655
  }
161
656
  return { missingMandatory, foreignAngles };
162
657
  }
163
658
 
659
+ /**
660
+ * Angles the fan-in itself mandates and may synthesize (consolidate-fanin's
661
+ * `--pr-checklist-matrix clean` upsert) without them appearing in any gate's
662
+ * configured `angles` pool. Always legal in the foreign-angle check above —
663
+ * requiring every consumer repo to also list them per-gate would make the two
664
+ * tools contradict the shared contract they implement.
665
+ */
666
+ export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist-matrix"]);
667
+
164
668
  /**
165
669
  * Default cap on parallel fan-out reviewers when a caller does not supply one.
166
670
  * Mirrors the config default (gates.maxFanoutReviewers).
167
671
  */
168
672
  export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
169
673
 
674
+ // Every sanctioned angle name is a short, hand-authored slug (e.g.
675
+ // "contradiction-lens", "pr-checklist-matrix"); nothing legitimate ever
676
+ // approaches this length. Bounding it here, at the trust boundary this
677
+ // function already owns, fails a pathological artifact closed as malformed —
678
+ // the same place every other angle-result defect is caught — instead of
679
+ // leaving an unbounded reviewer-supplied string to reach the render path,
680
+ // where consolidate-fanin.mjs's per-angle budget marking cannot compress it.
681
+ // This is a malformed-artifact guard, not a comment-budget guarantee: several
682
+ // angles each right at this cap can still exceed the render budget on their
683
+ // headers alone and force the withheld tier — that outcome is the render
684
+ // budget's degradation ladder doing its job, not something this cap prevents.
685
+ const MAX_ANGLE_NAME_LENGTH = 200;
686
+
170
687
  /**
171
688
  * Validate a single per-angle review result. Returns an error string when the
172
689
  * result is malformed, or null when it is well-formed.
@@ -182,6 +699,9 @@ function validateAngleResult(result) {
182
699
  if (typeof r.angle !== "string" || r.angle.trim().length === 0) {
183
700
  return "angle result is missing a non-empty 'angle'";
184
701
  }
702
+ if (r.angle.trim().length > MAX_ANGLE_NAME_LENGTH) {
703
+ return `angle result's 'angle' exceeds ${MAX_ANGLE_NAME_LENGTH} chars`;
704
+ }
185
705
  if (typeof r.verdict !== "string" || !VALID_VERDICTS.has(r.verdict)) {
186
706
  return `angle '${r.angle}' has invalid verdict (expected clean|findings_present)`;
187
707
  }
@@ -193,8 +713,8 @@ function validateAngleResult(result) {
193
713
  return `angle '${r.angle}' has a non-object finding`;
194
714
  }
195
715
  const finding = /** @type {Record<string, unknown>} */ (f);
196
- if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(finding.severity)) {
197
- return `angle '${r.angle}' has a finding with invalid severity (expected must-fix|worth-fixing-now|defer)`;
716
+ if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(normalizeSeverity(finding.severity))) {
717
+ return `angle '${r.angle}' has a finding with invalid severity (expected ${SEVERITY_ORDER.join("|")})`;
198
718
  }
199
719
  if (typeof finding.summary !== "string" || finding.summary.trim().length === 0) {
200
720
  return `angle '${r.angle}' has a finding without a summary`;
@@ -224,7 +744,7 @@ function validateAngleResult(result) {
224
744
  *
225
745
  * @param {object} input
226
746
  * @param {Array<unknown>} input.angleResults — per-angle review artifacts
227
- * @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["must-fix"])
747
+ * @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["high"])
228
748
  * @returns {{
229
749
  * verdict: "clean"|"findings_present"|"blocked",
230
750
  * findings: Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>,
@@ -234,10 +754,14 @@ function validateAngleResult(result) {
234
754
  */
235
755
  export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities } = {}) {
236
756
  const results = Array.isArray(angleResults) ? angleResults : [];
757
+ // Config values normalize through the same alias map as finding severities,
758
+ // so a legacy config spelling ("must-fix", "defer", …) still blocks the
759
+ // renamed tier.
237
760
  const blocking = new Set(
238
- Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
761
+ (Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
239
762
  ? blockCleanOnFindingSeverities
240
- : ["must-fix"],
763
+ : ["high"]
764
+ ).map((s) => normalizeSeverity(s)),
241
765
  );
242
766
 
243
767
  const malformed = [];
@@ -246,7 +770,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
246
770
  if (err) malformed.push({ index, reason: err });
247
771
  });
248
772
 
249
- const bySeverity = { "must-fix": 0, "worth-fixing-now": 0, "defer": 0 };
773
+ const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
250
774
  /** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
251
775
  const findings = [];
252
776
  let blockingCount = 0;
@@ -255,16 +779,17 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
255
779
  for (const r of results) {
256
780
  const angle = r.angle.trim();
257
781
  for (const f of r.findings) {
258
- const isBlocking = blocking.has(f.severity);
782
+ const severity = /** @type {string} */ (normalizeSeverity(f.severity));
783
+ const isBlocking = blocking.has(severity);
259
784
  if (isBlocking) blockingCount += 1;
260
- bySeverity[f.severity] += 1;
785
+ bySeverity[severity] += 1;
261
786
  const entry = {
262
- severity: f.severity,
787
+ severity,
263
788
  angle,
264
789
  summary: String(f.summary).trim(),
265
- // Blocking findings default to accepted-for-fix; non-blocking default
266
- // to deferred. The fix cycle / operator can override the disposition.
267
- disposition: isBlocking ? "accepted-for-fix" : "deferred",
790
+ // See deriveDisposition's own doc for the full rule; the fix cycle
791
+ // / operator can override the disposition afterward.
792
+ disposition: deriveDisposition(severity, { isBlocking, locatable: hasLocatableShape(f) }),
268
793
  };
269
794
  if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
270
795
  if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
@@ -298,13 +823,155 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
298
823
  };
299
824
  }
300
825
 
826
+ /**
827
+ * The judge's relevance-based disposition vocabulary — distinct from the
828
+ * severity-based `disposition` (accepted-for-fix/deferred/needs-answer) that
829
+ * `deriveDisposition` owns. The judge decides *where* a finding is acted on
830
+ * (this PR or a follow-up), never *whether* it is real: a `reject` is a
831
+ * relevance verdict (out-of-scope against a named non-goal or scope
832
+ * boundary), not a reproduction verdict. The fixer retains reproduction-based
833
+ * rejection; the judge owns relevance (#1525).
834
+ */
835
+ export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
836
+
837
+ /**
838
+ * Validate a judge verdict artifact shape (the dedicated `judge` agent's only
839
+ * write). Pure; throws on a malformed verdict rather than silently enriching
840
+ * findings with garbage. The judge is the designated memory across rounds, so
841
+ * its artifact is the authoritative relevance record — a malformed one fails
842
+ * closed rather than degrading to severity-only disposition.
843
+ *
844
+ * Shape:
845
+ * ```
846
+ * {
847
+ * headSha: "<sha>",
848
+ * scopeDrift: { verdict: "within_scope"|"drift_detected", rationale: "...", driftedAreas: ["..."] },
849
+ * dispositions: [{ index, disposition: "act"|"defer"|"reject", rationale, criterion?, followUpDraft? }]
850
+ * }
851
+ * ```
852
+ *
853
+ * @param {unknown} verdict
854
+ * @returns {{ headSha: string, scopeDrift: object, dispositions: Array<object> }}
855
+ */
856
+ export function validateJudgeVerdict(verdict) {
857
+ if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
858
+ throw new Error("judge verdict must be a JSON object");
859
+ }
860
+ const v = /** @type {Record<string, unknown>} */ (verdict);
861
+ if (typeof v.headSha !== "string" || v.headSha.trim().length === 0) {
862
+ throw new Error("judge verdict.headSha must be a non-empty string");
863
+ }
864
+ if (!v.scopeDrift || typeof v.scopeDrift !== "object" || Array.isArray(v.scopeDrift)) {
865
+ throw new Error("judge verdict.scopeDrift must be an object");
866
+ }
867
+ const sd = /** @type {Record<string, unknown>} */ (v.scopeDrift);
868
+ if (sd.verdict !== "within_scope" && sd.verdict !== "drift_detected") {
869
+ throw new Error("judge verdict.scopeDrift.verdict must be 'within_scope' or 'drift_detected'");
870
+ }
871
+ if (typeof sd.rationale !== "string" || sd.rationale.trim().length === 0) {
872
+ throw new Error("judge verdict.scopeDrift.rationale must be a non-empty string");
873
+ }
874
+ if (!Array.isArray(sd.driftedAreas)) {
875
+ throw new Error("judge verdict.scopeDrift.driftedAreas must be an array");
876
+ }
877
+ for (const [di, area] of sd.driftedAreas.entries()) {
878
+ if (typeof area !== "string" || area.trim().length === 0) {
879
+ throw new Error(`judge verdict.scopeDrift.driftedAreas[${di}] must be a non-empty string`);
880
+ }
881
+ }
882
+ if (!Array.isArray(v.dispositions)) {
883
+ throw new Error("judge verdict.dispositions must be an array");
884
+ }
885
+ const seenIndices = new Set();
886
+ for (const [i, d] of v.dispositions.entries()) {
887
+ if (!d || typeof d !== "object" || Array.isArray(d)) {
888
+ throw new Error(`judge verdict.dispositions[${i}] must be an object`);
889
+ }
890
+ const entry = /** @type {Record<string, unknown>} */ (d);
891
+ if (!Number.isInteger(entry.index) || entry.index < 0) {
892
+ throw new Error(`judge verdict.dispositions[${i}].index must be a non-negative integer`);
893
+ }
894
+ if (seenIndices.has(entry.index)) {
895
+ throw new Error(`judge verdict.dispositions[${i}].index ${entry.index} is a duplicate — the contract is one disposition per finding`);
896
+ }
897
+ seenIndices.add(entry.index);
898
+ if (!JUDGE_DISPOSITIONS.includes(entry.disposition)) {
899
+ throw new Error(`judge verdict.dispositions[${i}].disposition must be one of: ${JUDGE_DISPOSITIONS.join(", ")}`);
900
+ }
901
+ if (typeof entry.rationale !== "string" || entry.rationale.trim().length === 0) {
902
+ throw new Error(`judge verdict.dispositions[${i}].rationale must be a non-empty string naming the criterion, non-goal, or scope boundary`);
903
+ }
904
+ // followUpDraft is REQUIRED on a defer disposition (soft-cap contract: a
905
+ // deferred finding carries a fileable follow-up draft). Optional otherwise.
906
+ if (entry.disposition === "defer") {
907
+ if (!entry.followUpDraft || typeof entry.followUpDraft !== "object" || Array.isArray(entry.followUpDraft)) {
908
+ throw new Error(`judge verdict.dispositions[${i}].followUpDraft is required on a defer disposition`);
909
+ }
910
+ const draft = /** @type {Record<string, unknown>} */ (entry.followUpDraft);
911
+ if (typeof draft.title !== "string" || draft.title.trim().length === 0 || typeof draft.body !== "string") {
912
+ throw new Error(`judge verdict.dispositions[${i}].followUpDraft must have a non-empty title and a body string`);
913
+ }
914
+ }
915
+ }
916
+ return { headSha: v.headSha, scopeDrift: v.scopeDrift, dispositions: v.dispositions };
917
+ }
918
+
919
+ /**
920
+ * Merge the judge's relevance-based dispositions into the consolidated findings
921
+ * array (the flat per-finding shape `consolidateFanin` / `toFindingsLogShape`
922
+ * produce). The judge runs AFTER fan-in and BEFORE the fix pass (#1525): it
923
+ * receives the consolidated ledger, the issue's AC/DoD/non-goals, the PR's
924
+ * declared scope, and prior-round ledgers, and emits a per-finding disposition
925
+ * (`act` / `defer` / `reject`) plus a scope-drift verdict on the PR as a whole.
926
+ *
927
+ * This function enriches each finding with `judgeDisposition`, `judgeRationale`,
928
+ * and (for `defer`) `followUpDraft` so the disposition ledger and posted findings
929
+ * comment carry what was consciously not acted on and why. The severity-based
930
+ * `disposition` (accepted-for-fix/deferred/needs-answer) is LEFT INTACT — the
931
+ * judge's relevance axis is complementary, not a replacement (a real defect
932
+ * stays a real defect; the judge decides *where* it is fixed, not *whether* it
933
+ * is real).
934
+ *
935
+ * The fix pass consumes only the `act` list; the fixer retains reproduction-
936
+ * based rejection (a finding that does not reproduce is dead regardless of the
937
+ * judge's verdict) but stops deciding relevance.
938
+ *
939
+ * Pure. Fails closed (throws) when a disposition references an out-of-range
940
+ * index — a judge verdict that names a finding that is not in the ledger is a
941
+ * mismatch, never a silent enrichment.
942
+ *
943
+ * @param {Array<object>} findings — the flat consolidated findings array
944
+ * @param {object} judgeVerdict — the validated judge verdict artifact
945
+ * @returns {{ findings: Array<object>, scopeDrift: object }}
946
+ */
947
+ export function applyJudgeDispositions(findings, judgeVerdict) {
948
+ const validated = validateJudgeVerdict(judgeVerdict);
949
+ const list = Array.isArray(findings) ? findings : [];
950
+ const enriched = list.map((f) => ({ ...f }));
951
+ for (const d of validated.dispositions) {
952
+ if (d.index >= enriched.length) {
953
+ throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`);
954
+ }
955
+ const target = enriched[d.index];
956
+ target.judgeDisposition = d.disposition;
957
+ target.judgeRationale = d.rationale;
958
+ if (typeof d.criterion === "string" && d.criterion.trim().length > 0) {
959
+ target.judgeCriterion = d.criterion.trim();
960
+ }
961
+ if (d.disposition === "defer" && d.followUpDraft) {
962
+ target.followUpDraft = d.followUpDraft;
963
+ }
964
+ }
965
+ return { findings: enriched, scopeDrift: validated.scopeDrift };
966
+ }
967
+
301
968
  /**
302
969
  * Map consolidated findings into the `--findings` JSON shape consumed by
303
970
  * scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
304
- * disposition, optional files). Pure.
971
+ * disposition, optional files, optional line). Pure.
305
972
  *
306
- * @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string}>} findings
307
- * @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[]}>}
973
+ * @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string, recommendation?: string, line?: number}>} findings
974
+ * @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[], recommendation?: string, line?: number}>}
308
975
  */
309
976
  export function toFindingsLogShape(findings) {
310
977
  const list = Array.isArray(findings) ? findings : [];
@@ -317,12 +984,33 @@ export function toFindingsLogShape(findings) {
317
984
  if (typeof f.disposition === "string" && f.disposition.trim().length > 0) {
318
985
  entry.disposition = f.disposition.trim();
319
986
  }
987
+ if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
988
+ entry.recommendation = f.recommendation.trim();
989
+ }
320
990
  if (typeof f.file === "string" && f.file.trim().length > 0) {
321
991
  entry.files = [f.file.trim()];
322
992
  } else if (Array.isArray(f.files)) {
323
993
  const files = f.files.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
324
994
  if (files.length > 0) entry.files = files;
325
995
  }
996
+ if (Number.isInteger(f.line) && f.line > 0) {
997
+ entry.line = f.line;
998
+ }
999
+ // Carry the judge's relevance-based dispositions through (#1525) so the
1000
+ // durable ledger and posted findings comment show what was consciously not
1001
+ // acted on and why.
1002
+ if (typeof f.judgeDisposition === "string" && f.judgeDisposition.trim().length > 0) {
1003
+ entry.judgeDisposition = f.judgeDisposition.trim();
1004
+ }
1005
+ if (typeof f.judgeRationale === "string" && f.judgeRationale.trim().length > 0) {
1006
+ entry.judgeRationale = f.judgeRationale.trim();
1007
+ }
1008
+ if (typeof f.judgeCriterion === "string" && f.judgeCriterion.trim().length > 0) {
1009
+ entry.judgeCriterion = f.judgeCriterion.trim();
1010
+ }
1011
+ if (f.followUpDraft && typeof f.followUpDraft === "object" && !Array.isArray(f.followUpDraft)) {
1012
+ entry.followUpDraft = f.followUpDraft;
1013
+ }
326
1014
  return entry;
327
1015
  });
328
1016
  }
@@ -330,6 +1018,12 @@ export function toFindingsLogShape(findings) {
330
1018
  /**
331
1019
  * Plan how a resolved angle set fans out across the reviewer cap. Pure.
332
1020
  *
1021
+ * SUPERSEDED by `scheduleFanoutWaves` (#1601, ADR 0048): the gate fan-out
1022
+ * conductor now dispatches wave-by-wave at most `gates.fanout.maxConcurrent`
1023
+ * (M) dispatch units per wave, using the wave plan emitted by
1024
+ * `write-gate-context.mjs`. This helper is kept only for back-compat (zero
1025
+ * non-test callers) and no longer participates in the dispatch path.
1026
+ *
333
1027
  * When `angles.length <= maxReviewers`, all reviewers run in a single parallel
334
1028
  * batch (no degradation). When it exceeds the cap, the overflow is split into
335
1029
  * sequential batches of at most `maxReviewers` each, and `degraded` is true so