@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4

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.
Files changed (33) hide show
  1. package/package.json +7 -1
  2. package/src/analysis/diff-analyzer.mjs +31 -5
  3. package/src/claude/hook-decisions.mjs +14 -0
  4. package/src/cli/primitives.mjs +10 -2
  5. package/src/cli/retry-wrapper.mjs +14 -6
  6. package/src/config/config.mjs +1125 -240
  7. package/src/config/extension-defaults.yaml +217 -426
  8. package/src/github/copilot-helpers.mjs +139 -18
  9. package/src/github/issue-ops.mjs +556 -0
  10. package/src/github/ownership-helpers.mjs +79 -0
  11. package/src/github/review-threads.mjs +44 -3
  12. package/src/loop/bash-command-classify.mjs +35 -5
  13. package/src/loop/conductor-routing.mjs +1 -1
  14. package/src/loop/copilot-ci-status.mjs +76 -0
  15. package/src/loop/copilot-loop-iterations.mjs +1 -2
  16. package/src/loop/copilot-loop-state.mjs +9 -5
  17. package/src/loop/default-branch-guard.mjs +380 -0
  18. package/src/loop/gate-carry-forward.mjs +29 -2
  19. package/src/loop/gate-fanin.mjs +481 -31
  20. package/src/loop/handoff-envelope.mjs +43 -23
  21. package/src/loop/main-checkout-ff.mjs +58 -0
  22. package/src/loop/pr-gate-coordination.mjs +204 -47
  23. package/src/loop/pr-title-markers.mjs +76 -15
  24. package/src/loop/queue-board-sync.mjs +26 -9
  25. package/src/loop/reviewer-loop-state.mjs +2 -2
  26. package/src/loop/ui-e2e-scoping.mjs +2 -0
  27. package/src/loop/ui-review-drive.mjs +23 -0
  28. package/src/loop/ui-review-provision.mjs +36 -0
  29. package/src/projects/resolve-project.mjs +14 -7
  30. package/src/tracker/adapter.mjs +127 -0
  31. package/src/tracker/github-adapter.mjs +150 -0
  32. package/src/tracker/index.mjs +50 -0
  33. package/src/tracker/noop-adapter.mjs +35 -0
@@ -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 { 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
  ],
@@ -354,20 +354,30 @@ function applySpecSourceVariant(criteria, specSource) {
354
354
 
355
355
  function deriveGateConfig(settings, subGate) {
356
356
  const gateKey = subGate === "pre-approval" ? "preApproval" : subGate;
357
- const gateSettings = settings?.gates?.[gateKey];
358
- if (!gateSettings) return undefined;
359
-
360
- const angles = Array.isArray(gateSettings.angles) ? [...gateSettings.angles] : [];
361
- const excludeAngles = Array.isArray(gateSettings.excludeAngles) ? [...gateSettings.excludeAngles] : [];
362
- const filteredAngles = angles.filter((a) => !excludeAngles.includes(a));
363
-
357
+ if (!settings?.gates?.[gateKey]) return undefined;
358
+
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.
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])];
364
376
  return {
365
- angles: filteredAngles,
366
- excludeAngles: excludeAngles.length > 0 ? excludeAngles : undefined,
367
- blockCleanOnFindingSeverities: Array.isArray(gateSettings.blockCleanOnFindingSeverities)
368
- ? [...gateSettings.blockCleanOnFindingSeverities]
369
- : ["must-fix"],
370
- requireCi: gateSettings.requireCi ?? true,
377
+ angles,
378
+ excludeAngles: resolved.excludeAngles.length > 0 ? resolved.excludeAngles : undefined,
379
+ blockCleanOnFindingSeverities: resolved.blockCleanOnFindingSeverities,
380
+ requireCi: resolved.requireCi,
371
381
  };
372
382
  }
373
383
 
@@ -614,14 +624,9 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
614
624
 
615
625
  const envelope = {
616
626
  handoffVersion: ENVELOPE_HANDOFF_VERSION,
617
- derivedAt: (now ?? new Date()).toISOString(),
618
627
 
619
628
  target,
620
629
  currentGate: subGate,
621
- currentHeadSha: gs.currentHeadSha,
622
- ciStatus: gs.ciStatus,
623
- unresolvedThreadCount: gs.unresolvedThreadCount,
624
- copilotRoundCount: gs.copilotRoundCount,
625
630
  maxCopilotRounds: settings?.refinement?.maxCopilotRounds ?? 5,
626
631
  executionMode,
627
632
 
@@ -675,6 +680,20 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
675
680
  envelope.specSource = specSource;
676
681
  }
677
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
+
678
697
  return deepFreeze(envelope);
679
698
  }
680
699
 
@@ -941,9 +960,10 @@ export function validateHandoffEnvelope(envelope) {
941
960
  }
942
961
  }
943
962
 
944
- // ----- derivedAt (informational, warn on missing) -----
945
- if (typeof envelope.derivedAt !== "string" || !envelope.derivedAt.trim()) {
946
- 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" });
947
967
  }
948
968
 
949
969
  return {
@@ -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,
@@ -1380,41 +1437,17 @@ function evaluatePrGateCoordinationCore(input = {}) {
1380
1437
  // exhausted and the current head is clean (zero unresolved threads + green CI)
1381
1438
  // — including a POST-CAP head Copilot has not (and will not) re-review, since
1382
1439
  // no further Copilot round is permitted. Re-requesting review is illegal here,
1383
- // so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW. It routes to the
1384
- // pre_approval_gate, which reviews the post-cap head itself (per #848). The CI
1385
- // guards below still hold (failing / credibly-green CI blocks), and conflicts /
1386
- // blocked states are handled earlier, so genuinely-blocked states still forbid
1387
- // pre_approval. Mirrors LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
1440
+ // so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW — nor at a forced
1441
+ // rerequest for a post-convergence significant change (#1387): the cap makes
1442
+ // that rerequest impossible (request-copilot-review suppresses it), so a
1443
+ // significant change discovered here is reviewed by the pre_approval_gate
1444
+ // fan-out itself, on the post-cap head, same as any other clean fallback. It
1445
+ // routes to the pre_approval_gate, which reviews the post-cap head itself
1446
+ // (per #848). The CI guards below still hold (failing / credibly-green CI
1447
+ // blocks), and conflicts / blocked states are handled earlier, so
1448
+ // genuinely-blocked states still forbid pre_approval. Mirrors
1449
+ // LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
1388
1450
  if (effectiveLifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK) {
1389
- if (roundCapNewCycleRequired) {
1390
- pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW]);
1391
- pushUnique(forbiddenActions, [
1392
- PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1393
- PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
1394
- PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
1395
- PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
1396
- PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1397
- ]);
1398
- return buildResult({
1399
- repo: input.repo ?? null,
1400
- pr: Number.isInteger(input.pr) ? input.pr : null,
1401
- currentHeadSha,
1402
- lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
1403
- loopDisposition: DISPOSITION.ACTION_REQUIRED,
1404
- gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1405
- draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1406
- draftGate,
1407
- preApprovalGate,
1408
- allowedNextActions,
1409
- forbiddenActions,
1410
- nextAction: PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
1411
- reason: `The previous Copilot cycle converged at the round cap (${copilotReviewRoundCount}/${maxCopilotRounds}), but significant post-convergence changes landed on the current head. Open a new cycle and re-request Copilot review before entering \`pre_approval_gate\`.`,
1412
- mergeStateStatus,
1413
- conflictFiles,
1414
- refinementArtifact,
1415
- copilotReviewRoundCount,
1416
- });
1417
- }
1418
1451
  if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
1419
1452
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
1420
1453
  pushUnique(forbiddenActions, postDraftForbidden);
@@ -1521,7 +1554,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1521
1554
  allowedNextActions,
1522
1555
  forbiddenActions,
1523
1556
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1524
- 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.`,
1525
1558
  mergeStateStatus,
1526
1559
  conflictFiles,
1527
1560
  refinementArtifact,
@@ -1548,15 +1581,139 @@ function evaluatePrGateCoordinationCore(input = {}) {
1548
1581
  allowedNextActions,
1549
1582
  forbiddenActions,
1550
1583
  nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
1551
- 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).`,
1552
1585
  mergeStateStatus,
1553
1586
  conflictFiles,
1554
1587
  refinementArtifact,
1555
- gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds }),
1588
+ gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds, ciStatus, preApprovalRequireCi }),
1556
1589
  copilotReviewRoundCount,
1557
1590
  });
1558
1591
  }
1559
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
+
1560
1717
  if (effectiveLifecycleState === STATE.LOW_SIGNAL_CONVERGED) {
1561
1718
  if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
1562
1719
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
@@ -12,30 +12,91 @@
12
12
  * It is intentionally pure and side-effect free.
13
13
  */
14
14
 
15
+ /**
16
+ * Builds a status-marker tester for a bare word like "WIP" or "DRAFT".
17
+ *
18
+ * A status marker asserts, on its own, that the PR is unfinished: bracketed
19
+ * (`[WIP]`), parenthesized (`(draft)`), colon-suffixed (`WIP:`), or the
20
+ * entire title with nothing else attached (a bare standalone `DRAFT`). A
21
+ * plain `\bWORD\b` match also hits the same word inside a compound noun
22
+ * phrase that names a component instead — `draft-gate`, `draft gate`,
23
+ * `wip-branch` — because a hyphen or a space is itself a word boundary. An
24
+ * underscore is a word character, so `draft_gate` never matched `\bDRAFT\b`
25
+ * to begin with; it is listed among the exempt forms for consistency, not
26
+ * because it was ever flagged. None of those forms satisfy any construction
27
+ * below, so a component name is left unflagged while a real status claim
28
+ * still is. "swipe"/"wiped"/"drafting" already fail every construction
29
+ * because there is no boundary between the marker word and the letters that
30
+ * follow it; a hyphen-prefixed compound like "re-draft" DOES create such a
31
+ * boundary (`\b` sees the hyphen).
32
+ *
33
+ * A trailing tag set off by a dash (`Fix login flow — WIP`) is deliberately
34
+ * NOT its own construction, even though it reads as a real status claim.
35
+ * Any dash-based construction narrow enough to close a title's tag also
36
+ * reopens the compound-noun false positive whenever the joiner is a
37
+ * different dash character (`Handle en dash–draft–gate naming`), and any fix
38
+ * for that narrows the construction until it drops real status claims that
39
+ * were previously caught (`Fix login flow — WIP.`, `— WIP (rebasing)`). The
40
+ * bracket/paren/colon/standalone set stays free of both failure modes, so a
41
+ * dash-set-off marker is left unflagged; `WIP:`/`DRAFT:` remains one
42
+ * keystroke away and stays flagged, the same trade already accepted for
43
+ * `WIP foo bar`.
44
+ *
45
+ * The bracket/paren/colon constructions all require the opening delimiter
46
+ * (`[`, `(`, or the marker word itself for colon) to sit at the start of the
47
+ * title or after whitespace — never directly after a letter, `/`, or `-` —
48
+ * so a conventional-commit scope (`fix(draft): support x`), a path segment
49
+ * (`app/[draft]/page.tsx`), a scoped label (`feat/draft: x`, `docs/wip:
50
+ * notes`), and a hyphen-prefixed compound (`re-draft: cleanup`) are all read
51
+ * as a component name, not a status claim — the same anchoring rule as the
52
+ * hyphen/underscore/space compound-noun exemption above. This does introduce
53
+ * one accepted false-negative class: a marker preceded by punctuation other
54
+ * than whitespace with no space of its own, e.g. `Fix bug,(draft)` or `Fix
55
+ * login(wip)`. Widening the anchor to "start, whitespace, or punctuation"
56
+ * would also re-admit the very forms (`/`, `-`) the anchor exists to
57
+ * exclude, so the narrower, whitespace-only anchor is kept and this
58
+ * false-negative class is accepted as its cost.
59
+ *
60
+ * The colon construction additionally requires the colon itself to CLOSE the
61
+ * tag — followed by whitespace or the end of the title, never directly by
62
+ * another character — so a scheme/tag/ref that merely starts with the
63
+ * marker word (`draft://`, `draft:latest`, `wip:branch`) is read as an
64
+ * unrelated identifier, not a status claim.
65
+ */
66
+ function statusMarkerTester(word) {
67
+ const bracket = new RegExp(`(?:^|\\s)\\[\\s*${word}\\s*\\]`, "i");
68
+ const paren = new RegExp(`(?:^|\\s)\\(\\s*${word}\\s*\\)`, "i");
69
+ const colon = new RegExp(`(?:^|\\s)${word}\\s*:(?:\\s|$)`, "i");
70
+ const standalone = new RegExp(`^\\s*${word}\\s*$`, "i");
71
+ return (title) => bracket.test(title) || paren.test(title) || colon.test(title)
72
+ || standalone.test(title);
73
+ }
74
+
15
75
  /**
16
76
  * Canonical merge-blocking markers and how to detect them.
17
77
  *
18
- * Word-boundary matching is used for the alphabetic markers so that real words
19
- * are not false-positives (e.g. "swipe"/"wiped" must not match WIP;
20
- * "drafting"/"redraft" must not match DRAFT). Bracket/paren/colon punctuation
21
- * (`[WIP]`, `(wip)`, `WIP:`) are non-word characters, so `\b` boundaries still
22
- * match those variants. The construction emoji has no word boundary, so it is
23
- * matched literally anywhere in the title.
78
+ * "DO NOT MERGE" is a three-word phrase with no plausible compound-noun
79
+ * reading, so it keeps simple word-boundary matching. The construction emoji
80
+ * has no word boundary at all, so it is matched literally anywhere in the
81
+ * title.
24
82
  */
25
83
  const MARKER_MATCHERS = [
26
- { label: "WIP", pattern: /\bWIP\b/i },
27
- { label: "DRAFT", pattern: /\bDRAFT\b/i },
84
+ { label: "WIP", test: statusMarkerTester("WIP") },
85
+ { label: "DRAFT", test: statusMarkerTester("DRAFT") },
28
86
  // Flexible (any) whitespace between the phrase words, case-insensitive.
29
- { label: "DO NOT MERGE", pattern: /\bDO\s+NOT\s+MERGE\b/i },
30
- { label: "🚧", pattern: /🚧/u },
87
+ { label: "DO NOT MERGE", test: (title) => /\bDO\s+NOT\s+MERGE\b/i.test(title) },
88
+ { label: "🚧", test: (title) => /🚧/u.test(title) },
31
89
  ];
32
90
 
33
91
  /**
34
92
  * Finds merge-blocking markers in a PR title.
35
93
  *
36
- * Returns the canonical labels of every matched marker, de-duped and in a
37
- * stable order (the declaration order of {@link MARKER_MATCHERS}). Returns an
38
- * empty array when the title is clean, empty, or not a string.
94
+ * Returns the canonical labels of every matched marker, in a stable order
95
+ * (the declaration order of {@link MARKER_MATCHERS}). Each label can appear
96
+ * at most once: MARKER_MATCHERS visits each entry exactly once and every
97
+ * entry's label is distinct, so the result is de-duped by construction —
98
+ * there is no separate dedupe step to fail. Returns an empty array when the
99
+ * title is clean, empty, or not a string.
39
100
  *
40
101
  * @param {unknown} title - The PR title to inspect.
41
102
  * @returns {string[]} Canonical labels of matched markers, e.g. ["WIP"] or
@@ -47,8 +108,8 @@ export function findBlockingTitleMarkers(title) {
47
108
  }
48
109
 
49
110
  const matched = [];
50
- for (const { label, pattern } of MARKER_MATCHERS) {
51
- if (pattern.test(title) && !matched.includes(label)) {
111
+ for (const { label, test } of MARKER_MATCHERS) {
112
+ if (test(title)) {
52
113
  matched.push(label);
53
114
  }
54
115
  }