@cairnkeep/cli 1.0.2

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 (129) hide show
  1. package/AGENTS.md +45 -0
  2. package/CONTRIBUTING.md +49 -0
  3. package/LICENSE +201 -0
  4. package/README.md +149 -0
  5. package/SECURITY.md +37 -0
  6. package/bin/cairn +73 -0
  7. package/claude/agents/code-reviewer.md +174 -0
  8. package/claude/agents/security-investigator.md +112 -0
  9. package/claude/agents/security-target-selector.md +90 -0
  10. package/claude/agents/security-validator.md +97 -0
  11. package/claude/agents/wiki-ingester.md +108 -0
  12. package/claude/agents/wiki-lint-auditor.md +94 -0
  13. package/claude/agents/wiki-query-analyst.md +84 -0
  14. package/claude/commands/context-explore.md +40 -0
  15. package/claude/commands/graphify.md +150 -0
  16. package/claude/commands/memory-review.md +85 -0
  17. package/claude/commands/memory-sync.md +71 -0
  18. package/claude/commands/recall.md +32 -0
  19. package/claude/commands/remember.md +43 -0
  20. package/claude/commands/repo-review.md +191 -0
  21. package/claude/commands/security-audit.md +174 -0
  22. package/claude/commands/wiki-ingest.md +112 -0
  23. package/claude/commands/wiki-lint.md +86 -0
  24. package/claude/commands/wiki-query.md +92 -0
  25. package/claude/hooks/context-explore-pretask.sh +93 -0
  26. package/claude/hooks/memory-capture.sh +59 -0
  27. package/claude/hooks/memory-recall.sh +84 -0
  28. package/claude/hooks/memory-wakeup.sh +45 -0
  29. package/docs/building-an-overlay.md +117 -0
  30. package/docs/domain-knowledge.md +86 -0
  31. package/docs/git-providers.md +36 -0
  32. package/docs/operating.md +366 -0
  33. package/mcp-memory-server/dist/embeddings.js +122 -0
  34. package/mcp-memory-server/dist/explore-cache.js +97 -0
  35. package/mcp-memory-server/dist/index.js +1256 -0
  36. package/mcp-memory-server/package.json +36 -0
  37. package/opencode/agents/code-reviewer.md +173 -0
  38. package/opencode/agents/security-investigator.md +111 -0
  39. package/opencode/agents/security-target-selector.md +89 -0
  40. package/opencode/agents/security-validator.md +96 -0
  41. package/opencode/agents/wiki-ingester.md +107 -0
  42. package/opencode/agents/wiki-lint-auditor.md +93 -0
  43. package/opencode/agents/wiki-query-analyst.md +83 -0
  44. package/opencode/command/code-review.md +199 -0
  45. package/opencode/command/context-explore.md +42 -0
  46. package/opencode/command/graphify.md +153 -0
  47. package/opencode/command/memory-review.md +44 -0
  48. package/opencode/command/memory-sync.md +94 -0
  49. package/opencode/command/recall.md +38 -0
  50. package/opencode/command/remember.md +46 -0
  51. package/opencode/command/security-audit.md +80 -0
  52. package/opencode/command/wiki-ingest.md +55 -0
  53. package/opencode/command/wiki-lint.md +51 -0
  54. package/opencode/command/wiki-query.md +50 -0
  55. package/opencode/plugins/memory-capture.ts +188 -0
  56. package/opencode/plugins/memory-recall.ts +132 -0
  57. package/opencode/plugins/memory-wakeup.ts +102 -0
  58. package/opencode/templates/security-finding-register.yaml.template +51 -0
  59. package/opencode/templates/security-issue.md.template +43 -0
  60. package/opencode/templates/security-policy.md.template +29 -0
  61. package/opencode/templates/security-report.md.template +43 -0
  62. package/opencode/templates/wiki-contradictions.md.template +31 -0
  63. package/opencode/templates/wiki-index.md.template +40 -0
  64. package/opencode/templates/wiki-lint-report.md.template +38 -0
  65. package/opencode/templates/wiki-log.md.template +11 -0
  66. package/opencode/templates/wiki-policy.md.template +47 -0
  67. package/opencode/templates/wiki-query-answer.md.template +26 -0
  68. package/opencode/templates/wiki-source-summary.md.template +33 -0
  69. package/opencode/workflows/memory-review-workflow.md +66 -0
  70. package/opencode/workflows/security-audit-workflow.md +197 -0
  71. package/opencode/workflows/wiki-ingest-workflow.md +149 -0
  72. package/opencode/workflows/wiki-lint-workflow.md +128 -0
  73. package/opencode/workflows/wiki-query-workflow.md +122 -0
  74. package/package.json +56 -0
  75. package/scripts/bootstrap.sh +99 -0
  76. package/scripts/doctor.sh +91 -0
  77. package/scripts/install-audit-timer.sh +68 -0
  78. package/scripts/lib/assert-tool-event.mjs +50 -0
  79. package/scripts/memory-store.sh +71 -0
  80. package/scripts/memory-wiki-audit.sh +156 -0
  81. package/scripts/sync-claude-assets.sh +182 -0
  82. package/scripts/sync-opencode-explore-assets.sh +187 -0
  83. package/scripts/sync-opencode-graphify-assets.sh +152 -0
  84. package/scripts/sync-opencode-memory-assets.sh +160 -0
  85. package/scripts/sync-opencode-plugin-assets.sh +163 -0
  86. package/scripts/sync-opencode-security-assets.sh +195 -0
  87. package/scripts/sync-opencode-wiki-assets.sh +250 -0
  88. package/scripts/test-audit-timer.sh +29 -0
  89. package/scripts/test-bootstrap-untracked.sh +41 -0
  90. package/scripts/test-cli-dispatch.sh +21 -0
  91. package/scripts/test-doctor.sh +32 -0
  92. package/scripts/test-launcher-hooks.sh +66 -0
  93. package/scripts/test-memory-store.sh +44 -0
  94. package/scripts/test-remember-recall-assertions.sh +70 -0
  95. package/scripts/transcript-to-text.mjs +53 -0
  96. package/scripts/verify-docs-parity.sh +112 -0
  97. package/scripts/verify-explore-maturation.sh +288 -0
  98. package/scripts/verify-fastcontext-reliability.sh +624 -0
  99. package/scripts/verify-no-private-references.sh +145 -0
  100. package/scripts/verify-opencode-live-parity.sh +864 -0
  101. package/scripts/verify-routing-seam.sh +169 -0
  102. package/scripts/verify-token-savings-ab.sh +557 -0
  103. package/templates/PROJECT-INSTRUCTIONS.md.template +42 -0
  104. package/templates/alignment-gap-register.yaml.template +56 -0
  105. package/templates/alignment-policy.md.template +28 -0
  106. package/templates/alignment-pr-template.md.template +18 -0
  107. package/templates/alignment-report.md.template +56 -0
  108. package/templates/env.example.template +25 -0
  109. package/templates/graph-policy.md.template +37 -0
  110. package/templates/graphs-gitignore.template +3 -0
  111. package/templates/memory-wiki-audit.service.template +6 -0
  112. package/templates/memory-wiki-audit.timer.template +9 -0
  113. package/templates/memory.json.template +4 -0
  114. package/templates/planning-config.json.template +6 -0
  115. package/templates/project-brief.md.template +40 -0
  116. package/templates/security-finding-register.yaml.template +51 -0
  117. package/templates/security-issue.md.template +43 -0
  118. package/templates/security-policy.md.template +29 -0
  119. package/templates/security-report.md.template +43 -0
  120. package/templates/start-claude.sh.template +46 -0
  121. package/templates/start-opencode.sh.template +41 -0
  122. package/templates/structured-change-canvas.md.template +63 -0
  123. package/templates/wiki-contradictions.md.template +31 -0
  124. package/templates/wiki-index.md.template +40 -0
  125. package/templates/wiki-lint-report.md.template +38 -0
  126. package/templates/wiki-log.md.template +11 -0
  127. package/templates/wiki-policy.md.template +47 -0
  128. package/templates/wiki-query-answer.md.template +26 -0
  129. package/templates/wiki-source-summary.md.template +33 -0
@@ -0,0 +1,864 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Live OpenCode parity verification harness (OCP-06).
5
+ #
6
+ # Extends the Phase-4 OCP-05 scratch-HOME acceptance pattern (04-06-SUMMARY.md)
7
+ # to register `cairn-memory` as a real local MCP server inside a fresh scratch
8
+ # OpenCode environment and confirm the registration loads before any stage
9
+ # assertion is trusted. Stage functions (wakeup/recall/capture/remember/recall,
10
+ # negative controls) are added in 05-02; this script only builds setup,
11
+ # registration, the positive-load check, and cleanup.
12
+ #
13
+ # Every value here is either read from the operator's own environment
14
+ # (CAIRN_LLM_API_KEY / CAIRN_LLM_API_URL / CAIRN_LLM_EXTRACTION_MODEL) or
15
+ # generated at runtime (canary tokens, scratch dirs via mktemp -d). No
16
+ # endpoint host, model name, or canary literal is hardcoded here
17
+ # (DEC-no-private-references).
18
+
19
+ usage() {
20
+ cat <<'EOF'
21
+ Usage: verify-opencode-live-parity.sh --setup-only [seeded|unseeded]
22
+ verify-opencode-live-parity.sh --stage wakeup
23
+ verify-opencode-live-parity.sh --full
24
+ verify-opencode-live-parity.sh --repeat N
25
+
26
+ Stands up a fresh scratch OpenCode HOME, registers cairn-memory as a local
27
+ stdio MCP server pointing at the real mcp-memory-server/dist/index.js,
28
+ installs the OpenCode plugin/command assets, and runs a positive-load check
29
+ confirming cairn-memory_ tools are visible in the scratch environment.
30
+
31
+ Options:
32
+ --setup-only [seeded|unseeded]
33
+ Run setup_scratch, seed_canary, install_assets, write_scratch_config,
34
+ and positive_load_check end to end, then clean up. Mode defaults to
35
+ "seeded"; pass "unseeded" for a negative-control scratch project
36
+ (no canary written).
37
+ --stage wakeup
38
+ Fastest per-commit signal: the setup above plus the wakeup
39
+ FOUND/NOT-FOUND canary probe alone.
40
+ --full
41
+ The full suite: wakeup, recall-on-edit, capture, remember->recall
42
+ against a seeded project, then the full negative-control sweep
43
+ against a fresh unseeded project. Exits non-zero if any stage fails.
44
+ --repeat N
45
+ Soak the hardened remember->recall round-trip stage N times (default
46
+ 5), each iteration with its own fresh scratch environment (setup,
47
+ canary, asset install, config, capture server, and teardown -- no
48
+ state bleed between runs). Gated by a preflight tool-call-reliability
49
+ probe that runs once before the loop and aborts before any iteration
50
+ if the configured model isn't tool-call-reliable. Prints a
51
+ per-iteration PASS/FAIL row with its retry count, plus an aggregate
52
+ N/N verdict; exits non-zero unless every iteration passes. This is the
53
+ explicit slow reliability mode -- distinct from --stage wakeup
54
+ (fastest per-commit signal) and --full (one-shot regression of every
55
+ stage).
56
+ -h, --help
57
+ Show this help text.
58
+
59
+ Every run always cleans up scratch dirs and confirms the operator's real
60
+ ~/.config/opencode and ~/.claude are unmodified, regardless of exit path.
61
+ EOF
62
+ }
63
+
64
+ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
65
+ SERVER_ENTRY="$ROOT_DIR/mcp-memory-server/dist/index.js"
66
+ AGENTFS_SDK_ENTRY="$ROOT_DIR/mcp-memory-server/node_modules/agentfs-sdk/dist/index_node.js"
67
+
68
+ # Captured before any HOME reassignment so cleanup() always compares against
69
+ # the operator's real config, never a scratch path (T-05-02 mitigation).
70
+ ORIG_HOME="$HOME"
71
+ REAL_OPENCODE_CONFIG_DIR="${OPENCODE_CONFIG_DIR:-$ORIG_HOME/.config/opencode}"
72
+ REAL_CLAUDE_DIR="$ORIG_HOME/.claude"
73
+
74
+ SCRATCH_HOME=""
75
+ SCRATCH_PROJECT=""
76
+ SCRATCH_PROJECT_UNSEEDED=""
77
+ CANARY_KEY=""
78
+ CANARY_VALUE=""
79
+ CLEANED_UP=0
80
+ PRE_OPENCODE_FINGERPRINT=""
81
+ PRE_CLAUDE_FINGERPRINT=""
82
+ CAPTURE_SERVE_PID=""
83
+ CAPTURE_SERVE_URL=""
84
+ CAPTURE_SERVE_LOG=""
85
+ LAST_ROUNDTRIP_RETRIES=0
86
+
87
+ fingerprint_dir() {
88
+ local dir="$1"
89
+ if [[ -d "$dir" ]]; then
90
+ find "$dir" -type f -printf '%p %s %T@\n' 2>/dev/null | sort
91
+ else
92
+ echo "MISSING:$dir"
93
+ fi
94
+ }
95
+
96
+ capture_real_config_fingerprint() {
97
+ PRE_OPENCODE_FINGERPRINT=$(fingerprint_dir "$REAL_OPENCODE_CONFIG_DIR")
98
+ PRE_CLAUDE_FINGERPRINT=$(fingerprint_dir "$REAL_CLAUDE_DIR")
99
+ }
100
+
101
+ cleanup() {
102
+ if [[ "$CLEANED_UP" -eq 1 ]]; then
103
+ return 0
104
+ fi
105
+ CLEANED_UP=1
106
+
107
+ # Stop the capture stage's persistent `opencode serve` (if started) before
108
+ # touching scratch dirs — it may still be holding the scratch HOME's
109
+ # OPENCODE_CONFIG_DIR/cairn-memory MCP subprocess open.
110
+ if [[ -n "$CAPTURE_SERVE_PID" ]]; then
111
+ kill "$CAPTURE_SERVE_PID" 2>/dev/null || true
112
+ wait "$CAPTURE_SERVE_PID" 2>/dev/null || true
113
+ CAPTURE_SERVE_PID=""
114
+ fi
115
+ if [[ -n "$CAPTURE_SERVE_LOG" && -f "$CAPTURE_SERVE_LOG" ]]; then
116
+ rm -f "$CAPTURE_SERVE_LOG"
117
+ fi
118
+
119
+ if [[ -n "$SCRATCH_HOME" && -d "$SCRATCH_HOME" ]]; then
120
+ rm -rf "$SCRATCH_HOME"
121
+ fi
122
+ if [[ -n "$SCRATCH_PROJECT" && -d "$SCRATCH_PROJECT" ]]; then
123
+ rm -rf "$SCRATCH_PROJECT"
124
+ fi
125
+ if [[ -n "$SCRATCH_PROJECT_UNSEEDED" && -d "$SCRATCH_PROJECT_UNSEEDED" ]]; then
126
+ rm -rf "$SCRATCH_PROJECT_UNSEEDED"
127
+ fi
128
+
129
+ export HOME="$ORIG_HOME"
130
+ unset OPENCODE_CONFIG_DIR || true
131
+
132
+ local post_opencode post_claude tamper
133
+ post_opencode=$(fingerprint_dir "$REAL_OPENCODE_CONFIG_DIR")
134
+ post_claude=$(fingerprint_dir "$REAL_CLAUDE_DIR")
135
+ tamper=0
136
+
137
+ if [[ "$post_opencode" != "$PRE_OPENCODE_FINGERPRINT" ]]; then
138
+ echo "FATAL: real OpenCode config changed during the run ($REAL_OPENCODE_CONFIG_DIR)" >&2
139
+ tamper=1
140
+ fi
141
+ if [[ "$post_claude" != "$PRE_CLAUDE_FINGERPRINT" ]]; then
142
+ echo "FATAL: real ~/.claude changed during the run ($REAL_CLAUDE_DIR)" >&2
143
+ tamper=1
144
+ fi
145
+
146
+ if [[ "$tamper" -eq 0 ]]; then
147
+ echo "[cleanup] real OpenCode config and ~/.claude confirmed untouched; scratch dirs removed"
148
+ fi
149
+ }
150
+
151
+ # setup_scratch(): fresh scratch HOME + project, CAIRN_LLM_* env exported into
152
+ # this shell (reaches memory-capture.ts's/memory-wakeup.ts's direct `node
153
+ # <server>` shell-out, not only mcp.cairn-memory.environment — Pitfall 3),
154
+ # OPENCODE_CONFIG_DIR set to exactly $SCRATCH_HOME/.config/opencode (the
155
+ # default resolution path — never a custom path, avoiding the open #4399 bug).
156
+ setup_scratch() {
157
+ SCRATCH_HOME=$(mktemp -d)
158
+ SCRATCH_PROJECT=$(mktemp -d)
159
+
160
+ export HOME="$SCRATCH_HOME"
161
+ export OPENCODE_CONFIG_DIR="$SCRATCH_HOME/.config/opencode"
162
+
163
+ export CAIRN_LLM_API_KEY="${CAIRN_LLM_API_KEY:-}"
164
+ export CAIRN_LLM_API_URL="${CAIRN_LLM_API_URL:-}"
165
+ export CAIRN_LLM_EXTRACTION_MODEL="${CAIRN_LLM_EXTRACTION_MODEL:-}"
166
+
167
+ echo "[setup_scratch] SCRATCH_HOME=$SCRATCH_HOME"
168
+ echo "[setup_scratch] SCRATCH_PROJECT=$SCRATCH_PROJECT"
169
+ echo "[setup_scratch] OPENCODE_CONFIG_DIR=$OPENCODE_CONFIG_DIR"
170
+ echo "[setup_scratch] CAIRN_LLM_API_KEY set: $([[ -n "$CAIRN_LLM_API_KEY" ]] && echo yes || echo no)"
171
+ echo "[setup_scratch] CAIRN_LLM_API_URL set: $([[ -n "$CAIRN_LLM_API_URL" ]] && echo yes || echo no)"
172
+ echo "[setup_scratch] CAIRN_LLM_EXTRACTION_MODEL set: $([[ -n "$CAIRN_LLM_EXTRACTION_MODEL" ]] && echo yes || echo no)"
173
+
174
+ trap cleanup EXIT
175
+ }
176
+
177
+ # seed_canary(mode): mode="seeded" (default) writes a fresh, runtime-generated
178
+ # canary into the scratch project's AgentFS project scope
179
+ # ($SCRATCH_PROJECT/.agentfs/project.db, mirroring resolveScopePath("project")
180
+ # in mcp-memory-server/src/index.ts). mode="unseeded" leaves the project
181
+ # scope empty for a negative control (05-02).
182
+ seed_canary() {
183
+ local mode="${1:-seeded}"
184
+ local db_dir="$SCRATCH_PROJECT/.agentfs"
185
+ local db_path="$db_dir/project.db"
186
+
187
+ mkdir -p "$db_dir"
188
+
189
+ if [[ "$mode" == "unseeded" ]]; then
190
+ echo "[seed_canary] mode=unseeded — no canary written (negative-control project)"
191
+ return 0
192
+ fi
193
+
194
+ CANARY_KEY="ocp-06-canary-fact"
195
+ CANARY_VALUE="OCP-06-CANARY-$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')"
196
+
197
+ SEED_DB_PATH="$db_path" \
198
+ SEED_KEY="$CANARY_KEY" \
199
+ SEED_VALUE="$CANARY_VALUE" \
200
+ AGENTFS_SDK_ENTRY="$AGENTFS_SDK_ENTRY" \
201
+ node --input-type=module -e '
202
+ import { pathToFileURL } from "node:url";
203
+ const mod = await import(pathToFileURL(process.env.AGENTFS_SDK_ENTRY).href);
204
+ const { AgentFS } = mod;
205
+ const agent = await AgentFS.open({ id: "project", path: process.env.SEED_DB_PATH });
206
+ try {
207
+ await agent.kv.set(process.env.SEED_KEY, process.env.SEED_VALUE);
208
+ } finally {
209
+ await agent.close();
210
+ }
211
+ '
212
+
213
+ echo "[seed_canary] mode=seeded key=$CANARY_KEY value=$CANARY_VALUE db=$db_path"
214
+ }
215
+
216
+ # install_assets(): installs the OpenCode plugin + command assets into the
217
+ # scratch config tree via the existing idempotent sync scripts, rendering
218
+ # @@INFRA_ROOT@@ to this real repo checkout. No Claude assets are installed
219
+ # anywhere in this flow, proving the fresh-OpenCode-only install bar.
220
+ install_assets() {
221
+ "$ROOT_DIR/scripts/sync-opencode-plugin-assets.sh" --apply --live-root "$OPENCODE_CONFIG_DIR"
222
+ "$ROOT_DIR/scripts/sync-opencode-memory-assets.sh" --apply --live-root "$OPENCODE_CONFIG_DIR"
223
+ }
224
+
225
+ # write_scratch_config(): writes the scratch opencode.json registering
226
+ # cairn-memory as a local stdio MCP server (absolute real repo path — a
227
+ # relative path breaks once OpenCode's cwd differs from the repo root),
228
+ # a provider/model block interpolated from the exported CAIRN_LLM_* vars
229
+ # (no hardcoded host), and a permissive permission block so headless runs
230
+ # never hang on approval prompts (Pitfall 4). The config path is built
231
+ # explicitly from $SCRATCH_HOME, never from $HOME after reassignment
232
+ # (T-05-02 tampering mitigation).
233
+ write_scratch_config() {
234
+ local config_dir="$SCRATCH_HOME/.config/opencode"
235
+ local config_path="$config_dir/opencode.json"
236
+ local api_key="${CAIRN_LLM_API_KEY:-}"
237
+ local api_url="${CAIRN_LLM_API_URL:-}"
238
+ local model_name="${CAIRN_LLM_EXTRACTION_MODEL:-}"
239
+
240
+ mkdir -p "$config_dir"
241
+
242
+ cat > "$config_path" <<EOF
243
+ {
244
+ "\$schema": "https://opencode.ai/config.json",
245
+ "mcp": {
246
+ "cairn-memory": {
247
+ "type": "local",
248
+ "command": ["node", "$SERVER_ENTRY"],
249
+ "enabled": true
250
+ }
251
+ },
252
+ "provider": {
253
+ "verify-local": {
254
+ "npm": "@ai-sdk/openai-compatible",
255
+ "name": "Verify Local",
256
+ "options": { "apiKey": "$api_key", "baseURL": "$api_url" },
257
+ "models": { "$model_name": { "name": "$model_name" } }
258
+ }
259
+ },
260
+ "model": "verify-local/$model_name",
261
+ "permission": {
262
+ "exec": { "*": "allow" },
263
+ "external_directory": { "**": "allow" },
264
+ "read": { "**": "allow" },
265
+ "write": { "**": "allow" }
266
+ }
267
+ }
268
+ EOF
269
+
270
+ echo "[write_scratch_config] wrote $config_path"
271
+ }
272
+
273
+ # positive_load_check(): a fast confirmation that the MCP registration loaded
274
+ # and no real-config tools leaked in — Pitfall 2's cheap pre-flight. Bounded
275
+ # by `timeout` so an unreachable model endpoint fails loudly instead of
276
+ # hanging the harness indefinitely.
277
+ #
278
+ # Note: the installed CLI (confirmed live: `opencode run --help`) has no
279
+ # `--auto` flag as 05-RESEARCH.md's docs-derived assumption expected; the
280
+ # real flag for "auto-approve permissions not explicitly denied" is
281
+ # --dangerously-skip-permissions. A fresh scratch opencode.json also carries
282
+ # a permissive `permission` block (write_scratch_config), so this flag is
283
+ # belt-and-suspenders against Pitfall 4, not the sole guard.
284
+ positive_load_check() {
285
+ echo "[positive_load_check] running opencode run to list tools..."
286
+ local out
287
+ out=$(cd "$SCRATCH_PROJECT" && timeout 90 opencode run "list your available tools" --dir "$SCRATCH_PROJECT" --format json --dangerously-skip-permissions 2>&1) || true
288
+
289
+ if echo "$out" | grep -q 'cairn-memory_'; then
290
+ echo "[positive_load_check] OK: cairn-memory_ tools found in scratch env"
291
+ return 0
292
+ fi
293
+
294
+ echo "[positive_load_check] FAIL: no cairn-memory_ tool names found in opencode run output" >&2
295
+ echo "$out" | tail -n 40 >&2
296
+ return 1
297
+ }
298
+
299
+ # log_env_presence(): echoes whether the CAIRN_LLM_* guard vars are set
300
+ # (never their values) so a stage failure can be triaged as "env wasn't
301
+ # exported into this shell" vs "a real plugin/model failure" (Pitfall 3).
302
+ log_env_presence() {
303
+ echo "[env] CAIRN_LLM_API_KEY set: $([[ -n "${CAIRN_LLM_API_KEY:-}" ]] && echo yes || echo no)" >&2
304
+ echo "[env] CAIRN_LLM_API_URL set: $([[ -n "${CAIRN_LLM_API_URL:-}" ]] && echo yes || echo no)" >&2
305
+ echo "[env] CAIRN_LLM_EXTRACTION_MODEL set: $([[ -n "${CAIRN_LLM_EXTRACTION_MODEL:-}" ]] && echo yes || echo no)" >&2
306
+ }
307
+
308
+ # run_opencode(project_dir, timeout_secs, prompt, [extra opencode-run args...]):
309
+ # thin wrapper around `opencode run` used by every stage. Uses
310
+ # --dangerously-skip-permissions (not --auto — the installed CLI, v1.17.11,
311
+ # has no such flag; confirmed live in 05-01 and reconfirmed here). Never pass
312
+ # a prompt containing literal `<...>` angle-bracket placeholder syntax — live
313
+ # testing this phase found it makes the installed CLI hang indefinitely with
314
+ # zero output (reproduced twice, unrelated to model load/timeout); every
315
+ # prompt in this script is phrased without angle brackets for this reason.
316
+ run_opencode() {
317
+ local project_dir="$1"; shift
318
+ local timeout_secs="$1"; shift
319
+ local prompt="$1"; shift
320
+ (cd "$project_dir" && timeout "$timeout_secs" opencode run "$prompt" --dir "$project_dir" --format json --dangerously-skip-permissions "$@" 2>&1)
321
+ }
322
+
323
+ # CANARY_PREFIX: the fixed literal prefix every seed_canary()-generated value
324
+ # starts with, regardless of the random suffix. Used by negative controls to
325
+ # assert "no canary-shaped value leaked" even when the control project never
326
+ # had its own canary generated (T-05-01/T-05-03).
327
+ CANARY_PREFIX="OCP-06-CANARY"
328
+
329
+ # run_stage_wakeup(project_dir, mode): re-confirms OCP-05 live in THIS
330
+ # phase's own scratch env (05-RESEARCH.md anti-pattern: never assume Phase-4
331
+ # carryover). mode="seeded" asserts the seeded canary is echoed back under
332
+ # both an explicit-recite prompt and a natural-framing prompt (Phase-4 Run
333
+ # A/Run B pattern — rules out prompt leakage). mode="unseeded" asserts
334
+ # NOT-FOUND and no canary-shaped leak.
335
+ run_stage_wakeup() {
336
+ local project_dir="$1" mode="$2"
337
+ local explicit_prompt='Inspect your session-start system context. If it contains a line under a heading about project memory, reply with exactly: FOUND: the fact verbatim. If there is no such fact, reply exactly: NOT-FOUND.'
338
+ local natural_prompt='Without me telling you anything new, what specific fact do you already know about this project?'
339
+ local out_a out_b
340
+
341
+ out_a=$(run_opencode "$project_dir" 60 "$explicit_prompt") || true
342
+ out_b=$(run_opencode "$project_dir" 60 "$natural_prompt") || true
343
+
344
+ if [[ "$mode" == "seeded" ]]; then
345
+ if echo "$out_a" | grep -qF "$CANARY_VALUE" && echo "$out_b" | grep -qF "$CANARY_VALUE"; then
346
+ echo "[run_stage_wakeup:$mode] OK: canary surfaced (explicit-recite AND natural-framing runs)"
347
+ return 0
348
+ fi
349
+ echo "[run_stage_wakeup:$mode] FAIL: canary not surfaced in one or both runs" >&2
350
+ log_env_presence
351
+ echo "--- explicit-recite run (tail) ---" >&2
352
+ echo "$out_a" | tail -n 20 >&2
353
+ echo "--- natural-framing run (tail) ---" >&2
354
+ echo "$out_b" | tail -n 20 >&2
355
+ return 1
356
+ else
357
+ if echo "$out_a" | grep -qi "NOT-FOUND" && ! echo "$out_a" | grep -qF "$CANARY_PREFIX" && ! echo "$out_b" | grep -qF "$CANARY_PREFIX"; then
358
+ echo "[run_stage_wakeup:$mode] OK: NOT-FOUND confirmed, no canary-shaped leak"
359
+ return 0
360
+ fi
361
+ echo "[run_stage_wakeup:$mode] FAIL: expected NOT-FOUND / no canary-shaped leak" >&2
362
+ log_env_presence
363
+ echo "$out_a" | tail -n 20 >&2
364
+ return 1
365
+ fi
366
+ }
367
+
368
+ # run_stage_recall_edit(project_dir, mode): seeds a fact whose key IS the
369
+ # file stem (CANARY_KEY, seeded by seed_canary()) so memory-recall.ts's
370
+ # stem-substring match against the wakeup index's `- <key>: <preview>` line
371
+ # hits. mode="seeded" asserts a matching-file edit throws the injected
372
+ # "Memory recall" context containing the canary, AND a non-matching-file
373
+ # edit stays silent (the high-signal/low-noise pair, OCP-02). mode="unseeded"
374
+ # asserts a matching-file-name edit stays silent (no AgentFS data to surface).
375
+ run_stage_recall_edit() {
376
+ local project_dir="$1" mode="$2"
377
+ local match_file="${CANARY_KEY}.md"
378
+ local nomatch_file="routine-notes.md"
379
+ local out_match out_nomatch matched=0
380
+
381
+ if [[ "$mode" == "seeded" ]]; then
382
+ # Bounded retry (live testing this phase found the local model sometimes
383
+ # narrates an edit without actually issuing the tool call, regardless of
384
+ # prompt category — a general model-reliability characteristic, not a
385
+ # plugin defect; delete the target file between attempts so a stale
386
+ # "file already exists" observation from the model can't suppress the
387
+ # next attempt's tool call).
388
+ for _attempt in 1 2 3; do
389
+ rm -f "${project_dir:?}/${match_file}"
390
+ out_match=$(run_opencode "$project_dir" 60 "Create a new file named ${match_file} in the project root with the single line: placeholder") || true
391
+ if echo "$out_match" | grep -qF "Memory recall (auto-injected" && echo "$out_match" | grep -qF "$CANARY_VALUE"; then
392
+ matched=1
393
+ break
394
+ fi
395
+ done
396
+
397
+ if [[ "$matched" -eq 1 ]]; then
398
+ echo "[run_stage_recall_edit:$mode] OK: matching-file edit threw injected recall context containing the canary"
399
+ else
400
+ echo "[run_stage_recall_edit:$mode] FAIL: matching-file edit did not surface the injected recall context after 3 attempts" >&2
401
+ log_env_presence
402
+ echo "$out_match" | tail -n 30 >&2
403
+ return 1
404
+ fi
405
+
406
+ out_nomatch=$(run_opencode "$project_dir" 60 "Create a new file named ${nomatch_file} in the project root with the single line: placeholder") || true
407
+ if echo "$out_nomatch" | grep -qF "Memory recall (auto-injected"; then
408
+ echo "[run_stage_recall_edit:$mode] FAIL: non-matching-file edit unexpectedly surfaced recall context" >&2
409
+ echo "$out_nomatch" | tail -n 30 >&2
410
+ return 1
411
+ fi
412
+ echo "[run_stage_recall_edit:$mode] OK: non-matching-file edit stayed silent"
413
+ return 0
414
+ else
415
+ out_match=$(run_opencode "$project_dir" 60 "Create a new file named ${match_file} in the project root with the single line: placeholder") || true
416
+ if echo "$out_match" | grep -qF "Memory recall (auto-injected"; then
417
+ echo "[run_stage_recall_edit:$mode] FAIL: unseeded project surfaced recall context unexpectedly" >&2
418
+ echo "$out_match" | tail -n 30 >&2
419
+ return 1
420
+ fi
421
+ echo "[run_stage_recall_edit:$mode] OK: unseeded project stayed silent on a matching-name edit (no AgentFS data)"
422
+ return 0
423
+ fi
424
+ }
425
+
426
+ # setup_negative_project(): a second scratch project directory (no .agentfs
427
+ # seeded) for every stage's negative control. Plugin/command assets are
428
+ # global (installed once into $OPENCODE_CONFIG_DIR by install_assets), so
429
+ # the negative-control project only needs to exist as a bare directory.
430
+ setup_negative_project() {
431
+ SCRATCH_PROJECT_UNSEEDED=$(mktemp -d)
432
+ echo "[setup_negative_project] SCRATCH_PROJECT_UNSEEDED=$SCRATCH_PROJECT_UNSEEDED"
433
+ }
434
+
435
+ # start_capture_server()/stop_capture_server(): the capture stage cannot use
436
+ # a bare `opencode run` invocation. Live verification this phase found that
437
+ # `opencode run` tears down its whole process (killing any still-running
438
+ # child processes, e.g. the extract subprocess memory-capture.ts spawns)
439
+ # immediately once its own turn finishes — it does not wait for plugin
440
+ # `event` handlers' async work (session.idle -> extract) to settle. This
441
+ # reproduced deterministically: an instrumented run showed the extract
442
+ # child process spawned successfully but was killed before its "close"
443
+ # event could fire, even though the whole `opencode run` invocation's own
444
+ # wall-clock time was only ~3s. This is a genuine limitation of `opencode
445
+ # run`'s headless process lifecycle, not something a plugin-side code
446
+ # change can outrun.
447
+ #
448
+ # Workaround (harness-only, no plugin/server change): `opencode serve`
449
+ # starts a headless, persistent server process; `opencode run --attach
450
+ # <url>` then drives it as a client whose own exit does NOT kill the
451
+ # server. The server keeps running long enough for session.idle's async
452
+ # extract call to complete naturally. Confirmed live: with --attach, the
453
+ # same extract subprocess's "close" event fired ~8s after the triggering
454
+ # turn, well after the `run` client itself had already exited.
455
+ start_capture_server() {
456
+ CAPTURE_SERVE_LOG=$(mktemp)
457
+ opencode serve --port 0 --hostname 127.0.0.1 >"$CAPTURE_SERVE_LOG" 2>&1 &
458
+ CAPTURE_SERVE_PID=$!
459
+
460
+ CAPTURE_SERVE_URL=""
461
+ for _wait_sec in 1 2 3 4 5 6 7 8 9 10; do
462
+ if grep -q "listening on" "$CAPTURE_SERVE_LOG" 2>/dev/null; then
463
+ CAPTURE_SERVE_URL=$(grep -o 'http://[0-9.]*:[0-9]*' "$CAPTURE_SERVE_LOG" | head -1)
464
+ break
465
+ fi
466
+ sleep 1
467
+ done
468
+
469
+ if [[ -z "$CAPTURE_SERVE_URL" ]]; then
470
+ echo "[start_capture_server] FAIL: opencode serve did not report a listening URL within 10s" >&2
471
+ cat "$CAPTURE_SERVE_LOG" >&2
472
+ return 1
473
+ fi
474
+ echo "[start_capture_server] OK: $CAPTURE_SERVE_URL (pid=$CAPTURE_SERVE_PID)"
475
+ }
476
+
477
+ stop_capture_server() {
478
+ if [[ -n "$CAPTURE_SERVE_PID" ]]; then
479
+ kill "$CAPTURE_SERVE_PID" 2>/dev/null || true
480
+ wait "$CAPTURE_SERVE_PID" 2>/dev/null || true
481
+ CAPTURE_SERVE_PID=""
482
+ fi
483
+ CAPTURE_SERVE_URL=""
484
+ }
485
+
486
+ # run_stage_capture(project_dir, mode): drives the capture stage through the
487
+ # persistent server (start_capture_server must already be running). A
488
+ # durable-fact-worthy turn is sent via --attach; the stage then polls for a
489
+ # new .planning/memory-staging/*.json containing the turn's canary. Bounded
490
+ # retry (3 attempts for mode="seeded") absorbs an independently-confirmed
491
+ # characteristic of the configured local extraction model: it sometimes
492
+ # exhausts its response budget on reasoning tokens before emitting the
493
+ # candidates JSON, yielding an empty (not malformed) extraction result —
494
+ # not a plugin defect (mcp-memory-server's extractMemoryCandidates, out of
495
+ # this plan's file scope).
496
+ run_stage_capture() {
497
+ local project_dir="$1" mode="$2"
498
+ local staging_dir="$project_dir/.planning/memory-staging"
499
+ rm -rf "${staging_dir:?}"
500
+
501
+ if [[ -z "$CAPTURE_SERVE_URL" ]]; then
502
+ echo "[run_stage_capture:$mode] FAIL: capture server not running (call start_capture_server first)" >&2
503
+ return 1
504
+ fi
505
+
506
+ local attempts_list="1 2 3"
507
+ [[ "$mode" == "unseeded" ]] && attempts_list="1"
508
+
509
+ local canary found=0
510
+ for _attempt in $attempts_list; do
511
+ canary="OCP-06-CAPTURE-${mode}-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
512
+ (cd "$project_dir" && timeout 60 opencode run "Important decision to remember for future sessions, no need to use any tools yourself: this project's staging deployment region is permanently fixed to $canary. This is a durable architectural constraint, not a temporary note." --attach "$CAPTURE_SERVE_URL" --dir "$project_dir" --format json --dangerously-skip-permissions >/dev/null 2>&1) || true
513
+
514
+ for _poll_sec in 1 2 3 4 5 6 7 8 9 10; do
515
+ if [[ -d "$staging_dir" ]] && grep -rq "$canary" "$staging_dir"/*.json 2>/dev/null; then
516
+ found=1
517
+ break
518
+ fi
519
+ sleep 1
520
+ done
521
+ [[ "$found" -eq 1 ]] && break
522
+ done
523
+
524
+ if [[ "$mode" == "seeded" ]]; then
525
+ if [[ "$found" -eq 1 ]]; then
526
+ echo "[run_stage_capture:$mode] OK: staged candidate contains the turn's canary"
527
+ return 0
528
+ fi
529
+ echo "[run_stage_capture:$mode] FAIL: no staged candidate contained the canary after retries" >&2
530
+ log_env_presence
531
+ return 1
532
+ else
533
+ if [[ "$found" -eq 0 ]]; then
534
+ echo "[run_stage_capture:$mode] OK: unseeded project staged nothing containing a canary"
535
+ return 0
536
+ fi
537
+ echo "[run_stage_capture:$mode] FAIL: unseeded project unexpectedly staged a canary-bearing candidate" >&2
538
+ return 1
539
+ fi
540
+ }
541
+
542
+ # extract_session_id(): reads NDJSON `opencode run --format json` output on
543
+ # stdin, prints the first non-null top-level `sessionID` field (05-01
544
+ # confirmed this exact field name live) so a later `opencode run --session`
545
+ # call can continue the same session.
546
+ extract_session_id() {
547
+ node -e '
548
+ let data = "";
549
+ process.stdin.on("data", (c) => { data += c; });
550
+ process.stdin.on("end", () => {
551
+ for (const line of data.split("\n")) {
552
+ if (!line.trim()) continue;
553
+ try {
554
+ const parsed = JSON.parse(line);
555
+ if (parsed.sessionID) {
556
+ process.stdout.write(parsed.sessionID);
557
+ process.exit(0);
558
+ }
559
+ } catch {
560
+ // not a JSON line, skip
561
+ }
562
+ }
563
+ });
564
+ '
565
+ }
566
+
567
+ # assert_tool_event(tool_name_regex, [canary]): bash wrapper around
568
+ # scripts/lib/assert-tool-event.mjs (D-08/D-09). Reads NDJSON on stdin,
569
+ # returns 0 only for a genuine completed tool_use event whose part.tool
570
+ # matches tool_name_regex (and, if canary is given, whose part.state.output
571
+ # contains it) -- never a narrated-but-unexecuted mention inside a text event.
572
+ assert_tool_event() {
573
+ TOOL_EVENT_REGEX="$1" TOOL_EVENT_CANARY="${2:-}" node "$ROOT_DIR/scripts/lib/assert-tool-event.mjs"
574
+ }
575
+
576
+ # run_stage_remember_recall(project_dir, mode): mode="seeded" writes a fresh
577
+ # canary via `/remember`, asserting a genuine cairn-memory_memory_write/
578
+ # _supersede tool_use event (D-08/D-09, via assert_tool_event), captures the
579
+ # sessionID from that call's JSON output, then continues the SAME session
580
+ # with `/recall` for the topic, asserting a genuine
581
+ # cairn-memory_memory_search/_read tool_use event whose part.state.output
582
+ # contains the canary (OCP-03/OCP-04's write-then-read-back round trip).
583
+ # mode="unseeded" only runs `/recall` (nothing was ever remembered there)
584
+ # and asserts no canary-linked tool_use event fires.
585
+ #
586
+ # D-11: both halves drive through the persistent `opencode serve` via
587
+ # --attach (start_capture_server must already be running) rather than bare
588
+ # `opencode run`, matching run_stage_capture's transport.
589
+ #
590
+ # D-13 retry classification: a run's exit code is captured explicitly (never
591
+ # swallowed by `|| true` before being read). A run is retried (bounded to 3
592
+ # attempts per half, LAST_ROUNDTRIP_RETRIES incremented each retry) only on
593
+ # an INFRA failure — a `timeout`-triggered kill (exit 124) or empty output
594
+ # (extract_session_id yields nothing, i.e. no real events were emitted at
595
+ # all). A run that completed cleanly (a real session_id exists) but whose
596
+ # assert_tool_event check fails is a NARRATION failure: the iteration FAILs
597
+ # immediately, no retry — this is the exact upstream race (Pitfall 4) vs.
598
+ # model-narration distinction 13-RESEARCH.md pins down.
599
+ run_stage_remember_recall() {
600
+ local project_dir="$1" mode="$2"
601
+ local topic="the ci pipeline canary token"
602
+ local canary out_remember out_recall session_id rc
603
+
604
+ if [[ -z "$CAPTURE_SERVE_URL" ]]; then
605
+ echo "[run_stage_remember_recall:$mode] FAIL: capture server not running (call start_capture_server first)" >&2
606
+ return 1
607
+ fi
608
+
609
+ LAST_ROUNDTRIP_RETRIES=0
610
+
611
+ if [[ "$mode" == "seeded" ]]; then
612
+ canary="OCP-06-REMEMBER-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
613
+
614
+ for _attempt in 1 2 3; do
615
+ rc=0
616
+ out_remember=$(run_opencode "$project_dir" 60 "/remember ${topic} is ${canary}" --attach "$CAPTURE_SERVE_URL") || rc=$?
617
+ session_id=$(printf '%s' "$out_remember" | extract_session_id)
618
+
619
+ if [[ "$rc" -eq 124 || -z "$session_id" ]]; then
620
+ if [[ "$_attempt" -lt 3 ]]; then
621
+ LAST_ROUNDTRIP_RETRIES=$((LAST_ROUNDTRIP_RETRIES + 1))
622
+ echo "[run_stage_remember_recall:$mode] INFRA retry: /remember attempt $_attempt (rc=$rc, no real events emitted)" >&2
623
+ continue
624
+ fi
625
+ echo "[run_stage_remember_recall:$mode] FAIL: /remember exhausted infra retries (rc=$rc, no session established)" >&2
626
+ log_env_presence
627
+ printf '%s' "$out_remember" | tail -n 20 >&2
628
+ return 1
629
+ fi
630
+
631
+ if printf '%s' "$out_remember" | assert_tool_event 'cairn-memory_memory_(write|supersede)'; then
632
+ break
633
+ fi
634
+
635
+ echo "[run_stage_remember_recall:$mode] FAIL: /remember completed but performed no genuine memory_write/_supersede tool_use event (narration failure, no retry)" >&2
636
+ log_env_presence
637
+ printf '%s' "$out_remember" | tail -n 20 >&2
638
+ return 1
639
+ done
640
+ echo "[run_stage_remember_recall:$mode] OK: /remember wrote via a genuine tool_use event (sessionID=$session_id, retries=$LAST_ROUNDTRIP_RETRIES)"
641
+
642
+ for _attempt in 1 2 3; do
643
+ rc=0
644
+ out_recall=$( (cd "$project_dir" && timeout 60 opencode run "recall ${topic}" --dir "$project_dir" --format json --dangerously-skip-permissions --session "$session_id" --attach "$CAPTURE_SERVE_URL" 2>&1) ) || rc=$?
645
+ local recall_session_id
646
+ recall_session_id=$(printf '%s' "$out_recall" | extract_session_id)
647
+
648
+ if [[ "$rc" -eq 124 || -z "$recall_session_id" ]]; then
649
+ if [[ "$_attempt" -lt 3 ]]; then
650
+ LAST_ROUNDTRIP_RETRIES=$((LAST_ROUNDTRIP_RETRIES + 1))
651
+ echo "[run_stage_remember_recall:$mode] INFRA retry: /recall attempt $_attempt (rc=$rc, no real events emitted)" >&2
652
+ continue
653
+ fi
654
+ echo "[run_stage_remember_recall:$mode] FAIL: /recall exhausted infra retries (rc=$rc, no real events emitted)" >&2
655
+ log_env_presence
656
+ printf '%s' "$out_recall" | tail -n 30 >&2
657
+ return 1
658
+ fi
659
+
660
+ if printf '%s' "$out_recall" | assert_tool_event 'cairn-memory_memory_(search|read)' "$canary"; then
661
+ echo "[run_stage_remember_recall:$mode] OK: recall retrieved the canary via a genuine memory_search/_read tool_use event (retries=$LAST_ROUNDTRIP_RETRIES)"
662
+ return 0
663
+ fi
664
+
665
+ echo "[run_stage_remember_recall:$mode] FAIL: /recall completed but performed no genuine memory_search/_read tool_use event returning the canary (narration failure, no retry)" >&2
666
+ log_env_presence
667
+ printf '%s' "$out_recall" | tail -n 30 >&2
668
+ return 1
669
+ done
670
+ else
671
+ canary="OCP-06-REMEMBER-unseeded-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
672
+ rc=0
673
+ out_recall=$(run_opencode "$project_dir" 60 "recall ${topic}" --attach "$CAPTURE_SERVE_URL") || rc=$?
674
+
675
+ if [[ "$rc" -eq 124 || -z "$(printf '%s' "$out_recall" | extract_session_id)" ]]; then
676
+ echo "[run_stage_remember_recall:$mode] FAIL: unseeded recall infra failure (rc=$rc, no real events emitted)" >&2
677
+ log_env_presence
678
+ printf '%s' "$out_recall" | tail -n 20 >&2
679
+ return 1
680
+ fi
681
+
682
+ if printf '%s' "$out_recall" | assert_tool_event 'cairn-memory_memory_(search|read)' "$canary"; then
683
+ echo "[run_stage_remember_recall:$mode] FAIL: unseeded project unexpectedly returned a canary-linked memory_search/_read tool_use event" >&2
684
+ return 1
685
+ fi
686
+ echo "[run_stage_remember_recall:$mode] OK: unseeded project recall performed no canary-linked tool_use event (not-found)"
687
+ return 0
688
+ fi
689
+ }
690
+
691
+ # preflight_tool_call_probe(): D-06 mechanical gate for the --repeat soak.
692
+ # Step 1 (before ANY opencode call -- Pitfall 2): fails fast if any of
693
+ # CAIRN_LLM_API_KEY/CAIRN_LLM_API_URL/CAIRN_LLM_EXTRACTION_MODEL is empty.
694
+ # Note (load-bearing per Pitfall 3): opencode ships a bundled default model,
695
+ # so this env-var presence check is the guard that prevents a false PASS
696
+ # against opencode's own default when CAIRN_LLM_* is unset -- do not remove
697
+ # it. Step 2: reuses the existing scratch bring-up so the probe drives the
698
+ # REAL cairn-memory MCP tool (Open Question 2), then runs ONE cheap turn via
699
+ # run_opencode with --attach forcing a single write-style tool call against a
700
+ # disposable canary, asserted via assert_tool_event. Step 3: tears the
701
+ # scratch environment down. Never echoes CAIRN_LLM_API_KEY.
702
+ preflight_tool_call_probe() {
703
+ if [[ -z "${CAIRN_LLM_API_KEY:-}" || -z "${CAIRN_LLM_API_URL:-}" || -z "${CAIRN_LLM_EXTRACTION_MODEL:-}" ]]; then
704
+ log_env_presence
705
+ echo "[preflight] FAIL: CAIRN_LLM_* not fully set — see docs/operating.md" >&2
706
+ return 1
707
+ fi
708
+
709
+ capture_real_config_fingerprint
710
+ setup_scratch
711
+ seed_canary seeded
712
+ install_assets
713
+ write_scratch_config
714
+ start_capture_server
715
+
716
+ local canary out
717
+ canary="OCP-07-PREFLIGHT-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
718
+ out=$(run_opencode "$SCRATCH_PROJECT" 90 "/remember the preflight probe canary is ${canary}" --attach "$CAPTURE_SERVE_URL") || true
719
+
720
+ stop_capture_server
721
+ CLEANED_UP=0
722
+ cleanup
723
+
724
+ if printf '%s' "$out" | assert_tool_event 'cairn-memory_memory_(write|supersede)'; then
725
+ echo "[preflight] OK: model made a genuine tool call"
726
+ return 0
727
+ fi
728
+
729
+ echo "[preflight] FAIL: model is not tool-call-reliable (no-thinking required) — see docs/operating.md" >&2
730
+ log_env_presence
731
+ printf '%s' "$out" | tail -n 20 >&2
732
+ return 1
733
+ }
734
+
735
+ # run_negative_controls(): sweeps every stage against the unseeded
736
+ # negative-control project as one pass — the D-04 proof that a surfaced
737
+ # canary can only have come from injected memory, never model
738
+ # training/guessing. Returns non-zero if ANY control fails.
739
+ run_negative_controls() {
740
+ local failures=0
741
+ echo "=== Negative control sweep (unseeded project: $SCRATCH_PROJECT_UNSEEDED) ==="
742
+ run_stage_wakeup "$SCRATCH_PROJECT_UNSEEDED" unseeded || failures=1
743
+ run_stage_recall_edit "$SCRATCH_PROJECT_UNSEEDED" unseeded || failures=1
744
+ run_stage_capture "$SCRATCH_PROJECT_UNSEEDED" unseeded || failures=1
745
+ run_stage_remember_recall "$SCRATCH_PROJECT_UNSEEDED" unseeded || failures=1
746
+ return "$failures"
747
+ }
748
+
749
+ main() {
750
+ case "${1:-}" in
751
+ --setup-only)
752
+ capture_real_config_fingerprint
753
+ setup_scratch
754
+ seed_canary "${2:-seeded}"
755
+ install_assets
756
+ write_scratch_config
757
+ positive_load_check
758
+ ;;
759
+ --stage)
760
+ # Fastest per-commit signal: the wakeup FOUND/NOT-FOUND probe alone
761
+ # (confirms MCP registration + isolation didn't regress) without the
762
+ # slower capture/remember/recall stages.
763
+ capture_real_config_fingerprint
764
+ setup_scratch
765
+ seed_canary seeded
766
+ install_assets
767
+ write_scratch_config
768
+ positive_load_check
769
+ case "${2:-wakeup}" in
770
+ wakeup)
771
+ run_stage_wakeup "$SCRATCH_PROJECT" seeded
772
+ ;;
773
+ *)
774
+ echo "Unknown stage: ${2:-}" >&2
775
+ exit 2
776
+ ;;
777
+ esac
778
+ ;;
779
+ --full)
780
+ # Full suite: every stage against the seeded project, then the full
781
+ # negative-control sweep against a fresh unseeded project. Single
782
+ # non-zero-on-failure exit code for a 05-03-consumable green/red
783
+ # signal.
784
+ capture_real_config_fingerprint
785
+ setup_scratch
786
+ seed_canary seeded
787
+ setup_negative_project
788
+ install_assets
789
+ write_scratch_config
790
+ positive_load_check
791
+ start_capture_server
792
+
793
+ local failures=0
794
+ run_stage_wakeup "$SCRATCH_PROJECT" seeded || failures=1
795
+ run_stage_recall_edit "$SCRATCH_PROJECT" seeded || failures=1
796
+ run_stage_capture "$SCRATCH_PROJECT" seeded || failures=1
797
+ run_stage_remember_recall "$SCRATCH_PROJECT" seeded || failures=1
798
+ run_negative_controls || failures=1
799
+
800
+ stop_capture_server
801
+
802
+ if [[ "$failures" -ne 0 ]]; then
803
+ echo "[main:--full] ONE OR MORE STAGES FAILED" >&2
804
+ return 1
805
+ fi
806
+ echo "[main:--full] ALL STAGES PASSED"
807
+ ;;
808
+ --repeat)
809
+ # D-01/D-02/D-03/D-04 soak: 5/5 (default) independent cold
810
+ # reproductions of the hardened round-trip stage, gated by the
811
+ # preflight probe (D-06 -- never burn N setups against a
812
+ # non-tool-call-reliable model).
813
+ local repeat_n="${2:-5}"
814
+ if ! [[ "$repeat_n" =~ ^[1-9][0-9]*$ ]]; then
815
+ echo "Usage: verify-opencode-live-parity.sh --repeat N (N must be a positive integer)" >&2
816
+ exit 2
817
+ fi
818
+
819
+ echo "[repeat] running preflight_tool_call_probe before the $repeat_n-iteration soak..."
820
+ if ! preflight_tool_call_probe; then
821
+ echo "[repeat] ABORT: preflight probe failed — not starting the $repeat_n-iteration soak" >&2
822
+ exit 1
823
+ fi
824
+
825
+ local repeat_passes=0
826
+ for repeat_i in $(seq 1 "$repeat_n"); do
827
+ capture_real_config_fingerprint
828
+ setup_scratch
829
+ seed_canary seeded
830
+ install_assets
831
+ write_scratch_config
832
+ positive_load_check
833
+ start_capture_server
834
+
835
+ if run_stage_remember_recall "$SCRATCH_PROJECT" seeded; then
836
+ echo "[repeat:$repeat_i/$repeat_n] PASS (retries=$LAST_ROUNDTRIP_RETRIES)"
837
+ repeat_passes=$((repeat_passes + 1))
838
+ else
839
+ echo "[repeat:$repeat_i/$repeat_n] FAIL (retries=$LAST_ROUNDTRIP_RETRIES)"
840
+ fi
841
+
842
+ stop_capture_server
843
+ CLEANED_UP=0
844
+ cleanup
845
+ done
846
+
847
+ echo "[repeat] $repeat_passes/$repeat_n PASSED"
848
+ if [[ "$repeat_passes" -ne "$repeat_n" ]]; then
849
+ echo "[repeat] FAIL" >&2
850
+ return 1
851
+ fi
852
+ echo "[repeat] OK: $repeat_n/$repeat_n consecutive round-trips"
853
+ ;;
854
+ -h|--help)
855
+ usage
856
+ ;;
857
+ *)
858
+ usage >&2
859
+ exit 2
860
+ ;;
861
+ esac
862
+ }
863
+
864
+ main "$@"