session-orchestrator 4.1.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -0,0 +1,285 @@
1
+ /**
2
+ * ux-grill/compare.mjs — cross-run fingerprint classification (PRD § 2 S5,
3
+ * § 3 "Reconcile & Regression").
4
+ *
5
+ * Two layers, deliberately split:
6
+ * - {@link compareFingerprints} is PURE — two arrays in, three arrays out. It
7
+ * is the whole classification rule and needs no filesystem to be tested.
8
+ * - {@link compareRuns} is the thin I/O wrapper: it loads the current run's
9
+ * findings, locates the baseline run via `run-record.mjs`, applies the pure
10
+ * function, subtracts the UNMEASURED set, and patches the run-record's
11
+ * `compare` counts.
12
+ *
13
+ * Imports `./run-record.mjs` only (which is itself fs + `./paths.mjs` +
14
+ * `./schema.mjs`). No shell-out, no network.
15
+ *
16
+ * THE TWO WAYS A `fixed` CLASSIFICATION LIES, both surfaced rather than hidden:
17
+ *
18
+ * 1. A rule left the RUBRIC. If the baseline ran under a different
19
+ * `rubric_hash`, a fingerprint that disappeared may no longer be
20
+ * checked for. `compareRuns` still compares (refusing to compare would
21
+ * lose the persisting set too) but returns `rubricChanged: true`, and the
22
+ * `fixed` array then carries FIXED-OR-RUBRIC-CHANGED semantics — a fact
23
+ * for the caller to render, never a filter applied here.
24
+ *
25
+ * 2. A measurement never RAN. A route whose `open` failed, a viewport whose
26
+ * `set device` did not take, one `eval` that threw — every such step is a
27
+ * `skipped[]` entry in the run record precisely because zero findings for
28
+ * a check that never ran is indistinguishable from a clean check
29
+ * (`schema.mjs` § SKIP_REASONS). Previous findings under a skipped scope
30
+ * are therefore removed from `fixed` and returned as `unmeasured`.
31
+ *
32
+ * Exports:
33
+ * compareFingerprints(previousFindings, currentFindings)
34
+ * compareRuns({repoRoot, runId, manifestHash, build, rubricHash, skipped})
35
+ */
36
+
37
+ import {
38
+ findPreviousRun,
39
+ readFindings,
40
+ readRunRecords,
41
+ updateRunRecordCompare,
42
+ } from './run-record.mjs';
43
+
44
+ /** Sort findings by fingerprint so every consumer sees one deterministic order. */
45
+ function byFingerprint(findings) {
46
+ return [...findings].sort((a, b) =>
47
+ a.fingerprint < b.fingerprint ? -1 : a.fingerprint > b.fingerprint ? 1 : 0,
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Index findings by fingerprint. A duplicate fingerprint keeps the FIRST entry:
53
+ * `collect()` writes at most one finding per (checkId, locator) pair, so a
54
+ * duplicate is a malformed input, and "first wins" makes the result independent
55
+ * of file order.
56
+ *
57
+ * @param {Array<{fingerprint: string}>} findings
58
+ * @returns {Map<string, object>}
59
+ */
60
+ function indexByFingerprint(findings) {
61
+ const map = new Map();
62
+ for (const finding of findings) {
63
+ const fp = finding?.fingerprint;
64
+ if (typeof fp !== 'string' || fp.length === 0) continue;
65
+ if (!map.has(fp)) map.set(fp, finding);
66
+ }
67
+ return map;
68
+ }
69
+
70
+ /**
71
+ * Classify every fingerprint of two runs as `new`, `persisting` or `fixed`.
72
+ *
73
+ * Pure. Knows nothing about skips or rubric hashes — the caller subtracts those
74
+ * (see {@link compareRuns}), because they are facts about the RUN, not about the
75
+ * fingerprint sets.
76
+ *
77
+ * @param {Array<{fingerprint: string}>} previousFindings - the baseline run's findings
78
+ * @param {Array<{fingerprint: string}>} currentFindings - this run's findings
79
+ * @returns {{new: object[], persisting: object[], fixed: object[]}} FINDING
80
+ * objects, each array sorted by fingerprint. `new` and `persisting` carry the
81
+ * CURRENT finding (its severity/evidence are the ones that matter now);
82
+ * `fixed` carries the PREVIOUS finding, which is the only record of it left.
83
+ * @throws {TypeError} if either argument is not an array
84
+ */
85
+ export function compareFingerprints(previousFindings, currentFindings) {
86
+ if (!Array.isArray(previousFindings)) {
87
+ throw new TypeError('compareFingerprints: previousFindings must be an array');
88
+ }
89
+ if (!Array.isArray(currentFindings)) {
90
+ throw new TypeError('compareFingerprints: currentFindings must be an array');
91
+ }
92
+ const previous = indexByFingerprint(previousFindings);
93
+ const current = indexByFingerprint(currentFindings);
94
+
95
+ const fresh = [];
96
+ const persisting = [];
97
+ for (const [fp, finding] of current) {
98
+ (previous.has(fp) ? persisting : fresh).push(finding);
99
+ }
100
+ const fixed = [];
101
+ for (const [fp, finding] of previous) {
102
+ if (!current.has(fp)) fixed.push(finding);
103
+ }
104
+ return { new: byFingerprint(fresh), persisting: byFingerprint(persisting), fixed: byFingerprint(fixed) };
105
+ }
106
+
107
+ /**
108
+ * Parse ONE `skipped[].what` into the locator scope it invalidates.
109
+ *
110
+ * `collect.mjs` emits exactly five `what` shapes (measured 2026-09-12 against
111
+ * `scripts/lib/ux-grill/collect.mjs` — `skipEntry(` call sites at :745, :748,
112
+ * :765, :774, :991):
113
+ *
114
+ * `viewport:<vp>` device-mismatch → the whole viewport
115
+ * `viewport:<vp>|eval:viewport-width` measure-failed → the whole viewport
116
+ * `route:<path>|<vp>` route-unreachable→ that route × viewport
117
+ * `<path>|<vp>|<call>` measure-failed → that route × viewport
118
+ * `journey:<name>` route-unreachable→ that journey, all viewports
119
+ *
120
+ * The last shape carries no viewport, so it invalidates the journey on EVERY
121
+ * viewport — widening rather than guessing, because the cost of a
122
+ * false `unmeasured` is a finding reported as still-open, while the cost of a
123
+ * missed one is an outage reported as `fixed`.
124
+ *
125
+ * @param {string} what
126
+ * @returns {{kind: 'viewport', viewport: string}
127
+ * | {kind: 'route', route: string, viewport: string|null}
128
+ * | {kind: 'journey', name: string}
129
+ * | null} `null` for an unrecognised shape (it invalidates nothing)
130
+ */
131
+ function skipScope(what) {
132
+ if (typeof what !== 'string' || what.length === 0) return null;
133
+ const segments = what.split('|');
134
+ const head = segments[0];
135
+ if (head.startsWith('viewport:')) {
136
+ const viewport = head.slice('viewport:'.length);
137
+ return viewport.length > 0 ? { kind: 'viewport', viewport } : null;
138
+ }
139
+ if (head.startsWith('route:')) {
140
+ const route = head.slice('route:'.length);
141
+ return route.length > 0 ? { kind: 'route', route, viewport: segments[1] ?? null } : null;
142
+ }
143
+ if (head.startsWith('journey:')) {
144
+ const name = head.slice('journey:'.length);
145
+ return name.length > 0 ? { kind: 'journey', name } : null;
146
+ }
147
+ // Bare `<route>|<viewport>|<call>` — the measure-failed shape.
148
+ if (segments.length >= 2 && head.length > 0) {
149
+ return { kind: 'route', route: head, viewport: segments[1] };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Build a predicate over finding LOCATORS from a run's `skipped[]`.
156
+ *
157
+ * Finding locators are `route|viewport|selector` or `journey|viewport|<name>`
158
+ * (`schema.mjs` § makeFinding).
159
+ *
160
+ * @param {Array<{what: string, reason: string}>} skipped
161
+ * @returns {(locator: string) => boolean} true when the locator sits under a
162
+ * scope this run did not measure
163
+ */
164
+ function unmeasuredMatcher(skipped) {
165
+ const scopes = [];
166
+ for (const entry of Array.isArray(skipped) ? skipped : []) {
167
+ const scope = skipScope(entry?.what);
168
+ if (scope) scopes.push(scope);
169
+ }
170
+ if (scopes.length === 0) return () => false;
171
+ return (locator) => {
172
+ if (typeof locator !== 'string') return false;
173
+ const segments = locator.split('|');
174
+ for (const scope of scopes) {
175
+ if (scope.kind === 'viewport' && segments[1] === scope.viewport) return true;
176
+ if (
177
+ scope.kind === 'route' &&
178
+ segments[0] === scope.route &&
179
+ (scope.viewport === null || segments[1] === scope.viewport)
180
+ ) {
181
+ return true;
182
+ }
183
+ if (scope.kind === 'journey' && segments[0] === 'journey' && segments[2] === scope.name) return true;
184
+ }
185
+ return false;
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Compare one run against its baseline and patch the run-record's counts.
191
+ *
192
+ * @param {object} opts
193
+ * @param {string} opts.repoRoot - absolute repo root of the TARGET repo
194
+ * @param {string} opts.runId - the CURRENT run
195
+ * @param {string} [opts.manifestHash] - compare key; read from the current run's
196
+ * ledger record when omitted
197
+ * @param {string} [opts.build] - `'dev'`/`'prod'`; read from the record when omitted
198
+ * @param {string} [opts.rubricHash] - this run's rubric hash; read from the record
199
+ * when omitted. Drives `rubricChanged` only — never the baseline selection.
200
+ * @param {Array<{what: string, reason: string}>} [opts.skipped] - this run's
201
+ * skips; read from the record when omitted
202
+ * @returns {{
203
+ * runId: string, previousRunId: string|null, baseline: boolean, rubricChanged: boolean,
204
+ * new: object[], persisting: object[], fixed: object[], unmeasured: object[],
205
+ * counts: {new: number, persisting: number, fixed: number},
206
+ * recordUpdated: number, skippedLines: {current: number, previous: number, ledger: number}
207
+ * }} `fixed` carries FIXED-OR-RUBRIC-CHANGED semantics whenever
208
+ * `rubricChanged` is true (see module header). `unmeasured` carries previous
209
+ * findings whose route/viewport/journey this run did not measure — they are
210
+ * NOT in `fixed` and NOT in `persisting`.
211
+ * @throws {TypeError} when `manifestHash`/`build` can be resolved from neither
212
+ * the arguments nor the ledger — comparing without a compare key would silently
213
+ * pick an unrelated run as the baseline.
214
+ */
215
+ export function compareRuns({ repoRoot, runId, manifestHash, build, rubricHash, skipped } = {}) {
216
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
217
+ throw new TypeError('compareRuns: repoRoot must be a non-empty string');
218
+ }
219
+ if (typeof runId !== 'string' || runId.length === 0) {
220
+ throw new TypeError('compareRuns: runId must be a non-empty string');
221
+ }
222
+
223
+ const { records, skippedLines: ledgerSkippedLines } = readRunRecords(repoRoot, { limit: Infinity });
224
+ const currentRecord = records.find((record) => record.run_id === runId) ?? null;
225
+
226
+ const effectiveManifestHash = manifestHash ?? currentRecord?.manifest_hash;
227
+ const effectiveBuild = build ?? currentRecord?.build;
228
+ const effectiveRubricHash = rubricHash ?? currentRecord?.rubric_hash ?? null;
229
+ const effectiveSkipped = skipped ?? currentRecord?.skipped ?? [];
230
+ if (typeof effectiveManifestHash !== 'string' || effectiveManifestHash.length === 0) {
231
+ throw new TypeError(`compareRuns: no manifestHash for run ${runId} — pass one or append its run-record first`);
232
+ }
233
+ if (typeof effectiveBuild !== 'string' || effectiveBuild.length === 0) {
234
+ throw new TypeError(`compareRuns: no build for run ${runId} — pass one or append its run-record first`);
235
+ }
236
+
237
+ const currentRead = readFindings(repoRoot, runId);
238
+ const previousRecord = findPreviousRun(repoRoot, {
239
+ manifestHash: effectiveManifestHash,
240
+ build: effectiveBuild,
241
+ beforeRunId: runId,
242
+ });
243
+
244
+ let result;
245
+ let previousRead = { findings: [], skippedLines: 0 };
246
+ if (previousRecord === null) {
247
+ // Baseline run: everything measured is new by definition. Not "clean" —
248
+ // a first run with 40 findings has 40 new ones.
249
+ result = { new: byFingerprint(currentRead.findings), persisting: [], fixed: [] };
250
+ } else {
251
+ previousRead = readFindings(repoRoot, previousRecord.run_id);
252
+ result = compareFingerprints(previousRead.findings, currentRead.findings);
253
+ }
254
+
255
+ const isUnmeasured = unmeasuredMatcher(effectiveSkipped);
256
+ const unmeasured = [];
257
+ const fixed = [];
258
+ for (const finding of result.fixed) {
259
+ (isUnmeasured(finding?.locator) ? unmeasured : fixed).push(finding);
260
+ }
261
+
262
+ const counts = { new: result.new.length, persisting: result.persisting.length, fixed: fixed.length };
263
+ const { updated } = updateRunRecordCompare(repoRoot, runId, counts);
264
+
265
+ return {
266
+ runId,
267
+ previousRunId: previousRecord?.run_id ?? null,
268
+ baseline: previousRecord === null,
269
+ rubricChanged:
270
+ previousRecord !== null &&
271
+ effectiveRubricHash !== null &&
272
+ previousRecord.rubric_hash !== effectiveRubricHash,
273
+ new: result.new,
274
+ persisting: result.persisting,
275
+ fixed,
276
+ unmeasured,
277
+ counts,
278
+ recordUpdated: updated,
279
+ skippedLines: {
280
+ current: currentRead.skippedLines,
281
+ previous: previousRead.skippedLines,
282
+ ledger: ledgerSkippedLines,
283
+ },
284
+ };
285
+ }