@dev-loops/core 1.0.0-rc.3 → 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.
@@ -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 {
@@ -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]);
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Deterministic state machine and bounded planning/merge contracts for reviewer-side PR loops.
3
3
  */
4
+ import { SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
4
5
 
5
6
  export const REVIEWER_STATE = Object.freeze({
6
7
  WAITING_FOR_REVIEW_REQUEST: "waiting_for_review_request",
@@ -105,7 +106,6 @@ const VALID_LOCAL_RUN_STATUSES = new Set(["none", "running", "completed", "faile
105
106
  const VALID_LOCAL_MERGE_STATUSES = new Set(["none", "ready", "failed"]);
106
107
  const VALID_DRAFT_NOTIFICATION_STATUSES = new Set(["none", "notified"]);
107
108
  const VALID_SUBMISSION_STATUSES = new Set(["none", "submitted", "failed"]);
108
- const VALID_SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
109
109
 
110
110
  const SUPPORTED_REVIEW_ANGLES = Object.freeze([
111
111
  "correctness",
@@ -143,7 +143,7 @@ function normalizeSubmittedReviewState(value) {
143
143
  }
144
144
 
145
145
  const normalized = value.trim().toUpperCase();
146
- return VALID_SUBMITTED_REVIEW_STATES.has(normalized) ? normalized : null;
146
+ return SUBMITTED_REVIEW_STATES.has(normalized) ? normalized : null;
147
147
  }
148
148
 
149
149
  /**
@@ -38,8 +38,10 @@ export const VIEWER_SOURCE_PATHS = Object.freeze([
38
38
  export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
39
39
  "docs/presentations/introducing-dev-loops.html",
40
40
  "docs/presentations/dev-loops-deep-dive.html",
41
+ "docs/presentations/how-dev-loops-decided-itself.html",
41
42
  "docs/articles/introducing-dev-loops.html",
42
43
  "docs/articles/dev-loops-deep-dive.html",
44
+ "docs/articles/how-dev-loops-decided-itself.html",
43
45
  ]);
44
46
 
45
47
  export const VIEWER_ARTIFACT_ID = "inspect-run-viewer";
@@ -44,6 +44,19 @@ export function isErrorResponseStatus(status) {
44
44
  return typeof status === "number" && (status < 200 || status >= 400);
45
45
  }
46
46
 
47
+ /** The one owner of the request-abort carve-out: a request the browser itself
48
+ * aborted carries no defect signal. Navigating away cancels in-flight asset
49
+ * requests, so these appear on every multi-step flow. Matched per engine:
50
+ * WebKit reports "cancelled", Chromium "net::ERR_ABORTED", Firefox
51
+ * "NS_BINDING_ABORTED". Matching is case-insensitive and substring-based because
52
+ * engines wrap the token in longer text. A genuine DNS/connection/TLS failure
53
+ * carries a different token and is still classified must-fix. */
54
+ export function isAbortedRequestFailure(failure) {
55
+ if (typeof failure !== "string") return false;
56
+ const f = failure.toLowerCase();
57
+ return f.includes("cancelled") || f.includes("canceled") || f.includes("err_aborted") || f.includes("ns_binding_aborted");
58
+ }
59
+
47
60
  /** Bound the stack text carried onto a page-error failure so a runaway stack
48
61
  * (or a synthetic error with a huge stack) can't bloat the feed envelope. Keeps
49
62
  * the head — the top frames, where the throwing file:line sits. Exported so the
@@ -157,6 +170,16 @@ export function classifyFailures({
157
170
  }
158
171
 
159
172
  for (const f of requestFailures) {
173
+ // A request the BROWSER aborted is not evidence of a defect: navigating away
174
+ // cancels every asset request still in flight, so a flow with more than one
175
+ // `goto` manufactures one of these per unfinished image/font on the page it
176
+ // left. Measured on sofatutor 2026-08-05: a clean two-goto admin2 walk
177
+ // produced 13, all "cancelled", all classified must-fix — and since
178
+ // `ok: failures.length === 0`, they failed an otherwise passing drive and
179
+ // would have been posted as findings against the PR. This is the request-abort
180
+ // counterpart of the 3xx carve-out in isErrorResponseStatus: a real
181
+ // server/network fault still arrives with its own failure text and is kept.
182
+ if (isAbortedRequestFailure(f.failure)) continue;
160
183
  failures.push({
161
184
  kind: "request-failed",
162
185
  severity: MUST_FIX,