bmad-plus 0.14.0 → 0.16.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 (60) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +34 -15
  3. package/SECURITY.md +71 -0
  4. package/THIRD-PARTY-LICENSES.md +349 -0
  5. package/osint-agent-package/README.md +1 -1
  6. package/package.json +9 -3
  7. package/readme-international/README.de.md +14 -8
  8. package/readme-international/README.es.md +15 -9
  9. package/readme-international/README.fr.md +14 -8
  10. package/src/bmad-plus/agents/agent-architect-dev/SKILL.md +11 -13
  11. package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +147 -8
  12. package/src/bmad-plus/agents/agent-quality/SKILL.md +41 -11
  13. package/src/bmad-plus/data/role-triggers.yaml +19 -0
  14. package/src/bmad-plus/module-help.csv +1 -0
  15. package/src/bmad-plus/module.yaml +1 -0
  16. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +3 -1
  17. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +2 -0
  18. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +5 -1
  19. package/src/bmad-plus/packs/pack-memory/README.md +29 -4
  20. package/src/bmad-plus/packs/pack-memory/memory-orchestrator.md +21 -1
  21. package/src/bmad-plus/packs/pack-memory/shared/karpathy-guardrails.md +3 -3
  22. package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +27 -3
  23. package/src/bmad-plus/packs/pack-memory/zecher-agent.md +18 -2
  24. package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +47 -10
  25. package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +17 -3
  26. package/src/bmad-plus/skills/bmad-plus-sync/SKILL.md +76 -67
  27. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +144 -0
  28. package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +60 -0
  29. package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-spec.schema.json +121 -0
  30. package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-triage.schema.json +60 -0
  31. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +552 -0
  32. package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +362 -0
  33. package/tools/build/check-install-contract.js +202 -4
  34. package/tools/build/generate.js +16 -0
  35. package/tools/build/generated-adapters/.codex/AGENTS.md +1 -1
  36. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +1 -1
  37. package/tools/build/generated-adapters/.opencode/AGENTS.md +1 -1
  38. package/tools/build/generated-adapters/AGENTS.md +1 -1
  39. package/tools/build/generated-adapters/CLAUDE.md +1 -1
  40. package/tools/build/generated-adapters/CONVENTIONS.md +1 -1
  41. package/tools/build/generated-adapters/GEMINI.md +1 -1
  42. package/tools/cli/bmad-plus-cli.js +15 -12
  43. package/tools/cli/commands/doctor.js +1 -0
  44. package/tools/cli/commands/install.js +21 -2
  45. package/tools/cli/commands/memory-journal-cmd.js +119 -19
  46. package/tools/cli/commands/nexus.js +111 -0
  47. package/tools/cli/commands/uat.js +389 -0
  48. package/tools/cli/lib/README-memory-journal.md +19 -8
  49. package/tools/cli/lib/installation-health.js +6 -0
  50. package/tools/cli/lib/memory-journal.js +0 -0
  51. package/tools/cli/lib/memory-outcomes.js +293 -0
  52. package/tools/cli/lib/memory-store.js +139 -0
  53. package/tools/cli/lib/nexus-process.js +377 -0
  54. package/tools/cli/lib/nexus.js +1532 -0
  55. package/tools/cli/lib/pack-copy.js +39 -11
  56. package/tools/cli/lib/packs.js +17 -3
  57. package/tools/cli/lib/uat.js +869 -0
  58. package/tools/maintain/upstream-candidate.js +456 -0
  59. package/tools/release/publication-content.js +3 -1
  60. package/tools/release/supply-chain.js +282 -0
@@ -0,0 +1,869 @@
1
+ /** Human acceptance recipes (recette): spec validation, page build, run reading and gate. */
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+
8
+ const SPEC_SCHEMA = 'bmad-plus/uat-spec/2';
9
+ const RESULTS_SCHEMA = 'bmad-plus/uat-results/2';
10
+ const TRIAGE_SCHEMA = 'bmad-plus/uat-triage/1';
11
+ const LEGACY_SPEC_SCHEMA = 'recette-interactive/1';
12
+ const LEGACY_RESULTS_SCHEMA = 'recette-interactive/resultats/1';
13
+
14
+ const DEFAULT_DIR = '_bmad-output/uat';
15
+ const MAX_FILE = 4 * 1024 * 1024;
16
+ const MAX_SOURCE_FILE = 512 * 1024;
17
+ const MAX_SOURCE_FILES = 20000;
18
+ const DEFAULT_BUDGET = { maxSteps: 15, maxMinutes: 30 };
19
+ const STATES = ['passed', 'failed', 'blocked', 'skipped'];
20
+ const TRIAGE_CLASSES = ['product', 'recipe', 'data', 'undecided'];
21
+ const TRIAGE_DECISIONS = ['fix', 'amend-spec', 'remeasure', 'accept-risk', 'ask-tester'];
22
+ const VERIFY_KINDS = ['sql', 'http', 'command', 'manual'];
23
+
24
+ const SPEC_KEYS = [
25
+ '$schema',
26
+ 'schema',
27
+ 'id',
28
+ 'product',
29
+ 'versions',
30
+ 'date',
31
+ 'language',
32
+ 'title',
33
+ 'subtitle',
34
+ 'environment',
35
+ 'estimate',
36
+ 'intro',
37
+ 'warnings',
38
+ 'notes',
39
+ 'witnesses',
40
+ 'after',
41
+ 'steps',
42
+ 'closing',
43
+ 'authorNotes',
44
+ ];
45
+ const STEP_KEYS = [
46
+ 'id',
47
+ 'title',
48
+ 'duration',
49
+ 'writes',
50
+ 'warning',
51
+ 'optional',
52
+ 'stories',
53
+ 'verify',
54
+ 'where',
55
+ 'do',
56
+ 'expect',
57
+ ];
58
+ const EXPECT_KEYS = ['id', 'text'];
59
+ const WITNESS_KEYS = ['id', 'proof', 'reads', 'writes'];
60
+
61
+ /** Inline markup a tester's page may contain. Anything else is refused, never stripped. */
62
+ const ALLOWED_TAGS = {
63
+ b: [],
64
+ i: [],
65
+ em: [],
66
+ strong: [],
67
+ code: [],
68
+ br: [],
69
+ span: ['class'],
70
+ a: ['href', 'target', 'rel'],
71
+ };
72
+
73
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
74
+ const canonical = (spec) => JSON.stringify(spec);
75
+ const specHash = (spec) => sha256(canonical(spec));
76
+
77
+ function layout(projectDir, dir = DEFAULT_DIR) {
78
+ const root = path.resolve(projectDir, dir);
79
+ return {
80
+ root,
81
+ specs: path.join(root, 'specs'),
82
+ pages: path.join(root, 'pages'),
83
+ results: path.join(root, 'results'),
84
+ triage: path.join(root, 'triage'),
85
+ checks: path.join(root, 'checks'),
86
+ };
87
+ }
88
+
89
+ function readJson(file) {
90
+ const stat = fs.lstatSync(file);
91
+ if (stat.isSymbolicLink()) throw new Error(`${file} is a symbolic link; refused.`);
92
+ if (stat.size > MAX_FILE) throw new Error(`${file} exceeds ${MAX_FILE} bytes.`);
93
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
94
+ }
95
+
96
+ // ── Spec: legacy adapter, validation, lint ────────────────────────────────────
97
+
98
+ /** FormaPro's `recette-interactive/1` files stay readable: keys are mapped, nothing is guessed. */
99
+ function fromLegacySpec(doc) {
100
+ const step = (e) => ({
101
+ id: e.id,
102
+ title: e.titre,
103
+ ...(e.duree ? { duration: e.duree } : {}),
104
+ ...(e.ecrit === undefined ? {} : { writes: e.ecrit }),
105
+ ...(e.avertissement ? { warning: e.avertissement } : {}),
106
+ where: e.ou || [],
107
+ do: e.faire || [],
108
+ expect: (e.attendus || []).map((a) => ({ id: a.id, text: a.texte })),
109
+ });
110
+ return {
111
+ schema: SPEC_SCHEMA,
112
+ id: doc.id,
113
+ product: doc.produit,
114
+ versions: doc.versions || [],
115
+ ...(doc.date ? { date: doc.date } : {}),
116
+ language: 'fr',
117
+ title: doc.titre,
118
+ ...(doc.sousTitre ? { subtitle: doc.sousTitre } : {}),
119
+ environment: { name: doc.environnement?.nom, url: doc.environnement?.url },
120
+ ...(doc.dureeEstimee ? { estimate: doc.dureeEstimee } : {}),
121
+ ...(doc.intro ? { intro: doc.intro } : {}),
122
+ ...(doc.avertissements ? { warnings: doc.avertissements } : {}),
123
+ ...(doc.infos ? { notes: doc.infos } : {}),
124
+ steps: (doc.etapes || []).map(step),
125
+ ...(doc.cloture?.texte ? { closing: { text: doc.cloture.texte } } : {}),
126
+ ...(doc.notesAgent ? { authorNotes: doc.notesAgent } : {}),
127
+ };
128
+ }
129
+
130
+ function loadSpec(file) {
131
+ const doc = readJson(file);
132
+ const legacy = doc && doc.schema === LEGACY_SPEC_SCHEMA;
133
+ const spec = legacy ? fromLegacySpec(doc) : doc;
134
+ return { spec, legacy, sha256: specHash(spec), file };
135
+ }
136
+
137
+ function unknownKeys(object, allowed) {
138
+ return Object.keys(object || {}).filter((key) => !allowed.includes(key));
139
+ }
140
+
141
+ function checkMarkup(value, where, errors) {
142
+ if (typeof value !== 'string') {
143
+ errors.push(`${where}: expected text`);
144
+ return;
145
+ }
146
+ let rest = value;
147
+ const tag = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^<>]*)?)\/?>/g;
148
+ let match;
149
+ while ((match = tag.exec(value))) {
150
+ const [, closing, rawName, rawAttrs] = match;
151
+ const name = rawName.toLowerCase();
152
+ if (!Object.prototype.hasOwnProperty.call(ALLOWED_TAGS, name)) {
153
+ errors.push(`${where}: <${name}> is not allowed in a tester page`);
154
+ continue;
155
+ }
156
+ if (!closing) {
157
+ const attrs = [...(rawAttrs || '').matchAll(/([a-zA-Z-]+)\s*=\s*"([^"]*)"/g)];
158
+ const named = attrs.map(([, key]) => key.toLowerCase());
159
+ for (const key of named) {
160
+ if (!ALLOWED_TAGS[name].includes(key))
161
+ errors.push(`${where}: <${name} ${key}> is not allowed`);
162
+ }
163
+ if ((rawAttrs || '').replace(/([a-zA-Z-]+)\s*=\s*"([^"]*)"/g, '').trim()) {
164
+ errors.push(`${where}: <${name}> has an attribute that is not a quoted name="value" pair`);
165
+ }
166
+ for (const [, key, val] of attrs) {
167
+ if (name === 'span' && key.toLowerCase() === 'class' && val !== 'ecran') {
168
+ errors.push(`${where}: <span class="${val}"> — only class="ecran" is allowed`);
169
+ }
170
+ if (name === 'a' && key.toLowerCase() === 'href' && !/^https:\/\//.test(val)) {
171
+ errors.push(`${where}: link "${val}" must be https`);
172
+ }
173
+ }
174
+ }
175
+ rest = rest.replace(match[0], '');
176
+ }
177
+ if (rest.includes('<')) errors.push(`${where}: a "<" is left outside any allowed tag`);
178
+ }
179
+
180
+ function eachMarkup(spec, visit) {
181
+ const list = (values, where) => (values || []).forEach((v, i) => visit(v, `${where}[${i}]`));
182
+ list(spec.intro, 'intro');
183
+ list(spec.warnings, 'warnings');
184
+ list(spec.notes, 'notes');
185
+ list(spec.closing?.text, 'closing.text');
186
+ for (const step of spec.steps || []) {
187
+ if (step.warning) visit(step.warning, `step ${step.id}: warning`);
188
+ list(step.where, `step ${step.id}: where`);
189
+ list(step.do, `step ${step.id}: do`);
190
+ for (const expect of step.expect || []) visit(expect.text, `step ${step.id}/${expect.id}`);
191
+ }
192
+ }
193
+
194
+ function validateSpec(spec, options = {}) {
195
+ const errors = [];
196
+ const warnings = [];
197
+ const budget = { ...DEFAULT_BUDGET, ...(options.budget || {}) };
198
+ const need = (condition, message) => {
199
+ if (!condition) errors.push(message);
200
+ };
201
+
202
+ need(spec && spec.schema === SPEC_SCHEMA, `schema must be "${SPEC_SCHEMA}"`);
203
+ need(
204
+ typeof spec.id === 'string' && /^[a-z0-9][a-z0-9.-]{2,80}$/.test(spec.id),
205
+ 'id: lowercase letters, digits, dots and dashes (e.g. formapro-1.203.0)'
206
+ );
207
+ need(typeof spec.product === 'string' && spec.product, 'product is missing');
208
+ need(Array.isArray(spec.versions) && spec.versions.length, 'versions: at least one');
209
+ need(typeof spec.title === 'string' && spec.title, 'title is missing');
210
+ need(spec.environment && spec.environment.name, 'environment.name is required');
211
+ if (spec.environment?.url) {
212
+ need(/^https?:\/\//.test(spec.environment.url), 'environment.url must be http(s)');
213
+ }
214
+ need(Array.isArray(spec.steps) && spec.steps.length, 'steps: at least one');
215
+ for (const key of unknownKeys(spec, SPEC_KEYS)) {
216
+ errors.push(`unknown key at spec level: "${key}" (a typo would be ignored in silence)`);
217
+ }
218
+
219
+ const stepIds = new Set();
220
+ for (const [index, step] of (spec.steps || []).entries()) {
221
+ const at = `step ${step?.id ?? index}`;
222
+ for (const key of unknownKeys(step, STEP_KEYS)) errors.push(`${at}: unknown key "${key}"`);
223
+ need(
224
+ typeof step.id === 'string' && /^[a-z0-9][a-z0-9-]{0,60}$/.test(step.id),
225
+ `${at}: invalid id`
226
+ );
227
+ need(!stepIds.has(step.id), `${at}: duplicate id`);
228
+ stepIds.add(step.id);
229
+ need(step.title, `${at}: title is missing`);
230
+ need(Array.isArray(step.where) && step.where.length, `${at}: "where" is empty`);
231
+ need(Array.isArray(step.do) && step.do.length, `${at}: "do" is empty`);
232
+ need(Array.isArray(step.expect) && step.expect.length, `${at}: no expectation`);
233
+ need(
234
+ step.writes === undefined || typeof step.writes === 'boolean',
235
+ `${at}: writes must be true or false`
236
+ );
237
+ need(
238
+ step.optional === undefined || typeof step.optional === 'boolean',
239
+ `${at}: optional must be true or false`
240
+ );
241
+ if (step.writes) {
242
+ // A tick on a step that writes is an observation; the read-only proof is what settles it.
243
+ need(
244
+ step.verify &&
245
+ VERIFY_KINDS.includes(step.verify.kind) &&
246
+ String(step.verify.text || '').trim(),
247
+ `${at}: writes: true requires verify {kind: ${VERIFY_KINDS.join('|')}, text} — the read-only check run after the tester`
248
+ );
249
+ }
250
+ const letters = new Set();
251
+ for (const expect of step.expect || []) {
252
+ for (const key of unknownKeys(expect, EXPECT_KEYS))
253
+ errors.push(`${at}: expectation ${expect.id}: unknown key "${key}"`);
254
+ need(
255
+ typeof expect.id === 'string' && /^[a-z]$/.test(expect.id),
256
+ `${at}: expectation id must be one letter (${expect.id})`
257
+ );
258
+ need(!letters.has(expect.id), `${at}: duplicate expectation ${expect.id}`);
259
+ letters.add(expect.id);
260
+ need(expect.text, `${at}: expectation ${expect.id} has no text`);
261
+ const screens = String(expect.text || '').match(/<span class="ecran">/g) || [];
262
+ if (screens.length > 1 && /\b(et|and)\b/i.test(String(expect.text))) {
263
+ warnings.push(
264
+ `${at}/${expect.id}: two on-screen labels joined by "and" — one fact per expectation, or the tester cannot answer`
265
+ );
266
+ }
267
+ }
268
+ }
269
+
270
+ for (const witness of spec.witnesses || []) {
271
+ for (const key of unknownKeys(witness, WITNESS_KEYS))
272
+ errors.push(`witness ${witness.id}: unknown key "${key}"`);
273
+ need(witness.id, 'a witness has no id');
274
+ need(
275
+ String(witness.proof || '').trim(),
276
+ `witness ${witness.id}: proof is required (the read-only query that establishes its state)`
277
+ );
278
+ for (const ref of [...(witness.reads || []), ...(witness.writes || [])]) {
279
+ need(stepIds.has(ref), `witness ${witness.id}: unknown step "${ref}"`);
280
+ }
281
+ }
282
+
283
+ eachMarkup(spec, (value, where) => checkMarkup(value, where, errors));
284
+
285
+ const steps = (spec.steps || []).length;
286
+ if (steps > budget.maxSteps) {
287
+ warnings.push(
288
+ `${steps} steps: over the ${budget.maxSteps}-step budget — split into ordered pages, a step meant "for later" gets played at once`
289
+ );
290
+ }
291
+ const minutes = Number.parseInt(String(spec.estimate || '').replace(/[^0-9]/g, ''), 10);
292
+ if (Number.isFinite(minutes) && minutes > budget.maxMinutes) {
293
+ warnings.push(
294
+ `estimated ${minutes} min: over the ${budget.maxMinutes}-minute budget — split into ordered pages`
295
+ );
296
+ }
297
+ return { errors, warnings };
298
+ }
299
+
300
+ const decodeEntities = (value) =>
301
+ String(value)
302
+ .replace(/&nbsp;/g, ' ')
303
+ .replace(/&lt;/g, '<')
304
+ .replace(/&gt;/g, '>')
305
+ .replace(/&quot;/g, '"')
306
+ .replace(/&#39;/g, "'")
307
+ .replace(/&amp;/g, '&');
308
+ const flatten = (value) =>
309
+ decodeEntities(String(value).replace(/<[^>]+>/g, ''))
310
+ .replace(/\s+/g, ' ')
311
+ .trim();
312
+
313
+ /** Every quoted on-screen label, with the expectation it belongs to. */
314
+ function screenLabels(spec) {
315
+ const labels = [];
316
+ for (const step of spec.steps || []) {
317
+ const visit = (value, at) => {
318
+ for (const [, inner] of String(value).matchAll(/<span class="ecran">([\s\S]*?)<\/span>/g)) {
319
+ const label = flatten(inner);
320
+ if (label) labels.push({ label, at });
321
+ }
322
+ };
323
+ (step.where || []).forEach((v) => visit(v, `step ${step.id}: where`));
324
+ (step.do || []).forEach((v) => visit(v, `step ${step.id}: do`));
325
+ (step.expect || []).forEach((e) => visit(e.text, `step ${step.id}/${e.id}`));
326
+ }
327
+ return labels;
328
+ }
329
+
330
+ const SOURCE_EXTENSIONS = [
331
+ '.ts',
332
+ '.tsx',
333
+ '.js',
334
+ '.jsx',
335
+ '.mjs',
336
+ '.cjs',
337
+ '.vue',
338
+ '.svelte',
339
+ '.html',
340
+ '.htm',
341
+ '.php',
342
+ '.py',
343
+ '.rb',
344
+ '.java',
345
+ '.kt',
346
+ '.cs',
347
+ '.go',
348
+ '.json',
349
+ '.yaml',
350
+ '.yml',
351
+ '.md',
352
+ ];
353
+ const SKIPPED_DIRS = new Set([
354
+ 'node_modules',
355
+ '.git',
356
+ 'dist',
357
+ 'build',
358
+ 'coverage',
359
+ '.next',
360
+ '.nuxt',
361
+ 'vendor',
362
+ '__pycache__',
363
+ '.venv',
364
+ 'venv',
365
+ 'target',
366
+ 'out',
367
+ ]);
368
+
369
+ function sourceHaystack(roots) {
370
+ let files = 0;
371
+ const chunks = [];
372
+ const walk = (dir) => {
373
+ let entries;
374
+ try {
375
+ entries = fs.readdirSync(dir, { withFileTypes: true });
376
+ } catch {
377
+ return;
378
+ }
379
+ for (const entry of entries) {
380
+ if (entry.isSymbolicLink()) continue;
381
+ const full = path.join(dir, entry.name);
382
+ if (entry.isDirectory()) {
383
+ if (!SKIPPED_DIRS.has(entry.name) && !entry.name.startsWith('.')) walk(full);
384
+ continue;
385
+ }
386
+ if (!SOURCE_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
387
+ if (files >= MAX_SOURCE_FILES) return;
388
+ let stat;
389
+ try {
390
+ stat = fs.statSync(full);
391
+ } catch {
392
+ continue;
393
+ }
394
+ if (stat.size > MAX_SOURCE_FILE) continue;
395
+ files += 1;
396
+ try {
397
+ chunks.push(fs.readFileSync(full, 'utf8').replace(/\s+/g, ' '));
398
+ } catch {
399
+ /* unreadable file: it simply proves nothing */
400
+ }
401
+ }
402
+ };
403
+ for (const root of roots) walk(path.resolve(root));
404
+ return { text: chunks.join('\n'), files };
405
+ }
406
+
407
+ /**
408
+ * Lint: schema, markup, budget, and the check that pays most — every quoted label
409
+ * must exist verbatim in the source. Wording and accent drift produced most of the
410
+ * false "not seen" measured on FormaPro.
411
+ */
412
+ function lintSpec(spec, options = {}) {
413
+ const { errors, warnings } = validateSpec(spec, options);
414
+ const labels = screenLabels(spec);
415
+ const missing = [];
416
+ let scannedFiles = 0;
417
+ if ((options.sources || []).length) {
418
+ const { text, files } = sourceHaystack(options.sources);
419
+ scannedFiles = files;
420
+ const seen = new Set();
421
+ for (const { label, at } of labels) {
422
+ if (seen.has(label)) continue;
423
+ seen.add(label);
424
+ if (!text.includes(label)) missing.push({ label, at });
425
+ }
426
+ for (const { label, at } of missing) {
427
+ errors.push(
428
+ `${at}: the label "${label}" is in no source file — copy it from the component, accents included or not`
429
+ );
430
+ }
431
+ }
432
+ return { errors, warnings, labels: labels.length, scannedFiles, missing };
433
+ }
434
+
435
+ // ── Page build ────────────────────────────────────────────────────────────────
436
+
437
+ const TEMPLATE_DIR = path.join(
438
+ __dirname,
439
+ '..',
440
+ '..',
441
+ '..',
442
+ 'src',
443
+ 'bmad-plus',
444
+ 'skills',
445
+ 'bmad-plus-uat',
446
+ 'template'
447
+ );
448
+ const TEMPLATE = path.join(TEMPLATE_DIR, 'page.html');
449
+
450
+ const STRINGS = JSON.parse(fs.readFileSync(path.join(TEMPLATE_DIR, 'strings.json'), 'utf8'));
451
+
452
+ /** A page language: a code ('fr'), a framework language name ('Français'), or a locale tag. */
453
+ function languageCode(value, strings = STRINGS) {
454
+ const wanted = String(value || '').trim();
455
+ if (!wanted) return null;
456
+ if (strings[wanted]) return wanted;
457
+ const lower = wanted.toLowerCase();
458
+ const byCode = Object.keys(strings).find((code) => code.toLowerCase() === lower);
459
+ if (byCode) return byCode;
460
+ const byName = Object.keys(strings).find((code) => strings[code].name.toLowerCase() === lower);
461
+ if (byName) return byName;
462
+ const base = lower.split(/[-_]/)[0];
463
+ return Object.keys(strings).find((code) => code.toLowerCase().split('-')[0] === base) || null;
464
+ }
465
+
466
+ function buildPage(spec, options = {}) {
467
+ const template = options.template || fs.readFileSync(TEMPLATE, 'utf8');
468
+ const hash = specHash(spec);
469
+ const json = canonical(spec).replace(/<\//g, '<\\/');
470
+ const strings = options.strings || STRINGS;
471
+ // The recipe states its language; otherwise the project's, otherwise English. Every
472
+ // language ships in the page: a tester who does not read the author’s can switch.
473
+ const language =
474
+ languageCode(spec.language, strings) || languageCode(options.language, strings) || 'en';
475
+ const allStrings = JSON.stringify(strings).replace(/<\//g, '<\\/');
476
+ const title = `${spec.product} ${spec.versions.join(' · ')} — ${spec.title}`;
477
+ const escape = (value) =>
478
+ String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
479
+ // Replacement FUNCTIONS: in a replacement string $&, $', $` and $1 are expanded,
480
+ // and a spec quoting a regular expression silently duplicated the template.
481
+ const html = template
482
+ .replace('__TITLE__', () => escape(title))
483
+ .replace('__SPEC_JSON__', () => json)
484
+ .replace('__SPEC_SHA__', () => hash)
485
+ .replace('__STRINGS__', () => allStrings)
486
+ .replace('__LANGUAGE__', () => language)
487
+ .replace('__DIR__', () => strings[language].dir || 'ltr');
488
+ const embedded = /<script id="uat-spec" type="application\/json">([\s\S]*?)<\/script>/.exec(html);
489
+ try {
490
+ JSON.parse(embedded[1]);
491
+ } catch (error) {
492
+ throw new Error(`the built page does not read its own spec back (${error.message})`, {
493
+ cause: error,
494
+ });
495
+ }
496
+ return { html, sha256: hash, language };
497
+ }
498
+
499
+ // ── Runs: legacy adapter, normalisation, reading ──────────────────────────────
500
+
501
+ function fromLegacyResults(doc, spec) {
502
+ const steps = {};
503
+ const stateOf = (value) => (value === true ? 'passed' : value === false ? 'failed' : null);
504
+ for (const [id, step] of Object.entries(doc.etapes || {})) {
505
+ const expect = {};
506
+ for (const [letter, value] of Object.entries(step.attendus || {})) {
507
+ const text = spec?.steps
508
+ ?.find((s) => s.id === id)
509
+ ?.expect?.find((e) => e.id === letter)?.text;
510
+ expect[letter] = { state: stateOf(value), ...(text ? { text } : {}) };
511
+ }
512
+ steps[id] = {
513
+ title: step.titre,
514
+ state: Object.values(expect).some((e) => e.state === 'failed')
515
+ ? 'failed'
516
+ : Object.values(expect).every((e) => e.state === 'passed')
517
+ ? 'passed'
518
+ : null,
519
+ note: step.note || '',
520
+ expect,
521
+ };
522
+ }
523
+ return {
524
+ schema: RESULTS_SCHEMA,
525
+ specId: doc.specId || (spec ? spec.id : ''),
526
+ ...(doc.specSha256 ? { specSha256: doc.specSha256 } : {}),
527
+ runId: doc.runId || '',
528
+ tester: doc.testeur || '',
529
+ startedAt: doc.demarreLe,
530
+ updatedAt: doc.misAJourLe,
531
+ finishedAt: doc.termineLe || null,
532
+ overallNote: doc.noteGlobale || '',
533
+ steps,
534
+ summary: summarise(steps),
535
+ };
536
+ }
537
+
538
+ /** A step's state is recomputed from its expectations: a page-written verdict is not evidence. */
539
+ function stepState(step) {
540
+ const states = Object.values(step.expect || {}).map((expect) => expect.state);
541
+ if (!states.length) return null;
542
+ if (states.includes('failed')) return 'failed';
543
+ if (states.includes('blocked')) return 'blocked';
544
+ if (states.every((state) => state === 'skipped')) return 'skipped';
545
+ if (states.every((state) => state === 'passed' || state === 'skipped')) return 'passed';
546
+ return null;
547
+ }
548
+
549
+ function summarise(steps) {
550
+ const summary = { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 };
551
+ for (const step of Object.values(steps || {})) {
552
+ for (const expect of Object.values(step.expect || {})) {
553
+ if (expect.state === null || expect.state === undefined) summary.unanswered += 1;
554
+ else if (STATES.includes(expect.state)) summary[expect.state] += 1;
555
+ }
556
+ }
557
+ return summary;
558
+ }
559
+
560
+ function normalizeRun(doc, spec) {
561
+ const legacy = doc && (doc.schema === LEGACY_RESULTS_SCHEMA || doc.schema === 1);
562
+ const run = legacy ? fromLegacyResults(doc, spec) : doc;
563
+ if (!run || run.schema !== RESULTS_SCHEMA)
564
+ throw new Error(`unsupported results schema: ${doc && doc.schema}`);
565
+ if (!run.runId) throw new Error('results without runId');
566
+ if (!run.specId) throw new Error('results without specId');
567
+ for (const step of Object.values(run.steps || {})) step.state = stepState(step);
568
+ run.summary = summarise(run.steps);
569
+ return run;
570
+ }
571
+
572
+ function readRuns(dir, spec) {
573
+ const runs = [];
574
+ const seen = new Set();
575
+ const walk = (current) => {
576
+ let entries;
577
+ try {
578
+ entries = fs.readdirSync(current, { withFileTypes: true });
579
+ } catch {
580
+ return;
581
+ }
582
+ for (const entry of entries) {
583
+ if (entry.isSymbolicLink()) continue;
584
+ const full = path.join(current, entry.name);
585
+ if (entry.isDirectory()) {
586
+ walk(full);
587
+ continue;
588
+ }
589
+ if (!entry.name.endsWith('.json')) continue;
590
+ let run;
591
+ try {
592
+ run = normalizeRun(readJson(full), spec);
593
+ } catch {
594
+ continue;
595
+ }
596
+ if (spec && run.specId !== spec.id) continue;
597
+ // The same run exported twice (file plus artifact copy) is one run.
598
+ if (seen.has(run.runId)) continue;
599
+ seen.add(run.runId);
600
+ run.file = full;
601
+ run.stale = Boolean(spec && run.specSha256 && run.specSha256 !== specHash(spec));
602
+ run.unsigned = !run.specSha256;
603
+ runs.push(run);
604
+ }
605
+ };
606
+ walk(path.resolve(dir));
607
+ return runs.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
608
+ }
609
+
610
+ /** Failed and blocked expectations, with their text — the dev agent must not reopen the spec. */
611
+ function failures(run, spec) {
612
+ const list = [];
613
+ for (const [stepId, step] of Object.entries(run.steps || {})) {
614
+ const specStep = spec?.steps?.find((s) => s.id === stepId) || null;
615
+ for (const [letter, expect] of Object.entries(step.expect || {})) {
616
+ if (expect.state !== 'failed' && expect.state !== 'blocked') continue;
617
+ const specExpect = specStep?.expect?.find((e) => e.id === letter) || null;
618
+ list.push({
619
+ step: stepId,
620
+ expect: letter,
621
+ title: step.title || specStep?.title || '',
622
+ state: expect.state,
623
+ text: flatten(expect.text || specExpect?.text || ''),
624
+ missingFromSpec: Boolean(spec && specStep && !specExpect),
625
+ note: expect.note || step.note || '',
626
+ });
627
+ }
628
+ }
629
+ return list;
630
+ }
631
+
632
+ function unanswered(run, spec) {
633
+ const list = [];
634
+ for (const [stepId, step] of Object.entries(run.steps || {})) {
635
+ const specStep = spec?.steps?.find((s) => s.id === stepId) || null;
636
+ if (specStep?.optional) continue;
637
+ for (const [letter, expect] of Object.entries(step.expect || {})) {
638
+ if (expect.state === null || expect.state === undefined) list.push(`${stepId}/${letter}`);
639
+ }
640
+ }
641
+ return list;
642
+ }
643
+
644
+ function loadTriage(file) {
645
+ if (!fs.existsSync(file)) return null;
646
+ const doc = readJson(file);
647
+ if (!doc || doc.schema !== TRIAGE_SCHEMA)
648
+ throw new Error(`triage must use schema "${TRIAGE_SCHEMA}"`);
649
+ return doc;
650
+ }
651
+
652
+ function triageFor(triage, runId) {
653
+ const entry = (triage?.runs || []).find((run) => run.runId === runId) || null;
654
+ return {
655
+ failures: entry?.failures || [],
656
+ writeChecks: entry?.writeChecks || [],
657
+ };
658
+ }
659
+
660
+ /**
661
+ * The gate. It establishes integrity and completeness of a human run — never that
662
+ * the tester looked at the right place. Evidence stays labelled human-observed.
663
+ */
664
+ function gate({ spec, runs, triage }) {
665
+ const finished = runs
666
+ .filter((run) => run.finishedAt)
667
+ .sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt)));
668
+ const run = finished[0] || null;
669
+ if (!run) {
670
+ return {
671
+ status: 'awaiting',
672
+ reasons: [runs.length ? `${runs.length} run(s) started, none finished` : 'no run yet'],
673
+ run: null,
674
+ };
675
+ }
676
+ if (run.stale) {
677
+ return {
678
+ status: 'stale',
679
+ reasons: [
680
+ `the spec changed since this run (answered ${run.specSha256.slice(0, 12)}, now ${specHash(spec).slice(0, 12)}) — replay the affected steps`,
681
+ ],
682
+ run,
683
+ };
684
+ }
685
+ const reasons = [];
686
+ const open = unanswered(run, spec);
687
+ if (open.length)
688
+ reasons.push(
689
+ `${open.length} expectation(s) never answered: ${open.slice(0, 5).join(', ')}${open.length > 5 ? '…' : ''}`
690
+ );
691
+
692
+ const decided = triageFor(triage, run.runId);
693
+ for (const failure of failures(run, spec)) {
694
+ const entry = decided.failures.find(
695
+ (f) => f.step === failure.step && f.expect === failure.expect
696
+ );
697
+ if (!entry)
698
+ reasons.push(`${failure.step}/${failure.expect} is ${failure.state} and not triaged`);
699
+ else if (!TRIAGE_CLASSES.includes(entry.class) || entry.class === 'undecided') {
700
+ reasons.push(
701
+ `${failure.step}/${failure.expect}: class "${entry.class}" — ask the tester what they saw, never guess`
702
+ );
703
+ } else if (!TRIAGE_DECISIONS.includes(entry.decision)) {
704
+ reasons.push(
705
+ `${failure.step}/${failure.expect}: decision "${entry.decision}" is not one of ${TRIAGE_DECISIONS.join('|')}`
706
+ );
707
+ } else if (entry.decision === 'accept-risk' && !String(entry.decidedBy || '').trim()) {
708
+ reasons.push(`${failure.step}/${failure.expect}: accept-risk requires decidedBy`);
709
+ }
710
+ }
711
+ // A tick on a step that writes is not a write: the read-only proof settles it.
712
+ for (const step of spec.steps || []) {
713
+ if (!step.writes) continue;
714
+ const observed = run.steps?.[step.id];
715
+ if (!observed || stepState(observed) !== 'passed') continue;
716
+ const check = decided.writeChecks.find((entry) => entry.step === step.id);
717
+ if (!check || check.verified !== true || !String(check.evidence || '').trim()) {
718
+ reasons.push(
719
+ `${step.id}: passed but the write was never confirmed read-only (writeChecks.verified with evidence)`
720
+ );
721
+ }
722
+ }
723
+ return { status: reasons.length ? 'failed' : 'passed', reasons, run };
724
+ }
725
+
726
+ /** Play order across pending recipes: declared `after`, then witness collisions. */
727
+ function playOrder(specs) {
728
+ const byId = new Map(specs.map((spec) => [spec.id, spec]));
729
+ const edges = new Map(specs.map((spec) => [spec.id, new Set(spec.after || [])]));
730
+ const collisions = [];
731
+ for (const spec of specs) {
732
+ for (const witness of spec.witnesses || []) {
733
+ if (!(witness.writes || []).length) continue;
734
+ for (const other of specs) {
735
+ if (other.id === spec.id) continue;
736
+ const reader = (other.witnesses || []).find(
737
+ (w) => w.id === witness.id && (w.reads || []).length
738
+ );
739
+ if (!reader) continue;
740
+ collisions.push({ witness: witness.id, writtenBy: spec.id, readBy: other.id });
741
+ if (!edges.get(other.id).has(spec.id) && !edges.get(spec.id).has(other.id)) {
742
+ edges.get(spec.id).add(other.id); // the reader plays before the writer unless declared otherwise
743
+ }
744
+ }
745
+ }
746
+ }
747
+ const order = [];
748
+ const state = new Map();
749
+ const cycles = [];
750
+ const visit = (id, trail) => {
751
+ if (state.get(id) === 'done') return;
752
+ if (state.get(id) === 'open') {
753
+ cycles.push([...trail, id].join(' → '));
754
+ return;
755
+ }
756
+ state.set(id, 'open');
757
+ for (const next of edges.get(id) || []) {
758
+ if (byId.has(next)) visit(next, [...trail, id]);
759
+ }
760
+ state.set(id, 'done');
761
+ order.push(id);
762
+ };
763
+ for (const spec of specs) visit(spec.id, []);
764
+ return { order, collisions, cycles };
765
+ }
766
+
767
+ /**
768
+ * A self-contained verifier for a Nexus check: no imports, so the plan's hash
769
+ * covers the whole thing (`docs/specs/nexus-runtime.md`).
770
+ */
771
+ function emitCheck({ specId, specSha256, dir = DEFAULT_DIR, writeSteps = [] }) {
772
+ const source = `#!/usr/bin/env node
773
+ /** Generated by bmad-plus uat --emit-check. Verifies one human acceptance run. */
774
+ 'use strict';
775
+ const fs = require('node:fs');
776
+ const path = require('node:path');
777
+ const SPEC_ID = __SPEC_ID__;
778
+ const SPEC_SHA = __SPEC_SHA__;
779
+ const DIR = __DIR__;
780
+ const WRITE_STEPS = __WRITE_STEPS__;
781
+ const root = path.resolve(process.cwd(), DIR);
782
+ const read = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
783
+ const fail = (message) => {
784
+ console.error('uat gate: ' + message);
785
+ process.exit(1);
786
+ };
787
+ let runs = [];
788
+ const runDir = path.join(root, 'results', SPEC_ID);
789
+ try {
790
+ runs = fs
791
+ .readdirSync(runDir)
792
+ .filter((name) => name.endsWith('.json'))
793
+ .map((name) => read(path.join(runDir, name)))
794
+ .filter((run) => run.specId === SPEC_ID && run.finishedAt);
795
+ } catch {
796
+ fail('no finished run in ' + runDir);
797
+ }
798
+ if (!runs.length) fail('no finished run in ' + runDir);
799
+ runs.sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt)));
800
+ const run = runs[0];
801
+ if (run.specSha256 !== SPEC_SHA) fail('run ' + run.runId + ' answered another revision of the spec');
802
+ let triage = { runs: [] };
803
+ try {
804
+ triage = read(path.join(root, 'triage', SPEC_ID + '.json'));
805
+ } catch {
806
+ /* absence is handled below, per failure */
807
+ }
808
+ const decided = (triage.runs || []).find((entry) => entry.runId === run.runId) || { failures: [], writeChecks: [] };
809
+ const problems = [];
810
+ for (const [stepId, step] of Object.entries(run.steps || {})) {
811
+ for (const [letter, expect] of Object.entries(step.expect || {})) {
812
+ if (expect.state === null || expect.state === undefined) problems.push(stepId + '/' + letter + ' never answered');
813
+ if (expect.state !== 'failed' && expect.state !== 'blocked') continue;
814
+ const entry = (decided.failures || []).find((f) => f.step === stepId && f.expect === letter);
815
+ if (!entry) problems.push(stepId + '/' + letter + ' ' + expect.state + ', not triaged');
816
+ else if (!entry.class || entry.class === 'undecided' || !entry.decision) {
817
+ problems.push(stepId + '/' + letter + ' triaged without a class and a decision');
818
+ }
819
+ }
820
+ }
821
+ // A tick on a step that writes is an observation; the read-only proof settles it.
822
+ for (const stepId of WRITE_STEPS) {
823
+ const step = (run.steps || {})[stepId];
824
+ if (!step) continue;
825
+ const states = Object.values(step.expect || {}).map((expect) => expect.state);
826
+ const passed = states.length && states.every((state) => state === 'passed' || state === 'skipped');
827
+ if (!passed) continue;
828
+ const check = (decided.writeChecks || []).find((entry) => entry.step === stepId);
829
+ if (!check || check.verified !== true) problems.push(stepId + ' passed but its write was never confirmed read-only');
830
+ }
831
+ if (problems.length) fail(problems.join('; '));
832
+ console.log('uat gate: run ' + run.runId + ' by ' + run.tester + ' — human-observed, complete and triaged.');
833
+ `;
834
+ return source
835
+ .replace('__SPEC_ID__', () => JSON.stringify(specId))
836
+ .replace('__SPEC_SHA__', () => JSON.stringify(specSha256))
837
+ .replace('__DIR__', () => JSON.stringify(dir))
838
+ .replace('__WRITE_STEPS__', () => JSON.stringify(writeSteps));
839
+ }
840
+
841
+ module.exports = {
842
+ SPEC_SCHEMA,
843
+ RESULTS_SCHEMA,
844
+ TRIAGE_SCHEMA,
845
+ DEFAULT_DIR,
846
+ DEFAULT_BUDGET,
847
+ STATES,
848
+ TRIAGE_CLASSES,
849
+ TRIAGE_DECISIONS,
850
+ layout,
851
+ loadSpec,
852
+ fromLegacySpec,
853
+ specHash,
854
+ validateSpec,
855
+ lintSpec,
856
+ screenLabels,
857
+ buildPage,
858
+ languageCode,
859
+ normalizeRun,
860
+ readRuns,
861
+ stepState,
862
+ failures,
863
+ unanswered,
864
+ loadTriage,
865
+ gate,
866
+ playOrder,
867
+ emitCheck,
868
+ STRINGS,
869
+ };