@try-works/dsh-recursive-mode 0.1.4 → 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.
- package/lib/bootstrap.d.ts +6 -3
- package/lib/client/board.d.ts +4 -1
- package/lib/client/open-state.d.ts +20 -0
- package/lib/client/slots.d.ts +7 -1
- package/lib/client.js +135 -47
- package/lib/fs-intent.d.ts +34 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +3429 -911
- package/lib/policy.d.ts +1 -1
- package/lib/runtime.d.ts +12 -5
- package/lib/ts-lint.d.ts +205 -0
- package/package.json +1 -1
- package/src/bootstrap.ts +338 -331
- package/src/client/board.tsx +0 -0
- package/src/client/open-state.ts +46 -0
- package/src/client/slots.ts +41 -20
- package/src/commands.ts +11 -0
- package/src/fs-intent.ts +84 -0
- package/src/index.ts +238 -226
- package/src/policy.ts +1 -1
- package/src/runtime.ts +19 -23
- package/src/ts-lint.ts +2110 -0
- package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
- package/references/scripts/lint-recursive-run.ps1 +0 -25
- package/references/scripts/lint-recursive-run.py +0 -2870
- package/references/scripts/recursive-closeout.ps1 +0 -38
- package/references/scripts/recursive-closeout.py +0 -541
- package/references/scripts/recursive-init.ps1 +0 -515
- package/references/scripts/recursive-init.py +0 -356
- package/references/scripts/recursive-lock.ps1 +0 -26
- package/references/scripts/recursive-lock.py +0 -302
- package/references/scripts/recursive-review-bundle.ps1 +0 -58
- package/references/scripts/recursive-review-bundle.py +0 -503
- package/references/scripts/recursive-router-cli-configure.ps1 +0 -2
- package/references/scripts/recursive-router-cli-configure.py +0 -9
- package/references/scripts/recursive-router-cli-init.ps1 +0 -2
- package/references/scripts/recursive-router-cli-init.py +0 -9
- package/references/scripts/recursive-router-cli-invoke.ps1 +0 -2
- package/references/scripts/recursive-router-cli-invoke.py +0 -9
- package/references/scripts/recursive-router-cli-probe.ps1 +0 -2
- package/references/scripts/recursive-router-cli-probe.py +0 -9
- package/references/scripts/recursive-router-cli-resolve.ps1 +0 -2
- package/references/scripts/recursive-router-cli-resolve.py +0 -9
- package/references/scripts/recursive-router-cli-validate.ps1 +0 -2
- package/references/scripts/recursive-router-cli-validate.py +0 -9
- package/references/scripts/recursive-router-configure.ps1 +0 -27
- package/references/scripts/recursive-router-configure.py +0 -74
- package/references/scripts/recursive-router-init.ps1 +0 -17
- package/references/scripts/recursive-router-init.py +0 -29
- package/references/scripts/recursive-router-invoke.ps1 +0 -47
- package/references/scripts/recursive-router-invoke.py +0 -103
- package/references/scripts/recursive-router-probe.ps1 +0 -25
- package/references/scripts/recursive-router-probe.py +0 -44
- package/references/scripts/recursive-router-resolve.ps1 +0 -26
- package/references/scripts/recursive-router-resolve.py +0 -46
- package/references/scripts/recursive-router-validate.ps1 +0 -17
- package/references/scripts/recursive-router-validate.py +0 -27
- package/references/scripts/recursive-status.ps1 +0 -23
- package/references/scripts/recursive-status.py +0 -2124
- package/references/scripts/recursive-subagent-action.ps1 +0 -98
- package/references/scripts/recursive-subagent-action.py +0 -197
- package/references/scripts/recursive-training-extract.ps1 +0 -23
- package/references/scripts/recursive-training-extract.py +0 -99
- package/references/scripts/recursive-training-grpo.ps1 +0 -52
- package/references/scripts/recursive-training-grpo.py +0 -1341
- package/references/scripts/recursive-training-loader.ps1 +0 -78
- package/references/scripts/recursive-training-loader.py +0 -558
- package/references/scripts/recursive-training-mcp.ps1 +0 -29
- package/references/scripts/recursive-training-mcp.py +0 -261
- package/references/scripts/recursive-training-phase8-trigger.ps1 +0 -55
- package/references/scripts/recursive-training-phase8-trigger.py +0 -137
- package/references/scripts/recursive-training-sync.ps1 +0 -29
- package/references/scripts/recursive-training-sync.py +0 -183
- package/references/scripts/recursive_phase_rules.py +0 -367
- package/references/scripts/recursive_router_cli_lib.py +0 -2
- package/references/scripts/recursive_router_lib.py +0 -2282
- package/references/scripts/verify-locks.ps1 +0 -25
- package/references/scripts/verify-locks.py +0 -353
- package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
- package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
- package/scripts/install-recursive-mode.ps1 +0 -956
- package/scripts/install-recursive-mode.py +0 -750
- package/scripts/lint-recursive-run.py +0 -2870
- package/scripts/recursive-closeout.py +0 -541
- package/scripts/recursive-init.py +0 -356
- package/scripts/recursive-lock.py +0 -302
- package/scripts/recursive-status.py +0 -2124
- package/scripts/recursive_phase_rules.py +0 -367
- package/scripts/recursive_router_lib.py +0 -2282
- 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
|
-
|
|
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
|
|
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/
|
|
267
|
+
//#region src/lock.ts
|
|
271
268
|
/**
|
|
272
|
-
*
|
|
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
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
*
|
|
335
|
-
*
|
|
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
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
*
|
|
390
|
-
*
|
|
335
|
+
* Canonical lock-validity classifier:
|
|
336
|
+
* MISSING (no file) / DRAFT (not LOCKED) / STALE_LOCK (missing or mismatched hash fields) / LOCKED.
|
|
391
337
|
*/
|
|
392
|
-
function
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
return
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
522
|
-
return lines.join("\n");
|
|
365
|
+
return blockers;
|
|
523
366
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
*
|
|
585
|
-
*
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
|
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/
|
|
614
|
-
|
|
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
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
if (
|
|
804
|
-
if (
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
817
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
if (
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
return
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1218
|
+
return readdirSync(addendaDir).filter((n) => n.startsWith(baseName + ".upstream-gap.") && n.includes(".addendum-") && n.endsWith(".md")).sort();
|
|
1009
1219
|
} catch {
|
|
1010
|
-
return
|
|
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
|
-
/**
|
|
1021
|
-
function
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
|
1238
|
+
return captured.join("\n").trim();
|
|
1032
1239
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
return
|
|
1240
|
+
/** get_header_input_paths: paths under Inputs:. */
|
|
1241
|
+
function getHeaderInputPaths(content) {
|
|
1242
|
+
return extractPathsFromText(getHeaderFieldBlock(content, "Inputs", ["Outputs", "Scope note"]));
|
|
1036
1243
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
if (
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
1668
|
+
return issues;
|
|
1123
1669
|
}
|
|
1124
|
-
/**
|
|
1125
|
-
function
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
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));
|
|
1144
1796
|
}
|
|
1797
|
+
return [...new Set(issues)].sort();
|
|
1145
1798
|
}
|
|
1146
|
-
|
|
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();
|
|
1147
1820
|
}
|
|
1148
|
-
/**
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
const
|
|
1160
|
-
if (
|
|
1161
|
-
|
|
1162
|
-
|
|
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");
|
|
1163
3299
|
}
|
|
1164
|
-
if (
|
|
1165
|
-
|
|
1166
|
-
|
|
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;
|
|
3307
|
+
}
|
|
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
|
+
};
|
|
3330
|
+
}
|
|
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
|
|
1176
|
-
|
|
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
|
-
|
|
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:
|
|
2915
|
-
*
|
|
2916
|
-
*
|
|
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).
|
|
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
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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();
|
|
@@ -3444,6 +5921,42 @@ function registerRecursiveCommand(ctx, recursive) {
|
|
|
3444
5921
|
});
|
|
3445
5922
|
}
|
|
3446
5923
|
//#endregion
|
|
5924
|
+
//#region src/fs-intent.ts
|
|
5925
|
+
/**
|
|
5926
|
+
* fs-intent.ts — filesystem-derived recursive intent (R5 policy-render fix).
|
|
5927
|
+
*
|
|
5928
|
+
* SP2 zero-emission retired the recursive/phase-intent SESSION EVENT emitter,
|
|
5929
|
+
* so detectTransitionIntent(events) is ALWAYS null and the recursive:policy
|
|
5930
|
+
* prompt section rendered ''. This module derives the SAME intent from the
|
|
5931
|
+
* filesystem instead: session cwd -> control-plane root -> enumerate runs ->
|
|
5932
|
+
* latest run -> current phase (foldRun). Pure read-only fs folding, zero
|
|
5933
|
+
* emission — consistent with the per-workspace design + pre-step listener.
|
|
5934
|
+
*/
|
|
5935
|
+
/**
|
|
5936
|
+
* Resolve the recursive policy intent for one agent from the FILESYSTEM.
|
|
5937
|
+
* Returns null when no control-plane root or run is present (callers defer,
|
|
5938
|
+
* never fail hard). The registry path is async-only in the harness, so when a
|
|
5939
|
+
* registry is present this resolves the session cwd directly (B4: the session
|
|
5940
|
+
* cwd IS the workspace root when unregistered; a registered workspace resolves
|
|
5941
|
+
* to the same cwd via resolveControlPlaneRoot's sync fallback).
|
|
5942
|
+
*/
|
|
5943
|
+
function fsPolicyIntent(agent, registry, cwdFallback) {
|
|
5944
|
+
const cwd = agent?.session?.header?.cwd ?? cwdFallback ?? null;
|
|
5945
|
+
if (!cwd) return null;
|
|
5946
|
+
const root = registry ? cwd : cwd;
|
|
5947
|
+
const runs = enumerateRuns(root);
|
|
5948
|
+
if (runs.length === 0) return null;
|
|
5949
|
+
const runId = runs[runs.length - 1];
|
|
5950
|
+
const runDir = join(root, ".recursive", "run", runId);
|
|
5951
|
+
if (!existsSync(runDir)) return null;
|
|
5952
|
+
const folded = foldRun(runDir, runId);
|
|
5953
|
+
return {
|
|
5954
|
+
worktreeRoot: root,
|
|
5955
|
+
runId,
|
|
5956
|
+
currentPhaseFile: folded.currentPhase ? folded.currentPhase.phaseName : null
|
|
5957
|
+
};
|
|
5958
|
+
}
|
|
5959
|
+
//#endregion
|
|
3447
5960
|
//#region src/snapshot.ts
|
|
3448
5961
|
/** Resolve a run's lock facts for one artifact file (receipt first, status fallback). */
|
|
3449
5962
|
function lockFactsOf(runDir, artifact) {
|
|
@@ -3682,9 +6195,10 @@ function apply(ctx, config) {
|
|
|
3682
6195
|
ctx.effect(function* () {
|
|
3683
6196
|
const workspaceRegistry = ctx.get("workspaceRegistry");
|
|
3684
6197
|
const recursive = new RecursiveRuntime(ctx, {
|
|
3685
|
-
repoRoot: process.cwd(),
|
|
6198
|
+
repoRoot: config?.repoRoot ?? process.cwd(),
|
|
3686
6199
|
workspaceRegistry
|
|
3687
6200
|
});
|
|
6201
|
+
const repairedRoots = /* @__PURE__ */ new Set();
|
|
3688
6202
|
const disposers = [
|
|
3689
6203
|
ctx.tools.register(createRecursiveStatusTool(recursive)),
|
|
3690
6204
|
ctx.tools.register(createRecursiveInitTool(recursive)),
|
|
@@ -3702,14 +6216,11 @@ function apply(ctx, config) {
|
|
|
3702
6216
|
text: (context) => {
|
|
3703
6217
|
const agent = context?.agent;
|
|
3704
6218
|
if (!agent) return "";
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
const intent = detectTransitionIntent(events);
|
|
3708
|
-
if (!intent || intent.worktreeRoot === "") return "";
|
|
6219
|
+
const intent = fsPolicyIntent(agent, workspaceRegistry);
|
|
6220
|
+
if (!intent) return "";
|
|
3709
6221
|
return renderRecursivePolicy({
|
|
3710
6222
|
worktreeRoot: intent.worktreeRoot,
|
|
3711
6223
|
runId: intent.runId,
|
|
3712
|
-
folded: recursive.foldPhase(events),
|
|
3713
6224
|
config: recursive.enforcementConfig
|
|
3714
6225
|
});
|
|
3715
6226
|
}
|
|
@@ -3737,6 +6248,13 @@ function apply(ctx, config) {
|
|
|
3737
6248
|
kind: "enter",
|
|
3738
6249
|
messages
|
|
3739
6250
|
};
|
|
6251
|
+
if (!repairedRoots.has(root)) {
|
|
6252
|
+
repairedRoots.add(root);
|
|
6253
|
+
stageBWorkflowInit({
|
|
6254
|
+
root,
|
|
6255
|
+
source: "resume"
|
|
6256
|
+
});
|
|
6257
|
+
}
|
|
3740
6258
|
const runs = enumerateRuns(root);
|
|
3741
6259
|
if (runs.length === 0) return {
|
|
3742
6260
|
kind: "enter",
|