@dev-loops/core 0.9.0 → 1.0.0-rc.2
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 +265 -67
- 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
|
@@ -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
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Teardown + side-effect ledger orchestrator for the ui_review route (Stage 5).
|
|
3
3
|
*
|
|
4
4
|
* Terminal cleanup for a running-app review: stop the app booted in Stage 1,
|
|
5
|
-
* drop the dev-DB rows the Stage-2 drive
|
|
5
|
+
* drop the dev-DB rows the Stage-2 drive tagged, and remove the provisioned
|
|
6
6
|
* worktree. The core safety property of this stage is that a side-effect ledger
|
|
7
7
|
* is ALWAYS emitted — enumerating every migration applied, row created/dropped,
|
|
8
8
|
* the worktree path, and any process left running — so nothing the loop touched
|
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* reports rows "may remain (untagged)"
|
|
31
|
+
* Row-drop model: Stage 2 stamps each mutating step with a drive-session id and
|
|
32
|
+
* emits a session-tagged row manifest. Given that manifest (and confirmation),
|
|
33
|
+
* this stage drops exactly the rows tagged with that session. Only the fallback
|
|
34
|
+
* case — a drive that mutated but handed in no manifest — CANNOT know which rows
|
|
35
|
+
* to drop and MUST NOT guess: the ledger reports rows "may remain (untagged)"
|
|
36
|
+
* rather than dropping anything.
|
|
36
37
|
*/
|
|
37
38
|
|
|
38
39
|
const ROW_STATUS = Object.freeze({
|
|
@@ -60,6 +61,13 @@ const PROCESS_STATUS = Object.freeze({
|
|
|
60
61
|
SKIPPED: "skipped",
|
|
61
62
|
});
|
|
62
63
|
|
|
64
|
+
const GIST_STATUS = Object.freeze({
|
|
65
|
+
DELETED: "deleted",
|
|
66
|
+
DELETE_FAILED: "delete-failed",
|
|
67
|
+
SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
|
|
68
|
+
NONE: "none",
|
|
69
|
+
});
|
|
70
|
+
|
|
63
71
|
/**
|
|
64
72
|
* Did the Stage-2 drive potentially create dev-DB rows? Without row tagging this
|
|
65
73
|
* is a coarse but honest signal: a drive that actually walked steps exercised
|
|
@@ -82,8 +90,12 @@ function driveMayHaveCreatedRows(driveResult) {
|
|
|
82
90
|
* signal (whether the drive walked mutating steps). Null when no drive ran.
|
|
83
91
|
* @param {Array<object>|null} [input.rowManifest] - Explicit rows to drop, when
|
|
84
92
|
* a session tag/manifest is available. Absent/empty => untagged fallback.
|
|
93
|
+
* @param {{id?:string|null,url?:string|null}|null} [input.gist] - The Stage-4
|
|
94
|
+
* GitHub-native hosting artifact (a secret gist) to prune, when one was
|
|
95
|
+
* published off-Claude. Absent => nothing to prune.
|
|
85
96
|
* @param {boolean} [input.confirm] - Explicit authorization for the destructive
|
|
86
|
-
* steps (row drop, worktree removal). Fail-safe: absent means
|
|
97
|
+
* steps (row drop, worktree removal, gist deletion). Fail-safe: absent means
|
|
98
|
+
* NOT confirmed.
|
|
87
99
|
* @param {boolean} [input.stopApp] - Stop the Stage-1 app (default true). This is
|
|
88
100
|
* a clean shutdown, NOT gated on confirmation.
|
|
89
101
|
* @param {object} seams
|
|
@@ -92,12 +104,14 @@ function driveMayHaveCreatedRows(driveResult) {
|
|
|
92
104
|
* signalling is unsupported) — mapped to MAY_BE_RUNNING (non-fatal), not KILL_FAILED.
|
|
93
105
|
* @param {(a:{rows:Array<object>})=>Promise<{ok:boolean,dropped:number,detail:string}>} seams.dropRows
|
|
94
106
|
* @param {(a:{worktreePath:string})=>Promise<{removed:string|null,ok:boolean,detail:string}>} seams.removeWorktree
|
|
107
|
+
* @param {(a:{id:string})=>Promise<{ok:boolean,detail:string}>} [seams.deleteGist] - Prune the
|
|
108
|
+
* Stage-4 hosting gist. Only invoked when a gist id is present AND confirmed.
|
|
95
109
|
* @param {(msg:string)=>void} [seams.log]
|
|
96
110
|
* @returns {Promise<{ok:boolean,confirmed:boolean,ledger:object,errors:string[],logs:string[]}>}
|
|
97
111
|
*/
|
|
98
112
|
export async function teardown(
|
|
99
|
-
{ provisionResult, driveResult = null, rowManifest = null, confirm = false, stopApp = true },
|
|
100
|
-
{ killProcess, dropRows, removeWorktree, log = () => {} } = {},
|
|
113
|
+
{ provisionResult, driveResult = null, rowManifest = null, gist = null, confirm = false, stopApp = true },
|
|
114
|
+
{ killProcess, dropRows, removeWorktree, deleteGist, log = () => {} } = {},
|
|
101
115
|
) {
|
|
102
116
|
const logs = [];
|
|
103
117
|
const errors = [];
|
|
@@ -229,6 +243,33 @@ export async function teardown(
|
|
|
229
243
|
}
|
|
230
244
|
}
|
|
231
245
|
|
|
246
|
+
// 4. Prune the Stage-4 hosting gist — DESTRUCTIVE, confirmation-gated. A gist
|
|
247
|
+
// accretes one secret entry per run; deleting it keeps the hosting target
|
|
248
|
+
// from piling up. Only ever acts on an explicit gist id from the report
|
|
249
|
+
// result; a missing id is NONE (nothing published, or Claude-hosted).
|
|
250
|
+
const gistId = typeof gist?.id === "string" && gist.id.trim().length > 0 ? gist.id.trim() : null;
|
|
251
|
+
let gistLedger;
|
|
252
|
+
if (!gistId) {
|
|
253
|
+
gistLedger = { id: null, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.NONE, detail: "no hosting gist to prune" };
|
|
254
|
+
} else if (!confirm) {
|
|
255
|
+
gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.SKIPPED_UNCONFIRMED, detail: "hosting gist retained: teardown not confirmed" };
|
|
256
|
+
record(`gist prune skipped (not confirmed): ${gistId} retained`);
|
|
257
|
+
} else {
|
|
258
|
+
try {
|
|
259
|
+
const del = await deleteGist({ id: gistId });
|
|
260
|
+
if (del.ok) {
|
|
261
|
+
gistLedger = { id: gistId, url: gist?.url ?? null, deleted: true, status: GIST_STATUS.DELETED, detail: del.detail };
|
|
262
|
+
record(`hosting gist deleted: ${gistId} (${del.detail})`);
|
|
263
|
+
} else {
|
|
264
|
+
gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.DELETE_FAILED, detail: del.detail };
|
|
265
|
+
fail(`hosting gist delete FAILED: ${gistId} (${del.detail})`);
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.DELETE_FAILED, detail: `deleteGist seam threw: ${err?.message ?? err}` };
|
|
269
|
+
fail(`hosting gist delete FAILED: ${gistId} (deleteGist seam threw: ${err?.message ?? err})`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
232
273
|
// The ledger is ALWAYS emitted (every case), enumerating every known side
|
|
233
274
|
// effect. Migrations are recorded as applied-not-reverted by design.
|
|
234
275
|
const ledger = {
|
|
@@ -241,10 +282,11 @@ export async function teardown(
|
|
|
241
282
|
},
|
|
242
283
|
rows: rowsLedger,
|
|
243
284
|
worktree: worktreeLedger,
|
|
285
|
+
gist: gistLedger,
|
|
244
286
|
process: processLedger,
|
|
245
287
|
};
|
|
246
288
|
|
|
247
289
|
return { ok: errors.length === 0, confirmed: confirm, ledger, errors, logs };
|
|
248
290
|
}
|
|
249
291
|
|
|
250
|
-
export { ROW_STATUS, WORKTREE_STATUS, PROCESS_STATUS };
|
|
292
|
+
export { ROW_STATUS, WORKTREE_STATUS, PROCESS_STATUS, GIST_STATUS };
|