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,618 @@
1
+ /**
2
+ * ux-grill/manifest.mjs — Manifest parsing, loopback guards, env-name
3
+ * resolution and the bootstrap writer for `/ux-grill`.
4
+ *
5
+ * Spec: docs/prd/2026-09-12-ux-grill.md § 2 S1 and § 3 "Manifest, Bootstrap &
6
+ * Sicherheit".
7
+ *
8
+ * Near-leaf module: imports `node:fs`, `node:path`, `js-yaml` and
9
+ * `../crypto-digest-utils.mjs` only. It NEVER calls `process.exit` — exit codes
10
+ * belong to the CLI layer, which maps {@link ManifestError} `code` values onto
11
+ * them (`base-url-not-loopback` / `guarded-env-not-loopback` → exit 2).
12
+ *
13
+ * Secret discipline (PRD § 3 AC 3/AC 4): the manifest carries env NAMES only.
14
+ * No function here puts an env VALUE into a message, an error or a log line —
15
+ * `guarded env <NAME> must be loopback` names the variable, never its content.
16
+ *
17
+ * Exports:
18
+ * ManifestError, DEFAULT_MANIFEST_PATH, DEFAULT_VIEWPORTS, LOOPBACK_HOSTS,
19
+ * parseManifest(), isLoopbackUrl(), assertLoopbackBaseUrl(), readEnvFile(),
20
+ * assertGuardedEnvsLoopback(), resolvePersonaCredentials(), manifestHash(),
21
+ * loadManifest(), buildBootstrapManifest(), writeBootstrapManifest(),
22
+ * UX_GRILL_ARTEFACT_IGNORE
23
+ */
24
+
25
+ import { appendFileSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
26
+ import path from 'node:path';
27
+ import yaml from 'js-yaml';
28
+ import { digestSha256 } from '../crypto-digest-utils.mjs';
29
+
30
+ /**
31
+ * Repo-relative default location of a target repo's ux-manifest.
32
+ * @type {string}
33
+ */
34
+ export const DEFAULT_MANIFEST_PATH = '.orchestrator/ux-manifest.md';
35
+
36
+ /**
37
+ * `.gitignore` line every ux-grill target repo needs, ensured by
38
+ * {@link writeBootstrapManifest}.
39
+ *
40
+ * A run writes screenshots, axe JSON and measures JSON under
41
+ * `.orchestrator/metrics/ux-grill/<runId>/`, and a journey screenshot is taken
42
+ * after EVERY step — including the one right after
43
+ * `fill #pw ${LOGIN_PASSWORD}`, which renders the e-mail by construction and
44
+ * the password whenever the app has a reveal toggle or a `type=text` field.
45
+ * Nothing else in the target repo knows to ignore that path, and the PRD's
46
+ * secret-leak acceptance test is a grep, which cannot see a PNG.
47
+ * @type {string}
48
+ */
49
+ export const UX_GRILL_ARTEFACT_IGNORE = '.orchestrator/metrics/ux-grill/';
50
+
51
+ /**
52
+ * Viewports used when the manifest does not declare any (PRD § 2 S1).
53
+ * Frozen: a caller mutating this would change every later parse.
54
+ * @type {ReadonlyArray<{name: string, viewport?: string, device?: string}>}
55
+ */
56
+ export const DEFAULT_VIEWPORTS = Object.freeze([
57
+ Object.freeze({ name: 'desktop', viewport: '1440x900' }),
58
+ Object.freeze({ name: 'mobile', device: 'iPhone 15' }),
59
+ ]);
60
+
61
+ /**
62
+ * Hostnames accepted as loopback. `new URL('http://[::1]/').hostname` yields
63
+ * `[::1]` WITH the brackets, so both spellings are listed.
64
+ * @type {readonly string[]}
65
+ */
66
+ export const LOOPBACK_HOSTS = Object.freeze(['127.0.0.1', 'localhost', '[::1]', '::1']);
67
+
68
+ /**
69
+ * Schemes a loopback URL may carry.
70
+ *
71
+ * Without this, `foo://localhost/` parses, its hostname IS loopback — and its
72
+ * `URL.origin` is the string `'null'` (every non-special scheme has an opaque
73
+ * origin). `collect.mjs` `resolveWithinOrigin()` then compares `'null'` with
74
+ * `'null'` and EVERY absolute off-origin location passes the same-origin gate
75
+ * that exists to keep a substituted password inside the declared app.
76
+ * @type {readonly string[]}
77
+ */
78
+ export const LOOPBACK_PROTOCOLS = Object.freeze(['http:', 'https:']);
79
+
80
+ const VALID_BUILDS = Object.freeze(['dev', 'prod']);
81
+
82
+ /**
83
+ * Frontmatter block, tolerating leading whitespace and leading HTML comments
84
+ * (the template carries a plugin-provenance comment as its first line, and a
85
+ * bootstrap-written manifest must round-trip through the same parser).
86
+ */
87
+ const FRONTMATTER_RE = /^\s*(?:<!--[\s\S]*?-->\s*)*---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/;
88
+
89
+ /**
90
+ * A manifest-level failure carrying a machine-readable `code`.
91
+ *
92
+ * The CLI layer maps codes onto exit codes; nothing here exits. No code path
93
+ * ever places an env VALUE into `message`.
94
+ */
95
+ export class ManifestError extends Error {
96
+ /**
97
+ * @param {string} code - stable machine-readable code, e.g. `'base-url-not-loopback'`
98
+ * @param {string} message - human-readable, secret-free
99
+ */
100
+ constructor(code, message) {
101
+ super(message);
102
+ this.name = 'ManifestError';
103
+ /** @type {string} */
104
+ this.code = code;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Parse a ux-manifest into its frontmatter object and its Markdown body.
110
+ *
111
+ * Frontmatter is parsed with js-yaml's `CORE_SCHEMA` (no custom types, no
112
+ * implicit date/timestamp coercion) rather than line regexes — a line-oriented
113
+ * validator is structurally blind to unparseable YAML.
114
+ *
115
+ * Absent optional collections are DEFAULTED here (empty arrays,
116
+ * {@link DEFAULT_VIEWPORTS}), so every downstream consumer sees one shape.
117
+ *
118
+ * @param {string} text - full manifest file contents
119
+ * @returns {{frontmatter: object, body: string}}
120
+ * @throws {ManifestError} `manifest-not-string`, `frontmatter-missing`,
121
+ * `frontmatter-unparseable`, `frontmatter-not-object`, `base-url-missing`,
122
+ * `build-missing`, `build-invalid`
123
+ */
124
+ export function parseManifest(text) {
125
+ if (typeof text !== 'string') {
126
+ throw new ManifestError('manifest-not-string', 'manifest contents must be a string');
127
+ }
128
+ const match = FRONTMATTER_RE.exec(text);
129
+ if (!match) {
130
+ throw new ManifestError('frontmatter-missing', 'manifest has no YAML frontmatter block (--- … ---)');
131
+ }
132
+
133
+ let parsed;
134
+ try {
135
+ parsed = yaml.load(match[1], { schema: yaml.CORE_SCHEMA });
136
+ } catch (err) {
137
+ throw new ManifestError('frontmatter-unparseable', `manifest frontmatter is not valid YAML: ${err.message}`);
138
+ }
139
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
140
+ throw new ManifestError('frontmatter-not-object', 'manifest frontmatter must be a YAML mapping');
141
+ }
142
+
143
+ const baseUrl = parsed['base-url'];
144
+ if (typeof baseUrl !== 'string' || baseUrl.length === 0) {
145
+ throw new ManifestError('base-url-missing', 'manifest frontmatter must set base-url');
146
+ }
147
+ const build = parsed.build;
148
+ if (build === undefined || build === null || build === '') {
149
+ throw new ManifestError('build-missing', 'manifest frontmatter must set build (dev|prod)');
150
+ }
151
+ if (!VALID_BUILDS.includes(build)) {
152
+ throw new ManifestError('build-invalid', `manifest build must be one of ${VALID_BUILDS.join('|')}`);
153
+ }
154
+
155
+ const frontmatter = {
156
+ ...parsed,
157
+ 'base-url': baseUrl,
158
+ build,
159
+ 'guarded-url-envs': asArray(parsed['guarded-url-envs']),
160
+ personas: asArray(parsed.personas),
161
+ routes: asArray(parsed.routes),
162
+ journeys: asArray(parsed.journeys),
163
+ viewports: asArray(parsed.viewports).length > 0 ? asArray(parsed.viewports) : DEFAULT_VIEWPORTS.map((v) => ({ ...v })),
164
+ };
165
+
166
+ return { frontmatter, body: match[2] ?? '' };
167
+ }
168
+
169
+ /**
170
+ * @param {unknown} value
171
+ * @returns {any[]} `value` when it is an array, otherwise an empty array.
172
+ */
173
+ function asArray(value) {
174
+ return Array.isArray(value) ? value : [];
175
+ }
176
+
177
+ /**
178
+ * Is `url` an absolute URL pointing at a loopback host?
179
+ *
180
+ * Loopback means BOTH: a {@link LOOPBACK_HOSTS} hostname AND a
181
+ * {@link LOOPBACK_PROTOCOLS} scheme. The scheme half is not cosmetic — see
182
+ * {@link LOOPBACK_PROTOCOLS} for the opaque-origin bypass it closes. It also
183
+ * drops `ws://localhost`, which no ux-grill field has ever named.
184
+ *
185
+ * @param {unknown} url
186
+ * @returns {boolean} `false` for anything that does not parse as a URL —
187
+ * an unparseable value is never treated as safe.
188
+ */
189
+ export function isLoopbackUrl(url) {
190
+ if (typeof url !== 'string' || url.length === 0) return false;
191
+ let parsed;
192
+ try {
193
+ parsed = new URL(url);
194
+ } catch {
195
+ return false;
196
+ }
197
+ if (!LOOPBACK_PROTOCOLS.includes(parsed.protocol)) return false;
198
+ return LOOPBACK_HOSTS.includes(parsed.hostname);
199
+ }
200
+
201
+ /**
202
+ * Assert the manifest's `base-url` is loopback (PRD § 3 AC 2).
203
+ *
204
+ * @param {{['base-url']?: unknown}} frontmatter
205
+ * @returns {void}
206
+ * @throws {ManifestError} code `base-url-not-loopback`, message exactly
207
+ * `base-url must be loopback` — the CLI maps this code to exit 2.
208
+ */
209
+ export function assertLoopbackBaseUrl(frontmatter) {
210
+ const baseUrl = frontmatter?.['base-url'];
211
+ if (!isLoopbackUrl(baseUrl)) {
212
+ throw new ManifestError('base-url-not-loopback', 'base-url must be loopback');
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Read a `KEY=VALUE` env file into a Map.
218
+ *
219
+ * Convention: split on the FIRST `=` (a value may contain further `=`), skip
220
+ * blank lines and `#` comments, tolerate an `export ` prefix, and strip one
221
+ * layer of matching surrounding quotes.
222
+ *
223
+ * No helper for this existed in `scripts/lib/` (measured 2026-09-12:
224
+ * `grep -rn "split('='" scripts/lib` → 3 hits, all argv parsing), so the rules
225
+ * live here.
226
+ *
227
+ * @param {string} absPath - absolute path of the env file
228
+ * @returns {Map<string,string>}
229
+ * @throws {ManifestError} code `env-file-missing` — the message names only the
230
+ * BASENAME, never the absolute path (host paths must not reach CI logs).
231
+ */
232
+ export function readEnvFile(absPath) {
233
+ if (typeof absPath !== 'string' || absPath.length === 0) {
234
+ throw new ManifestError('env-file-missing', 'env-file path must be a non-empty string');
235
+ }
236
+ let raw;
237
+ try {
238
+ raw = readFileSync(absPath, 'utf8');
239
+ } catch {
240
+ throw new ManifestError('env-file-missing', `env-file ${path.basename(absPath)} not found`);
241
+ }
242
+
243
+ const map = new Map();
244
+ for (const line of raw.split(/\r?\n/)) {
245
+ const trimmed = line.trim();
246
+ if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
247
+ const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trim() : trimmed;
248
+ const eq = withoutExport.indexOf('=');
249
+ if (eq <= 0) continue;
250
+ const key = withoutExport.slice(0, eq).trim();
251
+ if (key.length === 0) continue;
252
+ map.set(key, stripQuotes(withoutExport.slice(eq + 1).trim()));
253
+ }
254
+ return map;
255
+ }
256
+
257
+ /**
258
+ * Strip ONE layer of matching surrounding quotes.
259
+ * @param {string} value
260
+ * @returns {string}
261
+ */
262
+ function stripQuotes(value) {
263
+ if (value.length >= 2) {
264
+ const first = value[0];
265
+ if ((first === '"' || first === "'") && value[value.length - 1] === first) {
266
+ return value.slice(1, -1);
267
+ }
268
+ }
269
+ return value;
270
+ }
271
+
272
+ /**
273
+ * ONE env-map type across this module and `collect.mjs`: a `Map`.
274
+ *
275
+ * A plain object is a programmer error, not a manifest defect — so it is a
276
+ * `TypeError`, not a {@link ManifestError}, and it is never absorbed into an
277
+ * empty `Map`: an empty map answers `has()` with `false` for every name, which
278
+ * reads downstream as "the operator did not set the variable".
279
+ *
280
+ * @param {unknown} envMap
281
+ * @param {string} caller - function name, for the message only
282
+ * @returns {Map<string,string>}
283
+ * @throws {TypeError} when `envMap` is not a `Map`
284
+ */
285
+ function requireEnvMap(envMap, caller) {
286
+ if (!(envMap instanceof Map)) {
287
+ throw new TypeError(`${caller}: envMap must be a Map (got ${envMap === null ? 'null' : typeof envMap})`);
288
+ }
289
+ return envMap;
290
+ }
291
+
292
+ /**
293
+ * Assert every `guarded-url-envs` entry resolves to a loopback URL
294
+ * (PRD § 3 AC 3).
295
+ *
296
+ * The VALUE is never included in any message, error or log — a guarded env
297
+ * pointing at production is exactly the case where printing it would leak a
298
+ * live endpoint into a run record.
299
+ *
300
+ * @param {{['guarded-url-envs']?: unknown}} frontmatter
301
+ * @param {Map<string,string>} envMap - MUST be a `Map` (the one env-map type in
302
+ * this module chain). A plain object used to degrade SILENTLY to an empty
303
+ * `Map` here, which turned "every guarded env is missing" into the same
304
+ * `guarded-env-missing` a genuinely unset variable produces — a type error
305
+ * wearing the costume of a manifest error.
306
+ * @returns {void}
307
+ * @throws {TypeError} when `envMap` is not a `Map`
308
+ * @throws {ManifestError} `guarded-env-missing` (`guarded env <NAME> is not set`)
309
+ * or `guarded-env-not-loopback` (`guarded env <NAME> must be loopback`).
310
+ */
311
+ export function assertGuardedEnvsLoopback(frontmatter, envMap) {
312
+ const names = asArray(frontmatter?.['guarded-url-envs']);
313
+ const map = requireEnvMap(envMap, 'assertGuardedEnvsLoopback');
314
+ for (const name of names) {
315
+ if (typeof name !== 'string' || name.length === 0) {
316
+ throw new ManifestError('guarded-env-missing', 'guarded-url-envs entries must be non-empty env NAMES');
317
+ }
318
+ if (!map.has(name)) {
319
+ throw new ManifestError('guarded-env-missing', `guarded env ${name} is not set`);
320
+ }
321
+ if (!isLoopbackUrl(map.get(name))) {
322
+ throw new ManifestError('guarded-env-not-loopback', `guarded env ${name} must be loopback`);
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Resolve a persona's login credentials from the env NAMES it declares.
329
+ *
330
+ * WARNING — the returned object carries SECRET VALUES. It must never be
331
+ * serialised into a run record, a findings file, a dossier, an issue body or a
332
+ * log line; pass it straight to the browser-login step and drop it. Everything
333
+ * persisted about a persona is the env NAME (PRD § 3 AC 4).
334
+ *
335
+ * @param {{name?: string, ['login-env-email']?: string, ['login-env-password']?: string}} persona
336
+ * @param {Map<string,string>} envMap - MUST be a `Map`; see
337
+ * {@link assertGuardedEnvsLoopback} for why the silent fallback was removed.
338
+ * @returns {{email: string, password: string}}
339
+ * @throws {TypeError} when `envMap` is not a `Map`
340
+ * @throws {ManifestError} code `persona-env-missing` — names the env VARIABLE
341
+ * (or the missing manifest key), never a value.
342
+ */
343
+ export function resolvePersonaCredentials(persona, envMap) {
344
+ const map = requireEnvMap(envMap, 'resolvePersonaCredentials');
345
+ const out = {};
346
+ for (const [key, field] of [
347
+ ['login-env-email', 'email'],
348
+ ['login-env-password', 'password'],
349
+ ]) {
350
+ const envName = persona?.[key];
351
+ if (typeof envName !== 'string' || envName.length === 0) {
352
+ throw new ManifestError('persona-env-missing', `persona ${persona?.name ?? '<unnamed>'} has no ${key}`);
353
+ }
354
+ if (!map.has(envName)) {
355
+ throw new ManifestError('persona-env-missing', `env ${envName} is not set`);
356
+ }
357
+ out[field] = map.get(envName);
358
+ }
359
+ return { email: out.email, password: out.password };
360
+ }
361
+
362
+ /**
363
+ * SHA-256 (hex) of the manifest TEXT — the compare key of a run-record: two
364
+ * runs are comparable only when their `manifest_hash` matches, so the hash is
365
+ * taken over the raw text, not over the parsed object.
366
+ *
367
+ * @param {string} text
368
+ * @returns {string} 64-char hex digest
369
+ */
370
+ export function manifestHash(text) {
371
+ return digestSha256(String(text ?? ''));
372
+ }
373
+
374
+ /**
375
+ * Read, parse and guard a repo's ux-manifest.
376
+ *
377
+ * Pure orchestration: read → {@link parseManifest} → {@link assertLoopbackBaseUrl}
378
+ * → {@link readEnvFile} (only when `env-file` is set) → {@link assertGuardedEnvsLoopback}.
379
+ *
380
+ * @param {object} opts
381
+ * @param {string} opts.repoRoot - absolute path of the target repo
382
+ * @param {string} [opts.manifestPath] - repo-relative, defaults to {@link DEFAULT_MANIFEST_PATH}
383
+ * @returns {{frontmatter: object, body: string, envMap: Map<string,string>, manifestHash: string, path: string}}
384
+ * @throws {ManifestError} `repo-root-invalid`, `manifest-missing`,
385
+ * `env-file-outside-repo`, or any code thrown by the steps above.
386
+ */
387
+ export function loadManifest({ repoRoot, manifestPath = DEFAULT_MANIFEST_PATH } = {}) {
388
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
389
+ throw new ManifestError('repo-root-invalid', 'repoRoot must be a non-empty string');
390
+ }
391
+ const absolute = path.resolve(repoRoot, manifestPath);
392
+ let text;
393
+ try {
394
+ text = readFileSync(absolute, 'utf8');
395
+ } catch {
396
+ throw new ManifestError('manifest-missing', `manifest ${path.basename(absolute)} not found`);
397
+ }
398
+
399
+ const { frontmatter, body } = parseManifest(text);
400
+ assertLoopbackBaseUrl(frontmatter);
401
+
402
+ const envFile = frontmatter['env-file'];
403
+ const envMap = typeof envFile === 'string' && envFile.length > 0
404
+ ? readEnvFile(resolveInsideRepo(repoRoot, envFile))
405
+ : new Map();
406
+
407
+ assertGuardedEnvsLoopback(frontmatter, envMap);
408
+
409
+ return { frontmatter, body, envMap, manifestHash: manifestHash(text), path: absolute };
410
+ }
411
+
412
+ /**
413
+ * Resolve a repo-relative manifest path and REQUIRE it to stay inside the repo.
414
+ *
415
+ * The template documents `env-file` as "filename relative to the target repo
416
+ * root", but `path.resolve` happily walks out of it: an `env-file` of
417
+ * `../../../.config/session-orchestrator/secrets.env` turns any host
418
+ * `KEY=VALUE` file into a credential source for a persona, and every error path
419
+ * here is secret-free, so nothing would surface.
420
+ *
421
+ * No existing helper was reusable (measured 2026-09-12,
422
+ * `rg -n "startsWith\(.*sep|isInside|containsPath" scripts/lib` → 10 hits, each
423
+ * an inlined comparison bound to its own module's error shape), so the
424
+ * comparison is inlined here too — against `repoRoot + sep`, with the repo root
425
+ * itself accepted.
426
+ *
427
+ * @param {string} repoRoot - absolute path of the target repo
428
+ * @param {string} relative - repo-relative path from the manifest
429
+ * @returns {string} the absolute, contained path
430
+ * @throws {ManifestError} code `env-file-outside-repo`; the message names NO
431
+ * path (a traversal string is operator-supplied text that may itself carry a
432
+ * host path).
433
+ */
434
+ function resolveInsideRepo(repoRoot, relative) {
435
+ const root = path.resolve(repoRoot);
436
+ const resolved = path.resolve(root, relative);
437
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
438
+ throw new ManifestError('env-file-outside-repo', 'env-file must resolve inside the target repo');
439
+ }
440
+ return resolved;
441
+ }
442
+
443
+ /**
444
+ * Escape a literal page title into an anchored regular expression source, so a
445
+ * discovered title becomes a usable `title-pattern` without matching by accident.
446
+ * @param {string} title
447
+ * @returns {string}
448
+ */
449
+ function titlePatternFor(title) {
450
+ return `^${String(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`;
451
+ }
452
+
453
+ /**
454
+ * Build the TEXT of a bootstrap manifest from a crawl result.
455
+ *
456
+ * The crawl itself is NOT done here — the caller (`collect.mjs` / the skill,
457
+ * via agent-browser) discovers the navigation and hands over `routes` as
458
+ * `{path, title}` records; this function only turns them into manifest
459
+ * `routes[]`.
460
+ *
461
+ * `personas` carries exactly ONE entry when both login env NAMES are given, and
462
+ * is empty otherwise ("ohne Login", PRD § 2 S1). `journeys` is always empty —
463
+ * journeys are hand-written after the bootstrap run.
464
+ *
465
+ * The result is asserted to round-trip through {@link parseManifest} before it
466
+ * is returned (PRD § 3 AC 1): a bootstrap manifest the parser rejects would
467
+ * fail only on the NEXT run, far from its cause.
468
+ *
469
+ * @param {object} opts
470
+ * @param {string} opts.baseUrl - must be loopback
471
+ * @param {string} [opts.build] - `'dev'` (default) or `'prod'`
472
+ * @param {string} [opts.envFile] - filename relative to the target repo root
473
+ * @param {string} [opts.loginEnvEmail] - env NAME, never a value
474
+ * @param {string} [opts.loginEnvPassword] - env NAME, never a value
475
+ * @param {string} [opts.personaName] - defaults to `'operator'`
476
+ * @param {Array<{path: string, title?: string}>} [opts.routes] - discovered navigation
477
+ * @param {string} [opts.notes] - free Markdown appended below the frontmatter
478
+ * @returns {string} the manifest text (frontmatter + body)
479
+ * @throws {ManifestError} `base-url-not-loopback` (message `base-url must be
480
+ * loopback`), `build-invalid`, or `bootstrap-roundtrip-failed`
481
+ */
482
+ export function buildBootstrapManifest({
483
+ baseUrl,
484
+ build = 'dev',
485
+ envFile,
486
+ loginEnvEmail,
487
+ loginEnvPassword,
488
+ personaName = 'operator',
489
+ routes,
490
+ notes,
491
+ } = {}) {
492
+ if (!isLoopbackUrl(baseUrl)) {
493
+ throw new ManifestError('base-url-not-loopback', 'base-url must be loopback');
494
+ }
495
+ if (!VALID_BUILDS.includes(build)) {
496
+ throw new ManifestError('build-invalid', `manifest build must be one of ${VALID_BUILDS.join('|')}`);
497
+ }
498
+
499
+ const hasLogin = typeof loginEnvEmail === 'string' && loginEnvEmail.length > 0
500
+ && typeof loginEnvPassword === 'string' && loginEnvPassword.length > 0;
501
+
502
+ const frontmatter = { 'base-url': baseUrl, build };
503
+ if (typeof envFile === 'string' && envFile.length > 0) frontmatter['env-file'] = envFile;
504
+ frontmatter['guarded-url-envs'] = [];
505
+ frontmatter.personas = hasLogin
506
+ ? [{
507
+ name: personaName,
508
+ 'login-env-email': loginEnvEmail,
509
+ 'login-env-password': loginEnvPassword,
510
+ goal: '',
511
+ }]
512
+ : [];
513
+ frontmatter.routes = asArray(routes).map((route) => {
514
+ const entry = { path: String(route?.path ?? '') };
515
+ if (typeof route?.title === 'string' && route.title.length > 0) {
516
+ entry['title-pattern'] = titlePatternFor(route.title);
517
+ }
518
+ if (hasLogin) entry.persona = personaName;
519
+ return entry;
520
+ });
521
+ frontmatter.journeys = [];
522
+ frontmatter.viewports = DEFAULT_VIEWPORTS.map((v) => ({ ...v }));
523
+
524
+ const body = [
525
+ '# UX Manifest (bootstrapped)',
526
+ '',
527
+ 'Written by `/ux-grill` from the discovered navigation. Fill in the gaps:',
528
+ 'persona `goal`, `guarded-url-envs` (env NAMES of every endpoint the app',
529
+ 'talks to), `seed-command`, and at least one entry under `journeys`.',
530
+ '',
531
+ 'Credentials are env NAMES only — the values belong in the gitignored file',
532
+ 'named by `env-file`, never in this file.',
533
+ ...(typeof notes === 'string' && notes.length > 0 ? ['', notes] : []),
534
+ '',
535
+ ].join('\n');
536
+
537
+ const text = `---\n${yaml.dump(frontmatter, { schema: yaml.CORE_SCHEMA, lineWidth: -1 })}---\n\n${body}`;
538
+
539
+ try {
540
+ parseManifest(text);
541
+ } catch (err) {
542
+ throw new ManifestError(
543
+ 'bootstrap-roundtrip-failed',
544
+ `bootstrap manifest does not parse back (${err.code ?? 'unknown'}): ${err.message}`,
545
+ );
546
+ }
547
+ return text;
548
+ }
549
+
550
+ /**
551
+ * Ensure the target repo's `.gitignore` carries {@link UX_GRILL_ARTEFACT_IGNORE}.
552
+ *
553
+ * APPEND-ONLY, mirroring the `owner.yaml` first-run append to `~/.gitignore`:
554
+ * the file is created when absent, the line is appended when missing, and
555
+ * existing content is NEVER rewritten — a `.gitignore` is operator-owned, and a
556
+ * rewrite here would be a silent edit of a file this module does not own.
557
+ *
558
+ * Failure is NOT fatal: a read-only or otherwise unwritable `.gitignore` must
559
+ * not block the bootstrap. The return value says what happened so the caller
560
+ * can warn.
561
+ *
562
+ * @param {string} repoRoot - absolute path of the target repo
563
+ * @returns {{path: string, action: 'created'|'appended'|'present'|'failed'}}
564
+ */
565
+ function ensureArtefactIgnore(repoRoot) {
566
+ const file = path.join(repoRoot, '.gitignore');
567
+ try {
568
+ let existing = '';
569
+ try {
570
+ existing = readFileSync(file, 'utf8');
571
+ } catch {
572
+ writeFileSync(file, `${UX_GRILL_ARTEFACT_IGNORE}\n`, 'utf8');
573
+ return { path: file, action: 'created' };
574
+ }
575
+ if (existing.split(/\r?\n/).some((line) => line.trim() === UX_GRILL_ARTEFACT_IGNORE)) {
576
+ return { path: file, action: 'present' };
577
+ }
578
+ const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
579
+ appendFileSync(file, `${prefix}${UX_GRILL_ARTEFACT_IGNORE}\n`, 'utf8');
580
+ return { path: file, action: 'appended' };
581
+ } catch {
582
+ return { path: file, action: 'failed' };
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Write a bootstrap manifest, creating the parent directory, and ensure the
588
+ * target repo ignores the run-artefact directory
589
+ * ({@link UX_GRILL_ARTEFACT_IGNORE} via {@link ensureArtefactIgnore}) — those
590
+ * artefacts can carry a rendered credential, and no other step in the target
591
+ * repo would add that line.
592
+ *
593
+ * REFUSES to overwrite the manifest: one that already exists carries
594
+ * hand-written journeys and personas a crawl cannot reconstruct. The
595
+ * `.gitignore` is only ever APPENDED to, never rewritten.
596
+ *
597
+ * @param {object} opts
598
+ * @param {string} opts.repoRoot - absolute path of the target repo
599
+ * @param {string} [opts.manifestPath] - repo-relative, defaults to {@link DEFAULT_MANIFEST_PATH}
600
+ * @param {string} opts.text - manifest text, e.g. from {@link buildBootstrapManifest}
601
+ * @returns {{path: string, manifestHash: string, gitignore: {path: string, action: 'created'|'appended'|'present'|'failed'}}}
602
+ * @throws {ManifestError} `repo-root-invalid`, `manifest-text-invalid`, `manifest-exists`
603
+ */
604
+ export function writeBootstrapManifest({ repoRoot, manifestPath = DEFAULT_MANIFEST_PATH, text } = {}) {
605
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
606
+ throw new ManifestError('repo-root-invalid', 'repoRoot must be a non-empty string');
607
+ }
608
+ if (typeof text !== 'string' || text.length === 0) {
609
+ throw new ManifestError('manifest-text-invalid', 'manifest text must be a non-empty string');
610
+ }
611
+ const absolute = path.resolve(repoRoot, manifestPath);
612
+ if (existsSync(absolute)) {
613
+ throw new ManifestError('manifest-exists', `manifest ${path.basename(absolute)} already exists — refusing to overwrite`);
614
+ }
615
+ mkdirSync(path.dirname(absolute), { recursive: true });
616
+ writeFileSync(absolute, text, 'utf8');
617
+ return { path: absolute, manifestHash: manifestHash(text), gitignore: ensureArtefactIgnore(repoRoot) };
618
+ }