wdi-method 0.6.18 → 0.6.24

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 (34) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/LICENSE +21 -21
  3. package/NOTICE +28 -0
  4. package/README.id.md +190 -0
  5. package/README.ja.md +188 -0
  6. package/README.md +190 -530
  7. package/README.zh.md +188 -0
  8. package/bin/wdi-method.js +2199 -2112
  9. package/kit/.constitution/method/README.md +1 -0
  10. package/kit/.constitution/method/branch-guide.md +87 -0
  11. package/kit/.constitution/method/ci-guide.md +169 -0
  12. package/kit/.constitution/method/constitution.md +1 -0
  13. package/kit/.constitution/method/scripts/lifecycle.py +416 -0
  14. package/kit/.constitution/method/scripts/validate.py +126 -9
  15. package/kit/skills/wdi-autopilot/SKILL.md +461 -384
  16. package/kit/skills/wdi-build/SKILL.md +404 -393
  17. package/kit/skills/wdi-daily-autopilot/SKILL.md +138 -0
  18. package/kit/skills/wdi-daily-what-to-build/SKILL.md +155 -0
  19. package/kit/skills/wdi-daily-what-to-test/SKILL.md +127 -0
  20. package/kit/skills/wdi-explain-to-me/SKILL.md +1 -1
  21. package/kit/skills/wdi-help/SKILL.md +21 -7
  22. package/kit/skills/wdi-init/SKILL.md +16 -0
  23. package/kit/skills/wdi-prune-or-archive/SKILL.md +76 -0
  24. package/kit/skills/wdi-review/SKILL.md +4 -1
  25. package/kit-overlay/AGENTS.md +37 -4
  26. package/kit-overlay/README.md +1 -0
  27. package/kit-overlay/constitution.md +1 -0
  28. package/lib/identity.mjs +246 -117
  29. package/package.json +8 -4
  30. package/scaffold/.control/custom-dispatch.yaml.example +65 -0
  31. package/scaffold/.control/registry/index.yaml +9 -0
  32. package/scaffold/.control/test-targets/desktop.md +15 -0
  33. package/scaffold/.control/test-targets/mobile.md +6 -0
  34. package/scaffold/.control/test-targets/web.md +6 -0
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = ["pyyaml>=6"]
5
+ # ///
6
+ """lifecycle — manages spec lifecycle transitions: archive or prune closed specs.
7
+
8
+ Specs in WDI method have two lifecycle choices once closed:
9
+ - archive: git mv folder to .archive/specs/<spec>/ and update specs.yaml spec_folder
10
+ - prune: git rm -r folder, preserving specs.yaml metadata and RTM traceability
11
+
12
+ Active (open) specs MUST remain in .scratch/ where tickets are materialized
13
+ into git worktrees for implementation.
14
+
15
+ Preflight guards:
16
+ 1. Git working tree must be clean.
17
+ 2. Target spec must be `status: closed`.
18
+ 3. No memlog in .control/memlog/ may cite the target folder in `artifact:`.
19
+ 4. No active git worktree may be using the target folder path.
20
+ 5. Target path must reside inside repository and within allowed spec locations (.scratch/, _bmad-output/specs/).
21
+ 6. Archived specs in .archive/ cannot be pruned; --all-closed only archives/prunes specs in .scratch/.
22
+ 7. Post-execution, `validate.py --check` must pass; any failure triggers atomic git rollback.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ import yaml
35
+
36
+
37
+ def _run_cmd(cmd: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
38
+ return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace")
39
+
40
+
41
+ def check_git_clean(root: Path, dry_run: bool = False) -> None:
42
+ res = _run_cmd(["git", "status", "--porcelain"], root)
43
+ if res.returncode != 0:
44
+ sys.exit(f"error: failed to run git status (not a git repo?):\n{res.stderr.strip()}")
45
+ if res.stdout.strip():
46
+ if dry_run:
47
+ print("advisory: git working tree has uncommitted changes (ignored for --dry-run)")
48
+ else:
49
+ sys.exit("error: git working tree has uncommitted changes. Commit or stash them before running lifecycle operations.")
50
+
51
+
52
+ def check_worktree_collision(root: Path, target_folder: Path) -> None:
53
+ res = _run_cmd(["git", "worktree", "list", "--porcelain"], root)
54
+ if res.returncode != 0:
55
+ sys.exit(f"error: failed to query git worktree list (code {res.returncode}):\n{res.stderr.strip()}")
56
+ resolved_root = root.resolve()
57
+ resolved_target = target_folder.resolve()
58
+ for line in res.stdout.splitlines():
59
+ if line.startswith("worktree "):
60
+ wt_path = Path(line.removeprefix("worktree ").strip()).resolve()
61
+ if wt_path == resolved_root:
62
+ if resolved_target == resolved_root:
63
+ sys.exit(f"error: target path `{target_folder}` cannot be the repository root.")
64
+ continue
65
+ if wt_path == resolved_target or resolved_target in wt_path.parents or wt_path in resolved_target.parents:
66
+ sys.exit(f"error: target path `{target_folder}` is in use by active git worktree at `{wt_path}`.")
67
+
68
+
69
+ def check_source_boundary(root: Path, target_folder: Path, spec_id: str) -> None:
70
+ try:
71
+ rel = target_folder.resolve().relative_to(root.resolve()).as_posix()
72
+ except ValueError:
73
+ sys.exit(f"error: spec `{spec_id}` folder `{target_folder}` is outside repository root.")
74
+ if rel == "." or not rel:
75
+ sys.exit(f"error: spec `{spec_id}` folder cannot be the repository root.")
76
+ clean = rel.rstrip("/")
77
+ allowed_roots = (".scratch", "_bmad-output/specs", ".archive/specs")
78
+ if not any(clean == a or clean.startswith(f"{a}/") for a in allowed_roots):
79
+ sys.exit(
80
+ f"error: spec `{spec_id}` folder `{rel}` is not within an allowed spec location "
81
+ f"(.scratch/, _bmad-output/specs/, or .archive/specs/)."
82
+ )
83
+
84
+
85
+ def check_memlog_artifacts(root: Path, target_folder: Path) -> None:
86
+ memlog_dir = root / ".control" / "memlog"
87
+ if not memlog_dir.is_dir():
88
+ return
89
+ try:
90
+ rel_posix = target_folder.relative_to(root).as_posix().rstrip("/")
91
+ except ValueError:
92
+ rel_posix = str(target_folder).replace("\\", "/").rstrip("/")
93
+ rel_norm = rel_posix.lower()
94
+
95
+ for path in memlog_dir.rglob("*.md"):
96
+ try:
97
+ content = path.read_text(encoding="utf-8", errors="replace")
98
+ except Exception:
99
+ continue
100
+ if not content.startswith("---"):
101
+ continue
102
+ parts = content.split("---", 2)
103
+ if len(parts) < 3:
104
+ continue
105
+ fm_text = parts[1]
106
+ try:
107
+ fm = yaml.safe_load(fm_text) or {}
108
+ except Exception:
109
+ continue
110
+ artifact = fm.get("artifact")
111
+ if not artifact:
112
+ continue
113
+ artifacts = artifact if isinstance(artifact, list) else [artifact]
114
+ for item in artifacts:
115
+ item_str = str(item).replace("\\", "/").strip().rstrip("/").lower()
116
+ if item_str == rel_norm or item_str.startswith(f"{rel_norm}/"):
117
+ memlog_rel = path.relative_to(root).as_posix()
118
+ sys.exit(
119
+ f"error: refusing to modify `{rel_posix}`: memlog `{memlog_rel}` has artifact "
120
+ f"pointing to `{item}` — run provenance must not be broken."
121
+ )
122
+
123
+
124
+ def _rollback_git_changes(root: Path, created_dirs: list[Path] | None = None) -> None:
125
+ _run_cmd(["git", "restore", "--staged", "--worktree", "--", "."], root)
126
+ if created_dirs:
127
+ for d in created_dirs:
128
+ if d.is_dir():
129
+ try:
130
+ d.rmdir()
131
+ except OSError:
132
+ pass
133
+
134
+
135
+ def find_specs_file(root: Path) -> Path:
136
+ candidates = [
137
+ root / ".control" / "registry" / "specs.yaml",
138
+ root / "control" / "registry" / "specs.yaml",
139
+ ]
140
+ for c in candidates:
141
+ if c.is_file():
142
+ return c
143
+ sys.exit(f"error: specs.yaml not found in {root / '.control' / 'registry'}")
144
+
145
+
146
+ def load_specs_data(specs_file: Path) -> tuple[dict, list[dict]]:
147
+ try:
148
+ data = yaml.safe_load(specs_file.read_text(encoding="utf-8")) or {}
149
+ except Exception as e:
150
+ sys.exit(f"error: failed to parse `{specs_file}`: {e}")
151
+ spec_list = data.get("specs") or data.get("waves") or []
152
+ return data, spec_list
153
+
154
+
155
+ def resolve_spec_folder(root: Path, spec: dict) -> Path | None:
156
+ folder_val = str(spec.get("spec_folder") or "").strip()
157
+ if folder_val:
158
+ p = root / folder_val.replace("\\", "/").strip("/")
159
+ if p.exists():
160
+ return p
161
+
162
+ # Check tickets fallback
163
+ for t in spec.get("tickets") or []:
164
+ if isinstance(t, dict):
165
+ tf = str(t.get("spec_folder") or "").strip()
166
+ if tf:
167
+ p = root / tf.replace("\\", "/").strip("/")
168
+ if p.exists():
169
+ return p
170
+
171
+ # Fallback to searching in .scratch/
172
+ sid = str(spec.get("id") or "").strip()
173
+ scratch_dir = root / ".scratch"
174
+ if scratch_dir.is_dir() and sid:
175
+ sid_lower = sid.lower()
176
+ for child in scratch_dir.iterdir():
177
+ if child.is_dir():
178
+ c_name = child.name.lower()
179
+ if c_name == sid_lower or c_name.startswith(f"{sid_lower}-") or f"-{sid_lower}-" in c_name:
180
+ return child
181
+ return None
182
+
183
+
184
+ def update_spec_folder_in_yaml(specs_file: Path, spec_id: str, new_folder: str) -> None:
185
+ content = specs_file.read_text(encoding="utf-8")
186
+ newline = "\r\n" if "\r\n" in content else "\n"
187
+ lines = content.splitlines()
188
+
189
+ clean_sid = spec_id.strip().upper()
190
+
191
+ item_starts: list[tuple[int, str]] = []
192
+ for idx, line in enumerate(lines):
193
+ m = re.match(r"^(\s*)-\s+", line)
194
+ if m:
195
+ item_starts.append((idx, m.group(1)))
196
+
197
+ target_start_line = -1
198
+ target_end_line = len(lines)
199
+ base_indent = ""
200
+
201
+ for i, (start_line, indent) in enumerate(item_starts):
202
+ end_line = item_starts[i + 1][0] if i + 1 < len(item_starts) else len(lines)
203
+ found_id = False
204
+ for l_idx in range(start_line, end_line):
205
+ line = lines[l_idx]
206
+ if l_idx > start_line and line and not line.startswith(" ") and not line.startswith("\t") and not line.startswith("#"):
207
+ end_line = l_idx
208
+ break
209
+ id_m = re.search(r"^\s*(?:-\s+)?id:\s*['\"]?([^'\"#\s]+)['\"]?", line, re.IGNORECASE)
210
+ if id_m and id_m.group(1).strip().upper() == clean_sid:
211
+ found_id = True
212
+ break
213
+ if found_id:
214
+ target_start_line = start_line
215
+ target_end_line = end_line
216
+ base_indent = indent
217
+ break
218
+
219
+ if target_start_line == -1:
220
+ raise RuntimeError(f"spec `{spec_id}` could not be located in `{specs_file}` for update")
221
+
222
+ folder_idx = -1
223
+ field_indent = base_indent + " "
224
+ for idx in range(target_start_line, target_end_line):
225
+ line = lines[idx]
226
+ m = re.match(r"^(\s*)spec_folder:\s*.*$", line)
227
+ if m:
228
+ folder_idx = idx
229
+ field_indent = m.group(1)
230
+ break
231
+
232
+ clean_folder = new_folder.replace("\\", "/").strip("/") + "/"
233
+ new_line = f"{field_indent}spec_folder: {clean_folder}"
234
+
235
+ if folder_idx != -1:
236
+ lines[folder_idx] = new_line
237
+ else:
238
+ lines.insert(target_start_line + 1, new_line)
239
+
240
+ specs_file.write_text(newline.join(lines) + newline, encoding="utf-8")
241
+
242
+
243
+ def run_validator_check(root: Path, created_dirs: list[Path] | None = None) -> None:
244
+ validate_script = Path(__file__).parent / "validate.py"
245
+ if not validate_script.is_file():
246
+ _rollback_git_changes(root, created_dirs)
247
+ sys.exit(f"error: validate.py script not found at `{validate_script}` — fail-closed.")
248
+ baseline_file = root / ".github" / "validate-baseline.txt"
249
+ base_args = ["--baseline", str(baseline_file)] if baseline_file.is_file() else []
250
+ res = _run_cmd(["uv", "run", str(validate_script), "--root", str(root), "--check", *base_args], root)
251
+ if res.returncode != 0 and ("No such file or directory" in res.stderr or "not recognized" in res.stderr):
252
+ res = _run_cmd(["uv", "run", "--with", "pyyaml", "python", str(validate_script), "--root", str(root), "--check", *base_args], root)
253
+ if res.returncode != 0 and ("No such file or directory" in res.stderr or "not recognized" in res.stderr):
254
+ res = _run_cmd([sys.executable, str(validate_script), "--root", str(root), "--check", *base_args], root)
255
+ if res.returncode != 0:
256
+ if baseline_file.is_file():
257
+ raw_lines = res.stdout.splitlines()
258
+ findings = []
259
+ for line in raw_lines:
260
+ if line.startswith("Skipped:") or line.startswith("V14 reference date:"):
261
+ break
262
+ if re.match(r"^\s{2}[a-z][a-z0-9-]*\s+", line):
263
+ findings.append(line.rstrip())
264
+ baseline_lines = [l.rstrip() for l in baseline_file.read_text(encoding="utf-8").splitlines() if l.strip()]
265
+ if sorted(findings) == sorted(baseline_lines):
266
+ return
267
+ _rollback_git_changes(root, created_dirs)
268
+ sys.exit(
269
+ f"error: validate.py --check failed after lifecycle operation. Git changes have been rolled back.\n"
270
+ f"{res.stdout}\n{res.stderr}"
271
+ )
272
+
273
+
274
+ def main() -> None:
275
+ parser = argparse.ArgumentParser(
276
+ description="Manage spec lifecycle: archive or prune closed specs."
277
+ )
278
+ parser.add_argument("--spec", help="Spec ID (e.g. SPEC-1)")
279
+ parser.add_argument("--archive", action="store_true", help="Move closed spec to .archive/specs/<spec>/ and update specs.yaml")
280
+ parser.add_argument("--prune", action="store_true", help="Remove closed spec folder with git rm -r, preserving specs.yaml metadata")
281
+ parser.add_argument("--all-closed", action="store_true", help="Process all closed specs currently in .scratch/")
282
+ parser.add_argument("--dry-run", action="store_true", help="Simulate execution without modifying files or git index")
283
+ parser.add_argument("--root", default=".", help="Root directory of the repository (default: .)")
284
+
285
+ args = parser.parse_args()
286
+
287
+ if not args.archive and not args.prune:
288
+ parser.error("must specify either --archive or --prune")
289
+ if args.archive and args.prune:
290
+ parser.error("cannot specify both --archive and --prune")
291
+ if not args.spec and not args.all_closed:
292
+ parser.error("must specify either --spec <id> or --all-closed")
293
+ if args.spec and args.all_closed:
294
+ parser.error("cannot specify both --spec and --all-closed")
295
+
296
+ root = Path(args.root).resolve()
297
+ check_git_clean(root, dry_run=args.dry_run)
298
+
299
+ specs_file = find_specs_file(root)
300
+ _, spec_list = load_specs_data(specs_file)
301
+
302
+ target_specs: list[dict] = []
303
+ if args.spec:
304
+ sid_req = args.spec.strip().upper()
305
+ found = None
306
+ for s in spec_list:
307
+ if str(s.get("id") or "").strip().upper() == sid_req:
308
+ found = s
309
+ break
310
+ if not found:
311
+ sys.exit(f"error: spec `{args.spec}` not found in `{specs_file}`")
312
+ status = str(found.get("status") or "").strip()
313
+ if status != "closed":
314
+ sys.exit(f"error: spec `{found.get('id')}` status is `{status or 'empty'}` — only closed specs may be archived or pruned.")
315
+ target_specs.append(found)
316
+ else:
317
+ for s in spec_list:
318
+ if str(s.get("status") or "").strip() == "closed":
319
+ target_specs.append(s)
320
+ if not target_specs:
321
+ print("No closed specs found in specs.yaml.")
322
+ return
323
+
324
+ operations: list[tuple[dict, Path, str]] = []
325
+ for spec in target_specs:
326
+ sid = str(spec.get("id"))
327
+ folder_path = resolve_spec_folder(root, spec)
328
+ if not folder_path or not folder_path.exists():
329
+ if args.prune:
330
+ print(f"advisory: spec `{sid}` folder does not exist on disk (already pruned); skipping.")
331
+ else:
332
+ print(f"advisory: spec `{sid}` folder not found on disk; skipping.")
333
+ continue
334
+
335
+ try:
336
+ rel_folder = folder_path.relative_to(root).as_posix()
337
+ except ValueError:
338
+ rel_folder = str(folder_path).replace("\\", "/")
339
+
340
+ clean = rel_folder.replace("\\", "/").strip()
341
+ while clean.startswith("./"):
342
+ clean = clean[2:]
343
+ if clean.startswith("/"):
344
+ clean = clean.lstrip("/")
345
+
346
+ if clean.startswith(".archive/") or clean == ".archive":
347
+ if args.archive:
348
+ print(f"advisory: spec `{sid}` is already archived at `{rel_folder}`; skipping.")
349
+ continue
350
+ elif args.prune:
351
+ if args.spec:
352
+ sys.exit(f"error: spec `{sid}` is already archived at `{rel_folder}` — refusing to prune an archived audit record.")
353
+ else:
354
+ print(f"advisory: spec `{sid}` is already archived at `{rel_folder}`; skipping.")
355
+ continue
356
+
357
+ if not args.spec and not (clean.startswith(".scratch/") or clean == ".scratch"):
358
+ print(f"advisory: spec `{sid}` folder `{rel_folder}` is outside `.scratch/`; skipping in bulk operation.")
359
+ continue
360
+
361
+ check_source_boundary(root, folder_path, sid)
362
+ check_memlog_artifacts(root, folder_path)
363
+ check_worktree_collision(root, folder_path)
364
+ operations.append((spec, folder_path, "archive" if args.archive else "prune"))
365
+
366
+ if not operations:
367
+ print("No eligible spec folders to process.")
368
+ return
369
+
370
+ created_dirs: list[Path] = []
371
+ try:
372
+ for spec, src_path, action in operations:
373
+ sid = str(spec.get("id"))
374
+ rel_src = src_path.relative_to(root).as_posix()
375
+ if action == "archive":
376
+ dest_dir = root / ".archive" / "specs" / src_path.name
377
+ rel_dest = dest_dir.relative_to(root).as_posix() + "/"
378
+ if args.dry_run:
379
+ print(f"[dry-run] would git mv -- `{rel_src}` -> `{rel_dest}`")
380
+ print(f"[dry-run] would update `spec_folder: {rel_dest}` in `{specs_file.relative_to(root).as_posix()}`")
381
+ else:
382
+ if not dest_dir.parent.exists():
383
+ dest_dir.parent.mkdir(parents=True, exist_ok=True)
384
+ created_dirs.append(dest_dir.parent)
385
+ res = _run_cmd(["git", "mv", "--", rel_src, dest_dir.relative_to(root).as_posix()], root)
386
+ if res.returncode != 0:
387
+ raise RuntimeError(f"git mv failed for spec `{sid}`:\n{res.stderr.strip()}")
388
+ update_spec_folder_in_yaml(specs_file, sid, rel_dest)
389
+ res_add = _run_cmd(["git", "add", "--", specs_file.relative_to(root).as_posix()], root)
390
+ if res_add.returncode != 0:
391
+ raise RuntimeError(f"git add failed for specs.yaml:\n{res_add.stderr.strip()}")
392
+ print(f"Archived spec `{sid}`: `{rel_src}` -> `{rel_dest}`")
393
+ elif action == "prune":
394
+ if args.dry_run:
395
+ print(f"[dry-run] would git rm -r -- `{rel_src}`")
396
+ else:
397
+ res = _run_cmd(["git", "rm", "-r", "--", rel_src], root)
398
+ if res.returncode != 0:
399
+ raise RuntimeError(f"git rm failed for spec `{sid}`:\n{res.stderr.strip()}")
400
+ print(f"Pruned spec `{sid}`: removed `{rel_src}` from git and filesystem")
401
+ except (Exception, BaseException) as e:
402
+ _rollback_git_changes(root, created_dirs)
403
+ sys.exit(f"error: lifecycle operation failed; rolled back all git changes.\n{e}")
404
+
405
+ if args.dry_run:
406
+ print("[dry-run] would run validate.py --check")
407
+ print("[dry-run] lifecycle dry run completed successfully.")
408
+ return
409
+
410
+ print("Running post-lifecycle validation check...")
411
+ run_validator_check(root, created_dirs)
412
+ print("All lifecycle operations completed and validated successfully.")
413
+
414
+
415
+ if __name__ == "__main__":
416
+ main()
@@ -51,6 +51,7 @@ CHECK_ORDER = (
51
51
  "memlog-home",
52
52
  "spec-names-release-prd",
53
53
  "ticket-status-one-home",
54
+ "archived-spec-closed",
54
55
  "defect-root-cause",
55
56
  "entity-one-writer",
56
57
  "spec-after-g4",
@@ -172,7 +173,7 @@ def git(root: Path, *args: str) -> str | None:
172
173
  try:
173
174
  out = subprocess.run(
174
175
  ["git", "-C", str(root), *args],
175
- capture_output=True, text=True, timeout=30, check=False,
176
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, check=False,
176
177
  )
177
178
  except (OSError, subprocess.SubprocessError):
178
179
  return None
@@ -656,7 +657,8 @@ def lc_registered(c: Corpus, r: Result) -> None: # was V12
656
657
  if area not in areas:
657
658
  r.fail("lc-registered", str(ticket.get("id")),
658
659
  f"its spec is already closed, but `{area}` is not registered as an `area` "
659
- f"in components.yaml")
660
+ f"in components.yaml. For corpus- or documentation-only tickets with no "
661
+ f"application code changes, use `touches: []` instead of inventing an area name.")
660
662
  pid = str(ticket.get("component") or "")
661
663
  row = pc_by_id.get(pid)
662
664
  if row is None or (str(spec.get("id")), pid) in seen:
@@ -942,6 +944,35 @@ def ticket_status_one_home(c: Corpus, r: Result) -> None: # was V18
942
944
  "`status:` in frontmatter")
943
945
 
944
946
 
947
+ def archived_spec_closed(c: Corpus, r: Result) -> None:
948
+ """A spec whose `spec_folder` points to `.archive/` MUST have status `closed`.
949
+
950
+ Archiving is reserved for completed specs. Active work belongs in `.scratch/` where tickets
951
+ are materialized into git worktrees for implementation.
952
+ """
953
+ for spec in c.spec_list:
954
+ sid = str(spec.get("id") or "")
955
+ folder = str(spec.get("spec_folder") or "").strip()
956
+ if not folder:
957
+ for t in spec.get("tickets") or []:
958
+ if isinstance(t, dict) and str(t.get("spec_folder") or "").strip():
959
+ folder = str(t.get("spec_folder")).strip()
960
+ break
961
+ clean = folder.replace("\\", "/").strip()
962
+ while clean.startswith("./"):
963
+ clean = clean[2:]
964
+ if clean.startswith("/"):
965
+ clean = clean.lstrip("/")
966
+ norm = os.path.normpath(clean).replace("\\", "/") if clean else ""
967
+ if (clean == ".archive" or clean.startswith(".archive/") or
968
+ norm == ".archive" or norm.startswith(".archive/")):
969
+ status = str(spec.get("status") or "").strip()
970
+ if status != "closed":
971
+ r.fail("archived-spec-closed", sid,
972
+ f"points to `{folder}` under `.archive/` but its status is `{status or 'unspecified'}` — "
973
+ "only closed specs may be archived")
974
+
975
+
945
976
  PLATFORM = "_platform"
946
977
  CROSS_CUTTING = ".how/_platform/cross-cutting.md"
947
978
  # The section heading entity-one-writer looks for. A heading a SCRIPT matches is a machine-facing key, and
@@ -1288,6 +1319,7 @@ PAST_RECORD = (
1288
1319
  ".control/decisions/",
1289
1320
  ".control/questions/answered.md",
1290
1321
  ".control/reports/",
1322
+ ".archive/",
1291
1323
  )
1292
1324
  # Corpus that §25 freezes as-is. Its citation of a now-retired prototype is authorized by DEC-016.
1293
1325
  FROZEN = (".what/",)
@@ -1655,7 +1687,7 @@ def custom_room_declared(c: Corpus, r: Result) -> None: # was V27
1655
1687
  # The two rendered trees are DELIBERATELY absent from this list. They are regenerated by this
1656
1688
  # script, so a product that declines to commit derived output is making a choice the method allows.
1657
1689
  COMMITTED_DIRS = (".constitution", ".control", ".what", ".how", "_bmad-output", ".work",
1658
- ".scratch")
1690
+ ".scratch", ".archive")
1659
1691
 
1660
1692
  # Probed inside each directory above, and named so that no honest pattern would ever mean to match
1661
1693
  # it. The distinction this draws is the entire point of the check: `.work/upstream/` or
@@ -1678,7 +1710,7 @@ def _ignore_rule(root: Path, rel: str) -> tuple[bool, str]:
1678
1710
  try:
1679
1711
  out = subprocess.run(
1680
1712
  ["git", "-C", str(root), "check-ignore", "-v", "--no-index", rel],
1681
- capture_output=True, text=True, timeout=30, check=False,
1713
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, check=False,
1682
1714
  )
1683
1715
  except (OSError, subprocess.SubprocessError):
1684
1716
  return (False, "")
@@ -1712,6 +1744,20 @@ def corpus_in_git(c: Corpus, r: Result) -> None:
1712
1744
  f"is excluded from git by `{rule}` — the method commits this folder, "
1713
1745
  f"so no clone has what is in it")
1714
1746
 
1747
+ dispatch_file = c.root / ".control" / "custom-dispatch.yaml"
1748
+ if dispatch_file.exists():
1749
+ try:
1750
+ ls_res = subprocess.run(
1751
+ ["git", "-C", str(c.root), "ls-files", ".control/custom-dispatch.yaml"],
1752
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, check=False,
1753
+ )
1754
+ if ls_res.returncode == 0 and ls_res.stdout.strip():
1755
+ r.fail("custom-dispatch-untracked", ".control/custom-dispatch.yaml",
1756
+ "is tracked in git — local runner configuration must never be committed; "
1757
+ "add it to .gitignore and run `git rm --cached .control/custom-dispatch.yaml`")
1758
+ except (OSError, subprocess.SubprocessError):
1759
+ pass
1760
+
1715
1761
 
1716
1762
  ENGINE_HOMES = (".claude", ".agents", ".agent", ".cursor", ".codex")
1717
1763
  ENGINE_FLAGGED = ("to-spec", "to-tickets", "implement")
@@ -1858,7 +1904,7 @@ def run_checks(c: Corpus, asof: dt.date) -> Result:
1858
1904
  # no two copies left to compare.
1859
1905
  # V19 is REPEALED. It checked one line item — an `RTR-` file in .control/reports/ — and the
1860
1906
  # retrospective it archived was the only thing spec size `L` ever decided. Both went together.
1861
- for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, mandate_accept, cites_resolve, container_built, custom_room_declared, corpus_in_git, engines_invocable, withdrawn_recorded, id_allocated_once):
1907
+ for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, archived_spec_closed, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, mandate_accept, cites_resolve, container_built, custom_room_declared, corpus_in_git, engines_invocable, withdrawn_recorded, id_allocated_once):
1862
1908
  fn(c, r)
1863
1909
  plan_dates(c, r, asof)
1864
1910
  return r
@@ -2048,7 +2094,57 @@ def gen_rtm(c: Corpus) -> dict:
2048
2094
  return {"rtm": lines}
2049
2095
 
2050
2096
 
2051
- def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
2097
+ def _active_mandates(c: Corpus, asof: dt.date | None = None) -> dict:
2098
+ """Project active mandate status into status.yaml: resolution (one | none | ambiguous),
2099
+ active_ids list, and active_mandate dict.
2100
+
2101
+ An active accepted mandate MUST have type: mandate, status: accepted, an unexpired
2102
+ expires date (today <= expires), and no superseded_by or status: superseded.
2103
+ """
2104
+ today = asof or dt.date.today()
2105
+ actives: list[dict] = []
2106
+ for dec in c.decs:
2107
+ if str(dec.get("type") or "") != "mandate":
2108
+ continue
2109
+ status = str(dec.get("status") or "")
2110
+ if status != "accepted":
2111
+ continue
2112
+ did = str(dec.get("id") or "")
2113
+ ref = str(dec.get("superseded_by") or _dec_fm(c, dec).get("superseded_by") or "").strip()
2114
+ if ref or status == "superseded":
2115
+ continue
2116
+ params = dec.get("mandate") if isinstance(dec.get("mandate"), dict) else {}
2117
+ expires = _dec_date(c, {"date": params.get("expires")})
2118
+ if expires is None or expires < today:
2119
+ continue
2120
+ actives.append({
2121
+ "id": did,
2122
+ "status": status,
2123
+ "expires": expires.isoformat(),
2124
+ "scope": params.get("scope", "all"),
2125
+ })
2126
+ actives.sort(key=lambda a: a["id"])
2127
+ if len(actives) == 1:
2128
+ return {
2129
+ "resolution": "one",
2130
+ "active_ids": [actives[0]["id"]],
2131
+ "active_mandate": actives[0],
2132
+ }
2133
+ elif len(actives) > 1:
2134
+ return {
2135
+ "resolution": "ambiguous",
2136
+ "active_ids": [a["id"] for a in actives],
2137
+ "active_mandate": None,
2138
+ }
2139
+ else:
2140
+ return {
2141
+ "resolution": "none",
2142
+ "active_ids": [],
2143
+ "active_mandate": None,
2144
+ }
2145
+
2146
+
2147
+ def gen_status(c: Corpus, rtm: dict, result: Result, asof: dt.date | None = None) -> dict:
2052
2148
  lines = rtm.get("rtm") or []
2053
2149
  counted = [line for line in lines if not line.get("exempt")]
2054
2150
  exempt = len(lines) - len(counted)
@@ -2071,6 +2167,7 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
2071
2167
  "validators_red": result.red,
2072
2168
  "validators_skipped": dict(sorted(result.skipped.items())),
2073
2169
  "open_questions": _question_budget(c),
2170
+ "mandates": _active_mandates(c, asof),
2074
2171
  }
2075
2172
 
2076
2173
 
@@ -2870,7 +2967,7 @@ def page_sdd(c: Corpus, pid: str) -> str:
2870
2967
  return "\n".join(parts) + "\n"
2871
2968
 
2872
2969
 
2873
- def generate(c: Corpus, result: Result) -> list[Path]:
2970
+ def generate(c: Corpus, result: Result, asof: dt.date | None = None) -> list[Path]:
2874
2971
  """Machine tables into `.control/generated/`; every page a human reads into the two rendered
2875
2972
  trees, at the mirror path of the working document it projects."""
2876
2973
  out_dir = c.root / ".control" / "generated"
@@ -2881,7 +2978,7 @@ def generate(c: Corpus, result: Result) -> list[Path]:
2881
2978
  "risks": gen_risks(c),
2882
2979
  "dag": gen_dag(c),
2883
2980
  "rtm": rtm,
2884
- "status": gen_status(c, rtm, result),
2981
+ "status": gen_status(c, rtm, result, asof),
2885
2982
  }
2886
2983
  written = []
2887
2984
  for name in GENERATED_ORDER:
@@ -2938,6 +3035,9 @@ def main(argv: list[str] | None = None) -> int:
2938
3035
  parser.add_argument("--asof", default=None,
2939
3036
  help="reference date for plan-dates, format YYYY-MM-DD (default: today). "
2940
3037
  "Stated explicitly so a run can be repeated exactly")
3038
+ parser.add_argument("--baseline", nargs="?", const=".github/validate-baseline.txt", default=None,
3039
+ help="path to baseline findings file (default: .github/validate-baseline.txt); "
3040
+ "exits 0 if current findings match baseline exactly")
2941
3041
  args = parser.parse_args(argv)
2942
3042
 
2943
3043
  if not args.check and not args.generate:
@@ -2953,10 +3053,27 @@ def main(argv: list[str] | None = None) -> int:
2953
3053
  result = run_checks(corpus, asof)
2954
3054
 
2955
3055
  if args.generate:
2956
- for path in generate(corpus, result):
3056
+ for path in generate(corpus, result, asof):
2957
3057
  print(f" wrote {path.relative_to(root).as_posix()}")
2958
3058
 
2959
3059
  if result.findings:
3060
+ if args.baseline:
3061
+ base_p = Path(args.baseline)
3062
+ if not base_p.is_absolute():
3063
+ base_p = root / base_p
3064
+ if base_p.is_file():
3065
+ current_fmt = [f" {f.vid:<26} {f.subject}: {f.message}".rstrip()
3066
+ for f in sorted(result.findings, key=lambda f: f.sort_key)]
3067
+ base_lines = [l.rstrip() for l in base_p.read_text(encoding="utf-8").splitlines() if l.strip()]
3068
+ if sorted(current_fmt) == sorted(base_lines):
3069
+ print(f"\nGREEN (baseline match) — {len(result.findings)} finding(s) match baseline `{base_p.relative_to(root).as_posix()}`")
3070
+ if result.skipped:
3071
+ print("\nSkipped:")
3072
+ for vid, why in sorted(result.skipped.items()):
3073
+ print(f" {vid:<26} {why}")
3074
+ print(f"\nV14 reference date: {asof.isoformat()}")
3075
+ return 0
3076
+
2960
3077
  print(f"\nRED — {len(result.findings)} findings across {len(result.red)} validators\n")
2961
3078
  for finding in sorted(result.findings, key=lambda f: f.sort_key):
2962
3079
  print(f" {finding.vid:<26} {finding.subject}: {finding.message}")