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,316 @@
1
+ /**
2
+ * ux-grill/run-record.mjs — the ONLY reader/writer of the ux-grill run-record
3
+ * ledger (`.orchestrator/metrics/ux-grill.jsonl`) and of a run's `findings.jsonl`.
4
+ *
5
+ * Leaf-ish module: `node:fs` + `node:path` plus the two ux-grill leaf modules
6
+ * (`./paths.mjs`, `./schema.mjs`). No shell-outs, no network, no other project
7
+ * imports — so `compare.mjs` can depend on it without dragging a closure in.
8
+ *
9
+ * WHY a module and not `fs.appendFileSync` at each call site: the record is
10
+ * written ONCE by `collect()` with `compare` at its schema defaults
11
+ * (`{new:0, persisting:0, fixed:0}`) and PATCHED later by `compare.mjs`, after
12
+ * the previous run has been located. Two writers on one append-only file need a
13
+ * single place that knows (a) how a record is validated before it is appended,
14
+ * (b) that the patch is a line rewrite which must preserve line ORDER and every
15
+ * line it does not own, including unparseable ones.
16
+ *
17
+ * TOLERANT READS, COUNTED. Both readers skip a line they cannot parse and
18
+ * return how many they skipped, instead of throwing. A ledger is append-only
19
+ * telemetry that several processes write; one truncated line must not make the
20
+ * whole comparison impossible — but a silently-dropped line is exactly the
21
+ * "measurement over an unnamed population" trap, so the count is part of the
22
+ * return value and callers are expected to surface it.
23
+ *
24
+ * Exports:
25
+ * appendRunRecord(repoRoot, record)
26
+ * readRunRecords(repoRoot, {manifestHash, build, limit})
27
+ * findPreviousRun(repoRoot, {manifestHash, build, beforeRunId})
28
+ * updateRunRecordCompare(repoRoot, runId, compare)
29
+ * readFindings(repoRoot, runId)
30
+ */
31
+
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+
35
+ import { findingsPath, runRecordPath } from './paths.mjs';
36
+ import { makeRunRecord } from './schema.mjs';
37
+
38
+ /** Default number of (filtered) records `readRunRecords` returns. */
39
+ const DEFAULT_READ_LIMIT = 50;
40
+
41
+ /**
42
+ * Read a UTF-8 file, returning `null` when it does not exist.
43
+ * Any other error (EACCES, EISDIR) propagates — that is a real defect, not an
44
+ * empty history, and must not read as "no previous run".
45
+ *
46
+ * @param {string} file
47
+ * @returns {string|null}
48
+ */
49
+ function readTextOrNull(file) {
50
+ try {
51
+ return fs.readFileSync(file, 'utf8');
52
+ } catch (error) {
53
+ if (error?.code === 'ENOENT') return null;
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Parse a JSONL text blob into objects, counting the lines that did not parse.
60
+ *
61
+ * @param {string|null} text
62
+ * @param {(value: unknown) => boolean} isRecord - shape predicate; a parsed
63
+ * line failing it counts as skipped, same as a syntax error.
64
+ * @returns {{values: object[], skippedLines: number}}
65
+ */
66
+ function parseJsonl(text, isRecord) {
67
+ const values = [];
68
+ let skippedLines = 0;
69
+ if (text === null) return { values, skippedLines };
70
+ for (const raw of text.split('\n')) {
71
+ const line = raw.trim();
72
+ if (line.length === 0) continue;
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(line);
76
+ } catch {
77
+ skippedLines += 1;
78
+ continue;
79
+ }
80
+ if (!isRecord(parsed)) {
81
+ skippedLines += 1;
82
+ continue;
83
+ }
84
+ values.push(parsed);
85
+ }
86
+ return { values, skippedLines };
87
+ }
88
+
89
+ /** A run-record is usable only when it carries the compare key: `run_id`. */
90
+ function isRunRecord(value) {
91
+ return typeof value === 'object' && value !== null && typeof value.run_id === 'string';
92
+ }
93
+
94
+ /** A finding is usable only when it carries its identity: `fingerprint`. */
95
+ function isFinding(value) {
96
+ return typeof value === 'object' && value !== null && typeof value.fingerprint === 'string';
97
+ }
98
+
99
+ /**
100
+ * Append ONE run-record to the ledger, creating the metrics directory if needed.
101
+ *
102
+ * The record is re-validated through {@link makeRunRecord} (required fields,
103
+ * `build` enum, `skipped[].reason` enum) but the ORIGINAL object is what gets
104
+ * written — re-validation must never silently re-default a field the caller
105
+ * set. Invalid input throws rather than appending a half-record: a ledger line
106
+ * nothing can parse is worse than a missing one, because the next reader counts
107
+ * it as `skippedLines` forever.
108
+ *
109
+ * This is the function `collect.mjs` calls (`collect.mjs:60` imports it, `:970`
110
+ * invokes it) — verified 2026-09-12, which is what makes the module header's
111
+ * "ONLY writer" claim true rather than aspirational.
112
+ *
113
+ * @param {string} repoRoot - absolute repo root of the TARGET repo
114
+ * @param {object} record - a record built by `makeRunRecord`
115
+ * @returns {object} the same record, for chaining
116
+ * @throws {TypeError} when the record fails schema validation
117
+ */
118
+ export function appendRunRecord(repoRoot, record) {
119
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) {
120
+ throw new TypeError('appendRunRecord: record must be a plain object');
121
+ }
122
+ // Throws on any invalid required field; the return value is deliberately unused.
123
+ makeRunRecord({
124
+ runId: record.run_id,
125
+ manifestHash: record.manifest_hash,
126
+ rubricHash: record.rubric_hash,
127
+ build: record.build,
128
+ skipped: Array.isArray(record.skipped) ? record.skipped : [],
129
+ countsBySeverity: record.counts_by_severity,
130
+ provisionalCount: record.provisional_count,
131
+ });
132
+
133
+ const ledger = runRecordPath(repoRoot);
134
+ fs.mkdirSync(path.dirname(ledger), { recursive: true });
135
+ fs.appendFileSync(ledger, `${JSON.stringify(record)}\n`, 'utf8');
136
+ return record;
137
+ }
138
+
139
+ /**
140
+ * Read run-records from the ledger, newest LAST (file/append order preserved).
141
+ *
142
+ * @param {string} repoRoot
143
+ * @param {object} [opts]
144
+ * @param {string} [opts.manifestHash] - keep only records with this `manifest_hash`
145
+ * @param {string} [opts.build] - keep only records with this `build`
146
+ * @param {number} [opts.limit=50] - keep at most this many of the MOST RECENT
147
+ * matches (the tail of the filtered list). `Infinity` reads everything.
148
+ * @returns {{records: object[], skippedLines: number}} `skippedLines` counts
149
+ * lines that were unparseable OR carried no `run_id`.
150
+ */
151
+ export function readRunRecords(repoRoot, { manifestHash, build, limit = DEFAULT_READ_LIMIT } = {}) {
152
+ const { values, skippedLines } = parseJsonl(readTextOrNull(runRecordPath(repoRoot)), isRunRecord);
153
+ let records = values;
154
+ if (manifestHash !== undefined) records = records.filter((r) => r.manifest_hash === manifestHash);
155
+ if (build !== undefined) records = records.filter((r) => r.build === build);
156
+ if (Number.isFinite(limit) && limit >= 0 && records.length > limit) {
157
+ records = records.slice(records.length - limit);
158
+ }
159
+ return { records, skippedLines };
160
+ }
161
+
162
+ /**
163
+ * Find the run to compare the current run against.
164
+ *
165
+ * The baseline must share BOTH keys:
166
+ * - `manifest_hash` — a different manifest measured different routes/viewports,
167
+ * so a fingerprint missing from it was never looked for, not fixed.
168
+ * - `build` — a dev build is not a measurement basis for a prod run (a dev
169
+ * bundle's geometry differs, which is exactly why `makeFinding` marks
170
+ * dev-build target-size findings `provisional`). Comparing across builds
171
+ * would report layout noise as `new`/`fixed` product change.
172
+ *
173
+ * `rubric_hash` is deliberately NOT a filter: a rubric change must still be
174
+ * comparable, it only changes how `fixed` may be READ — see `compare.mjs`
175
+ * § rubricChanged.
176
+ *
177
+ * @param {string} repoRoot
178
+ * @param {object} opts
179
+ * @param {string} opts.manifestHash
180
+ * @param {string} opts.build
181
+ * @param {string} [opts.beforeRunId] - the CURRENT run; records from its ledger
182
+ * line onward are ignored, so a run never compares against itself or against
183
+ * a run appended after it.
184
+ * @returns {object|null} the most recent matching record, or `null` (baseline run)
185
+ */
186
+ export function findPreviousRun(repoRoot, { manifestHash, build, beforeRunId } = {}) {
187
+ const { records } = readRunRecords(repoRoot, { limit: Infinity });
188
+ let scope = records;
189
+ if (typeof beforeRunId === 'string' && beforeRunId.length > 0) {
190
+ const index = records.findIndex((r) => r.run_id === beforeRunId);
191
+ if (index >= 0) scope = records.slice(0, index);
192
+ }
193
+ for (let i = scope.length - 1; i >= 0; i -= 1) {
194
+ const record = scope[i];
195
+ if (manifestHash !== undefined && record.manifest_hash !== manifestHash) continue;
196
+ if (build !== undefined && record.build !== build) continue;
197
+ return record;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * Validate a compare-counts object: exactly three non-negative integers.
204
+ * @param {unknown} compare
205
+ * @returns {{new: number, persisting: number, fixed: number}}
206
+ * @throws {TypeError}
207
+ */
208
+ function assertCompareCounts(compare) {
209
+ if (typeof compare !== 'object' || compare === null || Array.isArray(compare)) {
210
+ throw new TypeError('updateRunRecordCompare: compare must be a plain object');
211
+ }
212
+ const out = {};
213
+ for (const key of ['new', 'persisting', 'fixed']) {
214
+ const value = compare[key];
215
+ if (!Number.isInteger(value) || value < 0) {
216
+ throw new TypeError(`updateRunRecordCompare: compare.${key} must be a non-negative integer, got ${String(value)}`);
217
+ }
218
+ out[key] = value;
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * Patch the `compare` counts of ONE already-appended run-record, in place.
225
+ *
226
+ * Rewrites the ledger atomically (tmp file in the same directory + `rename`) so
227
+ * a crash mid-write cannot leave a truncated ledger. Line ORDER is preserved and
228
+ * every line this call does not own is written back BYTE-IDENTICALLY, including
229
+ * lines that do not parse — a repair pass must never be the thing that destroys
230
+ * the evidence it was reading (the raw-sidecar lesson, one layer up).
231
+ *
232
+ * The record carries COUNTS, never the fingerprint lists — that is the schema
233
+ * (`makeRunRecord` § compare). The lists stay in the caller's hands.
234
+ *
235
+ * BV-004 — deliberate simplification, its CEILING and its REVISIT TRIGGER:
236
+ * this is a WHOLE-LEDGER rewrite (read every line, re-serialise the one that
237
+ * matches, `rename` the lot) rather than a seek-and-patch. Ceiling: fine while
238
+ * `.orchestrator/metrics/ux-grill.jsonl` stays under ~10k records — one record
239
+ * per `/ux-grill` run, i.e. years of routine use at a few runs per session.
240
+ * REVISIT TRIGGER: the ledger passing ~10k lines, or `/ux-grill` becoming a
241
+ * per-commit/CI step rather than an operator-invoked one. Either makes the
242
+ * rewrite O(n) per compare over a file nobody reads whole.
243
+ *
244
+ * CONCURRENCY — decided, not overlooked: this function takes NO lock, and a
245
+ * concurrent {@link appendRunRecord} landing between the read above and the
246
+ * `rename` below would be lost. That is accepted because ONE ux-grill run per
247
+ * repo is the only shape the surrounding design admits: Stufe 1 is a single
248
+ * coordinator-direct Bash call (`skills/ux-grill/SKILL.md` § 0.4 — "exactly one
249
+ * Bash invocation", "No dispatched agent runs Stufe 1"), and the compare step
250
+ * runs after it in the same thread. Taking `withFileLock` from
251
+ * `scripts/lib/file-lock.mjs` would additionally force this whole module async
252
+ * for a race the design forbids. REVISIT TRIGGER for the assumption: the first
253
+ * caller that runs `collect()` in parallel (multi-target sweep, CI matrix) —
254
+ * then use `withFileLock` here AND in `appendRunRecord`, never only one of them.
255
+ *
256
+ * @param {string} repoRoot
257
+ * @param {string} runId
258
+ * @param {{new: number, persisting: number, fixed: number}} compare
259
+ * @returns {{updated: number, skippedLines: number}} `updated` is how many
260
+ * ledger lines matched `runId` (0 = the record is not in the ledger; the
261
+ * caller decides whether that is an error).
262
+ * @throws {TypeError} on a malformed `compare`
263
+ */
264
+ export function updateRunRecordCompare(repoRoot, runId, compare) {
265
+ const counts = assertCompareCounts(compare);
266
+ const ledger = runRecordPath(repoRoot);
267
+ const text = readTextOrNull(ledger);
268
+ if (text === null) return { updated: 0, skippedLines: 0 };
269
+
270
+ let updated = 0;
271
+ let skippedLines = 0;
272
+ const out = [];
273
+ for (const raw of text.split('\n')) {
274
+ if (raw.trim().length === 0) continue;
275
+ let parsed;
276
+ try {
277
+ parsed = JSON.parse(raw);
278
+ } catch {
279
+ skippedLines += 1;
280
+ out.push(raw); // preserved verbatim — never dropped by a patch pass
281
+ continue;
282
+ }
283
+ if (isRunRecord(parsed) && parsed.run_id === runId) {
284
+ parsed.compare = { ...counts };
285
+ updated += 1;
286
+ out.push(JSON.stringify(parsed));
287
+ continue;
288
+ }
289
+ if (!isRunRecord(parsed)) skippedLines += 1;
290
+ out.push(raw);
291
+ }
292
+
293
+ const tmp = `${ledger}.${process.pid}.tmp`;
294
+ fs.writeFileSync(tmp, out.length > 0 ? `${out.join('\n')}\n` : '', 'utf8');
295
+ fs.renameSync(tmp, ledger);
296
+ return { updated, skippedLines };
297
+ }
298
+
299
+ /**
300
+ * Read one run's `findings.jsonl`.
301
+ *
302
+ * Returns an ENVELOPE rather than a bare array so the dropped-line count has
303
+ * somewhere to live (see module header): a compare run that silently lost three
304
+ * previous findings would report them as `fixed`.
305
+ *
306
+ * @param {string} repoRoot
307
+ * @param {string} runId
308
+ * @returns {{findings: object[], skippedLines: number}} `findings: []` when the
309
+ * run produced none AND when the file does not exist — `skippedLines` does not
310
+ * distinguish those; the caller that needs to tell them apart checks the run
311
+ * record's `skipped[]`, which is what that field is for.
312
+ */
313
+ export function readFindings(repoRoot, runId) {
314
+ const { values, skippedLines } = parseJsonl(readTextOrNull(findingsPath(repoRoot, runId)), isFinding);
315
+ return { findings: values, skippedLines };
316
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * ux-grill/schema.mjs — Frozen contract for Stufe-1 (mechanical) ux-grill records.
3
+ *
4
+ * Leaf module: imports only `../test-runner/fingerprint.mjs` and the pure
5
+ * `../crypto-digest-utils.mjs` (no I/O, no side effects; `node:crypto` reaches
6
+ * it only through that helper). Every producer of a ux-grill finding or run-record
7
+ * (`collect.mjs`, `measures.mjs`, `compare.mjs`) goes through this module so the
8
+ * fingerprint inputs and the severity table have exactly one definition.
9
+ *
10
+ * Spec: docs/prd/2026-09-12-ux-grill.md § 2 S2/S3 and § 4 Data Model.
11
+ *
12
+ * Exports:
13
+ * SCOPE, CHECK_IDS, SEVERITIES, SEVERITY_BY_CHECK, SKIP_REASONS,
14
+ * RUN_RECORD_SCHEMA_VERSION, LOCATOR_MAX_LENGTH,
15
+ * severityForAxeImpact(), makeFinding(), countBySeverity(), makeRunRecord()
16
+ */
17
+
18
+ import { digestSha256Short } from '../crypto-digest-utils.mjs';
19
+ import { fingerprintFinding } from '../test-runner/fingerprint.mjs';
20
+
21
+ /**
22
+ * Fingerprint scope for every ux-grill finding. Constant by contract: it is a
23
+ * fingerprint input, so changing it invalidates every previously filed issue.
24
+ * @type {string}
25
+ */
26
+ export const SCOPE = 'ux-grill';
27
+
28
+ /**
29
+ * The check identifiers ux-grill Stufe 1 can emit (PRD § 2 S3 table).
30
+ *
31
+ * `axe-violations` is the CATALOGUE entry only — an emitted axe finding carries
32
+ * `checkId = 'axe-<ruleId>'` (rubric-v1 § Fingerprint), so that two axe rules
33
+ * violated on the same selector remain two distinct findings.
34
+ * @type {Readonly<Record<string, string>>}
35
+ */
36
+ export const CHECK_IDS = Object.freeze({
37
+ AXE_VIOLATIONS: 'axe-violations',
38
+ CONSOLE_ERRORS: 'console-errors',
39
+ JOURNEY_STEP_COUNT: 'journey-step-count',
40
+ JOURNEY_FAILED: 'journey-failed',
41
+ TARGET_SIZE_FLOOR: 'target-size-floor',
42
+ TARGET_SIZE_TARGET: 'target-size-target',
43
+ HORIZONTAL_OVERFLOW: 'horizontal-overflow',
44
+ TITLE_MISMATCH: 'title-mismatch',
45
+ });
46
+
47
+ /**
48
+ * Severities ux-grill Stufe 1 emits, most severe first.
49
+ *
50
+ * Note: `skills/test-runner/rubric-v1.md` additionally knows `critical` for
51
+ * issue routing. Stufe 1 of ux-grill NEVER emits `critical` — every severity
52
+ * here is derived from a measured value (PRD § 2 S3), and no measured ux
53
+ * violation is defined as release-blocking. Stufe 2 emits no severity at all.
54
+ * @type {readonly string[]}
55
+ */
56
+ export const SEVERITIES = Object.freeze(['high', 'medium', 'low']);
57
+
58
+ /**
59
+ * Static severity per check id (PRD § 2 S3). Axe is absent by design — its
60
+ * severity comes from the impact field, see {@link severityForAxeImpact}.
61
+ * @type {Readonly<Record<string, string>>}
62
+ */
63
+ export const SEVERITY_BY_CHECK = Object.freeze({
64
+ [CHECK_IDS.TARGET_SIZE_FLOOR]: 'high',
65
+ [CHECK_IDS.TARGET_SIZE_TARGET]: 'medium',
66
+ [CHECK_IDS.HORIZONTAL_OVERFLOW]: 'medium',
67
+ [CHECK_IDS.JOURNEY_FAILED]: 'high',
68
+ [CHECK_IDS.JOURNEY_STEP_COUNT]: 'medium',
69
+ [CHECK_IDS.CONSOLE_ERRORS]: 'medium',
70
+ [CHECK_IDS.TITLE_MISMATCH]: 'low',
71
+ });
72
+
73
+ /**
74
+ * Reasons a Stufe-1 step may be recorded as skipped in the run-record.
75
+ *
76
+ * - `device-mismatch` — `set device` / `set viewport` did not produce the
77
+ * requested `window.innerWidth`; the viewport is skipped rather than filed
78
+ * under a wrong label (PRD § 3 AC "Stufe 1").
79
+ * - `pencil-unavailable` — Pen.app or its MCP surface is not reachable; the
80
+ * optional coverage step is skipped and the run still ends without error.
81
+ * - `route-unreachable` — the route failed to load. It is a SKIP, not a
82
+ * dropped route: a route that never loaded produced no measurement, so
83
+ * emitting zero findings for it would read as "clean" in the next compare
84
+ * run and silently turn an outage into a `fixed` classification. Also used
85
+ * for a journey whose `start` page never opened.
86
+ * - `measure-failed` — ONE browser measurement on an otherwise reachable page
87
+ * did not produce a usable payload: a non-zero exit, an unparseable stdout,
88
+ * or an `agent-browser` envelope carrying `success: false` (measured
89
+ * 2026-09-12, v0.37.1: a page-side `eval` throw answers `{"success":false,
90
+ * "data":null,...}` at EXIT CODE 0). Without this reason such a call is
91
+ * indistinguishable from "measured, found nothing" — zero findings for a
92
+ * check that never ran, which the next compare run reads as `fixed`.
93
+ * @type {Readonly<Record<string, string>>}
94
+ */
95
+ export const SKIP_REASONS = Object.freeze({
96
+ DEVICE_MISMATCH: 'device-mismatch',
97
+ PENCIL_UNAVAILABLE: 'pencil-unavailable',
98
+ ROUTE_UNREACHABLE: 'route-unreachable',
99
+ MEASURE_FAILED: 'measure-failed',
100
+ });
101
+
102
+ /**
103
+ * Schema version of the `.orchestrator/metrics/ux-grill.jsonl` run-record.
104
+ * @type {number}
105
+ */
106
+ export const RUN_RECORD_SCHEMA_VERSION = 1;
107
+
108
+ /**
109
+ * Locators longer than this are truncated in the finding RECORD
110
+ * (`skills/test-runner/rubric-v1.md` § Truncation rule for long locators).
111
+ *
112
+ * Deliberate deviation from rubric-v1 for the FINGERPRINT input (#1334): an
113
+ * over-long locator is fingerprinted as `truncated + ':' + sha256(full).slice(0, 8)`,
114
+ * not as the bare truncation. The audited page controls its selectors, so with
115
+ * bare truncation a decoy under a >256-char class chain sharing the real
116
+ * element's prefix would take its fingerprint and shadow it in `compare.mjs`.
117
+ * Locators of ≤ 256 chars keep their fingerprint unchanged; the suffixed input
118
+ * is 265 chars long, so it can never equal an untruncated one.
119
+ * @type {number}
120
+ */
121
+ export const LOCATOR_MAX_LENGTH = 256;
122
+
123
+ /** Characters forbidden in a locator: they are the fingerprint separator / shell arg boundary. */
124
+ const LOCATOR_FORBIDDEN = /[\n\r\0]/;
125
+
126
+ const VALID_BUILDS = Object.freeze(['dev', 'prod']);
127
+
128
+ /**
129
+ * Map an axe-core `impact` value to a ux-grill severity (PRD § 2 S3).
130
+ *
131
+ * @param {string} impact - axe impact: 'critical' | 'serious' | 'moderate' | 'minor'
132
+ * @returns {string} 'high' | 'medium' | 'low'. Unknown, missing or non-string
133
+ * impacts default to `'low'` — an unclassifiable violation is reported but
134
+ * never allowed to inflate the high band that drives automatic issue filing.
135
+ */
136
+ export function severityForAxeImpact(impact) {
137
+ if (impact === 'critical' || impact === 'serious') return 'high';
138
+ if (impact === 'moderate') return 'medium';
139
+ return 'low';
140
+ }
141
+
142
+ /**
143
+ * Build a ux-grill finding record.
144
+ *
145
+ * The fingerprint is computed from `(SCOPE, checkId, truncatedLocator)` ONLY —
146
+ * never from severity, build, message or evidence, so that the same violation
147
+ * keeps its identity across runs and across dev/prod builds.
148
+ *
149
+ * @param {object} opts
150
+ * @param {string} opts.checkId - a value of {@link CHECK_IDS}, or `axe-<ruleId>`
151
+ * for axe. Passing the literal `'axe-violations'` throws: two axe rules
152
+ * violated on one selector must remain two findings, which only holds when the
153
+ * rule id is part of the fingerprint input.
154
+ * @param {string} opts.locator - `route|viewport|selector` for route checks,
155
+ * `journey|viewport|<name>` for journey checks. Must contain at least one `|`
156
+ * and no newline/CR/NUL. Truncated to {@link LOCATOR_MAX_LENGTH} before
157
+ * fingerprinting; the truncated form is what the record carries.
158
+ * @param {string} opts.severity - one of {@link SEVERITIES}.
159
+ * @param {string} opts.build - `'dev'` or `'prod'`; drives `provisional`.
160
+ * @param {string} [opts.message] - one-line human summary.
161
+ * @param {object} [opts.evidence] - free-form evidence pointers (screenshot
162
+ * path, axe node, measured px). Defaults to `{}`.
163
+ * @returns {{scope: string, checkId: string, locator: string, severity: string,
164
+ * provisional: boolean, fingerprint: string, message: string, evidence: object}}
165
+ * @throws {TypeError} on any invalid input — no silent defaults.
166
+ */
167
+ export function makeFinding({ checkId, locator, severity, build, message, evidence } = {}) {
168
+ if (typeof checkId !== 'string' || checkId.length === 0) {
169
+ throw new TypeError('makeFinding: checkId must be a non-empty string');
170
+ }
171
+ // Same predicate as the locator check below. `checkId` is fingerprint input
172
+ // and is rendered into issue bodies and JSONL lines; a newline or NUL in it
173
+ // splits one record into two. Downstream `reconcile.oneLine()` contains it
174
+ // today, but the constructor is where a rejected input belongs (BV-002).
175
+ if (LOCATOR_FORBIDDEN.test(checkId)) {
176
+ throw new TypeError('makeFinding: checkId must not contain newline, CR or NUL');
177
+ }
178
+ if (checkId === CHECK_IDS.AXE_VIOLATIONS) {
179
+ throw new TypeError(
180
+ "makeFinding: checkId 'axe-violations' is the catalogue entry, not an emittable id — pass 'axe-<ruleId>' so two axe rules on one selector stay two findings",
181
+ );
182
+ }
183
+ if (typeof locator !== 'string' || locator.length === 0) {
184
+ throw new TypeError('makeFinding: locator must be a non-empty string');
185
+ }
186
+ if (!locator.includes('|')) {
187
+ throw new TypeError("makeFinding: locator must be pipe-delimited (route|viewport|selector or journey|viewport|<name>)");
188
+ }
189
+ if (LOCATOR_FORBIDDEN.test(locator)) {
190
+ throw new TypeError('makeFinding: locator must not contain newline, CR or NUL');
191
+ }
192
+ if (!SEVERITIES.includes(severity)) {
193
+ throw new TypeError(`makeFinding: severity must be one of ${SEVERITIES.join('|')}, got ${String(severity)}`);
194
+ }
195
+ if (!VALID_BUILDS.includes(build)) {
196
+ throw new TypeError(`makeFinding: build must be one of ${VALID_BUILDS.join('|')}, got ${String(build)}`);
197
+ }
198
+ if (message !== undefined && typeof message !== 'string') {
199
+ throw new TypeError('makeFinding: message must be a string when provided');
200
+ }
201
+ if (evidence !== undefined && (typeof evidence !== 'object' || evidence === null || Array.isArray(evidence))) {
202
+ throw new TypeError('makeFinding: evidence must be a plain object when provided');
203
+ }
204
+
205
+ const truncated = locator.slice(0, LOCATOR_MAX_LENGTH);
206
+ // See LOCATOR_MAX_LENGTH: only an over-long locator gets the full-text digest
207
+ // suffix, so ≤256-char fingerprints stay byte-for-byte stable.
208
+ const fingerprintLocator =
209
+ locator.length > LOCATOR_MAX_LENGTH ? `${truncated}:${digestSha256Short(locator)}` : truncated;
210
+ return {
211
+ scope: SCOPE,
212
+ checkId,
213
+ locator: truncated,
214
+ severity,
215
+ // Dev builds are not a geometry measurement basis (PRD § 2 S3), so
216
+ // target-size findings from a dev build are flagged rather than dropped.
217
+ provisional: build === 'dev' && checkId.startsWith('target-size-'),
218
+ fingerprint: fingerprintFinding({ scope: SCOPE, checkId, locator: fingerprintLocator }),
219
+ message: message ?? '',
220
+ evidence: evidence ?? {},
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Count findings per severity band.
226
+ *
227
+ * @param {Array<{severity: string}>} findings
228
+ * @returns {{high: number, medium: number, low: number}} zero-filled for every band.
229
+ * @throws {TypeError} if `findings` is not an array or carries an unknown severity.
230
+ */
231
+ export function countBySeverity(findings) {
232
+ if (!Array.isArray(findings)) {
233
+ throw new TypeError('countBySeverity: findings must be an array');
234
+ }
235
+ const counts = { high: 0, medium: 0, low: 0 };
236
+ for (const finding of findings) {
237
+ const severity = finding?.severity;
238
+ if (!SEVERITIES.includes(severity)) {
239
+ throw new TypeError(`countBySeverity: unknown severity ${String(severity)}`);
240
+ }
241
+ counts[severity] += 1;
242
+ }
243
+ return counts;
244
+ }
245
+
246
+ /**
247
+ * Build the `.orchestrator/metrics/ux-grill.jsonl` run-record (PRD § 4).
248
+ *
249
+ * @param {object} opts
250
+ * @param {string} opts.runId - run identifier (see `paths.mjs` `makeRunId`).
251
+ * @param {string} opts.manifestHash - hash of the resolved ux-manifest; the
252
+ * compare key — only runs with the same manifest_hash are comparable.
253
+ * @param {string} opts.rubricHash - hash of `skills/ux-grill/rubric-v2.md`.
254
+ * @param {string} opts.build - `'dev'` or `'prod'`.
255
+ * @param {string} [opts.timestamp] - ISO-8601 UTC; defaults to now.
256
+ * @param {string[]} [opts.viewports] - viewport labels actually run.
257
+ * @param {string[]} [opts.routes] - route paths actually run.
258
+ * @param {Array<{severity: string}>} [opts.findings] - drives `counts_by_severity`
259
+ * and `provisional_count` when those are not passed explicitly.
260
+ * @param {{high: number, medium: number, low: number}} [opts.countsBySeverity]
261
+ * @param {number} [opts.provisionalCount]
262
+ * @param {{new: number, persisting: number, fixed: number}} [opts.compare]
263
+ * @param {Array<{what: string, reason: string}>} [opts.skipped] - `reason` must
264
+ * be a value of {@link SKIP_REASONS}.
265
+ * @param {Array<{route: string, frame: string}>} [opts.pencilCoverage]
266
+ * @returns {object} the run-record, with exactly the PRD § 4 fields.
267
+ * @throws {TypeError} on missing/invalid required fields.
268
+ */
269
+ export function makeRunRecord({
270
+ runId,
271
+ manifestHash,
272
+ rubricHash,
273
+ build,
274
+ timestamp,
275
+ viewports,
276
+ routes,
277
+ findings,
278
+ countsBySeverity,
279
+ provisionalCount,
280
+ compare,
281
+ skipped,
282
+ pencilCoverage,
283
+ } = {}) {
284
+ for (const [name, value] of [
285
+ ['runId', runId],
286
+ ['manifestHash', manifestHash],
287
+ ['rubricHash', rubricHash],
288
+ ]) {
289
+ if (typeof value !== 'string' || value.length === 0) {
290
+ throw new TypeError(`makeRunRecord: ${name} must be a non-empty string`);
291
+ }
292
+ }
293
+ if (!VALID_BUILDS.includes(build)) {
294
+ throw new TypeError(`makeRunRecord: build must be one of ${VALID_BUILDS.join('|')}, got ${String(build)}`);
295
+ }
296
+ const findingList = findings ?? [];
297
+ if (!Array.isArray(findingList)) {
298
+ throw new TypeError('makeRunRecord: findings must be an array when provided');
299
+ }
300
+ for (const entry of skipped ?? []) {
301
+ if (!Object.values(SKIP_REASONS).includes(entry?.reason)) {
302
+ throw new TypeError(`makeRunRecord: unknown skip reason ${String(entry?.reason)}`);
303
+ }
304
+ }
305
+
306
+ return {
307
+ schema_version: RUN_RECORD_SCHEMA_VERSION,
308
+ run_id: runId,
309
+ timestamp: timestamp ?? new Date().toISOString(),
310
+ manifest_hash: manifestHash,
311
+ rubric_hash: rubricHash,
312
+ build,
313
+ viewports: viewports ?? [],
314
+ routes: routes ?? [],
315
+ counts_by_severity: countsBySeverity ?? countBySeverity(findingList),
316
+ provisional_count: provisionalCount ?? findingList.filter((f) => f?.provisional).length,
317
+ compare: compare ?? { new: 0, persisting: 0, fixed: 0 },
318
+ skipped: skipped ?? [],
319
+ pencil_coverage: pencilCoverage ?? [],
320
+ };
321
+ }