bmad-method 6.6.1-next.9 → 6.7.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.
- package/{tools/installer/modules/registry-fallback.yaml → bmad-modules.yaml} +29 -15
- package/package.json +1 -1
- package/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +18 -11
- package/src/bmm-skills/1-analysis/bmad-product-brief/customize.toml +13 -8
- package/src/bmm-skills/2-plan-workflows/bmad-prd/SKILL.md +54 -57
- package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/headless-schemas.md +2 -2
- package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/prd-template.md +40 -30
- package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/prd-validation-checklist.md +126 -21
- package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/validation-report-template.html +193 -58
- package/src/bmm-skills/2-plan-workflows/bmad-prd/customize.toml +47 -13
- package/src/bmm-skills/2-plan-workflows/bmad-prd/references/headless.md +27 -12
- package/src/bmm-skills/2-plan-workflows/bmad-prd/references/validate.md +97 -0
- package/src/bmm-skills/module.yaml +2 -2
- package/src/core-skills/module.yaml +1 -1
- package/tools/installer/core/installer.js +1 -22
- package/tools/installer/core/manifest.js +0 -22
- package/tools/installer/modules/channel-plan.js +1 -1
- package/tools/installer/modules/external-manager.js +9 -27
- package/tools/installer/modules/official-modules.js +9 -48
- package/tools/installer/ui.js +12 -196
- package/src/bmm-skills/2-plan-workflows/bmad-prd/references/facilitation-guide.md +0 -79
- package/src/bmm-skills/2-plan-workflows/bmad-prd/references/validation-render.md +0 -58
- package/src/bmm-skills/2-plan-workflows/bmad-prd/scripts/render-validation-html.py +0 -290
- package/tools/installer/modules/community-manager.js +0 -704
- package/tools/installer/modules/registry-client.js +0 -187
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# PRD Facilitation Guide
|
|
2
|
-
|
|
3
|
-
Per-section conversation techniques for facilitative mode. Each entry names the coaching move that makes the section's conversation productive — not a checklist, a posture. Skip sections the PM has already resolved; spend more time where thinking is thin.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Users and Personas
|
|
8
|
-
|
|
9
|
-
**The move:** Ground personas in real people, not archetypes.
|
|
10
|
-
|
|
11
|
-
Ask the PM to describe a specific person they have observed or talked to — not a type, an actual human. "Who is the clerk at your store? Tell me about them." Invented detail (name, age, backstory from nowhere) is persona theater — the team builds for a fiction. If the PM says "someone like..." push gently: "Is there a real person you're thinking of?"
|
|
12
|
-
|
|
13
|
-
Once grounded: what does that person want to accomplish in the time they interact with this product? What would make them say this is easier than what they do today? What would make them abandon it?
|
|
14
|
-
|
|
15
|
-
For the remote user or secondary persona: same grounding, different question — what question do they need answered in under ten seconds, and what do they do if they can't get it?
|
|
16
|
-
|
|
17
|
-
Mark anything the PM could not ground in observation as `[ILLUSTRATIVE]` — and note it's a hypothesis to validate, not a spec to build for.
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## Core User Journeys
|
|
22
|
-
|
|
23
|
-
**The move:** Story structure, not use-case list.
|
|
24
|
-
|
|
25
|
-
For each primary journey, walk through four beats:
|
|
26
|
-
|
|
27
|
-
- **Opening scene** — where do we meet this person, what is their situation right now, what pain or need is present?
|
|
28
|
-
- **Rising action** — what steps do they take, what do they discover or decide along the way?
|
|
29
|
-
- **Climax** — the moment the product delivers real value; the thing they could not do before
|
|
30
|
-
- **Resolution** — what is their new reality; how is their situation different?
|
|
31
|
-
|
|
32
|
-
After each journey: what could go wrong at the climax? What is the recovery path? This is where edge cases that matter surface — not invented error states, but real failure modes for this person.
|
|
33
|
-
|
|
34
|
-
Explicitly name what capability each journey reveals. "This journey requires the operator to log an entry with no internet — which means we need a decision on whether that's in or out of MVP." Journeys produce capability requirements; make the link visible.
|
|
35
|
-
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
## Key Feature Decisions
|
|
39
|
-
|
|
40
|
-
**The move:** Surface the assumptions that would otherwise be silent.
|
|
41
|
-
|
|
42
|
-
Before the draft exists, there are decisions the agent would silently make and the PM would never know were made. These are the ones worth a thirty-second conversation:
|
|
43
|
-
|
|
44
|
-
- Decisions that drive the core UX model (e.g., one record per day vs. many; who can edit vs. view; what happens when the expected input doesn't exist)
|
|
45
|
-
- Decisions where the "obvious" choice has real consequences the PM may not have considered
|
|
46
|
-
- Decisions that, if wrong, require structural changes to fix later
|
|
47
|
-
|
|
48
|
-
For each: state what you inferred, name the alternative, ask which is right. Do not present options as a quiz — present your inference and invite correction. "I'm assuming one sales tally per day replaces rather than adds. Is that right, or should the operator be able to log multiple?" Resolve and move on. Only tag as `[ASSUMPTION]` when the answer requires external input or research the PM cannot provide now.
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
## Scope Boundary
|
|
53
|
-
|
|
54
|
-
**The move:** Establish MVP philosophy before listing features.
|
|
55
|
-
|
|
56
|
-
Before asking what is in or out, ask what kind of MVP this is:
|
|
57
|
-
|
|
58
|
-
- **Problem-solving MVP** — the minimum that proves the core problem is solved; rough edges acceptable
|
|
59
|
-
- **Experience MVP** — the minimum that proves the interaction model works; quality matters
|
|
60
|
-
- **Platform MVP** — the minimum infrastructure other things can build on; completeness of the base matters
|
|
61
|
-
- **Revenue MVP** — the minimum someone will pay for; business viability is the test
|
|
62
|
-
|
|
63
|
-
The answer changes what "minimum" means. A problem-solving MVP for a personal-use tool has different scope logic than an experience MVP aimed at non-tech-savvy users who will bounce at the first confusion.
|
|
64
|
-
|
|
65
|
-
Once the philosophy is named, non-goals do as much work as in-scope items. Probe for the things the PM is tempted to add. "What keeps almost making it onto the list?" For each: is it truly out of MVP, or does it need to be in because the MVP fails without it?
|
|
66
|
-
|
|
67
|
-
---
|
|
68
|
-
|
|
69
|
-
## Success Metrics
|
|
70
|
-
|
|
71
|
-
**The move:** Push every adjective to a measurement.
|
|
72
|
-
|
|
73
|
-
"Users will love it" — what does that mean in behavior? "It'll be fast" — fast at what, for whom, measured how? "Good adoption" — what percentage, by when, doing what? Every quality claim needs a measurement or it is not a success criterion, it is a wish.
|
|
74
|
-
|
|
75
|
-
For each metric surfaced: connect it back to the product's differentiator. If the differentiator is simplicity for non-tech users, the primary metric should measure whether non-tech users successfully complete the core action without help — not session count or feature usage breadth.
|
|
76
|
-
|
|
77
|
-
Name counter-metrics explicitly — what this product should *not* optimize for. These prevent the wrong thing being built: more entries per day is not better if the goal is accurate daily records; longer dashboard sessions may indicate a broken IA, not high engagement. Counter-metrics are as load-bearing as primary metrics for downstream readers.
|
|
78
|
-
|
|
79
|
-
For low-stakes or personal-use products: one sentence is enough. "Success: I use this daily and it replaces the notebook within a month." Do not impose metric rigor where the stakes do not warrant it.
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# Validation Rendering
|
|
2
|
-
|
|
3
|
-
How the validator subagent's findings become a validation report. Loaded only when the user has explicitly asked for analysis — either Validate intent or a mid-session report request. The Finalize discipline pass during Create/Update does NOT render a report; its findings stay in-conversation.
|
|
4
|
-
|
|
5
|
-
## Validator subagent output contract
|
|
6
|
-
|
|
7
|
-
The subagent walks `{workflow.validation_checklist}` against `prd.md` (and `addendum.md` if present) and writes `{doc_workspace}/validation-findings.json`:
|
|
8
|
-
|
|
9
|
-
```json
|
|
10
|
-
{
|
|
11
|
-
"prd_name": "Plantsona",
|
|
12
|
-
"prd_path": "{doc_workspace}/prd.md",
|
|
13
|
-
"checklist_path": "{workflow.validation_checklist}",
|
|
14
|
-
"timestamp": "2026-05-11T09:14:00",
|
|
15
|
-
"overall_synthesis": "2-3 sentences of judgment about the PRD's overall state — what holds up, what's at risk. Written by the subagent, not the parent.",
|
|
16
|
-
"findings": [
|
|
17
|
-
{
|
|
18
|
-
"id": "Q-2",
|
|
19
|
-
"category": "Quality",
|
|
20
|
-
"title": "Measurability",
|
|
21
|
-
"status": "warn",
|
|
22
|
-
"severity": "medium",
|
|
23
|
-
"location": "§16 Success Metrics, lines 408-422",
|
|
24
|
-
"note": "Success Metrics list is measurable but counter-metrics are named only for premium conversion. Other metrics lack paired counter-metrics.",
|
|
25
|
-
"suggested_fix": "Add counter-metrics for engagement (e.g., DAU/MAU) and seasonal cadence."
|
|
26
|
-
}
|
|
27
|
-
]
|
|
28
|
-
}
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Per-finding fields:
|
|
32
|
-
|
|
33
|
-
- `id` (required) — checklist item ID (e.g., `Q-1`, `D-2`, `STK-1`, or org-custom prefixes).
|
|
34
|
-
- `category` (optional) — explicit category name; if omitted, the renderer maps from the ID prefix.
|
|
35
|
-
- `title` (optional but recommended) — the checklist item's short name.
|
|
36
|
-
- `status` — `pass` | `warn` | `fail` | `n/a`.
|
|
37
|
-
- `severity` — `low` | `medium` | `high` | `critical`.
|
|
38
|
-
- `location` (optional) — section/line/range in the PRD where the finding lives. Cite specifics, never abstract criticism.
|
|
39
|
-
- `note` (optional) — the finding itself, in one or two sentences.
|
|
40
|
-
- `suggested_fix` (optional) — concrete next action.
|
|
41
|
-
|
|
42
|
-
## Rendering invocation
|
|
43
|
-
|
|
44
|
-
After the subagent writes findings:
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
python3 {skill-root}/scripts/render-validation-html.py \
|
|
48
|
-
--findings {doc_workspace}/validation-findings.json \
|
|
49
|
-
--template {workflow.validation_report_template} \
|
|
50
|
-
--output {doc_workspace}/validation-report.html \
|
|
51
|
-
--open
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Include `--open` for interactive runs (auto-opens in default browser). Omit `--open` in headless runs.
|
|
55
|
-
|
|
56
|
-
The script writes two artifacts side-by-side: the HTML report at `--output`, and a markdown companion at the same path with `.md` extension (e.g. `validation-report.md`). Both are always produced when the script runs — trigger gating happens upstream (the script is only invoked when the user has asked for analysis). It computes pass/warn/fail/na counts, derives a grade (Excellent / Good / Fair / Poor) from critical-fail and total-fail counts, renders an inline SVG score bar in the HTML, groups findings by category, and returns a one-line JSON summary on stdout: `{"output": "...", "markdown": "...", "grade": "...", "stats": {...}}`.
|
|
57
|
-
|
|
58
|
-
Re-running validation overwrites the existing report files in place. Markdown form is what Update mode reads when rolling findings into a revision.
|
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# /// script
|
|
3
|
-
# requires-python = ">=3.10"
|
|
4
|
-
# ///
|
|
5
|
-
"""Render a PRD validation findings JSON into HTML + markdown reports.
|
|
6
|
-
|
|
7
|
-
Reads structured findings produced by the validator subagent, groups them by
|
|
8
|
-
category (explicit `category` field, else derived from ID prefix), computes a
|
|
9
|
-
pass/warn/fail summary and grade, substitutes into the configured HTML
|
|
10
|
-
template, writes a markdown companion at the same path with `.md` extension,
|
|
11
|
-
and optionally opens the HTML in the default browser.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
import argparse
|
|
15
|
-
import html
|
|
16
|
-
import json
|
|
17
|
-
import string
|
|
18
|
-
import sys
|
|
19
|
-
import webbrowser
|
|
20
|
-
from datetime import datetime
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
CATEGORY_FROM_PREFIX = {
|
|
24
|
-
"Q": "Quality",
|
|
25
|
-
"D": "Discipline",
|
|
26
|
-
"S": "Structural integrity",
|
|
27
|
-
"STK": "Stakes-gated",
|
|
28
|
-
"M": "Mechanical",
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
CATEGORY_ORDER = ["Quality", "Discipline", "Structural integrity", "Stakes-gated", "Mechanical"]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def category_for(finding: dict) -> str:
|
|
35
|
-
explicit = finding.get("category")
|
|
36
|
-
if explicit:
|
|
37
|
-
return explicit
|
|
38
|
-
fid = finding.get("id", "")
|
|
39
|
-
prefix = fid.split("-", 1)[0] if "-" in fid else fid
|
|
40
|
-
return CATEGORY_FROM_PREFIX.get(prefix, prefix or "Other")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def compute_stats(findings: list[dict]) -> dict:
|
|
44
|
-
total = len(findings)
|
|
45
|
-
by_status = {"pass": 0, "warn": 0, "fail": 0, "n/a": 0}
|
|
46
|
-
failed_critical = 0
|
|
47
|
-
failed_high = 0
|
|
48
|
-
for f in findings:
|
|
49
|
-
status = (f.get("status") or "n/a").lower()
|
|
50
|
-
if status in by_status:
|
|
51
|
-
by_status[status] += 1
|
|
52
|
-
if status == "fail":
|
|
53
|
-
sev = (f.get("severity") or "low").lower()
|
|
54
|
-
if sev == "critical":
|
|
55
|
-
failed_critical += 1
|
|
56
|
-
elif sev == "high":
|
|
57
|
-
failed_high += 1
|
|
58
|
-
return {
|
|
59
|
-
"total": total,
|
|
60
|
-
"passed": by_status["pass"],
|
|
61
|
-
"warned": by_status["warn"],
|
|
62
|
-
"failed": by_status["fail"],
|
|
63
|
-
"na": by_status["n/a"],
|
|
64
|
-
"failed_critical": failed_critical,
|
|
65
|
-
"failed_high": failed_high,
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def grade_from(stats: dict) -> tuple[str, str]:
|
|
70
|
-
if stats["failed_critical"] > 0:
|
|
71
|
-
return "Poor", "grade-poor"
|
|
72
|
-
if stats["failed_high"] >= 1 or stats["failed"] >= 4:
|
|
73
|
-
return "Fair", "grade-fair"
|
|
74
|
-
if stats["failed"] > 0 or stats["warned"] > 2:
|
|
75
|
-
return "Good", "grade-good"
|
|
76
|
-
return "Excellent", "grade-excellent"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def render_score_bar(stats: dict, width: int = 480, height: int = 22) -> str:
|
|
80
|
-
total = max(stats["total"], 1)
|
|
81
|
-
p = stats["passed"] / total * width
|
|
82
|
-
w = stats["warned"] / total * width
|
|
83
|
-
f = stats["failed"] / total * width
|
|
84
|
-
n = stats["na"] / total * width
|
|
85
|
-
return (
|
|
86
|
-
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" '
|
|
87
|
-
f'aria-label="Pass / warn / fail / n-a breakdown">'
|
|
88
|
-
f'<rect x="0" y="0" width="{p:.1f}" height="{height}" fill="#22c55e"/>'
|
|
89
|
-
f'<rect x="{p:.1f}" y="0" width="{w:.1f}" height="{height}" fill="#eab308"/>'
|
|
90
|
-
f'<rect x="{p + w:.1f}" y="0" width="{f:.1f}" height="{height}" fill="#ef4444"/>'
|
|
91
|
-
f'<rect x="{p + w + f:.1f}" y="0" width="{n:.1f}" height="{height}" fill="#94a3b8"/>'
|
|
92
|
-
f"</svg>"
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def render_finding(f: dict) -> str:
|
|
97
|
-
status = (f.get("status") or "n/a").lower()
|
|
98
|
-
severity = (f.get("severity") or "low").lower()
|
|
99
|
-
fid = html.escape(f.get("id") or "")
|
|
100
|
-
title = html.escape(f.get("title") or fid)
|
|
101
|
-
location = html.escape(f.get("location") or "")
|
|
102
|
-
note = html.escape(f.get("note") or "")
|
|
103
|
-
fix = html.escape(f.get("suggested_fix") or "")
|
|
104
|
-
|
|
105
|
-
status_class = "na" if status == "n/a" else status
|
|
106
|
-
parts = [
|
|
107
|
-
f'<article class="finding finding-{status_class}">',
|
|
108
|
-
'<header>',
|
|
109
|
-
f'<span class="badge badge-status badge-{status_class}">{status.upper()}</span>',
|
|
110
|
-
f'<span class="badge badge-severity badge-sev-{severity}">{severity}</span>',
|
|
111
|
-
f'<span class="finding-id">{fid}</span>',
|
|
112
|
-
f'<h3 class="finding-title">{title}</h3>',
|
|
113
|
-
'</header>',
|
|
114
|
-
]
|
|
115
|
-
if location:
|
|
116
|
-
parts.append(f'<div class="finding-location"><strong>Location:</strong> {location}</div>')
|
|
117
|
-
if note:
|
|
118
|
-
parts.append(f'<div class="finding-note">{note}</div>')
|
|
119
|
-
if fix:
|
|
120
|
-
parts.append(f'<div class="finding-fix"><strong>Suggested fix:</strong> {fix}</div>')
|
|
121
|
-
parts.append("</article>")
|
|
122
|
-
return "\n".join(parts)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def render_category(name: str, findings: list[dict]) -> str:
|
|
126
|
-
items = "\n".join(render_finding(f) for f in findings)
|
|
127
|
-
name_e = html.escape(name)
|
|
128
|
-
return (
|
|
129
|
-
f'<section class="category">'
|
|
130
|
-
f"<details open>"
|
|
131
|
-
f'<summary><h2>{name_e} <span class="count">({len(findings)})</span></h2></summary>'
|
|
132
|
-
f"{items}"
|
|
133
|
-
f"</details>"
|
|
134
|
-
f"</section>"
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
SEVERITY_ORDER = ["critical", "high", "medium", "low"]
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def render_finding_md(f: dict) -> str:
|
|
142
|
-
status = (f.get("status") or "n/a").upper()
|
|
143
|
-
severity = (f.get("severity") or "low").lower()
|
|
144
|
-
fid = f.get("id") or ""
|
|
145
|
-
title = f.get("title") or fid
|
|
146
|
-
location = f.get("location") or ""
|
|
147
|
-
note = f.get("note") or ""
|
|
148
|
-
fix = f.get("suggested_fix") or ""
|
|
149
|
-
|
|
150
|
-
lines = [f"### [{status}] {fid} — {title} _(severity: {severity})_"]
|
|
151
|
-
if location:
|
|
152
|
-
lines.append(f"- **Location:** {location}")
|
|
153
|
-
if note:
|
|
154
|
-
lines.append(f"- **Finding:** {note}")
|
|
155
|
-
if fix:
|
|
156
|
-
lines.append(f"- **Suggested fix:** {fix}")
|
|
157
|
-
return "\n".join(lines)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def render_markdown_report(data: dict, findings: list[dict], stats: dict, grade: str) -> str:
|
|
161
|
-
prd_name = data.get("prd_name") or "PRD"
|
|
162
|
-
prd_path = data.get("prd_path") or ""
|
|
163
|
-
checklist_path = data.get("checklist_path") or ""
|
|
164
|
-
timestamp = data.get("timestamp") or datetime.now().isoformat(timespec="seconds")
|
|
165
|
-
synthesis = data.get("overall_synthesis") or ""
|
|
166
|
-
|
|
167
|
-
out = [
|
|
168
|
-
f"# Validation Report — {prd_name}",
|
|
169
|
-
"",
|
|
170
|
-
f"- **PRD:** `{prd_path}`",
|
|
171
|
-
f"- **Checklist:** `{checklist_path}`",
|
|
172
|
-
f"- **Run at:** {timestamp}",
|
|
173
|
-
f"- **Grade:** {grade}",
|
|
174
|
-
"",
|
|
175
|
-
f"**Summary:** {stats['passed']} pass · {stats['warned']} warn · {stats['failed']} fail · {stats['na']} n/a "
|
|
176
|
-
f"(total {stats['total']}; critical fails: {stats['failed_critical']}, high fails: {stats['failed_high']})",
|
|
177
|
-
]
|
|
178
|
-
if synthesis:
|
|
179
|
-
out += ["", "## Overall synthesis", "", synthesis]
|
|
180
|
-
|
|
181
|
-
# Group by severity then status: failed criticals first, then highs, etc.
|
|
182
|
-
by_sev: dict[str, list[dict]] = {s: [] for s in SEVERITY_ORDER}
|
|
183
|
-
other: list[dict] = []
|
|
184
|
-
for f in findings:
|
|
185
|
-
sev = (f.get("severity") or "low").lower()
|
|
186
|
-
if sev in by_sev:
|
|
187
|
-
by_sev[sev].append(f)
|
|
188
|
-
else:
|
|
189
|
-
other.append(f)
|
|
190
|
-
|
|
191
|
-
out += ["", "## Findings by severity"]
|
|
192
|
-
any_findings = False
|
|
193
|
-
for sev in SEVERITY_ORDER:
|
|
194
|
-
items = by_sev[sev]
|
|
195
|
-
if not items:
|
|
196
|
-
continue
|
|
197
|
-
any_findings = True
|
|
198
|
-
out += ["", f"### {sev.capitalize()} ({len(items)})", ""]
|
|
199
|
-
out += [render_finding_md(f) for f in items]
|
|
200
|
-
if other:
|
|
201
|
-
any_findings = True
|
|
202
|
-
out += ["", f"### Other ({len(other)})", ""]
|
|
203
|
-
out += [render_finding_md(f) for f in other]
|
|
204
|
-
if not any_findings:
|
|
205
|
-
out += ["", "_No findings._"]
|
|
206
|
-
|
|
207
|
-
return "\n".join(out) + "\n"
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def main(argv: list[str]) -> int:
|
|
211
|
-
parser = argparse.ArgumentParser(description="Render PRD validation findings to HTML.")
|
|
212
|
-
parser.add_argument("--findings", required=True, help="Path to validation-findings.json")
|
|
213
|
-
parser.add_argument("--template", required=True, help="Path to HTML template")
|
|
214
|
-
parser.add_argument("--output", required=True, help="Path to write the rendered HTML")
|
|
215
|
-
parser.add_argument("--open", action="store_true", help="Open the rendered HTML in the default browser")
|
|
216
|
-
args = parser.parse_args(argv)
|
|
217
|
-
|
|
218
|
-
findings_path = Path(args.findings)
|
|
219
|
-
template_path = Path(args.template)
|
|
220
|
-
output_path = Path(args.output)
|
|
221
|
-
|
|
222
|
-
try:
|
|
223
|
-
data = json.loads(findings_path.read_text(encoding="utf-8"))
|
|
224
|
-
except FileNotFoundError:
|
|
225
|
-
print(f"error: findings file not found: {findings_path}", file=sys.stderr)
|
|
226
|
-
return 1
|
|
227
|
-
except json.JSONDecodeError as e:
|
|
228
|
-
print(f"error: findings file is not valid JSON ({findings_path}): {e}", file=sys.stderr)
|
|
229
|
-
return 1
|
|
230
|
-
try:
|
|
231
|
-
template = template_path.read_text(encoding="utf-8")
|
|
232
|
-
except FileNotFoundError:
|
|
233
|
-
print(f"error: template file not found: {template_path}", file=sys.stderr)
|
|
234
|
-
return 1
|
|
235
|
-
|
|
236
|
-
findings = data.get("findings", []) or []
|
|
237
|
-
|
|
238
|
-
by_cat: dict[str, list[dict]] = {}
|
|
239
|
-
for f in findings:
|
|
240
|
-
by_cat.setdefault(category_for(f), []).append(f)
|
|
241
|
-
|
|
242
|
-
sorted_cats = sorted(
|
|
243
|
-
by_cat.keys(),
|
|
244
|
-
key=lambda c: (CATEGORY_ORDER.index(c) if c in CATEGORY_ORDER else 99, c),
|
|
245
|
-
)
|
|
246
|
-
categories_html = "\n".join(render_category(c, by_cat[c]) for c in sorted_cats)
|
|
247
|
-
|
|
248
|
-
stats = compute_stats(findings)
|
|
249
|
-
grade, grade_class = grade_from(stats)
|
|
250
|
-
score_svg = render_score_bar(stats)
|
|
251
|
-
|
|
252
|
-
timestamp = data.get("timestamp") or datetime.now().isoformat(timespec="seconds")
|
|
253
|
-
substitutions = {
|
|
254
|
-
"prd_name": html.escape(str(data.get("prd_name") or "PRD")),
|
|
255
|
-
"prd_path": html.escape(str(data.get("prd_path") or "")),
|
|
256
|
-
"checklist_path": html.escape(str(data.get("checklist_path") or "")),
|
|
257
|
-
"timestamp": html.escape(timestamp),
|
|
258
|
-
"overall_synthesis": html.escape(str(data.get("overall_synthesis") or "")),
|
|
259
|
-
"grade": grade,
|
|
260
|
-
"grade_class": grade_class,
|
|
261
|
-
"total": str(stats["total"]),
|
|
262
|
-
"passed": str(stats["passed"]),
|
|
263
|
-
"failed": str(stats["failed"]),
|
|
264
|
-
"warned": str(stats["warned"]),
|
|
265
|
-
"na": str(stats["na"]),
|
|
266
|
-
"score_svg": score_svg,
|
|
267
|
-
"categories_html": categories_html,
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
rendered = string.Template(template).safe_substitute(substitutions)
|
|
271
|
-
output_path.write_text(rendered, encoding="utf-8")
|
|
272
|
-
|
|
273
|
-
md_path = output_path.with_suffix(".md")
|
|
274
|
-
md_path.write_text(render_markdown_report(data, findings, stats, grade), encoding="utf-8")
|
|
275
|
-
|
|
276
|
-
print(json.dumps({
|
|
277
|
-
"output": str(output_path),
|
|
278
|
-
"markdown": str(md_path),
|
|
279
|
-
"grade": grade,
|
|
280
|
-
"stats": stats,
|
|
281
|
-
}))
|
|
282
|
-
|
|
283
|
-
if args.open:
|
|
284
|
-
webbrowser.open(output_path.resolve().as_uri())
|
|
285
|
-
|
|
286
|
-
return 0
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if __name__ == "__main__":
|
|
290
|
-
sys.exit(main(sys.argv[1:]))
|