Files
magnus919_agent-skills/bmad/scripts/check-spec.py
T
Magnus HedemarkandGitHub e10508b034 feat(bmad): add BMad control-plane protocol skill (#400)
* feat(bmad): add BMad control-plane protocol skill

New standalone methodology skill that lets any agent run the BMad method
(Breakthrough Method of Agile AI-Driven Development) as a harness-agnostic
control-plane protocol: five-field intent contracts, direct/bounded/initiative
classification, review-as-triage, failure routing by layer, and autonomy gating
with machine-readable spec status.

- SKILL.md protocol core with progressive disclosure + When not to use
- README.md human-facing install guide
- 9 references: protocol, classification, spec, lifecycle, project-context,
  review-and-failure-routing, autonomy, party-mode, adoption
- 4 templates: SPEC, INTENT, STORY, REVIEW
- scripts/check-spec.py + 16 tests (stdlib, deterministic spec validation)
- evals/evals.json: 9 output-quality cases
- Routing seams from bmad to adjacent skills and back from
  spec-driven-development, product-shaping, implementation-planning, neckbeard
- Catalog updates: root README, skill-triggers, marketplace/plugin/llms.txt

Closes #399

* fix(bmad): address droid-review findings

- check-spec.py: skip headings inside fenced/indented code blocks so a spec
  cannot PASS on section text that only appears in a code sample
- check-spec.py: catch UnicodeDecodeError on non-UTF-8 files and report FAIL
  instead of crashing
- STORY.md template: add created key for resumability/traceability parity
- SPEC.md template: split in-progress and in-review status bullets
- add 2 regression tests (heading-in-fence, non-UTF-8)

* fix(bmad): address droid-review round 2

- check-spec.py: read specs with utf-8-sig so a UTF-8 BOM cannot silently
  disable the frontmatter status check
- check-spec.py: handle standard YAML inline comments after status values
  (status: draft  # pending review) without a false FAIL
- references/protocol.md: make lifecycle phrasing consistent with
  lifecycle.md — four phases plus a learning closeout
- add 2 regression tests (BOM, inline comment)

* fix(bmad): tolerate trailing whitespace on frontmatter delimiters

A spec whose --- delimiter lines carry trailing spaces or tabs would silently
disable the status check and let an invalid status PASS. Relax the delimiter
pattern and add a regression test.

* fix(bmad): ignore inline comments in quoted status values

* fix(bmad): tolerate leading blank lines before frontmatter

* fix(bmad): fail closed on unparseable frontmatter, matching fence markers

Address droid-review round 5 and 6 findings as a single closed class:
- Fail closed when a file opens with a --- delimiter that cannot be parsed,
  so no whitespace/frontmatter permutation can silently disable the status
  check (previously: unparseable frontmatter was treated as 'no status'
  warning, letting an invalid status PASS).
- Track fence opener markers in collect_headings so a mismatched fence no
  longer closes a code block early (false-PASS on missing sections) and an
  unclosed fence no longer swallows real headings.
- Accept empty well-formed frontmatter (---\n---) and closing delimiters
  without a trailing newline.
- STORY.md template: parent-spec points at the sibling SPEC.md.
- README: status vocabulary is not a strict linear chain; blocked is a
  resumable routing signal.

Whitespace/frontmatter mutation sweep: 9 formatting variants x valid/invalid
status all verdict correctly; malformed delimiters fail closed. 29 tests.
2026-08-24 08:05:43 -04:00

210 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""Validate BMad SPEC files: required five sections and status vocabulary.
Checks that a SPEC.md (or INTENT.md) contains the five core contract fields
(Why, Capabilities, Constraints, Non-goals, Success signal) and that the
frontmatter ``status`` field, when present, is one of the six vocabulary
values (draft, ready-for-dev, in-progress, in-review, done, blocked).
Exit codes: 0 = all files valid, 1 = at least one invalid or unreadable file.
Standard library only; usable in CI or by any harness that can run python3.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
STATUS_VOCABULARY = (
"draft",
"ready-for-dev",
"in-progress",
"in-review",
"done",
"blocked",
)
REQUIRED_SECTIONS = (
"Why",
"Capabilities",
"Constraints",
"Non-goals",
"Success signal",
)
HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$")
FRONTMATTER_RE = re.compile(
r"\A(?:[ \t]*\r?\n)*[ \t]*---[ \t]*\r?\n(.*?)(?:\r?\n)?[ \t]*---[ \t]*(?:\r?\n|$)",
re.DOTALL,
)
@dataclass
class SpecReport:
"""Validation result for one spec file."""
path: str
valid: bool = True
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def extract_frontmatter(text: str) -> dict[str, str]:
"""Parse YAML frontmatter as a flat key/value map (no YAML dependency)."""
match = FRONTMATTER_RE.match(text)
if not match:
return {}
fields: dict[str, str] = {}
for line in match.group(1).splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or ":" not in stripped:
continue
key, _, raw = stripped.partition(":")
raw = raw.strip()
if raw[:1] in ("'", '"'):
quote = raw[0]
end = raw.find(quote, 1)
value = raw[1:end].strip() if end != -1 else raw[1:].strip()
else:
value = raw.split(" #", 1)[0].strip()
fields[key.strip()] = value
return fields
def collect_headings(text: str) -> list[str]:
"""Return the text of every markdown heading outside code blocks."""
headings: list[str] = []
fence: str | None = None
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith(("```", "~~~")):
marker = stripped[:3]
if fence is None:
fence = marker
elif fence == marker:
fence = None
continue
if fence is not None:
continue
if line.startswith((" ", "\t")):
continue # indented code block
match = HEADING_RE.match(stripped)
if match:
headings.append(match.group(1).strip())
return headings
def section_present(headings: list[str], required: str) -> bool:
"""True when a heading matches the required section name."""
return any(heading.lower() == required.lower() for heading in headings)
def looks_like_frontmatter(text: str) -> bool:
"""True when the file opens with an apparent ``---`` frontmatter delimiter."""
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
return stripped.startswith("---")
return False
def validate_spec(path: Path) -> SpecReport:
"""Validate a single spec file and return its report."""
report = SpecReport(path=str(path))
try:
text = path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeDecodeError) as exc:
report.valid = False
report.errors.append(f"cannot read file: {exc}")
return report
frontmatter = extract_frontmatter(text)
headings = collect_headings(text)
parse_failed = FRONTMATTER_RE.match(text) is None and looks_like_frontmatter(text)
if parse_failed:
report.valid = False
report.errors.append(
"frontmatter appears present but could not be parsed; check the --- delimiters"
)
status = frontmatter.get("status")
if status is not None:
if status not in STATUS_VOCABULARY:
report.valid = False
report.errors.append(
f"invalid status {status!r}; expected one of " + ", ".join(STATUS_VOCABULARY)
)
elif not parse_failed:
report.warnings.append("no 'status' in frontmatter; add one when the work is resumable")
missing = [name for name in REQUIRED_SECTIONS if not section_present(headings, name)]
if missing:
report.valid = False
report.errors.append("missing required section(s): " + ", ".join(missing))
return report
def render_text(reports: list[SpecReport]) -> str:
"""Human-readable summary of all reports."""
lines: list[str] = []
for report in reports:
verdict = "PASS" if report.valid else "FAIL"
lines.append(f"{verdict} {report.path}")
for warning in report.warnings:
lines.append(f" warning: {warning}")
for error in report.errors:
lines.append(f" error: {error}")
valid = sum(1 for report in reports if report.valid)
lines.append(f"{valid}/{len(reports)} spec(s) valid")
return "\n".join(lines)
def render_json(reports: list[SpecReport]) -> str:
"""Machine-readable summary of all reports."""
payload = {
"valid": all(report.valid for report in reports),
"files": [
{
"path": report.path,
"valid": report.valid,
"errors": report.errors,
"warnings": report.warnings,
}
for report in reports
],
}
return json.dumps(payload, indent=2)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate BMad spec files: required sections and status vocabulary."
)
parser.add_argument(
"files", nargs="+", type=Path, help="SPEC.md or INTENT.md files to validate"
)
parser.add_argument(
"--json", action="store_true", help="emit machine-readable JSON instead of text"
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
reports = [validate_spec(path) for path in args.files]
output = render_json(reports) if args.json else render_text(reports)
print(output)
return 0 if all(report.valid for report in reports) else 1
if __name__ == "__main__":
sys.exit(main())