wdi-method 0.5.13 → 0.6.0

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.
Files changed (87) hide show
  1. package/README.md +436 -271
  2. package/bin/wdi-method.js +198 -5
  3. package/kit/.constitution/method/README.md +76 -75
  4. package/kit/.constitution/method/constitution.md +2 -1
  5. package/kit/.constitution/method/document/architecture-guide.md +10 -10
  6. package/kit/.constitution/method/document/bmad-guide.md +50 -16
  7. package/kit/.constitution/method/document/bmad-skill-register.md +74 -71
  8. package/kit/.constitution/method/document/brief-guide.md +42 -11
  9. package/kit/.constitution/method/document/corpus-guide.md +146 -11
  10. package/kit/.constitution/method/document/decision-guide.md +199 -134
  11. package/kit/.constitution/method/document/delivery-flow-guide.md +154 -83
  12. package/kit/.constitution/method/document/prd-guide.md +63 -20
  13. package/kit/.constitution/method/document/sdd-guide.md +5 -4
  14. package/kit/.constitution/method/document/srs-guide.md +150 -147
  15. package/kit/.constitution/method/document/templates/addendum.md +70 -71
  16. package/kit/.constitution/method/document/templates/architecture-spine.md +6 -4
  17. package/kit/.constitution/method/document/templates/brief.md +132 -110
  18. package/kit/.constitution/method/document/templates/c4.md +11 -11
  19. package/kit/.constitution/method/document/templates/contract.md +51 -52
  20. package/kit/.constitution/method/document/templates/cross-cutting.md +1 -2
  21. package/kit/.constitution/method/document/templates/design-system.md +0 -1
  22. package/kit/.constitution/method/document/templates/integration.md +68 -69
  23. package/kit/.constitution/method/document/templates/inventory.md +0 -1
  24. package/kit/.constitution/method/document/templates/lc.md +49 -47
  25. package/kit/.constitution/method/document/templates/lifecycle.md +51 -52
  26. package/kit/.constitution/method/document/templates/model.md +68 -55
  27. package/kit/.constitution/method/document/templates/oq.md +45 -45
  28. package/kit/.constitution/method/document/templates/prd.md +244 -226
  29. package/kit/.constitution/method/document/templates/questions.md +100 -76
  30. package/kit/.constitution/method/document/templates/rules.md +46 -47
  31. package/kit/.constitution/method/document/templates/sdd.md +26 -11
  32. package/kit/.constitution/method/document/templates/srs.md +46 -25
  33. package/kit/.constitution/method/document/templates/uc.md +1 -1
  34. package/kit/.constitution/method/document/templates/ux.md +76 -77
  35. package/kit/.constitution/method/document/ux-guide.md +115 -99
  36. package/kit/.constitution/method/language-guide.md +1 -1
  37. package/kit/.constitution/method/method-glossary.md +29 -16
  38. package/kit/.constitution/method/scripts/timeline.py +663 -665
  39. package/kit/.constitution/method/scripts/validate.py +1143 -362
  40. package/kit/.constitution/method/structure-guide.md +3 -3
  41. package/kit/.constitution/method/why/README.md +184 -169
  42. package/kit/.constitution/method/why/artifact-map.md +15 -13
  43. package/kit/.constitution/method/why/mode-risk-map.md +140 -0
  44. package/kit/.constitution/method/why/portability.md +1 -1
  45. package/kit/.constitution/method/why/rationale.md +12 -5
  46. package/kit/.constitution/project/README.md +2 -2
  47. package/kit/.constitution/project/codebase-brownfield-guide.md +18 -18
  48. package/kit/.constitution/project/codebase-conventions-guide.md +18 -18
  49. package/kit/.constitution/project/codebase-stack-guide.md +18 -18
  50. package/kit/assets/bmad-custom/bmad-architecture.toml +1 -1
  51. package/kit/assets/bmad-custom/bmad-correct-course.toml +5 -5
  52. package/kit/assets/bmad-custom/bmad-prd.toml +9 -3
  53. package/kit/assets/bmad-custom/bmad-product-brief.toml +6 -2
  54. package/kit/skills/wdi-blueprint/SKILL.md +71 -10
  55. package/kit/skills/wdi-build/SKILL.md +233 -122
  56. package/kit/skills/wdi-component/SKILL.md +8 -6
  57. package/kit/skills/wdi-decision/SKILL.md +187 -183
  58. package/kit/skills/wdi-help/SKILL.md +8 -7
  59. package/kit/skills/wdi-init/SKILL.md +20 -6
  60. package/kit/skills/wdi-problem/SKILL.md +31 -13
  61. package/kit/skills/wdi-product/SKILL.md +30 -17
  62. package/kit/skills/wdi-question/SKILL.md +170 -88
  63. package/kit/skills/wdi-reconcile/SKILL.md +33 -10
  64. package/kit/skills/wdi-report/SKILL.md +77 -25
  65. package/kit/skills/wdi-review/SKILL.md +201 -118
  66. package/kit/skills/wdi-systematic-debugging/SKILL.md +7 -7
  67. package/kit/skills/wdi-upgrade/SKILL.md +179 -0
  68. package/kit/skills/wdi-ux/SKILL.md +35 -11
  69. package/kit-overlay/AGENTS.md +2 -2
  70. package/kit-overlay/README.md +76 -75
  71. package/kit-overlay/constitution.md +2 -1
  72. package/kit-overlay/portability.md +1 -1
  73. package/package.json +43 -43
  74. package/scaffold/.control/product-glossary.md +1 -1
  75. package/scaffold/.control/registry/goals.yaml +25 -0
  76. package/scaffold/.control/registry/index.yaml +5 -5
  77. package/scaffold/.control/registry/specs.yaml +5 -0
  78. package/scaffold/.control/structure-codebase.md +19 -19
  79. package/scaffold/.control/structure-document.md +25 -25
  80. package/kit/.constitution/method/document/templates/rtr.md +0 -61
  81. package/kit/assets/bmad-custom/bmad-build-auto.toml +0 -10
  82. package/kit/assets/bmad-custom/bmad-build.toml +0 -52
  83. package/kit/assets/bmad-custom/bmad-code-review.toml +0 -13
  84. package/kit/assets/bmad-custom/bmad-retrospective.toml +0 -9
  85. package/kit/assets/bmad-custom/bmad-spec.toml +0 -16
  86. package/scaffold/.control/registry/requirements.yaml +0 -15
  87. package/scaffold/.control/registry/waves.yaml +0 -5
@@ -3,7 +3,7 @@
3
3
  # requires-python = ">=3.11"
4
4
  # dependencies = ["pyyaml>=6"]
5
5
  # ///
6
- """validate — V1..V27 plus the .control/generated/ generator.
6
+ """validate — goal-has-fr..id-allocated-once plus the .control/generated/ generator.
7
7
 
8
8
  Two modes:
9
9
  validate --check exit non-zero if anything is red; writes nothing
@@ -11,7 +11,7 @@ Two modes:
11
11
 
12
12
  Determinism is the contract: two runs over the same data MUST produce the same result.
13
13
  That is why there is no unordered iteration, and the one time-dependent input
14
- (--asof, used by V14) is stated explicitly instead of being taken silently from the wall clock.
14
+ (--asof, used by plan-dates) is stated explicitly instead of being taken silently from the wall clock.
15
15
 
16
16
  What is NOT done here: the time dimension from git. `generated/timeline` and
17
17
  `generated/report` belong to wdi-report. See 08-project-management.md.
@@ -30,16 +30,55 @@ from pathlib import Path
30
30
 
31
31
  import yaml
32
32
 
33
+ # Every check, in the order findings are printed. A check is NAMED for what must be true, so a
34
+ # failure reads as "this is what is not true". The numbers these replaced are kept as a `# was V<n>`
35
+ # marker on each def, because decisions and reports frozen in real repos still cite them.
36
+ CHECK_ORDER = (
37
+ "goal-has-fr",
38
+ "fr-has-uc",
39
+ "uc-scheduled",
40
+ "ticket-has-test",
41
+ "nfr-has-enforcer",
42
+ "refs-resolve",
43
+ "no-cycles",
44
+ "applied-dec-touches",
45
+ "locked-gate-passed",
46
+ "parallel-tickets-blocked",
47
+ "lc-registered",
48
+ "review-trace",
49
+ "plan-dates",
50
+ "chain-links",
51
+ "memlog-home",
52
+ "spec-names-release-prd",
53
+ "ticket-status-one-home",
54
+ "defect-root-cause",
55
+ "entity-one-writer",
56
+ "spec-after-g4",
57
+ "high-risk-named",
58
+ "cites-resolve",
59
+ "container-built",
60
+ "custom-room-declared",
61
+ "id-allocated-once",
62
+ )
63
+
33
64
  REGISTRY = "control/registry" # tidied up in resolve(); '.control' is what is actually used
34
65
  GENERATED_ORDER = ["components", "risks", "dag", "rtm", "status"]
35
66
 
36
67
  # Pages read by HUMANS, not machines: written as real markdown tables, not yaml
37
- # in a fence. All three are named in §22 and each has one clear reader.
38
- GENERATED_PAGES = ["decisions", "blueprint", "estimate"]
68
+ # in a fence. Each has one clear reader. `brief` and `prd-<slug>` are deliverables see
69
+ # page_brief/page_prd and are not listed here because a PRD page is one per initiative found
70
+ # on disk, not one fixed name.
71
+ GENERATED_PAGES = ["decisions", "estimate"]
72
+
73
+ # The two trees a HUMAN reads. Every file in them is a projection: prose lifted from the working
74
+ # document at the mirror path, rows rendered from the registry, nothing authored, nothing edited.
75
+ # A skill MUST NOT read them as input — they are output, and the working document is the source.
76
+ RENDERED_WHAT = ".what-rendered"
77
+ RENDERED_HOW = ".how-rendered"
39
78
 
40
79
  MODES = ("catalog", "outline", "guarded", "deep")
41
80
 
42
- # Keywords that make a component "sensitive" for V23. Matched against `risk_note`, which is PROSE in
81
+ # Keywords that make a component "sensitive" for high-risk-named. Matched against `risk_note`, which is PROSE in
43
82
  # whatever `policy.doc_language` the product chose — so the set is the UNION of both languages rather
44
83
  # than a translation. It leans toward disclosing more, which is what this check is for: it discloses,
45
84
  # it does not judge. Deliberately short.
@@ -65,8 +104,8 @@ class Finding:
65
104
 
66
105
  @property
67
106
  def sort_key(self) -> tuple[int, str, str]:
68
- digits = "".join(ch for ch in self.vid if ch.isdigit())
69
- return (int(digits or 0), self.subject, self.message)
107
+ order = CHECK_ORDER.index(self.vid) if self.vid in CHECK_ORDER else len(CHECK_ORDER)
108
+ return (order, self.subject, self.message)
70
109
 
71
110
 
72
111
  @dataclass
@@ -141,33 +180,74 @@ def git(root: Path, *args: str) -> str | None:
141
180
  # ------------------------------------------------------------------- loading
142
181
 
143
182
 
183
+ REQUIREMENT_KEYS = ("goals", "capabilities", "functional", "nonfunctional", "journeys")
184
+
185
+
144
186
  @dataclass
145
187
  class Corpus:
146
188
  root: Path
147
189
  requirements: dict
190
+ """The MERGED view of every requirement file. Read by everything that asks a product-wide
191
+ question — the RTM, the estimate, goal-has-fr/fr-has-uc/nfr-has-enforcer/chain-links. What is merged is decided in `load`."""
192
+ requirement_files: dict[str, dict]
193
+ """Each requirement file kept separately, keyed by its name: `goals` for the product's `BG`,
194
+ and one `requirements-<slug>` per PRD — the slug being that PRD's folder name.
195
+
196
+ ONE FILE, ONE WRITER, ONE GATE. `wdi-problem` owns `goals.yaml` at G1; `wdi-product` owns its
197
+ own initiative's file at G2, and that is where `CAP` lives too — a capability is declared by a
198
+ feature, one feature is one capability, and a feature lives in exactly one PRD. Co-locating
199
+ `CAP` with `BG` was tried and reverted: the argument for it was that `depends_on` between
200
+ capabilities crosses initiatives, and that turned out to buy nothing, because no-cycles reads the
201
+ MERGED view and never opens a file.
202
+
203
+ The split also makes an `FR`'s initiative STRUCTURAL — before it, nothing in the registry
204
+ recorded which PRD a promise belonged to, and the only way to find out was to grep the PRD's
205
+ prose for its id."""
148
206
  usecases: dict
149
207
  decisions: dict
150
208
  risks: dict
151
209
  components: dict
152
- waves: dict
210
+ specs: dict
153
211
  defects: dict
154
212
  index: dict
155
213
 
156
214
  @classmethod
157
215
  def load(cls, root: Path) -> "Corpus":
158
216
  reg = root / ".control" / "registry"
217
+ files: dict[str, dict] = {}
218
+ product = reg / "goals.yaml"
219
+ if product.exists():
220
+ files[product.stem] = load_yaml(product)
221
+ # A corpus from before the split keeps ONE `requirements.yaml`, and it is still read: an
222
+ # install that has not been cut per PRD yet stays green. `update` seeds `goals.yaml`;
223
+ # moving the rows is the owner's act, done by the skill that owns each row.
224
+ legacy = reg / "requirements.yaml"
225
+ if legacy.exists():
226
+ files[legacy.stem] = load_yaml(legacy)
227
+ for path in sorted(reg.glob("requirements-*.yaml")):
228
+ files[path.stem] = load_yaml(path)
229
+
230
+ # Union, not precedence. A row present in two files is a REAL defect — id-allocated-once names it — and
231
+ # preferring one file would hide it instead.
232
+ merged = {key: [row for doc in files.values() for row in rows(doc, key)]
233
+ for key in REQUIREMENT_KEYS}
159
234
  return cls(
160
235
  root=root,
161
- requirements=load_yaml(reg / "requirements.yaml"),
236
+ requirements=merged,
237
+ requirement_files=files,
162
238
  usecases=load_yaml(reg / "usecases.yaml"),
163
239
  decisions=load_yaml(reg / "decisions.yaml"),
164
240
  risks=load_yaml(reg / "risks.yaml"),
165
241
  components=load_yaml(reg / "components.yaml"),
166
- waves=load_yaml(reg / "waves.yaml"),
242
+ specs=load_yaml(reg / "specs.yaml"),
167
243
  defects=load_yaml(reg / "defects.yaml"),
168
244
  index=load_yaml(reg / "index.yaml"),
169
245
  )
170
246
 
247
+ def requirements_of(self, slug: str) -> dict:
248
+ """One initiative's own requirement file, empty when it has none yet."""
249
+ return self.requirement_files.get(f"requirements-{slug}", {})
250
+
171
251
  # --- shortcuts used repeatedly
172
252
  @property
173
253
  def goals(self) -> list[dict]:
@@ -209,23 +289,25 @@ class Corpus:
209
289
  return rows(self.components, "product_components")
210
290
 
211
291
  @property
212
- def wave_list(self) -> list[dict]:
213
- return rows(self.waves, "waves")
292
+ def spec_list(self) -> list[dict]:
293
+ return rows(self.specs, "specs")
214
294
 
215
295
  @property
216
296
  def defect_list(self) -> list[dict]:
217
297
  return rows(self.defects, "defects")
218
298
 
219
- def stories(self) -> list[tuple[dict, dict, dict]]:
220
- """(wave, epic, story) — sorted by id at each level."""
299
+ def tickets(self) -> list[tuple[dict, dict]]:
300
+ """(spec, ticket) — sorted by ticket id.
301
+
302
+ FLAT. The `epics` level between a spec and its tickets is repealed: it grouped rows and
303
+ bought nothing, and every reader here had to walk through it to reach the row it wanted.
304
+ A ticket names its `component` directly.
305
+ """
221
306
  out = []
222
- for wave in self.wave_list:
223
- for epic in sorted(wave.get("epics") or [], key=lambda e: str(e.get("id", ""))):
224
- if not isinstance(epic, dict):
225
- continue
226
- for story in sorted(epic.get("stories") or [], key=lambda s: str(s.get("id", ""))):
227
- if isinstance(story, dict):
228
- out.append((wave, epic, story))
307
+ for spec in self.spec_list:
308
+ for ticket in sorted(spec.get("tickets") or [], key=lambda t: str(t.get("id", ""))):
309
+ if isinstance(ticket, dict):
310
+ out.append((spec, ticket))
229
311
  return out
230
312
 
231
313
 
@@ -239,7 +321,7 @@ def listy(row: dict, key: str) -> list[str]:
239
321
  # ------------------------------------------------------------------ validators
240
322
 
241
323
 
242
- def v1(c: Corpus, r: Result) -> None:
324
+ def goal_has_fr(c: Corpus, r: Result) -> None: # was V1
243
325
  """Every BG has >=1 FR through its CAP, OR states its reason in `no_fr`.
244
326
 
245
327
  A goal MAY be satisfied by an **invariant** rather than a feature. `BG-6` — the data and
@@ -247,7 +329,7 @@ def v1(c: Corpus, r: Result) -> None:
247
329
  properties that its own `measure` names, and no `FR` can carry it without being invented. Demanding
248
330
  one `FR` there produces a false promise, and a false promise is more expensive than a finding.
249
331
 
250
- The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (V2).
332
+ The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (fr-has-uc).
251
333
  """
252
334
  cap_by_goal: dict[str, list[str]] = {}
253
335
  for cap in c.caps:
@@ -260,10 +342,10 @@ def v1(c: Corpus, r: Result) -> None:
260
342
  continue
261
343
  if str(goal.get("no_fr") or "").strip():
262
344
  continue
263
- r.fail("V1", gid, "has no FR through its CAP and states no reason in `no_fr`")
345
+ r.fail("goal-has-fr", gid, "has no FR through its CAP and states no reason in `no_fr`")
264
346
 
265
347
 
266
- def v2(c: Corpus, r: Result) -> None:
348
+ def fr_has_uc(c: Corpus, r: Result) -> None: # was V2
267
349
  covered = {fr for uc in c.ucs for fr in listy(uc, "satisfies")}
268
350
  for fr in c.frs:
269
351
  fid = str(fr.get("id"))
@@ -271,43 +353,43 @@ def v2(c: Corpus, r: Result) -> None:
271
353
  continue
272
354
  if str(fr.get("no_uc") or "").strip():
273
355
  continue
274
- r.fail("V2", fid, "has no UC and states no reason in `no_uc`")
356
+ r.fail("fr-has-uc", fid, "has no UC and states no reason in `no_uc`")
275
357
 
276
358
 
277
- def v3(c: Corpus, r: Result) -> None:
278
- """A UC on a component that a wave has ALREADY touched MUST be scheduled to a story.
359
+ def uc_scheduled(c: Corpus, r: Result) -> None: # was V3
360
+ """A UC on a component that a spec has ALREADY touched MUST be scheduled to a ticket.
279
361
 
280
- The old shape demanded this of EVERY UC, at any time. Before the first wave that meant the
362
+ The old shape demanded this of EVERY UC, at any time. Before the first spec that meant the
281
363
  entire catalogue was reported red — 56 findings out of 62, and those 56 were the correct state,
282
- not drift: a story is born in a wave, and there was no wave yet. A validator that drowns six real
283
- findings under fifty-six expected ones stops being read, and a validator that is not read
364
+ not drift: a ticket is born in a spec, and there was no spec yet. A validator that drowns six
365
+ real findings under fifty-six expected ones stops being read, and a validator that is not read
284
366
  guards nothing.
285
367
 
286
- What is guarded now is the actual omission: a wave touches a component, and a UC of that
287
- component is left behind without a story. Full coverage of the whole catalogue is a G5 question,
288
- and `wdi-build` owns it — the same way V12 was shifted to wave closing.
368
+ What is guarded now is the actual omission: a spec touches a component, and a UC of that
369
+ component is left behind without a ticket. Full coverage of the whole catalogue is a G5
370
+ question, and `wdi-build` owns it — the same way lc-registered was shifted to spec closing.
289
371
  """
290
- scheduled = {uc for _, _, s in c.stories() for uc in listy(s, "satisfies")}
291
- touched = {str(s.get("component")) for _, _, s in c.stories() if s.get("component")}
292
- if not c.wave_list:
293
- r.skip("V3", "no wave yet, so no story yet — every unscheduled UC is the correct "
372
+ scheduled = {uc for _, t in c.tickets() for uc in listy(t, "satisfies")}
373
+ touched = {str(t.get("component")) for _, t in c.tickets() if t.get("component")}
374
+ if not c.spec_list:
375
+ r.skip("uc-scheduled", "no spec yet, so no ticket yet — every unscheduled UC is the correct "
294
376
  "state. Full catalogue coverage is checked at G5")
295
377
  return
296
378
  for uc in c.ucs:
297
379
  uid = str(uc.get("id"))
298
380
  if uid in scheduled or str(uc.get("component")) not in touched:
299
381
  continue
300
- r.fail("V3", uid, f"component `{uc.get('component')}` has already been touched by a wave, "
301
- f"but this UC is not scheduled to any story")
382
+ r.fail("uc-scheduled", uid, f"component `{uc.get('component')}` has already been touched by a spec, "
383
+ f"but this UC is not scheduled to any ticket")
302
384
 
303
385
 
304
- def v4(c: Corpus, r: Result) -> None:
305
- for _, _, story in c.stories():
306
- if not [t for t in listy(story, "tests") if t.strip()]:
307
- r.fail("V4", str(story.get("id")), "has not one named test")
386
+ def ticket_has_test(c: Corpus, r: Result) -> None: # was V4
387
+ for _, ticket in c.tickets():
388
+ if not [t for t in listy(ticket, "tests") if t.strip()]:
389
+ r.fail("ticket-has-test", str(ticket.get("id")), "has not one named test")
308
390
 
309
391
 
310
- def v5(c: Corpus, r: Result) -> None:
392
+ def nfr_has_enforcer(c: Corpus, r: Result) -> None: # was V5
311
393
  """Every NFR has an enforcer, OR states its reason in `no_enforcer`.
312
394
 
313
395
  Two NFRs in this repo cannot have an enforcer, and both are valid: one has already been
@@ -319,20 +401,19 @@ def v5(c: Corpus, r: Result) -> None:
319
401
  continue
320
402
  if str(nfr.get("no_enforcer") or "").strip():
321
403
  continue
322
- r.fail("V5", str(nfr.get("id")),
404
+ r.fail("nfr-has-enforcer", str(nfr.get("id")),
323
405
  "has no enforcer in `enforced_by` and states no reason in `no_enforcer`")
324
406
 
325
407
 
326
- def v6(c: Corpus, r: Result) -> None:
408
+ def refs_resolve(c: Corpus, r: Result) -> None: # was V6
327
409
  defined: set[str] = set()
328
410
  for group in (c.goals, c.caps, c.frs, c.nfrs, c.ucs, c.decs, c.lcs, c.pcs,
329
411
  rows(c.requirements, "journeys"), rows(c.risks, "risks"), c.defect_list):
330
412
  defined |= {str(row.get("id")) for row in group if row.get("id") is not None}
331
- for wave in c.wave_list:
332
- defined.add(str(wave.get("id")))
333
- for _, epic, story in c.stories():
334
- defined.add(str(epic.get("id")))
335
- defined.add(str(story.get("id")))
413
+ for spec in c.spec_list:
414
+ defined.add(str(spec.get("id")))
415
+ for _, ticket in c.tickets():
416
+ defined.add(str(ticket.get("id")))
336
417
 
337
418
  refs: list[tuple[str, str]] = []
338
419
  for cap in c.caps:
@@ -348,13 +429,15 @@ def v6(c: Corpus, r: Result) -> None:
348
429
  refs += [(str(dec.get("id")), s) for s in listy(dec, "serves")]
349
430
  for defect in c.defect_list:
350
431
  refs += [(str(defect.get("id")), v) for v in listy(defect, "violates")]
351
- for _, _, story in c.stories():
352
- refs += [(str(story.get("id")), u) for u in listy(story, "satisfies")]
353
- refs += [(str(story.get("id")), d) for d in listy(story, "depends_on")]
432
+ for spec in c.spec_list:
433
+ refs += [(str(spec.get("id")), d) for d in listy(spec, "depends_on")]
434
+ for _, ticket in c.tickets():
435
+ refs += [(str(ticket.get("id")), u) for u in listy(ticket, "satisfies")]
436
+ refs += [(str(ticket.get("id")), b) for b in listy(ticket, "blocked_by")]
354
437
 
355
438
  for owner, target in sorted(set(refs)):
356
439
  if target and target not in defined:
357
- r.fail("V6", owner, f"points to `{target}` which does not exist in any registry")
440
+ r.fail("refs-resolve", owner, f"points to `{target}` which does not exist in any registry")
358
441
 
359
442
 
360
443
  def _cycles(graph: dict[str, list[str]]) -> list[str]:
@@ -376,16 +459,23 @@ def _cycles(graph: dict[str, list[str]]) -> list[str]:
376
459
  return sorted(set(bad))
377
460
 
378
461
 
379
- def v7(c: Corpus, r: Result) -> None:
462
+ def no_cycles(c: Corpus, r: Result) -> None: # was V7
380
463
  caps = {str(x.get("id")): listy(x, "depends_on") for x in c.caps}
381
464
  for node in _cycles(caps):
382
- r.fail("V7", node, "is part of a `depends_on` cycle among CAPs")
383
- stories = {str(s.get("id")): listy(s, "depends_on") for _, _, s in c.stories()}
384
- for node in _cycles(stories):
385
- r.fail("V7", node, "is part of a `depends_on` cycle among stories")
386
-
387
-
388
- def v8(c: Corpus, r: Result) -> None:
465
+ r.fail("no-cycles", node, "is part of a `depends_on` cycle among CAPs")
466
+ # Two graphs, two field names, and the difference is not cosmetic. A spec `depends_on` another
467
+ # spec an ordering between units of delivery. A ticket is `blocked_by` other tickets, which is
468
+ # what the frontier is read from and what the tracker calls the same edge.
469
+ specs = {str(x.get("id")): listy(x, "depends_on") for x in c.spec_list}
470
+ for node in _cycles(specs):
471
+ r.fail("no-cycles", node, "is part of a `depends_on` cycle among specs")
472
+ tickets = {str(t.get("id")): listy(t, "blocked_by") for _, t in c.tickets()}
473
+ for node in _cycles(tickets):
474
+ r.fail("no-cycles", node, "is part of a `blocked_by` cycle among tickets — the frontier is empty "
475
+ "and no ticket can ever start")
476
+
477
+
478
+ def applied_dec_touches(c: Corpus, r: Result) -> None: # was V8
389
479
  """Every `applied` decision names a non-empty `touches`.
390
480
 
391
481
  Replaces the old shape "every accepted decision serves >=1 FR/NFR". A decision like
@@ -396,11 +486,11 @@ def v8(c: Corpus, r: Result) -> None:
396
486
  if str(dec.get("status")) != "applied":
397
487
  continue
398
488
  if not [x for x in listy(dec, "touches") if str(x).strip()]:
399
- r.fail("V8", str(dec.get("id")),
489
+ r.fail("applied-dec-touches", str(dec.get("id")),
400
490
  "is applied but `touches` is empty — an application with no file trace")
401
491
 
402
492
 
403
- def v9(c: Corpus, r: Result) -> None:
493
+ def locked_gate_passed(c: Corpus, r: Result) -> None: # was V9
404
494
  passed = {str(g) for g in (c.index.get("gates_passed") or [])}
405
495
  for path in sorted(c.root.glob(".what/**/*.md")) + sorted(c.root.glob(".how/**/*.md")):
406
496
  fm = frontmatter(path) or {}
@@ -409,17 +499,17 @@ def v9(c: Corpus, r: Result) -> None:
409
499
  gate = str(fm.get("locked_at_gate") or "")
410
500
  if gate not in passed:
411
501
  rel = path.relative_to(c.root).as_posix()
412
- r.fail("V9", rel, f"is locked but gate `{gate or '?'}` is not recorded as passed")
502
+ r.fail("locked-gate-passed", rel, f"is locked but gate `{gate or '?'}` is not recorded as passed")
413
503
 
414
504
 
415
- def v11(c: Corpus, r: Result) -> None:
416
- per_wave: dict[str, list[dict]] = {}
417
- for wave, _, story in c.stories():
418
- per_wave.setdefault(str(wave.get("id")), []).append(story)
505
+ def parallel_tickets_blocked(c: Corpus, r: Result) -> None: # was V11
506
+ per_spec: dict[str, list[dict]] = {}
507
+ for spec, ticket in c.tickets():
508
+ per_spec.setdefault(str(spec.get("id")), []).append(ticket)
419
509
 
420
- for wid in sorted(per_wave):
421
- items = per_wave[wid]
422
- edges = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
510
+ for wid in sorted(per_spec):
511
+ items = per_spec[wid]
512
+ edges = {str(t.get("id")): set(listy(t, "blocked_by")) for t in items}
423
513
 
424
514
  def reaches(a: str, b: str, seen: set[str] | None = None) -> bool:
425
515
  seen = seen or set()
@@ -438,14 +528,15 @@ def v11(c: Corpus, r: Result) -> None:
438
528
  continue
439
529
  if reaches(lid, rid) or reaches(rid, lid):
440
530
  continue
441
- r.fail("V11", f"{lid} + {rid}",
442
- f"share touches {shared} with no depends_on relation — MUST NOT run in parallel")
531
+ r.fail("parallel-tickets-blocked", f"{lid} + {rid}",
532
+ f"share touches {shared} with no blocking edge between them — MUST NOT run "
533
+ f"in parallel")
443
534
 
444
535
 
445
- def v12(c: Corpus, r: Result) -> None:
446
- """LC registration is checked when a wave CLOSES, not before a story goes `ready-for-dev`.
536
+ def lc_registered(c: Corpus, r: Result) -> None: # was V12
537
+ """LC registration is checked when a spec CLOSES, not before a ticket is picked up.
447
538
 
448
- The old shape demanded the answer when the information was thinnest. At wave closing,
539
+ The old shape demanded the answer when the information was thinnest. At spec closing,
449
540
  every `touches` already has an area and every boundary already has a name.
450
541
  """
451
542
  areas = {str(lc.get("area")) for lc in c.lcs if lc.get("area")}
@@ -455,22 +546,22 @@ def v12(c: Corpus, r: Result) -> None:
455
546
  pc_by_id = {str(x.get("id")): x for x in c.pcs}
456
547
 
457
548
  seen: set[tuple[str, str]] = set()
458
- for wave, _, story in c.stories():
459
- if str(wave.get("status")) != "closed":
549
+ for spec, ticket in c.tickets():
550
+ if str(spec.get("status")) != "closed":
460
551
  continue
461
- for area in listy(story, "touches"):
552
+ for area in listy(ticket, "touches"):
462
553
  if area not in areas:
463
- r.fail("V12", str(story.get("id")),
464
- f"its wave is already closed, but `{area}` is not registered as an `area` "
554
+ r.fail("lc-registered", str(ticket.get("id")),
555
+ f"its spec is already closed, but `{area}` is not registered as an `area` "
465
556
  f"in components.yaml")
466
- pid = str(story.get("component") or "")
557
+ pid = str(ticket.get("component") or "")
467
558
  row = pc_by_id.get(pid)
468
- if row is None or (str(wave.get("id")), pid) in seen:
559
+ if row is None or (str(spec.get("id")), pid) in seen:
469
560
  continue
470
- seen.add((str(wave.get("id")), pid))
561
+ seen.add((str(spec.get("id")), pid))
471
562
  if c.mode_of(row) in ("guarded", "deep") and not lcs_per_pc.get(pid):
472
- r.fail("V12", f"{wave.get('id')} / {pid}",
473
- f"wave closed and component with mode `{c.mode_of(row)}` has not one "
563
+ r.fail("lc-registered", f"{spec.get('id')} / {pid}",
564
+ f"spec closed and component with mode `{c.mode_of(row)}` has not one "
474
565
  f"`LC` registered")
475
566
 
476
567
 
@@ -488,26 +579,26 @@ def _reviewed_ok(r: Result, rel: str, block: object, need: set[str]) -> None:
488
579
  # The old test then reported "carries no reviewed trace" about a file that plainly carries one,
489
580
  # which is the worst kind of finding: correct-looking, and wrong.
490
581
  if not isinstance(block, dict):
491
- r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
582
+ r.fail("review-trace", rel, "carries no `reviewed` trace with a date and sha")
492
583
  return
493
584
  # NOT `block.get("sha") or ""` — for the integer 0 that yields "" and reintroduces the very
494
585
  # bug this guards. `.get(key, "")` returns the 0, and str(0) is "0", which is truthy.
495
586
  if not str(block.get("sha", "")).strip() or not str(block.get("date", "")).strip():
496
- r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
587
+ r.fail("review-trace", rel, "carries no `reviewed` trace with a date and sha")
497
588
  return
498
589
  lenses = {str(x) for x in (block.get("lenses") or [])}
499
590
  if not lenses:
500
- r.fail("V13", rel, "the `reviewed` trace names not one lens")
591
+ r.fail("review-trace", rel, "the `reviewed` trace names not one lens")
501
592
  missing = sorted(need - lenses)
502
593
  if missing:
503
- r.fail("V13", rel,
594
+ r.fail("review-trace", rel,
504
595
  f"lenses {missing} MUST be included — that is what the component's `risk_accepted` demands")
505
596
 
506
597
 
507
598
  def _only_reviewed_block(diff: str) -> bool:
508
599
  """True if a commit's diff on one file ONLY touches the `reviewed:` block.
509
600
 
510
- This is the OQ-146 fix. The old V13 compared `sha` against the last commit that changed
601
+ This is the OQ-146 fix. The old review-trace compared `sha` against the last commit that changed
511
602
  the file — but the commit that WRITES the `reviewed:` block always changes the file, and
512
603
  writing its own hash into a git commit is cryptographically impossible. As a result every
513
604
  artifact that had just been stamped immediately read as "stale review", forever.
@@ -543,16 +634,28 @@ def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
543
634
  return None
544
635
 
545
636
 
546
- def v13(c: Corpus, r: Result) -> None:
637
+ def review_trace(c: Corpus, r: Result) -> None: # was V13
547
638
  """Review trace follows review INTENSITY, not document depth.
548
639
 
549
640
  Narrowed to components with `risk_accepted` `low` or `medium`. At `high` the owner has already
550
641
  stated they accept the risk, and demanding a trace there is bookkeeping with no buyer.
642
+
643
+ Two narrowings answer the same complaint — that review had become a treadmill:
644
+
645
+ ABSENCE still fails. A binding artifact with no trace at all has never been reviewed.
646
+ STALENESS is ADVISORY. A trace has to be fresh at a gate and at spec close, and review-trace cannot see
647
+ a gate; firing on every commit turned every edit into a re-review. G4's fourth star question
648
+ is what holds the gate on a stale review, and it is asked by a human who can see one.
649
+ LENS SET is demanded BEFORE the component's G4 has passed. That is the first review and the
650
+ review that opens the gate — the two the heavy lens is bought for. A re-review after G4 may
651
+ legitimately run structure + prose, so demanding edge-case-hunter there would force either a
652
+ pointless run or a false trace, and a false trace is worse.
551
653
  """
654
+ stale_advisory: list[str] = []
552
655
  watched = [pc for pc in c.pcs
553
656
  if str(pc.get("risk_accepted") or "").strip() in ("low", "medium")]
554
657
  if not watched:
555
- r.skip("V13", "no component with risk_accepted low or medium — nothing to guard")
658
+ r.skip("review-trace", "no component with risk_accepted low or medium — nothing to guard")
556
659
  targets: list[tuple[Path, set[str]]] = []
557
660
  if watched:
558
661
  targets.append((c.root / ".how/_platform/ARCHITECTURE-SPINE.md", set()))
@@ -570,32 +673,43 @@ def v13(c: Corpus, r: Result) -> None:
570
673
  # theater — exactly the ceremony this redesign cut, and a review that cannot fail proves
571
674
  # nothing. Once G4 passes, the demand comes back and it is meaningful.
572
675
  passed = str(pc.get("g4_passed") or "").strip().lower()
573
- if c.mode_of(pc) != "catalog" and passed not in ("", "false", "no", "belum"):
574
- targets.append((c.root / f".how/{pid}/SDD-{pid}.md", need))
676
+ gate_open = passed in ("", "false", "no", "belum")
677
+ # Before G4 passes, the risk-mandated lens set is demanded: that covers the first review and the
678
+ # review that opens the gate. After it passes, a re-review naming any lens satisfies review-trace.
679
+ if not gate_open:
680
+ targets[-1] = (targets[-1][0], set())
681
+ if c.mode_of(pc) != "catalog" and not gate_open:
682
+ targets.append((c.root / f".how/{pid}/SDD-{pid}.md", set()))
575
683
 
576
684
  for path, need in targets:
577
685
  fm = frontmatter(path)
578
686
  if fm is None:
579
- continue # not born yet — not V13's business
687
+ continue # not born yet — not review-trace's business
580
688
  rel = path.relative_to(c.root).as_posix()
581
689
  _reviewed_ok(r, rel, fm.get("reviewed"), need)
582
690
  block = fm.get("reviewed")
583
691
  if isinstance(block, dict) and block.get("sha"):
584
692
  stale = _stale_since(c, rel, str(block["sha"]))
585
693
  if stale:
586
- r.fail("V13", rel,
587
- f"changed at {stale[:7]} after being reviewed at {str(block['sha'])[:7]}"
588
- f"stale review")
589
-
590
- for wave in c.wave_list:
591
- if not wave.get("epics"):
694
+ stale_advisory.append(
695
+ f"{rel} (changed at {stale[:7]}, reviewed at {str(block['sha'])[:7]})")
696
+
697
+ # One trace per SPEC, never one per ticket. Where there is a `SPEC.md` the trace covers it;
698
+ # at size `S` there is none and the trace covers the ticket set as one artifact. Either way the
699
+ # unit reviewed is the spec, which is why the trace lives on the spec row.
700
+ for spec in c.spec_list:
701
+ if not spec.get("tickets"):
592
702
  continue
593
- _reviewed_ok(r, f"waves.yaml:{wave.get('id')}", wave.get("spec_reviewed"),
703
+ _reviewed_ok(r, f"specs.yaml:{spec.get('id')}", spec.get("spec_reviewed"),
594
704
  {"edge-case-hunter"})
595
705
 
706
+ if stale_advisory:
707
+ r.skip("review-trace", "advisory — trace stale, re-run before the next gate or spec close: "
708
+ + ", ".join(sorted(stale_advisory)))
709
+
596
710
 
597
- def cap_stories(c: Corpus) -> dict[str, list[dict]]:
598
- """CAP -> story, traced through CAP -> FR -> UC -> story. No git, no timeline."""
711
+ def cap_tickets(c: Corpus) -> dict[str, list[dict]]:
712
+ """CAP -> ticket, traced through CAP -> FR -> UC -> ticket. No git, no timeline."""
599
713
  frs_of: dict[str, list[str]] = {}
600
714
  for fr in c.frs:
601
715
  frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
@@ -607,19 +721,19 @@ def cap_stories(c: Corpus) -> dict[str, list[dict]]:
607
721
  for cap in c.caps:
608
722
  cid = str(cap.get("id"))
609
723
  wanted = {u for fid in frs_of.get(cid, []) for u in ucs_of.get(fid, [])}
610
- out[cid] = [s for _, _, s in c.stories()
611
- if wanted & set(listy(s, "satisfies"))]
724
+ out[cid] = [t for _, t in c.tickets()
725
+ if wanted & set(listy(t, "satisfies"))]
612
726
  return out
613
727
 
614
728
 
615
- def v14(c: Corpus, r: Result, asof: dt.date) -> None:
729
+ def plan_dates(c: Corpus, r: Result, asof: dt.date) -> None: # was V14
616
730
  """Overdue-ness is computed from the registry itself — the timeline only reinforces, never gates."""
617
- by_cap = cap_stories(c)
731
+ by_cap = cap_tickets(c)
618
732
  timeline = load_yaml(c.root / ".control/generated/timeline.yaml")
619
733
  listed = {str(row.get("id")) for row in rows(timeline, "capabilities")
620
734
  if str(row.get("state")) == "overdue"} if timeline else None
621
735
  if listed is None:
622
- r.skip("V14", "generated/timeline.yaml does not exist yet — overdue-ness is still computed "
736
+ r.skip("plan-dates", "generated/timeline.yaml does not exist yet — overdue-ness is still computed "
623
737
  "from the registry, but its presence in generated/report is not checked")
624
738
 
625
739
  for cap in c.caps:
@@ -630,100 +744,90 @@ def v14(c: Corpus, r: Result, asof: dt.date) -> None:
630
744
  try:
631
745
  due = dt.date.fromisoformat(end)
632
746
  except ValueError:
633
- r.fail("V14", cid, f"`planned_end` `{end}` is not an ISO date")
747
+ r.fail("plan-dates", cid, f"`planned_end` `{end}` is not an ISO date")
634
748
  continue
635
749
  items = by_cap.get(cid, [])
636
- closed = bool(items) and all(_story_status(c, s) == "done" for s in items)
750
+ closed = bool(items) and all(_ticket_status(c, t) == "done" for t in items)
637
751
  if closed or due >= asof:
638
752
  continue
639
753
  late = (asof - due).days
640
754
  if listed is not None and cid not in listed:
641
- r.fail("V14", cid, f"{late} days overdue with nothing delivered, and not flagged "
755
+ r.fail("plan-dates", cid, f"{late} days overdue with nothing delivered, and not flagged "
642
756
  f"`overdue` in generated/timeline")
643
757
  else:
644
- r.fail("V14", cid, f"{late} days overdue with nothing closed")
758
+ r.fail("plan-dates", cid, f"{late} days overdue with nothing closed")
645
759
 
646
760
 
647
- def v15(c: Corpus, r: Result) -> None:
761
+ def chain_links(c: Corpus, r: Result) -> None: # was V15
648
762
  for cap in c.caps:
649
763
  if not str(cap.get("goal") or "").strip():
650
- r.fail("V15", str(cap.get("id")), "does not point to a `goal`")
764
+ r.fail("chain-links", str(cap.get("id")), "does not point to a `goal`")
651
765
  for fr in c.frs:
652
766
  if not str(fr.get("capability") or "").strip():
653
- r.fail("V15", str(fr.get("id")), "does not point to a `capability`")
767
+ r.fail("chain-links", str(fr.get("id")), "does not point to a `capability`")
654
768
 
655
769
 
656
- def v16(c: Corpus, r: Result) -> None:
770
+ def memlog_home(c: Corpus, r: Result) -> None: # was V16
657
771
  for path in sorted((c.root / ".control/memlog").glob("*.md")):
658
772
  fm = frontmatter(path) or {}
659
773
  rel = path.relative_to(c.root).as_posix()
660
774
  artifact = str(fm.get("artifact") or "")
661
775
  if not artifact:
662
- r.fail("V16", rel, "has no `artifact:` in frontmatter")
776
+ r.fail("memlog-home", rel, "has no `artifact:` in frontmatter")
663
777
  elif not (c.root / artifact).exists():
664
- r.fail("V16", rel, f"`artifact:` points to `{artifact}` which does not exist")
778
+ r.fail("memlog-home", rel, f"`artifact:` points to `{artifact}` which does not exist")
665
779
  for layer in (".what", ".how"):
666
780
  for stray in sorted(c.root.glob(f"{layer}/**/.memlog.md")):
667
- r.fail("V16", stray.relative_to(c.root).as_posix(),
781
+ r.fail("memlog-home", stray.relative_to(c.root).as_posix(),
668
782
  "a memlog MUST NOT live inside the corpus")
669
783
 
670
784
 
671
- def v17(c: Corpus, r: Result) -> None:
672
- for wave in c.wave_list:
673
- wid = str(wave.get("id"))
674
- if not str(wave.get("release") or "").strip():
675
- r.fail("V17", wid, "does not name a `release`")
676
- slugs = listy(wave, "prd")
785
+ def spec_names_release_prd(c: Corpus, r: Result) -> None: # was V17
786
+ for spec in c.spec_list:
787
+ wid = str(spec.get("id"))
788
+ if not str(spec.get("release") or "").strip():
789
+ r.fail("spec-names-release-prd", wid, "does not name a `release`")
790
+ slugs = listy(spec, "prd")
677
791
  if not slugs:
678
- r.fail("V17", wid, "does not name a `prd`")
792
+ r.fail("spec-names-release-prd", wid, "does not name a `prd`")
679
793
  for slug in slugs:
680
794
  if not (c.root / ".what/_prd" / slug).is_dir():
681
- r.fail("V17", wid, f"`prd: {slug}` has no folder .what/_prd/{slug}/")
795
+ r.fail("spec-names-release-prd", wid, f"`prd: {slug}` has no folder .what/_prd/{slug}/")
682
796
 
683
797
 
684
- def v18(c: Corpus, r: Result) -> None:
685
- for _, _, story in c.stories():
686
- sid = str(story.get("id"))
687
- folder = str(story.get("spec_folder") or "").strip()
688
- if not folder:
689
- r.fail("V18", sid, "does not name a `spec_folder`")
690
- continue
691
- matches = sorted((c.root / folder / "stories").glob(f"{sid}-*.md"))
692
- if not matches:
693
- r.fail("V18", sid, f"has no story file in {folder}stories/")
694
- continue
695
- fm = frontmatter(matches[0]) or {}
696
- if not str(fm.get("status") or "").strip():
697
- r.fail("V18", sid, "story file has no `status` in frontmatter")
698
-
798
+ def ticket_status_one_home(c: Corpus, r: Result) -> None: # was V18
799
+ """A ticket's status has exactly ONE home, and the corpus can point to it.
699
800
 
700
- def v19(c: Corpus, r: Result) -> None:
701
- """The retrospective archive is tied to WAVE SIZE, not to `mode`.
801
+ That home is the ticket file. `specs.yaml` carries the traceability index — what a ticket
802
+ satisfies, what blocks it, what it touches, what tests it — and a `status` key copied in beside
803
+ them is the second home this validator exists to prevent.
702
804
 
703
- Mandatory on wave `L`; advisory on `S` and `M`. Document depth and volume of work are two
704
- different things, and demanding a retrospective for a three-story wave is ceremony.
805
+ The file's LOCATION is ours only as far as the root: `{spec_folder}/issues/`. Below that the
806
+ shape belongs to the engine that writes them one file per ticket, numbered from `01` in
807
+ dependency order — and that number is the tail of the ticket id, which is why `SPEC-3-01`
808
+ finds `issues/01-*.md`.
705
809
  """
706
- names = [x.name for x in sorted((c.root / ".control/reports").glob("RTR-*"))]
707
- advisory: list[str] = []
708
- for wave in c.wave_list:
709
- if str(wave.get("status")) != "closed":
810
+ for spec, ticket in c.tickets():
811
+ sid = str(ticket.get("id"))
812
+ if str(ticket.get("status") or "").strip():
813
+ r.fail("ticket-status-one-home", sid, "carries a `status` in specs.yaml — status lives in the ticket "
814
+ "file, and two homes for one fact is how a registry starts lying")
815
+ folder = _spec_folder(spec, ticket)
816
+ if not folder:
817
+ r.fail("ticket-status-one-home", sid, "its spec does not name a `spec_folder`")
710
818
  continue
711
- wid = str(wave.get("id"))
712
- if any(wid in name for name in names):
819
+ matches = _ticket_files(c, spec, ticket)
820
+ if not matches:
821
+ r.fail("ticket-status-one-home", sid, f"has no ticket file under {folder}issues/")
713
822
  continue
714
- if str(wave.get("size")).upper() == "L":
715
- r.fail("V19", wid, "wave `L` closed without an `RTR-` in .control/reports/")
716
- else:
717
- advisory.append(wid)
718
- if advisory:
719
- r.skip("V19", "advisory — wave S/M closed without an RTR-: " + ", ".join(sorted(advisory)))
720
- else:
721
- r.skip("V19", "only the RTR- line item is checked mechanically; the rest of the distillation is guarded by wdi-build")
823
+ if _read_status(matches[0]) == "unknown":
824
+ r.fail("ticket-status-one-home", sid, "ticket file states no status neither a `**Status:**` line nor "
825
+ "`status:` in frontmatter")
722
826
 
723
827
 
724
828
  PLATFORM = "_platform"
725
829
  CROSS_CUTTING = ".how/_platform/cross-cutting.md"
726
- # The section heading V21 looks for. A heading a SCRIPT matches is a machine-facing key, and
830
+ # The section heading entity-one-writer looks for. A heading a SCRIPT matches is a machine-facing key, and
727
831
  # `language-guide.md` says a key is always English — so the template writes the English one and
728
832
  # this is what a new corpus carries. The Indonesian form is kept as a READER-side alias, exactly
729
833
  # like `yes|ya`: a corpus written before this MUST NOT be migrated for a regex.
@@ -731,7 +835,7 @@ PLATFORM_DATA_HEADINGS = ("Platform-owned", "Milik platform")
731
835
  PLATFORM_DATA_HEADING = PLATFORM_DATA_HEADINGS[0]
732
836
 
733
837
 
734
- def v21(c: Corpus, r: Result) -> None:
838
+ def entity_one_writer(c: Corpus, r: Result) -> None: # was V21
735
839
  """One domain entity has EXACTLY ONE owner authorized to write it.
736
840
 
737
841
  The owner is a Product Component, OR `_platform` for an entity with no single component
@@ -750,7 +854,7 @@ def v21(c: Corpus, r: Result) -> None:
750
854
  pid = str(pc.get("id"))
751
855
  for entity in listy(pc, "owns"):
752
856
  if entity in owner and owner[entity] != pid:
753
- r.fail("V21", entity,
857
+ r.fail("entity-one-writer", entity,
754
858
  f"claimed as `owns` by both `{owner[entity]}` and `{pid}` — one entity MUST "
755
859
  f"have exactly one owner")
756
860
  else:
@@ -759,7 +863,7 @@ def v21(c: Corpus, r: Result) -> None:
759
863
  platform = listy(c.components, "platform_owns")
760
864
  for entity in platform:
761
865
  if entity in owner:
762
- r.fail("V21", entity,
866
+ r.fail("entity-one-writer", entity,
763
867
  f"claimed as `platform_owns` and also as `owns` by `{owner[entity]}` — "
764
868
  f"`{PLATFORM}` is not a second path for an entity that already has an owner")
765
869
  else:
@@ -781,7 +885,7 @@ def v21(c: Corpus, r: Result) -> None:
781
885
  # _platform_documented checks above.
782
886
  continue
783
887
  if not [d for d in listy(fr, "defers_to") if str(d).strip()]:
784
- r.fail("V21", fid,
888
+ r.fail("entity-one-writer", fid,
785
889
  f"promises to write `{entity}` which `{own}` owns, without `defers_to` "
786
890
  f"pointing to an `FR` owned by that owner")
787
891
 
@@ -813,30 +917,30 @@ def _platform_documented(c: Corpus, r: Result, entities: list[str]) -> None:
813
917
  path = c.root / CROSS_CUTTING
814
918
  text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
815
919
  if not any(h.lower() in text.lower() for h in PLATFORM_DATA_HEADINGS):
816
- r.skip("V21", f"`{CROSS_CUTTING}` has no `{PLATFORM_DATA_HEADING}` section yet — "
920
+ r.skip("entity-one-writer", f"`{CROSS_CUTTING}` has no `{PLATFORM_DATA_HEADING}` section yet — "
817
921
  f"{len(entities)} entities with platform_owns are not documented yet: "
818
922
  + ", ".join(sorted(entities)))
819
923
  return
820
924
  for entity in sorted(entities):
821
925
  if entity not in text:
822
- r.fail("V21", entity,
926
+ r.fail("entity-one-writer", entity,
823
927
  f"claimed as `platform_owns` but not named in `{CROSS_CUTTING}` — "
824
928
  f"a platform that owns data MUST document it")
825
929
 
826
930
 
827
- def v22(c: Corpus, r: Result) -> None:
828
- """A wave MUST NOT touch a component whose G4 has not passed and whose mode is not catalog.
931
+ def spec_after_g4(c: Corpus, r: Result) -> None: # was V22
932
+ """A spec MUST NOT touch a component whose G4 has not passed and whose mode is not catalog.
829
933
 
830
934
  `catalog` skips G4 on purpose, so it is not an exception — it is part of the rule.
831
935
  """
832
936
  pc_by_id = {str(x.get("id")): x for x in c.pcs}
833
937
  seen: set[tuple[str, str]] = set()
834
- for wave, _, story in c.stories():
835
- pid = str(story.get("component") or "")
938
+ for spec, ticket in c.tickets():
939
+ pid = str(ticket.get("component") or "")
836
940
  row = pc_by_id.get(pid)
837
941
  if row is None:
838
942
  continue
839
- key = (str(wave.get("id")), pid)
943
+ key = (str(spec.get("id")), pid)
840
944
  if key in seen:
841
945
  continue
842
946
  seen.add(key)
@@ -844,20 +948,26 @@ def v22(c: Corpus, r: Result) -> None:
844
948
  if mode == "catalog":
845
949
  continue
846
950
  if mode not in MODES:
847
- r.fail("V22", pid, f"`mode: {mode}` is not one of {list(MODES)}")
951
+ r.fail("spec-after-g4", pid, f"`mode: {mode}` is not one of {list(MODES)}")
848
952
  continue
849
953
  passed = row.get("g4_passed")
850
954
  if not passed or str(passed).strip().lower() in ("false", "no", "belum"):
851
- r.fail("V22", f"{wave.get('id')} / {pid}",
852
- f"wave touches a component with mode `{mode}` whose `g4_passed` has not been set")
955
+ r.fail("spec-after-g4", f"{spec.get('id')} / {pid}",
956
+ f"spec touches a component with mode `{mode}` whose `g4_passed` has not been set")
853
957
 
854
958
 
855
- def v23(c: Corpus, r: Result) -> None:
856
- """`risk_accepted: high` on a sensitive component demands a `DEC-` in `risk_accepted_by`.
959
+ def high_risk_named(c: Corpus, r: Result) -> None: # was V23
960
+ """`risk_accepted: high` on a sensitive component demands a NAMED acceptance in `risk_accepted_by`.
857
961
 
858
962
  On a component that touches nothing on that list, `high` is FREE. The control is
859
963
  disclosure, not veto — the owner may still choose quickly, just not without knowing what
860
964
  they are wagering.
965
+
966
+ It used to demand that the record be a `DEC-` file, which made accepting a risk cost a document and
967
+ put the fact in a second home while `components.yaml` already had the field for it. A person and a
968
+ date, written where the risk is set, IS the disclosure. A `DEC-` id is still accepted and still has
969
+ to resolve: a repo pointing at a decision is making a checkable claim, and a pointer to a decision
970
+ that does not exist is worse than no pointer.
861
971
  """
862
972
  known = {str(x.get("id")) for x in c.decs}
863
973
  for pc in c.pcs:
@@ -870,14 +980,14 @@ def v23(c: Corpus, r: Result) -> None:
870
980
  continue
871
981
  ref = str(pc.get("risk_accepted_by") or "").strip()
872
982
  if not ref:
873
- r.fail("V23", pid,
874
- f"`risk_accepted: high` while `risk_note` mentions {hits}, without "
875
- f"`risk_accepted_by` pointing to a risk-acceptance `DEC-`")
876
- elif ref not in known:
877
- r.fail("V23", pid, f"`risk_accepted_by: {ref}` does not exist in decisions.yaml")
983
+ r.fail("high-risk-named", pid,
984
+ f"`risk_accepted: high` while `risk_note` mentions {hits}, and "
985
+ f"`risk_accepted_by` names nobody a person and a date is enough")
986
+ elif ref.startswith("DEC-") and ref not in known:
987
+ r.fail("high-risk-named", pid, f"`risk_accepted_by: {ref}` does not exist in decisions.yaml")
878
988
 
879
989
 
880
- def v20(c: Corpus, r: Result) -> None:
990
+ def defect_root_cause(c: Corpus, r: Result) -> None: # was V20
881
991
  needs_link = {"requirement", "architecture"}
882
992
  for defect in c.defect_list:
883
993
  did = str(defect.get("id"))
@@ -885,9 +995,9 @@ def v20(c: Corpus, r: Result) -> None:
885
995
  if cause not in needs_link:
886
996
  continue
887
997
  if not listy(defect, "violates"):
888
- r.fail("V20", did, f"has `root_cause` `{cause}` but `violates` is empty")
998
+ r.fail("defect-root-cause", did, f"has `root_cause` `{cause}` but `violates` is empty")
889
999
  if str(defect.get("status")) == "fixed" and not str(defect.get("decision") or "").strip():
890
- r.fail("V20", did,
1000
+ r.fail("defect-root-cause", did,
891
1001
  f"closed as fixed with root_cause `{cause}` without an accompanying `DEC-`")
892
1002
 
893
1003
 
@@ -905,7 +1015,7 @@ FROZEN = (".what/",)
905
1015
  # by hand, so nobody may fix it where it is reported. It also renders registry values inside
906
1016
  # backticks, which makes a frozen `DEC-` `touches:` entry look like a live citation: the 0.5.0
907
1017
  # layout move surfaced three of those, all of them correct history. Fix the source or leave it.
908
- DERIVED = (".control/generated/",)
1018
+ DERIVED = (".control/generated/", RENDERED_WHAT + "/", RENDERED_HOW + "/")
909
1019
  # A path a run WILL PRODUCE, not one a document cites as existing. A rule stating "this pass's
910
1020
  # memlog lands at X" names a DESTINATION; demanding X already exist would demand the run has already
911
1021
  # happened.
@@ -920,14 +1030,17 @@ DESTINATION = (
920
1030
  #
921
1031
  # `.constitution/method/` is portable explanation. Its citations teach where a thing GOES — "the
922
1032
  # glossary lives at `.control/product-glossary.md`" — and are not this product's claim that it has
923
- # one yet. Scanning it made V24 unsatisfiable in both directions: a fresh install went RED on 69
1033
+ # one yet. Scanning it made cites-resolve unsatisfiable in both directions: a fresh install went RED on 69
924
1034
  # such lines before G1 had run, and a mature one stayed quiet only by accident. A method guide that
925
1035
  # cites a method file IS checked, but here in the package where it can be fixed — see
926
1036
  # tests/kit-integrity.test.mjs. A product cannot fix a guide `update` overwrites.
927
1037
  #
928
- # The BMad skill trees are the same class under whichever host the installer wrote them to. Both
929
- # hosts MUST be listed: `.claude/skills/bmad-` alone left the `.agents/` copy of one identical
930
- # template failing, which reads as a defect in that product rather than an omission here.
1038
+ # The BMad skill trees are the same class under whichever host the installer wrote them to. EVERY
1039
+ # host MUST be listed, and there are three: `.claude/skills/bmad-` alone left the `.agents/` copy of
1040
+ # one identical template failing, which reads as a defect in that product rather than an omission
1041
+ # here. Listing two then left the `.agent/` copy failing the same way — the singular host is a
1042
+ # separate directory from `.agents/`, not a prefix of it, and a product carrying all three saw the
1043
+ # same worked example reported once per host it was missing.
931
1044
  #
932
1045
  # `wdi-*` skills are OURS and are deliberately NOT here. They MUST NOT cite a product file that
933
1046
  # does not exist unless the cite is a placeholder.
@@ -935,9 +1048,17 @@ INSTALLED = (
935
1048
  ".constitution/method/",
936
1049
  ".claude/skills/bmad-",
937
1050
  ".agents/skills/bmad-",
1051
+ ".agent/skills/bmad-",
1052
+ # The method's own skills are installed by `update` the same way the guides are, and a consumer
1053
+ # can no more fix a cite in one than in a guide. Their cites are checked where the fix is made —
1054
+ # `kit-integrity` in the package. And one of them, `wdi-upgrade`, deliberately names paths of the
1055
+ # OLD shape to probe for them; read as claims, every probe would be a finding.
1056
+ ".claude/skills/wdi-",
1057
+ ".agents/skills/wdi-",
1058
+ ".agent/skills/wdi-",
938
1059
  )
939
1060
 
940
- # The extension list is deliberately WIDE. A narrow one does not make V24 safer — it makes it
1061
+ # The extension list is deliberately WIDE. A narrow one does not make cites-resolve safer — it makes it
941
1062
  # silent: a product written in a language missing from the list has its code citations
942
1063
  # unchecked, and nothing says so. Adding one is cheap; a gap is invisible.
943
1064
  CITE_RE = re.compile(
@@ -980,7 +1101,7 @@ def _walk_corpus(root: Path, suffixes: tuple[str, ...]) -> list[Path]:
980
1101
  return sorted(out)
981
1102
 
982
1103
 
983
- def v24(c: Corpus, r: Result) -> None:
1104
+ def cites_resolve(c: Corpus, r: Result) -> None: # was V24
984
1105
  """A path citation inside a document that STATES what currently holds MUST resolve.
985
1106
 
986
1107
  This is the mechanical half of `wdi-reconcile`'s Evidence check, and it is the only way to know
@@ -1009,9 +1130,9 @@ def v24(c: Corpus, r: Result) -> None:
1009
1130
  if cited.startswith(DESTINATION):
1010
1131
  continue
1011
1132
  if not (c.root / cited).exists():
1012
- r.fail("V24", rel, f"cites `{cited}` which does not exist")
1133
+ r.fail("cites-resolve", rel, f"cites `{cited}` which does not exist")
1013
1134
  if not scanned:
1014
- r.skip("V24", "no file was scanned")
1135
+ r.skip("cites-resolve", "no file was scanned")
1015
1136
 
1016
1137
 
1017
1138
  CTR_HEADING = re.compile(r"^###\s+(.+?)\s*$", re.M)
@@ -1033,7 +1154,7 @@ def map_container_headings(root: Path) -> list[str] | None:
1033
1154
  return [m.group(1).strip().strip("`") for m in CTR_HEADING.finditer(rest)]
1034
1155
 
1035
1156
 
1036
- def v25(c: Corpus, r: Result) -> None:
1157
+ def container_built(c: Corpus, r: Result) -> None: # was V25
1037
1158
  """A container's `built` and its four consequences, plus the PC x container matrix.
1038
1159
 
1039
1160
  A container EXISTS inside the boundary whether or not we write its content, and that is what
@@ -1047,55 +1168,70 @@ def v25(c: Corpus, r: Result) -> None:
1047
1168
  """
1048
1169
  containers = rows(c.components, "containers")
1049
1170
  if not containers:
1050
- r.skip("V25", "`containers:` is not registered yet")
1171
+ r.skip("container-built", "`containers:` is not registered yet")
1051
1172
  return
1052
1173
 
1053
1174
  built: dict[str, bool] = {}
1054
1175
  for ctr in containers:
1055
1176
  cid = str(ctr.get("id") or "").strip()
1056
1177
  if not cid:
1057
- r.fail("V25", "containers", "a container has no `id`")
1178
+ r.fail("container-built", "containers", "a container has no `id`")
1058
1179
  continue
1059
1180
  flag = ctr.get("built")
1060
1181
  if not isinstance(flag, bool):
1061
- r.fail("V25", cid, "`built` MUST be a bool — true if we write its content, false if someone else implements it")
1182
+ r.fail("container-built", cid, "`built` MUST be a bool — true if we write its content, false if someone else implements it")
1062
1183
  continue
1063
1184
  built[cid] = flag
1064
1185
 
1065
1186
  # (1) code-map heading = EXACTLY a container with `built: true`
1066
1187
  headings = map_container_headings(c.root)
1067
1188
  if headings is None:
1068
- r.fail("V25", ".control/structure-codebase.md", "the code map does not exist, so container headings cannot be compared")
1189
+ r.fail("container-built", ".control/structure-codebase.md", "the code map does not exist, so container headings cannot be compared")
1069
1190
  else:
1070
1191
  for h in headings:
1071
1192
  if h not in built:
1072
- r.fail("V25", f"code map §{h}", "heading is not a registered container — register it, or it is not a container")
1193
+ r.fail("container-built", f"code map §{h}", "heading is not a registered container — register it, or it is not a container")
1073
1194
  elif not built[h]:
1074
- r.fail("V25", f"code map §{h}", "`built: false` MUST NOT have a heading — there is no code of ours inside it")
1195
+ r.fail("container-built", f"code map §{h}", "`built: false` MUST NOT have a heading — there is no code of ours inside it")
1075
1196
  for cid, flag in sorted(built.items()):
1076
1197
  if flag and cid not in headings:
1077
- r.fail("V25", cid, "`built: true` MUST have a heading in the code map")
1198
+ r.fail("container-built", cid, "`built: true` MUST have a heading in the code map")
1078
1199
 
1079
1200
  # (2) `built: false` MUST NOT be used by an LC, and (3) MUST NOT appear in a PC's `containers:`
1201
+ #
1202
+ # An EMPTY container is legal while the answer does not exist yet. Screens are known as soon as
1203
+ # DESIGN.md is written at G2; containers are born at G3. Blocking the UX landing until then bought
1204
+ # nothing and cost a half-placed artifact somebody had to come back to.
1205
+ #
1206
+ # The deadline is derived, not scheduled: once the LC's own PC lists containers, the information
1207
+ # exists and the answer is owed. Silent before G3, automatic after, and no gate in between.
1208
+ pc_containers = {str(pc.get("id")): listy(pc, "containers") for pc in c.pcs}
1080
1209
  for lc in c.lcs:
1081
1210
  ctr = str(lc.get("container") or "").strip()
1082
- if ctr and built.get(ctr) is False:
1083
- r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is `built: false`")
1084
- elif ctr and ctr not in built:
1085
- r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is not registered")
1211
+ lid = str(lc.get("id") or "LC-?")
1212
+ if not ctr:
1213
+ if pc_containers.get(str(lc.get("component") or "")):
1214
+ r.fail("container-built", lid,
1215
+ "has no `container` while its Product Component already lists one — the answer "
1216
+ "exists now, so a screen with no deployable home is a gap rather than a wait")
1217
+ continue
1218
+ if built.get(ctr) is False:
1219
+ r.fail("container-built", lid, f"names container `{ctr}` which is `built: false`")
1220
+ elif ctr not in built:
1221
+ r.fail("container-built", lid, f"names container `{ctr}` which is not registered")
1086
1222
 
1087
1223
  # (4) PC x container matrix — this field is its SSOT, and it MUST be complete at G3
1088
1224
  for pc in c.pcs:
1089
1225
  pid = str(pc.get("id") or "?")
1090
1226
  listed = listy(pc, "containers")
1091
1227
  if not listed:
1092
- r.fail("V25", pid, "`containers:` is empty — every PC MUST live in at least one container (a G3 debt)")
1228
+ r.fail("container-built", pid, "`containers:` is empty — every PC MUST live in at least one container (a G3 debt)")
1093
1229
  continue
1094
1230
  for ctr in listed:
1095
1231
  if ctr not in built:
1096
- r.fail("V25", pid, f"`containers:` names `{ctr}` which is not registered")
1232
+ r.fail("container-built", pid, f"`containers:` names `{ctr}` which is not registered")
1097
1233
  elif not built[ctr]:
1098
- r.fail("V25", pid, f"`containers:` names `{ctr}` which is `built: false` — the data lives there by definition, so the row tells us nothing")
1234
+ r.fail("container-built", pid, f"`containers:` names `{ctr}` which is `built: false` — the data lives there by definition, so the row tells us nothing")
1099
1235
 
1100
1236
  # (5) L3 — only for `built: true`, and only ones that hold more than one PC
1101
1237
  pcs_per: dict[str, list[str]] = {}
@@ -1105,79 +1241,26 @@ def v25(c: Corpus, r: Result) -> None:
1105
1241
  for path in sorted((c.root / ".how" / "_platform").glob("c4-l3-*.md")):
1106
1242
  cid = path.name[len("c4-l3-"):-len(".md")]
1107
1243
  if cid not in built:
1108
- r.fail("V25", path.relative_to(c.root).as_posix(),
1244
+ r.fail("container-built", path.relative_to(c.root).as_posix(),
1109
1245
  f"L3 for `{cid}` which is not a registered container")
1110
1246
  elif not built[cid]:
1111
- r.fail("V25", path.relative_to(c.root).as_posix(),
1247
+ r.fail("container-built", path.relative_to(c.root).as_posix(),
1112
1248
  f"`{cid}` `built: false` MUST NOT have an L3 — not one box inside it is ours to draw")
1113
1249
  for cid, pids in sorted(pcs_per.items()):
1114
1250
  if built.get(cid) and len(pids) > 1:
1115
1251
  l3 = c.root / ".how" / "_platform" / f"c4-l3-{cid}.md"
1116
1252
  if not l3.exists():
1117
- r.fail("V25", cid, f"holds {len(pids)} PCs, so `c4-l3-{cid}.md` MUST exist")
1253
+ r.fail("container-built", cid, f"holds {len(pids)} PCs, so `c4-l3-{cid}.md` MUST exist")
1118
1254
 
1119
1255
 
1120
- UC_ROW_RE = re.compile(r"^\|\s*(UC-\d+)\s*\|([^\n]*)$", re.M)
1121
1256
 
1122
1257
  # The `critical` column value is machine-matched, so it is machine-facing and its canonical form
1123
1258
  # is English `yes`. `ya` is still accepted: a corpus that wrote it before this rule took effect
1124
1259
  # MUST NOT be forced to migrate just so a regex can be tidier. The word boundary keeps `ya` from
1125
1260
  # matching inside other words.
1126
- CRITICAL_YES = re.compile(r"\b(yes|ya)\b", re.I)
1127
-
1128
-
1129
- def v26(c: Corpus, r: Result) -> None:
1130
- """The UC catalogue in every SRS MUST agree with `usecases.yaml` — both its id AND its `critical`.
1131
1261
 
1132
- This is the most expensive gap this pass closes, because it is the only one that **had already
1133
- happened and no validator saw it.** Step 16 re-derived `critical` in the registry with a
1134
- narrowed definition — money, personal data, irreversible action — and the seven catalogue tables
1135
- in the SRS did not follow along. Twenty-six rows disagreed, and the disagreement was only
1136
- discovered when a human read the sentence "nine of these are critical" in SRS-admin while the
1137
- registry held three.
1138
1262
 
1139
- The registry is the SSOT. The table in the SRS is the catalogue's permanent home for a reader,
1140
- and two homes for one fact are only safe if something compares them. This is what compares them.
1141
-
1142
- What is NOT checked here: title and actor. Both are prose, and prose with different words is
1143
- not prose with a different meaning — comparing them would report style as a defect.
1144
- """
1145
- reg = {str(uc.get("id")): bool(uc.get("critical")) for uc in c.ucs}
1146
- reg_pc = {str(uc.get("id")): str(uc.get("component") or "") for uc in c.ucs}
1147
- checked = 0
1148
- for pc in c.pcs:
1149
- pid = str(pc.get("id"))
1150
- path = c.root / f".what/{pid}/SRS-{pid}.md"
1151
- if not path.exists():
1152
- continue
1153
- checked += 1
1154
- text = path.read_text(encoding="utf-8", errors="replace")
1155
- seen: set[str] = set()
1156
- for match in UC_ROW_RE.finditer(text):
1157
- uid = match.group(1)
1158
- cells = [x.strip() for x in match.group(2).split("|")]
1159
- if len(cells) < 4:
1160
- continue
1161
- seen.add(uid)
1162
- if uid not in reg:
1163
- r.fail("V26", f"{pid}/{uid}", "is in the SRS catalogue but not in `usecases.yaml`")
1164
- continue
1165
- if reg_pc[uid] != pid:
1166
- r.fail("V26", f"{pid}/{uid}",
1167
- f"the registry places it in `{reg_pc[uid]}`, not in this component")
1168
- marked = CRITICAL_YES.search(cells[3]) is not None
1169
- if marked != reg[uid]:
1170
- r.fail("V26", f"{pid}/{uid}",
1171
- f"`critical` in the SRS {'yes' if marked else 'no'}, "
1172
- f"in the registry {'yes' if reg[uid] else 'no'}")
1173
- for uid, owner in sorted(reg_pc.items()):
1174
- if owner == pid and uid not in seen:
1175
- r.fail("V26", f"{pid}/{uid}", "is in `usecases.yaml` but not in the SRS catalogue")
1176
- if not checked:
1177
- r.skip("V26", "no SRS could be read")
1178
-
1179
-
1180
- def v27(c: Corpus, r: Result) -> None:
1263
+ def custom_room_declared(c: Corpus, r: Result) -> None: # was V27
1181
1264
  """Every file in the custom room MUST declare itself, and a rebuttal MUST have a decision.
1182
1265
 
1183
1266
  The `.constitution/project/` room exists so product-specific rules have a home that `update`
@@ -1194,23 +1277,23 @@ def v27(c: Corpus, r: Result) -> None:
1194
1277
  README.md authored in the package, not in the product
1195
1278
  constitution.md Articles 1, 2, 5 — carries `status:`, and Article 4 governs it
1196
1279
  codebase-*-guide.md the stack, conventions, and brownfield guides — `status:` plus
1197
- `ratified_by:`, and they are filled by a wave's distillation
1280
+ `ratified_by:`, and they are filled by a spec's distillation
1198
1281
 
1199
1282
  Demanding `scope:` and `purpose:` of those would be demanding a declaration of files whose
1200
- role is already fixed by the layout. What V27 exists to guard is the file somebody ADDS.
1283
+ role is already fixed by the layout. What custom-room-declared exists to guard is the file somebody ADDS.
1201
1284
 
1202
1285
  Only `.md` is looked at. A script in the room — `inventory-readers.py` is the one the package
1203
1286
  seeds — is not an ad-hoc rule and has nowhere to put frontmatter.
1204
1287
  """
1205
1288
  room = c.root / ".constitution" / "project"
1206
1289
  if not room.is_dir():
1207
- r.skip("V27", "the `.constitution/project/` room does not exist yet — it is seeded at install")
1290
+ r.skip("custom-room-declared", "the `.constitution/project/` room does not exist yet — it is seeded at install")
1208
1291
  return
1209
1292
  structural = {"README.md", "constitution.md"}
1210
1293
  files = [p for p in sorted(room.rglob("*.md"))
1211
1294
  if p.name not in structural and not p.name.startswith("codebase-")]
1212
1295
  if not files:
1213
- r.skip("V27", "the `.constitution/project/` room is empty, and that is a valid state — "
1296
+ r.skip("custom-room-declared", "the `.constitution/project/` room is empty, and that is a valid state — "
1214
1297
  "a generic rule MUST NOT be moved here just to give the room content")
1215
1298
  return
1216
1299
  dec_ids = {str(d.get("id")) for d in c.decs}
@@ -1218,48 +1301,134 @@ def v27(c: Corpus, r: Result) -> None:
1218
1301
  rel = path.relative_to(c.root).as_posix()
1219
1302
  fm = frontmatter(path)
1220
1303
  if fm is None:
1221
- r.fail("V27", rel, "has no frontmatter")
1304
+ r.fail("custom-room-declared", rel, "has no frontmatter")
1222
1305
  continue
1223
1306
  if str(fm.get("scope") or "").strip() != "project":
1224
- r.fail("V27", rel, "`scope:` MUST contain exactly `project`")
1307
+ r.fail("custom-room-declared", rel, "`scope:` MUST contain exactly `project`")
1225
1308
  if not str(fm.get("purpose") or "").strip():
1226
- r.fail("V27", rel, "`purpose:` is empty — one line: what this rule guards")
1309
+ r.fail("custom-room-declared", rel, "`purpose:` is empty — one line: what this rule guards")
1227
1310
  over = str(fm.get("overrides") or "").strip()
1228
1311
  dec = str(fm.get("decision") or "").strip()
1229
1312
  if over:
1230
1313
  if not (c.root / over).exists():
1231
- r.fail("V27", rel, f"`overrides:` points to `{over}` which does not exist — "
1314
+ r.fail("custom-room-declared", rel, f"`overrides:` points to `{over}` which does not exist — "
1232
1315
  f"the rebutted rule may already be gone")
1233
1316
  if not dec:
1234
- r.fail("V27", rel, "rebuts a generic rule without `decision:` — "
1317
+ r.fail("custom-room-declared", rel, "rebuts a generic rule without `decision:` — "
1235
1318
  "a rebuttal MUST have a `DEC-` that decided it")
1236
1319
  elif dec not in dec_ids:
1237
- r.fail("V27", rel, f"`decision: {dec}` is not registered in decisions.yaml")
1320
+ r.fail("custom-room-declared", rel, f"`decision: {dec}` is not registered in decisions.yaml")
1238
1321
  elif dec:
1239
- r.fail("V27", rel, "`decision:` is set without `overrides:` — "
1322
+ r.fail("custom-room-declared", rel, "`decision:` is set without `overrides:` — "
1240
1323
  "name which rule is rebutted, or drop `decision:`")
1241
1324
 
1242
1325
 
1326
+ def id_allocated_once(c: Corpus, r: Result) -> None: # was V28
1327
+ """One id, one row — across every file the requirement registry is split into.
1328
+
1329
+ The split into `goals.yaml` plus one `requirements-<slug>.yaml` per PRD bought
1330
+ one writer per file, and cost exactly one new failure mode: two initiatives written in parallel
1331
+ can both allocate `FR-12`. Nothing before this saw it. refs-resolve builds its `defined` set as a SET, so
1332
+ a duplicate id silently collapsed into one entry and every reference to it still resolved.
1333
+
1334
+ The id sequence is global to the product — that is what lets a ticket say `satisfies: [FR-12]`
1335
+ without also naming which PRD it came from. This is the check that keeps it true now that the
1336
+ rows live in more than one file.
1337
+ """
1338
+ where: dict[str, list[str]] = {}
1339
+ for name, doc in sorted(c.requirement_files.items()):
1340
+ for key in REQUIREMENT_KEYS:
1341
+ for row in rows(doc, key):
1342
+ rid = str(row.get("id") or "").strip()
1343
+ if rid:
1344
+ where.setdefault(rid, []).append(name)
1345
+ for rid, sources in sorted(where.items()):
1346
+ if len(sources) > 1:
1347
+ r.fail("id-allocated-once", rid, "is declared in " + ", ".join(f"`{s}.yaml`" for s in sources)
1348
+ + " — an id is allocated ONCE, and the sequence is global to the product")
1349
+ if not where:
1350
+ r.skip("id-allocated-once", "no requirement row in any registry file yet")
1351
+
1352
+
1243
1353
  def run_checks(c: Corpus, asof: dt.date) -> Result:
1244
1354
  r = Result()
1245
- for fn in (v1, v2, v3, v4, v5, v6, v7, v8, v9, v11, v12, v13, v15, v16, v17, v18, v19, v20,
1246
- v21, v22, v23, v24, v25, v26, v27):
1355
+ # uc-catalogue-matches (was V26) is RETIRED: it compared the SRS's hand-written UC table with
1356
+ # `usecases.yaml`. The table is a pointer now and the rendered SRS shows the rows, so there are
1357
+ # no two copies left to compare.
1358
+ # V19 is REPEALED. It checked one line item — an `RTR-` file in .control/reports/ — and the
1359
+ # retrospective it archived was the only thing spec size `L` ever decided. Both went together.
1360
+ 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, cites_resolve, container_built, custom_room_declared, id_allocated_once):
1247
1361
  fn(c, r)
1248
- v14(c, r, asof)
1362
+ plan_dates(c, r, asof)
1249
1363
  return r
1250
1364
 
1251
1365
 
1252
1366
  # ------------------------------------------------------------------ generator
1253
1367
 
1254
1368
 
1255
- def _story_status(c: Corpus, story: dict) -> str:
1256
- folder = str(story.get("spec_folder") or "").strip()
1369
+ def _spec_folder(spec: dict, ticket: dict) -> str:
1370
+ """`spec_folder` belongs to the SPEC — one per spec, not one per spec x component.
1371
+
1372
+ A ticket row is still read as a fallback, because that is where a repo written before the
1373
+ rename put it, and a validator that reports every one of those rows as missing a folder is
1374
+ reporting the migration, not a defect.
1375
+ """
1376
+ return str(spec.get("spec_folder") or ticket.get("spec_folder") or "").strip()
1377
+
1378
+
1379
+ def _ticket_files(c: Corpus, spec: dict, ticket: dict) -> list[Path]:
1380
+ """`{spec_folder}/issues/<NN>-<slug>.md`, found by the number at the tail of the ticket id.
1381
+
1382
+ The full id is tried too, so a product that names its files after the whole id is not punished
1383
+ for a convention this method never demanded of it.
1384
+ """
1385
+ folder = _spec_folder(spec, ticket)
1257
1386
  if not folder:
1387
+ return []
1388
+ tid = str(ticket.get("id") or "")
1389
+ issues = c.root / folder / "issues"
1390
+ for stem in (tid.rsplit("-", 1)[-1], tid):
1391
+ if not stem:
1392
+ continue
1393
+ found = sorted(issues.glob(f"{stem}-*.md"))
1394
+ if found:
1395
+ return found
1396
+ return []
1397
+
1398
+
1399
+ def status_in(text: str) -> str:
1400
+ """Two spellings, one home.
1401
+
1402
+ The engine writes `**Status:** ready-for-agent` as a body line, because a ticket file is a
1403
+ tracker payload and trackers do not read YAML frontmatter. A product that keeps `status:` in
1404
+ frontmatter instead is not wrong, so both are read. Reading two spellings of one field in one
1405
+ file is not two homes; it is one home written two ways.
1406
+
1407
+ Takes TEXT, not a path, because timeline.py asks the same question of historical revisions
1408
+ pulled out of git, where there is no file to open.
1409
+ """
1410
+ m = FM.match(text)
1411
+ if m:
1412
+ try:
1413
+ fm = yaml.safe_load(m.group(1)) or {}
1414
+ except yaml.YAMLError:
1415
+ fm = {}
1416
+ if isinstance(fm, dict) and str(fm.get("status") or "").strip():
1417
+ return str(fm["status"]).strip()
1418
+ m = re.search(r"^\*\*Status:\*\*\s*(.+?)\s*$", text, re.M)
1419
+ return m.group(1).strip() if m else "unknown"
1420
+
1421
+
1422
+ def _read_status(path: Path) -> str:
1423
+ try:
1424
+ return status_in(path.read_text(encoding="utf-8"))
1425
+ except OSError:
1258
1426
  return "unknown"
1259
- matches = sorted((c.root / folder / "stories").glob(f"{story.get('id')}-*.md"))
1260
- if not matches:
1261
- return "unknown"
1262
- return str((frontmatter(matches[0]) or {}).get("status") or "unknown")
1427
+
1428
+
1429
+ def _ticket_status(c: Corpus, spec: dict, ticket: dict) -> str:
1430
+ matches = _ticket_files(c, spec, ticket)
1431
+ return _read_status(matches[0]) if matches else "unknown"
1263
1432
 
1264
1433
 
1265
1434
  def gen_components(c: Corpus) -> dict:
@@ -1290,25 +1459,26 @@ def gen_risks(c: Corpus) -> dict:
1290
1459
 
1291
1460
 
1292
1461
  def gen_dag(c: Corpus) -> dict:
1462
+ """The frontier, spec by spec: each `parallel` group is what can be started at once."""
1293
1463
  out = []
1294
- per_wave: dict[str, list[dict]] = {}
1295
- for wave, _, story in c.stories():
1296
- per_wave.setdefault(str(wave.get("id")), []).append(story)
1297
- for wid in sorted(per_wave):
1298
- items = per_wave[wid]
1464
+ per_spec: dict[str, list[dict]] = {}
1465
+ for spec, ticket in c.tickets():
1466
+ per_spec.setdefault(str(spec.get("id")), []).append(ticket)
1467
+ for wid in sorted(per_spec):
1468
+ items = per_spec[wid]
1299
1469
  done: set[str] = set()
1300
- pending = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
1301
- waves_out = []
1470
+ pending = {str(t.get("id")): set(listy(t, "blocked_by")) for t in items}
1471
+ order = []
1302
1472
  while pending:
1303
1473
  ready = sorted(k for k, deps in pending.items() if not (deps - done))
1304
- if not ready: # cycle — V7 has already reported it
1305
- waves_out.append({"blocked": sorted(pending)})
1474
+ if not ready: # cycle — no-cycles has already reported it
1475
+ order.append({"blocked": sorted(pending)})
1306
1476
  break
1307
- waves_out.append({"parallel": ready})
1477
+ order.append({"parallel": ready})
1308
1478
  done |= set(ready)
1309
1479
  for k in ready:
1310
1480
  pending.pop(k)
1311
- out.append({"wave": wid, "order": waves_out})
1481
+ out.append({"spec": wid, "order": order})
1312
1482
  return {"dag": out}
1313
1483
 
1314
1484
 
@@ -1318,10 +1488,10 @@ def gen_rtm(c: Corpus) -> dict:
1318
1488
  for uc in c.ucs:
1319
1489
  for fr in listy(uc, "satisfies"):
1320
1490
  ucs_for_fr.setdefault(fr, []).append(str(uc.get("id")))
1321
- stories_for_uc: dict[str, list[tuple[dict, dict]]] = {}
1322
- for wave, _, story in c.stories():
1323
- for uc in listy(story, "satisfies"):
1324
- stories_for_uc.setdefault(uc, []).append((wave, story))
1491
+ tickets_for_uc: dict[str, list[tuple[dict, dict]]] = {}
1492
+ for spec, ticket in c.tickets():
1493
+ for uc in listy(ticket, "satisfies"):
1494
+ tickets_for_uc.setdefault(uc, []).append((spec, ticket))
1325
1495
  decs_for: dict[str, list[str]] = {}
1326
1496
  for dec in c.decs:
1327
1497
  for target in listy(dec, "serves"):
@@ -1336,28 +1506,28 @@ def gen_rtm(c: Corpus) -> dict:
1336
1506
  ucs = sorted(ucs_for_fr.get(fid, []))
1337
1507
  if not ucs:
1338
1508
  exempt = bool(str(fr.get("no_uc") or "").strip())
1339
- lines.append({**base, "UC": "", "story": "", "wave": "", "release": "",
1509
+ lines.append({**base, "UC": "", "ticket": "", "spec": "", "release": "",
1340
1510
  "test": [], "status": "", "green": False,
1341
1511
  "exempt": exempt,
1342
1512
  "broken_at": "no_uc" if exempt else "UC"})
1343
1513
  continue
1344
1514
  for uid in ucs:
1345
- pairs = sorted(stories_for_uc.get(uid, []), key=lambda p: str(p[1].get("id")))
1515
+ pairs = sorted(tickets_for_uc.get(uid, []), key=lambda p: str(p[1].get("id")))
1346
1516
  if not pairs:
1347
- lines.append({**base, "UC": uid, "story": "", "wave": "", "release": "",
1517
+ lines.append({**base, "UC": uid, "ticket": "", "spec": "", "release": "",
1348
1518
  "test": [], "status": "", "green": False, "exempt": False,
1349
- "broken_at": "story"})
1519
+ "broken_at": "ticket"})
1350
1520
  continue
1351
- for wave, story in pairs:
1352
- status = _story_status(c, story)
1353
- tests = listy(story, "tests")
1521
+ for spec, ticket in pairs:
1522
+ status = _ticket_status(c, spec, ticket)
1523
+ tests = listy(ticket, "tests")
1354
1524
  broken = ""
1355
1525
  if not tests:
1356
1526
  broken = "test"
1357
1527
  elif status != "done":
1358
1528
  broken = "status"
1359
- lines.append({**base, "UC": uid, "story": str(story.get("id")),
1360
- "wave": str(wave.get("id")), "release": str(wave.get("release", "")),
1529
+ lines.append({**base, "UC": uid, "ticket": str(ticket.get("id")),
1530
+ "spec": str(spec.get("id")), "release": str(spec.get("release", "")),
1361
1531
  "test": tests, "status": status, "exempt": False,
1362
1532
  "green": broken == "", "broken_at": broken})
1363
1533
  return {"rtm": lines}
@@ -1368,20 +1538,20 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
1368
1538
  counted = [line for line in lines if not line.get("exempt")]
1369
1539
  exempt = len(lines) - len(counted)
1370
1540
  green = sum(1 for line in counted if line.get("green"))
1371
- per_wave = []
1372
- for wave in c.wave_list:
1373
- wid = str(wave.get("id"))
1374
- items = [s for w, _, s in c.stories() if str(w.get("id")) == wid]
1375
- done = sum(1 for s in items if _story_status(c, s) == "done")
1376
- per_wave.append({"wave": wid, "status": wave.get("status"),
1377
- "stories_done": done, "stories_total": len(items),
1541
+ per_spec = []
1542
+ for spec in c.spec_list:
1543
+ wid = str(spec.get("id"))
1544
+ items = [t for sp, t in c.tickets() if str(sp.get("id")) == wid]
1545
+ done = sum(1 for t in items if _ticket_status(c, spec, t) == "done")
1546
+ per_spec.append({"spec": wid, "status": spec.get("status"),
1547
+ "tickets_done": done, "tickets_total": len(items),
1378
1548
  "work_progress": _pct(done, len(items))})
1379
- applicable = 26 # V1..V27 minus V10, which was retired
1549
+ applicable = 26 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
1380
1550
  return {
1381
1551
  "promise_progress": _pct(green, len(counted)),
1382
1552
  "rtm_rows": {"green": green, "counted": len(counted),
1383
1553
  "excluded_no_uc": exempt},
1384
- "work_progress": per_wave,
1554
+ "work_progress": per_spec,
1385
1555
  "gate_readiness": _pct(applicable - len(result.red), applicable),
1386
1556
  "validators_red": result.red,
1387
1557
  "validators_skipped": dict(sorted(result.skipped.items())),
@@ -1397,13 +1567,38 @@ def _question_budget(c: Corpus) -> dict:
1397
1567
  """
1398
1568
  budget = c.index.get("question_budget") or {}
1399
1569
  out: dict[str, object] = {}
1570
+ # `Whose` splits the open rows by who can act, and whether anyone may act yet. A flat "25 open"
1571
+ # is what made a six-item list read as twenty-five items of homework; the owner's number is the
1572
+ # only one they can do anything about. templates/questions.md owns the vocabulary.
1573
+ whose: dict[str, int] = {"owner": 0, "run": 0, "frozen": 0, "unstated": 0}
1400
1574
  for name in ("blocking", "assumptions", "external", "answered"):
1401
1575
  path = c.root / ".control/questions" / f"{name}.md"
1402
1576
  rows_n = 0
1403
1577
  if path.exists():
1404
- rows_n = sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines()
1405
- if line.startswith("| OQ-"))
1578
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
1579
+ if not line.startswith("| OQ-"):
1580
+ continue
1581
+ rows_n += 1
1582
+ # `answered` is closed; `external` needs no `Whose` — sitting in that file already
1583
+ # says who acts, and it is reported as its own line rather than folded in.
1584
+ if name in ("answered", "external"):
1585
+ continue
1586
+ cells = [x.strip() for x in line.strip().strip("|").split("|")]
1587
+ key = "unstated"
1588
+ for cell in cells:
1589
+ low = cell.lower()
1590
+ if low == "owner":
1591
+ key = "owner"
1592
+ elif low.startswith("run:"):
1593
+ key = "run"
1594
+ elif low.startswith("frozen:"):
1595
+ key = "frozen"
1596
+ else:
1597
+ continue
1598
+ break
1599
+ whose[key] += 1
1406
1600
  out[name] = rows_n
1601
+ out["open_by_whose"] = whose
1407
1602
  cap_block = budget.get("blocking_per_component")
1408
1603
  if cap_block and c.pcs:
1409
1604
  allowed = int(cap_block) * len(c.pcs)
@@ -1433,18 +1628,26 @@ PAGE_HEADER = ("> Generated by `.constitution/method/scripts/validate.py --gener
1433
1628
  "MUST NOT be hand-edited.\n")
1434
1629
 
1435
1630
 
1631
+ def _hname(heading: str) -> str:
1632
+ """A heading's NAME: lower-cased, with any leading `3.` / `4.2` / `§5` numbering dropped. Section
1633
+ numbers moved between kits (Non-Goals was §7 in one, §5 in the next) and a migrated document may
1634
+ keep its old ones — a page that keyed on the number went blind on exactly those corpora."""
1635
+ return re.sub(r"^\s*(?:§\s*)?\d+(?:\.\d+)*\.?\s*", "", heading.strip().lower())
1636
+
1637
+
1436
1638
  def _section(path: Path, heading: str) -> str:
1437
- """Extract one `## <heading>` section from a markdown file, as-is."""
1639
+ """Extract one `## <heading>` section from a markdown file, as-is. Matched by name, not number."""
1438
1640
  if not path.exists():
1439
1641
  return ""
1440
1642
  lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
1441
1643
  out: list[str] = []
1442
1644
  inside = False
1645
+ want = _hname(heading)
1443
1646
  for line in lines:
1444
1647
  if line.startswith("## "):
1445
1648
  if inside:
1446
1649
  break
1447
- inside = line[3:].strip().lower().startswith(heading.lower())
1650
+ inside = _hname(line[3:]).startswith(want)
1448
1651
  continue
1449
1652
  if inside:
1450
1653
  out.append(line)
@@ -1486,37 +1689,30 @@ def page_decisions(c: Corpus) -> str:
1486
1689
 
1487
1690
 
1488
1691
  def page_blueprint(c: Corpus) -> str:
1489
- """One-page roll-up reviewed at G3. Seven files become one read.
1692
+ """The ONE page read at G3 and it MUST answer all seven of G3's questions, or the owner is
1693
+ back to opening files. Before this it answered four: the actor list, the UC catalogue, the
1694
+ domain model, and the inventories. The three it left to other files were the AD-N invariants,
1695
+ the cross-component business rules, and the glossary — questions 5, 6, and 7.
1490
1696
 
1491
- The UC catalogue, actor list, and domain model stay put in their own component's kernel as
1492
- their permanent home. This is their view. One fact, one home, one view.
1697
+ Every table here is a VIEW. The UC catalogue's home is `usecases.yaml`; the invariants' home is
1698
+ the spine; the PC x container matrix's home is `components.yaml`. One fact, one home, one view.
1493
1699
  """
1494
1700
  parts = ["# blueprint\n", PAGE_HEADER,
1495
- "\nThis is what the owner reads at **G3 Blueprint**, instead of seven files. Its "
1496
- "content is affected by neither `mode` nor `risk_accepted`.\n"]
1701
+ "\nThis is what the owner reads at **G3 Blueprint** one page, every one of the gate's "
1702
+ "seven questions answerable from it. Its content is affected by neither `mode` nor "
1703
+ "`risk_accepted`.\n"]
1497
1704
 
1498
1705
  crit = sum(1 for uc in c.ucs if uc.get("critical"))
1499
- parts.append(f"\n## Use case catalogue\n\n**{len(c.ucs)} use cases**, {crit} marked "
1500
- f"`critical`.\n")
1501
- parts.append("| id | Use case | Component | Satisfies | critical |")
1502
- parts.append("| --- | --- | --- | --- | --- |")
1503
- for uc in c.ucs:
1504
- sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
1505
- flag = "yes" if uc.get("critical") else "no"
1506
- parts.append(f"| `{uc.get('id')}` | {_cell(uc.get('title'))} | "
1507
- f"`{uc.get('component', '')}` | {sat} | {flag} |")
1706
+ parts.append(f"\n## Use case catalogue\n\n**{len(c.ucs)} use cases**, {crit} marked `critical`. "
1707
+ f"Rendered from `usecases.yaml`.\n\n{_uc_table(c.ucs)}")
1508
1708
 
1509
- parts.append("\n## Actor list\n")
1709
+ parts.append("\n\n## Actor list\n")
1510
1710
  for pc in c.pcs:
1511
1711
  pid = str(pc.get("id"))
1512
1712
  block = _section(c.root / f".what/{pid}/SRS-{pid}.md", "Actor Register")
1513
- # A Product Component carries no `name` in `components.yaml` — only a container does — so
1514
- # this heading rendered as `### settings — `, with an orphaned separator, for every
1515
- # component of every product. The separator belongs to the name, not to the heading.
1516
1713
  name = str(pc.get("name") or "").strip()
1517
1714
  parts.append(f"\n### {pid} — {name}\n" if name else f"\n### {pid}\n")
1518
- parts.append(_demote(block) if block
1519
- else "_no § Actor Register in this component's SRS yet._")
1715
+ parts.append(_demote(block) if block else "_no § Actor Register in this component's SRS yet._")
1520
1716
 
1521
1717
  parts.append("\n## Domain model\n")
1522
1718
  for pc in c.pcs:
@@ -1525,12 +1721,40 @@ def page_blueprint(c: Corpus) -> str:
1525
1721
  parts.append(f"\n### {pid}\n")
1526
1722
  parts.append(_demote(block) if block else "_no `03-domain/domain-model.md` yet._")
1527
1723
 
1528
- parts.append("\n## Three inventories\n")
1724
+ parts.append("\n## Business rules binding more than one component\n")
1725
+ br = _section(c.root / ".what" / "business-rules.md", "Rules")
1726
+ parts.append(f"\n{br}\n" if br else "\n_no `.what/business-rules.md` § Rules yet._\n")
1727
+
1728
+ parts.append("\n## Invariants — the spine\n")
1729
+ parts.append("\nRendered from `.how/_platform/ARCHITECTURE-SPINE.md`. G3 asks of every row: does it "
1730
+ "name the concrete failure it prevents, and would breaking it in one component break "
1731
+ "another?\n\n" + _ad_table(_ad_blocks(c.root)))
1732
+
1733
+ parts.append("\n\n## Containers, and which components live in each\n")
1734
+ parts.append("\nRendered from `components.yaml` — the table C4 L2 used to carry by hand.\n\n"
1735
+ + _pc_container_table(c))
1736
+ c4 = _body(c.root / ".how" / "_platform" / "c4-l2-containers.md")
1737
+ if c4:
1738
+ parts.append(f"\n\n### C4 L2 — `c4-l2-containers.md`\n\n{_demote(c4)}")
1739
+
1740
+ parts.append("\n\n## Three inventories\n")
1529
1741
  for kind, name in (("db", "table"), ("api", "endpoint"), ("screen", "screen")):
1530
1742
  block = _body(c.root / f".how/_platform/inventory-{kind}.md")
1531
1743
  parts.append(f"\n### List of {name}s — `inventory-{kind}.md`\n")
1532
1744
  parts.append(_demote(block) if block else f"_no `inventory-{kind}.md` yet._")
1533
1745
 
1746
+ parts.append("\n## Error envelope\n")
1747
+ cc = c.root / ".how" / "_platform" / "cross-cutting.md"
1748
+ env = _section(cc, "Error envelope")
1749
+ cat = _section(cc, "Error catalogue")
1750
+ parts.append(f"\n{env}\n" if env else "\n_no § Error envelope in `cross-cutting.md` yet._\n")
1751
+ if cat:
1752
+ parts.append(f"\n### Error catalogue\n\n{cat}\n")
1753
+
1754
+ parts.append("\n## Glossary\n")
1755
+ gl = _section(c.root / ".control" / "product-glossary.md", "Entries")
1756
+ parts.append(f"\n{gl}\n" if gl.strip() else "\n_no entry in `product-glossary.md` yet._\n")
1757
+
1534
1758
  return "\n".join(parts) + "\n"
1535
1759
 
1536
1760
 
@@ -1540,6 +1764,62 @@ def _cell(value: object, limit: int = 110) -> str:
1540
1764
  return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
1541
1765
 
1542
1766
 
1767
+ def _full(value: object, _limit: int = 0) -> str:
1768
+ """One table cell on a HUMAN page: whitespace collapsed, pipes escaped, never shortened. The
1769
+ rendered trees are the complete document — a cell ending in `…` would send the reader back to
1770
+ the registry, which is exactly the trip the page exists to save. `_limit` is accepted and
1771
+ ignored so a call site shared with `_cell` reads the same."""
1772
+ return " ".join(str(value or "").split()).replace("|", "\\|")
1773
+
1774
+
1775
+ def _by_id(items: list[dict]) -> list[dict]:
1776
+ """`FR-2` before `FR-10`. YAML order is whatever the last writer left; a page reads in id order."""
1777
+ def key(row: dict) -> tuple:
1778
+ m = re.match(r"^([A-Za-z]+)-(\d+)$", str(row.get("id") or ""))
1779
+ return (m.group(1), int(m.group(2))) if m else (str(row.get("id") or ""), 0)
1780
+ return sorted(items, key=key)
1781
+
1782
+
1783
+ def _doc_title(path: Path, fallback: str) -> str:
1784
+ """The working document's own title — frontmatter `title:` first, else its first `# ` line."""
1785
+ if not path.exists():
1786
+ return fallback
1787
+ text = path.read_text(encoding="utf-8", errors="replace")
1788
+ fm = FM.match(text)
1789
+ if fm:
1790
+ m = re.search(r"^title:\s*[\"']?(.+?)[\"']?\s*$", fm.group(0), re.M)
1791
+ if m and m.group(1).strip():
1792
+ return m.group(1).strip()
1793
+ m = re.search(r"^# (.+?)\s*$", text, re.M)
1794
+ return m.group(1).strip() if m else fallback
1795
+
1796
+
1797
+ def _req_block(row: dict, level: int = 4) -> str:
1798
+ """One requirement, whole: heading, the full statement when the row carries one, then the fields
1799
+ a reader checks it by. This is the shape the PRD used to write by hand, rebuilt from the row."""
1800
+ rid = str(row.get("id") or "")
1801
+ title = _text(row)
1802
+ out = [f"{'#' * level} {rid} — {title}", ""]
1803
+ statement = " ".join(str(row.get("statement") or "").split())
1804
+ if statement and statement.rstrip(".").lower() != title.rstrip(".").lower():
1805
+ out += [statement, ""]
1806
+ fields = []
1807
+ if rid.startswith("FR"):
1808
+ proof = _proof(row)
1809
+ fields.append(("Proof of done", proof or "—"))
1810
+ if row.get("capability"):
1811
+ fields.append(("Capability", f"`{row.get('capability')}`"))
1812
+ elif rid.startswith("NFR"):
1813
+ enforced = listy(row, "enforced_by") or ([row.get("enforced_by")] if row.get("enforced_by") else [])
1814
+ fields.append(("Enforced by", ", ".join(str(x) for x in enforced) or "—"))
1815
+ if row.get("goal"):
1816
+ fields.append(("Serves", f"`{row.get('goal')}`"))
1817
+ if row.get("component"):
1818
+ fields.append(("Component", f"`{row.get('component')}`"))
1819
+ out += [f"**{k}:** {v} " for k, v in fields]
1820
+ return "\n".join(out).rstrip() + "\n"
1821
+
1822
+
1543
1823
  def _demote(block: str, by: int = 2) -> str:
1544
1824
  """Demote the heading level of inlined content, so it does not clash with the roll-up's own structure."""
1545
1825
  out = []
@@ -1554,7 +1834,7 @@ def _demote(block: str, by: int = 2) -> str:
1554
1834
 
1555
1835
 
1556
1836
  def page_estimate(c: Corpus) -> str:
1557
- """Table of CANDIDATE tasks. One row per `FR`, since that is a wave's ideal shape."""
1837
+ """Table of CANDIDATE tasks. One row per `FR`, since that is a spec's ideal shape."""
1558
1838
  mode_of = {str(pc.get("id")): c.mode_of(pc) for pc in c.pcs}
1559
1839
  risk_of = {str(pc.get("id")): (str(pc.get("risk_accepted") or "—"),
1560
1840
  str(pc.get("risk_note") or "—")) for pc in c.pcs}
@@ -1567,14 +1847,14 @@ def page_estimate(c: Corpus) -> str:
1567
1847
  have_mandays = any(x.get("estimate_mandays") for x in c.caps)
1568
1848
  parts = ["# estimate\n", PAGE_HEADER,
1569
1849
  "\n**THIS IS AN ESTIMATE, FORWARD-LOOKING.** Every row below is a **candidate** "
1570
- "task; the wave in `waves.yaml` is the real one. One row MAY become one wave, and three "
1850
+ "task; the spec in `specs.yaml` is the real one. One row MAY become one spec, and three "
1571
1851
  "neighboring rows MAY be merged into one — that merge is a human decision made when the "
1572
- "wave is opened.\n"]
1852
+ "spec is opened.\n"]
1573
1853
  if not have_mandays:
1574
1854
  parts.append("\n**With no `estimate_mandays` on a single `CAP`**, the Load column is empty and "
1575
1855
  "this output is only as good as a T-shirt-size estimate. It MUST be reported as such.\n")
1576
1856
 
1577
- parts.append("\n| Task | FR | Epic | mode | Exposure | Load | Priority | Depends on | Release |")
1857
+ parts.append("\n| Task | FR | Component | mode | Exposure | Load | Priority | Depends on | Release |")
1578
1858
  parts.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
1579
1859
  for fr in c.frs:
1580
1860
  cap_id = str(fr.get("capability", ""))
@@ -1597,7 +1877,487 @@ def page_estimate(c: Corpus) -> str:
1597
1877
  return "\n".join(parts) + "\n"
1598
1878
 
1599
1879
 
1880
+ ID_CITE_RE = re.compile(r"\b(?:CAP|FR|NFR)-\d+\b")
1881
+
1882
+
1883
+ def _cited_ids(text: str) -> list[str]:
1884
+ """Every CAP-N / FR-N / NFR-N cited in TEXT, in first-seen order, no repeats."""
1885
+ seen: set[str] = set()
1886
+ out: list[str] = []
1887
+ for m in ID_CITE_RE.finditer(text):
1888
+ if m.group(0) not in seen:
1889
+ seen.add(m.group(0))
1890
+ out.append(m.group(0))
1891
+ return out
1892
+
1893
+
1894
+ def _subsection(path: Path, parent: str, sub: str) -> str:
1895
+ """Extract one `### <sub>` subsection from within a `## <parent>` block, as-is."""
1896
+ block = _section(path, parent)
1897
+ if not block:
1898
+ return ""
1899
+ lines = block.splitlines()
1900
+ out: list[str] = []
1901
+ inside = False
1902
+ for line in lines:
1903
+ if line.startswith("### "):
1904
+ if inside:
1905
+ break
1906
+ inside = _hname(line[4:]).startswith(_hname(sub))
1907
+ continue
1908
+ if inside:
1909
+ out.append(line)
1910
+ return "\n".join(out).strip("\n")
1911
+
1912
+
1913
+ def _has_rows(block: str) -> bool:
1914
+ """True if a `## Open` table has a real data row — not just header, separator, and the
1915
+ scaffold's own `| — | — | ... |` placeholder for "nothing here yet"."""
1916
+ lines = [ln.strip() for ln in block.splitlines() if ln.strip().startswith("|")]
1917
+ for line in lines[2:]:
1918
+ first = line.strip("|").split("|", 1)[0].strip()
1919
+ if first and first not in ("—", "-"):
1920
+ return True
1921
+ return False
1922
+
1923
+
1924
+ def _filtered_rows(block: str, needles: list[str]) -> str:
1925
+ """A markdown table, kept whole for its header and separator, with only the DATA rows that
1926
+ mention one of `needles` (case-insensitive substring). Returns "" if nothing matched.
1927
+
1928
+ This is how the brief and PRD deliverables show only the open questions and assumptions that
1929
+ are actually theirs, without `.control/questions/` carrying a field that says so.
1930
+ """
1931
+ if not block.strip():
1932
+ return ""
1933
+ lines = block.splitlines()
1934
+ out: list[str] = []
1935
+ header_seen = 0
1936
+ kept_any = False
1937
+ for line in lines:
1938
+ stripped = line.strip()
1939
+ if stripped.startswith("|") and header_seen < 2:
1940
+ out.append(line)
1941
+ header_seen += 1
1942
+ continue
1943
+ if stripped.startswith("|"):
1944
+ if any(n.lower() in line.lower() for n in needles):
1945
+ out.append(line)
1946
+ kept_any = True
1947
+ continue
1948
+ out.append(line)
1949
+ return "\n".join(out).strip("\n") if kept_any else ""
1950
+
1951
+
1952
+ def _glossary_terms_used(root: Path, text: str) -> str:
1953
+ """`## Entries` bullets from `product-glossary.md` whose term is a whole word somewhere in TEXT."""
1954
+ path = root / ".control" / "product-glossary.md"
1955
+ entries = _section(path, "Entries")
1956
+ if not entries:
1957
+ return ""
1958
+ out = []
1959
+ for line in entries.splitlines():
1960
+ m = re.match(r"^-\s*\*\*(.+?)\*\*", line.strip())
1961
+ if m and re.search(rf"\b{re.escape(m.group(1))}\b", text, re.I):
1962
+ out.append(line)
1963
+ return "\n".join(out)
1964
+
1965
+
1966
+ AD_HEAD_RE = re.compile(r"^###\s+(AD-\d+)\s+[—-]+\s+(.*?)\s*$", re.M)
1967
+
1968
+
1969
+ def _ad_blocks(root: Path) -> list[dict]:
1970
+ """Every `### AD-N — title` block in the spine, with its Binds / Prevents / Rule lines.
1971
+
1972
+ The spine is the SSOT for an invariant and `AD-` is edited in place — so nothing else may hold
1973
+ its text. The rendered SDD gets it from here, which is what made the verbatim quote in the
1974
+ working SDD unnecessary, and unsafe.
1975
+ """
1976
+ text = _body(root / ".how" / "_platform" / "ARCHITECTURE-SPINE.md")
1977
+ if not text:
1978
+ return []
1979
+ heads = list(AD_HEAD_RE.finditer(text))
1980
+ out: list[dict] = []
1981
+ for i, m in enumerate(heads):
1982
+ end = heads[i + 1].start() if i + 1 < len(heads) else len(text)
1983
+ block = text[m.end():end]
1984
+ field = lambda name: (re.search(rf"\*\*{name}:\*\*\s*(.*)", block) or [None, ""])[1].strip()
1985
+ out.append({"id": m.group(1), "title": m.group(2).strip(),
1986
+ "binds": field("Binds"), "prevents": field("Prevents"), "rule": field("Rule")})
1987
+ return out
1988
+
1989
+
1990
+ def _binds_pc(ad: dict, pid: str, lc_ids: set[str]) -> bool:
1991
+ b = ad.get("binds", "")
1992
+ if not b:
1993
+ return False
1994
+ if re.search(r"\ball\b", b):
1995
+ return True
1996
+ return bool(re.search(rf"(?<![\w-]){re.escape(pid)}(?![\w-])", b)) or any(l in b for l in lc_ids)
1997
+
1998
+
1999
+ def _ad_table(ads: list[dict], pid: str | None = None, lc_ids: set[str] | None = None) -> str:
2000
+ if not ads:
2001
+ return "_no `AD-N` in the spine yet._"
2002
+ if pid is None:
2003
+ rows_out = ["| id | Invariant | Binds | Prevents | Rule |", "| --- | --- | --- | --- | --- |"]
2004
+ for ad in _by_id(ads):
2005
+ rows_out.append(f"| `{ad['id']}` | {_full(ad['title'])} | {_full(ad['binds'], 60)} "
2006
+ f"| {_full(ad['prevents'])} | {_full(ad['rule'])} |")
2007
+ else:
2008
+ rows_out = ["| id | Invariant | Binds this component | Prevents | Rule |",
2009
+ "| --- | --- | --- | --- | --- |"]
2010
+ for ad in _by_id(ads):
2011
+ hit = "**yes**" if _binds_pc(ad, pid, lc_ids or set()) else "no"
2012
+ rows_out.append(f"| `{ad['id']}` | {_full(ad['title'])} | {hit} "
2013
+ f"| {_full(ad['prevents'])} | {_full(ad['rule'])} |")
2014
+ return "\n".join(rows_out)
2015
+
2016
+
2017
+ def _uc_table(ucs: list[dict]) -> str:
2018
+ if not ucs:
2019
+ return "_no use case registered yet._"
2020
+ rows_out = ["| id | Use case | Component | Satisfies | critical |",
2021
+ "| --- | --- | --- | --- | --- |"]
2022
+ for uc in _by_id(ucs):
2023
+ sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
2024
+ rows_out.append(f"| `{uc.get('id')}` | {_full(uc.get('title'))} | `{uc.get('component', '')}` "
2025
+ f"| {sat} | {'yes' if uc.get('critical') else 'no'} |")
2026
+ return "\n".join(rows_out)
2027
+
2028
+
2029
+ def _lc_table(lcs: list[dict]) -> str:
2030
+ if not lcs:
2031
+ return "_no Logical Component registered yet._"
2032
+ rows_out = ["| id | Name | Type | Container |", "| --- | --- | --- | --- |"]
2033
+ for lc in _by_id(lcs):
2034
+ rows_out.append(f"| `{lc.get('id')}` | {_full(lc.get('name'))} | `{lc.get('lc_type', '—')}` "
2035
+ f"| `{lc.get('container') or '—'}` |")
2036
+ return "\n".join(rows_out)
2037
+
2038
+
2039
+ def _pc_container_table(c: Corpus) -> str:
2040
+ """The PC x container matrix, RENDERED from `components.yaml`. This is the table C4 L2 used to
2041
+ carry by hand and `container-built` had to keep honest; rendered, there is nothing to compare."""
2042
+ built = [ct for ct in rows(c.components, "containers") if ct.get("built")]
2043
+ if not built:
2044
+ return "_no `built: true` container registered yet._"
2045
+ rows_out = ["| Container | What | Product Components living in it |", "| --- | --- | --- |"]
2046
+ for ct in built:
2047
+ cid = str(ct.get("id"))
2048
+ pcs = [str(pc.get("id")) for pc in c.pcs if cid in listy(pc, "containers")]
2049
+ rows_out.append(f"| `{cid}` | {_full(ct.get('what'))} | "
2050
+ f"{', '.join(f'`{x}`' for x in pcs) or '—'} |")
2051
+ return "\n".join(rows_out)
2052
+
2053
+
2054
+ def _slot_files(root: Path, layer: str, pid: str, slot: str) -> list[Path]:
2055
+ d = root / layer / pid / slot
2056
+ return sorted(d.glob("*.md")) if d.is_dir() else []
2057
+
2058
+
2059
+ def _inline(path: Path, level: int = 3) -> str:
2060
+ """A whole working file inlined under a heading that names it, its own headings demoted."""
2061
+ body = _body(path)
2062
+ if not body:
2063
+ return ""
2064
+ return f"\n{'#' * level} `{path.name}`\n\n{_demote(body, by=level - 1)}\n"
2065
+
2066
+
2067
+ def _text(row: dict) -> str:
2068
+ """The one-line LABEL of a registry row. `title` is what newer corpora carry, `text` what older ones
2069
+ do — both are the short label and rank first. `statement` is the full sentence and comes last, so a
2070
+ row that has a label never renders its paragraph as the heading. Ranking it higher once made an
2071
+ upgrade copy `text` into `title` on every row to get a readable page."""
2072
+ return str(row.get("title") or row.get("text") or row.get("statement") or "")
2073
+
2074
+
2075
+ def _proof(row: dict) -> str:
2076
+ return str(row.get("proof") or row.get("proof_of_done") or "")
2077
+
2078
+
2079
+ def page_brief(c: Corpus) -> str:
2080
+ """The brief's own sections verbatim, plus what only the registry and `.control/questions/`
2081
+ can complete: Goals rendered from `goals.yaml`, and the open Assumptions and
2082
+ Prerequisites rows. The page read at G1. Nobody edits it — it is regenerated from its sources.
2083
+ """
2084
+ path = c.root / ".what" / "_product-brief" / "brief.md"
2085
+ if not path.exists():
2086
+ return "# brief\n\n" + PAGE_HEADER + "\n\n_no `.what/_product-brief/brief.md` yet._\n"
2087
+
2088
+ parts = [f"# {_doc_title(path, 'Product Brief')}\n", PAGE_HEADER]
2089
+ for heading in ("Why", "The Problem", "The Solution", "What Makes This Different",
2090
+ "Who This Serves"):
2091
+ block = _section(path, heading)
2092
+ if block:
2093
+ parts.append(f"\n## {heading}\n\n{block}\n")
2094
+
2095
+ parts.append("\n## Goals\n")
2096
+ if c.goals:
2097
+ parts.append("\nRendered from `.control/registry/goals.yaml`.\n")
2098
+ for g in _by_id(c.goals):
2099
+ title = _text(g)
2100
+ parts.append(f"\n### {g.get('id')} — {title}\n")
2101
+ statement = " ".join(str(g.get("statement") or "").split())
2102
+ if statement and statement.rstrip(".").lower() != title.rstrip(".").lower():
2103
+ parts.append(f"\n{statement}\n")
2104
+ if g.get("measure"):
2105
+ parts.append(f"\n**Measure:** {_full(g.get('measure'))} ")
2106
+ if g.get("why"):
2107
+ parts.append(f"**Why:** {_full(g.get('why'))} ")
2108
+ else:
2109
+ parts.append("\n_no goal registered yet._")
2110
+
2111
+ success = _section(path, "Success Criteria")
2112
+ if success:
2113
+ parts.append(f"\n\n## Success Criteria\n\n{success}\n")
2114
+
2115
+ scope = _section(path, "Scope")
2116
+ if scope:
2117
+ parts.append(f"\n## Scope\n\n{scope}\n")
2118
+
2119
+ constraints = _section(path, "Constraints")
2120
+ if constraints:
2121
+ parts.append(f"\n## Constraints\n\n{constraints}\n")
2122
+
2123
+ parts.append("\n## Assumptions\n")
2124
+ open_assumptions = _section(c.root / ".control" / "questions" / "assumptions.md", "Open")
2125
+ parts.append(f"\n{open_assumptions}\n" if _has_rows(open_assumptions)
2126
+ else "\n_none open._\n")
2127
+
2128
+ parts.append("\n## Prerequisites\n")
2129
+ open_prereqs = _section(c.root / ".control" / "questions" / "external.md", "Open")
2130
+ parts.append(f"\n{open_prereqs}\n" if _has_rows(open_prereqs) else "\n_none open._\n")
2131
+
2132
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(parts)) + "\n"
2133
+
2134
+
2135
+ def page_prd(c: Corpus, slug: str) -> str:
2136
+ """This PRD's own sections verbatim, plus what only the brief, the registry, the glossary, and
2137
+ `.control/questions/` can complete. Nobody edits this by hand.
2138
+ """
2139
+ path = c.root / ".what" / "_prd" / slug / "prd.md"
2140
+ if not path.exists():
2141
+ return (f"# prd-{slug}\n\n" + PAGE_HEADER +
2142
+ f"\n\n_no `.what/_prd/{slug}/prd.md` yet._\n")
2143
+ text = _body(path)
2144
+ brief_path = c.root / ".what" / "_product-brief" / "brief.md"
2145
+
2146
+ parts = [f"# {_doc_title(path, f'PRD — {slug}')}\n", PAGE_HEADER]
2147
+
2148
+ parts.append("\n## Why\n")
2149
+ why_brief = _section(brief_path, "Why")
2150
+ why_delta = _section(path, "1. Why This Initiative") or _section(path, "Why This Initiative")
2151
+ if why_brief:
2152
+ parts.append(f"\n{why_brief}\n")
2153
+ if why_delta:
2154
+ parts.append(f"\n**This initiative:** {why_delta}\n")
2155
+
2156
+ target_user = _section(path, "2. Target User") or _section(path, "Target User")
2157
+ if target_user:
2158
+ parts.append(f"\n## Target User\n\n{target_user}\n")
2159
+
2160
+ # This initiative's OWN requirement file is the source, not a scan of the prose. That is the
2161
+ # whole point of the split: which PRD a promise belongs to is structural now, and a page that
2162
+ # guessed it from citations would still be guessing.
2163
+ own = c.requirements_of(slug)
2164
+ own_caps = _by_id(rows(own, "capabilities"))
2165
+ own_frs = _by_id(rows(own, "functional"))
2166
+ own_nfrs = _by_id(rows(own, "nonfunctional"))
2167
+ own_ujs = _by_id(rows(own, "journeys"))
2168
+ by_id = {str(r.get("id")): r for r in own_frs + own_nfrs}
2169
+
2170
+ # § Features, with every `**Realizes:** FR-1, NFR-2` line expanded into the rows it names. That
2171
+ # rebuilds the shape the PRD used to write by hand — feature, then its requirements in full —
2172
+ # from the registry, so the reader never leaves this page for the text.
2173
+ features = _section(path, "3. Features") or _section(path, "Features")
2174
+ realized: set[str] = set()
2175
+ if features:
2176
+ out_lines: list[str] = []
2177
+ for line in features.splitlines():
2178
+ m = re.match(r"^\*\*Realizes:\*\*\s*(.*)$", line.strip())
2179
+ if not m:
2180
+ out_lines.append(line)
2181
+ continue
2182
+ ids = [i for i in re.findall(r"\b(?:FR|NFR)-\d+\b", m.group(1))]
2183
+ found = [by_id[i] for i in ids if i in by_id]
2184
+ missing = [i for i in ids if i not in by_id]
2185
+ if not found:
2186
+ out_lines.append(line)
2187
+ continue
2188
+ realized.update(str(r.get("id")) for r in found)
2189
+ out_lines.append("")
2190
+ for r in found:
2191
+ out_lines.append(_req_block(r, level=4))
2192
+ if missing:
2193
+ out_lines.append(f"_Also cited, not in `requirements-{slug}.yaml`: "
2194
+ + ", ".join(f"`{i}`" for i in missing) + "._\n")
2195
+ parts.append("\n## Features\n\n" + "\n".join(out_lines).rstrip() + "\n")
2196
+
2197
+ if own_caps:
2198
+ parts.append("\n### Capabilities\n")
2199
+ parts.append("\n| id | Serves | Capability | Priority | Release | Depends on |"
2200
+ "\n| --- | --- | --- | --- | --- | --- |")
2201
+ for cap in own_caps:
2202
+ deps = ", ".join(f"`{x}`" for x in listy(cap, "depends_on")) or "—"
2203
+ parts.append(f"| `{cap.get('id')}` | `{cap.get('goal', '—')}` "
2204
+ f"| {_full(_text(cap))} | {cap.get('priority', '—')} "
2205
+ f"| {cap.get('target_release', '—')} | {deps} |")
2206
+ if own_ujs:
2207
+ parts.append("\n\n### User journeys\n")
2208
+ parts.append("\n| id | Journey |\n| --- | --- |")
2209
+ for uj in own_ujs:
2210
+ parts.append(f"| `{uj.get('id')}` | {_full(_text(uj))} |")
2211
+ rest_frs = [r for r in own_frs if str(r.get("id")) not in realized]
2212
+ rest_nfrs = [r for r in own_nfrs if str(r.get("id")) not in realized]
2213
+ if rest_frs:
2214
+ note = " not attached to a feature above" if realized else ""
2215
+ parts.append(f"\n\n### Functional requirements{note}\n")
2216
+ for fr in rest_frs:
2217
+ parts.append("\n" + _req_block(fr))
2218
+ if rest_nfrs:
2219
+ note = " not attached to a feature above" if realized else ""
2220
+ parts.append(f"\n\n### Non-functional requirements{note}\n")
2221
+ for nfr in rest_nfrs:
2222
+ parts.append("\n" + _req_block(nfr))
2223
+ if not own and _cited_ids(text):
2224
+ parts.append(f"\n_`requirements-{slug}.yaml` does not exist yet — this initiative's `FR` "
2225
+ f"and `NFR` have not been landed in a registry file of their own._\n")
2226
+
2227
+ cited = _cited_ids(text)
2228
+
2229
+ for heading, title in (("4. MVP Scope", "MVP Scope"), ("5. Success Metrics", "Success Metrics"),
2230
+ ("6. Cross-Cutting NFRs", "Cross-Cutting NFRs"),
2231
+ ("7. Constraints and Guardrails", "Constraints and Guardrails")):
2232
+ block = _section(path, heading)
2233
+ if block:
2234
+ parts.append(f"\n\n## {title}\n\n{block}\n")
2235
+
2236
+ # The UX half G2 reads beside the PRD. An EXPERIENCE.md belongs to this initiative when it
2237
+ # references one of this initiative's UJ ids — the template requires it to reference them.
2238
+ uj_ids = [str(x.get("id")) for x in rows(own, "journeys") if x.get("id")]
2239
+ if uj_ids:
2240
+ for exp in sorted((c.root / ".what").glob("*/04-usecases/EXPERIENCE.md")):
2241
+ etext = _body(exp)
2242
+ if not any(re.search(rf"(?<![\w-]){re.escape(u)}(?![\w-])", etext) for u in uj_ids):
2243
+ continue
2244
+ pcname = exp.parent.parent.name
2245
+ parts.append(f"\n\n## Experience — `{pcname}`\n")
2246
+ for heading in ("Information architecture", "Journeys", "Behaviour per surface",
2247
+ "Accessibility", "Edge cases"):
2248
+ block = _section(exp, heading)
2249
+ if block:
2250
+ parts.append(f"\n### {heading}\n\n{block}\n")
2251
+
2252
+ parts.append("\n\n## Non-Goals\n")
2253
+ scope_out = _subsection(brief_path, "Scope", "Scope Out")
2254
+ if scope_out:
2255
+ parts.append(f"\n**Product-wide, from the brief:**\n\n{scope_out}\n")
2256
+ else:
2257
+ parts.append("\n_none stated in the brief._\n")
2258
+ parts.append("\nRelease-specific exclusions are under **MVP Scope → Out of Scope for MVP** "
2259
+ "above.\n")
2260
+
2261
+ glossary = _glossary_terms_used(c.root, text)
2262
+ if glossary:
2263
+ parts.append(f"\n## Glossary\n\n{glossary}\n")
2264
+
2265
+ needles = cited + [slug]
2266
+ q_dir = c.root / ".control" / "questions"
2267
+ open_q = "\n\n".join(
2268
+ filter(None, (_filtered_rows(_section(q_dir / f"{name}.md", "Open"), needles)
2269
+ for name in ("blocking", "assumptions", "external"))))
2270
+ if open_q:
2271
+ parts.append(f"\n## Open Questions and Assumptions\n\n{open_q}\n")
2272
+
2273
+ rh = _section(path, "Revision History")
2274
+ if rh:
2275
+ parts.append(f"\n\n## Revision History\n\n{rh}\n")
2276
+
2277
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(parts)) + "\n"
2278
+
2279
+
2280
+ def page_srs(c: Corpus, pid: str) -> str:
2281
+ """One component's SRS, complete: its own kernel prose verbatim, the UC catalogue rendered from
2282
+ `usecases.yaml`, and every slot file inlined. For a reader who wants ONE component whole."""
2283
+ path = c.root / ".what" / pid / f"SRS-{pid}.md"
2284
+ pc = next((x for x in c.pcs if str(x.get("id")) == pid), {})
2285
+ parts = [f"# {_doc_title(path, f'SRS — {pid}')}\n", PAGE_HEADER]
2286
+ if not path.exists():
2287
+ return "\n".join(parts) + f"\n\n_no `.what/{pid}/SRS-{pid}.md` yet._\n"
2288
+ parts.append(f"\n`mode: {c.mode_of(pc)}` · `risk_accepted: {pc.get('risk_accepted', '—')}` — "
2289
+ f"from `components.yaml`.\n")
2290
+ for heading in ("Decision Summary", "Why", "Actor Register"):
2291
+ block = _section(path, heading)
2292
+ if block:
2293
+ parts.append(f"\n## {heading}\n\n{block}\n")
2294
+ own_ucs = [uc for uc in c.ucs if str(uc.get("component") or "") == pid]
2295
+ parts.append(f"\n## UC Catalogue\n\nRendered from `usecases.yaml`.\n\n{_uc_table(own_ucs)}\n")
2296
+ for heading in ("Constraints", "Non-Goals", "Prerequisite", "Success Signal", "Design Reference"):
2297
+ block = _section(path, heading)
2298
+ if block:
2299
+ parts.append(f"\n## {heading}\n\n{block}\n")
2300
+ for slot, title in (("02-rules", "Business rules — local"), ("03-domain", "Domain"),
2301
+ ("04-usecases", "Use cases"), ("05-scenarios", "Scenarios")):
2302
+ files = _slot_files(c.root, ".what", pid, slot)
2303
+ if files:
2304
+ parts.append(f"\n## {title} — `{slot}/`\n")
2305
+ for f in files:
2306
+ parts.append(_inline(f))
2307
+ return "\n".join(parts) + "\n"
2308
+
2309
+
2310
+ def page_sdd(c: Corpus, pid: str) -> str:
2311
+ """The ONE page read at G4 for one component. Seven questions, and where each is answered:
2312
+ what is staked -> registry; boundaries with no failure answer -> § Failure Behaviour; what
2313
+ stops us tomorrow -> questions/; validators + review -> status; vendor lock -> § Decision
2314
+ Summary; DAG -> generated/dag; top risk -> risks.yaml. Only two of the seven need SDD prose.
2315
+
2316
+ The AD-N table is rendered from the spine, not quoted. `AD-` is edited in place, so a quote
2317
+ goes stale the moment the spine changes and nothing sees it — the working SDD now cites ids.
2318
+ """
2319
+ path = c.root / ".how" / pid / f"SDD-{pid}.md"
2320
+ pc = next((x for x in c.pcs if str(x.get("id")) == pid), {})
2321
+ lcs = [lc for lc in c.lcs if str(lc.get("component") or "") == pid]
2322
+ lc_ids = {str(lc.get("id")) for lc in lcs}
2323
+ parts = [f"# {_doc_title(path, f'SDD — {pid}')}\n", PAGE_HEADER,
2324
+ "\nThis is what the owner reads at **G4 Component** for this component.\n"]
2325
+ parts.append(f"\n## What is staked\n\n`mode: {c.mode_of(pc)}` · "
2326
+ f"`risk_accepted: {pc.get('risk_accepted', '—')}` · "
2327
+ f"`g4_passed: {pc.get('g4_passed', '—')}`\n\n"
2328
+ f"**risk_note:** {pc.get('risk_note') or '—'}\n\n"
2329
+ f"**owns:** {', '.join(f'`{x}`' for x in listy(pc, 'owns')) or '—'} · "
2330
+ f"**containers:** {', '.join(f'`{x}`' for x in listy(pc, 'containers')) or '—'}\n")
2331
+ if not path.exists():
2332
+ return "\n".join(parts) + f"\n\n_no `.how/{pid}/SDD-{pid}.md` yet._\n"
2333
+ ds = _section(path, "Decision Summary")
2334
+ parts.append(f"\n## Decision Summary\n\n{ds}\n" if ds else "\n## Decision Summary\n\n_not written yet._\n")
2335
+ parts.append(f"\n## Structure — Logical Components\n\nRendered from `components.yaml`.\n\n{_lc_table(lcs)}\n")
2336
+ st = _section(path, "Structure")
2337
+ if st:
2338
+ parts.append(f"\n### Dependency direction and responsibilities\n\n{_demote(st)}\n")
2339
+ parts.append("\n## Inherited Constraints — the spine's invariants\n\nRendered from "
2340
+ "`ARCHITECTURE-SPINE.md`. A row marked **yes** binds this component by `Binds:`; the "
2341
+ "rest are shown so a miss in `Binds:` is visible, not hidden.\n\n"
2342
+ + _ad_table(_ad_blocks(c.root), pid, lc_ids) + "\n")
2343
+ for heading in ("Failure Behaviour", "Robustness Analysis", "Design Notes", "Evidence"):
2344
+ block = _section(path, heading)
2345
+ if block:
2346
+ parts.append(f"\n## {heading}\n\n{block}\n")
2347
+ for slot, title in (("01-ux", "UX — screens"), ("02-contracts", "Contracts"),
2348
+ ("03-integrations", "Integrations"), ("04-components", "Components"),
2349
+ ("05-model", "Data model"), ("06-flows", "Flows")):
2350
+ files = _slot_files(c.root, ".how", pid, slot)
2351
+ if files:
2352
+ parts.append(f"\n## {title} — `{slot}/`\n")
2353
+ for f in files:
2354
+ parts.append(_inline(f))
2355
+ return "\n".join(parts) + "\n"
2356
+
2357
+
1600
2358
  def generate(c: Corpus, result: Result) -> list[Path]:
2359
+ """Machine tables into `.control/generated/`; every page a human reads into the two rendered
2360
+ trees, at the mirror path of the working document it projects."""
1601
2361
  out_dir = c.root / ".control" / "generated"
1602
2362
  out_dir.mkdir(parents=True, exist_ok=True)
1603
2363
  rtm = gen_rtm(c)
@@ -1617,14 +2377,35 @@ def generate(c: Corpus, result: Result) -> list[Path]:
1617
2377
  md_path.write_text(as_markdown(name, payload), encoding="utf-8")
1618
2378
  written += [yaml_path, md_path]
1619
2379
 
1620
- # Three pages for HUMANS: real markdown tables, with no .yaml twin. What people read is
1621
- # not wrapped in a yaml fence, and no machine reader demands a second version of it.
1622
- for name, render in (("decisions", page_decisions),
1623
- ("blueprint", page_blueprint),
1624
- ("estimate", page_estimate)):
2380
+ # Two indexes that are tables for both readers, and stay beside the machine ones.
2381
+ for name, render in (("decisions", page_decisions), ("estimate", page_estimate)):
1625
2382
  page = out_dir / f"{name}.md"
1626
2383
  page.write_text(render(c), encoding="utf-8")
1627
2384
  written.append(page)
2385
+
2386
+ # The pages a HUMAN reads: one per gate, at the mirror path of the working document.
2387
+ def emit(rel: Path, text: str) -> None:
2388
+ rel.parent.mkdir(parents=True, exist_ok=True)
2389
+ rel.write_text(text, encoding="utf-8")
2390
+ written.append(rel)
2391
+
2392
+ what_r = c.root / RENDERED_WHAT
2393
+ how_r = c.root / RENDERED_HOW
2394
+ emit(what_r / "_product-brief" / "brief.md", page_brief(c)) # G1
2395
+ for prd_path in sorted((c.root / ".what" / "_prd").glob("*/prd.md")):
2396
+ slug = prd_path.parent.name
2397
+ emit(what_r / "_prd" / slug / "prd.md", page_prd(c, slug)) # G2
2398
+ emit(how_r / "blueprint.md", page_blueprint(c)) # G3
2399
+ for pc in c.pcs:
2400
+ pid = str(pc.get("id"))
2401
+ emit(what_r / pid / f"SRS-{pid}.md", page_srs(c, pid))
2402
+ if c.mode_of(pc) != "catalog":
2403
+ emit(how_r / pid / f"SDD-{pid}.md", page_sdd(c, pid)) # G4
2404
+
2405
+ # These used to be written here before the rendered trees existed. A stale copy left behind
2406
+ # would be the one home too many this whole design removes, so it is cleared, not kept.
2407
+ for stale in ["brief.md", "blueprint.md", *[p.name for p in out_dir.glob("prd-*.md")]]:
2408
+ (out_dir / stale).unlink(missing_ok=True)
1628
2409
  return written
1629
2410
 
1630
2411
 
@@ -1633,14 +2414,14 @@ def generate(c: Corpus, result: Result) -> list[Path]:
1633
2414
 
1634
2415
  def main(argv: list[str] | None = None) -> int:
1635
2416
  parser = argparse.ArgumentParser(
1636
- prog="validate", description="V1..V27 and the .control/generated/ generator")
2417
+ prog="validate", description="goal-has-fr..id-allocated-once and the .control/generated/ generator")
1637
2418
  parser.add_argument("--check", action="store_true",
1638
2419
  help="check only; exit non-zero if anything is red")
1639
2420
  parser.add_argument("--generate", action="store_true",
1640
2421
  help="rewrite .control/generated/ (still runs the check first)")
1641
2422
  parser.add_argument("--root", default=".", help="repo root (default: current directory)")
1642
2423
  parser.add_argument("--asof", default=None,
1643
- help="reference date for V14, format YYYY-MM-DD (default: today). "
2424
+ help="reference date for plan-dates, format YYYY-MM-DD (default: today). "
1644
2425
  "Stated explicitly so a run can be repeated exactly")
1645
2426
  args = parser.parse_args(argv)
1646
2427
 
@@ -1663,14 +2444,14 @@ def main(argv: list[str] | None = None) -> int:
1663
2444
  if result.findings:
1664
2445
  print(f"\nRED — {len(result.findings)} findings across {len(result.red)} validators\n")
1665
2446
  for finding in sorted(result.findings, key=lambda f: f.sort_key):
1666
- print(f" {finding.vid:<4} {finding.subject}: {finding.message}")
2447
+ print(f" {finding.vid:<26} {finding.subject}: {finding.message}")
1667
2448
  else:
1668
2449
  print("\nGREEN — no findings")
1669
2450
 
1670
2451
  if result.skipped:
1671
2452
  print("\nSkipped:")
1672
2453
  for vid, why in sorted(result.skipped.items()):
1673
- print(f" {vid:<4} {why}")
2454
+ print(f" {vid:<26} {why}")
1674
2455
 
1675
2456
  print(f"\nV14 reference date: {asof.isoformat()}")
1676
2457
  return 1 if result.findings else 0