@cairnkeep/cli 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +221 -6
  3. package/bin/cairn +43 -2
  4. package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
  5. package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
  6. package/claude/hooks/compaction-capture.sh +34 -0
  7. package/claude/hooks/memory-capture.sh +47 -5
  8. package/claude/hooks/memory-wakeup.sh +55 -2
  9. package/docs/building-an-overlay.md +1 -1
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +2 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  15. package/docs/operating.md +531 -4
  16. package/docs/privacy-and-data-flow.md +417 -0
  17. package/docs/storage.md +252 -0
  18. package/examples/eval/README.md +42 -0
  19. package/examples/eval/adapter.json +12 -0
  20. package/examples/eval/bundled-fake.json +1 -0
  21. package/examples/eval/task-set.json +1 -0
  22. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  23. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  24. package/mcp-memory-server/dist/artifact-store.js +812 -0
  25. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  26. package/mcp-memory-server/dist/capability-cli.js +361 -0
  27. package/mcp-memory-server/dist/capability-config.js +310 -0
  28. package/mcp-memory-server/dist/capability-harness.js +496 -0
  29. package/mcp-memory-server/dist/capability-registry.js +123 -0
  30. package/mcp-memory-server/dist/capability-schema.js +65 -0
  31. package/mcp-memory-server/dist/capability-store.js +431 -0
  32. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  33. package/mcp-memory-server/dist/eval-cli.js +523 -0
  34. package/mcp-memory-server/dist/eval-plan.js +346 -0
  35. package/mcp-memory-server/dist/eval-process.js +273 -0
  36. package/mcp-memory-server/dist/eval-report.js +609 -0
  37. package/mcp-memory-server/dist/eval-runner.js +827 -0
  38. package/mcp-memory-server/dist/eval-schema.js +368 -0
  39. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  40. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  41. package/mcp-memory-server/dist/failure-signature.js +141 -0
  42. package/mcp-memory-server/dist/index.js +600 -55
  43. package/mcp-memory-server/dist/node-cli.js +84 -0
  44. package/mcp-memory-server/dist/node-schema.js +170 -0
  45. package/mcp-memory-server/dist/node-store.js +455 -0
  46. package/mcp-memory-server/dist/note-cli.js +235 -0
  47. package/mcp-memory-server/dist/note-distiller.js +177 -0
  48. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  49. package/mcp-memory-server/dist/note-schema.js +4 -0
  50. package/mcp-memory-server/dist/note-store.js +985 -0
  51. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  52. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  53. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  54. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  55. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  56. package/mcp-memory-server/package.json +24 -2
  57. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  58. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  59. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  60. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  61. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  62. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  63. package/opencode/command/graphify.md +25 -14
  64. package/opencode/plugins/memory-capture.ts +76 -10
  65. package/opencode/plugins/memory-wakeup.ts +58 -6
  66. package/package.json +8 -4
  67. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  68. package/schemas/artifact.schema.json +133 -0
  69. package/schemas/capability-callback.schema.json +68 -0
  70. package/schemas/capability-contract.schema.json +126 -0
  71. package/schemas/eval-adapter.schema.json +43 -0
  72. package/schemas/eval-protocol.schema.json +114 -0
  73. package/schemas/eval-report.schema.json +423 -0
  74. package/schemas/eval-task-set.schema.json +114 -0
  75. package/schemas/memory-node.schema.json +90 -0
  76. package/schemas/note.schema.json +112 -0
  77. package/schemas/trajectory-redaction.schema.json +25 -0
  78. package/schemas/trajectory.schema.json +82 -0
  79. package/scripts/bootstrap.sh +4 -0
  80. package/scripts/completion.sh +119 -5
  81. package/scripts/doctor.sh +284 -0
  82. package/scripts/fake-eval-adapter.mjs +120 -0
  83. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  84. package/scripts/install-audit-timer.sh +3 -1
  85. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  86. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  87. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  88. package/scripts/memory-wiki-audit.sh +49 -0
  89. package/scripts/smoke-bash32.sh +9 -0
  90. package/scripts/sync-claude-assets.sh +99 -6
  91. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  92. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  93. package/scripts/sync-opencode-security-assets.sh +24 -3
  94. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  95. package/scripts/sync-pi-assets.sh +72 -0
  96. package/scripts/test-audit-timer.sh +98 -0
  97. package/scripts/test-bootstrap-untracked.sh +48 -0
  98. package/scripts/test-cli-dispatch.sh +41 -2
  99. package/scripts/test-compaction-hooks.sh +495 -0
  100. package/scripts/test-completion.sh +77 -0
  101. package/scripts/test-doctor.sh +348 -1
  102. package/scripts/test-eval-cli.sh +175 -0
  103. package/scripts/test-launcher-hooks.sh +602 -3
  104. package/scripts/test-notes-cli.sh +114 -0
  105. package/scripts/test-package-install.sh +251 -0
  106. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  107. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  108. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  109. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  110. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  111. package/scripts/test-trajectory-cli.sh +87 -0
  112. package/scripts/test-trajectory-hooks.sh +142 -0
  113. package/scripts/test-uninstall.sh +231 -5
  114. package/scripts/uninstall.sh +37 -5
  115. package/scripts/verify-dependency-security.mjs +121 -0
  116. package/scripts/verify-docs-parity.sh +527 -1
  117. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  118. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  119. package/templates/capabilities.json.template +7 -0
  120. package/templates/env.example.template +51 -0
  121. package/templates/memory-wiki-audit.service.template +1 -1
  122. package/templates/start-claude.sh.template +36 -0
  123. package/templates/start-opencode.sh.template +47 -5
  124. package/templates/start-pi.sh.template +30 -0
  125. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,346 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { accessSync, closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, } from "node:fs";
4
+ import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CAPABILITY_IDS } from "./capability-schema.js";
7
+ import { EVAL_SCHEMA_VERSION, canonicalDigest, canonicalJson, evalAdapterConfigSchema, evalTaskSetSchema, } from "./eval-schema.js";
8
+ const MAX_INPUT_BYTES = 4 * 1024 * 1024;
9
+ const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024;
10
+ const MAX_REPETITIONS = 1_000;
11
+ const truthyPattern = /^(?:1|true|yes|on)$/i;
12
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
13
+ const projectPackagePath = resolve(moduleDirectory, "..", "..", "package.json");
14
+ const bundledTaskSetPath = resolve(dirname(projectPackagePath), "examples", "eval", "task-set.json");
15
+ function isContained(root, candidate) {
16
+ const rel = relative(root, candidate);
17
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
18
+ }
19
+ function assertRealDirectory(path, label) {
20
+ const info = lstatSync(path);
21
+ if (info.isSymbolicLink() || !info.isDirectory())
22
+ throw new Error(`${label} must be a real directory.`);
23
+ return realpathSync(path);
24
+ }
25
+ function readBoundedJson(path, label) {
26
+ const absolute = resolve(path);
27
+ const pathInfo = lstatSync(absolute);
28
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile())
29
+ throw new Error(`${label} must be a regular non-symlink file.`);
30
+ const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
31
+ const descriptor = openSync(absolute, flags);
32
+ try {
33
+ const info = fstatSync(descriptor);
34
+ if (!info.isFile() || info.size > MAX_INPUT_BYTES)
35
+ throw new Error(`${label} exceeds the ${MAX_INPUT_BYTES}-byte limit.`);
36
+ const bytes = readFileSync(descriptor);
37
+ if (bytes.byteLength > MAX_INPUT_BYTES)
38
+ throw new Error(`${label} exceeds the ${MAX_INPUT_BYTES}-byte limit.`);
39
+ let value;
40
+ try {
41
+ value = JSON.parse(bytes.toString("utf8"));
42
+ }
43
+ catch {
44
+ throw new Error(`${label} is not valid JSON.`);
45
+ }
46
+ return { path: realpathSync(absolute), value, bytes };
47
+ }
48
+ finally {
49
+ closeSync(descriptor);
50
+ }
51
+ }
52
+ function git(repository, args, label) {
53
+ const result = spawnSync("git", ["-C", repository, ...args], {
54
+ encoding: "utf8",
55
+ timeout: 10_000,
56
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
57
+ shell: false,
58
+ windowsHide: true,
59
+ });
60
+ if (result.error || result.status !== 0)
61
+ throw new Error(`${label} failed.`);
62
+ return (result.stdout ?? "").trim();
63
+ }
64
+ function resolveExactCommit(repository, revision) {
65
+ const resolved = git(repository, ["rev-parse", "--verify", `${revision}^{commit}`], "Resolve evaluation source commit");
66
+ if (resolved !== revision)
67
+ throw new Error("Evaluation source revision must resolve exactly to the supplied full commit ID.");
68
+ return resolved;
69
+ }
70
+ function parseCommittedTaskSet(taskSetPath) {
71
+ const manifestRepository = assertRealDirectory(git(dirname(taskSetPath), ["rev-parse", "--show-toplevel"], "Resolve task-set repository"), "Task-set repository");
72
+ if (!isContained(manifestRepository, taskSetPath))
73
+ throw new Error("The task-set manifest must be contained by its Git repository.");
74
+ const manifestPath = relative(manifestRepository, taskSetPath).split(sep).join("/");
75
+ if (!manifestPath || manifestPath.startsWith("../"))
76
+ throw new Error("The task-set manifest path is invalid.");
77
+ git(manifestRepository, ["ls-files", "--error-unmatch", "--", manifestPath], "Require a tracked evaluation task set");
78
+ const dirty = git(manifestRepository, ["status", "--porcelain", "--untracked-files=all", "--", manifestPath], "Check evaluation task set status");
79
+ if (dirty !== "")
80
+ throw new Error("Evaluation task set must be committed and unchanged.");
81
+ const commit = git(manifestRepository, ["rev-parse", "--verify", "HEAD^{commit}"], "Resolve task-set commit");
82
+ const committed = git(manifestRepository, ["show", `${commit}:${manifestPath}`], "Read committed evaluation task set");
83
+ let value;
84
+ try {
85
+ value = JSON.parse(committed);
86
+ }
87
+ catch {
88
+ throw new Error("The committed evaluation task set is not valid JSON.");
89
+ }
90
+ return { taskSet: evalTaskSetSchema.parse(value), commit };
91
+ }
92
+ function resolveWorkspaceDirectories(taskSet, repository, revision) {
93
+ for (const task of taskSet.tasks) {
94
+ const candidate = resolve(repository, task.workspace.path);
95
+ if (!isContained(repository, candidate))
96
+ throw new Error(`Task ${task.id} workspace escapes the declared repository.`);
97
+ const object = task.workspace.path === "." ? `${revision}^{tree}` : `${revision}:${task.workspace.path}`;
98
+ const kind = git(repository, ["cat-file", "-t", object], `Resolve task ${task.id} workspace`);
99
+ if (kind !== "tree")
100
+ throw new Error(`Task ${task.id} workspace is not a committed directory.`);
101
+ }
102
+ }
103
+ function resolvePathProgram(program, base) {
104
+ const candidate = isAbsolute(program) ? program : resolve(base, program);
105
+ const info = lstatSync(candidate);
106
+ if (!info.isFile() && !info.isSymbolicLink())
107
+ throw new Error(`Evaluation program is not a file: ${program}`);
108
+ accessSync(candidate, constants.X_OK);
109
+ return realpathSync(candidate);
110
+ }
111
+ function resolveProgram(program, base) {
112
+ if (isAbsolute(program) || program.includes("/") || program.includes("\\"))
113
+ return resolvePathProgram(program, base);
114
+ for (const directory of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
115
+ const candidate = join(directory, program);
116
+ try {
117
+ accessSync(candidate, constants.X_OK);
118
+ if (lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink())
119
+ return realpathSync(candidate);
120
+ }
121
+ catch {
122
+ // Continue through PATH without executing the candidate.
123
+ }
124
+ }
125
+ throw new Error(`Evaluation program was not found on PATH: ${program}`);
126
+ }
127
+ function validateOutputRoot(path) {
128
+ const absolute = resolve(path);
129
+ let parent = absolute;
130
+ while (!existsSync(parent)) {
131
+ const next = dirname(parent);
132
+ if (next === parent)
133
+ throw new Error("Evaluation output has no existing parent directory.");
134
+ parent = next;
135
+ }
136
+ const realParent = assertRealDirectory(parent, "Evaluation output parent");
137
+ accessSync(realParent, constants.W_OK | constants.X_OK);
138
+ if (existsSync(absolute)) {
139
+ const realOutput = assertRealDirectory(absolute, "Evaluation output root");
140
+ if (!isContained(realParent, realOutput) && realParent !== realOutput) {
141
+ throw new Error("Evaluation output root resolves outside its writable parent.");
142
+ }
143
+ }
144
+ return absolute;
145
+ }
146
+ function packageVersion() {
147
+ const value = readBoundedJson(projectPackagePath, "Installed package manifest").value;
148
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
149
+ throw new Error("Installed package version is invalid.");
150
+ }
151
+ const record = value;
152
+ if (typeof record.version !== "string" || record.version.length < 1 || record.version.length > 128) {
153
+ throw new Error("Installed package version is invalid.");
154
+ }
155
+ return record.version;
156
+ }
157
+ function validateBundledBinding(taskSetPath, taskSet, taskSetBytes) {
158
+ if (taskSet.source.kind !== "bundled_fake")
159
+ throw new Error("Bundled binding requires the dedicated bundled source.");
160
+ if (!existsSync(bundledTaskSetPath) || realpathSync(taskSetPath) !== realpathSync(bundledTaskSetPath)) {
161
+ throw new Error("The bundled evaluation source is accepted only from the installed package-owned task set.");
162
+ }
163
+ if (!taskSetBytes.equals(Buffer.from(`${canonicalJson(taskSet)}\n`, "utf8"))) {
164
+ throw new Error("The bundled evaluation task set must use its exact canonical bytes.");
165
+ }
166
+ const bindingPath = join(dirname(taskSetPath), "bundled-fake.json");
167
+ const binding = readBoundedJson(bindingPath, "Bundled evaluation binding").value;
168
+ if (!binding || typeof binding !== "object" || Array.isArray(binding))
169
+ throw new Error("Bundled evaluation binding is invalid.");
170
+ const record = binding;
171
+ const keys = Object.keys(record).sort();
172
+ if (canonicalJson(keys) !== canonicalJson(["identifier", "package_version", "schema_version", "task_set_digest"])) {
173
+ throw new Error("Bundled evaluation binding has unknown or missing fields.");
174
+ }
175
+ const installedVersion = packageVersion();
176
+ if (record.schema_version !== EVAL_SCHEMA_VERSION
177
+ || record.identifier !== "cairn-offline-fake-v1"
178
+ || record.package_version !== installedVersion
179
+ || record.task_set_digest !== canonicalDigest(taskSet)) {
180
+ throw new Error("Bundled evaluation binding does not match the installed package and committed task set.");
181
+ }
182
+ return {
183
+ kind: "bundled_fake",
184
+ identifier: "cairn-offline-fake-v1",
185
+ package_version: installedVersion,
186
+ binding_path: realpathSync(bindingPath),
187
+ };
188
+ }
189
+ function validateArms(arms) {
190
+ if (arms.length < 1 || arms.length > 2)
191
+ throw new Error("Evaluation requires one or two arms.");
192
+ const seen = new Set();
193
+ return arms.map((arm) => {
194
+ if (arm.id !== "baseline" && arm.id !== "treatment")
195
+ throw new Error("Evaluation arm is invalid.");
196
+ if (seen.has(arm.id))
197
+ throw new Error("Evaluation arm IDs must be unique.");
198
+ seen.add(arm.id);
199
+ if (arm.disabled_capability !== null && !CAPABILITY_IDS.includes(arm.disabled_capability)) {
200
+ throw new Error("Evaluation arm disables an unknown capability.");
201
+ }
202
+ if (arm.id === "baseline" && arm.disabled_capability !== null)
203
+ throw new Error("Baseline cannot disable a capability.");
204
+ if (arm.id === "treatment" && arm.disabled_capability === null)
205
+ throw new Error("Treatment must disable exactly one capability.");
206
+ return { id: arm.id, disabled_capability: arm.disabled_capability };
207
+ });
208
+ }
209
+ function rowSeed(seed, repetition, taskId) {
210
+ return canonicalDigest({ seed, repetition, task_id: taskId }).slice(0, 32);
211
+ }
212
+ export function isEvalEnabled(value = process.env.CAIRN_EVAL) {
213
+ return truthyPattern.test(value?.trim() ?? "");
214
+ }
215
+ export function buildEvalSchedule(options) {
216
+ const taskSet = evalTaskSetSchema.parse(options.taskSet);
217
+ const arms = validateArms(options.arms);
218
+ if (!Number.isSafeInteger(options.repetitions) || options.repetitions < 1 || options.repetitions > MAX_REPETITIONS) {
219
+ throw new Error(`Evaluation repetitions must be an integer from 1 to ${MAX_REPETITIONS}.`);
220
+ }
221
+ if (options.seed.length < 1 || options.seed.length > 256)
222
+ throw new Error("Evaluation seed is invalid.");
223
+ if (options.passes.length !== 2 || options.passes[0] !== "run1" || options.passes[1] !== "run2") {
224
+ throw new Error("Evaluation passes must be exactly run1 followed by run2.");
225
+ }
226
+ const rows = [];
227
+ for (const [armOrder, arm] of arms.entries()) {
228
+ for (let repetition = 0; repetition < options.repetitions; repetition += 1) {
229
+ for (const pass of options.passes) {
230
+ for (const task of taskSet.tasks) {
231
+ const observationId = `${arm.id}-r${repetition}-${pass}-${task.id}`;
232
+ rows.push({
233
+ schedule_index: rows.length,
234
+ observation_id: observationId,
235
+ task_id: task.id,
236
+ arm: arm.id,
237
+ disabled_capability: arm.disabled_capability,
238
+ arm_order: armOrder,
239
+ repetition,
240
+ pass,
241
+ seed: rowSeed(options.seed, repetition, task.id),
242
+ note_snapshot_task_id: pass === "run2" ? task.id : null,
243
+ });
244
+ }
245
+ }
246
+ }
247
+ }
248
+ const frozenRows = rows.map((row) => Object.freeze({ ...row }));
249
+ return Object.freeze({
250
+ concurrency: 1,
251
+ invocation_count: frozenRows.length,
252
+ digest: canonicalDigest(frozenRows),
253
+ rows: Object.freeze(frozenRows),
254
+ });
255
+ }
256
+ export function validateEvalInputs(options) {
257
+ const cwd = assertRealDirectory(resolve(options.cwd ?? process.cwd()), "Evaluation working directory");
258
+ const taskInput = readBoundedJson(resolve(cwd, options.taskSetPath), "Evaluation task set");
259
+ const adapterInput = readBoundedJson(resolve(cwd, options.adapterPath), "Evaluation adapter configuration");
260
+ const parsedTaskSet = evalTaskSetSchema.parse(taskInput.value);
261
+ const adapterConfig = evalAdapterConfigSchema.parse(adapterInput.value);
262
+ let taskSet = parsedTaskSet;
263
+ let taskSetCommit;
264
+ let source;
265
+ let repositoryRoot;
266
+ if (parsedTaskSet.source.kind === "git") {
267
+ const committed = parseCommittedTaskSet(taskInput.path);
268
+ if (canonicalJson(committed.taskSet) !== canonicalJson(parsedTaskSet)) {
269
+ throw new Error("Evaluation task set differs from its committed document.");
270
+ }
271
+ taskSet = committed.taskSet;
272
+ taskSetCommit = committed.commit;
273
+ const repositoryCandidate = resolve(dirname(taskInput.path), parsedTaskSet.source.repository);
274
+ repositoryRoot = assertRealDirectory(repositoryCandidate, "Evaluation source repository");
275
+ const revision = resolveExactCommit(repositoryRoot, parsedTaskSet.source.revision);
276
+ resolveWorkspaceDirectories(taskSet, repositoryRoot, revision);
277
+ source = { kind: "git", repository_root: repositoryRoot, revision };
278
+ }
279
+ else {
280
+ source = validateBundledBinding(taskInput.path, taskSet, taskInput.bytes);
281
+ taskSetCommit = null;
282
+ }
283
+ const repetitions = options.repetitions ?? 1;
284
+ const seed = options.seed ?? "cairn-eval-v1";
285
+ const arms = validateArms(options.arms ?? [{ id: "baseline", disabled_capability: null }]);
286
+ const passes = ["run1", "run2"];
287
+ const schedule = buildEvalSchedule({ taskSet, arms, repetitions, passes, seed });
288
+ const adapterBase = dirname(adapterInput.path);
289
+ const workspaceBase = repositoryRoot ?? dirname(taskInput.path);
290
+ const outputRoot = validateOutputRoot(resolve(cwd, options.outputRoot));
291
+ const taskSetDigest = canonicalDigest(taskSet);
292
+ const adapterConfigDigest = canonicalDigest(adapterConfig);
293
+ const resolvedPrograms = {
294
+ adapter: resolveProgram(adapterConfig.command.program, adapterBase),
295
+ prepare: taskSet.tasks.map((task) => resolveProgram(task.prepare.program, workspaceBase)),
296
+ verify: taskSet.tasks.map((task) => resolveProgram(task.verify.program, workspaceBase)),
297
+ };
298
+ const identity = {
299
+ schema_version: EVAL_SCHEMA_VERSION,
300
+ task_set_digest: taskSetDigest,
301
+ adapter_config_digest: adapterConfigDigest,
302
+ source,
303
+ output_root: outputRoot,
304
+ repetitions,
305
+ seed,
306
+ arms,
307
+ passes,
308
+ concurrency: 1,
309
+ task_set_commit: taskSetCommit,
310
+ schedule_digest: schedule.digest,
311
+ };
312
+ const plan = {
313
+ schema_version: EVAL_SCHEMA_VERSION,
314
+ task_set: taskSet,
315
+ adapter_config: adapterConfig,
316
+ task_set_path: taskInput.path,
317
+ adapter_path: adapterInput.path,
318
+ output_root: outputRoot,
319
+ source,
320
+ repetitions,
321
+ seed,
322
+ arms,
323
+ passes: ["run1", "run2"],
324
+ concurrency: 1,
325
+ invocation_count: schedule.invocation_count,
326
+ task_set_commit: taskSetCommit,
327
+ task_set_digest: taskSetDigest,
328
+ adapter_config_digest: adapterConfigDigest,
329
+ schedule_digest: schedule.digest,
330
+ plan_digest: canonicalDigest(identity),
331
+ schedule: schedule.rows,
332
+ resolved_programs: resolvedPrograms,
333
+ };
334
+ return deepFreeze(plan);
335
+ }
336
+ function deepFreeze(value) {
337
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
338
+ Object.freeze(value);
339
+ for (const child of Object.values(value))
340
+ deepFreeze(child);
341
+ }
342
+ return value;
343
+ }
344
+ export function loadEvalPlan(options) {
345
+ return validateEvalInputs(options);
346
+ }
@@ -0,0 +1,273 @@
1
+ import { spawn } from "node:child_process";
2
+ import { evalAdapterRequestSchema, evalAdapterResultSchema, } from "./eval-schema.js";
3
+ export const EVAL_PROCESS_ERROR_CODES = [
4
+ "spawn_error",
5
+ "timeout",
6
+ "cancelled",
7
+ "stdout_overflow",
8
+ "invalid_utf8",
9
+ "multiple_json",
10
+ "invalid_json",
11
+ "invalid_request",
12
+ "invalid_result",
13
+ "adapter_error",
14
+ "stdin_error",
15
+ "cleanup_failed",
16
+ ];
17
+ export class EvalProcessError extends Error {
18
+ code;
19
+ exit_code;
20
+ signal;
21
+ cleanup;
22
+ termination_scope;
23
+ constructor(code, details = {}) {
24
+ super(code);
25
+ this.name = "EvalProcessError";
26
+ this.code = code;
27
+ this.exit_code = details.exit_code ?? null;
28
+ this.signal = details.signal ?? null;
29
+ this.cleanup = details.cleanup ?? "closed";
30
+ this.termination_scope = details.termination_scope ?? terminationScope();
31
+ }
32
+ }
33
+ const DEFAULT_KILL_GRACE_MS = 1_000;
34
+ const MAX_TIMER_MS = 2_147_483_647;
35
+ function terminationScope() {
36
+ return process.platform === "win32" ? "child" : "process-group";
37
+ }
38
+ function assertOptions(options) {
39
+ if (!Number.isSafeInteger(options.timeout_ms) || options.timeout_ms <= 0 || options.timeout_ms > MAX_TIMER_MS) {
40
+ throw new EvalProcessError("invalid_request");
41
+ }
42
+ if (!Number.isSafeInteger(options.max_stdout_bytes) || options.max_stdout_bytes < 0) {
43
+ throw new EvalProcessError("invalid_request");
44
+ }
45
+ if (options.kill_grace_ms !== undefined
46
+ && (!Number.isSafeInteger(options.kill_grace_ms) || options.kill_grace_ms < 0 || options.kill_grace_ms > MAX_TIMER_MS)) {
47
+ throw new EvalProcessError("invalid_request");
48
+ }
49
+ if (!options.command.program || options.command.program.includes("\u0000")
50
+ || options.command.args.some((argument) => argument.includes("\u0000"))) {
51
+ throw new EvalProcessError("invalid_request");
52
+ }
53
+ }
54
+ function signalChild(child, signal) {
55
+ if (child.pid === undefined)
56
+ return false;
57
+ try {
58
+ if (process.platform !== "win32") {
59
+ process.kill(-child.pid, signal);
60
+ return true;
61
+ }
62
+ return child.kill(signal);
63
+ }
64
+ catch {
65
+ try {
66
+ return child.kill(signal);
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ }
73
+ async function runCommandInternal(options) {
74
+ assertOptions(options);
75
+ if (options.signal?.aborted)
76
+ throw new EvalProcessError("cancelled");
77
+ return new Promise((resolvePromise, rejectPromise) => {
78
+ const scope = terminationScope();
79
+ const graceMs = options.kill_grace_ms ?? DEFAULT_KILL_GRACE_MS;
80
+ const captureStdout = options.stdout_mode === "raw";
81
+ const chunks = [];
82
+ let stdoutBytes = 0;
83
+ let terminalCode = null;
84
+ let cleanup = "closed";
85
+ let settled = false;
86
+ let closed = false;
87
+ let termSent = false;
88
+ let killSent = false;
89
+ let exitCode = null;
90
+ let exitSignal = null;
91
+ let graceTimer;
92
+ let closeTimer;
93
+ const child = spawn(options.command.program, options.command.args, {
94
+ cwd: options.cwd,
95
+ env: options.env,
96
+ shell: false,
97
+ detached: process.platform !== "win32",
98
+ windowsHide: true,
99
+ stdio: [options.stdin_document ? "pipe" : "ignore", captureStdout ? "pipe" : "ignore", "inherit"],
100
+ });
101
+ const clearTimers = () => {
102
+ clearTimeout(timeoutTimer);
103
+ if (graceTimer)
104
+ clearTimeout(graceTimer);
105
+ if (closeTimer)
106
+ clearTimeout(closeTimer);
107
+ options.signal?.removeEventListener("abort", onAbort);
108
+ };
109
+ const finish = () => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ clearTimers();
114
+ if (terminalCode) {
115
+ rejectPromise(new EvalProcessError(terminalCode, {
116
+ exit_code: exitCode,
117
+ signal: exitSignal,
118
+ cleanup,
119
+ termination_scope: scope,
120
+ }));
121
+ return;
122
+ }
123
+ let stdout;
124
+ if (captureStdout) {
125
+ try {
126
+ stdout = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, stdoutBytes));
127
+ }
128
+ catch {
129
+ rejectPromise(new EvalProcessError("invalid_utf8", {
130
+ exit_code: exitCode,
131
+ signal: exitSignal,
132
+ cleanup,
133
+ termination_scope: scope,
134
+ }));
135
+ return;
136
+ }
137
+ }
138
+ resolvePromise({
139
+ exit_code: exitCode,
140
+ signal: exitSignal,
141
+ cleanup,
142
+ termination_scope: scope,
143
+ ...(stdout === undefined ? {} : { stdout }),
144
+ });
145
+ };
146
+ const forceKill = () => {
147
+ if (closed || killSent)
148
+ return;
149
+ killSent = true;
150
+ cleanup = signalChild(child, "SIGKILL") ? "killed" : "failed";
151
+ closeTimer = setTimeout(() => {
152
+ if (closed)
153
+ return;
154
+ cleanup = "failed";
155
+ terminalCode = terminalCode ?? "cleanup_failed";
156
+ finish();
157
+ }, Math.max(graceMs, 100));
158
+ closeTimer.unref();
159
+ };
160
+ const terminate = (code) => {
161
+ if (terminalCode !== null || settled)
162
+ return;
163
+ terminalCode = code;
164
+ child.stdout?.destroy();
165
+ child.stdin?.destroy();
166
+ if (!termSent) {
167
+ termSent = true;
168
+ cleanup = signalChild(child, "SIGTERM") ? "terminated" : "failed";
169
+ }
170
+ if (closed) {
171
+ finish();
172
+ return;
173
+ }
174
+ graceTimer = setTimeout(forceKill, graceMs);
175
+ graceTimer.unref();
176
+ };
177
+ const onAbort = () => terminate("cancelled");
178
+ options.signal?.addEventListener("abort", onAbort, { once: true });
179
+ const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeout_ms);
180
+ timeoutTimer.unref();
181
+ child.once("error", () => {
182
+ if (closed || settled)
183
+ return;
184
+ terminalCode = terminalCode ?? "spawn_error";
185
+ cleanup = "failed";
186
+ finish();
187
+ });
188
+ child.stdout?.on("data", (value) => {
189
+ if (terminalCode !== null)
190
+ return;
191
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
192
+ stdoutBytes += chunk.byteLength;
193
+ if (stdoutBytes > options.max_stdout_bytes) {
194
+ chunks.length = 0;
195
+ terminate("stdout_overflow");
196
+ return;
197
+ }
198
+ chunks.push(Buffer.from(chunk));
199
+ });
200
+ child.once("close", (code, signal) => {
201
+ if (settled)
202
+ return;
203
+ closed = true;
204
+ exitCode = code;
205
+ exitSignal = signal;
206
+ if (!terminalCode)
207
+ cleanup = "closed";
208
+ finish();
209
+ });
210
+ if (options.stdin_document) {
211
+ child.stdin?.once("error", () => terminate("stdin_error"));
212
+ child.stdin?.end(options.stdin_document);
213
+ }
214
+ });
215
+ }
216
+ export async function runBoundedCommand(options) {
217
+ return runCommandInternal(options);
218
+ }
219
+ function hasTrailingJsonDocument(value) {
220
+ const document = value.trimStart();
221
+ if (document[0] !== "{" && document[0] !== "[")
222
+ return false;
223
+ let depth = 0;
224
+ let quoted = false;
225
+ let escaped = false;
226
+ for (let index = 0; index < document.length; index += 1) {
227
+ const character = document[index];
228
+ if (quoted) {
229
+ if (escaped)
230
+ escaped = false;
231
+ else if (character === "\\")
232
+ escaped = true;
233
+ else if (character === '"')
234
+ quoted = false;
235
+ continue;
236
+ }
237
+ if (character === '"')
238
+ quoted = true;
239
+ else if (character === "{" || character === "[")
240
+ depth += 1;
241
+ else if (character === "}" || character === "]") {
242
+ depth -= 1;
243
+ if (depth === 0)
244
+ return document.slice(index + 1).trim().length > 0;
245
+ }
246
+ }
247
+ return false;
248
+ }
249
+ export async function runBoundedJsonAdapter(options) {
250
+ const request = evalAdapterRequestSchema.safeParse(options.request);
251
+ if (!request.success)
252
+ throw new EvalProcessError("invalid_request");
253
+ const commandResult = await runCommandInternal({
254
+ ...options,
255
+ stdout_mode: "raw",
256
+ stdin_document: Buffer.from(JSON.stringify(request.data), "utf8"),
257
+ });
258
+ if (commandResult.exit_code !== 0 || commandResult.signal !== null || commandResult.cleanup !== "closed") {
259
+ throw new EvalProcessError("adapter_error", commandResult);
260
+ }
261
+ let parsed;
262
+ try {
263
+ parsed = JSON.parse(commandResult.stdout ?? "");
264
+ }
265
+ catch {
266
+ throw new EvalProcessError(hasTrailingJsonDocument(commandResult.stdout ?? "") ? "multiple_json" : "invalid_json", commandResult);
267
+ }
268
+ const result = evalAdapterResultSchema.safeParse(parsed);
269
+ if (!result.success)
270
+ throw new EvalProcessError("invalid_result", commandResult);
271
+ const { stdout: _stdout, ...processResult } = commandResult;
272
+ return { ...processResult, result: result.data };
273
+ }