@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,2124 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Show recursive-mode run status, lock-chain validity, and audit blockers.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import hashlib
10
+ import importlib.util
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+
18
+ CURRENT_WORKFLOW_PROFILE = "recursive-mode-audit-v2"
19
+ STRICT_WORKFLOW_PROFILE = "recursive-mode-audit-v1"
20
+ COMPAT_WORKFLOW_PROFILE = "memory-phase8"
21
+ STRICT_WORKFLOW_PROFILES = {CURRENT_WORKFLOW_PROFILE, STRICT_WORKFLOW_PROFILE}
22
+ LATE_PHASE_KEYS = {"06", "07", "08"}
23
+ LATE_PHASE_FILES = {"06-decisions-update.md", "07-state-update.md", "08-memory-impact.md"}
24
+ AUDITED_PHASE_FILES = {
25
+ "01-as-is.md",
26
+ "01.5-root-cause.md",
27
+ "02-to-be-plan.md",
28
+ "03-implementation-summary.md",
29
+ "03.5-code-review.md",
30
+ "04-test-summary.md",
31
+ "06-decisions-update.md",
32
+ "07-state-update.md",
33
+ "08-memory-impact.md",
34
+ }
35
+ PRIOR_RECURSIVE_EVIDENCE_FILES = {
36
+ "01-as-is.md",
37
+ "02-to-be-plan.md",
38
+ "04-test-summary.md",
39
+ "07-state-update.md",
40
+ "08-memory-impact.md",
41
+ }
42
+ DIFF_AUDITED_FILES = {
43
+ "02-to-be-plan.md",
44
+ "03-implementation-summary.md",
45
+ "03.5-code-review.md",
46
+ "04-test-summary.md",
47
+ "06-decisions-update.md",
48
+ "07-state-update.md",
49
+ "08-memory-impact.md",
50
+ }
51
+ PRODUCT_DIFF_PHASE_FILES = {"03-implementation-summary.md", "03.5-code-review.md", "04-test-summary.md"}
52
+ DECISIONS_DIFF_PHASE_FILES = {"06-decisions-update.md"}
53
+ STATE_DIFF_PHASE_FILES = {"07-state-update.md"}
54
+ MEMORY_DIFF_PHASE_FILES = {"08-memory-impact.md"}
55
+ LOCK_HASH_LINE_RE = re.compile(r"(?m)^[ \t]*LockHash:.*(?:\n|$)")
56
+ TDD_MODES = {"strict", "pragmatic"}
57
+ QA_EXECUTION_MODES = {"human", "agent-operated", "hybrid"}
58
+ TRANSIENT_RUNTIME_DIR_MARKERS = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".hypothesis", ".tox", ".nox"}
59
+ TRANSIENT_RUNTIME_FILE_NAMES = {".ds_store", "thumbs.db"}
60
+ TRANSIENT_RUNTIME_SUFFIXES = (".pyc", ".pyo", ".pyd")
61
+ DIFF_BASIS_ALLOWED_TYPES = {"local commit", "local branch", "remote ref", "merge-base derived"}
62
+ WORKING_TREE_COMPARISON_REFS = {"working-tree", "working-tree@head", "worktree", "working-tree+head"}
63
+ REQUIREMENT_DISPOSITION_STATUSES = {
64
+ "implemented",
65
+ "verified",
66
+ "deferred",
67
+ "out-of-scope",
68
+ "blocked",
69
+ "superseded by approved addendum",
70
+ }
71
+ SKILL_USAGE_RELEVANCE_STATUSES = {"relevant", "not-relevant", "yes", "no"}
72
+ FINAL_REQUIREMENT_DISPOSITION_FILES = {
73
+ "04-test-summary.md",
74
+ "06-decisions-update.md",
75
+ "07-state-update.md",
76
+ "08-memory-impact.md",
77
+ }
78
+ REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES = {
79
+ "03-implementation-summary.md",
80
+ "03.5-code-review.md",
81
+ "04-test-summary.md",
82
+ }
83
+ AUDIT_REQUIRED_HEADINGS = [
84
+ "Audit Context",
85
+ "Effective Inputs Re-read",
86
+ "Earlier Phase Reconciliation",
87
+ "Subagent Contribution Verification",
88
+ "Worktree Diff Audit",
89
+ "Gaps Found",
90
+ "Repair Work Performed",
91
+ "Requirement Completion Status",
92
+ "Audit Verdict",
93
+ ]
94
+ DIFF_BASIS_FIELDS = [
95
+ "Baseline type",
96
+ "Baseline reference",
97
+ "Comparison reference",
98
+ "Normalized baseline",
99
+ "Normalized comparison",
100
+ "Normalized diff command",
101
+ ]
102
+ SKILL_MEMORY_ROUTER_NAMES = {"MEMORY.md", "SKILLS.md"}
103
+ SUBAGENT_ACTION_REQUIRED_HEADINGS = [
104
+ "Metadata",
105
+ "Inputs Provided",
106
+ "Claimed Actions Taken",
107
+ "Claimed File Impact",
108
+ "Claimed Artifact Impact",
109
+ "Claimed Findings",
110
+ "Verification Handoff",
111
+ ]
112
+ RUN_ARTIFACT_SEQUENCE = [
113
+ "00-requirements.md",
114
+ "00-worktree.md",
115
+ "01-as-is.md",
116
+ "01.5-root-cause.md",
117
+ "02-to-be-plan.md",
118
+ "03-implementation-summary.md",
119
+ "03.5-code-review.md",
120
+ "04-test-summary.md",
121
+ "05-manual-qa.md",
122
+ "06-decisions-update.md",
123
+ "07-state-update.md",
124
+ "08-memory-impact.md",
125
+ ]
126
+
127
+
128
+ def load_lint_module():
129
+ module_path = Path(__file__).with_name("lint-recursive-run.py")
130
+ spec = importlib.util.spec_from_file_location("recursive_mode_lint", module_path)
131
+ if spec is None or spec.loader is None:
132
+ raise RuntimeError(f"Unable to load lint module from {module_path}")
133
+ module = importlib.util.module_from_spec(spec)
134
+ spec.loader.exec_module(module)
135
+ return module
136
+
137
+
138
+ def load_phase_rules_module():
139
+ module_path = Path(__file__).with_name("recursive_phase_rules.py")
140
+ spec = importlib.util.spec_from_file_location("recursive_phase_rules", module_path)
141
+ if spec is None or spec.loader is None:
142
+ raise RuntimeError(f"Unable to load phase rules module from {module_path}")
143
+ module = importlib.util.module_from_spec(spec)
144
+ try:
145
+ spec.loader.exec_module(module)
146
+ except FileNotFoundError:
147
+ raise RuntimeError(f"Phase rules module not found: {module_path}")
148
+ return module
149
+
150
+
151
+ def trim_md_value(value: str) -> str:
152
+ trimmed = value.strip()
153
+ for quote in ("`", '"', "'"):
154
+ if trimmed.startswith(quote) and trimmed.endswith(quote) and len(trimmed) >= 2:
155
+ inner = trimmed[1:-1]
156
+ if quote not in inner:
157
+ return inner.strip()
158
+ return trimmed
159
+
160
+
161
+ def get_md_field_value(content: str, field_name: str) -> str | None:
162
+ pattern = re.compile(rf"(?m)^[ \t]*(?:[-*][ \t]+)?{re.escape(field_name)}:[ \t]*(.+?)\s*$")
163
+ match = pattern.search(content)
164
+ if not match:
165
+ return None
166
+ return trim_md_value(match.group(1))
167
+
168
+
169
+ def has_gate_line(content: str, gate_name: str) -> bool:
170
+ pattern = re.compile(rf"(?m)^[ \t]*{re.escape(gate_name)}:\s*(PASS|FAIL)\s*$")
171
+ return bool(pattern.search(content))
172
+
173
+
174
+ def get_gate_status(content: str, gate_name: str) -> str:
175
+ pattern = re.compile(rf"(?m)^[ \t]*{re.escape(gate_name)}:\s*(PASS|FAIL)\s*$")
176
+ match = pattern.search(content)
177
+ return match.group(1).upper() if match else "MISSING"
178
+
179
+
180
+ def get_heading_body(content: str, heading_text: str) -> str:
181
+ pattern = re.compile(
182
+ rf"(?ms)^[ \t]*##\s+{re.escape(heading_text)}\s*$\n?(.*?)(?=^[ \t]*##\s+|\Z)"
183
+ )
184
+ match = pattern.search(content)
185
+ if not match:
186
+ return ""
187
+ return match.group(1).strip()
188
+
189
+
190
+ def get_subheading_body(content: str, heading_text: str, level: int = 3) -> str:
191
+ hashes = "#" * level
192
+ pattern = re.compile(
193
+ rf"(?ms)^[ \t]*{re.escape(hashes)}\s+{re.escape(heading_text)}\s*$\n?(.*?)(?=^[ \t]*#{{1,{level}}}\s+|\Z)"
194
+ )
195
+ match = pattern.search(content)
196
+ if not match:
197
+ return ""
198
+ return match.group(1).strip()
199
+
200
+
201
+ def normalize_for_lock_hash(content: str) -> str:
202
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n")
203
+ return LOCK_HASH_LINE_RE.sub("", normalized)
204
+
205
+
206
+ def lock_hash_from_content(content: str) -> str:
207
+ normalized = normalize_for_lock_hash(content)
208
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
209
+
210
+
211
+ def get_todo_stats(content: str) -> tuple[bool, int, int, int]:
212
+ lines = content.splitlines()
213
+ in_todo = False
214
+ has_todo = False
215
+ checked = 0
216
+ unchecked = 0
217
+ total = 0
218
+
219
+ for line in lines:
220
+ if not in_todo:
221
+ if re.match(r"^\s*##\s+TODO\s*$", line):
222
+ in_todo = True
223
+ has_todo = True
224
+ continue
225
+
226
+ if re.match(r"^\s*##\s+", line) or re.match(r"^\s*#\s+", line):
227
+ break
228
+
229
+ item = re.match(r"^\s*[-*]\s+\[([ xX])\]\s+", line)
230
+ if item:
231
+ total += 1
232
+ if item.group(1).lower() == "x":
233
+ checked += 1
234
+ else:
235
+ unchecked += 1
236
+
237
+ return has_todo, total, checked, unchecked
238
+
239
+
240
+ def parse_requirement_ids(requirements_content: str) -> list[str]:
241
+ requirements_body = get_heading_body(requirements_content, "Requirements") or requirements_content
242
+ ids = sorted(set(re.findall(r"\bR\d+\b", requirements_body)), key=lambda value: int(value[1:]))
243
+ return ids
244
+
245
+
246
+ def extract_paths_from_text(text: str) -> set[str]:
247
+ paths: set[str] = set()
248
+ for candidate in re.findall(r"`([^`\n]+)`", text):
249
+ normalized = candidate.strip().replace("\\", "/").lstrip("/")
250
+ if not normalized or normalized.lower().startswith("git "):
251
+ continue
252
+ if normalized.startswith("<") and normalized.endswith(">"):
253
+ continue
254
+ if "/" in normalized or "." in Path(normalized).name:
255
+ paths.add(normalized)
256
+ return paths
257
+
258
+
259
+ def extract_paths_from_field_value(text: str) -> set[str]:
260
+ paths = extract_paths_from_text(text)
261
+ if paths:
262
+ return paths
263
+ discovered: set[str] = set()
264
+ for candidate in re.split(r"[,;\n]", trim_md_value(text)):
265
+ normalized = candidate.strip().replace("\\", "/").lstrip("/")
266
+ if not normalized or normalized.lower().startswith("git "):
267
+ continue
268
+ if normalized.startswith("<") and normalized.endswith(">"):
269
+ continue
270
+ if "/" in normalized or "." in Path(normalized).name:
271
+ discovered.add(normalized)
272
+ return discovered
273
+
274
+
275
+ def extract_paths_from_named_field(content: str, field_name: str) -> set[str]:
276
+ inline_value = get_md_field_value(content, field_name)
277
+ if inline_value is not None:
278
+ return extract_paths_from_field_value(inline_value)
279
+
280
+ pattern = re.compile(
281
+ rf"(?ms)^[ \t]*(?:[-*][ \t]+)?{re.escape(field_name)}:[ \t]*$\n(.*?)(?=^[ \t]*(?:[-*][ \t]+)?[A-Za-z][^:\n]*:[ \t]*|\Z)"
282
+ )
283
+ match = pattern.search(content)
284
+ if not match:
285
+ return set()
286
+ return {
287
+ normalize_repo_path(path)
288
+ for path in extract_paths_from_text(match.group(1))
289
+ if normalize_repo_path(path)
290
+ }
291
+
292
+
293
+ def get_named_field_text(content: str, field_name: str) -> str | None:
294
+ inline_value = get_md_field_value(content, field_name)
295
+ if inline_value is not None:
296
+ return inline_value
297
+
298
+ pattern = re.compile(
299
+ rf"(?ms)^[ \t]*(?:[-*][ \t]+)?{re.escape(field_name)}:[ \t]*$\n(.*?)(?=^[ \t]*(?:[-*][ \t]+)?[A-Za-z][^:\n]*:[ \t]*|\Z)"
300
+ )
301
+ match = pattern.search(content)
302
+ if not match:
303
+ return None
304
+ return match.group(1).strip()
305
+
306
+
307
+ def has_meaningful_value(value: str | None, *, disallowed: set[str] | None = None) -> bool:
308
+ if value is None:
309
+ return False
310
+ normalized = trim_md_value(value).strip()
311
+ if not normalized:
312
+ return False
313
+ return normalized.lower() not in (disallowed or set())
314
+
315
+
316
+ def collect_subagent_delegation_blockers(audit_context: str) -> list[str]:
317
+ blockers: list[str] = []
318
+ audit_execution_mode = get_md_field_value(audit_context, "Audit Execution Mode")
319
+ subagent_availability = get_md_field_value(audit_context, "Subagent Availability")
320
+ override_reason = get_md_field_value(audit_context, "Delegation Override Reason")
321
+
322
+ if subagent_availability == "unavailable" and audit_execution_mode == "subagent":
323
+ blockers.append("Audit Execution Mode cannot be subagent when Subagent Availability is unavailable")
324
+
325
+ if subagent_availability == "available" and audit_execution_mode == "self-audit":
326
+ if not has_meaningful_value(override_reason, disallowed={"n/a", "none"}):
327
+ blockers.append("Delegation Override Reason is required when available subagents were not used")
328
+
329
+ return blockers
330
+
331
+
332
+ def collect_paths_under_prefix(text: str, prefix: str) -> list[str]:
333
+ return sorted(path for path in extract_paths_from_text(text) if path.startswith(prefix))
334
+
335
+
336
+ def find_missing_repo_paths(repo_root: Path, paths: list[str]) -> list[str]:
337
+ return [path for path in paths if not (repo_root / path).exists()]
338
+
339
+
340
+ def normalize_repo_path(raw_path: str) -> str:
341
+ return raw_path.replace("\\", "/").strip().lstrip("/")
342
+
343
+
344
+ def is_placeholder_only(text: str) -> bool:
345
+ compact = text.strip()
346
+ if not compact:
347
+ return True
348
+ return compact in {"...", "<content>", "[...]", "[same structure]"}
349
+
350
+
351
+ def is_addendum_artifact(file_name: str) -> bool:
352
+ return ".addendum-" in file_name
353
+
354
+
355
+ def is_transient_runtime_path(normalized_path: str) -> bool:
356
+ candidate = normalize_repo_path(normalized_path)
357
+ if not candidate:
358
+ return False
359
+ parts = Path(candidate).parts
360
+ if any(part in TRANSIENT_RUNTIME_DIR_MARKERS for part in parts):
361
+ return True
362
+ file_name = Path(candidate).name.lower()
363
+ if file_name in TRANSIENT_RUNTIME_FILE_NAMES:
364
+ return True
365
+ return file_name.endswith(TRANSIENT_RUNTIME_SUFFIXES)
366
+
367
+
368
+ def filter_runtime_changed_files(paths: list[str], run_id: str) -> list[str]:
369
+ filtered: list[str] = []
370
+ for raw_path in paths:
371
+ normalized = normalize_repo_path(raw_path)
372
+ if not normalized:
373
+ continue
374
+ if normalized.startswith(f".recursive/run/{run_id}/"):
375
+ continue
376
+ if is_transient_runtime_path(normalized):
377
+ continue
378
+ filtered.append(normalized)
379
+ return sorted(set(filtered))
380
+
381
+
382
+ def content_sha256(content: str) -> str:
383
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n")
384
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
385
+
386
+
387
+ def run_git(repo_root: Path, *args: str) -> tuple[str | None, str | None]:
388
+ try:
389
+ result = subprocess.run(
390
+ ["git", "-C", str(repo_root), *args],
391
+ check=False,
392
+ capture_output=True,
393
+ text=True,
394
+ )
395
+ except OSError as exc:
396
+ return None, f"Unable to execute git: {exc}"
397
+ if result.returncode != 0:
398
+ message = result.stderr.strip() or result.stdout.strip() or f"git {' '.join(args)} failed"
399
+ return None, message
400
+ return result.stdout.strip(), None
401
+
402
+
403
+ def normalize_baseline_type(value: str | None) -> str | None:
404
+ if value is None:
405
+ return None
406
+ compact = trim_md_value(value).strip().lower().replace("-", " ")
407
+ compact = re.sub(r"\s+", " ", compact)
408
+ if compact in DIFF_BASIS_ALLOWED_TYPES:
409
+ return compact
410
+ aliases = {
411
+ "commit": "local commit",
412
+ "branch": "local branch",
413
+ "remote": "remote ref",
414
+ "remote branch": "remote ref",
415
+ "merge base": "merge-base derived",
416
+ }
417
+ return aliases.get(compact)
418
+
419
+
420
+ def normalize_comparison_reference(value: str | None) -> str | None:
421
+ if value is None:
422
+ return None
423
+ compact = trim_md_value(value).strip()
424
+ if not compact:
425
+ return None
426
+ if compact.lower() in WORKING_TREE_COMPARISON_REFS:
427
+ return "working-tree"
428
+ return compact
429
+
430
+
431
+ def parse_diff_basis_source(content: str) -> str:
432
+ diff_body = get_heading_body(content, "Diff Basis For Later Audits")
433
+ if diff_body:
434
+ return diff_body
435
+ diff_body = get_heading_body(content, "Diff Basis")
436
+ if diff_body:
437
+ return diff_body
438
+ return content
439
+
440
+
441
+ def get_phase_owned_actual_changed_files(file_name: str, actual_changed_files: list[str] | None) -> list[str] | None:
442
+ if actual_changed_files is None:
443
+ return None
444
+ if file_name == "02-to-be-plan.md":
445
+ return None
446
+
447
+ owned_paths: list[str] = []
448
+ for path in actual_changed_files:
449
+ if path == ".recursive/DECISIONS.md":
450
+ if file_name in DECISIONS_DIFF_PHASE_FILES:
451
+ owned_paths.append(path)
452
+ continue
453
+ if path == ".recursive/STATE.md":
454
+ if file_name in STATE_DIFF_PHASE_FILES:
455
+ owned_paths.append(path)
456
+ continue
457
+ if path.startswith(".recursive/memory/"):
458
+ if file_name in MEMORY_DIFF_PHASE_FILES:
459
+ owned_paths.append(path)
460
+ continue
461
+ owned_paths.append(path)
462
+
463
+ if file_name in PRODUCT_DIFF_PHASE_FILES:
464
+ return owned_paths
465
+ if file_name in DECISIONS_DIFF_PHASE_FILES | STATE_DIFF_PHASE_FILES | MEMORY_DIFF_PHASE_FILES:
466
+ return owned_paths
467
+ return None
468
+
469
+
470
+ def get_related_addenda_paths(run_dir: Path, artifact_name: str) -> list[Path]:
471
+ addenda_dir = run_dir / "addenda"
472
+ if not addenda_dir.exists():
473
+ return []
474
+
475
+ base_name = artifact_name[:-3] if artifact_name.endswith(".md") else artifact_name
476
+ matches: list[Path] = []
477
+ for pattern in (f"{base_name}.addendum-*.md", f"{base_name}.upstream-gap.*.addendum-*.md"):
478
+ matches.extend(sorted(addenda_dir.glob(pattern)))
479
+ return matches
480
+
481
+
482
+ def get_stage_local_addenda_paths(run_dir: Path, artifact_name: str) -> list[Path]:
483
+ addenda_dir = run_dir / "addenda"
484
+ if not addenda_dir.exists():
485
+ return []
486
+ base_name = artifact_name[:-3] if artifact_name.endswith(".md") else artifact_name
487
+ return sorted(addenda_dir.glob(f"{base_name}.addendum-*.md"))
488
+
489
+
490
+ def get_current_phase_upstream_gap_addenda_paths(run_dir: Path, artifact_name: str) -> list[Path]:
491
+ addenda_dir = run_dir / "addenda"
492
+ if not addenda_dir.exists():
493
+ return []
494
+ base_name = artifact_name[:-3] if artifact_name.endswith(".md") else artifact_name
495
+ return sorted(addenda_dir.glob(f"{base_name}.upstream-gap.*.addendum-*.md"))
496
+
497
+
498
+ def get_header_field_block(content: str, field_name: str, stop_fields: list[str]) -> str:
499
+ lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
500
+ collecting = False
501
+ captured: list[str] = []
502
+ stop_patterns = [re.compile(rf"^\s*{re.escape(stop_field)}:") for stop_field in stop_fields]
503
+ field_pattern = re.compile(rf"^\s*{re.escape(field_name)}:\s*$")
504
+
505
+ for line in lines:
506
+ if not collecting:
507
+ if field_pattern.match(line):
508
+ collecting = True
509
+ continue
510
+ if any(pattern.match(line) for pattern in stop_patterns):
511
+ break
512
+ captured.append(line)
513
+
514
+ return "\n".join(captured).strip()
515
+
516
+
517
+ def get_header_input_paths(content: str) -> set[str]:
518
+ return extract_paths_from_text(get_header_field_block(content, "Inputs", ["Outputs", "Scope note"]))
519
+
520
+
521
+ def get_phase_expected_input_artifact_names(file_name: str, run_dir: Path) -> list[str]:
522
+ present = {artifact for artifact in RUN_ARTIFACT_SEQUENCE if (run_dir / artifact).exists()}
523
+ if file_name == "00-worktree.md":
524
+ candidates = ["00-requirements.md"]
525
+ elif file_name == "01-as-is.md":
526
+ candidates = ["00-requirements.md"]
527
+ elif file_name == "01.5-root-cause.md":
528
+ candidates = ["01-as-is.md"]
529
+ elif file_name == "02-to-be-plan.md":
530
+ candidates = ["00-requirements.md", "01-as-is.md"]
531
+ if "01.5-root-cause.md" in present:
532
+ candidates.append("01.5-root-cause.md")
533
+ elif file_name == "03-implementation-summary.md":
534
+ candidates = ["02-to-be-plan.md"]
535
+ elif file_name == "03.5-code-review.md":
536
+ candidates = ["02-to-be-plan.md", "03-implementation-summary.md"]
537
+ elif file_name == "04-test-summary.md":
538
+ candidates = ["02-to-be-plan.md", "03-implementation-summary.md"]
539
+ if "03.5-code-review.md" in present:
540
+ candidates.append("03.5-code-review.md")
541
+ elif file_name == "05-manual-qa.md":
542
+ candidates = ["02-to-be-plan.md"]
543
+ elif file_name == "06-decisions-update.md":
544
+ candidates = [artifact for artifact in RUN_ARTIFACT_SEQUENCE if artifact in present and artifact not in {"06-decisions-update.md", "07-state-update.md", "08-memory-impact.md"}]
545
+ elif file_name == "07-state-update.md":
546
+ candidates = ["06-decisions-update.md"]
547
+ elif file_name == "08-memory-impact.md":
548
+ candidates = [artifact for artifact in RUN_ARTIFACT_SEQUENCE if artifact in present and artifact != "08-memory-impact.md"]
549
+ else:
550
+ candidates = []
551
+ return [artifact for artifact in candidates if artifact in present]
552
+
553
+
554
+ def get_expected_effective_input_addenda_paths(run_dir: Path, file_name: str) -> list[str]:
555
+ if is_addendum_artifact(file_name):
556
+ return []
557
+ expected_paths: list[str] = []
558
+ run_prefix = f".recursive/run/{run_dir.name}/"
559
+ for artifact_name in get_phase_expected_input_artifact_names(file_name, run_dir):
560
+ for addendum_path in get_stage_local_addenda_paths(run_dir, artifact_name):
561
+ expected_paths.append(f"{run_prefix}addenda/{addendum_path.name}")
562
+ for addendum_path in get_current_phase_upstream_gap_addenda_paths(run_dir, file_name):
563
+ expected_paths.append(f"{run_prefix}addenda/{addendum_path.name}")
564
+ return sorted(set(expected_paths))
565
+
566
+
567
+ def collect_effective_input_addenda_blockers(file_name: str, content: str, workflow_profile: str, run_dir: Path) -> list[str]:
568
+ if workflow_profile not in (STRICT_WORKFLOW_PROFILES | {COMPAT_WORKFLOW_PROFILE}):
569
+ return []
570
+ if is_addendum_artifact(file_name):
571
+ return []
572
+
573
+ expected_addenda = get_expected_effective_input_addenda_paths(run_dir, file_name)
574
+ if not expected_addenda:
575
+ return []
576
+
577
+ blockers: list[str] = []
578
+ header_inputs = {normalize_repo_path(path) for path in get_header_input_paths(content)}
579
+ missing_inputs = [path for path in expected_addenda if path not in header_inputs]
580
+ if missing_inputs:
581
+ blockers.append(f"Missing effective-input addenda in Inputs: {', '.join(missing_inputs[:5])}")
582
+
583
+ if workflow_profile in STRICT_WORKFLOW_PROFILES and file_name in AUDITED_PHASE_FILES:
584
+ reread_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(content, "Effective Inputs Re-read"))}
585
+ missing_reread = [path for path in expected_addenda if path not in reread_paths]
586
+ if missing_reread:
587
+ blockers.append(f"Missing effective-input addenda in Effective Inputs Re-read: {', '.join(missing_reread[:5])}")
588
+
589
+ reconciliation_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(content, "Earlier Phase Reconciliation"))}
590
+ missing_reconciliation = [path for path in expected_addenda if path not in reconciliation_paths]
591
+ if missing_reconciliation:
592
+ blockers.append(f"Missing effective-input addenda in Earlier Phase Reconciliation: {', '.join(missing_reconciliation[:5])}")
593
+
594
+ return blockers
595
+
596
+
597
+ def parse_requirement_completion_entries(section_body: str) -> tuple[dict[str, dict[str, str]], list[str]]:
598
+ entries: dict[str, dict[str, str]] = {}
599
+ issues: list[str] = []
600
+ for raw_line in section_body.splitlines():
601
+ line = raw_line.strip()
602
+ if not line.startswith(("-", "*")):
603
+ continue
604
+ body = line[1:].strip()
605
+ parts = [part.strip() for part in body.split("|") if part.strip()]
606
+ if len(parts) < 2:
607
+ continue
608
+ requirement_id = parts[0]
609
+ if not re.fullmatch(r"R\d+", requirement_id):
610
+ continue
611
+ fields: dict[str, str] = {"Requirement ID": requirement_id}
612
+ for part in parts[1:]:
613
+ if ":" not in part:
614
+ continue
615
+ key, value = part.split(":", 1)
616
+ fields[key.strip()] = value.strip()
617
+ if requirement_id in entries:
618
+ issues.append(f"Duplicate Requirement Completion Status entry for {requirement_id}")
619
+ continue
620
+ entries[requirement_id] = fields
621
+ return entries, issues
622
+
623
+
624
+ def is_meaningful_requirement_field(value: str | None) -> bool:
625
+ if value is None:
626
+ return False
627
+ normalized = trim_md_value(value).strip()
628
+ return bool(normalized) and normalized.lower() not in {"...", "none", "n/a", "tbd", "todo"}
629
+
630
+
631
+ def collect_requirement_field_paths(fields: dict[str, str], field_names: list[str]) -> set[str]:
632
+ paths: set[str] = set()
633
+ for field_name in field_names:
634
+ raw_value = fields.get(field_name, "")
635
+ paths.update(normalize_repo_path(path) for path in extract_paths_from_field_value(raw_value))
636
+ return {path for path in paths if path}
637
+
638
+
639
+ def collect_meaningful_requirement_fields(fields: dict[str, str]) -> dict[str, str]:
640
+ meaningful: dict[str, str] = {}
641
+ for key, value in fields.items():
642
+ if key in {"Requirement ID", "Status"}:
643
+ continue
644
+ if is_meaningful_requirement_field(value):
645
+ meaningful[key] = value
646
+ return meaningful
647
+
648
+
649
+ def normalize_skill_usage_relevance(value: str | None) -> str:
650
+ normalized = trim_md_value(value or "").strip().lower()
651
+ if normalized == "yes":
652
+ return "relevant"
653
+ if normalized == "no":
654
+ return "not-relevant"
655
+ return normalized
656
+
657
+
658
+ def collect_requirement_disposition_blockers(
659
+ requirement_id: str,
660
+ status: str,
661
+ fields: dict[str, str],
662
+ file_name: str,
663
+ run_dir: Path,
664
+ repo_root: Path,
665
+ actual_changed_files: list[str] | None,
666
+ ) -> list[str]:
667
+ blockers: list[str] = []
668
+ actual_changed_scope = set(actual_changed_files or [])
669
+ meaningful_fields = collect_meaningful_requirement_fields(fields)
670
+ allowed_fields_by_status = {
671
+ "implemented": {"Changed Files", "Implementation Evidence", "Audit Note"},
672
+ "verified": {"Changed Files", "Implementation Evidence", "Verification Evidence", "Audit Note"},
673
+ "deferred": {"Rationale", "Deferred By", "Addendum", "Audit Note"},
674
+ "out-of-scope": {"Rationale", "Scope Decision", "Addendum", "Audit Note"},
675
+ "blocked": {"Rationale", "Blocking Evidence", "Audit Note"},
676
+ "superseded by approved addendum": {"Addendum", "Audit Note"},
677
+ }
678
+ unexpected_fields = sorted(set(meaningful_fields) - allowed_fields_by_status[status])
679
+ if unexpected_fields:
680
+ blockers.append(
681
+ f"Requirement {requirement_id} with Status {status} contains contradictory field(s): "
682
+ + ", ".join(unexpected_fields)
683
+ )
684
+
685
+ if status == "implemented":
686
+ changed_files = fields.get("Changed Files", "")
687
+ changed_paths = collect_requirement_field_paths(fields, ["Changed Files"])
688
+ implementation_evidence = fields.get("Implementation Evidence", "")
689
+ implementation_paths = collect_requirement_field_paths(fields, ["Implementation Evidence"])
690
+ if not is_meaningful_requirement_field(changed_files):
691
+ blockers.append(f"Requirement {requirement_id} with Status implemented must cite Changed Files")
692
+ elif not changed_paths:
693
+ blockers.append(f"Requirement {requirement_id} with Status implemented must cite repo paths in Changed Files")
694
+ else:
695
+ missing_changed_paths = find_missing_repo_paths(repo_root, sorted(changed_paths))
696
+ if missing_changed_paths:
697
+ blockers.append(f"Requirement {requirement_id} Changed Files path(s) do not exist: {', '.join(missing_changed_paths[:5])}")
698
+ if actual_changed_scope:
699
+ unexplained_paths = sorted(path for path in changed_paths if path not in actual_changed_scope)
700
+ if unexplained_paths:
701
+ blockers.append(
702
+ f"Requirement {requirement_id} Changed Files are outside the current diff scope: {', '.join(unexplained_paths[:5])}"
703
+ )
704
+ if not is_meaningful_requirement_field(implementation_evidence):
705
+ blockers.append(f"Requirement {requirement_id} with Status implemented must cite Implementation Evidence")
706
+ elif not implementation_paths:
707
+ blockers.append(f"Requirement {requirement_id} with Status implemented must cite file or artifact paths in Implementation Evidence")
708
+ else:
709
+ missing = find_missing_repo_paths(repo_root, sorted(implementation_paths))
710
+ if missing:
711
+ blockers.append(f"Requirement {requirement_id} Implementation Evidence path(s) do not exist: {', '.join(missing[:5])}")
712
+ if changed_paths and not implementation_paths.intersection(changed_paths) and not any(
713
+ path.startswith(f".recursive/run/{run_dir.name}/") for path in implementation_paths
714
+ ):
715
+ blockers.append(
716
+ f"Requirement {requirement_id} Implementation Evidence must reference the changed files or a current-run artifact that proves the implementation work"
717
+ )
718
+
719
+ elif status == "verified":
720
+ changed_files = fields.get("Changed Files", "")
721
+ changed_paths = collect_requirement_field_paths(fields, ["Changed Files"])
722
+ implementation_evidence = fields.get("Implementation Evidence", "")
723
+ verification_evidence = fields.get("Verification Evidence", "")
724
+ implementation_paths = collect_requirement_field_paths(fields, ["Implementation Evidence"])
725
+ verification_paths = collect_requirement_field_paths(fields, ["Verification Evidence"])
726
+ if not is_meaningful_requirement_field(changed_files):
727
+ blockers.append(f"Requirement {requirement_id} with Status verified must cite Changed Files")
728
+ elif not changed_paths:
729
+ blockers.append(f"Requirement {requirement_id} with Status verified must cite repo paths in Changed Files")
730
+ else:
731
+ missing_changed_paths = find_missing_repo_paths(repo_root, sorted(changed_paths))
732
+ if missing_changed_paths:
733
+ blockers.append(f"Requirement {requirement_id} Changed Files path(s) do not exist: {', '.join(missing_changed_paths[:5])}")
734
+ if actual_changed_scope:
735
+ unexplained_paths = sorted(path for path in changed_paths if path not in actual_changed_scope)
736
+ if unexplained_paths:
737
+ blockers.append(
738
+ f"Requirement {requirement_id} Changed Files are outside the current diff scope: {', '.join(unexplained_paths[:5])}"
739
+ )
740
+ if not is_meaningful_requirement_field(implementation_evidence):
741
+ blockers.append(f"Requirement {requirement_id} with Status verified must cite Implementation Evidence")
742
+ elif not implementation_paths:
743
+ blockers.append(f"Requirement {requirement_id} with Status verified must cite file or artifact paths in Implementation Evidence")
744
+ else:
745
+ missing = find_missing_repo_paths(repo_root, sorted(implementation_paths))
746
+ if missing:
747
+ blockers.append(f"Requirement {requirement_id} Implementation Evidence path(s) do not exist: {', '.join(missing[:5])}")
748
+ if changed_paths and not implementation_paths.intersection(changed_paths) and not any(
749
+ path.startswith(f".recursive/run/{run_dir.name}/") for path in implementation_paths
750
+ ):
751
+ blockers.append(
752
+ f"Requirement {requirement_id} Implementation Evidence must reference the changed files or a current-run artifact that proves the implementation work"
753
+ )
754
+ if not is_meaningful_requirement_field(verification_evidence):
755
+ blockers.append(f"Requirement {requirement_id} with Status verified must cite Verification Evidence")
756
+ elif not verification_paths:
757
+ blockers.append(f"Requirement {requirement_id} with Status verified must cite test, review, QA, or artifact paths in Verification Evidence")
758
+ else:
759
+ missing = find_missing_repo_paths(repo_root, sorted(verification_paths))
760
+ if missing:
761
+ blockers.append(f"Requirement {requirement_id} Verification Evidence path(s) do not exist: {', '.join(missing[:5])}")
762
+ if changed_paths and verification_paths.issubset(changed_paths):
763
+ blockers.append(
764
+ f"Requirement {requirement_id} Verification Evidence must cite verification artifacts, review receipts, or evidence beyond the changed files themselves"
765
+ )
766
+ if implementation_paths and verification_paths.issubset(implementation_paths):
767
+ blockers.append(
768
+ f"Requirement {requirement_id} Verification Evidence cannot be satisfied by restating only the implementation evidence"
769
+ )
770
+
771
+ elif status == "deferred":
772
+ rationale = fields.get("Rationale", "")
773
+ deferred_by = fields.get("Deferred By", "") or fields.get("Addendum", "")
774
+ deferred_paths = collect_requirement_field_paths(fields, ["Deferred By", "Addendum"])
775
+ if not is_meaningful_requirement_field(rationale):
776
+ blockers.append(f"Requirement {requirement_id} with Status deferred is missing Rationale")
777
+ if not is_meaningful_requirement_field(deferred_by):
778
+ blockers.append(f"Requirement {requirement_id} with Status deferred must cite Deferred By or Addendum")
779
+ elif not deferred_paths:
780
+ blockers.append(f"Requirement {requirement_id} with Status deferred must cite an approved deferral path")
781
+ else:
782
+ missing = find_missing_repo_paths(repo_root, sorted(deferred_paths))
783
+ if missing:
784
+ blockers.append(f"Requirement {requirement_id} deferral reference path(s) do not exist: {', '.join(missing[:5])}")
785
+
786
+ elif status == "out-of-scope":
787
+ rationale = fields.get("Rationale", "")
788
+ scope_decision = fields.get("Scope Decision", "") or fields.get("Addendum", "")
789
+ scope_paths = collect_requirement_field_paths(fields, ["Scope Decision", "Addendum"])
790
+ if not is_meaningful_requirement_field(rationale):
791
+ blockers.append(f"Requirement {requirement_id} with Status out-of-scope is missing Rationale")
792
+ if not is_meaningful_requirement_field(scope_decision):
793
+ blockers.append(f"Requirement {requirement_id} with Status out-of-scope must cite Scope Decision or Addendum")
794
+ elif not scope_paths:
795
+ blockers.append(f"Requirement {requirement_id} with Status out-of-scope must cite an approved scope decision path")
796
+ else:
797
+ missing = find_missing_repo_paths(repo_root, sorted(scope_paths))
798
+ if missing:
799
+ blockers.append(f"Requirement {requirement_id} scope decision path(s) do not exist: {', '.join(missing[:5])}")
800
+
801
+ elif status == "blocked":
802
+ rationale = fields.get("Rationale", "")
803
+ blocking_evidence = fields.get("Blocking Evidence", "")
804
+ blocking_paths = collect_requirement_field_paths(fields, ["Blocking Evidence"])
805
+ if not is_meaningful_requirement_field(rationale):
806
+ blockers.append(f"Requirement {requirement_id} with Status blocked is missing Rationale")
807
+ if not is_meaningful_requirement_field(blocking_evidence):
808
+ blockers.append(f"Requirement {requirement_id} with Status blocked must cite Blocking Evidence")
809
+ elif not blocking_paths:
810
+ blockers.append(f"Requirement {requirement_id} with Status blocked must cite file, artifact, or evidence paths in Blocking Evidence")
811
+ else:
812
+ missing = find_missing_repo_paths(repo_root, sorted(blocking_paths))
813
+ if missing:
814
+ blockers.append(f"Requirement {requirement_id} Blocking Evidence path(s) do not exist: {', '.join(missing[:5])}")
815
+
816
+ elif status == "superseded by approved addendum":
817
+ addendum_path = normalize_repo_path(fields.get("Addendum", ""))
818
+ if not addendum_path:
819
+ blockers.append(f"Requirement {requirement_id} superseded by approved addendum must cite Addendum")
820
+ elif not addendum_path.startswith(f".recursive/run/{run_dir.name}/addenda/"):
821
+ blockers.append(f"Requirement {requirement_id} addendum reference must live under the current run addenda/")
822
+ elif not (repo_root / addendum_path).exists():
823
+ blockers.append(f"Requirement {requirement_id} addendum reference does not exist: {addendum_path}")
824
+
825
+ if file_name in FINAL_REQUIREMENT_DISPOSITION_FILES:
826
+ if status == "implemented":
827
+ blockers.append(f"Requirement {requirement_id} cannot remain implemented in {file_name}; final closeout phases require verified or explicitly approved non-completion states")
828
+ if status == "blocked":
829
+ blockers.append(f"Requirement {requirement_id} cannot remain blocked in {file_name} while the phase is approaching closeout")
830
+
831
+ return blockers
832
+
833
+
834
+ def collect_requirement_completion_blockers(
835
+ file_name: str,
836
+ content: str,
837
+ workflow_profile: str,
838
+ requirement_ids: list[str],
839
+ run_dir: Path,
840
+ repo_root: Path,
841
+ actual_changed_files: list[str] | None,
842
+ ) -> list[str]:
843
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES or file_name not in AUDITED_PHASE_FILES:
844
+ return []
845
+
846
+ body = get_heading_body(content, "Requirement Completion Status")
847
+ if not body:
848
+ return ["Missing section: ## Requirement Completion Status"]
849
+
850
+ entries, blockers = parse_requirement_completion_entries(body)
851
+ missing = [requirement_id for requirement_id in requirement_ids if requirement_id not in entries]
852
+ if missing:
853
+ blockers.append(f"Missing requirement dispositions for: {', '.join(missing)}")
854
+
855
+ for requirement_id, fields in entries.items():
856
+ status = trim_md_value(fields.get("Status", "")).lower()
857
+ if status not in REQUIREMENT_DISPOSITION_STATUSES:
858
+ blockers.append(f"Requirement {requirement_id} has invalid Status '{fields.get('Status', '')}'")
859
+ continue
860
+ blockers.extend(
861
+ collect_requirement_disposition_blockers(
862
+ requirement_id,
863
+ status,
864
+ fields,
865
+ file_name,
866
+ run_dir,
867
+ repo_root,
868
+ actual_changed_files,
869
+ )
870
+ )
871
+
872
+ if file_name in REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES:
873
+ expected_scope = set(get_phase_owned_actual_changed_files(file_name, actual_changed_files) or [])
874
+ if expected_scope:
875
+ claimed_changed_files = set()
876
+ for fields in entries.values():
877
+ status = trim_md_value(fields.get("Status", "")).lower()
878
+ if status in {"implemented", "verified"}:
879
+ claimed_changed_files.update(collect_requirement_field_paths(fields, ["Changed Files"]))
880
+ missing_claims = sorted(path for path in expected_scope if path not in claimed_changed_files)
881
+ if missing_claims:
882
+ blockers.append(
883
+ "Requirement Completion Status leaves diff-owned changed file(s) unaccounted for: "
884
+ + ", ".join(missing_claims[:5])
885
+ )
886
+
887
+ return sorted(set(blockers))
888
+
889
+
890
+ def collect_prior_recursive_evidence_blockers(
891
+ file_name: str,
892
+ content: str,
893
+ workflow_profile: str,
894
+ repo_root: Path,
895
+ ) -> list[str]:
896
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES or file_name not in PRIOR_RECURSIVE_EVIDENCE_FILES:
897
+ return []
898
+
899
+ body = get_heading_body(content, "Prior Recursive Evidence Reviewed")
900
+ if not body:
901
+ return ["Missing section: ## Prior Recursive Evidence Reviewed"]
902
+
903
+ referenced_paths = {
904
+ normalize_repo_path(path)
905
+ for path in extract_paths_from_text(body)
906
+ if normalize_repo_path(path).startswith(".recursive/run/") or normalize_repo_path(path).startswith(".recursive/memory/")
907
+ }
908
+ if referenced_paths:
909
+ missing_paths = find_missing_repo_paths(repo_root, sorted(referenced_paths))
910
+ if missing_paths:
911
+ return [f"Prior Recursive Evidence references missing path(s): {', '.join(missing_paths[:5])}"]
912
+ return []
913
+
914
+ if re.search(r"\bnone\b", body, re.IGNORECASE) and re.search(r"\b(justification|reason|because)\b", body, re.IGNORECASE):
915
+ return []
916
+
917
+ return ["Prior Recursive Evidence Reviewed must cite run/memory paths or an explicit no-relevant-evidence justification"]
918
+
919
+
920
+ def get_subagent_action_record_paths(content: str, run_dir: Path) -> list[str]:
921
+ body = get_heading_body(content, "Subagent Contribution Verification")
922
+ if not body:
923
+ return []
924
+ expected_prefix = f".recursive/run/{run_dir.name}/subagents/"
925
+ return sorted(
926
+ path
927
+ for path in {normalize_repo_path(path) for path in extract_paths_from_text(body)}
928
+ if path.startswith(expected_prefix)
929
+ )
930
+
931
+
932
+ def get_all_subagent_action_record_paths(content: str) -> list[str]:
933
+ body = get_heading_body(content, "Subagent Contribution Verification")
934
+ if not body:
935
+ return []
936
+ return sorted(
937
+ path
938
+ for path in {normalize_repo_path(path) for path in extract_paths_from_text(body)}
939
+ if re.match(r"^\.recursive/run/[^/]+/subagents/.+\.md$", path)
940
+ )
941
+
942
+
943
+ def parse_subagent_action_record_claims(action_content: str) -> dict[str, set[str] | str]:
944
+ inputs = get_heading_body(action_content, "Inputs Provided")
945
+ claimed_file_impact = get_heading_body(action_content, "Claimed File Impact")
946
+ claimed_artifact_impact = get_heading_body(action_content, "Claimed Artifact Impact")
947
+ current_artifact = normalize_repo_path(get_md_field_value(inputs, "Current Artifact") or "")
948
+ review_bundle = normalize_repo_path(get_md_field_value(inputs, "Review Bundle") or "")
949
+ upstream_artifacts = extract_paths_from_named_field(inputs, "Upstream Artifacts")
950
+ claimed_created = {
951
+ normalize_repo_path(path)
952
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Created"))
953
+ if normalize_repo_path(path)
954
+ }
955
+ claimed_modified = {
956
+ normalize_repo_path(path)
957
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Modified"))
958
+ if normalize_repo_path(path)
959
+ }
960
+ claimed_reviewed = {
961
+ normalize_repo_path(path)
962
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Reviewed"))
963
+ if normalize_repo_path(path)
964
+ }
965
+ claimed_relevant_untouched = {
966
+ normalize_repo_path(path)
967
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Relevant but Untouched"))
968
+ if normalize_repo_path(path)
969
+ }
970
+ claimed_artifact_refs = {
971
+ normalize_repo_path(path)
972
+ for path in extract_paths_from_text(claimed_artifact_impact)
973
+ if normalize_repo_path(path).startswith(".recursive/")
974
+ }
975
+ return {
976
+ "current_artifact": current_artifact,
977
+ "review_bundle": review_bundle,
978
+ "upstream_artifacts": upstream_artifacts,
979
+ "created": claimed_created,
980
+ "modified": claimed_modified,
981
+ "reviewed": claimed_reviewed,
982
+ "relevant_untouched": claimed_relevant_untouched,
983
+ "artifact_refs": claimed_artifact_refs,
984
+ }
985
+
986
+
987
+ def collect_subagent_contribution_blockers(
988
+ file_name: str,
989
+ content: str,
990
+ workflow_profile: str,
991
+ run_dir: Path,
992
+ repo_root: Path,
993
+ actual_changed_files: list[str] | None,
994
+ ) -> list[str]:
995
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES or file_name not in AUDITED_PHASE_FILES:
996
+ return []
997
+
998
+ body = get_heading_body(content, "Subagent Contribution Verification")
999
+ if not body:
1000
+ return ["Missing section: ## Subagent Contribution Verification"]
1001
+
1002
+ blockers: list[str] = []
1003
+ action_record_paths = get_subagent_action_record_paths(content, run_dir)
1004
+ all_action_record_paths = get_all_subagent_action_record_paths(content)
1005
+ audit_mode = get_md_field_value(get_heading_body(content, "Audit Context"), "Audit Execution Mode") or ""
1006
+ if audit_mode == "subagent" and not action_record_paths:
1007
+ blockers.append("Audit Execution Mode subagent requires at least one reviewed subagent action record")
1008
+ out_of_run_action_records = sorted(path for path in all_action_record_paths if path not in action_record_paths)
1009
+ if out_of_run_action_records:
1010
+ blockers.append(
1011
+ "Subagent Contribution Verification may only reference action records under the current run subagents/: "
1012
+ + ", ".join(out_of_run_action_records[:5])
1013
+ )
1014
+
1015
+ current_bundle_path = normalize_repo_path(
1016
+ get_md_field_value(get_heading_body(content, "Review Metadata"), "Review Bundle Path")
1017
+ or get_md_field_value(content, "Review Bundle Path")
1018
+ or ""
1019
+ )
1020
+ current_phase = get_md_field_value(content, "Phase") or ""
1021
+ reviewed_action_records_field = get_named_field_text(body, "Reviewed Action Records") or ""
1022
+ main_agent_verification = get_named_field_text(body, "Main-Agent Verification Performed") or ""
1023
+ acceptance_decision = trim_md_value(get_md_field_value(body, "Acceptance Decision") or "").lower()
1024
+ refresh_handling = get_named_field_text(body, "Refresh Handling") or ""
1025
+ repair_performed = get_named_field_text(body, "Repair Performed After Verification") or ""
1026
+ verification_paths = {
1027
+ normalize_repo_path(path)
1028
+ for path in extract_paths_from_field_value(main_agent_verification)
1029
+ if normalize_repo_path(path)
1030
+ }
1031
+ repair_paths = {
1032
+ normalize_repo_path(path)
1033
+ for path in extract_paths_from_field_value(repair_performed)
1034
+ if normalize_repo_path(path)
1035
+ }
1036
+ for action_record_path in action_record_paths:
1037
+ action_path = repo_root / action_record_path
1038
+ if not action_path.exists():
1039
+ blockers.append(f"Referenced subagent action record does not exist: {action_record_path}")
1040
+ continue
1041
+ action_content = action_path.read_text(encoding="utf-8")
1042
+ action_claims = parse_subagent_action_record_claims(action_content)
1043
+ metadata = get_heading_body(action_content, "Metadata")
1044
+ inputs = get_heading_body(action_content, "Inputs Provided")
1045
+ claimed_actions = get_heading_body(action_content, "Claimed Actions Taken")
1046
+ claimed_file_impact = get_heading_body(action_content, "Claimed File Impact")
1047
+ claimed_artifact_impact = get_heading_body(action_content, "Claimed Artifact Impact")
1048
+ for heading in SUBAGENT_ACTION_REQUIRED_HEADINGS:
1049
+ if not get_heading_body(action_content, heading):
1050
+ blockers.append(f"Subagent action record missing section {heading}: {action_record_path}")
1051
+ for field_name in ("Subagent ID", "Run ID", "Phase", "Purpose", "Execution Mode", "Timestamp"):
1052
+ if not has_meaningful_value(get_md_field_value(metadata, field_name)):
1053
+ blockers.append(f"Subagent action record missing {field_name}: {action_record_path}")
1054
+ if action_path.parent != run_dir / "subagents":
1055
+ blockers.append(f"Subagent action record must live under `/.recursive/run/{run_dir.name}/subagents/`: {action_record_path}")
1056
+ if (get_md_field_value(metadata, "Run ID") or "") not in {"", run_dir.name}:
1057
+ blockers.append(f"Subagent action record run mismatch: {action_record_path}")
1058
+ if current_phase and (get_md_field_value(metadata, "Phase") or "") not in {"", current_phase}:
1059
+ blockers.append(f"Subagent action record phase mismatch: {action_record_path}")
1060
+ current_artifact = normalize_repo_path(get_md_field_value(inputs, "Current Artifact") or "")
1061
+ if not current_artifact:
1062
+ blockers.append(f"Subagent action record missing Current Artifact: {action_record_path}")
1063
+ elif not (repo_root / current_artifact).exists():
1064
+ blockers.append(f"Subagent action record Current Artifact does not exist: {action_record_path}")
1065
+ artifact_hash = trim_md_value(get_md_field_value(inputs, "Artifact Content Hash") or "")
1066
+ if current_artifact and (repo_root / current_artifact).exists():
1067
+ current_artifact_hash = content_sha256((repo_root / current_artifact).read_text(encoding="utf-8"))
1068
+ if not artifact_hash:
1069
+ blockers.append(f"Subagent action record missing Artifact Content Hash: {action_record_path}")
1070
+ elif artifact_hash != current_artifact_hash:
1071
+ blockers.append(f"Subagent action record Artifact Content Hash is stale: {action_record_path}")
1072
+ if not has_meaningful_value(get_md_field_value(inputs, "Diff Basis"), disallowed={"n/a", "none"}):
1073
+ blockers.append(f"Subagent action record missing Diff Basis: {action_record_path}")
1074
+ if is_placeholder_only(claimed_actions):
1075
+ blockers.append(f"Subagent action record Claimed Actions Taken is empty: {action_record_path}")
1076
+ claimed_created = {
1077
+ normalize_repo_path(path)
1078
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Created"))
1079
+ if normalize_repo_path(path)
1080
+ }
1081
+ claimed_modified = {
1082
+ normalize_repo_path(path)
1083
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Modified"))
1084
+ if normalize_repo_path(path)
1085
+ }
1086
+ claimed_reviewed = {
1087
+ normalize_repo_path(path)
1088
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Reviewed"))
1089
+ if normalize_repo_path(path)
1090
+ }
1091
+ claimed_relevant_untouched = {
1092
+ normalize_repo_path(path)
1093
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Relevant but Untouched"))
1094
+ if normalize_repo_path(path)
1095
+ }
1096
+ claimed_file_refs = claimed_created | claimed_modified | claimed_reviewed | claimed_relevant_untouched
1097
+ if not claimed_file_refs:
1098
+ blockers.append(f"Subagent action record Claimed File Impact is empty: {action_record_path}")
1099
+ claimed_artifact_refs = {
1100
+ normalize_repo_path(path)
1101
+ for path in extract_paths_from_text(claimed_artifact_impact)
1102
+ if normalize_repo_path(path).startswith(".recursive/")
1103
+ }
1104
+ evidence_refs = {
1105
+ normalize_repo_path(path)
1106
+ for path in extract_paths_from_text(get_subheading_body(claimed_artifact_impact, "Evidence Used"))
1107
+ if normalize_repo_path(path).startswith(f".recursive/run/{run_dir.name}/evidence/")
1108
+ }
1109
+ if not claimed_artifact_refs and not evidence_refs:
1110
+ blockers.append(f"Subagent action record Claimed Artifact Impact is empty: {action_record_path}")
1111
+ else:
1112
+ missing_artifact_refs = find_missing_repo_paths(repo_root, sorted(claimed_artifact_refs))
1113
+ if missing_artifact_refs:
1114
+ blockers.append(
1115
+ f"Subagent action record references missing recursive artifacts: {action_record_path} -> {', '.join(missing_artifact_refs[:5])}"
1116
+ )
1117
+ missing_evidence_refs = find_missing_repo_paths(repo_root, sorted(evidence_refs))
1118
+ if missing_evidence_refs:
1119
+ blockers.append(
1120
+ f"Subagent action record references missing evidence: {action_record_path} -> {', '.join(missing_evidence_refs[:5])}"
1121
+ )
1122
+ action_bundle_path = normalize_repo_path(get_md_field_value(inputs, "Review Bundle") or "")
1123
+ if current_bundle_path and action_bundle_path and current_bundle_path != action_bundle_path:
1124
+ blockers.append(f"Subagent action record review bundle mismatch: {action_record_path}")
1125
+ if action_bundle_path and (repo_root / action_bundle_path).exists():
1126
+ bundle_content = (repo_root / action_bundle_path).read_text(encoding="utf-8")
1127
+ bundle_artifact_path = normalize_repo_path(get_md_field_value(bundle_content, "Artifact Path") or "")
1128
+ bundle_upstream_artifacts = {
1129
+ normalize_repo_path(path)
1130
+ for path in extract_paths_from_text(get_heading_body(bundle_content, "Upstream Artifacts To Re-read"))
1131
+ if normalize_repo_path(path)
1132
+ }
1133
+ bundle_changed_paths = {
1134
+ normalize_repo_path(path)
1135
+ for path in extract_paths_from_text(get_heading_body(bundle_content, "Changed Files Reviewed"))
1136
+ if normalize_repo_path(path)
1137
+ }
1138
+ bundle_code_refs = {
1139
+ normalize_repo_path(path)
1140
+ for path in extract_paths_from_text(get_heading_body(bundle_content, "Targeted Code References"))
1141
+ if normalize_repo_path(path)
1142
+ }
1143
+ allowed_artifacts = {path for path in {bundle_artifact_path, *bundle_upstream_artifacts} if path}
1144
+ if current_artifact and allowed_artifacts and current_artifact not in allowed_artifacts:
1145
+ blockers.append(
1146
+ f"Subagent action record Current Artifact must match the review bundle Artifact Path or a cited upstream artifact: {action_record_path}"
1147
+ )
1148
+ action_upstream_artifacts = extract_paths_from_named_field(inputs, "Upstream Artifacts")
1149
+ missing_bundle_upstream = sorted(path for path in bundle_upstream_artifacts if path not in {
1150
+ normalize_repo_path(path)
1151
+ for path in action_upstream_artifacts
1152
+ if normalize_repo_path(path)
1153
+ })
1154
+ if missing_bundle_upstream:
1155
+ blockers.append(
1156
+ "Subagent action record Upstream Artifacts omit bundle upstream artifact(s): "
1157
+ + ", ".join(missing_bundle_upstream[:5])
1158
+ )
1159
+ required_bundle_file_scope = bundle_code_refs or bundle_changed_paths
1160
+ if required_bundle_file_scope:
1161
+ missing_bundle_scope_paths = sorted(path for path in required_bundle_file_scope if path not in claimed_file_refs)
1162
+ if missing_bundle_scope_paths:
1163
+ blockers.append(
1164
+ "Subagent action record omits targeted file scope present in the review bundle: "
1165
+ + ", ".join(missing_bundle_scope_paths[:5])
1166
+ )
1167
+ if acceptance_decision in {"accepted", "partially accepted"}:
1168
+ claimed_diff_scope = set()
1169
+ claimed_diff_scope.update(set(action_claims["created"])) # type: ignore[arg-type]
1170
+ claimed_diff_scope.update(set(action_claims["modified"])) # type: ignore[arg-type]
1171
+ claimed_diff_scope.update(set(action_claims["reviewed"])) # type: ignore[arg-type]
1172
+ expected_verified_paths = set(claimed_diff_scope)
1173
+ if actual_changed_files is not None:
1174
+ expected_verified_paths = {path for path in expected_verified_paths if path in set(actual_changed_files)}
1175
+ missing_verified_paths = sorted(path for path in expected_verified_paths if path not in verification_paths and path not in repair_paths)
1176
+ if missing_verified_paths:
1177
+ blockers.append(
1178
+ "Main-Agent Verification Performed does not reconcile delegated file-impact claims against the actual diff scope: "
1179
+ + ", ".join(missing_verified_paths[:5])
1180
+ )
1181
+ verification_artifact_scope = {
1182
+ str(action_claims["current_artifact"]),
1183
+ *set(action_claims["upstream_artifacts"]), # type: ignore[arg-type]
1184
+ *set(action_claims["artifact_refs"]), # type: ignore[arg-type]
1185
+ }
1186
+ if action_claims["review_bundle"]: # type: ignore[index]
1187
+ verification_artifact_scope.add(str(action_claims["review_bundle"]))
1188
+ verification_artifact_scope.discard("")
1189
+ if verification_artifact_scope and not any(path in verification_paths for path in verification_artifact_scope):
1190
+ blockers.append(
1191
+ "Main-Agent Verification Performed must cite the reviewed artifact, bundle, or upstream recursive artifacts used to accept delegated work"
1192
+ )
1193
+
1194
+ if action_record_paths:
1195
+ reviewed_record_paths = {
1196
+ normalize_repo_path(path)
1197
+ for path in extract_paths_from_field_value(reviewed_action_records_field)
1198
+ if normalize_repo_path(path).startswith(f".recursive/run/{run_dir.name}/subagents/")
1199
+ }
1200
+ if not reviewed_action_records_field.strip():
1201
+ blockers.append("Subagent Contribution Verification must record Reviewed Action Records")
1202
+ else:
1203
+ missing_reviewed_records = sorted(path for path in action_record_paths if path not in reviewed_record_paths)
1204
+ if missing_reviewed_records:
1205
+ blockers.append(
1206
+ "Reviewed Action Records is missing referenced action record path(s): "
1207
+ + ", ".join(missing_reviewed_records[:5])
1208
+ )
1209
+ if not has_meaningful_value(main_agent_verification, disallowed={"n/a", "none"}):
1210
+ blockers.append("Subagent Contribution Verification must record Main-Agent Verification Performed")
1211
+ elif not verification_paths:
1212
+ blockers.append("Main-Agent Verification Performed must cite files, artifacts, or diff-owned paths that were checked")
1213
+ else:
1214
+ missing_verification_paths = find_missing_repo_paths(repo_root, sorted(verification_paths))
1215
+ if missing_verification_paths:
1216
+ blockers.append(
1217
+ "Main-Agent Verification Performed references missing path(s): "
1218
+ + ", ".join(missing_verification_paths[:5])
1219
+ )
1220
+ if acceptance_decision not in {"accepted", "partially accepted", "rejected"}:
1221
+ blockers.append("Subagent Contribution Verification must record Acceptance Decision: accepted|partially accepted|rejected")
1222
+ if not has_meaningful_value(refresh_handling, disallowed={"n/a", "none"}):
1223
+ blockers.append("Subagent Contribution Verification must record Refresh Handling")
1224
+ if not trim_md_value(repair_performed):
1225
+ blockers.append("Subagent Contribution Verification must record Repair Performed After Verification")
1226
+ elif repair_paths:
1227
+ missing_repair_paths = find_missing_repo_paths(repo_root, sorted(repair_paths))
1228
+ if missing_repair_paths:
1229
+ blockers.append(
1230
+ "Repair Performed After Verification references missing path(s): "
1231
+ + ", ".join(missing_repair_paths[:5])
1232
+ )
1233
+
1234
+ return sorted(set(blockers))
1235
+
1236
+
1237
+ def collect_reviewed_paths(run_dir: Path, artifact_name: str, content: str) -> set[str]:
1238
+ reviewed_paths = extract_paths_from_text(get_heading_body(content, "Worktree Diff Audit"))
1239
+ for addendum_path in get_related_addenda_paths(run_dir, artifact_name):
1240
+ reviewed_paths.update(extract_paths_from_text(addendum_path.read_text(encoding="utf-8")))
1241
+ return reviewed_paths
1242
+
1243
+
1244
+ def collect_review_bundle_blockers(content: str, run_dir: Path, repo_root: Path) -> list[str]:
1245
+ blockers: list[str] = []
1246
+ review_metadata = get_heading_body(content, "Review Metadata")
1247
+ bundle_path = (
1248
+ get_md_field_value(review_metadata, "Review Bundle Path")
1249
+ or get_md_field_value(content, "Review Bundle Path")
1250
+ or ""
1251
+ ).strip()
1252
+ expected_prefix = f".recursive/run/{run_dir.name}/evidence/review-bundles/"
1253
+
1254
+ if not bundle_path:
1255
+ blockers.append("Missing Review Bundle Path in Review Metadata")
1256
+ return blockers
1257
+
1258
+ normalized_bundle_path = normalize_repo_path(bundle_path)
1259
+ if not normalized_bundle_path.startswith(expected_prefix):
1260
+ blockers.append(f"Review Bundle Path must live under `/{expected_prefix}`")
1261
+ return blockers
1262
+
1263
+ if not (repo_root / normalized_bundle_path).exists():
1264
+ blockers.append(f"Missing review bundle file: {normalized_bundle_path}")
1265
+ return blockers
1266
+
1267
+ bundle_content = (repo_root / normalized_bundle_path).read_text(encoding="utf-8")
1268
+ artifact_path = normalize_repo_path(get_md_field_value(bundle_content, "Artifact Path") or "")
1269
+ artifact_hash = trim_md_value(get_md_field_value(bundle_content, "Artifact Content Hash") or "")
1270
+ if not artifact_path:
1271
+ blockers.append("Review bundle is missing Artifact Path")
1272
+ elif not (repo_root / artifact_path).exists():
1273
+ blockers.append(f"Review bundle Artifact Path does not exist: {artifact_path}")
1274
+ if artifact_path:
1275
+ current_hash = content_sha256((repo_root / artifact_path).read_text(encoding="utf-8")) if (repo_root / artifact_path).exists() else ""
1276
+ if artifact_hash and current_hash and artifact_hash != current_hash:
1277
+ blockers.append("Review bundle is stale: Artifact Content Hash no longer matches the current artifact")
1278
+ if not artifact_hash:
1279
+ blockers.append("Review bundle is missing Artifact Content Hash")
1280
+
1281
+ missing_bundle_headings = []
1282
+ for heading in (
1283
+ "Diff Basis",
1284
+ "Changed Files Reviewed",
1285
+ "Upstream Artifacts To Re-read",
1286
+ "Relevant Addenda",
1287
+ "Prior Recursive Evidence",
1288
+ "Targeted Code References",
1289
+ "Audit Questions",
1290
+ "Required Output",
1291
+ ):
1292
+ if not get_heading_body(bundle_content, heading):
1293
+ missing_bundle_headings.append(heading)
1294
+ if missing_bundle_headings:
1295
+ blockers.append(f"Review bundle is missing required section(s): {', '.join(missing_bundle_headings)}")
1296
+
1297
+ review_narrative = "\n".join(
1298
+ [
1299
+ get_heading_body(content, "Review Scope"),
1300
+ get_heading_body(content, "Requirement And Plan Reconciliation"),
1301
+ get_heading_body(content, "Plan Alignment Assessment"),
1302
+ get_heading_body(content, "Code Quality Assessment"),
1303
+ get_heading_body(content, "Issues Found"),
1304
+ get_heading_body(content, "Verdict"),
1305
+ ]
1306
+ )
1307
+ cited_paths = {normalize_repo_path(path) for path in extract_paths_from_text(content)}
1308
+ cited_review_paths = {normalize_repo_path(path) for path in extract_paths_from_text(review_narrative)}
1309
+ upstream_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Upstream Artifacts To Re-read"))}
1310
+ addenda_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Relevant Addenda"))}
1311
+ prior_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Prior Recursive Evidence"))}
1312
+ changed_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Changed Files Reviewed"))}
1313
+ code_ref_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Targeted Code References"))}
1314
+ audit_questions = get_heading_body(bundle_content, "Audit Questions")
1315
+ if is_placeholder_only(audit_questions):
1316
+ blockers.append("Review bundle Audit Questions cannot be placeholder-only")
1317
+ diff_basis_body = get_heading_body(bundle_content, "Diff Basis")
1318
+ for field_name in DIFF_BASIS_FIELDS:
1319
+ if get_md_field_value(diff_basis_body, field_name) is None:
1320
+ blockers.append(f"Review bundle Diff Basis is missing {field_name}")
1321
+ if not changed_paths:
1322
+ blockers.append("Review bundle Changed Files Reviewed cannot be empty")
1323
+ else:
1324
+ missing_changed_paths = find_missing_repo_paths(repo_root, sorted(changed_paths))
1325
+ if missing_changed_paths:
1326
+ blockers.append(f"Review bundle changed file path(s) do not exist: {', '.join(missing_changed_paths[:5])}")
1327
+ if not code_ref_paths:
1328
+ blockers.append("Review bundle Targeted Code References cannot be empty")
1329
+ else:
1330
+ missing_code_refs = find_missing_repo_paths(repo_root, sorted(code_ref_paths))
1331
+ if missing_code_refs:
1332
+ blockers.append(f"Review bundle code ref path(s) do not exist: {', '.join(missing_code_refs[:5])}")
1333
+ elif changed_paths and not any(path in changed_paths for path in code_ref_paths):
1334
+ blockers.append("Review bundle Targeted Code References do not overlap the changed-file scope")
1335
+ expected_addenda = set(get_expected_effective_input_addenda_paths(run_dir, "03.5-code-review.md"))
1336
+ missing_bundle_addenda = sorted(path for path in expected_addenda if path not in addenda_paths)
1337
+ if missing_bundle_addenda:
1338
+ blockers.append(f"Review bundle is missing effective-input addenda: {', '.join(missing_bundle_addenda[:5])}")
1339
+
1340
+ if upstream_paths and not any(path in cited_review_paths for path in upstream_paths):
1341
+ blockers.append("Review narrative does not cite any upstream artifact from the review bundle")
1342
+ if addenda_paths and not any(path in cited_review_paths for path in addenda_paths):
1343
+ blockers.append("Review narrative does not cite any relevant addendum from the review bundle")
1344
+ if prior_paths and not any(path in cited_review_paths for path in prior_paths):
1345
+ blockers.append("Review narrative does not cite any prior recursive evidence from the review bundle")
1346
+ if (changed_paths or code_ref_paths) and not any(path in cited_review_paths for path in (changed_paths | code_ref_paths)):
1347
+ blockers.append("Review narrative does not cite any changed file or code reference from the review bundle")
1348
+ if normalized_bundle_path not in cited_paths:
1349
+ blockers.append("Review artifact does not cite its Review Bundle Path")
1350
+
1351
+ verdict_body = get_heading_body(content, "Verdict")
1352
+ if not verdict_body or is_placeholder_only(verdict_body):
1353
+ blockers.append("Verdict section must contain a concrete review verdict grounded in the review bundle")
1354
+
1355
+ return blockers
1356
+
1357
+
1358
+ def collect_phase8_skill_usage_blockers(content: str) -> list[str]:
1359
+ blockers: list[str] = []
1360
+ usage_body = get_heading_body(content, "Run-Local Skill Usage Capture")
1361
+ if not usage_body:
1362
+ return ["Missing section: ## Run-Local Skill Usage Capture"]
1363
+
1364
+ required_fields = [
1365
+ "Skill Usage Relevance",
1366
+ "Available Skills",
1367
+ "Skills Sought",
1368
+ "Skills Attempted",
1369
+ "Skills Used",
1370
+ "Worked Well",
1371
+ "Issues Encountered",
1372
+ "Future Guidance",
1373
+ "Promotion Candidates",
1374
+ ]
1375
+ for field_name in required_fields:
1376
+ if get_md_field_value(usage_body, field_name) is None:
1377
+ blockers.append(f"Run-Local Skill Usage Capture is missing {field_name}")
1378
+
1379
+ relevance = normalize_skill_usage_relevance(get_md_field_value(usage_body, "Skill Usage Relevance"))
1380
+ if relevance not in SKILL_USAGE_RELEVANCE_STATUSES:
1381
+ blockers.append("Run-Local Skill Usage Capture must declare Skill Usage Relevance: relevant|not-relevant")
1382
+ return blockers
1383
+
1384
+ if relevance in {"relevant", "yes"}:
1385
+ for field_name in ("Available Skills", "Skills Attempted", "Skills Used", "Future Guidance"):
1386
+ if not is_meaningful_requirement_field(get_md_field_value(usage_body, field_name)):
1387
+ blockers.append(f"Run-Local Skill Usage Capture must record {field_name} when skill usage is relevant")
1388
+ attempted = trim_md_value(get_md_field_value(usage_body, "Skills Attempted") or "").lower()
1389
+ used = trim_md_value(get_md_field_value(usage_body, "Skills Used") or "").lower()
1390
+ if attempted in {"none", "n/a"} and used in {"none", "n/a"}:
1391
+ blockers.append("Run-Local Skill Usage Capture cannot mark skill usage relevant while claiming no attempted or used skills")
1392
+
1393
+ promotion_body = get_heading_body(content, "Skill Memory Promotion Review")
1394
+ if not promotion_body:
1395
+ blockers.append("Missing section: ## Skill Memory Promotion Review")
1396
+ return blockers
1397
+
1398
+ for field_name in (
1399
+ "Durable Skill Lessons Promoted",
1400
+ "Generalized Guidance Updated",
1401
+ "Run-Local Observations Left Unpromoted",
1402
+ "Promotion Decision Rationale",
1403
+ ):
1404
+ if get_md_field_value(promotion_body, field_name) is None:
1405
+ blockers.append(f"Skill Memory Promotion Review is missing {field_name}")
1406
+
1407
+ if relevance in {"relevant", "yes"} and not is_meaningful_requirement_field(
1408
+ get_md_field_value(promotion_body, "Promotion Decision Rationale")
1409
+ ):
1410
+ blockers.append("Skill Memory Promotion Review must explain why relevant run-local observations were or were not promoted")
1411
+
1412
+ return blockers
1413
+
1414
+
1415
+ def collect_phase_specific_blockers(
1416
+ file_name: str,
1417
+ content: str,
1418
+ workflow_profile: str,
1419
+ run_dir: Path,
1420
+ repo_root: Path,
1421
+ requirement_ids: list[str],
1422
+ actual_changed_files: list[str] | None,
1423
+ ) -> list[str]:
1424
+ lint = load_lint_module()
1425
+ blockers = lint.lint_phase_specific_rules(
1426
+ run_dir / file_name,
1427
+ content,
1428
+ workflow_profile,
1429
+ run_dir,
1430
+ repo_root,
1431
+ requirement_ids,
1432
+ actual_changed_files,
1433
+ )
1434
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES:
1435
+ return blockers
1436
+
1437
+ if file_name == "00-worktree.md":
1438
+ _diff_basis, diff_basis_error = normalize_diff_basis(repo_root, get_run_diff_basis(run_dir))
1439
+ if diff_basis_error:
1440
+ blockers.append(f"Phase 0 diff basis is not executable: {diff_basis_error}")
1441
+
1442
+ if file_name == "03-implementation-summary.md":
1443
+ tdd_body = get_heading_body(content, "TDD Compliance Log")
1444
+ tdd_mode = (get_md_field_value(tdd_body, "TDD Mode") or get_md_field_value(content, "TDD Mode") or "").lower()
1445
+ tdd_gate = get_gate_status(content, "TDD Compliance")
1446
+
1447
+ if not has_gate_line(content, "TDD Compliance"):
1448
+ blockers.append("Missing TDD Compliance gate line")
1449
+ elif tdd_gate != "PASS":
1450
+ blockers.append(f"TDD Compliance gate is {tdd_gate}")
1451
+
1452
+ if tdd_mode not in TDD_MODES:
1453
+ blockers.append("Missing TDD Mode: strict|pragmatic")
1454
+ elif tdd_mode == "strict":
1455
+ red_prefix = f".recursive/run/{run_dir.name}/evidence/logs/red/"
1456
+ green_prefix = f".recursive/run/{run_dir.name}/evidence/logs/green/"
1457
+ red_paths = collect_paths_under_prefix(tdd_body, red_prefix)
1458
+ green_paths = collect_paths_under_prefix(tdd_body, green_prefix)
1459
+ if not red_paths:
1460
+ blockers.append(f"Strict TDD requires RED evidence under `/{red_prefix}`")
1461
+ else:
1462
+ missing_red = find_missing_repo_paths(repo_root, red_paths)
1463
+ if missing_red:
1464
+ blockers.append(f"Missing RED evidence file(s): {', '.join(missing_red[:5])}")
1465
+ if not green_paths:
1466
+ blockers.append(f"Strict TDD requires GREEN evidence under `/{green_prefix}`")
1467
+ else:
1468
+ missing_green = find_missing_repo_paths(repo_root, green_paths)
1469
+ if missing_green:
1470
+ blockers.append(f"Missing GREEN evidence file(s): {', '.join(missing_green[:5])}")
1471
+ else:
1472
+ exception_body = get_heading_body(content, "Pragmatic TDD Exception")
1473
+ if not exception_body:
1474
+ blockers.append("TDD Mode pragmatic requires ## Pragmatic TDD Exception")
1475
+ else:
1476
+ if not has_meaningful_value(get_md_field_value(exception_body, "Exception reason"), disallowed={"n/a", "none"}):
1477
+ blockers.append("Pragmatic TDD Exception is missing Exception reason")
1478
+ if not has_meaningful_value(get_md_field_value(exception_body, "Compensating validation"), disallowed={"n/a", "none"}):
1479
+ blockers.append("Pragmatic TDD Exception is missing Compensating validation")
1480
+ pragmatic_paths = collect_paths_under_prefix(exception_body, f".recursive/run/{run_dir.name}/evidence/")
1481
+ if not pragmatic_paths:
1482
+ blockers.append(f"Pragmatic TDD requires compensating evidence under `/.recursive/run/{run_dir.name}/evidence/`")
1483
+ else:
1484
+ missing_pragmatic = find_missing_repo_paths(repo_root, pragmatic_paths)
1485
+ if missing_pragmatic:
1486
+ blockers.append(f"Missing pragmatic TDD evidence file(s): {', '.join(missing_pragmatic[:5])}")
1487
+
1488
+ if file_name == "05-manual-qa.md":
1489
+ qa_record = get_heading_body(content, "QA Execution Record")
1490
+ evidence_body = get_heading_body(content, "Evidence and Artifacts")
1491
+ signoff_body = get_heading_body(content, "User Sign-Off")
1492
+ qa_mode = (get_md_field_value(qa_record, "QA Execution Mode") or get_md_field_value(content, "QA Execution Mode") or "").lower()
1493
+
1494
+ if not qa_record:
1495
+ blockers.append("Missing QA Execution Record section")
1496
+ if qa_mode not in QA_EXECUTION_MODES:
1497
+ blockers.append("Missing QA Execution Mode: human|agent-operated|hybrid")
1498
+ else:
1499
+ if qa_mode in {"human", "hybrid"}:
1500
+ if not has_meaningful_value(get_md_field_value(signoff_body, "Approved by"), disallowed={"n/a", "not required", "none"}):
1501
+ blockers.append(f"QA mode {qa_mode} requires Approved by in User Sign-Off")
1502
+ if not has_meaningful_value(get_md_field_value(signoff_body, "Date"), disallowed={"n/a", "not required", "none"}):
1503
+ blockers.append(f"QA mode {qa_mode} requires Date in User Sign-Off")
1504
+ if qa_mode in {"agent-operated", "hybrid"}:
1505
+ if not has_meaningful_value(get_md_field_value(qa_record, "Agent Executor"), disallowed={"n/a", "none"}):
1506
+ blockers.append(f"QA mode {qa_mode} requires Agent Executor")
1507
+ if not has_meaningful_value(get_md_field_value(qa_record, "Tools Used"), disallowed={"n/a", "none"}):
1508
+ blockers.append(f"QA mode {qa_mode} requires Tools Used")
1509
+ qa_paths = collect_paths_under_prefix(f"{qa_record}\n{evidence_body}", f".recursive/run/{run_dir.name}/evidence/")
1510
+ if not qa_paths:
1511
+ blockers.append(f"QA mode {qa_mode} requires evidence paths under `/.recursive/run/{run_dir.name}/evidence/`")
1512
+ else:
1513
+ missing_qa_paths = find_missing_repo_paths(repo_root, qa_paths)
1514
+ if missing_qa_paths:
1515
+ blockers.append(f"Missing QA evidence file(s): {', '.join(missing_qa_paths[:5])}")
1516
+
1517
+ if file_name == "03.5-code-review.md":
1518
+ blockers.extend(collect_review_bundle_blockers(content, run_dir, repo_root))
1519
+ if file_name == "08-memory-impact.md":
1520
+ blockers.extend(collect_phase8_skill_usage_blockers(content))
1521
+
1522
+ return blockers
1523
+
1524
+
1525
+ def get_latest_run_directory(run_root: Path) -> Path | None:
1526
+ runs = [p for p in run_root.iterdir() if p.is_dir()]
1527
+ if not runs:
1528
+ return None
1529
+ runs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
1530
+ return runs[0]
1531
+
1532
+
1533
+ def get_workflow_profile(run_dir: Path) -> str:
1534
+ requirements_path = run_dir / "00-requirements.md"
1535
+ if requirements_path.exists():
1536
+ content = requirements_path.read_text(encoding="utf-8")
1537
+ workflow_version = get_md_field_value(content, "Workflow version")
1538
+ if workflow_version == CURRENT_WORKFLOW_PROFILE:
1539
+ return CURRENT_WORKFLOW_PROFILE
1540
+ if workflow_version == STRICT_WORKFLOW_PROFILE:
1541
+ return STRICT_WORKFLOW_PROFILE
1542
+ if workflow_version == COMPAT_WORKFLOW_PROFILE:
1543
+ return COMPAT_WORKFLOW_PROFILE
1544
+
1545
+ if any((run_dir / file_name).exists() for file_name in LATE_PHASE_FILES):
1546
+ return COMPAT_WORKFLOW_PROFILE
1547
+
1548
+ return "legacy"
1549
+
1550
+
1551
+ def get_run_diff_basis(run_dir: Path) -> dict[str, str | None]:
1552
+ worktree_path = run_dir / "00-worktree.md"
1553
+ if not worktree_path.exists():
1554
+ return {
1555
+ "baseline_type": None,
1556
+ "baseline_reference": None,
1557
+ "comparison_reference": None,
1558
+ "normalized_baseline": None,
1559
+ "normalized_comparison": None,
1560
+ "normalized_diff_command": None,
1561
+ "base_branch": None,
1562
+ "worktree_branch": None,
1563
+ "notes": None,
1564
+ }
1565
+
1566
+ content = worktree_path.read_text(encoding="utf-8")
1567
+ source = parse_diff_basis_source(content)
1568
+ return {
1569
+ "baseline_type": get_md_field_value(source, "Baseline type"),
1570
+ "baseline_reference": get_md_field_value(source, "Baseline reference"),
1571
+ "comparison_reference": get_md_field_value(source, "Comparison reference"),
1572
+ "normalized_baseline": (
1573
+ get_md_field_value(source, "Normalized baseline")
1574
+ or get_md_field_value(source, "Normalized baseline commit")
1575
+ or get_md_field_value(source, "Base commit")
1576
+ ),
1577
+ "normalized_comparison": (
1578
+ get_md_field_value(source, "Normalized comparison")
1579
+ or get_md_field_value(source, "Normalized comparison reference")
1580
+ or get_md_field_value(source, "Worktree branch")
1581
+ ),
1582
+ "normalized_diff_command": (
1583
+ get_md_field_value(source, "Normalized diff command")
1584
+ or get_md_field_value(source, "Diff command convention")
1585
+ ),
1586
+ "base_branch": get_md_field_value(source, "Base branch"),
1587
+ "worktree_branch": get_md_field_value(source, "Worktree branch"),
1588
+ "notes": get_md_field_value(source, "Diff basis notes") or get_md_field_value(source, "Notes"),
1589
+ }
1590
+
1591
+
1592
+ def normalize_diff_basis(repo_root: Path, diff_basis: dict[str, str | None]) -> tuple[dict[str, str] | None, str | None]:
1593
+ baseline_type = normalize_baseline_type(diff_basis.get("baseline_type"))
1594
+ baseline_reference = trim_md_value(diff_basis.get("baseline_reference") or "")
1595
+ comparison_reference = normalize_comparison_reference(diff_basis.get("comparison_reference"))
1596
+ normalized_baseline = trim_md_value(diff_basis.get("normalized_baseline") or "")
1597
+ normalized_comparison = normalize_comparison_reference(diff_basis.get("normalized_comparison"))
1598
+ normalized_diff_command = trim_md_value(diff_basis.get("normalized_diff_command") or "")
1599
+
1600
+ missing_fields = []
1601
+ if not baseline_type:
1602
+ missing_fields.append("Baseline type")
1603
+ if not baseline_reference:
1604
+ missing_fields.append("Baseline reference")
1605
+ if not comparison_reference:
1606
+ missing_fields.append("Comparison reference")
1607
+ if not normalized_baseline:
1608
+ missing_fields.append("Normalized baseline")
1609
+ if not normalized_comparison:
1610
+ missing_fields.append("Normalized comparison")
1611
+ if not normalized_diff_command:
1612
+ missing_fields.append("Normalized diff command")
1613
+ if missing_fields:
1614
+ return None, f"Diff basis is missing required field(s): {', '.join(missing_fields)}"
1615
+
1616
+ comparison_git_ref = "HEAD" if normalized_comparison == "working-tree" else normalized_comparison
1617
+ if baseline_type == "merge-base derived":
1618
+ computed_baseline, error = run_git(repo_root, "merge-base", comparison_git_ref, baseline_reference)
1619
+ if error:
1620
+ return None, f"Unable to compute merge-base for diff basis: {error}"
1621
+ else:
1622
+ computed_baseline, error = run_git(repo_root, "rev-parse", "--verify", f"{baseline_reference}^{{commit}}")
1623
+ if error:
1624
+ return None, f"Unable to resolve baseline reference '{baseline_reference}': {error}"
1625
+
1626
+ if normalized_baseline != computed_baseline:
1627
+ return None, (
1628
+ "Recorded Normalized baseline does not match the executable diff basis "
1629
+ f"({normalized_baseline} != {computed_baseline})"
1630
+ )
1631
+
1632
+ if normalized_comparison == "working-tree":
1633
+ expected_command = f"git diff --name-only {computed_baseline}"
1634
+ git_args = ["diff", "--name-only", computed_baseline]
1635
+ else:
1636
+ computed_comparison, error = run_git(repo_root, "rev-parse", "--verify", f"{normalized_comparison}^{{commit}}")
1637
+ if error:
1638
+ return None, f"Unable to resolve comparison reference '{normalized_comparison}': {error}"
1639
+ expected_command = f"git diff --name-only {computed_baseline}..{computed_comparison}"
1640
+ git_args = ["diff", "--name-only", f"{computed_baseline}..{computed_comparison}"]
1641
+ if normalized_comparison != computed_comparison:
1642
+ return None, (
1643
+ "Recorded Normalized comparison does not match the executable diff basis "
1644
+ f"({normalized_comparison} != {computed_comparison})"
1645
+ )
1646
+
1647
+ if normalized_diff_command != expected_command:
1648
+ return None, (
1649
+ "Recorded Normalized diff command does not match the executable diff basis "
1650
+ f"({normalized_diff_command} != {expected_command})"
1651
+ )
1652
+
1653
+ return {
1654
+ "baseline_type": baseline_type,
1655
+ "baseline_reference": baseline_reference,
1656
+ "comparison_reference": comparison_reference,
1657
+ "normalized_baseline": computed_baseline,
1658
+ "normalized_comparison": normalized_comparison,
1659
+ "normalized_diff_command": expected_command,
1660
+ "comparison_git_ref": comparison_git_ref,
1661
+ "git_args": git_args,
1662
+ }, None
1663
+
1664
+
1665
+ def get_git_changed_files(repo_root: Path, diff_basis: dict[str, str | None]) -> tuple[list[str] | None, str | None]:
1666
+ normalized_basis, basis_error = normalize_diff_basis(repo_root, diff_basis)
1667
+ if basis_error:
1668
+ return None, basis_error
1669
+
1670
+ try:
1671
+ diff_result = subprocess.run(
1672
+ ["git", "-C", str(repo_root), *normalized_basis["git_args"]],
1673
+ check=False,
1674
+ capture_output=True,
1675
+ text=True,
1676
+ )
1677
+ untracked_result = subprocess.run(
1678
+ ["git", "-C", str(repo_root), "ls-files", "--others", "--exclude-standard"],
1679
+ check=False,
1680
+ capture_output=True,
1681
+ text=True,
1682
+ )
1683
+ except OSError as exc:
1684
+ return None, f"Unable to execute git: {exc}"
1685
+
1686
+ if diff_result.returncode != 0:
1687
+ message = diff_result.stderr.strip() or diff_result.stdout.strip() or "git diff failed"
1688
+ return None, message
1689
+ if untracked_result.returncode != 0:
1690
+ message = untracked_result.stderr.strip() or untracked_result.stdout.strip() or "git ls-files failed"
1691
+ return None, message
1692
+
1693
+ tracked_changed = [path.strip() for path in diff_result.stdout.splitlines() if path.strip()]
1694
+ untracked_changed = [
1695
+ path.strip()
1696
+ for path in untracked_result.stdout.splitlines()
1697
+ if path.strip() and not is_transient_runtime_path(path.strip())
1698
+ ]
1699
+ changed = tracked_changed + untracked_changed
1700
+ return sorted(set(path.strip() for path in changed if path.strip())), None
1701
+
1702
+
1703
+ @dataclass
1704
+ class ArtifactState:
1705
+ exists: bool
1706
+ status: str
1707
+ lock_valid: bool
1708
+ lock_problems: list[str]
1709
+ blockers: list[str]
1710
+ locked_at: str | None
1711
+ stored_hash: str | None
1712
+ actual_hash: str | None
1713
+ coverage: str
1714
+ approval: str
1715
+ audit: str
1716
+ todo_has_section: bool
1717
+ todo_unchecked: int
1718
+
1719
+
1720
+ def collect_audit_blockers(
1721
+ file_name: str,
1722
+ content: str,
1723
+ workflow_profile: str,
1724
+ requirement_ids: list[str],
1725
+ actual_changed_files: list[str] | None,
1726
+ diff_basis_error: str | None,
1727
+ run_dir: Path,
1728
+ ) -> list[str]:
1729
+ blockers: list[str] = []
1730
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES or file_name not in AUDITED_PHASE_FILES:
1731
+ return blockers
1732
+
1733
+ audit = get_gate_status(content, "Audit")
1734
+ coverage = get_gate_status(content, "Coverage")
1735
+ approval = get_gate_status(content, "Approval")
1736
+
1737
+ if audit != "PASS":
1738
+ blockers.append(f"Audit verdict is {audit}")
1739
+ if coverage == "PASS" and audit != "PASS":
1740
+ blockers.append("Coverage cannot pass before Audit: PASS")
1741
+ if approval == "PASS" and audit != "PASS":
1742
+ blockers.append("Approval cannot pass before Audit: PASS")
1743
+
1744
+ audit_context = get_heading_body(content, "Audit Context")
1745
+ if not audit_context:
1746
+ blockers.append("Missing Audit Context section")
1747
+ else:
1748
+ if get_md_field_value(audit_context, "Audit Execution Mode") not in {"subagent", "self-audit"}:
1749
+ blockers.append("Missing valid Audit Execution Mode")
1750
+ if get_md_field_value(audit_context, "Subagent Availability") not in {"available", "unavailable"}:
1751
+ blockers.append("Missing valid Subagent Availability")
1752
+ if not has_meaningful_value(get_md_field_value(audit_context, "Subagent Capability Probe"), disallowed={"n/a", "none"}):
1753
+ blockers.append("Missing Subagent Capability Probe")
1754
+ if not has_meaningful_value(get_md_field_value(audit_context, "Delegation Decision Basis"), disallowed={"n/a", "none"}):
1755
+ blockers.append("Missing Delegation Decision Basis")
1756
+ if get_md_field_value(audit_context, "Audit Inputs Provided") is None and "Audit Inputs Provided:" not in audit_context:
1757
+ blockers.append("Missing Audit Inputs Provided")
1758
+ blockers.extend(collect_subagent_delegation_blockers(audit_context))
1759
+
1760
+ for heading in AUDIT_REQUIRED_HEADINGS:
1761
+ if not get_heading_body(content, heading):
1762
+ blockers.append(f"Missing section: ## {heading}")
1763
+
1764
+ traceability_body = get_heading_body(content, "Traceability")
1765
+ if file_name not in {"00-worktree.md", "00-requirements.md"}:
1766
+ missing_ids = [requirement_id for requirement_id in requirement_ids if requirement_id not in traceability_body]
1767
+ if missing_ids:
1768
+ blockers.append(f"Traceability missing requirement IDs: {', '.join(missing_ids)}")
1769
+
1770
+ diff_audit_body = get_heading_body(content, "Worktree Diff Audit")
1771
+ for field_name in DIFF_BASIS_FIELDS:
1772
+ if get_md_field_value(diff_audit_body, field_name) is None:
1773
+ blockers.append(f"Missing diff basis field: {field_name}:")
1774
+ gaps_body = get_heading_body(content, "Gaps Found")
1775
+ if audit == "PASS" and gaps_body and not re.search(r"\bnone\b", gaps_body, re.IGNORECASE):
1776
+ blockers.append("Audit: PASS is invalid while Gaps Found still lists unresolved in-scope gaps")
1777
+
1778
+ expected_changed_files = get_phase_owned_actual_changed_files(file_name, actual_changed_files)
1779
+ if file_name in DIFF_AUDITED_FILES and expected_changed_files is not None:
1780
+ if diff_basis_error:
1781
+ blockers.append(f"Cannot verify git diff basis: {diff_basis_error}")
1782
+ else:
1783
+ reviewed_paths = collect_reviewed_paths(run_dir, file_name, content)
1784
+ missing_paths = [path for path in expected_changed_files if path not in reviewed_paths]
1785
+ if missing_paths:
1786
+ preview = ", ".join(missing_paths[:5])
1787
+ suffix = " ..." if len(missing_paths) > 5 else ""
1788
+ blockers.append(f"Unexplained changed files outside reviewed scope: {preview}{suffix}")
1789
+
1790
+ return blockers
1791
+
1792
+
1793
+ def get_artifact_state(
1794
+ artifact_path: Path,
1795
+ workflow_profile: str,
1796
+ requirement_ids: list[str],
1797
+ actual_changed_files: list[str] | None,
1798
+ diff_basis_error: str | None,
1799
+ ) -> ArtifactState:
1800
+ if not artifact_path.exists():
1801
+ return ArtifactState(
1802
+ exists=False,
1803
+ status="PENDING",
1804
+ lock_valid=False,
1805
+ lock_problems=["File missing"],
1806
+ blockers=["File missing"],
1807
+ locked_at=None,
1808
+ stored_hash=None,
1809
+ actual_hash=None,
1810
+ coverage="MISSING",
1811
+ approval="MISSING",
1812
+ audit="MISSING",
1813
+ todo_has_section=False,
1814
+ todo_unchecked=0,
1815
+ )
1816
+
1817
+ content = artifact_path.read_text(encoding="utf-8")
1818
+ status = get_md_field_value(content, "Status") or "UNKNOWN"
1819
+ has_todo, _, _, unchecked = get_todo_stats(content)
1820
+ coverage = get_gate_status(content, "Coverage")
1821
+ approval = get_gate_status(content, "Approval")
1822
+ audit = get_gate_status(content, "Audit")
1823
+ tdd_compliance = get_gate_status(content, "TDD Compliance")
1824
+ locked_at = get_md_field_value(content, "LockedAt")
1825
+ stored_hash = get_md_field_value(content, "LockHash")
1826
+ actual_hash = None
1827
+ lock_problems: list[str] = []
1828
+ blockers: list[str] = []
1829
+ lock_valid = False
1830
+ run_dir = artifact_path.parent
1831
+ repo_root = run_dir.parent.parent.parent
1832
+
1833
+ if status != "LOCKED":
1834
+ lock_problems.append(f"Status is '{status}' (expected LOCKED for lock-valid)")
1835
+ else:
1836
+ if not locked_at:
1837
+ lock_problems.append("Missing LockedAt")
1838
+ if not stored_hash:
1839
+ lock_problems.append("Missing LockHash")
1840
+ if stored_hash:
1841
+ actual_hash = lock_hash_from_content(content)
1842
+ if stored_hash.lower() != actual_hash.lower():
1843
+ lock_problems.append("LockHash mismatch")
1844
+ if coverage != "PASS":
1845
+ lock_problems.append(f"Coverage gate is {coverage}")
1846
+ if approval != "PASS":
1847
+ lock_problems.append(f"Approval gate is {approval}")
1848
+ if workflow_profile in STRICT_WORKFLOW_PROFILES and artifact_path.name in AUDITED_PHASE_FILES and audit != "PASS":
1849
+ lock_problems.append(f"Audit gate is {audit}")
1850
+ if artifact_path.name == "03-implementation-summary.md" and tdd_compliance != "PASS":
1851
+ lock_problems.append(f"TDD Compliance gate is {tdd_compliance}")
1852
+ if not has_todo:
1853
+ lock_problems.append("Missing ## TODO section")
1854
+ elif unchecked > 0:
1855
+ lock_problems.append(f"Unchecked TODO items: {unchecked}")
1856
+ lock_problems.extend(
1857
+ collect_phase_specific_blockers(
1858
+ artifact_path.name,
1859
+ content,
1860
+ workflow_profile,
1861
+ run_dir,
1862
+ repo_root,
1863
+ requirement_ids,
1864
+ actual_changed_files,
1865
+ )
1866
+ )
1867
+ if not lock_problems:
1868
+ lock_valid = True
1869
+
1870
+ blockers.extend(collect_audit_blockers(artifact_path.name, content, workflow_profile, requirement_ids, actual_changed_files, diff_basis_error, artifact_path.parent))
1871
+ blockers.extend(
1872
+ collect_phase_specific_blockers(
1873
+ artifact_path.name,
1874
+ content,
1875
+ workflow_profile,
1876
+ run_dir,
1877
+ repo_root,
1878
+ requirement_ids,
1879
+ actual_changed_files,
1880
+ )
1881
+ )
1882
+ if not has_todo:
1883
+ blockers.append("Missing ## TODO section")
1884
+ elif unchecked > 0:
1885
+ blockers.append(f"Unchecked TODO items: {unchecked}")
1886
+ if coverage != "PASS":
1887
+ blockers.append(f"Coverage gate is {coverage}")
1888
+ if approval != "PASS":
1889
+ blockers.append(f"Approval gate is {approval}")
1890
+ if artifact_path.name == "03-implementation-summary.md" and tdd_compliance != "PASS":
1891
+ blockers.append(f"TDD Compliance gate is {tdd_compliance}")
1892
+
1893
+ deduped_blockers: list[str] = []
1894
+ for blocker in blockers:
1895
+ if blocker not in deduped_blockers:
1896
+ deduped_blockers.append(blocker)
1897
+
1898
+ return ArtifactState(
1899
+ exists=True,
1900
+ status=status,
1901
+ lock_valid=lock_valid,
1902
+ lock_problems=lock_problems,
1903
+ blockers=deduped_blockers,
1904
+ locked_at=locked_at,
1905
+ stored_hash=stored_hash,
1906
+ actual_hash=actual_hash,
1907
+ coverage=coverage,
1908
+ approval=approval,
1909
+ audit=audit,
1910
+ todo_has_section=has_todo,
1911
+ todo_unchecked=unchecked,
1912
+ )
1913
+
1914
+
1915
+ def print_phase_status(phases: list[dict[str, str]], states: dict[str, ArtifactState], show_hashes: bool, run_id: str, workflow_profile: str) -> None:
1916
+ print("Phase Status:")
1917
+ for phase in phases:
1918
+ state = states[phase["Key"]]
1919
+ display = state.status
1920
+ suffix = ""
1921
+ if display == "SKIPPED":
1922
+ suffix = " (legacy workflow)" if phase["Key"] in LATE_PHASE_KEYS and workflow_profile == "legacy" else " (not needed)"
1923
+ elif display == "LOCKED" and not state.lock_valid:
1924
+ display = "LOCKED*"
1925
+ suffix = " (invalid)"
1926
+ print(f" {phase['Label']:<26} [{display}]{suffix}")
1927
+ if state.blockers and display not in {"SKIPPED"}:
1928
+ preview = "; ".join(state.blockers[:2])
1929
+ if len(state.blockers) > 2:
1930
+ preview += "; ..."
1931
+ print(f" blockers: {preview}")
1932
+
1933
+ print()
1934
+ print("Lock Chain:")
1935
+ for phase in phases:
1936
+ state = states[phase["Key"]]
1937
+ if state.status == "SKIPPED":
1938
+ continue
1939
+
1940
+ artifact_rel = f".recursive/run/{run_id}/{phase['File']}"
1941
+ if state.lock_valid:
1942
+ print(f" [OK] {artifact_rel}")
1943
+ if show_hashes and state.stored_hash:
1944
+ print(f" LockHash: {state.stored_hash}")
1945
+ continue
1946
+
1947
+ if not state.exists:
1948
+ print(f" [PENDING] {artifact_rel}")
1949
+ elif state.status != "LOCKED":
1950
+ print(f" [DRAFT] {artifact_rel}")
1951
+ else:
1952
+ reason = state.lock_problems[0] if state.lock_problems else "Not lock-valid"
1953
+ print(f" [FAIL] {artifact_rel} - {reason}")
1954
+ break
1955
+
1956
+
1957
+ def main() -> None:
1958
+ parser = argparse.ArgumentParser(description="Show recursive-mode run status and lock-chain summary.")
1959
+ parser.add_argument("--run-id", default="", help="Run ID to inspect (default: latest run).")
1960
+ parser.add_argument("--repo-root", default=".", help="Repository root path.")
1961
+ parser.add_argument("--show-hashes", action="store_true", help="Show LockHash values for lock-valid phases.")
1962
+ args = parser.parse_args()
1963
+
1964
+ repo_root = Path(args.repo_root).resolve()
1965
+ run_root = repo_root / ".recursive" / "run"
1966
+ if not run_root.exists():
1967
+ print(f"[FAIL] recursive run directory not found at: {run_root}")
1968
+ print(" Is this the project repo root? (Expected .recursive/run/)")
1969
+ sys.exit(1)
1970
+
1971
+ if args.run_id.strip():
1972
+ run_dir = run_root / args.run_id.strip()
1973
+ if not run_dir.exists():
1974
+ print(f"[FAIL] Run directory not found: {run_dir}")
1975
+ sys.exit(1)
1976
+ run_id = args.run_id.strip()
1977
+ else:
1978
+ latest = get_latest_run_directory(run_root)
1979
+ if latest is None:
1980
+ print(f"[FAIL] No runs found under: {run_root}")
1981
+ sys.exit(1)
1982
+ run_dir = latest
1983
+ run_id = latest.name
1984
+
1985
+ workflow_profile = get_workflow_profile(run_dir)
1986
+ requirement_ids: list[str] = []
1987
+ requirements_path = run_dir / "00-requirements.md"
1988
+ if requirements_path.exists():
1989
+ requirement_ids = load_lint_module().get_run_requirement_ids(run_dir, workflow_profile)
1990
+
1991
+ actual_changed_files: list[str] | None = None
1992
+ diff_basis_error: str | None = None
1993
+ if workflow_profile in STRICT_WORKFLOW_PROFILES:
1994
+ diff_basis = get_run_diff_basis(run_dir)
1995
+ raw_changed_files, diff_basis_error = get_git_changed_files(repo_root, diff_basis)
1996
+ if raw_changed_files is not None:
1997
+ actual_changed_files = filter_runtime_changed_files(raw_changed_files, run_id)
1998
+
1999
+ phases = [
2000
+ {"Key": "00R", "Label": "Phase 0 (Requirements)", "File": "00-requirements.md", "Optional": False, "PhaseName": "0 (Requirements)"},
2001
+ {"Key": "00W", "Label": "Phase 0 (Worktree)", "File": "00-worktree.md", "Optional": False, "PhaseName": "0 (Worktree)"},
2002
+ {"Key": "01", "Label": "Phase 1 (AS-IS)", "File": "01-as-is.md", "Optional": False, "PhaseName": "1 (AS-IS)"},
2003
+ {"Key": "01.5", "Label": "Phase 1.5 (Root Cause)", "File": "01.5-root-cause.md", "Optional": True, "PhaseName": "1.5 (Root Cause)"},
2004
+ {"Key": "02", "Label": "Phase 2 (TO-BE Plan)", "File": "02-to-be-plan.md", "Optional": False, "PhaseName": "2 (TO-BE Plan)"},
2005
+ {"Key": "03", "Label": "Phase 3 (Implementation)", "File": "03-implementation-summary.md", "Optional": False, "PhaseName": "3 (Implementation)"},
2006
+ {"Key": "03.5", "Label": "Phase 3.5 (Code Review)", "File": "03.5-code-review.md", "Optional": True, "PhaseName": "3.5 (Code Review)"},
2007
+ {"Key": "04", "Label": "Phase 4 (Test Summary)", "File": "04-test-summary.md", "Optional": False, "PhaseName": "4 (Test Summary)"},
2008
+ {"Key": "05", "Label": "Phase 5 (Manual QA)", "File": "05-manual-qa.md", "Optional": False, "PhaseName": "5 (Manual QA)"},
2009
+ {"Key": "06", "Label": "Phase 6 (Decisions)", "File": "06-decisions-update.md", "Optional": False, "PhaseName": "6 (Decisions Update)"},
2010
+ {"Key": "07", "Label": "Phase 7 (State)", "File": "07-state-update.md", "Optional": False, "PhaseName": "7 (State Update)"},
2011
+ {"Key": "08", "Label": "Phase 8 (Memory)", "File": "08-memory-impact.md", "Optional": False, "PhaseName": "8 (Memory Impact)"},
2012
+ ]
2013
+
2014
+ states: dict[str, ArtifactState] = {}
2015
+ for phase in phases:
2016
+ state = get_artifact_state(run_dir / phase["File"], workflow_profile, requirement_ids, actual_changed_files, diff_basis_error)
2017
+ if phase["Optional"] and not state.exists:
2018
+ state.status = "SKIPPED"
2019
+ elif workflow_profile == "legacy" and phase["Key"] in LATE_PHASE_KEYS and not state.exists:
2020
+ state.status = "SKIPPED"
2021
+ states[phase["Key"]] = state
2022
+
2023
+ current_phase: dict[str, str] | None = None
2024
+ current_state: ArtifactState | None = None
2025
+ for phase in phases:
2026
+ state = states[phase["Key"]]
2027
+ if state.status == "SKIPPED":
2028
+ continue
2029
+ if not state.exists or not state.lock_valid:
2030
+ current_phase = phase
2031
+ current_state = state
2032
+ break
2033
+
2034
+ title = f"Recursive Run: {run_id}"
2035
+ print(title)
2036
+ print("=" * max(8, len(title)))
2037
+ print()
2038
+ print(f"Workflow Profile: {workflow_profile}")
2039
+ if workflow_profile in STRICT_WORKFLOW_PROFILES:
2040
+ print("Audit Contract: audited phases must reach Audit: PASS before Coverage/Approval may pass")
2041
+ print()
2042
+ print_phase_status(phases, states, args.show_hashes, run_id, workflow_profile)
2043
+
2044
+ # Next-legal-phase and stale-chain summary using the canonical phase-rules model.
2045
+ phase_rules = load_phase_rules_module()
2046
+ next_legal = phase_rules.get_next_legal_phase(run_dir)
2047
+ stale_entries = phase_rules.get_all_stale_receipts(run_dir)
2048
+
2049
+ if next_legal:
2050
+ print(f"Next Legal Phase: {next_legal}")
2051
+ prereq_blockers = phase_rules.get_prerequisite_blockers(next_legal, run_dir)
2052
+ if prereq_blockers:
2053
+ print(" Prerequisite blockers:")
2054
+ for blocker in prereq_blockers:
2055
+ print(f" - {blocker['artifact']}: {blocker['status']}")
2056
+ elif current_phase is None:
2057
+ print("Next Legal Phase: COMPLETE (all phases locked)")
2058
+ else:
2059
+ print("Next Legal Phase: BLOCKED")
2060
+ print(" All candidate phases have unresolved prerequisites.")
2061
+
2062
+ if stale_entries:
2063
+ print()
2064
+ print("Stale Lock-Chain Receipts (re-lock downstream phases in order):")
2065
+ for entry in stale_entries:
2066
+ print(f" - {entry['artifact']}: {entry['reason']}")
2067
+ print()
2068
+ if current_phase is None:
2069
+ print("Current Phase: COMPLETE")
2070
+ print("Status: LOCKED")
2071
+ else:
2072
+ print(f"Current Phase: {current_phase['PhaseName']}")
2073
+ print(f"Status: {current_state.status}")
2074
+ if current_state.blockers:
2075
+ print("Audit Blockers:")
2076
+ for blocker in current_state.blockers:
2077
+ print(f" - {blocker}")
2078
+
2079
+ evidence_dir = run_dir / "evidence"
2080
+ evidence_files = sum(1 for path in evidence_dir.rglob("*") if path.is_file()) if evidence_dir.exists() else 0
2081
+ evidence_rel = f".recursive/run/{run_id}/evidence/"
2082
+ print()
2083
+ print("Evidence:")
2084
+ print(f" Path: {evidence_rel}")
2085
+ print(f" Exists: {'Yes' if evidence_dir.exists() else 'No'}")
2086
+ print(f" Files: {evidence_files}")
2087
+
2088
+ if workflow_profile in STRICT_WORKFLOW_PROFILES:
2089
+ print()
2090
+ print("Diff Audit:")
2091
+ if diff_basis_error:
2092
+ print(f" Status: blocked - {diff_basis_error}")
2093
+ else:
2094
+ print(f" Changed files reviewed from git diff basis: {len(actual_changed_files or [])}")
2095
+
2096
+ print()
2097
+ print("Next Steps:")
2098
+ if current_phase is None:
2099
+ if workflow_profile == "legacy":
2100
+ print(" 1. Legacy run is complete under the pre-Phase-8 workflow contract.")
2101
+ print(" 2. If you resume this run under the new workflow, add `Workflow version: recursive-mode-audit-v2` and continue with the stricter audited closeout.")
2102
+ elif workflow_profile == COMPAT_WORKFLOW_PROFILE:
2103
+ print(" 1. Compatibility run is complete through Phase 8.")
2104
+ print(" 2. Use `Workflow version: recursive-mode-audit-v2` for new runs that should enforce the stronger audit loop.")
2105
+ else:
2106
+ print(" 1. Run is complete through audited Phase 8.")
2107
+ print(" 2. Merge the worktree branch when ready.")
2108
+ else:
2109
+ next_artifact = f".recursive/run/{run_id}/{current_phase['File']}"
2110
+ if workflow_profile in STRICT_WORKFLOW_PROFILES and current_phase["File"] in AUDITED_PHASE_FILES:
2111
+ print(f" 1. Update {next_artifact} so the audit sections are complete and grounded in upstream artifacts plus the recorded diff basis.")
2112
+ print(" 2. Repair any in-scope gaps or unexplained drift, then rerun the audit.")
2113
+ print(" 3. Only after `Audit: PASS` may Coverage/Approval pass and the phase lock.")
2114
+ else:
2115
+ print(f" 1. Complete {next_artifact}.")
2116
+ print(" 2. Pass the required gates and lock the artifact before advancing.")
2117
+
2118
+ print()
2119
+ print("Quick Command:")
2120
+ print(f" Implement requirement '{run_id}'")
2121
+
2122
+
2123
+ if __name__ == "__main__":
2124
+ main()