Zurück zu Skills
bmad-code-org/bmad-methodPrüfung bestanden

SKILL DETAIL

bmad-sprint-planning

bmad-code-org/bmad-method/bmad-sprint-planning

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"

Installationen · 169Quelle ansehen

Installation

npx skills add https://github.com/bmad-code-org/bmad-method --skill bmad-sprint-planning

Skill-Dateien

SKILL.md

Zuletzt synchronisiert · 11.09.2026

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
bmad-sprint-planning · Trendende Agent Skills | Mengbi