@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,2868 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Lint recursive-mode artifacts for structure, audit discipline, and gate integrity.
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 pathlib import Path
15
+
16
+
17
+ def load_phase_rules_module():
18
+ module_path = Path(__file__).with_name("recursive_phase_rules.py")
19
+ spec = importlib.util.spec_from_file_location("recursive_phase_rules", module_path)
20
+ if spec is None or spec.loader is None:
21
+ raise RuntimeError(f"Unable to load phase rules module from {module_path}")
22
+ module = importlib.util.module_from_spec(spec)
23
+ try:
24
+ spec.loader.exec_module(module)
25
+ except FileNotFoundError:
26
+ raise RuntimeError(f"Phase rules module not found: {module_path}")
27
+ return module
28
+
29
+
30
+ CURRENT_WORKFLOW_PROFILE = "recursive-mode-audit-v2"
31
+ STRICT_WORKFLOW_PROFILE = "recursive-mode-audit-v1"
32
+ COMPAT_WORKFLOW_PROFILE = "memory-phase8"
33
+ STRICT_WORKFLOW_PROFILES = {CURRENT_WORKFLOW_PROFILE, STRICT_WORKFLOW_PROFILE}
34
+ LATE_PHASE_ARTIFACTS = ["06-decisions-update.md", "07-state-update.md", "08-memory-impact.md"]
35
+ AUDITED_PHASE_FILES = {
36
+ "01-as-is.md",
37
+ "01.5-root-cause.md",
38
+ "02-to-be-plan.md",
39
+ "03-implementation-summary.md",
40
+ "03.5-code-review.md",
41
+ "04-test-summary.md",
42
+ "06-decisions-update.md",
43
+ "07-state-update.md",
44
+ "08-memory-impact.md",
45
+ }
46
+ PRIOR_RECURSIVE_EVIDENCE_FILES = {
47
+ "01-as-is.md",
48
+ "02-to-be-plan.md",
49
+ "04-test-summary.md",
50
+ "07-state-update.md",
51
+ "08-memory-impact.md",
52
+ }
53
+ DIFF_AUDITED_FILES = {
54
+ "02-to-be-plan.md",
55
+ "03-implementation-summary.md",
56
+ "03.5-code-review.md",
57
+ "04-test-summary.md",
58
+ "06-decisions-update.md",
59
+ "07-state-update.md",
60
+ "08-memory-impact.md",
61
+ }
62
+ TRACEABILITY_REQUIRED_FILES = {
63
+ "01-as-is.md",
64
+ "01.5-root-cause.md",
65
+ "02-to-be-plan.md",
66
+ "03-implementation-summary.md",
67
+ "03.5-code-review.md",
68
+ "04-test-summary.md",
69
+ "05-manual-qa.md",
70
+ "06-decisions-update.md",
71
+ "07-state-update.md",
72
+ "08-memory-impact.md",
73
+ }
74
+ AUDIT_REQUIRED_HEADINGS = [
75
+ "Audit Context",
76
+ "Effective Inputs Re-read",
77
+ "Earlier Phase Reconciliation",
78
+ "Subagent Contribution Verification",
79
+ "Worktree Diff Audit",
80
+ "Gaps Found",
81
+ "Repair Work Performed",
82
+ "Requirement Completion Status",
83
+ "Audit Verdict",
84
+ ]
85
+ DIFF_BASIS_FIELDS = [
86
+ "Baseline type",
87
+ "Baseline reference",
88
+ "Comparison reference",
89
+ "Normalized baseline",
90
+ "Normalized comparison",
91
+ "Normalized diff command",
92
+ ]
93
+ PRODUCT_DIFF_PHASE_FILES = {"03-implementation-summary.md", "03.5-code-review.md", "04-test-summary.md"}
94
+ DECISIONS_DIFF_PHASE_FILES = {"06-decisions-update.md"}
95
+ STATE_DIFF_PHASE_FILES = {"07-state-update.md"}
96
+ MEMORY_DIFF_PHASE_FILES = {"08-memory-impact.md"}
97
+
98
+ MEMORY_ALLOWED_TYPES = {"index", "domain", "pattern", "incident", "episode"}
99
+ MEMORY_ALLOWED_STATUSES = {"CURRENT", "SUSPECT", "STALE", "DEPRECATED", "DRAFT"}
100
+ MEMORY_REQUIRED_FIELDS = [
101
+ "Type",
102
+ "Status",
103
+ "Scope",
104
+ "Owns-Paths",
105
+ "Watch-Paths",
106
+ "Source-Runs",
107
+ "Validated-At-Commit",
108
+ "Last-Validated",
109
+ "Tags",
110
+ ]
111
+ TDD_MODES = {"strict", "pragmatic"}
112
+ QA_EXECUTION_MODES = {"human", "agent-operated", "hybrid"}
113
+ TRANSIENT_RUNTIME_DIR_MARKERS = {
114
+ "__pycache__",
115
+ ".pytest_cache",
116
+ ".mypy_cache",
117
+ ".ruff_cache",
118
+ ".hypothesis",
119
+ ".tox",
120
+ ".nox",
121
+ ".target",
122
+ ".playwright-mcp",
123
+ ".cargo-target-dir",
124
+ }
125
+ TRANSIENT_RUNTIME_FILE_NAMES = {".ds_store", "thumbs.db"}
126
+ TRANSIENT_RUNTIME_SUFFIXES = (".pyc", ".pyo", ".pyd")
127
+ DIFF_BASIS_ALLOWED_TYPES = {"local commit", "local branch", "remote ref", "merge-base derived"}
128
+ WORKING_TREE_COMPARISON_REFS = {"working-tree", "working-tree@head", "worktree", "working-tree+head"}
129
+ INVENTORY_DISPOSITIONS = {"in-scope", "out-of-scope", "constraint", "quality-gate"}
130
+ PHASE2_REQUIREMENT_DISPOSITION_STATUSES = {
131
+ "planned",
132
+ "planned-via-merge",
133
+ "planned-indirectly",
134
+ "deferred",
135
+ "out-of-scope",
136
+ "blocked",
137
+ "superseded by approved addendum",
138
+ }
139
+ REQUIREMENT_DISPOSITION_STATUSES = {
140
+ "implemented",
141
+ "verified",
142
+ "deferred",
143
+ "out-of-scope",
144
+ "blocked",
145
+ "superseded by approved addendum",
146
+ }
147
+ SKILL_USAGE_RELEVANCE_STATUSES = {"relevant", "not-relevant", "yes", "no"}
148
+ FINAL_REQUIREMENT_DISPOSITION_FILES = {
149
+ "04-test-summary.md",
150
+ "06-decisions-update.md",
151
+ "07-state-update.md",
152
+ "08-memory-impact.md",
153
+ }
154
+ REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES = {
155
+ "03-implementation-summary.md",
156
+ "03.5-code-review.md",
157
+ "04-test-summary.md",
158
+ }
159
+ SUBAGENT_ACTION_REQUIRED_HEADINGS = [
160
+ "Metadata",
161
+ "Inputs Provided",
162
+ "Claimed Actions Taken",
163
+ "Claimed File Impact",
164
+ "Claimed Artifact Impact",
165
+ "Claimed Findings",
166
+ "Verification Handoff",
167
+ ]
168
+ SKILL_MEMORY_ROUTER_NAMES = {"MEMORY.md", "SKILLS.md"}
169
+ RUN_ARTIFACT_SEQUENCE = [
170
+ "00-requirements.md",
171
+ "00-worktree.md",
172
+ "01-as-is.md",
173
+ "01.5-root-cause.md",
174
+ "02-to-be-plan.md",
175
+ "03-implementation-summary.md",
176
+ "03.5-code-review.md",
177
+ "04-test-summary.md",
178
+ "05-manual-qa.md",
179
+ "06-decisions-update.md",
180
+ "07-state-update.md",
181
+ "08-memory-impact.md",
182
+ ]
183
+ REQUIREMENT_ID_RE = re.compile(r"^(R\d+|SRC-\d{3})$")
184
+
185
+
186
+ def write_issue(severity: str, file_path: Path, message: str, remediation_lines: list[str] | None = None) -> None:
187
+ print(f"[{severity}] {file_path}: {message}")
188
+ if remediation_lines:
189
+ print("Remediation (copy/paste):")
190
+ for line in remediation_lines:
191
+ print(f" {line}")
192
+ print()
193
+
194
+
195
+ def trim_md_value(value: str) -> str:
196
+ trimmed = value.strip()
197
+ for quote in ("`", '"', "'"):
198
+ if trimmed.startswith(quote) and trimmed.endswith(quote) and len(trimmed) >= 2:
199
+ inner = trimmed[1:-1]
200
+ if quote not in inner:
201
+ return inner.strip()
202
+ return trimmed
203
+
204
+
205
+ def get_md_field_value(content: str, field_name: str) -> str | None:
206
+ pattern = re.compile(rf"(?m)^[ \t]*(?:[-*][ \t]+)?{re.escape(field_name)}:[ \t]*(.+?)\s*$")
207
+ match = pattern.search(content)
208
+ if not match:
209
+ return None
210
+ return trim_md_value(match.group(1))
211
+
212
+
213
+ def has_header_field(content: str, field_name: str) -> bool:
214
+ pattern = re.compile(rf"(?m)^[ \t]*(?:[-*][ \t]+)?{re.escape(field_name)}:")
215
+ return bool(pattern.search(content))
216
+
217
+
218
+ def has_heading(content: str, heading_text: str) -> bool:
219
+ pattern = re.compile(rf"(?m)^[ \t]*##\s+{re.escape(heading_text)}\s*$")
220
+ return bool(pattern.search(content))
221
+
222
+
223
+ def get_heading_body(content: str, heading_text: str) -> str:
224
+ pattern = re.compile(
225
+ rf"(?ms)^[ \t]*##\s+{re.escape(heading_text)}\s*$\n?(.*?)(?=^[ \t]*##\s+|\Z)"
226
+ )
227
+ match = pattern.search(content)
228
+ if not match:
229
+ return ""
230
+ return match.group(1).strip()
231
+
232
+
233
+ def get_subheading_body(content: str, heading_text: str, level: int = 3) -> str:
234
+ hashes = "#" * level
235
+ pattern = re.compile(
236
+ rf"(?ms)^[ \t]*{re.escape(hashes)}\s+{re.escape(heading_text)}\s*$\n?(.*?)(?=^[ \t]*#{{1,{level}}}\s+|\Z)"
237
+ )
238
+ match = pattern.search(content)
239
+ if not match:
240
+ return ""
241
+ return match.group(1).strip()
242
+
243
+
244
+ def has_gate_line(content: str, gate_name: str) -> bool:
245
+ pattern = re.compile(rf"(?m)^[ \t]*{re.escape(gate_name)}:\s*(PASS|FAIL)\s*$")
246
+ return bool(pattern.search(content))
247
+
248
+
249
+ def get_gate_status(content: str, gate_name: str) -> str:
250
+ pattern = re.compile(rf"(?m)^[ \t]*{re.escape(gate_name)}:\s*(PASS|FAIL)\s*$")
251
+ match = pattern.search(content)
252
+ return match.group(1).upper() if match else "MISSING"
253
+
254
+
255
+ def get_todo_stats(content: str) -> tuple[bool, int, int, int]:
256
+ lines = content.splitlines()
257
+ in_todo = False
258
+ has_todo = False
259
+ checked = 0
260
+ unchecked = 0
261
+ total = 0
262
+
263
+ for line in lines:
264
+ if not in_todo:
265
+ if re.match(r"^\s*##\s+TODO\s*$", line):
266
+ in_todo = True
267
+ has_todo = True
268
+ continue
269
+
270
+ if re.match(r"^\s*##\s+", line) or re.match(r"^\s*#\s+", line):
271
+ break
272
+
273
+ item = re.match(r"^\s*[-*]\s+\[([ xX])\]\s+", line)
274
+ if item:
275
+ total += 1
276
+ if item.group(1).lower() == "x":
277
+ checked += 1
278
+ else:
279
+ unchecked += 1
280
+
281
+ return has_todo, total, checked, unchecked
282
+
283
+
284
+ def get_latest_run_directory(run_root: Path) -> Path | None:
285
+ runs = [p for p in run_root.iterdir() if p.is_dir()]
286
+ if not runs:
287
+ return None
288
+ runs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
289
+ return runs[0]
290
+
291
+
292
+ def get_workflow_profile(run_dir: Path) -> str:
293
+ requirements_path = run_dir / "00-requirements.md"
294
+ if requirements_path.exists():
295
+ content = requirements_path.read_text(encoding="utf-8")
296
+ workflow_version = get_md_field_value(content, "Workflow version")
297
+ if workflow_version == CURRENT_WORKFLOW_PROFILE:
298
+ return CURRENT_WORKFLOW_PROFILE
299
+ if workflow_version == STRICT_WORKFLOW_PROFILE:
300
+ return STRICT_WORKFLOW_PROFILE
301
+ if workflow_version == COMPAT_WORKFLOW_PROFILE:
302
+ return COMPAT_WORKFLOW_PROFILE
303
+
304
+ if any((run_dir / artifact).exists() for artifact in LATE_PHASE_ARTIFACTS):
305
+ return COMPAT_WORKFLOW_PROFILE
306
+
307
+ return "legacy"
308
+
309
+
310
+ def is_placeholder_only(text: str) -> bool:
311
+ compact = text.strip()
312
+ if not compact:
313
+ return True
314
+ return compact in {"...", "<content>", "[...]", "[same structure]"}
315
+
316
+
317
+ def parse_requirement_ids(requirements_content: str) -> list[str]:
318
+ requirements_body = get_heading_body(requirements_content, "Requirements") or requirements_content
319
+ ids = sorted(set(re.findall(r"\bR\d+\b", requirements_body)), key=lambda value: int(value[1:]))
320
+ return ids
321
+
322
+
323
+ def requirement_sort_key(value: str) -> tuple[int, int | str, str]:
324
+ if re.fullmatch(r"R\d+", value):
325
+ return (0, int(value[1:]), value)
326
+ if re.fullmatch(r"SRC-\d{3}", value):
327
+ return (1, int(value.split("-", 1)[1]), value)
328
+ return (2, value, value)
329
+
330
+
331
+ def normalize_source_text(value: str) -> str:
332
+ return re.sub(r"\s+", " ", value.replace("`", "").replace('"', "").strip()).strip().lower()
333
+
334
+
335
+ def source_quote_matches(source_quote: str, requirements_content: str) -> bool:
336
+ normalized_quote = normalize_source_text(source_quote)
337
+ normalized_requirements = normalize_source_text(requirements_content)
338
+ return bool(normalized_quote) and normalized_quote in normalized_requirements
339
+
340
+
341
+ def parse_source_requirement_inventory_entries(section_body: str) -> tuple[dict[str, dict[str, str]], list[str]]:
342
+ entries: dict[str, dict[str, str]] = {}
343
+ issues: list[str] = []
344
+ for raw_line in section_body.splitlines():
345
+ line = raw_line.strip()
346
+ if not line.startswith(("-", "*")):
347
+ continue
348
+ body = trim_md_value(line[1:].strip())
349
+ parts = [trim_md_value(part.strip()) for part in body.split("|") if part.strip()]
350
+ if len(parts) < 2:
351
+ continue
352
+ requirement_id = trim_md_value(parts[0])
353
+ if not REQUIREMENT_ID_RE.fullmatch(requirement_id):
354
+ continue
355
+ fields: dict[str, str] = {"Requirement ID": requirement_id}
356
+ for part in parts[1:]:
357
+ if ":" not in part:
358
+ continue
359
+ key, value = part.split(":", 1)
360
+ fields[key.strip()] = trim_md_value(value.strip())
361
+ if requirement_id in entries:
362
+ issues.append(f"Source Requirement Inventory contains duplicate entries for {requirement_id}")
363
+ continue
364
+ entries[requirement_id] = fields
365
+ return entries, issues
366
+
367
+
368
+ def parse_requirement_mapping_entries(section_body: str) -> tuple[dict[str, dict[str, str]], list[str]]:
369
+ entries: dict[str, dict[str, str]] = {}
370
+ issues: list[str] = []
371
+ for raw_line in section_body.splitlines():
372
+ line = raw_line.strip()
373
+ if not line.startswith(("-", "*")):
374
+ continue
375
+ body = trim_md_value(line[1:].strip())
376
+ parts = [trim_md_value(part.strip()) for part in body.split("|") if part.strip()]
377
+ if len(parts) < 2:
378
+ continue
379
+ requirement_id = trim_md_value(parts[0])
380
+ if not REQUIREMENT_ID_RE.fullmatch(requirement_id):
381
+ continue
382
+ fields: dict[str, str] = {"Requirement ID": requirement_id}
383
+ for part in parts[1:]:
384
+ if ":" not in part:
385
+ continue
386
+ key, value = part.split(":", 1)
387
+ fields[key.strip()] = trim_md_value(value.strip())
388
+ if requirement_id in entries:
389
+ issues.append(f"Requirement Mapping contains duplicate entries for {requirement_id}")
390
+ continue
391
+ entries[requirement_id] = fields
392
+ return entries, issues
393
+
394
+
395
+ def get_run_requirement_ids(run_dir: Path, workflow_profile: str) -> list[str]:
396
+ requirements_path = run_dir / "00-requirements.md"
397
+ explicit_ids: list[str] = []
398
+ if requirements_path.exists():
399
+ explicit_ids = parse_requirement_ids(requirements_path.read_text(encoding="utf-8"))
400
+ if workflow_profile == CURRENT_WORKFLOW_PROFILE:
401
+ phase1_path = run_dir / "01-as-is.md"
402
+ if phase1_path.exists():
403
+ inventory_body = get_heading_body(phase1_path.read_text(encoding="utf-8"), "Source Requirement Inventory")
404
+ if inventory_body:
405
+ entries, _issues = parse_source_requirement_inventory_entries(inventory_body)
406
+ if entries:
407
+ return sorted(entries.keys(), key=requirement_sort_key)
408
+ return explicit_ids
409
+
410
+
411
+ def validate_planned_surface_paths(
412
+ requirement_id: str,
413
+ field_name: str,
414
+ raw_value: str,
415
+ repo_root: Path,
416
+ ) -> list[str]:
417
+ issues: list[str] = []
418
+ extracted_paths = collect_requirement_field_paths({field_name: raw_value}, [field_name])
419
+ if not extracted_paths:
420
+ issues.append(f"Requirement {requirement_id} {field_name} must cite concrete repo paths or file names")
421
+ return issues
422
+ unresolved: list[str] = []
423
+ for path in sorted(extracted_paths):
424
+ candidate = repo_root / path
425
+ if candidate.exists():
426
+ continue
427
+ if candidate.parent.exists():
428
+ continue
429
+ unresolved.append(path)
430
+ if unresolved:
431
+ issues.append(f"Requirement {requirement_id} {field_name} contains unresolved planned path(s): {', '.join(unresolved[:5])}")
432
+ return issues
433
+
434
+
435
+ def extract_paths_from_text(text: str) -> set[str]:
436
+ paths: set[str] = set()
437
+ for candidate in re.findall(r"`([^`\n]+)`", text):
438
+ normalized = candidate.strip().replace("\\", "/").lstrip("/")
439
+ if not normalized or normalized.lower().startswith("git "):
440
+ continue
441
+ if normalized.startswith("<") and normalized.endswith(">"):
442
+ continue
443
+ if "/" in normalized or "." in Path(normalized).name:
444
+ paths.add(normalized)
445
+ return paths
446
+
447
+
448
+ def extract_paths_from_field_value(text: str) -> set[str]:
449
+ paths = extract_paths_from_text(text)
450
+ if paths:
451
+ return paths
452
+ discovered: set[str] = set()
453
+ for candidate in re.split(r"[,;\n]", trim_md_value(text)):
454
+ normalized = candidate.strip().replace("\\", "/").lstrip("/")
455
+ if not normalized or normalized.lower().startswith("git "):
456
+ continue
457
+ if normalized.startswith("<") and normalized.endswith(">"):
458
+ continue
459
+ if "/" in normalized or "." in Path(normalized).name:
460
+ discovered.add(normalized)
461
+ return discovered
462
+
463
+
464
+ def extract_paths_from_named_field(content: str, field_name: str) -> set[str]:
465
+ inline_value = get_md_field_value(content, field_name)
466
+ if inline_value is not None:
467
+ return extract_paths_from_field_value(inline_value)
468
+
469
+ pattern = re.compile(
470
+ rf"(?ms)^[ \t]*(?:[-*][ \t]+)?{re.escape(field_name)}:[ \t]*$\n(.*?)(?=^[ \t]*(?:[-*][ \t]+)?[A-Za-z][^:\n]*:[ \t]*|\Z)"
471
+ )
472
+ match = pattern.search(content)
473
+ if not match:
474
+ return set()
475
+ return {
476
+ normalize_repo_path(path)
477
+ for path in extract_paths_from_text(match.group(1))
478
+ if normalize_repo_path(path)
479
+ }
480
+
481
+
482
+ def get_named_field_text(content: str, field_name: str) -> str | None:
483
+ inline_value = get_md_field_value(content, field_name)
484
+ if inline_value is not None:
485
+ return inline_value
486
+
487
+ pattern = re.compile(
488
+ rf"(?ms)^[ \t]*(?:[-*][ \t]+)?{re.escape(field_name)}:[ \t]*$\n(.*?)(?=^[ \t]*(?:[-*][ \t]+)?[A-Za-z][^:\n]*:[ \t]*|\Z)"
489
+ )
490
+ match = pattern.search(content)
491
+ if not match:
492
+ return None
493
+ return match.group(1).strip()
494
+
495
+
496
+ def has_meaningful_value(value: str | None, *, disallowed: set[str] | None = None) -> bool:
497
+ if value is None:
498
+ return False
499
+ normalized = trim_md_value(value).strip()
500
+ if not normalized:
501
+ return False
502
+ return normalized.lower() not in (disallowed or set())
503
+
504
+
505
+ def collect_subagent_delegation_issues(audit_context: str) -> list[str]:
506
+ issues: list[str] = []
507
+ audit_execution_mode = get_md_field_value(audit_context, "Audit Execution Mode")
508
+ subagent_availability = get_md_field_value(audit_context, "Subagent Availability")
509
+ override_reason = get_md_field_value(audit_context, "Delegation Override Reason")
510
+
511
+ if subagent_availability == "unavailable" and audit_execution_mode == "subagent":
512
+ issues.append("Audit Context cannot claim Audit Execution Mode: subagent when Subagent Availability is unavailable")
513
+
514
+ if subagent_availability == "available" and audit_execution_mode == "self-audit":
515
+ if not has_meaningful_value(override_reason, disallowed={"n/a", "none"}):
516
+ issues.append(
517
+ "Audit Context is missing Delegation Override Reason even though subagents were available and self-audit was chosen"
518
+ )
519
+
520
+ return issues
521
+
522
+
523
+ def collect_paths_under_prefix(text: str, prefix: str) -> list[str]:
524
+ return sorted(path for path in extract_paths_from_text(text) if path.startswith(prefix))
525
+
526
+
527
+ def find_missing_repo_paths(repo_root: Path, paths: list[str]) -> list[str]:
528
+ return [path for path in paths if not (repo_root / path).exists()]
529
+
530
+
531
+ def normalize_repo_path(raw_path: str) -> str:
532
+ return raw_path.replace("\\", "/").strip().lstrip("/")
533
+
534
+
535
+ def is_addendum_artifact(file_name: str) -> bool:
536
+ return ".addendum-" in file_name
537
+
538
+
539
+ def is_transient_runtime_path(normalized_path: str) -> bool:
540
+ candidate = normalize_repo_path(normalized_path)
541
+ if not candidate:
542
+ return False
543
+ parts = Path(candidate).parts
544
+ if any(part in TRANSIENT_RUNTIME_DIR_MARKERS for part in parts):
545
+ return True
546
+ file_name = Path(candidate).name.lower()
547
+ if file_name in TRANSIENT_RUNTIME_FILE_NAMES:
548
+ return True
549
+ return file_name.endswith(TRANSIENT_RUNTIME_SUFFIXES)
550
+
551
+
552
+ def filter_runtime_changed_files(paths: list[str], run_id: str) -> list[str]:
553
+ filtered: list[str] = []
554
+ for raw_path in paths:
555
+ normalized = normalize_repo_path(raw_path)
556
+ if not normalized:
557
+ continue
558
+ if normalized.startswith(f".recursive/run/{run_id}/"):
559
+ continue
560
+ if is_transient_runtime_path(normalized):
561
+ continue
562
+ filtered.append(normalized)
563
+ return sorted(set(filtered))
564
+
565
+
566
+ def content_sha256(content: str) -> str:
567
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n")
568
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
569
+
570
+
571
+ def run_git(repo_root: Path, *args: str) -> tuple[str | None, str | None]:
572
+ try:
573
+ result = subprocess.run(
574
+ ["git", "-C", str(repo_root), *args],
575
+ check=False,
576
+ capture_output=True,
577
+ text=True,
578
+ )
579
+ except OSError as exc:
580
+ return None, f"Unable to execute git: {exc}"
581
+ if result.returncode != 0:
582
+ message = result.stderr.strip() or result.stdout.strip() or f"git {' '.join(args)} failed"
583
+ return None, message
584
+ return result.stdout.strip(), None
585
+
586
+
587
+ def normalize_baseline_type(value: str | None) -> str | None:
588
+ if value is None:
589
+ return None
590
+ compact = trim_md_value(value).strip().lower().replace("-", " ")
591
+ compact = re.sub(r"\s+", " ", compact)
592
+ if compact in DIFF_BASIS_ALLOWED_TYPES:
593
+ return compact
594
+ aliases = {
595
+ "commit": "local commit",
596
+ "branch": "local branch",
597
+ "remote": "remote ref",
598
+ "remote branch": "remote ref",
599
+ "merge base": "merge-base derived",
600
+ }
601
+ return aliases.get(compact)
602
+
603
+
604
+ def normalize_comparison_reference(value: str | None) -> str | None:
605
+ if value is None:
606
+ return None
607
+ compact = trim_md_value(value).strip()
608
+ if not compact:
609
+ return None
610
+ if compact.lower() in WORKING_TREE_COMPARISON_REFS:
611
+ return "working-tree"
612
+ return compact
613
+
614
+
615
+ def parse_diff_basis_source(content: str) -> str:
616
+ diff_body = get_heading_body(content, "Diff Basis For Later Audits")
617
+ if diff_body:
618
+ return diff_body
619
+ diff_body = get_heading_body(content, "Diff Basis")
620
+ if diff_body:
621
+ return diff_body
622
+ return content
623
+
624
+
625
+ def get_run_diff_basis(run_dir: Path) -> dict[str, str | None]:
626
+ worktree_path = run_dir / "00-worktree.md"
627
+ if not worktree_path.exists():
628
+ return {
629
+ "baseline_type": None,
630
+ "baseline_reference": None,
631
+ "comparison_reference": None,
632
+ "normalized_baseline": None,
633
+ "normalized_comparison": None,
634
+ "normalized_diff_command": None,
635
+ "base_branch": None,
636
+ "worktree_branch": None,
637
+ "notes": None,
638
+ }
639
+
640
+ content = worktree_path.read_text(encoding="utf-8")
641
+ source = parse_diff_basis_source(content)
642
+ return {
643
+ "baseline_type": get_md_field_value(source, "Baseline type"),
644
+ "baseline_reference": get_md_field_value(source, "Baseline reference"),
645
+ "comparison_reference": get_md_field_value(source, "Comparison reference"),
646
+ "normalized_baseline": (
647
+ get_md_field_value(source, "Normalized baseline")
648
+ or get_md_field_value(source, "Normalized baseline commit")
649
+ or get_md_field_value(source, "Base commit")
650
+ ),
651
+ "normalized_comparison": (
652
+ get_md_field_value(source, "Normalized comparison")
653
+ or get_md_field_value(source, "Normalized comparison reference")
654
+ or get_md_field_value(source, "Worktree branch")
655
+ ),
656
+ "normalized_diff_command": (
657
+ get_md_field_value(source, "Normalized diff command")
658
+ or get_md_field_value(source, "Diff command convention")
659
+ ),
660
+ "base_branch": get_md_field_value(source, "Base branch"),
661
+ "worktree_branch": get_md_field_value(source, "Worktree branch"),
662
+ "notes": get_md_field_value(source, "Diff basis notes") or get_md_field_value(source, "Notes"),
663
+ }
664
+
665
+
666
+ def normalize_diff_basis(repo_root: Path, diff_basis: dict[str, str | None]) -> tuple[dict[str, str] | None, str | None]:
667
+ baseline_type = normalize_baseline_type(diff_basis.get("baseline_type"))
668
+ baseline_reference = trim_md_value(diff_basis.get("baseline_reference") or "")
669
+ comparison_reference = normalize_comparison_reference(diff_basis.get("comparison_reference"))
670
+ normalized_baseline = trim_md_value(diff_basis.get("normalized_baseline") or "")
671
+ normalized_comparison = normalize_comparison_reference(diff_basis.get("normalized_comparison"))
672
+ normalized_diff_command = trim_md_value(diff_basis.get("normalized_diff_command") or "")
673
+
674
+ missing_fields = []
675
+ if not baseline_type:
676
+ missing_fields.append("Baseline type")
677
+ if not baseline_reference:
678
+ missing_fields.append("Baseline reference")
679
+ if not comparison_reference:
680
+ missing_fields.append("Comparison reference")
681
+ if not normalized_baseline:
682
+ missing_fields.append("Normalized baseline")
683
+ if not normalized_comparison:
684
+ missing_fields.append("Normalized comparison")
685
+ if not normalized_diff_command:
686
+ missing_fields.append("Normalized diff command")
687
+ if missing_fields:
688
+ return None, f"Diff basis is missing required field(s): {', '.join(missing_fields)}"
689
+
690
+ comparison_git_ref = "HEAD" if normalized_comparison == "working-tree" else normalized_comparison
691
+ if baseline_type == "merge-base derived":
692
+ computed_baseline, error = run_git(repo_root, "merge-base", comparison_git_ref, baseline_reference)
693
+ if error:
694
+ return None, f"Unable to compute merge-base for diff basis: {error}"
695
+ else:
696
+ computed_baseline, error = run_git(repo_root, "rev-parse", "--verify", f"{baseline_reference}^{{commit}}")
697
+ if error:
698
+ return None, f"Unable to resolve baseline reference '{baseline_reference}': {error}"
699
+
700
+ if normalized_baseline != computed_baseline:
701
+ return None, (
702
+ "Recorded Normalized baseline does not match the executable diff basis "
703
+ f"({normalized_baseline} != {computed_baseline})"
704
+ )
705
+
706
+ if normalized_comparison == "working-tree":
707
+ expected_command = f"git diff --name-only {computed_baseline}"
708
+ git_args = ["diff", "--name-only", computed_baseline]
709
+ else:
710
+ computed_comparison, error = run_git(repo_root, "rev-parse", "--verify", f"{normalized_comparison}^{{commit}}")
711
+ if error:
712
+ return None, f"Unable to resolve comparison reference '{normalized_comparison}': {error}"
713
+ expected_command = f"git diff --name-only {computed_baseline}..{computed_comparison}"
714
+ git_args = ["diff", "--name-only", f"{computed_baseline}..{computed_comparison}"]
715
+ if normalized_comparison != computed_comparison:
716
+ return None, (
717
+ "Recorded Normalized comparison does not match the executable diff basis "
718
+ f"({normalized_comparison} != {computed_comparison})"
719
+ )
720
+
721
+ if normalized_diff_command != expected_command:
722
+ return None, (
723
+ "Recorded Normalized diff command does not match the executable diff basis "
724
+ f"({normalized_diff_command} != {expected_command})"
725
+ )
726
+
727
+ return {
728
+ "baseline_type": baseline_type,
729
+ "baseline_reference": baseline_reference,
730
+ "comparison_reference": comparison_reference,
731
+ "normalized_baseline": computed_baseline,
732
+ "normalized_comparison": normalized_comparison,
733
+ "normalized_diff_command": expected_command,
734
+ "comparison_git_ref": comparison_git_ref,
735
+ "git_args": git_args,
736
+ }, None
737
+
738
+
739
+ def get_git_changed_files(repo_root: Path, diff_basis: dict[str, str | None]) -> tuple[list[str] | None, str | None]:
740
+ normalized_basis, basis_error = normalize_diff_basis(repo_root, diff_basis)
741
+ if basis_error:
742
+ return None, basis_error
743
+
744
+ try:
745
+ diff_result = subprocess.run(
746
+ ["git", "-C", str(repo_root), *normalized_basis["git_args"]],
747
+ check=False,
748
+ capture_output=True,
749
+ text=True,
750
+ )
751
+ untracked_result = subprocess.run(
752
+ ["git", "-C", str(repo_root), "ls-files", "--others", "--exclude-standard"],
753
+ check=False,
754
+ capture_output=True,
755
+ text=True,
756
+ )
757
+ except OSError as exc:
758
+ return None, f"Unable to execute git: {exc}"
759
+
760
+ if diff_result.returncode != 0:
761
+ message = diff_result.stderr.strip() or diff_result.stdout.strip() or "git diff failed"
762
+ return None, message
763
+ if untracked_result.returncode != 0:
764
+ message = untracked_result.stderr.strip() or untracked_result.stdout.strip() or "git ls-files failed"
765
+ return None, message
766
+
767
+ tracked_changed = [path.strip() for path in diff_result.stdout.splitlines() if path.strip()]
768
+ untracked_changed = [
769
+ path.strip()
770
+ for path in untracked_result.stdout.splitlines()
771
+ if path.strip() and not is_transient_runtime_path(path.strip())
772
+ ]
773
+ changed = tracked_changed + untracked_changed
774
+ return sorted(set(path.strip() for path in changed if path.strip())), None
775
+
776
+
777
+ def get_phase_owned_actual_changed_files(file_name: str, actual_changed_files: list[str] | None) -> list[str] | None:
778
+ if actual_changed_files is None:
779
+ return None
780
+ if file_name == "02-to-be-plan.md":
781
+ return None
782
+
783
+ owned_paths: list[str] = []
784
+ for path in actual_changed_files:
785
+ if path == ".recursive/DECISIONS.md":
786
+ if file_name in DECISIONS_DIFF_PHASE_FILES:
787
+ owned_paths.append(path)
788
+ continue
789
+ if path == ".recursive/STATE.md":
790
+ if file_name in STATE_DIFF_PHASE_FILES:
791
+ owned_paths.append(path)
792
+ continue
793
+ if path.startswith(".recursive/memory/"):
794
+ if file_name in MEMORY_DIFF_PHASE_FILES:
795
+ owned_paths.append(path)
796
+ continue
797
+ owned_paths.append(path)
798
+
799
+ if file_name in PRODUCT_DIFF_PHASE_FILES:
800
+ return owned_paths
801
+ if file_name in DECISIONS_DIFF_PHASE_FILES | STATE_DIFF_PHASE_FILES | MEMORY_DIFF_PHASE_FILES:
802
+ return owned_paths
803
+ return None
804
+
805
+
806
+ def get_related_addenda_paths(run_dir: Path, artifact_name: str) -> list[Path]:
807
+ addenda_dir = run_dir / "addenda"
808
+ if not addenda_dir.exists():
809
+ return []
810
+
811
+ base_name = artifact_name[:-3] if artifact_name.endswith(".md") else artifact_name
812
+ matches: list[Path] = []
813
+ for pattern in (f"{base_name}.addendum-*.md", f"{base_name}.upstream-gap.*.addendum-*.md"):
814
+ matches.extend(sorted(addenda_dir.glob(pattern)))
815
+ return matches
816
+
817
+
818
+ def get_stage_local_addenda_paths(run_dir: Path, artifact_name: str) -> list[Path]:
819
+ addenda_dir = run_dir / "addenda"
820
+ if not addenda_dir.exists():
821
+ return []
822
+ base_name = artifact_name[:-3] if artifact_name.endswith(".md") else artifact_name
823
+ return sorted(addenda_dir.glob(f"{base_name}.addendum-*.md"))
824
+
825
+
826
+ def get_current_phase_upstream_gap_addenda_paths(run_dir: Path, artifact_name: str) -> list[Path]:
827
+ addenda_dir = run_dir / "addenda"
828
+ if not addenda_dir.exists():
829
+ return []
830
+ base_name = artifact_name[:-3] if artifact_name.endswith(".md") else artifact_name
831
+ return sorted(addenda_dir.glob(f"{base_name}.upstream-gap.*.addendum-*.md"))
832
+
833
+
834
+ def get_header_field_block(content: str, field_name: str, stop_fields: list[str]) -> str:
835
+ lines = content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
836
+ collecting = False
837
+ captured: list[str] = []
838
+ stop_patterns = [re.compile(rf"^\s*{re.escape(stop_field)}:") for stop_field in stop_fields]
839
+ field_pattern = re.compile(rf"^\s*{re.escape(field_name)}:\s*$")
840
+
841
+ for line in lines:
842
+ if not collecting:
843
+ if field_pattern.match(line):
844
+ collecting = True
845
+ continue
846
+
847
+ if any(pattern.match(line) for pattern in stop_patterns):
848
+ break
849
+ captured.append(line)
850
+
851
+ return "\n".join(captured).strip()
852
+
853
+
854
+ def get_header_input_paths(content: str) -> set[str]:
855
+ return extract_paths_from_text(get_header_field_block(content, "Inputs", ["Outputs", "Scope note"]))
856
+
857
+
858
+ def get_phase_expected_input_artifact_names(file_name: str, run_dir: Path) -> list[str]:
859
+ present = {artifact for artifact in RUN_ARTIFACT_SEQUENCE if (run_dir / artifact).exists()}
860
+ if file_name == "00-worktree.md":
861
+ candidates = ["00-requirements.md"]
862
+ elif file_name == "01-as-is.md":
863
+ candidates = ["00-requirements.md"]
864
+ elif file_name == "01.5-root-cause.md":
865
+ candidates = ["01-as-is.md"]
866
+ elif file_name == "02-to-be-plan.md":
867
+ candidates = ["00-requirements.md", "01-as-is.md"]
868
+ if "01.5-root-cause.md" in present:
869
+ candidates.append("01.5-root-cause.md")
870
+ elif file_name == "03-implementation-summary.md":
871
+ candidates = ["02-to-be-plan.md"]
872
+ elif file_name == "03.5-code-review.md":
873
+ candidates = ["02-to-be-plan.md", "03-implementation-summary.md"]
874
+ elif file_name == "04-test-summary.md":
875
+ candidates = ["02-to-be-plan.md", "03-implementation-summary.md"]
876
+ if "03.5-code-review.md" in present:
877
+ candidates.append("03.5-code-review.md")
878
+ elif file_name == "05-manual-qa.md":
879
+ candidates = ["02-to-be-plan.md"]
880
+ elif file_name == "06-decisions-update.md":
881
+ 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"}]
882
+ elif file_name == "07-state-update.md":
883
+ candidates = ["06-decisions-update.md"]
884
+ elif file_name == "08-memory-impact.md":
885
+ candidates = [artifact for artifact in RUN_ARTIFACT_SEQUENCE if artifact in present and artifact != "08-memory-impact.md"]
886
+ else:
887
+ candidates = []
888
+ return [artifact for artifact in candidates if artifact in present]
889
+
890
+
891
+ def get_expected_effective_input_addenda_paths(run_dir: Path, file_name: str) -> list[str]:
892
+ if is_addendum_artifact(file_name):
893
+ return []
894
+
895
+ expected_paths: list[str] = []
896
+ run_prefix = f".recursive/run/{run_dir.name}/"
897
+ for artifact_name in get_phase_expected_input_artifact_names(file_name, run_dir):
898
+ for addendum_path in get_stage_local_addenda_paths(run_dir, artifact_name):
899
+ expected_paths.append(f"{run_prefix}addenda/{addendum_path.name}")
900
+ for addendum_path in get_current_phase_upstream_gap_addenda_paths(run_dir, file_name):
901
+ expected_paths.append(f"{run_prefix}addenda/{addendum_path.name}")
902
+ return sorted(set(expected_paths))
903
+
904
+
905
+ def lint_effective_input_addenda(file_path: Path, content: str, workflow_profile: str, run_dir: Path) -> list[str]:
906
+ if workflow_profile not in (STRICT_WORKFLOW_PROFILES | {COMPAT_WORKFLOW_PROFILE}):
907
+ return []
908
+ if is_addendum_artifact(file_path.name):
909
+ return []
910
+
911
+ expected_addenda = get_expected_effective_input_addenda_paths(run_dir, file_path.name)
912
+ if not expected_addenda:
913
+ return []
914
+
915
+ issues: list[str] = []
916
+ header_inputs = {normalize_repo_path(path) for path in get_header_input_paths(content)}
917
+ missing_inputs = [path for path in expected_addenda if path not in header_inputs]
918
+ if missing_inputs:
919
+ issues.append(f"Inputs is missing relevant addenda: {', '.join(missing_inputs[:5])}")
920
+
921
+ if workflow_profile in STRICT_WORKFLOW_PROFILES and file_path.name in AUDITED_PHASE_FILES:
922
+ reread_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(content, 'Effective Inputs Re-read'))}
923
+ missing_reread = [path for path in expected_addenda if path not in reread_paths]
924
+ if missing_reread:
925
+ issues.append(f"Effective Inputs Re-read is missing relevant addenda: {', '.join(missing_reread[:5])}")
926
+
927
+ reconciliation_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(content, 'Earlier Phase Reconciliation'))}
928
+ missing_reconciliation = [path for path in expected_addenda if path not in reconciliation_paths]
929
+ if missing_reconciliation:
930
+ issues.append(f"Earlier Phase Reconciliation is missing relevant addenda: {', '.join(missing_reconciliation[:5])}")
931
+
932
+ return issues
933
+
934
+
935
+ def parse_requirement_completion_entries(section_body: str) -> tuple[dict[str, dict[str, str]], list[str]]:
936
+ entries: dict[str, dict[str, str]] = {}
937
+ issues: list[str] = []
938
+ for raw_line in section_body.splitlines():
939
+ line = raw_line.strip()
940
+ if not line.startswith(("-", "*")):
941
+ continue
942
+ body = trim_md_value(line[1:].strip())
943
+ parts = [trim_md_value(part.strip()) for part in body.split("|") if part.strip()]
944
+ if len(parts) < 2:
945
+ continue
946
+ requirement_id = trim_md_value(parts[0])
947
+ if not REQUIREMENT_ID_RE.fullmatch(requirement_id):
948
+ continue
949
+ fields: dict[str, str] = {"Requirement ID": requirement_id}
950
+ for part in parts[1:]:
951
+ if ":" not in part:
952
+ continue
953
+ key, value = part.split(":", 1)
954
+ fields[key.strip()] = trim_md_value(value.strip())
955
+ if requirement_id in entries:
956
+ issues.append(f"Requirement Completion Status contains duplicate entries for {requirement_id}")
957
+ continue
958
+ entries[requirement_id] = fields
959
+ return entries, issues
960
+
961
+
962
+ def is_meaningful_requirement_field(value: str | None) -> bool:
963
+ if value is None:
964
+ return False
965
+ normalized = trim_md_value(value).strip()
966
+ return bool(normalized) and normalized.lower() not in {"...", "none", "n/a", "tbd", "todo"}
967
+
968
+
969
+ def collect_requirement_field_paths(fields: dict[str, str], field_names: list[str]) -> set[str]:
970
+ paths: set[str] = set()
971
+ for field_name in field_names:
972
+ raw_value = fields.get(field_name, "")
973
+ paths.update(normalize_repo_path(path) for path in extract_paths_from_field_value(raw_value))
974
+ return {path for path in paths if path}
975
+
976
+
977
+ def collect_meaningful_requirement_fields(fields: dict[str, str]) -> dict[str, str]:
978
+ meaningful: dict[str, str] = {}
979
+ for key, value in fields.items():
980
+ if key in {"Requirement ID", "Status"}:
981
+ continue
982
+ if is_meaningful_requirement_field(value):
983
+ meaningful[key] = value
984
+ return meaningful
985
+
986
+
987
+ def normalize_skill_usage_relevance(value: str | None) -> str:
988
+ normalized = trim_md_value(value or "").strip().lower()
989
+ if normalized == "yes":
990
+ return "relevant"
991
+ if normalized == "no":
992
+ return "not-relevant"
993
+ return normalized
994
+
995
+
996
+ def lint_requirement_disposition_fields(
997
+ requirement_id: str,
998
+ status: str,
999
+ fields: dict[str, str],
1000
+ file_name: str,
1001
+ run_dir: Path,
1002
+ repo_root: Path,
1003
+ actual_changed_files: list[str] | None,
1004
+ ) -> list[str]:
1005
+ issues: list[str] = []
1006
+ actual_changed_scope = set(actual_changed_files or [])
1007
+ meaningful_fields = collect_meaningful_requirement_fields(fields)
1008
+ allowed_fields_by_status = {
1009
+ "implemented": {"Changed Files", "Implementation Evidence", "Audit Note"},
1010
+ "verified": {"Changed Files", "Implementation Evidence", "Verification Evidence", "Audit Note"},
1011
+ "deferred": {"Rationale", "Deferred By", "Addendum", "Audit Note"},
1012
+ "out-of-scope": {"Rationale", "Scope Decision", "Addendum", "Audit Note"},
1013
+ "blocked": {"Rationale", "Blocking Evidence", "Audit Note"},
1014
+ "superseded by approved addendum": {"Addendum", "Audit Note"},
1015
+ }
1016
+ unexpected_fields = sorted(set(meaningful_fields) - allowed_fields_by_status[status])
1017
+ if unexpected_fields:
1018
+ issues.append(
1019
+ f"Requirement {requirement_id} with Status {status} contains contradictory field(s): "
1020
+ + ", ".join(unexpected_fields)
1021
+ )
1022
+
1023
+ if status == "implemented":
1024
+ changed_files = fields.get("Changed Files", "")
1025
+ changed_paths = collect_requirement_field_paths(fields, ["Changed Files"])
1026
+ implementation_evidence = fields.get("Implementation Evidence", "")
1027
+ implementation_paths = collect_requirement_field_paths(fields, ["Implementation Evidence"])
1028
+ if not is_meaningful_requirement_field(changed_files):
1029
+ issues.append(f"Requirement {requirement_id} with Status implemented must cite Changed Files")
1030
+ elif not changed_paths:
1031
+ issues.append(f"Requirement {requirement_id} with Status implemented must cite repo paths in Changed Files")
1032
+ else:
1033
+ missing_changed_paths = find_missing_repo_paths(repo_root, sorted(changed_paths))
1034
+ if missing_changed_paths:
1035
+ issues.append(f"Requirement {requirement_id} Changed Files path(s) do not exist: {', '.join(missing_changed_paths[:5])}")
1036
+ if actual_changed_scope:
1037
+ unexplained_paths = sorted(path for path in changed_paths if path not in actual_changed_scope)
1038
+ if unexplained_paths:
1039
+ issues.append(
1040
+ f"Requirement {requirement_id} Changed Files are outside the current diff scope: {', '.join(unexplained_paths[:5])}"
1041
+ )
1042
+ if not is_meaningful_requirement_field(implementation_evidence):
1043
+ issues.append(f"Requirement {requirement_id} with Status implemented must cite Implementation Evidence")
1044
+ elif not implementation_paths:
1045
+ issues.append(f"Requirement {requirement_id} with Status implemented must cite file or artifact paths in Implementation Evidence")
1046
+ else:
1047
+ missing = find_missing_repo_paths(repo_root, sorted(implementation_paths))
1048
+ if missing:
1049
+ issues.append(f"Requirement {requirement_id} Implementation Evidence path(s) do not exist: {', '.join(missing[:5])}")
1050
+ if changed_paths and not implementation_paths.intersection(changed_paths) and not any(
1051
+ path.startswith(f".recursive/run/{run_dir.name}/") for path in implementation_paths
1052
+ ):
1053
+ issues.append(
1054
+ f"Requirement {requirement_id} Implementation Evidence must reference the changed files or a current-run artifact that proves the implementation work"
1055
+ )
1056
+
1057
+ elif status == "verified":
1058
+ changed_files = fields.get("Changed Files", "")
1059
+ changed_paths = collect_requirement_field_paths(fields, ["Changed Files"])
1060
+ implementation_evidence = fields.get("Implementation Evidence", "")
1061
+ verification_evidence = fields.get("Verification Evidence", "")
1062
+ implementation_paths = collect_requirement_field_paths(fields, ["Implementation Evidence"])
1063
+ verification_paths = collect_requirement_field_paths(fields, ["Verification Evidence"])
1064
+ if not is_meaningful_requirement_field(changed_files):
1065
+ issues.append(f"Requirement {requirement_id} with Status verified must cite Changed Files")
1066
+ elif not changed_paths:
1067
+ issues.append(f"Requirement {requirement_id} with Status verified must cite repo paths in Changed Files")
1068
+ else:
1069
+ missing_changed_paths = find_missing_repo_paths(repo_root, sorted(changed_paths))
1070
+ if missing_changed_paths:
1071
+ issues.append(f"Requirement {requirement_id} Changed Files path(s) do not exist: {', '.join(missing_changed_paths[:5])}")
1072
+ if actual_changed_scope:
1073
+ unexplained_paths = sorted(path for path in changed_paths if path not in actual_changed_scope)
1074
+ if unexplained_paths:
1075
+ issues.append(
1076
+ f"Requirement {requirement_id} Changed Files are outside the current diff scope: {', '.join(unexplained_paths[:5])}"
1077
+ )
1078
+ if not is_meaningful_requirement_field(implementation_evidence):
1079
+ issues.append(f"Requirement {requirement_id} with Status verified must cite Implementation Evidence")
1080
+ elif not implementation_paths:
1081
+ issues.append(f"Requirement {requirement_id} with Status verified must cite file or artifact paths in Implementation Evidence")
1082
+ else:
1083
+ missing = find_missing_repo_paths(repo_root, sorted(implementation_paths))
1084
+ if missing:
1085
+ issues.append(f"Requirement {requirement_id} Implementation Evidence path(s) do not exist: {', '.join(missing[:5])}")
1086
+ if changed_paths and not implementation_paths.intersection(changed_paths) and not any(
1087
+ path.startswith(f".recursive/run/{run_dir.name}/") for path in implementation_paths
1088
+ ):
1089
+ issues.append(
1090
+ f"Requirement {requirement_id} Implementation Evidence must reference the changed files or a current-run artifact that proves the implementation work"
1091
+ )
1092
+ if not is_meaningful_requirement_field(verification_evidence):
1093
+ issues.append(f"Requirement {requirement_id} with Status verified must cite Verification Evidence")
1094
+ elif not verification_paths:
1095
+ issues.append(f"Requirement {requirement_id} with Status verified must cite test, review, QA, or artifact paths in Verification Evidence")
1096
+ else:
1097
+ missing = find_missing_repo_paths(repo_root, sorted(verification_paths))
1098
+ if missing:
1099
+ issues.append(f"Requirement {requirement_id} Verification Evidence path(s) do not exist: {', '.join(missing[:5])}")
1100
+ if changed_paths and verification_paths.issubset(changed_paths):
1101
+ issues.append(
1102
+ f"Requirement {requirement_id} Verification Evidence must cite verification artifacts, review receipts, or evidence beyond the changed files themselves"
1103
+ )
1104
+ if implementation_paths and verification_paths.issubset(implementation_paths):
1105
+ issues.append(
1106
+ f"Requirement {requirement_id} Verification Evidence cannot be satisfied by restating only the implementation evidence"
1107
+ )
1108
+
1109
+ elif status == "deferred":
1110
+ rationale = fields.get("Rationale", "")
1111
+ deferred_by = fields.get("Deferred By", "") or fields.get("Addendum", "")
1112
+ deferred_paths = collect_requirement_field_paths(fields, ["Deferred By", "Addendum"])
1113
+ if not is_meaningful_requirement_field(rationale):
1114
+ issues.append(f"Requirement {requirement_id} with Status deferred is missing Rationale")
1115
+ if not is_meaningful_requirement_field(deferred_by):
1116
+ issues.append(f"Requirement {requirement_id} with Status deferred must cite Deferred By or Addendum")
1117
+ elif not deferred_paths:
1118
+ issues.append(f"Requirement {requirement_id} with Status deferred must cite an approved deferral path")
1119
+ else:
1120
+ missing = find_missing_repo_paths(repo_root, sorted(deferred_paths))
1121
+ if missing:
1122
+ issues.append(f"Requirement {requirement_id} deferral reference path(s) do not exist: {', '.join(missing[:5])}")
1123
+
1124
+ elif status == "out-of-scope":
1125
+ rationale = fields.get("Rationale", "")
1126
+ scope_decision = fields.get("Scope Decision", "") or fields.get("Addendum", "")
1127
+ scope_paths = collect_requirement_field_paths(fields, ["Scope Decision", "Addendum"])
1128
+ if not is_meaningful_requirement_field(rationale):
1129
+ issues.append(f"Requirement {requirement_id} with Status out-of-scope is missing Rationale")
1130
+ if not is_meaningful_requirement_field(scope_decision):
1131
+ issues.append(f"Requirement {requirement_id} with Status out-of-scope must cite Scope Decision or Addendum")
1132
+ elif not scope_paths:
1133
+ issues.append(f"Requirement {requirement_id} with Status out-of-scope must cite an approved scope decision path")
1134
+ else:
1135
+ missing = find_missing_repo_paths(repo_root, sorted(scope_paths))
1136
+ if missing:
1137
+ issues.append(f"Requirement {requirement_id} scope decision path(s) do not exist: {', '.join(missing[:5])}")
1138
+
1139
+ elif status == "blocked":
1140
+ rationale = fields.get("Rationale", "")
1141
+ blocking_evidence = fields.get("Blocking Evidence", "")
1142
+ blocking_paths = collect_requirement_field_paths(fields, ["Blocking Evidence"])
1143
+ if not is_meaningful_requirement_field(rationale):
1144
+ issues.append(f"Requirement {requirement_id} with Status blocked is missing Rationale")
1145
+ if not is_meaningful_requirement_field(blocking_evidence):
1146
+ issues.append(f"Requirement {requirement_id} with Status blocked must cite Blocking Evidence")
1147
+ elif not blocking_paths:
1148
+ issues.append(f"Requirement {requirement_id} with Status blocked must cite file, artifact, or evidence paths in Blocking Evidence")
1149
+ else:
1150
+ missing = find_missing_repo_paths(repo_root, sorted(blocking_paths))
1151
+ if missing:
1152
+ issues.append(f"Requirement {requirement_id} Blocking Evidence path(s) do not exist: {', '.join(missing[:5])}")
1153
+
1154
+ elif status == "superseded by approved addendum":
1155
+ addendum_path = normalize_repo_path(fields.get("Addendum", ""))
1156
+ if not addendum_path:
1157
+ issues.append(f"Requirement {requirement_id} superseded by approved addendum must cite Addendum")
1158
+ elif not addendum_path.startswith(f".recursive/run/{run_dir.name}/addenda/"):
1159
+ issues.append(f"Requirement {requirement_id} addendum reference must live under the current run addenda/")
1160
+ elif not (repo_root / addendum_path).exists():
1161
+ issues.append(f"Requirement {requirement_id} addendum reference does not exist: {addendum_path}")
1162
+
1163
+ if file_name in FINAL_REQUIREMENT_DISPOSITION_FILES:
1164
+ if status == "implemented":
1165
+ issues.append(f"Requirement {requirement_id} cannot remain implemented in {file_name}; final closeout phases require verified or explicitly approved non-completion states")
1166
+ if status == "blocked":
1167
+ issues.append(f"Requirement {requirement_id} cannot remain blocked in {file_name} while the phase is approaching closeout")
1168
+
1169
+ return issues
1170
+
1171
+
1172
+ def lint_source_requirement_inventory(file_path: Path, content: str, workflow_profile: str, run_dir: Path) -> list[str]:
1173
+ if workflow_profile != CURRENT_WORKFLOW_PROFILE or file_path.name != "01-as-is.md":
1174
+ return []
1175
+
1176
+ issues: list[str] = []
1177
+ requirements_path = run_dir / "00-requirements.md"
1178
+ requirements_content = requirements_path.read_text(encoding="utf-8") if requirements_path.exists() else ""
1179
+ body = get_heading_body(content, "Source Requirement Inventory")
1180
+ if not body:
1181
+ return ["Missing or empty section: ## Source Requirement Inventory"]
1182
+
1183
+ entries, entry_issues = parse_source_requirement_inventory_entries(body)
1184
+ issues.extend(entry_issues)
1185
+ if not entries:
1186
+ issues.append("Source Requirement Inventory must contain at least one requirement inventory entry")
1187
+ return sorted(set(issues))
1188
+
1189
+ explicit_ids = parse_requirement_ids(requirements_content)
1190
+ missing_explicit = [requirement_id for requirement_id in explicit_ids if requirement_id not in entries]
1191
+ if missing_explicit:
1192
+ issues.append(f"Source Requirement Inventory is missing explicit requirement IDs from 00-requirements.md: {', '.join(missing_explicit)}")
1193
+
1194
+ for requirement_id, fields in entries.items():
1195
+ disposition = trim_md_value(fields.get("Disposition", "")).lower()
1196
+ source_quote = fields.get("Source Quote", "")
1197
+ summary = fields.get("Summary", "")
1198
+ if disposition not in INVENTORY_DISPOSITIONS:
1199
+ issues.append(f"Source Requirement Inventory for {requirement_id} has invalid Disposition '{fields.get('Disposition', '')}'")
1200
+ if not is_meaningful_requirement_field(source_quote):
1201
+ issues.append(f"Source Requirement Inventory for {requirement_id} is missing Source Quote")
1202
+ elif not source_quote_matches(source_quote, requirements_content):
1203
+ issues.append(f"Source Requirement Inventory for {requirement_id} cites a Source Quote that does not appear in 00-requirements.md")
1204
+ if not is_meaningful_requirement_field(summary):
1205
+ issues.append(f"Source Requirement Inventory for {requirement_id} is missing Summary")
1206
+
1207
+ return sorted(set(issues))
1208
+
1209
+
1210
+ def lint_requirement_mapping(content: str, workflow_profile: str, run_dir: Path, repo_root: Path) -> list[str]:
1211
+ if workflow_profile != CURRENT_WORKFLOW_PROFILE:
1212
+ return []
1213
+
1214
+ issues: list[str] = []
1215
+ phase1_path = run_dir / "01-as-is.md"
1216
+ requirements_path = run_dir / "00-requirements.md"
1217
+ if not phase1_path.exists() or not requirements_path.exists():
1218
+ return ["Requirement Mapping requires existing 00-requirements.md and 01-as-is.md inputs"]
1219
+
1220
+ inventory_body = get_heading_body(phase1_path.read_text(encoding="utf-8"), "Source Requirement Inventory")
1221
+ if not inventory_body:
1222
+ return ["Requirement Mapping requires 01-as-is.md to contain ## Source Requirement Inventory"]
1223
+ inventory_entries, inventory_issues = parse_source_requirement_inventory_entries(inventory_body)
1224
+ issues.extend(inventory_issues)
1225
+
1226
+ body = get_heading_body(content, "Requirement Mapping")
1227
+ if not body:
1228
+ issues.append("Missing or empty section: ## Requirement Mapping")
1229
+ return sorted(set(issues))
1230
+
1231
+ mapping_entries, mapping_issues = parse_requirement_mapping_entries(body)
1232
+ issues.extend(mapping_issues)
1233
+ missing_entries = [requirement_id for requirement_id in inventory_entries if requirement_id not in mapping_entries]
1234
+ if missing_entries:
1235
+ issues.append(f"Requirement Mapping is missing source inventory items: {', '.join(missing_entries)}")
1236
+
1237
+ requirements_content = requirements_path.read_text(encoding="utf-8")
1238
+ for requirement_id, fields in mapping_entries.items():
1239
+ coverage = trim_md_value(fields.get("Coverage", "")).lower()
1240
+ source_quote = fields.get("Source Quote", "")
1241
+ implementation_surface = fields.get("Implementation Surface", "")
1242
+ verification_surface = fields.get("Verification Surface", "")
1243
+ qa_surface = fields.get("QA Surface", "")
1244
+ rationale = fields.get("Rationale", "") or fields.get("Merge Rationale", "")
1245
+
1246
+ if coverage not in {"direct", "merged", "indirect", "deferred", "out-of-scope", "blocked"}:
1247
+ issues.append(f"Requirement Mapping for {requirement_id} has invalid Coverage '{fields.get('Coverage', '')}'")
1248
+ continue
1249
+ if not is_meaningful_requirement_field(source_quote):
1250
+ issues.append(f"Requirement Mapping for {requirement_id} is missing Source Quote")
1251
+ elif requirement_id in inventory_entries:
1252
+ inventory_quote = inventory_entries[requirement_id].get("Source Quote", "")
1253
+ if normalize_source_text(source_quote) != normalize_source_text(inventory_quote):
1254
+ issues.append(f"Requirement Mapping for {requirement_id} must preserve the Source Quote recorded in Source Requirement Inventory")
1255
+ elif not source_quote_matches(source_quote, requirements_content):
1256
+ issues.append(f"Requirement Mapping for {requirement_id} cites a Source Quote that does not appear in 00-requirements.md")
1257
+
1258
+ if coverage in {"direct", "merged", "indirect"}:
1259
+ if not is_meaningful_requirement_field(implementation_surface):
1260
+ issues.append(f"Requirement Mapping for {requirement_id} is missing Implementation Surface")
1261
+ else:
1262
+ issues.extend(validate_planned_surface_paths(requirement_id, "Implementation Surface", implementation_surface, repo_root))
1263
+ if not is_meaningful_requirement_field(verification_surface):
1264
+ issues.append(f"Requirement Mapping for {requirement_id} is missing Verification Surface")
1265
+ if not is_meaningful_requirement_field(qa_surface):
1266
+ issues.append(f"Requirement Mapping for {requirement_id} is missing QA Surface")
1267
+ if coverage == "merged" and not is_meaningful_requirement_field(rationale):
1268
+ issues.append(f"Requirement Mapping for {requirement_id} with Coverage merged must cite Merge Rationale or Rationale")
1269
+ if coverage == "indirect" and not is_meaningful_requirement_field(rationale):
1270
+ issues.append(f"Requirement Mapping for {requirement_id} with Coverage indirect must cite Rationale")
1271
+ if coverage in {"deferred", "out-of-scope", "blocked"} and not is_meaningful_requirement_field(rationale):
1272
+ issues.append(f"Requirement Mapping for {requirement_id} with Coverage {coverage} must cite Rationale")
1273
+
1274
+ return sorted(set(issues))
1275
+
1276
+
1277
+ def lint_plan_drift_check(content: str, workflow_profile: str) -> list[str]:
1278
+ if workflow_profile != CURRENT_WORKFLOW_PROFILE:
1279
+ return []
1280
+ body = get_heading_body(content, "Plan Drift Check")
1281
+ if not body:
1282
+ return ["Missing or empty section: ## Plan Drift Check"]
1283
+ if re.search(r"\bmerge\b", body, re.IGNORECASE) and not re.search(r"\brationale\b", body, re.IGNORECASE):
1284
+ return ["Plan Drift Check mentions merged obligations without explaining why the merge is lossless"]
1285
+ return []
1286
+
1287
+
1288
+ def lint_phase2_requirement_disposition_fields(
1289
+ requirement_id: str,
1290
+ status: str,
1291
+ fields: dict[str, str],
1292
+ run_dir: Path,
1293
+ repo_root: Path,
1294
+ ) -> list[str]:
1295
+ issues: list[str] = []
1296
+ allowed_fields_by_status = {
1297
+ "planned": {"Implementation Surface", "Verification Surface", "QA Surface", "Audit Note"},
1298
+ "planned-via-merge": {"Implementation Surface", "Verification Surface", "QA Surface", "Rationale", "Audit Note"},
1299
+ "planned-indirectly": {"Implementation Surface", "Verification Surface", "QA Surface", "Rationale", "Audit Note"},
1300
+ "deferred": {"Rationale", "Deferred By", "Addendum", "Audit Note"},
1301
+ "out-of-scope": {"Rationale", "Scope Decision", "Addendum", "Audit Note"},
1302
+ "blocked": {"Rationale", "Blocking Evidence", "Audit Note"},
1303
+ "superseded by approved addendum": {"Addendum", "Audit Note"},
1304
+ }
1305
+ meaningful_fields = collect_meaningful_requirement_fields(fields)
1306
+ unexpected_fields = sorted(set(meaningful_fields) - allowed_fields_by_status[status])
1307
+ if unexpected_fields:
1308
+ issues.append(
1309
+ f"Requirement {requirement_id} with Status {status} contains contradictory field(s): "
1310
+ + ", ".join(unexpected_fields)
1311
+ )
1312
+
1313
+ if status in {"planned", "planned-via-merge", "planned-indirectly"}:
1314
+ implementation_surface = fields.get("Implementation Surface", "")
1315
+ verification_surface = fields.get("Verification Surface", "")
1316
+ qa_surface = fields.get("QA Surface", "")
1317
+ rationale = fields.get("Rationale", "")
1318
+ if not is_meaningful_requirement_field(implementation_surface):
1319
+ issues.append(f"Requirement {requirement_id} with Status {status} must cite Implementation Surface")
1320
+ else:
1321
+ issues.extend(validate_planned_surface_paths(requirement_id, "Implementation Surface", implementation_surface, repo_root))
1322
+ if not is_meaningful_requirement_field(verification_surface):
1323
+ issues.append(f"Requirement {requirement_id} with Status {status} must cite Verification Surface")
1324
+ if not is_meaningful_requirement_field(qa_surface):
1325
+ issues.append(f"Requirement {requirement_id} with Status {status} must cite QA Surface")
1326
+ if status in {"planned-via-merge", "planned-indirectly"} and not is_meaningful_requirement_field(rationale):
1327
+ issues.append(f"Requirement {requirement_id} with Status {status} must cite Rationale")
1328
+ elif status == "deferred":
1329
+ deferred_by = fields.get("Deferred By", "") or fields.get("Addendum", "")
1330
+ deferred_paths = collect_requirement_field_paths(fields, ["Deferred By", "Addendum"])
1331
+ if not is_meaningful_requirement_field(fields.get("Rationale", "")):
1332
+ issues.append(f"Requirement {requirement_id} with Status deferred is missing Rationale")
1333
+ if not is_meaningful_requirement_field(deferred_by):
1334
+ issues.append(f"Requirement {requirement_id} with Status deferred must cite Deferred By or Addendum")
1335
+ elif not deferred_paths:
1336
+ issues.append(f"Requirement {requirement_id} with Status deferred must cite an approved deferral path")
1337
+ else:
1338
+ missing = find_missing_repo_paths(repo_root, sorted(deferred_paths))
1339
+ if missing:
1340
+ issues.append(f"Requirement {requirement_id} deferral reference path(s) do not exist: {', '.join(missing[:5])}")
1341
+ elif status == "out-of-scope":
1342
+ scope_decision = fields.get("Scope Decision", "") or fields.get("Addendum", "")
1343
+ scope_paths = collect_requirement_field_paths(fields, ["Scope Decision", "Addendum"])
1344
+ if not is_meaningful_requirement_field(fields.get("Rationale", "")):
1345
+ issues.append(f"Requirement {requirement_id} with Status out-of-scope is missing Rationale")
1346
+ if not is_meaningful_requirement_field(scope_decision):
1347
+ issues.append(f"Requirement {requirement_id} with Status out-of-scope must cite Scope Decision or Addendum")
1348
+ elif not scope_paths:
1349
+ issues.append(f"Requirement {requirement_id} with Status out-of-scope must cite an approved scope decision path")
1350
+ else:
1351
+ missing = find_missing_repo_paths(repo_root, sorted(scope_paths))
1352
+ if missing:
1353
+ issues.append(f"Requirement {requirement_id} scope decision path(s) do not exist: {', '.join(missing[:5])}")
1354
+ elif status == "blocked":
1355
+ blocking_evidence = fields.get("Blocking Evidence", "")
1356
+ blocking_paths = collect_requirement_field_paths(fields, ["Blocking Evidence"])
1357
+ if not is_meaningful_requirement_field(fields.get("Rationale", "")):
1358
+ issues.append(f"Requirement {requirement_id} with Status blocked is missing Rationale")
1359
+ if not is_meaningful_requirement_field(blocking_evidence):
1360
+ issues.append(f"Requirement {requirement_id} with Status blocked must cite Blocking Evidence")
1361
+ elif not blocking_paths:
1362
+ issues.append(f"Requirement {requirement_id} with Status blocked must cite file, artifact, or evidence paths in Blocking Evidence")
1363
+ elif status == "superseded by approved addendum":
1364
+ addendum_path = normalize_repo_path(fields.get("Addendum", ""))
1365
+ if not addendum_path:
1366
+ issues.append(f"Requirement {requirement_id} superseded by approved addendum must cite Addendum")
1367
+ elif not addendum_path.startswith(f".recursive/run/{run_dir.name}/addenda/"):
1368
+ issues.append(f"Requirement {requirement_id} addendum reference must live under the current run addenda/")
1369
+ elif not (repo_root / addendum_path).exists():
1370
+ issues.append(f"Requirement {requirement_id} addendum reference does not exist: {addendum_path}")
1371
+
1372
+ return issues
1373
+
1374
+
1375
+ def lint_requirement_completion_status(
1376
+ file_path: Path,
1377
+ content: str,
1378
+ requirement_ids: list[str],
1379
+ run_dir: Path,
1380
+ workflow_profile: str,
1381
+ actual_changed_files: list[str] | None,
1382
+ ) -> list[str]:
1383
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES or file_path.name not in AUDITED_PHASE_FILES:
1384
+ return []
1385
+
1386
+ body = get_heading_body(content, "Requirement Completion Status")
1387
+ if not body:
1388
+ return ["Missing or empty section: ## Requirement Completion Status"]
1389
+
1390
+ entries, issues = parse_requirement_completion_entries(body)
1391
+ missing = [requirement_id for requirement_id in requirement_ids if requirement_id not in entries]
1392
+ if missing:
1393
+ issues.append(f"Requirement Completion Status is missing in-scope requirements: {', '.join(missing)}")
1394
+
1395
+ if workflow_profile == CURRENT_WORKFLOW_PROFILE and file_path.name == "02-to-be-plan.md":
1396
+ for requirement_id, fields in entries.items():
1397
+ status = trim_md_value(fields.get("Status", "")).lower()
1398
+ if status not in PHASE2_REQUIREMENT_DISPOSITION_STATUSES:
1399
+ issues.append(
1400
+ f"Requirement Completion Status for {requirement_id} has invalid Phase 2 Status '{fields.get('Status', '')}'"
1401
+ )
1402
+ continue
1403
+ issues.extend(
1404
+ lint_phase2_requirement_disposition_fields(
1405
+ requirement_id,
1406
+ status,
1407
+ fields,
1408
+ run_dir,
1409
+ run_dir.parent.parent.parent,
1410
+ )
1411
+ )
1412
+ return sorted(set(issues))
1413
+
1414
+ for requirement_id, fields in entries.items():
1415
+ status = trim_md_value(fields.get("Status", "")).lower()
1416
+ if status not in REQUIREMENT_DISPOSITION_STATUSES:
1417
+ issues.append(
1418
+ f"Requirement Completion Status for {requirement_id} has invalid Status '{fields.get('Status', '')}'"
1419
+ )
1420
+ continue
1421
+
1422
+ issues.extend(
1423
+ lint_requirement_disposition_fields(
1424
+ requirement_id,
1425
+ status,
1426
+ fields,
1427
+ file_path.name,
1428
+ run_dir,
1429
+ run_dir.parent.parent.parent,
1430
+ actual_changed_files,
1431
+ )
1432
+ )
1433
+
1434
+ if file_path.name in REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES:
1435
+ expected_scope = set(get_phase_owned_actual_changed_files(file_path.name, actual_changed_files) or [])
1436
+ if expected_scope:
1437
+ claimed_changed_files = set()
1438
+ for fields in entries.values():
1439
+ status = trim_md_value(fields.get("Status", "")).lower()
1440
+ if status in {"implemented", "verified"}:
1441
+ claimed_changed_files.update(collect_requirement_field_paths(fields, ["Changed Files"]))
1442
+ missing_claims = sorted(path for path in expected_scope if path not in claimed_changed_files)
1443
+ if missing_claims:
1444
+ issues.append(
1445
+ "Requirement Completion Status leaves diff-owned changed file(s) unaccounted for: "
1446
+ + ", ".join(missing_claims[:5])
1447
+ )
1448
+
1449
+ return sorted(set(issues))
1450
+
1451
+
1452
+ def lint_prior_recursive_evidence(content: str, run_dir: Path, workflow_profile: str, repo_root: Path, file_name: str) -> list[str]:
1453
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES or file_name not in PRIOR_RECURSIVE_EVIDENCE_FILES:
1454
+ return []
1455
+
1456
+ body = get_heading_body(content, "Prior Recursive Evidence Reviewed")
1457
+ if not body:
1458
+ return ["Missing or empty section: ## Prior Recursive Evidence Reviewed"]
1459
+
1460
+ referenced_paths = {
1461
+ normalize_repo_path(path)
1462
+ for path in extract_paths_from_text(body)
1463
+ if normalize_repo_path(path).startswith(".recursive/run/") or normalize_repo_path(path).startswith(".recursive/memory/")
1464
+ }
1465
+ if referenced_paths:
1466
+ missing_paths = find_missing_repo_paths(repo_root, sorted(referenced_paths))
1467
+ if missing_paths:
1468
+ return [f"Prior Recursive Evidence Reviewed references missing path(s): {', '.join(missing_paths[:5])}"]
1469
+ return []
1470
+
1471
+ if re.search(r"\bnone\b", body, re.IGNORECASE) and re.search(r"\b(justification|reason|because)\b", body, re.IGNORECASE):
1472
+ return []
1473
+
1474
+ return [
1475
+ "Prior Recursive Evidence Reviewed must contain structured run/memory paths or an explicit no-relevant-evidence justification"
1476
+ ]
1477
+
1478
+
1479
+ def get_subagent_action_record_paths(content: str, run_dir: Path) -> list[str]:
1480
+ body = get_heading_body(content, "Subagent Contribution Verification")
1481
+ if not body:
1482
+ return []
1483
+ expected_prefix = f".recursive/run/{run_dir.name}/subagents/"
1484
+ return sorted(
1485
+ path
1486
+ for path in {normalize_repo_path(path) for path in extract_paths_from_text(body)}
1487
+ if path.startswith(expected_prefix)
1488
+ )
1489
+
1490
+
1491
+ def get_all_subagent_action_record_paths(content: str) -> list[str]:
1492
+ body = get_heading_body(content, "Subagent Contribution Verification")
1493
+ if not body:
1494
+ return []
1495
+ return sorted(
1496
+ path
1497
+ for path in {normalize_repo_path(path) for path in extract_paths_from_text(body)}
1498
+ if re.match(r"^\.recursive/run/[^/]+/subagents/.+\.md$", path)
1499
+ )
1500
+
1501
+
1502
+ def lint_subagent_action_record_file(
1503
+ file_path: Path,
1504
+ repo_root: Path,
1505
+ run_dir: Path,
1506
+ actual_changed_files: list[str] | None,
1507
+ ) -> list[str]:
1508
+ content = file_path.read_text(encoding="utf-8")
1509
+ issues: list[str] = []
1510
+
1511
+ if "# Subagent Action Record" not in content:
1512
+ issues.append("Missing title: # Subagent Action Record")
1513
+
1514
+ for heading in SUBAGENT_ACTION_REQUIRED_HEADINGS:
1515
+ if not get_heading_body(content, heading):
1516
+ issues.append(f"Missing or empty section: ## {heading}")
1517
+
1518
+ metadata = get_heading_body(content, "Metadata")
1519
+ inputs = get_heading_body(content, "Inputs Provided")
1520
+ claimed_actions = get_heading_body(content, "Claimed Actions Taken")
1521
+ claimed_file_impact = get_heading_body(content, "Claimed File Impact")
1522
+ claimed_artifact_impact = get_heading_body(content, "Claimed Artifact Impact")
1523
+
1524
+ if file_path.parent != run_dir / "subagents":
1525
+ issues.append(f"Subagent action record must live under `/.recursive/run/{run_dir.name}/subagents/`")
1526
+
1527
+ required_metadata_fields = ("Subagent ID", "Run ID", "Phase", "Purpose", "Execution Mode", "Timestamp")
1528
+ for field_name in required_metadata_fields:
1529
+ if not has_meaningful_value(get_md_field_value(metadata, field_name)):
1530
+ issues.append(f"Metadata is missing {field_name}")
1531
+
1532
+ run_id = get_md_field_value(metadata, "Run ID")
1533
+ if run_id and run_id != run_dir.name:
1534
+ issues.append(f"Run ID mismatch: {run_id} != {run_dir.name}")
1535
+
1536
+ current_artifact = normalize_repo_path(get_md_field_value(inputs, "Current Artifact") or "")
1537
+ if not current_artifact:
1538
+ issues.append("Inputs Provided is missing Current Artifact")
1539
+ elif not (repo_root / current_artifact).exists():
1540
+ issues.append(f"Current Artifact does not exist: {current_artifact}")
1541
+
1542
+ artifact_hash = trim_md_value(get_md_field_value(inputs, "Artifact Content Hash") or "")
1543
+ if current_artifact and (repo_root / current_artifact).exists():
1544
+ current_artifact_hash = content_sha256((repo_root / current_artifact).read_text(encoding="utf-8"))
1545
+ if not artifact_hash:
1546
+ issues.append("Inputs Provided is missing Artifact Content Hash")
1547
+ elif artifact_hash != current_artifact_hash:
1548
+ issues.append("Inputs Provided Artifact Content Hash does not match the current artifact content")
1549
+
1550
+ review_bundle = normalize_repo_path(get_md_field_value(inputs, "Review Bundle") or "")
1551
+ if review_bundle and not (repo_root / review_bundle).exists():
1552
+ issues.append(f"Review Bundle does not exist: {review_bundle}")
1553
+
1554
+ diff_basis_text = get_md_field_value(inputs, "Diff Basis") or ""
1555
+ if not diff_basis_text.strip():
1556
+ issues.append("Inputs Provided is missing Diff Basis")
1557
+
1558
+ upstream_artifacts = {
1559
+ path for path in extract_paths_from_named_field(inputs, "Upstream Artifacts") if path.startswith(f".recursive/run/{run_dir.name}/")
1560
+ }
1561
+ code_refs = extract_paths_from_named_field(inputs, "Code Refs")
1562
+ memory_refs = {
1563
+ path for path in extract_paths_from_named_field(inputs, "Memory Refs") if path.startswith(".recursive/memory/")
1564
+ }
1565
+ audit_question_text = get_md_field_value(inputs, "Audit / Task Questions") or inputs
1566
+ if not upstream_artifacts and not review_bundle:
1567
+ issues.append("Inputs Provided must cite upstream artifacts or the review bundle used for delegation")
1568
+ if is_placeholder_only(audit_question_text):
1569
+ issues.append("Inputs Provided is missing concrete Audit / Task Questions")
1570
+
1571
+ claimed_created = {
1572
+ normalize_repo_path(path)
1573
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Created"))
1574
+ }
1575
+ claimed_modified = {
1576
+ normalize_repo_path(path)
1577
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Modified"))
1578
+ }
1579
+ claimed_reviewed = {
1580
+ normalize_repo_path(path)
1581
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Reviewed"))
1582
+ }
1583
+ claimed_relevant_untouched = {
1584
+ normalize_repo_path(path)
1585
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Relevant but Untouched"))
1586
+ }
1587
+
1588
+ if is_placeholder_only(claimed_actions):
1589
+ issues.append("Claimed Actions Taken must contain concrete delegated work details")
1590
+ claimed_file_refs = claimed_created | claimed_modified | claimed_reviewed | claimed_relevant_untouched
1591
+ if not claimed_file_refs:
1592
+ issues.append("Claimed File Impact must cite at least one created, modified, reviewed, or relevant untouched file")
1593
+
1594
+ for created_path in sorted(claimed_created):
1595
+ if not (repo_root / created_path).exists():
1596
+ issues.append(f"Claimed created file does not exist: {created_path}")
1597
+ for reviewed_path in sorted(claimed_modified | claimed_reviewed | claimed_relevant_untouched):
1598
+ if not (repo_root / reviewed_path).exists():
1599
+ issues.append(f"Claimed file reference does not exist: {reviewed_path}")
1600
+
1601
+ if actual_changed_files is not None:
1602
+ actual_changed = set(actual_changed_files)
1603
+ missing_changed_claims = [path for path in claimed_modified | claimed_created if path not in actual_changed]
1604
+ if missing_changed_claims:
1605
+ issues.append(
1606
+ f"Claimed modified/created files are not present in the current diff: {', '.join(sorted(missing_changed_claims)[:5])}"
1607
+ )
1608
+
1609
+ if review_bundle and (repo_root / review_bundle).exists():
1610
+ bundle_content = (repo_root / review_bundle).read_text(encoding="utf-8")
1611
+ bundle_artifact_path = normalize_repo_path(get_md_field_value(bundle_content, "Artifact Path") or "")
1612
+ bundle_upstream_artifacts = {
1613
+ normalize_repo_path(path)
1614
+ for path in extract_paths_from_text(get_heading_body(bundle_content, "Upstream Artifacts To Re-read"))
1615
+ if normalize_repo_path(path)
1616
+ }
1617
+ bundle_changed_paths = {
1618
+ normalize_repo_path(path)
1619
+ for path in extract_paths_from_text(get_heading_body(bundle_content, "Changed Files Reviewed"))
1620
+ if normalize_repo_path(path)
1621
+ }
1622
+ bundle_code_refs = {
1623
+ normalize_repo_path(path)
1624
+ for path in extract_paths_from_text(get_heading_body(bundle_content, "Targeted Code References"))
1625
+ if normalize_repo_path(path)
1626
+ }
1627
+ allowed_artifacts = {path for path in {bundle_artifact_path, *bundle_upstream_artifacts} if path}
1628
+ if current_artifact and allowed_artifacts and current_artifact not in allowed_artifacts:
1629
+ issues.append(
1630
+ "Inputs Provided Current Artifact must match the review bundle Artifact Path or a cited upstream artifact: "
1631
+ + f"{current_artifact}"
1632
+ )
1633
+ missing_bundle_upstream = sorted(path for path in bundle_upstream_artifacts if path not in upstream_artifacts)
1634
+ if missing_bundle_upstream:
1635
+ issues.append(
1636
+ "Inputs Provided Upstream Artifacts omit bundle upstream artifact(s): "
1637
+ + ", ".join(missing_bundle_upstream[:5])
1638
+ )
1639
+ required_bundle_file_scope = bundle_code_refs or bundle_changed_paths
1640
+ if required_bundle_file_scope:
1641
+ missing_bundle_scope_paths = sorted(path for path in required_bundle_file_scope if path not in claimed_file_refs)
1642
+ if missing_bundle_scope_paths:
1643
+ issues.append(
1644
+ "Claimed File Impact omits targeted file scope present in the review bundle: "
1645
+ + ", ".join(missing_bundle_scope_paths[:5])
1646
+ )
1647
+
1648
+ artifact_refs = {
1649
+ normalize_repo_path(path)
1650
+ for path in extract_paths_from_text(claimed_artifact_impact)
1651
+ if normalize_repo_path(path).startswith(".recursive/")
1652
+ }
1653
+ evidence_refs = {
1654
+ normalize_repo_path(path)
1655
+ for path in extract_paths_from_text(claimed_artifact_impact)
1656
+ if normalize_repo_path(path).startswith(f".recursive/run/{run_dir.name}/evidence/")
1657
+ }
1658
+ if not artifact_refs and not evidence_refs:
1659
+ issues.append("Claimed Artifact Impact must cite recursive artifacts or evidence paths used by the subagent")
1660
+ missing_artifact_refs = find_missing_repo_paths(repo_root, sorted(artifact_refs))
1661
+ if missing_artifact_refs:
1662
+ issues.append(f"Claimed artifact references do not exist: {', '.join(missing_artifact_refs[:5])}")
1663
+ missing_code_refs = find_missing_repo_paths(repo_root, sorted(code_refs))
1664
+ if missing_code_refs:
1665
+ issues.append(f"Inputs Provided code refs do not exist: {', '.join(missing_code_refs[:5])}")
1666
+ missing_memory_refs = find_missing_repo_paths(repo_root, sorted(memory_refs))
1667
+ if missing_memory_refs:
1668
+ issues.append(f"Inputs Provided memory refs do not exist: {', '.join(missing_memory_refs[:5])}")
1669
+
1670
+ verification_handoff = get_heading_body(content, "Verification Handoff")
1671
+ if verification_handoff and not extract_paths_from_text(verification_handoff):
1672
+ issues.append("Verification Handoff must cite files, diffs, or artifacts to inspect")
1673
+
1674
+ return sorted(set(issues))
1675
+
1676
+
1677
+ def parse_subagent_action_record_claims(action_content: str) -> dict[str, set[str] | str]:
1678
+ inputs = get_heading_body(action_content, "Inputs Provided")
1679
+ claimed_file_impact = get_heading_body(action_content, "Claimed File Impact")
1680
+ claimed_artifact_impact = get_heading_body(action_content, "Claimed Artifact Impact")
1681
+ current_artifact = normalize_repo_path(get_md_field_value(inputs, "Current Artifact") or "")
1682
+ review_bundle = normalize_repo_path(get_md_field_value(inputs, "Review Bundle") or "")
1683
+ upstream_artifacts = extract_paths_from_named_field(inputs, "Upstream Artifacts")
1684
+ claimed_created = {
1685
+ normalize_repo_path(path)
1686
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Created"))
1687
+ if normalize_repo_path(path)
1688
+ }
1689
+ claimed_modified = {
1690
+ normalize_repo_path(path)
1691
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Modified"))
1692
+ if normalize_repo_path(path)
1693
+ }
1694
+ claimed_reviewed = {
1695
+ normalize_repo_path(path)
1696
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Reviewed"))
1697
+ if normalize_repo_path(path)
1698
+ }
1699
+ claimed_relevant_untouched = {
1700
+ normalize_repo_path(path)
1701
+ for path in extract_paths_from_text(get_subheading_body(claimed_file_impact, "Relevant but Untouched"))
1702
+ if normalize_repo_path(path)
1703
+ }
1704
+ claimed_artifact_refs = {
1705
+ normalize_repo_path(path)
1706
+ for path in extract_paths_from_text(claimed_artifact_impact)
1707
+ if normalize_repo_path(path).startswith(".recursive/")
1708
+ }
1709
+ return {
1710
+ "current_artifact": current_artifact,
1711
+ "review_bundle": review_bundle,
1712
+ "upstream_artifacts": upstream_artifacts,
1713
+ "created": claimed_created,
1714
+ "modified": claimed_modified,
1715
+ "reviewed": claimed_reviewed,
1716
+ "relevant_untouched": claimed_relevant_untouched,
1717
+ "artifact_refs": claimed_artifact_refs,
1718
+ }
1719
+
1720
+
1721
+ def lint_subagent_contribution_verification(
1722
+ file_path: Path,
1723
+ content: str,
1724
+ workflow_profile: str,
1725
+ run_dir: Path,
1726
+ repo_root: Path,
1727
+ actual_changed_files: list[str] | None,
1728
+ ) -> list[str]:
1729
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES or file_path.name not in AUDITED_PHASE_FILES:
1730
+ return []
1731
+
1732
+ body = get_heading_body(content, "Subagent Contribution Verification")
1733
+ if not body:
1734
+ return ["Missing or empty section: ## Subagent Contribution Verification"]
1735
+
1736
+ issues: list[str] = []
1737
+ action_record_paths = get_subagent_action_record_paths(content, run_dir)
1738
+ all_action_record_paths = get_all_subagent_action_record_paths(content)
1739
+ audit_context = get_heading_body(content, "Audit Context")
1740
+ audit_mode = get_md_field_value(audit_context, "Audit Execution Mode") or ""
1741
+ current_phase = get_md_field_value(content, "Phase") or ""
1742
+ reviewed_action_records_field = get_named_field_text(body, "Reviewed Action Records") or ""
1743
+ main_agent_verification = get_named_field_text(body, "Main-Agent Verification Performed") or ""
1744
+ acceptance_decision = trim_md_value(get_md_field_value(body, "Acceptance Decision") or "").lower()
1745
+ refresh_handling = get_named_field_text(body, "Refresh Handling") or ""
1746
+ repair_performed = get_named_field_text(body, "Repair Performed After Verification") or ""
1747
+ verification_paths = {
1748
+ normalize_repo_path(path)
1749
+ for path in extract_paths_from_field_value(main_agent_verification)
1750
+ if normalize_repo_path(path)
1751
+ }
1752
+ repair_paths = {
1753
+ normalize_repo_path(path)
1754
+ for path in extract_paths_from_field_value(repair_performed)
1755
+ if normalize_repo_path(path)
1756
+ }
1757
+ current_bundle_path = normalize_repo_path(
1758
+ get_md_field_value(get_heading_body(content, "Review Metadata"), "Review Bundle Path")
1759
+ or get_md_field_value(content, "Review Bundle Path")
1760
+ or ""
1761
+ )
1762
+ if audit_mode == "subagent" and not action_record_paths:
1763
+ issues.append("Audit Execution Mode subagent requires at least one reviewed subagent action record")
1764
+ out_of_run_action_records = sorted(path for path in all_action_record_paths if path not in action_record_paths)
1765
+ if out_of_run_action_records:
1766
+ issues.append(
1767
+ "Subagent Contribution Verification may only reference action records under the current run subagents/: "
1768
+ + ", ".join(out_of_run_action_records[:5])
1769
+ )
1770
+ if action_record_paths:
1771
+ reviewed_record_paths = {
1772
+ normalize_repo_path(path)
1773
+ for path in extract_paths_from_field_value(reviewed_action_records_field)
1774
+ if normalize_repo_path(path).startswith(f".recursive/run/{run_dir.name}/subagents/")
1775
+ }
1776
+ if not reviewed_action_records_field.strip():
1777
+ issues.append("Subagent Contribution Verification must record Reviewed Action Records")
1778
+ else:
1779
+ missing_reviewed_records = sorted(path for path in action_record_paths if path not in reviewed_record_paths)
1780
+ if missing_reviewed_records:
1781
+ issues.append(
1782
+ "Reviewed Action Records is missing referenced action record path(s): "
1783
+ + ", ".join(missing_reviewed_records[:5])
1784
+ )
1785
+ if not has_meaningful_value(main_agent_verification, disallowed={"n/a", "none"}):
1786
+ issues.append("Subagent Contribution Verification must record Main-Agent Verification Performed")
1787
+ elif not verification_paths:
1788
+ issues.append("Main-Agent Verification Performed must cite files, artifacts, or diff-owned paths that were checked")
1789
+ else:
1790
+ missing_verification_paths = find_missing_repo_paths(repo_root, sorted(verification_paths))
1791
+ if missing_verification_paths:
1792
+ issues.append(
1793
+ "Main-Agent Verification Performed references missing path(s): "
1794
+ + ", ".join(missing_verification_paths[:5])
1795
+ )
1796
+ if acceptance_decision not in {"accepted", "partially accepted", "rejected"}:
1797
+ issues.append("Subagent Contribution Verification must record Acceptance Decision: accepted|partially accepted|rejected")
1798
+ if not has_meaningful_value(refresh_handling, disallowed={"n/a", "none"}):
1799
+ issues.append("Subagent Contribution Verification must record Refresh Handling")
1800
+ if not trim_md_value(repair_performed):
1801
+ issues.append("Subagent Contribution Verification must record Repair Performed After Verification")
1802
+ elif repair_paths:
1803
+ missing_repair_paths = find_missing_repo_paths(repo_root, sorted(repair_paths))
1804
+ if missing_repair_paths:
1805
+ issues.append(
1806
+ "Repair Performed After Verification references missing path(s): "
1807
+ + ", ".join(missing_repair_paths[:5])
1808
+ )
1809
+
1810
+ for action_record_path in action_record_paths:
1811
+ if not (repo_root / action_record_path).exists():
1812
+ issues.append(f"Referenced subagent action record does not exist: {action_record_path}")
1813
+ continue
1814
+ action_content = (repo_root / action_record_path).read_text(encoding="utf-8")
1815
+ action_phase = get_md_field_value(get_heading_body(action_content, "Metadata"), "Phase") or ""
1816
+ action_claims = parse_subagent_action_record_claims(action_content)
1817
+ if current_phase and action_phase and current_phase != action_phase:
1818
+ issues.append(f"Subagent action record phase mismatch: {action_record_path} -> {action_phase}")
1819
+ if current_bundle_path:
1820
+ action_bundle_path = str(action_claims["review_bundle"])
1821
+ if action_bundle_path and action_bundle_path != current_bundle_path:
1822
+ issues.append(
1823
+ f"Subagent action record review bundle mismatch: {action_record_path} -> {action_bundle_path}"
1824
+ )
1825
+ issues.extend(
1826
+ lint_subagent_action_record_file(
1827
+ repo_root / action_record_path,
1828
+ repo_root,
1829
+ run_dir,
1830
+ actual_changed_files,
1831
+ )
1832
+ )
1833
+ if acceptance_decision in {"accepted", "partially accepted"}:
1834
+ claimed_diff_scope = set()
1835
+ claimed_diff_scope.update(set(action_claims["created"])) # type: ignore[arg-type]
1836
+ claimed_diff_scope.update(set(action_claims["modified"])) # type: ignore[arg-type]
1837
+ claimed_diff_scope.update(set(action_claims["reviewed"])) # type: ignore[arg-type]
1838
+ expected_verified_paths = set(claimed_diff_scope)
1839
+ if actual_changed_files is not None:
1840
+ expected_verified_paths = {path for path in expected_verified_paths if path in set(actual_changed_files)}
1841
+ missing_verified_paths = sorted(path for path in expected_verified_paths if path not in verification_paths and path not in repair_paths)
1842
+ if missing_verified_paths:
1843
+ issues.append(
1844
+ "Main-Agent Verification Performed does not reconcile delegated file-impact claims against the actual diff scope: "
1845
+ + ", ".join(missing_verified_paths[:5])
1846
+ )
1847
+
1848
+ verification_artifact_scope = {
1849
+ str(action_claims["current_artifact"]),
1850
+ *set(action_claims["upstream_artifacts"]), # type: ignore[arg-type]
1851
+ *set(action_claims["artifact_refs"]), # type: ignore[arg-type]
1852
+ }
1853
+ if action_claims["review_bundle"]: # type: ignore[index]
1854
+ verification_artifact_scope.add(str(action_claims["review_bundle"]))
1855
+ verification_artifact_scope.discard("")
1856
+ if verification_artifact_scope and not any(path in verification_paths for path in verification_artifact_scope):
1857
+ issues.append(
1858
+ "Main-Agent Verification Performed must cite the reviewed artifact, bundle, or upstream recursive artifacts used to accept delegated work"
1859
+ )
1860
+
1861
+ return sorted(set(issues))
1862
+
1863
+
1864
+ def collect_reviewed_paths(run_dir: Path, artifact_name: str, content: str) -> set[str]:
1865
+ reviewed_paths = extract_paths_from_text(get_heading_body(content, "Worktree Diff Audit"))
1866
+ for addendum_path in get_related_addenda_paths(run_dir, artifact_name):
1867
+ reviewed_paths.update(extract_paths_from_text(addendum_path.read_text(encoding="utf-8")))
1868
+ return reviewed_paths
1869
+
1870
+
1871
+ def lint_review_bundle_reference(content: str, run_dir: Path, repo_root: Path) -> list[str]:
1872
+ issues: list[str] = []
1873
+ review_metadata = get_heading_body(content, "Review Metadata")
1874
+ bundle_path = (
1875
+ get_md_field_value(review_metadata, "Review Bundle Path")
1876
+ or get_md_field_value(content, "Review Bundle Path")
1877
+ or ""
1878
+ ).strip()
1879
+ expected_prefix = f".recursive/run/{run_dir.name}/evidence/review-bundles/"
1880
+
1881
+ if not bundle_path:
1882
+ issues.append("Review Metadata is missing Review Bundle Path")
1883
+ return issues
1884
+
1885
+ normalized_bundle_path = normalize_repo_path(bundle_path)
1886
+ if not normalized_bundle_path.startswith(expected_prefix):
1887
+ issues.append(f"Review Bundle Path must live under `/{expected_prefix}`")
1888
+ return issues
1889
+
1890
+ if not (repo_root / normalized_bundle_path).exists():
1891
+ issues.append(f"Review Bundle Path does not exist: {normalized_bundle_path}")
1892
+ return issues
1893
+
1894
+ bundle_content = (repo_root / normalized_bundle_path).read_text(encoding="utf-8")
1895
+ artifact_path = normalize_repo_path(get_md_field_value(bundle_content, "Artifact Path") or "")
1896
+ artifact_hash = trim_md_value(get_md_field_value(bundle_content, "Artifact Content Hash") or "")
1897
+ if not artifact_path:
1898
+ issues.append("Review bundle is missing Artifact Path")
1899
+ elif not (repo_root / artifact_path).exists():
1900
+ issues.append(f"Review bundle Artifact Path does not exist: {artifact_path}")
1901
+ if artifact_path:
1902
+ current_hash = content_sha256((repo_root / artifact_path).read_text(encoding="utf-8")) if (repo_root / artifact_path).exists() else ""
1903
+ if artifact_hash and current_hash and artifact_hash != current_hash:
1904
+ issues.append("Review bundle is stale: Artifact Content Hash no longer matches the current artifact")
1905
+ if not artifact_hash:
1906
+ issues.append("Review bundle is missing Artifact Content Hash")
1907
+
1908
+ missing_bundle_headings = []
1909
+ for heading in (
1910
+ "Diff Basis",
1911
+ "Changed Files Reviewed",
1912
+ "Upstream Artifacts To Re-read",
1913
+ "Relevant Addenda",
1914
+ "Prior Recursive Evidence",
1915
+ "Targeted Code References",
1916
+ "Audit Questions",
1917
+ "Required Output",
1918
+ ):
1919
+ if not get_heading_body(bundle_content, heading):
1920
+ missing_bundle_headings.append(heading)
1921
+ if missing_bundle_headings:
1922
+ issues.append(f"Review bundle is missing required section(s): {', '.join(missing_bundle_headings)}")
1923
+
1924
+ review_narrative = "\n".join(
1925
+ [
1926
+ get_heading_body(content, "Review Scope"),
1927
+ get_heading_body(content, "Requirement And Plan Reconciliation"),
1928
+ get_heading_body(content, "Plan Alignment Assessment"),
1929
+ get_heading_body(content, "Code Quality Assessment"),
1930
+ get_heading_body(content, "Issues Found"),
1931
+ get_heading_body(content, "Verdict"),
1932
+ ]
1933
+ )
1934
+ cited_paths = {normalize_repo_path(path) for path in extract_paths_from_text(content)}
1935
+ cited_review_paths = {normalize_repo_path(path) for path in extract_paths_from_text(review_narrative)}
1936
+ upstream_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Upstream Artifacts To Re-read"))}
1937
+ addenda_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Relevant Addenda"))}
1938
+ prior_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Prior Recursive Evidence"))}
1939
+ changed_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Changed Files Reviewed"))}
1940
+ code_ref_paths = {normalize_repo_path(path) for path in extract_paths_from_text(get_heading_body(bundle_content, "Targeted Code References"))}
1941
+ audit_questions = get_heading_body(bundle_content, "Audit Questions")
1942
+ if is_placeholder_only(audit_questions):
1943
+ issues.append("Review bundle Audit Questions cannot be placeholder-only")
1944
+ diff_basis_body = get_heading_body(bundle_content, "Diff Basis")
1945
+ for field_name in DIFF_BASIS_FIELDS:
1946
+ if get_md_field_value(diff_basis_body, field_name) is None:
1947
+ issues.append(f"Review bundle Diff Basis is missing {field_name}")
1948
+ if not changed_paths:
1949
+ issues.append("Review bundle Changed Files Reviewed cannot be empty")
1950
+ else:
1951
+ missing_changed_paths = find_missing_repo_paths(repo_root, sorted(changed_paths))
1952
+ if missing_changed_paths:
1953
+ issues.append(f"Review bundle changed file path(s) do not exist: {', '.join(missing_changed_paths[:5])}")
1954
+ if not code_ref_paths:
1955
+ issues.append("Review bundle Targeted Code References cannot be empty")
1956
+ else:
1957
+ missing_code_refs = find_missing_repo_paths(repo_root, sorted(code_ref_paths))
1958
+ if missing_code_refs:
1959
+ issues.append(f"Review bundle code ref path(s) do not exist: {', '.join(missing_code_refs[:5])}")
1960
+ elif changed_paths and not any(path in changed_paths for path in code_ref_paths):
1961
+ issues.append("Review bundle Targeted Code References do not overlap the changed-file scope")
1962
+ expected_addenda = set(get_expected_effective_input_addenda_paths(run_dir, "03.5-code-review.md"))
1963
+ missing_bundle_addenda = sorted(path for path in expected_addenda if path not in addenda_paths)
1964
+ if missing_bundle_addenda:
1965
+ issues.append(f"Review bundle is missing effective-input addenda: {', '.join(missing_bundle_addenda[:5])}")
1966
+
1967
+ if upstream_paths and not any(path in cited_review_paths for path in upstream_paths):
1968
+ issues.append("Code review narrative does not cite any upstream artifact from the review bundle")
1969
+ if addenda_paths and not any(path in cited_review_paths for path in addenda_paths):
1970
+ issues.append("Code review narrative does not cite any relevant addendum from the review bundle")
1971
+ if prior_paths and not any(path in cited_review_paths for path in prior_paths):
1972
+ issues.append("Code review narrative does not cite any prior recursive evidence from the review bundle")
1973
+ if (changed_paths or code_ref_paths) and not any(path in cited_review_paths for path in (changed_paths | code_ref_paths)):
1974
+ issues.append("Code review narrative does not cite any changed file or code reference from the review bundle")
1975
+ if normalized_bundle_path not in cited_paths:
1976
+ issues.append("Code review must cite the Review Bundle Path in its written review artifact")
1977
+
1978
+ verdict_body = get_heading_body(content, "Verdict")
1979
+ if not verdict_body or is_placeholder_only(verdict_body):
1980
+ issues.append("Verdict section must contain a concrete review verdict grounded in the review bundle")
1981
+
1982
+ return issues
1983
+
1984
+
1985
+ def lint_phase8_skill_usage_capture(content: str) -> list[str]:
1986
+ issues: list[str] = []
1987
+ usage_body = get_heading_body(content, "Run-Local Skill Usage Capture")
1988
+ if not usage_body:
1989
+ return ["Missing or empty section: ## Run-Local Skill Usage Capture"]
1990
+
1991
+ required_fields = [
1992
+ "Skill Usage Relevance",
1993
+ "Available Skills",
1994
+ "Skills Sought",
1995
+ "Skills Attempted",
1996
+ "Skills Used",
1997
+ "Worked Well",
1998
+ "Issues Encountered",
1999
+ "Future Guidance",
2000
+ "Promotion Candidates",
2001
+ ]
2002
+ for field_name in required_fields:
2003
+ if get_md_field_value(usage_body, field_name) is None:
2004
+ issues.append(f"Run-Local Skill Usage Capture is missing {field_name}")
2005
+
2006
+ relevance = normalize_skill_usage_relevance(get_md_field_value(usage_body, "Skill Usage Relevance"))
2007
+ if relevance not in SKILL_USAGE_RELEVANCE_STATUSES:
2008
+ issues.append("Run-Local Skill Usage Capture must declare Skill Usage Relevance: relevant|not-relevant")
2009
+ return issues
2010
+
2011
+ if relevance in {"relevant", "yes"}:
2012
+ for field_name in ("Available Skills", "Skills Attempted", "Skills Used", "Future Guidance"):
2013
+ if not is_meaningful_requirement_field(get_md_field_value(usage_body, field_name)):
2014
+ issues.append(f"Run-Local Skill Usage Capture must record {field_name} when skill usage is relevant")
2015
+ attempted = trim_md_value(get_md_field_value(usage_body, "Skills Attempted") or "").lower()
2016
+ used = trim_md_value(get_md_field_value(usage_body, "Skills Used") or "").lower()
2017
+ if attempted in {"none", "n/a"} and used in {"none", "n/a"}:
2018
+ issues.append("Run-Local Skill Usage Capture cannot mark skill usage relevant while claiming no attempted or used skills")
2019
+
2020
+ promotion_body = get_heading_body(content, "Skill Memory Promotion Review")
2021
+ if not promotion_body:
2022
+ issues.append("Missing or empty section: ## Skill Memory Promotion Review")
2023
+ return issues
2024
+
2025
+ for field_name in (
2026
+ "Durable Skill Lessons Promoted",
2027
+ "Generalized Guidance Updated",
2028
+ "Run-Local Observations Left Unpromoted",
2029
+ "Promotion Decision Rationale",
2030
+ ):
2031
+ if get_md_field_value(promotion_body, field_name) is None:
2032
+ issues.append(f"Skill Memory Promotion Review is missing {field_name}")
2033
+
2034
+ if relevance in {"relevant", "yes"} and not is_meaningful_requirement_field(
2035
+ get_md_field_value(promotion_body, "Promotion Decision Rationale")
2036
+ ):
2037
+ issues.append("Skill Memory Promotion Review must explain why relevant run-local observations were or were not promoted")
2038
+
2039
+ return issues
2040
+
2041
+
2042
+ def lint_phase_specific_rules(
2043
+ file_path: Path,
2044
+ content: str,
2045
+ workflow_profile: str,
2046
+ run_dir: Path,
2047
+ repo_root: Path,
2048
+ requirement_ids: list[str],
2049
+ actual_changed_files: list[str] | None,
2050
+ ) -> list[str]:
2051
+ issues: list[str] = []
2052
+ issues.extend(lint_effective_input_addenda(file_path, content, workflow_profile, run_dir))
2053
+ issues.extend(lint_source_requirement_inventory(file_path, content, workflow_profile, run_dir))
2054
+ if file_path.name == "02-to-be-plan.md":
2055
+ issues.extend(lint_requirement_mapping(content, workflow_profile, run_dir, repo_root))
2056
+ issues.extend(lint_plan_drift_check(content, workflow_profile))
2057
+ issues.extend(
2058
+ lint_requirement_completion_status(
2059
+ file_path,
2060
+ content,
2061
+ requirement_ids,
2062
+ run_dir,
2063
+ workflow_profile,
2064
+ actual_changed_files,
2065
+ )
2066
+ )
2067
+ issues.extend(lint_prior_recursive_evidence(content, run_dir, workflow_profile, repo_root, file_path.name))
2068
+ issues.extend(
2069
+ lint_subagent_contribution_verification(
2070
+ file_path,
2071
+ content,
2072
+ workflow_profile,
2073
+ run_dir,
2074
+ repo_root,
2075
+ actual_changed_files,
2076
+ )
2077
+ )
2078
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES:
2079
+ return issues
2080
+
2081
+ if file_path.name == "00-worktree.md":
2082
+ diff_basis, diff_basis_error = normalize_diff_basis(repo_root, get_run_diff_basis(run_dir))
2083
+ if diff_basis_error:
2084
+ issues.append(f"Phase 0 diff basis is not executable: {diff_basis_error}")
2085
+ elif diff_basis is None:
2086
+ issues.append("Phase 0 diff basis could not be normalized")
2087
+
2088
+ if file_path.name == "03-implementation-summary.md":
2089
+ tdd_body = get_heading_body(content, "TDD Compliance Log")
2090
+ tdd_mode = (get_md_field_value(tdd_body, "TDD Mode") or get_md_field_value(content, "TDD Mode") or "").lower()
2091
+
2092
+ if not has_gate_line(content, "TDD Compliance"):
2093
+ issues.append("Missing required gate line: TDD Compliance: PASS|FAIL")
2094
+ if tdd_mode not in TDD_MODES:
2095
+ issues.append("TDD Compliance Log is missing TDD Mode: strict|pragmatic")
2096
+ elif tdd_mode == "strict":
2097
+ if "RED Evidence:" not in tdd_body:
2098
+ issues.append("Strict TDD is missing RED Evidence in ## TDD Compliance Log")
2099
+ if "GREEN Evidence:" not in tdd_body:
2100
+ issues.append("Strict TDD is missing GREEN Evidence in ## TDD Compliance Log")
2101
+
2102
+ red_prefix = f".recursive/run/{run_dir.name}/evidence/logs/red/"
2103
+ green_prefix = f".recursive/run/{run_dir.name}/evidence/logs/green/"
2104
+ red_paths = collect_paths_under_prefix(tdd_body, red_prefix)
2105
+ green_paths = collect_paths_under_prefix(tdd_body, green_prefix)
2106
+
2107
+ if not red_paths:
2108
+ issues.append(f"Strict TDD requires at least one RED evidence path under `/{red_prefix}`")
2109
+ else:
2110
+ missing_red = find_missing_repo_paths(repo_root, red_paths)
2111
+ if missing_red:
2112
+ issues.append(f"Strict TDD RED evidence path(s) do not exist: {', '.join(missing_red[:5])}")
2113
+
2114
+ if not green_paths:
2115
+ issues.append(f"Strict TDD requires at least one GREEN evidence path under `/{green_prefix}`")
2116
+ else:
2117
+ missing_green = find_missing_repo_paths(repo_root, green_paths)
2118
+ if missing_green:
2119
+ issues.append(f"Strict TDD GREEN evidence path(s) do not exist: {', '.join(missing_green[:5])}")
2120
+ else:
2121
+ exception_body = get_heading_body(content, "Pragmatic TDD Exception")
2122
+ if not exception_body:
2123
+ issues.append("TDD Mode pragmatic requires ## Pragmatic TDD Exception")
2124
+ else:
2125
+ if not has_meaningful_value(get_md_field_value(exception_body, "Exception reason"), disallowed={"n/a", "none"}):
2126
+ issues.append("Pragmatic TDD Exception is missing Exception reason")
2127
+ if not has_meaningful_value(get_md_field_value(exception_body, "Compensating validation"), disallowed={"n/a", "none"}):
2128
+ issues.append("Pragmatic TDD Exception is missing Compensating validation")
2129
+
2130
+ pragmatic_paths = collect_paths_under_prefix(exception_body, f".recursive/run/{run_dir.name}/evidence/")
2131
+ if not pragmatic_paths:
2132
+ issues.append(
2133
+ f"Pragmatic TDD Exception requires compensating evidence paths under `/.recursive/run/{run_dir.name}/evidence/`"
2134
+ )
2135
+ else:
2136
+ missing_pragmatic = find_missing_repo_paths(repo_root, pragmatic_paths)
2137
+ if missing_pragmatic:
2138
+ issues.append(
2139
+ f"Pragmatic TDD compensating evidence path(s) do not exist: {', '.join(missing_pragmatic[:5])}"
2140
+ )
2141
+
2142
+ if file_path.name == "05-manual-qa.md":
2143
+ qa_record = get_heading_body(content, "QA Execution Record")
2144
+ evidence_body = get_heading_body(content, "Evidence and Artifacts")
2145
+ signoff_body = get_heading_body(content, "User Sign-Off")
2146
+ qa_mode = (get_md_field_value(qa_record, "QA Execution Mode") or get_md_field_value(content, "QA Execution Mode") or "").lower()
2147
+
2148
+ if not qa_record:
2149
+ issues.append("Missing or empty section: ## QA Execution Record")
2150
+ if qa_mode not in QA_EXECUTION_MODES:
2151
+ issues.append("QA Execution Record is missing QA Execution Mode: human|agent-operated|hybrid")
2152
+ else:
2153
+ if qa_mode in {"human", "hybrid"}:
2154
+ if not has_meaningful_value(get_md_field_value(signoff_body, "Approved by"), disallowed={"n/a", "not required", "none"}):
2155
+ issues.append(f"QA Execution Mode {qa_mode} requires User Sign-Off -> Approved by")
2156
+ if not has_meaningful_value(get_md_field_value(signoff_body, "Date"), disallowed={"n/a", "not required", "none"}):
2157
+ issues.append(f"QA Execution Mode {qa_mode} requires User Sign-Off -> Date")
2158
+
2159
+ if qa_mode in {"agent-operated", "hybrid"}:
2160
+ if not has_meaningful_value(get_md_field_value(qa_record, "Agent Executor"), disallowed={"n/a", "none"}):
2161
+ issues.append(f"QA Execution Mode {qa_mode} requires QA Execution Record -> Agent Executor")
2162
+ if not has_meaningful_value(get_md_field_value(qa_record, "Tools Used"), disallowed={"n/a", "none"}):
2163
+ issues.append(f"QA Execution Mode {qa_mode} requires QA Execution Record -> Tools Used")
2164
+
2165
+ qa_paths = collect_paths_under_prefix(f"{qa_record}\n{evidence_body}", f".recursive/run/{run_dir.name}/evidence/")
2166
+ if not qa_paths:
2167
+ issues.append(f"QA Execution Mode {qa_mode} requires evidence paths under `/.recursive/run/{run_dir.name}/evidence/`")
2168
+ else:
2169
+ missing_qa_paths = find_missing_repo_paths(repo_root, qa_paths)
2170
+ if missing_qa_paths:
2171
+ issues.append(f"QA evidence path(s) do not exist: {', '.join(missing_qa_paths[:5])}")
2172
+
2173
+ if file_path.name == "03.5-code-review.md":
2174
+ issues.extend(lint_review_bundle_reference(content, run_dir, repo_root))
2175
+
2176
+ if file_path.name == "08-memory-impact.md":
2177
+ issues.extend(lint_phase8_skill_usage_capture(content))
2178
+
2179
+ return sorted(set(issues))
2180
+
2181
+
2182
+ def get_artifact_required_sections(file_name: str, workflow_profile: str) -> list[str]:
2183
+ section_map: dict[str, list[str]] = {
2184
+ "00-worktree.md": [
2185
+ "TODO",
2186
+ "Directory Selection",
2187
+ "Safety Verification",
2188
+ "Worktree Creation",
2189
+ "Main Branch Protection",
2190
+ "Project Setup",
2191
+ "Test Baseline Verification",
2192
+ "Worktree Context",
2193
+ "Diff Basis For Later Audits",
2194
+ "Traceability",
2195
+ "Coverage Gate",
2196
+ "Approval Gate",
2197
+ ],
2198
+ "00-requirements.md": [
2199
+ "TODO",
2200
+ "Requirements",
2201
+ "Out of Scope",
2202
+ "Constraints",
2203
+ "Coverage Gate",
2204
+ "Approval Gate",
2205
+ ],
2206
+ "01-as-is.md": [
2207
+ "TODO",
2208
+ "Reproduction Steps (Novice-Runnable)",
2209
+ "Current Behavior by Requirement",
2210
+ "Source Requirement Inventory",
2211
+ "Relevant Code Pointers",
2212
+ "Known Unknowns",
2213
+ "Evidence",
2214
+ "Traceability",
2215
+ "Coverage Gate",
2216
+ "Approval Gate",
2217
+ ],
2218
+ "01.5-root-cause.md": [
2219
+ "TODO",
2220
+ "Error Analysis",
2221
+ "Reproduction Verification",
2222
+ "Recent Changes Analysis",
2223
+ "Evidence Gathering (Multi-Layer if applicable)",
2224
+ "Data Flow Trace",
2225
+ "Pattern Analysis",
2226
+ "Hypothesis Testing",
2227
+ "Root Cause Summary",
2228
+ "Traceability",
2229
+ "Coverage Gate",
2230
+ "Approval Gate",
2231
+ ],
2232
+ "02-to-be-plan.md": [
2233
+ "TODO",
2234
+ "Planned Changes by File",
2235
+ "Requirement Mapping",
2236
+ "Implementation Steps",
2237
+ "Testing Strategy",
2238
+ "Playwright Plan (if applicable)",
2239
+ "Manual QA Scenarios",
2240
+ "Idempotence and Recovery",
2241
+ "Implementation Sub-phases",
2242
+ "Plan Drift Check",
2243
+ "Traceability",
2244
+ "Coverage Gate",
2245
+ "Approval Gate",
2246
+ ],
2247
+ "03-implementation-summary.md": [
2248
+ "TODO",
2249
+ "Changes Applied",
2250
+ "TDD Compliance Log",
2251
+ "Plan Deviations",
2252
+ "Implementation Evidence",
2253
+ "Traceability",
2254
+ "Coverage Gate",
2255
+ "Approval Gate",
2256
+ ],
2257
+ "03.5-code-review.md": [
2258
+ "TODO",
2259
+ "Review Scope",
2260
+ "Plan Alignment Assessment",
2261
+ "Code Quality Assessment",
2262
+ "Issues Found",
2263
+ "Verdict",
2264
+ "Review Metadata",
2265
+ "Traceability",
2266
+ "Coverage Gate",
2267
+ "Approval Gate",
2268
+ ],
2269
+ "04-test-summary.md": [
2270
+ "TODO",
2271
+ "Pre-Test Implementation Audit",
2272
+ "Environment",
2273
+ "Execution Mode",
2274
+ "Commands Executed (Exact)",
2275
+ "Results Summary",
2276
+ "Evidence and Artifacts",
2277
+ "Failures and Diagnostics (if any)",
2278
+ "Flake/Rerun Notes",
2279
+ "Traceability",
2280
+ "Coverage Gate",
2281
+ "Approval Gate",
2282
+ ],
2283
+ "05-manual-qa.md": [
2284
+ "TODO",
2285
+ "QA Execution Record",
2286
+ "QA Scenarios and Results",
2287
+ "Evidence and Artifacts",
2288
+ "User Sign-Off",
2289
+ "Traceability",
2290
+ "Coverage Gate",
2291
+ "Approval Gate",
2292
+ ],
2293
+ "06-decisions-update.md": [
2294
+ "TODO",
2295
+ "Decisions Changes Applied",
2296
+ "Rationale",
2297
+ "Resulting Decision Entry",
2298
+ "Traceability",
2299
+ "Coverage Gate",
2300
+ "Approval Gate",
2301
+ ],
2302
+ "07-state-update.md": [
2303
+ "TODO",
2304
+ "State Changes Applied",
2305
+ "Rationale",
2306
+ "Resulting State Summary",
2307
+ "Traceability",
2308
+ "Coverage Gate",
2309
+ "Approval Gate",
2310
+ ],
2311
+ "08-memory-impact.md": [
2312
+ "TODO",
2313
+ "Diff Basis",
2314
+ "Changed Paths Review",
2315
+ "Affected Memory Docs",
2316
+ "Run-Local Skill Usage Capture",
2317
+ "Skill Memory Promotion Review",
2318
+ "Uncovered Paths",
2319
+ "Router and Parent Refresh",
2320
+ "Final Status Summary",
2321
+ "Traceability",
2322
+ "Coverage Gate",
2323
+ "Approval Gate",
2324
+ ],
2325
+ }
2326
+ headings = list(section_map.get(file_name, ["TODO", "Coverage Gate", "Approval Gate"]))
2327
+ if workflow_profile in STRICT_WORKFLOW_PROFILES and file_name in AUDITED_PHASE_FILES:
2328
+ headings.extend(AUDIT_REQUIRED_HEADINGS)
2329
+ if file_name in PRIOR_RECURSIVE_EVIDENCE_FILES:
2330
+ headings.append("Prior Recursive Evidence Reviewed")
2331
+ return headings
2332
+
2333
+
2334
+ def get_header_remediation_lines(missing_fields: list[str]) -> list[str]:
2335
+ out: list[str] = []
2336
+ for field in missing_fields:
2337
+ if field == "Run":
2338
+ out.append("Run: `/.recursive/run/<run-id>/`")
2339
+ elif field == "Phase":
2340
+ out.append("Phase: `<phase name>`")
2341
+ elif field == "Status":
2342
+ out.append("Status: `DRAFT`")
2343
+ elif field == "Inputs":
2344
+ out.append("Inputs:")
2345
+ out.append("- `<path>`")
2346
+ elif field == "Outputs":
2347
+ out.append("Outputs:")
2348
+ out.append("- `<path>`")
2349
+ elif field == "Scope note":
2350
+ out.append("Scope note: <one sentence describing what this artifact decides/enables>.")
2351
+ elif field == "LockedAt":
2352
+ out.append("LockedAt: `YYYY-MM-DDTHH:mm:ssZ`")
2353
+ elif field == "LockHash":
2354
+ out.append("LockHash: `<sha256-hex>`")
2355
+ return out
2356
+
2357
+
2358
+ def is_lock_valid_for_lint(file_path: Path, workflow_profile: str) -> bool:
2359
+ if not file_path.exists():
2360
+ return False
2361
+
2362
+ content = file_path.read_text(encoding="utf-8")
2363
+ status = get_md_field_value(content, "Status") or ""
2364
+ has_todo, _, _, unchecked = get_todo_stats(content)
2365
+ audit_required = workflow_profile in STRICT_WORKFLOW_PROFILES and file_path.name in AUDITED_PHASE_FILES
2366
+ audit_ok = (not audit_required) or get_gate_status(content, "Audit") == "PASS"
2367
+ run_dir = file_path.parent
2368
+ repo_root = run_dir.parent.parent.parent
2369
+ requirement_ids: list[str] = []
2370
+ requirements_path = run_dir / "00-requirements.md"
2371
+ if requirements_path.exists():
2372
+ requirement_ids = get_run_requirement_ids(run_dir, workflow_profile)
2373
+ actual_changed_files: list[str] | None = None
2374
+ if workflow_profile in STRICT_WORKFLOW_PROFILES:
2375
+ diff_basis = get_run_diff_basis(run_dir)
2376
+ raw_changed_files, _diff_basis_error = get_git_changed_files(repo_root, diff_basis)
2377
+ if raw_changed_files is not None:
2378
+ actual_changed_files = filter_runtime_changed_files(raw_changed_files, run_dir.name)
2379
+ phase_specific_issues = lint_phase_specific_rules(
2380
+ file_path,
2381
+ content,
2382
+ workflow_profile,
2383
+ run_dir,
2384
+ repo_root,
2385
+ requirement_ids,
2386
+ actual_changed_files,
2387
+ )
2388
+ tdd_gate_ok = file_path.name != "03-implementation-summary.md" or get_gate_status(content, "TDD Compliance") == "PASS"
2389
+ return (
2390
+ status == "LOCKED"
2391
+ and has_header_field(content, "LockedAt")
2392
+ and has_header_field(content, "LockHash")
2393
+ and get_gate_status(content, "Coverage") == "PASS"
2394
+ and get_gate_status(content, "Approval") == "PASS"
2395
+ and audit_ok
2396
+ and tdd_gate_ok
2397
+ and has_todo
2398
+ and unchecked == 0
2399
+ and not phase_specific_issues
2400
+ )
2401
+
2402
+
2403
+ def lint_traceability(file_path: Path, content: str, requirement_ids: list[str]) -> list[str]:
2404
+ issues: list[str] = []
2405
+ if file_path.name not in TRACEABILITY_REQUIRED_FILES:
2406
+ return issues
2407
+
2408
+ traceability_body = get_heading_body(content, "Traceability")
2409
+ if not traceability_body:
2410
+ issues.append("Missing Traceability section content")
2411
+ return issues
2412
+
2413
+ missing_ids = [requirement_id for requirement_id in requirement_ids if requirement_id not in traceability_body]
2414
+ if missing_ids:
2415
+ issues.append(f"Traceability is missing explicit coverage for: {', '.join(missing_ids)}")
2416
+
2417
+ if requirement_ids and not re.search(r"\bR\d+\b", traceability_body):
2418
+ issues.append("Traceability is vague and does not mention any requirement IDs")
2419
+
2420
+ return issues
2421
+
2422
+
2423
+ def lint_audit_sections(
2424
+ file_path: Path,
2425
+ content: str,
2426
+ workflow_profile: str,
2427
+ actual_changed_files: list[str] | None,
2428
+ diff_basis_error: str | None,
2429
+ run_id: str,
2430
+ run_dir: Path,
2431
+ ) -> list[str]:
2432
+ issues: list[str] = []
2433
+ if workflow_profile not in STRICT_WORKFLOW_PROFILES or file_path.name not in AUDITED_PHASE_FILES:
2434
+ return issues
2435
+
2436
+ audit_status = get_gate_status(content, "Audit")
2437
+ coverage_status = get_gate_status(content, "Coverage")
2438
+ approval_status = get_gate_status(content, "Approval")
2439
+
2440
+ if audit_status == "MISSING":
2441
+ issues.append("Missing required audit verdict line: Audit: PASS|FAIL")
2442
+ if coverage_status == "PASS" and audit_status != "PASS":
2443
+ issues.append("Coverage: PASS is invalid without Audit: PASS")
2444
+ if approval_status == "PASS" and audit_status != "PASS":
2445
+ issues.append("Approval: PASS is invalid without Audit: PASS")
2446
+
2447
+ audit_context = get_heading_body(content, "Audit Context")
2448
+ if not audit_context:
2449
+ issues.append("Audit Context section is empty")
2450
+ else:
2451
+ if get_md_field_value(audit_context, "Audit Execution Mode") not in {"subagent", "self-audit"}:
2452
+ issues.append("Audit Context is missing a valid Audit Execution Mode: subagent|self-audit")
2453
+ if get_md_field_value(audit_context, "Subagent Availability") not in {"available", "unavailable"}:
2454
+ issues.append("Audit Context is missing a valid Subagent Availability: available|unavailable")
2455
+ if not has_meaningful_value(get_md_field_value(audit_context, "Subagent Capability Probe"), disallowed={"n/a", "none"}):
2456
+ issues.append("Audit Context is missing Subagent Capability Probe")
2457
+ if not has_meaningful_value(get_md_field_value(audit_context, "Delegation Decision Basis"), disallowed={"n/a", "none"}):
2458
+ issues.append("Audit Context is missing Delegation Decision Basis")
2459
+ if get_md_field_value(audit_context, "Audit Inputs Provided") is None and "Audit Inputs Provided:" not in audit_context:
2460
+ issues.append("Audit Context is missing Audit Inputs Provided")
2461
+ issues.extend(collect_subagent_delegation_issues(audit_context))
2462
+
2463
+ for heading in AUDIT_REQUIRED_HEADINGS:
2464
+ section_body = get_heading_body(content, heading)
2465
+ if not section_body:
2466
+ issues.append(f"Missing or empty audited-phase section: ## {heading}")
2467
+ elif is_placeholder_only(section_body):
2468
+ issues.append(f"Audited-phase section still contains placeholder-only content: ## {heading}")
2469
+
2470
+ diff_audit_body = get_heading_body(content, "Worktree Diff Audit")
2471
+ for field in DIFF_BASIS_FIELDS:
2472
+ if get_md_field_value(diff_audit_body, field) is None:
2473
+ issues.append(f"Worktree Diff Audit is missing: {field}:")
2474
+
2475
+ gaps_body = get_heading_body(content, "Gaps Found")
2476
+ if audit_status == "PASS" and gaps_body and not re.search(r"\bnone\b", gaps_body, re.IGNORECASE):
2477
+ issues.append("Audit: PASS is invalid while Gaps Found still lists unresolved in-scope gaps")
2478
+
2479
+ expected_changed_files = get_phase_owned_actual_changed_files(file_path.name, actual_changed_files)
2480
+ if file_path.name in DIFF_AUDITED_FILES and expected_changed_files is not None:
2481
+ if diff_basis_error:
2482
+ issues.append(f"Cannot verify git diff basis: {diff_basis_error}")
2483
+ else:
2484
+ reviewed_paths = collect_reviewed_paths(run_dir, file_path.name, content)
2485
+ missing_paths = [path for path in expected_changed_files if path not in reviewed_paths]
2486
+ if missing_paths:
2487
+ preview = ", ".join(missing_paths[:5])
2488
+ suffix = " ..." if len(missing_paths) > 5 else ""
2489
+ issues.append(f"Worktree Diff Audit does not account for actual changed files from git diff: {preview}{suffix}")
2490
+
2491
+ return issues
2492
+
2493
+
2494
+ def lint_artifact_file(
2495
+ file_path: Path,
2496
+ run_dir: Path,
2497
+ repo_root: Path,
2498
+ workflow_profile: str,
2499
+ requirement_ids: list[str],
2500
+ actual_changed_files: list[str] | None,
2501
+ diff_basis_error: str | None,
2502
+ ) -> tuple[int, int]:
2503
+ content = file_path.read_text(encoding="utf-8")
2504
+ file_name = file_path.name
2505
+ status = get_md_field_value(content, "Status") or "UNKNOWN"
2506
+
2507
+ missing_header_fields = [
2508
+ field
2509
+ for field in ("Run", "Phase", "Status", "Inputs", "Outputs", "Scope note")
2510
+ if not has_header_field(content, field)
2511
+ ]
2512
+ if missing_header_fields:
2513
+ write_issue(
2514
+ "FAIL",
2515
+ file_path,
2516
+ f"Missing required header field(s): {', '.join(missing_header_fields)}",
2517
+ get_header_remediation_lines(missing_header_fields),
2518
+ )
2519
+ return 1, 0
2520
+
2521
+ fail_count = 0
2522
+ warn_count = 0
2523
+
2524
+ if status not in ("DRAFT", "LOCKED"):
2525
+ fail_count += 1
2526
+ write_issue("FAIL", file_path, f"Invalid Status value '{status}' (expected DRAFT or LOCKED)", ["Status: `DRAFT`"])
2527
+
2528
+ if status == "LOCKED":
2529
+ lock_missing = [field for field in ("LockedAt", "LockHash") if not has_header_field(content, field)]
2530
+ if lock_missing:
2531
+ fail_count += 1
2532
+ write_issue(
2533
+ "FAIL",
2534
+ file_path,
2535
+ f"Status is LOCKED but missing: {', '.join(lock_missing)}",
2536
+ get_header_remediation_lines(lock_missing),
2537
+ )
2538
+
2539
+ has_todo, _total, _checked, unchecked = get_todo_stats(content)
2540
+ if not has_todo:
2541
+ fail_count += 1
2542
+ write_issue("FAIL", file_path, "Missing required section: ## TODO", ["## TODO", "", "- [ ] <task 1>", "- [ ] <task 2>"])
2543
+ elif status == "LOCKED" and unchecked > 0:
2544
+ fail_count += 1
2545
+ write_issue(
2546
+ "FAIL",
2547
+ file_path,
2548
+ f"LOCKED artifact has unchecked TODO items: {unchecked}",
2549
+ ["# Option A: check all TODO boxes under ## TODO", "# Option B: set Status back to `DRAFT` until TODOs are complete"],
2550
+ )
2551
+
2552
+ for heading in get_artifact_required_sections(file_name, workflow_profile):
2553
+ if not has_heading(content, heading):
2554
+ fail_count += 1
2555
+ write_issue("FAIL", file_path, f"Missing required section heading: ## {heading}", [f"## {heading}", "", "<content>"])
2556
+
2557
+ for gate in ("Coverage", "Approval"):
2558
+ if not has_gate_line(content, gate):
2559
+ fail_count += 1
2560
+ write_issue("FAIL", file_path, f"Missing required gate line: {gate}: PASS|FAIL", [f"{gate}: FAIL"])
2561
+
2562
+ traceability_issues = lint_traceability(file_path, content, requirement_ids)
2563
+ for issue in traceability_issues:
2564
+ fail_count += 1
2565
+ write_issue("FAIL", file_path, issue)
2566
+
2567
+ audit_issues = lint_audit_sections(
2568
+ file_path,
2569
+ content,
2570
+ workflow_profile,
2571
+ actual_changed_files,
2572
+ diff_basis_error,
2573
+ run_dir.name,
2574
+ run_dir,
2575
+ )
2576
+ for issue in audit_issues:
2577
+ fail_count += 1
2578
+ write_issue("FAIL", file_path, issue)
2579
+
2580
+ phase_specific_issues = lint_phase_specific_rules(
2581
+ file_path,
2582
+ content,
2583
+ workflow_profile,
2584
+ run_dir,
2585
+ repo_root,
2586
+ requirement_ids,
2587
+ actual_changed_files,
2588
+ )
2589
+ for issue in phase_specific_issues:
2590
+ fail_count += 1
2591
+ write_issue("FAIL", file_path, issue)
2592
+
2593
+ if file_name in ("04-test-summary.md", "05-manual-qa.md"):
2594
+ evidence_dir = run_dir / "evidence"
2595
+ required_subdirs = ("screenshots", "logs", "perf", "traces")
2596
+ if not evidence_dir.exists():
2597
+ warn_count += 1
2598
+ remediation = [
2599
+ f'mkdir -p "{evidence_dir / "screenshots"}"',
2600
+ f'mkdir -p "{evidence_dir / "logs"}"',
2601
+ f'mkdir -p "{evidence_dir / "perf"}"',
2602
+ f'mkdir -p "{evidence_dir / "traces"}"',
2603
+ f'mkdir -p "{evidence_dir / "other"}"',
2604
+ ]
2605
+ write_issue("WARN", file_path, f"Evidence directory missing at {evidence_dir}", remediation)
2606
+ else:
2607
+ missing_subdirs = [subdir for subdir in required_subdirs if not (evidence_dir / subdir).exists()]
2608
+ if missing_subdirs:
2609
+ warn_count += 1
2610
+ remediation = [f'mkdir -p "{evidence_dir / subdir}"' for subdir in missing_subdirs]
2611
+ write_issue(
2612
+ "WARN",
2613
+ file_path,
2614
+ f"Evidence subfolder(s) missing under {evidence_dir}: {', '.join(missing_subdirs)}",
2615
+ remediation,
2616
+ )
2617
+
2618
+ return fail_count, warn_count
2619
+
2620
+
2621
+ def lint_memory_doc(file_path: Path) -> tuple[int, int]:
2622
+ content = file_path.read_text(encoding="utf-8")
2623
+ fail_count = 0
2624
+ warn_count = 0
2625
+
2626
+ missing_fields = [field for field in MEMORY_REQUIRED_FIELDS if not has_header_field(content, field)]
2627
+ if missing_fields:
2628
+ fail_count += 1
2629
+ write_issue("FAIL", file_path, f"Missing required memory metadata field(s): {', '.join(missing_fields)}")
2630
+ return fail_count, warn_count
2631
+
2632
+ memory_type = (get_md_field_value(content, "Type") or "").lower()
2633
+ if memory_type not in MEMORY_ALLOWED_TYPES:
2634
+ fail_count += 1
2635
+ write_issue("FAIL", file_path, f"Invalid memory Type '{memory_type}' (expected one of: {', '.join(sorted(MEMORY_ALLOWED_TYPES))})")
2636
+
2637
+ memory_status = (get_md_field_value(content, "Status") or "").upper()
2638
+ if memory_status not in MEMORY_ALLOWED_STATUSES:
2639
+ fail_count += 1
2640
+ write_issue("FAIL", file_path, f"Invalid memory Status '{memory_status}' (expected one of: {', '.join(sorted(MEMORY_ALLOWED_STATUSES))})")
2641
+
2642
+ if has_header_field(content, "Parent") and not get_md_field_value(content, "Parent"):
2643
+ warn_count += 1
2644
+ write_issue("WARN", file_path, "Parent metadata field is present but empty")
2645
+
2646
+ if has_header_field(content, "Superseded-By") and not get_md_field_value(content, "Superseded-By"):
2647
+ warn_count += 1
2648
+ write_issue("WARN", file_path, "Superseded-By metadata field is present but empty")
2649
+
2650
+ return fail_count, warn_count
2651
+
2652
+
2653
+ def lint_memory_plane(repo_root: Path) -> tuple[int, int]:
2654
+ fail_count = 0
2655
+ warn_count = 0
2656
+
2657
+ memory_root = repo_root / ".recursive" / "memory"
2658
+ router_path = memory_root / "MEMORY.md"
2659
+
2660
+ if not memory_root.exists():
2661
+ write_issue("FAIL", memory_root, "Memory plane directory is missing")
2662
+ return 1, 0
2663
+
2664
+ if not router_path.exists():
2665
+ fail_count += 1
2666
+ write_issue("FAIL", router_path, "Memory router file is missing")
2667
+
2668
+ for subdir in ("domains", "patterns", "incidents", "episodes", "archive", "skills"):
2669
+ full = memory_root / subdir
2670
+ if not full.exists():
2671
+ warn_count += 1
2672
+ write_issue("WARN", full, "Memory subdirectory is missing")
2673
+
2674
+ skill_router_path = memory_root / "skills" / "SKILLS.md"
2675
+ if not skill_router_path.exists():
2676
+ warn_count += 1
2677
+ write_issue("WARN", skill_router_path, "Skill memory router file is missing")
2678
+ else:
2679
+ for subdir in ("availability", "usage", "issues", "patterns"):
2680
+ full = memory_root / "skills" / subdir
2681
+ if not full.exists():
2682
+ warn_count += 1
2683
+ write_issue("WARN", full, "Skill memory subdirectory is missing")
2684
+
2685
+ for doc in sorted(memory_root.rglob("*.md")):
2686
+ if doc.name in SKILL_MEMORY_ROUTER_NAMES:
2687
+ continue
2688
+ doc_fail, doc_warn = lint_memory_doc(doc)
2689
+ fail_count += doc_fail
2690
+ warn_count += doc_warn
2691
+
2692
+ return fail_count, warn_count
2693
+
2694
+
2695
+ def main() -> None:
2696
+ parser = argparse.ArgumentParser(description="Lint recursive-mode run artifacts.")
2697
+ parser.add_argument("--run-id", default="", help="Run ID to lint (default: latest run).")
2698
+ parser.add_argument("--repo-root", default=".", help="Repository root path.")
2699
+ parser.add_argument("--all-runs", action="store_true", help="Lint all runs under .recursive/run/.")
2700
+ parser.add_argument("--strict", action="store_true", help="Treat WARN as FAIL.")
2701
+ args = parser.parse_args()
2702
+
2703
+ repo_root = Path(args.repo_root).resolve()
2704
+ run_root = repo_root / ".recursive" / "run"
2705
+ if not run_root.exists():
2706
+ print(f"[FAIL] recursive run directory not found at: {run_root}")
2707
+ print(" Is this the project repo root? (Expected .recursive/run/)")
2708
+ sys.exit(1)
2709
+
2710
+ if args.all_runs:
2711
+ run_dirs = [path for path in run_root.iterdir() if path.is_dir()]
2712
+ if not run_dirs:
2713
+ print(f"[FAIL] No runs found under: {run_root}")
2714
+ sys.exit(1)
2715
+ elif args.run_id.strip():
2716
+ run_dir = run_root / args.run_id.strip()
2717
+ if not run_dir.exists():
2718
+ print(f"[FAIL] Run directory not found: {run_dir}")
2719
+ sys.exit(1)
2720
+ run_dirs = [run_dir]
2721
+ else:
2722
+ latest = get_latest_run_directory(run_root)
2723
+ if latest is None:
2724
+ print(f"[FAIL] No runs found under: {run_root}")
2725
+ sys.exit(1)
2726
+ run_dirs = [latest]
2727
+
2728
+ total_fail = 0
2729
+ total_warn = 0
2730
+ artifacts = [
2731
+ "00-requirements.md",
2732
+ "00-worktree.md",
2733
+ "01-as-is.md",
2734
+ "01.5-root-cause.md",
2735
+ "02-to-be-plan.md",
2736
+ "03-implementation-summary.md",
2737
+ "03.5-code-review.md",
2738
+ "04-test-summary.md",
2739
+ "05-manual-qa.md",
2740
+ "06-decisions-update.md",
2741
+ "07-state-update.md",
2742
+ "08-memory-impact.md",
2743
+ ]
2744
+
2745
+ for run_dir in run_dirs:
2746
+ print(f"Linting run: {run_dir.name}")
2747
+ print(f"Path: {run_dir}")
2748
+ workflow_profile = get_workflow_profile(run_dir)
2749
+ print(f"Workflow Profile: {workflow_profile}")
2750
+ print()
2751
+
2752
+ requirements_path = run_dir / "00-requirements.md"
2753
+ requirement_ids: list[str] = []
2754
+ if requirements_path.exists():
2755
+ requirement_ids = get_run_requirement_ids(run_dir, workflow_profile)
2756
+ if not requirement_ids:
2757
+ total_fail += 1
2758
+ write_issue("FAIL", requirements_path, "Could not determine any requirement or source-inventory IDs for the run")
2759
+
2760
+ diff_basis = get_run_diff_basis(run_dir)
2761
+ actual_changed_files: list[str] | None = None
2762
+ diff_basis_error: str | None = None
2763
+ if workflow_profile in STRICT_WORKFLOW_PROFILES:
2764
+ raw_changed_files, diff_basis_error = get_git_changed_files(repo_root, diff_basis)
2765
+ if raw_changed_files is not None:
2766
+ actual_changed_files = filter_runtime_changed_files(raw_changed_files, run_dir.name)
2767
+
2768
+ expected_artifacts = list(artifacts)
2769
+ if workflow_profile == "legacy":
2770
+ expected_artifacts = [artifact for artifact in expected_artifacts if artifact not in LATE_PHASE_ARTIFACTS]
2771
+
2772
+ for artifact in expected_artifacts:
2773
+ artifact_path = run_dir / artifact
2774
+ if not artifact_path.exists():
2775
+ print(f"[WARN] Missing artifact (ok if not reached yet): {artifact_path}")
2776
+ total_warn += 1
2777
+ continue
2778
+
2779
+ fail_count, warn_count = lint_artifact_file(
2780
+ artifact_path,
2781
+ run_dir,
2782
+ repo_root,
2783
+ workflow_profile,
2784
+ requirement_ids,
2785
+ actual_changed_files,
2786
+ diff_basis_error,
2787
+ )
2788
+ total_fail += fail_count
2789
+ total_warn += warn_count
2790
+
2791
+ addenda_dir = run_dir / "addenda"
2792
+ if addenda_dir.exists():
2793
+ for addendum in sorted(addenda_dir.glob("*.md")):
2794
+ fail_count, warn_count = lint_artifact_file(
2795
+ addendum,
2796
+ run_dir,
2797
+ repo_root,
2798
+ workflow_profile,
2799
+ requirement_ids,
2800
+ actual_changed_files,
2801
+ diff_basis_error,
2802
+ )
2803
+ total_fail += fail_count
2804
+ total_warn += warn_count
2805
+
2806
+ subagents_dir = run_dir / "subagents"
2807
+ if subagents_dir.exists():
2808
+ for action_record in sorted(subagents_dir.glob("*.md")):
2809
+ issues = lint_subagent_action_record_file(action_record, repo_root, run_dir, actual_changed_files)
2810
+ for issue in issues:
2811
+ total_fail += 1
2812
+ write_issue("FAIL", action_record, issue)
2813
+
2814
+ # Phase-sequence prerequisite check: if a later artifact exists, all
2815
+ # earlier phases that also exist must already be LOCKED. This catches
2816
+ # backfilled or out-of-order artifact creation.
2817
+ phase_rules = load_phase_rules_module()
2818
+ for artifact in expected_artifacts:
2819
+ artifact_path = run_dir / artifact
2820
+ if not artifact_path.exists():
2821
+ continue
2822
+ blockers = phase_rules.get_prerequisite_blockers(artifact, run_dir)
2823
+ for blocker in blockers:
2824
+ total_fail += 1
2825
+ write_issue(
2826
+ "FAIL",
2827
+ artifact_path,
2828
+ f"Phase-sequence violation: {artifact!r} exists but prerequisite {blocker['artifact']!r} is {blocker['status']} (must be LOCKED first)",
2829
+ )
2830
+
2831
+ if workflow_profile in (STRICT_WORKFLOW_PROFILES | {COMPAT_WORKFLOW_PROFILE}):
2832
+ late_requirements = [
2833
+ ("05-manual-qa.md", "06-decisions-update.md"),
2834
+ ("06-decisions-update.md", "07-state-update.md"),
2835
+ ("07-state-update.md", "08-memory-impact.md"),
2836
+ ]
2837
+ for prior_artifact, next_artifact in late_requirements:
2838
+ if is_lock_valid_for_lint(run_dir / prior_artifact, workflow_profile) and not (run_dir / next_artifact).exists():
2839
+ total_fail += 1
2840
+ write_issue(
2841
+ "FAIL",
2842
+ run_dir / next_artifact,
2843
+ f"Run profile {workflow_profile} requires {next_artifact} after {prior_artifact} locks",
2844
+ )
2845
+
2846
+ memory_fail, memory_warn = lint_memory_plane(repo_root)
2847
+ total_fail += memory_fail
2848
+ total_warn += memory_warn
2849
+
2850
+ print("----")
2851
+ print()
2852
+
2853
+ print("Summary")
2854
+ print(f"- FAIL: {total_fail}")
2855
+ print(f"- WARN: {total_warn}")
2856
+ print()
2857
+
2858
+ effective_fail = total_fail + (total_warn if args.strict else 0)
2859
+ if effective_fail > 0:
2860
+ print("[FAIL] Lint failed")
2861
+ sys.exit(1)
2862
+
2863
+ print("[OK] Lint passed")
2864
+ sys.exit(0)
2865
+
2866
+
2867
+ if __name__ == "__main__":
2868
+ main()