@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/lib/index.js CHANGED
@@ -1,50 +1,11 @@
1
1
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
4
4
  import { Service } from "@deepseek-ai/cordis";
5
- import { execFileSync } from "node:child_process";
6
- import { fileURLToPath } from "node:url";
7
5
  import { createHash } from "node:crypto";
6
+ import { execFileSync } from "node:child_process";
8
7
  import { defineTool } from "@deepseek-ai/dsh-tools";
9
- //#region src/bootstrap.ts
10
- /**
11
- * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
12
- * core: bootstrap the FULL canonical /.recursive/ control plane + cross-tool
13
- * bridges byte-identically (RECURSIVE.md marker-wrapped, AGENTS.md, STATE/
14
- * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore,
15
- * vendored runtime scripts copied into .recursive/scripts/), plus the
16
- * agent/session-start Stage B (new vs resume) workspace-scoped to the session's
17
- * control-plane root (R1).
18
- *
19
- * Templates + bodies + runtime scripts are SHIPPED package files under
20
- * references/ (never inlined TS string literals) and resolved relative to this
21
- * module (package install location), never process.cwd().
22
- */
23
- const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
24
- /** Package root: <package>/src/.. — references/ sits next to src/. */
25
- const PACKAGE_ROOT = join(MODULE_DIR, "..");
26
- /** references/ root (shipped package files). */
27
- function referencesRoot() {
28
- return join(PACKAGE_ROOT, "references");
29
- }
30
- /**
31
- * Enumerate runs as directory names only (bounded O(#dirs)). Never reads
32
- * run docs; never returns file paths.
33
- */
34
- function enumerateRuns(root) {
35
- const runRoot = join(root, ".recursive", "run");
36
- if (!existsSync(runRoot)) return [];
37
- return readdirSync(runRoot, { withFileTypes: true }).filter((d) => {
38
- if (d.isDirectory()) return true;
39
- if (!d.isSymbolicLink()) return false;
40
- try {
41
- return statSync(join(runRoot, d.name)).isDirectory();
42
- } catch {
43
- return false;
44
- }
45
- }).map((d) => d.name).sort();
46
- }
47
- //#endregion
8
+ import { fileURLToPath } from "node:url";
48
9
  //#region src/phase-rules.ts
49
10
  /**
50
11
  * Phase rules (R5): canonical parity port of lint-recursive-run.py's
@@ -55,7 +16,14 @@ function enumerateRuns(root) {
55
16
  * mode-audit-v2).
56
17
  */
57
18
  const CURRENT_WORKFLOW_PROFILE$1 = "recursive-mode-audit-v2";
58
- const STRICT_WORKFLOW_PROFILES$1 = /* @__PURE__ */ new Set([CURRENT_WORKFLOW_PROFILE$1, "recursive-mode-audit-v1"]);
19
+ const STRICT_WORKFLOW_PROFILE$1 = "recursive-mode-audit-v1";
20
+ const COMPAT_WORKFLOW_PROFILE$1 = "memory-phase8";
21
+ const STRICT_WORKFLOW_PROFILES$1 = /* @__PURE__ */ new Set([CURRENT_WORKFLOW_PROFILE$1, STRICT_WORKFLOW_PROFILE$1]);
22
+ const LATE_PHASE_ARTIFACTS = [
23
+ "06-decisions-update.md",
24
+ "07-state-update.md",
25
+ "08-memory-impact.md"
26
+ ];
59
27
  const AUDITED_PHASE_FILES$2 = /* @__PURE__ */ new Set([
60
28
  "01-as-is.md",
61
29
  "01.5-root-cause.md",
@@ -74,6 +42,27 @@ const PRIOR_RECURSIVE_EVIDENCE_FILES = /* @__PURE__ */ new Set([
74
42
  "07-state-update.md",
75
43
  "08-memory-impact.md"
76
44
  ]);
45
+ const DIFF_AUDITED_FILES = /* @__PURE__ */ new Set([
46
+ "02-to-be-plan.md",
47
+ "03-implementation-summary.md",
48
+ "03.5-code-review.md",
49
+ "04-test-summary.md",
50
+ "06-decisions-update.md",
51
+ "07-state-update.md",
52
+ "08-memory-impact.md"
53
+ ]);
54
+ const TRACEABILITY_REQUIRED_FILES = /* @__PURE__ */ new Set([
55
+ "01-as-is.md",
56
+ "01.5-root-cause.md",
57
+ "02-to-be-plan.md",
58
+ "03-implementation-summary.md",
59
+ "03.5-code-review.md",
60
+ "04-test-summary.md",
61
+ "05-manual-qa.md",
62
+ "06-decisions-update.md",
63
+ "07-state-update.md",
64
+ "08-memory-impact.md"
65
+ ]);
77
66
  const AUDIT_REQUIRED_HEADINGS = [
78
67
  "Audit Context",
79
68
  "Effective Inputs Re-read",
@@ -85,6 +74,14 @@ const AUDIT_REQUIRED_HEADINGS = [
85
74
  "Requirement Completion Status",
86
75
  "Audit Verdict"
87
76
  ];
77
+ const DIFF_BASIS_FIELDS = [
78
+ "Baseline type",
79
+ "Baseline reference",
80
+ "Comparison reference",
81
+ "Normalized baseline",
82
+ "Normalized comparison",
83
+ "Normalized diff command"
84
+ ];
88
85
  const SECTION_MAP = {
89
86
  "00-worktree.md": [
90
87
  "TODO",
@@ -267,351 +264,430 @@ function phaseLintRulesMessage(fileName, workflowProfile = CURRENT_WORKFLOW_PROF
267
264
  ].join("\n");
268
265
  }
269
266
  //#endregion
270
- //#region src/init-templates.ts
267
+ //#region src/lock.ts
271
268
  /**
272
- * Init templates (R5): canonical-parity port of recursive-init.py's
273
- * requirements_content / worktree_content / detect_git_context, plus full
274
- * per-phase templates derived from the canonical artifact-template sections
275
- * (get_artifact_required_sections) + TODO + Coverage/Approval FAIL gates.
269
+ * Lock-hash + lock-chain validation for recursive-mode runs.
276
270
  *
277
- * 00-requirements.md + 00-worktree.md are byte-identical to canonical
278
- * recursive-init.py output (LF, no BOM). Later phases are structural: every
279
- * required section heading + the two FAIL gates, so lint fails honestly until
280
- * the phase is actually worked.
281
- */
282
- /**
283
- * detect_git_context(): canonical parity. Returns {context, error} — never
284
- * throws on missing git; error is surfaced in the worktree template note.
271
+ * Ports recursive-lock.py / verify-locks.py / recursive_phase_rules.py
272
+ * semantics (byte-for-byte where observable):
273
+ * - lockHashFromContent: LF-normalize, strip LockHash lines, SHA-256 hex
274
+ * - getLockStatus: MISSING / DRAFT / STALE_LOCK / LOCKED
275
+ * - getPrerequisites / getPrerequisiteBlockers: sequence ordering
276
+ * - validateChain: per-phase validity + break + next legal phase
277
+ * - receipts: read/write/invalidate with Python-identical JSON
278
+ *
279
+ * R3 (run 02).
285
280
  */
286
- function detectGitContext(repoRoot) {
287
- const run = (args) => {
288
- try {
289
- return execFileSync("git", args, {
290
- cwd: repoRoot,
291
- encoding: "utf8",
292
- stdio: [
293
- "ignore",
294
- "pipe",
295
- "ignore"
296
- ]
297
- }).trim();
298
- } catch {
299
- return null;
300
- }
301
- };
302
- const headSha = run([
303
- "rev-parse",
304
- "--verify",
305
- "HEAD^{commit}"
306
- ]);
307
- if (!headSha) return {
308
- context: {},
309
- error: "Unable to resolve HEAD commit for Phase 0 diff basis prefill: git rev-parse returned no output"
310
- };
311
- const branch = run([
312
- "symbolic-ref",
313
- "--quiet",
314
- "--short",
315
- "HEAD"
316
- ]) || "(detached HEAD)";
317
- return {
318
- context: {
319
- baselineType: "local commit",
320
- baselineReference: headSha,
321
- comparisonReference: "working-tree",
322
- normalizedBaseline: headSha,
323
- normalizedComparison: "working-tree",
324
- normalizedDiffCommand: "git diff --name-only " + headSha,
325
- baseBranch: branch,
326
- worktreeBranch: branch,
327
- baseCommit: headSha,
328
- notes: "recursive-init prefilled this executable diff basis from the current HEAD commit. If Phase 0 later changes the chosen baseline, update every diff-basis field and rerun lint before locking."
329
- },
330
- error: null
331
- };
281
+ const PHASE_SEQUENCE = [
282
+ "00-requirements.md",
283
+ "00-worktree.md",
284
+ "01-as-is.md",
285
+ "01.5-root-cause.md",
286
+ "02-to-be-plan.md",
287
+ "03-implementation-summary.md",
288
+ "03.5-code-review.md",
289
+ "04-test-summary.md",
290
+ "05-manual-qa.md",
291
+ "06-decisions-update.md",
292
+ "07-state-update.md",
293
+ "08-memory-impact.md"
294
+ ];
295
+ const OPTIONAL_PHASES = /* @__PURE__ */ new Set([
296
+ "01-as-is.md",
297
+ "01.5-root-cause.md",
298
+ "02-to-be-plan.md",
299
+ "03-implementation-summary.md",
300
+ "03.5-code-review.md",
301
+ "04-test-summary.md",
302
+ "05-manual-qa.md"
303
+ ]);
304
+ const LOCK_HASH_LINE_RE$1 = /^[ \t]*LockHash:.*(?:\n|$)/gm;
305
+ const STATUS_RE = /* @__PURE__ */ new RegExp("^[ \\t]*Status:\\s*(?:`|\")?(\\w+)(?:`|\")?\\s*$", "m");
306
+ const LOCK_HASH_RE = /* @__PURE__ */ new RegExp("^[ \\t]*LockHash:\\s*(?:`|\")?([a-fA-F0-9]{64})(?:`|\")?\\s*$", "m");
307
+ const LOCKED_AT_RE = /* @__PURE__ */ new RegExp("^[ \\t]*LockedAt:\\s*(?:`|\")?([^`\"\\r\\n]+)(?:`|\")?\\s*$", "m");
308
+ /** LF-normalize, strip every LockHash: line, return the normalized content. */
309
+ function normalizeForLockHash(content) {
310
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(LOCK_HASH_LINE_RE$1, "");
311
+ }
312
+ /** SHA-256 hex over the UTF-8 bytes of the normalized content. */
313
+ function lockHashFromContent(content) {
314
+ const normalized = normalizeForLockHash(content);
315
+ return createHash("sha256").update(normalized, "utf8").digest("hex");
316
+ }
317
+ function phaseIndex(artifactFile) {
318
+ return PHASE_SEQUENCE.indexOf(artifactFile);
319
+ }
320
+ function isCoreArtifact(artifactFile) {
321
+ return phaseIndex(artifactFile) >= 0;
332
322
  }
333
323
  /**
334
- * requirements_content(run_id, template, from_issue): byte-identical to the
335
- * canonical recursive-init.py function (LF, template flavor in Scope note).
324
+ * Every earlier phase in PHASE_SEQUENCE that exists on disk.
325
+ * Mirrors: [phase for phase in PHASE_SEQUENCE[:idx] if (run_dir / phase).exists()]
336
326
  */
337
- function requirementsContent(runId, template = "feature", fromIssue = "") {
338
- const inputs = ["- [chat summary or source notes if captured in repo]"];
339
- if (fromIssue.trim()) inputs.push("- Source: " + fromIssue.trim());
340
- const inputsBlock = inputs.join("\n");
341
- return [
342
- "Run: `/.recursive/run/" + runId + "/`",
343
- "Phase: `00 Requirements`",
344
- "Status: `DRAFT`",
345
- "Workflow version: `recursive-mode-audit-v2`",
346
- "Inputs:",
347
- inputsBlock,
348
- "Outputs:",
349
- "- `/.recursive/run/" + runId + "/00-requirements.md`",
350
- "Scope note: This document defines stable requirement identifiers and acceptance criteria. (Template: " + template + ")",
351
- "",
352
- "## TODO",
353
- "",
354
- "- [ ] Elicit requirements from user/context",
355
- "- [ ] Define requirement identifiers (R1, R2, ...)",
356
- "- [ ] Write acceptance criteria for each requirement",
357
- "- [ ] Document out of scope items (OOS1, OOS2, ...)",
358
- "- [ ] List constraints and assumptions",
359
- "- [ ] Complete Coverage Gate checklist",
360
- "- [ ] Complete Approval Gate checklist",
361
- "",
362
- "## Requirements",
363
- "",
364
- "### `R1` <short title>",
365
- "",
366
- "Description:",
367
- "Acceptance criteria:",
368
- "- [observable condition 1]",
369
- "- [observable condition 2]",
370
- "",
371
- "## Out of Scope",
372
- "",
373
- "- `OOS1`: ...",
374
- "",
375
- "## Constraints",
376
- "",
377
- "- ...",
378
- "",
379
- "## Coverage Gate",
380
- "...",
381
- "Coverage: FAIL",
382
- "",
383
- "## Approval Gate",
384
- "...",
385
- "Approval: FAIL"
386
- ].join("\n") + "\n";
327
+ function getPrerequisites(runDir, artifactFile) {
328
+ const idx = phaseIndex(artifactFile);
329
+ if (idx <= 0) return [];
330
+ const prereqs = [];
331
+ for (const phase of PHASE_SEQUENCE.slice(0, idx)) if (existsSync(join(runDir, phase))) prereqs.push(phase);
332
+ return prereqs;
387
333
  }
388
334
  /**
389
- * worktree_content(run_id, repo_root, git_context, prefill_error): byte-identical
390
- * to the canonical recursive-init.py function.
335
+ * Canonical lock-validity classifier:
336
+ * MISSING (no file) / DRAFT (not LOCKED) / STALE_LOCK (missing or mismatched hash fields) / LOCKED.
391
337
  */
392
- function worktreeContent(runId, repoRoot, git, prefillError) {
393
- const baseBranch = git.baseBranch ?? "(resolve during Phase 0)";
394
- const worktreeBranch = git.worktreeBranch ?? "(resolve during Phase 0)";
395
- const baseCommit = git.baseCommit ?? "<resolve-before-locking>";
396
- const baselineType = git.baselineType ?? "local commit";
397
- const baselineReference = git.baselineReference ?? "<resolve-before-locking>";
398
- const comparisonReference = git.comparisonReference ?? "working-tree";
399
- const normalizedBaseline = git.normalizedBaseline ?? "<resolve-before-locking>";
400
- const normalizedComparison = git.normalizedComparison ?? "working-tree";
401
- const normalizedDiffCommand = git.normalizedDiffCommand ?? "git diff --name-only <resolve-before-locking>";
402
- const notes = git.notes ?? "Populate an executable diff basis before locking Phase 0. Lint and lock will fail until the normalized basis matches live git state.";
403
- const setupNote = prefillError ? "recursive-init could not prefill the Phase 0 diff basis automatically: " + prefillError : "recursive-init detected the current repository context and prefilled the Phase 0 diff basis.";
404
- return [
405
- "Run: `/.recursive/run/" + runId + "/`",
406
- "Phase: `00 Worktree`",
407
- "Status: `DRAFT`",
408
- "Inputs:",
409
- "- `/.recursive/run/" + runId + "/00-requirements.md`",
410
- "- Current git repository state",
411
- "Outputs:",
412
- "- `/.recursive/run/" + runId + "/00-worktree.md`",
413
- "Scope note: This document records the Phase 0 worktree context and the executable diff basis that all later audited phases must reuse.",
414
- "",
415
- "## TODO",
416
- "",
417
- "- [ ] Confirm the selected worktree location and isolation approach",
418
- "- [ ] Confirm the base branch and worktree branch values",
419
- "- [ ] Run setup and verify the clean test baseline",
420
- "- [ ] Confirm the diff basis fields still match live git state",
421
- "- [ ] Complete Coverage Gate checklist",
422
- "- [ ] Complete Approval Gate checklist",
423
- "",
424
- "## Directory Selection",
425
- "",
426
- "- Repository root: `" + repoRoot + "`",
427
- "- Preferred worktree location: `.worktrees/" + runId + "/`",
428
- "- Update this section with the actual selected location before locking Phase 0.",
429
- "",
430
- "## Safety Verification",
431
- "",
432
- "- Original branch / repo state observed at init time: `" + baseBranch + "`",
433
- "- Isolation still must be confirmed after the actual worktree is created.",
434
- "",
435
- "## Worktree Creation",
436
- "",
437
- "- Intended worktree branch: `" + worktreeBranch + "`",
438
- "- Record the actual worktree creation command and output before locking.",
439
- "",
440
- "## Main Branch Protection",
441
- "",
442
- "- Base branch source of truth at init time: `" + baseBranch + "`",
443
- "- Explicitly document any deviation from isolated worktree execution before locking.",
444
- "",
445
- "## Project Setup",
446
- "",
447
- "- Init-time note: " + setupNote,
448
- "- Replace this section with the actual setup commands and results during Phase 0.",
449
- "",
450
- "## Test Baseline Verification",
451
- "",
452
- "- Record the baseline commands and results after setup completes.",
453
- "",
454
- "## Worktree Context",
455
- "",
456
- "- Base branch: `" + baseBranch + "`",
457
- "- Worktree branch: `" + worktreeBranch + "`",
458
- "- Base commit: `" + baseCommit + "`",
459
- "",
460
- "## Diff Basis For Later Audits",
461
- "",
462
- "- Baseline type: `" + baselineType + "`",
463
- "- Baseline reference: `" + baselineReference + "`",
464
- "- Comparison reference: `" + comparisonReference + "`",
465
- "- Normalized baseline: `" + normalizedBaseline + "`",
466
- "- Normalized comparison: `" + normalizedComparison + "`",
467
- "- Normalized diff command: `" + normalizedDiffCommand + "`",
468
- "- Base branch: `" + baseBranch + "`",
469
- "- Worktree branch: `" + worktreeBranch + "`",
470
- "- Diff basis notes: `" + notes + "`",
471
- "",
472
- "## Traceability",
473
- "",
474
- "- Recursive workflow safety -> Phase 0 records a reusable executable diff basis before audited phases begin.",
475
- "",
476
- "## Coverage Gate",
477
- "",
478
- "- [ ] Worktree location and branch context are recorded",
479
- "- [ ] Setup and clean baseline verification are recorded",
480
- "- [ ] Diff basis fields are executable against live git state",
481
- "",
482
- "Coverage: FAIL",
483
- "",
484
- "## Approval Gate",
485
- "",
486
- "- [ ] Phase 0 context is ready for downstream audited phases",
487
- "- [ ] No unresolved setup or diff-basis inconsistencies remain",
488
- "",
489
- "Approval: FAIL"
490
- ].join("\n") + "\n";
338
+ function getLockStatus(artifactPath) {
339
+ if (!existsSync(artifactPath)) return "MISSING";
340
+ let content;
341
+ try {
342
+ content = readFileSync(artifactPath, "utf8");
343
+ } catch {
344
+ return "MISSING";
345
+ }
346
+ const statusMatch = STATUS_RE.exec(content);
347
+ if (!statusMatch || statusMatch[1] !== "LOCKED") return "DRAFT";
348
+ const hashMatch = LOCK_HASH_RE.exec(content);
349
+ const lockedAtMatch = LOCKED_AT_RE.exec(content);
350
+ if (!hashMatch || !lockedAtMatch) return "STALE_LOCK";
351
+ if (hashMatch[1].toLowerCase() !== lockHashFromContent(content)) return "STALE_LOCK";
352
+ return "LOCKED";
491
353
  }
492
- /**
493
- * Later-phase template: required sections (canonical get_artifact_required_
494
- * sections) as ## headings + TODO + FAIL gates. Structural scaffold: the phase
495
- * author fills each section; lint stays FAIL until Coverage/Approval are set.
496
- */
497
- function laterPhaseContent(runId, fileName, workflowProfile = "recursive-mode-audit-v2") {
498
- const sections = getArtifactRequiredSections(fileName, workflowProfile);
499
- const lines = [
500
- "Run: `/.recursive/run/" + runId + "/`",
501
- "Phase: `" + fileName.replace(/\.md$/, "").replace(/^0?/, "") + "`",
502
- "Status: `DRAFT`",
503
- "Workflow version: `" + workflowProfile + "`",
504
- "Inputs:",
505
- "- (list upstream artifacts re-read for this phase)",
506
- "Outputs:",
507
- "- `/.recursive/run/" + runId + "/" + fileName + "`",
508
- "Scope note: Scaffold generated by the recursive-mode plugin (R5). Fill every required section before lint.",
509
- "",
510
- "## TODO",
511
- "",
512
- "- [ ] Work through every required section below",
513
- "- [ ] Record evidence and traceability",
514
- "- [ ] Complete Coverage Gate checklist",
515
- "- [ ] Complete Approval Gate checklist"
516
- ];
517
- for (const section of sections) {
518
- if (section === "TODO") continue;
519
- lines.push("", "## " + section, "", "- (fill)");
354
+ /** Every prerequisite whose status is not LOCKED, in PHASE_SEQUENCE order. */
355
+ function getPrerequisiteBlockers(runDir, artifactFile) {
356
+ const blockers = [];
357
+ for (const prereq of getPrerequisites(runDir, artifactFile)) {
358
+ const status = getLockStatus(join(runDir, prereq));
359
+ if (status !== "LOCKED") blockers.push({
360
+ artifact: prereq,
361
+ status,
362
+ path: join(runDir, prereq)
363
+ });
520
364
  }
521
- lines.push("", "## Coverage Gate", "", "Coverage: FAIL", "", "## Approval Gate", "", "Approval: FAIL", "");
522
- return lines.join("\n");
365
+ return blockers;
523
366
  }
524
- /**
525
- * Scaffold dirs under a run (addenda/subagents/router-prompts/evidence + subs).
526
- * Mirrors canonical recursive-init.py's ensure_directory sequence.
527
- */
528
- const RUN_SCAFFOLD_DIRS = [
529
- "addenda",
530
- "subagents",
531
- "router-prompts",
532
- "evidence",
533
- "evidence/screenshots",
534
- "evidence/logs",
535
- "evidence/perf",
536
- "evidence/traces",
537
- "evidence/review-bundles",
538
- "evidence/router",
539
- "evidence/other"
540
- ];
541
- //#endregion
542
- //#region src/run.ts
543
- /**
544
- * List run ids under <repoRoot>/.recursive/run/, excluding non-directories.
545
- * Mirrors: runs = [p for p in run_root.iterdir() if p.is_dir()]
546
- */
547
- function discoverRuns(repoRoot) {
548
- const runRoot = join(repoRoot, ".recursive", "run");
549
- let runs = [];
367
+ function receiptPath(runDir, artifactFile) {
368
+ const stem = artifactFile.replace(/\.md$/, "");
369
+ return join(runDir, "locks", stem + ".receipt.json");
370
+ }
371
+ function readReceipt(runDir, artifactFile) {
372
+ const rpath = receiptPath(runDir, artifactFile);
373
+ if (!existsSync(rpath)) return null;
550
374
  try {
551
- runs = readdirSync(runRoot, { withFileTypes: true }).filter((d) => {
552
- if (d.isDirectory()) return true;
553
- if (!d.isSymbolicLink()) return false;
554
- try {
555
- return statSync(join(runRoot, d.name)).isDirectory();
556
- } catch {
557
- return false;
558
- }
559
- }).map((d) => d.name);
375
+ return JSON.parse(readFileSync(rpath, "utf8"));
560
376
  } catch {
561
- return [];
377
+ return null;
562
378
  }
563
- return runs;
564
379
  }
565
380
  /**
566
- * Resolve the run directory for an explicit run id, or the latest run by mtime.
567
- * Mirrors get_latest_run_directory: sort by st_mtime descending, stable sort
568
- * (ties preserve discovery order — no name-based tie-break).
381
+ * Write a lock receipt with Python-identical JSON semantics.
569
382
  */
570
- function resolveRunDir(repoRoot, runId) {
571
- const runRoot = join(repoRoot, ".recursive", "run");
572
- if (runId && runId.trim() !== "") return {
573
- runDir: join(runRoot, runId.trim()),
574
- runId: runId.trim()
383
+ function writeReceipt(runDir, artifactFile, artifactPath) {
384
+ const artifactHash = lockHashFromContent(readFileSync(artifactPath, "utf8"));
385
+ const prereqHashes = {};
386
+ for (const prereq of getPrerequisites(runDir, artifactFile)) {
387
+ const prereqPath = join(runDir, prereq);
388
+ if (existsSync(prereqPath)) {
389
+ const prereqStatus = getLockStatus(prereqPath);
390
+ if (prereqStatus !== "LOCKED") throw new Error(`Cannot write receipt for '${artifactFile}': prerequisite '${prereq}' is not LOCKED (status: ${prereqStatus})`);
391
+ prereqHashes[prereq] = lockHashFromContent(readFileSync(prereqPath, "utf8"));
392
+ }
393
+ }
394
+ const existing = readReceipt(runDir, artifactFile);
395
+ const prevReceiptHash = existing ? existing.receipt_hash : null;
396
+ const base = {
397
+ artifact: artifactFile,
398
+ artifact_path: artifactPath,
399
+ artifact_hash: artifactHash,
400
+ locked_at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
401
+ prerequisite_hashes: prereqHashes,
402
+ previous_receipt_hash: prevReceiptHash
575
403
  };
576
- const latest = getLatestRunDirectory(runRoot);
577
- if (!latest) return null;
578
- return {
579
- runDir: join(runRoot, latest),
580
- runId: latest
404
+ const receiptHash = createHash("sha256").update(pythonJsonDumps(base), "utf8").digest("hex");
405
+ const receipt = {
406
+ ...base,
407
+ receipt_hash: receiptHash
581
408
  };
409
+ const locksDir = join(runDir, "locks");
410
+ mkdirSync(locksDir, { recursive: true });
411
+ const rpath = receiptPath(runDir, artifactFile);
412
+ writeFileSync(rpath, pythonJsonDumpsIndent(receipt), "utf8");
413
+ return receipt;
414
+ }
415
+ function invalidateReceipt(runDir, artifactFile) {
416
+ const rpath = receiptPath(runDir, artifactFile);
417
+ if (existsSync(rpath)) {
418
+ rmSync(rpath, { force: true });
419
+ return true;
420
+ }
421
+ return false;
582
422
  }
583
423
  /**
584
- * Latest run directory by mtime (descending), stable sort preserving
585
- * discovery order for exact ties. Returns null when no runs exist.
424
+ * Downstream phases (strictly after artifactFile in PHASE_SEQUENCE) whose
425
+ * receipt's prerequisite_hashes[artifactFile] differs from the current hash
426
+ * (or the artifact no longer exists).
586
427
  */
587
- function getLatestRunDirectory(runRoot) {
588
- let runs = [];
589
- try {
590
- runs = readdirSync(runRoot, { withFileTypes: true }).filter((d) => {
591
- if (d.isDirectory()) return true;
592
- if (!d.isSymbolicLink()) return false;
593
- try {
594
- return statSync(join(runRoot, d.name)).isDirectory();
595
- } catch {
596
- return false;
597
- }
598
- }).map((d) => d.name);
599
- } catch {
600
- return null;
428
+ function getStaleDownstreamPhases(runDir, artifactFile) {
429
+ const idx = phaseIndex(artifactFile);
430
+ if (idx < 0) return [];
431
+ let currentHash = null;
432
+ const artifactPath = join(runDir, artifactFile);
433
+ if (existsSync(artifactPath)) currentHash = lockHashFromContent(readFileSync(artifactPath, "utf8"));
434
+ const stale = [];
435
+ for (const downstream of PHASE_SEQUENCE.slice(idx + 1)) {
436
+ const receipt = readReceipt(runDir, downstream);
437
+ if (!receipt) continue;
438
+ const prereqHashes = receipt.prerequisite_hashes ?? {};
439
+ if (!(artifactFile in prereqHashes)) continue;
440
+ const storedHash = prereqHashes[artifactFile];
441
+ if (currentHash === null || storedHash !== currentHash) stale.push({
442
+ artifact: downstream,
443
+ reason: `prerequisite '${artifactFile}' hash changed`
444
+ });
601
445
  }
602
- if (runs.length === 0) return null;
603
- runs.sort((a, b) => {
604
- try {
605
- return statSync(join(runRoot, b)).mtimeMs - statSync(join(runRoot, a)).mtimeMs;
606
- } catch {
607
- return 0;
446
+ return stale;
447
+ }
448
+ function getNextLegalPhase(runDir) {
449
+ for (const phase of PHASE_SEQUENCE) {
450
+ const phasePath = join(runDir, phase);
451
+ if (getLockStatus(phasePath) === "LOCKED") continue;
452
+ if (!existsSync(phasePath) && OPTIONAL_PHASES.has(phase)) continue;
453
+ if (getPrerequisiteBlockers(runDir, phase).length > 0) return null;
454
+ return phase;
455
+ }
456
+ return null;
457
+ }
458
+ /** All receipts whose prerequisite_hashes reference a missing or changed artifact. */
459
+ function getAllStaleReceipts(runDir) {
460
+ const stale = [];
461
+ for (const phase of PHASE_SEQUENCE) {
462
+ const receipt = readReceipt(runDir, phase);
463
+ if (!receipt) continue;
464
+ const prereqHashes = receipt.prerequisite_hashes ?? {};
465
+ for (const [prereq, storedHash] of Object.entries(prereqHashes)) {
466
+ const prereqPath = join(runDir, prereq);
467
+ if (!existsSync(prereqPath)) {
468
+ stale.push({
469
+ artifact: phase,
470
+ reason: `prerequisite '${prereq}' no longer exists`
471
+ });
472
+ continue;
473
+ }
474
+ if (storedHash !== lockHashFromContent(readFileSync(prereqPath, "utf8"))) stale.push({
475
+ artifact: phase,
476
+ reason: `prerequisite '${prereq}' content changed since lock at ${receipt.locked_at}`
477
+ });
608
478
  }
609
- });
610
- return runs[0];
479
+ }
480
+ return stale;
481
+ }
482
+ /**
483
+ * Validate the full lock chain of a run: per-phase lock status, break phase,
484
+ * next legal phase, completion, and stale receipts.
485
+ */
486
+ function validateChain(runDir, runId) {
487
+ const phases = [];
488
+ let breakPhase = null;
489
+ let complete = true;
490
+ for (const phase of PHASE_SEQUENCE) {
491
+ const status = getLockStatus(join(runDir, phase));
492
+ const problems = [];
493
+ const lockValid = status === "LOCKED";
494
+ if (!lockValid && breakPhase === null) {
495
+ breakPhase = phase;
496
+ complete = false;
497
+ }
498
+ if (!lockValid && status !== "MISSING") {
499
+ if (status === "DRAFT") problems.push(`Status is not LOCKED (${status})`);
500
+ else if (status === "STALE_LOCK") problems.push("LockHash mismatch or missing lock fields");
501
+ }
502
+ phases.push({
503
+ file: phase,
504
+ status,
505
+ lockValid,
506
+ lockProblems: problems
507
+ });
508
+ }
509
+ const nextLegalPhase = getNextLegalPhase(runDir);
510
+ const staleReceipts = getAllStaleReceipts(runDir);
511
+ return {
512
+ runId,
513
+ phases,
514
+ breakPhase,
515
+ nextLegalPhase,
516
+ complete,
517
+ staleReceipts
518
+ };
519
+ }
520
+ /** Python json.dumps(obj, sort_keys=True) compact serialization (receipt_hash input). */
521
+ function pythonJsonDumps(obj) {
522
+ const rec = obj;
523
+ return "{" + Object.keys(rec).sort().map((key) => {
524
+ const value = rec[key];
525
+ return `${jsonString(key)}: ${jsonValue(value)}`;
526
+ }).join(", ") + "}";
527
+ }
528
+ /** Python json.dumps(obj, indent=2, sort_keys=True) disk serialization. */
529
+ function pythonJsonDumpsIndent(obj) {
530
+ const rec = obj;
531
+ return "{\n" + Object.keys(rec).sort().map((key) => {
532
+ const value = rec[key];
533
+ return " " + jsonString(key) + ": " + jsonValueIndent(value, 2);
534
+ }).join(",\n") + "\n}";
535
+ }
536
+ function jsonString(s) {
537
+ return JSON.stringify(s);
538
+ }
539
+ function jsonValue(value) {
540
+ if (value === null) return "null";
541
+ if (typeof value === "string") return jsonString(value);
542
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
543
+ if (Array.isArray(value)) return "[" + value.map(jsonValue).join(", ") + "]";
544
+ if (typeof value === "object") return pythonJsonDumps(value);
545
+ return "null";
546
+ }
547
+ function jsonValueIndent(value, depth) {
548
+ if (value === null) return "null";
549
+ if (typeof value === "string") return jsonString(value);
550
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
551
+ if (Array.isArray(value)) return "[" + value.map((v) => jsonValueIndent(v, depth)).join(", ") + "]";
552
+ if (typeof value === "object") {
553
+ const obj = value;
554
+ const keys = Object.keys(obj).sort();
555
+ const pad = " ".repeat(depth);
556
+ return "{\n" + keys.map((key) => pad + " " + jsonString(key) + ": " + jsonValueIndent(obj[key], depth + 1)).join(",\n") + "\n" + pad + "}";
557
+ }
558
+ return "null";
611
559
  }
612
560
  //#endregion
613
- //#region src/status.ts
614
- const RUN_ARTIFACT_SEQUENCE = [
561
+ //#region src/ts-lint.ts
562
+ /**
563
+ * ts-lint.ts (R1, run 09): FULL TS port of the canonical lint-recursive-run.py.
564
+ * In-process lint with byte-identical [FAIL]/[WARN] verdicts + Summary.
565
+ * No python is shelled out anywhere in the plugin.
566
+ *
567
+ * Constants + per-phase required sections are imported from phase-rules.ts
568
+ * (single source of truth, already at 14/14 canonical parity).
569
+ */
570
+ /** get_prerequisite_blockers: reuse lock.ts chain validation for phase-sequence checks. */
571
+ const PRODUCT_DIFF_PHASE_FILES = /* @__PURE__ */ new Set([
572
+ "03-implementation-summary.md",
573
+ "03.5-code-review.md",
574
+ "04-test-summary.md"
575
+ ]);
576
+ const DECISIONS_DIFF_PHASE_FILES = /* @__PURE__ */ new Set(["06-decisions-update.md"]);
577
+ const STATE_DIFF_PHASE_FILES = /* @__PURE__ */ new Set(["07-state-update.md"]);
578
+ const MEMORY_DIFF_PHASE_FILES = /* @__PURE__ */ new Set(["08-memory-impact.md"]);
579
+ const MEMORY_ALLOWED_TYPES = /* @__PURE__ */ new Set([
580
+ "index",
581
+ "domain",
582
+ "pattern",
583
+ "incident",
584
+ "episode"
585
+ ]);
586
+ const MEMORY_ALLOWED_STATUSES = /* @__PURE__ */ new Set([
587
+ "CURRENT",
588
+ "SUSPECT",
589
+ "STALE",
590
+ "DEPRECATED",
591
+ "DRAFT"
592
+ ]);
593
+ const MEMORY_REQUIRED_FIELDS = [
594
+ "Type",
595
+ "Status",
596
+ "Scope",
597
+ "Owns-Paths",
598
+ "Watch-Paths",
599
+ "Source-Runs",
600
+ "Validated-At-Commit",
601
+ "Last-Validated",
602
+ "Tags"
603
+ ];
604
+ const TDD_MODES = /* @__PURE__ */ new Set(["strict", "pragmatic"]);
605
+ const QA_EXECUTION_MODES = /* @__PURE__ */ new Set([
606
+ "human",
607
+ "agent-operated",
608
+ "hybrid"
609
+ ]);
610
+ const TRANSIENT_RUNTIME_DIR_MARKERS = /* @__PURE__ */ new Set([
611
+ "__pycache__",
612
+ ".pytest_cache",
613
+ ".mypy_cache",
614
+ ".ruff_cache",
615
+ ".hypothesis",
616
+ ".tox",
617
+ ".nox",
618
+ ".target",
619
+ ".playwright-mcp",
620
+ ".cargo-target-dir"
621
+ ]);
622
+ const TRANSIENT_RUNTIME_FILE_NAMES = /* @__PURE__ */ new Set([".ds_store", "thumbs.db"]);
623
+ const TRANSIENT_RUNTIME_SUFFIXES = [
624
+ ".pyc",
625
+ ".pyo",
626
+ ".pyd"
627
+ ];
628
+ const DIFF_BASIS_ALLOWED_TYPES = /* @__PURE__ */ new Set([
629
+ "local commit",
630
+ "local branch",
631
+ "remote ref",
632
+ "merge-base derived"
633
+ ]);
634
+ const WORKING_TREE_COMPARISON_REFS = /* @__PURE__ */ new Set([
635
+ "working-tree",
636
+ "working-tree@head",
637
+ "worktree",
638
+ "working-tree+head"
639
+ ]);
640
+ const INVENTORY_DISPOSITIONS = /* @__PURE__ */ new Set([
641
+ "in-scope",
642
+ "out-of-scope",
643
+ "constraint",
644
+ "quality-gate"
645
+ ]);
646
+ const PHASE2_REQUIREMENT_DISPOSITION_STATUSES = /* @__PURE__ */ new Set([
647
+ "planned",
648
+ "planned-via-merge",
649
+ "planned-indirectly",
650
+ "deferred",
651
+ "out-of-scope",
652
+ "blocked",
653
+ "superseded by approved addendum"
654
+ ]);
655
+ const REQUIREMENT_DISPOSITION_STATUSES = /* @__PURE__ */ new Set([
656
+ "implemented",
657
+ "verified",
658
+ "deferred",
659
+ "out-of-scope",
660
+ "blocked",
661
+ "superseded by approved addendum"
662
+ ]);
663
+ const SKILL_USAGE_RELEVANCE_STATUSES = /* @__PURE__ */ new Set([
664
+ "relevant",
665
+ "not-relevant",
666
+ "yes",
667
+ "no"
668
+ ]);
669
+ const FINAL_REQUIREMENT_DISPOSITION_FILES = /* @__PURE__ */ new Set([
670
+ "04-test-summary.md",
671
+ "06-decisions-update.md",
672
+ "07-state-update.md",
673
+ "08-memory-impact.md"
674
+ ]);
675
+ const REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES = /* @__PURE__ */ new Set([
676
+ "03-implementation-summary.md",
677
+ "03.5-code-review.md",
678
+ "04-test-summary.md"
679
+ ]);
680
+ const SUBAGENT_ACTION_REQUIRED_HEADINGS = [
681
+ "Metadata",
682
+ "Inputs Provided",
683
+ "Claimed Actions Taken",
684
+ "Claimed File Impact",
685
+ "Claimed Artifact Impact",
686
+ "Claimed Findings",
687
+ "Verification Handoff"
688
+ ];
689
+ const SKILL_MEMORY_ROUTER_NAMES = /* @__PURE__ */ new Set(["MEMORY.md", "SKILLS.md"]);
690
+ const RUN_ARTIFACT_SEQUENCE$1 = [
615
691
  "00-requirements.md",
616
692
  "00-worktree.md",
617
693
  "01-as-is.md",
@@ -625,122 +701,24 @@ const RUN_ARTIFACT_SEQUENCE = [
625
701
  "07-state-update.md",
626
702
  "08-memory-impact.md"
627
703
  ];
628
- const PHASES = [
629
- {
630
- key: "00R",
631
- label: "Phase 0 (Requirements)",
632
- file: "00-requirements.md",
633
- optional: false,
634
- phaseName: "0 (Requirements)"
635
- },
636
- {
637
- key: "00W",
638
- label: "Phase 0 (Worktree)",
639
- file: "00-worktree.md",
640
- optional: false,
641
- phaseName: "0 (Worktree)"
642
- },
643
- {
644
- key: "01",
645
- label: "Phase 1 (AS-IS)",
646
- file: "01-as-is.md",
647
- optional: false,
648
- phaseName: "1 (AS-IS)"
649
- },
650
- {
651
- key: "01.5",
652
- label: "Phase 1.5 (Root Cause)",
653
- file: "01.5-root-cause.md",
654
- optional: true,
655
- phaseName: "1.5 (Root Cause)"
656
- },
657
- {
658
- key: "02",
659
- label: "Phase 2 (TO-BE Plan)",
660
- file: "02-to-be-plan.md",
661
- optional: false,
662
- phaseName: "2 (TO-BE Plan)"
663
- },
664
- {
665
- key: "03",
666
- label: "Phase 3 (Implementation)",
667
- file: "03-implementation-summary.md",
668
- optional: false,
669
- phaseName: "3 (Implementation)"
670
- },
671
- {
672
- key: "03.5",
673
- label: "Phase 3.5 (Code Review)",
674
- file: "03.5-code-review.md",
675
- optional: true,
676
- phaseName: "3.5 (Code Review)"
677
- },
678
- {
679
- key: "04",
680
- label: "Phase 4 (Test Summary)",
681
- file: "04-test-summary.md",
682
- optional: false,
683
- phaseName: "4 (Test Summary)"
684
- },
685
- {
686
- key: "05",
687
- label: "Phase 5 (Manual QA)",
688
- file: "05-manual-qa.md",
689
- optional: false,
690
- phaseName: "5 (Manual QA)"
691
- },
692
- {
693
- key: "06",
694
- label: "Phase 6 (Decisions)",
695
- file: "06-decisions-update.md",
696
- optional: false,
697
- phaseName: "6 (Decisions Update)"
698
- },
699
- {
700
- key: "07",
701
- label: "Phase 7 (State)",
702
- file: "07-state-update.md",
703
- optional: false,
704
- phaseName: "7 (State Update)"
705
- },
706
- {
707
- key: "08",
708
- label: "Phase 8 (Memory)",
709
- file: "08-memory-impact.md",
710
- optional: false,
711
- phaseName: "8 (Memory Impact)"
704
+ const REQUIREMENT_ID_RE = /^(R\d+|SRC-\d{3})$/;
705
+ /** write_issue: collect [severity] path: message + remediation lines. */
706
+ const issues = [];
707
+ function writeIssue(severity, filePath, message, remediationLines) {
708
+ issues.push({
709
+ severity,
710
+ filePath,
711
+ message
712
+ });
713
+ console.log(`[${severity}] ${filePath}: ${message}`);
714
+ if (remediationLines) {
715
+ console.log("Remediation (copy/paste):");
716
+ for (const line of remediationLines) console.log(" " + line);
712
717
  }
713
- ];
714
- const LOCK_HASH_LINE_RE$1 = /^[ \t]*LockHash:.*(?:\n|$)/gm;
715
- const STRICT_WORKFLOW_PROFILES = /* @__PURE__ */ new Set(["recursive-mode-audit-v1", "recursive-mode-audit-v2"]);
716
- const AUDITED_PHASE_FILES$1 = /* @__PURE__ */ new Set([
717
- "01-as-is.md",
718
- "01.5-root-cause.md",
719
- "02-to-be-plan.md",
720
- "03-implementation-summary.md",
721
- "03.5-code-review.md",
722
- "04-test-summary.md",
723
- "06-decisions-update.md",
724
- "07-state-update.md",
725
- "08-memory-impact.md"
726
- ]);
727
- const CURRENT_WORKFLOW_PROFILE = "recursive-mode-audit-v2";
728
- const STRICT_WORKFLOW_PROFILE = "recursive-mode-audit-v1";
729
- const COMPAT_WORKFLOW_PROFILE = "memory-phase8";
730
- const LATE_PHASE_FILES = [
731
- "06-decisions-update.md",
732
- "07-state-update.md",
733
- "08-memory-impact.md"
734
- ];
735
- const LATE_PHASE_KEYS = /* @__PURE__ */ new Set([
736
- "06",
737
- "07",
738
- "08"
739
- ]);
740
- function escapeRegExp(value) {
741
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
718
+ console.log();
742
719
  }
743
- function trimMdValue(value) {
720
+ /** trim_md_value: strip surrounding quotes/backticks. */
721
+ function trimMdValue$1(value) {
744
722
  let trimmed = value.trim();
745
723
  for (const quote of [
746
724
  "`",
@@ -752,22 +730,50 @@ function trimMdValue(value) {
752
730
  }
753
731
  return trimmed;
754
732
  }
755
- function getMdFieldValue(content, fieldName) {
756
- const match = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*(.+?)\\s*$`, "m").exec(content);
757
- if (!match) return null;
758
- return trimMdValue(match[1]);
733
+ /** get_md_field_value: first `Field: value` line. */
734
+ function getMdFieldValue$1(content, fieldName) {
735
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp$3(fieldName)}:[ \\t]*(.+?)\\s*$`, "m");
736
+ const m = content.match(re);
737
+ if (!m) return null;
738
+ return trimMdValue$1(m[1]);
759
739
  }
760
- function getGateStatus(content, gateName) {
761
- const match = new RegExp(`^[ \\t]*${escapeRegExp(gateName)}:\\s*(PASS|FAIL)\\s*$`, "m").exec(content);
762
- return match ? match[1].toUpperCase() : "MISSING";
740
+ /** has_header_field: any `Field:` line. */
741
+ function hasHeaderField(content, fieldName) {
742
+ return new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp$3(fieldName)}:`, "m").test(content);
763
743
  }
764
- function getTodoStats(content) {
744
+ /** has_heading: `## Heading` line. */
745
+ function hasHeading(content, headingText) {
746
+ return new RegExp(`^[ \\t]*##\\s+${escapeRegExp$3(headingText)}\\s*$`, "m").test(content);
747
+ }
748
+ /** get_heading_body: text under `## Heading` until next heading or end. */
749
+ function getHeadingBody(content, headingText) {
750
+ const re = new RegExp(`^[ \\t]*##\\s+${escapeRegExp$3(headingText)}\\s*$\\n?(.*?)(?=^[ \\t]*##\\s+|\Z)`, "ms");
751
+ const m = content.match(re);
752
+ if (!m) return "";
753
+ return (m[1] ?? "").trim();
754
+ }
755
+ /** get_subheading_body: text under `### Heading` (or level N) until next N-or-less heading. */
756
+ function getSubheadingBody(content, headingText, level = 3) {
757
+ const hashes = "#".repeat(level);
758
+ const re = new RegExp(`^[ \\t]*${escapeRegExp$3(hashes)}\\s+${escapeRegExp$3(headingText)}\\s*$\\n?(.*?)(?=^[ \\t]*#{1,${level}}\\s+|\Z)`, "ms");
759
+ const m = content.match(re);
760
+ if (!m) return "";
761
+ return (m[1] ?? "").trim();
762
+ }
763
+ /** has_gate_line: `Gate: PASS|FAIL` line. */
764
+ function hasGateLine(content, gateName) {
765
+ return new RegExp(`^[ \\t]*${escapeRegExp$3(gateName)}:\\s*(PASS|FAIL)\\s*$`, "m").test(content);
766
+ }
767
+ /** get_gate_status: PASS|FAIL|MISSING. */
768
+ function getGateStatus$1(content, gateName) {
769
+ const re = new RegExp(`^[ \\t]*${escapeRegExp$3(gateName)}:\\s*(PASS|FAIL)\\s*$`, "m");
770
+ const m = content.match(re);
771
+ return m ? m[1].toUpperCase() : "MISSING";
772
+ }
773
+ /** get_todo_stats: [has_todo, total, checked, unchecked] under ## TODO. */
774
+ function getTodoStats$1(content) {
765
775
  const lines = content.split(/\r?\n/);
766
- let inTodo = false;
767
- let hasTodo = false;
768
- let checked = 0;
769
- let unchecked = 0;
770
- let total = 0;
776
+ let inTodo = false, hasTodo = false, checked = 0, unchecked = 0, total = 0;
771
777
  for (const line of lines) {
772
778
  if (!inTodo) {
773
779
  if (/^\s*##\s+TODO\s*$/.test(line)) {
@@ -777,452 +783,2595 @@ function getTodoStats(content) {
777
783
  continue;
778
784
  }
779
785
  if (/^\s*##\s+/.test(line) || /^\s*#\s+/.test(line)) break;
780
- const item = /^\s*[-*]\s+\[([ xX])\]\s+/.exec(line);
786
+ const item = line.match(/^\s*[-*]\s+\[([ xX])\]\s+/);
781
787
  if (item) {
782
788
  total += 1;
783
789
  if (item[1].toLowerCase() === "x") checked += 1;
784
790
  else unchecked += 1;
785
791
  }
786
792
  }
787
- return {
793
+ return [
788
794
  hasTodo,
789
795
  total,
790
796
  checked,
791
797
  unchecked
792
- };
793
- }
794
- function lockHashFromContent$1(content) {
795
- const stripped = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(LOCK_HASH_LINE_RE$1, "");
796
- return createHash("sha256").update(stripped, "utf8").digest("hex");
798
+ ];
797
799
  }
798
- function getWorkflowProfile(runDir) {
799
- const requirementsPath = join(runDir, "00-requirements.md");
800
- try {
801
- const workflowVersion = getMdFieldValue(readFileSync(requirementsPath, "utf8"), "Workflow version");
802
- if (workflowVersion === CURRENT_WORKFLOW_PROFILE) return CURRENT_WORKFLOW_PROFILE;
803
- if (workflowVersion === STRICT_WORKFLOW_PROFILE) return STRICT_WORKFLOW_PROFILE;
804
- if (workflowVersion === COMPAT_WORKFLOW_PROFILE) return COMPAT_WORKFLOW_PROFILE;
805
- } catch {}
806
- if (LATE_PHASE_FILES.some((file) => {
807
- try {
808
- statSync(join(runDir, file));
809
- return true;
810
- } catch {
811
- return false;
812
- }
813
- })) return COMPAT_WORKFLOW_PROFILE;
800
+ /** get_workflow_profile: from Workflow version field or late-phase presence. */
801
+ function getWorkflowProfile$1(runDir) {
802
+ const reqPath = join(runDir, "00-requirements.md");
803
+ if (existsSync(reqPath)) {
804
+ const wf = getMdFieldValue$1(readFileSync(reqPath, "utf8"), "Workflow version");
805
+ if (wf === "recursive-mode-audit-v2") return CURRENT_WORKFLOW_PROFILE$1;
806
+ if (wf === "recursive-mode-audit-v1") return STRICT_WORKFLOW_PROFILE$1;
807
+ if (wf === "memory-phase8") return COMPAT_WORKFLOW_PROFILE$1;
808
+ }
809
+ if (LATE_PHASE_ARTIFACTS.some((a) => existsSync(join(runDir, a)))) return COMPAT_WORKFLOW_PROFILE$1;
814
810
  return "legacy";
815
811
  }
816
- function getArtifactState(artifactPath, workflowProfile) {
817
- let content = null;
812
+ /** is_placeholder_only: empty or placeholder token. */
813
+ function isPlaceholderOnly(text) {
814
+ const compact = text.trim();
815
+ if (!compact) return true;
816
+ return compact === "..." || compact === "<content>" || compact === "[...]" || compact === "[same structure]";
817
+ }
818
+ /** parse_requirement_ids: Rd+ ids from Requirements heading. */
819
+ function parseRequirementIds(requirementsContent) {
820
+ const body = getHeadingBody(requirementsContent, "Requirements") || requirementsContent;
821
+ const ids = [...new Set(body.match(/\bR\d+\b/g) ?? [])];
822
+ ids.sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10));
823
+ return ids;
824
+ }
825
+ /** requirement_sort_key: Rd+ < SRC-d{3} < other. */
826
+ function requirementSortKey(value) {
827
+ if (/^R\d+$/.test(value)) return [
828
+ 0,
829
+ parseInt(value.slice(1), 10),
830
+ value
831
+ ];
832
+ if (/^SRC-\d{3}$/.test(value)) return [
833
+ 1,
834
+ parseInt(value.split("-")[1], 10),
835
+ value
836
+ ];
837
+ return [
838
+ 2,
839
+ value,
840
+ value
841
+ ];
842
+ }
843
+ /** normalize_source_text: collapse whitespace, strip quotes/backticks, lowercase. */
844
+ function normalizeSourceText(value) {
845
+ return value.replace(/`/g, "").replace(/"/g, "").replace(/\s+/g, " ").trim().toLowerCase();
846
+ }
847
+ /** source_quote_matches: normalized quote appears in requirements content. */
848
+ function sourceQuoteMatches(sourceQuote, requirementsContent) {
849
+ const nq = normalizeSourceText(sourceQuote);
850
+ const nr = normalizeSourceText(requirementsContent);
851
+ return Boolean(nq) && nr.includes(nq);
852
+ }
853
+ /** escapeRegExp helper. */
854
+ function escapeRegExp$3(s) {
855
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
856
+ }
857
+ /** content_sha256: sha256 hex of UTF-8 content (LF-normalized). */
858
+ function contentSha256$1(content) {
859
+ return createHash("sha256").update(content.replace(/\r\n/g, "\n"), "utf8").digest("hex");
860
+ }
861
+ /** extract_paths_from_text: backtick paths with / or . in name, non-git, non-placeholder. */
862
+ function extractPathsFromText(text) {
863
+ const paths = /* @__PURE__ */ new Set();
864
+ const re = /`([^`\n]+)`/g;
865
+ let m;
866
+ while ((m = re.exec(text)) !== null) {
867
+ const candidate = m[1].trim().replace(/\\/g, "/").replace(/^\/+/, "");
868
+ if (!candidate || candidate.toLowerCase().startsWith("git ")) continue;
869
+ if (candidate.startsWith("<") && candidate.endsWith(">")) continue;
870
+ const name = candidate.split("/").pop() ?? "";
871
+ if (candidate.includes("/") || name.includes(".")) paths.add(candidate);
872
+ }
873
+ return paths;
874
+ }
875
+ /** extract_paths_from_field_value: backtick paths, else split on ,; newline. */
876
+ function extractPathsFromFieldValue(text) {
877
+ const paths = extractPathsFromText(text);
878
+ if (paths.size > 0) return paths;
879
+ const discovered = /* @__PURE__ */ new Set();
880
+ for (const candidateRaw of trimMdValue$1(text).split(/[,;\n]/)) {
881
+ const candidate = candidateRaw.trim().replace(/\\/g, "/").replace(/^\/+/, "");
882
+ if (!candidate || candidate.toLowerCase().startsWith("git ")) continue;
883
+ if (candidate.startsWith("<") && candidate.endsWith(">")) continue;
884
+ const name = candidate.split("/").pop() ?? "";
885
+ if (candidate.includes("/") || name.includes(".")) discovered.add(candidate);
886
+ }
887
+ return discovered;
888
+ }
889
+ /** extract_paths_from_named_field: inline value or block body until next field. */
890
+ function extractPathsFromNamedField(content, fieldName) {
891
+ const inlineValue = getMdFieldValue$1(content, fieldName);
892
+ if (inlineValue !== null) return extractPathsFromFieldValue(inlineValue);
893
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp$3(fieldName)}:[ \\t]*$\\n(.*?)(?=^[ \\t]*(?:[-*][ \\t]+)?[A-Za-z][^:\\n]*:[ \\t]*|\Z)`, "ms");
894
+ const m = content.match(re);
895
+ if (!m) return /* @__PURE__ */ new Set();
896
+ const out = /* @__PURE__ */ new Set();
897
+ for (const path of extractPathsFromText(m[1] ?? "")) {
898
+ const normalized = normalizeRepoPath(path);
899
+ if (normalized) out.add(normalized);
900
+ }
901
+ return out;
902
+ }
903
+ /** get_named_field_text: inline value or block body until next field. */
904
+ function getNamedFieldText(content, fieldName) {
905
+ const inlineValue = getMdFieldValue$1(content, fieldName);
906
+ if (inlineValue !== null) return inlineValue;
907
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp$3(fieldName)}:[ \\t]*$\\n(.*?)(?=^[ \\t]*(?:[-*][ \\t]+)?[A-Za-z][^:\\n]*:[ \\t]*|\Z)`, "ms");
908
+ const m = content.match(re);
909
+ if (!m) return null;
910
+ return (m[1] ?? "").trim();
911
+ }
912
+ /** has_meaningful_value: non-empty and not in disallowed set. */
913
+ function hasMeaningfulValue(value, disallowed) {
914
+ if (value === null) return false;
915
+ const normalized = trimMdValue$1(value).trim();
916
+ if (!normalized) return false;
917
+ return !(disallowed ?? /* @__PURE__ */ new Set()).has(normalized.toLowerCase());
918
+ }
919
+ /** collect_subagent_delegation_issues: audit-context consistency rules. */
920
+ function collectSubagentDelegationIssues(auditContext) {
921
+ const issues = [];
922
+ const mode = getMdFieldValue$1(auditContext, "Audit Execution Mode");
923
+ const availability = getMdFieldValue$1(auditContext, "Subagent Availability");
924
+ const overrideReason = getMdFieldValue$1(auditContext, "Delegation Override Reason");
925
+ if (availability === "unavailable" && mode === "subagent") issues.push("Audit Context cannot claim Audit Execution Mode: subagent when Subagent Availability is unavailable");
926
+ if (availability === "available" && mode === "self-audit") {
927
+ if (!hasMeaningfulValue(overrideReason, /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push("Audit Context is missing Delegation Override Reason even though subagents were available and self-audit was chosen");
928
+ }
929
+ return issues;
930
+ }
931
+ /** collect_paths_under_prefix: sorted paths under a prefix. */
932
+ function collectPathsUnderPrefix(text, prefix) {
933
+ return [...extractPathsFromText(text)].filter((p) => p.startsWith(prefix)).sort();
934
+ }
935
+ /** find_missing_repo_paths: paths not existing under repo_root. */
936
+ function findMissingRepoPaths(repoRoot, paths) {
937
+ return paths.filter((p) => !existsSync(join(repoRoot, p)));
938
+ }
939
+ /** normalize_repo_path: backslash->slash, strip leading /. */
940
+ function normalizeRepoPath(rawPath) {
941
+ return rawPath.replace(/\\/g, "/").trim().replace(/^\/+/, "");
942
+ }
943
+ /** is_addendum_artifact: file name contains .addendum-. */
944
+ function isAddendumArtifact(fileName) {
945
+ return fileName.includes(".addendum-");
946
+ }
947
+ /** is_transient_runtime_path: __pycache__, .pyc, etc. */
948
+ function isTransientRuntimePath(normalizedPath) {
949
+ const candidate = normalizeRepoPath(normalizedPath);
950
+ if (!candidate) return false;
951
+ const parts = candidate.split("/");
952
+ if (parts.some((p) => TRANSIENT_RUNTIME_DIR_MARKERS.has(p))) return true;
953
+ const fileName = parts[parts.length - 1].toLowerCase();
954
+ if (TRANSIENT_RUNTIME_FILE_NAMES.has(fileName)) return true;
955
+ return TRANSIENT_RUNTIME_SUFFIXES.some((s) => fileName.endsWith(s));
956
+ }
957
+ /** filter_runtime_changed_files: drop the run's own dir + transient paths. */
958
+ function filterRuntimeChangedFiles(paths, runId) {
959
+ const filtered = [];
960
+ for (const rawPath of paths) {
961
+ const normalized = normalizeRepoPath(rawPath);
962
+ if (!normalized) continue;
963
+ if (normalized.startsWith(`.recursive/run/${runId}/`)) continue;
964
+ if (isTransientRuntimePath(normalized)) continue;
965
+ filtered.push(normalized);
966
+ }
967
+ return [...new Set(filtered)].sort();
968
+ }
969
+ /** run_git: run git -C repo args, return [stdout, error]. */
970
+ function runGit(repoRoot, ...args) {
818
971
  try {
819
- content = readFileSync(artifactPath, "utf8");
820
- } catch {}
821
- if (content === null) return {
822
- exists: false,
823
- status: "PENDING",
824
- lockValid: false,
825
- lockProblems: ["File missing"],
826
- blockers: ["File missing"],
827
- lockedAt: null,
828
- storedHash: null,
829
- actualHash: null,
830
- coverage: "MISSING",
831
- approval: "MISSING",
832
- audit: "MISSING",
833
- todoHasSection: false,
834
- todoUnchecked: 0
835
- };
836
- const status = getMdFieldValue(content, "Status") ?? "UNKNOWN";
837
- const todo = getTodoStats(content);
838
- const coverage = getGateStatus(content, "Coverage");
839
- const approval = getGateStatus(content, "Approval");
840
- const audit = getGateStatus(content, "Audit");
841
- const tddCompliance = getGateStatus(content, "TDD Compliance");
842
- const lockedAt = getMdFieldValue(content, "LockedAt");
843
- const storedHash = getMdFieldValue(content, "LockHash");
844
- let actualHash = null;
845
- const lockProblems = [];
846
- const blockers = [];
847
- let lockValid = false;
848
- const fileName = artifactPath.split(/[\\/]/).pop() ?? "";
849
- if (status !== "LOCKED") lockProblems.push(`Status is '${status}' (expected LOCKED for lock-valid)`);
850
- else {
851
- if (!lockedAt) lockProblems.push("Missing LockedAt");
852
- if (!storedHash) lockProblems.push("Missing LockHash");
853
- if (storedHash) {
854
- actualHash = lockHashFromContent$1(content);
855
- if (storedHash.toLowerCase() !== actualHash.toLowerCase()) lockProblems.push("LockHash mismatch");
972
+ const out = execFileSync("git", [
973
+ "-C",
974
+ repoRoot,
975
+ ...args
976
+ ], {
977
+ encoding: "utf8",
978
+ stdio: [
979
+ "ignore",
980
+ "pipe",
981
+ "pipe"
982
+ ]
983
+ });
984
+ return [String(out).trim(), null];
985
+ } catch (err) {
986
+ const e = err;
987
+ if (e && typeof e === "object" && "status" in e) {
988
+ const stderr = (e.stderr ?? "").toString().trim();
989
+ const stdout = (e.stdout ?? "").toString().trim();
990
+ return [null, stderr || stdout || `git ${args.join(" ")} failed`];
856
991
  }
857
- if (coverage !== "PASS") lockProblems.push(`Coverage gate is ${coverage}`);
858
- if (approval !== "PASS") lockProblems.push(`Approval gate is ${approval}`);
859
- if (STRICT_WORKFLOW_PROFILES.has(workflowProfile) && AUDITED_PHASE_FILES$1.has(fileName) && audit !== "PASS") lockProblems.push(`Audit gate is ${audit}`);
860
- if (fileName === "03-implementation-summary.md" && tddCompliance !== "PASS") lockProblems.push(`TDD Compliance gate is ${tddCompliance}`);
861
- if (!todo.hasTodo) lockProblems.push("Missing ## TODO section");
862
- else if (todo.unchecked > 0) lockProblems.push(`Unchecked TODO items: ${todo.unchecked}`);
863
- if (lockProblems.length === 0) lockValid = true;
992
+ return [null, `Unable to execute git: ${String(err)}`];
864
993
  }
865
- if (!todo.hasTodo) blockers.push("Missing ## TODO section");
866
- else if (todo.unchecked > 0) blockers.push(`Unchecked TODO items: ${todo.unchecked}`);
867
- if (coverage !== "PASS") blockers.push(`Coverage gate is ${coverage}`);
868
- if (approval !== "PASS") blockers.push(`Approval gate is ${approval}`);
869
- if (fileName === "03-implementation-summary.md" && tddCompliance !== "PASS") blockers.push(`TDD Compliance gate is ${tddCompliance}`);
870
- const dedupedBlockers = [];
871
- for (const blocker of blockers) if (!dedupedBlockers.includes(blocker)) dedupedBlockers.push(blocker);
994
+ }
995
+ /** normalize_baseline_type: canonicalize baseline type with aliases. */
996
+ function normalizeBaselineType(value) {
997
+ if (value === null) return null;
998
+ const compact = trimMdValue$1(value).trim().toLowerCase().replace(/-/g, " ").replace(/\s+/g, " ");
999
+ if (DIFF_BASIS_ALLOWED_TYPES.has(compact)) return compact;
872
1000
  return {
873
- exists: true,
874
- status,
875
- lockValid,
876
- lockProblems,
877
- blockers: dedupedBlockers,
878
- lockedAt,
879
- storedHash,
880
- actualHash,
881
- coverage,
882
- approval,
883
- audit,
884
- todoHasSection: todo.hasTodo,
885
- todoUnchecked: todo.unchecked
1001
+ "commit": "local commit",
1002
+ "branch": "local branch",
1003
+ "remote": "remote ref",
1004
+ "remote branch": "remote ref",
1005
+ "merge base": "merge-base derived"
1006
+ }[compact] ?? null;
1007
+ }
1008
+ /** normalize_comparison_reference: map working-tree refs. */
1009
+ function normalizeComparisonReference(value) {
1010
+ if (value === null) return null;
1011
+ const compact = trimMdValue$1(value).trim();
1012
+ if (!compact) return null;
1013
+ if (WORKING_TREE_COMPARISON_REFS.has(compact.toLowerCase())) return "working-tree";
1014
+ return compact;
1015
+ }
1016
+ /** parse_diff_basis_source: heading body of Diff Basis For Later Audits / Diff Basis. */
1017
+ function parseDiffBasisSource(content) {
1018
+ let body = getHeadingBody(content, "Diff Basis For Later Audits");
1019
+ if (body) return body;
1020
+ body = getHeadingBody(content, "Diff Basis");
1021
+ if (body) return body;
1022
+ return content;
1023
+ }
1024
+ /** get_run_diff_basis: read diff-basis fields from 00-worktree.md. */
1025
+ function getRunDiffBasis(runDir) {
1026
+ const worktreePath = join(runDir, "00-worktree.md");
1027
+ if (!existsSync(worktreePath)) return {
1028
+ baseline_type: null,
1029
+ baseline_reference: null,
1030
+ comparison_reference: null,
1031
+ normalized_baseline: null,
1032
+ normalized_comparison: null,
1033
+ normalized_diff_command: null,
1034
+ base_branch: null,
1035
+ worktree_branch: null,
1036
+ notes: null
1037
+ };
1038
+ const source = parseDiffBasisSource(readFileSync(worktreePath, "utf8"));
1039
+ return {
1040
+ baseline_type: getMdFieldValue$1(source, "Baseline type"),
1041
+ baseline_reference: getMdFieldValue$1(source, "Baseline reference"),
1042
+ comparison_reference: getMdFieldValue$1(source, "Comparison reference"),
1043
+ normalized_baseline: getMdFieldValue$1(source, "Normalized baseline") ?? getMdFieldValue$1(source, "Normalized baseline commit") ?? getMdFieldValue$1(source, "Base commit"),
1044
+ normalized_comparison: getMdFieldValue$1(source, "Normalized comparison") ?? getMdFieldValue$1(source, "Normalized comparison reference") ?? getMdFieldValue$1(source, "Worktree branch"),
1045
+ normalized_diff_command: getMdFieldValue$1(source, "Normalized diff command") ?? getMdFieldValue$1(source, "Diff command convention"),
1046
+ base_branch: getMdFieldValue$1(source, "Base branch"),
1047
+ worktree_branch: getMdFieldValue$1(source, "Worktree branch"),
1048
+ notes: getMdFieldValue$1(source, "Diff basis notes") ?? getMdFieldValue$1(source, "Notes")
886
1049
  };
887
1050
  }
888
- function foldRun(runDir, runId) {
889
- const workflowProfile = getWorkflowProfile(runDir);
890
- const states = /* @__PURE__ */ new Map();
891
- for (const phase of PHASES) {
892
- const state = getArtifactState(join(runDir, phase.file), workflowProfile);
893
- if (phase.optional && !state.exists) state.status = "SKIPPED";
894
- else if (workflowProfile === "legacy" && LATE_PHASE_KEYS.has(phase.key) && !state.exists) state.status = "SKIPPED";
895
- states.set(phase.key, state);
1051
+ /** normalize_diff_basis: validate + compute the executable diff basis. */
1052
+ function normalizeDiffBasis(repoRoot, diffBasis) {
1053
+ const baselineType = normalizeBaselineType(diffBasis.baseline_type);
1054
+ const baselineReference = trimMdValue$1(diffBasis.baseline_reference ?? "");
1055
+ const comparisonReference = normalizeComparisonReference(diffBasis.comparison_reference);
1056
+ const normalizedBaseline = trimMdValue$1(diffBasis.normalized_baseline ?? "");
1057
+ const normalizedComparison = normalizeComparisonReference(diffBasis.normalized_comparison);
1058
+ const normalizedDiffCommand = trimMdValue$1(diffBasis.normalized_diff_command ?? "");
1059
+ const missingFields = [];
1060
+ if (!baselineType) missingFields.push("Baseline type");
1061
+ if (!baselineReference) missingFields.push("Baseline reference");
1062
+ if (!comparisonReference) missingFields.push("Comparison reference");
1063
+ if (!normalizedBaseline) missingFields.push("Normalized baseline");
1064
+ if (!normalizedComparison) missingFields.push("Normalized comparison");
1065
+ if (!normalizedDiffCommand) missingFields.push("Normalized diff command");
1066
+ if (missingFields.length > 0) return [null, `Diff basis is missing required field(s): ${missingFields.join(", ")}`];
1067
+ const baselineTypeN = baselineType;
1068
+ const baselineReferenceN = baselineReference;
1069
+ const comparisonReferenceN = comparisonReference;
1070
+ const normalizedBaselineN = normalizedBaseline;
1071
+ const normalizedComparisonN = normalizedComparison;
1072
+ const normalizedDiffCommandN = normalizedDiffCommand;
1073
+ const comparisonGitRef = normalizedComparisonN === "working-tree" ? "HEAD" : normalizedComparisonN;
1074
+ let computedBaseline;
1075
+ let error;
1076
+ if (baselineType === "merge-base derived") {
1077
+ const [out, err] = runGit(repoRoot, "merge-base", comparisonGitRef, baselineReferenceN);
1078
+ computedBaseline = out;
1079
+ error = err ? `Unable to compute merge-base for diff basis: ${err}` : null;
1080
+ } else {
1081
+ const [out, err] = runGit(repoRoot, "rev-parse", "--verify", `${baselineReferenceN}^{commit}`);
1082
+ computedBaseline = out;
1083
+ error = err ? `Unable to resolve baseline reference '${baselineReference}': ${err}` : null;
896
1084
  }
897
- let currentPhase = null;
898
- for (const phase of PHASES) {
899
- const state = states.get(phase.key);
900
- if (state.status === "SKIPPED") continue;
901
- if (!state.exists || !state.lockValid) {
902
- currentPhase = {
903
- key: phase.key,
904
- label: phase.label,
905
- phaseName: phase.phaseName,
906
- status: state.status
907
- };
908
- break;
1085
+ if (error || computedBaseline === null) return [null, error ?? "Unable to compute diff basis"];
1086
+ if (normalizedBaselineN !== computedBaseline) return [null, `Recorded Normalized baseline does not match the executable diff basis (${normalizedBaselineN} != ${computedBaseline})`];
1087
+ let expectedCommand;
1088
+ let gitArgs;
1089
+ if (normalizedComparisonN === "working-tree") {
1090
+ expectedCommand = `git diff --name-only ${computedBaseline}`;
1091
+ gitArgs = [
1092
+ "diff",
1093
+ "--name-only",
1094
+ computedBaseline
1095
+ ];
1096
+ } else {
1097
+ const [computedComparison, cmpError] = runGit(repoRoot, "rev-parse", "--verify", `${normalizedComparisonN}^{commit}`);
1098
+ if (cmpError || computedComparison === null) return [null, `Unable to resolve comparison reference '${normalizedComparison}': ${cmpError}`];
1099
+ expectedCommand = `git diff --name-only ${computedBaseline}..${computedComparison}`;
1100
+ gitArgs = [
1101
+ "diff",
1102
+ "--name-only",
1103
+ `${computedBaseline}..${computedComparison}`
1104
+ ];
1105
+ if (normalizedComparisonN !== computedComparison) return [null, `Recorded Normalized comparison does not match the executable diff basis (${normalizedComparisonN} != ${computedComparison})`];
1106
+ }
1107
+ if (normalizedDiffCommandN !== expectedCommand) return [null, `Recorded Normalized diff command does not match the executable diff basis (${normalizedDiffCommandN} != ${expectedCommand})`];
1108
+ return [{
1109
+ baseline_type: baselineTypeN,
1110
+ baseline_reference: baselineReferenceN,
1111
+ comparison_reference: comparisonReferenceN,
1112
+ normalized_baseline: computedBaseline,
1113
+ normalized_comparison: normalizedComparisonN,
1114
+ normalized_diff_command: expectedCommand,
1115
+ comparison_git_ref: comparisonGitRef,
1116
+ git_args: gitArgs.join(" ")
1117
+ }, null];
1118
+ }
1119
+ /** get_git_changed_files: git diff --name-only + untracked (--relative). */
1120
+ function getGitChangedFiles(repoRoot, diffBasis) {
1121
+ const [normalizedBasis, basisError] = normalizeDiffBasis(repoRoot, diffBasis);
1122
+ if (basisError) return [null, basisError];
1123
+ const gitArgs = (normalizedBasis.git_args ?? "").split(" ").filter(Boolean);
1124
+ try {
1125
+ const diffOut = execFileSync("git", [
1126
+ "-C",
1127
+ repoRoot,
1128
+ ...gitArgs,
1129
+ "--relative"
1130
+ ], {
1131
+ encoding: "utf8",
1132
+ stdio: [
1133
+ "ignore",
1134
+ "pipe",
1135
+ "pipe"
1136
+ ]
1137
+ });
1138
+ const untrackedOut = execFileSync("git", [
1139
+ "-C",
1140
+ repoRoot,
1141
+ "ls-files",
1142
+ "--others",
1143
+ "--exclude-standard"
1144
+ ], {
1145
+ encoding: "utf8",
1146
+ stdio: [
1147
+ "ignore",
1148
+ "pipe",
1149
+ "pipe"
1150
+ ]
1151
+ });
1152
+ const tracked = String(diffOut).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
1153
+ const untracked = String(untrackedOut).split(/\r?\n/).map((s) => s.trim()).filter((s) => s && !isTransientRuntimePath(s));
1154
+ return [[.../* @__PURE__ */ new Set([...tracked, ...untracked])].sort(), null];
1155
+ } catch (err) {
1156
+ return [null, `Unable to execute git: ${String(err)}`];
1157
+ }
1158
+ }
1159
+ /** get_phase_owned_actual_changed_files: which changed files belong to this phase. */
1160
+ function getPhaseOwnedActualChangedFiles(fileName, actualChangedFiles, repoRoot) {
1161
+ if (actualChangedFiles === null) return null;
1162
+ if (fileName === "02-to-be-plan.md") return null;
1163
+ const ownedPaths = [];
1164
+ for (const path of actualChangedFiles) {
1165
+ if (path === ".recursive/DECISIONS.md") {
1166
+ if (DECISIONS_DIFF_PHASE_FILES.has(fileName)) ownedPaths.push(path);
1167
+ continue;
1168
+ }
1169
+ if (path === ".recursive/STATE.md") {
1170
+ if (STATE_DIFF_PHASE_FILES.has(fileName)) ownedPaths.push(path);
1171
+ continue;
909
1172
  }
1173
+ if (path.startsWith(".recursive/memory/")) {
1174
+ if (MEMORY_DIFF_PHASE_FILES.has(fileName)) ownedPaths.push(path);
1175
+ continue;
1176
+ }
1177
+ ownedPaths.push(path);
910
1178
  }
911
- const phases = PHASES.map((phase) => {
912
- const state = states.get(phase.key);
913
- return {
914
- key: phase.key,
915
- label: phase.label,
916
- file: phase.file,
917
- optional: phase.optional,
918
- exists: state.exists,
919
- status: state.status,
920
- lockValid: state.lockValid,
921
- lockProblems: state.lockProblems,
922
- blockers: state.blockers
923
- };
924
- });
925
- return {
926
- runId,
927
- currentPhase,
928
- phases,
929
- workflowProfile
930
- };
1179
+ if (PRODUCT_DIFF_PHASE_FILES.has(fileName)) {
1180
+ if (repoRoot === void 0) return ownedPaths;
1181
+ return ownedPaths.filter((p) => existsSync(join(repoRoot, p)));
1182
+ }
1183
+ if (DECISIONS_DIFF_PHASE_FILES.has(fileName) || STATE_DIFF_PHASE_FILES.has(fileName) || MEMORY_DIFF_PHASE_FILES.has(fileName)) return ownedPaths;
1184
+ return null;
931
1185
  }
932
- //#endregion
933
- //#region src/lock.ts
934
- /**
935
- * Lock-hash + lock-chain validation for recursive-mode runs.
936
- *
937
- * Ports recursive-lock.py / verify-locks.py / recursive_phase_rules.py
938
- * semantics (byte-for-byte where observable):
939
- * - lockHashFromContent: LF-normalize, strip LockHash lines, SHA-256 hex
940
- * - getLockStatus: MISSING / DRAFT / STALE_LOCK / LOCKED
941
- * - getPrerequisites / getPrerequisiteBlockers: sequence ordering
942
- * - validateChain: per-phase validity + break + next legal phase
943
- * - receipts: read/write/invalidate with Python-identical JSON
944
- *
945
- * R3 (run 02).
946
- */
947
- const PHASE_SEQUENCE = [
948
- "00-requirements.md",
949
- "00-worktree.md",
950
- "01-as-is.md",
951
- "01.5-root-cause.md",
952
- "02-to-be-plan.md",
953
- "03-implementation-summary.md",
954
- "03.5-code-review.md",
955
- "04-test-summary.md",
956
- "05-manual-qa.md",
957
- "06-decisions-update.md",
958
- "07-state-update.md",
959
- "08-memory-impact.md"
960
- ];
961
- const OPTIONAL_PHASES = /* @__PURE__ */ new Set([
962
- "01-as-is.md",
963
- "01.5-root-cause.md",
964
- "02-to-be-plan.md",
965
- "03-implementation-summary.md",
966
- "03.5-code-review.md",
967
- "04-test-summary.md",
968
- "05-manual-qa.md"
969
- ]);
970
- const LOCK_HASH_LINE_RE = /^[ \t]*LockHash:.*(?:\n|$)/gm;
971
- const STATUS_RE = /* @__PURE__ */ new RegExp("^[ \\t]*Status:\\s*(?:`|\")?(\\w+)(?:`|\")?\\s*$", "m");
972
- const LOCK_HASH_RE = /* @__PURE__ */ new RegExp("^[ \\t]*LockHash:\\s*(?:`|\")?([a-fA-F0-9]{64})(?:`|\")?\\s*$", "m");
973
- const LOCKED_AT_RE = /* @__PURE__ */ new RegExp("^[ \\t]*LockedAt:\\s*(?:`|\")?([^`\"\\r\\n]+)(?:`|\")?\\s*$", "m");
974
- /** LF-normalize, strip every LockHash: line, return the normalized content. */
975
- function normalizeForLockHash(content) {
976
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(LOCK_HASH_LINE_RE, "");
977
- }
978
- /** SHA-256 hex over the UTF-8 bytes of the normalized content. */
979
- function lockHashFromContent(content) {
980
- const normalized = normalizeForLockHash(content);
981
- return createHash("sha256").update(normalized, "utf8").digest("hex");
982
- }
983
- function phaseIndex(artifactFile) {
984
- return PHASE_SEQUENCE.indexOf(artifactFile);
985
- }
986
- function isCoreArtifact(artifactFile) {
987
- return phaseIndex(artifactFile) >= 0;
1186
+ /** get_related_addenda_paths: run_dir/addenda/<base>.addendum-*.md (+ upstream-gap). */
1187
+ function getRelatedAddendaPaths(runDir, artifactName) {
1188
+ const addendaDir = join(runDir, "addenda");
1189
+ if (!existsSync(addendaDir)) return [];
1190
+ const baseName = artifactName.endsWith(".md") ? artifactName.slice(0, -3) : artifactName;
1191
+ let matches = [];
1192
+ try {
1193
+ matches = readdirSync(addendaDir).filter((n) => n.startsWith(baseName + ".addendum-") && n.endsWith(".md")).sort();
1194
+ const upstream = readdirSync(addendaDir).filter((n) => n.startsWith(baseName + ".upstream-gap.") && n.includes(".addendum-") && n.endsWith(".md")).sort();
1195
+ matches.push(...upstream);
1196
+ } catch {
1197
+ return [];
1198
+ }
1199
+ return matches;
988
1200
  }
989
- /**
990
- * Every earlier phase in PHASE_SEQUENCE that exists on disk.
991
- * Mirrors: [phase for phase in PHASE_SEQUENCE[:idx] if (run_dir / phase).exists()]
992
- */
993
- function getPrerequisites(runDir, artifactFile) {
994
- const idx = phaseIndex(artifactFile);
995
- if (idx <= 0) return [];
996
- const prereqs = [];
997
- for (const phase of PHASE_SEQUENCE.slice(0, idx)) if (existsSync(join(runDir, phase))) prereqs.push(phase);
998
- return prereqs;
1201
+ /** get_stage_local_addenda_paths: run_dir/addenda/<base>.addendum-*.md. */
1202
+ function getStageLocalAddendaPaths(runDir, artifactName) {
1203
+ const addendaDir = join(runDir, "addenda");
1204
+ if (!existsSync(addendaDir)) return [];
1205
+ const baseName = artifactName.endsWith(".md") ? artifactName.slice(0, -3) : artifactName;
1206
+ try {
1207
+ return readdirSync(addendaDir).filter((n) => n.startsWith(baseName + ".addendum-") && n.endsWith(".md")).sort();
1208
+ } catch {
1209
+ return [];
1210
+ }
999
1211
  }
1000
- /**
1001
- * Canonical lock-validity classifier:
1002
- * MISSING (no file) / DRAFT (not LOCKED) / STALE_LOCK (missing or mismatched hash fields) / LOCKED.
1003
- */
1004
- function getLockStatus(artifactPath) {
1005
- if (!existsSync(artifactPath)) return "MISSING";
1006
- let content;
1212
+ /** get_current_phase_upstream_gap_addenda_paths: run_dir/addenda/<base>.upstream-gap.*.addendum-*.md. */
1213
+ function getCurrentPhaseUpstreamGapAddendaPaths(runDir, artifactName) {
1214
+ const addendaDir = join(runDir, "addenda");
1215
+ if (!existsSync(addendaDir)) return [];
1216
+ const baseName = artifactName.endsWith(".md") ? artifactName.slice(0, -3) : artifactName;
1007
1217
  try {
1008
- content = readFileSync(artifactPath, "utf8");
1218
+ return readdirSync(addendaDir).filter((n) => n.startsWith(baseName + ".upstream-gap.") && n.includes(".addendum-") && n.endsWith(".md")).sort();
1009
1219
  } catch {
1010
- return "MISSING";
1220
+ return [];
1011
1221
  }
1012
- const statusMatch = STATUS_RE.exec(content);
1013
- if (!statusMatch || statusMatch[1] !== "LOCKED") return "DRAFT";
1014
- const hashMatch = LOCK_HASH_RE.exec(content);
1015
- const lockedAtMatch = LOCKED_AT_RE.exec(content);
1016
- if (!hashMatch || !lockedAtMatch) return "STALE_LOCK";
1017
- if (hashMatch[1].toLowerCase() !== lockHashFromContent(content)) return "STALE_LOCK";
1018
- return "LOCKED";
1019
1222
  }
1020
- /** Every prerequisite whose status is not LOCKED, in PHASE_SEQUENCE order. */
1021
- function getPrerequisiteBlockers(runDir, artifactFile) {
1022
- const blockers = [];
1023
- for (const prereq of getPrerequisites(runDir, artifactFile)) {
1024
- const status = getLockStatus(join(runDir, prereq));
1025
- if (status !== "LOCKED") blockers.push({
1026
- artifact: prereq,
1027
- status,
1028
- path: join(runDir, prereq)
1029
- });
1223
+ /** get_header_field_block: lines under `Field:` until a stop field. */
1224
+ function getHeaderFieldBlock(content, fieldName, stopFields) {
1225
+ const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
1226
+ let collecting = false;
1227
+ const captured = [];
1228
+ const stopPatterns = stopFields.map((sf) => new RegExp(`^\\s*${escapeRegExp$3(sf)}:`));
1229
+ const fieldPattern = new RegExp(`^\\s*${escapeRegExp$3(fieldName)}:\\s*$`);
1230
+ for (const line of lines) {
1231
+ if (!collecting) {
1232
+ if (fieldPattern.test(line)) collecting = true;
1233
+ continue;
1234
+ }
1235
+ if (stopPatterns.some((p) => p.test(line))) break;
1236
+ captured.push(line);
1030
1237
  }
1031
- return blockers;
1238
+ return captured.join("\n").trim();
1032
1239
  }
1033
- function receiptPath(runDir, artifactFile) {
1034
- const stem = artifactFile.replace(/\.md$/, "");
1035
- return join(runDir, "locks", stem + ".receipt.json");
1240
+ /** get_header_input_paths: paths under Inputs:. */
1241
+ function getHeaderInputPaths(content) {
1242
+ return extractPathsFromText(getHeaderFieldBlock(content, "Inputs", ["Outputs", "Scope note"]));
1036
1243
  }
1037
- function readReceipt(runDir, artifactFile) {
1038
- const rpath = receiptPath(runDir, artifactFile);
1039
- if (!existsSync(rpath)) return null;
1040
- try {
1041
- return JSON.parse(readFileSync(rpath, "utf8"));
1042
- } catch {
1043
- return null;
1244
+ /** get_phase_expected_input_artifact_names: expected input artifacts for a phase. */
1245
+ function getPhaseExpectedInputArtifactNames(fileName, runDir) {
1246
+ const present = new Set(RUN_ARTIFACT_SEQUENCE$1.filter((a) => existsSync(join(runDir, a))));
1247
+ let candidates = [];
1248
+ if (fileName === "00-worktree.md") candidates = ["00-requirements.md"];
1249
+ else if (fileName === "01-as-is.md") candidates = ["00-requirements.md"];
1250
+ else if (fileName === "01.5-root-cause.md") candidates = ["01-as-is.md"];
1251
+ else if (fileName === "02-to-be-plan.md") {
1252
+ candidates = ["00-requirements.md", "01-as-is.md"];
1253
+ if (present.has("01.5-root-cause.md")) candidates.push("01.5-root-cause.md");
1254
+ } else if (fileName === "03-implementation-summary.md") candidates = ["02-to-be-plan.md"];
1255
+ else if (fileName === "03.5-code-review.md") candidates = ["02-to-be-plan.md", "03-implementation-summary.md"];
1256
+ else if (fileName === "04-test-summary.md") {
1257
+ candidates = ["02-to-be-plan.md", "03-implementation-summary.md"];
1258
+ if (present.has("03.5-code-review.md")) candidates.push("03.5-code-review.md");
1259
+ } else if (fileName === "05-manual-qa.md") candidates = ["02-to-be-plan.md"];
1260
+ else if (fileName === "06-decisions-update.md") candidates = RUN_ARTIFACT_SEQUENCE$1.filter((a) => present.has(a) && a !== "06-decisions-update.md");
1261
+ else if (fileName === "07-state-update.md") candidates = ["06-decisions-update.md"];
1262
+ else if (fileName === "08-memory-impact.md") candidates = RUN_ARTIFACT_SEQUENCE$1.filter((a) => present.has(a) && a !== "08-memory-impact.md");
1263
+ return candidates.filter((a) => present.has(a));
1264
+ }
1265
+ /** get_expected_effective_input_addenda_paths: expected addenda cited in Inputs. */
1266
+ function getExpectedEffectiveInputAddendaPaths(runDir, fileName) {
1267
+ if (isAddendumArtifact(fileName)) return [];
1268
+ const expectedPaths = [];
1269
+ const runPrefix = `.recursive/run/${basename(runDir)}/`;
1270
+ for (const artifactName of getPhaseExpectedInputArtifactNames(fileName, runDir)) for (const addendumPath of getStageLocalAddendaPaths(runDir, artifactName)) expectedPaths.push(`${runPrefix}addenda/${addendumPath}`);
1271
+ for (const addendumPath of getCurrentPhaseUpstreamGapAddendaPaths(runDir, fileName)) expectedPaths.push(`${runPrefix}addenda/${addendumPath}`);
1272
+ return [...new Set(expectedPaths)].sort();
1273
+ }
1274
+ /** parse_requirement_completion_entries: `- R1 | Status: ... | ...` rows. */
1275
+ function parseRequirementCompletionEntries(sectionBody) {
1276
+ const entries = {};
1277
+ const issues = [];
1278
+ for (const rawLine of sectionBody.split(/\r?\n/)) {
1279
+ const line = rawLine.trim();
1280
+ if (!line.startsWith("-") && !line.startsWith("*")) continue;
1281
+ const parts = trimMdValue$1(line.slice(1).trim()).split("|").map((p) => trimMdValue$1(p.trim())).filter(Boolean);
1282
+ if (parts.length < 2) continue;
1283
+ const requirementId = trimMdValue$1(parts[0]);
1284
+ if (!REQUIREMENT_ID_RE.test(requirementId)) continue;
1285
+ const fields = { "Requirement ID": requirementId };
1286
+ for (const part of parts.slice(1)) {
1287
+ const idx = part.indexOf(":");
1288
+ if (idx < 0) continue;
1289
+ fields[part.slice(0, idx).trim()] = trimMdValue$1(part.slice(idx + 1).trim());
1290
+ }
1291
+ if (requirementId in entries) {
1292
+ issues.push(`Requirement Completion Status contains duplicate entries for ${requirementId}`);
1293
+ continue;
1294
+ }
1295
+ entries[requirementId] = fields;
1044
1296
  }
1297
+ return [entries, issues];
1045
1298
  }
1046
- /**
1047
- * Write a lock receipt with Python-identical JSON semantics.
1048
- */
1049
- function writeReceipt(runDir, artifactFile, artifactPath) {
1050
- const artifactHash = lockHashFromContent(readFileSync(artifactPath, "utf8"));
1051
- const prereqHashes = {};
1052
- for (const prereq of getPrerequisites(runDir, artifactFile)) {
1053
- const prereqPath = join(runDir, prereq);
1054
- if (existsSync(prereqPath)) {
1055
- const prereqStatus = getLockStatus(prereqPath);
1056
- if (prereqStatus !== "LOCKED") throw new Error(`Cannot write receipt for '${artifactFile}': prerequisite '${prereq}' is not LOCKED (status: ${prereqStatus})`);
1057
- prereqHashes[prereq] = lockHashFromContent(readFileSync(prereqPath, "utf8"));
1299
+ /** is_meaningful_requirement_field: non-empty, not placeholder/none/n/a/tbd/todo. */
1300
+ function isMeaningfulRequirementField(value) {
1301
+ if (value === null) return false;
1302
+ const normalized = trimMdValue$1(value).trim();
1303
+ if (!normalized) return false;
1304
+ return ![
1305
+ "...",
1306
+ "none",
1307
+ "n/a",
1308
+ "tbd",
1309
+ "todo"
1310
+ ].includes(normalized.toLowerCase());
1311
+ }
1312
+ /** collect_requirement_field_paths: normalized repo paths from field values. */
1313
+ function collectRequirementFieldPaths(fields, fieldNames) {
1314
+ const paths = /* @__PURE__ */ new Set();
1315
+ for (const fieldName of fieldNames) {
1316
+ const rawValue = fields[fieldName] ?? "";
1317
+ for (const path of extractPathsFromFieldValue(rawValue)) {
1318
+ const normalized = normalizeRepoPath(path);
1319
+ if (normalized) paths.add(normalized);
1058
1320
  }
1059
1321
  }
1060
- const existing = readReceipt(runDir, artifactFile);
1061
- const prevReceiptHash = existing ? existing.receipt_hash : null;
1062
- const base = {
1063
- artifact: artifactFile,
1064
- artifact_path: artifactPath,
1065
- artifact_hash: artifactHash,
1066
- locked_at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
1067
- prerequisite_hashes: prereqHashes,
1068
- previous_receipt_hash: prevReceiptHash
1069
- };
1070
- const receiptHash = createHash("sha256").update(pythonJsonDumps(base), "utf8").digest("hex");
1071
- const receipt = {
1072
- ...base,
1073
- receipt_hash: receiptHash
1322
+ return paths;
1323
+ }
1324
+ /** collect_meaningful_requirement_fields: drop ID/Status + placeholders. */
1325
+ function collectMeaningfulRequirementFields(fields) {
1326
+ const meaningful = {};
1327
+ for (const [key, value] of Object.entries(fields)) {
1328
+ if (key === "Requirement ID" || key === "Status") continue;
1329
+ if (isMeaningfulRequirementField(value)) meaningful[key] = value;
1330
+ }
1331
+ return meaningful;
1332
+ }
1333
+ /** normalize_skill_usage_relevance: yes->relevant, no->not-relevant. */
1334
+ function normalizeSkillUsageRelevance(value) {
1335
+ const normalized = trimMdValue$1(value ?? "").trim().toLowerCase();
1336
+ if (normalized === "yes") return "relevant";
1337
+ if (normalized === "no") return "not-relevant";
1338
+ return normalized;
1339
+ }
1340
+ /** lint_requirement_disposition_fields: per-status field requirements (implemented/verified/deferred/...). */
1341
+ function lintRequirementDispositionFields(requirementId, status, fields, fileName, runDir, repoRoot, actualChangedFiles) {
1342
+ const issues = [];
1343
+ const actualChangedScope = new Set(actualChangedFiles ?? []);
1344
+ const meaningfulFields = collectMeaningfulRequirementFields(fields);
1345
+ const allowedFieldsByStatus = {
1346
+ "implemented": /* @__PURE__ */ new Set([
1347
+ "Changed Files",
1348
+ "Implementation Evidence",
1349
+ "Audit Note"
1350
+ ]),
1351
+ "verified": /* @__PURE__ */ new Set([
1352
+ "Changed Files",
1353
+ "Implementation Evidence",
1354
+ "Verification Evidence",
1355
+ "Audit Note"
1356
+ ]),
1357
+ "deferred": /* @__PURE__ */ new Set([
1358
+ "Rationale",
1359
+ "Deferred By",
1360
+ "Addendum",
1361
+ "Audit Note"
1362
+ ]),
1363
+ "out-of-scope": /* @__PURE__ */ new Set([
1364
+ "Rationale",
1365
+ "Scope Decision",
1366
+ "Addendum",
1367
+ "Audit Note"
1368
+ ]),
1369
+ "blocked": /* @__PURE__ */ new Set([
1370
+ "Rationale",
1371
+ "Blocking Evidence",
1372
+ "Audit Note"
1373
+ ]),
1374
+ "superseded by approved addendum": /* @__PURE__ */ new Set(["Addendum", "Audit Note"])
1074
1375
  };
1075
- const locksDir = join(runDir, "locks");
1076
- mkdirSync(locksDir, { recursive: true });
1077
- const rpath = receiptPath(runDir, artifactFile);
1078
- writeFileSync(rpath, pythonJsonDumpsIndent(receipt), "utf8");
1079
- return receipt;
1376
+ const unexpected = Object.keys(meaningfulFields).filter((k) => !(allowedFieldsByStatus[status] ?? /* @__PURE__ */ new Set()).has(k)).sort();
1377
+ if (unexpected.length > 0) issues.push(`Requirement ${requirementId} with Status ${status} contains contradictory field(s): ${unexpected.join(", ")}`);
1378
+ if (status === "implemented") {
1379
+ const changedFiles = fields["Changed Files"] ?? "";
1380
+ const changedPaths = collectRequirementFieldPaths(fields, ["Changed Files"]);
1381
+ const implEvidence = fields["Implementation Evidence"] ?? "";
1382
+ const implPaths = collectRequirementFieldPaths(fields, ["Implementation Evidence"]);
1383
+ if (!isMeaningfulRequirementField(changedFiles)) issues.push(`Requirement ${requirementId} with Status implemented must cite Changed Files`);
1384
+ else if (changedPaths.size === 0) issues.push(`Requirement ${requirementId} with Status implemented must cite repo paths in Changed Files`);
1385
+ else {
1386
+ const missing = findMissingRepoPaths(repoRoot, [...changedPaths].sort());
1387
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Changed Files path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1388
+ if (actualChangedScope.size > 0) {
1389
+ const unexplained = [...changedPaths].filter((p) => !actualChangedScope.has(p)).sort();
1390
+ if (unexplained.length > 0) issues.push(`Requirement ${requirementId} Changed Files are outside the current diff scope: ${unexplained.slice(0, 5).join(", ")}`);
1391
+ }
1392
+ }
1393
+ if (!isMeaningfulRequirementField(implEvidence)) issues.push(`Requirement ${requirementId} with Status implemented must cite Implementation Evidence`);
1394
+ else if (implPaths.size === 0) issues.push(`Requirement ${requirementId} with Status implemented must cite file or artifact paths in Implementation Evidence`);
1395
+ else {
1396
+ const missing = findMissingRepoPaths(repoRoot, [...implPaths].sort());
1397
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Implementation Evidence path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1398
+ if (changedPaths.size > 0 && [...implPaths].every((p) => !changedPaths.has(p)) && ![...implPaths].some((p) => p.startsWith(`.recursive/run/${basename(runDir)}/`))) issues.push(`Requirement ${requirementId} Implementation Evidence must reference the changed files or a current-run artifact that does so`);
1399
+ }
1400
+ } else if (status === "verified") {
1401
+ const changedFiles = fields["Changed Files"] ?? "";
1402
+ const changedPaths = collectRequirementFieldPaths(fields, ["Changed Files"]);
1403
+ const implEvidence = fields["Implementation Evidence"] ?? "";
1404
+ const verifEvidence = fields["Verification Evidence"] ?? "";
1405
+ const implPaths = collectRequirementFieldPaths(fields, ["Implementation Evidence"]);
1406
+ const verifPaths = collectRequirementFieldPaths(fields, ["Verification Evidence"]);
1407
+ if (!isMeaningfulRequirementField(changedFiles)) issues.push(`Requirement ${requirementId} with Status verified must cite Changed Files`);
1408
+ else if (changedPaths.size === 0) issues.push(`Requirement ${requirementId} with Status verified must cite repo paths in Changed Files`);
1409
+ else {
1410
+ const missing = findMissingRepoPaths(repoRoot, [...changedPaths].sort());
1411
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Changed Files path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1412
+ if (actualChangedScope.size > 0) {
1413
+ const unexplained = [...changedPaths].filter((p) => !actualChangedScope.has(p)).sort();
1414
+ if (unexplained.length > 0) issues.push(`Requirement ${requirementId} Changed Files are outside the current diff scope: ${unexplained.slice(0, 5).join(", ")}`);
1415
+ }
1416
+ }
1417
+ if (!isMeaningfulRequirementField(implEvidence)) issues.push(`Requirement ${requirementId} with Status verified must cite Implementation Evidence`);
1418
+ else if (implPaths.size === 0) issues.push(`Requirement ${requirementId} with Status verified must cite file or artifact paths in Implementation Evidence`);
1419
+ else {
1420
+ const missing = findMissingRepoPaths(repoRoot, [...implPaths].sort());
1421
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Implementation Evidence path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1422
+ if (changedPaths.size > 0 && [...implPaths].every((p) => !changedPaths.has(p)) && ![...implPaths].some((p) => p.startsWith(`.recursive/run/${basename(runDir)}/`))) issues.push(`Requirement ${requirementId} Implementation Evidence must reference the changed files or a current-run artifact that does so`);
1423
+ }
1424
+ if (!isMeaningfulRequirementField(verifEvidence)) issues.push(`Requirement ${requirementId} with Status verified must cite Verification Evidence`);
1425
+ else if (verifPaths.size === 0) issues.push(`Requirement ${requirementId} with Status verified must cite test, review, QA, or artifact paths in Verification Evidence`);
1426
+ else {
1427
+ const missing = findMissingRepoPaths(repoRoot, [...verifPaths].sort());
1428
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Verification Evidence path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1429
+ if (changedPaths.size > 0 && [...verifPaths].every((p) => changedPaths.has(p))) issues.push(`Requirement ${requirementId} Verification Evidence must cite verification artifacts, review receipts, or evidence paths distinct from the changed files`);
1430
+ if (implPaths.size > 0 && [...verifPaths].every((p) => implPaths.has(p))) issues.push(`Requirement ${requirementId} Verification Evidence cannot be satisfied by restating only the implementation evidence`);
1431
+ }
1432
+ } else if (status === "deferred") {
1433
+ const rationale = fields["Rationale"] ?? "";
1434
+ const deferredBy = fields["Deferred By"] ?? fields["Addendum"] ?? "";
1435
+ const deferredPaths = collectRequirementFieldPaths(fields, ["Deferred By", "Addendum"]);
1436
+ if (!isMeaningfulRequirementField(rationale)) issues.push(`Requirement ${requirementId} with Status deferred is missing Rationale`);
1437
+ if (!isMeaningfulRequirementField(deferredBy)) issues.push(`Requirement ${requirementId} with Status deferred must cite Deferred By or Addendum`);
1438
+ else if (deferredPaths.size === 0) issues.push(`Requirement ${requirementId} with Status deferred must cite an approved deferral path`);
1439
+ else {
1440
+ const missing = findMissingRepoPaths(repoRoot, [...deferredPaths].sort());
1441
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} deferral reference path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1442
+ }
1443
+ } else if (status === "out-of-scope") {
1444
+ const rationale = fields["Rationale"] ?? "";
1445
+ const scopeDecision = fields["Scope Decision"] ?? fields["Addendum"] ?? "";
1446
+ const scopePaths = collectRequirementFieldPaths(fields, ["Scope Decision", "Addendum"]);
1447
+ if (!isMeaningfulRequirementField(rationale)) issues.push(`Requirement ${requirementId} with Status out-of-scope is missing Rationale`);
1448
+ if (!isMeaningfulRequirementField(scopeDecision)) issues.push(`Requirement ${requirementId} with Status out-of-scope must cite Scope Decision or Addendum`);
1449
+ else if (scopePaths.size === 0) issues.push(`Requirement ${requirementId} with Status out-of-scope must cite an approved scope decision path`);
1450
+ else {
1451
+ const missing = findMissingRepoPaths(repoRoot, [...scopePaths].sort());
1452
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} scope decision path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1453
+ }
1454
+ } else if (status === "blocked") {
1455
+ const rationale = fields["Rationale"] ?? "";
1456
+ const blockingEvidence = fields["Blocking Evidence"] ?? "";
1457
+ const blockingPaths = collectRequirementFieldPaths(fields, ["Blocking Evidence"]);
1458
+ if (!isMeaningfulRequirementField(rationale)) issues.push(`Requirement ${requirementId} with Status blocked is missing Rationale`);
1459
+ if (!isMeaningfulRequirementField(blockingEvidence)) issues.push(`Requirement ${requirementId} with Status blocked must cite Blocking Evidence`);
1460
+ else if (blockingPaths.size === 0) issues.push(`Requirement ${requirementId} with Status blocked must cite file, artifact, or evidence paths in Blocking Evidence`);
1461
+ else {
1462
+ const missing = findMissingRepoPaths(repoRoot, [...blockingPaths].sort());
1463
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} Blocking Evidence path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1464
+ }
1465
+ } else if (status === "superseded by approved addendum") {
1466
+ const addendumPath = normalizeRepoPath(fields["Addendum"] ?? "");
1467
+ if (!addendumPath) issues.push(`Requirement ${requirementId} superseded by approved addendum must cite Addendum`);
1468
+ else if (!addendumPath.startsWith(`.recursive/run/${basename(runDir)}/addenda/`)) issues.push(`Requirement ${requirementId} addendum reference must live under the current run addenda/`);
1469
+ else if (!existsSync(join(repoRoot, addendumPath))) issues.push(`Requirement ${requirementId} addendum reference does not exist: ${addendumPath}`);
1470
+ }
1471
+ if (FINAL_REQUIREMENT_DISPOSITION_FILES.has(fileName)) {
1472
+ if (status === "implemented") issues.push(`Requirement ${requirementId} cannot remain implemented in ${fileName}; final closeout phases require verification`);
1473
+ if (status === "blocked") issues.push(`Requirement ${requirementId} cannot remain blocked in ${fileName} while the phase is approaching closeout`);
1474
+ }
1475
+ return issues;
1080
1476
  }
1081
- function invalidateReceipt(runDir, artifactFile) {
1082
- const rpath = receiptPath(runDir, artifactFile);
1083
- if (existsSync(rpath)) {
1084
- rmSync(rpath, { force: true });
1085
- return true;
1477
+ /** lint_source_requirement_inventory: 01-as-is Source Requirement Inventory rules. */
1478
+ function lintSourceRequirementInventory(filePath, content, workflowProfile, runDir) {
1479
+ if (workflowProfile !== "recursive-mode-audit-v2" || basename(filePath) !== "01-as-is.md") return [];
1480
+ const issues = [];
1481
+ const requirementsPath = join(runDir, "00-requirements.md");
1482
+ const requirementsContent = existsSync(requirementsPath) ? readFileSync(requirementsPath, "utf8") : "";
1483
+ const body = getHeadingBody(content, "Source Requirement Inventory");
1484
+ if (!body) return ["Missing or empty section: ## Source Requirement Inventory"];
1485
+ const [entries, entryIssues] = parseSourceRequirementInventoryEntries(body);
1486
+ issues.push(...entryIssues);
1487
+ if (Object.keys(entries).length === 0) {
1488
+ issues.push("Source Requirement Inventory must contain at least one requirement inventory entry");
1489
+ return [...new Set(issues)].sort();
1086
1490
  }
1087
- return false;
1491
+ const missingExplicit = parseRequirementIds(requirementsContent).filter((id) => !(id in entries));
1492
+ if (missingExplicit.length > 0) issues.push(`Source Requirement Inventory is missing explicit requirement IDs from 00-requirements.md: ${missingExplicit.join(", ")}`);
1493
+ for (const [requirementId, fields] of Object.entries(entries)) {
1494
+ const disposition = trimMdValue$1(fields["Disposition"] ?? "").toLowerCase();
1495
+ const sourceQuote = fields["Source Quote"] ?? "";
1496
+ const summary = fields["Summary"] ?? "";
1497
+ if (!INVENTORY_DISPOSITIONS.has(disposition)) issues.push(`Source Requirement Inventory for ${requirementId} has invalid Disposition '${fields["Disposition"] ?? ""}'`);
1498
+ if (!isMeaningfulRequirementField(sourceQuote)) issues.push(`Source Requirement Inventory for ${requirementId} is missing Source Quote`);
1499
+ else if (!sourceQuoteMatches(sourceQuote, requirementsContent)) issues.push(`Source Requirement Inventory for ${requirementId} cites a Source Quote that does not appear in 00-requirements.md`);
1500
+ if (!isMeaningfulRequirementField(summary)) issues.push(`Source Requirement Inventory for ${requirementId} is missing Summary`);
1501
+ }
1502
+ return [...new Set(issues)].sort();
1088
1503
  }
1089
- /**
1090
- * Downstream phases (strictly after artifactFile in PHASE_SEQUENCE) whose
1091
- * receipt's prerequisite_hashes[artifactFile] differs from the current hash
1092
- * (or the artifact no longer exists).
1093
- */
1094
- function getStaleDownstreamPhases(runDir, artifactFile) {
1095
- const idx = phaseIndex(artifactFile);
1096
- if (idx < 0) return [];
1097
- let currentHash = null;
1098
- const artifactPath = join(runDir, artifactFile);
1099
- if (existsSync(artifactPath)) currentHash = lockHashFromContent(readFileSync(artifactPath, "utf8"));
1100
- const stale = [];
1101
- for (const downstream of PHASE_SEQUENCE.slice(idx + 1)) {
1102
- const receipt = readReceipt(runDir, downstream);
1103
- if (!receipt) continue;
1104
- const prereqHashes = receipt.prerequisite_hashes ?? {};
1105
- if (!(artifactFile in prereqHashes)) continue;
1106
- const storedHash = prereqHashes[artifactFile];
1107
- if (currentHash === null || storedHash !== currentHash) stale.push({
1108
- artifact: downstream,
1109
- reason: `prerequisite '${artifactFile}' hash changed`
1110
- });
1504
+ /** lint_requirement_mapping: 02-to-be-plan Requirement Mapping rules. */
1505
+ function lintRequirementMapping(content, workflowProfile, runDir, repoRoot) {
1506
+ if (workflowProfile !== "recursive-mode-audit-v2") return [];
1507
+ const issues = [];
1508
+ const phase1Path = join(runDir, "01-as-is.md");
1509
+ const requirementsPath = join(runDir, "00-requirements.md");
1510
+ if (!existsSync(phase1Path) || !existsSync(requirementsPath)) return ["Requirement Mapping requires existing 00-requirements.md and 01-as-is.md inputs"];
1511
+ const inventoryBody = getHeadingBody(readFileSync(phase1Path, "utf8"), "Source Requirement Inventory");
1512
+ if (!inventoryBody) return ["Requirement Mapping requires 01-as-is.md to contain ## Source Requirement Inventory"];
1513
+ const [inventoryEntries, inventoryIssues] = parseSourceRequirementInventoryEntries(inventoryBody);
1514
+ issues.push(...inventoryIssues);
1515
+ const body = getHeadingBody(content, "Requirement Mapping");
1516
+ if (!body) {
1517
+ issues.push("Missing or empty section: ## Requirement Mapping");
1518
+ return [...new Set(issues)].sort();
1111
1519
  }
1112
- return stale;
1520
+ const [mappingEntries, mappingIssues] = parseRequirementMappingEntries(body);
1521
+ issues.push(...mappingIssues);
1522
+ const missingEntries = Object.keys(inventoryEntries).filter((id) => !(id in mappingEntries));
1523
+ if (missingEntries.length > 0) issues.push(`Requirement Mapping is missing source inventory items: ${missingEntries.join(", ")}`);
1524
+ const requirementsContent = readFileSync(requirementsPath, "utf8");
1525
+ for (const [requirementId, fields] of Object.entries(mappingEntries)) {
1526
+ const coverage = trimMdValue$1(fields["Coverage"] ?? "").toLowerCase();
1527
+ const sourceQuote = fields["Source Quote"] ?? "";
1528
+ const implementationSurface = fields["Implementation Surface"] ?? "";
1529
+ const verificationSurface = fields["Verification Surface"] ?? "";
1530
+ const qaSurface = fields["QA Surface"] ?? "";
1531
+ const rationale = fields["Rationale"] ?? fields["Merge Rationale"] ?? "";
1532
+ if (![
1533
+ "direct",
1534
+ "merged",
1535
+ "indirect",
1536
+ "deferred",
1537
+ "out-of-scope",
1538
+ "blocked"
1539
+ ].includes(coverage)) {
1540
+ issues.push(`Requirement Mapping for ${requirementId} has invalid Coverage '${fields["Coverage"] ?? ""}'`);
1541
+ continue;
1542
+ }
1543
+ if (!isMeaningfulRequirementField(sourceQuote)) issues.push(`Requirement Mapping for ${requirementId} is missing Source Quote`);
1544
+ else if (requirementId in inventoryEntries) {
1545
+ const inventoryQuote = inventoryEntries[requirementId]["Source Quote"] ?? "";
1546
+ if (normalizeSourceText(sourceQuote) !== normalizeSourceText(inventoryQuote)) issues.push(`Requirement Mapping for ${requirementId} must preserve the Source Quote recorded in Source Requirement Inventory`);
1547
+ } else if (!sourceQuoteMatches(sourceQuote, requirementsContent)) issues.push(`Requirement Mapping for ${requirementId} cites a Source Quote that does not appear in 00-requirements.md`);
1548
+ if ([
1549
+ "direct",
1550
+ "merged",
1551
+ "indirect"
1552
+ ].includes(coverage)) {
1553
+ if (!isMeaningfulRequirementField(implementationSurface)) issues.push(`Requirement Mapping for ${requirementId} is missing Implementation Surface`);
1554
+ else issues.push(...validatePlannedSurfacePaths(requirementId, "Implementation Surface", implementationSurface, repoRoot));
1555
+ if (!isMeaningfulRequirementField(verificationSurface)) issues.push(`Requirement Mapping for ${requirementId} is missing Verification Surface`);
1556
+ if (!isMeaningfulRequirementField(qaSurface)) issues.push(`Requirement Mapping for ${requirementId} is missing QA Surface`);
1557
+ }
1558
+ if (coverage === "merged" && !isMeaningfulRequirementField(rationale)) issues.push(`Requirement Mapping for ${requirementId} with Coverage merged must cite Merge Rationale or Rationale`);
1559
+ if (coverage === "indirect" && !isMeaningfulRequirementField(rationale)) issues.push(`Requirement Mapping for ${requirementId} with Coverage indirect must cite Rationale`);
1560
+ if ([
1561
+ "deferred",
1562
+ "out-of-scope",
1563
+ "blocked"
1564
+ ].includes(coverage) && !isMeaningfulRequirementField(rationale)) issues.push(`Requirement Mapping for ${requirementId} with Coverage ${coverage} must cite Rationale`);
1565
+ }
1566
+ return [...new Set(issues)].sort();
1113
1567
  }
1114
- function getNextLegalPhase(runDir) {
1115
- for (const phase of PHASE_SEQUENCE) {
1116
- const phasePath = join(runDir, phase);
1117
- if (getLockStatus(phasePath) === "LOCKED") continue;
1118
- if (!existsSync(phasePath) && OPTIONAL_PHASES.has(phase)) continue;
1119
- if (getPrerequisiteBlockers(runDir, phase).length > 0) return null;
1120
- return phase;
1568
+ /** lint_plan_drift_check: Plan Drift Check merge/rationale rule. */
1569
+ function lintPlanDriftCheck(content, workflowProfile) {
1570
+ if (workflowProfile !== "recursive-mode-audit-v2") return [];
1571
+ const body = getHeadingBody(content, "Plan Drift Check");
1572
+ if (!body) return ["Missing or empty section: ## Plan Drift Check"];
1573
+ if (/\bmerge\b/i.test(body) && !/\brationale\b/i.test(body)) return ["Plan Drift Check mentions merged obligations without explaining why the merge is lossless"];
1574
+ return [];
1575
+ }
1576
+ /** lint_phase2_requirement_disposition_fields: 02 phase-2 status rules. */
1577
+ function lintPhase2RequirementDispositionFields(requirementId, status, fields, runDir, repoRoot) {
1578
+ const issues = [];
1579
+ const allowedFieldsByStatus = {
1580
+ "planned": /* @__PURE__ */ new Set([
1581
+ "Implementation Surface",
1582
+ "Verification Surface",
1583
+ "QA Surface",
1584
+ "Audit Note"
1585
+ ]),
1586
+ "planned-via-merge": /* @__PURE__ */ new Set([
1587
+ "Implementation Surface",
1588
+ "Verification Surface",
1589
+ "QA Surface",
1590
+ "Rationale",
1591
+ "Audit Note"
1592
+ ]),
1593
+ "planned-indirectly": /* @__PURE__ */ new Set([
1594
+ "Implementation Surface",
1595
+ "Verification Surface",
1596
+ "QA Surface",
1597
+ "Rationale",
1598
+ "Audit Note"
1599
+ ]),
1600
+ "deferred": /* @__PURE__ */ new Set([
1601
+ "Rationale",
1602
+ "Deferred By",
1603
+ "Addendum",
1604
+ "Audit Note"
1605
+ ]),
1606
+ "out-of-scope": /* @__PURE__ */ new Set([
1607
+ "Rationale",
1608
+ "Scope Decision",
1609
+ "Addendum",
1610
+ "Audit Note"
1611
+ ]),
1612
+ "blocked": /* @__PURE__ */ new Set([
1613
+ "Rationale",
1614
+ "Blocking Evidence",
1615
+ "Audit Note"
1616
+ ]),
1617
+ "superseded by approved addendum": /* @__PURE__ */ new Set(["Addendum", "Audit Note"])
1618
+ };
1619
+ const meaningfulFields = collectMeaningfulRequirementFields(fields);
1620
+ const unexpected = Object.keys(meaningfulFields).filter((k) => !(allowedFieldsByStatus[status] ?? /* @__PURE__ */ new Set()).has(k)).sort();
1621
+ if (unexpected.length > 0) issues.push(`Requirement ${requirementId} with Status ${status} contains contradictory field(s): ${unexpected.join(", ")}`);
1622
+ if ([
1623
+ "planned",
1624
+ "planned-via-merge",
1625
+ "planned-indirectly"
1626
+ ].includes(status)) {
1627
+ const implSurface = fields["Implementation Surface"] ?? "";
1628
+ const verifSurface = fields["Verification Surface"] ?? "";
1629
+ const qaSurface = fields["QA Surface"] ?? "";
1630
+ const rationale = fields["Rationale"] ?? "";
1631
+ if (!isMeaningfulRequirementField(implSurface)) issues.push(`Requirement ${requirementId} with Status ${status} must cite Implementation Surface`);
1632
+ else issues.push(...validatePlannedSurfacePaths(requirementId, "Implementation Surface", implSurface, repoRoot));
1633
+ if (!isMeaningfulRequirementField(verifSurface)) issues.push(`Requirement ${requirementId} with Status ${status} must cite Verification Surface`);
1634
+ if (!isMeaningfulRequirementField(qaSurface)) issues.push(`Requirement ${requirementId} with Status ${status} must cite QA Surface`);
1635
+ if (["planned-via-merge", "planned-indirectly"].includes(status) && !isMeaningfulRequirementField(rationale)) issues.push(`Requirement ${requirementId} with Status ${status} must cite Rationale`);
1636
+ } else if (status === "deferred") {
1637
+ const deferredBy = fields["Deferred By"] ?? fields["Addendum"] ?? "";
1638
+ const deferredPaths = collectRequirementFieldPaths(fields, ["Deferred By", "Addendum"]);
1639
+ if (!isMeaningfulRequirementField(fields["Rationale"] ?? "")) issues.push(`Requirement ${requirementId} with Status deferred is missing Rationale`);
1640
+ if (!isMeaningfulRequirementField(deferredBy)) issues.push(`Requirement ${requirementId} with Status deferred must cite Deferred By or Addendum`);
1641
+ else if (deferredPaths.size === 0) issues.push(`Requirement ${requirementId} with Status deferred must cite an approved deferral path`);
1642
+ else {
1643
+ const missing = findMissingRepoPaths(repoRoot, [...deferredPaths].sort());
1644
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} deferral reference path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1645
+ }
1646
+ } else if (status === "out-of-scope") {
1647
+ const scopeDecision = fields["Scope Decision"] ?? fields["Addendum"] ?? "";
1648
+ const scopePaths = collectRequirementFieldPaths(fields, ["Scope Decision", "Addendum"]);
1649
+ if (!isMeaningfulRequirementField(fields["Rationale"] ?? "")) issues.push(`Requirement ${requirementId} with Status out-of-scope is missing Rationale`);
1650
+ if (!isMeaningfulRequirementField(scopeDecision)) issues.push(`Requirement ${requirementId} with Status out-of-scope must cite Scope Decision or Addendum`);
1651
+ else if (scopePaths.size === 0) issues.push(`Requirement ${requirementId} with Status out-of-scope must cite an approved scope decision path`);
1652
+ else {
1653
+ const missing = findMissingRepoPaths(repoRoot, [...scopePaths].sort());
1654
+ if (missing.length > 0) issues.push(`Requirement ${requirementId} scope decision path(s) do not exist: ${missing.slice(0, 5).join(", ")}`);
1655
+ }
1656
+ } else if (status === "blocked") {
1657
+ const blockingEvidence = fields["Blocking Evidence"] ?? "";
1658
+ const blockingPaths = collectRequirementFieldPaths(fields, ["Blocking Evidence"]);
1659
+ if (!isMeaningfulRequirementField(fields["Rationale"] ?? "")) issues.push(`Requirement ${requirementId} with Status blocked is missing Rationale`);
1660
+ if (!isMeaningfulRequirementField(blockingEvidence)) issues.push(`Requirement ${requirementId} with Status blocked must cite Blocking Evidence`);
1661
+ else if (blockingPaths.size === 0) issues.push(`Requirement ${requirementId} with Status blocked must cite file, artifact, or evidence paths in Blocking Evidence`);
1662
+ } else if (status === "superseded by approved addendum") {
1663
+ const addendumPath = normalizeRepoPath(fields["Addendum"] ?? "");
1664
+ if (!addendumPath) issues.push(`Requirement ${requirementId} superseded by approved addendum must cite Addendum`);
1665
+ else if (!addendumPath.startsWith(`.recursive/run/${basename(runDir)}/addenda/`)) issues.push(`Requirement ${requirementId} addendum reference must live under the current run addenda/`);
1666
+ else if (!existsSync(join(repoRoot, addendumPath))) issues.push(`Requirement ${requirementId} addendum reference does not exist: ${addendumPath}`);
1121
1667
  }
1122
- return null;
1668
+ return issues;
1123
1669
  }
1124
- /** All receipts whose prerequisite_hashes reference a missing or changed artifact. */
1125
- function getAllStaleReceipts(runDir) {
1126
- const stale = [];
1127
- for (const phase of PHASE_SEQUENCE) {
1128
- const receipt = readReceipt(runDir, phase);
1129
- if (!receipt) continue;
1130
- const prereqHashes = receipt.prerequisite_hashes ?? {};
1131
- for (const [prereq, storedHash] of Object.entries(prereqHashes)) {
1132
- const prereqPath = join(runDir, prereq);
1133
- if (!existsSync(prereqPath)) {
1134
- stale.push({
1135
- artifact: phase,
1136
- reason: `prerequisite '${prereq}' no longer exists`
1670
+ /** parse_source_requirement_inventory_entries: `- R1 | Disposition: ... | Source Quote: ... | Summary: ...` rows. */
1671
+ function parseSourceRequirementInventoryEntries(sectionBody) {
1672
+ const entries = {};
1673
+ const issues = [];
1674
+ for (const rawLine of sectionBody.split(/\r?\n/)) {
1675
+ const line = rawLine.trim();
1676
+ if (!line.startsWith("-") && !line.startsWith("*")) continue;
1677
+ const parts = trimMdValue$1(line.slice(1).trim()).split("|").map((p) => trimMdValue$1(p.trim())).filter(Boolean);
1678
+ if (parts.length < 2) continue;
1679
+ const requirementId = trimMdValue$1(parts[0]);
1680
+ if (!REQUIREMENT_ID_RE.test(requirementId)) continue;
1681
+ const fields = { "Requirement ID": requirementId };
1682
+ for (const part of parts.slice(1)) {
1683
+ const idx = part.indexOf(":");
1684
+ if (idx < 0) continue;
1685
+ fields[part.slice(0, idx).trim()] = trimMdValue$1(part.slice(idx + 1).trim());
1686
+ }
1687
+ if (requirementId in entries) {
1688
+ issues.push(`Source Requirement Inventory contains duplicate entries for ${requirementId}`);
1689
+ continue;
1690
+ }
1691
+ entries[requirementId] = fields;
1692
+ }
1693
+ return [entries, issues];
1694
+ }
1695
+ /** parse_requirement_mapping_entries: `- R1 | Source Quote: ... | Coverage: ... | ...` rows. */
1696
+ function parseRequirementMappingEntries(sectionBody) {
1697
+ const entries = {};
1698
+ const issues = [];
1699
+ for (const rawLine of sectionBody.split(/\r?\n/)) {
1700
+ const line = rawLine.trim();
1701
+ if (!line.startsWith("-") && !line.startsWith("*")) continue;
1702
+ const parts = trimMdValue$1(line.slice(1).trim()).split("|").map((p) => trimMdValue$1(p.trim())).filter(Boolean);
1703
+ if (parts.length < 2) continue;
1704
+ const requirementId = trimMdValue$1(parts[0]);
1705
+ if (!REQUIREMENT_ID_RE.test(requirementId)) continue;
1706
+ const fields = { "Requirement ID": requirementId };
1707
+ for (const part of parts.slice(1)) {
1708
+ const idx = part.indexOf(":");
1709
+ if (idx < 0) continue;
1710
+ fields[part.slice(0, idx).trim()] = trimMdValue$1(part.slice(idx + 1).trim());
1711
+ }
1712
+ if (requirementId in entries) {
1713
+ issues.push(`Requirement Mapping contains duplicate entries for ${requirementId}`);
1714
+ continue;
1715
+ }
1716
+ entries[requirementId] = fields;
1717
+ }
1718
+ return [entries, issues];
1719
+ }
1720
+ /** get_run_requirement_ids: R ids from 00-requirements or 01-as-is inventory (current profile). */
1721
+ function getRunRequirementIds(runDir, workflowProfile) {
1722
+ const requirementsPath = join(runDir, "00-requirements.md");
1723
+ let explicitIds = [];
1724
+ if (existsSync(requirementsPath)) explicitIds = parseRequirementIds(readFileSync(requirementsPath, "utf8"));
1725
+ if (workflowProfile === "recursive-mode-audit-v2") {
1726
+ const phase1Path = join(runDir, "01-as-is.md");
1727
+ if (existsSync(phase1Path)) {
1728
+ const inventoryBody = getHeadingBody(readFileSync(phase1Path, "utf8"), "Source Requirement Inventory");
1729
+ if (inventoryBody) {
1730
+ const [entries] = parseSourceRequirementInventoryEntries(inventoryBody);
1731
+ if (Object.keys(entries).length > 0) return Object.keys(entries).sort((a, b) => {
1732
+ const [ka, va, sa] = requirementSortKey(a);
1733
+ const [kb, vb, sb] = requirementSortKey(b);
1734
+ return ka !== kb ? ka - kb : typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
1137
1735
  });
1736
+ }
1737
+ }
1738
+ }
1739
+ return explicitIds;
1740
+ }
1741
+ /** validate_planned_surface_paths: implementation-surface paths must resolve or have an existing parent. */
1742
+ function validatePlannedSurfacePaths(requirementId, fieldName, rawValue, repoRoot) {
1743
+ const issues = [];
1744
+ const extracted = collectRequirementFieldPaths({ [fieldName]: rawValue }, [fieldName]);
1745
+ if (extracted.size === 0) {
1746
+ issues.push(`Requirement ${requirementId} ${fieldName} must cite concrete repo paths or file names`);
1747
+ return issues;
1748
+ }
1749
+ const unresolved = [];
1750
+ for (const path of [...extracted].sort()) {
1751
+ const candidate = join(repoRoot, path);
1752
+ if (existsSync(candidate)) continue;
1753
+ if (existsSync(dirname(candidate))) continue;
1754
+ unresolved.push(path);
1755
+ }
1756
+ if (unresolved.length > 0) issues.push(`Requirement ${requirementId} ${fieldName} contains unresolved planned path(s): ${unresolved.slice(0, 5).join(", ")}`);
1757
+ return issues;
1758
+ }
1759
+ /** lint_effective_input_addenda: Inputs / Effective Inputs Re-read / Earlier Phase Reconciliation addenda citations. */
1760
+ function lintEffectiveInputAddenda(filePath, content, workflowProfile, runDir) {
1761
+ if (![...STRICT_WORKFLOW_PROFILES$1, "memory-phase8"].includes(workflowProfile)) return [];
1762
+ if (isAddendumArtifact(basename(filePath))) return [];
1763
+ const expectedAddenda = getExpectedEffectiveInputAddendaPaths(runDir, basename(filePath));
1764
+ if (expectedAddenda.length === 0) return [];
1765
+ const issues = [];
1766
+ const headerInputs = new Set([...getHeaderInputPaths(content)].map(normalizeRepoPath));
1767
+ const missingInputs = expectedAddenda.filter((p) => !headerInputs.has(p));
1768
+ if (missingInputs.length > 0) issues.push(`Inputs is missing relevant addenda: ${missingInputs.slice(0, 5).join(", ")}`);
1769
+ if (STRICT_WORKFLOW_PROFILES$1.has(workflowProfile) && AUDITED_PHASE_FILES$2.has(basename(filePath))) {
1770
+ const rereadPaths = new Set([...extractPathsFromText(getHeadingBody(content, "Effective Inputs Re-read"))].map(normalizeRepoPath));
1771
+ const missingReread = expectedAddenda.filter((p) => !rereadPaths.has(p));
1772
+ if (missingReread.length > 0) issues.push(`Effective Inputs Re-read is missing relevant addenda: ${missingReread.slice(0, 5).join(", ")}`);
1773
+ const reconciliationPaths = new Set([...extractPathsFromText(getHeadingBody(content, "Earlier Phase Reconciliation"))].map(normalizeRepoPath));
1774
+ const missingRecon = expectedAddenda.filter((p) => !reconciliationPaths.has(p));
1775
+ if (missingRecon.length > 0) issues.push(`Earlier Phase Reconciliation is missing relevant addenda: ${missingRecon.slice(0, 5).join(", ")}`);
1776
+ }
1777
+ return issues;
1778
+ }
1779
+ /** lint_requirement_completion_status: RCS section rules for audited phases. */
1780
+ function lintRequirementCompletionStatus(filePath, content, requirementIds, runDir, workflowProfile, actualChangedFiles) {
1781
+ if (!STRICT_WORKFLOW_PROFILES$1.has(workflowProfile) || !AUDITED_PHASE_FILES$2.has(basename(filePath))) return [];
1782
+ const body = getHeadingBody(content, "Requirement Completion Status");
1783
+ if (!body) return ["Missing or empty section: ## Requirement Completion Status"];
1784
+ const [entries, issues] = parseRequirementCompletionEntries(body);
1785
+ const missing = requirementIds.filter((id) => !(id in entries));
1786
+ if (missing.length > 0) issues.push(`Requirement Completion Status is missing in-scope requirements: ${missing.join(", ")}`);
1787
+ const repoRoot = dirname(dirname(dirname(runDir)));
1788
+ if (workflowProfile === "recursive-mode-audit-v2" && basename(filePath) === "02-to-be-plan.md") {
1789
+ for (const [requirementId, fields] of Object.entries(entries)) {
1790
+ const status = trimMdValue$1(fields["Status"] ?? "").toLowerCase();
1791
+ if (!PHASE2_REQUIREMENT_DISPOSITION_STATUSES.has(status)) {
1792
+ issues.push(`Requirement Completion Status for ${requirementId} has invalid Phase 2 Status '${fields["Status"] ?? ""}'`);
1138
1793
  continue;
1139
1794
  }
1140
- if (storedHash !== lockHashFromContent(readFileSync(prereqPath, "utf8"))) stale.push({
1141
- artifact: phase,
1142
- reason: `prerequisite '${prereq}' content changed since lock at ${receipt.locked_at}`
1143
- });
1795
+ issues.push(...lintPhase2RequirementDispositionFields(requirementId, status, fields, runDir, repoRoot));
1796
+ }
1797
+ return [...new Set(issues)].sort();
1798
+ }
1799
+ for (const [requirementId, fields] of Object.entries(entries)) {
1800
+ const status = trimMdValue$1(fields["Status"] ?? "").toLowerCase();
1801
+ if (!REQUIREMENT_DISPOSITION_STATUSES.has(status)) {
1802
+ issues.push(`Requirement Completion Status for ${requirementId} has invalid Status '${fields["Status"] ?? ""}'`);
1803
+ continue;
1804
+ }
1805
+ issues.push(...lintRequirementDispositionFields(requirementId, status, fields, basename(filePath), runDir, repoRoot, actualChangedFiles));
1806
+ }
1807
+ if (REQUIREMENT_CHANGED_FILE_ACCOUNTING_FILES.has(basename(filePath))) {
1808
+ const expectedScope = new Set(getPhaseOwnedActualChangedFiles(basename(filePath), actualChangedFiles, repoRoot) ?? []);
1809
+ if (expectedScope.size > 0) {
1810
+ const claimedChangedFiles = /* @__PURE__ */ new Set();
1811
+ for (const fields of Object.values(entries)) {
1812
+ const status = trimMdValue$1(fields["Status"] ?? "").toLowerCase();
1813
+ if (["implemented", "verified"].includes(status)) for (const p of collectRequirementFieldPaths(fields, ["Changed Files"])) claimedChangedFiles.add(p);
1814
+ }
1815
+ const missingClaims = [...expectedScope].filter((p) => !claimedChangedFiles.has(p)).sort();
1816
+ if (missingClaims.length > 0) issues.push("Requirement Completion Status leaves diff-owned changed file(s) unaccounted for: " + missingClaims.slice(0, 5).join(", "));
1817
+ }
1818
+ }
1819
+ return [...new Set(issues)].sort();
1820
+ }
1821
+ /** lint_prior_recursive_evidence: Prior Recursive Evidence Reviewed rules. */
1822
+ function lintPriorRecursiveEvidence(content, runDir, workflowProfile, repoRoot, fileName) {
1823
+ if (!STRICT_WORKFLOW_PROFILES$1.has(workflowProfile) || !PRIOR_RECURSIVE_EVIDENCE_FILES.has(fileName)) return [];
1824
+ const body = getHeadingBody(content, "Prior Recursive Evidence Reviewed");
1825
+ if (!body) return ["Missing or empty section: ## Prior Recursive Evidence Reviewed"];
1826
+ const referencedPaths = /* @__PURE__ */ new Set();
1827
+ for (const path of extractPathsFromText(body)) {
1828
+ const normalized = normalizeRepoPath(path);
1829
+ if (normalized.startsWith(".recursive/run/") || normalized.startsWith(".recursive/memory/")) referencedPaths.add(normalized);
1830
+ }
1831
+ if (referencedPaths.size > 0) {
1832
+ const missing = findMissingRepoPaths(repoRoot, [...referencedPaths].sort());
1833
+ if (missing.length > 0) return [`Prior Recursive Evidence Reviewed references missing path(s): ${missing.slice(0, 5).join(", ")}`];
1834
+ return [];
1835
+ }
1836
+ if (/\bnone\b/i.test(body) && /\b(justification|reason|because)\b/i.test(body)) return [];
1837
+ return ["Prior Recursive Evidence Reviewed must contain structured run/memory paths or an explicit no-relevant-evidence justification"];
1838
+ }
1839
+ /** get_subagent_action_record_paths: subagents/ paths cited in Subagent Contribution Verification. */
1840
+ function getSubagentActionRecordPaths(content, runDir) {
1841
+ const body = getHeadingBody(content, "Subagent Contribution Verification");
1842
+ if (!body) return [];
1843
+ const expectedPrefix = `.recursive/run/${basename(runDir)}/subagents/`;
1844
+ return [...new Set([...extractPathsFromText(body)].map(normalizeRepoPath).filter((p) => p.startsWith(expectedPrefix)))].sort();
1845
+ }
1846
+ /** get_all_subagent_action_record_paths: any run subagents/ path cited. */
1847
+ function getAllSubagentActionRecordPaths(content) {
1848
+ const body = getHeadingBody(content, "Subagent Contribution Verification");
1849
+ if (!body) return [];
1850
+ return [...new Set([...extractPathsFromText(body)].map(normalizeRepoPath).filter((p) => /^\.recursive\/run\/[^/]+\/subagents\/.+\.md$/.test(p)))].sort();
1851
+ }
1852
+ /** lint_subagent_action_record_file: validate a subagent action record file. */
1853
+ function lintSubagentActionRecordFile(filePath, repoRoot, runDir, actualChangedFiles) {
1854
+ const content = readFileSync(filePath, "utf8");
1855
+ const issues = [];
1856
+ if (!content.includes("# Subagent Action Record")) issues.push("Missing title: # Subagent Action Record");
1857
+ for (const heading of SUBAGENT_ACTION_REQUIRED_HEADINGS) if (!getHeadingBody(content, heading)) issues.push(`Missing or empty section: ## ${heading}`);
1858
+ const metadata = getHeadingBody(content, "Metadata");
1859
+ const inputs = getHeadingBody(content, "Inputs Provided");
1860
+ const claimedActions = getHeadingBody(content, "Claimed Actions Taken");
1861
+ const claimedFileImpact = getHeadingBody(content, "Claimed File Impact");
1862
+ const claimedArtifactImpact = getHeadingBody(content, "Claimed Artifact Impact");
1863
+ if (dirname(filePath) !== join(runDir, "subagents")) issues.push(`Subagent action record must live under \`/.recursive/run/${basename(runDir)}/subagents/\``);
1864
+ for (const fieldName of [
1865
+ "Subagent ID",
1866
+ "Run ID",
1867
+ "Phase",
1868
+ "Purpose",
1869
+ "Execution Mode",
1870
+ "Timestamp"
1871
+ ]) if (!hasMeaningfulValue(getMdFieldValue$1(metadata, fieldName))) issues.push(`Metadata is missing ${fieldName}`);
1872
+ const runId = getMdFieldValue$1(metadata, "Run ID");
1873
+ if (runId && runId !== basename(runDir)) issues.push(`Run ID mismatch: ${runId} != ${basename(runDir)}`);
1874
+ const currentArtifact = normalizeRepoPath(getMdFieldValue$1(inputs, "Current Artifact") ?? "");
1875
+ if (!currentArtifact) issues.push("Inputs Provided is missing Current Artifact");
1876
+ else if (!existsSync(join(repoRoot, currentArtifact))) issues.push(`Current Artifact does not exist: ${currentArtifact}`);
1877
+ const artifactHash = trimMdValue$1(getMdFieldValue$1(inputs, "Artifact Content Hash") ?? "");
1878
+ if (currentArtifact && existsSync(join(repoRoot, currentArtifact))) {
1879
+ const currentHash = contentSha256$1(readFileSync(join(repoRoot, currentArtifact), "utf8"));
1880
+ if (!artifactHash) issues.push("Inputs Provided is missing Artifact Content Hash");
1881
+ else if (artifactHash !== currentHash) issues.push("Inputs Provided Artifact Content Hash does not match the current artifact content");
1882
+ }
1883
+ const reviewBundle = normalizeRepoPath(getMdFieldValue$1(inputs, "Review Bundle") ?? "");
1884
+ if (reviewBundle && !existsSync(join(repoRoot, reviewBundle))) issues.push(`Review Bundle does not exist: ${reviewBundle}`);
1885
+ if (!(getMdFieldValue$1(inputs, "Diff Basis") ?? "").trim()) issues.push("Inputs Provided is missing Diff Basis");
1886
+ const upstreamArtifacts = new Set([...extractPathsFromNamedField(inputs, "Upstream Artifacts")].filter((p) => p.startsWith(`.recursive/run/${basename(runDir)}/`)));
1887
+ const codeRefs = extractPathsFromNamedField(inputs, "Code Refs");
1888
+ const memoryRefs = new Set([...extractPathsFromNamedField(inputs, "Memory Refs")].filter((p) => p.startsWith(".recursive/memory/")));
1889
+ const auditQuestionText = getMdFieldValue$1(inputs, "Audit / Task Questions") ?? inputs;
1890
+ if (upstreamArtifacts.size === 0 && !reviewBundle) issues.push("Inputs Provided must cite upstream artifacts or the review bundle used for delegation");
1891
+ if (isPlaceholderOnly(auditQuestionText)) issues.push("Inputs Provided is missing concrete Audit / Task Questions");
1892
+ const claimedCreated = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, "Created"))].map(normalizeRepoPath));
1893
+ const claimedModified = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, "Modified"))].map(normalizeRepoPath));
1894
+ const claimedReviewed = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, "Reviewed"))].map(normalizeRepoPath));
1895
+ const claimedRelevantUntouched = new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, "Relevant but Untouched"))].map(normalizeRepoPath));
1896
+ if (isPlaceholderOnly(claimedActions)) issues.push("Claimed Actions Taken must contain concrete delegated work details");
1897
+ const claimedFileRefs = /* @__PURE__ */ new Set([
1898
+ ...claimedCreated,
1899
+ ...claimedModified,
1900
+ ...claimedReviewed,
1901
+ ...claimedRelevantUntouched
1902
+ ]);
1903
+ if (claimedFileRefs.size === 0) issues.push("Claimed File Impact must cite at least one created, modified, reviewed, or relevant untouched file");
1904
+ for (const createdPath of [...claimedCreated].sort()) if (!existsSync(join(repoRoot, createdPath))) issues.push(`Claimed created file does not exist: ${createdPath}`);
1905
+ for (const reviewedPath of [.../* @__PURE__ */ new Set([
1906
+ ...claimedModified,
1907
+ ...claimedReviewed,
1908
+ ...claimedRelevantUntouched
1909
+ ])].sort()) if (!existsSync(join(repoRoot, reviewedPath))) issues.push(`Claimed file reference does not exist: ${reviewedPath}`);
1910
+ if (actualChangedFiles !== null) {
1911
+ const actualChanged = new Set(actualChangedFiles);
1912
+ const missingChangedClaims = [.../* @__PURE__ */ new Set([...claimedModified, ...claimedCreated])].filter((p) => !actualChanged.has(p));
1913
+ 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(", ")}`);
1914
+ }
1915
+ if (reviewBundle && existsSync(join(repoRoot, reviewBundle))) {
1916
+ const bundleContent = readFileSync(join(repoRoot, reviewBundle), "utf8");
1917
+ const bundleArtifactPath = normalizeRepoPath(getMdFieldValue$1(bundleContent, "Artifact Path") ?? "");
1918
+ const bundleUpstream = new Set([...extractPathsFromText(getHeadingBody(bundleContent, "Upstream Artifacts To Re-read"))].map(normalizeRepoPath).filter(Boolean));
1919
+ const bundleChanged = new Set([...extractPathsFromText(getHeadingBody(bundleContent, "Changed Files Reviewed"))].map(normalizeRepoPath).filter(Boolean));
1920
+ const bundleCodeRefs = new Set([...extractPathsFromText(getHeadingBody(bundleContent, "Targeted Code References"))].map(normalizeRepoPath).filter(Boolean));
1921
+ const allowedArtifacts = new Set([bundleArtifactPath, ...bundleUpstream].filter(Boolean));
1922
+ if (currentArtifact && allowedArtifacts.size > 0 && !allowedArtifacts.has(currentArtifact)) issues.push(`Inputs Provided Current Artifact must match the review bundle Artifact Path or a cited upstream artifact: ${currentArtifact}`);
1923
+ const missingBundleUpstream = [...bundleUpstream].filter((p) => !upstreamArtifacts.has(p)).sort();
1924
+ if (missingBundleUpstream.length > 0) issues.push(`Inputs Provided Upstream Artifacts omit bundle upstream artifact(s): ${missingBundleUpstream.slice(0, 5).join(", ")}`);
1925
+ const requiredBundleFileScope = /* @__PURE__ */ new Set([...bundleCodeRefs, ...bundleChanged]);
1926
+ if (requiredBundleFileScope.size > 0) {
1927
+ const missingBundleScope = [...requiredBundleFileScope].filter((p) => !claimedFileRefs.has(p)).sort();
1928
+ if (missingBundleScope.length > 0) issues.push(`Claimed File Impact omits targeted file scope present in the review bundle: ${missingBundleScope.slice(0, 5).join(", ")}`);
1929
+ }
1930
+ }
1931
+ const artifactRefs = new Set([...extractPathsFromText(claimedArtifactImpact)].map(normalizeRepoPath).filter((p) => p.startsWith(".recursive/")));
1932
+ const evidenceRefs = new Set([...extractPathsFromText(claimedArtifactImpact)].map(normalizeRepoPath).filter((p) => p.startsWith(`.recursive/run/${basename(runDir)}/evidence/`)));
1933
+ if (artifactRefs.size === 0 && evidenceRefs.size === 0) issues.push("Claimed Artifact Impact must cite recursive artifacts or evidence paths used by the subagent");
1934
+ const missingArtifactRefs = findMissingRepoPaths(repoRoot, [...artifactRefs].sort());
1935
+ if (missingArtifactRefs.length > 0) issues.push(`Claimed artifact references do not exist: ${missingArtifactRefs.slice(0, 5).join(", ")}`);
1936
+ const missingCodeRefs = findMissingRepoPaths(repoRoot, [...codeRefs].sort());
1937
+ if (missingCodeRefs.length > 0) issues.push(`Inputs Provided code refs do not exist: ${missingCodeRefs.slice(0, 5).join(", ")}`);
1938
+ const missingMemoryRefs = findMissingRepoPaths(repoRoot, [...memoryRefs].sort());
1939
+ if (missingMemoryRefs.length > 0) issues.push(`Inputs Provided memory refs do not exist: ${missingMemoryRefs.slice(0, 5).join(", ")}`);
1940
+ const verificationHandoff = getHeadingBody(content, "Verification Handoff");
1941
+ if (verificationHandoff && extractPathsFromText(verificationHandoff).size === 0) issues.push("Verification Handoff must cite files, diffs, or artifacts to inspect");
1942
+ return [...new Set(issues)].sort();
1943
+ }
1944
+ /** parse_subagent_action_record_claims: extract claims from an action record. */
1945
+ function parseSubagentActionRecordClaims(actionContent) {
1946
+ const inputs = getHeadingBody(actionContent, "Inputs Provided");
1947
+ const claimedFileImpact = getHeadingBody(actionContent, "Claimed File Impact");
1948
+ const claimedArtifactImpact = getHeadingBody(actionContent, "Claimed Artifact Impact");
1949
+ return {
1950
+ current_artifact: normalizeRepoPath(getMdFieldValue$1(inputs, "Current Artifact") ?? ""),
1951
+ review_bundle: normalizeRepoPath(getMdFieldValue$1(inputs, "Review Bundle") ?? ""),
1952
+ upstream_artifacts: extractPathsFromNamedField(inputs, "Upstream Artifacts"),
1953
+ created: new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, "Created"))].map(normalizeRepoPath).filter(Boolean)),
1954
+ modified: new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, "Modified"))].map(normalizeRepoPath).filter(Boolean)),
1955
+ reviewed: new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, "Reviewed"))].map(normalizeRepoPath).filter(Boolean)),
1956
+ relevant_untouched: new Set([...extractPathsFromText(getSubheadingBody(claimedFileImpact, "Relevant but Untouched"))].map(normalizeRepoPath).filter(Boolean)),
1957
+ artifact_refs: new Set([...extractPathsFromText(claimedArtifactImpact)].map(normalizeRepoPath).filter((p) => p.startsWith(".recursive/")))
1958
+ };
1959
+ }
1960
+ /** lint_subagent_contribution_verification: SCV section rules for audited phases. */
1961
+ function lintSubagentContributionVerification(filePath, content, workflowProfile, runDir, repoRoot, actualChangedFiles) {
1962
+ if (!STRICT_WORKFLOW_PROFILES$1.has(workflowProfile) || !AUDITED_PHASE_FILES$2.has(basename(filePath))) return [];
1963
+ const body = getHeadingBody(content, "Subagent Contribution Verification");
1964
+ if (!body) return ["Missing or empty section: ## Subagent Contribution Verification"];
1965
+ const issues = [];
1966
+ const actionRecordPaths = getSubagentActionRecordPaths(content, runDir);
1967
+ const allActionRecordPaths = getAllSubagentActionRecordPaths(content);
1968
+ const auditMode = getMdFieldValue$1(getHeadingBody(content, "Audit Context"), "Audit Execution Mode") ?? "";
1969
+ const currentPhase = getMdFieldValue$1(content, "Phase") ?? "";
1970
+ const reviewedActionRecordsField = getNamedFieldText(body, "Reviewed Action Records") ?? "";
1971
+ const mainAgentVerification = getNamedFieldText(body, "Main-Agent Verification Performed") ?? "";
1972
+ const acceptanceDecision = trimMdValue$1(getMdFieldValue$1(body, "Acceptance Decision") ?? "").toLowerCase();
1973
+ const refreshHandling = getNamedFieldText(body, "Refresh Handling") ?? "";
1974
+ const repairPerformed = getNamedFieldText(body, "Repair Performed After Verification") ?? "";
1975
+ const verificationPaths = new Set([...extractPathsFromFieldValue(mainAgentVerification)].map(normalizeRepoPath).filter(Boolean));
1976
+ const repairPaths = new Set([...extractPathsFromFieldValue(repairPerformed)].map(normalizeRepoPath).filter(Boolean));
1977
+ const currentBundlePath = normalizeRepoPath(getMdFieldValue$1(getHeadingBody(content, "Review Metadata"), "Review Bundle Path") ?? getMdFieldValue$1(content, "Review Bundle Path") ?? "");
1978
+ if (auditMode === "subagent" && actionRecordPaths.length === 0) issues.push("Audit Execution Mode subagent requires at least one reviewed subagent action record");
1979
+ const outOfRunRecords = allActionRecordPaths.filter((p) => !actionRecordPaths.includes(p)).sort();
1980
+ if (outOfRunRecords.length > 0) issues.push(`Subagent Contribution Verification may only reference action records under the current run subagents/: ${outOfRunRecords.slice(0, 5).join(", ")}`);
1981
+ if (actionRecordPaths.length > 0) {
1982
+ const reviewedRecordPaths = new Set([...extractPathsFromFieldValue(reviewedActionRecordsField)].map(normalizeRepoPath).filter((p) => p.startsWith(`.recursive/run/${basename(runDir)}/subagents/`)));
1983
+ if (!reviewedActionRecordsField.trim()) issues.push("Subagent Contribution Verification must record Reviewed Action Records");
1984
+ else {
1985
+ const missingReviewed = actionRecordPaths.filter((p) => !reviewedRecordPaths.has(p)).sort();
1986
+ if (missingReviewed.length > 0) issues.push(`Reviewed Action Records is missing referenced action record path(s): ${missingReviewed.slice(0, 5).join(", ")}`);
1987
+ }
1988
+ if (!hasMeaningfulValue(mainAgentVerification, /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push("Subagent Contribution Verification must record Main-Agent Verification Performed");
1989
+ else if (verificationPaths.size === 0) issues.push("Main-Agent Verification Performed must cite files, artifacts, or diff-owned paths that were checked");
1990
+ else {
1991
+ const missingVerification = findMissingRepoPaths(repoRoot, [...verificationPaths].sort());
1992
+ if (missingVerification.length > 0) issues.push(`Main-Agent Verification Performed references missing path(s): ${missingVerification.slice(0, 5).join(", ")}`);
1993
+ }
1994
+ if (![
1995
+ "accepted",
1996
+ "partially accepted",
1997
+ "rejected"
1998
+ ].includes(acceptanceDecision)) issues.push("Subagent Contribution Verification must record Acceptance Decision: accepted|partially accepted|rejected");
1999
+ if (!hasMeaningfulValue(refreshHandling, /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push("Subagent Contribution Verification must record Refresh Handling");
2000
+ if (!trimMdValue$1(repairPerformed)) issues.push("Subagent Contribution Verification must record Repair Performed After Verification");
2001
+ else if (repairPaths.size > 0) {
2002
+ const missingRepair = findMissingRepoPaths(repoRoot, [...repairPaths].sort());
2003
+ if (missingRepair.length > 0) issues.push(`Repair Performed After Verification references missing path(s): ${missingRepair.slice(0, 5).join(", ")}`);
2004
+ }
2005
+ }
2006
+ for (const actionRecordPath of actionRecordPaths) {
2007
+ if (!existsSync(join(repoRoot, actionRecordPath))) {
2008
+ issues.push(`Referenced subagent action record does not exist: ${actionRecordPath}`);
2009
+ continue;
2010
+ }
2011
+ const actionContent = readFileSync(join(repoRoot, actionRecordPath), "utf8");
2012
+ const actionPhase = getMdFieldValue$1(getHeadingBody(actionContent, "Metadata"), "Phase") ?? "";
2013
+ const actionClaims = parseSubagentActionRecordClaims(actionContent);
2014
+ if (currentPhase && actionPhase && currentPhase !== actionPhase) issues.push(`Subagent action record phase mismatch: ${actionRecordPath} -> ${actionPhase}`);
2015
+ if (currentBundlePath) {
2016
+ const actionBundlePath = String(actionClaims["review_bundle"]);
2017
+ if (actionBundlePath && actionBundlePath !== currentBundlePath) issues.push(`Subagent action record review bundle mismatch: ${actionRecordPath} -> ${actionBundlePath}`);
2018
+ }
2019
+ issues.push(...lintSubagentActionRecordFile(join(repoRoot, actionRecordPath), repoRoot, runDir, actualChangedFiles));
2020
+ if (["accepted", "partially accepted"].includes(acceptanceDecision)) {
2021
+ const claimedDiffScope = new Set([
2022
+ "created",
2023
+ "modified",
2024
+ "reviewed"
2025
+ ].flatMap((k) => [...actionClaims[k]]));
2026
+ let expectedVerifiedPaths = new Set(claimedDiffScope);
2027
+ if (actualChangedFiles !== null) expectedVerifiedPaths = new Set([...expectedVerifiedPaths].filter((p) => actualChangedFiles.includes(p)));
2028
+ const missingVerified = [...expectedVerifiedPaths].filter((p) => !verificationPaths.has(p) && !repairPaths.has(p)).sort();
2029
+ 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(", ")}`);
2030
+ const verificationArtifactScope = /* @__PURE__ */ new Set([
2031
+ String(actionClaims["current_artifact"]),
2032
+ ...actionClaims["upstream_artifacts"],
2033
+ ...actionClaims["artifact_refs"]
2034
+ ]);
2035
+ if (actionClaims["review_bundle"]) verificationArtifactScope.add(String(actionClaims["review_bundle"]));
2036
+ verificationArtifactScope.delete("");
2037
+ if (verificationArtifactScope.size > 0 && ![...verificationArtifactScope].some((p) => verificationPaths.has(p))) issues.push("Main-Agent Verification Performed must cite the reviewed artifact, bundle, or upstream recursive artifacts used to verify the delegated work");
2038
+ }
2039
+ }
2040
+ return [...new Set(issues)].sort();
2041
+ }
2042
+ /** collect_reviewed_paths: paths from Worktree Diff Audit + related addenda. */
2043
+ function collectReviewedPaths(runDir, artifactName, content) {
2044
+ const reviewed = extractPathsFromText(getHeadingBody(content, "Worktree Diff Audit"));
2045
+ for (const addendumPath of getRelatedAddendaPaths(runDir, artifactName)) {
2046
+ const full = join(runDir, "addenda", addendumPath);
2047
+ if (existsSync(full)) for (const p of extractPathsFromText(readFileSync(full, "utf8"))) reviewed.add(p);
2048
+ }
2049
+ return reviewed;
2050
+ }
2051
+ /** lint_review_bundle_reference: 03.5 review-bundle validation. */
2052
+ function lintReviewBundleReference(content, runDir, repoRoot) {
2053
+ const issues = [];
2054
+ const bundlePath = (getMdFieldValue$1(getHeadingBody(content, "Review Metadata"), "Review Bundle Path") ?? getMdFieldValue$1(content, "Review Bundle Path") ?? "").trim();
2055
+ const expectedPrefix = `.recursive/run/${basename(runDir)}/evidence/review-bundles/`;
2056
+ if (!bundlePath) {
2057
+ issues.push("Review Metadata is missing Review Bundle Path");
2058
+ return issues;
2059
+ }
2060
+ const normalizedBundlePath = normalizeRepoPath(bundlePath);
2061
+ if (!normalizedBundlePath.startsWith(expectedPrefix)) {
2062
+ issues.push(`Review Bundle Path must live under \`/${expectedPrefix}\``);
2063
+ return issues;
2064
+ }
2065
+ if (!existsSync(join(repoRoot, normalizedBundlePath))) {
2066
+ issues.push(`Review Bundle Path does not exist: ${normalizedBundlePath}`);
2067
+ return issues;
2068
+ }
2069
+ const bundleContent = readFileSync(join(repoRoot, normalizedBundlePath), "utf8");
2070
+ const artifactPath = normalizeRepoPath(getMdFieldValue$1(bundleContent, "Artifact Path") ?? "");
2071
+ const artifactHash = trimMdValue$1(getMdFieldValue$1(bundleContent, "Artifact Content Hash") ?? "");
2072
+ if (!artifactPath) issues.push("Review bundle is missing Artifact Path");
2073
+ else if (!existsSync(join(repoRoot, artifactPath))) issues.push(`Review bundle Artifact Path does not exist: ${artifactPath}`);
2074
+ if (artifactPath) {
2075
+ const currentHash = existsSync(join(repoRoot, artifactPath)) ? contentSha256$1(readFileSync(join(repoRoot, artifactPath), "utf8")) : null;
2076
+ if (artifactHash && currentHash && artifactHash !== currentHash) issues.push("Review bundle is stale: Artifact Content Hash no longer matches the current artifact");
2077
+ }
2078
+ if (!artifactHash) issues.push("Review bundle is missing Artifact Content Hash");
2079
+ const missingHeadings = [];
2080
+ for (const heading of [
2081
+ "Diff Basis",
2082
+ "Changed Files Reviewed",
2083
+ "Upstream Artifacts To Re-read",
2084
+ "Relevant Addenda",
2085
+ "Prior Recursive Evidence",
2086
+ "Targeted Code References",
2087
+ "Audit Questions",
2088
+ "Required Output"
2089
+ ]) if (!getHeadingBody(bundleContent, heading)) missingHeadings.push(heading);
2090
+ if (missingHeadings.length > 0) issues.push(`Review bundle is missing required section(s): ${missingHeadings.join(", ")}`);
2091
+ const reviewNarrative = [
2092
+ getHeadingBody(content, "Review Scope"),
2093
+ getHeadingBody(content, "Requirement And Plan Reconciliation"),
2094
+ getHeadingBody(content, "Plan Alignment Assessment"),
2095
+ getHeadingBody(content, "Code Quality Assessment"),
2096
+ getHeadingBody(content, "Issues Found"),
2097
+ getHeadingBody(content, "Verdict")
2098
+ ].join("\n");
2099
+ const citedPaths = new Set([...extractPathsFromText(content)].map(normalizeRepoPath));
2100
+ const citedReviewPaths = new Set([...extractPathsFromText(reviewNarrative)].map(normalizeRepoPath));
2101
+ const upstreamPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, "Upstream Artifacts To Re-read"))].map(normalizeRepoPath));
2102
+ const addendaPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, "Relevant Addenda"))].map(normalizeRepoPath));
2103
+ const priorPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, "Prior Recursive Evidence"))].map(normalizeRepoPath));
2104
+ const changedPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, "Changed Files Reviewed"))].map(normalizeRepoPath));
2105
+ const codeRefPaths = new Set([...extractPathsFromText(getHeadingBody(bundleContent, "Targeted Code References"))].map(normalizeRepoPath));
2106
+ if (isPlaceholderOnly(getHeadingBody(bundleContent, "Audit Questions"))) issues.push("Review bundle Audit Questions cannot be placeholder-only");
2107
+ const diffBasisBody = getHeadingBody(bundleContent, "Diff Basis");
2108
+ for (const fieldName of DIFF_BASIS_FIELDS) if (getMdFieldValue$1(diffBasisBody, fieldName) === null) issues.push(`Review bundle Diff Basis is missing ${fieldName}`);
2109
+ if (changedPaths.size === 0) issues.push("Review bundle Changed Files Reviewed cannot be empty");
2110
+ else {
2111
+ const missingChanged = findMissingRepoPaths(repoRoot, [...changedPaths].sort());
2112
+ if (missingChanged.length > 0) issues.push(`Review bundle changed file path(s) do not exist: ${missingChanged.slice(0, 5).join(", ")}`);
2113
+ }
2114
+ if (codeRefPaths.size === 0) issues.push("Review bundle Targeted Code References cannot be empty");
2115
+ else {
2116
+ const missingCode = findMissingRepoPaths(repoRoot, [...codeRefPaths].sort());
2117
+ if (missingCode.length > 0) issues.push(`Review bundle code ref path(s) do not exist: ${missingCode.slice(0, 5).join(", ")}`);
2118
+ else if (changedPaths.size > 0 && ![...codeRefPaths].some((p) => changedPaths.has(p))) issues.push("Review bundle Targeted Code References do not overlap the changed-file scope");
2119
+ }
2120
+ const missingBundleAddenda = [...new Set(getExpectedEffectiveInputAddendaPaths(runDir, "03.5-code-review.md"))].filter((p) => !addendaPaths.has(p)).sort();
2121
+ if (missingBundleAddenda.length > 0) issues.push(`Review bundle is missing effective-input addenda: ${missingBundleAddenda.slice(0, 5).join(", ")}`);
2122
+ 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");
2123
+ 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");
2124
+ 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");
2125
+ if ((changedPaths.size > 0 || codeRefPaths.size > 0) && ![.../* @__PURE__ */ 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");
2126
+ if (!citedPaths.has(normalizedBundlePath)) issues.push("Code review must cite the Review Bundle Path in its written review artifact");
2127
+ const verdictBody = getHeadingBody(content, "Verdict");
2128
+ if (!verdictBody || isPlaceholderOnly(verdictBody)) issues.push("Verdict section must contain a concrete review verdict grounded in the review bundle");
2129
+ return issues;
2130
+ }
2131
+ /** lint_phase8_skill_usage_capture: 08 Run-Local Skill Usage Capture rules. */
2132
+ function lintPhase8SkillUsageCapture(content) {
2133
+ const issues = [];
2134
+ const usageBody = getHeadingBody(content, "Run-Local Skill Usage Capture");
2135
+ if (!usageBody) return ["Missing or empty section: ## Run-Local Skill Usage Capture"];
2136
+ for (const fieldName of [
2137
+ "Skill Usage Relevance",
2138
+ "Available Skills",
2139
+ "Skills Sought",
2140
+ "Skills Attempted",
2141
+ "Skills Used",
2142
+ "Worked Well",
2143
+ "Issues Encountered",
2144
+ "Future Guidance",
2145
+ "Promotion Candidates"
2146
+ ]) if (getMdFieldValue$1(usageBody, fieldName) === null) issues.push(`Run-Local Skill Usage Capture is missing ${fieldName}`);
2147
+ const relevance = normalizeSkillUsageRelevance(getMdFieldValue$1(usageBody, "Skill Usage Relevance"));
2148
+ if (!SKILL_USAGE_RELEVANCE_STATUSES.has(relevance)) {
2149
+ issues.push("Run-Local Skill Usage Capture must declare Skill Usage Relevance: relevant|not-relevant");
2150
+ return issues;
2151
+ }
2152
+ if (["relevant", "yes"].includes(relevance)) {
2153
+ for (const fieldName of [
2154
+ "Available Skills",
2155
+ "Skills Attempted",
2156
+ "Skills Used",
2157
+ "Future Guidance"
2158
+ ]) if (!isMeaningfulRequirementField(getMdFieldValue$1(usageBody, fieldName))) issues.push(`Run-Local Skill Usage Capture must record ${fieldName} when skill usage is relevant`);
2159
+ const attempted = trimMdValue$1(getMdFieldValue$1(usageBody, "Skills Attempted") ?? "").toLowerCase();
2160
+ const used = trimMdValue$1(getMdFieldValue$1(usageBody, "Skills Used") ?? "").toLowerCase();
2161
+ 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");
2162
+ }
2163
+ const promotionBody = getHeadingBody(content, "Skill Memory Promotion Review");
2164
+ if (!promotionBody) {
2165
+ issues.push("Missing or empty section: ## Skill Memory Promotion Review");
2166
+ return issues;
2167
+ }
2168
+ for (const fieldName of [
2169
+ "Durable Skill Lessons Promoted",
2170
+ "Generalized Guidance Updated",
2171
+ "Run-Local Observations Left Unpromoted",
2172
+ "Promotion Decision Rationale"
2173
+ ]) if (getMdFieldValue$1(promotionBody, fieldName) === null) issues.push(`Skill Memory Promotion Review is missing ${fieldName}`);
2174
+ if (["relevant", "yes"].includes(relevance) && !isMeaningfulRequirementField(getMdFieldValue$1(promotionBody, "Promotion Decision Rationale"))) issues.push("Skill Memory Promotion Review must explain why relevant run-local observations were or were not promoted");
2175
+ return issues;
2176
+ }
2177
+ /** lint_phase_specific_rules: per-phase rules dispatched from the artifact file. */
2178
+ function lintPhaseSpecificRules(filePath, content, workflowProfile, runDir, repoRoot, requirementIds, actualChangedFiles) {
2179
+ const issues = [];
2180
+ const fileName = basename(filePath);
2181
+ issues.push(...lintEffectiveInputAddenda(filePath, content, workflowProfile, runDir));
2182
+ issues.push(...lintSourceRequirementInventory(filePath, content, workflowProfile, runDir));
2183
+ if (fileName === "02-to-be-plan.md") {
2184
+ issues.push(...lintRequirementMapping(content, workflowProfile, runDir, repoRoot));
2185
+ issues.push(...lintPlanDriftCheck(content, workflowProfile));
2186
+ }
2187
+ issues.push(...lintRequirementCompletionStatus(filePath, content, requirementIds, runDir, workflowProfile, actualChangedFiles));
2188
+ issues.push(...lintPriorRecursiveEvidence(content, runDir, workflowProfile, repoRoot, fileName));
2189
+ issues.push(...lintSubagentContributionVerification(filePath, content, workflowProfile, runDir, repoRoot, actualChangedFiles));
2190
+ if (!STRICT_WORKFLOW_PROFILES$1.has(workflowProfile)) return issues;
2191
+ if (fileName === "00-worktree.md") {
2192
+ const [diffBasis, diffBasisError] = normalizeDiffBasis(repoRoot, getRunDiffBasis(runDir));
2193
+ if (diffBasisError) issues.push(`Phase 0 diff basis is not executable: ${diffBasisError}`);
2194
+ else if (diffBasis === null) issues.push("Phase 0 diff basis could not be normalized");
2195
+ }
2196
+ if (fileName === "03-implementation-summary.md") {
2197
+ const tddBody = getHeadingBody(content, "TDD Compliance Log");
2198
+ const tddMode = (getMdFieldValue$1(tddBody, "TDD Mode") ?? getMdFieldValue$1(content, "TDD Mode") ?? "").toLowerCase();
2199
+ if (!hasGateLine(content, "TDD Compliance")) issues.push("Missing required gate line: TDD Compliance: PASS|FAIL");
2200
+ if (!TDD_MODES.has(tddMode)) issues.push("TDD Compliance Log is missing TDD Mode: strict|pragmatic");
2201
+ else if (tddMode === "strict") {
2202
+ if (!tddBody.includes("RED Evidence:")) issues.push("Strict TDD is missing RED Evidence in ## TDD Compliance Log");
2203
+ if (!tddBody.includes("GREEN Evidence:")) issues.push("Strict TDD is missing GREEN Evidence in ## TDD Compliance Log");
2204
+ const redPrefix = `.recursive/run/${basename(runDir)}/evidence/logs/red/`;
2205
+ const greenPrefix = `.recursive/run/${basename(runDir)}/evidence/logs/green/`;
2206
+ const redPaths = collectPathsUnderPrefix(tddBody, redPrefix);
2207
+ const greenPaths = collectPathsUnderPrefix(tddBody, greenPrefix);
2208
+ if (redPaths.length === 0) issues.push(`Strict TDD requires at least one RED evidence path under \`/${redPrefix}\``);
2209
+ else {
2210
+ const missingRed = findMissingRepoPaths(repoRoot, redPaths);
2211
+ if (missingRed.length > 0) issues.push(`Strict TDD RED evidence path(s) do not exist: ${missingRed.slice(0, 5).join(", ")}`);
2212
+ }
2213
+ if (greenPaths.length === 0) issues.push(`Strict TDD requires at least one GREEN evidence path under \`/${greenPrefix}\``);
2214
+ else {
2215
+ const missingGreen = findMissingRepoPaths(repoRoot, greenPaths);
2216
+ if (missingGreen.length > 0) issues.push(`Strict TDD GREEN evidence path(s) do not exist: ${missingGreen.slice(0, 5).join(", ")}`);
2217
+ }
2218
+ } else {
2219
+ const exceptionBody = getHeadingBody(content, "Pragmatic TDD Exception");
2220
+ if (!exceptionBody) issues.push("TDD Mode pragmatic requires ## Pragmatic TDD Exception");
2221
+ else {
2222
+ if (!hasMeaningfulValue(getMdFieldValue$1(exceptionBody, "Exception reason"), /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push("Pragmatic TDD Exception is missing Exception reason");
2223
+ if (!hasMeaningfulValue(getMdFieldValue$1(exceptionBody, "Compensating validation"), /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push("Pragmatic TDD Exception is missing Compensating validation");
2224
+ const pragmaticPaths = collectPathsUnderPrefix(exceptionBody, `.recursive/run/${basename(runDir)}/evidence/`);
2225
+ if (pragmaticPaths.length === 0) issues.push(`Pragmatic TDD Exception requires compensating evidence paths under \`/.recursive/run/${basename(runDir)}/evidence/\``);
2226
+ else {
2227
+ const missingPragmatic = findMissingRepoPaths(repoRoot, pragmaticPaths);
2228
+ if (missingPragmatic.length > 0) issues.push(`Pragmatic TDD compensating evidence path(s) do not exist: ${missingPragmatic.slice(0, 5).join(", ")}`);
2229
+ }
2230
+ }
2231
+ }
2232
+ }
2233
+ if (fileName === "05-manual-qa.md") {
2234
+ const qaRecord = getHeadingBody(content, "QA Execution Record");
2235
+ const evidenceBody = getHeadingBody(content, "Evidence and Artifacts");
2236
+ const signoffBody = getHeadingBody(content, "User Sign-Off");
2237
+ const qaMode = (getMdFieldValue$1(qaRecord, "QA Execution Mode") ?? getMdFieldValue$1(content, "QA Execution Mode") ?? "").toLowerCase();
2238
+ if (!qaRecord) issues.push("Missing or empty section: ## QA Execution Record");
2239
+ if (!QA_EXECUTION_MODES.has(qaMode)) issues.push("QA Execution Record is missing QA Execution Mode: human|agent-operated|hybrid");
2240
+ else {
2241
+ if (["human", "hybrid"].includes(qaMode)) {
2242
+ if (!hasMeaningfulValue(getMdFieldValue$1(signoffBody, "Approved by"), /* @__PURE__ */ new Set([
2243
+ "n/a",
2244
+ "not required",
2245
+ "none"
2246
+ ]))) issues.push(`QA Execution Mode ${qaMode} requires User Sign-Off -> Approved by`);
2247
+ if (!hasMeaningfulValue(getMdFieldValue$1(signoffBody, "Date"), /* @__PURE__ */ new Set([
2248
+ "n/a",
2249
+ "not required",
2250
+ "none"
2251
+ ]))) issues.push(`QA Execution Mode ${qaMode} requires User Sign-Off -> Date`);
2252
+ }
2253
+ if (["agent-operated", "hybrid"].includes(qaMode)) {
2254
+ if (!hasMeaningfulValue(getMdFieldValue$1(qaRecord, "Agent Executor"), /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push(`QA Execution Mode ${qaMode} requires QA Execution Record -> Agent Executor`);
2255
+ if (!hasMeaningfulValue(getMdFieldValue$1(qaRecord, "Tools Used"), /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push(`QA Execution Mode ${qaMode} requires QA Execution Record -> Tools Used`);
2256
+ const qaPaths = collectPathsUnderPrefix(`${qaRecord}\n${evidenceBody}`, `.recursive/run/${basename(runDir)}/evidence/`);
2257
+ if (qaPaths.length === 0) issues.push(`QA Execution Mode ${qaMode} requires evidence paths under \`/.recursive/run/${basename(runDir)}/evidence/\``);
2258
+ else {
2259
+ const missingQa = findMissingRepoPaths(repoRoot, qaPaths);
2260
+ if (missingQa.length > 0) issues.push(`QA evidence path(s) do not exist: ${missingQa.slice(0, 5).join(", ")}`);
2261
+ }
2262
+ }
2263
+ }
2264
+ }
2265
+ if (fileName === "03.5-code-review.md") issues.push(...lintReviewBundleReference(content, runDir, repoRoot));
2266
+ if (fileName === "08-memory-impact.md") issues.push(...lintPhase8SkillUsageCapture(content));
2267
+ return [...new Set(issues)].sort();
2268
+ }
2269
+ /** get_header_remediation_lines: copy/paste lines for missing header fields. */
2270
+ function getHeaderRemediationLines(missingFields) {
2271
+ const out = [];
2272
+ for (const field of missingFields) if (field === "Run") out.push("Run: `/.recursive/run/<run-id>/`");
2273
+ else if (field === "Phase") out.push("Phase: `<phase name>`");
2274
+ else if (field === "Status") out.push("Status: `DRAFT`");
2275
+ else if (field === "Inputs") {
2276
+ out.push("Inputs:");
2277
+ out.push("- `<path>`");
2278
+ } else if (field === "Outputs") {
2279
+ out.push("Outputs:");
2280
+ out.push("- `<path>`");
2281
+ } else if (field === "Scope note") out.push("Scope note: <one sentence describing what this artifact decides/enables>.");
2282
+ else if (field === "LockedAt") out.push("LockedAt: `YYYY-MM-DDTHH:mm:ssZ`");
2283
+ else if (field === "LockHash") out.push("LockHash: `<sha256-hex>`");
2284
+ return out;
2285
+ }
2286
+ /** lint_traceability: Traceability section coverage of requirement IDs. */
2287
+ function lintTraceability(filePath, content, requirementIds) {
2288
+ const issues = [];
2289
+ if (!TRACEABILITY_REQUIRED_FILES.has(basename(filePath))) return issues;
2290
+ const traceabilityBody = getHeadingBody(content, "Traceability");
2291
+ if (!traceabilityBody) {
2292
+ issues.push("Missing Traceability section content");
2293
+ return issues;
2294
+ }
2295
+ const missingIds = requirementIds.filter((id) => !traceabilityBody.includes(id));
2296
+ if (missingIds.length > 0) issues.push(`Traceability is missing explicit coverage for: ${missingIds.join(", ")}`);
2297
+ if (requirementIds.length > 0 && !/\bR\d+\b/.test(traceabilityBody)) issues.push("Traceability is vague and does not mention any requirement IDs");
2298
+ return issues;
2299
+ }
2300
+ /** lint_audit_sections: audited-phase audit section rules. */
2301
+ function lintAuditSections(filePath, content, workflowProfile, actualChangedFiles, diffBasisError, runId, runDir) {
2302
+ const issues = [];
2303
+ if (!STRICT_WORKFLOW_PROFILES$1.has(workflowProfile) || !AUDITED_PHASE_FILES$2.has(basename(filePath))) return issues;
2304
+ const auditStatus = getGateStatus$1(content, "Audit");
2305
+ const coverageStatus = getGateStatus$1(content, "Coverage");
2306
+ const approvalStatus = getGateStatus$1(content, "Approval");
2307
+ if (auditStatus === "MISSING") issues.push("Missing required audit verdict line: Audit: PASS|FAIL");
2308
+ if (coverageStatus === "PASS" && auditStatus !== "PASS") issues.push("Coverage: PASS is invalid without Audit: PASS");
2309
+ if (approvalStatus === "PASS" && auditStatus !== "PASS") issues.push("Approval: PASS is invalid without Audit: PASS");
2310
+ const auditContext = getHeadingBody(content, "Audit Context");
2311
+ if (!auditContext) issues.push("Audit Context section is empty");
2312
+ else {
2313
+ if (!["subagent", "self-audit"].includes(getMdFieldValue$1(auditContext, "Audit Execution Mode") ?? "")) issues.push("Audit Context is missing a valid Audit Execution Mode: subagent|self-audit");
2314
+ if (!["available", "unavailable"].includes(getMdFieldValue$1(auditContext, "Subagent Availability") ?? "")) issues.push("Audit Context is missing a valid Subagent Availability: available|unavailable");
2315
+ if (!hasMeaningfulValue(getMdFieldValue$1(auditContext, "Subagent Capability Probe"), /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push("Audit Context is missing Subagent Capability Probe");
2316
+ if (!hasMeaningfulValue(getMdFieldValue$1(auditContext, "Delegation Decision Basis"), /* @__PURE__ */ new Set(["n/a", "none"]))) issues.push("Audit Context is missing Delegation Decision Basis");
2317
+ if (getMdFieldValue$1(auditContext, "Audit Inputs Provided") === null && !auditContext.includes("Audit Inputs Provided:")) issues.push("Audit Context is missing Audit Inputs Provided");
2318
+ issues.push(...collectSubagentDelegationIssues(auditContext));
2319
+ }
2320
+ for (const heading of AUDIT_REQUIRED_HEADINGS) {
2321
+ const sectionBody = getHeadingBody(content, heading);
2322
+ if (!sectionBody) issues.push(`Missing or empty audited-phase section: ## ${heading}`);
2323
+ else if (isPlaceholderOnly(sectionBody)) issues.push(`Audited-phase section still contains placeholder-only content: ## ${heading}`);
2324
+ }
2325
+ const diffAuditBody = getHeadingBody(content, "Worktree Diff Audit");
2326
+ for (const field of DIFF_BASIS_FIELDS) if (getMdFieldValue$1(diffAuditBody, field) === null) issues.push(`Worktree Diff Audit is missing: ${field}:`);
2327
+ const gapsBody = getHeadingBody(content, "Gaps Found");
2328
+ if (auditStatus === "PASS" && gapsBody && !/\bnone\b/i.test(gapsBody)) issues.push("Audit: PASS is invalid while Gaps Found still lists unresolved in-scope gaps");
2329
+ const expectedChangedFiles = getPhaseOwnedActualChangedFiles(basename(filePath), actualChangedFiles, dirname(dirname(dirname(runDir))));
2330
+ if (DIFF_AUDITED_FILES.has(basename(filePath)) && expectedChangedFiles !== null) {
2331
+ if (diffBasisError) issues.push(`Cannot verify git diff basis: ${diffBasisError}`);
2332
+ else {
2333
+ const reviewedPaths = collectReviewedPaths(runDir, basename(filePath), content);
2334
+ const missingPaths = expectedChangedFiles.filter((p) => !reviewedPaths.has(p));
2335
+ if (missingPaths.length > 0) {
2336
+ const preview = missingPaths.slice(0, 5).join(", ");
2337
+ const suffix = missingPaths.length > 5 ? " ..." : "";
2338
+ issues.push(`Worktree Diff Audit does not account for actual changed files from git diff: ${preview}${suffix}`);
2339
+ }
2340
+ }
2341
+ }
2342
+ return issues;
2343
+ }
2344
+ /** lint_artifact_file: full per-artifact lint (canonical lint_artifact_file parity). */
2345
+ function lintArtifactFile(filePath, runDir, repoRoot, workflowProfile, requirementIds, actualChangedFiles, diffBasisError) {
2346
+ const content = readFileSync(filePath, "utf8");
2347
+ const fileName = basename(filePath);
2348
+ const status = getMdFieldValue$1(content, "Status") ?? "UNKNOWN";
2349
+ const missingHeaderFields = [
2350
+ "Run",
2351
+ "Phase",
2352
+ "Status",
2353
+ "Inputs",
2354
+ "Outputs",
2355
+ "Scope note"
2356
+ ].filter((f) => !hasHeaderField(content, f));
2357
+ if (missingHeaderFields.length > 0) {
2358
+ writeIssue("FAIL", filePath, `Missing required header field(s): ${missingHeaderFields.join(", ")}`, getHeaderRemediationLines(missingHeaderFields));
2359
+ return [1, 0];
2360
+ }
2361
+ let failCount = 0;
2362
+ let warnCount = 0;
2363
+ if (!["DRAFT", "LOCKED"].includes(status)) {
2364
+ failCount += 1;
2365
+ writeIssue("FAIL", filePath, `Invalid Status value '${status}' (expected DRAFT or LOCKED)`, ["Status: `DRAFT`"]);
2366
+ }
2367
+ if (status === "LOCKED") {
2368
+ const lockMissing = ["LockedAt", "LockHash"].filter((f) => !hasHeaderField(content, f));
2369
+ if (lockMissing.length > 0) {
2370
+ failCount += 1;
2371
+ writeIssue("FAIL", filePath, `Status is LOCKED but missing: ${lockMissing.join(", ")}`, getHeaderRemediationLines(lockMissing));
2372
+ }
2373
+ }
2374
+ const [hasTodo, , , unchecked] = getTodoStats$1(content);
2375
+ if (!hasTodo) {
2376
+ failCount += 1;
2377
+ writeIssue("FAIL", filePath, "Missing required section: ## TODO", [
2378
+ "## TODO",
2379
+ "",
2380
+ "- [ ] <task 1>",
2381
+ "- [ ] <task 2>"
2382
+ ]);
2383
+ } else if (status === "LOCKED" && unchecked > 0) {
2384
+ failCount += 1;
2385
+ 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"]);
2386
+ }
2387
+ for (const heading of getArtifactRequiredSections(fileName, workflowProfile)) if (!hasHeading(content, heading)) {
2388
+ failCount += 1;
2389
+ writeIssue("FAIL", filePath, `Missing required section heading: ## ${heading}`, [
2390
+ `## ${heading}`,
2391
+ "",
2392
+ "<content>"
2393
+ ]);
2394
+ }
2395
+ for (const gate of ["Coverage", "Approval"]) if (!hasGateLine(content, gate)) {
2396
+ failCount += 1;
2397
+ writeIssue("FAIL", filePath, `Missing required gate line: ${gate}: PASS|FAIL`, [`${gate}: FAIL`]);
2398
+ }
2399
+ for (const issue of lintTraceability(filePath, content, requirementIds)) {
2400
+ failCount += 1;
2401
+ writeIssue("FAIL", filePath, issue);
2402
+ }
2403
+ for (const issue of lintAuditSections(filePath, content, workflowProfile, actualChangedFiles, diffBasisError, basename(runDir), runDir)) {
2404
+ failCount += 1;
2405
+ writeIssue("FAIL", filePath, issue);
2406
+ }
2407
+ for (const issue of lintPhaseSpecificRules(filePath, content, workflowProfile, runDir, repoRoot, requirementIds, actualChangedFiles)) {
2408
+ failCount += 1;
2409
+ writeIssue("FAIL", filePath, issue);
2410
+ }
2411
+ if (["04-test-summary.md", "05-manual-qa.md"].includes(fileName)) {
2412
+ const evidenceDir = join(runDir, "evidence");
2413
+ const requiredSubdirs = [
2414
+ "screenshots",
2415
+ "logs",
2416
+ "perf",
2417
+ "traces"
2418
+ ];
2419
+ if (!existsSync(evidenceDir)) {
2420
+ warnCount += 1;
2421
+ writeIssue("WARN", filePath, `Evidence directory missing at ${evidenceDir}`, [
2422
+ `mkdir -p "${join(evidenceDir, "screenshots")}"`,
2423
+ `mkdir -p "${join(evidenceDir, "logs")}"`,
2424
+ `mkdir -p "${join(evidenceDir, "perf")}"`,
2425
+ `mkdir -p "${join(evidenceDir, "traces")}"`,
2426
+ `mkdir -p "${join(evidenceDir, "other")}"`
2427
+ ]);
2428
+ } else {
2429
+ const missingSubdirs = requiredSubdirs.filter((s) => !existsSync(join(evidenceDir, s)));
2430
+ if (missingSubdirs.length > 0) {
2431
+ warnCount += 1;
2432
+ writeIssue("WARN", filePath, `Evidence subfolder(s) missing under ${evidenceDir}: ${missingSubdirs.join(", ")}`, missingSubdirs.map((s) => `mkdir -p "${join(evidenceDir, s)}"`));
2433
+ }
2434
+ }
2435
+ }
2436
+ return [failCount, warnCount];
2437
+ }
2438
+ /** is_lock_valid_for_lint: canonical lock-validity check (used for late-phase sequencing). */
2439
+ function isLockValidForLint(filePath, workflowProfile) {
2440
+ if (!existsSync(filePath)) return false;
2441
+ const content = readFileSync(filePath, "utf8");
2442
+ const status = getMdFieldValue$1(content, "Status") ?? "";
2443
+ const [hasTodo, , , unchecked] = getTodoStats$1(content);
2444
+ const auditOk = !(STRICT_WORKFLOW_PROFILES$1.has(workflowProfile) && AUDITED_PHASE_FILES$2.has(basename(filePath))) || getGateStatus$1(content, "Audit") === "PASS";
2445
+ const runDir = dirname(filePath);
2446
+ const repoRoot = dirname(dirname(dirname(runDir)));
2447
+ const requirementIds = existsSync(join(runDir, "00-requirements.md")) ? getRunRequirementIds(runDir, workflowProfile) : [];
2448
+ let actualChangedFiles = null;
2449
+ if (STRICT_WORKFLOW_PROFILES$1.has(workflowProfile)) {
2450
+ const [rawChanged] = getGitChangedFiles(repoRoot, getRunDiffBasis(runDir));
2451
+ if (rawChanged !== null) actualChangedFiles = filterRuntimeChangedFiles(rawChanged, basename(runDir));
2452
+ }
2453
+ const phaseSpecificIssues = lintPhaseSpecificRules(filePath, content, workflowProfile, runDir, repoRoot, requirementIds, actualChangedFiles);
2454
+ const tddGateOk = basename(filePath) !== "03-implementation-summary.md" || getGateStatus$1(content, "TDD Compliance") === "PASS";
2455
+ return status === "LOCKED" && hasHeaderField(content, "LockedAt") && hasHeaderField(content, "LockHash") && getGateStatus$1(content, "Coverage") === "PASS" && getGateStatus$1(content, "Approval") === "PASS" && auditOk && tddGateOk && hasTodo && unchecked === 0 && phaseSpecificIssues.length === 0;
2456
+ }
2457
+ /** lint_memory_doc: validate a memory shard's metadata frontmatter. */
2458
+ function lintMemoryDoc(filePath) {
2459
+ const content = readFileSync(filePath, "utf8");
2460
+ let failCount = 0;
2461
+ let warnCount = 0;
2462
+ const missingFields = MEMORY_REQUIRED_FIELDS.filter((f) => !hasHeaderField(content, f));
2463
+ if (missingFields.length > 0) {
2464
+ failCount += 1;
2465
+ writeIssue("FAIL", filePath, `Missing required memory metadata field(s): ${missingFields.join(", ")}`);
2466
+ return [failCount, warnCount];
2467
+ }
2468
+ const memoryType = (getMdFieldValue$1(content, "Type") ?? "").toLowerCase();
2469
+ if (!MEMORY_ALLOWED_TYPES.has(memoryType)) {
2470
+ failCount += 1;
2471
+ writeIssue("FAIL", filePath, `Invalid memory Type '${memoryType}' (expected one of: ${[...MEMORY_ALLOWED_TYPES].sort().join(", ")})`);
2472
+ }
2473
+ const memoryStatus = (getMdFieldValue$1(content, "Status") ?? "").toUpperCase();
2474
+ if (!MEMORY_ALLOWED_STATUSES.has(memoryStatus)) {
2475
+ failCount += 1;
2476
+ writeIssue("FAIL", filePath, `Invalid memory Status '${memoryStatus}' (expected one of: ${[...MEMORY_ALLOWED_STATUSES].sort().join(", ")})`);
2477
+ }
2478
+ if (hasHeaderField(content, "Parent") && !getMdFieldValue$1(content, "Parent")) {
2479
+ warnCount += 1;
2480
+ writeIssue("WARN", filePath, "Parent metadata field is present but empty");
2481
+ }
2482
+ if (hasHeaderField(content, "Superseded-By") && !getMdFieldValue$1(content, "Superseded-By")) {
2483
+ warnCount += 1;
2484
+ writeIssue("WARN", filePath, "Superseded-By metadata field is present but empty");
2485
+ }
2486
+ return [failCount, warnCount];
2487
+ }
2488
+ /** lint_memory_plane: validate the .recursive/memory/ plane. */
2489
+ function lintMemoryPlane(repoRoot) {
2490
+ let failCount = 0;
2491
+ let warnCount = 0;
2492
+ const memoryRoot = join(repoRoot, ".recursive", "memory");
2493
+ const routerPath = join(memoryRoot, "MEMORY.md");
2494
+ if (!existsSync(memoryRoot)) {
2495
+ writeIssue("FAIL", memoryRoot, "Memory plane directory is missing");
2496
+ return [1, 0];
2497
+ }
2498
+ if (!existsSync(routerPath)) {
2499
+ failCount += 1;
2500
+ writeIssue("FAIL", routerPath, "Memory router file is missing");
2501
+ }
2502
+ for (const subdir of [
2503
+ "domains",
2504
+ "patterns",
2505
+ "incidents",
2506
+ "episodes",
2507
+ "archive",
2508
+ "skills"
2509
+ ]) {
2510
+ const full = join(memoryRoot, subdir);
2511
+ if (!existsSync(full)) {
2512
+ warnCount += 1;
2513
+ writeIssue("WARN", full, "Memory subdirectory is missing");
2514
+ }
2515
+ }
2516
+ const skillRouterPath = join(memoryRoot, "skills", "SKILLS.md");
2517
+ if (!existsSync(skillRouterPath)) {
2518
+ warnCount += 1;
2519
+ writeIssue("WARN", skillRouterPath, "Skill memory router file is missing");
2520
+ } else for (const subdir of [
2521
+ "availability",
2522
+ "usage",
2523
+ "issues",
2524
+ "patterns"
2525
+ ]) {
2526
+ const full = join(memoryRoot, "skills", subdir);
2527
+ if (!existsSync(full)) {
2528
+ warnCount += 1;
2529
+ writeIssue("WARN", full, "Skill memory subdirectory is missing");
2530
+ }
2531
+ }
2532
+ const walk = (dir) => {
2533
+ const out = [];
2534
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
2535
+ const full = join(dir, e.name);
2536
+ if (e.isDirectory()) out.push(...walk(full));
2537
+ else if (e.name.endsWith(".md")) out.push(full);
2538
+ }
2539
+ return out;
2540
+ };
2541
+ let docs = [];
2542
+ try {
2543
+ docs = walk(memoryRoot).sort();
2544
+ } catch {
2545
+ docs = [];
2546
+ }
2547
+ for (const doc of docs) {
2548
+ if (SKILL_MEMORY_ROUTER_NAMES.has(basename(doc))) continue;
2549
+ const [docFail, docWarn] = lintMemoryDoc(doc);
2550
+ failCount += docFail;
2551
+ warnCount += docWarn;
2552
+ }
2553
+ return [failCount, warnCount];
2554
+ }
2555
+ /**
2556
+ * lintRun(repoRoot, runId): FULL canonical lint of a recursive run (in-process).
2557
+ * Mirrors lint-recursive-run.py main():
2558
+ * - resolve run dir (runId or latest), derive workflow profile + requirement ids
2559
+ * - validate the diff basis + collect actual changed files (git)
2560
+ * - lint each artifact in RUN_ARTIFACT_SEQUENCE order (WARN if missing),
2561
+ * then addenda, then subagent action records
2562
+ * - phase-sequence prerequisite checks + late-phase requirement checks
2563
+ * - lint the memory plane
2564
+ * - print Summary + return verdict
2565
+ */
2566
+ function lintRun(repoRoot, runId) {
2567
+ issues.length = 0;
2568
+ const root = repoRoot;
2569
+ const runRoot = join(root, ".recursive", "run");
2570
+ if (!existsSync(runRoot)) {
2571
+ const msg = `recursive run directory not found at: ${runRoot}\n Is this the project repo root? (Expected .recursive/run/)`;
2572
+ console.log(`[FAIL] ${msg}`);
2573
+ return {
2574
+ errors: [msg],
2575
+ warnings: [],
2576
+ failCount: 1,
2577
+ warnCount: 0,
2578
+ passed: false,
2579
+ summary: "Summary\n- FAIL: 1\n- WARN: 0"
2580
+ };
2581
+ }
2582
+ const runDirs = readdirSync(runRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
2583
+ const selectedRuns = runId ? [runId] : runDirs.length > 0 ? [runDirs[runDirs.length - 1]] : [];
2584
+ if (selectedRuns.length === 0) {
2585
+ const msg = "no recursive runs found under .recursive/run/";
2586
+ console.log(`[FAIL] ${msg}`);
2587
+ return {
2588
+ errors: [msg],
2589
+ warnings: [],
2590
+ failCount: 1,
2591
+ warnCount: 0,
2592
+ passed: false,
2593
+ summary: "Summary\n- FAIL: 1\n- WARN: 0"
2594
+ };
2595
+ }
2596
+ let totalFail = 0;
2597
+ let totalWarn = 0;
2598
+ for (const selectedRun of selectedRuns) {
2599
+ const runDir = join(runRoot, selectedRun);
2600
+ if (!existsSync(runDir)) {
2601
+ totalFail += 1;
2602
+ writeIssue("FAIL", runDir, `recursive run directory not found: ${runDir}`);
2603
+ continue;
2604
+ }
2605
+ console.log(`Linting run: ${selectedRun}`);
2606
+ console.log(`Path: ${runDir}`);
2607
+ const workflowProfile = getWorkflowProfile$1(runDir);
2608
+ console.log(`Workflow Profile: ${workflowProfile}`);
2609
+ console.log();
2610
+ const requirementIds = getRunRequirementIds(runDir, workflowProfile);
2611
+ let actualChangedFiles = null;
2612
+ let diffBasisError = null;
2613
+ if (STRICT_WORKFLOW_PROFILES$1.has(workflowProfile)) {
2614
+ const diffBasis = getRunDiffBasis(runDir);
2615
+ if (diffBasis.baseline_reference || diffBasis.normalized_baseline) {
2616
+ const [rawChanged, gitError] = getGitChangedFiles(root, diffBasis);
2617
+ if (gitError) {
2618
+ diffBasisError = gitError;
2619
+ totalFail += 1;
2620
+ writeIssue("FAIL", runDir, `Unable to verify git diff basis: ${gitError}`);
2621
+ } else if (rawChanged !== null) actualChangedFiles = filterRuntimeChangedFiles(rawChanged, selectedRun);
2622
+ }
2623
+ }
2624
+ const expectedArtifacts = RUN_ARTIFACT_SEQUENCE$1;
2625
+ for (const artifact of expectedArtifacts) {
2626
+ const artifactPath = join(runDir, artifact);
2627
+ if (!existsSync(artifactPath)) {
2628
+ if (getLockStatus(artifactPath) === "MISSING") {
2629
+ issues.push({
2630
+ severity: "WARN",
2631
+ filePath: "",
2632
+ message: `Missing artifact (ok if not reached yet): ${artifactPath}`
2633
+ });
2634
+ console.log(`[WARN] Missing artifact (ok if not reached yet): ${artifactPath}`);
2635
+ totalWarn += 1;
2636
+ continue;
2637
+ }
2638
+ totalFail += 1;
2639
+ writeIssue("FAIL", artifactPath, `Artifact exists but cannot be read: ${artifact}`);
2640
+ continue;
2641
+ }
2642
+ const [failCount, warnCount] = lintArtifactFile(artifactPath, runDir, root, workflowProfile, requirementIds, actualChangedFiles, diffBasisError);
2643
+ totalFail += failCount;
2644
+ totalWarn += warnCount;
2645
+ }
2646
+ const addendaDir = join(runDir, "addenda");
2647
+ if (existsSync(addendaDir)) {
2648
+ const addenda = readdirSync(addendaDir).filter((n) => n.endsWith(".md")).sort();
2649
+ for (const addendum of addenda) {
2650
+ const [failCount, warnCount] = lintArtifactFile(join(addendaDir, addendum), runDir, root, workflowProfile, requirementIds, actualChangedFiles, diffBasisError);
2651
+ totalFail += failCount;
2652
+ totalWarn += warnCount;
2653
+ }
2654
+ }
2655
+ const subagentsDir = join(runDir, "subagents");
2656
+ if (existsSync(subagentsDir)) {
2657
+ const actionRecords = readdirSync(subagentsDir).filter((n) => n.endsWith(".md")).sort();
2658
+ for (const actionRecord of actionRecords) {
2659
+ const issuesFound = lintSubagentActionRecordFile(join(subagentsDir, actionRecord), root, runDir, actualChangedFiles);
2660
+ for (const issue of issuesFound) {
2661
+ totalFail += 1;
2662
+ writeIssue("FAIL", join(subagentsDir, actionRecord), issue);
2663
+ }
2664
+ }
2665
+ }
2666
+ for (const artifact of expectedArtifacts) {
2667
+ const artifactPath = join(runDir, artifact);
2668
+ if (!existsSync(artifactPath)) continue;
2669
+ const blockers = getPrerequisiteBlockers(runDir, artifact);
2670
+ for (const blocker of blockers) {
2671
+ totalFail += 1;
2672
+ writeIssue("FAIL", artifactPath, `Phase-sequence violation: '${artifact}' exists but prerequisite '${blocker.artifact}' is ${blocker.status} (must be LOCKED first)`);
2673
+ }
2674
+ }
2675
+ if (STRICT_WORKFLOW_PROFILES$1.has(workflowProfile) || workflowProfile === "memory-phase8") {
2676
+ for (const [priorArtifact, nextArtifact] of [
2677
+ ["05-manual-qa.md", "06-decisions-update.md"],
2678
+ ["06-decisions-update.md", "07-state-update.md"],
2679
+ ["07-state-update.md", "08-memory-impact.md"]
2680
+ ]) if (isLockValidForLint(join(runDir, priorArtifact), workflowProfile) && !existsSync(join(runDir, nextArtifact))) {
2681
+ totalFail += 1;
2682
+ writeIssue("FAIL", join(runDir, nextArtifact), `Run profile ${workflowProfile} requires ${nextArtifact} after ${priorArtifact} locks`);
2683
+ }
2684
+ const [memoryFail, memoryWarn] = lintMemoryPlane(root);
2685
+ totalFail += memoryFail;
2686
+ totalWarn += memoryWarn;
2687
+ }
2688
+ console.log("----");
2689
+ console.log();
2690
+ }
2691
+ console.log("Summary");
2692
+ console.log(`- FAIL: ${totalFail}`);
2693
+ console.log(`- WARN: ${totalWarn}`);
2694
+ console.log();
2695
+ if (totalFail > 0) console.log("[FAIL] Lint failed");
2696
+ else console.log("[OK] Lint passed");
2697
+ const errors = [];
2698
+ const warnings = [];
2699
+ for (const issue of issues) if (issue.severity === "FAIL") errors.push(`[FAIL] ${issue.filePath}: ${issue.message}`);
2700
+ else if (issue.filePath === "") warnings.push(`[WARN] ${issue.message}`);
2701
+ else warnings.push(`[WARN] ${issue.filePath}: ${issue.message}`);
2702
+ const summary = `Summary\n- FAIL: ${totalFail}\n- WARN: ${totalWarn}`;
2703
+ return {
2704
+ errors,
2705
+ warnings,
2706
+ failCount: totalFail,
2707
+ warnCount: totalWarn,
2708
+ passed: totalFail === 0,
2709
+ summary
2710
+ };
2711
+ }
2712
+ //#endregion
2713
+ //#region src/init-templates.ts
2714
+ /**
2715
+ * Init templates (R5): canonical-parity port of recursive-init.py's
2716
+ * requirements_content / worktree_content / detect_git_context, plus full
2717
+ * per-phase templates derived from the canonical artifact-template sections
2718
+ * (get_artifact_required_sections) + TODO + Coverage/Approval FAIL gates.
2719
+ *
2720
+ * 00-requirements.md + 00-worktree.md are byte-identical to canonical
2721
+ * recursive-init.py output (LF, no BOM). Later phases are structural: every
2722
+ * required section heading + the two FAIL gates, so lint fails honestly until
2723
+ * the phase is actually worked.
2724
+ */
2725
+ /**
2726
+ * detect_git_context(): canonical parity. Returns {context, error} — never
2727
+ * throws on missing git; error is surfaced in the worktree template note.
2728
+ */
2729
+ function detectGitContext(repoRoot) {
2730
+ const run = (args) => {
2731
+ try {
2732
+ return execFileSync("git", args, {
2733
+ cwd: repoRoot,
2734
+ encoding: "utf8",
2735
+ stdio: [
2736
+ "ignore",
2737
+ "pipe",
2738
+ "ignore"
2739
+ ]
2740
+ }).trim();
2741
+ } catch {
2742
+ return null;
2743
+ }
2744
+ };
2745
+ const headSha = run([
2746
+ "rev-parse",
2747
+ "--verify",
2748
+ "HEAD^{commit}"
2749
+ ]);
2750
+ if (!headSha) return {
2751
+ context: {},
2752
+ error: "Unable to resolve HEAD commit for Phase 0 diff basis prefill: git rev-parse returned no output"
2753
+ };
2754
+ const branch = run([
2755
+ "symbolic-ref",
2756
+ "--quiet",
2757
+ "--short",
2758
+ "HEAD"
2759
+ ]) || "(detached HEAD)";
2760
+ return {
2761
+ context: {
2762
+ baselineType: "local commit",
2763
+ baselineReference: headSha,
2764
+ comparisonReference: "working-tree",
2765
+ normalizedBaseline: headSha,
2766
+ normalizedComparison: "working-tree",
2767
+ normalizedDiffCommand: "git diff --name-only " + headSha,
2768
+ baseBranch: branch,
2769
+ worktreeBranch: branch,
2770
+ baseCommit: headSha,
2771
+ notes: "recursive-init prefilled this executable diff basis from the current HEAD commit. If Phase 0 later changes the chosen baseline, update every diff-basis field and rerun lint before locking."
2772
+ },
2773
+ error: null
2774
+ };
2775
+ }
2776
+ /**
2777
+ * requirements_content(run_id, template, from_issue): byte-identical to the
2778
+ * canonical recursive-init.py function (LF, template flavor in Scope note).
2779
+ */
2780
+ function requirementsContent(runId, template = "feature", fromIssue = "") {
2781
+ const inputs = ["- [chat summary or source notes if captured in repo]"];
2782
+ if (fromIssue.trim()) inputs.push("- Source: " + fromIssue.trim());
2783
+ const inputsBlock = inputs.join("\n");
2784
+ return [
2785
+ "Run: `/.recursive/run/" + runId + "/`",
2786
+ "Phase: `00 Requirements`",
2787
+ "Status: `DRAFT`",
2788
+ "Workflow version: `recursive-mode-audit-v2`",
2789
+ "Inputs:",
2790
+ inputsBlock,
2791
+ "Outputs:",
2792
+ "- `/.recursive/run/" + runId + "/00-requirements.md`",
2793
+ "Scope note: This document defines stable requirement identifiers and acceptance criteria. (Template: " + template + ")",
2794
+ "",
2795
+ "## TODO",
2796
+ "",
2797
+ "- [ ] Elicit requirements from user/context",
2798
+ "- [ ] Define requirement identifiers (R1, R2, ...)",
2799
+ "- [ ] Write acceptance criteria for each requirement",
2800
+ "- [ ] Document out of scope items (OOS1, OOS2, ...)",
2801
+ "- [ ] List constraints and assumptions",
2802
+ "- [ ] Complete Coverage Gate checklist",
2803
+ "- [ ] Complete Approval Gate checklist",
2804
+ "",
2805
+ "## Requirements",
2806
+ "",
2807
+ "### `R1` <short title>",
2808
+ "",
2809
+ "Description:",
2810
+ "Acceptance criteria:",
2811
+ "- [observable condition 1]",
2812
+ "- [observable condition 2]",
2813
+ "",
2814
+ "## Out of Scope",
2815
+ "",
2816
+ "- `OOS1`: ...",
2817
+ "",
2818
+ "## Constraints",
2819
+ "",
2820
+ "- ...",
2821
+ "",
2822
+ "## Coverage Gate",
2823
+ "...",
2824
+ "Coverage: FAIL",
2825
+ "",
2826
+ "## Approval Gate",
2827
+ "...",
2828
+ "Approval: FAIL"
2829
+ ].join("\n") + "\n";
2830
+ }
2831
+ /**
2832
+ * worktree_content(run_id, repo_root, git_context, prefill_error): byte-identical
2833
+ * to the canonical recursive-init.py function.
2834
+ */
2835
+ function worktreeContent(runId, repoRoot, git, prefillError) {
2836
+ const baseBranch = git.baseBranch ?? "(resolve during Phase 0)";
2837
+ const worktreeBranch = git.worktreeBranch ?? "(resolve during Phase 0)";
2838
+ const baseCommit = git.baseCommit ?? "<resolve-before-locking>";
2839
+ const baselineType = git.baselineType ?? "local commit";
2840
+ const baselineReference = git.baselineReference ?? "<resolve-before-locking>";
2841
+ const comparisonReference = git.comparisonReference ?? "working-tree";
2842
+ const normalizedBaseline = git.normalizedBaseline ?? "<resolve-before-locking>";
2843
+ const normalizedComparison = git.normalizedComparison ?? "working-tree";
2844
+ const normalizedDiffCommand = git.normalizedDiffCommand ?? "git diff --name-only <resolve-before-locking>";
2845
+ const notes = git.notes ?? "Populate an executable diff basis before locking Phase 0. Lint and lock will fail until the normalized basis matches live git state.";
2846
+ const setupNote = prefillError ? "recursive-init could not prefill the Phase 0 diff basis automatically: " + prefillError : "recursive-init detected the current repository context and prefilled the Phase 0 diff basis.";
2847
+ return [
2848
+ "Run: `/.recursive/run/" + runId + "/`",
2849
+ "Phase: `00 Worktree`",
2850
+ "Status: `DRAFT`",
2851
+ "Inputs:",
2852
+ "- `/.recursive/run/" + runId + "/00-requirements.md`",
2853
+ "- Current git repository state",
2854
+ "Outputs:",
2855
+ "- `/.recursive/run/" + runId + "/00-worktree.md`",
2856
+ "Scope note: This document records the Phase 0 worktree context and the executable diff basis that all later audited phases must reuse.",
2857
+ "",
2858
+ "## TODO",
2859
+ "",
2860
+ "- [ ] Confirm the selected worktree location and isolation approach",
2861
+ "- [ ] Confirm the base branch and worktree branch values",
2862
+ "- [ ] Run setup and verify the clean test baseline",
2863
+ "- [ ] Confirm the diff basis fields still match live git state",
2864
+ "- [ ] Complete Coverage Gate checklist",
2865
+ "- [ ] Complete Approval Gate checklist",
2866
+ "",
2867
+ "## Directory Selection",
2868
+ "",
2869
+ "- Repository root: `" + repoRoot + "`",
2870
+ "- Preferred worktree location: `.worktrees/" + runId + "/`",
2871
+ "- Update this section with the actual selected location before locking Phase 0.",
2872
+ "",
2873
+ "## Safety Verification",
2874
+ "",
2875
+ "- Original branch / repo state observed at init time: `" + baseBranch + "`",
2876
+ "- Isolation still must be confirmed after the actual worktree is created.",
2877
+ "",
2878
+ "## Worktree Creation",
2879
+ "",
2880
+ "- Intended worktree branch: `" + worktreeBranch + "`",
2881
+ "- Record the actual worktree creation command and output before locking.",
2882
+ "",
2883
+ "## Main Branch Protection",
2884
+ "",
2885
+ "- Base branch source of truth at init time: `" + baseBranch + "`",
2886
+ "- Explicitly document any deviation from isolated worktree execution before locking.",
2887
+ "",
2888
+ "## Project Setup",
2889
+ "",
2890
+ "- Init-time note: " + setupNote,
2891
+ "- Replace this section with the actual setup commands and results during Phase 0.",
2892
+ "",
2893
+ "## Test Baseline Verification",
2894
+ "",
2895
+ "- Record the baseline commands and results after setup completes.",
2896
+ "",
2897
+ "## Worktree Context",
2898
+ "",
2899
+ "- Base branch: `" + baseBranch + "`",
2900
+ "- Worktree branch: `" + worktreeBranch + "`",
2901
+ "- Base commit: `" + baseCommit + "`",
2902
+ "",
2903
+ "## Diff Basis For Later Audits",
2904
+ "",
2905
+ "- Baseline type: `" + baselineType + "`",
2906
+ "- Baseline reference: `" + baselineReference + "`",
2907
+ "- Comparison reference: `" + comparisonReference + "`",
2908
+ "- Normalized baseline: `" + normalizedBaseline + "`",
2909
+ "- Normalized comparison: `" + normalizedComparison + "`",
2910
+ "- Normalized diff command: `" + normalizedDiffCommand + "`",
2911
+ "- Base branch: `" + baseBranch + "`",
2912
+ "- Worktree branch: `" + worktreeBranch + "`",
2913
+ "- Diff basis notes: `" + notes + "`",
2914
+ "",
2915
+ "## Traceability",
2916
+ "",
2917
+ "- Recursive workflow safety -> Phase 0 records a reusable executable diff basis before audited phases begin.",
2918
+ "",
2919
+ "## Coverage Gate",
2920
+ "",
2921
+ "- [ ] Worktree location and branch context are recorded",
2922
+ "- [ ] Setup and clean baseline verification are recorded",
2923
+ "- [ ] Diff basis fields are executable against live git state",
2924
+ "",
2925
+ "Coverage: FAIL",
2926
+ "",
2927
+ "## Approval Gate",
2928
+ "",
2929
+ "- [ ] Phase 0 context is ready for downstream audited phases",
2930
+ "- [ ] No unresolved setup or diff-basis inconsistencies remain",
2931
+ "",
2932
+ "Approval: FAIL"
2933
+ ].join("\n") + "\n";
2934
+ }
2935
+ /**
2936
+ * Later-phase template: required sections (canonical get_artifact_required_
2937
+ * sections) as ## headings + TODO + FAIL gates. Structural scaffold: the phase
2938
+ * author fills each section; lint stays FAIL until Coverage/Approval are set.
2939
+ */
2940
+ function laterPhaseContent(runId, fileName, workflowProfile = "recursive-mode-audit-v2") {
2941
+ const sections = getArtifactRequiredSections(fileName, workflowProfile);
2942
+ const lines = [
2943
+ "Run: `/.recursive/run/" + runId + "/`",
2944
+ "Phase: `" + fileName.replace(/\.md$/, "").replace(/^0?/, "") + "`",
2945
+ "Status: `DRAFT`",
2946
+ "Workflow version: `" + workflowProfile + "`",
2947
+ "Inputs:",
2948
+ "- (list upstream artifacts re-read for this phase)",
2949
+ "Outputs:",
2950
+ "- `/.recursive/run/" + runId + "/" + fileName + "`",
2951
+ "Scope note: Scaffold generated by the recursive-mode plugin (R5). Fill every required section before lint.",
2952
+ "",
2953
+ "## TODO",
2954
+ "",
2955
+ "- [ ] Work through every required section below",
2956
+ "- [ ] Record evidence and traceability",
2957
+ "- [ ] Complete Coverage Gate checklist",
2958
+ "- [ ] Complete Approval Gate checklist"
2959
+ ];
2960
+ for (const section of sections) {
2961
+ if (section === "TODO") continue;
2962
+ lines.push("", "## " + section, "", "- (fill)");
2963
+ }
2964
+ lines.push("", "## Coverage Gate", "", "Coverage: FAIL", "", "## Approval Gate", "", "Approval: FAIL", "");
2965
+ return lines.join("\n");
2966
+ }
2967
+ /**
2968
+ * Scaffold dirs under a run (addenda/subagents/router-prompts/evidence + subs).
2969
+ * Mirrors canonical recursive-init.py's ensure_directory sequence.
2970
+ */
2971
+ const RUN_SCAFFOLD_DIRS = [
2972
+ "addenda",
2973
+ "subagents",
2974
+ "router-prompts",
2975
+ "evidence",
2976
+ "evidence/screenshots",
2977
+ "evidence/logs",
2978
+ "evidence/perf",
2979
+ "evidence/traces",
2980
+ "evidence/review-bundles",
2981
+ "evidence/router",
2982
+ "evidence/other"
2983
+ ];
2984
+ //#endregion
2985
+ //#region src/run.ts
2986
+ /**
2987
+ * List run ids under <repoRoot>/.recursive/run/, excluding non-directories.
2988
+ * Mirrors: runs = [p for p in run_root.iterdir() if p.is_dir()]
2989
+ */
2990
+ function discoverRuns(repoRoot) {
2991
+ const runRoot = join(repoRoot, ".recursive", "run");
2992
+ let runs = [];
2993
+ try {
2994
+ runs = readdirSync(runRoot, { withFileTypes: true }).filter((d) => {
2995
+ if (d.isDirectory()) return true;
2996
+ if (!d.isSymbolicLink()) return false;
2997
+ try {
2998
+ return statSync(join(runRoot, d.name)).isDirectory();
2999
+ } catch {
3000
+ return false;
3001
+ }
3002
+ }).map((d) => d.name);
3003
+ } catch {
3004
+ return [];
3005
+ }
3006
+ return runs;
3007
+ }
3008
+ /**
3009
+ * Resolve the run directory for an explicit run id, or the latest run by mtime.
3010
+ * Mirrors get_latest_run_directory: sort by st_mtime descending, stable sort
3011
+ * (ties preserve discovery order — no name-based tie-break).
3012
+ */
3013
+ function resolveRunDir(repoRoot, runId) {
3014
+ const runRoot = join(repoRoot, ".recursive", "run");
3015
+ if (runId && runId.trim() !== "") return {
3016
+ runDir: join(runRoot, runId.trim()),
3017
+ runId: runId.trim()
3018
+ };
3019
+ const latest = getLatestRunDirectory(runRoot);
3020
+ if (!latest) return null;
3021
+ return {
3022
+ runDir: join(runRoot, latest),
3023
+ runId: latest
3024
+ };
3025
+ }
3026
+ /**
3027
+ * Latest run directory by mtime (descending), stable sort preserving
3028
+ * discovery order for exact ties. Returns null when no runs exist.
3029
+ */
3030
+ function getLatestRunDirectory(runRoot) {
3031
+ let runs = [];
3032
+ try {
3033
+ runs = readdirSync(runRoot, { withFileTypes: true }).filter((d) => {
3034
+ if (d.isDirectory()) return true;
3035
+ if (!d.isSymbolicLink()) return false;
3036
+ try {
3037
+ return statSync(join(runRoot, d.name)).isDirectory();
3038
+ } catch {
3039
+ return false;
3040
+ }
3041
+ }).map((d) => d.name);
3042
+ } catch {
3043
+ return null;
3044
+ }
3045
+ if (runs.length === 0) return null;
3046
+ runs.sort((a, b) => {
3047
+ try {
3048
+ return statSync(join(runRoot, b)).mtimeMs - statSync(join(runRoot, a)).mtimeMs;
3049
+ } catch {
3050
+ return 0;
3051
+ }
3052
+ });
3053
+ return runs[0];
3054
+ }
3055
+ //#endregion
3056
+ //#region src/status.ts
3057
+ const RUN_ARTIFACT_SEQUENCE = [
3058
+ "00-requirements.md",
3059
+ "00-worktree.md",
3060
+ "01-as-is.md",
3061
+ "01.5-root-cause.md",
3062
+ "02-to-be-plan.md",
3063
+ "03-implementation-summary.md",
3064
+ "03.5-code-review.md",
3065
+ "04-test-summary.md",
3066
+ "05-manual-qa.md",
3067
+ "06-decisions-update.md",
3068
+ "07-state-update.md",
3069
+ "08-memory-impact.md"
3070
+ ];
3071
+ const PHASES = [
3072
+ {
3073
+ key: "00R",
3074
+ label: "Phase 0 (Requirements)",
3075
+ file: "00-requirements.md",
3076
+ optional: false,
3077
+ phaseName: "0 (Requirements)"
3078
+ },
3079
+ {
3080
+ key: "00W",
3081
+ label: "Phase 0 (Worktree)",
3082
+ file: "00-worktree.md",
3083
+ optional: false,
3084
+ phaseName: "0 (Worktree)"
3085
+ },
3086
+ {
3087
+ key: "01",
3088
+ label: "Phase 1 (AS-IS)",
3089
+ file: "01-as-is.md",
3090
+ optional: false,
3091
+ phaseName: "1 (AS-IS)"
3092
+ },
3093
+ {
3094
+ key: "01.5",
3095
+ label: "Phase 1.5 (Root Cause)",
3096
+ file: "01.5-root-cause.md",
3097
+ optional: true,
3098
+ phaseName: "1.5 (Root Cause)"
3099
+ },
3100
+ {
3101
+ key: "02",
3102
+ label: "Phase 2 (TO-BE Plan)",
3103
+ file: "02-to-be-plan.md",
3104
+ optional: false,
3105
+ phaseName: "2 (TO-BE Plan)"
3106
+ },
3107
+ {
3108
+ key: "03",
3109
+ label: "Phase 3 (Implementation)",
3110
+ file: "03-implementation-summary.md",
3111
+ optional: false,
3112
+ phaseName: "3 (Implementation)"
3113
+ },
3114
+ {
3115
+ key: "03.5",
3116
+ label: "Phase 3.5 (Code Review)",
3117
+ file: "03.5-code-review.md",
3118
+ optional: true,
3119
+ phaseName: "3.5 (Code Review)"
3120
+ },
3121
+ {
3122
+ key: "04",
3123
+ label: "Phase 4 (Test Summary)",
3124
+ file: "04-test-summary.md",
3125
+ optional: false,
3126
+ phaseName: "4 (Test Summary)"
3127
+ },
3128
+ {
3129
+ key: "05",
3130
+ label: "Phase 5 (Manual QA)",
3131
+ file: "05-manual-qa.md",
3132
+ optional: false,
3133
+ phaseName: "5 (Manual QA)"
3134
+ },
3135
+ {
3136
+ key: "06",
3137
+ label: "Phase 6 (Decisions)",
3138
+ file: "06-decisions-update.md",
3139
+ optional: false,
3140
+ phaseName: "6 (Decisions Update)"
3141
+ },
3142
+ {
3143
+ key: "07",
3144
+ label: "Phase 7 (State)",
3145
+ file: "07-state-update.md",
3146
+ optional: false,
3147
+ phaseName: "7 (State Update)"
3148
+ },
3149
+ {
3150
+ key: "08",
3151
+ label: "Phase 8 (Memory)",
3152
+ file: "08-memory-impact.md",
3153
+ optional: false,
3154
+ phaseName: "8 (Memory Impact)"
3155
+ }
3156
+ ];
3157
+ const LOCK_HASH_LINE_RE = /^[ \t]*LockHash:.*(?:\n|$)/gm;
3158
+ const STRICT_WORKFLOW_PROFILES = /* @__PURE__ */ new Set(["recursive-mode-audit-v1", "recursive-mode-audit-v2"]);
3159
+ const AUDITED_PHASE_FILES$1 = /* @__PURE__ */ new Set([
3160
+ "01-as-is.md",
3161
+ "01.5-root-cause.md",
3162
+ "02-to-be-plan.md",
3163
+ "03-implementation-summary.md",
3164
+ "03.5-code-review.md",
3165
+ "04-test-summary.md",
3166
+ "06-decisions-update.md",
3167
+ "07-state-update.md",
3168
+ "08-memory-impact.md"
3169
+ ]);
3170
+ const CURRENT_WORKFLOW_PROFILE = "recursive-mode-audit-v2";
3171
+ const STRICT_WORKFLOW_PROFILE = "recursive-mode-audit-v1";
3172
+ const COMPAT_WORKFLOW_PROFILE = "memory-phase8";
3173
+ const LATE_PHASE_FILES = [
3174
+ "06-decisions-update.md",
3175
+ "07-state-update.md",
3176
+ "08-memory-impact.md"
3177
+ ];
3178
+ const LATE_PHASE_KEYS = /* @__PURE__ */ new Set([
3179
+ "06",
3180
+ "07",
3181
+ "08"
3182
+ ]);
3183
+ function escapeRegExp(value) {
3184
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3185
+ }
3186
+ function trimMdValue(value) {
3187
+ let trimmed = value.trim();
3188
+ for (const quote of [
3189
+ "`",
3190
+ "\"",
3191
+ "'"
3192
+ ]) if (trimmed.startsWith(quote) && trimmed.endsWith(quote) && trimmed.length >= 2) {
3193
+ const inner = trimmed.slice(1, -1);
3194
+ if (!inner.includes(quote)) return inner.trim();
3195
+ }
3196
+ return trimmed;
3197
+ }
3198
+ function getMdFieldValue(content, fieldName) {
3199
+ const match = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*(.+?)\\s*$`, "m").exec(content);
3200
+ if (!match) return null;
3201
+ return trimMdValue(match[1]);
3202
+ }
3203
+ function getGateStatus(content, gateName) {
3204
+ const match = new RegExp(`^[ \\t]*${escapeRegExp(gateName)}:\\s*(PASS|FAIL)\\s*$`, "m").exec(content);
3205
+ return match ? match[1].toUpperCase() : "MISSING";
3206
+ }
3207
+ function getTodoStats(content) {
3208
+ const lines = content.split(/\r?\n/);
3209
+ let inTodo = false;
3210
+ let hasTodo = false;
3211
+ let checked = 0;
3212
+ let unchecked = 0;
3213
+ let total = 0;
3214
+ for (const line of lines) {
3215
+ if (!inTodo) {
3216
+ if (/^\s*##\s+TODO\s*$/.test(line)) {
3217
+ inTodo = true;
3218
+ hasTodo = true;
3219
+ }
3220
+ continue;
3221
+ }
3222
+ if (/^\s*##\s+/.test(line) || /^\s*#\s+/.test(line)) break;
3223
+ const item = /^\s*[-*]\s+\[([ xX])\]\s+/.exec(line);
3224
+ if (item) {
3225
+ total += 1;
3226
+ if (item[1].toLowerCase() === "x") checked += 1;
3227
+ else unchecked += 1;
3228
+ }
3229
+ }
3230
+ return {
3231
+ hasTodo,
3232
+ total,
3233
+ checked,
3234
+ unchecked
3235
+ };
3236
+ }
3237
+ function lockHashFromContent$1(content) {
3238
+ const stripped = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(LOCK_HASH_LINE_RE, "");
3239
+ return createHash("sha256").update(stripped, "utf8").digest("hex");
3240
+ }
3241
+ function getWorkflowProfile(runDir) {
3242
+ const requirementsPath = join(runDir, "00-requirements.md");
3243
+ try {
3244
+ const workflowVersion = getMdFieldValue(readFileSync(requirementsPath, "utf8"), "Workflow version");
3245
+ if (workflowVersion === CURRENT_WORKFLOW_PROFILE) return CURRENT_WORKFLOW_PROFILE;
3246
+ if (workflowVersion === STRICT_WORKFLOW_PROFILE) return STRICT_WORKFLOW_PROFILE;
3247
+ if (workflowVersion === COMPAT_WORKFLOW_PROFILE) return COMPAT_WORKFLOW_PROFILE;
3248
+ } catch {}
3249
+ if (LATE_PHASE_FILES.some((file) => {
3250
+ try {
3251
+ statSync(join(runDir, file));
3252
+ return true;
3253
+ } catch {
3254
+ return false;
3255
+ }
3256
+ })) return COMPAT_WORKFLOW_PROFILE;
3257
+ return "legacy";
3258
+ }
3259
+ function getArtifactState(artifactPath, workflowProfile) {
3260
+ let content = null;
3261
+ try {
3262
+ content = readFileSync(artifactPath, "utf8");
3263
+ } catch {}
3264
+ if (content === null) return {
3265
+ exists: false,
3266
+ status: "PENDING",
3267
+ lockValid: false,
3268
+ lockProblems: ["File missing"],
3269
+ blockers: ["File missing"],
3270
+ lockedAt: null,
3271
+ storedHash: null,
3272
+ actualHash: null,
3273
+ coverage: "MISSING",
3274
+ approval: "MISSING",
3275
+ audit: "MISSING",
3276
+ todoHasSection: false,
3277
+ todoUnchecked: 0
3278
+ };
3279
+ const status = getMdFieldValue(content, "Status") ?? "UNKNOWN";
3280
+ const todo = getTodoStats(content);
3281
+ const coverage = getGateStatus(content, "Coverage");
3282
+ const approval = getGateStatus(content, "Approval");
3283
+ const audit = getGateStatus(content, "Audit");
3284
+ const tddCompliance = getGateStatus(content, "TDD Compliance");
3285
+ const lockedAt = getMdFieldValue(content, "LockedAt");
3286
+ const storedHash = getMdFieldValue(content, "LockHash");
3287
+ let actualHash = null;
3288
+ const lockProblems = [];
3289
+ const blockers = [];
3290
+ let lockValid = false;
3291
+ const fileName = artifactPath.split(/[\\/]/).pop() ?? "";
3292
+ if (status !== "LOCKED") lockProblems.push(`Status is '${status}' (expected LOCKED for lock-valid)`);
3293
+ else {
3294
+ if (!lockedAt) lockProblems.push("Missing LockedAt");
3295
+ if (!storedHash) lockProblems.push("Missing LockHash");
3296
+ if (storedHash) {
3297
+ actualHash = lockHashFromContent$1(content);
3298
+ if (storedHash.toLowerCase() !== actualHash.toLowerCase()) lockProblems.push("LockHash mismatch");
1144
3299
  }
3300
+ if (coverage !== "PASS") lockProblems.push(`Coverage gate is ${coverage}`);
3301
+ if (approval !== "PASS") lockProblems.push(`Approval gate is ${approval}`);
3302
+ if (STRICT_WORKFLOW_PROFILES.has(workflowProfile) && AUDITED_PHASE_FILES$1.has(fileName) && audit !== "PASS") lockProblems.push(`Audit gate is ${audit}`);
3303
+ if (fileName === "03-implementation-summary.md" && tddCompliance !== "PASS") lockProblems.push(`TDD Compliance gate is ${tddCompliance}`);
3304
+ if (!todo.hasTodo) lockProblems.push("Missing ## TODO section");
3305
+ else if (todo.unchecked > 0) lockProblems.push(`Unchecked TODO items: ${todo.unchecked}`);
3306
+ if (lockProblems.length === 0) lockValid = true;
1145
3307
  }
1146
- return stale;
3308
+ if (!todo.hasTodo) blockers.push("Missing ## TODO section");
3309
+ else if (todo.unchecked > 0) blockers.push(`Unchecked TODO items: ${todo.unchecked}`);
3310
+ if (coverage !== "PASS") blockers.push(`Coverage gate is ${coverage}`);
3311
+ if (approval !== "PASS") blockers.push(`Approval gate is ${approval}`);
3312
+ if (fileName === "03-implementation-summary.md" && tddCompliance !== "PASS") blockers.push(`TDD Compliance gate is ${tddCompliance}`);
3313
+ const dedupedBlockers = [];
3314
+ for (const blocker of blockers) if (!dedupedBlockers.includes(blocker)) dedupedBlockers.push(blocker);
3315
+ return {
3316
+ exists: true,
3317
+ status,
3318
+ lockValid,
3319
+ lockProblems,
3320
+ blockers: dedupedBlockers,
3321
+ lockedAt,
3322
+ storedHash,
3323
+ actualHash,
3324
+ coverage,
3325
+ approval,
3326
+ audit,
3327
+ todoHasSection: todo.hasTodo,
3328
+ todoUnchecked: todo.unchecked
3329
+ };
1147
3330
  }
1148
- /**
1149
- * Validate the full lock chain of a run: per-phase lock status, break phase,
1150
- * next legal phase, completion, and stale receipts.
1151
- */
1152
- function validateChain(runDir, runId) {
1153
- const phases = [];
1154
- let breakPhase = null;
1155
- let complete = true;
1156
- for (const phase of PHASE_SEQUENCE) {
1157
- const status = getLockStatus(join(runDir, phase));
1158
- const problems = [];
1159
- const lockValid = status === "LOCKED";
1160
- if (!lockValid && breakPhase === null) {
1161
- breakPhase = phase;
1162
- complete = false;
1163
- }
1164
- if (!lockValid && status !== "MISSING") {
1165
- if (status === "DRAFT") problems.push(`Status is not LOCKED (${status})`);
1166
- else if (status === "STALE_LOCK") problems.push("LockHash mismatch or missing lock fields");
3331
+ function foldRun(runDir, runId) {
3332
+ const workflowProfile = getWorkflowProfile(runDir);
3333
+ const states = /* @__PURE__ */ new Map();
3334
+ for (const phase of PHASES) {
3335
+ const state = getArtifactState(join(runDir, phase.file), workflowProfile);
3336
+ if (phase.optional && !state.exists) state.status = "SKIPPED";
3337
+ else if (workflowProfile === "legacy" && LATE_PHASE_KEYS.has(phase.key) && !state.exists) state.status = "SKIPPED";
3338
+ states.set(phase.key, state);
3339
+ }
3340
+ let currentPhase = null;
3341
+ for (const phase of PHASES) {
3342
+ const state = states.get(phase.key);
3343
+ if (state.status === "SKIPPED") continue;
3344
+ if (!state.exists || !state.lockValid) {
3345
+ currentPhase = {
3346
+ key: phase.key,
3347
+ label: phase.label,
3348
+ phaseName: phase.phaseName,
3349
+ status: state.status
3350
+ };
3351
+ break;
1167
3352
  }
1168
- phases.push({
1169
- file: phase,
1170
- status,
1171
- lockValid,
1172
- lockProblems: problems
1173
- });
1174
3353
  }
1175
- const nextLegalPhase = getNextLegalPhase(runDir);
1176
- const staleReceipts = getAllStaleReceipts(runDir);
3354
+ const phases = PHASES.map((phase) => {
3355
+ const state = states.get(phase.key);
3356
+ return {
3357
+ key: phase.key,
3358
+ label: phase.label,
3359
+ file: phase.file,
3360
+ optional: phase.optional,
3361
+ exists: state.exists,
3362
+ status: state.status,
3363
+ lockValid: state.lockValid,
3364
+ lockProblems: state.lockProblems,
3365
+ blockers: state.blockers
3366
+ };
3367
+ });
1177
3368
  return {
1178
3369
  runId,
3370
+ currentPhase,
1179
3371
  phases,
1180
- breakPhase,
1181
- nextLegalPhase,
1182
- complete,
1183
- staleReceipts
3372
+ workflowProfile
1184
3373
  };
1185
3374
  }
1186
- /** Python json.dumps(obj, sort_keys=True) compact serialization (receipt_hash input). */
1187
- function pythonJsonDumps(obj) {
1188
- const rec = obj;
1189
- return "{" + Object.keys(rec).sort().map((key) => {
1190
- const value = rec[key];
1191
- return `${jsonString(key)}: ${jsonValue(value)}`;
1192
- }).join(", ") + "}";
1193
- }
1194
- /** Python json.dumps(obj, indent=2, sort_keys=True) disk serialization. */
1195
- function pythonJsonDumpsIndent(obj) {
1196
- const rec = obj;
1197
- return "{\n" + Object.keys(rec).sort().map((key) => {
1198
- const value = rec[key];
1199
- return " " + jsonString(key) + ": " + jsonValueIndent(value, 2);
1200
- }).join(",\n") + "\n}";
1201
- }
1202
- function jsonString(s) {
1203
- return JSON.stringify(s);
1204
- }
1205
- function jsonValue(value) {
1206
- if (value === null) return "null";
1207
- if (typeof value === "string") return jsonString(value);
1208
- if (typeof value === "number" || typeof value === "boolean") return String(value);
1209
- if (Array.isArray(value)) return "[" + value.map(jsonValue).join(", ") + "]";
1210
- if (typeof value === "object") return pythonJsonDumps(value);
1211
- return "null";
1212
- }
1213
- function jsonValueIndent(value, depth) {
1214
- if (value === null) return "null";
1215
- if (typeof value === "string") return jsonString(value);
1216
- if (typeof value === "number" || typeof value === "boolean") return String(value);
1217
- if (Array.isArray(value)) return "[" + value.map((v) => jsonValueIndent(v, depth)).join(", ") + "]";
1218
- if (typeof value === "object") {
1219
- const obj = value;
1220
- const keys = Object.keys(obj).sort();
1221
- const pad = " ".repeat(depth);
1222
- return "{\n" + keys.map((key) => pad + " " + jsonString(key) + ": " + jsonValueIndent(obj[key], depth + 1)).join(",\n") + "\n" + pad + "}";
1223
- }
1224
- return "null";
1225
- }
1226
3375
  //#endregion
1227
3376
  //#region src/workspace.ts
1228
3377
  /**
@@ -2911,12 +5060,11 @@ var RecursiveRuntime = class extends Service {
2911
5060
  };
2912
5061
  }
2913
5062
  /**
2914
- * Lint an artifact with FULL canonical parity: shells out to the vendored
2915
- * lint-recursive-run.py (byte-identical to canonical) and parses its
2916
- * [FAIL]/[WARN] lines + Summary counts. `errors` = FAIL lines, `warnings` =
5063
+ * Lint an artifact with FULL canonical parity: runs the in-process ts-lint
5064
+ * port (ts-lint.ts `lintRun`) against the whole run and filters the verdict
5065
+ * lines to the requested artifact. `errors` = FAIL lines, `warnings` =
2917
5066
  * WARN lines, `passed` = no FAIL (WARN-only passes, matching the canonical
2918
- * non-strict verdict). Falls back to the structural check only if the vendored
2919
- * script is unavailable (e.g. bundled lib without references/).
5067
+ * non-strict verdict).
2920
5068
  */
2921
5069
  async lintArtifact(runId, artifact, agent) {
2922
5070
  const root = await this.resolveRootFor(agent);
@@ -2931,27 +5079,23 @@ var RecursiveRuntime = class extends Service {
2931
5079
  warnings: [],
2932
5080
  passed: false
2933
5081
  };
2934
- const lintScript = join(referencesRoot(), "scripts", "lint-recursive-run.py");
2935
- if (existsSync(lintScript)) try {
2936
- return parseLintOutput(execFileSync("python", [
2937
- lintScript,
2938
- "--repo-root",
2939
- root,
2940
- "--run-id",
2941
- runId
2942
- ], {
2943
- encoding: "utf8",
2944
- stdio: [
2945
- "ignore",
2946
- "pipe",
2947
- "ignore"
2948
- ],
2949
- timeout: 6e4
2950
- }), target, runId);
2951
- } catch (err) {
2952
- const stdout = err?.stdout ?? "";
2953
- if (stdout.includes("Summary")) return parseLintOutput(stdout, target, runId);
2954
- }
5082
+ const result = lintRun(root, runId);
5083
+ return parseLintOutput([...result.errors, ...result.warnings].join("\n"), target, runId);
5084
+ }
5085
+ /** Legacy structural fallback (kept for type reference; lintArtifact uses lintRun). */
5086
+ async lintArtifactStructuralFallback(runId, artifact, agent) {
5087
+ const root = await this.resolveRootFor(agent);
5088
+ if (!root) throw new Error("cannot resolve workspace control-plane root for this session");
5089
+ const runDir = join(root, ".recursive", "run", runId);
5090
+ const target = artifact ?? getNextLegalPhase(runDir) ?? "00-requirements.md";
5091
+ const artifactPath = join(runDir, target);
5092
+ if (!existsSync(artifactPath)) return {
5093
+ artifact: target,
5094
+ runId,
5095
+ errors: ["Artifact not found"],
5096
+ warnings: [],
5097
+ passed: false
5098
+ };
2955
5099
  const content = readFileSync(artifactPath, "utf8");
2956
5100
  const errors = [];
2957
5101
  const warnings = [];
@@ -3018,18 +5162,18 @@ var RecursiveRuntime = class extends Service {
3018
5162
  /** set_or_insert_field: replace first field occurrence, else insert after the last listed after-field. */
3019
5163
  function setOrInsertField(content, fieldName, value, afterFields) {
3020
5164
  const line = fieldName + ": `" + value + "`";
3021
- const fieldRe = new RegExp("^[ \\t]*(?:[-*][ \\t]+)?" + escapeRegExp$1(fieldName) + ":\\s*.*$", "m");
5165
+ const fieldRe = new RegExp("^[ \\t]*(?:[-*][ \\t]+)?" + escapeRegExp$2(fieldName) + ":\\s*.*$", "m");
3022
5166
  if (fieldRe.test(content)) return content.replace(fieldRe, line);
3023
5167
  const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
3024
5168
  let insertAt = 0;
3025
5169
  for (const afterField of afterFields) {
3026
- const afterRe = new RegExp("^[ \\t]*(?:[-*][ \\t]+)?" + escapeRegExp$1(afterField) + ":\\s*.*$", "m");
5170
+ const afterRe = new RegExp("^[ \\t]*(?:[-*][ \\t]+)?" + escapeRegExp$2(afterField) + ":\\s*.*$", "m");
3027
5171
  for (let i = 0; i < lines.length; i++) if (afterRe.test(lines[i])) insertAt = Math.max(insertAt, i + 1);
3028
5172
  }
3029
5173
  lines.splice(insertAt, 0, line);
3030
5174
  return lines.join("\n");
3031
5175
  }
3032
- function escapeRegExp$1(s) {
5176
+ function escapeRegExp$2(s) {
3033
5177
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3034
5178
  }
3035
5179
  /**
@@ -3267,6 +5411,332 @@ function createRecursiveScratchTool(recursive) {
3267
5411
  });
3268
5412
  }
3269
5413
  //#endregion
5414
+ //#region src/bootstrap.ts
5415
+ /**
5416
+ * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
5417
+ * core: bootstrap the FULL canonical /.recursive/ control plane + cross-tool
5418
+ * bridges byte-identically (RECURSIVE.md marker-wrapped, AGENTS.md, STATE/
5419
+ * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore,
5420
+ * vendored runtime scripts copied into .recursive/scripts/), plus the
5421
+ * agent/session-start Stage B (new vs resume) workspace-scoped to the session's
5422
+ * control-plane root (R1).
5423
+ *
5424
+ * Templates + bodies + runtime scripts are SHIPPED package files under
5425
+ * references/ (never inlined TS string literals) and resolved relative to this
5426
+ * module (package install location), never process.cwd().
5427
+ */
5428
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
5429
+ /** Package root: <package>/src/.. — references/ sits next to src/. */
5430
+ const PACKAGE_ROOT = join(MODULE_DIR, "..");
5431
+ /** references/ root (shipped package files). */
5432
+ function referencesRoot() {
5433
+ return join(PACKAGE_ROOT, "references");
5434
+ }
5435
+ const M = {
5436
+ recursiveStart: "<!-- RECURSIVE-MODE-CANONICAL:START -->",
5437
+ recursiveEnd: "<!-- RECURSIVE-MODE-CANONICAL:END -->",
5438
+ memoryStart: "<!-- RECURSIVE-MODE-MEMORY:START -->",
5439
+ memoryEnd: "<!-- RECURSIVE-MODE-MEMORY:END -->",
5440
+ agentsStart: "<!-- RECURSIVE-MODE-AGENTS:START -->",
5441
+ agentsEnd: "<!-- RECURSIVE-MODE-AGENTS:END -->",
5442
+ plansStart: "<!-- RECURSIVE-MODE-PLANS-BRIDGE:START -->",
5443
+ plansEnd: "<!-- RECURSIVE-MODE-PLANS-BRIDGE:END -->",
5444
+ cursorrulesStart: "# RECURSIVE-MODE-MEMORY-POINTERS:START",
5445
+ cursorrulesEnd: "# RECURSIVE-MODE-MEMORY-POINTERS:END",
5446
+ repoMdStart: "<!-- RECURSIVE-MODE-MEMORY-POINTERS:START -->",
5447
+ repoMdEnd: "<!-- RECURSIVE-MODE-MEMORY-POINTERS:END -->"
5448
+ };
5449
+ /** write_utf8_no_bom: LF newlines, no BOM. */
5450
+ function writeUtf8NoBom(path, content) {
5451
+ writeFileSync(path, content, { encoding: "utf8" });
5452
+ }
5453
+ /** ensure_gitignore_line: add the line when absent (preserving existing). */
5454
+ function ensureGitignoreLine(repoRoot, line) {
5455
+ const gitignorePath = join(repoRoot, ".gitignore");
5456
+ const normalized = line.trim();
5457
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
5458
+ if (existing.split(/\r?\n/).some((c) => c.trim() === normalized)) return;
5459
+ let updated = existing;
5460
+ if (updated && !updated.endsWith("\n")) updated += "\n";
5461
+ updated += normalized + "\n";
5462
+ writeUtf8NoBom(gitignorePath, updated);
5463
+ }
5464
+ /** upsert_marked_block: replace the START..END block, else append after existing. */
5465
+ function upsertMarkedBlock(filePath, startMarker, endMarker, blockBody) {
5466
+ const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
5467
+ const block = startMarker + "\n" + blockBody + "\n" + endMarker;
5468
+ const pattern = new RegExp(escapeRegExp$1(startMarker) + "[\\s\\S]*?" + escapeRegExp$1(endMarker));
5469
+ let updated;
5470
+ if (pattern.test(existing)) updated = existing.replace(pattern, block);
5471
+ else if (existing.trim() !== "") updated = existing.replace(/\s+$/, "") + "\n\n" + block + "\n";
5472
+ else updated = block + "\n";
5473
+ if (updated !== existing) writeUtf8NoBom(filePath, updated);
5474
+ }
5475
+ function escapeRegExp$1(s) {
5476
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5477
+ }
5478
+ /** normalize_plain_or_wrapped_content: strip markers; return plain body when wrapped. */
5479
+ function normalizePlainOrWrappedContent(content, startMarker, endMarker) {
5480
+ const startIndex = content.indexOf(startMarker);
5481
+ const endIndex = content.lastIndexOf(endMarker);
5482
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) return content.replace(/\r?\n+$/, "");
5483
+ const prefix = content.slice(0, startIndex).replace(/\r?\n+$/, "");
5484
+ if (prefix.trim() !== "") return prefix;
5485
+ const bodyStart = startIndex + startMarker.length;
5486
+ return content.slice(bodyStart, endIndex).replace(/^\r?\n+|\r?\n+$/g, "");
5487
+ }
5488
+ /** upsert_or_migrate_canonical: canonical block upsert with plain-file migration. */
5489
+ function upsertOrMigrateCanonical(filePath, startMarker, endMarker, canonicalBody) {
5490
+ const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
5491
+ const block = startMarker + "\n" + canonicalBody.replace(/\r?\n+$/, "") + "\n" + endMarker;
5492
+ const pattern = new RegExp(escapeRegExp$1(startMarker) + "[\\s\\S]*?" + escapeRegExp$1(endMarker));
5493
+ let updated;
5494
+ if (pattern.test(existing)) updated = existing.replace(pattern, block);
5495
+ else if (existing.trim() === "") updated = block + "\n";
5496
+ else if (existing.replace(/\r?\n+$/, "") === canonicalBody.replace(/\r?\n+$/, "")) updated = block + "\n";
5497
+ else updated = existing.replace(/\s+$/, "") + "\n\n" + block + "\n";
5498
+ if (updated !== existing) writeUtf8NoBom(filePath, updated);
5499
+ }
5500
+ /** Read a shipped body file (references/bodies/<name>) — CRLF-normalized. */
5501
+ function body(name) {
5502
+ return readFileSync(join(referencesRoot(), "bodies", name), "utf8").replace(/\r\n/g, "\n");
5503
+ }
5504
+ /** Read the canonical RECURSIVE.md template from shipped references/bootstrap/ — CRLF-normalized. */
5505
+ function canonicalWorkflowContent() {
5506
+ return readFileSync(join(referencesRoot(), "bootstrap", "RECURSIVE.md"), "utf8").replace(/\r\n/g, "\n");
5507
+ }
5508
+ /** Read the agents bridge block from shipped references/ — CRLF-normalized. */
5509
+ function agentsBlockContent() {
5510
+ return readFileSync(join(referencesRoot(), "agents-block.md"), "utf8").replace(/\r\n/g, "\n").replace(/\r?\n+$/, "");
5511
+ }
5512
+ /** default_router_policy(): byte-identical to recursive_router_lib.default_router_policy + pretty_json. */
5513
+ function defaultRouterPolicy() {
5514
+ return JSON.stringify({
5515
+ version: 1,
5516
+ defaults: {
5517
+ when_role_unconfigured: "ask",
5518
+ when_cli_unavailable: "fallback-local",
5519
+ when_model_unknown: "ask",
5520
+ allow_auto_assign_if_single_cli: false,
5521
+ probe_timeout_ms: 5e4,
5522
+ invoke_timeout_ms: 18e4
5523
+ },
5524
+ role_routes: {
5525
+ orchestrator: {
5526
+ enabled: true,
5527
+ mode: "local-only",
5528
+ cli: null,
5529
+ model: null,
5530
+ fallback: "local-controller"
5531
+ },
5532
+ analyst: {
5533
+ enabled: true,
5534
+ mode: "external-cli",
5535
+ cli: null,
5536
+ model: null,
5537
+ fallback: "self-audit"
5538
+ },
5539
+ planner: {
5540
+ enabled: true,
5541
+ mode: "external-cli",
5542
+ cli: null,
5543
+ model: null,
5544
+ fallback: "self-audit"
5545
+ },
5546
+ implementer: {
5547
+ enabled: false,
5548
+ mode: "external-cli",
5549
+ cli: null,
5550
+ model: null,
5551
+ fallback: "local-controller"
5552
+ },
5553
+ "code-reviewer": {
5554
+ enabled: true,
5555
+ mode: "external-cli",
5556
+ cli: null,
5557
+ model: null,
5558
+ fallback: "self-audit"
5559
+ },
5560
+ tester: {
5561
+ enabled: true,
5562
+ mode: "external-cli",
5563
+ cli: null,
5564
+ model: null,
5565
+ fallback: "self-audit"
5566
+ },
5567
+ "memory-auditor": {
5568
+ enabled: true,
5569
+ mode: "external-cli",
5570
+ cli: null,
5571
+ model: null,
5572
+ fallback: "self-audit"
5573
+ }
5574
+ },
5575
+ cli_overrides: {},
5576
+ custom_clis: []
5577
+ }, null, 2) + "\n";
5578
+ }
5579
+ /** ensure_router_scaffold: write config/recursive-router.json when missing. */
5580
+ function ensureRouterScaffold(repoRoot) {
5581
+ const configDir = join(repoRoot, ".recursive", "config");
5582
+ const policyPath = join(configDir, "recursive-router.json");
5583
+ if (!existsSync(policyPath)) {
5584
+ mkdirSync(configDir, { recursive: true });
5585
+ writeUtf8NoBom(policyPath, defaultRouterPolicy());
5586
+ }
5587
+ }
5588
+ /**
5589
+ * Idempotent scaffold install: reproduce the canonical installer's 82-file
5590
+ * tree byte-for-byte. Never overwrites existing user content; marked blocks
5591
+ * are re-upserted in place.
5592
+ */
5593
+ function bootstrapScaffold(root) {
5594
+ const created = [];
5595
+ const existing = [];
5596
+ const noteDir = (rel) => {
5597
+ const p = join(root, rel);
5598
+ if (existsSync(p)) existing.push(rel + "/");
5599
+ else {
5600
+ mkdirSync(p, { recursive: true });
5601
+ created.push(rel + "/");
5602
+ }
5603
+ };
5604
+ const noteFile = (rel, content) => {
5605
+ const p = join(root, rel);
5606
+ if (existsSync(p)) {
5607
+ existing.push(rel);
5608
+ return;
5609
+ }
5610
+ if (content !== void 0) {
5611
+ mkdirSync(dirname(p), { recursive: true });
5612
+ writeUtf8NoBom(p, content);
5613
+ } else {
5614
+ mkdirSync(dirname(p), { recursive: true });
5615
+ writeFileSync(p, "");
5616
+ }
5617
+ created.push(rel);
5618
+ };
5619
+ const recursiveRoot = join(root, ".recursive");
5620
+ const memoryRoot = join(recursiveRoot, "memory");
5621
+ const skillMemoryRoot = join(memoryRoot, "skills");
5622
+ for (const rel of [
5623
+ ".recursive",
5624
+ ".codex",
5625
+ ".agent",
5626
+ ".github",
5627
+ ".recursive/memory",
5628
+ ".recursive/memory/training",
5629
+ ".recursive/memory/skills",
5630
+ ".recursive/run",
5631
+ ".recursive/config"
5632
+ ]) noteDir(rel);
5633
+ for (const sub of [
5634
+ "domains",
5635
+ "patterns",
5636
+ "incidents",
5637
+ "episodes",
5638
+ "archive"
5639
+ ]) noteDir(join(".recursive/memory", sub));
5640
+ for (const sub of [
5641
+ "availability",
5642
+ "usage",
5643
+ "issues",
5644
+ "patterns"
5645
+ ]) noteDir(join(".recursive/memory/skills", sub));
5646
+ for (const rel of [
5647
+ ".recursive/memory/domains/.gitkeep",
5648
+ ".recursive/memory/patterns/.gitkeep",
5649
+ ".recursive/memory/incidents/.gitkeep",
5650
+ ".recursive/memory/episodes/.gitkeep",
5651
+ ".recursive/memory/archive/.gitkeep",
5652
+ ".recursive/memory/training/.gitkeep",
5653
+ ".recursive/memory/skills/availability/.gitkeep",
5654
+ ".recursive/memory/skills/usage/.gitkeep",
5655
+ ".recursive/memory/skills/issues/.gitkeep",
5656
+ ".recursive/memory/skills/patterns/.gitkeep",
5657
+ ".recursive/run/.gitkeep"
5658
+ ]) noteFile(rel, "");
5659
+ noteDir(".recursive/scripts");
5660
+ {
5661
+ const scriptsDir = join(recursiveRoot, "scripts");
5662
+ if (existsSync(scriptsDir)) {
5663
+ for (const name of readdirSync(scriptsDir)) if (name.endsWith(".py") || name.endsWith(".ps1")) rmSync(join(scriptsDir, name), { force: true });
5664
+ }
5665
+ }
5666
+ noteFile(".recursive/RECURSIVE.md", "# RECURSIVE.md\n");
5667
+ noteFile(".recursive/AGENTS.md", "# AGENTS.md\n");
5668
+ noteFile(".recursive/STATE.md", body("state.md") + "\n");
5669
+ noteFile(".recursive/DECISIONS.md", body("decisions.md") + "\n");
5670
+ noteFile(".recursive/memory/MEMORY.md", "# MEMORY.md\n");
5671
+ noteFile(".recursive/memory/skills/SKILLS.md", "# SKILLS.md\n");
5672
+ noteFile(".recursive/memory/skills/usage/skill-discovery-and-evaluation.md", body("skill-discovery.md") + "\n");
5673
+ noteFile(".recursive/memory/skills/patterns/delegated-verification-and-refresh.md", body("delegated-verification.md") + "\n");
5674
+ noteFile(".recursive/memory/skills/patterns/phase8-skill-memory-promotion.md", body("phase8-skill-memory.md") + "\n");
5675
+ noteFile(".codex/AGENTS.md", "# AGENTS.md\n");
5676
+ noteFile(".agent/PLANS.md", "# PLANS.md\n");
5677
+ noteFile(".cursorrules", "");
5678
+ noteFile("CLAUDE.md", "");
5679
+ noteFile(".github/copilot-instructions.md", "");
5680
+ ensureGitignoreLine(root, "/.recursive/config/recursive-router-discovered.json");
5681
+ ensureRouterScaffold(root);
5682
+ upsertMarkedBlock(join(recursiveRoot, "AGENTS.md"), M.agentsStart, M.agentsEnd, body("recursive-agents-router.md"));
5683
+ upsertMarkedBlock(join(memoryRoot, "MEMORY.md"), M.memoryStart, M.memoryEnd, body("memory-router.md"));
5684
+ upsertMarkedBlock(join(skillMemoryRoot, "SKILLS.md"), M.memoryStart, M.memoryEnd, body("skill-memory-router.md"));
5685
+ upsertMarkedBlock(join(root, ".cursorrules"), M.cursorrulesStart, M.cursorrulesEnd, body("cursorrules.md"));
5686
+ upsertMarkedBlock(join(root, "CLAUDE.md"), M.repoMdStart, M.repoMdEnd, body("claude.md"));
5687
+ upsertMarkedBlock(join(root, ".github", "copilot-instructions.md"), M.repoMdStart, M.repoMdEnd, body("copilot.md"));
5688
+ upsertMarkedBlock(join(root, ".codex", "AGENTS.md"), M.agentsStart, M.agentsEnd, body("codex-agents.md"));
5689
+ const rootAgents = join(root, "AGENTS.md");
5690
+ if (existsSync(rootAgents)) upsertMarkedBlock(rootAgents, M.agentsStart, M.agentsEnd, agentsBlockContent());
5691
+ upsertMarkedBlock(join(root, ".agent", "PLANS.md"), M.plansStart, M.plansEnd, body("plans-bridge.md"));
5692
+ const canonicalBody = normalizePlainOrWrappedContent(canonicalWorkflowContent(), M.recursiveStart, M.recursiveEnd);
5693
+ upsertOrMigrateCanonical(join(recursiveRoot, "RECURSIVE.md"), M.recursiveStart, M.recursiveEnd, canonicalBody);
5694
+ return {
5695
+ root,
5696
+ bootstrapped: created.length > 0,
5697
+ created,
5698
+ existing
5699
+ };
5700
+ }
5701
+ /**
5702
+ * Enumerate runs as directory names only (bounded O(#dirs)). Never reads
5703
+ * run docs; never returns file paths.
5704
+ */
5705
+ function enumerateRuns(root) {
5706
+ const runRoot = join(root, ".recursive", "run");
5707
+ if (!existsSync(runRoot)) return [];
5708
+ return readdirSync(runRoot, { withFileTypes: true }).filter((d) => {
5709
+ if (d.isDirectory()) return true;
5710
+ if (!d.isSymbolicLink()) return false;
5711
+ try {
5712
+ return statSync(join(runRoot, d.name)).isDirectory();
5713
+ } catch {
5714
+ return false;
5715
+ }
5716
+ }).map((d) => d.name).sort();
5717
+ }
5718
+ /**
5719
+ * Stage B workflow init at agent/session-start. New: bootstrap if missing +
5720
+ * enumerate runs. Resume: idempotent REPAIR (not a full re-bootstrap) — the
5721
+ * upsert-only bootstrapScaffold adds missing control-plane files/dirs and
5722
+ * re-upserts marked blocks WITHOUT overwriting user content or touching run/
5723
+ * artifacts, then enumerates + notes the active run. The repair is safe on
5724
+ * fresh, partial, AND complete scaffolds (run 09 R3/R6). Always scoped to the
5725
+ * given control-plane root.
5726
+ */
5727
+ function stageBWorkflowInit(input) {
5728
+ const { root, source, activeRunId } = input;
5729
+ const bootstrap = bootstrapScaffold(root);
5730
+ const runs = enumerateRuns(root);
5731
+ return {
5732
+ root,
5733
+ source,
5734
+ bootstrapped: source === "new" ? bootstrap.bootstrapped : false,
5735
+ runs,
5736
+ activeRunId
5737
+ };
5738
+ }
5739
+ //#endregion
3270
5740
  //#region src/commands.ts
3271
5741
  /**
3272
5742
  * /recursive slash command surface (R4). Preset-scoped verbs + global
@@ -3357,6 +5827,13 @@ function executeRecursiveCommand(root, rawInput) {
3357
5827
  kind: "success",
3358
5828
  text: HELP_TEXT + " — see /.recursive/RECURSIVE.md"
3359
5829
  };
5830
+ case "bootstrap": {
5831
+ const result = bootstrapScaffold(root);
5832
+ return {
5833
+ kind: "success",
5834
+ text: "recursive-mode scaffold repair: " + (result.created.length === 0 ? "scaffold already complete (no changes)" : "created " + result.created.length + " item(s), " + result.existing.length + " existing") + " — root " + root
5835
+ };
5836
+ }
3360
5837
  case "closeout": {
3361
5838
  const phase = arg.match(/--phase\s+(\d{2})/)?.[1] ?? "";
3362
5839
  const runId = arg.replace(/--phase\s+\d{2}/, "").trim();
@@ -3721,6 +6198,7 @@ function apply(ctx, config) {
3721
6198
  repoRoot: config?.repoRoot ?? process.cwd(),
3722
6199
  workspaceRegistry
3723
6200
  });
6201
+ const repairedRoots = /* @__PURE__ */ new Set();
3724
6202
  const disposers = [
3725
6203
  ctx.tools.register(createRecursiveStatusTool(recursive)),
3726
6204
  ctx.tools.register(createRecursiveInitTool(recursive)),
@@ -3770,6 +6248,13 @@ function apply(ctx, config) {
3770
6248
  kind: "enter",
3771
6249
  messages
3772
6250
  };
6251
+ if (!repairedRoots.has(root)) {
6252
+ repairedRoots.add(root);
6253
+ stageBWorkflowInit({
6254
+ root,
6255
+ source: "resume"
6256
+ });
6257
+ }
3773
6258
  const runs = enumerateRuns(root);
3774
6259
  if (runs.length === 0) return {
3775
6260
  kind: "enter",