wdi-method 0.3.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 (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/bin/wdi-method.js +906 -0
  4. package/kit/.constitution/README.md +66 -0
  5. package/kit/.constitution/codebase/brownfield-guide.md +18 -0
  6. package/kit/.constitution/codebase/conventions-guide.md +18 -0
  7. package/kit/.constitution/codebase/stack-guide.md +18 -0
  8. package/kit/.constitution/constitution.md +135 -0
  9. package/kit/.constitution/document/architecture-guide.md +209 -0
  10. package/kit/.constitution/document/bmad-guide.md +151 -0
  11. package/kit/.constitution/document/bmad-skill-register.md +71 -0
  12. package/kit/.constitution/document/brief-guide.md +94 -0
  13. package/kit/.constitution/document/corpus-guide.md +341 -0
  14. package/kit/.constitution/document/decision-guide.md +134 -0
  15. package/kit/.constitution/document/delivery-flow-guide.md +338 -0
  16. package/kit/.constitution/document/prd-guide.md +202 -0
  17. package/kit/.constitution/document/sdd-guide.md +206 -0
  18. package/kit/.constitution/document/srs-guide.md +147 -0
  19. package/kit/.constitution/document/templates/addendum.md +71 -0
  20. package/kit/.constitution/document/templates/architecture-spine.md +100 -0
  21. package/kit/.constitution/document/templates/brief.md +110 -0
  22. package/kit/.constitution/document/templates/c4.md +77 -0
  23. package/kit/.constitution/document/templates/contract.md +52 -0
  24. package/kit/.constitution/document/templates/cross-cutting.md +80 -0
  25. package/kit/.constitution/document/templates/decision.md +64 -0
  26. package/kit/.constitution/document/templates/design-system.md +67 -0
  27. package/kit/.constitution/document/templates/flow.md +51 -0
  28. package/kit/.constitution/document/templates/integration.md +69 -0
  29. package/kit/.constitution/document/templates/inventory.md +69 -0
  30. package/kit/.constitution/document/templates/lc.md +47 -0
  31. package/kit/.constitution/document/templates/lifecycle.md +52 -0
  32. package/kit/.constitution/document/templates/meeting.md +47 -0
  33. package/kit/.constitution/document/templates/model.md +55 -0
  34. package/kit/.constitution/document/templates/oq.md +45 -0
  35. package/kit/.constitution/document/templates/prd.md +226 -0
  36. package/kit/.constitution/document/templates/questions.md +75 -0
  37. package/kit/.constitution/document/templates/rtr.md +61 -0
  38. package/kit/.constitution/document/templates/rules.md +47 -0
  39. package/kit/.constitution/document/templates/scn.md +40 -0
  40. package/kit/.constitution/document/templates/sdd.md +146 -0
  41. package/kit/.constitution/document/templates/srs.md +149 -0
  42. package/kit/.constitution/document/templates/structure-codebase.md +129 -0
  43. package/kit/.constitution/document/templates/structure-document.md +90 -0
  44. package/kit/.constitution/document/templates/uc.md +69 -0
  45. package/kit/.constitution/document/templates/ux.md +77 -0
  46. package/kit/.constitution/document/ux-guide.md +99 -0
  47. package/kit/.constitution/language-guide.md +69 -0
  48. package/kit/.constitution/method/README.md +169 -0
  49. package/kit/.constitution/method/artifact-map.md +155 -0
  50. package/kit/.constitution/method/portability.md +91 -0
  51. package/kit/.constitution/method/rationale.md +246 -0
  52. package/kit/.constitution/method-glossary.md +169 -0
  53. package/kit/.constitution/project/README.md +68 -0
  54. package/kit/.constitution/repo-guide.md +97 -0
  55. package/kit/.constitution/scripts/inventory.py +527 -0
  56. package/kit/.constitution/scripts/timeline.py +665 -0
  57. package/kit/.constitution/scripts/validate.py +1579 -0
  58. package/kit/.constitution/structure-guide.md +202 -0
  59. package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +15 -0
  60. package/kit/assets/bmad-custom/bmad-architecture.toml +31 -0
  61. package/kit/assets/bmad-custom/bmad-build-auto.toml +10 -0
  62. package/kit/assets/bmad-custom/bmad-build.toml +52 -0
  63. package/kit/assets/bmad-custom/bmad-code-review.toml +12 -0
  64. package/kit/assets/bmad-custom/bmad-correct-course.toml +27 -0
  65. package/kit/assets/bmad-custom/bmad-deep-recon.toml +11 -0
  66. package/kit/assets/bmad-custom/bmad-prd.toml +42 -0
  67. package/kit/assets/bmad-custom/bmad-product-brief.toml +34 -0
  68. package/kit/assets/bmad-custom/bmad-retrospective.toml +9 -0
  69. package/kit/assets/bmad-custom/bmad-spec.toml +15 -0
  70. package/kit/assets/bmad-custom/bmad-ux.toml +19 -0
  71. package/kit/assets/bmad-custom/config.toml +6 -0
  72. package/kit/skills/wdi-blueprint/SKILL.md +203 -0
  73. package/kit/skills/wdi-build/SKILL.md +244 -0
  74. package/kit/skills/wdi-component/SKILL.md +172 -0
  75. package/kit/skills/wdi-decision/.gitkeep +0 -0
  76. package/kit/skills/wdi-decision/SKILL.md +183 -0
  77. package/kit/skills/wdi-help/.gitkeep +0 -0
  78. package/kit/skills/wdi-help/SKILL.md +98 -0
  79. package/kit/skills/wdi-init/SKILL.md +149 -0
  80. package/kit/skills/wdi-log/SKILL.md +107 -0
  81. package/kit/skills/wdi-problem/SKILL.md +90 -0
  82. package/kit/skills/wdi-product/SKILL.md +127 -0
  83. package/kit/skills/wdi-question/.gitkeep +0 -0
  84. package/kit/skills/wdi-question/SKILL.md +88 -0
  85. package/kit/skills/wdi-reconcile/.gitkeep +0 -0
  86. package/kit/skills/wdi-reconcile/SKILL.md +146 -0
  87. package/kit/skills/wdi-report/SKILL.md +205 -0
  88. package/kit/skills/wdi-review/SKILL.md +118 -0
  89. package/kit/skills/wdi-systematic-debugging/SKILL.md +193 -0
  90. package/kit/skills/wdi-systematic-debugging/references/condition-based-waiting.md +115 -0
  91. package/kit/skills/wdi-systematic-debugging/references/defense-in-depth.md +122 -0
  92. package/kit/skills/wdi-systematic-debugging/references/find-polluter.sh +72 -0
  93. package/kit/skills/wdi-systematic-debugging/references/root-cause-tracing.md +169 -0
  94. package/kit/skills/wdi-ux/SKILL.md +145 -0
  95. package/kit-overlay/AGENTS.md +196 -0
  96. package/kit-overlay/README.md +66 -0
  97. package/kit-overlay/constitution.md +135 -0
  98. package/kit-overlay/portability.md +91 -0
  99. package/kit-overlay/repo-guide.md +97 -0
  100. package/lib/agents-block.mjs +52 -0
  101. package/lib/identity.mjs +65 -0
  102. package/package.json +37 -0
  103. package/scaffold/.control/decisions/.gitkeep +0 -0
  104. package/scaffold/.control/generated/.gitkeep +2 -0
  105. package/scaffold/.control/meetings/.gitkeep +0 -0
  106. package/scaffold/.control/memlog/.gitkeep +0 -0
  107. package/scaffold/.control/product-glossary.md +21 -0
  108. package/scaffold/.control/project-non-technical-log.md +23 -0
  109. package/scaffold/.control/questions/answered.md +11 -0
  110. package/scaffold/.control/questions/assumptions.md +15 -0
  111. package/scaffold/.control/questions/blocking.md +21 -0
  112. package/scaffold/.control/questions/external.md +11 -0
  113. package/scaffold/.control/registry/components.yaml +21 -0
  114. package/scaffold/.control/registry/decisions.yaml +6 -0
  115. package/scaffold/.control/registry/defects.yaml +3 -0
  116. package/scaffold/.control/registry/index.yaml +46 -0
  117. package/scaffold/.control/registry/requirements.yaml +15 -0
  118. package/scaffold/.control/registry/risks.yaml +5 -0
  119. package/scaffold/.control/registry/usecases.yaml +6 -0
  120. package/scaffold/.control/registry/waves.yaml +5 -0
  121. package/scaffold/.control/structure-codebase.md +19 -0
  122. package/scaffold/.control/structure-document.md +25 -0
@@ -0,0 +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 disalin — satu 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 sah — baris 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 judul — bukan 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 kiri — belum 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
+ "Terbukti — di 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 ulang — cerita 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 dilaporkan — dan 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 laporan — jalankan "
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())