bmad-plus 0.14.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +55 -18
  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 +11 -3
  7. package/readme-international/README.de.md +20 -9
  8. package/readme-international/README.es.md +21 -10
  9. package/readme-international/README.fr.md +20 -9
  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 +158 -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 +628 -0
  32. package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +442 -0
  33. package/src/bmad-plus/skills/bmad-plus-uat/templates/README.md +63 -0
  34. package/src/bmad-plus/skills/bmad-plus-uat/templates/example-uat-spec.json +95 -0
  35. package/src/bmad-plus/skills/bmad-plus-uat/templates/uat-release-gate.mjs +127 -0
  36. package/src/bmad-plus/skills/bmad-plus-uat/templates/uat-spec-present.mjs +77 -0
  37. package/tools/build/check-install-contract.js +202 -4
  38. package/tools/build/generate.js +16 -0
  39. package/tools/build/generated-adapters/.codex/AGENTS.md +1 -1
  40. package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +1 -1
  41. package/tools/build/generated-adapters/.opencode/AGENTS.md +1 -1
  42. package/tools/build/generated-adapters/AGENTS.md +1 -1
  43. package/tools/build/generated-adapters/CLAUDE.md +1 -1
  44. package/tools/build/generated-adapters/CONVENTIONS.md +1 -1
  45. package/tools/build/generated-adapters/GEMINI.md +1 -1
  46. package/tools/cli/bmad-plus-cli.js +15 -12
  47. package/tools/cli/commands/autoconfig.js +4 -2
  48. package/tools/cli/commands/doctor.js +1 -0
  49. package/tools/cli/commands/install.js +21 -2
  50. package/tools/cli/commands/memory-journal-cmd.js +119 -19
  51. package/tools/cli/commands/nexus.js +111 -0
  52. package/tools/cli/commands/uat.js +405 -0
  53. package/tools/cli/i18n.js +10 -0
  54. package/tools/cli/lib/README-memory-journal.md +19 -8
  55. package/tools/cli/lib/installation-health.js +6 -0
  56. package/tools/cli/lib/memory-journal.js +0 -0
  57. package/tools/cli/lib/memory-outcomes.js +293 -0
  58. package/tools/cli/lib/memory-store.js +139 -0
  59. package/tools/cli/lib/nexus-process.js +377 -0
  60. package/tools/cli/lib/nexus.js +1532 -0
  61. package/tools/cli/lib/pack-copy.js +39 -11
  62. package/tools/cli/lib/packs.js +17 -3
  63. package/tools/cli/lib/uat.js +887 -0
  64. package/tools/maintain/upstream-candidate.js +456 -0
  65. package/tools/release/publication-content.js +4 -1
  66. package/tools/release/supply-chain.js +282 -0
@@ -0,0 +1,628 @@
1
+ <!doctype html>
2
+ <html lang="__LANGUAGE__" dir="__DIR__">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>__TITLE__</title>
7
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
8
+ <style>
9
+ :root {
10
+ --bg: #f6f5f2; --card: #ffffff; --ink: #1b1d1f; --muted: #6b6f73; --line: #d9d7d1; --line-strong: #b9b6ae;
11
+ --accent: #0f6f74; --accent-ink: #ffffff; --accent-soft: #e2f0f0;
12
+ --ok: #1f7a3a; --ok-soft: #e4f2e8; --ko: #b3261e; --ko-soft: #f8e3e1; --warn: #b26a00; --warn-soft: #fbeeda;
13
+ --mono-bg: #edece7; --focus: #0f6f74;
14
+ --sans: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
15
+ --serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;
16
+ --mono: "IBM Plex Mono", Consolas, "Courier New", monospace;
17
+ }
18
+ @media (prefers-color-scheme: dark) {
19
+ :root:not([data-theme="light"]) {
20
+ --bg: #151719; --card: #1d2023; --ink: #e8e6e1; --muted: #a2a5a9; --line: #2c3034; --line-strong: #454a4f;
21
+ --accent: #4fb3b8; --accent-ink: #0c1516; --accent-soft: #163537;
22
+ --ok: #5fc27a; --ok-soft: #173321; --ko: #f08a80; --ko-soft: #3a1c19; --warn: #e0a24a; --warn-soft: #3a2a12;
23
+ --mono-bg: #24282c; --focus: #4fb3b8;
24
+ }
25
+ }
26
+ :root[data-theme="dark"] {
27
+ --bg: #151719; --card: #1d2023; --ink: #e8e6e1; --muted: #a2a5a9; --line: #2c3034; --line-strong: #454a4f;
28
+ --accent: #4fb3b8; --accent-ink: #0c1516; --accent-soft: #163537;
29
+ --ok: #5fc27a; --ok-soft: #173321; --ko: #f08a80; --ko-soft: #3a1c19; --warn: #e0a24a; --warn-soft: #3a2a12;
30
+ --mono-bg: #24282c; --focus: #4fb3b8;
31
+ }
32
+ * { box-sizing: border-box; }
33
+ /* A class that sets display wins over the hidden attribute: without this rule the steps
34
+ showed before any run existed, and a tester's answers were silently discarded. */
35
+ [hidden] { display: none !important; }
36
+ code.cmd { cursor: copy; }
37
+ code.cmd.copied::after { content: " \2713 " attr(data-copied); font-family: var(--sans); color: var(--ok); }
38
+ body { background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.5; margin: 0; padding-inline: 16px; padding-block: 0 64px; }
39
+ h1, h2, h3 { font-family: var(--serif); text-wrap: balance; margin: 0; }
40
+ h1 { font-size: 1.7rem; font-weight: 600; }
41
+ h2 { font-size: 1.2rem; font-weight: 600; }
42
+ p { margin: 0; }
43
+ a { color: var(--accent); }
44
+ code, .ecran { font-family: var(--mono); font-size: 0.88em; background: var(--mono-bg); padding: 1px 6px; border-radius: 3px; }
45
+ .wrap { max-width: 860px; margin: 0 auto; }
46
+ .eyebrow { font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
47
+ .bar { position: sticky; top: 0; z-index: 5; background: var(--bg); border-bottom: 1px solid var(--line); padding-block: 10px; margin-inline: -16px; padding-inline: 16px; }
48
+ .bar-in { max-width: 860px; margin: 0 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; }
49
+ .count { display: flex; gap: 14px; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
50
+ .count b { font-weight: 600; }
51
+ .count .ok { color: var(--ok); } .count .ko { color: var(--ko); } .count .warn { color: var(--warn); } .count .todo { color: var(--muted); }
52
+ /* One segment per step: green seen, red not seen, orange blocked, grey skipped, empty to do. */
53
+ .strip { flex: 1 1 100%; display: flex; gap: 3px; min-height: 12px; }
54
+ .strip a { flex: 1 1 0; min-width: 10px; height: 12px; border-radius: 3px; background: var(--line); border: 1px solid transparent; }
55
+ .strip a:hover, .strip a:focus-visible { border-color: var(--ink); outline: none; }
56
+ .strip a[data-state="passed"] { background: var(--ok); }
57
+ .strip a[data-state="failed"] { background: var(--ko); }
58
+ .strip a[data-state="blocked"] { background: var(--warn); }
59
+ .strip a[data-state="skipped"] { background: var(--line-strong); }
60
+ .strip a[data-state="partial"] { background: linear-gradient(90deg, var(--ok) 50%, var(--line) 50%); }
61
+ .state { font-size: 0.82rem; color: var(--muted); }
62
+ .state.saved { color: var(--ok); }
63
+ .head { padding-block: 26px 18px; display: grid; gap: 10px; }
64
+ .head p { max-width: 65ch; }
65
+ .lang { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; color: var(--muted); }
66
+ select { font: inherit; font-size: 0.85rem; color: var(--ink); background: var(--card); border: 1px solid var(--line-strong); border-radius: 4px; padding: 4px 8px; }
67
+ .startbox { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; margin-block: 10px 22px; }
68
+ .startbox label { display: grid; gap: 6px; font-weight: 500; }
69
+ input[type="text"], textarea { font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line-strong); border-radius: 4px; padding: 8px 10px; width: 100%; }
70
+ textarea { min-height: 64px; resize: vertical; }
71
+ input:focus-visible, textarea:focus-visible, button:focus-visible, select:focus-visible, .pill input:focus-visible + span { outline: 2px solid var(--focus); outline-offset: 2px; }
72
+ .btn { font: inherit; font-weight: 600; border-radius: 4px; padding: 8px 14px; border: 1px solid var(--line-strong); background: var(--card); color: var(--ink); cursor: pointer; }
73
+ .btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
74
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
75
+ .row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
76
+ .resume { display: grid; gap: 6px; }
77
+ .resume button { text-align: start; }
78
+ .warn-box { border-inline-start: 3px solid var(--warn); background: var(--warn-soft); padding: 10px 12px; border-radius: 4px; }
79
+ .info-box { border-inline-start: 3px solid var(--accent); background: var(--accent-soft); padding: 10px 12px; border-radius: 4px; }
80
+ .steps { display: grid; gap: 18px; }
81
+ .step { background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
82
+ .step[data-state="passed"] { border-color: var(--ok); }
83
+ .step[data-state="failed"] { border-color: var(--ko); }
84
+ .step[data-state="blocked"] { border-color: var(--warn); }
85
+ .step-head { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
86
+ .num { font-family: var(--serif); font-size: 1.5rem; font-weight: 600; color: var(--accent); min-width: 2ch; line-height: 1; }
87
+ .step-head h2 { flex: 1 1 260px; }
88
+ .duration { font-size: 0.8rem; color: var(--muted); white-space: nowrap; }
89
+ .verdict { font-size: 0.78rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; padding: 3px 9px; border-radius: 12px; background: var(--mono-bg); color: var(--muted); }
90
+ .step[data-state="passed"] .verdict { background: var(--ok-soft); color: var(--ok); }
91
+ .step[data-state="failed"] .verdict { background: var(--ko-soft); color: var(--ko); }
92
+ .step[data-state="blocked"] .verdict { background: var(--warn-soft); color: var(--warn); }
93
+ .step-body { padding: 14px 16px 16px; display: grid; gap: 14px; }
94
+ .section { display: grid; gap: 6px; }
95
+ .section ol, .section ul { margin: 0; padding-inline-start: 22px; display: grid; gap: 5px; }
96
+ .section li { max-width: 68ch; }
97
+ .expects { display: grid; gap: 8px; }
98
+ .expect { display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; align-items: start; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; }
99
+ .expect[data-state="passed"] { background: var(--ok-soft); border-color: transparent; }
100
+ .expect[data-state="failed"] { background: var(--ko-soft); border-color: transparent; }
101
+ .expect[data-state="blocked"] { background: var(--warn-soft); border-color: transparent; }
102
+ .expect[data-state="skipped"] { opacity: 0.6; }
103
+ .expect .text { max-width: 60ch; }
104
+ .expect .why { grid-column: 1 / -1; }
105
+ .expect .why label { font-size: 0.85rem; color: var(--muted); display: grid; gap: 4px; }
106
+ .choice { display: inline-flex; gap: 6px; flex-wrap: wrap; }
107
+ .pill { position: relative; }
108
+ .pill input { position: absolute; opacity: 0; width: 1px; height: 1px; }
109
+ .pill span { display: inline-block; font-size: 0.8rem; font-weight: 600; padding: 5px 10px; border-radius: 14px; border: 1px solid var(--line-strong); cursor: pointer; user-select: none; color: var(--muted); background: var(--card); }
110
+ .pill.passed input:checked + span { background: var(--ok); color: #fff; border-color: var(--ok); }
111
+ .pill.failed input:checked + span { background: var(--ko); color: #fff; border-color: var(--ko); }
112
+ .pill.blocked input:checked + span { background: var(--warn); color: #fff; border-color: var(--warn); }
113
+ .note label { font-size: 0.85rem; color: var(--muted); display: grid; gap: 4px; }
114
+ .end { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; margin-top: 22px; }
115
+ .json { font-family: var(--mono); font-size: 0.8rem; min-height: 180px; white-space: pre; overflow: auto; direction: ltr; text-align: start; }
116
+ details > summary { cursor: pointer; font-weight: 600; }
117
+ @media (max-width: 480px) { .expect { grid-template-columns: 1fr; } .num { font-size: 1.25rem; } }
118
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <div class="bar" id="bar" hidden>
123
+ <div class="bar-in">
124
+ <span class="eyebrow" id="bar-tester"></span>
125
+ <div class="count">
126
+ <span class="ok"><b id="c-passed">0</b> <span id="l-passed"></span></span>
127
+ <span class="ko"><b id="c-failed">0</b> <span id="l-failed"></span></span>
128
+ <span class="warn"><b id="c-blocked">0</b> <span id="l-blocked"></span></span>
129
+ <span class="todo"><b id="c-todo">0</b> <span id="l-todo"></span></span>
130
+ </div>
131
+ <nav class="strip" id="strip"></nav>
132
+ <span class="state" id="save-state">—</span>
133
+ </div>
134
+ </div>
135
+
136
+ <div class="wrap">
137
+ <header class="head" id="head"></header>
138
+ <p class="lang"><label for="lang" id="t-language"></label><select id="lang"></select></p>
139
+ <section class="startbox" id="startbox">
140
+ <h2 id="t-before"></h2>
141
+ <label for="tester"><span id="t-tester"></span><input type="text" id="tester" autocomplete="name"></label>
142
+ <div class="row">
143
+ <button class="btn primary" id="btn-start" type="button"></button>
144
+ <span class="state" id="start-state"></span>
145
+ </div>
146
+ <div class="resume" id="resume" hidden>
147
+ <span class="eyebrow" id="t-resume"></span>
148
+ <div id="resume-list" class="resume"></div>
149
+ </div>
150
+ </section>
151
+ <main class="steps" id="steps" hidden></main>
152
+ <section class="end" id="end" hidden>
153
+ <h2 id="t-finish"></h2>
154
+ <div id="closing" class="section"></div>
155
+ <label class="note" for="overall"><span id="t-overall"></span><textarea id="overall"></textarea></label>
156
+ <div class="row">
157
+ <button class="btn primary" id="btn-finish" type="button" disabled></button>
158
+ <button class="btn" id="btn-save" type="button" disabled></button>
159
+ <button class="btn" id="btn-copy" type="button" disabled></button>
160
+ <span class="state" id="end-state"></span>
161
+ </div>
162
+ <p class="info-box" id="mode-note"></p>
163
+ <details><summary>JSON</summary><textarea class="json" id="json" readonly aria-label="JSON"></textarea></details>
164
+ </section>
165
+ </div>
166
+
167
+ <script id="uat-spec" type="application/json">__SPEC_JSON__</script>
168
+ <script id="uat-strings" type="application/json">__STRINGS__</script>
169
+ <script>
170
+ (function () {
171
+ "use strict";
172
+ var SPEC = JSON.parse(document.getElementById("uat-spec").textContent);
173
+ var ALL = JSON.parse(document.getElementById("uat-strings").textContent);
174
+ var SPEC_SHA = "__SPEC_SHA__";
175
+ var RESULTS_SCHEMA = "bmad-plus/uat-results/2";
176
+ var STEPS = SPEC.steps;
177
+ var KEY = "uat-" + SPEC.id;
178
+
179
+ var run = null, runId = null, T = null, lang = null;
180
+ var db = null, dbDoc = null, unsub = null, downloads = null, serve = null;
181
+ var timer = null, lastWritten = "";
182
+ var $ = function (id) { return document.getElementById(id); };
183
+ var lsGet = function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } };
184
+ var lsSet = function (k, v) { try { localStorage.setItem(k, v); } catch (e) {} };
185
+
186
+ // ── Language: the recipe's own, the tester's browser, or their choice ───
187
+ function pickLanguage() {
188
+ var saved = lsGet(KEY + "-lang");
189
+ if (saved && ALL[saved]) return saved;
190
+ if (SPEC.language && ALL[SPEC.language]) return SPEC.language;
191
+ var tags = (navigator.languages || [navigator.language || ""]).map(String);
192
+ for (var i = 0; i < tags.length; i++) {
193
+ var tag = tags[i];
194
+ if (ALL[tag]) return tag;
195
+ var base = tag.split("-")[0];
196
+ for (var code in ALL) { if (code === base || code.split("-")[0] === base) return code; }
197
+ }
198
+ return ALL.en ? "en" : Object.keys(ALL)[0];
199
+ }
200
+
201
+ function fillLanguageChoice() {
202
+ var select = $("lang");
203
+ select.innerHTML = "";
204
+ Object.keys(ALL).forEach(function (code) {
205
+ var option = document.createElement("option");
206
+ option.value = code;
207
+ option.textContent = ALL[code].name;
208
+ option.selected = code === lang;
209
+ select.appendChild(option);
210
+ });
211
+ }
212
+
213
+ function applyLanguage(code) {
214
+ lang = ALL[code] ? code : "en";
215
+ T = ALL[lang];
216
+ lsSet(KEY + "-lang", lang);
217
+ document.documentElement.lang = lang;
218
+ document.documentElement.dir = T.dir || "ltr";
219
+ $("t-language").textContent = T.uiLanguage;
220
+ $("t-before").textContent = T.before;
221
+ $("t-tester").textContent = T.tester;
222
+ $("btn-start").textContent = T.start;
223
+ $("t-resume").textContent = T.resume;
224
+ $("t-finish").textContent = T.finish;
225
+ $("t-overall").textContent = T.overall;
226
+ $("btn-finish").textContent = run && run.finishedAt ? T.finished : T.finishRun;
227
+ $("btn-save").textContent = T.saveJson;
228
+ $("btn-copy").textContent = T.copyJson;
229
+ $("l-passed").textContent = T.countSeen;
230
+ $("l-failed").textContent = T.countNotSeen;
231
+ $("l-blocked").textContent = T.countBlocked;
232
+ $("l-todo").textContent = T.todo;
233
+ $("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
234
+ renderHead();
235
+ renderSteps();
236
+ renderStrip();
237
+ if (run) reflect();
238
+ listResumable();
239
+ }
240
+
241
+ function renderHead() {
242
+ var head = $("head");
243
+ head.innerHTML = "";
244
+ var subtitle = document.createElement("span");
245
+ subtitle.className = "eyebrow";
246
+ subtitle.textContent = SPEC.subtitle || (SPEC.product + " · " + SPEC.environment.name + " · " + SPEC.versions.join(" · ") + (SPEC.date ? " · " + SPEC.date : ""));
247
+ head.appendChild(subtitle);
248
+ var title = document.createElement("h1");
249
+ title.textContent = SPEC.title;
250
+ head.appendChild(title);
251
+ var summary = document.createElement("p");
252
+ summary.textContent = STEPS.length + " " + T.stepsLabel + (SPEC.estimate ? " · " + SPEC.estimate : "") + " · " + SPEC.environment.name + (SPEC.environment.url ? " — " + SPEC.environment.url : "");
253
+ head.appendChild(summary);
254
+ (SPEC.intro || []).forEach(function (text) { var p = document.createElement("p"); p.innerHTML = text; head.appendChild(p); });
255
+ (SPEC.warnings || []).forEach(function (text) { var p = document.createElement("p"); p.className = "warn-box"; p.innerHTML = text; head.appendChild(p); });
256
+ (SPEC.notes || []).forEach(function (text) { var p = document.createElement("p"); p.className = "info-box"; p.innerHTML = text; head.appendChild(p); });
257
+ var closing = $("closing");
258
+ closing.innerHTML = "";
259
+ ((SPEC.closing && SPEC.closing.text) || []).forEach(function (text) { var p = document.createElement("p"); p.innerHTML = text; closing.appendChild(p); });
260
+ }
261
+
262
+ function newRun(tester) {
263
+ var steps = {};
264
+ STEPS.forEach(function (step) {
265
+ var expect = {};
266
+ step.expect.forEach(function (e) { expect[e.id] = { text: e.text, state: null, note: "" }; });
267
+ steps[step.id] = { title: step.title, state: null, note: "", expect: expect };
268
+ });
269
+ var now = new Date().toISOString();
270
+ return {
271
+ schema: RESULTS_SCHEMA, specId: SPEC.id, specSha256: SPEC_SHA, runId: null, tester: tester,
272
+ startedAt: now, updatedAt: now, finishedAt: null, steps: steps, overallNote: "",
273
+ summary: { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 },
274
+ };
275
+ }
276
+ function slug(s) { return String(s).normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 24) || "anonymous"; }
277
+ function newRunId(tester) {
278
+ var d = new Date(), p = function (n) { return String(n).padStart(2, "0"); };
279
+ return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + "-" + slug(tester) + "-" + Math.random().toString(36).slice(2, 6);
280
+ }
281
+ function stepState(step) {
282
+ var values = Object.keys(step.expect).map(function (k) { return step.expect[k].state; });
283
+ if (values.indexOf("failed") >= 0) return "failed";
284
+ if (values.indexOf("blocked") >= 0) return "blocked";
285
+ if (values.length && values.every(function (v) { return v === "skipped"; })) return "skipped";
286
+ if (values.length && values.every(function (v) { return v === "passed" || v === "skipped"; })) return "passed";
287
+ return null;
288
+ }
289
+ function recount() {
290
+ var s = { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 };
291
+ Object.keys(run.steps).forEach(function (id) {
292
+ var step = run.steps[id];
293
+ step.state = stepState(step);
294
+ Object.keys(step.expect).forEach(function (k) {
295
+ var v = step.expect[k].state;
296
+ if (v === null || v === undefined) s.unanswered++; else s[v]++;
297
+ });
298
+ });
299
+ run.summary = s;
300
+ }
301
+
302
+ // ── Steps ───────────────────────────────────────────────────────────
303
+ function renderSteps() {
304
+ var main = $("steps");
305
+ main.innerHTML = "";
306
+ STEPS.forEach(function (step, index) {
307
+ var article = document.createElement("article");
308
+ article.className = "step";
309
+ article.id = "step-" + step.id;
310
+ var head = '<div class="step-head"><span class="num">' + index + '</span><h2></h2>'
311
+ + (step.duration ? '<span class="duration"></span>' : "")
312
+ + '<span class="verdict" id="v-' + step.id + '"></span></div>';
313
+ var body = '<div class="step-body">';
314
+ if (step.warning) body += '<p class="warn-box">' + step.warning + "</p>";
315
+ else if (step.writes) body += '<p class="warn-box"><b>' + T.writesWarning + " " + SPEC.environment.name + ".</b> " + T.intended + "</p>";
316
+ if (step.optional) body += '<p class="info-box">' + T.optional + ' — <button class="btn" type="button" data-skip="' + step.id + '">' + T.skip + "</button></p>";
317
+ body += '<div class="section"><span class="eyebrow">' + T.where + "</span><ul>" + step.where.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ul></div>";
318
+ body += '<div class="section"><span class="eyebrow">' + T.doIt + "</span><ol>" + step.do.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ol></div>";
319
+ body += '<div class="section"><span class="eyebrow">' + T.expect + '</span><div class="expects">';
320
+ step.expect.forEach(function (e) {
321
+ var name = "e-" + step.id + "-" + e.id;
322
+ body += '<div class="expect" id="' + name + '" data-state=""><div class="text">' + e.text + "</div>"
323
+ + '<div class="choice" role="radiogroup">'
324
+ + '<label class="pill passed"><input type="radio" name="' + name + '" id="' + name + '-passed" value="passed"><span>' + T.seen + "</span></label>"
325
+ + '<label class="pill failed"><input type="radio" name="' + name + '" id="' + name + '-failed" value="failed"><span>' + T.notSeen + "</span></label>"
326
+ + '<label class="pill blocked"><input type="radio" name="' + name + '" id="' + name + '-blocked" value="blocked"><span>' + T.blocked + "</span></label>"
327
+ + "</div>"
328
+ + '<div class="why" id="why-' + step.id + "-" + e.id + '" hidden><label for="n-' + step.id + "-" + e.id + '"><span>' + T.whyNot + '</span><textarea id="n-' + step.id + "-" + e.id + '"></textarea></label></div>'
329
+ + "</div>";
330
+ });
331
+ body += "</div></div>";
332
+ body += '<div class="section note"><label for="note-' + step.id + '"><span>' + T.note + '</span><textarea id="note-' + step.id + '"></textarea></label></div></div>';
333
+ article.innerHTML = head + body;
334
+ article.querySelector("h2").textContent = step.title;
335
+ if (step.duration) article.querySelector(".duration").textContent = step.duration;
336
+ // A command read inside a sentence gets copied with the sentence's punctuation
337
+ // ("doctor." instead of "doctor"): each command in "Do" copies itself exactly.
338
+ Array.prototype.forEach.call(article.querySelectorAll(".section ol code"), function (code) {
339
+ code.classList.add("cmd");
340
+ code.setAttribute("role", "button");
341
+ code.setAttribute("tabindex", "0");
342
+ code.title = T.copyCommand;
343
+ });
344
+ main.appendChild(article);
345
+ });
346
+ }
347
+
348
+ function renderStrip() {
349
+ var strip = $("strip");
350
+ strip.innerHTML = "";
351
+ STEPS.forEach(function (step, index) {
352
+ var segment = document.createElement("a");
353
+ segment.id = "s-" + step.id;
354
+ segment.href = "#step-" + step.id;
355
+ segment.title = index + " · " + step.title;
356
+ strip.appendChild(segment);
357
+ });
358
+ }
359
+
360
+ function copyCommand(code) {
361
+ var text = code.textContent.trim();
362
+ var done = function () {
363
+ code.setAttribute("data-copied", T.commandCopied);
364
+ code.classList.add("copied");
365
+ setTimeout(function () { code.classList.remove("copied"); }, 1500);
366
+ };
367
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () {});
368
+ }
369
+
370
+ /** Nothing is recorded before a run exists: say so, instead of discarding answers in silence. */
371
+ function requireRun(event) {
372
+ if (run) return true;
373
+ if (event && event.target instanceof HTMLInputElement && event.target.type === "radio") event.target.checked = false;
374
+ $("startbox").hidden = false;
375
+ $("start-state").textContent = T.startFirst;
376
+ $("startbox").scrollIntoView({ block: "center" });
377
+ $("tester").focus();
378
+ return false;
379
+ }
380
+
381
+ function reflect() {
382
+ recount();
383
+ STEPS.forEach(function (step) {
384
+ var observed = run.steps[step.id];
385
+ if (!observed) return;
386
+ var article = $("step-" + step.id);
387
+ article.dataset.state = observed.state || "";
388
+ var verdict = observed.state === "passed" ? T.seen : observed.state === "failed" ? T.notSeen : observed.state === "blocked" ? T.blocked : observed.state === "skipped" ? T.skip : T.todo;
389
+ $("v-" + step.id).textContent = verdict;
390
+ step.expect.forEach(function (e) {
391
+ var name = "e-" + step.id + "-" + e.id, value = observed.expect[e.id].state;
392
+ ["passed", "failed", "blocked"].forEach(function (state) { $(name + "-" + state).checked = value === state; });
393
+ $(name).dataset.state = value || "";
394
+ $("why-" + step.id + "-" + e.id).hidden = !(value === "failed" || value === "blocked");
395
+ var field = $("n-" + step.id + "-" + e.id);
396
+ if (document.activeElement !== field) field.value = observed.expect[e.id].note || "";
397
+ });
398
+ var note = $("note-" + step.id);
399
+ if (document.activeElement !== note) note.value = observed.note || "";
400
+ });
401
+ var s = run.summary;
402
+ var total = s.passed + s.failed + s.blocked + s.skipped + s.unanswered;
403
+ $("c-passed").textContent = s.passed;
404
+ $("c-failed").textContent = s.failed;
405
+ $("c-blocked").textContent = s.blocked;
406
+ $("c-todo").textContent = s.unanswered;
407
+ STEPS.forEach(function (step, index) {
408
+ var segment = $("s-" + step.id);
409
+ if (!segment) return;
410
+ var observed = run.steps[step.id];
411
+ var states = Object.keys(observed.expect).map(function (k) { return observed.expect[k].state; });
412
+ var answered = states.filter(function (v) { return v !== null && v !== undefined; }).length;
413
+ var state = observed.state || (answered ? "partial" : "");
414
+ segment.setAttribute("data-state", state);
415
+ segment.title = index + " · " + step.title + " — " + (state === "passed" ? T.seen : state === "failed" ? T.notSeen : state === "blocked" ? T.blocked : state === "skipped" ? T.skip : T.todo) + " (" + answered + "/" + states.length + ")";
416
+ });
417
+ $("strip").setAttribute("aria-label", total + " · " + s.passed + " " + T.seen + " · " + s.failed + " " + T.notSeen + " · " + s.blocked + " " + T.blocked);
418
+ // "Run by laurent · finished on 2026-09-16", as the recipes played in claude.ai always read.
419
+ $("bar-tester").textContent = T.runBy.replace("{tester}", run.tester) +
420
+ (run.finishedAt ? " · " + T.finishedOn.replace("{date}", String(run.finishedAt).slice(0, 10)) : "");
421
+ var overall = $("overall");
422
+ if (document.activeElement !== overall) overall.value = run.overallNote || "";
423
+ $("json").value = JSON.stringify(run, null, 2);
424
+ $("btn-finish").disabled = Boolean(run.finishedAt);
425
+ $("btn-finish").textContent = run.finishedAt ? T.finished : T.finishRun;
426
+ }
427
+
428
+ // ── Persistence: artifact database, local server, or this browser ────
429
+ function persist(immediate) {
430
+ recount();
431
+ run.updatedAt = new Date().toISOString();
432
+ reflect();
433
+ lsSet(KEY + "-" + runId, JSON.stringify(run));
434
+ var body = JSON.stringify(run);
435
+ if (body === lastWritten) return;
436
+ var write = function () {
437
+ if (dbDoc) {
438
+ dbDoc.set(JSON.parse(body)).then(function () {
439
+ lastWritten = body;
440
+ $("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
441
+ $("save-state").className = "state saved";
442
+ }).catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
443
+ return;
444
+ }
445
+ if (serve) {
446
+ fetch(serve + "/results/" + runId, { method: "PUT", headers: { "content-type": "application/json" }, body: body })
447
+ .then(function (response) {
448
+ if (!response.ok) throw new Error(String(response.status));
449
+ lastWritten = body;
450
+ $("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
451
+ $("save-state").className = "state saved";
452
+ })
453
+ .catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
454
+ return;
455
+ }
456
+ $("save-state").textContent = T.savedLocal;
457
+ };
458
+ clearTimeout(timer);
459
+ if (immediate) write(); else timer = setTimeout(write, 500);
460
+ }
461
+
462
+ function open(id, doc) {
463
+ runId = id;
464
+ run = doc;
465
+ run.runId = id;
466
+ lsSet(KEY + "-last", id);
467
+ $("startbox").hidden = true;
468
+ $("steps").hidden = false;
469
+ $("end").hidden = false;
470
+ $("bar").hidden = false;
471
+ $("btn-save").disabled = false;
472
+ $("btn-copy").disabled = false;
473
+ reflect();
474
+ if (db) {
475
+ dbDoc = db.doc("recettes/" + id);
476
+ if (unsub) unsub();
477
+ unsub = dbDoc.onSnapshot(function (snapshot) {
478
+ if (!snapshot.exists) return;
479
+ var data = snapshot.data();
480
+ if (!data || snapshot.metadata.hasPendingWrites) return;
481
+ var body = JSON.stringify(data);
482
+ if (body !== JSON.stringify(run) && data.updatedAt > run.updatedAt) { run = JSON.parse(body); lastWritten = body; reflect(); }
483
+ }, function () {});
484
+ }
485
+ $("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
486
+ }
487
+
488
+ function start() {
489
+ var tester = $("tester").value.trim();
490
+ if (!tester) { $("start-state").textContent = T.startFirst; $("tester").focus(); return; }
491
+ var id = newRunId(tester);
492
+ open(id, newRun(tester));
493
+ persist(true);
494
+ window.scrollTo({ top: 0 });
495
+ }
496
+
497
+ function listResumable() {
498
+ var box = $("resume-list");
499
+ box.innerHTML = "";
500
+ var seen = {};
501
+ var add = function (id, doc) {
502
+ if (seen[id] || !doc) return;
503
+ seen[id] = true;
504
+ var button = document.createElement("button");
505
+ button.className = "btn";
506
+ button.type = "button";
507
+ button.textContent = (doc.tester || "?") + " — " + String(doc.startedAt || "").slice(0, 10) + (doc.finishedAt ? " · " + T.finished : "");
508
+ button.addEventListener("click", function () { open(id, doc); });
509
+ box.appendChild(button);
510
+ };
511
+ var pending = db
512
+ ? db.collection("recettes").where("specId", "==", SPEC.id).limit(50).get()
513
+ .then(function (query) { query.docs.forEach(function (d) { add(d.id, d.data()); }); }).catch(function () {})
514
+ : Promise.resolve();
515
+ pending.then(function () {
516
+ var last = lsGet(KEY + "-last");
517
+ if (last) { var raw = lsGet(KEY + "-" + last); if (raw) { try { add(last, JSON.parse(raw)); } catch (e) {} } }
518
+ $("resume").hidden = box.children.length === 0;
519
+ });
520
+ }
521
+
522
+ document.addEventListener("change", function (event) {
523
+ var target = event.target;
524
+ if (target === $("lang")) { applyLanguage(target.value); return; }
525
+ if (!(target instanceof HTMLInputElement) || target.type !== "radio") return;
526
+ var m = /^e-(.+)-([a-z])$/.exec(target.name);
527
+ if (!m || !requireRun(event)) return;
528
+ run.steps[m[1]].expect[m[2]].state = target.value;
529
+ persist();
530
+ });
531
+ document.addEventListener("input", function (event) {
532
+ var target = event.target;
533
+ if (!(target instanceof HTMLTextAreaElement)) return;
534
+ if (!requireRun(event)) return;
535
+ if (target === $("overall")) { run.overallNote = target.value; persist(); return; }
536
+ var step = /^note-(.+)$/.exec(target.id);
537
+ if (step && run.steps[step[1]]) { run.steps[step[1]].note = target.value; persist(); return; }
538
+ var expect = /^n-(.+)-([a-z])$/.exec(target.id);
539
+ if (expect && run.steps[expect[1]]) { run.steps[expect[1]].expect[expect[2]].note = target.value; persist(); }
540
+ });
541
+ document.addEventListener("keydown", function (event) {
542
+ var code = event.target && event.target.closest && event.target.closest("code.cmd");
543
+ if (code && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); copyCommand(code); }
544
+ });
545
+ document.addEventListener("click", function (event) {
546
+ var code = event.target && event.target.closest && event.target.closest("code.cmd");
547
+ if (code) { copyCommand(code); return; }
548
+ var id = event.target && event.target.getAttribute && event.target.getAttribute("data-skip");
549
+ if (!id || !requireRun(event)) return;
550
+ Object.keys(run.steps[id].expect).forEach(function (k) { run.steps[id].expect[k].state = "skipped"; });
551
+ persist();
552
+ });
553
+
554
+ $("btn-start").addEventListener("click", start);
555
+ $("tester").addEventListener("keydown", function (event) { if (event.key === "Enter") start(); });
556
+ $("btn-finish").addEventListener("click", function () {
557
+ if (!requireRun()) return;
558
+ if (run.summary.unanswered > 0 && !window.confirm(T.confirmUnanswered.replace("{n}", run.summary.unanswered))) return;
559
+ run.finishedAt = new Date().toISOString();
560
+ persist(true);
561
+ $("end-state").textContent = T.finished;
562
+ });
563
+ $("btn-copy").addEventListener("click", function () {
564
+ if (!requireRun()) return;
565
+ var text = JSON.stringify(run, null, 2);
566
+ var fallback = function () { $("json").parentElement.open = true; $("json").focus(); $("json").select(); };
567
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { $("end-state").textContent = T.copied; }, fallback);
568
+ else fallback();
569
+ });
570
+ $("btn-save").addEventListener("click", function () {
571
+ // Never export a run that does not exist: that is how a tester once saved "null.json".
572
+ if (!requireRun()) return;
573
+ var name = runId + ".json";
574
+ var text = JSON.stringify(run, null, 2);
575
+ if (downloads) {
576
+ downloads.save({ filename: name, data: text }).then(function () { $("end-state").textContent = name; }).catch(function () {});
577
+ return;
578
+ }
579
+ try {
580
+ var url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
581
+ var link = document.createElement("a");
582
+ link.href = url;
583
+ link.download = name;
584
+ document.body.appendChild(link);
585
+ link.click();
586
+ link.remove();
587
+ setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
588
+ $("end-state").textContent = name;
589
+ } catch (e) { $("end-state").textContent = T.copyJson; }
590
+ });
591
+
592
+ // ── Start: the page is complete without any capability ──────────────
593
+ lang = pickLanguage();
594
+ fillLanguageChoice();
595
+ applyLanguage(lang);
596
+ var resumeLast = function () {
597
+ var last = lsGet(KEY + "-last");
598
+ if (!last || run) return;
599
+ var raw = lsGet(KEY + "-" + last);
600
+ if (!raw) return;
601
+ try { open(last, JSON.parse(raw)); } catch (e) {}
602
+ };
603
+ fetch("__uat/ping", { method: "GET" })
604
+ .then(function (response) { return response.ok ? response.json() : null; })
605
+ .then(function (info) { if (info && info.uat) { serve = "__uat"; $("mode-note").textContent = T.serveMode; listResumable(); } })
606
+ .catch(function () {})
607
+ .then(function () {
608
+ var use = window.claude && typeof window.claude.use === "function" ? window.claude.use.bind(window.claude) : function () { return Promise.resolve(null); };
609
+ return use("db").then(function (namespace) {
610
+ db = namespace;
611
+ listResumable();
612
+ if (run && db && !dbDoc) { open(runId, run); persist(true); return; }
613
+ var last = lsGet(KEY + "-last");
614
+ if (last && !run && db) {
615
+ db.doc("recettes/" + last).get()
616
+ .then(function (snapshot) { if (snapshot.exists) open(last, snapshot.data()); else resumeLast(); })
617
+ .catch(resumeLast);
618
+ } else resumeLast();
619
+ });
620
+ })
621
+ .catch(resumeLast);
622
+ if (window.claude && typeof window.claude.use === "function") {
623
+ window.claude.use("downloads").then(function (namespace) { downloads = namespace; });
624
+ }
625
+ })();
626
+ </script>
627
+ </body>
628
+ </html>