@cairnkeep/cli 2.3.0 → 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 +78 -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 +9 -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
@@ -9,28 +9,597 @@ trap 'rm -rf "$tmp"' EXIT
9
9
 
10
10
  fail() { echo "FAIL: $1" >&2; exit 1; }
11
11
 
12
+ write_recovery_runtime_fixture() {
13
+ local runtime_root="$1"
14
+ local name
15
+ mkdir -p "$runtime_root/bin" "$runtime_root/mcp-memory-server/dist"
16
+ for name in scripts claude opencode templates; do
17
+ ln -s "$ROOT/$name" "$runtime_root/$name"
18
+ done
19
+ for asset in "$ROOT"/mcp-memory-server/dist/*; do
20
+ name=${asset##*/}
21
+ [[ "$name" == capability-cli.js ]] || ln -s "$asset" "$runtime_root/mcp-memory-server/dist/$name"
22
+ done
23
+ cat > "$runtime_root/bin/cairn" <<'SH'
24
+ #!/usr/bin/env bash
25
+ exit 0
26
+ SH
27
+ chmod +x "$runtime_root/bin/cairn"
28
+ cat > "$runtime_root/mcp-memory-server/dist/capability-cli.js" <<NODE
29
+ #!/usr/bin/env node
30
+ const fs = require("node:fs");
31
+ const { spawnSync } = require("node:child_process");
32
+ const operation = process.argv[2] ?? "";
33
+ fs.appendFileSync(process.env.CAIRN_RECOVERY_TRACE, \`coordinator:\${operation}\\n\`);
34
+ if (operation === "harness-recover" && process.env.CAIRN_TEST_RECOVERY_FAIL === "1") process.exit(23);
35
+ const input = fs.readFileSync(0);
36
+ const result = spawnSync(process.execPath, ["$ROOT/mcp-memory-server/dist/capability-cli.js", ...process.argv.slice(2)], {
37
+ cwd: process.cwd(),
38
+ env: process.env,
39
+ input,
40
+ encoding: "utf8",
41
+ });
42
+ process.stdout.write(result.stdout ?? "");
43
+ process.stderr.write(result.stderr ?? "");
44
+ process.exit(result.status ?? 1);
45
+ NODE
46
+ chmod +x "$runtime_root/mcp-memory-server/dist/capability-cli.js"
47
+ }
48
+
49
+ write_recovery_harness_fixtures() {
50
+ local bin_root="$1"
51
+ local name
52
+ mkdir -p "$bin_root"
53
+ for name in claude opencode; do
54
+ cat > "$bin_root/$name" <<'SH'
55
+ #!/usr/bin/env bash
56
+ name=${0##*/}
57
+ if [[ "$name" == claude ]]; then config=${CLAUDE_CONFIG_DIR:-}; else config=${OPENCODE_CONFIG_DIR:-}; fi
58
+ printf 'harness:%s|args:%s|config:%s\n' "$name" "$*" "$config" >> "$CAIRN_RECOVERY_TRACE"
59
+ exit "${FAKE_EXIT:-0}"
60
+ SH
61
+ chmod +x "$bin_root/$name"
62
+ done
63
+ }
64
+
65
+ write_recovery_config() {
66
+ local project="$1"
67
+ mkdir -p "$project/.ai"
68
+ printf '%s\n' '{"schema_version":1,"capabilities":{"wiki":true},"logging":{"callbacks":true}}' \
69
+ > "$project/.ai/capabilities.json"
70
+ chmod 600 "$project/.ai/capabilities.json"
71
+ }
72
+
73
+ create_recovery_lease() {
74
+ local project="$1"
75
+ local state_root="$2"
76
+ local harness="$3"
77
+ local session="$4"
78
+ local output
79
+ output=$(
80
+ cd "$project"
81
+ printf '%s' "{\"schema_version\":1,\"harness\":\"$harness\",\"command\":\"wiki-query\",\"session_id\":\"$session\",\"project_root\":\"$project\"}" | \
82
+ env CAIRN_CAPABILITY_CONTRACT=1 CAIRN_CAPABILITY_LOGGING=1 CAIRN_TRAJECTORY_CAPTURE=1 \
83
+ CAIRN_HARNESS_STATE_DIR="$state_root" \
84
+ node "$ROOT/mcp-memory-server/dist/capability-cli.js" harness-before
85
+ )
86
+ [[ "$output" == '{"schema_version":1,"decision":"allow"}' ]] || fail "could not create genuine recovery lease"
87
+ }
88
+
89
+ inject_terminal_crash() {
90
+ local project="$1"
91
+ local state_root="$2"
92
+ local harness="$3"
93
+ local session="$4"
94
+ (
95
+ cd "$project"
96
+ env CAIRN_CAPABILITY_CONTRACT=1 CAIRN_CAPABILITY_LOGGING=1 CAIRN_TRAJECTORY_CAPTURE=1 \
97
+ CAIRN_HARNESS_STATE_DIR="$state_root" \
98
+ node --input-type=module - "$ROOT/mcp-memory-server/dist/capability-harness.js" "$harness" "$session" <<'NODE'
99
+ import assert from "node:assert/strict";
100
+ import { pathToFileURL } from "node:url";
101
+ const [modulePath, harness, sessionID] = process.argv.slice(2);
102
+ const coordinator = await import(pathToFileURL(modulePath).href);
103
+ await assert.rejects(
104
+ coordinator.finishHarnessCapability({
105
+ schema_version: 1,
106
+ harness,
107
+ session_id: sessionID,
108
+ outcome: "success",
109
+ }, { testCrashAt: "after-claim" }),
110
+ (error) => error?.name === "HarnessCrashInjection",
111
+ );
112
+ NODE
113
+ )
114
+ }
115
+
116
+ expire_recovery_lease() {
117
+ local state_root="$1"
118
+ local lease
119
+ lease=$(find "$state_root/capability-leases-v1" -type f -name '*.json' -print -quit)
120
+ [[ -n "$lease" ]] || fail "active recovery lease is absent"
121
+ node - "$lease" <<'NODE'
122
+ const fs = require("node:fs");
123
+ const path = process.argv[2];
124
+ const before = JSON.parse(fs.readFileSync(path, "utf8"));
125
+ const after = { ...before, expires_at: "2020-01-01T00:00:00.000Z" };
126
+ if (Object.keys(before).sort().join(",") !== Object.keys(after).sort().join(",")) process.exit(1);
127
+ fs.writeFileSync(path, `${JSON.stringify(after)}\n`, { mode: 0o600 });
128
+ fs.chmodSync(path, 0o600);
129
+ NODE
130
+ }
131
+
132
+ recovery_state() {
133
+ local project="$1"
134
+ local state_root="$2"
135
+ (
136
+ cd "$ROOT/mcp-memory-server"
137
+ NODE_NO_WARNINGS=1 node --input-type=module - "$project" "$state_root" <<'NODE'
138
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
139
+ import { join } from "node:path";
140
+ import { AgentFS } from "agentfs-sdk";
141
+ const [project, stateRoot] = process.argv.slice(2);
142
+ const database = join(project, ".agentfs", "trajectory.db");
143
+ let pending = [];
144
+ let finals = [];
145
+ if (existsSync(database)) {
146
+ const agent = await AgentFS.open({ id: "trajectory", path: database });
147
+ pending = await agent.kv.list("capability-callback/v1/pending/");
148
+ finals = await agent.kv.list("capability-callback/v1/record/");
149
+ await agent.close();
150
+ }
151
+ function files(root) {
152
+ if (!existsSync(root)) return [];
153
+ return readdirSync(root).flatMap((name) => {
154
+ const path = join(root, name);
155
+ return statSync(path).isDirectory() ? files(path) : [path];
156
+ });
157
+ }
158
+ const stateFiles = files(stateRoot);
159
+ const raw = Buffer.concat([
160
+ ...stateFiles.map((path) => readFileSync(path)),
161
+ ...(existsSync(join(project, ".agentfs")) ? readdirSync(join(project, ".agentfs"))
162
+ .filter((name) => /^trajectory\.db(?:-(?:wal|shm))?$/.test(name))
163
+ .map((name) => readFileSync(join(project, ".agentfs", name))) : []),
164
+ ]).toString("utf8");
165
+ process.stdout.write(JSON.stringify({
166
+ pending: pending.length,
167
+ finals: finals.map(({ value }) => value),
168
+ leases: stateFiles.filter((path) => path.endsWith(".json")).length,
169
+ leaked: ["launcher-argument-sentinel-18-28", "launcher-result-sentinel-18-28"].some((value) => raw.includes(value)),
170
+ }));
171
+ NODE
172
+ )
173
+ }
174
+
175
+ assert_recovered_state() {
176
+ local state="$1"
177
+ local outcome="$2"
178
+ node - "$state" "$outcome" <<'NODE'
179
+ const state = JSON.parse(process.argv[2]);
180
+ const outcome = process.argv[3];
181
+ if (state.pending !== 0 || state.leases !== 0 || state.leaked) process.exit(1);
182
+ if (state.finals.length !== 1 || state.finals[0].outcome !== outcome) process.exit(1);
183
+ if (["launcher-argument-sentinel-18-28", "launcher-result-sentinel-18-28"]
184
+ .some((value) => JSON.stringify(state.finals[0]).includes(value))) process.exit(1);
185
+ NODE
186
+ }
187
+
188
+ capability_recovery() {
189
+ local recovery_root="$tmp/capability-recovery"
190
+ local runtime_root="$recovery_root/runtime"
191
+ local harness_bin="$recovery_root/harness-bin"
192
+ local trace="$recovery_root/trace"
193
+ local claude_repo="$recovery_root/claude-project"
194
+ local opencode_repo="$recovery_root/opencode-project"
195
+ local claude_state="$recovery_root/claude-state"
196
+ local opencode_state="$recovery_root/opencode-state"
197
+ local state before status launcher harness project state_root master_value
198
+
199
+ npm --prefix "$ROOT/mcp-memory-server" run build >/dev/null
200
+ mkdir -p "$claude_repo" "$opencode_repo"
201
+ git -C "$claude_repo" init -q
202
+ git -C "$opencode_repo" init -q
203
+ "$ROOT/scripts/bootstrap.sh" "$claude_repo" >/dev/null
204
+ "$ROOT/scripts/bootstrap.sh" "$opencode_repo" >/dev/null
205
+ write_recovery_config "$claude_repo"
206
+ write_recovery_config "$opencode_repo"
207
+ write_recovery_runtime_fixture "$runtime_root"
208
+ write_recovery_harness_fixtures "$harness_bin"
209
+ : > "$trace"
210
+
211
+ create_recovery_lease "$claude_repo" "$claude_state" claude-code launcher-crash-claude-18-28
212
+ inject_terminal_crash "$claude_repo" "$claude_state" claude-code launcher-crash-claude-18-28
213
+ create_recovery_lease "$opencode_repo" "$opencode_state" opencode launcher-expired-opencode-18-28
214
+ expire_recovery_lease "$opencode_state"
215
+
216
+ CAIRN_RECOVERY_TRACE="$trace" CAIRN_CAPABILITY_CONTRACT=1 CAIRN_CAPABILITY_LOGGING=1 \
217
+ CAIRN_TRAJECTORY_CAPTURE=1 CAIRN_HARNESS_STATE_DIR="$claude_state" \
218
+ PATH="$runtime_root/bin:$harness_bin:$PATH" \
219
+ "$claude_repo/.ai/start-claude.sh" --launcher-argument-sentinel-18-28 >/dev/null 2>&1 || \
220
+ fail "Claude recovery fixture did not reach the harness"
221
+ CAIRN_RECOVERY_TRACE="$trace" CAIRN_CAPABILITY_CONTRACT=1 CAIRN_CAPABILITY_LOGGING=1 \
222
+ CAIRN_TRAJECTORY_CAPTURE=1 CAIRN_HARNESS_STATE_DIR="$opencode_state" \
223
+ PATH="$runtime_root/bin:$harness_bin:$PATH" \
224
+ "$opencode_repo/.ai/start-opencode.sh" --launcher-argument-sentinel-18-28 >/dev/null 2>&1 || \
225
+ fail "OpenCode recovery fixture did not reach the harness"
226
+
227
+ if [[ $(grep -c '^coordinator:harness-recover$' "$trace" || true) -eq 0 ]]; then
228
+ [[ $(grep -c '^harness:' "$trace" || true) -eq 2 ]] || fail "known launcher recovery gap changed harness execution"
229
+ [[ $(node -e 'const s=JSON.parse(process.argv[1]);process.stdout.write(String(s.leases))' "$(recovery_state "$claude_repo" "$claude_state")") -eq 1 ]] || \
230
+ fail "known launcher recovery gap consumed the Claude crash lease"
231
+ [[ $(node -e 'const s=JSON.parse(process.argv[1]);process.stdout.write(String(s.leases))' "$(recovery_state "$opencode_repo" "$opencode_state")") -eq 1 ]] || \
232
+ fail "known launcher recovery gap consumed the OpenCode expiry lease"
233
+ echo "PHASE18_RED:PRODUCTION_STARTUP_RECOVERY"
234
+ return 86
235
+ fi
236
+
237
+ node - "$trace" <<'NODE' || fail "recovery did not run once before each harness"
238
+ const fs = require("node:fs");
239
+ const rows = fs.readFileSync(process.argv[2], "utf8").trim().split(/\n/);
240
+ if (rows.length !== 4) process.exit(1);
241
+ if (rows[0] !== "coordinator:harness-recover" || !rows[1].startsWith("harness:claude|")) process.exit(1);
242
+ if (rows[2] !== "coordinator:harness-recover" || !rows[3].startsWith("harness:opencode|")) process.exit(1);
243
+ NODE
244
+ assert_recovered_state "$(recovery_state "$claude_repo" "$claude_state")" success || fail "Claude crash recovery state is incorrect"
245
+ assert_recovered_state "$(recovery_state "$opencode_repo" "$opencode_state")" timeout || fail "OpenCode expiry recovery state is incorrect"
246
+
247
+ before="$(recovery_state "$claude_repo" "$claude_state")|$(recovery_state "$opencode_repo" "$opencode_state")"
248
+ : > "$trace"
249
+ CAIRN_RECOVERY_TRACE="$trace" CAIRN_CAPABILITY_CONTRACT=1 CAIRN_HARNESS_STATE_DIR="$claude_state" \
250
+ PATH="$runtime_root/bin:$harness_bin:$PATH" "$claude_repo/.ai/start-claude.sh" --replay >/dev/null 2>&1
251
+ CAIRN_RECOVERY_TRACE="$trace" CAIRN_CAPABILITY_CONTRACT=1 CAIRN_HARNESS_STATE_DIR="$opencode_state" \
252
+ PATH="$runtime_root/bin:$harness_bin:$PATH" "$opencode_repo/.ai/start-opencode.sh" --replay >/dev/null 2>&1
253
+ [[ "$before" == "$(recovery_state "$claude_repo" "$claude_state")|$(recovery_state "$opencode_repo" "$opencode_state")" ]] || \
254
+ fail "replayed startup changed recovered finals"
255
+
256
+ for harness in claude opencode; do
257
+ if [[ "$harness" == claude ]]; then
258
+ project="$claude_repo"; state_root="$claude_state"; launcher="$project/.ai/start-claude.sh"
259
+ else
260
+ project="$opencode_repo"; state_root="$opencode_state"; launcher="$project/.ai/start-opencode.sh"
261
+ fi
262
+ : > "$trace"
263
+ status=0
264
+ CAIRN_RECOVERY_TRACE="$trace" CAIRN_TEST_RECOVERY_FAIL=1 FAKE_EXIT=7 \
265
+ CAIRN_CAPABILITY_CONTRACT=1 CAIRN_HARNESS_STATE_DIR="$state_root" \
266
+ PATH="$runtime_root/bin:$harness_bin:$PATH" "$launcher" --failure-argv >/dev/null 2>&1 || status=$?
267
+ [[ "$status" -eq 7 ]] || fail "$harness recovery failure changed the harness exit status"
268
+ node - "$trace" "$harness" "$project" <<'NODE' || fail "recovery failure changed harness ordering, argv, or config"
269
+ const fs = require("node:fs");
270
+ const [trace, harness, project] = process.argv.slice(2);
271
+ const rows = fs.readFileSync(trace, "utf8").trim().split(/\n/);
272
+ if (rows.length !== 2 || rows[0] !== "coordinator:harness-recover") process.exit(1);
273
+ if (!rows[1].startsWith(`harness:${harness}|args:--failure-argv|`)) process.exit(1);
274
+ if (!rows[1].includes(`config:${project}/.ai/capability-contract/${harness}`)) process.exit(1);
275
+ NODE
276
+ done
277
+
278
+ for master_value in 0 false FALSE no off invalid; do
279
+ for harness in claude opencode; do
280
+ if [[ "$harness" == claude ]]; then project="$claude_repo"; launcher="$project/.ai/start-claude.sh"
281
+ else project="$opencode_repo"; launcher="$project/.ai/start-opencode.sh"; fi
282
+ : > "$trace"
283
+ CAIRN_RECOVERY_TRACE="$trace" CAIRN_CAPABILITY_CONTRACT="$master_value" \
284
+ PATH="$runtime_root/bin:$harness_bin:$PATH" "$launcher" --legacy-argv >/dev/null 2>&1 || \
285
+ fail "$harness master-off spelling failed: $master_value"
286
+ [[ $(grep -c '^coordinator:' "$trace" || true) -eq 0 ]] || fail "$harness master-off invoked recovery: $master_value"
287
+ grep -q "^harness:$harness|args:--legacy-argv|config:$" "$trace" || \
288
+ fail "$harness master-off changed argv or config: $master_value"
289
+ done
290
+ done
291
+
292
+ echo "PASS: production launcher crash and expiry recovery contract"
293
+ }
294
+
295
+ opencode_sync_tree_digest() {
296
+ local live_root="$1"
297
+ (
298
+ cd "$live_root"
299
+ find . -type f -print | LC_ALL=C sort | while IFS= read -r asset; do
300
+ sha256sum "$asset"
301
+ done
302
+ )
303
+ }
304
+
305
+ assert_opencode_legacy_plugins() {
306
+ local live_root="$1"
307
+ local plugin
308
+ for plugin in memory-wakeup.ts memory-capture.ts memory-recall.ts; do
309
+ [[ -f "$live_root/plugins/$plugin" ]] || fail "OpenCode sync omitted legacy plugin: $plugin"
310
+ cmp -s <(sed "s|@@INFRA_ROOT@@|$ROOT|g" "$ROOT/opencode/plugins/$plugin") \
311
+ "$live_root/plugins/$plugin" || fail "OpenCode sync changed legacy plugin bytes: $plugin"
312
+ done
313
+ }
314
+
315
+ assert_no_retired_opencode_owners() {
316
+ local live_root="$1"
317
+ [[ ! -e "$live_root/capability-contract" ]] || fail "OpenCode sync installed a retired capability owner tree"
318
+ [[ ! -e "$live_root/hooks/capability-command-start.sh" ]] || fail "OpenCode sync installed a Claude capability hook"
319
+ [[ ! -e "$live_root/hooks/capability-command-finish.sh" ]] || fail "OpenCode sync installed a Claude capability hook"
320
+ if grep -R -q -E 'cairn capabilities (guard|start|finish)|capability_handle' \
321
+ "$live_root/command" "$live_root/workflows" 2>/dev/null; then
322
+ fail "OpenCode sync installed retired model-visible lifecycle ownership"
323
+ fi
324
+ }
325
+
326
+ assert_opencode_sync_assets() {
327
+ local name="$1"
328
+ local live_root="$2"
329
+ case "$name" in
330
+ wiki)
331
+ cmp -s "$ROOT/opencode/command/wiki-ingest.md" "$live_root/command/wiki-ingest.md" || fail "wiki sync changed legacy command bytes"
332
+ cmp -s "$ROOT/opencode/workflows/wiki-ingest-workflow.md" "$live_root/workflows/wiki-ingest-workflow.md" || fail "wiki sync changed legacy workflow bytes"
333
+ ;;
334
+ graph)
335
+ cmp -s "$ROOT/opencode/command/graphify.md" "$live_root/command/graphify.md" || fail "graph sync changed legacy command bytes"
336
+ ;;
337
+ security)
338
+ cmp -s "$ROOT/opencode/command/security-audit.md" "$live_root/command/security-audit.md" || fail "security sync changed legacy command bytes"
339
+ cmp -s "$ROOT/opencode/workflows/security-audit-workflow.md" "$live_root/workflows/security-audit-workflow.md" || fail "security sync changed legacy workflow bytes"
340
+ ;;
341
+ esac
342
+ }
343
+
344
+ opencode_all_sync_modes() {
345
+ local probe_bin="$tmp/sync-probe-bin"
346
+ local process_log="$tmp/sync-processes.log"
347
+ local entry name normal_root enabled_root before after output state_root plugin_count
348
+ mkdir -p "$probe_bin"
349
+ : > "$process_log"
350
+ for command_name in cairn node; do
351
+ cat > "$probe_bin/$command_name" <<EOF
352
+ #!/usr/bin/env bash
353
+ echo "$command_name:\$*" >> "$process_log"
354
+ exit 91
355
+ EOF
356
+ chmod +x "$probe_bin/$command_name"
357
+ done
358
+
359
+ for spec in \
360
+ "wiki:scripts/sync-opencode-wiki-assets.sh" \
361
+ "graph:scripts/sync-opencode-graphify-assets.sh" \
362
+ "security:scripts/sync-opencode-security-assets.sh"
363
+ do
364
+ name=${spec%%:*}
365
+ entry="$ROOT/${spec#*:}"
366
+ normal_root="$tmp/$name-normal"
367
+ enabled_root="$tmp/$name-enabled"
368
+ state_root="$tmp/$name-state"
369
+
370
+ output=$(env -u CAIRN_CAPABILITY_CONTRACT \
371
+ PATH="$probe_bin:$PATH" CAIRN_HARNESS_STATE_DIR="$state_root/normal" \
372
+ "$entry" --apply --live-root "$normal_root") || fail "$name normal sync failed"
373
+ assert_opencode_sync_assets "$name" "$normal_root"
374
+ assert_opencode_legacy_plugins "$normal_root"
375
+ assert_no_retired_opencode_owners "$normal_root"
376
+ [[ ! -e "$normal_root/plugins/capability-command.ts" ]] || fail "$name normal sync registered capability plugin"
377
+ before=$(opencode_sync_tree_digest "$normal_root")
378
+
379
+ output+=$(CAIRN_CAPABILITY_CONTRACT=0 \
380
+ PATH="$probe_bin:$PATH" CAIRN_HARNESS_STATE_DIR="$state_root/master-off" \
381
+ "$entry" --apply --capability-overlay --live-root "$normal_root") || fail "$name master-off overlay sync failed"
382
+ after=$(opencode_sync_tree_digest "$normal_root")
383
+ [[ "$after" == "$before" ]] || fail "$name master-off overlay differs from exact normal sync"
384
+ assert_no_retired_opencode_owners "$normal_root"
385
+ [[ ! -e "$normal_root/plugins/capability-command.ts" ]] || fail "$name master-off sync registered capability plugin"
386
+
387
+ output+=$(CAIRN_CAPABILITY_CONTRACT=1 \
388
+ PATH="$probe_bin:$PATH" CAIRN_HARNESS_STATE_DIR="$state_root/enabled" \
389
+ "$entry" --apply --capability-overlay --live-root "$enabled_root") || fail "$name enabled overlay sync failed"
390
+ assert_opencode_sync_assets "$name" "$enabled_root"
391
+ assert_opencode_legacy_plugins "$enabled_root"
392
+ assert_no_retired_opencode_owners "$enabled_root"
393
+ plugin_count=$(find "$enabled_root/plugins" -maxdepth 1 -type f -name 'capability-*.ts' | wc -l | tr -d ' ')
394
+ [[ "$plugin_count" -eq 1 ]] || fail "$name enabled overlay installed more than the native capability plugin"
395
+ cmp -s <(sed "s|@@INFRA_ROOT@@|$ROOT|g" "$ROOT/opencode/capability-contract/plugins/capability-command.ts") \
396
+ "$enabled_root/plugins/capability-command.ts" || fail "$name enabled overlay capability plugin bytes differ"
397
+ grep -qF 'export const CapabilityCommandPlugin' "$enabled_root/plugins/capability-command.ts" || \
398
+ fail "$name enabled overlay omitted native plugin registration"
399
+ [[ ! -e "$state_root" ]] || fail "$name sync created capability measurement state"
400
+ [[ ! -s "$process_log" ]] || fail "$name sync started a capability coordinator process"
401
+ if grep -q -E 'disabled|permissionDecision|block' <<<"$output"; then
402
+ fail "$name sync emitted capability blocking state"
403
+ fi
404
+ done
405
+
406
+ echo "PASS: all OpenCode sync entrypoints preserve inert identity and select only the native overlay plugin"
407
+ }
408
+
409
+ if [[ "${1:-}" == "opencode-all-sync-modes" ]]; then
410
+ opencode_all_sync_modes
411
+ exit 0
412
+ fi
413
+
414
+ if [[ "${1:-}" == "capability-recovery" ]]; then
415
+ capability_recovery
416
+ exit $?
417
+ fi
418
+
12
419
  # A git repo scaffolded with the real templates.
13
420
  repo="$tmp/repo"; mkdir "$repo"; git -C "$repo" init -q
14
421
  "$ROOT/scripts/bootstrap.sh" "$repo" >/dev/null
15
422
  launcher="$repo/.ai/start-claude.sh"
16
423
  [[ -x "$launcher" ]] || fail "launcher not scaffolded"
424
+ pi_launcher="$repo/.ai/start-pi.sh"
425
+ [[ -x "$pi_launcher" ]] || fail "Pi launcher not scaffolded"
17
426
 
18
- # Fake `claude` on PATH: records its args + a marker env var, exits FAKE_EXIT.
427
+ # OpenCode's run mode ends a turn after content-only output. Commands that need
428
+ # owner I/O must therefore make their first response action a tool call rather
429
+ # than requiring a standalone banner before the config gate.
430
+ ! grep -qF '**Before ANY tool calls**, display this banner:' "$ROOT/opencode/command/graphify.md" \
431
+ || fail "OpenCode graphify can stop after its pre-tool banner"
432
+ grep -qF '**First action:** invoke the Read tool' "$ROOT/opencode/command/graphify.md" \
433
+ || fail "OpenCode graphify does not require the config-gate tool call first"
434
+ grep -qF '**Invoke the shell tool** with this exact command' "$ROOT/opencode/command/graphify.md" \
435
+ || fail "OpenCode graphify does not require real shell delegation"
436
+ grep -qF '<argument>$ARGUMENTS</argument>' "$ROOT/opencode/command/graphify.md" \
437
+ || fail "OpenCode graphify does not delimit its substituted argument"
438
+
439
+ # Fake harnesses on PATH: record argv, selected config root, and a marker env var.
19
440
  mkdir "$tmp/bin"
20
441
  cat > "$tmp/bin/claude" <<'FAKE'
21
442
  #!/usr/bin/env bash
22
- { echo "args:$*"; echo "prelaunch:${PRELAUNCH_RAN:-0}"; } > "$CLAUDE_LOG"
443
+ { echo "args:$*"; echo "prelaunch:${PRELAUNCH_RAN:-0}"; echo "config:${CLAUDE_CONFIG_DIR:-}"; echo "contract:${CAIRN_CAPABILITY_CONTRACT:-}"; } > "$CLAUDE_LOG"
444
+ exit "${FAKE_EXIT:-0}"
445
+ FAKE
446
+ cat > "$tmp/bin/opencode" <<'FAKE'
447
+ #!/usr/bin/env bash
448
+ {
449
+ echo "launch-time:$(date +%s)"
450
+ echo "args:$*"
451
+ echo "prelaunch:${PRELAUNCH_RAN:-0}"
452
+ echo "config:${OPENCODE_CONFIG_DIR:-}"
453
+ echo "explicit-config:${OPENCODE_CONFIG:-}"
454
+ echo "contract:${CAIRN_CAPABILITY_CONTRACT:-}"
455
+ if [[ -n "${OPENCODE_CONFIG_DIR:-}" && -d "$OPENCODE_CONFIG_DIR/plugins" ]]; then
456
+ find "$OPENCODE_CONFIG_DIR/plugins" -maxdepth 1 -type f -name '*.ts' -printf 'plugin:%f\n' | LC_ALL=C sort
457
+ fi
458
+ if [[ -n "${OPENCODE_CONFIG_DIR:-}" && -f "$OPENCODE_CONFIG_DIR/plugins/capability-command.ts" ]] \
459
+ && grep -qF 'export const CapabilityCommandPlugin' "$OPENCODE_CONFIG_DIR/plugins/capability-command.ts"; then
460
+ echo "capability-registration:1"
461
+ else
462
+ echo "capability-registration:0"
463
+ fi
464
+ } > "$OPENCODE_LOG"
23
465
  exit "${FAKE_EXIT:-0}"
24
466
  FAKE
25
- chmod +x "$tmp/bin/claude"
467
+ chmod +x "$tmp/bin/claude" "$tmp/bin/opencode"
468
+ ln -s "$ROOT/bin/cairn" "$tmp/bin/cairn"
26
469
  export PATH="$tmp/bin:$PATH"
27
470
  export CLAUDE_LOG="$tmp/claude.log"
471
+ export OPENCODE_LOG="$tmp/opencode.log"
472
+
473
+ # Claude sync has three exact installation states. Normal sync and an explicit
474
+ # overlay request with the master off must contain no capability hook bytes or
475
+ # registrations; only master-on plus --capability-overlay may install them.
476
+ normal_sync="$tmp/claude-normal-sync"
477
+ normal_sync_before="$tmp/claude-normal-sync-before"
478
+ master_on_sync="$tmp/claude-master-on-sync"
479
+ env -u CAIRN_CAPABILITY_CONTRACT \
480
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --live-root "$normal_sync" >/dev/null
481
+ cp -a "$normal_sync" "$normal_sync_before"
482
+ CAIRN_CAPABILITY_CONTRACT=0 \
483
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --capability-overlay --live-root "$normal_sync" >/dev/null
484
+ for live in "$normal_sync" "$normal_sync_before"; do
485
+ [[ ! -e "$live/hooks/capability-command-start.sh" ]] || fail "inert Claude sync installed start-hook bytes"
486
+ [[ ! -e "$live/hooks/capability-command-finish.sh" ]] || fail "inert Claude sync installed finish-hook bytes"
487
+ ! grep -R -qF 'capability-command-' "$live" || fail "inert Claude sync registered a capability hook"
488
+ [[ ! -e "$repo/.agentfs/trajectory.db" ]] || fail "inert Claude sync created measurement state"
489
+ done
490
+ diff -qr "$normal_sync_before" "$normal_sync" >/dev/null || fail "master-off overlay differs from exact normal sync"
491
+
492
+ CAIRN_CAPABILITY_CONTRACT=1 \
493
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --capability-overlay --live-root "$master_on_sync" >/dev/null
494
+ for name in capability-command-start.sh capability-command-finish.sh; do
495
+ installed="$master_on_sync/hooks/$name"
496
+ [[ -x "$installed" ]] || fail "enabled Claude overlay omitted $name"
497
+ grep -qF "INFRA_ROOT=\"$ROOT\"" "$installed" || fail "enabled Claude hook was not rendered"
498
+ grep -qF 'mcp-memory-server/dist/capability-cli.js' "$installed" || fail "enabled Claude hook omitted coordinator delegation"
499
+ done
500
+ node - "$master_on_sync/settings.json" <<'NODE' || fail "enabled Claude overlay registrations are incomplete"
501
+ const fs = require("node:fs");
502
+ const settings = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
503
+ const registrations = [];
504
+ for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
505
+ for (const entry of entries) for (const hook of entry.hooks ?? []) {
506
+ if (String(hook.command ?? "").includes("capability-command-")) {
507
+ registrations.push({ event, matcher: entry.matcher ?? "", command: hook.command });
508
+ }
509
+ }
510
+ }
511
+ if (registrations.filter((row) => row.command.includes("capability-command-start.sh")).length !== 1) process.exit(1);
512
+ if (registrations.filter((row) => row.command.includes("capability-command-finish.sh")).length !== 4) process.exit(1);
513
+ if (!registrations.some((row) => row.event === "UserPromptExpansion" && row.matcher === "wiki-ingest|wiki-query|wiki-lint|graphify|security-audit")) process.exit(1);
514
+ if (["Stop", "StopFailure", "CwdChanged", "SessionEnd"].some((event) => !registrations.some((row) => row.event === event))) process.exit(1);
515
+ NODE
28
516
 
29
517
  # 1. No hooks: launcher execs claude with the passed args (baseline unchanged).
30
518
  rm -f "$CLAUDE_LOG"
31
519
  "$launcher" --foo bar >/dev/null 2>&1 || fail "baseline launch failed"
32
520
  grep -qx "args:--foo bar" "$CLAUDE_LOG" || fail "baseline did not pass args through"
33
521
  grep -qx "prelaunch:0" "$CLAUDE_LOG" || fail "prelaunch marker leaked without a hook"
522
+ grep -qx "config:" "$CLAUDE_LOG" || fail "baseline selected a Claude config root"
523
+
524
+ opencode_launcher="$repo/.ai/start-opencode.sh"
525
+ [[ -x "$opencode_launcher" ]] || fail "OpenCode launcher not scaffolded"
526
+ "$opencode_launcher" --foo bar >/dev/null 2>&1 || fail "OpenCode baseline launch failed"
527
+ grep -qx "args:--foo bar" "$OPENCODE_LOG" || fail "OpenCode baseline did not pass args through"
528
+ grep -qx "config:" "$OPENCODE_LOG" || fail "OpenCode baseline selected a config root"
529
+ grep -qx "capability-registration:0" "$OPENCODE_LOG" || fail "OpenCode baseline registered a capability plugin"
530
+
531
+ # Invalid/off master values stay on the legacy direct-exec path: one harness
532
+ # process, unchanged argv/config environment, and no isolated assets or store.
533
+ legacy_claude="$tmp/legacy-claude"
534
+ legacy_opencode="$tmp/legacy-opencode"
535
+ mkdir -p "$legacy_claude/commands" "$legacy_opencode/command"
536
+ printf 'legacy-claude\n' > "$legacy_claude/commands/sentinel.md"
537
+ printf 'legacy-opencode\n' > "$legacy_opencode/command/sentinel.md"
538
+ sha256sum "$legacy_claude/commands/sentinel.md" > "$tmp/legacy-claude.sha"
539
+ sha256sum "$legacy_opencode/command/sentinel.md" > "$tmp/legacy-opencode.sha"
540
+ for master_value in 0 false FALSE no off invalid; do
541
+ rm -rf "$repo/.ai/capability-contract" "$repo/.agentfs/trajectory.db" "$CLAUDE_LOG" "$OPENCODE_LOG"
542
+ CAIRN_CAPABILITY_CONTRACT="$master_value" CLAUDE_CONFIG_DIR="$legacy_claude" \
543
+ "$launcher" --identity >/dev/null 2>&1 || fail "Claude invalid-master launch failed: $master_value"
544
+ CAIRN_CAPABILITY_CONTRACT="$master_value" OPENCODE_CONFIG_DIR="$legacy_opencode" \
545
+ "$opencode_launcher" --identity >/dev/null 2>&1 || fail "OpenCode invalid-master launch failed: $master_value"
546
+ grep -qx "args:--identity" "$CLAUDE_LOG" || fail "Claude invalid-master argv changed: $master_value"
547
+ grep -qx "config:$legacy_claude" "$CLAUDE_LOG" || fail "Claude invalid-master config changed: $master_value"
548
+ grep -qx "contract:$master_value" "$CLAUDE_LOG" || fail "Claude invalid-master environment changed: $master_value"
549
+ grep -qx "args:--identity" "$OPENCODE_LOG" || fail "OpenCode invalid-master argv changed: $master_value"
550
+ grep -qx "config:$legacy_opencode" "$OPENCODE_LOG" || fail "OpenCode invalid-master config changed: $master_value"
551
+ grep -qx "contract:$master_value" "$OPENCODE_LOG" || fail "OpenCode invalid-master environment changed: $master_value"
552
+ grep -qx "capability-registration:0" "$OPENCODE_LOG" || fail "OpenCode invalid-master launch registered a capability plugin: $master_value"
553
+ [[ ! -e "$repo/.ai/capability-contract" ]] || fail "invalid master created an isolated root: $master_value"
554
+ [[ ! -e "$repo/.agentfs/trajectory.db" ]] || fail "invalid master created a callback store: $master_value"
555
+ sha256sum -c "$tmp/legacy-claude.sha" >/dev/null || fail "invalid master changed legacy Claude assets"
556
+ sha256sum -c "$tmp/legacy-opencode.sha" >/dev/null || fail "invalid master changed legacy OpenCode assets"
557
+ done
558
+
559
+ # A truthy master selects project-local isolated roots and installs every guarded
560
+ # family through the explicit overlay modes. Legacy live roots remain untouched.
561
+ rm -rf "$repo/.ai/capability-contract" "$CLAUDE_LOG" "$OPENCODE_LOG"
562
+ CAIRN_CAPABILITY_CONTRACT=' TrUe ' CLAUDE_CONFIG_DIR="$legacy_claude" \
563
+ "$launcher" --overlay >/dev/null 2>&1 || fail "Claude capability-overlay launch failed"
564
+ claude_overlay="$repo/.ai/capability-contract/claude"
565
+ grep -qx "args:--overlay" "$CLAUDE_LOG" || fail "Claude overlay launch changed argv"
566
+ grep -qx "config:$claude_overlay" "$CLAUDE_LOG" || fail "Claude overlay root was not selected"
567
+ for rel in commands/wiki-ingest.md commands/wiki-query.md commands/wiki-lint.md commands/graphify.md commands/security-audit.md; do
568
+ cmp -s "$ROOT/claude/$rel" "$claude_overlay/$rel" || fail "Claude overlay changed legacy owner bytes: $rel"
569
+ done
570
+ CAIRN_CAPABILITY_CONTRACT=1 "$ROOT/scripts/sync-claude-assets.sh" --check --capability-overlay --live-root "$claude_overlay" >/dev/null || fail "Claude overlay family is incomplete"
571
+ for name in capability-command-start.sh capability-command-finish.sh; do
572
+ cmp -s "$master_on_sync/hooks/$name" "$claude_overlay/hooks/$name" || fail "launcher overlay hook bytes differ: $name"
573
+ done
574
+
575
+ CAIRN_CAPABILITY_CONTRACT=1 OPENCODE_CONFIG_DIR="$legacy_opencode" \
576
+ "$opencode_launcher" --overlay >/dev/null 2>&1 || fail "OpenCode capability-overlay launch failed"
577
+ opencode_overlay="$repo/.ai/capability-contract/opencode"
578
+ grep -qx "args:--overlay" "$OPENCODE_LOG" || fail "OpenCode overlay launch changed argv"
579
+ grep -qx "config:$opencode_overlay" "$OPENCODE_LOG" || fail "OpenCode overlay root was not selected"
580
+ grep -Eq '^launch-time:[0-9]+$' "$OPENCODE_LOG" || fail "OpenCode stub did not record launch time"
581
+ grep -qx 'capability-registration:1' "$OPENCODE_LOG" || fail "OpenCode capability plugin was not registered before process start"
582
+ for plugin in memory-wakeup.ts memory-capture.ts memory-recall.ts capability-command.ts; do
583
+ grep -qx "plugin:$plugin" "$OPENCODE_LOG" || fail "OpenCode plugin was not present before process start: $plugin"
584
+ done
585
+ cmp -s <(sed "s|@@INFRA_ROOT@@|$ROOT|g" "$ROOT/opencode/capability-contract/plugins/capability-command.ts") \
586
+ "$opencode_overlay/plugins/capability-command.ts" || fail "launcher capability plugin bytes differ from rendered source"
587
+ for rel in \
588
+ command/wiki-ingest.md command/wiki-query.md command/wiki-lint.md \
589
+ workflows/wiki-ingest-workflow.md workflows/wiki-query-workflow.md workflows/wiki-lint-workflow.md \
590
+ command/graphify.md command/security-audit.md workflows/security-audit-workflow.md
591
+ do
592
+ cmp -s "$ROOT/opencode/$rel" "$opencode_overlay/$rel" || fail "OpenCode overlay changed legacy owner bytes: $rel"
593
+ done
594
+ "$ROOT/scripts/sync-opencode-wiki-assets.sh" --check --capability-overlay --live-root "$opencode_overlay" >/dev/null || fail "OpenCode wiki overlay family is incomplete"
595
+ "$ROOT/scripts/sync-opencode-graphify-assets.sh" --check --capability-overlay --live-root "$opencode_overlay" >/dev/null || fail "OpenCode graph overlay family is incomplete"
596
+ "$ROOT/scripts/sync-opencode-security-assets.sh" --check --capability-overlay --live-root "$opencode_overlay" >/dev/null || fail "OpenCode security overlay family is incomplete"
597
+ CAIRN_CAPABILITY_CONTRACT=1 "$ROOT/scripts/sync-opencode-plugin-assets.sh" --check --capability-overlay --live-root "$opencode_overlay" >/dev/null || fail "OpenCode plugin overlay family is incomplete"
598
+ [[ ! -e "$repo/.agentfs/trajectory.db" ]] || fail "OpenCode launcher sync created measurement state before command admission"
599
+ "$ROOT/scripts/test-phase18-capability-lifecycle.sh" operating --harness opencode-workflow --capability wiki >/dev/null
600
+ "$ROOT/scripts/test-phase18-capability-lifecycle.sh" operating --harness opencode-workflow --capability security.audit >/dev/null
601
+ sha256sum -c "$tmp/legacy-claude.sha" >/dev/null || fail "overlay launch changed legacy Claude assets"
602
+ sha256sum -c "$tmp/legacy-opencode.sha" >/dev/null || fail "overlay launch changed legacy OpenCode assets"
34
603
 
35
604
  # 2. pre-launch.sh is sourced (can export env) + CAIRN_EXTRA_SETTINGS layers in.
36
605
  rm -f "$CLAUDE_LOG"
@@ -43,6 +612,12 @@ echo '{}' > "$tmp/settings.json"
43
612
  grep -qx "prelaunch:1" "$CLAUDE_LOG" || fail "pre-launch env not visible to harness"
44
613
  grep -q -- "--settings $tmp/settings.json" "$CLAUDE_LOG" || fail "CAIRN_EXTRA_SETTINGS not passed as --settings"
45
614
 
615
+ rm -f "$OPENCODE_LOG"
616
+ "$opencode_launcher" --settings-probe >/dev/null 2>&1 || fail "OpenCode launch with extra config failed"
617
+ grep -qx "prelaunch:1" "$OPENCODE_LOG" || fail "OpenCode pre-launch env not visible to harness"
618
+ grep -qx "args:--settings-probe" "$OPENCODE_LOG" || fail "OpenCode extra config changed harness argv"
619
+ grep -qx "explicit-config:$tmp/settings.json" "$OPENCODE_LOG" || fail "CAIRN_EXTRA_SETTINGS not exported as OPENCODE_CONFIG"
620
+
46
621
  # 3. pre-launch.sh non-zero aborts the launch before the harness runs.
47
622
  rm -f "$CLAUDE_LOG"
48
623
  cat > "$repo/.ai/pre-launch.sh" <<'EOF'
@@ -63,4 +638,28 @@ status=$?
63
638
  [[ "$status" -eq 7 ]] || fail "expected exit 7 from launcher, got $status"
64
639
  grep -qx "post:7" "$tmp/post.log" || fail "post-exit hook did not see CAIRN_EXIT_STATUS"
65
640
 
641
+ # 5. Pi launcher preserves the same env/pre/post seams and passes arguments
642
+ # unchanged; Pi has no generic settings-file flag so CAIRN_EXTRA_SETTINGS is ignored.
643
+ rm -f "$repo/.ai/pre-launch.sh" "$repo/.ai/post-exit.sh"
644
+ cat > "$tmp/bin/pi" <<'FAKE'
645
+ #!/usr/bin/env bash
646
+ { echo "args:$*"; echo "prelaunch:${PRELAUNCH_RAN:-0}"; echo "extra:${CAIRN_EXTRA_SETTINGS:-}"; } > "$PI_LOG"
647
+ exit "${FAKE_EXIT:-0}"
648
+ FAKE
649
+ chmod +x "$tmp/bin/pi"
650
+ export PI_LOG="$tmp/pi.log"
651
+ cat > "$repo/.ai/pre-launch.sh" <<EOF
652
+ export PRELAUNCH_RAN=1
653
+ export CAIRN_EXTRA_SETTINGS="$tmp/pi-settings.json"
654
+ EOF
655
+ cat > "$repo/.ai/post-exit.sh" <<EOF
656
+ echo "pi-post:\${CAIRN_EXIT_STATUS}" > "$tmp/pi-post.log"
657
+ EOF
658
+ FAKE_EXIT=9 "$pi_launcher" --model fixture/model >/dev/null 2>&1 && fail "Pi launcher should propagate harness exit code"
659
+ status=$?
660
+ [[ "$status" -eq 9 ]] || fail "expected exit 9 from Pi launcher, got $status"
661
+ grep -qx "args:--model fixture/model" "$PI_LOG" || fail "Pi launcher changed arguments"
662
+ grep -qx "prelaunch:1" "$PI_LOG" || fail "Pi launcher did not source pre-launch"
663
+ grep -qx "pi-post:9" "$tmp/pi-post.log" || fail "Pi launcher post-exit status missing"
664
+
66
665
  echo "PASS: launcher hook seams (pre-launch source/abort, settings layering, post-exit)"