wdi-method 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,9 +26,11 @@ method does not invoke, ask `bmad-help`, which reads BMad's own documentation.
26
26
  | Course correction | `bmad-correct-course` | `wdi-decision` |
27
27
 
28
28
  **Everything below G5 left this table.** `SPEC.md`, the tickets, the code, and the code panel are produced
29
- by `to-spec`, `to-tickets`, `implement`, `tdd`, and `code-review` — engines that are not BMad's, and that
30
- three of the five carry `disable-model-invocation: true` for, so **the owner runs them and no skill can**.
31
- `wdi-build` owns that pipeline; `bmad-guide.md` owns the reasoning.
29
+ by `to-spec`, `to-tickets`, `implement`, `tdd`, and `code-review` — engines that are not BMad's. Three of
30
+ the five ship with `disable-model-invocation: true`; `wdi-method` strips it from the copies installed in
31
+ this repo and writes a guard line naming who may drive them, so **`wdi-build` and `wdi-autopilot` invoke
32
+ them directly** and an unattended iteration needs nobody. `wdi-build` owns that pipeline;
33
+ `bmad-guide.md` owns the reasoning.
32
34
 
33
35
  **The three bold rows are why this method exists.** BMad stops at the promise and starts again at the
34
36
  mechanism, and every behaviour in between had no author. Three consequences attach to those artifacts and
@@ -50,15 +52,43 @@ A Fast Path that turns out to touch an `FR` MUST stop and become a spec `S`, whi
50
52
  |---|---|
51
53
  | `bmad-advanced-elicitation` · `bmad-party-mode` | Thinking aids. They produce no artifact and MUST NOT be treated as authors |
52
54
  | `bmad-deep-recon` | Research, before a brief rests on outside data. Its output stays in `_bmad-output/` permanently and MUST NOT be folded into the brief |
55
+ | `bmad-qa-generate-e2e-tests` | Tests for a feature that **already exists**. `tdd` is test-first for work being built, so this has no replacement here and is NOT retired — but what it writes is a test, never a contract, and it MUST NOT be read as one |
56
+ | `bmad-checkpoint-preview` | A human reading aid over a change, the same class as `bmad-advanced-elicitation`. It MUST NOT be counted as the Step 3 panel: that one is a separate dispatch by a different agent |
53
57
  | `bmad-help` | Questions about BMad itself. It MUST NOT be used to answer "where am I" — that is `wdi-help` |
54
58
 
55
59
  ## What is NOT USED, and MUST NOT be
56
60
 
61
+ **The criterion, and it binds every row below.** A BMad skill is retired only where this method has a
62
+ **named replacement** for what it produces. Without one it is not retired — it goes in the table above
63
+ instead, as something that may be used but MUST NOT author. Banning a capability with nothing in its
64
+ place is how a method gets worked around rather than followed.
65
+
66
+ ### Retired at G5 — enforced by install and update
67
+
68
+ **This is enforced, not only stated.** `install` and `update` set `disable-model-invocation: true` on
69
+ every wrapper below and add a `Skill(<name>)` deny rule to `.claude/settings.json`, both re-applied on
70
+ every run because BMad's installer rewrites its own wrappers. A person typing `/bmad-build` still gets
71
+ it: the method retires a default, it does not confiscate a tool. The list lives in
72
+ `bin/wdi-method.js` as `BMAD_RETIRED_G5`, and a test fails when this table and that array disagree.
73
+
74
+ | Skill | Replaced by | Why |
75
+ |---|---|---|
76
+ | `bmad-spec` | `to-spec` | **Retired.** The contract below G5 is no longer BMad's. Its `_bmad/custom/*.toml` override is withdrawn and `update` removes any still installed |
77
+ | `bmad-build` · `bmad-build-auto` | `implement` · `wdi-autopilot` | **Retired.** `bmad-build` describes itself as implementing "any user intent, requirement, story, bug fix or change request" — the most inviting description in the repo's skill index, for the one thing this method owns most tightly. `bmad-build-auto` is an unattended loop, which is `wdi-autopilot`'s |
78
+ | `bmad-code-review` | `code-review` | **Retired.** The panel at Step 3 is a separate dispatch by a different agent; BMad's own review layers are the builder reviewing itself |
79
+ | `bmad-retrospective` | — | **Retired** with `RTR-` and `V19`. A frozen `RTR-` file stays where it is |
80
+ | `bmad-agent-dev` | `implement` | **Retired.** "Senior software engineer for story execution and code implementation" is `implement`'s sentence |
81
+ | `bmad-create-epics-and-stories` | `to-tickets` | **Retired**, and not merely by preference: the `epics` level between a spec and its tickets is **repealed in code** — `validate.py` reads a flat `tickets:` list. A skill whose only output is a shape nothing reads |
82
+ | `bmad-create-story` · `bmad-dev-story` · `bmad-dev-auto` · `bmad-quick-dev` | `implement` | **Retired.** BMad deprecated all four in favour of `bmad-build`, which is itself retired here. An alias to a retired skill is retired |
83
+ | `bmad-sprint-planning` · `bmad-sprint-status` | the ticket itself | **Retired.** The sprint route keeps status in a hand-edited file; this method reads status from the ticket — `bmad-guide.md` owns the reasoning |
84
+
85
+ ### Not used for other reasons — not enforced, and not G5's
86
+
87
+ These are outside the enforced list above: nothing locks them, because nothing here replaces them and
88
+ none of them competes for G5's work.
89
+
57
90
  | Skill | Why |
58
91
  |---|---|
59
- | `bmad-spec` · `bmad-build` · `bmad-build-auto` · `bmad-code-review` · `bmad-retrospective` | **Retired.** The engine layer below G5 is no longer BMad's. Their `_bmad/custom/*.toml` overrides are withdrawn and `update` removes any still installed |
60
- | `bmad-create-epics-and-stories` · `bmad-sprint-planning` · `bmad-sprint-status` | They belong to the sprint route, which keeps status in a hand-edited file. This method reads status from the ticket itself — `bmad-guide.md` owns the reasoning |
61
- | `bmad-create-story` · `bmad-dev-story` · `bmad-quick-dev` · `bmad-dev-auto` | Deprecated in BMad itself, and their replacements are retired too |
62
92
  | `bmad-editorial-review*` · `bmad-review-*` | Shims onto `bmad-review` lenses. Ask for the lens, not the shim |
63
93
  | `bmad-document-project` · `bmad-generate-project-context` | Forward to `bmad-project-context`. This repo's agent instructions are maintained by hand |
64
94
  | Any skill named as a **gate condition** | A gate is passed by its checklist and its validators, never by a skill having run |
@@ -292,7 +292,11 @@ class Corpus:
292
292
 
293
293
  @property
294
294
  def spec_list(self) -> list[dict]:
295
- return rows(self.specs, "specs")
295
+ """A repo migrated before the `waves.yaml` -> `specs.yaml` rename kept its rows' own
296
+ top-level key, `waves:` — the migration renamed the FILE, byte for byte, and never touched
297
+ what is inside it (`registry-migration.test.mjs` pins that as the contract). Reading only
298
+ `specs:` here would make every spec in such a repo invisible, not merely its tickets."""
299
+ return rows(self.specs, "specs") or rows(self.specs, "waves")
296
300
 
297
301
  @property
298
302
  def defect_list(self) -> list[dict]:
@@ -304,13 +308,54 @@ class Corpus:
304
308
  FLAT. The `epics` level between a spec and its tickets is repealed: it grouped rows and
305
309
  bought nothing, and every reader here had to walk through it to reach the row it wanted.
306
310
  A ticket names its `component` directly.
311
+
312
+ A spec still carrying the pre-rename `epics: -> stories:` nesting — a wave closed before
313
+ this repeal — is read here too, via `_legacy_tickets`. Nothing is rewritten in the file:
314
+ the `W<N>` id stays a retired alias, and flattening happens once, in memory, on every run.
307
315
  """
308
316
  out = []
309
317
  for spec in self.spec_list:
310
- for ticket in sorted(spec.get("tickets") or [], key=lambda t: str(t.get("id", ""))):
311
- if isinstance(ticket, dict):
312
- out.append((spec, ticket))
313
- return out
318
+ raw = spec.get("tickets")
319
+ if raw:
320
+ for ticket in raw:
321
+ if isinstance(ticket, dict):
322
+ out.append((spec, ticket))
323
+ else:
324
+ out += [(spec, t) for t in _legacy_tickets(spec)]
325
+ return sorted(out, key=lambda pair: str(pair[1].get("id", "")))
326
+
327
+
328
+ def _legacy_tickets(spec: dict) -> list[dict]:
329
+ """Flatten a pre-rename spec's `epics: -> stories:` into ticket-shaped dicts.
330
+
331
+ A story's own id (`"1"`, `"1-1"`) only ever promised uniqueness inside one epic — the new
332
+ convention's `<spec-id>-<NN>` is global, and this method's own graphs (`no-cycles`,
333
+ `refs-resolve`) key tickets by id across every spec at once. Two waves both naming a story
334
+ `"1"` would otherwise collide into one node the moment both were read here. So every
335
+ synthesized id, and every `depends_on` reference to a sibling, is prefixed with the spec's
336
+ own id — `W3-1`, never bare `1`.
337
+
338
+ The old story key is `depends_on`; the new ticket key is `blocked_by`. No `component` field
339
+ existed on a story, so it is left unset here — a synthesized ticket does not count toward
340
+ `uc-scheduled`'s per-component `touched` set, which is correct: that check is about work not
341
+ yet scheduled, and a closed wave has nothing left to schedule.
342
+ """
343
+ sid = str(spec.get("id") or "")
344
+ out = []
345
+ for epic in spec.get("epics") or []:
346
+ if not isinstance(epic, dict):
347
+ continue
348
+ for story in epic.get("stories") or []:
349
+ if not isinstance(story, dict):
350
+ continue
351
+ ticket = {k: v for k, v in story.items() if k not in ("id", "depends_on")}
352
+ ticket["id"] = f"{sid}-{story.get('id')}"
353
+ ticket["blocked_by"] = [f"{sid}-{d}" for d in (story.get("depends_on") or [])]
354
+ # The story's OWN id is kept because the file on disk is named after it, not after the
355
+ # synthesized ticket id — see `_ticket_files`.
356
+ ticket["_legacy_story_id"] = str(story.get("id") or "")
357
+ out.append(ticket)
358
+ return out
314
359
 
315
360
 
316
361
  def listy(row: dict, key: str) -> list[str]:
@@ -710,8 +755,13 @@ def review_trace(c: Corpus, r: Result) -> None: # was V13
710
755
  + ", ".join(sorted(stale_advisory)))
711
756
 
712
757
 
713
- def cap_tickets(c: Corpus) -> dict[str, list[dict]]:
714
- """CAP -> ticket, traced through CAP -> FR -> UC -> ticket. No git, no timeline."""
758
+ def cap_tickets(c: Corpus) -> dict[str, list[tuple[dict, dict]]]:
759
+ """CAP -> (spec, ticket), traced through CAP -> FR -> UC -> ticket. No git, no timeline.
760
+
761
+ The SPEC travels with its ticket because a ticket's status cannot be read without it — a closed
762
+ spec answers for its own tickets (`_ticket_status`). Dropping it here is what made `plan-dates`
763
+ raise `TypeError` instead of reporting, and a raise there takes the whole run with it.
764
+ """
715
765
  frs_of: dict[str, list[str]] = {}
716
766
  for fr in c.frs:
717
767
  frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
@@ -719,11 +769,11 @@ def cap_tickets(c: Corpus) -> dict[str, list[dict]]:
719
769
  for uc in c.ucs:
720
770
  for fid in listy(uc, "satisfies"):
721
771
  ucs_of.setdefault(fid, []).append(str(uc.get("id")))
722
- out: dict[str, list[dict]] = {}
772
+ out: dict[str, list[tuple[dict, dict]]] = {}
723
773
  for cap in c.caps:
724
774
  cid = str(cap.get("id"))
725
775
  wanted = {u for fid in frs_of.get(cid, []) for u in ucs_of.get(fid, [])}
726
- out[cid] = [t for _, t in c.tickets()
776
+ out[cid] = [(spec, t) for spec, t in c.tickets()
727
777
  if wanted & set(listy(t, "satisfies"))]
728
778
  return out
729
779
 
@@ -749,7 +799,7 @@ def plan_dates(c: Corpus, r: Result, asof: dt.date) -> None: # was V14
749
799
  r.fail("plan-dates", cid, f"`planned_end` `{end}` is not an ISO date")
750
800
  continue
751
801
  items = by_cap.get(cid, [])
752
- closed = bool(items) and all(_ticket_status(c, t) == "done" for t in items)
802
+ closed = bool(items) and all(_ticket_status(c, spec, t) == "done" for spec, t in items)
753
803
  if closed or due >= asof:
754
804
  continue
755
805
  late = (asof - due).days
@@ -808,19 +858,28 @@ def ticket_status_one_home(c: Corpus, r: Result) -> None: # was V18
808
858
  shape belongs to the engine that writes them — one file per ticket, numbered from `01` in
809
859
  dependency order — and that number is the tail of the ticket id, which is why `SPEC-3-01`
810
860
  finds `issues/01-*.md`.
861
+
862
+ A CLOSED spec is exempt from the file being present: Phase 4 distillation is what closed it,
863
+ and distillation is what may have removed the file — "dies with it" is not a defect to report
864
+ back at G5. `status` copied into `specs.yaml` is still checked on every ticket regardless, and a
865
+ file that IS present but states no status is still a finding — both are about the record lying,
866
+ not about whether the record still exists.
811
867
  """
812
868
  for spec, ticket in c.tickets():
813
869
  sid = str(ticket.get("id"))
814
870
  if str(ticket.get("status") or "").strip():
815
871
  r.fail("ticket-status-one-home", sid, "carries a `status` in specs.yaml — status lives in the ticket "
816
872
  "file, and two homes for one fact is how a registry starts lying")
873
+ closed = str(spec.get("status") or "").strip() == "closed"
817
874
  folder = _spec_folder(spec, ticket)
818
875
  if not folder:
819
- r.fail("ticket-status-one-home", sid, "its spec does not name a `spec_folder`")
876
+ if not closed:
877
+ r.fail("ticket-status-one-home", sid, "its spec does not name a `spec_folder`")
820
878
  continue
821
879
  matches = _ticket_files(c, spec, ticket)
822
880
  if not matches:
823
- r.fail("ticket-status-one-home", sid, f"has no ticket file under {folder}issues/")
881
+ if not closed:
882
+ r.fail("ticket-status-one-home", sid, f"has no ticket file under {folder}issues/")
824
883
  continue
825
884
  if _read_status(matches[0]) == "unknown":
826
885
  r.fail("ticket-status-one-home", sid, "ticket file states no status — neither a `**Status:**` line nor "
@@ -1431,7 +1490,8 @@ def custom_room_declared(c: Corpus, r: Result) -> None: # was V27
1431
1490
  #
1432
1491
  # The two rendered trees are DELIBERATELY absent from this list. They are regenerated by this
1433
1492
  # script, so a product that declines to commit derived output is making a choice the method allows.
1434
- COMMITTED_DIRS = (".constitution", ".control", ".what", ".how", "_bmad-output", ".work")
1493
+ COMMITTED_DIRS = (".constitution", ".control", ".what", ".how", "_bmad-output", ".work",
1494
+ ".scratch")
1435
1495
 
1436
1496
  # Probed inside each directory above, and named so that no honest pattern would ever mean to match
1437
1497
  # it. The distinction this draws is the entire point of the check: `.work/upstream/` or
@@ -1489,6 +1549,76 @@ def corpus_in_git(c: Corpus, r: Result) -> None:
1489
1549
  f"so no clone has what is in it")
1490
1550
 
1491
1551
 
1552
+ ENGINE_HOMES = (".claude", ".agents", ".agent", ".cursor", ".codex")
1553
+ ENGINE_FLAGGED = ("to-spec", "to-tickets", "implement")
1554
+ ENGINE_SKILLS = ENGINE_FLAGGED + ("tdd", "code-review", "domain-modeling")
1555
+ FLAG_RE = re.compile(r"^disable-model-invocation\s*:\s*true", re.M)
1556
+
1557
+
1558
+ def _engine_files(root: Path, name: str) -> list[Path]:
1559
+ """Every copy of one engine, de-duplicated by what it resolves to.
1560
+
1561
+ `npx skills add` can symlink one SKILL.md into several platform folders, so the same file is
1562
+ reachable under `.claude/skills/` and `.agents/skills/` at once. Reporting it twice would make
1563
+ one finding read as two.
1564
+ """
1565
+ seen: dict[Path, Path] = {}
1566
+ for home in ENGINE_HOMES:
1567
+ path = root / home / "skills" / name / "SKILL.md"
1568
+ if not path.is_file():
1569
+ continue
1570
+ try:
1571
+ seen.setdefault(path.resolve(), path)
1572
+ except OSError:
1573
+ seen.setdefault(path, path)
1574
+ return list(seen.values())
1575
+
1576
+
1577
+ def _frontmatter(text: str) -> str:
1578
+ m = FM.match(text)
1579
+ return m.group(1) if m else ""
1580
+
1581
+
1582
+ def engines_invocable(c: Corpus, r: Result) -> None:
1583
+ """The three flagged engines MUST be invocable in the repo's own copies.
1584
+
1585
+ `to-spec`, `to-tickets` and `implement` ship with `disable-model-invocation: true`. Nothing
1586
+ outside the file lifts it — Claude Code's gate reads the frontmatter and consults no setting,
1587
+ and `skillOverrides` only ever tightens — so `wdi-method` strips the key from the copies the
1588
+ repo owns and writes one guard line in its place. That is what lets `wdi-build` invoke an engine
1589
+ and `wdi-autopilot` run an iteration with nobody watching.
1590
+
1591
+ Two things put the key back, both routine: `npx skills update`, which restores the author's file
1592
+ byte for byte, and a fresh `npx skills add`. Neither says anything, and the failure surfaces
1593
+ hours later as an unattended run that stalled at Phase 2 with no explanation. This is the check
1594
+ that turns that into a line of output.
1595
+
1596
+ Checked in the FRONTMATTER, not the file: the guard line is prose about the key, and matching
1597
+ the whole file would fail on the fix rather than on the defect.
1598
+
1599
+ A repo with no engines at all is SKIPPED, not failed. `--skip-engines-check` is a supported
1600
+ install — CI, and a repo that will never reach G5 — and a validator that is permanently red
1601
+ there is a validator that gets ignored everywhere else.
1602
+ """
1603
+ installed = {name: _engine_files(c.root, name) for name in ENGINE_SKILLS}
1604
+ if not any(installed.values()):
1605
+ r.skip("engines-invocable", "no engines in this repo — G5 cannot run until they are "
1606
+ "installed (`npx skills@latest add mattpocock/skills`), and until then "
1607
+ "there is nothing here to check")
1608
+ return
1609
+ for name in ENGINE_SKILLS:
1610
+ if not installed[name]:
1611
+ r.fail("engines-invocable", name, "is not installed in this repo — G5 needs all six, and "
1612
+ "a user-level plugin does not count: its files are not this repo's to unlock")
1613
+ for name in ENGINE_FLAGGED:
1614
+ for path in installed[name]:
1615
+ if FLAG_RE.search(_frontmatter(path.read_text(encoding="utf-8", errors="replace"))):
1616
+ r.fail("engines-invocable", name,
1617
+ f"{path.relative_to(c.root).as_posix()} carries `disable-model-invocation` "
1618
+ f"again — no skill can invoke it, so wdi-build stops at Phase 2. `npx skills "
1619
+ f"update` restores it; `npx wdi-method engines --fix` strips it back out")
1620
+
1621
+
1492
1622
  def id_allocated_once(c: Corpus, r: Result) -> None: # was V28
1493
1623
  """One id, one row — across every file the requirement registry is split into.
1494
1624
 
@@ -1523,7 +1653,7 @@ def run_checks(c: Corpus, asof: dt.date) -> Result:
1523
1653
  # no two copies left to compare.
1524
1654
  # V19 is REPEALED. It checked one line item — an `RTR-` file in .control/reports/ — and the
1525
1655
  # retrospective it archived was the only thing spec size `L` ever decided. Both went together.
1526
- for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, mandate_accept, cites_resolve, container_built, custom_room_declared, corpus_in_git, id_allocated_once):
1656
+ for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, mandate_accept, cites_resolve, container_built, custom_room_declared, corpus_in_git, engines_invocable, id_allocated_once):
1527
1657
  fn(c, r)
1528
1658
  plan_dates(c, r, asof)
1529
1659
  return r
@@ -1547,16 +1677,24 @@ def _ticket_files(c: Corpus, spec: dict, ticket: dict) -> list[Path]:
1547
1677
 
1548
1678
  The full id is tried too, so a product that names its files after the whole id is not punished
1549
1679
  for a convention this method never demanded of it.
1680
+
1681
+ `issues/` arrived WITH the flat `tickets:` shape. A pre-rename wave's files are in
1682
+ `{spec_folder}/stories/`, named by the story's own id (`1-2-<slug>.md`) — so a synthesized
1683
+ legacy ticket is looked up by that id, in both folders, BEFORE the tail-of-the-id fallback:
1684
+ the tail of `W1-1-2` is `2`, which would find nothing here and `1-*.md` for every story in the
1685
+ wave elsewhere. Reporting those as missing reports the migration, not a defect.
1550
1686
  """
1551
1687
  folder = _spec_folder(spec, ticket)
1552
1688
  if not folder:
1553
1689
  return []
1554
1690
  tid = str(ticket.get("id") or "")
1555
- issues = c.root / folder / "issues"
1556
- for stem in (tid.rsplit("-", 1)[-1], tid):
1691
+ story = str(ticket.get("_legacy_story_id") or "")
1692
+ tries = [("stories", story), ("issues", story)] if story else []
1693
+ tries += [("issues", tid.rsplit("-", 1)[-1]), ("issues", tid)]
1694
+ for sub_dir, stem in tries:
1557
1695
  if not stem:
1558
1696
  continue
1559
- found = sorted(issues.glob(f"{stem}-*.md"))
1697
+ found = sorted((c.root / folder / sub_dir).glob(f"{stem}-*.md"))
1560
1698
  if found:
1561
1699
  return found
1562
1700
  return []
@@ -1593,6 +1731,12 @@ def _read_status(path: Path) -> str:
1593
1731
 
1594
1732
 
1595
1733
  def _ticket_status(c: Corpus, spec: dict, ticket: dict) -> str:
1734
+ """A closed spec's tickets are done — Phase 4 gates closure on RTM already being green, and its
1735
+ ticket files may legitimately be gone by then (distillation says they die with the spec). Asking
1736
+ the filesystem what closure already answered is how a correct history goes red on file cleanup.
1737
+ """
1738
+ if str(spec.get("status") or "").strip() == "closed":
1739
+ return "done"
1596
1740
  matches = _ticket_files(c, spec, ticket)
1597
1741
  return _read_status(matches[0]) if matches else "unknown"
1598
1742
 
@@ -1712,7 +1856,7 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
1712
1856
  per_spec.append({"spec": wid, "status": spec.get("status"),
1713
1857
  "tickets_done": done, "tickets_total": len(items),
1714
1858
  "work_progress": _pct(done, len(items))})
1715
- applicable = 26 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
1859
+ applicable = 27 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
1716
1860
  return {
1717
1861
  "promise_progress": _pct(green, len(counted)),
1718
1862
  "rtm_rows": {"green": green, "counted": len(counted),