ran through analysis with codex this time
This commit is contained in:
parent
1136a59e0c
commit
e80d00d66e
|
|
@ -51,7 +51,7 @@ Execute each entry in `{workflow.activation_steps_prepend}` in order before proc
|
||||||
|
|
||||||
### Step 3: Load Persistent Facts
|
### 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
|
### Step 4: Load Config
|
||||||
|
|
||||||
|
|
@ -94,7 +94,7 @@ If the user passed `--from-spec <path>`, 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/` 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 `{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`)
|
### 4. Edit sub-mode dispatch (only when `{mode}=edit`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,12 @@ activation_steps_append = []
|
||||||
#
|
#
|
||||||
# Each entry is either:
|
# Each entry is either:
|
||||||
# - a literal sentence, e.g. "Stories must reference at least one FR or UX-DR."
|
# - 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"
|
# - a file reference prefixed with `file:`. The path may use either
|
||||||
# (glob patterns are supported; the file's contents are loaded and treated as facts).
|
# `{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 = [
|
persistent_facts = [
|
||||||
"file:{project-root}/**/project-context.md",
|
"file:{project-root}/**/project-context.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.
|
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 <path> — 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
|
## Ambiguous-intent menu
|
||||||
|
|
||||||
If the user's opening message did not match any sub-mode, ask:
|
If the user's opening message did not match any sub-mode, ask:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,11 @@ python3 scripts/validate_initiative.py --initiative-store {initiative_store} --s
|
||||||
From the JSON, surface a 3-line block such as:
|
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.
|
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
|
## Step 3: Confirm initial statuses
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@ python3 scripts/validate_initiative.py --initiative-store {initiative_store} \
|
||||||
--inventory {initiative_store}/.bmad-cache/inventory.json
|
--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:
|
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.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.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 found in any story body (only populated when `--inventory` was passed).
|
- `summary.coverage_missing` — codes from the inventory not claimed by any story's `## Coverage` section (only populated when `--inventory` was passed).
|
||||||
|
|
||||||
## Headless mode
|
## 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.
|
- **`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.
|
- **`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.
|
- **`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
|
### 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`.
|
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
|
#### Speeding up the auditor with a deterministic pre-pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-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. |
|
| `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 `<folder>/<basename>` 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-unresolved` | A story's `depends_on` entry doesn't resolve — either as a within-epic basename or as a cross-epic `<folder>/<basename>` 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 `<epic>/<basename>` 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
|
## 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. |
|
| `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). |
|
| `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-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 <name>` 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
|
## Coverage findings
|
||||||
|
|
||||||
| Code | Meaning | Fix |
|
| Code | Meaning | Fix |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `coverage-missing` | An inventory code from `--inventory <file>` 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 <file>` 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
|
## Sizing warnings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,24 @@ AC_LINE_RE = re.compile(r"\bAC\d+\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def extract_coverage_section(text: str) -> str | None:
|
def extract_coverage_section(text: str) -> str | None:
|
||||||
m = COVERAGE_HEADING_RE.search(text)
|
"""Return the concatenated body of every ``## Coverage`` block in ``text``.
|
||||||
if not m:
|
|
||||||
|
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
|
return None
|
||||||
start = m.end()
|
parts: list[str] = []
|
||||||
nxt = NEXT_HEADING_RE.search(text, start)
|
for m in matches:
|
||||||
end = nxt.start() if nxt else len(text)
|
start = m.end()
|
||||||
return text[start: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]]:
|
def parse_coverage_section(section: str) -> dict[str, list[str]]:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
# ///
|
# ///
|
||||||
"""Generate a complete v7 epic-and-story tree from a structured spec, then validate.
|
"""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)
|
"title": "...", # initiative name (informational)
|
||||||
"intent": "...", # one-line intent (informational)
|
"intent": "...", # one-line intent (informational)
|
||||||
|
|
@ -75,7 +75,7 @@ def _load_spec(path: Path) -> dict:
|
||||||
try:
|
try:
|
||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
except json.JSONDecodeError as exc:
|
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]:
|
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}`")
|
errs.append(f"epic[{i}].stories[{j}] missing `{k}`")
|
||||||
if story.get("type") not in {"feature", "task", "bug", "spike"}:
|
if story.get("type") not in {"feature", "task", "bug", "spike"}:
|
||||||
errs.append(f"epic[{i}].stories[{j}].type invalid: {story.get('type')!r}")
|
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
|
return errs
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,22 @@ class TestFromSpec(unittest.TestCase):
|
||||||
data = json.loads(r.stdout)
|
data = json.loads(r.stdout)
|
||||||
self.assertTrue(any("type invalid" in msg for msg in data["details"]))
|
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:
|
def test_coverage_strict_fails_when_missing(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
store = Path(tmp) / "store"
|
store = Path(tmp) / "store"
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,87 @@ class TestValidateInitiative(unittest.TestCase):
|
||||||
self.assertEqual(r.returncode, 1)
|
self.assertEqual(r.returncode, 1)
|
||||||
self.assertIn("not found", r.stderr)
|
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 → <list of FR / NFR / UX-DR codes>",
|
||||||
|
"- AC1 → FR1",
|
||||||
|
)
|
||||||
|
text = text.replace(
|
||||||
|
"<Implementation hints — file paths, API contracts, gotchas, references into the epic's Shared Context. Not a full design; just what saves the implementer a lookup.>",
|
||||||
|
"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:
|
def test_lax_skips_sizing_warnings(self) -> None:
|
||||||
# Sizing warnings fire when one body exceeds 3x the epic mean. With 5 normal
|
# 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
|
# stories and one massively-padded outlier, the mean stays low enough for
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ Checks (strict mode):
|
||||||
3. Story `epic:` field equals the enclosing folder name; epic `epic:` field equals the folder NN.
|
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 <epic-folder>/<basename> cross-epic).
|
4. depends_on entries resolve (within-epic basenames or <epic-folder>/<basename> cross-epic).
|
||||||
5. Cross-epic depends_on graph is acyclic.
|
5. Cross-epic depends_on graph is acyclic.
|
||||||
6. Within-epic story numbering is sequential starting at 01.
|
6. Story-level depends_on graph is acyclic; within-epic deps must point at earlier siblings.
|
||||||
7. Sizing sanity (warnings only): a story body >3x the epic mean is flagged.
|
7. Within-epic story numbering is sequential starting at 01, with no duplicates.
|
||||||
8. Coverage (only when --inventory FILE is provided): every requirement code listed
|
8. Sizing sanity (warnings only): a story body >3x the epic mean is flagged.
|
||||||
in the inventory must appear textually in at least one story body.
|
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": {...}}
|
Output (stdout, JSON): {"findings": [...], "summary": {...}}
|
||||||
--summary-only emits a structured tree block instead, used by edit-mode and finalize.
|
--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:
|
Flags:
|
||||||
--lax skip sizing warnings; never relaxes schema or dep checks
|
--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,
|
--inventory FILE path to inventory.json (or .bmad-cache/inventory.json); when present,
|
||||||
missing requirement codes are reported. Default level is warning;
|
missing requirement codes are reported. Default level is warning;
|
||||||
pair with --coverage-strict to escalate to error.
|
pair with --coverage-strict to escalate to error.
|
||||||
|
|
@ -41,6 +44,10 @@ import sys
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from pathlib import Path
|
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"}
|
STORY_TYPES = {"feature", "bug", "task", "spike"}
|
||||||
STATUSES = {"draft", "ready", "in-progress", "review", "done", "blocked"}
|
STATUSES = {"draft", "ready", "in-progress", "review", "done", "blocked"}
|
||||||
STORY_KEYS = {"title", "type", "status", "epic", "depends_on", "metadata"}
|
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_KEYS = {"title", "epic", "status", "depends_on", "metadata"}
|
||||||
EPIC_REQUIRED = {"title", "epic", "status", "depends_on"}
|
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]:
|
def parse_frontmatter(text: str) -> tuple[dict | None, str | None]:
|
||||||
|
|
@ -211,6 +224,23 @@ def validate(
|
||||||
return findings, {}
|
return findings, {}
|
||||||
|
|
||||||
all_epic_folders = sorted(p for p in epics_dir.iterdir() if p.is_dir() and re.match(r"^\d+-", p.name))
|
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)]
|
walk_folders = [p for p in all_epic_folders if (only_epic is None or p.name == only_epic)]
|
||||||
|
|
||||||
epic_meta: dict[str, dict] = {}
|
epic_meta: dict[str, dict] = {}
|
||||||
|
|
@ -301,7 +331,7 @@ def validate(
|
||||||
if in_walk:
|
if in_walk:
|
||||||
findings.append({"level": "error", "code": "story-deps-not-list", "message": "depends_on must be a list", "path": str(sf)})
|
findings.append({"level": "error", "code": "story-deps-not-list", "message": "depends_on must be a list", "path": str(sf)})
|
||||||
sdeps = []
|
sdeps = []
|
||||||
mentioned_codes.update(REQUIREMENT_CODE_RE.findall(stext))
|
mentioned_codes.update(_story_coverage_codes(stext))
|
||||||
story_index[f"{ed.name}/{sf.stem}"] = {
|
story_index[f"{ed.name}/{sf.stem}"] = {
|
||||||
"depends_on": [str(d) for d in sdeps],
|
"depends_on": [str(d) for d in sdeps],
|
||||||
"path": sf,
|
"path": sf,
|
||||||
|
|
@ -316,9 +346,20 @@ def validate(
|
||||||
}
|
}
|
||||||
|
|
||||||
if in_walk and seen_nns:
|
if in_walk and seen_nns:
|
||||||
expected = list(range(1, len(seen_nns) + 1))
|
counts = Counter(seen_nns)
|
||||||
if sorted(seen_nns) != expected:
|
duplicates = sorted(n for n, c in counts.items() if c > 1)
|
||||||
findings.append({"level": "error", "code": "story-numbering-gaps", "message": f"story NNs {sorted(seen_nns)} expected {expected}", "path": str(ed)})
|
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
|
# depends_on resolution
|
||||||
epic_nns = {meta["nn"]: name for name, meta in epic_meta.items()}
|
epic_nns = {meta["nn"]: name for name, meta in epic_meta.items()}
|
||||||
|
|
@ -330,16 +371,32 @@ def validate(
|
||||||
if d2 not in epic_nns:
|
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"])})
|
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():
|
for skey, smeta in story_index.items():
|
||||||
if not smeta["in_walk"]:
|
targets: list[str] = []
|
||||||
continue
|
|
||||||
for d in smeta["depends_on"]:
|
for d in smeta["depends_on"]:
|
||||||
if "/" in d:
|
target_key = d if "/" in d else f"{smeta['epic']}/{d}"
|
||||||
if f"{d.split('/', 1)[0]}/{d.split('/', 1)[1]}" not in story_index:
|
if target_key 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"])})
|
if smeta["in_walk"]:
|
||||||
else:
|
if "/" in d:
|
||||||
if f"{smeta['epic']}/{d}" 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"])})
|
||||||
findings.append({"level": "error", "code": "story-dep-unresolved", "message": f"within-epic dep {d!r} not found in {smeta['epic']}", "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)
|
# epic dep cycles (compute on whole tree; report once)
|
||||||
if walk_set:
|
if walk_set:
|
||||||
|
|
@ -347,6 +404,24 @@ def validate(
|
||||||
for cyc in _find_cycles(cycle_graph):
|
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)})
|
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
|
# sizing warnings
|
||||||
if not lax:
|
if not lax:
|
||||||
by_epic: dict[str, list] = defaultdict(list)
|
by_epic: dict[str, list] = defaultdict(list)
|
||||||
|
|
@ -376,7 +451,7 @@ def validate(
|
||||||
findings.append({
|
findings.append({
|
||||||
"level": level,
|
"level": level,
|
||||||
"code": "coverage-missing",
|
"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"),
|
"path": str(initiative_store / "epics"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue