Spec database OPAL command reference

Every task that reaches the spec database, and the rules each one enforces

The spec.* family is the only way anything reaches the spec database. No session opens the file; every access goes through a task, so it is mediated, permissioned and attributable.

Three companion families are documented here too: trove.dispatch, which turns a described work unit into a running session, worktree.*, which keeps the worktrees that sessions work in, session.*, which runs and records the LLM sessions themselves, and node.probe/fleet.describe, which say what a machine can do. The tasks of recurring specs, spec.recurring.*, spec.probe.*, spec.run.*, measure.run and trove.land.*, have their own section.

Reads never ask permission

spec.search, spec.get, spec.tree, spec.relations, spec.history, spec.blame, spec.dod.standards and spec.dod.check declare no permission scope, are idempotent, and never prompt. So do the reads of recurring specs: spec.recurring.get, spec.run.list, spec.run.get, spec.run.samples and measure.run.

That is deliberate and it is what makes the database useful: a session can read a spec before anything is committed. A model that had to ask consent to look something up would guess instead.

Writes are the opposite — one thing per call, always. There is no batch mutate, no glob, no "update all matching". Those are the surfaces where one careless call corrupts fifty rows, so they are not offered. Bulk reads are fine.

Keys, not paths

Every spec, section and work unit has a stable key — sqb4gsd1zn9 — issued by the database, never chosen by a caller. The human-readable path (CSCC.EFC.3) is a display address derived from position, so renumbering a section changes paths and changes nothing else. Always pass keys.

Reading

Task What it answers
spec.search q=<text> Full-text over titles, instructions and notes. Returns key + title only.
spec.get key=<key> [body=1] One entity. Bodies are opt-in, because a read that returns everything is a token bill somebody pays every call.
spec.tree [key=<key>] [project=<id>] The shape. With a key, one spec: its sections and their units. Without one, every spec of the project, or of every project: the sections with a unit count n, and free, the count of units in no section. spec= is the same as key=. body=1 needs a key.
spec.relations key=<key> Units naming this one in depends_on or depended_on_by.
spec.history key=<key> Every change, appended and never altered.
spec.blame key=<key> field=<field> Who last wrote one field.

Deleted entities still resolve — that is what soft delete means. Ask the returned deleted flag rather than expecting a miss.

Authoring

Task Notes
spec.put kind=spec|section … Create or edit one spec or one section. Returns its key.
spec.workunit.edit key=<key> … Edit one unit.
spec.workunit.reparent key=<key> section=<key> A redirect: the unit keeps its key, status and history. An empty section leaves it free-standing.
spec.workunit.delete key=<key> Soft delete. The one task that requires physical presence — it is the only irreversible-shaped operation, so it cannot be invoked from a remote lane.

Every work unit needs a Definition of Done. The field is NOT NULL from the first migration and authoring rejects an empty one: a unit whose done-ness cannot be checked is a unit that will be argued about later.

Definition of Done

Task Notes
spec.dod.standards The catalog of standards a Definition of Done can cite.
spec.dod.record key=<key> standard=<id> passed=1 node=<box> [target=…] Record one mechanical result.
spec.dod.check key=<key> Is it met, and if not, what is missing.

The catalog is fixed: STD-UNIT, STD-COVERAGE-NEW, STD-E2E, STD-BUILD, STD-DOC, STD-VERIFY, STD-MEASURE. A Definition of Done cites them by name — "meets STD-UNIT and STD-BUILD" — instead of re-describing testing every time.

A citation the catalog does not know is refused when you write it. A typo'd STD-COVERAGE would cite nothing at all, leaving a Definition of Done that reads rigorous and enforces nothing.

node= is not optional for STD-BUILD. A green build says nothing without the box it was green on — the same reason a green Linux build says nothing about Windows-only code. A pass recorded without one counts as unattributed, and blocks completion exactly as a failure would.

STD-VERIFY cannot be recorded at all. It is settled by a separate work unit of type Verify that names its subject in depends_on and finishes successfully. The implementer does not grade its own homework for the parts that need an opinion.

STD-MEASURE cannot be recorded through spec.dod.record. It belongs to the unit of a recurring run. Trove records it from the probes of the run, one row for each probe with the target probe:<key>. A session that records it grades its own work, so the task refuses it.

Status, claims and plans

Task Notes
spec.workunit.claim key=<key> owner=<who> Take a lease. A live lease blocks another taker; an expired one does not.
spec.workunit.renew key=<key> owner=<who> Extend it while working.
spec.workunit.set-status key=<key> status=<status> Report where the work stands.
spec.plan.put … Create or edit a plan: whole specs plus individually chosen side quests.
spec.plan.progress key=<key> Derived rollup — nothing is stored, so a spec can change mid-flight with nothing to resync.
spec.plan.regroup key=<key> [include_rejected=1] [dry_run=1] Re-open unsatisfactory or stale work for another attempt.

donesuccess is the one status the database will not take on trust. If the unit's Definition of Done cites standards, they must all be satisfied first, and the refusal names the gap. Every other terminal status is a report rather than a claim: doneerror says it was not met, rejected says it will not be attempted, and neither needs evidence.

A claim is a lease, not a lock. If a session dies mid-unit the lease expires and the work becomes available again — a crashed session cannot make a unit permanently unclaimable.

spec.plan.regroup runs one round and reports what it re-opened. It does not loop: how many rounds are worth trying depends on the budget you have left, which only you know. Rejected is left alone unless you ask, because it is a decision rather than a failure.

Publishing

spec.publish key=<key> [root=<worktree>] [spec_path=<tree>] [node=<box>]

Renders a spec into a .md under the repository's spec tree so the code arrives with a description of what it was for. It publishes terminal work only, and says how many units it left out.

The file says in its own header that it is generated and that editing it changes nothing. It is a re-derivable snapshot; the database is the source.

This is the one spec.* task that takes a coarse resource scope. It touches the working tree rather than the database, and there "another publish is already running" is the right answer.

Running work

trove.dispatch lane=<n> key=<plan-or-unit> [model=<model>]

Selects the next runnable unit of a plan (or takes one named unit), claims it, writes its briefing into the lane's CLAUDE.md, and queues the session.

It claims before it queues, because a queued-but-unclaimed unit is one a second lane can take. A plan with nothing left says "no work left" rather than failing — those two call for opposite responses.

Opening a page

trove.open [project=<id-or-name>] [tab=lanes|specs|files|activity|settings] [key=<spec-section-or-unit>] [lane=<n>]

Opens Trove home, the project page at a tab, or the lane page of a lane. It opens the pages above the target too, so Back goes up one level from the page that it opens. The how-to is Find your way in Trove.

  • With no project, it opens Trove home.
  • With a project, it opens its project page at tab. The default tab is lanes, as the page opens. A key selects that spec, section or unit on the Specs tab, and with no tab it opens the Specs tab.
  • With a project and a lane, it opens the lane page of that lane.
  • A tab, a key or a lane with no project fails. An unknown project, a wrong tab and a lane that the project does not have fail too, and the message names the projects, the tabs or the lanes.

Its scope is trove.open. It changes no data, so it never asks. It needs a window: on a HUB with no display, it fails.

Recurring specs

A recurring spec runs the same work again at the times of a schedule, and measures each run with probes. The how-tos are Run a recurring spec and Trust a recurring spec.

Task Scope Asks Notes
spec.recurring.get key=<spec> none never The switch, the schedule and its next time, the probes, the template, the budget and the trust
spec.recurring.set key=<spec> [enabled=1|0] [template=<unit>] [budget=<n>|all] [rule=<cron>] [deleteLimit=<n>] [landWindow=HH:MM-HH:MM] [quietMinutes=<n>] [landMode=push|local] [verify=<commands>] spec.write once per session Off keeps every setting. On needs at least one probe and a template, and the refusal names each missing part. A change of the template, the budget, the delete limit or the verify step clears trust
spec.recurring.trust key=<spec> trusted=1|0 spec.trust every call On this Hub only. Only a spec with probes and a template can be trusted
spec.probe.put [key=<probe>] [spec=<spec>] [name=…] [kind=lint|regex] [config=<json>] [direction=lower|higher] [target=<n>] [ordinal=<n>] spec.write once per session Creates a probe when key is empty, else changes it. A regex is compiled when you write it. A change clears trust
spec.probe.delete key=<probe> spec.write once per session Its samples stay in the history. It clears trust
measure.run probe=<probe> lane=<n>|folder=<path> none never Measures one probe on a folder: a lane's worktree, or a checkout. It records nothing
spec.run.start key=<spec> trove.spawn every call Run now. It works with the switch on or off
spec.run.list key=<spec> [limit=<n>] none never The runs, newest first, with before, after, drift and improvement for each probe. The default limit is 20
spec.run.get key=<run> none never One run: its state, its samples and its judgement
spec.run.samples key=<run>|probe=<probe> none never The samples of one run, or of one probe across its runs, with the first findings of each
spec.run.cancel key=<run> [reason=…] trove.spawn once per session Cancels a run that did not end. Its unit becomes stalled, with the reason
spec.run.land key=<run> spec.trust every call On this Hub only. Lands one run that passed its judgement through the lander and its guards, also on a spec that is not trusted
trove.land.pause [project=<id>] [reason=…] spec.trust every call On this Hub only. The kill switch: no landing of the project, or of the whole Hub when project is empty
trove.land.resume [project=<id>] spec.trust every call On this Hub only. Turns the kill switch off again

The keys of a probe start with m, and the keys of a run start with r. The write of a probe or a run posts to the Specs tab, so it follows live.

The door never gets the scope spec.trust. A session can never trust a spec, land a run or move the kill switch. These tasks also ask before every call, so through the door they answer 409.

The config of a probe

Kind Config The value
lint {"check": "code-wrap"} The findings of the check that are not notes
regex {"pattern": "…", "include": ["src/**"], "exclude": ["**/test/**"], "count": "matches", "case": "sensitive"} The matches, lines or files of the pattern in the tracked files that the globs select

The regex probe never counts the trees of integration/lint/vendored-paths.txt. It skips a file that is not UTF-8, and names it in the sample. A sample keeps the first 50 findings, with path and line.

The states and the outcomes of a run

State Next
scheduled measuring-before
measuring-before dispatched, or done with the outcome at-target when each probe meets its target
dispatched working. A run with no free lane waits here
working verifying
verifying measuring-after
measuring-after judged
judged landing when the spec is trusted and the judgement passed, else done or failed
landing done with landed or not-landed, or landing-deferred after three attempts

The outcome of a run that ended is one of passed, at-target, landed, not-landed, deferred, judgement, definition-of-done, session, restart, error and canceled. The reason gives the details.

A probe passes when its after value is better than its before value, or when it meets its target before and after. It fails when its after value is worse. The judgement of a run passes when no probe failed and at least one probe passed.

Worktrees

The worktree.* family keeps many named worktrees on one git checkout, and lends each one to one holder at a time. Many sessions can then work on one code base, each in its own worktree, without touching each other's files.

Task Scope · confirmation
worktree.list —
worktree.status —
worktree.ensure worktree.write · once per session
worktree.sync worktree.write · once per session
worktree.park worktree.write · every call
worktree.finalize worktree.write · every call
worktree.remove worktree.write · every call
worktree.claim worktree.write · never asks
worktree.renew worktree.write · never asks
worktree.release worktree.write · never asks

The arguments and the outputs of each task:

Task Arguments Outputs
worktree.list checkout worktrees: a JSON array of name, branch, path, dirty, ahead, holder. Also count
worktree.status checkout, name name, branch, path, dirty, ahead, holder, and the operation state below
worktree.ensure checkout, name, base path, branch, created
worktree.sync checkout, name, holder ok, or conflict with the paths
worktree.park checkout, name, force, holder parked_branch
worktree.finalize checkout, name, holder ok, or conflict with the paths
worktree.remove checkout, name, holder ok
worktree.claim checkout, name, holder, seconds (default 1800) expires
worktree.renew checkout, name, holder, seconds (default 1800) expires
worktree.release checkout, name, holder ok

The operation state of worktree.status is the output operation_in_progress.

checkout is the absolute folder of the git checkout. Leave it out and the task uses the checkout of the current Trove project.

A worktree's name is also its branch and its folder. It is one git branch component: letters, digits, ., _ and -, and it cannot start with - or .. Trove's lanes use lane-<N>.

What each task does

  • ensure creates the worktree from base (by default the default branch), or attaches it again if the branch already exists. Calling it twice changes nothing, and created says whether this call made the folder.
  • sync rebases the worktree onto origin/<default>, or onto the local default branch when there is no origin. On a conflict it aborts the rebase, leaves the branch as it was, and returns ok = false with the conflicting paths in conflict. It never resolves a conflict.
  • park removes the worktree and its branch. If the worktree has uncommitted or unmerged work, the call is refused unless you pass force, which first keeps that work on wip/<name>. Nothing is lost.
  • finalize fetches, rebases onto the latest default branch, merges with --no-ff into the local default branch, and pushes when there is an origin. A conflict fails the call with ok = false and the paths in conflict, and nothing is merged.
  • remove removes the worktree and keeps its branch, so committed work survives. A worktree with uncommitted changes is refused: park it instead.

Leases

A lease lends one worktree to one holder. A holder is named in one of three forms: lane:<N> for a Trove lane, session:<id> for a session, and external:<label> for anything else, such as a Claude Code session in a terminal.

  • sync, park, finalize and remove need your lease, or no lease at all on the worktree. Pass your holder in holder. If another holder has the lease, the call is refused and the message names that holder.
  • ensure, list and status need no lease.
  • A lease lasts 30 minutes unless you ask for another term, and at most one day. Renew it before it runs out. A claim by the current holder extends the lease too.
  • An expired lease is free, and the next claim takes it. A holder that crashed blocks a worktree for at most one term.
  • release frees your lease at once.

A Trove lane holds the lease of its worktree while its session runs, on the same 30-minute term as its work-unit claim, and renews it every 10 minutes. While another holder has a lane's worktree, the lane's next job waits in the queue.

LLM sessions

The session.* family runs LLM sessions and records every event of them. A session is a run of turns with one LLM provider. It works in one folder, and its record and events live in the session store, sessions.db, in the base folder of the host that runs it. The store is the history of a session: Claude Code's own transcript files are never read while a session runs.

Each session has an id that never changes. The provider's own session id, for example Claude Code's, is a separate field, provider_session, and it can arrive after the first answer.

Reading

Task What it answers
session.list [kind=…] [state=…] [lane=…] [project=…] [folder=…] [limit=100] The matching records, the most recently active first. At most 1 000.
session.get id=<id> One record: ids, kind, provider, model, folder, worktree, lane, project, job, state, holder, lease expiry, retention, times and token counts.
session.events id=<id> [since=0] [limit=500] [wait_ms=0] The events after the cursor since, at most 500, and next, the cursor for your next call.

Reads never ask permission. To follow a session, call session.events again with the next it gave you. With wait_ms (at most 30 000) the call waits for the first new event and returns as soon as one arrives, so you follow a live session without a busy loop.

The event types are prompt, text, thinking, tool_use, tool_result, usage, limit, error, system and turn_end. Each event carries its seq (from 1, with no gaps), its turn, its time and its content blocks. A usage event carries the token counts of a turn, and a limit event the time the provider's limit resets.

Running

Task Scope · confirmation Notes
session.start kind=assistant|coding [provider=claude-code] [model=…] [model-record=…] [folder=…|worktree=…] [prompt=…] [briefing=…] [holder=…] session.spawn · asks once Returns id. A coding session needs a folder or a worktree. holder takes the lease at once. model-record is the id of a model in the LLM settings: it picks the provider of an api session when two providers list the same model.
session.send id=<id> text=<text> [holder=…] session.write · asks once Returns turn. A message sent during a turn starts the next turn, in order.
session.interrupt id=<id> [holder=…] session.write · asks once Stops the current turn. The session stays: your next message resumes it.
session.close id=<id> [holder=…] session.write · asks once Stops the session for good. A closed session takes no message.

A task that asks once asks the operator on its first call, and the approval holds until that OPAL chat ends.

The two kinds decide what a session can touch:

  • An assistant session gets a folder of its own under the host's base folder and reads files only. It keeps those read-only tools when it resumes.
  • A coding session works in the folder or worktree you give it, with the tools of a Trove lane. The briefing goes into the folder's CLAUDE.md. Nobody is there to answer a permission prompt, so Claude Code denies one instead of waiting.

Claude Code sessions need Claude Code 2.1.259 or later. An older version is refused when the session starts, and the message names both versions.

When a provider's limit stops a turn, the session records a limit event and its state becomes parked until your next message.

Leases

Task Scope · confirmation Notes
session.claim id=<id> holder=<who> [seconds=1800] session.write · never asks Returns expires. Refused while another holder has a live lease.
session.renew id=<id> [holder=<who>] [seconds=1800] session.write · never asks Extends your lease from now. Refused for another holder's lease.
session.release id=<id> [holder=<who>] session.write · never asks Gives the lease back. Without holder, any lease goes.

A lease keeps one holder at a time: a Trove lane, or an outside client such as a Claude Code session in a terminal. While a lease is live, session.send, session.interrupt and session.close work only for its holder, so pass your holder with them. A session with no live lease takes them from anyone. A lease lasts 30 minutes unless you say otherwise; renew it while you work. An expired lease is free for the next taker.

The lease tasks never ask for confirmation, because an engine claims and renews all the time. They still carry the session.write scope, so you can revoke the grant.

Adopt and export

  • session.adopt — Scope session.spawn, asks every time. Returns id, provider_session, folder, worktree and turns. With only folder, and more than one Claude Code session in it, returns candidates instead, newest first, and adopts nothing.
  • session.export — Scope session.write, asks every time. Returns command, the command below. The session stops here and closes.

The arguments of the two tasks:

session.adopt folder=…|provider_session=…|transcript=… confirm_closed=1 [holder=…] [lane=…] [project=…]
session.export id=<id> [holder=…]

The command that export returns:

cd <folder> && claude --resume <provider_session>

Adopt makes a Claude Code session that you ran in a terminal into a session of the store. Its transcript is read once: every prompt, answer, tool call and usage report becomes an event, marked imported. Your next session.send resumes it in its own folder. If the folder is a git worktree, worktree names it.

Close the terminal first. Two running copies of one Claude Code session write into one transcript, and nothing here can see a terminal, so adopt refuses to run until you say confirm_closed=1.

Export is the way back. Run the command it returns, and Claude Code shows the whole session, with the turns that ran here, in its own view. To bring the session back once more, close the terminal and adopt it again.

How to do it from start to end: Adopt a terminal session.

What a machine can do

Task Notes
node.probe What this node brings: OS, arch, cores, disk, load, and the toolchains actually on PATH.
fleet.describe Every node's advertisement. Today that is one node, in a list.

Everything advertised is measured, not configured. An advertisement that can be optimistic routes work to a box that cannot run it, and the failure lands much later as a confusing error on somebody else's machine.

The door

A program outside the Hub reaches every task on this page through the Trove door: one local endpoint of your user on this computer. Two tasks describe the door itself.

Task Scope Notes
door.status none The host (hub or standalone), its process, the identity it serves, the protocol, its uptime, the calls answered and running, and the last backup of the spec database with its error, if any.
door.handover base=<folder> door.host A standalone door host stops accepting calls, lets the running ones end, closes its stores and ends. Only a Hub of the same data folder takes the door over; a Hub host refuses.

A call through the door answers with an HTTP status and a JSON body:

Answer When
200 {"ok":true,"outputs":{…}} The task completed
200 {"ok":false,"error":"…"} The task ran and failed
401 The token is missing or wrong
403 {"scope":"…"} A scope of the task has no grant
404 No task has that name
409 {"confirm":"…"} The task asks before every call, and nobody can answer: run it in the Hub
413 The body is over 1 MiB
426 {"protocol":1} The caller names a protocol the door does not speak
503 {"busy":"…"} The task waited 30 seconds for its resource; try again

Calling these from the CLI

The --opal forms and their two silent traps are documented in the audio-recovery task reference and apply identically here:

hub --opal 'spec.put kind=spec code=gait title=Gait' -c
hub --opal '{"steps":[{"task":"spec.search","args":{"q":"gait"}}]}' -c

Remember that the short form splits on spaces, so any value containing one needs the plan form.

Topics
reference trove spec opal
See also