@cairnkeep/cli 2.17.3 → 2.19.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 (47) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/docs/harness-compatibility.md +48 -0
  4. package/docs/learning/CURRICULUM-MAP.md +2 -1
  5. package/docs/learning/FEATURE-GUIDE.md +2 -1
  6. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  7. package/docs/learning/lessons/L01-safe-trial.md +1 -1
  8. package/docs/learning/lessons/L02-installation.md +1 -1
  9. package/docs/learning/lessons/L03-first-project.md +1 -1
  10. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  11. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  12. package/docs/learning/lessons/L21-context-packs.md +1 -1
  13. package/docs/learning/lessons/L22-native-windows.md +1 -1
  14. package/docs/learning/lessons/L23-guided-setup.md +18 -7
  15. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  16. package/docs/learning/lessons/L25-playbooks.md +1 -1
  17. package/docs/operating.md +13 -6
  18. package/examples/eval/bundled-fake.json +1 -1
  19. package/mcp-memory-server/dist/artifact-schema.js +1 -1
  20. package/mcp-memory-server/dist/pi-bridge-child.js +109 -0
  21. package/mcp-memory-server/dist/pi-mcp-bridge.js +15 -3
  22. package/mcp-memory-server/dist/work-evidence-schema.js +1 -1
  23. package/mcp-memory-server/dist/work-evidence-store.js +1 -1
  24. package/mcp-memory-server/package.json +5 -3
  25. package/omp/extensions/cairnkeep-capture.ts +226 -0
  26. package/omp/extensions/cairnkeep-memory.ts +288 -0
  27. package/package.json +77 -76
  28. package/schemas/artifact.schema.json +1 -1
  29. package/schemas/cairnkeep-setup-policy.schema.json +3 -3
  30. package/schemas/cairnkeep-setup.schema.json +1 -1
  31. package/scripts/cairn-cli.mjs +2 -0
  32. package/scripts/completion.sh +8 -3
  33. package/scripts/harness-registry.mjs +15 -3
  34. package/scripts/lib/omp-capture-stage.mjs +103 -0
  35. package/scripts/setup.mjs +10 -5
  36. package/scripts/sync-omp-assets.sh +96 -0
  37. package/scripts/test-bootstrap-untracked.sh +1 -1
  38. package/scripts/test-cli-dispatch.sh +2 -0
  39. package/scripts/test-completion.sh +4 -2
  40. package/scripts/test-harness-registry.sh +28 -1
  41. package/scripts/test-setup-output.sh +20 -1
  42. package/scripts/test-setup-preflight.mjs +1 -1
  43. package/scripts/test-work-evidence-cli.sh +1 -1
  44. package/scripts/uninstall.sh +1 -0
  45. package/scripts/verify-omp-capture.mjs +370 -0
  46. package/scripts/verify-omp-mcp-bridge.mjs +333 -0
  47. package/templates/start-omp.sh.template +47 -0
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env bun
2
+ // Smoke verification for the oh-my-pi (omp) Cairnkeep memory extension.
3
+ //
4
+ // Renders omp/extensions/cairnkeep-memory.ts into a sandbox agent directory,
5
+ // loads it under Bun against a mock ExtensionAPI, and drives the real MCP
6
+ // bridge against a supervised `cairn memory-server` child. Asserts the
7
+ // registered tool surface matches the trusted MCP catalog, a real read call
8
+ // normalizes to the omp AgentToolResult shape, cancellation works, shutdown
9
+ // leaves no orphaned server, and a name collision refuses startup.
10
+ //
11
+ // Requires Bun (the omp runtime). Usage:
12
+ // bun scripts/verify-omp-mcp-bridge.mjs
13
+ // Exit 0 with {"status":"PASS"} on success; exit 1 with {"status":"FAIL"}.
14
+ // Missing Bun or an unbuilt memory server reports {"status":"SKIP"}.
15
+
16
+ import assert from "node:assert/strict";
17
+ import {
18
+ chmodSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readFileSync,
23
+ rmSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { dirname, join, resolve } from "node:path";
28
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
+
30
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
31
+ const serverRoot = join(root, "mcp-memory-server");
32
+ const serverEntry = join(serverRoot, "dist", "index.js");
33
+ const bridgeEntry = join(serverRoot, "dist", "pi-mcp-bridge.js");
34
+ const extensionTemplate = join(root, "omp", "extensions", "cairnkeep-memory.ts");
35
+ const ORPHAN_TIMEOUT_MS = 3_000;
36
+ const CALL_TIMEOUT_MS = 30_000;
37
+
38
+ function output(record) {
39
+ process.stdout.write(`${JSON.stringify(record)}\n`);
40
+ }
41
+
42
+ function fail(reason) {
43
+ throw Object.assign(new Error(reason), { code: reason });
44
+ }
45
+
46
+ function isGate(error) {
47
+ return error instanceof Error && typeof error.code === "string";
48
+ }
49
+
50
+ function cleanEnvironment(extra = {}) {
51
+ const env = { ...process.env };
52
+ for (const name of [
53
+ "CAIRN_MCP_TOOL_PROFILE",
54
+ "CAIRN_MCP_ALLOWED_TOOLS",
55
+ "CAIRN_TYPED_MEMORY_NODES",
56
+ "CAIRN_ARTIFACT_STORE",
57
+ "CAIRN_ARTIFACT_HTTP",
58
+ "CAIRN_CONTEXT_PACKS",
59
+ "CAIRN_CONTEXT_PACK_HTTP",
60
+ "CAIRN_CAPABILITY_CONTRACT",
61
+ "MCP_HTTP_PORT",
62
+ "CAIRN_OMP_SMOKE_PIDS",
63
+ ]) delete env[name];
64
+ return { ...env, ...extra };
65
+ }
66
+
67
+ function pidAlive(pid) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch (error) {
72
+ return error?.code !== "ESRCH";
73
+ }
74
+ }
75
+
76
+ async function requireNoOrphans(pidFile) {
77
+ const pids = existsSync(pidFile)
78
+ ? readFileSync(pidFile, "utf8").split(/\s+/).filter(Boolean).map(Number).filter(Number.isSafeInteger)
79
+ : [];
80
+ if (pids.length === 0) fail("omp-child-evidence-missing");
81
+ const deadline = Date.now() + ORPHAN_TIMEOUT_MS;
82
+ while (Date.now() < deadline && pids.some(pidAlive)) await Bun.sleep(50);
83
+ const alive = pids.filter(pidAlive);
84
+ for (const pid of alive) {
85
+ try { process.kill(pid, "SIGKILL"); } catch {}
86
+ }
87
+ if (alive.length) fail("omp-memory-child-orphaned");
88
+ }
89
+
90
+ function writeCairnWrapper(sandbox) {
91
+ const bin = join(sandbox, "bin");
92
+ mkdirSync(bin, { recursive: true });
93
+ const wrapper = join(bin, "cairn");
94
+ writeFileSync(wrapper, `#!/usr/bin/env node
95
+ import { appendFileSync } from "node:fs";
96
+ import { pathToFileURL } from "node:url";
97
+ if (process.argv.length !== 3 || process.argv[2] !== "memory-server") process.exit(64);
98
+ appendFileSync(process.env.CAIRN_OMP_SMOKE_PIDS, String(process.pid) + "\\n");
99
+ await import(pathToFileURL(${JSON.stringify(serverEntry)}).href);
100
+ `);
101
+ chmodSync(wrapper, 0o755);
102
+ return bin;
103
+ }
104
+
105
+ // Metadata-only mock of the omp ExtensionAPI: registration captures full
106
+ // definitions, while getAllTools() exposes identity/schema without execute,
107
+ // mirroring omp's ToolInfo contract.
108
+ function createMockPi(existingNames = []) {
109
+ const handlers = new Map();
110
+ const definitions = new Map();
111
+ const pi = {
112
+ registerTool(tool) {
113
+ definitions.set(tool.name, tool);
114
+ },
115
+ getAllTools() {
116
+ const registered = Array.from(definitions.values(), ({ name, label, description, parameters }) => ({
117
+ name,
118
+ label,
119
+ description,
120
+ parameters,
121
+ }));
122
+ return [
123
+ ...existingNames.map((name) => ({ name, label: name, description: name, parameters: { type: "object" } })),
124
+ ...registered,
125
+ ];
126
+ },
127
+ on(event, handler) {
128
+ if (!handlers.has(event)) handlers.set(event, []);
129
+ handlers.get(event).push(handler);
130
+ },
131
+ };
132
+ const fire = async (event, eventPayload, ctx) => {
133
+ for (const handler of handlers.get(event) ?? []) await handler(eventPayload, ctx);
134
+ };
135
+ return { pi, definitions, fire };
136
+ }
137
+
138
+ async function trustedCatalog(env, cwd) {
139
+ const module = await import(pathToFileURL(bridgeEntry).href);
140
+ const bridge = await module.connectCairnPiBridge({
141
+ command: process.execPath,
142
+ args: [serverEntry],
143
+ cwd,
144
+ env,
145
+ startupTimeoutMs: 10_000,
146
+ });
147
+ try {
148
+ return await bridge.listAllTools();
149
+ } finally {
150
+ await bridge.close();
151
+ }
152
+ }
153
+
154
+ async function main() {
155
+ if (!process.versions.bun) {
156
+ output({ schema_version: 1, status: "SKIP", reason: "bun-runtime-required" });
157
+ return;
158
+ }
159
+ if (!existsSync(serverEntry) || !existsSync(bridgeEntry)) fail("server-build-missing");
160
+ if (!existsSync(extensionTemplate)) fail("extension-template-missing");
161
+
162
+ const sandbox = mkdtempSync(join(tmpdir(), "cairn-omp-bridge-"));
163
+ // The extension forwards `{ ...process.env }` to its bridge child, so the
164
+ // sandbox environment must be live on this process while it runs.
165
+ const savedEnv = new Map();
166
+ const restoreEnv = () => {
167
+ for (const [name, value] of savedEnv) {
168
+ if (value === undefined) delete process.env[name];
169
+ else process.env[name] = value;
170
+ }
171
+ };
172
+ try {
173
+ const pidFile = join(sandbox, "children.txt");
174
+ const childPidFile = join(sandbox, "bridge-children.txt");
175
+ const env = cleanEnvironment({
176
+ CAIRN_AGENTFS_BASE_DIR: join(sandbox, "memory"),
177
+ CAIRN_MCP_TOOL_PROFILE: "full",
178
+ CAIRN_EXPLORE_BINARY: join(sandbox, "delayed-explore.mjs"),
179
+ CAIRN_EXPLORE_CACHE: "0",
180
+ CAIRN_OMP_SMOKE_PIDS: pidFile,
181
+ CAIRN_BRIDGE_CHILD_PID_LOG: childPidFile,
182
+ PATH: `${writeCairnWrapper(sandbox)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
183
+ });
184
+ writeFileSync(
185
+ join(sandbox, "delayed-explore.mjs"),
186
+ "#!/usr/bin/env node\nsetTimeout(() => process.stdout.write(JSON.stringify({citations:[],stats:{turns:0,tool_calls:0}})), 10000);\n",
187
+ );
188
+ const cwd = join(sandbox, "project");
189
+ mkdirSync(cwd, { recursive: true });
190
+
191
+ const expected = await trustedCatalog(env, cwd);
192
+
193
+ for (const [name, value] of Object.entries(env)) {
194
+ savedEnv.set(name, process.env[name]);
195
+ process.env[name] = value;
196
+ }
197
+
198
+ // Render and load the extension exactly as sync-omp-assets.sh installs it.
199
+ const renderedExtension = join(sandbox, "cairnkeep-memory.ts");
200
+ writeFileSync(
201
+ renderedExtension,
202
+ readFileSync(extensionTemplate, "utf8").replaceAll("@@INFRA_ROOT@@", root.replaceAll("\\", "/")),
203
+ );
204
+ const factoryModule = await import(pathToFileURL(renderedExtension).href);
205
+ if (typeof factoryModule.default !== "function") fail("extension-factory-missing");
206
+
207
+ const { pi, definitions, fire } = createMockPi();
208
+ factoryModule.default(pi);
209
+ const ctx = { cwd, shutdown() {} };
210
+ await fire("session_start", { type: "session_start" }, ctx);
211
+
212
+ const registered = Array.from(definitions.values());
213
+ assert.deepEqual(
214
+ registered.map(({ name }) => name),
215
+ expected.map(({ name }) => name),
216
+ "omp registered tool order drifted from the MCP catalog",
217
+ );
218
+ for (let index = 0; index < expected.length; index += 1) {
219
+ assert.equal(registered[index].description, expected[index].description, `${expected[index].name} description drifted`);
220
+ assert.deepEqual(registered[index].parameters, expected[index].inputSchema, `${expected[index].name} input schema drifted`);
221
+ assert.equal(typeof registered[index].execute, "function", `${expected[index].name} missing execute`);
222
+ assert.equal(registered[index].strict, false, `${expected[index].name} should opt out of strict grammar`);
223
+ }
224
+ const visibleRegistered = pi.getAllTools().filter(({ name }) => definitions.has(name));
225
+ assert.equal(visibleRegistered.length, expected.length, "omp public tool surface incomplete");
226
+ for (const tool of visibleRegistered) {
227
+ assert.equal("execute" in tool, false, "omp public metadata exposed execute");
228
+ }
229
+
230
+ const read = definitions.get("memory_list");
231
+ if (!read) fail("omp-read-tool-missing");
232
+ const noSignal = undefined;
233
+ const first = await read.execute("smoke-read-1", { scope: "project" }, noSignal, undefined, ctx);
234
+ assert.ok(Array.isArray(first.content) && first.content.length > 0, "omp read returned no content");
235
+ for (const block of first.content) {
236
+ if (block.type === "text") assert.equal(typeof block.text, "string");
237
+ else if (block.type === "image") {
238
+ assert.equal(typeof block.data, "string");
239
+ assert.equal(typeof block.mimeType, "string");
240
+ } else fail("omp-content-shape-invalid");
241
+ }
242
+ assert.equal(first.details?.tool?.name, "memory_list", "omp details lost trusted tool metadata");
243
+ assert.equal(first.details?.isError, false, "omp details misreport failure state");
244
+
245
+ const delayed = definitions.get("context_explore");
246
+ if (!delayed) fail("omp-explore-tool-missing");
247
+ const controller = new AbortController();
248
+ const pending = delayed.execute(
249
+ "smoke-cancel",
250
+ { query: "synthetic cancellation probe", repo_root: cwd, timeout_seconds: 1 },
251
+ controller.signal,
252
+ undefined,
253
+ ctx,
254
+ );
255
+ setTimeout(() => controller.abort(new Error("cancelled")), 50);
256
+ let cancellation = false;
257
+ try { await pending; } catch { cancellation = true; }
258
+ assert.ok(cancellation, "omp cancellation probe was not interrupted");
259
+
260
+ const after = await read.execute("smoke-read-2", { scope: "project" }, noSignal, undefined, ctx);
261
+ assert.ok(Array.isArray(after.content), "omp session unusable after cancellation");
262
+
263
+ await fire("session_shutdown", { type: "session_shutdown" }, ctx);
264
+ await requireNoOrphans(pidFile);
265
+ await requireNoOrphans(childPidFile);
266
+
267
+ // A colliding pre-existing tool name must refuse startup and close the bridge.
268
+ const colliding = createMockPi([expected[0].name]);
269
+ factoryModule.default(colliding.pi);
270
+ let collisionRefused = false;
271
+ try {
272
+ await colliding.fire("session_start", { type: "session_start" }, ctx);
273
+ } catch (error) {
274
+ collisionRefused = /collision/.test(error instanceof Error ? error.message : String(error));
275
+ }
276
+ assert.ok(collisionRefused, "omp extension did not refuse a tool-name collision");
277
+ await colliding.fire("session_shutdown", { type: "session_shutdown" }, ctx);
278
+ await requireNoOrphans(pidFile);
279
+ await requireNoOrphans(childPidFile);
280
+
281
+ // An uninitialized host runtime (omp ephemeral side turns) must be a no-op.
282
+ const pidsBefore = existsSync(pidFile) ? readFileSync(pidFile, "utf8").split(/\s+/).filter(Boolean).length : 0;
283
+ const childPidsBefore = existsSync(childPidFile) ? readFileSync(childPidFile, "utf8").split(/\s+/).filter(Boolean).length : 0;
284
+ const uninitialized = createMockPi();
285
+ const throwingPi = new Proxy(uninitialized.pi, {
286
+ get(target, property, receiver) {
287
+ if (property === "getAllTools") {
288
+ return () => {
289
+ throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading.");
290
+ };
291
+ }
292
+ return Reflect.get(target, property, receiver);
293
+ },
294
+ });
295
+ factoryModule.default(throwingPi);
296
+ await uninitialized.fire("session_start", { type: "session_start" }, ctx);
297
+ assert.equal(uninitialized.definitions.size, 0, "omp extension registered tools on an uninitialized runtime");
298
+ await uninitialized.fire("session_shutdown", { type: "session_shutdown" }, ctx);
299
+ const pidsAfter = existsSync(pidFile) ? readFileSync(pidFile, "utf8").split(/\s+/).filter(Boolean).length : 0;
300
+ assert.equal(pidsAfter, pidsBefore, "omp extension spawned a server for an uninitialized runtime");
301
+ const childPidsAfter = existsSync(childPidFile) ? readFileSync(childPidFile, "utf8").split(/\s+/).filter(Boolean).length : 0;
302
+ assert.equal(childPidsAfter, childPidsBefore, "omp extension spawned a bridge child for an uninitialized runtime");
303
+
304
+ output({
305
+ schema_version: 1,
306
+ status: "PASS",
307
+ checks: [
308
+ "extension-load",
309
+ "catalog-parity",
310
+ "public-metadata-shape",
311
+ "agent-tool-result-shape",
312
+ "trusted-details",
313
+ "cancellation",
314
+ "shutdown",
315
+ "orphan-free",
316
+ "bridge-child-orphan-free",
317
+ "collision-refusal",
318
+ "uninitialized-runtime-noop",
319
+ ],
320
+ tool_count: expected.length,
321
+ });
322
+ } finally {
323
+ restoreEnv();
324
+ rmSync(sandbox, { recursive: true, force: true });
325
+ }
326
+ }
327
+
328
+ try {
329
+ await main();
330
+ } catch (error) {
331
+ output({ schema_version: 1, status: "FAIL", reason: isGate(error) ? error.code : "omp-smoke-failed" });
332
+ process.exitCode = 1;
333
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # Cairnkeep launcher for oh-my-pi (OMP). Loads this repo's private .ai/.env,
3
+ # runs the optional pre/post seams, and launches OMP from the project root.
4
+ set -euo pipefail
5
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
6
+
7
+ if [[ -f "$ROOT_DIR/.ai/.env" ]]; then
8
+ set -a
9
+ # shellcheck disable=SC1091
10
+ . "$ROOT_DIR/.ai/.env"
11
+ set +a
12
+ fi
13
+ if [[ -f "$ROOT_DIR/.ai/pre-launch.sh" ]]; then
14
+ # shellcheck disable=SC1091
15
+ . "$ROOT_DIR/.ai/pre-launch.sh"
16
+ fi
17
+
18
+ cd "$ROOT_DIR"
19
+
20
+ case "${CAIRN_WORK_EVIDENCE:-}" in
21
+ 1|true|TRUE|yes|YES|on|ON)
22
+ if command -v cairn >/dev/null 2>&1 && cairn evidence --help >/dev/null 2>&1; then
23
+ set +e
24
+ cairn evidence run --harness omp -- omp "$@"
25
+ CAIRN_EXIT_STATUS=$?
26
+ set -e
27
+ export CAIRN_EXIT_STATUS
28
+ if [[ -f "$ROOT_DIR/.ai/post-exit.sh" ]]; then
29
+ # shellcheck disable=SC1091
30
+ . "$ROOT_DIR/.ai/post-exit.sh" || true
31
+ fi
32
+ exit "$CAIRN_EXIT_STATUS"
33
+ fi
34
+ echo "cairn: compatible evidence command unavailable; launching without capture" >&2
35
+ ;;
36
+ esac
37
+ if [[ -f "$ROOT_DIR/.ai/post-exit.sh" ]]; then
38
+ set +e
39
+ omp "$@"
40
+ CAIRN_EXIT_STATUS=$?
41
+ set -e
42
+ export CAIRN_EXIT_STATUS
43
+ # shellcheck disable=SC1091
44
+ . "$ROOT_DIR/.ai/post-exit.sh" || true
45
+ exit "$CAIRN_EXIT_STATUS"
46
+ fi
47
+ exec omp "$@"