@eventually-consistent/cairn-server
v2.7.0
Published
cairn 2.0 MCP server — tracker, planning, memory
Downloads
782
Readme
@eventually-consistent/cairn-server
The cairn 2.0 MCP server — tracker-first project management for AI coding agents, speaking plain MCP over stdio.
Most people want one of these installs instead of this package directly:
- Claude Code: install the cairn plugin — it wires this server plus hooks, slash commands, and skills.
- Grok Build, Copilot CLI, Codex, Gemini CLI, Cursor, OpenCode, Zed:
npx @eventually-consistent/cairn cairn-setup <harness>wires your project's MCP config at this server.
Running it directly: npx -y @eventually-consistent/cairn-server — expects
a cairn.json in the project directory (CLAUDE_PROJECT_DIR or cwd); see
the quickstart.
Everything below is the developer reference. Design spec:
docs/superpowers/specs/2026-07-12-cairn-2-design.md.
71 tools total across planning, memory, continuity, collaboration, milestones, config, sessions (trace, probe, draft, thread), audits (plan_check, audit_record), the project knowledge graph (map_set, map_get), workspace/board (workspace_list, workspace_focus, workspace_status, board_get, board_update), peers (peer_list, peer_run), tracker-delta ingest (plan_tracker_delta), issue links + dependency graph (issue_link, issue_unlink, issue_links, graph_report), local-tracker promotion (tracker_migrate), and docs publishing (docs_publish, docs_status), and issue attachments (issue_attach).
Test rings
npm test— unit + contract-vs-FakeTracker (CI, no network)npm run test:live— contract vs a real backend. Per spec, an adapter is NOT shipped until live-green.
Each .live.test.ts skips cleanly when CAIRN_LIVE_TESTS and its backend's
env vars aren't set, so npm test never touches the network.
Adapter status
| type | unit | contract (fake) | contract (cached) | live status | live env vars |
|---|---|---|---|---|---|
| github | ✅ | ✅ | ✅ | 🟢 live-green (8/8, sandbox, 2026-07-13) | CAIRN_TEST_GITHUB_REPO, GITHUB_TOKEN (or gh auth login) |
| gitlab | ✅ | ✅ | ✅ | ⏳ implemented, live pending credentials | CAIRN_TEST_GITLAB_PROJECT, GITLAB_TOKEN |
| jira | ✅ | ✅ | ✅ | ⏳ implemented, live pending credentials | CAIRN_TEST_JIRA_BASE_URL, CAIRN_TEST_JIRA_PROJECT_KEY, JIRA_EMAIL, JIRA_API_TOKEN |
| asana | ✅ | ✅ | ✅ | ⏳ implemented, live pending credentials | CAIRN_TEST_ASANA_PROJECT_GID, ASANA_TOKEN |
| azure-boards | ✅ | ✅ | ✅ | ⏳ implemented, live pending credentials | CAIRN_TEST_AZURE_ORG_URL, CAIRN_TEST_AZURE_PROJECT, AZURE_DEVOPS_PAT |
| clickup | ✅ | ✅ | ✅ | ⏳ implemented, live pending credentials | CAIRN_TEST_CLICKUP_DEFAULT_LIST, CAIRN_TEST_CLICKUP_SPACE (or CAIRN_TEST_CLICKUP_FOLDER), CLICKUP_TOKEN |
| linear | ✅ | ✅ | ✅ | ⏳ implemented, live pending credentials | CAIRN_TEST_LINEAR_TEAM, LINEAR_API_KEY |
"contract (fake)" is ONE shared run of test/contract.ts against FakeTracker
(and CachedTracker(FakeTracker) for "contract (cached)") — it is not a
per-adapter run; each adapter's own live-gate coverage is what "live status"
reports above.
"contract (fake)"/"contract (cached)" mean the adapter's behavior is exercised
indirectly — every adapter shares the same trackerContract suite
(test/contract.ts), which today runs directly against FakeTracker and
CachedTracker(FakeTracker). The adapter-specific "contract" coverage lives
in each <name>.live.test.ts, gated as shown below; "unit" is each adapter's
own <name>.unit.test.ts against fixture HTTP responses.
Planning tools
The plan_* tools manage project artifacts and phase tracking across cairn integrations:
| tool | purpose |
|---|---|
| plan_scaffold_project | Create .cairn/plans/PROJECT.md + roadmap.md (never overwrites) |
| plan_scaffold_phase | Create phases/NN-slug/ with CONTEXT.md + PLAN.md (+ optional RESEARCH.md) |
| plan_status | Report phases, artifact presence (CONTEXT/RESEARCH/PLAN/VERIFICATION), and referenced tracker issues |
| plan_phase_ensure | Ensure the tracker has a phase named Phase N: <name> (idempotent by canonical name) |
| plan_drift | Flag plan-referenced issues that are missing or closed without a VERIFICATION.md |
| plan_issues_set | Set the tracker issue ids a phase's PLAN.md frontmatter advances |
| plan_meta_set | Set wave grouping (wave_N frontmatter) and/or the TDD-eligible task list on a phase's PLAN.md |
| plan_resync | Detect out-of-band commits (covered by no LEDGER.md range) since the last resync marker; advances the marker. First run initializes the marker and reports nothing |
| plan_tracker_delta | Diff the live tracker against the snapshot cursor (.cairn/tracker-marker.json): new issues/phases, per-field edits, external state changes. Peek by default; ack: true advances the cursor. First run initializes and reports nothing. Cairn's own mutations never echo — every issue-mutating tool writes through to the snapshot |
Artifact layout
Plans live at <projectDir>/.cairn/plans/:
.cairn/plans/
PROJECT.md # project vision and scope
roadmap.md # phase roadmap
phases/
01-name/ # phase directories (zero-padded number + slug)
CONTEXT.md # phase requirements
RESEARCH.md # optional deep-mode research
PLAN.md # execution plan (frontmatter: issues: [<tracker-ids>])
VERIFICATION.md # (optional) drift guard — presence exempts a closed issueEach phase directory name matches NN-<slug> where NN is 01..99 zero-padded
and <slug> is lowercase alphanumerics + hyphens (auto-slugified from phase name).
Drift semantics
plan_drift scans all phase PLAN.md frontmatters for referenced tracker issues and flags:
- missing: issue id no longer exists in the tracker
- closed: issue is closed AND its phase has no VERIFICATION.md
The presence of a VERIFICATION.md file in a phase directory signals that the
phase has been verified complete, so closed issues are no longer considered drift.
This gate is the human-controlled contract between plan state and phase completion.
Milestone tools
The milestone_* tools manage the project's milestone lifecycle, backed by the tracker's native milestone object when the backend supports one:
| tool | purpose |
|---|---|
| milestone_create | Start the next milestone — native tracker object when the backend supports it; stamps milestone_id into roadmap.md |
| milestone_list | Current milestone number, archived milestones, and the tracker's native list when supported |
| milestone_complete | Complete the current milestone: gate on all-phases-verified, close tracker phases, release the native milestone when supported, archive phases/ to milestones/vN/, bump roadmap. Idempotent — safe to re-run after a partial tracker failure |
Memory tools
The mem_* tools give agents a two-tier memory: a disposable full-text index for
reference material, and durable, git-committed cards for decisions/constraints/
gotchas/references:
| tool | purpose |
|---|---|
| mem_index | Index reference material into the searchable memory store (disposable, rebuildable) |
| mem_search | Full-text search the memory index, optionally scoped to a phase/issue |
| mem_stats | Memory index size — chunk count and approximate token usage (capacity guard signal), card-store health, and the observation backlog waiting on retro |
| mem_card_create | Write a durable memory card (decision/constraint/gotcha/reference) with provenance |
| mem_card_list | List memory cards, optionally filtered by phase/issue scope |
| mem_card_recall | List memory cards with staleness checked against their provenance (the anti-rot check) |
| mem_card_update | Adjust a memory card's confidence (frontmatter-only; body and id are immutable) |
| mem_timeline | Chronological neighbors (cards + index chunks) around an anchor, at index cost |
Artifact layout
Two tiers, with different durability guarantees:
~/.cairn/index/<project>.db # Tier 1 — FTS5 index, disposable, never git-tracked, safe to delete (rebuildable)
.cairn/memory/cards/*.md # Tier 2 — durable memory cards, git-committedTier 1 is a better-sqlite3 FTS5 virtual table keyed off mem_index/mem_search.
Tier 2 cards are frontmatter'd Markdown files (type, scopePhase, scopeIssue,
provenanceFiles, provenanceCommits, created) with a deterministic id
(<type>-<sha256(body).slice(0,8)>), so re-creating a card with identical content
never produces a duplicate file. type includes note (knowledge captured by
/cairn:mark --note, not work); every type may carry an optional
confidence: high | medium | low in frontmatter, surfaced by mem_search,
mem_card_list, mem_card_recall, and the SessionStart banner when present.
mem_card_update({ id, confidence }) is the one mutation cards get — a
frontmatter-only patch (body and id, a content hash of the body, stay
immutable; a changed lesson is a new card, not an edit) that throws
NOT_FOUND on an unknown id and triggers a banner re-render. /cairn:retro
is the primary writer: it grades a card's confidence up when a later phase
proves it out, or down (plus a corrected card) when contradicted.
Staleness
Every mem_card_recall call re-checks each card's provenance against git diff
at the recorded commit; stale: true means the underlying files have since
changed (or vanished, or couldn't be verified) — treat the card as a lead to
re-verify, not a fact to trust.
Capacity guard
cairn.json carries memory.tokenThreshold (default 150000) — read directly
from config by the skill (not returned by any tool) to decide when the memory
index is getting large enough to warrant summarizing or pruning.
Timeline
mem_timeline({ anchor, before?: 3, after?: 3 }) answers "what was happening
around this decision?" at index cost — anchor is a memory card id or an
index chunk source; the result merges chronologically adjacent cards
({ id, type, title, created, cost }) and index chunks ({ source,
createdAt }) into one ordered list. Cards carry a day-precision created
date while index chunks carry a full ISO timestamp, so entries sort by
whatever precision they actually have (a same-day chunk sorts after a
same-day card); same-day cards tie-break by id.
Session continuity
Kill a session mid-task — compaction, usage cap, /clear, a real crash —
and the next session resumes at the exact task with zero re-executed work.
The server is the primary writer: every state-changing tool (context_set,
issue_update, issue_close, plan_issues_set, plan_import,
mem_card_create, ledger_append) refreshes the handoff in-process,
write-through, on every call.
| tool | purpose |
|---|---|
| continuity_checkpoint | Write/refresh the session handoff (also called manually by /cairn:waypoint) |
| continuity_get | Read the current handoff; flags one older than 14 days as stale but never errors on staleness |
| continuity_clear | Delete the handoff — called on confirmed resume, ship, and summit |
| ledger_append | Append a verified-task line to a phase's git-committed LEDGER.md (append-only) |
Artifact layout
~/.cairn/handoff/<project>-<hash>.json # per-machine, ephemeral session handoff — never git-tracked
~/.cairn/banner/<project>-<hash>.md # pre-rendered recall-index cache (see below)
.cairn/plans/phases/<NN-slug>/LEDGER.md # per-phase, git-committed, append-only task ledger<hash> is sha256(resolve(projectDir)).slice(0, 16) — the same per-machine
keying scheme the memory index uses, so handoff/banner state never collides
across projects that happen to share a basename.
Resume flow
- A
PostToolUsehook (throttled to ≤1 write/60s) and an unthrottledPreCompacthook cover the gaps between tool calls;SessionStartcats the handoff and, percontinuity.resume, offers (prompt), auto-runs (auto), or suppresses (off) the resume. /cairn:waypointis the manual path: no argument pauses (prompts fornext_action/notes, optionally offers awip(cairn):commit);/cairn:waypoint resumeresumes.- Trust order is never the handoff alone. The tracker and
git logoutrankLEDGER.md, which outranks the handoff — a handoff that contradicts the tracker (an issue it names as open that's actually closed, a task the ledger already shows landed) is reported and corrected before it's followed. Seeskills/cairn-trailhead/verbs/waypoint.md. - On confirmed resume,
ship, orsummit:continuity_clear().
Guard rails
- Skeleton guard. A write can never replace a handoff with
task.currentornext_actionpopulated with an empty one — richness is monotonic between clears. - Unregistered guard. Every writer requires a loadable
cairn.json; nothing is ever created outside~/.cairn/for a project cairn doesn't know. - Never trusted blind. A handoff older than 14 days is surfaced as stale
and never auto-resumed, even with
continuity.resume: "auto". - Every hook is fire-and-forget (errors exit 0 silently) and targets <100ms — a hook failure is never visible to the session.
Recall index (session-start memory banner)
On every card mutation or active-context scope change, the server
re-renders the banner cache above: a byte-stable, token-cost-annotated table
of memory cards scoped issue > phase > project (id tiebreak), capped at
continuity.recallIndex.maxCards. SessionStart just cats the file — no DB
access, no runtime spawn. Fetch cost is ceil(card_chars / 4), computed
fresh at render time; mem_stats reports bannerTokens (the banner's own
cost) and tokensSavedVsFullInjection (sum of the scoped cards' costs minus
the banner cost, floored at 0) — honest accounting of what the pre-rendered
index actually saves versus injecting every card in full.
The banner also carries the observation buffer's state. Once
.cairn/observations/observations.jsonl holds at least
memory.observationWarnThreshold rows (default 25), a line directly under
the header reads N unreviewed observations (oldest Xd) — run retro, and a
backlog past that line renders the banner even with no cards and no open
sessions. mem_stats reports the same facts under observations (count,
oldestAgeDays, threshold). Both read the plain JSONL file — like the card
audit, independent of the index binding, so a broken index can't hide the
backlog. Capture stays passive; retro remains the only reader and the only
garbage collector.
Configuration
cairn.json's continuity block:
"continuity": {
"resume": "prompt",
"checkpoint": true,
"wipCommits": false,
"recallIndex": { "enabled": true, "maxCards": 20 }
}resume—prompt(default) asks before resuming,autoproceeds without asking,offsuppresses theSessionStartresume offer entirely.checkpoint— enables/disables thePostToolUsebreadcrumb hook.wipCommits—/cairn:waypointoffers awip(cairn): waypoint — <next_action>commit on pause when there's uncommitted work.recallIndex.enabled/recallIndex.maxCards— the recall banner above.
Collaboration
The plan_* and issue_* tools coordinate team workflow when multiple agents (or humans + agents) work on the same project.
| tool | purpose |
|---|---|
| plan_unplanned | Tracker issues (non-closed) that no phase's PLAN.md references — work at risk of being missed |
| plan_import | Reverse-mirror a tracker phase (by id or name substring) into .cairn/plans/ artifacts |
On very large trackers the underlying issue list is capped (1000 items on GitHub/GitLab via pagination, 100 on Jira/Asana/Azure Boards/ClickUp), so plan_unplanned's report may be incomplete beyond that cap; a truncation warning is logged to the server's stderr when it happens.
Configuration
User handle (optional). Set cairn.json's user.handle field to your identity (e.g., your GitHub username) to participate in ownership tracking:
{
"tracker": { "type": "github", "config": {} },
"user": { "handle": "alice" }
}When user.handle is set:
- Claim & assign:
/cairn:work <phase>callsissue_update(id, assignee: <handle>)so teammates see who holds each issue. If an issue is assigned to someone else, the workflow skips it unless the user explicitly overrides. - Skip others' work: By default, the work flow skips issues assigned to teammates, to avoid stepping on toes.
When user.handle is absent, cairn operates in single-user mode — no assignee tracking, no ownership checks.
Collaboration mode (optional). user.mode is "vibe" (default —
cairn drives end-to-end) or "engineer" (the human claims issues, writes
code, and makes the design calls; cairn pairs, scaffolds, verifies, and
keeps the tracker mirror honest for both parties' work — see the work
verb's pairing overlay and the auto/ship no-self-merge gate). Absent
key ≡ "vibe". Engineer mode needs user.handle for assignment identity
— set both together via tune mode engineer.
Assignee write support today is GitHub and Azure Boards only. The other backends accept the issue_update(..., assignee: ...) call but don't propagate it: ClickUp explicitly defers it (needs numeric user-id resolution not yet implemented); Jira, Asana, and GitLab have no assignee mapping yet.
Infrastructure (not new machinery)
Plans and memory cards collaborate via ordinary git — push your changes, open a PR, review and merge together. The server does not enforce locking or concurrency control.
Work-state concurrency (two agents starting the same issue at once) is the tracker's responsibility — its issue_update() call with state: "in_progress" is the atomic claim. Cairn reads the tracker's truth; the tracker enforces the constraint.
Per-machine isolation. Each machine holds its own active-context state (.cairn/state/active-context.json). Agents on different machines can work on different issues in the same phase without conflict — coordination happens via the tracker and git-committed plan artifacts.
Config tools
cairn.json gets a validated single-writer, the same discipline plan_meta_set
and patchRoadmapMeta already apply to plan artifacts:
| tool | purpose |
|---|---|
| config_get | Read cairn.json as the parsed, validated, post-defaults effective config (what /cairn:tune displays) |
| config_set | Deep-merge-patch the raw cairn.json; null deletes a key |
config_set validates the merged result against ConfigSchema before
writing anything — an invalid patch throws CONFIG_INVALID and the file is
left untouched. Patches touching tracker credential/env-var-shaped fields are
refused outright: secrets live in env vars, never in cairn.json.
ConfigSchema carries the leakGuard block below (all fields defaulted), so
its toggles validate through the same gate as everything else.
Sessions
Persistent, typed session files that survive /clear — git-side session
files paired with a tracker mirror that keeps management informed in plain
language, without diving into code. Four kinds share one core: trace
(persistent debugging, Tier C1), probe (risk-ordered throwaway spikes,
Tier C2, GSD spike parity), draft (multi-variant HTML mockups on a
shared theme, Tier C2, GSD sketch parity), and thread (persistent context
threads that outlive a single sitting, Tier E). The routing law for trace
(spec §726) is unchanged: a failed verify or a reported bug routes into
evidence → hypothesis → test → verdict, never an inline improvised fix (a
proven-obvious ≤3-line fix may still use the fast lane: one evidence entry,
one verdict, close).
The store generalization
server/src/sessions/store.ts is the shared core, parameterized by a kind
descriptor (entry-kind vocabulary + close gate). server/src/trace/store.ts
is now a thin compatibility re-export binding the core to the trace
descriptor — the Tier C1 public API (startTrace, appendTrace,
listTraces, closeTrace, lastEntryKind, traceId) and its on-disk
format stay byte-identical; test/trace-store.test.ts passes unmodified
and IS the compatibility test.
| kind | dir | entry kinds | close gate |
|---|---|---|---|
| trace | .cairn/trace/ | evidence hypothesis test verdict | ≥1 verdict |
| probe | .cairn/probe/ | experiment result requirement verdict | ≥1 verdict |
| draft | .cairn/draft/ | variant decision note | ≥1 decision |
| thread | .cairn/thread/ | note link decision wrap | ≥1 wrap |
Session ids are description-derived (<kind>-<sha256(description).slice(0,8)>
— the same hashed-content convention memory cards use), with the same
already-open guard across all four kinds: starting the same description
twice while a session is open throws PRECONDITION_FAILED pointing at the
existing session instead of forking it. Archives are immutable at
.cairn/<kind>/archive/<id>.md — a resolved session is immutable by
construction, not by convention, and every kind mechanically refuses to
close without its gate entry logged first. Probe/draft frontmatter adds one
optional field over trace's: phase: <n>, stamped at *_start from the
active context when one is set — the session_landscape phase linkage
below; thread_start stamps phase the identical way. thread closes on
a wrap entry rather than a verdict/decision — a thread closes by
being wrapped up, never abandoned silently.
Tools
| tool | purpose |
|---|---|
| issue_comment | Post a plain-language comment on a tracker issue (management-visible progress note) |
| trace_start | Open a trace session (.cairn/trace/<id>.md); creates the tracker bug issue (label cairn:bug) when no issueId is given; same-description open session → PRECONDITION_FAILED pointing at it |
| trace_log | Append a typed entry (evidence|hypothesis|test|verdict) — append-only; unknown id → NOT_FOUND, resolved session → PRECONDITION_FAILED |
| trace_list | List open and/or resolved trace sessions with entry counts — the read surface for status, the SessionStart banner, and resume |
| trace_close | Resolve: requires ≥1 verdict entry (else PRECONDITION_FAILED: "close needs a verdict — log one first"), archives the session, comments the resolution on the bug issue and closes it |
| probe_start | Open a spike session (.cairn/probe/<id>.md); creates the tracker issue (label cairn:spike) when no issueId is given; stamps phase from the active context; same-description open session → PRECONDITION_FAILED |
| probe_log | Append a typed entry (experiment|result|requirement|verdict) — same append-only/gate rules as trace_log |
| probe_close | Resolve: requires ≥1 verdict entry, archives the session, comments the resolution (proceed|pivot|stop — <reason>) and closes the issue |
| draft_start | Open a sketch session (.cairn/draft/<id>.md); creates the tracker issue (label cairn:sketch) when no issueId is given; stamps phase; same-description open session → PRECONDITION_FAILED |
| draft_log | Append a typed entry (variant|decision|note) — same append-only/gate rules as trace_log |
| draft_close | Resolve: requires ≥1 decision entry, archives the session, comments the chosen direction and closes the issue |
| thread_start | Open a persistent context thread (.cairn/thread/<id>.md); creates the tracker issue (label cairn:thread) when no issueId is given; stamps phase; same-description open session → resume, not a duplicate (PRECONDITION_FAILED pointing at it) |
| thread_log | Append a typed entry (note|link|decision|wrap) — same append-only/gate rules as trace_log |
| thread_close | Resolve: requires ≥1 wrap entry, archives the session, comments the resolution on the issue and closes it |
| session_landscape | Read-only join over all four kinds: every session's kind/id/status/issue/description/entryCounts, openByKind totals, archived resolution text (read from the ## resolution block), and phase groupings. Deterministic ordering (kind, then id — trace, probe, draft, thread) — same store state, same bytes |
Backed by the tracker interface's commentIssue(id, text) method +
Capability.hasComments (true on all eight adapters — Local, Linear, GitHub,
GitLab, Jira, Azure Boards, Asana, ClickUp — the flag exists so a future
backend can degrade to a recorded skip, same posture as hasMilestones).
Time tracking. issue_close accepts an optional timeSpentMinutes;
on backends with Capability.hasWorklog (Jira only today, via
POST /rest/api/3/issue/<key>/worklog) it writes a real worklog entry and
returns worklogLogged: true. A worklog failure never fails a close that
already succeeded — the result carries worklogLogged: false plus a
worklogError note, and the verb's close comment carries the time line as
the fallback on every backend.
Artifact layout
.cairn/trace/<id>.md # open trace session — single-writer through the trace_* tools
.cairn/trace/archive/<id>.md # moved here on trace_close — immutable once archived
.cairn/probe/<id>.md # open probe session
.cairn/probe/<id>/ # experiment code, runnable, throwaway
.cairn/probe/archive/<id>.md
.cairn/draft/<id>.md # open draft session
.cairn/draft/<id>/NNN-<name>.html # one variant per file, links the shared theme
.cairn/draft/themes/default.css # shared theme — CSS custom properties ONLY, one per project
.cairn/draft/archive/<id>.md
.cairn/thread/<id>.md # open persistent context thread
.cairn/thread/archive/<id>.md # moved here on thread_close — immutable once archivedFrontmatter: status: open|resolved, issue: <tracker id>, created,
resolved (close-time), plus probe/draft's optional phase. The body is
append-only typed blocks (e.g. ## evidence — <date>, ## verdict) —
*_log never rewrites a prior block, only appends a new one. *_close
appends a final ## resolution block, stamps status: resolved +
resolved, then moves the file live → archive (rename, not
copy-and-delete). *_close-equivalent archiving moves the session .md
only; probe/draft artifact directories remain until the verb's --wrap
packages what is worth keeping (.claude/skills/<name>/ with provenance),
after which they're deletable — the verb offers, never auto-deletes.
Tracker mirror touches are milestone-only, verb-driven (never per-entry
noise, never tool-implicit): comment #1 on *_start ("started"), a
key-finding/decision comment mid-session, and the resolution rides as the
*_close issue-close note — three touches per session, same story shape
across all four kinds (thread collapses the middle touch: two touches
only, start and wrap, per verbs/thread.md). *_start/*_log refresh the
session handoff (source: "tool") the same write-through way every other
state-changing tool does, so a killed session resumes into it via the
continuity machinery above.
Map store (project knowledge graph)
A single-writer knowledge graph backing the map verb (Tier E): typed
nodes and typed edges over one deterministic JSON store, the same
config_set-style merge-patch discipline cairn.json uses — validated
shape + atomic writes, never an index or a database.
// .cairn/map/map.json
{
"nodes": { "<id>": { "type": "module|phase|issue|decision|person", "label": "...", "detail": "..." } },
"edges": [ { "from": "<id>", "to": "<id>", "type": "depends-on|implements|decided-in|owns" } ]
}| tool | purpose |
|---|---|
| map_set | Merge-patch the graph: patch.nodes merges by id (null deletes an unattached node; deleting one still edge-attached throws PRECONDITION_FAILED naming the edges), patch.edges — when present — REPLACES the edge list wholesale (edge-level merge has no stable identity). Every edge endpoint must exist in the post-merge node set, or the write throws PRECONDITION_FAILED naming the missing id. Invalid node/edge types throw UNSUPPORTED. Returns { nodes: <count>, edges: <count> } |
| map_get | Read the graph, whole or filtered by nodeType, edgeType, or node (that node + every edge touching it + the neighbor nodes). Deterministic: nodes sorted by id, edges sorted by (from, to, type). A missing store reads as { nodes: {}, edges: [] }, never an error |
Writes are atomic (.tmp file + rename, never a partial map.json on
disk). The map verb is the only intended writer of graph content
(map build walks code/plans/tracker and proposes patches; map diff
reads and compares but never writes) — the server enforces shape and
atomicity, the verb owns the intelligence.
Plan checks / Audit records
Two read-only/single-writer tools backing the audit and review verbs
(Tier C3, GSD's audit-uat/audit-milestone/ui-review/eval-review/
validate-phase/add-tests/code-review/code-review-fix folded into one
discipline). Deterministic, no tracker calls of their own — the verb
layer does the tracker mirroring on top.
Tools
| tool | purpose |
|---|---|
| plan_check | Read-only scan of a phase's (or the whole project's) PLAN.md files for two detector classes: contract-drift and unanchored-threshold. Returns { findings, scanned }, deterministically ordered (plan path, then line) and byte-equal across calls on an unchanged tree |
| audit_record | Single-writer: audit_record(scope, verdict, findings) writes .cairn/audit/<scope>-<YYYY-MM-DD>.md. verdict must be pass (zero findings) or findings (one or more) — a mismatch throws. Re-running the same scope on the same day overwrites that file; prior dates are untouched. Returns { path, findings: <count> } |
plan_check detector rules
- Contract drift — collects every
- Produces:/- Consumes:bullet (plus its continuation lines, including inside a code fence) across a phase's plans. A consumer whose contract text shares a named symbol with a producer's, but doesn't match it after whitespace normalization, is acontract-driftfinding on the consumer's line, naming the producer's plan+line ascounterpart— unless both plans reference the same path-like fixture token (e.g.test/fixtures/export-contract.json), which silences it. - Unanchored thresholds — a quantitative threshold pattern
(comparison/bound word + number + unit:
< 100ms,>= 500 rps,at least 99.9%, …) with no anchor — a path-like token or one ofbenchmark|fixture|measured|per spec|spec §|source:— on its own line or an immediately adjacent line (that isn't itself a separate threshold statement) is anunanchored-thresholdfinding with the plan, line, and matched text. - Findings are sorted by plan path, then line, then type, then counterpart, then detail — never filesystem/readdir order — so two calls against an unchanged tree are byte-equal JSON.
Audit record file shape
.cairn/audit/<scope>-<YYYY-MM-DD>.mdFrontmatter: scope, verdict: pass|findings, created. Body: one
## finding — <severity> block per finding (critical|important|
minor), the plain-language title on the next line, an issue: <id> line
when the finding was mirrored to the tracker, and an optional detail
paragraph. A pass verdict carries zero finding blocks — the file itself
is still the evidence the audit ran. review verbs write the same shape
under scope: review-<target> (target slugged to
[a-z0-9]-plus-hyphens first).
Workspace (multi-project) and the dispatch board
Tier F1's full workspace awareness: a cairn-workspace.json at a workspace
root names member projects, and resolveProjectDir() is the ONE function
every existing tool's deps.projectDir now resolves through. The
compatibility guarantee, stated plainly: with no cairn-workspace.json
anywhere above the launch dir, resolution returns the launch dir and
NOTHING changes — every single-project cairn project today behaves
byte-identically. All 55 pre-F1 tools keep their exact names and
schemas; workspace awareness is additive, never a rewire.
Discovery
findWorkspace(launchDir) walks parent directories from the server's
launch dir looking for cairn-workspace.json, stopping at the filesystem
root (.git is NOT required — discovery is workspace-file-only):
// cairn-workspace.json at the workspace root
{
"workspace": "acme-platform",
"members": [
{ "name": "api", "path": "services/api" },
{ "name": "web", "path": "apps/web" },
{ "name": "infra", "path": "infra" }
]
}No workspace file anywhere up the tree → findWorkspace returns null —
the compatibility path. A workspace file that exists but is malformed (bad
JSON, missing workspace/members) is never silently treated as "no
workspace" — it throws CONFIG_INVALID naming the file, so a typo can't
quietly fall back to single-project behavior. Each member path resolves
relative to the workspace root; a member directory without its own
cairn.json is listed as configured: false (unconfigured) and refuses
focus with a pointed hint rather than failing silently.
Focus file
.cairn/basecamp/focus.json at the workspace root (never per-member),
single-writer, atomic (tmp + rename):
{ "focus": "api" }No workspace → no focus file → resolution is the launch dir. A workspace
that exists but has no focus set also resolves to the launch dir — a
session opened inside services/api works on api until it explicitly
switches. workspace_focus(project: null) clears focus back to the launch
dir. A focus naming a member that has since been removed or lost its
cairn.json (a stale focus) throws CONFIG_INVALID rather than silently
falling back — clear it or restore the member.
Resolution rules
resolveProjectDir(launchDir) is the single choke point:
- No workspace found →
launchDir(compatibility path). - Workspace found, no focus set →
launchDir. - Workspace found, focus set to a member that is still a configured member → that member's absolute path.
- Workspace found, focus set to a member that is no longer a member or no
longer configured →
CONFIG_INVALIDnaming the stale focus and the fix (never a silent fallback to the launch dir).
Per-project state that already keyed off the resolved directory (the
getTracker() cache, the handoff/banner path-hash) needed zero changes —
getTracker() became getTracker(resolvedDir) memoized per dir, and the
handoff/banner scheme already hashes resolve(projectDir), so a focused
member's state lands under its own hash automatically.
Concurrency caveat
Focus is workspace-global shared state, one file, single-writer, last write
wins — it is not scoped per session. In the #3256 parallel-dispatch
topology, several sessions run against the same workspace root at once, and
one session calling workspace_focus redirects every other session's
subsequent resolveProjectDir() calls to the new member, silently, with
no error and no signal to the sessions that just got moved. Each individual
tool call still resolves against a single, consistent snapshot of the focus
file taken at the start of that handler — a flip mid-call can never cause
one tool call to operate on two different members. But a multi-call flow
(claim a workstream, create an issue, update the board) spans several
handler invocations, and nothing above resolveProjectDir() re-checks that
focus still points where the flow expects between those calls. Callers
driving multi-call flows must re-confirm focus themselves before each write
— see the focus-discipline rule in skills/cairn-trailhead/verbs/basecamp.md.
Board shapes
.cairn/basecamp/board.json at the workspace root — a single-writer,
config_set-style merge-patch board for parallel workstreams (the #3256
dispatch pattern), one level up from any one session's own continuity:
{
"workstreams": {
"<id>": {
"title": "migrate api auth",
"project": "api", // member name
"status": "queued|active|blocked|done",
"issue": "GH-12", // once claimed — the member-project issue
"session": "…", // free-text claim tag (who/where)
"note": "…", // one-liner, latest state
"updated": "YYYY-MM-DD"
}
}
}| tool | purpose |
|---|---|
| workspace_list | Workspace name, root, members (name/path/configured/focusable), current focus. No workspace → { workspace: null } — not an error |
| workspace_focus | Validates the target is a configured member, writes the focus file, returns the resolution ({ focus, projectDir }). project: null clears focus |
| workspace_status | Curated cross-project read: per configured member, { name, phase, openIssues, openSessions } pulled from that member's own stores/tracker — read-only, never switches focus. A member whose tracker errors reports { name, error } rather than failing the whole call |
| board_get | Deterministic read: workstream ids sorted, plus derived counts by status. Missing board file reads as an empty board with zeroed counts, never an error |
| board_update | Single-writer merge-patch by workstream id (null deletes); validates status enum, project names a real workspace member, and title+project are required on create — all before any write, so a rejected patch leaves the file untouched. Requires a workspace; no workspace → PRECONDITION_FAILED ("run basecamp init") |
Peers (external AI CLI reviewers)
Tier F2's peer runner (server/src/peers/run.ts) shells out to four
allow-listed external AI CLIs — codex, opencode, antigravity, grok — as
a transport layer only. It moves bytes in and out of a child process; the
peers verb (skills/cairn-trailhead/verbs/peers.md) is what judges
whatever comes back. Nothing here is guaranteed installed — none of the
four are assumed present anywhere in the server.
Templates (fixed argv, stdin-only input)
Each provider has exactly one argv template, a constant, never built from user input; input reaches the CLI per its verified prompt convention:
| provider | argv | input |
|---|---|---|
| codex | codex exec --skip-git-repo-check - | stdin (documented - convention); the flag keeps codex from refusing directories the host hasn't marked trusted |
| opencode | opencode run <input> | argv — run [message..] is positional |
| antigravity | agy -p <input> | argv — the binary is agy; headless -p takes the prompt as its value and ignores piped stdin (verified live) |
| grok | grok -p <input> | argv — grok's headless mode does not consume piped stdin (verified live, formerly CRN-76 on the retired Jira tracker) |
Input is NEVER shell-interpolated — execFile takes an argv array and
resolves the binary via PATH itself; no shell ever parses any of it. For
the stdin-mode peer (codex) caller-supplied content goes in via stdin;
for argv-mode peers (grok, opencode, antigravity — their CLIs don't read
piped stdin) the capped input is the single final argv element, which is data
to execFile, not command line to a shell. The child-process surface is
exactly these four templates; there is no path from any tool argument to
a shell command string. Every child runs with cwd pinned to the project
dir — peer results must never depend on where the MCP server process
happens to be sitting (#56). The 200k default cap keeps argv-mode input well
under ARG_MAX.
Caps (cairn.json → peers block)
"peers": {
"codex": { "enabled": true, "maxInputChars": 200000 },
"antigravity": { "maxInputChars": 900000 }
// absent provider = enabled with the default cap; unknown keys rejected
}Default cap is 200,000 chars per provider (#997's constrained-model
knob) — a provider not named in cairn.json still gets this default.
Input longer than the configured cap is head-truncated with a marker line
appended verbatim ([cairn: input truncated at N chars]) and
truncatedInput: true in the result — the marker text is part of the
contract callers can depend on, not cosmetic.
Degradation (missing/disabled peers never block)
- Provider not on PATH →
peer_listreportsonPath: false(a DETECTED state, not an error);peer_runagainst it throwsPRECONDITION_FAILEDnaming the provider and an install hint. - Provider disabled (
peers.<name>.enabled: false) →peer_runthrowsPRECONDITION_FAILEDbefore ever touching PATH. - Timeout (default 120s) → the child is
SIGKILLed andpeer_runthrowsPRECONDITION_FAILEDnaming the provider and elapsed time. - Zero peers detected is a normal
peer_listresult, not a warning — thepeersverb proceeds without them rather than stalling.
Exit-code taxonomy (the one thing every caller needs to get right)
- Non-zero exit from the peer CLI itself is an advisory RESULT, not an
error. Peers are advisory; the
peersverb is the judge.peer_runresolves normally with{ exitCode, output, ... }whatever that peer's process returned, stderr folded intooutputwhen present. - A spawn/precondition failure THROWS (
CairnError, codePRECONDITION_FAILED): missing binary (ENOENT), disabled provider, timeout kill, or any other errno the child never turned into a real exit code (EACCES,EMFILE, amaxBufferoverrun). The process never really ran in these cases, so there's no advisory output to return — it's a tool error, handled the same way every other cairn tool reports a precondition failure.
| tool | purpose |
|---|---|
| peer_list | Detection/config snapshot per provider: { provider, onPath, enabled, maxInputChars }. Never throws — an absent CLI is data, not an error |
| peer_run | Runs one provider with capped stdin input and a timeout; resolves with the real exit code and output on any completed run (including non-zero exit), throws PRECONDITION_FAILED only when the process itself never ran |
Hooks
Four dependency-free, fire-and-forget Node scripts, registered in
hooks/hooks.json and run out of hooks/scripts/. Every hook targets
<100ms and fails open — an internal error exits 0 rather than blocking work:
| # | event | matcher | script | purpose |
|---|---|---|---|---|
| 1 | PostToolUse | Edit\|Write\|Bash | posttooluse-breadcrumb.mjs | Refresh the session handoff, throttled to ≤1 write/60s (see Session continuity above) |
| 2 | PreCompact | — | precompact-refresh.mjs | Unthrottled handoff refresh before compaction discards context |
| 3 | SessionStart | — | sessionstart-continuity.mjs | Cat the handoff + recall banner; offer/auto-run/suppress resume per continuity.resume |
| 4 | PreToolUse | Bash | pretooluse-leakguard.mjs | Leak guard — scan a staged git commit's diff for cairn-internal refs before it lands |
| 5 | PostToolUse | Edit\|Write\|Bash | posttooluse-observe.mjs | Passive observation capture (tool, target, error flag) to .cairn/observations/ — cairn projects only, capped, reviewed and cleared by retro, with its depth surfaced by mem_stats and the banner |
| 6 | Stop | — | stop-costtracker.mjs | Cumulative session cost snapshot (tokens + approximate $ + report bytes) tagged with the active phase/issue, to ~/.cairn/metrics/; cost-report.mjs rolls it up |
Leak guard (hook #4)
Fires only on git commit Bash calls (anything else exits 0 instantly).
Scans git diff --cached -U0 ADDED lines against the pattern set in
hooks/scripts/leak-patterns.mjs — the single source both this hook and
distill's sanitization gate scrub with:
.cairn/path strings- phase-dir refs (
phases/NN-slug,milestones/vN/) - cairn label strings (
cairn:seed,cairn:backlog) - the configured backend's issue-id pattern read from
cairn.json(e.g.PROJ-\d+for Jira) — for GitHub, bare#Nis deliberately NOT matched, since "fixes #123" is legitimate
Allowlisted paths (.cairn/**, docs/**, *.md, LEDGER/VERIFICATION
artifacts) are skipped. A hit exits 2 with a file:line: [pattern] match
listing on stderr, computed from the diff's hunk headers (accurate to the
real file line, not just diff-relative position) — the tool call is blocked
and the agent sees exactly what leaked and where.
Escape hatches, per spec §3:
cairn.json→leakGuard: { enabled: true, allow: [globs], extraPatterns: [regex] }, editable via/cairn:tune leakguard off|on.- One-shot override: prefix the command with
CAIRN_LEAK_OK=1. The override must prefix the command —CAIRN_LEAK_OK=1merely mentioned elsewhere (e.g. quoted inside the commit message) does not bypass the guard.
Accepted limitation (spec §Why): commits made outside Claude Code are
unguarded — a git-hook installer is a possible later tune offering, out of
scope here.
Report bytes (hook #6)
Fan-out is not free: every subagent's report lands in the coordinator's
context and stays there. Hook #6 measures how much, so the question "is
compressing reports worth building" has a number behind it instead of a
hunch. It counts both shapes a report arrives in — the tool_result
answering a Task/Agent call, and the <task-notification> an async
agent's result comes back on — and records three cumulative fields per
session: report_bytes, report_count (injections), and report_tasks
(distinct subagents; an async fan-out produces both shapes but is one
subagent, deduped by the tool-use id the notification names).
The share is taken against context_peak_tokens — the largest single
request a session made — never against the token sums. With prompt caching
those sums count the same replayed prefix over and over: one real session
here totalled 957M cache-read tokens against a window that never passed
~1M, so a share against them means nothing.
cost-report.mjs renders a task reports block in the summary, a
reports object in --json, and a per-coordinator breakdown under
--reports.
Running the live gates
Only github is live-green today — it's been run against a real sandbox
repo. The other five adapters are fully implemented and pass unit +
contract-fake but have not yet been run against live credentials; run their
gate below before relying on them in production.
github
export CAIRN_LIVE_TESTS=1
export CAIRN_TEST_GITHUB_REPO="<you>/cairn-sandbox" # a throwaway repo you own
gh auth status || gh auth login # or export GITHUB_TOKEN
cd server && npm run test:livegitlab
export CAIRN_LIVE_TESTS=1
export CAIRN_TEST_GITLAB_PROJECT="<you>/cairn-sandbox" # a throwaway project you own
export GITLAB_TOKEN="<personal access token, api scope>"
cd server && npx vitest run test/gitlab.live.test.tsjira
export CAIRN_LIVE_TESTS=1
export CAIRN_TEST_JIRA_BASE_URL="https://your-domain.atlassian.net"
export CAIRN_TEST_JIRA_PROJECT_KEY="SAND" # a throwaway project you own
export JIRA_EMAIL="[email protected]"
export JIRA_API_TOKEN="<token from https://id.atlassian.com/manage-profile/security/api-tokens>"
cd server && npx vitest run test/jira.live.test.tsKnown risk: this adapter posts search queries to POST /rest/api/3/search,
which Atlassian has deprecated on Jira Cloud in favor of /search/jql.
Migrating the adapter is expected to be needed by the time this gate runs
live against a real Jira Cloud instance.
asana
export CAIRN_LIVE_TESTS=1
export CAIRN_TEST_ASANA_PROJECT_GID="<numeric project gid>" # a throwaway project you own
export ASANA_TOKEN="<personal access token>"
cd server && npx vitest run test/asana.live.test.tsazure-boards
export CAIRN_LIVE_TESTS=1
export CAIRN_TEST_AZURE_ORG_URL="https://dev.azure.com/your-org"
export CAIRN_TEST_AZURE_PROJECT="<throwaway project you own>"
export AZURE_DEVOPS_PAT="<PAT with Work Items (Read & Write) scope>"
cd server && npx vitest run test/azure-boards.live.test.tsKnown risk: the classificationnodes/iterations response-shape handling (both
the { value: [...] } wrapper and the root-node children shape, plus
\Iteration\-path normalization) was hardened speculatively based on known
API variance, not against a live org. This live gate is the definitive check
that the parsing matches what a real Azure DevOps org actually returns.
clickup
export CAIRN_LIVE_TESTS=1
export CAIRN_TEST_CLICKUP_DEFAULT_LIST="<throwaway list id>"
export CAIRN_TEST_CLICKUP_SPACE="<throwaway space id>" # or CAIRN_TEST_CLICKUP_FOLDER
export CLICKUP_TOKEN="<personal API token>"
cd server && npx vitest run test/clickup.live.test.tslinear
export CAIRN_LIVE_TESTS=1
export CAIRN_TEST_LINEAR_TEAM="<throwaway team UUID>"
export LINEAR_API_KEY="<personal API key>"
cd server && npx vitest run test/linear.live.test.tsKnown risk: phase close writes a projectUpdate with the organization's
completed project-status id — that status lookup and write path was built
against the documented schema, not a live workspace. This live gate is the
definitive check.
Every suite creates issues/milestones (or the backend's phase equivalent)
prefixed contract: in the sandbox project. Clean up by closing them or
deleting the sandbox; the suite never touches anything it did not create.
Rebuilding dist/
The server/dist/ directory is committed to the repository so that marketplace installations (via git clone or tarball) can run the MCP server without requiring a build step. When you modify files in server/src/, you must rebuild and commit the updated dist/ directory:
cd server && npm run build && git add dist && git commit -m "…"Contributors should always rebuild dist/ alongside source changes; a CI check for drift is planned as future work.
