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.
- package/README.md +3 -3
- package/lib/agents.js +9 -2
- package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
- package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
- package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
- package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
- package/lib/bmad-cache/bmb/package-lock.json +2 -2
- package/lib/bmad-cache/bmb/package.json +1 -1
- package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
- package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
- package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
- package/lib/bmad-cache/cache-manifest.json +4 -4
- package/lib/bmad-customize/bmm-qa.customize.yaml +3 -3
- package/lib/bmad-extension/.claude-plugin/marketplace.json.template +3 -2
- package/lib/bmad-extension/skills/bmad-dev-epic/SKILL.md +101 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/checklist.md +44 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/customize.toml +54 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/references/story-pipeline.md +112 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-01-intake.md +59 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-02-plan-waves.md +56 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-03-epic-branch.md +35 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-04-run-waves.md +49 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-05-epic-adversarial.md +31 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-06-release.md +57 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/steps/step-07-report.md +40 -0
- package/lib/bmad-extension/skills/ma-agent-sqa/SKILL.md +3 -3
- package/lib/bmad-extension/skills/ma-agent-sqa/bmad-skill-manifest.yaml +1 -1
- package/lib/bmad-extension/skills/module-help.csv +2 -1
- package/lib/bmad-extension/skills/module.yaml +1 -1
- package/lib/bmad-extension/skills/sqa-audit/SKILL.md +2 -2
- package/lib/bmad-extension/skills/sqa-ieee12207/SKILL.md +3 -3
- package/lib/bmad-extension/skills/sqa-requirements-quality/SKILL.md +2 -2
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +4 -3
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/SKILL.md +101 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/checklist.md +44 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/customize.toml +54 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/references/story-pipeline.md +112 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-01-intake.md +59 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-02-plan-waves.md +56 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-03-epic-branch.md +35 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-04-run-waves.md +49 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-05-epic-adversarial.md +31 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-06-release.md +57 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/steps/step-07-report.md +40 -0
- package/lib/bmad-extension-plugin/skills/ma-agent-sqa/SKILL.md +3 -3
- package/lib/bmad-extension-plugin/skills/ma-agent-sqa/bmad-skill-manifest.yaml +1 -1
- package/lib/bmad-extension-plugin/skills/module-help.csv +2 -1
- package/lib/bmad-extension-plugin/skills/module.yaml +1 -1
- package/lib/bmad-extension-plugin/skills/sqa-audit/SKILL.md +2 -2
- package/lib/bmad-extension-plugin/skills/sqa-ieee12207/SKILL.md +3 -3
- package/lib/bmad-extension-plugin/skills/sqa-requirements-quality/SKILL.md +2 -2
- package/lib/bmad.js +6 -1
- package/package.json +3 -3
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/.claude-plugin/marketplace.json +0 -109
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module-help.csv +0 -62
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module.yaml +0 -20
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
- 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,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-code",
|
|
3
|
+
"invocation": ["claude", "-p", "{prompt}", "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"],
|
|
4
|
+
"auth_env": "ANTHROPIC_API_KEY",
|
|
5
|
+
"transcript": { "format": "stdout-jsonl" },
|
|
6
|
+
"skill_dir": ".claude/skills",
|
|
7
|
+
"load_signal": { "skill_tool": "Skill", "read_tool": "Read" },
|
|
8
|
+
"env_passthrough": []
|
|
9
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.9"
|
|
4
|
+
# ///
|
|
5
|
+
"""Variance benchmark: summarize a metric across N runs, and compare two configs.
|
|
6
|
+
|
|
7
|
+
A single skill run is noisy. Running the same case N times and summarizing the
|
|
8
|
+
spread tells you whether a difference between two versions is real or just noise.
|
|
9
|
+
This script computes, per numeric metric, the mean, the sample standard deviation
|
|
10
|
+
(n-1, the unbiased estimator for a sample), the min, and the max across N runs.
|
|
11
|
+
Given two such config summaries it reports the delta on each shared metric so a
|
|
12
|
+
"did the change help" question gets a number instead of a guess.
|
|
13
|
+
|
|
14
|
+
Input shapes accepted for a single config:
|
|
15
|
+
- a list of run records, each a flat dict of metric -> number
|
|
16
|
+
[{"elapsed_s": 12.1, "total_tokens": 800}, {"elapsed_s": 11.4, ...}]
|
|
17
|
+
- {"runs": [ ...records... ]}
|
|
18
|
+
- a directory of run folders, each holding timing.json files written by
|
|
19
|
+
run_evals.py (the script reads every timing.json under the directory and
|
|
20
|
+
treats each as one run record)
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
Summarize one config across its runs:
|
|
24
|
+
python3 aggregate_benchmark.py --runs CONFIG_A.json
|
|
25
|
+
python3 aggregate_benchmark.py --runs RUN_DIR/ (reads timing.json files)
|
|
26
|
+
|
|
27
|
+
Compare two configs (each summarized, then delta = B - A):
|
|
28
|
+
python3 aggregate_benchmark.py --baseline A.json --variant B.json
|
|
29
|
+
|
|
30
|
+
Self-test on a known fixture (no external input needed):
|
|
31
|
+
python3 aggregate_benchmark.py --self-test
|
|
32
|
+
|
|
33
|
+
Output is one JSON object on stdout.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import math
|
|
41
|
+
import sys
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
NUMERIC = (int, float)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- statistics -------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def sample_stddev(values: list[float]) -> float:
|
|
51
|
+
"""Sample standard deviation using n-1 (Bessel's correction).
|
|
52
|
+
|
|
53
|
+
Returns 0.0 for fewer than two values, where the sample variance is
|
|
54
|
+
undefined and reporting zero spread is the least surprising choice.
|
|
55
|
+
"""
|
|
56
|
+
n = len(values)
|
|
57
|
+
if n < 2:
|
|
58
|
+
return 0.0
|
|
59
|
+
mean = sum(values) / n
|
|
60
|
+
var = sum((x - mean) ** 2 for x in values) / (n - 1)
|
|
61
|
+
return math.sqrt(var)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def summarize_metric(values: list[float]) -> dict:
|
|
65
|
+
return {
|
|
66
|
+
"n": len(values),
|
|
67
|
+
"mean": (sum(values) / len(values)) if values else 0.0,
|
|
68
|
+
"stddev": sample_stddev(values),
|
|
69
|
+
"min": min(values) if values else 0.0,
|
|
70
|
+
"max": max(values) if values else 0.0,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def collect_numeric_metrics(records: list[dict]) -> dict[str, list[float]]:
|
|
75
|
+
"""Group every numeric field across records by metric name."""
|
|
76
|
+
by_metric: dict[str, list[float]] = {}
|
|
77
|
+
for rec in records:
|
|
78
|
+
if not isinstance(rec, dict):
|
|
79
|
+
continue
|
|
80
|
+
for key, val in rec.items():
|
|
81
|
+
if isinstance(val, bool):
|
|
82
|
+
continue # bools are ints in Python; not a metric
|
|
83
|
+
if isinstance(val, NUMERIC):
|
|
84
|
+
by_metric.setdefault(key, []).append(float(val))
|
|
85
|
+
return by_metric
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def summarize_config(records: list[dict]) -> dict:
|
|
89
|
+
by_metric = collect_numeric_metrics(records)
|
|
90
|
+
return {
|
|
91
|
+
"runs": len(records),
|
|
92
|
+
"metrics": {name: summarize_metric(vals)
|
|
93
|
+
for name, vals in sorted(by_metric.items())},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def delta_configs(baseline: dict, variant: dict) -> dict:
|
|
98
|
+
"""Per shared metric, delta = variant.mean - baseline.mean, plus context."""
|
|
99
|
+
b_metrics = baseline.get("metrics", {})
|
|
100
|
+
v_metrics = variant.get("metrics", {})
|
|
101
|
+
shared = sorted(set(b_metrics) & set(v_metrics))
|
|
102
|
+
out: dict[str, dict] = {}
|
|
103
|
+
for name in shared:
|
|
104
|
+
b = b_metrics[name]
|
|
105
|
+
v = v_metrics[name]
|
|
106
|
+
diff = v["mean"] - b["mean"]
|
|
107
|
+
pct = (diff / b["mean"] * 100.0) if b["mean"] != 0 else None
|
|
108
|
+
out[name] = {
|
|
109
|
+
"baseline_mean": b["mean"],
|
|
110
|
+
"variant_mean": v["mean"],
|
|
111
|
+
"delta": diff,
|
|
112
|
+
"delta_pct": pct,
|
|
113
|
+
"baseline_stddev": b["stddev"],
|
|
114
|
+
"variant_stddev": v["stddev"],
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- input loading ----------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def load_records(path: Path) -> list[dict]:
|
|
122
|
+
"""Load run records from a JSON file, a {'runs': [...]} file, or a dir of
|
|
123
|
+
timing.json files."""
|
|
124
|
+
if path.is_dir():
|
|
125
|
+
records: list[dict] = []
|
|
126
|
+
for f in sorted(path.rglob("timing.json")):
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(f.read_text(encoding="utf-8"))
|
|
129
|
+
except (OSError, json.JSONDecodeError):
|
|
130
|
+
continue
|
|
131
|
+
if isinstance(data, dict):
|
|
132
|
+
records.append(data)
|
|
133
|
+
return records
|
|
134
|
+
|
|
135
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
136
|
+
if isinstance(data, dict) and "runs" in data:
|
|
137
|
+
data = data["runs"]
|
|
138
|
+
if not isinstance(data, list):
|
|
139
|
+
raise ValueError(f"expected a list of run records in {path}")
|
|
140
|
+
return [r for r in data if isinstance(r, dict)]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --- self-test --------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def run_self_test() -> int:
|
|
146
|
+
"""Verify mean/stddev/min/max/delta on a known fixture."""
|
|
147
|
+
config_a = [
|
|
148
|
+
{"elapsed_s": 10.0, "total_tokens": 100},
|
|
149
|
+
{"elapsed_s": 12.0, "total_tokens": 200},
|
|
150
|
+
{"elapsed_s": 14.0, "total_tokens": 300},
|
|
151
|
+
]
|
|
152
|
+
summary_a = summarize_config(config_a)
|
|
153
|
+
el = summary_a["metrics"]["elapsed_s"]
|
|
154
|
+
# mean of 10,12,14 = 12; n-1 stddev = sqrt(((-2)^2+0+2^2)/2)=sqrt(4)=2
|
|
155
|
+
assert el["n"] == 3, el
|
|
156
|
+
assert abs(el["mean"] - 12.0) < 1e-9, el
|
|
157
|
+
assert abs(el["stddev"] - 2.0) < 1e-9, el
|
|
158
|
+
assert el["min"] == 10.0 and el["max"] == 14.0, el
|
|
159
|
+
tok = summary_a["metrics"]["total_tokens"]
|
|
160
|
+
# mean of 100,200,300 = 200; n-1 stddev = sqrt((10000+0+10000)/2)=100
|
|
161
|
+
assert abs(tok["mean"] - 200.0) < 1e-9, tok
|
|
162
|
+
assert abs(tok["stddev"] - 100.0) < 1e-9, tok
|
|
163
|
+
|
|
164
|
+
# single value -> stddev 0
|
|
165
|
+
one = summarize_config([{"x": 5}])
|
|
166
|
+
assert one["metrics"]["x"]["stddev"] == 0.0, one
|
|
167
|
+
|
|
168
|
+
# bools are not treated as metrics
|
|
169
|
+
with_bool = summarize_config([{"ok": True, "x": 1}, {"ok": False, "x": 3}])
|
|
170
|
+
assert "ok" not in with_bool["metrics"], with_bool
|
|
171
|
+
assert abs(with_bool["metrics"]["x"]["mean"] - 2.0) < 1e-9, with_bool
|
|
172
|
+
|
|
173
|
+
# delta: variant slower by 3s on mean, faster question answered by sign
|
|
174
|
+
config_b = [
|
|
175
|
+
{"elapsed_s": 13.0, "total_tokens": 90},
|
|
176
|
+
{"elapsed_s": 15.0, "total_tokens": 110},
|
|
177
|
+
{"elapsed_s": 17.0, "total_tokens": 100},
|
|
178
|
+
]
|
|
179
|
+
summary_b = summarize_config(config_b)
|
|
180
|
+
d = delta_configs(summary_a, summary_b)
|
|
181
|
+
# elapsed mean: A=12, B=15 -> delta +3, pct +25%
|
|
182
|
+
assert abs(d["elapsed_s"]["delta"] - 3.0) < 1e-9, d
|
|
183
|
+
assert abs(d["elapsed_s"]["delta_pct"] - 25.0) < 1e-9, d
|
|
184
|
+
# tokens mean: A=200, B=100 -> delta -100, pct -50%
|
|
185
|
+
assert abs(d["total_tokens"]["delta"] + 100.0) < 1e-9, d
|
|
186
|
+
assert abs(d["total_tokens"]["delta_pct"] + 50.0) < 1e-9, d
|
|
187
|
+
|
|
188
|
+
print(json.dumps({"self_test": "passed",
|
|
189
|
+
"checked": ["mean", "stddev_n_minus_1", "min", "max",
|
|
190
|
+
"single_value_stddev", "bool_excluded",
|
|
191
|
+
"delta", "delta_pct"]}))
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --- main -------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def main(argv: list[str] | None = None) -> int:
|
|
198
|
+
p = argparse.ArgumentParser(
|
|
199
|
+
description=__doc__,
|
|
200
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
201
|
+
)
|
|
202
|
+
p.add_argument("--runs", type=Path,
|
|
203
|
+
help="summarize one config (JSON file or dir of timing.json)")
|
|
204
|
+
p.add_argument("--baseline", type=Path,
|
|
205
|
+
help="baseline config for a two-config comparison")
|
|
206
|
+
p.add_argument("--variant", type=Path,
|
|
207
|
+
help="variant config for a two-config comparison")
|
|
208
|
+
p.add_argument("--self-test", action="store_true",
|
|
209
|
+
help="run the built-in fixture self-test and exit")
|
|
210
|
+
args = p.parse_args(argv)
|
|
211
|
+
|
|
212
|
+
if args.self_test:
|
|
213
|
+
return run_self_test()
|
|
214
|
+
|
|
215
|
+
if args.baseline and args.variant:
|
|
216
|
+
b = summarize_config(load_records(args.baseline))
|
|
217
|
+
v = summarize_config(load_records(args.variant))
|
|
218
|
+
out = {
|
|
219
|
+
"baseline": b,
|
|
220
|
+
"variant": v,
|
|
221
|
+
"delta": delta_configs(b, v),
|
|
222
|
+
}
|
|
223
|
+
print(json.dumps(out, indent=2))
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
if args.runs:
|
|
227
|
+
out = summarize_config(load_records(args.runs))
|
|
228
|
+
print(json.dumps(out, indent=2))
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
p.error("provide --runs, or both --baseline and --variant, or --self-test")
|
|
232
|
+
return 2
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
sys.exit(main())
|