@cairnkeep/cli 2.3.1 → 2.5.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 (151) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +232 -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 +4 -3
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/harness-compatibility.md +117 -0
  12. package/docs/learning/CURRICULUM-MAP.md +91 -0
  13. package/docs/learning/FEATURE-GUIDE.md +86 -0
  14. package/docs/learning/PRODUCTION-PLAN.md +45 -14
  15. package/docs/learning/README.md +20 -1
  16. package/docs/learning/instructor-guide.md +6 -0
  17. package/docs/learning/lessons/L00-why-cairnkeep.md +20 -8
  18. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  19. package/docs/learning/lessons/L02-installation.md +23 -5
  20. package/docs/learning/lessons/L03-first-project.md +7 -2
  21. package/docs/learning/lessons/L04-memory-fundamentals.md +4 -0
  22. package/docs/learning/lessons/L05-daily-workflow.md +4 -0
  23. package/docs/learning/lessons/L07-privacy-and-storage.md +3 -0
  24. package/docs/learning/lessons/L10-context-exploration.md +4 -2
  25. package/docs/learning/lessons/L12-managed-overlays.md +2 -0
  26. package/docs/learning/lessons/L13-session-evidence.md +71 -0
  27. package/docs/learning/lessons/L14-typed-memory.md +51 -0
  28. package/docs/learning/lessons/L15-capability-governance.md +56 -0
  29. package/docs/learning/lessons/L16-evaluation.md +67 -0
  30. package/docs/learning/lessons/L17-agent-boundary.md +55 -0
  31. package/docs/learning/templates/lesson-template.md +4 -1
  32. package/docs/learning/tracks/evidence-and-evaluation.md +20 -0
  33. package/docs/learning/tracks/operator.md +8 -2
  34. package/docs/learning/tracks/practitioner.md +6 -1
  35. package/docs/learning/tracks/quickstart.md +3 -0
  36. package/docs/learning/video-scripts/L00-why-cairnkeep.md +21 -17
  37. package/docs/learning/video-scripts/L02-installation.md +9 -2
  38. package/docs/learning/video-scripts/L03-first-project.md +3 -0
  39. package/docs/operating.md +567 -7
  40. package/docs/privacy-and-data-flow.md +417 -0
  41. package/docs/storage.md +252 -0
  42. package/examples/eval/README.md +42 -0
  43. package/examples/eval/adapter.json +12 -0
  44. package/examples/eval/bundled-fake.json +1 -0
  45. package/examples/eval/task-set.json +1 -0
  46. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  47. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  48. package/mcp-memory-server/dist/artifact-store.js +812 -0
  49. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  50. package/mcp-memory-server/dist/capability-cli.js +361 -0
  51. package/mcp-memory-server/dist/capability-config.js +310 -0
  52. package/mcp-memory-server/dist/capability-harness.js +496 -0
  53. package/mcp-memory-server/dist/capability-registry.js +123 -0
  54. package/mcp-memory-server/dist/capability-schema.js +65 -0
  55. package/mcp-memory-server/dist/capability-store.js +431 -0
  56. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  57. package/mcp-memory-server/dist/eval-cli.js +523 -0
  58. package/mcp-memory-server/dist/eval-plan.js +346 -0
  59. package/mcp-memory-server/dist/eval-process.js +273 -0
  60. package/mcp-memory-server/dist/eval-report.js +609 -0
  61. package/mcp-memory-server/dist/eval-runner.js +827 -0
  62. package/mcp-memory-server/dist/eval-schema.js +368 -0
  63. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  64. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  65. package/mcp-memory-server/dist/failure-signature.js +141 -0
  66. package/mcp-memory-server/dist/index.js +600 -55
  67. package/mcp-memory-server/dist/node-cli.js +84 -0
  68. package/mcp-memory-server/dist/node-schema.js +170 -0
  69. package/mcp-memory-server/dist/node-store.js +455 -0
  70. package/mcp-memory-server/dist/note-cli.js +235 -0
  71. package/mcp-memory-server/dist/note-distiller.js +177 -0
  72. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  73. package/mcp-memory-server/dist/note-schema.js +4 -0
  74. package/mcp-memory-server/dist/note-store.js +985 -0
  75. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  76. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  77. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  78. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  79. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  80. package/mcp-memory-server/package.json +24 -2
  81. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  82. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  83. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  84. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  85. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  86. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  87. package/opencode/command/graphify.md +25 -14
  88. package/opencode/plugins/memory-capture.ts +76 -10
  89. package/opencode/plugins/memory-wakeup.ts +58 -6
  90. package/package.json +8 -4
  91. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  92. package/schemas/artifact.schema.json +133 -0
  93. package/schemas/capability-callback.schema.json +68 -0
  94. package/schemas/capability-contract.schema.json +126 -0
  95. package/schemas/eval-adapter.schema.json +43 -0
  96. package/schemas/eval-protocol.schema.json +114 -0
  97. package/schemas/eval-report.schema.json +423 -0
  98. package/schemas/eval-task-set.schema.json +114 -0
  99. package/schemas/memory-node.schema.json +90 -0
  100. package/schemas/note.schema.json +112 -0
  101. package/schemas/trajectory-redaction.schema.json +25 -0
  102. package/schemas/trajectory.schema.json +82 -0
  103. package/scripts/bootstrap.sh +6 -0
  104. package/scripts/completion.sh +119 -5
  105. package/scripts/doctor.sh +284 -0
  106. package/scripts/fake-eval-adapter.mjs +120 -0
  107. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  108. package/scripts/install-audit-timer.sh +3 -1
  109. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  110. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  111. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  112. package/scripts/memory-wiki-audit.sh +49 -0
  113. package/scripts/smoke-bash32.sh +9 -0
  114. package/scripts/sync-claude-assets.sh +99 -6
  115. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  116. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  117. package/scripts/sync-opencode-security-assets.sh +24 -3
  118. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  119. package/scripts/sync-pi-assets.sh +72 -0
  120. package/scripts/test-audit-timer.sh +98 -0
  121. package/scripts/test-bootstrap-untracked.sh +49 -0
  122. package/scripts/test-cli-dispatch.sh +41 -2
  123. package/scripts/test-compaction-hooks.sh +495 -0
  124. package/scripts/test-completion.sh +77 -0
  125. package/scripts/test-doctor.sh +348 -1
  126. package/scripts/test-eval-cli.sh +175 -0
  127. package/scripts/test-launcher-hooks.sh +630 -3
  128. package/scripts/test-learning-docs.sh +20 -4
  129. package/scripts/test-notes-cli.sh +114 -0
  130. package/scripts/test-package-install.sh +254 -0
  131. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  132. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  133. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  134. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  135. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  136. package/scripts/test-trajectory-cli.sh +87 -0
  137. package/scripts/test-trajectory-hooks.sh +142 -0
  138. package/scripts/test-uninstall.sh +231 -5
  139. package/scripts/uninstall.sh +38 -5
  140. package/scripts/verify-dependency-security.mjs +121 -0
  141. package/scripts/verify-docs-parity.sh +527 -1
  142. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  143. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  144. package/templates/capabilities.json.template +7 -0
  145. package/templates/env.example.template +51 -0
  146. package/templates/memory-wiki-audit.service.template +1 -1
  147. package/templates/start-claude.sh.template +36 -0
  148. package/templates/start-kimi.sh.template +39 -0
  149. package/templates/start-opencode.sh.template +47 -5
  150. package/templates/start-pi.sh.template +30 -0
  151. package/templates/trajectory-redaction.json.template +4 -0
@@ -9,28 +9,609 @@ 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"
426
+ kimi_launcher="$repo/.ai/start-kimi.sh"
427
+ [[ -x "$kimi_launcher" ]] || fail "Kimi launcher not scaffolded"
428
+
429
+ # OpenCode's run mode ends a turn after content-only output. Commands that need
430
+ # owner I/O must therefore make their first response action a tool call rather
431
+ # than requiring a standalone banner before the config gate.
432
+ ! grep -qF '**Before ANY tool calls**, display this banner:' "$ROOT/opencode/command/graphify.md" \
433
+ || fail "OpenCode graphify can stop after its pre-tool banner"
434
+ grep -qF '**First action:** invoke the Read tool' "$ROOT/opencode/command/graphify.md" \
435
+ || fail "OpenCode graphify does not require the config-gate tool call first"
436
+ grep -qF '**Invoke the shell tool** with this exact command' "$ROOT/opencode/command/graphify.md" \
437
+ || fail "OpenCode graphify does not require real shell delegation"
438
+ grep -qF '<argument>$ARGUMENTS</argument>' "$ROOT/opencode/command/graphify.md" \
439
+ || fail "OpenCode graphify does not delimit its substituted argument"
17
440
 
18
- # Fake `claude` on PATH: records its args + a marker env var, exits FAKE_EXIT.
441
+ # Fake harnesses on PATH: record argv, selected config root, and a marker env var.
19
442
  mkdir "$tmp/bin"
20
443
  cat > "$tmp/bin/claude" <<'FAKE'
21
444
  #!/usr/bin/env bash
22
- { echo "args:$*"; echo "prelaunch:${PRELAUNCH_RAN:-0}"; } > "$CLAUDE_LOG"
445
+ { echo "args:$*"; echo "prelaunch:${PRELAUNCH_RAN:-0}"; echo "config:${CLAUDE_CONFIG_DIR:-}"; echo "contract:${CAIRN_CAPABILITY_CONTRACT:-}"; } > "$CLAUDE_LOG"
446
+ exit "${FAKE_EXIT:-0}"
447
+ FAKE
448
+ cat > "$tmp/bin/opencode" <<'FAKE'
449
+ #!/usr/bin/env bash
450
+ {
451
+ echo "launch-time:$(date +%s)"
452
+ echo "args:$*"
453
+ echo "prelaunch:${PRELAUNCH_RAN:-0}"
454
+ echo "config:${OPENCODE_CONFIG_DIR:-}"
455
+ echo "explicit-config:${OPENCODE_CONFIG:-}"
456
+ echo "contract:${CAIRN_CAPABILITY_CONTRACT:-}"
457
+ if [[ -n "${OPENCODE_CONFIG_DIR:-}" && -d "$OPENCODE_CONFIG_DIR/plugins" ]]; then
458
+ find "$OPENCODE_CONFIG_DIR/plugins" -maxdepth 1 -type f -name '*.ts' -printf 'plugin:%f\n' | LC_ALL=C sort
459
+ fi
460
+ if [[ -n "${OPENCODE_CONFIG_DIR:-}" && -f "$OPENCODE_CONFIG_DIR/plugins/capability-command.ts" ]] \
461
+ && grep -qF 'export const CapabilityCommandPlugin' "$OPENCODE_CONFIG_DIR/plugins/capability-command.ts"; then
462
+ echo "capability-registration:1"
463
+ else
464
+ echo "capability-registration:0"
465
+ fi
466
+ } > "$OPENCODE_LOG"
467
+ exit "${FAKE_EXIT:-0}"
468
+ FAKE
469
+ cat > "$tmp/bin/kimi" <<'FAKE'
470
+ #!/usr/bin/env bash
471
+ { echo "args:$*"; echo "prelaunch:${PRELAUNCH_RAN:-0}"; echo "extra:${CAIRN_EXTRA_SETTINGS:-}"; } > "$KIMI_LOG"
23
472
  exit "${FAKE_EXIT:-0}"
24
473
  FAKE
25
- chmod +x "$tmp/bin/claude"
474
+ chmod +x "$tmp/bin/claude" "$tmp/bin/opencode" "$tmp/bin/kimi"
475
+ ln -s "$ROOT/bin/cairn" "$tmp/bin/cairn"
26
476
  export PATH="$tmp/bin:$PATH"
27
477
  export CLAUDE_LOG="$tmp/claude.log"
478
+ export OPENCODE_LOG="$tmp/opencode.log"
479
+ export KIMI_LOG="$tmp/kimi.log"
480
+
481
+ # Claude sync has three exact installation states. Normal sync and an explicit
482
+ # overlay request with the master off must contain no capability hook bytes or
483
+ # registrations; only master-on plus --capability-overlay may install them.
484
+ normal_sync="$tmp/claude-normal-sync"
485
+ normal_sync_before="$tmp/claude-normal-sync-before"
486
+ master_on_sync="$tmp/claude-master-on-sync"
487
+ env -u CAIRN_CAPABILITY_CONTRACT \
488
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --live-root "$normal_sync" >/dev/null
489
+ cp -a "$normal_sync" "$normal_sync_before"
490
+ CAIRN_CAPABILITY_CONTRACT=0 \
491
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --capability-overlay --live-root "$normal_sync" >/dev/null
492
+ for live in "$normal_sync" "$normal_sync_before"; do
493
+ [[ ! -e "$live/hooks/capability-command-start.sh" ]] || fail "inert Claude sync installed start-hook bytes"
494
+ [[ ! -e "$live/hooks/capability-command-finish.sh" ]] || fail "inert Claude sync installed finish-hook bytes"
495
+ ! grep -R -qF 'capability-command-' "$live" || fail "inert Claude sync registered a capability hook"
496
+ [[ ! -e "$repo/.agentfs/trajectory.db" ]] || fail "inert Claude sync created measurement state"
497
+ done
498
+ diff -qr "$normal_sync_before" "$normal_sync" >/dev/null || fail "master-off overlay differs from exact normal sync"
499
+
500
+ CAIRN_CAPABILITY_CONTRACT=1 \
501
+ "$ROOT/scripts/sync-claude-assets.sh" --apply --capability-overlay --live-root "$master_on_sync" >/dev/null
502
+ for name in capability-command-start.sh capability-command-finish.sh; do
503
+ installed="$master_on_sync/hooks/$name"
504
+ [[ -x "$installed" ]] || fail "enabled Claude overlay omitted $name"
505
+ grep -qF "INFRA_ROOT=\"$ROOT\"" "$installed" || fail "enabled Claude hook was not rendered"
506
+ grep -qF 'mcp-memory-server/dist/capability-cli.js' "$installed" || fail "enabled Claude hook omitted coordinator delegation"
507
+ done
508
+ node - "$master_on_sync/settings.json" <<'NODE' || fail "enabled Claude overlay registrations are incomplete"
509
+ const fs = require("node:fs");
510
+ const settings = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
511
+ const registrations = [];
512
+ for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
513
+ for (const entry of entries) for (const hook of entry.hooks ?? []) {
514
+ if (String(hook.command ?? "").includes("capability-command-")) {
515
+ registrations.push({ event, matcher: entry.matcher ?? "", command: hook.command });
516
+ }
517
+ }
518
+ }
519
+ if (registrations.filter((row) => row.command.includes("capability-command-start.sh")).length !== 1) process.exit(1);
520
+ if (registrations.filter((row) => row.command.includes("capability-command-finish.sh")).length !== 4) process.exit(1);
521
+ if (!registrations.some((row) => row.event === "UserPromptExpansion" && row.matcher === "wiki-ingest|wiki-query|wiki-lint|graphify|security-audit")) process.exit(1);
522
+ if (["Stop", "StopFailure", "CwdChanged", "SessionEnd"].some((event) => !registrations.some((row) => row.event === event))) process.exit(1);
523
+ NODE
28
524
 
29
525
  # 1. No hooks: launcher execs claude with the passed args (baseline unchanged).
30
526
  rm -f "$CLAUDE_LOG"
31
527
  "$launcher" --foo bar >/dev/null 2>&1 || fail "baseline launch failed"
32
528
  grep -qx "args:--foo bar" "$CLAUDE_LOG" || fail "baseline did not pass args through"
33
529
  grep -qx "prelaunch:0" "$CLAUDE_LOG" || fail "prelaunch marker leaked without a hook"
530
+ grep -qx "config:" "$CLAUDE_LOG" || fail "baseline selected a Claude config root"
531
+
532
+ opencode_launcher="$repo/.ai/start-opencode.sh"
533
+ [[ -x "$opencode_launcher" ]] || fail "OpenCode launcher not scaffolded"
534
+ "$opencode_launcher" --foo bar >/dev/null 2>&1 || fail "OpenCode baseline launch failed"
535
+ grep -qx "args:--foo bar" "$OPENCODE_LOG" || fail "OpenCode baseline did not pass args through"
536
+ grep -qx "config:" "$OPENCODE_LOG" || fail "OpenCode baseline selected a config root"
537
+ grep -qx "capability-registration:0" "$OPENCODE_LOG" || fail "OpenCode baseline registered a capability plugin"
538
+
539
+ "$kimi_launcher" --foo bar >/dev/null 2>&1 || fail "Kimi baseline launch failed"
540
+ grep -qx "args:--foo bar" "$KIMI_LOG" || fail "Kimi baseline did not pass args through"
541
+ grep -qx "prelaunch:0" "$KIMI_LOG" || fail "Kimi prelaunch marker leaked without a hook"
542
+
543
+ # Invalid/off master values stay on the legacy direct-exec path: one harness
544
+ # process, unchanged argv/config environment, and no isolated assets or store.
545
+ legacy_claude="$tmp/legacy-claude"
546
+ legacy_opencode="$tmp/legacy-opencode"
547
+ mkdir -p "$legacy_claude/commands" "$legacy_opencode/command"
548
+ printf 'legacy-claude\n' > "$legacy_claude/commands/sentinel.md"
549
+ printf 'legacy-opencode\n' > "$legacy_opencode/command/sentinel.md"
550
+ sha256sum "$legacy_claude/commands/sentinel.md" > "$tmp/legacy-claude.sha"
551
+ sha256sum "$legacy_opencode/command/sentinel.md" > "$tmp/legacy-opencode.sha"
552
+ for master_value in 0 false FALSE no off invalid; do
553
+ rm -rf "$repo/.ai/capability-contract" "$repo/.agentfs/trajectory.db" "$CLAUDE_LOG" "$OPENCODE_LOG"
554
+ CAIRN_CAPABILITY_CONTRACT="$master_value" CLAUDE_CONFIG_DIR="$legacy_claude" \
555
+ "$launcher" --identity >/dev/null 2>&1 || fail "Claude invalid-master launch failed: $master_value"
556
+ CAIRN_CAPABILITY_CONTRACT="$master_value" OPENCODE_CONFIG_DIR="$legacy_opencode" \
557
+ "$opencode_launcher" --identity >/dev/null 2>&1 || fail "OpenCode invalid-master launch failed: $master_value"
558
+ grep -qx "args:--identity" "$CLAUDE_LOG" || fail "Claude invalid-master argv changed: $master_value"
559
+ grep -qx "config:$legacy_claude" "$CLAUDE_LOG" || fail "Claude invalid-master config changed: $master_value"
560
+ grep -qx "contract:$master_value" "$CLAUDE_LOG" || fail "Claude invalid-master environment changed: $master_value"
561
+ grep -qx "args:--identity" "$OPENCODE_LOG" || fail "OpenCode invalid-master argv changed: $master_value"
562
+ grep -qx "config:$legacy_opencode" "$OPENCODE_LOG" || fail "OpenCode invalid-master config changed: $master_value"
563
+ grep -qx "contract:$master_value" "$OPENCODE_LOG" || fail "OpenCode invalid-master environment changed: $master_value"
564
+ grep -qx "capability-registration:0" "$OPENCODE_LOG" || fail "OpenCode invalid-master launch registered a capability plugin: $master_value"
565
+ [[ ! -e "$repo/.ai/capability-contract" ]] || fail "invalid master created an isolated root: $master_value"
566
+ [[ ! -e "$repo/.agentfs/trajectory.db" ]] || fail "invalid master created a callback store: $master_value"
567
+ sha256sum -c "$tmp/legacy-claude.sha" >/dev/null || fail "invalid master changed legacy Claude assets"
568
+ sha256sum -c "$tmp/legacy-opencode.sha" >/dev/null || fail "invalid master changed legacy OpenCode assets"
569
+ done
570
+
571
+ # A truthy master selects project-local isolated roots and installs every guarded
572
+ # family through the explicit overlay modes. Legacy live roots remain untouched.
573
+ rm -rf "$repo/.ai/capability-contract" "$CLAUDE_LOG" "$OPENCODE_LOG"
574
+ CAIRN_CAPABILITY_CONTRACT=' TrUe ' CLAUDE_CONFIG_DIR="$legacy_claude" \
575
+ "$launcher" --overlay >/dev/null 2>&1 || fail "Claude capability-overlay launch failed"
576
+ claude_overlay="$repo/.ai/capability-contract/claude"
577
+ grep -qx "args:--overlay" "$CLAUDE_LOG" || fail "Claude overlay launch changed argv"
578
+ grep -qx "config:$claude_overlay" "$CLAUDE_LOG" || fail "Claude overlay root was not selected"
579
+ for rel in commands/wiki-ingest.md commands/wiki-query.md commands/wiki-lint.md commands/graphify.md commands/security-audit.md; do
580
+ cmp -s "$ROOT/claude/$rel" "$claude_overlay/$rel" || fail "Claude overlay changed legacy owner bytes: $rel"
581
+ done
582
+ 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"
583
+ for name in capability-command-start.sh capability-command-finish.sh; do
584
+ cmp -s "$master_on_sync/hooks/$name" "$claude_overlay/hooks/$name" || fail "launcher overlay hook bytes differ: $name"
585
+ done
586
+
587
+ CAIRN_CAPABILITY_CONTRACT=1 OPENCODE_CONFIG_DIR="$legacy_opencode" \
588
+ "$opencode_launcher" --overlay >/dev/null 2>&1 || fail "OpenCode capability-overlay launch failed"
589
+ opencode_overlay="$repo/.ai/capability-contract/opencode"
590
+ grep -qx "args:--overlay" "$OPENCODE_LOG" || fail "OpenCode overlay launch changed argv"
591
+ grep -qx "config:$opencode_overlay" "$OPENCODE_LOG" || fail "OpenCode overlay root was not selected"
592
+ grep -Eq '^launch-time:[0-9]+$' "$OPENCODE_LOG" || fail "OpenCode stub did not record launch time"
593
+ grep -qx 'capability-registration:1' "$OPENCODE_LOG" || fail "OpenCode capability plugin was not registered before process start"
594
+ for plugin in memory-wakeup.ts memory-capture.ts memory-recall.ts capability-command.ts; do
595
+ grep -qx "plugin:$plugin" "$OPENCODE_LOG" || fail "OpenCode plugin was not present before process start: $plugin"
596
+ done
597
+ cmp -s <(sed "s|@@INFRA_ROOT@@|$ROOT|g" "$ROOT/opencode/capability-contract/plugins/capability-command.ts") \
598
+ "$opencode_overlay/plugins/capability-command.ts" || fail "launcher capability plugin bytes differ from rendered source"
599
+ for rel in \
600
+ command/wiki-ingest.md command/wiki-query.md command/wiki-lint.md \
601
+ workflows/wiki-ingest-workflow.md workflows/wiki-query-workflow.md workflows/wiki-lint-workflow.md \
602
+ command/graphify.md command/security-audit.md workflows/security-audit-workflow.md
603
+ do
604
+ cmp -s "$ROOT/opencode/$rel" "$opencode_overlay/$rel" || fail "OpenCode overlay changed legacy owner bytes: $rel"
605
+ done
606
+ "$ROOT/scripts/sync-opencode-wiki-assets.sh" --check --capability-overlay --live-root "$opencode_overlay" >/dev/null || fail "OpenCode wiki overlay family is incomplete"
607
+ "$ROOT/scripts/sync-opencode-graphify-assets.sh" --check --capability-overlay --live-root "$opencode_overlay" >/dev/null || fail "OpenCode graph overlay family is incomplete"
608
+ "$ROOT/scripts/sync-opencode-security-assets.sh" --check --capability-overlay --live-root "$opencode_overlay" >/dev/null || fail "OpenCode security overlay family is incomplete"
609
+ 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"
610
+ [[ ! -e "$repo/.agentfs/trajectory.db" ]] || fail "OpenCode launcher sync created measurement state before command admission"
611
+ "$ROOT/scripts/test-phase18-capability-lifecycle.sh" operating --harness opencode-workflow --capability wiki >/dev/null
612
+ "$ROOT/scripts/test-phase18-capability-lifecycle.sh" operating --harness opencode-workflow --capability security.audit >/dev/null
613
+ sha256sum -c "$tmp/legacy-claude.sha" >/dev/null || fail "overlay launch changed legacy Claude assets"
614
+ sha256sum -c "$tmp/legacy-opencode.sha" >/dev/null || fail "overlay launch changed legacy OpenCode assets"
34
615
 
35
616
  # 2. pre-launch.sh is sourced (can export env) + CAIRN_EXTRA_SETTINGS layers in.
36
617
  rm -f "$CLAUDE_LOG"
@@ -43,6 +624,18 @@ echo '{}' > "$tmp/settings.json"
43
624
  grep -qx "prelaunch:1" "$CLAUDE_LOG" || fail "pre-launch env not visible to harness"
44
625
  grep -q -- "--settings $tmp/settings.json" "$CLAUDE_LOG" || fail "CAIRN_EXTRA_SETTINGS not passed as --settings"
45
626
 
627
+ rm -f "$OPENCODE_LOG"
628
+ "$opencode_launcher" --settings-probe >/dev/null 2>&1 || fail "OpenCode launch with extra config failed"
629
+ grep -qx "prelaunch:1" "$OPENCODE_LOG" || fail "OpenCode pre-launch env not visible to harness"
630
+ grep -qx "args:--settings-probe" "$OPENCODE_LOG" || fail "OpenCode extra config changed harness argv"
631
+ grep -qx "explicit-config:$tmp/settings.json" "$OPENCODE_LOG" || fail "CAIRN_EXTRA_SETTINGS not exported as OPENCODE_CONFIG"
632
+
633
+ rm -f "$KIMI_LOG"
634
+ "$kimi_launcher" --plan >/dev/null 2>&1 || fail "Kimi launch with pre-launch failed"
635
+ grep -qx "args:--plan" "$KIMI_LOG" || fail "Kimi pre-launch changed harness argv"
636
+ grep -qx "prelaunch:1" "$KIMI_LOG" || fail "Kimi pre-launch env not visible to harness"
637
+ grep -qx "extra:$tmp/settings.json" "$KIMI_LOG" || fail "Kimi pre-launch environment was not preserved"
638
+
46
639
  # 3. pre-launch.sh non-zero aborts the launch before the harness runs.
47
640
  rm -f "$CLAUDE_LOG"
48
641
  cat > "$repo/.ai/pre-launch.sh" <<'EOF'
@@ -51,6 +644,9 @@ return 1
51
644
  EOF
52
645
  if "$launcher" >/dev/null 2>&1; then fail "non-zero pre-launch should abort the launch"; fi
53
646
  [[ ! -f "$CLAUDE_LOG" ]] || fail "harness ran despite pre-launch abort"
647
+ rm -f "$KIMI_LOG"
648
+ if "$kimi_launcher" >/dev/null 2>&1; then fail "non-zero pre-launch should abort Kimi"; fi
649
+ [[ ! -f "$KIMI_LOG" ]] || fail "Kimi ran despite pre-launch abort"
54
650
  rm -f "$repo/.ai/pre-launch.sh"
55
651
 
56
652
  # 4. post-exit.sh runs after the harness with $CAIRN_EXIT_STATUS.
@@ -63,4 +659,35 @@ status=$?
63
659
  [[ "$status" -eq 7 ]] || fail "expected exit 7 from launcher, got $status"
64
660
  grep -qx "post:7" "$tmp/post.log" || fail "post-exit hook did not see CAIRN_EXIT_STATUS"
65
661
 
662
+ rm -f "$tmp/post.log" "$KIMI_LOG"
663
+ FAKE_EXIT=8 "$kimi_launcher" --auto >/dev/null 2>&1 && fail "Kimi launcher should propagate harness exit code"
664
+ status=$?
665
+ [[ "$status" -eq 8 ]] || fail "expected exit 8 from Kimi launcher, got $status"
666
+ grep -qx "args:--auto" "$KIMI_LOG" || fail "Kimi launcher changed arguments"
667
+ grep -qx "post:8" "$tmp/post.log" || fail "Kimi launcher post-exit status missing"
668
+
669
+ # 5. Pi launcher preserves the same env/pre/post seams and passes arguments
670
+ # unchanged; Pi has no generic settings-file flag so CAIRN_EXTRA_SETTINGS is ignored.
671
+ rm -f "$repo/.ai/pre-launch.sh" "$repo/.ai/post-exit.sh"
672
+ cat > "$tmp/bin/pi" <<'FAKE'
673
+ #!/usr/bin/env bash
674
+ { echo "args:$*"; echo "prelaunch:${PRELAUNCH_RAN:-0}"; echo "extra:${CAIRN_EXTRA_SETTINGS:-}"; } > "$PI_LOG"
675
+ exit "${FAKE_EXIT:-0}"
676
+ FAKE
677
+ chmod +x "$tmp/bin/pi"
678
+ export PI_LOG="$tmp/pi.log"
679
+ cat > "$repo/.ai/pre-launch.sh" <<EOF
680
+ export PRELAUNCH_RAN=1
681
+ export CAIRN_EXTRA_SETTINGS="$tmp/pi-settings.json"
682
+ EOF
683
+ cat > "$repo/.ai/post-exit.sh" <<EOF
684
+ echo "pi-post:\${CAIRN_EXIT_STATUS}" > "$tmp/pi-post.log"
685
+ EOF
686
+ FAKE_EXIT=9 "$pi_launcher" --model fixture/model >/dev/null 2>&1 && fail "Pi launcher should propagate harness exit code"
687
+ status=$?
688
+ [[ "$status" -eq 9 ]] || fail "expected exit 9 from Pi launcher, got $status"
689
+ grep -qx "args:--model fixture/model" "$PI_LOG" || fail "Pi launcher changed arguments"
690
+ grep -qx "prelaunch:1" "$PI_LOG" || fail "Pi launcher did not source pre-launch"
691
+ grep -qx "pi-post:9" "$tmp/pi-post.log" || fail "Pi launcher post-exit status missing"
692
+
66
693
  echo "PASS: launcher hook seams (pre-launch source/abort, settings layering, post-exit)"