bmad-plus 0.16.0 → 0.17.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.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gate G3 — production waits for a human run that was read and classified.
4
+ *
5
+ * Resolves the recipe covering the version in package.json, then runs
6
+ * `bmad-plus uat gate` on it. Exit 0 only when the gate passes: a finished run,
7
+ * answering the current revision of the recipe, with every failure classified and
8
+ * every writing step confirmed read-only.
9
+ *
10
+ * node scripts/uat-release-gate.mjs
11
+ * node scripts/uat-release-gate.mjs --id myproduct-1.4.0
12
+ * node scripts/uat-release-gate.mjs --cli node_modules/bmad-plus/tools/cli/bmad-plus-cli.js
13
+ * BMAD_PLUS_CLI=/path/to/bmad-plus-cli.js node scripts/uat-release-gate.mjs
14
+ *
15
+ * Exit codes mirror the command: 0 passed · 1 failed · 2 awaiting a run ·
16
+ * 3 the run answered another revision · 4 the gate could not be run at all.
17
+ *
18
+ * The gate establishes that the run is complete, current and classified. It never
19
+ * establishes that the tester looked at the right place: that stays human-observed.
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ const args = process.argv.slice(2);
26
+ const option = (name, fallback) => {
27
+ const index = args.indexOf(`--${name}`);
28
+ return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
29
+ };
30
+ const root = path.resolve(option('project', process.cwd()));
31
+ const directory = option('dir', '_bmad-output/uat');
32
+ const specs = path.join(root, directory, 'specs');
33
+
34
+ function resolveId() {
35
+ const explicit = option('id', null);
36
+ if (explicit) return explicit;
37
+ const file = path.join(root, 'package.json');
38
+ if (!fs.existsSync(file)) throw new Error(`no package.json in ${root}; pass --id <recipe>`);
39
+ const version = JSON.parse(fs.readFileSync(file, 'utf8')).version;
40
+ if (!fs.existsSync(specs)) throw new Error(`no recipe folder at ${specs}`);
41
+ const covering = fs
42
+ .readdirSync(specs)
43
+ .filter((name) => name.endsWith('.json'))
44
+ .filter((name) => {
45
+ try {
46
+ const spec = JSON.parse(fs.readFileSync(path.join(specs, name), 'utf8'));
47
+ return Array.isArray(spec.versions) && spec.versions.includes(version);
48
+ } catch {
49
+ return false;
50
+ }
51
+ });
52
+ if (covering.length !== 1) {
53
+ throw new Error(
54
+ covering.length
55
+ ? `${covering.length} recipes name ${version}; pass --id to choose one`
56
+ : `no recipe names ${version}; write it before deploying`
57
+ );
58
+ }
59
+ return covering[0].replace(/\.json$/, '');
60
+ }
61
+
62
+ /**
63
+ * The CLI's own entry point, run by this Node: an explicit one, or the copy
64
+ * installed in the project. Never a shell, never `npx` — a `.cmd` shim cannot be
65
+ * spawned without a shell on Windows, and a gate must not depend on a download.
66
+ */
67
+ function command() {
68
+ const candidates = [
69
+ option('cli', null),
70
+ process.env.BMAD_PLUS_CLI,
71
+ path.join(root, 'node_modules', 'bmad-plus', 'tools', 'cli', 'bmad-plus-cli.js'),
72
+ ].filter(Boolean);
73
+ for (const candidate of candidates) {
74
+ const resolved = path.resolve(root, candidate);
75
+ if (fs.existsSync(resolved)) return resolved;
76
+ }
77
+ throw new Error(
78
+ 'no BMAD+ CLI to run the gate with. Install bmad-plus in this project ' +
79
+ '(npm install --save-dev bmad-plus) or set BMAD_PLUS_CLI to its bmad-plus-cli.js. ' +
80
+ 'A missing gate is not a passing gate.'
81
+ );
82
+ }
83
+
84
+ try {
85
+ const id = resolveId();
86
+ const cli = command();
87
+ const child = spawnSync(
88
+ process.execPath,
89
+ [cli, 'uat', 'gate', id, '--directory', root, '--dir', directory, '--json'],
90
+ { encoding: 'utf8', timeout: 120000, windowsHide: true }
91
+ );
92
+
93
+ // The verdict is read from the gate's own answer, not guessed from an exit code:
94
+ // a CLI that never ran also exits non-zero, and that is a different problem.
95
+ let verdict = null;
96
+ try {
97
+ const parsed = JSON.parse(child.stdout);
98
+ if (parsed && typeof parsed.status === 'string') verdict = parsed;
99
+ } catch {
100
+ verdict = null;
101
+ }
102
+ if (!verdict) {
103
+ console.error(
104
+ `uat: the gate could not be run for ${id}` +
105
+ `${child.error ? ` (${child.error.message})` : ''}.\n` +
106
+ `${(child.stdout || '').trim()}${(child.stderr || '').trim()}\n` +
107
+ 'Install the CLI in this project, or set BMAD_PLUS_CLI to its path. ' +
108
+ 'A missing gate is not a passing gate.'
109
+ );
110
+ process.exit(4);
111
+ }
112
+
113
+ const explanation = {
114
+ passed: `${id}: human-observed, complete, current and triaged.`,
115
+ failed: `${id} did not pass its gate — production stays where it is.`,
116
+ awaiting: `${id} has no finished run yet — someone still has to play it.`,
117
+ stale: `${id} was answered on another revision of the recipe — replay the affected steps.`,
118
+ };
119
+ const code = { passed: 0, failed: 1, awaiting: 2, stale: 3 }[verdict.status] ?? 4;
120
+ const report = code === 0 ? console.log : console.error;
121
+ report(explanation[verdict.status] || `${id}: unexpected gate status "${verdict.status}".`);
122
+ for (const reason of verdict.reasons || []) report(` - ${reason}`);
123
+ process.exit(code);
124
+ } catch (error) {
125
+ console.error(`uat: ${error.message}`);
126
+ process.exit(4);
127
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gate G1 — a version does not leave without saying what a person must check.
4
+ *
5
+ * Fails when no acceptance recipe covers the version in package.json. Copy this
6
+ * file into your project and run it wherever your other checks run: a pre-push
7
+ * hook, a test script, a CI step. It needs nothing but Node.
8
+ *
9
+ * node scripts/uat-spec-present.mjs
10
+ * node scripts/uat-spec-present.mjs --version 1.4.0 --dir _bmad-output/uat --json
11
+ *
12
+ * Exit 0 when a recipe covers the version, 1 otherwise. A reminder printed after
13
+ * a deployment protects nothing; this one runs before.
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const args = process.argv.slice(2);
19
+ const option = (name, fallback) => {
20
+ const index = args.indexOf(`--${name}`);
21
+ return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
22
+ };
23
+ const json = args.includes('--json');
24
+ const root = path.resolve(option('project', process.cwd()));
25
+ const directory = path.resolve(root, option('dir', '_bmad-output/uat'));
26
+
27
+ function projectVersion() {
28
+ const explicit = option('version', null);
29
+ if (explicit) return explicit;
30
+ const file = path.join(root, 'package.json');
31
+ if (!fs.existsSync(file)) {
32
+ throw new Error(`no package.json in ${root}; pass --version <x.y.z>`);
33
+ }
34
+ const version = JSON.parse(fs.readFileSync(file, 'utf8')).version;
35
+ if (!version) throw new Error('package.json declares no version');
36
+ return version;
37
+ }
38
+
39
+ /** Recipes that name this version, whether they cover one version or a campaign. */
40
+ function covering(version) {
41
+ const specs = path.join(directory, 'specs');
42
+ if (!fs.existsSync(specs)) return [];
43
+ return fs
44
+ .readdirSync(specs)
45
+ .filter((name) => name.endsWith('.json'))
46
+ .filter((name) => {
47
+ try {
48
+ const spec = JSON.parse(fs.readFileSync(path.join(specs, name), 'utf8'));
49
+ // A grouped recipe covers a version because it NAMES it, never because its
50
+ // file name suggests a range.
51
+ return Array.isArray(spec.versions) && spec.versions.includes(version);
52
+ } catch {
53
+ return false;
54
+ }
55
+ })
56
+ .map((name) => name.replace(/\.json$/, ''));
57
+ }
58
+
59
+ try {
60
+ const version = projectVersion();
61
+ const found = covering(version);
62
+ if (json) {
63
+ console.log(JSON.stringify({ version, directory, covering: found, ok: found.length > 0 }, null, 2));
64
+ } else if (found.length) {
65
+ console.log(`uat: ${version} is covered by ${found.join(', ')}`);
66
+ } else {
67
+ console.error(
68
+ `uat: no acceptance recipe covers ${version}.\n` +
69
+ `Write ${path.relative(root, path.join(directory, 'specs'))}/<product>-${version}.json before shipping,\n` +
70
+ `or add ${version} to the versions of a grouped recipe. Ask your agent: "write the recette for this delivery".`
71
+ );
72
+ }
73
+ process.exit(found.length ? 0 : 1);
74
+ } catch (error) {
75
+ console.error(`uat: ${error.message}`);
76
+ process.exit(1);
77
+ }
@@ -113,7 +113,7 @@ After a successful update, reread the project spine and relevant installed skill
113
113
 
114
114
  ## Registry facts (computed from registry.yaml — never hand-typed)
115
115
 
116
- - Product: BMAD+ v0.16.0 (derived from BMAD-METHOD v6.6.0)
116
+ - Product: BMAD+ v0.17.0 (derived from BMAD-METHOD v6.6.0)
117
117
  - Models supported: claude, gpt, gemini, local (model-agnostic by contract)
118
118
  - Packs (9): Core, OSINT, Maker, Shield, SEO, Memory, Dev Studio, Backup, Animated
119
119
  - Installer agents (14 across all packs):
@@ -118,7 +118,7 @@ After a successful update, reread the project spine and relevant installed skill
118
118
 
119
119
  ## Registry facts (computed from registry.yaml — never hand-typed)
120
120
 
121
- - Product: BMAD+ v0.16.0 (derived from BMAD-METHOD v6.6.0)
121
+ - Product: BMAD+ v0.17.0 (derived from BMAD-METHOD v6.6.0)
122
122
  - Models supported: claude, gpt, gemini, local (model-agnostic by contract)
123
123
  - Packs (9): Core, OSINT, Maker, Shield, SEO, Memory, Dev Studio, Backup, Animated
124
124
  - Installer agents (14 across all packs):
@@ -113,7 +113,7 @@ After a successful update, reread the project spine and relevant installed skill
113
113
 
114
114
  ## Registry facts (computed from registry.yaml — never hand-typed)
115
115
 
116
- - Product: BMAD+ v0.16.0 (derived from BMAD-METHOD v6.6.0)
116
+ - Product: BMAD+ v0.17.0 (derived from BMAD-METHOD v6.6.0)
117
117
  - Models supported: claude, gpt, gemini, local (model-agnostic by contract)
118
118
  - Packs (9): Core, OSINT, Maker, Shield, SEO, Memory, Dev Studio, Backup, Animated
119
119
  - Installer agents (14 across all packs):
@@ -111,7 +111,7 @@ After a successful update, reread the project spine and relevant installed skill
111
111
 
112
112
  ## Registry facts (computed from registry.yaml — never hand-typed)
113
113
 
114
- - Product: BMAD+ v0.16.0 (derived from BMAD-METHOD v6.6.0)
114
+ - Product: BMAD+ v0.17.0 (derived from BMAD-METHOD v6.6.0)
115
115
  - Models supported: claude, gpt, gemini, local (model-agnostic by contract)
116
116
  - Packs (9): Core, OSINT, Maker, Shield, SEO, Memory, Dev Studio, Backup, Animated
117
117
  - Installer agents (14 across all packs):
@@ -114,7 +114,7 @@ After a successful update, reread the project spine and relevant installed skill
114
114
 
115
115
  ## Registry facts (computed from registry.yaml — never hand-typed)
116
116
 
117
- - Product: BMAD+ v0.16.0 (derived from BMAD-METHOD v6.6.0)
117
+ - Product: BMAD+ v0.17.0 (derived from BMAD-METHOD v6.6.0)
118
118
  - Models supported: claude, gpt, gemini, local (model-agnostic by contract)
119
119
  - Packs (9): Core, OSINT, Maker, Shield, SEO, Memory, Dev Studio, Backup, Animated
120
120
  - Installer agents (14 across all packs):
@@ -113,7 +113,7 @@ After a successful update, reread the project spine and relevant installed skill
113
113
 
114
114
  ## Registry facts (computed from registry.yaml — never hand-typed)
115
115
 
116
- - Product: BMAD+ v0.16.0 (derived from BMAD-METHOD v6.6.0)
116
+ - Product: BMAD+ v0.17.0 (derived from BMAD-METHOD v6.6.0)
117
117
  - Models supported: claude, gpt, gemini, local (model-agnostic by contract)
118
118
  - Packs (9): Core, OSINT, Maker, Shield, SEO, Memory, Dev Studio, Backup, Animated
119
119
  - Installer agents (14 across all packs):
@@ -118,7 +118,7 @@ After a successful update, reread the project spine and relevant installed skill
118
118
 
119
119
  ## Registry facts (computed from registry.yaml — never hand-typed)
120
120
 
121
- - Product: BMAD+ v0.16.0 (derived from BMAD-METHOD v6.6.0)
121
+ - Product: BMAD+ v0.17.0 (derived from BMAD-METHOD v6.6.0)
122
122
  - Models supported: claude, gpt, gemini, local (model-agnostic by contract)
123
123
  - Packs (9): Core, OSINT, Maker, Shield, SEO, Memory, Dev Studio, Backup, Animated
124
124
  - Installer agents (14 across all packs):
@@ -87,7 +87,9 @@ function calculateHealth(structure) {
87
87
  return { pct, checks };
88
88
  }
89
89
 
90
- function recommendPacks(stack, structure, health) {
90
+ // `health` is measured elsewhere and does not change the recommendation: a project
91
+ // with failing checks needs the same packs as a healthy one.
92
+ function recommendPacks(stack, structure) {
91
93
  const packs = ['core', 'memory']; // Always
92
94
  const reasons = {
93
95
  core: 'Essential multi-role agents (Atlas, Forge, Sentinel, Nexus)',
@@ -215,7 +217,7 @@ module.exports = {
215
217
  const stack = detectStack(projectDir);
216
218
  const structure = analyzeStructure(projectDir);
217
219
  const health = calculateHealth(structure);
218
- const { packs, reasons } = recommendPacks(stack, structure, health);
220
+ const { packs, reasons } = recommendPacks(stack, structure);
219
221
  const recs = generateRecommendations(stack, structure, health);
220
222
 
221
223
  spinner.stop('Analysis complete');
@@ -17,11 +17,27 @@ function fail(message) {
17
17
 
18
18
  function resolveSpec(target, paths) {
19
19
  if (!target) fail('an id or a spec file is required');
20
- const file = target.endsWith('.json')
21
- ? path.resolve(target)
22
- : path.join(paths.specs, `${target}.json`);
23
- if (!fs.existsSync(file)) fail(`no spec at ${file}`);
24
- return uat.loadSpec(file);
20
+ if (target.endsWith('.json')) {
21
+ const file = path.resolve(target);
22
+ if (!fs.existsSync(file)) fail(`no spec at ${file}`);
23
+ return uat.loadSpec(file);
24
+ }
25
+ const direct = path.join(paths.specs, `${target}.json`);
26
+ if (fs.existsSync(direct)) return uat.loadSpec(direct);
27
+ // A recipe is named by its id, not by its file: a copied template keeps its own name
28
+ // (the shipped example-uat-spec.json holds the id example-1.0.0).
29
+ if (fs.existsSync(paths.specs)) {
30
+ for (const name of fs.readdirSync(paths.specs).filter((entry) => entry.endsWith('.json'))) {
31
+ try {
32
+ const loaded = uat.loadSpec(path.join(paths.specs, name));
33
+ if (loaded.spec.id === target) return loaded;
34
+ } catch {
35
+ // an unreadable file cannot be the recipe asked for
36
+ }
37
+ }
38
+ }
39
+ fail(`no spec with the id "${target}" in ${paths.specs}`);
40
+ return null;
25
41
  }
26
42
 
27
43
  function readStdin() {
package/tools/cli/i18n.js CHANGED
@@ -21,6 +21,7 @@ const LANGUAGES = {
21
21
  selected_packs: 'Selected packs',
22
22
  enter_name: 'Your first name (agents use it to greet you)',
23
23
  comm_language: 'Communication language',
24
+ uat_environment: 'Test environment for human acceptance recipes (URL, blank if none)',
24
25
  exec_mode: 'Execution mode',
25
26
  exec_manual: 'Manual — You call agents yourself',
26
27
  exec_autopilot: 'Autopilot — Nexus manages the full pipeline',
@@ -109,6 +110,7 @@ const LANGUAGES = {
109
110
  selected_packs: 'Packs sélectionnés',
110
111
  enter_name: 'Ton prénom (les agents l\'utilisent pour te saluer)',
111
112
  comm_language: 'Langue de communication',
113
+ uat_environment: 'Environnement de test pour les recettes humaines (URL, vide si aucun)',
112
114
  exec_mode: 'Mode d\'exécution',
113
115
  exec_manual: 'Manuel — Tu appelles les agents toi-même',
114
116
  exec_autopilot: 'Autopilot — Nexus gère tout le pipeline',
@@ -195,6 +197,7 @@ const LANGUAGES = {
195
197
  selected_packs: 'Packs seleccionados',
196
198
  enter_name: 'Tu nombre (los agentes lo usan para saludarte)',
197
199
  comm_language: 'Idioma de comunicación',
200
+ uat_environment: 'Entorno de pruebas para las recetas de aceptación (URL, vacío si no hay)',
198
201
  exec_mode: 'Modo de ejecución',
199
202
  exec_manual: 'Manual — Tú llamas a los agentes',
200
203
  exec_autopilot: 'Autopilot — Nexus gestiona todo el pipeline',
@@ -281,6 +284,7 @@ const LANGUAGES = {
281
284
  selected_packs: 'Ausgewählte Packs',
282
285
  enter_name: 'Dein Vorname (Agenten verwenden ihn zur Begrüßung)',
283
286
  comm_language: 'Kommunikationssprache',
287
+ uat_environment: 'Testumgebung für menschliche Abnahmen (URL, leer wenn keine)',
284
288
  exec_mode: 'Ausführungsmodus',
285
289
  exec_manual: 'Manuell — Du rufst Agenten selbst auf',
286
290
  exec_autopilot: 'Autopilot — Nexus verwaltet die gesamte Pipeline',
@@ -367,6 +371,7 @@ const LANGUAGES = {
367
371
  selected_packs: 'Packs selecionados',
368
372
  enter_name: 'Seu nome (os agentes usam para te cumprimentar)',
369
373
  comm_language: 'Idioma de comunicação',
374
+ uat_environment: 'Ambiente de teste para receitas de aceitação humana (URL, vazio se não houver)',
370
375
  exec_mode: 'Modo de execução',
371
376
  exec_manual: 'Manual — Você chama os agentes',
372
377
  exec_autopilot: 'Autopilot — Nexus gerencia todo o pipeline',
@@ -453,6 +458,7 @@ const LANGUAGES = {
453
458
  selected_packs: 'Выбранные пакеты',
454
459
  enter_name: 'Ваше имя (агенты используют его для приветствия)',
455
460
  comm_language: 'Язык общения',
461
+ uat_environment: 'Тестовая среда для ручной приёмки (URL, пусто если нет)',
456
462
  exec_mode: 'Режим выполнения',
457
463
  exec_manual: 'Ручной — Вы вызываете агентов сами',
458
464
  exec_autopilot: 'Автопилот — Nexus управляет всем конвейером',
@@ -539,6 +545,7 @@ const LANGUAGES = {
539
545
  selected_packs: '已选择的包',
540
546
  enter_name: '您的名字(代理会用它来问候您)',
541
547
  comm_language: '沟通语言',
548
+ uat_environment: '人工验收的测试环境(URL,没有则留空)',
542
549
  exec_mode: '执行模式',
543
550
  exec_manual: '手动 — 您自己调用代理',
544
551
  exec_autopilot: '自动驾驶 — Nexus 管理整个流程',
@@ -625,6 +632,7 @@ const LANGUAGES = {
625
632
  selected_packs: 'חבילות שנבחרו',
626
633
  enter_name: 'השם שלך (הסוכנים משתמשים בו לברכך)',
627
634
  comm_language: 'שפת תקשורת',
635
+ uat_environment: 'סביבת בדיקה לבדיקות קבלה אנושיות (כתובת URL, ריק אם אין)',
628
636
  exec_mode: 'מצב ביצוע',
629
637
  exec_manual: 'ידני — אתה קורא לסוכנים בעצמך',
630
638
  exec_autopilot: 'טייס אוטומטי — Nexus מנהל את כל הצינור',
@@ -711,6 +719,7 @@ const LANGUAGES = {
711
719
  selected_packs: '選択されたパック',
712
720
  enter_name: 'あなたの名前(エージェントが挨拶に使います)',
713
721
  comm_language: 'コミュニケーション言語',
722
+ uat_environment: '人による受け入れ確認のテスト環境(URL、なければ空欄)',
714
723
  exec_mode: '実行モード',
715
724
  exec_manual: '手動 — 自分でエージェントを呼び出す',
716
725
  exec_autopilot: 'オートパイロット — Nexusがパイプライン全体を管理',
@@ -797,6 +806,7 @@ const LANGUAGES = {
797
806
  selected_packs: 'Pack selezionati',
798
807
  enter_name: 'Il tuo nome (gli agenti lo usano per salutarti)',
799
808
  comm_language: 'Lingua di comunicazione',
809
+ uat_environment: 'Ambiente di test per le verifiche di accettazione umana (URL, vuoto se nessuno)',
800
810
  exec_mode: 'Modalità di esecuzione',
801
811
  exec_manual: 'Manuale — Chiami tu gli agenti',
802
812
  exec_autopilot: 'Autopilot — Nexus gestisce l\'intera pipeline',
@@ -211,7 +211,7 @@ const DERIVED = {
211
211
  "product": {
212
212
  "code": "bmad-plus",
213
213
  "displayName": "BMAD+",
214
- "version": "0.16.0",
214
+ "version": "0.17.0",
215
215
  "derivedFrom": "BMAD-METHOD v6.6.0"
216
216
  },
217
217
  "packOrder": [
@@ -247,6 +247,15 @@ function validateSpec(spec, options = {}) {
247
247
  `${at}: writes: true requires verify {kind: ${VERIFY_KINDS.join('|')}, text} — the read-only check run after the tester`
248
248
  );
249
249
  }
250
+ for (const [line, text] of (step.do || []).entries()) {
251
+ // A tester copies "doctor." from "Run <code>…doctor</code>." into the terminal —
252
+ // it happened on the first human run of BMAD+'s own recipe.
253
+ if (/<\/code>\s*[.,;:!?](?:\s|$)/.test(String(text))) {
254
+ warnings.push(
255
+ `${at}: "do" line ${line + 1} puts punctuation right after a command — testers copy it into the terminal`
256
+ );
257
+ }
258
+ }
250
259
  const letters = new Set();
251
260
  for (const expect of step.expect || []) {
252
261
  for (const key of unknownKeys(expect, EXPECT_KEYS))
@@ -706,6 +715,13 @@ function gate({ spec, runs, triage }) {
706
715
  );
707
716
  } else if (entry.decision === 'accept-risk' && !String(entry.decidedBy || '').trim()) {
708
717
  reasons.push(`${failure.step}/${failure.expect}: accept-risk requires decidedBy`);
718
+ } else if (failure.state === 'blocked' && entry.decision !== 'accept-risk') {
719
+ // A blocked expectation was never observed. Classifying why does not establish
720
+ // anything about the product: replay it, or have someone accept the risk by name.
721
+ // (First run of BMAD+'s own recipe: 17 of 21 blocked would otherwise have passed.)
722
+ reasons.push(
723
+ `${failure.step}/${failure.expect} was blocked: nothing was observed — replay it, or record accept-risk with decidedBy`
724
+ );
709
725
  }
710
726
  }
711
727
  // A tick on a step that writes is not a write: the read-only proof settles it.
@@ -815,6 +831,8 @@ for (const [stepId, step] of Object.entries(run.steps || {})) {
815
831
  if (!entry) problems.push(stepId + '/' + letter + ' ' + expect.state + ', not triaged');
816
832
  else if (!entry.class || entry.class === 'undecided' || !entry.decision) {
817
833
  problems.push(stepId + '/' + letter + ' triaged without a class and a decision');
834
+ } else if (expect.state === 'blocked' && (entry.decision !== 'accept-risk' || !entry.decidedBy)) {
835
+ problems.push(stepId + '/' + letter + ' was blocked and never observed; replay it or accept the risk by name');
818
836
  }
819
837
  }
820
838
  }
@@ -38,6 +38,7 @@ const CANDIDATE_WEBSITE_FILES = Object.freeze([
38
38
  'docs-page.js',
39
39
  'docs-markdown.js',
40
40
  'page-shell.js',
41
+ 'public-links.js',
41
42
  'styles.css',
42
43
  'site.js',
43
44
  'favicon.svg',