npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-mcp

Requirements: Node.js >= 22.15.0


Setup

cairn install

That'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-hooks or cairn install-hooks --global


Upgrade

npm install -g @misterhuydo/cairn-mcp@latest
cairn install

Updating 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 them

It 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_maintain runs at the parent folder, it skips files already covered by a sub-project and instead federates all sub-indexes via SQLite ATTACH — no duplication
  • When a session opens inside a subfolder (e.g. elprint-component-service/), Cairn automatically discovers and mounts siblings:
    1. If the parent folder has a .cairn/index.db (already initialized) → uses its registry
    2. Otherwise → scans the parent directory for any sibling folders that already have a .cairn/index.db

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 services

When 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 maintain

Stats 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 root

The 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 ← current marker 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.md remains the authoring source and stays as detailed as you like; this half is the summary.
  • One roadmap-json block 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 phase ids, ref (the name that actually identifies a phase — see below), number, label, group, title, status, goal, blocked_by, slices, the full per-phase detail (the body you wrote in .cairn/roadmap.md, verbatim and in full — thinning the prose never thins this), and links — 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 completedone 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-json block 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.
  • detail may 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.
  • cursor may name a slice, not a top-level phase. It is the exact focus. To mark one phase current, use cursor_phase, which is always the top-level ancestor and always resolves in phases; cursor_path is the root-to-focus chain for breadcrumbs. All three are null when nothing is active, so "nothing is in progress" is never ambiguous with a broken pointer. A cursor you cannot find in phases[] is normal and not a dangling pointer — look in slices[], or just follow cursor_path.
  • number does not identify a phase; ref does. number is the phase number authored in the plan, and it is unique only within a decision group — one real plan publishes number: 1 on forty separate rows. Print or say ref, 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. ref is a name and can lengthen when phases are added elsewhere; id stays 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 -> 5

or 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 phase1 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_advice has no stale state.
  • 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 the reorder result, 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 the M/N done denominator (with the exclusion stated on the line, never quiet), and published as status: "deferred".
  • It stays in the plan: shown in the tree as [>], published in phases[], not pruned from roadmap.md and not logged to roadmap_completed.md. Parked is not shipped. set_status <id> open picks it back up.
  • The ## Order section still remembers it, even though it drops out of the published order — unparking should not cost you your place in the queue.
  • A blocker that is parked still blocks: next refuses 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, status or next. 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.
  • why is 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: true records the second in the ## Order section, in a line you can read and delete, and ask goes false. It is not a lock: any later reorder replaces 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 N entries in roadmap.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 already done is 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:

  1. cairn_checkpoint asks. A would_be_lost field 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.

  2. cairn_resume carries 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 /clear rarely opens with the word "resume".

  3. It ages. The end-of-turn hook checkpoints every turn, so session.json always 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.

  4. 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 /clear Checkpoint + /compact — I write the handoff now, then you type /compact Keep going — decline

    Which one is recommended follows from the handoff, not from fullness. A current handoff means the thread survives a hard reset, so /clear wins and you get the full window back. A missing or stale one means something unwritten is at risk, so /compact wins and keeps it reachable. Either choice writes the checkpoint first — including /clear, which needs it most, not least.

  5. 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