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.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/wdi-method.js +906 -0
- package/kit/.constitution/README.md +66 -0
- package/kit/.constitution/codebase/brownfield-guide.md +18 -0
- package/kit/.constitution/codebase/conventions-guide.md +18 -0
- package/kit/.constitution/codebase/stack-guide.md +18 -0
- package/kit/.constitution/constitution.md +135 -0
- package/kit/.constitution/document/architecture-guide.md +209 -0
- package/kit/.constitution/document/bmad-guide.md +151 -0
- package/kit/.constitution/document/bmad-skill-register.md +71 -0
- package/kit/.constitution/document/brief-guide.md +94 -0
- package/kit/.constitution/document/corpus-guide.md +341 -0
- package/kit/.constitution/document/decision-guide.md +134 -0
- package/kit/.constitution/document/delivery-flow-guide.md +338 -0
- package/kit/.constitution/document/prd-guide.md +202 -0
- package/kit/.constitution/document/sdd-guide.md +206 -0
- package/kit/.constitution/document/srs-guide.md +147 -0
- package/kit/.constitution/document/templates/addendum.md +71 -0
- package/kit/.constitution/document/templates/architecture-spine.md +100 -0
- package/kit/.constitution/document/templates/brief.md +110 -0
- package/kit/.constitution/document/templates/c4.md +77 -0
- package/kit/.constitution/document/templates/contract.md +52 -0
- package/kit/.constitution/document/templates/cross-cutting.md +80 -0
- package/kit/.constitution/document/templates/decision.md +64 -0
- package/kit/.constitution/document/templates/design-system.md +67 -0
- package/kit/.constitution/document/templates/flow.md +51 -0
- package/kit/.constitution/document/templates/integration.md +69 -0
- package/kit/.constitution/document/templates/inventory.md +69 -0
- package/kit/.constitution/document/templates/lc.md +47 -0
- package/kit/.constitution/document/templates/lifecycle.md +52 -0
- package/kit/.constitution/document/templates/meeting.md +47 -0
- package/kit/.constitution/document/templates/model.md +55 -0
- package/kit/.constitution/document/templates/oq.md +45 -0
- package/kit/.constitution/document/templates/prd.md +226 -0
- package/kit/.constitution/document/templates/questions.md +75 -0
- package/kit/.constitution/document/templates/rtr.md +61 -0
- package/kit/.constitution/document/templates/rules.md +47 -0
- package/kit/.constitution/document/templates/scn.md +40 -0
- package/kit/.constitution/document/templates/sdd.md +146 -0
- package/kit/.constitution/document/templates/srs.md +149 -0
- package/kit/.constitution/document/templates/structure-codebase.md +129 -0
- package/kit/.constitution/document/templates/structure-document.md +90 -0
- package/kit/.constitution/document/templates/uc.md +69 -0
- package/kit/.constitution/document/templates/ux.md +77 -0
- package/kit/.constitution/document/ux-guide.md +99 -0
- package/kit/.constitution/language-guide.md +69 -0
- package/kit/.constitution/method/README.md +169 -0
- package/kit/.constitution/method/artifact-map.md +155 -0
- package/kit/.constitution/method/portability.md +91 -0
- package/kit/.constitution/method/rationale.md +246 -0
- package/kit/.constitution/method-glossary.md +169 -0
- package/kit/.constitution/project/README.md +68 -0
- package/kit/.constitution/repo-guide.md +97 -0
- package/kit/.constitution/scripts/inventory.py +527 -0
- package/kit/.constitution/scripts/timeline.py +665 -0
- package/kit/.constitution/scripts/validate.py +1579 -0
- package/kit/.constitution/structure-guide.md +202 -0
- package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +15 -0
- package/kit/assets/bmad-custom/bmad-architecture.toml +31 -0
- package/kit/assets/bmad-custom/bmad-build-auto.toml +10 -0
- package/kit/assets/bmad-custom/bmad-build.toml +52 -0
- package/kit/assets/bmad-custom/bmad-code-review.toml +12 -0
- package/kit/assets/bmad-custom/bmad-correct-course.toml +27 -0
- package/kit/assets/bmad-custom/bmad-deep-recon.toml +11 -0
- package/kit/assets/bmad-custom/bmad-prd.toml +42 -0
- package/kit/assets/bmad-custom/bmad-product-brief.toml +34 -0
- package/kit/assets/bmad-custom/bmad-retrospective.toml +9 -0
- package/kit/assets/bmad-custom/bmad-spec.toml +15 -0
- package/kit/assets/bmad-custom/bmad-ux.toml +19 -0
- package/kit/assets/bmad-custom/config.toml +6 -0
- package/kit/skills/wdi-blueprint/SKILL.md +203 -0
- package/kit/skills/wdi-build/SKILL.md +244 -0
- package/kit/skills/wdi-component/SKILL.md +172 -0
- package/kit/skills/wdi-decision/.gitkeep +0 -0
- package/kit/skills/wdi-decision/SKILL.md +183 -0
- package/kit/skills/wdi-help/.gitkeep +0 -0
- package/kit/skills/wdi-help/SKILL.md +98 -0
- package/kit/skills/wdi-init/SKILL.md +149 -0
- package/kit/skills/wdi-log/SKILL.md +107 -0
- package/kit/skills/wdi-problem/SKILL.md +90 -0
- package/kit/skills/wdi-product/SKILL.md +127 -0
- package/kit/skills/wdi-question/.gitkeep +0 -0
- package/kit/skills/wdi-question/SKILL.md +88 -0
- package/kit/skills/wdi-reconcile/.gitkeep +0 -0
- package/kit/skills/wdi-reconcile/SKILL.md +146 -0
- package/kit/skills/wdi-report/SKILL.md +205 -0
- package/kit/skills/wdi-review/SKILL.md +118 -0
- package/kit/skills/wdi-systematic-debugging/SKILL.md +193 -0
- package/kit/skills/wdi-systematic-debugging/references/condition-based-waiting.md +115 -0
- package/kit/skills/wdi-systematic-debugging/references/defense-in-depth.md +122 -0
- package/kit/skills/wdi-systematic-debugging/references/find-polluter.sh +72 -0
- package/kit/skills/wdi-systematic-debugging/references/root-cause-tracing.md +169 -0
- package/kit/skills/wdi-ux/SKILL.md +145 -0
- package/kit-overlay/AGENTS.md +196 -0
- package/kit-overlay/README.md +66 -0
- package/kit-overlay/constitution.md +135 -0
- package/kit-overlay/portability.md +91 -0
- package/kit-overlay/repo-guide.md +97 -0
- package/lib/agents-block.mjs +52 -0
- package/lib/identity.mjs +65 -0
- package/package.json +37 -0
- package/scaffold/.control/decisions/.gitkeep +0 -0
- package/scaffold/.control/generated/.gitkeep +2 -0
- package/scaffold/.control/meetings/.gitkeep +0 -0
- package/scaffold/.control/memlog/.gitkeep +0 -0
- package/scaffold/.control/product-glossary.md +21 -0
- package/scaffold/.control/project-non-technical-log.md +23 -0
- package/scaffold/.control/questions/answered.md +11 -0
- package/scaffold/.control/questions/assumptions.md +15 -0
- package/scaffold/.control/questions/blocking.md +21 -0
- package/scaffold/.control/questions/external.md +11 -0
- package/scaffold/.control/registry/components.yaml +21 -0
- package/scaffold/.control/registry/decisions.yaml +6 -0
- package/scaffold/.control/registry/defects.yaml +3 -0
- package/scaffold/.control/registry/index.yaml +46 -0
- package/scaffold/.control/registry/requirements.yaml +15 -0
- package/scaffold/.control/registry/risks.yaml +5 -0
- package/scaffold/.control/registry/usecases.yaml +6 -0
- package/scaffold/.control/registry/waves.yaml +5 -0
- package/scaffold/.control/structure-codebase.md +19 -0
- 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())
|