bmad 6.11

This commit is contained in:
2026-08-14 17:08:24 +02:00
parent 0edaef0be7
commit 373857c868
268 changed files with 26063 additions and 0 deletions
@@ -0,0 +1,697 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["ruamel.yaml>=0.18"]
# ///
"""Parse epic files and deterministically generate or refresh sprint-status.yaml.
Prints ONLY JSON to stdout — argparse failures included. Errors are emitted as
JSON to stdout with a non-zero exit code. Writes are atomic (temp file, fsync,
``os.replace``) and the original file bytes are restored atomically if
post-write validation fails.
Subcommands:
generate Parse epics, merge with any existing status file, write the result.
--dry-run reports (including drift: in_sync, illegal, orphans)
without writing. --fresh ignores existing statuses for a pristine
rebuild; --set key=status applies explicit, user-confirmed statuses
on top — the repair path is allowed to downgrade.
status Summarize an existing status file: counts, risks, open action
items, and the next recommended action. No writes.
validate Report whether an existing status file is structurally valid:
parseable, recognized keys, legal statuses, well-formed
action_items. No writes; exit 0 whether valid or not.
The LLM decides *which* files are epics (discovery is judgment); this script
owns everything after that decision: parsing, key derivation, ordering, status
preservation, story-file detection, action-item carry-over, and validation.
Legacy v6 statuses (drafted, contexted) are normalized on read everywhere, so
they merge and count by their modern meaning and are reported, never reset.
"""
import argparse
import hashlib
import io
import json
import os
import re
import sys
import tempfile
from pathlib import Path
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
EPIC_RE = re.compile(r"^#{1,3}\s*Epic\s+(\d+)\s*:?\s*(.*?)\s*#*\s*$", re.IGNORECASE)
STORY_RE = re.compile(
r"^#{2,4}\s*Story\s+(\d+)\.(\d+[a-z]?)\s*:?\s*(.*?)\s*#*\s*$", re.IGNORECASE
)
# Heading lines that mention Epic/Story but failed the strict patterns above.
SUSPECT_RE = re.compile(r"^#{1,4}\s.*\b(?:epic|story)\b", re.IGNORECASE)
FENCE_RE = re.compile(r"^\s{0,3}(?:```|~~~)")
# The key grammar for sprint-status.yaml. The trailing [a-z]? matches
# split-story keys like 2-6a-...; bmad-retrospective's sprint_status.py reads
# the same file with the same grammar.
EPIC_KEY_RE = re.compile(r"^epic-(\d+)$")
RETRO_KEY_RE = re.compile(r"^epic-(\d+)-retrospective$")
STORY_KEY_RE = re.compile(r"^(\d+)-(\d+)([a-z]?)-.+")
STORY_RANK = {"backlog": 0, "ready-for-dev": 1, "in-progress": 2, "review": 3, "done": 4}
EPIC_RANK = {"backlog": 0, "in-progress": 1, "done": 2}
RETRO_RANK = {"optional": 0, "done": 1}
RANKS = {"epic": EPIC_RANK, "story": STORY_RANK, "retro": RETRO_RANK}
ACTION_STATUSES = ("open", "in-progress", "done")
# v6 wrote these; they still exist in the wild (v6-shims/bmad-create-story
# actively writes 'contexted'). Normalized on every read so no subcommand ever
# treats a valid legacy file as illegal or resets its progress.
LEGACY_STATUS = {"drafted": "ready-for-dev", "contexted": "in-progress"}
STALE_DAYS_DEFAULT = 7
DATE_FORMAT = "%m-%d-%Y %H:%M"
# Hand-edited files drift toward ISO stamps; accept them rather than silently
# disabling the staleness check.
STAMP_FORMATS = (DATE_FORMAT, "%Y-%m-%d %H:%M", "%Y-%m-%d")
# Kept byte-identical (modulo the leading "# ") with the STATUS DEFINITIONS
# block in sprint-status-template.yaml; test_sprint_plan.py asserts the two
# never drift.
HEADER_COMMENT = """\
STATUS DEFINITIONS:
==================
Epic Status:
- backlog: Epic not yet started
- in-progress: Epic actively being worked on
- done: All stories in epic completed
Story Status:
- backlog: Story only exists in epic file
- ready-for-dev: Story file created, ready for development
- in-progress: Developer actively working on implementation
- review: Implementation complete, ready for review
- done: Story completed
Retrospective Status:
- optional: Can be completed but not required
- done: Retrospective has been completed
Action Item Status:
- open: Committed during a retrospective, not yet addressed
- in-progress: Actively being worked on
- done: Completed
WORKFLOW NOTES:
===============
- Epic transitions to 'in-progress' automatically when its first story starts (via build's sprint sync)
- Stories can be worked in parallel if team capacity allows
- Developer typically creates the next story after the previous one is 'done' to incorporate learnings
- Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
- Retrospective appends its action items to action_items; the status view surfaces open ones
"""
def _fail(message, **extra):
print(json.dumps({"ok": False, "error": message, **extra}, default=str))
sys.exit(1)
class JsonArgumentParser(argparse.ArgumentParser):
"""Emit argparse failures on the JSON-only stdout contract, not usage text.
Built with ``add_help=False`` everywhere: the built-in help action prints
plain usage to stdout with exit 0, which would break the machine consumer
this script serves. ``-h`` therefore routes through ``error()`` as an
ordinary unrecognized argument; the skill's SKILL.md carries the usage a
human needs.
"""
def error(self, message):
print(json.dumps({"ok": False, "error": f"argument error: {message}"}))
sys.exit(2)
def _slug(text, maxlen=60):
# Unicode-aware: a non-Latin title must keep its own characters in the key
# rather than every such story collapsing onto one shared placeholder.
slug = re.sub(r"[^\w]+", "-", str(text).lower(), flags=re.UNICODE).strip("-")
slug = slug[:maxlen].strip("-")
if not slug:
# Nothing sluggable (punctuation/emoji only): a short content hash keeps
# the key deterministic and distinct instead of a bare "untitled".
slug = hashlib.sha256(str(text).encode("utf-8")).hexdigest()[:8]
return slug
def classify_key(key):
"""Return (kind, epic_num) for a recognized key, else None."""
m = RETRO_KEY_RE.match(key)
if m:
return "retro", int(m.group(1))
m = EPIC_KEY_RE.match(key)
if m:
return "epic", int(m.group(1))
m = STORY_KEY_RE.match(key)
if m:
return "story", int(m.group(1))
return None
def _story_sort_key(key):
m = STORY_KEY_RE.match(key)
if not m:
return (10**9, 10**9, "", key)
return (int(m.group(1)), int(m.group(2)), m.group(3), key)
def _normalize(raw):
"""Map a raw status through the legacy vocabulary. Returns (status, was_legacy)."""
status = LEGACY_STATUS.get(raw, raw)
return status, raw in LEGACY_STATUS
def parse_epics(paths):
"""Return (entries, warnings). Entries are (key, kind, epic_num) in file order."""
epics = {} # epic_num -> [story keys in order]
warnings = []
for path in paths:
try:
lines = Path(path).read_text(encoding="utf-8").splitlines()
except OSError as exc:
_fail(f"cannot read epic file {path}: {exc}")
in_fence = False
for lineno, line in enumerate(lines, 1):
if FENCE_RE.match(line):
in_fence = not in_fence
continue
if in_fence:
continue
epic_m = EPIC_RE.match(line)
if epic_m:
epics.setdefault(int(epic_m.group(1)), [])
continue
story_m = STORY_RE.match(line)
if story_m:
epic_num = int(story_m.group(1))
story_num = story_m.group(2)
key = f"{epic_num}-{story_num}-{_slug(story_m.group(3))}"
stories = epics.setdefault(epic_num, [])
if key in stories:
warnings.append(f"duplicate story heading '{key}' at {path}:{lineno}")
else:
stories.append(key)
continue
if SUSPECT_RE.match(line):
warnings.append(f"unparsed Epic/Story-like heading at {path}:{lineno}: {line.strip()}")
entries = []
for epic_num in sorted(epics):
entries.append((f"epic-{epic_num}", "epic", epic_num))
for story_key in epics[epic_num]:
entries.append((story_key, "story", epic_num))
entries.append((f"epic-{epic_num}-retrospective", "retro", epic_num))
return entries, warnings
def _make_yaml():
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
# Pin the emitter to the indentation the sprint-status template ships with.
# Without this, ruamel re-dumps block sequences at its own default offset and
# every write silently de-indents pre-existing, untouched action_items.
yaml.indent(mapping=2, sequence=4, offset=2)
yaml.encoding = "utf-8"
return yaml
def _load_existing(path):
yaml = _make_yaml()
if not Path(path).exists():
return yaml, None
try:
with io.open(path, "r", encoding="utf-8") as fh:
data = yaml.load(fh)
except Exception as exc:
_fail(f"existing status file is not valid YAML: {exc}", status_file=str(path))
if data is not None and not isinstance(data, dict):
_fail(
f"existing status file is valid YAML but not a mapping (got {type(data).__name__})",
status_file=str(path),
)
return yaml, data
def _merge_status(kind, computed, existing_raw, key, warnings, report):
"""Return the higher-ranked of computed/existing; never downgrade."""
rank = RANKS[kind]
if existing_raw is None:
return computed
existing, was_legacy = _normalize(existing_raw)
if was_legacy:
report["legacy_mapped"].append({"key": key, "from": existing_raw, "to": existing})
if existing not in rank:
warnings.append(f"illegal status '{existing_raw}' on '{key}' replaced with '{computed}'")
report["illegal"].append({"key": key, "status": existing_raw})
return computed
return existing if rank[existing] >= rank[computed] else computed
def build_status(entries, existing_data, stories_dir, warnings):
"""Return (development_status CommentedMap, merge report dict)."""
existing_status = {}
if existing_data is not None:
existing_status = dict(existing_data.get("development_status") or {})
report = {
"new_entries": [],
"preserved": 0,
"changed": 0,
"upgraded_from_disk": [],
"dropped_orphans": [],
"legacy_mapped": [],
"illegal": [],
}
# One directory scan instead of a stat() per story.
story_files = set()
if stories_dir and Path(stories_dir).is_dir():
story_files = {p.name for p in Path(stories_dir).glob("*.md")}
dev = CommentedMap()
first_epic = True
for key, kind, _epic_num in entries:
default = {"epic": "backlog", "story": "backlog", "retro": "optional"}[kind]
computed = default
if kind == "story" and f"{key}.md" in story_files:
computed = "ready-for-dev"
merged = _merge_status(kind, computed, existing_status.get(key), key, warnings, report)
if key not in existing_status:
report["new_entries"].append(key)
elif merged == _normalize(existing_status[key])[0]:
report["preserved"] += 1
else:
report["changed"] += 1
if kind == "story" and computed == "ready-for-dev" and existing_status.get(key) in (None, "backlog"):
report["upgraded_from_disk"].append(key)
dev[key] = merged
if kind == "epic" and not first_epic:
dev.yaml_set_comment_before_after_key(key, before="\n")
if kind == "epic":
first_epic = False
computed_keys = {key for key, _, _ in entries}
# Old statuses ride along so the LLM can transplant them after a rename —
# the values would otherwise be destroyed by the write.
report["dropped_orphans"] = [
{"key": k, "status": existing_status[k]}
for k in existing_status
if k not in computed_keys
]
report["in_sync"] = (
not report["new_entries"]
and not report["dropped_orphans"]
and not report["illegal"]
and not report["legacy_mapped"]
and report["changed"] == 0
)
return dev, report
def _counts(dev):
counts = {}
for value in dev.values():
counts[value] = counts.get(value, 0) + 1
return counts
def _dump_bytes(yaml, doc):
"""Serialize before any file is touched, so a dump failure cannot leave a
partial file anywhere."""
buf = io.BytesIO()
yaml.dump(doc, buf)
return buf.getvalue()
def _atomic_write(path, payload, mode=None):
"""Replace ``path``'s contents with ``payload`` atomically.
Temp file alongside the target, fsynced, taking the target's permission
bits (mkstemp creates 0600, which would silently narrow the file), then
renamed over it. ``path`` is resolved through symlinks first: renaming onto
a symlink would detach the link and leave the real file stale.
"""
path = os.path.realpath(path)
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".sprint-status-", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
if mode is not None:
os.chmod(tmp, mode)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _parse_sets(pairs, valid_keys):
"""Validate --set key=status pairs against the generated plan and vocabulary."""
parsed = []
for pair in pairs:
key, sep, status = pair.partition("=")
if not sep or not key or not status:
_fail(f"--set expects key=status, got '{pair}'")
if key not in valid_keys:
_fail(f"--set key '{key}' is not in the generated plan", valid_keys=sorted(valid_keys))
kind, _ = classify_key(key)
if status not in RANKS[kind]:
_fail(
f"--set status '{status}' is not legal for {kind} '{key}'",
legal=sorted(RANKS[kind]),
)
parsed.append((key, status))
return parsed
def cmd_generate(args):
entries, warnings = parse_epics(args.epic_file)
if not entries:
_fail("no epics or stories parsed from the given epic files", epic_files=args.epic_file)
yaml, existing = _load_existing(args.status_file)
status_path = Path(args.status_file)
original_bytes = status_path.read_bytes() if status_path.exists() else None
original_mode = (os.stat(status_path).st_mode & 0o777) if status_path.exists() else None
merge_source = None if args.fresh else existing
dev, report = build_status(entries, merge_source, args.stories_dir, warnings)
# Explicit, user-confirmed statuses (the fix flow). Applied last: repair is
# the one path allowed to downgrade.
explicit = _parse_sets(args.set or [], set(dev.keys()))
for key, status in explicit:
dev[key] = status
report["explicit_set"] = [f"{k}={s}" for k, s in explicit]
def _meta(field, arg_value, default):
if arg_value is not None:
return arg_value
if existing is not None and existing.get(field):
return str(existing[field])
return default
generated = args.date
if existing is not None and existing.get("generated"):
generated = str(existing["generated"])
if existing is not None and not args.fresh:
# Round-trip the existing document: unknown top-level keys and their
# comments survive; only the managed fields and development_status are
# replaced.
doc = existing
else:
doc = CommentedMap()
doc.yaml_set_start_comment(HEADER_COMMENT)
doc["generated"] = generated
doc["last_updated"] = args.date
doc["project"] = args.project
doc["project_key"] = _meta("project_key", args.project_key, "NOKEY")
doc["tracking_system"] = _meta("tracking_system", args.tracking_system, "file-system")
doc["story_location"] = _meta("story_location", args.story_location, args.stories_dir)
doc["development_status"] = dev
if "action_items" not in doc and existing is not None and existing.get("action_items") is not None:
doc["action_items"] = existing["action_items"]
doc.yaml_set_comment_before_after_key(
"action_items",
before="\nAction items committed during retrospectives (section created by the retrospective workflow)",
)
result = {
"ok": True,
"action": "generate",
"status_file": str(args.status_file),
"dry_run": bool(args.dry_run),
"fresh": bool(args.fresh),
"epics": sum(1 for _, kind, _ in entries if kind == "epic"),
"stories": sum(1 for _, kind, _ in entries if kind == "story"),
"counts": _counts(dev),
"generated": generated,
"last_updated": args.date,
"warnings": warnings,
**report,
}
if args.dry_run:
print(json.dumps(result, default=str))
return
try:
payload = _dump_bytes(yaml, doc)
_atomic_write(args.status_file, payload, original_mode)
verify_yaml = _make_yaml()
with io.open(args.status_file, "r", encoding="utf-8") as fh:
reread = verify_yaml.load(fh)
if dict(reread.get("development_status") or {}) != {k: v for k, v in dev.items()}:
raise ValueError("development_status mismatch after write")
for field in ("generated", "last_updated", "project"):
if str(reread.get(field)) != str(doc[field]):
raise ValueError(f"{field} mismatch after write")
except Exception as exc:
if original_bytes is not None:
try:
_atomic_write(args.status_file, original_bytes, original_mode)
restored = True
except Exception:
restored = False
else:
Path(args.status_file).unlink(missing_ok=True)
restored = True
_fail(f"write or validation failed, original {'restored' if restored else 'NOT restored'}: {exc}",
restored=restored)
print(json.dumps(result, default=str))
def _parse_stamp(value):
from datetime import datetime
for fmt in STAMP_FORMATS:
try:
return datetime.strptime(str(value), fmt)
except ValueError:
continue
return None
def cmd_status(args):
from datetime import timedelta
_, data = _load_existing(args.status_file)
if data is None:
_fail("status file does not exist — run sprint planning to generate it",
status_file=str(args.status_file))
dev = dict(data.get("development_status") or {})
if not dev:
_fail("development_status missing or empty — re-run sprint planning",
status_file=str(args.status_file))
warnings = []
counts = {"story": {}, "epic": {}, "retro": {}}
by_status = {}
legacy_mapped, illegal, unrecognized = [], [], []
epic_nums, story_epic_nums = set(), set()
epic_status, retro_status = {}, {}
for key, raw in dev.items():
key = str(key)
parsed = classify_key(key)
if parsed is None:
unrecognized.append({"key": key, "status": raw})
continue
kind, epic_num = parsed
status, was_legacy = _normalize(raw)
if was_legacy:
legacy_mapped.append({"key": key, "from": raw, "to": status})
if status not in RANKS[kind]:
illegal.append({"key": key, "status": raw})
continue
counts[kind][status] = counts[kind].get(status, 0) + 1
if kind == "story":
by_status.setdefault(status, []).append(key)
story_epic_nums.add(epic_num)
elif kind == "epic":
epic_nums.add(epic_num)
epic_status[epic_num] = status
else:
retro_status[epic_num] = status
for stories in by_status.values():
stories.sort(key=_story_sort_key)
action_items = data.get("action_items") or []
open_items = []
for i, item in enumerate(action_items):
if not isinstance(item, dict):
warnings.append(f"action_items[{i}] is not a mapping and was skipped: {item!r}")
continue
status = item.get("status")
if status not in ACTION_STATUSES:
warnings.append(f"action_items[{i}] has a missing or unknown status ({status!r})")
continue
if status in ("open", "in-progress"):
open_items.append({k: item.get(k) for k in ("epic", "action", "owner", "status")})
risks = []
stamp = data.get("last_updated") or data.get("generated")
if args.date and stamp:
now, then = _parse_stamp(args.date), _parse_stamp(stamp)
if now is None or then is None:
warnings.append(
f"timestamp format not recognized (--date {args.date!r}, file {stamp!r}); "
"staleness check skipped"
)
elif now - then > timedelta(days=args.stale_days):
risks.append(f"sprint-status.yaml may be stale (last updated {stamp})")
for stories in by_status.values():
for key in stories:
num = classify_key(key)[1]
if num not in epic_nums:
risks.append(f"orphaned story '{key}' has no epic-{num} entry")
for num, status in epic_status.items():
if status == "in-progress" and num not in story_epic_nums:
risks.append(f"in-progress epic 'epic-{num}' has no stories")
if by_status.get("review"):
risks.append(f"{len(by_status['review'])} story(ies) in review — run bmad-code-review")
if unrecognized:
risks.append(f"{len(unrecognized)} unrecognized key(s) in development_status — run validate")
recommendation = None
if by_status.get("in-progress"):
recommendation = {"skill": "bmad-build", "story_key": by_status["in-progress"][0],
"reason": "resume the in-progress story"}
elif by_status.get("review"):
recommendation = {"skill": "bmad-code-review", "story_key": by_status["review"][0],
"reason": "review the completed implementation"}
elif by_status.get("ready-for-dev"):
recommendation = {"skill": "bmad-build", "story_key": by_status["ready-for-dev"][0],
"reason": "start the next ready story"}
elif by_status.get("backlog"):
recommendation = {"skill": "bmad-build", "story_key": by_status["backlog"][0],
"reason": "start the first backlog story"}
else:
optional_retros = sorted(num for num, status in retro_status.items() if status == "optional")
if optional_retros:
recommendation = {"skill": "bmad-retrospective", "story_key": None,
"reason": f"all stories done — epic-{optional_retros[0]}-retrospective is still open"}
print(json.dumps({
"ok": True, "action": "status", "status_file": str(args.status_file),
"project": data.get("project"), "project_key": data.get("project_key"),
"tracking_system": data.get("tracking_system"),
"generated": data.get("generated"), "last_updated": data.get("last_updated"),
"stories": counts["story"], "epics": counts["epic"], "retrospectives": counts["retro"],
"legacy_mapped": legacy_mapped, "illegal": illegal, "unrecognized": unrecognized,
"open_action_items": open_items, "risks": risks, "warnings": warnings,
"recommendation": recommendation,
"all_done": recommendation is None,
}, default=str))
def cmd_validate(args):
problems = []
legacy_mapped = []
path = Path(args.status_file)
if not path.exists():
print(json.dumps({
"ok": True, "action": "validate", "status_file": str(args.status_file),
"valid": False, "problems": ["status file does not exist"], "legacy_mapped": [],
}))
return
yaml = _make_yaml()
try:
with io.open(args.status_file, "r", encoding="utf-8") as fh:
data = yaml.load(fh)
except Exception as exc:
print(json.dumps({
"ok": True, "action": "validate", "status_file": str(args.status_file),
"valid": False, "problems": [f"not valid YAML: {exc}"], "legacy_mapped": [],
}, default=str))
return
if not isinstance(data, dict):
problems.append(f"top level is not a mapping (got {type(data).__name__})")
else:
for field in ("generated", "last_updated", "project", "development_status"):
if data.get(field) is None:
problems.append(f"missing required key '{field}'")
for field in ("generated", "last_updated"):
value = data.get(field)
if value is not None and _parse_stamp(value) is None:
problems.append(f"'{field}' timestamp {str(value)!r} does not match '{DATE_FORMAT}'")
dev = data.get("development_status")
if dev is not None and not isinstance(dev, dict):
problems.append("development_status is not a mapping")
elif dev:
for key, raw in dev.items():
parsed = classify_key(str(key))
if parsed is None:
problems.append(f"unrecognized key '{key}' (expected epic-N, N-M-slug, or epic-N-retrospective)")
continue
kind, _ = parsed
status, was_legacy = _normalize(raw)
if was_legacy:
legacy_mapped.append({"key": str(key), "from": raw, "to": status})
if status not in RANKS[kind]:
problems.append(f"illegal {kind} status {str(raw)!r} on '{key}'")
elif isinstance(data.get("development_status"), dict):
problems.append("development_status is empty")
items = data.get("action_items")
if items is not None:
if not isinstance(items, list):
problems.append("action_items is not a list")
else:
for i, item in enumerate(items):
if not isinstance(item, dict):
problems.append(f"action_items[{i}] is not a mapping")
elif item.get("status") not in ACTION_STATUSES:
problems.append(
f"action_items[{i}] has a missing or unknown status ({item.get('status')!r})"
)
print(json.dumps({
"ok": True, "action": "validate", "status_file": str(args.status_file),
"valid": not problems, "problems": problems, "legacy_mapped": legacy_mapped,
}, default=str))
def build_parser():
parser = JsonArgumentParser(prog="sprint_plan.py", add_help=False)
sub = parser.add_subparsers(dest="command", required=True, parser_class=JsonArgumentParser)
gen = sub.add_parser("generate", add_help=False)
gen.add_argument("--epic-file", action="append", required=True)
gen.add_argument("--status-file", required=True)
gen.add_argument("--stories-dir", required=True)
gen.add_argument("--project", required=True)
gen.add_argument("--date", required=True)
gen.add_argument("--project-key", default=None)
gen.add_argument("--tracking-system", default=None)
gen.add_argument("--story-location", default=None)
gen.add_argument("--dry-run", action="store_true")
gen.add_argument("--fresh", action="store_true")
gen.add_argument("--set", action="append", metavar="KEY=STATUS")
gen.set_defaults(func=cmd_generate)
st = sub.add_parser("status", add_help=False)
st.add_argument("--status-file", required=True)
st.add_argument("--date", default=None)
st.add_argument("--stale-days", type=int, default=STALE_DAYS_DEFAULT)
st.set_defaults(func=cmd_status)
val = sub.add_parser("validate", add_help=False)
val.add_argument("--status-file", required=True)
val.set_defaults(func=cmd_validate)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
args.func(args)
if __name__ == "__main__":
main()
@@ -0,0 +1,524 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=8.0", "ruamel.yaml>=0.18"]
# ///
"""Tests for sprint_plan.py — deterministic sprint-status generation.
Run: uv run scripts/tests/test_sprint_plan.py
or: uv run --with pytest --with ruamel.yaml -m pytest scripts/tests/test_sprint_plan.py
"""
import importlib.util
import json
import sys
from pathlib import Path
import pytest
from ruamel.yaml import YAML
SCRIPT = Path(__file__).resolve().parents[1] / "sprint_plan.py"
TEMPLATE = Path(__file__).resolve().parents[2] / "sprint-status-template.yaml"
spec = importlib.util.spec_from_file_location("sprint_plan", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
EPICS_FIXTURE = """\
# Project Epics
## Epic 1: Foundation
Some prose.
### Story 1.1: User Authentication
Acceptance criteria...
### Story 1.2: Account Management
## Epic 2: Chat
### Story 2.1: Personality System
### Story 2.6a: Split Story, With Punctuation!
"""
DATE = "08-01-2026 14:30"
def run_generate(tmp_path, epics_text=EPICS_FIXTURE, existing=None, stories=(), extra=()):
epic_file = tmp_path / "epics.md"
epic_file.write_text(epics_text, encoding="utf-8")
status_file = tmp_path / "impl" / "sprint-status.yaml"
if existing is not None:
status_file.parent.mkdir(parents=True, exist_ok=True)
status_file.write_text(existing, encoding="utf-8")
stories_dir = tmp_path / "impl"
stories_dir.mkdir(parents=True, exist_ok=True)
for name in stories:
(stories_dir / f"{name}.md").write_text("story", encoding="utf-8")
argv = [
"generate", "--epic-file", str(epic_file), "--status-file", str(status_file),
"--stories-dir", str(stories_dir), "--project", "My Project", "--date", DATE,
*extra,
]
mod.main(argv)
return status_file
def load(status_file):
yaml = YAML()
with open(status_file, encoding="utf-8") as fh:
return yaml.load(fh)
def out_json(capsys):
return json.loads(capsys.readouterr().out)
def test_fresh_generate_orders_and_defaults(tmp_path, capsys):
status_file = run_generate(tmp_path)
result = out_json(capsys)
data = load(status_file)
keys = list(data["development_status"].keys())
assert keys == [
"epic-1", "1-1-user-authentication", "1-2-account-management", "epic-1-retrospective",
"epic-2", "2-1-personality-system", "2-6a-split-story-with-punctuation", "epic-2-retrospective",
]
assert data["development_status"]["epic-1"] == "backlog"
assert data["development_status"]["1-1-user-authentication"] == "backlog"
assert data["development_status"]["epic-1-retrospective"] == "optional"
assert data["project"] == "My Project"
assert data["generated"] == DATE and data["last_updated"] == DATE
assert result["ok"] and result["epics"] == 2 and result["stories"] == 4
text = status_file.read_text(encoding="utf-8")
assert "STATUS DEFINITIONS" in text
assert "\n\n epic-2:" in text # blank line between epic groups
def test_header_comment_matches_template():
"""The template's STATUS DEFINITIONS block and the script's HEADER_COMMENT
are two copies of one contract; this pins them together."""
lines = TEMPLATE.read_text(encoding="utf-8").splitlines()
start = lines.index("# STATUS DEFINITIONS:")
block = []
for line in lines[start:]:
if not line.startswith("#"):
break
block.append(line[2:] if line.startswith("# ") else line[1:])
assert "\n".join(block) + "\n" == mod.HEADER_COMMENT
EXISTING = """\
generated: 01-01-2026 09:00
last_updated: 01-01-2026 09:00
project: My Project
project_key: NOKEY
tracking_system: file-system
story_location: impl
development_status:
epic-1: in-progress
1-1-user-authentication: done
1-2-account-management: backlog
epic-1-retrospective: optional
9-9-ghost-story: done
action_items:
- epic: 1
action: "Add error-handling review; watch: quotes, commas"
owner: "Charlie"
status: open
"""
def test_merge_preserves_and_never_downgrades(tmp_path, capsys):
status_file = run_generate(tmp_path, existing=EXISTING)
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["epic-1"] == "in-progress"
assert data["development_status"]["1-1-user-authentication"] == "done"
assert data["generated"] == "01-01-2026 09:00"
assert data["last_updated"] == DATE
assert result["dropped_orphans"] == [{"key": "9-9-ghost-story", "status": "done"}]
assert "9-9-ghost-story" not in data["development_status"]
def test_legacy_statuses_merge_by_meaning_not_reset(tmp_path, capsys):
existing = (EXISTING
.replace("1-2-account-management: backlog", "1-2-account-management: drafted")
.replace("epic-1: in-progress", "epic-1: contexted"))
status_file = run_generate(tmp_path, existing=existing)
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-2-account-management"] == "ready-for-dev"
assert data["development_status"]["epic-1"] == "in-progress"
assert {"key": "1-2-account-management", "from": "drafted", "to": "ready-for-dev"} in result["legacy_mapped"]
assert not any("illegal" in w for w in result["warnings"])
assert result["illegal"] == []
def test_metadata_preserved_when_flags_omitted(tmp_path, capsys):
existing = (EXISTING
.replace("project_key: NOKEY", "project_key: JIRA-PROJ")
.replace("tracking_system: file-system", "tracking_system: jira")
.replace("story_location: impl", "story_location: /custom/stories"))
status_file = run_generate(tmp_path, existing=existing)
out_json(capsys)
data = load(status_file)
assert data["project_key"] == "JIRA-PROJ"
assert data["tracking_system"] == "jira"
assert data["story_location"] == "/custom/stories"
def test_unknown_keys_and_comments_survive_regenerate(tmp_path, capsys):
existing = EXISTING + "\n# my own note\nsprint_goal: Ship the beta\n"
status_file = run_generate(tmp_path, existing=existing)
out_json(capsys)
data = load(status_file)
assert data["sprint_goal"] == "Ship the beta"
assert "# my own note" in status_file.read_text(encoding="utf-8")
def test_fresh_rebuild_ignores_existing_statuses(tmp_path, capsys):
status_file = run_generate(tmp_path, existing=EXISTING, extra=("--fresh",))
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-1-user-authentication"] == "backlog"
assert data["development_status"]["epic-1"] == "backlog"
assert result["fresh"] is True
# action_items are retro history, not tracking state — carried even on --fresh
assert data["action_items"][0]["status"] == "open"
def test_set_applies_explicit_statuses_even_downgrades(tmp_path, capsys):
status_file = run_generate(
tmp_path, existing=EXISTING,
extra=("--fresh", "--set", "1-1-user-authentication=in-progress", "--set", "epic-1=in-progress"),
)
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-1-user-authentication"] == "in-progress"
assert data["development_status"]["epic-1"] == "in-progress"
assert "1-1-user-authentication=in-progress" in result["explicit_set"]
def test_set_rejects_unknown_key_and_illegal_status(tmp_path, capsys):
with pytest.raises(SystemExit) as excinfo:
run_generate(tmp_path, extra=("--set", "9-9-nope=done"))
assert excinfo.value.code == 1
assert out_json(capsys)["ok"] is False
with pytest.raises(SystemExit):
run_generate(tmp_path, extra=("--set", "epic-1=review"))
assert out_json(capsys)["ok"] is False
def test_action_items_carried_verbatim(tmp_path):
status_file = run_generate(tmp_path, existing=EXISTING)
data = load(status_file)
assert data["action_items"][0]["action"] == "Add error-handling review; watch: quotes, commas"
assert data["action_items"][0]["status"] == "open"
def test_story_file_on_disk_floors_ready_for_dev(tmp_path, capsys):
status_file = run_generate(tmp_path, stories=["1-2-account-management"])
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-2-account-management"] == "ready-for-dev"
assert data["development_status"]["1-1-user-authentication"] == "backlog"
assert result["upgraded_from_disk"] == ["1-2-account-management"]
def test_story_file_never_downgrades_done(tmp_path):
status_file = run_generate(tmp_path, existing=EXISTING, stories=["1-1-user-authentication"])
data = load(status_file)
assert data["development_status"]["1-1-user-authentication"] == "done"
def test_illegal_existing_status_warns_and_resets(tmp_path, capsys):
existing = EXISTING.replace("1-2-account-management: backlog", "1-2-account-management: shipped")
status_file = run_generate(tmp_path, existing=existing)
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-2-account-management"] == "backlog"
assert any("illegal status 'shipped'" in w for w in result["warnings"])
assert {"key": "1-2-account-management", "status": "shipped"} in result["illegal"]
def test_fenced_code_blocks_are_not_parsed(tmp_path, capsys):
text = EPICS_FIXTURE + "\n```\n## Epic 9: Example Format\n### Story 9.1: Sample\n```\n"
status_file = run_generate(tmp_path, epics_text=text)
result = out_json(capsys)
data = load(status_file)
assert "epic-9" not in data["development_status"]
assert result["epics"] == 2
assert not any("Epic 9" in w for w in result["warnings"])
def test_non_ascii_titles_keep_distinct_keys(tmp_path, capsys):
text = "## Epic 1: 基础\n### Story 1.1: 用户认证\n### Story 1.2: 账户管理\n"
status_file = run_generate(tmp_path, epics_text=text)
out_json(capsys)
keys = list(load(status_file)["development_status"].keys())
assert "1-1-用户认证" in keys and "1-2-账户管理" in keys
def test_suspect_heading_is_reported(tmp_path, capsys):
text = EPICS_FIXTURE + "\n### Story Two point one: Bad Format\n"
run_generate(tmp_path, epics_text=text)
result = out_json(capsys)
assert any("unparsed Epic/Story-like heading" in w for w in result["warnings"])
def test_dry_run_writes_nothing_and_reports_drift(tmp_path, capsys):
epic_file = tmp_path / "epics.md"
epic_file.write_text(EPICS_FIXTURE, encoding="utf-8")
status_file = tmp_path / "sprint-status.yaml"
status_file.write_text(EXISTING, encoding="utf-8")
original = status_file.read_bytes()
mod.main([
"generate", "--epic-file", str(epic_file), "--status-file", str(status_file),
"--stories-dir", str(tmp_path), "--project", "P", "--date", DATE, "--dry-run",
])
result = out_json(capsys)
assert result["dry_run"] is True and result["ok"] is True
assert result["in_sync"] is False
assert "epic-2" in result["new_entries"]
assert result["dropped_orphans"] == [{"key": "9-9-ghost-story", "status": "done"}]
assert status_file.read_bytes() == original
def test_dry_run_in_sync_after_generate(tmp_path, capsys):
status_file = run_generate(tmp_path)
capsys.readouterr()
mod.main([
"generate", "--epic-file", str(tmp_path / "epics.md"), "--status-file", str(status_file),
"--stories-dir", str(tmp_path / "impl"), "--project", "My Project", "--date", DATE,
"--dry-run",
])
result = out_json(capsys)
assert result["in_sync"] is True
assert result["new_entries"] == [] and result["dropped_orphans"] == [] and result["illegal"] == []
def test_no_epics_fails_with_json(tmp_path, capsys):
epic_file = tmp_path / "notes.md"
epic_file.write_text("just prose, no epics", encoding="utf-8")
with pytest.raises(SystemExit) as excinfo:
mod.main([
"generate", "--epic-file", str(epic_file), "--status-file", str(tmp_path / "s.yaml"),
"--stories-dir", str(tmp_path), "--project", "P", "--date", DATE,
])
assert excinfo.value.code == 1
assert out_json(capsys)["ok"] is False
def test_non_mapping_yaml_fails_with_json(tmp_path, capsys):
epic_file = tmp_path / "epics.md"
epic_file.write_text(EPICS_FIXTURE, encoding="utf-8")
status_file = tmp_path / "sprint-status.yaml"
status_file.write_text("- just\n- a\n- list\n", encoding="utf-8")
with pytest.raises(SystemExit) as excinfo:
mod.main([
"generate", "--epic-file", str(epic_file), "--status-file", str(status_file),
"--stories-dir", str(tmp_path), "--project", "P", "--date", DATE,
])
assert excinfo.value.code == 1
assert "not a mapping" in out_json(capsys)["error"]
with pytest.raises(SystemExit):
mod.main(["status", "--status-file", str(status_file)])
assert "not a mapping" in out_json(capsys)["error"]
def test_argument_errors_emit_json(capsys):
with pytest.raises(SystemExit) as excinfo:
mod.main(["generate"])
assert excinfo.value.code == 2
assert out_json(capsys)["ok"] is False
with pytest.raises(SystemExit) as excinfo:
mod.main(["-h"])
assert excinfo.value.code == 2
assert out_json(capsys)["ok"] is False
STATUS_FIXTURE = """\
generated: 01-01-2026 09:00
last_updated: 07-30-2026 09:00
project: My Project
project_key: NOKEY
tracking_system: file-system
story_location: impl
development_status:
epic-1: in-progress
1-1-user-authentication: done
1-2-account-management: drafted
epic-1-retrospective: optional
epic-2: backlog
2-1-personality-system: backlog
epic-2-retrospective: optional
action_items:
- epic: 1
action: "Tighten error handling"
owner: "Charlie"
status: open
- epic: 1
action: "Old item"
owner: "Charlie"
status: done
"""
def run_status(tmp_path, capsys, fixture=STATUS_FIXTURE, extra=()):
status_file = tmp_path / "sprint-status.yaml"
status_file.write_text(fixture, encoding="utf-8")
mod.main(["status", "--status-file", str(status_file), "--date", DATE, *extra])
return json.loads(capsys.readouterr().out)
def test_status_counts_and_recommendation(tmp_path, capsys):
result = run_status(tmp_path, capsys)
assert result["stories"] == {"done": 1, "ready-for-dev": 1, "backlog": 1}
assert result["epics"] == {"in-progress": 1, "backlog": 1}
assert result["retrospectives"] == {"optional": 2}
assert result["recommendation"]["skill"] == "bmad-build"
assert result["recommendation"]["story_key"] == "1-2-account-management"
assert result["all_done"] is False
def test_status_maps_legacy_values(tmp_path, capsys):
result = run_status(tmp_path, capsys)
assert {"key": "1-2-account-management", "from": "drafted", "to": "ready-for-dev"} in result["legacy_mapped"]
def test_status_open_action_items(tmp_path, capsys):
result = run_status(tmp_path, capsys)
assert len(result["open_action_items"]) == 1
assert result["open_action_items"][0]["action"] == "Tighten error handling"
def test_status_malformed_action_items_are_flagged_not_dropped(tmp_path, capsys):
fixture = STATUS_FIXTURE + " - \"just a string\"\n - epic: 2\n action: \"No status\"\n"
result = run_status(tmp_path, capsys, fixture=fixture)
assert any("not a mapping" in w for w in result["warnings"])
assert any("missing or unknown status" in w for w in result["warnings"])
assert len(result["open_action_items"]) == 1
def test_status_review_beats_ready(tmp_path, capsys):
fixture = STATUS_FIXTURE.replace("2-1-personality-system: backlog", "2-1-personality-system: review")
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["recommendation"]["skill"] == "bmad-code-review"
assert result["recommendation"]["story_key"] == "2-1-personality-system"
assert any("review" in r for r in result["risks"])
def test_status_in_progress_beats_all(tmp_path, capsys):
fixture = STATUS_FIXTURE.replace("2-1-personality-system: backlog", "2-1-personality-system: in-progress")
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["recommendation"]["skill"] == "bmad-build"
assert result["recommendation"]["story_key"] == "2-1-personality-system"
assert result["recommendation"]["reason"] == "resume the in-progress story"
def test_status_staleness_and_orphan_risks(tmp_path, capsys):
fixture = (STATUS_FIXTURE
.replace("last_updated: 07-30-2026 09:00", "last_updated: 01-02-2026 09:00")
.replace(" epic-2-retrospective: optional",
" epic-2-retrospective: optional\n 5-1-ghost: backlog"))
result = run_status(tmp_path, capsys, fixture=fixture)
assert any("stale" in r for r in result["risks"])
assert any("orphaned story '5-1-ghost'" in r for r in result["risks"])
def test_status_unparseable_timestamp_warns_instead_of_silence(tmp_path, capsys):
fixture = STATUS_FIXTURE.replace("last_updated: 07-30-2026 09:00", "last_updated: whenever")
result = run_status(tmp_path, capsys, fixture=fixture)
assert any("staleness check skipped" in w for w in result["warnings"])
def test_status_iso_and_date_typed_stamps_do_not_crash(tmp_path, capsys):
fixture = (STATUS_FIXTURE
.replace("generated: 01-01-2026 09:00", "generated: 2026-01-01")
.replace("last_updated: 07-30-2026 09:00", "last_updated: 2026-01-02"))
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["ok"] is True
assert result["generated"] == "2026-01-01"
assert any("stale" in r for r in result["risks"]) # ISO stamp still parses
def test_status_all_done_recommends_retro_then_nothing(tmp_path, capsys):
fixture = (STATUS_FIXTURE
.replace("1-2-account-management: drafted", "1-2-account-management: done")
.replace("2-1-personality-system: backlog", "2-1-personality-system: done"))
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["recommendation"]["skill"] == "bmad-retrospective"
assert "epic-1-retrospective" in result["recommendation"]["reason"]
fixture_done = fixture.replace("epic-1-retrospective: optional", "epic-1-retrospective: done") \
.replace("epic-2-retrospective: optional", "epic-2-retrospective: done")
result = run_status(tmp_path, capsys, fixture=fixture_done)
assert result["all_done"] is True and result["recommendation"] is None
def test_status_odd_retro_key_reports_instead_of_crashing(tmp_path, capsys):
fixture = (STATUS_FIXTURE
.replace("1-2-account-management: drafted", "1-2-account-management: done")
.replace("2-1-personality-system: backlog", "2-1-personality-system: done")
.replace("epic-1-retrospective: optional", "epic-1-retrospective: done")
.replace("epic-2-retrospective: optional",
"epic-2-retrospective: done\n epic-abc-retrospective: optional"))
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["ok"] is True
assert {"key": "epic-abc-retrospective", "status": "optional"} in result["unrecognized"]
assert any("unrecognized key" in r for r in result["risks"])
def test_status_illegal_status_reported(tmp_path, capsys):
fixture = STATUS_FIXTURE.replace("2-1-personality-system: backlog", "2-1-personality-system: shipped")
result = run_status(tmp_path, capsys, fixture=fixture)
assert {"key": "2-1-personality-system", "status": "shipped"} in result["illegal"]
def test_status_missing_file_fails_json(tmp_path, capsys):
with pytest.raises(SystemExit) as excinfo:
mod.main(["status", "--status-file", str(tmp_path / "nope.yaml")])
assert excinfo.value.code == 1
assert json.loads(capsys.readouterr().out)["ok"] is False
def run_validate(tmp_path, capsys, content):
status_file = tmp_path / "sprint-status.yaml"
if content is not None:
status_file.write_text(content, encoding="utf-8")
mod.main(["validate", "--status-file", str(status_file)])
return json.loads(capsys.readouterr().out)
def test_validate_clean_file(tmp_path, capsys):
clean = STATUS_FIXTURE.replace("1-2-account-management: drafted", "1-2-account-management: backlog")
result = run_validate(tmp_path, capsys, clean)
assert result["valid"] is True and result["problems"] == []
def test_validate_reports_problems_without_crashing(tmp_path, capsys):
broken = (STATUS_FIXTURE
.replace("2-1-personality-system: backlog", "2-1-personality-system: shipped")
.replace("epic-2-retrospective: optional",
"epic-2-retrospective: optional\n weird-key: done")
.replace("last_updated: 07-30-2026 09:00", "last_updated: whenever"))
result = run_validate(tmp_path, capsys, broken)
assert result["valid"] is False
assert any("illegal story status 'shipped'" in p for p in result["problems"])
assert any("unrecognized key 'weird-key'" in p for p in result["problems"])
assert any("'last_updated' timestamp" in p for p in result["problems"])
assert {"key": "1-2-account-management", "from": "drafted", "to": "ready-for-dev"} in result["legacy_mapped"]
def test_validate_missing_file_and_bad_yaml(tmp_path, capsys):
result = run_validate(tmp_path, capsys, None)
assert result["valid"] is False and "does not exist" in result["problems"][0]
result = run_validate(tmp_path, capsys, "development_status: [unclosed\n")
assert result["valid"] is False and "not valid YAML" in result["problems"][0]
result = run_validate(tmp_path, capsys, "- a\n- b\n")
assert result["valid"] is False and any("not a mapping" in p for p in result["problems"])
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))