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 attab. The default tab islanes, as the page opens. Akeyselects that spec, section or unit on the Specs tab, and with notabit opens the Specs tab. - With a
projectand alane, it opens the lane page of that lane. - A
tab, akeyor alanewith noprojectfails. 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
ensurecreates the worktree frombase(by default the default branch), or attaches it again if the branch already exists. Calling it twice changes nothing, andcreatedsays whether this call made the folder.syncrebases the worktree ontoorigin/<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 returnsok=falsewith the conflicting paths inconflict. It never resolves a conflict.parkremoves the worktree and its branch. If the worktree has uncommitted or unmerged work, the call is refused unless you passforce, which first keeps that work onwip/<name>. Nothing is lost.finalizefetches, rebases onto the latest default branch, merges with--no-ffinto the local default branch, and pushes when there is an origin. A conflict fails the call withok=falseand the paths inconflict, and nothing is merged.removeremoves 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,finalizeandremoveneed your lease, or no lease at all on the worktree. Pass your holder inholder. If another holder has the lease, the call is refused and the message names that holder.ensure,listandstatusneed 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.
releasefrees 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
assistantsession 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
codingsession works in the folder or worktree you give it, with the tools of a Trove lane. Thebriefinggoes into the folder'sCLAUDE.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— Scopesession.spawn, asks every time. Returnsid,provider_session,folder,worktreeandturns. With onlyfolder, and more than one Claude Code session in it, returnscandidatesinstead, newest first, and adopts nothing.session.export— Scopesession.write, asks every time. Returnscommand, 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.