@dev-loops/core 1.0.0-rc.3 → 1.0.0-rc.5

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.
@@ -21,7 +21,7 @@ import {
21
21
  import { normalizeRepoSlug } from "../github/repo-slug.mjs";
22
22
  import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
23
23
  import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
24
- import { resolveGateConfig, resolveHumanMergeOnly } from "../config/config.mjs";
24
+ import { resolveGateAngleContract, resolveGateAngles, resolveGateConfig, resolveHumanMergeOnly } from "../config/config.mjs";
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
27
  // Constants
@@ -95,7 +95,7 @@ register(INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP, "watch", {
95
95
  register(INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP, "pre-approval", {
96
96
  criteria: [
97
97
  { id: "full-gate-chain", must: "Complete pre-approval gate chain with all configured review angles.", severity: "required" },
98
- { id: "clean-verdict", must: "Pre-approval gate must return clean verdict (no must-fix or worth-fixing-now findings).", severity: "required" },
98
+ { id: "clean-verdict", must: "Pre-approval gate must return clean verdict (no findings at a severity in the gate's configured blockCleanOnFindingSeverities, high by default).", severity: "required" },
99
99
  { id: "unresolved-threads", must: "All review threads must be resolved before pre-approval gate runs.", severity: "required" },
100
100
  { id: "ci-green", must: "CI must be green on the current head SHA.", severity: "required" },
101
101
  ],
@@ -356,14 +356,25 @@ function deriveGateConfig(settings, subGate) {
356
356
  const gateKey = subGate === "pre-approval" ? "preApproval" : subGate;
357
357
  if (!settings?.gates?.[gateKey]) return undefined;
358
358
 
359
- // Route through the canonical resolver rather than re-parsing
360
- // gates.<gate>.angles by hand: resolveGateConfig already folds the unified
361
- // angle-entry shape (mandatory/enabled per-entry, D3) into this same
362
- // exclude-filtered angles + separate excludeAngles list the envelope
363
- // contract has always shipped.
359
+ // Route through the canonical resolvers rather than re-parsing
360
+ // gates.<gate>.angles by hand: resolveGateConfig folds the unified
361
+ // angle-entry shape (mandatory/enabled per-entry, D3) into excludeAngles/
362
+ // blockCleanOnFindingSeverities/requireCi, the envelope contract's
363
+ // long-standing shape. `angles` is the RUN-set (the configured angles the
364
+ // orchestrator is told to dispatch) with every validator-MANDATORY angle
365
+ // merged in — never resolveGateAngleContract's `pool`, which is the
366
+ // enforcement CEILING and deliberately widens to the whole lens catalog
367
+ // under gates.<gate>.dynamic.additive (advertising that as the run-set
368
+ // would tell the orchestrator to dispatch 20+ angles). The parity contract
369
+ // (test/contracts/envelope-validator-angle-parity.test.mjs) pins both
370
+ // invariants: everything advertised is within the validator pool, and
371
+ // every mandatory angle is advertised.
364
372
  const resolved = resolveGateConfig(settings, gateKey);
373
+ const { mandatoryAngles } = resolveGateAngleContract(settings, gateKey);
374
+ const runSet = resolveGateAngles(settings, gateKey) ?? [];
375
+ const angles = [...new Set([...runSet, ...mandatoryAngles])];
365
376
  return {
366
- angles: resolved.angles ?? [],
377
+ angles,
367
378
  excludeAngles: resolved.excludeAngles.length > 0 ? resolved.excludeAngles : undefined,
368
379
  blockCleanOnFindingSeverities: resolved.blockCleanOnFindingSeverities,
369
380
  requireCi: resolved.requireCi,
@@ -613,14 +624,9 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
613
624
 
614
625
  const envelope = {
615
626
  handoffVersion: ENVELOPE_HANDOFF_VERSION,
616
- derivedAt: (now ?? new Date()).toISOString(),
617
627
 
618
628
  target,
619
629
  currentGate: subGate,
620
- currentHeadSha: gs.currentHeadSha,
621
- ciStatus: gs.ciStatus,
622
- unresolvedThreadCount: gs.unresolvedThreadCount,
623
- copilotRoundCount: gs.copilotRoundCount,
624
630
  maxCopilotRounds: settings?.refinement?.maxCopilotRounds ?? 5,
625
631
  executionMode,
626
632
 
@@ -674,6 +680,20 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
674
680
  envelope.specSource = specSource;
675
681
  }
676
682
 
683
+ // #1462: the ONLY per-round-varying block, kept LAST. Every field here changes
684
+ // between builds/rounds (the timestamp, the head SHA, CI status, thread/round
685
+ // counts); isolating them as the envelope's tail keeps everything above a
686
+ // byte-stable prefix that a fresh reviewer spawn can cache-READ instead of
687
+ // re-billing the full contract scaffolding each round. Consumers must treat
688
+ // gateState as volatile — read it last, or re-derive it fresh via detectors.
689
+ envelope.gateState = {
690
+ derivedAt: (now ?? new Date()).toISOString(),
691
+ currentHeadSha: gs.currentHeadSha,
692
+ ciStatus: gs.ciStatus,
693
+ unresolvedThreadCount: gs.unresolvedThreadCount,
694
+ copilotRoundCount: gs.copilotRoundCount,
695
+ };
696
+
677
697
  return deepFreeze(envelope);
678
698
  }
679
699
 
@@ -940,9 +960,10 @@ export function validateHandoffEnvelope(envelope) {
940
960
  }
941
961
  }
942
962
 
943
- // ----- derivedAt (informational, warn on missing) -----
944
- if (typeof envelope.derivedAt !== "string" || !envelope.derivedAt.trim()) {
945
- warnings.push({ field: "derivedAt", reason: "should be an ISO 8601 timestamp" });
963
+ // ----- gateState.derivedAt (informational, warn on missing) — #1462 moved the
964
+ // volatile timestamp into the gateState tail so the rest stays byte-stable -----
965
+ if (typeof envelope.gateState?.derivedAt !== "string" || !envelope.gateState.derivedAt.trim()) {
966
+ warnings.push({ field: "gateState.derivedAt", reason: "should be an ISO 8601 timestamp" });
946
967
  }
947
968
 
948
969
  return {
@@ -148,20 +148,25 @@ function findSectionByPatterns(sections, patterns) {
148
148
  }
149
149
 
150
150
  /**
151
- * Extract bullet items from a section body. Counts both `- [ ]`/`- [x]`
152
- * checklist items and top-level plain `- ` bullets (dash at column 0, so
153
- * nested/indented sub-bullets are not counted). Empty checkbox placeholders
154
- * (`- [ ]` / `- [x]` with no trailing text) are skipped, not counted, so a
155
- * section of only unfilled placeholders reports as unrefined. Returns the
156
- * trimmed item text for each matching line. The checkbox state (checked vs
157
- * unchecked) is intentionally not preserved: callers only need the item
158
- * text to satisfy the refinement-artifact contract.
151
+ * Parse bullet/checkbox items from a section body into item states. Each
152
+ * checkbox item (`- [ ]`/`- [x]`/`- [X]`) becomes `{ text, checked }`
153
+ * (`checked` true only for a ticked `[x]`/`[X]`); a top-level plain bullet
154
+ * (`- text`, dash at column 0 so nested/indented sub-bullets are not counted)
155
+ * becomes `{ text, checked: null }` — it has no checkbox to tick. Empty
156
+ * checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
157
+ * not counted, so a section of only unfilled placeholders reports as unrefined.
158
+ * Code-fenced lines are skipped (same fence logic as parseMarkdownSections,
159
+ * issue #1025) so a body cannot spoof the AC/DoD gate with code-fenced
160
+ * checkboxes.
159
161
  *
160
- * This is only ever called on the body of an already-recognized AC/DoD
161
- * section (see `detectIssueRefinementArtifact`), so counting plain bullets
162
- * is scoped to those sections and never affects prose sections.
162
+ * Shared by `extractChecklistItems` (text-only) and the unticked-AC check
163
+ * (`extractUncheckedChecklistItems`) so the two never drift on what counts as
164
+ * a checklist item or on the checkbox-state read (#1621). Only ever called on
165
+ * the body of an already-recognized AC/DoD section (see
166
+ * `detectIssueRefinementArtifact`), so counting plain bullets is scoped to
167
+ * those sections and never affects prose sections.
163
168
  */
164
- export function extractChecklistItems(sectionBody) {
169
+ function parseChecklistItems(sectionBody) {
165
170
  if (typeof sectionBody !== "string" || sectionBody.length === 0) {
166
171
  return [];
167
172
  }
@@ -171,9 +176,6 @@ export function extractChecklistItems(sectionBody) {
171
176
  let fence = null;
172
177
 
173
178
  for (const line of lines) {
174
- // Checkboxes/bullets inside a fenced code span are non-interactive text, not
175
- // real items — skip them so a body cannot spoof the AC/DoD gate with
176
- // code-fenced checkboxes (issue #1025). Same fence logic as parseMarkdownSections.
177
179
  const step = stepFence(fence, line);
178
180
  fence = step.fence;
179
181
  if (step.insideFence) {
@@ -186,7 +188,10 @@ export function extractChecklistItems(sectionBody) {
186
188
  if (checkboxMatch) {
187
189
  const text = (checkboxMatch[1] ?? "").trim();
188
190
  if (text.length > 0) {
189
- items.push(text);
191
+ // `checked` is true only for a ticked box; `[ ]` (space) is false.
192
+ // A plain bullet has no checkbox, so it stays `null` below — it is
193
+ // neither ticked nor unticked and does not count as an unticked AC.
194
+ items.push({ text, checked: /^\s*-\s+\[[xX]\]/u.test(line) });
190
195
  }
191
196
  continue;
192
197
  }
@@ -196,7 +201,7 @@ export function extractChecklistItems(sectionBody) {
196
201
  if (bulletMatch) {
197
202
  const text = bulletMatch[1].trim();
198
203
  if (text.length > 0) {
199
- items.push(text);
204
+ items.push({ text, checked: null });
200
205
  }
201
206
  }
202
207
  }
@@ -204,6 +209,32 @@ export function extractChecklistItems(sectionBody) {
204
209
  return items;
205
210
  }
206
211
 
212
+ /**
213
+ * Extract bullet items from a section body. Counts both `- [ ]`/`- [x]`
214
+ * checklist items and top-level plain `- ` bullets. Empty checkbox placeholders
215
+ * are skipped. Returns the trimmed item text for each matching line; the
216
+ * checkbox state is not preserved (use `extractUncheckedChecklistItems` for
217
+ * that). Thin wrapper over `parseChecklistItems` so the text-only contract
218
+ * stays byte-identical to its pre-#1621 shape.
219
+ */
220
+ export function extractChecklistItems(sectionBody) {
221
+ return parseChecklistItems(sectionBody).map((item) => item.text);
222
+ }
223
+
224
+ /**
225
+ * Extract the text of UNCHECKED checkbox items (`- [ ]`) from a section body.
226
+ * A ticked box (`- [x]`/`- [X]`) and a plain bullet (no checkbox) are both
227
+ * excluded — only an actual unticked checkbox is an "unticked AC item"
228
+ * (#1621, ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped.
229
+ * Thin wrapper over `parseChecklistItems` so the unticked read never drifts
230
+ * from `extractChecklistItems` on what counts as a checklist item.
231
+ */
232
+ export function extractUncheckedChecklistItems(sectionBody) {
233
+ return parseChecklistItems(sectionBody)
234
+ .filter((item) => item.checked === false)
235
+ .map((item) => item.text);
236
+ }
237
+
207
238
  /**
208
239
  * Detect a linked refinement doc path from the issue body.
209
240
  * Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
@@ -246,6 +277,7 @@ export function detectLinkedRefinementDoc(body) {
246
277
  * hasACs: boolean,
247
278
  * source: string,
248
279
  * acItems: string[],
280
+ * uncheckedAcItems: string[],
249
281
  * dodItems: string[],
250
282
  * sections: string[],
251
283
  * linkedDoc: { found: boolean, path: string|null, reason: string },
@@ -259,6 +291,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
259
291
  hasACs: false,
260
292
  source: REFINEMENT_SOURCE.MISSING,
261
293
  acItems: [],
294
+ uncheckedAcItems: [],
262
295
  dodItems: [],
263
296
  sections: [],
264
297
  linkedDoc: { found: false, path: null, reason: "empty-body" },
@@ -274,6 +307,11 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
274
307
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
275
308
 
276
309
  const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
310
+ // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
311
+ // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
312
+ // must refuse on (#1621). Only actual unticked checkboxes count; a ticked
313
+ // box and a plain bullet (no checkbox) are both excluded.
314
+ const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
277
315
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
278
316
 
279
317
  const linkedDoc = detectLinkedRefinementDoc(body);
@@ -283,6 +321,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
283
321
  hasACs: true,
284
322
  source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
285
323
  acItems,
324
+ uncheckedAcItems,
286
325
  dodItems,
287
326
  sections: sectionNames,
288
327
  linkedDoc,
@@ -296,6 +335,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
296
335
  hasACs: true,
297
336
  source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
298
337
  acItems,
338
+ uncheckedAcItems,
299
339
  dodItems,
300
340
  sections: sectionNames,
301
341
  linkedDoc,
@@ -309,6 +349,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
309
349
  hasACs: true,
310
350
  source: REFINEMENT_SOURCE.LINKED_DOC,
311
351
  acItems: [],
352
+ uncheckedAcItems: [],
312
353
  dodItems: [],
313
354
  sections: sectionNames,
314
355
  linkedDoc,
@@ -321,6 +362,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
321
362
  hasACs: false,
322
363
  source: REFINEMENT_SOURCE.MISSING,
323
364
  acItems: [],
365
+ uncheckedAcItems: [],
324
366
  dodItems: [],
325
367
  sections: sectionNames,
326
368
  linkedDoc,
@@ -537,7 +579,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
537
579
  const reason =
538
580
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
539
581
  "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
540
- "(e.g. run `/loop-grill <issue> --auto`, or the refiner) — before it enters the pickup queue.";
582
+ "(e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself), or the refiner) — before it enters the pickup queue.";
541
583
  return { action: auto ? "divert" : "block", reason, missing };
542
584
  }
543
585
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Main-checkout fast-forward command shape (#1596).
3
+ *
4
+ * The dev-loop merges remotely (`gh pr merge` → origin/main) but neither the merge
5
+ * procedure nor the post-merge hooks fast-forwarded the main checkout's local
6
+ * `main`. Read-only gate scripts (`probe-ci-status.mjs`, `detect-copilot-loop-state.mjs`,
7
+ * …) run from the main checkout, so a stale local `main` made them execute pre-merge
8
+ * code — re-introducing the CI-wait stall every PR (e.g. #1531's fix was invisible
9
+ * until the main checkout caught up).
10
+ *
11
+ * This module owns the shared, dependency-free command string both harness hooks
12
+ * (Pi `post-merge-update`, Claude `post-tool-use-merge`) run after a successful
13
+ * merge. It is best-effort and NON-BLOCKING: `--ff-only` refuses a diverged `main`
14
+ * without rewriting history, so a diverged checkout fails the merge step cleanly and
15
+ * the caller treats that as warn-and-continue (never a hard failure, never a force
16
+ * push). `mainCheckout` is POSIX single-quoted so consumer checkout paths containing
17
+ * spaces or shell metacharacters cannot break or inject into the shell string.
18
+ *
19
+ * The `merge --ff-only` is guarded to only run when the main checkout is currently on
20
+ * `main`, so a non-`main` checkout (detached HEAD, or another branch checked out)
21
+ * warns-and-continues instead of fast-forwarding the wrong branch. No `git switch` is
22
+ * performed (a state change) — only the guard test runs.
23
+ *
24
+ * No imports so this file vendors into the `.claude/hooks/` bundle unchanged
25
+ * (vendored modules may only import `node:` builtins or relative paths).
26
+ */
27
+
28
+ /**
29
+ * Timeout (ms) for the `git worktree list` resolution step (the fetch-half budget;
30
+ * a separate fetch timeout isn't applied — the fetch runs inline within the merge
31
+ * command under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS`).
32
+ */
33
+ export const MAIN_CHECKOUT_FF_FETCH_TIMEOUT_MS = 60_000;
34
+
35
+ /** Timeout (ms) for the `git merge --ff-only origin/main` half. */
36
+ export const MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS = 60_000;
37
+
38
+ /**
39
+ * POSIX single-quote a path so spaces/shell metacharacters in a consumer's checkout
40
+ * path cannot break or inject into the shell string.
41
+ */
42
+ function shellQuotePath(value) {
43
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
44
+ }
45
+
46
+ /**
47
+ * Build the best-effort main-checkout fast-forward command string.
48
+ *
49
+ * @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
50
+ * @returns {string} `git -C '<main>' fetch origin main && [ "$(git -C '<main>' rev-parse --abbrev-ref HEAD)" = main ] && git -C '<main>' merge --ff-only origin/main` (path POSIX single-quoted; merge only runs when the main checkout is on `main`)
51
+ */
52
+ export function buildMainCheckoutFastForwardCommand(mainCheckout) {
53
+ const quoted = shellQuotePath(mainCheckout);
54
+ // ponytail: guard with a `[ ... = main ]` test instead of switching branches — a
55
+ // non-main checkout fails the && chain (warn-and-continue) rather than ff-ing the
56
+ // wrong branch. No state change, no git switch.
57
+ return `git -C ${quoted} fetch origin main && [ "$(git -C ${quoted} rev-parse --abbrev-ref HEAD)" = main ] && git -C ${quoted} merge --ff-only origin/main`;
58
+ }
@@ -252,8 +252,26 @@ function formatRefinementBlockedReason(linkedIssue, status, refinementArtifact)
252
252
  return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (Acceptance criteria / DoD / linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
253
253
  }
254
254
 
255
- function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds }) {
256
- return `Copilot review rounds exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}); current head has zero unresolved threads and green or credibly green CI, so pre_approval_gate fallback is allowed without another Copilot re-request.`;
255
+ // #1472: describes the CI state a round-cap-reached fallback branch actually
256
+ // granted on, for human-read reason text. preApprovalRequireCi:false plus a
257
+ // non-success/non-crediblyGreen ciStatus is not "green CI" — claiming it is
258
+ // would be the same false-CI-claim buildRoundExhaustionGateEvidenceNote below
259
+ // exists to avoid. The `ciStatus === "success"` fallback covers the
260
+ // requireCi:true path, where these callers are only reachable with ciStatus
261
+ // "success" (failure/crediblyGreen/pending/none all return earlier).
262
+ function describeAcceptedCiState(ciStatus, preApprovalRequireCi) {
263
+ if (ciStatus === "crediblyGreen") return "credibly green CI";
264
+ if (ciStatus === "success" || preApprovalRequireCi !== false) return "green CI";
265
+ return "CI not required by config";
266
+ }
267
+
268
+ function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciStatus, preApprovalRequireCi, ciDescriptor }) {
269
+ // One rule serves both the reason string and this note. A branch whose CI
270
+ // acceptance differs from describeAcceptedCiState (the strict-green grant
271
+ // rejects crediblyGreen as a basis) passes its own descriptor explicitly so
272
+ // reason and note can never disagree.
273
+ const descriptor = ciDescriptor ?? describeAcceptedCiState(ciStatus, preApprovalRequireCi);
274
+ return `Copilot review rounds exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}); current head has zero unresolved threads and ${descriptor}, so pre_approval_gate fallback is allowed without another Copilot re-request.`;
257
275
  }
258
276
 
259
277
  /**
@@ -514,6 +532,21 @@ const PRE_APPROVAL_ENTRY_BOUNDARIES = Object.freeze([
514
532
  PR_CHECKPOINT.FINAL_APPROVAL_READY,
515
533
  ]);
516
534
 
535
+ /**
536
+ * Identifies the ROUND_CAP_REACHED branch's own defensive grant shape (see
537
+ * that branch below): lifecycleState stays round_cap_reached (never
538
+ * round_cap_clean_fallback) while gateBoundary settles on
539
+ * pre_approval_gate_window. Every caller that exempts the round-cap clean
540
+ * fallback from the formal-request / unsettled-review guards
541
+ * (applyUnsettledCopilotReviewEntryGuard below, and
542
+ * detect-pr-gate-coordination-state.mjs's shouldGuardCopilotReviewRequest
543
+ * wiring) must exempt this shape too, or the grant this evaluator just
544
+ * returned gets rewritten straight back to a Copilot-review wait.
545
+ */
546
+ export function isRoundCapReachedCleanGrant({ lifecycleState, gateBoundary } = {}) {
547
+ return lifecycleState === STATE.ROUND_CAP_REACHED && gateBoundary === PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW;
548
+ }
549
+
517
550
  function applyUnsettledCopilotReviewEntryGuard(input, result) {
518
551
  if (!result || typeof result !== "object" || !PRE_APPROVAL_ENTRY_BOUNDARIES.includes(result.gateBoundary)) {
519
552
  return null;
@@ -544,7 +577,12 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
544
577
  maxCopilotRounds: input.maxCopilotRounds,
545
578
  });
546
579
  const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
547
- const roundCapCleanFallback = lifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK;
580
+ // Also exempt the evaluator's own ROUND_CAP_REACHED grant shape (#1472):
581
+ // without this, this guard would rewrite that grant back to
582
+ // waiting_for_copilot_review the instant it is produced, re-introducing the
583
+ // never-arriving-review dead-end the round-cap exemption exists to prevent.
584
+ const roundCapCleanFallback = lifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK
585
+ || isRoundCapReachedCleanGrant(result);
548
586
  if (
549
587
  roundCapReached
550
588
  && (input.sameHeadCleanConverged === true || roundCapCleanFallback)
@@ -647,6 +685,14 @@ function evaluatePrGateCoordinationCore(input = {}) {
647
685
  const prClosed = input.prClosed === true;
648
686
  const prMerged = input.prMerged === true;
649
687
  const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
688
+ // Operator-authorized post-convergence suppression (#1441): set only when the
689
+ // caller has verified an explicit prior withdrawal (withdraw-copilot-review-
690
+ // request.mjs) recorded a suppression marker for this EXACT head, proving the
691
+ // delta since Copilot's last submitted review is a pure doc/prose bump. Never
692
+ // derived here from other snapshot facts — this evaluator trusts the caller's
693
+ // verification rather than re-deriving it, so it cannot become an automatic
694
+ // loosening of the round-below-cap precondition.
695
+ const postConvergenceReviewSuppressed = input.postConvergenceReviewSuppressed === true;
650
696
  // maxCopilotRounds: 0 disables the external Copilot review gate entirely
651
697
  // (for repos without Copilot / local-harness-only review). It reuses the
652
698
  // existing internal_only routing — skip the Copilot cycle, go straight to
@@ -668,6 +714,15 @@ function evaluatePrGateCoordinationCore(input = {}) {
668
714
  const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
669
715
  const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
670
716
  const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
717
+ // #1472: explicit current-head unresolved-thread signal for the round-cap
718
+ // fallback check below (STATE.ROUND_CAP_REACHED). Unlike copilotReviewRoundCount
719
+ // (which safely defaults to 0 when absent), an absent/invalid count here must
720
+ // NOT be coerced to 0 — that would silently treat an unknown thread count as
721
+ // clean and wrongly unblock the fallback. `null` means "unknown" (fail closed).
722
+ const unresolvedThreadCount = Number.isInteger(input.unresolvedThreadCount)
723
+ && input.unresolvedThreadCount >= 0
724
+ ? input.unresolvedThreadCount
725
+ : null;
671
726
  const postConvergenceSignificantChange = input.postConvergenceSignificantChange === true;
672
727
  const roundCapNewCycleRequired = roundCapReached && copilotReviewRoundCount > 0 && postConvergenceSignificantChange;
673
728
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
@@ -1255,10 +1310,10 @@ function evaluatePrGateCoordinationCore(input = {}) {
1255
1310
  }
1256
1311
 
1257
1312
  const roundExhaustionGateEvidenceNote = (roundCapReached && !roundCapNewCycleRequired)
1258
- ? buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds })
1313
+ ? buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciStatus, preApprovalRequireCi })
1259
1314
  : null;
1260
1315
 
1261
- if (!sameHeadCleanConverged && (!roundCapReached || roundCapNewCycleRequired)) {
1316
+ if (!sameHeadCleanConverged && !postConvergenceReviewSuppressed && (!roundCapReached || roundCapNewCycleRequired)) {
1262
1317
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
1263
1318
  pushUnique(forbiddenActions, postDraftForbidden);
1264
1319
  return buildResult({
@@ -1334,7 +1389,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1334
1389
  forbiddenActions,
1335
1390
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1336
1391
  reason: roundCapReached
1337
- ? `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciStatus === "crediblyGreen" ? "credibly green" : "green"} CI). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`
1392
+ ? `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.`
1338
1393
  : (ciStatus === "crediblyGreen"
1339
1394
  ? "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."
1340
1395
  : "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."),
@@ -1365,10 +1420,12 @@ function evaluatePrGateCoordinationCore(input = {}) {
1365
1420
  forbiddenActions,
1366
1421
  nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
1367
1422
  reason: roundCapReached
1368
- ? `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${ciStatus === "crediblyGreen" ? "credibly green" : "green"} CI, so \`pre_approval_gate\` fallback is now the next legal boundary.`
1369
- : (ciStatus === "crediblyGreen"
1370
- ? "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."
1371
- : "The current head has a clean settled post-draft review cycle, so `pre_approval_gate` is now the next legal boundary."),
1423
+ ? `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.`
1424
+ : (postConvergenceReviewSuppressed && !sameHeadCleanConverged
1425
+ ? "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."
1426
+ : (ciStatus === "crediblyGreen"
1427
+ ? "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."
1428
+ : "The current head has a clean settled post-draft review cycle, so `pre_approval_gate` is now the next legal boundary.")),
1372
1429
  mergeStateStatus,
1373
1430
  conflictFiles,
1374
1431
  gateEvidenceNote: roundCapReached ? roundExhaustionGateEvidenceNote : null,
@@ -1497,7 +1554,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1497
1554
  allowedNextActions,
1498
1555
  forbiddenActions,
1499
1556
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1500
- reason: `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciStatus === "crediblyGreen" ? "credibly green" : "green"} CI). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
1557
+ 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.`,
1501
1558
  mergeStateStatus,
1502
1559
  conflictFiles,
1503
1560
  refinementArtifact,
@@ -1524,15 +1581,139 @@ function evaluatePrGateCoordinationCore(input = {}) {
1524
1581
  allowedNextActions,
1525
1582
  forbiddenActions,
1526
1583
  nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
1527
- reason: `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${ciStatus === "crediblyGreen" ? "credibly green" : "green"} CI, so \`pre_approval_gate\` fallback is now the next legal boundary (it reviews the current post-cap head; no further Copilot re-request is permitted).`,
1584
+ reason: `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 (it reviews the current post-cap head; no further Copilot re-request is permitted).`,
1528
1585
  mergeStateStatus,
1529
1586
  conflictFiles,
1530
1587
  refinementArtifact,
1531
- gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds }),
1588
+ gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciStatus, preApprovalRequireCi }),
1532
1589
  copilotReviewRoundCount,
1533
1590
  });
1534
1591
  }
1535
1592
 
1593
+ // Defensive gate-entry re-check for ROUND_CAP_REACHED (#1472): the compound
1594
+ // "unresolved threads OR non-clean CI" hard stop that copilot-loop-state.mjs
1595
+ // emits has no dedicated boundary of its own, so without this branch the
1596
+ // generic fallback below always names `report_blocked`. Both shipped
1597
+ // callers read lifecycleState and the CI/thread facts from ONE snapshot, and
1598
+ // the interpreter's own CI predicate is strictly wider than this branch's
1599
+ // (it also accepts `crediblyGreen`, #1371): whenever this branch's narrower
1600
+ // predicate and zero unresolved threads hold, the interpreter has already
1601
+ // classified the snapshot ROUND_CAP_CLEAN_FALLBACK, never ROUND_CAP_REACHED
1602
+ // (see the equivalence test in pr-gate-coordination.test.mjs). This branch
1603
+ // is therefore unreachable through the shipped callers — it is pure
1604
+ // defense-in-depth, demanded by issue 1472's corrected AC so the three
1605
+ // fields can never disagree if a future caller hands the evaluator a
1606
+ // round_cap_reached label alongside facts that satisfy the grant. The
1607
+ // predicate intentionally mirrors every other pre-approval CI boundary in
1608
+ // this file (success, or CI not required) — `crediblyGreen` is unconfirmed
1609
+ // CI and stays blocked here exactly as it does everywhere else (#1371). Any
1610
+ // other combination (threads still unresolved, an unknown thread count, or
1611
+ // CI not confirmed green) falls through unchanged to the generic default
1612
+ // below, preserving today's blocked behavior exactly.
1613
+ if (effectiveLifecycleState === STATE.ROUND_CAP_REACHED && roundCapReached) {
1614
+ const ciConfirmedGreen = ciStatus === "success" || !preApprovalRequireCi;
1615
+ // ciConfirmedGreen above is only true when ciStatus is literally "success"
1616
+ // OR CI is not required by config — never for an unconfirmed/failing/absent
1617
+ // status gated in only because requireCi is false. Naming it "green" in
1618
+ // human-read reason/evidence text for the latter case would be a false CI
1619
+ // claim (#1472 defer), so describe the actual grant basis instead.
1620
+ const ciClause = ciStatus === "success" ? "green CI" : "CI not required by config";
1621
+ if (unresolvedThreadCount === 0 && ciConfirmedGreen) {
1622
+ if (preApprovalGate.currentHeadClean) {
1623
+ // Inline title-marker check, mirroring ROUND_CAP_CLEAN_FALLBACK: the
1624
+ // outer post-pass guards FINAL_APPROVAL_READY and
1625
+ // PRE_APPROVAL_GATE_WINDOW, but NOT the DRAFT_GATE_NEEDED boundary the
1626
+ // sub-branch below can return — without this check a marker-titled
1627
+ // head would route to reconcile_draft_gate instead of blocking.
1628
+ const grantTitleMarkers = findBlockingTitleMarkers(prTitle);
1629
+ if (grantTitleMarkers.length > 0) {
1630
+ return buildTitleMarkerBlockedResult({
1631
+ input,
1632
+ currentHeadSha,
1633
+ draftGateAlreadySatisfied: true,
1634
+ draftGate,
1635
+ preApprovalGate,
1636
+ mergeStateStatus,
1637
+ conflictFiles,
1638
+ markers: grantTitleMarkers,
1639
+ refinementArtifact,
1640
+ });
1641
+ }
1642
+ // Mirror ROUND_CAP_CLEAN_FALLBACK/#579: a clean current head with no clean
1643
+ // draft_gate evidence must reconcile the draft gate rather than jump to
1644
+ // final approval.
1645
+ if (!draftGate.cleanEvidenceExists) {
1646
+ return buildDraftGateNeededForMergeResult({
1647
+ input,
1648
+ currentHeadSha,
1649
+ draftGate,
1650
+ preApprovalGate,
1651
+ mergeStateStatus,
1652
+ conflictFiles,
1653
+ underlyingReason: "Round-cap exhaustion fallback has clean pre_approval_gate but no clean draft_gate evidence.",
1654
+ refinementArtifact,
1655
+ effectiveLifecycleState,
1656
+ });
1657
+ }
1658
+
1659
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL]);
1660
+ pushUnique(forbiddenActions, [
1661
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1662
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
1663
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
1664
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1665
+ ]);
1666
+ return buildResult({
1667
+ repo: input.repo ?? null,
1668
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1669
+ currentHeadSha,
1670
+ lifecycleState: effectiveLifecycleState,
1671
+ loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1672
+ gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1673
+ draftGateAlreadySatisfied: true,
1674
+ draftGate,
1675
+ preApprovalGate,
1676
+ allowedNextActions,
1677
+ forbiddenActions,
1678
+ nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1679
+ 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.`,
1680
+ mergeStateStatus,
1681
+ conflictFiles,
1682
+ refinementArtifact,
1683
+ });
1684
+ }
1685
+
1686
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE]);
1687
+ pushUnique(forbiddenActions, [
1688
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1689
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
1690
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
1691
+ PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
1692
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1693
+ ]);
1694
+ return buildResult({
1695
+ repo: input.repo ?? null,
1696
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1697
+ currentHeadSha,
1698
+ lifecycleState: effectiveLifecycleState,
1699
+ loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1700
+ gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1701
+ draftGateAlreadySatisfied: true,
1702
+ draftGate,
1703
+ preApprovalGate,
1704
+ allowedNextActions,
1705
+ forbiddenActions,
1706
+ nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
1707
+ reason: `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads and ${ciClause}, so \`pre_approval_gate\` fallback is now the next legal boundary (it reviews the current post-cap head; no further Copilot re-request is permitted).`,
1708
+ mergeStateStatus,
1709
+ conflictFiles,
1710
+ refinementArtifact,
1711
+ gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciDescriptor: ciClause }),
1712
+ copilotReviewRoundCount,
1713
+ });
1714
+ }
1715
+ }
1716
+
1536
1717
  if (effectiveLifecycleState === STATE.LOW_SIGNAL_CONVERGED) {
1537
1718
  if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
1538
1719
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);