From e80d00d66e1a7275704de2f73b20cbe3033756d7 Mon Sep 17 00:00:00 2001 From: pbean Date: Thu, 30 Apr 2026 19:21:26 -0700 Subject: [PATCH] ran through analysis with codex this time --- .../bmad-create-epics-and-stories/SKILL.md | 4 +- .../customize.toml | 8 +- .../prompts/edit-mode.md | 8 ++ .../prompts/epic-design.md | 2 +- .../prompts/finalize.md | 4 +- .../prompts/validate.md | 15 ++- .../resources/validation-error-codes.md | 6 +- .../scripts/extract_coverage.py | 23 +++- .../scripts/from_spec.py | 24 +++- .../scripts/tests/test_from_spec.py | 16 +++ .../scripts/tests/test_validate_initiative.py | 81 +++++++++++++ .../scripts/validate_initiative.py | 113 +++++++++++++++--- 12 files changed, 263 insertions(+), 41 deletions(-) diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md index 61d30e362..938cbb617 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md @@ -51,7 +51,7 @@ Execute each entry in `{workflow.activation_steps_prepend}` in order before proc ### Step 3: Load Persistent Facts -Treat every entry in `{workflow.persistent_facts}` as foundational context for the whole run. Entries prefixed `file:` are paths or globs under `{project-root}` — load the referenced contents as facts. All other entries are facts verbatim. +Treat every entry in `{workflow.persistent_facts}` as foundational context for the whole run. Entries prefixed `file:` are paths or globs and may use `{project-root}` (project-local files) or `{skill-root}` (skill-shipped resources); resolve the placeholder, then load the referenced contents as facts. Glob patterns are honored. All other entries are facts verbatim. ### Step 4: Load Config @@ -94,7 +94,7 @@ If the user passed `--from-spec `, set `{mode}=from-spec` and `{spec_path} - If `{initiative_store}/epics/` contains v7 epic folders (any folder matching `NN-*` with an `epic.md` inside) → `{mode}=edit`. - If `{initiative_store}/epics/` is absent BUT a v6 monolithic file exists at `{initiative_store}/epics.md` or `{planning_artifacts}/epics.md`, OR a sharded v6 directory exists at the same locations → `{mode}=migrate`. -If both v7 folders and a v6 file exist, prefer `edit` and surface the v6 file in Stage 1 as a one-line note. +If both v7 folders and a v6 file exist, prefer `edit`. The edit-mode prompt surfaces the leftover v6 file as a one-line note before any sub-mode dispatches. ### 4. Edit sub-mode dispatch (only when `{mode}=edit`) diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml index 121a44128..0cb30d864 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml @@ -28,8 +28,12 @@ activation_steps_append = [] # # Each entry is either: # - a literal sentence, e.g. "Stories must reference at least one FR or UX-DR." -# - 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). +# - a file reference prefixed with `file:`. The path may use either +# `{project-root}` (project-local docs) or `{skill-root}` (skill-shipped +# resources). Glob patterns are honored; the file's contents are loaded +# and treated as facts. Examples: +# "file:{project-root}/docs/standards.md" +# "file:{skill-root}/resources/sizing-heuristics.md" persistent_facts = [ "file:{project-root}/**/project-context.md", diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/edit-mode.md b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/edit-mode.md index ed74876b1..f581b7e7f 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/edit-mode.md +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/edit-mode.md @@ -18,6 +18,14 @@ python3 scripts/validate_initiative.py --initiative-store {initiative_store} --s The JSON's `summary.epics[]` already contains, per epic: folder, NN, title, status, depends_on, story_count, and per-story metadata (basename, title, type, status, depends_on). Use this — do not read every `epic.md` and every story file. +### Refuse to dispatch when the tree is broken + +`--summary-only` suppresses the detailed `findings[]` list, but it still includes `summary.errors` and `summary.warnings` counts. **If `summary.errors > 0`, do not proceed into any sub-mode.** Re-run the validator without `--summary-only` to surface the findings, route to `prompts/validate.md` to fix them, and only return to this prompt once the tree validates clean. Edit-mode flows like `split-epic` and `re-derive-deps` rewrite many files; running them on top of a parse or schema error compounds the damage. + +### Surface a stale v6 file (one-line note) + +If a v6 monolithic file exists alongside the v7 tree (at `{initiative_store}/epics.md` or `{planning_artifacts}/epics.md`), surface it once before dispatch as a single line: `"there's still a v6 epics.md at — leave it, archive it, or migrate it via a fresh create flow"`. The v7 tree is canonical; the v6 file is treated as inert. Do not stop or branch — continue to the sub-mode the user asked for. + ## Ambiguous-intent menu If the user's opening message did not match any sub-mode, ask: diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/epic-design.md b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/epic-design.md index c0916cb24..e5d910aeb 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/epic-design.md +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/epic-design.md @@ -12,7 +12,7 @@ - **User-value first.** Each epic must enable users to accomplish something meaningful, or — for tech-debt epics — leave a measurably better engineering state. Epics organized by technical layers ("database setup," "API endpoints," "frontend components") are wrong; reshape them. - **Standalone within the dependency graph.** Each epic delivers complete functionality for its domain. Epic 2 must not require Epic 3 to function. Epic 3 may build on 1 and 2 but must stand alone. -- **Dependency-free within an epic.** Stories within an epic must not depend on later stories in the same epic. (The validator enforces this in Stage 5 via `depends_on` resolution.) +- **Dependency-free within an epic.** Stories within an epic must not depend on later stories in the same epic. (The validator enforces this in Stage 5: `story-dep-forward` rejects within-epic deps that point at later siblings, and `story-dep-cycle` rejects loops in the story-level graph.) - **File-churn check.** If multiple proposed epics repeatedly modify the same core files, ask whether they should consolidate into one epic with ordered stories. Distinguish meaningful overlap (same component end-to-end) from incidental sharing. Consolidate when the split provides no risk-mitigation or feedback-loop value. - **Implementation efficiency over taxonomy.** When the outcome is certain and direction changes between epics are unlikely, prefer fewer larger epics. Split into more epics when there's a genuine risk boundary or where early feedback could change direction. - **Starter template (if Stage 2 flagged one in the inventory).** Epic 1's first story must be "set up the project from the starter template." Plan for it now. diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/finalize.md b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/finalize.md index 46fba49fe..f8328b83c 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/finalize.md +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/finalize.md @@ -38,11 +38,11 @@ python3 scripts/validate_initiative.py --initiative-store {initiative_store} --s From the JSON, surface a 3-line block such as: ``` -2 epics, 5 stories: 3 features, 2 tasks. Coverage: 100% (8/8 inventory codes mentioned). +2 epics, 5 stories: 3 features, 2 tasks. Coverage: 100% (8/8 inventory codes claimed by some story's ## Coverage section). Median story body: ~600 chars. No oversized stories. ``` -Pull the coverage number from `summary.mentioned_requirements` vs the inventory at `{initiative_store}/.bmad-cache/inventory.json` (if present); skip the coverage line when no inventory exists. +Pull the coverage number from `summary.mentioned_requirements` (the strict Coverage-section set) vs the inventory at `{initiative_store}/.bmad-cache/inventory.json` (if present); skip the coverage line when no inventory exists. ## Step 3: Confirm initial statuses diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/validate.md b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/validate.md index ed43ad601..f8fd97cf0 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/validate.md +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/prompts/validate.md @@ -15,14 +15,14 @@ python3 scripts/validate_initiative.py --initiative-store {initiative_store} \ --inventory {initiative_store}/.bmad-cache/inventory.json ``` -Without `--inventory`, the validator only checks schema/deps/cycles/numbering and emits the regex-extracted `mentioned_requirements` set; coverage findings will not be generated. +Without `--inventory`, the validator only checks schema/deps/cycles/numbering and emits the Coverage-section codes as `mentioned_requirements`; coverage findings will not be generated. The JSON contract: -- `findings[]` — every error and warning. New code: `coverage-missing` for inventory codes that don't appear textually in any story body. Default level is `warning`; pass `--coverage-strict` to escalate to `error`. +- `findings[]` — every error and warning. `coverage-missing` fires for inventory codes that no story's `## Coverage` section claims (mentions in prose elsewhere do not count). Default level is `warning`; pass `--coverage-strict` to escalate to `error`. Other dependency-shape codes: `story-dep-forward` (within-epic dep on a later sibling), `story-dep-cycle` (loop in the story-level depends_on graph), `epic-dep-cycle` (loop in the epic-level graph). - `summary.epics[]` — full per-epic summary including story-level metadata (basename, title, type, status, depends_on, body_len). Use this instead of re-reading every file. -- `summary.mentioned_requirements` — deduplicated set of codes the regex found. -- `summary.coverage_missing` — codes from the inventory not found in any story body (only populated when `--inventory` was passed). +- `summary.mentioned_requirements` — deduplicated set of codes the validator found in any story's `## Coverage` section. The strict source for the coverage gate. +- `summary.coverage_missing` — codes from the inventory not claimed by any story's `## Coverage` section (only populated when `--inventory` was passed). ## Headless mode @@ -43,13 +43,16 @@ For each error in `findings`, explain it in one sentence and offer to fix. Group - **`epic-nn-mismatch` / `story-epic-mismatch`** → likely a hand-edit of the front matter; the folder name is canonical, so update the front matter to match. - **`story-dep-unresolved`** → either the dep was a typo (fix the depends_on entry) or the target was renamed (`scripts/rename_story.py`) or moved (`scripts/move_story.py`) without updating refs. Use the move/rename scripts going forward — they update refs atomically. - **`epic-dep-cycle`** → cross-epic graph has a loop. Loop back to `prompts/epic-design.md` (re-derive-deps flow) to fix it. -- **`story-numbering-gaps`** → use `scripts/rename_story.py --to-nn` to fill the gap or renumber the survivors. +- **`story-dep-forward`** → a within-epic dep points at a later sibling. Either reorder the stories with `rename_story.py --to-nn` so the dep points backward, or move the dep target into an upstream epic and use the cross-epic ref form. +- **`story-dep-cycle`** → loop in the story-level graph. Loop back to `prompts/edit-mode.md` re-derive-deps. Often a sign two stories should be merged. +- **`story-numbering-gaps`** / **`story-numbering-duplicates`** → use `scripts/rename_story.py --to-nn` to fill the gap or break the duplicate. +- **`epic-filter-not-found`** → the `--epic` flag named a folder that doesn't exist. Spell-check the folder name (including the `NN-` prefix), or drop the flag. ### 2. Coverage check When `--inventory` was passed, the validator already produced `coverage_missing` deterministically — surface those codes conversationally and route into the **coverage-fix** edit-mode entry point in `prompts/epic-authoring.md`. -For each missing code: ask whether it should be added to an existing story's AC mapping or whether a new story is needed. The validator does not distinguish between "code not mentioned anywhere" and "code mentioned in prose without the literal token" — when you suspect the latter, fan out `agents/coverage-auditor.md` to do a fuzzy semantic check. +For each missing code: ask whether it should be added to an existing story's AC mapping or whether a new story is needed. A code mentioned in Technical Notes (or any prose outside the `## Coverage` section) is treated as uncovered — that's by design, since the AC→codes contract is what downstream skills like `bmad-code-review` rely on. If you suspect the requirement *is* implicitly covered but no story claims its code, fan out `agents/coverage-auditor.md` to do a fuzzy semantic check before adding new stories. #### Speeding up the auditor with a deterministic pre-pass diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/resources/validation-error-codes.md b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/resources/validation-error-codes.md index 4fafd2e72..c3d6d2602 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/resources/validation-error-codes.md +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/resources/validation-error-codes.md @@ -40,6 +40,8 @@ These fire when references don't resolve. | `epic-dep-unresolved` | An epic's `depends_on` references an NN that has no corresponding folder in the tree. | Either fix the typo, or remove the dep, or create the missing epic. | | `epic-dep-cycle` | The cross-epic depends_on graph has a cycle (Epic A → B → A, directly or transitively). The message lists the cycle. | Break the cycle. If the cycle reflects a real bidirectional dependency, the epics should probably be merged or the seam reconsidered. | | `story-dep-unresolved` | A story's `depends_on` entry doesn't resolve — either as a within-epic basename or as a cross-epic `/` ref. | Fix the typo, or use `move_story.py` / `rename_story.py` going forward (they update refs atomically). For after-the-fact fixes, edit `depends_on:` directly and re-validate. | +| `story-dep-forward` | A story's within-epic `depends_on` references a sibling whose NN is greater than or equal to its own. Within an epic, stories must build on earlier ones. | Reorder (re-NN) the stories with `rename_story.py --to-nn` so the dependency points backward, or move the dep target to an upstream epic and use the cross-epic ref form. | +| `story-dep-cycle` | The story-level `depends_on` graph (across the whole tree) has a cycle. The message lists the cycle in `/` form. | Break the cycle. Often a symptom of stories that should be merged, or of a missed seam where one of them should split off into an earlier epic. | ## Structural errors @@ -48,12 +50,14 @@ These fire when references don't resolve. | `no-epics-dir` | `{initiative_store}/epics/` doesn't exist. | Either you have the wrong `--initiative-store`, or no tree has been generated yet. Run the skill in create mode. | | `missing-epic-md` | A folder matches `NN-*` but has no `epic.md` inside. | Either the folder is bogus (delete it) or the `epic.md` was lost (regenerate with `init_epic.py` and re-fill). | | `story-numbering-gaps` | Story NNs in an epic are not sequential `01..N`. The message lists what was found vs expected. | Use `rename_story.py --to-nn` to fill the gap or renumber the survivors. | +| `story-numbering-duplicates` | Two or more stories in the same epic share an NN. The message lists the duplicated NNs. | Renumber one of them with `rename_story.py --to-nn`. Don't try to renumber the survivors with `move_story.py` — moving across epics is a different operation. | +| `epic-filter-not-found` | `--epic ` was passed but no folder under `{initiative_store}/epics/` matches. | Spell-check the folder name (including the `NN-` prefix), or drop the flag to scan the whole tree. | ## Coverage findings | Code | Meaning | Fix | | --- | --- | --- | -| `coverage-missing` | An inventory code from `--inventory ` does not appear textually in any story body. **Warning** by default; **error** under `--coverage-strict`. | Use the `coverage-fix` edit-mode entry point in `prompts/edit-mode.md` — extend an existing story's `## Coverage` section, or add a new story for the missing code. | +| `coverage-missing` | An inventory code from `--inventory ` does not appear in any story's `## Coverage` section. Mentions in prose elsewhere in the body (Technical Notes, Goal, etc.) do **not** count — coverage is the AC→codes contract, not free-form description. **Warning** by default; **error** under `--coverage-strict`. | Use the `coverage-fix` edit-mode entry point in `prompts/edit-mode.md` — extend an existing story's `## Coverage` section, or add a new story for the missing code. | ## Sizing warnings diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/extract_coverage.py b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/extract_coverage.py index f4007a631..a563a05b5 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/extract_coverage.py +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/extract_coverage.py @@ -49,13 +49,24 @@ AC_LINE_RE = re.compile(r"\bAC\d+\b", re.IGNORECASE) def extract_coverage_section(text: str) -> str | None: - m = COVERAGE_HEADING_RE.search(text) - if not m: + """Return the concatenated body of every ``## Coverage`` block in ``text``. + + Both authoring flows are tolerated: replacing the template's placeholder + Coverage block in-place (the intended flow), or appending a new + ``## Coverage`` block at the end of the file (a common LLM/human mistake + that we don't want to silently flip into "uncovered"). + Returns ``None`` only when no ``## Coverage`` heading exists at all. + """ + matches = list(COVERAGE_HEADING_RE.finditer(text)) + if not matches: return None - start = m.end() - nxt = NEXT_HEADING_RE.search(text, start) - end = nxt.start() if nxt else len(text) - return text[start:end] + parts: list[str] = [] + for m in matches: + start = m.end() + nxt = NEXT_HEADING_RE.search(text, start) + end = nxt.start() if nxt else len(text) + parts.append(text[start:end]) + return "\n".join(parts) def parse_coverage_section(section: str) -> dict[str, list[str]]: diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/from_spec.py b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/from_spec.py index 1b6e1a3e9..c76e556ba 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/from_spec.py +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/from_spec.py @@ -4,7 +4,7 @@ # /// """Generate a complete v7 epic-and-story tree from a structured spec, then validate. -Spec schema (JSON or YAML-equivalent JSON): +Spec schema (JSON): { "title": "...", # initiative name (informational) "intent": "...", # one-line intent (informational) @@ -75,7 +75,7 @@ def _load_spec(path: Path) -> dict: try: return json.loads(text) except json.JSONDecodeError as exc: - raise SystemExit(f"could not parse spec as JSON: {exc}") + raise SystemExit(f"could not parse spec as JSON: {exc}") from exc def _run(cmd: list[str]) -> tuple[int, str, str]: @@ -148,6 +148,26 @@ def _validate_spec(spec: dict) -> list[str]: errs.append(f"epic[{i}].stories[{j}] missing `{k}`") if story.get("type") not in {"feature", "task", "bug", "spike"}: errs.append(f"epic[{i}].stories[{j}].type invalid: {story.get('type')!r}") + + inv = spec.get("inventory") + if inv is not None: + if not isinstance(inv, dict): + errs.append("inventory must be an object") + else: + reqs = inv.get("requirements") + if reqs is not None: + if not isinstance(reqs, dict): + errs.append("inventory.requirements must be an object keyed by category") + else: + for cat, entries in reqs.items(): + if not isinstance(entries, list): + errs.append(f"inventory.requirements.{cat} must be a list") + continue + for k_idx, entry in enumerate(entries): + if not isinstance(entry, dict): + errs.append(f"inventory.requirements.{cat}[{k_idx}] must be an object") + elif "code" not in entry: + errs.append(f"inventory.requirements.{cat}[{k_idx}] missing `code`") return errs diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/tests/test_from_spec.py b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/tests/test_from_spec.py index 6183dd814..022121a34 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/tests/test_from_spec.py +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/tests/test_from_spec.py @@ -88,6 +88,22 @@ class TestFromSpec(unittest.TestCase): data = json.loads(r.stdout) self.assertTrue(any("type invalid" in msg for msg in data["details"])) + def test_invalid_inventory_shape_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = Path(tmp) / "store" + spec_data = json.loads(json.dumps(BASE_SPEC)) + # Drop the `code` from one entry; spec validator should reject before authoring. + spec_data["inventory"]["requirements"]["functional"] = [{"text": "Missing code"}] + spec = Path(tmp) / "spec.json" + spec.write_text(json.dumps(spec_data), encoding="utf-8") + r = _run("--initiative-store", str(store), "--spec", str(spec)) + self.assertEqual(r.returncode, 1) + data = json.loads(r.stdout) + self.assertEqual(data["error"], "invalid spec") + self.assertTrue(any("missing `code`" in msg for msg in data["details"])) + # Tree must not have been created on a rejected spec. + self.assertFalse((store / "epics").exists()) + def test_coverage_strict_fails_when_missing(self) -> None: with tempfile.TemporaryDirectory() as tmp: store = Path(tmp) / "store" diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/tests/test_validate_initiative.py b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/tests/test_validate_initiative.py index 1e9d2f24d..682bfede8 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/tests/test_validate_initiative.py +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/tests/test_validate_initiative.py @@ -181,6 +181,87 @@ class TestValidateInitiative(unittest.TestCase): self.assertEqual(r.returncode, 1) self.assertIn("not found", r.stderr) + def test_within_epic_forward_ref_rejected(self) -> None: + # 01-schema must not depend on 02-register; forward-deps within the same + # epic violate the "stories build on earlier siblings" invariant. + with tempfile.TemporaryDirectory() as tmp: + store = Path(tmp) + _build_clean_tree(store) + sp = store / "epics" / "01-auth" / "01-schema.md" + sp.write_text(sp.read_text(encoding="utf-8").replace("depends_on: []", 'depends_on: ["02-register"]', 1), encoding="utf-8") + r = _run(VALIDATE, "--initiative-store", str(store)) + self.assertEqual(r.returncode, 1) + codes = {f["code"] for f in json.loads(r.stdout)["findings"]} + self.assertIn("story-dep-forward", codes) + + def test_story_dep_cycle_detected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = Path(tmp) + _build_clean_tree(store) + schema = store / "epics" / "01-auth" / "01-schema.md" + register = store / "epics" / "01-auth" / "02-register.md" + schema.write_text(schema.read_text(encoding="utf-8").replace("depends_on: []", 'depends_on: ["02-register"]', 1), encoding="utf-8") + # 02-register already depends on 01-schema; adding the reverse edge + # closes the loop. + r = _run(VALIDATE, "--initiative-store", str(store)) + self.assertEqual(r.returncode, 1) + codes = {f["code"] for f in json.loads(r.stdout)["findings"]} + self.assertIn("story-dep-cycle", codes) + + def test_story_numbering_duplicates_distinct_from_gaps(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = Path(tmp) + _build_clean_tree(store) + # Force a duplicate by hand-placing a second NN=01 file in 01-auth. + dup = store / "epics" / "01-auth" / "01-second.md" + dup.write_text( + "---\ntitle: \"Second\"\ntype: task\nstatus: draft\nepic: 01-auth\ndepends_on: []\n---\n\n# Second\n", + encoding="utf-8", + ) + r = _run(VALIDATE, "--initiative-store", str(store)) + self.assertEqual(r.returncode, 1) + codes = {f["code"] for f in json.loads(r.stdout)["findings"]} + self.assertIn("story-numbering-duplicates", codes) + self.assertNotIn("story-numbering-gaps", codes) + + def test_epic_filter_missing_folder_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = Path(tmp) + _build_clean_tree(store) + r = _run(VALIDATE, "--initiative-store", str(store), "--epic", "99-nope") + self.assertEqual(r.returncode, 1) + codes = {f["code"] for f in json.loads(r.stdout)["findings"]} + self.assertIn("epic-filter-not-found", codes) + + def test_coverage_gate_only_counts_coverage_section(self) -> None: + # FR99 mentioned only in Technical Notes must NOT count as covered, even + # though the body-wide regex would match it. + with tempfile.TemporaryDirectory() as tmp: + store = Path(tmp) + _build_clean_tree(store) + inv = store / "inventory.json" + inv.write_text(json.dumps({ + "requirements": { + "functional": [{"code": "FR1", "text": "Login"}, {"code": "FR99", "text": "Hidden"}], + } + }), encoding="utf-8") + schema = store / "epics" / "01-auth" / "01-schema.md" + text = schema.read_text(encoding="utf-8") + text = text.replace( + "- AC1 → ", + "- AC1 → FR1", + ) + text = text.replace( + "", + "We rely on FR99 here.", + ) + schema.write_text(text, encoding="utf-8") + r = _run(VALIDATE, "--initiative-store", str(store), "--inventory", str(inv), "--coverage-strict") + self.assertEqual(r.returncode, 1, r.stdout) + data = json.loads(r.stdout) + self.assertEqual(data["summary"]["coverage_missing"], ["FR99"]) + self.assertNotIn("FR99", data["summary"]["mentioned_requirements"]) + def test_lax_skips_sizing_warnings(self) -> None: # Sizing warnings fire when one body exceeds 3x the epic mean. With 5 normal # stories and one massively-padded outlier, the mean stays low enough for diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/validate_initiative.py b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/validate_initiative.py index 75f411220..a2ba7bedf 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/validate_initiative.py +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/validate_initiative.py @@ -10,10 +10,12 @@ Checks (strict mode): 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. Within-epic story numbering is sequential starting at 01. - 7. Sizing sanity (warnings only): a story body >3x the epic mean is flagged. - 8. Coverage (only when --inventory FILE is provided): every requirement code listed - in the inventory must appear textually in at least one story body. + 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. @@ -22,7 +24,8 @@ Exit codes: 0 if no errors (warnings ok), 1 if any error finding, 2 on internal 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) + --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. @@ -41,6 +44,10 @@ 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"} @@ -48,7 +55,13 @@ STORY_REQUIRED = {"title", "type", "status", "epic", "depends_on"} EPIC_KEYS = {"title", "epic", "status", "depends_on", "metadata"} EPIC_REQUIRED = {"title", "epic", "status", "depends_on"} -REQUIREMENT_CODE_RE = re.compile(r"\b(?:UX-DR|NFR|FR|D|R)\d+(?:\.\d+)?\b") +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]: @@ -211,6 +224,23 @@ def validate( 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] = {} @@ -301,7 +331,7 @@ def validate( 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(REQUIREMENT_CODE_RE.findall(stext)) + mentioned_codes.update(_story_coverage_codes(stext)) story_index[f"{ed.name}/{sf.stem}"] = { "depends_on": [str(d) for d in sdeps], "path": sf, @@ -316,9 +346,20 @@ def validate( } if in_walk and seen_nns: - expected = list(range(1, len(seen_nns) + 1)) - if sorted(seen_nns) != expected: - findings.append({"level": "error", "code": "story-numbering-gaps", "message": f"story NNs {sorted(seen_nns)} expected {expected}", "path": str(ed)}) + 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()} @@ -330,16 +371,32 @@ def validate( 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(): - if not smeta["in_walk"]: - continue + targets: list[str] = [] for d in smeta["depends_on"]: - if "/" in d: - if f"{d.split('/', 1)[0]}/{d.split('/', 1)[1]}" not in story_index: - findings.append({"level": "error", "code": "story-dep-unresolved", "message": f"cross-epic dep {d!r} references missing story", "path": str(smeta["path"])}) - else: - if f"{smeta['epic']}/{d}" not in story_index: - findings.append({"level": "error", "code": "story-dep-unresolved", "message": f"within-epic dep {d!r} not found in {smeta['epic']}", "path": str(smeta["path"])}) + 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: @@ -347,6 +404,24 @@ def validate( 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) @@ -376,7 +451,7 @@ def validate( findings.append({ "level": level, "code": "coverage-missing", - "message": f"requirement {code!r} ({text[:60]}...) not referenced by any story body" if text else f"requirement {code!r} not referenced by any story body", + "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"), })