@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.
@@ -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);
@@ -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
- }
@@ -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