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,284 @@
1
+ /**
2
+ * ux-grill/pencil-coverage.mjs — S6 Pencil frame coverage (optional, gated).
3
+ *
4
+ * Spec: docs/prd/2026-09-12-ux-grill.md § 2 S6 and § 3 "Pencil-Abdeckung
5
+ * (optional)" — two acceptance criteria: (a) the run-record reports per route
6
+ * `frame: desktop|mobile|both|none` derived from the `.pen` file's top-level
7
+ * frames, (b) Pen.app unreachable → the step is recorded as
8
+ * `skipped: pencil-unavailable` and the run ends WITHOUT error.
9
+ *
10
+ * **Why this module is a pure classifier.** The Pencil MCP tools
11
+ * ({@link PENCIL_TOOL_NAMES}) are callable only from the coordinator's tool
12
+ * surface, never from a Node process — there is no client for them here. So the
13
+ * division of labour is: the COORDINATOR reads the top-level frames (an
14
+ * `execute` Get visitor at depth 1) and hands them in; this module does the
15
+ * matching and the classification, deterministically and without I/O.
16
+ *
17
+ * Leaf module: imports only `./schema.mjs` (no I/O, no side effects, no node
18
+ * builtins).
19
+ *
20
+ * Exports:
21
+ * PENCIL_TOOL_NAMES, MOBILE_WIDTH_CEILING_PX, FRAME_COVERAGE,
22
+ * classifyFrameCoverage(), pencilSkipped(), describePencilStep()
23
+ */
24
+
25
+ import { SKIP_REASONS } from './schema.mjs';
26
+
27
+ /**
28
+ * The LIVE Pencil MCP tool names — the one place they are spelled in code, so
29
+ * prose (skills, probes, references) can cite this constant instead of keeping
30
+ * a private copy that rots.
31
+ *
32
+ * Measured 2026-09-12 from this session's MCP tool listing, which exposes
33
+ * exactly: `mcp__pencil__browser`, `mcp__pencil__execute`,
34
+ * `mcp__pencil__get_app_state`, `mcp__pencil__get_style`,
35
+ * `mcp__pencil__read_skill`. The pre-2026 surface (`get_editor_state`,
36
+ * `open_document`, `batch_get`, `get_screenshot`) no longer exists — any prose
37
+ * still naming it is dead and was swept in the same change (PRD § 2 S6).
38
+ *
39
+ * Only the three tools this feature needs are named here; `get_style` and
40
+ * `read_skill` exist but have no call site in ux-grill (BV-001.1).
41
+ * @type {Readonly<{appState: string, execute: string, browser: string}>}
42
+ */
43
+ export const PENCIL_TOOL_NAMES = Object.freeze({
44
+ appState: 'mcp__pencil__get_app_state',
45
+ execute: 'mcp__pencil__execute',
46
+ browser: 'mcp__pencil__browser',
47
+ });
48
+
49
+ /**
50
+ * A top-level frame narrower than this is classified `mobile`, otherwise
51
+ * `desktop`.
52
+ *
53
+ * Named ceiling (BV-004): 600 px sits above every phone frame in common design
54
+ * kits (iPhone 15 = 393, Pixel 8 = 412, iPhone 15 Pro Max = 430) and below every
55
+ * tablet/desktop canvas (768, 1024, 1440). Revisit trigger: the first manifest
56
+ * whose design file carries a deliberate tablet frame — a 768-px frame lands in
57
+ * `desktop` here, which is a coverage answer, not a device taxonomy.
58
+ * @type {number}
59
+ */
60
+ export const MOBILE_WIDTH_CEILING_PX = 600;
61
+
62
+ /**
63
+ * The four coverage values a route can carry in the run-record (PRD § 3).
64
+ * @type {Readonly<Record<string, string>>}
65
+ */
66
+ export const FRAME_COVERAGE = Object.freeze({
67
+ DESKTOP: 'desktop',
68
+ MOBILE: 'mobile',
69
+ BOTH: 'both',
70
+ NONE: 'none',
71
+ });
72
+
73
+ const COVERAGE_VALUES = Object.freeze(Object.values(FRAME_COVERAGE));
74
+
75
+ /** `Dashboard (mobile)` / `Dashboard — desktop` → captures the device word. */
76
+ const DEVICE_SUFFIX = /[([\s—-]\s*(mobile|desktop)\s*\)?\s*$/i;
77
+
78
+ /** Regex metacharacters that make a `title-pattern` something other than a literal. */
79
+ const REGEX_METACHAR = /[.*+?^${}()|[\]\\]/;
80
+
81
+ /**
82
+ * Normalise a frame or route key for case-insensitive comparison: lowercased,
83
+ * trimmed, inner whitespace collapsed. Deliberately NOT slug-folding `-`/`_` —
84
+ * `new-document` and `new document` are different frame names, and silently
85
+ * equating them would report design coverage that does not exist.
86
+ * @param {unknown} value
87
+ * @returns {string} `''` for anything that is not a non-empty string.
88
+ */
89
+ function normalise(value) {
90
+ if (typeof value !== 'string') return '';
91
+ return value.trim().replace(/\s+/g, ' ').toLowerCase();
92
+ }
93
+
94
+ /**
95
+ * The frame name with a trailing `(mobile)` / `(desktop)` marker removed, so
96
+ * `Dashboard (mobile)` matches the route key `dashboard`.
97
+ * @param {unknown} name
98
+ * @returns {string}
99
+ */
100
+ function baseName(name) {
101
+ return normalise(typeof name === 'string' ? name.replace(DEVICE_SUFFIX, '') : '');
102
+ }
103
+
104
+ /**
105
+ * Classify ONE frame as `desktop` or `mobile`.
106
+ *
107
+ * Precedence — stated, because the two signals can disagree and a design file
108
+ * is allowed to lie about its width:
109
+ * 1. an explicit `(mobile)` / `(desktop)` suffix in the frame name;
110
+ * 2. otherwise `width < MOBILE_WIDTH_CEILING_PX` → mobile, else desktop;
111
+ * 3. no suffix and no usable width → `desktop`, because a `.pen` top-level
112
+ * frame defaults to the desktop canvas, and a mobile frame is in practice
113
+ * either narrower than the ceiling or suffix-labelled.
114
+ * @param {{name?: unknown, width?: unknown}} frame
115
+ * @returns {'desktop'|'mobile'}
116
+ */
117
+ function deviceOfFrame(frame) {
118
+ const suffix = typeof frame?.name === 'string' ? DEVICE_SUFFIX.exec(frame.name) : null;
119
+ if (suffix) return suffix[1].toLowerCase() === 'mobile' ? FRAME_COVERAGE.MOBILE : FRAME_COVERAGE.DESKTOP;
120
+ const width = frame?.width;
121
+ if (typeof width === 'number' && Number.isFinite(width) && width > 0) {
122
+ return width < MOBILE_WIDTH_CEILING_PX ? FRAME_COVERAGE.MOBILE : FRAME_COVERAGE.DESKTOP;
123
+ }
124
+ return FRAME_COVERAGE.DESKTOP;
125
+ }
126
+
127
+ /**
128
+ * Fold a set of matched frames into one coverage value.
129
+ * @param {Array<object>} matched
130
+ * @returns {string} a value of {@link FRAME_COVERAGE}.
131
+ */
132
+ function coverageOf(matched) {
133
+ if (matched.length === 0) return FRAME_COVERAGE.NONE;
134
+ let hasDesktop = false;
135
+ let hasMobile = false;
136
+ for (const frame of matched) {
137
+ if (deviceOfFrame(frame) === FRAME_COVERAGE.MOBILE) hasMobile = true;
138
+ else hasDesktop = true;
139
+ }
140
+ if (hasDesktop && hasMobile) return FRAME_COVERAGE.BOTH;
141
+ return hasMobile ? FRAME_COVERAGE.MOBILE : FRAME_COVERAGE.DESKTOP;
142
+ }
143
+
144
+ /**
145
+ * The comparison keys a route offers for name matching: the last segment of
146
+ * `path`, plus `title-pattern` when it is a plain literal.
147
+ *
148
+ * A `title-pattern` is a REGEX in the manifest (PRD § 2 S1). Only an
149
+ * anchor-only pattern (`^Dashboard`, `Dashboard$`) is usable as a literal name;
150
+ * anything carrying further metacharacters is skipped rather than matched
151
+ * approximately — a wrong match reports design coverage that does not exist.
152
+ * @param {{path?: unknown, ['title-pattern']?: unknown}} route
153
+ * @returns {string[]} normalised, de-duplicated, never containing `''`.
154
+ */
155
+ function routeKeys(route) {
156
+ const keys = [];
157
+ if (typeof route?.path === 'string') {
158
+ const segments = route.path.split('/').filter((s) => s.length > 0);
159
+ keys.push(normalise(segments.length > 0 ? segments[segments.length - 1] : route.path));
160
+ }
161
+ const pattern = route?.['title-pattern'];
162
+ if (typeof pattern === 'string') {
163
+ const stripped = pattern.replace(/^\^/, '').replace(/\$$/, '');
164
+ if (stripped.length > 0 && !REGEX_METACHAR.test(stripped)) keys.push(normalise(stripped));
165
+ }
166
+ return [...new Set(keys.filter((k) => k.length > 0))];
167
+ }
168
+
169
+ /**
170
+ * Classify per-route design-frame coverage (PRD § 2 S6, AC "Pencil-Abdeckung").
171
+ *
172
+ * Matching precedence, highest first:
173
+ * 1. **`routes[].frame` as a coverage literal** — `desktop|mobile|both|none`
174
+ * written straight into the manifest (the shape
175
+ * `templates/_shared/ux-manifest.template.md` ships) is taken as the
176
+ * answer; `matchedBy: 'routes[].frame'`.
177
+ * 2. **`routes[].frame` as a reference** — any other value is a frame `id`
178
+ * (exact) or frame `name` (case-insensitive, `(mobile)`/`(desktop)` suffix
179
+ * ignored). Resolving to nothing yields `none` with
180
+ * `matchedBy: 'routes[].frame'`: the declaration WAS honoured, the frame is
181
+ * absent — distinguishable from a route that declared nothing.
182
+ * 3. **Name equality** — case-insensitive equality between a
183
+ * {@link routeKeys} key and a frame's base name; `matchedBy: 'name'`.
184
+ * 4. Otherwise `none` with `matchedBy: null`.
185
+ *
186
+ * Pure: no I/O, no mutation of the inputs.
187
+ *
188
+ * @param {object} opts
189
+ * @param {Array<{path: string, frame?: string, ['title-pattern']?: string}>} opts.routes
190
+ * the manifest's `routes[]`.
191
+ * @param {Array<{id?: string, name?: string, width?: number, height?: number}>} [opts.frames]
192
+ * the `.pen` file's TOP-LEVEL frames, as obtained by the coordinator via
193
+ * `PENCIL_TOOL_NAMES.execute` with a depth-1 Get visitor. Defaults to `[]`,
194
+ * which classifies every route as `none` — the honest answer for "the design
195
+ * file has no top-level frames", NOT the same thing as a skipped step (see
196
+ * {@link pencilSkipped}).
197
+ * @returns {Array<{route: string, frame: string, matchedBy: string|null}>} one
198
+ * entry per route, input order preserved. `{route, frame}` is exactly the
199
+ * `pencilCoverage` shape `makeRunRecord()` accepts; `matchedBy` is extra
200
+ * provenance for the report and is ignored by the run-record.
201
+ * @throws {TypeError} on any invalid input — no silent defaults.
202
+ */
203
+ export function classifyFrameCoverage({ routes, frames = [] } = {}) {
204
+ if (!Array.isArray(routes)) {
205
+ throw new TypeError('classifyFrameCoverage: routes must be an array');
206
+ }
207
+ if (!Array.isArray(frames)) {
208
+ throw new TypeError('classifyFrameCoverage: frames must be an array when provided');
209
+ }
210
+
211
+ return routes.map((route, index) => {
212
+ if (route === null || typeof route !== 'object' || Array.isArray(route)) {
213
+ throw new TypeError(`classifyFrameCoverage: routes[${index}] must be a plain object`);
214
+ }
215
+ if (typeof route.path !== 'string' || route.path.length === 0) {
216
+ throw new TypeError(`classifyFrameCoverage: routes[${index}].path must be a non-empty string`);
217
+ }
218
+
219
+ const declared = typeof route.frame === 'string' ? route.frame.trim() : '';
220
+
221
+ if (declared.length > 0) {
222
+ if (COVERAGE_VALUES.includes(declared.toLowerCase())) {
223
+ return { route: route.path, frame: declared.toLowerCase(), matchedBy: 'routes[].frame' };
224
+ }
225
+ const wanted = normalise(declared);
226
+ const matched = frames.filter(
227
+ (frame) => frame?.id === declared || baseName(frame?.name) === wanted || normalise(frame?.name) === wanted,
228
+ );
229
+ return { route: route.path, frame: coverageOf(matched), matchedBy: 'routes[].frame' };
230
+ }
231
+
232
+ const keys = routeKeys(route);
233
+ const matched = frames.filter((frame) => {
234
+ const name = baseName(frame?.name);
235
+ return name.length > 0 && keys.includes(name);
236
+ });
237
+ if (matched.length === 0) return { route: route.path, frame: FRAME_COVERAGE.NONE, matchedBy: null };
238
+ return { route: route.path, frame: coverageOf(matched), matchedBy: 'name' };
239
+ });
240
+ }
241
+
242
+ /**
243
+ * The `skipped[]` record for an unavailable Pencil step (PRD § 3 AC 2).
244
+ *
245
+ * Returned rather than thrown on purpose: the run must end without error when
246
+ * Pen.app or its MCP surface is unreachable, and a skip that is RECORDED is
247
+ * what keeps "no coverage measured" from reading as "no design frames".
248
+ *
249
+ * @param {string} [reason] - a value of `SKIP_REASONS`; defaults to
250
+ * `SKIP_REASONS.PENCIL_UNAVAILABLE`.
251
+ * @returns {{what: string, reason: string}} accepted by `makeRunRecord({skipped})`.
252
+ * @throws {TypeError} if `reason` is not a known skip reason.
253
+ */
254
+ export function pencilSkipped(reason = SKIP_REASONS.PENCIL_UNAVAILABLE) {
255
+ if (!Object.values(SKIP_REASONS).includes(reason)) {
256
+ throw new TypeError(`pencilSkipped: unknown skip reason ${String(reason)}`);
257
+ }
258
+ return { what: 'pencil', reason };
259
+ }
260
+
261
+ /**
262
+ * Whether the optional Pencil step runs at all, per the manifest (PRD § 2 S6:
263
+ * "if `pencil.file` is set and Pen.app is reachable"). This answers only the
264
+ * first half — reachability is the coordinator's to determine, by calling
265
+ * `PENCIL_TOOL_NAMES.appState`.
266
+ *
267
+ * @param {object} opts
268
+ * @param {{frontmatter?: object}|object} opts.manifest - a `loadManifest()` /
269
+ * `parseManifest()` result, or a bare frontmatter object (both accepted: a
270
+ * bare frontmatter would otherwise silently answer `enabled: false`).
271
+ * @returns {{enabled: boolean, file: string|null}} `file` is the manifest value
272
+ * verbatim — a repo-relative path that the caller resolves; this module never
273
+ * touches the filesystem.
274
+ */
275
+ export function describePencilStep({ manifest } = {}) {
276
+ const source = manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? manifest : {};
277
+ const frontmatter =
278
+ source.frontmatter && typeof source.frontmatter === 'object' && !Array.isArray(source.frontmatter)
279
+ ? source.frontmatter
280
+ : source;
281
+ const file = frontmatter?.pencil?.file;
282
+ if (typeof file !== 'string' || file.trim().length === 0) return { enabled: false, file: null };
283
+ return { enabled: true, file: file.trim() };
284
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * ux-grill/reconcile.mjs — route Stufe-1 findings to the issue tracker
3
+ * (PRD § 2 S5, § 3 "Reconcile & Regression" AC 2).
4
+ *
5
+ * This module names NO tracker binary. Every tracker interaction goes
6
+ * through `scripts/lib/test-runner/issue-reconcile.mjs`, which owns the binary
7
+ * allowlist, the `execFile`-not-shell rule (ADR-364 §C5), the arg-boundary
8
+ * validation and the 64 KiB body cap. Grepping this file for the binary name
9
+ * must return zero — that is the invariant, not a coincidence, and it is why the
10
+ * budget gate below reconstructs its command string WITHOUT the binary (the
11
+ * exemption regexes in `issue-budget.mjs` match labels and title templates only,
12
+ * never the program name — measured 2026-09-12 against its `EXEMPT_RULES`).
13
+ *
14
+ * SEVERITY ROUTING (`skills/test-runner/SKILL.md` § Severity Routing / § Batched
15
+ * AUQ Triage, mechanised here):
16
+ * - `high` (and `critical`, which ux-grill Stufe 1 never emits) → auto-routed
17
+ * through `triageDecision` → `createFinding` / `updateFinding`.
18
+ * - `medium` / `low` → NEVER created here. They are returned as `batch` for the
19
+ * coordinator's ONE bundled AUQ. An agent cannot call `AskUserQuestion`, so
20
+ * the decision has to travel upward as data.
21
+ * - `provisional: true` → `batch`, whatever its severity. A dev-build geometry
22
+ * number is a measurement taken on a basis the PRD declares invalid
23
+ * (`schema.mjs` § makeFinding), so filing it as a product defect would put a
24
+ * bundler artefact in the tracker under a `priority::high` label.
25
+ *
26
+ * ISSUE BUDGET. `createFinding` spawns the tracker CLI via `execFile` with
27
+ * `shell: false`, so the `PreToolUse`/Bash hook `hooks/pre-bash-issue-budget.mjs`
28
+ * NEVER sees it —
29
+ * exactly the programmatic hole `scripts/lib/spiral-carryover.mjs` closes by
30
+ * calling `chargeIssueBudget` itself. This module does the same, with the same
31
+ * counting contract and the same ledger: there is one counter, not a second one.
32
+ * A blocked creation is not dropped — it moves to `batch` (so the operator still
33
+ * sees it) and increments `budgetStops`.
34
+ *
35
+ * `dryRun` DEFAULTS TO TRUE. The skill flips it explicitly after the operator has
36
+ * seen the plan. A reconcile helper whose default writes to a live tracker is one
37
+ * mistaken import away from filing issues nobody asked for.
38
+ *
39
+ * Exports:
40
+ * UX_GRILL_LABELS, buildIssueTitle, buildIssueBody, reconcileFindings
41
+ */
42
+
43
+ import {
44
+ createFinding as defaultCreateFinding,
45
+ listExistingFindings as defaultListExisting,
46
+ triageDecision,
47
+ updateFinding as defaultUpdateFinding,
48
+ } from '../test-runner/issue-reconcile.mjs';
49
+ import { chargeIssueBudget } from '../issue-budget.mjs';
50
+
51
+ /**
52
+ * Labels applied to every auto-created ux-grill issue.
53
+ *
54
+ * Spelling follows `skills/gitlab-ops/SKILL.md` § Label Taxonomy EXACTLY:
55
+ * `from:` is single-colon, `priority::` is double-colon (measured there:
56
+ * 416 `priority::` against 249 `priority:` on the instance). Only `high`
57
+ * findings are auto-created, so the priority label is constant.
58
+ * @type {string}
59
+ */
60
+ export const UX_GRILL_LABELS = 'from:ux-grill,priority::high';
61
+
62
+ /** Label used to QUERY already-filed ux-grill issues (the `from:` axis alone). */
63
+ export const UX_GRILL_QUERY_LABEL = 'from:ux-grill';
64
+
65
+ /** GitLab issue titles are capped well above this; 100 keeps a list readable. */
66
+ const TITLE_MAX_LENGTH = 100;
67
+
68
+ /** Severities that are auto-routed to the tracker. `critical` is future-proofing. */
69
+ const AUTO_SEVERITIES = Object.freeze(['critical', 'high']);
70
+
71
+ /**
72
+ * Neutralise a fingerprint-sentinel literal in free text (#388 SEC-IR-MED-1).
73
+ *
74
+ * `issue-reconcile.mjs` applies this to `recommendation` only, inside
75
+ * `reconcileFinding` — a body built HERE and handed to `createFinding` is not
76
+ * sanitised by anything, so a finding `message` echoing page text could forge
77
+ * the authoritative `**Fingerprint:**` line and make the next run's
78
+ * `triageDecision` dedup against the wrong issue.
79
+ *
80
+ * @param {unknown} text
81
+ * @returns {string}
82
+ */
83
+ function sanitizeSentinel(text) {
84
+ return String(text ?? '').replace(/\*\*Fingerprint:\*\*/gi, '__Fingerprint__');
85
+ }
86
+
87
+ /**
88
+ * Strip the characters `createFinding` rejects at the argv boundary, so a
89
+ * multi-line page title in a finding message cannot turn into a VALIDATION
90
+ * failure for the whole reconcile pass.
91
+ *
92
+ * @param {unknown} text
93
+ * @returns {string}
94
+ */
95
+ function oneLine(text) {
96
+ return sanitizeSentinel(text).replace(/[\n\r\0]+/g, ' ').trim();
97
+ }
98
+
99
+ /**
100
+ * `route|viewport` — the first two locator segments (`schema.mjs` § makeFinding).
101
+ * @param {string} locator
102
+ * @returns {string}
103
+ */
104
+ function locatorScope(locator) {
105
+ return String(locator ?? '').split('|').slice(0, 2).join('|');
106
+ }
107
+
108
+ /**
109
+ * Build the issue title: `[ux-grill] <checkId> — <route|viewport>`, capped at 100.
110
+ *
111
+ * The title is a `triageDecision` input (fuzzy Levenshtein ≤ 2 match), so it must
112
+ * stay stable across runs for one finding — hence checkId + scope and NOT the
113
+ * measured value, which changes with every layout tweak and would make every run
114
+ * look like a new issue.
115
+ *
116
+ * @param {{checkId: string, locator: string}} finding
117
+ * @returns {string}
118
+ */
119
+ export function buildIssueTitle(finding) {
120
+ const title = `[ux-grill] ${oneLine(finding?.checkId)} — ${oneLine(locatorScope(finding?.locator))}`;
121
+ return title.slice(0, TITLE_MAX_LENGTH);
122
+ }
123
+
124
+ /**
125
+ * Build the issue body.
126
+ *
127
+ * Carries the authoritative `**Fingerprint:** \`<fp>\`` sentinel in the exact
128
+ * shape `issue-reconcile.mjs`'s `extractFingerprintFromBody` matches —
129
+ * `createFinding` does NOT append it (verified 2026-09-12: its `args` are
130
+ * `['issue','create','--title',…,'--description',body]`, the `fingerprint`
131
+ * parameter is validated and otherwise unused). Without this line the next run
132
+ * cannot dedup and files the same finding again.
133
+ *
134
+ * WHAT MUST NEVER BE IN HERE: an env value (the manifest resolves credentials by
135
+ * ENV NAME and `collect.mjs` never puts them in a finding) and an absolute host
136
+ * path. Evidence paths are already run-dir-relative — `collect()` stores
137
+ * `path.relative(runDir, …)` — and {@link reconcileFindings} rejects a finding
138
+ * carrying an absolute one rather than publishing an operator's home directory
139
+ * to a tracker.
140
+ *
141
+ * @param {object} finding - a `makeFinding` record
142
+ * @param {object} [context]
143
+ * @param {string} [context.runId]
144
+ * @param {string} [context.rubricHash]
145
+ * @returns {string}
146
+ */
147
+ export function buildIssueBody(finding, { runId, rubricHash } = {}) {
148
+ const evidence = finding?.evidence ?? {};
149
+ const evidenceLines = Object.entries(evidence)
150
+ .filter(([, value]) => typeof value === 'string' || Array.isArray(value))
151
+ .map(([key, value]) => `- \`${key}\`: ${oneLine(Array.isArray(value) ? value.join(', ') : value)}`);
152
+
153
+ return [
154
+ oneLine(finding?.message) || `${oneLine(finding?.checkId)} at ${oneLine(finding?.locator)}`,
155
+ '',
156
+ `**Fingerprint:** \`${oneLine(finding?.fingerprint)}\``,
157
+ `**Severity:** ${oneLine(finding?.severity)}`,
158
+ `**Check:** ${oneLine(finding?.checkId)}`,
159
+ `**Locator:** \`${oneLine(finding?.locator)}\``,
160
+ `**Provisional:** ${finding?.provisional === true}`,
161
+ runId ? `**Run:** \`${oneLine(runId)}\`` : null,
162
+ rubricHash ? `**Rubric hash:** \`${oneLine(rubricHash)}\`` : null,
163
+ evidenceLines.length > 0 ? '' : null,
164
+ evidenceLines.length > 0 ? '**Evidence** (paths are relative to the run directory):' : null,
165
+ ...evidenceLines,
166
+ ]
167
+ .filter((line) => line !== null)
168
+ .join('\n');
169
+ }
170
+
171
+ /**
172
+ * Collect every string in a finding's `evidence` that looks like an absolute
173
+ * host path (POSIX `/…` or Windows `C:\…`).
174
+ *
175
+ * @param {object} finding
176
+ * @returns {string[]}
177
+ */
178
+ function absoluteEvidencePaths(finding) {
179
+ const out = [];
180
+ for (const value of Object.values(finding?.evidence ?? {})) {
181
+ for (const entry of Array.isArray(value) ? value : [value]) {
182
+ if (typeof entry !== 'string') continue;
183
+ if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) out.push(entry);
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * Default budget gate — the SAME `chargeIssueBudget` decision the Bash hook and
191
+ * `spiral-carryover.mjs` apply, against the same per-session ledger.
192
+ *
193
+ * @param {{repoRoot: string, sessionId: string|null, title: string}} opts
194
+ * @returns {{decision: string, count: number, max: number, reason?: string|null}}
195
+ */
196
+ function defaultBudget({ repoRoot, sessionId, title }) {
197
+ // classifyExemption() reads a COMMAND string; reconstruct the argv this
198
+ // creation is about to become so exemption classification (`priority::critical`,
199
+ // carryover) sees what it would have seen on the shell path.
200
+ const command = `issue create --title ${JSON.stringify(title)} --label ${UX_GRILL_LABELS}`;
201
+ return chargeIssueBudget({ repoRoot, sessionId, command, title });
202
+ }
203
+
204
+ /**
205
+ * Route a run's findings to the issue tracker.
206
+ *
207
+ * @param {object} opts
208
+ * @param {string} opts.repoRoot - absolute repo root (ledger root AND the cwd
209
+ * `issue-reconcile.mjs` auto-detects the `--repo` spec from)
210
+ * @param {string} [opts.runId] - stamped into every body
211
+ * @param {string} [opts.rubricHash] - stamped into every body
212
+ * @param {Array<object>} opts.findings - `makeFinding` records
213
+ * @param {string} [opts.project] - explicit GitLab project path; omitted → the
214
+ * spine auto-detects it from `repoRoot`'s git remotes
215
+ * @param {boolean} [opts.dryRun=true] - TRUE by default; see module header
216
+ * @param {Function} [opts.execFile] - DI seam handed straight to the spine
217
+ * @param {string|null} [opts.sessionId=null] - issue-budget accounting key
218
+ * @param {Function} [opts.budget] - budget gate; defaults to `chargeIssueBudget`
219
+ * @param {Function} [opts.listExisting=listExistingFindings]
220
+ * @param {Function} [opts.create=createFinding]
221
+ * @param {Function} [opts.update=updateFinding]
222
+ * @returns {Promise<{
223
+ * created: object[], updated: object[], ignored: object[], batch: object[],
224
+ * errors: object[], budgetStops: number, dryRun: boolean
225
+ * }>} `batch` is the medium/low + provisional + budget-stopped set the
226
+ * coordinator turns into ONE AUQ. `errors` carries per-finding refusals and a
227
+ * tracker-query failure (`code: 'list-failed'`) — a failed query means the
228
+ * dedup set is unknown, so NOTHING is created and every auto finding moves to
229
+ * `batch` rather than risking duplicates.
230
+ * @throws {TypeError} on a missing `repoRoot` or a non-array `findings`
231
+ */
232
+ export async function reconcileFindings({
233
+ repoRoot,
234
+ runId,
235
+ rubricHash,
236
+ findings,
237
+ project,
238
+ dryRun = true,
239
+ execFile,
240
+ sessionId = null,
241
+ budget = defaultBudget,
242
+ listExisting = defaultListExisting,
243
+ create = defaultCreateFinding,
244
+ update = defaultUpdateFinding,
245
+ } = {}) {
246
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
247
+ throw new TypeError('reconcileFindings: repoRoot must be a non-empty string');
248
+ }
249
+ if (!Array.isArray(findings)) {
250
+ throw new TypeError('reconcileFindings: findings must be an array');
251
+ }
252
+
253
+ const created = [];
254
+ const updated = [];
255
+ const ignored = [];
256
+ const batch = [];
257
+ const errors = [];
258
+ let budgetStops = 0;
259
+
260
+ const auto = [];
261
+ for (const finding of findings) {
262
+ const isAuto = AUTO_SEVERITIES.includes(finding?.severity) && finding?.provisional !== true;
263
+ (isAuto ? auto : batch).push(finding);
264
+ }
265
+ // Deterministic order: the budget cap decides WHICH findings get filed, so the
266
+ // order must not depend on how the findings array happened to be built.
267
+ auto.sort((a, b) => (a?.fingerprint < b?.fingerprint ? -1 : a?.fingerprint > b?.fingerprint ? 1 : 0));
268
+
269
+ if (auto.length === 0) {
270
+ return { created, updated, ignored, batch, errors, budgetStops, dryRun };
271
+ }
272
+
273
+ const existing = await listExisting({
274
+ project,
275
+ label: UX_GRILL_QUERY_LABEL,
276
+ repoRoot,
277
+ execFile,
278
+ });
279
+ if (!existing?.ok) {
280
+ errors.push({ code: 'list-failed', error: existing?.error ?? null });
281
+ batch.push(...auto);
282
+ return { created, updated, ignored, batch, errors, budgetStops, dryRun };
283
+ }
284
+ const candidates = existing.issues ?? [];
285
+
286
+ for (const finding of auto) {
287
+ const leaked = absoluteEvidencePaths(finding);
288
+ if (leaked.length > 0) {
289
+ errors.push({
290
+ code: 'absolute-evidence-path',
291
+ fingerprint: finding?.fingerprint ?? null,
292
+ paths: leaked,
293
+ });
294
+ batch.push(finding);
295
+ continue;
296
+ }
297
+
298
+ const title = buildIssueTitle(finding);
299
+ const body = buildIssueBody(finding, { runId, rubricHash });
300
+ const decision = triageDecision({ fingerprint: finding?.fingerprint, title }, candidates);
301
+
302
+ if (decision.action === 'ignore') {
303
+ ignored.push({ finding, decision });
304
+ continue;
305
+ }
306
+
307
+ if (decision.action === 'update') {
308
+ const result = await update({
309
+ project,
310
+ iid: decision.target,
311
+ comment: `Still present in ux-grill run \`${oneLine(runId)}\`.\n\n${body}`,
312
+ dryRun,
313
+ execFile,
314
+ repoRoot,
315
+ });
316
+ if (result?.ok) updated.push({ finding, decision, iid: decision.target, result });
317
+ else errors.push({ code: 'update-failed', fingerprint: finding?.fingerprint ?? null, error: result?.error ?? null });
318
+ continue;
319
+ }
320
+
321
+ // create — the only action the budget bounds.
322
+ const verdict = budget({ repoRoot, sessionId, title, finding });
323
+ if (verdict?.decision === 'block') {
324
+ budgetStops += 1;
325
+ batch.push(finding);
326
+ continue;
327
+ }
328
+
329
+ const result = await create({
330
+ project,
331
+ fingerprint: finding?.fingerprint,
332
+ title,
333
+ body,
334
+ labels: UX_GRILL_LABELS,
335
+ dryRun,
336
+ execFile,
337
+ repoRoot,
338
+ });
339
+ if (result?.ok) created.push({ finding, decision, result, budget: verdict?.decision ?? null });
340
+ else errors.push({ code: 'create-failed', fingerprint: finding?.fingerprint ?? null, error: result?.error ?? null });
341
+ }
342
+
343
+ return { created, updated, ignored, batch, errors, budgetStops, dryRun };
344
+ }