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.
- package/CHANGELOG.md +105 -0
- package/LICENSE +21 -21
- package/NOTICE +28 -0
- package/README.id.md +190 -0
- package/README.ja.md +188 -0
- package/README.md +190 -530
- package/README.zh.md +188 -0
- package/bin/wdi-method.js +2199 -2112
- package/kit/.constitution/method/README.md +1 -0
- package/kit/.constitution/method/branch-guide.md +87 -0
- package/kit/.constitution/method/ci-guide.md +169 -0
- package/kit/.constitution/method/constitution.md +1 -0
- package/kit/.constitution/method/scripts/lifecycle.py +416 -0
- package/kit/.constitution/method/scripts/validate.py +126 -9
- package/kit/skills/wdi-autopilot/SKILL.md +461 -384
- package/kit/skills/wdi-build/SKILL.md +404 -393
- package/kit/skills/wdi-daily-autopilot/SKILL.md +138 -0
- package/kit/skills/wdi-daily-what-to-build/SKILL.md +155 -0
- package/kit/skills/wdi-daily-what-to-test/SKILL.md +127 -0
- package/kit/skills/wdi-explain-to-me/SKILL.md +1 -1
- package/kit/skills/wdi-help/SKILL.md +21 -7
- package/kit/skills/wdi-init/SKILL.md +16 -0
- package/kit/skills/wdi-prune-or-archive/SKILL.md +76 -0
- package/kit/skills/wdi-review/SKILL.md +4 -1
- package/kit-overlay/AGENTS.md +37 -4
- package/kit-overlay/README.md +1 -0
- package/kit-overlay/constitution.md +1 -0
- package/lib/identity.mjs +246 -117
- package/package.json +8 -4
- package/scaffold/.control/custom-dispatch.yaml.example +65 -0
- package/scaffold/.control/registry/index.yaml +9 -0
- package/scaffold/.control/test-targets/desktop.md +15 -0
- package/scaffold/.control/test-targets/mobile.md +6 -0
- 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
|
|
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}")
|