opencode-longrun-harness 1.2.22
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/LICENSE +21 -0
- package/README.md +390 -0
- package/docs/V1.2.20_EVIDENCE.md +114 -0
- package/docs/V1.2.21_EVIDENCE.md +68 -0
- package/docs/V1.2.22_EVIDENCE.md +52 -0
- package/harness/commissioning/README.md +16 -0
- package/harness/commissioning/inspect-copied-run.mjs +25 -0
- package/harness/commissioning/verify-copied-case.mjs +35 -0
- package/harness/plugin/longrun.js +677 -0
- package/harness/src/cli.mjs +40 -0
- package/harness/src/controller.js +1413 -0
- package/harness/src/evidence.mjs +135 -0
- package/harness/src/execution.mjs +217 -0
- package/harness/src/executor.mjs +21 -0
- package/harness/src/install.mjs +435 -0
- package/harness/src/maintenance.mjs +257 -0
- package/harness/src/memory.mjs +472 -0
- package/harness/test/candidates.test.mjs +73 -0
- package/harness/test/checkpoint.test.mjs +65 -0
- package/harness/test/controller.test.mjs +230 -0
- package/harness/test/evidence.test.mjs +57 -0
- package/harness/test/fixtures/durable-host.mjs +27 -0
- package/harness/test/fixtures/example-app-run.json +1375 -0
- package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
- package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
- package/harness/test/fixtures/notes-recovery-run.json +622 -0
- package/harness/test/fixtures/presets-readout-run.json +825 -0
- package/harness/test/fixtures/routing-worker.mjs +35 -0
- package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
- package/harness/test/helper.mjs +41 -0
- package/harness/test/install.test.mjs +117 -0
- package/harness/test/lifecycle.test.mjs +102 -0
- package/harness/test/maintenance.test.mjs +204 -0
- package/harness/test/memory.test.mjs +145 -0
- package/harness/test/negative-control.test.mjs +91 -0
- package/harness/test/plugin.test.mjs +169 -0
- package/harness/test/recovery-runner.test.mjs +435 -0
- package/harness/test/recovery.test.mjs +68 -0
- package/harness/test/repair-mechanics.test.mjs +122 -0
- package/harness/test/toolbehavior.test.mjs +75 -0
- package/harness/test/v121-commissioning.test.mjs +177 -0
- package/harness/test/v1210-deadline.test.mjs +134 -0
- package/harness/test/v1211-pause.test.mjs +81 -0
- package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
- package/harness/test/v1213-readout.test.mjs +82 -0
- package/harness/test/v1214-durable.test.mjs +121 -0
- package/harness/test/v1215-guidance.test.mjs +57 -0
- package/harness/test/v1216-test-summary.test.mjs +39 -0
- package/harness/test/v1217-discovery.test.mjs +73 -0
- package/harness/test/v1218-completion-review.test.mjs +203 -0
- package/harness/test/v1219-budget-pause.test.mjs +134 -0
- package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
- package/harness/test/v1220-budget-amendment.test.mjs +343 -0
- package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
- package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
- package/harness/test/v123-plugin-e2e.test.mjs +120 -0
- package/harness/test/v123-receipt-model.test.mjs +185 -0
- package/harness/test/v124-canonical.test.mjs +147 -0
- package/harness/test/v124-installed.test.mjs +48 -0
- package/harness/test/v125-stability.test.mjs +183 -0
- package/harness/test/v126-execution.test.mjs +183 -0
- package/harness/test/v127-reconciliation.test.mjs +139 -0
- package/harness/test/v128-compaction.test.mjs +156 -0
- package/harness/test/v129-routing.test.mjs +165 -0
- package/harness/tools/audit-receipts.mjs +121 -0
- package/harness/tools/recovery-runner.mjs +499 -0
- package/package.json +49 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// Long-run Harness — hierarchical project memory (structural memory plane A).
|
|
2
|
+
// Dependency-free (node builtins only). Deterministic. Copied verbatim into the built install
|
|
3
|
+
// next to controller.js and imported by it. Produces AGENTS.md + .longrun/memory-index.json and
|
|
4
|
+
// assesses staleness. It never writes transient run state (that is the episodic plane).
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
export const SCHEMA_VERSION = 1;
|
|
11
|
+
export const DEFAULT_MAX_DEPTH = 3;
|
|
12
|
+
|
|
13
|
+
export const CODE_EXT = new Set([
|
|
14
|
+
".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".vue", ".svelte",
|
|
15
|
+
".go", ".rs", ".py", ".java", ".kt", ".c", ".h", ".cc", ".cpp", ".hpp",
|
|
16
|
+
".rb", ".php", ".swift", ".scala", ".ex", ".exs", ".zig", ".sql",
|
|
17
|
+
]);
|
|
18
|
+
export const MANIFEST_NAMES = new Set([
|
|
19
|
+
"package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb",
|
|
20
|
+
"Cargo.toml", "go.mod", "go.sum", "pom.xml", "build.gradle", "build.gradle.kts",
|
|
21
|
+
"pyproject.toml", "setup.py", "requirements.txt", "Makefile", "CMakeLists.txt",
|
|
22
|
+
]);
|
|
23
|
+
// directories that never get their own memory node and are skipped during discovery
|
|
24
|
+
export const SKIP_DIRS = new Set([
|
|
25
|
+
".git", ".hg", ".svn", "node_modules", "vendor", "target", "build", "dist", "out",
|
|
26
|
+
"bin", "__pycache__", ".venv", "venv", ".next", ".nuxt", "coverage", ".longrun",
|
|
27
|
+
".cache", ".turbo", ".parcel-cache", ".idea", ".vscode", "tmp", "fixtures", "__tests__",
|
|
28
|
+
]);
|
|
29
|
+
const TEST_HINT_RE = /(^|[-_.])(test|spec|tests|specs)([-_.]|$)/i;
|
|
30
|
+
const ENTRY_RE = /(^|\/)(index|main|mod|lib|server|app|application|cli|entry|router)(\.[a-z]+)?$/i;
|
|
31
|
+
|
|
32
|
+
// Generic advice that must NEVER appear in generated memory (dedup/trim strips it).
|
|
33
|
+
export const GENERIC_ADVICE = [
|
|
34
|
+
"write clean code", "follow best practices", "write tests", "keep it simple",
|
|
35
|
+
"be careful", "use common sense", "read the documentation", "document your code",
|
|
36
|
+
"single source of truth", "don't repeat yourself", "solid principles",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export const MANAGED_BEGIN = "<!-- BEGIN longrun:managed -->";
|
|
40
|
+
export const MANAGED_END = "<!-- END longrun:managed -->";
|
|
41
|
+
|
|
42
|
+
function sha256(s) { return crypto.createHash("sha256").update(s).digest("hex"); }
|
|
43
|
+
function relSlash(root, p) { return path.relative(root, p).split(path.sep).join("/"); }
|
|
44
|
+
function readIfExists(p, max = 256 * 1024) { try { const b = fs.readFileSync(p); return b.length > max ? b.subarray(0, max) : b; } catch { return null; } }
|
|
45
|
+
function isCode(f) { return CODE_EXT.has(path.extname(f).toLowerCase()); }
|
|
46
|
+
const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
47
|
+
function listDirs(dir) { try { return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).sort(byName); } catch { return []; } }
|
|
48
|
+
function listFiles(dir) { try { return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile()).sort(byName); } catch { return []; } }
|
|
49
|
+
|
|
50
|
+
export function gitHeadAt(root) {
|
|
51
|
+
const r = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
52
|
+
return r && r.status === 0 ? r.stdout.trim() : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- Complexity scoring model (documented; max 100) ---------------------------------------
|
|
56
|
+
// file count (HIGH) 1/file cap25 | subdirs 2/subdir cap12 | code ratio 15*ratio
|
|
57
|
+
// package boundary 15 | entry points 8 | exports 1/export cap10 | local rules 10 | tests 5 | centrality 5
|
|
58
|
+
// tier: >=45 HIGH, 20..44 MEDIUM, <20 LOW
|
|
59
|
+
export function scoreDir(info) {
|
|
60
|
+
const reasons = [];
|
|
61
|
+
let score = 0;
|
|
62
|
+
const fp = Math.min(25, info.fileCount); score += fp;
|
|
63
|
+
if (info.fileCount >= 8) reasons.push(`file_count=${info.fileCount}(+${fp})`);
|
|
64
|
+
const sp = Math.min(12, info.subdirCount * 2); score += sp;
|
|
65
|
+
if (info.subdirCount >= 4) reasons.push(`subdir_count=${info.subdirCount}(+${sp})`);
|
|
66
|
+
const cr = Math.round(15 * (info.codeRatio || 0)); score += cr;
|
|
67
|
+
if (cr > 0) reasons.push(`code_ratio=${(info.codeRatio || 0).toFixed(2)}(+${cr})`);
|
|
68
|
+
if (info.hasManifest) { score += 15; reasons.push("package_boundary(+15)"); }
|
|
69
|
+
if (info.entryPoints.length) { score += 8; reasons.push(`entry_points=${info.entryPoints.length}(+8)`); }
|
|
70
|
+
const xp = Math.min(10, info.exportCount); score += xp;
|
|
71
|
+
if (info.exportCount >= 3) reasons.push(`exports=${info.exportCount}(+${xp})`);
|
|
72
|
+
if (info.ruleMarkers.length) { score += 10; reasons.push(`local_rules=${info.ruleMarkers.length}(+10)`); }
|
|
73
|
+
if (info.testFileCount >= 2) { score += 5; reasons.push(`tests=${info.testFileCount}(+5)`); }
|
|
74
|
+
if (info.centrality >= 2) { score += 5; reasons.push(`centrality=${info.centrality}(+5)`); }
|
|
75
|
+
const tier = score >= 45 ? "high" : score >= 20 ? "medium" : "low";
|
|
76
|
+
return { score, tier, reasons };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function computeCentrality(root, dirRel, budget) {
|
|
80
|
+
if (!dirRel) return 0;
|
|
81
|
+
const base = path.posix.basename(dirRel);
|
|
82
|
+
let hits = 0;
|
|
83
|
+
const stack = [root];
|
|
84
|
+
let files = budget;
|
|
85
|
+
const re = new RegExp(`from ["'][^"']*${base}/`, "i");
|
|
86
|
+
while (stack.length && files-- > 0) {
|
|
87
|
+
const d = stack.pop();
|
|
88
|
+
for (const it of listFiles(d)) {
|
|
89
|
+
if (!isCode(it.name)) continue;
|
|
90
|
+
const buf = readIfExists(path.join(d, it.name), 64 * 1024);
|
|
91
|
+
if (buf && re.test(buf.toString("utf8"))) hits++;
|
|
92
|
+
if (hits > 12) return hits;
|
|
93
|
+
}
|
|
94
|
+
for (const sd of listDirs(d)) { if (!SKIP_DIRS.has(sd.name)) stack.push(path.join(d, sd.name)); }
|
|
95
|
+
}
|
|
96
|
+
return hits;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Inspect ONE directory (non-recursive beyond listing its immediate children).
|
|
100
|
+
export function scanDir(root, absDir, opts = {}) {
|
|
101
|
+
const dirRel = relSlash(root, absDir);
|
|
102
|
+
// Generated memory must not change its own counts/selection on the next refresh.
|
|
103
|
+
const files = listFiles(absDir).filter(f => f.name !== "AGENTS.md");
|
|
104
|
+
const dirs = listDirs(absDir);
|
|
105
|
+
const subdirNames = dirs.map((d) => d.name).filter((n) => !SKIP_DIRS.has(n));
|
|
106
|
+
const fileNames = files.map((f) => f.name);
|
|
107
|
+
let codeCount = 0;
|
|
108
|
+
let exportCount = 0;
|
|
109
|
+
let testFileCount = 0;
|
|
110
|
+
const ruleMarkers = new Set();
|
|
111
|
+
const budget = opts.fileBudget || 200;
|
|
112
|
+
let inspected = 0;
|
|
113
|
+
for (const it of files) {
|
|
114
|
+
if (!isCode(it.name)) continue;
|
|
115
|
+
codeCount++;
|
|
116
|
+
if (TEST_HINT_RE.test(it.name)) testFileCount++;
|
|
117
|
+
if (inspected++ >= budget) continue;
|
|
118
|
+
const buf = readIfExists(path.join(absDir, it.name), 64 * 1024);
|
|
119
|
+
if (!buf) continue;
|
|
120
|
+
const txt = buf.toString("utf8");
|
|
121
|
+
const ex = txt.match(/^\s*export\s/gm);
|
|
122
|
+
if (ex) exportCount += ex.length;
|
|
123
|
+
for (const [n, line] of txt.split("\n").entries()) {
|
|
124
|
+
if (!/\b(DO NOT|NEVER|ALWAYS|DEPRECATED|FIXME|HACK|XXX)\b/.test(line)) continue;
|
|
125
|
+
// Retain the subject and source location, not only the imperative fragment.
|
|
126
|
+
// These are excerpts to inspect, not independently verified project-wide rules.
|
|
127
|
+
const excerpt = line.trim();
|
|
128
|
+
ruleMarkers.add(`Source excerpt (${it.name}:${n + 1}; inspect in context): ${excerpt.slice(0, 300)}${excerpt.length > 300 ? " [excerpt truncated]" : ""}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const entryPoints = fileNames.filter((f) => ENTRY_RE.test(f));
|
|
132
|
+
const hasManifest = fileNames.some((f) => MANIFEST_NAMES.has(f)) || fileNames.includes("README.md");
|
|
133
|
+
const total = fileNames.length;
|
|
134
|
+
const codeRatio = total ? Math.min(1, codeCount / total) : 0;
|
|
135
|
+
return {
|
|
136
|
+
dir: absDir, rel: dirRel, depth: dirRel ? dirRel.split("/").length : 0,
|
|
137
|
+
fileCount: total, codeCount, subdirCount: subdirNames.length, subdirs: subdirNames,
|
|
138
|
+
codeRatio, entryPoints, exportCount, testFileCount,
|
|
139
|
+
ruleMarkers: [...ruleMarkers],
|
|
140
|
+
hasManifest,
|
|
141
|
+
manifestFiles: fileNames.filter((f) => MANIFEST_NAMES.has(f)),
|
|
142
|
+
fileNames,
|
|
143
|
+
centrality: computeCentrality(root, dirRel, opts.centralityBudget || 120),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Walk up to maxDepth collecting dir infos (bounded total dirs to keep inference cost low).
|
|
148
|
+
export function scanTree(root, opts = {}) {
|
|
149
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
150
|
+
const dirBudget = opts.dirBudget || 80;
|
|
151
|
+
const infos = [];
|
|
152
|
+
const stack = [root];
|
|
153
|
+
let budget = dirBudget;
|
|
154
|
+
while (stack.length && budget-- > 0) {
|
|
155
|
+
const d = stack.pop();
|
|
156
|
+
const relp = relSlash(root, d);
|
|
157
|
+
const depth = relp ? relp.split("/").length : 0;
|
|
158
|
+
if (depth > maxDepth) continue;
|
|
159
|
+
infos.push(scanDir(root, d, opts));
|
|
160
|
+
for (const sd of listDirs(d)) {
|
|
161
|
+
if (SKIP_DIRS.has(sd.name)) continue;
|
|
162
|
+
stack.push(path.join(d, sd.name));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
infos.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
166
|
+
return infos;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- Selection: which directories receive a (nested) AGENTS.md ---------------------------
|
|
170
|
+
export function isDistinctDomain(info) {
|
|
171
|
+
if (info.hasManifest) return true;
|
|
172
|
+
if (info.depth === 1 && info.testFileCount >= 1 && info.entryPoints.length >= 1) return true;
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function selectTargets(infos, opts = {}) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const info of infos) {
|
|
179
|
+
const { score, tier, reasons } = scoreDir(info);
|
|
180
|
+
const node = { rel: info.rel, dir: info.dir, depth: info.depth, score, tier, reasons };
|
|
181
|
+
if (info.rel === "") { node.selected = true; node.reason = "root always receives AGENTS.md"; out.push(node); continue; }
|
|
182
|
+
if (tier === "high") { node.selected = true; node.reason = "high complexity"; }
|
|
183
|
+
else if (tier === "medium" && isDistinctDomain(info)) { node.selected = true; node.reason = "medium + distinct domain (own boundary/tests)"; }
|
|
184
|
+
else if (tier === "medium") { node.selected = false; node.reason = "medium but not a distinct domain -> parent guidance sufficient"; }
|
|
185
|
+
else { node.selected = false; node.reason = "low complexity -> parent guidance sufficient"; }
|
|
186
|
+
out.push(node);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- AGENTS.md generation (root + nested) -------------------------------------------------
|
|
192
|
+
function section(title, lines) {
|
|
193
|
+
const body = lines.filter((l) => l && String(l).trim().length);
|
|
194
|
+
if (!body.length) return "";
|
|
195
|
+
return `## ${title}\n${body.map((l) => `- ${l}`).join("\n")}\n`;
|
|
196
|
+
}
|
|
197
|
+
function isGeneric(line) {
|
|
198
|
+
const l = line.toLowerCase();
|
|
199
|
+
return GENERIC_ADVICE.some((g) => l.includes(g)) || l.trim().length < 6;
|
|
200
|
+
}
|
|
201
|
+
function uniqueLines(arr) { const seen = new Set(); const out = []; for (const x of arr) { const k = x.trim().toLowerCase(); if (!k || seen.has(k)) continue; seen.add(k); out.push(x.trim()); } return out; }
|
|
202
|
+
function countCode(byRel, subdir) { let n = 0; for (const [rel, info] of byRel) if (rel.startsWith(subdir + "/") || rel === subdir) n += info.codeCount; return n; }
|
|
203
|
+
|
|
204
|
+
export function stripParentDuplicates(childLines, parentLines) {
|
|
205
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
206
|
+
const parentSet = new Set(parentLines.map(norm));
|
|
207
|
+
return childLines.filter((l) => !parentSet.has(norm(l)) && !isGeneric(l));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function wrapManaged(body) { return `${MANAGED_BEGIN}\n${body}${MANAGED_END}\n`; }
|
|
211
|
+
|
|
212
|
+
export function buildRootMemory(info, ctx) {
|
|
213
|
+
const lines = [];
|
|
214
|
+
lines.push(`# Project Memory (${ctx.harnessVersion})`);
|
|
215
|
+
lines.push(section("OVERVIEW", [
|
|
216
|
+
ctx.overview,
|
|
217
|
+
info.entryPoints.length ? `Entry points: ${info.entryPoints.join(", ")}` : null,
|
|
218
|
+
`Top-level modules: ${info.subdirs.join(", ") || "(flat)"}`,
|
|
219
|
+
]));
|
|
220
|
+
lines.push(section("STRUCTURE", info.subdirs.map((d) => `${d}/ — inspect AGENTS.md there if present`)));
|
|
221
|
+
lines.push(section("WHERE TO LOOK", ctx.whereToLook));
|
|
222
|
+
lines.push(section("CODE MAP", ctx.codeMap));
|
|
223
|
+
lines.push(section("PROJECT INVARIANTS", ctx.invariants));
|
|
224
|
+
lines.push(section("ANTI-PATTERNS", ctx.antiPatterns));
|
|
225
|
+
lines.push(section("COMMANDS", ctx.commands));
|
|
226
|
+
lines.push(section("VERIFICATION", ctx.verification));
|
|
227
|
+
lines.push(section("MEMORY CHILDREN", info.subdirs.filter((d) => ctx.childDirs && ctx.childDirs.includes(d))));
|
|
228
|
+
return wrapManaged(uniqueLines(lines).join("\n").trim() + "\n");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function buildChildMemory(info, ctx, parentLines = []) {
|
|
232
|
+
const all = [
|
|
233
|
+
`local overview: ${ctx.overview || ""}`,
|
|
234
|
+
...info.entryPoints.map((e) => `entry point: ${e}`),
|
|
235
|
+
...ctx.whereToLook, ...ctx.invariants, ...ctx.conventions, ...ctx.antiPatterns,
|
|
236
|
+
...ctx.tests.map((t) => `local tests: ${t}`),
|
|
237
|
+
];
|
|
238
|
+
const kept = stripParentDuplicates(all, parentLines).filter((l) => !isGeneric(l));
|
|
239
|
+
const lines = [`# ${info.rel} — local memory`, ""];
|
|
240
|
+
if (kept.length) lines.push(kept.map((l) => `- ${l}`).join("\n"));
|
|
241
|
+
else lines.push(`- responsibilities: ${info.fileCount} files, ${info.subdirCount} subdirs, ${info.codeCount} code files`);
|
|
242
|
+
return wrapManaged(uniqueLines(lines).join("\n").trim() + "\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Preserve human content: only the managed block is generated/updated. A file with no managed
|
|
246
|
+
// markers is pure human content (preserved unless regenerate is set).
|
|
247
|
+
export function mergeManaged(existing, newManaged, { regenerate = false } = {}) {
|
|
248
|
+
if (!existing) return { created: true, content: newManaged };
|
|
249
|
+
const has = existing.includes(MANAGED_BEGIN);
|
|
250
|
+
if (!has) {
|
|
251
|
+
if (regenerate) return { created: true, content: newManaged };
|
|
252
|
+
return { preserved: true, content: existing };
|
|
253
|
+
}
|
|
254
|
+
const i = existing.indexOf(MANAGED_BEGIN);
|
|
255
|
+
const end = existing.indexOf(MANAGED_END, i);
|
|
256
|
+
if (end < 0 || existing.indexOf(MANAGED_BEGIN, i + MANAGED_BEGIN.length) >= 0 ||
|
|
257
|
+
existing.indexOf(MANAGED_END, end + MANAGED_END.length) >= 0) {
|
|
258
|
+
return { preserved: true, content: existing, note: "ambiguous managed markers; preserved for review" };
|
|
259
|
+
}
|
|
260
|
+
const j = end + MANAGED_END.length;
|
|
261
|
+
const merged = existing.slice(0, i) + newManaged.trimEnd() + existing.slice(j);
|
|
262
|
+
return { updated: merged !== existing, unchanged: merged === existing, content: merged };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---- Memory index (.longrun/memory-index.json) --------------------------------------------
|
|
266
|
+
export function nodeSignature(root, info) {
|
|
267
|
+
const abs = path.join(root, info.rel);
|
|
268
|
+
const manifests = {};
|
|
269
|
+
for (const m of (info.manifestFiles || [])) {
|
|
270
|
+
const buf = readIfExists(path.join(abs, m));
|
|
271
|
+
if (buf) manifests[m] = sha256(buf);
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
rel: info.rel, fileCount: info.fileCount, subdirCount: info.subdirCount,
|
|
275
|
+
files: info.fileNames.filter(f => f !== "AGENTS.md").sort(), subdirs: [...info.subdirs].sort(),
|
|
276
|
+
codeRatio: Math.round((info.codeRatio || 0) * 100) / 100, hasManifest: !!info.hasManifest,
|
|
277
|
+
entryPoints: [...info.entryPoints].sort(), exportCount: info.exportCount,
|
|
278
|
+
testFileCount: info.testFileCount, rules: info.ruleMarkers.length, manifests,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function structuralDependencies(root) {
|
|
283
|
+
const dependencies = {};
|
|
284
|
+
const stack = [root];
|
|
285
|
+
let budget = 400;
|
|
286
|
+
while (stack.length && budget-- > 0) {
|
|
287
|
+
const d = stack.pop();
|
|
288
|
+
for (const f of listFiles(d)) {
|
|
289
|
+
if (MANIFEST_NAMES.has(f.name) || ENTRY_RE.test(f.name)) {
|
|
290
|
+
const buf = readIfExists(path.join(d, f.name), 64 * 1024);
|
|
291
|
+
dependencies[relSlash(root, path.join(d, f.name))] = buf ? sha256(buf).slice(0, 16) : "gone";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const sd of listDirs(d)) if (!SKIP_DIRS.has(sd.name)) stack.push(path.join(d, sd.name));
|
|
295
|
+
}
|
|
296
|
+
return { dependencies: Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b))), truncated: stack.length > 0 };
|
|
297
|
+
}
|
|
298
|
+
export function structuralSignature(root) {
|
|
299
|
+
return sha256(Object.entries(structuralDependencies(root).dependencies).map(([p, hash]) => `${p}:${hash}`).sort().join("\n"));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function buildMemoryIndex(root, infos, targets, { commit = null, harnessVersion = "", generatedAt = Date.now() } = {}) {
|
|
303
|
+
const byRel = new Map(infos.map((i) => [i.rel, i]));
|
|
304
|
+
const nodes = targets.map((t) => {
|
|
305
|
+
const info = byRel.get(t.rel);
|
|
306
|
+
return { rel: t.rel, selected: !!t.selected, tier: t.tier, score: t.score, reasons: t.reasons, signature: nodeSignature(root, info) };
|
|
307
|
+
});
|
|
308
|
+
return {
|
|
309
|
+
schemaVersion: SCHEMA_VERSION, memorySchemaVersion: SCHEMA_VERSION, harnessVersion, generatedByHarnessVersion: harnessVersion, generatedAt,
|
|
310
|
+
commit: commit || null, maxDepth: DEFAULT_MAX_DEPTH,
|
|
311
|
+
structuralKey: structuralSignature(root), structuralDependencies: structuralDependencies(root).dependencies, nodes,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export const MEMORY_PATH = ".longrun/memory-index.json";
|
|
316
|
+
export function readMemoryIndex(root) {
|
|
317
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, MEMORY_PATH), "utf8")); } catch { return null; }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- Staleness assessment (no full rewrite on ordinary edits) -----------------------------
|
|
321
|
+
export function assessStaleness(root, index) {
|
|
322
|
+
if (!index) return { status: "NO_MEMORY", reasons: ["no memory index; run init-deep"], perNode: {} };
|
|
323
|
+
const reasons = [];
|
|
324
|
+
const perNode = {};
|
|
325
|
+
const changedDependencies = [];
|
|
326
|
+
const memorySchemaVersion = index.memorySchemaVersion ?? index.schemaVersion;
|
|
327
|
+
let worst = "FRESH";
|
|
328
|
+
const bump = (s) => { const order = { FRESH: 0, POSSIBLY_STALE: 1, STALE: 2 }; if (order[s] > order[worst]) worst = s; };
|
|
329
|
+
if (memorySchemaVersion !== SCHEMA_VERSION) {
|
|
330
|
+
bump("STALE"); reasons.push("incompatible memory schema; refresh required");
|
|
331
|
+
changedDependencies.push({ path: `${MEMORY_PATH}#memorySchemaVersion`, previousFingerprint: memorySchemaVersion, currentFingerprint: SCHEMA_VERSION, reason: "MEMORY_SCHEMA_INCOMPATIBLE" });
|
|
332
|
+
}
|
|
333
|
+
const current = structuralDependencies(root);
|
|
334
|
+
const curKey = structuralSignature(root);
|
|
335
|
+
if (current.truncated) {
|
|
336
|
+
bump("POSSIBLY_STALE"); reasons.push("structural scan budget exceeded");
|
|
337
|
+
changedDependencies.push({ path: ".", reason: "STRUCTURAL_SCAN_INCOMPLETE", previousFingerprint: index.structuralKey, currentFingerprint: curKey });
|
|
338
|
+
}
|
|
339
|
+
if (index.structuralKey && curKey !== index.structuralKey) {
|
|
340
|
+
bump("STALE"); reasons.push("structural files (manifests/entry points) changed since memory was generated");
|
|
341
|
+
if (index.structuralDependencies) {
|
|
342
|
+
for (const p of [...new Set([...Object.keys(index.structuralDependencies), ...Object.keys(current.dependencies)])].sort()) {
|
|
343
|
+
if (index.structuralDependencies[p] !== current.dependencies[p]) changedDependencies.push({ path: p, previousFingerprint: index.structuralDependencies[p] || null, currentFingerprint: current.dependencies[p] || null, reason: "STRUCTURAL_DEPENDENCY_CHANGED" });
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
// Old schema stored only a digest. The changed path cannot be recovered from a hash.
|
|
347
|
+
changedDependencies.push({ path: `${MEMORY_PATH}#structuralKey`, previousFingerprint: index.structuralKey,
|
|
348
|
+
currentFingerprint: curKey, reason: "LEGACY_STRUCTURAL_DIGEST_CHANGED", exactPathsKnown: false,
|
|
349
|
+
note: "legacy index lacks per-path entry-point hashes; changed path cannot be reconstructed; refresh records a path baseline" });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const node of index.nodes || []) {
|
|
353
|
+
if (!node.selected) continue;
|
|
354
|
+
const abs = path.join(root, node.rel);
|
|
355
|
+
if (!fs.existsSync(abs)) { perNode[node.rel] = { status: "STALE", reasons: ["directory removed"] }; bump("STALE"); continue; }
|
|
356
|
+
const info = scanDir(root, abs);
|
|
357
|
+
const sig = nodeSignature(root, info);
|
|
358
|
+
const old = node.signature || {};
|
|
359
|
+
const why = [];
|
|
360
|
+
const nodeChanges = [];
|
|
361
|
+
if ((old.hasManifest ?? false) !== sig.hasManifest) why.push("manifest presence changed");
|
|
362
|
+
const oldM = Object.keys(old.manifests || {}).sort(); const curM = Object.keys(sig.manifests || {}).sort();
|
|
363
|
+
if (oldM.join(",") !== curM.join(",")) why.push("manifest set changed (package add/remove/rename)");
|
|
364
|
+
else for (const m of curM) if (old.manifests[m] !== sig.manifests[m]) why.push(`manifest content changed: ${m}`);
|
|
365
|
+
if ((old.entryPoints || []).join(",") !== sig.entryPoints.join(",")) why.push("entry points changed");
|
|
366
|
+
const dFile = Math.abs((old.fileCount || 0) - sig.fileCount);
|
|
367
|
+
const dExport = Math.abs((old.exportCount || 0) - sig.exportCount);
|
|
368
|
+
if (Math.abs((old.subdirCount || 0) - sig.subdirCount) >= 2) why.push("directory topology changed materially");
|
|
369
|
+
if (dExport >= 5) why.push(`public API shifted (export count ${old.exportCount}->${sig.exportCount})`);
|
|
370
|
+
for (const dep of changedDependencies) {
|
|
371
|
+
if (dep.reason === "STRUCTURAL_DEPENDENCY_CHANGED" && path.posix.dirname(dep.path) === (node.rel || ".")) {
|
|
372
|
+
why.push(`structural dependency changed: ${dep.path}`); nodeChanges.push(dep);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (why.length || dFile >= 5) nodeChanges.push({ path: node.rel || ".", reason: "NODE_SIGNATURE_CHANGED", previousFingerprint: sha256(JSON.stringify(old)), currentFingerprint: sha256(JSON.stringify(sig)), previousSignature: old, currentSignature: sig });
|
|
376
|
+
changedDependencies.push(...nodeChanges.filter(d => !changedDependencies.includes(d)));
|
|
377
|
+
if (why.length) { perNode[node.rel] = { status: "STALE", reasons: why }; bump("STALE"); }
|
|
378
|
+
else if (dFile >= 5) { perNode[node.rel] = { status: "POSSIBLY_STALE", reasons: [`file count drift ${old.fileCount}->${sig.fileCount} (no structural change)`] }; bump("POSSIBLY_STALE"); }
|
|
379
|
+
else perNode[node.rel] = { status: "FRESH", reasons: [] };
|
|
380
|
+
}
|
|
381
|
+
return { status: worst, reasons, perNode, changedDependencies, memorySchemaVersion,
|
|
382
|
+
generatedByHarnessVersion: index.generatedByHarnessVersion ?? index.harnessVersion ?? null,
|
|
383
|
+
structuralFingerprint: { previous: index.structuralKey || null, current: curKey },
|
|
384
|
+
reloadableNodes: (index.nodes || []).filter(n => n.selected).map(n => path.posix.join(n.rel, "AGENTS.md")) };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Derive real project commands from manifests (never invents them). Used for root COMMANDS +
|
|
388
|
+
// VERIFICATION so generated memory points at commands that actually exist.
|
|
389
|
+
export function detectCommands(root) {
|
|
390
|
+
const cmds = [];
|
|
391
|
+
const testHints = [];
|
|
392
|
+
const pj = path.join(root, "package.json");
|
|
393
|
+
const buf = readIfExists(pj);
|
|
394
|
+
if (buf) {
|
|
395
|
+
try {
|
|
396
|
+
const obj = JSON.parse(buf.toString("utf8"));
|
|
397
|
+
for (const [k, v] of Object.entries(obj.scripts || {})) cmds.push(`npm run ${k} — ${v}`);
|
|
398
|
+
if (/\b(node --test|jest|vitest|mocha|playwright|cypress|cypress)/i.test(JSON.stringify(obj))) testHints.push("node test runner present (node --test/jest/vitest/playwright)");
|
|
399
|
+
} catch {}
|
|
400
|
+
}
|
|
401
|
+
if (fs.existsSync(path.join(root, "Makefile"))) { try { const mk = fs.readFileSync(path.join(root, "Makefile"), "utf8"); for (const m of mk.matchAll(/^([a-zA-Z0-9_-]+):/gm)) cmds.push(`make ${m[1]}`); } catch {} }
|
|
402
|
+
if (fs.existsSync(path.join(root, "go.mod"))) { cmds.push("go build ./..."); cmds.push("go test ./..."); testHints.push("go test"); }
|
|
403
|
+
if (fs.existsSync(path.join(root, "Cargo.toml"))) { cmds.push("cargo build"); cmds.push("cargo test"); testHints.push("cargo test"); }
|
|
404
|
+
return { cmds: cmds.slice(0, 12), testHints };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---- init-deep orchestration --------------------------------------------------------------
|
|
408
|
+
export function initDeep(root, opts = {}) {
|
|
409
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
410
|
+
const dryRun = !!opts.dryRun;
|
|
411
|
+
const regenerate = !!opts.regenerate;
|
|
412
|
+
const created = [], updated = [], unchanged = [], preserved = [], skipped = [];
|
|
413
|
+
const infos = scanTree(root, { maxDepth, ...opts });
|
|
414
|
+
const targets = selectTargets(infos, { maxDepth });
|
|
415
|
+
const byRel = new Map(infos.map((i) => [i.rel, i]));
|
|
416
|
+
const childDirs = targets.filter((t) => t.selected && t.rel !== "").map((t) => t.rel.split("/")[0]);
|
|
417
|
+
const detected = opts.detectCommands === false ? { cmds: [], testHints: [] } : detectCommands(root);
|
|
418
|
+
|
|
419
|
+
for (const t of targets) {
|
|
420
|
+
if (!t.selected) { skipped.push({ rel: t.rel, reason: t.reason, tier: t.tier, score: t.score }); continue; }
|
|
421
|
+
const info = byRel.get(t.rel);
|
|
422
|
+
const agPath = path.join(t.dir, "AGENTS.md");
|
|
423
|
+
const isRoot = t.rel === "";
|
|
424
|
+
let body;
|
|
425
|
+
if (isRoot) {
|
|
426
|
+
const ctx = {
|
|
427
|
+
harnessVersion: opts.harnessVersion || "",
|
|
428
|
+
overview: info.entryPoints.length ? `Entry points: ${info.entryPoints.join(", ")}. Top-level modules: ${info.subdirs.join(", ") || "(flat)"}.` : `Top-level modules: ${info.subdirs.join(", ") || "(flat)"}.`,
|
|
429
|
+
whereToLook: info.subdirs.map((d) => `${d}/: inspect AGENTS.md in ${d}/ if present`),
|
|
430
|
+
codeMap: info.subdirs.map((d) => `${d}/: ${countCode(byRel, d)} code files`),
|
|
431
|
+
invariants: info.ruleMarkers.slice(0, 8),
|
|
432
|
+
antiPatterns: [], commands: (opts.commands || []).concat(detected.cmds), verification: detected.testHints.slice(0, 6), childDirs,
|
|
433
|
+
};
|
|
434
|
+
body = buildRootMemory(info, ctx);
|
|
435
|
+
} else {
|
|
436
|
+
const parentDir = path.dirname(t.rel) ? path.join(root, path.dirname(t.rel)) : root;
|
|
437
|
+
const parentText = readIfExists(path.join(parentDir, "AGENTS.md"));
|
|
438
|
+
const parentLines = parentText ? parentText.toString("utf8").split("\n").map((s) => s.replace(/^- /, "").trim()).filter(Boolean) : [];
|
|
439
|
+
const ctx = {
|
|
440
|
+
overview: `${info.rel}: ${info.fileCount} files, ${info.codeCount} code, ${info.testFileCount} test files.`,
|
|
441
|
+
whereToLook: info.entryPoints.length ? [`entry points: ${info.entryPoints.join(", ")}`] : [],
|
|
442
|
+
invariants: info.ruleMarkers.slice(0, 6), conventions: [], antiPatterns: [],
|
|
443
|
+
tests: [...new Set(info.fileNames.filter((f) => TEST_HINT_RE.test(f)))].slice(0, 8),
|
|
444
|
+
};
|
|
445
|
+
body = buildChildMemory(info, ctx, parentLines);
|
|
446
|
+
}
|
|
447
|
+
let action = "created", out = body, note = "";
|
|
448
|
+
const existing = readIfExists(agPath);
|
|
449
|
+
if (existing) {
|
|
450
|
+
const m = mergeManaged(existing.toString("utf8"), body, { regenerate });
|
|
451
|
+
if (m.preserved) { action = "preserved"; note = m.note || "human content without managed markers; use regenerate=true to replace"; }
|
|
452
|
+
else if (m.unchanged) { action = "unchanged"; out = m.content; }
|
|
453
|
+
else if (m.updated) { action = "updated"; out = m.content; }
|
|
454
|
+
}
|
|
455
|
+
const entry = { rel: t.rel, path: agPath, action, tier: t.tier, score: t.score, reasons: t.reasons, note };
|
|
456
|
+
if (action === "created") created.push(entry); else if (action === "updated") updated.push(entry); else if (action === "unchanged") unchanged.push(entry); else if (action === "preserved") preserved.push(entry);
|
|
457
|
+
if (!dryRun && action !== "preserved" && action !== "unchanged") {
|
|
458
|
+
try { fs.mkdirSync(path.dirname(agPath), { recursive: true }); if (!existing || existing.toString("utf8") !== out) fs.writeFileSync(agPath, out); } catch {}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const commit = gitHeadAt(root);
|
|
462
|
+
const index = buildMemoryIndex(root, scanTree(root, { maxDepth, ...opts }), targets, { commit, harnessVersion: opts.harnessVersion || "" });
|
|
463
|
+
index.maxDepth = maxDepth;
|
|
464
|
+
const indexPath = path.join(root, MEMORY_PATH);
|
|
465
|
+
const oldIndex = readMemoryIndex(root);
|
|
466
|
+
if (oldIndex && JSON.stringify({ ...oldIndex, generatedAt: 0 }) === JSON.stringify({ ...index, generatedAt: 0 })) index.generatedAt = oldIndex.generatedAt;
|
|
467
|
+
const indexText = JSON.stringify(index, null, 2);
|
|
468
|
+
if (!dryRun && readIfExists(indexPath)?.toString("utf8") !== indexText) {
|
|
469
|
+
try { fs.mkdirSync(path.dirname(indexPath), { recursive: true }); fs.writeFileSync(indexPath, indexText); } catch {}
|
|
470
|
+
}
|
|
471
|
+
return { dryRun, regenerate, created, updated, unchanged, preserved, skipped, index, indexPath, willWrite: [...created, ...updated].map((e) => e.path) };
|
|
472
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import * as C from "../src/controller.js";
|
|
4
|
+
|
|
5
|
+
function mkRun(fp = "fpA") {
|
|
6
|
+
return C.startRun({
|
|
7
|
+
request: "task",
|
|
8
|
+
contract: { criteria: [{ id: "c1", required: true, checks: ["t"], status: "FAIL", weight: 1 }, { id: "c2", required: true, checks: ["t"], status: "FAIL", weight: 1 }], gates: [], lossTarget: 0 },
|
|
9
|
+
budgets: C.defaultBudget(), sourceFingerprint: fp,
|
|
10
|
+
}).run;
|
|
11
|
+
}
|
|
12
|
+
function receipt(fp, status = "PASS", testCount = 2) {
|
|
13
|
+
return C.makeReceipt({ checkId: "t", command: "node run.mjs", exitCode: status === "PASS" ? 0 : 1, testCount, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: fp });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---- unchanged source + repeated test does NOT create a new candidate ----------------------
|
|
17
|
+
test("unchanged-source retries are not candidates (only the first real eval counts)", () => {
|
|
18
|
+
const run = mkRun("fpA");
|
|
19
|
+
// first evaluation at a NEW state (lastEvalFingerprint was seeded to the start fingerprint)
|
|
20
|
+
const first = C.applyVerification(run, { receipt: receipt("fpZ"), canChangeAcceptance: true });
|
|
21
|
+
assert.equal(first.candidate.counted, true, "first evaluated new source counts");
|
|
22
|
+
const second = C.applyVerification(run, { receipt: receipt("fpZ"), canChangeAcceptance: true });
|
|
23
|
+
assert.equal(second.candidate.counted, false);
|
|
24
|
+
assert.equal(second.candidate.reason, "unchanged_source_retry");
|
|
25
|
+
assert.equal(C.candidateCount(run), 1, "exactly one candidate after a repeat");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ---- evaluated CHANGED source creates exactly ONE candidate --------------------------------
|
|
29
|
+
test("each distinct changed source yields exactly one candidate", () => {
|
|
30
|
+
const run = mkRun("fpA");
|
|
31
|
+
C.applyVerification(run, { receipt: receipt("fpB"), canChangeAcceptance: true });
|
|
32
|
+
const two = C.applyVerification(run, { receipt: receipt("fpC"), canChangeAcceptance: true });
|
|
33
|
+
assert.equal(two.candidate.counted, true);
|
|
34
|
+
assert.equal(C.candidateCount(run), 2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ---- pure inspection / diagnostic-only are not candidates ----------------------------------
|
|
38
|
+
test("pure inspection and diagnostic-only runs are not candidates", () => {
|
|
39
|
+
const run = mkRun("fpA");
|
|
40
|
+
const insp = C.evidence.considerCandidate(run, { fingerprint: "fpX", evaluated: false });
|
|
41
|
+
assert.equal(insp.counted, false); assert.equal(insp.reason, "inspection_not_evaluation");
|
|
42
|
+
const diag = C.evidence.considerCandidate(run, { fingerprint: "fpY", evaluated: true, diagnosticOnly: true });
|
|
43
|
+
assert.equal(diag.counted, false); assert.equal(diag.reason, "diagnostic_only");
|
|
44
|
+
assert.equal(C.candidateCount(run), 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---- counters survive compaction/restart/resume (serialized state round-trips) -------------
|
|
48
|
+
test("candidate count + state survive a serialize -> reload (compaction/session boundary)", () => {
|
|
49
|
+
const run = mkRun("fpA");
|
|
50
|
+
C.applyVerification(run, { receipt: receipt("fpB"), canChangeAcceptance: true });
|
|
51
|
+
C.applyVerification(run, { receipt: receipt("fpC"), canChangeAcceptance: true });
|
|
52
|
+
const countBefore = C.candidateCount(run);
|
|
53
|
+
const serialized = JSON.parse(JSON.stringify(run)); // survive a persistence round-trip
|
|
54
|
+
assert.equal(C.candidateCount(serialized), countBefore, "count persists across reload");
|
|
55
|
+
assert.equal(countBefore, 2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---- loss/current/best stay associated with the correct candidate --------------------------
|
|
59
|
+
test("best/current loss is associated with the right candidate and not mixed up", () => {
|
|
60
|
+
const run = mkRun("fpA");
|
|
61
|
+
const a = C.applyVerification(run, { receipt: receipt("fpB", "FAIL"), canChangeAcceptance: true });
|
|
62
|
+
assert.equal(a.candidate.lossAfter, 1, "nothing passed yet -> full loss");
|
|
63
|
+
assert.equal(a.candidate.result, "FAIL");
|
|
64
|
+
// two more real evals (each a distinct candidate) improving loss
|
|
65
|
+
const b = C.applyVerification(run, { receipt: receipt("fpC", "PASS"), canChangeAcceptance: true });
|
|
66
|
+
const c = C.applyVerification(run, { receipt: receipt("fpD", "PASS"), canChangeAcceptance: true });
|
|
67
|
+
assert.ok(c.candidate.n > b.candidate.n, "candidate numbering is monotonic");
|
|
68
|
+
assert.ok(run.state.best, "a best candidate was tracked");
|
|
69
|
+
assert.equal(run.state.best.result, "PASS", "best is a PASS, never the FAIL");
|
|
70
|
+
assert.ok(run.state.best.lossAfter <= run.state.current.lossAfter, "best is not worse than current");
|
|
71
|
+
// candidate count must equal distinct counted states, never tool-call count
|
|
72
|
+
assert.equal(C.candidateCount(run), 3);
|
|
73
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import * as C from "../src/controller.js";
|
|
4
|
+
|
|
5
|
+
function mkRun() {
|
|
6
|
+
const run = C.startRun({
|
|
7
|
+
request: "Build the feature",
|
|
8
|
+
contract: { criteria: [{ id: "c1", required: true, checks: ["t"], status: "FAIL" }, { id: "c2", required: true, checks: ["t"], status: "FAIL", evidenceClass: "BROWSER", visual: true }], gates: [], lossTarget: 0 },
|
|
9
|
+
budgets: C.defaultBudget(), sourceFingerprint: "fpA",
|
|
10
|
+
}).run;
|
|
11
|
+
C.applyVerification(run, { receipt: C.makeReceipt({ checkId: "t", command: "x", exitCode: 0, testCount: 2, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: "fpB" }) });
|
|
12
|
+
C.applyVerification(run, { receipt: C.makeReceipt({ checkId: "t", command: "x", exitCode: 0, testCount: 2, requirementKind: "test", startedAt: 1, finishedAt: 2, sourceFingerprint: "fpC", evidenceClass: "UNIT" }) });
|
|
13
|
+
run.state.failedHypotheses = ["assumed the in-memory store was enough"];
|
|
14
|
+
run.state.nextAction = "add real persistence then re-run c1";
|
|
15
|
+
run.state.currentSlice = "persistence";
|
|
16
|
+
run.state.memoryNodes = ["AGENTS.md", "packages/core/AGENTS.md"];
|
|
17
|
+
return run;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---- recovery reconstructs state from AUTHORITATIVE records --------------------------------
|
|
21
|
+
test("resume packet is rebuilt from authoritative state (not from prose)", () => {
|
|
22
|
+
const run = mkRun();
|
|
23
|
+
const { packet } = C.buildRecoveryPacket(run);
|
|
24
|
+
assert.match(packet, /CANDIDATES: 2\/40/, "exact candidate count included");
|
|
25
|
+
assert.match(packet, /NEXT ACTION: add real persistence/, "exact next action survives");
|
|
26
|
+
assert.match(packet, /UNSUCCESSFUL APPROACHES: assumed the in-memory store/, "disproved approaches survive");
|
|
27
|
+
assert.match(packet, /MEMORY NODES.*packages\/core\/AGENTS\.md/, "memory node references survive (lazy reload)");
|
|
28
|
+
assert.match(packet, /BUDGETS \(persisted\): iters=/, "budget counters survive");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ---- does NOT recursively summarize previous summaries -------------------------------------
|
|
32
|
+
test("a prior summary embedded in state is NOT echoed (no summary-of-summary)", () => {
|
|
33
|
+
const run = mkRun();
|
|
34
|
+
run.state.summary = "PRIOR_SUMMARY_MARKER: this old compaction summary should not be re-summarized verbatim into the packet";
|
|
35
|
+
const { packet } = C.buildRecoveryPacket(run);
|
|
36
|
+
assert.ok(!packet.includes("PRIOR_SUMMARY_MARKER"), "regenerates from source-of-truth, not prior summaries");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ---- retains budgets + exact candidate count + best loss -----------------------------------
|
|
40
|
+
test("recovery retains budgets, exact candidate count and best-vs-current loss", () => {
|
|
41
|
+
const run = mkRun();
|
|
42
|
+
const { packet } = C.buildRecoveryPacket(run);
|
|
43
|
+
assert.match(packet, /BEST LOSS:/, "best loss present");
|
|
44
|
+
assert.match(packet, /CURRENT LOSS:/, "current loss present");
|
|
45
|
+
assert.match(packet, /CANDIDATES: 2\/40/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---- identifies a stale source fingerprint --------------------------------------------------
|
|
49
|
+
test("recovery flags a stale source fingerprint so it is re-checked, not trusted", () => {
|
|
50
|
+
const run = mkRun();
|
|
51
|
+
run.sourceFingerprintStale = true;
|
|
52
|
+
const { packet } = C.buildRecoveryPacket(run);
|
|
53
|
+
assert.match(packet, /SOURCE FINGERPRINT:.*STALE/, "stale fingerprint explicitly flagged");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---- candidate count + state persist across a serialize/reload boundary --------------------
|
|
57
|
+
test("recovery after a serialize->reload reconstructs the SAME exact candidate count", t => {
|
|
58
|
+
const run = mkRun();
|
|
59
|
+
const observedAt = Date.now(); t.mock.method(Date, "now", () => observedAt); // compare two reconstructions at the same instant
|
|
60
|
+
const copy = JSON.parse(JSON.stringify(run));
|
|
61
|
+
const a = C.buildRecoveryPacket(run).packet;
|
|
62
|
+
const b = C.buildRecoveryPacket(copy).packet;
|
|
63
|
+
assert.equal(a, b, "packet regenerates identically from reconstructed state");
|
|
64
|
+
assert.equal(C.candidateCount(copy), C.candidateCount(run));
|
|
65
|
+
});
|