@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.
- package/CHANGELOG.md +69 -0
- package/README.md +221 -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 +1 -1
- package/docs/design/meta-agent.md +439 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/operating.md +531 -4
- 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 +4 -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 +48 -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 +602 -3
- package/scripts/test-notes-cli.sh +114 -0
- package/scripts/test-package-install.sh +251 -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 +37 -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-opencode.sh.template +47 -5
- package/templates/start-pi.sh.template +30 -0
- package/templates/trajectory-redaction.json.template +4 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { lstat, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { capabilityIdSchema } from "./capability-schema.js";
|
|
6
|
+
import { isEvalEnabled, loadEvalPlan } from "./eval-plan.js";
|
|
7
|
+
import { diagnoseEvalReport, readEvalReport, renderEvalReport, } from "./eval-report.js";
|
|
8
|
+
import { buildAblationArms, runCapabilityAblation, runTwoPassExperiment, } from "./eval-runner.js";
|
|
9
|
+
import { canonicalJson, EVAL_SCHEMA_VERSION } from "./eval-schema.js";
|
|
10
|
+
process.stdout.on("error", (error) => {
|
|
11
|
+
if (error.code === "EPIPE")
|
|
12
|
+
process.exit(0);
|
|
13
|
+
throw error;
|
|
14
|
+
});
|
|
15
|
+
const publicCommands = new Set(["validate", "run", "ablate", "report", "prune", "delete"]);
|
|
16
|
+
const validateFlags = new Set(["--task-set", "--adapter", "--output", "--repetitions", "--seed", "--json"]);
|
|
17
|
+
const runFlags = new Set([...validateFlags, "--yes"]);
|
|
18
|
+
const ablateFlags = new Set([...runFlags, "--disable"]);
|
|
19
|
+
const reportFlags = new Set(["--experiment", "--json"]);
|
|
20
|
+
const pruneFlags = new Set(["--older-than-days", "--dry-run", "--json"]);
|
|
21
|
+
const deleteFlags = new Set(["--experiment", "--dry-run", "--json"]);
|
|
22
|
+
const valueFlags = new Set(["--task-set", "--adapter", "--output", "--repetitions", "--seed", "--disable", "--experiment", "--older-than-days"]);
|
|
23
|
+
const EXPERIMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
24
|
+
const DEFAULT_REPORT_BYTES = 16 * 1024 * 1024;
|
|
25
|
+
const DEFAULT_RETENTION_DAYS = 30;
|
|
26
|
+
const MAX_EXPERIMENTS = 10_000;
|
|
27
|
+
const DOCTOR_DIAGNOSES = ["absent", "ok", "partial", "tampered", "unsafe"];
|
|
28
|
+
function usage() {
|
|
29
|
+
return `cairn eval — deterministic local evaluation coordinator
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
cairn eval validate --task-set PATH --adapter PATH --output ROOT [--repetitions N] [--seed VALUE] [--json]
|
|
33
|
+
cairn eval run --task-set PATH --adapter PATH --output ROOT [--repetitions N] [--seed VALUE] --yes [--json]
|
|
34
|
+
cairn eval ablate --disable CAPABILITY --task-set PATH --adapter PATH --output ROOT [--repetitions N] [--seed VALUE] [--yes] [--json]
|
|
35
|
+
cairn eval report --experiment ID [--json]
|
|
36
|
+
cairn eval prune [--older-than-days N] [--dry-run] [--json]
|
|
37
|
+
cairn eval delete --experiment ID [--dry-run] [--json]
|
|
38
|
+
|
|
39
|
+
Evaluation is disabled unless CAIRN_EVAL is explicitly enabled. Live harness
|
|
40
|
+
commands remain operator-owned; validate resolves inputs without executing one.
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
function valueAfter(args, flag) {
|
|
44
|
+
const index = args.indexOf(flag);
|
|
45
|
+
if (index < 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
const value = args[index + 1];
|
|
48
|
+
if (!value || value.startsWith("--"))
|
|
49
|
+
throw new Error(`${flag} requires a value.`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function assertKnown(args, flags) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
55
|
+
const arg = args[index];
|
|
56
|
+
if (!arg.startsWith("--"))
|
|
57
|
+
throw new Error(`unexpected positional argument "${arg}".`);
|
|
58
|
+
if (!flags.has(arg))
|
|
59
|
+
throw new Error(`unknown option "${arg}".`);
|
|
60
|
+
if (seen.has(arg))
|
|
61
|
+
throw new Error(`option "${arg}" may be supplied only once.`);
|
|
62
|
+
seen.add(arg);
|
|
63
|
+
if (valueFlags.has(arg)) {
|
|
64
|
+
const value = args[index + 1];
|
|
65
|
+
if (!value || value.startsWith("--"))
|
|
66
|
+
throw new Error(`${arg} requires a value.`);
|
|
67
|
+
index += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function requireValue(args, flag) {
|
|
72
|
+
const value = valueAfter(args, flag);
|
|
73
|
+
if (value === undefined)
|
|
74
|
+
throw new Error(`${flag} is required.`);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function repetitions(args) {
|
|
78
|
+
const raw = valueAfter(args, "--repetitions");
|
|
79
|
+
if (raw === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (!/^[0-9]+$/.test(raw))
|
|
82
|
+
throw new Error("--repetitions must be a positive integer.");
|
|
83
|
+
const value = Number(raw);
|
|
84
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
85
|
+
throw new Error("--repetitions must be a positive integer.");
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function disabled(json) {
|
|
89
|
+
const value = {
|
|
90
|
+
schema_version: EVAL_SCHEMA_VERSION,
|
|
91
|
+
enabled: false,
|
|
92
|
+
operation: "eval",
|
|
93
|
+
status: "disabled",
|
|
94
|
+
};
|
|
95
|
+
process.stdout.write(`${json ? JSON.stringify(value) : "Evaluation is disabled. Set CAIRN_EVAL=1 to enable it."}\n`);
|
|
96
|
+
}
|
|
97
|
+
function isContained(root, candidate) {
|
|
98
|
+
const path = relative(root, candidate);
|
|
99
|
+
return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path));
|
|
100
|
+
}
|
|
101
|
+
function requireExperiment(args) {
|
|
102
|
+
const experimentId = requireValue(args, "--experiment");
|
|
103
|
+
if (!EXPERIMENT_PATTERN.test(experimentId))
|
|
104
|
+
throw new Error("--experiment must be a canonical experiment ID.");
|
|
105
|
+
return experimentId;
|
|
106
|
+
}
|
|
107
|
+
function retentionDays(args) {
|
|
108
|
+
const raw = valueAfter(args, "--older-than-days");
|
|
109
|
+
if (raw === undefined)
|
|
110
|
+
return DEFAULT_RETENTION_DAYS;
|
|
111
|
+
if (!/^[0-9]+$/.test(raw))
|
|
112
|
+
throw new Error("--older-than-days must be a nonnegative integer.");
|
|
113
|
+
const value = Number(raw);
|
|
114
|
+
if (!Number.isSafeInteger(value) || value > 36_500)
|
|
115
|
+
throw new Error("--older-than-days is outside the supported range.");
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
async function evalRoot() {
|
|
119
|
+
const root = resolve(process.cwd(), ".agentfs", "eval", "experiments");
|
|
120
|
+
let info;
|
|
121
|
+
try {
|
|
122
|
+
info = await lstat(root);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code === "ENOENT")
|
|
126
|
+
return null;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
if (info.isSymbolicLink() || !info.isDirectory() || (info.mode & 0o077) !== 0) {
|
|
130
|
+
throw new Error("unsafe_eval_root");
|
|
131
|
+
}
|
|
132
|
+
const resolvedRoot = await realpath(root);
|
|
133
|
+
if (resolvedRoot !== root)
|
|
134
|
+
throw new Error("unsafe_eval_root");
|
|
135
|
+
return root;
|
|
136
|
+
}
|
|
137
|
+
function isStrictReportDiagnosis(value) {
|
|
138
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
139
|
+
return false;
|
|
140
|
+
const record = value;
|
|
141
|
+
if (Object.keys(record).length !== 2 || !Object.hasOwn(record, "state") || !Object.hasOwn(record, "code")) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const expectedCodes = {
|
|
145
|
+
absent: "report_absent",
|
|
146
|
+
ok: "report_ok",
|
|
147
|
+
partial: "report_partial",
|
|
148
|
+
tampered: "report_invalid",
|
|
149
|
+
unsafe: "report_unsafe",
|
|
150
|
+
};
|
|
151
|
+
return typeof record.state === "string"
|
|
152
|
+
&& DOCTOR_DIAGNOSES.includes(record.state)
|
|
153
|
+
&& record.code === expectedCodes[record.state];
|
|
154
|
+
}
|
|
155
|
+
function diagnosisEnvelope(diagnosis) {
|
|
156
|
+
return { schema_version: EVAL_SCHEMA_VERSION, diagnosis };
|
|
157
|
+
}
|
|
158
|
+
function privateDoctorRoot(args) {
|
|
159
|
+
if (args.length !== 3 || args[0] !== "--root" || args[2] !== "--json")
|
|
160
|
+
throw new Error("unsafe");
|
|
161
|
+
const root = args[1];
|
|
162
|
+
if (!root || !isAbsolute(root) || resolve(root) !== root)
|
|
163
|
+
throw new Error("unsafe");
|
|
164
|
+
return root;
|
|
165
|
+
}
|
|
166
|
+
async function diagnoseProjectReports(projectRoot) {
|
|
167
|
+
const projectInfo = await lstat(projectRoot);
|
|
168
|
+
if (projectInfo.isSymbolicLink() || !projectInfo.isDirectory() || await realpath(projectRoot) !== projectRoot) {
|
|
169
|
+
return "unsafe";
|
|
170
|
+
}
|
|
171
|
+
const agentfsRoot = resolve(projectRoot, ".agentfs");
|
|
172
|
+
const evalDirectory = resolve(agentfsRoot, "eval");
|
|
173
|
+
const root = resolve(evalDirectory, "experiments");
|
|
174
|
+
if (!isContained(projectRoot, root) || root === projectRoot)
|
|
175
|
+
return "unsafe";
|
|
176
|
+
for (const directory of [agentfsRoot, evalDirectory, root]) {
|
|
177
|
+
let info;
|
|
178
|
+
try {
|
|
179
|
+
info = await lstat(directory);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code === "ENOENT")
|
|
183
|
+
return "absent";
|
|
184
|
+
return "unsafe";
|
|
185
|
+
}
|
|
186
|
+
if (info.isSymbolicLink() || !info.isDirectory() || await realpath(directory) !== directory)
|
|
187
|
+
return "unsafe";
|
|
188
|
+
if (directory === root && (info.mode & 0o077) !== 0)
|
|
189
|
+
return "unsafe";
|
|
190
|
+
}
|
|
191
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
192
|
+
if (entries.length === 0)
|
|
193
|
+
return "absent";
|
|
194
|
+
if (entries.length > MAX_EXPERIMENTS)
|
|
195
|
+
return "unsafe";
|
|
196
|
+
let aggregate = "ok";
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (!EXPERIMENT_PATTERN.test(entry.name) || entry.isSymbolicLink() || !entry.isDirectory())
|
|
199
|
+
return "unsafe";
|
|
200
|
+
const experimentPath = resolve(root, entry.name);
|
|
201
|
+
if (!isContained(root, experimentPath) || experimentPath === root)
|
|
202
|
+
return "unsafe";
|
|
203
|
+
const experimentInfo = await lstat(experimentPath);
|
|
204
|
+
if (experimentInfo.isSymbolicLink() || !experimentInfo.isDirectory() || (experimentInfo.mode & 0o077) !== 0
|
|
205
|
+
|| await realpath(experimentPath) !== experimentPath) {
|
|
206
|
+
return "unsafe";
|
|
207
|
+
}
|
|
208
|
+
const diagnosis = await diagnoseEvalReport({
|
|
209
|
+
root_path: root,
|
|
210
|
+
experiment_path: experimentPath,
|
|
211
|
+
report_path: join(experimentPath, "report.json"),
|
|
212
|
+
experiment_id: entry.name,
|
|
213
|
+
max_report_bytes: DEFAULT_REPORT_BYTES,
|
|
214
|
+
});
|
|
215
|
+
if (!isStrictReportDiagnosis(diagnosis) || diagnosis.state === "unsafe")
|
|
216
|
+
return "unsafe";
|
|
217
|
+
if (diagnosis.state === "tampered")
|
|
218
|
+
aggregate = "tampered";
|
|
219
|
+
else if (aggregate !== "tampered" && diagnosis.state !== "ok")
|
|
220
|
+
aggregate = "partial";
|
|
221
|
+
}
|
|
222
|
+
return aggregate;
|
|
223
|
+
}
|
|
224
|
+
async function privateDoctorDiagnosis(args) {
|
|
225
|
+
let diagnosis = "unsafe";
|
|
226
|
+
try {
|
|
227
|
+
diagnosis = await diagnoseProjectReports(privateDoctorRoot(args));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
diagnosis = "unsafe";
|
|
231
|
+
}
|
|
232
|
+
process.stdout.write(`${JSON.stringify(diagnosisEnvelope(diagnosis))}\n`);
|
|
233
|
+
}
|
|
234
|
+
async function safeReportStore(experimentId) {
|
|
235
|
+
const root = await evalRoot();
|
|
236
|
+
if (!root)
|
|
237
|
+
throw new Error("eval_report_not_found");
|
|
238
|
+
const experimentPath = resolve(root, experimentId);
|
|
239
|
+
if (!isContained(root, experimentPath) || experimentPath === root)
|
|
240
|
+
throw new Error("unsafe_experiment_path");
|
|
241
|
+
let directory;
|
|
242
|
+
try {
|
|
243
|
+
directory = await lstat(experimentPath);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error.code === "ENOENT")
|
|
247
|
+
throw new Error("eval_report_not_found");
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
if (directory.isSymbolicLink() || !directory.isDirectory() || (directory.mode & 0o077) !== 0
|
|
251
|
+
|| await realpath(experimentPath) !== experimentPath) {
|
|
252
|
+
throw new Error("unsafe_experiment_path");
|
|
253
|
+
}
|
|
254
|
+
const reportPath = join(experimentPath, "report.json");
|
|
255
|
+
let reportInfo;
|
|
256
|
+
try {
|
|
257
|
+
reportInfo = await lstat(reportPath);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (error.code === "ENOENT")
|
|
261
|
+
throw new Error("eval_report_not_found");
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
if (reportInfo.isSymbolicLink() || !reportInfo.isFile() || (reportInfo.mode & 0o077) !== 0
|
|
265
|
+
|| reportInfo.size > DEFAULT_REPORT_BYTES) {
|
|
266
|
+
throw new Error("unsafe_eval_report");
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
root_path: root,
|
|
270
|
+
experiment_path: experimentPath,
|
|
271
|
+
report_path: reportPath,
|
|
272
|
+
experiment_id: experimentId,
|
|
273
|
+
max_report_bytes: DEFAULT_REPORT_BYTES,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async function validatedStoredReport(experimentId) {
|
|
277
|
+
const store = await safeReportStore(experimentId);
|
|
278
|
+
const report = await readEvalReport(store);
|
|
279
|
+
if (!report)
|
|
280
|
+
throw new Error("eval_report_not_found");
|
|
281
|
+
if (report.experiment_id !== experimentId)
|
|
282
|
+
throw new Error("report_experiment_mismatch");
|
|
283
|
+
return { store, report };
|
|
284
|
+
}
|
|
285
|
+
async function removeExperiment(store) {
|
|
286
|
+
const tombstone = join(store.root_path, `.delete-${randomBytes(16).toString("hex")}`);
|
|
287
|
+
await rename(store.experiment_path, tombstone);
|
|
288
|
+
await rm(tombstone, { recursive: true });
|
|
289
|
+
}
|
|
290
|
+
async function reportCommand(args, json) {
|
|
291
|
+
assertKnown(args, reportFlags);
|
|
292
|
+
const { report } = await validatedStoredReport(requireExperiment(args));
|
|
293
|
+
process.stdout.write(json ? `${canonicalJson(report)}\n` : renderEvalReport(report));
|
|
294
|
+
}
|
|
295
|
+
async function deleteCommand(args, json) {
|
|
296
|
+
assertKnown(args, deleteFlags);
|
|
297
|
+
const experimentId = requireExperiment(args);
|
|
298
|
+
const dryRun = args.includes("--dry-run");
|
|
299
|
+
const { store } = await validatedStoredReport(experimentId);
|
|
300
|
+
if (!dryRun)
|
|
301
|
+
await removeExperiment(store);
|
|
302
|
+
const value = { schema_version: EVAL_SCHEMA_VERSION, operation: "delete", dry_run: dryRun, experiment_id: experimentId, deleted: !dryRun };
|
|
303
|
+
process.stdout.write(`${json ? canonicalJson(value) : `${dryRun ? "Would delete" : "Deleted"} experiment ${experimentId}.`}\n`);
|
|
304
|
+
}
|
|
305
|
+
async function pruneCommand(args, json) {
|
|
306
|
+
assertKnown(args, pruneFlags);
|
|
307
|
+
const olderThanDays = retentionDays(args);
|
|
308
|
+
const dryRun = args.includes("--dry-run");
|
|
309
|
+
const root = await evalRoot();
|
|
310
|
+
const eligible = [];
|
|
311
|
+
if (root) {
|
|
312
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
313
|
+
if (entries.length > 10_000)
|
|
314
|
+
throw new Error("experiment_limit_exceeded");
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
if (!EXPERIMENT_PATTERN.test(entry.name) || entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
317
|
+
throw new Error("unsafe_experiment_entry");
|
|
318
|
+
}
|
|
319
|
+
const { store, report } = await validatedStoredReport(entry.name);
|
|
320
|
+
const age = Date.now() - Date.parse(report.updated_at);
|
|
321
|
+
if (!Number.isFinite(age))
|
|
322
|
+
throw new Error("invalid_eval_report");
|
|
323
|
+
if (age >= olderThanDays * 86_400_000)
|
|
324
|
+
eligible.push({ id: entry.name, store });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!dryRun) {
|
|
328
|
+
for (const { store } of eligible)
|
|
329
|
+
await removeExperiment(store);
|
|
330
|
+
}
|
|
331
|
+
const value = {
|
|
332
|
+
schema_version: EVAL_SCHEMA_VERSION,
|
|
333
|
+
operation: "prune",
|
|
334
|
+
dry_run: dryRun,
|
|
335
|
+
older_than_days: olderThanDays,
|
|
336
|
+
experiments: eligible.map(({ id }) => id),
|
|
337
|
+
removed: dryRun ? 0 : eligible.length,
|
|
338
|
+
};
|
|
339
|
+
process.stdout.write(`${json ? canonicalJson(value) : `${dryRun ? "Would remove" : "Removed"} ${eligible.length} experiment(s).`}\n`);
|
|
340
|
+
}
|
|
341
|
+
function renderValidation(value) {
|
|
342
|
+
const source = value.plan.source.kind === "git"
|
|
343
|
+
? `${value.plan.source.revision} (${value.plan.source.repository_root})`
|
|
344
|
+
: `${value.plan.source.identifier} (${value.plan.source.package_version})`;
|
|
345
|
+
return [
|
|
346
|
+
`Evaluation plan ${value.plan.plan_digest}`,
|
|
347
|
+
`Task set: ${value.plan.task_set.id} (${value.plan.task_set_digest})`,
|
|
348
|
+
`Source: ${source}`,
|
|
349
|
+
`Adapter: ${value.plan.adapter_config.id} (${value.plan.adapter_config_digest})`,
|
|
350
|
+
`Schedule: ${value.invocation_count} serial invocation(s) (${value.plan.schedule_digest})`,
|
|
351
|
+
`Output root: ${value.plan.output_root}`,
|
|
352
|
+
].join("\n");
|
|
353
|
+
}
|
|
354
|
+
function validationResult(plan) {
|
|
355
|
+
return {
|
|
356
|
+
schema_version: EVAL_SCHEMA_VERSION,
|
|
357
|
+
enabled: true,
|
|
358
|
+
operation: "validate",
|
|
359
|
+
invocation_count: plan.invocation_count,
|
|
360
|
+
plan,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function loadPlan(args, disabledCapability) {
|
|
364
|
+
return loadEvalPlan({
|
|
365
|
+
taskSetPath: requireValue(args, "--task-set"),
|
|
366
|
+
adapterPath: requireValue(args, "--adapter"),
|
|
367
|
+
outputRoot: requireValue(args, "--output"),
|
|
368
|
+
repetitions: repetitions(args),
|
|
369
|
+
seed: valueAfter(args, "--seed"),
|
|
370
|
+
...(disabledCapability === undefined ? {} : {
|
|
371
|
+
arms: buildAblationArms(disabledCapability).map(({ id, disabled_capability }) => ({ id, disabled_capability })),
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function requireDisabledCapability(args) {
|
|
376
|
+
const parsed = capabilityIdSchema.safeParse(requireValue(args, "--disable"));
|
|
377
|
+
if (!parsed.success)
|
|
378
|
+
throw new Error("--disable must name exactly one canonical capability ID.");
|
|
379
|
+
return parsed.data;
|
|
380
|
+
}
|
|
381
|
+
function ablationPreview(plan, disabledCapability) {
|
|
382
|
+
return {
|
|
383
|
+
schema_version: EVAL_SCHEMA_VERSION,
|
|
384
|
+
enabled: true,
|
|
385
|
+
operation: "ablate-preview",
|
|
386
|
+
disabled_capability: disabledCapability,
|
|
387
|
+
invocation_count: plan.invocation_count,
|
|
388
|
+
arms: buildAblationArms(disabledCapability).map((arm) => ({
|
|
389
|
+
id: arm.id,
|
|
390
|
+
disabled_capability: arm.disabled_capability,
|
|
391
|
+
expected_capabilities: arm.expected_capabilities,
|
|
392
|
+
expected_configuration_digest: arm.expected_configuration_digest,
|
|
393
|
+
})),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function renderAblationPreview(value) {
|
|
397
|
+
const armLines = value.arms.flatMap((arm) => [
|
|
398
|
+
`${arm.id}: ${arm.expected_configuration_digest}`,
|
|
399
|
+
...arm.expected_capabilities.map(({ id, enabled }) => ` ${id}: ${enabled ? "enabled" : "disabled"}`),
|
|
400
|
+
]);
|
|
401
|
+
return [
|
|
402
|
+
`Capability ablation will perform ${value.invocation_count} serial adapter invocation(s).`,
|
|
403
|
+
`Disabled capability: ${value.disabled_capability}`,
|
|
404
|
+
...armLines,
|
|
405
|
+
].join("\n");
|
|
406
|
+
}
|
|
407
|
+
async function executePlan(options) {
|
|
408
|
+
const controller = new AbortController();
|
|
409
|
+
let receivedSignal = null;
|
|
410
|
+
const stop = (signal) => {
|
|
411
|
+
receivedSignal ??= signal;
|
|
412
|
+
controller.abort();
|
|
413
|
+
};
|
|
414
|
+
const onSigint = () => stop("SIGINT");
|
|
415
|
+
const onSigterm = () => stop("SIGTERM");
|
|
416
|
+
process.on("SIGINT", onSigint);
|
|
417
|
+
process.on("SIGTERM", onSigterm);
|
|
418
|
+
try {
|
|
419
|
+
const run = options.operation === "ablate"
|
|
420
|
+
? await runCapabilityAblation({
|
|
421
|
+
plan: options.plan,
|
|
422
|
+
disabled_capability: options.disabled_capability,
|
|
423
|
+
signal: controller.signal,
|
|
424
|
+
})
|
|
425
|
+
: await runTwoPassExperiment({ plan: options.plan, signal: controller.signal });
|
|
426
|
+
const value = {
|
|
427
|
+
schema_version: EVAL_SCHEMA_VERSION,
|
|
428
|
+
enabled: true,
|
|
429
|
+
operation: options.operation,
|
|
430
|
+
invocation_count: options.plan.invocation_count,
|
|
431
|
+
...(options.disabled_capability === undefined ? {} : { disabled_capability: options.disabled_capability }),
|
|
432
|
+
experiment_id: run.report.experiment_id,
|
|
433
|
+
report_path: run.report_store.report_path,
|
|
434
|
+
status: run.report.status,
|
|
435
|
+
};
|
|
436
|
+
process.stdout.write(`${options.json ? JSON.stringify(value) : [
|
|
437
|
+
`Experiment: ${value.experiment_id}`,
|
|
438
|
+
`Status: ${value.status}`,
|
|
439
|
+
`Report: ${value.report_path}`,
|
|
440
|
+
].join("\n")}\n`);
|
|
441
|
+
if (receivedSignal === "SIGINT")
|
|
442
|
+
process.exitCode = 130;
|
|
443
|
+
else if (receivedSignal === "SIGTERM")
|
|
444
|
+
process.exitCode = 143;
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
process.removeListener("SIGINT", onSigint);
|
|
448
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async function main() {
|
|
452
|
+
const [command = "help", ...args] = process.argv.slice(2);
|
|
453
|
+
if (command === "doctor-diagnosis") {
|
|
454
|
+
await privateDoctorDiagnosis(args);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (["help", "--help", "-h"].includes(command)) {
|
|
458
|
+
process.stdout.write(usage());
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!publicCommands.has(command))
|
|
462
|
+
throw new Error(`unknown eval command "${command}".`);
|
|
463
|
+
const json = args.includes("--json");
|
|
464
|
+
if (!isEvalEnabled()) {
|
|
465
|
+
disabled(json);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (command === "validate") {
|
|
469
|
+
assertKnown(args, validateFlags);
|
|
470
|
+
const plan = loadPlan(args);
|
|
471
|
+
const value = validationResult(plan);
|
|
472
|
+
process.stdout.write(`${json ? JSON.stringify(value) : renderValidation(value)}\n`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (command === "run") {
|
|
476
|
+
assertKnown(args, runFlags);
|
|
477
|
+
if (!args.includes("--yes"))
|
|
478
|
+
throw new Error("run requires --yes for non-interactive execution.");
|
|
479
|
+
const plan = loadPlan(args);
|
|
480
|
+
const estimate = `Evaluation will perform ${plan.invocation_count} serial adapter invocation(s).`;
|
|
481
|
+
if (json)
|
|
482
|
+
process.stderr.write(`${estimate}\n`);
|
|
483
|
+
else
|
|
484
|
+
process.stdout.write(`${estimate}\n`);
|
|
485
|
+
await executePlan({ operation: "run", plan, json });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (command === "ablate") {
|
|
489
|
+
assertKnown(args, ablateFlags);
|
|
490
|
+
const disabledCapability = requireDisabledCapability(args);
|
|
491
|
+
const plan = loadPlan(args, disabledCapability);
|
|
492
|
+
const preview = ablationPreview(plan, disabledCapability);
|
|
493
|
+
const rendered = json ? JSON.stringify(preview) : renderAblationPreview(preview);
|
|
494
|
+
if (json && args.includes("--yes"))
|
|
495
|
+
process.stderr.write(`${rendered}\n`);
|
|
496
|
+
else
|
|
497
|
+
process.stdout.write(`${rendered}\n`);
|
|
498
|
+
if (!args.includes("--yes"))
|
|
499
|
+
throw new Error("ablate requires --yes for non-interactive execution.");
|
|
500
|
+
await executePlan({ operation: "ablate", plan, json, disabled_capability: disabledCapability });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (command === "report") {
|
|
504
|
+
await reportCommand(args, json);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (command === "prune") {
|
|
508
|
+
await pruneCommand(args, json);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (command === "delete") {
|
|
512
|
+
await deleteCommand(args, json);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
throw new Error(`eval command "${command}" is not available in this build.`);
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
await main();
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
process.stderr.write(`cairn eval: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
522
|
+
process.exitCode = 2;
|
|
523
|
+
}
|