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

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