@dev-loops/core 0.8.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 +8 -1
- package/src/analysis/change-classifier.mjs +15 -3
- package/src/analysis/diff-analyzer.mjs +112 -6
- package/src/claude/asset-generation.mjs +43 -4
- package/src/config/config.mjs +454 -5
- package/src/config/extension-defaults.yaml +7 -1
- 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/handoff-envelope.mjs +27 -0
- package/src/loop/issue-refinement-artifact.mjs +10 -5
- package/src/loop/policy-constants.mjs +0 -3
- package/src/loop/pr-gate-coordination.mjs +12 -7
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- package/src/loop/queue-state.mjs +0 -9
- package/src/loop/steering.mjs +4 -2
- package/src/loop/ui-review-diagnose.mjs +291 -0
- package/src/loop/ui-review-drive.mjs +372 -0
- package/src/loop/ui-review-provision.mjs +264 -0
- package/src/loop/ui-review-report.mjs +289 -0
- package/src/loop/ui-review-teardown.mjs +292 -0
|
@@ -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
|
+
}
|
|
@@ -44,6 +44,13 @@ const STRATEGY_DEFAULT_STOP_RULES = Object.freeze({
|
|
|
44
44
|
[INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH]: ["merge"],
|
|
45
45
|
[INTERNAL_DEV_LOOP_STRATEGY.FINAL_APPROVAL]: ["merge"],
|
|
46
46
|
[INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION]: [],
|
|
47
|
+
[INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW]: [
|
|
48
|
+
"no-product-code-writes",
|
|
49
|
+
"worktree-only",
|
|
50
|
+
"outward-review-pending",
|
|
51
|
+
"ack-destructive-migrations",
|
|
52
|
+
"merge",
|
|
53
|
+
],
|
|
47
54
|
});
|
|
48
55
|
|
|
49
56
|
// ---------------------------------------------------------------------------
|
|
@@ -134,6 +141,26 @@ register(INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH, "default", {
|
|
|
134
141
|
activeNoticeAfterMs: WATCH_ACTIVE_NOTICE_MS,
|
|
135
142
|
});
|
|
136
143
|
|
|
144
|
+
// ui_review — running-app review sibling of reviewer/fixer. Scaffold slice:
|
|
145
|
+
// self-validation only, no drive/report/provision/boot logic. The criteria
|
|
146
|
+
// capture the route-specific review boundaries (no product-code writes,
|
|
147
|
+
// worktree isolation, outward review stays pending/draft, destructive
|
|
148
|
+
// migrations acknowledged before running) so the dispatched agent self-checks
|
|
149
|
+
// them; the generic finalization stop rules (e.g. merge) are layered on
|
|
150
|
+
// separately and are not restated here.
|
|
151
|
+
register(INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW, "default", {
|
|
152
|
+
criteria: [
|
|
153
|
+
{ id: "no-product-code-writes", must: "No product code is written; the UI-review route only observes and reports on the running app.", severity: "required" },
|
|
154
|
+
{ id: "worktree-only", must: "All work stays inside the isolated worktree; nothing is written outside it.", severity: "required" },
|
|
155
|
+
{ id: "outward-review-pending", must: "Any outward review stays pending/draft; no approval or merge is emitted from the UI-review route.", severity: "required" },
|
|
156
|
+
{ id: "ack-destructive-migrations", must: "Destructive migrations are explicitly acknowledged before they are run.", severity: "required" },
|
|
157
|
+
],
|
|
158
|
+
evidence: ["commands-run", "validation-output"],
|
|
159
|
+
maxFinalizationTurns: 4,
|
|
160
|
+
needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
|
|
161
|
+
activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
|
|
162
|
+
});
|
|
163
|
+
|
|
137
164
|
// Remaining strategies get a generic acceptance template
|
|
138
165
|
function registerGeneric(strategy) {
|
|
139
166
|
register(strategy, "default", {
|
|
@@ -29,6 +29,15 @@ export const REFINEMENT_SOURCE = Object.freeze({
|
|
|
29
29
|
|
|
30
30
|
const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
|
|
31
31
|
|
|
32
|
+
// The three artifact sources, any ONE of which satisfies the refinement gate.
|
|
33
|
+
// Single source of truth for the "missing" vocabulary reported when none is
|
|
34
|
+
// present — consumed by the enqueue gate and the parked-unrefined discovery.
|
|
35
|
+
export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
|
|
36
|
+
"Acceptance criteria section",
|
|
37
|
+
"Definition of done section",
|
|
38
|
+
"linked refinement doc",
|
|
39
|
+
]);
|
|
40
|
+
|
|
32
41
|
/**
|
|
33
42
|
* Canonical list of section headings that satisfy the refinement check.
|
|
34
43
|
* Matching is case-insensitive and tolerates trailing/leading whitespace.
|
|
@@ -524,11 +533,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
|
|
|
524
533
|
if (!targetIsPickup || artifact.finding === null) {
|
|
525
534
|
return { action: "enqueue" };
|
|
526
535
|
}
|
|
527
|
-
const missing = [
|
|
528
|
-
"Acceptance criteria section",
|
|
529
|
-
"Definition of done section",
|
|
530
|
-
"linked refinement doc",
|
|
531
|
-
];
|
|
536
|
+
const missing = [...REFINEMENT_ARTIFACT_SOURCES];
|
|
532
537
|
const reason =
|
|
533
538
|
`Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
|
|
534
539
|
"Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
|
|
@@ -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({
|
|
@@ -23,6 +23,7 @@ export const DEV_LOOP_PUBLIC_INTENT = Object.freeze({
|
|
|
23
23
|
CONTINUE_CURRENT: "continue_current",
|
|
24
24
|
AUTO_CONTINUE_CURRENT: "auto_continue_current",
|
|
25
25
|
INSPECT_STATE: "inspect_state",
|
|
26
|
+
REVIEW_PR_UI: "review_pr_ui",
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
export const DEV_LOOP_TARGET_KIND = Object.freeze({
|
|
@@ -76,6 +77,7 @@ export const DEV_LOOP_GATE = Object.freeze({
|
|
|
76
77
|
EXTERNAL_PR_FOLLOWUP: "external_pr_followup",
|
|
77
78
|
REVIEWER_FIXER: "reviewer_fixer",
|
|
78
79
|
COPILOT_PR_FOLLOWUP: "copilot_pr_followup",
|
|
80
|
+
UI_REVIEW: "ui_review",
|
|
79
81
|
FAIL_CLOSED_RECONCILE: "fail_closed_reconcile",
|
|
80
82
|
});
|
|
81
83
|
|
|
@@ -87,6 +89,7 @@ export const INTERNAL_DEV_LOOP_STRATEGY = Object.freeze({
|
|
|
87
89
|
REVIEWER_FIXER: "reviewer_fixer",
|
|
88
90
|
WAIT_WATCH: "wait_watch",
|
|
89
91
|
FINAL_APPROVAL: "final_approval",
|
|
92
|
+
UI_REVIEW: "ui_review",
|
|
90
93
|
NONE: null,
|
|
91
94
|
});
|
|
92
95
|
|
|
@@ -267,6 +270,12 @@ export const PUBLIC_DEV_LOOP_GATE_CONTRACT = Object.freeze([
|
|
|
267
270
|
selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP,
|
|
268
271
|
summary: "Copilot-owned PR state routes to Copilot PR follow-up; an already-linked open PR stays the canonical artifact for that issue until reconciled",
|
|
269
272
|
}),
|
|
273
|
+
Object.freeze({
|
|
274
|
+
gate: DEV_LOOP_GATE.UI_REVIEW,
|
|
275
|
+
routeKind: DEV_LOOP_ROUTE_KIND.ROUTE,
|
|
276
|
+
selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW,
|
|
277
|
+
summary: "an explicit UI-review request on a PR target routes to the ui_review running-app review strategy",
|
|
278
|
+
}),
|
|
270
279
|
Object.freeze({
|
|
271
280
|
gate: DEV_LOOP_GATE.FAIL_CLOSED_RECONCILE,
|
|
272
281
|
routeKind: DEV_LOOP_ROUTE_KIND.NEEDS_RECONCILE,
|
|
@@ -461,7 +461,7 @@ function toRoutableCanonicalState(canonicalState) {
|
|
|
461
461
|
};
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
-
function selectGateForState(canonicalState) {
|
|
464
|
+
function selectGateForState(canonicalState, { uiReviewRequested = false } = {}) {
|
|
465
465
|
if (canonicalState.status === DEV_LOOP_STATUS.BLOCKED || canonicalState.authorization === DEV_LOOP_AUTHORIZATION.NOT_AUTHORIZED) {
|
|
466
466
|
return DEV_LOOP_GATE.STOP_BLOCKED_OR_NOT_AUTHORIZED;
|
|
467
467
|
}
|
|
@@ -499,6 +499,15 @@ function selectGateForState(canonicalState) {
|
|
|
499
499
|
return DEV_LOOP_GATE.ISSUE_INTAKE;
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
// An explicit UI-review request intercepts a PR target ahead of the
|
|
503
|
+
// ownership-derived PR gates: the running-app review is requested regardless
|
|
504
|
+
// of who owns the PR. It stays after the authoritative lifecycle stop/terminal/
|
|
505
|
+
// approval/waiting gates so it can never bypass them. Absent the signal this
|
|
506
|
+
// branch is inert, so existing routes stay byte-identical.
|
|
507
|
+
if (uiReviewRequested && canonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR) {
|
|
508
|
+
return DEV_LOOP_GATE.UI_REVIEW;
|
|
509
|
+
}
|
|
510
|
+
|
|
502
511
|
if (canonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR && canonicalState.ownership === DEV_LOOP_ACTOR.EXTERNAL_HUMAN) {
|
|
503
512
|
return DEV_LOOP_GATE.EXTERNAL_PR_FOLLOWUP;
|
|
504
513
|
}
|
|
@@ -581,10 +590,11 @@ function routeForState(
|
|
|
581
590
|
issueAssignmentState = null,
|
|
582
591
|
gateReviewEvidence = null,
|
|
583
592
|
targetPreference = null,
|
|
593
|
+
uiReviewRequested = false,
|
|
584
594
|
} = {},
|
|
585
595
|
) {
|
|
586
596
|
const routableCanonicalState = toRoutableCanonicalState(canonicalState);
|
|
587
|
-
const selectedGate = selectGateForState(routableCanonicalState);
|
|
597
|
+
const selectedGate = selectGateForState(routableCanonicalState, { uiReviewRequested });
|
|
588
598
|
if (
|
|
589
599
|
selectedGate === DEV_LOOP_GATE.FINAL_APPROVAL
|
|
590
600
|
&& routableCanonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR
|
|
@@ -763,6 +773,18 @@ function routeForState(
|
|
|
763
773
|
});
|
|
764
774
|
}
|
|
765
775
|
|
|
776
|
+
if (selectedGate === DEV_LOOP_GATE.UI_REVIEW) {
|
|
777
|
+
return buildResult({
|
|
778
|
+
selectedGate,
|
|
779
|
+
routeKind: DEV_LOOP_ROUTE_KIND.ROUTE,
|
|
780
|
+
selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW,
|
|
781
|
+
executionMode,
|
|
782
|
+
canonicalState: routableCanonicalState,
|
|
783
|
+
nextAction: "Run the UI-review route for the current PR: prove the change in the running app from an isolated worktree. Do not write product code; keep any outward review pending/draft; acknowledge destructive migrations before running them.",
|
|
784
|
+
reason: "An explicit UI-review request on a PR target routes to the ui_review strategy — the running-app review sibling of the reviewer/fixer route.",
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
766
788
|
return buildReconcile(
|
|
767
789
|
"The canonical current state does not map cleanly to any first-slice internal strategy.",
|
|
768
790
|
routableCanonicalState,
|
|
@@ -1171,6 +1193,7 @@ export function resolveAuthoritativeStartupResumeBundle(input = {}) {
|
|
|
1171
1193
|
issueAssignmentState,
|
|
1172
1194
|
gateReviewEvidence,
|
|
1173
1195
|
targetPreference,
|
|
1196
|
+
uiReviewRequested: intent === DEV_LOOP_PUBLIC_INTENT.REVIEW_PR_UI,
|
|
1174
1197
|
});
|
|
1175
1198
|
if (routed.routeKind === DEV_LOOP_ROUTE_KIND.NEEDS_RECONCILE) {
|
|
1176
1199
|
return buildStartupResumeBundleReconcile({
|
|
@@ -1703,6 +1726,23 @@ export function evaluatePublicDevLoopRouting(input = {}) {
|
|
|
1703
1726
|
));
|
|
1704
1727
|
}
|
|
1705
1728
|
|
|
1729
|
+
if (intent === DEV_LOOP_PUBLIC_INTENT.REVIEW_PR_UI) {
|
|
1730
|
+
if (!explicitTarget || explicitTarget.kind !== DEV_LOOP_TARGET_KIND.PR) {
|
|
1731
|
+
return buildInputReconcile("`review_pr_ui` requires a PR target.", null, effectiveMode);
|
|
1732
|
+
}
|
|
1733
|
+
if (!explicitState || explicitState.target.kind !== DEV_LOOP_TARGET_KIND.PR) {
|
|
1734
|
+
return buildInputReconcile("`review_pr_ui` requires a valid canonical PR state.", explicitState, effectiveMode);
|
|
1735
|
+
}
|
|
1736
|
+
if (explicitState.target.pr !== explicitTarget.pr) {
|
|
1737
|
+
return buildInputReconcile("`review_pr_ui` target conflicts with the canonical current PR state.", explicitState, effectiveMode);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
return finalizeRoutingResult(applyWatchValidation(
|
|
1741
|
+
routeForState(explicitState, { ...routingOptions, executionMode: effectiveMode, uiReviewRequested: true }),
|
|
1742
|
+
watchRequested,
|
|
1743
|
+
));
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1706
1746
|
if (intent === DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT) {
|
|
1707
1747
|
if (!explicitState) {
|
|
1708
1748
|
return buildInputReconcile("`continue_current` requires a valid canonical current state.", null, effectiveMode);
|
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
|
|