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.
- package/README.md +33 -5
- package/bin/wdi-method.js +389 -26
- 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/scripts/validate.py +162 -18
- 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-upgrade/SKILL.md +194 -187
- package/kit-overlay/constitution.md +4 -1
- package/package.json +1 -1
- package/scaffold/docs/agents/issue-tracker.md +56 -43
|
@@ -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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1556
|
-
|
|
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(
|
|
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 =
|
|
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),
|