@cairnkeep/cli 2.17.2 → 2.18.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 (31) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/docs/harness-compatibility.md +47 -0
  3. package/docs/learning/CURRICULUM-MAP.md +2 -1
  4. package/docs/learning/FEATURE-GUIDE.md +1 -1
  5. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  6. package/docs/learning/lessons/L01-safe-trial.md +1 -1
  7. package/docs/learning/lessons/L02-installation.md +1 -1
  8. package/docs/learning/lessons/L03-first-project.md +1 -1
  9. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  10. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  11. package/docs/learning/lessons/L21-context-packs.md +1 -1
  12. package/docs/learning/lessons/L22-native-windows.md +1 -1
  13. package/docs/learning/lessons/L23-guided-setup.md +1 -1
  14. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  15. package/docs/learning/lessons/L25-playbooks.md +1 -1
  16. package/docs/operating.md +2 -0
  17. package/examples/eval/bundled-fake.json +1 -1
  18. package/mcp-memory-server/dist/pi-bridge-child.js +109 -0
  19. package/mcp-memory-server/dist/pi-mcp-bridge.js +15 -3
  20. package/mcp-memory-server/package.json +5 -3
  21. package/omp/extensions/cairnkeep-capture.ts +226 -0
  22. package/omp/extensions/cairnkeep-memory.ts +288 -0
  23. package/package.json +77 -76
  24. package/scripts/cairn-cli.mjs +2 -0
  25. package/scripts/completion.sh +8 -3
  26. package/scripts/lib/omp-capture-stage.mjs +103 -0
  27. package/scripts/sync-omp-assets.sh +96 -0
  28. package/scripts/test-cli-dispatch.sh +2 -0
  29. package/scripts/test-completion.sh +3 -1
  30. package/scripts/verify-omp-capture.mjs +370 -0
  31. package/scripts/verify-omp-mcp-bridge.mjs +333 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable user-facing changes are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.18.0] - 2026-09-28
9
+
10
+ ### Added
11
+
12
+ - oh-my-pi (omp) integration: `omp/extensions/cairnkeep-memory.ts` registers
13
+ the cairn-memory MCP tools as native omp tools (collision-guarded), and
14
+ `omp/extensions/cairnkeep-capture.ts` stages durable-memory candidates at
15
+ session stop and surfaces them on the next session start, with a
16
+ `/cairn-staged` review command. The bridge runs as a node child process so
17
+ it also works under compiled Bun standalone binaries.
18
+ - `cairn sync-omp` command, bash/zsh/fish completion entries, and
19
+ `verify-omp-mcp-bridge.mjs` / `verify-omp-capture.mjs` verification gates.
20
+ - Smoke gates for the pi-mcp-bridge child environment: Bun node-shim PATH
21
+ stripping, `MCP_HTTP_PORT` isolation, and the node child bridge protocol.
22
+
23
+ ### Fixed
24
+
25
+ - `pi-mcp-bridge` strips Bun `/tmp/bun-node-*` shim dirs from the stdio child
26
+ environment; grandchildren like `cairn memory-server` spawn a real node.
27
+ - Work-evidence smoke used fixed calendar dates that aged past the retention
28
+ window; timestamps are relative to the run.
29
+
30
+ ### Documentation
31
+
32
+ - `docs/harness-compatibility.md`: oh-my-pi (omp) section.
33
+ - `docs/operating.md`: `CAIRN_PI_BRIDGE_COMMAND` / `CAIRN_PI_BRIDGE_ARGS`.
34
+
35
+ ## [2.17.3] - 2026-09-03
36
+
37
+ ### Security
38
+
39
+ - Refresh the production dependency locks to `fast-uri` 3.1.7 and `qs`
40
+ 6.16.0 so published server and workspace containers do not retain the URI
41
+ parsing, SSRF, host-confusion, or denial-of-service advisories affecting the
42
+ versions locked by 2.17.2. Runtime APIs and command behavior are unchanged.
43
+
8
44
  ## [2.17.2] - 2026-09-02
9
45
 
10
46
  ### Fixed
@@ -525,7 +561,8 @@ stored memories.
525
561
  - Preserve executable permissions and Bash 3.2 portability on macOS.
526
562
  - Add backup-first uninstall and SQLite-safe memory export/import guidance.
527
563
 
528
- [Unreleased]: https://github.com/cairnkeep/cairnkeep/compare/v2.15.1...HEAD
564
+ [Unreleased]: https://github.com/cairnkeep/cairnkeep/compare/v2.18.0...HEAD
565
+ [2.18.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.17.3...v2.18.0
529
566
  [2.15.1]: https://github.com/cairnkeep/cairnkeep/compare/v2.15.0...v2.15.1
530
567
  [2.15.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.14.0...v2.15.0
531
568
  [2.14.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.13.1...v2.14.0
@@ -19,6 +19,7 @@ Cairnkeep commands, hooks or native trajectory capture.
19
19
  | Kimi Code | Memory MCP, `AGENTS.md`, launcher, opt-in graph and cairn-work Skills | Launcher tested; remote MCP tested with Kimi Code 0.30.0; Skills contract-tested |
20
20
  | Qwen Code | Memory MCP, launcher, project `AGENTS.md` durable-context and playbook guidance | Launcher tested; stdio and remote MCP tested with Qwen Code 0.21.1 |
21
21
  | Pi | Memory MCP through maintained local stdio extension, native opt-in trajectory extension, launcher, graph and cairn-work prompts | Pi 0.84.1 validated minimum; deterministic and real bridge/lifecycle tests |
22
+ | oh-my-pi (omp) | Memory MCP through maintained local stdio extension (Pi-successor API) | Smoke-tested against the local omp checkout with the deterministic bridge verification |
22
23
  | Codex CLI | Project-scoped memory MCP, launcher, and project `AGENTS.md` durable-context and playbook guidance | Setup and launcher contract-tested on POSIX and simulated native Windows; project trust remains operator-controlled |
23
24
  | Other MCP clients | Memory plus optional domain-knowledge and context-pack tools | Protocol-compatible; not automatically runtime-tested |
24
25
 
@@ -77,6 +78,52 @@ release, together with Node.js 22/24/26, Bash 3.2, and native Windows. The
77
78
  executable paths were distinct; both reported 0.84.1 because that was also the
78
79
  registry-current release.
79
80
 
81
+ ## oh-my-pi (omp)
82
+
83
+ Run `cairn sync-omp --apply` explicitly to install
84
+ `extensions/cairnkeep-memory.ts` and `extensions/cairnkeep-capture.ts` under
85
+ the omp agent root (default
86
+ `~/.omp/agent`; `PI_CODING_AGENT_DIR` overrides it, matching omp's agent
87
+ directory resolution). Use `--check` for drift. Uninstall removes only that
88
+ owned path, backup-first.
89
+
90
+ The extension is the Pi memory extension ported to omp's successor extension
91
+ API: it supervises `cairn memory-server` as a local stdio child through the
92
+ same bridge, discovers the effective MCP catalog dynamically, and registers
93
+ each tool natively with omp under its bare MCP name. omp accepts the MCP input
94
+ schema as a plain JSON Schema document and normalizes results to its
95
+ `AgentToolResult` shape, preserving the trusted call metadata in `details`. A
96
+ `cairn-memory` entry in the omp MCP config coexists without collision:
97
+ config-served MCP tools are minted with an `mcp__` name prefix, so the two
98
+ surfaces never claim the same tool name. Server feature gates, capability
99
+ state, and least-authority profiles continue to decide which tools exist.
100
+
101
+ Startup, calls, results, stderr retention, cancellation, and shutdown are
102
+ bounded. The extension adds tools only: it does not run prompts, activate
103
+ context-pack skills, create an autonomous loop, or add a remote transport.
104
+ `bun scripts/verify-omp-mcp-bridge.mjs` renders, loads, and exercises the
105
+ installed extension against a supervised memory server, including catalog
106
+ parity, the result shape, cancellation, shutdown, orphan-freedom, and
107
+ tool-name collision refusal.
108
+
109
+ Sync also installs `extensions/cairnkeep-capture.ts`, the omp port of the
110
+ Claude SessionEnd/SessionStart `memory-capture` + `memory-wakeup` hook pair.
111
+ On the first `session_stop` of a main session (the event never fires for
112
+ task/subagent sessions), it converts the persisted branch to transcript text
113
+ (user/assistant text only, capped at 12000 characters keeping the most recent
114
+ turns) and pipes it to a detached staging child that runs the shared
115
+ extraction and writes candidates to `.planning/memory-staging/` — one file
116
+ per session, retention capped at 5 — so staging completes even if the agent
117
+ process exits first. Candidates are staged for review only: writes to AgentFS
118
+ stay agent-gated via the `memory_write` tool. On `session_start` of the main
119
+ session, staged candidates are surfaced through a UI notification and a
120
+ `deliverAs: "nextTurn"` context message injected at the next user turn; the
121
+ `/cairn-staged` command lists the staged files on demand. Both halves are
122
+ fail-open and skip ephemeral side-turn runtimes whose extension actions are
123
+ not initialized. `bun scripts/verify-omp-capture.mjs` exercises the guards,
124
+ transcript shaping, staging contract, retention cap, session de-dupe, wakeup
125
+ injection, and the skip paths.
126
+
80
127
  ## Kimi Code
81
128
 
82
129
  Kimi Code reads three MCP files, in increasing precedence:
@@ -1,6 +1,6 @@
1
1
  # Curriculum Coverage Map
2
2
 
3
- **Baseline:** Cairnkeep 2.17.2
3
+ **Baseline:** Cairnkeep 2.18.0
4
4
  **Last reviewed:** 2026-08-19
5
5
 
6
6
  This map prevents the course from drifting behind the product. It assigns each
@@ -91,6 +91,7 @@ against `cairn help` so a new command cannot bypass a curriculum decision.
91
91
  | `cairn sync` | L02 |
92
92
  | `cairn sync-pi` | L13 and L23 |
93
93
  | `cairn sync-kimi` | L02 |
94
+ | `cairn sync-omp` | L02 |
94
95
  | `cairn doctor` | L03; advanced repair in L13-L14 |
95
96
  | `cairn playbook` | L25 |
96
97
  | `cairn trajectory` | L13 |
@@ -1,6 +1,6 @@
1
1
  # Cairnkeep feature guide
2
2
 
3
- **Baseline:** Cairnkeep 2.17.2
3
+ **Baseline:** Cairnkeep 2.18.0
4
4
 
5
5
  This is the step-back map for choosing and operating Cairnkeep features. The
6
6
  [course labs](https://github.com/cairnkeep/cairnkeep-course-labs) provide one
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 10 minutes
6
- **Tested with:** Cairnkeep 2.17.2
6
+ **Tested with:** Cairnkeep 2.18.0
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 15 minutes
6
- **Tested with:** Cairnkeep 2.17.2
6
+ **Tested with:** Cairnkeep 2.18.0
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 25 minutes
6
- **Tested with:** Cairnkeep 2.17.2 and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.18.0 and Node.js 22 or newer
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart
5
5
  **Time:** 25 minutes
6
- **Tested with:** Cairnkeep 2.17.2
6
+ **Tested with:** Cairnkeep 2.18.0
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Evidence and Evaluation
5
5
  **Time:** 50 minutes
6
- **Tested with:** Cairnkeep 2.17.2
6
+ **Tested with:** Cairnkeep 2.18.0
7
7
 
8
8
  ## Outcome
9
9
 
@@ -1,7 +1,7 @@
1
1
  # L20 - Least-authority MCP tool profiles
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.17.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.18.0 and Node.js 22 or newer
5
5
  **Time:** 25 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L21 - Immutable context packs and skill approval
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.17.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.18.0 and Node.js 22 or newer
5
5
  **Time:** 40 minutes
6
6
 
7
7
  ## Outcome
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Operator
5
5
  **Time:** 30 minutes
6
- **Tested with:** Cairnkeep 2.17.2, Windows x64, and Node.js 22 or newer
6
+ **Tested with:** Cairnkeep 2.18.0, Windows x64, and Node.js 22 or newer
7
7
 
8
8
  ## Outcome
9
9
 
@@ -3,7 +3,7 @@
3
3
  **Status:** Ready
4
4
  **Track:** Quickstart and Operator
5
5
  **Time:** 35 minutes
6
- **Tested with:** Cairnkeep 2.17.2; Pi 0.84.1; Node.js 22, 24, and 26
6
+ **Tested with:** Cairnkeep 2.18.0; Pi 0.84.1; Node.js 22, 24, and 26
7
7
 
8
8
  ## Outcome
9
9
 
@@ -1,7 +1,7 @@
1
1
  # L24 - Reviewed knowledge exchange
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.17.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.18.0 and Node.js 22 or newer
5
5
  **Time:** 35 minutes
6
6
 
7
7
  ## Outcome
@@ -1,7 +1,7 @@
1
1
  # L25 - Bounded workflow playbooks
2
2
 
3
3
  **Status:** Ready
4
- **Tested with:** Cairnkeep 2.17.2 and Node.js 22 or newer
4
+ **Tested with:** Cairnkeep 2.18.0 and Node.js 22 or newer
5
5
  **Time:** 35 minutes
6
6
 
7
7
  ## Outcome
package/docs/operating.md CHANGED
@@ -461,6 +461,8 @@ vendor or host.
461
461
  | `CAIRN_LLM_API_KEY` | API key for the extraction / embeddings endpoint (unset → substring-only memory) |
462
462
  | `CAIRN_LLM_API_URL` | Base URL of the OpenAI-compatible endpoint |
463
463
  | `CAIRN_LLM_EXTRACTION_MODEL` | Chat model for `memory-capture` extraction |
464
+ | `CAIRN_PI_BRIDGE_COMMAND` | Memory-server command for the `pi-bridge-child` stdio host (default `cairn`) |
465
+ | `CAIRN_PI_BRIDGE_ARGS` | JSON argument array for the bridge child server command (default `["memory-server"]`) |
464
466
  | `CAIRN_MEMORY_EMBEDDING_URL` | Embeddings endpoint (falls back to `CAIRN_LLM_API_URL`) |
465
467
  | `CAIRN_MEMORY_EMBEDDING_MODEL` | Embedding model name (required for semantic search) |
466
468
  | `CAIRN_MEMORY_EMBEDDING_TIMEOUT_MS` | Embedding request timeout before substring fallback (default `15000`) |
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.17.2","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.18.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -0,0 +1,109 @@
1
+ import { childEnvironment, connectCairnPiBridge } from "./pi-mcp-bridge.js";
2
+ const STDERR_LIMIT_BYTES = 16 * 1024;
3
+ let stderrTail = "";
4
+ const stderrWrite = process.stderr.write.bind(process.stderr);
5
+ process.stderr.write = ((chunk, ...rest) => {
6
+ const text = typeof chunk === "string" ? chunk : String(chunk);
7
+ const next = stderrTail + text;
8
+ stderrTail = next.length <= STDERR_LIMIT_BYTES ? next : next.slice(next.length - STDERR_LIMIT_BYTES);
9
+ return stderrWrite(chunk, ...rest);
10
+ });
11
+ function send(message) {
12
+ process.stdout.write(`${JSON.stringify(message)}\n`);
13
+ }
14
+ function fatal(error) {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ process.stderr.write(`${JSON.stringify({ type: "error", error: message })}\n`);
17
+ process.exit(1);
18
+ }
19
+ process.on("uncaughtException", fatal);
20
+ process.on("unhandledRejection", (reason) => fatal(reason));
21
+ async function main() {
22
+ const cwd = process.argv[2] ?? process.cwd();
23
+ const command = process.env.CAIRN_PI_BRIDGE_COMMAND ?? "cairn";
24
+ const args = JSON.parse(process.env.CAIRN_PI_BRIDGE_ARGS ?? '["memory-server"]');
25
+ const bridge = await connectCairnPiBridge({ cwd, command, args, env: childEnvironment(process.env) });
26
+ const controllers = new Map();
27
+ let stdinBuffer = "";
28
+ let closing = false;
29
+ const close = async () => {
30
+ if (closing)
31
+ return;
32
+ closing = true;
33
+ await bridge.close().catch(() => undefined);
34
+ process.exit(0);
35
+ };
36
+ process.on("SIGTERM", () => void close());
37
+ process.on("SIGINT", () => void close());
38
+ process.stdin.on("end", () => void close());
39
+ async function handle(request) {
40
+ try {
41
+ switch (request.type) {
42
+ case "list": {
43
+ send({ type: "result", id: request.id, ok: true, tools: await bridge.listAllTools() });
44
+ break;
45
+ }
46
+ case "call": {
47
+ const controller = new AbortController();
48
+ controllers.set(request.id, controller);
49
+ try {
50
+ const result = await bridge.call(request.tool, request.args ?? {}, { signal: controller.signal });
51
+ send({ type: "result", id: request.id, ok: true, result });
52
+ }
53
+ catch (error) {
54
+ send({
55
+ type: "result",
56
+ id: request.id,
57
+ ok: false,
58
+ error: error instanceof Error ? error.message : String(error),
59
+ stderr: stderrTail,
60
+ });
61
+ }
62
+ finally {
63
+ controllers.delete(request.id);
64
+ }
65
+ break;
66
+ }
67
+ case "cancel": {
68
+ controllers.get(request.id)?.abort(new Error("cancelled"));
69
+ break;
70
+ }
71
+ case "close": {
72
+ await close();
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ catch (error) {
78
+ const id = typeof request.id === "number" ? request.id : -1;
79
+ send({
80
+ type: "result",
81
+ id,
82
+ ok: false,
83
+ error: error instanceof Error ? error.message : String(error),
84
+ stderr: stderrTail,
85
+ });
86
+ }
87
+ }
88
+ process.stdin.on("data", (chunk) => {
89
+ stdinBuffer += chunk.toString("utf8");
90
+ let newline = stdinBuffer.indexOf("\n");
91
+ while (newline >= 0) {
92
+ const line = stdinBuffer.slice(0, newline);
93
+ stdinBuffer = stdinBuffer.slice(newline + 1);
94
+ newline = stdinBuffer.indexOf("\n");
95
+ if (!line.trim())
96
+ continue;
97
+ let request;
98
+ try {
99
+ request = JSON.parse(line);
100
+ }
101
+ catch {
102
+ continue;
103
+ }
104
+ void handle(request);
105
+ }
106
+ });
107
+ send({ type: "ready" });
108
+ }
109
+ main().catch(fatal);
@@ -11,11 +11,23 @@ function positiveInteger(value, fallback, name) {
11
11
  throw new Error(`${name} must be a positive integer.`);
12
12
  return selected;
13
13
  }
14
- function childEnvironment(source) {
14
+ // Bun-hosted agents (pi, omp) prepend a /tmp/bun-node-* dir whose `node` is
15
+ // Bun itself; grandchildren like `cairn memory-server` spawn real node
16
+ // subprocesses, so those shim dirs must not shadow it.
17
+ const BUN_NODE_SHIM = /\/tmp\/bun-node-[^/:]+$/;
18
+ export function childEnvironment(source) {
15
19
  const env = {};
16
20
  for (const [name, value] of Object.entries(source)) {
17
- if (value !== undefined && name !== "MCP_HTTP_PORT")
18
- env[name] = value;
21
+ if (value === undefined || name === "MCP_HTTP_PORT")
22
+ continue;
23
+ if (name === "PATH") {
24
+ env[name] = value
25
+ .split(":")
26
+ .filter((entry) => !BUN_NODE_SHIM.test(entry))
27
+ .join(":");
28
+ continue;
29
+ }
30
+ env[name] = value;
19
31
  }
20
32
  return env;
21
33
  }
@@ -2,7 +2,7 @@
2
2
  "name": "@cairnkeep/memory-server",
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
- "description": "Durable, harness-agnostic memory MCP server for coding agents — the core of Cairnkeep.",
5
+ "description": "Durable, harness-agnostic memory MCP server for coding agents \u2014 the core of Cairnkeep.",
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
8
8
  "engines": {
@@ -61,10 +61,12 @@
61
61
  "check:playbook": "node scripts/smoke-playbook.mjs",
62
62
  "check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
63
63
  "test": "npm run test:smoke",
64
- "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:memory-proposals && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:context-pack && npm run check:context-pack-retrieval && npm run check:context-pack-v216-compat && npm run check:context-usage && npm run check:domain-retrieval && npm run check:retrieval-benchmark && npm run check:work-evidence && npm run check:okf && npm run check:playbook",
64
+ "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:memory-proposals && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:pi-bridge-env && npm run check:pi-bridge-child && npm run check:context-pack && npm run check:context-pack-retrieval && npm run check:context-pack-v216-compat && npm run check:context-usage && npm run check:domain-retrieval && npm run check:retrieval-benchmark && npm run check:work-evidence && npm run check:okf && npm run check:playbook",
65
65
  "test:search": "npm run build && npm run check:search",
66
66
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
67
- "test:all": "npm run test:memory"
67
+ "test:all": "npm run test:memory",
68
+ "check:pi-bridge-env": "node scripts/smoke-pi-bridge-env.mjs",
69
+ "check:pi-bridge-child": "node scripts/smoke-pi-bridge-child.mjs"
68
70
  },
69
71
  "dependencies": {
70
72
  "@modelcontextprotocol/sdk": "^1.30.0",
@@ -0,0 +1,226 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ // omp port of the claude/hooks memory-capture + memory-wakeup pair (D-03/D-04,
7
+ // OCP-01/OCP-05 contracts).
8
+ //
9
+ // CAPTURE: on the first `session_stop` of a main session (the event never
10
+ // fires for task/subagent sessions and is awaited while the process is fully
11
+ // alive), the session branch is converted to transcript text and piped to a
12
+ // DETACHED, unref'd `node scripts/lib/omp-capture-stage.mjs` child that runs
13
+ // the shared `extract` subcommand and stages candidates under
14
+ // .planning/memory-staging/ — so staging survives even if the agent process
15
+ // exits before the extraction call returns. Extraction candidates are staged
16
+ // for review only; writes to AgentFS stay agent-gated via the memory_* tools
17
+ // from cairnkeep-memory.ts.
18
+ //
19
+ // WAKEUP: on `session_start` of the main session (and only when the extension
20
+ // runtime is initialized — omp's title-generation ephemeral agents rebind
21
+ // extensions with uninitialized action stubs), staged candidates are surfaced
22
+ // via a UI notification and a `deliverAs: "nextTurn"` context message injected
23
+ // at the next real user turn. `/cairn-staged` lists staged files on demand.
24
+ //
25
+ // Fail-open everywhere: every guard is a cheap check and every path is
26
+ // try/caught so a throw can never reach the extension runner.
27
+ const CAIRN_ROOT = "@@INFRA_ROOT@@";
28
+ const SERVER_ENTRY = join(CAIRN_ROOT, "mcp-memory-server", "dist", "index.js");
29
+ const STAGE_ENTRY = join(CAIRN_ROOT, "scripts", "lib", "omp-capture-stage.mjs");
30
+
31
+ const MAX_TEXT_CHARS = 12000;
32
+ const STAGED_MAX_FILES = 5;
33
+ const STAGED_FILE_READ_CAP = 8192;
34
+ const STAGED_CUSTOM_TYPE = "sh.cairnkeep.staged-memory";
35
+
36
+ // Minimal structural view of omp's persisted SessionEntry (entry.type
37
+ // "message", camelCase role per docs/extensions.md); anything else is skipped.
38
+ type BranchEntry = {
39
+ type?: string;
40
+ message?: {
41
+ role?: string;
42
+ content?: unknown;
43
+ };
44
+ };
45
+
46
+ type SessionStopLike = {
47
+ type: "session_stop";
48
+ session_id?: string;
49
+ };
50
+
51
+ function transcriptText(branch: BranchEntry[]): string {
52
+ const turns: string[] = [];
53
+ for (const entry of branch) {
54
+ if (entry?.type !== "message") continue;
55
+ const role = entry.message?.role;
56
+ if (role !== "user" && role !== "assistant") continue;
57
+ if (!Array.isArray(entry.message?.content)) continue;
58
+ const text = (entry.message.content as Array<{ type?: string; text?: unknown }>)
59
+ .filter((block) => block && block.type === "text" && typeof block.text === "string")
60
+ .map((block) => block.text as string)
61
+ .join("\n")
62
+ .trim();
63
+ if (!text) continue;
64
+ turns.push(`${role === "user" ? "USER" : "ASSISTANT"}: ${text}`);
65
+ }
66
+ let out = turns.join("\n\n");
67
+ if (out.length > MAX_TEXT_CHARS) out = out.slice(-MAX_TEXT_CHARS);
68
+ return out;
69
+ }
70
+
71
+ // Same detection as cairnkeep-memory.ts: omp rebinds extensions to ephemeral
72
+ // side-turn runtimes whose action methods are uninitialized throwing stubs.
73
+ function runtimeUninitialized(pi: ExtensionAPI): boolean {
74
+ try {
75
+ pi.getAllTools();
76
+ return false;
77
+ } catch (error) {
78
+ if (error instanceof Error && error.message.includes("Extension runtime not initialized")) return true;
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ type StagedCandidate = {
84
+ file: string;
85
+ count: number;
86
+ };
87
+
88
+ // Bounded staged-candidate scan: the STAGED_MAX_FILES most recent *.json
89
+ // files, each read capped, malformed JSON skipped (fail-open).
90
+ function stagedCandidates(repo: string): StagedCandidate[] {
91
+ const stagingDir = join(repo, ".planning", "memory-staging");
92
+ if (!existsSync(stagingDir)) return [];
93
+ const files = readdirSync(stagingDir)
94
+ .filter((name) => name.endsWith(".json"))
95
+ .map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
96
+ .sort((a, b) => b.mtime - a.mtime)
97
+ .slice(0, STAGED_MAX_FILES)
98
+ .map(({ name }) => name);
99
+ const staged: StagedCandidate[] = [];
100
+ for (const file of files) {
101
+ try {
102
+ const path = join(stagingDir, file);
103
+ if (statSync(path).size > STAGED_FILE_READ_CAP) continue;
104
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
105
+ const count = Array.isArray((parsed as { candidates?: unknown })?.candidates)
106
+ ? ((parsed as { candidates: unknown[] }).candidates.length)
107
+ : 0;
108
+ if (count > 0) staged.push({ file, count });
109
+ } catch {
110
+ // Malformed staged file — skip it, never wedge session start.
111
+ }
112
+ }
113
+ return staged;
114
+ }
115
+
116
+ export default function cairnCaptureExtension(pi: ExtensionAPI): void {
117
+ const capturedSessions = new Set<string>();
118
+
119
+ pi.on("session_stop", async (event: SessionStopLike, ctx: ExtensionContext) => {
120
+ try {
121
+ if (ctx.agent?.kind !== "main") return;
122
+ const sessionId = typeof event?.session_id === "string" ? event.session_id : "";
123
+ // One capture attempt per session, no matter how many times
124
+ // session_stop fires for it (continuation cap).
125
+ if (!sessionId || capturedSessions.has(sessionId)) return;
126
+
127
+ const repo = ctx.cwd;
128
+ // Cheap guards first, mirroring the claude hook's ordering.
129
+ if (!existsSync(join(repo, ".agentfs", "project.db"))) return;
130
+ if (!existsSync(SERVER_ENTRY)) return;
131
+ if (!existsSync(STAGE_ENTRY)) return;
132
+ const apiKey = process.env.CAIRN_LLM_API_KEY;
133
+ const model = process.env.CAIRN_LLM_EXTRACTION_MODEL;
134
+ if (!apiKey || !model) return;
135
+
136
+ // Mark before the async boundary so a re-fire never double-stages.
137
+ capturedSessions.add(sessionId);
138
+
139
+ let text = "";
140
+ try {
141
+ text = transcriptText(ctx.sessionManager.getBranch() as BranchEntry[]);
142
+ } catch {
143
+ return;
144
+ }
145
+ if (!text) return;
146
+
147
+ // Detached + unref'd: the staging child outlives the agent process,
148
+ // so the result lands even if omp exits right after settle.
149
+ const child = spawn(
150
+ process.execPath,
151
+ [STAGE_ENTRY, repo, SERVER_ENTRY, model],
152
+ { detached: true, stdio: ["pipe", "ignore", "ignore"] },
153
+ );
154
+ child.on("error", () => undefined);
155
+ child.stdin.on("error", () => undefined);
156
+ child.stdin.write(text);
157
+ child.stdin.end();
158
+ child.unref();
159
+ } catch {
160
+ // Fail open — never block session settle because capture failed.
161
+ }
162
+ });
163
+
164
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
165
+ try {
166
+ if (ctx.agent?.kind !== "main") return;
167
+ if (runtimeUninitialized(pi)) return;
168
+ const staged = stagedCandidates(ctx.cwd);
169
+ if (staged.length === 0) return;
170
+
171
+ const notice =
172
+ `${staged.length} memory candidate file(s) staged for review in ` +
173
+ `.planning/memory-staging/ (${staged.reduce((total, { count }) => total + count, 0)} candidate(s) from ` +
174
+ "the previous session's transcript). Review them and write accepted ones with the memory_write tool " +
175
+ "(agent-gated), then remove or rename the staged file. Do not write to AgentFS without that review.";
176
+ if (ctx.hasUI) ctx.ui.notify(notice, "info");
177
+ pi.sendMessage(
178
+ {
179
+ customType: STAGED_CUSTOM_TYPE,
180
+ content: notice,
181
+ attribution: "agent",
182
+ display: false,
183
+ },
184
+ { deliverAs: "nextTurn" },
185
+ );
186
+ } catch {
187
+ // Fail open — never block session start because wakeup failed.
188
+ }
189
+ });
190
+
191
+ pi.registerCommand("cairn-staged", {
192
+ description: "List staged memory candidates awaiting review",
193
+ handler: async (_args, ctx: ExtensionContext) => {
194
+ try {
195
+ const stagingDir = join(ctx.cwd, ".planning", "memory-staging");
196
+ if (!existsSync(stagingDir)) {
197
+ if (ctx.hasUI) ctx.ui.notify("No staged memory candidates (.planning/memory-staging is absent).", "info");
198
+ return;
199
+ }
200
+ const files = readdirSync(stagingDir)
201
+ .filter((name) => name.endsWith(".json"))
202
+ .map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
203
+ .sort((a, b) => b.mtime - a.mtime)
204
+ .slice(0, STAGED_MAX_FILES)
205
+ .map(({ name }) => name);
206
+ if (files.length === 0) {
207
+ if (ctx.hasUI) ctx.ui.notify("No staged memory candidates.", "info");
208
+ return;
209
+ }
210
+ const sections = files.map((file) => {
211
+ let body: string;
212
+ try {
213
+ body = readFileSync(join(stagingDir, file), "utf8").slice(0, STAGED_FILE_READ_CAP);
214
+ } catch {
215
+ body = "(unreadable)";
216
+ }
217
+ return `### ${file}\n${body}`;
218
+ });
219
+ const listing = `Staged memory candidates — review, then accept via memory_write or discard.\n\n${sections.join("\n\n")}`;
220
+ if (ctx.hasUI) ctx.ui.notify(listing, "info");
221
+ } catch {
222
+ // Fail open — command inspection is best-effort.
223
+ }
224
+ },
225
+ });
226
+ }