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

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.
@@ -100,7 +100,19 @@ function normalizeGateComment(summary = null) {
100
100
  };
101
101
  }
102
102
 
103
- function toGateStatus(comment, marker, currentHeadSha) {
103
+ // `unresolvedGateThreadCount` (ADR 0088): the gate-authored review-thread
104
+ // invariant close-gate-findings.mjs/ready-for-review.mjs already enforce
105
+ // (GATE-EXEC-FINDING-THREADS) — a clean marker verdict alone is not
106
+ // sufficient while a gate-authored thread (e.g. an unanswered or
107
+ // not-yet-judge-rejected `question`) still dangles. Folded ONLY into
108
+ // `currentHeadClean` here, never `cleanEvidenceExists`: `undefined`/`null`
109
+ // (the caller did not supply the signal — preApprovalGate's own
110
+ // toGateStatus call never does) preserves the marker-only definition
111
+ // exactly, so only draftGate's `currentHeadClean` — the one field that
112
+ // gates MARK_READY_FOR_REVIEW below — gains this guard. -1 (fail-closed
113
+ // "could not read thread state") counts as NOT clean, same as any positive
114
+ // count.
115
+ function toGateStatus(comment, marker, currentHeadSha, unresolvedGateThreadCount) {
104
116
  const normalizedComment = normalizeGateComment(comment);
105
117
  const normalizedMarker = normalizeGateComment(marker);
106
118
  const markerHeadMatches = normalizedMarker.headSha !== null
@@ -109,6 +121,26 @@ function toGateStatus(comment, marker, currentHeadSha) {
109
121
  const anyVisible = normalizedComment.visible || normalizedMarker.visible;
110
122
 
111
123
  const cleanEvidenceExists = normalizedComment.visible && normalizedComment.verdict === "clean" && normalizedComment.headSha !== null;
124
+ // Only null/undefined means "the caller supplied no signal" (preApprovalGate's
125
+ // own toGateStatus call never passes this argument). Anything else that is
126
+ // not a non-negative integer — NaN, a float, a string, a negative number
127
+ // other than -1 — is a malformed signal from a future caller, treated the
128
+ // same as -1 (unreadable) rather than "no signal": this is a public core
129
+ // export, and failing open on a malformed count would let a broken caller
130
+ // report currentHeadClean=true with a dangling thread.
131
+ const hasThreadSignal = unresolvedGateThreadCount !== null && unresolvedGateThreadCount !== undefined;
132
+ const safeThreadCount = hasThreadSignal
133
+ ? (Number.isInteger(unresolvedGateThreadCount) && unresolvedGateThreadCount >= 0 ? unresolvedGateThreadCount : -1)
134
+ : null;
135
+ const gateThreadsClean = !hasThreadSignal || safeThreadCount === 0;
136
+ // -1 is a distinct signal from a real positive count: it means the caller
137
+ // could not even READ the thread state (API failure) or supplied a
138
+ // malformed value, not that a thread is known to dangle. Both still block
139
+ // MARK_READY_FOR_REVIEW, but the two get different reasons below (a real
140
+ // thread names its own remedy; an unreadable count names retrying the read
141
+ // instead).
142
+ const gateThreadCountUnreadable = safeThreadCount === -1;
143
+ const markerVerdictClean = normalizedMarker.visible && markerHeadMatches && normalizedMarker.verdict === "clean" && normalizedMarker.contractComplete;
112
144
 
113
145
  return {
114
146
  visible: normalizedComment.visible,
@@ -120,7 +152,18 @@ function toGateStatus(comment, marker, currentHeadSha) {
120
152
  findingsSummary: normalizedComment.findingsSummary ?? normalizedMarker.findingsSummary,
121
153
  nextAction: normalizedComment.nextAction ?? normalizedMarker.nextAction,
122
154
  contractComplete: normalizedMarker.visible && markerHeadMatches && normalizedMarker.contractComplete,
123
- currentHeadClean: normalizedMarker.visible && markerHeadMatches && normalizedMarker.verdict === "clean" && normalizedMarker.contractComplete,
155
+ currentHeadClean: markerVerdictClean && gateThreadsClean,
156
+ // ADR 0088: the marker verdict is clean on its own terms, but a
157
+ // gate-authored thread still dangles — distinct from "not clean at all"
158
+ // (draftGate below uses this to name the thread blocker directly rather
159
+ // than re-running the draft gate or blocking on CI, neither of which can
160
+ // ever clear a dangling thread). Excludes the -1 (unreadable) case below,
161
+ // which gets its own distinct reason.
162
+ markerCleanThreadsUnresolved: markerVerdictClean && !gateThreadsClean && !gateThreadCountUnreadable,
163
+ // The thread-state READ itself failed (-1) rather than reporting a real
164
+ // dangling thread — MARK_READY_FOR_REVIEW stays forbidden either way, but
165
+ // this names "retry the read" instead of "resolve a thread".
166
+ markerCleanThreadStateUnreadable: markerVerdictClean && gateThreadCountUnreadable,
124
167
  cleanEvidenceExists,
125
168
  };
126
169
  }
@@ -624,11 +667,12 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
624
667
  // grant boundary here after gating CI/threads, and a significant
625
668
  // post-convergence change routes to a rerequest (a new cycle) before this
626
669
  // 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).
670
+ // - postConvergenceReviewSuppressed: the caller verified a carried
671
+ // convergence (or an operator suppression marker) for this head: no
672
+ // outstanding request, zero unresolved threads, and a provably docs-only
673
+ // or integrate-only delta since Copilot's last submitted review, so the
674
+ // prior converged review stands for this head (never derived here from
675
+ // other snapshot facts).
632
676
  // Fail closed on ANY non-outstanding status, not only the literal "none":
633
677
  // this is the independent gate-ENTRY re-check, so it must not trust the
634
678
  // caller's status string. A non-canonical/unknown value ("", "unavailable",
@@ -723,6 +767,67 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
723
767
  });
724
768
  }
725
769
 
770
+ /**
771
+ * Boundaries at which a PR must carry clean draft_gate evidence; without it
772
+ * the only legal next action is reconcile_draft_gate
773
+ * (skills/docs/pr-lifecycle-contract.md, LIFECYCLE-FAIL-CLOSED).
774
+ */
775
+ const DRAFT_GATE_EVIDENCE_GUARDED_BOUNDARIES = Object.freeze([
776
+ PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
777
+ PR_CHECKPOINT.FEEDBACK_RESOLUTION,
778
+ PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
779
+ PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
780
+ PR_CHECKPOINT.FINAL_APPROVAL_READY,
781
+ ]);
782
+
783
+ function applyDraftGateEvidenceGuard(result, { prDraft = false } = {}) {
784
+ if (!result || typeof result !== "object") {
785
+ return result;
786
+ }
787
+ // The PR is still draft: absent/not-yet-clean draft_gate evidence is the
788
+ // EXPECTED state while draft_gate remediation is in progress (e.g. a fixer
789
+ // mid-way through COMPLETE_FIXER_DISPOSITION for threads it tackled from an
790
+ // earlier draft_gate round) — the PR_DRAFT branch above already owns this
791
+ // state (RUN_DRAFT_GATE / MARK_READY_FOR_REVIEW). This guard exists for the
792
+ // POST-draft merge-path boundaries only (mirrors the wrapper's own
793
+ // prDraft skip for the title-marker guard, below); rewriting an in-progress
794
+ // draft-side result to reconcile_draft_gate would misdirect the fixer.
795
+ if (prDraft) {
796
+ return result;
797
+ }
798
+ if (result.draftGate?.cleanEvidenceExists === true) {
799
+ return result;
800
+ }
801
+ if (!DRAFT_GATE_EVIDENCE_GUARDED_BOUNDARIES.includes(result.gateBoundary)) {
802
+ return result;
803
+ }
804
+
805
+ const allowedNextActions = [];
806
+ const forbiddenActions = [];
807
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE]);
808
+ pushUnique(forbiddenActions, [
809
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
810
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
811
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
812
+ PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW,
813
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
814
+ PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
815
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
816
+ ]);
817
+
818
+ return {
819
+ ...result,
820
+ gateBoundary: PR_CHECKPOINT.DRAFT_GATE_NEEDED,
821
+ nextAction: PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE,
822
+ allowedNextActions,
823
+ forbiddenActions,
824
+ reason: result.draftGate?.anyVisible
825
+ ? "Clean draft_gate evidence is required before merge (no gate exemptions, #579). A draft_gate comment exists but is not clean; convert the PR back to draft before re-running draft_gate, or clear the existing evidence before running reconcile_draft_gate."
826
+ : "Clean draft_gate evidence is required before merge (no gate exemptions, #579). No visible clean draft_gate comment exists for this PR; run reconcile_draft_gate before proceeding.",
827
+ gateEvidenceNote: null,
828
+ };
829
+ }
830
+
726
831
  /**
727
832
  * Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
728
833
  * at the pre-approval / final-approval boundary for non-draft PRs.
@@ -731,31 +836,38 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
731
836
  * sites (defense in depth); this wrapper additionally covers the pre-approval
732
837
  * gate boundary, which is reached before any pre-approval evidence exists and so
733
838
  * is not protected by the inline checks.
839
+ *
840
+ * Invariant: every return path passes through `applyDraftGateEvidenceGuard`,
841
+ * which rewrites the five DRAFT_GATE_EVIDENCE_GUARDED_BOUNDARIES boundaries to
842
+ * DRAFT_GATE_NEEDED / reconcile_draft_gate whenever clean draft_gate evidence
843
+ * is absent — a still-draft PR is exempted (ADR 0088): absent/not-yet-clean
844
+ * draft_gate evidence is the normal state while draft_gate remediation is in
845
+ * progress, and the PR_DRAFT branch already owns that state.
734
846
  */
735
847
  export function evaluatePrGateCoordination(input = {}) {
736
848
  const result = evaluatePrGateCoordinationCore(input);
849
+ const prDraft = input.prDraft === true;
737
850
 
738
851
  const unsettledReviewResult = applyUnsettledCopilotReviewEntryGuard(input, result);
739
852
  if (unsettledReviewResult) {
740
- return unsettledReviewResult;
853
+ return applyDraftGateEvidenceGuard(unsettledReviewResult, { prDraft });
741
854
  }
742
855
 
743
- const prDraft = input.prDraft === true;
744
856
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
745
857
  // Draft PRs may legitimately carry a WIP title; the marker only blocks once
746
858
  // the PR has left draft and is at a pre-approval/final-approval boundary.
747
859
  if (prDraft || !result || typeof result !== "object") {
748
- return result;
860
+ return applyDraftGateEvidenceGuard(result, { prDraft });
749
861
  }
750
862
  if (!TITLE_MARKER_GUARDED_BOUNDARIES.includes(result.gateBoundary)) {
751
- return result;
863
+ return applyDraftGateEvidenceGuard(result, { prDraft });
752
864
  }
753
865
  const markers = findBlockingTitleMarkers(prTitle);
754
866
  if (markers.length === 0) {
755
- return result;
867
+ return applyDraftGateEvidenceGuard(result, { prDraft });
756
868
  }
757
869
 
758
- return buildTitleMarkerBlockedResult({
870
+ return applyDraftGateEvidenceGuard(buildTitleMarkerBlockedResult({
759
871
  input,
760
872
  currentHeadSha: result.currentHeadSha ?? null,
761
873
  draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
@@ -765,7 +877,7 @@ export function evaluatePrGateCoordination(input = {}) {
765
877
  conflictFiles: result.conflictFiles ?? [],
766
878
  markers,
767
879
  refinementArtifact: result.refinementArtifact ?? null,
768
- });
880
+ }), { prDraft });
769
881
  }
770
882
 
771
883
  function evaluatePrGateCoordinationCore(input = {}) {
@@ -775,14 +887,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
775
887
  const prDraft = input.prDraft === true;
776
888
  const prClosed = input.prClosed === true;
777
889
  const prMerged = input.prMerged === true;
778
- const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
779
- // Operator-authorized post-convergence suppression: set only when the
780
- // caller has verified an explicit prior withdrawal (withdraw-copilot-review-
781
- // request.mjs) recorded a suppression marker for this EXACT head, proving the
782
- // delta since Copilot's last submitted review is a pure doc/prose bump. Never
783
- // derived here from other snapshot facts this evaluator trusts the caller's
784
- // verification rather than re-deriving it, so it cannot become an automatic
785
- // loosening of the round-below-cap precondition.
890
+ const copilotConvergenceOk = input.copilotConvergenceOk === true;
891
+ // Carried convergence: set only when the caller verified, through the
892
+ // shared carried-convergence predicate the Copilot request tool also uses,
893
+ // that the prior converged Copilot review still stands for this head (no
894
+ // outstanding request, zero unresolved threads, a provably docs-only or
895
+ // integrate-only delta), or that an operator suppression marker re-verifies
896
+ // on the same terms. Never derived here from other snapshot facts: this
897
+ // evaluator trusts the caller's verification, so the request tool and the
898
+ // gate cannot disagree about the same head.
786
899
  const postConvergenceReviewSuppressed = input.postConvergenceReviewSuppressed === true;
787
900
  // maxCopilotRounds: 0 disables the external Copilot review gate entirely
788
901
  // (for repos without Copilot / local-harness-only review). It reuses the
@@ -833,9 +946,20 @@ function evaluatePrGateCoordinationCore(input = {}) {
833
946
  const refinementArtifactStatus = normalizeRefinementArtifactStatus(refinementArtifact?.status);
834
947
  const refinementLinkedIssue = Number.isInteger(refinementArtifact?.linkedIssue) ? refinementArtifact.linkedIssue : null;
835
948
 
836
- const effectiveLifecycleState = lifecycleState;
837
-
838
- const draftGate = toGateStatus(input.draftGate, input.draftGateMarker, currentHeadSha);
949
+ // Keep the shared loop's body-feedback signal intact. Gate entry alone may
950
+ // reinterpret a thread-clean, current-head review that the shared convergence
951
+ // evaluator accepts (notably a conductor-overridable blue review).
952
+ const gateEntryConverged = copilotConvergenceOk
953
+ && input.copilotReviewOnCurrentHead === true
954
+ && unresolvedThreadCount === 0;
955
+ const effectiveLifecycleState = gateEntryConverged && lifecycleState === STATE.UNRESOLVED_FEEDBACK_PRESENT
956
+ ? STATE.READY_TO_REREQUEST_REVIEW
957
+ : (gateEntryConverged && lifecycleState === STATE.ROUND_CAP_REACHED
958
+ ? STATE.ROUND_CAP_CLEAN_FALLBACK
959
+ : lifecycleState);
960
+ const sameHeadCleanConverged = input.sameHeadCleanConverged === true || gateEntryConverged;
961
+
962
+ const draftGate = toGateStatus(input.draftGate, input.draftGateMarker, currentHeadSha, input.unresolvedGateThreadCount);
839
963
  const preApprovalGate = toGateStatus(input.preApprovalGate, input.preApprovalGateMarker, currentHeadSha);
840
964
  const draftGateAlreadySatisfied = !prDraft && (draftGate?.cleanEvidenceExists ?? false);
841
965
 
@@ -1118,7 +1242,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1118
1242
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1119
1243
  loopDisposition: DISPOSITION.BLOCKED,
1120
1244
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1121
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1245
+ draftGateAlreadySatisfied,
1122
1246
  draftGate,
1123
1247
  preApprovalGate,
1124
1248
  allowedNextActions,
@@ -1138,6 +1262,83 @@ function evaluatePrGateCoordinationCore(input = {}) {
1138
1262
  PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1139
1263
  ];
1140
1264
 
1265
+ // ADR 0088: the round's draft_gate marker verdict is ALREADY clean — CI
1266
+ // pending/failing or re-running draft_gate can never clear a dangling
1267
+ // gate-authored thread (e.g. an answered, judge-rejected `question`
1268
+ // close-gate-findings has not yet reject-closed), so this must run BEFORE
1269
+ // the CI-status checks below: neither a CI-blocked nor a run_draft_gate
1270
+ // result would name the real blocker, and a loop that followed
1271
+ // run_draft_gate would re-run a gate round that already passed.
1272
+ // reply_resolve_review_threads (FEEDBACK_RESOLUTION) is reused as-is —
1273
+ // resolving the thread (reject-close or fixer triage) is exactly what
1274
+ // this action already means on the post-draft path.
1275
+ if (draftGate.markerCleanThreadsUnresolved) {
1276
+ pushUnique(allowedNextActions, [
1277
+ PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS,
1278
+ // The reason text names "stop for the operator" as the remedy for an
1279
+ // unanswered question that cannot be answered — that escalation must
1280
+ // be a legal next action, not just prose an allowedNextActions-honoring
1281
+ // conductor has no sanctioned way to take.
1282
+ PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
1283
+ ]);
1284
+ pushUnique(forbiddenActions, [
1285
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1286
+ ...draftReviewForbidden,
1287
+ ]);
1288
+ return buildResult({
1289
+ repo: input.repo ?? null,
1290
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1291
+ currentHeadSha,
1292
+ lifecycleState: lifecycleState || STATE.PR_DRAFT,
1293
+ loopDisposition: DISPOSITION.UNRESOLVED_FEEDBACK,
1294
+ gateBoundary: PR_CHECKPOINT.FEEDBACK_RESOLUTION,
1295
+ draftGateAlreadySatisfied,
1296
+ draftGate,
1297
+ preApprovalGate,
1298
+ allowedNextActions,
1299
+ forbiddenActions,
1300
+ nextAction: PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS,
1301
+ reason: "The PR is still draft and this round's draft_gate verdict is clean, but a gate-authored review thread is still unresolved (ADR 0088) — reply/resolve it rather than re-running the draft gate. An open defect thread is resolved via fixer fix-close, or the disposition pass's defer-close (close-gate-findings) once eligible. A question thread's remedy depends on the judge's disposition: unanswered — post an answer reply, then rerun close-gate-findings, which reject-closes it if the judge disposed it reject; answered and judge-disposed reject — rerun close-gate-findings to reject-close it; answered and judge-disposed act — resolved by the fixer's own answer-and-resolve path; judge-deferred, or ambiguous/unrecorded disposition — needs an operator decision, no automated path.",
1302
+ mergeStateStatus,
1303
+ conflictFiles,
1304
+ refinementArtifact,
1305
+ copilotReviewRoundCount,
1306
+ });
1307
+ }
1308
+
1309
+ // Distinct from markerCleanThreadsUnresolved above: the thread-state READ
1310
+ // itself failed (unresolvedGateThreadCount === -1), not a real dangling
1311
+ // thread — naming "resolve the thread" would be misleading when there is
1312
+ // no known thread to resolve. MARK_READY_FOR_REVIEW stays forbidden
1313
+ // either way (draftReviewForbidden); reply_resolve_review_threads is
1314
+ // reused as-is rather than adding a new action for "retry the read".
1315
+ if (draftGate.markerCleanThreadStateUnreadable) {
1316
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS]);
1317
+ pushUnique(forbiddenActions, [
1318
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1319
+ ...draftReviewForbidden,
1320
+ ]);
1321
+ return buildResult({
1322
+ repo: input.repo ?? null,
1323
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1324
+ currentHeadSha,
1325
+ lifecycleState: lifecycleState || STATE.PR_DRAFT,
1326
+ loopDisposition: DISPOSITION.UNRESOLVED_FEEDBACK,
1327
+ gateBoundary: PR_CHECKPOINT.FEEDBACK_RESOLUTION,
1328
+ draftGateAlreadySatisfied,
1329
+ draftGate,
1330
+ preApprovalGate,
1331
+ allowedNextActions,
1332
+ forbiddenActions,
1333
+ nextAction: PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS,
1334
+ reason: "The PR is still draft and this round's draft_gate verdict is clean, but this round's gate-authored review-thread count could not be read (API failure) — could not read review-thread state; re-run when API connectivity is restored, rather than treating an unreadable count as either clean or a real dangling thread.",
1335
+ mergeStateStatus,
1336
+ conflictFiles,
1337
+ refinementArtifact,
1338
+ copilotReviewRoundCount,
1339
+ });
1340
+ }
1341
+
1141
1342
  if (!draftGate.currentHeadClean && draftGateRequireCi) {
1142
1343
  if (ciStatus === "failure") {
1143
1344
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
@@ -1152,7 +1353,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1152
1353
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1153
1354
  loopDisposition: DISPOSITION.BLOCKED,
1154
1355
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1155
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1356
+ draftGateAlreadySatisfied,
1156
1357
  draftGate,
1157
1358
  preApprovalGate,
1158
1359
  allowedNextActions,
@@ -1178,7 +1379,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1178
1379
  lifecycleState: STATE.WAITING_FOR_CI,
1179
1380
  loopDisposition: DISPOSITION.PENDING,
1180
1381
  gateBoundary: PR_CHECKPOINT.DRAFT_REVIEW,
1181
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1382
+ draftGateAlreadySatisfied,
1182
1383
  draftGate,
1183
1384
  preApprovalGate,
1184
1385
  allowedNextActions,
@@ -1250,7 +1451,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1250
1451
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1251
1452
  loopDisposition: DISPOSITION.BLOCKED,
1252
1453
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1253
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1454
+ draftGateAlreadySatisfied,
1254
1455
  draftGate,
1255
1456
  preApprovalGate,
1256
1457
  allowedNextActions,
@@ -1270,7 +1471,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1270
1471
  return buildTitleMarkerBlockedResult({
1271
1472
  input,
1272
1473
  currentHeadSha,
1273
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1474
+ draftGateAlreadySatisfied,
1274
1475
  draftGate,
1275
1476
  preApprovalGate,
1276
1477
  mergeStateStatus,
@@ -1302,7 +1503,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1302
1503
  lifecycleState: effectiveLifecycleState,
1303
1504
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1304
1505
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1305
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1506
+ draftGateAlreadySatisfied,
1306
1507
  draftGate,
1307
1508
  preApprovalGate,
1308
1509
  allowedNextActions,
@@ -1326,7 +1527,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1326
1527
  lifecycleState: effectiveLifecycleState,
1327
1528
  loopDisposition: loopDisposition ?? DISPOSITION.ACTION_REQUIRED,
1328
1529
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1329
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1530
+ draftGateAlreadySatisfied,
1330
1531
  draftGate,
1331
1532
  preApprovalGate,
1332
1533
  allowedNextActions,
@@ -1453,7 +1654,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1453
1654
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1454
1655
  loopDisposition: DISPOSITION.BLOCKED,
1455
1656
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1456
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1657
+ draftGateAlreadySatisfied,
1457
1658
  draftGate,
1458
1659
  preApprovalGate,
1459
1660
  allowedNextActions,
@@ -1478,7 +1679,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1478
1679
  lifecycleState: STATE.WAITING_FOR_CI,
1479
1680
  loopDisposition: DISPOSITION.PENDING,
1480
1681
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1481
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1682
+ draftGateAlreadySatisfied,
1482
1683
  draftGate,
1483
1684
  preApprovalGate,
1484
1685
  allowedNextActions,
@@ -1512,7 +1713,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1512
1713
  lifecycleState: effectiveLifecycleState,
1513
1714
  loopDisposition: loopDisposition ?? DISPOSITION.ACTION_REQUIRED,
1514
1715
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1515
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1716
+ draftGateAlreadySatisfied,
1516
1717
  draftGate,
1517
1718
  preApprovalGate,
1518
1719
  allowedNextActions,
@@ -1533,7 +1734,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1533
1734
  return buildTitleMarkerBlockedResult({
1534
1735
  input,
1535
1736
  currentHeadSha,
1536
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1737
+ draftGateAlreadySatisfied,
1537
1738
  draftGate,
1538
1739
  preApprovalGate,
1539
1740
  mergeStateStatus,
@@ -1543,7 +1744,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1543
1744
  });
1544
1745
  }
1545
1746
 
1546
- if (!draftGate.cleanEvidenceExists && !roundCapReached) {
1747
+ if (!draftGate.cleanEvidenceExists) {
1547
1748
  return buildDraftGateNeededForMergeResult({
1548
1749
  input,
1549
1750
  currentHeadSha,
@@ -1571,14 +1772,14 @@ function evaluatePrGateCoordinationCore(input = {}) {
1571
1772
  lifecycleState: effectiveLifecycleState,
1572
1773
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1573
1774
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1574
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1775
+ draftGateAlreadySatisfied,
1575
1776
  draftGate,
1576
1777
  preApprovalGate,
1577
1778
  allowedNextActions,
1578
1779
  forbiddenActions,
1579
1780
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1580
1781
  reason: roundCapReached
1581
- ? `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`
1782
+ ? `Round-cap clean fallback (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}) with clean draft_gate evidence; the current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`
1582
1783
  : (ciStatus === "crediblyGreen"
1583
1784
  ? "The current head has both a clean settled review cycle and clean `pre_approval_gate` evidence, and its zero-suite CI state is accepted as credibly green, so the PR is at the final approval boundary."
1584
1785
  : "The current head has both a clean settled review cycle and clean `pre_approval_gate` evidence, so the PR is at the final approval boundary."),
@@ -1611,7 +1812,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1611
1812
  reason: roundCapReached
1612
1813
  ? `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}, so \`pre_approval_gate\` fallback is now the next legal boundary.`
1613
1814
  : (postConvergenceReviewSuppressed && !sameHeadCleanConverged
1614
- ? "An operator explicitly withdrew a stranded Copilot review request for this exact head, whose delta since Copilot's last submitted review is a provable pure doc/prose bump; the prior converged Copilot review still stands, so `pre_approval_gate` is now the next legal boundary."
1815
+ ? "The current head carries the prior converged Copilot review: no request is outstanding, no review thread is unresolved, and the delta since Copilot's last submitted review is provably docs-only or integrate-only, so `pre_approval_gate` is now the next legal boundary."
1615
1816
  : (ciStatus === "crediblyGreen"
1616
1817
  ? "The current head has a clean settled post-draft review cycle, and its zero-suite CI state is accepted as credibly green, so `pre_approval_gate` is now the next legal boundary."
1617
1818
  : "The current head has a clean settled post-draft review cycle, so `pre_approval_gate` is now the next legal boundary.")),
@@ -1647,7 +1848,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1647
1848
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1648
1849
  loopDisposition: DISPOSITION.BLOCKED,
1649
1850
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1650
- draftGateAlreadySatisfied: true,
1851
+ draftGateAlreadySatisfied,
1651
1852
  draftGate,
1652
1853
  preApprovalGate,
1653
1854
  allowedNextActions,
@@ -1671,7 +1872,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1671
1872
  lifecycleState: STATE.WAITING_FOR_CI,
1672
1873
  loopDisposition: DISPOSITION.PENDING,
1673
1874
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1674
- draftGateAlreadySatisfied: true,
1875
+ draftGateAlreadySatisfied,
1675
1876
  draftGate,
1676
1877
  preApprovalGate,
1677
1878
  allowedNextActions,
@@ -1689,7 +1890,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1689
1890
  return buildTitleMarkerBlockedResult({
1690
1891
  input,
1691
1892
  currentHeadSha,
1692
- draftGateAlreadySatisfied: true,
1893
+ draftGateAlreadySatisfied,
1693
1894
  draftGate,
1694
1895
  preApprovalGate,
1695
1896
  mergeStateStatus,
@@ -1700,11 +1901,12 @@ function evaluatePrGateCoordinationCore(input = {}) {
1700
1901
  }
1701
1902
  // Mirror LOW_SIGNAL_CONVERGED: a clean current head with no clean
1702
1903
  // draft_gate evidence must reconcile the draft gate rather than jump to
1703
- // final approval. This keeps the core handler consistent with the
1704
- // detect-pr-gate-coordination-state post-pass, which unconditionally
1705
- // downgrades FINAL_APPROVAL_READY DRAFT_GATE_NEEDED when
1706
- // draftGate.cleanEvidenceExists is false (no ROUND_CAP_CLEAN_FALLBACK
1707
- // exemption). Without this guard the final-approval-without-draft-gate
1904
+ // final approval. This is the SAME rule applyDraftGateEvidenceGuard
1905
+ // enforces generically for every DRAFT_GATE_EVIDENCE_GUARDED_BOUNDARIES
1906
+ // boundary on a non-draft PR (draftGate.cleanEvidenceExists false →
1907
+ // reconcile_draft_gate); this inline check covers the same outcome for
1908
+ // FINAL_APPROVAL_READY specifically, before that wrapper-level guard
1909
+ // runs. Without this guard the final-approval-without-draft-gate
1708
1910
  // branch is dead through the real script and asserts behavior it never
1709
1911
  // produces.
1710
1912
  if (!draftGate.cleanEvidenceExists) {
@@ -1737,13 +1939,13 @@ function evaluatePrGateCoordinationCore(input = {}) {
1737
1939
  lifecycleState: effectiveLifecycleState,
1738
1940
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1739
1941
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1740
- draftGateAlreadySatisfied: true,
1942
+ draftGateAlreadySatisfied,
1741
1943
  draftGate,
1742
1944
  preApprovalGate,
1743
1945
  allowedNextActions,
1744
1946
  forbiddenActions,
1745
1947
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1746
- reason: `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
1948
+ reason: `Round-cap clean fallback (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${describeAcceptedCiState(ciStatus, preApprovalRequireCi)}) with clean draft_gate evidence; the current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
1747
1949
  mergeStateStatus,
1748
1950
  conflictFiles,
1749
1951
  refinementArtifact,
@@ -1764,7 +1966,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1764
1966
  lifecycleState: effectiveLifecycleState,
1765
1967
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1766
1968
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1767
- draftGateAlreadySatisfied: true,
1969
+ draftGateAlreadySatisfied,
1768
1970
  draftGate,
1769
1971
  preApprovalGate,
1770
1972
  allowedNextActions,
@@ -1807,7 +2009,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
1807
2009
  // human-read reason/evidence text for the latter case would be a false CI
1808
2010
  // claim, so describe the actual grant basis instead.
1809
2011
  const ciClause = ciStatus === "success" ? "green CI" : "CI not required by config";
1810
- if (unresolvedThreadCount === 0 && ciConfirmedGreen) {
2012
+ // No current-head Copilot review at the cap IS the round-cap clean
2013
+ // fallback, so the convergence evaluator's absent-review refusal must not
2014
+ // block this grant; a current-head review with findings still does. An
2015
+ // unknown head never opens the absent-review branch. Nor does an
2016
+ // earlier-head body-only finding without a disposition record: the caller
2017
+ // must state it absent (fail closed when the fact is missing).
2018
+ const absentReviewFallback = currentHeadSha !== null && input.copilotReviewOnCurrentHead === false
2019
+ && input.copilotPriorHeadBodyFeedbackUnresolved === false;
2020
+ if (unresolvedThreadCount === 0 && ciConfirmedGreen && (copilotConvergenceOk || absentReviewFallback)) {
1811
2021
  if (preApprovalGate.currentHeadClean) {
1812
2022
  // Inline title-marker check, mirroring ROUND_CAP_CLEAN_FALLBACK: the
1813
2023
  // outer post-pass guards FINAL_APPROVAL_READY and
@@ -1819,7 +2029,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1819
2029
  return buildTitleMarkerBlockedResult({
1820
2030
  input,
1821
2031
  currentHeadSha,
1822
- draftGateAlreadySatisfied: true,
2032
+ draftGateAlreadySatisfied,
1823
2033
  draftGate,
1824
2034
  preApprovalGate,
1825
2035
  mergeStateStatus,
@@ -1859,13 +2069,13 @@ function evaluatePrGateCoordinationCore(input = {}) {
1859
2069
  lifecycleState: effectiveLifecycleState,
1860
2070
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1861
2071
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1862
- draftGateAlreadySatisfied: true,
2072
+ draftGateAlreadySatisfied,
1863
2073
  draftGate,
1864
2074
  preApprovalGate,
1865
2075
  allowedNextActions,
1866
2076
  forbiddenActions,
1867
2077
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1868
- reason: `Round-cap exhaustion fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciClause}). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
2078
+ reason: `Round-cap exhaustion fallback (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciClause}) with clean draft_gate evidence; the current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
1869
2079
  mergeStateStatus,
1870
2080
  conflictFiles,
1871
2081
  refinementArtifact,
@@ -1887,7 +2097,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1887
2097
  lifecycleState: effectiveLifecycleState,
1888
2098
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1889
2099
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1890
- draftGateAlreadySatisfied: true,
2100
+ draftGateAlreadySatisfied,
1891
2101
  draftGate,
1892
2102
  preApprovalGate,
1893
2103
  allowedNextActions,
@@ -1914,7 +2124,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1914
2124
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1915
2125
  loopDisposition: DISPOSITION.BLOCKED,
1916
2126
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1917
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2127
+ draftGateAlreadySatisfied,
1918
2128
  draftGate,
1919
2129
  preApprovalGate,
1920
2130
  allowedNextActions,
@@ -1938,7 +2148,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1938
2148
  lifecycleState: STATE.WAITING_FOR_CI,
1939
2149
  loopDisposition: DISPOSITION.PENDING,
1940
2150
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1941
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2151
+ draftGateAlreadySatisfied,
1942
2152
  draftGate,
1943
2153
  preApprovalGate,
1944
2154
  allowedNextActions,
@@ -1956,7 +2166,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1956
2166
  return buildTitleMarkerBlockedResult({
1957
2167
  input,
1958
2168
  currentHeadSha,
1959
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2169
+ draftGateAlreadySatisfied,
1960
2170
  draftGate,
1961
2171
  preApprovalGate,
1962
2172
  mergeStateStatus,
@@ -1994,7 +2204,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1994
2204
  lifecycleState: effectiveLifecycleState,
1995
2205
  loopDisposition: DISPOSITION.DONE,
1996
2206
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1997
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2207
+ draftGateAlreadySatisfied,
1998
2208
  draftGate,
1999
2209
  preApprovalGate,
2000
2210
  allowedNextActions,
@@ -498,6 +498,27 @@ export function validateSpecAuthorityVerdict(verdict, { findingsCount, criterion
498
498
  };
499
499
  }
500
500
 
501
+ /**
502
+ * Dispose every spec-authority `finding_conflicts` finding `reject`, whatever
503
+ * its relevance disposition. Mutates the judge-enriched findings in place; the
504
+ * judge pass and the durable findings-log writer share it so the ledger's
505
+ * `judgeDisposition` matches what the judge pass enforces.
506
+ *
507
+ * @param {object[]} findings — judge-enriched findings, indexed as the verdict
508
+ * @param {Iterable<number>} conflictIndices — the `finding_conflicts` indexes
509
+ * @returns {object[]} the same `findings` array
510
+ */
511
+ export function rejectFindingConflicts(findings, conflictIndices) {
512
+ const rejected = new Set(conflictIndices);
513
+ for (const [i, f] of findings.entries()) {
514
+ if (rejected.has(i) && f.judgeDisposition !== "reject") {
515
+ f.judgeRationale = `spec-authority finding_conflicts: rejected against the spec (was relevance-${f.judgeDisposition}) — ${f.judgeRationale ?? ""}`.trim();
516
+ f.judgeDisposition = "reject";
517
+ }
518
+ }
519
+ return findings;
520
+ }
521
+
501
522
  /**
502
523
  * Resolve which prior criterion approvals survive a revision change. This is the
503
524
  * one authority for both invalidation rules: