@thebackstoryis/engineering-with-ai 0.2.9

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 (334) hide show
  1. package/Docs/README.md +50 -0
  2. package/Docs/adoption/consultancy-and-multi-project-rollout.md +135 -0
  3. package/Docs/adoption/non-technical-team-guide.md +126 -0
  4. package/Docs/archaeology-technology-and-hosting-discovery.md +212 -0
  5. package/Docs/blast-radius-and-impact-routing-guide.md +325 -0
  6. package/Docs/blueprints/internal-blueprint-catalogue.md +146 -0
  7. package/Docs/blueprints/maintaining-organisation-blueprints.md +154 -0
  8. package/Docs/blueprints/validation-and-troubleshooting.md +168 -0
  9. package/Docs/cli-reference.md +113 -0
  10. package/Docs/completed-phase-evidence-amendments.md +74 -0
  11. package/Docs/consultancy-network-rollout-control-plane-guide.md +202 -0
  12. package/Docs/context-aware-delivery-companion-guide.md +198 -0
  13. package/Docs/context-aware-delivery-companion-user-guide.md +184 -0
  14. package/Docs/context-management-and-token-efficiency.md +113 -0
  15. package/Docs/design-systems/design-system-implementation-guide.md +85 -0
  16. package/Docs/design-systems/design-system-pack-authoring-guide.md +95 -0
  17. package/Docs/design-systems/design-system-review-guide.md +51 -0
  18. package/Docs/design-systems/design-system-user-guide.md +96 -0
  19. package/Docs/design-systems/product-owner-guide.md +49 -0
  20. package/Docs/designing-organisation-blueprint-packs.md +384 -0
  21. package/Docs/developer-delivery-guide.md +224 -0
  22. package/Docs/error-reporting-guide.md +110 -0
  23. package/Docs/error-reporting-provider-guide.md +49 -0
  24. package/Docs/examples/error-report-adapter.md +70 -0
  25. package/Docs/examples/meeting-review.md +76 -0
  26. package/Docs/examples/minimal-design-system.md +67 -0
  27. package/Docs/examples/prototype-review-inputs.md +175 -0
  28. package/Docs/examples/reproducible-archaeology-depth-example.md +144 -0
  29. package/Docs/examples/test-scenario-input.md +68 -0
  30. package/Docs/examples/worked-examples.md +147 -0
  31. package/Docs/existing-project-onboarding-guide.md +214 -0
  32. package/Docs/explanation/core-concepts.md +26 -0
  33. package/Docs/explanation/delivery-workflow.md +48 -0
  34. package/Docs/governance/governance-team-guide.md +139 -0
  35. package/Docs/governed-starter-project-materialisation-guide.md +284 -0
  36. package/Docs/guide-catalogue.md +117 -0
  37. package/Docs/guided-discovery-facilitator-guide.md +172 -0
  38. package/Docs/guided-intent-workspace-guide.md +109 -0
  39. package/Docs/guided-phase-evidence-drafting-guide.md +119 -0
  40. package/Docs/human-approval-and-assurance-guide.md +146 -0
  41. package/Docs/knowledge-proposals-implementer-guide.md +106 -0
  42. package/Docs/knowledge-proposals-user-guide.md +247 -0
  43. package/Docs/maintainers/context-benchmarks.md +29 -0
  44. package/Docs/maintainers/contributing.md +58 -0
  45. package/Docs/maintainers/evidence-depth-acceptance.md +72 -0
  46. package/Docs/maintainers/verification-walkthroughs.md +104 -0
  47. package/Docs/meeting-evidence-implementer-guide.md +132 -0
  48. package/Docs/meeting-evidence-user-guide.md +200 -0
  49. package/Docs/operations/dashboard-and-delivery-state.md +153 -0
  50. package/Docs/operations/dashboard-configuration.md +87 -0
  51. package/Docs/operations/installation-updating-and-entitlements.md +135 -0
  52. package/Docs/operations/premium-personas-setup.md +76 -0
  53. package/Docs/operations/troubleshooting-and-recovery.md +205 -0
  54. package/Docs/organisation-rollout-guide.md +142 -0
  55. package/Docs/persona-entitlement-provider-guide.md +199 -0
  56. package/Docs/persona-guided-prototype-iteration.md +129 -0
  57. package/Docs/personas/organisation-specific-personas.md +103 -0
  58. package/Docs/personas/persona-authoring-cookbook.md +176 -0
  59. package/Docs/personas/persona-engagement-ui.md +133 -0
  60. package/Docs/personas/persona-governance.md +118 -0
  61. package/Docs/platform-export-analysis-guide.md +336 -0
  62. package/Docs/policies/governance-owner-guide.md +36 -0
  63. package/Docs/policies/implementation-guide.md +42 -0
  64. package/Docs/policies/organisation-policy-design-gates.md +58 -0
  65. package/Docs/policies/policy-pack-authoring-guide.md +108 -0
  66. package/Docs/policies/product-owner-guide.md +43 -0
  67. package/Docs/policies/technical-owner-guide.md +37 -0
  68. package/Docs/product-owner-guide.md +327 -0
  69. package/Docs/project-portfolio-orchestration-guide.md +199 -0
  70. package/Docs/quality/manual-qa-and-acceptance.md +162 -0
  71. package/Docs/quality/persona-driven-test-scenarios.md +172 -0
  72. package/Docs/quality/reproducible-archaeology-depth-review-checklist.md +89 -0
  73. package/Docs/reference/capabilities-and-project-layout.md +678 -0
  74. package/Docs/reference/cli-and-configuration.md +398 -0
  75. package/Docs/reference/contributions-api.md +23 -0
  76. package/Docs/reference/security-adapter-authoring.md +81 -0
  77. package/Docs/reference/starter-adapter-authoring.md +74 -0
  78. package/Docs/repository-source-map-guide.md +381 -0
  79. package/Docs/reproducible-archaeology-and-discovery-depth.md +292 -0
  80. package/Docs/screen-prototype-creation-guide.md +324 -0
  81. package/Docs/security-validation-guide.md +353 -0
  82. package/Docs/solution-readiness-review-guide.md +123 -0
  83. package/Docs/standards/project-standards-authoring.md +157 -0
  84. package/Docs/team-hub-guide.md +162 -0
  85. package/Docs/team-hub-resource-registry-guide.md +167 -0
  86. package/Docs/tutorials/first-delivery.md +83 -0
  87. package/Docs/tutorials/first-session.md +62 -0
  88. package/Docs/using-lifecycle-hooks.md +381 -0
  89. package/Docs/working-with-personas.md +274 -0
  90. package/LICENSE +165 -0
  91. package/README.md +96 -0
  92. package/agents-src/claude/ewai-security-reviewer.md +15 -0
  93. package/bin/ewai +5 -0
  94. package/config/archaeology-record-families.yaml +59 -0
  95. package/config/delivery-artifacts.yaml +121 -0
  96. package/config/delivery-stages.yaml +77 -0
  97. package/config/design-system.schema.json +46 -0
  98. package/config/error-reporting.schema.json +79 -0
  99. package/config/evidence-depth.schema.json +53 -0
  100. package/config/intent.schema.json +90 -0
  101. package/config/knowledge-proposals-proposal.schema.json +34 -0
  102. package/config/lifecycle-event.schema.json +68 -0
  103. package/config/lifecycle-handler.schema.json +45 -0
  104. package/config/lifecycle-hook-ack.schema.json +19 -0
  105. package/config/meeting-evidence-candidate.schema.json +102 -0
  106. package/config/organisation-policy.schema.json +137 -0
  107. package/config/pack.schema.json +250 -0
  108. package/config/persona-pack.schema.json +21 -0
  109. package/config/persona.schema.json +17 -0
  110. package/config/policy-evaluation.schema.json +77 -0
  111. package/config/policy-facts.schema.json +140 -0
  112. package/config/portfolio.schema.json +68 -0
  113. package/config/project.schema.json +313 -0
  114. package/config/prototype-iteration.schema.json +128 -0
  115. package/config/rollout.schema.json +87 -0
  116. package/config/security-adapter.schema.json +31 -0
  117. package/config/security-scan-request.schema.json +64 -0
  118. package/config/security-scan-response.schema.json +52 -0
  119. package/config/security-validation-policy.schema.json +74 -0
  120. package/config/starter-source-acknowledgement.schema.json +13 -0
  121. package/config/starter-source-adapter.schema.json +38 -0
  122. package/config/starter-source-request.schema.json +61 -0
  123. package/package.json +77 -0
  124. package/packs/core/pack.yaml +7 -0
  125. package/packs/design-systems/default/experience-promise.md +9 -0
  126. package/packs/design-systems/default/intentional-review.md +10 -0
  127. package/packs/design-systems/default/interaction-and-entry.md +9 -0
  128. package/packs/design-systems/default/meaningful-content-and-states.md +9 -0
  129. package/packs/design-systems/default/pack.yaml +44 -0
  130. package/packs/design-systems/default/principles.md +10 -0
  131. package/packs/personas/core/pack.yaml +7 -0
  132. package/packs/personas/core/personas/archaeologist.md +37 -0
  133. package/packs/personas/core/personas/end-user.md +17 -0
  134. package/packs/personas/core/personas/maintainer.md +17 -0
  135. package/packs/personas/core/personas/operator.md +17 -0
  136. package/packs/personas/core/personas/specs-knowledge-curator.md +35 -0
  137. package/packs/technologies/laravel/pack.yaml +30 -0
  138. package/packs/technologies/laravel-nuxt/pack.yaml +30 -0
  139. package/packs/technologies/nuxt/pack.yaml +30 -0
  140. package/packs/technologies/power-platform/pack.yaml +31 -0
  141. package/packs/technologies/salesforce/pack.yaml +25 -0
  142. package/public/app.js +4896 -0
  143. package/public/apple-touch-icon.png +0 -0
  144. package/public/assets/backstory-icon.png +0 -0
  145. package/public/dashboard-navigation.js +98 -0
  146. package/public/favicon-16.png +0 -0
  147. package/public/favicon-32.png +0 -0
  148. package/public/favicon.ico +0 -0
  149. package/public/index.html +789 -0
  150. package/public/styles.css +2693 -0
  151. package/public/team-hub/app.js +202 -0
  152. package/public/team-hub/index.html +79 -0
  153. package/public/team-hub/styles.css +90 -0
  154. package/scripts/publication-check.mjs +140 -0
  155. package/scripts/setup.mjs +21 -0
  156. package/skills-src/ewai-archaeology/SKILL.md +334 -0
  157. package/skills-src/ewai-archaeology/agents/openai.yaml +4 -0
  158. package/skills-src/ewai-archaeology/references/archaeology-contract.md +201 -0
  159. package/skills-src/ewai-archaeology/references/lifecycle-reconstruction.md +177 -0
  160. package/skills-src/ewai-archaeology/references/maximum-detail-reconstruction.md +97 -0
  161. package/skills-src/ewai-archaeology/references/model-routing.md +26 -0
  162. package/skills-src/ewai-architecture/SKILL.md +108 -0
  163. package/skills-src/ewai-architecture/agents/openai.yaml +4 -0
  164. package/skills-src/ewai-architecture/references/architecture-contract.md +176 -0
  165. package/skills-src/ewai-context/SKILL.md +68 -0
  166. package/skills-src/ewai-context/agents/openai.yaml +4 -0
  167. package/skills-src/ewai-context-import/SKILL.md +118 -0
  168. package/skills-src/ewai-context-import/agents/openai.yaml +4 -0
  169. package/skills-src/ewai-context-import/references/context-import-contract.md +106 -0
  170. package/skills-src/ewai-dashboard-configuration/SKILL.md +20 -0
  171. package/skills-src/ewai-deliver/SKILL.md +122 -0
  172. package/skills-src/ewai-deliver/references/delivery-evidence.md +92 -0
  173. package/skills-src/ewai-deliver/references/phase-routing.md +31 -0
  174. package/skills-src/ewai-design-system-apply/SKILL.md +27 -0
  175. package/skills-src/ewai-design-system-apply/agents/openai.yaml +4 -0
  176. package/skills-src/ewai-design-system-apply/references/application-contract.md +36 -0
  177. package/skills-src/ewai-design-system-author/SKILL.md +28 -0
  178. package/skills-src/ewai-design-system-author/agents/openai.yaml +4 -0
  179. package/skills-src/ewai-design-system-author/references/authoring-contract.md +38 -0
  180. package/skills-src/ewai-design-system-review/SKILL.md +26 -0
  181. package/skills-src/ewai-design-system-review/agents/openai.yaml +4 -0
  182. package/skills-src/ewai-design-system-review/references/review-contract.md +40 -0
  183. package/skills-src/ewai-error-reporting/SKILL.md +46 -0
  184. package/skills-src/ewai-error-reporting/agents/openai.yaml +4 -0
  185. package/skills-src/ewai-error-reporting/references/provider-contract.md +74 -0
  186. package/skills-src/ewai-evidence-depth/SKILL.md +72 -0
  187. package/skills-src/ewai-evidence-depth/agents/openai.yaml +4 -0
  188. package/skills-src/ewai-evidence-depth/references/evidence-depth-contract.md +127 -0
  189. package/skills-src/ewai-intent/SKILL.md +68 -0
  190. package/skills-src/ewai-intent/agents/openai.yaml +4 -0
  191. package/skills-src/ewai-intent/references/intent-contract.md +42 -0
  192. package/skills-src/ewai-knowledge-proposals/SKILL.md +105 -0
  193. package/skills-src/ewai-knowledge-proposals/agents/openai.yaml +4 -0
  194. package/skills-src/ewai-knowledge-proposals/references/proposal-contract.md +59 -0
  195. package/skills-src/ewai-meeting-evidence/SKILL.md +106 -0
  196. package/skills-src/ewai-meeting-evidence/agents/openai.yaml +4 -0
  197. package/skills-src/ewai-meeting-evidence/references/candidate-contract.md +64 -0
  198. package/skills-src/ewai-organisation-policy/SKILL.md +62 -0
  199. package/skills-src/ewai-organisation-policy/agents/openai.yaml +4 -0
  200. package/skills-src/ewai-organisation-policy/references/policy-contract.md +94 -0
  201. package/skills-src/ewai-palace-housekeeping/SKILL.md +55 -0
  202. package/skills-src/ewai-palace-housekeeping/agents/openai.yaml +4 -0
  203. package/skills-src/ewai-persona-entitlement/SKILL.md +60 -0
  204. package/skills-src/ewai-persona-entitlement/agents/openai.yaml +4 -0
  205. package/skills-src/ewai-phase-evidence/SKILL.md +79 -0
  206. package/skills-src/ewai-phase-evidence/agents/openai.yaml +4 -0
  207. package/skills-src/ewai-pipeline/SKILL.md +130 -0
  208. package/skills-src/ewai-pipeline/agents/openai.yaml +4 -0
  209. package/skills-src/ewai-pipeline/references/cli.md +86 -0
  210. package/skills-src/ewai-pipeline/references/specs-contract.md +16 -0
  211. package/skills-src/ewai-portfolio/SKILL.md +70 -0
  212. package/skills-src/ewai-portfolio/agents/openai.yaml +4 -0
  213. package/skills-src/ewai-portfolio/references/portfolio-contract.md +67 -0
  214. package/skills-src/ewai-project-discovery/SKILL.md +95 -0
  215. package/skills-src/ewai-project-discovery/agents/openai.yaml +4 -0
  216. package/skills-src/ewai-project-discovery/references/discovery-contract.md +34 -0
  217. package/skills-src/ewai-prototype-iteration/SKILL.md +30 -0
  218. package/skills-src/ewai-prototype-iteration/agents/openai.yaml +4 -0
  219. package/skills-src/ewai-prototype-iteration/references/review-contract.md +49 -0
  220. package/skills-src/ewai-retro/SKILL.md +48 -0
  221. package/skills-src/ewai-retro/agents/openai.yaml +4 -0
  222. package/skills-src/ewai-retro/references/asset-routing.md +14 -0
  223. package/skills-src/ewai-rollout/SKILL.md +74 -0
  224. package/skills-src/ewai-rollout/agents/openai.yaml +4 -0
  225. package/skills-src/ewai-rollout/references/rollout-contract.md +74 -0
  226. package/skills-src/ewai-shape-intents/SKILL.md +84 -0
  227. package/skills-src/ewai-shape-intents/agents/openai.yaml +4 -0
  228. package/skills-src/ewai-shape-intents/references/intent-mapping-contract.md +109 -0
  229. package/skills-src/ewai-solution-readiness/SKILL.md +55 -0
  230. package/skills-src/ewai-solution-readiness/agents/openai.yaml +4 -0
  231. package/skills-src/ewai-standards-check/SKILL.md +93 -0
  232. package/skills-src/ewai-standards-check/agents/openai.yaml +4 -0
  233. package/skills-src/ewai-standards-check/references/report-contract.md +116 -0
  234. package/skills-src/ewai-test-scenarios/SKILL.md +94 -0
  235. package/skills-src/ewai-test-scenarios/agents/openai.yaml +4 -0
  236. package/skills-src/ewai-test-scenarios/references/scenario-contract.md +88 -0
  237. package/src/afk-worker.mjs +16 -0
  238. package/src/archaeology.mjs +1333 -0
  239. package/src/checkin.mjs +261 -0
  240. package/src/cli.mjs +2427 -0
  241. package/src/companion-guidance.mjs +257 -0
  242. package/src/companion-opening.mjs +62 -0
  243. package/src/companion.mjs +256 -0
  244. package/src/context.mjs +210 -0
  245. package/src/dashboard-preferences.mjs +80 -0
  246. package/src/delivery-artifacts.mjs +204 -0
  247. package/src/delivery-documents.mjs +248 -0
  248. package/src/delivery-gates.mjs +317 -0
  249. package/src/delivery.mjs +1433 -0
  250. package/src/design-system-application.mjs +291 -0
  251. package/src/design-system-authoring.mjs +101 -0
  252. package/src/design-systems.mjs +466 -0
  253. package/src/discovery.mjs +1314 -0
  254. package/src/error-reporting.mjs +323 -0
  255. package/src/evidence-depth.mjs +543 -0
  256. package/src/execution-state.mjs +243 -0
  257. package/src/install.mjs +166 -0
  258. package/src/intent-dependencies.mjs +117 -0
  259. package/src/intent-maps.mjs +402 -0
  260. package/src/intents.mjs +747 -0
  261. package/src/knowledge-proposals.mjs +717 -0
  262. package/src/launcher.mjs +51 -0
  263. package/src/meeting-evidence.mjs +703 -0
  264. package/src/network-rollout.mjs +386 -0
  265. package/src/organisation-blueprints.mjs +438 -0
  266. package/src/organisation-policies.mjs +448 -0
  267. package/src/packs.mjs +44 -0
  268. package/src/paths.mjs +62 -0
  269. package/src/persona-entitlements.mjs +438 -0
  270. package/src/persona-licence-config.mjs +98 -0
  271. package/src/persona-website-provider.mjs +134 -0
  272. package/src/persona-zip.mjs +87 -0
  273. package/src/personas.mjs +159 -0
  274. package/src/platform-metadata-analysis.mjs +314 -0
  275. package/src/policy-design-gates.mjs +623 -0
  276. package/src/policy-gate-integration.mjs +318 -0
  277. package/src/portfolio.mjs +509 -0
  278. package/src/power-platform-source-map.mjs +190 -0
  279. package/src/project.mjs +449 -0
  280. package/src/prototype-iterations.mjs +730 -0
  281. package/src/repository-source-map.mjs +603 -0
  282. package/src/runtime/afk-conductor.mjs +973 -0
  283. package/src/runtime/context-assembly.mjs +457 -0
  284. package/src/runtime/context-benchmarks.mjs +115 -0
  285. package/src/runtime/dashboard-actions.mjs +109 -0
  286. package/src/runtime/dashboard-handoffs.mjs +149 -0
  287. package/src/runtime/dashboard-server.mjs +1272 -0
  288. package/src/runtime/dashboard.mjs +197 -0
  289. package/src/runtime/database.mjs +789 -0
  290. package/src/runtime/error-reporting.mjs +581 -0
  291. package/src/runtime/evidence-depth-workspace.mjs +412 -0
  292. package/src/runtime/execution-leases.mjs +299 -0
  293. package/src/runtime/guided-discovery.mjs +350 -0
  294. package/src/runtime/guided-intents.mjs +517 -0
  295. package/src/runtime/impact-analysis.mjs +535 -0
  296. package/src/runtime/intents.mjs +222 -0
  297. package/src/runtime/knowledge.mjs +86 -0
  298. package/src/runtime/lifecycle-hooks.mjs +1239 -0
  299. package/src/runtime/mcp-config.mjs +110 -0
  300. package/src/runtime/mcp-server.mjs +1885 -0
  301. package/src/runtime/palace.mjs +362 -0
  302. package/src/runtime/paths.mjs +58 -0
  303. package/src/runtime/persona-engagement.mjs +255 -0
  304. package/src/runtime/phase-contributions.mjs +594 -0
  305. package/src/runtime/policy-workspace.mjs +170 -0
  306. package/src/runtime/prototype-iterations.mjs +235 -0
  307. package/src/runtime/provider-adapters.mjs +163 -0
  308. package/src/runtime/repository-index.mjs +838 -0
  309. package/src/runtime/runs.mjs +185 -0
  310. package/src/runtime/security-validation.mjs +1230 -0
  311. package/src/runtime/starter-materialisation.mjs +1155 -0
  312. package/src/runtime/team-hub-client.mjs +479 -0
  313. package/src/runtime/team-hub-database.mjs +288 -0
  314. package/src/runtime/team-hub-server.mjs +191 -0
  315. package/src/runtime/team-hub.mjs +110 -0
  316. package/src/runtime/tree-sitter-index.mjs +390 -0
  317. package/src/runtime/version.mjs +1 -0
  318. package/src/runtime/work.mjs +633 -0
  319. package/src/salesforce-source-map.mjs +212 -0
  320. package/src/security-validation-config.mjs +224 -0
  321. package/src/solution-readiness.mjs +620 -0
  322. package/src/starter-materialisation-contract.mjs +407 -0
  323. package/src/task-graph.mjs +544 -0
  324. package/src/team-hub-resources.mjs +239 -0
  325. package/src/team-hub.mjs +242 -0
  326. package/src/test-scenarios.mjs +622 -0
  327. package/src/validation-config.mjs +289 -0
  328. package/templates/SPECS/1.Scope/personas/registry.yaml +12 -0
  329. package/templates/SPECS/5.Strategy/patterns/context-packet.md +119 -0
  330. package/templates/SPECS/6.Build/_tracker-template.md +16 -0
  331. package/templates/SPECS/pipeline.yaml +62 -0
  332. package/templates/discovery-answers.yaml +86 -0
  333. package/templates/intent-body.md +21 -0
  334. package/tests/fixtures/context-benchmarks.json +9 -0
@@ -0,0 +1,622 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync
10
+ } from 'node:fs';
11
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
12
+ import YAML from 'yaml';
13
+ import { deliveryPaths, isWithin } from './delivery-documents.mjs';
14
+ import { loadProjectConfig } from './project.mjs';
15
+ import { selectContextualPersonas } from './runtime/persona-engagement.mjs';
16
+
17
+ const BRIEF_SCHEMA = 'ewai.persona-test-brief/v1';
18
+ const SCENARIO_SCHEMA = 'ewai.persona-test-scenarios/v1';
19
+ const WORKSPACE_SCHEMA = 'ewai.persona-test-workspace/v1';
20
+ const scenarioIdPattern = /^PTS-[0-9]{3}$/;
21
+ const sourceIdPattern = /^[a-z0-9][a-z0-9:._-]{2,159}$/i;
22
+ const allowedTypes = new Set([
23
+ 'happy-path', 'alternate', 'error', 'permissions', 'accessibility', 'security-privacy',
24
+ 'operations', 'recovery', 'data', 'misuse', 'adversarial', 'regression'
25
+ ]);
26
+ const allowedRoutes = new Set(['automated', 'manual-qa', 'specialist-assurance', 'representative-user']);
27
+ const allowedAutomation = new Set(['automated', 'manual', 'hybrid']);
28
+ const allowedStatuses = new Set(['accepted', 'blocked', 'superseded', 'hypothesis']);
29
+ const vagueOraclePattern = /^(?:works?|works correctly|is correct|is successful|succeeds?|passes?|handled correctly|expected result)$/i;
30
+ const MAX_FOCUS = 1_000;
31
+ const MAX_SOURCES = 240;
32
+ const MAX_SCENARIOS = 200;
33
+ const MAX_LIST_ITEMS = 50;
34
+ const MAX_TEXT = 1_000;
35
+
36
+ function sha256(value) {
37
+ return createHash('sha256').update(value).digest('hex');
38
+ }
39
+
40
+ function cleanText(value, label, maximum = MAX_TEXT, required = true) {
41
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
42
+ if (required && !text) throw new Error(`${label} is required`);
43
+ if (text.length > maximum) throw new Error(`${label} exceeds ${maximum} characters`);
44
+ return text;
45
+ }
46
+
47
+ function canonical(value) {
48
+ if (Array.isArray(value)) return value.map(canonical);
49
+ if (!value || typeof value !== 'object') return value;
50
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
51
+ }
52
+
53
+ function canonicalJson(value) {
54
+ return JSON.stringify(canonical(value));
55
+ }
56
+
57
+ function relativePath(root, path) {
58
+ return relative(resolve(root), resolve(path)).replaceAll('\\', '/');
59
+ }
60
+
61
+ function assertSafeProjectPath(root, value, label, { requireExisting = false } = {}) {
62
+ const input = cleanText(value, label, 500);
63
+ if (input.includes('\0') || input.includes('://') || isAbsolute(input)) {
64
+ throw new Error(`${label} must be a safe repository-relative path`);
65
+ }
66
+ const absolute = resolve(root, input);
67
+ const rel = relative(root, absolute);
68
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
69
+ throw new Error(`${label} must be a safe repository-relative path`);
70
+ }
71
+ if (requireExisting && !existsSync(absolute)) throw new Error(`${label} does not exist: ${input}`);
72
+ return rel.replaceAll('\\', '/');
73
+ }
74
+
75
+ function readJson(path, label) {
76
+ try {
77
+ return JSON.parse(readFileSync(path, 'utf8'));
78
+ } catch (error) {
79
+ throw new Error(`${label} is invalid JSON: ${error.message}`);
80
+ }
81
+ }
82
+
83
+ function findFiles(root, predicate, found = []) {
84
+ if (!existsSync(root)) return found;
85
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
86
+ const path = resolve(root, entry.name);
87
+ if (entry.isDirectory()) findFiles(path, predicate, found);
88
+ else if (entry.isFile() && predicate(path, entry.name)) found.push(path);
89
+ }
90
+ return found;
91
+ }
92
+
93
+ function parseFrontmatter(content) {
94
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
95
+ if (!match) return { metadata: {}, body: content };
96
+ let metadata;
97
+ try {
98
+ metadata = YAML.parse(match[1]) ?? {};
99
+ } catch (error) {
100
+ throw new Error(`Intent frontmatter is invalid: ${error.message}`);
101
+ }
102
+ return { metadata, body: content.slice(match[0].length) };
103
+ }
104
+
105
+ function findIntent(projectRoot, slug) {
106
+ const { paths } = loadProjectConfig(projectRoot);
107
+ const intentsRoot = resolve(paths.specsRoot, '2.Purpose/intents');
108
+ const matches = findFiles(intentsRoot, (_path, name) => name.endsWith('.md')).flatMap((path) => {
109
+ const content = readFileSync(path, 'utf8');
110
+ const parsed = parseFrontmatter(content);
111
+ return parsed.metadata.slug === slug ? [{ path, content, ...parsed }] : [];
112
+ });
113
+ if (!matches.length) throw new Error(`Unknown intent slug: ${slug}`);
114
+ if (matches.length > 1) throw new Error(`Intent slug is ambiguous: ${slug}`);
115
+ return { ...matches[0], specsRoot: paths.specsRoot };
116
+ }
117
+
118
+ function section(body, heading) {
119
+ const lines = String(body).split(/\r?\n/);
120
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading}`.toLowerCase());
121
+ if (start === -1) return '';
122
+ const content = [];
123
+ for (let index = start + 1; index < lines.length; index += 1) {
124
+ if (/^##\s+/.test(lines[index])) break;
125
+ content.push(lines[index]);
126
+ }
127
+ return content.join('\n').trim();
128
+ }
129
+
130
+ function boundedExcerpt(value, maximum = 500) {
131
+ return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, maximum);
132
+ }
133
+
134
+ function enumeratedLines(value) {
135
+ return String(value ?? '').split(/\r?\n/).map((line) => line.trim())
136
+ .filter((line) => /^(?:[-*]|\d+[.)])\s+/.test(line))
137
+ .map((line) => line.replace(/^(?:[-*]|\d+[.)])\s+/, '').trim())
138
+ .slice(0, 100);
139
+ }
140
+
141
+ function sourceRecord(projectRoot, { id, kind, label, path, text, authority = 'authoritative' }) {
142
+ if (!sourceIdPattern.test(id)) throw new Error(`Invalid source reference: ${id}`);
143
+ const safePath = assertSafeProjectPath(projectRoot, relativePath(projectRoot, path), 'Source path', { requireExisting: true });
144
+ const excerpt = boundedExcerpt(text);
145
+ return { id, kind, label: cleanText(label, 'Source label', 200), path: safePath, digest: sha256(readFileSync(path)), excerpt, authority };
146
+ }
147
+
148
+ function explicitOrGeneratedId(text, prefix, index) {
149
+ const explicit = String(text).match(new RegExp(`\\b(${prefix}-\\d{3})\\b`, 'i'))?.[1]?.toUpperCase();
150
+ return explicit ?? `${prefix}-${String(index + 1).padStart(3, '0')}`;
151
+ }
152
+
153
+ function intentSources(projectRoot, intent) {
154
+ const sources = [];
155
+ const specs = [
156
+ ['Problem', 'section', 'problem'],
157
+ ['Desired outcome', 'section', 'desired-outcome'],
158
+ ['Constraints', 'section', 'constraints']
159
+ ];
160
+ for (const [heading, kind, id] of specs) {
161
+ const text = section(intent.body, heading);
162
+ if (text && !/^Not captured\./i.test(text)) {
163
+ sources.push(sourceRecord(projectRoot, { id: `intent:${kind}:${id}`, kind: `intent-${kind}`, label: heading, path: intent.path, text }));
164
+ }
165
+ }
166
+ for (const [index, text] of enumeratedLines(section(intent.body, 'Journeys')).entries()) {
167
+ const id = explicitOrGeneratedId(text, 'J', index);
168
+ sources.push(sourceRecord(projectRoot, { id: `intent:journey:${id}`, kind: 'intent-journey', label: `Journey ${id}`, path: intent.path, text }));
169
+ }
170
+ for (const [index, text] of enumeratedLines(section(intent.body, 'Acceptance criteria')).entries()) {
171
+ const id = explicitOrGeneratedId(text, 'AC', index);
172
+ sources.push(sourceRecord(projectRoot, { id: `intent:acceptance:${id}`, kind: 'intent-acceptance', label: `Acceptance criterion ${id}`, path: intent.path, text }));
173
+ }
174
+ return sources;
175
+ }
176
+
177
+ function planSources(projectRoot, deliveryRoot) {
178
+ const sources = [];
179
+ const planPath = resolve(deliveryRoot, 'gates/plan/plan-contract.json');
180
+ if (existsSync(planPath)) {
181
+ const plan = readJson(planPath, 'Plan Contract');
182
+ if (plan.slug && plan.slug !== deliveryRoot.split('/').at(-1)) throw new Error('Plan Contract slug does not match the intent');
183
+ for (const item of (plan.test_obligations ?? []).slice(0, 100)) {
184
+ const id = cleanText(item.id, 'Plan test obligation ID', 80);
185
+ sources.push(sourceRecord(projectRoot, {
186
+ id: `plan:test:${id}`, kind: 'plan-test-obligation', label: `Test obligation ${id}`, path: planPath,
187
+ text: [item.obligation, item.file, item.type].filter(Boolean).join(' · ')
188
+ }));
189
+ }
190
+ }
191
+ const claimsPath = resolve(deliveryRoot, 'gates/plan/claim-ledger.json');
192
+ if (existsSync(claimsPath)) {
193
+ const claims = readJson(claimsPath, 'Claim Ledger');
194
+ if (claims.slug && claims.slug !== deliveryRoot.split('/').at(-1)) throw new Error('Claim Ledger slug does not match the intent');
195
+ for (const item of (claims.implementation_claims ?? []).slice(0, 100)) {
196
+ const id = cleanText(item.id, 'Plan claim ID', 80);
197
+ sources.push(sourceRecord(projectRoot, {
198
+ id: `plan:claim:${id}`, kind: 'plan-claim', label: `Implementation claim ${id}`, path: claimsPath,
199
+ text: [item.statement, ...(item.tests_required ?? [])].filter(Boolean).join(' · ')
200
+ }));
201
+ }
202
+ }
203
+ const testPlanPath = resolve(deliveryRoot, 'test-plan.md');
204
+ if (existsSync(testPlanPath)) {
205
+ sources.push(sourceRecord(projectRoot, {
206
+ id: 'test-plan:document', kind: 'test-plan', label: 'Accepted test plan', path: testPlanPath,
207
+ text: readFileSync(testPlanPath, 'utf8')
208
+ }));
209
+ }
210
+ return sources;
211
+ }
212
+
213
+ function standardSources(projectRoot, specsRoot) {
214
+ const standardsRoot = resolve(specsRoot, '4.Guidelines');
215
+ return findFiles(standardsRoot, (_path, name) => /\.(?:md|json|ya?ml)$/i.test(name)).sort().slice(0, 20).map((path, index) => sourceRecord(projectRoot, {
216
+ id: `standard:project:${String(index + 1).padStart(3, '0')}`,
217
+ kind: 'project-standard',
218
+ label: relativePath(standardsRoot, path),
219
+ path,
220
+ text: readFileSync(path, 'utf8')
221
+ }));
222
+ }
223
+
224
+ function contextualImpactSources(projectRoot, deliveryRoot) {
225
+ const impactRoot = resolve(deliveryRoot, 'impacts');
226
+ return findFiles(impactRoot, (_path, name) => name.endsWith('.json')).sort().slice(0, 20).flatMap((path) => {
227
+ const record = readJson(path, 'Impact assessment');
228
+ if (record.schema !== 'ewai.impact-assessment/v1' || !record.assessmentId) return [];
229
+ return (record.impactAreas ?? []).slice(0, 20).map((area, index) => sourceRecord(projectRoot, {
230
+ id: `impact:${record.assessmentId}:area:${area.id ?? index + 1}`,
231
+ kind: 'impact-assessment',
232
+ label: area.label ?? area.id ?? `Impact area ${index + 1}`,
233
+ path,
234
+ text: [area.label, ...(area.signals ?? [])].filter(Boolean).join(' · '),
235
+ authority: 'inferred-context'
236
+ }));
237
+ });
238
+ }
239
+
240
+ function personaAvailability(catalogue) {
241
+ return Object.fromEntries(['core', 'premium', 'personal', 'project'].map((tier) => {
242
+ const count = catalogue.filter((persona) => String(persona.tier || 'core') === tier).length;
243
+ return [tier, {
244
+ installed: count > 0,
245
+ count,
246
+ reason: count ? `${count} installed ${tier} persona${count === 1 ? '' : 's'} available.` : `No installed ${tier} personas were found.`
247
+ }];
248
+ }));
249
+ }
250
+
251
+ function personaSignals(sources, contextualEvidence, focus) {
252
+ const direct = [focus, ...sources.map((source) => `${source.label} ${source.excerpt}`), ...contextualEvidence.map((source) => `${source.label} ${source.excerpt}`)];
253
+ return [...new Set(direct.flatMap((value) => String(value).toLowerCase().split(/[^a-z0-9-]+/)).filter((word) => word.length >= 4))].slice(0, 120);
254
+ }
255
+
256
+ function briefDigest(sources, contextualEvidence) {
257
+ return sha256(canonicalJson({
258
+ sources: sources.map(({ id, path, digest, authority }) => ({ id, path, digest, authority })),
259
+ contextualEvidence: contextualEvidence.map(({ id, path, digest, authority }) => ({ id, path, digest, authority }))
260
+ }));
261
+ }
262
+
263
+ export function preparePersonaTestScenarioBrief(projectRoot, slug, options = {}) {
264
+ const root = resolve(projectRoot);
265
+ const focus = cleanText(options.focus, 'Focus', MAX_FOCUS, false);
266
+ const intent = findIntent(root, cleanText(slug, 'Intent slug', 100));
267
+ const paths = deliveryPaths(root, slug);
268
+ const authoritativeSources = [
269
+ ...intentSources(root, intent),
270
+ ...planSources(root, paths.deliveryRoot),
271
+ ...standardSources(root, intent.specsRoot)
272
+ ];
273
+ const contextualEvidence = contextualImpactSources(root, paths.deliveryRoot);
274
+ if (!authoritativeSources.length) throw new Error('No bounded authoritative testing sources are available for this intent');
275
+ if (authoritativeSources.length + contextualEvidence.length > MAX_SOURCES) throw new Error(`Testing brief exceeds ${MAX_SOURCES} sources`);
276
+ const catalogue = Array.isArray(options.personas) ? options.personas : Array.isArray(options.personaCatalogue) ? options.personaCatalogue : [];
277
+ const signals = personaSignals(authoritativeSources, contextualEvidence, focus);
278
+ const activePersonas = selectContextualPersonas({
279
+ signals,
280
+ context: { focus, authoritativeSources, contextualEvidence },
281
+ personaCatalogue: catalogue,
282
+ contextLabel: 'persona-led test scenario design',
283
+ limit: 4
284
+ });
285
+ const sourceDigest = briefDigest(authoritativeSources, contextualEvidence);
286
+ return {
287
+ schema: BRIEF_SCHEMA,
288
+ slug,
289
+ focus,
290
+ sourceDigest,
291
+ authoritativeSources,
292
+ contextualEvidence,
293
+ activePersonas,
294
+ availability: personaAvailability(catalogue),
295
+ unresolvedQuestions: activePersonas.length ? [] : ['No installed persona matched the current testing signals; select or create a relevant persona before semantic challenge.'],
296
+ recordingContract: {
297
+ schema: SCENARIO_SCHEMA,
298
+ scenarioTypes: [...allowedTypes], evidenceRoutes: [...allowedRoutes], automation: [...allowedAutomation], statuses: [...allowedStatuses]
299
+ },
300
+ guidance: {
301
+ personaOutputIsAuthority: false,
302
+ hypothesesRequireHumanResolution: true,
303
+ humanEvidenceTakesPriority: true,
304
+ approvalsChanged: false,
305
+ premiumSyncAttempted: false
306
+ }
307
+ };
308
+ }
309
+
310
+ function textList(value, label, { required = true } = {}) {
311
+ if (!Array.isArray(value) || (required && !value.length) || value.length > MAX_LIST_ITEMS) {
312
+ throw new Error(`${label} must contain ${required ? 'one to ' : 'no more than '}${MAX_LIST_ITEMS} items`);
313
+ }
314
+ return value.map((item, index) => cleanText(item, `${label} item ${index + 1}`));
315
+ }
316
+
317
+ function validateScenario(root, raw, knownSources, knownPersonas) {
318
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Each test scenario must be an object');
319
+ const id = cleanText(raw.id, 'Scenario ID', 20);
320
+ if (!scenarioIdPattern.test(id)) throw new Error(`Scenario ID must match PTS-###: ${id}`);
321
+ const type = cleanText(raw.type, `${id} type`, 40);
322
+ if (!allowedTypes.has(type)) throw new Error(`${id} has unsupported scenario type: ${type}`);
323
+ const status = cleanText(raw.status, `${id} status`, 40);
324
+ if (!allowedStatuses.has(status)) throw new Error(`${id} has unsupported status: ${status}`);
325
+ const sourceRefs = textList(raw.sourceRefs ?? [], `${id} source references`, { required: status === 'accepted' });
326
+ for (const reference of sourceRefs) if (!knownSources.has(reference)) throw new Error(`${id} has unknown source reference: ${reference}`);
327
+ if (status === 'accepted' && !sourceRefs.length) throw new Error(`${id} accepted scenario requires a source reference`);
328
+ const contributions = raw.personaContributions;
329
+ if (!Array.isArray(contributions) || !contributions.length || contributions.length > 12) throw new Error(`${id} requires persona contributions`);
330
+ const personaContributions = contributions.map((item, index) => {
331
+ const personaId = cleanText(item?.personaId, `${id} persona contribution ${index + 1}`, 160);
332
+ if (!knownPersonas.has(personaId)) throw new Error(`${id} has unknown persona: ${personaId}`);
333
+ return { personaId, concern: cleanText(item?.concern, `${id} persona concern`, 500) };
334
+ });
335
+ const expectedResults = textList(raw.expectedResults, `${id} expected results`);
336
+ if (expectedResults.some((oracle) => vagueOraclePattern.test(oracle) || oracle.length < 12)) {
337
+ throw new Error(`${id} requires an observable expected result rather than a vague oracle`);
338
+ }
339
+ const evidenceRoute = cleanText(raw.evidenceRoute, `${id} evidence route`, 40);
340
+ if (!allowedRoutes.has(evidenceRoute)) throw new Error(`${id} has unsupported evidence route: ${evidenceRoute}`);
341
+ const automation = cleanText(raw.automation, `${id} automation`, 40);
342
+ if (!allowedAutomation.has(automation)) throw new Error(`${id} has unsupported automation classification: ${automation}`);
343
+ let plannedTest = null;
344
+ if (automation === 'automated' || automation === 'hybrid') {
345
+ if (!raw.plannedTest || typeof raw.plannedTest !== 'object') throw new Error(`${id} requires a planned test contract`);
346
+ plannedTest = {
347
+ file: assertSafeProjectPath(root, raw.plannedTest.file, `${id} planned test file`),
348
+ name: cleanText(raw.plannedTest.name, `${id} planned test name`, 300)
349
+ };
350
+ } else if (raw.plannedTest?.file || raw.plannedTest?.name) {
351
+ plannedTest = {
352
+ file: assertSafeProjectPath(root, raw.plannedTest.file, `${id} planned test file`),
353
+ name: cleanText(raw.plannedTest.name, `${id} planned test name`, 300)
354
+ };
355
+ }
356
+ return {
357
+ id,
358
+ title: cleanText(raw.title, `${id} title`, 240),
359
+ type,
360
+ sourceRefs,
361
+ personaContributions,
362
+ preconditions: textList(raw.preconditions, `${id} preconditions`),
363
+ actions: textList(raw.actions, `${id} actions`),
364
+ expectedResults,
365
+ evidenceRoute,
366
+ automation,
367
+ plannedTest,
368
+ owner: cleanText(raw.owner, `${id} owner`, 160),
369
+ status
370
+ };
371
+ }
372
+
373
+ function scenarioPayload(slug, brief, input, reviewer) {
374
+ if (input.schema !== SCENARIO_SCHEMA) throw new Error(`Scenario pack schema must be ${SCENARIO_SCHEMA}`);
375
+ if (input.slug !== slug) throw new Error('Scenario pack slug does not match the requested intent');
376
+ if (input.preparedSourceDigest !== brief.sourceDigest) throw new Error('Prepared source digest is stale or does not match this intent');
377
+ if (!Array.isArray(input.scenarios) || !input.scenarios.length || input.scenarios.length > MAX_SCENARIOS) {
378
+ throw new Error(`Scenario pack must contain one to ${MAX_SCENARIOS} scenarios`);
379
+ }
380
+ const knownSources = new Set(brief.authoritativeSources.map(({ id }) => id));
381
+ const knownPersonas = new Set(brief.activePersonas.map(({ id }) => id));
382
+ const scenarios = input.scenarios.map((scenario) => validateScenario(brief.projectRoot ?? '', scenario, knownSources, knownPersonas));
383
+ const ids = scenarios.map(({ id }) => id);
384
+ if (new Set(ids).size !== ids.length) throw new Error('Scenario pack contains duplicate scenario IDs');
385
+ const gaps = Array.isArray(input.gaps) ? input.gaps.slice(0, 100).map((gap, index) => {
386
+ if (typeof gap === 'string') return { id: `GAP-${String(index + 1).padStart(3, '0')}`, question: cleanText(gap, 'Gap', 500), status: 'open' };
387
+ return {
388
+ id: cleanText(gap?.id ?? `GAP-${String(index + 1).padStart(3, '0')}`, 'Gap ID', 40),
389
+ question: cleanText(gap?.question, 'Gap question', 500),
390
+ status: cleanText(gap?.status ?? 'open', 'Gap status', 40)
391
+ };
392
+ }) : [];
393
+ return {
394
+ schema: SCENARIO_SCHEMA,
395
+ slug,
396
+ preparedSourceDigest: brief.sourceDigest,
397
+ reviewer,
398
+ focus: brief.focus,
399
+ sources: brief.authoritativeSources.map(({ id, kind, label, path, digest, authority }) => ({ id, kind, label, path, digest, authority })),
400
+ contextualEvidence: brief.contextualEvidence.map(({ id, kind, label, path, digest, authority }) => ({ id, kind, label, path, digest, authority })),
401
+ activePersonas: brief.activePersonas,
402
+ availability: brief.availability,
403
+ scenarios,
404
+ gaps,
405
+ authority: {
406
+ personaPerspectivesAreAdvisory: true,
407
+ humanEvidenceTakesPriority: true,
408
+ approvalsChanged: false,
409
+ premiumSyncAttempted: false
410
+ }
411
+ };
412
+ }
413
+
414
+ function recordDigestPayload(record) {
415
+ const { contentDigest: _digest, markdownDigest: _markdownDigest, reviewedAt: _reviewedAt, ...payload } = record;
416
+ return payload;
417
+ }
418
+
419
+ function renderMarkdown(record) {
420
+ const personaRows = record.activePersonas.length
421
+ ? record.activePersonas.map((persona) => `| ${persona.name.replaceAll('|', '\\|')} | ${persona.tier} | ${persona.matchedSignals.join(', ')} | ${persona.engagementReason.replaceAll('|', '\\|')} |`).join('\n')
422
+ : '| No installed persona matched | — | — | — |';
423
+ const scenarioSections = record.scenarios.map((scenario) => `### ${scenario.id} — ${scenario.title}\n\n- **Status:** ${scenario.status}\n- **Type:** ${scenario.type}\n- **Sources:** ${scenario.sourceRefs.map((id) => `\`${id}\``).join(', ') || 'Unresolved hypothesis'}\n- **Evidence route:** ${scenario.evidenceRoute}\n- **Automation:** ${scenario.automation}\n- **Owner:** ${scenario.owner}\n- **Preconditions:** ${scenario.preconditions.join('; ')}\n- **Actions:** ${scenario.actions.join('; ')}\n- **Expected results:** ${scenario.expectedResults.join('; ')}\n- **Planned test:** ${scenario.plannedTest ? `\`${scenario.plannedTest.file}\` — ${scenario.plannedTest.name}` : 'human evidence route'}\n`).join('\n');
424
+ const gapRows = record.gaps.length ? record.gaps.map((gap) => `- **${gap.id}:** ${gap.question} (${gap.status})`).join('\n') : '- None recorded.';
425
+ return `# Persona-Driven Test Scenarios — ${record.slug}\n\n**Schema:** \`${record.schema}\` \n**Reviewed by:** ${record.reviewer} \n**Reviewed at:** ${record.reviewedAt} \n**Content digest:** \`${record.contentDigest}\` \n**Prepared source digest:** \`${record.preparedSourceDigest}\`\n\n## Active personas\n\n| Persona | Tier | Matched concerns | Engagement reason |\n| --- | --- | --- | --- |\n${personaRows}\n\n## Scenarios\n\n${scenarioSections}\n## Gaps and unresolved hypotheses\n\n${gapRows}\n\n## Authority boundary\n\nPersona perspectives are advisory. They do not create requirements, approve delivery, complete Manual QA, provide specialist assurance, or replace representative users. Human evidence and accountable decisions retain authority.\n`;
426
+ }
427
+
428
+ function transactionPaths(projectRoot, slug) {
429
+ const root = resolve(projectRoot, '.ewai-pipeline/runtime/test-scenario-transactions');
430
+ return { root, journal: resolve(root, `${slug}.json`) };
431
+ }
432
+
433
+ function transactionStagePath(value, targetPath, deliveryRoot, label) {
434
+ const candidate = resolve(String(value ?? ''));
435
+ if (
436
+ !value
437
+ || !isWithin(deliveryRoot, candidate)
438
+ || !candidate.startsWith(`${targetPath}.`)
439
+ || !candidate.endsWith('.tmp')
440
+ ) {
441
+ throw new Error(`Unsafe test-scenario transaction ${label} path`);
442
+ }
443
+ return candidate;
444
+ }
445
+
446
+ function cleanTransaction(projectRoot, slug) {
447
+ const paths = deliveryPaths(projectRoot, slug);
448
+ const transaction = transactionPaths(projectRoot, slug);
449
+ if (!existsSync(transaction.journal)) return;
450
+ let journal;
451
+ try {
452
+ journal = JSON.parse(readFileSync(transaction.journal, 'utf8'));
453
+ } catch (error) {
454
+ throw new Error(`Invalid test-scenario transaction journal: ${error.message}`);
455
+ }
456
+ if (
457
+ !journal
458
+ || typeof journal !== 'object'
459
+ || ![undefined, 'ewai.test-scenario-transaction/v1'].includes(journal.schema)
460
+ || journal.slug !== slug
461
+ || !['staged', 'json-committed', 'committed'].includes(journal.phase)
462
+ ) {
463
+ throw new Error('Invalid test-scenario transaction journal contract');
464
+ }
465
+ const jsonPath = resolve(paths.deliveryRoot, 'test-scenarios.json');
466
+ const markdownPath = resolve(paths.deliveryRoot, 'test-scenarios.md');
467
+ const stages = [
468
+ transactionStagePath(journal.jsonStage, jsonPath, paths.deliveryRoot, 'JSON stage'),
469
+ transactionStagePath(journal.markdownStage, markdownPath, paths.deliveryRoot, 'Markdown stage')
470
+ ];
471
+ for (const path of stages) rmSync(path, { force: true });
472
+ if (journal.phase !== 'committed') {
473
+ rmSync(jsonPath, { force: true });
474
+ rmSync(markdownPath, { force: true });
475
+ }
476
+ rmSync(transaction.journal, { force: true });
477
+ }
478
+
479
+ function writePair(projectRoot, slug, jsonContent, markdownContent, options = {}) {
480
+ const paths = deliveryPaths(projectRoot, slug);
481
+ const jsonPath = resolve(paths.deliveryRoot, 'test-scenarios.json');
482
+ const markdownPath = resolve(paths.deliveryRoot, 'test-scenarios.md');
483
+ const transaction = transactionPaths(projectRoot, slug);
484
+ mkdirSync(paths.deliveryRoot, { recursive: true });
485
+ mkdirSync(transaction.root, { recursive: true });
486
+ const nonce = `${process.pid}-${randomUUID()}`;
487
+ const jsonStage = `${jsonPath}.${nonce}.tmp`;
488
+ const markdownStage = `${markdownPath}.${nonce}.tmp`;
489
+ const journal = { schema: 'ewai.test-scenario-transaction/v1', slug, phase: 'staged', jsonStage, markdownStage };
490
+ try {
491
+ writeFileSync(jsonStage, jsonContent, 'utf8');
492
+ writeFileSync(markdownStage, markdownContent, 'utf8');
493
+ writeFileSync(transaction.journal, `${JSON.stringify(journal, null, 2)}\n`, 'utf8');
494
+ renameSync(jsonStage, jsonPath);
495
+ journal.phase = 'json-committed';
496
+ writeFileSync(transaction.journal, `${JSON.stringify(journal, null, 2)}\n`, 'utf8');
497
+ if (options.failAfterStage === 'json-committed') throw new Error('Test-scenario write was interrupted after JSON commit');
498
+ renameSync(markdownStage, markdownPath);
499
+ journal.phase = 'committed';
500
+ writeFileSync(transaction.journal, `${JSON.stringify(journal, null, 2)}\n`, 'utf8');
501
+ rmSync(transaction.journal, { force: true });
502
+ } catch (error) {
503
+ rmSync(jsonStage, { force: true });
504
+ rmSync(markdownStage, { force: true });
505
+ rmSync(jsonPath, { force: true });
506
+ rmSync(markdownPath, { force: true });
507
+ rmSync(transaction.journal, { force: true });
508
+ throw error;
509
+ }
510
+ return { jsonPath, markdownPath };
511
+ }
512
+
513
+ export function recordPersonaTestScenarios(projectRoot, slug, input = {}, options = {}) {
514
+ const root = resolve(projectRoot);
515
+ cleanTransaction(root, slug);
516
+ const reviewer = cleanText(options.reviewedBy ?? input.reviewedBy, 'Named reviewer', 160);
517
+ const catalogue = Array.isArray(options.personas) ? options.personas : Array.isArray(options.personaCatalogue) ? options.personaCatalogue : [];
518
+ const brief = preparePersonaTestScenarioBrief(root, slug, { focus: input.focus ?? options.focus ?? '', personas: catalogue });
519
+ // Path validation needs the project root while the brief itself remains a safe projection.
520
+ brief.projectRoot = root;
521
+ const payload = scenarioPayload(slug, brief, input, reviewer);
522
+ delete brief.projectRoot;
523
+ const contentDigest = sha256(canonicalJson(payload));
524
+ const paths = deliveryPaths(root, slug);
525
+ const jsonPath = resolve(paths.deliveryRoot, 'test-scenarios.json');
526
+ const markdownPath = resolve(paths.deliveryRoot, 'test-scenarios.md');
527
+ if (existsSync(jsonPath) || existsSync(markdownPath)) {
528
+ if (!existsSync(jsonPath) || !existsSync(markdownPath)) throw new Error('Existing persona test-scenario evidence is incomplete and will not be overwritten');
529
+ const existing = readJson(jsonPath, 'Existing persona test-scenario evidence');
530
+ const existingDigest = sha256(canonicalJson(recordDigestPayload(existing)));
531
+ if (existing.contentDigest === contentDigest && existingDigest === contentDigest && sha256(readFileSync(markdownPath, 'utf8')) === existing.markdownDigest) {
532
+ return { schema: 'ewai.persona-test-recording/v1', status: 'recorded', digest: contentDigest, jsonPath: relativePath(root, jsonPath), markdownPath: relativePath(root, markdownPath), idempotent: true };
533
+ }
534
+ throw new Error('Conflicting accepted persona test-scenario evidence already exists and will not be overwritten');
535
+ }
536
+ const record = { ...payload, reviewedAt: options.now ?? new Date().toISOString(), contentDigest };
537
+ const markdown = renderMarkdown(record);
538
+ record.markdownDigest = sha256(markdown);
539
+ const json = `${JSON.stringify(record, null, 2)}\n`;
540
+ writePair(root, slug, json, markdown, options);
541
+ return { schema: 'ewai.persona-test-recording/v1', status: 'recorded', digest: contentDigest, jsonPath: relativePath(root, jsonPath), markdownPath: relativePath(root, markdownPath), idempotent: false };
542
+ }
543
+
544
+ function safeWorkspaceRecord(record, status, reason = '') {
545
+ return {
546
+ schema: WORKSPACE_SCHEMA,
547
+ slug: record.slug,
548
+ status,
549
+ reason,
550
+ reviewer: record.reviewer,
551
+ reviewedAt: record.reviewedAt,
552
+ digest: record.contentDigest,
553
+ preparedSourceDigest: record.preparedSourceDigest,
554
+ sources: record.sources,
555
+ contextualEvidence: record.contextualEvidence,
556
+ activePersonas: record.activePersonas,
557
+ availability: record.availability,
558
+ scenarios: record.scenarios,
559
+ gaps: record.gaps,
560
+ evidenceRoutes: [...new Set(record.scenarios.map(({ evidenceRoute }) => evidenceRoute))],
561
+ guidance: { advisory: true, humanEvidenceTakesPriority: true, approvalsChanged: false, readOnly: true }
562
+ };
563
+ }
564
+
565
+ export function readPersonaTestScenarioWorkspace(projectRoot, slug, options = {}) {
566
+ const root = resolve(projectRoot);
567
+ const safeSlug = cleanText(slug, 'Intent slug', 100);
568
+ // Validate the intent before recovery so a malformed or arbitrary slug cannot create a phantom workspace.
569
+ findIntent(root, safeSlug);
570
+ try {
571
+ cleanTransaction(root, safeSlug);
572
+ } catch (error) {
573
+ return {
574
+ schema: WORKSPACE_SCHEMA,
575
+ slug: safeSlug,
576
+ status: 'invalid',
577
+ reason: error.message,
578
+ activePersonas: [], sources: [], contextualEvidence: [], scenarios: [], gaps: [], evidenceRoutes: [],
579
+ guidance: { advisory: true, humanEvidenceTakesPriority: true, approvalsChanged: false, readOnly: true }
580
+ };
581
+ }
582
+ const paths = deliveryPaths(root, safeSlug);
583
+ const jsonPath = resolve(paths.deliveryRoot, 'test-scenarios.json');
584
+ const markdownPath = resolve(paths.deliveryRoot, 'test-scenarios.md');
585
+ if (!existsSync(jsonPath) && !existsSync(markdownPath)) {
586
+ const catalogue = Array.isArray(options.personas) ? options.personas : Array.isArray(options.personaCatalogue) ? options.personaCatalogue : [];
587
+ return {
588
+ schema: WORKSPACE_SCHEMA,
589
+ slug: safeSlug,
590
+ status: 'missing',
591
+ reason: 'No reviewed persona test-scenario pack has been recorded.',
592
+ activePersonas: [],
593
+ availability: personaAvailability(catalogue),
594
+ sources: [], contextualEvidence: [], scenarios: [], gaps: [], evidenceRoutes: [],
595
+ nextAction: `ewai test-scenarios prepare ${safeSlug} --project .`,
596
+ guidance: { advisory: true, humanEvidenceTakesPriority: true, approvalsChanged: false, readOnly: true }
597
+ };
598
+ }
599
+ if (!existsSync(jsonPath) || !existsSync(markdownPath)) {
600
+ return { schema: WORKSPACE_SCHEMA, slug: safeSlug, status: 'invalid', reason: 'The paired persona test-scenario evidence is incomplete.', activePersonas: [], sources: [], scenarios: [], gaps: [], evidenceRoutes: [], guidance: { readOnly: true } };
601
+ }
602
+ let record;
603
+ try {
604
+ record = readJson(jsonPath, 'Persona test-scenario evidence');
605
+ if (record.schema !== SCENARIO_SCHEMA || record.slug !== safeSlug) throw new Error('Persona test-scenario JSON schema or slug is invalid');
606
+ if (sha256(canonicalJson(recordDigestPayload(record))) !== record.contentDigest) throw new Error('Persona test-scenario content digest does not match');
607
+ if (sha256(readFileSync(markdownPath, 'utf8')) !== record.markdownDigest) throw new Error('Persona test-scenario Markdown digest does not match');
608
+ } catch (error) {
609
+ return { schema: WORKSPACE_SCHEMA, slug: safeSlug, status: 'invalid', reason: error.message, activePersonas: [], sources: [], scenarios: [], gaps: [], evidenceRoutes: [], guidance: { readOnly: true } };
610
+ }
611
+ const catalogue = Array.isArray(options.personas) ? options.personas : Array.isArray(options.personaCatalogue) ? options.personaCatalogue : [];
612
+ let current;
613
+ try {
614
+ current = preparePersonaTestScenarioBrief(root, safeSlug, { focus: record.focus, personas: catalogue });
615
+ } catch (error) {
616
+ return safeWorkspaceRecord(record, 'invalid', `Current authoritative sources cannot be read: ${error.message}`);
617
+ }
618
+ if (current.sourceDigest !== record.preparedSourceDigest) {
619
+ return safeWorkspaceRecord(record, 'stale', 'Authoritative source evidence changed after this pack was reviewed. Prepare and review it again.');
620
+ }
621
+ return safeWorkspaceRecord(record, 'recorded');
622
+ }