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
@@ -3,18 +3,18 @@
3
3
  # requires-python = ">=3.11"
4
4
  # dependencies = ["pyyaml>=6"]
5
5
  # ///
6
- """validate — V1..V27 plus generator .control/generated/.
6
+ """validate — V1..V27 plus the .control/generated/ generator.
7
7
 
8
- Dua mode:
9
- validate --check keluar non-zero bila ada yang merah; tidak menulis apa pun
10
- validate --generate tulis ulang .control/generated/ (dan tetap menjalankan --check)
8
+ Two modes:
9
+ validate --check exit non-zero if anything is red; writes nothing
10
+ validate --generate rewrite .control/generated/ (and still runs --check)
11
11
 
12
- Determinisme adalah kontraknya: dua run atas data yang sama MUST memberi hasil yang sama.
13
- Karena itu tidak ada iterasi tak berurut, dan satu-satunya masukan yang bergantung waktu
14
- (--asof, dipakai V14) dinyatakan eksplisit alih-alih diambil diam-diam dari jam dinding.
12
+ Determinism is the contract: two runs over the same data MUST produce the same result.
13
+ That is why there is no unordered iteration, and the one time-dependent input
14
+ (--asof, used by V14) is stated explicitly instead of being taken silently from the wall clock.
15
15
 
16
- Yang TIDAK dikerjakan di sini: dimensi waktu dari git. `generated/timeline` dan
17
- `generated/report` milik wdi-report. Lihat 08-project-management.md.
16
+ What is NOT done here: the time dimension from git. `generated/timeline` and
17
+ `generated/report` belong to wdi-report. See 08-project-management.md.
18
18
  """
19
19
 
20
20
  from __future__ import annotations
@@ -29,11 +29,11 @@ from pathlib import Path
29
29
 
30
30
  import yaml
31
31
 
32
- REGISTRY = "control/registry" # dirapikan di resolve(); '.control' dipakai sebenarnya
32
+ REGISTRY = "control/registry" # tidied up in resolve(); '.control' is what is actually used
33
33
  GENERATED_ORDER = ["components", "risks", "dag", "rtm", "status"]
34
34
 
35
- # Halaman yang dibaca MANUSIA, bukan mesin: ditulis sebagai tabel markdown sungguhan, bukan yaml
36
- # dalam fence. Ketiganya disebut §22 dan masing-masing punya satu pembaca yang jelas.
35
+ # Pages read by HUMANS, not machines: written as real markdown tables, not yaml
36
+ # in a fence. All three are named in §22 and each has one clear reader.
37
37
  GENERATED_PAGES = ["decisions", "blueprint", "estimate"]
38
38
 
39
39
  MODES = ("catalog", "outline", "guarded", "deep")
@@ -53,7 +53,7 @@ SENSITIVE_MARKERS = (
53
53
  )
54
54
 
55
55
 
56
- # ---------------------------------------------------------------- infrastruktur
56
+ # ---------------------------------------------------------------- infrastructure
57
57
 
58
58
 
59
59
  @dataclass(frozen=True)
@@ -92,7 +92,7 @@ def load_yaml(path: Path) -> dict:
92
92
 
93
93
 
94
94
  def rows(data: dict, key: str) -> list[dict]:
95
- """Registry list, selalu terurut menurut id supaya keluaran deterministik."""
95
+ """Registry list, always sorted by id so the output is deterministic."""
96
96
  value = data.get(key) or []
97
97
  if not isinstance(value, list):
98
98
  return []
@@ -104,7 +104,7 @@ FM = re.compile(r"\A---\s*\n(.*?)\n---\s*(\n|\Z)", re.S)
104
104
 
105
105
 
106
106
  class Dumper(yaml.SafeDumper):
107
- """Tanpa anchor/alias: keluaran MUST bisa dibaca dan di-diff baris demi baris."""
107
+ """No anchors/aliases: output MUST be readable and diffable line by line."""
108
108
 
109
109
  def ignore_aliases(self, data) -> bool: # noqa: ARG002
110
110
  return True
@@ -116,7 +116,7 @@ def dump(payload: dict) -> str:
116
116
 
117
117
 
118
118
  def frontmatter(path: Path) -> dict | None:
119
- """None bila berkasnya tidak ada; {} bila ada tetapi tanpa frontmatter."""
119
+ """None if the file does not exist; {} if it exists but has no frontmatter."""
120
120
  if not path.exists():
121
121
  return None
122
122
  match = FM.match(path.read_text(encoding="utf-8", errors="replace"))
@@ -137,7 +137,7 @@ def git(root: Path, *args: str) -> str | None:
137
137
  return out.stdout.strip() if out.returncode == 0 else None
138
138
 
139
139
 
140
- # ------------------------------------------------------------------- pemuatan
140
+ # ------------------------------------------------------------------- loading
141
141
 
142
142
 
143
143
  @dataclass
@@ -167,7 +167,7 @@ class Corpus:
167
167
  index=load_yaml(reg / "index.yaml"),
168
168
  )
169
169
 
170
- # --- pintasan yang dipakai berulang
170
+ # --- shortcuts used repeatedly
171
171
  @property
172
172
  def goals(self) -> list[dict]:
173
173
  return rows(self.requirements, "goals")
@@ -193,7 +193,7 @@ class Corpus:
193
193
  return rows(self.decisions, "decisions")
194
194
 
195
195
  def mode_of(self, pc: dict) -> str:
196
- """`mode` per komponen menang atas global; tanpa keduanya, default `catalog`."""
196
+ """Per-component `mode` wins over the global one; with neither, default `catalog`."""
197
197
  own = str(pc.get("mode") or "").strip()
198
198
  if own:
199
199
  return own
@@ -216,7 +216,7 @@ class Corpus:
216
216
  return rows(self.defects, "defects")
217
217
 
218
218
  def stories(self) -> list[tuple[dict, dict, dict]]:
219
- """(wave, epic, story) — urut menurut id di tiap tingkat."""
219
+ """(wave, epic, story) — sorted by id at each level."""
220
220
  out = []
221
221
  for wave in self.wave_list:
222
222
  for epic in sorted(wave.get("epics") or [], key=lambda e: str(e.get("id", ""))):
@@ -235,18 +235,18 @@ def listy(row: dict, key: str) -> list[str]:
235
235
  return [str(v) for v in value if v is not None]
236
236
 
237
237
 
238
- # ------------------------------------------------------------------ validator
238
+ # ------------------------------------------------------------------ validators
239
239
 
240
240
 
241
241
  def v1(c: Corpus, r: Result) -> None:
242
- """Tiap BG punya >=1 FR lewat CAP-nya, ATAU menyatakan alasannya di `no_fr`.
242
+ """Every BG has >=1 FR through its CAP, OR states its reason in `no_fr`.
243
243
 
244
- Sebuah sasaran MAY dipenuhi oleh **invarian**, bukan oleh fitur. `BG-6` — fondasi data dan
245
- deployment dapat dilanjutkan tanpa dibongkar diukur oleh dua sifat arsitektural yang `measure`-nya
246
- sendiri sebut, dan tidak ada `FR` yang dapat memikulnya tanpa dikarang. Menuntut satu `FR` di sana
247
- menghasilkan janji palsu, dan janji palsu lebih mahal daripada temuan.
244
+ A goal MAY be satisfied by an **invariant** rather than a feature. `BG-6` — the data and
245
+ deployment foundation can be extended without being torn down is measured by two architectural
246
+ properties that its own `measure` names, and no `FR` can carry it without being invented. Demanding
247
+ one `FR` there produces a false promise, and a false promise is more expensive than a finding.
248
248
 
249
- Escape-nya MUST membawa alasan, bukan boolean — bentuk yang sama dengan `no_uc` pada `FR` (V2).
249
+ The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (V2).
250
250
  """
251
251
  cap_by_goal: dict[str, list[str]] = {}
252
252
  for cap in c.caps:
@@ -259,7 +259,7 @@ def v1(c: Corpus, r: Result) -> None:
259
259
  continue
260
260
  if str(goal.get("no_fr") or "").strip():
261
261
  continue
262
- r.fail("V1", gid, "tidak punya FR lewat CAP-nya dan tidak menyatakan alasan di `no_fr`")
262
+ r.fail("V1", gid, "has no FR through its CAP and states no reason in `no_fr`")
263
263
 
264
264
 
265
265
  def v2(c: Corpus, r: Result) -> None:
@@ -270,48 +270,48 @@ def v2(c: Corpus, r: Result) -> None:
270
270
  continue
271
271
  if str(fr.get("no_uc") or "").strip():
272
272
  continue
273
- r.fail("V2", fid, "tidak punya UC dan tidak menyatakan alasan di `no_uc`")
273
+ r.fail("V2", fid, "has no UC and states no reason in `no_uc`")
274
274
 
275
275
 
276
276
  def v3(c: Corpus, r: Result) -> None:
277
- """Sebuah UC pada komponen yang SUDAH disentuh sebuah wave MUST dijadwalkan story.
277
+ """A UC on a component that a wave has ALREADY touched MUST be scheduled to a story.
278
278
 
279
- Bentuk lama menuntutnya atas SETIAP UC, kapan pun. Sebelum wave pertama itu berarti seluruh
280
- katalog dilaporkan merah — 56 temuan dari 62, dan ke-56 itu keadaan yang benar, bukan drift:
281
- story lahir di wave, dan belum ada wave. Sebuah validator yang menenggelamkan enam temuan nyata
282
- di bawah lima puluh enam yang diharapkan berhenti dibaca, dan validator yang tidak dibaca tidak
283
- menjaga apa pun.
279
+ The old shape demanded this of EVERY UC, at any time. Before the first wave that meant the
280
+ entire catalogue was reported red — 56 findings out of 62, and those 56 were the correct state,
281
+ not drift: a story is born in a wave, and there was no wave yet. A validator that drowns six real
282
+ findings under fifty-six expected ones stops being read, and a validator that is not read
283
+ guards nothing.
284
284
 
285
- Yang dijaga sekarang adalah kelalaian yang sebenarnya: sebuah wave menyentuh komponen, dan sebuah
286
- UC komponen itu tertinggal tanpa story. Cakupan penuh atas seluruh katalog adalah pertanyaan G5,
287
- dan `wdi-build` yang memilikinyasama seperti V12 yang digeser ke penutupan wave.
285
+ What is guarded now is the actual omission: a wave touches a component, and a UC of that
286
+ component is left behind without a story. Full coverage of the whole catalogue is a G5 question,
287
+ and `wdi-build` owns itthe same way V12 was shifted to wave closing.
288
288
  """
289
289
  scheduled = {uc for _, _, s in c.stories() for uc in listy(s, "satisfies")}
290
290
  touched = {str(s.get("component")) for _, _, s in c.stories() if s.get("component")}
291
291
  if not c.wave_list:
292
- r.skip("V3", "belum ada wave, jadi belum ada story — tiap UC tak terjadwal adalah keadaan "
293
- "yang benar. Cakupan penuh katalog diperiksa di G5")
292
+ r.skip("V3", "no wave yet, so no story yet every unscheduled UC is the correct "
293
+ "state. Full catalogue coverage is checked at G5")
294
294
  return
295
295
  for uc in c.ucs:
296
296
  uid = str(uc.get("id"))
297
297
  if uid in scheduled or str(uc.get("component")) not in touched:
298
298
  continue
299
- r.fail("V3", uid, f"komponen `{uc.get('component')}` sudah disentuh sebuah wave, "
300
- f"tetapi UC ini tidak dijadwalkan story mana pun")
299
+ r.fail("V3", uid, f"component `{uc.get('component')}` has already been touched by a wave, "
300
+ f"but this UC is not scheduled to any story")
301
301
 
302
302
 
303
303
  def v4(c: Corpus, r: Result) -> None:
304
304
  for _, _, story in c.stories():
305
305
  if not [t for t in listy(story, "tests") if t.strip()]:
306
- r.fail("V4", str(story.get("id")), "tidak punya satu pun test bernama")
306
+ r.fail("V4", str(story.get("id")), "has not one named test")
307
307
 
308
308
 
309
309
  def v5(c: Corpus, r: Result) -> None:
310
- """Tiap NFR punya penegak, ATAU menyatakan alasannya di `no_enforcer`.
310
+ """Every NFR has an enforcer, OR states its reason in `no_enforcer`.
311
311
 
312
- Dua NFR di repo ini tidak dapat punya penegak, dan keduanya sah: satu sudah **dicabut**, dan satu
313
- lagi menyatakan sendiri bahwa ia **ukuran perancangan, bukan pagar**. Menuntut test untuk keduanya
314
- menghasilkan test yang tidak mungkin gagal, dan test yang tidak mungkin gagal adalah teater.
312
+ Two NFRs in this repo cannot have an enforcer, and both are valid: one has already been
313
+ **retired**, and the other states of itself that it is a **design measure, not a gate**. Demanding
314
+ a test for both produces a test that cannot fail, and a test that cannot fail is theater.
315
315
  """
316
316
  for nfr in c.nfrs:
317
317
  if [e for e in listy(nfr, "enforced_by") if e.strip()]:
@@ -319,7 +319,7 @@ def v5(c: Corpus, r: Result) -> None:
319
319
  if str(nfr.get("no_enforcer") or "").strip():
320
320
  continue
321
321
  r.fail("V5", str(nfr.get("id")),
322
- "tidak punya penegak di `enforced_by` dan tidak menyatakan alasan di `no_enforcer`")
322
+ "has no enforcer in `enforced_by` and states no reason in `no_enforcer`")
323
323
 
324
324
 
325
325
  def v6(c: Corpus, r: Result) -> None:
@@ -353,7 +353,7 @@ def v6(c: Corpus, r: Result) -> None:
353
353
 
354
354
  for owner, target in sorted(set(refs)):
355
355
  if target and target not in defined:
356
- r.fail("V6", owner, f"menunjuk `{target}` yang tidak ada di registry mana pun")
356
+ r.fail("V6", owner, f"points to `{target}` which does not exist in any registry")
357
357
 
358
358
 
359
359
  def _cycles(graph: dict[str, list[str]]) -> list[str]:
@@ -378,25 +378,25 @@ def _cycles(graph: dict[str, list[str]]) -> list[str]:
378
378
  def v7(c: Corpus, r: Result) -> None:
379
379
  caps = {str(x.get("id")): listy(x, "depends_on") for x in c.caps}
380
380
  for node in _cycles(caps):
381
- r.fail("V7", node, "ikut dalam siklus `depends_on` antar-CAP")
381
+ r.fail("V7", node, "is part of a `depends_on` cycle among CAPs")
382
382
  stories = {str(s.get("id")): listy(s, "depends_on") for _, _, s in c.stories()}
383
383
  for node in _cycles(stories):
384
- r.fail("V7", node, "ikut dalam siklus `depends_on` antar-story")
384
+ r.fail("V7", node, "is part of a `depends_on` cycle among stories")
385
385
 
386
386
 
387
387
  def v8(c: Corpus, r: Result) -> None:
388
- """Tiap keputusan `applied` menyebut `touches` yang tidak kosong.
388
+ """Every `applied` decision names a non-empty `touches`.
389
389
 
390
- Menggantikan bentuk lama "tiap keputusan accepted melayani >=1 FR/NFR". Keputusan seperti
391
- "filter harus begini" tidak melayani FR mana pun, dan itu SAHjustru keputusan seperti itu
392
- yang paling perlu diingat, dan aturan lama menyingkirkannya.
390
+ Replaces the old shape "every accepted decision serves >=1 FR/NFR". A decision like
391
+ "the filter MUST work like this" serves no FR at all, and that is VALID it is exactly
392
+ decisions like that which most need remembering, and the old rule discarded them.
393
393
  """
394
394
  for dec in c.decs:
395
395
  if str(dec.get("status")) != "applied":
396
396
  continue
397
397
  if not [x for x in listy(dec, "touches") if str(x).strip()]:
398
398
  r.fail("V8", str(dec.get("id")),
399
- "berstatus applied tetapi `touches` kosongpenerapan tanpa jejak berkas")
399
+ "is applied but `touches` is empty an application with no file trace")
400
400
 
401
401
 
402
402
  def v9(c: Corpus, r: Result) -> None:
@@ -408,7 +408,7 @@ def v9(c: Corpus, r: Result) -> None:
408
408
  gate = str(fm.get("locked_at_gate") or "")
409
409
  if gate not in passed:
410
410
  rel = path.relative_to(c.root).as_posix()
411
- r.fail("V9", rel, f"berstatus locked tetapi gate `{gate or '?'}` tidak tercatat lulus")
411
+ r.fail("V9", rel, f"is locked but gate `{gate or '?'}` is not recorded as passed")
412
412
 
413
413
 
414
414
  def v11(c: Corpus, r: Result) -> None:
@@ -438,14 +438,14 @@ def v11(c: Corpus, r: Result) -> None:
438
438
  if reaches(lid, rid) or reaches(rid, lid):
439
439
  continue
440
440
  r.fail("V11", f"{lid} + {rid}",
441
- f"berbagi touches {shared} tanpa relasi depends_on — MUST NOT paralel")
441
+ f"share touches {shared} with no depends_on relation — MUST NOT run in parallel")
442
442
 
443
443
 
444
444
  def v12(c: Corpus, r: Result) -> None:
445
- """Pendaftaran LC diperiksa saat wave DITUTUP, bukan sebelum story `ready-for-dev`.
445
+ """LC registration is checked when a wave CLOSES, not before a story goes `ready-for-dev`.
446
446
 
447
- Bentuk lama menuntut jawabannya pada saat informasinya paling tipis. Di penutupan wave,
448
- tiap `touches` sudah punya wilayah dan tiap boundary sudah punya nama.
447
+ The old shape demanded the answer when the information was thinnest. At wave closing,
448
+ every `touches` already has an area and every boundary already has a name.
449
449
  """
450
450
  areas = {str(lc.get("area")) for lc in c.lcs if lc.get("area")}
451
451
  lcs_per_pc: dict[str, int] = {}
@@ -460,8 +460,8 @@ def v12(c: Corpus, r: Result) -> None:
460
460
  for area in listy(story, "touches"):
461
461
  if area not in areas:
462
462
  r.fail("V12", str(story.get("id")),
463
- f"wave-nya sudah ditutup, tetapi `{area}` tidak terdaftar sebagai `area` "
464
- f"di components.yaml")
463
+ f"its wave is already closed, but `{area}` is not registered as an `area` "
464
+ f"in components.yaml")
465
465
  pid = str(story.get("component") or "")
466
466
  row = pc_by_id.get(pid)
467
467
  if row is None or (str(wave.get("id")), pid) in seen:
@@ -469,8 +469,8 @@ def v12(c: Corpus, r: Result) -> None:
469
469
  seen.add((str(wave.get("id")), pid))
470
470
  if c.mode_of(row) in ("guarded", "deep") and not lcs_per_pc.get(pid):
471
471
  r.fail("V12", f"{wave.get('id')} / {pid}",
472
- f"wave tertutup dan komponen ber-mode `{c.mode_of(row)}` belum punya satu pun "
473
- f"`LC` terdaftar")
472
+ f"wave closed and component with mode `{c.mode_of(row)}` has not one "
473
+ f"`LC` registered")
474
474
 
475
475
 
476
476
  LENS_BY_RISK = {
@@ -483,24 +483,24 @@ FRONTMATTER_KEYS = ("reviewed:", "date:", "sha:", "lenses:", "updated:")
483
483
 
484
484
  def _reviewed_ok(r: Result, rel: str, block: object, need: set[str]) -> None:
485
485
  if not isinstance(block, dict) or not block.get("sha") or not block.get("date"):
486
- r.fail("V13", rel, "tidak membawa jejak `reviewed` berisi date dan sha")
486
+ r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
487
487
  return
488
488
  lenses = {str(x) for x in (block.get("lenses") or [])}
489
489
  if not lenses:
490
- r.fail("V13", rel, "jejak `reviewed` tidak menyebut satu lensa pun")
490
+ r.fail("V13", rel, "the `reviewed` trace names not one lens")
491
491
  missing = sorted(need - lenses)
492
492
  if missing:
493
493
  r.fail("V13", rel,
494
- f"lensa {missing} MUST ikutitu yang dituntut `risk_accepted` komponennya")
494
+ f"lenses {missing} MUST be included that is what the component's `risk_accepted` demands")
495
495
 
496
496
 
497
497
  def _only_reviewed_block(diff: str) -> bool:
498
- """True bila diff sebuah commit atas satu berkas HANYA menyentuh blok `reviewed:`.
498
+ """True if a commit's diff on one file ONLY touches the `reviewed:` block.
499
499
 
500
- Inilah perbaikan OQ-146. V13 lama membandingkan `sha` dengan commit terakhir yang mengubah
501
- berkasnyatetapi commit yang MENULISKAN blok `reviewed:` selalu mengubah berkasnya, dan
502
- menulis hash diri sendiri ke dalam sebuah commit git mustahil secara kriptografis. Akibatnya
503
- setiap artefak yang baru distempel langsung terbaca "review basi", selamanya.
500
+ This is the OQ-146 fix. The old V13 compared `sha` against the last commit that changed
501
+ the file but the commit that WRITES the `reviewed:` block always changes the file, and
502
+ writing its own hash into a git commit is cryptographically impossible. As a result every
503
+ artifact that had just been stamped immediately read as "stale review", forever.
504
504
  """
505
505
  touched = [ln for ln in diff.splitlines()
506
506
  if ln[:1] in "+-" and not ln.startswith("+++") and not ln.startswith("---")]
@@ -516,7 +516,7 @@ def _only_reviewed_block(diff: str) -> bool:
516
516
 
517
517
 
518
518
  def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
519
- """Commit pertama sesudah `sha` yang mengubah berkas ini karena alasan selain stempel review."""
519
+ """First commit after `sha` that changes this file for a reason other than a review stamp."""
520
520
  log = git(c.root, "log", "--format=%H", f"{sha}..HEAD", "--", rel)
521
521
  if not log:
522
522
  return None
@@ -534,31 +534,31 @@ def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
534
534
 
535
535
 
536
536
  def v13(c: Corpus, r: Result) -> None:
537
- """Jejak review mengikuti INTENSITAS review, bukan kedalaman dokumen.
537
+ """Review trace follows review INTENSITY, not document depth.
538
538
 
539
- Dipersempit ke komponen ber-`risk_accepted` `low` atau `medium`. Pada `high` pemilik sudah
540
- menyatakan menerima risikonya, dan menuntut jejak di situ adalah pembukuan tanpa pembeli.
539
+ Narrowed to components with `risk_accepted` `low` or `medium`. At `high` the owner has already
540
+ stated they accept the risk, and demanding a trace there is bookkeeping with no buyer.
541
541
  """
542
542
  watched = [pc for pc in c.pcs
543
543
  if str(pc.get("risk_accepted") or "").strip() in ("low", "medium")]
544
544
  if not watched:
545
- r.skip("V13", "tidak ada komponen ber-risk_accepted low atau medium — tidak ada yang dijaga")
545
+ r.skip("V13", "no component with risk_accepted low or medium — nothing to guard")
546
546
  targets: list[tuple[Path, set[str]]] = []
547
547
  if watched:
548
548
  targets.append((c.root / ".how/_platform/ARCHITECTURE-SPINE.md", set()))
549
549
  for pc in watched:
550
550
  pid = str(pc.get("id"))
551
551
  need = LENS_BY_RISK.get(str(pc.get("risk_accepted")).strip(), set())
552
- # SRS ada dan bermakna pada SETIAP mode: ia membawa Actor Register dan UC Catalogue, dan
553
- # keduanya lahir di G3 yang tidak disentuh knob kedalaman.
552
+ # The SRS exists and is meaningful at EVERY mode: it carries the Actor Register and UC
553
+ # Catalogue, and both are born at G3, which the depth knob does not touch.
554
554
  targets.append((c.root / f".what/{pid}/SRS-{pid}.md", need))
555
- # SDD hanya dijaga ketika ia PUNYA ISI untuk dijaga. Dua keadaan mengecualikannya, dan
556
- # keduanya keadaan SELESAI bukan keadaan tertinggal:
557
- # mode: catalog skeleton adalah bentuk akhirnya; G4 dilewati di situ
558
- # g4_passed belum ada G4 belum dijalankan, jadi tidak ada satu bagian pun yang tertulis
559
- # Menuntut jejak review atas berkas yang isinya 13 baris komentar template adalah teater —
560
- # persis upacara yang rancang ulang ini cabut, dan review yang tidak mungkin gagal tidak
561
- # membuktikan apa pun. Begitu G4 lewat, tuntutannya kembali dan ia bermakna.
555
+ # The SDD is guarded only when it HAS content worth guarding. Two states exempt it, and
556
+ # both are FINISHED states, not neglected ones:
557
+ # mode: catalog the skeleton is its final form; G4 is skipped there
558
+ # g4_passed not set G4 has not run yet, so not one section is written
559
+ # Demanding a review trace on a file whose content is 13 lines of template comments is
560
+ # theater exactly the ceremony this redesign cut, and a review that cannot fail proves
561
+ # nothing. Once G4 passes, the demand comes back and it is meaningful.
562
562
  passed = str(pc.get("g4_passed") or "").strip().lower()
563
563
  if c.mode_of(pc) != "catalog" and passed not in ("", "false", "no", "belum"):
564
564
  targets.append((c.root / f".how/{pid}/SDD-{pid}.md", need))
@@ -566,7 +566,7 @@ def v13(c: Corpus, r: Result) -> None:
566
566
  for path, need in targets:
567
567
  fm = frontmatter(path)
568
568
  if fm is None:
569
- continue # belum lahirbukan urusan V13
569
+ continue # not born yet not V13's business
570
570
  rel = path.relative_to(c.root).as_posix()
571
571
  _reviewed_ok(r, rel, fm.get("reviewed"), need)
572
572
  block = fm.get("reviewed")
@@ -574,8 +574,8 @@ def v13(c: Corpus, r: Result) -> None:
574
574
  stale = _stale_since(c, rel, str(block["sha"]))
575
575
  if stale:
576
576
  r.fail("V13", rel,
577
- f"berubah di {stale[:7]} sesudah direview di {str(block['sha'])[:7]} — "
578
- f"review basi")
577
+ f"changed at {stale[:7]} after being reviewed at {str(block['sha'])[:7]} — "
578
+ f"stale review")
579
579
 
580
580
  for wave in c.wave_list:
581
581
  if not wave.get("epics"):
@@ -585,7 +585,7 @@ def v13(c: Corpus, r: Result) -> None:
585
585
 
586
586
 
587
587
  def cap_stories(c: Corpus) -> dict[str, list[dict]]:
588
- """CAP -> story, ditelusuri CAP -> FR -> UC -> story. Tanpa git, tanpa timeline."""
588
+ """CAP -> story, traced through CAP -> FR -> UC -> story. No git, no timeline."""
589
589
  frs_of: dict[str, list[str]] = {}
590
590
  for fr in c.frs:
591
591
  frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
@@ -603,14 +603,14 @@ def cap_stories(c: Corpus) -> dict[str, list[dict]]:
603
603
 
604
604
 
605
605
  def v14(c: Corpus, r: Result, asof: dt.date) -> None:
606
- """Keterlambatan dihitung dari registry sendiri — timeline hanya memperkuat, bukan syarat."""
606
+ """Overdue-ness is computed from the registry itselfthe timeline only reinforces, never gates."""
607
607
  by_cap = cap_stories(c)
608
608
  timeline = load_yaml(c.root / ".control/generated/timeline.yaml")
609
609
  listed = {str(row.get("id")) for row in rows(timeline, "capabilities")
610
610
  if str(row.get("state")) == "overdue"} if timeline else None
611
611
  if listed is None:
612
- r.skip("V14", "generated/timeline.yaml belum adaketerlambatan tetap dihitung "
613
- "dari registry, tetapi kehadirannya di generated/report tidak diperiksa")
612
+ r.skip("V14", "generated/timeline.yaml does not exist yet overdue-ness is still computed "
613
+ "from the registry, but its presence in generated/report is not checked")
614
614
 
615
615
  for cap in c.caps:
616
616
  cid = str(cap.get("id"))
@@ -620,7 +620,7 @@ def v14(c: Corpus, r: Result, asof: dt.date) -> None:
620
620
  try:
621
621
  due = dt.date.fromisoformat(end)
622
622
  except ValueError:
623
- r.fail("V14", cid, f"`planned_end` `{end}` bukan tanggal ISO")
623
+ r.fail("V14", cid, f"`planned_end` `{end}` is not an ISO date")
624
624
  continue
625
625
  items = by_cap.get(cid, [])
626
626
  closed = bool(items) and all(_story_status(c, s) == "done" for s in items)
@@ -628,19 +628,19 @@ def v14(c: Corpus, r: Result, asof: dt.date) -> None:
628
628
  continue
629
629
  late = (asof - due).days
630
630
  if listed is not None and cid not in listed:
631
- r.fail("V14", cid, f"lewat {late} hari tanpa realisasi, dan tidak disebut "
632
- f"`overdue` di generated/timeline")
631
+ r.fail("V14", cid, f"{late} days overdue with nothing delivered, and not flagged "
632
+ f"`overdue` in generated/timeline")
633
633
  else:
634
- r.fail("V14", cid, f"lewat {late} hari tanpa realisasi tertutup")
634
+ r.fail("V14", cid, f"{late} days overdue with nothing closed")
635
635
 
636
636
 
637
637
  def v15(c: Corpus, r: Result) -> None:
638
638
  for cap in c.caps:
639
639
  if not str(cap.get("goal") or "").strip():
640
- r.fail("V15", str(cap.get("id")), "tidak menunjuk `goal`")
640
+ r.fail("V15", str(cap.get("id")), "does not point to a `goal`")
641
641
  for fr in c.frs:
642
642
  if not str(fr.get("capability") or "").strip():
643
- r.fail("V15", str(fr.get("id")), "tidak menunjuk `capability`")
643
+ r.fail("V15", str(fr.get("id")), "does not point to a `capability`")
644
644
 
645
645
 
646
646
  def v16(c: Corpus, r: Result) -> None:
@@ -649,26 +649,26 @@ def v16(c: Corpus, r: Result) -> None:
649
649
  rel = path.relative_to(c.root).as_posix()
650
650
  artifact = str(fm.get("artifact") or "")
651
651
  if not artifact:
652
- r.fail("V16", rel, "tidak punya `artifact:` di frontmatter")
652
+ r.fail("V16", rel, "has no `artifact:` in frontmatter")
653
653
  elif not (c.root / artifact).exists():
654
- r.fail("V16", rel, f"`artifact:` menunjuk `{artifact}` yang tidak ada")
654
+ r.fail("V16", rel, f"`artifact:` points to `{artifact}` which does not exist")
655
655
  for layer in (".what", ".how"):
656
656
  for stray in sorted(c.root.glob(f"{layer}/**/.memlog.md")):
657
657
  r.fail("V16", stray.relative_to(c.root).as_posix(),
658
- "memlog MUST NOT tinggal di dalam korpus")
658
+ "a memlog MUST NOT live inside the corpus")
659
659
 
660
660
 
661
661
  def v17(c: Corpus, r: Result) -> None:
662
662
  for wave in c.wave_list:
663
663
  wid = str(wave.get("id"))
664
664
  if not str(wave.get("release") or "").strip():
665
- r.fail("V17", wid, "tidak menyebut `release`")
665
+ r.fail("V17", wid, "does not name a `release`")
666
666
  slugs = listy(wave, "prd")
667
667
  if not slugs:
668
- r.fail("V17", wid, "tidak menyebut `prd`")
668
+ r.fail("V17", wid, "does not name a `prd`")
669
669
  for slug in slugs:
670
670
  if not (c.root / ".what/_prd" / slug).is_dir():
671
- r.fail("V17", wid, f"`prd: {slug}` tidak punya folder .what/_prd/{slug}/")
671
+ r.fail("V17", wid, f"`prd: {slug}` has no folder .what/_prd/{slug}/")
672
672
 
673
673
 
674
674
  def v18(c: Corpus, r: Result) -> None:
@@ -676,22 +676,22 @@ def v18(c: Corpus, r: Result) -> None:
676
676
  sid = str(story.get("id"))
677
677
  folder = str(story.get("spec_folder") or "").strip()
678
678
  if not folder:
679
- r.fail("V18", sid, "tidak menyebut `spec_folder`")
679
+ r.fail("V18", sid, "does not name a `spec_folder`")
680
680
  continue
681
681
  matches = sorted((c.root / folder / "stories").glob(f"{sid}-*.md"))
682
682
  if not matches:
683
- r.fail("V18", sid, f"tidak punya story file di {folder}stories/")
683
+ r.fail("V18", sid, f"has no story file in {folder}stories/")
684
684
  continue
685
685
  fm = frontmatter(matches[0]) or {}
686
686
  if not str(fm.get("status") or "").strip():
687
- r.fail("V18", sid, "story file tidak punya `status` di frontmatter")
687
+ r.fail("V18", sid, "story file has no `status` in frontmatter")
688
688
 
689
689
 
690
690
  def v19(c: Corpus, r: Result) -> None:
691
- """Arsip retrospektif diikatkan ke UKURAN WAVE, bukan ke `mode`.
691
+ """The retrospective archive is tied to WAVE SIZE, not to `mode`.
692
692
 
693
- Wajib pada wave `L`; advisory pada `S` dan `M`. Kedalaman dokumen dan volume kerja adalah dua
694
- hal berbeda, dan menuntut retrospektif atas wave tiga story adalah upacara.
693
+ Mandatory on wave `L`; advisory on `S` and `M`. Document depth and volume of work are two
694
+ different things, and demanding a retrospective for a three-story wave is ceremony.
695
695
  """
696
696
  names = [x.name for x in sorted((c.root / ".control/reports").glob("RTR-*"))]
697
697
  advisory: list[str] = []
@@ -702,13 +702,13 @@ def v19(c: Corpus, r: Result) -> None:
702
702
  if any(wid in name for name in names):
703
703
  continue
704
704
  if str(wave.get("size")).upper() == "L":
705
- r.fail("V19", wid, "wave `L` tertutup tanpa `RTR-` di .control/reports/")
705
+ r.fail("V19", wid, "wave `L` closed without an `RTR-` in .control/reports/")
706
706
  else:
707
707
  advisory.append(wid)
708
708
  if advisory:
709
- r.skip("V19", "advisory — wave S/M tertutup tanpa RTR-: " + ", ".join(sorted(advisory)))
709
+ r.skip("V19", "advisory — wave S/M closed without an RTR-: " + ", ".join(sorted(advisory)))
710
710
  else:
711
- r.skip("V19", "hanya baris RTR- yang diperiksa mekanis; sisa distilasi dijaga wdi-build")
711
+ r.skip("V19", "only the RTR- line item is checked mechanically; the rest of the distillation is guarded by wdi-build")
712
712
 
713
713
 
714
714
  PLATFORM = "_platform"
@@ -717,18 +717,18 @@ PLATFORM_DATA_HEADING = "Milik platform"
717
717
 
718
718
 
719
719
  def v21(c: Corpus, r: Result) -> None:
720
- """Satu entitas domain punya TEPAT SATU pemilik yang berwenang menulisnya.
721
-
722
- Pemiliknya sebuah Product Component, ATAU `_platform` untuk entitas yang tidak ada satu pun
723
- janji komponen di belakangnya. Bentrokan semantik antar-PRD sudah pernah terjadi sungguhan: satu
724
- komponen mengambil rentang penomoran business rule dari deret global milik bersama. Dua `FR`
725
- yang mengklaim wewenang tulis atas entitas yang sama, tanpa salah satunya menunjuk yang lain,
726
- adalah defect saat ditulis.
727
-
728
- `_platform` BUKAN Product Component dan karena itu tidak punya `mode`, `risk_accepted`, SRS,
729
- maupun G4. Ia rumah kepemilikan, bukan irisan domain — dan supaya ia tidak jadi tempat
730
- pembuangan, tiap entitas yang ia klaim MUST dijelaskan di `cross-cutting.md`: kalau platform
731
- memiliki data, platform yang mendokumentasikannya.
720
+ """One domain entity has EXACTLY ONE owner authorized to write it.
721
+
722
+ The owner is a Product Component, OR `_platform` for an entity with no single component
723
+ promise behind it. Semantic collisions across PRDs have already happened for real: one
724
+ component took a business-rule numbering range from a shared global sequence. Two `FR`s
725
+ that both claim write authority over the same entity, with neither pointing at the other,
726
+ are a defect the moment they are written.
727
+
728
+ `_platform` is NOT a Product Component and therefore has no `mode`, `risk_accepted`, SRS,
729
+ or G4. It is a home for ownership, not a domain slice and so it does not become a dumping
730
+ ground, every entity it claims MUST be explained in `cross-cutting.md`: if the platform
731
+ owns the data, the platform documents it.
732
732
  """
733
733
  owner: dict[str, str] = {}
734
734
  for pc in c.pcs:
@@ -736,8 +736,8 @@ def v21(c: Corpus, r: Result) -> None:
736
736
  for entity in listy(pc, "owns"):
737
737
  if entity in owner and owner[entity] != pid:
738
738
  r.fail("V21", entity,
739
- f"diklaim `owns` oleh `{owner[entity]}` dan `{pid}` — satu entitas MUST "
740
- f"punya tepat satu pemilik")
739
+ f"claimed as `owns` by both `{owner[entity]}` and `{pid}` — one entity MUST "
740
+ f"have exactly one owner")
741
741
  else:
742
742
  owner.setdefault(entity, pid)
743
743
 
@@ -745,8 +745,8 @@ def v21(c: Corpus, r: Result) -> None:
745
745
  for entity in platform:
746
746
  if entity in owner:
747
747
  r.fail("V21", entity,
748
- f"diklaim `platform_owns` dan juga `owns` milik `{owner[entity]}` — "
749
- f"`{PLATFORM}` bukan jalan kedua bagi entitas yang sudah punya pemilik")
748
+ f"claimed as `platform_owns` and also as `owns` by `{owner[entity]}` — "
749
+ f"`{PLATFORM}` is not a second path for an entity that already has an owner")
750
750
  else:
751
751
  owner[entity] = PLATFORM
752
752
 
@@ -761,21 +761,21 @@ def v21(c: Corpus, r: Result) -> None:
761
761
  if not own or not home or own == home:
762
762
  continue
763
763
  if own == PLATFORM:
764
- # Platform tidak punya `FR`, jadi tidak ada yang bisa ditunjuk `defers_to`. Yang
765
- # menggantikan "satu penulis" di sini adalah SATU BENTUK YANG TERDOKUMENTASI, dan
766
- # itu diperiksa _platform_documented di atas.
764
+ # The platform has no `FR`, so there is nothing a `defers_to` could point to. What
765
+ # stands in for "one writer" here is ONE DOCUMENTED FORM, and that is what
766
+ # _platform_documented checks above.
767
767
  continue
768
768
  if not [d for d in listy(fr, "defers_to") if str(d).strip()]:
769
769
  r.fail("V21", fid,
770
- f"menjanjikan penulisan `{entity}` yang dimiliki `{own}`, tanpa `defers_to` "
771
- f"menunjuk `FR` milik pemiliknya")
770
+ f"promises to write `{entity}` which `{own}` owns, without `defers_to` "
771
+ f"pointing to an `FR` owned by that owner")
772
772
 
773
773
 
774
774
  def _platform_inventory_rows(c: Corpus) -> list[str]:
775
- """Baris inventaris yang dimiliki `_platform`, dibaca dari `platform_rows:` tiap inventaris.
775
+ """Inventory rows owned by `_platform`, read from `platform_rows:` in each inventory.
776
776
 
777
- `_platform` adalah nilai sah di SETIAP posisi kepemilikan, jadi penjaganya berlaku di setiap
778
- posisi juga: apa pun yang ia miliki MUST terdokumentasi di `cross-cutting.md`.
777
+ `_platform` is a valid value at EVERY ownership position, so the guard applies at every
778
+ position too: whatever it owns MUST be documented in `cross-cutting.md`.
779
779
  """
780
780
  out: list[str] = []
781
781
  for kind in ("db", "api", "screen"):
@@ -788,31 +788,31 @@ def _platform_inventory_rows(c: Corpus) -> list[str]:
788
788
 
789
789
 
790
790
  def _platform_documented(c: Corpus, r: Result, entities: list[str]) -> None:
791
- """Tiap entitas ber-`platform_owns` MUST disebut di `cross-cutting.md`.
791
+ """Every entity with `platform_owns` MUST be named in `cross-cutting.md`.
792
792
 
793
- Dilewati selama berkasnya belum memuat bagian itu: `cross-cutting.md` adalah keluaran G3, dan
794
- artefak yang gate berikutnya lahirkan MUST NOT dilaporkan hilang.
793
+ Skipped while the file does not yet carry that section: `cross-cutting.md` is a G3 output, and
794
+ an artifact the next gate will produce MUST NOT be reported missing.
795
795
  """
796
796
  if not entities:
797
797
  return
798
798
  path = c.root / CROSS_CUTTING
799
799
  text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
800
800
  if PLATFORM_DATA_HEADING.lower() not in text.lower():
801
- r.skip("V21", f"`{CROSS_CUTTING}` belum punya bagian `{PLATFORM_DATA_HEADING}` — "
802
- f"{len(entities)} entitas ber-platform_owns belum terdokumentasi: "
801
+ r.skip("V21", f"`{CROSS_CUTTING}` has no `{PLATFORM_DATA_HEADING}` section yet — "
802
+ f"{len(entities)} entities with platform_owns are not documented yet: "
803
803
  + ", ".join(sorted(entities)))
804
804
  return
805
805
  for entity in sorted(entities):
806
806
  if entity not in text:
807
807
  r.fail("V21", entity,
808
- f"diklaim `platform_owns` tetapi tidak disebut di `{CROSS_CUTTING}` — "
809
- f"platform yang memiliki data MUST mendokumentasikannya")
808
+ f"claimed as `platform_owns` but not named in `{CROSS_CUTTING}` — "
809
+ f"a platform that owns data MUST document it")
810
810
 
811
811
 
812
812
  def v22(c: Corpus, r: Result) -> None:
813
- """Sebuah wave MUST NOT menyentuh komponen yang G4-nya belum lewat dan mode-nya bukan catalog.
813
+ """A wave MUST NOT touch a component whose G4 has not passed and whose mode is not catalog.
814
814
 
815
- `catalog` melewati G4 dengan sengaja, jadi ia bukan pengecualiania bagian aturannya.
815
+ `catalog` skips G4 on purpose, so it is not an exception it is part of the rule.
816
816
  """
817
817
  pc_by_id = {str(x.get("id")): x for x in c.pcs}
818
818
  seen: set[tuple[str, str]] = set()
@@ -829,20 +829,20 @@ def v22(c: Corpus, r: Result) -> None:
829
829
  if mode == "catalog":
830
830
  continue
831
831
  if mode not in MODES:
832
- r.fail("V22", pid, f"`mode: {mode}` bukan salah satu dari {list(MODES)}")
832
+ r.fail("V22", pid, f"`mode: {mode}` is not one of {list(MODES)}")
833
833
  continue
834
834
  passed = row.get("g4_passed")
835
835
  if not passed or str(passed).strip().lower() in ("false", "no", "belum"):
836
836
  r.fail("V22", f"{wave.get('id')} / {pid}",
837
- f"wave menyentuh komponen ber-mode `{mode}` yang `g4_passed`-nya belum diisi")
837
+ f"wave touches a component with mode `{mode}` whose `g4_passed` has not been set")
838
838
 
839
839
 
840
840
  def v23(c: Corpus, r: Result) -> None:
841
- """`risk_accepted: high` pada komponen sensitif menuntut sebuah `DEC-` di `risk_accepted_by`.
841
+ """`risk_accepted: high` on a sensitive component demands a `DEC-` in `risk_accepted_by`.
842
842
 
843
- Pada komponen yang tidak menyentuh apa pun dari daftar itu, `high` GRATIS. Kontrolnya
844
- pengungkapan, bukan veto — pemilik tetap boleh memilih cepat, tapi tidak tanpa tahu apa yang ia
845
- taruhkan.
843
+ On a component that touches nothing on that list, `high` is FREE. The control is
844
+ disclosure, not veto — the owner may still choose quickly, just not without knowing what
845
+ they are wagering.
846
846
  """
847
847
  known = {str(x.get("id")) for x in c.decs}
848
848
  for pc in c.pcs:
@@ -856,10 +856,10 @@ def v23(c: Corpus, r: Result) -> None:
856
856
  ref = str(pc.get("risk_accepted_by") or "").strip()
857
857
  if not ref:
858
858
  r.fail("V23", pid,
859
- f"`risk_accepted: high` sementara `risk_note` menyebut {hits}, tanpa "
860
- f"`risk_accepted_by` menunjuk sebuah `DEC-` bertipe risk-acceptance")
859
+ f"`risk_accepted: high` while `risk_note` mentions {hits}, without "
860
+ f"`risk_accepted_by` pointing to a risk-acceptance `DEC-`")
861
861
  elif ref not in known:
862
- r.fail("V23", pid, f"`risk_accepted_by: {ref}` tidak ada di decisions.yaml")
862
+ r.fail("V23", pid, f"`risk_accepted_by: {ref}` does not exist in decisions.yaml")
863
863
 
864
864
 
865
865
  def v20(c: Corpus, r: Result) -> None:
@@ -870,24 +870,25 @@ def v20(c: Corpus, r: Result) -> None:
870
870
  if cause not in needs_link:
871
871
  continue
872
872
  if not listy(defect, "violates"):
873
- r.fail("V20", did, f"ber-root_cause `{cause}` tetapi `violates` kosong")
873
+ r.fail("V20", did, f"has `root_cause` `{cause}` but `violates` is empty")
874
874
  if str(defect.get("status")) == "fixed" and not str(defect.get("decision") or "").strip():
875
875
  r.fail("V20", did,
876
- f"ditutup sebagai fixed dengan root_cause `{cause}` tanpa `DEC-` yang menyertainya")
876
+ f"closed as fixed with root_cause `{cause}` without an accompanying `DEC-`")
877
877
 
878
878
 
879
- # Berkas yang MENGGAMBARKAN masa lalu, bukan MENYATAKAN apa yang berlaku. Kutipan menggantung di sini
880
- # bukan temuan — corpus-guide.md memiliki aturannya, dan menulisnya ulang akan memalsukan riwayat.
879
+ # Files that DESCRIBE the past, not STATE what currently holds. A dangling citation here is
880
+ # not a finding — corpus-guide.md owns that rule, and rewriting it would falsify history.
881
881
  PAST_RECORD = (
882
882
  ".control/memlog/",
883
883
  ".control/decisions/",
884
884
  ".control/questions/answered.md",
885
885
  ".control/reports/",
886
886
  )
887
- # Korpus yang §25 bekukan apa adanya. Kutipannya ke prototipe yang sudah dipensiunkan disahkan DEC-016.
887
+ # Corpus that §25 freezes as-is. Its citation of a now-retired prototype is authorized by DEC-016.
888
888
  FROZEN = (".what/",)
889
- # Path yang sebuah run LAHIRKAN, bukan yang sebuah dokumen kutip. Aturan yang menyatakan "memlog pass
890
- # ini mendarat di X" menyebut TUJUAN; menuntut X sudah ada berarti menuntut run-nya sudah jalan.
889
+ # A path a run WILL PRODUCE, not one a document cites as existing. A rule stating "this pass's
890
+ # memlog lands at X" names a DESTINATION; demanding X already exist would demand the run has already
891
+ # happened.
891
892
  DESTINATION = (
892
893
  ".control/memlog/",
893
894
  ".control/meetings/",
@@ -901,16 +902,16 @@ CITE_RE = re.compile(
901
902
 
902
903
 
903
904
  def v24(c: Corpus, r: Result) -> None:
904
- """Kutipan path di dalam dokumen yang MENYATAKAN apa yang berlaku MUST resolve.
905
+ """A path citation inside a document that STATES what currently holds MUST resolve.
905
906
 
906
- Ini paruh mekanis dari Evidence check `wdi-reconcile`, dan ia satu-satunya cara mengetahui bahwa
907
- sebuah migrasi tetap tuntas. Kelas kegagalannya khas: sebuah berkas dihapus atau dipindahkan,
908
- sementara baris routing yang menunjuk ke arahnya tinggal tidak ada satu pun validator lain yang
909
- melihatnya, sebab tidak ada id yang bergerak.
907
+ This is the mechanical half of `wdi-reconcile`'s Evidence check, and it is the only way to know
908
+ that a migration stayed complete. Its failure class is distinctive: a file gets deleted or moved,
909
+ while the routing line that points at it stays behind no other validator sees it, because no
910
+ id moved.
910
911
 
911
- Yang DILEWATI dengan sengaja: berkas yang menggambarkan masa lalu, dan korpus yang dibekukan.
912
- Sebuah Trace `DEC-` yang menyebut bahan yang sudah dipensiunkan menggambarkan apa yang dibaca pada
913
- tanggal itu; melaporkannya akan menuntut riwayat ditulis ulang agar cocok dengan masa kini.
912
+ Deliberately SKIPPED: files that describe the past, and corpus that has been frozen. A `DEC-`
913
+ Trace that names material that has since been retired describes what was read on that date;
914
+ reporting it would demand history be rewritten to match the present.
914
915
  """
915
916
  scanned = 0
916
917
  for path in sorted(c.root.rglob("*.md")) + sorted(c.root.rglob("*.yaml")):
@@ -923,20 +924,20 @@ def v24(c: Corpus, r: Result) -> None:
923
924
  text = path.read_text(encoding="utf-8", errors="replace")
924
925
  for cited in sorted(set(CITE_RE.findall(text))):
925
926
  if "<" in cited or "{" in cited:
926
- continue # placeholder, bukan path
927
+ continue # placeholder, not a path
927
928
  if cited.startswith(DESTINATION):
928
929
  continue
929
930
  if not (c.root / cited).exists():
930
- r.fail("V24", rel, f"mengutip `{cited}` yang tidak ada")
931
+ r.fail("V24", rel, f"cites `{cited}` which does not exist")
931
932
  if not scanned:
932
- r.skip("V24", "tidak ada berkas yang dipindai")
933
+ r.skip("V24", "no file was scanned")
933
934
 
934
935
 
935
936
  CTR_HEADING = re.compile(r"^###\s+(.+?)\s*$", re.M)
936
937
 
937
938
 
938
939
  def map_container_headings(root: Path) -> list[str] | None:
939
- """Heading `### x` di bawah `## Containers` pada peta kode. None bila petanya tidak ada."""
940
+ """Heading `### x` under `## Containers` in the code map. None if the map does not exist."""
940
941
  path = root / ".control" / "structure-codebase.md"
941
942
  if not path.exists():
942
943
  return None
@@ -952,70 +953,70 @@ def map_container_headings(root: Path) -> list[str] | None:
952
953
 
953
954
 
954
955
  def v25(c: Corpus, r: Result) -> None:
955
- """`built` sebuah container dan keempat konsekuensinya, plus matriks PC x container.
956
+ """A container's `built` and its four consequences, plus the PC x container matrix.
956
957
 
957
- Sebuah container ADA di dalam boundary entah kita yang menulis isinya atau bukan, dan itulah yang
958
- dulu membuat aturannya tak bisa dipenuhi: `structure-guide.md` menuntut tiap heading peta kode cocok
959
- dengan registry, sementara basis data dan web server MUST terdaftar dan MUST NOT punya heading.
960
- `built` memisahkan keduanya, dan pemeriksaan ini yang membuat pemisahan itu berlaku alih-alih
961
- diulang argumennya tiap proyek. `DEC-017` merekam definisinya.
958
+ A container EXISTS inside the boundary whether or not we write its content, and that is what
959
+ used to make the rule unsatisfiable: `structure-guide.md` demands every code-map heading match
960
+ the registry, while a database or web server MUST be registered and MUST NOT have a heading.
961
+ `built` separates the two, and this check is what makes that separation hold instead of the
962
+ argument being repeated on every project. `DEC-017` records its definition.
962
963
 
963
- Yang runtime-nya bukan kita yang deploy adalah external system: ia hidup di C4 L1 dan MUST NOT
964
- terdaftar di sini sama sekaliketiadaannya di registry itulah pemeriksaannya.
964
+ Anything whose runtime we do not deploy is an external system: it lives in C4 L1 and MUST NOT
965
+ be registered here at allits absence from the registry is the check.
965
966
  """
966
967
  containers = rows(c.components, "containers")
967
968
  if not containers:
968
- r.skip("V25", "`containers:` belum terdaftar")
969
+ r.skip("V25", "`containers:` is not registered yet")
969
970
  return
970
971
 
971
972
  built: dict[str, bool] = {}
972
973
  for ctr in containers:
973
974
  cid = str(ctr.get("id") or "").strip()
974
975
  if not cid:
975
- r.fail("V25", "containers", "ada container tanpa `id`")
976
+ r.fail("V25", "containers", "a container has no `id`")
976
977
  continue
977
978
  flag = ctr.get("built")
978
979
  if not isinstance(flag, bool):
979
- r.fail("V25", cid, "`built` MUST bool — true bila isinya kita tulis, false bila implementasinya orang lain")
980
+ r.fail("V25", cid, "`built` MUST be a bool — true if we write its content, false if someone else implements it")
980
981
  continue
981
982
  built[cid] = flag
982
983
 
983
- # (1) heading peta kode = TEPAT container `built: true`
984
+ # (1) code-map heading = EXACTLY a container with `built: true`
984
985
  headings = map_container_headings(c.root)
985
986
  if headings is None:
986
- r.fail("V25", ".control/structure-codebase.md", "peta kode tidak ada, jadi heading container tidak dapat diadu")
987
+ r.fail("V25", ".control/structure-codebase.md", "the code map does not exist, so container headings cannot be compared")
987
988
  else:
988
989
  for h in headings:
989
990
  if h not in built:
990
- r.fail("V25", f"peta kode §{h}", "heading bukan container terdaftar daftarkan, atau ia bukan container")
991
+ r.fail("V25", f"code map §{h}", "heading is not a registered container — register it, or it is not a container")
991
992
  elif not built[h]:
992
- r.fail("V25", f"peta kode §{h}", "`built: false` MUST NOT punya heading — tidak ada kode kita di dalamnya")
993
+ r.fail("V25", f"code map §{h}", "`built: false` MUST NOT have a heading — there is no code of ours inside it")
993
994
  for cid, flag in sorted(built.items()):
994
995
  if flag and cid not in headings:
995
- r.fail("V25", cid, "`built: true` MUST punya heading di peta kode")
996
+ r.fail("V25", cid, "`built: true` MUST have a heading in the code map")
996
997
 
997
- # (2) `built: false` MUST NOT dipakai sebuah LC, dan (3) MUST NOT muncul di `containers:` sebuah PC
998
+ # (2) `built: false` MUST NOT be used by an LC, and (3) MUST NOT appear in a PC's `containers:`
998
999
  for lc in c.lcs:
999
1000
  ctr = str(lc.get("container") or "").strip()
1000
1001
  if ctr and built.get(ctr) is False:
1001
- r.fail("V25", str(lc.get("id") or "LC-?"), f"menyebut container `{ctr}` yang `built: false`")
1002
+ r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is `built: false`")
1002
1003
  elif ctr and ctr not in built:
1003
- r.fail("V25", str(lc.get("id") or "LC-?"), f"menyebut container `{ctr}` yang tidak terdaftar")
1004
+ r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is not registered")
1004
1005
 
1005
- # (4) matriks PC x container — SSOT-nya field ini, dan ia MUST lengkap di G3
1006
+ # (4) PC x container matrix this field is its SSOT, and it MUST be complete at G3
1006
1007
  for pc in c.pcs:
1007
1008
  pid = str(pc.get("id") or "?")
1008
1009
  listed = listy(pc, "containers")
1009
1010
  if not listed:
1010
- r.fail("V25", pid, "`containers:` kosongtiap PC MUST hidup di setidaknya satu container (utang G3)")
1011
+ r.fail("V25", pid, "`containers:` is empty every PC MUST live in at least one container (a G3 debt)")
1011
1012
  continue
1012
1013
  for ctr in listed:
1013
1014
  if ctr not in built:
1014
- r.fail("V25", pid, f"`containers:` menyebut `{ctr}` yang tidak terdaftar")
1015
+ r.fail("V25", pid, f"`containers:` names `{ctr}` which is not registered")
1015
1016
  elif not built[ctr]:
1016
- r.fail("V25", pid, f"`containers:` menyebut `{ctr}` yang `built: false` — data hidup di sana menurut definisi, jadi barisnya tidak memberi tahu apa pun")
1017
+ r.fail("V25", pid, f"`containers:` names `{ctr}` which is `built: false` — the data lives there by definition, so the row tells us nothing")
1017
1018
 
1018
- # (5) L3 — hanya untuk `built: true`, dan hanya yang memuat lebih dari satu PC
1019
+ # (5) L3 — only for `built: true`, and only ones that hold more than one PC
1019
1020
  pcs_per: dict[str, list[str]] = {}
1020
1021
  for pc in c.pcs:
1021
1022
  for ctr in listy(pc, "containers"):
@@ -1024,40 +1025,41 @@ def v25(c: Corpus, r: Result) -> None:
1024
1025
  cid = path.name[len("c4-l3-"):-len(".md")]
1025
1026
  if cid not in built:
1026
1027
  r.fail("V25", path.relative_to(c.root).as_posix(),
1027
- f"L3 untuk `{cid}` yang bukan container terdaftar")
1028
+ f"L3 for `{cid}` which is not a registered container")
1028
1029
  elif not built[cid]:
1029
1030
  r.fail("V25", path.relative_to(c.root).as_posix(),
1030
- f"`{cid}` `built: false` MUST NOT punya L3 — tak satu kotak di dalamnya kita yang gambar")
1031
+ f"`{cid}` `built: false` MUST NOT have an L3 — not one box inside it is ours to draw")
1031
1032
  for cid, pids in sorted(pcs_per.items()):
1032
1033
  if built.get(cid) and len(pids) > 1:
1033
1034
  l3 = c.root / ".how" / "_platform" / f"c4-l3-{cid}.md"
1034
1035
  if not l3.exists():
1035
- r.fail("V25", cid, f"memuat {len(pids)} PC, jadi `c4-l3-{cid}.md` MUST ada")
1036
+ r.fail("V25", cid, f"holds {len(pids)} PCs, so `c4-l3-{cid}.md` MUST exist")
1036
1037
 
1037
1038
 
1038
1039
  UC_ROW_RE = re.compile(r"^\|\s*(UC-\d+)\s*\|([^\n]*)$", re.M)
1039
1040
 
1040
- # Nilai kolom `critical` dicocokkan mesin, jadi ia machine-facing dan bentuk kanoniknya English `yes`.
1041
- # `ya` tetap diterima: sebuah korpus yang menulisnya sebelum aturan ini berlaku MUST NOT dipaksa migrasi
1042
- # hanya supaya sebuah regex lebih rapi. Batas kata mencegah `ya` mencocoki kata lain.
1041
+ # The `critical` column value is machine-matched, so it is machine-facing and its canonical form
1042
+ # is English `yes`. `ya` is still accepted: a corpus that wrote it before this rule took effect
1043
+ # MUST NOT be forced to migrate just so a regex can be tidier. The word boundary keeps `ya` from
1044
+ # matching inside other words.
1043
1045
  CRITICAL_YES = re.compile(r"\b(yes|ya)\b", re.I)
1044
1046
 
1045
1047
 
1046
1048
  def v26(c: Corpus, r: Result) -> None:
1047
- """Katalog UC di tiap SRS MUST sepakat dengan `usecases.yaml` — id-nya DAN `critical`-nya.
1049
+ """The UC catalogue in every SRS MUST agree with `usecases.yaml` — both its id AND its `critical`.
1048
1050
 
1049
- Ini celah yang paling mahal dari semua yang ditutup lintasan ini, sebab ia satu-satunya yang
1050
- **sudah terjadi dan tidak satu pun validator melihatnya.** Step 16 menurunkan ulang `critical`
1051
- di registry dengan definisi yang dipersempit uang, data pribadi, tindakan tak-terbalikkandan
1052
- ketujuh tabel katalog di SRS tidak ikut. Dua puluh enam baris berselisih, dan selisihnya baru
1053
- ketahuan ketika seorang manusia membaca kalimat "sembilan di antaranya critical" di SRS-admin
1054
- sementara registry menyimpan tiga.
1051
+ This is the most expensive gap this pass closes, because it is the only one that **had already
1052
+ happened and no validator saw it.** Step 16 re-derived `critical` in the registry with a
1053
+ narrowed definitionmoney, personal data, irreversible actionand the seven catalogue tables
1054
+ in the SRS did not follow along. Twenty-six rows disagreed, and the disagreement was only
1055
+ discovered when a human read the sentence "nine of these are critical" in SRS-admin while the
1056
+ registry held three.
1055
1057
 
1056
- Registry-nya SSOT. Tabel di SRS adalah rumah permanen katalog untuk seorang pembaca, dan dua rumah
1057
- untuk satu fakta hanya aman kalau ada yang mengadu keduanya. Ini yang mengadu.
1058
+ The registry is the SSOT. The table in the SRS is the catalogue's permanent home for a reader,
1059
+ and two homes for one fact are only safe if something compares them. This is what compares them.
1058
1060
 
1059
- Yang TIDAK diperiksa di sini: judul dan aktor. Keduanya prosa, dan prosa yang berbeda kata bukan
1060
- prosa yang berbeda artimengadunya akan melaporkan gaya sebagai cacat.
1061
+ What is NOT checked here: title and actor. Both are prose, and prose with different words is
1062
+ not prose with a different meaning comparing them would report style as a defect.
1061
1063
  """
1062
1064
  reg = {str(uc.get("id")): bool(uc.get("critical")) for uc in c.ucs}
1063
1065
  reg_pc = {str(uc.get("id")): str(uc.get("component") or "") for uc in c.ucs}
@@ -1077,11 +1079,11 @@ def v26(c: Corpus, r: Result) -> None:
1077
1079
  continue
1078
1080
  seen.add(uid)
1079
1081
  if uid not in reg:
1080
- r.fail("V26", f"{pid}/{uid}", "ada di katalog SRS tetapi tidak di `usecases.yaml`")
1082
+ r.fail("V26", f"{pid}/{uid}", "is in the SRS catalogue but not in `usecases.yaml`")
1081
1083
  continue
1082
1084
  if reg_pc[uid] != pid:
1083
1085
  r.fail("V26", f"{pid}/{uid}",
1084
- f"registry menaruhnya di `{reg_pc[uid]}`, bukan di komponen ini")
1086
+ f"the registry places it in `{reg_pc[uid]}`, not in this component")
1085
1087
  marked = CRITICAL_YES.search(cells[3]) is not None
1086
1088
  if marked != reg[uid]:
1087
1089
  r.fail("V26", f"{pid}/{uid}",
@@ -1089,58 +1091,58 @@ def v26(c: Corpus, r: Result) -> None:
1089
1091
  f"in the registry {'yes' if reg[uid] else 'no'}")
1090
1092
  for uid, owner in sorted(reg_pc.items()):
1091
1093
  if owner == pid and uid not in seen:
1092
- r.fail("V26", f"{pid}/{uid}", "ada di `usecases.yaml` tetapi tidak di katalog SRS")
1094
+ r.fail("V26", f"{pid}/{uid}", "is in `usecases.yaml` but not in the SRS catalogue")
1093
1095
  if not checked:
1094
- r.skip("V26", "tidak ada SRS yang dapat dibaca")
1096
+ r.skip("V26", "no SRS could be read")
1095
1097
 
1096
1098
 
1097
1099
  def v27(c: Corpus, r: Result) -> None:
1098
- """Tiap berkas di kamar custom MUST menyatakan dirinya, dan pembantahan MUST punya keputusan.
1100
+ """Every file in the custom room MUST declare itself, and a rebuttal MUST have a decision.
1099
1101
 
1100
- Kamar `.constitution/project/` ada supaya aturan khusus produk punya rumah yang `update` tidak
1101
- timpa dan `promote` tidak terbitkan. Ongkos yang datang bersamanya: ia juga tempat paling mudah
1102
- untuk melanggar aturan generic tanpa jejak. Frontmatter-nya yang menahan itu.
1102
+ The `.constitution/project/` room exists so product-specific rules have a home that `update`
1103
+ does not overwrite and `promote` does not publish. The cost that comes with it: it is also the
1104
+ easiest place to break a generic rule without a trace. Its frontmatter is what holds that back.
1103
1105
 
1104
- Sebuah berkas di sini MAY mempersempit atau menambah tanpa menyebut apa pun. Untuk MEMBANTAH
1105
- aturan generic ia MUST menyebutnya di `overrides:` dan membawa `decision:` — sebab metode yang
1106
- boleh dibantah tanpa keputusan berhenti dapat dipercaya di repo berikutnya.
1106
+ A file here MAY narrow or add without naming anything. To REBUT a generic rule it MUST name it
1107
+ in `overrides:` and carry a `decision:` — because a method that can be rebutted without a
1108
+ decision stops being trustworthy in the next repo.
1107
1109
 
1108
- `README.md` kamar dilewati: ia dikarang di paket, bukan di produk.
1110
+ The room's `README.md` is skipped: it is authored in the package, not in the product.
1109
1111
  """
1110
1112
  room = c.root / ".constitution" / "project"
1111
1113
  if not room.is_dir():
1112
- r.skip("V27", "kamar `.constitution/project/` belum adaia disemai saat install")
1114
+ r.skip("V27", "the `.constitution/project/` room does not exist yet it is seeded at install")
1113
1115
  return
1114
1116
  files = [p for p in sorted(room.rglob("*.md")) if p.name != "README.md"]
1115
1117
  if not files:
1116
- r.skip("V27", "kamar `.constitution/project/` kosong, dan itu keadaan yang sah — "
1117
- "aturan generic MUST NOT dipindahkan ke sini supaya kamarnya terpakai")
1118
+ r.skip("V27", "the `.constitution/project/` room is empty, and that is a valid state — "
1119
+ "a generic rule MUST NOT be moved here just to give the room content")
1118
1120
  return
1119
1121
  dec_ids = {str(d.get("id")) for d in c.decs}
1120
1122
  for path in files:
1121
1123
  rel = path.relative_to(c.root).as_posix()
1122
1124
  fm = frontmatter(path)
1123
1125
  if fm is None:
1124
- r.fail("V27", rel, "tidak punya frontmatter")
1126
+ r.fail("V27", rel, "has no frontmatter")
1125
1127
  continue
1126
1128
  if str(fm.get("scope") or "").strip() != "project":
1127
- r.fail("V27", rel, "`scope:` MUST berisi tepat `project`")
1129
+ r.fail("V27", rel, "`scope:` MUST contain exactly `project`")
1128
1130
  if not str(fm.get("purpose") or "").strip():
1129
- r.fail("V27", rel, "`purpose:` kosongsatu baris: aturan ini menjaga apa")
1131
+ r.fail("V27", rel, "`purpose:` is empty one line: what this rule guards")
1130
1132
  over = str(fm.get("overrides") or "").strip()
1131
1133
  dec = str(fm.get("decision") or "").strip()
1132
1134
  if over:
1133
1135
  if not (c.root / over).exists():
1134
- r.fail("V27", rel, f"`overrides:` menunjuk `{over}` yang tidak ada — "
1135
- f"aturan yang dibantah mungkin sudah hilang")
1136
+ r.fail("V27", rel, f"`overrides:` points to `{over}` which does not exist — "
1137
+ f"the rebutted rule may already be gone")
1136
1138
  if not dec:
1137
- r.fail("V27", rel, "membantah aturan generic tanpa `decision:` — "
1138
- "pembantahan MUST punya `DEC-` yang memutuskannya")
1139
+ r.fail("V27", rel, "rebuts a generic rule without `decision:` — "
1140
+ "a rebuttal MUST have a `DEC-` that decided it")
1139
1141
  elif dec not in dec_ids:
1140
- r.fail("V27", rel, f"`decision: {dec}` tidak terdaftar di decisions.yaml")
1142
+ r.fail("V27", rel, f"`decision: {dec}` is not registered in decisions.yaml")
1141
1143
  elif dec:
1142
- r.fail("V27", rel, "`decision:` terisi tanpa `overrides:` — "
1143
- "sebutkan aturan mana yang dibantah, atau cabut `decision:`")
1144
+ r.fail("V27", rel, "`decision:` is set without `overrides:` — "
1145
+ "name which rule is rebutted, or drop `decision:`")
1144
1146
 
1145
1147
 
1146
1148
  def run_checks(c: Corpus, asof: dt.date) -> Result:
@@ -1204,7 +1206,7 @@ def gen_dag(c: Corpus) -> dict:
1204
1206
  waves_out = []
1205
1207
  while pending:
1206
1208
  ready = sorted(k for k, deps in pending.items() if not (deps - done))
1207
- if not ready: # siklus — V7 sudah melaporkannya
1209
+ if not ready: # cycle — V7 has already reported it
1208
1210
  waves_out.append({"blocked": sorted(pending)})
1209
1211
  break
1210
1212
  waves_out.append({"parallel": ready})
@@ -1278,25 +1280,25 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
1278
1280
  done = sum(1 for s in items if _story_status(c, s) == "done")
1279
1281
  per_wave.append({"wave": wid, "status": wave.get("status"),
1280
1282
  "stories_done": done, "stories_total": len(items),
1281
- "progres_kerja": _pct(done, len(items))})
1282
- applicable = 26 # V1..V27 tanpa V10 yang gugur
1283
+ "work_progress": _pct(done, len(items))})
1284
+ applicable = 26 # V1..V27 minus V10, which was retired
1283
1285
  return {
1284
- "progres_janji": _pct(green, len(counted)),
1285
- "baris_rtm": {"hijau": green, "dihitung": len(counted),
1286
- "dikecualikan_no_uc": exempt},
1287
- "progres_kerja": per_wave,
1288
- "kesiapan_gate": _pct(applicable - len(result.red), applicable),
1289
- "validator_merah": result.red,
1290
- "validator_dilewati": dict(sorted(result.skipped.items())),
1291
- "pertanyaan_terbuka": _question_budget(c),
1286
+ "promise_progress": _pct(green, len(counted)),
1287
+ "rtm_rows": {"green": green, "counted": len(counted),
1288
+ "excluded_no_uc": exempt},
1289
+ "work_progress": per_wave,
1290
+ "gate_readiness": _pct(applicable - len(result.red), applicable),
1291
+ "validators_red": result.red,
1292
+ "validators_skipped": dict(sorted(result.skipped.items())),
1293
+ "open_questions": _question_budget(c),
1292
1294
  }
1293
1295
 
1294
1296
 
1295
1297
  def _question_budget(c: Corpus) -> dict:
1296
- """Hitungan keempat daftar pertanyaan, diadu dengan jatah di index.yaml.
1298
+ """Counts of all four question lists, compared against the budget in index.yaml.
1297
1299
 
1298
- Jatahnya BUKAN pagar keras. Ia yang dilaporkan ketika sebuah batch melewatinya, karena batch
1299
- yang lebih besar adalah sinyal tentang lintasannya, bukan tentang korpus.
1300
+ The budget is NOT a hard gate. It is reported when a batch exceeds it, because a larger
1301
+ batch is a signal about the pass, not about the corpus.
1300
1302
  """
1301
1303
  budget = c.index.get("question_budget") or {}
1302
1304
  out: dict[str, object] = {}
@@ -1310,11 +1312,11 @@ def _question_budget(c: Corpus) -> dict:
1310
1312
  cap_block = budget.get("blocking_per_component")
1311
1313
  if cap_block and c.pcs:
1312
1314
  allowed = int(cap_block) * len(c.pcs)
1313
- out["blocking_jatah"] = allowed
1314
- out["blocking_lewat_jatah"] = out["blocking"] > allowed
1315
+ out["blocking_budget"] = allowed
1316
+ out["blocking_over_budget"] = out["blocking"] > allowed
1315
1317
  cap_assume = budget.get("assumptions_per_gate")
1316
1318
  if cap_assume:
1317
- out["assumptions_jatah_per_gate"] = int(cap_assume)
1319
+ out["assumptions_budget_per_gate"] = int(cap_assume)
1318
1320
  return out
1319
1321
 
1320
1322
 
@@ -1325,19 +1327,19 @@ def _pct(part: int, total: int) -> str:
1325
1327
  def as_markdown(name: str, payload: dict) -> str:
1326
1328
  body = dump(payload)
1327
1329
  return (f"# {name}\n\n"
1328
- f"> Tergenerate oleh `.constitution/scripts/validate --generate`. "
1329
- f"MUST NOT diedit tangan.\n\n"
1330
+ f"> Generated by `.constitution/scripts/validate --generate`. "
1331
+ f"MUST NOT be hand-edited.\n\n"
1330
1332
  f"```yaml\n{body}```\n")
1331
1333
 
1332
1334
 
1333
- # ------------------------------------------------------- halaman untuk manusia
1335
+ # ------------------------------------------------------- pages for humans
1334
1336
 
1335
- PAGE_HEADER = ("> Tergenerate oleh `.constitution/scripts/validate --generate`. "
1336
- "MUST NOT diedit tangan.\n")
1337
+ PAGE_HEADER = ("> Generated by `.constitution/scripts/validate --generate`. "
1338
+ "MUST NOT be hand-edited.\n")
1337
1339
 
1338
1340
 
1339
1341
  def _section(path: Path, heading: str) -> str:
1340
- """Ambil satu bagian `## <heading>` dari sebuah berkas markdown, apa adanya."""
1342
+ """Extract one `## <heading>` section from a markdown file, as-is."""
1341
1343
  if not path.exists():
1342
1344
  return ""
1343
1345
  lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
@@ -1355,7 +1357,7 @@ def _section(path: Path, heading: str) -> str:
1355
1357
 
1356
1358
 
1357
1359
  def _body(path: Path) -> str:
1358
- """Isi berkas tanpa frontmatter dan tanpa komentar template."""
1360
+ """File content without frontmatter and without template comments."""
1359
1361
  if not path.exists():
1360
1362
  return ""
1361
1363
  text = path.read_text(encoding="utf-8", errors="replace")
@@ -1370,8 +1372,8 @@ def _body(path: Path) -> str:
1370
1372
 
1371
1373
 
1372
1374
  def page_decisions(c: Corpus) -> str:
1373
- """Tabel rata seluruh `DEC-`. Ini yang menggantikan mencari keputusan lewat memlog."""
1374
- rows_out = ["| id | Judul | Status | Tipe | Menyentuh | Berkas |",
1375
+ """Flat table of every `DEC-`. This is what replaces looking up decisions through the memlog."""
1376
+ rows_out = ["| id | Title | Status | Type | Touches | File |",
1375
1377
  "| --- | --- | --- | --- | --- | --- |"]
1376
1378
  for dec in c.decs:
1377
1379
  touches = ", ".join(f"`{x}`" for x in listy(dec, "touches")) or "—"
@@ -1382,65 +1384,65 @@ def page_decisions(c: Corpus) -> str:
1382
1384
  for dec in c.decs:
1383
1385
  key = str(dec.get("status"))
1384
1386
  counts[key] = counts.get(key, 0) + 1
1385
- tally = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "belum ada keputusan"
1387
+ tally = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "no decisions yet"
1386
1388
  return ("# decisions\n\n" + PAGE_HEADER +
1387
- "\nMencari keputusan tidak lagi lewat memlog — memlog kembali jadi log lintasan saja.\n"
1388
- f"\n**{len(c.decs)} keputusan** — {tally}.\n\n" + "\n".join(rows_out) + "\n")
1389
+ "\nDecisions are no longer looked up through the memlog — the memlog goes back to being just a pass log.\n"
1390
+ f"\n**{len(c.decs)} decisions** — {tally}.\n\n" + "\n".join(rows_out) + "\n")
1389
1391
 
1390
1392
 
1391
1393
  def page_blueprint(c: Corpus) -> str:
1392
- """Roll-up satu halaman yang di-review di G3. Tujuh berkas jadi satu bacaan.
1394
+ """One-page roll-up reviewed at G3. Seven files become one read.
1393
1395
 
1394
- Katalog UC, daftar aktor, dan model domain tetap tinggal di kernel komponennya masing-masing
1395
- sebagai rumah permanennya. Ini tampilannya. Satu fakta, satu rumah, satu tampilan.
1396
+ The UC catalogue, actor list, and domain model stay put in their own component's kernel as
1397
+ their permanent home. This is their view. One fact, one home, one view.
1396
1398
  """
1397
1399
  parts = ["# blueprint\n", PAGE_HEADER,
1398
- "\nIni yang dibaca pemilik di **G3 Blueprint**, bukan tujuh berkas. Isinya tidak "
1399
- "dipengaruhi `mode` maupun `risk_accepted`.\n"]
1400
+ "\nThis is what the owner reads at **G3 Blueprint**, instead of seven files. Its "
1401
+ "content is affected by neither `mode` nor `risk_accepted`.\n"]
1400
1402
 
1401
1403
  crit = sum(1 for uc in c.ucs if uc.get("critical"))
1402
- parts.append(f"\n## Katalog use case\n\n**{len(c.ucs)} use case**, {crit} bertanda "
1404
+ parts.append(f"\n## Use case catalogue\n\n**{len(c.ucs)} use cases**, {crit} marked "
1403
1405
  f"`critical`.\n")
1404
- parts.append("| id | Use case | Komponen | Memenuhi | critical |")
1406
+ parts.append("| id | Use case | Component | Satisfies | critical |")
1405
1407
  parts.append("| --- | --- | --- | --- | --- |")
1406
1408
  for uc in c.ucs:
1407
1409
  sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
1408
- flag = "ya" if uc.get("critical") else "tidak"
1410
+ flag = "yes" if uc.get("critical") else "no"
1409
1411
  parts.append(f"| `{uc.get('id')}` | {_cell(uc.get('title'))} | "
1410
1412
  f"`{uc.get('component', '')}` | {sat} | {flag} |")
1411
1413
 
1412
- parts.append("\n## Daftar aktor\n")
1414
+ parts.append("\n## Actor list\n")
1413
1415
  for pc in c.pcs:
1414
1416
  pid = str(pc.get("id"))
1415
1417
  block = _section(c.root / f".what/{pid}/SRS-{pid}.md", "Actor Register")
1416
1418
  parts.append(f"\n### {pid} — {pc.get('name', '')}\n")
1417
1419
  parts.append(_demote(block) if block
1418
- else "_belum ada § Actor Register di SRS komponen ini._")
1420
+ else "_no § Actor Register in this component's SRS yet._")
1419
1421
 
1420
- parts.append("\n## Model domain\n")
1422
+ parts.append("\n## Domain model\n")
1421
1423
  for pc in c.pcs:
1422
1424
  pid = str(pc.get("id"))
1423
1425
  block = _body(c.root / f".what/{pid}/03-domain/domain-model.md")
1424
1426
  parts.append(f"\n### {pid}\n")
1425
- parts.append(_demote(block) if block else "_belum ada `03-domain/domain-model.md`._")
1427
+ parts.append(_demote(block) if block else "_no `03-domain/domain-model.md` yet._")
1426
1428
 
1427
- parts.append("\n## Tiga inventaris\n")
1428
- for kind, name in (("db", "tabel"), ("api", "endpoint"), ("screen", "layar")):
1429
+ parts.append("\n## Three inventories\n")
1430
+ for kind, name in (("db", "table"), ("api", "endpoint"), ("screen", "screen")):
1429
1431
  block = _body(c.root / f".how/_platform/inventory-{kind}.md")
1430
- parts.append(f"\n### Daftar {name} — `inventory-{kind}.md`\n")
1431
- parts.append(_demote(block) if block else f"_belum ada `inventory-{kind}.md`._")
1432
+ parts.append(f"\n### List of {name}s — `inventory-{kind}.md`\n")
1433
+ parts.append(_demote(block) if block else f"_no `inventory-{kind}.md` yet._")
1432
1434
 
1433
1435
  return "\n".join(parts) + "\n"
1434
1436
 
1435
1437
 
1436
1438
  def _cell(value: object, limit: int = 110) -> str:
1437
- """Satu baris tabel, dipendekkan. Sumber panjangnya tetap di registry — ini tampilan."""
1439
+ """One table row, shortened. The full-length source stays in the registry — this is just a view."""
1438
1440
  text = " ".join(str(value or "").split()).replace("|", "\\|")
1439
1441
  return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
1440
1442
 
1441
1443
 
1442
1444
  def _demote(block: str, by: int = 2) -> str:
1443
- """Turunkan tingkat heading isi yang di-inline, supaya ia tidak menabrak kerangka roll-up."""
1445
+ """Demote the heading level of inlined content, so it does not clash with the roll-up's own structure."""
1444
1446
  out = []
1445
1447
  for line in block.splitlines():
1446
1448
  stripped = line.lstrip()
@@ -1453,7 +1455,7 @@ def _demote(block: str, by: int = 2) -> str:
1453
1455
 
1454
1456
 
1455
1457
  def page_estimate(c: Corpus) -> str:
1456
- """Tabel task KANDIDAT. Satu baris per `FR`, karena itu bentuk ideal sebuah wave."""
1458
+ """Table of CANDIDATE tasks. One row per `FR`, since that is a wave's ideal shape."""
1457
1459
  mode_of = {str(pc.get("id")): c.mode_of(pc) for pc in c.pcs}
1458
1460
  risk_of = {str(pc.get("id")): (str(pc.get("risk_accepted") or "—"),
1459
1461
  str(pc.get("risk_note") or "—")) for pc in c.pcs}
@@ -1465,22 +1467,22 @@ def page_estimate(c: Corpus) -> str:
1465
1467
 
1466
1468
  have_mandays = any(x.get("estimate_mandays") for x in c.caps)
1467
1469
  parts = ["# estimate\n", PAGE_HEADER,
1468
- "\n**INI ESTIMASI, MENGHADAP KE DEPAN.** Tiap baris di bawah adalah task "
1469
- "**kandidat**; wave di `waves.yaml` yang nyata. Satu baris MAY jadi satu wave, dan tiga "
1470
- "baris bertetangga MAY digabung jadi satupenggabungan itu keputusan manusia saat wave "
1471
- "dibuka.\n"]
1470
+ "\n**THIS IS AN ESTIMATE, FORWARD-LOOKING.** Every row below is a **candidate** "
1471
+ "task; the wave in `waves.yaml` is the real one. One row MAY become one wave, and three "
1472
+ "neighboring rows MAY be merged into one that merge is a human decision made when the "
1473
+ "wave is opened.\n"]
1472
1474
  if not have_mandays:
1473
- parts.append("\n**Tanpa `estimate_mandays` pada satu pun `CAP`**, kolom Beban kosong dan "
1474
- "keluaran ini setara ukuran kelas T-shirt. Ia MUST dilaporkan sebagai itu.\n")
1475
+ parts.append("\n**With no `estimate_mandays` on a single `CAP`**, the Load column is empty and "
1476
+ "this output is only as good as a T-shirt-size estimate. It MUST be reported as such.\n")
1475
1477
 
1476
- parts.append("\n| Task | FR | Epic | mode | Paparan | Beban | Prioritas | Bergantung | Rilis |")
1478
+ parts.append("\n| Task | FR | Epic | mode | Exposure | Load | Priority | Depends on | Release |")
1477
1479
  parts.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
1478
1480
  for fr in c.frs:
1479
1481
  cap_id = str(fr.get("capability", ""))
1480
1482
  cap = cap_by_id.get(cap_id, {})
1481
1483
  pid = str(fr.get("component") or cap.get("component") or "")
1482
1484
  risk, note = risk_of.get(pid, ("—", "—"))
1483
- exposure = "belum disetel" if risk == "—" else f"`{risk}` — {_cell(note, 60)}"
1485
+ exposure = "not set yet" if risk == "—" else f"`{risk}` — {_cell(note, 60)}"
1484
1486
  mandays = cap.get("estimate_mandays")
1485
1487
  share = "—"
1486
1488
  if mandays:
@@ -1516,8 +1518,8 @@ def generate(c: Corpus, result: Result) -> list[Path]:
1516
1518
  md_path.write_text(as_markdown(name, payload), encoding="utf-8")
1517
1519
  written += [yaml_path, md_path]
1518
1520
 
1519
- # Tiga halaman untuk MANUSIA: tabel markdown sungguhan, tanpa kembar .yaml. Yang dibaca orang
1520
- # tidak dibungkus fence yaml, dan tidak ada pembaca mesin yang menuntut versi keduanya.
1521
+ # Three pages for HUMANS: real markdown tables, with no .yaml twin. What people read is
1522
+ # not wrapped in a yaml fence, and no machine reader demands a second version of it.
1521
1523
  for name, render in (("decisions", page_decisions),
1522
1524
  ("blueprint", page_blueprint),
1523
1525
  ("estimate", page_estimate)):
@@ -1532,15 +1534,15 @@ def generate(c: Corpus, result: Result) -> list[Path]:
1532
1534
 
1533
1535
  def main(argv: list[str] | None = None) -> int:
1534
1536
  parser = argparse.ArgumentParser(
1535
- prog="validate", description="V1..V27 dan generator .control/generated/")
1537
+ prog="validate", description="V1..V27 and the .control/generated/ generator")
1536
1538
  parser.add_argument("--check", action="store_true",
1537
- help="periksa saja; keluar non-zero bila ada yang merah")
1539
+ help="check only; exit non-zero if anything is red")
1538
1540
  parser.add_argument("--generate", action="store_true",
1539
- help="tulis ulang .control/generated/ (tetap memeriksa lebih dulu)")
1540
- parser.add_argument("--root", default=".", help="akar repo (default: direktori sekarang)")
1541
+ help="rewrite .control/generated/ (still runs the check first)")
1542
+ parser.add_argument("--root", default=".", help="repo root (default: current directory)")
1541
1543
  parser.add_argument("--asof", default=None,
1542
- help="tanggal acuan V14, format YYYY-MM-DD (default: hari ini). "
1543
- "Dinyatakan eksplisit supaya run bisa diulang persis")
1544
+ help="reference date for V14, format YYYY-MM-DD (default: today). "
1545
+ "Stated explicitly so a run can be repeated exactly")
1544
1546
  args = parser.parse_args(argv)
1545
1547
 
1546
1548
  if not args.check and not args.generate:
@@ -1548,7 +1550,7 @@ def main(argv: list[str] | None = None) -> int:
1548
1550
 
1549
1551
  root = Path(args.root).resolve()
1550
1552
  if not (root / ".control" / "registry").is_dir():
1551
- print(f"validate: {root} tidak punya .control/registry/ — salah akar repo?", file=sys.stderr)
1553
+ print(f"validate: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
1552
1554
  return 2
1553
1555
 
1554
1556
  asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
@@ -1557,21 +1559,21 @@ def main(argv: list[str] | None = None) -> int:
1557
1559
 
1558
1560
  if args.generate:
1559
1561
  for path in generate(corpus, result):
1560
- print(f" tulis {path.relative_to(root).as_posix()}")
1562
+ print(f" wrote {path.relative_to(root).as_posix()}")
1561
1563
 
1562
1564
  if result.findings:
1563
- print(f"\nMERAH — {len(result.findings)} temuan di {len(result.red)} validator\n")
1565
+ print(f"\nRED — {len(result.findings)} findings across {len(result.red)} validators\n")
1564
1566
  for finding in sorted(result.findings, key=lambda f: f.sort_key):
1565
1567
  print(f" {finding.vid:<4} {finding.subject}: {finding.message}")
1566
1568
  else:
1567
- print("\nHIJAUtidak ada temuan")
1569
+ print("\nGREENno findings")
1568
1570
 
1569
1571
  if result.skipped:
1570
- print("\nDilewati:")
1572
+ print("\nSkipped:")
1571
1573
  for vid, why in sorted(result.skipped.items()):
1572
1574
  print(f" {vid:<4} {why}")
1573
1575
 
1574
- print(f"\nacuan waktu V14: {asof.isoformat()}")
1576
+ print(f"\nV14 reference date: {asof.isoformat()}")
1575
1577
  return 1 if result.findings else 0
1576
1578
 
1577
1579