@dev-loops/core 1.0.3 → 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.
@@ -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
  }
@@ -828,6 +828,7 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
828
828
  disposition: deriveDisposition(severity, { isBlocking, locatable: hasLocatableShape(f) }),
829
829
  };
830
830
  if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
831
+ if (Array.isArray(f.files)) entry.files = f.files.filter((file) => typeof file === "string" && file.trim().length > 0).map((file) => file.trim());
831
832
  if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
832
833
  if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
833
834
  entry.recommendation = f.recommendation.trim();
@@ -871,6 +872,38 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
871
872
  */
872
873
  export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
873
874
 
875
+ /**
876
+ * The `scopeDrift.verdict` vocabulary — the single shared source. The validator
877
+ * below consumes it directly, and a divergence-guard test asserts the judge
878
+ * persona surface (`agents/judge.agent.md`) documents exactly this set, so the
879
+ * producer and enforcer cannot silently drift apart.
880
+ */
881
+ export const SCOPE_DRIFT_VERDICTS = Object.freeze(["within_scope", "drift_detected"]);
882
+
883
+ /**
884
+ * Aliases a persona-following judge reaches for, normalized to the canonical
885
+ * vocabulary before validation. `none` is the intuitive no-drift spelling; it
886
+ * maps to `within_scope` so a semantically-correct verdict passes with no
887
+ * resend or hand-edit, disposition and rationale byte-intact.
888
+ */
889
+ export const SCOPE_DRIFT_VERDICT_ALIASES = Object.freeze({ none: "within_scope" });
890
+
891
+ /**
892
+ * Normalize a `scopeDrift.verdict` alias to its canonical value. Canonical and
893
+ * unknown values pass through untouched; validation rejects unknowns.
894
+ * @param {unknown} verdict
895
+ * @returns {unknown}
896
+ */
897
+ export function normalizeScopeDriftVerdict(verdict) {
898
+ // Only a primitive string spelling normalizes. `hasOwnProperty.call` coerces
899
+ // its key argument, so without this guard a boxed `new String("none")` or an
900
+ // object whose `toString()` returns `"none"` would alias to `within_scope` —
901
+ // a fail-open contradicting the validator's non-string-fails-closed contract.
902
+ return typeof verdict === "string" && Object.prototype.hasOwnProperty.call(SCOPE_DRIFT_VERDICT_ALIASES, verdict)
903
+ ? SCOPE_DRIFT_VERDICT_ALIASES[verdict]
904
+ : verdict;
905
+ }
906
+
874
907
  /**
875
908
  * Validate a judge verdict artifact shape (the `judge` agent's only write).
876
909
  * Pure; throws on a malformed verdict rather than enriching findings with
@@ -901,8 +934,9 @@ export function validateJudgeVerdict(verdict) {
901
934
  throw new Error("judge verdict.scopeDrift must be an object");
902
935
  }
903
936
  const sd = /** @type {Record<string, unknown>} */ (v.scopeDrift);
904
- if (sd.verdict !== "within_scope" && sd.verdict !== "drift_detected") {
905
- throw new Error("judge verdict.scopeDrift.verdict must be 'within_scope' or 'drift_detected'");
937
+ const normalizedVerdict = normalizeScopeDriftVerdict(sd.verdict);
938
+ if (!SCOPE_DRIFT_VERDICTS.includes(/** @type {string} */ (normalizedVerdict))) {
939
+ throw new Error(`judge verdict.scopeDrift.verdict must be one of: ${SCOPE_DRIFT_VERDICTS.join(", ")}`);
906
940
  }
907
941
  if (typeof sd.rationale !== "string" || sd.rationale.trim().length === 0) {
908
942
  throw new Error("judge verdict.scopeDrift.rationale must be a non-empty string");
@@ -949,7 +983,7 @@ export function validateJudgeVerdict(verdict) {
949
983
  }
950
984
  }
951
985
  }
952
- return { headSha: v.headSha, scopeDrift: v.scopeDrift, dispositions: v.dispositions };
986
+ return { headSha: v.headSha, scopeDrift: { ...sd, verdict: normalizedVerdict }, dispositions: v.dispositions };
953
987
  }
954
988
 
955
989
  /**
@@ -949,14 +949,95 @@ function sectionHasBody(section) {
949
949
  return false;
950
950
  }
951
951
 
952
+ // A top-level list marker line (any GFM/CommonMark family: `-`/`*`/`+`,
953
+ // ordered `N.`/`N)`, optional leading blockquote `>` runs) with NO checkbox.
954
+ // `validatePrBodySpec`-local: unlike `parseChecklistItems` (whose
955
+ // `checked: null` state fires ONLY for the bare-dash form — see its
956
+ // docstring), this recognizes every marker family, so a mixed AC/DoD section
957
+ // (`- [ ] works` next to `* plain`) cannot escape detection just because the
958
+ // plain line used a non-dash marker. No leading-space match before the
959
+ // marker/blockquote keeps this top-level only: an indented continuation
960
+ // (` - detail`) is sub-content and is not matched. The negative lookahead
961
+ // excludes a real checkbox line so `- [ ] works` is never double-counted; the
962
+ // lookahead treats end-of-line as a checkbox terminator too (`(?:\s|$)`), so
963
+ // an empty checkbox placeholder (`- [ ]`/`- [x]`, no trailing text) is
964
+ // excluded rather than mis-caught as a plain bullet — `parseChecklistItems`
965
+ // already documents empty placeholders as skipped, and this scan must not
966
+ // contradict that.
967
+ const TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN = /^(?:>\s*)*(?:[-*+]|\d+[.)])\s+(?!\[[ xX]\](?:\s|$))(.+?)\s*$/u;
968
+
969
+ // A spaced Markdown thematic break (`* * *`, `- - -`) also matches
970
+ // `TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN` (marker, whitespace, more marker
971
+ // text) but is a divider, not a bullet — `parseChecklistItems` never treats
972
+ // it as an item, so counting it here would reject a legitimate AC/DoD
973
+ // section over a divider line. `***`/`---` (no spaces) already fail the
974
+ // bullet pattern's `\s+` requirement, so only the spaced form needs this
975
+ // guard. Same-marker-only via the backreference, and only `-`/`*`/`_` per
976
+ // the CommonMark thematic-break rule (3+ of the SAME char, optional spaces,
977
+ // nothing else) — `+` is NOT a valid thematic-break marker, so `+ + +`
978
+ // stays correctly counted as a real (single-item) plain bullet.
979
+ const THEMATIC_BREAK_RE = /^([-*_])(?:[ \t]*\1){2,}[ \t]*$/u;
980
+
981
+ /**
982
+ * Scan a flattened section body for top-level non-checkbox bullet lines
983
+ * (`TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN`). Fence-skipped via the shared
984
+ * `stepFence` so a fenced fake bullet cannot spoof this check. Thematic-break
985
+ * divider lines (`THEMATIC_BREAK_RE`) are excluded — see its docstring.
986
+ */
987
+ function scanTopLevelNonCheckboxBulletLines(text) {
988
+ if (typeof text !== "string" || text.length === 0) return [];
989
+ const found = [];
990
+ let fence = null;
991
+ for (const line of text.split(/\r?\n/u)) {
992
+ const step = stepFence(fence, line);
993
+ fence = step.fence;
994
+ if (step.insideFence) continue;
995
+ const match = TOP_LEVEL_NON_CHECKBOX_BULLET_PATTERN.exec(line);
996
+ if (!match || match[1].trim().length === 0) continue;
997
+ const strippedLine = line.replace(/^(?:>\s*)*/u, "").trim();
998
+ if (THEMATIC_BREAK_RE.test(strippedLine)) continue;
999
+ found.push(match[1].trim());
1000
+ }
1001
+ return found;
1002
+ }
1003
+
1004
+ /**
1005
+ * Scan every section the completeness block would read (ALL sections matching
1006
+ * `patterns`, deep-flattened past `###` sub-headings via
1007
+ * `findAllSectionsByPatterns` + `flattenSectionDeep` — the same union
1008
+ * `extractPrBodyUncheckedChecklistItems` reads) for top-level plain bullets
1009
+ * of ANY marker (no checkbox). Shares the section-set read with the
1010
+ * completeness block on purpose: a plain bullet invisible to
1011
+ * `validatePrBodySpec`'s own single-section read but visible to the
1012
+ * completeness block (a duplicate AC/DoD heading, or a bullet nested under a
1013
+ * `###` sub-heading) must still reject here, or the two surfaces diverge on
1014
+ * the same checkbox-marker fail-open class this module closes for the simple
1015
+ * single-section case. Local `scanTopLevelNonCheckboxBulletLines` scan, not
1016
+ * `parseChecklistItems`'s `checked: null` state, so a non-dash plain bullet
1017
+ * (`*`/`+`/ordered) is caught too — `parseChecklistItems` and the shared
1018
+ * deterministic pre-approval completeness-block logic it backs (see
1019
+ * acceptance-criteria-verification.md) stay untouched.
1020
+ */
1021
+ function scanPlainBullets(sections, patterns) {
1022
+ const matched = findAllSectionsByPatterns(sections, patterns);
1023
+ const items = [];
1024
+ for (let i = 0; i < sections.length; i += 1) {
1025
+ if (!matched.includes(sections[i])) continue;
1026
+ items.push(...scanTopLevelNonCheckboxBulletLines(flattenSectionDeep(sections, i)));
1027
+ }
1028
+ return items;
1029
+ }
1030
+
952
1031
  /**
953
1032
  * Validate that a PR body carries every invariant required to serve as the
954
1033
  * lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
955
- * testable Acceptance criteria (>=1 checklist item), Definition of done
956
- * (>=1 checklist item), Open questions/risks, and — unless issue-less mode is
1034
+ * testable Acceptance criteria (>=1 checkbox item), Definition of done
1035
+ * (>=1 checkbox item), Open questions/risks, and — unless issue-less mode is
957
1036
  * requested — a GitHub closing-keyword issue reference. Reuses the generic
958
1037
  * markdown logic so there is no parallel validator. Fails closed: every missing
959
- * invariant is reported under its distinct `missing_*` code. Pure; no I/O.
1038
+ * invariant is reported under its distinct `missing_*` code (plus the two
1039
+ * `*_not_checkboxes` codes for a plain-bullet AC/DoD section — see
1040
+ * `scanPlainBullets`). Pure; no I/O.
960
1041
  *
961
1042
  * Issue-less mode (`issueLess: true`): the closing-issue linkage flips from
962
1043
  * REQUIRED to FORBIDDEN (the PR is the sole artifact), failing closed under
@@ -1044,21 +1125,48 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
1044
1125
  }
1045
1126
  }
1046
1127
 
1128
+ // Checkbox markers are REQUIRED (not just any bullet): the completeness
1129
+ // block (extractPrBodyUncheckedChecklistItems) only ever sees checkbox
1130
+ // items, so a plain bullet here would fail-open the completeness check
1131
+ // even though this validator accepted it. The plain-bullet scan reads the
1132
+ // SAME section set the completeness block reads (scanPlainBullets), not
1133
+ // just the first section, so a duplicate heading or a bullet nested under a
1134
+ // `###` sub-heading cannot hide from this check either. The scan runs
1135
+ // INDEPENDENTLY of parseChecklistItems (not gated on it finding items) and
1136
+ // is checked FIRST: an AC/DoD section made entirely of non-dash plain
1137
+ // bullets (`* plain`, `1. plain`) yields zero parsed checkbox items, so
1138
+ // gating the scan on "items found" would misreport that case as
1139
+ // missing_acceptance_criteria/missing_definition_of_done instead of the
1140
+ // distinct *_not_checkboxes code.
1047
1141
  const acSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
1048
- const acItems = acSection ? extractChecklistItems(acSection.bodyLines.join("\n")) : [];
1049
- if (acItems.length === 0) {
1142
+ const acParsed = acSection ? parseChecklistItems(acSection.bodyLines.join("\n")) : [];
1143
+ const acItems = acParsed.filter((item) => item.checked !== null).map((item) => item.text);
1144
+ const acPlainBullets = scanPlainBullets(sections, ACCEPTANCE_SECTION_PATTERNS);
1145
+ if (acPlainBullets.length > 0) {
1146
+ errors.push({
1147
+ code: "acceptance_criteria_not_checkboxes",
1148
+ message: `Acceptance criteria must use checkbox markers ('- [ ]'/'- [x]'), not plain bullets (found ${acPlainBullets.length} plain bullet(s)); plain bullets fail-open the completeness block.`,
1149
+ });
1150
+ } else if (acParsed.length === 0) {
1050
1151
  errors.push({
1051
1152
  code: "missing_acceptance_criteria",
1052
- message: "Missing testable Acceptance criteria (no checklist items found).",
1153
+ message: "Missing testable Acceptance criteria (no checkbox items found).",
1053
1154
  });
1054
1155
  }
1055
1156
 
1056
1157
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
1057
- const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
1058
- if (dodItems.length === 0) {
1158
+ const dodParsed = dodSection ? parseChecklistItems(dodSection.bodyLines.join("\n")) : [];
1159
+ const dodItems = dodParsed.filter((item) => item.checked !== null).map((item) => item.text);
1160
+ const dodPlainBullets = scanPlainBullets(sections, DOD_SECTION_PATTERNS);
1161
+ if (dodPlainBullets.length > 0) {
1162
+ errors.push({
1163
+ code: "definition_of_done_not_checkboxes",
1164
+ message: `Definition of done must use checkbox markers ('- [ ]'/'- [x]'), not plain bullets (found ${dodPlainBullets.length} plain bullet(s)); plain bullets fail-open the completeness block.`,
1165
+ });
1166
+ } else if (dodParsed.length === 0) {
1059
1167
  errors.push({
1060
1168
  code: "missing_definition_of_done",
1061
- message: "Missing Definition of done (no checklist items found).",
1169
+ message: "Missing Definition of done (no checkbox items found).",
1062
1170
  });
1063
1171
  }
1064
1172
 
@@ -11,13 +11,16 @@
11
11
  *
12
12
  * It reuses, never re-derives, the existing precondition set:
13
13
  * `resolveSizeBudgetHumanApprovalRequired` (size-budget-merge-gate),
14
- * `findBlockingTitleMarkers` (pr-title-markers), and the detect-checkpoint-evidence
14
+ * `findBlockingTitleMarkers` (pr-title-markers), the detect-checkpoint-evidence
15
15
  * `preMergeGateCheck` bundle (draft/pre-approval verdicts, threads, runner lock,
16
- * fan-out provenance). This module adds ONLY the human-approver identity, the
17
- * merge-class split, and the aggregate naming.
16
+ * fan-out provenance), and `classifyCopilotReviewBodyDisposition` (copilot-helpers,
17
+ * the same current-head Copilot disposition detection the loop's
18
+ * `copilotBodyFeedbackUnresolved` reads). This module adds ONLY the
19
+ * human-approver identity, the merge-class split, the Copilot-convergence
20
+ * precondition, and the aggregate naming.
18
21
  */
19
22
 
20
- import { isCopilotLogin } from "../github/copilot-helpers.mjs";
23
+ import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
21
24
  import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
22
25
  import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
23
26
  import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
@@ -150,6 +153,103 @@ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews =
150
153
  };
151
154
  }
152
155
 
156
+ /**
157
+ * Copilot-convergence merge precondition. Wires the current-head Copilot
158
+ * body-disposition detection into the merge gate so the merge wrapper
159
+ * and the loop (`copilotBodyFeedbackUnresolved`) read the SAME classification
160
+ * (`classifyCopilotReviewBodyDisposition`). The classification is shared, so it
161
+ * cannot drift; the POLICY differs by design (the loop self-blocks on 🔵, this
162
+ * gate treats 🔵 as conductor-overridable). Fail-closed.
163
+ *
164
+ * Only the LATEST Copilot review pinned to `currentHeadSha` is judged, so a
165
+ * stale non-approval at an earlier head never blocks and a later same-head 🟢
166
+ * clears an earlier same-head finding.
167
+ *
168
+ * Policy (operator-resolved during the v1.0.4 drain):
169
+ * 🟡 "Changes recommended" on the current head -> BLOCK (actionable; the review
170
+ * body is unresolved feedback even with zero inline threads — the exact
171
+ * body-only fail-open the loop detects and this precondition enforces at merge).
172
+ * 🔵 "Needs a closer look" -> conductor-OVERRIDABLE (soft), NOT blocked here.
173
+ * Unresolved threads still gate it (detect-checkpoint-evidence refuses any
174
+ * unresolved review thread), so a 🔵 merges only with zero unresolved
175
+ * threads — the conductor's override is choosing to run the merge on a
176
+ * thread-clean 🔵.
177
+ * unrecognized disposition -> BLOCK (fail closed on a Copilot format change).
178
+ * 🟢 clean / no current-head Copilot review / stale earlier-head -> PASS.
179
+ *
180
+ * @returns {{ ok: boolean, disposition: string|null, reason: string|null }}
181
+ */
182
+ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [] } = {}) {
183
+ const head = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
184
+ // Head unknown: no current-head review can be pinned. Fail closed (matches
185
+ // verifyFreshHumanApproval), so this precondition can never pass without a
186
+ // known head to pin the Copilot disposition to.
187
+ if (head.length === 0) return { ok: false, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
188
+
189
+ // Mirror summarizeCopilotReviews' current-head finding selection BYTE-FOR-BYTE
190
+ // so the merge gate and the loop can never diverge (they already share
191
+ // classifyCopilotReviewBodyDisposition at the detection layer). Use the SAME
192
+ // comparison the loop uses — the RAW submittedAt string (a non-string is null),
193
+ // compared with `>`/`===`, NOT a parsed timestamp: parsing would diverge on a
194
+ // malformed/mixed-offset submittedAt (an invalid-timestamp 🟡 that the loop
195
+ // keeps as latest could otherwise be superseded here — a fail-open). Consider
196
+ // only SUBMITTED current-head reviews, skip PENDING drafts (a PENDING never
197
+ // sets the finding), pick the latest by submittedAt string, and on an
198
+ // equal-string tie (or both-null) fold toward the most-blocking disposition so
199
+ // array order never silently drops a finding.
200
+ let latestDisposition = null;
201
+ let latestAt = null;
202
+ for (const entry of Array.isArray(reviews) ? reviews : []) {
203
+ const login = reviewLogin(entry);
204
+ if (login === null || !isCopilotLogin(login)) continue;
205
+ if (reviewCommit(entry) !== head) continue; // only current-head reviews
206
+ const state = typeof entry?.state === "string" ? entry.state.toUpperCase() : "";
207
+ if (state === "PENDING" || !SUBMITTED_REVIEW_STATES.has(state)) continue; // PENDING/unknown never sets the finding
208
+ const disposition = classifyCopilotReviewBodyDisposition(state, entry?.body);
209
+ const submittedAt = typeof entry?.submittedAt === "string"
210
+ ? entry.submittedAt
211
+ : (typeof entry?.submitted_at === "string" ? entry.submitted_at : null);
212
+ if (submittedAt !== null && (latestAt === null || submittedAt > latestAt)) {
213
+ latestDisposition = disposition; // a lexicographically-later submittedAt supersedes (matches summarize)
214
+ latestAt = submittedAt;
215
+ } else if (submittedAt !== null && submittedAt === latestAt) {
216
+ latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
217
+ } else if (submittedAt === null && latestAt === null) {
218
+ latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
219
+ }
220
+ // a null submittedAt once a non-null latest exists is ignored (mirrors summarize)
221
+ }
222
+ if (latestDisposition === null) return { ok: true, disposition: null, reason: null };
223
+
224
+ if (latestDisposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED) {
225
+ return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
226
+ }
227
+ if (latestDisposition === COPILOT_DISPOSITION.UNRECOGNIZED) {
228
+ return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
229
+ }
230
+ // CLEAN, NONE, and NEEDS_CLOSER_LOOK (🔵, conductor-overridable) pass.
231
+ return { ok: true, disposition: latestDisposition, reason: null };
232
+ }
233
+
234
+ // Disposition blocking precedence, most-blocking first. Used to fold an
235
+ // equal-timestamp same-head tie toward the most-blocking disposition so a tied
236
+ // 🟡/unrecognized is never silently dropped by a co-timestamped 🟢/🔵.
237
+ const COPILOT_DISPOSITION_BLOCKING_ORDER = [
238
+ COPILOT_DISPOSITION.CHANGES_RECOMMENDED,
239
+ COPILOT_DISPOSITION.UNRECOGNIZED,
240
+ COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK,
241
+ COPILOT_DISPOSITION.CLEAN,
242
+ COPILOT_DISPOSITION.NONE,
243
+ ];
244
+ function moreBlockingDisposition(a, b) {
245
+ const ia = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(a);
246
+ const ib = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(b);
247
+ // A value absent from the order (defensive) sorts last.
248
+ const ra = ia === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ia;
249
+ const rb = ib === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ib;
250
+ return ra <= rb ? a : b;
251
+ }
252
+
153
253
  /**
154
254
  * Decide whether merge is authorized given the class, the standing
155
255
  * authorization signal, and any fresh per-merge approval.
@@ -273,11 +373,27 @@ export function evaluateMergePreconditions({
273
373
  failures.push({ precondition: "size_budget_human_approval", reason: "size-budget requires a human APPROVED review OR a head-pinned \"approve merge <headSha>\" operator comment, with zero unresolved CHANGES_REQUESTED, for this escalated/T1 PR" });
274
374
  }
275
375
 
376
+ // Copilot-convergence precondition: refuse a current-head Copilot non-approval
377
+ // body disposition, mirroring the loop's copilotBodyFeedbackUnresolved.
378
+ const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews });
379
+ if (!copilotConvergence.ok) {
380
+ failures.push({ precondition: "copilot_convergence", reason: copilotConvergence.reason });
381
+ }
382
+
276
383
  const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
277
384
  const decision = resolveMergeApprovalDecision({ mergeClass, standingAuthorized, freshApproval });
278
385
  if (!decision.authorized) {
279
386
  failures.push({ precondition: "merge_approval", reason: decision.reason });
280
387
  }
281
388
 
282
- return { ok: failures.length === 0, failures, mergeClass, approvalVia: decision.authorized ? decision.via : null };
389
+ return {
390
+ ok: failures.length === 0,
391
+ failures,
392
+ mergeClass,
393
+ approvalVia: decision.authorized ? decision.via : null,
394
+ // Audit trace: the current-head Copilot disposition this verdict saw, so a
395
+ // merge that ran on a conductor-overridable 🔵 (or any disposition) is
396
+ // recorded on the machine-readable result rather than being invisible.
397
+ copilotDisposition: copilotConvergence.disposition,
398
+ };
283
399
  }
@@ -592,7 +592,59 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
592
592
  const copilotReviewRequestStatus = typeof input.copilotReviewRequestStatus === "string"
593
593
  ? input.copilotReviewRequestStatus.trim().toLowerCase()
594
594
  : "none";
595
- if (copilotReviewRequestStatus !== "requested" && copilotReviewRequestStatus !== "already-requested") {
595
+ const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
596
+ const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
597
+ const copilotReviewOnCurrentHead = input.copilotReviewOnCurrentHead === true;
598
+ const outstandingRequest = copilotReviewRequestStatus === "requested"
599
+ || copilotReviewRequestStatus === "already-requested";
600
+ const roundCapReached = isCopilotRoundCapReached({
601
+ copilotReviewRoundCount: input.copilotReviewRoundCount,
602
+ maxCopilotRounds: input.maxCopilotRounds,
603
+ });
604
+ // Absent / never-driven: Copilot review is enabled (maxCopilotRounds
605
+ // > 0, not internal_only) but no round was requested or received for the
606
+ // CURRENT head, so a clean pre_approval_gate verdict would rest on nothing the
607
+ // loop actually drove on the head under review. The reconciled status alone is
608
+ // ambiguous: the reconciler (resolveCopilotReviewRequestStatus) folds a clean
609
+ // same-head submitted review into "none" too. Three facts each prove this is
610
+ // not the never-driven case and exempt this branch:
611
+ // - sameHeadCleanConverged: a clean same-head submitted Copilot review
612
+ // exists on THIS head (including GitHub's incidental auto-review) — the
613
+ // no-redundant-re-request case;
614
+ // - copilotReviewOnCurrentHead: a submitted Copilot review exists on THIS
615
+ // head (a settled current-head round; at a grant boundary its threads are
616
+ // already resolved). This is keyed on CURRENT-HEAD evidence, never a raw
617
+ // across-PR round count: copilotReviewRoundCount counts reviews on ANY
618
+ // head, so a PR with prior-head rounds and a NEW current head that carries
619
+ // no review (interpreter state low_signal_converged with
620
+ // copilotReviewOnCurrentHead false) must still fail closed;
621
+ // - roundCapReached: at/past the cap no further Copilot round can be driven,
622
+ // so requiring a current-head review is impossible to satisfy — the
623
+ // pre_approval_gate reviews the post-cap head. The core only reaches a
624
+ // grant boundary here after gating CI/threads, and a significant
625
+ // post-convergence change routes to a rerequest (a new cycle) before this
626
+ // guard runs, so this exemption cannot mask a genuinely-unreviewed change;
627
+ // - postConvergenceReviewSuppressed: an operator verified (via
628
+ // withdraw-copilot-review-request) that the current-head delta since
629
+ // Copilot's last submitted review is a pure doc/prose bump, so the prior
630
+ // converged review stands for this head — the core grants pre_approval on
631
+ // the same basis (never derived here from other snapshot facts).
632
+ // Fail closed on ANY non-outstanding status, not only the literal "none":
633
+ // this is the independent gate-ENTRY re-check, so it must not trust the
634
+ // caller's status string. A non-canonical/unknown value ("", "unavailable",
635
+ // "failed", a typo) with a grant-y lifecycleState and no current-head review
636
+ // fails closed rather than slipping through the "none"-only default. Only a
637
+ // driven current-head review (or the impossible-further-round cap state, or an
638
+ // operator-verified pure-doc-bump suppression) exempts, so an agent that skips
639
+ // the explicit Copilot round cannot reach a clean pre_approval verdict via any
640
+ // grant-y lifecycleState (e.g. a stale/racy low_signal_converged label, or one
641
+ // carried by prior-head rounds).
642
+ const absentNeverDriven = !outstandingRequest
643
+ && !copilotReviewOnCurrentHead
644
+ && !sameHeadCleanConverged
645
+ && !roundCapReached
646
+ && input.postConvergenceReviewSuppressed !== true;
647
+ if (!outstandingRequest && !absentNeverDriven) {
596
648
  return null;
597
649
  }
598
650
  // Round-cap exemption (mirrors shouldGuardCopilotReviewRequest):
@@ -603,10 +655,6 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
603
655
  // and the head is clean — either sameHeadCleanConverged or the interpreter's
604
656
  // round_cap_clean_fallback state — the pre_approval_gate proceeds unless
605
657
  // significant post-convergence changes require a new review cycle.
606
- const roundCapReached = isCopilotRoundCapReached({
607
- copilotReviewRoundCount: input.copilotReviewRoundCount,
608
- maxCopilotRounds: input.maxCopilotRounds,
609
- });
610
658
  const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
611
659
  // Also exempt the evaluator's own ROUND_CAP_REACHED grant shape:
612
660
  // without this, this guard would rewrite that grant back to
@@ -624,7 +672,15 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
624
672
 
625
673
  const allowedNextActions = [];
626
674
  const forbiddenActions = [];
627
- pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW]);
675
+ // Two shapes fail closed here. An OUTSTANDING request (requested/
676
+ // already-requested) waits for the current-head review to settle. The
677
+ // ABSENT / never-driven case has nothing to wait for — no request is
678
+ // in flight — so the loop must first REQUEST a Copilot review, mirroring the
679
+ // detector-side shouldGuardCopilotReviewRequest override.
680
+ const nextAction = absentNeverDriven
681
+ ? PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW
682
+ : PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW;
683
+ pushUnique(allowedNextActions, [nextAction]);
628
684
  // Full postDraftForbidden set (matching the canonical WAITING_FOR_COPILOT_REVIEW
629
685
  // result this guard synthesizes) plus the final-approval actions the replaced
630
686
  // boundary result also forbade — dropping RUN_DRAFT_GATE/MARK_READY_FOR_REVIEW
@@ -642,18 +698,24 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
642
698
  repo: input.repo ?? null,
643
699
  pr: Number.isInteger(input.pr) ? input.pr : null,
644
700
  currentHeadSha: result.currentHeadSha ?? null,
645
- lifecycleState: STATE.WAITING_FOR_COPILOT_REVIEW,
646
- loopDisposition: DISPOSITION.PENDING,
701
+ lifecycleState: absentNeverDriven ? STATE.PR_READY_NO_FEEDBACK : STATE.WAITING_FOR_COPILOT_REVIEW,
702
+ loopDisposition: absentNeverDriven ? DISPOSITION.ACTION_REQUIRED : DISPOSITION.PENDING,
647
703
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
648
704
  draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
649
705
  draftGate: result.draftGate,
650
706
  preApprovalGate: result.preApprovalGate,
651
707
  allowedNextActions,
652
708
  forbiddenActions,
653
- nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW,
654
- reason: "A Copilot review request is still outstanding on the current head (independent gate-entry "
655
- + "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
656
- + "review settles, even though the caller-reported convergence signal claims otherwise.",
709
+ nextAction,
710
+ reason: absentNeverDriven
711
+ ? "Copilot review is enabled for this repo (maxCopilotRounds > 0) but no Copilot review round has been "
712
+ + "requested or received for the current head (independent gate-entry re-check, issue #2146) — a clean "
713
+ + "pre_approval_gate/final-approval verdict requires a Copilot round the loop actually drove and awaited, "
714
+ + "so request Copilot review first. A clean same-head submitted Copilot review (including GitHub's "
715
+ + "auto-review) would satisfy this; an absent/never-driven round is not settled convergence."
716
+ : "A Copilot review request is still outstanding on the current head (independent gate-entry "
717
+ + "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
718
+ + "review settles, even though the caller-reported convergence signal claims otherwise.",
657
719
  mergeStateStatus: result.mergeStateStatus ?? null,
658
720
  conflictFiles: result.conflictFiles ?? [],
659
721
  refinementArtifact: result.refinementArtifact ?? null,
@@ -555,6 +555,12 @@ export function composeRunInspectionSnapshot({
555
555
  if (lifecyclePhase === null) {
556
556
  // Fallback: derive from available PR facts
557
557
  const loopIter = loopIterations ?? {};
558
+ // Deferring the loop-iteration fan-out cannot change the phase: every copilot
559
+ // state maps to one (COPILOT_INNER_STATE_MAP is total over STATE), so whenever
560
+ // copilot evidence is present the phase is already resolved above and this
561
+ // fallback never runs. It runs only when that evidence is ABSENT, where there
562
+ // is no thread count to consult from any source — so there is nothing here to
563
+ // reconcile between a deferred and a full inspection.
558
564
  const hasUnresolvedThreads = typeof loopIter.unresolvedReviewThreads === "number"
559
565
  && loopIter.unresolvedReviewThreads > 0;
560
566
  const copilotState = copilotLiveOk && copilotEvidence !== null
@@ -98,6 +98,17 @@ export const SPEC_AUTHORITY_OUTCOME_VALUES = Object.freeze(
98
98
  * three resolve autonomously. Exported so no consumer re-hardcodes the set. */
99
99
  export const HUMAN_SPEC_DECISION_OUTCOME = SPEC_AUTHORITY_OUTCOMES.SPEC_CANNOT_DECIDE;
100
100
 
101
+ /** The two outcomes that REQUIRE explicit conflict evidence: a non-empty
102
+ * `conflictingCriteria` array of criterion ids (SPEC-AUTHORITY-CONFLICT-EVIDENCE).
103
+ * The enforcer and the divergence guard bind to this set directly; the guard
104
+ * also pins the producer contract prose (`agents/judge.agent.md`) to it, so
105
+ * producer and enforcer cannot silently diverge. SPEC-AUTHORITY-CONFLICT-EVIDENCE
106
+ * in this module is the governing rule. */
107
+ export const SPEC_AUTHORITY_CONFLICT_OUTCOMES = Object.freeze([
108
+ SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS,
109
+ SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS,
110
+ ]);
111
+
101
112
  /**
102
113
  * Does an outcome require the loop to stop at the human-spec-decision state?
103
114
  * Only `spec_cannot_decide` does — a finding/remediation conflict alone never
@@ -364,15 +375,17 @@ export function validateSpecAuthorityDecision(decision, { specDigest, headSha, c
364
375
  // The two conflict outcomes require explicit conflict evidence: a non-empty
365
376
  // conflictingCriteria drawn from the spec. Autonomous rejection is only
366
377
  // legitimate when it names what the finding/remedy conflicts with.
367
- const isConflict =
368
- d.outcome === SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS ||
369
- d.outcome === SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS;
378
+ const isConflict = SPEC_AUTHORITY_CONFLICT_OUTCOMES.includes(d.outcome);
370
379
  let conflictingCriteria = [];
371
380
  if (isConflict) {
372
- const conflicts = normalizeIdSet(d.conflictingCriteria, "decision.conflictingCriteria");
373
- if (conflicts.size === 0) {
374
- throw new Error(`SPEC-AUTHORITY-CONFLICT-EVIDENCE: ${d.outcome} decision requires non-empty conflictingCriteria (explicit conflict evidence; fail closed)`);
381
+ // Absent, non-array, and empty all mean "no conflict evidence". Route them
382
+ // to the named rule (not the generic id-set shape error) so a human hitting
383
+ // this is pointed at the fix — populate the field — rather than a hand-edit;
384
+ // naming the criteria in `rationale` prose alone does not satisfy it.
385
+ if (!Array.isArray(d.conflictingCriteria) || d.conflictingCriteria.length === 0) {
386
+ throw new Error(`SPEC-AUTHORITY-CONFLICT-EVIDENCE: ${d.outcome} decision requires a non-empty conflictingCriteria array of criterion ids (explicit conflict evidence; naming them in rationale prose alone does not satisfy it; fail closed)`);
375
387
  }
388
+ const conflicts = normalizeIdSet(d.conflictingCriteria, "decision.conflictingCriteria");
376
389
  const unknownConflicts = [...conflicts].filter((id) => !fullCriteria.has(id));
377
390
  if (unknownConflicts.length > 0) {
378
391
  throw new Error(`spec-authority decision.conflictingCriteria names unknown criterion id(s): ${unknownConflicts.join(", ")}`);