ma-agents 3.13.2 → 3.14.1

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 (107) hide show
  1. package/README.md +3 -3
  2. package/lib/agents.js +9 -2
  3. package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
  4. package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
  5. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
  6. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
  7. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
  8. package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
  9. package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
  10. package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
  11. package/lib/bmad-cache/bmb/package-lock.json +2 -2
  12. package/lib/bmad-cache/bmb/package.json +1 -1
  13. package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
  14. package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
  15. package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
  16. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
  17. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
  18. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
  19. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
  20. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
  21. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
  22. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
  23. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
  24. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
  25. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
  26. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
  27. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
  28. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
  29. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
  30. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
  31. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
  32. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
  33. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
  34. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
  35. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
  36. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
  37. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
  38. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
  39. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
  40. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
  41. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
  42. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
  43. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
  44. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
  45. package/lib/bmad-cache/cache-manifest.json +4 -4
  46. package/lib/bmad-customize/bmm-qa.customize.yaml +3 -3
  47. package/lib/bmad-extension/.claude-plugin/marketplace.json.template +3 -2
  48. package/lib/bmad-extension/skills/bmad-dev-epic/SKILL.md +101 -0
  49. package/lib/bmad-extension/skills/bmad-dev-epic/bmad-skill-manifest.yaml +3 -0
  50. package/lib/bmad-extension/skills/bmad-dev-epic/checklist.md +44 -0
  51. package/lib/bmad-extension/skills/bmad-dev-epic/customize.toml +54 -0
  52. package/lib/bmad-extension/skills/bmad-dev-epic/references/story-pipeline.md +112 -0
  53. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-01-intake.md +59 -0
  54. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-02-plan-waves.md +56 -0
  55. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-03-epic-branch.md +35 -0
  56. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-04-run-waves.md +49 -0
  57. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-05-epic-adversarial.md +31 -0
  58. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-06-release.md +57 -0
  59. package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-07-report.md +40 -0
  60. package/lib/bmad-extension/skills/ma-agent-sqa/SKILL.md +3 -3
  61. package/lib/bmad-extension/skills/ma-agent-sqa/bmad-skill-manifest.yaml +1 -1
  62. package/lib/bmad-extension/skills/module-help.csv +2 -1
  63. package/lib/bmad-extension/skills/module.yaml +1 -1
  64. package/lib/bmad-extension/skills/sqa-audit/SKILL.md +2 -2
  65. package/lib/bmad-extension/skills/sqa-ieee12207/SKILL.md +3 -3
  66. package/lib/bmad-extension/skills/sqa-requirements-quality/SKILL.md +2 -2
  67. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +4 -3
  68. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/SKILL.md +101 -0
  69. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/bmad-skill-manifest.yaml +3 -0
  70. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/checklist.md +44 -0
  71. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/customize.toml +54 -0
  72. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/references/story-pipeline.md +112 -0
  73. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-01-intake.md +59 -0
  74. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-02-plan-waves.md +56 -0
  75. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-03-epic-branch.md +35 -0
  76. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-04-run-waves.md +49 -0
  77. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-05-epic-adversarial.md +31 -0
  78. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-06-release.md +57 -0
  79. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-07-report.md +40 -0
  80. package/lib/bmad-extension-plugin/skills/ma-agent-sqa/SKILL.md +3 -3
  81. package/lib/bmad-extension-plugin/skills/ma-agent-sqa/bmad-skill-manifest.yaml +1 -1
  82. package/lib/bmad-extension-plugin/skills/module-help.csv +2 -1
  83. package/lib/bmad-extension-plugin/skills/module.yaml +1 -1
  84. package/lib/bmad-extension-plugin/skills/sqa-audit/SKILL.md +2 -2
  85. package/lib/bmad-extension-plugin/skills/sqa-ieee12207/SKILL.md +3 -3
  86. package/lib/bmad-extension-plugin/skills/sqa-requirements-quality/SKILL.md +2 -2
  87. package/lib/bmad.js +6 -1
  88. package/package.json +3 -3
  89. package/lib/.bmad-extension-plugin.build-22612-1782454014750/.claude-plugin/marketplace.json +0 -109
  90. package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module-help.csv +0 -62
  91. package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module.yaml +0 -20
  92. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
  93. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
  94. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
  95. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
  96. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
  97. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
  98. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
  99. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
  100. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
  101. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
  102. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
  103. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
  104. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
  105. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
  106. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
  107. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py +0 -288
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # ///
5
+ """Render the analysis report deterministically from findings JSON.
6
+
7
+ Injects a validated findings JSON object into the report shell's
8
+ report-data island and writes the self-contained HTML atomically.
9
+ With --md, also writes a markdown rendering of the same data as the
10
+ archival artifact.
11
+
12
+ Refuses (non-zero exit, message on stderr) when the JSON does not
13
+ parse, fails shape validation, or still carries the shell's
14
+ placeholder subject — a refused render means fix the findings file
15
+ and re-run, never hand-edit the HTML.
16
+
17
+ Usage:
18
+ uv run render_report.py <findings.json> --shell <report-shell.html> \
19
+ -o <out.html> [--md <out.md>]
20
+
21
+ On success prints one JSON line: output paths, grade, and severity
22
+ counts derived from the findings array.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import tempfile
32
+ from pathlib import Path
33
+
34
+ SEVERITIES = ("critical", "high", "medium", "low")
35
+ GRADES = ("excellent", "good", "fair", "poor")
36
+ PLACEHOLDER_SUBJECT = "__PLACEHOLDER__"
37
+ ISLAND_RE = re.compile(
38
+ r'(<script[^>]*\bid="report-data"[^>]*>)(.*?)(</script>)', re.DOTALL
39
+ )
40
+
41
+
42
+ def fail(message: str) -> None:
43
+ print(f"render_report: {message}", file=sys.stderr)
44
+ sys.exit(1)
45
+
46
+
47
+ def validate(data: object) -> list[str]:
48
+ """Return a list of shape errors; empty list means valid."""
49
+ if not isinstance(data, dict):
50
+ return ["top level must be a JSON object"]
51
+ errors: list[str] = []
52
+
53
+ subject = data.get("subject")
54
+ if not isinstance(subject, str) or not subject.strip():
55
+ errors.append('"subject" must be a non-empty string')
56
+ elif PLACEHOLDER_SUBJECT in subject:
57
+ errors.append(
58
+ f'"subject" still carries the placeholder {PLACEHOLDER_SUBJECT}; '
59
+ "this is the unfilled shell sample, not real findings"
60
+ )
61
+
62
+ findings = data.get("findings")
63
+ if not isinstance(findings, list):
64
+ errors.append('"findings" must be an array (use [] for a clean pass)')
65
+ else:
66
+ for i, finding in enumerate(findings):
67
+ if not isinstance(finding, dict):
68
+ errors.append(f"findings[{i}] must be an object")
69
+
70
+ grade = data.get("grade")
71
+ if grade is not None and grade not in GRADES:
72
+ errors.append(f'"grade" must be one of: {", ".join(GRADES)}')
73
+
74
+ for key in ("themes", "recommendations"):
75
+ value = data.get(key)
76
+ if value is not None and (
77
+ not isinstance(value, list)
78
+ or any(not isinstance(item, dict) for item in value)
79
+ ):
80
+ errors.append(f'"{key}" must be an array of objects')
81
+
82
+ strengths = data.get("strengths")
83
+ if strengths is not None and (
84
+ not isinstance(strengths, list)
85
+ or any(not isinstance(item, str) for item in strengths)
86
+ ):
87
+ errors.append('"strengths" must be an array of strings')
88
+
89
+ return errors
90
+
91
+
92
+ def severity_counts(findings: list[dict]) -> dict[str, int]:
93
+ counts = {sev: 0 for sev in SEVERITIES}
94
+ for finding in findings:
95
+ sev = finding.get("severity")
96
+ counts[sev if sev in counts else "low"] += 1
97
+ return counts
98
+
99
+
100
+ def inject(shell_html: str, data: dict) -> str:
101
+ payload = json.dumps(data, ensure_ascii=False, indent=2)
102
+ # A "</" sequence inside a JSON string would close the script tag
103
+ # early in the browser; "<\/" is the same string to JSON.parse.
104
+ payload = payload.replace("</", "<\\/")
105
+
106
+ def replace(match: re.Match) -> str:
107
+ return match.group(1) + "\n" + payload + "\n" + match.group(3)
108
+
109
+ new_html, count = ISLAND_RE.subn(replace, shell_html, count=1)
110
+ if count != 1:
111
+ fail('shell has no <script id="report-data"> island to fill')
112
+ return new_html
113
+
114
+
115
+ def atomic_write(path: Path, text: str) -> None:
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ fd, tmp = tempfile.mkstemp(
118
+ dir=path.parent, prefix=path.name + ".", suffix=".tmp"
119
+ )
120
+ try:
121
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
122
+ handle.write(text)
123
+ handle.flush()
124
+ os.fsync(handle.fileno())
125
+ os.replace(tmp, path)
126
+ except BaseException:
127
+ try:
128
+ os.unlink(tmp)
129
+ except OSError:
130
+ pass
131
+ raise
132
+
133
+
134
+ def _finding_lines(finding: dict, heading_level: str) -> list[str]:
135
+ fid = str(finding.get("id", ""))
136
+ title = str(finding.get("title", "(untitled finding)"))
137
+ lines = [f"{heading_level} {fid} — {title}" if fid else f"{heading_level} {title}", ""]
138
+ for key, label in (
139
+ ("lens", "Lens"),
140
+ ("location", "Location"),
141
+ ("evidence", "Evidence"),
142
+ ("recommendation", "Recommendation"),
143
+ ("proposed_smallest", "Proposed smallest"),
144
+ ("predicted_delta", "Predicted delta"),
145
+ ):
146
+ value = finding.get(key)
147
+ if value:
148
+ value = f"`{value}`" if key == "location" else str(value)
149
+ lines.append(f"- {label}: {value}")
150
+ lines.append("")
151
+ return lines
152
+
153
+
154
+ def render_md(data: dict) -> str:
155
+ findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
156
+ by_id = {str(f.get("id")): f for f in findings if f.get("id") is not None}
157
+ counts = severity_counts(findings)
158
+ lines: list[str] = []
159
+
160
+ lines.append(f"# Analysis Report: {data.get('subject', '')}")
161
+ lines.append("")
162
+ meta = []
163
+ if data.get("generated"):
164
+ meta.append(f"Generated: {data['generated']}")
165
+ if data.get("schema_version") is not None:
166
+ meta.append(f"Schema: {data['schema_version']}")
167
+ if meta:
168
+ lines.append(" · ".join(meta))
169
+ lines.append("")
170
+
171
+ if data.get("grade"):
172
+ lines.append(f"**Grade: {str(data['grade']).capitalize()}**")
173
+ lines.append("")
174
+ if data.get("verdict"):
175
+ lines.append(f"> {data['verdict']}")
176
+ lines.append("")
177
+ summary = data.get("summary")
178
+ if isinstance(summary, str) and summary:
179
+ lines.append(summary)
180
+ lines.append("")
181
+
182
+ lines.append("| Severity | Count |")
183
+ lines.append("| --- | --- |")
184
+ for sev in SEVERITIES:
185
+ lines.append(f"| {sev.capitalize()} | {counts[sev]} |")
186
+ lines.append("")
187
+
188
+ themes = data.get("themes") or []
189
+ if themes:
190
+ lines.append("## Themes")
191
+ lines.append("")
192
+ for i, theme in enumerate(themes, 1):
193
+ lines.append(f"### {i}. {theme.get('title', '(untitled theme)')}")
194
+ lines.append("")
195
+ if theme.get("root_cause"):
196
+ lines.append(f"- Root cause: {theme['root_cause']}")
197
+ if theme.get("action"):
198
+ lines.append(f"- Fix: {theme['action']}")
199
+ ids = theme.get("finding_ids") or []
200
+ if ids:
201
+ lines.append("- Findings:")
202
+ for fid in ids:
203
+ finding = by_id.get(str(fid))
204
+ if finding:
205
+ loc = finding.get("location")
206
+ suffix = f" — `{loc}`" if loc else ""
207
+ lines.append(
208
+ f" - `{fid}` {finding.get('title', '')}{suffix}"
209
+ )
210
+ else:
211
+ lines.append(f" - `{fid}`")
212
+ lines.append("")
213
+
214
+ strengths = data.get("strengths") or []
215
+ if strengths:
216
+ lines.append("## Strengths")
217
+ lines.append("")
218
+ for strength in strengths:
219
+ lines.append(f"- {strength}")
220
+ lines.append("")
221
+
222
+ recommendations = data.get("recommendations") or []
223
+ if recommendations:
224
+ lines.append("## Recommendations")
225
+ lines.append("")
226
+ for i, rec in enumerate(recommendations, 1):
227
+ rank = rec.get("rank", i)
228
+ resolves = rec.get("resolves")
229
+ if isinstance(resolves, list) and resolves:
230
+ suffix = " (resolves: " + ", ".join(map(str, resolves)) + ")"
231
+ elif isinstance(resolves, (int, float)):
232
+ suffix = f" (resolves {int(resolves)} findings)"
233
+ else:
234
+ suffix = ""
235
+ lines.append(f"{rank}. {rec.get('action', '')}{suffix}")
236
+ lines.append("")
237
+
238
+ # Optional agent blocks: rendered only when present so the same
239
+ # renderer serves both the workflow and agent schemas.
240
+ profile = data.get("agent_profile")
241
+ if isinstance(profile, dict) and any(profile.values()):
242
+ lines.append("## Agent Profile")
243
+ lines.append("")
244
+ for key, label in (
245
+ ("name", "Name"),
246
+ ("title", "Title"),
247
+ ("agent_type", "Type"),
248
+ ("mission", "Mission"),
249
+ ):
250
+ if profile.get(key):
251
+ lines.append(f"- {label}: {profile[key]}")
252
+ lines.append("")
253
+
254
+ capabilities = data.get("capabilities")
255
+ if isinstance(capabilities, list) and capabilities:
256
+ lines.append("## Capabilities")
257
+ lines.append("")
258
+ for cap in capabilities:
259
+ if not isinstance(cap, dict) or not cap.get("name"):
260
+ continue
261
+ kind = f" ({cap['kind']})" if cap.get("kind") else ""
262
+ note = f" — {cap['note']}" if cap.get("note") else ""
263
+ lines.append(f"- **{cap['name']}**{kind}{note}")
264
+ lines.append("")
265
+
266
+ detailed = data.get("detailed_analysis")
267
+ if isinstance(detailed, dict) and detailed:
268
+ lines.append("## Per-Lens Verdicts")
269
+ lines.append("")
270
+ for lens, verdict in detailed.items():
271
+ if verdict:
272
+ lines.append(f"- **{lens}**: {verdict}")
273
+ lines.append("")
274
+
275
+ sanctum = data.get("sanctum")
276
+ if isinstance(sanctum, dict) and sanctum.get("present") is not False:
277
+ rows = []
278
+ if sanctum.get("location"):
279
+ rows.append(f"- Location: `{sanctum['location']}`")
280
+ files = sanctum.get("files") or []
281
+ if files:
282
+ rows.append("- Files: " + ", ".join(f"`{f}`" for f in files))
283
+ if sanctum.get("note"):
284
+ rows.append(f"- Note: {sanctum['note']}")
285
+ if rows:
286
+ lines.append("## Sanctum (runtime memory)")
287
+ lines.append("")
288
+ lines.extend(rows)
289
+ lines.append("")
290
+
291
+ experience = data.get("experience")
292
+ if isinstance(experience, dict):
293
+ journeys = [
294
+ j for j in experience.get("journeys") or [] if isinstance(j, dict)
295
+ ]
296
+ headless = experience.get("headless")
297
+ if journeys or headless:
298
+ lines.append("## Experience")
299
+ lines.append("")
300
+ for journey in journeys:
301
+ steps = f" — {journey['steps']}" if journey.get("steps") else ""
302
+ lines.append(f"- **{journey.get('name', '(unnamed journey)')}**{steps}")
303
+ if headless:
304
+ lines.append(f"- Headless: {headless}")
305
+ lines.append("")
306
+
307
+ lines.append("## Findings")
308
+ lines.append("")
309
+ if not findings:
310
+ lines.append("No findings: the scanners returned a clean pass.")
311
+ lines.append("")
312
+ else:
313
+ for sev in SEVERITIES:
314
+ group = [
315
+ f
316
+ for f in findings
317
+ if (f.get("severity") if f.get("severity") in SEVERITIES else "low")
318
+ == sev
319
+ ]
320
+ if not group:
321
+ continue
322
+ lines.append(f"### {sev.capitalize()} ({len(group)})")
323
+ lines.append("")
324
+ for finding in group:
325
+ lines.extend(_finding_lines(finding, "####"))
326
+
327
+ return "\n".join(lines).rstrip() + "\n"
328
+
329
+
330
+ def main() -> int:
331
+ parser = argparse.ArgumentParser(
332
+ description="Inject findings JSON into the report shell and render HTML (+ optional markdown)."
333
+ )
334
+ parser.add_argument("findings", type=Path, help="path to findings.json")
335
+ parser.add_argument(
336
+ "--shell", type=Path, required=True, help="path to report-shell.html"
337
+ )
338
+ parser.add_argument(
339
+ "-o", "--output", type=Path, required=True, help="output HTML path"
340
+ )
341
+ parser.add_argument(
342
+ "--md", type=Path, help="also write a markdown rendering to this path"
343
+ )
344
+ args = parser.parse_args()
345
+
346
+ try:
347
+ raw = args.findings.read_text(encoding="utf-8")
348
+ except OSError as err:
349
+ fail(f"cannot read {args.findings}: {err}")
350
+ try:
351
+ data = json.loads(raw)
352
+ except json.JSONDecodeError as err:
353
+ fail(f"{args.findings} is not valid JSON: {err}")
354
+
355
+ errors = validate(data)
356
+ if errors:
357
+ fail(
358
+ f"{args.findings} failed shape validation:\n - "
359
+ + "\n - ".join(errors)
360
+ )
361
+
362
+ try:
363
+ shell_html = args.shell.read_text(encoding="utf-8")
364
+ except OSError as err:
365
+ fail(f"cannot read shell {args.shell}: {err}")
366
+
367
+ atomic_write(args.output, inject(shell_html, data))
368
+ if args.md:
369
+ atomic_write(args.md, render_md(data))
370
+
371
+ findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
372
+ print(
373
+ json.dumps(
374
+ {
375
+ "html_report": str(args.output),
376
+ "md_report": str(args.md) if args.md else None,
377
+ "grade": data.get("grade"),
378
+ "counts": severity_counts(findings),
379
+ "findings": len(findings),
380
+ }
381
+ )
382
+ )
383
+ return 0
384
+
385
+
386
+ if __name__ == "__main__":
387
+ sys.exit(main())
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """Guard against drift between the embedded prompt-quality-canon copies.
3
+
4
+ The canon is embedded in three places — workflow-builder references,
5
+ agent-builder references, and the agent-builder asset emitted into built
6
+ agents — with no in-file sync note, because the loaded files are LLM-facing
7
+ and a maintenance comment there is paid on every load. This test is the
8
+ sync mechanism instead: all three copies must be byte-identical.
9
+ Run with: python3 -m pytest test_canon_sync.py
10
+ (or plain `python3 test_canon_sync.py` for a lightweight self-check).
11
+ """
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ SKILLS_DIR = Path(__file__).resolve().parents[3]
16
+
17
+ CANON_COPIES = [
18
+ SKILLS_DIR / "bmad-workflow-builder" / "references" / "prompt-quality-canon.md",
19
+ SKILLS_DIR / "bmad-agent-builder" / "references" / "prompt-quality-canon.md",
20
+ SKILLS_DIR / "bmad-agent-builder" / "assets" / "prompt-quality-canon.md",
21
+ ]
22
+
23
+
24
+ def test_all_copies_exist():
25
+ missing = [str(p) for p in CANON_COPIES if not p.is_file()]
26
+ assert not missing, f"canon copy missing: {missing}"
27
+
28
+
29
+ def test_all_copies_identical():
30
+ contents = {p: p.read_bytes() for p in CANON_COPIES if p.is_file()}
31
+ reference = CANON_COPIES[0]
32
+ diverged = [
33
+ str(p)
34
+ for p, body in contents.items()
35
+ if body != contents.get(reference)
36
+ ]
37
+ assert not diverged, (
38
+ "canon copies have drifted from "
39
+ f"{reference}: {diverged} — sync all copies together"
40
+ )
41
+
42
+
43
+ if __name__ == "__main__":
44
+ test_all_copies_exist()
45
+ test_all_copies_identical()
46
+ print(f"ok: {len(CANON_COPIES)} canon copies present and identical")
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for count_tokens.py.
3
+
4
+ Covers the output schema, the tiktoken path and the forced-fallback path
5
+ agreeing within tolerance, the CLI over a file and over stdin, and argument
6
+ guards. Run with: python3 -m pytest test_count_tokens.py
7
+ (or plain `python3 test_count_tokens.py` to run a lightweight self-check).
8
+ """
9
+ import builtins
10
+ import importlib.util
11
+ import json
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ SCRIPT = Path(__file__).resolve().parent.parent / "count_tokens.py"
17
+
18
+ SAMPLE = (
19
+ "The builder is platform-agnostic. Nothing assumes a single runtime, and no "
20
+ "model list is ever hardcoded. Token counts replace line counts as the one "
21
+ "length metric, with a chars-over-four fallback when tiktoken is absent.\n"
22
+ ) * 8
23
+
24
+
25
+ def _load_module():
26
+ spec = importlib.util.spec_from_file_location("count_tokens", SCRIPT)
27
+ mod = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(mod)
29
+ return mod
30
+
31
+
32
+ def test_tiktoken_path():
33
+ mod = _load_module()
34
+ try:
35
+ import tiktoken # noqa: F401
36
+ except Exception:
37
+ # No tiktoken in this interpreter; the real path can't be exercised here.
38
+ tokens, method = mod.count_tokens(SAMPLE)
39
+ assert method == "fallback"
40
+ assert tokens == len(SAMPLE) // 4
41
+ return
42
+ tokens, method = mod.count_tokens(SAMPLE)
43
+ assert method == "tiktoken"
44
+ assert isinstance(tokens, int)
45
+ assert tokens > 0
46
+
47
+
48
+ def test_fallback_path_when_import_blocked():
49
+ """Force the import of tiktoken to fail and confirm the fallback fires."""
50
+ mod = _load_module()
51
+ real_import = builtins.__import__
52
+
53
+ def blocked_import(name, *args, **kwargs):
54
+ if name == "tiktoken" or name.startswith("tiktoken."):
55
+ raise ImportError("blocked for test")
56
+ return real_import(name, *args, **kwargs)
57
+
58
+ builtins.__import__ = blocked_import
59
+ try:
60
+ tokens, method = mod.count_tokens(SAMPLE)
61
+ finally:
62
+ builtins.__import__ = real_import
63
+
64
+ assert method == "fallback"
65
+ assert tokens == len(SAMPLE) // 4
66
+
67
+
68
+ def test_paths_agree_within_tolerance():
69
+ """tiktoken and chars//4 should be in the same order of magnitude.
70
+
71
+ Skipped when tiktoken is not installed (nothing to compare against).
72
+ """
73
+ mod = _load_module()
74
+ try:
75
+ import tiktoken # noqa: F401
76
+ except Exception:
77
+ return
78
+
79
+ real_tokens, real_method = mod.count_tokens(SAMPLE)
80
+ assert real_method == "tiktoken"
81
+
82
+ fallback_tokens = len(SAMPLE) // 4
83
+
84
+ # The chars//4 heuristic is a rough proxy; require it within +/-50% of the
85
+ # real count so the fallback stays a usable budget gate, not a wild guess.
86
+ lower = real_tokens * 0.5
87
+ upper = real_tokens * 1.5
88
+ assert lower <= fallback_tokens <= upper, (
89
+ f"fallback {fallback_tokens} not within 50% of tiktoken {real_tokens}"
90
+ )
91
+
92
+
93
+ def test_cli_file_output_schema(tmp_path):
94
+ f = tmp_path / "sample.md"
95
+ f.write_text(SAMPLE, encoding="utf-8")
96
+ out = subprocess.run(
97
+ [sys.executable, str(SCRIPT), str(f)],
98
+ capture_output=True, text=True, check=True,
99
+ ).stdout
100
+ data = json.loads(out)
101
+ assert set(data.keys()) == {"tokens", "method"}
102
+ assert isinstance(data["tokens"], int)
103
+ assert data["method"] in ("tiktoken", "fallback")
104
+ assert data["tokens"] > 0
105
+
106
+
107
+ def test_cli_stdin_output_schema():
108
+ out = subprocess.run(
109
+ [sys.executable, str(SCRIPT), "--stdin"],
110
+ input=SAMPLE, capture_output=True, text=True, check=True,
111
+ ).stdout
112
+ data = json.loads(out)
113
+ assert set(data.keys()) == {"tokens", "method"}
114
+ assert isinstance(data["tokens"], int)
115
+ assert data["method"] in ("tiktoken", "fallback")
116
+
117
+
118
+ def test_cli_file_and_stdin_agree():
119
+ """The CLI over a file and over stdin produce the same count for same text."""
120
+ import tempfile, os
121
+ fd, name = tempfile.mkstemp(suffix=".md")
122
+ try:
123
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
124
+ fh.write(SAMPLE)
125
+ file_out = json.loads(subprocess.run(
126
+ [sys.executable, str(SCRIPT), name],
127
+ capture_output=True, text=True, check=True,
128
+ ).stdout)
129
+ finally:
130
+ os.unlink(name)
131
+ stdin_out = json.loads(subprocess.run(
132
+ [sys.executable, str(SCRIPT), "--stdin"],
133
+ input=SAMPLE, capture_output=True, text=True, check=True,
134
+ ).stdout)
135
+ assert file_out == stdin_out
136
+
137
+
138
+ def test_cli_requires_an_input():
139
+ """No file and no --stdin is a usage error (exit 2 from argparse)."""
140
+ res = subprocess.run(
141
+ [sys.executable, str(SCRIPT)],
142
+ capture_output=True, text=True,
143
+ )
144
+ assert res.returncode != 0
145
+
146
+
147
+ def _run_all():
148
+ import tempfile
149
+ failures = 0
150
+ tests = [
151
+ test_tiktoken_path,
152
+ test_fallback_path_when_import_blocked,
153
+ test_paths_agree_within_tolerance,
154
+ test_cli_stdin_output_schema,
155
+ test_cli_file_and_stdin_agree,
156
+ test_cli_requires_an_input,
157
+ ]
158
+ for t in tests:
159
+ try:
160
+ t()
161
+ print(f"PASS {t.__name__}")
162
+ except AssertionError as e:
163
+ failures += 1
164
+ print(f"FAIL {t.__name__}: {e}")
165
+ except Exception as e:
166
+ failures += 1
167
+ print(f"ERROR {t.__name__}: {e}")
168
+ # tmp_path-based test handled separately
169
+ with tempfile.TemporaryDirectory() as d:
170
+ try:
171
+ test_cli_file_output_schema(Path(d))
172
+ print("PASS test_cli_file_output_schema")
173
+ except Exception as e:
174
+ failures += 1
175
+ print(f"FAIL test_cli_file_output_schema: {e}")
176
+ return failures
177
+
178
+
179
+ if __name__ == "__main__":
180
+ sys.exit(1 if _run_all() else 0)