Using it
Install once. Then four ways in.
Research doesn't start the same way twice. Cairn handles four openings, and picking the wrong one is the most common way a graph starts out untrustworthy.
First: two repos, two variables #
Confusing these is the first thing that goes wrong.
| variable | means | holds |
|---|---|---|
| $CAIRN_PATH | the tool | bin/ scripts/ skills/ docs/ site/ |
| $CAIRN_ROOT | a graph — yours | nodes/ papers/ meetings/ inbox/ build/ |
The tool can be shared without shipping anyone's research record, and one
tool can serve several graphs. A clone from before the split — with
nodes/ sitting beside scripts/ — still works
unchanged, because CAIRN_ROOT falls back to the tool repo when
unset.
Install — once per machine #
git clone <the tool repo> ~/cairn && cd ~/cairn cairn init ~/research-graph # your graph: dirs, git, hook export CAIRN_PATH=$HOME/cairn # the tool — add both to your profile export CAIRN_ROOT=$HOME/research-graph # the graph make setup # hook + slash commands + checks export PATH=$CAIRN_PATH/bin:$PATH # optional: plain `cairn` anywhere make doctor # confirm every step took
Then wire the machine up once, so every session sees the graph without a per-project step:
cairn install_context # dry run: says what it would change
cairn install_context --apply
That does three things, none of them per-project:
- Writes a marked block into
~/.claude/CLAUDE.md— user-level memory, loaded in every session in every directory — naming the graph and the four verbs. - Installs a
SessionStarthook that pulls (throttled) and returns the graph's current state as the session's context, plus aSessionEndhook that says what is unpushed. - Symlinks the slash commands, so
/log,/standup,/capture,/triageand/digestresolve from any repo.
The hook is the part a pointer can't do. A line in a
CLAUDE.md has to be followed, and an agent in the
middle of a task does not go looking. State already in the context window
gets used.
Clone the tool outside every project repo. One graph spans
all projects, and a clone nested inside a project shows up as an untracked
directory in that project's git status, makes /log's
repo field ambiguous, and quietly implies the graph belongs to
one project. make doctor warns if it's nested.
The one dependency
Python ≥ 3.7 with PyYAML, and nothing else, ever.
scripts/find_python.sh finds a suitable interpreter rather than
assuming python3 is one — on an HPC login node the default
python3 is routinely a 3.6 with PyYAML while
python3.9 has none, and picking either blindly fails.
That's why every command is cairn <something>
rather than scripts/<something>.py. The dispatcher
is a /bin/sh script, so it has no Python version of its own to
trip over. Running a script directly hands that choice to whatever
#!/usr/bin/env python3 resolves to — and a shim inside the script
can't correct it, because Python compiles the whole file before running a line
of it, so on a 3.6 interpreter the SyntaxError arrives first.
When something is off, make doctor before debugging
anything else. It reports which install step is missing — interpreter, hook,
slash commands, CAIRN_PATH, CAIRN_ROOT — and it runs
without a working Python, because the interpreter is the most common thing to
be wrong.
The session loop #
cairn sync at the start, cairn sync --push at the
end. That's git pull --rebase --autostash, then
regenerate-commit-build/-and-push, plus a report of what still
needs a human.
sync never commits a node, and never will.
Nothing enters the graph unreviewed, so --push commits only
build/, which is generated. Nodes stay exactly where they are,
uncommitted, and sync names them — a node file that has
never been committed is almost always a draft an agent wrote and nobody has
read.
Two ways a thing gets written, and only one of them reaches the graph
inbox/ is explicitly a junk drawer, and that's what makes
it safe to write to without asking. capture doesn't even commit,
because committing runs the validation hook — and a capture that can fail
because of schema drift somewhere else is not a capture.Four ways in #
A. Work that already happened → backfill first
Do not skip this because it sounds like homework. An empty graph means nothing for months, and during those months a missing node doesn't mean “not tried”, it means “we hadn't started yet” — the exact ambiguity that makes a graph untrustworthy.
cairn mine_sessions --since 2026-01-01 # what work exists?
cairn mine_sessions --project <slug> --summaries
If the work went through an agent, the transcripts are the best source — and better than the repo for the part that matters, because they contain the dead ends. If it didn't, there's an interview-based version, including why it will be biased toward what worked and what to ask to counter that.
Backfill to the depth that answers “did we already try this?” — not exhaustively.
B. Starting something new → open the node before you run it
cairn new_node direction "Does X predict Y?" --project <key> --new-project # then, before the first job goes to the queue cairn new_node experiment "X predicts Y from Z alone" \ --project <key> --parent <direction-id> --status planned \ --note "pre-registered, not yet queued" # then when it actually starts cairn new_node --update <id> --status running \ --repo $(git remote get-url origin) --commit $(git rev-parse HEAD) \ --host $(hostname -s)
Write a ## Claim that says what you expect and what
result would prove it wrong. At the end of a session,
/log from inside the project repo does the rest: reads the git
log, drafts the node, asks only for the interpretation it can't infer, and
commits.
--project is checked against the keys already in the graph, and
an unrecognised one is refused with the near-matches listed.
It's the only field whose typo validates cleanly —
photpoly for photopoly passes every check and
silently splits one project into two lanes. Starting a genuinely new project
takes --new-project.
C. Starting from a literature review
Most new projects start here, before there is a repo or a run.
A literature finding is an experiment — it has a
claim, a method and a result, and the corpus is the instrument.
cairn new_node experiment 'Can a reported "final Tg" be fitted as Tgp?' \
--project photopoly --parent <direction-id> --status dead \
--ref 10.1021/ma101296j \
--note "vitrification-limited; the bias is formulation-dependent"
status: dead because the claim was refuted — by the literature
rather than by a job that failed, which makes no difference to what it means
downstream. Six months on, “can we just fit published final Tg
values?” has an answer with a reason attached.
- One claim per node. A review touching eight things is
eight nodes, or one
directionwith eight open questions — not one node called “lit review”, which is unsearchable by the question you'll actually ask. --refthe DOI and register the paper, so the evidence is resolvable rather than remembered.## What I didis the corpus and the filter — how many papers, from where, what was excluded. That's this node's methods section, and it's what tells a reader how much weight the result carries.- A constraint that shapes later work belongs in the graph, not only in the synthesis document. The document is read once; the node is what the graph shows you when you touch that part of the problem again.
At corpus scale — hundreds or thousands of PDFs — those papers can't each get a node, and the answer isn't one node per cluster either: the map is an artifact, one node describes the mapping run, and a cluster becomes a node only once someone has read it.
D. Your work isn't computational
The model is domain-neutral — claims, results, dead ends, directions,
deadlines. repo, commit, host and
artifacts are all optional on an experiment, so
a bench experiment node validates today with none of them, and
a node with no SHA is not a second-class node.
The interface is not neutral, but it was never meant to be the interface: the design assumes an agent writes the record while you work, and you read the map and answer one question — what the result means.
Work that happened somewhere Cairn can't see — a web session, a conversation,
a whiteboard — goes to inbox/ as unstructured text and gets
triaged later. Capture always beats filing.
Reading the graph before you add to it #
Three views, and they answer different questions. Don't substitute one for another. None of them writes anything.
standup
What should I pick up today? Ordered by what needs you rather than when it happened, so a project whose live threads have gone quiet ranks above one that moved yesterday — the quiet one is the one whose context you've lost.
digest
What changed? Chronological. The right question when you've been present all week, and the wrong one after two weeks away.
build/report.md
How much do I trust a gap? Where to start, cross-project edges, and the coverage table.
cairn standup # every project, most-needs-you first cairn standup --project polyid # the resume packet for one project
--project adds the part that stops repeated work: what
this project already settled, most recent first, each with the note
saying why. A dead node there is a claim someone refuted, and
rereading it is how the same dead end doesn't get tried twice.
It also reads ## Next back out of the node bodies,
which is the cheapest resume there is: the sentence you wrote when the whole
problem was still in your head.
Trusting a gap
The coverage table in build/report.md gives the two numbers that
make “a missing node means not tried” usable at all: how many nodes a project
has, and what share were committed within a week of the work they
describe.
A project at 0% is a reconstruction, and a reconstruction is biased toward what worked. So a gap there means the graph doesn't know — not that nobody tried. Treat a missing node as “not tried” only where the project is well covered; in a thin project, saying “the graph doesn't know yet” is the honest answer.
Reacting, and who has read what #
cairn note 2026-07-20-polyid-run-variance "the spread looks bimodal — seeds?" cairn note polyid-run-variance # unique suffix is enough; opens $EDITOR cairn note --list --project polyid cairn review --list # what nobody has read cairn review polyid-run-variance "the seeds explain it" cairn review polyid-run-variance # read it, nothing to add
note commits by default, which is the one place
this differs from capture: the rule refuses to commit a node
you haven't seen, and a sentence you just typed is the definition of
seen. The whole value is that it's visible from the other machine tomorrow.
reviewed is derived from git, never a field. A
commit carrying a Cairn-Review: <who> <node-id> trailer
says a human read that node; a node whose last write came after its last stamp
is one nobody has read since.
- A note is a review.
cairn notestamps the trailer itself — reading a node and reacting to it is the thing being measured, so it shouldn't take two commands. - Reviewing an uncommitted draft admits it. The file and the stamp land in the same commit. That's the review queue draining.
- What predates the convention stays unclassified. The epoch is derived — the oldest commit carrying a stamp — so nothing has to be stored.
What this is not is proof a human did the reading. Agents run as whoever owns the clone, so nothing in git can tell them apart. The hook only checks that a stamp is well-formed and aimed at a node that exists, because that failure — a mistyped id that marks nothing — is otherwise silent.
Tasks as GitHub issues #
cairn sync_issues # dry run — prints, writes nothing cairn sync_issues -v # ...including the exact body it would post cairn sync_issues --apply
Only task nodes, and only outward. A task has a
due, an owner and no scientific claim, so an issue holds it
faithfully. An experiment is open or closed to GitHub — while the
point of this graph is that dead is not closed,
parked is neither, and the status event log is what makes replay
work. Mirroring an experiment would discard the part worth keeping.
What issues buy that a markdown file cannot: assignment, notification, and a phone. The graph stays authoritative and each issue says so in its own body. The target repo is derived from the project's other nodes, not stored per task; projects with no remote are reported and skipped, never guessed at.
Two machines, or two people #
They are the same problem to this repo, so a collaborator is a collaborator on the git remote and nothing more.
cairn sync # pull, then say what still needs you cairn sync --push # regenerate build/, commit build/, push cairn sync --check # no network; local state only
The friction here was never storage. It's a habit gap at two
moments: forgetting to pull before working, and forgetting to push
after. The second is the expensive one, because the work is written down and
simply invisible from the other machine. So sync automates the
half that is safe to automate and reports the half that isn't.
Conflicts are near-impossible by design. Nodes are separate
append-only files. The only shared-file contention is generated output under
build/, and when a rebase stops only on that,
sync regenerates rather than merging, and continues. A conflict
anywhere else is left for a human, because anywhere else it means something
interesting happened.
The one genuine collision is two people creating the same id on the same day — same project, same date, similar enough title to slug identically. Git catches that as a real conflict rather than losing one of them, which is the correct behaviour, if a startling way to find out.