@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4

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.
Files changed (33) hide show
  1. package/package.json +7 -1
  2. package/src/analysis/diff-analyzer.mjs +31 -5
  3. package/src/claude/hook-decisions.mjs +14 -0
  4. package/src/cli/primitives.mjs +10 -2
  5. package/src/cli/retry-wrapper.mjs +14 -6
  6. package/src/config/config.mjs +1125 -240
  7. package/src/config/extension-defaults.yaml +217 -426
  8. package/src/github/copilot-helpers.mjs +139 -18
  9. package/src/github/issue-ops.mjs +556 -0
  10. package/src/github/ownership-helpers.mjs +79 -0
  11. package/src/github/review-threads.mjs +44 -3
  12. package/src/loop/bash-command-classify.mjs +35 -5
  13. package/src/loop/conductor-routing.mjs +1 -1
  14. package/src/loop/copilot-ci-status.mjs +76 -0
  15. package/src/loop/copilot-loop-iterations.mjs +1 -2
  16. package/src/loop/copilot-loop-state.mjs +9 -5
  17. package/src/loop/default-branch-guard.mjs +380 -0
  18. package/src/loop/gate-carry-forward.mjs +29 -2
  19. package/src/loop/gate-fanin.mjs +481 -31
  20. package/src/loop/handoff-envelope.mjs +43 -23
  21. package/src/loop/main-checkout-ff.mjs +58 -0
  22. package/src/loop/pr-gate-coordination.mjs +204 -47
  23. package/src/loop/pr-title-markers.mjs +76 -15
  24. package/src/loop/queue-board-sync.mjs +26 -9
  25. package/src/loop/reviewer-loop-state.mjs +2 -2
  26. package/src/loop/ui-e2e-scoping.mjs +2 -0
  27. package/src/loop/ui-review-drive.mjs +23 -0
  28. package/src/loop/ui-review-provision.mjs +36 -0
  29. package/src/projects/resolve-project.mjs +14 -7
  30. package/src/tracker/adapter.mjs +127 -0
  31. package/src/tracker/github-adapter.mjs +150 -0
  32. package/src/tracker/index.mjs +50 -0
  33. package/src/tracker/noop-adapter.mjs +35 -0
@@ -14,14 +14,231 @@
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
+ // Exported so other tools (e.g. scripts/loop/consolidate-fanin.mjs,
81
+ // scripts/github/upsert-checkpoint-verdict.mjs) sort/rank/validate against
82
+ // this single ordered copy of the severity vocabulary instead of each
83
+ // hand-copying its own list (and its own load-time drift guard) — ORDER is
84
+ // part of the contract here, not just membership, so a consumer that only
85
+ // checked membership against a Set could accept a silently reordered copy.
86
+ // Ranked by gate-close urgency, not just defect-severity: "question" sits
87
+ // right after "high" because BOTH force gate-close to stay blocked (a high
88
+ // finding via the fix loop, a question via never being auto-deferred) — it
89
+ // outranks "medium"/"low", which both eventually defer. "nit" trails last:
90
+ // it defers immediately, with no fixer cycle at all.
91
+ export const SEVERITY_ORDER = ["high", "question", "medium", "low", "nit"];
92
+
93
+ // Marker gate name → gates.<key> config key. Owned here so every caller of
94
+ // resolveFanoutGroups maps the same way; passing the marker name verbatim
95
+ // resolves no groups and silently downgrades pairing enforcement.
96
+ export const GATE_CONFIG_KEY = Object.freeze({ draft_gate: "draft", pre_approval_gate: "preApproval" });
97
+ export const VALID_SEVERITIES = new Set(SEVERITY_ORDER);
98
+
99
+ // Pre-rename spellings. Old ledgers, markers, and configs still carry them;
100
+ // every read boundary normalizes through this map. Every SANCTIONED producer
101
+ // (consolidateFanin, write-gate-findings-log.mjs, post-gate-findings.mjs)
102
+ // normalizes before a severity reaches a marker/ledger, so a freshly posted
103
+ // marker carries only a canonical spelling in practice — but this map is a
104
+ // read-side normalizer, not a write-side enforcement boundary:
105
+ // buildFindingMarker (_gate-finding-surface.mjs) is a thin text builder that
106
+ // emits whatever severity string it is given, verbatim (a legacy-spelled
107
+ // marker built directly, e.g. for round-trip test fixtures, still parses
108
+ // correctly via normalizeSeverity on read).
109
+ export const LEGACY_SEVERITY_ALIASES = Object.freeze({
110
+ "must-fix": "high",
111
+ "worth-fixing-now": "medium",
112
+ "nice-to-have": "low",
113
+ defer: "low",
114
+ });
115
+
116
+ /**
117
+ * Map a legacy severity spelling to its canonical name; unknown values pass
118
+ * through trimmed (the caller's validation still rejects them) — a
119
+ * non-string passes through unchanged. Trimming BEFORE the alias lookup
120
+ * (rather than requiring every caller to do it first) is what keeps every
121
+ * call site of this function agreeing on the same value for the same
122
+ * incidentally-whitespace-varied input: consolidate-fanin.mjs's own floor
123
+ * validation trims before calling this, while gate-fanin's `consolidateFanin`
124
+ * does not — two call sites trimming inconsistently is exactly how an
125
+ * untrimmed "high " passed one gate's validation and then failed the
126
+ * other's. Deliberately case-SENSITIVE (no lowercasing): every sanctioned
127
+ * writer (slugForMarker, config authoring, this module's own producers)
128
+ * already emits lowercase, so a forged/hand-edited mixed-case value (e.g.
129
+ * "NIT") must fail VALID_SEVERITIES validation and dangle fail-closed rather
130
+ * than being silently coerced into a real severity that then auto-defers.
131
+ * @param {unknown} severity
132
+ * @returns {unknown}
133
+ */
134
+ export function normalizeSeverity(severity) {
135
+ if (typeof severity !== "string") return severity;
136
+ const normalized = severity.trim();
137
+ return Object.hasOwn(LEGACY_SEVERITY_ALIASES, normalized) ? LEGACY_SEVERITY_ALIASES[normalized] : normalized;
138
+ }
139
+
140
+ /**
141
+ * Map a (possibly legacy-spelled/untrimmed) severity to its SEVERITY_ORDER
142
+ * index — the ONE rank rule every sort/ordering consumer
143
+ * (consolidate-fanin.mjs's `angleWorstSeverityRank`,
144
+ * upsert-checkpoint-verdict.mjs's severity-grouped rendering) shares, so the
145
+ * two can never drift on how an unknown severity ranks. An unrecognized
146
+ * severity (after normalization) ranks LAST (`SEVERITY_ORDER.length`, never
147
+ * -1) so it always sorts after every known severity instead of floating
148
+ * above "high" the way a raw, unmapped `indexOf` would.
149
+ * @param {unknown} severity
150
+ * @returns {number}
151
+ */
152
+ export function severityRank(severity) {
153
+ const idx = SEVERITY_ORDER.indexOf(/** @type {string} */ (normalizeSeverity(severity)));
154
+ return idx === -1 ? SEVERITY_ORDER.length : idx;
155
+ }
156
+
157
+ /**
158
+ * Merge a severity→count map's legacy-spelled keys into their canonical keys
159
+ * (summing counts) so both the CLI parser and direct programmatic callers of
160
+ * the verdict poster share ONE merge rule. Values pass through unvalidated —
161
+ * the caller keeps its own integer/shape checks.
162
+ * @param {Record<string, number>} counts
163
+ * @returns {Record<string, number>} null-prototype object with canonical keys
164
+ */
165
+ export function normalizeSeverityCounts(counts) {
166
+ const normalized = Object.create(null);
167
+ for (const [key, value] of Object.entries(counts)) {
168
+ const canonicalKey = /** @type {string} */ (normalizeSeverity(key));
169
+ normalized[canonicalKey] = (normalized[canonicalKey] ?? 0) + value;
170
+ }
171
+ return normalized;
172
+ }
173
+
174
+ /**
175
+ * A finding is LOCATABLE-SHAPED when it names a real file (via `file` or
176
+ * `files[0]`) and a positive-integer `line` — the ONE shared shape check
177
+ * every producer/consumer of the locatable/non-locatable distinction keys
178
+ * on, whether the finding is the raw per-angle `{file, line}` shape
179
+ * (consolidateFanin's own input) or the ledger's `{files, line}` shape
180
+ * (write-gate-findings-log.mjs / post-gate-findings.mjs). This is NECESSARY
181
+ * but not SUFFICIENT for a thread-locatable finding: `isLocatableFinding`
182
+ * (scripts/github/_gate-finding-surface.mjs) additionally requires the
183
+ * file:line to fall inside the reviewed diff, which only that caller
184
+ * (holding the diff's commentable-line set) can check — this function is
185
+ * its shared shape floor, not a replacement for it.
186
+ * @param {{ file?: unknown, files?: unknown, line?: unknown }} finding
187
+ * @returns {boolean}
188
+ */
189
+ export function hasLocatableShape(finding) {
190
+ const file = typeof finding?.file === "string"
191
+ ? finding.file
192
+ : (Array.isArray(finding?.files) ? finding.files[0] : undefined);
193
+ return typeof file === "string" && file.trim().length > 0
194
+ && Number.isInteger(finding?.line) && /** @type {number} */ (finding.line) >= 1;
195
+ }
196
+
197
+ /**
198
+ * Derive the ledger disposition for a finding at `severity` — the ONE rule
199
+ * every producer (consolidateFanin, write-gate-findings-log.mjs,
200
+ * post-gate-findings.mjs) shares, so the three can never drift on what a
201
+ * severity/locatability combination resolves to. A LOCATABLE `question` is
202
+ * answered, never fixed or deferred — it gets its own disposition
203
+ * ("needs-answer") regardless of `isBlocking` (a question can never be
204
+ * blocking in practice — blockCleanOnFindingSeverities is restricted to
205
+ * defect severities — but this stays severity-first rather than
206
+ * isBlocking-first so that invariant is enforced here too, not just at the
207
+ * config boundary). A NON-LOCATABLE question has no resolvable thread to
208
+ * answer through — it is body-filed and deferred by construction, exactly
209
+ * like every other non-`high` body-filed finding
210
+ * (GATE-EXEC-DEFERRAL-RECORD). Every other severity ignores `locatable`
211
+ * entirely: `isBlocking` alone decides accepted-for-fix vs deferred.
212
+ * @param {string} severity — already normalized
213
+ * @param {{ isBlocking?: boolean, locatable?: boolean }} [options]
214
+ * @returns {"accepted-for-fix"|"deferred"|"needs-answer"}
215
+ */
216
+ export function deriveDisposition(severity, { isBlocking = false, locatable = false } = {}) {
217
+ if (severity === "question") return locatable ? "needs-answer" : "deferred";
218
+ return isBlocking ? "accepted-for-fix" : "deferred";
219
+ }
220
+
221
+ /**
222
+ * Does `severity` (already normalized) have a default disposition that
223
+ * `deriveDisposition` can resolve WITHOUT `isBlocking` context? "low" and
224
+ * "nit" always defer regardless of any gate's `blockCleanOnFindingSeverities`
225
+ * config, and "question" resolves off `locatable` alone — so a caller with no
226
+ * `isBlocking` context (write-gate-findings-log.mjs / post-gate-findings.mjs's
227
+ * CLI validators, which accept a bare `--findings` array with no config in
228
+ * scope) can still fill in a default disposition for these three, and only
229
+ * these three, when the caller left it unset. "high" and "medium" are
230
+ * excluded: whether either blocks a clean verdict depends on config, which
231
+ * only a caller holding `blockCleanOnFindingSeverities` can know — guessing
232
+ * "deferred" for one of those here would be wrong for a repo that configures
233
+ * it as blocking. Shared by both CLI validators (see `deriveDisposition`) so
234
+ * the two can never restate this guard out of sync.
235
+ * @param {string} severity — already normalized
236
+ * @returns {boolean}
237
+ */
238
+ export function isDefaultDeferrableSeverity(severity) {
239
+ return severity === "low" || severity === "nit" || severity === "question";
240
+ }
241
+
25
242
  const VALID_VERDICTS = new Set(["clean", "findings_present"]);
26
243
 
27
244
  /**
@@ -30,7 +247,7 @@ const VALID_VERDICTS = new Set(["clean", "findings_present"]);
30
247
  * depth). The flow MUST fail closed with this message and route the gate review
31
248
  * to the conductor rather than silently degrading to a single-agent inline
32
249
  * review (which requireFanoutProvenance is designed to reject). Documented as a
33
- * contract in docs/gate-review-sub-loop-contract.md.
250
+ * contract in skills/docs/gate-review-sub-loop-contract.md.
34
251
  */
35
252
  export const FANOUT_UNAVAILABLE_MESSAGE = "fan-out unavailable — route to conductor";
36
253
 
@@ -63,23 +280,39 @@ export function countDistinctReviewers(perAngle) {
63
280
  const ids = new Set();
64
281
  for (const e of perAngle) {
65
282
  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);
283
+ const identity = reviewerIdentity(e);
284
+ if (identity) ids.add(identity.id);
72
285
  }
73
286
  return ids.size;
74
287
  }
75
288
 
289
+ /**
290
+ * The single identity-selection rule for a perAngle entry: a non-empty
291
+ * `reviewer` wins, else a non-empty `dispatchId`, else no identity. Returns
292
+ * `{ id, label }` (label = which field carried the identity, for error
293
+ * messages) or null. Shared by countDistinctReviewers and
294
+ * fanoutReviewerPairingError so the two can never diverge.
295
+ *
296
+ * @param {object} entry — a perAngle entry
297
+ * @returns {{ id: string, label: "reviewer"|"dispatchId" }|null}
298
+ */
299
+ function reviewerIdentity(entry) {
300
+ if (typeof entry.reviewer === "string" && entry.reviewer.trim().length > 0) {
301
+ return { id: entry.reviewer.trim(), label: "reviewer" };
302
+ }
303
+ if (typeof entry.dispatchId === "string" && entry.dispatchId.trim().length > 0) {
304
+ return { id: entry.dispatchId.trim(), label: "dispatchId" };
305
+ }
306
+ return null;
307
+ }
308
+
76
309
  /**
77
310
  * Validate INTERNAL CONSISTENCY of a fan-out provenance object. Returns an error
78
311
  * string when the provenance is malformed or self-inconsistent, or null when it
79
312
  * is well-formed and consistent. Shared by the write path (write-gate-findings-log)
80
313
  * and the enforcement read path (buildPreMergeGateCheck) so both agree.
81
314
  *
82
- * Consistency rule (documented in docs/gate-review-sub-loop-contract.md):
315
+ * Consistency rule (documented in skills/docs/gate-review-sub-loop-contract.md):
83
316
  * - `distinctReviewers` must be a non-negative integer.
84
317
  * - `perAngle` must be an array, and non-empty when `distinctReviewers > 0`.
85
318
  * - `distinctReviewers` must be <= the count of DISTINCT reviewer identities
@@ -116,6 +349,180 @@ export function provenanceConsistencyError(prov) {
116
349
  return null;
117
350
  }
118
351
 
352
+ /**
353
+ * Yield `{ entry, angle, group }` for each "fresh" entry in a `perAngle`
354
+ * array — a valid object entry naming a non-blank `angle` and carrying no
355
+ * `carriedFromHead` (a carried angle's clean verdict was reused from a prior
356
+ * head's review, see @dev-loops/core/loop/gate-carry-forward, not freshly
357
+ * reviewed here). `group` is the entry's normalized, non-blank `group`
358
+ * string, or `null`. This is the ONE definition of "fresh" and "declared
359
+ * group" — {@link freshAngleNames}, {@link countFreshDispatchUnits}, and
360
+ * {@link fanoutReviewerPairingError} all derive from it so the write-time
361
+ * floor and the pairing check can never silently drift apart on what either
362
+ * term means. Pure.
363
+ * @param {unknown} perAngle
364
+ * @returns {Generator<{ entry: object, angle: string, group: string|null }>}
365
+ */
366
+ function* freshEntries(perAngle) {
367
+ if (!Array.isArray(perAngle)) return;
368
+ for (const entry of perAngle) {
369
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
370
+ if (typeof entry.carriedFromHead === "string" && entry.carriedFromHead.trim().length > 0) continue;
371
+ const angle = typeof entry.angle === "string" ? entry.angle.trim() : "";
372
+ if (!angle) continue;
373
+ const group = typeof entry.group === "string" && entry.group.trim().length > 0 ? entry.group.trim() : null;
374
+ yield { entry, angle, group };
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Names of DISTINCT "fresh" angles in a `perAngle` array — see
380
+ * {@link freshEntries}. Used by callers that need the names themselves (e.g.
381
+ * resolving this round's dispatch groups via `resolveFanoutGroups` for
382
+ * {@link fanoutReviewerPairingError}'s cross-check). Pure.
383
+ *
384
+ * @param {unknown} perAngle
385
+ * @returns {string[]}
386
+ */
387
+ export function freshAngleNames(perAngle) {
388
+ const angles = new Set();
389
+ for (const { angle } of freshEntries(perAngle)) angles.add(angle);
390
+ return [...angles];
391
+ }
392
+
393
+ /**
394
+ * Count distinct FRESH dispatch units in a `perAngle` array: a fresh angle
395
+ * that declares a `group` counts once per DISTINCT group name (its whole
396
+ * group is one reviewer's dispatch), and a fresh angle with no `group`
397
+ * counts as its own dispatch unit (today's one-reviewer-per-angle shape).
398
+ * This is the grouping-aware generalization of counting distinct fresh
399
+ * angle names via {@link freshAngleNames} — for an ungrouped ledger the two
400
+ * are identical; for a grouped ledger this is <= the ungrouped count, since
401
+ * one group of N angles is one dispatch unit, not N. Shared by the write
402
+ * path (write-gate-findings-log.mjs) and the
403
+ * requireFanoutProvenance read path (detect-checkpoint-evidence.mjs) so the
404
+ * `distinctReviewers` floor scales with what was actually DISPATCHED, not
405
+ * with the angle count a grouped round deliberately dispatches fewer
406
+ * reviewers than. Pure.
407
+ *
408
+ * @param {unknown} perAngle
409
+ * @returns {number}
410
+ */
411
+ export function countFreshDispatchUnits(perAngle) {
412
+ const groups = new Set();
413
+ const ungroupedAngles = new Set();
414
+ for (const { angle, group } of freshEntries(perAngle)) {
415
+ if (group) groups.add(group);
416
+ else ungroupedAngles.add(angle);
417
+ }
418
+ return groups.size + ungroupedAngles.size;
419
+ }
420
+
421
+ /**
422
+ * Validate the one-scoped-reviewer-per-fresh-angle contract (fanout_fanin
423
+ * execution mandates one independent reviewer per resolved angle; #1431): no
424
+ * two FRESH angles (angles without `carriedFromHead` — see
425
+ * {@link freshEntries}) may share one reviewer identity (`reviewer`,
426
+ * else `dispatchId` — matching {@link countDistinctReviewers}'s identity
427
+ * rule), UNLESS every entry sharing that identity declares the SAME `group`
428
+ * name (grouped fan-out dispatch, AC6/AC7 — see resolveFanoutGroups). The
429
+ * recorded `group` is self-attested at write time; when `resolvedGroups` is
430
+ * supplied (both call sites always supply it) it is also checked against
431
+ * the CURRENT `gates.fanout.groups` table, so an edit to that table between
432
+ * the round and a later read (e.g. a merge-evidence check) can invalidate a
433
+ * ledger's group claim that was honest when written — see the
434
+ * `resolvedGroups` paragraph below. Two
435
+ * fresh angles sharing a reviewer with differing or missing `group` values
436
+ * still violate the contract. Carried angles keep their prior reviewer and
437
+ * are exempt. Pure; shared by the write path (write-gate-findings-log.mjs,
438
+ * always-on) and the merge-evidence read path (detect-checkpoint-evidence.mjs,
439
+ * scaling the `requireFanoutProvenance` floor) so both agree.
440
+ *
441
+ * Returns an actionable error string naming the offending angle(s) when the
442
+ * contract is violated (an ungrouped reviewer covering >1 fresh angle, angles
443
+ * sharing a reviewer under inconsistent `group` values, or a fresh angle
444
+ * recording no reviewer identity at all — which also silently lowers the
445
+ * distinct-reviewer count below the fresh-angle count), or `null` when it
446
+ * holds (including when `perAngle` has no fresh angles).
447
+ *
448
+ * The recorded `group` is self-attested (any non-empty string the writer
449
+ * chooses), so the grouped exception above is only as strong as the caller
450
+ * lets it be. An optional `resolvedGroups` (the round's `resolveFanoutGroups`
451
+ * output, `{ name, angles }[]`) closes that: a shared identity is only
452
+ * honored when every fresh angle it covers is a member of the SAME
453
+ * configured dispatch unit — a fabricated `group` label spanning angles the
454
+ * table splits apart (or never groups at all) no longer passes.
455
+ * `resolveFanoutGroups` itself emits one-angle-per-unit singletons for
456
+ * `gates.fanout.mode: per-angle` (bypasses configured groups), so passing its
457
+ * output here rejects ANY shared identity in that mode — no separate mode flag
458
+ * needed. As of #1601 (ADR 0048) `gate:full` dispatches GROUPED (fullLabel is a
459
+ * no-op for dispatch shape), so a shared identity within an auto-chunked
460
+ * dispatch unit is honored exactly as for a configured group.
461
+ * Omitting `resolvedGroups` entirely keeps today's fully permissive behavior (any one
462
+ * shared non-null `group` value is accepted, unchecked against config) — both
463
+ * call sites already load config, so they should always supply it; this
464
+ * default only preserves callers (and old ledgers) that don't.
465
+ *
466
+ * @param {unknown} perAngle
467
+ * @param {{name: string, angles: string[]}[]|null} [resolvedGroups]
468
+ * @returns {string|null}
469
+ */
470
+ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
471
+ if (!Array.isArray(perAngle)) return null;
472
+ const configuredGroupOf = new Map();
473
+ for (const g of Array.isArray(resolvedGroups) ? resolvedGroups : []) {
474
+ for (const a of Array.isArray(g?.angles) ? g.angles : []) configuredGroupOf.set(a, g.name);
475
+ }
476
+ const freshAngles = new Set();
477
+ const anglesByIdentity = new Map();
478
+ const anonymousAngles = [];
479
+ for (const { entry, angle, group } of freshEntries(perAngle)) {
480
+ freshAngles.add(angle);
481
+ const identity = reviewerIdentity(entry);
482
+ if (identity) {
483
+ if (!anglesByIdentity.has(identity.id)) anglesByIdentity.set(identity.id, { angles: new Set(), label: identity.label, groups: new Set() });
484
+ const record = anglesByIdentity.get(identity.id);
485
+ record.angles.add(angle);
486
+ record.groups.add(group);
487
+ } else {
488
+ anonymousAngles.push(angle);
489
+ }
490
+ }
491
+ const freshAngleCount = freshAngles.size;
492
+ const distinctFreshReviewers = anglesByIdentity.size;
493
+ // Enforce the relation itself, not its cardinality shadow: a padded ledger
494
+ // (duplicate-angle entries) can satisfy distinctReviewers >= freshAngleCount
495
+ // while one identity still covers two fresh angles.
496
+ const details = [];
497
+ for (const [id, { angles, label, groups }] of anglesByIdentity) {
498
+ if (angles.size <= 1) continue;
499
+ // One shared, non-null `group` across every entry for this identity is
500
+ // the grouped-dispatch exception: a single reviewer legitimately covers
501
+ // its whole declared group. Differing or missing `group` values fall
502
+ // back to the one-reviewer-per-angle rule.
503
+ const sameGroup = groups.size === 1 && [...groups][0] !== null;
504
+ if (!sameGroup) {
505
+ details.push(`${label} "${id}" is recorded for fresh angles: ${[...angles].join(", ")}`);
506
+ continue;
507
+ }
508
+ // resolvedGroups supplied: the claimed group is only honest when every
509
+ // angle it covers is a member of the SAME configured group — a claimed
510
+ // group spanning angles the table splits apart (or never groups) fails
511
+ // closed even though the audit record itself is internally consistent.
512
+ if (configuredGroupOf.size > 0) {
513
+ const configuredGroups = new Set([...angles].map((a) => configuredGroupOf.get(a) ?? null));
514
+ if (configuredGroups.size !== 1 || configuredGroups.has(null)) {
515
+ 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`);
516
+ }
517
+ }
518
+ }
519
+ if (anonymousAngles.length > 0) {
520
+ details.push(`fresh angle(s) with no recorded reviewer identity: ${anonymousAngles.join(", ")}`);
521
+ }
522
+ if (details.length === 0) return null;
523
+ 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`;
524
+ }
525
+
119
526
  /**
120
527
  * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
121
528
  * e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
@@ -125,15 +532,16 @@ export function provenanceConsistencyError(prov) {
125
532
  * @param {string} angle
126
533
  * @returns {string}
127
534
  */
128
- function baseAngleName(angle) {
535
+ export function baseAngleName(angle) {
129
536
  return angle.replace(/-delta-at-.+$/, "");
130
537
  }
131
538
 
132
539
  /**
133
540
  * Validate a recorded fan-out angle list against a gate's configured angle
134
541
  * 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
542
+ * supplied — every recorded angle must be a member of it or of
543
+ * {@link FANIN_SYNTHETIC_ANGLES} (delta-suffixed angles count toward their
544
+ * {@link baseAngleName}). Pure; shared by the write
137
545
  * path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
138
546
  * `--findings-json` per-angle results) and the merge-evidence read path
139
547
  * (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
@@ -142,7 +550,7 @@ function baseAngleName(angle) {
142
550
  * @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
143
551
  * @param {object} [gateAngleContract]
144
552
  * @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
553
+ * @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
554
  * @returns {{ missingMandatory: string[], foreignAngles: string[] }}
147
555
  */
148
556
  export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
@@ -155,18 +563,40 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
155
563
  const missingMandatory = mandatoryAngles.filter((a) => !recordedBases.has(a));
156
564
  let foreignAngles = [];
157
565
  if (Array.isArray(pool) && pool.length > 0) {
158
- const poolSet = new Set(pool);
566
+ const poolSet = new Set([...pool, ...FANIN_SYNTHETIC_ANGLES]);
159
567
  foreignAngles = [...new Set(recorded.filter((a) => !poolSet.has(baseAngleName(a))))];
160
568
  }
161
569
  return { missingMandatory, foreignAngles };
162
570
  }
163
571
 
572
+ /**
573
+ * Angles the fan-in itself mandates and may synthesize (consolidate-fanin's
574
+ * `--pr-checklist-matrix clean` upsert) without them appearing in any gate's
575
+ * configured `angles` pool. Always legal in the foreign-angle check above —
576
+ * requiring every consumer repo to also list them per-gate would make the two
577
+ * tools contradict the shared contract they implement.
578
+ */
579
+ export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist-matrix"]);
580
+
164
581
  /**
165
582
  * Default cap on parallel fan-out reviewers when a caller does not supply one.
166
583
  * Mirrors the config default (gates.maxFanoutReviewers).
167
584
  */
168
585
  export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
169
586
 
587
+ // Every sanctioned angle name is a short, hand-authored slug (e.g.
588
+ // "contradiction-lens", "pr-checklist-matrix"); nothing legitimate ever
589
+ // approaches this length. Bounding it here, at the trust boundary this
590
+ // function already owns, fails a pathological artifact closed as malformed —
591
+ // the same place every other angle-result defect is caught — instead of
592
+ // leaving an unbounded reviewer-supplied string to reach the render path,
593
+ // where consolidate-fanin.mjs's per-angle budget marking cannot compress it.
594
+ // This is a malformed-artifact guard, not a comment-budget guarantee: several
595
+ // angles each right at this cap can still exceed the render budget on their
596
+ // headers alone and force the withheld tier — that outcome is the render
597
+ // budget's degradation ladder doing its job, not something this cap prevents.
598
+ const MAX_ANGLE_NAME_LENGTH = 200;
599
+
170
600
  /**
171
601
  * Validate a single per-angle review result. Returns an error string when the
172
602
  * result is malformed, or null when it is well-formed.
@@ -182,6 +612,9 @@ function validateAngleResult(result) {
182
612
  if (typeof r.angle !== "string" || r.angle.trim().length === 0) {
183
613
  return "angle result is missing a non-empty 'angle'";
184
614
  }
615
+ if (r.angle.trim().length > MAX_ANGLE_NAME_LENGTH) {
616
+ return `angle result's 'angle' exceeds ${MAX_ANGLE_NAME_LENGTH} chars`;
617
+ }
185
618
  if (typeof r.verdict !== "string" || !VALID_VERDICTS.has(r.verdict)) {
186
619
  return `angle '${r.angle}' has invalid verdict (expected clean|findings_present)`;
187
620
  }
@@ -193,8 +626,8 @@ function validateAngleResult(result) {
193
626
  return `angle '${r.angle}' has a non-object finding`;
194
627
  }
195
628
  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)`;
629
+ if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(normalizeSeverity(finding.severity))) {
630
+ return `angle '${r.angle}' has a finding with invalid severity (expected ${SEVERITY_ORDER.join("|")})`;
198
631
  }
199
632
  if (typeof finding.summary !== "string" || finding.summary.trim().length === 0) {
200
633
  return `angle '${r.angle}' has a finding without a summary`;
@@ -224,7 +657,7 @@ function validateAngleResult(result) {
224
657
  *
225
658
  * @param {object} input
226
659
  * @param {Array<unknown>} input.angleResults — per-angle review artifacts
227
- * @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["must-fix"])
660
+ * @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["high"])
228
661
  * @returns {{
229
662
  * verdict: "clean"|"findings_present"|"blocked",
230
663
  * findings: Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>,
@@ -234,10 +667,14 @@ function validateAngleResult(result) {
234
667
  */
235
668
  export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities } = {}) {
236
669
  const results = Array.isArray(angleResults) ? angleResults : [];
670
+ // Config values normalize through the same alias map as finding severities,
671
+ // so a legacy config spelling ("must-fix", "defer", …) still blocks the
672
+ // renamed tier.
237
673
  const blocking = new Set(
238
- Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
674
+ (Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
239
675
  ? blockCleanOnFindingSeverities
240
- : ["must-fix"],
676
+ : ["high"]
677
+ ).map((s) => normalizeSeverity(s)),
241
678
  );
242
679
 
243
680
  const malformed = [];
@@ -246,7 +683,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
246
683
  if (err) malformed.push({ index, reason: err });
247
684
  });
248
685
 
249
- const bySeverity = { "must-fix": 0, "worth-fixing-now": 0, "defer": 0 };
686
+ const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
250
687
  /** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
251
688
  const findings = [];
252
689
  let blockingCount = 0;
@@ -255,16 +692,17 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
255
692
  for (const r of results) {
256
693
  const angle = r.angle.trim();
257
694
  for (const f of r.findings) {
258
- const isBlocking = blocking.has(f.severity);
695
+ const severity = /** @type {string} */ (normalizeSeverity(f.severity));
696
+ const isBlocking = blocking.has(severity);
259
697
  if (isBlocking) blockingCount += 1;
260
- bySeverity[f.severity] += 1;
698
+ bySeverity[severity] += 1;
261
699
  const entry = {
262
- severity: f.severity,
700
+ severity,
263
701
  angle,
264
702
  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",
703
+ // See deriveDisposition's own doc for the full rule; the fix cycle
704
+ // / operator can override the disposition afterward.
705
+ disposition: deriveDisposition(severity, { isBlocking, locatable: hasLocatableShape(f) }),
268
706
  };
269
707
  if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
270
708
  if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
@@ -301,10 +739,10 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
301
739
  /**
302
740
  * Map consolidated findings into the `--findings` JSON shape consumed by
303
741
  * scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
304
- * disposition, optional files). Pure.
742
+ * disposition, optional files, optional line). Pure.
305
743
  *
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[]}>}
744
+ * @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string, recommendation?: string, line?: number}>} findings
745
+ * @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[], recommendation?: string, line?: number}>}
308
746
  */
309
747
  export function toFindingsLogShape(findings) {
310
748
  const list = Array.isArray(findings) ? findings : [];
@@ -317,12 +755,18 @@ export function toFindingsLogShape(findings) {
317
755
  if (typeof f.disposition === "string" && f.disposition.trim().length > 0) {
318
756
  entry.disposition = f.disposition.trim();
319
757
  }
758
+ if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
759
+ entry.recommendation = f.recommendation.trim();
760
+ }
320
761
  if (typeof f.file === "string" && f.file.trim().length > 0) {
321
762
  entry.files = [f.file.trim()];
322
763
  } else if (Array.isArray(f.files)) {
323
764
  const files = f.files.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
324
765
  if (files.length > 0) entry.files = files;
325
766
  }
767
+ if (Number.isInteger(f.line) && f.line > 0) {
768
+ entry.line = f.line;
769
+ }
326
770
  return entry;
327
771
  });
328
772
  }
@@ -330,6 +774,12 @@ export function toFindingsLogShape(findings) {
330
774
  /**
331
775
  * Plan how a resolved angle set fans out across the reviewer cap. Pure.
332
776
  *
777
+ * SUPERSEDED by `scheduleFanoutWaves` (#1601, ADR 0048): the gate fan-out
778
+ * conductor now dispatches wave-by-wave at most `gates.fanout.maxConcurrent`
779
+ * (M) dispatch units per wave, using the wave plan emitted by
780
+ * `write-gate-context.mjs`. This helper is kept only for back-compat (zero
781
+ * non-test callers) and no longer participates in the dispatch path.
782
+ *
333
783
  * When `angles.length <= maxReviewers`, all reviewers run in a single parallel
334
784
  * batch (no degradation). When it exceeds the cap, the overflow is split into
335
785
  * sequential batches of at most `maxReviewers` each, and `degraded` is true so