customize.toml
# DO NOT EDIT -- overwritten on every update.
#
# Workflow customization surface for bmad-sprint-planning. Mirrors the
# agent customization shape under the [workflow] namespace.
[workflow]
# --- Configurable below. Overrides merge per BMad structural rules: ---
# scalars: override wins • arrays (persistent_facts, activation_steps_*): append
# arrays-of-tables with `code`/`id`: replace matching items, append new ones.
# Steps to run before the standard activation (config load, greet).
# Overrides append. Use for pre-flight loads, compliance checks, etc.
activation_steps_prepend = []
# Steps to run after greet but before the workflow begins.
# Overrides append. Use for context-heavy setup that should happen
# once the user has been acknowledged.
activation_steps_append = []
# Persistent facts the workflow keeps in mind for the whole run
# (standards, compliance constraints, stylistic guardrails).
# Distinct from the runtime memory sidecar — these are static context
# loaded on activation. Overrides append.
#
# Each entry is either:
# - a literal sentence, e.g. "All stories must include testable acceptance criteria."
# - a file reference prefixed with `file:`, e.g. "file:{project-root}/docs/standards.md"
# (glob patterns are supported; the file's contents are loaded and treated as facts).
persistent_facts = []
# Scalar: executed when the workflow reaches its final step,
# after sprint-status.yaml is generated and validated. Override wins.
# Leave empty for no custom post-completion behavior.
on_complete = ""
module-manifest.toml
module = "method"
version = "6.13.0-next"
update_source = "github:bmad-code-org/BMAD-METHOD/skills"
knowledge = "`references/help.md` in the `bmad` skill"
references/fix-sprint-status.md
# Fix Sprint Status
Rebuild `sprint-status.yaml` to a pristine, script-valid state when it is broken, hand-mangled, drifted from reality, or the user simply asks to fix it. Inference determines what the state *should* be; the user confirms it; the script writes it. Never write without the confirmation.
1. **Scope the damage.** Run `sprint_plan.py validate` and share what it found. If even the epic files are missing or unparseable, say so — there is nothing to rebuild tracking against until planning artifacts exist.
2. **Determine the true state by inference.** This is judgment work — fan out subagents in parallel, each gathering one kind of evidence, and have each return proposed `key=status` pairs with the evidence behind them:
- **Epics** — read the epic files in `{planning_artifacts}`: the authoritative work breakdown (which epics, stories, and retrospectives should exist at all)
- **Story files** — scan `{implementation_artifacts}`: which stories have files on disk, and what their content says about progress (acceptance criteria checked off, completion notes, review sections)
- **Code evidence** — git history and the codebase: commits or merged work referencing story keys are evidence a story is done or in progress
- **The current file** — salvage everything credible from the existing `sprint-status.yaml`, especially `action_items`, even when its structure is broken
3. **Reconcile into one proposed state.** Merge the evidence into a single table: key → proposed status, evidence, and anything uncertain. When evidence conflicts or is thin, prefer the lower status and flag it — a false `done` costs more than a false `in-progress`.
4. **Confirm with the user.** Show the table. Highlight every entry that differs from the current file — especially downgrades — and every low-confidence call. Adjust to their corrections. Headless: halt with `blocked` instead of confirming.
5. **Write pristine.** One command, from the confirmed table:
```
uv run {skill-root}/scripts/sprint_plan.py generate \
--epic-file <path> [...] \
--status-file {implementation_artifacts}/sprint-status.yaml \
--stories-dir {implementation_artifacts} \
--project "{project_name}" --date "{date}" \
--fresh --set <key>=<status> [--set <key>=<status> ...]
```
`--fresh` rebuilds the document cleanly (canonical vocabulary, standard header) while still carrying `action_items`; `--set` applies the confirmed statuses and is the one path allowed to downgrade. Only confirmed entries that differ from the fresh defaults need a `--set`.
6. **Verify.** Run `validate` again (expect `valid: true`) and present the status view summary so the user sees the repaired state.
references/generate-tracking.md
# Generate Tracking
Discovery is your call; everything after it is the script's.
1. Identify the epic files. The gate inventory already surfaced them — typically `epics.md`, `epic-*.md`, or a sharded `epics/` folder in `{planning_artifacts}`, but trust content over filename. If both a whole document and a sharded version exist, ask which is current rather than guessing.
2. Run the script, passing every epic file:
```
uv run {skill-root}/scripts/sprint_plan.py generate \
--epic-file <path> [--epic-file <path> ...] \
--status-file {implementation_artifacts}/sprint-status.yaml \
--stories-dir {implementation_artifacts} \
--project "{project_name}" --date "{date}"
```
`{date}` must be `MM-DD-YYYY HH:MM` — the format the staleness check parses. The script owns parsing (`## Epic N:` / `### Story N.M: Title` → kebab-case keys; fenced code blocks ignored), ordering (epic, its stories, its retrospective), merging with any existing file (preserve advanced statuses, never downgrade; legacy v6 values like `drafted`/`contexted` are normalized to their modern meaning, never reset; `action_items`, custom keys, and user comments carried through; `project_key`/`tracking_system`/`story_location` kept from the existing file unless overridden by flag), story-file detection (a story file on disk floors its status at `ready-for-dev`), atomic writes, and post-write validation. It prints a JSON report. Add `--dry-run` to preview — the report's `in_sync`, `new_entries`, `dropped_orphans`, `illegal`, and `legacy_mapped` fields answer "is tracking in sync?" without writing.
3. Read the JSON report and act on it — this is where judgment re-enters:
- `warnings` about unparsed Epic/Story-like headings mean the epic file deviates from the standard format. Show the user, fix the headings together (or accept the omission), and rerun.
- `dropped_orphans` are entries that existed in the old status file but match nothing in the epics — usually renames. Each carries its old status; reconcile with the user, then transplant by rerunning with `--set <new-key>=<old-status>`.
- If the epics defeat the parser entirely (a format the regexes can't see), fall back to building the file yourself against `sprint-status-template.yaml`, and tell the user the deterministic path didn't apply.
## Report
Present the result from the script's JSON: file path, epic/story counts, status breakdown, anything upgraded from disk. Suggest next steps — review the file, `bmad-build` to start the first story, rerun this skill anytime to refresh after epics change.
references/readiness-gate.md
# Readiness Gate
Before generating any tracking, judge whether the plan can actually be built. If the user only asked to check readiness, this gate is the deliverable — report the verdict and stop.
Inventory what planning actually exists: scan `{planning_artifacts}` and `{project_knowledge}` for intent and planning artifacts — briefs, PRFAQs, PRDs, specs, UX outputs, architecture, epics and stories. Identify documents by reading what they are, not by filename patterns; projects arrive with different artifact mixes and naming.
Assess the plan as a whole against one question: **could a developer implement these epics without inventing decisions nothing records?**
- Requirements and decisions in the intent artifacts trace forward into stories; stories trace back to recorded intent — flag orphans in both directions
- Epics deliver user value and carry no forward dependencies; stories are independently completable
- Architecture and UX decisions the stories rely on are recorded somewhere, not assumed
- Conflicts between artifacts (a spec and an epic disagreeing) are surfaced, not silently resolved
A missing document type is only a finding if stories depend on decisions nothing records — a project with no UX artifact and no UI stories is fine.
Deliver a verdict:
- **PASS** — state it in one line; for the full sprint-planning intent, continue with `generate-tracking.md`
- **CONCERNS** — list them briefly with where each gap lives; ask the user whether to proceed anyway or fix first
- **FAIL** — the plan is not implementable as recorded. Present findings ordered by severity, name the skill that fixes each (the relevant plan skill, or `bmad-correct-course` for cross-cutting changes), offer to save the findings to `{planning_artifacts}/implementation-readiness.md`, and stop
references/status-view.md
# Status View
When the user wants to know where the sprint stands ("show sprint status", "where are we"), run:
```
uv run {skill-root}/scripts/sprint_plan.py status \
--status-file {implementation_artifacts}/sprint-status.yaml --date "{date}"
```
`{date}` is `MM-DD-YYYY HH:MM`. The script computes everything: counts by status (legacy values like `drafted` mapped transparently and reported in `legacy_mapped`), risk flags (stale file, orphaned stories, in-progress epics without stories, stories waiting in review, unrecognized keys), open action items from retrospectives, and the next recommended action by fixed priority — resume in-progress → review what's in review → start the next ready or backlog story → run an open retrospective → all done. If the file is missing, the script says so — offer to run sprint planning to create it.
Render the JSON as a compact summary: counts, risks, open action items, and the recommendation with its story key. Offer to run the recommended skill. Surface `illegal` and `unrecognized` entries and the script's `warnings` (malformed action items land there); if the user gives corrections, apply them via the fix flow rather than ad-hoc edits. No time estimates — status, risks, and next steps only.
If the script errors — malformed YAML, a hand-edited structure it can't parse, anything — do not stop at the error. Read `sprint-status.yaml` yourself, apply best judgment to give the user the same summary (counts, risks, next recommended action), tell them the deterministic path failed and why, and offer the fix flow (`fix-sprint-status.md`) so the script works next time.
references/validate.md
# Validate
When the user asks whether `sprint-status.yaml` is well-formed, run:
```
uv run {skill-root}/scripts/sprint_plan.py validate \
--status-file {implementation_artifacts}/sprint-status.yaml
```
Never writes; exits 0 whether valid or not. Report `valid` in one line. If `problems` is non-empty, list them plainly (each names the key or field at fault) and offer the fix flow (`fix-sprint-status.md`). If `legacy_mapped` is non-empty, note the file still uses v6 status names and that any regenerate will rewrite them to the modern vocabulary — progress is preserved either way.
scripts/sprint_plan.py
# /// script
# requires-python = ">=3.11"
# 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 open(path, 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 open(args.status_file, 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 open(args.status_file, 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()
scripts/tests/test_sprint_plan.py
# /// script
# requires-python = ">=3.11"
# 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"]))
SKILL.md
---
name: bmad-sprint-planning
description: 'Check that planning is complete enough to implement, then generate the sprint status file from the epics. Can also summarize sprint progress and validate or repair the tracking file. Use when the user says "run sprint planning", "generate sprint plan", "check implementation readiness", "show sprint status", "validate sprint status", or "fix sprint status"'
---
# Overview
You are a senior developer about to commit to this plan. Two moves, in order: first scrutinize the planning the way a skeptic reads a handoff — gaps found now are cheap, gaps found mid-build are not. Then hand the mechanical work to the script: parsing epics, deriving keys, merging statuses, and writing `sprint-status.yaml` are deterministic jobs, not judgment calls. Your judgment goes where the script can't: deciding which files are epics, weighing readiness, and reconciling anything the script flags.
## On Activation
1. Resolve customization: `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
2. Execute each entry in `{workflow.activation_steps_prepend}` in order.
3. Treat every entry in `{workflow.persistent_facts}` as foundational context for the rest of the run. Entries prefixed `file:` are paths or globs under `{project-root}` — load the referenced contents as facts. All other entries are facts verbatim.
4. Resolve config: `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key core.project_name --key modules.bmm.planning_artifacts --key modules.bmm.implementation_artifacts --key modules.bmm.project_knowledge`. `{date}` is the current system datetime.
5. Greet the user, detect intent, and load only what that intent needs:
- **readiness** — check implementation readiness only: load `references/readiness-gate.md`, run the gate, report, stop
- **sprint-planning** — the full flow (also the refresh path for an existing `sprint-status.yaml`): load `references/readiness-gate.md`, then on PASS `references/generate-tracking.md`
- **status** — "show sprint status", "where are we": skip the gate, load `references/status-view.md`
- **validate** — check the tracking file's format: load `references/validate.md`
- **fix** — repair or rebuild a broken `sprint-status.yaml`: load `references/fix-sprint-status.md`
If interactive and unclear, ask; for headless behavior see `## Headless Mode`.
Execute each entry in `{workflow.activation_steps_append}` in order.
Activation is complete. If `activation_steps_prepend` or `activation_steps_append` were non-empty, confirm every entry was executed in order before proceeding.
## If the Script Fails
This rule covers every intent: when `sprint_plan.py` errors or the file is in a state it cannot handle, do not stop at the error and do not guess silently. Read the files yourself, deliver the same outcome by best judgment, tell the user the deterministic path failed and why, and offer the fix flow (`references/fix-sprint-status.md`) to restore a file the script can work with.
## On Completion
Whatever the intent, close out per the loaded reference, then run `{workflow.on_complete}` if non-empty; treat a string scalar as one instruction and an array as a sequence.
## Headless Mode
When invoked headless, do not ask. Run the gate and, unless intent was readiness-only, generate tracking. Ambiguity the interactive flow would resolve by asking (duplicate epic versions, unreconciled orphans, an unconfirmed fix) halts with a `blocked` status instead of guessing. End with a JSON response:
```json
{
"status": "complete",
"intent": "sprint-planning",
"gate": "PASS",
"status_file": "{implementation_artifacts}/sprint-status.yaml",
"findings": [],
"warnings": []
}
```
`gate` is `PASS`, `CONCERNS`, or `FAIL`; on `FAIL` include `findings` and the saved findings path if written, and omit `status_file`. `intent` is `"readiness"`, `"sprint-planning"`, `"status"`, `"validate"`, or `"fix"` — for status and validate intents, omit `gate` and pass the script's JSON through under a `report` key (not `status`, which names the run state).
## References
- `scripts/sprint_plan.py` — the deterministic parser/generator/merger; subcommands `generate`, `status`, `validate`. Its JSON output is the contract this skill reads; argparse errors are JSON too
- `references/readiness-gate.md` — the PASS/CONCERNS/FAIL gate: artifact inventory and the implementability question
- `references/generate-tracking.md` — epic discovery, the generate command, and acting on its JSON report
- `references/status-view.md` — the status view: counts, risks, open action items, next recommended action
- `references/fix-sprint-status.md` — rebuild a broken tracking file: evidence-gathering subagents, user confirmation, pristine regeneration
- `references/validate.md` — format validation of an existing `sprint-status.yaml`
- `sprint-status-template.yaml` — the documented file format and status vocabulary; the script embeds the same block and the test suite pins the two copies together
sprint-status-template.yaml
# Sprint Status Template
# This is an EXAMPLE showing the expected format
# The actual file will be generated with all epics/stories from your epic files
# generated: {date}
# project: {project_name}
# project_key: {project_key}
# tracking_system: {tracking_system}
# story_location: {story_location}
# 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
# EXAMPLE STRUCTURE (your actual epics/stories will replace these):
# Timestamps use MM-DD-YYYY HH:MM.
generated: 05-06-2025 21:30
last_updated: 05-06-2025 21:30
project: My Awesome Project
project_key: NOKEY
tracking_system: file-system
story_location: docs/stories
development_status:
epic-1: backlog
1-1-user-authentication: done
1-2-account-management: ready-for-dev
1-3-plant-data-model: backlog
1-4-add-plant-manual: backlog
epic-1-retrospective: optional
epic-2: backlog
2-1-personality-system: backlog
2-2-chat-interface: backlog
2-3-llm-integration: backlog
epic-2-retrospective: optional
# Action items committed during retrospectives (section created by the retrospective workflow)
action_items:
- epic: 1
action: Add error-handling review to the code review checklist
owner: Charlie
status: open