wdi-method 0.6.19 → 0.6.25

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.
@@ -0,0 +1,87 @@
1
+ ---
2
+ status: Accepted
3
+ ---
4
+
5
+ # Branch Guide
6
+
7
+ **Loaded when:** creating, switching, pushing, merging, or deleting git branches, creating task worktrees, targeting pull requests, or configuring repository branch policy
8
+
9
+ This guide governs git branching conventions, protected branches, and pull request target policies across repositories using WDI Method.
10
+
11
+ ## Branch policy settings
12
+
13
+ Branch settings live in `.control/registry/index.yaml` under `policy:`. Both default to `main`:
14
+
15
+ | Setting | Governs | Default |
16
+ |---|---|---|
17
+ | `primary_branch` | Production / release trunk | `main` |
18
+ | `development_branch` | Active development branch for specs, worktrees, and PR targets | `main` |
19
+
20
+ Two workflows are supported:
21
+ - **Single-branch workflow:** `primary_branch` and `development_branch` are identical (e.g. both `main`). Suitable for small tools and straightforward repositories.
22
+ - **Two-branch workflow:** `primary_branch` is `main` (production-ready releases) and `development_branch` is `development` (ongoing feature integration).
23
+
24
+ ## Absolute branch immunity
25
+
26
+ - An agent MUST NOT delete, rename, force-push, or overwrite `primary_branch` or `development_branch`, locally or on any remote.
27
+
28
+ ## Primary branch protection
29
+
30
+ The `primary_branch` represents production stability:
31
+ - An agent MUST NOT commit application code directly to `primary_branch`.
32
+ - An agent MUST NOT force-push to `primary_branch`.
33
+ - In single-branch mode (where `development_branch` equals `primary_branch`), the only exception to direct commits is non-code corpus planning: spec and ticket authoring in `.scratch/` and index updates in `.control/registry/specs.yaml`. All application code changes MUST go through isolated branches/worktrees and pull requests.
34
+ - An agent MUST NOT merge into `primary_branch`, except in single-branch mode where `development_branch` equals `primary_branch`, and only when explicitly instructed by the repository maintainer.
35
+ - Merges into `primary_branch` and release tagging belong exclusively to human maintainers or designated release workflows.
36
+
37
+ ## Development branch protection
38
+
39
+ - An agent MUST NOT force-push to `development_branch`.
40
+ - An agent MUST NOT commit application code directly to `development_branch`; code delivery MUST arrive via isolated task branches/worktrees and pull requests.
41
+ - Direct commits to `development_branch` are permitted strictly for Phase 1 & 2 spec/ticket authoring in `.scratch/` and `.control/registry/specs.yaml`, with no application code changes.
42
+ - Merging pull requests into `development_branch` requires explicit maintainer approval.
43
+
44
+ ## Active development target
45
+
46
+ The `development_branch` is the sole base and landing target for active engineering work:
47
+ - Feature specs, ticket authoring, and `.scratch/` planning documents are based on `development_branch`.
48
+ - Task branches and implementation worktrees MUST branch from `development_branch`.
49
+ - Pull requests opened by `wdi-build` or `wdi-autopilot` MUST target `development_branch`.
50
+
51
+ ## Fail-closed precheck
52
+
53
+ Before running branch or worktree operations, an agent MUST verify that the configured `development_branch` exists locally or on the remote tracking ref:
54
+
55
+ ```bash
56
+ git rev-parse --verify "refs/heads/<development_branch>" >/dev/null 2>&1 || git rev-parse --verify "refs/remotes/origin/<development_branch>" >/dev/null 2>&1
57
+ ```
58
+
59
+ - If `development_branch` cannot be verified locally or on remote, the agent MUST STOP immediately and report the error to the maintainer.
60
+ - An agent MUST NOT guess branch names or silently fall back to `main` when `development_branch` is missing.
61
+
62
+ ## Worktree and task branch lifecycle
63
+
64
+ - Task branches and worktrees are temporary mechanisms for isolated implementation.
65
+ - Once a task or run branch is merged into `development_branch`, the local task worktree and branch SHOULD be pruned.
66
+ - Intermediate task branches MUST NOT be left lingering on the remote; only the designated run branch or PR branch reaches the remote.
67
+ - When synchronizing `development_branch`, agents SHOULD run `git fetch --prune origin` to prune stale remote-tracking references of branches already deleted on the remote.
68
+ - For method-owned run branches (e.g. `autopilot/<mandate-id>`), once the pull request is confirmed merged into `development_branch`, the remote branch SHOULD be pruned (`git push origin --delete <branch>`). If PR merge status cannot be verified, the remote branch MUST NOT be deleted and MUST be reported as a residual remote branch.
69
+ - Absolute branch immunity strictly applies to remote operations: an agent MUST NEVER delete or prune `primary_branch` or `development_branch` on any remote.
70
+
71
+ ### Working tree isolation models
72
+
73
+ Isolation protects branch integrity and build state during implementation:
74
+ 1. **Linked worktree (`git worktree add`):** An additional isolated checkout directory. Ideal for multi-task workflows and environments without toolchain file locks.
75
+ 2. **Exclusive primary working tree:** The root repository checkout temporarily dedicated exclusively to a task branch or autopilot run branch (`autopilot/<mandate-id>`). This model is permitted where linked worktrees encounter filesystem or toolchain locks (e.g. Windows file locking on compiler output or artifact build directories), provided all three conditions hold:
76
+ - The working tree is clean (`git status --porcelain` empty) before switching to the run branch.
77
+ - The checkout is dedicated exclusively to the active run (no parallel builders, concurrent human edits, or competing processes sharing the root tree).
78
+ - Only one active mandate or task run executes on the primary tree at any given time.
79
+ 3. **Shared checkout (PROHIBITED for code changes):** A working tree with dirty state, unstaged edits, or concurrent uncoordinated activities.
80
+
81
+ ## Red flags
82
+
83
+ - Committing directly to `primary_branch` when `development_branch` differs
84
+ - Deleting `primary_branch` or `development_branch` during worktree cleanup
85
+ - Force-pushing to any protected branch
86
+ - Falling back to `main` when `git rev-parse --verify <development_branch>` fails
87
+ - Opening a pull request targeting `primary_branch` instead of `development_branch`
@@ -33,13 +33,30 @@ triggers**.
33
33
  the run MUST keep intermediate work off the remote instead: hold the push, or make the pushed head
34
34
  commit carry `[skip ci]`, which GitHub honours for `push` and `pull_request` events.
35
35
 
36
+ ### CI execution policy
37
+
38
+ The product's CI execution policy is configured in `.control/registry/index.yaml` under `policy:`, defaulting to `cycle-end-cloud`:
39
+
40
+ ```yaml
41
+ policy:
42
+ ci_execution: cycle-end-cloud # cycle-end-cloud (default)
43
+ ```
44
+
45
+ - **`cycle-end-cloud` (default):** Intermediate pushes start nothing; exactly one cloud CI run is triggered at `wdi-autopilot` § Finish when the work is offered for review. Green CI on the pushed head SHA is required before marking the PR ready for review.
46
+ - **Mandate CI override (signed bypass for constrained environments):** Where cloud runner allowances are strictly limited or emergency releases require local verification, an autonomous mandate MAY record a signed override on its decision block in `decisions.yaml`:
47
+ ```yaml
48
+ mandate:
49
+ ci_override: local-only-approved-by: "<Person, YYYY-MM-DD>"
50
+ ```
51
+ Under this signed override, the autopilot run MUST NOT call `gh pr ready`, the PR MUST remain as a Draft, no cloud runner is awaited, and the final release report MUST explicitly state: *"locally verified; cloud verification intentionally deferred by mandate"*. A global un-audited toggle MUST NOT be used to silently disable CI evidence.
52
+
36
53
  ## Trigger shape
37
54
 
38
55
  | Event | Use it | Why |
39
56
  |---|---|---|
40
57
  | `workflow_dispatch` | **MUST** be present | The manual re-run. Without it, a red run can only be retried by pushing again |
41
- | `pull_request:` `types: [ready_for_review]` | The one automatic trigger | A draft PR is work in progress; marking it ready is the moment somebody is asking for the verdict |
42
- | `push:` `branches: [main]` | MAY | One run per merge, as the record of trunk health. Drop it where the allowance is tight |
58
+ | `pull_request:` `types: [ready_for_review]` | The one automatic trigger | A draft PR is work in progress; marking it ready is the moment somebody is asking for the verdict. 100% branch-agnostic standard — triggers identically for `main`, `development`, or custom target branches |
59
+ | `push:` `branches: [main, development]` | MAY | One run per merge, as the record of branch health. Note: GitHub Actions evaluates branch filters statically before checkout; if custom branch names are configured in `index.yaml` policy, update this list manually. Drop it where the allowance is tight |
43
60
  | bare `on: push` | **MUST NOT** | Every branch, every commit, no filter. This is the setting that spends an allowance |
44
61
 
45
62
  Two consequences worth stating, because both surprise people:
@@ -81,9 +98,11 @@ on:
81
98
  - '.constitution/**'
82
99
  - '_bmad-output/**'
83
100
  - '.work/**'
84
- # One run per merge, as the record of trunk health. Delete this block where the allowance is tight.
101
+ # One run per merge, as the record of branch health. GitHub Actions evaluates branch filters
102
+ # statically before checkout; if index.yaml policy configures custom branch names (e.g. trunk, dev),
103
+ # update this list manually. Delete this block where the allowance is tight.
85
104
  push:
86
- branches: [main]
105
+ branches: [main, development]
87
106
  paths-ignore:
88
107
  - '**.md'
89
108
  - '.scratch/**'
@@ -26,6 +26,7 @@ The repo layout is governed by `corpus-guide.md` and mapped by
26
26
  | `.what-rendered/` · `.how-rendered/` | The two above, assembled for a human to read — one page per gate, at the mirror path. Regenerated by `validate.py`, never edited, never read by a skill |
27
27
  | `_bmad-output/` | Run workspace; MUST be in git, not curated |
28
28
  | `.scratch/` | One directory per effort: a spec's `SPEC.md` and its ticket files, and ad hoc work that has no `FR` yet. MUST be in git — the corpus cites into it by path |
29
+ | `.archive/` | Archived records — closed specs and historical provenance moved from `.scratch/`. MUST be in git |
29
30
  | `.work/` | Scratch; MUST be in git, emptied when a task closes |
30
31
  | *(application roots)* | Application code — named and mapped in `.control/structure-codebase.md` |
31
32
 
@@ -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()