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 two command-line tools for backing it up and sweeping it.

Status: private, pre-1.0. Extracted from substrate/spine on 2026-09-02 with its history preserved (git subtree split, 25 commits). The copy inside substrate is still the one every live session talks to; nothing has been cut over. The site is spine.commitwork.online.

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, 23 tools — see the reference below
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
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
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

SUBSTRATE_TASKS_DB=~/.substrate/tasks.db node mcp.mjs

The server speaks MCP on stdin/stdout. It opens (and creates, with its parent directory) the store named by SUBSTRATE_TASKS_DB, defaulting to ~/.substrate/tasks.db. Registered with Claude Code it looks like this, and its tools then appear as mcp__substrate__<tool>:

claude mcp add --scope user substrate \
  -e SUBSTRATE_TASKS_DB=$HOME/.substrate/tasks.db \
  -- node /path/to/spine/mcp.mjs

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

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

Environment

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

variabledefaultread by
SUBSTRATE_TASKS_DB~/.substrate/tasks.dbmcp.mjs, reap.mjs, snapshot.mjs
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, archive_plandb.mjs planscreate_plan with tasks lands the plan and its whole tree in one 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

Substrate consumes spine by path today: server/api.mjs imports ../spine/db.mjs and the MCP registration names substrate/spine/mcp.mjs. This repository is a mirror with history, not yet the source of truth — a change made here does not reach the running server until substrate's copy is updated, and a change landed in substrate does not reach here until it is re-split. The cutover (substrate consuming this repository, or this repository becoming the registered server) is a separate act, deliberately: every live session calling mcp__substrate__* breaks mid-turn if the server moves without warning.

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 yet.

~/.substrate/. Both names are left as they are until the cutover, so that this copy and the live one cannot be told apart by a client.

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

The Spine Evaluation Licence, in LICENSE — the same licence substrate carries, with the product name changed. It grants a personal, revocable right to install, run, read and assess the software on hardware you control, and nothing else: no commercial use, modification, derivative work, redistribution or hosting. For any use beyond evaluation, contact john@portll.net.

Copyright (c) 2025-2026 Portll. All rights reserved.