mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
* 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.
210 lines
6.5 KiB
Python
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())
|