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
@@ -1,665 +1,663 @@
1
- #!/usr/bin/env -S uv run --script
2
- # /// script
3
- # requires-python = ">=3.11"
4
- # dependencies = ["pyyaml>=6"]
5
- # ///
6
- """timeline — the time dimension: generated/timeline, generated/report, .control/reports/<period>.md.
7
-
8
- The corpus can state what is true. It cannot state WHEN that became true, because not one
9
- delivery date is stored — and indeed MUST NOT be stored. This script supplies that missing
10
- dimension by reading git.
11
-
12
- timeline.py --generate write .control/generated/timeline.* and report.*
13
- timeline.py --publish weekly freeze .control/reports/2026-W34.md
14
- timeline.py --publish monthly freeze .control/reports/2026-08.md
15
- timeline.py --refresh --generate run validate --generate first
16
-
17
- The division of labor stays as in 08-project-management.md: whatever can be computed from the
18
- registry alone belongs to validate.py; only what needs git stays here. That is why Corpus, dump,
19
- and friends are imported, not copied — one fact MUST NOT have two homes.
20
-
21
- A delivery date MUST NOT be written back to any registry. It is re-derived on every run; a stored
22
- copy is a copy that will go stale.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- import argparse
28
- import datetime as dt
29
- import sys
30
- from pathlib import Path
31
-
32
- import yaml
33
-
34
- from validate import (FM, Corpus, _story_status, cap_stories, dump, git,
35
- listy, load_yaml)
36
-
37
- # Frontmatter statuses that do not yet mean "in progress". A story still in this set has no
38
- # actual_start, no matter how old the file is.
39
- NOT_STARTED = {"", "draft", "backlog", "todo", "planned"}
40
-
41
- TIMELINE_COLUMNS = [
42
- "id", "text", "size", "priority", "owner", "target_release",
43
- "planned_start", "planned_end", "estimate_mandays",
44
- "actual_start", "actual_end", "delta_days", "state",
45
- ]
46
-
47
-
48
- # ------------------------------------------------------------------ git history
49
-
50
-
51
- _HIST: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
52
-
53
-
54
- def history(root: Path, rel: str) -> list[tuple[str, str, str]]:
55
- """[(sha, date, content)] in chronological order for one file. Empty if never committed."""
56
- key = (str(root), rel)
57
- if key in _HIST:
58
- return _HIST[key]
59
- out = git(root, "log", "--reverse", "--format=%H|%ad", "--date=short", "--", rel)
60
- revs: list[tuple[str, str, str]] = []
61
- for line in (out or "").splitlines():
62
- sha, _, date = line.partition("|")
63
- if not sha or not date:
64
- continue
65
- text = git(root, "show", f"{sha}:./{rel}")
66
- if text is not None:
67
- revs.append((sha, date, text))
68
- _HIST[key] = revs
69
- return revs
70
-
71
-
72
- def fm_of(text: str) -> dict:
73
- match = FM.match(text)
74
- if not match:
75
- return {}
76
- data = yaml.safe_load(match.group(1))
77
- return data if isinstance(data, dict) else {}
78
-
79
-
80
- def yaml_of(text: str) -> dict:
81
- try:
82
- data = yaml.safe_load(text)
83
- except yaml.YAMLError:
84
- return {}
85
- return data if isinstance(data, dict) else {}
86
-
87
-
88
- # ------------------------------------------------------------- time derivation
89
-
90
-
91
- def story_path(c: Corpus, story: dict) -> str | None:
92
- folder = str(story.get("spec_folder") or "").strip()
93
- if not folder:
94
- return None
95
- matches = sorted((c.root / folder / "stories").glob(f"{story.get('id')}-*.md"))
96
- if not matches:
97
- return None
98
- return matches[0].relative_to(c.root).as_posix()
99
-
100
-
101
- def story_span(c: Corpus, story: dict) -> dict:
102
- """When a story started being worked on and when it went `done`, read from its file history.
103
-
104
- A file that exists on disk but has never been committed is marked `uncommitted` instead
105
- of being given a made-up date. A finished story that has not been committed is a state that
106
- MUST be visible, not one that gets patched over.
107
- """
108
- rel = story_path(c, story)
109
- if rel is None:
110
- return {"start": None, "end": None, "uncommitted": False, "path": None}
111
- revs = history(c.root, rel)
112
- if not revs:
113
- return {"start": None, "end": None, "uncommitted": True, "path": rel}
114
- start = end = None
115
- for _sha, date, text in revs:
116
- status = str(fm_of(text).get("status") or "").strip().lower()
117
- if start is None and status not in NOT_STARTED:
118
- start = date
119
- if status == "done":
120
- end = date
121
- break
122
- return {"start": start, "end": end, "uncommitted": False, "path": rel}
123
-
124
-
125
- def fr_stories(c: Corpus) -> dict[str, list[dict]]:
126
- """FR -> story, through its UCs. cap_stories()'s twin, one level down."""
127
- ucs_of: dict[str, list[str]] = {}
128
- for uc in c.ucs:
129
- for fid in listy(uc, "satisfies"):
130
- ucs_of.setdefault(fid, []).append(str(uc.get("id")))
131
- all_stories = [s for _, _, s in c.stories()]
132
- out: dict[str, list[dict]] = {}
133
- for fr in c.frs:
134
- fid = str(fr.get("id"))
135
- wanted = set(ucs_of.get(fid, []))
136
- out[fid] = [s for s in all_stories if wanted & set(listy(s, "satisfies"))]
137
- return out
138
-
139
-
140
- def span_of(c: Corpus, items: list[dict], spans: dict[str, dict]) -> tuple[str | None, str | None, bool]:
141
- """(earliest start, latest end, closed). Closed only when ALL are finished."""
142
- if not items:
143
- return None, None, False
144
- starts = [spans[str(s.get("id"))]["start"] for s in items]
145
- ends = [spans[str(s.get("id"))]["end"] for s in items]
146
- closed = all(e for e in ends)
147
- return (min([x for x in starts if x], default=None),
148
- max([x for x in ends if x], default=None) if closed else None,
149
- closed)
150
-
151
-
152
- def days_between(a: str | None, b: str | None) -> int | None:
153
- if not a or not b:
154
- return None
155
- try:
156
- return (dt.date.fromisoformat(a) - dt.date.fromisoformat(b)).days
157
- except ValueError:
158
- return None
159
-
160
-
161
- def state_of(planned_end: str, actual_start: str | None, closed: bool, asof: dt.date) -> str:
162
- if closed:
163
- return "done"
164
- if planned_end:
165
- try:
166
- if dt.date.fromisoformat(planned_end) < asof:
167
- return "overdue"
168
- except ValueError:
169
- pass
170
- return "in-progress" if actual_start else "not-started"
171
-
172
-
173
- # ---------------------------------------------------------------- generated/timeline
174
-
175
-
176
- def gen_timeline(c: Corpus, asof: dt.date) -> dict:
177
- spans = {str(s.get("id")): story_span(c, s) for _, _, s in c.stories()}
178
- by_cap = cap_stories(c)
179
- by_fr = fr_stories(c)
180
- fr_of_cap: dict[str, list[dict]] = {}
181
- for fr in c.frs:
182
- fr_of_cap.setdefault(str(fr.get("capability", "")), []).append(fr)
183
-
184
- out = []
185
- for cap in c.caps:
186
- cid = str(cap.get("id"))
187
- items = by_cap.get(cid, [])
188
- start, end, closed = span_of(c, items, spans)
189
- planned_end = str(cap.get("planned_end") or "")
190
- row = {
191
- "id": cid,
192
- "text": str(cap.get("text") or ""),
193
- "size": str(cap.get("size") or ""),
194
- "priority": str(cap.get("priority") or ""),
195
- "owner": str(cap.get("owner") or ""),
196
- "target_release": str(cap.get("target_release") or ""),
197
- "planned_start": str(cap.get("planned_start") or ""),
198
- "planned_end": planned_end,
199
- "estimate_mandays": cap.get("estimate_mandays", 0),
200
- "actual_start": start or "",
201
- "actual_end": end or "",
202
- "delta_days": days_between(end, planned_end),
203
- "state": state_of(planned_end, start, closed, asof),
204
- }
205
- children = []
206
- for fr in sorted(fr_of_cap.get(cid, []), key=lambda x: str(x.get("id"))):
207
- fid = str(fr.get("id"))
208
- f_items = by_fr.get(fid, [])
209
- f_start, f_end, f_closed = span_of(c, f_items, spans)
210
- children.append({
211
- "id": fid,
212
- "text": str(fr.get("text") or ""),
213
- "stories": sorted(str(s.get("id")) for s in f_items),
214
- "actual_start": f_start or "",
215
- "actual_end": f_end or "",
216
- "state": state_of("", f_start, f_closed, asof),
217
- })
218
- row["children"] = children
219
- row["waiting_on"] = sorted(
220
- str(s.get("id")) for s in items if _story_status(c, s) != "done"
221
- ) if not closed else []
222
- out.append(row)
223
-
224
- stray = sorted(sid for sid, span in spans.items() if span["uncommitted"])
225
- return {"asof": asof.isoformat(), "capabilities": out, "uncommitted_stories": stray}
226
-
227
-
228
- # ------------------------------------------------------------------ generated/report
229
-
230
-
231
- def last_report(c: Corpus, asof: dt.date) -> tuple[str | None, str | None]:
232
- """(reference date of the last report, its name). Both None if no report exists yet."""
233
- best: tuple[str, str] | None = None
234
- for path in sorted((c.root / ".control/reports").glob("*.md")):
235
- text = path.read_text(encoding="utf-8", errors="replace")
236
- fm = fm_of(text)
237
- stamp = str(fm.get("asof") or "")
238
- if not stamp or stamp >= asof.isoformat():
239
- continue
240
- if best is None or stamp > best[0]:
241
- best = (stamp, path.stem)
242
- return best if best else (None, None)
243
-
244
-
245
- def in_period(date: str | None, since: str | None, asof: dt.date) -> bool:
246
- if not date:
247
- return False
248
- if date > asof.isoformat():
249
- return False
250
- return True if since is None else date > since
251
-
252
-
253
- def first_seen(c: Corpus, rel: str, key: str, pick) -> dict[str, str]:
254
- """id -> date when `pick` first became true in a registry's history."""
255
- seen: dict[str, str] = {}
256
- for _sha, date, text in history(c.root, rel):
257
- data = yaml_of(text)
258
- for item in data.get(key) or []:
259
- ident = pick(item)
260
- if ident:
261
- seen.setdefault(str(ident), date)
262
- return seen
263
-
264
-
265
- def gen_report(c: Corpus, timeline: dict, asof: dt.date) -> dict:
266
- since, since_name = last_report(c, asof)
267
- rtm = (load_yaml(c.root / ".control/generated/rtm.yaml").get("rtm") or [])
268
- status = load_yaml(c.root / ".control/generated/status.yaml")
269
- spans = {str(s.get("id")): story_span(c, s) for _, _, s in c.stories()}
270
-
271
- proven = []
272
- for row in rtm:
273
- sid = str(row.get("story") or "")
274
- end = spans.get(sid, {}).get("end")
275
- if row.get("green") and in_period(end, since, asof):
276
- proven.append({"FR": row.get("FR"), "UC": row.get("UC"), "story": sid,
277
- "test": row.get("test"), "closed": end})
278
- proven.sort(key=lambda x: (str(x["closed"]), str(x["story"])))
279
-
280
- moved = []
281
- for row in timeline["capabilities"]:
282
- for field, event in (("actual_start", "started"), ("actual_end", "closed")):
283
- when = row.get(field) or None
284
- if in_period(when, since, asof):
285
- moved.append({"id": row["id"], "kind": "CAP", "event": event, "date": when})
286
- for child in row["children"]:
287
- for field, event in (("actual_start", "started"), ("actual_end", "closed")):
288
- when = child.get(field) or None
289
- if in_period(when, since, asof):
290
- moved.append({"id": child["id"], "kind": "FR", "event": event, "date": when})
291
- moved.sort(key=lambda x: (x["date"], x["id"], x["event"]))
292
-
293
- late = []
294
- for row in timeline["capabilities"]:
295
- if row["state"] != "overdue":
296
- continue
297
- overdue_by = days_between(asof.isoformat(), row["planned_end"])
298
- late.append({"id": row["id"], "text": row["text"], "owner": row["owner"],
299
- "planned_end": row["planned_end"], "days_late": overdue_by,
300
- "waiting_on": row["waiting_on"] or ["no story yet"]})
301
- late.sort(key=lambda x: (-(x["days_late"] or 0), x["id"]))
302
-
303
- closures = first_seen(c, ".control/registry/defects.yaml", "defects",
304
- lambda d: d.get("id") if str(d.get("status")) == "fixed" else None)
305
- opened, closed_rows = [], []
306
- for defect in c.defect_list:
307
- did = str(defect.get("id"))
308
- entry = {"id": did, "title": str(defect.get("title") or ""),
309
- "root_cause": str(defect.get("root_cause") or ""),
310
- "violates": listy(defect, "violates")}
311
- if in_period(str(defect.get("reported") or ""), since, asof):
312
- opened.append(entry)
313
- when = closures.get(did)
314
- if in_period(when, since, asof):
315
- closed_rows.append({**entry, "closed": when})
316
-
317
- by_cause: dict[str, list[str]] = {}
318
- for row in closed_rows:
319
- by_cause.setdefault(row["root_cause"] or "?", []).append(row["id"])
320
-
321
- # An empty `root_cause` is a valid state the row was opened by someone who has not yet
322
- # diagnosed it, and V20 does skip it. What MUST NOT happen is it aging unseen, so it is
323
- # surfaced in the report instead of being held by a validator. The whole period, not just this one.
324
- undiagnosed = sorted(
325
- ({"id": str(d.get("id")), "title": str(d.get("title") or ""),
326
- "reported": str(d.get("reported") or ""),
327
- "age_days": days_between(asof.isoformat(), str(d.get("reported") or ""))}
328
- for d in c.defect_list
329
- if not str(d.get("root_cause") or "").strip() and str(d.get("status")) != "fixed"),
330
- key=lambda x: (-(x["age_days"] or 0), x["id"]))
331
-
332
- gates = [{"gate": gid, "date": when} for gid, when in sorted(
333
- first_seen(c, ".control/registry/index.yaml", "gates_passed", lambda g: g).items())
334
- if in_period(when, since, asof)]
335
-
336
- head = git(c.root, "rev-parse", "HEAD") or ""
337
- dirty = bool(git(c.root, "status", "--porcelain", "--", ".control/registry"))
338
-
339
- return {
340
- "asof": asof.isoformat(),
341
- "since": since or "",
342
- "since_report": since_name or "",
343
- "sha": head,
344
- "registry_dirty": dirty,
345
- # A done story that has not been committed still counts in the RTM its status is read
346
- # from the working tree — but it will never appear under "Proven", which needs a date from
347
- # git. That gap MUST be visible in the report, not only in the timeline.
348
- "uncommitted_stories": timeline["uncommitted_stories"],
349
- "promise_progress": status.get("promise_progress", "n/a"),
350
- "rtm_rows": status.get("rtm_rows", {}),
351
- "work_progress": status.get("work_progress", []),
352
- "gate_readiness": status.get("gate_readiness", "n/a"),
353
- "proven": proven,
354
- "moved": moved,
355
- "late": late,
356
- "defects": {"opened": sorted(opened, key=lambda x: x["id"]),
357
- "closed": sorted(closed_rows, key=lambda x: x["id"]),
358
- "closed_by_root_cause": {k: sorted(v) for k, v in sorted(by_cause.items())},
359
- "undiagnosed": undiagnosed},
360
- "gates": gates,
361
- }
362
-
363
-
364
- # -------------------------------------------------------------------- rendering
365
-
366
-
367
- def cell(value: object) -> str:
368
- text = ", ".join(str(v) for v in value) if isinstance(value, list) else str(value or "")
369
- return text.replace("|", "\\|").replace("\n", " ").strip() or "—"
370
-
371
-
372
- def table(headers: list[str], lines: list[list[object]]) -> str:
373
- if not lines:
374
- return "_None._\n"
375
- out = ["| " + " | ".join(headers) + " |",
376
- "|" + "|".join("---" for _ in headers) + "|"]
377
- out += ["| " + " | ".join(cell(v) for v in line) + " |" for line in lines]
378
- return "\n".join(out) + "\n"
379
-
380
-
381
- def safe(text: str, width: int = 44) -> str:
382
- """Text safe to put into mermaid: without the `:` and `,` that break its syntax."""
383
- clean = str(text or "").replace(":", " ").replace(",", " ").replace("#", "").strip()
384
- return (clean[:width].rstrip() + "…") if len(clean) > width else (clean or "untitled")
385
-
386
-
387
- def gantt(timeline: dict) -> str:
388
- """Mermaid Gantt: planned and actual side by side, so the gap is visible."""
389
- lines = ["```mermaid", "gantt", " dateFormat YYYY-MM-DD", " axisFormat %d %b",
390
- " title Planned vs actual per CAP", ""]
391
- by_release: dict[str, list[dict]] = {}
392
- for row in timeline["capabilities"]:
393
- by_release.setdefault(row["target_release"] or "no release", []).append(row)
394
- drawn = 0
395
- for release in sorted(by_release):
396
- rows_ = by_release[release]
397
- drawable = [r for r in rows_ if (r["planned_start"] and r["planned_end"])
398
- or (r["actual_start"] and r["actual_end"])]
399
- if not drawable:
400
- continue
401
- lines.append(f" section {safe(release, 24)}")
402
- for row in drawable:
403
- slug = row["id"].replace("-", "").lower()
404
- label = f"{row['id']} {safe(row['text'])}"
405
- if row["planned_start"] and row["planned_end"]:
406
- mark = "crit, " if row["state"] == "overdue" else ""
407
- lines.append(f" {label} planned :{mark}{slug}p, "
408
- f"{row['planned_start']}, {row['planned_end']}")
409
- if row["actual_start"]:
410
- end = row["actual_end"] or timeline["asof"]
411
- mark = "done, " if row["state"] == "done" else "active, "
412
- lines.append(f" {label} actual :{mark}{slug}a, {row['actual_start']}, {end}")
413
- drawn += 1
414
- lines.append("")
415
- lines.append("```")
416
- if drawn == 0:
417
- return ("_No CAP has a planned or actual date yet — "
418
- "the gantt is not drawn._\n")
419
- return "\n".join(lines) + "\n"
420
-
421
-
422
- HEADER = ("> Generated by `.constitution/method/scripts/timeline.py --generate`. "
423
- "MUST NOT be hand-edited.\n")
424
-
425
-
426
- def with_header(rendered: str) -> str:
427
- """Insert the warning right below the title — not above it, so the title stays H1."""
428
- title, _, body = rendered.partition("\n")
429
- return f"{title}\n\n{HEADER}{body}"
430
-
431
-
432
- def render_timeline(timeline: dict) -> str:
433
- out = ["# Timeline\n", f"\nAs of: **{timeline['asof']}**\n\n"]
434
- out.append(gantt(timeline))
435
- out.append("\n## Per CAP\n\n")
436
- out.append(table(
437
- ["CAP", "Title", "Release", "Size", "Priority", "Owner",
438
- "Planned", "Actual", days", "State"],
439
- [[r["id"], r["text"], r["target_release"], r["size"], r["priority"], r["owner"],
440
- f"{r['planned_start'] or '—'} {r['planned_end'] or '—'}",
441
- f"{r['actual_start'] or '—'} {r['actual_end'] or '—'}",
442
- "—" if r["delta_days"] is None else f"{r['delta_days']:+d}",
443
- r["state"]] for r in timeline["capabilities"]]))
444
-
445
- children = [[r["id"], ch["id"], ch["text"], ch["stories"],
446
- f"{ch['actual_start'] or '—'} → {ch['actual_end'] or '—'}", ch["state"]]
447
- for r in timeline["capabilities"] if r["size"] == "L" for ch in r["children"]]
448
- if children:
449
- out.append("\n## FR detail for CAPs sized L\n\n")
450
- out.append("A CAP sized `L` is drawn as one summary bar; this is what is inside it.\n\n")
451
- out.append(table(["CAP", "FR", "Title", "Story", "Actual", "State"], children))
452
-
453
- if timeline["uncommitted_stories"]:
454
- out.append("\n## Stories with no git history\n\n")
455
- out.append("The file exists on disk but has never been committed, so its date "
456
- "MUST NOT be derived. Commit it first, then run again.\n\n")
457
- out.append("".join(f"- `{sid}`\n" for sid in timeline["uncommitted_stories"]))
458
- return "".join(out)
459
-
460
-
461
- def render_report(report: dict, title: str = "Report") -> str:
462
- since = report["since"] or "the project's start"
463
- edge = ("This period has no left bound — there is no earlier report yet."
464
- if not report["since"] else
465
- f"Since `{report['since_report']}` ({report['since']}).")
466
- out = [f"# {title}\n\n"]
467
- out.append(f"Period: **{since} → {report['asof']}**. {edge}\n\n")
468
- out.append(f"Freshness: commit `{(report['sha'] or '?')[:12]}`.")
469
- if report["registry_dirty"]:
470
- out.append(" **The registry has uncommitted changes — the numbers below "
471
- "may not reflect what is on `main`.**")
472
- out.append("\n\n")
473
- if report["uncommitted_stories"]:
474
- out.append("> **Warning.** The following stories have a status read from the working "
475
- "tree but have never been committed: "
476
- + ", ".join(f"`{s}`" for s in report["uncommitted_stories"])
477
- + ". They still count toward promise progress, but MUST NOT appear in the "
478
- "Proven section — there, the date must come from git. Commit them first, "
479
- "then run again.\n\n")
480
-
481
- out.append(f"## Promise progress {report['promise_progress']}\n\n")
482
- counts = report["rtm_rows"] or {}
483
- out.append(f"This is the number that counts: green RTM rows divided by counted rows "
484
- f"({counts.get('green', 0)} out of {counts.get('counted', 0)}; "
485
- f"{counts.get('excluded_no_uc', 0)} excluded for having `no_uc`). "
486
- f"It measures what is **proven**, not what has been worked on.\n\n")
487
- out.append(table(["Other measure", "Value", "Answers"], [
488
- ["Work progress", ", ".join(f"{w.get('wave')} {w.get('work_progress')}"
489
- for w in report["work_progress"]) or "n/a",
490
- "how much has been worked on"],
491
- ["Gate readiness", report["gate_readiness"], "whether the next gate can open"],
492
- ]))
493
-
494
- out.append("\n## 1. Proven\n\n")
495
- out.append("RTM rows that turned green within this period.\n\n")
496
- out.append(table(["FR", "UC", "Story", "Test", "Date"],
497
- [[p["FR"], p["UC"], p["story"], p["test"], p["closed"]]
498
- for p in report["proven"]]))
499
-
500
- out.append("\n## 2. Moved\n\n")
501
- out.append(table(["ID", "Layer", "Event", "Date"],
502
- [[m["id"], m["kind"], m["event"], m["date"]] for m in report["moved"]]))
503
-
504
- out.append("\n## 3. Late\n\n")
505
- if report["late"]:
506
- out.append("Named one by one. Summarizing it into a count is how a plan that missed "
507
- "keeps feeling comfortable.\n\n")
508
- out.append(table(["CAP", "Title", "Owner", "Planned end", "Late (days)", "Waiting on"],
509
- [[l["id"], l["text"], l["owner"], l["planned_end"],
510
- l["days_late"], l["waiting_on"]] for l in report["late"]]))
511
-
512
- out.append("\n## 4. Defects\n\n")
513
- defects = report["defects"]
514
- out.append("**Opened**\n\n")
515
- out.append(table(["ID", "Title", "Root cause", "Violates"],
516
- [[d["id"], d["title"], d["root_cause"], d["violates"]]
517
- for d in defects["opened"]]))
518
- out.append("\n**Closed, grouped by root cause**\n\n")
519
- out.append(table(["Root cause", "Defects", "Count"],
520
- [[cause, ids, len(ids)]
521
- for cause, ids in defects["closed_by_root_cause"].items()]))
522
- if defects["closed_by_root_cause"]:
523
- out.append("\nThe `requirement` and `architecture` rows are worth reading twice: "
524
- "both count defects that turned out not to be bad code.\n")
525
- if defects["undiagnosed"]:
526
- out.append("\n**Not yet diagnosed** — open with no `root_cause`, the whole period\n\n")
527
- out.append(table(["ID", "Title", "Reported", "Age (days)"],
528
- [[d["id"], d["title"], d["reported"], d["age_days"]]
529
- for d in defects["undiagnosed"]]))
530
- out.append("\nA row with no `root_cause` violates nothing it means no one has run "
531
- "`wdi-systematic-debugging` on it yet. While that holds it also does not "
532
- "count toward the ratio above, so that ratio applies only to defects that "
533
- "have already been diagnosed.\n")
534
-
535
- out.append("\n## 5. Gates\n\n")
536
- out.append(table(["Gate", "Date"], [[g["gate"], g["date"]] for g in report["gates"]]))
537
- return "".join(out)
538
-
539
-
540
- # ---------------------------------------------------------------------- publish
541
-
542
-
543
- def period_name(kind: str, asof: dt.date) -> str:
544
- if kind == "weekly":
545
- return asof.strftime("%G-W%V")
546
- if kind == "monthly":
547
- return asof.strftime("%Y-%m")
548
- return kind
549
-
550
-
551
- NOTE = """
552
- ## Notes
553
-
554
- <!-- Written by a human once, at publish time. MUST cite the cause (ADR, OQ-, risk, or
555
- defect), not retell it — a second telling will drift from the first.
556
- Leave empty if there is truly nothing to add. -->
557
- """
558
-
559
-
560
- def publish(c: Corpus, report: dict, kind: str, asof: dt.date) -> tuple[Path, str | None]:
561
- name = period_name(kind, asof)
562
- path = c.root / ".control" / "reports" / f"{name}.md"
563
- if path.exists():
564
- return path, (f"{path.name} has already been published. A published report is FROZEN — "
565
- f"if it turns out to be wrong, the next report is what corrects it.")
566
- path.parent.mkdir(parents=True, exist_ok=True)
567
- front = dump({
568
- "period": name,
569
- "asof": report["asof"],
570
- "since": report["since"],
571
- "sha": report["sha"],
572
- "promise_progress": report["promise_progress"],
573
- "generated_by": ".constitution/method/scripts/timeline.py",
574
- })
575
- path.write_text(f"---\n{front}---\n\n{render_report(report, f'Report {name}')}{NOTE}",
576
- encoding="utf-8")
577
- return path, None
578
-
579
-
580
- # -------------------------------------------------------------------------- CLI
581
-
582
-
583
- def main(argv: list[str] | None = None) -> int:
584
- parser = argparse.ArgumentParser(
585
- prog="timeline", description="the time dimension from git: timeline, report, period report")
586
- parser.add_argument("--generate", action="store_true",
587
- help="write .control/generated/timeline.* and report.*")
588
- parser.add_argument("--publish", metavar="PERIOD",
589
- help="freeze .control/reports/<period>.md weekly | monthly | <name>")
590
- parser.add_argument("--refresh", action="store_true",
591
- help="run validate --generate first so the tables are fresh")
592
- parser.add_argument("--root", default=".", help="repo root (default: current directory)")
593
- parser.add_argument("--asof", default=None,
594
- help="reference date, YYYY-MM-DD (default: today)")
595
- args = parser.parse_args(argv)
596
-
597
- if not args.generate and not args.publish:
598
- args.generate = True
599
-
600
- root = Path(args.root).resolve()
601
- if not (root / ".control" / "registry").is_dir():
602
- print(f"timeline: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
603
- return 2
604
-
605
- asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
606
- corpus = Corpus.load(root)
607
-
608
- if git(root, "rev-parse", "HEAD") is None:
609
- print("timeline: git did not respond at this root. Every delivery date is derived "
610
- "from git, so with no git there is nothing that can be reported — and making "
611
- "one up MUST NOT be done.", file=sys.stderr)
612
- return 3
613
-
614
- if args.refresh:
615
- import validate
616
- result = validate.run_checks(corpus, asof)
617
- validate.generate(corpus, result)
618
- print(f" refreshed .control/generated/ — {len(result.findings)} validator findings")
619
-
620
- generated = root / ".control" / "generated"
621
- missing = [n for n in ("rtm", "status") if not (generated / f"{n}.yaml").exists()]
622
- if missing:
623
- print(f"timeline: {', '.join(missing)} does not exist yet in .control/generated/. A "
624
- f"report on top of a stale table is worse than no report — run "
625
- f"`validate.py --generate`, or repeat with `--refresh`.", file=sys.stderr)
626
- return 3
627
-
628
- timeline = gen_timeline(corpus, asof)
629
- report = gen_report(corpus, timeline, asof)
630
-
631
- if args.generate:
632
- generated.mkdir(parents=True, exist_ok=True)
633
- for name, payload, rendered in (
634
- ("timeline", timeline, render_timeline(timeline)),
635
- ("report", report, render_report(report)),
636
- ):
637
- (generated / f"{name}.yaml").write_text(dump(payload), encoding="utf-8")
638
- (generated / f"{name}.md").write_text(with_header(rendered), encoding="utf-8")
639
- print(f" wrote .control/generated/{name}.yaml")
640
- print(f" wrote .control/generated/{name}.md")
641
-
642
- if args.publish:
643
- path, refusal = publish(corpus, report, args.publish, asof)
644
- if refusal:
645
- print(f"\ntimeline: {refusal}", file=sys.stderr)
646
- return 4
647
- print(f" published {path.relative_to(root).as_posix()}")
648
-
649
- overdue = [r for r in timeline["capabilities"] if r["state"] == "overdue"]
650
- print(f"\npromise progress: {report['promise_progress']}"
651
- f" · CAP overdue: {len(overdue)}"
652
- f" · as of: {asof.isoformat()}")
653
- for row in overdue:
654
- print(f" LATE {row['id']} — planned end {row['planned_end']}, "
655
- f"waiting on {', '.join(row['waiting_on']) or 'no story yet'}")
656
- if report["registry_dirty"]:
657
- print("\nthe registry has uncommitted changes — the numbers above may not "
658
- "reflect what is on main")
659
- if timeline["uncommitted_stories"]:
660
- print(f"stories with no git history: {', '.join(timeline['uncommitted_stories'])}")
661
- return 0
662
-
663
-
664
- if __name__ == "__main__":
665
- raise SystemExit(main())
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = ["pyyaml>=6"]
5
+ # ///
6
+ """timeline — the time dimension: generated/timeline, generated/report, .control/reports/<period>.md.
7
+
8
+ The corpus can state what is true. It cannot state WHEN that became true, because not one
9
+ delivery date is stored — and indeed MUST NOT be stored. This script supplies that missing
10
+ dimension by reading git.
11
+
12
+ timeline.py --generate write .control/generated/timeline.* and report.*
13
+ timeline.py --publish weekly freeze .control/reports/2026-W34.md
14
+ timeline.py --publish monthly freeze .control/reports/2026-08.md
15
+ timeline.py --refresh --generate run validate --generate first
16
+
17
+ The division of labor stays as in 08-project-management.md: whatever can be computed from the
18
+ registry alone belongs to validate.py; only what needs git stays here. That is why Corpus, dump,
19
+ and friends are imported, not copied — one fact MUST NOT have two homes.
20
+
21
+ A delivery date MUST NOT be written back to any registry. It is re-derived on every run; a stored
22
+ copy is a copy that will go stale.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import datetime as dt
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ import yaml
33
+
34
+ from validate import (FM, Corpus, _ticket_files, _ticket_status, cap_tickets, dump, git,
35
+ listy, load_yaml, status_in)
36
+
37
+ # Statuses that do not yet mean "in progress". A ticket still in this set has no
38
+ # actual_start, no matter how old the file is. `ready-for-agent` is the engine's own opening
39
+ # state a ticket published and not yet picked up — so it belongs here too.
40
+ NOT_STARTED = {"", "unknown", "draft", "backlog", "todo", "planned", "ready-for-agent"}
41
+
42
+ TIMELINE_COLUMNS = [
43
+ "id", "text", "size", "priority", "owner", "target_release",
44
+ "planned_start", "planned_end", "estimate_mandays",
45
+ "actual_start", "actual_end", "delta_days", "state",
46
+ ]
47
+
48
+
49
+ # ------------------------------------------------------------------ git history
50
+
51
+
52
+ _HIST: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
53
+
54
+
55
+ def history(root: Path, rel: str) -> list[tuple[str, str, str]]:
56
+ """[(sha, date, content)] in chronological order for one file. Empty if never committed."""
57
+ key = (str(root), rel)
58
+ if key in _HIST:
59
+ return _HIST[key]
60
+ out = git(root, "log", "--reverse", "--format=%H|%ad", "--date=short", "--", rel)
61
+ revs: list[tuple[str, str, str]] = []
62
+ for line in (out or "").splitlines():
63
+ sha, _, date = line.partition("|")
64
+ if not sha or not date:
65
+ continue
66
+ text = git(root, "show", f"{sha}:./{rel}")
67
+ if text is not None:
68
+ revs.append((sha, date, text))
69
+ _HIST[key] = revs
70
+ return revs
71
+
72
+
73
+ def fm_of(text: str) -> dict:
74
+ match = FM.match(text)
75
+ if not match:
76
+ return {}
77
+ data = yaml.safe_load(match.group(1))
78
+ return data if isinstance(data, dict) else {}
79
+
80
+
81
+ def yaml_of(text: str) -> dict:
82
+ try:
83
+ data = yaml.safe_load(text)
84
+ except yaml.YAMLError:
85
+ return {}
86
+ return data if isinstance(data, dict) else {}
87
+
88
+
89
+ # ------------------------------------------------------------- time derivation
90
+
91
+
92
+ def ticket_path(c: Corpus, spec: dict, ticket: dict) -> str | None:
93
+ matches = _ticket_files(c, spec, ticket)
94
+ return matches[0].relative_to(c.root).as_posix() if matches else None
95
+
96
+
97
+ def ticket_span(c: Corpus, spec: dict, ticket: dict) -> dict:
98
+ """When a ticket started being worked on and when it went `done`, read from its file history.
99
+
100
+ A file that exists on disk but has never been committed is marked `uncommitted` instead
101
+ of being given a made-up date. A finished ticket that has not been committed is a state that
102
+ MUST be visible, not one that gets patched over.
103
+ """
104
+ rel = ticket_path(c, spec, ticket)
105
+ if rel is None:
106
+ return {"start": None, "end": None, "uncommitted": False, "path": None}
107
+ revs = history(c.root, rel)
108
+ if not revs:
109
+ return {"start": None, "end": None, "uncommitted": True, "path": rel}
110
+ start = end = None
111
+ for _sha, date, text in revs:
112
+ status = status_in(text).lower()
113
+ if start is None and status not in NOT_STARTED:
114
+ start = date
115
+ if status == "done":
116
+ end = date
117
+ break
118
+ return {"start": start, "end": end, "uncommitted": False, "path": rel}
119
+
120
+
121
+ def fr_tickets(c: Corpus) -> dict[str, list[dict]]:
122
+ """FR -> ticket, through its UCs. cap_tickets()'s twin, one level down."""
123
+ ucs_of: dict[str, list[str]] = {}
124
+ for uc in c.ucs:
125
+ for fid in listy(uc, "satisfies"):
126
+ ucs_of.setdefault(fid, []).append(str(uc.get("id")))
127
+ all_tickets = [t for _, t in c.tickets()]
128
+ out: dict[str, list[dict]] = {}
129
+ for fr in c.frs:
130
+ fid = str(fr.get("id"))
131
+ wanted = set(ucs_of.get(fid, []))
132
+ out[fid] = [t for t in all_tickets if wanted & set(listy(t, "satisfies"))]
133
+ return out
134
+
135
+
136
+ def span_of(c: Corpus, items: list[dict], spans: dict[str, dict]) -> tuple[str | None, str | None, bool]:
137
+ """(earliest start, latest end, closed). Closed only when ALL are finished."""
138
+ if not items:
139
+ return None, None, False
140
+ starts = [spans[str(t.get("id"))]["start"] for t in items]
141
+ ends = [spans[str(t.get("id"))]["end"] for t in items]
142
+ closed = all(e for e in ends)
143
+ return (min([x for x in starts if x], default=None),
144
+ max([x for x in ends if x], default=None) if closed else None,
145
+ closed)
146
+
147
+
148
+ def days_between(a: str | None, b: str | None) -> int | None:
149
+ if not a or not b:
150
+ return None
151
+ try:
152
+ return (dt.date.fromisoformat(a) - dt.date.fromisoformat(b)).days
153
+ except ValueError:
154
+ return None
155
+
156
+
157
+ def state_of(planned_end: str, actual_start: str | None, closed: bool, asof: dt.date) -> str:
158
+ if closed:
159
+ return "done"
160
+ if planned_end:
161
+ try:
162
+ if dt.date.fromisoformat(planned_end) < asof:
163
+ return "overdue"
164
+ except ValueError:
165
+ pass
166
+ return "in-progress" if actual_start else "not-started"
167
+
168
+
169
+ # ---------------------------------------------------------------- generated/timeline
170
+
171
+
172
+ def gen_timeline(c: Corpus, asof: dt.date) -> dict:
173
+ spans = {str(t.get("id")): ticket_span(c, sp, t) for sp, t in c.tickets()}
174
+ spec_of = {str(t.get("id")): sp for sp, t in c.tickets()}
175
+ by_cap = cap_tickets(c)
176
+ by_fr = fr_tickets(c)
177
+ fr_of_cap: dict[str, list[dict]] = {}
178
+ for fr in c.frs:
179
+ fr_of_cap.setdefault(str(fr.get("capability", "")), []).append(fr)
180
+
181
+ out = []
182
+ for cap in c.caps:
183
+ cid = str(cap.get("id"))
184
+ items = by_cap.get(cid, [])
185
+ start, end, closed = span_of(c, items, spans)
186
+ planned_end = str(cap.get("planned_end") or "")
187
+ row = {
188
+ "id": cid,
189
+ "text": str(cap.get("title") or cap.get("text") or ""),
190
+ "size": str(cap.get("size") or ""),
191
+ "priority": str(cap.get("priority") or ""),
192
+ "owner": str(cap.get("owner") or ""),
193
+ "target_release": str(cap.get("target_release") or ""),
194
+ "planned_start": str(cap.get("planned_start") or ""),
195
+ "planned_end": planned_end,
196
+ "estimate_mandays": cap.get("estimate_mandays", 0),
197
+ "actual_start": start or "",
198
+ "actual_end": end or "",
199
+ "delta_days": days_between(end, planned_end),
200
+ "state": state_of(planned_end, start, closed, asof),
201
+ }
202
+ children = []
203
+ for fr in sorted(fr_of_cap.get(cid, []), key=lambda x: str(x.get("id"))):
204
+ fid = str(fr.get("id"))
205
+ f_items = by_fr.get(fid, [])
206
+ f_start, f_end, f_closed = span_of(c, f_items, spans)
207
+ children.append({
208
+ "id": fid,
209
+ "text": str(fr.get("title") or fr.get("text") or ""),
210
+ "tickets": sorted(str(t.get("id")) for t in f_items),
211
+ "actual_start": f_start or "",
212
+ "actual_end": f_end or "",
213
+ "state": state_of("", f_start, f_closed, asof),
214
+ })
215
+ row["children"] = children
216
+ row["waiting_on"] = sorted(
217
+ str(t.get("id")) for t in items
218
+ if _ticket_status(c, spec_of.get(str(t.get("id")), {}), t) != "done"
219
+ ) if not closed else []
220
+ out.append(row)
221
+
222
+ stray = sorted(sid for sid, span in spans.items() if span["uncommitted"])
223
+ return {"asof": asof.isoformat(), "capabilities": out, "uncommitted_tickets": stray}
224
+
225
+
226
+ # ------------------------------------------------------------------ generated/report
227
+
228
+
229
+ def last_report(c: Corpus, asof: dt.date) -> tuple[str | None, str | None]:
230
+ """(reference date of the last report, its name). Both None if no report exists yet."""
231
+ best: tuple[str, str] | None = None
232
+ for path in sorted((c.root / ".control/reports").glob("*.md")):
233
+ text = path.read_text(encoding="utf-8", errors="replace")
234
+ fm = fm_of(text)
235
+ stamp = str(fm.get("asof") or "")
236
+ if not stamp or stamp >= asof.isoformat():
237
+ continue
238
+ if best is None or stamp > best[0]:
239
+ best = (stamp, path.stem)
240
+ return best if best else (None, None)
241
+
242
+
243
+ def in_period(date: str | None, since: str | None, asof: dt.date) -> bool:
244
+ if not date:
245
+ return False
246
+ if date > asof.isoformat():
247
+ return False
248
+ return True if since is None else date > since
249
+
250
+
251
+ def first_seen(c: Corpus, rel: str, key: str, pick) -> dict[str, str]:
252
+ """id -> date when `pick` first became true in a registry's history."""
253
+ seen: dict[str, str] = {}
254
+ for _sha, date, text in history(c.root, rel):
255
+ data = yaml_of(text)
256
+ for item in data.get(key) or []:
257
+ ident = pick(item)
258
+ if ident:
259
+ seen.setdefault(str(ident), date)
260
+ return seen
261
+
262
+
263
+ def gen_report(c: Corpus, timeline: dict, asof: dt.date) -> dict:
264
+ since, since_name = last_report(c, asof)
265
+ rtm = (load_yaml(c.root / ".control/generated/rtm.yaml").get("rtm") or [])
266
+ status = load_yaml(c.root / ".control/generated/status.yaml")
267
+ spans = {str(t.get("id")): ticket_span(c, sp, t) for sp, t in c.tickets()}
268
+
269
+ proven = []
270
+ for row in rtm:
271
+ sid = str(row.get("ticket") or "")
272
+ end = spans.get(sid, {}).get("end")
273
+ if row.get("green") and in_period(end, since, asof):
274
+ proven.append({"FR": row.get("FR"), "UC": row.get("UC"), "ticket": sid,
275
+ "test": row.get("test"), "closed": end})
276
+ proven.sort(key=lambda x: (str(x["closed"]), str(x["ticket"])))
277
+
278
+ moved = []
279
+ for row in timeline["capabilities"]:
280
+ for field, event in (("actual_start", "started"), ("actual_end", "closed")):
281
+ when = row.get(field) or None
282
+ if in_period(when, since, asof):
283
+ moved.append({"id": row["id"], "kind": "CAP", "event": event, "date": when})
284
+ for child in row["children"]:
285
+ for field, event in (("actual_start", "started"), ("actual_end", "closed")):
286
+ when = child.get(field) or None
287
+ if in_period(when, since, asof):
288
+ moved.append({"id": child["id"], "kind": "FR", "event": event, "date": when})
289
+ moved.sort(key=lambda x: (x["date"], x["id"], x["event"]))
290
+
291
+ late = []
292
+ for row in timeline["capabilities"]:
293
+ if row["state"] != "overdue":
294
+ continue
295
+ overdue_by = days_between(asof.isoformat(), row["planned_end"])
296
+ late.append({"id": row["id"], "text": row["text"], "owner": row["owner"],
297
+ "planned_end": row["planned_end"], "days_late": overdue_by,
298
+ "waiting_on": row["waiting_on"] or ["no ticket yet"]})
299
+ late.sort(key=lambda x: (-(x["days_late"] or 0), x["id"]))
300
+
301
+ closures = first_seen(c, ".control/registry/defects.yaml", "defects",
302
+ lambda d: d.get("id") if str(d.get("status")) == "fixed" else None)
303
+ opened, closed_rows = [], []
304
+ for defect in c.defect_list:
305
+ did = str(defect.get("id"))
306
+ entry = {"id": did, "title": str(defect.get("title") or ""),
307
+ "root_cause": str(defect.get("root_cause") or ""),
308
+ "violates": listy(defect, "violates")}
309
+ if in_period(str(defect.get("reported") or ""), since, asof):
310
+ opened.append(entry)
311
+ when = closures.get(did)
312
+ if in_period(when, since, asof):
313
+ closed_rows.append({**entry, "closed": when})
314
+
315
+ by_cause: dict[str, list[str]] = {}
316
+ for row in closed_rows:
317
+ by_cause.setdefault(row["root_cause"] or "?", []).append(row["id"])
318
+
319
+ # An empty `root_cause` is a valid state — the row was opened by someone who has not yet
320
+ # diagnosed it, and `defect-root-cause` does skip it. What MUST NOT happen is it aging unseen, so it is
321
+ # surfaced in the report instead of being held by a validator. The whole period, not just this one.
322
+ undiagnosed = sorted(
323
+ ({"id": str(d.get("id")), "title": str(d.get("title") or ""),
324
+ "reported": str(d.get("reported") or ""),
325
+ "age_days": days_between(asof.isoformat(), str(d.get("reported") or ""))}
326
+ for d in c.defect_list
327
+ if not str(d.get("root_cause") or "").strip() and str(d.get("status")) != "fixed"),
328
+ key=lambda x: (-(x["age_days"] or 0), x["id"]))
329
+
330
+ gates = [{"gate": gid, "date": when} for gid, when in sorted(
331
+ first_seen(c, ".control/registry/index.yaml", "gates_passed", lambda g: g).items())
332
+ if in_period(when, since, asof)]
333
+
334
+ head = git(c.root, "rev-parse", "HEAD") or ""
335
+ dirty = bool(git(c.root, "status", "--porcelain", "--", ".control/registry"))
336
+
337
+ return {
338
+ "asof": asof.isoformat(),
339
+ "since": since or "",
340
+ "since_report": since_name or "",
341
+ "sha": head,
342
+ "registry_dirty": dirty,
343
+ # A done ticket that has not been committed still counts in the RTM — its status is read
344
+ # from the working tree — but it will never appear under "Proven", which needs a date from
345
+ # git. That gap MUST be visible in the report, not only in the timeline.
346
+ "uncommitted_tickets": timeline["uncommitted_tickets"],
347
+ "promise_progress": status.get("promise_progress", "n/a"),
348
+ "rtm_rows": status.get("rtm_rows", {}),
349
+ "work_progress": status.get("work_progress", []),
350
+ "gate_readiness": status.get("gate_readiness", "n/a"),
351
+ "proven": proven,
352
+ "moved": moved,
353
+ "late": late,
354
+ "defects": {"opened": sorted(opened, key=lambda x: x["id"]),
355
+ "closed": sorted(closed_rows, key=lambda x: x["id"]),
356
+ "closed_by_root_cause": {k: sorted(v) for k, v in sorted(by_cause.items())},
357
+ "undiagnosed": undiagnosed},
358
+ "gates": gates,
359
+ }
360
+
361
+
362
+ # -------------------------------------------------------------------- rendering
363
+
364
+
365
+ def cell(value: object) -> str:
366
+ text = ", ".join(str(v) for v in value) if isinstance(value, list) else str(value or "")
367
+ return text.replace("|", "\\|").replace("\n", " ").strip() or "—"
368
+
369
+
370
+ def table(headers: list[str], lines: list[list[object]]) -> str:
371
+ if not lines:
372
+ return "_None._\n"
373
+ out = ["| " + " | ".join(headers) + " |",
374
+ "|" + "|".join("---" for _ in headers) + "|"]
375
+ out += ["| " + " | ".join(cell(v) for v in line) + " |" for line in lines]
376
+ return "\n".join(out) + "\n"
377
+
378
+
379
+ def safe(text: str, width: int = 44) -> str:
380
+ """Text safe to put into mermaid: without the `:` and `,` that break its syntax."""
381
+ clean = str(text or "").replace(":", " ").replace(",", " ").replace("#", "").strip()
382
+ return (clean[:width].rstrip() + "…") if len(clean) > width else (clean or "untitled")
383
+
384
+
385
+ def gantt(timeline: dict) -> str:
386
+ """Mermaid Gantt: planned and actual side by side, so the gap is visible."""
387
+ lines = ["```mermaid", "gantt", " dateFormat YYYY-MM-DD", " axisFormat %d %b",
388
+ " title Planned vs actual per CAP", ""]
389
+ by_release: dict[str, list[dict]] = {}
390
+ for row in timeline["capabilities"]:
391
+ by_release.setdefault(row["target_release"] or "no release", []).append(row)
392
+ drawn = 0
393
+ for release in sorted(by_release):
394
+ rows_ = by_release[release]
395
+ drawable = [r for r in rows_ if (r["planned_start"] and r["planned_end"])
396
+ or (r["actual_start"] and r["actual_end"])]
397
+ if not drawable:
398
+ continue
399
+ lines.append(f" section {safe(release, 24)}")
400
+ for row in drawable:
401
+ slug = row["id"].replace("-", "").lower()
402
+ label = f"{row['id']} {safe(row['text'])}"
403
+ if row["planned_start"] and row["planned_end"]:
404
+ mark = "crit, " if row["state"] == "overdue" else ""
405
+ lines.append(f" {label} planned :{mark}{slug}p, "
406
+ f"{row['planned_start']}, {row['planned_end']}")
407
+ if row["actual_start"]:
408
+ end = row["actual_end"] or timeline["asof"]
409
+ mark = "done, " if row["state"] == "done" else "active, "
410
+ lines.append(f" {label} actual :{mark}{slug}a, {row['actual_start']}, {end}")
411
+ drawn += 1
412
+ lines.append("")
413
+ lines.append("```")
414
+ if drawn == 0:
415
+ return ("_No CAP has a planned or actual date yet — "
416
+ "the gantt is not drawn._\n")
417
+ return "\n".join(lines) + "\n"
418
+
419
+
420
+ HEADER = ("> Generated by `.constitution/method/scripts/timeline.py --generate`. "
421
+ "MUST NOT be hand-edited.\n")
422
+
423
+
424
+ def with_header(rendered: str) -> str:
425
+ """Insert the warning right below the title — not above it, so the title stays H1."""
426
+ title, _, body = rendered.partition("\n")
427
+ return f"{title}\n\n{HEADER}{body}"
428
+
429
+
430
+ def render_timeline(timeline: dict) -> str:
431
+ out = ["# Timeline\n", f"\nAs of: **{timeline['asof']}**\n\n"]
432
+ out.append(gantt(timeline))
433
+ out.append("\n## Per CAP\n\n")
434
+ out.append(table(
435
+ ["CAP", "Title", "Release", "Size", "Priority", "Owner",
436
+ "Planned", "Actual", "Δ days", "State"],
437
+ [[r["id"], r["text"], r["target_release"], r["size"], r["priority"], r["owner"],
438
+ f"{r['planned_start'] or '—'} {r['planned_end'] or '—'}",
439
+ f"{r['actual_start'] or '—'} → {r['actual_end'] or '—'}",
440
+ "—" if r["delta_days"] is None else f"{r['delta_days']:+d}",
441
+ r["state"]] for r in timeline["capabilities"]]))
442
+
443
+ children = [[r["id"], ch["id"], ch["text"], ch["tickets"],
444
+ f"{ch['actual_start'] or '—'} → {ch['actual_end'] or '—'}", ch["state"]]
445
+ for r in timeline["capabilities"] if r["size"] == "L" for ch in r["children"]]
446
+ if children:
447
+ out.append("\n## FR detail for CAPs sized L\n\n")
448
+ out.append("A CAP sized `L` is drawn as one summary bar; this is what is inside it.\n\n")
449
+ out.append(table(["CAP", "FR", "Title", "Ticket", "Actual", "State"], children))
450
+
451
+ if timeline["uncommitted_tickets"]:
452
+ out.append("\n## Tickets with no git history\n\n")
453
+ out.append("The file exists on disk but has never been committed, so its date "
454
+ "MUST NOT be derived. Commit it first, then run again.\n\n")
455
+ out.append("".join(f"- `{sid}`\n" for sid in timeline["uncommitted_tickets"]))
456
+ return "".join(out)
457
+
458
+
459
+ def render_report(report: dict, title: str = "Report") -> str:
460
+ since = report["since"] or "the project's start"
461
+ edge = ("This period has no left bound there is no earlier report yet."
462
+ if not report["since"] else
463
+ f"Since `{report['since_report']}` ({report['since']}).")
464
+ out = [f"# {title}\n\n"]
465
+ out.append(f"Period: **{since} {report['asof']}**. {edge}\n\n")
466
+ out.append(f"Freshness: commit `{(report['sha'] or '?')[:12]}`.")
467
+ if report["registry_dirty"]:
468
+ out.append(" **The registry has uncommitted changes — the numbers below "
469
+ "may not reflect what is on `main`.**")
470
+ out.append("\n\n")
471
+ if report["uncommitted_tickets"]:
472
+ out.append("> **Warning.** The following tickets have a status read from the working "
473
+ "tree but have never been committed: "
474
+ + ", ".join(f"`{s}`" for s in report["uncommitted_tickets"])
475
+ + ". They still count toward promise progress, but MUST NOT appear in the "
476
+ "Proven section — there, the date must come from git. Commit them first, "
477
+ "then run again.\n\n")
478
+
479
+ out.append(f"## Promise progress — {report['promise_progress']}\n\n")
480
+ counts = report["rtm_rows"] or {}
481
+ out.append(f"This is the number that counts: green RTM rows divided by counted rows "
482
+ f"({counts.get('green', 0)} out of {counts.get('counted', 0)}; "
483
+ f"{counts.get('excluded_no_uc', 0)} excluded for having `no_uc`). "
484
+ f"It measures what is **proven**, not what has been worked on.\n\n")
485
+ out.append(table(["Other measure", "Value", "Answers"], [
486
+ ["Work progress", ", ".join(f"{w.get('spec')} {w.get('work_progress')}"
487
+ for w in report["work_progress"]) or "n/a",
488
+ "how much has been worked on"],
489
+ ["Gate readiness", report["gate_readiness"], "whether the next gate can open"],
490
+ ]))
491
+
492
+ out.append("\n## 1. Proven\n\n")
493
+ out.append("RTM rows that turned green within this period.\n\n")
494
+ out.append(table(["FR", "UC", "Ticket", "Test", "Date"],
495
+ [[p["FR"], p["UC"], p["ticket"], p["test"], p["closed"]]
496
+ for p in report["proven"]]))
497
+
498
+ out.append("\n## 2. Moved\n\n")
499
+ out.append(table(["ID", "Layer", "Event", "Date"],
500
+ [[m["id"], m["kind"], m["event"], m["date"]] for m in report["moved"]]))
501
+
502
+ out.append("\n## 3. Late\n\n")
503
+ if report["late"]:
504
+ out.append("Named one by one. Summarizing it into a count is how a plan that missed "
505
+ "keeps feeling comfortable.\n\n")
506
+ out.append(table(["CAP", "Title", "Owner", "Planned end", "Late (days)", "Waiting on"],
507
+ [[l["id"], l["text"], l["owner"], l["planned_end"],
508
+ l["days_late"], l["waiting_on"]] for l in report["late"]]))
509
+
510
+ out.append("\n## 4. Defects\n\n")
511
+ defects = report["defects"]
512
+ out.append("**Opened**\n\n")
513
+ out.append(table(["ID", "Title", "Root cause", "Violates"],
514
+ [[d["id"], d["title"], d["root_cause"], d["violates"]]
515
+ for d in defects["opened"]]))
516
+ out.append("\n**Closed, grouped by root cause**\n\n")
517
+ out.append(table(["Root cause", "Defects", "Count"],
518
+ [[cause, ids, len(ids)]
519
+ for cause, ids in defects["closed_by_root_cause"].items()]))
520
+ if defects["closed_by_root_cause"]:
521
+ out.append("\nThe `requirement` and `architecture` rows are worth reading twice: "
522
+ "both count defects that turned out not to be bad code.\n")
523
+ if defects["undiagnosed"]:
524
+ out.append("\n**Not yet diagnosed** open with no `root_cause`, the whole period\n\n")
525
+ out.append(table(["ID", "Title", "Reported", "Age (days)"],
526
+ [[d["id"], d["title"], d["reported"], d["age_days"]]
527
+ for d in defects["undiagnosed"]]))
528
+ out.append("\nA row with no `root_cause` violates nothing — it means no one has run "
529
+ "`wdi-systematic-debugging` on it yet. While that holds it also does not "
530
+ "count toward the ratio above, so that ratio applies only to defects that "
531
+ "have already been diagnosed.\n")
532
+
533
+ out.append("\n## 5. Gates\n\n")
534
+ out.append(table(["Gate", "Date"], [[g["gate"], g["date"]] for g in report["gates"]]))
535
+ return "".join(out)
536
+
537
+
538
+ # ---------------------------------------------------------------------- publish
539
+
540
+
541
+ def period_name(kind: str, asof: dt.date) -> str:
542
+ if kind == "weekly":
543
+ return asof.strftime("%G-W%V")
544
+ if kind == "monthly":
545
+ return asof.strftime("%Y-%m")
546
+ return kind
547
+
548
+
549
+ NOTE = """
550
+ ## Notes
551
+
552
+ <!-- Written by a human once, at publish time. MUST cite the cause (ADR, OQ-, risk, or
553
+ defect), not retell it — a second telling will drift from the first.
554
+ Leave empty if there is truly nothing to add. -->
555
+ """
556
+
557
+
558
+ def publish(c: Corpus, report: dict, kind: str, asof: dt.date) -> tuple[Path, str | None]:
559
+ name = period_name(kind, asof)
560
+ path = c.root / ".control" / "reports" / f"{name}.md"
561
+ if path.exists():
562
+ return path, (f"{path.name} has already been published. A published report is FROZEN — "
563
+ f"if it turns out to be wrong, the next report is what corrects it.")
564
+ path.parent.mkdir(parents=True, exist_ok=True)
565
+ front = dump({
566
+ "period": name,
567
+ "asof": report["asof"],
568
+ "since": report["since"],
569
+ "sha": report["sha"],
570
+ "promise_progress": report["promise_progress"],
571
+ "generated_by": ".constitution/method/scripts/timeline.py",
572
+ })
573
+ path.write_text(f"---\n{front}---\n\n{render_report(report, f'Report {name}')}{NOTE}",
574
+ encoding="utf-8")
575
+ return path, None
576
+
577
+
578
+ # -------------------------------------------------------------------------- CLI
579
+
580
+
581
+ def main(argv: list[str] | None = None) -> int:
582
+ parser = argparse.ArgumentParser(
583
+ prog="timeline", description="the time dimension from git: timeline, report, period report")
584
+ parser.add_argument("--generate", action="store_true",
585
+ help="write .control/generated/timeline.* and report.*")
586
+ parser.add_argument("--publish", metavar="PERIOD",
587
+ help="freeze .control/reports/<period>.md weekly | monthly | <name>")
588
+ parser.add_argument("--refresh", action="store_true",
589
+ help="run validate --generate first so the tables are fresh")
590
+ parser.add_argument("--root", default=".", help="repo root (default: current directory)")
591
+ parser.add_argument("--asof", default=None,
592
+ help="reference date, YYYY-MM-DD (default: today)")
593
+ args = parser.parse_args(argv)
594
+
595
+ if not args.generate and not args.publish:
596
+ args.generate = True
597
+
598
+ root = Path(args.root).resolve()
599
+ if not (root / ".control" / "registry").is_dir():
600
+ print(f"timeline: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
601
+ return 2
602
+
603
+ asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
604
+ corpus = Corpus.load(root)
605
+
606
+ if git(root, "rev-parse", "HEAD") is None:
607
+ print("timeline: git did not respond at this root. Every delivery date is derived "
608
+ "from git, so with no git there is nothing that can be reported — and making "
609
+ "one up MUST NOT be done.", file=sys.stderr)
610
+ return 3
611
+
612
+ if args.refresh:
613
+ import validate
614
+ result = validate.run_checks(corpus, asof)
615
+ validate.generate(corpus, result)
616
+ print(f" refreshed .control/generated/ — {len(result.findings)} validator findings")
617
+
618
+ generated = root / ".control" / "generated"
619
+ missing = [n for n in ("rtm", "status") if not (generated / f"{n}.yaml").exists()]
620
+ if missing:
621
+ print(f"timeline: {', '.join(missing)} does not exist yet in .control/generated/. A "
622
+ f"report on top of a stale table is worse than no report — run "
623
+ f"`validate.py --generate`, or repeat with `--refresh`.", file=sys.stderr)
624
+ return 3
625
+
626
+ timeline = gen_timeline(corpus, asof)
627
+ report = gen_report(corpus, timeline, asof)
628
+
629
+ if args.generate:
630
+ generated.mkdir(parents=True, exist_ok=True)
631
+ for name, payload, rendered in (
632
+ ("timeline", timeline, render_timeline(timeline)),
633
+ ("report", report, render_report(report)),
634
+ ):
635
+ (generated / f"{name}.yaml").write_text(dump(payload), encoding="utf-8")
636
+ (generated / f"{name}.md").write_text(with_header(rendered), encoding="utf-8")
637
+ print(f" wrote .control/generated/{name}.yaml")
638
+ print(f" wrote .control/generated/{name}.md")
639
+
640
+ if args.publish:
641
+ path, refusal = publish(corpus, report, args.publish, asof)
642
+ if refusal:
643
+ print(f"\ntimeline: {refusal}", file=sys.stderr)
644
+ return 4
645
+ print(f" published {path.relative_to(root).as_posix()}")
646
+
647
+ overdue = [r for r in timeline["capabilities"] if r["state"] == "overdue"]
648
+ print(f"\npromise progress: {report['promise_progress']}"
649
+ f" · CAP overdue: {len(overdue)}"
650
+ f" · as of: {asof.isoformat()}")
651
+ for row in overdue:
652
+ print(f" LATE {row['id']} planned end {row['planned_end']}, "
653
+ f"waiting on {', '.join(row['waiting_on']) or 'no ticket yet'}")
654
+ if report["registry_dirty"]:
655
+ print("\nthe registry has uncommitted changes the numbers above may not "
656
+ "reflect what is on main")
657
+ if timeline["uncommitted_tickets"]:
658
+ print(f"tickets with no git history: {', '.join(timeline['uncommitted_tickets'])}")
659
+ return 0
660
+
661
+
662
+ if __name__ == "__main__":
663
+ raise SystemExit(main())