@dev-loops/core 1.0.2 → 1.0.4-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
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 { normalizeSeverity, 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 means no finding at a BLOCKING severity remains open. It is
261
+ * invalid only when a finding at a blocking severity was acted on — that is
262
+ * unresolved blocking work, so the round cannot be clean. Acting on a
263
+ * NON-BLOCKING finding (a medium in the fix window, a low the fixer triages) is
264
+ * expected under a clean verdict per `GATE-EXEC-BLOCKING-ONLY-FIX`: the fix
265
+ * cycle covers non-blocking findings even though they never block clean, so a
266
+ * clean verdict routinely carries non-blocking act findings.
267
+ *
268
+ * Throws a clear Error on `overallVerdict === "clean"` with any act finding at a
269
+ * blocking severity; returns `overallVerdict` unchanged otherwise.
270
+ *
271
+ * @param {unknown} overallVerdict
272
+ * @param {Array<{severity?: unknown}>} actFindings — the round's act-disposed findings.
273
+ * @param {string[]} [blockingSeverities] — the gate's blocking severities (default ["high"]).
274
+ * @returns {unknown} `overallVerdict`, unchanged.
275
+ */
276
+ export function assertCleanImpliesNoBlockingAct(overallVerdict, actFindings, blockingSeverities) {
277
+ if (!Array.isArray(actFindings)) {
278
+ throw new TypeError("assertCleanImpliesNoBlockingAct requires actFindings to be an array");
279
+ }
280
+ if (overallVerdict !== "clean") return overallVerdict;
281
+ const blocking = new Set(
282
+ (Array.isArray(blockingSeverities) && blockingSeverities.length > 0 ? blockingSeverities : ["high"]).map((s) =>
283
+ normalizeSeverity(s),
284
+ ),
285
+ );
286
+ const offending = actFindings.filter((f) => blocking.has(normalizeSeverity(f?.severity)));
287
+ if (offending.length > 0) {
288
+ const severities = [...new Set(offending.map((f) => normalizeSeverity(f?.severity)))].join(", ");
289
+ throw new Error(
290
+ `clean verdict is invalid with ${offending.length} acted finding(s) at a blocking severity (${severities}): ` +
291
+ `a blocking-severity finding acted on this round cannot be clean. Non-blocking act findings are allowed under ` +
292
+ `a clean verdict (GATE-EXEC-BLOCKING-ONLY-FIX).`,
293
+ );
294
+ }
295
+ return overallVerdict;
296
+ }
@@ -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
+ }
@@ -177,10 +177,19 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
177
177
  * @param {AngleReviewSurface} [input.angleSurface] — the angle's declared surface;
178
178
  * derived from {@link angleReviewSurface} when omitted.
179
179
  * @param {string[]} input.changedFiles — repo-relative paths changed between head
180
- * A and head B (the delta, NOT the full PR diff against base).
180
+ * A and head B (the delta, NOT the full PR diff against base). For a base-move
181
+ * re-gate the caller passes the MAIN-RELATIVE incremental delta: files changed
182
+ * since head A whose head-B content is genuinely PR-own (differs from
183
+ * origin/main), NOT the raw two-dot A..B delta — so an integrate-only base-move
184
+ * that only replays already-merged main commits contributes an empty delta.
181
185
  * @param {string} input.prevVerdict — the angle's verdict at head A. "clean" and
182
186
  * "findings_present" are carry-forward-eligible; anything else (e.g.
183
187
  * "blocked", missing) is not.
188
+ * @param {boolean} [input.deltaComplete=false] — the caller PROVES `changedFiles`
189
+ * is the complete, successfully-computed delta (git succeeded and, for a
190
+ * base-move, the main-relative reduction ran). Only then does an EMPTY delta
191
+ * mean "nothing PR-own changed" and carry forward; without the proof an empty
192
+ * delta is indistinguishable from an unavailable one and still fails closed.
184
193
  * @returns {{ carryForward: boolean, reason: string }}
185
194
  */
186
195
 
@@ -207,7 +216,7 @@ export function isDevLoopConfigSourcePath(filePath) {
207
216
  return DEV_LOOP_CONFIG_SOURCE_RE.test(filePath.trim().replace(/\\/g, "/"));
208
217
  }
209
218
 
210
- export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
219
+ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict, deltaComplete = false }) {
211
220
  if (!CARRY_FORWARD_ELIGIBLE_VERDICTS.has(prevVerdict)) {
212
221
  return {
213
222
  carryForward: false,
@@ -221,7 +230,16 @@ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, pr
221
230
  if (surface.kind === "unknown") {
222
231
  return { carryForward: false, reason: "angle has no declared review surface (fail-closed)" };
223
232
  }
224
- if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
233
+ if (!Array.isArray(changedFiles)) {
234
+ return { carryForward: false, reason: "delta is unavailable (fail-closed)" };
235
+ }
236
+ // A PROVEN-complete empty delta (deltaComplete) means the main-relative
237
+ // reduction found NO PR-own change since the prior reviewed head — an
238
+ // integrate-only base-move that only replays already-merged main commits.
239
+ // That carries forward: the zero-file loop below proves the surface untouched.
240
+ // Without that proof an empty delta is indistinguishable from an unavailable
241
+ // one, so it still fails closed.
242
+ if (changedFiles.length === 0 && !deltaComplete) {
225
243
  return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
226
244
  }
227
245
  for (const file of changedFiles) {
@@ -287,12 +305,22 @@ const COPILOT_REVIEW_SURFACE_KINDS = new Set(["code", "test", "config", "ci"]);
287
305
  * re-runs, since classifyFile is path-based). Any code/test/config/CI file, an unclassifiable
288
306
  * file, or an empty/unavailable delta -> re-run (fresh blocking round required).
289
307
  *
308
+ * `deltaComplete` mirrors {@link resolveAngleCarryForward}: when the caller PROVES
309
+ * `changedFiles` is the complete main-relative reduction, an EMPTY delta means an
310
+ * integrate-only base-move touched no Copilot surface and the convergence carries
311
+ * forward. Without the proof an empty delta still fails closed.
312
+ *
290
313
  * @param {object} input
291
314
  * @param {string[]} input.changedFiles — delta since the converged head
315
+ * @param {boolean} [input.deltaComplete=false] — proof the empty case is a real
316
+ * "nothing PR-own changed", not an unavailable delta
292
317
  * @returns {{ carryForward: boolean, reason: string }}
293
318
  */
294
- export function resolveConvergenceCarryForward({ changedFiles }) {
295
- if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
319
+ export function resolveConvergenceCarryForward({ changedFiles, deltaComplete = false }) {
320
+ if (!Array.isArray(changedFiles)) {
321
+ return { carryForward: false, reason: "delta is unavailable (fail-closed)" };
322
+ }
323
+ if (changedFiles.length === 0 && !deltaComplete) {
296
324
  return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
297
325
  }
298
326
  for (const file of changedFiles) {
@@ -304,5 +332,10 @@ export function resolveConvergenceCarryForward({ changedFiles }) {
304
332
  return { carryForward: false, reason: `delta touches Copilot's review surface (${kind}): ${file}` };
305
333
  }
306
334
  }
307
- return { carryForward: true, reason: "delta is a pure doc/prose bump, provably outside Copilot's review surface" };
335
+ return {
336
+ carryForward: true,
337
+ reason: changedFiles.length === 0
338
+ ? "no PR-own change since the converged head (integrate-only base-move), provably outside Copilot's review surface"
339
+ : "delta is a pure doc/prose bump, provably outside Copilot's review surface",
340
+ };
308
341
  }