spine

README

The task and session ledger that agent sessions file into: who is working, on what, since when, holding which files, and what actually happened. One SQLite file, an MCP server over stdio that reads and writes it, and three command-line tools: a backup, a sweep, and a board of who is working on what.

Status: private, pre-1.0. THIS repository is the one live sessions talk to, since 2026-09-06. Extracted from substrate/spine on 2026-09-02 with its history preserved (git subtree split, 25 commits), and cut over on the operator's instruction that the spine leaves substrate entirely: it is an orchestrator, not part of the surface substrate ships.

What "cut over" means concretely, because the sentence this replaces claimed the opposite for four days and was true when written:

reaper net.portll.substrate-spine-reap runs reap.mjs here. Both were repointed only after a real initialize + tools/list + list_plans handshake against the live store answered from this tree — 23 tools, and the plan list came back.

had filed was disturbed by the move and no migration was needed.

this checkout as a sibling. That import previously named substrate's own copy, which was UNTRACKED there — so substrate at HEAD could not load work.mjs in a fresh clone at all. Pointing it here fixed a closure defect as well as a location.

session that started before the cutover**. Measured on this box shortly after: 37 live node substrate/spine/mcp.mjs processes against 3 from here. A registration decides what the NEXT session launches; it does not migrate the ones already running, which hold their server for the life of the session. So the old directory drains rather than switching, and removing it is gated on that drain — not merely on the registrations being repointed. An already-loaded process survives the file being deleted, but a session that reconnects its MCP server afterwards would find nothing there.

The site is spine.commitwork.online. The proxied CNAME spine → spine-d5l.pages.dev was created 2026-09-15 with the Keychain token (service zippr-site-production; the wrangler OAuth token carries zone:read only), after which the Pages custom domain went from deactivated to active. The same bundle stays reachable at spine-d5l.pages.dev.

Zero runtime dependencies: node:sqlite and the standard library. Node 22.5 or later.

What it is

An agent session is a process that appears, edits a shared tree, and vanishes. Nothing in git records that it existed, and the name a harness gives it is per-connection — it changes across a resume and is reassigned later. Spine is the ledger that survives that:

makes it identifiable once its process is gone (the transcript id, the fleet name, the cwd, the IDE window), and each id carrying how it was known.

activation, structured state passed between tasks, a terminal result distinct from an always-mutable notes, and an abandonReason that siblings can read.

collision visible while it is happening; it is not a lock and stops no write.

and writes reaped, never completed or abandoned: the sweep witnessed a process die, not an outcome, and either verdict word would assert one.

It records; it does not act. No tool runs a command, writes a file or touches a repository.

Layout

filewhat
db.mjsstorage and every query; the schema lives here. open(path) returns the handle every other module takes
mcp.mjsthe MCP server: JSON-RPC 2.0 over stdio, newline-delimited, 25 tools at the 2026-09-15 deploy — the site test pins the published count to the source, so read the reference below rather than this cell
identity.mjsresolves the ids for a session at open — transcript, window, cwd — from the process tree and the harness's own presence files, each value tagged with its evidence class
order.mjsthe dependency order of a plan's tasks, pure: takes the rows listTasks() returns and reports what is ready, blocked, dangling or cyclic
board.mjscommand line and export: every live Claude session (spine rows joined to the harness presence files by transcript id, undeclared ones included) with the active tasks each can be shown to hold and the evidence per attribution; --json feeds the megablast-chat Spine tab. Reads only
reap.mjscommand line: what is active, and what a sweep would retire; --apply retires it
snapshot.mjscommand line: a verified VACUUM INTO copy of the store, refused rather than claimed if it reads back wrong
store-path.mjswhich file the store is, and which rule said so; the one resolver every reader here shares
migrate-store.mjscommand line: moves the store to its new home, and refuses while any process still holds the old one
site/the static site, built from this tree by node site/build.mjs
test/node --test 'test/*.test.mjs'

Running it

As an MCP server

node mcp.mjs                             # resolves the store itself
SPINE_TASKS_DB=/somewhere/tasks.db node mcp.mjs

The server speaks MCP on stdin/stdout. It opens (and creates, with its parent directory) whichever store store-path.mjs resolves: SPINE_TASKS_DB, then SUBSTRATE_TASKS_DB (the legacy name, still honoured), then ~/.spine/tasks.db if it exists, then ~/.substrate/tasks.db if it exists, and for a fresh install ~/.spine/tasks.db. Registered with Claude Code it looks like this, and its tools then appear as mcp__spine__<tool>:

claude mcp add --scope user spine -- node /path/to/spine/mcp.mjs

Do not pin the path in the registration while the store is moving. An environment variable naming ~/.substrate/tasks.db after the file has moved does not fail: the server creates an empty store there and reports a world with no work in it. Leaving the variable unset lets resolution follow the file.

One server process per agent session is what stdio gives you, and that is what makes sessions concurrent: owner answers who (the OS user; SUBSTRATE_SESSION_OWNER overrides) and agent answers which worker (this server process; SUBSTRATE_AGENT_ID overrides — set it if your process must keep its identity across a restart). One active session per (owner, agent) is enforced by storage.

The sweeps

node reap.mjs                        # what is active, and what a sweep would take — reads only
node reap.mjs --apply                # close sessions whose holding process is gone
node reap.mjs --apply --idle 86400   # also close anything idle past N seconds
node reap.mjs --close s-… [--force]  # close one named session
node snapshot.mjs [--out <dir>]      # consistent copy of the store, verified by reopening it
node migrate-store.mjs              # the store's move: what would move, and what is in the way
node migrate-store.mjs --apply      # move ~/.substrate/tasks.db to ~/.spine/tasks.db

Nothing writes without --apply. The pid sweep treats only ESRCH as gone — a live process is never reaped, at the cost of missing one whose pid was recycled; the idle sweep is the backstop for exactly that.

Tests

node --test 'test/*.test.mjs'        # quote the glob: an unquoted directory resolves as a MODULE
                                     # and reports "tests 1 / pass 0 / fail 1"

Every test runs against a store in a fresh temporary directory. Nothing reads or writes the real store under either home.

Environment

Every path is env-overridable and read at call time, so tests run entirely on fixtures:

variabledefaultread by
SPINE_TASKS_DBunsetstore-path.mjs, and so every reader — checked first
SUBSTRATE_TASKS_DBunsetthe same, checked second: the legacy name, still honoured
SUBSTRATE_SESSION_OWNERthe OS userdb.mjs
SUBSTRATE_AGENT_IDa-<pid>-<process start>db.mjs
CW_PRESENCE_DIR~/.claude/sessionsidentity.mjs
CW_IDE_LOCK_DIR~/.claude/ideidentity.mjs

identity.mjs reads the IDE lock files for a window's pid and workspace and never reads their authToken into anything it returns — the projection is an allowlist built field by field.

How the MCP tools map to the code

Every tool is dispatched by mcp.mjs to a function in db.mjs of the same shape; the server adds schema validation before dispatch (a missing required field is refused by name, never passed to SQLite) and turns a thrown refusal into a tool error carrying the reason. The published reference, generated by asking the running server for tools/list, is at spine.commitwork.online/tools/.

toolsbacked bynotes
list_plans, create_plan, set_plan_status, archive_plan, archive_plansdb.mjs planslifecycle created → ready → active → complete → reviewed → archived; task writes carry the first two steps, reviewed keeps its review text, archive_plans sweeps by ids or dormant:true and is not a transaction
create_task, get_context, list_tasks, set_status, update_task, synthesize, provision_tasks, timelinedb.mjs tasksids are tree addresses; set_status has a batch form that is not a transaction and says so in its receipt
claim_files, release_files, file_owners, worktree_ownersdb.mjs file_claimsfile_owners reports joined:false unless you pass the dirty set — "the join did not run" is not "nothing is dirty"
open_session, close_session, active_session, assign_session, session_idsdb.mjs sessions + identity.mjsids are resolved at open, when every process in the chain is alive
reap_sessionsdb.mjs sessionsdry by default; the command-line reap.mjs is the same sweep from outside a session
snapshot_overlook, list_overlookdb.mjs overlooka posture reading against a session; refuses an unknown session rather than writing an orphan row

Nine tools declare readOnlyHint: the listers, readers and synthesize. Writers reply with a receipt naming what changed — the assigned id, the resulting status, what a claim took — rather than echoing the row back.

The site

node site/build.mjs                  # → site/dist/  (index, README, MCP tool reference, 404)

Self-contained pages: the stylesheet is inlined (a verbatim copy of the commitwork docsite's), the mark is a data URI, nothing is fetched, and every file opens from file://. No clock reaches the output — each page embeds the sha256 of its source, so two builds of one tree are byte-identical and test/site-build.test.mjs asserts it. The tool reference is generated from a live tools/list against a throwaway store, and the same test checks it against the tools mcp.mjs declares in source: two witnesses that cannot fail the same way.

Deployed to Cloudflare Pages as project spine:

node site/build.mjs && wrangler pages deploy site/dist --project-name spine --branch main

Relationship to substrate

This repository is the source of truth; substrate/spine/ no longer exists. The section this replaces described a mirror awaiting cutover and stayed on the published site until 2026-09-15, nine days after the cutover it described. Measured on that date, on the box that runs the fleet:

path that could still name the old copywhat it names nowhow measured
MCP registration spine in ~/.claude.json<this checkout>/mcp.mjsread the file
live node …/mcp.mjs processes16, all from this checkout, 0 from substrateps
launchd net.portll.substrate-spine-reap<this checkout>/reap.mjsread the plist
substrate/server/api.mjs, server/work.mjs, server/runner/routes.mjs, two runner testsdb.mjs and order.mjs in this checkout, as a siblinggrep, then a live import() from substrate/server
substrate/spine/ directorydeleted in substrate 7ababca (2026-09-07)ls, git log
hooks under ~/.claude and the fleet reposnone name substrate/spinegrep

Substrate consumes this checkout by relative path, so the two must sit side by side under one parent. That is a deployment constraint, stated here so nobody moves one without the other.

Known state at extraction

Re-measure rather than trust this line; it is a reading, not a promise.

sweep writes abandoned; the sweep had been changed to write reaped, and reap.mjs and the reap_sessions description already said so. The test was the last reader of the old word, and substrate's committed HEAD carries the same red test.

untracked files on the extraction box, in no commit of substrate; they are tracked here from the first standalone commit. Nothing in db.mjs or mcp.mjs calls order.mjs; substrate's server/work.mjs does, from here.

purpose: a client cannot tell the old server from this one, so no session had to be restarted.

The store's move, in progress

The store is still the file substrate created, ~/.substrate/tasks.db. Its home is ~/.spine/tasks.db, and every reader resolves both while it moves: store-path.mjs in this repository and store_path() in src/spine.rs, in the same order, which they must keep.

The move itself waits for a drain, and migrate-store.mjs enforces that rather than trusting anyone to remember. SQLite names the write-ahead log after the path a connection was opened with. A session that opened the old path keeps writing through its descriptor after a rename, while the next session to open the new path writes to a log of its own: two logs over one database. So the migrator refuses while any process holds the old store and names every holder; there is no override. Measured 2026-09-16, 24 node processes held it — the agent sessions themselves and their MCP servers. Closing those windows drains it; then:

node migrate-store.mjs           # expect: holders none — the move can run
node migrate-store.mjs --apply   # renames the database, its -wal and its -shm together

The move is a rename inside one filesystem, so it cannot half-happen, and row counts are compared before and after; a mismatch puts everything back. Nothing else needs changing afterwards, because no environment variable pins the path: the MCP registration was cleared of SUBSTRATE_TASKS_DB on 2026-09-16 so that resolution, not a stale variable, decides.

Not built

Spine is intended to become the single place that holds local LLM and environment settings — model rosters and endpoints, per-project environment, and the permissions a session acts under — so one artefact can answer "what was this session permitted to do, against which model, when". None of that exists in this tree. It is recorded here as intent so nobody reads its absence as an omission.

Licence

Dual-licensed. The default is the GNU Affero General Public License v3.0 or later, full text in LICENSE: use, modify and distribute it freely; if you modify it and let users interact with the modified version over a network, offer them its source. Running it unmodified as the MCP server your own sessions talk to triggers no obligation.

A commercial licence is available on request for anyone who cannot take spine under the AGPL, whether by policy or because they want to embed or host a modified version without releasing source. LICENSING.md states both options and when each applies. Contact john@portll.net.

Copyright (c) 2025-2026 Portll.