wdi-method 0.6.7 → 0.6.15

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.
@@ -185,6 +185,27 @@ def git(root: Path, *args: str) -> str | None:
185
185
  REQUIREMENT_KEYS = ("goals", "capabilities", "functional", "nonfunctional", "journeys")
186
186
 
187
187
 
188
+ def is_withdrawn(row: dict) -> bool:
189
+ """`status: withdrawn` — the product stopped promising this, and the row STAYED.
190
+
191
+ Deleting it is what a repo used to do, and the cost was measured: two capabilities withdrawn by
192
+ decision, their rows removed, and twelve `refs-resolve` findings — eight `DEC-` rows still named
193
+ them, six of which genuinely served them at the time. `corpus-guide.md` forbids the other repair:
194
+ a retired name in a record of what happened is a fact about the past, and a `DEC-` is exactly
195
+ that record.
196
+
197
+ So a withdrawn row is read TWO ways. It is still **defined** — every old reference resolves and
198
+ `id-allocated-once` still refuses the number to anyone else. It is no longer **promised** — no UC
199
+ is owed, no ticket, no RTM row, and `promise_progress` is not dragged down by something nobody
200
+ promises.
201
+ """
202
+ return str(row.get("status") or "").strip().lower() == "withdrawn"
203
+
204
+
205
+ def promised(items: list[dict]) -> list[dict]:
206
+ return [row for row in items if not is_withdrawn(row)]
207
+
208
+
188
209
  @dataclass
189
210
  class Corpus:
190
211
  root: Path
@@ -253,23 +274,23 @@ class Corpus:
253
274
  # --- shortcuts used repeatedly
254
275
  @property
255
276
  def goals(self) -> list[dict]:
256
- return rows(self.requirements, "goals")
277
+ return promised(rows(self.requirements, "goals"))
257
278
 
258
279
  @property
259
280
  def caps(self) -> list[dict]:
260
- return rows(self.requirements, "capabilities")
281
+ return promised(rows(self.requirements, "capabilities"))
261
282
 
262
283
  @property
263
284
  def frs(self) -> list[dict]:
264
- return rows(self.requirements, "functional")
285
+ return promised(rows(self.requirements, "functional"))
265
286
 
266
287
  @property
267
288
  def nfrs(self) -> list[dict]:
268
- return rows(self.requirements, "nonfunctional")
289
+ return promised(rows(self.requirements, "nonfunctional"))
269
290
 
270
291
  @property
271
292
  def ucs(self) -> list[dict]:
272
- return rows(self.usecases, "usecases")
293
+ return promised(rows(self.usecases, "usecases"))
273
294
 
274
295
  @property
275
296
  def decs(self) -> list[dict]:
@@ -282,6 +303,15 @@ class Corpus:
282
303
  return own
283
304
  return str(self.index.get("mode") or "").strip() or "catalog"
284
305
 
306
+ @property
307
+ def withdrawn_rows(self) -> list[dict]:
308
+ """Every withdrawn requirement row, with the key it came from — the id side of the split."""
309
+ out = []
310
+ for key in REQUIREMENT_KEYS:
311
+ out += [(key, row) for row in rows(self.requirements, key) if is_withdrawn(row)]
312
+ out += [("usecases", row) for row in rows(self.usecases, "usecases") if is_withdrawn(row)]
313
+ return [row for _, row in out]
314
+
285
315
  @property
286
316
  def lcs(self) -> list[dict]:
287
317
  return rows(self.components, "logical_components")
@@ -341,6 +371,18 @@ def _legacy_tickets(spec: dict) -> list[dict]:
341
371
  yet scheduled, and a closed wave has nothing left to schedule.
342
372
  """
343
373
  sid = str(spec.get("id") or "")
374
+
375
+ def scoped(raw: str) -> str:
376
+ """`W7-S2` under wave `W7` stays `W7-S2`, not `W7-W7-S2`.
377
+
378
+ The prefix exists so two waves both naming a story `"1"` cannot collide into one node. A
379
+ story already scoped to its wave has nothing to collide with, and prefixing it again produces
380
+ an id that matches no file, no memlog line, and nothing a person would search for. Measured on
381
+ a live repo whose RTM read `W7-W7-S2`.
382
+ """
383
+ sub = str(raw)
384
+ return sub if sid and sub.startswith(f"{sid}-") else f"{sid}-{sub}"
385
+
344
386
  out = []
345
387
  for epic in spec.get("epics") or []:
346
388
  if not isinstance(epic, dict):
@@ -349,8 +391,8 @@ def _legacy_tickets(spec: dict) -> list[dict]:
349
391
  if not isinstance(story, dict):
350
392
  continue
351
393
  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 [])]
394
+ ticket["id"] = scoped(story.get("id"))
395
+ ticket["blocked_by"] = [scoped(d) for d in (story.get("depends_on") or [])]
354
396
  # The story's OWN id is kept because the file on disk is named after it, not after the
355
397
  # synthesized ticket id — see `_ticket_files`.
356
398
  ticket["_legacy_story_id"] = str(story.get("id") or "")
@@ -461,6 +503,8 @@ def refs_resolve(c: Corpus, r: Result) -> None: # was V6
461
503
  defined.add(str(spec.get("id")))
462
504
  for _, ticket in c.tickets():
463
505
  defined.add(str(ticket.get("id")))
506
+ # Withdrawn, therefore still defined. This is the whole point of keeping the row.
507
+ defined |= {str(row.get("id")) for row in c.withdrawn_rows if row.get("id") is not None}
464
508
 
465
509
  refs: list[tuple[str, str]] = []
466
510
  for cap in c.caps:
@@ -482,9 +526,21 @@ def refs_resolve(c: Corpus, r: Result) -> None: # was V6
482
526
  refs += [(str(ticket.get("id")), u) for u in listy(ticket, "satisfies")]
483
527
  refs += [(str(ticket.get("id")), b) for b in listy(ticket, "blocked_by")]
484
528
 
529
+ # A promise's id going missing has one likely cause and one wrong-looking-obvious repair. The
530
+ # cause: the row was DELETED when the product stopped promising it. The wrong repair: edit the
531
+ # reference — which `corpus-guide.md` refuses, because a `DEC-` records what happened and it did
532
+ # serve that promise at the time. One repo carried twelve of these before anyone worked out that
533
+ # the row was meant to stay, so the route travels with the finding.
534
+ promise_id = re.compile(r"^(BG|CAP|FR|NFR|UC)-\d+$")
485
535
  for owner, target in sorted(set(refs)):
486
536
  if target and target not in defined:
487
- r.fail("refs-resolve", owner, f"points to `{target}` which does not exist in any registry")
537
+ hint = ""
538
+ if promise_id.match(target):
539
+ hint = (" — if it was withdrawn, the row STAYS with `status: withdrawn` and a "
540
+ "`withdrawn_by`, and deleting it is what broke this reference (corpus-guide.md). "
541
+ "Editing the reference instead rewrites a record of the past")
542
+ r.fail("refs-resolve", owner,
543
+ f"points to `{target}` which does not exist in any registry{hint}")
488
544
 
489
545
 
490
546
  def _cycles(graph: dict[str, list[str]]) -> list[str]:
@@ -1490,7 +1546,8 @@ def custom_room_declared(c: Corpus, r: Result) -> None: # was V27
1490
1546
  #
1491
1547
  # The two rendered trees are DELIBERATELY absent from this list. They are regenerated by this
1492
1548
  # script, so a product that declines to commit derived output is making a choice the method allows.
1493
- COMMITTED_DIRS = (".constitution", ".control", ".what", ".how", "_bmad-output", ".work")
1549
+ COMMITTED_DIRS = (".constitution", ".control", ".what", ".how", "_bmad-output", ".work",
1550
+ ".scratch")
1494
1551
 
1495
1552
  # Probed inside each directory above, and named so that no honest pattern would ever mean to match
1496
1553
  # it. The distinction this draws is the entire point of the check: `.work/upstream/` or
@@ -1548,6 +1605,117 @@ def corpus_in_git(c: Corpus, r: Result) -> None:
1548
1605
  f"so no clone has what is in it")
1549
1606
 
1550
1607
 
1608
+ ENGINE_HOMES = (".claude", ".agents", ".agent", ".cursor", ".codex")
1609
+ ENGINE_FLAGGED = ("to-spec", "to-tickets", "implement")
1610
+ ENGINE_SKILLS = ENGINE_FLAGGED + ("tdd", "code-review", "domain-modeling")
1611
+ FLAG_RE = re.compile(r"^disable-model-invocation\s*:\s*true", re.M)
1612
+
1613
+
1614
+ def _engine_files(root: Path, name: str) -> list[Path]:
1615
+ """Every copy of one engine, de-duplicated by what it resolves to.
1616
+
1617
+ `npx skills add` can symlink one SKILL.md into several platform folders, so the same file is
1618
+ reachable under `.claude/skills/` and `.agents/skills/` at once. Reporting it twice would make
1619
+ one finding read as two.
1620
+ """
1621
+ seen: dict[Path, Path] = {}
1622
+ for home in ENGINE_HOMES:
1623
+ path = root / home / "skills" / name / "SKILL.md"
1624
+ if not path.is_file():
1625
+ continue
1626
+ try:
1627
+ seen.setdefault(path.resolve(), path)
1628
+ except OSError:
1629
+ seen.setdefault(path, path)
1630
+ return list(seen.values())
1631
+
1632
+
1633
+ def _frontmatter(text: str) -> str:
1634
+ m = FM.match(text)
1635
+ return m.group(1) if m else ""
1636
+
1637
+
1638
+ def engines_invocable(c: Corpus, r: Result) -> None:
1639
+ """The three flagged engines MUST be invocable in the repo's own copies.
1640
+
1641
+ `to-spec`, `to-tickets` and `implement` ship with `disable-model-invocation: true`. Nothing
1642
+ outside the file lifts it — Claude Code's gate reads the frontmatter and consults no setting,
1643
+ and `skillOverrides` only ever tightens — so `wdi-method` strips the key from the copies the
1644
+ repo owns and writes one guard line in its place. That is what lets `wdi-build` invoke an engine
1645
+ and `wdi-autopilot` run an iteration with nobody watching.
1646
+
1647
+ Two things put the key back, both routine: `npx skills update`, which restores the author's file
1648
+ byte for byte, and a fresh `npx skills add`. Neither says anything, and the failure surfaces
1649
+ hours later as an unattended run that stalled at Phase 2 with no explanation. This is the check
1650
+ that turns that into a line of output.
1651
+
1652
+ Checked in the FRONTMATTER, not the file: the guard line is prose about the key, and matching
1653
+ the whole file would fail on the fix rather than on the defect.
1654
+
1655
+ A repo with no engines at all is SKIPPED, not failed. `--skip-engines-check` is a supported
1656
+ install — CI, and a repo that will never reach G5 — and a validator that is permanently red
1657
+ there is a validator that gets ignored everywhere else.
1658
+ """
1659
+ installed = {name: _engine_files(c.root, name) for name in ENGINE_SKILLS}
1660
+ if not any(installed.values()):
1661
+ r.skip("engines-invocable", "no engines in this repo — G5 cannot run until they are "
1662
+ "installed (`npx skills@latest add mattpocock/skills`), and until then "
1663
+ "there is nothing here to check")
1664
+ return
1665
+ for name in ENGINE_SKILLS:
1666
+ if not installed[name]:
1667
+ r.fail("engines-invocable", name, "is not installed in this repo — G5 needs all six, and "
1668
+ "a user-level plugin does not count: its files are not this repo's to unlock")
1669
+ for name in ENGINE_FLAGGED:
1670
+ for path in installed[name]:
1671
+ if FLAG_RE.search(_frontmatter(path.read_text(encoding="utf-8", errors="replace"))):
1672
+ r.fail("engines-invocable", name,
1673
+ f"{path.relative_to(c.root).as_posix()} carries `disable-model-invocation` "
1674
+ f"again — no skill can invoke it, so wdi-build stops at Phase 2. `npx skills "
1675
+ f"update` restores it; `npx wdi-method engines --fix` strips it back out")
1676
+
1677
+
1678
+ def withdrawn_recorded(c: Corpus, r: Result) -> None:
1679
+ """Two things, and without either one `withdrawn` is just a word that quiets a validator.
1680
+
1681
+ **It names the decision.** Withdrawing a promise is decision-worthy on the method's own terms —
1682
+ `corpus-guide.md` lists "no `BG`/`CAP`/`FR`/`NFR`/`UC`/`LC` id is born, renamed, or retired" as a
1683
+ test for whether something is a `DEC-`. So `withdrawn_by` MUST name one that exists.
1684
+
1685
+ **It does not orphan what is left.** A live `FR` whose capability is withdrawn still promises
1686
+ something whose capability nobody promises any more. Withdrawal that takes half a chain with it
1687
+ silently is worse than the deletion this replaced, because at least deletion went red.
1688
+ """
1689
+ dec_ids = {str(d.get("id")) for d in c.decs}
1690
+ for row in c.withdrawn_rows:
1691
+ rid = str(row.get("id") or "")
1692
+ by = str(row.get("withdrawn_by") or "").strip()
1693
+ if not by:
1694
+ r.fail("withdrawn-recorded", rid, "is `status: withdrawn` and names no `withdrawn_by`. "
1695
+ "Withdrawing a promise is a decision — name the `DEC-` that took it, or the "
1696
+ "word is only silencing a validator")
1697
+ elif by not in dec_ids:
1698
+ r.fail("withdrawn-recorded", rid, f"names `withdrawn_by: {by}`, which is not a decision in "
1699
+ f"`decisions.yaml`")
1700
+
1701
+ withdrawn_ids = {str(row.get("id")) for row in c.withdrawn_rows}
1702
+ if not withdrawn_ids:
1703
+ return
1704
+ for row, parent_key, what in ([(x, "goal", "goal") for x in c.caps]
1705
+ + [(x, "capability", "capability") for x in c.frs]
1706
+ + [(x, "capability", "capability") for x in c.nfrs]):
1707
+ parent = str(row.get(parent_key) or "").strip()
1708
+ if parent and parent in withdrawn_ids:
1709
+ r.fail("withdrawn-recorded", str(row.get("id")),
1710
+ f"is live, and the {what} it hangs off (`{parent}`) is withdrawn. Withdraw this row "
1711
+ f"too, or move it under something still promised")
1712
+ for uc in c.ucs:
1713
+ for fr in listy(uc, "satisfies"):
1714
+ if fr in withdrawn_ids:
1715
+ r.fail("withdrawn-recorded", str(uc.get("id")),
1716
+ f"is live and satisfies `{fr}`, which is withdrawn")
1717
+
1718
+
1551
1719
  def id_allocated_once(c: Corpus, r: Result) -> None: # was V28
1552
1720
  """One id, one row — across every file the requirement registry is split into.
1553
1721
 
@@ -1582,7 +1750,7 @@ def run_checks(c: Corpus, asof: dt.date) -> Result:
1582
1750
  # no two copies left to compare.
1583
1751
  # V19 is REPEALED. It checked one line item — an `RTR-` file in .control/reports/ — and the
1584
1752
  # retrospective it archived was the only thing spec size `L` ever decided. Both went together.
1585
- 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):
1753
+ 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, withdrawn_recorded, id_allocated_once):
1586
1754
  fn(c, r)
1587
1755
  plan_dates(c, r, asof)
1588
1756
  return r
@@ -1785,7 +1953,7 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
1785
1953
  per_spec.append({"spec": wid, "status": spec.get("status"),
1786
1954
  "tickets_done": done, "tickets_total": len(items),
1787
1955
  "work_progress": _pct(done, len(items))})
1788
- applicable = 26 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
1956
+ applicable = 28 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
1789
1957
  return {
1790
1958
  "promise_progress": _pct(green, len(counted)),
1791
1959
  "rtm_rows": {"green": green, "counted": len(counted),