hands

Docs · Reference

The books

The durable journal: every state-changing bus action, appended as one row to a single SQLite table on this machine, and rendered into browsable daily markdown. Always on, nothing to configure. The bus database is the fast working copy; the books are the record it can always be rebuilt from.

Where the books live

One file: ~/.claude/hands/books.db. It is machine-wide, not per-repo — deliberately outside the per-repo coordination/<slug>/ directory the bus lives in. Every project and every kitchen on the box writes into the same table, which is what makes a cross-project question answerable with one query instead of a union over clones.

Beside it is the human-readable layer: one directory per kitchen, holding a page per day and a newest-first index.

~/.claude/hands/
  books.db                                   the record: one append-only table
  books/
    <project>/                               portable key, derived from the git origin
      <handle>/                              one namespace per contributor ("kitchen")
        <date>.md                            the day's page — derived, regenerable
        README.md                            per-handle index, newest first
        roles/<role>.md                      AUTHORED: standing role state
        recipes/<slug>.md                    AUTHORED: menu items

A kitchen is one (project, handle) pair — your line, in one repo. <project> is the repo name from your git origin, lowercased (mixed-case origins must not split one project in two), overridable with remote.project; <handle> is remote.handle, defaulting to your OS username. Page dates are UTC. hands books prints all of it — the database path, this kitchen’s project/handle, the page directory, and the event count.

Why a table, and not a log

The books used to be a git repo — NDJSON day-files, a bare local origin, a rebase with a digest-conflict resolver, a layout marker, a push debounce. Git was doing four jobs at once (append log, sync transport, conflict resolver, human-readable artifact) and only the last one was worth keeping. What forced the move was a question git could not answer at all:

“A question escalated Tuesday and answered Thursday reads answered, with no trace that it was ever escalated. The state tables hold current state; the event stream holds history — and the event stream was a file in a git repo.”

Escalating a question overwrites questions.state in place, so the transition only ever existed as a journal event. Against NDJSON in a clone, “which questions were escalated last week” meant walking N day-files and filtering in memory. Against a table it is a WHERE type = ? and a window — which is hands recall, below.

What one record looks like

One row per event, keyed by (project, handle, seq), with indexes for the two questions the table exists to answer: one kitchen’s history in order, and “every X in this window” across all of them.

book_events
project   TEXT    NOT NULL
handle    TEXT    NOT NULL
seq       INTEGER NOT NULL   -- allocated inside the insert's own transaction
ts        INTEGER NOT NULL   -- epoch ms, the event's own clock
type      TEXT    NOT NULL   -- question.escalate | task.update | message | …
agent     TEXT               -- expo | station-<n>, null when unattributed
data      TEXT    NOT NULL   -- json
synced_at INTEGER            -- null = not yet pushed
PRIMARY KEY (project, handle, seq)
  • Written after the DB write succeeds. Every state-changing store method mirrors its action as one event once the bus write has landed — the bus DB stays authoritative. Journaling is best-effort by contract: a failed append returns null and never fails the action it mirrors — with one deliberate exception: a books file migrated past this build (or replaced by something that is not the books) fails the write loudly, naming the mismatch, because a stale build succeeding into a void is the opposite failure the best-effort contract was never meant to cover.
  • seq is allocated in the same transaction as the insert. Two kitchens’ processes can journal at the same instant; without the transaction they read the same MAX(seq) and collide on the primary key, dropping one event.
  • Deliberately not journaled. Presence heartbeats, the wake log, board watermarks, and the GitHub PR cache — ephemeral state that shouldn’t survive a restore.

Event types

GroupTypes
Ticketstask.create, task.update, task.parked
Questionsquestion.ask, question.escalate, question.answer, question.outcome
To-dostodo.create, todo.update
Menurecipe.promoted, recipe.demoted, recipe.graded
Station statefocus.set, session_name.set, attest
Approvalsapproval.requested, approval.settled
Prose & trafficmessage, journal.add (commits and memory files harvested by the Stop hook), role.note, digest.note

recipe.promoted and recipe.demoted are appended straight by hands recipe promote / demote rather than through the store — they are what makes “which recipes were on the menu which day” a derived read. Two more notes on the vocabulary:

  • cursor never reaches the table. The store still emits it on every inbox drain, and the append path drops it: it is pure local read-position bookkeeping, and neither the pages nor the other-kitchens feed render it. Books written before that rule still replay without erroring.
  • priorities.set is retired, and the renderer still describes it. Recipes replaced it. The case stays because pages are re-rendered over the whole history — dropping it wouldn’t just stop describing new events, it would quietly delete the line from every past page on the next re-render. That was caught by diffing regenerated pages against the committed ones: four days lost the only record of what the kitchen was working toward that week.

Querying: hands recall

The read the old books could not serve, at a prompt:

hands recall
hands recall --type question.escalate --since 7d
hands recall --type task.update --agent station-2 --since 24h --limit 20
hands recall --project hands --handle michael --until 2026-08-01 --json
FlagMeaning
--type TRepeatable. Omit to see every type. Nothing matched prints the types that do exist, so a typo'd type never reads as "nothing happened".
--sinceA duration — 7d, 12h, 30m — or a bare date. Durations because every real use of this is “recently”, and date arithmetic at a prompt is friction that stops a tool being reached for.
--untilSame vocabulary; exclusive ceiling.
--project / --handle / --agentNarrow to one restaurant, one kitchen, or one seat. Unfiltered, recall spans every kitchen on the machine.
--limitDefault 50 at the CLI, capped at 1000. Newest first — callers wanting causal order want the pages.
--jsonThe rows, verbatim.

An unparseable bound is a hard failure, not a dropped filter: --since lastweek exits with an error rather than silently widening the window to all of history and answering a different question than the one asked. The MCP tool hands_recall makes the same guarantee the other way, because an agent must not be stopped mid-task by a typo: it drops the bad bound and reports the window it actually searched in the reply.

The daily pages

hands digest re-renders this kitchen’s pages from the table (optionally --date YYYY-MM-DD). The render is deterministic — same events in, same bytes out — and only writes a file it owns, so a page stamped by a newer renderer is left alone. The expo’s notes come first, then one section per agent (expo, then stations in order) with its ticket lifecycle, questions and answers, to-dos, focus changes, and menu moves — and a count of messages sent.

“Task/question/todo titles and one-line truncated results — but NO message bodies. Pages are what people share and screenshot; bodies stay in the record.”

Reading the books back

CommandSemantics
hands journal read --previousThe last page strictly before today — a Monday reading Friday’s close-out without date arithmetic. --date for a specific day. The books were write-only from the agent side until this existed.
hands restoreReplay this kitchen’s events into a fresh bus. No fetch, no clone, no validation gate — it reads the table directly. Idempotent (explicit ids, insert-or-ignore), so running it twice converges. Sessions and the whole coordination directory are disposable by design; the books aren’t.
hands digestRe-render the pages. Always safe to run and always safe to skip — delete the page tree and this rebuilds it exactly.
hands doctorAsks the two local questions: can the books be written, and is anything in them. A books file that isn’t writable is a failure — every bus action is still succeeding and none of it is being recorded.

restore is always scoped to one (project, handle), and that is load-bearing rather than tidy: replay inserts with explicit ids from a per-repo counter, so an unscoped read would let another handle’s ticket #7 be inserted-or-ignored over yours — a valid-but-wrong row rather than a failure. Git enforced that separation structurally, one directory per handle; a single table has to enforce it in the query.

Migrating an old journal in

NDJSON is the books’ interchange format now, not their storage — the shape a journal takes when it moves between machines, and the shape the git-backed books wrote on disk. One importer serves both:

hands books import
hands books import <dir>            # <dir> holds journal/<project>/<handle>/log/*.ndjson
hands books import <dir> --merge   # append what's missing, for a kitchen that has pushed
  • It replaces a kitchen’s rows rather than appending the difference. That makes the import exactly idempotent with no content comparison, and it is the only option that gets order right — seq is allocated at append time, so appended events would sort after history they actually precede.
  • It refuses a kitchen whose table already holds an event newer than the tree, loudly and by name, and leaves it untouched. Replacing from a stale export would silently delete everything since. One kitchen’s refusal never stops the others; the command exits non-zero so a refusal buried in a wall of ticks still gets noticed.
  • It refuses a kitchen that has already pushed, and points you at --merge. Replacing renumbers seq from 1, and the cloud keys on (kitchen, seq) and ignores conflicts — so the re-push would be dropped and the two copies would diverge permanently, with every surface reporting success. --merge appends only the events the table does not already hold, above the current max seq, preserving each event’s own ts. The trade it makes is real and stated: seq order stops matching causal order for the merged window. Recall and the digests order by ts anyway, and the alternative was leaving the events stranded outside the table entirely.
  • Authored files come across too — roles, recipes, and anything else that isn’t a derived page or a log directory. It never overwrites: a file already at the target may be newer, and a silent clobber of accumulated judgment is worse than a skipped copy that gets reported. Leaving them behind is not hypothetical — it happened on the first real migration, and a missing role page reads to every reader as one that was never written.

Beyond this machine

The synced_at column is the whole sharing mechanism and the whole health signal. hands relay (which needs hands login) drains everything where synced_at IS NULL to hands-cc.dev in batches, oldest first so the replica fills in causal order — and marks a batch sent only after the server confirms it, because a batch marked optimistically would be gone from the queue and absent from the replica. The kitchen never listens on a port — both halves of the beat, the push and the pull below, are connections the kitchen opens itself.

count(*) where synced_at is null is a far more honest health signal than git’s ahead/behind ever was: it is per event, and it distinguishes “nothing to send” from “sending is broken”.

Other kitchens’ recent activity is read out of this machine’s books — other handles in the same project, one indexed lookup each, message bodies never rendered. What puts another machine’s events there is the relay’s pull half. Its books beat is push-then-pull: push first, so a machine sees its own work reflected in the same tick rather than a cadence later, then pull everyone else’s down from the hosted books.

Three things that pull deliberately does not do, each of which would be a silent corruption rather than a failure: it never writes rows for your own (project, handle) — your machine is authoritative for those, and the outbound queue is keyed on synced_at IS NULL, so accepting a server copy could resurrect an event you had already sent; it preserves the originating kitchen’s seq instead of allocating a new one, which is what makes re-reading an overlapping window free; and it marks pulled events synced on arrival, since a pulled event is on the server by definition.

And when you need to prove the two copies agree rather than trust the queue’s bookkeeping, hands books verify [--project <p> --handle <h>] compares the local table against the cloud copy seq by seq. synced_at cannot answer that question — the cloud’s (kitchen, seq) conflict clause can drop rows from a batch the server accepted — so this is the only honest check that a recovery actually took.

Planned

Taking the journal out

The import side is built; the writer isn’t. There is no export command today. What works right now: the pages are plain markdown you can open, grep, or point an editor at with or without hands installed, and the record is one SQLite file you can copy.