@misterhuydo/cairn-mcp
v1.41.0
Published
MCP server that gives Claude Code persistent memory across sessions. Index your codebase once, search symbols, bundle source, scan for vulnerabilities, and checkpoint/resume work — across Java, TypeScript, Vue, Python, SQL and more.
Maintainers
Readme
Cairn MCP
Persistent polyglot knowledge graph for Claude Code. Index once, query forever.
Claude is stateless — every session starts cold. Cairn fixes this by indexing your project into a local SQLite knowledge graph and wiring into Claude Code via hooks, so file compression, checkpointing, and session restore all happen automatically in the background.
Install
npm install -g @misterhuydo/cairn-mcpRequirements: Node.js >= 22.15.0
Setup
cairn installThat's it. One command registers the MCP server in ~/.claude.json and installs all hooks globally. Works on macOS, Linux, and Windows.
Restart Claude Code once after running this — required for the MCP server to load.
Project-only hooks (no MCP registration):
cairn install-hooksorcairn install-hooks --global
Upgrade
npm install -g @misterhuydo/cairn-mcp@latest
cairn installUpdating the package replaces only the binary. Re-run cairn install to re-sync the
hook set — a release that adds new hooks (e.g. the roadmap-capture hooks) won't wire
them otherwise. It's idempotent and additive, so your MCP config and any existing
hooks are preserved. Restart Claude Code once afterward so the new hooks and MCP
server load.
What the hooks do
| Hook | Trigger | Effect |
|---|---|---|
| PreToolUse[Read] | Every file read | Source files compressed ~68% before Claude sees them; large files show structural outline with line numbers to save tokens |
| PreToolUse[Edit] | Every file edit | Blocks Edit if Claude only saw compressed content — requires a full re-read first. Reported as a permission decision with a one-line reason, not as a red hook error: being sent back to re-read a file is a normal step, and the full source rides along out of sight so it does not fill the screen |
| PostToolUse[ExitPlanMode] | A plan is approved | The plan is parsed into ### Phase N entries in .cairn/roadmap.md, synced, and the cursor activated |
| PostToolUse[TodoWrite] | The todo list changes | Todos mirror onto the roadmap as sub-tasks under the current phase (or seed root phases if none exist) |
| Stop | End of every response | Session auto-saved to .cairn/session.json, Claude auto-memory backed up to .cairn/memory/, the roadmap re-surfaced, any git-detected completed phases surfaced for you to confirm, and — once the session is genuinely large — a prompt asking whether you want to /clear or /compact, with a recommendation and the reason. All in one line |
| SessionStart[clear]SessionStart[compact] | Right after you /clear or /compact | The checkpoint is re-injected into the fresh session automatically, so it opens already knowing the open thread and what the last session would have lost. You do not have to type "resume" |
| UserPromptSubmit | First message of a new session | Fresh project: Claude prompted to run cairn_maintain. Returning session: Claude prompted to run cairn_resume. Memory restored from .cairn/memory/ if the Claude store is empty (new machine / fresh clone) |
First use
Just open Claude Code from your project root and start working. The UserPromptSubmit hook handles everything:
- Fresh project — hook tells Claude to run
cairn_maintain, index is built automatically - Returning session — hook tells Claude to run
cairn_resume, prior context is restored
No manual steps. The index lives in .cairn/index.db inside your project — like a .git folder, just cd to the root and everything works.
Tools
| Tool | What it does |
|---|---|
| cairn_maintain | Full index of the current project. Suggests adding .cairn/ to .gitignore if not already present |
| cairn_resume | Restore last session + re-index only changed files |
| cairn_search | Find classes, functions, components by name or concept — results include file path and line number |
| cairn_describe | Summarize what a folder or module does |
| cairn_outline | Structural outline with line numbers per symbol + heuristic issue detection. On large federated projects returns a per-service summary; use repo param to drill into one service |
| cairn_code_graph | Dependency health — instability, cycles, load-bearing modules |
| cairn_security | Scan for XSS, SQLi, hardcoded secrets, weak crypto, and more |
| cairn_todos | Scan codebase for TODO/FIXME/HACK comments, add manual items, resolve and list them |
| cairn_roadmap | The active project plan: phases authored in .cairn/roadmap.md, with a cursor, dependencies, and auto-pruning of shipped phases into .cairn/roadmap_completed.md |
| cairn_bundle | Minified source snapshot (auto-handled by hooks) |
| cairn_checkpoint | Save session state (auto-handled by hooks). At a stopping point, answer would_be_lost — the one part that git and the index cannot recover. Also takes context_window, the one thing cairn cannot measure for itself |
| cairn_minify | Minify a single file on demand (fallback when hooks are not installed) |
| cairn_switch | Switch active project root mid-session (use for maintenance on a sibling service) |
| cairn_memo | Save a preference, decision, or discovery to the project's persistent memory. action: "reindex" repairs an index that has lost entries (see below) |
| cairn_employ_memory | Recall stored memories explicitly (call only when asked) |
Repairing a truncated memory index
MEMORY.md is shared. Claude Code's own auto-memory writes plain pointer lines
with no type tag, and cairn writes tagged ones. Before v1.23.0, cairn required
the tag and rebuilt the whole index from the lines it could parse — so every
Claude-written pointer was deleted the next time a memo was saved. Seen in the
wild: an index of ~150 entries rewritten down to 3.
Only the pointers were lost. The memory files themselves were never touched, so the index can be rebuilt from their frontmatter:
cd /path/to/project
cairn repair-memory --dry-run # what would be restored, writes nothing
cairn repair-memory # restore themIt is additive: it only adds pointers for files the index has lost, and never
rewrites or removes a line that is already there. Entries restored from a
Claude-written memory keep Claude's format (no type tag is invented for them). The
previous index is kept as MEMORY.bak — deliberately not a .md, since every
.md in that directory is treated as a memory. A pointer whose file is missing is
reported but left alone. Running it twice does nothing the second time.
The same repair is available in-session as cairn_memo with
action: "reindex" (and dry_run: true to preview).
Multi-service / monorepo support
Cairn understands workspace structures where multiple services or packages live under a common parent folder.
How it works:
- Each service has its own
.cairn/index.db— it is the authoritative index for that service - When
cairn_maintainruns at the parent folder, it skips files already covered by a sub-project and instead federates all sub-indexes via SQLiteATTACH— no duplication - When a session opens inside a subfolder (e.g.
elprint-component-service/), Cairn automatically discovers and mounts siblings:- If the parent folder has a
.cairn/index.db(already initialized) → uses its registry - Otherwise → scans the parent directory for any sibling folders that already have a
.cairn/index.db
- If the parent folder has a
The result: every service session has full cross-service visibility for search, outline, and bundle — no manual setup required.
Drilling into a service from a federated session:
cairn_outline { repo: "elprint-component-service" } → outline one service
cairn_search { query: "PartRepository" } → searches all servicesWhen you need to re-index a sibling service:
cairn_switch { path: "/path/to/elprint-project-service" }
cairn_maintain
cairn_switch { path: "/path/to/elprint-component-service" }cairn_switch is the explicit escape hatch for maintenance — it changes the active project root so cairn_maintain and cairn_checkpoint target the right service.
TODO tracking
cairn_todos scans every file Cairn indexes for TODO, FIXME, HACK, XXX, and NOTE comments and stores them in the project database. Manual items you add yourself are never overwritten by a scan.
cairn_todos { action: "list" } → all todos (open + done)
cairn_todos { action: "list", status: "open" } → open only
cairn_todos { action: "list", source: "manual" } → items you added yourself
cairn_todos { action: "add", text: "...", kind: "FIXME" } → add a manual item
cairn_todos { action: "resolve", id: 42 } → mark done
cairn_todos { action: "scan" } → re-scan without full maintainStats are included automatically in cairn_maintain (todos_found) and cairn_resume (open_todos), so you always know the backlog at a glance.
Roadmap
TODOs are a long-tail backlog; the roadmap is the active plan. It's a first-class
document at .cairn/roadmap.md (not a memory) — a ## Phases section of
### Phase N: title blocks, plus whatever overview prose you like:
## Phases
### Phase 1: Decouple roadmap from memory
Move phases out of decision memos into this file.
### Phase 2: Completed log
Prune shipped phases, append them to roadmap_completed.md.cairn_roadmap reads that file and tracks it:
cairn_roadmap { action: "tree" } → render the plan (auto-syncs roadmap.md)
cairn_roadmap { action: "next" } → advance the cursor to the next actionable phase
cairn_roadmap { action: "focus", id: 3 } → make phase 3 the current focus
cairn_roadmap { action: "set_status", id: 3, status: "done" } → ship it
cairn_roadmap { action: "add", parent: 3, text: "..." } → ad-hoc sub-task under a phase
cairn_roadmap { action: "deps_add", from: 2, to: 4 } → phase 2 blocks phase 4
cairn_roadmap { action: "reorder", order: "1 -> 8 -> 9 -> 5" } → state the priority order
cairn_roadmap { action: "reorder", move: 8, before: 5 } → "do phase 8 before phase 5"
cairn_roadmap { action: "reorder", decline: true } → "no stated order, deliberately" — and stop asking
cairn_roadmap { action: "set_status", id: 3, status: "deferred" } → park it: still in the plan, out of the live set
cairn_roadmap { action: "deferrable" } → propose which phases look parked, from the markers already in their titles
cairn_roadmap { action: "publish" } → (re)write the human-readable ROADMAP.md at the repo rootThe live plan is also projected into a ROADMAP.md at the repo root, published in
the roadmap/1 slot format so any cockpit or tool can read it without writing a
parser per provider. One file carries both halves — two files drift, and the drift is
invisible:
- A markdown half for readers: checkboxes, per-item status, a progress bar, one
plain sentence per phase, and a
← currentmarker on the cursor phase. Written for somebody who has never heard of cairn and is reading the file on GitHub: they should be able to say what the project is working on and what is next. That means no authoring syntax survives into the prose —[[wikilinks]]are unwrapped or dropped (they are filenames a reader cannot open) and nothing points at.cairn/, which is gitignored and therefore not there for them..cairn/roadmap.mdremains the authoring source and stays as detailed as you like; this half is the summary. - One
roadmap-jsonblock for consumers, collapsed inside a<details>at the end of the file so a reader never scrolls through it, and never removed — a cockpit's per-phase view is rendered from it, and without it that view degrades silently to flat markdown. Collapsing costs nothing: the fence keeps blank lines around it, so it stays a valid CommonMark code block and both line-anchored and AST consumers still find it. It carries stable phaseids,ref(the name that actually identifies a phase — see below),number,label,group,title,status,goal,blocked_by,slices, the full per-phasedetail(the body you wrote in.cairn/roadmap.md, verbatim and in full — thinning the prose never thins this), andlinks— the[[wikilinks]]in that body resolved to the memory files behind each phase, so the reasoning is one tap away instead of "go and find it". A link that cannot be resolved is dropped rather than emitted dead.
The two halves answer different questions and deliberately disagree. The reader half
hides shipped and abandoned work so what is next leads. The JSON half publishes the
plan complete — done and stale phases stay in phases with their real status,
because a consumer that cannot see finished work will confidently render an incomplete
plan. A shipped phase also carries shipped (the date) and commit (short HEAD when it
was marked done), which is what turns "this was done" into something you can point an
agent at when you are tracing a bug back to where it came from.
shipped_phases is the shipping log — every phase ever marked done, newest first,
read from the same .cairn/roadmap_completed.md the reader half's Recently shipped
section is generated from, so the two halves of one file cannot disagree about the same
fact. (They used to: two real projects published shipped_phases: [] beside a markdown
list of six and eight shipped phases, and a consumer taking the JSON at its word saw two
projects that had shipped nothing.) The markdown shows the most recent few; this carries
all of them. Entries are identity and provenance only — id, ref, number, title,
shipped, commit, never detail — so a long-lived repo's history cannot bloat the
file. A phase still in the current plan appears in both phases[] and here: they are
two indexes of one plan, the tree and the chronology, joined by id. Entries logged
before the log carried ids publish no id and no ref rather than a guessed one. The
key is always present; its absence means the file predates this.
Three rules the format leaves implicit, each of which has cost a consumer real time:
- The
roadmap-jsonblock is terminated by a fence at the start of a line. Match it that way, not with a naive non-greedy```. This is safe by construction: a newline inside a JSON string is escaped, so an embedded fence can never begin a line and only the real terminator can. detailmay itself contain fenced code, which is exactly why the rule above matters. A consumer that ends the block at the first```it meets can end it inside a JSON string and spill ~125KB of machine data onto the page as prose.cursormay name a slice, not a top-level phase. It is the exact focus. To mark one phase current, usecursor_phase, which is always the top-level ancestor and always resolves inphases;cursor_pathis the root-to-focus chain for breadcrumbs. All three arenullwhen nothing is active, so "nothing is in progress" is never ambiguous with a broken pointer. Acursoryou cannot find inphases[]is normal and not a dangling pointer — look inslices[], or just followcursor_path.numberdoes not identify a phase;refdoes.numberis the phase number authored in the plan, and it is unique only within a decision group — one real plan publishesnumber: 1on forty separate rows. Print or sayref, which is always present and unique in the document: the project's own name for the phase (TC-4) where the titles carry one, otherwise a bare number where that identifies, otherwise a group-qualified one (roadmap-3), otherwise the row id.refis a name and can lengthen when phases are added elsewhere;idstays the stable machine key.
The priority order
## Phases in .cairn/roadmap.md says what the phases are. It does not say what order
to work them in, and renumbering to express that is destructive — the file-to-DB
sync matches rows by phase number, so moving "Phase 8" to "Phase 2" reads as "phase 2's
text changed" and rewires which row is which.
So the order is its own statement. Author it as an ## Order section, in the numbers
the phase headers already use:
## Order
1 -> 8 -> 9 -> 5or let a working session set it — cairn_roadmap reorder takes either the whole list or
a single relative move, so "do phase 8 before phase 5" mid-session lands in the file and
in the next publish. A relative move splices against the effective order (what was
stated, then the rest in plan order), so phases the stated list omits keep their place
instead of being silently demoted.
It publishes as a priority integer on each phase — 1 first, contiguous 1..N,
absent where unset. A partial list is fine; anything it omits follows in plan order.
A list is the right way to say an order and a number is the right way to publish one, which is why the two halves differ. Three things follow from putting the number on the phase:
- It cannot go stale. An array beside the plan keeps pointing at work that shipped,
got parked or left it. A number on the phase leaves with the phase — there is nothing
to dangle, which is why
order_advicehas nostalestate. - cairn owns compaction. Priority is sequence, not identity: nothing resolves against it, so cairn renumbers it freely and there are never gaps, decimals or ties. (Renumbering a phase number is forbidden for exactly the opposite reason — the file-to-DB sync matches rows by it.)
- Optional is what keeps it honest. One real plan has 70 live phases and nobody has ranked any of them; presenting file order as priority would assert a considered ranking that does not exist. Both halves say how many are actually prioritised.
priority and number are never the same field, and the trap is real: stating an order
today can need [11, 12, 5, 16], where 5 is TC-3 because TC-3 is phase number 5. A
priority of 3 meaning "third" and a phase number of 5 meaning "TC-3" must not share a
key. Slices carry no priority — a consumer does not sort slices across phases.
The top-level order array and order_source: "authored" are still published
alongside, naming the same sequence, so a consumer can switch its sort before the array
is removed in a later release.
Two rules hold it honest:
- It never contradicts
blocked_by. If A is blocked by B, B comes first. An order that disagrees is repaired rather than refused (publishing is a background side effect and must not fail) — an unmentioned blocker is pulled in, a late one is pulled forward, and everything the deps do not implicate keeps its stated position. The repair is reported in thereorderresult, never applied silently. - cairn never publishes an order it worked out itself. When nothing is stated no
phase carries a
priority, so a consumer is free to derive one and label it as derived. A guessed sequence and a stated one are different claims and must not look alike.
next follows the stated order too. A plan that publishes a priority the tool then
advances past is publishing a decoration.
Deferred: work that is parked, not in progress
A phase is deferred when it is finished, with a tail somebody deliberately parked.
Not open — nobody is picking it up. Not done — the work is real and the plan should
still show it.
Without that status, one slice titled S5 Billing (paid gate) keeps its whole phase in
the live set forever, and anything that ranks live phases ranks parked work above
genuinely open work. Measured on a real plan: four of eleven live phases were live
solely because of one slice whose own title said (later) or (paid gate), and the
phase with two genuinely open slices and nothing blocking it sorted eighth.
A phase is deferred either because you said so:
cairn_roadmap { action: "set_status", id: 3, status: "deferred" }or because every slice it has left is — that half is derived, not stored, on every read, from the same rows everything else already loads. Storing it would mean re-deriving on each path that can change a slice, and one missed path leaves a phase permanently mis-parked. The rule is exact: a phase with at least one deferred slice and no slice still open, in progress or blocked is deferred; a phase whose slices are all done keeps the status it was given, because whether it is finished is a judgement about work outside its slices and cairn does not make it for you.
What parking does, and does not do:
- It leaves the live set: out of
next, out of the published order, out of theM/N donedenominator (with the exclusion stated on the line, never quiet), and published asstatus: "deferred". - It stays in the plan: shown in the tree as
[>], published inphases[], not pruned fromroadmap.mdand not logged toroadmap_completed.md. Parked is not shipped.set_status <id> openpicks it back up. - The
## Ordersection still remembers it, even though it drops out of the publishedorder— unparking should not cost you your place in the queue. - A blocker that is parked still blocks:
nextrefuses to advance past it and says which phase did it. What changes is that the order stops dragging parked work forward.
Plans said this in prose long before there was a status for it. deferrable reads those
markers back:
cairn_roadmap { action: "deferrable" }It proposes — it never writes. Candidates are titles carrying a parenthetical park
marker ((later), (paid gate), (design-later), (on hold), …), gated on the
parenthetical rather than the bare word, because "do this later" appears in half the
phase titles ever written. Each candidate comes with the consequence that makes it worth
confirming: which phases would leave the live set, and whether that is because of the
marker itself or because of what would be left under them. You confirm the ones you
mean, one set_status each.
Being asked for the order
cairn shipped order and reorder — documented, with relative moves and cycle repair —
and two days later order was still unset in every project that had one. Not because
anyone disliked it: because nothing ever mentioned it. A capability nobody is prompted
to use is indistinguishable, from the outside, from one that does not exist.
So plan-changing actions — a sync that inserted or retired phases, set_status to
done or deferred, and reorder itself — may carry an advisory field:
"order_advice": {
"state": "never_set",
"live_phases": 11,
"unordered": ["phase-25", "phase-60", "phase-101"],
"why": "11 live phases and no stated order, so `next` follows plan number order — the sequence they were written in, which is not a priority; 3 of them are waiting on another phase",
"ask": true
}Four things about it are deliberate:
- It never fires on
tree,statusornext. The end-of-turn hook calls those after every assistant turn, and a nudge there is noise on a loop — which is precisely how a cursor sat on a finished phase for weeks, on screen the whole time, in a line everybody had stopped reading. - It is advisory. Never a refusal, never a blocked action, and cairn never writes an
order itself. cairn is an MCP server and cannot ask a question; the agent can, and it
has the code in front of it, so it brings you a recommendation with reasons rather
than a bare "what order?". An order the agent invented would be published as
order_source: "authored"and shown to you as your own decision. whyis generated from the plan, and it is the point. "No order is set" produces a vague question and a vague answer.- It can go quiet. "No order has been set" and "we decided not to have one" are
different facts.
cairn_roadmap reorder decline: truerecords the second in the## Ordersection, in a line you can read and delete, andaskgoes false. It is not a lock: any laterreorderreplaces it with no ceremony. The decline records the phases live at the time, so a phase added later revives the question — and even then the advisory says the decline stands and asks only where the newcomer goes.
A stated order is never reported as needing attention. There is nothing to report:
publishing the order as a priority on each phase means it leaves with the phase, so it
cannot point at work that shipped or got parked. Phases it simply does not mention are
reported in unordered and nothing more — "phase 8 first, sort the rest out when we get
there" is a complete answer, and an advisory that treats a partial order as unfinished
makes stating one a chore nobody completes.
cursor_advice is the same shape for the other thing nothing ever said out loud — a
cursor left on finished (on_done_phase), parked (on_parked_phase) or removed
(dangling) work, or unset while live phases remain. The cursor is cairn's own state,
so no consumer can warn about it on cairn's behalf.
Both ride on the tool result, not in the published ROADMAP.md. A file is read
continuously by panels and dashboards; an advisory in it would be a permanent nag on a
screen that cannot act on it. It goes to the one reader who can put the question to a
person.
The first line is the marker that says who generated the file and when
(<!-- slot:roadmap format=roadmap/1 provider=cairn generated=… -->). cairn declares
the slot in its MCP entry in ~/.claude.json, so a cockpit does not have to guess.
It refreshes automatically on session activity (the end-of-turn hook and the first
prompt of a session) as well as on every cairn_roadmap call, so a workspace that has a
roadmap but hasn't touched the roadmap tool this session still shows a populated file.
Phase ids are stable across regenerations (they key off the database row, so renaming
a phase never changes its id), unknown JSON fields survive a rewrite, an unchanged plan
re-writes byte-identically (the generated stamp is only refreshed when something
actually changed, so diffs stay clean), nothing is ever written as an empty placeholder,
and writes are atomic (temp file + rename) so a reader never catches a half-written plan.
An existing ROADMAP.md is never destroyed. If the file is there but carries no
provider marker (hand-written, or from a tool that doesn't stamp its output), cairn
adopts it: the original is renamed to ROADMAP.bak.md (or ROADMAP.bak.2.md, .3,
… — an existing backup is never overwritten), the data block records migrated_from
and migrated, and the result reports backed_up_to. If the file is stamped by a
different provider, that's a conflict over who owns the path: cairn leaves it
completely alone. Any publish that declines returns a warning that the caller surfaces
to you, so a stale plan can't rot in place unnoticed.
Editing roadmap.md is editing the plan — read actions re-seed from it, and the
end-of-turn hook folds in any hand-edits. Marking a phase done prunes it from
roadmap.md and appends it to the append-only .cairn/roadmap_completed.md, so the
file always shows only what's next. The shipped phase stays in the database (counted
in the M/N done rollup, recoverable) but is hidden from the tree.
If a project still has its roadmap in the old decision_production_roadmap.md memo,
the first roadmap read migrates it automatically: the content moves to
.cairn/roadmap.md and the memo (plus its index entry) is removed from the memory
store. Content is preserved verbatim; the migration is one-time and idempotent.
Automatic capture
You don't have to register phases by hand. Two PostToolUse hooks fold work into
the roadmap mechanically:
- Approve a plan (Claude's plan mode →
ExitPlanMode) and its steps are parsed into### Phase Nentries inroadmap.md. Headings become phases; failing that, a top-level numbered list; failing that, the whole plan becomes one phase. Duplicate titles are skipped, and the cursor activates so the roadmap surfaces immediately. - Write a todo list (
TodoWrite) and the items mirror onto the roadmap — as ad-hoc sub-tasks under the current phase, or as seed root phases when no roadmap exists yet. A sub-task that's alreadydoneis never downgraded.
Completion is proposed, never auto-applied. The end-of-turn hook inspects new git
commits; when a commit references a phase (explicit phase N, or a strong overlap
with the phase title) the candidate is surfaced for you to confirm — Claude asks
before calling set_status … done, so a stray keyword can't silently prune your plan.
Stopping points: what clearing would cost
Before /clear or /compact, the question that actually matters is not how full
is the window. Fullness tells you when; it never tells you which. What
decides it is whether the saved state is sufficient — "the slice is three-quarters
built and the fact that long-press selection has never run on a real handset is
written down nowhere" is a judgement about the handoff, not about a percentage.
Cairn cannot make that judgement for you. It cannot audit for absence: the gap worth reporting is by definition the thing nobody wrote down, and a lazy checkpoint looks exactly like a thorough one from the outside. A tool that computed this would print "nothing outstanding" on precisely the sessions where the most was outstanding. So cairn does the part it can do honestly — it demands the judgement on the way in and carries it across the gap:
cairn_checkpointasks. Awould_be_lostfield whose description is the literal question: what would a fresh session not know? Unverified work, a thread you are mid-way through, a decision whose reason never made it into the commit message. A checkpoint that skips it says so in its result rather than passing silently.cairn_resumecarries it back, near the top of the payload and first in the summary, because everything else resume returns is recoverable from git and the index and this is the only part that is not. It also lands on the first prompt of the next session, since a session picked up after/clearrarely opens with the word "resume".It ages. The end-of-turn hook checkpoints every turn, so
session.jsonalways looks fresh while its contents may be hours and several slices stale. The handoff is stamped only when it is actually written, never by the heartbeat that copies it forward, and anything written before commits that have since landed is reported as possibly stale.Then, and only then, it asks. When the session crosses the threshold and something is actually at risk, the Stop hook asks you to choose, with a recommendation and the reason:
Checkpoint + /clear (Recommended) — I write the handoff now, then you type
/clearCheckpoint + /compact — I write the handoff now, then you type/compactKeep going — declineWhich one is recommended follows from the handoff, not from fullness. A current handoff means the thread survives a hard reset, so
/clearwins and you get the full window back. A missing or stale one means something unwritten is at risk, so/compactwins and keeps it reachable. Either choice writes the checkpoint first — including/clear, which needs it most, not least.And it is there afterwards.
SessionStart[clear|compact]re-injects the checkpoint into the fresh session, so it opens already knowing where it is. You never have to remember to type "resume", which is exactly the manual step that gets skipped on the sessions where skipping it costs the most.
Cairn cannot type the command. No hook output and no tool can trigger a slash command, so the agent does the half it can do (the checkpoint, immediately) and you type the six characters. Anything that claimed otherwise would leave you waiting for something that never happens.
How the size is measured
Not from file size. No hook input carries token counts, but every hook receives
transcript_path, and the transcript's assistant records carry the usage the API
actually reported — so cairn tail-reads the last 256 KB and sums the real window
(input + cache reads + cache creation + output). Measured cost: 6 lines parsed,
0.5 ms.
v1.19.0 used transcript bytes as the proxy and it was wrong in the one place that
mattered. Measured on cairn's own session: 3.40 MB of transcript holding 72,285
tokens of context. Bytes keep accumulating across a /compact while the window
resets, so a byte threshold is loudest immediately after a compaction, when there
is least reason to speak. Bytes survive now only as a fallback for when no usage
record is reachable, and when that happens the line says so instead of dressing a
file size up as a token count.
There is no threshold, and since v1.33.0 there is deliberately no attempt at one. Cairn reports the measured count on every prompt and the agent decides whether to stop, weighing what is left against how big the work you just asked for is going to be.
The reason is that a threshold needs a window, and the window is the one thing cairn
cannot see. No hook input carries it, the transcript records usage but never a limit,
and the model id is no help either: a claude-opus-5[1m] session writes the base
claude-opus-5 into both the transcript's message.model and lastModelUsage in
~/.claude.json. Three versions tried anyway, each failing differently:
| | approach | how it failed |
|---|---|---|
| v1.20.0 | fixed 120k tokens | 60% of a 200k window but 12% of a 1M one, so it fired a tenth of the way into a 1M session |
| v1.31.0 | infer the smallest window that fits the measurement | sound as a lower bound only, so below 196k it assumed 200k — reported 96% where /context said 19% |
| v1.32.0 | never guess downward; CAIRN_CONTEXT_WINDOW states the truth | nothing could ever set it. install-hooks cannot know the window either, so there was no correct value to write — unwritable by construction, and the feature went silently dead |
| v1.33.0 | report the bare count, let the agent judge | right about who decides. But a reader handed a number with no denominator supplies one, and supplies the familiar 200k: an agent at 253,500 of a 1M window called for a clear, twice, with 74% free |
v1.31.0's false-alarm band could never have been tuned away: any firing point for a 200k window is a point a 1M session also passes through, so no number serves both.
The agent, though, does know its window — it is stated in its own system prompt — and it is the only party that knows whether the next task is a one-line answer or a refactor across thirty files. So cairn hands over what it actually measured and the rules for acting on it, and never issues the verdict itself. It is the same division of labour as the handoff: cairn demands the judgement and carries it, never computes it.
Since v1.34.0 the agent also declares the denominator. Pass context_window to
cairn_resume or cairn_checkpoint once per project (optionally with
context_window_model), and every reading from then on is scaled:
[cairn] Context: 253,500 of 1,000,000 tokens (25%, declared for claude-opus-5[1m]).Until you do, the line carries the raw count and says the window is undeclared, which
is the honest state — cairn still never invents a denominator. The declared window is
stored beside the handoff in session.json and carried forward by checkpoints that do
not restate it, so the every-turn heartbeat cannot drop it.
This is not CAIRN_CONTEXT_WINDOW coming back. That died on who could ever set it,
and the answer turned out to be: the one party that can read it off its own system
prompt. A declaration can still be wrong — carried into a narrower model's session, say
— but it is wrong on screen, next to the model it claims to be for, and a reading over
100% is called out as an impossible one rather than an emergency. A stated assumption
gets corrected; the unstated one that replaced it in v1.33.0 could not be.
That also removes the rate-limiting. The old block was a verdict, so repeating it was nagging and it had to be shown once per situation; this is a measurement, and a budget you are only shown once it is nearly spent cannot inform the decision to start something. It rides every prompt.
Since v1.35.0 the line also says which reset to use. Every reading carries a
second line naming /clear or /compact and why:
[cairn] Context: 90,000 of 1,000,000 tokens (9%, declared for claude-opus-5[1m]).
Handoff is STALE, written before the 1 commit(s) since: the retry path is untested…
→ If you reset: /compact — the handoff predates the 1 commit(s) since, so it no
longer describes the work in flight.Cairn had been computing that recommendation since v1.29.0 and printing none of it:
recommendedAction() decided between the two on every prompt, and the hook built its
output from the measurement and the protocol alone, so the advice reached the screen
only if the agent chose to raise it. That routed the whole path through the one
component with a demonstrated failure record — the same judgement that read 253,500 of
1M as an emergency, twice.
The advice turns on what is saved, never on how full the window is. A current
handoff means the thread survives a hard reset, so /clear is the better trade; a
missing or stale one means something unwritten is still at risk, so /compact keeps it
reachable. That is why it can print at 9% as readily as at 94%, and why the wording is
strictly conditional. If you reset, use this one. It never says to reset — that
judgement is still the agent's, and still yours to act on, since cairn cannot type
either command. A fact about your saved state does not become false or urgent by being
displayed; the verdict it replaced did, which is why that one had to be rationed.
Since v1.36.0 there is exactly one threshold again: the 90% gate. At 90% of the declared window, cairn asks the agent for one specific thing:
⚠ [cairn] 94% of the declared window (940,000 of 1,000,000 tokens, 60,000 left) and
nothing has been written down about what this session would lose. Call cairn_checkpoint
with would_be_lost: the every-turn auto-checkpoint carries that field forward but never
writes it, so nothing else will. This is not a verdict on whether your next task fits —
that judgement is still yours.A threshold is defensible now for a reason that did not exist before v1.34.0: the window is declared, not guessed. The gate fires only when an agent has stated its denominator and never otherwise, so a project that has declared nothing gets no trigger and no false alarm — by construction, not by tuning. That is the property the fixed 120k, the inferred-smallest and the unwritable env var could never have had.
It also asks for something none of them asked for. It does not say stop, and it does not
rule on whether the work fits. It demands the one field an automatic save must never
invent: the Stop hook checkpoints every turn, but would_be_lost is deliberately carried
forward untouched rather than fabricated (checkpoint.js:68), which makes it precisely
the field still missing when the window runs out.
90% rather than 95% because the cost is asymmetric — firing early costs about 1k tokens for a handoff worth writing anyway, firing late costs the session's whole unwritten reasoning — and because usage does not climb smoothly. A single image read or large test dump can take 10k in one turn, which is the entire margin 95% leaves on a 200k window.
It also names who picks /clear vs /compact, and on what basis. Cairn's own
recommendation is about what is saved — but that is a precondition, not the decision.
The decision is whether the work ahead needs the context already loaded: a new,
unrelated phase gains nothing from this conversation, so /clear is the better trade,
while a phase continuing this one would force a re-read of everything, so /compact is.
Cairn cannot see which of those is coming, and does not try — that is the window-inference mistake in a new costume. The agent can: it has the incoming request, the roadmap cursor, and where the conversation was heading. So the gate demands that judgement rather than leaving you a two-branch menu, and the user-facing half explains what each command does so you can act on it or overrule it. Same division of labour as everywhere else here: cairn measures, the agent judges, you act — because cairn cannot type either command.
Once the handoff is written, neither option drops anything that was written down, so the choice collapses to that single question about what comes next.
v1.36.1 is the release that made the sentence above true. It was not. The gate would
demand a thorough would_be_lost, the agent would write three and a half thousand
characters of reasoning that existed nowhere else, correctly tell you a reset was safe —
and the SessionStart re-injection would hand the fresh session 220 characters of it, cut
off mid-word. Everything after the first point was gone. The agent could not remember the
conversation you had just had with it, which read as the checkpoint failing when the
checkpoint had worked perfectly.
Storage was never the problem; delivery was. inlineResumePayload() rendered the restore
payload through the same 220-character clip as the every-prompt budget line. That cap is
right for the budget line — it rides every turn and has to stay one line — and ruinous
for the restore payload, which fires once and is the only copy of the handoff that
session will ever see. One cap could not serve both, so handoffSummaryLine() now takes
one, and the restore path passes none. It costs about 900 tokens a session.
The failure is worth naming because of its shape: the agent was right about what got
saved and wrong about what got delivered, and no amount of care inside the writing
session could have caught the difference. The same bug had a second instance — notes long
enough to be filed to .cairn/notes/ are held as objects, and the restore stringified
them to [object Object], so the most substantial notes were the ones that arrived as
noise. When a feature demands expensive judgement from the agent, test the whole return
path, not just the write.
v1.37.0 gives the handoff somewhere permanent to live, and stops previews cutting
mid-word. session.json holds exactly one would_be_lost, and every manual
checkpoint overwrites the last, so a project's history of what each session would have
lost was destroyed one checkpoint at a time — and the rotation at 100KB scattered
whatever survived across session.N.json files nobody reads. The most expensive field
cairn ever asks an agent to produce had the shortest life of anything cairn stores.
Every answered handoff is now appended to .cairn/handoff.md: full text, never
clipped, newlines intact, plain Markdown you can read or grep without cairn. Newest
first — unlike roadmap_completed.md, which appends at the end because it is a shipping
record read in order. This is a lookup, so it is ordered for the lookup. The every-turn
heartbeat is not logged; only a real answer is, or a handful of genuine judgements would
be buried under thousands of identical copies. It is an addition rather than a migration:
session.json keeps the field unchanged as the hooks' fast path, so nothing that reads
it needs to know the file exists.
Previews no longer end mid-word. A cut like …the re-injection clipped would… is worse
than a shorter clean one: the reader cannot tell whether the truncated token was would,
would_be_lost or wouldn't, so the last thing they are shown is the one thing they
must not trust. clip() backs up to the last space and drops trailing punctuation. The
exception is deliberate — an unbroken run longer than the cap (a stack trace, a base64
blob, a Windows path) has no boundary to back up to, so below a 0.6 floor it takes the
hard cut rather than returning almost nothing. The cap itself went 220 → 400, because at
220 the budget line reliably died inside the first sentence of point (1): enough to prove
a handoff existed, not enough to say what it was about.
One test had to change, and it is worth saying which. The v1.36.1 guard asserted the every-prompt line stayed under 300 characters — a magic number tied to the old cap, so it failed on a change that was entirely intended. It now asserts a ratio: the preview must stay under half the payload. The invariant was never a particular length, it was that one channel stays a preview while the other carries everything.
v1.38.0 adds the other half of the question. Everything above is retrospective.
would_be_lost asks what a fresh session would not know, and every example in it is
something that already happened. So a restored session inherited the whole of what the
last one learned and nothing of what it was going to do with it — thorough notes, a
legible thread, and an agent that still opened with "what now?" to a user who had
answered that an hour earlier.
cairn_checkpoint now takes next_step, carried by exactly the same rules as the
handoff: the heartbeat copies it forward but never restamps it, it ages against commits,
and it is delivered in full on restore, directly under the handoff. It asks for the
reason as well as the action, because the action alone already survives in the roadmap
and the message field — what does not survive is why that action was next rather than the
alternatives, and an agent that inherits only "run the Windows fixtures" will happily do
it in the wrong order or redo the deliberation that chose it.
Staleness means something sharper on this half. A stale handoff is still true: what was learned stays learned, and later commits only mean it may be incomplete. A stale next step may be actively false — commits landing after "next I will run the fixtures" are quite often that step being done, so the warning says verify before acting on it rather than "may be stale". A restored agent acting confidently on a completed instruction is worse than one told nothing.
The 90% gate now demands both halves, and only the missing one. Asking for a handoff that
is already current invites it to be rewritten, and a rewrite restamps at — which is how
a thorough answer gets replaced by a hurried one at the worst possible moment.
And when nothing forward is registered anywhere, the gate says so. If the roadmap has no cursor and no open phase, then after the reset there is nothing outside the discarded conversation that says what comes next, and the gate suggests registering the phase. This is not cairn inferring the plan — that remains the thing it cannot see and must not guess. It reports an observed absence: the plan points at nothing. Absence is observable, content is not, and that distinction is the only reason this arm is allowed to exist. When the roadmap cannot be read at all — no index, no sqlite — it stays silent rather than claiming a bare plan it never saw, the same honest-absence rule the commit counter uses.
The probe is deliberately not the roadmap renderer: it is two cheap queries, run only when the gate is actually crossing, so the every-prompt path opens no database at all.
It needs no rate limit: it self-extinguishes. The moment the handoff is written the
demand returns null, and it speaks again only if later commits make that answer stale.
Crossing is remembered in .cairn/.handoff-gate and forgotten when usage drops back
below the gate, so a /clear makes the next crossing a genuinely new event. The stored
crossing also closes a hole that stale alone cannot: staleness is counted in commits,
so a long research session that commits nothing would keep a handoff from its opening
minutes marked current all the way to the top of the window.
CAIRN_CONTEXT_HINT_TOKENS no longer gates anything, but if you set it, cairn tells
the agent when you have passed it rather than ignoring you.
Deliberately not built: no full transcript walk (the tail only; a full walk in this hot path once froze every terminal in the cockpit for 4.3 seconds), no counting subagent spend as main-thread context, no cairn-side verdict about when to stop, and no auto-clearing, auto-compacting, or filling the field in on your behalf. Same shape as completed-phase detection: detect, surface, confirm with the user, never apply.
Supported languages
| Language | What Cairn extracts | |---|---| | Java | packages, classes, interfaces, enums, records, methods | | TypeScript / JavaScript | classes, interfaces, functions, types, enums | | Vue | components, composables, script block symbols | | Python | classes, functions, decorators | | SQL | tables, views, stored procedures | | XML / HTML | bean ids, component names | | Config (YAML, properties, .env) | top-level keys | | Markdown | headings | | Build files (pom.xml, package.json, build.gradle) | dependencies |
License
MIT
