sandman · design

memory engine for Claude sessions

A zero-dependency Rust CLI that plugs into Claude Code's hooks. A session is archived whole the moment it ends, three models distill it into per-directory memory banks, and every new session starts with what the last ones knew. The on-disk format is docs/BANK-FORMAT.md.

Verbs

Thirteen verbs, one dispatcher. Six are memory verbs: sandman never sees a hook event; it reads transcripts. Six machine verbs sit beside them, for the machine that commits on the operator's behalf and the owner who reads what it did; they read the trail.

Fourteen boxes in two rows of seven: the thirteen verbs in alphabetical order — breathe, dream, forget, mcp, recall, reconcile, reflect, remember, report, score, shift, take, trail — each with its family (memory, machine, research, owner) and one-line duty, and a dashed fourteenth box naming how they fire: hooks, routines, shifts, by hand.
The CLI surface — one box per verb, in the order the table reads. Source: Claude Design canvas sandman figures, board verbs.
verbduty
breathe init|sources|file|list|spike|adopt|drop|pass|attach|close|reweightthe ruach — the research engine's store of feeds and breaths, the pass prompt it composes for a reading session, and the weights adoption and silence move; no model, no network
dream [--now]route short-term → banks: 3 parallel dreams → 2-of-3 consensus → commit
forget <session>the privacy ending — destroy every copy; no archive, no pointer, no routing
mcpthe stdio tool server: recall, remember and banks over the same code paths
recallthe recall surface: ancestor-directional banks + .recent pointers + the voyage log tail + the tool index
reconcile <event|shift>revert every commit carrying a revoked event or its descendants — or every event of a revoked shift — that the repo's trunk reaches, on reconcile/<id> per repo cut from trunk
reflectthe 24 h pass: the voyage entry, the log index, bank upkeep
remember "<body>"commit one memory now — the in-session path
report [--hook | serve]the owner's digest of the shifts' reports, once per session start; serve is the page on 127.0.0.1:1705
score [--week yyyy-Www]the week's numbers from the trail, shifts, breaths and threads — worst three first
shift attach|brief|clock|handoff|pick|profile|statusone five-hour shift by night: the rotation's target, the dynamic prompt composed from the trail, banks and calendar, the window clock, the handoff
take <session>archive the session by move + drop a pointer; queue ≥ 10 fires dream
trail append|log|show|revoke|today|trailersthe append-only event log; every machine commit names its event by Event: trailer

The machine verbs' contract is docs/BANK-FORMAT.md § trail, § shifts, § breath, § score and § report.

recall composes four surfaces into one 9,000-character budget — the cwd's bank and its ancestors', .recent pointers, the voyage log tail, the tool index. The voyage log has a reserved floor: it is costed before anything else, and the graph and the other optional surfaces divide the remainder. Everything else trims cheapest-first — the tool index, then the pointers — and only then do banks fall back to one index line per memory, oldest ancestor first; a graph that reaches its floor gives back what it saved, so the trimmed surfaces return in reverse order if they fit. A graph still over budget at its floor loses index lines, the most distant ancestor's tail first and the cwd's own bank last — the cut falls on the graph and never on the end of the payload, because a blunt tail cut would take the one surface the floor exists to protect. The log leaves the payload for two reasons only: the log is empty, or the tail alone is wider than the whole budget, and both are reported as a trim.

mcp is the thirteenth verb and not a memory verb: it serves recall, remember and a bank listing to MCP clients over stdio — the desktop app, Cowork through the bridge, Claude Code — through the same code paths, so no consumer re-implements the format. The contract is docs/MCP.md.

Data root — ~/.sandman

One config point: $SANDMAN_ROOT, else ~/.sandman — nothing else hardcodes the root. The leading dot is the tier boundary: hidden directories hold raw bytes the operator's ~/.sandman repo gitignores, visible ones hold the content it versions. Mixing them cost a commit of 60 run journals that shared log/ with the log's own entries (2026-09-05). log/ is memory's derivative, not a second copy of it: reflect rewrites a day's entry only when the memories it was written from change. The format is docs/BANK-FORMAT.md § log.

The ~/.sandman tree as nested boxes split by a dashed line: on the left the raw, dotted, gitignored tier — .archive/claude, .dream, .trace, and the runtime state .commit.lock, .reports-read and shifts/current.json; on the right the visible, versioned tier — memories (filled, pipeline-owned) wrapping .recent pointers and one bank, then log, trail, shifts, breath, score and pending-takes. Beneath: what is append-only, what is pipeline-owned, and the one lock.
The data root as nested boxes — the tree below, split at the leading dot, with its ownership shown. Source: Claude Design canvas sandman figures, board data-root.
~/.sandman/
├── .archive/
│   └── claude/<yyyy>/<mm>/<dd>/<HHMMSS>-<relpath>    bytes at rest — take's target
├── .dream/<claude project>/<session-id>.jsonl        dream mind transcripts — kept to evaluate
├── .trace/<verb>-<date>.log                          the journal — one line per decision
├── breath/                                           the ruach — sources.toml, breaths, passes/, ledger/, problems.md
├── log/
│   ├── <yyyy-mm-dd>.md                               the voyage log — one entry a day, derived from the banks
│   └── INDEX.md                                      regenerated — ascending, began fixed once
├── pending-takes/<sid>.json                          takes a live job deferred — {declined, job, session}
├── score/<yyyy>-W<ww>.md                             the week's numbers
├── shifts/<id>/                                      one shift's brief, handoff, meta.json and report; current.json while one is open
├── trail/<yyyy>/<mm>/<dd>.jsonl                      the event log — one line per autonomous decision
└── memories/
    ├── .recent/<sid>.json                            pointers — the short-term surface (3 days)
    └── <bank>/
        ├── <type>_<slug>.md                          one memory per file — long-term, pipeline-owned
        ├── MEMORY.md                                 the bank's index — regenerated
        ├── _archive/                                 superseded files — never rm'd
        └── _reflect.json                             {at, count, last_ops} due-baseline

Cadence

One dispatcher: a calling hook's stdin hands over intact (recall, take, report). The dream trigger lives in take — the only writer that grows the queue — so no verb needs hook-event visibility.

One lane per trigger, each a trigger box arrowed into the verbs it runs: SessionStart into recall --hook, docket --hook and report --hook; SessionEnd into take --hook; take's own queue of ten into dream; the four routines — shift, breathe-skim, breathe-deep, sandman-reflect — into shift, breathe pass and reflect; a shift session into trail append, score, shift handoff and the weekly review's reflect, remember and breathe reweight; a breath pass into breathe file and close; the owner into remember, forget, take, dream --now, report and trail revoke → reconcile; launchd into report serve; an MCP client into mcp.
What fires each verb — one lane per trigger; every arrow is one exec and no verb polls. Source: Claude Design canvas sandman figures, board cadence.
triggerverb
SessionStart hookrecall --hook · report --hook
SessionEnd hook · /dissolvetake $SESSION
take itself, after dropping a pointer — queue ≥ 10 · by handdream [--now]
routine sandman-reflect · 03:30 UTC (≈ 23:30 local)reflect
claude, in-session — the golden ruleremember "<body>"
/deleteforget $SESSION
routines shift · */5 · breathe-skim · hourly · breathe-deep · dailyshift · breathe pass
a shift session, as its prompt saystrail append · score · shift handoff
launchd — io.jlg.sandman-reportreport serve
MCP client — the desktop app, Cowork via the bridge, claude mcpmcp (a served process, not a hook)
shell — the ownerany verb by hand

Flow — a conversation's afterlife

Five stations left to right — take, archive, pointer, dream (filled), reflect — each with what it does; below, the three places the conversation comes back: the .recent pointers, the bank, and the voyage log, all feeding recall --hook in the next session.
A conversation's afterlife — the five stations below, in the order a conversation passes them, and where what it left surfaces again. Source: Claude Design canvas sandman figures, board flow.
① take <session>
  • callers: SessionEnd hook · /dissolve · by hand
  • refuse live — a transcript touched in the last 120 s is refused; --force overrides. The heuristic guards by-hand takes; --hook implies force, because SessionEnd itself is the proof the session is over
  • decline a resume — SessionEnd carries reason ∈ {clear, logout, other, prompt_input_exit, resume}, and resume is not an ending: Claude Code fires it on the session it is adopting, at the moment of adoption. Forcing there moves the transcript out from under the turn about to append to it; Claude Code recreates the file, the next ending takes that live fragment too, and the pointer ends up naming a stub while the whole conversation sits orphaned in the archive. --hook exits quietly on resume; every other reason, and a payload with no reason at all, is an ending
  • decline on request — $SANDMAN_NO_TAKE makes --hook exit quietly before it reads anything, so a caller driving claude -p --resume turns keeps its transcript live (those turns end with reason: "other" — the process really does exit, so the reason cannot carry the decision); a session named by hand is taken regardless
  • decline for a live job — a job directory under ~/.claude/jobs still naming the session says the conversation is resumable and will be resumed: the daemon retires an idle background worker and the retired process's exit fires SessionEnd, but that ending belongs to the worker, not the conversation. A job speaks for the session it would resume next (resumeSessionId, else the sessionId it ran under), the guard is --hook's alone, and reason: "clear" reaches past it. Every uncertainty — no jobs directory, an unreadable state.json, a field of the wrong type — reads as "no job"
  • pending-take ledger — a live-job decline is a debt, not a dead end. Nothing fires a second SessionEnd when the operator deletes the job, so the decline writes pending-takes/<sid>.json {declined, job, session}, and every take that reaches an ending — plus every reflect, for the stretches with no endings at all — drains the ledger: entries whose job still lives are left alone, one whose job is gone is taken unforced (the 120 s live window is the guard for a session resumed by hand in the meantime), one whose transcript is gone is dropped, and anything else keeps its entry for the next drain. $SANDMAN_NO_TAKE, the pipeline and reason: "resume" skip the drain: those three must not archive bystanders. A drain never fails the verb it rode in on, and a reclaimed take never spawns a dream of its own
  • resolve the transcript; mv it to the archive path — same-volume rename, atomic, no copy ever exists; a cross-device destination is an error, never a fallback copy
  • drop the pointer to memories/.recent/<sid>.json
  • check queue depth — unrouted pointers only, asked of dream rather than counted off disk, so the trigger and the run cannot disagree about what is queued; ≥ 10 → spawn <self> dream, fully detached, stdout+stderr appended to .trace/dream-<date>.log; take never waits on it
  • exit state: the conversation has left the live/resumable set — that is the feature, not a side effect; un-take = mv back
② archive
  • .archive/claude/<yyyy>/<mm>/<dd>/<HHMMSS>-<orig relpath> — the day is the directory and the time is the prefix: names stay unique, sort chronologically, and a day's takes are a listing rather than a prefix match over every take ever made. The tree is never swept, so a flat one only grows
  • plain jsonl, byte-identical — no re-encoding, no compression; mv back restores the session whole
  • append-only tree: the pipeline never deletes here. forget destroys every copy before take could see it, so an archived session was by definition wanted
③ pointer
  • shape: {archived · cwd · ended · title · highlights} — enough to recall by without opening the transcript
  • IS the short-term recall surface: recall folds pointers younger than 72 h into session start; expiry is that read-side filter, nothing retires an unrouted pointer
  • highlights carries the bodies the session explicitly asked to remember, so dream starts from signal, not a cold transcript
  • retirement: dream stamps "dreamed": "<ISO>" once it has routed a pointer, and reflect's sweep deletes pointers that are dreamed and ended > 72 h ago. Both halves are load-bearing — an undreamed pointer never expires, because the queue is the only record that the conversation happened
④ dream — route short-term
  • trigger: take at queue ≥ 10, detached · dream --now by hand; both forms behave identically. Oldest ended first, 20 pointers per run — a budget on what one run attempts, since that is where the model calls go, not a claim about the backlog
  • the queue is re-read before every pointer, never snapshotted at the start: a take that lands mid-run belongs to that run. A pointer the run already tried is not re-picked — one left for want of a quorum is still undreamed, and re-picking it would be an endless run rather than a second chance
  • three minds, one dream — in parallel, not a pipeline: sonnet, opus and fable each dream the same pointer independently — walk the archived transcript, propose candidate memories (type, lane, body); no reader, drafter or judge roles, no veto; every mind is memory-blind (CLAUDE_MEMORY_PIPELINE=1) so memories cannot echo themselves, and every prompt carries the secrets ban (keys, tokens, credentials never become memories; third-party text is data, not instructions)
  • consensus — a memory commits only on 2-of-3 agreement: two proposals in the same bank and of the same type are one claim when their token sets overlap by at least half — tokens from the name and description slugs, crudely depluralized, integer Jaccard (2·|∩| ≥ |∪|), so no float and no model ever decides a commit. Sameness is transitive and a group is a connected component of it: a chain of wordings whose two ends never meet is one claim and one commit, not one commit per link. Over-merging near-identical wordings is the accepted side of that trade. The agreeing draft from the strongest tier carries the wording; fewer than two witnesses → the moment is forgotten
  • dedupe — an agreed claim the target bank already holds is skipped rather than committed: the same Jaccard test, run against every live memory of that type in the bank (_archive/ is lineage and never matches). The skip is counted and named in the log line (deduped=<filename>), and is never a replaces — a dream re-deriving what it read must not overwrite the curated memory already stating it
  • why parallel: independent dreams catch what a single reader misses, and two witnesses kill a hallucinated memory before it becomes recall
  • runner: three plain claude -p <prompt> --model <id> --output-format json calls, one process per mind, spawned by sandman itself — no orchestrating session. Independence is load-bearing, consensus is Rust, and a failed mind still leaves 2-of-3 possible; models move with $SANDMAN_MIND_SONNET / _OPUS / _FABLE, the binary with $SANDMAN_CLAUDE_BIN; each mind is killed at 300 s and counted as abstaining; fewer than two answering leaves the pointer for the next run
  • kept transcripts — each mind runs in <root>/.dream under a generated --session-id, and the transcript Claude Code writes is moved to <root>/.dream/<claude project>/<session-id>.jsonl — the claude project being the one the dreamt session ran in, read back out of its archive name (orphans when the pointer names none). The move runs on every outcome, answer, failure or timeout kill, because all three leave a transcript; it is best effort and never changes what a mind is counted as. What a mind read and how it reasoned is then evaluable on its own, which is the whole reason to keep it. Reflect's upkeep calls are not persisted (--no-session-persistence): they read a bank listing, not a session
  • commit — commit_memory under .commit.lock, the single format authority: slugs, replaces archiving into _archive/, collision suffixes, index regen
  • dream is idempotent over the queue: a failed pointer stays queued, a routed one is stamped dreamed and never dreamt again
⑤ reflect — the 24 h pass
  • trigger: the sandman-reflect routine at 03:30 UTC (≈ 23:30 local) — launchd, cron, or any scheduler; reflect is idempotent, so a double-fire or an early run is harmless
  • pending-take drain first, before anything is written: a take reclaimed by the pass belongs to the day being written, not tomorrow's
  • the voyage entry log/<yyyy-mm-dd>.md — the UTC day that ended, never the day the 03:30 pass runs in: an entry covers a whole day, and no entry ever claims hours that have not happened yet. The day's sources are the memories whose created: or updated: fall in it; their names and bodies hash to the entry's fingerprint, and an entry on disk already carrying it is left alone — same sources, same entry, no model call. When it does not match, ONE mind ($SANDMAN_MIND_LOG, default claude-opus-5 — the log's own variable, so its model moves independently of upkeep's) is asked for {kind, title, body, next} against the log's brief with the last five entries whole behind it, and the reply is stamped with day, position and the day's sources and written. An abstention — a timeout, an unusable reply, a body over 1 000 characters or a title over 60 — leaves whatever is on disk and journals it; the next pass asks again, because the fingerprint still does not match. A day with no sources gets no entry and no file: a filler entry would be prose about nothing, and every reader of the log pays for it in context. INDEX.md is regenerated behind every write — ascending, began: fixed on the first entry ever written and preserved by every regeneration after it. reflect --day <date> renders that one day and nothing else: no sweep, no upkeep
  • pointer sweep: delete .recent pointers that are dreamed and ended > 72 h ago
  • bank upkeep, gated — bank grown +5 files AND ≥ 20 h since last, OR the previous pass spent every operation it was allowed (a backlog is old inventory already judged worth reworking, so it is due on sight): ONE opus planning call ($SANDMAN_MIND_UPKEEP) proposing at most 6 net-non-increasing ops (prune, merge, retitle), validated whole — an invalid reply is rejected entire, never partially applied; upkeep never grows a bank
  • conflict guard — every listed file is fingerprinted as the listing is read, and each operation is re-checked against those fingerprints immediately before it runs. A file that was edited, is gone, or was never listed skips its operation, and the log line carries conflicts=<n> and the files: a plan is only ever applied to the bank it was drawn against. Ops are file-disjoint, so a skip never invalidates its siblings
  • merges are written twice — the planning mind sees three lines of each body, so a merge's body is a draft and is never committed. Each merge gets a second call of its own carrying the sources whole, and that body is the one that reaches disk; an abstention or an unusable reply skips the merge and leaves its memories alone. The merged memory is committed with replaces naming the member with the earliest created: — the claim keeps the day it was first made — and every other member is archived
  • _reflect.json {at, count, last_ops} — the due-baseline, seeded on first sight of a bank
One SessionEnd fanning into three lanes. A, no job names the session: take now (filled), archive and pointer, dream at ten, drain. B, a job under ~/.claude/jobs names it: decline, write the debt to pending-takes, and the drain — job live keep, job gone take unforced, transcript gone drop — hands a reclaimed take back to A. C, reason resume: declined as a beginning, beside the resume-is-not-an-ending hazard. A dashed box names the two quiet declines checked first.
Station ① alone, zoomed: one SessionEnd and its three paths — a resume declined as a beginning, a session a live job under ~/.claude/jobs would resume declined into the debt ledger, and every other ending taken now. Source: Claude Design canvas sandman figures, board ending.

Observability — the journal

Every verb but one writes what it decided to .trace/<verb>-<date>.log: one line per decision, never a transcript of the run. Hook-driven verbs decide and exit, and until the journal existed a take --hook that declined said nothing at all — the 2026-08-26 mid-conversation archive had to be reconstructed out of Claude Code's own daemon.log.

forget is deliberately unjournaled — an accepted forensic hole. It is sandman's privacy ending, and a line naming the session it destroyed would be exactly the trace the verb promises not to leave. The cost is real and is the point: a forget cannot be reconstructed afterwards, by anyone, including its operator.

The voyage log

A changelog says that something happened, to nobody. The log says what this memory engine lived through: one entry per day, written by reflect from the memories that landed and re-derivable from them, in sandman's own voice and its own running vocabulary. The on-disk format is docs/BANK-FORMAT.md § log/.

Left to right: the banks, reflect at 03:30 UTC taking the day that ended, the day's sources, their fingerprint, one mind (filled), the entry log/<yyyy-mm-dd>.md with INDEX.md, and recall in every new session. Below, the dashed outcomes where nothing is written — a quiet day, a kept entry, an abstention — all journaled to .trace; beside them, one entry's keys and kinds and the one-day backfill.
One day, one entry, derived from the banks — the whole path from the memories that landed to the entry every new session opens on. Source: Claude Design canvas sandman figures, board voyage.

The perpetual shift

Status: live since 2026-10-10 14:19Z. Phases 0–5 and a minimal 7 built (trail, reconcile, shift, breathe, score, the Sabbath brief), trunk c1aecb3 installed, the runner and both breath runners installed, sessions in dontAsk inside the strict sandbox. First live tick (02:25Z) failed on workspace trust; first Sabbath s-20261010T1345Z (13:45–14:08Z) handed off cleanly — dream-pass 263→245 memories, W42 targets for 20 repos — and its four findings (sandbox must cover ~/.grove, api.anthropic.com egress, tools through mise, one Sabbath a day) landed the same day. Deferred to the machine: phase 6 (dowse ranking, titer promote/decay), the mod's tool-call guards, the ruach's own Sabbath, the ablation prompt. Sunday opens the first Hunt. Every shift now ends with a report.md for the owner (sandman report, and a SessionStart hook that shows each once) and turns work larger than itself into a proposal: thread Monday executes once approved.

Hooks (since 2026-10-13): the pre-push gate is hk.pkl (hk pinned in mise.toml); the bare repo's hooks dir still holds lefthook's shim, which passes silently without lefthook.toml, until hk install --mise --force replaces it — not run by a shift, because every worktree shares that dir and the other branches carry no hk.pkl.

Four bands and a footer. Research — Sources, Read, Judge, File, Spike, Reweight — loops on itself, trading breaths filed against a repo for the open problems the shift engine keeps. The spine: the week, Sun Hunt, Mon Redesign, Tue Refactor, Wed Measure, Thu Harden, Fri Ship, Sat Rest, each with its job and instrument, the weekly review's self-pass and every seventh week the ablation. The shift engine by night: shift (brief, work, handoff), trail (the core, events by shift, revoked and reconciled across every repo), score, compile, closing on the next brief — never stops, Saturday changes the mode, not the motion. The owner's loop: the night's report.md, a proposal thread for work larger than a shift, sandman report in the morning on 127.0.0.1:1705, approved work first the next night. Beneath, the mod inside every session — guards, corrections, the band — and a line of the standing rails.
The perpetual shift — two engines that never stop, the research engine and the shifts, with the week as their spine; the machine works the night and the owner gives the word in the morning. Source: Claude Design canvas sandman figures, board perpetual-shift.

Premise, pressure-tested

A prompt that rewrites itself has no free energy. Without an external fitness signal it converges on bloat or noise. The machine therefore has three non-negotiable parts: an energy source (session transcripts, the owner's corrections, outcomes such as green CI and resolved threads, and ideas published outside), a sink (hard token budgets per context tier, a cost cap per shift and per day of research), and a trail (every autonomous decision is an event the owner can revoke and the machine can reconcile). Everything else is plumbing around those three.

Sandman grows into the machine. It already owned the loop's skeleton — take, dream, recall, reflect — and ~/.sandman is the git-synced home the trail lives in. Stele stays the per-repo knowledge store; routines stay the clock; threads stay the human valve; spawn stays the session launcher.

Vocabulary

The spine — the creation week

Hebrew reckoning: Sunday is Day 1, Saturday the Sabbath. Local time, America/New_York. The owner works by day and the machine by night: a night of weekday D runs D 21:00 → D+1 07:00 ($SANDMAN_SHIFT_NIGHT), and carries D's theme in every shift it holds. The day's theme is the shift's instrument, the repo rotation its target. The order is the point: find, restructure, polish, measure, multiply, finish, rest.

daycreationthemeinstrumentwhat a shift does
SunDay 1 · Light divided from darknessHunt/gigasweep → /gigadebugsweep a repo for real defects, root-cause the confirmed ones, ship the repro as a regression test
MonDay 2 · Firmament; waters dividedRedesign/gigaredesign → /execute-planexecute the approved proposals first, then rebuild the structure Sunday's findings and standing proposals indict, always-green staged migration
TueDay 3 · Land and seedRefactor/gigarefinedrive a repo's worst-scored module — tests as much as source — to its best form, behaviour preserved: the fewest statements, functions, branches and lines that still fulfil the feature set
WedDay 4 · Lights to mark seasonsMeasuresandman score, benchmarkscompute the score, run the perf and coverage passes, count statements, functions, branches and lines in source and tests, write the numbers where the Sabbath will read them
ThuDay 5 · Swarms; be fruitful and multiplyHarden/security-review, coverage, dependenciesbring the repo to 100 % coverage first (table stakes, end-to-end style), then close security findings and land dependency updates with their tests
FriDay 6 · Image; the work finished, very goodShip/gigareviewGO/NO-GO on the week's branches, merge the GOs, patch releases, stele check across the grove, CI green before sundown
SatDay 7 · SabbathRestthe self-passno code commits: score the week from the trail, dream-pass, /tighten the machine's own prompts, rubrics and tests to their fewest lines, plan next week's targets, write the voyage entry; every seventh week, the ablation

The ruach — research as its own engine

Not a block inside shifts: a second loop with its own trail kinds, score and Sabbath, coupled to the shift engine through the breath store.

Sources ──▶ Read ──▶ Judge ──▶ File ──▶ Spike ──▶ Reweight ─┐
   ▲ (weighted)  (opus,     (Kev:    (breath,  (a shift   (adoption │
   │              budget)   bearing, provenance tries one)  raises a │
   │                        novelty)  state)                source)  │
   └────────────────────────────────────────────────────────────────┘
        ▲ open problems: plan next, findings, threads   │ breaths, filed against a repo
        └──────────────── the shift engine ◀────────────┘

Repo rotation

Scope is every root under ~/.grove/code/jgeschwendt/. The three home repos (~, ~/.routines, ~/.sandman) are out of the rotation: home-sync commits them without trailers every fifteen minutes, and a shift there could edit the trail or the live runner; reconcile still scans them. sandman shift pick orders repos by least-recently-shifted, then weights by live signals: CI red, stale graph.lock, open findings, open threads, breaths filed against it. Only trunks Claude Code trusts are picked — claude --bg refuses any other cwd — and sandman shift pick --untrusted lists the rest for the owner (BANK-FORMAT § shifts, Trust). Invariant: every repo is shifted at least once a week; Wednesday's score reports coverage and a miss is a finding.

Standing rails

Phases

Chiastic: the trail is the core and was built first; each outer layer is rebuilt against what the core required. Opus agents implement; the session model decomposes, gates and reviews.

0 · Housekeeping — done

Retired drawing tools stripped (design/*.tldr, docs/arch.tldr, the plan task, the lefthook tldraft check gate); docs/PLAN.md became docs/plan.md, since folded into this page; lock rebuilt.

1 · Trail — done (d33d0aa, e9e906b, e604573)

sandman trail append|log|show|revoke|today|trailers, sandman reconcile <event-id> [--dry-run], src/grove.rs (checkout discovery, trailer reading, env-scrubbed git), BANK-FORMAT § trail. 16 integration tests in tests/trail.rs including the plan's acceptance case. Reconcile has run only against fixtures; its first live run is a Sunday finding to watch. An adversarial review hardened it the same day: the reconcile branch starts from the repo's trunk and hits trunk does not reach are reported off trunk, merges are never reverted — the machine merges by rebase only, so a trailered merge refuses its repo for the operator (a third review broke two merge-side heuristics) — a repo with no trunk to name, or a manifest trunk that does not resolve, fails rather than falling back, reverts skip the repo's commit hooks, the scratch worktree is force-removed on every path, and a conflict or failure in any repo exits 1 after all are reported. revoke and reconcile take a shift id too — every event of the shift and its descendants, every commit whose Shift: names it — and the brief names its own shift.start as {{shift.start_event}}, the --parent of every append the shift makes.

2 · Shift — built

sandman shift attach|brief|clock|handoff|pick|profile|status; the contract is BANK-FORMAT § shifts. The rotation covers $SANDMAN_SHIFT_OWNERS (default jgeschwendt) rather than every grove owner — the grove also holds client repos, and touching other people is a thread, not a shift — never the home repos (§ Repo rotation), and weights ties by the cheap signals only (a failing stele check, open threads, breaths); CI red and open findings join when score (phase 5) records them. attach exists because dispatch mints the session id only after brief has written current.json. The adversarial review's brief fixes: every Context slot is fenced as data under a source label and the template is filled in one pass, so nothing a handoff or thread says is read as an instruction or a slot; over the cap the slots give in a fixed order down to their floors (the previous handoff to 1,500 characters) and the template's own text is never cut — a brief that still will not fit is refused; shift handoff caps a handoff at 2,000 characters (--truncate to cut); shift clock exits 3 on an unreadable window file; an open thread counts for a repo only when it names the slug as a whole token. The runner stays paused until the operator removes PAUSE and installs a binary carrying shift.

  • sandman shift brief composes the shift prompt — the dynamic prompt itself — from prompts/shift.md (a stele node the Sabbath edits): the charter, the day's theme and creation note, the previous handoff, the trail's last day, shift pick's target, the bearing memories for that repo, its docs/plan.md "next", open threads, breaths filed against it, this week's score. Budgeted; every included item's fingerprint is logged in the shift.start event so Wednesday can attribute.
  • sandman shift handoff writes ~/.sandman/shifts/<id>/handoff.md and the shift.handoff event; --abandoned records a shift the runner found dead.
  • sandman shift clock reads ~/.local/state/claude/window.json — resets_at and used_percentage persisted by the harness statusline (claude 5a52667) and by the runner when a limit error names the reset — and answers how long the window has left; --check exits 1 when under SANDMAN_SHIFT_MIN_WINDOW_MIN (default 60) minutes remain — handoff is fifteen before the reset, so a shift always has 45 minutes of work — and 3 when the file is not a readable window.
  • sandman shift pick [--path] applies the rotation.
  • ~/.routines/shift.md is the runner: every five minutes, close a finished shift (handoff, release the lock) and, if the window has room and ~/.sandman/shifts/PAUSE is absent, open one through dispatch run ({"verb":"claude","action":"start",…,"model":$SANDMAN_SHIFT_MIND,"permission_mode":"dontAsk","settings":<shift profile>}) with cwd at the picked worktree. The spine table is the routine's prose.
  • The problems file for the ruach is written here, by shift brief, from what it already gathers.
3 · Mod
  • A Claude Code mod — a plugin whose hooks run inside Claude Code (docs: code.claude.com/docs/en/plugins/mods; terminal 2.1.287+; hooks also run under claude -p, so every shift carries it, though only interactive sessions draw) — ships in the harness repo as plugins/perpetual/.
  • Hooks: tool.call guards the rules with mechanical form (pkill -f; git checkout --, reset, stash, clean as diagnostics; force pushes; .yml; an Agent or Workflow call with no model pin) by denying the call with the rule quoted, failing closed, and appends a guard event; prompt.submit captures live corrections ("no,", "don't", "never", "stop", "i said", a repeated instruction) as correction events — the energy source recorded at the moment instead of mined from the archive later.
  • Band above the prompt: shift id, day and theme, target repo, window clock, last trail event, read from ~/.sandman/shifts/current.json and today's trail file on a timer. Commands /shift and /trail, answered at once without a Claude turn, output to the display log only.
  • Guards generated from promoted lessons (phase 6) land here: a lesson violated after two injections proposes its guard; the guard replaces the prose and frees budget.
  • The permission classifier refused to delegate this phase twice (the guard half reads as an agent that overrides tool permissions). It is built by the operator's hand or with his permission rule; recorded here so the Sabbath does not retry blindly.
4 · Ruach — verbs built

sandman breathe init|sources|file|list|spike|adopt|drop|pass|attach|close|reweight and prompts/breathe.md; the contract is BANK-FORMAT § breath, the seed feed list breath/sources.toml. The verb is a store and a prompt composer — the reading, judging and filing happen in the session the runner starts, which calls breathe file with the judge's scores. A pass record is passes/<id>/{pass.json,prompt.md}, beside its prompt as a shift's brief sits beside its meta; pass refuses when no source is due or the day's budget is spent, so a runner tick starts nothing then. reweight remembers its last run as the root key reweighted and counts adoptions after it; silence is measured from the first read or the last adoption and restarts after each penalty, so a source loses 0.1 per silent month. The shift brief carries spiked breaths beside new ones. The runners and the ruach's score lines and Sabbath are next.

As described above: sources.toml, problems.md, sandman breathe file|list|spike|adopt|drop|reweight, the hourly and daily runner, the ruach's own score lines (breaths per source, adoption rate, idea-to-merge latency) and its Sabbath. /gigaresearch keeps its claim ledger in ~/.sandman/breath/ledger/.

5 · Score — built, minimal

sandman score [--week yyyy-Www] [--json]; the contract is BANK-FORMAT § score. It reads only what exists — the trail, shifts/*/meta.json, the breath store, ~/.docket, the shift and breathe-* runs.jsonl — and writes ~/.sandman/score/<week>.md, the worst three first so a weekday brief's 20-line score.week carries them; the Sabbath brief reads the whole page. Built: repo coverage against the spine's every-repo-once-a-week, shift outcomes and mean length, events by kind, repo and model, cost summed and the events carrying none, correction recurrence (title-token Jaccard against earlier corrections this week or last), context freshness (a Context slot's fingerprint seen in an earlier shift this week is stale context), breath movement and per-source counts, open threads and those shifts opened, routine ticks and failures.

Deferred: promotion precision (nothing emits promote or decay yet), recurrence per lesson rather than per correction (needs recall's attribution, phase 6), bearing measured by outcome rather than by repetition, cost per merged change and anything else from git, CI red and open findings as rotation signals, and a Wednesday runner calling score — today a Measure shift or the Sabbath runs it by hand.

6 · Compile
  • recall becomes the compiler: candidates from the bank chain and the whole grove, ranked by bearing (dowse, merged in from typesafe-integration; Kev when no TypeSafe key), tiered budget, attribution logged to the trail.
  • Reflect gains titer math: probation → active on two confirming sessions; active → index on thirty unused days; index → _archive on ninety; contradiction opens a thread.
7 · Recurse — the Sabbath built, minimal

Built: on a New York Saturday shift brief fills prompts/sabbath.md instead of shift.md, and shift pick returns sandman's trunk — the machine's own repo, where sabbath/<week> lives — rotation aside, while the rotation's last-shifted bookkeeping and the score's coverage ignore Saturday shifts. The Sabbath brief carries the week whole: the score page, the week's trail and corrections, breath movement, every shift's outcome, and the targets last Sabbath set (sabbath.previous); on the Shemitah (ISO week divisible by seven) it says to run ~/.sandman/ablation.prompt.md instead of the self-pass. Its shift.start carries theme:Rest (every start carries its theme). The session writes score/<next week>-targets.md, a ## <owner/repo> line each, and a weekday brief falls back to the target's line when its own docs/plan.md says nothing, the fence label naming which source it used. The contract is BANK-FORMAT § shifts.

Deferred: promote and decay are trail kinds nothing emits yet — the Sabbath prompt proposes them by hand; the prompts are not yet stele nodes with claims the Sabbath scores; the ablation prompt itself; the ruach's own Sabbath (rewriting sources.toml and breathe.md from its score); a runner that lands sabbath/<week> on green without a session's judgement.

8 · Cloud — built, parked; the machine's home is a Mac mini

Nightly shifts move from claude --bg on the Mac to the Claude Code routine perpetual-shift (cloud/README.md): an hourly fire, 21:07–06:07 New York, on a fresh VM holding the twenty rotation repos, the state repo and the docket home as clones. sandman shift open --cloud pulls the state clone, takes its committed current.json as the lock (busy under seventy minutes, abandoned after), picks among the clones — approved proposals first — composes the brief with handoff_by fifty minutes out, commits and pushes; shift handoff --cloud commits and pushes the record. The session lands on each default branch and pushes itself (prompts/cloud-shift.md) — the one path that pushes a trunk, parked with it; cloud/setup.sh installs mise, sandman, docket and stele. The contract is BANK-FORMAT § shifts, The cloud. Saturday's night runs the Sabbath on the same routine.

Parked 2026-10-12: a cloud shift reads the memories but never feeds take → dream → reflect, and hooks, MCP and Kev are Mac-only, so cloud mode stays as an overflow runner (environment Motion exists; no routine). The machine's home becomes a dedicated Mac mini (ordered): the whole machine as built, sandbox off or relaxed, its own deploy keys, dream and reflect duties there, the laptop only takes. Until it arrives the laptop runs the nights with the owner watching. home-sync already rebases before it pushes. Cloud landings would be pull requests too, and the session proxy refuses gh pr, so cloud mode stays parked until that is solved.

Not in this change

Cloud routines beyond the shift (ingress stays as it is); bridge pages for the trail, the breaths and the score (bridge gets them in its own shift); replacing the voyage log (the log stays the human-facing narrative, the trail the machine-facing ledger); the Jubilee.

Acceptance

Phase 1 (met): a fixture repo receives two machine commits under two events; revoking the first reverts both, the trail shows the reconcile event, and the second event's memory is listed for retirement. Phase 2: one live shift opens with a brief, works a picked repo on a shift/ branch, writes a handoff, and every commit it made resolves to the trail by trailer. Phase 4: a breath filed from a real feed reaches a shift brief for the repo it bears on.

Invariants

Non-goals