@try-works/dsh-recursive-mode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +12 -0
- package/lib/bootstrap.d.ts +35 -0
- package/lib/client/board.d.ts +10 -0
- package/lib/client/contract.d.ts +51 -0
- package/lib/client/derive.d.ts +92 -0
- package/lib/client/index.d.ts +21 -0
- package/lib/client/inspector.d.ts +10 -0
- package/lib/client/node.d.ts +71 -0
- package/lib/client/settings.d.ts +6 -0
- package/lib/client/slots.d.ts +7 -0
- package/lib/client/strip.d.ts +7 -0
- package/lib/client.d.ts +10 -0
- package/lib/client.js +490 -0
- package/lib/closeout.d.ts +23 -0
- package/lib/commands.d.ts +51 -0
- package/lib/delegation.d.ts +92 -0
- package/lib/enforcement.d.ts +53 -0
- package/lib/events.d.ts +173 -0
- package/lib/handoff.d.ts +51 -0
- package/lib/index.d.ts +40 -0
- package/lib/lifecycle.d.ts +107 -0
- package/lib/lock.d.ts +92 -0
- package/lib/policy.d.ts +12 -0
- package/lib/projection.d.ts +29 -0
- package/lib/recursive_closeout.tool.d.ts +8 -0
- package/lib/recursive_init.tool.d.ts +2 -0
- package/lib/recursive_lint.tool.d.ts +2 -0
- package/lib/recursive_lock.tool.d.ts +2 -0
- package/lib/recursive_scratch.tool.d.ts +7 -0
- package/lib/recursive_status.tool.d.ts +2 -0
- package/lib/review.d.ts +39 -0
- package/lib/router.d.ts +77 -0
- package/lib/run.d.ts +29 -0
- package/lib/runtime.d.ts +241 -0
- package/lib/scratch.d.ts +18 -0
- package/lib/status.d.ts +19 -0
- package/lib/types.d.ts +104 -0
- package/lib/workspace.d.ts +50 -0
- package/package.json +119 -0
- package/preset/recursive/agent.cordis.yml +282 -0
- package/preset/recursive/preset.yml +3 -0
- package/scripts/install-recursive-mode.ps1 +956 -0
- package/scripts/install-recursive-mode.py +750 -0
- package/scripts/lint-recursive-run.py +2868 -0
- package/scripts/recursive-closeout.py +541 -0
- package/scripts/recursive-init.py +356 -0
- package/scripts/recursive-lock.py +302 -0
- package/scripts/recursive-status.py +2124 -0
- package/scripts/recursive_phase_rules.py +367 -0
- package/scripts/recursive_router_lib.py +2282 -0
- package/scripts/test-recursive-mode-smoke.ts +204 -0
- package/scripts/verify-locks.py +353 -0
- package/src/bootstrap.ts +118 -0
- package/src/client/board.tsx +61 -0
- package/src/client/contract.ts +58 -0
- package/src/client/derive.ts +241 -0
- package/src/client/index.ts +28 -0
- package/src/client/inspector.tsx +49 -0
- package/src/client/node.ts +156 -0
- package/src/client/settings.tsx +18 -0
- package/src/client/slots.ts +67 -0
- package/src/client/strip.tsx +28 -0
- package/src/client.ts +11 -0
- package/src/closeout.ts +183 -0
- package/src/commands.ts +142 -0
- package/src/delegation.ts +306 -0
- package/src/enforcement.ts +180 -0
- package/src/events.ts +173 -0
- package/src/handoff.ts +165 -0
- package/src/index.ts +283 -0
- package/src/lifecycle.ts +235 -0
- package/src/lock.ts +369 -0
- package/src/policy.ts +56 -0
- package/src/projection.ts +237 -0
- package/src/recursive_closeout.tool.ts +35 -0
- package/src/recursive_init.tool.ts +28 -0
- package/src/recursive_lint.tool.ts +29 -0
- package/src/recursive_lock.tool.ts +33 -0
- package/src/recursive_scratch.tool.ts +42 -0
- package/src/recursive_status.tool.ts +24 -0
- package/src/review.ts +178 -0
- package/src/router.ts +197 -0
- package/src/run.ts +85 -0
- package/src/runtime.ts +564 -0
- package/src/scratch.ts +85 -0
- package/src/status.ts +194 -0
- package/src/types.ts +112 -0
- package/src/workspace.ts +67 -0
|
@@ -0,0 +1,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()
|