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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +390 -0
  3. package/docs/V1.2.20_EVIDENCE.md +114 -0
  4. package/docs/V1.2.21_EVIDENCE.md +68 -0
  5. package/docs/V1.2.22_EVIDENCE.md +52 -0
  6. package/harness/commissioning/README.md +16 -0
  7. package/harness/commissioning/inspect-copied-run.mjs +25 -0
  8. package/harness/commissioning/verify-copied-case.mjs +35 -0
  9. package/harness/plugin/longrun.js +677 -0
  10. package/harness/src/cli.mjs +40 -0
  11. package/harness/src/controller.js +1413 -0
  12. package/harness/src/evidence.mjs +135 -0
  13. package/harness/src/execution.mjs +217 -0
  14. package/harness/src/executor.mjs +21 -0
  15. package/harness/src/install.mjs +435 -0
  16. package/harness/src/maintenance.mjs +257 -0
  17. package/harness/src/memory.mjs +472 -0
  18. package/harness/test/candidates.test.mjs +73 -0
  19. package/harness/test/checkpoint.test.mjs +65 -0
  20. package/harness/test/controller.test.mjs +230 -0
  21. package/harness/test/evidence.test.mjs +57 -0
  22. package/harness/test/fixtures/durable-host.mjs +27 -0
  23. package/harness/test/fixtures/example-app-run.json +1375 -0
  24. package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
  25. package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
  26. package/harness/test/fixtures/notes-recovery-run.json +622 -0
  27. package/harness/test/fixtures/presets-readout-run.json +825 -0
  28. package/harness/test/fixtures/routing-worker.mjs +35 -0
  29. package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
  30. package/harness/test/helper.mjs +41 -0
  31. package/harness/test/install.test.mjs +117 -0
  32. package/harness/test/lifecycle.test.mjs +102 -0
  33. package/harness/test/maintenance.test.mjs +204 -0
  34. package/harness/test/memory.test.mjs +145 -0
  35. package/harness/test/negative-control.test.mjs +91 -0
  36. package/harness/test/plugin.test.mjs +169 -0
  37. package/harness/test/recovery-runner.test.mjs +435 -0
  38. package/harness/test/recovery.test.mjs +68 -0
  39. package/harness/test/repair-mechanics.test.mjs +122 -0
  40. package/harness/test/toolbehavior.test.mjs +75 -0
  41. package/harness/test/v121-commissioning.test.mjs +177 -0
  42. package/harness/test/v1210-deadline.test.mjs +134 -0
  43. package/harness/test/v1211-pause.test.mjs +81 -0
  44. package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
  45. package/harness/test/v1213-readout.test.mjs +82 -0
  46. package/harness/test/v1214-durable.test.mjs +121 -0
  47. package/harness/test/v1215-guidance.test.mjs +57 -0
  48. package/harness/test/v1216-test-summary.test.mjs +39 -0
  49. package/harness/test/v1217-discovery.test.mjs +73 -0
  50. package/harness/test/v1218-completion-review.test.mjs +203 -0
  51. package/harness/test/v1219-budget-pause.test.mjs +134 -0
  52. package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
  53. package/harness/test/v1220-budget-amendment.test.mjs +343 -0
  54. package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
  55. package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
  56. package/harness/test/v123-plugin-e2e.test.mjs +120 -0
  57. package/harness/test/v123-receipt-model.test.mjs +185 -0
  58. package/harness/test/v124-canonical.test.mjs +147 -0
  59. package/harness/test/v124-installed.test.mjs +48 -0
  60. package/harness/test/v125-stability.test.mjs +183 -0
  61. package/harness/test/v126-execution.test.mjs +183 -0
  62. package/harness/test/v127-reconciliation.test.mjs +139 -0
  63. package/harness/test/v128-compaction.test.mjs +156 -0
  64. package/harness/test/v129-routing.test.mjs +165 -0
  65. package/harness/tools/audit-receipts.mjs +121 -0
  66. package/harness/tools/recovery-runner.mjs +499 -0
  67. package/package.json +49 -0
@@ -0,0 +1,145 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import * as M from "../src/memory.mjs";
7
+
8
+ const mk = (...p) => { const d = path.join(...p); fs.mkdirSync(d, { recursive: true }); return d; };
9
+ const tmp = (p = "mem") => fs.mkdtempSync(path.join(os.tmpdir(), p + "-"));
10
+ const write = (p, s) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, s); };
11
+
12
+ // ---- tiny repo -> root only (do not create unnecessary hierarchy) --------------------------
13
+ test("tiny repo: init-deep creates ONLY the root AGENTS.md", () => {
14
+ const d = tmp();
15
+ write(path.join(d, "main.cjs"), "module.exports=1;");
16
+ const sel = M.selectTargets(M.scanTree(d));
17
+ assert.deepEqual(sel.filter((t) => t.selected).map((t) => t.rel), [""], "only root selected");
18
+ const r = M.initDeep(d, { harnessVersion: "1.2.0" });
19
+ assert.deepEqual(r.created.map((c) => c.rel).sort(), [""], "only root written");
20
+ assert.ok(fs.existsSync(path.join(d, "AGENTS.md")));
21
+ });
22
+
23
+ // ---- monorepo -> appropriate nested memory --------------------------------------------------
24
+ test("monorepo: high-complexity package gets a child AGENTS.md", () => {
25
+ const d = tmp("mono");
26
+ for (let i = 0; i < 14; i++) write(path.join(d, "packages", "core", "f" + i + ".ts"), "export const v" + i + " = 1;\n// NEVER inline secrets\n");
27
+ write(path.join(d, "packages", "core", "index.ts"), "export * from './f0';\n");
28
+ write(path.join(d, "packages", "core", "core.test.ts"), "//test\n");
29
+ write(path.join(d, "packages", "core", "package.json"), JSON.stringify({ name: "core" }));
30
+ write(path.join(d, "package.json"), JSON.stringify({ name: "mono", scripts: { test: "node --test" } }));
31
+ const created = M.initDeep(d, {}).created.map((c) => c.rel).sort();
32
+ assert.ok(created.includes(""), "root present");
33
+ assert.ok(created.includes("packages/core"), "packages/core got child memory");
34
+ });
35
+
36
+ // ---- child must NOT merely repeat parent ----------------------------------------------------
37
+ test("child memory does not repeat parent content and contains no generic advice", () => {
38
+ const d = tmp("dedup");
39
+ // root has a broad structure section; child should not echo it verbatim, no generic advice.
40
+ write(path.join(d, "AGENTS.md"), "## STRUCTURE\n- top: layout of everything at the repo root for overview purposes\n");
41
+ for (let i = 0; i < 8; i++) write(path.join(d, "svc", "f" + i + ".ts"), "export const q = 1;");
42
+ write(path.join(d, "svc", "package.json"), JSON.stringify({ name: "svc" }));
43
+ const r = M.initDeep(d, { regenerate: true }); // regenerate so the child gets generated
44
+ const childPath = path.join(d, "svc", "AGENTS.md");
45
+ const child = fs.readFileSync(childPath, "utf8");
46
+ assert.ok(!/generic|write clean code|follow best practices|keep it simple/i.test(child), "no generic advice in child");
47
+ assert.ok(!child.includes("layout of everything at the repo root for overview purposes"), "child does not copy the parent's overview line");
48
+ assert.ok(r.created.some((c) => c.rel === "svc"));
49
+ });
50
+
51
+ // ---- high selected, low skipped -------------------------------------------------------------
52
+ test("high-complexity dir selected; low-complexity dir skipped (parent sufficient)", () => {
53
+ const d = tmp("tier");
54
+ for (let i = 0; i < 20; i++) write(path.join(d, "big", "f" + i + ".ts"), "export const v=1;\n// ALWAYS validate\n");
55
+ write(path.join(d, "big", "package.json"), JSON.stringify({ name: "big" }));
56
+ write(path.join(d, "big", "index.ts"), "//entry");
57
+ write(path.join(d, "big", "a.test.ts"), "//t");
58
+ write(path.join(d, "tiny", "single.ts"), "x=1;");
59
+ const sel = Object.fromEntries(M.selectTargets(M.scanTree(d)).filter((t) => t.rel && !t.rel.includes("/")).map((t) => [t.rel, { s: t.selected, tier: t.tier }]));
60
+ assert.equal(sel.big.s, true, "big selected"); assert.equal(sel.big.tier, "high");
61
+ assert.equal(sel.tiny.s, false, "tiny skipped"); assert.equal(sel.tiny.tier, "low");
62
+ });
63
+
64
+ // ---- existing human AGENTS.md preserved -----------------------------------------------------
65
+ test("existing human-written AGENTS.md (no managed markers) is preserved, not overwritten", () => {
66
+ const d = tmp("preserve");
67
+ for (let i = 0; i < 12; i++) write(path.join(d, "a" + i + ".ts"), "export const v=1;");
68
+ write(path.join(d, "AGENTS.md"), "# My hand-written notes\nIMPORTANT: never edit generated files.\n");
69
+ const r = M.initDeep(d, {});
70
+ const content = fs.readFileSync(path.join(d, "AGENTS.md"), "utf8");
71
+ assert.match(content, /My hand-written notes/, "human content preserved");
72
+ assert.ok(r.preserved.some((p) => p.rel === ""), "reported as preserved");
73
+ assert.ok(!r.created.some((c) => c.rel === ""), "not recreated");
74
+ });
75
+
76
+ // ---- managed-section refresh (incremental, not wholesale) ------------------------------------
77
+ test("incremental refresh updates the managed block but keeps human prose outside it", () => {
78
+ const d = tmp("refresh");
79
+ for (let i = 0; i < 12; i++) write(path.join(d, "a" + i + ".ts"), "export const v=1;");
80
+ write(path.join(d, "AGENTS.md"), "Human preamble: talk to alice before changing.\n" + M.MANAGED_BEGIN + "\nstale content\n" + M.MANAGED_END + "\nHuman epilogue: run make fmt.\n");
81
+ const r = M.initDeep(d, {});
82
+ const content = fs.readFileSync(path.join(d, "AGENTS.md"), "utf8");
83
+ assert.match(content, /talk to alice before changing/, "human preamble kept");
84
+ assert.match(content, /run make fmt/, "human epilogue kept");
85
+ assert.ok(!content.includes("stale content"), "stale managed block replaced");
86
+ assert.equal(r.updated.find((c) => c.rel === "").action, "updated");
87
+ });
88
+
89
+ // ---- staleness: ordinary source edit does not stale all memory ------------------------------
90
+ test("ordinary body edit does not mark all memory stale", () => {
91
+ const d = tmp("fresh");
92
+ for (let i = 0; i < 10; i++) write(path.join(d, "src", "f" + i + ".ts"), "export const v=1;");
93
+ write(path.join(d, "index.ts"), "export * from './src/f0';");
94
+ write(path.join(d, "package.json"), JSON.stringify({ name: "x", scripts: { test: "node --test" } }));
95
+ M.initDeep(d, {});
96
+ const idx = M.readMemoryIndex(d);
97
+ fs.writeFileSync(path.join(d, "src", "f3.ts"), "export const v=changed-but-same-shape;");
98
+ fs.writeFileSync(path.join(d, "src", "f4.ts"), "export const v=changed-too;");
99
+ const s = M.assessStaleness(d, idx);
100
+ assert.ok(s.status === "FRESH" || s.status === "POSSIBLY_STALE", "not full STALE: " + JSON.stringify(s));
101
+ assert.notEqual(s.status, "STALE", "a body edit is not a structural change");
102
+ });
103
+
104
+ // ---- staleness: structural changes ARE detected ---------------------------------------------
105
+ test("structural change (new package + manifest edit) is reported STALE with reasons", () => {
106
+ const d = tmp("stale");
107
+ for (let i = 0; i < 10; i++) write(path.join(d, "packages", "a", "f" + i + ".ts"), "export const v=1;");
108
+ write(path.join(d, "packages", "a", "package.json"), JSON.stringify({ name: "a" }));
109
+ write(path.join(d, "package.json"), JSON.stringify({ name: "root", scripts: { test: "node --test" } }));
110
+ M.initDeep(d, {});
111
+ const idx = M.readMemoryIndex(d);
112
+ // add a new package + change root manifest (add a build command)
113
+ for (let i = 0; i < 8; i++) write(path.join(d, "packages", "b", "g" + i + ".ts"), "export const v=1;");
114
+ write(path.join(d, "packages", "b", "package.json"), JSON.stringify({ name: "b" }));
115
+ write(path.join(d, "package.json"), JSON.stringify({ name: "root", scripts: { test: "node --test", build: "tsc" } }));
116
+ const s = M.assessStaleness(d, idx);
117
+ assert.equal(s.status, "STALE");
118
+ assert.ok(s.reasons.length >= 1, "reasons present");
119
+ });
120
+
121
+ // ---- dry-run does not modify files ----------------------------------------------------------
122
+ test("dry-run lists proposed locations but writes nothing", () => {
123
+ const d = tmp("dry");
124
+ for (let i = 0; i < 12; i++) write(path.join(d, "pkg", "f" + i + ".ts"), "export const v=1;");
125
+ write(path.join(d, "pkg", "package.json"), JSON.stringify({ name: "pkg" }));
126
+ const before = fs.readdirSync(d, { recursive: true }).slice().sort();
127
+ const r = M.initDeep(d, { dryRun: true });
128
+ const after = fs.readdirSync(d, { recursive: true }).slice().sort();
129
+ assert.deepEqual(after, before, "no files created/removed on dry-run");
130
+ assert.ok(!fs.existsSync(path.join(d, M.MEMORY_PATH)), "no memory index written on dry-run");
131
+ assert.ok(r.willWrite.length > 0, "dry-run still reports proposed locations");
132
+ });
133
+
134
+ // ---- memory index carries staleness inputs --------------------------------------------------
135
+ test("memory-index.json records schema version, commit, node scores + manifest fingerprints", () => {
136
+ const d = tmp("idx");
137
+ for (let i = 0; i < 12; i++) write(path.join(d, "pkg", "f" + i + ".ts"), "export const v=1;");
138
+ write(path.join(d, "pkg", "package.json"), JSON.stringify({ name: "pkg" }));
139
+ const r = M.initDeep(d, {});
140
+ const idx = M.readMemoryIndex(d);
141
+ assert.equal(idx.schemaVersion, 1);
142
+ assert.ok(typeof idx.structuralKey === "string" && idx.structuralKey.length === 64);
143
+ assert.ok(idx.nodes.some((n) => n.selected && n.signature.manifests && Object.keys(n.signature.manifests).length), "manifest fingerprint recorded for a package node");
144
+ assert.ok(idx.nodes.every((n) => typeof n.tier === "string"), "tiers recorded (score/reason kept in index, not prose)");
145
+ });
@@ -0,0 +1,91 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import * as C from "../src/controller.js";
7
+
8
+ const CONTROLLER = path.resolve(import.meta.dirname, "..", "src", "controller.js");
9
+ process.env.LONGRUN_CONTROLLER_FILE = CONTROLLER;
10
+ const PLUG_URL = "../plugin/longrun.js";
11
+ const { F } = await import("./helper.mjs");
12
+
13
+ function freshState() { const d = fs.mkdtempSync(path.join(os.tmpdir(), "lrnc-st-")); process.env.LONGRUN_STATE_DIR = d; return d; }
14
+
15
+ // A read-only protected runner: it only READS ./app.mjs (never writes anything).
16
+ const RUNNER = `
17
+ import { Widget } from "./app.mjs";
18
+ let fail = 0;
19
+ try { const w = new Widget(); if (!w.render().includes("hero")) { fail++; } } catch { fail++; }
20
+ console.log("RESULT " + (fail === 0 ? "PASS" : "FAIL"));
21
+ process.exit(fail === 0 ? 0 : 1);
22
+ `;
23
+ const APP_FIXED = `export class Widget { render(){ return "hero + scene"; } }`;
24
+ const APP_BROKEN = `export class Widget { render(){ return "nothing attached"; } }`; // hero missing => runner fails
25
+
26
+ function fixture(app) { const d = fs.mkdtempSync(path.join(os.tmpdir(), "fixture-")); fs.writeFileSync(path.join(d, "app.mjs"), app); fs.writeFileSync(path.join(d, "run_acceptance.mjs"), RUNNER); return d; }
27
+ function project(app) { const d = fixture(app); return d; } // project layout identical to a fixture
28
+
29
+ function safeRuns(dir) { try { return JSON.parse(fs.readFileSync(path.join(dir, "runs.json"), "utf8")); } catch { return {}; } }
30
+
31
+ async function seededRun({ app, check = { command: ["node", "run_acceptance.mjs"], kind: "test", integration: true, gate: true, negativeControl: true } }) {
32
+ freshState();
33
+ const dir = project(app);
34
+ const h = await F(PLUG_URL, { client: null });
35
+ const id = C.projectIdentity(dir);
36
+ const key = C.stateKey(id, "run1");
37
+ const store = new C.Store(process.env.LONGRUN_STATE_DIR);
38
+ const run = C.startRun({ request: "t", contract: { criteria: [{ id: "c1", required: true, checks: ["acc"], status: "FAIL", visual: true }], gates: [{ id: "render-gate", required: true, status: "FAIL" }], lossTarget: 0 }, budgets: C.defaultBudget(), sourceFingerprint: C.sourceFingerprint(dir).hash, directory: dir, checkCatalogue: { acc: check } }).run;
39
+ store.writeJSON(key, "run.json", run);
40
+ const runs = safeRuns(process.env.LONGRUN_STATE_DIR);
41
+ runs["sess-1"] = { runKey: key, directory: dir, checkCatalogue: { acc: check } };
42
+ fs.writeFileSync(path.join(process.env.LONGRUN_STATE_DIR, "runs.json"), JSON.stringify(runs));
43
+ return { h, dir, key, store };
44
+ }
45
+ const CTX = (dir) => ({ sessionID: "sess-1", agent: "longrun", directory: dir, worktree: dir });
46
+
47
+ // ---- a known-broken fixture makes the protected verifier FAIL (live verifier) --------------
48
+ test("negative control: a known-broken fixture causes the protected verifier to fail", async () => {
49
+ const { h, dir } = await seededRun({ app: APP_FIXED });
50
+ const brokenFixture = fixture(APP_BROKEN); // isolated temp copy of the BROKEN implementation
51
+ const res = JSON.parse(await h.tool.longrun_verify.execute({ checkId: "acc", mode: "negative", fixture: brokenFixture }, CTX(dir)));
52
+ assert.equal(res.kind, "negative_control");
53
+ assert.equal(res.ok, true, "verifier correctly failed on the broken fixture (proving it is live)");
54
+ });
55
+
56
+ // ---- a negative run must NOT be satisfied by the healthy app, and cannot touch production --
57
+ test("a negative control against the HEALTHY app is not a valid negative + never mutates production", async () => {
58
+ const { h, dir } = await seededRun({ app: APP_FIXED });
59
+ const fpBefore = C.sourceFingerprint(dir).hash;
60
+ const res = JSON.parse(await h.tool.longrun_verify.execute({ checkId: "acc", mode: "negative", fixture: project(APP_FIXED) }, CTX(dir)));
61
+ assert.equal(res.kind, "negative_control");
62
+ assert.equal(res.ok, false, "the verifier passed a healthy fixture => NOT a valid negative control (warning)");
63
+ // production state must be byte-identical afterwards (isolated fixture cwd only)
64
+ assert.equal(C.sourceFingerprint(dir).hash, fpBefore, "negative run must not mutate the active project");
65
+ });
66
+
67
+ // ---- a negative control without an isolated fixture is refused (no destructive path) -------
68
+ test("negative control without a fixture is refused (never edits the active source)", async () => {
69
+ const { h, dir } = await seededRun({ app: APP_FIXED });
70
+ const res = JSON.parse(await h.tool.longrun_verify.execute({ checkId: "acc", mode: "negative" }, CTX(dir)));
71
+ assert.equal(res.error, "no_fixture");
72
+ });
73
+
74
+ // ---- restored (healthy) app passes the normal verifier -------------------------------------
75
+ test("the restored/healthy implementation passes the normal verifier (positive control)", async () => {
76
+ freshState();
77
+ const dir = project(APP_FIXED); // project contains the FIXED app + runner
78
+ const h = await F(PLUG_URL, { client: null });
79
+ const id = C.projectIdentity(dir);
80
+ const key = C.stateKey(id, "run1");
81
+ const store = new C.Store(process.env.LONGRUN_STATE_DIR);
82
+ // baseline fingerprint differs from the live source -> this eval is an evaluated CHANGED state
83
+ const run = C.startRun({ request: "t", contract: { criteria: [{ id: "c1", required: true, checks: ["acc"], status: "FAIL" }], gates: [], lossTarget: 0 }, budgets: C.defaultBudget(), sourceFingerprint: "baseline-before-edit", directory: dir, checkCatalogue: { acc: { command: ["node", "run_acceptance.mjs"], kind: "cmd" } } }).run;
84
+ store.writeJSON(key, "run.json", run);
85
+ const runs = safeRuns(process.env.LONGRUN_STATE_DIR);
86
+ runs["sess-1"] = { runKey: key, directory: dir, checkCatalogue: { acc: { command: ["node", "run_acceptance.mjs"], kind: "cmd" } } };
87
+ fs.writeFileSync(path.join(process.env.LONGRUN_STATE_DIR, "runs.json"), JSON.stringify(runs));
88
+ const res = JSON.parse(await h.tool.longrun_verify.execute({ checkId: "acc" }, CTX(dir)));
89
+ assert.equal(res.status, "PASS", "healthy app passes; candidateCounted should follow a real eval");
90
+ assert.equal(res.candidateCounted, true);
91
+ });
@@ -0,0 +1,169 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ // Point the plugin at the shared controller + an isolated state dir BEFORE import.
8
+ const CONTROLLER = path.resolve(import.meta.dirname, "..", "src", "controller.js");
9
+ process.env.LONGRUN_CONTROLLER_FILE = CONTROLLER;
10
+ const STATE = fs.mkdtempSync(path.join(os.tmpdir(), "lrplug-"));
11
+ process.env.LONGRUN_STATE_DIR = STATE;
12
+
13
+ // The plugin entrypoint now exposes ONE export (the async factory). Tests use it via helper.F.
14
+ const PLUG_URL = "../plugin/longrun.js";
15
+ const SRC_PLUG = await import(PLUG_URL);
16
+ const { F, readRuns, writeRuns } = await import("./helper.mjs");
17
+ const { install } = await import("../src/install.mjs");
18
+ const C = await import("../src/controller.js");
19
+
20
+ // ---- 1) ARTIFACT-LEVEL LOADER CONTRACT (the real bug: a non-function / non-plugin export made
21
+ // the desktop loader throw "Plugin export is not a function" and skip the whole plugin).
22
+ // Faithful re-implementation of the backend loader: EVERY module export must be a server plugin —
23
+ // a callable function OR a strict `{ id, server }` descriptor whose server is callable — or throw.
24
+ const isServerPlugin = (v) => typeof v === "function" || (v !== null && typeof v === "object" && typeof v.server === "function");
25
+ function loaderCollect(mod) { const seen = new Set(); const out = []; for (const e of Object.values(mod)) { if (seen.has(e)) continue; seen.add(e); if (!isServerPlugin(e)) throw new TypeError("Plugin export is not a function"); out.push(typeof e === "function" ? e : e.server); } return out; }
26
+
27
+ function assertLoaderInvariant(mod, label) {
28
+ const keys = Object.keys(mod);
29
+ assert.deepEqual(keys, ["default"], `${label}: exactly ONE module export (default) — found: ${keys.join(",") || "(none)"}`);
30
+ assert.equal(typeof mod.default, "object", `${label}: default must be the V1 { id, server } descriptor`);
31
+ assert.equal(mod.default.id, "longrun", `${label}: descriptor id must be "longrun"`);
32
+ assert.equal(typeof mod.default.server, "function", `${label}: descriptor server must be callable`);
33
+ assert.doesNotThrow(() => loaderCollect(mod), `${label}: desktop loader must accept every export`);
34
+ }
35
+ test("LOADER CONTRACT: bad module shapes throw (negative control)", () => {
36
+ assert.throws(() => loaderCollect({ default: { hooks: {} } }), TypeError, "object export without callable server");
37
+ assert.throws(() => loaderCollect({ default: { id: "x" } }), TypeError, "descriptor without callable server");
38
+ assert.throws(() => loaderCollect({ helper: 42 }), TypeError, "non-plugin value");
39
+ });
40
+ test("ARTIFACT invariant: SOURCE plugin exposes exactly one function export", async () => {
41
+ assertLoaderInvariant(SRC_PLUG, "source");
42
+ });
43
+ test("ARTIFACT invariant: INSTALLER-COPIED plugin passes the same loader contract", async () => {
44
+ const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "install-"));
45
+ fs.mkdirSync(path.join(cfg, "plugins"), { recursive: true });
46
+ const rep = install({ configDir: cfg }); // run the ACTUAL installer -> generates the copied artifact
47
+ const built = path.join(cfg, "plugins", "longrun.js");
48
+ assert.ok(fs.existsSync(built), "installer produced plugins/longrun.js");
49
+ assert.ok(!rep.conflicts.some((c) => c.rel.includes("longrun.js")));
50
+ const BUILT = await import("file://" + built); // import the GENERATED artifact, not the source
51
+ assertLoaderInvariant(BUILT, "built/copy");
52
+ // the baked controller must resolve, and the built factory must build real hooks
53
+ const hooks = await BUILT.default.server({ client: { app: { log: () => {} } } });
54
+ assert.ok(hooks.tool && hooks.tool.longrun && hooks.tool.longrun_verify, "built plugin still exposes both tools");
55
+ });
56
+
57
+ // ---- v1.1.2: evidence discipline — an import is not a load; probes cannot fabricate. ----
58
+ const { spawnSync } = await import("node:child_process");
59
+ const PLUG_FILE = path.resolve(import.meta.dirname, "..", "plugin", "longrun.js").split(path.sep).join("/");
60
+ // Isolated probe children: state dir IS set, and the test context is explicitly disarmed.
61
+ // OPENCODE_CLIENT is stripped too — the harness runs inside a desktop backend whose env would
62
+ // otherwise make any inherited child process look like a host. A genuine probe is NOT a host.
63
+ const CHILD_ENV = (state) => { const e = { ...process.env, LONGRUN_STATE_DIR: state }; delete e.LONGRUN_TEST; delete e.NODE_TEST_CONTEXT; delete e.OPENCODE_CLIENT; return e; }
64
+ test("importing the plugin writes NO live evidence (import != load)", () => {
65
+ const state = fs.mkdtempSync(path.join(os.tmpdir(), "noev-"));
66
+ const r = spawnSync(process.execPath, ["--input-type=module", "-e", `await import("file://${PLUG_FILE}");`], { env: CHILD_ENV(state) });
67
+ assert.equal(r.status, 0, "child import ran: " + r.stderr);
68
+ assert.deepEqual(fs.readdirSync(state), [], "module import must not create any state");
69
+ });
70
+ test("a plain-node probe calling server() creates no live evidence (isolated state)", () => {
71
+ const state = fs.mkdtempSync(path.join(os.tmpdir(), "noev2-"));
72
+ const code = `const m = await import("file://${PLUG_FILE}"); await m.default.server({ client: null });`;
73
+ const r = spawnSync(process.execPath, ["--input-type=module", "-e", code], { env: CHILD_ENV(state) });
74
+ assert.equal(r.status, 0, "child factory ran: " + r.stderr);
75
+ assert.deepEqual(fs.readdirSync(state), [], "probe factory call must write nothing, even with state dir set");
76
+ });
77
+
78
+ function setupRun({ checkCatalogue, status = "IMPLEMENTING", extra = {} }) {
79
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lrproj-"));
80
+ fs.writeFileSync(path.join(dir, "a.js"), "x");
81
+ const id = C.projectIdentity(dir);
82
+ const key = C.stateKey(id, "default");
83
+ const store = new C.Store(STATE);
84
+ const run = {
85
+ status,
86
+ originalRequest: "do the thing",
87
+ contract: { criteria: [{ id: "c1", required: true, checks: ["pass"], status: "FAIL", weight: 1 }], gates: [], lossTarget: 0 },
88
+ state: {}, budget: C.defaultBudget(),
89
+ };
90
+ store.writeJSON(key, "run.json", run);
91
+ const runs = readRuns();
92
+ runs["sess-1"] = { runKey: key, directory: dir, checkCatalogue, ...extra };
93
+ writeRuns(runs);
94
+ return { dir, key, store };
95
+ }
96
+ const CTX = (dir) => ({ sessionID: "sess-1", agent: "longrun", directory: dir, worktree: dir });
97
+
98
+ // 6 + 4: verification must come from the declared catalogue; no arbitrary shell bypass.
99
+ test("longrun_verify refuses an undeclared check (no shell bypass)", async () => {
100
+ const { dir } = setupRun({ checkCatalogue: { pass: { command: ["node", "-e", "process.exit(0)"], kind: "cmd" } } });
101
+ const tools = (await F(PLUG_URL, { client: null })).tool;
102
+ const res = JSON.parse(await tools.longrun_verify.execute({ checkId: "rm -rf /" }, CTX(dir)));
103
+ assert.equal(res.error, "undeclared_check", "arbitrary command rejected");
104
+ });
105
+
106
+ test("longrun_verify runs a declared check and records PASS", async () => {
107
+ const { dir } = setupRun({ checkCatalogue: { pass: { command: ["node", "-e", "process.exit(0)"], kind: "cmd" } } });
108
+ const tools = (await F(PLUG_URL, { client: null })).tool;
109
+ const res = JSON.parse(await tools.longrun_verify.execute({ checkId: "pass" }, CTX(dir)));
110
+ assert.equal(res.status, "PASS");
111
+ });
112
+ test("longrun_verify reports FAIL from exit code, not console text", async () => {
113
+ const { dir } = setupRun({ checkCatalogue: { fail: { command: ["node", "-e", "console.log('ALL PASSED');process.exit(1)"], kind: "cmd" } } });
114
+ const tools = (await F(PLUG_URL, { client: null })).tool;
115
+ const res = JSON.parse(await tools.longrun_verify.execute({ checkId: "fail" }, CTX(dir)));
116
+ assert.equal(res.status, "FAIL", "optimistic text ignored");
117
+ });
118
+
119
+ // 9: compaction supplements (does not replace) the default prompt with a bounded packet.
120
+ test("compacting hook appends a recovery packet to context", async () => {
121
+ const { dir } = setupRun({ checkCatalogue: {} });
122
+ const hooks = await F(PLUG_URL, { client: null });
123
+ const out = { context: [], prompt: undefined };
124
+ await hooks["experimental.session.compacting"]({ sessionID: "sess-1" }, out);
125
+ assert.ok(out.context.some((c) => c.includes("Long-run recovery")), "packet appended");
126
+ assert.equal(out.prompt, undefined, "default compaction prompt NOT replaced");
127
+ });
128
+ test("compacting hook is a no-op for inactive sessions", async () => {
129
+ const hooks = await F(PLUG_URL, { client: null });
130
+ const out = { context: [] };
131
+ await hooks["experimental.session.compacting"]({ sessionID: "nope" }, out);
132
+ assert.equal(out.context.length, 0, "inactive -> nothing");
133
+ });
134
+
135
+ // 8 + 10: paused/cancelled session suppresses synthetic auto-continue.
136
+ test("autocontinue requires an authorized legacy rebind and stays disabled for a paused run", async () => {
137
+ const { dir } = setupRun({ checkCatalogue: {} });
138
+ const hooks = await F(PLUG_URL, { client: null });
139
+ const legacy = { enabled: true };
140
+ await hooks["experimental.compaction.autocontinue"]({ sessionID: "sess-1" }, legacy);
141
+ assert.equal(legacy.enabled, false, "a legacy routing flag alone cannot authorize continuation");
142
+ assert.equal(JSON.parse(await hooks.tool.longrun.execute({ action: "resume" }, CTX(dir))).resumed, true);
143
+ const a = { enabled: true };
144
+ await hooks["experimental.compaction.autocontinue"]({ sessionID: "sess-1" }, a);
145
+ assert.equal(a.enabled, true, "active run keeps default");
146
+ // now pause it (paused is still recognised, unlike fully-disabled)
147
+ await hooks.tool.longrun.execute({ action: "pause" }, CTX(dir));
148
+ const b = { enabled: true };
149
+ await hooks["experimental.compaction.autocontinue"]({ sessionID: "sess-1" }, b);
150
+ assert.equal(b.enabled, false, "paused/cancelled suppresses auto-continue");
151
+ // a fully-inactive session leaves the default untouched
152
+ const c = { enabled: true };
153
+ await hooks["experimental.compaction.autocontinue"]({ sessionID: "ghost" }, c);
154
+ assert.equal(c.enabled, true, "inactive -> no change");
155
+ });
156
+
157
+ // 9 + 10: session.compacted flips RECOVERY_REQUIRED, not a loop, only inside a run.
158
+ test("session.compacted sets RECOVERY_REQUIRED inside a run", async () => {
159
+ const { dir, key, store } = setupRun({ checkCatalogue: {} });
160
+ const hooks = await F(PLUG_URL, { client: null });
161
+ await hooks.event({ event: { type: "session.compacted", properties: { sessionID: "sess-1" } } });
162
+ assert.equal(store.readJSON(key, "run.json").status, "RECOVERY_REQUIRED");
163
+ });
164
+ test("session.compacted is a no-op for inactive sessions", async () => {
165
+ const hooks = await F(PLUG_URL, { client: null });
166
+ // must not throw and must not touch any state
167
+ await hooks.event({ event: { type: "session.compacted", properties: { sessionID: "ghost" } } });
168
+ assert.ok(true);
169
+ });