Cairn

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.

variablemeansholds
$CAIRN_PATHthe tool bin/ scripts/ skills/ docs/ site/
$CAIRN_ROOTa 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:

  1. 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.
  2. Installs a SessionStart hook that pulls (throttled) and returns the graph's current state as the session's context, plus a SessionEnd hook that says what is unpushed.
  3. Symlinks the slash commands, so /log, /standup, /capture, /triage and /digest resolve 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

you read it the gate REVIEWED PATH work you did a run, a commit, a result /log reads the git log drafted node uncommitted nodes/ committed, validated UNPROMPTED PATH a passing thought mid-task, any machine capture never refuses inbox/ no schema, no validation /triage discarded — also a correct outcome
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 direction with eight open questions — not one node called “lit review”, which is unsearchable by the question you'll actually ask.
  • --ref the DOI and register the paper, so the evidence is resolvable rather than remembered.
  • ## What I did is 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 note stamps 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.

laptop clone of the graph Obsidian, the map one git remote one file per node, append-only, never renamed the cluster clone of the graph where the jobs run push pull neither machine ever needs to reach the other code by repo + commit outputs by host:/path
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.