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.
- package/README.md +37 -10
- package/bin/wdi-method.js +498 -27
- package/kit/.constitution/method/constitution.md +4 -1
- package/kit/.constitution/method/document/bmad-guide.md +10 -6
- package/kit/.constitution/method/document/bmad-skill-register.md +36 -6
- package/kit/.constitution/method/document/corpus-guide.md +35 -0
- package/kit/.constitution/method/method-glossary.md +184 -183
- package/kit/.constitution/method/scripts/validate.py +179 -11
- package/kit/.constitution/method/why/README.md +192 -186
- package/kit/.constitution/method/why/artifact-map.md +1 -1
- package/kit/skills/wdi-autopilot/SKILL.md +383 -375
- package/kit/skills/wdi-blueprint/SKILL.md +3 -3
- package/kit/skills/wdi-build/SKILL.md +393 -371
- package/kit/skills/wdi-help/SKILL.md +111 -110
- package/kit/skills/wdi-init/SKILL.md +19 -1
- package/kit/skills/wdi-product/SKILL.md +22 -0
- package/kit/skills/wdi-upgrade/SKILL.md +214 -187
- package/kit-overlay/constitution.md +4 -1
- package/package.json +1 -1
- package/scaffold/docs/agents/issue-tracker.md +56 -43
|
@@ -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"] =
|
|
353
|
-
ticket["blocked_by"] = [
|
|
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
|
-
|
|
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 =
|
|
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),
|