@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,1060 @@
1
+ #!/usr/bin/env bash
2
+ # Native Claude/OpenCode capability-boundary contract tests. The no-argument
3
+ # path stays inert so callers must select an explicit, honestly scoped mode.
4
+ set -euo pipefail
5
+
6
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
7
+ FIXTURE="$ROOT/scripts/fixtures/capability-harness-contracts.json"
8
+ CLAUDE_START="$ROOT/claude/capability-contract/hooks/capability-command-start.sh"
9
+ CLAUDE_FINISH="$ROOT/claude/capability-contract/hooks/capability-command-finish.sh"
10
+ OPENCODE_PLUGIN="$ROOT/opencode/capability-contract/plugins/capability-command.ts"
11
+ OPENCODE_HARNESS="$ROOT/scripts/lib/capability-opencode-plugin-harness.mjs"
12
+
13
+ fail() {
14
+ echo "FAIL: $1" >&2
15
+ exit 1
16
+ }
17
+
18
+ usage() {
19
+ printf '%s\n' \
20
+ "Usage: test-phase18-harness-boundary.sh MODE" \
21
+ "" \
22
+ "Deterministic boundary modes:" \
23
+ " claude-hooks | opencode-plugin | opencode-sync-modes" \
24
+ " opencode-disposal" \
25
+ " claude-owner-only | opencode-command-owner-only | opencode-owner-only" \
26
+ " claude-delegate-calls | opencode-delegate-calls | evidence-scope" \
27
+ "" \
28
+ "Live negative controls:" \
29
+ " --live-claude | --live-opencode"
30
+ }
31
+
32
+ validate_fixture() {
33
+ node - "$FIXTURE" <<'NODE'
34
+ const fs = require("node:fs");
35
+ const fixture = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
36
+ if (fixture?.claude?.version !== "2.1.220") process.exit(1);
37
+ if (fixture?.opencode?.version !== "1.17.20") process.exit(1);
38
+ if (fixture?.opencode?.source?.commit !== "4473fc3c9055046183990a965d68df3db7ea6f62") process.exit(1);
39
+ const claudeEvents = Object.keys(fixture.claude.events).sort().join(",");
40
+ if (claudeEvents !== "CwdChanged,SessionEnd,Stop,StopFailure,UserPromptExpansion,UserPromptSubmit") process.exit(1);
41
+ const openCodeEvents = Object.keys(fixture.opencode.events).sort().join(",");
42
+ if (openCodeEvents !== "session.deleted,session.error,session.idle,session.status") process.exit(1);
43
+ if (fixture.opencode.hook.name !== "command.execute.before") process.exit(1);
44
+ if (fixture.opencode.dispose?.name !== "dispose") process.exit(1);
45
+ if (fixture.opencode.dispose?.source_commit !== "4473fc3c9055046183990a965d68df3db7ea6f62") process.exit(1);
46
+ NODE
47
+ }
48
+
49
+ assert_no_native_claude_assets() {
50
+ local live_root="$1"
51
+ [[ ! -e "$live_root/hooks/capability-command-start.sh" ]] || fail "normal Claude sync installed the capability start hook"
52
+ [[ ! -e "$live_root/hooks/capability-command-finish.sh" ]] || fail "normal Claude sync installed the capability finish hook"
53
+ if [[ -f "$live_root/settings.json" ]] && grep -q 'capability-command-' "$live_root/settings.json"; then
54
+ fail "normal Claude sync registered a capability hook"
55
+ fi
56
+ }
57
+
58
+ assert_no_native_opencode_assets() {
59
+ local live_root="$1"
60
+ [[ ! -e "$live_root/plugins/capability-command.ts" ]] || fail "normal OpenCode sync installed the capability plugin"
61
+ if [[ -d "$live_root/plugins" ]] && grep -R -q 'CapabilityCommandPlugin' "$live_root/plugins"; then
62
+ fail "normal OpenCode sync registered the capability plugin"
63
+ fi
64
+ }
65
+
66
+ normal_sync_trace() {
67
+ local temp_root="$1"
68
+ local claude_root="$temp_root/claude"
69
+ local opencode_root="$temp_root/opencode"
70
+ local trace="$temp_root/process.trace"
71
+
72
+ : > "$trace"
73
+ printf 'sync:claude:normal\n' >> "$trace"
74
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --live-root "$claude_root" >/dev/null
75
+ printf 'sync:opencode:normal\n' >> "$trace"
76
+ "$ROOT/scripts/sync-opencode-plugin-assets.sh" --apply --live-root "$opencode_root" >/dev/null
77
+
78
+ assert_no_native_claude_assets "$claude_root"
79
+ assert_no_native_opencode_assets "$opencode_root"
80
+ [[ $(wc -l < "$trace" | tr -d ' ') -eq 2 ]] || fail "unexpected normal-sync process trace"
81
+ [[ ! -e "$temp_root/project/.agentfs/trajectory.db" ]] || fail "normal sync created measurement state"
82
+ [[ ! -e "$temp_root/project/.agentfs/project.db" ]] || fail "normal sync created project state"
83
+ }
84
+
85
+ install_claude_hook_fixture() {
86
+ local source="$1"
87
+ local target="$2"
88
+ sed "s|@@INFRA_ROOT@@|$ROOT|g" "$source" > "$target"
89
+ chmod 700 "$target"
90
+ }
91
+
92
+ write_capability_config() {
93
+ local project="$1"
94
+ local wiki="$2"
95
+ local logging="$3"
96
+ mkdir -p "$project/.ai"
97
+ cat > "$project/.ai/capabilities.json" <<JSON
98
+ {"schema_version":1,"capabilities":{"wiki":$wiki},"logging":{"callbacks":$logging}}
99
+ JSON
100
+ chmod 600 "$project/.ai/capabilities.json"
101
+ }
102
+
103
+ run_claude_hook() {
104
+ local hook="$1"
105
+ local project="$2"
106
+ local state_root="$3"
107
+ local payload="$4"
108
+ local stdout_file="$5"
109
+ local stderr_file="$6"
110
+ local trajectory="${7:-1}"
111
+ local status=0
112
+ (
113
+ cd "$project"
114
+ printf '%s' "$payload" | env \
115
+ CAIRN_CAPABILITY_CONTRACT=1 \
116
+ CAIRN_HARNESS_STATE_DIR="$state_root" \
117
+ CAIRN_TRAJECTORY_CAPTURE="$trajectory" \
118
+ "$hook" >"$stdout_file" 2>"$stderr_file"
119
+ ) || status=$?
120
+ return "$status"
121
+ }
122
+
123
+ assert_fixed_block() {
124
+ local stdout_file="$1"
125
+ local stderr_file="$2"
126
+ local status="$3"
127
+ [[ "$status" -eq 2 ]] || fail "Claude admission did not fail closed with exit 2"
128
+ [[ "$(cat "$stdout_file")" == '{"decision":"block","reason":"capability disabled"}' ]] || \
129
+ fail "Claude admission did not emit the fixed disabled block"
130
+ [[ ! -s "$stderr_file" ]] || fail "Claude admission disclosed rejected input on stderr"
131
+ }
132
+
133
+ callback_rows() {
134
+ local project="$1"
135
+ local database="$project/.agentfs/trajectory.db"
136
+ [[ -f "$database" ]] || { printf '[]'; return; }
137
+ (
138
+ cd "$ROOT/mcp-memory-server"
139
+ NODE_NO_WARNINGS=1 node --input-type=module - "$database" <<'NODE'
140
+ import { AgentFS } from "agentfs-sdk";
141
+ const agent = await AgentFS.open({ id: "trajectory", path: process.argv[2] });
142
+ try {
143
+ const rows = await agent.kv.list("capability-callback/v1/record/");
144
+ process.stdout.write(JSON.stringify(rows.map((row) => row.value)));
145
+ } finally {
146
+ await agent.close();
147
+ }
148
+ NODE
149
+ )
150
+ }
151
+
152
+ pending_rows() {
153
+ local project="$1"
154
+ local database="$project/.agentfs/trajectory.db"
155
+ [[ -f "$database" ]] || { printf '[]'; return; }
156
+ (
157
+ cd "$ROOT/mcp-memory-server"
158
+ NODE_NO_WARNINGS=1 node --input-type=module - "$database" <<'NODE'
159
+ import { AgentFS } from "agentfs-sdk";
160
+ const agent = await AgentFS.open({ id: "trajectory", path: process.argv[2] });
161
+ try {
162
+ const rows = await agent.kv.list("capability-callback/v1/pending/");
163
+ process.stdout.write(JSON.stringify(rows.map((row) => row.value)));
164
+ } finally {
165
+ await agent.close();
166
+ }
167
+ NODE
168
+ )
169
+ }
170
+
171
+ assert_value_free_rows() {
172
+ local rows="$1"
173
+ local expected_outcome="$2"
174
+ node - "$rows" "$expected_outcome" <<'NODE'
175
+ const rows = JSON.parse(process.argv[2]);
176
+ const expected = process.argv[3];
177
+ if (rows.length !== 1 || rows[0].outcome !== expected) process.exit(1);
178
+ const forbidden = ["argument", "result", "prompt", "query", "path", "stack", "error_message", "secret"];
179
+ const raw = JSON.stringify(rows[0]).toLowerCase();
180
+ if (forbidden.some((name) => raw.includes(name))) process.exit(1);
181
+ NODE
182
+ }
183
+
184
+ assert_opencode_result() {
185
+ local result="$1"
186
+ local expected="$2"
187
+ node - "$result" "$expected" <<'NODE'
188
+ const value = JSON.parse(process.argv[2]);
189
+ const expected = process.argv[3];
190
+ const admission = value.calls.find((call) => call.boundary === "command.execute.before");
191
+ if (!admission || admission.result !== expected) process.exit(1);
192
+ if (expected === "blocked" && admission.error !== "Cairn capability disabled.") process.exit(1);
193
+ if (expected === "allowed" && admission.parts !== 0) process.exit(1);
194
+ NODE
195
+ }
196
+
197
+ run_opencode_scenario() {
198
+ local plugin="$1"
199
+ local project="$2"
200
+ local state_root="$3"
201
+ local scenario="$4"
202
+ local session="$5"
203
+ local trajectory="${6:-1}"
204
+ local non_awaited="${7:-0}"
205
+ (
206
+ cd "$project"
207
+ env \
208
+ CAIRN_CAPABILITY_CONTRACT=1 \
209
+ CAIRN_HARNESS_STATE_DIR="$state_root" \
210
+ CAIRN_TRAJECTORY_CAPTURE="$trajectory" \
211
+ CAIRN_TEST_OPENCODE_NON_AWAITED="$non_awaited" \
212
+ CAIRN_TEST_SESSION="$session" \
213
+ node --experimental-strip-types "$OPENCODE_HARNESS" \
214
+ "$plugin" "$project" "$FIXTURE" "$scenario"
215
+ )
216
+ }
217
+
218
+ opencode_plugin() {
219
+ local temp_root plugin result rows case_root project decoy state_root
220
+ temp_root=$(mktemp -d)
221
+ trap "rm -rf '$temp_root'" EXIT
222
+ plugin="$temp_root/capability-command.ts"
223
+
224
+ validate_fixture
225
+ [[ -f "$OPENCODE_PLUGIN" ]] || fail "OpenCode capability plugin is absent"
226
+ sed "s|@@INFRA_ROOT@@|$ROOT|g" "$OPENCODE_PLUGIN" > "$plugin"
227
+
228
+ result=$(node --experimental-strip-types "$OPENCODE_HARNESS" \
229
+ "$plugin" "$temp_root/contract-project" "$FIXTURE" contract)
230
+ node - "$result" <<'NODE' || fail "OpenCode implementation disagrees with the pinned contract"
231
+ const value = JSON.parse(process.argv[2]);
232
+ if (value.fixture.version !== "1.17.20") process.exit(1);
233
+ if (value.fixture.commit !== "4473fc3c9055046183990a965d68df3db7ea6f62") process.exit(1);
234
+ if (value.calls.length !== 0) process.exit(1);
235
+ NODE
236
+
237
+ case_root="$temp_root/malformed"
238
+ project="$case_root/project"
239
+ decoy="$project-decoy"
240
+ state_root="$case_root/state"
241
+ mkdir -p "$project" "$decoy"
242
+ write_capability_config "$project" true true
243
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" malformed-admission malformed 1)
244
+ assert_opencode_result "$result" blocked || fail "malformed OpenCode admission did not fail closed"
245
+ [[ "$(callback_rows "$project")" == '[]' ]] || fail "malformed OpenCode admission created callback state"
246
+ [[ ! -e "$state_root/capability-leases-v1" ]] || fail "malformed OpenCode admission created lease state"
247
+
248
+ case_root="$temp_root/identity"
249
+ project="$case_root/project"
250
+ decoy="$project-decoy"
251
+ state_root="$case_root/state"
252
+ mkdir -p "$project" "$decoy"
253
+ write_capability_config "$project" true true
254
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" identity-mismatch identity 1)
255
+ assert_opencode_result "$result" blocked || fail "ambiguous OpenCode project identity did not fail closed"
256
+ [[ "$(callback_rows "$project")" == '[]' ]] || fail "ambiguous OpenCode identity created callback state"
257
+
258
+ for consent in false:1 true:0; do
259
+ local logging=${consent%%:*}
260
+ local trajectory=${consent#*:}
261
+ case_root="$temp_root/enabled-$logging-$trajectory"
262
+ project="$case_root/project"
263
+ decoy="$project-decoy"
264
+ state_root="$case_root/state"
265
+ mkdir -p "$project" "$decoy"
266
+ write_capability_config "$project" true "$logging"
267
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" admission "enabled-$logging-$trajectory" "$trajectory")
268
+ assert_opencode_result "$result" allowed || fail "enabled unmeasured OpenCode command changed owner execution"
269
+ [[ "$(callback_rows "$project")" == '[]' ]] || fail "enabled unmeasured OpenCode command created callback state"
270
+ [[ ! -e "$state_root/capability-leases-v1" ]] || fail "enabled unmeasured OpenCode command created lease state"
271
+ done
272
+
273
+ for consent in false:1 true:0; do
274
+ local logging=${consent%%:*}
275
+ local trajectory=${consent#*:}
276
+ case_root="$temp_root/disabled-$logging-$trajectory"
277
+ project="$case_root/project"
278
+ decoy="$project-decoy"
279
+ state_root="$case_root/state"
280
+ mkdir -p "$project" "$decoy"
281
+ write_capability_config "$project" false "$logging"
282
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" admission "disabled-$logging-$trajectory" "$trajectory")
283
+ assert_opencode_result "$result" blocked || fail "disabled unmeasured OpenCode command did not return the fixed block"
284
+ [[ "$(callback_rows "$project")" == '[]' ]] || fail "disabled unmeasured OpenCode command created callback state"
285
+ find "$state_root/capability-leases-v1" -type f -print -quit 2>/dev/null | grep -q . && \
286
+ fail "disabled unmeasured OpenCode command left lease state"
287
+ done
288
+
289
+ case_root="$temp_root/disabled-measured"
290
+ project="$case_root/project"
291
+ decoy="$project-decoy"
292
+ state_root="$case_root/state"
293
+ mkdir -p "$project" "$decoy"
294
+ write_capability_config "$project" false true
295
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" admission disabled-measured 1)
296
+ assert_opencode_result "$result" blocked || fail "disabled measured OpenCode command did not return the fixed block"
297
+ rows=$(callback_rows "$project")
298
+ assert_value_free_rows "$rows" disabled || fail "disabled OpenCode command did not settle one value-free final"
299
+
300
+ for terminal in success error; do
301
+ case_root="$temp_root/$terminal"
302
+ project="$case_root/project"
303
+ decoy="$project-decoy"
304
+ state_root="$case_root/state"
305
+ mkdir -p "$project" "$decoy"
306
+ write_capability_config "$project" true true
307
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" "$terminal" "terminal-$terminal" 1)
308
+ assert_opencode_result "$result" allowed || fail "OpenCode $terminal terminal changed owner execution"
309
+ rows=$(callback_rows "$project")
310
+ assert_value_free_rows "$rows" "$terminal" || fail "OpenCode $terminal terminal did not settle exactly once"
311
+ done
312
+
313
+ case_root="$temp_root/non-awaited-success"
314
+ project="$case_root/project"
315
+ decoy="$project-decoy"
316
+ state_root="$case_root/state"
317
+ mkdir -p "$project" "$decoy"
318
+ write_capability_config "$project" true true
319
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" success non-awaited-success 1 1)
320
+ assert_opencode_result "$result" allowed || fail "non-awaited OpenCode terminal changed owner execution"
321
+ rows=$(callback_rows "$project")
322
+ assert_value_free_rows "$rows" success || fail "non-awaited OpenCode terminal did not settle before return"
323
+
324
+ for terminal in success error; do
325
+ case_root="$temp_root/duplicate-$terminal"
326
+ project="$case_root/project"
327
+ decoy="$project-decoy"
328
+ state_root="$case_root/state"
329
+ mkdir -p "$project" "$decoy"
330
+ write_capability_config "$project" true true
331
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" "duplicate-$terminal" "duplicate-$terminal" 1)
332
+ assert_opencode_result "$result" allowed || fail "duplicate OpenCode $terminal changed owner execution"
333
+ rows=$(callback_rows "$project")
334
+ assert_value_free_rows "$rows" "$terminal" || fail "duplicate OpenCode $terminal delivery was not idempotent"
335
+ done
336
+
337
+ case_root="$temp_root/settled-delete"
338
+ project="$case_root/project"
339
+ decoy="$project-decoy"
340
+ state_root="$case_root/state"
341
+ mkdir -p "$project" "$decoy"
342
+ write_capability_config "$project" true true
343
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" settled-then-delete settled-delete 1)
344
+ assert_opencode_result "$result" allowed || fail "settled OpenCode command changed owner execution"
345
+ rows=$(callback_rows "$project")
346
+ assert_value_free_rows "$rows" success || fail "session deletion replaced a settled OpenCode terminal"
347
+
348
+ case_root="$temp_root/abandonment"
349
+ project="$case_root/project"
350
+ decoy="$project-decoy"
351
+ state_root="$case_root/state"
352
+ mkdir -p "$project" "$decoy"
353
+ write_capability_config "$project" true true
354
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" abandonment abandonment 1)
355
+ assert_opencode_result "$result" allowed || fail "OpenCode abandonment changed owner execution"
356
+ [[ "$(callback_rows "$project")" == '[]' ]] || fail "OpenCode abandonment created a final callback"
357
+ find "$state_root/capability-leases-v1" -type f -print -quit 2>/dev/null | grep -q . && \
358
+ fail "OpenCode abandonment left an unfinished recoverable lease"
359
+
360
+ case_root="$temp_root/cwd-drift"
361
+ project="$case_root/project"
362
+ decoy="$project-decoy"
363
+ state_root="$case_root/state"
364
+ mkdir -p "$project" "$decoy"
365
+ write_capability_config "$project" true true
366
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" cwd-drift-success cwd-drift 1)
367
+ assert_opencode_result "$result" allowed || fail "OpenCode cwd drift changed owner execution"
368
+ rows=$(callback_rows "$project")
369
+ assert_value_free_rows "$rows" success || fail "OpenCode cwd drift rebound immutable project identity"
370
+ [[ "$(callback_rows "$decoy")" == '[]' ]] || fail "OpenCode cwd drift wrote callback state to the decoy project"
371
+
372
+ echo "PASS: OpenCode native capability plugin"
373
+ }
374
+
375
+ assert_dispose_completed() {
376
+ local result="$1"
377
+ node - "$result" <<'NODE'
378
+ const value = JSON.parse(process.argv[2]);
379
+ if (!value.dispose_present) process.exit(1);
380
+ const disposal = value.calls.filter((call) => call.boundary === "dispose");
381
+ if (disposal.length !== 1 || disposal[0].result !== "completed") process.exit(1);
382
+ NODE
383
+ }
384
+
385
+ write_disposal_coordinator_fixture() {
386
+ local fixture_root="$1"
387
+ mkdir -p "$fixture_root/mcp-memory-server/dist"
388
+ cat > "$fixture_root/mcp-memory-server/dist/capability-cli.js" <<'NODE'
389
+ #!/usr/bin/env node
390
+ const fs = require("node:fs");
391
+ const operation = process.argv[2];
392
+ const chunks = [];
393
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
394
+ process.stdin.on("end", () => {
395
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
396
+ fs.appendFileSync(process.env.CAIRN_BOUNDARY_TRACE, `${JSON.stringify({ operation, payload })}\n`);
397
+ const respond = () => {
398
+ if (operation === "harness-before") process.stdout.write('{"schema_version":1,"decision":"allow"}');
399
+ else process.stdout.write('{"schema_version":1,"finalized":true}');
400
+ };
401
+ if (operation === "harness-terminal" && process.env.CAIRN_TEST_HOLD_TERMINAL === "1") {
402
+ const timer = setInterval(() => {
403
+ if (!fs.existsSync(process.env.CAIRN_TEST_RELEASE_FILE)) return;
404
+ clearInterval(timer);
405
+ respond();
406
+ }, 10);
407
+ return;
408
+ }
409
+ respond();
410
+ });
411
+ NODE
412
+ chmod 700 "$fixture_root/mcp-memory-server/dist/capability-cli.js"
413
+ }
414
+
415
+ assert_disposal_trace() {
416
+ local trace="$1"
417
+ local scenario="$2"
418
+ node - "$trace" "$scenario" <<'NODE'
419
+ const fs = require("node:fs");
420
+ const [tracePath, scenario] = process.argv.slice(2);
421
+ const raw = fs.readFileSync(tracePath, "utf8").trim();
422
+ const rows = raw ? raw.split(/\n/).map(JSON.parse) : [];
423
+ const expected = scenario === "dispose-terminal-race"
424
+ ? ["harness-before", "harness-terminal"]
425
+ : ["harness-before", "harness-terminal", "harness-before"];
426
+ if (rows.map(({ operation }) => operation).join(",") !== expected.join(",")) process.exit(1);
427
+ for (const row of rows) {
428
+ const keys = Object.keys(row.payload).sort().join(",");
429
+ const allowed = row.operation === "harness-before"
430
+ ? "command,harness,project_root,schema_version,session_id"
431
+ : "harness,outcome,schema_version,session_id";
432
+ if (keys !== allowed) process.exit(1);
433
+ if (row.payload.harness !== "opencode" || row.payload.schema_version !== 1) process.exit(1);
434
+ }
435
+ const terminals = rows.filter(({ operation }) => operation === "harness-terminal");
436
+ if (terminals.length !== 1 || terminals[0].payload.outcome === "abandoned") process.exit(1);
437
+ if (scenario === "dispose-terminal-race" && terminals[0].payload.outcome !== "success") process.exit(1);
438
+ if (scenario === "terminal-next-admission" && terminals[0].payload.outcome !== "error") process.exit(1);
439
+ NODE
440
+ }
441
+
442
+ opencode_disposal() {
443
+ local temp_root plugin result case_root project state_root rows fixture_root trace release scenario expected
444
+ temp_root=$(mktemp -d)
445
+ trap "rm -rf '$temp_root'" EXIT
446
+ plugin="$temp_root/capability-command.ts"
447
+
448
+ validate_fixture
449
+ sed "s|@@INFRA_ROOT@@|$ROOT|g" "$OPENCODE_PLUGIN" > "$plugin"
450
+ mkdir -p "$temp_root/contract-project"
451
+ result=$(node --experimental-strip-types "$OPENCODE_HARNESS" \
452
+ "$plugin" "$temp_root/contract-project" "$FIXTURE" dispose-contract)
453
+ if ! node - "$result" <<'NODE'
454
+ const value = JSON.parse(process.argv[2]);
455
+ process.exit(value.dispose_present ? 0 : 1);
456
+ NODE
457
+ then
458
+ echo "PHASE18_RED:OPENCODE_DISPOSAL"
459
+ return 86
460
+ fi
461
+ assert_dispose_completed "$result" || fail "OpenCode dispose contract did not complete"
462
+
463
+ for scenario in dispose-unfinished dispose-multiple; do
464
+ case_root="$temp_root/$scenario"
465
+ project="$case_root/project"
466
+ state_root="$case_root/state"
467
+ mkdir -p "$project"
468
+ write_capability_config "$project" true true
469
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" "$scenario" "$scenario" 1)
470
+ assert_dispose_completed "$result" || fail "$scenario did not invoke native disposal"
471
+ [[ "$(callback_rows "$project")" == '[]' ]] || fail "$scenario appended an abandonment final"
472
+ [[ "$(pending_rows "$project")" == '[]' ]] || fail "$scenario left pending issuance"
473
+ find "$state_root/capability-leases-v1" -type f -print -quit 2>/dev/null | grep -q . && \
474
+ fail "$scenario left a recoverable lease"
475
+ done
476
+
477
+ for scenario in dispose-settled-success dispose-settled-error; do
478
+ case_root="$temp_root/$scenario"
479
+ project="$case_root/project"
480
+ state_root="$case_root/state"
481
+ mkdir -p "$project"
482
+ write_capability_config "$project" true true
483
+ result=$(run_opencode_scenario "$plugin" "$project" "$state_root" "$scenario" "$scenario" 1)
484
+ assert_dispose_completed "$result" || fail "$scenario did not invoke native disposal"
485
+ rows=$(callback_rows "$project")
486
+ [[ "$scenario" == dispose-settled-success ]] && expected=success || expected=error
487
+ assert_value_free_rows "$rows" "$expected" || fail "$scenario rewrote or duplicated its settled final"
488
+ [[ "$(pending_rows "$project")" == '[]' ]] || fail "$scenario left pending issuance"
489
+ find "$state_root/capability-leases-v1" -type f -print -quit 2>/dev/null | grep -q . && \
490
+ fail "$scenario left a recoverable lease"
491
+ done
492
+
493
+ fixture_root="$temp_root/fixture-infra"
494
+ write_disposal_coordinator_fixture "$fixture_root"
495
+ sed "s|@@INFRA_ROOT@@|$fixture_root|g" "$OPENCODE_PLUGIN" > "$plugin"
496
+ project="$temp_root/mock-project"
497
+ mkdir -p "$project"
498
+
499
+ trace="$temp_root/master-off.trace"
500
+ : > "$trace"
501
+ result=$(CAIRN_CAPABILITY_CONTRACT=0 CAIRN_BOUNDARY_TRACE="$trace" \
502
+ CAIRN_TEST_SESSION=dispose-master-off \
503
+ node --experimental-strip-types "$OPENCODE_HARNESS" \
504
+ "$plugin" "$project" "$FIXTURE" dispose-master-off)
505
+ assert_dispose_completed "$result" || fail "master-off disposal did not return cleanly"
506
+ [[ ! -s "$trace" ]] || fail "master-off disposal invoked the coordinator"
507
+
508
+ for scenario in dispose-terminal-race terminal-next-admission; do
509
+ trace="$temp_root/$scenario.trace"
510
+ release="$temp_root/$scenario.release"
511
+ : > "$trace"
512
+ rm -f "$release"
513
+ result=$(CAIRN_CAPABILITY_CONTRACT=1 CAIRN_BOUNDARY_TRACE="$trace" \
514
+ CAIRN_TEST_HOLD_TERMINAL=1 CAIRN_TEST_RELEASE_FILE="$release" \
515
+ CAIRN_TEST_SESSION="$scenario" \
516
+ node --experimental-strip-types "$OPENCODE_HARNESS" \
517
+ "$plugin" "$project" "$FIXTURE" "$scenario")
518
+ if [[ "$scenario" == dispose-terminal-race ]]; then
519
+ assert_dispose_completed "$result" || fail "terminal/dispose race did not complete disposal"
520
+ fi
521
+ assert_disposal_trace "$trace" "$scenario" || fail "$scenario violated per-session ordering"
522
+ done
523
+
524
+ echo "PASS: OpenCode disposal and per-session ordering contract"
525
+ }
526
+
527
+ plugin_tree_digest() {
528
+ local live_root="$1"
529
+ (
530
+ cd "$live_root"
531
+ find plugins -type f -print | LC_ALL=C sort | while IFS= read -r asset; do
532
+ sha256sum "$asset"
533
+ done
534
+ )
535
+ }
536
+
537
+ assert_legacy_opencode_plugins() {
538
+ local live_root="$1"
539
+ local asset
540
+ for asset in memory-wakeup.ts memory-capture.ts memory-recall.ts; do
541
+ [[ -f "$live_root/plugins/$asset" ]] || fail "legacy OpenCode plugin is missing: $asset"
542
+ cmp -s <(sed "s|@@INFRA_ROOT@@|$ROOT|g" "$ROOT/opencode/plugins/$asset") \
543
+ "$live_root/plugins/$asset" || fail "legacy OpenCode plugin changed: $asset"
544
+ done
545
+ }
546
+
547
+ opencode_sync_modes() {
548
+ local temp_root normal_root enabled_root before after plugin_count
549
+ temp_root=$(mktemp -d)
550
+ trap "rm -rf '$temp_root'" EXIT
551
+ normal_root="$temp_root/normal"
552
+ enabled_root="$temp_root/enabled"
553
+
554
+ env -u CAIRN_CAPABILITY_CONTRACT \
555
+ CAIRN_HARNESS_STATE_DIR="$temp_root/normal-state" \
556
+ "$ROOT/scripts/sync-opencode-plugin-assets.sh" --apply --live-root "$normal_root" >/dev/null
557
+ assert_legacy_opencode_plugins "$normal_root"
558
+ assert_no_native_opencode_assets "$normal_root"
559
+ before=$(plugin_tree_digest "$normal_root")
560
+ [[ ! -e "$temp_root/normal-state" ]] || fail "normal OpenCode sync invoked capability state"
561
+
562
+ env -u CAIRN_CAPABILITY_CONTRACT \
563
+ CAIRN_HARNESS_STATE_DIR="$temp_root/master-off-state" \
564
+ "$ROOT/scripts/sync-opencode-plugin-assets.sh" --apply --capability-overlay --live-root "$normal_root" >/dev/null
565
+ after=$(plugin_tree_digest "$normal_root")
566
+ [[ "$after" == "$before" ]] || fail "master-off OpenCode overlay changed legacy plugin bytes"
567
+ assert_no_native_opencode_assets "$normal_root"
568
+ [[ ! -e "$temp_root/master-off-state" ]] || fail "master-off OpenCode overlay invoked capability state"
569
+
570
+ CAIRN_CAPABILITY_CONTRACT=1 \
571
+ CAIRN_HARNESS_STATE_DIR="$temp_root/enabled-state" \
572
+ "$ROOT/scripts/sync-opencode-plugin-assets.sh" --apply --capability-overlay --live-root "$enabled_root" >/dev/null
573
+ assert_legacy_opencode_plugins "$enabled_root"
574
+ plugin_count=$(find "$enabled_root/plugins" -maxdepth 1 -type f -name '*.ts' | wc -l | tr -d ' ')
575
+ [[ "$plugin_count" -eq 4 ]] || fail "enabled OpenCode overlay did not install exactly one additional plugin"
576
+ [[ -f "$enabled_root/plugins/capability-command.ts" ]] || fail "enabled OpenCode overlay did not register the capability plugin"
577
+ cmp -s <(sed "s|@@INFRA_ROOT@@|$ROOT|g" "$OPENCODE_PLUGIN") \
578
+ "$enabled_root/plugins/capability-command.ts" || fail "enabled OpenCode capability plugin bytes do not match source"
579
+ grep -qF 'export const CapabilityCommandPlugin' "$enabled_root/plugins/capability-command.ts" || \
580
+ fail "enabled OpenCode capability plugin registration is absent"
581
+ [[ ! -e "$temp_root/enabled-state" ]] || fail "enabled OpenCode sync invoked the capability coordinator"
582
+
583
+ CAIRN_CAPABILITY_CONTRACT=1 \
584
+ "$ROOT/scripts/sync-opencode-plugin-assets.sh" --check --capability-overlay --live-root "$enabled_root" >/dev/null
585
+
586
+ echo "PASS: OpenCode capability plugin sync modes"
587
+ }
588
+
589
+ claude_hooks() {
590
+ local temp_root project decoy state_root start_hook finish_hook stdout_file stderr_file status payload rows
591
+ temp_root=$(mktemp -d)
592
+ trap "rm -rf '$temp_root'" EXIT
593
+ project="$temp_root/project"
594
+ decoy="$temp_root/decoy"
595
+ state_root="$temp_root/state"
596
+ start_hook="$temp_root/capability-command-start.sh"
597
+ finish_hook="$temp_root/capability-command-finish.sh"
598
+ stdout_file="$temp_root/stdout"
599
+ stderr_file="$temp_root/stderr"
600
+ mkdir -p "$project" "$decoy"
601
+
602
+ validate_fixture
603
+ [[ -f "$CLAUDE_START" ]] || fail "Claude capability start hook is absent"
604
+ [[ -f "$CLAUDE_FINISH" ]] || fail "Claude capability finish hook is absent"
605
+ install_claude_hook_fixture "$CLAUDE_START" "$start_hook"
606
+ install_claude_hook_fixture "$CLAUDE_FINISH" "$finish_hook"
607
+
608
+ status=0
609
+ run_claude_hook "$start_hook" "$project" "$state_root" '{bad-json' "$stdout_file" "$stderr_file" || status=$?
610
+ assert_fixed_block "$stdout_file" "$stderr_file" "$status"
611
+
612
+ payload=$(node - "$FIXTURE" "$project" <<'NODE'
613
+ const fixture = require(process.argv[2]);
614
+ const value = structuredClone(fixture.claude.events.UserPromptExpansion.sample);
615
+ value.cwd = process.argv[3];
616
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
617
+ value.command_name = "wiki-query";
618
+ value.session_id = "claude-enabled-no-measurement";
619
+ process.stdout.write(JSON.stringify(value));
620
+ NODE
621
+ )
622
+ status=0
623
+ run_claude_hook "$start_hook" "$decoy" "$state_root" "$payload" "$stdout_file" "$stderr_file" || status=$?
624
+ assert_fixed_block "$stdout_file" "$stderr_file" "$status"
625
+
626
+ write_capability_config "$project" true false
627
+ run_claude_hook "$start_hook" "$project" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || \
628
+ fail "enabled unmeasured Claude command was changed"
629
+ [[ "$(cat "$stdout_file")" == '{}' ]] || fail "enabled Claude command was not passed through unchanged"
630
+ [[ "$(callback_rows "$project")" == '[]' ]] || fail "enabled unmeasured Claude command created callback state"
631
+ [[ ! -e "$state_root/capability-leases-v1" ]] || fail "enabled unmeasured Claude command created lease state"
632
+
633
+ for consent in false:1 true:0; do
634
+ local logging=${consent%%:*}
635
+ local trajectory=${consent#*:}
636
+ write_capability_config "$project" false "$logging"
637
+ payload=$(node - "$FIXTURE" "$project" "$logging" <<'NODE'
638
+ const fixture = require(process.argv[2]);
639
+ const value = structuredClone(fixture.claude.events.UserPromptExpansion.sample);
640
+ value.cwd = process.argv[3];
641
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
642
+ value.session_id = `claude-disabled-unmeasured-${process.argv[4]}`;
643
+ process.stdout.write(JSON.stringify(value));
644
+ NODE
645
+ )
646
+ status=0
647
+ run_claude_hook "$start_hook" "$project" "$state_root" "$payload" "$stdout_file" "$stderr_file" "$trajectory" || status=$?
648
+ assert_fixed_block "$stdout_file" "$stderr_file" "$status"
649
+ [[ "$(callback_rows "$project")" == '[]' ]] || fail "disabled unmeasured Claude command created callback state"
650
+ done
651
+
652
+ write_capability_config "$project" false true
653
+ payload=$(node - "$FIXTURE" "$project" <<'NODE'
654
+ const fixture = require(process.argv[2]);
655
+ const value = structuredClone(fixture.claude.events.UserPromptExpansion.sample);
656
+ value.cwd = process.argv[3];
657
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
658
+ value.session_id = "claude-disabled-measured";
659
+ process.stdout.write(JSON.stringify(value));
660
+ NODE
661
+ )
662
+ status=0
663
+ run_claude_hook "$start_hook" "$project" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || status=$?
664
+ assert_fixed_block "$stdout_file" "$stderr_file" "$status"
665
+ rows=$(callback_rows "$project")
666
+ assert_value_free_rows "$rows" disabled || fail "disabled Claude command did not settle one value-free final"
667
+
668
+ write_capability_config "$project" true true
669
+ for terminal in Stop StopFailure; do
670
+ local session
671
+ case "$terminal" in
672
+ Stop) session="claude-stop" ;;
673
+ StopFailure) session="claude-stopfailure" ;;
674
+ esac
675
+ payload=$(node - "$FIXTURE" "$project" "$session" <<'NODE'
676
+ const fixture = require(process.argv[2]);
677
+ const value = structuredClone(fixture.claude.events.UserPromptExpansion.sample);
678
+ value.cwd = process.argv[3];
679
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
680
+ value.session_id = process.argv[4];
681
+ process.stdout.write(JSON.stringify(value));
682
+ NODE
683
+ )
684
+ run_claude_hook "$start_hook" "$project" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || \
685
+ fail "Claude start failed for $terminal"
686
+ payload=$(node - "$FIXTURE" "$project" "$session" "$terminal" <<'NODE'
687
+ const fixture = require(process.argv[2]);
688
+ const value = structuredClone(fixture.claude.events[process.argv[5]].sample);
689
+ value.cwd = process.argv[3];
690
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
691
+ value.session_id = process.argv[4];
692
+ process.stdout.write(JSON.stringify(value));
693
+ NODE
694
+ )
695
+ run_claude_hook "$finish_hook" "$project" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || \
696
+ fail "Claude $terminal hook changed the owner terminal"
697
+ run_claude_hook "$finish_hook" "$project" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || \
698
+ fail "repeated Claude $terminal hook changed the owner terminal"
699
+ payload=$(node - "$FIXTURE" "$project" "$session" <<'NODE'
700
+ const fixture = require(process.argv[2]);
701
+ const value = structuredClone(fixture.claude.events.SessionEnd.sample);
702
+ value.cwd = process.argv[3];
703
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
704
+ value.session_id = process.argv[4];
705
+ process.stdout.write(JSON.stringify(value));
706
+ NODE
707
+ )
708
+ run_claude_hook "$finish_hook" "$project" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || \
709
+ fail "SessionEnd changed a settled $terminal owner terminal"
710
+ done
711
+ rows=$(callback_rows "$project")
712
+ node - "$rows" <<'NODE' || fail "Claude terminal hooks did not settle exact success/error outcomes"
713
+ const rows = JSON.parse(process.argv[2]);
714
+ const outcomes = rows.map((row) => row.outcome).sort();
715
+ if (outcomes.join(",") !== "disabled,error,success") process.exit(1);
716
+ NODE
717
+
718
+ payload=$(node - "$FIXTURE" "$project" <<'NODE'
719
+ const fixture = require(process.argv[2]);
720
+ const value = structuredClone(fixture.claude.events.UserPromptExpansion.sample);
721
+ value.cwd = process.argv[3];
722
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
723
+ value.session_id = "claude-abandon";
724
+ process.stdout.write(JSON.stringify(value));
725
+ NODE
726
+ )
727
+ run_claude_hook "$start_hook" "$project" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || fail "Claude abandonment start failed"
728
+ payload=$(node - "$FIXTURE" "$project" "$decoy" <<'NODE'
729
+ const fixture = require(process.argv[2]);
730
+ const value = structuredClone(fixture.claude.events.CwdChanged.sample);
731
+ value.cwd = process.argv[3];
732
+ value.old_cwd = process.argv[3];
733
+ value.new_cwd = process.argv[4];
734
+ value.session_id = "claude-abandon";
735
+ process.stdout.write(JSON.stringify(value));
736
+ NODE
737
+ )
738
+ run_claude_hook "$finish_hook" "$decoy" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || fail "Claude CwdChanged observation failed"
739
+ payload=$(node - "$FIXTURE" "$decoy" <<'NODE'
740
+ const fixture = require(process.argv[2]);
741
+ const value = structuredClone(fixture.claude.events.SessionEnd.sample);
742
+ value.cwd = process.argv[3];
743
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
744
+ value.session_id = "claude-abandon";
745
+ process.stdout.write(JSON.stringify(value));
746
+ NODE
747
+ )
748
+ run_claude_hook "$finish_hook" "$decoy" "$state_root" "$payload" "$stdout_file" "$stderr_file" 1 || fail "Claude SessionEnd cleanup failed"
749
+ [[ "$(callback_rows "$project")" == "$rows" ]] || fail "SessionEnd rewrote a settled terminal or emitted an abandonment final"
750
+ find "$state_root/capability-leases-v1" -type f -print -quit 2>/dev/null | grep -q . && fail "SessionEnd left an unfinished recoverable lease"
751
+
752
+ echo "PASS: Claude native capability hooks"
753
+ }
754
+
755
+ claude_owner_only() {
756
+ [[ ! -e "$ROOT/claude/hooks/capability-command-start.sh" ]] || fail "capability start hook entered the normal hook tree"
757
+ [[ ! -e "$ROOT/claude/hooks/capability-command-finish.sh" ]] || fail "capability finish hook entered the normal hook tree"
758
+ grep -qF 'harness-before' "$CLAUDE_START" || fail "Claude admission is not owned by the native coordinator"
759
+ grep -qF 'harness-terminal' "$CLAUDE_FINISH" || fail "Claude terminal settlement is not owned by the native coordinator"
760
+ if grep -q -E 'cairn capabilities (guard|start|finish)' "$CLAUDE_START" "$CLAUDE_FINISH"; then
761
+ fail "Claude native hooks delegate through obsolete model-authored operations"
762
+ fi
763
+ claude_hooks
764
+ echo "PASS: Claude native hook owner is isolated from the normal hook tree"
765
+ }
766
+
767
+ opencode_command_owner_only() {
768
+ local temp_root command
769
+ local -a commands=(wiki-ingest wiki-lint wiki-query graphify security-audit)
770
+
771
+ [[ -f "$OPENCODE_PLUGIN" ]] || fail "OpenCode native capability plugin is absent"
772
+ grep -qF '"command.execute.before"' "$OPENCODE_PLUGIN" || \
773
+ fail "OpenCode native plugin does not own pre-expansion admission"
774
+ grep -qF 'harness-before' "$OPENCODE_PLUGIN" || \
775
+ fail "OpenCode native admission does not delegate through the coordinator"
776
+
777
+ for command in "${commands[@]}"; do
778
+ [[ ! -e "$ROOT/opencode/capability-contract/command/$command.md" ]] || \
779
+ fail "OpenCode capability command overlay remains: $command"
780
+ [[ -f "$ROOT/opencode/command/$command.md" ]] || \
781
+ fail "legacy OpenCode command is missing: $command"
782
+ done
783
+ if grep -q -E 'cairn capabilities (guard|start|finish)|capability_handle|capability-overlay' \
784
+ "$ROOT/opencode/command/wiki-ingest.md" \
785
+ "$ROOT/opencode/command/wiki-lint.md" \
786
+ "$ROOT/opencode/command/wiki-query.md" \
787
+ "$ROOT/opencode/command/graphify.md" \
788
+ "$ROOT/opencode/command/security-audit.md"; then
789
+ fail "legacy OpenCode command contains lifecycle enforcement"
790
+ fi
791
+
792
+ temp_root=$(mktemp -d)
793
+ CAIRN_CAPABILITY_CONTRACT=1 \
794
+ "$ROOT/scripts/sync-opencode-wiki-assets.sh" --apply --capability-overlay --live-root "$temp_root" >/dev/null
795
+ CAIRN_CAPABILITY_CONTRACT=1 \
796
+ "$ROOT/scripts/sync-opencode-graphify-assets.sh" --apply --capability-overlay --live-root "$temp_root" >/dev/null
797
+ CAIRN_CAPABILITY_CONTRACT=1 \
798
+ "$ROOT/scripts/sync-opencode-security-assets.sh" --apply --capability-overlay --live-root "$temp_root" >/dev/null
799
+ for command in "${commands[@]}"; do
800
+ cmp -s "$ROOT/opencode/command/$command.md" "$temp_root/command/$command.md" || {
801
+ rm -rf "$temp_root"
802
+ fail "enabled OpenCode sync changed the legacy command: $command"
803
+ }
804
+ done
805
+ rm -rf "$temp_root"
806
+
807
+ opencode_plugin
808
+ echo "PASS: OpenCode native plugin is the sole command lifecycle owner"
809
+ }
810
+
811
+ opencode_owner_only() {
812
+ local temp_root workflow
813
+ local -a workflows=(wiki-ingest-workflow wiki-lint-workflow wiki-query-workflow security-audit-workflow)
814
+
815
+ opencode_command_owner_only
816
+ for workflow in "${workflows[@]}"; do
817
+ [[ ! -e "$ROOT/opencode/capability-contract/workflows/$workflow.md" ]] || \
818
+ fail "OpenCode capability workflow overlay remains: $workflow"
819
+ [[ -f "$ROOT/opencode/workflows/$workflow.md" ]] || \
820
+ fail "legacy OpenCode workflow is missing: $workflow"
821
+ done
822
+ if grep -q -E 'cairn capabilities (guard|start|finish)|capability_handle|capability-overlay' \
823
+ "$ROOT/opencode/workflows/wiki-ingest-workflow.md" \
824
+ "$ROOT/opencode/workflows/wiki-lint-workflow.md" \
825
+ "$ROOT/opencode/workflows/wiki-query-workflow.md" \
826
+ "$ROOT/opencode/workflows/security-audit-workflow.md"; then
827
+ fail "legacy OpenCode workflow contains lifecycle enforcement"
828
+ fi
829
+
830
+ temp_root=$(mktemp -d)
831
+ CAIRN_CAPABILITY_CONTRACT=1 \
832
+ "$ROOT/scripts/sync-opencode-wiki-assets.sh" --apply --capability-overlay --live-root "$temp_root" >/dev/null
833
+ CAIRN_CAPABILITY_CONTRACT=1 \
834
+ "$ROOT/scripts/sync-opencode-security-assets.sh" --apply --capability-overlay --live-root "$temp_root" >/dev/null
835
+ for workflow in "${workflows[@]}"; do
836
+ cmp -s "$ROOT/opencode/workflows/$workflow.md" "$temp_root/workflows/$workflow.md" || {
837
+ rm -rf "$temp_root"
838
+ fail "enabled OpenCode sync changed the legacy workflow: $workflow"
839
+ }
840
+ done
841
+ rm -rf "$temp_root"
842
+
843
+ echo "PASS: OpenCode native plugin is the sole lifecycle owner"
844
+ }
845
+
846
+ write_boundary_coordinator_fixture() {
847
+ local fixture_root="$1"
848
+ mkdir -p "$fixture_root/mcp-memory-server/dist"
849
+ cat > "$fixture_root/mcp-memory-server/dist/capability-cli.js" <<'NODE'
850
+ #!/usr/bin/env node
851
+ const fs = require("node:fs");
852
+ const operation = process.argv[2];
853
+ const chunks = [];
854
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
855
+ process.stdin.on("end", () => {
856
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
857
+ fs.appendFileSync(process.env.CAIRN_BOUNDARY_TRACE, `${JSON.stringify({ operation, payload })}\n`);
858
+ if (operation === "harness-before") {
859
+ process.stdout.write('{"schema_version":1,"decision":"allow"}');
860
+ } else {
861
+ process.stdout.write('{"schema_version":1,"finalized":true}');
862
+ }
863
+ });
864
+ NODE
865
+ chmod 700 "$fixture_root/mcp-memory-server/dist/capability-cli.js"
866
+ }
867
+
868
+ append_owner_expansion() {
869
+ local trace="$1"
870
+ local harness="$2"
871
+ local outcome="$3"
872
+ printf '{"operation":"owner-expanded","payload":{"harness":"%s","outcome":"%s"}}\n' \
873
+ "$harness" "$outcome" >> "$trace"
874
+ }
875
+
876
+ assert_boundary_trace() {
877
+ local trace="$1"
878
+ local harness="$2"
879
+ local outcome="$3"
880
+ node - "$trace" "$harness" "$outcome" <<'NODE' || fail "$harness $outcome delegate order is not observable"
881
+ const fs = require("node:fs");
882
+ const [tracePath, harness, outcome] = process.argv.slice(2);
883
+ const rows = fs.readFileSync(tracePath, "utf8").trim().split(/\n/).map(JSON.parse);
884
+ if (rows.length !== 3) process.exit(1);
885
+ if (rows[0].operation !== "harness-before" || rows[0].payload?.harness !== harness) process.exit(1);
886
+ if (rows[1].operation !== "owner-expanded" || rows[1].payload?.harness !== harness) process.exit(1);
887
+ if (rows[2].operation !== "harness-terminal" || rows[2].payload?.harness !== harness) process.exit(1);
888
+ if (rows[1].payload?.outcome !== outcome || rows[2].payload?.outcome !== outcome) process.exit(1);
889
+ if (rows[0].payload?.session_id !== rows[2].payload?.session_id) process.exit(1);
890
+ if (typeof rows[0].payload?.command !== "string" || "command" in rows[2].payload) process.exit(1);
891
+ NODE
892
+ }
893
+
894
+ claude_delegate_calls() {
895
+ local temp_root fixture_root start_hook finish_hook project trace payload stdout_file stderr_file outcome event status
896
+ temp_root=$(mktemp -d)
897
+ fixture_root="$temp_root/fixture-infra"
898
+ start_hook="$temp_root/capability-command-start.sh"
899
+ finish_hook="$temp_root/capability-command-finish.sh"
900
+ stdout_file="$temp_root/stdout"
901
+ stderr_file="$temp_root/stderr"
902
+ write_boundary_coordinator_fixture "$fixture_root"
903
+ sed "s|@@INFRA_ROOT@@|$fixture_root|g" "$CLAUDE_START" > "$start_hook"
904
+ sed "s|@@INFRA_ROOT@@|$fixture_root|g" "$CLAUDE_FINISH" > "$finish_hook"
905
+ chmod 700 "$start_hook" "$finish_hook"
906
+
907
+ for outcome in success error; do
908
+ project="$temp_root/project-$outcome"
909
+ trace="$temp_root/claude-$outcome.jsonl"
910
+ mkdir -p "$project"
911
+ : > "$trace"
912
+ payload=$(node - "$FIXTURE" "$project" "$outcome" <<'NODE'
913
+ const fixture = require(process.argv[2]);
914
+ const value = structuredClone(fixture.claude.events.UserPromptExpansion.sample);
915
+ value.cwd = process.argv[3];
916
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
917
+ value.command_name = "wiki-query";
918
+ value.session_id = `claude-boundary-${process.argv[4]}`;
919
+ process.stdout.write(JSON.stringify(value));
920
+ NODE
921
+ )
922
+ status=0
923
+ CAIRN_BOUNDARY_TRACE="$trace" run_claude_hook \
924
+ "$start_hook" "$project" "$temp_root/state-$outcome" "$payload" "$stdout_file" "$stderr_file" 1 || status=$?
925
+ [[ "$status" -eq 0 && "$(cat "$stdout_file")" == '{}' && ! -s "$stderr_file" ]] || \
926
+ fail "Claude $outcome admission did not reach the coordinator"
927
+ append_owner_expansion "$trace" claude-code "$outcome"
928
+ [[ "$outcome" == success ]] && event=Stop || event=StopFailure
929
+ payload=$(node - "$FIXTURE" "$project" "$outcome" "$event" <<'NODE'
930
+ const fixture = require(process.argv[2]);
931
+ const value = structuredClone(fixture.claude.events[process.argv[5]].sample);
932
+ value.cwd = process.argv[3];
933
+ value.transcript_path = `${process.argv[3]}/transcript.jsonl`;
934
+ value.session_id = `claude-boundary-${process.argv[4]}`;
935
+ process.stdout.write(JSON.stringify(value));
936
+ NODE
937
+ )
938
+ CAIRN_BOUNDARY_TRACE="$trace" run_claude_hook \
939
+ "$finish_hook" "$project" "$temp_root/state-$outcome" "$payload" "$stdout_file" "$stderr_file" 1 || \
940
+ fail "Claude $event changed the terminal path"
941
+ assert_boundary_trace "$trace" claude-code "$outcome"
942
+ done
943
+ rm -rf "$temp_root"
944
+ echo "PASS: Claude hooks delegate admission before owner expansion and terminal settlement after it"
945
+ }
946
+
947
+ opencode_delegate_calls() {
948
+ local temp_root fixture_root plugin project trace outcome
949
+ temp_root=$(mktemp -d)
950
+ fixture_root="$temp_root/fixture-infra"
951
+ plugin="$temp_root/capability-command.ts"
952
+ write_boundary_coordinator_fixture "$fixture_root"
953
+ sed "s|@@INFRA_ROOT@@|$fixture_root|g" "$OPENCODE_PLUGIN" > "$plugin"
954
+
955
+ for outcome in success error; do
956
+ project="$temp_root/project-$outcome"
957
+ trace="$temp_root/opencode-$outcome.jsonl"
958
+ mkdir -p "$project"
959
+ : > "$trace"
960
+ CAIRN_CAPABILITY_CONTRACT=1 CAIRN_BOUNDARY_TRACE="$trace" \
961
+ node --experimental-strip-types --input-type=module - \
962
+ "$plugin" "$project" "$FIXTURE" "$outcome" "$trace" <<'NODE' || \
963
+ fail "OpenCode $outcome fixture did not execute the native plugin"
964
+ import { appendFileSync, readFileSync } from "node:fs";
965
+ import { pathToFileURL } from "node:url";
966
+ const [pluginPath, project, fixturePath, outcome, trace] = process.argv.slice(2);
967
+ const fixture = JSON.parse(readFileSync(fixturePath, "utf8")).opencode;
968
+ if (fixture.version !== "1.17.20" || fixture.source.commit !== "4473fc3c9055046183990a965d68df3db7ea6f62") process.exit(1);
969
+ const module = await import(`${pathToFileURL(pluginPath).href}?boundary=${outcome}`);
970
+ const plugin = await module.CapabilityCommandPlugin({
971
+ client: {},
972
+ project: { id: "project-fixture", worktree: project },
973
+ directory: project,
974
+ worktree: project,
975
+ serverUrl: new URL("http://127.0.0.1:4096"),
976
+ $: undefined,
977
+ experimental_workspace: undefined,
978
+ });
979
+ const input = structuredClone(fixture.hook.input.sample);
980
+ const output = structuredClone(fixture.hook.output.sample);
981
+ input.command = "wiki-query";
982
+ input.sessionID = `opencode-boundary-${outcome}`;
983
+ await plugin[fixture.hook.name](input, output);
984
+ appendFileSync(trace, `${JSON.stringify({ operation: "owner-expanded", payload: { harness: "opencode", outcome } })}\n`);
985
+ const eventName = outcome === "success" ? "session.idle" : "session.error";
986
+ const event = structuredClone(fixture.events[eventName].sample);
987
+ event.properties.sessionID = input.sessionID;
988
+ await plugin.event({ event });
989
+ NODE
990
+ assert_boundary_trace "$trace" opencode "$outcome"
991
+ done
992
+ rm -rf "$temp_root"
993
+ echo "PASS: OpenCode 1.17.20 plugin delegates admission before owner expansion and terminal settlement after it"
994
+ }
995
+
996
+ evidence_scope() {
997
+ validate_fixture
998
+ claude_delegate_calls
999
+ opencode_delegate_calls
1000
+ printf '%s\n' 'DETERMINISTIC_BOUNDARY_EVIDENCE:{"schema_version":1,"harnesses":["claude-code","opencode"],"outcomes":["success","error"],"owner_execution":"simulated-after-admission","scope":"native-delegate-order","status":"pass"}'
1001
+ printf '%s\n' 'PHASE18_REQUIRED_LIVE_MATRIX:{"schema_version":1,"required_cells":56,"passing_cells":0,"status":"blocking","acceptance":false,"owner_execution":"not-claimed","replacement_plan":"18-27"}'
1002
+ }
1003
+
1004
+ live_claude() {
1005
+ command -v claude >/dev/null 2>&1 || fail "Claude live control unavailable: claude is not on PATH"
1006
+ [[ -n "${CAIRN_PHASE18_CLAUDE_MODEL:-}" ]] || fail "Claude live control unavailable: CAIRN_PHASE18_CLAUDE_MODEL is unset"
1007
+ [[ -n "${CAIRN_PHASE18_LIVE_PROJECT:-}" ]] || fail "Claude live control unavailable: CAIRN_PHASE18_LIVE_PROJECT is unset"
1008
+ [[ -d "$CAIRN_PHASE18_LIVE_PROJECT" ]] || fail "Claude live control project does not exist"
1009
+ [[ -f "$CLAUDE_START" && -f "$CLAUDE_FINISH" ]] || fail "Claude native hooks are not installed in the source tree"
1010
+ echo "LIVE_REQUIRED:CLAUDE_HAIKU_NEGATIVE_CONTROL"
1011
+ return 1
1012
+ }
1013
+
1014
+ live_opencode() {
1015
+ command -v opencode >/dev/null 2>&1 || fail "OpenCode live control unavailable: opencode is not on PATH"
1016
+ [[ -n "${CAIRN_PHASE18_OPENCODE_MODEL:-}" ]] || fail "OpenCode live control unavailable: CAIRN_PHASE18_OPENCODE_MODEL is unset"
1017
+ [[ -n "${CAIRN_PHASE18_LIVE_PROJECT:-}" ]] || fail "OpenCode live control unavailable: CAIRN_PHASE18_LIVE_PROJECT is unset"
1018
+ [[ -d "$CAIRN_PHASE18_LIVE_PROJECT" ]] || fail "OpenCode live control project does not exist"
1019
+ [[ -f "$OPENCODE_PLUGIN" ]] || fail "OpenCode native plugin is not installed in the source tree"
1020
+ echo "LIVE_REQUIRED:OPENCODE_QWEN_NEGATIVE_CONTROL"
1021
+ return 1
1022
+ }
1023
+
1024
+ mode="${1:-}"
1025
+ case "$mode" in
1026
+ "")
1027
+ echo "SKIP: select an explicit native-boundary evidence mode"
1028
+ ;;
1029
+ -h|--help)
1030
+ usage
1031
+ ;;
1032
+ --live-claude)
1033
+ live_claude
1034
+ ;;
1035
+ --live-opencode)
1036
+ live_opencode
1037
+ ;;
1038
+ claude-hooks)
1039
+ claude_hooks
1040
+ ;;
1041
+ claude-owner-only)
1042
+ claude_owner_only
1043
+ ;;
1044
+ opencode-plugin|opencode-disposal|opencode-sync-modes|opencode-command-owner-only|opencode-owner-only|claude-delegate-calls|opencode-delegate-calls|evidence-scope)
1045
+ case "$mode" in
1046
+ opencode-plugin) opencode_plugin ;;
1047
+ opencode-disposal) opencode_disposal ;;
1048
+ opencode-sync-modes) opencode_sync_modes ;;
1049
+ opencode-command-owner-only) opencode_command_owner_only ;;
1050
+ opencode-owner-only) opencode_owner_only ;;
1051
+ claude-delegate-calls) claude_delegate_calls ;;
1052
+ opencode-delegate-calls) opencode_delegate_calls ;;
1053
+ evidence-scope) evidence_scope ;;
1054
+ esac
1055
+ ;;
1056
+ *)
1057
+ usage >&2
1058
+ exit 2
1059
+ ;;
1060
+ esac