@dev-loops/core 0.9.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -136,7 +136,25 @@ export const NEXT_ACTIONS = Object.freeze({
136
136
 
137
137
  const SAME_HEAD_CLEAN_CONVERGED_NEXT_ACTION = "Current head already has a clean submitted Copilot review; suppress automatic same-head re-request unless a meaningful remediation event occurs, or explicitly request another Copilot pass";
138
138
 
139
- const VALID_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
139
+ /**
140
+ * Canonical snapshot request-status enum (single source of truth). Any request
141
+ * outcome plumbed into the shared loop contract MUST normalize to one of these;
142
+ * richer request-tool outcomes (round_cap_reached, suppressed_*, etc.) collapse
143
+ * to "none" here because they mean "no active Copilot request is in flight".
144
+ */
145
+ export const VALID_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
146
+
147
+ /**
148
+ * Collapse an arbitrary request-tool outcome to the canonical snapshot
149
+ * request-status enum. Unrecognized statuses (round cap / suppression
150
+ * diagnostics) map to "none" so they never leak into the shared contract.
151
+ *
152
+ * @param {string|undefined} status
153
+ * @returns {string} a member of VALID_REVIEW_REQUEST_STATUSES
154
+ */
155
+ export function toSharedRequestStatus(status) {
156
+ return VALID_REVIEW_REQUEST_STATUSES.has(status) ? status : "none";
157
+ }
140
158
  const VALID_CI_STATUSES = new Set(["success", "failure", "pending", "none", "crediblyGreen"]);
141
159
  const ACTIVE_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
142
160
 
@@ -341,6 +359,9 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
341
359
  * @param {number} [refinementConfig.lowSignalRoundThreshold]
342
360
  * @param {number} [refinementConfig.lowSignalMaxComments]
343
361
  * @param {number} [refinementConfig.maxCopilotRounds]
362
+ * @param {boolean} [refinementConfig.preApprovalRequireCi] - #1337: default true. When false,
363
+ * the pre-approval CI precondition is opted out, so a non-draft PR with a pending/none/failure
364
+ * CI verdict is not routed to waiting_for_ci / blocked_needs_user_decision (it is past the draft gate).
344
365
  * @returns {{
345
366
  * state: string,
346
367
  * allowedTransitions: string[],
@@ -353,6 +374,17 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
353
374
  export function interpretLoopState(snapshot, refinementConfig) {
354
375
  const s = normalizeSnapshot(snapshot);
355
376
 
377
+ // Pre-approval CI opt-out (#1337): when `gates.preApproval.requireCi` is false,
378
+ // the CI verdict must not gate progression at the pre-approval boundary. A
379
+ // non-draft PR is past the draft gate, so this is the applicable knob — treat
380
+ // pending/none/failure CI as non-blocking here so a repo with no CI is not
381
+ // routed to WAITING_FOR_CI / BLOCKED_NEEDS_USER_DECISION before the downstream
382
+ // gate-coordination guards (which already honor this flag) are ever reached.
383
+ // Default true preserves current behavior for every caller that does not thread it.
384
+ const preApprovalRequireCi = refinementConfig?.preApprovalRequireCi !== false;
385
+ const ciBlocks = preApprovalRequireCi && isBlockedCiStatus(s.ciStatus);
386
+ const ciWaits = preApprovalRequireCi && isWaitingCiStatus(s.ciStatus);
387
+
356
388
  let state;
357
389
 
358
390
  if (!s.prExists) {
@@ -403,7 +435,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
403
435
  && state !== STATE.NO_PR && state !== STATE.DONE
404
436
  && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
405
437
  && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
406
- const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen";
438
+ const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
407
439
  const cleanThreads = s.unresolvedThreadCount === 0;
408
440
  if (cleanThreads && ciClean) {
409
441
  // Clean PR at the cap: proceed to the pre_approval_gate fallback regardless of a
@@ -430,18 +462,18 @@ export function interpretLoopState(snapshot, refinementConfig) {
430
462
  state = STATE.WAITING_FOR_COPILOT_REVIEW;
431
463
  } else if (s.copilotReviewPresent) {
432
464
  // Copilot has reviewed at least once; all threads resolved
433
- if (isBlockedCiStatus(s.ciStatus)) {
465
+ if (ciBlocks) {
434
466
  state = STATE.BLOCKED_NEEDS_USER_DECISION;
435
- } else if (isWaitingCiStatus(s.ciStatus)) {
467
+ } else if (ciWaits) {
436
468
  state = STATE.WAITING_FOR_CI;
437
469
  } else {
438
470
  state = STATE.READY_TO_REREQUEST_REVIEW;
439
471
  }
440
472
  } else {
441
473
  // No Copilot review yet; not currently requested
442
- if (isBlockedCiStatus(s.ciStatus)) {
474
+ if (ciBlocks) {
443
475
  state = STATE.BLOCKED_NEEDS_USER_DECISION;
444
- } else if (isWaitingCiStatus(s.ciStatus)) {
476
+ } else if (ciWaits) {
445
477
  state = STATE.WAITING_FOR_CI;
446
478
  } else {
447
479
  state = STATE.PR_READY_NO_FEEDBACK;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Gate carry-forward: a pure, fail-closed seam that decides whether a clean gate
3
+ * angle verdict recorded at head A may be CARRIED FORWARD to head B without
4
+ * re-running that angle's reviewer.
5
+ *
6
+ * Motivation: fresh-context-per-head re-fans ALL gate angles on every head bump,
7
+ * even when the delta between the two heads provably cannot affect most angles
8
+ * (e.g. a doc-only follow-up commit cannot change what a code-correctness angle
9
+ * would find). Carry-forward lets the gate reuse the prior clean verdict for such
10
+ * angles — but ONLY when it is provably safe.
11
+ *
12
+ * FAIL-CLOSED is paramount. An angle carries forward ONLY when EVERY changed file
13
+ * in the delta A..B is provably OUTSIDE that angle's declared review surface. The
14
+ * default in every uncertain case (non-clean prior verdict, empty/unavailable
15
+ * delta, an unclassifiable file, an angle with no declared surface, a mandatory /
16
+ * always-run angle) is MUST-RE-RUN. Carry-forward never fabricates a verdict: the
17
+ * caller records the carried verdict with provenance pointing at the PRIOR head's
18
+ * reviewer (that reviewer genuinely reviewed this angle's surface, which the delta
19
+ * did not touch), clearly marked as carried — see
20
+ * docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
21
+ * `carriedFromHead` provenance field.
22
+ *
23
+ * The angle -> review-surface mapping is DERIVED from the single source of truth
24
+ * for change-category -> angle relevance (CATEGORY_ANGLE_MAP in
25
+ * ../analysis/change-classifier.mjs) so the two never drift: an angle's review
26
+ * surface is exactly the set of file "surface kinds" whose change could, under the
27
+ * existing dynamic-angle rules, implicate that angle. File classification reuses
28
+ * classifyFile() from the diff analyzer (the same classifier dynamic angle
29
+ * resolution already trusts).
30
+ *
31
+ * This module is intentionally pure and side-effect free.
32
+ */
33
+
34
+ import { classifyFile } from "../analysis/diff-analyzer.mjs";
35
+ import { ALWAYS_INCLUDE, CATEGORY_ANGLE_MAP } from "../analysis/change-classifier.mjs";
36
+
37
+ /**
38
+ * File surface kind (classifyFile output) -> the change categories a change of
39
+ * that kind can produce. A code file can be either a logic change or a
40
+ * comment-only change; the other kinds each map to their single `_ONLY` category.
41
+ * "unknown" is intentionally ABSENT: an unclassifiable file is treated as
42
+ * touching EVERY angle's surface (fail-closed), so it never appears here.
43
+ *
44
+ * RENAME_ONLY is not a file kind — a renamed file still classifies by its
45
+ * destination path's kind, so the destination kind's own categories already
46
+ * implicate the right angles (a renamed code file -> code -> LOGIC_CHANGE, a
47
+ * renamed doc -> docs -> DOCS_ONLY). Folding RENAME_ONLY into every kind would
48
+ * over-attribute code angles to a doc-only delta and defeat the primary
49
+ * carry-forward case, so it is deliberately omitted here. A destination-kind
50
+ * classification alone, though, misses what the RENAME itself implicates (a
51
+ * moved doc can break a link; a moved test/code file shifts scope /
52
+ * contract-surface). Rename detection therefore lives at the DELTA layer: the
53
+ * CLI notices any rename/copy row and forces {@link RENAME_ONLY_ANGLES} to
54
+ * re-run for that run (fail-closed), instead of encoding a phantom "rename" file
55
+ * kind here.
56
+ *
57
+ * @type {Record<string, string[]>}
58
+ */
59
+ const KIND_TO_CATEGORIES = {
60
+ docs: ["DOCS_ONLY"],
61
+ config: ["CONFIG_ONLY"],
62
+ test: ["TEST_ONLY"],
63
+ ci: ["CI_ONLY"],
64
+ code: ["LOGIC_CHANGE", "COMMENT_ONLY"],
65
+ };
66
+
67
+ /**
68
+ * The angles a pure rename implicates (CATEGORY_ANGLE_MAP[RENAME_ONLY]), minus
69
+ * any always-run angle (already never carried). A delta containing ANY rename
70
+ * forces these to re-run — a rename's effect (moved doc breaking a link, moved
71
+ * test/code shifting scope/contract-surface) is not captured by classifying the
72
+ * destination path alone. Derived from the single source of truth so it never
73
+ * drifts from the dynamic-angle rules.
74
+ *
75
+ * @type {string[]}
76
+ */
77
+ export const RENAME_ONLY_ANGLES = (CATEGORY_ANGLE_MAP.RENAME_ONLY ?? []).filter(
78
+ (angle) => !ALWAYS_INCLUDE.has(angle),
79
+ );
80
+
81
+ /**
82
+ * angle -> Set<surface kind>: an angle's review surface is the set of file kinds
83
+ * whose change could implicate it, inverted from CATEGORY_ANGLE_MAP via
84
+ * KIND_TO_CATEGORIES. Built once at module load. ALWAYS_INCLUDE angles are NOT
85
+ * given a kinds surface here — they always re-run (handled in angleReviewSurface).
86
+ *
87
+ * @type {Map<string, Set<string>>}
88
+ */
89
+ const ANGLE_SURFACE_KINDS = (() => {
90
+ const map = new Map();
91
+ for (const [kind, categories] of Object.entries(KIND_TO_CATEGORIES)) {
92
+ for (const category of categories) {
93
+ for (const angle of CATEGORY_ANGLE_MAP[category] ?? []) {
94
+ if (ALWAYS_INCLUDE.has(angle)) continue;
95
+ if (!map.has(angle)) map.set(angle, new Set());
96
+ map.get(angle).add(kind);
97
+ }
98
+ }
99
+ }
100
+ return map;
101
+ })();
102
+
103
+ /**
104
+ * @typedef {{ kind: "always" }
105
+ * | { kind: "unknown" }
106
+ * | { kind: "kinds", kinds: Set<string> }} AngleReviewSurface
107
+ */
108
+
109
+ /**
110
+ * Resolve an angle's declared review surface (the pure angle -> surface mapping).
111
+ *
112
+ * - ALWAYS_INCLUDE angles (gate-evidence, renderer-security, pr-description) plus
113
+ * any explicit alwaysRerun angle -> `{ kind: "always" }`. These review a surface
114
+ * we cannot fully bound from the file delta alone (e.g. pr-description also
115
+ * depends on the PR body, which is not a changed FILE), so they NEVER carry
116
+ * forward.
117
+ * - A mapped angle -> `{ kind: "kinds", kinds }` (the file kinds that implicate it).
118
+ * - An unmapped / unknown angle -> `{ kind: "unknown" }` (fail-closed: never carry).
119
+ *
120
+ * @param {string} angle
121
+ * @param {{ alwaysRerun?: Iterable<string> }} [options]
122
+ * @returns {AngleReviewSurface}
123
+ */
124
+ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
125
+ const name = typeof angle === "string" ? angle.trim() : "";
126
+ if (name.length === 0) return { kind: "unknown" };
127
+ if (ALWAYS_INCLUDE.has(name)) return { kind: "always" };
128
+ if (alwaysRerun && new Set(alwaysRerun).has(name)) return { kind: "always" };
129
+ const kinds = ANGLE_SURFACE_KINDS.get(name);
130
+ if (!kinds || kinds.size === 0) return { kind: "unknown" };
131
+ return { kind: "kinds", kinds: new Set(kinds) };
132
+ }
133
+
134
+ /**
135
+ * Pure, deterministic, FAIL-CLOSED carry-forward decision for a single angle.
136
+ *
137
+ * Given a prior CLEAN verdict recorded at head A, the changed files of the delta
138
+ * A..B, and the angle's declared review surface, decide whether the clean verdict
139
+ * may be carried forward to head B (carryForward: true) or the angle MUST re-run
140
+ * (carryForward: false). Defaults to must-re-run in every uncertain case.
141
+ *
142
+ * @param {object} input
143
+ * @param {string} input.angle
144
+ * @param {AngleReviewSurface} [input.angleSurface] — the angle's declared surface;
145
+ * derived from {@link angleReviewSurface} when omitted.
146
+ * @param {string[]} input.changedFiles — repo-relative paths changed between head
147
+ * A and head B (the delta, NOT the full PR diff against base).
148
+ * @param {string} input.prevVerdict — the angle's verdict at head A. Only "clean"
149
+ * is carry-forward-eligible.
150
+ * @returns {{ carryForward: boolean, reason: string }}
151
+ */
152
+ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
153
+ if (prevVerdict !== "clean") {
154
+ return { carryForward: false, reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not "clean"` };
155
+ }
156
+ const surface = angleSurface ?? angleReviewSurface(angle);
157
+ if (surface.kind === "always") {
158
+ return { carryForward: false, reason: "angle always re-runs (mandatory / always-include surface)" };
159
+ }
160
+ if (surface.kind === "unknown") {
161
+ return { carryForward: false, reason: "angle has no declared review surface (fail-closed)" };
162
+ }
163
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
164
+ return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
165
+ }
166
+ for (const file of changedFiles) {
167
+ const kind = classifyFile(file);
168
+ if (kind === "unknown") {
169
+ return { carryForward: false, reason: `delta contains an unclassifiable file (fail-closed): ${file}` };
170
+ }
171
+ if (surface.kinds.has(kind)) {
172
+ return { carryForward: false, reason: `delta touches the angle's review surface (${kind}): ${file}` };
173
+ }
174
+ }
175
+ return {
176
+ carryForward: true,
177
+ reason: `delta is provably outside the angle's review surface (surface kinds: ${[...surface.kinds].sort().join(", ")})`,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Convenience: partition a set of previously-clean angles into those that may be
183
+ * carried forward and those that must re-run, given the delta A..B. Each entry
184
+ * carries the decision reason. Non-clean angles are not carry-forward-eligible and
185
+ * belong in the re-run set — callers should pass only angles whose prior verdict
186
+ * was clean, or set `prevVerdict` per angle via the single-angle function.
187
+ *
188
+ * @param {object} input
189
+ * @param {string[]} input.prevAngles — angles that were clean at head A
190
+ * @param {string[]} input.changedFiles — delta A..B
191
+ * @param {{ alwaysRerun?: Iterable<string> }} [input.options]
192
+ * @returns {{ carried: Array<{ angle: string, reason: string }>, mustRerun: Array<{ angle: string, reason: string }> }}
193
+ */
194
+ export function resolveCarryForwardAngles({ prevAngles, changedFiles, options = {} }) {
195
+ const carried = [];
196
+ const mustRerun = [];
197
+ for (const angle of Array.isArray(prevAngles) ? prevAngles : []) {
198
+ const decision = resolveAngleCarryForward({
199
+ angle,
200
+ angleSurface: angleReviewSurface(angle, options),
201
+ changedFiles,
202
+ prevVerdict: "clean",
203
+ });
204
+ (decision.carryForward ? carried : mustRerun).push({ angle, reason: decision.reason });
205
+ }
206
+ return { carried, mustRerun };
207
+ }
208
+
209
+ /**
210
+ * The file surface kinds the external Copilot code review actually reviews. Docs
211
+ * and comment-only prose are NOT part of it; everything a Copilot review could
212
+ * legitimately raise a code nit about is (code, tests, config, CI).
213
+ * @type {Set<string>}
214
+ */
215
+ const COPILOT_REVIEW_SURFACE_KINDS = new Set(["code", "test", "config", "ci"]);
216
+
217
+ /**
218
+ * AC2, fail-closed: decide whether a post-convergence head bump may carry forward
219
+ * a settled clean Copilot convergence instead of forcing a fresh BLOCKING Copilot
220
+ * round. Carries forward ONLY when the delta since the converged head is provably
221
+ * outside Copilot's review surface — a pure doc/prose-only bump (every changed
222
+ * file classifies as `docs`; a code comment-only change classifies as `code` and
223
+ * re-runs, since classifyFile is path-based). Any code/test/config/CI file, an unclassifiable
224
+ * file, or an empty/unavailable delta -> re-run (fresh blocking round required).
225
+ *
226
+ * @param {object} input
227
+ * @param {string[]} input.changedFiles — delta since the converged head
228
+ * @returns {{ carryForward: boolean, reason: string }}
229
+ */
230
+ export function resolveConvergenceCarryForward({ changedFiles }) {
231
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
232
+ return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
233
+ }
234
+ for (const file of changedFiles) {
235
+ const kind = classifyFile(file);
236
+ if (kind === "unknown") {
237
+ return { carryForward: false, reason: `delta contains an unclassifiable file (fail-closed): ${file}` };
238
+ }
239
+ if (COPILOT_REVIEW_SURFACE_KINDS.has(kind)) {
240
+ return { carryForward: false, reason: `delta touches Copilot's review surface (${kind}): ${file}` };
241
+ }
242
+ }
243
+ return { carryForward: true, reason: "delta is a pure doc/prose bump, provably outside Copilot's review surface" };
244
+ }
@@ -12,6 +12,3 @@ export const COPILOT_FIRST_DURABLE_WAIT_TIMEOUT_MS = 3_600_000;
12
12
 
13
13
  /** Copilot review wait: external healthy-wait budget */
14
14
  export const COPILOT_REVIEW_WAIT_TIMEOUT_MS = 1_800_000;
15
-
16
- /** Explicit single-check timeout value (used only for status probes) */
17
- export const PROBE_ONLY_TIMEOUT_MS = 0;
@@ -660,6 +660,11 @@ function evaluatePrGateCoordinationCore(input = {}) {
660
660
  const conflictFiles = normalizeConflictFiles(input.conflictFiles);
661
661
  const ciStatus = normalizeCiStatus(input.ciStatus);
662
662
  const draftGateRequireCi = input.draftGateRequireCi !== false;
663
+ // Opt-out CI precondition at the pre-approval boundary (mirrors the draft
664
+ // gate). Default true keeps CI required; false ignores the CI verdict
665
+ // entirely — a "none"/"pending"/"crediblyGreen"/"failure" head no longer
666
+ // waits on or blocks pre_approval.
667
+ const preApprovalRequireCi = input.preApprovalRequireCi !== false;
663
668
  const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
664
669
  const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
665
670
  const roundCapReached = isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds });
@@ -998,7 +1003,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
998
1003
  if (effectiveLifecycleState === STATE.PR_READY_NO_FEEDBACK) {
999
1004
  if (reviewMode === "internal_only") {
1000
1005
  // Explicitly internal-only PR: skip the external Copilot review cycle
1001
- if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
1006
+ if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
1002
1007
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
1003
1008
  pushUnique(forbiddenActions, internalOnlyPostDraftForbidden);
1004
1009
  return buildResult({
@@ -1201,7 +1206,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1201
1206
  }
1202
1207
 
1203
1208
  if (effectiveLifecycleState === STATE.READY_TO_REREQUEST_REVIEW) {
1204
- if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
1209
+ if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
1205
1210
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
1206
1211
  pushUnique(forbiddenActions, postDraftForbidden);
1207
1212
  return buildResult({
@@ -1226,7 +1231,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1226
1231
  });
1227
1232
  }
1228
1233
 
1229
- if (ciStatus === "pending" || ciStatus === "none") {
1234
+ if (preApprovalRequireCi && (ciStatus === "pending" || ciStatus === "none")) {
1230
1235
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
1231
1236
  pushUnique(forbiddenActions, postDraftForbidden);
1232
1237
  return buildResult({
@@ -1410,7 +1415,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1410
1415
  copilotReviewRoundCount,
1411
1416
  });
1412
1417
  }
1413
- if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
1418
+ if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
1414
1419
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
1415
1420
  pushUnique(forbiddenActions, postDraftForbidden);
1416
1421
  return buildResult({
@@ -1434,7 +1439,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1434
1439
  refinementArtifact,
1435
1440
  });
1436
1441
  }
1437
- if (ciStatus === "pending" || ciStatus === "none") {
1442
+ if (preApprovalRequireCi && (ciStatus === "pending" || ciStatus === "none")) {
1438
1443
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
1439
1444
  pushUnique(forbiddenActions, postDraftForbidden);
1440
1445
  return buildResult({
@@ -1553,7 +1558,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1553
1558
  }
1554
1559
 
1555
1560
  if (effectiveLifecycleState === STATE.LOW_SIGNAL_CONVERGED) {
1556
- if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
1561
+ if (preApprovalRequireCi && (ciStatus === "failure" || ciStatus === "crediblyGreen")) {
1557
1562
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
1558
1563
  pushUnique(forbiddenActions, postDraftForbidden);
1559
1564
  return buildResult({
@@ -1577,7 +1582,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1577
1582
  refinementArtifact,
1578
1583
  });
1579
1584
  }
1580
- if (ciStatus === "pending" || ciStatus === "none") {
1585
+ if (preApprovalRequireCi && (ciStatus === "pending" || ciStatus === "none")) {
1581
1586
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
1582
1587
  pushUnique(forbiddenActions, postDraftForbidden);
1583
1588
  return buildResult({
@@ -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
 
@@ -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
- const PAGE_ERROR_STACK_MAX_CHARS = 4000;
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 base = () => ({ appUrl: appUrl ?? null, logs });
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 -> fail closed with a
16
- * stated reason and a follow-up marker — no hosted link this stage).
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 / Artifacts unavailable -> fail closed with a
109
- * stated reason and the follow-up marker. The self-contained HTML is produced
110
- * regardless; only this link step is harness-aware.
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
- const followup = hosting?.followup ? ` [follow-up ${hosting.followup}]` : "";
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