@dev-loops/core 1.0.0-rc.3 → 1.0.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +549 -44
- package/src/config/extension-defaults.yaml +38 -5
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +77 -5
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/copilot-ci-status.mjs +46 -18
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +28 -1
- package/src/loop/gate-fanin.mjs +723 -29
- package/src/loop/handoff-envelope.mjs +37 -16
- package/src/loop/issue-refinement-artifact.mjs +60 -18
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +194 -13
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +1 -1
|
@@ -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
|
|
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
|
|
360
|
-
// gates.<gate>.angles by hand: resolveGateConfig
|
|
361
|
-
// angle-entry shape (mandatory/enabled per-entry, D3) into
|
|
362
|
-
//
|
|
363
|
-
//
|
|
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
|
|
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
|
-
|
|
945
|
-
|
|
963
|
+
// ----- gateState.derivedAt (informational, warn on missing) — #1462 moved the
|
|
964
|
+
// volatile timestamp into the gateState tail so the rest stays byte-stable -----
|
|
965
|
+
if (typeof envelope.gateState?.derivedAt !== "string" || !envelope.gateState.derivedAt.trim()) {
|
|
966
|
+
warnings.push({ field: "gateState.derivedAt", reason: "should be an ISO 8601 timestamp" });
|
|
946
967
|
}
|
|
947
968
|
|
|
948
969
|
return {
|
|
@@ -148,20 +148,25 @@ function findSectionByPatterns(sections, patterns) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* (`-
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
151
|
+
* Parse bullet/checkbox items from a section body into item states. Each
|
|
152
|
+
* checkbox item (`- [ ]`/`- [x]`/`- [X]`) becomes `{ text, checked }`
|
|
153
|
+
* (`checked` true only for a ticked `[x]`/`[X]`); a top-level plain bullet
|
|
154
|
+
* (`- text`, dash at column 0 so nested/indented sub-bullets are not counted)
|
|
155
|
+
* becomes `{ text, checked: null }` — it has no checkbox to tick. Empty
|
|
156
|
+
* checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
|
|
157
|
+
* not counted, so a section of only unfilled placeholders reports as unrefined.
|
|
158
|
+
* Code-fenced lines are skipped (same fence logic as parseMarkdownSections,
|
|
159
|
+
* issue #1025) so a body cannot spoof the AC/DoD gate with code-fenced
|
|
160
|
+
* checkboxes.
|
|
159
161
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
162
|
+
* Shared by `extractChecklistItems` (text-only) and the unticked-AC check
|
|
163
|
+
* (`extractUncheckedChecklistItems`) so the two never drift on what counts as
|
|
164
|
+
* a checklist item or on the checkbox-state read (#1621). Only ever called on
|
|
165
|
+
* the body of an already-recognized AC/DoD section (see
|
|
166
|
+
* `detectIssueRefinementArtifact`), so counting plain bullets is scoped to
|
|
167
|
+
* those sections and never affects prose sections.
|
|
163
168
|
*/
|
|
164
|
-
|
|
169
|
+
function parseChecklistItems(sectionBody) {
|
|
165
170
|
if (typeof sectionBody !== "string" || sectionBody.length === 0) {
|
|
166
171
|
return [];
|
|
167
172
|
}
|
|
@@ -171,9 +176,6 @@ export function extractChecklistItems(sectionBody) {
|
|
|
171
176
|
let fence = null;
|
|
172
177
|
|
|
173
178
|
for (const line of lines) {
|
|
174
|
-
// Checkboxes/bullets inside a fenced code span are non-interactive text, not
|
|
175
|
-
// real items — skip them so a body cannot spoof the AC/DoD gate with
|
|
176
|
-
// code-fenced checkboxes (issue #1025). Same fence logic as parseMarkdownSections.
|
|
177
179
|
const step = stepFence(fence, line);
|
|
178
180
|
fence = step.fence;
|
|
179
181
|
if (step.insideFence) {
|
|
@@ -186,7 +188,10 @@ export function extractChecklistItems(sectionBody) {
|
|
|
186
188
|
if (checkboxMatch) {
|
|
187
189
|
const text = (checkboxMatch[1] ?? "").trim();
|
|
188
190
|
if (text.length > 0) {
|
|
189
|
-
|
|
191
|
+
// `checked` is true only for a ticked box; `[ ]` (space) is false.
|
|
192
|
+
// A plain bullet has no checkbox, so it stays `null` below — it is
|
|
193
|
+
// neither ticked nor unticked and does not count as an unticked AC.
|
|
194
|
+
items.push({ text, checked: /^\s*-\s+\[[xX]\]/u.test(line) });
|
|
190
195
|
}
|
|
191
196
|
continue;
|
|
192
197
|
}
|
|
@@ -196,7 +201,7 @@ export function extractChecklistItems(sectionBody) {
|
|
|
196
201
|
if (bulletMatch) {
|
|
197
202
|
const text = bulletMatch[1].trim();
|
|
198
203
|
if (text.length > 0) {
|
|
199
|
-
items.push(text);
|
|
204
|
+
items.push({ text, checked: null });
|
|
200
205
|
}
|
|
201
206
|
}
|
|
202
207
|
}
|
|
@@ -204,6 +209,32 @@ export function extractChecklistItems(sectionBody) {
|
|
|
204
209
|
return items;
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Extract bullet items from a section body. Counts both `- [ ]`/`- [x]`
|
|
214
|
+
* checklist items and top-level plain `- ` bullets. Empty checkbox placeholders
|
|
215
|
+
* are skipped. Returns the trimmed item text for each matching line; the
|
|
216
|
+
* checkbox state is not preserved (use `extractUncheckedChecklistItems` for
|
|
217
|
+
* that). Thin wrapper over `parseChecklistItems` so the text-only contract
|
|
218
|
+
* stays byte-identical to its pre-#1621 shape.
|
|
219
|
+
*/
|
|
220
|
+
export function extractChecklistItems(sectionBody) {
|
|
221
|
+
return parseChecklistItems(sectionBody).map((item) => item.text);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Extract the text of UNCHECKED checkbox items (`- [ ]`) from a section body.
|
|
226
|
+
* A ticked box (`- [x]`/`- [X]`) and a plain bullet (no checkbox) are both
|
|
227
|
+
* excluded — only an actual unticked checkbox is an "unticked AC item"
|
|
228
|
+
* (#1621, ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped.
|
|
229
|
+
* Thin wrapper over `parseChecklistItems` so the unticked read never drifts
|
|
230
|
+
* from `extractChecklistItems` on what counts as a checklist item.
|
|
231
|
+
*/
|
|
232
|
+
export function extractUncheckedChecklistItems(sectionBody) {
|
|
233
|
+
return parseChecklistItems(sectionBody)
|
|
234
|
+
.filter((item) => item.checked === false)
|
|
235
|
+
.map((item) => item.text);
|
|
236
|
+
}
|
|
237
|
+
|
|
207
238
|
/**
|
|
208
239
|
* Detect a linked refinement doc path from the issue body.
|
|
209
240
|
* Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
|
|
@@ -246,6 +277,7 @@ export function detectLinkedRefinementDoc(body) {
|
|
|
246
277
|
* hasACs: boolean,
|
|
247
278
|
* source: string,
|
|
248
279
|
* acItems: string[],
|
|
280
|
+
* uncheckedAcItems: string[],
|
|
249
281
|
* dodItems: string[],
|
|
250
282
|
* sections: string[],
|
|
251
283
|
* linkedDoc: { found: boolean, path: string|null, reason: string },
|
|
@@ -259,6 +291,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
|
|
|
259
291
|
hasACs: false,
|
|
260
292
|
source: REFINEMENT_SOURCE.MISSING,
|
|
261
293
|
acItems: [],
|
|
294
|
+
uncheckedAcItems: [],
|
|
262
295
|
dodItems: [],
|
|
263
296
|
sections: [],
|
|
264
297
|
linkedDoc: { found: false, path: null, reason: "empty-body" },
|
|
@@ -274,6 +307,11 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
|
|
|
274
307
|
const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
|
|
275
308
|
|
|
276
309
|
const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
|
|
310
|
+
// Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
|
|
311
|
+
// ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
|
|
312
|
+
// must refuse on (#1621). Only actual unticked checkboxes count; a ticked
|
|
313
|
+
// box and a plain bullet (no checkbox) are both excluded.
|
|
314
|
+
const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
|
|
277
315
|
const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
|
|
278
316
|
|
|
279
317
|
const linkedDoc = detectLinkedRefinementDoc(body);
|
|
@@ -283,6 +321,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
|
|
|
283
321
|
hasACs: true,
|
|
284
322
|
source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
|
|
285
323
|
acItems,
|
|
324
|
+
uncheckedAcItems,
|
|
286
325
|
dodItems,
|
|
287
326
|
sections: sectionNames,
|
|
288
327
|
linkedDoc,
|
|
@@ -296,6 +335,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
|
|
|
296
335
|
hasACs: true,
|
|
297
336
|
source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
|
|
298
337
|
acItems,
|
|
338
|
+
uncheckedAcItems,
|
|
299
339
|
dodItems,
|
|
300
340
|
sections: sectionNames,
|
|
301
341
|
linkedDoc,
|
|
@@ -309,6 +349,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
|
|
|
309
349
|
hasACs: true,
|
|
310
350
|
source: REFINEMENT_SOURCE.LINKED_DOC,
|
|
311
351
|
acItems: [],
|
|
352
|
+
uncheckedAcItems: [],
|
|
312
353
|
dodItems: [],
|
|
313
354
|
sections: sectionNames,
|
|
314
355
|
linkedDoc,
|
|
@@ -321,6 +362,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
|
|
|
321
362
|
hasACs: false,
|
|
322
363
|
source: REFINEMENT_SOURCE.MISSING,
|
|
323
364
|
acItems: [],
|
|
365
|
+
uncheckedAcItems: [],
|
|
324
366
|
dodItems: [],
|
|
325
367
|
sections: sectionNames,
|
|
326
368
|
linkedDoc,
|
|
@@ -537,7 +579,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
|
|
|
537
579
|
const reason =
|
|
538
580
|
`Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
|
|
539
581
|
"Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
|
|
540
|
-
"(e.g. run `/loop-grill <issue> --auto
|
|
582
|
+
"(e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself), or the refiner) — before it enters the pickup queue.";
|
|
541
583
|
return { action: auto ? "divert" : "block", reason, missing };
|
|
542
584
|
}
|
|
543
585
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-checkout fast-forward command shape (#1596).
|
|
3
|
+
*
|
|
4
|
+
* The dev-loop merges remotely (`gh pr merge` → origin/main) but neither the merge
|
|
5
|
+
* procedure nor the post-merge hooks fast-forwarded the main checkout's local
|
|
6
|
+
* `main`. Read-only gate scripts (`probe-ci-status.mjs`, `detect-copilot-loop-state.mjs`,
|
|
7
|
+
* …) run from the main checkout, so a stale local `main` made them execute pre-merge
|
|
8
|
+
* code — re-introducing the CI-wait stall every PR (e.g. #1531's fix was invisible
|
|
9
|
+
* until the main checkout caught up).
|
|
10
|
+
*
|
|
11
|
+
* This module owns the shared, dependency-free command string both harness hooks
|
|
12
|
+
* (Pi `post-merge-update`, Claude `post-tool-use-merge`) run after a successful
|
|
13
|
+
* merge. It is best-effort and NON-BLOCKING: `--ff-only` refuses a diverged `main`
|
|
14
|
+
* without rewriting history, so a diverged checkout fails the merge step cleanly and
|
|
15
|
+
* the caller treats that as warn-and-continue (never a hard failure, never a force
|
|
16
|
+
* push). `mainCheckout` is POSIX single-quoted so consumer checkout paths containing
|
|
17
|
+
* spaces or shell metacharacters cannot break or inject into the shell string.
|
|
18
|
+
*
|
|
19
|
+
* The `merge --ff-only` is guarded to only run when the main checkout is currently on
|
|
20
|
+
* `main`, so a non-`main` checkout (detached HEAD, or another branch checked out)
|
|
21
|
+
* warns-and-continues instead of fast-forwarding the wrong branch. No `git switch` is
|
|
22
|
+
* performed (a state change) — only the guard test runs.
|
|
23
|
+
*
|
|
24
|
+
* No imports so this file vendors into the `.claude/hooks/` bundle unchanged
|
|
25
|
+
* (vendored modules may only import `node:` builtins or relative paths).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Timeout (ms) for the `git worktree list` resolution step (the fetch-half budget;
|
|
30
|
+
* a separate fetch timeout isn't applied — the fetch runs inline within the merge
|
|
31
|
+
* command under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS`).
|
|
32
|
+
*/
|
|
33
|
+
export const MAIN_CHECKOUT_FF_FETCH_TIMEOUT_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
/** Timeout (ms) for the `git merge --ff-only origin/main` half. */
|
|
36
|
+
export const MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* POSIX single-quote a path so spaces/shell metacharacters in a consumer's checkout
|
|
40
|
+
* path cannot break or inject into the shell string.
|
|
41
|
+
*/
|
|
42
|
+
function shellQuotePath(value) {
|
|
43
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the best-effort main-checkout fast-forward command string.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
|
|
50
|
+
* @returns {string} `git -C '<main>' fetch origin main && [ "$(git -C '<main>' rev-parse --abbrev-ref HEAD)" = main ] && git -C '<main>' merge --ff-only origin/main` (path POSIX single-quoted; merge only runs when the main checkout is on `main`)
|
|
51
|
+
*/
|
|
52
|
+
export function buildMainCheckoutFastForwardCommand(mainCheckout) {
|
|
53
|
+
const quoted = shellQuotePath(mainCheckout);
|
|
54
|
+
// ponytail: guard with a `[ ... = main ]` test instead of switching branches — a
|
|
55
|
+
// non-main checkout fails the && chain (warn-and-continue) rather than ff-ing the
|
|
56
|
+
// wrong branch. No state change, no git switch.
|
|
57
|
+
return `git -C ${quoted} fetch origin main && [ "$(git -C ${quoted} rev-parse --abbrev-ref HEAD)" = main ] && git -C ${quoted} merge --ff-only origin/main`;
|
|
58
|
+
}
|
|
@@ -252,8 +252,26 @@ function formatRefinementBlockedReason(linkedIssue, status, refinementArtifact)
|
|
|
252
252
|
return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (Acceptance criteria / DoD / linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1369
|
-
: (
|
|
1370
|
-
? "
|
|
1371
|
-
:
|
|
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
|
|
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
|
|
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]);
|