@dev-loops/core 0.2.6 → 0.3.0

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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * gate-fanin.mjs — pure fan-in consolidation + cap/batch planning for the
3
+ * gate-review fork sub-loop (epic #867, Phase 3 / #878).
4
+ *
5
+ * IMPORTANT: this module is PURE. It performs no I/O and never spawns agents.
6
+ * Spawning the per-angle scoped `review` subagents is an agent-orchestrated
7
+ * skill procedure (a node script cannot spawn Claude subagents). This module
8
+ * only consolidates the structured per-angle findings artifacts the fan-out
9
+ * produced, decides the gate verdict, plans the parallel/sequential batching of
10
+ * the fan-out, and maps consolidated findings into the `--findings` JSON shape
11
+ * understood by scripts/github/write-gate-findings-log.mjs.
12
+ *
13
+ * Per-angle review artifact shape (produced by the scoped `review` agent):
14
+ * {
15
+ * angle: string,
16
+ * verdict: "clean" | "findings_present",
17
+ * findings: [{ severity, file?, line?, summary, recommendation? }]
18
+ * }
19
+ *
20
+ * Severity vocabulary (mirrors write-gate-findings-log.mjs):
21
+ * "must-fix" | "worth-fixing-now" | "defer"
22
+ */
23
+
24
+ const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
25
+ const VALID_VERDICTS = new Set(["clean", "findings_present"]);
26
+
27
+ /**
28
+ * Default cap on parallel fan-out reviewers when a caller does not supply one.
29
+ * Mirrors the config default (gates.maxFanoutReviewers).
30
+ */
31
+ export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
32
+
33
+ /**
34
+ * Validate a single per-angle review result. Returns an error string when the
35
+ * result is malformed, or null when it is well-formed.
36
+ *
37
+ * @param {unknown} result
38
+ * @returns {string|null}
39
+ */
40
+ function validateAngleResult(result) {
41
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
42
+ return "angle result must be an object";
43
+ }
44
+ const r = /** @type {Record<string, unknown>} */ (result);
45
+ if (typeof r.angle !== "string" || r.angle.trim().length === 0) {
46
+ return "angle result is missing a non-empty 'angle'";
47
+ }
48
+ if (typeof r.verdict !== "string" || !VALID_VERDICTS.has(r.verdict)) {
49
+ return `angle '${r.angle}' has invalid verdict (expected clean|findings_present)`;
50
+ }
51
+ if (!Array.isArray(r.findings)) {
52
+ return `angle '${r.angle}' is missing a 'findings' array`;
53
+ }
54
+ for (const f of r.findings) {
55
+ if (!f || typeof f !== "object" || Array.isArray(f)) {
56
+ return `angle '${r.angle}' has a non-object finding`;
57
+ }
58
+ const finding = /** @type {Record<string, unknown>} */ (f);
59
+ if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(finding.severity)) {
60
+ return `angle '${r.angle}' has a finding with invalid severity (expected must-fix|worth-fixing-now|defer)`;
61
+ }
62
+ if (typeof finding.summary !== "string" || finding.summary.trim().length === 0) {
63
+ return `angle '${r.angle}' has a finding without a summary`;
64
+ }
65
+ }
66
+ // findings_present must carry at least one finding; clean must carry none.
67
+ if (r.verdict === "findings_present" && r.findings.length === 0) {
68
+ return `angle '${r.angle}' reported findings_present but has no findings`;
69
+ }
70
+ if (r.verdict === "clean" && r.findings.length > 0) {
71
+ return `angle '${r.angle}' reported clean but carries findings`;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Consolidate the parallel per-angle review results into one gate verdict +
78
+ * a merged, flattened findings list. Pure.
79
+ *
80
+ * Verdict rules:
81
+ * - "blocked": any angle result is malformed/missing (the gate could not
82
+ * produce a trustworthy verdict).
83
+ * - "clean": all results valid AND no finding carries a severity present in
84
+ * `blockCleanOnFindingSeverities`.
85
+ * - "findings_present": all results valid AND at least one finding carries a
86
+ * blocking severity.
87
+ *
88
+ * @param {object} input
89
+ * @param {Array<unknown>} input.angleResults — per-angle review artifacts
90
+ * @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["must-fix"])
91
+ * @returns {{
92
+ * verdict: "clean"|"findings_present"|"blocked",
93
+ * findings: Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>,
94
+ * counts: { angles: number, findings: number, blocking: number, bySeverity: Record<string, number> },
95
+ * malformed: Array<{ index: number, reason: string }>
96
+ * }}
97
+ */
98
+ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities } = {}) {
99
+ const results = Array.isArray(angleResults) ? angleResults : [];
100
+ const blocking = new Set(
101
+ Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
102
+ ? blockCleanOnFindingSeverities
103
+ : ["must-fix"],
104
+ );
105
+
106
+ const malformed = [];
107
+ results.forEach((r, index) => {
108
+ const err = validateAngleResult(r);
109
+ if (err) malformed.push({ index, reason: err });
110
+ });
111
+
112
+ const bySeverity = { "must-fix": 0, "worth-fixing-now": 0, "defer": 0 };
113
+ /** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
114
+ const findings = [];
115
+ let blockingCount = 0;
116
+
117
+ if (malformed.length === 0) {
118
+ for (const r of results) {
119
+ const angle = r.angle.trim();
120
+ for (const f of r.findings) {
121
+ const isBlocking = blocking.has(f.severity);
122
+ if (isBlocking) blockingCount += 1;
123
+ bySeverity[f.severity] += 1;
124
+ const entry = {
125
+ severity: f.severity,
126
+ angle,
127
+ summary: String(f.summary).trim(),
128
+ // Blocking findings default to accepted-for-fix; non-blocking default
129
+ // to deferred. The fix cycle / operator can override the disposition.
130
+ disposition: isBlocking ? "accepted-for-fix" : "deferred",
131
+ };
132
+ if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
133
+ if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
134
+ if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
135
+ entry.recommendation = f.recommendation.trim();
136
+ }
137
+ findings.push(entry);
138
+ }
139
+ }
140
+ }
141
+
142
+ let verdict;
143
+ if (malformed.length > 0) {
144
+ verdict = "blocked";
145
+ } else if (blockingCount > 0) {
146
+ verdict = "findings_present";
147
+ } else {
148
+ verdict = "clean";
149
+ }
150
+
151
+ return {
152
+ verdict,
153
+ findings,
154
+ counts: {
155
+ angles: results.length,
156
+ findings: findings.length,
157
+ blocking: blockingCount,
158
+ bySeverity,
159
+ },
160
+ malformed,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Map consolidated findings into the `--findings` JSON shape consumed by
166
+ * scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
167
+ * disposition, optional files). Pure.
168
+ *
169
+ * @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string}>} findings
170
+ * @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[]}>}
171
+ */
172
+ export function toFindingsLogShape(findings) {
173
+ const list = Array.isArray(findings) ? findings : [];
174
+ return list.map((f) => {
175
+ const entry = {
176
+ severity: f.severity,
177
+ angle: f.angle,
178
+ summary: f.summary,
179
+ };
180
+ if (typeof f.disposition === "string" && f.disposition.trim().length > 0) {
181
+ entry.disposition = f.disposition.trim();
182
+ }
183
+ if (typeof f.file === "string" && f.file.trim().length > 0) {
184
+ entry.files = [f.file.trim()];
185
+ } else if (Array.isArray(f.files)) {
186
+ const files = f.files.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
187
+ if (files.length > 0) entry.files = files;
188
+ }
189
+ return entry;
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Plan how a resolved angle set fans out across the reviewer cap. Pure.
195
+ *
196
+ * When `angles.length <= maxReviewers`, all reviewers run in a single parallel
197
+ * batch (no degradation). When it exceeds the cap, the overflow is split into
198
+ * sequential batches of at most `maxReviewers` each, and `degraded` is true so
199
+ * the skill can record the sequential degradation in the gate evidence.
200
+ *
201
+ * @param {string[]} angles
202
+ * @param {number} [maxReviewers] — default DEFAULT_MAX_FANOUT_REVIEWERS (8)
203
+ * @returns {{ batches: string[][], degraded: boolean }}
204
+ */
205
+ export function planFanoutBatches(angles, maxReviewers = DEFAULT_MAX_FANOUT_REVIEWERS) {
206
+ const list = Array.isArray(angles)
207
+ ? angles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim())
208
+ : [];
209
+ const cap = Number.isInteger(maxReviewers) && maxReviewers > 0
210
+ ? maxReviewers
211
+ : DEFAULT_MAX_FANOUT_REVIEWERS;
212
+
213
+ if (list.length === 0) {
214
+ return { batches: [], degraded: false };
215
+ }
216
+
217
+ const batches = [];
218
+ for (let i = 0; i < list.length; i += cap) {
219
+ batches.push(list.slice(i, i + cap));
220
+ }
221
+ return { batches, degraded: batches.length > 1 };
222
+ }
@@ -1,6 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { parseCliTokens } from "../cli/primitives.mjs";
5
+
4
6
  export function createDefaultPhaseManifest(phase) {
5
7
  return {
6
8
  phase,
@@ -134,45 +136,19 @@ export async function ensurePhaseFiles(projectRoot, phase, patch = {}) {
134
136
  };
135
137
  }
136
138
 
137
- function requireOptionValue(args, flag) {
138
- const value = args.shift();
139
-
140
- if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
141
- throw new Error(`Missing value for ${flag}`);
142
- }
143
-
144
- return value;
145
- }
146
-
147
139
  export function parseCliArgs(argv) {
148
- const args = [...argv];
140
+ const { values } = parseCliTokens(argv, {
141
+ "project-root": { type: "string" },
142
+ phase: { type: "string" },
143
+ patch: { type: "string" },
144
+ });
145
+
149
146
  const options = {
150
- projectRoot: process.cwd(),
151
- phase: undefined,
152
- patch: {},
147
+ projectRoot: values.has("project-root") ? values.get("project-root") : process.cwd(),
148
+ phase: values.get("phase"),
149
+ patch: values.has("patch") ? JSON.parse(values.get("patch")) : {},
153
150
  };
154
151
 
155
- while (args.length > 0) {
156
- const token = args.shift();
157
-
158
- if (token === "--project-root") {
159
- options.projectRoot = requireOptionValue(args, "--project-root");
160
- continue;
161
- }
162
-
163
- if (token === "--phase") {
164
- options.phase = requireOptionValue(args, "--phase");
165
- continue;
166
- }
167
-
168
- if (token === "--patch") {
169
- options.patch = JSON.parse(requireOptionValue(args, "--patch"));
170
- continue;
171
- }
172
-
173
- throw new Error(`Unknown argument: ${token}`);
174
- }
175
-
176
152
  if (!options.phase) {
177
153
  throw new Error("Missing required --phase <phase-name> argument");
178
154
  }
@@ -479,6 +479,11 @@ function buildResult({
479
479
  * @param {number} params.copilotReviewRoundCount
480
480
  * @param {number|null} params.maxCopilotRounds
481
481
  * @param {boolean} params.sameHeadCleanConverged
482
+ * @param {boolean} [params.roundCapCleanFallback=false] - interpreter resolved the
483
+ * round-cap clean fallback (#896): rounds exhausted + clean threads + green CI on
484
+ * the current head, including a post-cap head Copilot has not (and will not)
485
+ * re-review. No further Copilot round is permitted, so the formal-request guard
486
+ * must not fire — the pre_approval_gate reviews the post-cap head (per #848).
482
487
  * @param {string} params.gateBoundary - current gate boundary
483
488
  * @returns {boolean}
484
489
  */
@@ -488,6 +493,7 @@ export function shouldGuardCopilotReviewRequest({
488
493
  copilotReviewEverFormallyRequested = false,
489
494
  maxCopilotRounds = null,
490
495
  sameHeadCleanConverged = false,
496
+ roundCapCleanFallback = false,
491
497
  gateBoundary,
492
498
  }) {
493
499
  const gateBoundariesRequiringCopilotFormalRequest = new Set([
@@ -513,12 +519,17 @@ export function shouldGuardCopilotReviewRequest({
513
519
  if (copilotReviewEverFormallyRequested) {
514
520
  return false;
515
521
  }
516
- // Round-cap clean fallback: exhausted rounds + clean converged
517
- // does not require a formal re-request.
522
+ // Round-cap clean fallback: exhausted rounds + clean converged does not require
523
+ // a formal re-request. This covers two shapes of "clean at the cap":
524
+ // - sameHeadCleanConverged: the current head itself carries a clean Copilot review;
525
+ // - roundCapCleanFallback (#896): the head is clean (zero unresolved threads + green
526
+ // CI) but Copilot has NOT reviewed THIS head (e.g. a post-cap commit). No further
527
+ // Copilot round is permitted, so forcing a formal request would dead-end the loop;
528
+ // the pre_approval_gate reviews the post-cap head instead (per #848).
518
529
  const roundCapReached = maxCopilotRounds !== null
519
530
  && typeof copilotReviewRoundCount === "number"
520
531
  && copilotReviewRoundCount >= maxCopilotRounds;
521
- if (roundCapReached && sameHeadCleanConverged) {
532
+ if (roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback)) {
522
533
  return false;
523
534
  }
524
535
  return true;
@@ -1266,6 +1277,175 @@ function evaluatePrGateCoordinationCore(input = {}) {
1266
1277
  });
1267
1278
  }
1268
1279
 
1280
+ // Round-cap clean fallback (#896, #848): the Copilot review round cap is
1281
+ // exhausted and the current head is clean (zero unresolved threads + green CI)
1282
+ // — including a POST-CAP head Copilot has not (and will not) re-review, since
1283
+ // no further Copilot round is permitted. Re-requesting review is illegal here,
1284
+ // so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW. It routes to the
1285
+ // pre_approval_gate, which reviews the post-cap head itself (per #848). The CI
1286
+ // guards below still hold (failing / credibly-green CI blocks), and conflicts /
1287
+ // blocked states are handled earlier, so genuinely-blocked states still forbid
1288
+ // pre_approval. Mirrors LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
1289
+ if (effectiveLifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK) {
1290
+ if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
1291
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
1292
+ pushUnique(forbiddenActions, postDraftForbidden);
1293
+ return buildResult({
1294
+ repo: input.repo ?? null,
1295
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1296
+ currentHeadSha,
1297
+ lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1298
+ loopDisposition: DISPOSITION.BLOCKED,
1299
+ gateBoundary: PR_CHECKPOINT.BLOCKED,
1300
+ draftGateAlreadySatisfied: true,
1301
+ draftGate,
1302
+ preApprovalGate,
1303
+ allowedNextActions,
1304
+ forbiddenActions,
1305
+ nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
1306
+ reason: ciStatus === "crediblyGreen"
1307
+ ? "The Copilot round cap is exhausted, but the current head has unconfirmed CI (credibly green), so gate progression remains blocked until CI is confirmed green."
1308
+ : "The Copilot round cap is exhausted, but the current head still has failing CI, so gate progression remains blocked until the failing checks are fixed and revalidated.",
1309
+ mergeStateStatus,
1310
+ conflictFiles,
1311
+ refinementArtifact,
1312
+ });
1313
+ }
1314
+ if (ciStatus === "pending" || ciStatus === "none") {
1315
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
1316
+ pushUnique(forbiddenActions, postDraftForbidden);
1317
+ return buildResult({
1318
+ repo: input.repo ?? null,
1319
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1320
+ currentHeadSha,
1321
+ lifecycleState: STATE.WAITING_FOR_CI,
1322
+ loopDisposition: DISPOSITION.PENDING,
1323
+ gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1324
+ draftGateAlreadySatisfied: true,
1325
+ draftGate,
1326
+ preApprovalGate,
1327
+ allowedNextActions,
1328
+ forbiddenActions,
1329
+ nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_CI,
1330
+ reason: "The Copilot round cap is exhausted, but the current head does not yet have green or credibly green CI, so `pre_approval_gate` remains illegal until CI settles.",
1331
+ mergeStateStatus,
1332
+ conflictFiles,
1333
+ refinementArtifact,
1334
+ });
1335
+ }
1336
+ if (preApprovalGate.currentHeadClean) {
1337
+ const titleMarkers = findBlockingTitleMarkers(prTitle);
1338
+ if (titleMarkers.length > 0) {
1339
+ return buildTitleMarkerBlockedResult({
1340
+ input,
1341
+ currentHeadSha,
1342
+ draftGateAlreadySatisfied: true,
1343
+ draftGate,
1344
+ preApprovalGate,
1345
+ mergeStateStatus,
1346
+ conflictFiles,
1347
+ markers: titleMarkers,
1348
+ refinementArtifact,
1349
+ });
1350
+ }
1351
+ if (requireRetrospectiveGate) {
1352
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1353
+ if (!retrospectiveGate.approved) {
1354
+ return buildRetrospectiveGatePendingResult({
1355
+ input,
1356
+ currentHeadSha,
1357
+ draftGateAlreadySatisfied: true,
1358
+ draftGate,
1359
+ preApprovalGate,
1360
+ mergeStateStatus,
1361
+ conflictFiles,
1362
+ reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
1363
+ refinementArtifact,
1364
+ });
1365
+ }
1366
+ }
1367
+
1368
+ // Mirror LOW_SIGNAL_CONVERGED (#579): a clean current head with no clean
1369
+ // draft_gate evidence must reconcile the draft gate rather than jump to
1370
+ // final approval. This keeps the core handler consistent with the
1371
+ // detect-pr-gate-coordination-state #579 post-pass, which unconditionally
1372
+ // downgrades FINAL_APPROVAL_READY → DRAFT_GATE_NEEDED when
1373
+ // draftGate.cleanEvidenceExists is false (no ROUND_CAP_CLEAN_FALLBACK
1374
+ // exemption). Without this guard the final-approval-without-draft-gate
1375
+ // branch is dead through the real script and asserts behavior it never
1376
+ // produces.
1377
+ if (!draftGate.cleanEvidenceExists) {
1378
+ return buildDraftGateNeededForMergeResult({
1379
+ input,
1380
+ currentHeadSha,
1381
+ draftGate,
1382
+ preApprovalGate,
1383
+ mergeStateStatus,
1384
+ conflictFiles,
1385
+ underlyingReason: "Round-cap clean fallback has clean pre_approval_gate but no clean draft_gate evidence.",
1386
+ refinementArtifact,
1387
+ effectiveLifecycleState,
1388
+ });
1389
+ }
1390
+
1391
+ // Round-cap clean fallback with clean draft_gate evidence reaches final
1392
+ // approval when the current head also has clean pre_approval_gate evidence.
1393
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL]);
1394
+ pushUnique(forbiddenActions, [
1395
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1396
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
1397
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
1398
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1399
+ ]);
1400
+ return buildResult({
1401
+ repo: input.repo ?? null,
1402
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1403
+ currentHeadSha,
1404
+ lifecycleState: effectiveLifecycleState,
1405
+ loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1406
+ gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1407
+ draftGateAlreadySatisfied: true,
1408
+ draftGate,
1409
+ preApprovalGate,
1410
+ allowedNextActions,
1411
+ forbiddenActions,
1412
+ nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1413
+ 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.`,
1414
+ mergeStateStatus,
1415
+ conflictFiles,
1416
+ refinementArtifact,
1417
+ });
1418
+ }
1419
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE]);
1420
+ pushUnique(forbiddenActions, [
1421
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1422
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
1423
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
1424
+ PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
1425
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1426
+ ]);
1427
+ return buildResult({
1428
+ repo: input.repo ?? null,
1429
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1430
+ currentHeadSha,
1431
+ lifecycleState: effectiveLifecycleState,
1432
+ loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1433
+ gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1434
+ draftGateAlreadySatisfied: true,
1435
+ draftGate,
1436
+ preApprovalGate,
1437
+ allowedNextActions,
1438
+ forbiddenActions,
1439
+ nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
1440
+ 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).`,
1441
+ mergeStateStatus,
1442
+ conflictFiles,
1443
+ refinementArtifact,
1444
+ gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds }),
1445
+ copilotReviewRoundCount,
1446
+ });
1447
+ }
1448
+
1269
1449
  if (effectiveLifecycleState === STATE.LOW_SIGNAL_CONVERGED) {
1270
1450
  if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
1271
1451
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);