@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.
Files changed (88) hide show
  1. package/cordis.patch.yml +12 -0
  2. package/lib/bootstrap.d.ts +35 -0
  3. package/lib/client/board.d.ts +10 -0
  4. package/lib/client/contract.d.ts +51 -0
  5. package/lib/client/derive.d.ts +92 -0
  6. package/lib/client/index.d.ts +21 -0
  7. package/lib/client/inspector.d.ts +10 -0
  8. package/lib/client/node.d.ts +71 -0
  9. package/lib/client/settings.d.ts +6 -0
  10. package/lib/client/slots.d.ts +7 -0
  11. package/lib/client/strip.d.ts +7 -0
  12. package/lib/client.d.ts +10 -0
  13. package/lib/client.js +490 -0
  14. package/lib/closeout.d.ts +23 -0
  15. package/lib/commands.d.ts +51 -0
  16. package/lib/delegation.d.ts +92 -0
  17. package/lib/enforcement.d.ts +53 -0
  18. package/lib/events.d.ts +173 -0
  19. package/lib/handoff.d.ts +51 -0
  20. package/lib/index.d.ts +40 -0
  21. package/lib/lifecycle.d.ts +107 -0
  22. package/lib/lock.d.ts +92 -0
  23. package/lib/policy.d.ts +12 -0
  24. package/lib/projection.d.ts +29 -0
  25. package/lib/recursive_closeout.tool.d.ts +8 -0
  26. package/lib/recursive_init.tool.d.ts +2 -0
  27. package/lib/recursive_lint.tool.d.ts +2 -0
  28. package/lib/recursive_lock.tool.d.ts +2 -0
  29. package/lib/recursive_scratch.tool.d.ts +7 -0
  30. package/lib/recursive_status.tool.d.ts +2 -0
  31. package/lib/review.d.ts +39 -0
  32. package/lib/router.d.ts +77 -0
  33. package/lib/run.d.ts +29 -0
  34. package/lib/runtime.d.ts +241 -0
  35. package/lib/scratch.d.ts +18 -0
  36. package/lib/status.d.ts +19 -0
  37. package/lib/types.d.ts +104 -0
  38. package/lib/workspace.d.ts +50 -0
  39. package/package.json +119 -0
  40. package/preset/recursive/agent.cordis.yml +282 -0
  41. package/preset/recursive/preset.yml +3 -0
  42. package/scripts/install-recursive-mode.ps1 +956 -0
  43. package/scripts/install-recursive-mode.py +750 -0
  44. package/scripts/lint-recursive-run.py +2868 -0
  45. package/scripts/recursive-closeout.py +541 -0
  46. package/scripts/recursive-init.py +356 -0
  47. package/scripts/recursive-lock.py +302 -0
  48. package/scripts/recursive-status.py +2124 -0
  49. package/scripts/recursive_phase_rules.py +367 -0
  50. package/scripts/recursive_router_lib.py +2282 -0
  51. package/scripts/test-recursive-mode-smoke.ts +204 -0
  52. package/scripts/verify-locks.py +353 -0
  53. package/src/bootstrap.ts +118 -0
  54. package/src/client/board.tsx +61 -0
  55. package/src/client/contract.ts +58 -0
  56. package/src/client/derive.ts +241 -0
  57. package/src/client/index.ts +28 -0
  58. package/src/client/inspector.tsx +49 -0
  59. package/src/client/node.ts +156 -0
  60. package/src/client/settings.tsx +18 -0
  61. package/src/client/slots.ts +67 -0
  62. package/src/client/strip.tsx +28 -0
  63. package/src/client.ts +11 -0
  64. package/src/closeout.ts +183 -0
  65. package/src/commands.ts +142 -0
  66. package/src/delegation.ts +306 -0
  67. package/src/enforcement.ts +180 -0
  68. package/src/events.ts +173 -0
  69. package/src/handoff.ts +165 -0
  70. package/src/index.ts +283 -0
  71. package/src/lifecycle.ts +235 -0
  72. package/src/lock.ts +369 -0
  73. package/src/policy.ts +56 -0
  74. package/src/projection.ts +237 -0
  75. package/src/recursive_closeout.tool.ts +35 -0
  76. package/src/recursive_init.tool.ts +28 -0
  77. package/src/recursive_lint.tool.ts +29 -0
  78. package/src/recursive_lock.tool.ts +33 -0
  79. package/src/recursive_scratch.tool.ts +42 -0
  80. package/src/recursive_status.tool.ts +24 -0
  81. package/src/review.ts +178 -0
  82. package/src/router.ts +197 -0
  83. package/src/run.ts +85 -0
  84. package/src/runtime.ts +564 -0
  85. package/src/scratch.ts +85 -0
  86. package/src/status.ts +194 -0
  87. package/src/types.ts +112 -0
  88. 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())