@dev-loops/core 1.0.2-slim.0 → 1.0.3

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * finding-cluster.mjs — deterministic fan-in finding clustering for the
3
+ * gate-review sub-loop. Collapses duplicate findings (the SAME root cause
4
+ * reported by more than one reviewer/angle) into one cluster so the judge's
5
+ * relevance disposition and the fixer's act list both reason about root
6
+ * causes, not raw finding count.
7
+ *
8
+ * Pure and offline: no file reads, no network, no state held across calls.
9
+ * Every function deep-clones before enriching and never mutates a caller's
10
+ * array/objects.
11
+ */
12
+
13
+ import { resolveFindingFile } from "./gate-fanin.mjs";
14
+
15
+ // NUL separates the three key components so a value inside one component
16
+ // (e.g. a recommendation that happens to contain a colon or digits matching
17
+ // another component) can never be misread as spanning a boundary.
18
+ const ROOT_CAUSE_KEY_SEPARATOR = "\u0000";
19
+
20
+ /** @param {unknown} value @returns {boolean} */
21
+ function isNonEmptyString(value) {
22
+ return typeof value === "string" && value.trim().length > 0;
23
+ }
24
+
25
+ /**
26
+ * The canonical root-cause key for a finding: `(reviewed head, primary
27
+ * location "file:line", normalized remediation target)`. A finding missing
28
+ * ANY of the three components is UNKEYABLE and returns null — it is never
29
+ * grouped with another finding, keyable or not.
30
+ *
31
+ * @param {{file?: unknown, files?: unknown, line?: unknown, recommendation?: unknown}} finding
32
+ * @param {{headSha?: unknown}} [options] — the round's reviewed head; a
33
+ * finding never carries its own head, so this is always caller-supplied.
34
+ * @returns {string|null}
35
+ */
36
+ export function computeRootCauseKey(finding, { headSha } = {}) {
37
+ if (!finding || typeof finding !== "object") return null;
38
+ if (!isNonEmptyString(headSha)) return null;
39
+ const file = resolveFindingFile(finding);
40
+ if (!isNonEmptyString(file)) return null;
41
+ const line = finding.line;
42
+ if (!Number.isInteger(line) || line < 1) return null;
43
+ const recommendation = finding.recommendation;
44
+ if (!isNonEmptyString(recommendation)) return null;
45
+
46
+ const normalizedRecommendation = recommendation.trim().toLowerCase().replace(/\s+/g, " ");
47
+ return [headSha.trim(), `${file}:${line}`, normalizedRecommendation].join(ROOT_CAUSE_KEY_SEPARATOR);
48
+ }
49
+
50
+ /**
51
+ * Group findings by EXACT root-cause key match. Every UNKEYABLE finding
52
+ * (see {@link computeRootCauseKey}) is its own singleton cluster, never
53
+ * grouped with another unkeyable finding. Deterministic: clusters are
54
+ * ordered by `representativeIndex` ascending (first appearance), and a
55
+ * cluster's `memberIndices` are ascending.
56
+ *
57
+ * FAIL-OPEN: a non-array `findings`, a missing/empty/non-string `headSha`,
58
+ * or any internal error returns `{ ok: false, reason, clusters }` where
59
+ * `clusters` is one singleton (`keyable: false`) per ORIGINAL finding in
60
+ * input order — so a caller passes every original finding through to the
61
+ * judge unchanged. Never throws.
62
+ *
63
+ * @param {unknown} findings
64
+ * @param {{headSha?: unknown}} [options]
65
+ * @returns {{ ok: true, clusters: Array<{key: string|null, keyable: boolean, memberIndices: number[], representativeIndex: number}> }
66
+ * | { ok: false, reason: string, clusters: Array<{key: null, keyable: false, memberIndices: number[], representativeIndex: number}> }}
67
+ */
68
+ export function clusterFindings(findings, options = {}) {
69
+ try {
70
+ if (!Array.isArray(findings)) {
71
+ throw new Error("clusterFindings requires findings to be an array");
72
+ }
73
+ if (!isNonEmptyString(options?.headSha)) {
74
+ throw new Error("clusterFindings requires a non-empty options.headSha");
75
+ }
76
+ const headSha = options.headSha;
77
+ // A Map preserves insertion order, and every finding is visited index
78
+ // ascending below, so the FIRST index to create a cluster entry is
79
+ // always that cluster's smallest (representative) member — clusters is
80
+ // therefore already representativeIndex-ascending with no extra sort.
81
+ const byKey = new Map();
82
+ const clusters = [];
83
+ findings.forEach((finding, index) => {
84
+ const key = computeRootCauseKey(finding, { headSha });
85
+ if (key === null) {
86
+ clusters.push({ key: null, keyable: false, memberIndices: [index], representativeIndex: index });
87
+ return;
88
+ }
89
+ let cluster = byKey.get(key);
90
+ if (!cluster) {
91
+ cluster = { key, keyable: true, memberIndices: [], representativeIndex: index };
92
+ byKey.set(key, cluster);
93
+ clusters.push(cluster);
94
+ }
95
+ cluster.memberIndices.push(index);
96
+ });
97
+ return { ok: true, clusters };
98
+ } catch (err) {
99
+ const list = Array.isArray(findings) ? findings : [];
100
+ return {
101
+ ok: false,
102
+ reason: err instanceof Error ? err.message : String(err),
103
+ clusters: list.map((_finding, index) => ({ key: null, keyable: false, memberIndices: [index], representativeIndex: index })),
104
+ };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Reconstruct the clusters array from findings a producer already stamped
110
+ * with `clusterId` — the LOSSLESS identity consolidate-fanin.mjs computes
111
+ * (via {@link clusterFindings}) on pre-truncation finding text and stamps
112
+ * onto each ledger finding as `finding.clusterId = cluster.representativeIndex`.
113
+ * A consumer reading the ledger back (e.g. judge-pass.mjs) must group by this
114
+ * stamp rather than re-running {@link clusterFindings} on the ledger's own
115
+ * `recommendation`/`file` text, which the ledger pipeline TRUNCATES after
116
+ * clustering — re-deriving from truncated text can merge findings that only
117
+ * share a long truncated prefix, silently discarding the lossless grouping.
118
+ *
119
+ * Every finding must carry an integer `clusterId` (fails closed otherwise —
120
+ * a caller unsure every finding is stamped should fall back to
121
+ * {@link clusterFindings} instead of calling this). Deterministic: clusters
122
+ * are ordered by `representativeIndex` ascending, and each cluster's
123
+ * `memberIndices` are ascending (findings are visited index-ascending).
124
+ *
125
+ * @param {Array<{clusterId?: unknown}>} findings
126
+ * @returns {Array<{key: null, keyable: true, memberIndices: number[], representativeIndex: number}>}
127
+ */
128
+ export function clustersFromStampedIds(findings) {
129
+ if (!Array.isArray(findings)) {
130
+ throw new TypeError("clustersFromStampedIds requires findings to be an array");
131
+ }
132
+ const byClusterId = new Map();
133
+ findings.forEach((finding, index) => {
134
+ const clusterId = finding?.clusterId;
135
+ if (!Number.isInteger(clusterId)) {
136
+ throw new TypeError(`clustersFromStampedIds requires every finding to carry an integer clusterId (index ${index} does not)`);
137
+ }
138
+ let cluster = byClusterId.get(clusterId);
139
+ if (!cluster) {
140
+ cluster = { key: null, keyable: true, memberIndices: [], representativeIndex: clusterId };
141
+ byClusterId.set(clusterId, cluster);
142
+ }
143
+ cluster.memberIndices.push(index);
144
+ });
145
+ return [...byClusterId.values()].sort((a, b) => a.representativeIndex - b.representativeIndex);
146
+ }
147
+
148
+ /**
149
+ * Project every multi-member cluster's REPRESENTATIVE judge disposition
150
+ * (`judgeDisposition`/`judgeRationale`/`judgeCriterion`/`followUpDraft`) onto
151
+ * every member of that cluster — one judge decision per root cause, applied
152
+ * to every finding reporting it. Singleton clusters are unchanged. Pure:
153
+ * deep-clones before enriching, never mutates `enrichedFindings`.
154
+ *
155
+ * Fails closed (throws) when a cluster is malformed or its
156
+ * `memberIndices`/`representativeIndex` reference an out-of-range position.
157
+ *
158
+ * @param {Array<object>} enrichedFindings
159
+ * @param {Array<{memberIndices: number[], representativeIndex: number}>} clusters
160
+ * @returns {Array<object>} a new, deep-cloned array.
161
+ */
162
+ export function projectClusterDisposition(enrichedFindings, clusters) {
163
+ if (!Array.isArray(enrichedFindings)) {
164
+ throw new TypeError("projectClusterDisposition requires enrichedFindings to be an array");
165
+ }
166
+ if (!Array.isArray(clusters)) {
167
+ throw new TypeError("projectClusterDisposition requires clusters to be an array");
168
+ }
169
+ const result = enrichedFindings.map((finding) => structuredClone(finding));
170
+ for (const cluster of clusters) {
171
+ if (!cluster || !Array.isArray(cluster.memberIndices) || !Number.isInteger(cluster.representativeIndex)) {
172
+ throw new TypeError("projectClusterDisposition requires every cluster to carry memberIndices[] and a representativeIndex");
173
+ }
174
+ if (cluster.representativeIndex < 0 || cluster.representativeIndex >= result.length) {
175
+ throw new RangeError(`projectClusterDisposition: cluster representativeIndex ${cluster.representativeIndex} is out of range (${result.length} findings)`);
176
+ }
177
+ for (const memberIndex of cluster.memberIndices) {
178
+ if (!Number.isInteger(memberIndex) || memberIndex < 0 || memberIndex >= result.length) {
179
+ throw new RangeError(`projectClusterDisposition: cluster memberIndices references out-of-range index ${memberIndex} (${result.length} findings)`);
180
+ }
181
+ }
182
+ if (cluster.memberIndices.length === 0) {
183
+ throw new RangeError("projectClusterDisposition: a cluster must have at least one member (memberIndices is empty)");
184
+ }
185
+ if (!cluster.memberIndices.includes(cluster.representativeIndex)) {
186
+ throw new RangeError(
187
+ `projectClusterDisposition: cluster representativeIndex ${cluster.representativeIndex} is not one of its own memberIndices [${cluster.memberIndices.join(", ")}]`
188
+ );
189
+ }
190
+ if (cluster.memberIndices.length <= 1) continue; // singleton: shape validated above, nothing to project.
191
+ const representative = result[cluster.representativeIndex];
192
+ for (const memberIndex of cluster.memberIndices) {
193
+ const target = result[memberIndex];
194
+ target.judgeDisposition = representative.judgeDisposition;
195
+ target.judgeRationale = representative.judgeRationale;
196
+ if (representative.judgeCriterion !== undefined) target.judgeCriterion = representative.judgeCriterion;
197
+ else delete target.judgeCriterion;
198
+ if (representative.followUpDraft !== undefined) target.followUpDraft = representative.followUpDraft;
199
+ else delete target.followUpDraft;
200
+ }
201
+ }
202
+ return result;
203
+ }
204
+
205
+ /**
206
+ * Reduce an ACT list to at most one finding per acted cluster — the fixer's
207
+ * one-remediation-per-root-cause input. `allFindings` is the SAME array
208
+ * `clusters` was computed against; each act finding is matched back to its
209
+ * original position in it by reference, then to that position's cluster.
210
+ *
211
+ * A cluster's `representativeIndex` is by construction its smallest member
212
+ * index (see {@link clusterFindings}), so — given `actFindings` preserves
213
+ * the round's original relative order, which every producer in this
214
+ * pipeline does — the first member of a cluster encountered here is always
215
+ * either the representative itself, or, when the representative was not
216
+ * itself act-disposed, the earliest-index act member of that cluster: the
217
+ * exact preference rule this function implements, with no extra bookkeeping.
218
+ *
219
+ * A finding whose cluster cannot be resolved (absent from `allFindings`, or
220
+ * no cluster covers its position) passes through unchanged — fail-open,
221
+ * never silently dropped.
222
+ *
223
+ * @param {Array<object>} actFindings
224
+ * @param {Array<{memberIndices: number[], representativeIndex: number}>} clusters
225
+ * @param {Array<object>} allFindings
226
+ * @returns {Array<object>}
227
+ */
228
+ export function dedupeActListByCluster(actFindings, clusters, allFindings) {
229
+ if (!Array.isArray(actFindings)) {
230
+ throw new TypeError("dedupeActListByCluster requires actFindings to be an array");
231
+ }
232
+ const clusterList = Array.isArray(clusters) ? clusters : [];
233
+ const findingsList = Array.isArray(allFindings) ? allFindings : [];
234
+
235
+ const representativeIndexByPosition = new Map();
236
+ for (const cluster of clusterList) {
237
+ if (!cluster || !Array.isArray(cluster.memberIndices) || !Number.isInteger(cluster.representativeIndex)) continue;
238
+ for (const memberIndex of cluster.memberIndices) {
239
+ representativeIndexByPosition.set(memberIndex, cluster.representativeIndex);
240
+ }
241
+ }
242
+
243
+ const seenClusters = new Set();
244
+ const kept = [];
245
+ for (const finding of actFindings) {
246
+ const position = findingsList.indexOf(finding);
247
+ const representativeIndex = position === -1 ? undefined : representativeIndexByPosition.get(position);
248
+ if (representativeIndex === undefined) {
249
+ kept.push(finding); // Fail-open: unresolvable membership always passes through.
250
+ continue;
251
+ }
252
+ if (seenClusters.has(representativeIndex)) continue;
253
+ seenClusters.add(representativeIndex);
254
+ kept.push(finding);
255
+ }
256
+ return kept;
257
+ }
258
+
259
+ /**
260
+ * A "clean" verdict is invalid the moment any finding was acted on — an
261
+ * acted finding is, by definition, unresolved work. Throws a clear Error on
262
+ * `overallVerdict === "clean" && actCount > 0`; returns `overallVerdict`
263
+ * unchanged otherwise.
264
+ *
265
+ * @param {unknown} overallVerdict
266
+ * @param {number} actCount — non-negative integer; fails closed otherwise.
267
+ * @returns {unknown} `overallVerdict`, unchanged.
268
+ */
269
+ export function assertCleanImpliesNoAct(overallVerdict, actCount) {
270
+ if (!Number.isInteger(actCount) || actCount < 0) {
271
+ throw new TypeError("assertCleanImpliesNoAct requires actCount to be a non-negative integer");
272
+ }
273
+ if (overallVerdict === "clean" && actCount > 0) {
274
+ throw new Error("clean verdict is invalid with a nonzero act count: any acted finding prevents clean");
275
+ }
276
+ return overallVerdict;
277
+ }
@@ -0,0 +1,200 @@
1
+ // GATE-EXEC-FIXER-DISPOSITION-BOUNDARY (skills/docs/gate-review-sub-loop-contract.md):
2
+ // fail closed when a fixer push leaves the review threads it claims to have
3
+ // tackled incomplete (missing evidence, uncontained commit, unreplied, or
4
+ // unresolved). This module is a PURE evaluator — every GitHub/git fact
5
+ // (live thread state, commit containment) is injected as data, never fetched
6
+ // here, so the same decision is shared verbatim across every harness.
7
+
8
+ /** Disposition values a handoff entry may carry. Only "tackled" entries are
9
+ * auto-disposed by evaluateFixerDisposition; every other value keeps its
10
+ * existing judgment path untouched (see the boundary rule's scope note). */
11
+ export const FIXER_DISPOSITION_KIND = Object.freeze({
12
+ TACKLED: "tackled",
13
+ DEFERRED: "deferred",
14
+ REJECTED: "rejected",
15
+ });
16
+
17
+ export const FIXER_DISPOSITION_FAILED_STEP = Object.freeze({
18
+ MISSING_FROM_HANDOFF: "missing_from_handoff",
19
+ COMMIT_NOT_CONTAINED: "commit_not_contained",
20
+ REPLY_MISSING: "reply_missing",
21
+ NOT_RESOLVED: "not_resolved",
22
+ });
23
+
24
+ // The only legal next action while disposition is incomplete. Kept as a plain
25
+ // literal (not imported from pr-gate-coordination.mjs) so this module stays a
26
+ // dependency-free leaf; pr-gate-coordination.mjs's own PR_CHECKPOINT_ACTION
27
+ // token for this action is asserted (by test) to equal this same string.
28
+ export const COMPLETE_FIXER_DISPOSITION_ACTION = "complete_fixer_disposition";
29
+
30
+ // Broader than pr-gate-coordination's postDraftForbidden: the failure this
31
+ // boundary closes is the NEXT review/gate round opening over a dirty review
32
+ // surface, so every review-(re)request and gate-dispatch token is forbidden
33
+ // too, not just the draft/merge transition tokens. Literal tokens (not
34
+ // imported) for the same dependency-free-leaf reason as above.
35
+ export const FIXER_DISPOSITION_FORBIDDEN_ACTIONS = Object.freeze([
36
+ "run_draft_gate",
37
+ "reconcile_draft_gate",
38
+ "mark_ready_for_review",
39
+ "request_copilot_review",
40
+ "rerequest_copilot_review",
41
+ "run_pre_approval_gate",
42
+ "await_final_human_approval",
43
+ "declare_merge_ready",
44
+ ]);
45
+
46
+ function isNonEmptyString(value) {
47
+ return typeof value === "string" && value.trim().length > 0;
48
+ }
49
+
50
+ /**
51
+ * Validate + normalize a raw fixer-disposition handoff. Throws on any
52
+ * structural gap the evaluator must never silently tolerate: a missing
53
+ * headSha, a non-array dispositions field, a missing
54
+ * threadId/fixingCommitSha/disposition on any entry, an unrecognized
55
+ * disposition value, or a duplicate threadId/fingerprint across entries.
56
+ *
57
+ * @param {object} raw
58
+ * @returns {{ headSha: string, dispositions: Array<{ threadId: string, fingerprint: string|null, fixingCommitSha: string, disposition: string, validation: string|null }> }}
59
+ */
60
+ export function normalizeFixerDispositionHandoff(raw) {
61
+ if (!raw || typeof raw !== "object") {
62
+ throw new Error("Fixer disposition handoff must be an object");
63
+ }
64
+ if (!isNonEmptyString(raw.headSha)) {
65
+ throw new Error("Fixer disposition handoff is missing headSha");
66
+ }
67
+ if (raw.dispositions !== undefined && !Array.isArray(raw.dispositions)) {
68
+ throw new Error("Fixer disposition handoff dispositions must be an array");
69
+ }
70
+ const rawDispositions = raw.dispositions ?? [];
71
+ const seenThreadIds = new Set();
72
+ const seenFingerprints = new Set();
73
+ const dispositions = rawDispositions.map((entry, index) => {
74
+ if (!entry || typeof entry !== "object") {
75
+ throw new Error(`Fixer disposition handoff entry ${index} must be an object`);
76
+ }
77
+ if (!isNonEmptyString(entry.threadId)) {
78
+ throw new Error(`Fixer disposition handoff entry ${index} is missing threadId`);
79
+ }
80
+ const threadId = entry.threadId.trim();
81
+ if (seenThreadIds.has(threadId)) {
82
+ throw new Error(`Fixer disposition handoff has a duplicate threadId: ${threadId}`);
83
+ }
84
+ seenThreadIds.add(threadId);
85
+ if (!isNonEmptyString(entry.fixingCommitSha)) {
86
+ throw new Error(`Fixer disposition handoff entry for thread ${threadId} is missing fixingCommitSha`);
87
+ }
88
+ if (!isNonEmptyString(entry.disposition)) {
89
+ throw new Error(`Fixer disposition handoff entry for thread ${threadId} is missing disposition`);
90
+ }
91
+ const fingerprint = isNonEmptyString(entry.fingerprint) ? entry.fingerprint.trim() : null;
92
+ if (fingerprint !== null) {
93
+ if (seenFingerprints.has(fingerprint)) {
94
+ throw new Error(`Fixer disposition handoff has a duplicate fingerprint: ${fingerprint}`);
95
+ }
96
+ seenFingerprints.add(fingerprint);
97
+ }
98
+ const disposition = entry.disposition.trim().toLowerCase();
99
+ if (!Object.values(FIXER_DISPOSITION_KIND).includes(disposition)) {
100
+ throw new Error(`Fixer disposition handoff entry for thread ${threadId} has an unrecognized disposition: ${disposition}`);
101
+ }
102
+ return {
103
+ threadId,
104
+ fingerprint,
105
+ fixingCommitSha: entry.fixingCommitSha.trim(),
106
+ disposition,
107
+ validation: isNonEmptyString(entry.validation) ? entry.validation.trim() : null,
108
+ };
109
+ });
110
+ return {
111
+ headSha: raw.headSha.trim(),
112
+ dispositions,
113
+ };
114
+ }
115
+
116
+ function formatIncompleteReason(incomplete) {
117
+ const parts = incomplete.map((entry) => (
118
+ `thread ${entry.threadId} (expected commit ${entry.expectedCommit ?? "unknown"}, failed step: ${entry.failedStep})`
119
+ ));
120
+ return `Fixer disposition is incomplete for ${incomplete.length} tackled thread(s): ${parts.join("; ")}. `
121
+ + `The only legal next action is ${COMPLETE_FIXER_DISPOSITION_ACTION}.`;
122
+ }
123
+
124
+ /**
125
+ * PURE evaluator: given a normalized handoff, the live thread state, and an
126
+ * injected commit-containment fact table, decide whether every thread the
127
+ * fixer claims to have tackled is fully disposed (commit contained, replied
128
+ * with that commit's evidence, and resolved). No I/O — every fact the
129
+ * decision needs is a plain argument, which is what keeps this seam shared
130
+ * verbatim across harnesses.
131
+ *
132
+ * @param {object} params
133
+ * @param {{ headSha: string, dispositions: Array<object> }} params.handoff - raw or already-normalized; ALWAYS re-validated via normalizeFixerDispositionHandoff (idempotent on already-normalized input), so a malformed array-bearing handoff can never bypass schema+enum validation just by already carrying an array
134
+ * @param {Array<{ threadId: string, isResolved: boolean, replyBodies?: string[], claimedTackled?: boolean }>} [params.liveThreads]
135
+ * @param {Record<string, boolean>} [params.containment] - fixingCommitSha -> true when the observed PR head contains it
136
+ * @returns {{ ok: boolean, incomplete: Array<{ threadId: string, expectedCommit: string|null, failedStep: string }>, forbiddenActions: string[], nextAction: string|null, reason: string|null }}
137
+ */
138
+ export function evaluateFixerDisposition({ handoff, liveThreads = [], containment = {} } = {}) {
139
+ const normalizedHandoff = normalizeFixerDispositionHandoff(handoff ?? {});
140
+ const handoffByThreadId = new Map(normalizedHandoff.dispositions.map((entry) => [entry.threadId, entry]));
141
+ const liveByThreadId = new Map(
142
+ (Array.isArray(liveThreads) ? liveThreads : [])
143
+ .filter((entry) => entry && typeof entry.threadId === "string")
144
+ .map((entry) => [entry.threadId, entry]),
145
+ );
146
+
147
+ // The must-check set: every handoff entry explicitly marked "tackled", plus
148
+ // any live thread independently flagged claimedTackled (e.g. a fixer's own
149
+ // claim the CLI cross-referenced against live GitHub state) that has no
150
+ // handoff counterpart at all — that gap is exactly missing_from_handoff.
151
+ const tackledThreadIds = new Set();
152
+ for (const entry of normalizedHandoff.dispositions) {
153
+ if (entry.disposition === FIXER_DISPOSITION_KIND.TACKLED) {
154
+ tackledThreadIds.add(entry.threadId);
155
+ }
156
+ }
157
+ for (const liveThread of liveByThreadId.values()) {
158
+ if (liveThread.claimedTackled === true) {
159
+ tackledThreadIds.add(liveThread.threadId);
160
+ }
161
+ }
162
+
163
+ const incomplete = [];
164
+ for (const threadId of tackledThreadIds) {
165
+ const handoffEntry = handoffByThreadId.get(threadId) ?? null;
166
+ if (!handoffEntry) {
167
+ incomplete.push({ threadId, expectedCommit: null, failedStep: FIXER_DISPOSITION_FAILED_STEP.MISSING_FROM_HANDOFF });
168
+ continue;
169
+ }
170
+ const expectedCommit = handoffEntry.fixingCommitSha;
171
+ // Non-goal enforcement: a SHA alone is never evidence. Only an injected
172
+ // containment[sha] === true (the observed PR head provably contains the
173
+ // commit) can authorize the reply/resolve steps below.
174
+ if (containment?.[expectedCommit] !== true) {
175
+ incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.COMMIT_NOT_CONTAINED });
176
+ continue;
177
+ }
178
+ const liveThread = liveByThreadId.get(threadId) ?? null;
179
+ const replyBodies = Array.isArray(liveThread?.replyBodies) ? liveThread.replyBodies : [];
180
+ const hasEvidencedReply = replyBodies.some((body) => (
181
+ typeof body === "string" && body.toLowerCase().includes(expectedCommit.toLowerCase())
182
+ ));
183
+ if (!hasEvidencedReply) {
184
+ incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.REPLY_MISSING });
185
+ continue;
186
+ }
187
+ if (liveThread?.isResolved !== true) {
188
+ incomplete.push({ threadId, expectedCommit, failedStep: FIXER_DISPOSITION_FAILED_STEP.NOT_RESOLVED });
189
+ }
190
+ }
191
+
192
+ const ok = incomplete.length === 0;
193
+ return {
194
+ ok,
195
+ incomplete,
196
+ forbiddenActions: ok ? [] : [...FIXER_DISPOSITION_FORBIDDEN_ACTIONS],
197
+ nextAction: ok ? null : COMPLETE_FIXER_DISPOSITION_ACTION,
198
+ reason: ok ? null : formatIncompleteReason(incomplete),
199
+ };
200
+ }
@@ -68,6 +68,51 @@ export function backoffMaxConcurrent(maxConcurrent) {
68
68
  return Math.max(1, Math.floor(cap / 2));
69
69
  }
70
70
 
71
+ /** 30s/60s/120s same-unit retry schedule (`GATE-EXEC-DISPATCH-RETRY-BACKOFF`). */
72
+ const DISPATCH_RETRY_BACKOFF_SCHEDULE_MS = Object.freeze([30_000, 60_000, 120_000]);
73
+
74
+ /**
75
+ * True when `errorClass` names a transient provider error (429 rate-limit or
76
+ * any 5xx), the class `GATE-EXEC-DISPATCH-RETRY-BACKOFF` retries — as opposed
77
+ * to a hard 4xx (e.g. `402`), which never is.
78
+ * @param {string|number} errorClass
79
+ * @returns {boolean}
80
+ */
81
+ function isTransientDispatchErrorClass(errorClass) {
82
+ const s = String(errorClass ?? "").trim();
83
+ return s === "429" || /^5\d\d$/.test(s) || s.toLowerCase() === "5xx";
84
+ }
85
+
86
+ /**
87
+ * Pure retry-decision policy for `GATE-EXEC-DISPATCH-RETRY-BACKOFF`: whether a
88
+ * failed dispatch retries the SAME unit, and when a batch reduction is due.
89
+ *
90
+ * A transient failure (429 / 5xx) always retries — the round is never aborted
91
+ * on a transient error, safe because a retry overwrites the same idempotent
92
+ * per-angle findings artifact path (`GATE-EXEC-COLLECTABLE-DISPATCH`), never
93
+ * minting a second one. `delayMs` follows the 30s/60s/120s schedule (holding
94
+ * at 120s past the 3rd attempt); `reduceConcurrency` flips true starting at
95
+ * the 3rd exhausted attempt (`attempt` index 2), signaling the caller to halve
96
+ * the active batch via `backoffMaxConcurrent` — this function only signals
97
+ * WHEN, the halving itself stays `backoffMaxConcurrent`'s job.
98
+ *
99
+ * A hard 4xx (e.g. `402 Insufficient Balance`) is never transient: it escalates
100
+ * to the supervisor/operator immediately instead of retrying into the same wall.
101
+ *
102
+ * @param {number} attempt — 0-based count of prior failed attempts on this unit
103
+ * @param {string|number} errorClass — e.g. `"429"`, `"500"`, `"5xx"`, `"402"`
104
+ * @returns {{ retry: true, delayMs: number, reduceConcurrency: boolean } | { retry: false, escalate: true }}
105
+ */
106
+ export function planDispatchRetry(attempt, errorClass) {
107
+ if (!isTransientDispatchErrorClass(errorClass)) {
108
+ return { retry: false, escalate: true };
109
+ }
110
+ const n = Number.isInteger(attempt) && attempt >= 0 ? attempt : 0;
111
+ const lastIndex = DISPATCH_RETRY_BACKOFF_SCHEDULE_MS.length - 1;
112
+ const delayMs = DISPATCH_RETRY_BACKOFF_SCHEDULE_MS[Math.min(n, lastIndex)];
113
+ return { retry: true, delayMs, reduceConcurrency: n >= lastIndex };
114
+ }
115
+
71
116
  /**
72
117
  * Reviewer-budget preflight for a gate fan-out.
73
118
  *