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,431 @@
1
+ /**
2
+ * ux-grill/measures.mjs — Browser-side measurement scripts + pure classifiers.
3
+ *
4
+ * Leaf module: no imports at all (not even `./schema.mjs`) and no I/O. It ships
5
+ * two kinds of thing:
6
+ *
7
+ * 1. `*_EVAL` constants — JavaScript SOURCE STRINGS handed verbatim to
8
+ * `agent-browser eval` (0.37.1). They run in the PAGE, so they cannot
9
+ * import anything; every threshold they need is inlined and pinned against
10
+ * the exported constants by the measures test suite.
11
+ * 2. Pure functions — `classifyTargetSize`, `hasHorizontalOverflow`,
12
+ * `titleMatches`, `parseEvalOutput` — used by `collect.mjs` in node.
13
+ *
14
+ * This module NEVER builds findings. `collect.mjs` turns a measurement plus a
15
+ * classifier verdict into a finding via `makeFinding()` from `./schema.mjs`.
16
+ *
17
+ * ## `agent-browser eval` output contract (measured 2026-09-12, v0.37.1)
18
+ *
19
+ * The tool evaluates the script and prints the completion value **already
20
+ * JSON-serialised** (pretty-printed, multi-line):
21
+ *
22
+ * ```
23
+ * $ printf '(() => ({n: window.innerWidth}))()' | agent-browser eval --stdin
24
+ * {
25
+ * "n": 1280
26
+ * }
27
+ * $ agent-browser eval "document.title"
28
+ * ""
29
+ * ```
30
+ *
31
+ * Two consequences, both load-bearing for the shapes below:
32
+ * - The evals return plain values/objects, NOT `JSON.stringify(...)`. Returning
33
+ * a JSON string would print a *quoted, escaped* string (measured:
34
+ * `(() => JSON.stringify({n:1}))()` printed `"{\"n\":1}"`), forcing a double
35
+ * parse on every read.
36
+ * - Multi-line output is normal, so {@link parseEvalOutput} parses the WHOLE
37
+ * trimmed stdout, never the last line. It still tolerates the
38
+ * double-encoded shape, because a page that itself returns a JSON string is
39
+ * indistinguishable from the quoted form at the stdout boundary.
40
+ *
41
+ * Pass these strings via `eval --stdin` (heredoc) or `eval -b <base64>` — the
42
+ * tool's own guidance ("Inline `agent-browser eval "..."` works only for simple
43
+ * expressions"); {@link TARGET_SIZE_EVAL} contains quotes and brackets.
44
+ *
45
+ * Spec: docs/prd/2026-09-12-ux-grill.md § 2 S2/S3, § 3 AC "Stufe 1".
46
+ *
47
+ * Exports:
48
+ * TARGET_SIZE_FLOOR_PX, TARGET_SIZE_TARGET_PX, INTERACTIVE_TARGET_SELECTOR,
49
+ * TARGET_SIZE_MAX_ENTRIES, SELECTOR_MAX_DEPTH, SELECTOR_MAX_LENGTH,
50
+ * OVERFLOW_TOLERANCE_PX,
51
+ * TARGET_SIZE_EVAL, OVERFLOW_EVAL, VIEWPORT_WIDTH_EVAL,
52
+ * classifyTargetSize(), hasHorizontalOverflow(), titleMatches(),
53
+ * parseEvalOutput()
54
+ */
55
+
56
+ /**
57
+ * WCAG 2.2 SC 2.5.8 **Target Size (Minimum)**, AA: pointer targets must be at
58
+ * least 24 × 24 CSS px. Below this is `target-size-floor` → severity `high`
59
+ * (PRD § 2 S3).
60
+ * @type {number}
61
+ */
62
+ export const TARGET_SIZE_FLOOR_PX = 24;
63
+
64
+ /**
65
+ * WCAG 2.1 SC 2.5.5 **Target Size (Enhanced)**, AAA: 44 × 44 CSS px. A target
66
+ * at or above {@link TARGET_SIZE_FLOOR_PX} but below this is
67
+ * `target-size-target` → severity `medium` (PRD § 2 S3 row "24–43 px").
68
+ * @type {number}
69
+ */
70
+ export const TARGET_SIZE_TARGET_PX = 44;
71
+
72
+ /**
73
+ * THE population of interactive targets the size check runs over. Anything not
74
+ * matched here is not measured and therefore cannot produce a finding — state
75
+ * this list whenever a `target-size-*` count is reported (the count is
76
+ * meaningless without its denominator).
77
+ *
78
+ * Deliberately syntactic (tags + explicit ARIA roles), not "everything with a
79
+ * click handler": a listener attached in JS is invisible to a static query, so
80
+ * a handler-based population would be unreproducible run to run — and
81
+ * reproducibility is the AC ("Fingerprint-Mengen beider findings.jsonl
82
+ * identisch"). `input[type=hidden]` is excluded at the selector level because
83
+ * it has no box at all.
84
+ * @type {string}
85
+ */
86
+ export const INTERACTIVE_TARGET_SELECTOR = [
87
+ 'a[href]',
88
+ 'button',
89
+ 'input:not([type=hidden])',
90
+ 'select',
91
+ 'textarea',
92
+ '[role=button]',
93
+ '[role=link]',
94
+ '[role=menuitem]',
95
+ '[role=tab]',
96
+ '[role=checkbox]',
97
+ '[role=radio]',
98
+ '[role=switch]',
99
+ 'summary',
100
+ 'label[for]',
101
+ ].join(', ');
102
+
103
+ /**
104
+ * Ceiling on entries returned by {@link TARGET_SIZE_EVAL}. A page with more
105
+ * undersized targets than this has a systemic layout problem, not 200 separate
106
+ * ones, and every entry becomes a fingerprinted finding + an issue candidate.
107
+ * Revisit if a real route legitimately exceeds it — the eval reports
108
+ * `truncated: true` so the condition is observable rather than silent.
109
+ * @type {number}
110
+ */
111
+ export const TARGET_SIZE_MAX_ENTRIES = 200;
112
+
113
+ /**
114
+ * Maximum ancestor levels walked when building a target's CSS path.
115
+ *
116
+ * BV-004 — deliberate simplification. CEILING: 5 `:nth-of-type()` steps
117
+ * uniquely identify a target on a normally-nested page and keep the generated
118
+ * selector inside {@link SELECTOR_MAX_LENGTH}; the walk also stops early at the
119
+ * first stable `id`. It does NOT guarantee uniqueness — two sibling subtrees
120
+ * that differ only ABOVE level 5 produce the same path, hence the same
121
+ * fingerprint, and collapse into one finding.
122
+ * REVISIT TRIGGER: a real run reporting fewer `target-size-*` findings than the
123
+ * eval's own `targets[]` length (the collapse becoming observable), or a target
124
+ * app whose interactive elements routinely sit deeper than 5 levels below their
125
+ * nearest `id`.
126
+ * @type {number}
127
+ */
128
+ export const SELECTOR_MAX_DEPTH = 5;
129
+
130
+ /**
131
+ * Hard cap on a generated selector. The selector is the third segment of a
132
+ * finding locator (`route|viewport|selector`) which `schema.mjs` truncates at
133
+ * `LOCATOR_MAX_LENGTH` (256) BEFORE fingerprinting; keeping the selector well
134
+ * under that leaves the route and viewport segments intact, so two findings on
135
+ * different routes cannot collapse onto one fingerprint.
136
+ *
137
+ * BV-004 — deliberate simplification. CEILING: 160 leaves ≥ 96 chars of the
138
+ * 256-char locator for `route|viewport|`, which covers every route/viewport
139
+ * label the manifest schema admits. Over-long paths are TRUNCATED FROM THE LEFT
140
+ * (the eval keeps the tail, i.e. the element and its nearest ancestors), so a
141
+ * selector longer than this loses its outermost context and two deeply-nested
142
+ * targets sharing a tail can fingerprint alike.
143
+ * REVISIT TRIGGER: a route or viewport label pushing the assembled locator past
144
+ * `LOCATOR_MAX_LENGTH` (256) in `schema.mjs`, which would start truncating the
145
+ * SELECTOR segment a second time — raise 256 there rather than lowering 160 here.
146
+ * @type {number}
147
+ */
148
+ export const SELECTOR_MAX_LENGTH = 160;
149
+
150
+ /**
151
+ * Subpixel tolerance for the horizontal-overflow comparison, see
152
+ * {@link hasHorizontalOverflow}.
153
+ *
154
+ * BV-004 — deliberate simplification, stated AT the constant rather than only
155
+ * in `skills/ux-grill/rubric-v2.md` § horizontal-overflow (which now points
156
+ * here). CEILING: 1 CSS px absorbs fractional layout rounding (a 1439.5 px
157
+ * container in a 1440 px viewport rounds up into `scrollWidth` and shows no
158
+ * scrollbar) and nothing more — a real horizontal scrollbar is always several
159
+ * px wide, so no genuine overflow hides under this tolerance. It is a FIXED
160
+ * tolerance, not a ratio: on a hypothetical viewport measured in device pixels
161
+ * at a high DPR, 1 CSS px would no longer be the rounding unit.
162
+ * REVISIT TRIGGER: a run reporting `horizontal-overflow` on a page with no
163
+ * scrollbar (tolerance too small) or missing a visible scrollbar (too large) —
164
+ * both are observable from the recorded `scrollWidth`/`innerWidth` evidence.
165
+ * @type {number}
166
+ */
167
+ export const OVERFLOW_TOLERANCE_PX = 1;
168
+
169
+ /**
170
+ * Classify a measured target box against the WCAG thresholds.
171
+ *
172
+ * BOTH axes are checked (PRD § 2 S3: "beide Achsen geprüft") — the smaller axis
173
+ * decides, because a 147 × 20 px button is as hard to hit as a 20 × 20 one.
174
+ *
175
+ * Population note: this classifies a box that the caller has ALREADY decided is
176
+ * a rendered, visible, interactive target. It knows nothing about visibility —
177
+ * feeding it a hidden element's rect yields a verdict for an element that must
178
+ * never produce a finding.
179
+ *
180
+ * @param {{width?: number, height?: number}} box - CSS-pixel box dimensions.
181
+ * @returns {'floor'|'target'|null} `'floor'` when either axis is below
182
+ * {@link TARGET_SIZE_FLOOR_PX}; `'target'` when either axis is below
183
+ * {@link TARGET_SIZE_TARGET_PX}; otherwise `null` (compliant). Non-finite or
184
+ * missing input returns `null`: an unmeasurable box is not evidence of a
185
+ * violation, and a NaN comparison would otherwise silently read as "not
186
+ * below" anyway.
187
+ */
188
+ export function classifyTargetSize({ width, height } = {}) {
189
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
190
+ if (width < TARGET_SIZE_FLOOR_PX || height < TARGET_SIZE_FLOOR_PX) return 'floor';
191
+ if (width < TARGET_SIZE_TARGET_PX || height < TARGET_SIZE_TARGET_PX) return 'target';
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Page-side target-size measurement.
197
+ *
198
+ * Returns `{ targets: Array<{selector, tag, width, height, class, verdict}>,
199
+ * scanned: number, truncated: boolean }`, where `targets` holds ONLY the
200
+ * non-compliant ones (verdict `'floor'` or `'target'`) and `scanned` is the size
201
+ * of the population actually inspected — `collect.mjs` needs the denominator.
202
+ * When `truncated` is `true` the scan stopped at the cap, so `scanned` is a
203
+ * partial denominator and must be reported as such.
204
+ *
205
+ * Exclusions (an excluded element can never produce a finding):
206
+ * - zero-area rect, `display: none`, `visibility: hidden`
207
+ * - computed `opacity: 0` on the element or any ancestor (opacity does not
208
+ * inherit as a computed value, so the ancestor walk is required)
209
+ * - `aria-hidden="true"` or `inert` on the element or any ancestor
210
+ * - the custom-select pattern (PRD § 3 AC): a `select`/`input` that is ≤ 1 × 1
211
+ * px, sits off-screen, or is covered by another element at its centre point.
212
+ *
213
+ * The covered/off-screen/1×1 trio is scoped to `select`/`input` on purpose: it
214
+ * is the native-control-behind-a-custom-trigger idiom, and applying a
215
+ * hit-testing exclusion to every target would silently drop real violations
216
+ * that merely sit under a decorative overlay.
217
+ *
218
+ * `off-screen` deliberately means left/above/right-of the viewport, NOT below
219
+ * it: on any page taller than the viewport, "fully outside the viewport" would
220
+ * exclude every below-the-fold target — normal scroll content, not a hiding
221
+ * pattern.
222
+ *
223
+ * The thresholds 24/44, the depth 5, the length 160 and the cap 200 are inlined
224
+ * because a page script cannot import; they MUST equal
225
+ * {@link TARGET_SIZE_FLOOR_PX}, {@link TARGET_SIZE_TARGET_PX},
226
+ * {@link SELECTOR_MAX_DEPTH}, {@link SELECTOR_MAX_LENGTH} and
227
+ * {@link TARGET_SIZE_MAX_ENTRIES} (pinned by the measures test suite).
228
+ * @type {string}
229
+ */
230
+ export const TARGET_SIZE_EVAL = `(() => {
231
+ const FLOOR = 24; // must equal TARGET_SIZE_FLOOR_PX
232
+ const TARGET = 44; // must equal TARGET_SIZE_TARGET_PX
233
+ const MAX_ENTRIES = 200; // must equal TARGET_SIZE_MAX_ENTRIES
234
+ const MAX_DEPTH = 5; // must equal SELECTOR_MAX_DEPTH
235
+ const MAX_SELECTOR = 160; // must equal SELECTOR_MAX_LENGTH
236
+ const SELECTOR = ${JSON.stringify(INTERACTIVE_TARGET_SELECTOR)};
237
+
238
+ const classify = (w, h) => {
239
+ if (!isFinite(w) || !isFinite(h)) return null;
240
+ if (w < FLOOR || h < FLOOR) return 'floor';
241
+ if (w < TARGET || h < TARGET) return 'target';
242
+ return null;
243
+ };
244
+
245
+ const cssPath = (el) => {
246
+ const parts = [];
247
+ let node = el;
248
+ for (let depth = 0; node && node.nodeType === 1 && depth < MAX_DEPTH; depth++) {
249
+ const tag = node.tagName.toLowerCase();
250
+ if (node.id && /^[A-Za-z][A-Za-z0-9_-]*$/.test(node.id)) {
251
+ parts.unshift('#' + node.id);
252
+ break;
253
+ }
254
+ let index = 1;
255
+ let sib = node.previousElementSibling;
256
+ while (sib) {
257
+ if (sib.tagName === node.tagName) index++;
258
+ sib = sib.previousElementSibling;
259
+ }
260
+ parts.unshift(tag + ':nth-of-type(' + index + ')');
261
+ node = node.parentElement;
262
+ }
263
+ const path = parts.join(' > ');
264
+ return path.length > MAX_SELECTOR ? path.slice(-MAX_SELECTOR) : path;
265
+ };
266
+
267
+ const hiddenByAncestor = (el) => {
268
+ let node = el;
269
+ while (node && node.nodeType === 1) {
270
+ if (node.getAttribute('aria-hidden') === 'true') return true;
271
+ if (node.hasAttribute('inert')) return true;
272
+ const cs = getComputedStyle(node);
273
+ if (cs.display === 'none' || cs.visibility === 'hidden') return true;
274
+ if (parseFloat(cs.opacity) === 0) return true;
275
+ node = node.parentElement;
276
+ }
277
+ return false;
278
+ };
279
+
280
+ const isCovered = (el, rect) => {
281
+ const x = rect.left + rect.width / 2;
282
+ const y = rect.top + rect.height / 2;
283
+ if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return true;
284
+ const hit = document.elementFromPoint(x, y);
285
+ if (!hit) return true;
286
+ return !(hit === el || el.contains(hit));
287
+ };
288
+
289
+ const els = Array.from(document.querySelectorAll(SELECTOR));
290
+ const targets = [];
291
+ let scanned = 0;
292
+ let truncated = false;
293
+
294
+ for (const el of els) {
295
+ const rect = el.getBoundingClientRect();
296
+ if (rect.width === 0 || rect.height === 0) continue;
297
+ if (hiddenByAncestor(el)) continue;
298
+
299
+ const tag = el.tagName.toLowerCase();
300
+ if (tag === 'select' || tag === 'input') {
301
+ if (rect.width <= 1 || rect.height <= 1) continue;
302
+ const offscreen = rect.right <= 0 || rect.bottom <= 0 || rect.left >= innerWidth;
303
+ if (offscreen) continue;
304
+ if (isCovered(el, rect)) continue;
305
+ }
306
+
307
+ scanned++;
308
+ // Classify on the RAW float and round only for reporting: rounding first
309
+ // promotes a 23.6 x 23.6 px box to 24 x 24 and reclassifies a 'floor'
310
+ // violation as 'target' (or as compliant at 43.6).
311
+ const verdict = classify(rect.width, rect.height);
312
+ const width = Math.round(rect.width);
313
+ const height = Math.round(rect.height);
314
+ if (verdict === null) continue;
315
+ if (targets.length >= MAX_ENTRIES) { truncated = true; break; }
316
+ targets.push({
317
+ selector: cssPath(el),
318
+ tag: tag,
319
+ width: width,
320
+ height: height,
321
+ class: typeof el.className === 'string' ? el.className : '',
322
+ verdict: verdict,
323
+ });
324
+ }
325
+
326
+ return { targets: targets, scanned: scanned, truncated: truncated };
327
+ })()`;
328
+
329
+ /**
330
+ * Page-side horizontal-overflow measurement. Feed the result to
331
+ * {@link hasHorizontalOverflow}.
332
+ *
333
+ * `bodyScrollWidth` is reported alongside the documentElement value purely as
334
+ * diagnostic context for the operator (it localises the overflow to the body
335
+ * subtree); the verdict is computed from `scrollWidth`/`innerWidth` only.
336
+ * @type {string}
337
+ */
338
+ export const OVERFLOW_EVAL = `(() => ({
339
+ scrollWidth: document.documentElement.scrollWidth,
340
+ innerWidth: window.innerWidth,
341
+ bodyScrollWidth: document.body ? document.body.scrollWidth : null,
342
+ }))()`;
343
+
344
+ /**
345
+ * Page-side viewport width — the device/viewport verification probe (PRD § 2
346
+ * S2: "`window.innerWidth` muss zur Vorgabe passen, sonst
347
+ * `skipped: device-mismatch`").
348
+ * @type {string}
349
+ */
350
+ export const VIEWPORT_WIDTH_EVAL = 'window.innerWidth';
351
+
352
+ /**
353
+ * Decide whether a page overflows horizontally.
354
+ *
355
+ * The `+ 1` is a subpixel tolerance: fractional layout widths (a 1439.5 px
356
+ * container in a 1440 px viewport) round up into `scrollWidth`, so a strict
357
+ * `>` reports overflow on pages that show no scrollbar. One CSS pixel is the
358
+ * smallest tolerance that absorbs that rounding without hiding a real
359
+ * horizontal scrollbar, which is always ≥ several px wide.
360
+ *
361
+ * @param {{scrollWidth?: number, innerWidth?: number}} m - {@link OVERFLOW_EVAL} result.
362
+ * @returns {boolean} `false` when either value is non-finite — an unmeasured
363
+ * page is not a violating page.
364
+ */
365
+ export function hasHorizontalOverflow({ scrollWidth, innerWidth } = {}) {
366
+ if (!Number.isFinite(scrollWidth) || !Number.isFinite(innerWidth)) return false;
367
+ return scrollWidth > innerWidth + OVERFLOW_TOLERANCE_PX;
368
+ }
369
+
370
+ /**
371
+ * Test a page title against a manifest `title-pattern`.
372
+ *
373
+ * @param {string} title - the measured `document.title`, read by
374
+ * `collect.mjs` via `agent-browser get title --json`.
375
+ * @param {string} [pattern] - a regex SOURCE string from the manifest (no
376
+ * delimiters, no flags), compiled with `new RegExp(pattern)`.
377
+ * @returns {{ok: true, matched: boolean} | {ok: false, reason: 'invalid-pattern'}}
378
+ * An absent or empty pattern yields `{ok: true, matched: true}`: a route
379
+ * without `title-pattern` declares no expectation, so it can never produce a
380
+ * `title-mismatch` finding. An uncompilable pattern yields
381
+ * `{ok: false, reason: 'invalid-pattern'}` — a manifest defect the caller
382
+ * must surface as such, never as a title violation of the page.
383
+ */
384
+ export function titleMatches(title, pattern) {
385
+ if (pattern === undefined || pattern === null || pattern === '') {
386
+ return { ok: true, matched: true };
387
+ }
388
+ let re;
389
+ try {
390
+ re = new RegExp(pattern);
391
+ } catch {
392
+ return { ok: false, reason: 'invalid-pattern' };
393
+ }
394
+ return { ok: true, matched: re.test(typeof title === 'string' ? title : '') };
395
+ }
396
+
397
+ /**
398
+ * Parse the stdout of `agent-browser eval`.
399
+ *
400
+ * Tolerant by design: the value is JSON, but it arrives pretty-printed over
401
+ * several lines and may be double-encoded when the page itself returned a JSON
402
+ * string. Both are handled; anything else returns the raw text so the caller can
403
+ * record the failure instead of crashing a whole route.
404
+ *
405
+ * @param {string} stdout - raw process stdout.
406
+ * @returns {{ok: true, value: unknown} | {ok: false, raw: string}}
407
+ */
408
+ export function parseEvalOutput(stdout) {
409
+ const raw = typeof stdout === 'string' ? stdout : '';
410
+ const text = raw.trim();
411
+ if (text === '') return { ok: false, raw };
412
+ let value;
413
+ try {
414
+ value = JSON.parse(text);
415
+ } catch {
416
+ return { ok: false, raw };
417
+ }
418
+ // Double-encoded case: the page returned a JSON *string*, so one parse yields
419
+ // a string that is itself JSON. Only retry when it plausibly is.
420
+ if (typeof value === 'string') {
421
+ const inner = value.trim();
422
+ if (inner.startsWith('{') || inner.startsWith('[')) {
423
+ try {
424
+ return { ok: true, value: JSON.parse(inner) };
425
+ } catch {
426
+ return { ok: true, value };
427
+ }
428
+ }
429
+ }
430
+ return { ok: true, value };
431
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * ux-grill/paths.mjs — Pure path builders for ux-grill run artifacts.
3
+ *
4
+ * Near-leaf module: node builtins (`node:path`, `node:crypto`) plus the repo's
5
+ * own `../crypto-digest-utils.mjs` (itself a `node:crypto` leaf) and
6
+ * `../test-runner/artifact-paths.mjs` (a `node:path` leaf), no I/O. All
7
+ * functions are side-effect free — callers do the mkdir. Every builder returns
8
+ * an ABSOLUTE path resolved against `repoRoot`.
9
+ *
10
+ * The run-id invariant ({@link RUN_ID_PATTERN}, `assertRunId`) is imported from
11
+ * `scripts/lib/test-runner/artifact-paths.mjs`, so the two artifact trees can
12
+ * never drift apart on what a safe run id is. Its path BUILDERS are not reused:
13
+ * they return REPO-RELATIVE paths under `.orchestrator/metrics/test-runs/` and
14
+ * take no `repoRoot`, so they are not substitutable here (PRD § 2 S2: "eigener
15
+ * Pfad-Helfer"). Its `makeRunId()` (`<pid>-<ms>`) is likewise not reused:
16
+ * a pid is not unique across hosts and ux-grill artefacts are meant to be
17
+ * committed/compared across machines — see {@link makeRunId}.
18
+ *
19
+ * Exports:
20
+ * UX_GRILL_METRICS_DIR, UX_GRILL_LEDGER, RUN_ID_PATTERN,
21
+ * makeRunId(), runDirPath(), screenshotsDir(), axeDir(), measuresDir(),
22
+ * findingsPath(), runRecordPath(), artefactStem(), screenshotName()
23
+ */
24
+
25
+ import path from 'node:path';
26
+ import { randomBytes } from 'node:crypto';
27
+
28
+ import { digestSha256Short } from '../crypto-digest-utils.mjs';
29
+ import { RUN_ID_PATTERN, assertRunId } from '../test-runner/artifact-paths.mjs';
30
+
31
+ // Re-exported so existing `ux-grill/paths.mjs` importers keep working; the
32
+ // definition lives in test-runner/artifact-paths.mjs (one run-id invariant).
33
+ export { RUN_ID_PATTERN };
34
+
35
+ /**
36
+ * Repo-relative directory holding one sub-directory per ux-grill run.
37
+ * @type {string}
38
+ */
39
+ export const UX_GRILL_METRICS_DIR = '.orchestrator/metrics/ux-grill';
40
+
41
+ /**
42
+ * Repo-relative JSONL ledger of run-records (one line per run, PRD § 4).
43
+ * @type {string}
44
+ */
45
+ export const UX_GRILL_LEDGER = '.orchestrator/metrics/ux-grill.jsonl';
46
+
47
+ const SCREENSHOT_NAME_MAX = 120;
48
+
49
+ /**
50
+ * Length of the raw-input digest every artefact name carries.
51
+ *
52
+ * The slug alone is LOSSY — `/a b` and `/a_b` both slugify to `_a_b`, and two
53
+ * routes differing only past {@link STEM_SLUG_MAX} truncate to the same prefix.
54
+ * Either collision silently overwrites one route's screenshot, axe JSON and
55
+ * measures JSON with another's, so a filed issue's `evidence` points at a
56
+ * different page than the finding describes.
57
+ *
58
+ * Named ceiling (BV-004): 6 hex chars = 24 bits. Fine for the tens-to-hundreds
59
+ * of route × viewport pairs one manifest declares; revisit if a manifest ever
60
+ * declares thousands.
61
+ * @type {number}
62
+ */
63
+ const STEM_DIGEST_LENGTH = 6;
64
+
65
+ /**
66
+ * Cap on the slug part of {@link artefactStem}, leaving room under
67
+ * {@link SCREENSHOT_NAME_MAX} for `-<digest>` and the `-<variant>` suffix
68
+ * {@link screenshotName} appends.
69
+ * @type {number}
70
+ */
71
+ const STEM_SLUG_MAX = 96;
72
+
73
+ /**
74
+ * Generate a run id of the form `<unix-ms>-<6 hex>`.
75
+ *
76
+ * Millisecond prefix keeps runs lexically sortable; the 6 random hex chars
77
+ * disambiguate two runs started in the same millisecond (and, unlike a pid,
78
+ * stay meaningful when a run directory is compared across hosts). Not a
79
+ * security token — `randomBytes` is used because it is the node-native
80
+ * source (SEC-015 prefers it over `Math.random` regardless).
81
+ *
82
+ * @returns {string} e.g. `'1757635200123-9f3a01'`
83
+ */
84
+ export function makeRunId() {
85
+ return `${Date.now()}-${randomBytes(3).toString('hex')}`;
86
+ }
87
+
88
+ /**
89
+ * Validate a repo root.
90
+ * @param {string} repoRoot
91
+ * @returns {string} the same repoRoot
92
+ * @throws {TypeError} if absent or empty
93
+ */
94
+ function assertRepoRoot(repoRoot) {
95
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
96
+ throw new TypeError('ux-grill paths: repoRoot must be a non-empty string');
97
+ }
98
+ return repoRoot;
99
+ }
100
+
101
+ /**
102
+ * Absolute path of one run's artifact directory.
103
+ * @param {string} repoRoot
104
+ * @param {string} runId
105
+ * @returns {string}
106
+ * @throws {TypeError} on an invalid repoRoot or runId (path-traversal guard)
107
+ */
108
+ export function runDirPath(repoRoot, runId) {
109
+ return path.resolve(assertRepoRoot(repoRoot), UX_GRILL_METRICS_DIR, assertRunId(runId));
110
+ }
111
+
112
+ /**
113
+ * Absolute path of the run's screenshot directory (full + fold + journey steps).
114
+ * @param {string} repoRoot
115
+ * @param {string} runId
116
+ * @returns {string}
117
+ */
118
+ export function screenshotsDir(repoRoot, runId) {
119
+ return path.join(runDirPath(repoRoot, runId), 'screenshots');
120
+ }
121
+
122
+ /**
123
+ * Absolute path of the run's axe-JSON directory (one file per route × viewport).
124
+ * @param {string} repoRoot
125
+ * @param {string} runId
126
+ * @returns {string}
127
+ */
128
+ export function axeDir(repoRoot, runId) {
129
+ return path.join(runDirPath(repoRoot, runId), 'axe');
130
+ }
131
+
132
+ /**
133
+ * Absolute path of the run's measures directory (target-size / overflow / title).
134
+ * @param {string} repoRoot
135
+ * @param {string} runId
136
+ * @returns {string}
137
+ */
138
+ export function measuresDir(repoRoot, runId) {
139
+ return path.join(runDirPath(repoRoot, runId), 'measures');
140
+ }
141
+
142
+ /**
143
+ * Absolute path of the run's `findings.jsonl` (one finding per line).
144
+ * @param {string} repoRoot
145
+ * @param {string} runId
146
+ * @returns {string}
147
+ */
148
+ export function findingsPath(repoRoot, runId) {
149
+ return path.join(runDirPath(repoRoot, runId), 'findings.jsonl');
150
+ }
151
+
152
+ /**
153
+ * Absolute path of the cross-run run-record ledger ({@link UX_GRILL_LEDGER}).
154
+ * Takes no runId — it is shared by every run of the repo.
155
+ * @param {string} repoRoot
156
+ * @returns {string}
157
+ */
158
+ export function runRecordPath(repoRoot) {
159
+ return path.resolve(assertRepoRoot(repoRoot), UX_GRILL_LEDGER);
160
+ }
161
+
162
+ /**
163
+ * Filesystem-safe slug: every character outside `[A-Za-z0-9._-]` becomes `_`,
164
+ * runs of `_` collapse to one.
165
+ * @param {string} value
166
+ * @returns {string}
167
+ */
168
+ function slugify(value) {
169
+ return value.replace(/[^A-Za-z0-9._-]/g, '_').replace(/_+/g, '_');
170
+ }
171
+
172
+ /**
173
+ * Deterministic, collision-free stem shared by a route × viewport's three
174
+ * artefacts (screenshots, axe JSON, measures JSON).
175
+ *
176
+ * Shape: `<slug>-<6 hex of sha256(raw)>`. The digest is taken over the RAW
177
+ * `route-viewport` input, never over the slug — the slug is the lossy half, so
178
+ * digesting it would reproduce exactly the collisions it exists to break (see
179
+ * {@link STEM_DIGEST_LENGTH}).
180
+ *
181
+ * Same inputs always produce the same stem, so a re-run overwrites rather than
182
+ * accumulating near-duplicates.
183
+ *
184
+ * @param {object} opts
185
+ * @param {string} opts.route - route path, e.g. `/dashboard`, or a journey name
186
+ * @param {string} opts.viewport - viewport label, e.g. `desktop` or `mobile`
187
+ * @returns {string} e.g. `'_dashboard-desktop-3f1a9c'`
188
+ * @throws {TypeError} if any part is missing or empty
189
+ */
190
+ export function artefactStem({ route, viewport } = {}) {
191
+ for (const [name, value] of [
192
+ ['route', route],
193
+ ['viewport', viewport],
194
+ ]) {
195
+ if (typeof value !== 'string' || value.length === 0) {
196
+ throw new TypeError(`artefactStem: ${name} must be a non-empty string`);
197
+ }
198
+ }
199
+ const raw = `${route}-${viewport}`;
200
+ return `${slugify(raw).slice(0, STEM_SLUG_MAX)}-${digestSha256Short(raw, { length: STEM_DIGEST_LENGTH })}`;
201
+ }
202
+
203
+ /**
204
+ * Deterministic, filesystem-safe screenshot file name:
205
+ * {@link artefactStem} plus the variant, which stays the LAST segment so a
206
+ * caller can derive the stem from the name (and the reverse: the stem is the
207
+ * name without its variant suffix).
208
+ *
209
+ * @param {object} opts
210
+ * @param {string} opts.route - route path, e.g. `/dashboard`
211
+ * @param {string} opts.viewport - viewport label, e.g. `desktop` or `mobile`
212
+ * @param {string} opts.variant - `'full'`, `'fold'` or `'step-<n>'`
213
+ * @returns {string} e.g. `'_dashboard-desktop-3f1a9c-full'` (no extension)
214
+ * @throws {TypeError} if any part is missing, or `variant` is not one of the three shapes
215
+ */
216
+ export function screenshotName({ route, viewport, variant } = {}) {
217
+ if (typeof variant !== 'string' || variant.length === 0) {
218
+ throw new TypeError('screenshotName: variant must be a non-empty string');
219
+ }
220
+ if (variant !== 'full' && variant !== 'fold' && !/^step-\d+$/.test(variant)) {
221
+ throw new TypeError(`screenshotName: variant must be 'full', 'fold' or 'step-<n>', got ${JSON.stringify(variant)}`);
222
+ }
223
+ return `${artefactStem({ route, viewport })}-${variant}`.slice(0, SCREENSHOT_NAME_MAX);
224
+ }