@dev-loops/core 0.9.0 → 1.0.0-rc.1
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 +3 -1
- package/src/analysis/change-classifier.mjs +15 -3
- package/src/analysis/diff-analyzer.mjs +112 -6
- package/src/claude/asset-generation.mjs +34 -17
- package/src/config/config.mjs +178 -6
- package/src/config/extension-defaults.yaml +7 -0
- package/src/debt/shape.mjs +0 -12
- package/src/loop/copilot-loop-state.mjs +38 -6
- package/src/loop/gate-carry-forward.mjs +244 -0
- package/src/loop/policy-constants.mjs +0 -3
- package/src/loop/pr-gate-coordination.mjs +12 -7
- package/src/loop/queue-state.mjs +0 -9
- package/src/loop/steering.mjs +4 -2
- package/src/loop/ui-review-drive.mjs +28 -4
- package/src/loop/ui-review-report.mjs +19 -17
- package/src/loop/ui-review-teardown.mjs +52 -10
|
@@ -136,7 +136,25 @@ export const NEXT_ACTIONS = Object.freeze({
|
|
|
136
136
|
|
|
137
137
|
const SAME_HEAD_CLEAN_CONVERGED_NEXT_ACTION = "Current head already has a clean submitted Copilot review; suppress automatic same-head re-request unless a meaningful remediation event occurs, or explicitly request another Copilot pass";
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Canonical snapshot request-status enum (single source of truth). Any request
|
|
141
|
+
* outcome plumbed into the shared loop contract MUST normalize to one of these;
|
|
142
|
+
* richer request-tool outcomes (round_cap_reached, suppressed_*, etc.) collapse
|
|
143
|
+
* to "none" here because they mean "no active Copilot request is in flight".
|
|
144
|
+
*/
|
|
145
|
+
export const VALID_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Collapse an arbitrary request-tool outcome to the canonical snapshot
|
|
149
|
+
* request-status enum. Unrecognized statuses (round cap / suppression
|
|
150
|
+
* diagnostics) map to "none" so they never leak into the shared contract.
|
|
151
|
+
*
|
|
152
|
+
* @param {string|undefined} status
|
|
153
|
+
* @returns {string} a member of VALID_REVIEW_REQUEST_STATUSES
|
|
154
|
+
*/
|
|
155
|
+
export function toSharedRequestStatus(status) {
|
|
156
|
+
return VALID_REVIEW_REQUEST_STATUSES.has(status) ? status : "none";
|
|
157
|
+
}
|
|
140
158
|
const VALID_CI_STATUSES = new Set(["success", "failure", "pending", "none", "crediblyGreen"]);
|
|
141
159
|
const ACTIVE_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
|
|
142
160
|
|
|
@@ -341,6 +359,9 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
|
|
|
341
359
|
* @param {number} [refinementConfig.lowSignalRoundThreshold]
|
|
342
360
|
* @param {number} [refinementConfig.lowSignalMaxComments]
|
|
343
361
|
* @param {number} [refinementConfig.maxCopilotRounds]
|
|
362
|
+
* @param {boolean} [refinementConfig.preApprovalRequireCi] - #1337: default true. When false,
|
|
363
|
+
* the pre-approval CI precondition is opted out, so a non-draft PR with a pending/none/failure
|
|
364
|
+
* CI verdict is not routed to waiting_for_ci / blocked_needs_user_decision (it is past the draft gate).
|
|
344
365
|
* @returns {{
|
|
345
366
|
* state: string,
|
|
346
367
|
* allowedTransitions: string[],
|
|
@@ -353,6 +374,17 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
|
|
|
353
374
|
export function interpretLoopState(snapshot, refinementConfig) {
|
|
354
375
|
const s = normalizeSnapshot(snapshot);
|
|
355
376
|
|
|
377
|
+
// Pre-approval CI opt-out (#1337): when `gates.preApproval.requireCi` is false,
|
|
378
|
+
// the CI verdict must not gate progression at the pre-approval boundary. A
|
|
379
|
+
// non-draft PR is past the draft gate, so this is the applicable knob — treat
|
|
380
|
+
// pending/none/failure CI as non-blocking here so a repo with no CI is not
|
|
381
|
+
// routed to WAITING_FOR_CI / BLOCKED_NEEDS_USER_DECISION before the downstream
|
|
382
|
+
// gate-coordination guards (which already honor this flag) are ever reached.
|
|
383
|
+
// Default true preserves current behavior for every caller that does not thread it.
|
|
384
|
+
const preApprovalRequireCi = refinementConfig?.preApprovalRequireCi !== false;
|
|
385
|
+
const ciBlocks = preApprovalRequireCi && isBlockedCiStatus(s.ciStatus);
|
|
386
|
+
const ciWaits = preApprovalRequireCi && isWaitingCiStatus(s.ciStatus);
|
|
387
|
+
|
|
356
388
|
let state;
|
|
357
389
|
|
|
358
390
|
if (!s.prExists) {
|
|
@@ -403,7 +435,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
403
435
|
&& state !== STATE.NO_PR && state !== STATE.DONE
|
|
404
436
|
&& state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
|
|
405
437
|
&& state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
|
|
406
|
-
const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen";
|
|
438
|
+
const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
|
|
407
439
|
const cleanThreads = s.unresolvedThreadCount === 0;
|
|
408
440
|
if (cleanThreads && ciClean) {
|
|
409
441
|
// Clean PR at the cap: proceed to the pre_approval_gate fallback regardless of a
|
|
@@ -430,18 +462,18 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
430
462
|
state = STATE.WAITING_FOR_COPILOT_REVIEW;
|
|
431
463
|
} else if (s.copilotReviewPresent) {
|
|
432
464
|
// Copilot has reviewed at least once; all threads resolved
|
|
433
|
-
if (
|
|
465
|
+
if (ciBlocks) {
|
|
434
466
|
state = STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
435
|
-
} else if (
|
|
467
|
+
} else if (ciWaits) {
|
|
436
468
|
state = STATE.WAITING_FOR_CI;
|
|
437
469
|
} else {
|
|
438
470
|
state = STATE.READY_TO_REREQUEST_REVIEW;
|
|
439
471
|
}
|
|
440
472
|
} else {
|
|
441
473
|
// No Copilot review yet; not currently requested
|
|
442
|
-
if (
|
|
474
|
+
if (ciBlocks) {
|
|
443
475
|
state = STATE.BLOCKED_NEEDS_USER_DECISION;
|
|
444
|
-
} else if (
|
|
476
|
+
} else if (ciWaits) {
|
|
445
477
|
state = STATE.WAITING_FOR_CI;
|
|
446
478
|
} else {
|
|
447
479
|
state = STATE.PR_READY_NO_FEEDBACK;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate carry-forward: a pure, fail-closed seam that decides whether a clean gate
|
|
3
|
+
* angle verdict recorded at head A may be CARRIED FORWARD to head B without
|
|
4
|
+
* re-running that angle's reviewer.
|
|
5
|
+
*
|
|
6
|
+
* Motivation: fresh-context-per-head re-fans ALL gate angles on every head bump,
|
|
7
|
+
* even when the delta between the two heads provably cannot affect most angles
|
|
8
|
+
* (e.g. a doc-only follow-up commit cannot change what a code-correctness angle
|
|
9
|
+
* would find). Carry-forward lets the gate reuse the prior clean verdict for such
|
|
10
|
+
* angles — but ONLY when it is provably safe.
|
|
11
|
+
*
|
|
12
|
+
* FAIL-CLOSED is paramount. An angle carries forward ONLY when EVERY changed file
|
|
13
|
+
* in the delta A..B is provably OUTSIDE that angle's declared review surface. The
|
|
14
|
+
* default in every uncertain case (non-clean prior verdict, empty/unavailable
|
|
15
|
+
* delta, an unclassifiable file, an angle with no declared surface, a mandatory /
|
|
16
|
+
* always-run angle) is MUST-RE-RUN. Carry-forward never fabricates a verdict: the
|
|
17
|
+
* caller records the carried verdict with provenance pointing at the PRIOR head's
|
|
18
|
+
* reviewer (that reviewer genuinely reviewed this angle's surface, which the delta
|
|
19
|
+
* did not touch), clearly marked as carried — see
|
|
20
|
+
* docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
|
|
21
|
+
* `carriedFromHead` provenance field.
|
|
22
|
+
*
|
|
23
|
+
* The angle -> review-surface mapping is DERIVED from the single source of truth
|
|
24
|
+
* for change-category -> angle relevance (CATEGORY_ANGLE_MAP in
|
|
25
|
+
* ../analysis/change-classifier.mjs) so the two never drift: an angle's review
|
|
26
|
+
* surface is exactly the set of file "surface kinds" whose change could, under the
|
|
27
|
+
* existing dynamic-angle rules, implicate that angle. File classification reuses
|
|
28
|
+
* classifyFile() from the diff analyzer (the same classifier dynamic angle
|
|
29
|
+
* resolution already trusts).
|
|
30
|
+
*
|
|
31
|
+
* This module is intentionally pure and side-effect free.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { classifyFile } from "../analysis/diff-analyzer.mjs";
|
|
35
|
+
import { ALWAYS_INCLUDE, CATEGORY_ANGLE_MAP } from "../analysis/change-classifier.mjs";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* File surface kind (classifyFile output) -> the change categories a change of
|
|
39
|
+
* that kind can produce. A code file can be either a logic change or a
|
|
40
|
+
* comment-only change; the other kinds each map to their single `_ONLY` category.
|
|
41
|
+
* "unknown" is intentionally ABSENT: an unclassifiable file is treated as
|
|
42
|
+
* touching EVERY angle's surface (fail-closed), so it never appears here.
|
|
43
|
+
*
|
|
44
|
+
* RENAME_ONLY is not a file kind — a renamed file still classifies by its
|
|
45
|
+
* destination path's kind, so the destination kind's own categories already
|
|
46
|
+
* implicate the right angles (a renamed code file -> code -> LOGIC_CHANGE, a
|
|
47
|
+
* renamed doc -> docs -> DOCS_ONLY). Folding RENAME_ONLY into every kind would
|
|
48
|
+
* over-attribute code angles to a doc-only delta and defeat the primary
|
|
49
|
+
* carry-forward case, so it is deliberately omitted here. A destination-kind
|
|
50
|
+
* classification alone, though, misses what the RENAME itself implicates (a
|
|
51
|
+
* moved doc can break a link; a moved test/code file shifts scope /
|
|
52
|
+
* contract-surface). Rename detection therefore lives at the DELTA layer: the
|
|
53
|
+
* CLI notices any rename/copy row and forces {@link RENAME_ONLY_ANGLES} to
|
|
54
|
+
* re-run for that run (fail-closed), instead of encoding a phantom "rename" file
|
|
55
|
+
* kind here.
|
|
56
|
+
*
|
|
57
|
+
* @type {Record<string, string[]>}
|
|
58
|
+
*/
|
|
59
|
+
const KIND_TO_CATEGORIES = {
|
|
60
|
+
docs: ["DOCS_ONLY"],
|
|
61
|
+
config: ["CONFIG_ONLY"],
|
|
62
|
+
test: ["TEST_ONLY"],
|
|
63
|
+
ci: ["CI_ONLY"],
|
|
64
|
+
code: ["LOGIC_CHANGE", "COMMENT_ONLY"],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The angles a pure rename implicates (CATEGORY_ANGLE_MAP[RENAME_ONLY]), minus
|
|
69
|
+
* any always-run angle (already never carried). A delta containing ANY rename
|
|
70
|
+
* forces these to re-run — a rename's effect (moved doc breaking a link, moved
|
|
71
|
+
* test/code shifting scope/contract-surface) is not captured by classifying the
|
|
72
|
+
* destination path alone. Derived from the single source of truth so it never
|
|
73
|
+
* drifts from the dynamic-angle rules.
|
|
74
|
+
*
|
|
75
|
+
* @type {string[]}
|
|
76
|
+
*/
|
|
77
|
+
export const RENAME_ONLY_ANGLES = (CATEGORY_ANGLE_MAP.RENAME_ONLY ?? []).filter(
|
|
78
|
+
(angle) => !ALWAYS_INCLUDE.has(angle),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* angle -> Set<surface kind>: an angle's review surface is the set of file kinds
|
|
83
|
+
* whose change could implicate it, inverted from CATEGORY_ANGLE_MAP via
|
|
84
|
+
* KIND_TO_CATEGORIES. Built once at module load. ALWAYS_INCLUDE angles are NOT
|
|
85
|
+
* given a kinds surface here — they always re-run (handled in angleReviewSurface).
|
|
86
|
+
*
|
|
87
|
+
* @type {Map<string, Set<string>>}
|
|
88
|
+
*/
|
|
89
|
+
const ANGLE_SURFACE_KINDS = (() => {
|
|
90
|
+
const map = new Map();
|
|
91
|
+
for (const [kind, categories] of Object.entries(KIND_TO_CATEGORIES)) {
|
|
92
|
+
for (const category of categories) {
|
|
93
|
+
for (const angle of CATEGORY_ANGLE_MAP[category] ?? []) {
|
|
94
|
+
if (ALWAYS_INCLUDE.has(angle)) continue;
|
|
95
|
+
if (!map.has(angle)) map.set(angle, new Set());
|
|
96
|
+
map.get(angle).add(kind);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return map;
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {{ kind: "always" }
|
|
105
|
+
* | { kind: "unknown" }
|
|
106
|
+
* | { kind: "kinds", kinds: Set<string> }} AngleReviewSurface
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve an angle's declared review surface (the pure angle -> surface mapping).
|
|
111
|
+
*
|
|
112
|
+
* - ALWAYS_INCLUDE angles (gate-evidence, renderer-security, pr-description) plus
|
|
113
|
+
* any explicit alwaysRerun angle -> `{ kind: "always" }`. These review a surface
|
|
114
|
+
* we cannot fully bound from the file delta alone (e.g. pr-description also
|
|
115
|
+
* depends on the PR body, which is not a changed FILE), so they NEVER carry
|
|
116
|
+
* forward.
|
|
117
|
+
* - A mapped angle -> `{ kind: "kinds", kinds }` (the file kinds that implicate it).
|
|
118
|
+
* - An unmapped / unknown angle -> `{ kind: "unknown" }` (fail-closed: never carry).
|
|
119
|
+
*
|
|
120
|
+
* @param {string} angle
|
|
121
|
+
* @param {{ alwaysRerun?: Iterable<string> }} [options]
|
|
122
|
+
* @returns {AngleReviewSurface}
|
|
123
|
+
*/
|
|
124
|
+
export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
125
|
+
const name = typeof angle === "string" ? angle.trim() : "";
|
|
126
|
+
if (name.length === 0) return { kind: "unknown" };
|
|
127
|
+
if (ALWAYS_INCLUDE.has(name)) return { kind: "always" };
|
|
128
|
+
if (alwaysRerun && new Set(alwaysRerun).has(name)) return { kind: "always" };
|
|
129
|
+
const kinds = ANGLE_SURFACE_KINDS.get(name);
|
|
130
|
+
if (!kinds || kinds.size === 0) return { kind: "unknown" };
|
|
131
|
+
return { kind: "kinds", kinds: new Set(kinds) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pure, deterministic, FAIL-CLOSED carry-forward decision for a single angle.
|
|
136
|
+
*
|
|
137
|
+
* Given a prior CLEAN verdict recorded at head A, the changed files of the delta
|
|
138
|
+
* A..B, and the angle's declared review surface, decide whether the clean verdict
|
|
139
|
+
* may be carried forward to head B (carryForward: true) or the angle MUST re-run
|
|
140
|
+
* (carryForward: false). Defaults to must-re-run in every uncertain case.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} input
|
|
143
|
+
* @param {string} input.angle
|
|
144
|
+
* @param {AngleReviewSurface} [input.angleSurface] — the angle's declared surface;
|
|
145
|
+
* derived from {@link angleReviewSurface} when omitted.
|
|
146
|
+
* @param {string[]} input.changedFiles — repo-relative paths changed between head
|
|
147
|
+
* A and head B (the delta, NOT the full PR diff against base).
|
|
148
|
+
* @param {string} input.prevVerdict — the angle's verdict at head A. Only "clean"
|
|
149
|
+
* is carry-forward-eligible.
|
|
150
|
+
* @returns {{ carryForward: boolean, reason: string }}
|
|
151
|
+
*/
|
|
152
|
+
export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
|
|
153
|
+
if (prevVerdict !== "clean") {
|
|
154
|
+
return { carryForward: false, reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not "clean"` };
|
|
155
|
+
}
|
|
156
|
+
const surface = angleSurface ?? angleReviewSurface(angle);
|
|
157
|
+
if (surface.kind === "always") {
|
|
158
|
+
return { carryForward: false, reason: "angle always re-runs (mandatory / always-include surface)" };
|
|
159
|
+
}
|
|
160
|
+
if (surface.kind === "unknown") {
|
|
161
|
+
return { carryForward: false, reason: "angle has no declared review surface (fail-closed)" };
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
|
|
164
|
+
return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
|
|
165
|
+
}
|
|
166
|
+
for (const file of changedFiles) {
|
|
167
|
+
const kind = classifyFile(file);
|
|
168
|
+
if (kind === "unknown") {
|
|
169
|
+
return { carryForward: false, reason: `delta contains an unclassifiable file (fail-closed): ${file}` };
|
|
170
|
+
}
|
|
171
|
+
if (surface.kinds.has(kind)) {
|
|
172
|
+
return { carryForward: false, reason: `delta touches the angle's review surface (${kind}): ${file}` };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
carryForward: true,
|
|
177
|
+
reason: `delta is provably outside the angle's review surface (surface kinds: ${[...surface.kinds].sort().join(", ")})`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Convenience: partition a set of previously-clean angles into those that may be
|
|
183
|
+
* carried forward and those that must re-run, given the delta A..B. Each entry
|
|
184
|
+
* carries the decision reason. Non-clean angles are not carry-forward-eligible and
|
|
185
|
+
* belong in the re-run set — callers should pass only angles whose prior verdict
|
|
186
|
+
* was clean, or set `prevVerdict` per angle via the single-angle function.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} input
|
|
189
|
+
* @param {string[]} input.prevAngles — angles that were clean at head A
|
|
190
|
+
* @param {string[]} input.changedFiles — delta A..B
|
|
191
|
+
* @param {{ alwaysRerun?: Iterable<string> }} [input.options]
|
|
192
|
+
* @returns {{ carried: Array<{ angle: string, reason: string }>, mustRerun: Array<{ angle: string, reason: string }> }}
|
|
193
|
+
*/
|
|
194
|
+
export function resolveCarryForwardAngles({ prevAngles, changedFiles, options = {} }) {
|
|
195
|
+
const carried = [];
|
|
196
|
+
const mustRerun = [];
|
|
197
|
+
for (const angle of Array.isArray(prevAngles) ? prevAngles : []) {
|
|
198
|
+
const decision = resolveAngleCarryForward({
|
|
199
|
+
angle,
|
|
200
|
+
angleSurface: angleReviewSurface(angle, options),
|
|
201
|
+
changedFiles,
|
|
202
|
+
prevVerdict: "clean",
|
|
203
|
+
});
|
|
204
|
+
(decision.carryForward ? carried : mustRerun).push({ angle, reason: decision.reason });
|
|
205
|
+
}
|
|
206
|
+
return { carried, mustRerun };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The file surface kinds the external Copilot code review actually reviews. Docs
|
|
211
|
+
* and comment-only prose are NOT part of it; everything a Copilot review could
|
|
212
|
+
* legitimately raise a code nit about is (code, tests, config, CI).
|
|
213
|
+
* @type {Set<string>}
|
|
214
|
+
*/
|
|
215
|
+
const COPILOT_REVIEW_SURFACE_KINDS = new Set(["code", "test", "config", "ci"]);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* AC2, fail-closed: decide whether a post-convergence head bump may carry forward
|
|
219
|
+
* a settled clean Copilot convergence instead of forcing a fresh BLOCKING Copilot
|
|
220
|
+
* round. Carries forward ONLY when the delta since the converged head is provably
|
|
221
|
+
* outside Copilot's review surface — a pure doc/prose-only bump (every changed
|
|
222
|
+
* file classifies as `docs`; a code comment-only change classifies as `code` and
|
|
223
|
+
* re-runs, since classifyFile is path-based). Any code/test/config/CI file, an unclassifiable
|
|
224
|
+
* file, or an empty/unavailable delta -> re-run (fresh blocking round required).
|
|
225
|
+
*
|
|
226
|
+
* @param {object} input
|
|
227
|
+
* @param {string[]} input.changedFiles — delta since the converged head
|
|
228
|
+
* @returns {{ carryForward: boolean, reason: string }}
|
|
229
|
+
*/
|
|
230
|
+
export function resolveConvergenceCarryForward({ changedFiles }) {
|
|
231
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
|
|
232
|
+
return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
|
|
233
|
+
}
|
|
234
|
+
for (const file of changedFiles) {
|
|
235
|
+
const kind = classifyFile(file);
|
|
236
|
+
if (kind === "unknown") {
|
|
237
|
+
return { carryForward: false, reason: `delta contains an unclassifiable file (fail-closed): ${file}` };
|
|
238
|
+
}
|
|
239
|
+
if (COPILOT_REVIEW_SURFACE_KINDS.has(kind)) {
|
|
240
|
+
return { carryForward: false, reason: `delta touches Copilot's review surface (${kind}): ${file}` };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { carryForward: true, reason: "delta is a pure doc/prose bump, provably outside Copilot's review surface" };
|
|
244
|
+
}
|
|
@@ -12,6 +12,3 @@ export const COPILOT_FIRST_DURABLE_WAIT_TIMEOUT_MS = 3_600_000;
|
|
|
12
12
|
|
|
13
13
|
/** Copilot review wait: external healthy-wait budget */
|
|
14
14
|
export const COPILOT_REVIEW_WAIT_TIMEOUT_MS = 1_800_000;
|
|
15
|
-
|
|
16
|
-
/** Explicit single-check timeout value (used only for status probes) */
|
|
17
|
-
export const PROBE_ONLY_TIMEOUT_MS = 0;
|
|
@@ -660,6 +660,11 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
660
660
|
const conflictFiles = normalizeConflictFiles(input.conflictFiles);
|
|
661
661
|
const ciStatus = normalizeCiStatus(input.ciStatus);
|
|
662
662
|
const draftGateRequireCi = input.draftGateRequireCi !== false;
|
|
663
|
+
// Opt-out CI precondition at the pre-approval boundary (mirrors the draft
|
|
664
|
+
// gate). Default true keeps CI required; false ignores the CI verdict
|
|
665
|
+
// entirely — a "none"/"pending"/"crediblyGreen"/"failure" head no longer
|
|
666
|
+
// waits on or blocks pre_approval.
|
|
667
|
+
const preApprovalRequireCi = input.preApprovalRequireCi !== false;
|
|
663
668
|
const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
|
|
664
669
|
const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
|
|
665
670
|
const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
|
|
@@ -998,7 +1003,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
998
1003
|
if (effectiveLifecycleState === STATE.PR_READY_NO_FEEDBACK) {
|
|
999
1004
|
if (reviewMode === "internal_only") {
|
|
1000
1005
|
// Explicitly internal-only PR: skip the external Copilot review cycle
|
|
1001
|
-
if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
|
|
1006
|
+
if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
|
|
1002
1007
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1003
1008
|
pushUnique(forbiddenActions, internalOnlyPostDraftForbidden);
|
|
1004
1009
|
return buildResult({
|
|
@@ -1201,7 +1206,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1201
1206
|
}
|
|
1202
1207
|
|
|
1203
1208
|
if (effectiveLifecycleState === STATE.READY_TO_REREQUEST_REVIEW) {
|
|
1204
|
-
if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
|
|
1209
|
+
if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
|
|
1205
1210
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1206
1211
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1207
1212
|
return buildResult({
|
|
@@ -1226,7 +1231,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1226
1231
|
});
|
|
1227
1232
|
}
|
|
1228
1233
|
|
|
1229
|
-
if (ciStatus === "pending" || ciStatus === "none") {
|
|
1234
|
+
if (preApprovalRequireCi && (ciStatus === "pending" || ciStatus === "none")) {
|
|
1230
1235
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
|
|
1231
1236
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1232
1237
|
return buildResult({
|
|
@@ -1410,7 +1415,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1410
1415
|
copilotReviewRoundCount,
|
|
1411
1416
|
});
|
|
1412
1417
|
}
|
|
1413
|
-
if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
|
|
1418
|
+
if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
|
|
1414
1419
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1415
1420
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1416
1421
|
return buildResult({
|
|
@@ -1434,7 +1439,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1434
1439
|
refinementArtifact,
|
|
1435
1440
|
});
|
|
1436
1441
|
}
|
|
1437
|
-
if (ciStatus === "pending" || ciStatus === "none") {
|
|
1442
|
+
if (preApprovalRequireCi && (ciStatus === "pending" || ciStatus === "none")) {
|
|
1438
1443
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
|
|
1439
1444
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1440
1445
|
return buildResult({
|
|
@@ -1553,7 +1558,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1553
1558
|
}
|
|
1554
1559
|
|
|
1555
1560
|
if (effectiveLifecycleState === STATE.LOW_SIGNAL_CONVERGED) {
|
|
1556
|
-
if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
|
|
1561
|
+
if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
|
|
1557
1562
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
|
1558
1563
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1559
1564
|
return buildResult({
|
|
@@ -1577,7 +1582,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1577
1582
|
refinementArtifact,
|
|
1578
1583
|
});
|
|
1579
1584
|
}
|
|
1580
|
-
if (ciStatus === "pending" || ciStatus === "none") {
|
|
1585
|
+
if (preApprovalRequireCi && (ciStatus === "pending" || ciStatus === "none")) {
|
|
1581
1586
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
|
|
1582
1587
|
pushUnique(forbiddenActions, postDraftForbidden);
|
|
1583
1588
|
return buildResult({
|
package/src/loop/queue-state.mjs
CHANGED
|
@@ -285,12 +285,3 @@ export function appendBugIssue(queue, issueNumber, dependsOn = null) {
|
|
|
285
285
|
queue.entries.push(entry);
|
|
286
286
|
return entry;
|
|
287
287
|
}
|
|
288
|
-
|
|
289
|
-
// ── Serialization helpers ────────────────────────────────────────────
|
|
290
|
-
|
|
291
|
-
export function serializeQueue(queue) {
|
|
292
|
-
return {
|
|
293
|
-
version: queue.version,
|
|
294
|
-
entries: queue.entries.map((e) => ({ ...e })),
|
|
295
|
-
};
|
|
296
|
-
}
|
package/src/loop/steering.mjs
CHANGED
|
@@ -718,10 +718,12 @@ export function getEffectiveConstraints(steeringState) {
|
|
|
718
718
|
*
|
|
719
719
|
* @param {object} snapshot - raw or normalized loop snapshot
|
|
720
720
|
* @param {object} steeringState - current steering state for this run
|
|
721
|
+
* @param {object} [refinementConfig] - interpreter refinement config; pass a config-derived
|
|
722
|
+
* `resolveRefinement(config)` so the base interpretation honors gates.preApproval.requireCi:false (#1337).
|
|
721
723
|
* @returns {{ state: string, allowedTransitions: string[], nextAction: string, steeringApplied: boolean, pendingStopAtNextSafeGate: boolean, terminalStopAtNextSafeGate: boolean, effectiveConstraints: object }}
|
|
722
724
|
*/
|
|
723
|
-
export function resolveEffectiveLoopState(snapshot, steeringState) {
|
|
724
|
-
const base = interpretLoopState(snapshot);
|
|
725
|
+
export function resolveEffectiveLoopState(snapshot, steeringState, refinementConfig) {
|
|
726
|
+
const base = interpretLoopState(snapshot, refinementConfig);
|
|
725
727
|
const constraints = getEffectiveConstraints(steeringState);
|
|
726
728
|
const category = classifySafePoint(base.state);
|
|
727
729
|
|
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
|
|
27
27
|
const MUST_FIX = "must-fix";
|
|
28
28
|
|
|
29
|
+
/** Request header the drive advertises its drive-session id on, so a cooperating
|
|
30
|
+
* app can tag the dev-DB rows a create/edit/upload persists during the walk.
|
|
31
|
+
* Stage-5 teardown deletes exactly those tagged rows from an emitted manifest. */
|
|
32
|
+
export const DRIVE_SESSION_HEADER = "X-UI-Review-Drive-Session";
|
|
33
|
+
|
|
34
|
+
/** Step actions that can persist dev-DB state (a create/edit/reorder/upload/
|
|
35
|
+
* toggle). `goto` is navigation and `fill` only types into a field before a
|
|
36
|
+
* submit, so neither is recorded as a row-creating mutation in the manifest. */
|
|
37
|
+
const MUTATING_ACTIONS = new Set(["click", "select", "upload", "dispatch"]);
|
|
38
|
+
|
|
29
39
|
/** The one owner of the error-response threshold: an error response is anything
|
|
30
40
|
* outside 2xx/3xx. 3xx redirects are normal navigation (login/canonical), not
|
|
31
41
|
* errors, so they are not flagged. Shared by the CLI listener's pre-filter (for
|
|
@@ -36,8 +46,9 @@ export function isErrorResponseStatus(status) {
|
|
|
36
46
|
|
|
37
47
|
/** Bound the stack text carried onto a page-error failure so a runaway stack
|
|
38
48
|
* (or a synthetic error with a huge stack) can't bloat the feed envelope. Keeps
|
|
39
|
-
* the head — the top frames, where the throwing file:line sits.
|
|
40
|
-
|
|
49
|
+
* the head — the top frames, where the throwing file:line sits. Exported so the
|
|
50
|
+
* per-state console.json shaping clamps to the SAME bound as the mechanical feed. */
|
|
51
|
+
export const PAGE_ERROR_STACK_MAX_CHARS = 4000;
|
|
41
52
|
|
|
42
53
|
/** Lines of context to preserve on each side of a matching server-log line, so
|
|
43
54
|
* the traceback frames that carry file:line (often on adjacent, non-matching
|
|
@@ -211,6 +222,9 @@ export function classifyFailures({
|
|
|
211
222
|
* @param {object} input
|
|
212
223
|
* @param {string} input.appUrl - The arbitrary running-app URL from Stage 1.
|
|
213
224
|
* @param {object} input.login - Resolved dev-login recipe (loginUrl + selectors).
|
|
225
|
+
* @param {string|null} [input.driveSession] - Unique id advertised to the app on
|
|
226
|
+
* DRIVE_SESSION_HEADER; stamps the emitted row manifest so Stage-5 teardown can
|
|
227
|
+
* drop exactly the rows a mutating step created. Null => no manifest is emitted.
|
|
214
228
|
* @param {object[]} [input.flows] - Allowlisted changed-flow definitions.
|
|
215
229
|
* @param {object[]} [input.interstitials] - Config-declared dismiss selectors.
|
|
216
230
|
* @param {string[]} [input.changedPaths] - Changed file paths (drives selection).
|
|
@@ -226,7 +240,7 @@ export function classifyFailures({
|
|
|
226
240
|
* @returns {Promise<object>} Result envelope (steps, captures, failures, caps, logs).
|
|
227
241
|
*/
|
|
228
242
|
export async function driveUiReview(
|
|
229
|
-
{ appUrl, login, flows = [], interstitials = [], changedPaths = [], serverLogExceptionPattern, caps = {} },
|
|
243
|
+
{ appUrl, login, flows = [], interstitials = [], changedPaths = [], serverLogExceptionPattern, caps = {}, driveSession = null },
|
|
230
244
|
{
|
|
231
245
|
authenticate,
|
|
232
246
|
dismissInterstitials = async () => ({ dismissed: [] }),
|
|
@@ -245,7 +259,8 @@ export async function driveUiReview(
|
|
|
245
259
|
// No-retry is a fixed policy — log it every run so the bound is never implicit.
|
|
246
260
|
record(`caps: maxScreenshots=${resolvedCaps.maxScreenshots}, maxFlows=${resolvedCaps.maxFlows}, maxStepsPerFlow=${resolvedCaps.maxStepsPerFlow}, retries=${resolvedCaps.retries} (no-retry)`);
|
|
247
261
|
|
|
248
|
-
const
|
|
262
|
+
const session = typeof driveSession === "string" && driveSession.trim().length > 0 ? driveSession.trim() : null;
|
|
263
|
+
const base = () => ({ appUrl: appUrl ?? null, logs, driveSession: session });
|
|
249
264
|
|
|
250
265
|
// 1. Authenticate as the target role. Fail closed: no session -> STOP, drive
|
|
251
266
|
// nothing (a review that never reached the app is worthless, not empty).
|
|
@@ -261,6 +276,7 @@ export async function driveUiReview(
|
|
|
261
276
|
captures: [],
|
|
262
277
|
failures: [{ kind: "auth-failed", severity: MUST_FIX, message: stopReason }],
|
|
263
278
|
caps: resolvedCaps,
|
|
279
|
+
rowManifest: [],
|
|
264
280
|
...base(),
|
|
265
281
|
};
|
|
266
282
|
}
|
|
@@ -280,6 +296,10 @@ export async function driveUiReview(
|
|
|
280
296
|
// moves on — deterministic, bounded, never re-run.
|
|
281
297
|
const steps = [];
|
|
282
298
|
const captures = [];
|
|
299
|
+
// Row manifest: one session-tagged record per mutating step driven, so Stage-5
|
|
300
|
+
// teardown can drop exactly the dev-DB rows this walk created. Only built when a
|
|
301
|
+
// session is present (no session => nothing to tag => no manifest to drop).
|
|
302
|
+
const rowManifest = [];
|
|
283
303
|
let screenshots = 0;
|
|
284
304
|
let screensSkipped = 0;
|
|
285
305
|
for (const flow of selected) {
|
|
@@ -313,6 +333,9 @@ export async function driveUiReview(
|
|
|
313
333
|
};
|
|
314
334
|
steps.push(entry);
|
|
315
335
|
if (entry.screenshotPath) captures.push({ flow: flow.name, step: entry.step, screenshotPath: entry.screenshotPath, statePath: entry.statePath });
|
|
336
|
+
if (session && MUTATING_ACTIONS.has(step.action)) {
|
|
337
|
+
rowManifest.push({ session, flow: flow.name, step: entry.step, action: step.action });
|
|
338
|
+
}
|
|
316
339
|
if (!ok) record(`step failed (no retry): ${flow.name} / ${entry.step}: ${entry.detail ?? "unknown"}`);
|
|
317
340
|
}
|
|
318
341
|
}
|
|
@@ -343,6 +366,7 @@ export async function driveUiReview(
|
|
|
343
366
|
failures,
|
|
344
367
|
caps: resolvedCaps,
|
|
345
368
|
screensSkipped,
|
|
369
|
+
rowManifest,
|
|
346
370
|
...base(),
|
|
347
371
|
};
|
|
348
372
|
}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* - a self-contained, CSP-safe HTML artifact string (ranked findings + inline
|
|
13
13
|
* screenshot evidence), and
|
|
14
14
|
* - a harness-aware hosting directive (Claude Code -> a publishable Artifacts
|
|
15
|
-
* directive for the orchestrator; any other harness ->
|
|
16
|
-
*
|
|
15
|
+
* directive for the orchestrator; any other harness -> a GitHub-native
|
|
16
|
+
* gist-publish directive the CLI executes, which yields a real per-run URL
|
|
17
|
+
* or fails closed with a stated reason — never a fake link).
|
|
17
18
|
*
|
|
18
19
|
* All IO (reading the diagnose output + the screenshot bytes, writing the HTML,
|
|
19
20
|
* invoking the poster) lives in the thin CLI. This module reads only its inputs.
|
|
@@ -22,9 +23,6 @@
|
|
|
22
23
|
import { isClaudeHarness } from "./run-context.mjs";
|
|
23
24
|
import { sanitizeCopilotSummonTokens } from "../github/copilot-helpers.mjs";
|
|
24
25
|
|
|
25
|
-
/** Follow-up marker for the descoped GitHub-native hosting fallback. */
|
|
26
|
-
export const HOSTING_FOLLOWUP = "#1285";
|
|
27
|
-
|
|
28
26
|
/** Findings past this cap are dropped from the artifact and the drop is logged. */
|
|
29
27
|
export const ARTIFACT_MAX_FINDINGS = 100;
|
|
30
28
|
|
|
@@ -105,9 +103,11 @@ export function severityToEvent({ findings = [], submitAuthorized = false } = {}
|
|
|
105
103
|
/**
|
|
106
104
|
* Harness-aware hosting directive (pure). Claude Code -> a publishable Artifacts
|
|
107
105
|
* directive for the orchestrator to host (this module never calls an agent tool
|
|
108
|
-
* itself). Any other harness
|
|
109
|
-
*
|
|
110
|
-
*
|
|
106
|
+
* itself). Any other harness -> the portable GitHub-native default: publish the
|
|
107
|
+
* self-contained HTML as a secret GitHub Gist (a real per-run URL, zero repo
|
|
108
|
+
* pollution). This module decides the STRATEGY only; the CLI performs the gist
|
|
109
|
+
* publish IO and fails closed with a stated reason if it does not yield a URL.
|
|
110
|
+
* The self-contained HTML is produced regardless; only this link step differs.
|
|
111
111
|
*
|
|
112
112
|
* @param {{htmlPath: string, env?: Record<string,string|undefined>}} input
|
|
113
113
|
*/
|
|
@@ -115,13 +115,7 @@ export function decideHosting({ htmlPath, env = process.env } = {}) {
|
|
|
115
115
|
if (isClaudeHarness(env)) {
|
|
116
116
|
return { hosting: "claude-artifact", publishable: true, htmlPath: htmlPath ?? null };
|
|
117
117
|
}
|
|
118
|
-
return {
|
|
119
|
-
hosting: "unavailable",
|
|
120
|
-
publishable: false,
|
|
121
|
-
htmlPath: htmlPath ?? null,
|
|
122
|
-
reason: "no hosted-artifact publisher on this harness; GitHub-native fallback is deferred",
|
|
123
|
-
followup: HOSTING_FOLLOWUP,
|
|
124
|
-
};
|
|
118
|
+
return { hosting: "github-gist", publishable: true, htmlPath: htmlPath ?? null };
|
|
125
119
|
}
|
|
126
120
|
|
|
127
121
|
/** One review-body line describing where the screenshot artifact lives. Links a
|
|
@@ -129,14 +123,22 @@ export function decideHosting({ htmlPath, env = process.env } = {}) {
|
|
|
129
123
|
* the review never blocks on hosting. */
|
|
130
124
|
function artifactBodyLine({ hosting, hostedUrl }) {
|
|
131
125
|
if (typeof hostedUrl === "string" && hostedUrl.length > 0) {
|
|
126
|
+
// Only an ACTUALLY-published gist gets the source-rendered caveat: an explicit
|
|
127
|
+
// --hosted-url override leaves the strategy as github-gist but sets no gist, so
|
|
128
|
+
// a self-hosted (maybe live-rendered) URL falls through to the neutral line.
|
|
129
|
+
const rawUrl = hosting?.gist?.rawUrl;
|
|
130
|
+
if (rawUrl) {
|
|
131
|
+
// A gist renders HTML as source, not a live page; the raw file is the
|
|
132
|
+
// download/plain-text view — surface it so "open raw" is actually actionable.
|
|
133
|
+
return `Screenshot artifact (GitHub Gist — renders as source; open the raw file to view/download the HTML): ${hostedUrl} (raw: ${rawUrl})`;
|
|
134
|
+
}
|
|
132
135
|
return `Screenshot artifact: ${hostedUrl}`;
|
|
133
136
|
}
|
|
134
137
|
if (hosting?.hosting === "claude-artifact") {
|
|
135
138
|
return "Screenshot artifact prepared for Claude Artifacts hosting (published by the harness; see run output).";
|
|
136
139
|
}
|
|
137
140
|
const reason = hosting?.reason ? ` (${hosting.reason})` : "";
|
|
138
|
-
|
|
139
|
-
return `Screenshot artifact is unhosted this stage${reason}${followup}. Findings are included below.`;
|
|
141
|
+
return `Screenshot artifact is unhosted this stage${reason}. Findings are included below.`;
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
/** A finding is inlineable ONLY with a complete anchor buildDraftReviewPayload
|