@cairnkeep/cli 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +232 -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 +4 -3
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/harness-compatibility.md +117 -0
  12. package/docs/learning/CURRICULUM-MAP.md +91 -0
  13. package/docs/learning/FEATURE-GUIDE.md +86 -0
  14. package/docs/learning/PRODUCTION-PLAN.md +45 -14
  15. package/docs/learning/README.md +20 -1
  16. package/docs/learning/instructor-guide.md +6 -0
  17. package/docs/learning/lessons/L00-why-cairnkeep.md +20 -8
  18. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  19. package/docs/learning/lessons/L02-installation.md +23 -5
  20. package/docs/learning/lessons/L03-first-project.md +7 -2
  21. package/docs/learning/lessons/L04-memory-fundamentals.md +4 -0
  22. package/docs/learning/lessons/L05-daily-workflow.md +4 -0
  23. package/docs/learning/lessons/L07-privacy-and-storage.md +3 -0
  24. package/docs/learning/lessons/L10-context-exploration.md +4 -2
  25. package/docs/learning/lessons/L12-managed-overlays.md +2 -0
  26. package/docs/learning/lessons/L13-session-evidence.md +71 -0
  27. package/docs/learning/lessons/L14-typed-memory.md +51 -0
  28. package/docs/learning/lessons/L15-capability-governance.md +56 -0
  29. package/docs/learning/lessons/L16-evaluation.md +67 -0
  30. package/docs/learning/lessons/L17-agent-boundary.md +55 -0
  31. package/docs/learning/templates/lesson-template.md +4 -1
  32. package/docs/learning/tracks/evidence-and-evaluation.md +20 -0
  33. package/docs/learning/tracks/operator.md +8 -2
  34. package/docs/learning/tracks/practitioner.md +6 -1
  35. package/docs/learning/tracks/quickstart.md +3 -0
  36. package/docs/learning/video-scripts/L00-why-cairnkeep.md +21 -17
  37. package/docs/learning/video-scripts/L02-installation.md +9 -2
  38. package/docs/learning/video-scripts/L03-first-project.md +3 -0
  39. package/docs/operating.md +567 -7
  40. package/docs/privacy-and-data-flow.md +417 -0
  41. package/docs/storage.md +252 -0
  42. package/examples/eval/README.md +42 -0
  43. package/examples/eval/adapter.json +12 -0
  44. package/examples/eval/bundled-fake.json +1 -0
  45. package/examples/eval/task-set.json +1 -0
  46. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  47. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  48. package/mcp-memory-server/dist/artifact-store.js +812 -0
  49. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  50. package/mcp-memory-server/dist/capability-cli.js +361 -0
  51. package/mcp-memory-server/dist/capability-config.js +310 -0
  52. package/mcp-memory-server/dist/capability-harness.js +496 -0
  53. package/mcp-memory-server/dist/capability-registry.js +123 -0
  54. package/mcp-memory-server/dist/capability-schema.js +65 -0
  55. package/mcp-memory-server/dist/capability-store.js +431 -0
  56. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  57. package/mcp-memory-server/dist/eval-cli.js +523 -0
  58. package/mcp-memory-server/dist/eval-plan.js +346 -0
  59. package/mcp-memory-server/dist/eval-process.js +273 -0
  60. package/mcp-memory-server/dist/eval-report.js +609 -0
  61. package/mcp-memory-server/dist/eval-runner.js +827 -0
  62. package/mcp-memory-server/dist/eval-schema.js +368 -0
  63. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  64. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  65. package/mcp-memory-server/dist/failure-signature.js +141 -0
  66. package/mcp-memory-server/dist/index.js +600 -55
  67. package/mcp-memory-server/dist/node-cli.js +84 -0
  68. package/mcp-memory-server/dist/node-schema.js +170 -0
  69. package/mcp-memory-server/dist/node-store.js +455 -0
  70. package/mcp-memory-server/dist/note-cli.js +235 -0
  71. package/mcp-memory-server/dist/note-distiller.js +177 -0
  72. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  73. package/mcp-memory-server/dist/note-schema.js +4 -0
  74. package/mcp-memory-server/dist/note-store.js +985 -0
  75. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  76. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  77. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  78. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  79. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  80. package/mcp-memory-server/package.json +24 -2
  81. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  82. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  83. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  84. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  85. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  86. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  87. package/opencode/command/graphify.md +25 -14
  88. package/opencode/plugins/memory-capture.ts +76 -10
  89. package/opencode/plugins/memory-wakeup.ts +58 -6
  90. package/package.json +8 -4
  91. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  92. package/schemas/artifact.schema.json +133 -0
  93. package/schemas/capability-callback.schema.json +68 -0
  94. package/schemas/capability-contract.schema.json +126 -0
  95. package/schemas/eval-adapter.schema.json +43 -0
  96. package/schemas/eval-protocol.schema.json +114 -0
  97. package/schemas/eval-report.schema.json +423 -0
  98. package/schemas/eval-task-set.schema.json +114 -0
  99. package/schemas/memory-node.schema.json +90 -0
  100. package/schemas/note.schema.json +112 -0
  101. package/schemas/trajectory-redaction.schema.json +25 -0
  102. package/schemas/trajectory.schema.json +82 -0
  103. package/scripts/bootstrap.sh +6 -0
  104. package/scripts/completion.sh +119 -5
  105. package/scripts/doctor.sh +284 -0
  106. package/scripts/fake-eval-adapter.mjs +120 -0
  107. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  108. package/scripts/install-audit-timer.sh +3 -1
  109. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  110. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  111. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  112. package/scripts/memory-wiki-audit.sh +49 -0
  113. package/scripts/smoke-bash32.sh +9 -0
  114. package/scripts/sync-claude-assets.sh +99 -6
  115. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  116. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  117. package/scripts/sync-opencode-security-assets.sh +24 -3
  118. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  119. package/scripts/sync-pi-assets.sh +72 -0
  120. package/scripts/test-audit-timer.sh +98 -0
  121. package/scripts/test-bootstrap-untracked.sh +49 -0
  122. package/scripts/test-cli-dispatch.sh +41 -2
  123. package/scripts/test-compaction-hooks.sh +495 -0
  124. package/scripts/test-completion.sh +77 -0
  125. package/scripts/test-doctor.sh +348 -1
  126. package/scripts/test-eval-cli.sh +175 -0
  127. package/scripts/test-launcher-hooks.sh +630 -3
  128. package/scripts/test-learning-docs.sh +20 -4
  129. package/scripts/test-notes-cli.sh +114 -0
  130. package/scripts/test-package-install.sh +254 -0
  131. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  132. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  133. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  134. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  135. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  136. package/scripts/test-trajectory-cli.sh +87 -0
  137. package/scripts/test-trajectory-hooks.sh +142 -0
  138. package/scripts/test-uninstall.sh +231 -5
  139. package/scripts/uninstall.sh +38 -5
  140. package/scripts/verify-dependency-security.mjs +121 -0
  141. package/scripts/verify-docs-parity.sh +527 -1
  142. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  143. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  144. package/templates/capabilities.json.template +7 -0
  145. package/templates/env.example.template +51 -0
  146. package/templates/memory-wiki-audit.service.template +1 -1
  147. package/templates/start-claude.sh.template +36 -0
  148. package/templates/start-kimi.sh.template +39 -0
  149. package/templates/start-opencode.sh.template +47 -5
  150. package/templates/start-pi.sh.template +30 -0
  151. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,609 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { canonicalDigest, canonicalJson, evalAggregateSchema, evalReportSchema, } from "./eval-schema.js";
6
+ import { computeDifferenceInDifferences, computePairedEstimate, } from "./eval-statistics.js";
7
+ const DEFAULT_MAX_REPORT_BYTES = 16 * 1024 * 1024;
8
+ const MAX_ALLOWED_REPORT_BYTES = 64 * 1024 * 1024;
9
+ const DEFAULT_MAX_EXPERIMENTS = 10_000;
10
+ const EXPERIMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
11
+ const FORBIDDEN_FIELDS = new Set([
12
+ "prompt",
13
+ "model_output",
14
+ "stdout",
15
+ "stderr",
16
+ "environment",
17
+ "env",
18
+ "error",
19
+ "error_message",
20
+ "request",
21
+ ]);
22
+ const FORBIDDEN_SENTINELS = [
23
+ "prompt-sentinel",
24
+ "model-output-sentinel",
25
+ "adapter-stderr-sentinel",
26
+ "environment-sentinel",
27
+ ];
28
+ const writeQueues = new Map();
29
+ function isContained(root, candidate) {
30
+ const rel = relative(root, candidate);
31
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
32
+ }
33
+ async function ensureSafeDirectory(path, privateMode = false) {
34
+ try {
35
+ const info = await lstat(path);
36
+ if (info.isSymbolicLink() || !info.isDirectory())
37
+ throw new Error("unsafe_report_directory");
38
+ if (privateMode)
39
+ await chmod(path, 0o700);
40
+ return;
41
+ }
42
+ catch (error) {
43
+ if (error.code !== "ENOENT")
44
+ throw error;
45
+ }
46
+ const parent = dirname(path);
47
+ if (parent === path)
48
+ throw new Error("unsafe_report_directory");
49
+ await ensureSafeDirectory(parent, false);
50
+ await mkdir(path, { mode: 0o700 });
51
+ await chmod(path, 0o700);
52
+ }
53
+ function assertLimits(maxReportBytes, maxExperiments) {
54
+ if (!Number.isSafeInteger(maxReportBytes) || maxReportBytes < 1 || maxReportBytes > MAX_ALLOWED_REPORT_BYTES) {
55
+ throw new Error("invalid_report_limit");
56
+ }
57
+ if (!Number.isSafeInteger(maxExperiments) || maxExperiments < 1 || maxExperiments > DEFAULT_MAX_EXPERIMENTS) {
58
+ throw new Error("invalid_experiment_limit");
59
+ }
60
+ }
61
+ async function countExperiments(root, target, limit) {
62
+ const entries = await readdir(root, { withFileTypes: true });
63
+ let count = 0;
64
+ for (const entry of entries) {
65
+ if (entry.name === basename(target))
66
+ continue;
67
+ if (entry.isSymbolicLink())
68
+ throw new Error("unsafe_report_directory");
69
+ if (entry.isDirectory())
70
+ count += 1;
71
+ if (count >= limit)
72
+ throw new Error("experiment_limit_reached");
73
+ }
74
+ }
75
+ function assertNoSensitiveFields(value) {
76
+ if (typeof value === "string") {
77
+ if (FORBIDDEN_SENTINELS.some((sentinel) => value.includes(sentinel))
78
+ || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value)
79
+ || /^Bearer\s+/i.test(value)) {
80
+ throw new Error("sensitive_report_value");
81
+ }
82
+ return;
83
+ }
84
+ if (Array.isArray(value)) {
85
+ value.forEach(assertNoSensitiveFields);
86
+ return;
87
+ }
88
+ if (!value || typeof value !== "object")
89
+ return;
90
+ for (const [key, child] of Object.entries(value)) {
91
+ if (FORBIDDEN_FIELDS.has(key))
92
+ throw new Error("sensitive_report_field");
93
+ assertNoSensitiveFields(child);
94
+ }
95
+ }
96
+ function validatedReport(value) {
97
+ assertNoSensitiveFields(value);
98
+ const parsed = evalReportSchema.safeParse(value);
99
+ if (!parsed.success)
100
+ throw new Error("invalid_eval_report");
101
+ return parsed.data;
102
+ }
103
+ async function readBoundedReport(store) {
104
+ let handle;
105
+ try {
106
+ handle = await open(store.report_path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
107
+ }
108
+ catch (error) {
109
+ if (error.code === "ENOENT")
110
+ return undefined;
111
+ throw error;
112
+ }
113
+ try {
114
+ const info = await handle.stat();
115
+ if (!info.isFile() || info.size > store.max_report_bytes)
116
+ throw new Error("unsafe_eval_report");
117
+ const bytes = await handle.readFile();
118
+ if (bytes.byteLength > store.max_report_bytes)
119
+ throw new Error("unsafe_eval_report");
120
+ return bytes;
121
+ }
122
+ finally {
123
+ await handle.close();
124
+ }
125
+ }
126
+ async function withWriteQueue(path, operation) {
127
+ const prior = writeQueues.get(path) ?? Promise.resolve();
128
+ let release = () => { };
129
+ const turn = new Promise((resolveTurn) => { release = resolveTurn; });
130
+ const tail = prior.then(() => turn);
131
+ writeQueues.set(path, tail);
132
+ await prior;
133
+ try {
134
+ return await operation();
135
+ }
136
+ finally {
137
+ release();
138
+ if (writeQueues.get(path) === tail)
139
+ writeQueues.delete(path);
140
+ }
141
+ }
142
+ function injectFault(options, stage) {
143
+ if (options.fault === stage)
144
+ throw new Error(`injected_${stage}`);
145
+ }
146
+ async function assertSafeStore(store) {
147
+ const root = await realpath(store.root_path);
148
+ const experiment = await realpath(store.experiment_path);
149
+ const info = await lstat(store.experiment_path);
150
+ if (info.isSymbolicLink() || !info.isDirectory()
151
+ || root !== store.root_path
152
+ || experiment !== store.experiment_path
153
+ || !isContained(root, experiment)
154
+ || dirname(store.report_path) !== experiment
155
+ || basename(store.report_path) !== "report.json") {
156
+ throw new Error("unsafe_report_store");
157
+ }
158
+ }
159
+ export async function createEvalReportStore(options) {
160
+ if (!EXPERIMENT_PATTERN.test(options.experiment_id))
161
+ throw new Error("invalid_experiment_id");
162
+ const maxReportBytes = options.max_report_bytes ?? DEFAULT_MAX_REPORT_BYTES;
163
+ const maxExperiments = options.max_experiments ?? DEFAULT_MAX_EXPERIMENTS;
164
+ assertLimits(maxReportBytes, maxExperiments);
165
+ const projectRoot = resolve(options.project_root ?? process.cwd());
166
+ const root = resolve(options.root ?? join(projectRoot, ".agentfs", "eval", "experiments"));
167
+ if (options.root === undefined && !isContained(projectRoot, root))
168
+ throw new Error("report_root_escape");
169
+ await ensureSafeDirectory(root, true);
170
+ const realRoot = await realpath(root);
171
+ const experimentPath = resolve(realRoot, options.experiment_id);
172
+ if (!isContained(realRoot, experimentPath) || experimentPath === realRoot)
173
+ throw new Error("report_path_escape");
174
+ await countExperiments(realRoot, experimentPath, maxExperiments);
175
+ await ensureSafeDirectory(experimentPath, true);
176
+ const realExperiment = await realpath(experimentPath);
177
+ if (!isContained(realRoot, realExperiment))
178
+ throw new Error("report_path_escape");
179
+ return {
180
+ root_path: realRoot,
181
+ experiment_path: realExperiment,
182
+ report_path: join(realExperiment, "report.json"),
183
+ experiment_id: options.experiment_id,
184
+ max_report_bytes: maxReportBytes,
185
+ };
186
+ }
187
+ export async function checkpointEvalReport(store, report, options = {}) {
188
+ await withWriteQueue(store.report_path, async () => {
189
+ await assertSafeStore(store);
190
+ refreshEvalReport(report);
191
+ const parsed = validatedReport(report);
192
+ if (parsed.experiment_id !== store.experiment_id)
193
+ throw new Error("report_experiment_mismatch");
194
+ const bytes = Buffer.from(`${canonicalJson(parsed)}\n`, "utf8");
195
+ if (bytes.byteLength > store.max_report_bytes)
196
+ throw new Error("report_size_limit");
197
+ const temporary = join(store.experiment_path, `.report.${process.pid}.${randomBytes(12).toString("hex")}.tmp`);
198
+ let handle;
199
+ try {
200
+ handle = await open(temporary, "wx", 0o600);
201
+ injectFault(options, "after_open");
202
+ await handle.writeFile(bytes);
203
+ injectFault(options, "after_write");
204
+ await handle.sync();
205
+ injectFault(options, "after_sync");
206
+ await handle.close();
207
+ handle = undefined;
208
+ injectFault(options, "after_close");
209
+ await rename(temporary, store.report_path);
210
+ await chmod(store.report_path, 0o600);
211
+ injectFault(options, "after_rename");
212
+ }
213
+ finally {
214
+ if (handle)
215
+ await handle.close().catch(() => undefined);
216
+ await rm(temporary, { force: true });
217
+ }
218
+ });
219
+ }
220
+ export async function readEvalReport(store) {
221
+ const bytes = await readBoundedReport(store);
222
+ if (!bytes)
223
+ return null;
224
+ let value;
225
+ try {
226
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
227
+ }
228
+ catch {
229
+ throw new Error("invalid_eval_report");
230
+ }
231
+ return validatedReport(value);
232
+ }
233
+ export async function diagnoseEvalReport(store) {
234
+ try {
235
+ const info = await lstat(store.report_path).catch((error) => {
236
+ if (error.code === "ENOENT")
237
+ return null;
238
+ throw error;
239
+ });
240
+ if (!info)
241
+ return { state: "absent", code: "report_absent" };
242
+ if (info.isSymbolicLink() || !info.isFile() || (info.mode & 0o077) !== 0) {
243
+ return { state: "unsafe", code: "report_unsafe" };
244
+ }
245
+ const report = await readEvalReport(store);
246
+ if (!report)
247
+ return { state: "absent", code: "report_absent" };
248
+ return report.status === "partial"
249
+ ? { state: "partial", code: "report_partial" }
250
+ : { state: "ok", code: "report_ok" };
251
+ }
252
+ catch (error) {
253
+ if (error instanceof Error && error.message === "unsafe_eval_report") {
254
+ return { state: "unsafe", code: "report_unsafe" };
255
+ }
256
+ return { state: "tampered", code: "report_invalid" };
257
+ }
258
+ }
259
+ function observationKey(observation) {
260
+ return `${observation.task_id}\0${observation.repetition}\0${observation.arm}\0${observation.pass}`;
261
+ }
262
+ function conditionKey(taskId, repetition, condition) {
263
+ return `${taskId}\0${repetition}\0${condition.arm}\0${condition.pass}`;
264
+ }
265
+ function metricValue(observation, metric) {
266
+ if (!observation || observation.state !== "terminal" || observation.capability_status !== "valid")
267
+ return null;
268
+ if (metric === "turns")
269
+ return observation.result?.turns?.value ?? null;
270
+ if (metric === "total_tokens")
271
+ return observation.result?.usage?.total_tokens ?? null;
272
+ if (observation.pass_state === "passed")
273
+ return 1;
274
+ if (observation.pass_state === "failed")
275
+ return 0;
276
+ return null;
277
+ }
278
+ function missingReason(observation, metric) {
279
+ if (!observation)
280
+ return "observation_missing";
281
+ if (observation.state !== "terminal")
282
+ return "observation_pending";
283
+ if (observation.capability_status !== "valid")
284
+ return `capability_${observation.capability_status}`;
285
+ if (metric === "turns")
286
+ return observation.result?.turns ? "turn_semantics_mismatch" : "turns_missing";
287
+ if (metric === "total_tokens")
288
+ return "total_tokens_missing";
289
+ return observation.verifier.reason ?? observation.missing_reasons.find((reason) => reason.includes("verifier")) ?? "verifier_unknown";
290
+ }
291
+ function relevantTaskIds(report) {
292
+ return [...new Set(report.schedule.map(({ task_id }) => task_id))];
293
+ }
294
+ function relevantRepetitions(report, taskId) {
295
+ return [...new Set(report.schedule.filter(({ task_id }) => task_id).map(({ repetition }) => repetition))];
296
+ }
297
+ function pairRows(report, observations, metric, reference, comparison) {
298
+ const rows = [];
299
+ let turnSemantics;
300
+ for (const taskId of relevantTaskIds(report)) {
301
+ for (const repetition of relevantRepetitions(report, taskId)) {
302
+ const left = observations.get(conditionKey(taskId, repetition, reference));
303
+ const right = observations.get(conditionKey(taskId, repetition, comparison));
304
+ let referenceValue = metricValue(left, metric);
305
+ let comparisonValue = metricValue(right, metric);
306
+ let reason;
307
+ if (metric === "turns" && referenceValue !== null && comparisonValue !== null
308
+ && left?.result?.turns?.semantics !== right?.result?.turns?.semantics) {
309
+ referenceValue = null;
310
+ comparisonValue = null;
311
+ reason = "turn_semantics_mismatch";
312
+ }
313
+ if (metric === "turns" && referenceValue !== null && comparisonValue !== null) {
314
+ const semantics = left?.result?.turns?.semantics;
315
+ turnSemantics ??= semantics;
316
+ if (semantics !== turnSemantics) {
317
+ referenceValue = null;
318
+ comparisonValue = null;
319
+ reason = "turn_semantics_mismatch";
320
+ }
321
+ }
322
+ if (referenceValue !== null && comparisonValue !== null && left?.seed !== right?.seed) {
323
+ referenceValue = null;
324
+ comparisonValue = null;
325
+ reason = "seed_mismatch";
326
+ }
327
+ if (referenceValue === null || comparisonValue === null) {
328
+ reason ??= referenceValue === null ? missingReason(left, metric) : missingReason(right, metric);
329
+ }
330
+ rows.push({
331
+ task_id: taskId,
332
+ repetition,
333
+ seed: left?.seed ?? right?.seed,
334
+ reference: referenceValue,
335
+ comparison: comparisonValue,
336
+ missing_reason: reason,
337
+ });
338
+ }
339
+ }
340
+ return rows;
341
+ }
342
+ function comparisonPopulation(report, observations, reference, comparison, paired) {
343
+ const taskIds = relevantTaskIds(report);
344
+ let executed = 0;
345
+ let eligible = 0;
346
+ let noteEligible = 0;
347
+ for (const taskId of taskIds) {
348
+ const repetitions = relevantRepetitions(report, taskId);
349
+ const conditionPairs = repetitions.map((repetition) => [
350
+ observations.get(conditionKey(taskId, repetition, reference)),
351
+ observations.get(conditionKey(taskId, repetition, comparison)),
352
+ ]);
353
+ if (conditionPairs.some((pair) => pair.every((row) => row?.state === "terminal")))
354
+ executed += 1;
355
+ if (conditionPairs.some((pair) => pair.every((row) => row?.state === "terminal" && row.capability_status === "valid"))) {
356
+ eligible += 1;
357
+ }
358
+ if (conditionPairs.some((pair) => pair
359
+ .filter((row) => row?.pass === "run2")
360
+ .every((row) => row?.notes.notes_exposed === true))) {
361
+ noteEligible += 1;
362
+ }
363
+ }
364
+ return { full: taskIds.length, executed, eligible, paired, note_eligible: noteEligible };
365
+ }
366
+ function semanticsForRows(rows, report, metric) {
367
+ if (metric === "pass_rate")
368
+ return "verified-pass-rate";
369
+ if (metric === "total_tokens")
370
+ return "reported-total-tokens";
371
+ const validTasks = new Set(rows.filter(({ reference, comparison }) => reference !== null && comparison !== null)
372
+ .map(({ task_id }) => task_id));
373
+ const semantics = new Set(report.observations
374
+ .filter(({ task_id }) => validTasks.has(task_id))
375
+ .flatMap(({ result }) => result?.turns?.semantics ? [result.turns.semantics] : []));
376
+ return semantics.size === 1 ? [...semantics][0] : null;
377
+ }
378
+ function comparisonConditions(comparisonId) {
379
+ if (comparisonId === "memory-baseline") {
380
+ return [{ arm: "baseline", pass: "run1" }, { arm: "baseline", pass: "run2" }];
381
+ }
382
+ if (comparisonId === "memory-treatment") {
383
+ return [{ arm: "treatment", pass: "run1" }, { arm: "treatment", pass: "run2" }];
384
+ }
385
+ if (comparisonId === "endpoint-treatment-minus-baseline") {
386
+ return [{ arm: "baseline", pass: "run2" }, { arm: "treatment", pass: "run2" }];
387
+ }
388
+ return [
389
+ { arm: "baseline", pass: "run1" },
390
+ { arm: "baseline", pass: "run2" },
391
+ { arm: "treatment", pass: "run1" },
392
+ { arm: "treatment", pass: "run2" },
393
+ ];
394
+ }
395
+ function buildConditionLevels(report, observations, comparisonId, metric, semantics) {
396
+ const taskIds = relevantTaskIds(report);
397
+ return comparisonConditions(comparisonId).map((condition) => {
398
+ const taskValues = [];
399
+ const missingReasons = [];
400
+ let executed = 0;
401
+ let eligible = 0;
402
+ for (const taskId of taskIds) {
403
+ const rows = relevantRepetitions(report, taskId)
404
+ .map((repetition) => observations.get(conditionKey(taskId, repetition, condition)));
405
+ if (rows.some((row) => row?.state === "terminal"))
406
+ executed += 1;
407
+ if (rows.some((row) => row?.state === "terminal" && row.capability_status === "valid"))
408
+ eligible += 1;
409
+ const values = rows.flatMap((row) => {
410
+ const value = metricValue(row, metric);
411
+ if (value === null)
412
+ return [];
413
+ if (metric === "turns" && (semantics === null || row?.result?.turns?.semantics !== semantics))
414
+ return [];
415
+ return [value];
416
+ });
417
+ if (values.length > 0) {
418
+ taskValues.push({ taskId, value: values.reduce((sum, value) => sum + value, 0) / values.length });
419
+ }
420
+ else {
421
+ const reason = rows.length === 0
422
+ ? "observation_missing"
423
+ : metric === "turns" && rows.some((row) => row?.result?.turns)
424
+ ? "turn_semantics_mismatch"
425
+ : missingReason(rows.find((row) => row !== undefined), metric);
426
+ missingReasons.push(reason);
427
+ }
428
+ }
429
+ return {
430
+ arm: condition.arm,
431
+ pass: condition.pass,
432
+ value: taskValues.length === 0
433
+ ? null
434
+ : taskValues.reduce((sum, row) => sum + row.value, 0) / taskValues.length,
435
+ valid_task_ids: taskValues.map(({ taskId }) => taskId),
436
+ valid_task_count: taskValues.length,
437
+ population: { full: taskIds.length, executed, eligible },
438
+ missing: { count: taskIds.length - taskValues.length, reasons: [...new Set(missingReasons)].sort() },
439
+ };
440
+ });
441
+ }
442
+ function asAggregate(report, comparison, metric, rows, estimate, observations) {
443
+ const semantics = semanticsForRows(rows, report, metric);
444
+ return evalAggregateSchema.parse({
445
+ comparison_id: comparison.id,
446
+ metric_id: metric,
447
+ direction: comparison.direction,
448
+ semantics,
449
+ currency: null,
450
+ estimate: estimate.estimate,
451
+ within_arm: {
452
+ baseline: comparison.kind === "within" && comparison.reference.arm === "baseline" ? estimate.estimate : null,
453
+ treatment: comparison.kind === "within" && comparison.reference.arm === "treatment" ? estimate.estimate : null,
454
+ },
455
+ endpoint_delta: comparison.kind === "endpoint" ? estimate.estimate : null,
456
+ difference_in_differences: null,
457
+ uncertainty: {
458
+ algorithm: estimate.algorithm,
459
+ version: estimate.version,
460
+ seed: estimate.seed,
461
+ stream: estimate.stream,
462
+ iterations: estimate.iterations,
463
+ confidence: estimate.confidence,
464
+ quantile: estimate.quantile,
465
+ interval: estimate.interval,
466
+ null_reason: estimate.null_reason,
467
+ },
468
+ warnings: estimate.warnings,
469
+ valid_task_ids: estimate.valid_task_ids,
470
+ valid_pair_count: estimate.valid_pair_count,
471
+ condition_levels: buildConditionLevels(report, observations, comparison.id, metric, semantics),
472
+ population: comparisonPopulation(report, observations, comparison.reference, comparison.comparison, estimate.valid_pair_count),
473
+ missing: { count: estimate.missing.count, reasons: estimate.missing.reasons.map(({ reason }) => reason) },
474
+ });
475
+ }
476
+ export function buildEvalAggregates(value) {
477
+ const report = validatedReport(value);
478
+ const observationMap = new Map(report.observations.map((observation) => [observationKey(observation), observation]));
479
+ const seed = canonicalDigest(report.schedule.map(({ seed }) => seed));
480
+ const arms = new Set(report.schedule.map(({ arm }) => arm));
481
+ const comparisons = [];
482
+ if (arms.has("baseline"))
483
+ comparisons.push({
484
+ id: "memory-baseline", direction: "run2-minus-run1",
485
+ reference: { arm: "baseline", pass: "run1" }, comparison: { arm: "baseline", pass: "run2" }, kind: "within",
486
+ });
487
+ if (arms.has("treatment"))
488
+ comparisons.push({
489
+ id: "memory-treatment", direction: "run2-minus-run1",
490
+ reference: { arm: "treatment", pass: "run1" }, comparison: { arm: "treatment", pass: "run2" }, kind: "within",
491
+ });
492
+ if (arms.has("baseline") && arms.has("treatment"))
493
+ comparisons.push({
494
+ id: "endpoint-treatment-minus-baseline", direction: "treatment-run2-minus-baseline-run2",
495
+ reference: { arm: "baseline", pass: "run2" }, comparison: { arm: "treatment", pass: "run2" }, kind: "endpoint",
496
+ });
497
+ const aggregates = [];
498
+ for (const comparison of comparisons) {
499
+ for (const metric of ["turns", "total_tokens", "pass_rate"]) {
500
+ const rows = pairRows(report, observationMap, metric, comparison.reference, comparison.comparison);
501
+ const estimate = computePairedEstimate(rows, {
502
+ seed, comparison_id: comparison.id, metric_id: metric,
503
+ });
504
+ aggregates.push(asAggregate(report, comparison, metric, rows, estimate, observationMap));
505
+ }
506
+ }
507
+ if (arms.has("baseline") && arms.has("treatment")) {
508
+ for (const metric of ["turns", "total_tokens", "pass_rate"]) {
509
+ const baseline = pairRows(report, observationMap, metric, { arm: "baseline", pass: "run1" }, { arm: "baseline", pass: "run2" });
510
+ const treatment = pairRows(report, observationMap, metric, { arm: "treatment", pass: "run1" }, { arm: "treatment", pass: "run2" });
511
+ const treatmentByTask = new Map(treatment.map((row) => [`${row.task_id}\0${row.repetition}`, row]));
512
+ const didRows = baseline.map((row) => {
513
+ const other = treatmentByTask.get(`${row.task_id}\0${row.repetition}`);
514
+ return {
515
+ task_id: row.task_id,
516
+ baseline_run1: row.reference,
517
+ baseline_run2: row.comparison,
518
+ treatment_run1: other?.reference ?? null,
519
+ treatment_run2: other?.comparison ?? null,
520
+ missing_reason: row.missing_reason ?? other?.missing_reason,
521
+ };
522
+ });
523
+ const estimate = computeDifferenceInDifferences(didRows, {
524
+ seed, comparison_id: "difference-in-differences", metric_id: metric,
525
+ });
526
+ const population = comparisonPopulation(report, observationMap, { arm: "baseline", pass: "run1" }, { arm: "treatment", pass: "run2" }, estimate.valid_pair_count);
527
+ const semantics = semanticsForRows([...baseline, ...treatment], report, metric);
528
+ aggregates.push(evalAggregateSchema.parse({
529
+ comparison_id: "difference-in-differences",
530
+ metric_id: metric,
531
+ direction: "treatment-run2-minus-run1-minus-baseline-run2-minus-run1",
532
+ semantics, currency: null,
533
+ estimate: estimate.estimate, within_arm: { baseline: null, treatment: null }, endpoint_delta: null,
534
+ difference_in_differences: estimate.estimate,
535
+ uncertainty: {
536
+ algorithm: estimate.algorithm, version: estimate.version, seed: estimate.seed, stream: estimate.stream,
537
+ iterations: estimate.iterations, confidence: estimate.confidence, quantile: estimate.quantile,
538
+ interval: estimate.interval, null_reason: estimate.null_reason,
539
+ },
540
+ warnings: estimate.warnings, valid_task_ids: estimate.valid_task_ids,
541
+ valid_pair_count: estimate.valid_pair_count,
542
+ condition_levels: buildConditionLevels(report, observationMap, "difference-in-differences", metric, semantics),
543
+ population,
544
+ missing: { count: estimate.missing.count, reasons: estimate.missing.reasons.map(({ reason }) => reason) },
545
+ }));
546
+ }
547
+ }
548
+ return aggregates;
549
+ }
550
+ function refreshEvalReport(report) {
551
+ report.aggregates = buildEvalAggregates(report);
552
+ const reasons = [...new Set([
553
+ ...report.observations.flatMap(({ missing_reasons }) => missing_reasons),
554
+ ...report.aggregates.flatMap(({ missing }) => missing.reasons),
555
+ ])].sort();
556
+ const count = report.observations.filter(({ missing_reasons }) => missing_reasons.length > 0).length
557
+ + report.aggregates.reduce((sum, { missing }) => sum + missing.count, 0);
558
+ report.missingness = { count, reasons, digest: canonicalDigest({ count, reasons }) };
559
+ report.evidence.missingness_digest = report.missingness.digest;
560
+ report.warnings = [...new Set([
561
+ ...report.aggregates.flatMap(({ warnings }) => warnings),
562
+ ...(report.evidence.evidence_scope === "offline-framework" ? ["framework_only"] : []),
563
+ ])].sort();
564
+ report.updated_at = new Date().toISOString();
565
+ }
566
+ function displayNumber(value) {
567
+ return value === null ? "missing" : Number.isInteger(value) ? String(value) : value.toPrecision(6).replace(/0+$/, "").replace(/\.$/, "");
568
+ }
569
+ function conclusion(aggregate) {
570
+ const interval = aggregate.uncertainty.interval;
571
+ return aggregate.warnings.includes("small_sample") || interval === null || (interval.low <= 0 && interval.high >= 0)
572
+ ? "inconclusive"
573
+ : "estimate";
574
+ }
575
+ export function renderEvalReport(value) {
576
+ const report = validatedReport(value);
577
+ const lines = [
578
+ `Cairn evaluation report: ${report.experiment_id}`,
579
+ `status: ${report.status}`,
580
+ `evidence: ${report.evidence.evidence_scope === "offline-framework" ? "framework-only" : "live-evaluation"}`,
581
+ "All effects are estimates; small samples, missing intervals, and intervals crossing zero are inconclusive.",
582
+ "",
583
+ "Aggregates",
584
+ ];
585
+ for (const aggregate of report.aggregates) {
586
+ const interval = aggregate.uncertainty.interval;
587
+ lines.push(`${aggregate.comparison_id} / ${aggregate.metric_id}`);
588
+ for (const level of aggregate.condition_levels) {
589
+ lines.push(` condition=${level.arm}/${level.pass} value=${displayNumber(level.value)} valid=${level.valid_task_count} tasks=${level.valid_task_ids.length > 0 ? level.valid_task_ids.join(",") : "none"} full=${level.population.full} executed=${level.population.executed} eligible=${level.population.eligible} missing=${level.missing.count} reasons=${level.missing.reasons.length > 0 ? level.missing.reasons.join(",") : "none"}`);
590
+ }
591
+ lines.push(` estimate=${displayNumber(aggregate.estimate)} interval=${interval ? `[${displayNumber(interval.low)}, ${displayNumber(interval.high)}]` : `none (${aggregate.uncertainty.null_reason ?? "unavailable"})`} conclusion=${conclusion(aggregate)}`);
592
+ lines.push(` full=${aggregate.population.full} executed=${aggregate.population.executed} eligible=${aggregate.population.eligible} paired=${aggregate.valid_pair_count} note-eligible=${aggregate.population.note_eligible}`);
593
+ lines.push(` missing=${aggregate.missing.count} reasons=${aggregate.missing.reasons.length > 0 ? aggregate.missing.reasons.join(",") : "none"}`);
594
+ lines.push(` warnings=${aggregate.warnings.length > 0 ? aggregate.warnings.join(",") : "none"}`);
595
+ }
596
+ lines.push("", "Per-task observations");
597
+ for (const observation of report.observations) {
598
+ lines.push(`${observation.task_id} arm=${observation.arm} pass=${observation.pass} repetition=${observation.repetition} terminal=${observation.terminal_state ?? "pending"} verified=${observation.pass_state} turns=${observation.result?.turns ? `${observation.result.turns.value}:${observation.result.turns.semantics}` : "missing"} total_tokens=${observation.result?.usage?.total_tokens ?? "missing"}`);
599
+ lines.push(` expected capability digest: ${observation.expected_capability_digest}`);
600
+ lines.push(` observed capability digest: ${observation.observed_capability_digest ?? "missing"}`);
601
+ lines.push(` notes=${observation.notes.eligibility_reason} exposed=${observation.notes.notes_exposed} missing=${observation.missing_reasons.length > 0 ? observation.missing_reasons.join(",") : "none"}`);
602
+ }
603
+ lines.push("", `report missingness: count=${report.missingness.count} reasons=${report.missingness.reasons.length > 0 ? report.missingness.reasons.join(",") : "none"}`);
604
+ lines.push(`report warnings: ${report.warnings.length > 0 ? report.warnings.join(",") : "none"}`);
605
+ if (report.evidence.evidence_scope === "offline-framework") {
606
+ lines.push("Offline fixture results are framework-only and do not authorize product claims.");
607
+ }
608
+ return `${lines.join("\n")}\n`;
609
+ }