@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
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat <<'EOF'
6
+ Usage: sync-omp-assets.sh [--check|--apply] [--live-root PATH]
7
+
8
+ Compare or sync the repo-managed oh-my-pi (omp) extension asset against the
9
+ live omp agent configuration tree.
10
+
11
+ Options:
12
+ --check Verify managed live assets match the rendered repo copies (default)
13
+ --apply Install managed assets, then verify
14
+ --live-root PATH Override the omp agent root (default: $PI_CODING_AGENT_DIR or ~/.omp/agent)
15
+ -h, --help Show this help text
16
+ EOF
17
+ }
18
+
19
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
20
+ SOURCE_ROOT="$ROOT_DIR/omp"
21
+ LIVE_ROOT="${PI_CODING_AGENT_DIR:-$HOME/.omp/agent}"
22
+ MODE="check"
23
+ ASSETS=(
24
+ "extensions/cairnkeep-memory.ts"
25
+ "extensions/cairnkeep-capture.ts"
26
+ )
27
+
28
+ while [[ $# -gt 0 ]]; do
29
+ case "$1" in
30
+ --check) MODE="check"; shift ;;
31
+ --apply) MODE="apply"; shift ;;
32
+ --live-root) LIVE_ROOT="$2"; shift 2 ;;
33
+ -h|--help) usage; exit 0 ;;
34
+ *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
35
+ esac
36
+ done
37
+
38
+ render_asset() {
39
+ local rel="$1"
40
+ if [[ "$rel" == extensions/*.ts ]]; then
41
+ sed "s|@@INFRA_ROOT@@|$ROOT_DIR|g" "$SOURCE_ROOT/$rel"
42
+ else
43
+ cat "$SOURCE_ROOT/$rel"
44
+ fi
45
+ }
46
+
47
+ ensure_sources() {
48
+ local rel
49
+ for rel in "${ASSETS[@]}"; do
50
+ [[ -f "$SOURCE_ROOT/$rel" ]] || {
51
+ echo "Missing repo-managed source asset: $SOURCE_ROOT/$rel" >&2
52
+ return 2
53
+ }
54
+ done
55
+ }
56
+
57
+ check_assets() {
58
+ local rel
59
+ local failed=0
60
+ for rel in "${ASSETS[@]}"; do
61
+ if [[ ! -f "$LIVE_ROOT/$rel" ]]; then
62
+ echo "Missing live omp asset under $LIVE_ROOT: $rel" >&2
63
+ failed=1
64
+ elif ! cmp -s <(render_asset "$rel") "$LIVE_ROOT/$rel"; then
65
+ echo "Out-of-sync live omp asset under $LIVE_ROOT: $rel" >&2
66
+ failed=1
67
+ fi
68
+ done
69
+ return "$failed"
70
+ }
71
+
72
+ ensure_sources
73
+
74
+ if [[ "$MODE" == "apply" ]]; then
75
+ updated=0
76
+ unchanged=0
77
+ for rel in "${ASSETS[@]}"; do
78
+ mkdir -p "$(dirname "$LIVE_ROOT/$rel")"
79
+ if [[ -f "$LIVE_ROOT/$rel" ]] && cmp -s <(render_asset "$rel") "$LIVE_ROOT/$rel"; then
80
+ unchanged=$((unchanged + 1))
81
+ continue
82
+ fi
83
+ rendered_tmp=$(mktemp)
84
+ render_asset "$rel" > "$rendered_tmp"
85
+ install -m 0644 "$rendered_tmp" "$LIVE_ROOT/$rel"
86
+ rm -f "$rendered_tmp"
87
+ updated=$((updated + 1))
88
+ done
89
+ printf 'Applied %s omp asset(s); %s already matched.\n' "$updated" "$unchanged"
90
+ fi
91
+
92
+ if ! check_assets; then
93
+ echo "Recovery: sync-omp-assets.sh --apply --live-root PATH" >&2
94
+ exit 1
95
+ fi
96
+ printf 'omp extensions are in sync under %s\n' "$LIVE_ROOT"
@@ -12,6 +12,7 @@ tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
12
12
  "$cairn" help | grep -q "cairn sync" || fail "help missing sync"
13
13
  "$cairn" help | grep -q "cairn sync-pi" || fail "help missing sync-pi"
14
14
  "$cairn" help | grep -q "cairn sync-kimi" || fail "help missing sync-kimi"
15
+ "$cairn" help | grep -q "cairn sync-omp" || fail "help missing sync-omp"
15
16
  "$cairn" help | grep -q "cairn memory-server" || fail "help missing memory-server"
16
17
  "$cairn" help | grep -q "cairn notes" || fail "help missing notes"
17
18
  "$cairn" help | grep -q "cairn artifact <list|show|delete|prune>" || fail "help missing artifact"
@@ -24,6 +25,7 @@ tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
24
25
  "$cairn" sync --help >/dev/null 2>&1 || fail "cairn sync dispatch"
25
26
  "$cairn" sync-pi --help >/dev/null 2>&1 || fail "cairn sync-pi dispatch"
26
27
  "$cairn" sync-kimi --help >/dev/null 2>&1 || fail "cairn sync-kimi dispatch"
28
+ "$cairn" sync-omp --help >/dev/null 2>&1 || fail "cairn sync-omp dispatch"
27
29
  env -u CAIRN_NOTE_DISTILLATION "$cairn" notes --help >/dev/null 2>&1 || fail "cairn notes dispatch"
28
30
  "$cairn" artifact --help | grep -q "cairn artifact list" || fail "cairn artifact dispatch"
29
31
  "$cairn" evidence --help | grep -q "cairn evidence list" || fail "cairn evidence dispatch"
@@ -109,12 +109,14 @@ for shell in bash zsh; do
109
109
  grep -q 'sync-pi' "$tmp/$shell"
110
110
  grep -q 'codex' "$tmp/$shell"
111
111
  grep -q 'sync-kimi' "$tmp/$shell"
112
+ grep -q 'sync-omp' "$tmp/$shell"
112
113
  grep -q -- '--live-root' "$tmp/$shell"
113
114
  done
114
115
  grep -q 'sync-pi' "$tmp/fish"
115
116
  grep -q 'codex' "$tmp/fish"
116
117
  grep -q 'sync-kimi' "$tmp/fish"
117
- grep -q 'not __fish_seen_subcommand_from .*sync-pi sync-kimi' "$tmp/fish"
118
+ grep -q 'sync-omp' "$tmp/fish"
119
+ grep -q 'not __fish_seen_subcommand_from .*sync-pi sync-kimi sync-omp' "$tmp/fish"
118
120
  grep -q -- '-l live-root' "$tmp/fish"
119
121
  for shell in bash zsh fish; do
120
122
  grep -q 'notes' "$tmp/$shell"
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env bun
2
+ // Smoke verification for the oh-my-pi (omp) cairnkeep-capture extension
3
+ // (memory-capture + memory-wakeup port).
4
+ //
5
+ // Renders omp/extensions/cairnkeep-capture.ts against a synthetic infra root
6
+ // (stub server `extract` subcommand + the real staging wrapper), loads it
7
+ // under Bun against a mock ExtensionAPI, and drives session_stop /
8
+ // session_start with mock branch data. Asserts guard behavior, transcript
9
+ // text shaping (12000-char cap keeping the most recent text, toolResult
10
+ // noise dropped), staging filename/JSON contract, retention cap 5, session
11
+ // de-dupe, wakeup notify + nextTurn context injection, uninitialized-runtime
12
+ // and subagent skips, and the /cairn-staged command surface.
13
+ //
14
+ // Requires Bun (the omp runtime). Usage:
15
+ // bun scripts/verify-omp-capture.mjs
16
+ // Exit 0 with {"status":"PASS"} on success; exit 1 with {"status":"FAIL"}.
17
+
18
+ import assert from "node:assert/strict";
19
+ import {
20
+ chmodSync,
21
+ copyFileSync,
22
+ existsSync,
23
+ mkdirSync,
24
+ mkdtempSync,
25
+ readdirSync,
26
+ readFileSync,
27
+ rmSync,
28
+ statSync,
29
+ utimesSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { dirname, join, resolve } from "node:path";
34
+ import { fileURLToPath, pathToFileURL } from "node:url";
35
+
36
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
37
+ const extensionTemplate = join(root, "omp", "extensions", "cairnkeep-capture.ts");
38
+ const stageWrapper = join(root, "scripts", "lib", "omp-capture-stage.mjs");
39
+ const STAGING_REL = join(".planning", "memory-staging");
40
+ const STUB_MODEL = "stub-model";
41
+ const STAGED_CUSTOM_TYPE = "sh.cairnkeep.staged-memory";
42
+
43
+ function output(record) {
44
+ process.stdout.write(`${JSON.stringify(record)}\n`);
45
+ }
46
+
47
+ function fail(reason) {
48
+ throw Object.assign(new Error(reason), { code: reason });
49
+ }
50
+
51
+ function isGate(error) {
52
+ return error instanceof Error && typeof error.code === "string";
53
+ }
54
+
55
+ // Stub server entry: same stdin -> stdout contract as
56
+ // `node dist/index.js extract <model>` with a fixed candidate set, and logs
57
+ // the received transcript text for assertion.
58
+ const STUB_SERVER = String.raw`
59
+ let input = "";
60
+ process.stdin.on("data", (chunk) => { input += chunk.toString("utf8"); });
61
+ process.stdin.on("end", () => {
62
+ if (process.env.CAIRN_STUB_STDIN_LOG) require("node:fs").writeFileSync(process.env.CAIRN_STUB_STDIN_LOG, input);
63
+ process.stdout.write(JSON.stringify({
64
+ model: process.argv[3] ?? "unknown",
65
+ count: 2,
66
+ candidates: [
67
+ { category: "decisions", key: "decisions/stub-one", value: "Stub candidate one." },
68
+ { category: "pitfalls", key: "pitfalls/stub-two", value: "Stub candidate two." },
69
+ ],
70
+ }));
71
+ });
72
+ `;
73
+
74
+ function writeFakeInfra(sandbox) {
75
+ const fakeRoot = join(sandbox, "infra");
76
+ mkdirSync(join(fakeRoot, "mcp-memory-server", "dist"), { recursive: true });
77
+ mkdirSync(join(fakeRoot, "scripts", "lib"), { recursive: true });
78
+ const stub = join(fakeRoot, "mcp-memory-server", "dist", "index.js");
79
+ writeFileSync(stub, STUB_SERVER);
80
+ chmodSync(stub, 0o755);
81
+ copyFileSync(stageWrapper, join(fakeRoot, "scripts", "lib", "omp-capture-stage.mjs"));
82
+ return fakeRoot;
83
+ }
84
+
85
+ function bootstrappedRepo(sandbox, name) {
86
+ const repo = join(sandbox, name);
87
+ mkdirSync(join(repo, ".agentfs"), { recursive: true });
88
+ writeFileSync(join(repo, ".agentfs", "project.db"), "");
89
+ return repo;
90
+ }
91
+
92
+ function message(role, text) {
93
+ return { type: "message", message: { role, content: [{ type: "text", text }] } };
94
+ }
95
+
96
+ function mockCtx({ cwd, kind, branch = [], uiLog, uninitialized = false }) {
97
+ return {
98
+ cwd,
99
+ hasUI: true,
100
+ agent: { kind },
101
+ sessionManager: { getBranch: () => branch },
102
+ ui: { notify: (text, type) => uiLog.push({ text, type }) },
103
+ shutdown() {},
104
+ ...(uninitialized ? {} : {}),
105
+ };
106
+ }
107
+
108
+ function createMockPi({ uninitialized = false } = {}) {
109
+ const handlers = new Map();
110
+ const commands = new Map();
111
+ const sentMessages = [];
112
+ const pi = {
113
+ registerCommand(name, options) {
114
+ commands.set(name, options);
115
+ },
116
+ sendMessage(payload, options) {
117
+ sentMessages.push({ payload, options });
118
+ },
119
+ getAllTools() {
120
+ if (uninitialized) {
121
+ throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading.");
122
+ }
123
+ return [];
124
+ },
125
+ on(event, handler) {
126
+ if (!handlers.has(event)) handlers.set(event, []);
127
+ handlers.get(event).push(handler);
128
+ },
129
+ };
130
+ const fire = async (event, payload, ctx) => {
131
+ for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
132
+ };
133
+ return { pi, commands, sentMessages, fire };
134
+ }
135
+
136
+ async function waitFor(condition, timeoutMs = 5000, label = "condition") {
137
+ const deadline = Date.now() + timeoutMs;
138
+ while (Date.now() < deadline) {
139
+ try {
140
+ const value = condition();
141
+ if (value) return value;
142
+ } catch {
143
+ // not yet
144
+ }
145
+ await Bun.sleep(50);
146
+ }
147
+ fail(`timeout-waiting-for-${label}`);
148
+ }
149
+
150
+ function stagedFiles(repo) {
151
+ const dir = join(repo, STAGING_REL);
152
+ return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".json")).sort() : [];
153
+ }
154
+
155
+ async function main() {
156
+ if (!process.versions.bun) {
157
+ output({ schema_version: 1, status: "SKIP", reason: "bun-runtime-required" });
158
+ return;
159
+ }
160
+ if (!existsSync(extensionTemplate) || !existsSync(stageWrapper)) fail("capture-assets-missing");
161
+
162
+ const sandbox = mkdtempSync(join(tmpdir(), "cairn-omp-capture-"));
163
+ const savedEnv = new Map();
164
+ const setEnv = (entries) => {
165
+ for (const [name, value] of Object.entries(entries)) {
166
+ if (!savedEnv.has(name)) savedEnv.set(name, process.env[name]);
167
+ if (value === undefined) delete process.env[name];
168
+ else process.env[name] = value;
169
+ }
170
+ };
171
+ const restoreEnv = () => {
172
+ for (const [name, value] of savedEnv) {
173
+ if (value === undefined) delete process.env[name];
174
+ else process.env[name] = value;
175
+ }
176
+ };
177
+
178
+ try {
179
+ const fakeRoot = writeFakeInfra(sandbox);
180
+ const renderedExtension = join(sandbox, "cairnkeep-capture.ts");
181
+ writeFileSync(
182
+ renderedExtension,
183
+ readFileSync(extensionTemplate, "utf8").replaceAll("@@INFRA_ROOT@@", fakeRoot.replaceAll("\\", "/")),
184
+ );
185
+ const factoryModule = await import(pathToFileURL(renderedExtension).href);
186
+ if (typeof factoryModule.default !== "function") fail("extension-factory-missing");
187
+
188
+ setEnv({ CAIRN_LLM_API_KEY: "stub-key", CAIRN_LLM_EXTRACTION_MODEL: STUB_MODEL });
189
+
190
+ // 1) Guard: no .agentfs/project.db -> no staging.
191
+ {
192
+ const bare = join(sandbox, "bare-repo");
193
+ mkdirSync(bare, { recursive: true });
194
+ const mock = createMockPi();
195
+ factoryModule.default(mock.pi);
196
+ const branch = [message("user", "guard probe")];
197
+ await mock.fire("session_stop", { type: "session_stop", session_id: "guard-1" }, mockCtx({ cwd: bare, kind: "main", branch, uiLog: [] }));
198
+ await Bun.sleep(300);
199
+ assert.equal(existsSync(join(bare, STAGING_REL)), false, "capture staged outside a bootstrapped repo");
200
+ }
201
+
202
+ // 2) Guard: missing extraction env -> no staging.
203
+ {
204
+ const repo = bootstrappedRepo(sandbox, "env-guard-repo");
205
+ const mock = createMockPi();
206
+ factoryModule.default(mock.pi);
207
+ setEnv({ CAIRN_LLM_API_KEY: undefined, CAIRN_LLM_EXTRACTION_MODEL: undefined });
208
+ await mock.fire("session_stop", { type: "session_stop", session_id: "guard-2" }, mockCtx({ cwd: repo, kind: "main", branch: [message("user", "env guard probe")], uiLog: [] }));
209
+ await Bun.sleep(300);
210
+ assert.equal(stagedFiles(repo).length, 0, "capture staged without extraction env");
211
+ setEnv({ CAIRN_LLM_API_KEY: "stub-key", CAIRN_LLM_EXTRACTION_MODEL: STUB_MODEL });
212
+ }
213
+
214
+ // 3) Happy path: staging contract + transcript shaping.
215
+ const stdinLog = join(sandbox, "stdin-log.txt");
216
+ setEnv({ CAIRN_STUB_STDIN_LOG: stdinLog });
217
+ const repo = bootstrappedRepo(sandbox, "repo");
218
+ const oldTurn = `OLD-TURN-MARKER ${"x".repeat(20000)}`;
219
+ const recentTurn = "RECENT-TURN-MARKER this is the latest user text";
220
+ const branch = [
221
+ message("user", oldTurn),
222
+ { type: "message", message: { role: "toolResult", content: [{ type: "text", text: "TOOLRESULT-NOISE" }] } },
223
+ {
224
+ type: "message",
225
+ message: {
226
+ role: "assistant",
227
+ content: [
228
+ { type: "toolCall", name: "bash", arguments: {} },
229
+ { type: "text", text: "assistant visible text" },
230
+ ],
231
+ },
232
+ },
233
+ message("user", recentTurn),
234
+ ];
235
+ let uiLog = [];
236
+ const mock = createMockPi();
237
+ factoryModule.default(mock.pi);
238
+ await mock.fire("session_stop", { type: "session_stop", session_id: "happy-1" }, mockCtx({ cwd: repo, kind: "main", branch, uiLog }));
239
+
240
+ const staged = await waitFor(() => {
241
+ const files = stagedFiles(repo);
242
+ return files.length === 1 ? files : undefined;
243
+ }, 8000, "staged-file");
244
+ assert.match(staged[0], /^\d{8}T\d{6}Z\.json$/, "staging filename drifted from the UTC contract");
245
+ const stagedJson = readFileSync(join(repo, STAGING_REL, staged[0]), "utf8");
246
+ const stagedParsed = JSON.parse(stagedJson);
247
+ assert.equal(stagedParsed.count, 2, "staged candidate JSON drifted");
248
+ assert.ok(Array.isArray(stagedParsed.candidates) && stagedParsed.candidates.length === 2, "staged candidates missing");
249
+ assert.ok(stagedJson.endsWith("\n"), "staged file missing trailing newline");
250
+
251
+ await waitFor(() => (existsSync(stdinLog) ? readFileSync(stdinLog, "utf8") : ""), 8000, "stdin-log");
252
+ const transcript = readFileSync(stdinLog, "utf8");
253
+ assert.ok(transcript.length <= 12000, "transcript text exceeded the 12000-char cap");
254
+ assert.ok(transcript.includes("RECENT-TURN-MARKER"), "transcript text lost the most recent turn");
255
+ assert.ok(!transcript.includes("OLD-TURN-MARKER"), "transcript text kept dropped old content past the cap");
256
+ assert.ok(!transcript.includes("TOOLRESULT-NOISE"), "transcript text leaked a toolResult entry");
257
+ assert.ok(!transcript.includes("bash") || !transcript.includes("toolCall"), "transcript text leaked tool-call blocks");
258
+ assert.ok(transcript.includes("USER:") && transcript.includes("ASSISTANT:"), "transcript text lost role prefixes");
259
+
260
+ // 4) De-dupe: same session id never stages twice.
261
+ await mock.fire("session_stop", { type: "session_stop", session_id: "happy-1" }, mockCtx({ cwd: repo, kind: "main", branch: [message("user", "second settle")], uiLog: [] }));
262
+ await Bun.sleep(300);
263
+ assert.equal(stagedFiles(repo).length, 1, "session_stop de-dupe by session id failed");
264
+
265
+ // 5) Retention cap 5: pre-seed 5 older files, new capture drops the oldest.
266
+ for (let index = 0; index < 5; index += 1) {
267
+ const name = `2026010${index}T00000${index}Z.json`;
268
+ writeFileSync(join(repo, STAGING_REL, name), JSON.stringify({ candidates: [] }));
269
+ const then = new Date(Date.now() - (10 - index) * 60_000);
270
+ utimesSync(join(repo, STAGING_REL, name), then, then);
271
+ }
272
+ const preCap = stagedFiles(repo);
273
+ assert.equal(preCap.length, 6, "pre-seed setup wrong");
274
+ await mock.fire("session_stop", { type: "session_stop", session_id: "cap-1" }, mockCtx({ cwd: repo, kind: "main", branch: [message("user", "cap probe")], uiLog: [] }));
275
+ await waitFor(() => (stagedFiles(repo).length === 5 ? true : undefined), 8000, "retention-cap");
276
+ assert.ok(!stagedFiles(repo).includes("20260100T000000Z.json"), "retention cap kept the oldest staged file");
277
+
278
+ // 6) Wakeup: notify + nextTurn context, malformed staged JSON skipped.
279
+ uiLog = [];
280
+ const wakeupMock = createMockPi();
281
+ factoryModule.default(wakeupMock.pi);
282
+ const stagedBefore = stagedFiles(repo);
283
+ const validCount = stagedBefore.filter((file) => {
284
+ try {
285
+ const parsed = JSON.parse(readFileSync(join(repo, STAGING_REL, file), "utf8"));
286
+ return Array.isArray(parsed.candidates) && parsed.candidates.length > 0;
287
+ } catch {
288
+ return false;
289
+ }
290
+ }).length;
291
+ writeFileSync(join(repo, STAGING_REL, "malformed.json"), "{not json");
292
+ await wakeupMock.fire("session_start", { type: "session_start" }, mockCtx({ cwd: repo, kind: "main", uiLog }));
293
+ assert.equal(uiLog.length, 1, "wakeup notify missing or duplicated");
294
+ assert.ok(uiLog[0].text.includes(`${validCount} memory candidate file(s)`), "wakeup notify count drifted");
295
+ assert.equal(wakeupMock.sentMessages.length, 1, "wakeup context message missing");
296
+ const sent = wakeupMock.sentMessages[0];
297
+ assert.equal(sent.options?.deliverAs, "nextTurn", "wakeup context must target the next user turn");
298
+ assert.equal(sent.payload?.customType, STAGED_CUSTOM_TYPE, "wakeup customType drifted");
299
+ assert.ok(String(sent.payload?.content).includes("memory_write"), "wakeup context lost the agent-gated write instruction");
300
+
301
+ // 7) Uninitialized runtime: wakeup skipped entirely.
302
+ const uninit = createMockPi({ uninitialized: true });
303
+ factoryModule.default(uninit.pi);
304
+ const uninitUi = [];
305
+ await uninit.fire("session_start", { type: "session_start" }, mockCtx({ cwd: repo, kind: "main", uiLog: uninitUi }));
306
+ assert.equal(uninitUi.length, 0, "wakeup fired on an uninitialized runtime");
307
+ assert.equal(uninit.sentMessages.length, 0, "wakeup sent context on an uninitialized runtime");
308
+
309
+ // 8) Subagent sessions: capture and wakeup both skip.
310
+ const sub = createMockPi();
311
+ factoryModule.default(sub.pi);
312
+ const subUi = [];
313
+ const beforeSub = stagedFiles(repo);
314
+ await sub.fire("session_stop", { type: "session_stop", session_id: "sub-1" }, mockCtx({ cwd: repo, kind: "sub", branch: [message("user", "sub probe")], uiLog: subUi }));
315
+ await sub.fire("session_start", { type: "session_start" }, mockCtx({ cwd: repo, kind: "sub", uiLog: subUi }));
316
+ await Bun.sleep(300);
317
+ assert.deepEqual(stagedFiles(repo), beforeSub, "subagent session staged a capture");
318
+ assert.equal(subUi.length, 0, "subagent session fired wakeup");
319
+ assert.equal(sub.sentMessages.length, 0, "subagent session sent wakeup context");
320
+
321
+ // 9) /cairn-staged command surface lists staged files.
322
+ const command = wakeupMock.commands.get("cairn-staged");
323
+ if (!command) fail("cairn-staged-command-missing");
324
+ const cmdUi = [];
325
+ await command.handler("", mockCtx({ cwd: repo, kind: "main", uiLog: cmdUi }));
326
+ assert.equal(cmdUi.length, 1, "cairn-staged produced no listing");
327
+ const listingDir = join(repo, STAGING_REL);
328
+ const newestFive = readdirSync(listingDir)
329
+ .filter((name) => name.endsWith(".json"))
330
+ .map((name) => ({ name, mtime: statSync(join(listingDir, name)).mtimeMs }))
331
+ .sort((a, b) => b.mtime - a.mtime)
332
+ .slice(0, 5)
333
+ .map(({ name }) => name);
334
+ for (const file of newestFive) {
335
+ assert.ok(cmdUi[0].text.includes(file), `cairn-staged listing omitted ${file}`);
336
+ }
337
+
338
+ output({
339
+ schema_version: 1,
340
+ status: "PASS",
341
+ checks: [
342
+ "guards-skip",
343
+ "env-guards-skip",
344
+ "staging-contract",
345
+ "transcript-cap-recent",
346
+ "transcript-noise-dropped",
347
+ "session-dedupe",
348
+ "retention-cap",
349
+ "wakeup-notify-and-nextturn",
350
+ "uninitialized-runtime-skip",
351
+ "subagent-skip",
352
+ "cairn-staged-command",
353
+ ],
354
+ });
355
+ } finally {
356
+ restoreEnv();
357
+ rmSync(sandbox, { recursive: true, force: true });
358
+ }
359
+ }
360
+
361
+ try {
362
+ await main();
363
+ } catch (error) {
364
+ if (error instanceof assert.AssertionError) {
365
+ output({ schema_version: 1, status: "FAIL", reason: `assertion: ${error.message}` });
366
+ } else {
367
+ output({ schema_version: 1, status: "FAIL", reason: isGate(error) ? error.code : "omp-capture-smoke-failed" });
368
+ }
369
+ process.exitCode = 1;
370
+ }