@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,366 @@
1
+ # Operating guide
2
+
3
+ How to take a project from nothing to the full Cairnkeep workflow — durable
4
+ memory, the wiki layer, and the security/review commands — in one pass.
5
+
6
+ Cairnkeep has three moving parts:
7
+
8
+ 1. **The memory server** (`cairn-memory`) — an MCP server your harness talks to.
9
+ 2. **The project scaffold** — `.ai/` launchers and the `.planning/` knowledge
10
+ layer, written by `cairn bootstrap`.
11
+ 3. **The operating layer** — the commands, agents, and hooks that live in your
12
+ harness config and drive the workflow.
13
+
14
+ `cairn bootstrap` only does step 2. Steps 1 and 3 are one-time-per-machine
15
+ installs. This guide covers all three in order.
16
+
17
+ ## Prerequisites
18
+
19
+ - Node.js (for the memory server) and a supported harness: Claude Code or
20
+ OpenCode.
21
+ - Optional: an OpenAI-compatible LLM endpoint for memory extraction and
22
+ embedding-ranked search. Without it, memory search degrades to substring
23
+ matching — everything else still works.
24
+
25
+ ## Setup order (Claude Code)
26
+
27
+ **Via npm** (simplest — everything is on `PATH` as `cairn`):
28
+
29
+ ```bash
30
+ npm install -g @cairnkeep/cli
31
+ claude mcp add cairn-memory -s user -- cairn memory-server
32
+ cairn sync --apply # operating layer into ~/.claude
33
+ cairn bootstrap /path/to/project # add --untracked if you don't own the repo
34
+ cp /path/to/project/.ai/env.example /path/to/project/.ai/.env && $EDITOR "$_"
35
+ cd /path/to/project && cairn doctor && ./.ai/start-claude.sh
36
+ ```
37
+
38
+ **From a clone** (equivalent; use the in-repo scripts):
39
+
40
+ ```bash
41
+ # 1. Build the memory server
42
+ cd mcp-memory-server
43
+ npm install
44
+ npm run build
45
+ npm test # offline smoke tests, no API key required
46
+ cd ..
47
+
48
+ # 2. Register the MCP server with your harness (server name: cairn-memory)
49
+ claude mcp add cairn-memory -s user -- node "$PWD/mcp-memory-server/dist/index.js"
50
+
51
+ # 3. Install the operating layer (commands, agents, hooks, scaffold templates)
52
+ scripts/sync-claude-assets.sh --apply
53
+
54
+ # 4. Scaffold a target project
55
+ cairn bootstrap /path/to/project
56
+ cp /path/to/project/.ai/env.example /path/to/project/.ai/.env
57
+ $EDITOR /path/to/project/.ai/.env # see "Configuration" below
58
+ # Not the repo's owner? Add --untracked to keep the scaffold out of git
59
+ # entirely (written to .git/info/exclude; local-only, nothing to commit).
60
+
61
+ # 5. Launch the harness in the project
62
+ /path/to/project/.ai/start-claude.sh
63
+ ```
64
+
65
+ Step 3 installs into `~/.claude` (override with `CLAUDE_CONFIG_DIR` or
66
+ `--live-root <path>`):
67
+
68
+ - **11 commands** → `commands/`: `remember`, `recall`, `memory-sync`,
69
+ `memory-review`, `wiki-ingest`, `wiki-query`, `wiki-lint`, `security-audit`,
70
+ `repo-review`, `graphify`, `context-explore`
71
+ - **7 agents** → `agents/`: `code-reviewer`, the three `security-*` agents, and
72
+ the three `wiki-*` agents
73
+ - **3 hooks** → `hooks/`, registered in `settings.json`:
74
+ - `memory-wakeup.sh` on **SessionStart** — surfaces AgentFS memory + wiki index
75
+ - `memory-capture.sh` on **SessionEnd** — extracts memory candidates to staging
76
+ - `memory-recall.sh` on **PreToolUse** (Edit/Write/MultiEdit) — injects
77
+ file-specific memory before an edit
78
+ - **scaffold templates** → `templates/`, used by `/security-audit` and `/wiki-*`
79
+
80
+ Re-running `sync-claude-assets.sh --apply` is idempotent; use `--check` to see
81
+ drift without writing. Run it again whenever you pull changes to `claude/`.
82
+
83
+ ## Setup order (OpenCode)
84
+
85
+ OpenCode is a secondary path. Steps 1, 4, and 5 are identical (use
86
+ `start-opencode.sh` and register `cairn-memory` in your OpenCode MCP config).
87
+ The operating-layer assets are installed by topic-specific scripts:
88
+
89
+ ```bash
90
+ scripts/sync-opencode-plugin-assets.sh --apply # memory-wakeup plugin
91
+ scripts/sync-opencode-memory-assets.sh --apply # memory-sync/review + code-review
92
+ scripts/sync-opencode-wiki-assets.sh --apply # wiki commands/agents/workflows
93
+ scripts/sync-opencode-security-assets.sh --apply # security-audit chain
94
+ scripts/sync-opencode-graphify-assets.sh --apply # graphify command
95
+ scripts/sync-opencode-explore-assets.sh --apply # context-explore command
96
+ ```
97
+
98
+ Each installs into `~/.config/opencode` (override with `OPENCODE_CONFIG_DIR` or
99
+ `--live-root`) and is idempotent — re-run with `--check` to see drift without
100
+ writing.
101
+
102
+ **No Claude install required.** The OpenCode memory-wakeup plugin is self-sufficient
103
+ of Claude assets — it surfaces AgentFS project memory natively via OpenCode's own
104
+ `experimental.chat.system.transform` hook and never reads `~/.claude`.
105
+
106
+ ### Headless round-trip harness — model precondition
107
+
108
+ `scripts/verify-opencode-live-parity.sh` proves the `/remember`→`/recall`
109
+ round-trip against a real, registered `cairn-memory` MCP server. Reliable
110
+ headless reproduction of that round-trip requires **a no-thinking,
111
+ tool-call-reliable local model** — the publicly-known model that has passed
112
+ is `qwen3.5-27b`. A thinking model that narrates pseudo-tool-call syntax
113
+ instead of emitting real `tool_use` events will not pass, and no amount of
114
+ retry fixes that — retry in this harness only absorbs opencode's own
115
+ run-completion flakiness, never a model that fails to call tools for real.
116
+
117
+ Model selection stays operator-env-driven through the existing
118
+ `CAIRN_LLM_API_KEY` / `CAIRN_LLM_API_URL` / `CAIRN_LLM_EXTRACTION_MODEL`
119
+ variables (see "Configuration" below) — the harness commits no default model
120
+ and carries no known-good allowlist.
121
+
122
+ Before burning a multi-run soak, a preflight probe drives one real tool call
123
+ and fails fast with a trait-named message if the configured model is not
124
+ tool-call-reliable. The harness keeps a three-tier speed structure:
125
+ `--stage wakeup` (fastest per-commit signal), `--full` (one-shot regression
126
+ of every stage), and `--repeat N` — e.g. `scripts/verify-opencode-live-parity.sh --repeat 5`
127
+ — the slow reliability soak that runs N independent cold reproductions of the
128
+ round-trip stage; run it to confirm reliability, not on every commit.
129
+
130
+ ## Configuration
131
+
132
+ All configuration is environment-based (in `.ai/.env`) — the core hardcodes no
133
+ vendor or host.
134
+
135
+ | Variable | Purpose |
136
+ |---|---|
137
+ | `CAIRN_LLM_API_KEY` | API key for the extraction / embeddings endpoint (unset → substring-only memory) |
138
+ | `CAIRN_LLM_API_URL` | Base URL of the OpenAI-compatible endpoint |
139
+ | `CAIRN_LLM_EXTRACTION_MODEL` | Chat model for `memory-capture` extraction |
140
+ | `CAIRN_MEMORY_EMBEDDING_URL` | Embeddings endpoint (falls back to `CAIRN_LLM_API_URL`) |
141
+ | `CAIRN_MEMORY_EMBEDDING_MODEL` | Embedding model name (required for semantic search) |
142
+ | `CAIRN_AGENTFS_BASE_DIR` | Base dir for global memory scopes (default `~/.cairnkeep`) |
143
+ | `CAIRN_GIT_PROVIDER` | Git host for collaboration commands: `github`\|`gitlab`\|`codeberg`\|`forgejo`\|`none`. See [git-providers.md](git-providers.md) |
144
+ | `CAIRN_ROUTE_ENDPOINT` | Base URL of an already-running token-miser routing/tiering proxy (unset → the `route_check` tool is inert) |
145
+ | `CAIRN_EXPLORE_BINARY` | Absolute path to the `token_miser` binary used by `context_explore` (unset → the tool throws at call time) |
146
+ | `CAIRN_EXPLORE_REPO_ROOT` | Default repo root for `context_explore` when no per-call `repo_root` is given (unset + no param → the tool throws) |
147
+ | `CAIRN_EXPLORE_CACHE` | Caches `context_explore` results keyed on query + repo HEAD + dirty-state; default ON, set to `0` to disable |
148
+ | `CAIRN_EXPLORE_AUTOINVOKE` | Opt-in flag for the `UserPromptSubmit` pre-task hook; set to `1` together with `CAIRN_EXPLORE_BINARY` to let the hook auto-invoke `context_explore` for each task prompt (unset -> inert, no hook behavior) |
149
+ | `ANYTHINGLLM_API_KEY` | Required to enable the optional `domain_knowledge_*` RAG tools (unset → those tools error at call time; nothing else affected). See [domain-knowledge.md](domain-knowledge.md) |
150
+ | `ANYTHINGLLM_BASE_URL` | AnythingLLM base URL for `domain_knowledge_*` (default `http://localhost:3001`) |
151
+ | `CAIRN_ANYTHINGLLM_SYNC_SCRIPT` | Override path to the `domain_knowledge_sync` document-sync script (unset → in-repo default) |
152
+
153
+ ### Domain knowledge (RAG via AnythingLLM, opt-in)
154
+
155
+ `domain_knowledge_query` / `domain_knowledge_sync` bridge to an optional
156
+ [AnythingLLM](https://anythingllm.com/) instance for document RAG. Off unless
157
+ configured — full setup, workspaces, and the memory-config format are in
158
+ [domain-knowledge.md](domain-knowledge.md).
159
+
160
+ ### Routing seam (`route_check`, opt-in)
161
+
162
+ `route_check` is a thin MCP tool that checks whether an external token-miser
163
+ routing/tiering proxy is reachable. It hosts no proxy, endpoint list, model
164
+ list, or tier config itself — the proxy runs elsewhere and `route_check` only
165
+ confirms the wire to it is live. This is the full contract; no source reading
166
+ required.
167
+
168
+ - **Reads exactly one env var:** `CAIRN_ROUTE_ENDPOINT`. Unset or malformed
169
+ (fails `new URL(...)`) → the tool throws at call time.
170
+ - **Issues exactly one request:** `GET {CAIRN_ROUTE_ENDPOINT}/health`, with a
171
+ short per-call timeout (`timeout_seconds`, default 10s).
172
+ - **Execution-tier failures** (connection refused, non-2xx status, malformed
173
+ JSON body, or timeout) never throw — they return `{ ok: false, error, ... }`.
174
+ - **Success** (2xx + parseable JSON) returns
175
+ `{ ok: true, status, cluster_healthy }`.
176
+
177
+ **What it does NOT do:** it does not drive `/v1/chat/completions` or
178
+ `/v1/messages` — it never sends chat/messages traffic itself, only an
179
+ overlay that owns real routing decisions does that. It does not report which
180
+ tier serves a request, or any tier/model/endpoint configuration at all — a
181
+ `/health` 200 proves the proxy process is alive and reachable, not that a
182
+ routing decision was exercised.
183
+
184
+ The proxy this seam talks to is owned by
185
+ [token-miser](https://github.com/cairnkeep/token-miser), a public
186
+ cairnkeep-org sibling project.
187
+
188
+ `scripts/verify-routing-seam.sh` proves this against the real token_miser
189
+ binary (not a mock) — see the script's `--help` for usage.
190
+
191
+ ### Exploration cache (`context_explore`, on by default)
192
+
193
+ `context_explore` caches its result keyed on (normalized query, resolved
194
+ repo_root, `git rev-parse HEAD`, and a content-sensitive dirty-state hash
195
+ over `git diff HEAD` plus untracked-file size/mtime). A second identical
196
+ call against an unchanged repo returns `cached: true` and never re-spawns
197
+ the `token_miser` binary; any repo change — a tracked-file edit, a staged
198
+ change, or a new untracked file — invalidates the entry and forces a fresh
199
+ invocation. Entries live under `${XDG_CACHE_HOME:-~/.cache}/cairn/explore/`,
200
+ never inside the explored repo, with an oldest-first prune once the
201
+ directory holds more than ~200 entries. Set `CAIRN_EXPLORE_CACHE=0` to
202
+ disable caching entirely (every call spawns the binary, always
203
+ `cached: false`). The cache stores only the raw citations/stats the binary
204
+ returned — nothing else is layered on top of a cached entry.
205
+
206
+ `node dist/index.js explore "<query>"` runs the exact same code path as the
207
+ MCP tool (shared `runContextExplore()`), so a pre-task hook or any other
208
+ script-driven caller gets identical cache behavior without an MCP session.
209
+
210
+ ### Citation cross-referencing (`context_explore`, always on)
211
+
212
+ Every `context_explore` citation is cross-referenced against the explored
213
+ repo's own project memory and `.planning/wiki/sources/*.md` pages: for each
214
+ cited path with a basename stem of at least 4 characters, a case-insensitive
215
+ substring match against memory entries and wiki pages attaches `memory_refs`/
216
+ `wiki_refs` to that citation and appends a compact `<- memory: ... - wiki: ...`
217
+ marker to its rendered line. Cross-refs are recomputed on every call (cache
218
+ hit or miss alike, since memory/wiki evolve independently of repo HEAD) and
219
+ fail open — a missing `.agentfs` database or wiki directory, or any read
220
+ error, simply yields no refs. A citation with no hits gets no marker at all,
221
+ so a result with zero cross-ref hits renders byte-identical to a result from
222
+ before this feature existed.
223
+
224
+ ### Pre-task auto-invoke hook (`context_explore`, opt-in, Claude Code only)
225
+
226
+ Claude Code's `UserPromptSubmit` hook `context-explore-pretask.sh` can
227
+ auto-invoke `context_explore` for a task's prompt with no manual
228
+ `/context-explore` call — it supplements the manual command, it does not
229
+ replace it. It is double opt-in: inert unless both `CAIRN_EXPLORE_BINARY`
230
+ and `CAIRN_EXPLORE_AUTOINVOKE=1` are set. When active, it also skips
231
+ low-signal prompts (too short, a slash command, or a bare acknowledgement
232
+ like "ok"/"thanks") so it only fires on task-shaped prompts. It shells out to
233
+ the same `explore` CLI subcommand described above with an explicit ~20s
234
+ timeout — well inside Claude Code's own hook budget — and injects only
235
+ compact `path:start-end` citations plus their cross-ref flags (never the
236
+ full expanded snippets) as `additionalContext`, prefixed so the model knows
237
+ the context was auto-invoked. Any error (timeout, missing binary, malformed
238
+ output) injects nothing; the hook always exits 0.
239
+
240
+ **Known gap:** OpenCode currently exposes no plugin event that delivers the
241
+ user's message text before the LLM call runs, so this auto-invoke hook is a
242
+ Claude-Code-only path this milestone — there is no OpenCode parity plugin.
243
+
244
+ ### HTTP transport (opt-in, network-facing)
245
+
246
+ The server runs over stdio by default. Setting `MCP_HTTP_PORT` switches it to a
247
+ streamable HTTP transport so one long-lived process can serve many clients — but
248
+ because that exposes every memory tool over the network, HTTP mode is guarded and
249
+ **fails closed**:
250
+
251
+ | Variable | Purpose |
252
+ |---|---|
253
+ | `MCP_HTTP_PORT` | Enable HTTP mode on this port (unset → stdio) |
254
+ | `MCP_HTTP_HOST` | Bind address (default `127.0.0.1`) |
255
+ | `CAIRN_MEMORY_HTTP_TOKEN` | **Required** in HTTP mode — clients send `Authorization: Bearer <token>`; the server refuses to start without it |
256
+ | `CAIRN_MEMORY_HTTP_ALLOWED_ORIGINS` | Comma-separated browser origins allowed via CORS (default: none — no cross-origin access) |
257
+ | `CAIRN_MEMORY_HTTP_ALLOWED_HOSTS` | Comma-separated allowed `Host` headers for DNS-rebinding protection (default: the bind host + `localhost` on the chosen port) |
258
+
259
+ Requests without a valid bearer token get `401`; requests with an unexpected
260
+ `Host` header get `403`. Keep HTTP mode bound to `127.0.0.1` unless you have a
261
+ specific reason to expose it, and use a long random token.
262
+
263
+ ## The workflow
264
+
265
+ Once installed, the operating layer gives you:
266
+
267
+ **Memory** — durable facts that survive across sessions.
268
+ - `/remember <fact>` — persist an accepted finding to AgentFS + file-memory.
269
+ - `/recall <topic>` — read across memory layers mid-session.
270
+ - The three hooks run automatically: memory is surfaced at session start,
271
+ captured at session end (to a review queue), and injected before file edits.
272
+ - `/memory-review` — the accept gate: promote staged candidates to durable
273
+ memory, or discard them.
274
+ - `/memory-sync` — reconcile tracked PR/MR state into memory via the configured
275
+ git provider.
276
+
277
+ **Wiki** — a sparse, citation-heavy derived-knowledge layer under
278
+ `.planning/wiki/`.
279
+ - `/wiki-ingest <path>` — compile one canonical source into a cited wiki page
280
+ (`--refresh` to re-sync an existing page).
281
+ - `/wiki-query <question>` — answer from the wiki first, then canonical sources
282
+ (`--writeback` to save a reusable answer).
283
+ - `/wiki-lint` — advisory audit for citation gaps, staleness, and contradictions.
284
+
285
+ **Context exploration.**
286
+ - `/context-explore <query>` — delegates to the external `token_miser explore`
287
+ subprocess and relays compact path:line-range citations; owned by
288
+ [token-miser](https://github.com/cairnkeep/token-miser), the public
289
+ cairnkeep-org sibling, and holds no endpoint/model config of its own.
290
+ - Citations are cross-referenced against project memory and the wiki, results
291
+ are cached keyed on the query + repo HEAD/dirty-state, and (Claude Code
292
+ only, opt-in) a pre-task hook can auto-invoke exploration for a task's
293
+ prompt with no manual call — see "Citation cross-referencing",
294
+ "Exploration cache", and "Pre-task auto-invoke hook" above.
295
+
296
+ **Security and review.**
297
+ - `/security-audit` — a governed local audit (target-selector → investigator →
298
+ validator) that writes findings under `.planning/security/`.
299
+ - `/repo-review` — a code review across bugs, security, and maintainability that
300
+ writes `REVIEW.md`.
301
+
302
+ Raw repository docs, tests, interfaces, and code always remain canonical; the
303
+ wiki and memory layers are derived and never overrule them.
304
+
305
+ ## Verifying the install
306
+
307
+ - Memory server: `cd mcp-memory-server && npm test` (offline smoke tests).
308
+ - Claude operating layer: `scripts/sync-claude-assets.sh --check` should report
309
+ no drift after an apply.
310
+ - OpenCode operating layer: `scripts/sync-opencode-explore-assets.sh --check`
311
+ (and the other `sync-opencode-*-assets.sh --check` siblings) should report no
312
+ drift after an apply — a manual sanity check, not a CI job.
313
+ - End to end: launch the harness in a bootstrapped project; the SessionStart
314
+ hook should surface a project-memory section, and `/recall test` should return
315
+ from the `cairn-memory` MCP.
316
+
317
+ ## Wrapper & operations seams
318
+
319
+ These let an enterprise wrapper add provider/credential specifics — and let you
320
+ maintain a running install — without forking the core. All are opt-in.
321
+
322
+ ### Launcher seams
323
+
324
+ The generic launchers (`.ai/start-claude.sh`, `.ai/start-opencode.sh`) run three
325
+ optional hooks around the harness, each a no-op when absent:
326
+
327
+ | Seam | When | Purpose |
328
+ |---|---|---|
329
+ | `.ai/pre-launch.sh` | sourced after `.env`, before launch | export env (e.g. a provider base URL / auth), refresh credentials, or abort by returning non-zero |
330
+ | `CAIRN_EXTRA_SETTINGS` | read just before launch | path to a settings file layered on the harness (`--settings` / `--config`); process env still wins over it |
331
+ | `.ai/post-exit.sh` | sourced after the harness exits | teardown; `CAIRN_EXIT_STATUS` holds the exit code |
332
+
333
+ A wrapper that needs a non-default provider drops a `pre-launch.sh` that renders
334
+ its settings file and exports `CAIRN_EXTRA_SETTINGS` — no change to the launcher.
335
+
336
+ ### `cairn doctor`
337
+
338
+ Health-checks the configured pieces, reading only `./.ai/.env` (or the current
339
+ env). Unconfigured optional dependencies are skipped; it exits non-zero only when
340
+ a configured dependency (LLM/embedding endpoint, writable store) is unreachable.
341
+
342
+ ```bash
343
+ cd /path/to/project && cairn doctor
344
+ ```
345
+
346
+ ### `cairn memory export|import|path`
347
+
348
+ Relocate the durable memory store (one SQLite `.db` per scope under
349
+ `CAIRN_AGENTFS_BASE_DIR`) between machines or backends:
350
+
351
+ ```bash
352
+ cairn memory path # print the store location
353
+ cairn memory export store.tgz # WAL-safe snapshot of every scope db
354
+ cairn memory import store.tgz # restore on another machine (backs up existing)
355
+ ```
356
+
357
+ ### `cairn audit-timer`
358
+
359
+ `memory-wiki-audit.sh` is the deterministic invalidation backstop meant to run on
360
+ a schedule. `cairn audit-timer` installs it as a systemd user timer (opt-in):
361
+
362
+ ```bash
363
+ cairn audit-timer --on-calendar daily # install + enable the timer
364
+ cairn audit-timer --render-only ./units # just render the unit files
365
+ # no systemd? cron: @daily .../scripts/memory-wiki-audit.sh --para-root "$HOME/PARA" --report ...
366
+ ```
@@ -0,0 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ // Resolve the embeddings endpoint from the environment. Returns null when the
5
+ // API key, URL, or model name is not configured, so callers can fall back to
6
+ // substring search. Point CAIRN_LLM_API_URL (or CAIRN_MEMORY_EMBEDDING_URL) at
7
+ // any OpenAI-compatible embeddings endpoint.
8
+ export function getEmbeddingConfig() {
9
+ const apiKey = process.env.CAIRN_LLM_API_KEY;
10
+ if (!apiKey) {
11
+ return null;
12
+ }
13
+ const rawUrl = process.env.CAIRN_MEMORY_EMBEDDING_URL ?? process.env.CAIRN_LLM_API_URL;
14
+ if (!rawUrl) {
15
+ return null;
16
+ }
17
+ const apiUrl = rawUrl.trim().replace(/\/+$/, "");
18
+ // The model name must be configured explicitly — the core ships no vendor
19
+ // default. Unset means semantic search degrades to substring matching.
20
+ const model = process.env.CAIRN_MEMORY_EMBEDDING_MODEL?.trim();
21
+ if (!model) {
22
+ return null;
23
+ }
24
+ return { apiUrl, apiKey, model };
25
+ }
26
+ export function hashText(text) {
27
+ return createHash("sha1").update(text).digest("hex");
28
+ }
29
+ export function cosineSimilarity(a, b) {
30
+ if (a.length !== b.length || a.length === 0) {
31
+ return 0;
32
+ }
33
+ let dot = 0;
34
+ let normA = 0;
35
+ let normB = 0;
36
+ for (let i = 0; i < a.length; i += 1) {
37
+ dot += a[i] * b[i];
38
+ normA += a[i] * a[i];
39
+ normB += b[i] * b[i];
40
+ }
41
+ if (normA === 0 || normB === 0) {
42
+ return 0;
43
+ }
44
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
45
+ }
46
+ // Embed an array of strings via the OpenAI-compatible /embeddings endpoint.
47
+ // Chunked so a large memory scope does not produce one oversized request.
48
+ export async function embedTexts(config, texts, chunkSize = 64) {
49
+ if (texts.length === 0) {
50
+ return [];
51
+ }
52
+ const vectors = [];
53
+ for (let start = 0; start < texts.length; start += chunkSize) {
54
+ const chunk = texts.slice(start, start + chunkSize);
55
+ const response = await fetch(`${config.apiUrl}/embeddings`, {
56
+ method: "POST",
57
+ headers: {
58
+ Authorization: `Bearer ${config.apiKey}`,
59
+ "Content-Type": "application/json",
60
+ },
61
+ body: JSON.stringify({ model: config.model, input: chunk }),
62
+ signal: AbortSignal.timeout(120000),
63
+ });
64
+ if (!response.ok) {
65
+ const text = await response.text();
66
+ throw new Error(`Embeddings request failed with ${response.status}: ${text}`);
67
+ }
68
+ const payload = (await response.json());
69
+ const data = payload.data ?? [];
70
+ // Order by index so vectors align with the input chunk even if the API reorders.
71
+ const ordered = [...data].sort((left, right) => left.index - right.index);
72
+ for (const item of ordered) {
73
+ vectors.push(item.embedding);
74
+ }
75
+ }
76
+ return vectors;
77
+ }
78
+ // Persistent per-scope embedding cache keyed by entry key + content hash. Invalidated
79
+ // automatically when the embedding model changes so vectors of differing dimensionality
80
+ // never get compared.
81
+ export class EmbeddingCache {
82
+ path;
83
+ model;
84
+ data;
85
+ dirty = false;
86
+ constructor(path, model) {
87
+ this.path = path;
88
+ this.model = model;
89
+ this.data = { model, entries: {} };
90
+ if (existsSync(path)) {
91
+ try {
92
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
93
+ if (parsed.model === model && parsed.entries) {
94
+ this.data = parsed;
95
+ }
96
+ }
97
+ catch {
98
+ // Corrupt or stale cache — start fresh.
99
+ this.data = { model, entries: {} };
100
+ }
101
+ }
102
+ }
103
+ get(key, contentHash) {
104
+ const hit = this.data.entries[key];
105
+ if (hit && hit.hash === contentHash) {
106
+ return hit.vector;
107
+ }
108
+ return undefined;
109
+ }
110
+ set(key, contentHash, vector) {
111
+ this.data.entries[key] = { hash: contentHash, vector };
112
+ this.dirty = true;
113
+ }
114
+ save() {
115
+ if (!this.dirty) {
116
+ return;
117
+ }
118
+ mkdirSync(dirname(this.path), { recursive: true });
119
+ writeFileSync(this.path, JSON.stringify(this.data));
120
+ this.dirty = false;
121
+ }
122
+ }
@@ -0,0 +1,97 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ const PRUNE_CAP = 200;
7
+ const MAX_BUFFER = 64 * 1024 * 1024;
8
+ // D-11: cache dir lives OUTSIDE any explored repo -- never build it from
9
+ // repoRoot.
10
+ export function exploreCacheDir() {
11
+ const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
12
+ return join(base, "cairn", "explore");
13
+ }
14
+ // Trim + collapse internal whitespace runs to a single space; case preserved.
15
+ // Two prompts differing only in whitespace produce the same cache key.
16
+ export function normalizeExploreQuery(query) {
17
+ return query.trim().replace(/\s+/g, " ");
18
+ }
19
+ export function exploreCacheKey(normalizedQuery, resolvedRepoRoot, head, dirtyHash) {
20
+ return createHash("sha1")
21
+ .update(`${normalizedQuery}\0${resolvedRepoRoot}\0${head}\0${dirtyHash}`)
22
+ .digest("hex");
23
+ }
24
+ // D-10/Pattern 4: dirty-state hash basis is CONTENT, not just a file list --
25
+ // `git diff HEAD` (staged+unstaged tracked edits) plus each untracked path's
26
+ // size+mtime (git diff never touches untracked files, so two different
27
+ // untracked contents at the same path must still hash differently).
28
+ // execFileSync with argv arrays only (V5) -- never a shell string, and never
29
+ // runCommand, whose 12000-char truncateOutput cap would silently erode
30
+ // content-sensitivity for a large diff (D-10 is load-bearing, not stylistic).
31
+ export function computeRepoState(repoRoot) {
32
+ // stdio ignores the child's stderr so a non-git repoRoot (or any git
33
+ // failure) doesn't leak "fatal: not a git repository" noise onto the
34
+ // server's own stderr -- callers already fail this open to a cache miss.
35
+ const gitOpts = {
36
+ encoding: "utf8",
37
+ maxBuffer: MAX_BUFFER,
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ };
40
+ const head = execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], gitOpts).trim();
41
+ const diff = execFileSync("git", ["-C", repoRoot, "diff", "HEAD"], gitOpts);
42
+ const untrackedRaw = execFileSync("git", ["-C", repoRoot, "ls-files", "--others", "--exclude-standard", "-z"], gitOpts);
43
+ const untrackedPaths = untrackedRaw.split("\0").filter(Boolean).sort();
44
+ const untrackedStatLines = untrackedPaths.map((path) => {
45
+ const stat = statSync(join(repoRoot, path));
46
+ return `${path}:${stat.size}:${stat.mtimeMs}`;
47
+ });
48
+ const dirtyHash = createHash("sha1")
49
+ .update(`${diff}\0${untrackedStatLines.join("\n")}`)
50
+ .digest("hex");
51
+ return { head, dirtyHash };
52
+ }
53
+ // Fail-open: a missing dir/file or corrupt/unparseable JSON is always a miss,
54
+ // never a throw (mirrors EmbeddingCache's constructor try/catch shape).
55
+ export function readExploreCache(key) {
56
+ try {
57
+ const path = join(exploreCacheDir(), `${key}.json`);
58
+ if (!existsSync(path)) {
59
+ return undefined;
60
+ }
61
+ return JSON.parse(readFileSync(path, "utf8"));
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ // One JSON file per key; entry carries RAW evidence only (D-12) -- cross-refs
68
+ // are never part of the cached payload.
69
+ export function writeExploreCache(key, entry) {
70
+ try {
71
+ const dir = exploreCacheDir();
72
+ mkdirSync(dir, { recursive: true });
73
+ writeFileSync(join(dir, `${key}.json`), JSON.stringify(entry));
74
+ pruneExploreCache(dir);
75
+ }
76
+ catch {
77
+ // Best-effort cache write -- a write failure must never break exploration.
78
+ }
79
+ }
80
+ // Oldest-first prune by mtime once the directory exceeds the cap
81
+ // (RESEARCH Security V12 DoS mitigation).
82
+ export function pruneExploreCache(dir = exploreCacheDir(), cap = PRUNE_CAP) {
83
+ try {
84
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
85
+ if (files.length <= cap) {
86
+ return;
87
+ }
88
+ const withMtime = files.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }));
89
+ withMtime.sort((a, b) => a.mtime - b.mtime);
90
+ for (const { f } of withMtime.slice(0, withMtime.length - cap)) {
91
+ unlinkSync(join(dir, f));
92
+ }
93
+ }
94
+ catch {
95
+ // Prune is best-effort housekeeping -- never fail a cache write over it.
96
+ }
97
+ }