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,552 @@
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
+ 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; }
34
+ h1, h2, h3 { font-family: var(--serif); text-wrap: balance; margin: 0; }
35
+ h1 { font-size: 1.7rem; font-weight: 600; }
36
+ h2 { font-size: 1.2rem; font-weight: 600; }
37
+ p { margin: 0; }
38
+ a { color: var(--accent); }
39
+ code, .ecran { font-family: var(--mono); font-size: 0.88em; background: var(--mono-bg); padding: 1px 6px; border-radius: 3px; }
40
+ .wrap { max-width: 860px; margin: 0 auto; }
41
+ .eyebrow { font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
42
+ .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; }
43
+ .bar-in { max-width: 860px; margin: 0 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; }
44
+ .count { display: flex; gap: 14px; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
45
+ .count b { font-weight: 600; }
46
+ .count .ok { color: var(--ok); } .count .ko { color: var(--ko); } .count .warn { color: var(--warn); } .count .todo { color: var(--muted); }
47
+ .gauge { flex: 1 1 160px; height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; display: flex; }
48
+ .gauge i { display: block; height: 100%; }
49
+ .gauge .ok { background: var(--ok); } .gauge .ko { background: var(--ko); } .gauge .warn { background: var(--warn); }
50
+ .state { font-size: 0.82rem; color: var(--muted); }
51
+ .state.saved { color: var(--ok); }
52
+ .head { padding-block: 26px 18px; display: grid; gap: 10px; }
53
+ .head p { max-width: 65ch; }
54
+ .lang { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; color: var(--muted); }
55
+ 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; }
56
+ .startbox { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; margin-block: 10px 22px; }
57
+ .startbox label { display: grid; gap: 6px; font-weight: 500; }
58
+ 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%; }
59
+ textarea { min-height: 64px; resize: vertical; }
60
+ 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; }
61
+ .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; }
62
+ .btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
63
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
64
+ .row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
65
+ .resume { display: grid; gap: 6px; }
66
+ .resume button { text-align: start; }
67
+ .warn-box { border-inline-start: 3px solid var(--warn); background: var(--warn-soft); padding: 10px 12px; border-radius: 4px; }
68
+ .info-box { border-inline-start: 3px solid var(--accent); background: var(--accent-soft); padding: 10px 12px; border-radius: 4px; }
69
+ .steps { display: grid; gap: 18px; }
70
+ .step { background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
71
+ .step[data-state="passed"] { border-color: var(--ok); }
72
+ .step[data-state="failed"] { border-color: var(--ko); }
73
+ .step[data-state="blocked"] { border-color: var(--warn); }
74
+ .step-head { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
75
+ .num { font-family: var(--serif); font-size: 1.5rem; font-weight: 600; color: var(--accent); min-width: 2ch; line-height: 1; }
76
+ .step-head h2 { flex: 1 1 260px; }
77
+ .duration { font-size: 0.8rem; color: var(--muted); white-space: nowrap; }
78
+ .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); }
79
+ .step[data-state="passed"] .verdict { background: var(--ok-soft); color: var(--ok); }
80
+ .step[data-state="failed"] .verdict { background: var(--ko-soft); color: var(--ko); }
81
+ .step[data-state="blocked"] .verdict { background: var(--warn-soft); color: var(--warn); }
82
+ .step-body { padding: 14px 16px 16px; display: grid; gap: 14px; }
83
+ .section { display: grid; gap: 6px; }
84
+ .section ol, .section ul { margin: 0; padding-inline-start: 22px; display: grid; gap: 5px; }
85
+ .section li { max-width: 68ch; }
86
+ .expects { display: grid; gap: 8px; }
87
+ .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; }
88
+ .expect[data-state="passed"] { background: var(--ok-soft); border-color: transparent; }
89
+ .expect[data-state="failed"] { background: var(--ko-soft); border-color: transparent; }
90
+ .expect[data-state="blocked"] { background: var(--warn-soft); border-color: transparent; }
91
+ .expect[data-state="skipped"] { opacity: 0.6; }
92
+ .expect .text { max-width: 60ch; }
93
+ .expect .why { grid-column: 1 / -1; }
94
+ .expect .why label { font-size: 0.85rem; color: var(--muted); display: grid; gap: 4px; }
95
+ .choice { display: inline-flex; gap: 6px; flex-wrap: wrap; }
96
+ .pill { position: relative; }
97
+ .pill input { position: absolute; opacity: 0; width: 1px; height: 1px; }
98
+ .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); }
99
+ .pill.passed input:checked + span { background: var(--ok); color: #fff; border-color: var(--ok); }
100
+ .pill.failed input:checked + span { background: var(--ko); color: #fff; border-color: var(--ko); }
101
+ .pill.blocked input:checked + span { background: var(--warn); color: #fff; border-color: var(--warn); }
102
+ .note label { font-size: 0.85rem; color: var(--muted); display: grid; gap: 4px; }
103
+ .end { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; margin-top: 22px; }
104
+ .json { font-family: var(--mono); font-size: 0.8rem; min-height: 180px; white-space: pre; overflow: auto; direction: ltr; text-align: start; }
105
+ details > summary { cursor: pointer; font-weight: 600; }
106
+ @media (max-width: 480px) { .expect { grid-template-columns: 1fr; } .num { font-size: 1.25rem; } }
107
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <div class="bar" id="bar" hidden>
112
+ <div class="bar-in">
113
+ <span class="eyebrow" id="bar-tester"></span>
114
+ <div class="count">
115
+ <span class="ok"><b id="c-passed">0</b> <span id="l-passed"></span></span>
116
+ <span class="ko"><b id="c-failed">0</b> <span id="l-failed"></span></span>
117
+ <span class="warn"><b id="c-blocked">0</b> <span id="l-blocked"></span></span>
118
+ <span class="todo"><b id="c-todo">0</b> <span id="l-todo"></span></span>
119
+ </div>
120
+ <div class="gauge" aria-hidden="true"><i class="ok" id="g-passed" style="width:0%"></i><i class="ko" id="g-failed" style="width:0%"></i><i class="warn" id="g-blocked" style="width:0%"></i></div>
121
+ <span class="state" id="save-state">—</span>
122
+ </div>
123
+ </div>
124
+
125
+ <div class="wrap">
126
+ <header class="head" id="head"></header>
127
+ <p class="lang"><label for="lang" id="t-language"></label><select id="lang"></select></p>
128
+ <section class="startbox" id="startbox">
129
+ <h2 id="t-before"></h2>
130
+ <label for="tester"><span id="t-tester"></span><input type="text" id="tester" autocomplete="name"></label>
131
+ <div class="row">
132
+ <button class="btn primary" id="btn-start" type="button"></button>
133
+ <span class="state" id="start-state"></span>
134
+ </div>
135
+ <div class="resume" id="resume" hidden>
136
+ <span class="eyebrow" id="t-resume"></span>
137
+ <div id="resume-list" class="resume"></div>
138
+ </div>
139
+ </section>
140
+ <main class="steps" id="steps" hidden></main>
141
+ <section class="end" id="end" hidden>
142
+ <h2 id="t-finish"></h2>
143
+ <div id="closing" class="section"></div>
144
+ <label class="note" for="overall"><span id="t-overall"></span><textarea id="overall"></textarea></label>
145
+ <div class="row">
146
+ <button class="btn primary" id="btn-finish" type="button"></button>
147
+ <button class="btn" id="btn-save" type="button"></button>
148
+ <button class="btn" id="btn-copy" type="button"></button>
149
+ <span class="state" id="end-state"></span>
150
+ </div>
151
+ <p class="info-box" id="mode-note"></p>
152
+ <details><summary>JSON</summary><textarea class="json" id="json" readonly aria-label="JSON"></textarea></details>
153
+ </section>
154
+ </div>
155
+
156
+ <script id="uat-spec" type="application/json">__SPEC_JSON__</script>
157
+ <script id="uat-strings" type="application/json">__STRINGS__</script>
158
+ <script>
159
+ (function () {
160
+ "use strict";
161
+ var SPEC = JSON.parse(document.getElementById("uat-spec").textContent);
162
+ var ALL = JSON.parse(document.getElementById("uat-strings").textContent);
163
+ var SPEC_SHA = "__SPEC_SHA__";
164
+ var RESULTS_SCHEMA = "bmad-plus/uat-results/2";
165
+ var STEPS = SPEC.steps;
166
+ var KEY = "uat-" + SPEC.id;
167
+
168
+ var run = null, runId = null, T = null, lang = null;
169
+ var db = null, dbDoc = null, unsub = null, downloads = null, serve = null;
170
+ var timer = null, lastWritten = "";
171
+ var $ = function (id) { return document.getElementById(id); };
172
+ var lsGet = function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } };
173
+ var lsSet = function (k, v) { try { localStorage.setItem(k, v); } catch (e) {} };
174
+
175
+ // ── Language: the recipe's own, the tester's browser, or their choice ───
176
+ function pickLanguage() {
177
+ var saved = lsGet(KEY + "-lang");
178
+ if (saved && ALL[saved]) return saved;
179
+ if (SPEC.language && ALL[SPEC.language]) return SPEC.language;
180
+ var tags = (navigator.languages || [navigator.language || ""]).map(String);
181
+ for (var i = 0; i < tags.length; i++) {
182
+ var tag = tags[i];
183
+ if (ALL[tag]) return tag;
184
+ var base = tag.split("-")[0];
185
+ for (var code in ALL) { if (code === base || code.split("-")[0] === base) return code; }
186
+ }
187
+ return ALL.en ? "en" : Object.keys(ALL)[0];
188
+ }
189
+
190
+ function fillLanguageChoice() {
191
+ var select = $("lang");
192
+ select.innerHTML = "";
193
+ Object.keys(ALL).forEach(function (code) {
194
+ var option = document.createElement("option");
195
+ option.value = code;
196
+ option.textContent = ALL[code].name;
197
+ option.selected = code === lang;
198
+ select.appendChild(option);
199
+ });
200
+ }
201
+
202
+ function applyLanguage(code) {
203
+ lang = ALL[code] ? code : "en";
204
+ T = ALL[lang];
205
+ lsSet(KEY + "-lang", lang);
206
+ document.documentElement.lang = lang;
207
+ document.documentElement.dir = T.dir || "ltr";
208
+ $("t-language").textContent = T.uiLanguage;
209
+ $("t-before").textContent = T.before;
210
+ $("t-tester").textContent = T.tester;
211
+ $("btn-start").textContent = T.start;
212
+ $("t-resume").textContent = T.resume;
213
+ $("t-finish").textContent = T.finish;
214
+ $("t-overall").textContent = T.overall;
215
+ $("btn-finish").textContent = run && run.finishedAt ? T.finished : T.finishRun;
216
+ $("btn-save").textContent = T.saveJson;
217
+ $("btn-copy").textContent = T.copyJson;
218
+ $("l-passed").textContent = T.seen;
219
+ $("l-failed").textContent = T.notSeen;
220
+ $("l-blocked").textContent = T.blocked;
221
+ $("l-todo").textContent = T.todo;
222
+ $("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
223
+ renderHead();
224
+ renderSteps();
225
+ if (run) reflect();
226
+ listResumable();
227
+ }
228
+
229
+ function renderHead() {
230
+ var head = $("head");
231
+ head.innerHTML = "";
232
+ var subtitle = document.createElement("span");
233
+ subtitle.className = "eyebrow";
234
+ subtitle.textContent = SPEC.subtitle || (SPEC.product + " · " + SPEC.environment.name + " · " + SPEC.versions.join(" · ") + (SPEC.date ? " · " + SPEC.date : ""));
235
+ head.appendChild(subtitle);
236
+ var title = document.createElement("h1");
237
+ title.textContent = SPEC.title;
238
+ head.appendChild(title);
239
+ var summary = document.createElement("p");
240
+ summary.textContent = STEPS.length + " " + T.stepsLabel + (SPEC.estimate ? " · " + SPEC.estimate : "") + " · " + SPEC.environment.name + (SPEC.environment.url ? " — " + SPEC.environment.url : "");
241
+ head.appendChild(summary);
242
+ (SPEC.intro || []).forEach(function (text) { var p = document.createElement("p"); p.innerHTML = text; head.appendChild(p); });
243
+ (SPEC.warnings || []).forEach(function (text) { var p = document.createElement("p"); p.className = "warn-box"; p.innerHTML = text; head.appendChild(p); });
244
+ (SPEC.notes || []).forEach(function (text) { var p = document.createElement("p"); p.className = "info-box"; p.innerHTML = text; head.appendChild(p); });
245
+ var closing = $("closing");
246
+ closing.innerHTML = "";
247
+ ((SPEC.closing && SPEC.closing.text) || []).forEach(function (text) { var p = document.createElement("p"); p.innerHTML = text; closing.appendChild(p); });
248
+ }
249
+
250
+ function newRun(tester) {
251
+ var steps = {};
252
+ STEPS.forEach(function (step) {
253
+ var expect = {};
254
+ step.expect.forEach(function (e) { expect[e.id] = { text: e.text, state: null, note: "" }; });
255
+ steps[step.id] = { title: step.title, state: null, note: "", expect: expect };
256
+ });
257
+ var now = new Date().toISOString();
258
+ return {
259
+ schema: RESULTS_SCHEMA, specId: SPEC.id, specSha256: SPEC_SHA, runId: null, tester: tester,
260
+ startedAt: now, updatedAt: now, finishedAt: null, steps: steps, overallNote: "",
261
+ summary: { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 },
262
+ };
263
+ }
264
+ function slug(s) { return String(s).normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 24) || "anonymous"; }
265
+ function newRunId(tester) {
266
+ var d = new Date(), p = function (n) { return String(n).padStart(2, "0"); };
267
+ return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + "-" + slug(tester) + "-" + Math.random().toString(36).slice(2, 6);
268
+ }
269
+ function stepState(step) {
270
+ var values = Object.keys(step.expect).map(function (k) { return step.expect[k].state; });
271
+ if (values.indexOf("failed") >= 0) return "failed";
272
+ if (values.indexOf("blocked") >= 0) return "blocked";
273
+ if (values.length && values.every(function (v) { return v === "skipped"; })) return "skipped";
274
+ if (values.length && values.every(function (v) { return v === "passed" || v === "skipped"; })) return "passed";
275
+ return null;
276
+ }
277
+ function recount() {
278
+ var s = { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 };
279
+ Object.keys(run.steps).forEach(function (id) {
280
+ var step = run.steps[id];
281
+ step.state = stepState(step);
282
+ Object.keys(step.expect).forEach(function (k) {
283
+ var v = step.expect[k].state;
284
+ if (v === null || v === undefined) s.unanswered++; else s[v]++;
285
+ });
286
+ });
287
+ run.summary = s;
288
+ }
289
+
290
+ // ── Steps ───────────────────────────────────────────────────────────
291
+ function renderSteps() {
292
+ var main = $("steps");
293
+ main.innerHTML = "";
294
+ STEPS.forEach(function (step, index) {
295
+ var article = document.createElement("article");
296
+ article.className = "step";
297
+ article.id = "step-" + step.id;
298
+ var head = '<div class="step-head"><span class="num">' + index + '</span><h2></h2>'
299
+ + (step.duration ? '<span class="duration"></span>' : "")
300
+ + '<span class="verdict" id="v-' + step.id + '"></span></div>';
301
+ var body = '<div class="step-body">';
302
+ if (step.warning) body += '<p class="warn-box">' + step.warning + "</p>";
303
+ else if (step.writes) body += '<p class="warn-box"><b>' + T.writesWarning + " " + SPEC.environment.name + ".</b> " + T.intended + "</p>";
304
+ if (step.optional) body += '<p class="info-box">' + T.optional + ' — <button class="btn" type="button" data-skip="' + step.id + '">' + T.skip + "</button></p>";
305
+ body += '<div class="section"><span class="eyebrow">' + T.where + "</span><ul>" + step.where.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ul></div>";
306
+ body += '<div class="section"><span class="eyebrow">' + T.doIt + "</span><ol>" + step.do.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ol></div>";
307
+ body += '<div class="section"><span class="eyebrow">' + T.expect + '</span><div class="expects">';
308
+ step.expect.forEach(function (e) {
309
+ var name = "e-" + step.id + "-" + e.id;
310
+ body += '<div class="expect" id="' + name + '" data-state=""><div class="text">' + e.text + "</div>"
311
+ + '<div class="choice" role="radiogroup">'
312
+ + '<label class="pill passed"><input type="radio" name="' + name + '" id="' + name + '-passed" value="passed"><span>' + T.seen + "</span></label>"
313
+ + '<label class="pill failed"><input type="radio" name="' + name + '" id="' + name + '-failed" value="failed"><span>' + T.notSeen + "</span></label>"
314
+ + '<label class="pill blocked"><input type="radio" name="' + name + '" id="' + name + '-blocked" value="blocked"><span>' + T.blocked + "</span></label>"
315
+ + "</div>"
316
+ + '<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>'
317
+ + "</div>";
318
+ });
319
+ body += "</div></div>";
320
+ body += '<div class="section note"><label for="note-' + step.id + '"><span>' + T.note + '</span><textarea id="note-' + step.id + '"></textarea></label></div></div>';
321
+ article.innerHTML = head + body;
322
+ article.querySelector("h2").textContent = step.title;
323
+ if (step.duration) article.querySelector(".duration").textContent = step.duration;
324
+ main.appendChild(article);
325
+ });
326
+ }
327
+
328
+ function reflect() {
329
+ recount();
330
+ STEPS.forEach(function (step) {
331
+ var observed = run.steps[step.id];
332
+ if (!observed) return;
333
+ var article = $("step-" + step.id);
334
+ article.dataset.state = observed.state || "";
335
+ var verdict = observed.state === "passed" ? T.seen : observed.state === "failed" ? T.notSeen : observed.state === "blocked" ? T.blocked : observed.state === "skipped" ? T.skip : T.todo;
336
+ $("v-" + step.id).textContent = verdict;
337
+ step.expect.forEach(function (e) {
338
+ var name = "e-" + step.id + "-" + e.id, value = observed.expect[e.id].state;
339
+ ["passed", "failed", "blocked"].forEach(function (state) { $(name + "-" + state).checked = value === state; });
340
+ $(name).dataset.state = value || "";
341
+ $("why-" + step.id + "-" + e.id).hidden = !(value === "failed" || value === "blocked");
342
+ var field = $("n-" + step.id + "-" + e.id);
343
+ if (document.activeElement !== field) field.value = observed.expect[e.id].note || "";
344
+ });
345
+ var note = $("note-" + step.id);
346
+ if (document.activeElement !== note) note.value = observed.note || "";
347
+ });
348
+ var s = run.summary;
349
+ var total = s.passed + s.failed + s.blocked + s.skipped + s.unanswered;
350
+ $("c-passed").textContent = s.passed;
351
+ $("c-failed").textContent = s.failed;
352
+ $("c-blocked").textContent = s.blocked;
353
+ $("c-todo").textContent = s.unanswered;
354
+ $("g-passed").style.width = (total ? (s.passed / total) * 100 : 0) + "%";
355
+ $("g-failed").style.width = (total ? (s.failed / total) * 100 : 0) + "%";
356
+ $("g-blocked").style.width = (total ? (s.blocked / total) * 100 : 0) + "%";
357
+ $("bar-tester").textContent = run.tester + (run.finishedAt ? " · " + T.finished : "");
358
+ var overall = $("overall");
359
+ if (document.activeElement !== overall) overall.value = run.overallNote || "";
360
+ $("json").value = JSON.stringify(run, null, 2);
361
+ $("btn-finish").disabled = Boolean(run.finishedAt);
362
+ $("btn-finish").textContent = run.finishedAt ? T.finished : T.finishRun;
363
+ }
364
+
365
+ // ── Persistence: artifact database, local server, or this browser ────
366
+ function persist(immediate) {
367
+ recount();
368
+ run.updatedAt = new Date().toISOString();
369
+ reflect();
370
+ lsSet(KEY + "-" + runId, JSON.stringify(run));
371
+ var body = JSON.stringify(run);
372
+ if (body === lastWritten) return;
373
+ var write = function () {
374
+ if (dbDoc) {
375
+ dbDoc.set(JSON.parse(body)).then(function () {
376
+ lastWritten = body;
377
+ $("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
378
+ $("save-state").className = "state saved";
379
+ }).catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
380
+ return;
381
+ }
382
+ if (serve) {
383
+ fetch(serve + "/results/" + runId, { method: "PUT", headers: { "content-type": "application/json" }, body: body })
384
+ .then(function (response) {
385
+ if (!response.ok) throw new Error(String(response.status));
386
+ lastWritten = body;
387
+ $("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
388
+ $("save-state").className = "state saved";
389
+ })
390
+ .catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
391
+ return;
392
+ }
393
+ $("save-state").textContent = T.savedLocal;
394
+ };
395
+ clearTimeout(timer);
396
+ if (immediate) write(); else timer = setTimeout(write, 500);
397
+ }
398
+
399
+ function open(id, doc) {
400
+ runId = id;
401
+ run = doc;
402
+ run.runId = id;
403
+ lsSet(KEY + "-last", id);
404
+ $("startbox").hidden = true;
405
+ $("steps").hidden = false;
406
+ $("end").hidden = false;
407
+ $("bar").hidden = false;
408
+ reflect();
409
+ if (db) {
410
+ dbDoc = db.doc("recettes/" + id);
411
+ if (unsub) unsub();
412
+ unsub = dbDoc.onSnapshot(function (snapshot) {
413
+ if (!snapshot.exists) return;
414
+ var data = snapshot.data();
415
+ if (!data || snapshot.metadata.hasPendingWrites) return;
416
+ var body = JSON.stringify(data);
417
+ if (body !== JSON.stringify(run) && data.updatedAt > run.updatedAt) { run = JSON.parse(body); lastWritten = body; reflect(); }
418
+ }, function () {});
419
+ }
420
+ $("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
421
+ }
422
+
423
+ function start() {
424
+ var tester = $("tester").value.trim();
425
+ if (!tester) { $("start-state").textContent = T.tester; $("tester").focus(); return; }
426
+ var id = newRunId(tester);
427
+ open(id, newRun(tester));
428
+ persist(true);
429
+ window.scrollTo({ top: 0 });
430
+ }
431
+
432
+ function listResumable() {
433
+ var box = $("resume-list");
434
+ box.innerHTML = "";
435
+ var seen = {};
436
+ var add = function (id, doc) {
437
+ if (seen[id] || !doc) return;
438
+ seen[id] = true;
439
+ var button = document.createElement("button");
440
+ button.className = "btn";
441
+ button.type = "button";
442
+ button.textContent = (doc.tester || "?") + " — " + String(doc.startedAt || "").slice(0, 10) + (doc.finishedAt ? " · " + T.finished : "");
443
+ button.addEventListener("click", function () { open(id, doc); });
444
+ box.appendChild(button);
445
+ };
446
+ var pending = db
447
+ ? db.collection("recettes").where("specId", "==", SPEC.id).limit(50).get()
448
+ .then(function (query) { query.docs.forEach(function (d) { add(d.id, d.data()); }); }).catch(function () {})
449
+ : Promise.resolve();
450
+ pending.then(function () {
451
+ var last = lsGet(KEY + "-last");
452
+ if (last) { var raw = lsGet(KEY + "-" + last); if (raw) { try { add(last, JSON.parse(raw)); } catch (e) {} } }
453
+ $("resume").hidden = box.children.length === 0;
454
+ });
455
+ }
456
+
457
+ document.addEventListener("change", function (event) {
458
+ var target = event.target;
459
+ if (target === $("lang")) { applyLanguage(target.value); return; }
460
+ if (!(target instanceof HTMLInputElement) || target.type !== "radio" || !run) return;
461
+ var m = /^e-(.+)-([a-z])$/.exec(target.name);
462
+ if (!m) return;
463
+ run.steps[m[1]].expect[m[2]].state = target.value;
464
+ persist();
465
+ });
466
+ document.addEventListener("input", function (event) {
467
+ var target = event.target;
468
+ if (!(target instanceof HTMLTextAreaElement) || !run) return;
469
+ if (target === $("overall")) { run.overallNote = target.value; persist(); return; }
470
+ var step = /^note-(.+)$/.exec(target.id);
471
+ if (step && run.steps[step[1]]) { run.steps[step[1]].note = target.value; persist(); return; }
472
+ var expect = /^n-(.+)-([a-z])$/.exec(target.id);
473
+ if (expect && run.steps[expect[1]]) { run.steps[expect[1]].expect[expect[2]].note = target.value; persist(); }
474
+ });
475
+ document.addEventListener("click", function (event) {
476
+ var id = event.target && event.target.getAttribute && event.target.getAttribute("data-skip");
477
+ if (!id || !run) return;
478
+ Object.keys(run.steps[id].expect).forEach(function (k) { run.steps[id].expect[k].state = "skipped"; });
479
+ persist();
480
+ });
481
+
482
+ $("btn-start").addEventListener("click", start);
483
+ $("tester").addEventListener("keydown", function (event) { if (event.key === "Enter") start(); });
484
+ $("btn-finish").addEventListener("click", function () {
485
+ if (run.summary.unanswered > 0 && !window.confirm(T.confirmUnanswered.replace("{n}", run.summary.unanswered))) return;
486
+ run.finishedAt = new Date().toISOString();
487
+ persist(true);
488
+ $("end-state").textContent = T.finished;
489
+ });
490
+ $("btn-copy").addEventListener("click", function () {
491
+ var text = JSON.stringify(run, null, 2);
492
+ var fallback = function () { $("json").parentElement.open = true; $("json").focus(); $("json").select(); };
493
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { $("end-state").textContent = T.copied; }, fallback);
494
+ else fallback();
495
+ });
496
+ $("btn-save").addEventListener("click", function () {
497
+ var name = runId + ".json";
498
+ var text = JSON.stringify(run, null, 2);
499
+ if (downloads) {
500
+ downloads.save({ filename: name, data: text }).then(function () { $("end-state").textContent = name; }).catch(function () {});
501
+ return;
502
+ }
503
+ try {
504
+ var url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
505
+ var link = document.createElement("a");
506
+ link.href = url;
507
+ link.download = name;
508
+ document.body.appendChild(link);
509
+ link.click();
510
+ link.remove();
511
+ setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
512
+ $("end-state").textContent = name;
513
+ } catch (e) { $("end-state").textContent = T.copyJson; }
514
+ });
515
+
516
+ // ── Start: the page is complete without any capability ──────────────
517
+ lang = pickLanguage();
518
+ fillLanguageChoice();
519
+ applyLanguage(lang);
520
+ var resumeLast = function () {
521
+ var last = lsGet(KEY + "-last");
522
+ if (!last || run) return;
523
+ var raw = lsGet(KEY + "-" + last);
524
+ if (!raw) return;
525
+ try { open(last, JSON.parse(raw)); } catch (e) {}
526
+ };
527
+ fetch("__uat/ping", { method: "GET" })
528
+ .then(function (response) { return response.ok ? response.json() : null; })
529
+ .then(function (info) { if (info && info.uat) { serve = "__uat"; $("mode-note").textContent = T.serveMode; listResumable(); } })
530
+ .catch(function () {})
531
+ .then(function () {
532
+ var use = window.claude && typeof window.claude.use === "function" ? window.claude.use.bind(window.claude) : function () { return Promise.resolve(null); };
533
+ return use("db").then(function (namespace) {
534
+ db = namespace;
535
+ listResumable();
536
+ if (run && db && !dbDoc) { open(runId, run); persist(true); return; }
537
+ var last = lsGet(KEY + "-last");
538
+ if (last && !run && db) {
539
+ db.doc("recettes/" + last).get()
540
+ .then(function (snapshot) { if (snapshot.exists) open(last, snapshot.data()); else resumeLast(); })
541
+ .catch(resumeLast);
542
+ } else resumeLast();
543
+ });
544
+ })
545
+ .catch(resumeLast);
546
+ if (window.claude && typeof window.claude.use === "function") {
547
+ window.claude.use("downloads").then(function (namespace) { downloads = namespace; });
548
+ }
549
+ })();
550
+ </script>
551
+ </body>
552
+ </html>