@cairnkeep/cli 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +221 -6
  3. package/bin/cairn +43 -2
  4. package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
  5. package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
  6. package/claude/hooks/compaction-capture.sh +34 -0
  7. package/claude/hooks/memory-capture.sh +47 -5
  8. package/claude/hooks/memory-wakeup.sh +55 -2
  9. package/docs/building-an-overlay.md +1 -1
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +2 -2
  14. package/docs/learning/lessons/L03-first-project.md +1 -1
  15. package/docs/operating.md +531 -4
  16. package/docs/privacy-and-data-flow.md +417 -0
  17. package/docs/storage.md +252 -0
  18. package/examples/eval/README.md +42 -0
  19. package/examples/eval/adapter.json +12 -0
  20. package/examples/eval/bundled-fake.json +1 -0
  21. package/examples/eval/task-set.json +1 -0
  22. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  23. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  24. package/mcp-memory-server/dist/artifact-store.js +812 -0
  25. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  26. package/mcp-memory-server/dist/capability-cli.js +361 -0
  27. package/mcp-memory-server/dist/capability-config.js +310 -0
  28. package/mcp-memory-server/dist/capability-harness.js +496 -0
  29. package/mcp-memory-server/dist/capability-registry.js +123 -0
  30. package/mcp-memory-server/dist/capability-schema.js +65 -0
  31. package/mcp-memory-server/dist/capability-store.js +431 -0
  32. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  33. package/mcp-memory-server/dist/eval-cli.js +523 -0
  34. package/mcp-memory-server/dist/eval-plan.js +346 -0
  35. package/mcp-memory-server/dist/eval-process.js +273 -0
  36. package/mcp-memory-server/dist/eval-report.js +609 -0
  37. package/mcp-memory-server/dist/eval-runner.js +827 -0
  38. package/mcp-memory-server/dist/eval-schema.js +368 -0
  39. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  40. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  41. package/mcp-memory-server/dist/failure-signature.js +141 -0
  42. package/mcp-memory-server/dist/index.js +600 -55
  43. package/mcp-memory-server/dist/node-cli.js +84 -0
  44. package/mcp-memory-server/dist/node-schema.js +170 -0
  45. package/mcp-memory-server/dist/node-store.js +455 -0
  46. package/mcp-memory-server/dist/note-cli.js +235 -0
  47. package/mcp-memory-server/dist/note-distiller.js +177 -0
  48. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  49. package/mcp-memory-server/dist/note-schema.js +4 -0
  50. package/mcp-memory-server/dist/note-store.js +985 -0
  51. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  52. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  53. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  54. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  55. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  56. package/mcp-memory-server/package.json +24 -2
  57. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  58. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  59. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  60. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  61. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  62. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  63. package/opencode/command/graphify.md +25 -14
  64. package/opencode/plugins/memory-capture.ts +76 -10
  65. package/opencode/plugins/memory-wakeup.ts +58 -6
  66. package/package.json +8 -4
  67. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  68. package/schemas/artifact.schema.json +133 -0
  69. package/schemas/capability-callback.schema.json +68 -0
  70. package/schemas/capability-contract.schema.json +126 -0
  71. package/schemas/eval-adapter.schema.json +43 -0
  72. package/schemas/eval-protocol.schema.json +114 -0
  73. package/schemas/eval-report.schema.json +423 -0
  74. package/schemas/eval-task-set.schema.json +114 -0
  75. package/schemas/memory-node.schema.json +90 -0
  76. package/schemas/note.schema.json +112 -0
  77. package/schemas/trajectory-redaction.schema.json +25 -0
  78. package/schemas/trajectory.schema.json +82 -0
  79. package/scripts/bootstrap.sh +4 -0
  80. package/scripts/completion.sh +119 -5
  81. package/scripts/doctor.sh +284 -0
  82. package/scripts/fake-eval-adapter.mjs +120 -0
  83. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  84. package/scripts/install-audit-timer.sh +3 -1
  85. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  86. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  87. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  88. package/scripts/memory-wiki-audit.sh +49 -0
  89. package/scripts/smoke-bash32.sh +9 -0
  90. package/scripts/sync-claude-assets.sh +99 -6
  91. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  92. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  93. package/scripts/sync-opencode-security-assets.sh +24 -3
  94. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  95. package/scripts/sync-pi-assets.sh +72 -0
  96. package/scripts/test-audit-timer.sh +98 -0
  97. package/scripts/test-bootstrap-untracked.sh +48 -0
  98. package/scripts/test-cli-dispatch.sh +41 -2
  99. package/scripts/test-compaction-hooks.sh +495 -0
  100. package/scripts/test-completion.sh +77 -0
  101. package/scripts/test-doctor.sh +348 -1
  102. package/scripts/test-eval-cli.sh +175 -0
  103. package/scripts/test-launcher-hooks.sh +602 -3
  104. package/scripts/test-notes-cli.sh +114 -0
  105. package/scripts/test-package-install.sh +251 -0
  106. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  107. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  108. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  109. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  110. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  111. package/scripts/test-trajectory-cli.sh +87 -0
  112. package/scripts/test-trajectory-hooks.sh +142 -0
  113. package/scripts/test-uninstall.sh +231 -5
  114. package/scripts/uninstall.sh +37 -5
  115. package/scripts/verify-dependency-security.mjs +121 -0
  116. package/scripts/verify-docs-parity.sh +527 -1
  117. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  118. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  119. package/templates/capabilities.json.template +7 -0
  120. package/templates/env.example.template +51 -0
  121. package/templates/memory-wiki-audit.service.template +1 -1
  122. package/templates/start-claude.sh.template +36 -0
  123. package/templates/start-opencode.sh.template +47 -5
  124. package/templates/start-pi.sh.template +30 -0
  125. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env bash
2
+ # Cross-harness compaction capture/recovery contract. The default invocation
3
+ # is the complete green integration path and remains safe for root test globs.
4
+ set -euo pipefail
5
+
6
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
7
+ MODE="${1:-}"
8
+ EXPECTED_RED_EXIT=86
9
+ MISSING_CAPABILITY=85
10
+ RED_MARKER="PHASE17_RED:COMPACTION_HOOK_RECOVERY_MISSING"
11
+
12
+ case "$MODE" in
13
+ ""|--expect-red|--claude-only|--opencode-only|--full) ;;
14
+ *) echo "usage: $0 [--expect-red|--claude-only|--opencode-only|--full]" >&2; exit 2 ;;
15
+ esac
16
+
17
+ tmp=$(mktemp -d)
18
+ trap 'rm -rf "$tmp"' EXIT
19
+ fail() { echo "FAIL: $1" >&2; return 1; }
20
+
21
+ claude_fixture="$ROOT/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json"
22
+ claude_fixture_220="$ROOT/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json"
23
+ opencode_event_fixture="$ROOT/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json"
24
+ opencode_messages_fixture="$ROOT/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json"
25
+ PARITY_RECOVERY=$'## Compaction recovery\nSource: project_fallback\nSession: canonical:prior\nRevision: 2\nCaptured: 2026-07-25T00:00:00.000Z\nAge: 172800 seconds\nHarness: canonical\nCompleteness: goals=missing, decisions=missing, todos=missing, errors=missing\nWarning: this state is stale; validate it against the current repository before relying on it.\n\n### Task Goals\n(none captured)\n\n### Decisions Made\n(none captured)\n\n### Open TODOs\n(none captured)\n\n### Critical Error Traces\n(none captured)\n'
26
+
27
+ render() {
28
+ local source="$1"
29
+ local destination="$2"
30
+ local infra_root="${3:-$ROOT}"
31
+ sed "s|@@INFRA_ROOT@@|$infra_root|g" "$source" > "$destination"
32
+ }
33
+
34
+ assert_marker_free() {
35
+ local path="$1"
36
+ ! grep -qF "$RED_MARKER" "$path" || fail "default baseline emitted the compaction RED marker"
37
+ }
38
+
39
+ run_existing_baseline() {
40
+ local output="$tmp/existing-baseline.out"
41
+ "$ROOT/scripts/test-trajectory-hooks.sh" >"$output" 2>&1
42
+ grep -qF "PASS: Claude/OpenCode/Pi trajectory hooks and flag-off regression" "$output" \
43
+ || fail "existing trajectory hook baseline did not pass"
44
+ assert_marker_free "$output"
45
+ }
46
+
47
+ validate_fixtures() {
48
+ node - "$claude_fixture" "$claude_fixture_220" "$opencode_event_fixture" "$opencode_messages_fixture" <<'NODE'
49
+ const fs = require("fs")
50
+ const [claudePath, claude220Path, eventPath, messagesPath] = process.argv.slice(2)
51
+ const claude = JSON.parse(fs.readFileSync(claudePath, "utf8"))
52
+ const claude220 = JSON.parse(fs.readFileSync(claude220Path, "utf8"))
53
+ const event = JSON.parse(fs.readFileSync(eventPath, "utf8"))
54
+ const envelope = JSON.parse(fs.readFileSync(messagesPath, "utf8"))
55
+ if (claude.hook_event_name !== "PostCompact" || typeof claude.compact_summary !== "string") process.exit(1)
56
+ if (claude220.hook_event_name !== "PostCompact" || typeof claude220.prompt_id !== "string") process.exit(1)
57
+ if (Object.keys(claude220).sort().join(",") !== ["compact_summary", "cwd", "hook_event_name", "prompt_id", "session_id", "transcript_path", "trigger"].sort().join(",")) process.exit(1)
58
+ if (event.type !== "session.compacted" || Object.keys(event.properties ?? {}).join(",") !== "sessionID") process.exit(1)
59
+ if (envelope.session?.id !== event.properties.sessionID || !Array.isArray(envelope.messages)) process.exit(1)
60
+ NODE
61
+ }
62
+
63
+ require_managed_sources() {
64
+ local missing=0
65
+ [[ -f "$ROOT/claude/hooks/compaction-capture.sh" ]] || missing=1
66
+ grep -qF "compaction-capture.sh)" "$ROOT/scripts/sync-claude-assets.sh" || missing=1
67
+ grep -qF 'session.compacted' "$ROOT/opencode/plugins/memory-capture.ts" || missing=1
68
+ grep -qF 'CAIRN_COMPACTION_CAPTURE' "$ROOT/opencode/plugins/memory-wakeup.ts" || missing=1
69
+ [[ "$missing" -eq 0 ]] || return "$MISSING_CAPABILITY"
70
+ }
71
+
72
+ make_fake_infra() {
73
+ local fake_root="$1"
74
+ mkdir -p "$fake_root/mcp-memory-server/dist"
75
+ cat > "$fake_root/mcp-memory-server/dist/artifact-cli.js" <<'NODE'
76
+ import fs from "node:fs"
77
+ const args = process.argv.slice(2)
78
+ const command = args[0] ?? ""
79
+ const input = command.startsWith("capture-") ? await new Promise((resolve) => {
80
+ let value = ""
81
+ process.stdin.setEncoding("utf8")
82
+ process.stdin.on("data", (chunk) => { value += chunk })
83
+ process.stdin.on("end", () => resolve(value))
84
+ }) : ""
85
+ if (process.env.CAIRN_TEST_CALLS) {
86
+ fs.appendFileSync(process.env.CAIRN_TEST_CALLS, `${JSON.stringify({ args, input })}\n`)
87
+ }
88
+ if (command === "recover" && process.env.CAIRN_TEST_RECOVERY) {
89
+ process.stdout.write(process.env.CAIRN_TEST_RECOVERY)
90
+ }
91
+ NODE
92
+ }
93
+
94
+ write_fake_claude() {
95
+ local fake_bin="$1"
96
+ local version="$2"
97
+ mkdir -p "$fake_bin"
98
+ cat > "$fake_bin/claude" <<EOF
99
+ #!/usr/bin/env bash
100
+ printf '%s (Claude Code)\n' '$version'
101
+ EOF
102
+ chmod +x "$fake_bin/claude"
103
+ }
104
+
105
+ assert_no_spy_calls() {
106
+ local prefix="$1"
107
+ [[ ! -e "$prefix.node" ]] || fail "$prefix started Node while compaction capture was disabled"
108
+ [[ ! -e "$prefix.python" ]] || fail "$prefix parsed JSON while compaction capture was disabled"
109
+ [[ ! -e "$prefix.curl" ]] || fail "$prefix attempted network access while compaction capture was disabled"
110
+ }
111
+
112
+ make_path_spies() {
113
+ local spy_dir="$1"
114
+ local prefix="$2"
115
+ mkdir -p "$spy_dir"
116
+ for executable in node python3 curl; do
117
+ cat > "$spy_dir/$executable" <<EOF
118
+ #!/usr/bin/env bash
119
+ touch "$prefix.${executable%%3}"
120
+ exit 97
121
+ EOF
122
+ chmod +x "$spy_dir/$executable"
123
+ done
124
+ }
125
+
126
+ claude_disabled_contract() {
127
+ local hook="$1"
128
+ local wakeup="$2"
129
+ local repo="$tmp/claude-disabled"
130
+ local spies="$tmp/claude-disabled-spies"
131
+ local prefix="$tmp/claude-disabled-call"
132
+ mkdir -p "$repo"
133
+ make_path_spies "$spies" "$prefix"
134
+ local before after
135
+ before=$(find "$repo" -type f -print | sort)
136
+ (cd "$repo" && printf '%s\n' '{not-json' | \
137
+ PATH="$spies:$PATH" CAIRN_ARTIFACT_STORE=1 "$hook") \
138
+ >"$tmp/claude-disabled.out" 2>"$tmp/claude-disabled.err"
139
+ after=$(find "$repo" -type f -print | sort)
140
+ [[ "$before" == "$after" ]] || fail "disabled Claude capture changed project files"
141
+ [[ ! -s "$tmp/claude-disabled.out" && ! -s "$tmp/claude-disabled.err" ]] \
142
+ || fail "disabled Claude capture emitted output"
143
+ assert_no_spy_calls "$prefix"
144
+
145
+ (cd "$repo" && printf '%s\n' '{not-json' | \
146
+ PATH="$spies:$PATH" CAIRN_ARTIFACT_STORE=1 "$wakeup") \
147
+ >"$tmp/claude-wakeup-disabled.out" 2>"$tmp/claude-wakeup-disabled.err"
148
+ [[ ! -s "$tmp/claude-wakeup-disabled.out" && ! -s "$tmp/claude-wakeup-disabled.err" ]] \
149
+ || fail "disabled Claude recovery changed the empty-project baseline"
150
+ assert_no_spy_calls "$prefix"
151
+ }
152
+
153
+ claude_enabled_contract() {
154
+ local fake_root="$tmp/fake-infra-claude"
155
+ local calls="$tmp/claude-calls.jsonl"
156
+ local hook="$tmp/compaction-capture.sh"
157
+ local wakeup="$tmp/memory-wakeup.sh"
158
+ local repo="$tmp/claude-enabled"
159
+ local fake_bin="$tmp/claude-enabled-bin"
160
+ make_fake_infra "$fake_root"
161
+ write_fake_claude "$fake_bin" "2.1.219"
162
+ render "$ROOT/claude/hooks/compaction-capture.sh" "$hook" "$fake_root"
163
+ render "$ROOT/claude/hooks/memory-wakeup.sh" "$wakeup" "$fake_root"
164
+ chmod +x "$hook" "$wakeup"
165
+ claude_disabled_contract "$hook" "$wakeup"
166
+ mkdir -p "$repo/.agentfs"
167
+ touch "$repo/.agentfs/artifacts.db"
168
+
169
+ (cd "$repo" && PATH="$fake_bin:$PATH" CAIRN_COMPACTION_CAPTURE=1 CAIRN_TEST_CALLS="$calls" \
170
+ timeout 3 "$hook" < "$claude_fixture") >"$tmp/claude-enabled.out" 2>"$tmp/claude-enabled.err"
171
+ [[ ! -s "$tmp/claude-enabled.out" ]] || fail "enabled Claude capture emitted stdout"
172
+ node - "$calls" "$claude_fixture" <<'NODE'
173
+ const fs = require("fs")
174
+ const calls = fs.readFileSync(process.argv[2], "utf8").trim().split("\n").map(JSON.parse)
175
+ const fixture = JSON.parse(fs.readFileSync(process.argv[3], "utf8"))
176
+ if (calls.length !== 1 || calls[0].args[0] !== "capture-claude") process.exit(1)
177
+ if (calls[0].args.slice(-2).join("=") !== "--harness-version=2.1.219") process.exit(1)
178
+ if (JSON.stringify(JSON.parse(calls[0].input)) !== JSON.stringify(fixture)) process.exit(1)
179
+ NODE
180
+
181
+ rm -f "$calls"
182
+ write_fake_claude "$fake_bin" "2.1.220"
183
+ (cd "$repo" && PATH="$fake_bin:$PATH" CAIRN_COMPACTION_CAPTURE=1 CAIRN_TEST_CALLS="$calls" \
184
+ timeout 3 "$hook" < "$claude_fixture_220") >"$tmp/claude-220.out" 2>"$tmp/claude-220.err"
185
+ node - "$calls" "$claude_fixture_220" <<'NODE'
186
+ const fs = require("fs")
187
+ const calls = fs.readFileSync(process.argv[2], "utf8").trim().split("\n").map(JSON.parse)
188
+ const fixture = JSON.parse(fs.readFileSync(process.argv[3], "utf8"))
189
+ if (calls.length !== 1 || calls[0].args.slice(-2).join("=") !== "--harness-version=2.1.220") process.exit(1)
190
+ if (JSON.stringify(JSON.parse(calls[0].input)) !== JSON.stringify(fixture)) process.exit(1)
191
+ NODE
192
+
193
+ rm -f "$calls"
194
+ write_fake_claude "$fake_bin" "2.1.219"
195
+ (cd "$repo" && PATH="$fake_bin:$PATH" CAIRN_COMPACTION_CAPTURE=1 CAIRN_ARTIFACT_STORE=1 \
196
+ CAIRN_ARTIFACT_HTTP=1 CAIRN_TEST_CALLS="$calls" timeout 3 "$hook" < "$claude_fixture") \
197
+ >"$tmp/claude-both-http.out" 2>"$tmp/claude-both-http.err"
198
+ [[ $(wc -l < "$calls") -eq 1 ]] || fail "Claude both-flags/HTTP capture changed local call count"
199
+
200
+ local current_recovery fallback_recovery stale_recovery
201
+ current_recovery=$'Source: current_session\nSession: claude-code:claude-compaction-session-001\nRevision: 2\nTask Goals\n- current goal\nDecisions Made\n- current decision\nOpen TODOs\n- current todo\nCritical Error Traces\n- current error\n'
202
+ fallback_recovery=$'Source: project_fallback\nSession: claude-code:prior\nRevision: 1\nTask Goals\n- fallback goal\nDecisions Made\n- fallback decision\nOpen TODOs\n- fallback todo\nCritical Error Traces\n- fallback error\n'
203
+ stale_recovery="$PARITY_RECOVERY"
204
+ for case_name in current fallback stale; do
205
+ case "$case_name" in
206
+ current) recovery="$current_recovery"; session_id="claude-compaction-session-001" ;;
207
+ fallback) recovery="$fallback_recovery"; session_id="fresh-session" ;;
208
+ stale) recovery="$stale_recovery"; session_id="fresh-stale-session" ;;
209
+ esac
210
+ (cd "$repo" && CAIRN_COMPACTION_CAPTURE=1 CAIRN_TEST_CALLS="$calls" CAIRN_TEST_RECOVERY="$recovery" \
211
+ "$wakeup" <<<"{\"session_id\":\"$session_id\",\"source\":\"startup\"}") \
212
+ >"$tmp/claude-$case_name-recovery.out" 2>"$tmp/claude-$case_name-recovery.err"
213
+ grep -qF "## Compaction recovery" "$tmp/claude-$case_name-recovery.out" \
214
+ || fail "Claude $case_name recovery heading is missing"
215
+ grep -qF "Task Goals" "$tmp/claude-$case_name-recovery.out" \
216
+ || fail "Claude $case_name recovery body is missing"
217
+ ! grep -qiE 'raw_summary|compact_summary|Bearer|sk-' "$tmp/claude-$case_name-recovery.out" \
218
+ || fail "Claude $case_name recovery injected raw summary data"
219
+ done
220
+
221
+ (cd "$repo" && CAIRN_COMPACTION_CAPTURE=1 CAIRN_TEST_CALLS="$calls" "$wakeup" <<<'{not-json') \
222
+ >"$tmp/claude-malformed.out" 2>"$tmp/claude-malformed.err"
223
+ [[ ! -s "$tmp/claude-malformed.err" ]] || fail "malformed Claude recovery did not fail open"
224
+ }
225
+
226
+ claude_sync_contract() {
227
+ local live="$tmp/claude-live"
228
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --live-root "$live" >/dev/null
229
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --live-root "$live" >/dev/null
230
+ [[ -x "$live/hooks/compaction-capture.sh" ]] || fail "installed PostCompact hook is not executable"
231
+ local registrations
232
+ registrations=$(node - "$live/settings.json" <<'NODE'
233
+ const fs = require("fs")
234
+ const settings = JSON.parse(fs.readFileSync(process.argv[2], "utf8"))
235
+ let count = 0
236
+ for (const entry of settings.hooks?.PostCompact ?? []) {
237
+ if (entry.matcher !== undefined && entry.matcher !== "") process.exit(2)
238
+ for (const hook of entry.hooks ?? []) {
239
+ if (String(hook.command ?? "").includes("compaction-capture.sh")) count += 1
240
+ }
241
+ }
242
+ process.stdout.write(String(count))
243
+ NODE
244
+ )
245
+ [[ "$registrations" -eq 1 ]] || fail "PostCompact registration is missing, matched, or duplicated"
246
+ }
247
+
248
+ write_opencode_harness() {
249
+ cat > "$tmp/opencode-compaction-harness.mjs" <<'NODE'
250
+ import assert from "node:assert/strict"
251
+ import fs from "node:fs"
252
+ import { pathToFileURL } from "node:url"
253
+ const [capturePath, wakeupPath, eventPath, messagesPath, repo, mode] = process.argv.slice(2)
254
+ const event = JSON.parse(fs.readFileSync(eventPath, "utf8"))
255
+ const envelope = JSON.parse(fs.readFileSync(messagesPath, "utf8"))
256
+ const calls = []
257
+ const client = { session: {
258
+ get: async (input) => { calls.push(["get", input]); return { data: envelope.session } },
259
+ messages: async (input) => { calls.push(["messages", input]); return { data: envelope.messages } },
260
+ } }
261
+ const capture = await import(`${pathToFileURL(capturePath).href}?capture=${Date.now()}`)
262
+ const plugin = await capture.MemoryCapturePlugin({ client, directory: repo })
263
+ await plugin.event({ event })
264
+ if (mode === "disabled") {
265
+ assert.deepEqual(calls, [])
266
+ assert.equal(fs.existsSync(process.env.CAIRN_TEST_CALLS), false)
267
+ } else {
268
+ assert.deepEqual(calls.map(([name]) => name), ["get", "messages"])
269
+ const subprocess = fs.readFileSync(process.env.CAIRN_TEST_CALLS, "utf8").trim().split("\n").map(JSON.parse)
270
+ assert.equal(subprocess.length, 1)
271
+ assert.equal(subprocess[0].args[0], "capture-opencode")
272
+ const payload = JSON.parse(subprocess[0].input)
273
+ assert.equal(payload.event.type, "session.compacted")
274
+ assert.equal(payload.session.id, envelope.session.id)
275
+ assert.equal(payload.harness_version, "1.17.20")
276
+ assert.deepEqual(payload.messages, envelope.messages)
277
+ }
278
+
279
+ const shellCalls = []
280
+ const dollar = (...args) => {
281
+ shellCalls.push(args)
282
+ return { quiet() { return this }, nothrow: async () => ({ stdout: "" }) }
283
+ }
284
+ const wakeup = await import(`${pathToFileURL(wakeupPath).href}?wakeup=${Date.now()}`)
285
+ const wakeupPlugin = await wakeup.MemoryWakeupPlugin({ $: dollar, directory: repo })
286
+ const output = { system: [] }
287
+ await wakeupPlugin["experimental.chat.system.transform"]({ sessionID: envelope.session.id }, output)
288
+ if (mode === "disabled") {
289
+ assert.deepEqual(shellCalls, [])
290
+ assert.deepEqual(output.system, [])
291
+ } else {
292
+ assert.equal(output.system.some((value) => String(value).includes("Task Goals")), true)
293
+ assert.equal(output.system.some((value) => /raw_summary|compact_summary|Bearer|sk-/i.test(String(value))), false)
294
+ if (process.env.CAIRN_TEST_SYSTEM_OUTPUT) {
295
+ fs.writeFileSync(process.env.CAIRN_TEST_SYSTEM_OUTPUT, JSON.stringify(output.system))
296
+ }
297
+ }
298
+ NODE
299
+ }
300
+
301
+ opencode_contract() {
302
+ local fake_root="$tmp/fake-infra-opencode"
303
+ local calls="$tmp/opencode-calls.jsonl"
304
+ local capture="$tmp/opencode-memory-capture.ts"
305
+ local wakeup="$tmp/opencode-memory-wakeup.ts"
306
+ local repo="$tmp/opencode-project"
307
+ make_fake_infra "$fake_root"
308
+ render "$ROOT/opencode/plugins/memory-capture.ts" "$capture" "$fake_root"
309
+ render "$ROOT/opencode/plugins/memory-wakeup.ts" "$wakeup" "$fake_root"
310
+ mkdir -p "$repo"
311
+ write_opencode_harness
312
+
313
+ CAIRN_ARTIFACT_STORE=1 CAIRN_TEST_CALLS="$calls" node --experimental-strip-types \
314
+ "$tmp/opencode-compaction-harness.mjs" "$capture" "$wakeup" \
315
+ "$opencode_event_fixture" "$opencode_messages_fixture" "$repo" disabled \
316
+ >"$tmp/opencode-disabled.out" 2>"$tmp/opencode-disabled.err"
317
+ [[ ! -s "$tmp/opencode-disabled.out" && ! -s "$tmp/opencode-disabled.err" ]] \
318
+ || fail "disabled OpenCode compaction path emitted output"
319
+
320
+ local recovery
321
+ recovery="$PARITY_RECOVERY"
322
+ rm -f "$calls"
323
+ CAIRN_COMPACTION_CAPTURE=1 CAIRN_TEST_CALLS="$calls" CAIRN_TEST_RECOVERY="$recovery" \
324
+ CAIRN_TEST_SYSTEM_OUTPUT="$tmp/opencode-system.json" \
325
+ timeout 3 node --experimental-strip-types "$tmp/opencode-compaction-harness.mjs" \
326
+ "$capture" "$wakeup" "$opencode_event_fixture" "$opencode_messages_fixture" "$repo" enabled \
327
+ >"$tmp/opencode-enabled.out" 2>"$tmp/opencode-enabled.err"
328
+ [[ ! -s "$tmp/opencode-enabled.out" && ! -s "$tmp/opencode-enabled.err" ]] \
329
+ || fail "enabled OpenCode compaction path emitted output"
330
+
331
+ rm -f "$calls"
332
+ CAIRN_COMPACTION_CAPTURE=1 CAIRN_ARTIFACT_STORE=1 CAIRN_ARTIFACT_HTTP=1 \
333
+ CAIRN_TEST_CALLS="$calls" CAIRN_TEST_RECOVERY="$recovery" \
334
+ timeout 3 node --experimental-strip-types "$tmp/opencode-compaction-harness.mjs" \
335
+ "$capture" "$wakeup" "$opencode_event_fixture" "$opencode_messages_fixture" "$repo" enabled \
336
+ >"$tmp/opencode-both.out" 2>"$tmp/opencode-both.err"
337
+ [[ -s "$calls" ]] || fail "OpenCode capture did not remain active with both flags"
338
+
339
+ rm -f "$calls"
340
+ CAIRN_ARTIFACT_HTTP=1 CAIRN_TEST_CALLS="$calls" node --experimental-strip-types \
341
+ "$tmp/opencode-compaction-harness.mjs" "$capture" "$wakeup" \
342
+ "$opencode_event_fixture" "$opencode_messages_fixture" "$repo" disabled \
343
+ >"$tmp/opencode-http-only.out" 2>"$tmp/opencode-http-only.err"
344
+ [[ ! -e "$calls" ]] || fail "HTTP consent alone activated OpenCode compaction work"
345
+
346
+ local live="$tmp/opencode-live"
347
+ "$ROOT/scripts/sync-opencode-plugin-assets.sh" --apply --live-root "$live" >/dev/null
348
+ "$ROOT/scripts/sync-opencode-plugin-assets.sh" --apply --live-root "$live" >/dev/null
349
+ [[ $(find "$live" -type f -name 'memory-capture.ts' | wc -l) -eq 1 ]] \
350
+ || fail "OpenCode compaction capture plugin is duplicated"
351
+ [[ $(find "$live" -type f -name 'memory-wakeup.ts' | wc -l) -eq 1 ]] \
352
+ || fail "OpenCode compaction recovery plugin is duplicated"
353
+ }
354
+
355
+ assert_semantic_parity() {
356
+ node - "$tmp/claude-stale-recovery.out" "$tmp/opencode-system.json" <<'NODE'
357
+ const fs = require("fs")
358
+ const claude = fs.readFileSync(process.argv[2], "utf8")
359
+ const opencode = JSON.parse(fs.readFileSync(process.argv[3], "utf8"))
360
+ const heading = "## Compaction recovery"
361
+ const claudeSection = claude.slice(claude.indexOf(heading)).trim()
362
+ const opencodeSection = opencode.find((value) => String(value).includes(heading))?.trim()
363
+ if (!claudeSection || claudeSection !== opencodeSection) process.exit(1)
364
+ const ordered = [
365
+ "Source:", "Session:", "Revision:", "Captured:", "Age:", "Harness:", "Completeness:",
366
+ "Warning:", "### Task Goals", "### Decisions Made", "### Open TODOs", "### Critical Error Traces",
367
+ ]
368
+ let position = -1
369
+ for (const marker of ordered) {
370
+ const next = claudeSection.indexOf(marker)
371
+ if (next <= position) process.exit(2)
372
+ position = next
373
+ }
374
+ if ((claudeSection.match(/\(none captured\)/g) ?? []).length !== 4) process.exit(3)
375
+ if (!/validate it against the current repository/i.test(claudeSection)) process.exit(4)
376
+ NODE
377
+ }
378
+
379
+ artifact_revision_contract() {
380
+ local cli="$ROOT/mcp-memory-server/dist/artifact-cli.js"
381
+ local claude_repo="$tmp/claude-revisions"
382
+ local opencode_repo="$tmp/opencode-revisions"
383
+ local real_hook="$tmp/real-compaction-capture.sh"
384
+ local fake_bin="$tmp/claude-revisions-bin"
385
+ mkdir -p "$claude_repo" "$opencode_repo"
386
+ write_fake_claude "$fake_bin" "2.1.219"
387
+ render "$ROOT/claude/hooks/compaction-capture.sh" "$real_hook"
388
+ chmod +x "$real_hook"
389
+
390
+ node - "$claude_fixture" "$tmp/claude-second.json" <<'NODE'
391
+ const fs = require("fs")
392
+ const fixture = JSON.parse(fs.readFileSync(process.argv[2], "utf8"))
393
+ fixture.compact_summary += "\n- TODO: Validate parity after the second revision."
394
+ fs.writeFileSync(process.argv[3], JSON.stringify(fixture))
395
+ NODE
396
+ (cd "$claude_repo" && PATH="$fake_bin:$PATH" CAIRN_COMPACTION_CAPTURE=1 "$real_hook" < "$claude_fixture")
397
+ (cd "$claude_repo" && PATH="$fake_bin:$PATH" CAIRN_COMPACTION_CAPTURE=1 "$real_hook" < "$tmp/claude-second.json")
398
+
399
+ node - "$opencode_event_fixture" "$opencode_messages_fixture" \
400
+ "$tmp/opencode-first.json" "$tmp/opencode-second.json" <<'NODE'
401
+ const fs = require("fs")
402
+ const event = JSON.parse(fs.readFileSync(process.argv[2], "utf8"))
403
+ const envelope = JSON.parse(fs.readFileSync(process.argv[3], "utf8"))
404
+ const first = { event, session: envelope.session, messages: envelope.messages, harness_version: "1.17.20" }
405
+ const second = structuredClone(first)
406
+ const summary = second.messages.find((message) => message.info?.id === "oc-summary-valid-new")
407
+ const text = summary?.parts?.find((part) => part.type === "text" && part.ignored !== true)
408
+ if (!text) process.exit(1)
409
+ text.text += "\n- TODO: Validate parity after the second revision."
410
+ fs.writeFileSync(process.argv[4], JSON.stringify(first))
411
+ fs.writeFileSync(process.argv[5], JSON.stringify(second))
412
+ NODE
413
+ node "$cli" capture-opencode "$opencode_repo" < "$tmp/opencode-first.json"
414
+ node "$cli" capture-opencode "$opencode_repo" < "$tmp/opencode-second.json"
415
+
416
+ node - "$cli" "$claude_repo" "$opencode_repo" <<'NODE'
417
+ const fs = require("fs")
418
+ const { spawnSync } = require("child_process")
419
+ const [cli, claudeRepo, opencodeRepo] = process.argv.slice(2)
420
+ const cases = [
421
+ { repo: claudeRepo, session: "claude-code:claude-compaction-session-001", secrets: ["claude-compaction-secret-001", "sk-claude-compaction-secret-002"] },
422
+ { repo: opencodeRepo, session: "opencode:opencode-compaction-session-001", secrets: ["opencode-compaction-secret-001", "sk-opencode-compaction-secret-002"] },
423
+ ]
424
+ const run = (repo, args) => {
425
+ const result = spawnSync(process.execPath, [cli, ...args], { cwd: repo, encoding: "utf8" })
426
+ if (result.status !== 0 || result.stderr) process.exit(1)
427
+ return result.stdout
428
+ }
429
+ for (const value of cases) {
430
+ const listedOutput = run(value.repo, ["list", "--kind", "compaction_summary", "--json"])
431
+ const listed = JSON.parse(listedOutput)
432
+ if (listed.artifacts.length !== 2) process.exit(2)
433
+ const shown = listed.artifacts.map((artifact) => JSON.parse(run(value.repo, ["show", artifact.artifact_id, "--json"])))
434
+ if (shown.some((artifact) => artifact.session_ref !== value.session)) process.exit(3)
435
+ if (shown.map((artifact) => artifact.content.revision).sort().join(",") !== "1,2") process.exit(4)
436
+ if (shown.some((artifact) => typeof artifact.content.raw_summary !== "string")) process.exit(5)
437
+ const current = run(value.repo, ["recover", value.repo, "--session-ref", value.session])
438
+ const fallback = run(value.repo, ["recover", value.repo, "--session-ref", `${value.session}-fresh`])
439
+ if (!current.includes("Source: current_session") || !current.includes("Revision: 2")) process.exit(6)
440
+ if (!fallback.includes("Source: project_fallback") || !fallback.includes("Revision: 2")) process.exit(7)
441
+ const automatic = `${listedOutput}\n${current}\n${fallback}`
442
+ if (/raw_summary|compact_summary/i.test(automatic)) process.exit(8)
443
+ const storeBytes = ["artifacts.db", "artifacts.db-wal", "artifacts.db-shm"]
444
+ .flatMap((name) => {
445
+ const path = `${value.repo}/.agentfs/${name}`
446
+ return fs.existsSync(path) ? [fs.readFileSync(path).toString("latin1")] : []
447
+ }).join("\n")
448
+ for (const secret of value.secrets) {
449
+ if (storeBytes.includes(secret) || automatic.includes(secret) || JSON.stringify(shown).includes(secret)) process.exit(9)
450
+ }
451
+ }
452
+ NODE
453
+ }
454
+
455
+ run_claude_contract() {
456
+ require_managed_sources || return $?
457
+ claude_enabled_contract
458
+ claude_sync_contract
459
+ }
460
+
461
+ run_opencode_contract() {
462
+ require_managed_sources || return $?
463
+ opencode_contract
464
+ }
465
+
466
+ run_existing_baseline
467
+ validate_fixtures
468
+
469
+ if [[ "$MODE" == "--expect-red" ]]; then
470
+ set +e
471
+ require_managed_sources
472
+ status=$?
473
+ set -e
474
+ if [[ "$status" -eq "$MISSING_CAPABILITY" ]]; then
475
+ echo "$RED_MARKER"
476
+ exit "$EXPECTED_RED_EXIT"
477
+ fi
478
+ [[ "$status" -eq 0 ]] || exit "$status"
479
+ echo "FAIL: compaction hook/recovery integration unexpectedly exists; run a GREEN mode" >&2
480
+ exit 1
481
+ fi
482
+
483
+ case "$MODE" in
484
+ --claude-only) run_claude_contract ;;
485
+ --opencode-only) run_opencode_contract ;;
486
+ ""|--full)
487
+ node "$ROOT/mcp-memory-server/scripts/smoke-compaction-capture.mjs" >/dev/null
488
+ run_claude_contract
489
+ run_opencode_contract
490
+ assert_semantic_parity
491
+ artifact_revision_contract
492
+ ;;
493
+ esac
494
+
495
+ echo "PASS: compaction hook capture, recovery, disabled paths and sync contract"
@@ -11,9 +11,86 @@ for shell in bash zsh fish; do
11
11
  grep -q 'completion' "$tmp/$shell"
12
12
  done
13
13
 
14
+ capability_ids='memory.write memory.search notes.distill wiki graph security.audit route.check context.explore'
15
+ eval_commands='validate run ablate report prune delete'
16
+ eval_flags='--task-set --adapter --output --repetitions --seed --json --yes --disable --experiment --older-than-days --dry-run'
17
+ for shell in bash zsh fish; do
18
+ grep -q 'capabilities' "$tmp/$shell"
19
+ grep -q 'list.*status.*enable.*disable.*reset.*logging\|list status enable disable reset logging' "$tmp/$shell"
20
+ grep -q -- '--json\|-l json' "$tmp/$shell"
21
+ for capability_id in $capability_ids; do
22
+ grep -qF "$capability_id" "$tmp/$shell"
23
+ done
24
+ if grep -Eq '(^|[[:space:]"'"'"'])(guard|start|finish)([[:space:]"'"'"']|$)' "$tmp/$shell"; then
25
+ echo "completion exposed a private capability operation for $shell" >&2
26
+ exit 1
27
+ fi
28
+ grep -q 'eval' "$tmp/$shell"
29
+ grep -q 'validate.*run.*ablate.*report.*prune.*delete\|validate run ablate report prune delete' "$tmp/$shell"
30
+ for eval_flag in $eval_flags; do
31
+ grep -q -- "$eval_flag\|-l ${eval_flag#--}" "$tmp/$shell"
32
+ done
33
+ for capability_id in $capability_ids; do
34
+ grep -qF "$capability_id" "$tmp/$shell"
35
+ done
36
+ if grep -Eq '(^|[[:space:]"'"'"'])(doctor-diagnosis|guard|start|finish)([[:space:]"'"'"']|$)' "$tmp/$shell"; then
37
+ echo "completion exposed a private eval lifecycle operation for $shell" >&2
38
+ exit 1
39
+ fi
40
+ done
41
+
42
+ "$ROOT/bin/cairn" help >"$tmp/root-help"
43
+ grep -qF 'cairn eval <validate|run|ablate|report|prune|delete>' "$tmp/root-help"
44
+ node "$ROOT/mcp-memory-server/dist/eval-cli.js" --help >"$tmp/eval-help"
45
+ for command in $eval_commands; do
46
+ grep -q "cairn eval $command" "$tmp/eval-help"
47
+ done
48
+ for eval_flag in $eval_flags; do
49
+ grep -q -- "$eval_flag" "$tmp/eval-help"
50
+ done
51
+ if grep -q 'doctor-diagnosis' "$tmp/eval-help"; then
52
+ echo 'eval help exposed a private lifecycle operation' >&2
53
+ exit 1
54
+ fi
55
+
56
+ node - "$ROOT/mcp-memory-server/src/capability-registry.ts" "$tmp/bash" "$tmp/zsh" "$tmp/fish" <<'NODE'
57
+ const fs = require("fs");
58
+ const [registryPath, ...completionPaths] = process.argv.slice(2);
59
+ const registry = fs.readFileSync(registryPath, "utf8");
60
+ const ids = [...registry.matchAll(/^\s*id: "([^"]+)",$/gm)].map((match) => match[1]);
61
+ const expected = ["memory.write", "memory.search", "notes.distill", "wiki", "graph", "security.audit", "route.check", "context.explore"];
62
+ if (JSON.stringify(ids) !== JSON.stringify(expected)) process.exit(1);
63
+ for (const path of completionPaths) {
64
+ const output = fs.readFileSync(path, "utf8");
65
+ if (!output.includes(ids.join(" "))) process.exit(1);
66
+ }
67
+ NODE
68
+
14
69
  grep -q 'complete -F _cairn_complete cairn' "$tmp/bash"
15
70
  grep -q '#compdef cairn' "$tmp/zsh"
16
71
  grep -q 'complete -c cairn' "$tmp/fish"
72
+ for shell in bash zsh; do
73
+ grep -q 'sync-pi' "$tmp/$shell"
74
+ grep -q -- '--live-root' "$tmp/$shell"
75
+ done
76
+ grep -q 'sync-pi' "$tmp/fish"
77
+ grep -q -- '-l live-root' "$tmp/fish"
78
+ for shell in bash zsh fish; do
79
+ grep -q 'notes' "$tmp/$shell"
80
+ grep -q 'distill.*search-error.*promote.*doctor\|distill search-error promote doctor' "$tmp/$shell"
81
+ grep -q 'artifact' "$tmp/$shell"
82
+ grep -q 'list.*show.*delete.*prune' "$tmp/$shell"
83
+ grep -q -- '--json\|-l json' "$tmp/$shell"
84
+ grep -q -- '--dry-run\|-l dry-run' "$tmp/$shell"
85
+ grep -q -- '--include-protected\|-l include-protected' "$tmp/$shell"
86
+ done
87
+
88
+ grep -q -- '--kind' "$tmp/bash"
89
+ grep -q -- '--session' "$tmp/bash"
90
+ grep -q -- '--kind' "$tmp/zsh"
91
+ grep -q -- '--session' "$tmp/zsh"
92
+ grep -q -- '-l kind' "$tmp/fish"
93
+ grep -q -- '-l session' "$tmp/fish"
17
94
 
18
95
  if "$ROOT/bin/cairn" completion unsupported >/dev/null 2>&1; then
19
96
  echo "completion accepted an unsupported shell" >&2