wdi-method 0.4.3 → 0.4.6

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 (37) hide show
  1. package/README.md +252 -222
  2. package/bin/wdi-method.js +1030 -1029
  3. package/kit/.constitution/document/delivery-flow-guide.md +1 -1
  4. package/kit/.constitution/document/templates/cross-cutting.md +4 -4
  5. package/kit/.constitution/document/templates/model.md +2 -2
  6. package/kit/.constitution/document/templates/questions.md +10 -9
  7. package/kit/.constitution/document/templates/srs.md +2 -2
  8. package/kit/.constitution/scripts/inventory.py +102 -100
  9. package/kit/.constitution/scripts/timeline.py +665 -665
  10. package/kit/.constitution/scripts/validate.py +314 -312
  11. package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +15 -15
  12. package/kit/assets/bmad-custom/bmad-architecture.toml +17 -15
  13. package/kit/assets/bmad-custom/bmad-build-auto.toml +5 -5
  14. package/kit/assets/bmad-custom/bmad-build.toml +52 -52
  15. package/kit/assets/bmad-custom/bmad-code-review.toml +6 -5
  16. package/kit/assets/bmad-custom/bmad-correct-course.toml +28 -27
  17. package/kit/assets/bmad-custom/bmad-deep-recon.toml +12 -11
  18. package/kit/assets/bmad-custom/bmad-prd.toml +22 -22
  19. package/kit/assets/bmad-custom/bmad-product-brief.toml +34 -34
  20. package/kit/assets/bmad-custom/bmad-retrospective.toml +9 -9
  21. package/kit/assets/bmad-custom/bmad-spec.toml +9 -8
  22. package/kit/assets/bmad-custom/bmad-ux.toml +7 -7
  23. package/kit/assets/bmad-custom/config.toml +3 -3
  24. package/kit/skills/wdi-report/SKILL.md +5 -5
  25. package/package.json +2 -2
  26. package/scaffold/.control/product-glossary.md +21 -21
  27. package/scaffold/.control/project-non-technical-log.md +23 -23
  28. package/scaffold/.control/questions/answered.md +11 -11
  29. package/scaffold/.control/questions/assumptions.md +15 -15
  30. package/scaffold/.control/questions/blocking.md +21 -21
  31. package/scaffold/.control/questions/external.md +11 -11
  32. package/scaffold/.control/registry/components.yaml +21 -21
  33. package/scaffold/.control/registry/defects.yaml +3 -3
  34. package/scaffold/.control/registry/index.yaml +46 -46
  35. package/scaffold/.control/registry/requirements.yaml +15 -15
  36. package/scaffold/.control/registry/risks.yaml +5 -5
  37. package/scaffold/.control/registry/usecases.yaml +6 -6
@@ -1,665 +1,665 @@
1
- #!/usr/bin/env -S uv run --script
2
- # /// script
3
- # requires-python = ">=3.11"
4
- # dependencies = ["pyyaml>=6"]
5
- # ///
6
- """timeline — dimensi waktu: generated/timeline, generated/report, .control/reports/<periode>.md.
7
-
8
- Korpus bisa menyatakan apa yang benar. Ia tidak bisa menyatakan KAPAN itu jadi benar, karena
9
- tidak ada satu pun tanggal realisasi yang disimpan dan memang MUST NOT disimpan. Skrip ini
10
- memasok dimensi yang hilang itu dengan membaca git.
11
-
12
- timeline.py --generate tulis .control/generated/timeline.* dan report.*
13
- timeline.py --publish weekly bekukan .control/reports/2026-W34.md
14
- timeline.py --publish monthly bekukan .control/reports/2026-08.md
15
- timeline.py --refresh --generate jalankan validate --generate lebih dulu
16
-
17
- Pembagian kerjanya tetap seperti di 08-project-management.md: yang bisa dihitung dari registry
18
- saja milik validate.py; hanya yang butuh git yang tinggal di sini. Karena itu Corpus, dump, dan
19
- kawan-kawannya diimpor, bukan disalinsatu fakta MUST NOT punya dua rumah.
20
-
21
- Tanggal realisasi MUST NOT ditulis balik ke registry mana pun. Ia diturunkan tiap run; salinan
22
- yang disimpan adalah salinan yang akan salah.
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
- # Status frontmatter yang belum berarti "dikerjakan". Story yang masih di sini belum punya
38
- # actual_start, betapapun tuanya berkasnya.
39
- BELUM_MULAI = {"", "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
- # ------------------------------------------------------------------ riwayat git
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, tanggal, isi)] kronologis untuk satu berkas. Kosong bila belum pernah dicommit."""
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
- # ------------------------------------------------------------- penurunan waktu
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
- """Kapan story mulai dikerjakan dan kapan ia `done`, dibaca dari riwayat berkasnya.
103
-
104
- Berkas yang ada di disk tetapi belum pernah dicommit ditandai `uncommitted` alih-alih
105
- diberi tanggal karangan. Story selesai yang belum dicommit adalah keadaan yang MUST
106
- terlihat, bukan yang ditambal.
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 BELUM_MULAI:
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, lewat UC-nya. Kembaran cap_stories() satu tingkat di bawahnya."""
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
- """(mulai paling awal, selesai paling akhir, tertutup). Tertutup hanya bila SEMUA selesai."""
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
- """(tanggal acuan laporan terakhir, namanya). Keduanya None bila belum ada laporan."""
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 -> tanggal saat `pick` pertama kali benar di riwayat sebuah registry."""
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", "mulai"), ("actual_end", "tertutup")):
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", "mulai"), ("actual_end", "tertutup")):
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 ["belum ada story"]})
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
- # `root_cause` kosong adalah keadaan sahbaris itu dibuka orang yang belum mendiagnosisnya,
322
- # dan V20 memang melewatinya. Yang MUST NOT terjadi adalah ia menua tanpa terlihat, jadi ia
323
- # disorot di laporan alih-alih ditahan validator. Seluruh periode, bukan cuma yang ini.
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
- # Story done yang belum dicommit tetap dihitung RTM — statusnya dibaca dari working tree —
346
- # tetapi tidak akan pernah muncul di "Terbukti", yang butuh tanggal dari git. Selisih itu
347
- # MUST terlihat di laporan, bukan cuma di timeline.
348
- "uncommitted_stories": timeline["uncommitted_stories"],
349
- "progres_janji": status.get("progres_janji", "n/a"),
350
- "baris_rtm": status.get("baris_rtm", {}),
351
- "progres_kerja": status.get("progres_kerja", []),
352
- "kesiapan_gate": status.get("kesiapan_gate", "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 "_Tidak ada._\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
- """Teks yang aman masuk mermaid: tanpa `:` dan `,` yang memotong sintaksnya."""
383
- clean = str(text or "").replace(":", " ").replace(",", " ").replace("#", "").strip()
384
- return (clean[:width].rstrip() + "…") if len(clean) > width else (clean or "tanpa judul")
385
-
386
-
387
- def gantt(timeline: dict) -> str:
388
- """Gantt mermaid: rencana dan realisasi berdampingan, supaya selisihnya terlihat."""
389
- lines = ["```mermaid", "gantt", " dateFormat YYYY-MM-DD", " axisFormat %d %b",
390
- " title Rencana vs realisasi per CAP", ""]
391
- by_release: dict[str, list[dict]] = {}
392
- for row in timeline["capabilities"]:
393
- by_release.setdefault(row["target_release"] or "tanpa rilis", []).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} rencana :{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} realisasi :{mark}{slug}a, {row['actual_start']}, {end}")
413
- drawn += 1
414
- lines.append("")
415
- lines.append("```")
416
- if drawn == 0:
417
- return ("_Belum ada CAP yang punya tanggal rencana maupun realisasi — "
418
- "gantt tidak digambar._\n")
419
- return "\n".join(lines) + "\n"
420
-
421
-
422
- HEADER = ("> Tergenerate oleh `.constitution/scripts/timeline.py --generate`. "
423
- "MUST NOT diedit tangan.\n")
424
-
425
-
426
- def with_header(rendered: str) -> str:
427
- """Sisipkan peringatan tepat di bawah judulbukan di atasnya, supaya judul tetap 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"\nAcuan: **{timeline['asof']}**\n\n"]
434
- out.append(gantt(timeline))
435
- out.append("\n## Per CAP\n\n")
436
- out.append(table(
437
- ["CAP", "Judul", "Rilis", "Ukuran", "Prioritas", "Pemilik",
438
- "Rencana", "Realisasi", "Δ hari", "Keadaan"],
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## Rincian FR untuk CAP berukuran L\n\n")
450
- out.append("CAP berukuran `L` digambar sebagai batang ringkasan; ini isinya.\n\n")
451
- out.append(table(["CAP", "FR", "Judul", "Story", "Realisasi", "Keadaan"], children))
452
-
453
- if timeline["uncommitted_stories"]:
454
- out.append("\n## Story tanpa riwayat git\n\n")
455
- out.append("Berkasnya ada di disk tetapi belum pernah dicommit, jadi tanggalnya "
456
- "MUST NOT diturunkan. Commit dulu, lalu jalankan ulang.\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 "awal proyek"
463
- edge = ("Periode ini tidak berbatas di kiribelum ada laporan sebelumnya."
464
- if not report["since"] else
465
- f"Sejak `{report['since_report']}` ({report['since']}).")
466
- out = [f"# {title}\n\n"]
467
- out.append(f"Periode: **{since} → {report['asof']}**. {edge}\n\n")
468
- out.append(f"Kesegaran: commit `{(report['sha'] or '?')[:12]}`.")
469
- if report["registry_dirty"]:
470
- out.append(" **Registry punya perubahan yang belum dicommit angka di bawah "
471
- "belum tentu menggambarkan apa yang ada di `main`.**")
472
- out.append("\n\n")
473
- if report["uncommitted_stories"]:
474
- out.append("> **Peringatan.** Story berikut berstatus terbaca dari working tree tetapi "
475
- "belum pernah dicommit: "
476
- + ", ".join(f"`{s}`" for s in report["uncommitted_stories"])
477
- + ". Ia ikut menghitung progres janji, tetapi MUST NOT muncul di bagian "
478
- "Terbuktidi sana tanggalnya harus datang dari git. Commit dulu, lalu "
479
- "jalankan ulang.\n\n")
480
-
481
- out.append(f"## Progres janji — {report['progres_janji']}\n\n")
482
- counts = report["baris_rtm"] or {}
483
- out.append(f"Ini angka yang berlaku: baris RTM hijau dibagi baris yang dihitung "
484
- f"({counts.get('hijau', 0)} dari {counts.get('dihitung', 0)}; "
485
- f"{counts.get('dikecualikan_no_uc', 0)} dikecualikan karena ber-`no_uc`). "
486
- f"Ia mengukur yang **terbukti**, bukan yang dikerjakan.\n\n")
487
- out.append(table(["Ukuran lain", "Nilai", "Menjawab"], [
488
- ["Progres kerja", ", ".join(f"{w.get('wave')} {w.get('progres_kerja')}"
489
- for w in report["progres_kerja"]) or "n/a",
490
- "berapa banyak yang dikerjakan"],
491
- ["Kesiapan gate", report["kesiapan_gate"], "apakah gate berikutnya bisa dibuka"],
492
- ]))
493
-
494
- out.append("\n## 1. Terbukti\n\n")
495
- out.append("Baris RTM yang berubah hijau dalam periode ini.\n\n")
496
- out.append(table(["FR", "UC", "Story", "Test", "Tanggal"],
497
- [[p["FR"], p["UC"], p["story"], p["test"], p["closed"]]
498
- for p in report["proven"]]))
499
-
500
- out.append("\n## 2. Bergerak\n\n")
501
- out.append(table(["ID", "Lapis", "Peristiwa", "Tanggal"],
502
- [[m["id"], m["kind"], m["event"], m["date"]] for m in report["moved"]]))
503
-
504
- out.append("\n## 3. Telat\n\n")
505
- if report["late"]:
506
- out.append("Disebut satu per satu. Meringkasnya jadi hitungan adalah cara rencana "
507
- "yang meleset tetap terasa nyaman.\n\n")
508
- out.append(table(["CAP", "Judul", "Pemilik", "Rencana selesai", "Telat (hari)", "Menunggu"],
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. Cacat\n\n")
513
- defects = report["defects"]
514
- out.append("**Dibuka**\n\n")
515
- out.append(table(["ID", "Judul", "Root cause", "Melanggar"],
516
- [[d["id"], d["title"], d["root_cause"], d["violates"]]
517
- for d in defects["opened"]]))
518
- out.append("\n**Ditutup, dikelompokkan menurut root cause**\n\n")
519
- out.append(table(["Root cause", "Cacat", "Jumlah"],
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("\nBaris `requirement` dan `architecture` adalah yang layak dibaca dua kali: "
524
- "keduanya menghitung cacat yang ternyata bukan kode yang salah.\n")
525
- if defects["undiagnosed"]:
526
- out.append("\n**Belum didiagnosis** — terbuka tanpa `root_cause`, seluruh periode\n\n")
527
- out.append(table(["ID", "Judul", "Dilaporkan", "Umur (hari)"],
528
- [[d["id"], d["title"], d["reported"], d["age_days"]]
529
- for d in defects["undiagnosed"]]))
530
- out.append("\nBaris tanpa `root_cause` tidak melanggar apa pun ia berarti belum ada yang "
531
- "menjalankan `wdi-systematic-debugging` atasnya. Selama begitu ia juga tidak "
532
- "ikut menghitung rasio di atas, jadi rasio itu berlaku atas cacat yang sudah "
533
- "didiagnosis saja.\n")
534
-
535
- out.append("\n## 5. Gate\n\n")
536
- out.append(table(["Gate", "Tanggal"], [[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
- CATATAN = """
552
- ## Catatan
553
-
554
- <!-- Ditulis manusia sekali, saat terbit. MUST mengutip sebabnya (ADR, OQ-, risiko, atau
555
- cacat), bukan menceritakannya ulangcerita kedua akan menyimpang dari yang pertama.
556
- Kosongkan bila memang tidak ada yang perlu ditambahkan. -->
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} sudah terbit. Laporan yang sudah terbit itu BEKU — "
565
- f"bila ia keliru, laporan berikutnya yang menyatakannya.")
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
- "progres_janji": report["progres_janji"],
573
- "generated_by": ".constitution/scripts/timeline.py",
574
- })
575
- path.write_text(f"---\n{front}---\n\n{render_report(report, f'Laporan {name}')}{CATATAN}",
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="dimensi waktu dari git: timeline, report, laporan periode")
586
- parser.add_argument("--generate", action="store_true",
587
- help="tulis .control/generated/timeline.* dan report.*")
588
- parser.add_argument("--publish", metavar="PERIODE",
589
- help="bekukan .control/reports/<periode>.md — weekly | monthly | <nama>")
590
- parser.add_argument("--refresh", action="store_true",
591
- help="jalankan validate --generate lebih dulu supaya tabelnya segar")
592
- parser.add_argument("--root", default=".", help="akar repo (default: direktori sekarang)")
593
- parser.add_argument("--asof", default=None,
594
- help="tanggal acuan, YYYY-MM-DD (default: hari ini)")
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} tidak punya .control/registry/ — salah akar repo?", 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 tidak menjawab di akar ini. Seluruh tanggal realisasi diturunkan "
610
- "dari git, jadi tanpa git tidak ada yang bisa dilaporkandan mengarangnya "
611
- "MUST NOT dilakukan.", 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" segarkan .control/generated/ — {len(result.findings)} temuan validator")
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)} belum ada di .control/generated/. Laporan di atas "
624
- f"tabel yang basi lebih buruk daripada tidak ada laporanjalankan "
625
- f"`validate.py --generate`, atau ulangi dengan `--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" tulis .control/generated/{name}.yaml")
640
- print(f" tulis .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" terbit {path.relative_to(root).as_posix()}")
648
-
649
- overdue = [r for r in timeline["capabilities"] if r["state"] == "overdue"]
650
- print(f"\nprogres janji: {report['progres_janji']}"
651
- f" · CAP telat: {len(overdue)}"
652
- f" · acuan: {asof.isoformat()}")
653
- for row in overdue:
654
- print(f" TELAT {row['id']} — rencana selesai {row['planned_end']}, "
655
- f"menunggu {', '.join(row['waiting_on']) or 'belum ada story'}")
656
- if report["registry_dirty"]:
657
- print("\nregistry punya perubahan yang belum dicommit angka di atas belum tentu "
658
- "menggambarkan apa yang ada di main")
659
- if timeline["uncommitted_stories"]:
660
- print(f"story tanpa riwayat git: {', '.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 storedand 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 copiedone 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 statethe 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/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 boundthere 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 changesthe 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 ita 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/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 changesthe 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())