spine

MCP tool reference

What the server answers to tools/list, reproduced rather than paraphrased: substrate-spine 1.2.0, MCP protocol 2024-11-05, 23 tools, 9 of them declared read-only (readOnlyHint). Clients see these tools as mcp__substrate__<name> when the server is registered under the name substrate.

Every tool is a JSON-RPC tools/call over stdio. A refusal comes back as a tool error carrying the reason; an empty result is never used to mean failure, and a failure is never returned as an empty result.

Reading the tables. required is what the server enforces before dispatch: a missing required field is refused by name, not passed to storage. Everything else is optional and documented by the server's own description.

list_plansread-only

Listing plans

List plans. Active only unless all=true. Rows carry lastTouchedAt (newest of the plan row and its tasks), staleDays, and a COMPUTED dormant flag — threshold declared per row as dormantAfterDays, never stored — so "active" stops meaning both "being worked" and "nobody has looked in weeks".

parametertyperequireddescription
allbooleaninclude archived

create_plan

Creating a plan

Create a plan: named work with a working directory. Pass `project` — the fleet project slug this work belongs to (e.g. substrate, commitwork) — so the plan states its own owner. Without it the owner has to be inferred from cwd at read time, which cannot be done at all for a worktree outside the fleet root. Pass `tasks` (same shape as provision_tasks — tree address -> {goal, dependsOn?, worktree?, branch?, anchor?, ringParent?}) to create the plan AND its whole task tree in this one call, once list_plans has already established a new plan is genuinely needed. One transaction: the plan and every task land together, or neither does — a plan created with no tasks because the batch failed validation would be the same half-provisioned state provision_tasks refuses to produce on its own.

parametertyperequireddescription
idstringyesshort slug, e.g. substrate-v1
namestringyeshuman name
cwdstringworking directory
projectstringfleet project slug this plan belongs to
tasksobjectoptional: tree address -> task spec, e.g. {"1":{"goal":"..."},"1.1":{"goal":"..."}} — creates the plan and this whole tree in one call

archive_plan

Archiving a plan

Archive a plan. Does not delete it.

parametertyperequireddescription
idstringyesplan id

create_task

Creating a task

Add a task. The id is assigned as a TREE ADDRESS ("1", "1.2", "1.2.3") — pass parent to nest. Optionally claim a worktree/branch so the census can tell an owned worktree from an orphan.

parametertyperequireddescription
planIdstringyesplan id
parentstringparent task id, omit for a root task
goalstringyeswhat this task is trying to do
dependsOnarray of stringtask ids that must complete first
ringParentstringthe strand/ring this task drifted off — a back-link distinct from the tree parent. Set it when capturing an inner-ring remainder while attention moves on, so closing that ring resurfaces this task instead of orphaning it. Declaration only; never caps or gates work.
worktreestringabsolute path to a git worktree this task owns
branchstringbranch this task works on
anchorstringcommit the work started from

get_contextread-only

Reading task context

The immediate context for a task: itself, parent, siblings, children, blockers, and — the distinctive read — what abandoned siblings already tried and why it failed. Small however large the plan grows, which is what lets the tree live outside the context window.

parametertyperequireddescription
planIdstringyesplan id
idstringyestask id

list_tasksread-only

Listing tasks

All tasks in a plan, optionally filtered by status.

parametertyperequireddescription
planIdstringyesplan id
statuspending | active | completed | abandoned

set_status

Setting task status

Move a task to pending | active | completed | abandoned. Abandoning REQUIRES abandonReason: a dead end with no recorded reason gets repeated by the next sibling. Activating a task with unmet dependsOn is REFUSED unless force=true (the override is recorded in state). BATCH: pass `updates` to move several tasks in one call — each entry may set its own status/result or inherit the call-level `status`. A batch is NOT a transaction: entries apply in order and the reply names every id that moved and every one refused, with the reason. Use the single form for one task.

parametertyperequireddescription
planIdstringyesplan id
idstringtask id — single form
statuspending | active | completed | abandonedstatus for the single form, or the default for every batch entry that sets none
resultstringhuman-readable outcome
abandonReasonstringwhy this approach failed — required when abandoning
forcebooleanactivate despite unmet dependencies; recorded
updatesarray of objectbatch form: [{id, status?, result?, abandonReason?, force?}] — status falls back to the call-level status
item fieldtyperequireddescription
idstringyestask id
statuspending | active | completed | abandoned
resultstringhuman-readable outcome
abandonReasonstringwhy this approach failed — required when abandoning
forcebooleanactivate despite unmet dependencies; recorded

update_task

Updating a task

Update a task goal, result, notes, structured state, or its worktree claim. Prefer statePatch (shallow merge, null deletes a key) over state (whole replacement) — two writers replacing state wholesale clobber each other.

parametertyperequireddescription
planIdstringyesplan id
idstringyestask id
goalstringrevised goal
resultstringoutcome so far
stateobjectREPLACE structured state wholesale
statePatchobjectshallow-merge into structured state; a null value deletes that key
notesstringalways-mutable scratchpad; result stays the terminal outcome
ringParentstringattach or change the ring back-link (the strand this task drifted off); reporting only, never a gate
worktreestringworktree path
branchstringbranch
anchorstringanchor commit

synthesizeread-only

Synthesizing child results

Read before completing a parent: direct children grouped as completed (with result+state), abandoned (with reason), and open — plus ringChildren, tasks that drifted off this one (ring_parent back-link) and are still open, so closing a strand cannot silently orphan its remainder. A parent completed without this read summarised work it never looked at.

parametertyperequireddescription
planIdstringyesplan id
idstringyesparent task id

claim_files

Claiming files

Declare which files this task is editing. A worktree claim says who owns a CHECKOUT; this says who is editing a PATH, which is the only question that means anything when several sessions share one checkout. REFUSES a path already held by another active task and names the holder; force=true takes it anyway and RECORDS what it took it from. This is a declaration, not a lock — nothing stops a write, but a collision becomes visible while it is happening instead of at commit time.

parametertyperequireddescription
planIdstringyesplan id
idstringyestask id
pathsarray of stringyespaths this task is editing, as you refer to them
notestringanything the next session needs to know
forcebooleantake a held path anyway; recorded on the claim

release_files

Releasing file claims

Release claims. Omit paths to release everything this task holds. Completing or abandoning a task releases its claims automatically — a claim held by a dead task reads as contention that is not there.

parametertyperequireddescription
planIdstringyesplan id
idstringyestask id
pathsarray of stringomit to release all

file_ownersread-only

Listing file owners

Every live file claim, the STALE ones whose owning task is no longer active, and — when you supply `dirty` — the paths changed in the working tree that NOBODY claims. Read this before editing a shared file. UNCLAIMED IS NOT FREE: a claim releases when its task completes and the edit outlives it, so a file can read unheld while another session still has work in it. Omitting `dirty` reports joined:false, never an empty list, because "the join did not run" and "nothing is dirty" are different facts. Get the set with `git status --porcelain`.

parametertyperequireddescription
pathsarray of stringrestrict to these paths
dirtyarray of stringpaths changed in the working tree, as YOU measured them — this server does not shell out, and claims are free strings that may be relative to any repository

worktree_ownersread-only

Listing worktree owners

Every task claiming a worktree. This is the join orchestrator/lib/worktrees.mjs cannot make alone: git knows the worktree exists, the spine knows who owns it, and only the pair tells you an agent left something behind.

No parameters.

provision_tasks

Provisioning a task tree

Create a whole task tree in ONE call, with ids you choose, instead of N create_task calls whose ids you cannot predict. `tasks` maps tree address -> {goal, dependsOn?, worktree?, branch?, anchor?, ringParent?}. All-or-nothing: it refuses rather than half-provisions, because a partially built plan still looks like a plan. Refuses an id that is not a tree address, a goal-less task, an id that already exists, a parent that is in neither the batch nor the plan, and root-level ids on a plan that already has tasks.

parametertyperequireddescription
planIdstringyesplan id
tasksobjectyestree address -> task spec, e.g. {"1":{"goal":"..."},"1.1":{"goal":"..."}}

timelineread-only

Reading the task timeline

Recently-touched tasks for a plan, most recently updated first. What changed lately, when the tree is too big to read whole.

parametertyperequireddescription
planIdstringyesplan id
limitnumberdefault 20, max 500

open_session

Opening a session

Open a work session, optionally bound to a plan. ONE ACTIVE SESSION PER (OWNER, AGENT), enforced by the storage layer; a second open by the SAME agent is refused and names the incumbent. Concurrent sessions are expected and fine — `owner` answers WHO (defaults to the OS user; do not pass an agent or tool name) and `agent` answers WHICH WORKER, defaulting to this server process, which stdio makes one-per-session. Pass `agent` only to override that.

parametertyperequireddescription
planIdstringplan this session works on
labelstringwhat this session is for
ownerstringdefaults to the OS user
agentstringdefaults to this server process — one per agent session

session_ids

Resolving session identity

Read — and optionally refresh — every knowable identifier for a session: the Claude Code transcript id, the fleet name, the cwd, the VS Code window and the IDE port that holds it. Each value carries HOW it was known (`self`, `presence`, `ppid-walk`, `lockfile`), because a process-tree inference and a file the harness wrote about itself are not the same kind of fact and must not read as one. Ids are resolved at open_session, when every process in the chain is still alive; call this with `refresh` to re-resolve for THIS agent, or with `patch` to contribute what only you can see (a panel knows a window the server does not). Merges, never replaces — a wholesale write erases whatever the writer personally could not observe. An absent key means NOT KNOWN; it is never null-to-mean-nothing.

parametertyperequireddescription
sessionIdstringdefaults to this agent's active session
refreshbooleanre-resolve from the live process chain (only meaningful for this agent's own session)
patchobjectids to merge in; a null value deletes that key. Include a `how` object naming the evidence class for each key you set

close_session

Closing the session

Close THIS agent's active session and return its receipt: task counts by status, remaining, overlook snapshot COUNT, and duration. Scoped to the calling agent — it cannot close a concurrent session belonging to another. Refuses when there is none rather than reporting an empty close. `sessionId` is the exception, for retiring an ORPHAN: it closes a named session of the same owner whatever agent opened it, and is REFUSED if that agent's process is still running unless you also pass force. Use active_session with all:true to find the id.

parametertyperequireddescription
ownerstringdefaults to the OS user
agentstringdefaults to this server process
sessionIdstringclose this NAMED session instead of your own — for orphans
forcebooleanclose a named session whose process is still alive; recorded in the receipt

reap_sessions

Reaping sessions nobody can close

Retire sessions nobody can close. Reports what it would take and takes nothing unless apply:true. Two sweeps, and they answer different questions: the PID sweep closes sessions whose holding process is gone, and only ESRCH counts as gone — a live session is never touched, at the cost of missing one whose pid was recycled. `maxIdleSecs` adds the TTL sweep, which is the backstop for exactly that, plus rows predating the pid column. Both write status `reaped`, NEVER `completed` or `abandoned`: the sweep witnessed a dead holder, not an outcome, and either verdict word would assert one. Sessions with no recorded pid are counted and reported separately — they are unknown, not dead, and the PID sweep will not touch them.

parametertyperequireddescription
ownerstringdefaults to the OS user
applybooleanactually close them; default is a dry report
maxIdleSecsnumberalso close anything idle longer than this; omit to run the pid sweep alone

assign_session

Claiming a session for work already done

Claim a session for work you have ALREADY DONE, when you never called open_session. Starts at THIS SERVER PROCESS's start — an OS fact, not a supplied number — and records `claimed_at` separately, so the stretch the spine did not watch stays visible instead of being reported as observed. Use it when a session did substantive work without opening one (a continued context, a forgotten start); the alternative is losing the attribution or opening a session that pretends the work began now. REFUSED if you already hold an active session — a claim must not overwrite a record that is already running. If you override `agent`, the process start is evidence about somebody else, so the claim covers NO unobserved time unless you pass `startedAt` yourself.

parametertyperequireddescription
planIdstringplan this session works on
labelstringwhat this session was for
ownerstringdefaults to the OS user
agentstringdefaults to this server process
startedAtstringISO override for the start. AVOID: the default is derived from the process and is checkable, this is not, and the record marks which was used

active_sessionread-only

Reading the active session

This agent's current session, or null. Null means NO SESSION — it is not an error and not an empty one. Pass all:true for every concurrent session this owner is running (a census; you still cannot close another agent's).

parametertyperequireddescription
ownerstringdefaults to the OS user
agentstringdefaults to this server process
allbooleanevery active session for this owner, not just yours

snapshot_overlook

Recording an overlook snapshot

Record a posture/awareness reading against a session. Refuses an unknown session rather than writing an orphan row: a snapshot that cannot say as-of-when or by-whom is not evidence, and storing it anyway manufactures some.

parametertyperequireddescription
sessionIdstringyessession this reading belongs to
dimensionsobjectdimension -> score
posturestringposture label, e.g. cautious | ship
notestringanything the next reader needs

list_overlookread-only

Listing overlook snapshots

Overlook snapshots for a session, most recent first.

parametertyperequireddescription
sessionIdstringyessession id
limitnumberdefault 20, max 500