@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.
- package/AGENTS.md +45 -0
- package/CONTRIBUTING.md +49 -0
- package/LICENSE +201 -0
- package/README.md +149 -0
- package/SECURITY.md +37 -0
- package/bin/cairn +73 -0
- package/claude/agents/code-reviewer.md +174 -0
- package/claude/agents/security-investigator.md +112 -0
- package/claude/agents/security-target-selector.md +90 -0
- package/claude/agents/security-validator.md +97 -0
- package/claude/agents/wiki-ingester.md +108 -0
- package/claude/agents/wiki-lint-auditor.md +94 -0
- package/claude/agents/wiki-query-analyst.md +84 -0
- package/claude/commands/context-explore.md +40 -0
- package/claude/commands/graphify.md +150 -0
- package/claude/commands/memory-review.md +85 -0
- package/claude/commands/memory-sync.md +71 -0
- package/claude/commands/recall.md +32 -0
- package/claude/commands/remember.md +43 -0
- package/claude/commands/repo-review.md +191 -0
- package/claude/commands/security-audit.md +174 -0
- package/claude/commands/wiki-ingest.md +112 -0
- package/claude/commands/wiki-lint.md +86 -0
- package/claude/commands/wiki-query.md +92 -0
- package/claude/hooks/context-explore-pretask.sh +93 -0
- package/claude/hooks/memory-capture.sh +59 -0
- package/claude/hooks/memory-recall.sh +84 -0
- package/claude/hooks/memory-wakeup.sh +45 -0
- package/docs/building-an-overlay.md +117 -0
- package/docs/domain-knowledge.md +86 -0
- package/docs/git-providers.md +36 -0
- package/docs/operating.md +366 -0
- package/mcp-memory-server/dist/embeddings.js +122 -0
- package/mcp-memory-server/dist/explore-cache.js +97 -0
- package/mcp-memory-server/dist/index.js +1256 -0
- package/mcp-memory-server/package.json +36 -0
- package/opencode/agents/code-reviewer.md +173 -0
- package/opencode/agents/security-investigator.md +111 -0
- package/opencode/agents/security-target-selector.md +89 -0
- package/opencode/agents/security-validator.md +96 -0
- package/opencode/agents/wiki-ingester.md +107 -0
- package/opencode/agents/wiki-lint-auditor.md +93 -0
- package/opencode/agents/wiki-query-analyst.md +83 -0
- package/opencode/command/code-review.md +199 -0
- package/opencode/command/context-explore.md +42 -0
- package/opencode/command/graphify.md +153 -0
- package/opencode/command/memory-review.md +44 -0
- package/opencode/command/memory-sync.md +94 -0
- package/opencode/command/recall.md +38 -0
- package/opencode/command/remember.md +46 -0
- package/opencode/command/security-audit.md +80 -0
- package/opencode/command/wiki-ingest.md +55 -0
- package/opencode/command/wiki-lint.md +51 -0
- package/opencode/command/wiki-query.md +50 -0
- package/opencode/plugins/memory-capture.ts +188 -0
- package/opencode/plugins/memory-recall.ts +132 -0
- package/opencode/plugins/memory-wakeup.ts +102 -0
- package/opencode/templates/security-finding-register.yaml.template +51 -0
- package/opencode/templates/security-issue.md.template +43 -0
- package/opencode/templates/security-policy.md.template +29 -0
- package/opencode/templates/security-report.md.template +43 -0
- package/opencode/templates/wiki-contradictions.md.template +31 -0
- package/opencode/templates/wiki-index.md.template +40 -0
- package/opencode/templates/wiki-lint-report.md.template +38 -0
- package/opencode/templates/wiki-log.md.template +11 -0
- package/opencode/templates/wiki-policy.md.template +47 -0
- package/opencode/templates/wiki-query-answer.md.template +26 -0
- package/opencode/templates/wiki-source-summary.md.template +33 -0
- package/opencode/workflows/memory-review-workflow.md +66 -0
- package/opencode/workflows/security-audit-workflow.md +197 -0
- package/opencode/workflows/wiki-ingest-workflow.md +149 -0
- package/opencode/workflows/wiki-lint-workflow.md +128 -0
- package/opencode/workflows/wiki-query-workflow.md +122 -0
- package/package.json +56 -0
- package/scripts/bootstrap.sh +99 -0
- package/scripts/doctor.sh +91 -0
- package/scripts/install-audit-timer.sh +68 -0
- package/scripts/lib/assert-tool-event.mjs +50 -0
- package/scripts/memory-store.sh +71 -0
- package/scripts/memory-wiki-audit.sh +156 -0
- package/scripts/sync-claude-assets.sh +182 -0
- package/scripts/sync-opencode-explore-assets.sh +187 -0
- package/scripts/sync-opencode-graphify-assets.sh +152 -0
- package/scripts/sync-opencode-memory-assets.sh +160 -0
- package/scripts/sync-opencode-plugin-assets.sh +163 -0
- package/scripts/sync-opencode-security-assets.sh +195 -0
- package/scripts/sync-opencode-wiki-assets.sh +250 -0
- package/scripts/test-audit-timer.sh +29 -0
- package/scripts/test-bootstrap-untracked.sh +41 -0
- package/scripts/test-cli-dispatch.sh +21 -0
- package/scripts/test-doctor.sh +32 -0
- package/scripts/test-launcher-hooks.sh +66 -0
- package/scripts/test-memory-store.sh +44 -0
- package/scripts/test-remember-recall-assertions.sh +70 -0
- package/scripts/transcript-to-text.mjs +53 -0
- package/scripts/verify-docs-parity.sh +112 -0
- package/scripts/verify-explore-maturation.sh +288 -0
- package/scripts/verify-fastcontext-reliability.sh +624 -0
- package/scripts/verify-no-private-references.sh +145 -0
- package/scripts/verify-opencode-live-parity.sh +864 -0
- package/scripts/verify-routing-seam.sh +169 -0
- package/scripts/verify-token-savings-ab.sh +557 -0
- package/templates/PROJECT-INSTRUCTIONS.md.template +42 -0
- package/templates/alignment-gap-register.yaml.template +56 -0
- package/templates/alignment-policy.md.template +28 -0
- package/templates/alignment-pr-template.md.template +18 -0
- package/templates/alignment-report.md.template +56 -0
- package/templates/env.example.template +25 -0
- package/templates/graph-policy.md.template +37 -0
- package/templates/graphs-gitignore.template +3 -0
- package/templates/memory-wiki-audit.service.template +6 -0
- package/templates/memory-wiki-audit.timer.template +9 -0
- package/templates/memory.json.template +4 -0
- package/templates/planning-config.json.template +6 -0
- package/templates/project-brief.md.template +40 -0
- package/templates/security-finding-register.yaml.template +51 -0
- package/templates/security-issue.md.template +43 -0
- package/templates/security-policy.md.template +29 -0
- package/templates/security-report.md.template +43 -0
- package/templates/start-claude.sh.template +46 -0
- package/templates/start-opencode.sh.template +41 -0
- package/templates/structured-change-canvas.md.template +63 -0
- package/templates/wiki-contradictions.md.template +31 -0
- package/templates/wiki-index.md.template +40 -0
- package/templates/wiki-lint-report.md.template +38 -0
- package/templates/wiki-log.md.template +11 -0
- package/templates/wiki-policy.md.template +47 -0
- package/templates/wiki-query-answer.md.template +26 -0
- 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 "$@"
|