@dev-loops/core 0.2.7 → 0.4.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 +7 -1
- package/src/config/config.mjs +245 -4
- package/src/config/extension-defaults.yaml +12 -0
- package/src/github/copilot-helpers.mjs +65 -0
- package/src/loop/async-start-contract.mjs +8 -27
- package/src/loop/gate-fanin.mjs +222 -0
- package/src/loop/handoff-envelope.mjs +50 -8
- package/src/loop/lifecycle-state.mjs +13 -1
- package/src/loop/pr-gate-coordination.mjs +183 -3
- package/src/loop/queue-board-ordering.mjs +5 -1
- package/src/loop/queue-board-sync.mjs +4 -1
- package/src/loop/queue-driver.mjs +25 -3
- package/src/loop/queue-membership.mjs +145 -0
- package/src/loop/queue-state.mjs +56 -1
- package/src/loop/run-context.mjs +9 -16
- package/src/loop/worktree-guard.mjs +6 -16
|
@@ -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
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { normalizeRepoSlug } from "../github/repo-slug.mjs";
|
|
22
22
|
import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
|
|
23
23
|
import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
|
|
24
|
+
import { resolveHumanMergeOnly } from "../config/config.mjs";
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// Constants
|
|
@@ -243,10 +244,18 @@ function deriveTarget(bundle, repo) {
|
|
|
243
244
|
// ---------------------------------------------------------------------------
|
|
244
245
|
|
|
245
246
|
function deriveStopRules(settings, strategy) {
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
const base = (settings?.autonomy?.stopAt && Array.isArray(settings.autonomy.stopAt))
|
|
248
|
+
? [...settings.autonomy.stopAt]
|
|
249
|
+
: [...(STRATEGY_DEFAULT_STOP_RULES[strategy] ?? [])];
|
|
250
|
+
// Fail closed: humanMergeOnly forces "merge" into the dispatched agent's
|
|
251
|
+
// stopRules regardless of configured stopAt, mirroring the authoritative
|
|
252
|
+
// resolveAutonomyStopAt(config) invariant. Without this, a custom
|
|
253
|
+
// stopAt that omits "merge" (e.g. [] or ["draft-pr"]) would tell the agent
|
|
254
|
+
// NOT to stop at merge — a direct humanMergeOnly bypass.
|
|
255
|
+
if (resolveHumanMergeOnly(settings) && !base.includes("merge")) {
|
|
256
|
+
base.push("merge");
|
|
248
257
|
}
|
|
249
|
-
return
|
|
258
|
+
return base;
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
// ---------------------------------------------------------------------------
|
|
@@ -316,28 +325,61 @@ function deriveCwd(bundle, options = {}) {
|
|
|
316
325
|
const kind = normalizeTargetKind(artifact.kind);
|
|
317
326
|
|
|
318
327
|
if (root) {
|
|
328
|
+
// issue/pr go through the single source of truth (resolveWorktreePath); other
|
|
329
|
+
// slug kinds (local_branch/local_phase) still use the namespace + slug.
|
|
330
|
+
if (kind === DEV_LOOP_TARGET_KIND.ISSUE && Number.isInteger(artifact.issue) && artifact.issue > 0) {
|
|
331
|
+
return resolveWorktreePath({ repoRoot: root, kind: "issue", number: artifact.issue });
|
|
332
|
+
}
|
|
333
|
+
if (kind === DEV_LOOP_TARGET_KIND.PR && Number.isInteger(artifact.pr) && artifact.pr > 0) {
|
|
334
|
+
return resolveWorktreePath({ repoRoot: root, kind: "pr", number: artifact.pr });
|
|
335
|
+
}
|
|
319
336
|
const slug = buildWorktreeSlug(artifact, kind);
|
|
320
337
|
if (slug) {
|
|
321
|
-
return `${root}
|
|
338
|
+
return `${root}/${WORKTREE_NAMESPACE}/${slug}`;
|
|
322
339
|
}
|
|
323
340
|
}
|
|
324
341
|
|
|
325
342
|
return null;
|
|
326
343
|
}
|
|
327
344
|
|
|
345
|
+
/** Repo-relative root for loop-owned worktrees. The `dev-loops/` namespace */
|
|
346
|
+
/** marks them so cleanup can only ever remove its own (issue #909). */
|
|
347
|
+
export const WORKTREE_NAMESPACE = "tmp/worktrees/dev-loops";
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Resolve the canonical, namespaced worktree path for an issue/PR. Sole source
|
|
351
|
+
* of truth shared by create, provision, and cleanup. No branch suffix, so the
|
|
352
|
+
* path is recomputable from the issue/PR number alone.
|
|
353
|
+
*
|
|
354
|
+
* @param {{ repoRoot: string, kind: "issue"|"pr", number: number }} args
|
|
355
|
+
* @returns {string} Absolute path `<repoRoot>/tmp/worktrees/dev-loops/<kind>-<number>`
|
|
356
|
+
*/
|
|
357
|
+
export function resolveWorktreePath({ repoRoot, kind, number } = {}) {
|
|
358
|
+
const root = normalizeString(repoRoot);
|
|
359
|
+
if (!root) throw new Error("resolveWorktreePath: repoRoot is required and must be a non-empty string");
|
|
360
|
+
const k = typeof kind === "string" ? kind.trim().toLowerCase() : "";
|
|
361
|
+
if (k !== DEV_LOOP_TARGET_KIND.ISSUE && k !== DEV_LOOP_TARGET_KIND.PR) {
|
|
362
|
+
throw new Error(`resolveWorktreePath: kind must be "issue" or "pr", got "${kind}"`);
|
|
363
|
+
}
|
|
364
|
+
if (!Number.isInteger(number) || number < 1) {
|
|
365
|
+
throw new Error(`resolveWorktreePath: number must be a positive integer, got ${number}`);
|
|
366
|
+
}
|
|
367
|
+
return `${root}/${WORKTREE_NAMESPACE}/${k}-${number}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
328
370
|
function flattenSlugSegment(s) {
|
|
329
371
|
if (typeof s !== "string") return "";
|
|
330
372
|
return s.replace(/[/\\]/g, "-").replace(/[^a-zA-Z0-9._-]/g, "");
|
|
331
373
|
}
|
|
332
374
|
|
|
333
375
|
function buildWorktreeSlug(artifact, kind) {
|
|
376
|
+
// Canonical naming is namespaced + no branch suffix (issue #909) so the path
|
|
377
|
+
// is recomputable from the issue/PR number alone (cleanup can find it).
|
|
334
378
|
if (kind === DEV_LOOP_TARGET_KIND.ISSUE && Number.isInteger(artifact.issue) && artifact.issue > 0) {
|
|
335
|
-
|
|
336
|
-
return branch ? `issue-${artifact.issue}-${flattenSlugSegment(branch)}` : `issue-${artifact.issue}`;
|
|
379
|
+
return `issue-${artifact.issue}`;
|
|
337
380
|
}
|
|
338
381
|
if (kind === DEV_LOOP_TARGET_KIND.PR && Number.isInteger(artifact.pr) && artifact.pr > 0) {
|
|
339
|
-
|
|
340
|
-
return branch ? `pr-${artifact.pr}-${flattenSlugSegment(branch)}` : `pr-${artifact.pr}`;
|
|
382
|
+
return `pr-${artifact.pr}`;
|
|
341
383
|
}
|
|
342
384
|
if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
|
|
343
385
|
const branch = normalizeString(artifact.branch);
|
|
@@ -162,6 +162,7 @@ function normalizeLifecycleState(value) {
|
|
|
162
162
|
* hasUnresolvedThreads, // boolean: unresolved review threads exist
|
|
163
163
|
* preApprovalGatePassed, // boolean: current-head pre_approval_gate clean
|
|
164
164
|
* mergeAuthorized, // boolean: explicit merge authorization granted
|
|
165
|
+
* humanMergeOnly, // boolean: repo invariant — agent may never merge (fails closed)
|
|
165
166
|
* isMerged, // boolean: PR has been merged
|
|
166
167
|
* }
|
|
167
168
|
* ```
|
|
@@ -194,9 +195,18 @@ export function resolveLifecycleState(input = {}) {
|
|
|
194
195
|
hasUnresolvedThreads = false,
|
|
195
196
|
preApprovalGatePassed = false,
|
|
196
197
|
mergeAuthorized = false,
|
|
198
|
+
humanMergeOnly = false,
|
|
197
199
|
isMerged = false,
|
|
198
200
|
} = input;
|
|
199
201
|
|
|
202
|
+
// Fail closed: when the repo enforces human-only merge, the agent is never
|
|
203
|
+
// cleared to advance to the merge action — the per-run mergeAuthorized signal
|
|
204
|
+
// is ignored. Also fail closed on a non-boolean `mergeAuthorized` (only an
|
|
205
|
+
// exact `true` clears merge), matching the authoritative
|
|
206
|
+
// `resolveEffectiveMergeAuthorized` gate. An already-merged PR (isMerged) is
|
|
207
|
+
// still terminal below.
|
|
208
|
+
const effectiveMergeAuthorized = humanMergeOnly !== true && mergeAuthorized === true;
|
|
209
|
+
|
|
200
210
|
// 1. Explicit phase override — canonical or fail closed
|
|
201
211
|
if (phase !== null && phase !== undefined) {
|
|
202
212
|
const normalized = normalizeLifecycleState(phase);
|
|
@@ -212,7 +222,9 @@ export function resolveLifecycleState(input = {}) {
|
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
// 3. Merge authorized with pre-approval + linked PR → merge
|
|
215
|
-
|
|
225
|
+
// (humanMergeOnly forces effectiveMergeAuthorized=false above, so the loop
|
|
226
|
+
// stays at the pre_approval_gate human-merge handoff instead.)
|
|
227
|
+
if (effectiveMergeAuthorized && preApprovalGatePassed && hasLinkedPr) {
|
|
216
228
|
return buildResult(LIFECYCLE_STATE.MERGE);
|
|
217
229
|
}
|
|
218
230
|
|
|
@@ -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]);
|
|
@@ -25,7 +25,11 @@ export async function resolveNextUpOrder(
|
|
|
25
25
|
const listItems = dependencies.listQueueItems ?? listQueueItemsMain;
|
|
26
26
|
try {
|
|
27
27
|
const result = await listItems(
|
|
28
|
-
|
|
28
|
+
// list-queue-items validates `project` as a string ref (CLI contract);
|
|
29
|
+
// resolveProjectNumber yields a number, so stringify it. Passing the raw
|
|
30
|
+
// number trips parseProjectRef's `typeof raw !== "string"` guard, which
|
|
31
|
+
// surfaces as a misleading "--project is required" (#901).
|
|
32
|
+
{ repo, project: String(projectNumber), column: "Next Up" },
|
|
29
33
|
{ env, runChild: dependencies.runChild },
|
|
30
34
|
);
|
|
31
35
|
const order = (result?.items ?? [])
|
|
@@ -370,7 +370,10 @@ export async function syncBoardStatus(
|
|
|
370
370
|
const moveItem = dependencies.moveQueueItem ?? moveQueueItemMain;
|
|
371
371
|
try {
|
|
372
372
|
const result = await moveItem(
|
|
373
|
-
|
|
373
|
+
// move-queue-item validates project + item as string refs (CLI contract);
|
|
374
|
+
// resolveProjectNumber yields a number and itemNumber is numeric, so
|
|
375
|
+
// stringify both.
|
|
376
|
+
{ repo, project: String(projectNumber), item: String(itemNumber), toColumn: targetColumn },
|
|
374
377
|
{ env, runChild: dependencies.runChild },
|
|
375
378
|
);
|
|
376
379
|
return { ok: true, skipped: false, result };
|
|
@@ -57,6 +57,28 @@ export async function runQueue(repoRoot, repo, options = {}) {
|
|
|
57
57
|
const opts = { ...DEFAULT_QUEUE_DRIVER_OPTIONS, ...options };
|
|
58
58
|
const queue = await readQueue(repoRoot);
|
|
59
59
|
|
|
60
|
+
// Data-integrity guard (#913): this driver is a deterministic ADAPTER over the
|
|
61
|
+
// board, not the orchestration harness. Completion (`done` / move to Done) may
|
|
62
|
+
// only ever REFLECT a real terminal signal supplied by an orchestrator via
|
|
63
|
+
// `runEntry` (e.g. a merged PR). With no `runEntry` wired in the current
|
|
64
|
+
// harness there is nothing that can produce a verifiable terminal state, so
|
|
65
|
+
// the run MUST be a no-op: leave every entry and board column untouched and
|
|
66
|
+
// report the reason. Previously the missing-orchestrator path fell back to a
|
|
67
|
+
// fabricated `{ ok: true, pr: null }` per entry, which silently marked an
|
|
68
|
+
// entire Next Up `done` and moved it to Done without any work happening.
|
|
69
|
+
if (typeof opts.runEntry !== "function") {
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
noop: true,
|
|
73
|
+
reason: "no-orchestrator",
|
|
74
|
+
message:
|
|
75
|
+
"queue run is a deterministic adapter with no orchestrator wired (no runEntry); " +
|
|
76
|
+
"leaving board columns unchanged. Items move to Done only on a real terminal signal.",
|
|
77
|
+
results: [],
|
|
78
|
+
queue,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
60
82
|
// Config-driven loop-state → board-column mapping (#793, AC1/AC3). Loaded
|
|
61
83
|
// once per run; resolves logical columns to configured display names, with
|
|
62
84
|
// the AC1 defaults when no `queue.statusColumns`/`queue.stateColumnMap` is set.
|
|
@@ -135,9 +157,9 @@ export async function runQueue(repoRoot, repo, options = {}) {
|
|
|
135
157
|
await syncColumn(entry.target, columnFor("implementation"));
|
|
136
158
|
|
|
137
159
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
160
|
+
// runEntry is guaranteed a function here (guarded at function entry):
|
|
161
|
+
// the adapter never fabricates a terminal result for an undispatched item.
|
|
162
|
+
const entryResult = await opts.runEntry(entry, repo, opts);
|
|
141
163
|
|
|
142
164
|
if (entryResult.ok) {
|
|
143
165
|
if (entryResult.pr) {
|