@dev-loops/core 1.0.3 → 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
  }
@@ -592,7 +635,60 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
592
635
  const copilotReviewRequestStatus = typeof input.copilotReviewRequestStatus === "string"
593
636
  ? input.copilotReviewRequestStatus.trim().toLowerCase()
594
637
  : "none";
595
- if (copilotReviewRequestStatus !== "requested" && copilotReviewRequestStatus !== "already-requested") {
638
+ const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
639
+ const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
640
+ const copilotReviewOnCurrentHead = input.copilotReviewOnCurrentHead === true;
641
+ const outstandingRequest = copilotReviewRequestStatus === "requested"
642
+ || copilotReviewRequestStatus === "already-requested";
643
+ const roundCapReached = isCopilotRoundCapReached({
644
+ copilotReviewRoundCount: input.copilotReviewRoundCount,
645
+ maxCopilotRounds: input.maxCopilotRounds,
646
+ });
647
+ // Absent / never-driven: Copilot review is enabled (maxCopilotRounds
648
+ // > 0, not internal_only) but no round was requested or received for the
649
+ // CURRENT head, so a clean pre_approval_gate verdict would rest on nothing the
650
+ // loop actually drove on the head under review. The reconciled status alone is
651
+ // ambiguous: the reconciler (resolveCopilotReviewRequestStatus) folds a clean
652
+ // same-head submitted review into "none" too. Three facts each prove this is
653
+ // not the never-driven case and exempt this branch:
654
+ // - sameHeadCleanConverged: a clean same-head submitted Copilot review
655
+ // exists on THIS head (including GitHub's incidental auto-review) — the
656
+ // no-redundant-re-request case;
657
+ // - copilotReviewOnCurrentHead: a submitted Copilot review exists on THIS
658
+ // head (a settled current-head round; at a grant boundary its threads are
659
+ // already resolved). This is keyed on CURRENT-HEAD evidence, never a raw
660
+ // across-PR round count: copilotReviewRoundCount counts reviews on ANY
661
+ // head, so a PR with prior-head rounds and a NEW current head that carries
662
+ // no review (interpreter state low_signal_converged with
663
+ // copilotReviewOnCurrentHead false) must still fail closed;
664
+ // - roundCapReached: at/past the cap no further Copilot round can be driven,
665
+ // so requiring a current-head review is impossible to satisfy — the
666
+ // pre_approval_gate reviews the post-cap head. The core only reaches a
667
+ // grant boundary here after gating CI/threads, and a significant
668
+ // post-convergence change routes to a rerequest (a new cycle) before this
669
+ // guard runs, so this exemption cannot mask a genuinely-unreviewed change;
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).
676
+ // Fail closed on ANY non-outstanding status, not only the literal "none":
677
+ // this is the independent gate-ENTRY re-check, so it must not trust the
678
+ // caller's status string. A non-canonical/unknown value ("", "unavailable",
679
+ // "failed", a typo) with a grant-y lifecycleState and no current-head review
680
+ // fails closed rather than slipping through the "none"-only default. Only a
681
+ // driven current-head review (or the impossible-further-round cap state, or an
682
+ // operator-verified pure-doc-bump suppression) exempts, so an agent that skips
683
+ // the explicit Copilot round cannot reach a clean pre_approval verdict via any
684
+ // grant-y lifecycleState (e.g. a stale/racy low_signal_converged label, or one
685
+ // carried by prior-head rounds).
686
+ const absentNeverDriven = !outstandingRequest
687
+ && !copilotReviewOnCurrentHead
688
+ && !sameHeadCleanConverged
689
+ && !roundCapReached
690
+ && input.postConvergenceReviewSuppressed !== true;
691
+ if (!outstandingRequest && !absentNeverDriven) {
596
692
  return null;
597
693
  }
598
694
  // Round-cap exemption (mirrors shouldGuardCopilotReviewRequest):
@@ -603,10 +699,6 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
603
699
  // and the head is clean — either sameHeadCleanConverged or the interpreter's
604
700
  // round_cap_clean_fallback state — the pre_approval_gate proceeds unless
605
701
  // significant post-convergence changes require a new review cycle.
606
- const roundCapReached = isCopilotRoundCapReached({
607
- copilotReviewRoundCount: input.copilotReviewRoundCount,
608
- maxCopilotRounds: input.maxCopilotRounds,
609
- });
610
702
  const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
611
703
  // Also exempt the evaluator's own ROUND_CAP_REACHED grant shape:
612
704
  // without this, this guard would rewrite that grant back to
@@ -624,7 +716,15 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
624
716
 
625
717
  const allowedNextActions = [];
626
718
  const forbiddenActions = [];
627
- pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW]);
719
+ // Two shapes fail closed here. An OUTSTANDING request (requested/
720
+ // already-requested) waits for the current-head review to settle. The
721
+ // ABSENT / never-driven case has nothing to wait for — no request is
722
+ // in flight — so the loop must first REQUEST a Copilot review, mirroring the
723
+ // detector-side shouldGuardCopilotReviewRequest override.
724
+ const nextAction = absentNeverDriven
725
+ ? PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW
726
+ : PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW;
727
+ pushUnique(allowedNextActions, [nextAction]);
628
728
  // Full postDraftForbidden set (matching the canonical WAITING_FOR_COPILOT_REVIEW
629
729
  // result this guard synthesizes) plus the final-approval actions the replaced
630
730
  // boundary result also forbade — dropping RUN_DRAFT_GATE/MARK_READY_FOR_REVIEW
@@ -642,18 +742,24 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
642
742
  repo: input.repo ?? null,
643
743
  pr: Number.isInteger(input.pr) ? input.pr : null,
644
744
  currentHeadSha: result.currentHeadSha ?? null,
645
- lifecycleState: STATE.WAITING_FOR_COPILOT_REVIEW,
646
- loopDisposition: DISPOSITION.PENDING,
745
+ lifecycleState: absentNeverDriven ? STATE.PR_READY_NO_FEEDBACK : STATE.WAITING_FOR_COPILOT_REVIEW,
746
+ loopDisposition: absentNeverDriven ? DISPOSITION.ACTION_REQUIRED : DISPOSITION.PENDING,
647
747
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
648
748
  draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
649
749
  draftGate: result.draftGate,
650
750
  preApprovalGate: result.preApprovalGate,
651
751
  allowedNextActions,
652
752
  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.",
753
+ nextAction,
754
+ reason: absentNeverDriven
755
+ ? "Copilot review is enabled for this repo (maxCopilotRounds > 0) but no Copilot review round has been "
756
+ + "requested or received for the current head (independent gate-entry re-check, issue #2146) — a clean "
757
+ + "pre_approval_gate/final-approval verdict requires a Copilot round the loop actually drove and awaited, "
758
+ + "so request Copilot review first. A clean same-head submitted Copilot review (including GitHub's "
759
+ + "auto-review) would satisfy this; an absent/never-driven round is not settled convergence."
760
+ : "A Copilot review request is still outstanding on the current head (independent gate-entry "
761
+ + "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
762
+ + "review settles, even though the caller-reported convergence signal claims otherwise.",
657
763
  mergeStateStatus: result.mergeStateStatus ?? null,
658
764
  conflictFiles: result.conflictFiles ?? [],
659
765
  refinementArtifact: result.refinementArtifact ?? null,
@@ -661,6 +767,67 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
661
767
  });
662
768
  }
663
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
+
664
831
  /**
665
832
  * Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
666
833
  * at the pre-approval / final-approval boundary for non-draft PRs.
@@ -669,31 +836,38 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
669
836
  * sites (defense in depth); this wrapper additionally covers the pre-approval
670
837
  * gate boundary, which is reached before any pre-approval evidence exists and so
671
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.
672
846
  */
673
847
  export function evaluatePrGateCoordination(input = {}) {
674
848
  const result = evaluatePrGateCoordinationCore(input);
849
+ const prDraft = input.prDraft === true;
675
850
 
676
851
  const unsettledReviewResult = applyUnsettledCopilotReviewEntryGuard(input, result);
677
852
  if (unsettledReviewResult) {
678
- return unsettledReviewResult;
853
+ return applyDraftGateEvidenceGuard(unsettledReviewResult, { prDraft });
679
854
  }
680
855
 
681
- const prDraft = input.prDraft === true;
682
856
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
683
857
  // Draft PRs may legitimately carry a WIP title; the marker only blocks once
684
858
  // the PR has left draft and is at a pre-approval/final-approval boundary.
685
859
  if (prDraft || !result || typeof result !== "object") {
686
- return result;
860
+ return applyDraftGateEvidenceGuard(result, { prDraft });
687
861
  }
688
862
  if (!TITLE_MARKER_GUARDED_BOUNDARIES.includes(result.gateBoundary)) {
689
- return result;
863
+ return applyDraftGateEvidenceGuard(result, { prDraft });
690
864
  }
691
865
  const markers = findBlockingTitleMarkers(prTitle);
692
866
  if (markers.length === 0) {
693
- return result;
867
+ return applyDraftGateEvidenceGuard(result, { prDraft });
694
868
  }
695
869
 
696
- return buildTitleMarkerBlockedResult({
870
+ return applyDraftGateEvidenceGuard(buildTitleMarkerBlockedResult({
697
871
  input,
698
872
  currentHeadSha: result.currentHeadSha ?? null,
699
873
  draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
@@ -703,7 +877,7 @@ export function evaluatePrGateCoordination(input = {}) {
703
877
  conflictFiles: result.conflictFiles ?? [],
704
878
  markers,
705
879
  refinementArtifact: result.refinementArtifact ?? null,
706
- });
880
+ }), { prDraft });
707
881
  }
708
882
 
709
883
  function evaluatePrGateCoordinationCore(input = {}) {
@@ -713,14 +887,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
713
887
  const prDraft = input.prDraft === true;
714
888
  const prClosed = input.prClosed === true;
715
889
  const prMerged = input.prMerged === true;
716
- const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
717
- // Operator-authorized post-convergence suppression: set only when the
718
- // caller has verified an explicit prior withdrawal (withdraw-copilot-review-
719
- // request.mjs) recorded a suppression marker for this EXACT head, proving the
720
- // delta since Copilot's last submitted review is a pure doc/prose bump. Never
721
- // derived here from other snapshot facts — this evaluator trusts the caller's
722
- // verification rather than re-deriving it, so it cannot become an automatic
723
- // 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.
724
899
  const postConvergenceReviewSuppressed = input.postConvergenceReviewSuppressed === true;
725
900
  // maxCopilotRounds: 0 disables the external Copilot review gate entirely
726
901
  // (for repos without Copilot / local-harness-only review). It reuses the
@@ -771,9 +946,20 @@ function evaluatePrGateCoordinationCore(input = {}) {
771
946
  const refinementArtifactStatus = normalizeRefinementArtifactStatus(refinementArtifact?.status);
772
947
  const refinementLinkedIssue = Number.isInteger(refinementArtifact?.linkedIssue) ? refinementArtifact.linkedIssue : null;
773
948
 
774
- const effectiveLifecycleState = lifecycleState;
775
-
776
- 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);
777
963
  const preApprovalGate = toGateStatus(input.preApprovalGate, input.preApprovalGateMarker, currentHeadSha);
778
964
  const draftGateAlreadySatisfied = !prDraft && (draftGate?.cleanEvidenceExists ?? false);
779
965
 
@@ -1056,7 +1242,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1056
1242
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1057
1243
  loopDisposition: DISPOSITION.BLOCKED,
1058
1244
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1059
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1245
+ draftGateAlreadySatisfied,
1060
1246
  draftGate,
1061
1247
  preApprovalGate,
1062
1248
  allowedNextActions,
@@ -1076,6 +1262,83 @@ function evaluatePrGateCoordinationCore(input = {}) {
1076
1262
  PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1077
1263
  ];
1078
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
+
1079
1342
  if (!draftGate.currentHeadClean && draftGateRequireCi) {
1080
1343
  if (ciStatus === "failure") {
1081
1344
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
@@ -1090,7 +1353,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1090
1353
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1091
1354
  loopDisposition: DISPOSITION.BLOCKED,
1092
1355
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1093
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1356
+ draftGateAlreadySatisfied,
1094
1357
  draftGate,
1095
1358
  preApprovalGate,
1096
1359
  allowedNextActions,
@@ -1116,7 +1379,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1116
1379
  lifecycleState: STATE.WAITING_FOR_CI,
1117
1380
  loopDisposition: DISPOSITION.PENDING,
1118
1381
  gateBoundary: PR_CHECKPOINT.DRAFT_REVIEW,
1119
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1382
+ draftGateAlreadySatisfied,
1120
1383
  draftGate,
1121
1384
  preApprovalGate,
1122
1385
  allowedNextActions,
@@ -1188,7 +1451,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1188
1451
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1189
1452
  loopDisposition: DISPOSITION.BLOCKED,
1190
1453
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1191
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1454
+ draftGateAlreadySatisfied,
1192
1455
  draftGate,
1193
1456
  preApprovalGate,
1194
1457
  allowedNextActions,
@@ -1208,7 +1471,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1208
1471
  return buildTitleMarkerBlockedResult({
1209
1472
  input,
1210
1473
  currentHeadSha,
1211
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1474
+ draftGateAlreadySatisfied,
1212
1475
  draftGate,
1213
1476
  preApprovalGate,
1214
1477
  mergeStateStatus,
@@ -1240,7 +1503,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1240
1503
  lifecycleState: effectiveLifecycleState,
1241
1504
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1242
1505
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1243
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1506
+ draftGateAlreadySatisfied,
1244
1507
  draftGate,
1245
1508
  preApprovalGate,
1246
1509
  allowedNextActions,
@@ -1264,7 +1527,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1264
1527
  lifecycleState: effectiveLifecycleState,
1265
1528
  loopDisposition: loopDisposition ?? DISPOSITION.ACTION_REQUIRED,
1266
1529
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1267
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1530
+ draftGateAlreadySatisfied,
1268
1531
  draftGate,
1269
1532
  preApprovalGate,
1270
1533
  allowedNextActions,
@@ -1391,7 +1654,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1391
1654
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1392
1655
  loopDisposition: DISPOSITION.BLOCKED,
1393
1656
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1394
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1657
+ draftGateAlreadySatisfied,
1395
1658
  draftGate,
1396
1659
  preApprovalGate,
1397
1660
  allowedNextActions,
@@ -1416,7 +1679,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1416
1679
  lifecycleState: STATE.WAITING_FOR_CI,
1417
1680
  loopDisposition: DISPOSITION.PENDING,
1418
1681
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1419
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1682
+ draftGateAlreadySatisfied,
1420
1683
  draftGate,
1421
1684
  preApprovalGate,
1422
1685
  allowedNextActions,
@@ -1450,7 +1713,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1450
1713
  lifecycleState: effectiveLifecycleState,
1451
1714
  loopDisposition: loopDisposition ?? DISPOSITION.ACTION_REQUIRED,
1452
1715
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1453
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1716
+ draftGateAlreadySatisfied,
1454
1717
  draftGate,
1455
1718
  preApprovalGate,
1456
1719
  allowedNextActions,
@@ -1471,7 +1734,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1471
1734
  return buildTitleMarkerBlockedResult({
1472
1735
  input,
1473
1736
  currentHeadSha,
1474
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1737
+ draftGateAlreadySatisfied,
1475
1738
  draftGate,
1476
1739
  preApprovalGate,
1477
1740
  mergeStateStatus,
@@ -1481,7 +1744,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1481
1744
  });
1482
1745
  }
1483
1746
 
1484
- if (!draftGate.cleanEvidenceExists && !roundCapReached) {
1747
+ if (!draftGate.cleanEvidenceExists) {
1485
1748
  return buildDraftGateNeededForMergeResult({
1486
1749
  input,
1487
1750
  currentHeadSha,
@@ -1509,14 +1772,14 @@ function evaluatePrGateCoordinationCore(input = {}) {
1509
1772
  lifecycleState: effectiveLifecycleState,
1510
1773
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1511
1774
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1512
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1775
+ draftGateAlreadySatisfied,
1513
1776
  draftGate,
1514
1777
  preApprovalGate,
1515
1778
  allowedNextActions,
1516
1779
  forbiddenActions,
1517
1780
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1518
1781
  reason: roundCapReached
1519
- ? `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.`
1520
1783
  : (ciStatus === "crediblyGreen"
1521
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."
1522
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."),
@@ -1549,7 +1812,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1549
1812
  reason: roundCapReached
1550
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.`
1551
1814
  : (postConvergenceReviewSuppressed && !sameHeadCleanConverged
1552
- ? "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."
1553
1816
  : (ciStatus === "crediblyGreen"
1554
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."
1555
1818
  : "The current head has a clean settled post-draft review cycle, so `pre_approval_gate` is now the next legal boundary.")),
@@ -1585,7 +1848,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1585
1848
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1586
1849
  loopDisposition: DISPOSITION.BLOCKED,
1587
1850
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1588
- draftGateAlreadySatisfied: true,
1851
+ draftGateAlreadySatisfied,
1589
1852
  draftGate,
1590
1853
  preApprovalGate,
1591
1854
  allowedNextActions,
@@ -1609,7 +1872,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1609
1872
  lifecycleState: STATE.WAITING_FOR_CI,
1610
1873
  loopDisposition: DISPOSITION.PENDING,
1611
1874
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1612
- draftGateAlreadySatisfied: true,
1875
+ draftGateAlreadySatisfied,
1613
1876
  draftGate,
1614
1877
  preApprovalGate,
1615
1878
  allowedNextActions,
@@ -1627,7 +1890,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1627
1890
  return buildTitleMarkerBlockedResult({
1628
1891
  input,
1629
1892
  currentHeadSha,
1630
- draftGateAlreadySatisfied: true,
1893
+ draftGateAlreadySatisfied,
1631
1894
  draftGate,
1632
1895
  preApprovalGate,
1633
1896
  mergeStateStatus,
@@ -1638,11 +1901,12 @@ function evaluatePrGateCoordinationCore(input = {}) {
1638
1901
  }
1639
1902
  // Mirror LOW_SIGNAL_CONVERGED: a clean current head with no clean
1640
1903
  // draft_gate evidence must reconcile the draft gate rather than jump to
1641
- // final approval. This keeps the core handler consistent with the
1642
- // detect-pr-gate-coordination-state post-pass, which unconditionally
1643
- // downgrades FINAL_APPROVAL_READY → DRAFT_GATE_NEEDED when
1644
- // draftGate.cleanEvidenceExists is false (no ROUND_CAP_CLEAN_FALLBACK
1645
- // 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
1646
1910
  // branch is dead through the real script and asserts behavior it never
1647
1911
  // produces.
1648
1912
  if (!draftGate.cleanEvidenceExists) {
@@ -1675,13 +1939,13 @@ function evaluatePrGateCoordinationCore(input = {}) {
1675
1939
  lifecycleState: effectiveLifecycleState,
1676
1940
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1677
1941
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1678
- draftGateAlreadySatisfied: true,
1942
+ draftGateAlreadySatisfied,
1679
1943
  draftGate,
1680
1944
  preApprovalGate,
1681
1945
  allowedNextActions,
1682
1946
  forbiddenActions,
1683
1947
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1684
- 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.`,
1685
1949
  mergeStateStatus,
1686
1950
  conflictFiles,
1687
1951
  refinementArtifact,
@@ -1702,7 +1966,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1702
1966
  lifecycleState: effectiveLifecycleState,
1703
1967
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1704
1968
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1705
- draftGateAlreadySatisfied: true,
1969
+ draftGateAlreadySatisfied,
1706
1970
  draftGate,
1707
1971
  preApprovalGate,
1708
1972
  allowedNextActions,
@@ -1745,7 +2009,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
1745
2009
  // human-read reason/evidence text for the latter case would be a false CI
1746
2010
  // claim, so describe the actual grant basis instead.
1747
2011
  const ciClause = ciStatus === "success" ? "green CI" : "CI not required by config";
1748
- 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)) {
1749
2021
  if (preApprovalGate.currentHeadClean) {
1750
2022
  // Inline title-marker check, mirroring ROUND_CAP_CLEAN_FALLBACK: the
1751
2023
  // outer post-pass guards FINAL_APPROVAL_READY and
@@ -1757,7 +2029,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1757
2029
  return buildTitleMarkerBlockedResult({
1758
2030
  input,
1759
2031
  currentHeadSha,
1760
- draftGateAlreadySatisfied: true,
2032
+ draftGateAlreadySatisfied,
1761
2033
  draftGate,
1762
2034
  preApprovalGate,
1763
2035
  mergeStateStatus,
@@ -1797,13 +2069,13 @@ function evaluatePrGateCoordinationCore(input = {}) {
1797
2069
  lifecycleState: effectiveLifecycleState,
1798
2070
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1799
2071
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1800
- draftGateAlreadySatisfied: true,
2072
+ draftGateAlreadySatisfied,
1801
2073
  draftGate,
1802
2074
  preApprovalGate,
1803
2075
  allowedNextActions,
1804
2076
  forbiddenActions,
1805
2077
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1806
- 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.`,
1807
2079
  mergeStateStatus,
1808
2080
  conflictFiles,
1809
2081
  refinementArtifact,
@@ -1825,7 +2097,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1825
2097
  lifecycleState: effectiveLifecycleState,
1826
2098
  loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1827
2099
  gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1828
- draftGateAlreadySatisfied: true,
2100
+ draftGateAlreadySatisfied,
1829
2101
  draftGate,
1830
2102
  preApprovalGate,
1831
2103
  allowedNextActions,
@@ -1852,7 +2124,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1852
2124
  lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1853
2125
  loopDisposition: DISPOSITION.BLOCKED,
1854
2126
  gateBoundary: PR_CHECKPOINT.BLOCKED,
1855
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2127
+ draftGateAlreadySatisfied,
1856
2128
  draftGate,
1857
2129
  preApprovalGate,
1858
2130
  allowedNextActions,
@@ -1876,7 +2148,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1876
2148
  lifecycleState: STATE.WAITING_FOR_CI,
1877
2149
  loopDisposition: DISPOSITION.PENDING,
1878
2150
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1879
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2151
+ draftGateAlreadySatisfied,
1880
2152
  draftGate,
1881
2153
  preApprovalGate,
1882
2154
  allowedNextActions,
@@ -1894,7 +2166,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1894
2166
  return buildTitleMarkerBlockedResult({
1895
2167
  input,
1896
2168
  currentHeadSha,
1897
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2169
+ draftGateAlreadySatisfied,
1898
2170
  draftGate,
1899
2171
  preApprovalGate,
1900
2172
  mergeStateStatus,
@@ -1932,7 +2204,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1932
2204
  lifecycleState: effectiveLifecycleState,
1933
2205
  loopDisposition: DISPOSITION.DONE,
1934
2206
  gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1935
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
2207
+ draftGateAlreadySatisfied,
1936
2208
  draftGate,
1937
2209
  preApprovalGate,
1938
2210
  allowedNextActions,