@try-works/dsh-recursive-mode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +12 -0
- package/lib/bootstrap.d.ts +35 -0
- package/lib/client/board.d.ts +10 -0
- package/lib/client/contract.d.ts +51 -0
- package/lib/client/derive.d.ts +92 -0
- package/lib/client/index.d.ts +21 -0
- package/lib/client/inspector.d.ts +10 -0
- package/lib/client/node.d.ts +71 -0
- package/lib/client/settings.d.ts +6 -0
- package/lib/client/slots.d.ts +7 -0
- package/lib/client/strip.d.ts +7 -0
- package/lib/client.d.ts +10 -0
- package/lib/client.js +490 -0
- package/lib/closeout.d.ts +23 -0
- package/lib/commands.d.ts +51 -0
- package/lib/delegation.d.ts +92 -0
- package/lib/enforcement.d.ts +53 -0
- package/lib/events.d.ts +173 -0
- package/lib/handoff.d.ts +51 -0
- package/lib/index.d.ts +40 -0
- package/lib/lifecycle.d.ts +107 -0
- package/lib/lock.d.ts +92 -0
- package/lib/policy.d.ts +12 -0
- package/lib/projection.d.ts +29 -0
- package/lib/recursive_closeout.tool.d.ts +8 -0
- package/lib/recursive_init.tool.d.ts +2 -0
- package/lib/recursive_lint.tool.d.ts +2 -0
- package/lib/recursive_lock.tool.d.ts +2 -0
- package/lib/recursive_scratch.tool.d.ts +7 -0
- package/lib/recursive_status.tool.d.ts +2 -0
- package/lib/review.d.ts +39 -0
- package/lib/router.d.ts +77 -0
- package/lib/run.d.ts +29 -0
- package/lib/runtime.d.ts +241 -0
- package/lib/scratch.d.ts +18 -0
- package/lib/status.d.ts +19 -0
- package/lib/types.d.ts +104 -0
- package/lib/workspace.d.ts +50 -0
- package/package.json +119 -0
- package/preset/recursive/agent.cordis.yml +282 -0
- package/preset/recursive/preset.yml +3 -0
- package/scripts/install-recursive-mode.ps1 +956 -0
- package/scripts/install-recursive-mode.py +750 -0
- package/scripts/lint-recursive-run.py +2868 -0
- package/scripts/recursive-closeout.py +541 -0
- package/scripts/recursive-init.py +356 -0
- package/scripts/recursive-lock.py +302 -0
- package/scripts/recursive-status.py +2124 -0
- package/scripts/recursive_phase_rules.py +367 -0
- package/scripts/recursive_router_lib.py +2282 -0
- package/scripts/test-recursive-mode-smoke.ts +204 -0
- package/scripts/verify-locks.py +353 -0
- package/src/bootstrap.ts +118 -0
- package/src/client/board.tsx +61 -0
- package/src/client/contract.ts +58 -0
- package/src/client/derive.ts +241 -0
- package/src/client/index.ts +28 -0
- package/src/client/inspector.tsx +49 -0
- package/src/client/node.ts +156 -0
- package/src/client/settings.tsx +18 -0
- package/src/client/slots.ts +67 -0
- package/src/client/strip.tsx +28 -0
- package/src/client.ts +11 -0
- package/src/closeout.ts +183 -0
- package/src/commands.ts +142 -0
- package/src/delegation.ts +306 -0
- package/src/enforcement.ts +180 -0
- package/src/events.ts +173 -0
- package/src/handoff.ts +165 -0
- package/src/index.ts +283 -0
- package/src/lifecycle.ts +235 -0
- package/src/lock.ts +369 -0
- package/src/policy.ts +56 -0
- package/src/projection.ts +237 -0
- package/src/recursive_closeout.tool.ts +35 -0
- package/src/recursive_init.tool.ts +28 -0
- package/src/recursive_lint.tool.ts +29 -0
- package/src/recursive_lock.tool.ts +33 -0
- package/src/recursive_scratch.tool.ts +42 -0
- package/src/recursive_status.tool.ts +24 -0
- package/src/review.ts +178 -0
- package/src/router.ts +197 -0
- package/src/run.ts +85 -0
- package/src/runtime.ts +564 -0
- package/src/scratch.ts +85 -0
- package/src/status.ts +194 -0
- package/src/types.ts +112 -0
- package/src/workspace.ts +67 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Initialize a recursive-mode run folder and requirements scaffold.
|
|
4
|
+
|
|
5
|
+
Python equivalent to scripts/recursive-init.ps1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import importlib.util
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_utf8_no_bom(path: Path, content: str) -> None:
|
|
19
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ensure_directory(path: Path) -> None:
|
|
23
|
+
if not path.exists():
|
|
24
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
print(f"[OK] Created directory: {path}")
|
|
26
|
+
else:
|
|
27
|
+
print(f"[OK] Directory exists: {path}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_lint_module():
|
|
31
|
+
module_path = Path(__file__).with_name("lint-recursive-run.py")
|
|
32
|
+
spec = importlib.util.spec_from_file_location("recursive_mode_lint", module_path)
|
|
33
|
+
if spec is None or spec.loader is None:
|
|
34
|
+
raise RuntimeError(f"Unable to load lint module from {module_path}")
|
|
35
|
+
module = importlib.util.module_from_spec(spec)
|
|
36
|
+
spec.loader.exec_module(module)
|
|
37
|
+
return module
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_git(repo_root: Path, *args: str) -> tuple[str | None, str | None]:
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["git", "-C", str(repo_root), *args],
|
|
44
|
+
check=False,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
)
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
return None, f"Unable to execute git: {exc}"
|
|
50
|
+
if result.returncode != 0:
|
|
51
|
+
message = result.stderr.strip() or result.stdout.strip() or f"git {' '.join(args)} failed"
|
|
52
|
+
return None, message
|
|
53
|
+
return result.stdout.strip(), None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def detect_git_context(repo_root: Path) -> tuple[dict[str, str], str | None]:
|
|
57
|
+
head_sha, head_error = run_git(repo_root, "rev-parse", "--verify", "HEAD^{commit}")
|
|
58
|
+
if head_error or not head_sha:
|
|
59
|
+
return {}, f"Unable to resolve HEAD commit for Phase 0 diff basis prefill: {head_error or 'git rev-parse returned no output'}"
|
|
60
|
+
|
|
61
|
+
branch_name, branch_error = run_git(repo_root, "symbolic-ref", "--quiet", "--short", "HEAD")
|
|
62
|
+
if branch_error or not branch_name:
|
|
63
|
+
branch_name = "(detached HEAD)"
|
|
64
|
+
|
|
65
|
+
diff_command = f"git diff --name-only {head_sha}"
|
|
66
|
+
return {
|
|
67
|
+
"baseline_type": "local commit",
|
|
68
|
+
"baseline_reference": head_sha,
|
|
69
|
+
"comparison_reference": "working-tree",
|
|
70
|
+
"normalized_baseline": head_sha,
|
|
71
|
+
"normalized_comparison": "working-tree",
|
|
72
|
+
"normalized_diff_command": diff_command,
|
|
73
|
+
"base_branch": branch_name,
|
|
74
|
+
"worktree_branch": branch_name,
|
|
75
|
+
"base_commit": head_sha,
|
|
76
|
+
"notes": "recursive-init prefilled this executable diff basis from the current HEAD commit. If Phase 0 later changes the chosen baseline, update every diff-basis field and rerun lint before locking.",
|
|
77
|
+
}, None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_phase0_diff_basis(repo_root: Path, run_dir: Path) -> str | None:
|
|
81
|
+
lint = load_lint_module()
|
|
82
|
+
diff_basis = lint.get_run_diff_basis(run_dir)
|
|
83
|
+
_normalized_basis, error = lint.normalize_diff_basis(repo_root, diff_basis)
|
|
84
|
+
return error
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_training_loader_query(run_id: str, template: str, from_issue: str) -> str:
|
|
88
|
+
run_phrase = re.sub(r"[-_]+", " ", run_id).strip()
|
|
89
|
+
parts = [f"{template.strip()} recursive run".strip()]
|
|
90
|
+
if run_phrase:
|
|
91
|
+
parts.append(run_phrase)
|
|
92
|
+
if from_issue.strip():
|
|
93
|
+
parts.append(from_issue.strip())
|
|
94
|
+
return " ".join(part for part in parts if part)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_training_loader(repo_root: Path, run_id: str, template: str, from_issue: str) -> int:
|
|
98
|
+
loader_script = repo_root / ".recursive" / "scripts" / "recursive-training-loader.py"
|
|
99
|
+
if not loader_script.exists():
|
|
100
|
+
print("[INFO] recursive-training loader not installed; skipping experiential memory load.")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
query = build_training_loader_query(run_id, template, from_issue)
|
|
104
|
+
command = [
|
|
105
|
+
sys.executable,
|
|
106
|
+
str(loader_script),
|
|
107
|
+
"--repo-root",
|
|
108
|
+
str(repo_root),
|
|
109
|
+
"--query",
|
|
110
|
+
query,
|
|
111
|
+
]
|
|
112
|
+
result = subprocess.run(command, check=False, capture_output=True, text=True)
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
details = result.stderr.strip() or result.stdout.strip() or "no diagnostics emitted"
|
|
115
|
+
print(f"[WARN] recursive-training loader failed: {details}")
|
|
116
|
+
print("[WARN] Continuing without loaded experiential memory.")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
print("[OK] Loaded repository memory context via recursive-training.")
|
|
120
|
+
if result.stdout.strip():
|
|
121
|
+
print(result.stdout.strip())
|
|
122
|
+
if result.stderr.strip():
|
|
123
|
+
print(result.stderr.strip())
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def requirements_content(run_id: str, template: str, from_issue: str) -> str:
|
|
128
|
+
inputs = ["- [chat summary or source notes if captured in repo]"]
|
|
129
|
+
if from_issue.strip():
|
|
130
|
+
inputs.append(f"- Source: {from_issue.strip()}")
|
|
131
|
+
inputs_block = "\n".join(inputs)
|
|
132
|
+
|
|
133
|
+
return f"""Run: `/.recursive/run/{run_id}/`
|
|
134
|
+
Phase: `00 Requirements`
|
|
135
|
+
Status: `DRAFT`
|
|
136
|
+
Workflow version: `recursive-mode-audit-v2`
|
|
137
|
+
Inputs:
|
|
138
|
+
{inputs_block}
|
|
139
|
+
Outputs:
|
|
140
|
+
- `/.recursive/run/{run_id}/00-requirements.md`
|
|
141
|
+
Scope note: This document defines stable requirement identifiers and acceptance criteria. (Template: {template})
|
|
142
|
+
|
|
143
|
+
## TODO
|
|
144
|
+
|
|
145
|
+
- [ ] Elicit requirements from user/context
|
|
146
|
+
- [ ] Define requirement identifiers (R1, R2, ...)
|
|
147
|
+
- [ ] Write acceptance criteria for each requirement
|
|
148
|
+
- [ ] Document out of scope items (OOS1, OOS2, ...)
|
|
149
|
+
- [ ] List constraints and assumptions
|
|
150
|
+
- [ ] Complete Coverage Gate checklist
|
|
151
|
+
- [ ] Complete Approval Gate checklist
|
|
152
|
+
|
|
153
|
+
## Requirements
|
|
154
|
+
|
|
155
|
+
### `R1` <short title>
|
|
156
|
+
|
|
157
|
+
Description:
|
|
158
|
+
Acceptance criteria:
|
|
159
|
+
- [observable condition 1]
|
|
160
|
+
- [observable condition 2]
|
|
161
|
+
|
|
162
|
+
## Out of Scope
|
|
163
|
+
|
|
164
|
+
- `OOS1`: ...
|
|
165
|
+
|
|
166
|
+
## Constraints
|
|
167
|
+
|
|
168
|
+
- ...
|
|
169
|
+
|
|
170
|
+
## Coverage Gate
|
|
171
|
+
...
|
|
172
|
+
Coverage: FAIL
|
|
173
|
+
|
|
174
|
+
## Approval Gate
|
|
175
|
+
...
|
|
176
|
+
Approval: FAIL
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def worktree_content(run_id: str, repo_root: Path, git_context: dict[str, str], prefill_error: str | None) -> str:
|
|
181
|
+
base_branch = git_context.get("base_branch", "(resolve during Phase 0)")
|
|
182
|
+
worktree_branch = git_context.get("worktree_branch", "(resolve during Phase 0)")
|
|
183
|
+
base_commit = git_context.get("base_commit", "<resolve-before-locking>")
|
|
184
|
+
baseline_type = git_context.get("baseline_type", "local commit")
|
|
185
|
+
baseline_reference = git_context.get("baseline_reference", "<resolve-before-locking>")
|
|
186
|
+
comparison_reference = git_context.get("comparison_reference", "working-tree")
|
|
187
|
+
normalized_baseline = git_context.get("normalized_baseline", "<resolve-before-locking>")
|
|
188
|
+
normalized_comparison = git_context.get("normalized_comparison", "working-tree")
|
|
189
|
+
normalized_diff_command = git_context.get("normalized_diff_command", "git diff --name-only <resolve-before-locking>")
|
|
190
|
+
notes = git_context.get(
|
|
191
|
+
"notes",
|
|
192
|
+
"Populate an executable diff basis before locking Phase 0. Lint and lock will fail until the normalized basis matches live git state.",
|
|
193
|
+
)
|
|
194
|
+
setup_note = "recursive-init detected the current repository context and prefilled the Phase 0 diff basis."
|
|
195
|
+
if prefill_error:
|
|
196
|
+
setup_note = f"recursive-init could not prefill the Phase 0 diff basis automatically: {prefill_error}"
|
|
197
|
+
|
|
198
|
+
return f"""Run: `/.recursive/run/{run_id}/`
|
|
199
|
+
Phase: `00 Worktree`
|
|
200
|
+
Status: `DRAFT`
|
|
201
|
+
Inputs:
|
|
202
|
+
- `/.recursive/run/{run_id}/00-requirements.md`
|
|
203
|
+
- Current git repository state
|
|
204
|
+
Outputs:
|
|
205
|
+
- `/.recursive/run/{run_id}/00-worktree.md`
|
|
206
|
+
Scope note: This document records the Phase 0 worktree context and the executable diff basis that all later audited phases must reuse.
|
|
207
|
+
|
|
208
|
+
## TODO
|
|
209
|
+
|
|
210
|
+
- [ ] Confirm the selected worktree location and isolation approach
|
|
211
|
+
- [ ] Confirm the base branch and worktree branch values
|
|
212
|
+
- [ ] Run setup and verify the clean test baseline
|
|
213
|
+
- [ ] Confirm the diff basis fields still match live git state
|
|
214
|
+
- [ ] Complete Coverage Gate checklist
|
|
215
|
+
- [ ] Complete Approval Gate checklist
|
|
216
|
+
|
|
217
|
+
## Directory Selection
|
|
218
|
+
|
|
219
|
+
- Repository root: `{repo_root}`
|
|
220
|
+
- Preferred worktree location: `.worktrees/{run_id}/`
|
|
221
|
+
- Update this section with the actual selected location before locking Phase 0.
|
|
222
|
+
|
|
223
|
+
## Safety Verification
|
|
224
|
+
|
|
225
|
+
- Original branch / repo state observed at init time: `{base_branch}`
|
|
226
|
+
- Isolation still must be confirmed after the actual worktree is created.
|
|
227
|
+
|
|
228
|
+
## Worktree Creation
|
|
229
|
+
|
|
230
|
+
- Intended worktree branch: `{worktree_branch}`
|
|
231
|
+
- Record the actual worktree creation command and output before locking.
|
|
232
|
+
|
|
233
|
+
## Main Branch Protection
|
|
234
|
+
|
|
235
|
+
- Base branch source of truth at init time: `{base_branch}`
|
|
236
|
+
- Explicitly document any deviation from isolated worktree execution before locking.
|
|
237
|
+
|
|
238
|
+
## Project Setup
|
|
239
|
+
|
|
240
|
+
- Init-time note: {setup_note}
|
|
241
|
+
- Replace this section with the actual setup commands and results during Phase 0.
|
|
242
|
+
|
|
243
|
+
## Test Baseline Verification
|
|
244
|
+
|
|
245
|
+
- Record the baseline commands and results after setup completes.
|
|
246
|
+
|
|
247
|
+
## Worktree Context
|
|
248
|
+
|
|
249
|
+
- Base branch: `{base_branch}`
|
|
250
|
+
- Worktree branch: `{worktree_branch}`
|
|
251
|
+
- Base commit: `{base_commit}`
|
|
252
|
+
|
|
253
|
+
## Diff Basis For Later Audits
|
|
254
|
+
|
|
255
|
+
- Baseline type: `{baseline_type}`
|
|
256
|
+
- Baseline reference: `{baseline_reference}`
|
|
257
|
+
- Comparison reference: `{comparison_reference}`
|
|
258
|
+
- Normalized baseline: `{normalized_baseline}`
|
|
259
|
+
- Normalized comparison: `{normalized_comparison}`
|
|
260
|
+
- Normalized diff command: `{normalized_diff_command}`
|
|
261
|
+
- Base branch: `{base_branch}`
|
|
262
|
+
- Worktree branch: `{worktree_branch}`
|
|
263
|
+
- Diff basis notes: `{notes}`
|
|
264
|
+
|
|
265
|
+
## Traceability
|
|
266
|
+
|
|
267
|
+
- Recursive workflow safety -> Phase 0 records a reusable executable diff basis before audited phases begin.
|
|
268
|
+
|
|
269
|
+
## Coverage Gate
|
|
270
|
+
|
|
271
|
+
- [ ] Worktree location and branch context are recorded
|
|
272
|
+
- [ ] Setup and clean baseline verification are recorded
|
|
273
|
+
- [ ] Diff basis fields are executable against live git state
|
|
274
|
+
|
|
275
|
+
Coverage: FAIL
|
|
276
|
+
|
|
277
|
+
## Approval Gate
|
|
278
|
+
|
|
279
|
+
- [ ] Phase 0 context is ready for downstream audited phases
|
|
280
|
+
- [ ] No unresolved setup or diff-basis inconsistencies remain
|
|
281
|
+
|
|
282
|
+
Approval: FAIL
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def main() -> None:
|
|
287
|
+
parser = argparse.ArgumentParser(description="Initialize a recursive-mode run folder and requirements template.")
|
|
288
|
+
parser.add_argument("--run-id", required=True, help="Run ID (folder name under .recursive/run/).")
|
|
289
|
+
parser.add_argument("--repo-root", default=".", help="Repository root path.")
|
|
290
|
+
parser.add_argument(
|
|
291
|
+
"--template",
|
|
292
|
+
choices=["feature", "bugfix", "refactor"],
|
|
293
|
+
default="feature",
|
|
294
|
+
help="Requirements template flavor.",
|
|
295
|
+
)
|
|
296
|
+
parser.add_argument("--from-issue", default="", help="Optional issue/ticket/source reference.")
|
|
297
|
+
parser.add_argument("--force", action="store_true", help="Overwrite existing 00-requirements.md.")
|
|
298
|
+
args = parser.parse_args()
|
|
299
|
+
|
|
300
|
+
repo_root = Path(args.repo_root).resolve()
|
|
301
|
+
print(f"[INFO] Repo root: {repo_root}")
|
|
302
|
+
|
|
303
|
+
run_root = repo_root / ".recursive" / "run"
|
|
304
|
+
run_dir = run_root / args.run_id
|
|
305
|
+
|
|
306
|
+
ensure_directory(run_root)
|
|
307
|
+
ensure_directory(run_dir)
|
|
308
|
+
ensure_directory(run_dir / "addenda")
|
|
309
|
+
ensure_directory(run_dir / "subagents")
|
|
310
|
+
ensure_directory(run_dir / "router-prompts")
|
|
311
|
+
|
|
312
|
+
evidence_dir = run_dir / "evidence"
|
|
313
|
+
ensure_directory(evidence_dir)
|
|
314
|
+
for sub in ("screenshots", "logs", "perf", "traces", "review-bundles", "router", "other"):
|
|
315
|
+
ensure_directory(evidence_dir / sub)
|
|
316
|
+
|
|
317
|
+
requirements_path = run_dir / "00-requirements.md"
|
|
318
|
+
if requirements_path.exists() and not args.force:
|
|
319
|
+
print(f"[INFO] Requirements file exists, not overwriting: {requirements_path}")
|
|
320
|
+
else:
|
|
321
|
+
write_utf8_no_bom(requirements_path, requirements_content(args.run_id, args.template, args.from_issue))
|
|
322
|
+
print(f"[OK] Wrote requirements template: {requirements_path}")
|
|
323
|
+
|
|
324
|
+
git_context, prefill_error = detect_git_context(repo_root)
|
|
325
|
+
if prefill_error:
|
|
326
|
+
print(f"[WARN] {prefill_error}")
|
|
327
|
+
|
|
328
|
+
worktree_path = run_dir / "00-worktree.md"
|
|
329
|
+
if worktree_path.exists() and not args.force:
|
|
330
|
+
print(f"[INFO] Worktree file exists, not overwriting: {worktree_path}")
|
|
331
|
+
else:
|
|
332
|
+
write_utf8_no_bom(worktree_path, worktree_content(args.run_id, repo_root, git_context, prefill_error))
|
|
333
|
+
print(f"[OK] Wrote Phase 0 worktree template: {worktree_path}")
|
|
334
|
+
|
|
335
|
+
diff_basis_error = validate_phase0_diff_basis(repo_root, run_dir)
|
|
336
|
+
if diff_basis_error:
|
|
337
|
+
print(f"[FAIL] 00-worktree.md diff basis is not executable yet: {diff_basis_error}")
|
|
338
|
+
print(" Fix the Phase 0 diff-basis fields before locking or relying on downstream audit tooling.")
|
|
339
|
+
return 1
|
|
340
|
+
print("[OK] Phase 0 diff basis is executable against live git state.")
|
|
341
|
+
|
|
342
|
+
training_status = run_training_loader(repo_root, args.run_id, args.template, args.from_issue)
|
|
343
|
+
if training_status != 0:
|
|
344
|
+
return training_status
|
|
345
|
+
|
|
346
|
+
print()
|
|
347
|
+
print("Next steps:")
|
|
348
|
+
print(f"1) Edit: .recursive/run/{args.run_id}/00-requirements.md")
|
|
349
|
+
print(f"2) Edit: .recursive/run/{args.run_id}/00-worktree.md")
|
|
350
|
+
print(f"3) Run: Implement requirement '{args.run_id}'")
|
|
351
|
+
print("4) Complete the run through the audited Phase 8 closeout before considering it done.")
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Lock a recursive-mode artifact after validating gates, structure, and audit requirements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import hashlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
LOCK_HASH_LINE_RE = re.compile(r"(?m)^[ \t]*LockHash:.*(?:\n|$)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_phase_rules_module():
|
|
21
|
+
module_path = Path(__file__).with_name("recursive_phase_rules.py")
|
|
22
|
+
spec = importlib.util.spec_from_file_location("recursive_phase_rules", module_path)
|
|
23
|
+
if spec is None or spec.loader is None:
|
|
24
|
+
raise RuntimeError(f"Unable to load phase rules module from {module_path}")
|
|
25
|
+
module = importlib.util.module_from_spec(spec)
|
|
26
|
+
try:
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
raise RuntimeError(f"Phase rules module not found: {module_path}")
|
|
30
|
+
return module
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_lint_module():
|
|
34
|
+
module_path = Path(__file__).with_name("lint-recursive-run.py")
|
|
35
|
+
spec = importlib.util.spec_from_file_location("recursive_mode_lint", module_path)
|
|
36
|
+
if spec is None or spec.loader is None:
|
|
37
|
+
raise RuntimeError(f"Unable to load lint module from {module_path}")
|
|
38
|
+
module = importlib.util.module_from_spec(spec)
|
|
39
|
+
spec.loader.exec_module(module)
|
|
40
|
+
return module
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_for_lock_hash(content: str) -> str:
|
|
44
|
+
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
45
|
+
return LOCK_HASH_LINE_RE.sub("", normalized)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def lock_hash_from_content(content: str) -> str:
|
|
49
|
+
normalized = normalize_for_lock_hash(content)
|
|
50
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_field(field_name: str, value: str) -> str:
|
|
54
|
+
return f"{field_name}: `{value}`"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_or_insert_field(content: str, field_name: str, value: str, after_fields: list[str]) -> str:
|
|
58
|
+
line = render_field(field_name, value)
|
|
59
|
+
field_re = re.compile(rf"(?m)^[ \t]*(?:[-*][ \t]+)?{re.escape(field_name)}:\s*.*$")
|
|
60
|
+
if field_re.search(content):
|
|
61
|
+
return field_re.sub(line, content, count=1)
|
|
62
|
+
|
|
63
|
+
lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
64
|
+
insert_at = 0
|
|
65
|
+
for after_field in after_fields:
|
|
66
|
+
after_re = re.compile(rf"^[ \t]*(?:[-*][ \t]+)?{re.escape(after_field)}:\s*.*$")
|
|
67
|
+
for index, existing_line in enumerate(lines):
|
|
68
|
+
if after_re.match(existing_line):
|
|
69
|
+
insert_at = max(insert_at, index + 1)
|
|
70
|
+
lines.insert(insert_at, line)
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_lockable(lint, repo_root: Path, run_dir: Path, artifact_path: Path) -> tuple[list[str], str, str]:
|
|
75
|
+
issues: list[str] = []
|
|
76
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
77
|
+
workflow_profile = lint.get_workflow_profile(run_dir)
|
|
78
|
+
requirement_ids: list[str] = []
|
|
79
|
+
status = lint.get_md_field_value(content, "Status") or "UNKNOWN"
|
|
80
|
+
|
|
81
|
+
requirements_path = run_dir / "00-requirements.md"
|
|
82
|
+
if requirements_path.exists():
|
|
83
|
+
requirement_ids = lint.parse_requirement_ids(requirements_path.read_text(encoding="utf-8"))
|
|
84
|
+
|
|
85
|
+
actual_changed_files: list[str] | None = None
|
|
86
|
+
diff_basis_error: str | None = None
|
|
87
|
+
if workflow_profile == lint.STRICT_WORKFLOW_PROFILE:
|
|
88
|
+
diff_basis = lint.get_run_diff_basis(run_dir)
|
|
89
|
+
raw_changed_files, diff_basis_error = lint.get_git_changed_files(repo_root, diff_basis)
|
|
90
|
+
if raw_changed_files is not None:
|
|
91
|
+
actual_changed_files = lint.filter_runtime_changed_files(raw_changed_files, run_dir.name)
|
|
92
|
+
|
|
93
|
+
if status not in {"DRAFT", "LOCKED"}:
|
|
94
|
+
issues.append(f"Artifact Status must be DRAFT before locking (found {status})")
|
|
95
|
+
|
|
96
|
+
missing_header_fields = [
|
|
97
|
+
field for field in ("Run", "Phase", "Status", "Inputs", "Outputs", "Scope note") if not lint.has_header_field(content, field)
|
|
98
|
+
]
|
|
99
|
+
if missing_header_fields:
|
|
100
|
+
issues.append(f"Missing required header field(s): {', '.join(missing_header_fields)}")
|
|
101
|
+
|
|
102
|
+
todo_has_section, _todo_total, _todo_checked, todo_unchecked = lint.get_todo_stats(content)
|
|
103
|
+
if not todo_has_section:
|
|
104
|
+
issues.append("Missing required section: ## TODO")
|
|
105
|
+
elif todo_unchecked > 0:
|
|
106
|
+
issues.append(f"Unchecked TODO items remain: {todo_unchecked}")
|
|
107
|
+
|
|
108
|
+
for heading in lint.get_artifact_required_sections(artifact_path.name, workflow_profile):
|
|
109
|
+
if not lint.has_heading(content, heading):
|
|
110
|
+
issues.append(f"Missing required section heading: ## {heading}")
|
|
111
|
+
|
|
112
|
+
for gate_name in ("Coverage", "Approval"):
|
|
113
|
+
if not lint.has_gate_line(content, gate_name):
|
|
114
|
+
issues.append(f"Missing required gate line: {gate_name}: PASS|FAIL")
|
|
115
|
+
|
|
116
|
+
issues.extend(lint.lint_traceability(artifact_path, content, requirement_ids))
|
|
117
|
+
issues.extend(
|
|
118
|
+
lint.lint_audit_sections(
|
|
119
|
+
artifact_path,
|
|
120
|
+
content,
|
|
121
|
+
workflow_profile,
|
|
122
|
+
actual_changed_files,
|
|
123
|
+
diff_basis_error,
|
|
124
|
+
run_dir.name,
|
|
125
|
+
run_dir,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
issues.extend(
|
|
129
|
+
lint.lint_phase_specific_rules(
|
|
130
|
+
artifact_path,
|
|
131
|
+
content,
|
|
132
|
+
workflow_profile,
|
|
133
|
+
run_dir,
|
|
134
|
+
repo_root,
|
|
135
|
+
requirement_ids,
|
|
136
|
+
actual_changed_files,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if lint.get_gate_status(content, "Coverage") != "PASS":
|
|
141
|
+
issues.append("Coverage gate must be PASS before locking")
|
|
142
|
+
if lint.get_gate_status(content, "Approval") != "PASS":
|
|
143
|
+
issues.append("Approval gate must be PASS before locking")
|
|
144
|
+
if artifact_path.name == "03-implementation-summary.md" and lint.get_gate_status(content, "TDD Compliance") != "PASS":
|
|
145
|
+
issues.append("TDD Compliance gate must be PASS before locking Phase 3")
|
|
146
|
+
if workflow_profile == lint.STRICT_WORKFLOW_PROFILE and artifact_path.name in lint.AUDITED_PHASE_FILES:
|
|
147
|
+
if lint.get_gate_status(content, "Audit") != "PASS":
|
|
148
|
+
issues.append("Audit gate must be PASS before locking this audited phase")
|
|
149
|
+
|
|
150
|
+
deduped_issues = sorted(set(issues))
|
|
151
|
+
if status == "LOCKED":
|
|
152
|
+
locked_at = lint.get_md_field_value(content, "LockedAt")
|
|
153
|
+
stored_hash = lint.get_md_field_value(content, "LockHash")
|
|
154
|
+
actual_hash = lock_hash_from_content(content) if stored_hash else None
|
|
155
|
+
if locked_at and stored_hash and actual_hash and stored_hash.lower() == actual_hash.lower() and not deduped_issues:
|
|
156
|
+
return [], content, workflow_profile
|
|
157
|
+
if not deduped_issues:
|
|
158
|
+
deduped_issues.append("Artifact is already LOCKED but its lock metadata is invalid")
|
|
159
|
+
else:
|
|
160
|
+
deduped_issues.append("Artifact is already LOCKED but not lock-valid under current rules; set it back to DRAFT before re-locking")
|
|
161
|
+
|
|
162
|
+
return sorted(set(deduped_issues)), content, workflow_profile
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_artifact_path(run_dir: Path, artifact_arg: str) -> Path:
|
|
166
|
+
artifact_path = Path(artifact_arg)
|
|
167
|
+
if not artifact_path.is_absolute():
|
|
168
|
+
artifact_path = run_dir / artifact_path
|
|
169
|
+
artifact_path = artifact_path.resolve()
|
|
170
|
+
run_root = run_dir.resolve()
|
|
171
|
+
try:
|
|
172
|
+
artifact_path.relative_to(run_root)
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
raise ValueError(f"Artifact path must stay within the run directory: {run_root}") from exc
|
|
175
|
+
return artifact_path
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def reopen_artifact(phase_rules, run_dir: Path, artifact_path: Path, repo_root: Path) -> int:
|
|
179
|
+
"""
|
|
180
|
+
Revert a locked artifact back to DRAFT status and invalidate all downstream
|
|
181
|
+
lock receipts so they must be re-locked in order.
|
|
182
|
+
"""
|
|
183
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
184
|
+
status_re = re.compile(r"(?m)^[ \t]*Status:.*$")
|
|
185
|
+
locked_at_re = re.compile(r"(?m)^[ \t]*LockedAt:.*\n?")
|
|
186
|
+
hash_re = re.compile(r"(?m)^[ \t]*LockHash:.*\n?")
|
|
187
|
+
|
|
188
|
+
updated = status_re.sub("Status: `DRAFT`", content, count=1)
|
|
189
|
+
updated = locked_at_re.sub("", updated, count=1)
|
|
190
|
+
updated = hash_re.sub("", updated, count=1)
|
|
191
|
+
|
|
192
|
+
temp_path = artifact_path.with_name(f".{artifact_path.name}.tmp")
|
|
193
|
+
temp_path.write_text(updated, encoding="utf-8", newline="\n")
|
|
194
|
+
temp_path.replace(artifact_path)
|
|
195
|
+
|
|
196
|
+
rel = artifact_path.relative_to(repo_root)
|
|
197
|
+
print(f"[OK] Reopened {rel}: reverted to DRAFT")
|
|
198
|
+
|
|
199
|
+
artifact_name = artifact_path.name
|
|
200
|
+
if phase_rules.is_core_artifact(artifact_name):
|
|
201
|
+
removed = phase_rules.invalidate_receipt(run_dir, artifact_name)
|
|
202
|
+
if removed:
|
|
203
|
+
print(f"[OK] Invalidated lock receipt for {artifact_name}")
|
|
204
|
+
|
|
205
|
+
stale = phase_rules.get_stale_downstream_phases(artifact_name, run_dir)
|
|
206
|
+
if stale:
|
|
207
|
+
print("[WARN] The following downstream phases have stale receipts; re-lock them in order after fixing this phase:")
|
|
208
|
+
for entry in stale:
|
|
209
|
+
phase_rules.invalidate_receipt(run_dir, entry["artifact"])
|
|
210
|
+
print(f" - {entry['artifact']}: {entry['reason']} (receipt invalidated)")
|
|
211
|
+
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main() -> int:
|
|
216
|
+
parser = argparse.ArgumentParser(description="Lock a recursive-mode artifact after validating gates and structure.")
|
|
217
|
+
parser.add_argument("--run-id", required=True, help="Run ID under .recursive/run/.")
|
|
218
|
+
parser.add_argument("--artifact", required=True, help="Artifact file inside the run directory, e.g. 04-test-summary.md or addenda/foo.addendum-01.md")
|
|
219
|
+
parser.add_argument("--repo-root", default=".", help="Repository root path.")
|
|
220
|
+
parser.add_argument("--reopen", action="store_true", help="Reopen a locked artifact: revert to DRAFT, remove lock metadata, and invalidate downstream receipts.")
|
|
221
|
+
args = parser.parse_args()
|
|
222
|
+
|
|
223
|
+
repo_root = Path(args.repo_root).resolve()
|
|
224
|
+
run_dir = repo_root / ".recursive" / "run" / args.run_id.strip()
|
|
225
|
+
if not run_dir.exists():
|
|
226
|
+
print(f"[FAIL] Run directory not found: {run_dir}")
|
|
227
|
+
return 1
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
artifact_path = resolve_artifact_path(run_dir, args.artifact.strip())
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
print(f"[FAIL] {exc}")
|
|
233
|
+
return 1
|
|
234
|
+
|
|
235
|
+
if not artifact_path.exists():
|
|
236
|
+
print(f"[FAIL] Artifact not found: {artifact_path}")
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
phase_rules = load_phase_rules_module()
|
|
240
|
+
|
|
241
|
+
if args.reopen:
|
|
242
|
+
return reopen_artifact(phase_rules, run_dir, artifact_path, repo_root)
|
|
243
|
+
|
|
244
|
+
# Prerequisite gate: all earlier phases that exist must be LOCKED first.
|
|
245
|
+
artifact_name = artifact_path.name
|
|
246
|
+
if phase_rules.is_core_artifact(artifact_name):
|
|
247
|
+
blockers = phase_rules.get_prerequisite_blockers(artifact_name, run_dir)
|
|
248
|
+
if blockers:
|
|
249
|
+
print(f"[FAIL] Cannot lock {artifact_path.relative_to(repo_root)}: prerequisite phases are not yet LOCKED")
|
|
250
|
+
for blocker in blockers:
|
|
251
|
+
print(f" - {blocker['artifact']}: {blocker['status']} ({blocker['path']})")
|
|
252
|
+
return 1
|
|
253
|
+
|
|
254
|
+
lint = load_lint_module()
|
|
255
|
+
issues, content, workflow_profile = validate_lockable(lint, repo_root, run_dir, artifact_path)
|
|
256
|
+
|
|
257
|
+
if not issues:
|
|
258
|
+
status = lint.get_md_field_value(content, "Status") or "UNKNOWN"
|
|
259
|
+
if status == "LOCKED":
|
|
260
|
+
print(f"[OK] Artifact already lock-valid: {artifact_path.relative_to(repo_root)}")
|
|
261
|
+
print(f"Workflow Profile: {workflow_profile}")
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
if issues:
|
|
265
|
+
print(f"[FAIL] Refusing to lock {artifact_path.relative_to(repo_root)}")
|
|
266
|
+
print(f"Workflow Profile: {workflow_profile}")
|
|
267
|
+
for issue in issues:
|
|
268
|
+
print(f"- {issue}")
|
|
269
|
+
return 1
|
|
270
|
+
|
|
271
|
+
locked_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
272
|
+
updated = set_or_insert_field(content, "Status", "LOCKED", ["Phase"])
|
|
273
|
+
updated = set_or_insert_field(updated, "LockedAt", locked_at, ["Status"])
|
|
274
|
+
provisional = set_or_insert_field(updated, "LockHash", "0" * 64, ["LockedAt", "Status"])
|
|
275
|
+
lock_hash = lock_hash_from_content(provisional)
|
|
276
|
+
final_content = set_or_insert_field(updated, "LockHash", lock_hash, ["LockedAt", "Status"])
|
|
277
|
+
|
|
278
|
+
temp_path = artifact_path.with_name(f".{artifact_path.name}.tmp")
|
|
279
|
+
temp_path.write_text(final_content, encoding="utf-8", newline="\n")
|
|
280
|
+
temp_path.replace(artifact_path)
|
|
281
|
+
|
|
282
|
+
# Write lock receipt for chain tracking.
|
|
283
|
+
if phase_rules.is_core_artifact(artifact_name):
|
|
284
|
+
phase_rules.write_receipt(run_dir, artifact_name, artifact_path)
|
|
285
|
+
|
|
286
|
+
# Report stale downstream phases so the caller knows what needs re-locking.
|
|
287
|
+
if phase_rules.is_core_artifact(artifact_name):
|
|
288
|
+
stale = phase_rules.get_stale_downstream_phases(artifact_name, run_dir)
|
|
289
|
+
if stale:
|
|
290
|
+
print(f"[WARN] The following downstream phases have stale receipts and may need re-locking:")
|
|
291
|
+
for entry in stale:
|
|
292
|
+
print(f" - {entry['artifact']}: {entry['reason']}")
|
|
293
|
+
|
|
294
|
+
print(f"[OK] Locked {artifact_path.relative_to(repo_root)}")
|
|
295
|
+
print(f"Workflow Profile: {workflow_profile}")
|
|
296
|
+
print(f"LockedAt: {locked_at}")
|
|
297
|
+
print(f"LockHash: {lock_hash}")
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
raise SystemExit(main())
|