@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.
- package/package.json +4 -1
- package/src/bash-exit-one.mjs +26 -8
- package/src/claude/asset-generation.mjs +22 -6
- package/src/cli/primitives.mjs +84 -0
- package/src/config/config.mjs +82 -0
- package/src/config/extension-defaults.yaml +12 -0
- package/src/github/copilot-helpers.mjs +65 -0
- package/src/github/review-threads.mjs +8 -26
- package/src/loop/copilot-loop-state.mjs +35 -21
- package/src/loop/gate-fanin.mjs +222 -0
- package/src/loop/phase-files.mjs +11 -35
- package/src/loop/pr-gate-coordination.mjs +183 -3
- package/src/loop/queue-board-sync.mjs +182 -2
- package/src/loop/queue-driver.mjs +47 -12
- package/src/loop/queue-membership.mjs +145 -0
- package/src/loop/queue-state.mjs +56 -1
|
@@ -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
|
+
}
|
package/src/loop/phase-files.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
//
|
|
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]);
|