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:
- sessions — opened, claimed after the fact, closed, or reaped, each carrying every id 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.
- plans and tasks — a tree address per task (
1,1.2,1.2.3), dependencies that gate
activation, structured state passed between tasks, a terminal result distinct from an always-mutable notes, and an abandonReason that siblings can read.
- file claims — which task is editing which path. A claim is a declaration that makes a
collision visible while it is happening; it is not a lock and stops no write.
- receipts — a close reports counts and duration. A reap records that a dead holder was seen,
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
| file | what |
|---|---|
db.mjs | storage and every query; the schema lives here. open(path) returns the handle every other module takes |
mcp.mjs | the MCP server: JSON-RPC 2.0 over stdio, newline-delimited, 23 tools — see the reference below |
identity.mjs | resolves 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.mjs | the dependency order of a plan's tasks, pure: takes the rows listTasks() returns and reports what is ready, blocked, dangling or cyclic |
reap.mjs | command line: what is active, and what a sweep would retire; --apply retires it |
snapshot.mjs | command 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.mjsThe 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.mjsOne 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 itNothing 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:
| variable | default | read by |
|---|---|---|
SUBSTRATE_TASKS_DB | ~/.substrate/tasks.db | mcp.mjs, reap.mjs, snapshot.mjs |
SUBSTRATE_SESSION_OWNER | the OS user | db.mjs |
SUBSTRATE_AGENT_ID | a-<pid>-<process start> | db.mjs |
CW_PRESENCE_DIR | ~/.claude/sessions | identity.mjs |
CW_IDE_LOCK_DIR | ~/.claude/ide | identity.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/.
| tools | backed by | notes |
|---|---|---|
list_plans, create_plan, archive_plan | db.mjs plans | create_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, timeline | db.mjs tasks | ids 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_owners | db.mjs file_claims | file_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_ids | db.mjs sessions + identity.mjs | ids are resolved at open, when every process in the chain is alive |
reap_sessions | db.mjs sessions | dry by default; the command-line reap.mjs is the same sweep from outside a session |
snapshot_overlook, list_overlook | db.mjs overlook | a 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 mainRelationship 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
- Measured 2026-09-03 on Node 26.7.0:
node --test 'test/*.test.mjs'— 149 tests, 149 pass.
Re-measure rather than trust this line; it is a reading, not a promise.
- One test was corrected here after the split.
session-orphans.test.mjsstill asserted that a
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.
order.mjsandtest/order.test.mjswere rescued, not inherited. Both existed only as
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.
- The server still announces itself as
substrate-spine 1.2.0and the store still lives under
~/.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.