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

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
  }
@@ -467,6 +510,9 @@ function buildResult({
467
510
  * @param {boolean} [params.postConvergenceSignificantChange=false] - significant
468
511
  * post-convergence changes on a newer head start a new review cycle and must
469
512
  * not be treated as round-cap clean-fallback suppression.
513
+ * @param {boolean} [params.postConvergenceReviewSuppressed=false] - the caller
514
+ * verified a carried convergence for this head, so no further Copilot request
515
+ * is due (mirrors applyUnsettledCopilotReviewEntryGuard).
470
516
  * @param {string} params.gateBoundary - current gate boundary
471
517
  * @returns {boolean}
472
518
  */
@@ -478,6 +524,7 @@ export function shouldGuardCopilotReviewRequest({
478
524
  sameHeadCleanConverged = false,
479
525
  roundCapCleanFallback = false,
480
526
  postConvergenceSignificantChange = false,
527
+ postConvergenceReviewSuppressed = false,
481
528
  gateBoundary,
482
529
  }) {
483
530
  const gateBoundariesRequiringCopilotFormalRequest = new Set([
@@ -497,6 +544,11 @@ export function shouldGuardCopilotReviewRequest({
497
544
  if (copilotReviewRequestStatus !== "none") {
498
545
  return false;
499
546
  }
547
+ // A carried convergence settles this head without a new Copilot round; the
548
+ // request tool would return a suppressed status, so a forced request livelocks.
549
+ if (postConvergenceReviewSuppressed === true) {
550
+ return false;
551
+ }
500
552
  // Durable signal: if Copilot was ever formally requested as a reviewer,
501
553
  // the current "none" status is from a fulfilled request (normal cycle),
502
554
  // not from a missing request. Do not guard the happy path.
@@ -624,18 +676,21 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
624
676
  // grant boundary here after gating CI/threads, and a significant
625
677
  // post-convergence change routes to a rerequest (a new cycle) before this
626
678
  // 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).
679
+ // - postConvergenceReviewSuppressed: the caller verified a carried
680
+ // convergence (or an operator suppression marker) for this head: no
681
+ // outstanding request, zero unresolved threads, and a converged latest
682
+ // Copilot review on an earlier head. By default that review carries
683
+ // whatever the delta (converged-once); under the strict setting only a
684
+ // provably docs-only or integrate-only delta carries. The prior converged
685
+ // review then stands for this head (never derived here from other
686
+ // snapshot facts).
632
687
  // Fail closed on ANY non-outstanding status, not only the literal "none":
633
688
  // this is the independent gate-ENTRY re-check, so it must not trust the
634
689
  // caller's status string. A non-canonical/unknown value ("", "unavailable",
635
690
  // "failed", a typo) with a grant-y lifecycleState and no current-head review
636
691
  // 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
692
+ // driven current-head review (or the impossible-further-round cap state, or a
693
+ // caller-verified carried convergence) exempts, so an agent that skips
639
694
  // the explicit Copilot round cannot reach a clean pre_approval verdict via any
640
695
  // grant-y lifecycleState (e.g. a stale/racy low_signal_converged label, or one
641
696
  // carried by prior-head rounds).
@@ -723,6 +778,67 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
723
778
  });
724
779
  }
725
780
 
781
+ /**
782
+ * Boundaries at which a PR must carry clean draft_gate evidence; without it
783
+ * the only legal next action is reconcile_draft_gate
784
+ * (skills/docs/pr-lifecycle-contract.md, LIFECYCLE-FAIL-CLOSED).
785
+ */
786
+ const DRAFT_GATE_EVIDENCE_GUARDED_BOUNDARIES = Object.freeze([
787
+ PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
788
+ PR_CHECKPOINT.FEEDBACK_RESOLUTION,
789
+ PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
790
+ PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
791
+ PR_CHECKPOINT.FINAL_APPROVAL_READY,
792
+ ]);
793
+
794
+ function applyDraftGateEvidenceGuard(result, { prDraft = false } = {}) {
795
+ if (!result || typeof result !== "object") {
796
+ return result;
797
+ }
798
+ // The PR is still draft: absent/not-yet-clean draft_gate evidence is the
799
+ // EXPECTED state while draft_gate remediation is in progress (e.g. a fixer
800
+ // mid-way through COMPLETE_FIXER_DISPOSITION for threads it tackled from an
801
+ // earlier draft_gate round) — the PR_DRAFT branch above already owns this
802
+ // state (RUN_DRAFT_GATE / MARK_READY_FOR_REVIEW). This guard exists for the
803
+ // POST-draft merge-path boundaries only (mirrors the wrapper's own
804
+ // prDraft skip for the title-marker guard, below); rewriting an in-progress
805
+ // draft-side result to reconcile_draft_gate would misdirect the fixer.
806
+ if (prDraft) {
807
+ return result;
808
+ }
809
+ if (result.draftGate?.cleanEvidenceExists === true) {
810
+ return result;
811
+ }
812
+ if (!DRAFT_GATE_EVIDENCE_GUARDED_BOUNDARIES.includes(result.gateBoundary)) {
813
+ return result;
814
+ }
815
+
816
+ const allowedNextActions = [];
817
+ const forbiddenActions = [];
818
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE]);
819
+ pushUnique(forbiddenActions, [
820
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
821
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
822
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
823
+ PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW,
824
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
825
+ PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
826
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
827
+ ]);
828
+
829
+ return {
830
+ ...result,
831
+ gateBoundary: PR_CHECKPOINT.DRAFT_GATE_NEEDED,
832
+ nextAction: PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE,
833
+ allowedNextActions,
834
+ forbiddenActions,
835
+ reason: result.draftGate?.anyVisible
836
+ ? "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."
837
+ : "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.",
838
+ gateEvidenceNote: null,
839
+ };
840
+ }
841
+
726
842
  /**
727
843
  * Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
728
844
  * at the pre-approval / final-approval boundary for non-draft PRs.
@@ -731,31 +847,38 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
731
847
  * sites (defense in depth); this wrapper additionally covers the pre-approval
732
848
  * gate boundary, which is reached before any pre-approval evidence exists and so
733
849
  * is not protected by the inline checks.
850
+ *
851
+ * Invariant: every return path passes through `applyDraftGateEvidenceGuard`,
852
+ * which rewrites the five DRAFT_GATE_EVIDENCE_GUARDED_BOUNDARIES boundaries to
853
+ * DRAFT_GATE_NEEDED / reconcile_draft_gate whenever clean draft_gate evidence
854
+ * is absent — a still-draft PR is exempted (ADR 0088): absent/not-yet-clean
855
+ * draft_gate evidence is the normal state while draft_gate remediation is in
856
+ * progress, and the PR_DRAFT branch already owns that state.
734
857
  */
735
858
  export function evaluatePrGateCoordination(input = {}) {
736
859
  const result = evaluatePrGateCoordinationCore(input);
860
+ const prDraft = input.prDraft === true;
737
861
 
738
862
  const unsettledReviewResult = applyUnsettledCopilotReviewEntryGuard(input, result);
739
863
  if (unsettledReviewResult) {
740
- return unsettledReviewResult;
864
+ return applyDraftGateEvidenceGuard(unsettledReviewResult, { prDraft });
741
865
  }
742
866
 
743
- const prDraft = input.prDraft === true;
744
867
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
745
868
  // Draft PRs may legitimately carry a WIP title; the marker only blocks once
746
869
  // the PR has left draft and is at a pre-approval/final-approval boundary.
747
870
  if (prDraft || !result || typeof result !== "object") {
748
- return result;
871
+ return applyDraftGateEvidenceGuard(result, { prDraft });
749
872
  }
750
873
  if (!TITLE_MARKER_GUARDED_BOUNDARIES.includes(result.gateBoundary)) {
751
- return result;
874
+ return applyDraftGateEvidenceGuard(result, { prDraft });
752
875
  }
753
876
  const markers = findBlockingTitleMarkers(prTitle);
754
877
  if (markers.length === 0) {
755
- return result;
878
+ return applyDraftGateEvidenceGuard(result, { prDraft });
756
879
  }
757
880
 
758
- return buildTitleMarkerBlockedResult({
881
+ return applyDraftGateEvidenceGuard(buildTitleMarkerBlockedResult({
759
882
  input,
760
883
  currentHeadSha: result.currentHeadSha ?? null,
761
884
  draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
@@ -765,7 +888,7 @@ export function evaluatePrGateCoordination(input = {}) {
765
888
  conflictFiles: result.conflictFiles ?? [],
766
889
  markers,
767
890
  refinementArtifact: result.refinementArtifact ?? null,
768
- });
891
+ }), { prDraft });
769
892
  }
770
893
 
771
894
  function evaluatePrGateCoordinationCore(input = {}) {
@@ -775,14 +898,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
775
898
  const prDraft = input.prDraft === true;
776
899
  const prClosed = input.prClosed === true;
777
900
  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.
901
+ const copilotConvergenceOk = input.copilotConvergenceOk === true;
902
+ // Carried convergence: set only when the caller verified, through the
903
+ // shared carried-convergence predicate the Copilot request tool also uses,
904
+ // that the prior converged Copilot review still stands for this head (no
905
+ // outstanding request, zero unresolved threads, and in strict mode a
906
+ // provably docs-only or integrate-only delta), or that an operator suppression marker re-verifies
907
+ // on the same terms. Never derived here from other snapshot facts: this
908
+ // evaluator trusts the caller's verification, so the request tool and the
909
+ // gate cannot disagree about the same head.
786
910
  const postConvergenceReviewSuppressed = input.postConvergenceReviewSuppressed === true;
787
911
  // maxCopilotRounds: 0 disables the external Copilot review gate entirely
788
912
  // (for repos without Copilot / local-harness-only review). It reuses the
@@ -833,9 +957,20 @@ function evaluatePrGateCoordinationCore(input = {}) {
833
957
  const refinementArtifactStatus = normalizeRefinementArtifactStatus(refinementArtifact?.status);
834
958
  const refinementLinkedIssue = Number.isInteger(refinementArtifact?.linkedIssue) ? refinementArtifact.linkedIssue : null;
835
959
 
836
- const effectiveLifecycleState = lifecycleState;
837
-
838
- const draftGate = toGateStatus(input.draftGate, input.draftGateMarker, currentHeadSha);
960
+ // Keep the shared loop's body-feedback signal intact. Gate entry alone may
961
+ // reinterpret a thread-clean, current-head review that the shared convergence
962
+ // evaluator accepts (notably a conductor-overridable blue review).
963
+ const gateEntryConverged = copilotConvergenceOk
964
+ && input.copilotReviewOnCurrentHead === true
965
+ && unresolvedThreadCount === 0;
966
+ const effectiveLifecycleState = gateEntryConverged && lifecycleState === STATE.UNRESOLVED_FEEDBACK_PRESENT
967
+ ? STATE.READY_TO_REREQUEST_REVIEW
968
+ : (gateEntryConverged && lifecycleState === STATE.ROUND_CAP_REACHED
969
+ ? STATE.ROUND_CAP_CLEAN_FALLBACK
970
+ : lifecycleState);
971
+ const sameHeadCleanConverged = input.sameHeadCleanConverged === true || gateEntryConverged;
972
+
973
+ const draftGate = toGateStatus(input.draftGate, input.draftGateMarker, currentHeadSha, input.unresolvedGateThreadCount);
839
974
  const preApprovalGate = toGateStatus(input.preApprovalGate, input.preApprovalGateMarker, currentHeadSha);
840
975
  const draftGateAlreadySatisfied = !prDraft && (draftGate?.cleanEvidenceExists ?? false);
841
976
 
@@ -1118,7 +1253,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1118
1253
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1119
1254
  loopDisposition: DISPOSITION.BLOCKED,
1120
1255
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1121
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1256
+ draftGateAlreadySatisfied,
1122
1257
  draftGate,
1123
1258
  preApprovalGate,
1124
1259
  allowedNextActions,
@@ -1138,6 +1273,83 @@ function evaluatePrGateCoordinationCore(input = {}) {
1138
1273
  PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1139
1274
  ];
1140
1275
 
1276
+ // ADR 0088: the round's draft_gate marker verdict is ALREADY clean — CI
1277
+ // pending/failing or re-running draft_gate can never clear a dangling
1278
+ // gate-authored thread (e.g. an answered, judge-rejected `question`
1279
+ // close-gate-findings has not yet reject-closed), so this must run BEFORE
1280
+ // the CI-status checks below: neither a CI-blocked nor a run_draft_gate
1281
+ // result would name the real blocker, and a loop that followed
1282
+ // run_draft_gate would re-run a gate round that already passed.
1283
+ // reply_resolve_review_threads (FEEDBACK_RESOLUTION) is reused as-is —
1284
+ // resolving the thread (reject-close or fixer triage) is exactly what
1285
+ // this action already means on the post-draft path.
1286
+ if (draftGate.markerCleanThreadsUnresolved) {
1287
+ pushUnique(allowedNextActions, [
1288
+ PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS,
1289
+ // The reason text names "stop for the operator" as the remedy for an
1290
+ // unanswered question that cannot be answered — that escalation must
1291
+ // be a legal next action, not just prose an allowedNextActions-honoring
1292
+ // conductor has no sanctioned way to take.
1293
+ PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
1294
+ ]);
1295
+ pushUnique(forbiddenActions, [
1296
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1297
+ ...draftReviewForbidden,
1298
+ ]);
1299
+ return buildResult({
1300
+ repo: input.repo ?? null,
1301
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1302
+ currentHeadSha,
1303
+ lifecycleState: lifecycleState || STATE.PR_DRAFT,
1304
+ loopDisposition: DISPOSITION.UNRESOLVED_FEEDBACK,
1305
+ gateBoundary: PR_CHECKPOINT.FEEDBACK_RESOLUTION,
1306
+ draftGateAlreadySatisfied,
1307
+ draftGate,
1308
+ preApprovalGate,
1309
+ allowedNextActions,
1310
+ forbiddenActions,
1311
+ nextAction: PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS,
1312
+ 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.",
1313
+ mergeStateStatus,
1314
+ conflictFiles,
1315
+ refinementArtifact,
1316
+ copilotReviewRoundCount,
1317
+ });
1318
+ }
1319
+
1320
+ // Distinct from markerCleanThreadsUnresolved above: the thread-state READ
1321
+ // itself failed (unresolvedGateThreadCount === -1), not a real dangling
1322
+ // thread — naming "resolve the thread" would be misleading when there is
1323
+ // no known thread to resolve. MARK_READY_FOR_REVIEW stays forbidden
1324
+ // either way (draftReviewForbidden); reply_resolve_review_threads is
1325
+ // reused as-is rather than adding a new action for "retry the read".
1326
+ if (draftGate.markerCleanThreadStateUnreadable) {
1327
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS]);
1328
+ pushUnique(forbiddenActions, [
1329
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1330
+ ...draftReviewForbidden,
1331
+ ]);
1332
+ return buildResult({
1333
+ repo: input.repo ?? null,
1334
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1335
+ currentHeadSha,
1336
+ lifecycleState: lifecycleState || STATE.PR_DRAFT,
1337
+ loopDisposition: DISPOSITION.UNRESOLVED_FEEDBACK,
1338
+ gateBoundary: PR_CHECKPOINT.FEEDBACK_RESOLUTION,
1339
+ draftGateAlreadySatisfied,
1340
+ draftGate,
1341
+ preApprovalGate,
1342
+ allowedNextActions,
1343
+ forbiddenActions,
1344
+ nextAction: PR_CHECKPOINT_ACTION.REPLY_RESOLVE_REVIEW_THREADS,
1345
+ 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.",
1346
+ mergeStateStatus,
1347
+ conflictFiles,
1348
+ refinementArtifact,
1349
+ copilotReviewRoundCount,
1350
+ });
1351
+ }
1352
+
1141
1353
  if (!draftGate.currentHeadClean && draftGateRequireCi) {
1142
1354
  if (ciStatus === "failure") {
1143
1355
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
@@ -1152,7 +1364,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1152
1364
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1153
1365
  loopDisposition: DISPOSITION.BLOCKED,
1154
1366
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1155
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1367
+ draftGateAlreadySatisfied,
1156
1368
  draftGate,
1157
1369
  preApprovalGate,
1158
1370
  allowedNextActions,
@@ -1178,7 +1390,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1178
1390
  lifecycleState: STATE.WAITING_FOR_CI,
1179
1391
  loopDisposition: DISPOSITION.PENDING,
1180
1392
  gateBoundary: PR_CHECKPOINT.DRAFT_REVIEW,
1181
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1393
+ draftGateAlreadySatisfied,
1182
1394
  draftGate,
1183
1395
  preApprovalGate,
1184
1396
  allowedNextActions,
@@ -1250,7 +1462,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1250
1462
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1251
1463
  loopDisposition: DISPOSITION.BLOCKED,
1252
1464
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1253
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1465
+ draftGateAlreadySatisfied,
1254
1466
  draftGate,
1255
1467
  preApprovalGate,
1256
1468
  allowedNextActions,
@@ -1270,7 +1482,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1270
1482
  return buildTitleMarkerBlockedResult({
1271
1483
  input,
1272
1484
  currentHeadSha,
1273
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1485
+ draftGateAlreadySatisfied,
1274
1486
  draftGate,
1275
1487
  preApprovalGate,
1276
1488
  mergeStateStatus,
@@ -1302,7 +1514,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1302
1514
  lifecycleState: effectiveLifecycleState,
1303
1515
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1304
1516
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1305
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1517
+ draftGateAlreadySatisfied,
1306
1518
  draftGate,
1307
1519
  preApprovalGate,
1308
1520
  allowedNextActions,
@@ -1326,7 +1538,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1326
1538
  lifecycleState: effectiveLifecycleState,
1327
1539
  loopDisposition: loopDisposition ?? DISPOSITION.ACTION_REQUIRED,
1328
1540
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1329
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1541
+ draftGateAlreadySatisfied,
1330
1542
  draftGate,
1331
1543
  preApprovalGate,
1332
1544
  allowedNextActions,
@@ -1453,7 +1665,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1453
1665
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1454
1666
  loopDisposition: DISPOSITION.BLOCKED,
1455
1667
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1456
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1668
+ draftGateAlreadySatisfied,
1457
1669
  draftGate,
1458
1670
  preApprovalGate,
1459
1671
  allowedNextActions,
@@ -1478,7 +1690,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1478
1690
  lifecycleState: STATE.WAITING_FOR_CI,
1479
1691
  loopDisposition: DISPOSITION.PENDING,
1480
1692
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1481
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1693
+ draftGateAlreadySatisfied,
1482
1694
  draftGate,
1483
1695
  preApprovalGate,
1484
1696
  allowedNextActions,
@@ -1512,7 +1724,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1512
1724
  lifecycleState: effectiveLifecycleState,
1513
1725
  loopDisposition: loopDisposition ?? DISPOSITION.ACTION_REQUIRED,
1514
1726
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1515
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1727
+ draftGateAlreadySatisfied,
1516
1728
  draftGate,
1517
1729
  preApprovalGate,
1518
1730
  allowedNextActions,
@@ -1533,7 +1745,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1533
1745
  return buildTitleMarkerBlockedResult({
1534
1746
  input,
1535
1747
  currentHeadSha,
1536
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1748
+ draftGateAlreadySatisfied,
1537
1749
  draftGate,
1538
1750
  preApprovalGate,
1539
1751
  mergeStateStatus,
@@ -1543,7 +1755,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1543
1755
  });
1544
1756
  }
1545
1757
 
1546
- if (!draftGate.cleanEvidenceExists && !roundCapReached) {
1758
+ if (!draftGate.cleanEvidenceExists) {
1547
1759
  return buildDraftGateNeededForMergeResult({
1548
1760
  input,
1549
1761
  currentHeadSha,
@@ -1571,14 +1783,14 @@ function evaluatePrGateCoordinationCore(input = {}) {
1571
1783
  lifecycleState: effectiveLifecycleState,
1572
1784
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1573
1785
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1574
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1786
+ draftGateAlreadySatisfied,
1575
1787
  draftGate,
1576
1788
  preApprovalGate,
1577
1789
  allowedNextActions,
1578
1790
  forbiddenActions,
1579
1791
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1580
1792
  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.`
1793
+ ? `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
1794
  : (ciStatus === "crediblyGreen"
1583
1795
  ? "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
1796
  : "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 +1823,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1611
1823
  reason: roundCapReached
1612
1824
  ? `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
1825
  : (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."
1826
+ ? "The current head carries the prior converged Copilot review (carriedConvergence names the source review): no request is outstanding, no review thread is unresolved, and the carry rule holds (converged-once by default; a provably docs-only or integrate-only delta under refinement.requireCopilotConvergenceAtLatestHead), so `pre_approval_gate` is now the next legal boundary."
1615
1827
  : (ciStatus === "crediblyGreen"
1616
1828
  ? "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
1829
  : "The current head has a clean settled post-draft review cycle, so `pre_approval_gate` is now the next legal boundary.")),
@@ -1647,7 +1859,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1647
1859
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1648
1860
  loopDisposition: DISPOSITION.BLOCKED,
1649
1861
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1650
- draftGateAlreadySatisfied: true,
1862
+ draftGateAlreadySatisfied,
1651
1863
  draftGate,
1652
1864
  preApprovalGate,
1653
1865
  allowedNextActions,
@@ -1671,7 +1883,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1671
1883
  lifecycleState: STATE.WAITING_FOR_CI,
1672
1884
  loopDisposition: DISPOSITION.PENDING,
1673
1885
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1674
- draftGateAlreadySatisfied: true,
1886
+ draftGateAlreadySatisfied,
1675
1887
  draftGate,
1676
1888
  preApprovalGate,
1677
1889
  allowedNextActions,
@@ -1689,7 +1901,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1689
1901
  return buildTitleMarkerBlockedResult({
1690
1902
  input,
1691
1903
  currentHeadSha,
1692
- draftGateAlreadySatisfied: true,
1904
+ draftGateAlreadySatisfied,
1693
1905
  draftGate,
1694
1906
  preApprovalGate,
1695
1907
  mergeStateStatus,
@@ -1700,11 +1912,12 @@ function evaluatePrGateCoordinationCore(input = {}) {
1700
1912
  }
1701
1913
  // Mirror LOW_SIGNAL_CONVERGED: a clean current head with no clean
1702
1914
  // 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
1915
+ // final approval. This is the SAME rule applyDraftGateEvidenceGuard
1916
+ // enforces generically for every DRAFT_GATE_EVIDENCE_GUARDED_BOUNDARIES
1917
+ // boundary on a non-draft PR (draftGate.cleanEvidenceExists false →
1918
+ // reconcile_draft_gate); this inline check covers the same outcome for
1919
+ // FINAL_APPROVAL_READY specifically, before that wrapper-level guard
1920
+ // runs. Without this guard the final-approval-without-draft-gate
1708
1921
  // branch is dead through the real script and asserts behavior it never
1709
1922
  // produces.
1710
1923
  if (!draftGate.cleanEvidenceExists) {
@@ -1737,13 +1950,13 @@ function evaluatePrGateCoordinationCore(input = {}) {
1737
1950
  lifecycleState: effectiveLifecycleState,
1738
1951
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1739
1952
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1740
- draftGateAlreadySatisfied: true,
1953
+ draftGateAlreadySatisfied,
1741
1954
  draftGate,
1742
1955
  preApprovalGate,
1743
1956
  allowedNextActions,
1744
1957
  forbiddenActions,
1745
1958
  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.`,
1959
+ 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
1960
  mergeStateStatus,
1748
1961
  conflictFiles,
1749
1962
  refinementArtifact,
@@ -1764,7 +1977,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1764
1977
  lifecycleState: effectiveLifecycleState,
1765
1978
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1766
1979
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1767
- draftGateAlreadySatisfied: true,
1980
+ draftGateAlreadySatisfied,
1768
1981
  draftGate,
1769
1982
  preApprovalGate,
1770
1983
  allowedNextActions,
@@ -1807,7 +2020,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
1807
2020
  // human-read reason/evidence text for the latter case would be a false CI
1808
2021
  // claim, so describe the actual grant basis instead.
1809
2022
  const ciClause = ciStatus === "success" ? "green CI" : "CI not required by config";
1810
- if (unresolvedThreadCount === 0 && ciConfirmedGreen) {
2023
+ // No current-head Copilot review at the cap IS the round-cap clean
2024
+ // fallback, so the convergence evaluator's absent-review refusal must not
2025
+ // block this grant; a current-head review with findings still does. An
2026
+ // unknown head never opens the absent-review branch. Nor does an
2027
+ // earlier-head body-only finding without a disposition record: the caller
2028
+ // must state it absent (fail closed when the fact is missing).
2029
+ const absentReviewFallback = currentHeadSha !== null && input.copilotReviewOnCurrentHead === false
2030
+ && input.copilotPriorHeadBodyFeedbackUnresolved === false;
2031
+ if (unresolvedThreadCount === 0 && ciConfirmedGreen && (copilotConvergenceOk || absentReviewFallback)) {
1811
2032
  if (preApprovalGate.currentHeadClean) {
1812
2033
  // Inline title-marker check, mirroring ROUND_CAP_CLEAN_FALLBACK: the
1813
2034
  // outer post-pass guards FINAL_APPROVAL_READY and
@@ -1819,7 +2040,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1819
2040
  return buildTitleMarkerBlockedResult({
1820
2041
  input,
1821
2042
  currentHeadSha,
1822
- draftGateAlreadySatisfied: true,
2043
+ draftGateAlreadySatisfied,
1823
2044
  draftGate,
1824
2045
  preApprovalGate,
1825
2046
  mergeStateStatus,
@@ -1859,13 +2080,13 @@ function evaluatePrGateCoordinationCore(input = {}) {
1859
2080
  lifecycleState: effectiveLifecycleState,
1860
2081
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1861
2082
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1862
- draftGateAlreadySatisfied: true,
2083
+ draftGateAlreadySatisfied,
1863
2084
  draftGate,
1864
2085
  preApprovalGate,
1865
2086
  allowedNextActions,
1866
2087
  forbiddenActions,
1867
2088
  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.`,
2089
+ 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
2090
  mergeStateStatus,
1870
2091
  conflictFiles,
1871
2092
  refinementArtifact,
@@ -1887,7 +2108,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1887
2108
  lifecycleState: effectiveLifecycleState,
1888
2109
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1889
2110
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1890
- draftGateAlreadySatisfied: true,
2111
+ draftGateAlreadySatisfied,
1891
2112
  draftGate,
1892
2113
  preApprovalGate,
1893
2114
  allowedNextActions,
@@ -1914,7 +2135,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1914
2135
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1915
2136
  loopDisposition: DISPOSITION.BLOCKED,
1916
2137
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1917
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2138
+ draftGateAlreadySatisfied,
1918
2139
  draftGate,
1919
2140
  preApprovalGate,
1920
2141
  allowedNextActions,
@@ -1938,7 +2159,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1938
2159
  lifecycleState: STATE.WAITING_FOR_CI,
1939
2160
  loopDisposition: DISPOSITION.PENDING,
1940
2161
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1941
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2162
+ draftGateAlreadySatisfied,
1942
2163
  draftGate,
1943
2164
  preApprovalGate,
1944
2165
  allowedNextActions,
@@ -1956,7 +2177,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1956
2177
  return buildTitleMarkerBlockedResult({
1957
2178
  input,
1958
2179
  currentHeadSha,
1959
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2180
+ draftGateAlreadySatisfied,
1960
2181
  draftGate,
1961
2182
  preApprovalGate,
1962
2183
  mergeStateStatus,
@@ -1994,7 +2215,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1994
2215
  lifecycleState: effectiveLifecycleState,
1995
2216
  loopDisposition: DISPOSITION.DONE,
1996
2217
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1997
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2218
+ draftGateAlreadySatisfied,
1998
2219
  draftGate,
1999
2220
  preApprovalGate,
2000
2221
  allowedNextActions,