@try-works/dsh-recursive-mode 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/lib/bootstrap.d.ts +6 -3
  2. package/lib/client/board.d.ts +4 -1
  3. package/lib/client/open-state.d.ts +20 -0
  4. package/lib/client/slots.d.ts +7 -1
  5. package/lib/client.js +135 -47
  6. package/lib/index.js +3391 -906
  7. package/lib/runtime.d.ts +12 -5
  8. package/lib/ts-lint.d.ts +205 -0
  9. package/package.json +1 -1
  10. package/src/bootstrap.ts +338 -331
  11. package/src/client/board.tsx +0 -0
  12. package/src/client/open-state.ts +46 -0
  13. package/src/client/slots.ts +41 -20
  14. package/src/commands.ts +11 -0
  15. package/src/index.ts +238 -228
  16. package/src/runtime.ts +19 -23
  17. package/src/ts-lint.ts +2110 -0
  18. package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  19. package/references/scripts/lint-recursive-run.ps1 +0 -25
  20. package/references/scripts/lint-recursive-run.py +0 -2870
  21. package/references/scripts/recursive-closeout.ps1 +0 -38
  22. package/references/scripts/recursive-closeout.py +0 -541
  23. package/references/scripts/recursive-init.ps1 +0 -515
  24. package/references/scripts/recursive-init.py +0 -356
  25. package/references/scripts/recursive-lock.ps1 +0 -26
  26. package/references/scripts/recursive-lock.py +0 -302
  27. package/references/scripts/recursive-review-bundle.ps1 +0 -58
  28. package/references/scripts/recursive-review-bundle.py +0 -503
  29. package/references/scripts/recursive-router-cli-configure.ps1 +0 -2
  30. package/references/scripts/recursive-router-cli-configure.py +0 -9
  31. package/references/scripts/recursive-router-cli-init.ps1 +0 -2
  32. package/references/scripts/recursive-router-cli-init.py +0 -9
  33. package/references/scripts/recursive-router-cli-invoke.ps1 +0 -2
  34. package/references/scripts/recursive-router-cli-invoke.py +0 -9
  35. package/references/scripts/recursive-router-cli-probe.ps1 +0 -2
  36. package/references/scripts/recursive-router-cli-probe.py +0 -9
  37. package/references/scripts/recursive-router-cli-resolve.ps1 +0 -2
  38. package/references/scripts/recursive-router-cli-resolve.py +0 -9
  39. package/references/scripts/recursive-router-cli-validate.ps1 +0 -2
  40. package/references/scripts/recursive-router-cli-validate.py +0 -9
  41. package/references/scripts/recursive-router-configure.ps1 +0 -27
  42. package/references/scripts/recursive-router-configure.py +0 -74
  43. package/references/scripts/recursive-router-init.ps1 +0 -17
  44. package/references/scripts/recursive-router-init.py +0 -29
  45. package/references/scripts/recursive-router-invoke.ps1 +0 -47
  46. package/references/scripts/recursive-router-invoke.py +0 -103
  47. package/references/scripts/recursive-router-probe.ps1 +0 -25
  48. package/references/scripts/recursive-router-probe.py +0 -44
  49. package/references/scripts/recursive-router-resolve.ps1 +0 -26
  50. package/references/scripts/recursive-router-resolve.py +0 -46
  51. package/references/scripts/recursive-router-validate.ps1 +0 -17
  52. package/references/scripts/recursive-router-validate.py +0 -27
  53. package/references/scripts/recursive-status.ps1 +0 -23
  54. package/references/scripts/recursive-status.py +0 -2124
  55. package/references/scripts/recursive-subagent-action.ps1 +0 -98
  56. package/references/scripts/recursive-subagent-action.py +0 -197
  57. package/references/scripts/recursive-training-extract.ps1 +0 -23
  58. package/references/scripts/recursive-training-extract.py +0 -99
  59. package/references/scripts/recursive-training-grpo.ps1 +0 -52
  60. package/references/scripts/recursive-training-grpo.py +0 -1341
  61. package/references/scripts/recursive-training-loader.ps1 +0 -78
  62. package/references/scripts/recursive-training-loader.py +0 -558
  63. package/references/scripts/recursive-training-mcp.ps1 +0 -29
  64. package/references/scripts/recursive-training-mcp.py +0 -261
  65. package/references/scripts/recursive-training-phase8-trigger.ps1 +0 -55
  66. package/references/scripts/recursive-training-phase8-trigger.py +0 -137
  67. package/references/scripts/recursive-training-sync.ps1 +0 -29
  68. package/references/scripts/recursive-training-sync.py +0 -183
  69. package/references/scripts/recursive_phase_rules.py +0 -367
  70. package/references/scripts/recursive_router_cli_lib.py +0 -2
  71. package/references/scripts/recursive_router_lib.py +0 -2282
  72. package/references/scripts/verify-locks.ps1 +0 -25
  73. package/references/scripts/verify-locks.py +0 -353
  74. package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
  75. package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  76. package/scripts/install-recursive-mode.ps1 +0 -956
  77. package/scripts/install-recursive-mode.py +0 -750
  78. package/scripts/lint-recursive-run.py +0 -2870
  79. package/scripts/recursive-closeout.py +0 -541
  80. package/scripts/recursive-init.py +0 -356
  81. package/scripts/recursive-lock.py +0 -302
  82. package/scripts/recursive-status.py +0 -2124
  83. package/scripts/recursive_phase_rules.py +0 -367
  84. package/scripts/recursive_router_lib.py +0 -2282
  85. package/scripts/verify-locks.py +0 -353
package/src/ts-lint.ts ADDED
@@ -0,0 +1,2110 @@
1
+ /**
2
+ * ts-lint.ts (R1, run 09): FULL TS port of the canonical lint-recursive-run.py.
3
+ * In-process lint with byte-identical [FAIL]/[WARN] verdicts + Summary.
4
+ * No python is shelled out anywhere in the plugin.
5
+ *
6
+ * Constants + per-phase required sections are imported from phase-rules.ts
7
+ * (single source of truth, already at 14/14 canonical parity).
8
+ */
9
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
10
+ import { join, basename, dirname } from 'node:path'
11
+ import { createHash } from 'node:crypto'
12
+ import { execFileSync } from 'node:child_process'
13
+ import {
14
+ CURRENT_WORKFLOW_PROFILE,
15
+ STRICT_WORKFLOW_PROFILE,
16
+ COMPAT_WORKFLOW_PROFILE,
17
+ STRICT_WORKFLOW_PROFILES,
18
+ LATE_PHASE_ARTIFACTS,
19
+ AUDITED_PHASE_FILES,
20
+ PRIOR_RECURSIVE_EVIDENCE_FILES,
21
+ DIFF_AUDITED_FILES,
22
+ TRACEABILITY_REQUIRED_FILES,
23
+ AUDIT_REQUIRED_HEADINGS,
24
+ DIFF_BASIS_FIELDS,
25
+ getArtifactRequiredSections,
26
+ } from './phase-rules.ts'
27
+
28
+ // ---- remaining constants not in phase-rules.ts ----
29
+ export const PRODUCT_DIFF_PHASE_FILES = new Set(['03-implementation-summary.md', '03.5-code-review.md', '04-test-summary.md'])
30
+ export const DECISIONS_DIFF_PHASE_FILES = new Set(['06-decisions-update.md'])
31
+ export const STATE_DIFF_PHASE_FILES = new Set(['07-state-update.md'])
32
+ export const MEMORY_DIFF_PHASE_FILES = new Set(['08-memory-impact.md'])
33
+ export const MEMORY_ALLOWED_TYPES = new Set(['index', 'domain', 'pattern', 'incident', 'episode'])
34
+ export const MEMORY_ALLOWED_STATUSES = new Set(['CURRENT', 'SUSPECT', 'STALE', 'DEPRECATED', 'DRAFT'])
35
+ export const MEMORY_REQUIRED_FIELDS = [
36
+ 'Type', 'Status', 'Scope', 'Owns-Paths', 'Watch-Paths', 'Source-Runs',
37
+ 'Validated-At-Commit', 'Last-Validated', 'Tags',
38
+ ]
39
+ export const TDD_MODES = new Set(['strict', 'pragmatic'])
40
+ export const QA_EXECUTION_MODES = new Set(['human', 'agent-operated', 'hybrid'])
41
+ export const TRANSIENT_RUNTIME_DIR_MARKERS = new Set([
42
+ '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.hypothesis',
43
+ '.tox', '.nox', '.target', '.playwright-mcp', '.cargo-target-dir',
44
+ ])
45
+ export const TRANSIENT_RUNTIME_FILE_NAMES = new Set(['.ds_store', 'thumbs.db'])
46
+ export const TRANSIENT_RUNTIME_SUFFIXES = ['.pyc', '.pyo', '.pyd']
47
+ export const DIFF_BASIS_ALLOWED_TYPES = new Set(['local commit', 'local branch', 'remote ref', 'merge-base derived'])
48
+ export const WORKING_TREE_COMPARISON_REFS = new Set(['working-tree', 'working-tree@head', 'worktree', 'working-tree+head'])
49
+ export const INVENTORY_DISPOSITIONS = new Set(['in-scope', 'out-of-scope', 'constraint', 'quality-gate'])
50
+ export const PHASE2_REQUIREMENT_DISPOSITION_STATUSES = new Set([
51
+ 'planned', 'planned-via-merge', 'planned-indirectly', 'deferred', 'out-of-scope', 'blocked',
52
+ 'superseded by approved addendum',
53
+ ])
54
+ export const REQUIREMENT_DISPOSITION_STATUSES = new Set([
55
+ 'implemented', 'verified', 'deferred', 'out-of-scope', 'blocked', 'superseded by approved addendum',
56
+ ])
57
+ export const SKILL_USAGE_RELEVANCE_STATUSES = new Set(['relevant', 'not-relevant', 'yes', 'no'])
58
+ export const FINAL_REQUIREMENT_DISPOSITION_FILES = new Set([
59
+ '04-test-summary.md', '06-decisions-update.md', '07-state-update.md', '08-memory-impact.md',
60
+ ])
61
+ export const REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES = new Set([
62
+ '03-implementation-summary.md', '03.5-code-review.md', '04-test-summary.md',
63
+ ])
64
+ export const SUBAGENT_ACTION_REQUIRED_HEADINGS = [
65
+ 'Metadata', 'Inputs Provided', 'Claimed Actions Taken', 'Claimed File Impact',
66
+ 'Claimed Artifact Impact', 'Claimed Findings', 'Verification Handoff',
67
+ ]
68
+ export const SKILL_MEMORY_ROUTER_NAMES = new Set(['MEMORY.md', 'SKILLS.md'])
69
+ export const RUN_ARTIFACT_SEQUENCE = [
70
+ '00-requirements.md', '00-worktree.md', '01-as-is.md', '01.5-root-cause.md', '02-to-be-plan.md',
71
+ '03-implementation-summary.md', '03.5-code-review.md', '04-test-summary.md', '05-manual-qa.md',
72
+ '06-decisions-update.md', '07-state-update.md', '08-memory-impact.md',
73
+ ]
74
+ export const REQUIREMENT_ID_RE = /^(R\d+|SRC-\d{3})$/
75
+
76
+ export interface LintIssue {
77
+ severity: 'FAIL' | 'WARN'
78
+ filePath: string
79
+ message: string
80
+ }
81
+
82
+ export interface LintResult {
83
+ errors: string[]
84
+ warnings: string[]
85
+ failCount: number
86
+ warnCount: number
87
+ passed: boolean
88
+ summary: string
89
+ }
90
+
91
+ /** write_issue: collect [severity] path: message + remediation lines. */
92
+ const issues: LintIssue[] = []
93
+ function writeIssue(severity: 'FAIL' | 'WARN', filePath: string, message: string, remediationLines?: string[]): void {
94
+ issues.push({ severity, filePath, message })
95
+ console.log(`[${severity}] ${filePath}: ${message}`)
96
+ if (remediationLines) {
97
+ console.log('Remediation (copy/paste):')
98
+ for (const line of remediationLines) console.log(' ' + line)
99
+ }
100
+ console.log()
101
+ }
102
+
103
+ /** trim_md_value: strip surrounding quotes/backticks. */
104
+ export function trimMdValue(value: string): string {
105
+ let trimmed = value.trim()
106
+ for (const quote of ['`', '"', "'"]) {
107
+ if (trimmed.startsWith(quote) && trimmed.endsWith(quote) && trimmed.length >= 2) {
108
+ const inner = trimmed.slice(1, -1)
109
+ if (!inner.includes(quote)) return inner.trim()
110
+ }
111
+ }
112
+ return trimmed
113
+ }
114
+
115
+ /** get_md_field_value: first `Field: value` line. */
116
+ export function getMdFieldValue(content: string, fieldName: string): string | null {
117
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*(.+?)\\s*$`, 'm')
118
+ const m = content.match(re)
119
+ if (!m) return null
120
+ return trimMdValue(m[1])
121
+ }
122
+
123
+ /** has_header_field: any `Field:` line. */
124
+ export function hasHeaderField(content: string, fieldName: string): boolean {
125
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:`, 'm')
126
+ return re.test(content)
127
+ }
128
+
129
+ /** has_heading: `## Heading` line. */
130
+ export function hasHeading(content: string, headingText: string): boolean {
131
+ const re = new RegExp(`^[ \\t]*##\\s+${escapeRegExp(headingText)}\\s*$`, 'm')
132
+ return re.test(content)
133
+ }
134
+
135
+ /** get_heading_body: text under `## Heading` until next heading or end. */
136
+ export function getHeadingBody(content: string, headingText: string): string {
137
+ const re = new RegExp(`^[ \\t]*##\\s+${escapeRegExp(headingText)}\\s*$\\n?(.*?)(?=^[ \\t]*##\\s+|\Z)`, 'ms')
138
+ const m = content.match(re)
139
+ if (!m) return ''
140
+ return (m[1] ?? '').trim()
141
+ }
142
+
143
+ /** get_subheading_body: text under `### Heading` (or level N) until next N-or-less heading. */
144
+ export function getSubheadingBody(content: string, headingText: string, level = 3): string {
145
+ const hashes = '#'.repeat(level)
146
+ const re = new RegExp(`^[ \\t]*${escapeRegExp(hashes)}\\s+${escapeRegExp(headingText)}\\s*$\\n?(.*?)(?=^[ \\t]*#{1,${level}}\\s+|\Z)`, 'ms')
147
+ const m = content.match(re)
148
+ if (!m) return ''
149
+ return (m[1] ?? '').trim()
150
+ }
151
+
152
+ /** has_gate_line: `Gate: PASS|FAIL` line. */
153
+ export function hasGateLine(content: string, gateName: string): boolean {
154
+ const re = new RegExp(`^[ \\t]*${escapeRegExp(gateName)}:\\s*(PASS|FAIL)\\s*$`, 'm')
155
+ return re.test(content)
156
+ }
157
+
158
+ /** get_gate_status: PASS|FAIL|MISSING. */
159
+ export function getGateStatus(content: string, gateName: string): string {
160
+ const re = new RegExp(`^[ \\t]*${escapeRegExp(gateName)}:\\s*(PASS|FAIL)\\s*$`, 'm')
161
+ const m = content.match(re)
162
+ return m ? m[1].toUpperCase() : 'MISSING'
163
+ }
164
+
165
+ /** get_todo_stats: [has_todo, total, checked, unchecked] under ## TODO. */
166
+ export function getTodoStats(content: string): [boolean, number, number, number] {
167
+ const lines = content.split(/\r?\n/)
168
+ let inTodo = false, hasTodo = false, checked = 0, unchecked = 0, total = 0
169
+ for (const line of lines) {
170
+ if (!inTodo) {
171
+ if (/^\s*##\s+TODO\s*$/.test(line)) { inTodo = true; hasTodo = true }
172
+ continue
173
+ }
174
+ if (/^\s*##\s+/.test(line) || /^\s*#\s+/.test(line)) break
175
+ const item = line.match(/^\s*[-*]\s+\[([ xX])\]\s+/)
176
+ if (item) {
177
+ total += 1
178
+ if (item[1].toLowerCase() === 'x') checked += 1
179
+ else unchecked += 1
180
+ }
181
+ }
182
+ return [hasTodo, total, checked, unchecked]
183
+ }
184
+
185
+ /** get_latest_run_directory: newest mtime subdir of run_root. */
186
+ export function getLatestRunDirectory(runRoot: string): string | null {
187
+ let runs: string[] = []
188
+ try { runs = readdirSync(runRoot, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) } catch { return null }
189
+ if (runs.length === 0) return null
190
+ runs.sort((a, b) => statSync(join(runRoot, b)).mtimeMs - statSync(join(runRoot, a)).mtimeMs)
191
+ return runs[0]
192
+ }
193
+
194
+ /** get_workflow_profile: from Workflow version field or late-phase presence. */
195
+ export function getWorkflowProfile(runDir: string): string {
196
+ const reqPath = join(runDir, '00-requirements.md')
197
+ if (existsSync(reqPath)) {
198
+ const content = readFileSync(reqPath, 'utf8')
199
+ const wf = getMdFieldValue(content, 'Workflow version')
200
+ if (wf === CURRENT_WORKFLOW_PROFILE) return CURRENT_WORKFLOW_PROFILE
201
+ if (wf === STRICT_WORKFLOW_PROFILE) return STRICT_WORKFLOW_PROFILE
202
+ if (wf === COMPAT_WORKFLOW_PROFILE) return COMPAT_WORKFLOW_PROFILE
203
+ }
204
+ if (LATE_PHASE_ARTIFACTS.some(a => existsSync(join(runDir, a)))) return COMPAT_WORKFLOW_PROFILE
205
+ return 'legacy'
206
+ }
207
+
208
+ /** is_placeholder_only: empty or placeholder token. */
209
+ export function isPlaceholderOnly(text: string): boolean {
210
+ const compact = text.trim()
211
+ if (!compact) return true
212
+ return compact === '...' || compact === '<content>' || compact === '[...]' || compact === '[same structure]'
213
+ }
214
+
215
+ /** parse_requirement_ids: Rd+ ids from Requirements heading. */
216
+ export function parseRequirementIds(requirementsContent: string): string[] {
217
+ const body = getHeadingBody(requirementsContent, 'Requirements') || requirementsContent
218
+ const ids = [...new Set(body.match(/\bR\d+\b/g) ?? [])]
219
+ ids.sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10))
220
+ return ids
221
+ }
222
+
223
+ /** requirement_sort_key: Rd+ < SRC-d{3} < other. */
224
+ export function requirementSortKey(value: string): [number, number | string, string] {
225
+ if (/^R\d+$/.test(value)) return [0, parseInt(value.slice(1), 10), value]
226
+ if (/^SRC-\d{3}$/.test(value)) return [1, parseInt(value.split('-')[1], 10), value]
227
+ return [2, value, value]
228
+ }
229
+
230
+ /** normalize_source_text: collapse whitespace, strip quotes/backticks, lowercase. */
231
+ export function normalizeSourceText(value: string): string {
232
+ return value.replace(/`/g, '').replace(/"/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
233
+ }
234
+
235
+ /** source_quote_matches: normalized quote appears in requirements content. */
236
+ export function sourceQuoteMatches(sourceQuote: string, requirementsContent: string): boolean {
237
+ const nq = normalizeSourceText(sourceQuote)
238
+ const nr = normalizeSourceText(requirementsContent)
239
+ return Boolean(nq) && nr.includes(nq)
240
+ }
241
+
242
+ /** escapeRegExp helper. */
243
+ export function escapeRegExp(s: string): string {
244
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
245
+ }
246
+
247
+ /** content_sha256: sha256 hex of UTF-8 content (LF-normalized). */
248
+ export function contentSha256(content: string): string {
249
+ return createHash('sha256').update(content.replace(/\r\n/g, '\n'), 'utf8').digest('hex')
250
+ }
251
+
252
+ /** extract_paths_from_text: backtick paths with / or . in name, non-git, non-placeholder. */
253
+ export function extractPathsFromText(text: string): Set<string> {
254
+ const paths = new Set<string>()
255
+ const re = /`([^`\n]+)`/g
256
+ let m: RegExpExecArray | null
257
+ while ((m = re.exec(text)) !== null) {
258
+ const candidate = m[1].trim().replace(/\\/g, '/').replace(/^\/+/, '')
259
+ if (!candidate || candidate.toLowerCase().startsWith('git ')) continue
260
+ if (candidate.startsWith('<') && candidate.endsWith('>')) continue
261
+ const name = candidate.split('/').pop() ?? ''
262
+ if (candidate.includes('/') || name.includes('.')) paths.add(candidate)
263
+ }
264
+ return paths
265
+ }
266
+
267
+ /** extract_paths_from_field_value: backtick paths, else split on ,; newline. */
268
+ export function extractPathsFromFieldValue(text: string): Set<string> {
269
+ const paths = extractPathsFromText(text)
270
+ if (paths.size > 0) return paths
271
+ const discovered = new Set<string>()
272
+ for (const candidateRaw of trimMdValue(text).split(/[,;\n]/)) {
273
+ const candidate = candidateRaw.trim().replace(/\\/g, '/').replace(/^\/+/, '')
274
+ if (!candidate || candidate.toLowerCase().startsWith('git ')) continue
275
+ if (candidate.startsWith('<') && candidate.endsWith('>')) continue
276
+ const name = candidate.split('/').pop() ?? ''
277
+ if (candidate.includes('/') || name.includes('.')) discovered.add(candidate)
278
+ }
279
+ return discovered
280
+ }
281
+
282
+ /** extract_paths_from_named_field: inline value or block body until next field. */
283
+ export function extractPathsFromNamedField(content: string, fieldName: string): Set<string> {
284
+ const inlineValue = getMdFieldValue(content, fieldName)
285
+ if (inlineValue !== null) return extractPathsFromFieldValue(inlineValue)
286
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*$\\n(.*?)(?=^[ \\t]*(?:[-*][ \\t]+)?[A-Za-z][^:\\n]*:[ \\t]*|\Z)`, 'ms')
287
+ const m = content.match(re)
288
+ if (!m) return new Set()
289
+ const out = new Set<string>()
290
+ for (const path of extractPathsFromText(m[1] ?? '')) {
291
+ const normalized = normalizeRepoPath(path)
292
+ if (normalized) out.add(normalized)
293
+ }
294
+ return out
295
+ }
296
+
297
+ /** get_named_field_text: inline value or block body until next field. */
298
+ export function getNamedFieldText(content: string, fieldName: string): string | null {
299
+ const inlineValue = getMdFieldValue(content, fieldName)
300
+ if (inlineValue !== null) return inlineValue
301
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*$\\n(.*?)(?=^[ \\t]*(?:[-*][ \\t]+)?[A-Za-z][^:\\n]*:[ \\t]*|\Z)`, 'ms')
302
+ const m = content.match(re)
303
+ if (!m) return null
304
+ return (m[1] ?? '').trim()
305
+ }
306
+
307
+ /** has_meaningful_value: non-empty and not in disallowed set. */
308
+ export function hasMeaningfulValue(value: string | null, disallowed?: Set<string>): boolean {
309
+ if (value === null) return false
310
+ const normalized = trimMdValue(value).trim()
311
+ if (!normalized) return false
312
+ return !(disallowed ?? new Set()).has(normalized.toLowerCase())
313
+ }
314
+
315
+ /** collect_subagent_delegation_issues: audit-context consistency rules. */
316
+ export function collectSubagentDelegationIssues(auditContext: string): string[] {
317
+ const issues: string[] = []
318
+ const mode = getMdFieldValue(auditContext, 'Audit Execution Mode')
319
+ const availability = getMdFieldValue(auditContext, 'Subagent Availability')
320
+ const overrideReason = getMdFieldValue(auditContext, 'Delegation Override Reason')
321
+ if (availability === 'unavailable' && mode === 'subagent') {
322
+ issues.push('Audit Context cannot claim Audit Execution Mode: subagent when Subagent Availability is unavailable')
323
+ }
324
+ if (availability === 'available' && mode === 'self-audit') {
325
+ if (!hasMeaningfulValue(overrideReason, new Set(['n/a', 'none']))) {
326
+ issues.push('Audit Context is missing Delegation Override Reason even though subagents were available and self-audit was chosen')
327
+ }
328
+ }
329
+ return issues
330
+ }
331
+
332
+ /** collect_paths_under_prefix: sorted paths under a prefix. */
333
+ export function collectPathsUnderPrefix(text: string, prefix: string): string[] {
334
+ return [...extractPathsFromText(text)].filter(p => p.startsWith(prefix)).sort()
335
+ }
336
+
337
+ /** find_missing_repo_paths: paths not existing under repo_root. */
338
+ export function findMissingRepoPaths(repoRoot: string, paths: string[]): string[] {
339
+ return paths.filter(p => !existsSync(join(repoRoot, p)))
340
+ }
341
+
342
+ /** normalize_repo_path: backslash->slash, strip leading /. */
343
+ export function normalizeRepoPath(rawPath: string): string {
344
+ return rawPath.replace(/\\/g, '/').trim().replace(/^\/+/, '')
345
+ }
346
+
347
+ /** is_addendum_artifact: file name contains .addendum-. */
348
+ export function isAddendumArtifact(fileName: string): boolean {
349
+ return fileName.includes('.addendum-')
350
+ }
351
+
352
+ /** is_transient_runtime_path: __pycache__, .pyc, etc. */
353
+ export function isTransientRuntimePath(normalizedPath: string): boolean {
354
+ const candidate = normalizeRepoPath(normalizedPath)
355
+ if (!candidate) return false
356
+ const parts = candidate.split('/')
357
+ if (parts.some(p => TRANSIENT_RUNTIME_DIR_MARKERS.has(p))) return true
358
+ const fileName = parts[parts.length - 1].toLowerCase()
359
+ if (TRANSIENT_RUNTIME_FILE_NAMES.has(fileName)) return true
360
+ return TRANSIENT_RUNTIME_SUFFIXES.some(s => fileName.endsWith(s))
361
+ }
362
+
363
+ /** filter_runtime_changed_files: drop the run's own dir + transient paths. */
364
+ export function filterRuntimeChangedFiles(paths: string[], runId: string): string[] {
365
+ const filtered: string[] = []
366
+ for (const rawPath of paths) {
367
+ const normalized = normalizeRepoPath(rawPath)
368
+ if (!normalized) continue
369
+ if (normalized.startsWith(`.recursive/run/${runId}/`)) continue
370
+ if (isTransientRuntimePath(normalized)) continue
371
+ filtered.push(normalized)
372
+ }
373
+ return [...new Set(filtered)].sort()
374
+ }
375
+
376
+ /** run_git: run git -C repo args, return [stdout, error]. */
377
+ export function runGit(repoRoot: string, ...args: string[]): [string | null, string | null] {
378
+ try {
379
+ const out = execFileSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
380
+ return [String(out).trim(), null]
381
+ } catch (err) {
382
+ const e = err as { stdout?: Buffer | string; stderr?: Buffer | string; status?: number }
383
+ if (e && typeof e === 'object' && 'status' in e) {
384
+ const stderr = (e.stderr ?? '').toString().trim()
385
+ const stdout = (e.stdout ?? '').toString().trim()
386
+ const message = stderr || stdout || `git ${args.join(' ')} failed`
387
+ return [null, message]
388
+ }
389
+ return [null, `Unable to execute git: ${String(err)}`]
390
+ }
391
+ }
392
+
393
+ /** normalize_baseline_type: canonicalize baseline type with aliases. */
394
+ export function normalizeBaselineType(value: string | null): string | null {
395
+ if (value === null) return null
396
+ const compact = trimMdValue(value).trim().toLowerCase().replace(/-/g, ' ').replace(/\s+/g, ' ')
397
+ if (DIFF_BASIS_ALLOWED_TYPES.has(compact)) return compact
398
+ const aliases: Record<string, string> = {
399
+ 'commit': 'local commit', 'branch': 'local branch', 'remote': 'remote ref',
400
+ 'remote branch': 'remote ref', 'merge base': 'merge-base derived',
401
+ }
402
+ return aliases[compact] ?? null
403
+ }
404
+
405
+ /** normalize_comparison_reference: map working-tree refs. */
406
+ export function normalizeComparisonReference(value: string | null): string | null {
407
+ if (value === null) return null
408
+ const compact = trimMdValue(value).trim()
409
+ if (!compact) return null
410
+ if (WORKING_TREE_COMPARISON_REFS.has(compact.toLowerCase())) return 'working-tree'
411
+ return compact
412
+ }
413
+
414
+ /** parse_diff_basis_source: heading body of Diff Basis For Later Audits / Diff Basis. */
415
+ export function parseDiffBasisSource(content: string): string {
416
+ let body = getHeadingBody(content, 'Diff Basis For Later Audits')
417
+ if (body) return body
418
+ body = getHeadingBody(content, 'Diff Basis')
419
+ if (body) return body
420
+ return content
421
+ }
422
+
423
+ /** get_run_diff_basis: read diff-basis fields from 00-worktree.md. */
424
+ export function getRunDiffBasis(runDir: string): Record<string, string | null> {
425
+ const worktreePath = join(runDir, '00-worktree.md')
426
+ if (!existsSync(worktreePath)) {
427
+ return { baseline_type: null, baseline_reference: null, comparison_reference: null, normalized_baseline: null, normalized_comparison: null, normalized_diff_command: null, base_branch: null, worktree_branch: null, notes: null }
428
+ }
429
+ const content = readFileSync(worktreePath, 'utf8')
430
+ const source = parseDiffBasisSource(content)
431
+ return {
432
+ baseline_type: getMdFieldValue(source, 'Baseline type'),
433
+ baseline_reference: getMdFieldValue(source, 'Baseline reference'),
434
+ comparison_reference: getMdFieldValue(source, 'Comparison reference'),
435
+ normalized_baseline: getMdFieldValue(source, 'Normalized baseline') ?? getMdFieldValue(source, 'Normalized baseline commit') ?? getMdFieldValue(source, 'Base commit'),
436
+ normalized_comparison: getMdFieldValue(source, 'Normalized comparison') ?? getMdFieldValue(source, 'Normalized comparison reference') ?? getMdFieldValue(source, 'Worktree branch'),
437
+ normalized_diff_command: getMdFieldValue(source, 'Normalized diff command') ?? getMdFieldValue(source, 'Diff command convention'),
438
+ base_branch: getMdFieldValue(source, 'Base branch'),
439
+ worktree_branch: getMdFieldValue(source, 'Worktree branch'),
440
+ notes: getMdFieldValue(source, 'Diff basis notes') ?? getMdFieldValue(source, 'Notes'),
441
+ }
442
+ }
443
+
444
+ /** normalize_diff_basis: validate + compute the executable diff basis. */
445
+ export function normalizeDiffBasis(repoRoot: string, diffBasis: Record<string, string | null>): [Record<string, string> | null, string | null] {
446
+ const baselineType = normalizeBaselineType(diffBasis.baseline_type)
447
+ const baselineReference = trimMdValue(diffBasis.baseline_reference ?? '')
448
+ const comparisonReference = normalizeComparisonReference(diffBasis.comparison_reference)
449
+ const normalizedBaseline = trimMdValue(diffBasis.normalized_baseline ?? '')
450
+ const normalizedComparison = normalizeComparisonReference(diffBasis.normalized_comparison)
451
+ const normalizedDiffCommand = trimMdValue(diffBasis.normalized_diff_command ?? '')
452
+
453
+ const missingFields: string[] = []
454
+ if (!baselineType) missingFields.push('Baseline type')
455
+ if (!baselineReference) missingFields.push('Baseline reference')
456
+ if (!comparisonReference) missingFields.push('Comparison reference')
457
+ if (!normalizedBaseline) missingFields.push('Normalized baseline')
458
+ if (!normalizedComparison) missingFields.push('Normalized comparison')
459
+ if (!normalizedDiffCommand) missingFields.push('Normalized diff command')
460
+ if (missingFields.length > 0) return [null, `Diff basis is missing required field(s): ${missingFields.join(', ')}`]
461
+
462
+ const baselineTypeN = baselineType!
463
+ const baselineReferenceN = baselineReference!
464
+ const comparisonReferenceN = comparisonReference!
465
+ const normalizedBaselineN = normalizedBaseline!
466
+ const normalizedComparisonN = normalizedComparison!
467
+ const normalizedDiffCommandN = normalizedDiffCommand!
468
+ const comparisonGitRef = normalizedComparisonN === 'working-tree' ? 'HEAD' : normalizedComparisonN
469
+ let computedBaseline: string | null
470
+ let error: string | null
471
+ if (baselineType === 'merge-base derived') {
472
+ const [out, err] = runGit(repoRoot, 'merge-base', comparisonGitRef, baselineReferenceN)
473
+ computedBaseline = out
474
+ error = err ? `Unable to compute merge-base for diff basis: ${err}` : null
475
+ } else {
476
+ const [out, err] = runGit(repoRoot, 'rev-parse', '--verify', `${baselineReferenceN}^{commit}`)
477
+ computedBaseline = out
478
+ error = err ? `Unable to resolve baseline reference '${baselineReference}': ${err}` : null
479
+ }
480
+ if (error || computedBaseline === null) return [null, error ?? 'Unable to compute diff basis']
481
+ if (normalizedBaselineN !== computedBaseline) {
482
+ return [null, `Recorded Normalized baseline does not match the executable diff basis (${normalizedBaselineN} != ${computedBaseline})`]
483
+ }
484
+
485
+ let expectedCommand: string
486
+ let gitArgs: string[]
487
+ if (normalizedComparisonN === 'working-tree') {
488
+ expectedCommand = `git diff --name-only ${computedBaseline}`
489
+ gitArgs = ['diff', '--name-only', computedBaseline]
490
+ } else {
491
+ const [computedComparison, cmpError] = runGit(repoRoot, 'rev-parse', '--verify', `${normalizedComparisonN}^{commit}`)
492
+ if (cmpError || computedComparison === null) return [null, `Unable to resolve comparison reference '${normalizedComparison}': ${cmpError}`]
493
+ expectedCommand = `git diff --name-only ${computedBaseline}..${computedComparison}`
494
+ gitArgs = ['diff', '--name-only', `${computedBaseline}..${computedComparison}`]
495
+ if (normalizedComparisonN !== computedComparison) {
496
+ return [null, `Recorded Normalized comparison does not match the executable diff basis (${normalizedComparisonN} != ${computedComparison})`]
497
+ }
498
+ }
499
+ if (normalizedDiffCommandN !== expectedCommand) {
500
+ return [null, `Recorded Normalized diff command does not match the executable diff basis (${normalizedDiffCommandN} != ${expectedCommand})`]
501
+ }
502
+
503
+ return [{
504
+ baseline_type: baselineTypeN,
505
+ baseline_reference: baselineReferenceN,
506
+ comparison_reference: comparisonReferenceN,
507
+ normalized_baseline: computedBaseline,
508
+ normalized_comparison: normalizedComparisonN,
509
+ normalized_diff_command: expectedCommand,
510
+ comparison_git_ref: comparisonGitRef,
511
+ git_args: gitArgs.join(' '),
512
+ }, null]
513
+ }
514
+
515
+ /** get_git_changed_files: git diff --name-only + untracked (--relative). */
516
+ export function getGitChangedFiles(repoRoot: string, diffBasis: Record<string, string | null>): [string[] | null, string | null] {
517
+ const [normalizedBasis, basisError] = normalizeDiffBasis(repoRoot, diffBasis)
518
+ if (basisError) return [null, basisError]
519
+ const gitArgs = (normalizedBasis!.git_args ?? '').split(' ').filter(Boolean)
520
+ try {
521
+ const diffOut = execFileSync('git', ['-C', repoRoot, ...gitArgs, '--relative'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
522
+ const untrackedOut = execFileSync('git', ['-C', repoRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
523
+ const tracked = String(diffOut).split(/\r?\n/).map(s => s.trim()).filter(Boolean)
524
+ const untracked = String(untrackedOut).split(/\r?\n/).map(s => s.trim()).filter(s => s && !isTransientRuntimePath(s))
525
+ return [[...new Set([...tracked, ...untracked])].sort(), null]
526
+ } catch (err) {
527
+ return [null, `Unable to execute git: ${String(err)}`]
528
+ }
529
+ }
530
+
531
+ /** get_phase_owned_actual_changed_files: which changed files belong to this phase. */
532
+ export function getPhaseOwnedActualChangedFiles(fileName: string, actualChangedFiles: string[] | null, repoRoot?: string): string[] | null {
533
+ if (actualChangedFiles === null) return null
534
+ if (fileName === '02-to-be-plan.md') return null
535
+ const ownedPaths: string[] = []
536
+ for (const path of actualChangedFiles) {
537
+ if (path === '.recursive/DECISIONS.md') {
538
+ if (DECISIONS_DIFF_PHASE_FILES.has(fileName)) ownedPaths.push(path)
539
+ continue
540
+ }
541
+ if (path === '.recursive/STATE.md') {
542
+ if (STATE_DIFF_PHASE_FILES.has(fileName)) ownedPaths.push(path)
543
+ continue
544
+ }
545
+ if (path.startsWith('.recursive/memory/')) {
546
+ if (MEMORY_DIFF_PHASE_FILES.has(fileName)) ownedPaths.push(path)
547
+ continue
548
+ }
549
+ ownedPaths.push(path)
550
+ }
551
+ if (PRODUCT_DIFF_PHASE_FILES.has(fileName)) {
552
+ if (repoRoot === undefined) return ownedPaths
553
+ return ownedPaths.filter(p => existsSync(join(repoRoot, p)))
554
+ }
555
+ if (DECISIONS_DIFF_PHASE_FILES.has(fileName) || STATE_DIFF_PHASE_FILES.has(fileName) || MEMORY_DIFF_PHASE_FILES.has(fileName)) return ownedPaths
556
+ return null
557
+ }
558
+
559
+ /** get_related_addenda_paths: run_dir/addenda/<base>.addendum-*.md (+ upstream-gap). */
560
+ export function getRelatedAddendaPaths(runDir: string, artifactName: string): string[] {
561
+ const addendaDir = join(runDir, 'addenda')
562
+ if (!existsSync(addendaDir)) return []
563
+ const baseName = artifactName.endsWith('.md') ? artifactName.slice(0, -3) : artifactName
564
+ let matches: string[] = []
565
+ try {
566
+ matches = readdirSync(addendaDir).filter(n => n.startsWith(baseName + '.addendum-') && n.endsWith('.md')).sort()
567
+ const upstream = readdirSync(addendaDir).filter(n => n.startsWith(baseName + '.upstream-gap.') && n.includes('.addendum-') && n.endsWith('.md')).sort()
568
+ matches.push(...upstream)
569
+ } catch { return [] }
570
+ return matches
571
+ }
572
+
573
+ /** get_stage_local_addenda_paths: run_dir/addenda/<base>.addendum-*.md. */
574
+ export function getStageLocalAddendaPaths(runDir: string, artifactName: string): string[] {
575
+ const addendaDir = join(runDir, 'addenda')
576
+ if (!existsSync(addendaDir)) return []
577
+ const baseName = artifactName.endsWith('.md') ? artifactName.slice(0, -3) : artifactName
578
+ try { return readdirSync(addendaDir).filter(n => n.startsWith(baseName + '.addendum-') && n.endsWith('.md')).sort() }
579
+ catch { return [] }
580
+ }
581
+
582
+ /** get_current_phase_upstream_gap_addenda_paths: run_dir/addenda/<base>.upstream-gap.*.addendum-*.md. */
583
+ export function getCurrentPhaseUpstreamGapAddendaPaths(runDir: string, artifactName: string): string[] {
584
+ const addendaDir = join(runDir, 'addenda')
585
+ if (!existsSync(addendaDir)) return []
586
+ const baseName = artifactName.endsWith('.md') ? artifactName.slice(0, -3) : artifactName
587
+ try { return readdirSync(addendaDir).filter(n => n.startsWith(baseName + '.upstream-gap.') && n.includes('.addendum-') && n.endsWith('.md')).sort() }
588
+ catch { return [] }
589
+ }
590
+
591
+ /** get_header_field_block: lines under `Field:` until a stop field. */
592
+ export function getHeaderFieldBlock(content: string, fieldName: string, stopFields: string[]): string {
593
+ const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
594
+ let collecting = false
595
+ const captured: string[] = []
596
+ const stopPatterns = stopFields.map(sf => new RegExp(`^\\s*${escapeRegExp(sf)}:`))
597
+ const fieldPattern = new RegExp(`^\\s*${escapeRegExp(fieldName)}:\\s*$`)
598
+ for (const line of lines) {
599
+ if (!collecting) {
600
+ if (fieldPattern.test(line)) collecting = true
601
+ continue
602
+ }
603
+ if (stopPatterns.some(p => p.test(line))) break
604
+ captured.push(line)
605
+ }
606
+ return captured.join('\n').trim()
607
+ }
608
+
609
+ /** get_header_input_paths: paths under Inputs:. */
610
+ export function getHeaderInputPaths(content: string): Set<string> {
611
+ return extractPathsFromText(getHeaderFieldBlock(content, 'Inputs', ['Outputs', 'Scope note']))
612
+ }
613
+
614
+ /** get_phase_expected_input_artifact_names: expected input artifacts for a phase. */
615
+ export function getPhaseExpectedInputArtifactNames(fileName: string, runDir: string): string[] {
616
+ const present = new Set(RUN_ARTIFACT_SEQUENCE.filter(a => existsSync(join(runDir, a))))
617
+ let candidates: string[] = []
618
+ if (fileName === '00-worktree.md') candidates = ['00-requirements.md']
619
+ else if (fileName === '01-as-is.md') candidates = ['00-requirements.md']
620
+ else if (fileName === '01.5-root-cause.md') candidates = ['01-as-is.md']
621
+ else if (fileName === '02-to-be-plan.md') {
622
+ candidates = ['00-requirements.md', '01-as-is.md']
623
+ if (present.has('01.5-root-cause.md')) candidates.push('01.5-root-cause.md')
624
+ } else if (fileName === '03-implementation-summary.md') candidates = ['02-to-be-plan.md']
625
+ else if (fileName === '03.5-code-review.md') candidates = ['02-to-be-plan.md', '03-implementation-summary.md']
626
+ else if (fileName === '04-test-summary.md') {
627
+ candidates = ['02-to-be-plan.md', '03-implementation-summary.md']
628
+ if (present.has('03.5-code-review.md')) candidates.push('03.5-code-review.md')
629
+ } else if (fileName === '05-manual-qa.md') candidates = ['02-to-be-plan.md']
630
+ else if (fileName === '06-decisions-update.md') candidates = RUN_ARTIFACT_SEQUENCE.filter(a => present.has(a) && a !== '06-decisions-update.md')
631
+ else if (fileName === '07-state-update.md') candidates = ['06-decisions-update.md']
632
+ else if (fileName === '08-memory-impact.md') candidates = RUN_ARTIFACT_SEQUENCE.filter(a => present.has(a) && a !== '08-memory-impact.md')
633
+ return candidates.filter(a => present.has(a))
634
+ }
635
+
636
+ /** get_expected_effective_input_addenda_paths: expected addenda cited in Inputs. */
637
+ export function getExpectedEffectiveInputAddendaPaths(runDir: string, fileName: string): string[] {
638
+ if (isAddendumArtifact(fileName)) return []
639
+ const expectedPaths: string[] = []
640
+ const runPrefix = `.recursive/run/${basename(runDir)}/`
641
+ for (const artifactName of getPhaseExpectedInputArtifactNames(fileName, runDir)) {
642
+ for (const addendumPath of getStageLocalAddendaPaths(runDir, artifactName)) {
643
+ expectedPaths.push(`${runPrefix}addenda/${addendumPath}`)
644
+ }
645
+ }
646
+ for (const addendumPath of getCurrentPhaseUpstreamGapAddendaPaths(runDir, fileName)) {
647
+ expectedPaths.push(`${runPrefix}addenda/${addendumPath}`)
648
+ }
649
+ return [...new Set(expectedPaths)].sort()
650
+ }
651
+
652
+ /** parse_requirement_completion_entries: `- R1 | Status: ... | ...` rows. */
653
+ export function parseRequirementCompletionEntries(sectionBody: string): [Record<string, Record<string, string>>, string[]] {
654
+ const entries: Record<string, Record<string, string>> = {}
655
+ const issues: string[] = []
656
+ for (const rawLine of sectionBody.split(/\r?\n/)) {
657
+ const line = rawLine.trim()
658
+ if (!line.startsWith('-') && !line.startsWith('*')) continue
659
+ const body = trimMdValue(line.slice(1).trim())
660
+ const parts = body.split('|').map(p => trimMdValue(p.trim())).filter(Boolean)
661
+ if (parts.length < 2) continue
662
+ const requirementId = trimMdValue(parts[0])
663
+ if (!REQUIREMENT_ID_RE.test(requirementId)) continue
664
+ const fields: Record<string, string> = { 'Requirement ID': requirementId }
665
+ for (const part of parts.slice(1)) {
666
+ const idx = part.indexOf(':')
667
+ if (idx < 0) continue
668
+ fields[part.slice(0, idx).trim()] = trimMdValue(part.slice(idx + 1).trim())
669
+ }
670
+ if (requirementId in entries) {
671
+ issues.push(`Requirement Completion Status contains duplicate entries for ${requirementId}`)
672
+ continue
673
+ }
674
+ entries[requirementId] = fields
675
+ }
676
+ return [entries, issues]
677
+ }
678
+
679
+ /** is_meaningful_requirement_field: non-empty, not placeholder/none/n/a/tbd/todo. */
680
+ export function isMeaningfulRequirementField(value: string | null): boolean {
681
+ if (value === null) return false
682
+ const normalized = trimMdValue(value).trim()
683
+ if (!normalized) return false
684
+ return !['...', 'none', 'n/a', 'tbd', 'todo'].includes(normalized.toLowerCase())
685
+ }
686
+
687
+ /** collect_requirement_field_paths: normalized repo paths from field values. */
688
+ export function collectRequirementFieldPaths(fields: Record<string, string>, fieldNames: string[]): Set<string> {
689
+ const paths = new Set<string>()
690
+ for (const fieldName of fieldNames) {
691
+ const rawValue = fields[fieldName] ?? ''
692
+ for (const path of extractPathsFromFieldValue(rawValue)) {
693
+ const normalized = normalizeRepoPath(path)
694
+ if (normalized) paths.add(normalized)
695
+ }
696
+ }
697
+ return paths
698
+ }
699
+
700
+ /** collect_meaningful_requirement_fields: drop ID/Status + placeholders. */
701
+ export function collectMeaningfulRequirementFields(fields: Record<string, string>): Record<string, string> {
702
+ const meaningful: Record<string, string> = {}
703
+ for (const [key, value] of Object.entries(fields)) {
704
+ if (key === 'Requirement ID' || key === 'Status') continue
705
+ if (isMeaningfulRequirementField(value)) meaningful[key] = value
706
+ }
707
+ return meaningful
708
+ }
709
+
710
+ /** normalize_skill_usage_relevance: yes->relevant, no->not-relevant. */
711
+ export function normalizeSkillUsageRelevance(value: string | null): string {
712
+ const normalized = trimMdValue(value ?? '').trim().toLowerCase()
713
+ if (normalized === 'yes') return 'relevant'
714
+ if (normalized === 'no') return 'not-relevant'
715
+ return normalized
716
+ }
717
+
718
+ /** lint_requirement_disposition_fields: per-status field requirements (implemented/verified/deferred/...). */
719
+ export function lintRequirementDispositionFields(
720
+ requirementId: string,
721
+ status: string,
722
+ fields: Record<string, string>,
723
+ fileName: string,
724
+ runDir: string,
725
+ repoRoot: string,
726
+ actualChangedFiles: string[] | null,
727
+ ): string[] {
728
+ const issues: string[] = []
729
+ const actualChangedScope = new Set(actualChangedFiles ?? [])
730
+ const meaningfulFields = collectMeaningfulRequirementFields(fields)
731
+ const allowedFieldsByStatus: Record<string, Set<string>> = {
732
+ 'implemented': new Set(['Changed Files', 'Implementation Evidence', 'Audit Note']),
733
+ 'verified': new Set(['Changed Files', 'Implementation Evidence', 'Verification Evidence', 'Audit Note']),
734
+ 'deferred': new Set(['Rationale', 'Deferred By', 'Addendum', 'Audit Note']),
735
+ 'out-of-scope': new Set(['Rationale', 'Scope Decision', 'Addendum', 'Audit Note']),
736
+ 'blocked': new Set(['Rationale', 'Blocking Evidence', 'Audit Note']),
737
+ 'superseded by approved addendum': new Set(['Addendum', 'Audit Note']),
738
+ }
739
+ const unexpected = Object.keys(meaningfulFields).filter(k => !(allowedFieldsByStatus[status] ?? new Set()).has(k)).sort()
740
+ if (unexpected.length > 0) {
741
+ issues.push(`Requirement ${requirementId} with Status ${status} contains contradictory field(s): ${unexpected.join(', ')}`)
742
+ }
743
+
744
+ if (status === 'implemented') {
745
+ const changedFiles = fields['Changed Files'] ?? ''
746
+ const changedPaths = collectRequirementFieldPaths(fields, ['Changed Files'])
747
+ const implEvidence = fields['Implementation Evidence'] ?? ''
748
+ const implPaths = collectRequirementFieldPaths(fields, ['Implementation Evidence'])
749
+ if (!isMeaningfulRequirementField(changedFiles)) {
750
+ issues.push(`Requirement ${requirementId} with Status implemented must cite Changed Files`)
751
+ } else if (changedPaths.size === 0) {
752
+ issues.push(`Requirement ${requirementId} with Status implemented must cite repo paths in Changed Files`)
753
+ } else {
754
+ const missing = findMissingRepoPaths(repoRoot, [...changedPaths].sort())
755
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Changed Files path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
756
+ if (actualChangedScope.size > 0) {
757
+ const unexplained = [...changedPaths].filter(p => !actualChangedScope.has(p)).sort()
758
+ if (unexplained.length > 0) issues.push(`Requirement ${requirementId} Changed Files are outside the current diff scope: ${unexplained.slice(0, 5).join(', ')}`)
759
+ }
760
+ }
761
+ if (!isMeaningfulRequirementField(implEvidence)) {
762
+ issues.push(`Requirement ${requirementId} with Status implemented must cite Implementation Evidence`)
763
+ } else if (implPaths.size === 0) {
764
+ issues.push(`Requirement ${requirementId} with Status implemented must cite file or artifact paths in Implementation Evidence`)
765
+ } else {
766
+ const missing = findMissingRepoPaths(repoRoot, [...implPaths].sort())
767
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Implementation Evidence path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
768
+ if (changedPaths.size > 0 && [...implPaths].every(p => !changedPaths.has(p)) && ![...implPaths].some(p => p.startsWith(`.recursive/run/${basename(runDir)}/`))) {
769
+ issues.push(`Requirement ${requirementId} Implementation Evidence must reference the changed files or a current-run artifact that does so`)
770
+ }
771
+ }
772
+ } else if (status === 'verified') {
773
+ const changedFiles = fields['Changed Files'] ?? ''
774
+ const changedPaths = collectRequirementFieldPaths(fields, ['Changed Files'])
775
+ const implEvidence = fields['Implementation Evidence'] ?? ''
776
+ const verifEvidence = fields['Verification Evidence'] ?? ''
777
+ const implPaths = collectRequirementFieldPaths(fields, ['Implementation Evidence'])
778
+ const verifPaths = collectRequirementFieldPaths(fields, ['Verification Evidence'])
779
+ if (!isMeaningfulRequirementField(changedFiles)) {
780
+ issues.push(`Requirement ${requirementId} with Status verified must cite Changed Files`)
781
+ } else if (changedPaths.size === 0) {
782
+ issues.push(`Requirement ${requirementId} with Status verified must cite repo paths in Changed Files`)
783
+ } else {
784
+ const missing = findMissingRepoPaths(repoRoot, [...changedPaths].sort())
785
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Changed Files path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
786
+ if (actualChangedScope.size > 0) {
787
+ const unexplained = [...changedPaths].filter(p => !actualChangedScope.has(p)).sort()
788
+ if (unexplained.length > 0) issues.push(`Requirement ${requirementId} Changed Files are outside the current diff scope: ${unexplained.slice(0, 5).join(', ')}`)
789
+ }
790
+ }
791
+ if (!isMeaningfulRequirementField(implEvidence)) {
792
+ issues.push(`Requirement ${requirementId} with Status verified must cite Implementation Evidence`)
793
+ } else if (implPaths.size === 0) {
794
+ issues.push(`Requirement ${requirementId} with Status verified must cite file or artifact paths in Implementation Evidence`)
795
+ } else {
796
+ const missing = findMissingRepoPaths(repoRoot, [...implPaths].sort())
797
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Implementation Evidence path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
798
+ if (changedPaths.size > 0 && [...implPaths].every(p => !changedPaths.has(p)) && ![...implPaths].some(p => p.startsWith(`.recursive/run/${basename(runDir)}/`))) {
799
+ issues.push(`Requirement ${requirementId} Implementation Evidence must reference the changed files or a current-run artifact that does so`)
800
+ }
801
+ }
802
+ if (!isMeaningfulRequirementField(verifEvidence)) {
803
+ issues.push(`Requirement ${requirementId} with Status verified must cite Verification Evidence`)
804
+ } else if (verifPaths.size === 0) {
805
+ issues.push(`Requirement ${requirementId} with Status verified must cite test, review, QA, or artifact paths in Verification Evidence`)
806
+ } else {
807
+ const missing = findMissingRepoPaths(repoRoot, [...verifPaths].sort())
808
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Verification Evidence path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
809
+ if (changedPaths.size > 0 && [...verifPaths].every(p => changedPaths.has(p))) {
810
+ issues.push(`Requirement ${requirementId} Verification Evidence must cite verification artifacts, review receipts, or evidence paths distinct from the changed files`)
811
+ }
812
+ if (implPaths.size > 0 && [...verifPaths].every(p => implPaths.has(p))) {
813
+ issues.push(`Requirement ${requirementId} Verification Evidence cannot be satisfied by restating only the implementation evidence`)
814
+ }
815
+ }
816
+ } else if (status === 'deferred') {
817
+ const rationale = fields['Rationale'] ?? ''
818
+ const deferredBy = fields['Deferred By'] ?? fields['Addendum'] ?? ''
819
+ const deferredPaths = collectRequirementFieldPaths(fields, ['Deferred By', 'Addendum'])
820
+ if (!isMeaningfulRequirementField(rationale)) issues.push(`Requirement ${requirementId} with Status deferred is missing Rationale`)
821
+ if (!isMeaningfulRequirementField(deferredBy)) {
822
+ issues.push(`Requirement ${requirementId} with Status deferred must cite Deferred By or Addendum`)
823
+ } else if (deferredPaths.size === 0) {
824
+ issues.push(`Requirement ${requirementId} with Status deferred must cite an approved deferral path`)
825
+ } else {
826
+ const missing = findMissingRepoPaths(repoRoot, [...deferredPaths].sort())
827
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} deferral reference path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
828
+ }
829
+ } else if (status === 'out-of-scope') {
830
+ const rationale = fields['Rationale'] ?? ''
831
+ const scopeDecision = fields['Scope Decision'] ?? fields['Addendum'] ?? ''
832
+ const scopePaths = collectRequirementFieldPaths(fields, ['Scope Decision', 'Addendum'])
833
+ if (!isMeaningfulRequirementField(rationale)) issues.push(`Requirement ${requirementId} with Status out-of-scope is missing Rationale`)
834
+ if (!isMeaningfulRequirementField(scopeDecision)) {
835
+ issues.push(`Requirement ${requirementId} with Status out-of-scope must cite Scope Decision or Addendum`)
836
+ } else if (scopePaths.size === 0) {
837
+ issues.push(`Requirement ${requirementId} with Status out-of-scope must cite an approved scope decision path`)
838
+ } else {
839
+ const missing = findMissingRepoPaths(repoRoot, [...scopePaths].sort())
840
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} scope decision path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
841
+ }
842
+ } else if (status === 'blocked') {
843
+ const rationale = fields['Rationale'] ?? ''
844
+ const blockingEvidence = fields['Blocking Evidence'] ?? ''
845
+ const blockingPaths = collectRequirementFieldPaths(fields, ['Blocking Evidence'])
846
+ if (!isMeaningfulRequirementField(rationale)) issues.push(`Requirement ${requirementId} with Status blocked is missing Rationale`)
847
+ if (!isMeaningfulRequirementField(blockingEvidence)) {
848
+ issues.push(`Requirement ${requirementId} with Status blocked must cite Blocking Evidence`)
849
+ } else if (blockingPaths.size === 0) {
850
+ issues.push(`Requirement ${requirementId} with Status blocked must cite file, artifact, or evidence paths in Blocking Evidence`)
851
+ } else {
852
+ const missing = findMissingRepoPaths(repoRoot, [...blockingPaths].sort())
853
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Blocking Evidence path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
854
+ }
855
+ } else if (status === 'superseded by approved addendum') {
856
+ const addendumPath = normalizeRepoPath(fields['Addendum'] ?? '')
857
+ if (!addendumPath) {
858
+ issues.push(`Requirement ${requirementId} superseded by approved addendum must cite Addendum`)
859
+ } else if (!addendumPath.startsWith(`.recursive/run/${basename(runDir)}/addenda/`)) {
860
+ issues.push(`Requirement ${requirementId} addendum reference must live under the current run addenda/`)
861
+ } else if (!existsSync(join(repoRoot, addendumPath))) {
862
+ issues.push(`Requirement ${requirementId} addendum reference does not exist: ${addendumPath}`)
863
+ }
864
+ }
865
+
866
+ if (FINAL_REQUIREMENT_DISPOSITION_FILES.has(fileName)) {
867
+ if (status === 'implemented') issues.push(`Requirement ${requirementId} cannot remain implemented in ${fileName}; final closeout phases require verification`)
868
+ if (status === 'blocked') issues.push(`Requirement ${requirementId} cannot remain blocked in ${fileName} while the phase is approaching closeout`)
869
+ }
870
+ return issues
871
+ }
872
+
873
+ /** lint_source_requirement_inventory: 01-as-is Source Requirement Inventory rules. */
874
+ export function lintSourceRequirementInventory(filePath: string, content: string, workflowProfile: string, runDir: string): string[] {
875
+ if (workflowProfile !== CURRENT_WORKFLOW_PROFILE || basename(filePath) !== '01-as-is.md') return []
876
+ const issues: string[] = []
877
+ const requirementsPath = join(runDir, '00-requirements.md')
878
+ const requirementsContent = existsSync(requirementsPath) ? readFileSync(requirementsPath, 'utf8') : ''
879
+ const body = getHeadingBody(content, 'Source Requirement Inventory')
880
+ if (!body) return ['Missing or empty section: ## Source Requirement Inventory']
881
+ const [entries, entryIssues] = parseSourceRequirementInventoryEntries(body)
882
+ issues.push(...entryIssues)
883
+ if (Object.keys(entries).length === 0) {
884
+ issues.push('Source Requirement Inventory must contain at least one requirement inventory entry')
885
+ return [...new Set(issues)].sort()
886
+ }
887
+ const explicitIds = parseRequirementIds(requirementsContent)
888
+ const missingExplicit = explicitIds.filter(id => !(id in entries))
889
+ if (missingExplicit.length > 0) issues.push(`Source Requirement Inventory is missing explicit requirement IDs from 00-requirements.md: ${missingExplicit.join(', ')}`)
890
+ for (const [requirementId, fields] of Object.entries(entries)) {
891
+ const disposition = trimMdValue(fields['Disposition'] ?? '').toLowerCase()
892
+ const sourceQuote = fields['Source Quote'] ?? ''
893
+ const summary = fields['Summary'] ?? ''
894
+ if (!INVENTORY_DISPOSITIONS.has(disposition)) issues.push(`Source Requirement Inventory for ${requirementId} has invalid Disposition '${fields['Disposition'] ?? ''}'`)
895
+ if (!isMeaningfulRequirementField(sourceQuote)) {
896
+ issues.push(`Source Requirement Inventory for ${requirementId} is missing Source Quote`)
897
+ } else if (!sourceQuoteMatches(sourceQuote, requirementsContent)) {
898
+ issues.push(`Source Requirement Inventory for ${requirementId} cites a Source Quote that does not appear in 00-requirements.md`)
899
+ }
900
+ if (!isMeaningfulRequirementField(summary)) issues.push(`Source Requirement Inventory for ${requirementId} is missing Summary`)
901
+ }
902
+ return [...new Set(issues)].sort()
903
+ }
904
+
905
+ /** lint_requirement_mapping: 02-to-be-plan Requirement Mapping rules. */
906
+ export function lintRequirementMapping(content: string, workflowProfile: string, runDir: string, repoRoot: string): string[] {
907
+ if (workflowProfile !== CURRENT_WORKFLOW_PROFILE) return []
908
+ const issues: string[] = []
909
+ const phase1Path = join(runDir, '01-as-is.md')
910
+ const requirementsPath = join(runDir, '00-requirements.md')
911
+ if (!existsSync(phase1Path) || !existsSync(requirementsPath)) return ['Requirement Mapping requires existing 00-requirements.md and 01-as-is.md inputs']
912
+ const inventoryBody = getHeadingBody(readFileSync(phase1Path, 'utf8'), 'Source Requirement Inventory')
913
+ if (!inventoryBody) return ['Requirement Mapping requires 01-as-is.md to contain ## Source Requirement Inventory']
914
+ const [inventoryEntries, inventoryIssues] = parseSourceRequirementInventoryEntries(inventoryBody)
915
+ issues.push(...inventoryIssues)
916
+ const body = getHeadingBody(content, 'Requirement Mapping')
917
+ if (!body) {
918
+ issues.push('Missing or empty section: ## Requirement Mapping')
919
+ return [...new Set(issues)].sort()
920
+ }
921
+ const [mappingEntries, mappingIssues] = parseRequirementMappingEntries(body)
922
+ issues.push(...mappingIssues)
923
+ const missingEntries = Object.keys(inventoryEntries).filter(id => !(id in mappingEntries))
924
+ if (missingEntries.length > 0) issues.push(`Requirement Mapping is missing source inventory items: ${missingEntries.join(', ')}`)
925
+ const requirementsContent = readFileSync(requirementsPath, 'utf8')
926
+ for (const [requirementId, fields] of Object.entries(mappingEntries)) {
927
+ const coverage = trimMdValue(fields['Coverage'] ?? '').toLowerCase()
928
+ const sourceQuote = fields['Source Quote'] ?? ''
929
+ const implementationSurface = fields['Implementation Surface'] ?? ''
930
+ const verificationSurface = fields['Verification Surface'] ?? ''
931
+ const qaSurface = fields['QA Surface'] ?? ''
932
+ const rationale = fields['Rationale'] ?? fields['Merge Rationale'] ?? ''
933
+ if (!['direct', 'merged', 'indirect', 'deferred', 'out-of-scope', 'blocked'].includes(coverage)) {
934
+ issues.push(`Requirement Mapping for ${requirementId} has invalid Coverage '${fields['Coverage'] ?? ''}'`)
935
+ continue
936
+ }
937
+ if (!isMeaningfulRequirementField(sourceQuote)) {
938
+ issues.push(`Requirement Mapping for ${requirementId} is missing Source Quote`)
939
+ } else if (requirementId in inventoryEntries) {
940
+ const inventoryQuote = inventoryEntries[requirementId]['Source Quote'] ?? ''
941
+ if (normalizeSourceText(sourceQuote) !== normalizeSourceText(inventoryQuote)) {
942
+ issues.push(`Requirement Mapping for ${requirementId} must preserve the Source Quote recorded in Source Requirement Inventory`)
943
+ }
944
+ } else if (!sourceQuoteMatches(sourceQuote, requirementsContent)) {
945
+ issues.push(`Requirement Mapping for ${requirementId} cites a Source Quote that does not appear in 00-requirements.md`)
946
+ }
947
+ if (['direct', 'merged', 'indirect'].includes(coverage)) {
948
+ if (!isMeaningfulRequirementField(implementationSurface)) {
949
+ issues.push(`Requirement Mapping for ${requirementId} is missing Implementation Surface`)
950
+ } else {
951
+ issues.push(...validatePlannedSurfacePaths(requirementId, 'Implementation Surface', implementationSurface, repoRoot))
952
+ }
953
+ if (!isMeaningfulRequirementField(verificationSurface)) issues.push(`Requirement Mapping for ${requirementId} is missing Verification Surface`)
954
+ if (!isMeaningfulRequirementField(qaSurface)) issues.push(`Requirement Mapping for ${requirementId} is missing QA Surface`)
955
+ }
956
+ if (coverage === 'merged' && !isMeaningfulRequirementField(rationale)) issues.push(`Requirement Mapping for ${requirementId} with Coverage merged must cite Merge Rationale or Rationale`)
957
+ if (coverage === 'indirect' && !isMeaningfulRequirementField(rationale)) issues.push(`Requirement Mapping for ${requirementId} with Coverage indirect must cite Rationale`)
958
+ if (['deferred', 'out-of-scope', 'blocked'].includes(coverage) && !isMeaningfulRequirementField(rationale)) issues.push(`Requirement Mapping for ${requirementId} with Coverage ${coverage} must cite Rationale`)
959
+ }
960
+ return [...new Set(issues)].sort()
961
+ }
962
+
963
+ /** lint_plan_drift_check: Plan Drift Check merge/rationale rule. */
964
+ export function lintPlanDriftCheck(content: string, workflowProfile: string): string[] {
965
+ if (workflowProfile !== CURRENT_WORKFLOW_PROFILE) return []
966
+ const body = getHeadingBody(content, 'Plan Drift Check')
967
+ if (!body) return ['Missing or empty section: ## Plan Drift Check']
968
+ if (/\bmerge\b/i.test(body) && !/\brationale\b/i.test(body)) {
969
+ return ['Plan Drift Check mentions merged obligations without explaining why the merge is lossless']
970
+ }
971
+ return []
972
+ }
973
+
974
+ /** lint_phase2_requirement_disposition_fields: 02 phase-2 status rules. */
975
+ export function lintPhase2RequirementDispositionFields(
976
+ requirementId: string,
977
+ status: string,
978
+ fields: Record<string, string>,
979
+ runDir: string,
980
+ repoRoot: string,
981
+ ): string[] {
982
+ const issues: string[] = []
983
+ const allowedFieldsByStatus: Record<string, Set<string>> = {
984
+ 'planned': new Set(['Implementation Surface', 'Verification Surface', 'QA Surface', 'Audit Note']),
985
+ 'planned-via-merge': new Set(['Implementation Surface', 'Verification Surface', 'QA Surface', 'Rationale', 'Audit Note']),
986
+ 'planned-indirectly': new Set(['Implementation Surface', 'Verification Surface', 'QA Surface', 'Rationale', 'Audit Note']),
987
+ 'deferred': new Set(['Rationale', 'Deferred By', 'Addendum', 'Audit Note']),
988
+ 'out-of-scope': new Set(['Rationale', 'Scope Decision', 'Addendum', 'Audit Note']),
989
+ 'blocked': new Set(['Rationale', 'Blocking Evidence', 'Audit Note']),
990
+ 'superseded by approved addendum': new Set(['Addendum', 'Audit Note']),
991
+ }
992
+ const meaningfulFields = collectMeaningfulRequirementFields(fields)
993
+ const unexpected = Object.keys(meaningfulFields).filter(k => !(allowedFieldsByStatus[status] ?? new Set()).has(k)).sort()
994
+ if (unexpected.length > 0) {
995
+ issues.push(`Requirement ${requirementId} with Status ${status} contains contradictory field(s): ${unexpected.join(', ')}`)
996
+ }
997
+ if (['planned', 'planned-via-merge', 'planned-indirectly'].includes(status)) {
998
+ const implSurface = fields['Implementation Surface'] ?? ''
999
+ const verifSurface = fields['Verification Surface'] ?? ''
1000
+ const qaSurface = fields['QA Surface'] ?? ''
1001
+ const rationale = fields['Rationale'] ?? ''
1002
+ if (!isMeaningfulRequirementField(implSurface)) {
1003
+ issues.push(`Requirement ${requirementId} with Status ${status} must cite Implementation Surface`)
1004
+ } else {
1005
+ issues.push(...validatePlannedSurfacePaths(requirementId, 'Implementation Surface', implSurface, repoRoot))
1006
+ }
1007
+ if (!isMeaningfulRequirementField(verifSurface)) issues.push(`Requirement ${requirementId} with Status ${status} must cite Verification Surface`)
1008
+ if (!isMeaningfulRequirementField(qaSurface)) issues.push(`Requirement ${requirementId} with Status ${status} must cite QA Surface`)
1009
+ if (['planned-via-merge', 'planned-indirectly'].includes(status) && !isMeaningfulRequirementField(rationale)) issues.push(`Requirement ${requirementId} with Status ${status} must cite Rationale`)
1010
+ } else if (status === 'deferred') {
1011
+ const deferredBy = fields['Deferred By'] ?? fields['Addendum'] ?? ''
1012
+ const deferredPaths = collectRequirementFieldPaths(fields, ['Deferred By', 'Addendum'])
1013
+ if (!isMeaningfulRequirementField(fields['Rationale'] ?? '')) issues.push(`Requirement ${requirementId} with Status deferred is missing Rationale`)
1014
+ if (!isMeaningfulRequirementField(deferredBy)) {
1015
+ issues.push(`Requirement ${requirementId} with Status deferred must cite Deferred By or Addendum`)
1016
+ } else if (deferredPaths.size === 0) {
1017
+ issues.push(`Requirement ${requirementId} with Status deferred must cite an approved deferral path`)
1018
+ } else {
1019
+ const missing = findMissingRepoPaths(repoRoot, [...deferredPaths].sort())
1020
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} deferral reference path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
1021
+ }
1022
+ } else if (status === 'out-of-scope') {
1023
+ const scopeDecision = fields['Scope Decision'] ?? fields['Addendum'] ?? ''
1024
+ const scopePaths = collectRequirementFieldPaths(fields, ['Scope Decision', 'Addendum'])
1025
+ if (!isMeaningfulRequirementField(fields['Rationale'] ?? '')) issues.push(`Requirement ${requirementId} with Status out-of-scope is missing Rationale`)
1026
+ if (!isMeaningfulRequirementField(scopeDecision)) {
1027
+ issues.push(`Requirement ${requirementId} with Status out-of-scope must cite Scope Decision or Addendum`)
1028
+ } else if (scopePaths.size === 0) {
1029
+ issues.push(`Requirement ${requirementId} with Status out-of-scope must cite an approved scope decision path`)
1030
+ } else {
1031
+ const missing = findMissingRepoPaths(repoRoot, [...scopePaths].sort())
1032
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} scope decision path(s) do not exist: ${missing.slice(0, 5).join(', ')}`)
1033
+ }
1034
+ } else if (status === 'blocked') {
1035
+ const blockingEvidence = fields['Blocking Evidence'] ?? ''
1036
+ const blockingPaths = collectRequirementFieldPaths(fields, ['Blocking Evidence'])
1037
+ if (!isMeaningfulRequirementField(fields['Rationale'] ?? '')) issues.push(`Requirement ${requirementId} with Status blocked is missing Rationale`)
1038
+ if (!isMeaningfulRequirementField(blockingEvidence)) {
1039
+ issues.push(`Requirement ${requirementId} with Status blocked must cite Blocking Evidence`)
1040
+ } else if (blockingPaths.size === 0) {
1041
+ issues.push(`Requirement ${requirementId} with Status blocked must cite file, artifact, or evidence paths in Blocking Evidence`)
1042
+ }
1043
+ } else if (status === 'superseded by approved addendum') {
1044
+ const addendumPath = normalizeRepoPath(fields['Addendum'] ?? '')
1045
+ if (!addendumPath) {
1046
+ issues.push(`Requirement ${requirementId} superseded by approved addendum must cite Addendum`)
1047
+ } else if (!addendumPath.startsWith(`.recursive/run/${basename(runDir)}/addenda/`)) {
1048
+ issues.push(`Requirement ${requirementId} addendum reference must live under the current run addenda/`)
1049
+ } else if (!existsSync(join(repoRoot, addendumPath))) {
1050
+ issues.push(`Requirement ${requirementId} addendum reference does not exist: ${addendumPath}`)
1051
+ }
1052
+ }
1053
+ return issues
1054
+ }
1055
+
1056
+ /** parse_source_requirement_inventory_entries: `- R1 | Disposition: ... | Source Quote: ... | Summary: ...` rows. */
1057
+ export function parseSourceRequirementInventoryEntries(sectionBody: string): [Record<string, Record<string, string>>, string[]] {
1058
+ const entries: Record<string, Record<string, string>> = {}
1059
+ const issues: string[] = []
1060
+ for (const rawLine of sectionBody.split(/\r?\n/)) {
1061
+ const line = rawLine.trim()
1062
+ if (!line.startsWith('-') && !line.startsWith('*')) continue
1063
+ const body = trimMdValue(line.slice(1).trim())
1064
+ const parts = body.split('|').map(p => trimMdValue(p.trim())).filter(Boolean)
1065
+ if (parts.length < 2) continue
1066
+ const requirementId = trimMdValue(parts[0])
1067
+ if (!REQUIREMENT_ID_RE.test(requirementId)) continue
1068
+ const fields: Record<string, string> = { 'Requirement ID': requirementId }
1069
+ for (const part of parts.slice(1)) {
1070
+ const idx = part.indexOf(':')
1071
+ if (idx < 0) continue
1072
+ fields[part.slice(0, idx).trim()] = trimMdValue(part.slice(idx + 1).trim())
1073
+ }
1074
+ if (requirementId in entries) {
1075
+ issues.push(`Source Requirement Inventory contains duplicate entries for ${requirementId}`)
1076
+ continue
1077
+ }
1078
+ entries[requirementId] = fields
1079
+ }
1080
+ return [entries, issues]
1081
+ }
1082
+
1083
+ /** parse_requirement_mapping_entries: `- R1 | Source Quote: ... | Coverage: ... | ...` rows. */
1084
+ export function parseRequirementMappingEntries(sectionBody: string): [Record<string, Record<string, string>>, string[]] {
1085
+ const entries: Record<string, Record<string, string>> = {}
1086
+ const issues: string[] = []
1087
+ for (const rawLine of sectionBody.split(/\r?\n/)) {
1088
+ const line = rawLine.trim()
1089
+ if (!line.startsWith('-') && !line.startsWith('*')) continue
1090
+ const body = trimMdValue(line.slice(1).trim())
1091
+ const parts = body.split('|').map(p => trimMdValue(p.trim())).filter(Boolean)
1092
+ if (parts.length < 2) continue
1093
+ const requirementId = trimMdValue(parts[0])
1094
+ if (!REQUIREMENT_ID_RE.test(requirementId)) continue
1095
+ const fields: Record<string, string> = { 'Requirement ID': requirementId }
1096
+ for (const part of parts.slice(1)) {
1097
+ const idx = part.indexOf(':')
1098
+ if (idx < 0) continue
1099
+ fields[part.slice(0, idx).trim()] = trimMdValue(part.slice(idx + 1).trim())
1100
+ }
1101
+ if (requirementId in entries) {
1102
+ issues.push(`Requirement Mapping contains duplicate entries for ${requirementId}`)
1103
+ continue
1104
+ }
1105
+ entries[requirementId] = fields
1106
+ }
1107
+ return [entries, issues]
1108
+ }
1109
+
1110
+ /** get_run_requirement_ids: R ids from 00-requirements or 01-as-is inventory (current profile). */
1111
+ export function getRunRequirementIds(runDir: string, workflowProfile: string): string[] {
1112
+ const requirementsPath = join(runDir, '00-requirements.md')
1113
+ let explicitIds: string[] = []
1114
+ if (existsSync(requirementsPath)) explicitIds = parseRequirementIds(readFileSync(requirementsPath, 'utf8'))
1115
+ if (workflowProfile === CURRENT_WORKFLOW_PROFILE) {
1116
+ const phase1Path = join(runDir, '01-as-is.md')
1117
+ if (existsSync(phase1Path)) {
1118
+ const inventoryBody = getHeadingBody(readFileSync(phase1Path, 'utf8'), 'Source Requirement Inventory')
1119
+ if (inventoryBody) {
1120
+ const [entries] = parseSourceRequirementInventoryEntries(inventoryBody)
1121
+ if (Object.keys(entries).length > 0) return Object.keys(entries).sort((a, b) => {
1122
+ const [ka, va, sa] = requirementSortKey(a)
1123
+ const [kb, vb, sb] = requirementSortKey(b)
1124
+ return ka !== kb ? ka - kb : (typeof va === 'number' && typeof vb === 'number' ? va - vb : String(va).localeCompare(String(vb)))
1125
+ })
1126
+ }
1127
+ }
1128
+ }
1129
+ return explicitIds
1130
+ }
1131
+
1132
+ /** validate_planned_surface_paths: implementation-surface paths must resolve or have an existing parent. */
1133
+ export function validatePlannedSurfacePaths(requirementId: string, fieldName: string, rawValue: string, repoRoot: string): string[] {
1134
+ const issues: string[] = []
1135
+ const extracted = collectRequirementFieldPaths({ [fieldName]: rawValue }, [fieldName])
1136
+ if (extracted.size === 0) {
1137
+ issues.push(`Requirement ${requirementId} ${fieldName} must cite concrete repo paths or file names`)
1138
+ return issues
1139
+ }
1140
+ const unresolved: string[] = []
1141
+ for (const path of [...extracted].sort()) {
1142
+ const candidate = join(repoRoot, path)
1143
+ if (existsSync(candidate)) continue
1144
+ if (existsSync(dirname(candidate))) continue
1145
+ unresolved.push(path)
1146
+ }
1147
+ if (unresolved.length > 0) {
1148
+ issues.push(`Requirement ${requirementId} ${fieldName} contains unresolved planned path(s): ${unresolved.slice(0, 5).join(', ')}`)
1149
+ }
1150
+ return issues
1151
+ }
1152
+
1153
+ /** lint_effective_input_addenda: Inputs / Effective Inputs Re-read / Earlier Phase Reconciliation addenda citations. */
1154
+ export function lintEffectiveInputAddenda(filePath: string, content: string, workflowProfile: string, runDir: string): string[] {
1155
+ if (![...STRICT_WORKFLOW_PROFILES, COMPAT_WORKFLOW_PROFILE].includes(workflowProfile)) return []
1156
+ if (isAddendumArtifact(basename(filePath))) return []
1157
+ const expectedAddenda = getExpectedEffectiveInputAddendaPaths(runDir, basename(filePath))
1158
+ if (expectedAddenda.length === 0) return []
1159
+ const issues: string[] = []
1160
+ const headerInputs = new Set([...getHeaderInputPaths(content)].map(normalizeRepoPath))
1161
+ const missingInputs = expectedAddenda.filter(p => !headerInputs.has(p))
1162
+ if (missingInputs.length > 0) issues.push(`Inputs is missing relevant addenda: ${missingInputs.slice(0, 5).join(', ')}`)
1163
+ if (STRICT_WORKFLOW_PROFILES.has(workflowProfile) && AUDITED_PHASE_FILES.has(basename(filePath))) {
1164
+ const rereadPaths = new Set([...extractPathsFromText(getHeadingBody(content, 'Effective Inputs Re-read'))].map(normalizeRepoPath))
1165
+ const missingReread = expectedAddenda.filter(p => !rereadPaths.has(p))
1166
+ if (missingReread.length > 0) issues.push(`Effective Inputs Re-read is missing relevant addenda: ${missingReread.slice(0, 5).join(', ')}`)
1167
+ const reconciliationPaths = new Set([...extractPathsFromText(getHeadingBody(content, 'Earlier Phase Reconciliation'))].map(normalizeRepoPath))
1168
+ const missingRecon = expectedAddenda.filter(p => !reconciliationPaths.has(p))
1169
+ if (missingRecon.length > 0) issues.push(`Earlier Phase Reconciliation is missing relevant addenda: ${missingRecon.slice(0, 5).join(', ')}`)
1170
+ }
1171
+ return issues
1172
+ }
1173
+
1174
+ /** lint_requirement_completion_status: RCS section rules for audited phases. */
1175
+ export function lintRequirementCompletionStatus(
1176
+ filePath: string,
1177
+ content: string,
1178
+ requirementIds: string[],
1179
+ runDir: string,
1180
+ workflowProfile: string,
1181
+ actualChangedFiles: string[] | null,
1182
+ ): string[] {
1183
+ if (!STRICT_WORKFLOW_PROFILES.has(workflowProfile) || !AUDITED_PHASE_FILES.has(basename(filePath))) return []
1184
+ const body = getHeadingBody(content, 'Requirement Completion Status')
1185
+ if (!body) return ['Missing or empty section: ## Requirement Completion Status']
1186
+ const [entries, issues] = parseRequirementCompletionEntries(body)
1187
+ const missing = requirementIds.filter(id => !(id in entries))
1188
+ if (missing.length > 0) issues.push(`Requirement Completion Status is missing in-scope requirements: ${missing.join(', ')}`)
1189
+ const repoRoot = dirname(dirname(dirname(runDir)))
1190
+ if (workflowProfile === CURRENT_WORKFLOW_PROFILE && basename(filePath) === '02-to-be-plan.md') {
1191
+ for (const [requirementId, fields] of Object.entries(entries)) {
1192
+ const status = trimMdValue(fields['Status'] ?? '').toLowerCase()
1193
+ if (!PHASE2_REQUIREMENT_DISPOSITION_STATUSES.has(status)) {
1194
+ issues.push(`Requirement Completion Status for ${requirementId} has invalid Phase 2 Status '${fields['Status'] ?? ''}'`)
1195
+ continue
1196
+ }
1197
+ issues.push(...lintPhase2RequirementDispositionFields(requirementId, status, fields, runDir, repoRoot))
1198
+ }
1199
+ return [...new Set(issues)].sort()
1200
+ }
1201
+ for (const [requirementId, fields] of Object.entries(entries)) {
1202
+ const status = trimMdValue(fields['Status'] ?? '').toLowerCase()
1203
+ if (!REQUIREMENT_DISPOSITION_STATUSES.has(status)) {
1204
+ issues.push(`Requirement Completion Status for ${requirementId} has invalid Status '${fields['Status'] ?? ''}'`)
1205
+ continue
1206
+ }
1207
+ issues.push(...lintRequirementDispositionFields(requirementId, status, fields, basename(filePath), runDir, repoRoot, actualChangedFiles))
1208
+ }
1209
+ if (REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES.has(basename(filePath))) {
1210
+ const expectedScope = new Set(getPhaseOwnedActualChangedFiles(basename(filePath), actualChangedFiles, repoRoot) ?? [])
1211
+ if (expectedScope.size > 0) {
1212
+ const claimedChangedFiles = new Set<string>()
1213
+ for (const fields of Object.values(entries)) {
1214
+ const status = trimMdValue(fields['Status'] ?? '').toLowerCase()
1215
+ if (['implemented', 'verified'].includes(status)) {
1216
+ for (const p of collectRequirementFieldPaths(fields, ['Changed Files'])) claimedChangedFiles.add(p)
1217
+ }
1218
+ }
1219
+ const missingClaims = [...expectedScope].filter(p => !claimedChangedFiles.has(p)).sort()
1220
+ if (missingClaims.length > 0) {
1221
+ issues.push('Requirement Completion Status leaves diff-owned changed file(s) unaccounted for: ' + missingClaims.slice(0, 5).join(', '))
1222
+ }
1223
+ }
1224
+ }
1225
+ return [...new Set(issues)].sort()
1226
+ }
1227
+
1228
+ /** lint_prior_recursive_evidence: Prior Recursive Evidence Reviewed rules. */
1229
+ export function lintPriorRecursiveEvidence(content: string, runDir: string, workflowProfile: string, repoRoot: string, fileName: string): string[] {
1230
+ if (!STRICT_WORKFLOW_PROFILES.has(workflowProfile) || !PRIOR_RECURSIVE_EVIDENCE_FILES.has(fileName)) return []
1231
+ const body = getHeadingBody(content, 'Prior Recursive Evidence Reviewed')
1232
+ if (!body) return ['Missing or empty section: ## Prior Recursive Evidence Reviewed']
1233
+ const referencedPaths = new Set<string>()
1234
+ for (const path of extractPathsFromText(body)) {
1235
+ const normalized = normalizeRepoPath(path)
1236
+ if (normalized.startsWith('.recursive/run/') || normalized.startsWith('.recursive/memory/')) referencedPaths.add(normalized)
1237
+ }
1238
+ if (referencedPaths.size > 0) {
1239
+ const missing = findMissingRepoPaths(repoRoot, [...referencedPaths].sort())
1240
+ if (missing.length > 0) return [`Prior Recursive Evidence Reviewed references missing path(s): ${missing.slice(0, 5).join(', ')}`]
1241
+ return []
1242
+ }
1243
+ if (/\bnone\b/i.test(body) && /\b(justification|reason|because)\b/i.test(body)) return []
1244
+ return ['Prior Recursive Evidence Reviewed must contain structured run/memory paths or an explicit no-relevant-evidence justification']
1245
+ }
1246
+
1247
+ /** get_subagent_action_record_paths: subagents/ paths cited in Subagent Contribution Verification. */
1248
+ export function getSubagentActionRecordPaths(content: string, runDir: string): string[] {
1249
+ const body = getHeadingBody(content, 'Subagent Contribution Verification')
1250
+ if (!body) return []
1251
+ const expectedPrefix = `.recursive/run/${basename(runDir)}/subagents/`
1252
+ return [...new Set([...extractPathsFromText(body)].map(normalizeRepoPath).filter(p => p.startsWith(expectedPrefix)))].sort()
1253
+ }
1254
+
1255
+ /** get_all_subagent_action_record_paths: any run subagents/ path cited. */
1256
+ export function getAllSubagentActionRecordPaths(content: string): string[] {
1257
+ const body = getHeadingBody(content, 'Subagent Contribution Verification')
1258
+ if (!body) return []
1259
+ return [...new Set([...extractPathsFromText(body)].map(normalizeRepoPath).filter(p => /^\.recursive\/run\/[^/]+\/subagents\/.+\.md$/.test(p)))].sort()
1260
+ }
1261
+
1262
+ /** lint_subagent_action_record_file: validate a subagent action record file. */
1263
+ export function lintSubagentActionRecordFile(filePath: string, repoRoot: string, runDir: string, actualChangedFiles: string[] | null): string[] {
1264
+ const content = readFileSync(filePath, 'utf8')
1265
+ const issues: string[] = []
1266
+ if (!content.includes('# Subagent Action Record')) issues.push('Missing title: # Subagent Action Record')
1267
+ for (const heading of SUBAGENT_ACTION_REQUIRED_HEADINGS) {
1268
+ if (!getHeadingBody(content, heading)) issues.push(`Missing or empty section: ## ${heading}`)
1269
+ }
1270
+ const metadata = getHeadingBody(content, 'Metadata')
1271
+ const inputs = getHeadingBody(content, 'Inputs Provided')
1272
+ const claimedActions = getHeadingBody(content, 'Claimed Actions Taken')
1273
+ const claimedFileImpact = getHeadingBody(content, 'Claimed File Impact')
1274
+ const claimedArtifactImpact = getHeadingBody(content, 'Claimed Artifact Impact')
1275
+ if (dirname(filePath) !== join(runDir, 'subagents')) issues.push(`Subagent action record must live under \`/.recursive/run/${basename(runDir)}/subagents/\``)
1276
+ for (const fieldName of ['Subagent ID', 'Run ID', 'Phase', 'Purpose', 'Execution Mode', 'Timestamp']) {
1277
+ if (!hasMeaningfulValue(getMdFieldValue(metadata, fieldName))) issues.push(`Metadata is missing ${fieldName}`)
1278
+ }
1279
+ const runId = getMdFieldValue(metadata, 'Run ID')
1280
+ if (runId && runId !== basename(runDir)) issues.push(`Run ID mismatch: ${runId} != ${basename(runDir)}`)
1281
+ const currentArtifact = normalizeRepoPath(getMdFieldValue(inputs, 'Current Artifact') ?? '')
1282
+ if (!currentArtifact) {
1283
+ issues.push('Inputs Provided is missing Current Artifact')
1284
+ } else if (!existsSync(join(repoRoot, currentArtifact))) {
1285
+ issues.push(`Current Artifact does not exist: ${currentArtifact}`)
1286
+ }
1287
+ const artifactHash = trimMdValue(getMdFieldValue(inputs, 'Artifact Content Hash') ?? '')
1288
+ if (currentArtifact && existsSync(join(repoRoot, currentArtifact))) {
1289
+ const currentHash = contentSha256(readFileSync(join(repoRoot, currentArtifact), 'utf8'))
1290
+ if (!artifactHash) issues.push('Inputs Provided is missing Artifact Content Hash')
1291
+ else if (artifactHash !== currentHash) issues.push('Inputs Provided Artifact Content Hash does not match the current artifact content')
1292
+ }
1293
+ const reviewBundle = normalizeRepoPath(getMdFieldValue(inputs, 'Review Bundle') ?? '')
1294
+ if (reviewBundle && !existsSync(join(repoRoot, reviewBundle))) issues.push(`Review Bundle does not exist: ${reviewBundle}`)
1295
+ const diffBasisText = getMdFieldValue(inputs, 'Diff Basis') ?? ''
1296
+ if (!diffBasisText.trim()) issues.push('Inputs Provided is missing Diff Basis')
1297
+ const upstreamArtifacts = new Set([...extractPathsFromNamedField(inputs, 'Upstream Artifacts')].filter(p => p.startsWith(`.recursive/run/${basename(runDir)}/`)))
1298
+ const codeRefs = extractPathsFromNamedField(inputs, 'Code Refs')
1299
+ const memoryRefs = new Set([...extractPathsFromNamedField(inputs, 'Memory Refs')].filter(p => p.startsWith('.recursive/memory/')))
1300
+ const auditQuestionText = getMdFieldValue(inputs, 'Audit / Task Questions') ?? inputs
1301
+ if (upstreamArtifacts.size === 0 && !reviewBundle) issues.push('Inputs Provided must cite upstream artifacts or the review bundle used for delegation')
1302
+ if (isPlaceholderOnly(auditQuestionText)) issues.push('Inputs Provided is missing concrete Audit / Task Questions')
1303
+ const claimedCreated = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, 'Created'))].map(normalizeRepoPath))
1304
+ const claimedModified = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, 'Modified'))].map(normalizeRepoPath))
1305
+ const claimedReviewed = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, 'Reviewed'))].map(normalizeRepoPath))
1306
+ const claimedRelevantUntouched = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, 'Relevant but Untouched'))].map(normalizeRepoPath))
1307
+ if (isPlaceholderOnly(claimedActions)) issues.push('Claimed Actions Taken must contain concrete delegated work details')
1308
+ const claimedFileRefs = new Set([...claimedCreated, ...claimedModified, ...claimedReviewed, ...claimedRelevantUntouched])
1309
+ if (claimedFileRefs.size === 0) issues.push('Claimed File Impact must cite at least one created, modified, reviewed, or relevant untouched file')
1310
+ for (const createdPath of [...claimedCreated].sort()) {
1311
+ if (!existsSync(join(repoRoot, createdPath))) issues.push(`Claimed created file does not exist: ${createdPath}`)
1312
+ }
1313
+ for (const reviewedPath of [...new Set([...claimedModified, ...claimedReviewed, ...claimedRelevantUntouched])].sort()) {
1314
+ if (!existsSync(join(repoRoot, reviewedPath))) issues.push(`Claimed file reference does not exist: ${reviewedPath}`)
1315
+ }
1316
+ if (actualChangedFiles !== null) {
1317
+ const actualChanged = new Set(actualChangedFiles)
1318
+ const missingChangedClaims = [...new Set([...claimedModified, ...claimedCreated])].filter(p => !actualChanged.has(p))
1319
+ if (missingChangedClaims.length > 0) issues.push(`Claimed modified/created files are not present in the current diff: ${[...new Set(missingChangedClaims)].sort().slice(0, 5).join(', ')}`)
1320
+ }
1321
+ if (reviewBundle && existsSync(join(repoRoot, reviewBundle))) {
1322
+ const bundleContent = readFileSync(join(repoRoot, reviewBundle), 'utf8')
1323
+ const bundleArtifactPath = normalizeRepoPath(getMdFieldValue(bundleContent, 'Artifact Path') ?? '')
1324
+ const bundleUpstream = new Set([...extractPathsFromText(getHeadingBody(bundleContent, 'Upstream Artifacts To Re-read'))].map(normalizeRepoPath).filter(Boolean))
1325
+ const bundleChanged = new Set([...extractPathsFromText(getHeadingBody(bundleContent, 'Changed Files Reviewed'))].map(normalizeRepoPath).filter(Boolean))
1326
+ const bundleCodeRefs = new Set([...extractPathsFromText(getHeadingBody(bundleContent, 'Targeted Code References'))].map(normalizeRepoPath).filter(Boolean))
1327
+ const allowedArtifacts = new Set([bundleArtifactPath, ...bundleUpstream].filter(Boolean))
1328
+ if (currentArtifact && allowedArtifacts.size > 0 && !allowedArtifacts.has(currentArtifact)) {
1329
+ issues.push(`Inputs Provided Current Artifact must match the review bundle Artifact Path or a cited upstream artifact: ${currentArtifact}`)
1330
+ }
1331
+ const missingBundleUpstream = [...bundleUpstream].filter(p => !upstreamArtifacts.has(p)).sort()
1332
+ if (missingBundleUpstream.length > 0) issues.push(`Inputs Provided Upstream Artifacts omit bundle upstream artifact(s): ${missingBundleUpstream.slice(0, 5).join(', ')}`)
1333
+ const requiredBundleFileScope = new Set([...bundleCodeRefs, ...bundleChanged])
1334
+ if (requiredBundleFileScope.size > 0) {
1335
+ const missingBundleScope = [...requiredBundleFileScope].filter(p => !claimedFileRefs.has(p)).sort()
1336
+ if (missingBundleScope.length > 0) issues.push(`Claimed File Impact omits targeted file scope present in the review bundle: ${missingBundleScope.slice(0, 5).join(', ')}`)
1337
+ }
1338
+ }
1339
+ const artifactRefs = new Set([...extractPathsFromText(claimedArtifactImpact)].map(normalizeRepoPath).filter(p => p.startsWith('.recursive/')))
1340
+ const evidenceRefs = new Set([...extractPathsFromText(claimedArtifactImpact)].map(normalizeRepoPath).filter(p => p.startsWith(`.recursive/run/${basename(runDir)}/evidence/`)))
1341
+ if (artifactRefs.size === 0 && evidenceRefs.size === 0) issues.push('Claimed Artifact Impact must cite recursive artifacts or evidence paths used by the subagent')
1342
+ const missingArtifactRefs = findMissingRepoPaths(repoRoot, [...artifactRefs].sort())
1343
+ if (missingArtifactRefs.length > 0) issues.push(`Claimed artifact references do not exist: ${missingArtifactRefs.slice(0, 5).join(', ')}`)
1344
+ const missingCodeRefs = findMissingRepoPaths(repoRoot, [...codeRefs].sort())
1345
+ if (missingCodeRefs.length > 0) issues.push(`Inputs Provided code refs do not exist: ${missingCodeRefs.slice(0, 5).join(', ')}`)
1346
+ const missingMemoryRefs = findMissingRepoPaths(repoRoot, [...memoryRefs].sort())
1347
+ if (missingMemoryRefs.length > 0) issues.push(`Inputs Provided memory refs do not exist: ${missingMemoryRefs.slice(0, 5).join(', ')}`)
1348
+ const verificationHandoff = getHeadingBody(content, 'Verification Handoff')
1349
+ if (verificationHandoff && extractPathsFromText(verificationHandoff).size === 0) issues.push('Verification Handoff must cite files, diffs, or artifacts to inspect')
1350
+ return [...new Set(issues)].sort()
1351
+ }
1352
+
1353
+ /** parse_subagent_action_record_claims: extract claims from an action record. */
1354
+ export function parseSubagentActionRecordClaims(actionContent: string): Record<string, Set<string> | string> {
1355
+ const inputs = getHeadingBody(actionContent, 'Inputs Provided')
1356
+ const claimedFileImpact = getHeadingBody(actionContent, 'Claimed File Impact')
1357
+ const claimedArtifactImpact = getHeadingBody(actionContent, 'Claimed Artifact Impact')
1358
+ const currentArtifact = normalizeRepoPath(getMdFieldValue(inputs, 'Current Artifact') ?? '')
1359
+ const reviewBundle = normalizeRepoPath(getMdFieldValue(inputs, 'Review Bundle') ?? '')
1360
+ const upstreamArtifacts = extractPathsFromNamedField(inputs, 'Upstream Artifacts')
1361
+ const created = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, 'Created'))].map(normalizeRepoPath).filter(Boolean))
1362
+ const modified = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, 'Modified'))].map(normalizeRepoPath).filter(Boolean))
1363
+ const reviewed = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, 'Reviewed'))].map(normalizeRepoPath).filter(Boolean))
1364
+ const relevantUntouched = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, 'Relevant but Untouched'))].map(normalizeRepoPath).filter(Boolean))
1365
+ const artifactRefs = new Set([...extractPathsFromText(claimedArtifactImpact)].map(normalizeRepoPath).filter(p => p.startsWith('.recursive/')))
1366
+ return { current_artifact: currentArtifact, review_bundle: reviewBundle, upstream_artifacts: upstreamArtifacts, created, modified, reviewed, relevant_untouched: relevantUntouched, artifact_refs: artifactRefs }
1367
+ }
1368
+
1369
+ /** lint_subagent_contribution_verification: SCV section rules for audited phases. */
1370
+ export function lintSubagentContributionVerification(
1371
+ filePath: string,
1372
+ content: string,
1373
+ workflowProfile: string,
1374
+ runDir: string,
1375
+ repoRoot: string,
1376
+ actualChangedFiles: string[] | null,
1377
+ ): string[] {
1378
+ if (!STRICT_WORKFLOW_PROFILES.has(workflowProfile) || !AUDITED_PHASE_FILES.has(basename(filePath))) return []
1379
+ const body = getHeadingBody(content, 'Subagent Contribution Verification')
1380
+ if (!body) return ['Missing or empty section: ## Subagent Contribution Verification']
1381
+ const issues: string[] = []
1382
+ const actionRecordPaths = getSubagentActionRecordPaths(content, runDir)
1383
+ const allActionRecordPaths = getAllSubagentActionRecordPaths(content)
1384
+ const auditContext = getHeadingBody(content, 'Audit Context')
1385
+ const auditMode = getMdFieldValue(auditContext, 'Audit Execution Mode') ?? ''
1386
+ const currentPhase = getMdFieldValue(content, 'Phase') ?? ''
1387
+ const reviewedActionRecordsField = getNamedFieldText(body, 'Reviewed Action Records') ?? ''
1388
+ const mainAgentVerification = getNamedFieldText(body, 'Main-Agent Verification Performed') ?? ''
1389
+ const acceptanceDecision = trimMdValue(getMdFieldValue(body, 'Acceptance Decision') ?? '').toLowerCase()
1390
+ const refreshHandling = getNamedFieldText(body, 'Refresh Handling') ?? ''
1391
+ const repairPerformed = getNamedFieldText(body, 'Repair Performed After Verification') ?? ''
1392
+ const verificationPaths = new Set([...extractPathsFromFieldValue(mainAgentVerification)].map(normalizeRepoPath).filter(Boolean))
1393
+ const repairPaths = new Set([...extractPathsFromFieldValue(repairPerformed)].map(normalizeRepoPath).filter(Boolean))
1394
+ const currentBundlePath = normalizeRepoPath(getMdFieldValue(getHeadingBody(content, 'Review Metadata'), 'Review Bundle Path') ?? getMdFieldValue(content, 'Review Bundle Path') ?? '')
1395
+ if (auditMode === 'subagent' && actionRecordPaths.length === 0) issues.push('Audit Execution Mode subagent requires at least one reviewed subagent action record')
1396
+ const outOfRunRecords = allActionRecordPaths.filter(p => !actionRecordPaths.includes(p)).sort()
1397
+ if (outOfRunRecords.length > 0) issues.push(`Subagent Contribution Verification may only reference action records under the current run subagents/: ${outOfRunRecords.slice(0, 5).join(', ')}`)
1398
+ if (actionRecordPaths.length > 0) {
1399
+ const reviewedRecordPaths = new Set([...extractPathsFromFieldValue(reviewedActionRecordsField)].map(normalizeRepoPath).filter(p => p.startsWith(`.recursive/run/${basename(runDir)}/subagents/`)))
1400
+ if (!reviewedActionRecordsField.trim()) {
1401
+ issues.push('Subagent Contribution Verification must record Reviewed Action Records')
1402
+ } else {
1403
+ const missingReviewed = actionRecordPaths.filter(p => !reviewedRecordPaths.has(p)).sort()
1404
+ if (missingReviewed.length > 0) issues.push(`Reviewed Action Records is missing referenced action record path(s): ${missingReviewed.slice(0, 5).join(', ')}`)
1405
+ }
1406
+ if (!hasMeaningfulValue(mainAgentVerification, new Set(['n/a', 'none']))) {
1407
+ issues.push('Subagent Contribution Verification must record Main-Agent Verification Performed')
1408
+ } else if (verificationPaths.size === 0) {
1409
+ issues.push('Main-Agent Verification Performed must cite files, artifacts, or diff-owned paths that were checked')
1410
+ } else {
1411
+ const missingVerification = findMissingRepoPaths(repoRoot, [...verificationPaths].sort())
1412
+ if (missingVerification.length > 0) issues.push(`Main-Agent Verification Performed references missing path(s): ${missingVerification.slice(0, 5).join(', ')}`)
1413
+ }
1414
+ if (!['accepted', 'partially accepted', 'rejected'].includes(acceptanceDecision)) issues.push('Subagent Contribution Verification must record Acceptance Decision: accepted|partially accepted|rejected')
1415
+ if (!hasMeaningfulValue(refreshHandling, new Set(['n/a', 'none']))) issues.push('Subagent Contribution Verification must record Refresh Handling')
1416
+ if (!trimMdValue(repairPerformed)) {
1417
+ issues.push('Subagent Contribution Verification must record Repair Performed After Verification')
1418
+ } else if (repairPaths.size > 0) {
1419
+ const missingRepair = findMissingRepoPaths(repoRoot, [...repairPaths].sort())
1420
+ if (missingRepair.length > 0) issues.push(`Repair Performed After Verification references missing path(s): ${missingRepair.slice(0, 5).join(', ')}`)
1421
+ }
1422
+ }
1423
+ for (const actionRecordPath of actionRecordPaths) {
1424
+ if (!existsSync(join(repoRoot, actionRecordPath))) {
1425
+ issues.push(`Referenced subagent action record does not exist: ${actionRecordPath}`)
1426
+ continue
1427
+ }
1428
+ const actionContent = readFileSync(join(repoRoot, actionRecordPath), 'utf8')
1429
+ const actionPhase = getMdFieldValue(getHeadingBody(actionContent, 'Metadata'), 'Phase') ?? ''
1430
+ const actionClaims = parseSubagentActionRecordClaims(actionContent)
1431
+ if (currentPhase && actionPhase && currentPhase !== actionPhase) issues.push(`Subagent action record phase mismatch: ${actionRecordPath} -> ${actionPhase}`)
1432
+ if (currentBundlePath) {
1433
+ const actionBundlePath = String(actionClaims['review_bundle'])
1434
+ if (actionBundlePath && actionBundlePath !== currentBundlePath) issues.push(`Subagent action record review bundle mismatch: ${actionRecordPath} -> ${actionBundlePath}`)
1435
+ }
1436
+ issues.push(...lintSubagentActionRecordFile(join(repoRoot, actionRecordPath), repoRoot, runDir, actualChangedFiles))
1437
+ if (['accepted', 'partially accepted'].includes(acceptanceDecision)) {
1438
+ const claimedDiffScope = new Set<string>(['created', 'modified', 'reviewed'].flatMap(k => [...(actionClaims[k] as Set<string>)]))
1439
+ let expectedVerifiedPaths = new Set(claimedDiffScope)
1440
+ if (actualChangedFiles !== null) expectedVerifiedPaths = new Set([...expectedVerifiedPaths].filter(p => actualChangedFiles.includes(p)))
1441
+ const missingVerified = [...expectedVerifiedPaths].filter(p => !verificationPaths.has(p) && !repairPaths.has(p)).sort()
1442
+ if (missingVerified.length > 0) issues.push(`Main-Agent Verification Performed does not reconcile delegated file-impact claims against the actual diff scope: ${missingVerified.slice(0, 5).join(', ')}`)
1443
+ const verificationArtifactScope = new Set<string>([String(actionClaims['current_artifact']), ...(actionClaims['upstream_artifacts'] as Set<string>), ...(actionClaims['artifact_refs'] as Set<string>)])
1444
+ if (actionClaims['review_bundle']) verificationArtifactScope.add(String(actionClaims['review_bundle']))
1445
+ verificationArtifactScope.delete('')
1446
+ if (verificationArtifactScope.size > 0 && ![...verificationArtifactScope].some(p => verificationPaths.has(p))) {
1447
+ issues.push('Main-Agent Verification Performed must cite the reviewed artifact, bundle, or upstream recursive artifacts used to verify the delegated work')
1448
+ }
1449
+ }
1450
+ }
1451
+ return [...new Set(issues)].sort()
1452
+ }
1453
+
1454
+ /** collect_reviewed_paths: paths from Worktree Diff Audit + related addenda. */
1455
+ export function collectReviewedPaths(runDir: string, artifactName: string, content: string): Set<string> {
1456
+ const reviewed = extractPathsFromText(getHeadingBody(content, 'Worktree Diff Audit'))
1457
+ for (const addendumPath of getRelatedAddendaPaths(runDir, artifactName)) {
1458
+ const full = join(runDir, 'addenda', addendumPath)
1459
+ if (existsSync(full)) for (const p of extractPathsFromText(readFileSync(full, 'utf8'))) reviewed.add(p)
1460
+ }
1461
+ return reviewed
1462
+ }
1463
+
1464
+ /** lint_review_bundle_reference: 03.5 review-bundle validation. */
1465
+ export function lintReviewBundleReference(content: string, runDir: string, repoRoot: string): string[] {
1466
+ const issues: string[] = []
1467
+ const reviewMetadata = getHeadingBody(content, 'Review Metadata')
1468
+ const bundlePath = (getMdFieldValue(reviewMetadata, 'Review Bundle Path') ?? getMdFieldValue(content, 'Review Bundle Path') ?? '').trim()
1469
+ const expectedPrefix = `.recursive/run/${basename(runDir)}/evidence/review-bundles/`
1470
+ if (!bundlePath) {
1471
+ issues.push('Review Metadata is missing Review Bundle Path')
1472
+ return issues
1473
+ }
1474
+ const normalizedBundlePath = normalizeRepoPath(bundlePath)
1475
+ if (!normalizedBundlePath.startsWith(expectedPrefix)) {
1476
+ issues.push(`Review Bundle Path must live under \`/${expectedPrefix}\``)
1477
+ return issues
1478
+ }
1479
+ if (!existsSync(join(repoRoot, normalizedBundlePath))) {
1480
+ issues.push(`Review Bundle Path does not exist: ${normalizedBundlePath}`)
1481
+ return issues
1482
+ }
1483
+ const bundleContent = readFileSync(join(repoRoot, normalizedBundlePath), 'utf8')
1484
+ const artifactPath = normalizeRepoPath(getMdFieldValue(bundleContent, 'Artifact Path') ?? '')
1485
+ const artifactHash = trimMdValue(getMdFieldValue(bundleContent, 'Artifact Content Hash') ?? '')
1486
+ if (!artifactPath) {
1487
+ issues.push('Review bundle is missing Artifact Path')
1488
+ } else if (!existsSync(join(repoRoot, artifactPath))) {
1489
+ issues.push(`Review bundle Artifact Path does not exist: ${artifactPath}`)
1490
+ }
1491
+ if (artifactPath) {
1492
+ const currentHash = existsSync(join(repoRoot, artifactPath)) ? contentSha256(readFileSync(join(repoRoot, artifactPath), 'utf8')) : null
1493
+ if (artifactHash && currentHash && artifactHash !== currentHash) issues.push('Review bundle is stale: Artifact Content Hash no longer matches the current artifact')
1494
+ }
1495
+ if (!artifactHash) issues.push('Review bundle is missing Artifact Content Hash')
1496
+ const missingHeadings: string[] = []
1497
+ for (const heading of ['Diff Basis', 'Changed Files Reviewed', 'Upstream Artifacts To Re-read', 'Relevant Addenda', 'Prior Recursive Evidence', 'Targeted Code References', 'Audit Questions', 'Required Output']) {
1498
+ if (!getHeadingBody(bundleContent, heading)) missingHeadings.push(heading)
1499
+ }
1500
+ if (missingHeadings.length > 0) issues.push(`Review bundle is missing required section(s): ${missingHeadings.join(', ')}`)
1501
+ const reviewNarrative = [getHeadingBody(content, 'Review Scope'), getHeadingBody(content, 'Requirement And Plan Reconciliation'), getHeadingBody(content, 'Plan Alignment Assessment'), getHeadingBody(content, 'Code Quality Assessment'), getHeadingBody(content, 'Issues Found'), getHeadingBody(content, 'Verdict')].join('\n')
1502
+ const citedPaths = new Set([...extractPathsFromText(content)].map(normalizeRepoPath))
1503
+ const citedReviewPaths = new Set([...extractPathsFromText(reviewNarrative)].map(normalizeRepoPath))
1504
+ const upstreamPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, 'Upstream Artifacts To Re-read'))].map(normalizeRepoPath))
1505
+ const addendaPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, 'Relevant Addenda'))].map(normalizeRepoPath))
1506
+ const priorPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, 'Prior Recursive Evidence'))].map(normalizeRepoPath))
1507
+ const changedPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, 'Changed Files Reviewed'))].map(normalizeRepoPath))
1508
+ const codeRefPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, 'Targeted Code References'))].map(normalizeRepoPath))
1509
+ const auditQuestions = getHeadingBody(bundleContent, 'Audit Questions')
1510
+ if (isPlaceholderOnly(auditQuestions)) issues.push('Review bundle Audit Questions cannot be placeholder-only')
1511
+ const diffBasisBody = getHeadingBody(bundleContent, 'Diff Basis')
1512
+ for (const fieldName of DIFF_BASIS_FIELDS) {
1513
+ if (getMdFieldValue(diffBasisBody, fieldName) === null) issues.push(`Review bundle Diff Basis is missing ${fieldName}`)
1514
+ }
1515
+ if (changedPaths.size === 0) {
1516
+ issues.push('Review bundle Changed Files Reviewed cannot be empty')
1517
+ } else {
1518
+ const missingChanged = findMissingRepoPaths(repoRoot, [...changedPaths].sort())
1519
+ if (missingChanged.length > 0) issues.push(`Review bundle changed file path(s) do not exist: ${missingChanged.slice(0, 5).join(', ')}`)
1520
+ }
1521
+ if (codeRefPaths.size === 0) {
1522
+ issues.push('Review bundle Targeted Code References cannot be empty')
1523
+ } else {
1524
+ const missingCode = findMissingRepoPaths(repoRoot, [...codeRefPaths].sort())
1525
+ if (missingCode.length > 0) {
1526
+ issues.push(`Review bundle code ref path(s) do not exist: ${missingCode.slice(0, 5).join(', ')}`)
1527
+ } else if (changedPaths.size > 0 && ![...codeRefPaths].some(p => changedPaths.has(p))) {
1528
+ issues.push('Review bundle Targeted Code References do not overlap the changed-file scope')
1529
+ }
1530
+ }
1531
+ const expectedAddenda = new Set(getExpectedEffectiveInputAddendaPaths(runDir, '03.5-code-review.md'))
1532
+ const missingBundleAddenda = [...expectedAddenda].filter(p => !addendaPaths.has(p)).sort()
1533
+ if (missingBundleAddenda.length > 0) issues.push(`Review bundle is missing effective-input addenda: ${missingBundleAddenda.slice(0, 5).join(', ')}`)
1534
+ if (upstreamPaths.size > 0 && ![...upstreamPaths].some(p => citedReviewPaths.has(p))) issues.push('Code review narrative does not cite any upstream artifact from the review bundle')
1535
+ if (addendaPaths.size > 0 && ![...addendaPaths].some(p => citedReviewPaths.has(p))) issues.push('Code review narrative does not cite any relevant addendum from the review bundle')
1536
+ if (priorPaths.size > 0 && ![...priorPaths].some(p => citedReviewPaths.has(p))) issues.push('Code review narrative does not cite any prior recursive evidence from the review bundle')
1537
+ if ((changedPaths.size > 0 || codeRefPaths.size > 0) && ![...new Set([...changedPaths, ...codeRefPaths])].some(p => citedReviewPaths.has(p))) issues.push('Code review narrative does not cite any changed file or code reference from the review bundle')
1538
+ if (!citedPaths.has(normalizedBundlePath)) issues.push('Code review must cite the Review Bundle Path in its written review artifact')
1539
+ const verdictBody = getHeadingBody(content, 'Verdict')
1540
+ if (!verdictBody || isPlaceholderOnly(verdictBody)) issues.push('Verdict section must contain a concrete review verdict grounded in the review bundle')
1541
+ return issues
1542
+ }
1543
+
1544
+ /** lint_phase8_skill_usage_capture: 08 Run-Local Skill Usage Capture rules. */
1545
+ export function lintPhase8SkillUsageCapture(content: string): string[] {
1546
+ const issues: string[] = []
1547
+ const usageBody = getHeadingBody(content, 'Run-Local Skill Usage Capture')
1548
+ if (!usageBody) return ['Missing or empty section: ## Run-Local Skill Usage Capture']
1549
+ for (const fieldName of ['Skill Usage Relevance', 'Available Skills', 'Skills Sought', 'Skills Attempted', 'Skills Used', 'Worked Well', 'Issues Encountered', 'Future Guidance', 'Promotion Candidates']) {
1550
+ if (getMdFieldValue(usageBody, fieldName) === null) issues.push(`Run-Local Skill Usage Capture is missing ${fieldName}`)
1551
+ }
1552
+ const relevance = normalizeSkillUsageRelevance(getMdFieldValue(usageBody, 'Skill Usage Relevance'))
1553
+ if (!SKILL_USAGE_RELEVANCE_STATUSES.has(relevance)) {
1554
+ issues.push('Run-Local Skill Usage Capture must declare Skill Usage Relevance: relevant|not-relevant')
1555
+ return issues
1556
+ }
1557
+ if (['relevant', 'yes'].includes(relevance)) {
1558
+ for (const fieldName of ['Available Skills', 'Skills Attempted', 'Skills Used', 'Future Guidance']) {
1559
+ if (!isMeaningfulRequirementField(getMdFieldValue(usageBody, fieldName))) issues.push(`Run-Local Skill Usage Capture must record ${fieldName} when skill usage is relevant`)
1560
+ }
1561
+ const attempted = trimMdValue(getMdFieldValue(usageBody, 'Skills Attempted') ?? '').toLowerCase()
1562
+ const used = trimMdValue(getMdFieldValue(usageBody, 'Skills Used') ?? '').toLowerCase()
1563
+ if (['none', 'n/a'].includes(attempted) && ['none', 'n/a'].includes(used)) issues.push('Run-Local Skill Usage Capture cannot mark skill usage relevant while claiming no attempted or used skills')
1564
+ }
1565
+ const promotionBody = getHeadingBody(content, 'Skill Memory Promotion Review')
1566
+ if (!promotionBody) {
1567
+ issues.push('Missing or empty section: ## Skill Memory Promotion Review')
1568
+ return issues
1569
+ }
1570
+ for (const fieldName of ['Durable Skill Lessons Promoted', 'Generalized Guidance Updated', 'Run-Local Observations Left Unpromoted', 'Promotion Decision Rationale']) {
1571
+ if (getMdFieldValue(promotionBody, fieldName) === null) issues.push(`Skill Memory Promotion Review is missing ${fieldName}`)
1572
+ }
1573
+ if (['relevant', 'yes'].includes(relevance) && !isMeaningfulRequirementField(getMdFieldValue(promotionBody, 'Promotion Decision Rationale'))) {
1574
+ issues.push('Skill Memory Promotion Review must explain why relevant run-local observations were or were not promoted')
1575
+ }
1576
+ return issues
1577
+ }
1578
+
1579
+ /** lint_phase_specific_rules: per-phase rules dispatched from the artifact file. */
1580
+ export function lintPhaseSpecificRules(
1581
+ filePath: string,
1582
+ content: string,
1583
+ workflowProfile: string,
1584
+ runDir: string,
1585
+ repoRoot: string,
1586
+ requirementIds: string[],
1587
+ actualChangedFiles: string[] | null,
1588
+ ): string[] {
1589
+ const issues: string[] = []
1590
+ const fileName = basename(filePath)
1591
+ issues.push(...lintEffectiveInputAddenda(filePath, content, workflowProfile, runDir))
1592
+ issues.push(...lintSourceRequirementInventory(filePath, content, workflowProfile, runDir))
1593
+ if (fileName === '02-to-be-plan.md') {
1594
+ issues.push(...lintRequirementMapping(content, workflowProfile, runDir, repoRoot))
1595
+ issues.push(...lintPlanDriftCheck(content, workflowProfile))
1596
+ }
1597
+ issues.push(...lintRequirementCompletionStatus(filePath, content, requirementIds, runDir, workflowProfile, actualChangedFiles))
1598
+ issues.push(...lintPriorRecursiveEvidence(content, runDir, workflowProfile, repoRoot, fileName))
1599
+ issues.push(...lintSubagentContributionVerification(filePath, content, workflowProfile, runDir, repoRoot, actualChangedFiles))
1600
+ if (!STRICT_WORKFLOW_PROFILES.has(workflowProfile)) return issues
1601
+ if (fileName === '00-worktree.md') {
1602
+ const [diffBasis, diffBasisError] = normalizeDiffBasis(repoRoot, getRunDiffBasis(runDir))
1603
+ if (diffBasisError) issues.push(`Phase 0 diff basis is not executable: ${diffBasisError}`)
1604
+ else if (diffBasis === null) issues.push('Phase 0 diff basis could not be normalized')
1605
+ }
1606
+ if (fileName === '03-implementation-summary.md') {
1607
+ const tddBody = getHeadingBody(content, 'TDD Compliance Log')
1608
+ const tddMode = (getMdFieldValue(tddBody, 'TDD Mode') ?? getMdFieldValue(content, 'TDD Mode') ?? '').toLowerCase()
1609
+ if (!hasGateLine(content, 'TDD Compliance')) issues.push('Missing required gate line: TDD Compliance: PASS|FAIL')
1610
+ if (!TDD_MODES.has(tddMode)) {
1611
+ issues.push('TDD Compliance Log is missing TDD Mode: strict|pragmatic')
1612
+ } else if (tddMode === 'strict') {
1613
+ if (!tddBody.includes('RED Evidence:')) issues.push('Strict TDD is missing RED Evidence in ## TDD Compliance Log')
1614
+ if (!tddBody.includes('GREEN Evidence:')) issues.push('Strict TDD is missing GREEN Evidence in ## TDD Compliance Log')
1615
+ const redPrefix = `.recursive/run/${basename(runDir)}/evidence/logs/red/`
1616
+ const greenPrefix = `.recursive/run/${basename(runDir)}/evidence/logs/green/`
1617
+ const redPaths = collectPathsUnderPrefix(tddBody, redPrefix)
1618
+ const greenPaths = collectPathsUnderPrefix(tddBody, greenPrefix)
1619
+ if (redPaths.length === 0) {
1620
+ issues.push(`Strict TDD requires at least one RED evidence path under \`/${redPrefix}\``)
1621
+ } else {
1622
+ const missingRed = findMissingRepoPaths(repoRoot, redPaths)
1623
+ if (missingRed.length > 0) issues.push(`Strict TDD RED evidence path(s) do not exist: ${missingRed.slice(0, 5).join(', ')}`)
1624
+ }
1625
+ if (greenPaths.length === 0) {
1626
+ issues.push(`Strict TDD requires at least one GREEN evidence path under \`/${greenPrefix}\``)
1627
+ } else {
1628
+ const missingGreen = findMissingRepoPaths(repoRoot, greenPaths)
1629
+ if (missingGreen.length > 0) issues.push(`Strict TDD GREEN evidence path(s) do not exist: ${missingGreen.slice(0, 5).join(', ')}`)
1630
+ }
1631
+ } else {
1632
+ const exceptionBody = getHeadingBody(content, 'Pragmatic TDD Exception')
1633
+ if (!exceptionBody) {
1634
+ issues.push('TDD Mode pragmatic requires ## Pragmatic TDD Exception')
1635
+ } else {
1636
+ if (!hasMeaningfulValue(getMdFieldValue(exceptionBody, 'Exception reason'), new Set(['n/a', 'none']))) issues.push('Pragmatic TDD Exception is missing Exception reason')
1637
+ if (!hasMeaningfulValue(getMdFieldValue(exceptionBody, 'Compensating validation'), new Set(['n/a', 'none']))) issues.push('Pragmatic TDD Exception is missing Compensating validation')
1638
+ const pragmaticPaths = collectPathsUnderPrefix(exceptionBody, `.recursive/run/${basename(runDir)}/evidence/`)
1639
+ if (pragmaticPaths.length === 0) {
1640
+ issues.push(`Pragmatic TDD Exception requires compensating evidence paths under \`/.recursive/run/${basename(runDir)}/evidence/\``)
1641
+ } else {
1642
+ const missingPragmatic = findMissingRepoPaths(repoRoot, pragmaticPaths)
1643
+ if (missingPragmatic.length > 0) issues.push(`Pragmatic TDD compensating evidence path(s) do not exist: ${missingPragmatic.slice(0, 5).join(', ')}`)
1644
+ }
1645
+ }
1646
+ }
1647
+ }
1648
+ if (fileName === '05-manual-qa.md') {
1649
+ const qaRecord = getHeadingBody(content, 'QA Execution Record')
1650
+ const evidenceBody = getHeadingBody(content, 'Evidence and Artifacts')
1651
+ const signoffBody = getHeadingBody(content, 'User Sign-Off')
1652
+ const qaMode = (getMdFieldValue(qaRecord, 'QA Execution Mode') ?? getMdFieldValue(content, 'QA Execution Mode') ?? '').toLowerCase()
1653
+ if (!qaRecord) issues.push('Missing or empty section: ## QA Execution Record')
1654
+ if (!QA_EXECUTION_MODES.has(qaMode)) {
1655
+ issues.push('QA Execution Record is missing QA Execution Mode: human|agent-operated|hybrid')
1656
+ } else {
1657
+ if (['human', 'hybrid'].includes(qaMode)) {
1658
+ if (!hasMeaningfulValue(getMdFieldValue(signoffBody, 'Approved by'), new Set(['n/a', 'not required', 'none']))) issues.push(`QA Execution Mode ${qaMode} requires User Sign-Off -> Approved by`)
1659
+ if (!hasMeaningfulValue(getMdFieldValue(signoffBody, 'Date'), new Set(['n/a', 'not required', 'none']))) issues.push(`QA Execution Mode ${qaMode} requires User Sign-Off -> Date`)
1660
+ }
1661
+ if (['agent-operated', 'hybrid'].includes(qaMode)) {
1662
+ if (!hasMeaningfulValue(getMdFieldValue(qaRecord, 'Agent Executor'), new Set(['n/a', 'none']))) issues.push(`QA Execution Mode ${qaMode} requires QA Execution Record -> Agent Executor`)
1663
+ if (!hasMeaningfulValue(getMdFieldValue(qaRecord, 'Tools Used'), new Set(['n/a', 'none']))) issues.push(`QA Execution Mode ${qaMode} requires QA Execution Record -> Tools Used`)
1664
+ const qaPaths = collectPathsUnderPrefix(`${qaRecord}\n${evidenceBody}`, `.recursive/run/${basename(runDir)}/evidence/`)
1665
+ if (qaPaths.length === 0) {
1666
+ issues.push(`QA Execution Mode ${qaMode} requires evidence paths under \`/.recursive/run/${basename(runDir)}/evidence/\``)
1667
+ } else {
1668
+ const missingQa = findMissingRepoPaths(repoRoot, qaPaths)
1669
+ if (missingQa.length > 0) issues.push(`QA evidence path(s) do not exist: ${missingQa.slice(0, 5).join(', ')}`)
1670
+ }
1671
+ }
1672
+ }
1673
+ }
1674
+ if (fileName === '03.5-code-review.md') issues.push(...lintReviewBundleReference(content, runDir, repoRoot))
1675
+ if (fileName === '08-memory-impact.md') issues.push(...lintPhase8SkillUsageCapture(content))
1676
+ return [...new Set(issues)].sort()
1677
+ }
1678
+
1679
+ /** get_header_remediation_lines: copy/paste lines for missing header fields. */
1680
+ export function getHeaderRemediationLines(missingFields: string[]): string[] {
1681
+ const out: string[] = []
1682
+ for (const field of missingFields) {
1683
+ if (field === 'Run') out.push('Run: `/.recursive/run/<run-id>/`')
1684
+ else if (field === 'Phase') out.push('Phase: `<phase name>`')
1685
+ else if (field === 'Status') out.push('Status: `DRAFT`')
1686
+ else if (field === 'Inputs') { out.push('Inputs:'); out.push('- `<path>`') }
1687
+ else if (field === 'Outputs') { out.push('Outputs:'); out.push('- `<path>`') }
1688
+ else if (field === 'Scope note') out.push('Scope note: <one sentence describing what this artifact decides/enables>.')
1689
+ else if (field === 'LockedAt') out.push('LockedAt: `YYYY-MM-DDTHH:mm:ssZ`')
1690
+ else if (field === 'LockHash') out.push('LockHash: `<sha256-hex>`')
1691
+ }
1692
+ return out
1693
+ }
1694
+
1695
+ /** lint_traceability: Traceability section coverage of requirement IDs. */
1696
+ export function lintTraceability(filePath: string, content: string, requirementIds: string[]): string[] {
1697
+ const issues: string[] = []
1698
+ if (!TRACEABILITY_REQUIRED_FILES.has(basename(filePath))) return issues
1699
+ const traceabilityBody = getHeadingBody(content, 'Traceability')
1700
+ if (!traceabilityBody) {
1701
+ issues.push('Missing Traceability section content')
1702
+ return issues
1703
+ }
1704
+ const missingIds = requirementIds.filter(id => !traceabilityBody.includes(id))
1705
+ if (missingIds.length > 0) issues.push(`Traceability is missing explicit coverage for: ${missingIds.join(', ')}`)
1706
+ if (requirementIds.length > 0 && !/\bR\d+\b/.test(traceabilityBody)) issues.push('Traceability is vague and does not mention any requirement IDs')
1707
+ return issues
1708
+ }
1709
+
1710
+ /** lint_audit_sections: audited-phase audit section rules. */
1711
+ export function lintAuditSections(
1712
+ filePath: string,
1713
+ content: string,
1714
+ workflowProfile: string,
1715
+ actualChangedFiles: string[] | null,
1716
+ diffBasisError: string | null,
1717
+ runId: string,
1718
+ runDir: string,
1719
+ ): string[] {
1720
+ const issues: string[] = []
1721
+ if (!STRICT_WORKFLOW_PROFILES.has(workflowProfile) || !AUDITED_PHASE_FILES.has(basename(filePath))) return issues
1722
+ const auditStatus = getGateStatus(content, 'Audit')
1723
+ const coverageStatus = getGateStatus(content, 'Coverage')
1724
+ const approvalStatus = getGateStatus(content, 'Approval')
1725
+ if (auditStatus === 'MISSING') issues.push('Missing required audit verdict line: Audit: PASS|FAIL')
1726
+ if (coverageStatus === 'PASS' && auditStatus !== 'PASS') issues.push('Coverage: PASS is invalid without Audit: PASS')
1727
+ if (approvalStatus === 'PASS' && auditStatus !== 'PASS') issues.push('Approval: PASS is invalid without Audit: PASS')
1728
+ const auditContext = getHeadingBody(content, 'Audit Context')
1729
+ if (!auditContext) {
1730
+ issues.push('Audit Context section is empty')
1731
+ } else {
1732
+ if (!['subagent', 'self-audit'].includes(getMdFieldValue(auditContext, 'Audit Execution Mode') ?? '')) issues.push('Audit Context is missing a valid Audit Execution Mode: subagent|self-audit')
1733
+ if (!['available', 'unavailable'].includes(getMdFieldValue(auditContext, 'Subagent Availability') ?? '')) issues.push('Audit Context is missing a valid Subagent Availability: available|unavailable')
1734
+ if (!hasMeaningfulValue(getMdFieldValue(auditContext, 'Subagent Capability Probe'), new Set(['n/a', 'none']))) issues.push('Audit Context is missing Subagent Capability Probe')
1735
+ if (!hasMeaningfulValue(getMdFieldValue(auditContext, 'Delegation Decision Basis'), new Set(['n/a', 'none']))) issues.push('Audit Context is missing Delegation Decision Basis')
1736
+ if (getMdFieldValue(auditContext, 'Audit Inputs Provided') === null && !auditContext.includes('Audit Inputs Provided:')) issues.push('Audit Context is missing Audit Inputs Provided')
1737
+ issues.push(...collectSubagentDelegationIssues(auditContext))
1738
+ }
1739
+ for (const heading of AUDIT_REQUIRED_HEADINGS) {
1740
+ const sectionBody = getHeadingBody(content, heading)
1741
+ if (!sectionBody) issues.push(`Missing or empty audited-phase section: ## ${heading}`)
1742
+ else if (isPlaceholderOnly(sectionBody)) issues.push(`Audited-phase section still contains placeholder-only content: ## ${heading}`)
1743
+ }
1744
+ const diffAuditBody = getHeadingBody(content, 'Worktree Diff Audit')
1745
+ for (const field of DIFF_BASIS_FIELDS) {
1746
+ if (getMdFieldValue(diffAuditBody, field) === null) issues.push(`Worktree Diff Audit is missing: ${field}:`)
1747
+ }
1748
+ const gapsBody = getHeadingBody(content, 'Gaps Found')
1749
+ if (auditStatus === 'PASS' && gapsBody && !/\bnone\b/i.test(gapsBody)) issues.push('Audit: PASS is invalid while Gaps Found still lists unresolved in-scope gaps')
1750
+ const expectedChangedFiles = getPhaseOwnedActualChangedFiles(basename(filePath), actualChangedFiles, dirname(dirname(dirname(runDir))))
1751
+ if (DIFF_AUDITED_FILES.has(basename(filePath)) && expectedChangedFiles !== null) {
1752
+ if (diffBasisError) {
1753
+ issues.push(`Cannot verify git diff basis: ${diffBasisError}`)
1754
+ } else {
1755
+ const reviewedPaths = collectReviewedPaths(runDir, basename(filePath), content)
1756
+ const missingPaths = expectedChangedFiles.filter(p => !reviewedPaths.has(p))
1757
+ if (missingPaths.length > 0) {
1758
+ const preview = missingPaths.slice(0, 5).join(', ')
1759
+ const suffix = missingPaths.length > 5 ? ' ...' : ''
1760
+ issues.push(`Worktree Diff Audit does not account for actual changed files from git diff: ${preview}${suffix}`)
1761
+ }
1762
+ }
1763
+ }
1764
+ return issues
1765
+ }
1766
+
1767
+ /** lint_artifact_file: full per-artifact lint (canonical lint_artifact_file parity). */
1768
+ export function lintArtifactFile(
1769
+ filePath: string,
1770
+ runDir: string,
1771
+ repoRoot: string,
1772
+ workflowProfile: string,
1773
+ requirementIds: string[],
1774
+ actualChangedFiles: string[] | null,
1775
+ diffBasisError: string | null,
1776
+ ): [number, number] {
1777
+ const content = readFileSync(filePath, 'utf8')
1778
+ const fileName = basename(filePath)
1779
+ const status = getMdFieldValue(content, 'Status') ?? 'UNKNOWN'
1780
+ const missingHeaderFields = ['Run', 'Phase', 'Status', 'Inputs', 'Outputs', 'Scope note'].filter(f => !hasHeaderField(content, f))
1781
+ if (missingHeaderFields.length > 0) {
1782
+ writeIssue('FAIL', filePath, `Missing required header field(s): ${missingHeaderFields.join(', ')}`, getHeaderRemediationLines(missingHeaderFields))
1783
+ return [1, 0]
1784
+ }
1785
+ let failCount = 0
1786
+ let warnCount = 0
1787
+ if (!['DRAFT', 'LOCKED'].includes(status)) {
1788
+ failCount += 1
1789
+ writeIssue('FAIL', filePath, `Invalid Status value '${status}' (expected DRAFT or LOCKED)`, ['Status: `DRAFT`'])
1790
+ }
1791
+ if (status === 'LOCKED') {
1792
+ const lockMissing = ['LockedAt', 'LockHash'].filter(f => !hasHeaderField(content, f))
1793
+ if (lockMissing.length > 0) {
1794
+ failCount += 1
1795
+ writeIssue('FAIL', filePath, `Status is LOCKED but missing: ${lockMissing.join(', ')}`, getHeaderRemediationLines(lockMissing))
1796
+ }
1797
+ }
1798
+ const [hasTodo, , , unchecked] = getTodoStats(content)
1799
+ if (!hasTodo) {
1800
+ failCount += 1
1801
+ writeIssue('FAIL', filePath, 'Missing required section: ## TODO', ['## TODO', '', '- [ ] <task 1>', '- [ ] <task 2>'])
1802
+ } else if (status === 'LOCKED' && unchecked > 0) {
1803
+ failCount += 1
1804
+ writeIssue('FAIL', filePath, `LOCKED artifact has unchecked TODO items: ${unchecked}`, ['# Option A: check all TODO boxes under ## TODO', '# Option B: set Status back to `DRAFT` until TODOs are complete'])
1805
+ }
1806
+ for (const heading of getArtifactRequiredSections(fileName, workflowProfile)) {
1807
+ if (!hasHeading(content, heading)) {
1808
+ failCount += 1
1809
+ writeIssue('FAIL', filePath, `Missing required section heading: ## ${heading}`, [`## ${heading}`, '', '<content>'])
1810
+ }
1811
+ }
1812
+ for (const gate of ['Coverage', 'Approval']) {
1813
+ if (!hasGateLine(content, gate)) {
1814
+ failCount += 1
1815
+ writeIssue('FAIL', filePath, `Missing required gate line: ${gate}: PASS|FAIL`, [`${gate}: FAIL`])
1816
+ }
1817
+ }
1818
+ for (const issue of lintTraceability(filePath, content, requirementIds)) {
1819
+ failCount += 1
1820
+ writeIssue('FAIL', filePath, issue)
1821
+ }
1822
+ for (const issue of lintAuditSections(filePath, content, workflowProfile, actualChangedFiles, diffBasisError, basename(runDir), runDir)) {
1823
+ failCount += 1
1824
+ writeIssue('FAIL', filePath, issue)
1825
+ }
1826
+ for (const issue of lintPhaseSpecificRules(filePath, content, workflowProfile, runDir, repoRoot, requirementIds, actualChangedFiles)) {
1827
+ failCount += 1
1828
+ writeIssue('FAIL', filePath, issue)
1829
+ }
1830
+ if (['04-test-summary.md', '05-manual-qa.md'].includes(fileName)) {
1831
+ const evidenceDir = join(runDir, 'evidence')
1832
+ const requiredSubdirs = ['screenshots', 'logs', 'perf', 'traces']
1833
+ if (!existsSync(evidenceDir)) {
1834
+ warnCount += 1
1835
+ writeIssue('WARN', filePath, `Evidence directory missing at ${evidenceDir}`, [
1836
+ `mkdir -p "${join(evidenceDir, 'screenshots')}"`,
1837
+ `mkdir -p "${join(evidenceDir, 'logs')}"`,
1838
+ `mkdir -p "${join(evidenceDir, 'perf')}"`,
1839
+ `mkdir -p "${join(evidenceDir, 'traces')}"`,
1840
+ `mkdir -p "${join(evidenceDir, 'other')}"`,
1841
+ ])
1842
+ } else {
1843
+ const missingSubdirs = requiredSubdirs.filter(s => !existsSync(join(evidenceDir, s)))
1844
+ if (missingSubdirs.length > 0) {
1845
+ warnCount += 1
1846
+ writeIssue('WARN', filePath, `Evidence subfolder(s) missing under ${evidenceDir}: ${missingSubdirs.join(', ')}`, missingSubdirs.map(s => `mkdir -p "${join(evidenceDir, s)}"`))
1847
+ }
1848
+ }
1849
+ }
1850
+ return [failCount, warnCount]
1851
+ }
1852
+
1853
+ /** is_lock_valid_for_lint: canonical lock-validity check (used for late-phase sequencing). */
1854
+ export function isLockValidForLint(filePath: string, workflowProfile: string): boolean {
1855
+ if (!existsSync(filePath)) return false
1856
+ const content = readFileSync(filePath, 'utf8')
1857
+ const status = getMdFieldValue(content, 'Status') ?? ''
1858
+ const [hasTodo, , , unchecked] = getTodoStats(content)
1859
+ const auditRequired = STRICT_WORKFLOW_PROFILES.has(workflowProfile) && AUDITED_PHASE_FILES.has(basename(filePath))
1860
+ const auditOk = !auditRequired || getGateStatus(content, 'Audit') === 'PASS'
1861
+ const runDir = dirname(filePath)
1862
+ const repoRoot = dirname(dirname(dirname(runDir)))
1863
+ const requirementIds = existsSync(join(runDir, '00-requirements.md')) ? getRunRequirementIds(runDir, workflowProfile) : []
1864
+ let actualChangedFiles: string[] | null = null
1865
+ if (STRICT_WORKFLOW_PROFILES.has(workflowProfile)) {
1866
+ const diffBasis = getRunDiffBasis(runDir)
1867
+ const [rawChanged, ] = getGitChangedFiles(repoRoot, diffBasis)
1868
+ if (rawChanged !== null) actualChangedFiles = filterRuntimeChangedFiles(rawChanged, basename(runDir))
1869
+ }
1870
+ const phaseSpecificIssues = lintPhaseSpecificRules(filePath, content, workflowProfile, runDir, repoRoot, requirementIds, actualChangedFiles)
1871
+ const tddGateOk = basename(filePath) !== '03-implementation-summary.md' || getGateStatus(content, 'TDD Compliance') === 'PASS'
1872
+ return status === 'LOCKED' && hasHeaderField(content, 'LockedAt') && hasHeaderField(content, 'LockHash') && getGateStatus(content, 'Coverage') === 'PASS' && getGateStatus(content, 'Approval') === 'PASS' && auditOk && tddGateOk && hasTodo && unchecked === 0 && phaseSpecificIssues.length === 0
1873
+ }
1874
+
1875
+ /** lint_memory_doc: validate a memory shard's metadata frontmatter. */
1876
+ export function lintMemoryDoc(filePath: string): [number, number] {
1877
+ const content = readFileSync(filePath, 'utf8')
1878
+ let failCount = 0
1879
+ let warnCount = 0
1880
+ const missingFields = MEMORY_REQUIRED_FIELDS.filter(f => !hasHeaderField(content, f))
1881
+ if (missingFields.length > 0) {
1882
+ failCount += 1
1883
+ writeIssue('FAIL', filePath, `Missing required memory metadata field(s): ${missingFields.join(', ')}`)
1884
+ return [failCount, warnCount]
1885
+ }
1886
+ const memoryType = (getMdFieldValue(content, 'Type') ?? '').toLowerCase()
1887
+ if (!MEMORY_ALLOWED_TYPES.has(memoryType)) {
1888
+ failCount += 1
1889
+ writeIssue('FAIL', filePath, `Invalid memory Type '${memoryType}' (expected one of: ${[...MEMORY_ALLOWED_TYPES].sort().join(', ')})`)
1890
+ }
1891
+ const memoryStatus = (getMdFieldValue(content, 'Status') ?? '').toUpperCase()
1892
+ if (!MEMORY_ALLOWED_STATUSES.has(memoryStatus)) {
1893
+ failCount += 1
1894
+ writeIssue('FAIL', filePath, `Invalid memory Status '${memoryStatus}' (expected one of: ${[...MEMORY_ALLOWED_STATUSES].sort().join(', ')})`)
1895
+ }
1896
+ if (hasHeaderField(content, 'Parent') && !getMdFieldValue(content, 'Parent')) {
1897
+ warnCount += 1
1898
+ writeIssue('WARN', filePath, 'Parent metadata field is present but empty')
1899
+ }
1900
+ if (hasHeaderField(content, 'Superseded-By') && !getMdFieldValue(content, 'Superseded-By')) {
1901
+ warnCount += 1
1902
+ writeIssue('WARN', filePath, 'Superseded-By metadata field is present but empty')
1903
+ }
1904
+ return [failCount, warnCount]
1905
+ }
1906
+
1907
+ /** lint_memory_plane: validate the .recursive/memory/ plane. */
1908
+ export function lintMemoryPlane(repoRoot: string): [number, number] {
1909
+ let failCount = 0
1910
+ let warnCount = 0
1911
+ const memoryRoot = join(repoRoot, '.recursive', 'memory')
1912
+ const routerPath = join(memoryRoot, 'MEMORY.md')
1913
+ if (!existsSync(memoryRoot)) {
1914
+ writeIssue('FAIL', memoryRoot, 'Memory plane directory is missing')
1915
+ return [1, 0]
1916
+ }
1917
+ if (!existsSync(routerPath)) {
1918
+ failCount += 1
1919
+ writeIssue('FAIL', routerPath, 'Memory router file is missing')
1920
+ }
1921
+ for (const subdir of ['domains', 'patterns', 'incidents', 'episodes', 'archive', 'skills']) {
1922
+ const full = join(memoryRoot, subdir)
1923
+ if (!existsSync(full)) {
1924
+ warnCount += 1
1925
+ writeIssue('WARN', full, 'Memory subdirectory is missing')
1926
+ }
1927
+ }
1928
+ const skillRouterPath = join(memoryRoot, 'skills', 'SKILLS.md')
1929
+ if (!existsSync(skillRouterPath)) {
1930
+ warnCount += 1
1931
+ writeIssue('WARN', skillRouterPath, 'Skill memory router file is missing')
1932
+ } else {
1933
+ for (const subdir of ['availability', 'usage', 'issues', 'patterns']) {
1934
+ const full = join(memoryRoot, 'skills', subdir)
1935
+ if (!existsSync(full)) {
1936
+ warnCount += 1
1937
+ writeIssue('WARN', full, 'Skill memory subdirectory is missing')
1938
+ }
1939
+ }
1940
+ }
1941
+ const walk = (dir: string): string[] => {
1942
+ const out: string[] = []
1943
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1944
+ const full = join(dir, e.name)
1945
+ if (e.isDirectory()) out.push(...walk(full))
1946
+ else if (e.name.endsWith('.md')) out.push(full)
1947
+ }
1948
+ return out
1949
+ }
1950
+ let docs: string[] = []
1951
+ try { docs = walk(memoryRoot).sort() } catch { docs = [] }
1952
+ for (const doc of docs) {
1953
+ if (SKILL_MEMORY_ROUTER_NAMES.has(basename(doc))) continue
1954
+ const [docFail, docWarn] = lintMemoryDoc(doc)
1955
+ failCount += docFail
1956
+ warnCount += docWarn
1957
+ }
1958
+ return [failCount, warnCount]
1959
+ }
1960
+
1961
+ /** get_prerequisite_blockers: reuse lock.ts chain validation for phase-sequence checks. */
1962
+ import { getPrerequisiteBlockers as getPrereqBlockers, getLockStatus } from './lock.ts'
1963
+
1964
+ /**
1965
+ * lintRun(repoRoot, runId): FULL canonical lint of a recursive run (in-process).
1966
+ * Mirrors lint-recursive-run.py main():
1967
+ * - resolve run dir (runId or latest), derive workflow profile + requirement ids
1968
+ * - validate the diff basis + collect actual changed files (git)
1969
+ * - lint each artifact in RUN_ARTIFACT_SEQUENCE order (WARN if missing),
1970
+ * then addenda, then subagent action records
1971
+ * - phase-sequence prerequisite checks + late-phase requirement checks
1972
+ * - lint the memory plane
1973
+ * - print Summary + return verdict
1974
+ */
1975
+ export function lintRun(repoRoot: string, runId: string): LintResult {
1976
+ issues.length = 0
1977
+ const root = repoRoot
1978
+ const runRoot = join(root, '.recursive', 'run')
1979
+ if (!existsSync(runRoot)) {
1980
+ const msg = `recursive run directory not found at: ${runRoot}\n Is this the project repo root? (Expected .recursive/run/)`
1981
+ console.log(`[FAIL] ${msg}`)
1982
+ return { errors: [msg], warnings: [], failCount: 1, warnCount: 0, passed: false, summary: 'Summary\n- FAIL: 1\n- WARN: 0' }
1983
+ }
1984
+ const runDirs = readdirSync(runRoot, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name).sort()
1985
+ const selectedRuns = runId ? [runId] : (runDirs.length > 0 ? [runDirs[runDirs.length - 1]] : [])
1986
+ if (selectedRuns.length === 0) {
1987
+ const msg = 'no recursive runs found under .recursive/run/'
1988
+ console.log(`[FAIL] ${msg}`)
1989
+ return { errors: [msg], warnings: [], failCount: 1, warnCount: 0, passed: false, summary: 'Summary\n- FAIL: 1\n- WARN: 0' }
1990
+ }
1991
+ let totalFail = 0
1992
+ let totalWarn = 0
1993
+ for (const selectedRun of selectedRuns) {
1994
+ const runDir = join(runRoot, selectedRun)
1995
+ if (!existsSync(runDir)) {
1996
+ totalFail += 1
1997
+ writeIssue('FAIL', runDir, `recursive run directory not found: ${runDir}`)
1998
+ continue
1999
+ }
2000
+ console.log(`Linting run: ${selectedRun}`)
2001
+ console.log(`Path: ${runDir}`)
2002
+ const workflowProfile = getWorkflowProfile(runDir)
2003
+ console.log(`Workflow Profile: ${workflowProfile}`)
2004
+ console.log()
2005
+ const requirementIds = getRunRequirementIds(runDir, workflowProfile)
2006
+ let actualChangedFiles: string[] | null = null
2007
+ let diffBasisError: string | null = null
2008
+ if (STRICT_WORKFLOW_PROFILES.has(workflowProfile)) {
2009
+ const diffBasis = getRunDiffBasis(runDir)
2010
+ if (diffBasis.baseline_reference || diffBasis.normalized_baseline) {
2011
+ const [rawChanged, gitError] = getGitChangedFiles(root, diffBasis)
2012
+ if (gitError) {
2013
+ diffBasisError = gitError
2014
+ totalFail += 1
2015
+ writeIssue('FAIL', runDir, `Unable to verify git diff basis: ${gitError}`)
2016
+ } else if (rawChanged !== null) {
2017
+ actualChangedFiles = filterRuntimeChangedFiles(rawChanged, selectedRun)
2018
+ }
2019
+ }
2020
+ }
2021
+ const expectedArtifacts = RUN_ARTIFACT_SEQUENCE
2022
+ for (const artifact of expectedArtifacts) {
2023
+ const artifactPath = join(runDir, artifact)
2024
+ if (!existsSync(artifactPath)) {
2025
+ const status = getLockStatus(artifactPath)
2026
+ if (status === 'MISSING') {
2027
+ // Canonical emits: [WARN] Missing artifact (ok if not reached yet): <path>
2028
+ // The path is part of the MESSAGE, not the filePath, to mirror the CLI line.
2029
+ issues.push({ severity: 'WARN', filePath: '', message: `Missing artifact (ok if not reached yet): ${artifactPath}` })
2030
+ console.log(`[WARN] Missing artifact (ok if not reached yet): ${artifactPath}`)
2031
+ totalWarn += 1
2032
+ continue
2033
+ }
2034
+ totalFail += 1
2035
+ writeIssue('FAIL', artifactPath, `Artifact exists but cannot be read: ${artifact}`)
2036
+ continue
2037
+ }
2038
+ const [failCount, warnCount] = lintArtifactFile(artifactPath, runDir, root, workflowProfile, requirementIds, actualChangedFiles, diffBasisError)
2039
+ totalFail += failCount
2040
+ totalWarn += warnCount
2041
+ }
2042
+ const addendaDir = join(runDir, 'addenda')
2043
+ if (existsSync(addendaDir)) {
2044
+ const addenda = readdirSync(addendaDir).filter(n => n.endsWith('.md')).sort()
2045
+ for (const addendum of addenda) {
2046
+ const [failCount, warnCount] = lintArtifactFile(join(addendaDir, addendum), runDir, root, workflowProfile, requirementIds, actualChangedFiles, diffBasisError)
2047
+ totalFail += failCount
2048
+ totalWarn += warnCount
2049
+ }
2050
+ }
2051
+ const subagentsDir = join(runDir, 'subagents')
2052
+ if (existsSync(subagentsDir)) {
2053
+ const actionRecords = readdirSync(subagentsDir).filter(n => n.endsWith('.md')).sort()
2054
+ for (const actionRecord of actionRecords) {
2055
+ const issuesFound = lintSubagentActionRecordFile(join(subagentsDir, actionRecord), root, runDir, actualChangedFiles)
2056
+ for (const issue of issuesFound) {
2057
+ totalFail += 1
2058
+ writeIssue('FAIL', join(subagentsDir, actionRecord), issue)
2059
+ }
2060
+ }
2061
+ }
2062
+ // Phase-sequence prerequisite check: later artifacts require earlier LOCKED phases.
2063
+ for (const artifact of expectedArtifacts) {
2064
+ const artifactPath = join(runDir, artifact)
2065
+ if (!existsSync(artifactPath)) continue
2066
+ const blockers = getPrereqBlockers(runDir, artifact)
2067
+ for (const blocker of blockers) {
2068
+ totalFail += 1
2069
+ writeIssue('FAIL', artifactPath, `Phase-sequence violation: '${artifact}' exists but prerequisite '${blocker.artifact}' is ${blocker.status} (must be LOCKED first)`)
2070
+ }
2071
+ }
2072
+ if (STRICT_WORKFLOW_PROFILES.has(workflowProfile) || workflowProfile === COMPAT_WORKFLOW_PROFILE) {
2073
+ const lateRequirements: Array<[string, string]> = [
2074
+ ['05-manual-qa.md', '06-decisions-update.md'],
2075
+ ['06-decisions-update.md', '07-state-update.md'],
2076
+ ['07-state-update.md', '08-memory-impact.md'],
2077
+ ]
2078
+ for (const [priorArtifact, nextArtifact] of lateRequirements) {
2079
+ if (isLockValidForLint(join(runDir, priorArtifact), workflowProfile) && !existsSync(join(runDir, nextArtifact))) {
2080
+ totalFail += 1
2081
+ writeIssue('FAIL', join(runDir, nextArtifact), `Run profile ${workflowProfile} requires ${nextArtifact} after ${priorArtifact} locks`)
2082
+ }
2083
+ }
2084
+ const [memoryFail, memoryWarn] = lintMemoryPlane(root)
2085
+ totalFail += memoryFail
2086
+ totalWarn += memoryWarn
2087
+ }
2088
+ console.log('----')
2089
+ console.log()
2090
+ }
2091
+ console.log('Summary')
2092
+ console.log(`- FAIL: ${totalFail}`)
2093
+ console.log(`- WARN: ${totalWarn}`)
2094
+ console.log()
2095
+ if (totalFail > 0) console.log('[FAIL] Lint failed')
2096
+ else console.log('[OK] Lint passed')
2097
+ const errors: string[] = []
2098
+ const warnings: string[] = []
2099
+ for (const issue of issues) {
2100
+ if (issue.severity === 'FAIL') errors.push(`[FAIL] ${issue.filePath}: ${issue.message}`)
2101
+ else {
2102
+ // Missing-artifact warns carry the path in the message with empty filePath.
2103
+ if (issue.filePath === '') warnings.push(`[WARN] ${issue.message}`)
2104
+ else warnings.push(`[WARN] ${issue.filePath}: ${issue.message}`)
2105
+ }
2106
+ }
2107
+ const summary = `Summary\n- FAIL: ${totalFail}\n- WARN: ${totalWarn}`
2108
+ return { errors, warnings, failCount: totalFail, warnCount: totalWarn, passed: totalFail === 0, summary }
2109
+ }
2110
+