@cairnkeep/cli 2.3.1 → 2.5.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.
- package/CHANGELOG.md +85 -0
- package/README.md +232 -6
- package/bin/cairn +43 -2
- package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
- package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
- package/claude/hooks/compaction-capture.sh +34 -0
- package/claude/hooks/memory-capture.sh +47 -5
- package/claude/hooks/memory-wakeup.sh +55 -2
- package/docs/building-an-overlay.md +4 -3
- package/docs/design/meta-agent.md +439 -0
- package/docs/harness-compatibility.md +117 -0
- package/docs/learning/CURRICULUM-MAP.md +91 -0
- package/docs/learning/FEATURE-GUIDE.md +86 -0
- package/docs/learning/PRODUCTION-PLAN.md +45 -14
- package/docs/learning/README.md +20 -1
- package/docs/learning/instructor-guide.md +6 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +20 -8
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +23 -5
- package/docs/learning/lessons/L03-first-project.md +7 -2
- package/docs/learning/lessons/L04-memory-fundamentals.md +4 -0
- package/docs/learning/lessons/L05-daily-workflow.md +4 -0
- package/docs/learning/lessons/L07-privacy-and-storage.md +3 -0
- package/docs/learning/lessons/L10-context-exploration.md +4 -2
- package/docs/learning/lessons/L12-managed-overlays.md +2 -0
- package/docs/learning/lessons/L13-session-evidence.md +71 -0
- package/docs/learning/lessons/L14-typed-memory.md +51 -0
- package/docs/learning/lessons/L15-capability-governance.md +56 -0
- package/docs/learning/lessons/L16-evaluation.md +67 -0
- package/docs/learning/lessons/L17-agent-boundary.md +55 -0
- package/docs/learning/templates/lesson-template.md +4 -1
- package/docs/learning/tracks/evidence-and-evaluation.md +20 -0
- package/docs/learning/tracks/operator.md +8 -2
- package/docs/learning/tracks/practitioner.md +6 -1
- package/docs/learning/tracks/quickstart.md +3 -0
- package/docs/learning/video-scripts/L00-why-cairnkeep.md +21 -17
- package/docs/learning/video-scripts/L02-installation.md +9 -2
- package/docs/learning/video-scripts/L03-first-project.md +3 -0
- package/docs/operating.md +567 -7
- package/docs/privacy-and-data-flow.md +417 -0
- package/docs/storage.md +252 -0
- package/examples/eval/README.md +42 -0
- package/examples/eval/adapter.json +12 -0
- package/examples/eval/bundled-fake.json +1 -0
- package/examples/eval/task-set.json +1 -0
- package/mcp-memory-server/dist/artifact-cli.js +184 -0
- package/mcp-memory-server/dist/artifact-schema.js +218 -0
- package/mcp-memory-server/dist/artifact-store.js +812 -0
- package/mcp-memory-server/dist/capability-adapter.js +286 -0
- package/mcp-memory-server/dist/capability-cli.js +361 -0
- package/mcp-memory-server/dist/capability-config.js +310 -0
- package/mcp-memory-server/dist/capability-harness.js +496 -0
- package/mcp-memory-server/dist/capability-registry.js +123 -0
- package/mcp-memory-server/dist/capability-schema.js +65 -0
- package/mcp-memory-server/dist/capability-store.js +431 -0
- package/mcp-memory-server/dist/compaction-normalize.js +267 -0
- package/mcp-memory-server/dist/eval-cli.js +523 -0
- package/mcp-memory-server/dist/eval-plan.js +346 -0
- package/mcp-memory-server/dist/eval-process.js +273 -0
- package/mcp-memory-server/dist/eval-report.js +609 -0
- package/mcp-memory-server/dist/eval-runner.js +827 -0
- package/mcp-memory-server/dist/eval-schema.js +368 -0
- package/mcp-memory-server/dist/eval-statistics.js +161 -0
- package/mcp-memory-server/dist/eval-workspace.js +313 -0
- package/mcp-memory-server/dist/failure-signature.js +141 -0
- package/mcp-memory-server/dist/index.js +600 -55
- package/mcp-memory-server/dist/node-cli.js +84 -0
- package/mcp-memory-server/dist/node-schema.js +170 -0
- package/mcp-memory-server/dist/node-store.js +455 -0
- package/mcp-memory-server/dist/note-cli.js +235 -0
- package/mcp-memory-server/dist/note-distiller.js +177 -0
- package/mcp-memory-server/dist/note-enrichment.js +93 -0
- package/mcp-memory-server/dist/note-schema.js +4 -0
- package/mcp-memory-server/dist/note-store.js +985 -0
- package/mcp-memory-server/dist/trajectory-cli.js +141 -0
- package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
- package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
- package/mcp-memory-server/dist/trajectory-schema.js +73 -0
- package/mcp-memory-server/dist/trajectory-store.js +339 -0
- package/mcp-memory-server/package.json +24 -2
- package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
- package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
- package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
- package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
- package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
- package/opencode/capability-contract/plugins/capability-command.ts +319 -0
- package/opencode/command/graphify.md +25 -14
- package/opencode/plugins/memory-capture.ts +76 -10
- package/opencode/plugins/memory-wakeup.ts +58 -6
- package/package.json +8 -4
- package/pi/extensions/cairnkeep-trajectory.ts +55 -0
- package/schemas/artifact.schema.json +133 -0
- package/schemas/capability-callback.schema.json +68 -0
- package/schemas/capability-contract.schema.json +126 -0
- package/schemas/eval-adapter.schema.json +43 -0
- package/schemas/eval-protocol.schema.json +114 -0
- package/schemas/eval-report.schema.json +423 -0
- package/schemas/eval-task-set.schema.json +114 -0
- package/schemas/memory-node.schema.json +90 -0
- package/schemas/note.schema.json +112 -0
- package/schemas/trajectory-redaction.schema.json +25 -0
- package/schemas/trajectory.schema.json +82 -0
- package/scripts/bootstrap.sh +6 -0
- package/scripts/completion.sh +119 -5
- package/scripts/doctor.sh +284 -0
- package/scripts/fake-eval-adapter.mjs +120 -0
- package/scripts/fixtures/capability-harness-contracts.json +408 -0
- package/scripts/install-audit-timer.sh +3 -1
- package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
- package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
- package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
- package/scripts/memory-wiki-audit.sh +49 -0
- package/scripts/smoke-bash32.sh +9 -0
- package/scripts/sync-claude-assets.sh +99 -6
- package/scripts/sync-opencode-graphify-assets.sh +24 -3
- package/scripts/sync-opencode-plugin-assets.sh +31 -5
- package/scripts/sync-opencode-security-assets.sh +24 -3
- package/scripts/sync-opencode-wiki-assets.sh +24 -3
- package/scripts/sync-pi-assets.sh +72 -0
- package/scripts/test-audit-timer.sh +98 -0
- package/scripts/test-bootstrap-untracked.sh +49 -0
- package/scripts/test-cli-dispatch.sh +41 -2
- package/scripts/test-compaction-hooks.sh +495 -0
- package/scripts/test-completion.sh +77 -0
- package/scripts/test-doctor.sh +348 -1
- package/scripts/test-eval-cli.sh +175 -0
- package/scripts/test-launcher-hooks.sh +630 -3
- package/scripts/test-learning-docs.sh +20 -4
- package/scripts/test-notes-cli.sh +114 -0
- package/scripts/test-package-install.sh +254 -0
- package/scripts/test-phase16-node-lifecycle.sh +50 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
- package/scripts/test-phase18-capability-lifecycle.sh +504 -0
- package/scripts/test-phase18-harness-boundary.sh +1060 -0
- package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
- package/scripts/test-trajectory-cli.sh +87 -0
- package/scripts/test-trajectory-hooks.sh +142 -0
- package/scripts/test-uninstall.sh +231 -5
- package/scripts/uninstall.sh +38 -5
- package/scripts/verify-dependency-security.mjs +121 -0
- package/scripts/verify-docs-parity.sh +527 -1
- package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
- package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
- package/templates/capabilities.json.template +7 -0
- package/templates/env.example.template +51 -0
- package/templates/memory-wiki-audit.service.template +1 -1
- package/templates/start-claude.sh.template +36 -0
- package/templates/start-kimi.sh.template +39 -0
- package/templates/start-opencode.sh.template +47 -5
- package/templates/start-pi.sh.template +30 -0
- 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
|