#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # /// """Validate the v7 epic-and-story tree under an initiative store. Checks (strict mode): 1. Each file's front matter has only the allowed top-level keys, all required keys present. 2. Enum values valid (type, status). 3. Story `epic:` field equals the enclosing folder name; epic `epic:` field equals the folder NN. 4. depends_on entries resolve (within-epic basenames or / cross-epic). 5. Cross-epic depends_on graph is acyclic. 6. Story-level depends_on graph is acyclic; within-epic deps must point at earlier siblings. 7. Within-epic story numbering is sequential starting at 01, with no duplicates. 8. Sizing sanity (warnings only): a story body >3x the epic mean is flagged. 9. Coverage (only when --inventory FILE is provided): every requirement code listed in the inventory must appear in at least one story's `## Coverage` section. Codes mentioned elsewhere in the body (Technical Notes etc.) do NOT count as covered. Output (stdout, JSON): {"findings": [...], "summary": {...}} --summary-only emits a structured tree block instead, used by edit-mode and finalize. --tree emits a plain-text tree to stdout, used by Stage 6. Exit codes: 0 if no errors (warnings ok), 1 if any error finding, 2 on internal error. Flags: --lax skip sizing warnings; never relaxes schema or dep checks --epic NN-kebab limit walks to a single epic folder (still resolves cross-epic refs). Errors if the named folder doesn't exist. --inventory FILE path to inventory.json (or .bmad-cache/inventory.json); when present, missing requirement codes are reported. Default level is warning; pair with --coverage-strict to escalate to error. --coverage-strict upgrade coverage-missing findings from warning to error --summary-only emit tree-shaped summary JSON only (no schema findings); intended for prompts that need to see what's there without re-reading every file --tree emit a plain-text tree (epic folders, story files, statuses) and exit 0 """ from __future__ import annotations import argparse import json import re import sys from collections import Counter, defaultdict from pathlib import Path # Same directory; both files are shipped together as the skill's scripts/. sys.path.insert(0, str(Path(__file__).resolve().parent)) from extract_coverage import extract_coverage_section, parse_coverage_section # noqa: E402 STORY_TYPES = {"feature", "bug", "task", "spike"} STATUSES = {"draft", "ready", "in-progress", "review", "done", "blocked"} STORY_KEYS = {"title", "type", "status", "epic", "depends_on", "metadata"} STORY_REQUIRED = {"title", "type", "status", "epic", "depends_on"} EPIC_KEYS = {"title", "epic", "status", "depends_on", "metadata"} EPIC_REQUIRED = {"title", "epic", "status", "depends_on"} def _story_coverage_codes(stext: str) -> set[str]: """Codes claimed by a story's ``## Coverage`` section. Empty if no section.""" section = extract_coverage_section(stext) if section is None: return set() ac_map = parse_coverage_section(section) return {c for codes in ac_map.values() for c in codes} def parse_frontmatter(text: str) -> tuple[dict | None, str | None]: if not text.startswith("---\n"): return None, "missing front matter (expected leading '---')" end = text.find("\n---", 4) if end == -1: return None, "front matter not closed (expected closing '---')" return _parse_block(text[4:end]) def _parse_block(block: str) -> tuple[dict | None, str | None]: out: dict = {} lines = block.split("\n") i = 0 while i < len(lines): line = lines[i] if not line.strip() or line.lstrip().startswith("#"): i += 1 continue if line.startswith(" "): return None, f"unexpected indented line: {line!r}" if ":" not in line: return None, f"line missing colon: {line!r}" key, _, val = line.partition(":") key = key.strip() val = val.strip() if val == "": children = [] j = i + 1 while j < len(lines) and (lines[j].startswith(" ") or not lines[j].strip()): children.append(lines[j]) j += 1 out[key] = _parse_indented(children) i = j continue out[key] = _parse_scalar_or_list(val) i += 1 return out, None def _parse_scalar_or_list(val: str): if val.startswith("[") and val.endswith("]"): inner = val[1:-1].strip() if not inner: return [] return [_unquote(p.strip()) for p in _split_top_level(inner, ",")] return _unquote(val) def _split_top_level(s: str, sep: str) -> list[str]: out, cur, depth, in_q = [], [], 0, None i = 0 while i < len(s): c = s[i] if in_q: cur.append(c) if c == "\\" and i + 1 < len(s): cur.append(s[i + 1]) i += 2 continue if c == in_q: in_q = None elif c in '"\'': in_q = c cur.append(c) elif c in "[{": depth += 1 cur.append(c) elif c in "]}": depth -= 1 cur.append(c) elif c == sep and depth == 0: out.append("".join(cur)) cur = [] else: cur.append(c) i += 1 if cur: out.append("".join(cur)) return out def _unquote(val: str) -> str: val = val.strip() if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'": inner = val[1:-1] if val[0] == '"': return inner.replace('\\"', '"').replace("\\\\", "\\") return inner return val def _parse_indented(lines: list[str]) -> dict: out: dict = {} for line in lines: s = line.strip() if not s or s.startswith("#") or ":" not in s: continue key, _, val = s.partition(":") out[key.strip()] = _unquote(val.strip()) return out def _find_cycles(graph: dict[str, list[str]]) -> list[list[str]]: cycles: list[list[str]] = [] state: dict[str, int] = {} stack: list[str] = [] def dfs(node: str) -> None: state[node] = 1 stack.append(node) for nxt in graph.get(node, []): if state.get(nxt) == 1: cycles.append(stack[stack.index(nxt):] + [nxt]) elif state.get(nxt, 0) == 0: dfs(nxt) stack.pop() state[node] = 2 for n in graph: if state.get(n, 0) == 0: dfs(n) return cycles def _inventory_codes(inventory: dict) -> list[tuple[str, str]]: """Return a flat (code, text) list across every category in an inventory dict. Accepts either a `requirements` map keyed by category, or a flat list of {code, text} entries under `codes`. Tolerates missing fields. """ out: list[tuple[str, str]] = [] reqs = inventory.get("requirements") or {} if isinstance(reqs, dict): for entries in reqs.values(): if not isinstance(entries, list): continue for e in entries: if isinstance(e, dict) and "code" in e: out.append((str(e["code"]), str(e.get("text", "")))) for legacy_key in ("codes", "additional_codes"): for e in inventory.get(legacy_key, []) or []: if isinstance(e, dict) and "code" in e: out.append((str(e["code"]), str(e.get("text", "")))) return out def validate( initiative_store: Path, lax: bool, only_epic: str | None, inventory_codes: list[tuple[str, str]] | None = None, coverage_strict: bool = False, ) -> tuple[list[dict], dict]: findings: list[dict] = [] epics_dir = initiative_store / "epics" if not epics_dir.is_dir(): findings.append({"level": "error", "code": "no-epics-dir", "message": f"missing {epics_dir}", "path": str(epics_dir)}) return findings, {} all_epic_folders = sorted(p for p in epics_dir.iterdir() if p.is_dir() and re.match(r"^\d+-", p.name)) if only_epic is not None and not any(p.name == only_epic for p in all_epic_folders): findings.append({ "level": "error", "code": "epic-filter-not-found", "message": f"--epic {only_epic!r} does not match any folder under {epics_dir}", "path": str(epics_dir), }) return findings, { "epics": [], "story_count": 0, "story_status_counts": {}, "story_type_counts": {}, "errors": 1, "warnings": 0, "mentioned_requirements": [], "coverage_missing": [], } walk_folders = [p for p in all_epic_folders if (only_epic is None or p.name == only_epic)] epic_meta: dict[str, dict] = {} story_index: dict[str, dict] = {} mentioned_codes: set[str] = set() # walk every epic in the tree (so cross-epic refs always resolve), but only # report non-resolution findings when the offending file is in walk_folders. epic_folders_for_meta = all_epic_folders walk_set = {p.name for p in walk_folders} for ed in epic_folders_for_meta: in_walk = ed.name in walk_set nn = ed.name.split("-", 1)[0].zfill(2) epic_md = ed / "epic.md" if not epic_md.is_file(): if in_walk: findings.append({"level": "error", "code": "missing-epic-md", "message": f"no epic.md in {ed.name}", "path": str(ed)}) continue text = epic_md.read_text(encoding="utf-8") fm, err = parse_frontmatter(text) if err: if in_walk: findings.append({"level": "error", "code": "epic-frontmatter-parse", "message": err, "path": str(epic_md)}) continue if in_walk: present = set(fm.keys()) forbidden = present - EPIC_KEYS if forbidden: findings.append({"level": "error", "code": "epic-extra-keys", "message": f"forbidden top-level keys: {sorted(forbidden)}", "path": str(epic_md)}) missing = EPIC_REQUIRED - present if missing: findings.append({"level": "error", "code": "epic-missing-keys", "message": f"missing required keys: {sorted(missing)}", "path": str(epic_md)}) if fm.get("status") not in STATUSES: findings.append({"level": "error", "code": "epic-bad-status", "message": f"status={fm.get('status')!r} not in {sorted(STATUSES)}", "path": str(epic_md)}) ef = str(fm.get("epic", "")).strip() if ef and ef != nn: findings.append({"level": "error", "code": "epic-nn-mismatch", "message": f"epic field {ef!r} does not match folder NN {nn!r}", "path": str(epic_md)}) deps = fm.get("depends_on", []) if not isinstance(deps, list): if in_walk: findings.append({"level": "error", "code": "epic-deps-not-list", "message": "depends_on must be a list", "path": str(epic_md)}) deps = [] epic_meta[ed.name] = { "nn": nn, "title": str(fm.get("title", "")), "status": fm.get("status"), "depends_on": [str(d) for d in deps], "path": ed, "in_walk": in_walk, } story_files = sorted(p for p in ed.iterdir() if p.is_file() and p.suffix == ".md" and p.name != "epic.md" and re.match(r"^\d+-", p.name)) seen_nns: list[int] = [] for sf in story_files: nn_m = re.match(r"^(\d+)-", sf.name) if not nn_m: if in_walk: findings.append({"level": "error", "code": "story-bad-prefix", "message": "expected NN-kebab.md", "path": str(sf)}) continue snn = int(nn_m.group(1)) seen_nns.append(snn) stext = sf.read_text(encoding="utf-8") sfm, serr = parse_frontmatter(stext) if serr: if in_walk: findings.append({"level": "error", "code": "story-frontmatter-parse", "message": serr, "path": str(sf)}) continue if in_walk: present = set(sfm.keys()) forbidden = present - STORY_KEYS if forbidden: findings.append({"level": "error", "code": "story-extra-keys", "message": f"forbidden top-level keys: {sorted(forbidden)}", "path": str(sf)}) missing = STORY_REQUIRED - present if missing: findings.append({"level": "error", "code": "story-missing-keys", "message": f"missing required keys: {sorted(missing)}", "path": str(sf)}) if sfm.get("type") not in STORY_TYPES: findings.append({"level": "error", "code": "story-bad-type", "message": f"type={sfm.get('type')!r} not in {sorted(STORY_TYPES)}", "path": str(sf)}) if sfm.get("status") not in STATUSES: findings.append({"level": "error", "code": "story-bad-status", "message": f"status={sfm.get('status')!r} not in {sorted(STATUSES)}", "path": str(sf)}) if sfm.get("epic") != ed.name: findings.append({"level": "error", "code": "story-epic-mismatch", "message": f"epic field {sfm.get('epic')!r} does not match folder {ed.name!r}", "path": str(sf)}) sdeps = sfm.get("depends_on", []) if not isinstance(sdeps, list): if in_walk: findings.append({"level": "error", "code": "story-deps-not-list", "message": "depends_on must be a list", "path": str(sf)}) sdeps = [] mentioned_codes.update(_story_coverage_codes(stext)) story_index[f"{ed.name}/{sf.stem}"] = { "depends_on": [str(d) for d in sdeps], "path": sf, "basename": sf.stem, "epic": ed.name, "nn": snn, "title": str(sfm.get("title", "")), "type": sfm.get("type"), "status": sfm.get("status"), "body_len": len(stext), "in_walk": in_walk, } if in_walk and seen_nns: counts = Counter(seen_nns) duplicates = sorted(n for n, c in counts.items() if c > 1) if duplicates: findings.append({ "level": "error", "code": "story-numbering-duplicates", "message": f"duplicate story NNs in {ed.name}: {duplicates}", "path": str(ed), }) else: unique_sorted = sorted(set(seen_nns)) expected = list(range(1, len(unique_sorted) + 1)) if unique_sorted != expected: findings.append({"level": "error", "code": "story-numbering-gaps", "message": f"story NNs {unique_sorted} expected {expected}", "path": str(ed)}) # depends_on resolution epic_nns = {meta["nn"]: name for name, meta in epic_meta.items()} for name, meta in epic_meta.items(): if not meta["in_walk"]: continue for d in meta["depends_on"]: d2 = d.zfill(2) if d2 not in epic_nns: findings.append({"level": "error", "code": "epic-dep-unresolved", "message": f"epic {name} depends on NN {d!r} which has no folder", "path": str(meta["path"])}) # Story dep resolution + within-epic forward-ref check. # Build the story dependency graph on the whole tree so cycle detection # catches loops that span epics, even when the run is filtered to one. story_graph: dict[str, list[str]] = {} for skey, smeta in story_index.items(): targets: list[str] = [] for d in smeta["depends_on"]: target_key = d if "/" in d else f"{smeta['epic']}/{d}" if target_key not in story_index: if smeta["in_walk"]: if "/" in d: findings.append({"level": "error", "code": "story-dep-unresolved", "message": f"cross-epic dep {d!r} references missing story", "path": str(smeta["path"])}) else: findings.append({"level": "error", "code": "story-dep-unresolved", "message": f"within-epic dep {d!r} not found in {smeta['epic']}", "path": str(smeta["path"])}) continue targets.append(target_key) if smeta["in_walk"]: tgt = story_index[target_key] if tgt["epic"] == smeta["epic"] and tgt["nn"] >= smeta["nn"]: findings.append({ "level": "error", "code": "story-dep-forward", "message": f"depends on later sibling {d!r} (NN {tgt['nn']:02d} ≥ self NN {smeta['nn']:02d}); within-epic deps must point at earlier stories", "path": str(smeta["path"]), }) story_graph[skey] = targets # epic dep cycles (compute on whole tree; report once) if walk_set: cycle_graph = {meta["nn"]: [d.zfill(2) for d in meta["depends_on"]] for meta in epic_meta.values()} for cyc in _find_cycles(cycle_graph): findings.append({"level": "error", "code": "epic-dep-cycle", "message": "cycle in epic depends_on: " + " -> ".join(cyc), "path": str(epics_dir)}) seen_cycle_keys: set[frozenset[str]] = set() for cyc in _find_cycles(story_graph): # report only once per distinct cycle, and only when at least one # node lies in the walk (so --epic filtering still hides loops in # untouched parts of the tree). if not any(story_index.get(n, {}).get("in_walk") for n in cyc): continue key = frozenset(cyc) if key in seen_cycle_keys: continue seen_cycle_keys.add(key) findings.append({ "level": "error", "code": "story-dep-cycle", "message": "cycle in story depends_on: " + " -> ".join(cyc), "path": str(epics_dir), }) # sizing warnings if not lax: by_epic: dict[str, list] = defaultdict(list) for skey, smeta in story_index.items(): if smeta["in_walk"]: by_epic[smeta["epic"]].append(smeta) for epic_name, items in by_epic.items(): if len(items) < 3: continue mean = sum(s["body_len"] for s in items) / len(items) for smeta in items: if mean > 0 and smeta["body_len"] > mean * 3: findings.append({ "level": "warning", "code": "story-oversized", "message": f"body {smeta['body_len']} chars is >3x epic mean ({mean:.0f}); consider splitting", "path": str(smeta["path"]), }) coverage_missing: list[str] = [] if inventory_codes is not None: level = "error" if coverage_strict else "warning" for code, text in inventory_codes: if code in mentioned_codes: continue coverage_missing.append(code) findings.append({ "level": level, "code": "coverage-missing", "message": f"requirement {code!r} ({text[:60]}...) not referenced in any story's ## Coverage section" if text else f"requirement {code!r} not referenced in any story's ## Coverage section", "path": str(initiative_store / "epics"), }) epics_summary: list[dict] = [] for name, meta in epic_meta.items(): if not meta["in_walk"]: continue own_stories = [s for s in story_index.values() if s["epic"] == name and s["in_walk"]] epics_summary.append({ "folder": name, "nn": meta["nn"], "title": meta["title"], "status": meta["status"], "depends_on": meta["depends_on"], "story_count": len(own_stories), "story_status_counts": dict(Counter(s["status"] for s in own_stories)), "stories": [ { "basename": s["basename"], "nn": f"{s['nn']:02d}", "title": s["title"], "type": s["type"], "status": s["status"], "depends_on": s["depends_on"], "body_len": s["body_len"], } for s in sorted(own_stories, key=lambda s: s["nn"]) ], }) summary = { "epics": epics_summary, "story_count": sum(1 for s in story_index.values() if s["in_walk"]), "story_status_counts": dict(Counter(s["status"] for s in story_index.values() if s["in_walk"])), "story_type_counts": dict(Counter(s["type"] for s in story_index.values() if s["in_walk"])), "errors": sum(1 for f in findings if f["level"] == "error"), "warnings": sum(1 for f in findings if f["level"] == "warning"), "mentioned_requirements": sorted(mentioned_codes), "coverage_missing": sorted(coverage_missing), } return findings, summary def render_tree(initiative_store: Path, summary: dict) -> str: """Plain-text tree for direct printing in Stage 6 / edit-mode summary.""" lines = [f"{initiative_store}/epics/"] epics = summary.get("epics", []) for ei, epic in enumerate(epics): is_last_epic = ei == len(epics) - 1 epic_branch = "└── " if is_last_epic else "├── " lines.append(f"{epic_branch}{epic['folder']}/ (epic, {epic.get('status', '?')})") epic_indent = " " if is_last_epic else "│ " stories = epic.get("stories", []) for si, story in enumerate(stories): is_last_story = si == len(stories) - 1 story_branch = "└── " if is_last_story else "├── " lines.append( f"{epic_indent}{story_branch}{story['basename']}.md " f"({story.get('type', '?')}, {story.get('status', '?')})" ) return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--initiative-store", required=True, type=Path) ap.add_argument("--lax", action="store_true", help="Skip sizing warnings; never relaxes schema/dep checks") ap.add_argument("--epic", help="Limit reporting to a single epic folder name") ap.add_argument("--inventory", type=Path, help="inventory.json with requirement codes; enables coverage check") ap.add_argument("--coverage-strict", action="store_true", help="Escalate coverage-missing findings from warning to error") ap.add_argument("--summary-only", action="store_true", help="Emit summary block with full epic/story tree (no findings)") ap.add_argument("--tree", action="store_true", help="Emit a plain-text tree to stdout and exit") args = ap.parse_args() inventory_codes: list[tuple[str, str]] | None = None if args.inventory is not None: if not args.inventory.is_file(): print(f"inventory file not found: {args.inventory}", file=sys.stderr) return 1 try: inventory = json.loads(args.inventory.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: print(f"could not parse {args.inventory}: {exc}", file=sys.stderr) return 1 inventory_codes = _inventory_codes(inventory) findings, summary = validate( args.initiative_store, args.lax, args.epic, inventory_codes=inventory_codes, coverage_strict=args.coverage_strict, ) if args.tree: print(render_tree(args.initiative_store, summary)) return 0 if args.summary_only: print(json.dumps({"summary": summary})) return 0 print(json.dumps({"findings": findings, "summary": summary})) return 1 if any(f["level"] == "error" for f in findings) else 0 if __name__ == "__main__": sys.exit(main())