@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,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
|
+
}
|