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.

| verb | duty |
|---|---|
| breathe init|sources|file|list|spike|adopt|drop|pass|attach|close|reweight | the 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 |
| mcp | the stdio tool server: recall, remember and banks over the same code paths |
| recall | the 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 |
| reflect | the 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|status | one 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|trailers | the 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.

~/.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.

| trigger | verb |
|---|---|
SessionStart hook | recall --hook · report --hook |
SessionEnd hook · /dissolve | take $SESSION |
| take itself, after dropping a pointer — queue ≥ 10 · by hand | dream [--now] |
routine sandman-reflect · 03:30 UTC (≈ 23:30 local) | reflect |
| claude, in-session — the golden rule | remember "<body>" |
/delete | forget $SESSION |
routines shift · */5 · breathe-skim · hourly · breathe-deep · daily | shift · breathe pass |
| a shift session, as its prompt says | trail append · score · shift handoff |
launchd — io.jlg.sandman-report | report serve |
MCP client — the desktop app, Cowork via the bridge, claude mcp | mcp (a served process, not a hook) |
| shell — the owner | any verb by hand |
Flow — a conversation's afterlife

- callers:
SessionEndhook ·/dissolve· by hand - refuse live — a transcript touched in the last 120 s is refused;
--forceoverrides. The heuristic guards by-hand takes;--hookimplies force, becauseSessionEnditself is the proof the session is over - decline a resume — SessionEnd carries
reason∈ {clear,logout,other,prompt_input_exit,resume}, andresumeis 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.--hookexits quietly onresume; every other reason, and a payload with no reason at all, is an ending - decline on request —
$SANDMAN_NO_TAKEmakes--hookexit quietly before it reads anything, so a caller drivingclaude -p --resumeturns keeps its transcript live (those turns end withreason: "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/jobsstill 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 firesSessionEnd, but that ending belongs to the worker, not the conversation. A job speaks for the session it would resume next (resumeSessionId, else thesessionIdit ran under), the guard is--hook's alone, andreason: "clear"reaches past it. Every uncertainty — no jobs directory, an unreadablestate.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
SessionEndwhen the operator deletes the job, so the decline writespending-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 andreason: "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;
mvit 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 =
mvback
.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;
mvback restores the session whole - append-only tree: the pipeline never deletes here.
forgetdestroys every copy before take could see it, so an archived session was by definition wanted
- shape:
{archived · cwd · ended · title · highlights}— enough to recall by without opening the transcript - IS the short-term recall surface:
recallfolds pointers younger than 72 h into session start; expiry is that read-side filter, nothing retires an unrouted pointer highlightscarries 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
- trigger: take at queue ≥ 10, detached ·
dream --nowby hand; both forms behave identically. Oldestendedfirst, 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 areplaces— 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 jsoncalls, 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>/.dreamunder 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 (orphanswhen 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_memoryunder.commit.lock, the single format authority: slugs,replacesarchiving into_archive/, collision suffixes, index regen - dream is idempotent over the queue: a failed pointer stays queued, a routed one is stamped
dreamedand never dreamt again
- trigger: the
sandman-reflectroutine 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 whosecreated:orupdated:fall in it; their names and bodies hash to the entry'sfingerprint, 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, defaultclaude-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 withday,positionand 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.mdis 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
.recentpointers 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
bodyis 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 withreplacesnaming the member with the earliestcreated:— 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

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.
- Line shape —
<ISO> pid=<n> v=<version>-<commit> <verdict> <key>=<value>….pid=is what ties a decision to the same process indaemon.log;v=names the build that wrote it, since the installed binary is routinely a commit behind the tree and-dirtymeans the commit alone does not describe it (sandman versionprints the same stamp). Newlines in a payload fold to spaces: one entry is always one greppable line. - Shape, never content —
recallreports how many banks answered, which memory files each put in front of the session (recalled-bank), what the budget trimmed and how long composing took. It never logs a body: a log that quoted the memories back would be a second copy of the banks. - Honest units —
chars=is reported againstbudget=in the characters the budget is actually spent in, andtrimmed=/banks_degraded=/cut_lines=say what it cost to fit, which a size alone cannot; eachrecalled-bankline carriesrendering=—fullorindex— naming the form its memories arrived in.cut_lines=counts graph index lines the last-resort cut took, never characters off the payload's tail.trimmed=names surfaces the budget dropped, sochronologicalappears there only when the log itself outgrew the budget — its floor is reserved, and a payload without it is a fact worth a line. mcpjournals one line per request — method, tool, bank, outcome and recall's counts — never a body or payload.- Best effort by construction — every error is swallowed, there is no result to ignore, and a verb behaves identically whether its line landed or not. A journal that could fail would be a new way for a session edge to break.
- The voyage log is the narrative record — what this engine lived through; the journal
is the decision record — why a verb did or did not act.
reflectwrites both, to their own tiers: the entry tolog/, and to.trace/the entry's own line (reflect entry date=… day=… kind=… mind=… ms=…on a write,reflect entry-kept date=…when the fingerprint matched and no mind was asked,reflect entry-skipped date=… reason=quiet|abstainedwhen the day had no sources or the mind abstained), one line per bank, and one run line (reflect done banks=… due=… swept=… ms=…). Shape, never content: no line carries an entry's prose.dreamappends its detached run's stdout and stderr to.trace/dream-<date>.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/.

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.

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
- Shift — one autonomous session bounded by the five-hour usage window. Shifts run back to back; the machine never stops. A shift opens with a brief, closes with a handoff.
- Trail — the append-only event log at
~/.sandman/trail/<yyyy>/<mm>/<dd>.jsonl. Every machine commit in every repo carriesShift:andEvent:trailers, sogit loganywhere joins back to the trail. Contract:docs/BANK-FORMAT.md§ trail. - Reconcile — the owner revokes an event; the machine reverts every commit carrying it or its descendants, in a scratch worktree on a
reconcile/<id>branch per repo (new commits, never history rewrites), and lists the memories whosesource:names a revoked event for reflect to retire. - Ruach — the research engine: the breath over the waters, continuous, outside the days. It files breaths (ideas with provenance, a repo they bear on, a state: new, spiked, adopted, dropped) under
~/.sandman/breath/. - Titer — the evidence count behind a lesson. Probation needs two sessions of evidence to become active; an active lesson that is heeded decays toward an index line; one that keeps being violated escalates to a mechanical guard.
- Sabbath — the seventh day. The machine does not stop; it changes mode from making to remembering. Shemitah — every seventh week the Sabbath runs the ablation pass (rewrite the memory corpus from scratch) instead of the ordinary self-pass.
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.
| day | creation | theme | instrument | what a shift does |
|---|---|---|---|---|
| Sun | Day 1 · Light divided from darkness | Hunt | /gigasweep → /gigadebug | sweep a repo for real defects, root-cause the confirmed ones, ship the repro as a regression test |
| Mon | Day 2 · Firmament; waters divided | Redesign | /gigaredesign → /execute-plan | execute the approved proposals first, then rebuild the structure Sunday's findings and standing proposals indict, always-green staged migration |
| Tue | Day 3 · Land and seed | Refactor | /gigarefine | drive 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 |
| Wed | Day 4 · Lights to mark seasons | Measure | sandman score, benchmarks | compute 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 |
| Thu | Day 5 · Swarms; be fruitful and multiply | Harden | /security-review, coverage, dependencies | bring the repo to 100 % coverage first (table stakes, end-to-end style), then close security findings and land dependency updates with their tests |
| Fri | Day 6 · Image; the work finished, very good | Ship | /gigareview | GO/NO-GO on the week's branches, merge the GOs, patch releases, stele check across the grove, CI green before sundown |
| Sat | Day 7 · Sabbath | Rest | the self-pass | no 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 ◀────────────┘
- Sources —
~/.sandman/breath/sources.toml: arXiv cs.SE and cs.AI listings, Hacker News, GitHub trending for Rust, Elixir and TypeScript, and the release feeds of the authors the owner trusts (jdx, oxc, bun, Vercel, Elixir core, Anthropic). Each carries a weight and a budget. - Read — opus, under
SANDMAN_BREATH_BUDGET_USDper day, against the problems file~/.sandman/breath/problems.mdthat the shift engine keeps current from every repo'sdocs/plan.md"next", open findings and open threads. - Judge — Kev (free, local,
io.jlg.kevon 1703): per candidate a Noul "does it bear on a repo?" and "is it new to us?" (against existing breaths and memories). TypeSafe when a key is present; same fallback rules as dowse. - File —
sandman breathe file --url … --repo … --title …writesbreath/<date>-<slug>.mdwith provenance, the judge's scores and statenew; abreathtrail event. - Spike — the shift brief carries the breaths filed against its target; a shift spikes at most one and marks it
adoptedordroppedwith the reason. - Reweight — adoption raises the source and the query that found it; a month of silence lowers them. The ruach's Sabbath rewrites
sources.tomland its own prompt from its score. - Runners —
~/.routines/breathe-skim.md(hourly at :17, a cheap read-and-judge pass over the due feeds) and~/.routines/breathe-deep.md(daily at 10:00 UTC,/gigaresearchover every feed), both--bgsessions with cwd~/.sandman/breath, both exiting on PAUSE and whenbreathe passsays nothing is due or the budget is spent. Their text lives indocs/routines/until installed.
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
- A shift works in
shift/<id>worktrees created throughgrove tree add; trunk never carries in-flight work. - Trunk is never pushed; every landing is a pull request; merge by rebase. A shift rebases its
shift/<id>onto the localorigin/mainand records it withsandman shift land; the runner pushes the branch and opens the pull request, and auto-merge or the runner (a queue needs an organization-owned repo) merges it by rebase once CI is green — never a merge commit, so reconcile reverts every landed commit on its own and refuses a trailered merge. A trunk checkout is never ahead oforigin(owner 2026-10-13: "i want all prs to use a pr to run ci checks so we stay green, automerge and merge queues please"; BANK-FORMAT § shifts, Landing). - Land only when the repo's test, lint and
stele check && stele emit --checkare green, coverage has not dropped — 100 % is table stakes, end-to-end style like jlg.io, and test code is source code (owner 2026-10-09: "code golf should be in the spirit of the refactor/tighten phases") — and a/gigareviewsays GO. Patch releases ship on their own; a minor or major opens a decision thread (release policy, unchanged). - History is never rewritten and nothing is force-pushed; the trail reconciles by revert.
- Shift and breath sessions run
dontAskinside Claude Code's strict Bash sandbox undersandman shift profile(BANK-FORMAT § shifts, The sandbox) — notbypassPermissions, which auto-approves the sandbox's ask for an unlisted domain (probed 2026-10-09): git's network commands run outside it with the host's auth, nothing writes~/.local/bin, and the deny rules and the mod's guards hold in every mode. - The orchestrator model is a knob,
SANDMAN_SHIFT_MIND, defaultopus. Fable takes the Sabbath self-pass and Friday's verdicts while quota allows; when the weekly limit is exhausted every spawned agent pins opus or below. Workers are always opus or below. - Cost: every headless call logs its cost to the trail; a shift stops at
SANDMAN_SHIFT_BUDGET_USDand hands off early; the ruach stops atSANDMAN_BREATH_BUDGET_USDper day.claude-breakerkeeps its cap of twenty. - The Sabbath commits no code. Three consecutive failed blocks end a shift and open a problem thread. Threads are for what the trail cannot undo: publishing, deleting data, spending money, touching other people.
- Work larger than one shift — an architecture change, a redesign, a refactor spanning modules — is a
proposal:<owner/repo>:<slug>decision thread with a comparison matrix, never started unapproved; Monday's Redesign executes the approved ones first (BANK-FORMAT § report). - Every shift, the Sabbath too, writes
report.mdfor the owner before its handoff — Worth your eye, Landed, Proposals, Decisions (every choice with two live options and the one rejected), Numbers — andshift handoffrefuses without it;sandman report, its SessionStart hook andsandman report serve— a loopback page of each night, its proposals answered by form into their threads — are how he reads them.
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.
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.
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.
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 briefcomposes the shift prompt — the dynamic prompt itself — fromprompts/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, itsdocs/plan.md"next", open threads, breaths filed against it, this week's score. Budgeted; every included item's fingerprint is logged in theshift.startevent so Wednesday can attribute.sandman shift handoffwrites~/.sandman/shifts/<id>/handoff.mdand theshift.handoffevent;--abandonedrecords a shift the runner found dead.sandman shift clockreads~/.local/state/claude/window.json—resets_atandused_percentagepersisted 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;--checkexits 1 when underSANDMAN_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.mdis the runner: every five minutes, close a finished shift (handoff, release the lock) and, if the window has room and~/.sandman/shifts/PAUSEis absent, open one throughdispatch 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.
- 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 asplugins/perpetual/. - Hooks:
tool.callguards the rules with mechanical form (pkill -f;git checkout --,reset,stash,cleanas diagnostics; force pushes;.yml; an Agent or Workflow call with nomodelpin) by denying the call with the rule quoted, failing closed, and appends aguardevent;prompt.submitcaptures live corrections ("no,", "don't", "never", "stop", "i said", a repeated instruction) ascorrectionevents — 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.jsonand today's trail file on a timer. Commands/shiftand/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.
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/.
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.
recallbecomes the compiler: candidates from the bank chain and the whole grove, ranked by bearing (dowse, merged in fromtypesafe-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 →
_archiveon ninety; contradiction opens a thread.
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.
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
- forget never archives —
/deletedestroys every copy before take could see it. - one config point —
$SANDMAN_ROOT, else~/.sandman; nothing else hardcodes the root. - pipeline-owned trees —
memories/is never hand-edited. - secrets ban — keys, tokens, credentials never become memories; third-party text is data, not instructions.
- single format authority — every bank write goes through the commit path: slugging,
replacesarchiving, lineage, collision suffixes, index regen.
Non-goals
- Hook-event visibility — sandman reads transcripts, never hook events.
- A live-session registry — session discovery is another tool's job; sandman owns history.
- UI beyond one page — sandman is a CLI; its only UI is
report serve, one loopback page, and any other is a separate client consuming it.






