@cairnkeep/cli 2.3.1 → 2.4.0

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 (125) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +221 -6
  3. package/bin/cairn +43 -2
  4. package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
  5. package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
  6. package/claude/hooks/compaction-capture.sh +34 -0
  7. package/claude/hooks/memory-capture.sh +47 -5
  8. package/claude/hooks/memory-wakeup.sh +55 -2
  9. package/docs/building-an-overlay.md +1 -1
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +2 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  15. package/docs/operating.md +531 -4
  16. package/docs/privacy-and-data-flow.md +417 -0
  17. package/docs/storage.md +252 -0
  18. package/examples/eval/README.md +42 -0
  19. package/examples/eval/adapter.json +12 -0
  20. package/examples/eval/bundled-fake.json +1 -0
  21. package/examples/eval/task-set.json +1 -0
  22. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  23. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  24. package/mcp-memory-server/dist/artifact-store.js +812 -0
  25. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  26. package/mcp-memory-server/dist/capability-cli.js +361 -0
  27. package/mcp-memory-server/dist/capability-config.js +310 -0
  28. package/mcp-memory-server/dist/capability-harness.js +496 -0
  29. package/mcp-memory-server/dist/capability-registry.js +123 -0
  30. package/mcp-memory-server/dist/capability-schema.js +65 -0
  31. package/mcp-memory-server/dist/capability-store.js +431 -0
  32. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  33. package/mcp-memory-server/dist/eval-cli.js +523 -0
  34. package/mcp-memory-server/dist/eval-plan.js +346 -0
  35. package/mcp-memory-server/dist/eval-process.js +273 -0
  36. package/mcp-memory-server/dist/eval-report.js +609 -0
  37. package/mcp-memory-server/dist/eval-runner.js +827 -0
  38. package/mcp-memory-server/dist/eval-schema.js +368 -0
  39. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  40. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  41. package/mcp-memory-server/dist/failure-signature.js +141 -0
  42. package/mcp-memory-server/dist/index.js +600 -55
  43. package/mcp-memory-server/dist/node-cli.js +84 -0
  44. package/mcp-memory-server/dist/node-schema.js +170 -0
  45. package/mcp-memory-server/dist/node-store.js +455 -0
  46. package/mcp-memory-server/dist/note-cli.js +235 -0
  47. package/mcp-memory-server/dist/note-distiller.js +177 -0
  48. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  49. package/mcp-memory-server/dist/note-schema.js +4 -0
  50. package/mcp-memory-server/dist/note-store.js +985 -0
  51. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  52. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  53. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  54. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  55. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  56. package/mcp-memory-server/package.json +24 -2
  57. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  58. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  59. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  60. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  61. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  62. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  63. package/opencode/command/graphify.md +25 -14
  64. package/opencode/plugins/memory-capture.ts +76 -10
  65. package/opencode/plugins/memory-wakeup.ts +58 -6
  66. package/package.json +8 -4
  67. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  68. package/schemas/artifact.schema.json +133 -0
  69. package/schemas/capability-callback.schema.json +68 -0
  70. package/schemas/capability-contract.schema.json +126 -0
  71. package/schemas/eval-adapter.schema.json +43 -0
  72. package/schemas/eval-protocol.schema.json +114 -0
  73. package/schemas/eval-report.schema.json +423 -0
  74. package/schemas/eval-task-set.schema.json +114 -0
  75. package/schemas/memory-node.schema.json +90 -0
  76. package/schemas/note.schema.json +112 -0
  77. package/schemas/trajectory-redaction.schema.json +25 -0
  78. package/schemas/trajectory.schema.json +82 -0
  79. package/scripts/bootstrap.sh +4 -0
  80. package/scripts/completion.sh +119 -5
  81. package/scripts/doctor.sh +284 -0
  82. package/scripts/fake-eval-adapter.mjs +120 -0
  83. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  84. package/scripts/install-audit-timer.sh +3 -1
  85. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  86. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  87. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  88. package/scripts/memory-wiki-audit.sh +49 -0
  89. package/scripts/smoke-bash32.sh +9 -0
  90. package/scripts/sync-claude-assets.sh +99 -6
  91. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  92. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  93. package/scripts/sync-opencode-security-assets.sh +24 -3
  94. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  95. package/scripts/sync-pi-assets.sh +72 -0
  96. package/scripts/test-audit-timer.sh +98 -0
  97. package/scripts/test-bootstrap-untracked.sh +48 -0
  98. package/scripts/test-cli-dispatch.sh +41 -2
  99. package/scripts/test-compaction-hooks.sh +495 -0
  100. package/scripts/test-completion.sh +77 -0
  101. package/scripts/test-doctor.sh +348 -1
  102. package/scripts/test-eval-cli.sh +175 -0
  103. package/scripts/test-launcher-hooks.sh +602 -3
  104. package/scripts/test-notes-cli.sh +114 -0
  105. package/scripts/test-package-install.sh +251 -0
  106. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  107. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  108. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  109. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  110. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  111. package/scripts/test-trajectory-cli.sh +87 -0
  112. package/scripts/test-trajectory-hooks.sh +142 -0
  113. package/scripts/test-uninstall.sh +231 -5
  114. package/scripts/uninstall.sh +37 -5
  115. package/scripts/verify-dependency-security.mjs +121 -0
  116. package/scripts/verify-docs-parity.sh +527 -1
  117. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  118. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  119. package/templates/capabilities.json.template +7 -0
  120. package/templates/env.example.template +51 -0
  121. package/templates/memory-wiki-audit.service.template +1 -1
  122. package/templates/start-claude.sh.template +36 -0
  123. package/templates/start-opencode.sh.template +47 -5
  124. package/templates/start-pi.sh.template +30 -0
  125. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,1015 @@
1
+ #!/usr/bin/env bash
2
+ # Phase 19 evaluation lifecycle RED and later GREEN mode contract.
3
+ set -euo pipefail
4
+
5
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
6
+ MODE="${1:-composed}"
7
+ [[ $# -gt 0 ]] && shift
8
+
9
+ usage() {
10
+ cat <<'USAGE'
11
+ Usage: test-phase19-eval-lifecycle.sh [expected-red|workspace|two-pass|ablation|report|retention|claims|fake|cancellation|package]
12
+ test-phase19-eval-lifecycle.sh
13
+ USAGE
14
+ }
15
+
16
+ fail() { echo "FAIL: $1" >&2; exit 1; }
17
+
18
+ case "$MODE" in
19
+ composed|expected-red|workspace|two-pass|ablation|report|retention|claims|fake|cancellation|package)
20
+ [[ $# -eq 0 ]] || { usage >&2; exit 2; }
21
+ ;;
22
+ *) usage >&2; exit 2 ;;
23
+ esac
24
+
25
+ # Selector validation intentionally precedes all fixture mutation.
26
+ tmp=$(mktemp -d)
27
+ trap 'chmod -R u+w "$tmp" 2>/dev/null || true; rm -rf "$tmp"' EXIT
28
+ runner="$ROOT/mcp-memory-server/dist/eval-runner.js"
29
+ workspace="$ROOT/mcp-memory-server/dist/eval-workspace.js"
30
+ report="$ROOT/mcp-memory-server/dist/eval-report.js"
31
+ statistics="$ROOT/mcp-memory-server/dist/eval-statistics.js"
32
+ eval_cli="$ROOT/mcp-memory-server/dist/eval-cli.js"
33
+
34
+ baseline() {
35
+ bash -n "$ROOT/scripts/test-phase19-eval-lifecycle.sh" "$ROOT/scripts/test-eval-cli.sh"
36
+ node --check "$ROOT/scripts/verify-phase19-runtime-evidence.mjs"
37
+ [[ ! -e "$tmp/project" && ! -e "$tmp/output" && ! -e "$tmp/network" ]] || fail "baseline began with dirty sentinels"
38
+ env -u CAIRN_EVAL node -e 'process.exit(0)'
39
+ [[ ! -e "$tmp/project" && ! -e "$tmp/output" && ! -e "$tmp/network" ]] || fail "disabled baseline created a side effect"
40
+ echo "PASS: Phase 19 lifecycle Wave 0 baseline"
41
+ }
42
+
43
+ require_exports() {
44
+ local module=$1
45
+ shift
46
+ node --input-type=module - "$module" "$@" <<'NODE'
47
+ import assert from "node:assert/strict";
48
+ import { pathToFileURL } from "node:url";
49
+ const [modulePath, ...names] = process.argv.slice(2);
50
+ const value = await import(pathToFileURL(modulePath).href);
51
+ for (const name of names) assert.equal(typeof value[name], "function", `missing export ${name}`);
52
+ NODE
53
+ }
54
+
55
+ run_workspace() {
56
+ require_exports "$workspace" createEvalWorkspace runTaskPreparation runTaskVerifier cleanupEvalWorkspace
57
+ node --input-type=module - "$workspace" "$tmp" <<'NODE'
58
+ import assert from "node:assert/strict";
59
+ import { execFileSync } from "node:child_process";
60
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
61
+ import { join } from "node:path";
62
+ import { pathToFileURL } from "node:url";
63
+
64
+ const [modulePath, fixtureRoot] = process.argv.slice(2);
65
+ const api = await import(pathToFileURL(modulePath).href);
66
+ const repo = join(fixtureRoot, "workspace-repo");
67
+ mkdirSync(repo);
68
+ const git = (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
69
+ git("init", "-q");
70
+ git("config", "user.name", "Evaluation Fixture");
71
+ git("config", "user.email", "evaluation-fixture@example.invalid");
72
+ writeFileSync(join(repo, "source.txt"), "clean-source\n");
73
+ git("add", "source.txt");
74
+ git("commit", "-qm", "fixture");
75
+ const revision = git("rev-parse", "HEAD");
76
+ const task = {
77
+ id: "task-alpha",
78
+ input: "fixture",
79
+ workspace: { path: "." },
80
+ prepare: { program: process.execPath, args: ["-e", "require('node:fs').writeFileSync('prepared.txt','prepared')"] },
81
+ verify: { program: process.execPath, args: ["-e", "process.exit(require('node:fs').readFileSync('source.txt','utf8')==='clean-source\\n'?0:9)"] },
82
+ limits: { elapsed_ms: 2_000, stdout_bytes: 1_024 },
83
+ };
84
+ const plan = {
85
+ schema_version: 1,
86
+ task_set: { schema_version: 1, id: "fixture", source: { kind: "git", repository: ".", revision }, tasks: [task] },
87
+ source: { kind: "git", repository_root: repo, revision },
88
+ resolved_programs: { prepare: [process.execPath], verify: [process.execPath], adapter: process.execPath },
89
+ };
90
+ const row = (id) => ({ observation_id: id, task_id: task.id });
91
+ const first = await api.createEvalWorkspace({ plan, row: row("row-one"), temporary_root: fixtureRoot });
92
+ assert.equal(readFileSync(join(first.source_path, "source.txt"), "utf8"), "clean-source\n");
93
+ for (const directory of [first.parent_path, first.notes_path, first.output_path, first.home_path, first.temp_path]) {
94
+ assert.equal(statSync(directory).mode & 0o777, 0o700, `${directory} is not private`);
95
+ }
96
+ writeFileSync(join(first.source_path, "source-leak"), "sentinel");
97
+ writeFileSync(join(first.notes_path, "note-leak"), "sentinel");
98
+ writeFileSync(join(first.output_path, "output-leak"), "sentinel");
99
+ writeFileSync(join(first.home_path, "state-leak"), "sentinel");
100
+ const firstCleanup = await api.cleanupEvalWorkspace(first);
101
+ assert.equal(firstCleanup.status, "closed");
102
+ assert.equal(existsSync(first.parent_path), false);
103
+
104
+ const second = await api.createEvalWorkspace({ plan, row: row("row-two"), temporary_root: fixtureRoot });
105
+ assert.notEqual(second.parent_path, first.parent_path);
106
+ for (const candidate of [
107
+ join(second.source_path, "source-leak"), join(second.notes_path, "note-leak"),
108
+ join(second.output_path, "output-leak"), join(second.home_path, "state-leak"),
109
+ ]) assert.equal(existsSync(candidate), false, `row leakage reached ${candidate}`);
110
+ assert.equal(readFileSync(join(second.source_path, "source.txt"), "utf8"), "clean-source\n");
111
+ const preparation = await api.runTaskPreparation(second);
112
+ assert.equal(preparation.exit_code, 0);
113
+ assert.equal(existsSync(join(second.workspace_path, "prepared.txt")), true);
114
+ assert.equal((await api.runTaskVerifier(second, { adapter_completed: false })).pass_state, "unknown");
115
+ assert.equal((await api.runTaskVerifier(second, { adapter_completed: true })).pass_state, "passed");
116
+ second.task.verify.args = ["-e", "process.exit(3)"];
117
+ const failed = await api.runTaskVerifier(second, { adapter_completed: true });
118
+ assert.deepEqual([failed.pass_state, failed.terminal_state], ["failed", "verifier_failed"]);
119
+ second.verify_program = join(fixtureRoot, "missing-verifier");
120
+ const unknown = await api.runTaskVerifier(second, { adapter_completed: true });
121
+ assert.deepEqual([unknown.pass_state, unknown.verifier_state, unknown.reason], ["unknown", "error", "verifier_spawn_error"]);
122
+ assert.equal((await api.cleanupEvalWorkspace(second)).status, "closed");
123
+ assert.equal(git("worktree", "list", "--porcelain").includes(first.source_path), false);
124
+ assert.equal(git("worktree", "list", "--porcelain").includes(second.source_path), false);
125
+ NODE
126
+ echo "PASS: Phase 19 fresh workspace and independent verifier contract"
127
+ }
128
+
129
+ run_two_pass() {
130
+ require_exports "$runner" runTwoPassExperiment runEvalTwoPass runEvalObservation distillRunOneNotes snapshotTaskNotes verifyNoteSnapshot
131
+ node "$eval_cli" --help | grep -qE 'run|two-pass' || fail "eval help omits two-pass run"
132
+ node --input-type=module - "$runner" "$report" "$ROOT/mcp-memory-server/dist/eval-plan.js" "$tmp" <<'NODE'
133
+ import assert from "node:assert/strict";
134
+ import { execFileSync } from "node:child_process";
135
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
136
+ import { join } from "node:path";
137
+ import { pathToFileURL } from "node:url";
138
+
139
+ const [runnerPath, reportPath, planPath, fixtureRoot] = process.argv.slice(2);
140
+ const runner = await import(pathToFileURL(runnerPath).href);
141
+ const reports = await import(pathToFileURL(reportPath).href);
142
+ const { buildEvalSchedule } = await import(pathToFileURL(planPath).href);
143
+ const repo = join(fixtureRoot, "two-pass-repo");
144
+ mkdirSync(repo);
145
+ const git = (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
146
+ git("init", "-q");
147
+ git("config", "user.name", "Evaluation Fixture");
148
+ git("config", "user.email", "evaluation-fixture@example.invalid");
149
+ writeFileSync(join(repo, "source.txt"), "immutable-source\n");
150
+ mkdirSync(join(repo, "fixtures", "nested-task"), { recursive: true });
151
+ writeFileSync(join(repo, "fixtures", "nested-task", "sentinel.txt"), "nested-task-root\n");
152
+ git("add", "source.txt", "fixtures/nested-task/sentinel.txt");
153
+ git("commit", "-qm", "fixture");
154
+ const revision = git("rev-parse", "HEAD");
155
+ const invocationLog = join(fixtureRoot, "invocations.log");
156
+ const distillLog = join(fixtureRoot, "distill.log");
157
+ const adapterScript = join(fixtureRoot, "adapter.mjs");
158
+ const distillerScript = join(fixtureRoot, "distiller.mjs");
159
+ writeFileSync(adapterScript, `
160
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
161
+ import { join } from "node:path";
162
+ const chunks=[]; for await (const chunk of process.stdin) chunks.push(chunk);
163
+ const request=JSON.parse(Buffer.concat(chunks).toString("utf8"));
164
+ appendFileSync(process.env.EVAL_INVOCATION_LOG, request.task_id+":"+request.pass+"\\n");
165
+ const source=join(process.cwd(), request.workspace_path);
166
+ if (readFileSync(join(source,"source.txt"),"utf8")!=="immutable-source\\n" || existsSync(join(source,"source-leak"))) process.exit(31);
167
+ if (request.pass==="run1" && request.notes_path!==null) process.exit(32);
168
+ if (request.pass==="run2" && request.task_id==="task-success") {
169
+ if (request.notes_path===null || !existsSync(join(process.cwd(),request.notes_path,"projects","task-success","note.md"))) process.exit(33);
170
+ } else if (request.notes_path!==null) process.exit(34);
171
+ writeFileSync(join(source,"source-leak"),request.observation_id??request.task_id);
172
+ writeFileSync(join(source,"answer.txt"),"ok\\n");
173
+ writeFileSync(join(process.env.HOME,"home-leak"),"sentinel");
174
+ writeFileSync(join(process.cwd(),request.output_path,"output-leak"),"sentinel");
175
+ if (request.task_id==="task-cancel") await new Promise((resolve)=>setTimeout(resolve,60_000));
176
+ const result={schema_version:1,status:"completed",turns:{value:1,semantics:"fixture-turn"},usage:{total_tokens:10},adapter:{id:"fixture-adapter"},observed_capability_digest:request.expected_capability_digest};
177
+ if (request.task_id!=="task-skipped") result.trajectory_ref=request.arm+"-r"+request.repetition+"-"+request.pass+"-"+request.task_id;
178
+ process.stdout.write(JSON.stringify(result));
179
+ `);
180
+ writeFileSync(distillerScript, `
181
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
182
+ import { join } from "node:path";
183
+ const value=(flag)=>process.argv[process.argv.indexOf(flag)+1];
184
+ const session=value("--session");
185
+ appendFileSync(process.env.EVAL_DISTILL_LOG,session+"\\n");
186
+ if (session.includes("task-failed")) process.exit(7);
187
+ const created=[];
188
+ if (session.includes("task-success")) {
189
+ const directory=join(process.env.CAIRN_AGENTFS_BASE_DIR,"notes","projects","task-success");
190
+ mkdirSync(directory,{recursive:true});
191
+ const path=join(directory,"note.md"); writeFileSync(path,"same-task-note\\n"); created.push({id:"fixture-note",path});
192
+ }
193
+ process.stdout.write(JSON.stringify({schema_version:1,enabled:true,created,updated:[],already_processed:[],enrichment_skipped:[],enrichment_failed:[],failed:[]}));
194
+ `);
195
+
196
+ const taskIds = ["task-success", "task-no-notes", "task-failed", "task-skipped"];
197
+ const tasks = taskIds.map((id) => ({
198
+ id, input: id, workspace: { path: "." },
199
+ prepare: { program: process.execPath, args: ["-e", "process.exit(0)"] },
200
+ verify: { program: process.execPath, args: ["-e", "process.exit(require('node:fs').existsSync('answer.txt')?0:9)"] },
201
+ limits: { elapsed_ms: 2_000, stdout_bytes: 16_384 },
202
+ }));
203
+ function makePlan(selectedTasks=tasks) {
204
+ const taskSet={schema_version:1,id:"two-pass-fixture",source:{kind:"git",repository:".",revision},tasks:selectedTasks};
205
+ const schedule=buildEvalSchedule({taskSet,arms:[{id:"baseline",disabled_capability:null}],repetitions:1,passes:["run1","run2"],seed:"fixture-seed"});
206
+ return {
207
+ schema_version:1, task_set:taskSet,
208
+ adapter_config:{schema_version:1,id:"fixture-adapter",command:{program:process.execPath,args:[adapterScript]},turn_semantics:{id:"fixture-turn",description:"One deterministic fake turn."}},
209
+ task_set_path:join(repo,"task-set.json"), adapter_path:adapterScript, output_root:join(fixtureRoot,"output"),
210
+ source:{kind:"git",repository_root:repo,revision}, repetitions:1, seed:"fixture-seed",
211
+ arms:[{id:"baseline",disabled_capability:null}], passes:["run1","run2"], concurrency:1,
212
+ invocation_count:schedule.invocation_count, task_set_commit:revision, task_set_digest:"1".repeat(64),
213
+ adapter_config_digest:"2".repeat(64), schedule_digest:schedule.digest, plan_digest:"3".repeat(64), schedule:schedule.rows,
214
+ resolved_programs:{adapter:process.execPath,prepare:selectedTasks.map(()=>process.execPath),verify:selectedTasks.map(()=>process.execPath)},
215
+ };
216
+ }
217
+ mkdirSync(join(fixtureRoot,"output"));
218
+ process.env.EVAL_INVOCATION_LOG=invocationLog;
219
+ process.env.EVAL_DISTILL_LOG=distillLog;
220
+ const plan=makePlan();
221
+ const store=await reports.createEvalReportStore({root:join(fixtureRoot,"reports"),experiment_id:"two-pass-fixture"});
222
+ const result=await runner.runTwoPassExperiment({plan,report_store:store,temporary_root:fixtureRoot,distill_command:{program:process.execPath,args:[distillerScript]}});
223
+ assert.equal(result.report.status,"final");
224
+ assert.deepEqual(result.report.observations.map(({observation_id})=>observation_id),plan.schedule.map(({observation_id})=>observation_id));
225
+ assert.deepEqual(readFileSync(invocationLog,"utf8").trim().split("\n"),[
226
+ ...taskIds.map((id)=>`${id}:run1`), ...taskIds.map((id)=>`${id}:run2`),
227
+ ]);
228
+ assert.deepEqual(readFileSync(distillLog,"utf8").trim().split("\n"),taskIds.slice(0,3).map((id)=>`baseline-r0-run1-${id}`));
229
+ const run2=result.report.observations.filter(({pass})=>pass==="run2");
230
+ assert.deepEqual(run2.map(({notes})=>notes.distillation_outcome),["success","no_notes","failed","skipped"]);
231
+ assert.deepEqual(run2.map(({notes})=>notes.notes_exposed),[true,false,false,false]);
232
+ assert.equal(run2.every(({pass_state})=>pass_state==="passed"),true);
233
+ assert.equal(result.report.observations.every(({process})=>process.cleanup==="closed"),true);
234
+ const success=run2[0];
235
+ assert.equal(success.notes.note_snapshot_manifest.length>0,true);
236
+ assert.equal((statSync(result.snapshots[0].root_path).mode&0o222),0,"snapshot root remained writable");
237
+ assert.equal(await runner.verifyNoteSnapshot(result.snapshots[0]),true);
238
+ assert.equal(readdirSync(fixtureRoot).some((name)=>name.startsWith("cairn-eval-workspace-")),false,"workspace survived completed run");
239
+
240
+ const productionAdapterScript = join(fixtureRoot, "production-note-adapter.mjs");
241
+ const productionRequestLog = join(fixtureRoot, "production-note-requests.log");
242
+ writeFileSync(productionAdapterScript, `
243
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
244
+ import { join, resolve } from "node:path";
245
+ import { DatabaseSync } from "node:sqlite";
246
+ const chunks=[]; for await (const chunk of process.stdin) chunks.push(chunk);
247
+ const request=JSON.parse(Buffer.concat(chunks).toString("utf8"));
248
+ const taskRoot=resolve(process.cwd(),request.workspace_path);
249
+ const trajectoryRoot=request.task_id==="nested-misplaced" ? resolve(process.cwd(),"source") : taskRoot;
250
+ const sessionId=request.arm+"-r"+request.repetition+"-"+request.pass+"-"+request.task_id;
251
+ appendFileSync(process.env.EVAL_PRODUCTION_REQUEST_LOG,JSON.stringify({task_id:request.task_id,pass:request.pass,workspace_path:request.workspace_path})+"\\n");
252
+ writeFileSync(join(taskRoot,"answer.txt"),"ok\\n");
253
+ const agentfs=join(trajectoryRoot,".agentfs");
254
+ mkdirSync(agentfs,{recursive:true,mode:0o700});
255
+ const database=new DatabaseSync(join(agentfs,"trajectory.db"));
256
+ database.exec("CREATE TABLE kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch ()), updated_at INTEGER DEFAULT (unixepoch ()))");
257
+ const startedAt="2026-01-01T00:00:00.000Z";
258
+ const endedAt="2026-01-01T00:00:01.000Z";
259
+ const events=[
260
+ {sequence:0,kind:"tool_invocation",payload:{call_id:"fixture-call",tool_name:"fixture-check",input:{task:request.task_id}}},
261
+ {sequence:1,kind:"tool_result",payload:{call_id:"fixture-call",is_error:true,error:"Deterministic fixture failure"}},
262
+ ];
263
+ const session={schema_version:1,session_id:sessionId,harness:"pi",project_root:trajectoryRoot,started_at:startedAt,ended_at:endedAt,events,capture:{captured_at:endedAt,omitted_reasoning_blocks:0,omitted_unknown_records:0,truncated:false}};
264
+ const index={schema_version:1,session_id:sessionId,harness:"pi",started_at:startedAt,ended_at:endedAt,event_count:events.length,logical_bytes:Buffer.byteLength(JSON.stringify(session)),full_key:"trajectory/session/"+sessionId};
265
+ const insert=database.prepare("INSERT INTO kv_store(key,value) VALUES(?,?)");
266
+ insert.run("trajectory/meta/schema-version","1");
267
+ insert.run("trajectory/session/"+sessionId,JSON.stringify(session));
268
+ insert.run("trajectory/index/"+String(Date.parse(endedAt)).padStart(16,"0")+"/"+sessionId,JSON.stringify(index));
269
+ database.close();
270
+ process.stdout.write(JSON.stringify({schema_version:1,status:"completed",turns:{value:1,semantics:"fixture-turn"},usage:{total_tokens:10},adapter:{id:"fixture-adapter"},observed_capability_digest:request.expected_capability_digest,trajectory_ref:sessionId}));
271
+ `);
272
+
273
+ const productionTasks = [
274
+ { id: "root-production", input: "root", workspace: { path: "." } },
275
+ { id: "nested-production", input: "nested", workspace: { path: "fixtures/nested-task" } },
276
+ { id: "nested-misplaced", input: "misplaced", workspace: { path: "fixtures/nested-task" } },
277
+ ].map((task) => ({
278
+ ...task,
279
+ prepare: { program: process.execPath, args: ["-e", "process.exit(0)"] },
280
+ verify: { program: process.execPath, args: ["-e", "process.exit(require('node:fs').existsSync('answer.txt')?0:9)"] },
281
+ limits: { elapsed_ms: 2_000, stdout_bytes: 16_384 },
282
+ }));
283
+ function makeProductionPlan() {
284
+ const taskSet={schema_version:1,id:"production-note-fixture",source:{kind:"git",repository:".",revision},tasks:productionTasks};
285
+ const schedule=buildEvalSchedule({taskSet,arms:[{id:"baseline",disabled_capability:null}],repetitions:1,passes:["run1","run2"],seed:"production-note-seed"});
286
+ return {
287
+ schema_version:1, task_set:taskSet,
288
+ adapter_config:{schema_version:1,id:"fixture-adapter",command:{program:process.execPath,args:[productionAdapterScript]},turn_semantics:{id:"fixture-turn",description:"One deterministic fake turn."}},
289
+ task_set_path:join(repo,"production-note-task-set.json"), adapter_path:productionAdapterScript, output_root:join(fixtureRoot,"production-note-output"),
290
+ source:{kind:"git",repository_root:repo,revision}, repetitions:1, seed:"production-note-seed",
291
+ arms:[{id:"baseline",disabled_capability:null}], passes:["run1","run2"], concurrency:1,
292
+ invocation_count:schedule.invocation_count, task_set_commit:revision, task_set_digest:"4".repeat(64),
293
+ adapter_config_digest:"5".repeat(64), schedule_digest:schedule.digest, plan_digest:"6".repeat(64), schedule:schedule.rows,
294
+ resolved_programs:{adapter:process.execPath,prepare:productionTasks.map(()=>process.execPath),verify:productionTasks.map(()=>process.execPath)},
295
+ };
296
+ }
297
+ process.env.EVAL_PRODUCTION_REQUEST_LOG=productionRequestLog;
298
+ const productionPlan=makeProductionPlan();
299
+ const productionStore=await reports.createEvalReportStore({root:join(fixtureRoot,"reports"),experiment_id:"production-note-fixture"});
300
+ const productionResult=await runner.runTwoPassExperiment({plan:productionPlan,report_store:productionStore,temporary_root:fixtureRoot});
301
+ assert.equal(productionResult.report.status,"final");
302
+ const observation=(taskId,pass)=>productionResult.report.observations.find((item)=>item.task_id===taskId&&item.pass===pass);
303
+ const rootRun1=observation("root-production","run1");
304
+ const rootRun2=observation("root-production","run2");
305
+ assert.equal(rootRun1.notes.distillation_outcome,"success","root workspace trajectory must remain compatible");
306
+ assert.equal(rootRun2.notes.notes_exposed,true,"root workspace snapshot must reach Run 2");
307
+ const nestedRun1=observation("nested-production","run1");
308
+ const nestedRun2=observation("nested-production","run2");
309
+ assert.equal(nestedRun1.notes.distillation_outcome,"success","nested workspace trajectory must distill from the admitted task root");
310
+ assert.equal(nestedRun1.notes.note_snapshot_digest!==null,true);
311
+ assert.equal(nestedRun2.notes.notes_exposed,true);
312
+ assert.equal(nestedRun2.notes.note_snapshot_digest,nestedRun1.notes.note_snapshot_digest);
313
+ const misplacedRun1=observation("nested-misplaced","run1");
314
+ const misplacedRun2=observation("nested-misplaced","run2");
315
+ assert.equal(misplacedRun1.terminal_state,"completed");
316
+ assert.equal(misplacedRun1.pass_state,"passed");
317
+ assert.equal(misplacedRun1.notes.distillation_outcome,"failed");
318
+ assert.equal(misplacedRun1.notes.eligibility_reason,"distillation_failed");
319
+ assert.equal(misplacedRun1.notes.note_snapshot_digest,null);
320
+ assert.equal(misplacedRun1.missing_reasons.includes("notes_failed"),true);
321
+ assert.equal(misplacedRun2.notes.notes_exposed,false);
322
+ assert.equal(productionResult.snapshots.length,2);
323
+ for (const snapshot of productionResult.snapshots) {
324
+ assert.equal((statSync(snapshot.root_path).mode&0o222),0,"production snapshot root remained writable");
325
+ assert.equal(await runner.verifyNoteSnapshot(snapshot),true);
326
+ }
327
+ const productionRequests=readFileSync(productionRequestLog,"utf8").trim().split("\n").map((line)=>JSON.parse(line));
328
+ assert.deepEqual(productionRequests.filter(({pass})=>pass==="run1").map(({task_id,workspace_path})=>[task_id,workspace_path]),[
329
+ ["root-production","source"],
330
+ ["nested-production","source/fixtures/nested-task"],
331
+ ["nested-misplaced","source/fixtures/nested-task"],
332
+ ]);
333
+ assert.equal(readdirSync(fixtureRoot).some((name)=>name.startsWith("cairn-eval-workspace-")),false,"workspace survived production note run");
334
+
335
+ const cancelTask={...tasks[0],id:"task-cancel",input:"cancel"};
336
+ const cancelPlan=makePlan([cancelTask]);
337
+ const cancelStore=await reports.createEvalReportStore({root:join(fixtureRoot,"reports"),experiment_id:"cancel-fixture"});
338
+ const controller=new AbortController();
339
+ setTimeout(()=>controller.abort(),100);
340
+ const cancelled=await runner.runTwoPassExperiment({plan:cancelPlan,report_store:cancelStore,temporary_root:fixtureRoot,signal:controller.signal,distill_command:{program:process.execPath,args:[distillerScript]}});
341
+ assert.equal(cancelled.report.status,"partial");
342
+ assert.deepEqual(cancelled.report.observations.map(({terminal_state})=>terminal_state),["cancelled"]);
343
+ assert.equal(cancelled.report.observations[0].process.cleanup!=="pending",true);
344
+ assert.equal(readdirSync(fixtureRoot).some((name)=>name.startsWith("cairn-eval-workspace-")),false,"workspace survived cancellation");
345
+ assert.equal(existsSync(cancelStore.report_path),true);
346
+ execFileSync("chmod",["-R","u+w",fixtureRoot]);
347
+ NODE
348
+ echo "PASS: Phase 19 same-task two-pass and immutable note-snapshot contract"
349
+ }
350
+
351
+ run_ablation() {
352
+ require_exports "$runner" buildAblationArms runCapabilityAblation
353
+ node --input-type=module - "$runner" "$report" "$ROOT/mcp-memory-server/dist/eval-plan.js" "$ROOT/mcp-memory-server/dist/capability-schema.js" "$tmp" <<'NODE'
354
+ import assert from "node:assert/strict";
355
+ import { execFileSync } from "node:child_process";
356
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
357
+ import { join } from "node:path";
358
+ import { pathToFileURL } from "node:url";
359
+
360
+ const [runnerPath, reportPath, planPath, capabilityPath, fixtureRoot] = process.argv.slice(2);
361
+ const runner = await import(pathToFileURL(runnerPath).href);
362
+ const reports = await import(pathToFileURL(reportPath).href);
363
+ const { buildEvalSchedule } = await import(pathToFileURL(planPath).href);
364
+ const { CAPABILITY_IDS } = await import(pathToFileURL(capabilityPath).href);
365
+ const repo = join(fixtureRoot, "ablation-repo");
366
+ mkdirSync(repo);
367
+ const git = (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
368
+ git("init", "-q");
369
+ git("config", "user.name", "Evaluation Fixture");
370
+ git("config", "user.email", "evaluation-fixture@example.invalid");
371
+ writeFileSync(join(repo, "source.txt"), "immutable-source\n");
372
+ git("add", "source.txt");
373
+ git("commit", "-qm", "fixture");
374
+ const revision = git("rev-parse", "HEAD");
375
+ const adapterScript = join(fixtureRoot, "ablation-adapter.mjs");
376
+ const distillerScript = join(fixtureRoot, "ablation-distiller.mjs");
377
+ const invocationLog = join(fixtureRoot, "ablation-invocations.log");
378
+ const distillLog = join(fixtureRoot, "ablation-distill.log");
379
+ writeFileSync(adapterScript, `
380
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
381
+ import { join } from "node:path";
382
+ const chunks=[]; for await (const chunk of process.stdin) chunks.push(chunk);
383
+ const request=JSON.parse(Buffer.concat(chunks).toString("utf8"));
384
+ const ids=${JSON.stringify(CAPABILITY_IDS)};
385
+ const name=(id)=>"CAIRN_CAPABILITY_"+id.toUpperCase().replaceAll(".","_");
386
+ const disabled=ids.filter((id)=>process.env[name(id)]!=="1");
387
+ if (process.env.CAIRN_CAPABILITY_CONTRACT!=="1") process.exit(41);
388
+ if ((request.arm==="baseline"&&disabled.length!==0)||(request.arm==="treatment"&&disabled.length!==1)) process.exit(42);
389
+ if (existsSync(join(process.env.HOME,"leak"))) process.exit(43);
390
+ writeFileSync(join(process.env.HOME,"leak"),"sentinel");
391
+ if (request.pass==="run2") {
392
+ if (disabled[0]==="notes.distill") {
393
+ if (request.notes_path!==null) process.exit(44);
394
+ } else {
395
+ const expected=disabled[0]??"baseline";
396
+ if (request.notes_path===null||readFileSync(join(process.cwd(),request.notes_path,"note.md"),"utf8")!==expected+"\\n") process.exit(45);
397
+ }
398
+ }
399
+ appendFileSync(process.env.EVAL_ABLATION_LOG,[process.pid,request.arm,request.repetition,request.pass,request.task_id,request.seed,disabled[0]??"baseline"].join(":")+"\\n");
400
+ writeFileSync(join(process.cwd(),request.workspace_path,"answer.txt"),"ok\\n");
401
+ process.stdout.write(JSON.stringify({schema_version:1,status:"completed",turns:{value:1,semantics:"fixture-turn"},usage:{total_tokens:10},adapter:{id:"fixture-adapter"},observed_capability_digest:process.env.EVAL_FORCE_BAD_DIGEST==="1"?"0".repeat(64):request.expected_capability_digest,trajectory_ref:request.arm+"-r"+request.repetition+"-"+request.pass+"-"+request.task_id}));
402
+ `);
403
+ writeFileSync(distillerScript, `
404
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
405
+ import { join } from "node:path";
406
+ const ids=${JSON.stringify(CAPABILITY_IDS)};
407
+ const name=(id)=>"CAIRN_CAPABILITY_"+id.toUpperCase().replaceAll(".","_");
408
+ const disabled=ids.find((id)=>process.env[name(id)]!=="1")??"baseline";
409
+ appendFileSync(process.env.EVAL_DISTILL_LOG,disabled+"\\n");
410
+ const directory=join(process.env.CAIRN_AGENTFS_BASE_DIR,"notes"); mkdirSync(directory,{recursive:true});
411
+ const path=join(directory,"note.md"); writeFileSync(path,disabled+"\\n");
412
+ process.stdout.write(JSON.stringify({schema_version:1,enabled:true,created:[{id:"fixture-note",path}],updated:[],already_processed:[],enrichment_skipped:[],enrichment_failed:[],failed:[]}));
413
+ `);
414
+ const task={id:"task-alpha",input:"fixture",workspace:{path:"."},prepare:{program:process.execPath,args:["-e","process.exit(0)"]},verify:{program:process.execPath,args:["-e","process.exit(require('node:fs').existsSync('answer.txt')?0:9)"]},limits:{elapsed_ms:2_000,stdout_bytes:16_384}};
415
+ const taskSet={schema_version:1,id:"ablation-fixture",source:{kind:"git",repository:".",revision},tasks:[task]};
416
+ function makePlan(disabled, outputRoot) {
417
+ const arms=runner.buildAblationArms(disabled).map(({id,disabled_capability})=>({id,disabled_capability}));
418
+ const schedule=buildEvalSchedule({taskSet,arms,repetitions:1,passes:["run1","run2"],seed:"fixture-seed"});
419
+ return {schema_version:1,task_set:taskSet,adapter_config:{schema_version:1,id:"fixture-adapter",command:{program:process.execPath,args:[adapterScript]},turn_semantics:{id:"fixture-turn",description:"One deterministic turn."}},task_set_path:join(repo,"task-set.json"),adapter_path:adapterScript,output_root:outputRoot,source:{kind:"git",repository_root:repo,revision},repetitions:1,seed:"fixture-seed",arms,passes:["run1","run2"],concurrency:1,invocation_count:schedule.invocation_count,task_set_commit:revision,task_set_digest:"1".repeat(64),adapter_config_digest:"2".repeat(64),schedule_digest:schedule.digest,plan_digest:"3".repeat(64),schedule:schedule.rows,resolved_programs:{adapter:process.execPath,prepare:[process.execPath],verify:[process.execPath]}};
420
+ }
421
+ process.env.EVAL_ABLATION_LOG=invocationLog;
422
+ process.env.EVAL_DISTILL_LOG=distillLog;
423
+ for (const disabled of CAPABILITY_IDS) {
424
+ const arms=runner.buildAblationArms(disabled);
425
+ assert.deepEqual(arms.map(({id})=>id),["baseline","treatment"]);
426
+ assert.deepEqual(arms[0].expected_capabilities.map(({enabled})=>enabled),Array(8).fill(true));
427
+ assert.equal(arms[1].expected_capabilities.filter(({enabled})=>!enabled).length,1);
428
+ assert.equal(arms[1].expected_capabilities.find(({id})=>id===disabled).enabled,false);
429
+ assert.notEqual(arms[0].expected_configuration_digest,arms[1].expected_configuration_digest);
430
+ const plan=makePlan(disabled,join(fixtureRoot,`output-${disabled.replaceAll(".","-")}`));
431
+ const store=await reports.createEvalReportStore({root:plan.output_root,experiment_id:`ablate-${disabled.replaceAll(".","-")}`});
432
+ const result=await runner.runCapabilityAblation({plan,disabled_capability:disabled,report_store:store,temporary_root:fixtureRoot,distill_command:{program:process.execPath,args:[distillerScript]}});
433
+ assert.equal(result.report.status,"final");
434
+ assert.equal(result.report.observations.length,4);
435
+ assert.deepEqual(result.report.observations.map(({observation_id})=>observation_id),plan.schedule.map(({observation_id})=>observation_id));
436
+ assert.equal(new Set(result.report.observations.map(({four_cell_id})=>four_cell_id)).size,1);
437
+ assert.equal(new Set(result.report.observations.map(({seed})=>seed)).size,1);
438
+ assert.equal(result.report.observations.every(({capability_status,capability_digest_match})=>capability_status==="valid"&&capability_digest_match===true),true);
439
+ assert.equal(result.report.observations.every(({expected_capabilities,observed_capabilities})=>JSON.stringify(expected_capabilities)===JSON.stringify(observed_capabilities)),true);
440
+ const treatmentRun2=result.report.observations.find(({arm,pass})=>arm==="treatment"&&pass==="run2");
441
+ assert.ok(treatmentRun2);
442
+ assert.equal(treatmentRun2.notes.notes_exposed,disabled!=="notes.distill");
443
+ }
444
+ const lines=readFileSync(invocationLog,"utf8").trim().split("\n");
445
+ assert.equal(lines.length,CAPABILITY_IDS.length*4);
446
+ assert.equal(new Set(lines.map((line)=>line.split(":")[0])).size,lines.length,"an adapter owner process was reused");
447
+ assert.equal(readFileSync(distillLog,"utf8").trim().split("\n").length,CAPABILITY_IDS.length*2-1,"notes.distill treatment was distilled");
448
+
449
+ process.env.EVAL_FORCE_BAD_DIGEST="1";
450
+ const mismatchPlan=makePlan("memory.write",join(fixtureRoot,"mismatch-output"));
451
+ const mismatch=await runner.runCapabilityAblation({plan:mismatchPlan,disabled_capability:"memory.write",temporary_root:fixtureRoot,distill_command:{program:process.execPath,args:[distillerScript]}});
452
+ assert.equal(mismatch.report.observations.every(({capability_status,capability_digest_match,missing_reasons})=>capability_status==="mismatch"&&capability_digest_match===false&&missing_reasons.includes("capability_mismatch")),true);
453
+ NODE
454
+ local task_set="$ROOT/examples/eval/task-set.json"
455
+ local adapter="$ROOT/examples/eval/adapter.json"
456
+ local preview_root="$tmp/ablation-preview-output"
457
+ local capability
458
+ for capability in memory.write memory.search notes.distill wiki graph security.audit route.check context.explore; do
459
+ set +e
460
+ CAIRN_EVAL=1 node "$eval_cli" ablate --disable "$capability" --task-set "$task_set" --adapter "$adapter" --output "$preview_root" --json >"$tmp/preview-$capability.json" 2>"$tmp/preview-$capability.err"
461
+ local preview_status=$?
462
+ set -e
463
+ [[ "$preview_status" -eq 2 ]] || fail "ablation preview for $capability did not require confirmation"
464
+ node - "$tmp/preview-$capability.json" "$capability" <<'NODE'
465
+ const { readFileSync } = require("node:fs");
466
+ const value=JSON.parse(readFileSync(process.argv[2],"utf8"));
467
+ const capability=process.argv[3];
468
+ if (value.operation!=="ablate-preview"||value.disabled_capability!==capability||value.invocation_count!==40||value.arms.length!==2) process.exit(1);
469
+ if (value.arms[0].expected_capabilities.some(({enabled})=>!enabled)) process.exit(1);
470
+ if (value.arms[1].expected_capabilities.filter(({enabled})=>!enabled).map(({id})=>id).join("")!==capability) process.exit(1);
471
+ if (value.arms.some(({expected_configuration_digest})=>!/^[a-f0-9]{64}$/.test(expected_configuration_digest))) process.exit(1);
472
+ NODE
473
+ done
474
+ [[ ! -e "$preview_root" ]] || fail "unconfirmed ablation preview mutated output"
475
+
476
+ set +e
477
+ CAIRN_EVAL=1 node "$eval_cli" ablate --disable memory.write --disable wiki --task-set "$task_set" --adapter "$adapter" --output "$preview_root" --json >/dev/null 2>&1
478
+ local repeated_status=$?
479
+ CAIRN_EVAL=1 node "$eval_cli" ablate --disable memory.write,wiki --task-set "$task_set" --adapter "$adapter" --output "$preview_root" --json >/dev/null 2>&1
480
+ local combined_status=$?
481
+ CAIRN_EVAL=1 node "$eval_cli" ablate --disable unknown --task-set "$task_set" --adapter "$adapter" --output "$preview_root" --json >/dev/null 2>&1
482
+ local unknown_status=$?
483
+ set -e
484
+ [[ "$repeated_status" -eq 2 && "$combined_status" -eq 2 && "$unknown_status" -eq 2 ]] || fail "multifactor or unknown ablation was accepted"
485
+ [[ ! -e "$preview_root" ]] || fail "rejected ablation mutated output"
486
+
487
+ CAIRN_EVAL=1 node "$eval_cli" ablate --disable notes.distill --task-set "$task_set" --adapter "$adapter" --output "$preview_root" >"$tmp/preview-human.txt" 2>/dev/null || true
488
+ grep -q 'Capability ablation will perform 40 serial adapter invocation' "$tmp/preview-human.txt" || fail "human ablation preview omits estimate"
489
+ grep -q 'baseline: [a-f0-9]\{64\}' "$tmp/preview-human.txt" || fail "human ablation preview omits baseline digest"
490
+ grep -q 'treatment: [a-f0-9]\{64\}' "$tmp/preview-human.txt" || fail "human ablation preview omits treatment digest"
491
+ grep -q 'notes.distill: disabled' "$tmp/preview-human.txt" || fail "human ablation preview omits treatment state"
492
+
493
+ local cancel_root="$tmp/ablation-cancel-output"
494
+ local child_pid_file="$tmp/ablation-adapter.pid"
495
+ CAIRN_EVAL=1 CAIRN_FAKE_CANCEL_ALL=1 CAIRN_FAKE_PID_FILE="$child_pid_file" \
496
+ node "$eval_cli" ablate --disable memory.write --task-set "$task_set" --adapter "$adapter" --output "$cancel_root" --yes --json \
497
+ >"$tmp/ablation-cancel.json" 2>"$tmp/ablation-cancel.err" &
498
+ local cli_pid=$!
499
+ local attempt=0
500
+ while [[ ! -s "$child_pid_file" && "$attempt" -lt 100 ]]; do
501
+ sleep 0.05
502
+ attempt=$((attempt + 1))
503
+ done
504
+ [[ -s "$child_pid_file" ]] || { kill -TERM "$cli_pid" 2>/dev/null || true; fail "ablation cancellation child did not start"; }
505
+ local child_pid
506
+ child_pid=$(cat "$child_pid_file")
507
+ kill -INT "$cli_pid"
508
+ set +e
509
+ wait "$cli_pid"
510
+ local cancel_status=$?
511
+ set -e
512
+ [[ "$cancel_status" -eq 130 ]] || fail "cancelled ablation exited with $cancel_status instead of 130"
513
+ if kill -0 "$child_pid" 2>/dev/null; then fail "cancelled ablation child survived CLI exit"; fi
514
+ node - "$tmp/ablation-cancel.json" <<'NODE'
515
+ const { existsSync, readFileSync } = require("node:fs");
516
+ const value=JSON.parse(readFileSync(process.argv[2],"utf8"));
517
+ if (value.operation!=="ablate"||value.disabled_capability!=="memory.write"||value.invocation_count!==40||value.status!=="partial"||!existsSync(value.report_path)) process.exit(1);
518
+ const report=JSON.parse(readFileSync(value.report_path,"utf8"));
519
+ if (report.experiment_kind!=="ablation"||report.schedule.length!==40||report.observations.length!==1) process.exit(1);
520
+ if (report.observations[0].terminal_state!=="cancelled"||report.observations[0].process.cleanup==="pending") process.exit(1);
521
+ NODE
522
+ echo "PASS: Phase 19 explicit eight-on versus one-off four-cell ablation contract"
523
+ }
524
+
525
+ create_report_fixtures() {
526
+ local project=$1
527
+ mkdir -p "$project"
528
+ node --input-type=module - "$report" "$project" <<'NODE'
529
+ import { chmodSync, readFileSync, writeFileSync } from "node:fs";
530
+ import { join } from "node:path";
531
+ import { pathToFileURL } from "node:url";
532
+ const [modulePath, project] = process.argv.slice(2);
533
+ const reports = await import(pathToFileURL(modulePath).href);
534
+ const digest = "b".repeat(64);
535
+ const make = (id, status) => ({
536
+ schema_version:1, experiment_id:id, status, experiment_kind:"two_pass",
537
+ task_set_digest:digest, adapter_config_digest:digest, source_revision:digest, schedule_digest:digest,
538
+ created_at:"2026-01-01T00:00:00.000Z", updated_at:"2026-01-01T00:00:01.000Z",
539
+ runtime:{platform:"linux",arch:"x64",node:"v24",cairnkeep:"fixture"}, schedule:[], observations:[], aggregates:[],
540
+ missingness:{digest,count:0,reasons:[]}, warnings:[],
541
+ evidence:{schema_version:1,evidence_scope:"offline-framework",source_commit:digest,package_version:"fixture",
542
+ runtime_id:"node-local",task_set_digest:digest,report_digest:digest,schema_digests:[digest,digest],note_snapshot_digests:[],
543
+ missingness_digest:digest,claim_anchors:[]},
544
+ });
545
+ const root=join(project,".agentfs","eval","experiments");
546
+ for (const [id,status] of [["final-a","final"],["partial-b","partial"],["old-c","final"]]) {
547
+ const store=await reports.createEvalReportStore({root,experiment_id:id});
548
+ await reports.checkpointEvalReport(store,make(id,status));
549
+ }
550
+ const oldPath=join(root,"old-c","report.json");
551
+ const old=JSON.parse(readFileSync(oldPath,"utf8")); old.updated_at="2020-01-01T00:00:00.000Z";
552
+ writeFileSync(oldPath,JSON.stringify(old)+"\n",{mode:0o600}); chmodSync(oldPath,0o600);
553
+ NODE
554
+ }
555
+
556
+ run_report() {
557
+ require_exports "$report" readEvalReport buildEvalAggregates renderEvalReport
558
+ node "$eval_cli" --help | grep -q 'report' || fail "eval help omits report"
559
+ node --input-type=module - "$report" <<'NODE'
560
+ import assert from "node:assert/strict";
561
+ import { pathToFileURL } from "node:url";
562
+
563
+ const reports = await import(pathToFileURL(process.argv[2]).href);
564
+ const ids = ["memory.write", "memory.search", "notes.distill", "wiki", "graph", "security.audit", "route.check", "context.explore"];
565
+ const digest = "a".repeat(64);
566
+ const states = ids.map((id) => ({ id, enabled: true }));
567
+ const note = (outcome, exposed) => ({
568
+ distiller_id: outcome === "success" ? "fixture-distiller" : null,
569
+ distiller_config_digest: outcome === "success" ? digest : null,
570
+ trajectory_ref: outcome === "success" ? "fixture-trajectory" : null,
571
+ distillation_outcome: outcome,
572
+ eligibility_reason: outcome === "success" ? "same_task_snapshot" : `notes_${outcome}`,
573
+ note_snapshot_digest: outcome === "success" ? digest : null,
574
+ note_snapshot_manifest: [], notes_exposed: exposed,
575
+ });
576
+ const result = (turns, totalTokens) => ({
577
+ schema_version: 1, status: "completed", turns: { value: turns, semantics: "fixture-turn" },
578
+ ...(totalTokens === null ? {} : { usage: { total_tokens: totalTokens } }), observed_capability_digest: digest,
579
+ });
580
+ const rows = [
581
+ ["task-a", "run1", 10, 100, "passed", note("success", false), []],
582
+ ["task-a", "run2", 8, 80, "passed", note("success", true), []],
583
+ ["task-b", "run1", 20, null, "passed", note("no_notes", false), ["notes_no_notes"]],
584
+ ["task-b", "run2", 24, null, "failed", note("no_notes", false), ["notes_no_notes"]],
585
+ ["task-c", "run1", 5, 50, "unknown", note("failed", false), ["verifier_unknown", "notes_failed"]],
586
+ ["task-c", "run2", 4, 40, "passed", note("failed", false), ["notes_failed"]],
587
+ ];
588
+ const observations = rows.map(([taskId, pass, turns, tokens, passState, notes, missing], index) => ({
589
+ schema_version: 1, observation_id: `o-${index}`, task_id: taskId, schedule_index: index,
590
+ arm: "baseline", disabled_capability: null, arm_order: 0, repetition: 0, pass,
591
+ seed: `seed-${taskId}`, state: "terminal", terminal_state: passState === "failed" ? "verifier_failed" : "completed",
592
+ process: { exit_code: 0, signal: null, error_code: null, cleanup: "closed" },
593
+ verifier: { state: passState === "unknown" ? "error" : "completed", reason: passState === "unknown" ? "verifier_unknown" : null },
594
+ pass_state: passState, expected_capabilities: states, observed_capabilities: states,
595
+ expected_capability_digest: digest, observed_capability_digest: digest,
596
+ capability_status: "valid", capability_digest_match: true, four_cell_id: `pair-${taskId}`,
597
+ notes, result: result(turns, tokens), missing_reasons: missing,
598
+ }));
599
+ const schedule = observations.map(({ observation_id, task_id, arm, repetition, pass, seed }) => ({ observation_id, task_id, arm, repetition, pass, seed }));
600
+ const base = {
601
+ schema_version: 1, experiment_id: "report-fixture", status: "final", experiment_kind: "two_pass",
602
+ task_set_digest: digest, adapter_config_digest: digest, source_revision: digest, schedule_digest: digest,
603
+ created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:01.000Z",
604
+ runtime: { platform: "linux", arch: "x64", node: "v24", cairnkeep: "fixture" }, schedule, observations,
605
+ aggregates: [], missingness: { digest, count: 0, reasons: [] }, warnings: [],
606
+ evidence: { schema_version: 1, evidence_scope: "offline-framework", source_commit: digest,
607
+ package_version: "fixture", runtime_id: "node-local", task_set_digest: digest, report_digest: digest,
608
+ schema_digests: [digest, digest], note_snapshot_digests: [digest], missingness_digest: digest, claim_anchors: [] },
609
+ };
610
+ const aggregates = reports.buildEvalAggregates(base);
611
+ const byMetric = (metric) => aggregates.find(({ comparison_id, metric_id }) => comparison_id === "memory-baseline" && metric_id === metric);
612
+ const levels = (metric) => byMetric(metric).condition_levels;
613
+ assert.equal(byMetric("turns").valid_pair_count, 3);
614
+ assert.equal(byMetric("turns").population.full, 3);
615
+ assert.equal(byMetric("turns").population.note_eligible, 1);
616
+ assert.equal(byMetric("total_tokens").valid_pair_count, 2, "missing totals were inferred");
617
+ assert.equal(byMetric("total_tokens").missing.reasons.includes("total_tokens_missing"), true);
618
+ assert.equal(byMetric("pass_rate").valid_pair_count, 2, "unknown verifier outcome entered pass-rate pairs");
619
+ assert.equal(byMetric("pass_rate").missing.reasons.includes("verifier_unknown"), true);
620
+ assert.deepEqual(levels("turns"), [
621
+ { arm:"baseline", pass:"run1", value:35/3, valid_task_ids:["task-a","task-b","task-c"], valid_task_count:3,
622
+ population:{full:3,executed:3,eligible:3}, missing:{count:0,reasons:[]} },
623
+ { arm:"baseline", pass:"run2", value:12, valid_task_ids:["task-a","task-b","task-c"], valid_task_count:3,
624
+ population:{full:3,executed:3,eligible:3}, missing:{count:0,reasons:[]} },
625
+ ]);
626
+ assert.deepEqual(levels("total_tokens"), [
627
+ { arm:"baseline", pass:"run1", value:75, valid_task_ids:["task-a","task-c"], valid_task_count:2,
628
+ population:{full:3,executed:3,eligible:3}, missing:{count:1,reasons:["total_tokens_missing"]} },
629
+ { arm:"baseline", pass:"run2", value:60, valid_task_ids:["task-a","task-c"], valid_task_count:2,
630
+ population:{full:3,executed:3,eligible:3}, missing:{count:1,reasons:["total_tokens_missing"]} },
631
+ ]);
632
+ assert.deepEqual(levels("pass_rate"), [
633
+ { arm:"baseline", pass:"run1", value:1, valid_task_ids:["task-a","task-b"], valid_task_count:2,
634
+ population:{full:3,executed:3,eligible:3}, missing:{count:1,reasons:["verifier_unknown"]} },
635
+ { arm:"baseline", pass:"run2", value:2/3, valid_task_ids:["task-a","task-b","task-c"], valid_task_count:3,
636
+ population:{full:3,executed:3,eligible:3}, missing:{count:0,reasons:[]} },
637
+ ]);
638
+ const deltaOnly = { ...byMetric("turns") };
639
+ delete deltaOnly.condition_levels;
640
+ assert.throws(() => reports.renderEvalReport({ ...base, aggregates:[deltaOnly] }), /invalid_eval_report/,
641
+ "renderer accepted a delta-only aggregate");
642
+ const human = reports.renderEvalReport({ ...base, aggregates });
643
+ for (const aggregate of aggregates) {
644
+ assert.equal(human.includes(`${aggregate.comparison_id} / ${aggregate.metric_id}`), true);
645
+ assert.equal(human.includes(`paired=${aggregate.valid_pair_count}`), true);
646
+ for (const level of aggregate.condition_levels) {
647
+ assert.equal(human.includes(`condition=${level.arm}/${level.pass}`), true);
648
+ assert.equal(human.includes(`valid=${level.valid_task_count}`), true);
649
+ }
650
+ }
651
+ assert.match(human, /framework-only/);
652
+ assert.match(human, /inconclusive/);
653
+ assert.match(human, /expected capability digest: a{64}/);
654
+ assert.match(human, /observed capability digest: a{64}/);
655
+ assert.equal(human.includes("104000"), false, "paper calibration leaked into output");
656
+ NODE
657
+ local project="$tmp/report-project"
658
+ create_report_fixtures "$project"
659
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" report --experiment final-a --json) >"$tmp/final.json"
660
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" report --experiment partial-b --json) >"$tmp/partial.json"
661
+ node - "$project" "$tmp/final.json" "$tmp/partial.json" <<'NODE'
662
+ const assert=require("node:assert/strict");
663
+ const {readFileSync}=require("node:fs");
664
+ const {join}=require("node:path");
665
+ const [project,finalPath,partialPath]=process.argv.slice(2);
666
+ const stored=(id)=>JSON.parse(readFileSync(join(project,".agentfs","eval","experiments",id,"report.json"),"utf8"));
667
+ assert.deepEqual(JSON.parse(readFileSync(finalPath,"utf8")),stored("final-a"));
668
+ assert.deepEqual(JSON.parse(readFileSync(partialPath,"utf8")),stored("partial-b"));
669
+ NODE
670
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" report --experiment final-a) >"$tmp/final.txt"
671
+ grep -q '^status: final$' "$tmp/final.txt" || fail "human report omitted final status"
672
+ grep -q '^evidence: framework-only$' "$tmp/final.txt" || fail "human report omitted fake scope"
673
+ if grep -F "$project" "$tmp/final.txt" >/dev/null; then fail "human report exposed a private report path"; fi
674
+ echo "PASS: Phase 19 canonical JSON and derived report contract"
675
+ }
676
+
677
+ run_retention() {
678
+ node "$eval_cli" --help | grep -q 'prune' || fail "eval help omits prune"
679
+ node "$eval_cli" --help | grep -q 'delete' || fail "eval help omits delete"
680
+ local project="$tmp/retention-project"
681
+ create_report_fixtures "$project"
682
+ local experiments="$project/.agentfs/eval/experiments"
683
+ echo adjacent >"$project/adjacent"
684
+ local before
685
+ before=$(sha256sum "$experiments/final-a/report.json")
686
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" delete --experiment final-a --dry-run --json) >"$tmp/delete-dry.json"
687
+ [[ "$before" == "$(sha256sum "$experiments/final-a/report.json")" ]] || fail "dry-run delete changed report bytes"
688
+ node - "$tmp/delete-dry.json" <<'NODE'
689
+ const value=require(process.argv[2]);
690
+ if(value.operation!=="delete"||value.dry_run!==true||value.deleted!==false||value.experiment_id!=="final-a")process.exit(1);
691
+ NODE
692
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" delete --experiment final-a --json) >/dev/null
693
+ [[ ! -e "$experiments/final-a" && -f "$experiments/partial-b/report.json" && -f "$project/adjacent" ]] || fail "delete escaped the named experiment"
694
+
695
+ before=$(sha256sum "$experiments/old-c/report.json")
696
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" prune --older-than-days 30 --dry-run --json) >"$tmp/prune-dry.json"
697
+ [[ "$before" == "$(sha256sum "$experiments/old-c/report.json")" ]] || fail "dry-run prune changed report bytes"
698
+ node - "$tmp/prune-dry.json" <<'NODE'
699
+ const value=require(process.argv[2]);
700
+ if(value.operation!=="prune"||value.dry_run!==true||value.removed!==0||value.experiments.join()!=="old-c")process.exit(1);
701
+ NODE
702
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" prune --older-than-days 30 --json) >/dev/null
703
+ [[ ! -e "$experiments/old-c" && -f "$experiments/partial-b/report.json" && -f "$project/adjacent" ]] || fail "prune escaped eligible experiment trees"
704
+
705
+ mkdir "$experiments/malformed-d" && chmod 700 "$experiments/malformed-d"
706
+ printf '{bad\n' >"$experiments/malformed-d/report.json" && chmod 600 "$experiments/malformed-d/report.json"
707
+ set +e
708
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" delete --experiment malformed-d --json) >/dev/null 2>&1
709
+ local malformed_status=$?
710
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" delete --experiment ../adjacent --json) >/dev/null 2>&1
711
+ local traversal_status=$?
712
+ set -e
713
+ [[ "$malformed_status" -eq 2 && "$traversal_status" -eq 2 && -f "$experiments/malformed-d/report.json" && -f "$project/adjacent" ]] || fail "malformed or traversal deletion was not fail-closed"
714
+
715
+ mkdir "$experiments/tampered-f" && chmod 700 "$experiments/tampered-f"
716
+ cp "$experiments/partial-b/report.json" "$experiments/tampered-f/report.json" && chmod 600 "$experiments/tampered-f/report.json"
717
+ chmod 644 "$experiments/partial-b/report.json"
718
+ set +e
719
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" delete --experiment tampered-f --json) >/dev/null 2>&1
720
+ local tampered_status=$?
721
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" report --experiment partial-b --json) >/dev/null 2>&1
722
+ local unsafe_mode_status=$?
723
+ set -e
724
+ [[ "$tampered_status" -eq 2 && "$unsafe_mode_status" -eq 2 && -f "$experiments/tampered-f/report.json" && -f "$experiments/partial-b/report.json" ]] || fail "tampered or unsafe-mode report was accepted"
725
+
726
+ ln -s "$project" "$experiments/link-e"
727
+ set +e
728
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" delete --experiment link-e --json) >/dev/null 2>&1
729
+ local symlink_status=$?
730
+ (cd "$project" && CAIRN_EVAL=1 node "$eval_cli" prune --older-than-days 0 --json) >/dev/null 2>&1
731
+ local unsafe_prune_status=$?
732
+ set -e
733
+ [[ "$symlink_status" -eq 2 && "$unsafe_prune_status" -eq 2 && -f "$project/adjacent" ]] || fail "symlink retention case was not fail-closed"
734
+ echo "PASS: Phase 19 contained dry-run retention contract"
735
+ }
736
+
737
+ run_claims() {
738
+ require_exports "$report" renderEvalReport
739
+ for forbidden in 'statistically significant' 'causes improvement' 'quality gain' 'proves quality' 'improved efficiency'; do
740
+ if grep -R -F "$forbidden" "$ROOT/examples/eval" "$ROOT/mcp-memory-server/src/eval-report.ts" 2>/dev/null; then
741
+ fail "generated evaluation surface contains unsupported claim wording"
742
+ fi
743
+ done
744
+ echo "PASS: Phase 19 inconclusive and evidence-bounded claim contract"
745
+ }
746
+
747
+ run_fake() {
748
+ [[ -f "$ROOT/scripts/fake-eval-adapter.mjs" ]] || fail "offline fake adapter is absent"
749
+ [[ -f "$ROOT/examples/eval/task-set.json" ]] || fail "offline committed task set is absent"
750
+ [[ -f "$ROOT/examples/eval/bundled-fake.json" ]] || fail "offline bundled binding is absent"
751
+ [[ -f "$ROOT/examples/eval/adapter.json" ]] || fail "offline adapter configuration is absent"
752
+ grep -q 'offline-framework' "$ROOT/scripts/fake-eval-adapter.mjs" || fail "fake adapter lacks permanent offline scope"
753
+ if grep -E '^import ' "$ROOT/scripts/fake-eval-adapter.mjs" | grep -v 'from "node:' >/dev/null; then
754
+ fail "fake adapter imports a non-standard-library module"
755
+ fi
756
+ node --input-type=module - "$runner" "$ROOT/mcp-memory-server/dist/eval-plan.js" "$ROOT/mcp-memory-server/dist/eval-schema.js" "$ROOT/examples/eval/task-set.json" "$ROOT/examples/eval/adapter.json" "$tmp" <<'NODE'
757
+ import assert from "node:assert/strict";
758
+ import { execFileSync } from "node:child_process";
759
+ import { chmodSync, readFileSync, writeFileSync } from "node:fs";
760
+ import { join } from "node:path";
761
+ import { pathToFileURL } from "node:url";
762
+
763
+ const [runnerPath, planPath, schemaPath, taskSetPath, adapterPath, fixtureRoot] = process.argv.slice(2);
764
+ const runner = await import(pathToFileURL(runnerPath).href);
765
+ const { loadEvalPlan } = await import(pathToFileURL(planPath).href);
766
+ const { canonicalDigest } = await import(pathToFileURL(schemaPath).href);
767
+ const outputRoot=join(fixtureRoot,"offline-output");
768
+ const options={taskSetPath,adapterPath,outputRoot,repetitions:1,seed:"offline-fixture-seed",cwd:process.cwd()};
769
+ const plan=loadEvalPlan(options);
770
+ const taskSet=JSON.parse(readFileSync(taskSetPath,"utf8"));
771
+ assert.equal(plan.source.kind,"bundled_fake");
772
+ assert.equal(plan.task_set_digest,canonicalDigest(taskSet));
773
+ assert.equal(plan.invocation_count,taskSet.tasks.length*2);
774
+ const result=await runner.runTwoPassExperiment({plan,experiment_id:"offline-fixture",temporary_root:fixtureRoot});
775
+ assert.equal(result.report.status,"final");
776
+ assert.equal(result.report.task_set_digest,canonicalDigest(taskSet));
777
+ assert.equal(result.report.evidence.task_set_digest,canonicalDigest(taskSet));
778
+ assert.equal(result.report.evidence.evidence_scope,"offline-framework");
779
+ assert.equal(result.report.observations.length,taskSet.tasks.length*2);
780
+ const byTask=(id)=>result.report.observations.filter(({task_id})=>task_id===id);
781
+ assert.equal(byTask("offline-pass-note").every(({terminal_state,pass_state})=>terminal_state==="completed"&&pass_state==="passed"),true);
782
+ assert.equal(byTask("offline-verifier-fail").every(({terminal_state,pass_state})=>terminal_state==="verifier_failed"&&pass_state==="failed"),true);
783
+ assert.equal(byTask("offline-missing-tokens").every(({result})=>result&&!Object.hasOwn(result,"usage")),true);
784
+ assert.equal(byTask("offline-no-notes")[0].notes.distillation_outcome,"no_notes");
785
+ assert.equal(byTask("offline-no-notes")[1].notes.distillation_outcome,"no_notes");
786
+ assert.equal(byTask("offline-distillation-failure").every(({notes})=>notes.distillation_outcome==="failed"),true);
787
+ assert.equal(byTask("offline-skipped-notes").every(({notes})=>notes.distillation_outcome==="skipped"),true);
788
+ assert.deepEqual(byTask("offline-pass-note").map(({notes})=>notes.notes_exposed),[false,true]);
789
+ assert.equal(byTask("offline-timeout").every(({terminal_state})=>terminal_state==="timeout"),true);
790
+ assert.equal(byTask("offline-adapter-error").every(({terminal_state})=>terminal_state==="adapter_error"),true);
791
+ assert.equal(byTask("offline-invalid-result").every(({terminal_state})=>terminal_state==="invalid_result"),true);
792
+ assert.equal(byTask("offline-cancellation-control").every(({terminal_state})=>terminal_state==="completed"),true);
793
+ assert.equal(result.snapshots.every((snapshot)=>snapshot.task_id==="offline-pass-note"),true);
794
+
795
+ const bindingPath=join(taskSetPath,"..","bundled-fake.json");
796
+ const originalTask=readFileSync(taskSetPath);
797
+ const originalBinding=readFileSync(bindingPath);
798
+ function rejected(label,mutate) {
799
+ try { mutate(); assert.throws(()=>loadEvalPlan(options),undefined,label); }
800
+ finally { writeFileSync(taskSetPath,originalTask); writeFileSync(bindingPath,originalBinding); }
801
+ }
802
+ rejected("task-set bytes",()=>writeFileSync(taskSetPath,Buffer.concat([originalTask,Buffer.from(" ")])));
803
+ rejected("inline fixture",()=>{const value=JSON.parse(originalTask);value.source.files[0].content+="tamper";writeFileSync(taskSetPath,JSON.stringify(value)+"\n");});
804
+ for (const field of ["identifier","package_version","task_set_digest"]) rejected(field,()=>{const value=JSON.parse(originalBinding);value[field]=field==="task_set_digest"?"0".repeat(64):"tampered";writeFileSync(bindingPath,JSON.stringify(value)+"\n");});
805
+ chmodSync(fixtureRoot,0o700);
806
+ try { chmodSync(join(fixtureRoot,"offline-output","offline-fixture","snapshots"),0o700); } catch {}
807
+ for (const snapshot of result.snapshots) {
808
+ chmodSync(snapshot.root_path,0o700);
809
+ for (const entry of snapshot.manifest) chmodSync(join(snapshot.root_path,...entry.path.split("/")),0o600);
810
+ }
811
+ execFileSync("chmod",["-R","u+w",outputRoot]);
812
+ NODE
813
+ echo "PASS: Phase 19 deterministic offline fake population contract"
814
+ }
815
+
816
+ run_cancellation() {
817
+ require_exports "$runner" runEvalTwoPass
818
+ require_exports "$report" checkpointEvalReport
819
+ grep -q 'cancelled' "$ROOT/mcp-memory-server/src/eval-schema.ts" || fail "cancelled terminal state is absent"
820
+ local task_set="$ROOT/examples/eval/task-set.json"
821
+ local adapter="$ROOT/examples/eval/adapter.json"
822
+ local rejected_root="$tmp/rejected-output"
823
+ set +e
824
+ CAIRN_EVAL=1 node "$eval_cli" run --task-set "$task_set" --adapter "$adapter" --output "$rejected_root" --json >"$tmp/rejected.json" 2>"$tmp/rejected.err"
825
+ local rejected_status=$?
826
+ set -e
827
+ [[ "$rejected_status" -eq 2 ]] || fail "run without --yes did not fail with usage status"
828
+ [[ ! -e "$rejected_root" ]] || fail "run without --yes created output"
829
+
830
+ local complete_root="$tmp/complete-output"
831
+ CAIRN_EVAL=1 node "$eval_cli" run --task-set "$task_set" --adapter "$adapter" --output "$complete_root" --yes --json >"$tmp/complete.json" 2>"$tmp/complete.err"
832
+ node - "$tmp/complete.json" <<'NODE'
833
+ const { existsSync, readFileSync } = require("node:fs");
834
+ const value=JSON.parse(readFileSync(process.argv[2],"utf8"));
835
+ if (value.operation!=="run" || value.invocation_count!==20 || value.status!=="final" || !existsSync(value.report_path)) process.exit(1);
836
+ const report=JSON.parse(readFileSync(value.report_path,"utf8"));
837
+ if (report.observations.length!==20 || report.schedule.some((row,index)=>row.observation_id!==report.observations[index].observation_id)) process.exit(1);
838
+ NODE
839
+ chmod -R u+w "$complete_root"
840
+
841
+ local cancel_root="$tmp/cancel-output"
842
+ local child_pid_file="$tmp/adapter.pid"
843
+ CAIRN_EVAL=1 CAIRN_FAKE_CANCEL_ALL=1 CAIRN_FAKE_PID_FILE="$child_pid_file" \
844
+ node "$eval_cli" run --task-set "$task_set" --adapter "$adapter" --output "$cancel_root" --yes --json \
845
+ >"$tmp/cancel.json" 2>"$tmp/cancel.err" &
846
+ local cli_pid=$!
847
+ local attempt=0
848
+ while [[ ! -s "$child_pid_file" && "$attempt" -lt 100 ]]; do
849
+ sleep 0.05
850
+ attempt=$((attempt + 1))
851
+ done
852
+ [[ -s "$child_pid_file" ]] || { kill -TERM "$cli_pid" 2>/dev/null || true; fail "cancellation fixture child did not start"; }
853
+ local child_pid
854
+ child_pid=$(cat "$child_pid_file")
855
+ kill -INT "$cli_pid"
856
+ set +e
857
+ wait "$cli_pid"
858
+ local cancel_status=$?
859
+ set -e
860
+ [[ "$cancel_status" -eq 130 ]] || fail "SIGINT run exited with $cancel_status instead of 130"
861
+ if kill -0 "$child_pid" 2>/dev/null; then fail "cancelled adapter child survived CLI exit"; fi
862
+ node - "$tmp/cancel.json" <<'NODE'
863
+ const { existsSync, readFileSync } = require("node:fs");
864
+ const value=JSON.parse(readFileSync(process.argv[2],"utf8"));
865
+ if (value.operation!=="run" || value.status!=="partial" || !existsSync(value.report_path)) process.exit(1);
866
+ const report=JSON.parse(readFileSync(value.report_path,"utf8"));
867
+ if (report.status!=="partial" || report.observations.length!==1) process.exit(1);
868
+ const row=report.observations[0];
869
+ if (row.terminal_state!=="cancelled" || row.process.cleanup==="pending") process.exit(1);
870
+ NODE
871
+
872
+ node --input-type=module - "$runner" "$report" "$ROOT/mcp-memory-server/dist/eval-plan.js" "$tmp" <<'NODE'
873
+ import assert from "node:assert/strict";
874
+ import { execFileSync } from "node:child_process";
875
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
876
+ import { join } from "node:path";
877
+ import { setTimeout as delay } from "node:timers/promises";
878
+ import { pathToFileURL } from "node:url";
879
+
880
+ const [runnerPath, reportPath, planPath, fixtureRoot] = process.argv.slice(2);
881
+ const runner = await import(pathToFileURL(runnerPath).href);
882
+ const reports = await import(pathToFileURL(reportPath).href);
883
+ const { buildEvalSchedule } = await import(pathToFileURL(planPath).href);
884
+ const repo = join(fixtureRoot, "active-stage-cancel-repo");
885
+ mkdirSync(repo);
886
+ const git = (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
887
+ git("init", "-q");
888
+ git("config", "user.name", "Evaluation Fixture");
889
+ git("config", "user.email", "evaluation-fixture@example.invalid");
890
+ writeFileSync(join(repo, "source.txt"), "fixture\n");
891
+ git("add", "source.txt");
892
+ git("commit", "-qm", "fixture");
893
+ const revision = git("rev-parse", "HEAD");
894
+ const adapterScript = join(fixtureRoot, "active-stage-adapter.mjs");
895
+ const hangingScript = join(fixtureRoot, "active-stage-hang.mjs");
896
+ writeFileSync(adapterScript, `
897
+ const chunks=[]; for await (const chunk of process.stdin) chunks.push(chunk);
898
+ const request=JSON.parse(Buffer.concat(chunks).toString("utf8"));
899
+ process.stdout.write(JSON.stringify({schema_version:1,status:"completed",turns:{value:1,semantics:"fixture-turn"},usage:{total_tokens:1},observed_capability_digest:request.expected_capability_digest,trajectory_ref:request.arm+"-r"+request.repetition+"-"+request.pass+"-"+request.task_id}));
900
+ `);
901
+ writeFileSync(hangingScript, `
902
+ import { writeFileSync } from "node:fs";
903
+ writeFileSync(process.argv[2], String(process.pid));
904
+ await new Promise((resolve)=>setTimeout(resolve,60_000));
905
+ `);
906
+ const states = ["verifier", "distiller"];
907
+ const waitFor = async (path) => {
908
+ for (let attempt=0; attempt<200; attempt+=1) { if (existsSync(path)) return; await delay(10); }
909
+ throw new Error("active stage did not start");
910
+ };
911
+ for (const stage of states) {
912
+ const marker = join(fixtureRoot, `${stage}.pid`);
913
+ const tasks = ["first", "later"].map((id, index) => ({
914
+ id:`${stage}-${id}`, input:id, workspace:{path:"."},
915
+ prepare:{program:process.execPath,args:["-e","process.exit(0)"]},
916
+ verify:index===0 && stage==="verifier"
917
+ ? {program:process.execPath,args:[hangingScript,marker]}
918
+ : {program:process.execPath,args:["-e","process.exit(0)"]},
919
+ limits:{elapsed_ms:30_000,stdout_bytes:16_384},
920
+ }));
921
+ const taskSet={schema_version:1,id:`${stage}-cancel-fixture`,source:{kind:"git",repository:".",revision},tasks};
922
+ const schedule=buildEvalSchedule({taskSet,arms:[{id:"baseline",disabled_capability:null}],repetitions:1,passes:["run1","run2"],seed:"cancel-seed"});
923
+ const plan={schema_version:1,task_set:taskSet,
924
+ adapter_config:{schema_version:1,id:"fixture-adapter",command:{program:process.execPath,args:[adapterScript]},turn_semantics:{id:"fixture-turn",description:"One fixture turn."}},
925
+ task_set_path:join(repo,"task-set.json"),adapter_path:adapterScript,output_root:join(fixtureRoot,`${stage}-output`),
926
+ source:{kind:"git",repository_root:repo,revision},repetitions:1,seed:"cancel-seed",
927
+ arms:[{id:"baseline",disabled_capability:null}],passes:["run1","run2"],concurrency:1,
928
+ invocation_count:schedule.invocation_count,task_set_commit:revision,task_set_digest:"1".repeat(64),
929
+ adapter_config_digest:"2".repeat(64),schedule_digest:schedule.digest,plan_digest:(stage==="verifier"?"3":"4").repeat(64),schedule:schedule.rows,
930
+ resolved_programs:{adapter:process.execPath,prepare:tasks.map(()=>process.execPath),verify:tasks.map((task)=>task.verify.program)},
931
+ };
932
+ const store=await reports.createEvalReportStore({root:join(fixtureRoot,"active-stage-reports"),experiment_id:`${stage}-cancel-fixture`});
933
+ const controller=new AbortController();
934
+ const running=runner.runTwoPassExperiment({
935
+ plan,report_store:store,temporary_root:fixtureRoot,signal:controller.signal,
936
+ distill_command:stage==="distiller"?{program:process.execPath,args:[hangingScript,marker]}:{program:process.execPath,args:["-e","process.stdout.write(JSON.stringify({schema_version:1,enabled:true,created:[],updated:[],already_processed:[],enrichment_skipped:[],enrichment_failed:[],failed:[]}))"]},
937
+ });
938
+ await waitFor(marker);
939
+ const childPid=Number(readFileSync(marker,"utf8"));
940
+ controller.abort();
941
+ const result=await running;
942
+ assert.equal(result.report.status,"partial");
943
+ assert.equal(result.report.observations.length,1,"a later observation was admitted after cancellation");
944
+ const observation=result.report.observations[0];
945
+ assert.equal(observation.terminal_state,"cancelled");
946
+ assert.equal(observation.pass_state,"unknown");
947
+ assert.notEqual(observation.process.cleanup,"pending");
948
+ assert.equal(observation.process.error_code,"cancelled");
949
+ if (stage==="verifier") {
950
+ assert.equal(observation.verifier.state,"error");
951
+ assert.equal(observation.verifier.reason,"verifier_cancelled");
952
+ } else {
953
+ assert.equal(observation.notes.distillation_outcome,"skipped");
954
+ assert.equal(observation.notes.eligibility_reason,"distillation_cancelled");
955
+ assert.equal(observation.notes.eligibility_reason==="distillation_failed",false);
956
+ }
957
+ assert.throws(()=>process.kill(childPid,0),/ESRCH/,`${stage} child survived cancellation`);
958
+ assert.equal(existsSync(store.report_path),true);
959
+ assert.deepEqual((await reports.readEvalReport(store)).observations,result.report.observations);
960
+ }
961
+ NODE
962
+ echo "PASS: Phase 19 cancellation and partial-report contract"
963
+ }
964
+
965
+ run_package() {
966
+ "$ROOT/scripts/test-package-install.sh"
967
+ echo "PASS: Phase 19 installed evaluation asset contract"
968
+ }
969
+
970
+ run_composed() {
971
+ baseline
972
+ # Wave 0 remains default-green. Once production exists, no-argument execution
973
+ # automatically becomes the complete composed contract.
974
+ [[ -f "$runner" ]] || return 0
975
+ run_workspace
976
+ run_two_pass
977
+ run_fake
978
+ run_cancellation
979
+ if grep -q 'runEvalAblation' "$ROOT/mcp-memory-server/src/eval-runner.ts"; then run_ablation; fi
980
+ if grep -q 'renderEvalReport' "$ROOT/mcp-memory-server/src/eval-report.ts"; then
981
+ run_report
982
+ run_claims
983
+ fi
984
+ if grep -q 'command === "prune"' "$ROOT/mcp-memory-server/src/eval-cli.ts"; then run_retention; fi
985
+ if grep -q '"examples/eval/"' "$ROOT/package.json"; then run_package; fi
986
+ }
987
+
988
+ case "$MODE" in
989
+ expected-red)
990
+ baseline >/dev/null
991
+ set +e
992
+ node --input-type=module - "$runner" >"$tmp/red.out" 2>"$tmp/red.err" <<'NODE'
993
+ import { pathToFileURL } from "node:url";
994
+ await import(pathToFileURL(process.argv[2]).href);
995
+ NODE
996
+ status=$?
997
+ set -e
998
+ if [[ "$status" -ne 0 ]] && grep -qF '/dist/eval-runner.js' "$tmp/red.err" && grep -qF 'ERR_MODULE_NOT_FOUND' "$tmp/red.err"; then
999
+ echo 'PHASE19_RED:EVAL_LIFECYCLE_MISSING'
1000
+ exit 0
1001
+ fi
1002
+ cat "$tmp/red.out" "$tmp/red.err" >&2
1003
+ fail "expected only the missing eval-runner module"
1004
+ ;;
1005
+ workspace) run_workspace ;;
1006
+ two-pass) run_two_pass ;;
1007
+ ablation) run_ablation ;;
1008
+ report) run_report ;;
1009
+ retention) run_retention ;;
1010
+ claims) run_claims ;;
1011
+ fake) run_fake ;;
1012
+ cancellation) run_cancellation ;;
1013
+ package) run_package ;;
1014
+ composed) run_composed ;;
1015
+ esac