@dev-loops/core 1.0.0-rc.6 → 1.0.0-rc.7

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.
Files changed (41) hide show
  1. package/package.json +7 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +36 -4
  5. package/src/cli/primitives.mjs +30 -1
  6. package/src/config/config.mjs +254 -13
  7. package/src/config/extension-defaults.yaml +34 -1
  8. package/src/github/comment-id-guard.mjs +97 -9
  9. package/src/github/copilot-helpers.mjs +114 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +7 -0
  12. package/src/loop/agent-stall.mjs +4 -2
  13. package/src/loop/copilot-loop-iterations.mjs +2 -1
  14. package/src/loop/default-branch-guard.mjs +34 -1
  15. package/src/loop/gate-carry-forward.mjs +19 -6
  16. package/src/loop/gate-fanin.mjs +190 -29
  17. package/src/loop/handoff-envelope.mjs +12 -19
  18. package/src/loop/lifecycle-state.mjs +21 -2
  19. package/src/loop/main-checkout-ff.mjs +34 -0
  20. package/src/loop/markdown-sections.mjs +40 -0
  21. package/src/loop/normalize.mjs +7 -0
  22. package/src/loop/plan-file-promote-contract.mjs +14 -1
  23. package/src/loop/plan-file-refine-contract.mjs +92 -8
  24. package/src/loop/policy-constants.mjs +9 -0
  25. package/src/loop/pr-gate-coordination.mjs +65 -12
  26. package/src/loop/public-dev-loop-routing.mjs +7 -15
  27. package/src/loop/queue-board-sync.mjs +1 -26
  28. package/src/loop/queue-driver.mjs +14 -1
  29. package/src/loop/refinement-grill-state.mjs +3 -5
  30. package/src/loop/review-dispatch-plan.mjs +448 -9
  31. package/src/loop/reviewer-loop-state.mjs +8 -13
  32. package/src/loop/run-post-merge-actions.mjs +148 -0
  33. package/src/loop/size-budget-merge-gate.mjs +121 -0
  34. package/src/loop/tracker-pr-state.mjs +5 -15
  35. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  36. package/src/loop/ui-review-drive.mjs +3 -1
  37. package/src/loop/ui-review-report.mjs +2 -5
  38. package/src/loop/ui-review-teardown.mjs +3 -1
  39. package/src/projects/list-queue-items.mjs +1 -27
  40. package/src/projects/move-queue-item.mjs +1 -27
  41. package/src/security/secret-scan.mjs +330 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Post-merge actions runner (config family: `postMerge.actions`, #1457).
3
+ *
4
+ * Runs a repo's declared `postMerge.actions` sequentially, in declared order,
5
+ * after the dev-loop's merge succeeds — sync a checkout, restart a local
6
+ * service, run a smoke check. Mirrors the `uiReview.run` recipe's shape and
7
+ * execution precedent (`packages/core/src/loop/ui-review-provision.mjs`).
8
+ *
9
+ * SECURITY: `action.run` / `action.verify` are executed VERBATIM as the
10
+ * operator declared them in the repo's own committed `.devloops` (same trust
11
+ * level as `uiReview.run.command`) — this module never builds a command string
12
+ * by concatenating runtime data (changed-file paths, verify output) into it.
13
+ * `onlyIfChanged` scoping matches changed-file paths as DATA (plain substring
14
+ * compare), never by shelling them out. Every `run`/`verify` invocation is
15
+ * bounded by its own timeout; `verify` polling is bounded by `verifyTimeoutMs`.
16
+ *
17
+ * Pure orchestration: the shell-out (`exec`) and clock (`now`/`delay`) are
18
+ * injected so this module is fully deterministic under test. The CLI wires the
19
+ * real seams (scripts/loop/run-post-merge-actions.mjs).
20
+ */
21
+
22
+ /**
23
+ * True when at least one changed path contains at least one `onlyIfChanged`
24
+ * pattern (plain substring match — never a shell-out, never a regex).
25
+ */
26
+ function matchesOnlyIfChanged(patterns, changedPaths) {
27
+ return patterns.some((pattern) => changedPaths.some((changedPath) => changedPath.includes(pattern)));
28
+ }
29
+
30
+ /**
31
+ * Decide whether `action` runs. Returns a skip-reason string, or `null` to run
32
+ * it. `changedPaths === null` means the changed-file list could not be
33
+ * resolved (no PR number, `gh pr diff` failure): AC5 requires an
34
+ * `onlyIfChanged` action to still run in that case (never silently skipped),
35
+ * so the bypass is a run, not a skip.
36
+ */
37
+ function decideSkip(action, changedPaths) {
38
+ if (!Array.isArray(action.onlyIfChanged) || action.onlyIfChanged.length === 0) return null;
39
+ if (changedPaths === null) return null; // AC5: scoping unresolved — run unscoped
40
+ if (matchesOnlyIfChanged(action.onlyIfChanged, changedPaths)) return null;
41
+ return `no changed file matched onlyIfChanged (${action.onlyIfChanged.join(", ")})`;
42
+ }
43
+
44
+ /** Run `command` bounded by `timeoutMs` via the injected `exec` seam. */
45
+ async function execBounded(exec, command, cwd, timeoutMs) {
46
+ let result;
47
+ try {
48
+ result = await exec(command, { cwd, timeoutMs });
49
+ } catch (err) {
50
+ return { ok: false, detail: (err && err.message) || String(err) };
51
+ }
52
+ if (result?.killed) return { ok: false, detail: `timed out after ${timeoutMs}ms` };
53
+ if (result?.code !== 0) {
54
+ const stderr = typeof result?.stderr === "string" ? result.stderr.trim() : "";
55
+ return { ok: false, detail: `exit code ${result?.code}${stderr ? `: ${stderr}` : ""}` };
56
+ }
57
+ return { ok: true, detail: null };
58
+ }
59
+
60
+ /**
61
+ * Poll `verifyCommand` (bounded per-attempt by the remaining budget) until it
62
+ * exits 0 or `verifyTimeoutMs` elapses. Mirrors the readiness poll in
63
+ * `ui-review-provision.mjs`'s `provisionAndBoot` (never a fixed sleep).
64
+ */
65
+ async function pollVerify(exec, verifyCommand, cwd, verifyTimeoutMs, verifyIntervalMs, { delay, now }) {
66
+ const deadline = now() + verifyTimeoutMs;
67
+ let lastDetail = "verify never ran";
68
+ while (now() <= deadline) {
69
+ const attemptBudget = Math.max(1, deadline - now());
70
+ let result;
71
+ try {
72
+ result = await exec(verifyCommand, { cwd, timeoutMs: attemptBudget });
73
+ } catch (err) {
74
+ result = { code: 1, killed: false, stdout: "", stderr: (err && err.message) || String(err) };
75
+ }
76
+ if (!result?.killed && result?.code === 0) return { ok: true, detail: null };
77
+ const verifyStderr = typeof result?.stderr === "string" ? result.stderr.trim() : "";
78
+ lastDetail = result?.killed
79
+ ? "verify command timed out"
80
+ : `exit code ${result?.code}${verifyStderr ? `: ${verifyStderr}` : ""}`;
81
+ if (now() + verifyIntervalMs > deadline) break; // would overshoot the deadline
82
+ await delay(verifyIntervalMs);
83
+ }
84
+ return { ok: false, detail: `verify exhausted after ${verifyTimeoutMs}ms (${lastDetail})` };
85
+ }
86
+
87
+ /**
88
+ * Run every declared action sequentially, in order, with cwd set to `cwd`
89
+ * (the resolved main checkout).
90
+ *
91
+ * @param {object} input
92
+ * @param {{name:string,run:string,onlyIfChanged:string[]|null,verify:string|null,
93
+ * timeoutMs:number,verifyTimeoutMs:number,verifyIntervalMs:number}[]} input.actions
94
+ * @param {string[]|null} input.changedPaths - Changed file paths of the merged
95
+ * PR, or `null` when unresolved (AC5 bypass).
96
+ * @param {string} [input.changedPathsUnavailableReason] - Why `changedPaths` is
97
+ * `null` (e.g. "no PR number", "gh pr diff failed: ..."), surfaced in the
98
+ * bypass warning so the AC5 "states why scoping was bypassed" reads clearly.
99
+ * @param {string} input.cwd - Absolute path to the main checkout.
100
+ * @param {object} seams
101
+ * @param {(command:string, opts:{cwd:string,timeoutMs:number})=>Promise<{code:number|null,killed:boolean,stdout:string,stderr:string}>} seams.exec
102
+ * @param {(ms:number)=>Promise<void>} [seams.delay]
103
+ * @param {()=>number} [seams.now]
104
+ * @param {(msg:string)=>void} [seams.log]
105
+ * @returns {Promise<{ok:boolean, results:{name:string,status:"ok"|"skipped"|"failed",detail:string|null}[]}>}
106
+ */
107
+ export async function runPostMergeActions(
108
+ { actions = [], changedPaths = null, changedPathsUnavailableReason = "unknown reason", cwd },
109
+ { exec, delay = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), log = () => {} } = {},
110
+ ) {
111
+ if (changedPaths === null && actions.some((a) => Array.isArray(a.onlyIfChanged) && a.onlyIfChanged.length > 0)) {
112
+ log(`WARNING: changed-file list unavailable (${changedPathsUnavailableReason}) — onlyIfChanged scoping bypassed (affected actions run unscoped)`);
113
+ }
114
+
115
+ const results = [];
116
+ for (const action of actions) {
117
+ const skipReason = decideSkip(action, changedPaths);
118
+ if (skipReason) {
119
+ log(`skip ${action.name}: ${skipReason}`);
120
+ results.push({ name: action.name, status: "skipped", detail: skipReason });
121
+ continue;
122
+ }
123
+
124
+ log(`run ${action.name}: ${action.run}`);
125
+ const runResult = await execBounded(exec, action.run, cwd, action.timeoutMs);
126
+ if (!runResult.ok) {
127
+ const detail = `run failed: ${runResult.detail}`;
128
+ log(`FAILED ${action.name}: ${detail}`);
129
+ results.push({ name: action.name, status: "failed", detail });
130
+ continue;
131
+ }
132
+
133
+ if (action.verify) {
134
+ log(`verify ${action.name}: ${action.verify}`);
135
+ const verifyResult = await pollVerify(exec, action.verify, cwd, action.verifyTimeoutMs, action.verifyIntervalMs, { delay, now });
136
+ if (!verifyResult.ok) {
137
+ log(`FAILED ${action.name}: ${verifyResult.detail}`);
138
+ results.push({ name: action.name, status: "failed", detail: verifyResult.detail });
139
+ continue;
140
+ }
141
+ }
142
+
143
+ log(`ok ${action.name}`);
144
+ results.push({ name: action.name, status: "ok", detail: null });
145
+ }
146
+
147
+ return { ok: results.every((r) => r.status !== "failed"), results };
148
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Size-budget-driven human-approval-required merge gate (phase 3 of the
3
+ * fail-closed PR size budget). Pure, no I/O: consumes an
4
+ * already-resolved size-budget outcome (see check-size-budget.mjs's
5
+ * computeSizeBudget/evaluatePrSizeBudget — this module never recomputes it)
6
+ * plus a human-scoped review decision, and decides whether merge must wait
7
+ * for a human APPROVED review with zero unresolved CHANGES_REQUESTED.
8
+ *
9
+ * This gate is consulted IN ADDITION TO resolveEffectiveMergeAuthorized /
10
+ * humanMergeOnly (@dev-loops/core/config) — never instead of, and never as a
11
+ * relaxation. A `pass` outcome that never touches the T1 tier carries no
12
+ * size-imposed requirement at all; normal merge authorization applies
13
+ * unchanged.
14
+ */
15
+
16
+ import { SUBMITTED_REVIEW_STATES, isCopilotLogin } from "../github/copilot-helpers.mjs";
17
+
18
+ const VALID_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
19
+
20
+ /**
21
+ * Resolve whether an escalated/T1 PR's merge must wait for a human APPROVED
22
+ * review with zero unresolved CHANGES_REQUESTED.
23
+ *
24
+ * FAILS CLOSED: an unreadable `sizeOutcome`, a non-boolean `touchesT1`, a
25
+ * `reviewDecision` that is not exactly `"APPROVED"`, or a non-zero/unreadable
26
+ * `unresolvedChangesRequestedCount` all require human approval (return
27
+ * `true`). A `pass` outcome that never touches the T1 tier returns `false`
28
+ * (no size-imposed requirement).
29
+ *
30
+ * `sizeOutcome === "block"` is treated at least as strictly as `"escalate"`:
31
+ * the issue's own wording names only "escalated or T1", but a block outcome
32
+ * reaching this gate (e.g. new commits landed T1-heavy code after an earlier
33
+ * `pass`/`escalate` draft-gate check) must never require LESS than escalate
34
+ * does.
35
+ *
36
+ * `reviewDecision` MUST already be scoped to human reviewers only — see
37
+ * {@link resolveHumanReviewDecision}, which derives it from raw PR reviews
38
+ * and treats a Copilot review as never satisfying it.
39
+ *
40
+ * `touchesT1` is unprefixed here, but the persisted verdict field a caller
41
+ * would source it from is size-namespaced (`sizeTouchesT1` in
42
+ * copilot-helpers.mjs's detect-checkpoint-evidence output) — a caller wiring
43
+ * that evidence in MUST remap the field name, not spread it as-is.
44
+ *
45
+ * @param {{
46
+ * sizeOutcome?: "pass"|"escalate"|"block"|null,
47
+ * touchesT1?: boolean,
48
+ * reviewDecision?: "APPROVED"|"CHANGES_REQUESTED"|null,
49
+ * unresolvedChangesRequestedCount?: number,
50
+ * }} [input]
51
+ * @returns {boolean}
52
+ */
53
+ export function resolveSizeBudgetHumanApprovalRequired({
54
+ sizeOutcome,
55
+ touchesT1,
56
+ reviewDecision,
57
+ unresolvedChangesRequestedCount,
58
+ } = {}) {
59
+ if (!VALID_SIZE_OUTCOMES.has(sizeOutcome)) return true; // size evidence absent/unreadable
60
+ if (typeof touchesT1 !== "boolean") return true; // T1-touch signal missing/unreadable
61
+
62
+ const requiresEscalatedReview = sizeOutcome === "escalate" || sizeOutcome === "block" || touchesT1 === true;
63
+ if (!requiresEscalatedReview) return false; // pass, T1 untouched — no size-imposed requirement
64
+
65
+ if (reviewDecision !== "APPROVED") return true; // absent / CHANGES_REQUESTED / Copilot-only / unknown
66
+ if (typeof unresolvedChangesRequestedCount !== "number" || unresolvedChangesRequestedCount !== 0) return true;
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Reduce raw PR reviews to each human login's LATEST submitted state,
72
+ * excluding the Copilot bot login entirely (a Copilot review can never
73
+ * satisfy or block this gate) and any state outside
74
+ * {@link SUBMITTED_REVIEW_STATES} (e.g. a review payload that already
75
+ * dropped to a bare object). Reviews are assumed ordered oldest-first (the
76
+ * order GitHub's REST/GraphQL review lists are returned in), so the last
77
+ * occurrence of a login wins.
78
+ *
79
+ * @param {Array<{ login?: string, state?: string }>} reviews
80
+ * @returns {Map<string, string>}
81
+ */
82
+ function latestHumanReviewStatesByLogin(reviews) {
83
+ const latestByLogin = new Map();
84
+ for (const review of Array.isArray(reviews) ? reviews : []) {
85
+ const login = typeof review?.login === "string" ? review.login : null;
86
+ const state = typeof review?.state === "string" ? review.state : null;
87
+ if (!login || !state || isCopilotLogin(login) || !SUBMITTED_REVIEW_STATES.has(state)) continue;
88
+ latestByLogin.set(login, state);
89
+ }
90
+ return latestByLogin;
91
+ }
92
+
93
+ /**
94
+ * Derive a human-scoped review decision from raw PR reviews, so a Copilot
95
+ * review can never satisfy {@link resolveSizeBudgetHumanApprovalRequired}'s
96
+ * `reviewDecision === "APPROVED"` check. A CHANGES_REQUESTED from any human
97
+ * login wins over an APPROVED from another (matching GitHub's own
98
+ * reviewDecision semantics: any outstanding requested change blocks).
99
+ *
100
+ * @param {Array<{ login?: string, state?: string }>} reviews
101
+ * @returns {"APPROVED"|"CHANGES_REQUESTED"|null}
102
+ */
103
+ export function resolveHumanReviewDecision(reviews) {
104
+ const states = [...latestHumanReviewStatesByLogin(reviews).values()];
105
+ if (states.includes("CHANGES_REQUESTED")) return "CHANGES_REQUESTED";
106
+ if (states.includes("APPROVED")) return "APPROVED";
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Count human logins whose LATEST submitted review is CHANGES_REQUESTED (a
112
+ * login who later re-reviewed with APPROVED/COMMENTED, superseding their own
113
+ * earlier CHANGES_REQUESTED, does not count). Copilot is excluded.
114
+ *
115
+ * @param {Array<{ login?: string, state?: string }>} reviews
116
+ * @returns {number}
117
+ */
118
+ export function countUnresolvedHumanChangesRequested(reviews) {
119
+ const states = [...latestHumanReviewStatesByLogin(reviews).values()];
120
+ return states.filter((state) => state === "CHANGES_REQUESTED").length;
121
+ }
@@ -29,6 +29,9 @@
29
29
  * business fields
30
30
  */
31
31
 
32
+ import { trimmedOrNull } from "./normalize.mjs";
33
+ import { normalizeGateReviewVerdict } from "./policy-constants.mjs";
34
+
32
35
  /** Stable state name constants for the tracker-first story-to-PR lifecycle. */
33
36
  export const TRACKER_PR_STATE = Object.freeze({
34
37
  /**
@@ -120,19 +123,6 @@ export const REVERSE_SYNC_ACTION = Object.freeze({
120
123
  [TRACKER_PR_STATE.BLOCKED_NEEDS_USER_DECISION]: "none",
121
124
  });
122
125
 
123
- const GATE_REVIEW_VERDICT_SET = new Set(["clean", "findings_present", "blocked"]);
124
-
125
- function normalizeSha(value) {
126
- return typeof value === "string" && value.trim().length > 0
127
- ? value.trim()
128
- : null;
129
- }
130
-
131
- function normalizeGateReviewVerdict(value) {
132
- const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
133
- return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
134
- }
135
-
136
126
  function hasCleanVisibleCurrentHeadDraftGate(snapshot) {
137
127
  return snapshot.prHeadSha !== null
138
128
  && snapshot.draftGateCommentVisible
@@ -236,9 +226,9 @@ export function normalizeTrackerPrSnapshot(raw) {
236
226
  prDraft: normalizeBooleanLike(raw.prDraft),
237
227
  prMerged: normalizeBooleanLike(raw.prMerged),
238
228
  prClosed: normalizeBooleanLike(raw.prClosed),
239
- prHeadSha: prExists ? normalizeSha(raw.prHeadSha) : null,
229
+ prHeadSha: prExists ? trimmedOrNull(raw.prHeadSha) : null,
240
230
  draftGateCommentVisible: normalizeBooleanLike(raw.draftGateCommentVisible),
241
- draftGateCommentHeadSha: prExists ? normalizeSha(raw.draftGateCommentHeadSha) : null,
231
+ draftGateCommentHeadSha: prExists ? trimmedOrNull(raw.draftGateCommentHeadSha) : null,
242
232
  draftGateCommentVerdict: normalizeGateReviewVerdict(raw.draftGateCommentVerdict),
243
233
  };
244
234
  }
@@ -0,0 +1,171 @@
1
+ // UI designer/vision recorded-evidence auto-scoping — UI half of ADR 0041
2
+ // (RFC issue #1438, decision #0041), tracked by issue #1443.
3
+ //
4
+ // Deterministic, path-triggered, fail-closed criterion modeled on the UI e2e
5
+ // scoping check (./ui-e2e-scoping.mjs, issue #976): a PR that adds or modifies
6
+ // a *rendered* HTML artifact (docs/articles/*.html or docs/presentations/*.html)
7
+ // MUST carry recorded designer/vision review evidence for every touched
8
+ // rendered artifact, and each recorded outcome MUST be the loop's satisfied
9
+ // state (`ui_review_satisfied`). The check is grounded in the designer/vision
10
+ // review loop's EXISTING outcome + artifact bundle contract — no new evidence
11
+ // schema (ADR 0041: "reuse the designer/vision loop's existing outcome +
12
+ // artifact contract rather than a new shape"). Accessibility facts come from
13
+ // the captured axe.json, never judged from pixels, and no human is required by
14
+ // default — the same autonomy the gate-evidence path already has.
15
+ //
16
+ // Inclusion is triggered by the changed-file set, never by annotating the PR.
17
+ // Light-mode and spike-mode relaxed-gate carve-outs are honored: when the PR
18
+ // is light-dispatched / under the light threshold or a spike run, the required
19
+ // designer/vision check is exempt (the requirement is relaxed exactly like the
20
+ // other gates), letting small and exploratory work stay cheap (ADR 0041).
21
+
22
+ import { classifyRenderedArtifactPath } from "./ui-e2e-scoping.mjs";
23
+
24
+ // The recorded outcome the required check treats as the satisfied state. The
25
+ // designer/vision loop emits exactly one of these (ui-designer-review-loop.md);
26
+ // any other recorded outcome (continue_ui_fix_loop, blocked_needs_human_decision)
27
+ // or an absent outcome blocks.
28
+ export const DESIGNER_REVIEW_SATISFIED_OUTCOME = "ui_review_satisfied";
29
+
30
+ // The loop's existing outcome enum, re-used verbatim (no new schema).
31
+ export const DESIGNER_REVIEW_OUTCOMES = Object.freeze([
32
+ DESIGNER_REVIEW_SATISFIED_OUTCOME,
33
+ "continue_ui_fix_loop",
34
+ "blocked_needs_human_decision",
35
+ ]);
36
+
37
+ /**
38
+ * Normalize a designer/vision recorded-evidence value into an artifact-id →
39
+ * outcome map, tolerating both the array and keyed-object shapes.
40
+ *
41
+ * The evidence reuses the loop's existing outcome + artifact-bundle record: an
42
+ * entry identifies the rendered artifact by its full repo-relative path and
43
+ * carries the loop's `outcome`. A record may also carry its `artifactBundle`
44
+ * (the loop's existing bundle) - carried through untouched, never validated to
45
+ * a new shape here.
46
+ *
47
+ * @param {Array<{artifact:string,outcome:string,artifactBundle?:object}>|Record<string,{outcome:string,artifactBundle?:object}>|null} evidence
48
+ * @returns {Map<string,{outcome:string,artifactBundle?:object}>}
49
+ */
50
+ export function normalizeDesignerReviewEvidence(evidence) {
51
+ const byArtifact = new Map();
52
+ if (evidence == null) return byArtifact;
53
+ if (Array.isArray(evidence)) {
54
+ for (const entry of evidence) {
55
+ if (entry && typeof entry === "object" && typeof entry.artifact === "string" && entry.artifact.length > 0) {
56
+ byArtifact.set(entry.artifact, entry);
57
+ }
58
+ }
59
+ return byArtifact;
60
+ }
61
+ if (typeof evidence === "object") {
62
+ for (const [artifact, record] of Object.entries(evidence)) {
63
+ if (record && typeof record === "object") {
64
+ byArtifact.set(artifact, record);
65
+ }
66
+ }
67
+ }
68
+ return byArtifact;
69
+ }
70
+
71
+ /**
72
+ * Deterministic designer/vision recorded-evidence scoping check.
73
+ *
74
+ * @param {string[]} changedPaths - PR changed-file paths.
75
+ * @param {{
76
+ * designerReviewEvidence?: Array|Record|null,
77
+ * designerReviewExempt?: boolean,
78
+ * }} [opts]
79
+ * designerReviewEvidence: the loop's recorded outcome + artifact bundle for
80
+ * the artifacts in scope (null/undefined = "not recorded" → fails closed).
81
+ * designerReviewExempt: true when a light-mode/spike relaxed-gate carve-out
82
+ * applies (relaxes the requirement entirely).
83
+ * @returns {{
84
+ * required: boolean,
85
+ * artifacts: Array<{path,kind,id,registered}>,
86
+ * missing: string[],
87
+ * unsatisfied: string[],
88
+ * satisfied: boolean,
89
+ * reason: string|null,
90
+ * }}
91
+ */
92
+ export function evaluateUiDesignerReviewScoping(changedPaths = [], {
93
+ designerReviewEvidence = null,
94
+ designerReviewExempt = false,
95
+ } = {}) {
96
+ const artifacts = [];
97
+ const seen = new Set();
98
+ for (const p of Array.isArray(changedPaths) ? changedPaths : []) {
99
+ const descriptor = classifyRenderedArtifactPath(p);
100
+ // The designer/vision recorded-evidence requirement applies ONLY to
101
+ // rendered HTML deliverables (docs/articles|presentations/*.html). The
102
+ // shared classifier also recognizes the headless viewer source path
103
+ // (kind "viewer"), which is a runtime script, not a rendered artifact -
104
+ // exclude it so a viewer-source change does not demand designer evidence.
105
+ if (descriptor && descriptor.kind !== "viewer" && !seen.has(descriptor.path)) {
106
+ seen.add(descriptor.path);
107
+ artifacts.push(descriptor);
108
+ }
109
+ }
110
+
111
+ const required = artifacts.length > 0;
112
+ if (!required) {
113
+ return { required: false, artifacts, missing: [], unsatisfied: [], satisfied: true, reason: null };
114
+ }
115
+
116
+ // Honor the light/spike relaxed-gate carve-outs (ADR 0041): the requirement
117
+ // is exempt, so a rendered artifact change in those runs does not block.
118
+ if (designerReviewExempt === true) {
119
+ return {
120
+ required: true,
121
+ artifacts,
122
+ missing: [],
123
+ unsatisfied: [],
124
+ satisfied: true,
125
+ reason: "exempted_by_relaxed_gate_profile",
126
+ };
127
+ }
128
+
129
+ const byArtifact = normalizeDesignerReviewEvidence(designerReviewEvidence);
130
+
131
+ // Fail closed: any touched rendered artifact with NO recorded designer/vision
132
+ // evidence blocks and names itself so the fix is unambiguous (record the loop's
133
+ // outcome+bundle for it).
134
+ const missing = artifacts.filter((a) => !byArtifact.has(a.id)).map((a) => a.id);
135
+ if (missing.length > 0) {
136
+ return {
137
+ required: true,
138
+ artifacts,
139
+ missing,
140
+ unsatisfied: [],
141
+ satisfied: false,
142
+ reason:
143
+ `Designer/vision review evidence is required: this PR changes rendered artifact(s) ` +
144
+ `${missing.join(", ")} that have no recorded designer/vision review outcome + artifact bundle. ` +
145
+ `Run the designer/vision review loop for the artifact(s) and record its outcome so the required ` +
146
+ `recorded-evidence check can pass before this gate can proceed.`,
147
+ };
148
+ }
149
+
150
+ // Fail closed: recorded evidence exists but the recorded outcome is not the
151
+ // loop's satisfied state (continue_ui_fix_loop, blocked_needs_human_decision).
152
+ const unsatisfied = artifacts
153
+ .filter((a) => (byArtifact.get(a.id)?.outcome ?? null) !== DESIGNER_REVIEW_SATISFIED_OUTCOME)
154
+ .map((a) => a.id);
155
+ if (unsatisfied.length > 0) {
156
+ return {
157
+ required: true,
158
+ artifacts,
159
+ missing: [],
160
+ unsatisfied,
161
+ satisfied: false,
162
+ reason:
163
+ `Designer/vision review is not satisfied: this PR changes rendered artifact(s) ` +
164
+ `${unsatisfied.join(", ")}, but the recorded designer/vision review outcome is not ` +
165
+ `\`${DESIGNER_REVIEW_SATISFIED_OUTCOME}\`. Complete the designer/vision review loop until the ` +
166
+ `recorded outcome is the satisfied state before this gate can proceed.`,
167
+ };
168
+ }
169
+
170
+ return { required: true, artifacts, missing: [], unsatisfied: [], satisfied: true, reason: null };
171
+ }
@@ -24,6 +24,8 @@
24
24
  * posting, visual-regression/pixel-diffing, cross-browser matrix.
25
25
  */
26
26
 
27
+ import { trimmedOrNull } from "./normalize.mjs";
28
+
27
29
  const MUST_FIX = "must-fix";
28
30
 
29
31
  /** Request header the drive advertises its drive-session id on, so a cooperating
@@ -282,7 +284,7 @@ export async function driveUiReview(
282
284
  // No-retry is a fixed policy — log it every run so the bound is never implicit.
283
285
  record(`caps: maxScreenshots=${resolvedCaps.maxScreenshots}, maxFlows=${resolvedCaps.maxFlows}, maxStepsPerFlow=${resolvedCaps.maxStepsPerFlow}, retries=${resolvedCaps.retries} (no-retry)`);
284
286
 
285
- const session = typeof driveSession === "string" && driveSession.trim().length > 0 ? driveSession.trim() : null;
287
+ const session = trimmedOrNull(driveSession);
286
288
  const base = () => ({ appUrl: appUrl ?? null, logs, driveSession: session });
287
289
 
288
290
  // 1. Authenticate as the target role. Fail closed: no session -> STOP, drive
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { isClaudeHarness } from "./run-context.mjs";
24
24
  import { sanitizeCopilotSummonTokens } from "../github/copilot-helpers.mjs";
25
+ import { trimmedOrNull } from "./normalize.mjs";
25
26
 
26
27
  /** Findings past this cap are dropped from the artifact and the drop is logged. */
27
28
  export const ARTIFACT_MAX_FINDINGS = 100;
@@ -34,10 +35,6 @@ export const ARTIFACT_MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
34
35
  * a server-log exception the request raised. */
35
36
  const SERVER_ERROR_KINDS = new Set(["error-response", "server-log-exception"]);
36
37
 
37
- function normalizeSha(value) {
38
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
39
- }
40
-
41
38
  /** A confirmed user-facing server error: a must-fix error-response / server-log
42
39
  * exception. This is the single-source predicate the severity policy keys off. */
43
40
  function isBlockingFinding(finding) {
@@ -195,7 +192,7 @@ export function buildReviewInput({ findings = [], headSha = null, hosting = null
195
192
  const verdict = list.length === 0 ? "APPROVE" : (blocking ? "REQUEST_CHANGES" : "COMMENT");
196
193
 
197
194
  return {
198
- headSha: normalizeSha(headSha),
195
+ headSha: trimmedOrNull(headSha),
199
196
  verdict,
200
197
  inlineComments,
201
198
  summaryFindings,
@@ -36,6 +36,8 @@
36
36
  * rather than dropping anything.
37
37
  */
38
38
 
39
+ import { trimmedOrNull } from "./normalize.mjs";
40
+
39
41
  const ROW_STATUS = Object.freeze({
40
42
  DROPPED: "dropped",
41
43
  DROP_FAILED: "drop-failed",
@@ -247,7 +249,7 @@ export async function teardown(
247
249
  // accretes one secret entry per run; deleting it keeps the hosting target
248
250
  // from piling up. Only ever acts on an explicit gist id from the report
249
251
  // 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;
252
+ const gistId = trimmedOrNull(gist?.id);
251
253
  let gistLedger;
252
254
  if (!gistId) {
253
255
  gistLedger = { id: null, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.NONE, detail: "no hosting gist to prune" };
@@ -1,6 +1,6 @@
1
1
  import { runChild as _runChild } from "../cli/primitives.mjs";
2
- import { parseJsonText } from "../github/review-threads.mjs";
3
2
  import { resolveProjectSelector, findProject } from "./resolve-project.mjs";
3
+ import { ghGraphql } from "../github/gh.mjs";
4
4
 
5
5
  // ── Validation ───────────────────────────────────────────────────────────
6
6
 
@@ -30,32 +30,6 @@ function validateRepo(repo) {
30
30
  return repo;
31
31
  }
32
32
 
33
- // ── API helpers ──────────────────────────────────────────────────────────
34
-
35
- async function ghGraphql(query, vars, env, runChild = _runChild) {
36
- const fieldArgs = [];
37
- for (const [key, value] of Object.entries(vars)) {
38
- fieldArgs.push("--field", `${key}=${value}`);
39
- }
40
- const result = await runChild(
41
- "gh",
42
- ["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
43
- env,
44
- );
45
- if (result.code !== 0) {
46
- const detail = result.stderr.trim() || `exit code ${result.code}`;
47
- throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
48
- }
49
- const payload = parseJsonText(result.stdout);
50
- if (payload.errors && payload.errors.length > 0) {
51
- throw Object.assign(
52
- new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
53
- { code: "GRAPHQL_ERROR" },
54
- );
55
- }
56
- return payload;
57
- }
58
-
59
33
  // ── GraphQL fragments ────────────────────────────────────────────────────
60
34
 
61
35
  const GET_USER_ID = [
@@ -1,8 +1,8 @@
1
1
  import { runChild as _runChild } from "../cli/primitives.mjs";
2
- import { parseJsonText } from "../github/review-threads.mjs";
3
2
  import { runPickupRefinementGate } from "../loop/issue-refinement-artifact.mjs";
4
3
  import { loadStateColumnMap, LOGICAL_COLUMN } from "../loop/queue-board-sync.mjs";
5
4
  import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
5
+ import { ghGraphql } from "../github/gh.mjs";
6
6
 
7
7
  // ── Validation ───────────────────────────────────────────────────────────
8
8
 
@@ -29,32 +29,6 @@ function validateRepo(repo) {
29
29
  return repo;
30
30
  }
31
31
 
32
- // ── API helpers ──────────────────────────────────────────────────────────
33
-
34
- async function ghGraphql(query, vars, env, runChild = _runChild) {
35
- const fieldArgs = [];
36
- for (const [key, value] of Object.entries(vars)) {
37
- fieldArgs.push("--field", `${key}=${value}`);
38
- }
39
- const result = await runChild(
40
- "gh",
41
- ["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
42
- env,
43
- );
44
- if (result.code !== 0) {
45
- const detail = result.stderr.trim() || `exit code ${result.code}`;
46
- throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
47
- }
48
- const payload = parseJsonText(result.stdout);
49
- if (payload.errors && payload.errors.length > 0) {
50
- throw Object.assign(
51
- new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
52
- { code: "GRAPHQL_ERROR" },
53
- );
54
- }
55
- return payload;
56
- }
57
-
58
32
  // ── GraphQL fragments ────────────────────────────────────────────────────
59
33
 
60
34
  const GET_USER_ID = [