@cairnkeep/cli 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +221 -6
  3. package/bin/cairn +43 -2
  4. package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
  5. package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
  6. package/claude/hooks/compaction-capture.sh +34 -0
  7. package/claude/hooks/memory-capture.sh +47 -5
  8. package/claude/hooks/memory-wakeup.sh +55 -2
  9. package/docs/building-an-overlay.md +1 -1
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +2 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  15. package/docs/operating.md +531 -4
  16. package/docs/privacy-and-data-flow.md +417 -0
  17. package/docs/storage.md +252 -0
  18. package/examples/eval/README.md +42 -0
  19. package/examples/eval/adapter.json +12 -0
  20. package/examples/eval/bundled-fake.json +1 -0
  21. package/examples/eval/task-set.json +1 -0
  22. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  23. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  24. package/mcp-memory-server/dist/artifact-store.js +812 -0
  25. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  26. package/mcp-memory-server/dist/capability-cli.js +361 -0
  27. package/mcp-memory-server/dist/capability-config.js +310 -0
  28. package/mcp-memory-server/dist/capability-harness.js +496 -0
  29. package/mcp-memory-server/dist/capability-registry.js +123 -0
  30. package/mcp-memory-server/dist/capability-schema.js +65 -0
  31. package/mcp-memory-server/dist/capability-store.js +431 -0
  32. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  33. package/mcp-memory-server/dist/eval-cli.js +523 -0
  34. package/mcp-memory-server/dist/eval-plan.js +346 -0
  35. package/mcp-memory-server/dist/eval-process.js +273 -0
  36. package/mcp-memory-server/dist/eval-report.js +609 -0
  37. package/mcp-memory-server/dist/eval-runner.js +827 -0
  38. package/mcp-memory-server/dist/eval-schema.js +368 -0
  39. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  40. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  41. package/mcp-memory-server/dist/failure-signature.js +141 -0
  42. package/mcp-memory-server/dist/index.js +600 -55
  43. package/mcp-memory-server/dist/node-cli.js +84 -0
  44. package/mcp-memory-server/dist/node-schema.js +170 -0
  45. package/mcp-memory-server/dist/node-store.js +455 -0
  46. package/mcp-memory-server/dist/note-cli.js +235 -0
  47. package/mcp-memory-server/dist/note-distiller.js +177 -0
  48. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  49. package/mcp-memory-server/dist/note-schema.js +4 -0
  50. package/mcp-memory-server/dist/note-store.js +985 -0
  51. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  52. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  53. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  54. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  55. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  56. package/mcp-memory-server/package.json +24 -2
  57. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  58. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  59. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  60. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  61. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  62. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  63. package/opencode/command/graphify.md +25 -14
  64. package/opencode/plugins/memory-capture.ts +76 -10
  65. package/opencode/plugins/memory-wakeup.ts +58 -6
  66. package/package.json +9 -4
  67. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  68. package/schemas/artifact.schema.json +133 -0
  69. package/schemas/capability-callback.schema.json +68 -0
  70. package/schemas/capability-contract.schema.json +126 -0
  71. package/schemas/eval-adapter.schema.json +43 -0
  72. package/schemas/eval-protocol.schema.json +114 -0
  73. package/schemas/eval-report.schema.json +423 -0
  74. package/schemas/eval-task-set.schema.json +114 -0
  75. package/schemas/memory-node.schema.json +90 -0
  76. package/schemas/note.schema.json +112 -0
  77. package/schemas/trajectory-redaction.schema.json +25 -0
  78. package/schemas/trajectory.schema.json +82 -0
  79. package/scripts/bootstrap.sh +4 -0
  80. package/scripts/completion.sh +119 -5
  81. package/scripts/doctor.sh +284 -0
  82. package/scripts/fake-eval-adapter.mjs +120 -0
  83. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  84. package/scripts/install-audit-timer.sh +3 -1
  85. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  86. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  87. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  88. package/scripts/memory-wiki-audit.sh +49 -0
  89. package/scripts/smoke-bash32.sh +9 -0
  90. package/scripts/sync-claude-assets.sh +99 -6
  91. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  92. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  93. package/scripts/sync-opencode-security-assets.sh +24 -3
  94. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  95. package/scripts/sync-pi-assets.sh +72 -0
  96. package/scripts/test-audit-timer.sh +98 -0
  97. package/scripts/test-bootstrap-untracked.sh +48 -0
  98. package/scripts/test-cli-dispatch.sh +41 -2
  99. package/scripts/test-compaction-hooks.sh +495 -0
  100. package/scripts/test-completion.sh +77 -0
  101. package/scripts/test-doctor.sh +348 -1
  102. package/scripts/test-eval-cli.sh +175 -0
  103. package/scripts/test-launcher-hooks.sh +602 -3
  104. package/scripts/test-notes-cli.sh +114 -0
  105. package/scripts/test-package-install.sh +251 -0
  106. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  107. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  108. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  109. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  110. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  111. package/scripts/test-trajectory-cli.sh +87 -0
  112. package/scripts/test-trajectory-hooks.sh +142 -0
  113. package/scripts/test-uninstall.sh +231 -5
  114. package/scripts/uninstall.sh +37 -5
  115. package/scripts/verify-dependency-security.mjs +121 -0
  116. package/scripts/verify-docs-parity.sh +527 -1
  117. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  118. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  119. package/templates/capabilities.json.template +7 -0
  120. package/templates/env.example.template +51 -0
  121. package/templates/memory-wiki-audit.service.template +1 -1
  122. package/templates/start-claude.sh.template +36 -0
  123. package/templates/start-opencode.sh.template +47 -5
  124. package/templates/start-pi.sh.template +30 -0
  125. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,827 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { chmod, copyFile, lstat, mkdtemp, mkdir, readFile, readdir, realpath, rm, } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join, relative, resolve, sep } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { z } from "zod";
8
+ import { CAPABILITY_IDS, capabilityStatusSchema, } from "./capability-schema.js";
9
+ import { canonicalDigest, evalReportSchema, evalReportRuntimeContractDigest, } from "./eval-schema.js";
10
+ import { EvalProcessError, runBoundedCommand, runBoundedJsonAdapter, } from "./eval-process.js";
11
+ import { checkpointEvalReport, createEvalReportStore, } from "./eval-report.js";
12
+ import { cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
13
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
14
+ const publishedReportSchemaPath = resolve(moduleDirectory, "..", "..", "schemas", "eval-report.schema.json");
15
+ let reportSchemaDigestsCache;
16
+ export function evalReportSchemaDigests() {
17
+ if (reportSchemaDigestsCache)
18
+ return reportSchemaDigestsCache;
19
+ let published;
20
+ try {
21
+ published = JSON.parse(readFileSync(publishedReportSchemaPath, "utf8"));
22
+ z.fromJSONSchema(published);
23
+ }
24
+ catch (error) {
25
+ throw new Error(`invalid published eval report schema: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ if (!published || typeof published !== "object" || Array.isArray(published)) {
28
+ throw new Error("invalid published eval report schema: expected one JSON object");
29
+ }
30
+ const document = published;
31
+ const definitions = document.$defs;
32
+ if (document.$schema !== "https://json-schema.org/draft/2020-12/schema"
33
+ || document.type !== "object"
34
+ || document.additionalProperties !== false
35
+ || !definitions
36
+ || typeof definitions !== "object"
37
+ || !("aggregate" in definitions)
38
+ || !("evidence" in definitions)) {
39
+ throw new Error("invalid published eval report schema: required strict report definitions are missing");
40
+ }
41
+ reportSchemaDigestsCache = [evalReportRuntimeContractDigest, canonicalDigest(published)];
42
+ return reportSchemaDigestsCache;
43
+ }
44
+ const DEFAULT_DISTILLER_ID = "cairn-note-distiller-v1";
45
+ const DEFAULT_DISTILL_TIMEOUT_MS = 120_000;
46
+ const MAX_SNAPSHOT_FILES = 10_000;
47
+ const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024;
48
+ function isContained(root, candidate) {
49
+ const rel = relative(root, candidate);
50
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith(sep));
51
+ }
52
+ function sha256(value) {
53
+ return createHash("sha256").update(value).digest("hex");
54
+ }
55
+ function snapshotKey(row) {
56
+ return `${row.arm}:r${row.repetition}:${row.task_id}`;
57
+ }
58
+ function capabilityState(row) {
59
+ return CAPABILITY_IDS.map((id) => ({ id, enabled: row.disabled_capability !== id }));
60
+ }
61
+ function capabilityDigest(rows) {
62
+ return createHash("sha256").update(JSON.stringify({
63
+ schema_version: 1,
64
+ contract_enabled: true,
65
+ capabilities: rows,
66
+ logging: { callbacks: false },
67
+ }), "utf8").digest("hex");
68
+ }
69
+ function capabilityEnvironmentName(id) {
70
+ return `CAIRN_CAPABILITY_${id.toUpperCase().replaceAll(".", "_")}`;
71
+ }
72
+ export function buildAblationArms(disabledCapability) {
73
+ if (!CAPABILITY_IDS.includes(disabledCapability)) {
74
+ throw new Error("invalid_disabled_capability");
75
+ }
76
+ return ([
77
+ { id: "baseline", disabled_capability: null },
78
+ { id: "treatment", disabled_capability: disabledCapability },
79
+ ]).map(({ id, disabled_capability }) => {
80
+ const expectedCapabilities = CAPABILITY_IDS.map((capabilityId) => ({
81
+ id: capabilityId,
82
+ enabled: disabled_capability !== capabilityId,
83
+ }));
84
+ const environment = Object.fromEntries(expectedCapabilities.map(({ id: capabilityId, enabled }) => [
85
+ capabilityEnvironmentName(capabilityId),
86
+ enabled ? "1" : "0",
87
+ ]));
88
+ return {
89
+ id,
90
+ disabled_capability,
91
+ expected_capabilities: expectedCapabilities,
92
+ expected_configuration_digest: capabilityDigest(expectedCapabilities),
93
+ environment: {
94
+ CAIRN_CAPABILITY_CONTRACT: "1",
95
+ CAIRN_CAPABILITY_LOGGING: "0",
96
+ ...environment,
97
+ },
98
+ };
99
+ });
100
+ }
101
+ async function observeCapabilityArm(arm, temporaryRoot, signal) {
102
+ const root = await mkdtemp(join(temporaryRoot ?? tmpdir(), "cairn-eval-arm-"));
103
+ const home = join(root, "home");
104
+ const temporary = join(root, "tmp");
105
+ await Promise.all([mkdir(home, { mode: 0o700 }), mkdir(temporary, { mode: 0o700 })]);
106
+ let status = null;
107
+ try {
108
+ const result = await runBoundedCommand({
109
+ command: { program: process.execPath, args: [join(moduleDirectory, "capability-cli.js"), "status", "--json"] },
110
+ cwd: root,
111
+ env: {
112
+ ...process.env,
113
+ ...arm.environment,
114
+ HOME: home,
115
+ TMPDIR: temporary,
116
+ XDG_CACHE_HOME: join(home, ".cache"),
117
+ XDG_CONFIG_HOME: join(home, ".config"),
118
+ XDG_DATA_HOME: join(home, ".local", "share"),
119
+ },
120
+ stdout_mode: "raw",
121
+ timeout_ms: 15_000,
122
+ max_stdout_bytes: 1024 * 1024,
123
+ signal,
124
+ });
125
+ if (result.exit_code === 0 && result.signal === null && result.cleanup === "closed") {
126
+ status = capabilityStatusSchema.parse(JSON.parse(result.stdout ?? ""));
127
+ }
128
+ }
129
+ catch {
130
+ status = null;
131
+ }
132
+ return { arm, root_path: root, status };
133
+ }
134
+ async function cleanupCapabilityArm(runtime) {
135
+ if (runtime)
136
+ await rm(runtime.root_path, { recursive: true, force: true });
137
+ }
138
+ function emptyNotes(row, snapshot) {
139
+ if (row.pass === "run2" && snapshot) {
140
+ return {
141
+ distiller_id: snapshot.distiller_id,
142
+ distiller_config_digest: snapshot.distiller_config_digest,
143
+ trajectory_ref: snapshot.trajectory_ref,
144
+ distillation_outcome: "success",
145
+ eligibility_reason: "same_task_snapshot",
146
+ note_snapshot_digest: snapshot.digest,
147
+ note_snapshot_manifest: snapshot.manifest,
148
+ notes_exposed: true,
149
+ };
150
+ }
151
+ return {
152
+ distiller_id: null,
153
+ distiller_config_digest: null,
154
+ trajectory_ref: null,
155
+ distillation_outcome: row.pass === "run1" ? "not_applicable" : "skipped",
156
+ eligibility_reason: row.pass === "run1" ? "pending_distillation" : "snapshot_unavailable",
157
+ note_snapshot_digest: null,
158
+ note_snapshot_manifest: [],
159
+ notes_exposed: false,
160
+ };
161
+ }
162
+ function pendingObservation(row, snapshot, priorNotes, capabilityRuntime) {
163
+ const capabilities = capabilityRuntime?.arm.expected_capabilities ?? capabilityState(row);
164
+ const expectedDigest = capabilityRuntime?.arm.expected_configuration_digest ?? capabilityDigest(capabilities);
165
+ return {
166
+ schema_version: 1,
167
+ observation_id: row.observation_id,
168
+ task_id: row.task_id,
169
+ schedule_index: row.schedule_index,
170
+ arm: row.arm,
171
+ disabled_capability: row.disabled_capability,
172
+ arm_order: row.arm_order,
173
+ repetition: row.repetition,
174
+ pass: row.pass,
175
+ seed: row.seed,
176
+ state: "pending",
177
+ terminal_state: null,
178
+ process: { exit_code: null, signal: null, error_code: null, cleanup: "pending" },
179
+ verifier: { state: "pending", reason: null },
180
+ pass_state: "unknown",
181
+ expected_capabilities: capabilities,
182
+ observed_capabilities: capabilities,
183
+ expected_capability_digest: expectedDigest,
184
+ observed_capability_digest: null,
185
+ capability_status: "pending",
186
+ capability_digest_match: null,
187
+ four_cell_id: `r${row.repetition}-${row.task_id}`,
188
+ notes: row.pass === "run2" && priorNotes
189
+ ? {
190
+ ...priorNotes,
191
+ note_snapshot_manifest: [...priorNotes.note_snapshot_manifest],
192
+ notes_exposed: snapshot !== null && snapshot !== undefined,
193
+ }
194
+ : emptyNotes(row, snapshot),
195
+ result: null,
196
+ missing_reasons: [],
197
+ };
198
+ }
199
+ function commandProcess(result) {
200
+ return {
201
+ exit_code: result.exit_code,
202
+ signal: result.signal,
203
+ error_code: null,
204
+ cleanup: result.cleanup,
205
+ };
206
+ }
207
+ function processError(error) {
208
+ return {
209
+ exit_code: error.exit_code,
210
+ signal: error.signal,
211
+ error_code: error.code,
212
+ cleanup: error.cleanup,
213
+ };
214
+ }
215
+ function terminalForProcessError(code) {
216
+ if (code === "timeout")
217
+ return "timeout";
218
+ if (code === "cancelled")
219
+ return "cancelled";
220
+ if (["invalid_utf8", "multiple_json", "invalid_json", "invalid_request", "invalid_result", "stdout_overflow"].includes(code)) {
221
+ return "invalid_result";
222
+ }
223
+ return "adapter_error";
224
+ }
225
+ function reportMissingness(report) {
226
+ const reasons = [...new Set(report.observations.flatMap((observation) => observation.missing_reasons))].sort();
227
+ const count = report.observations.filter((observation) => observation.missing_reasons.length > 0).length;
228
+ const missingness = { count, reasons };
229
+ report.missingness = { ...missingness, digest: canonicalDigest(missingness) };
230
+ report.evidence.missingness_digest = report.missingness.digest;
231
+ report.evidence.note_snapshot_digests = [...new Set(report.observations
232
+ .map(({ notes }) => notes.note_snapshot_digest)
233
+ .filter((digest) => digest !== null))].sort();
234
+ }
235
+ async function checkpoint(reportStore, report) {
236
+ report.updated_at = new Date().toISOString();
237
+ reportMissingness(report);
238
+ await checkpointEvalReport(reportStore, evalReportSchema.parse(report));
239
+ }
240
+ async function collectSnapshotFiles(root) {
241
+ const files = [];
242
+ const walk = async (directory) => {
243
+ const entries = await readdir(directory, { withFileTypes: true });
244
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, "en"))) {
245
+ const absolute = join(directory, entry.name);
246
+ const info = await lstat(absolute);
247
+ if (info.isSymbolicLink())
248
+ throw new Error("unsafe_note_snapshot");
249
+ if (info.isDirectory()) {
250
+ await walk(absolute);
251
+ continue;
252
+ }
253
+ if (!info.isFile())
254
+ throw new Error("unsafe_note_snapshot");
255
+ const bytes = await readFile(absolute);
256
+ files.push({ path: relative(root, absolute).split(sep).join("/"), bytes });
257
+ if (files.length > MAX_SNAPSHOT_FILES
258
+ || files.reduce((total, file) => total + file.bytes.byteLength, 0) > MAX_SNAPSHOT_BYTES) {
259
+ throw new Error("note_snapshot_limit");
260
+ }
261
+ }
262
+ };
263
+ await walk(root);
264
+ return files;
265
+ }
266
+ async function makeReadOnlyDirectories(root) {
267
+ const directories = [];
268
+ const walk = async (directory) => {
269
+ directories.push(directory);
270
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
271
+ if (entry.isDirectory())
272
+ await walk(join(directory, entry.name));
273
+ }
274
+ };
275
+ await walk(root);
276
+ for (const directory of directories.reverse())
277
+ await chmod(directory, 0o500);
278
+ }
279
+ export async function snapshotTaskNotes(options) {
280
+ const sourceRoot = join(options.workspace.notes_path, "notes");
281
+ if (!existsSync(sourceRoot))
282
+ return null;
283
+ const sourceInfo = await lstat(sourceRoot);
284
+ if (sourceInfo.isSymbolicLink() || !sourceInfo.isDirectory())
285
+ throw new Error("unsafe_note_snapshot");
286
+ const files = await collectSnapshotFiles(sourceRoot);
287
+ if (files.length === 0)
288
+ return null;
289
+ const snapshotParent = join(options.report_store.experiment_path, "snapshots");
290
+ await mkdir(snapshotParent, { recursive: true, mode: 0o700 });
291
+ const root = join(snapshotParent, `${options.row.arm}-r${options.row.repetition}-${options.row.task_id}`);
292
+ if (existsSync(root))
293
+ throw new Error("note_snapshot_exists");
294
+ await mkdir(root, { mode: 0o700 });
295
+ try {
296
+ const manifest = [];
297
+ for (const file of files) {
298
+ const destination = resolve(root, file.path);
299
+ if (!isContained(root, destination) || destination === root)
300
+ throw new Error("unsafe_note_snapshot");
301
+ await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
302
+ await copyFile(join(sourceRoot, ...file.path.split("/")), destination);
303
+ await chmod(destination, 0o400);
304
+ manifest.push({ path: file.path, digest: sha256(file.bytes), bytes: file.bytes.byteLength });
305
+ }
306
+ await makeReadOnlyDirectories(root);
307
+ const snapshot = {
308
+ task_id: options.row.task_id,
309
+ arm: options.row.arm,
310
+ repetition: options.row.repetition,
311
+ root_path: await realpath(root),
312
+ digest: canonicalDigest(manifest),
313
+ manifest,
314
+ distiller_id: options.distiller_id,
315
+ distiller_config_digest: options.distiller_config_digest,
316
+ trajectory_ref: options.trajectory_ref,
317
+ };
318
+ if (!await verifyNoteSnapshot(snapshot))
319
+ throw new Error("note_snapshot_verification_failed");
320
+ return snapshot;
321
+ }
322
+ catch (error) {
323
+ await chmod(root, 0o700).catch(() => undefined);
324
+ await rm(root, { recursive: true, force: true }).catch(() => undefined);
325
+ throw error;
326
+ }
327
+ }
328
+ export async function verifyNoteSnapshot(snapshot) {
329
+ try {
330
+ const root = await realpath(snapshot.root_path);
331
+ if (root !== snapshot.root_path)
332
+ return false;
333
+ const rootInfo = await lstat(root);
334
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o222) !== 0)
335
+ return false;
336
+ const files = await collectSnapshotFiles(root);
337
+ const manifest = files.map((file) => ({ path: file.path, digest: sha256(file.bytes), bytes: file.bytes.byteLength }));
338
+ if (canonicalDigest(manifest) !== snapshot.digest || canonicalDigest(manifest) !== canonicalDigest(snapshot.manifest))
339
+ return false;
340
+ for (const file of files) {
341
+ const info = await lstat(join(root, ...file.path.split("/")));
342
+ if ((info.mode & 0o222) !== 0)
343
+ return false;
344
+ }
345
+ return true;
346
+ }
347
+ catch {
348
+ return false;
349
+ }
350
+ }
351
+ async function exposeSnapshot(snapshot, workspace) {
352
+ if (!await verifyNoteSnapshot(snapshot))
353
+ throw new Error("note_snapshot_tampered");
354
+ for (const entry of snapshot.manifest) {
355
+ const source = join(snapshot.root_path, ...entry.path.split("/"));
356
+ const destination = resolve(workspace.notes_path, entry.path);
357
+ if (!isContained(workspace.notes_path, destination) || destination === workspace.notes_path) {
358
+ throw new Error("unsafe_note_snapshot");
359
+ }
360
+ await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
361
+ await copyFile(source, destination);
362
+ await chmod(destination, 0o400);
363
+ }
364
+ await makeReadOnlyDirectories(workspace.notes_path);
365
+ if (!await verifyNoteSnapshot(snapshot))
366
+ throw new Error("note_snapshot_tampered");
367
+ }
368
+ async function makeWritableTree(root) {
369
+ if (!existsSync(root))
370
+ return;
371
+ const walk = async (path) => {
372
+ const info = await lstat(path);
373
+ if (info.isSymbolicLink())
374
+ throw new Error("unsafe_note_snapshot");
375
+ if (info.isDirectory()) {
376
+ await chmod(path, 0o700);
377
+ for (const entry of await readdir(path))
378
+ await walk(join(path, entry));
379
+ }
380
+ else if (info.isFile()) {
381
+ await chmod(path, 0o600);
382
+ }
383
+ };
384
+ await walk(root);
385
+ }
386
+ export async function distillRunOneNotes(options) {
387
+ const trajectoryRef = options.observation.result?.trajectory_ref ?? null;
388
+ if (options.row.pass !== "run1"
389
+ || options.observation.result?.status !== "completed"
390
+ || trajectoryRef !== options.row.observation_id) {
391
+ return {
392
+ outcome: "skipped",
393
+ eligibility_reason: trajectoryRef === null ? "trajectory_unavailable" : "trajectory_not_exact",
394
+ distiller_id: null,
395
+ distiller_config_digest: null,
396
+ trajectory_ref: trajectoryRef,
397
+ snapshot: null,
398
+ process: null,
399
+ };
400
+ }
401
+ const distillerId = DEFAULT_DISTILLER_ID;
402
+ const distillerConfigDigest = canonicalDigest({ schema_version: 1, distiller_id: distillerId });
403
+ if (options.capability_environment?.CAIRN_CAPABILITY_NOTES_DISTILL === "0") {
404
+ return {
405
+ outcome: "skipped",
406
+ eligibility_reason: "capability_disabled",
407
+ distiller_id: distillerId,
408
+ distiller_config_digest: distillerConfigDigest,
409
+ trajectory_ref: trajectoryRef,
410
+ snapshot: null,
411
+ process: null,
412
+ };
413
+ }
414
+ const command = options.command ?? { program: process.execPath, args: [join(moduleDirectory, "note-cli.js")] };
415
+ try {
416
+ const result = await runBoundedCommand({
417
+ command: {
418
+ program: command.program,
419
+ args: [...command.args, "distill", "--project", options.workspace.workspace_path, "--session", trajectoryRef, "--json"],
420
+ },
421
+ cwd: options.workspace.workspace_path,
422
+ env: {
423
+ ...process.env,
424
+ HOME: options.workspace.home_path,
425
+ TMPDIR: options.workspace.temp_path,
426
+ XDG_CACHE_HOME: join(options.workspace.home_path, ".cache"),
427
+ XDG_CONFIG_HOME: join(options.workspace.home_path, ".config"),
428
+ XDG_DATA_HOME: join(options.workspace.home_path, ".local", "share"),
429
+ CAIRN_AGENTFS_BASE_DIR: options.workspace.notes_path,
430
+ CAIRN_NOTE_DISTILLATION: "1",
431
+ CAIRN_NOTE_ENRICHMENT: "0",
432
+ CAIRN_CAPABILITY_CONTRACT: options.capability_environment ? "1" : "0",
433
+ ...(options.capability_environment ?? {}),
434
+ },
435
+ stdout_mode: "raw",
436
+ timeout_ms: options.timeout_ms ?? DEFAULT_DISTILL_TIMEOUT_MS,
437
+ max_stdout_bytes: 1024 * 1024,
438
+ signal: options.signal,
439
+ });
440
+ if (result.exit_code !== 0 || result.signal !== null || result.cleanup !== "closed")
441
+ throw new Error("distiller_failed");
442
+ const parsed = JSON.parse(result.stdout ?? "");
443
+ const created = Array.isArray(parsed.created) ? parsed.created.length : 0;
444
+ const updated = Array.isArray(parsed.updated) ? parsed.updated.length : 0;
445
+ const failed = Array.isArray(parsed.failed) ? parsed.failed.length : 0;
446
+ if (parsed.enabled !== true || failed > 0)
447
+ throw new Error("distiller_failed");
448
+ if (created + updated === 0) {
449
+ return {
450
+ outcome: "no_notes",
451
+ eligibility_reason: "no_distilled_notes",
452
+ distiller_id: distillerId,
453
+ distiller_config_digest: distillerConfigDigest,
454
+ trajectory_ref: trajectoryRef,
455
+ snapshot: null,
456
+ process: null,
457
+ };
458
+ }
459
+ const snapshot = await snapshotTaskNotes({
460
+ workspace: options.workspace,
461
+ row: options.row,
462
+ report_store: options.report_store,
463
+ distiller_id: distillerId,
464
+ distiller_config_digest: distillerConfigDigest,
465
+ trajectory_ref: trajectoryRef,
466
+ });
467
+ if (!snapshot)
468
+ throw new Error("distiller_missing_notes");
469
+ return {
470
+ outcome: "success",
471
+ eligibility_reason: "same_task_snapshot",
472
+ distiller_id: distillerId,
473
+ distiller_config_digest: distillerConfigDigest,
474
+ trajectory_ref: trajectoryRef,
475
+ snapshot,
476
+ process: null,
477
+ };
478
+ }
479
+ catch (error) {
480
+ if (error instanceof EvalProcessError && error.code === "cancelled") {
481
+ return {
482
+ outcome: "skipped",
483
+ eligibility_reason: "distillation_cancelled",
484
+ distiller_id: distillerId,
485
+ distiller_config_digest: distillerConfigDigest,
486
+ trajectory_ref: trajectoryRef,
487
+ snapshot: null,
488
+ process: {
489
+ exit_code: error.exit_code,
490
+ signal: error.signal,
491
+ cleanup: error.cleanup,
492
+ termination_scope: error.termination_scope,
493
+ },
494
+ };
495
+ }
496
+ return {
497
+ outcome: "failed",
498
+ eligibility_reason: "distillation_failed",
499
+ distiller_id: distillerId,
500
+ distiller_config_digest: distillerConfigDigest,
501
+ trajectory_ref: trajectoryRef,
502
+ snapshot: null,
503
+ process: null,
504
+ };
505
+ }
506
+ }
507
+ function applyDistillation(observation, result) {
508
+ observation.notes = {
509
+ distiller_id: result.distiller_id,
510
+ distiller_config_digest: result.distiller_config_digest,
511
+ trajectory_ref: result.trajectory_ref,
512
+ distillation_outcome: result.outcome,
513
+ eligibility_reason: result.eligibility_reason,
514
+ note_snapshot_digest: result.snapshot?.digest ?? null,
515
+ note_snapshot_manifest: result.snapshot?.manifest ?? [],
516
+ notes_exposed: false,
517
+ };
518
+ if (result.eligibility_reason === "distillation_cancelled") {
519
+ observation.missing_reasons.push("distillation_cancelled");
520
+ }
521
+ else if (result.outcome !== "success") {
522
+ observation.missing_reasons.push(`notes_${result.outcome}`);
523
+ }
524
+ }
525
+ function replaceObservation(report, observation) {
526
+ const index = report.observations.findIndex(({ observation_id }) => observation_id === observation.observation_id);
527
+ if (index < 0)
528
+ report.observations.push(observation);
529
+ else
530
+ report.observations[index] = observation;
531
+ }
532
+ export async function runEvalObservation(options) {
533
+ const observation = pendingObservation(options.row, options.snapshot, options.prior_notes, options.capability_runtime);
534
+ const localStatus = options.capability_runtime?.status ?? null;
535
+ if (localStatus) {
536
+ observation.observed_capabilities = localStatus.capabilities.map(({ id, enabled }) => ({ id, enabled }));
537
+ }
538
+ replaceObservation(options.report, observation);
539
+ await checkpoint(options.report_store, options.report);
540
+ let workspace;
541
+ let distilledSnapshot = null;
542
+ try {
543
+ workspace = await createEvalWorkspace({
544
+ plan: options.plan,
545
+ row: options.row,
546
+ temporary_root: options.temporary_root,
547
+ });
548
+ if (options.row.pass === "run2" && options.snapshot)
549
+ await exposeSnapshot(options.snapshot, workspace);
550
+ const preparation = await runTaskPreparation(workspace, { signal: options.signal });
551
+ if (preparation.exit_code !== 0 || preparation.signal !== null || preparation.cleanup !== "closed") {
552
+ observation.state = "terminal";
553
+ observation.terminal_state = "adapter_error";
554
+ observation.process = commandProcess(preparation);
555
+ observation.process.error_code = "preparation_failed";
556
+ observation.verifier = { state: "not_run", reason: "preparation_failed" };
557
+ observation.missing_reasons.push("preparation_failed");
558
+ }
559
+ else {
560
+ const request = {
561
+ schema_version: 1,
562
+ experiment_id: options.report.experiment_id,
563
+ task_id: options.row.task_id,
564
+ arm: options.row.arm,
565
+ repetition: options.row.repetition,
566
+ pass: options.row.pass,
567
+ workspace_path: relative(workspace.parent_path, workspace.workspace_path).split(sep).join("/"),
568
+ notes_path: options.row.pass === "run2" && options.snapshot ? "notes" : null,
569
+ input: workspace.task.input,
570
+ limits: workspace.task.limits,
571
+ seed: options.row.seed,
572
+ expected_capability_digest: observation.expected_capability_digest,
573
+ output_path: "output",
574
+ };
575
+ const adapter = await runBoundedJsonAdapter({
576
+ command: {
577
+ program: options.plan.resolved_programs.adapter,
578
+ args: options.plan.adapter_config.command.args,
579
+ },
580
+ request,
581
+ cwd: workspace.parent_path,
582
+ env: {
583
+ ...process.env,
584
+ ...(options.capability_runtime?.arm.environment ?? {}),
585
+ HOME: workspace.home_path,
586
+ TMPDIR: workspace.temp_path,
587
+ XDG_CACHE_HOME: join(workspace.home_path, ".cache"),
588
+ XDG_CONFIG_HOME: join(workspace.home_path, ".config"),
589
+ XDG_DATA_HOME: join(workspace.home_path, ".local", "share"),
590
+ },
591
+ timeout_ms: workspace.task.limits.elapsed_ms,
592
+ max_stdout_bytes: workspace.task.limits.stdout_bytes,
593
+ signal: options.signal,
594
+ });
595
+ observation.process = commandProcess(adapter);
596
+ observation.result = adapter.result;
597
+ observation.observed_capability_digest = adapter.result.observed_capability_digest ?? null;
598
+ if (!options.capability_runtime) {
599
+ observation.capability_digest_match = observation.observed_capability_digest === null
600
+ ? null
601
+ : observation.observed_capability_digest === observation.expected_capability_digest;
602
+ }
603
+ else {
604
+ const localStateMatches = localStatus !== null
605
+ && localStatus.contract_enabled
606
+ && localStatus.issues.length === 0
607
+ && localStatus.configuration_digest === observation.expected_capability_digest
608
+ && canonicalDigest(observation.observed_capabilities) === canonicalDigest(observation.expected_capabilities);
609
+ const adapterDigestMatches = observation.observed_capability_digest !== null
610
+ && observation.observed_capability_digest === observation.expected_capability_digest
611
+ && observation.observed_capability_digest === localStatus?.configuration_digest;
612
+ observation.capability_digest_match = localStatus === null || observation.observed_capability_digest === null
613
+ ? null
614
+ : localStateMatches && adapterDigestMatches;
615
+ }
616
+ observation.capability_status = observation.capability_digest_match === null
617
+ ? "unavailable"
618
+ : observation.capability_digest_match ? "valid" : "mismatch";
619
+ if (observation.capability_status !== "valid")
620
+ observation.missing_reasons.push(`capability_${observation.capability_status}`);
621
+ if (adapter.result.status !== "completed") {
622
+ observation.state = "terminal";
623
+ observation.terminal_state = "adapter_error";
624
+ observation.process.error_code = adapter.result.error_code ?? "adapter_error";
625
+ observation.verifier = { state: "not_run", reason: "adapter_not_completed" };
626
+ observation.missing_reasons.push("adapter_error");
627
+ }
628
+ else {
629
+ const verifier = await runTaskVerifier(workspace, { adapter_completed: true, signal: options.signal });
630
+ observation.state = "terminal";
631
+ observation.terminal_state = verifier.terminal_state;
632
+ observation.verifier = { state: verifier.verifier_state, reason: verifier.reason };
633
+ observation.pass_state = verifier.pass_state;
634
+ if (verifier.terminal_state === "cancelled" && verifier.process) {
635
+ observation.process = commandProcess(verifier.process);
636
+ observation.process.error_code = "cancelled";
637
+ }
638
+ if (verifier.pass_state === "unknown")
639
+ observation.missing_reasons.push(verifier.reason ?? "verifier_unknown");
640
+ }
641
+ }
642
+ }
643
+ catch (error) {
644
+ observation.state = "terminal";
645
+ observation.pass_state = "unknown";
646
+ observation.verifier = { state: "not_run", reason: "adapter_not_completed" };
647
+ if (error instanceof EvalProcessError) {
648
+ observation.terminal_state = terminalForProcessError(error.code);
649
+ observation.process = processError(error);
650
+ observation.missing_reasons.push(error.code);
651
+ }
652
+ else {
653
+ observation.terminal_state = options.signal?.aborted ? "cancelled" : "adapter_error";
654
+ observation.process = {
655
+ exit_code: null,
656
+ signal: null,
657
+ error_code: options.signal?.aborted ? "cancelled" : "workspace_error",
658
+ cleanup: "failed",
659
+ };
660
+ observation.missing_reasons.push(options.signal?.aborted ? "cancelled" : "workspace_error");
661
+ }
662
+ }
663
+ if (workspace && options.row.pass === "run1" && observation.terminal_state !== "cancelled") {
664
+ const distilled = await distillRunOneNotes({
665
+ workspace,
666
+ row: options.row,
667
+ observation,
668
+ report_store: options.report_store,
669
+ command: options.distill_command,
670
+ timeout_ms: options.distill_timeout_ms,
671
+ signal: options.signal,
672
+ capability_environment: options.capability_runtime?.arm.environment,
673
+ });
674
+ applyDistillation(observation, distilled);
675
+ if (distilled.eligibility_reason === "distillation_cancelled" && distilled.process) {
676
+ observation.terminal_state = "cancelled";
677
+ observation.pass_state = "unknown";
678
+ observation.process = commandProcess(distilled.process);
679
+ observation.process.error_code = "cancelled";
680
+ }
681
+ distilledSnapshot = distilled.snapshot;
682
+ }
683
+ replaceObservation(options.report, observation);
684
+ await checkpoint(options.report_store, options.report);
685
+ if (workspace) {
686
+ await makeWritableTree(workspace.notes_path).catch(() => undefined);
687
+ const cleanup = await cleanupEvalWorkspace(workspace);
688
+ if (cleanup.status !== "closed") {
689
+ observation.missing_reasons.push("workspace_cleanup_failed");
690
+ observation.process.cleanup = "failed";
691
+ }
692
+ }
693
+ if (options.snapshot && !await verifyNoteSnapshot(options.snapshot)) {
694
+ observation.missing_reasons.push("note_snapshot_tampered");
695
+ observation.notes.notes_exposed = false;
696
+ }
697
+ replaceObservation(options.report, observation);
698
+ await checkpoint(options.report_store, options.report);
699
+ return { observation, snapshot: distilledSnapshot, cancelled: observation.terminal_state === "cancelled" };
700
+ }
701
+ function initialReport(plan, experimentId) {
702
+ const now = new Date().toISOString();
703
+ const sourceCommit = plan.task_set_commit ?? (plan.source.kind === "git" ? plan.source.revision : plan.task_set_digest);
704
+ const missingness = { count: 0, reasons: [] };
705
+ return evalReportSchema.parse({
706
+ schema_version: 1,
707
+ experiment_id: experimentId,
708
+ status: "partial",
709
+ experiment_kind: plan.arms.length === 1 ? "two_pass" : "ablation",
710
+ task_set_digest: plan.task_set_digest,
711
+ adapter_config_digest: plan.adapter_config_digest,
712
+ source_revision: plan.source.kind === "git" ? plan.source.revision : plan.task_set_digest,
713
+ schedule_digest: plan.schedule_digest,
714
+ created_at: now,
715
+ updated_at: now,
716
+ runtime: {
717
+ platform: process.platform,
718
+ arch: process.arch,
719
+ node: process.version,
720
+ cairnkeep: "0.1.0",
721
+ },
722
+ schedule: plan.schedule.map(({ observation_id, task_id, arm, repetition, pass, seed }) => ({
723
+ observation_id, task_id, arm, repetition, pass, seed,
724
+ })),
725
+ observations: [],
726
+ aggregates: [],
727
+ missingness: { ...missingness, digest: canonicalDigest(missingness) },
728
+ warnings: [],
729
+ evidence: {
730
+ schema_version: 1,
731
+ evidence_scope: plan.source.kind === "bundled_fake" ? "offline-framework" : "live-evaluation",
732
+ source_commit: sourceCommit,
733
+ package_version: plan.source.kind === "bundled_fake" ? plan.source.package_version : "0.1.0",
734
+ runtime_id: "node-local",
735
+ task_set_digest: plan.task_set_digest,
736
+ report_digest: canonicalDigest({ experiment_id: experimentId, plan_digest: plan.plan_digest }),
737
+ schema_digests: [...evalReportSchemaDigests()],
738
+ note_snapshot_digests: [],
739
+ missingness_digest: canonicalDigest(missingness),
740
+ claim_anchors: [],
741
+ },
742
+ });
743
+ }
744
+ async function runExperiment(options, capabilityArms) {
745
+ const experimentId = options.experiment_id ?? options.report_store?.experiment_id
746
+ ?? `eval-${options.plan.plan_digest.slice(0, 24)}`;
747
+ const reportStore = options.report_store ?? await createEvalReportStore({
748
+ root: options.plan.output_root,
749
+ experiment_id: experimentId,
750
+ });
751
+ if (reportStore.experiment_id !== experimentId)
752
+ throw new Error("report_experiment_mismatch");
753
+ const report = initialReport(options.plan, experimentId);
754
+ const snapshotsByTask = new Map();
755
+ const notesByTask = new Map();
756
+ const snapshots = [];
757
+ const executed = [];
758
+ let capabilityRuntime;
759
+ await checkpoint(reportStore, report);
760
+ try {
761
+ for (const row of options.plan.schedule) {
762
+ if (options.signal?.aborted)
763
+ break;
764
+ if (capabilityArms && capabilityRuntime?.arm.id !== row.arm) {
765
+ await cleanupCapabilityArm(capabilityRuntime);
766
+ const arm = capabilityArms.find(({ id }) => id === row.arm);
767
+ if (!arm)
768
+ throw new Error("ablation_arm_missing");
769
+ capabilityRuntime = await observeCapabilityArm(arm, options.temporary_root, options.signal);
770
+ }
771
+ const snapshot = row.pass === "run2" ? snapshotsByTask.get(snapshotKey(row)) ?? null : null;
772
+ const priorNotes = row.pass === "run2" ? notesByTask.get(snapshotKey(row)) : undefined;
773
+ const result = await runEvalObservation({
774
+ plan: options.plan,
775
+ row,
776
+ report,
777
+ report_store: reportStore,
778
+ temporary_root: options.temporary_root,
779
+ signal: options.signal,
780
+ snapshot,
781
+ prior_notes: priorNotes,
782
+ distill_command: options.distill_command,
783
+ distill_timeout_ms: options.distill_timeout_ms,
784
+ capability_runtime: capabilityRuntime,
785
+ });
786
+ executed.push(row.observation_id);
787
+ if (result.snapshot) {
788
+ snapshotsByTask.set(snapshotKey(row), result.snapshot);
789
+ snapshots.push(result.snapshot);
790
+ }
791
+ if (row.pass === "run1")
792
+ notesByTask.set(snapshotKey(row), {
793
+ ...result.observation.notes,
794
+ note_snapshot_manifest: [...result.observation.notes.note_snapshot_manifest],
795
+ });
796
+ if (result.cancelled || options.signal?.aborted)
797
+ break;
798
+ }
799
+ }
800
+ finally {
801
+ await cleanupCapabilityArm(capabilityRuntime);
802
+ }
803
+ const expectedPrefix = options.plan.schedule.slice(0, executed.length).map(({ observation_id }) => observation_id);
804
+ if (canonicalDigest(executed) !== canonicalDigest(expectedPrefix))
805
+ throw new Error("schedule_order_mismatch");
806
+ if (executed.length === options.plan.schedule.length) {
807
+ report.status = "final";
808
+ }
809
+ await checkpoint(reportStore, report);
810
+ return { report: evalReportSchema.parse(report), report_store: reportStore, snapshots };
811
+ }
812
+ export async function runTwoPassExperiment(options) {
813
+ return runExperiment(options);
814
+ }
815
+ export async function runCapabilityAblation(options) {
816
+ const arms = buildAblationArms(options.disabled_capability);
817
+ const intended = arms.map(({ id, disabled_capability }) => ({ id, disabled_capability }));
818
+ if (canonicalDigest(options.plan.arms) !== canonicalDigest(intended))
819
+ throw new Error("ablation_arm_mismatch");
820
+ const expectedInvocations = options.plan.task_set.tasks.length * options.plan.repetitions * 4;
821
+ if (options.plan.invocation_count !== expectedInvocations || options.plan.schedule.length !== expectedInvocations) {
822
+ throw new Error("ablation_schedule_incomplete");
823
+ }
824
+ return runExperiment(options, arms);
825
+ }
826
+ export const runEvalTwoPass = runTwoPassExperiment;
827
+ export const runEvalAblation = runCapabilityAblation;