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