Introduction
seeds is an issue tracker for AI coding agents that stores each work item
(“a seed”) as facts in a quipu knowledge
graph. It answers the verbs agents already type against bd and br
(create, show, list, ready, count, update, close, dep,
comments), with --json output in the same shape, so an agent does not
have to learn anything new.
Status: the v0.1 core runs. Every verb works against a local quipu store, a repo-local pendant or a quipu server,
sd syncmerges them, the core builds for WebAssembly, and--atpins work. beads sync and formulas (through shuttle) are not built yet. See What is built for exactly what runs today.
The name
A quipucamayoc, the keeper of the quipus, did arithmetic on a yupana, a counting board of compartments, by moving seeds or pebbles across it. When the count was settled it was knotted into the quipu, the permanent record.
The stack already uses that picture: quipu is the record, yupana is the board. Seeds are the counters moved across the board before they become knots, which is what a work item is: something in motion that ends up in the record.
Why use it
- Pin one item, not the whole database. A snapshot-versioned tracker can tell you what the whole database looked like at a commit. seeds can tell you what one item looked like at a transaction, while the rest of the project keeps moving. See Pinning.
- No commit step, no server lifecycle. Every write is a quipu transaction and the transaction id is the cursor. There is no dirty state to commit, no garbage collection to schedule and no SQL server to start and stop.
- Identity is built in. A project is a named graph, and its IRI is its identity. There is no separate project-id row to drift out of sync.
- Governed writes. Every write is validated against the seeds profile (camayoc’s governance constraints plus the tracker’s own), so a seed with an unknown status or a dependency on nothing is refused at the door. See The storage model.
- Share a ledger like a package. A project’s graph, its shapes and the queries that define “ready” can travel as a pendant with separately registered stored queries. See Sharing a ledger.
Where it fits
seeds is the beads replacement for the quipu stack: bd callers are
redirected to sd by desire-path, and the verbs they type answer with
--json in the same shape. Why facts, not snapshots explains
the storage design the features above rest on.
Getting started
Install
The command is sd. The project is seeds and the crate is seeds-ai.
- Prebuilt binaries. Each GitHub release
(tag
seeds-ai-v<version>) carriessd-<tag>-<target>.tar.gzforx86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,aarch64-apple-darwinandx86_64-apple-darwin, plus aSHA256SUMS.txtcovering every archive. Verify before you extract. The recipe is in the README’s Install section. - crates.io, once the first version is published:
cargo install seeds-ai --locked(Rust 1.89 or newer). - From source:
cargo install --git https://github.com/scbrown/seeds --locked.
sd --version
sd 0.2.4 (seeds)
If you already have chmln/sd
chmln/sd is a widely used find-and-replace
tool whose command is also sd (crates.io sd, Homebrew sd).
cargo install seeds-airefuses to overwrite an existing~/.cargo/bin/sd;--forcereplaces it.- With both installed in different directories,
PATHorder decides which runs.which -a sdshows the order. - To keep both, install the seeds binary under another name (for example
seeds) and point the desire-path alias at that name.
A first session
No server and no configuration are needed: the first write creates a local
store at .seeds/seeds.db.
sd create "Write the parser" -p 1 # created ○ sd-k2x [P1] [task] Write the parser (tx 1)
sd create "Design the grammar" # created ○ sd-7mf ... (tx 2)
sd dep add sd-k2x sd-7mf # sd-k2x is blocked by sd-7mf
sd ready # only sd-7mf
sd update sd-7mf --claim # take it (exit 4 if someone else has)
sd close sd-7mf --reason "grammar in docs/grammar.md"
sd ready # now sd-k2x
sd show sd-k2x --at 2 # sd-k2x as it stood at transaction 2
Add --json to any of them for br-shaped output. To keep the store somewhere
else, or to change the id prefix, see Configuration.
Troubleshooting
error: unrecognized subcommand, exit 2. That verb is not in seeds. Exit 2 is always a usage error. Every code is in Reference.no seeds store at … yeton a read. Nothing has been written in this project (or the configuration points somewhere new); the answer is empty because the store is, not because nothing matched.- Exit 7 with a URL in the message. A quipu server is configured
(
[quipu] urlorSEEDS_QUIPU_URL) and cannot be reached. seeds does not fall back to a local store. See Storage modes. - An older version prints. Another copy is earlier on your
PATH:which -a sd. Ifsd --versiondoes not printsd <version>, it is a differentsd.
Why facts, not snapshots
seeds is the beads-compatible tracker for the quipu stack: it answers the
bd/br verbs agents already type, and it stores every work item as facts in
a quipu graph. This chapter explains the storage choice, because it is what
the features below are built on.
beads versions the whole issue database: a commit captures every table at once, and history is a sequence of database snapshots. That design has real strengths: branching, merging and a familiar SQL surface.
seeds versions facts. Each field of each seed is a triple in a quipu graph, written in a transaction. History is the sequence of transactions, and any single fact can be read as of any transaction.
What fact-level versioning gives you
- Fact-level pins. Pinning a single seed to a transaction and resolving it later (see Pinning) answers a question that a whole-database snapshot answers badly.
- A smaller operational surface. Of the seven things agent tooling asks a beads store to do (see The bd intent map), three go away entirely when the store is a graph.
- Ledgers that share cleanly. Two teams can exchange and reconcile work as pendants (see Sharing a ledger).
Design rules
- Map the intent, not the SQL surface. Tools that reach around
bdinto raw SQL are served by mapping the intent of each call, not its syntax. bdcallers move over without changes. desire-path redirectsbdtosd, and every verb seeds refuses is recorded, so the remaining parity work is driven by what agents actually type.
The bd intent map
Some agent tooling does not stop at the bd CLI. Gas City,
for example, reaches past it into raw SQL and Dolt procedure calls when a verb
it needs does not exist. seeds does not copy that surface. Instead, each raw
call was read for its intent, and each intent is mapped to what seeds must
provide on a graph store.
Seven intents cover every raw call. Three of them go away when the store is a quipu graph.
| # | intent | what callers do today | what seeds provides |
|---|---|---|---|
| 1 | Conditional claim / release | UPDATE … WHERE status='in_progress' AND assignee=X, plus a revision token | a compare-and-swap: sd update <id> --claim, sd release <id> --if-assignee X |
| 2 | Project identity | read or upsert a project-id metadata row; ask for the active branch; probe for the issues table | the project is a named-graph IRI, so identity comes built in |
| 3 | Commit / cursor | stage, commit, count dirty tables, read the head hash | goes away: every knot is a transaction and the transaction id is the cursor |
| 4 | Capability / schema probe | read the max migration version; list tables and columns | sd capabilities --json, derived from the loaded quipu shapes |
| 5 | Health / liveness | list databases, read the process list and data dir, start or stop the SQL server | goes away: quipu’s own health endpoint; there is no server lifecycle |
| 6 | Maintenance | garbage-collect, purge dropped databases, compact | goes away: quipu’s own job; a no-op in seeds |
| 7 | Read projections | COUNT, list and ready union queries | SPARQL behind sd count, sd list and sd ready, as camayoc stored queries |
What each surviving intent needs
1. Conditional claim / release
Two agents must not both claim the same seed. quipu’s SPARQL update endpoint
already runs DELETE/INSERT … WHERE under the store lock, so the WHERE
clause is an atomic precondition. What is missing for this to be a hot path:
- an affected count, so the caller knows whether the swap matched without a second read;
- attribution, so the write records who claimed it;
- a cheaper primitive than a whole-store update, most likely an
expectedfield on a single-predicate write.
2. Project identity
A project is a named graph. Its IRI is stable, globally unique and travels with the data, so “which project is this?” never disagrees with the data itself.
4. Capability probe
Callers ask “what does this store support?” to decide which code path to take. seeds answers from the shapes quipu has loaded, which are the real contract, rather than from a migration counter.
7. Read projections
ready, blocked and a worker’s plate are definitions, not code. They
live as stored queries next to the shapes (in camayoc), so the definition of
“ready” is the same for every caller and travels with a shared ledger.
The verbs
The CLI verbs cover what agents type directly: create, show, list,
ready, count, update, close, dep add|remove|list and
comments add|list, each with --json in the bd output shape. See
Reference.
Where each intent stands
| # | intent | status |
|---|---|---|
| 1 | conditional claim / release | update --claim is built (a compare-and-set, exit 4 on a lost claim); release --if-assignee is not |
| 2 | project identity | built: one project is one named graph |
| 3 | commit / cursor | built: every write prints its transaction, and --at reads from it |
| 4 | capability probe | not built |
| 5 | health / liveness | nothing to build locally; the store is a file |
| 6 | maintenance | nothing to build |
| 7 | read projections | built: count, list and ready (ready is a SPARQL query) |
How it works
agent types `bd ready --json`
│
▼
desire-path (dp alias --cmd bd --replace sd) [not built yet]
│ rewritten to `sd ready --json`
▼
sd (native CLI) config: flags > env > .seeds/config.toml > ~/.config/seeds > default
│ clock, store file, write lock, pendant files, HTTP
▼
seeds core verbs, ready, JSON, pendant, merge ◀── also builds for wasm32
│ Backend trait
├──▶ QuipuBackend ── quipu (embedded) ── .seeds/seeds.db ⇄ .seeds/pendant/ (git)
└──▶ RemoteBackend ── quipu server: /query, /update (compare-and-set)
one project = one named graph; one write = one transaction
shapes: the seeds profile over schema:Action
The pieces
- seeds core (this repo,
src/outsidesrc/native/). The work-item model, the verbs, the ready computation and the JSON output. It reads no clock, file or environment: time arrives in aCtx, storage as aBackend. That is what lets it build for wasm32. See WebAssembly. sd, the native CLI (src/native/). Its clap definitions followbr, so the flags match what agents already type. It resolves configuration, opens the store file under a write lock, reads the system clock, and prints results in thebdJSON shape.- quipu is the store, embedded as a library. Reads are SPARQL
(
readyruns a query) and fact scans; each write is one transaction of retractions and assertions. See The storage model, including why seeds embeds quipu rather than calling a quipu server. - camayoc owns what a work item means: a seed is a
schema:Actioncarrying camayoc’s governance terms (QuechuasourceKind,outcome,blockedOn), and every write is validated against the seeds profile that carries camayoc’s constraints. The ready query is written to become a camayoc stored query. - A crew harness can treat seeds as one more work-item tracker: the
--jsonshapes are br’s, so a tracker adapter written for br reads them. - caboodle and desire-path integration (install and verify; the
bdredirect) are not built yet. See What is built.
Where writes go
To the project’s named graph in the configured store: by default a local file,
.seeds/seeds.db, created on first write. The graph IRI is the project’s
identity.
Why there is no commit verb
Every write is a transaction. The transaction id is the cursor a caller keeps,
and “what did this look like then” is a read with --at. There is no staging
area, no dirty state and nothing to commit. See Pinning for what
that buys.
The storage model
A seed is a set of facts in a quipu graph. This page says which facts, where they live, how a write lands, and why seeds embeds quipu instead of calling a quipu server.
One project, one named graph
A project is one named graph in a quipu store. Its IRI is the project’s
identity, so “which project is this?” is answered by the data itself, never
by a separate metadata row. The default IRI is
https://seeds.local/project/<prefix>; set another with [project] graph,
SEEDS_GRAPH or --graph (see Configuration).
A seed is an entity in that graph, at https://seeds.local/item/<id>.
Ephemeral seeds (sd create --ephemeral, br’s flag) live in a sibling
graph, <project IRI>#seeds-ephemeral, created the first time one is written.
Every read (show, list, search, count, …) sees both graphs as one
project, and the seed’s --json carries "ephemeral": true. A pendant, an
export and sd sync carry only the project graph, so an ephemeral seed is
never shared, pulled or removed by a ledger, and it is never ready. A seed
stays in the graph it was created in; there is no verb to move it.
A shared seed cannot depend on an ephemeral one, by any dependency type:
the edge would be shared while its target is not, so the ledger would name a
seed no clone can resolve. br accepts such an edge, and its own
sync --import-only then refuses the export it wrote. seeds refuses the
edge instead (exit 2, nothing written). An ephemeral seed may depend on
anything.
The vocabulary
A seed is a schema:Action. The terms follow one order of preference: W3C
first, then schema.org, then another public
vocabulary, then Quechua for the
governance terms camayoc’s gate reads, and seeds’ own seeds: namespace only
for tracker mechanics no public vocabulary names. The mapping was ruled in
aegis-bqgdr3 (table v1.1). Item, comment, principal and graph IRIs are seeds’
own and did not change with it.
| prefix | namespace |
|---|---|
schema: | https://schema.org/ |
quechua: | https://scbrown.github.io/quechua/ns# |
ical: | http://www.w3.org/2002/12/cal/ical# |
dcterms: | http://purl.org/dc/terms/ |
prov: | http://www.w3.org/ns/prov# |
seeds: | https://seeds.local/ontology/ |
These are vocabulary names, not hosts anyone contacts.
| field | predicate | object |
|---|---|---|
| type | rdf:type | schema:Action |
| provenance | quechua:sourceKind | "declared" (an agent or person said so) |
| id | schema:identifier | "sd-a3f" |
| title | schema:name and rdfs:label | the same string in both (quipu’s label floor and /search read rdfs:label; the shapes hold them equal) |
| description | schema:description | string |
| status | seeds:status | open, hooked, in_progress, blocked, deferred, closed, tombstone: the field the claim compare-and-set reads |
| public status | schema:actionStatus | DERIVED in the same write: open/hooked/blocked/deferred are schema:PotentialActionStatus, in_progress is ActiveActionStatus, closed with outcome done is CompletedActionStatus, closed with abandoned/superseded/failed is FailedActionStatus, a tombstone has none |
| compare-and-set token | schema:version | integer, 1 at create, +1 per write |
| priority | seeds:priority | integer 0-4 |
| type | seeds:issueType | task, bug, feature, epic, chore, docs, question |
| assignee | schema:agent | principal IRI https://seeds.local/principal/<name> |
| creator, owner | schema:creator, schema:accountablePerson | principal IRI |
| labels | schema:keywords | one fact per label |
| created, updated, closed | schema:dateCreated, schema:dateModified, schema:endTime | xsd:dateTime |
| due | ical:due | xsd:date when a bare YYYY-MM-DD, else xsd:dateTime |
| defer | schema:scheduledTime | xsd:date or xsd:dateTime, as for due |
| estimate | schema:timeRequired | xsd:duration of the minutes, canonical (PT30M, PT1H30M, P1D) |
| close reason | schema:result | string |
| outcome | quechua:outcome | "done" (the default), abandoned, superseded or failed, only when closed |
blocks dependency | quechua:blockedOn | the blocker’s item IRI |
parent-child, related, discovered-from | schema:isPartOf, dcterms:relation, prov:wasDerivedFrom | item IRI |
| notes, design, acceptance criteria, agent context, external ref | seeds:notes, seeds:design, seeds:acceptanceCriteria, seeds:agentContext, seeds:externalRef | string (an external ref need not be a URL) |
| driving workflow run | seeds:workflowRun | a shuttle run IRI, urn:shuttle:run:<id> (Formulas) |
Times are typed, validated and written in canonical form. A value that
is not a valid xsd:dateTime (or, for due and defer, a bare YYYY-MM-DD
xsd:date) is refused with a usage error (exit 2) naming the seed, the field
and the value, and nothing is written. A space instead of T, for example,
is refused, not repaired.
A valid value is written in its XSD canonical spelling, which keeps the value
and changes only the bytes: trailing zeros in fractional seconds go
(…59.014436670Z is …59.01443667Z, .000Z is Z), +00:00 is Z,
24:00:00 is the next day’s 00:00:00, and an estimate of 90 minutes is
PT1H30M. This is the form a quipu server’s /update stores typed literals
in, and seeds computes it with the same code (oxigraph’s oxsdatatypes), so
a local store, a server and a sync between them hold identical bytes. sd cutover reports how many times it respelled (canonicalized_times), and its
export gives br back the original spelling.
A ledger in the old vocabulary is refused, not read as empty. sd 0.1
does not read what sd 0.0.x wrote (aegis:WorkItem, seeds:revision). Every
command first asks whether the project graph holds any seeds:revision fact
(one bounded ASK on a bound predicate; every sd before 0.1.0 wrote it on
every seed), and when it does it refuses (exit 5) naming the store or server
and the graph, with the migration: sd cutover export with the old sd, then
sd cutover import with this one into a new store. That recipe drops any
new-vocabulary seed from a mixed store, and only a pre-0.1.0 sd writing beside
0.1.0 could have made one. sd doctor reports it as a failed
store.vocabulary check with the exact count (a slower COUNT that only
doctor runs), and a pendant in the old vocabulary fails validation
the same way. A count that cannot be read (a query error, no answer, a
malformed or missing number) refuses too: the check fails closed, never as a
clean ledger. A timeout (the first query on a large graph right after a quipu
restart can outrun the server’s query budget) is retried once after 2 s, then
refused with a message saying the store may be warming up.
A comment is its own entity, https://seeds.local/item/<id>/comment/<n>, typed
schema:Comment, with schema:parentItem (the seed), schema:position
(1-based), schema:author (a principal IRI), schema:text and
schema:dateCreated (xsd:dateTime). Comments are append-only.
Who wrote what is kept in a side graph that exports and snapshots never read:
seeds:Write records (seeds:actor, seeds:wrote, seeds:version) and
their seeds:AttributionClaims. That vocabulary did not change.
One deliberate difference from camayoc’s tracker projection. camayoc’s
ingress keeps mutable tracker state (status) on a versioned Observation
rather than on the WorkItem, because its write path (/episode) appends
rather than replaces. seeds is the tracker and writes through quipu’s
transaction API, which retracts the old value and asserts the new one in the
same transaction, so status sits on the seed itself and history is still
complete: every old value stays readable with --at.
Shapes
Every write in the native CLI is validated against
shapes/seeds.shapes.ttl
before it is committed. It is the whole profile from the aegis-bqgdr3 ruling,
not a subset: quipu’s /update does not apply shapes, so on a shared board
this client-side check is the only gate.
quechua:ActionGovernanceShape: exactly onesourceKind, anoutcomefrom the closed list, andblockedOnpointing at an Action the graph actually holds (camayoc’s constraints, carried ontoschema:Action);seeds:SeedShape: exactly one identifier,schema:name(equal to the onerdfs:label), status, priority (0-4), type and version; typed dates; principal IRIs; and ansh:xonethat holds status, outcome andschema:actionStatusin agreement;seeds:CommentShape: one parent seed, one position from 1, one author IRI, one text;seeds:CalendarAkaShape: a calendar to-do (ical:Vtodo) is a separate node linked to its seed byskos:exactMatch. sd writes none today.
A write that does not conform is refused whole (exit 5) and nothing is
written. The wasm build has no SHACL engine (quipu leaves it out of its own
wasm gate), so there QuipuBackend::validates() is false and only the
structural rules are checked by hand: blockedOn and comments pointing at a
real seed, single-valued fields holding one value, and the time forms.
How a write lands
- The verb reads a snapshot of the project.
- It computes the complete post-state of every seed it touches, bumps each one’s revision, and hands the backend a batch saying “based on revision N”.
- The backend re-reads each seed’s current facts, refuses the whole batch with a conflict (exit 4) if any seed is no longer at the revision the writer read, validates the post-state against the shapes, and then writes the difference (retract what changed, assert its replacement) as one quipu transaction.
So a verb that touches several seeds (close a b c, update a b) lands all
of it or none of it, and the transaction id it prints is the cursor for
--at.
Concurrency
Two sd processes writing the same store are serialised by an exclusive lock
on <store>.lock, held from the snapshot read to the commit. The second writer
reads the first’s result, so nothing is lost: eight concurrent
update --add-label processes leave eight labels and revision 9, and six
simultaneous --claims leave exactly one winner and five conflicts
(tests/cli.rs).
The lock is load-bearing, and the tests show it: with the lock removed, both
of those tests fail. The backend’s revision check is atomic only within one
store handle, because quipu’s library API has no way to hold SQLite’s write
lock across a read and a transact. A program that writes the same file
without taking the lock can therefore still race sd. Closing that gap needs
an expected-value precondition on quipu’s own write path, which is the same
primitive the shared-server backend needs (below).
Reads take no lock. quipu keeps the store in SQLite WAL mode, so a reader sees the last committed transaction while a write is in flight.
Why the default is an embedded quipu
The original brief sketched reads through quipu’s SPARQL /query and writes
through /knot on a quipu server. seeds instead embeds quipu as a library
(quipu-ai) with a local store file by default, as the other stack tools do,
and reaches a server only when configured to
(Storage modes):
- A lost-update-free write needs a precondition.
/knothas none./update’sWHEREclause is one, and mode 2 uses it, but it returns no affected count (seeds reads back to confirm), records no actor, and copies the whole store into memory on every call. Embedded, seeds checks the revision and commits in one process, under a lock it controls, for the cost of the seeds that changed. - Out of the box means local.
sd createin an empty directory works with no server, no credentials and no configuration. - The same core runs on wasm32. quipu’s library builds for
wasm32-unknown-unknownwith an in-memory store; see WebAssembly. - Testable. Every verb, the concurrency guarantees and the JSON shapes
are tested against a real quipu store with nothing to stand up; the
remote mode is tested against a real
quipu-server.
The store file is an ordinary quipu store, and a project’s ledger travels as a pendant, quipu’s own share format.
Remote admission profile
shapes/quipu-gate.shapes.ttl is a separate, minimal server admission
profile for schema:Action, schema:Comment, seeds:Write and
seeds:AttributionClaim. It checks fields the item and
write-record producers always emit. It does not replace the complete
client-side profile, migrate data, or change stored class and predicate names. A server enabling vocabulary enforcement must probe every supported
write operation against its complete loaded shape set before deployment.
Write timestamps remain strings because that is the provenance producer’s
contract; item creation times remain typed xsd:dateTime.
Configuration
With no configuration at all, sd uses a local quipu store at
.seeds/seeds.db and creates it on the first write. Nothing needs a server.
Where the graph lives
Two keys, and a store uses exactly one of them:
# .seeds/config.toml (or ~/.config/seeds/config.toml)
[quipu]
store = ".seeds/seeds.db" # a local quipu store file (the default)
# url = "https://quipu.example.org" # OR a shared quipu server
# token_file = "~/.config/seeds/quipu-token" # bearer for writes: USER config only
# trusted_hosts = ["quipu.example.org"] # USER config only: see below
# allow_plain_http_hosts = ["quipu.internal.example"] # USER config only: see below
# signing_key_file = "~/.config/seeds/keys/<session>.key" # signed writes: USER config only
# signing_session = "<session>"
# signing_introducer = "<who registered it>"
[project]
prefix = "sd" # id prefix for new seeds (default sd)
# graph = "https://seeds.local/project/sd" # the project's named graph
[pendant]
# dir = ".seeds/pendant" # keep the ledger in the repo (mode 1)
[sync]
# remote = "https://quipu.example.org" # what `sd sync` exchanges with (mode 3)
Which combination gives which arrangement is in Storage modes.
storeis a path. Relative paths resolve against the directory that holds.seeds/(in the project file) or against the user config directory (in the user file).~/expands to your home directory.urlis anhttp://orhttps://quipu server.- A file (or the environment) that sets both is refused with exit 6. Choose one.
graph, when set, must be a plain absolute IRI; anything that could break out of the SPARQL it is written into is refused (exit 6).- Unknown keys are refused too, so a typo such as
stroefails loudly instead of being ignored.
Precedence
Highest first. A layer is consulted only when every layer above it says nothing about that setting.
| # | layer | location keys | other keys |
|---|---|---|---|
| 1 | flags | --store <path>, --quipu <url> | --graph |
| 2 | environment | SEEDS_QUIPU_STORE, SEEDS_QUIPU_URL | SEEDS_GRAPH, SEEDS_PREFIX, SEEDS_ACTOR, SEEDS_PENDANT_DIR, SEEDS_SYNC_REMOTE, SEEDS_QUIPU_TOKEN, SEEDS_MAX_WRITE_BYTES, SEEDS_MAX_WRITE_CLAUSES |
| 3 | project file | the nearest .seeds/config.toml, walking up from the current directory like git | [project], [pendant], [sync], token_file |
| 4 | user file | $XDG_CONFIG_HOME/seeds/config.toml, else ~/.config/seeds/config.toml | the same |
| 5 | default | a local store at <project>/.seeds/seeds.db | prefix sd |
<project> in the default is the directory holding the nearest .seeds/
(walking up), or the current directory when there is none.
The store location is one choice: the highest layer that names either a
store or a url decides it. A project file that says store wins over a
user file that says url; the environment wins over both; a flag wins over
everything.
A shared server
url is a shared, team-wide quipu server, read and written live over HTTP
(mode 2). Writes send
Authorization: Bearer <token> when a token is configured (SEEDS_QUIPU_TOKEN
or [quipu] token_file); reads send none.
Write provenance
Every write request to the server carries X-Quipu-Client: seeds and up to five
provenance headers, filled from the environment, never typed:
| header | first of |
|---|---|
X-Quipu-Agent | QUIPU_AGENT; SHANTY_AGENT inside an agent session; seeds |
X-Quipu-Harness | QUIPU_HARNESS; claude (CLAUDECODE=1); codex (CODEX_HOME); cli |
X-Quipu-Model | QUIPU_MODEL; SHANTY_MODEL |
X-Quipu-Session | QUIPU_SESSION; CLAUDE_CODE_SESSION_ID |
X-Quipu-Host | QUIPU_HOST; the machine’s hostname |
An agent session is CLAUDECODE=1 or a set CODEX_HOME; outside one,
SHANTY_AGENT is ignored. A cron or service running sd names itself with
the QUIPU_* overrides, e.g. QUIPU_AGENT=nightly-sync QUIPU_HARNESS=cron.
A field that cannot be filled is omitted. Values keep only printable ASCII,
at most 128 characters, so no header can be injected.
A configured URL that cannot be reached is exit 7, “cannot reach quipu at …”. seeds never falls back to a local store when a configured server is down, because the two would then hold different ledgers.
Where the token may go
A cloned repository’s .seeds/config.toml is not yours, so it is not trusted
with your token:
token_file,trusted_hostsandallow_plain_http_hostsare honoured only in your user config (orSEEDS_QUIPU_TOKEN_FILE/SEEDS_QUIPU_TOKEN); a project file that sets any of them is refused.- A server URL that came from a project file gets your token only if its
host is in your
trusted_hosts. A URL you chose (flag, environment, user file) always does. - A token is never sent over plain
http://, except to localhost or to a host your user config lists inallow_plain_http_hosts. That list is for a server on a network you trust that has no TLS. An entry matches exactly: a bare host (quipu.internal.example) admits any port on that host,host:portadmits only that port, and nothing is matched by suffix.
Signed writes: no bearer on the wire
A quipu server that accepts signed writes (POST /knot, /update, /episode)
does not need your bearer at all. sd key init makes an Ed25519 key that never
leaves your machine; its public half is registered once on the server by
someone else (your lead or a human, never you). After that every write carries
an x-quipu-attestation header: a signature over that one request (method,
path, content type, body hash) with a single-use nonce. Nothing in it can be
reused, so plain http:// is no longer an authentication risk, and the server
records the write as yours, not as “whoever holds the token”.
# ~/.config/seeds/config.toml (USER config only; sd key init prints these)
[quipu]
signing_key_file = "~/.config/seeds/keys/seeds-myhost-me.key"
signing_session = "seeds-myhost-me"
signing_introducer = "<lead>"
- A signed write never also sends the bearer.
- A key is used only for a server you would send your token to: a URL from a
project file needs its host in
trusted_hosts. The signature does not name the server, so a server a cloned project chose could otherwise pass your signed write on to yours. /graph/createis signed too. seeds creates a graph only when the server does not already list it (GET /graphs). Against a server too old to accept a signed/graph/create, seeds retries that one idempotent call with your token, if you have one.- The key must be registered with write granted (
--allow-write, orquipu attest allow-write <session>later). A key registered only to trust someone’s shares is refused withscope. - A refusal says which check failed:
skew(fix this machine’s clock),unbound(the key is not registered:sd key show),scope(registered without write),revoked/expired,replay,badsig.
See sd key. To set up an agent end to end (its own user, its key, enrollment on the Quipu host, verification and revocation), follow CABOODLE’s agent identity and write access guide.
The project id
With no graph configured, a project’s graph is
https://seeds.local/project/<prefix>-<id>, where <id> is a random id kept
in .seeds/project-id. It is created on the first write (or first use of a
server) and should be committed: it is what keeps two repositories that
never set a prefix from sharing one ledger on a shared server. A plain read in
a directory that never wrote creates nothing. A local store that exists
without its project id is refused rather than read as empty.
CI
Point a job at a throwaway store with the environment, which beats any config file in the checkout:
export SEEDS_QUIPU_STORE="$RUNNER_TEMP/seeds.db" SEEDS_ACTOR=ci
sd create "smoke" --silent
Where this lives in the code
Configuration is resolved only in the native CLI (src/native/config.rs). The
core never reads a file or an environment variable; it is handed a storage
backend that is already open, which is what keeps it
wasm-clean. The precedence rules above are unit-tested there, and
end to end in tests/cli.rs.
Storage modes
Remote pendant export currently acquires unpinned current facts. It does not
support historical export --at; that request refuses before contacting the
server or creating output. JSON output separates the requested remote graph and
unverified, unpinned source from artifact_origin, whose scope/store/transaction
come from the pendant’s local scratch producer. The manifest is not relabeled or
resealed as remote authority. A current export has no proven cross-query snapshot
receipt; do not use its artifact transaction as a source pin. Historical work-item
reads through show --at remain a separate supported operation.
Where the ledger lives is configuration (Configuration). There are three arrangements, and they compose.
| mode | config | the ledger lives | reads need | writes go |
|---|---|---|---|---|
| local (default) | nothing, or [quipu] store | a quipu store file, .seeds/seeds.db | nothing | the file |
| 1. repo-local pendant | [pendant] dir = ".seeds/pendant" | a pendant committed in the repository, plus a local working store | nothing: clone the repo and you have the board | the working store, then exported to the pendant in the same command |
| 2. remote | [quipu] url = "https://…" | a quipu server | the server | the server, live, over HTTP |
| 3. sync | a local store (with or without a pendant) and [sync] remote = "https://…" | both | nothing | locally; sd sync exchanges with the remote |
In every mode the core is the same: the same verbs, the same WorkItem facts, the same compare-and-set. The native CLI picks the storage; the core never touches a file or the network, which keeps it wasm-clean.
The pendant
A pendant is quipu’s share artifact, used as is. seeds does not have a file format of its own:
| file | what it is |
|---|---|
export.nt | the ledger: every current fact of the project graph as RDFC-1.0 canonical N-Triples, one fact per line, sorted |
shapes.ttl | the shapes the ledger conforms to |
manifest.json, manifest.ttl | the share manifest: hashes of the two files above, the producing store and transaction, the share id |
.gitattributes | how git should merge these files (below) |
export.nt is deterministic: the same ledger always produces the same bytes,
so a change to one seed changes only that seed’s lines, and a pull request that
closes a seed shows exactly the status, closed-at and revision lines that moved.
The manifest names the store that produced it; two clones exporting the same
ledger write identical export.nt files and manifests that differ in that
name. seeds treats export.nt as the data and the manifest as a seal it
re-checks.
The pendant is stamped destination: internal, quipu’s own marker that no
outward identifier scrub ran on it (the scrub needs an identifier-policy
catalogue a seeds store does not carry). The marker is part of the share id, so
it cannot be edited out.
sd export --to <dir> writes a pendant anywhere, from any mode. `sd import
Mode 1: the ledger in the repository
# .seeds/config.toml, committed
[pendant]
dir = ".seeds/pendant"
.seeds/
config.toml commit
pendant/ commit: the ledger
export.nt
shapes.ttl
manifest.json
manifest.ttl
.gitattributes
.gitignore written by sd: keeps the working store out of git
seeds.db the local working store (ignored)
seeds.db.pendant which export.nt the store last matched (ignored)
Every command first reconciles the working store with the pendant, using the marker file as the common base:
| working store since the marker | pendant since the marker | what sd does |
|---|---|---|
| unchanged (or no store yet: a fresh clone) | changed: a pull, a checkout, a merge | loads the pendant into the store |
| changed | unchanged | exports the store to the pendant |
| changed | changed | refuses (exit 4) and says how to choose: sd import <dir> --prefer pendant|store, --replace, or sd export |
A write then exports to the pendant before the command returns, under the same lock. Reading a fresh clone does not touch the pendant files, so a clone’s working tree stays clean until something actually changes.
A loaded pendant is always validated on its own terms, not just checked
against its manifest: every seed must hold exactly one status, priority, title
and version, every time must be a valid xsd:date or xsd:dateTime, every
blocks edge must point at a seed in the ledger, and (in the native build)
the whole ledger must pass the seeds shapes. A pendant that
fails is refused with every problem listed, and the store is left as it was.
Merging branches
Two branches that both changed the ledger merge in export.nt. seeds ships a
git merge driver that merges the two ledgers field by field against their
common ancestor, with the same rules as sd sync:
git config merge.seeds.driver "sd merge-driver %O %A %B"
The pendant’s .gitattributes already names it (export.nt merge=seeds), and
merges the manifests with merge=union (sd re-seals them afterwards).
- One branch closes a seed while the other relabels a different one (or the same one): the driver merges both.
- Both branches set the same field to different values: the driver exits
non-zero, git marks
export.ntconflicted, and the file’s first line is a conflict notice that does not parse, followed by each conflict. sd refuses to load it until a person resolves it. Nothing is decided by picking a side.
Without the driver registered, git falls back to its line merge. That often works too (the file is sorted, one fact per line), and when it leaves conflict markers or two values for one field, sd refuses the result with the line number or the field, exactly as above.
Mode 2: a quipu server
[quipu]
url = "https://quipu.example.org"
token_file = "~/.config/seeds/quipu-token" # if the server wants a bearer for writes
Reads are SPARQL on the server’s /query, paged so the server’s row ceiling
can never silently shorten a snapshot (a page count that disagrees with a
COUNT is re-read, then refused). The everyday commands do not read the
whole graph: show, update (but --parent), close, comments add and
dep add (but parent-child) read
the seeds they name, their comments, and the seeds on the other end of their
edges; ready runs the ready query on the server and reads only the seeds it
returns. list and search select matching subjects and compact sorting
metadata first, then retrieve full seed bodies only for the requested page.
Search matches titles by default; search --full explicitly includes ids,
descriptions and comments and holds a user-wide single-flight lock. Incoming-edge
counts are aggregates, so a page does not load its dependents’ bodies.
count, info and stats use aggregate queries; even mean lead time is
computed on the server, without downloading closed seeds or comments. Local
stores keep the snapshot implementation. --at, recursive ready scopes and
the remaining commands read the whole graph. Writes are one SPARQL Update per command,
shaped as a compare-and-set:
DELETE { GRAPH <project> { <seed> ?p ?o } }
INSERT { GRAPH <project> { …the new facts… } }
WHERE { GRAPH <project> { <seed> schema:version 4 } # the version read
FILTER NOT EXISTS { GRAPH <project> { <new-seed> ?x ?y } }
{ GRAPH <project> { <seed> ?p ?o } } UNION { } }
quipu runs an update under its store lock, so the WHERE clause is an atomic
precondition: if the seed moved, nothing matches and nothing is written. The
server’s /update reports no affected count, so seeds reads the written seeds
back and compares; a mismatch is a conflict (exit 4), never a success. Four
simultaneous --claims against one server leave exactly one winner
(tests/modes.rs).
quipu evaluates /update over a slice containing the facts the update can
match or write when its planner can identify that slice. Unsupported update
forms use the full-store evaluation path. When its configured fact limit is
enabled, exceeding that limit refuses the update. Write cost therefore depends
on the update’s scope and evaluation path, not just the size of the project graph.
Named graphs written by /update are registered after the transaction commits,
so subsequent updates include their facts in the evaluation dataset. seeds also
registers its project graph (POST /graph/create) before its first write.
A configured server that cannot be reached is exit 7. seeds never falls back to a local store, because two stores would then hold two ledgers.
A lost response is looked into, never retried blindly. When /update
fails in a way that leaves the outcome open (a dropped connection, a timeout,
a 5xx), seeds reads the store back. If the write is there, the command
succeeds and says the response was lost; if not, it exits 8
(INDETERMINATE) and names the ids to check, because the write may still
land and a retried create would mint a second seed.
Provenance. quipu records the caller’s declared actor and source on
/update transactions; these declarations are separate from the authenticated
principal. An omitted actor is unknown, and an omitted source defaults to
sparql-update.
seeds also writes a seeds:Write record into a side graph. It links each item
version (id@revision) produced by the write to the caller’s declared agent,
harness, model and session claims. This per-item version mapping is distinct
from transaction-level attribution. The side graph is
<project graph>#seeds-provenance, or <project graph>-seeds-provenance when the
project graph already contains #. Exports and snapshots read the project
ledger, not this side graph.
Tokens. The bearer goes only where you chose to send it: a token (from
SEEDS_QUIPU_TOKEN, SEEDS_QUIPU_TOKEN_FILE or your user config’s
token_file; a project file may not name one) is sent to a server URL from a
project file only if your user config lists its host in trusted_hosts, and
never over plain http:// except to localhost or to a host your user config
lists in allow_plain_http_hosts.
Explicit command-scoped credentials (native Unix)
A trusted launcher can opt in by setting both
SEEDS_QUIPU_CREDENTIAL_PRINCIPAL (a nonsecret declaration) and
SEEDS_QUIPU_TOKEN_FD (an inherited descriptor number, at least 3). The
selected descriptor wins over ambient SEEDS_QUIPU_TOKEN and
SEEDS_QUIPU_TOKEN_FILE; neither ambient credential is read. There is no
QUIPU_AUTH_TOKEN_FILE alias. Project configuration cannot select a descriptor
or executable resolver. The launcher must clear inherited Seeds token inputs
in the child only, clear both selection variables for unselected children, and
must not copy bearer bytes into environment, arguments,
persistent configuration or logs.
The descriptor must refer to an anonymous/unlinked, user-owned regular file, private to its owner, containing 1–4096 printable ASCII bytes without whitespace. An inherited anonymous file works on Linux and macOS; other platforms refuse this selection. Destination trust and transport policy are checked before reading it. The backend reads a bounded duplicate at offset zero and closes that duplicate; the launcher owns and closes its original descriptor. Provider output must remain private and stable until the child has read it. Pipes and linked files are refused. Selection resolves at command/backend creation, not on every HTTP request.
An unavailable, malformed or denied selected credential refuses the command; there is no same-command retry using a shared credential. Blank, whitespace-only or non-Unicode selection metadata is invalid; only both variables being absent means deselection. To explicitly override or return to shared defaults, remove both selection variables before starting the next command and deliberately set that command’s normal token inputs. Seeds cannot infer whether an inherited environment variable was intentionally set. A principal declaration is not proof of the principal authenticated by the server. SDK callers explicitly construct the same native selection contract; the core library does not import process environment or resolve providers. A selected bearer cannot be combined with a signing identity: signed requests authenticate the signing key instead of the selected bearer.
Launcher admission before provider access
Append --credential-admission to the intended command, using the same working
directory, arguments and sanitized child environment. Seeds resolves its normal
configuration but does not execute the operation, open a store, read a token
file/descriptor, create a project id, or make a network request. Even init and
other early-exit commands cannot run under this flag. Only a remote destination
admitted by the native trust/transport policy and compatible with bearer
authentication returns JSON:
{"contract":"seeds-credential-admission/v1","admitted":true,"destination":"https://example.org","destination_binding":true}
Local stores, untrusted project destinations, unsafe transport, signing identities, and URLs with credentials/query/fragment or malformed authority refuse. The launcher must refuse an unsupported CLI or malformed proof before accessing its provider; it must not interpret an open health response as this admission contract.
After fetching its selected credential, the launcher supplies the returned URL
unchanged as SEEDS_QUIPU_CREDENTIAL_DESTINATION, alongside the principal and
descriptor metadata. Native code checks this exact destination again before
reading the descriptor, so an intervening configuration change cannot retarget
the bearer. This optional binding does not change existing explicit descriptor
callers, but a strict provider adapter requires it. Destination metadata without
both selection fields is invalid. Clear all three fields on explicit
deselection; no provider error or server refusal triggers a shared retry.
This is a source protocol, not an installed launcher or authenticated SDK receipt. Providers and launchers remain outside the native CLI; their delivery, private descriptor lifetime, sanitation and authenticated attribution require separate controls.
Mode 3: sync
[pendant]
dir = ".seeds/pendant" # optional
[sync]
remote = "https://quipu.example.org"
sd sync merges the local store and the remote against the ledger as of the
last sync with that remote and graph (kept per pair under
<store>.sync/), writes the merge to the remote, then locally, then exports
the pendant:
- a side that did not change a seed since the base yields to the side that did;
- a seed both sides changed is merged field by field; labels and dependencies take every addition and removal from both sides;
- a field both sides changed to different values is a conflict: every conflict is listed, and nothing is written on either side (exit 4);
- a seed one side deleted and the other changed is a conflict;
- comments are append-only: both sides’ new comments are kept, and a local one that took the same number as a remote one is renumbered after it. A comment the other side already holds under another number (same author, text and time) is recognised as the same comment, so a sync retried after a lost response never duplicates it;
- removals are refused unless
--allow-remote-deletes: no verb deletes a seed, so a seed in the base that one side lacks almost always means that side was reset or is a different store. Sync names the count and writes nothing. Syncing a new remote (no base for that pair yet) pushes the local ledger to it and removes nothing.
An unreadable sync base is an error, never an empty base (an empty base would bring back seeds deleted since).
The remote write is a compare-and-set on the revisions sync read, so a remote
change that lands mid-sync fails the sync cleanly (nothing local is written)
and sd sync can simply run again. This is the br sync analogue: export
is the flush, import is the load, and sync is both with a three-way merge
instead of last-writer-wins.
What each mode does not do yet
- Pins across a pendant.
export.ntcarries current facts only, so--at <tx>history stays in the store that recorded it. quipu’s full reconstruction share could carry history; seeds does not use it yet. - Automatic sync.
sd syncruns when asked; nothing syncs in the background. - Delta pendants. Every export is a full share. quipu’s delta shares
(
delta.ru, a hash chain of updates) would make a pendant’s history verifiable step by step.
WebAssembly
The seeds core builds for wasm32-unknown-unknown, and its test suite runs
there.
What is the core
The crate has two layers:
- the core: the work-item model (
model), the verbs (engine), the ready computation, the JSON output (output), the storage seam (backend) and its quipu implementation (quipu_backend); native(the default feature): thesdCLI on top of it: arguments, configuration, the store file and its write lock, and the system clock.
The core never reads a clock, a file, the environment, a process, a thread or
the network. Time arrives in Ctx::now; storage arrives as a Backend. That
rule is enforced, not just stated: clippy.toml disallows those APIs, and
only src/native/ opts out. The wasm build alone would not catch them,
because most of them compile for wasm32 and fail only at run time.
Storage on wasm
The backend on wasm is the same QuipuBackend the CLI uses, over
quipu::store::Store::open_in_memory(). quipu’s library builds for wasm32 with
default features off (its CI gates exactly that, with an in-memory SQLite and a
wasm clock shim), so seeds did not need a second, hand-written graph backend.
What is missing on wasm is quipu’s SHACL engine, which quipu leaves out of its
own wasm gate: QuipuBackend::validates() is false there, and the one
structural rule that matters most (a blocks edge must point at a real seed)
is checked by hand.
#![allow(unused)]
fn main() {
use seeds::backend::Ctx;
use seeds::engine::{self, CreateReq, ReadyReq};
use seeds::quipu_backend::QuipuBackend;
let mut b = QuipuBackend::in_memory("https://seeds.local/project/sd")?;
let ctx = Ctx { now: "2026-09-30T00:00:00Z".into(), actor: "me".into(), prefix: "sd".into() };
let (a, _) = engine::create(&mut b, &ctx, &CreateReq { title: "a".into(), ..Default::default() })?;
}
Build and test
just wasm # build + lint the core for wasm32, link the demo, print its size
just wasm-test # run tests/core.rs on wasm32 under node
which are:
cargo build --target wasm32-unknown-unknown --lib --no-default-features --release
cargo test --target wasm32-unknown-unknown --no-default-features --test core
.cargo/config.toml supplies the two wasm32-only settings: the
getrandom_backend="wasm_js" cfg quipu’s dependencies need, and
wasm-bindgen-test-runner as the test runner. The runner must match the
wasm-bindgen version in Cargo.lock:
cargo install wasm-bindgen-cli --version "$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"/\1/p')" --locked
tests/core.rs is one file for both targets: each test is a #[test]
natively and a #[wasm_bindgen_test] on wasm32, so the verbs, the ready
computation, pinning and the compare-and-set are proved on both.
The ready_demo example (create, dep add, ready, close, ready) links the whole
core into one module; its release size is what just wasm reports.
Pinning: --at <tx>
Pinning is the clearest case for fact-level versioning.
The idea
In a snapshot-versioned store, “what did this look like before?” means checking out the whole database at an older commit. Everything else rewinds with it.
In seeds, every fact carries the transaction that asserted it and, once replaced, the transaction that retracted it. So “what did this one seed look like at transaction 1042?” is a query over that seed’s facts alone. Nothing else rewinds.
The demo
sd create "Ship the parser" -p 1 # created sd-k2x ... (tx 1042)
sd update sd-k2x --status in_progress # (tx 1043)
sd update sd-k2x --title "Ship the streaming parser" -p 0 # (tx 1051)
sd show sd-k2x # today: P0, "Ship the streaming parser", in_progress
sd show sd-k2x --at 1042 # the pin: P1, "Ship the parser", open
sd ready --json # the project has moved on without it
The pin still resolves to the old state while the rest of the project reads current. That is a fact-level pin.
A pin that crosses teams
When a ledger is shared as a pendant (see Sharing a ledger), a pinned reference is three things:
- the payload hash of the shared ledger;
- the transaction id;
- the seed’s IRI.
Together they name exactly one version of one work item, in another team’s ledger, without either team exposing the rest of its history.
Status
--at <tx> works on every read (show, list, ready, count,
dep list, comments list). Every write prints its transaction as (tx N).
A write with --at is refused (exit 2): a write always applies to the current
state. tests/core.rs (at_pins_a_seed_to_an_earlier_transaction) and
tests/cli.rs run the demo above.
The cross-team pin (payload hash plus transaction plus IRI) requires a verified source snapshot receipt; current remote pendant export is unpinned and cannot supply that receipt.
Sharing a ledger as a pendant
A project is one named graph, and that graph is the ledger. Seeds exports it as a Quipu pendant, a directory of share files, and imports another pendant into the configured store.
Export and import
sd export --to ledger-pendant
sd import ledger-pendant
The export carries export.nt (the ledger), shapes.ttl (its constraints),
manifest.json and manifest.ttl (producer, transaction and hashes), and
.gitattributes for Git merging. Seeds exports data and shapes; its readiness
logic remains in seeds. Quipu can separately carry registered stored queries
in queries.ttl; that does not make seeds’ ready query a registered query.
A seeds pendant is marked destination: internal: no outward identifier
scrub ran. Export is not permission to publish private comments or internal
names. Review what leaves before sharing it outside its original scope.
Reconcile a repository ledger
Configure [pendant] dir to keep the ledger beside the code. Every command
reconciles the working store with that directory. When both changed since
last matching, seeds refuses to choose a side silently. An explicit
sd import <dir> --prefer pendant|store resolves that choice;
--replace instead makes the store match the pendant, removing absent seeds.
See Storage modes for the configuration, manifest handling
and the three-way merge rules.
The local ledger can retain its history while the exported pendant contains
current facts. A remote export acquires unpinned current facts: it refuses
export --at, and the manifest transaction belongs to the local scratch
producer, not a historical remote snapshot. Do not use it as a cross-team pin.
Historical work-item reads such as show --at are a separate capability.
The verbs
| verb | writes | page |
|---|---|---|
init | yes (files) | init |
create | yes | create |
show | no | show |
list | no | list |
ready | no | ready |
search | no | search |
stale | no | stale |
stats (alias status) | no | stats |
q | yes | q |
query, upgrade, gate, scheduler, audit, robot-docs | no (exit 21, pointer) | elsewhere |
count | no | count |
update | yes | update |
close | yes | close |
reopen, defer, undefer | yes | reopen, defer, undefer |
delete | yes (tombstone) | delete |
blocked | no | blocked |
label add, label remove, label rename, label list, label list-all | add/remove/rename | label |
graph | no | graph |
history | no | history |
changelog | no | changelog |
lint | no | lint |
orphans | no | orphans |
epic status, epic close-eligible | close-eligible | epic |
dep add, dep remove, dep list | add/remove | dep |
comments add, comments list | add | comments |
version, where, info | no | version, where, info |
config list, get, path | no | config |
capabilities | no | capabilities |
schema | no | schema |
doctor | no | doctor |
key | no | key |
completions | no | completions |
export, import, sync, merge-driver | import, sync, merge-driver | export, import, sync |
Behaviour change (#41):
listandsearchhide deferred seeds unless--deferred,--allor--statusis given, as the upstream tracker does. Earlier versions listed them by default.
Every verb accepts the global options, and
every read accepts --at <tx>. A verb that writes prints the transaction it
wrote as (tx N) in text mode and as tx in --json (on the object, or on
each seed of a bare array); that number is what --at takes. A --dry-run
writes nothing, so its tx is null. Against a quipu server a write’s
tx is also null today: quipu’s /update returns no transaction id
(aegis-xajsgn). --at reads still work there, and history
recovers each version’s transaction from them.
--json output is a contract: every documented key is always present (an
absent value is null, never a missing key), and tests/json_schema.rs pins
the key sets. Errors in --json mode print {"error": {"code", "message"}}
on stdout and exit with the code from Reference.
Cutover: lossless beads JSONL exchange
sd cutover rehearses a migration on a copy of a beads JSONL board. It
requires an explicit local --store and --graph and refuses a configured
pendant or remote destination. It never opens or updates a br database.
Keep br authoritative until the migration owner approves the cutover.
sd --store rehearsal.db --graph https://example.org/project/trial \
cutover verify --file board-copy.jsonl
sd --store rehearsal.db --graph https://example.org/project/trial \
cutover import --file board-copy.jsonl --dry-run
sd --store rehearsal.db --graph https://example.org/project/trial \
cutover import --file board-copy.jsonl
sd --store rehearsal.db --graph https://example.org/project/trial \
cutover export --file returned.jsonl
verify imports into an isolated in-memory Quipu store, reads the committed
facts back and compares JSON by exact record ID and field path. Its JSON
report includes input and output counts, a difference count and every changed
field, including nested comment and dependency metadata. Nonzero differences
exit 1. Object key order is immaterial; array order and absent versus null
remain significant. Malformed records and duplicate IDs are refused.
Each imported seed carries its original JSON in a seeds:beadsJson fact.
Normal seed fields are also projected to the seeds vocabulary
(The storage model); a time that is not a valid
xsd:date/xsd:dateTime refuses the import, naming each record and field.
A valid time is stored in its canonical spelling (the same instant); the
output’s canonicalized_times counts them, and export restores br’s original
spelling.
Export overlays edits to those modeled fields on the original JSON. Unknown
fields, dependency types, timestamps, author names and nested metadata survive.
The legacy relates-to dependency projects as related, retaining its original
spelling on export. hooked is retained as a non-ready status. Unknown issue
types are retained by the bridge even when the create CLI does not offer them.
Unsupported dependency types are carried, not interpreted as blockers.
Seeds-authored items include a _seeds extension holding the revision and
unmodeled RDF facts (including literal datatypes and languages). Comments carry
their graph indexes there too. An occupied foreign _seeds key is refused when
it would be overwritten. A third-party importer must retain that extension to
preserve those facts; this command does not change third-party import behavior.
Historical br transactions absent from the source JSONL cannot be reconstructed.
New native dependencies retain their creation actor and time in carried
seeds:dependencyOrigin facts. Later item edits do not change edge attribution;
removing and re-adding an edge records its new creation. Imported dependency
metadata remains verbatim, including deliberate peer corrections. A legacy native
edge with neither carried JSON metadata nor creation provenance refuses export:
the item creator and its last update are not evidence of who created that edge.
Reconcile its provenance explicitly before using it in a cutover.
Newly synthesized label/dependency unions use br’s canonical ordering. Unchanged imported arrays retain their original order.
br comment IDs are global integers; a seeds comment index is local to its item.
The native bridge reserves global IDs in
<store>.cutover-<graph-sha256>.comments.json, bound to the store path and graph.
It retains imported IDs and maps each new (item ID, comment index) to an
unused positive integer. The high-water mark and deleted slots stay reserved.
The map is locked and saved before publishing an export, so another process or
a later export destination retains the same identities. Keep this sidecar with
the store when backing it up; do not delete it to reset a conflict. A malformed
map, identity collision or exhausted integer sequence refuses publication.
Concurrent independently allocated IDs may still conflict and require explicit
reconciliation; they are never silently reassigned.
Ongoing exchange on a copied workstream
sd --actor migration-operator --store rehearsal.db \
--graph https://example.org/project/trial \
cutover sync --file board-copy.jsonl --base trial.cursor.json --dry-run
# Inspect the would-change report, then repeat without --dry-run.
The cursor holds the last common JSON state and is bound to the store path,
graph and JSONL path. Reusing it for another pair refuses. A missing cursor
means a first exchange, not permission to resolve conflicting existing items.
A corrupt or unreadable cursor is an error. Record removals refuse unless
explicitly enabled with --allow-deletes. Import is additive and refuses
changed existing IDs; use sync to reconcile updates against a common base.
Sync compares each field with that common state. Independent edits merge;
conflicting scalar edits report their record IDs and fields and write nothing.
Labels, dependencies and comments union on simultaneous changes. updated_at
keeps the later UTC timestamp when both sides changed it, including mixed
fractional precision. Non-UTC concurrent timestamps require reconciliation. Unmodeled scalar fields have the
same conflict protection as modeled ones. An unchanged second sync does not
commit or replace the JSONL or cursor.
Each store commit records the supplied --actor and source=beads-sync.
Original creator and comment attribution are carried unchanged. A JSONL export
does not identify the actor of a later edit; the caller must supply that actor,
not infer it from created_by.
Writes hold the normal sd store lock plus a cursor lock and a JSONL sidecar
lock (<file>.cutover.lock). Any process producing that JSONL must cooperate
with that lock. Do not point this command at a live br export: br’s exporter
does not acquire the cutover lock. A pre-publication content check catches
changes observed during planning, but cannot fence an uncooperative writer.
A durable <cursor>.pending journal is written before committing the store.
It carries both pre-images and the intended common state. After a failure,
repeat the identical sync: it completes publication if both sides still match
an expected state. Newer edits cause a refusal that preserves the journal for
reconciliation. The cursor advances last. This makes interrupted publication
recoverable; it does not make two independent stores one atomic transaction.
Keep the cursor and pending journal private, like the board itself.
--dry-run creates no store, lock, journal, identity map or cursor. Export dry-run reports
its count and destination without writing a file.
This is an explicit one-shot operation. It installs no scheduler or fleet cutover. A trial runner must state its polling interval, maintain a complete rollback br copy, verify both directions and observe the required soak before any production switch. A successful isolated round trip is one gate, not a certificate that the fleet has migrated.
Sync reports aggregate field counts and the first 100 differences for each
side, with an explicit truncation flag. Use verify for a complete round-trip
difference report. Dry-run also validates the planned graph against the same
shapes as a real commit.
sd init
Make the current directory a seeds project.
sd init --prefix ab
sd init --force # restore missing files; never changes the id or prefix
- Writes
.seeds/config.toml(with[project] prefix),.seeds/project-idand.seeds/.gitignorein the current directory. A parent project is not consulted. The store itself is created by the first write. - The ledger a project writes to is named by its prefix and project id
together. Changing either moves the project to a different, empty ledger
and strands every seed already written. So:
initin a project that already has an id is refused (exit 4) and writes nothing.--forceonly restores missing files. It keeps the existing id, and asking it to change a configured prefix is refused (exit 5).
- Commit
config.tomlandproject-id. - The default prefix is
$SEEDS_PREFIX, elsesd. - Explicit
--store,--quipuand--graphoverrides are refused (exit 2) before any files are written.initconfigures the current directory; it does not provision a store or a remote project.
--json: {path, prefix, project_id, graph, created, kept}.
sd create
Create a seed.
sd create "Write the parser" -p 1 -t feature -l parser,core
sd create --title "Child task" --parent sd-a3f # minted as sd-a3f.1
sd create "Blocked work" --deps sd-a3f,related:sd-b7c
| flag | meaning |
|---|---|
TITLE / --title | the title (one of the two, not both) |
-t, --type | task (default), bug, feature, epic, chore, docs, question |
-p, --priority | 0-4 or P0-P4 (default 2) |
-d, --description / --description-file PATH | the description |
-a, --assignee | assignee |
--owner | who owns the work (br’s owner, usually an email) |
--acceptance-criteria TEXT (alias --acceptance) | acceptance criteria, often a - [ ] checklist |
--external-ref REF | a reference to the same work elsewhere (br’s external_ref) |
--agent-context JSON | governing instructions for an agent (br’s agent_context): inline JSON, @path (JSON) or @path.yaml/.yml (normalized to JSON). Stored as compact JSON with key order kept; "" leaves it unset. YAML keys become JSON strings, so 1 and "1" are one key (the last wins) |
--ephemeral | keep the seed out of the shared ledger: seen by every read here, never synced, exported or ready (br’s --ephemeral; see Storage). A shared seed cannot depend on it |
--slug SLUG | embed a slug in the id, <prefix>-<slug>-<hash>: lowercase ASCII letters, digits and single hyphens, at most 48 characters (br’s --slug). Ignored with --parent; refused with --step |
--due WHEN | due date: +1d, tomorrow, YYYY-MM-DD or an RFC 3339 instant (br’s due_at). A bare date and tomorrow mean 09:00 local time, stored as a UTC instant, as br does |
-e, --estimate MIN | time estimate in minutes, 0 to 525960 (br’s estimated_minutes) |
-l, --labels | comma-separated labels |
--parent ID | parent seed; the new id is <parent>.<n> |
--deps | comma-separated: ID (a blocks dependency) or TYPE:ID |
--workflow-run RUN | the shuttle run that creates or drives it: a run IRI, or a bare id (becomes urn:shuttle:run:<id>); see Formulas |
--step STEP | the workflow step creating the seed (needs --workflow-run). The id is derived from run, step and visit (<prefix>-w<hash>), so repeating the create returns the same seed (exit 0, exists ...) instead of a duplicate, even when two writers race |
--visit N | which entry into --step this is, from 1 (default 1); a step the run enters again gets a new seed |
--dry-run | print what would be created, write nothing |
--silent | print only the new id |
Ids are <prefix>-<hash>: the prefix from configuration
(sd by default), then three or more base36 characters from a hash of the
title, the creation instant and an attempt counter. A collision tries the next
attempt, and the hash grows by one character every four collisions, so ids stay
short and unique. No randomness is involved.
For one title and a body file, use sd create "Title" --description-file body.md.
Combining a title with --file is refused with that corrected command;
--file keeps its batch-import meaning.
-f, --file FILE creates every item of a markdown file in one
transaction (br’s bulk import). Each ## Title starts a seed; under it,
### Priority, ### Type, ### Labels, ### Assignee, ### Parent, ### Dependencies,
### Description, ### Notes, ### Design and ### Acceptance Criteria set
its fields, and the text between the
title and its first ### is the description. Anything before the first ##
is ignored, and headings inside fenced code blocks are text. Two differences
from br, both so that nothing is dropped silently: sd keeps the whole body
(br keeps only its first paragraph), and a section sd does not know, or an
item with both a body and a ### Description, refuses the file (exit 2) and
writes nothing.
--file takes no per-seed flags; --dry-run, --silent and --json apply.
--json is an array of the new seeds.
--json: the new seed object (the keys under list) plus tx
(null for --dry-run).
Exit codes: 0; 2 for a bad flag value; 3 when --parent or a --deps
target does not exist; 5 when the shapes refuse the write.
A file can create an epic and its children together. @N in Parent or
Dependencies names the Nth item in that file (one-based); forward references
are supported. Ordinary IDs still refer to existing seeds. Cyclic Parent
references and invalid references refuse the whole file.
## Release epic
### Type
epic
## Implementation
### Parent
@1
### Assignee
owner
### Labels
release,implementation
## Verification
### Parent
@1
### Dependencies
@2
Bulk creation reads only referenced seeds and candidate IDs, then commits one revision-checked batch. A concurrent ID collision refuses atomically; it does not overwrite another writer. Gateway 502/503 responses to health and standard board read requests (query, graph discovery and historical transaction lookup) retry at 1, 2, 4 and 8 seconds (five attempts maximum). Experimental acquisition keeps its separate whole-operation deadline. Writes are never automatically replayed: an uncertain write is reconciled by exact read-back, preserving the existing UNKNOWN/conflict contract.
sd show
Show one or more seeds with their dependencies, dependents and comments.
sd show sd-a3f
sd show sd-a3f sd-b7c --json
sd show sd-a3f --at 12 # as it stood at transaction 12
--json: an array, one object per id, with every seed key
plus:
dependencies: what this seed depends on, each{id, title, status, priority, dependency_type};dependents: what depends on this seed, same shape;comments: each{id, issue_id, author, text, created_at}.
Exit codes: 0; 3 when any id does not exist (nothing is printed for the others).
sd list
List seeds. Closed seeds are hidden unless you ask for them (--all, or
--status closed).
sd list
sd list --status in_progress --assignee ian
sd list -l parser -l core --limit 0 --json
| flag | meaning |
|---|---|
-s, --status | only this status |
-t, --type | only this type |
--assignee NAME / --unassigned | only this assignee / only unassigned |
-l, --label | only seeds carrying this label (repeatable; all must match) |
-p, --priority | only this priority |
-a, --all | include closed seeds |
--limit N | page size, default 50; 0 lists everything |
--sort | priority (default), created, updated, id, title |
A list cut short by --limit says so: text mode ends with
(showing N of M; --limit 0 shows all), stderr carries the same warning, and
--json carries has_more: true.
--json: br’s envelope, {issues, total, limit, offset, has_more}. Each
issue is a seed object with these keys:
id, title, description, notes, status, priority, issue_type,
assignee, labels, created_at, created_by, updated_at, closed_at,
close_reason, defer_until, parent, dependency_count, workflow_run,
revision.
In list, search and blocked envelopes each issue also carries br’s
dependent_count: how many seeds declare any dependency on it (blocks,
parent-child, related or discovered-from).
revision is the compare-and-set token (1 at create, +1 per write).
Filters (also on search): --title-contains,
--desc-contains, --notes-contains (case-insensitive), --label-any
(repeatable, any of), --priority-min/--priority-max, --id (repeatable),
--overdue (due before now and not closed, as br; deferred seeds stay hidden
unless --deferred).
Paging: --offset N skips N results (the envelope’s offset and
has_more account for it); -r, --reverse flips the sort.
Deferred seeds are hidden by default, as in br: pass
--deferred(or--all, or--status deferred) to see them. Earlier versions of sd listed them by default.
--defer-until-present selects items that carry a deferral date field,
including past or malformed values. It does not interpret the date or change
status. Combine it with --status open to find date-based deferrals without
reading every open item’s body; status-based deferrals still need their own
--status deferred query.
sd ready
List seeds that are ready to work on: status open, no blocks dependency on
a seed that is not closed, and no defer date in the future. Sorted by priority,
then explicit rank, then due date for tied ranks. Unranked items follow ranked
ones within each priority, oldest first. --sort priority retains age ordering.
sd ready --sort priority,rank --json
sd ready --assignee # mine (the current actor)
sd ready --unassigned -l parser
ready is not truncated. Without --limit it prints every ready seed.
With --limit N it prints N and warns on stderr:
ready: TRUNCATED to N of M ready seeds by --limit. A silently short ready
list reads as “nothing else is ready”, which is the wrong answer, so seeds
never produces one.
| flag | meaning |
|---|---|
--limit N | cut the list at N, and say so |
--assignee [NAME] | only this assignee; bare --assignee means the current actor |
--unassigned | only unassigned |
-l, --label | only with this label (repeatable) |
-t, --type, -p, --priority | only this type / priority |
--parent ID | only children of this seed |
The definition is a query. The core runs the SPARQL in
seeds::vocab::ready_query against the project graph, then applies the
filters and the defer date. The same definition is also computed directly
over the model, and tests/core.rs checks the two agree after every step of a
scripted sequence of dependency changes, closes and reopens.
Only blocks dependencies gate readiness; related, parent-child and
discovered-from do not.
--json: a bare array of seed objects, as br prints it.
Remote enumeration refuses a ready set that reaches the server’s bounded row ceiling rather than treating it as a complete result. Narrow the filters if that ceiling is reached; a refusal is an unreadable set, not an empty one.
sd search
Find seeds by title, with an explicit option for full-field search.
sd search lexer
sd search "chokes on" -t bug --all --json
sd search "comment phrase" --full --all
- By default, matches titles only, case-insensitively. Every result names this scope; an empty result says 0 title matches. Descriptions and comments may still contain the query.
--fullalso searches ids, descriptions and comments (notes are not searched). This administrative operation can read much more data. One full search at a time is permitted per user on a host, across all projects; a concurrent full search is refused rather than queued. Title searches share a two-slot host-user budget;--fulltakes both slots. If the budget remains busy for 30 seconds, a search refuses withboard search busy. Wait/refuse events are appended to$HOME/.config/seeds/search-admission.jsonlfor observation; failed telemetry does not weaken admission.- Closed matches are hidden and counted unless
--allor--status: the text says how many, and--jsoncarrieshidden_closed_count. - Takes the list filters (
-s,-t,--assignee,--unassigned,-l,-p),--sort, and--limit(default 50, as br; 0 = all). A cut-short page says so.
--json: br’s envelope {issues, hidden_closed_count, limit, offset, has_more}, plus total. Title mode adds search_scope, search_notice and
counts: "not_computed"; its dependency_count and dependent_count values
are null, never a measured zero. Human output names this omission. --full
keeps the original JSON envelope and exact counts. CSV results keep
their tabular stdout and report the scope on stderr.
Filters (also on search): --title-contains,
--desc-contains, --notes-contains (case-insensitive), --label-any
(repeatable, any of), --priority-min/--priority-max, --id (repeatable),
--overdue (due before now and not closed, as br; deferred seeds stay hidden
unless --deferred).
Paging: --offset N skips N results (the envelope’s offset and
has_more account for it); -r, --reverse flips the sort.
Deferred seeds are hidden by default, as in br: pass
--deferred(or--all, or--status deferred) to see them. Earlier versions of sd listed them by default.
Remote full-field search
sd search TERM retains its title-only default. Explicit --full additionally
matches identifiers, descriptions and valid comments, with the same filters,
closed-item counts, sorting and pagination as the snapshot implementation.
For current remote reads, the server must support the explicit substring-index contract and have a complete compatible index. A cheap readiness request precedes text selection. Each subsequent request requires the same contract; a missing acknowledgement or unavailable index refuses rather than materializing a board snapshot. No positive or negative readiness result is cached between searches. Terms shorter than three Unicode characters after lowercasing, or containing NUL, refuse before query evaluation because this index cannot accelerate them. Default title-only searches retain support for short terms.
The backend projects matching subjects before joining compact metadata, validates comment parents and integer positions, and deduplicates matching comment parents before item eligibility checks. Main and ephemeral graphs retain their effective shadowing rules. Only the selected page’s item bodies are hydrated.
An operator must explicitly prepare the optional index using the matching Quipu maintenance tooling. A source update does not authorize production backfill. Dropping it makes requiring searches refuse; ordinary title searches retain their existing contract. Historical reads retain the existing snapshot read-cost gate.
The public synthetic demo and protocol contract live in Quipu’s
docs/probes/pr-demos/substring-search/ and
docs/book/src/reference/substring-search.md.
sd stale
Find seeds nobody has touched in a while.
sd stale # not updated for 30 days
sd stale --days 7 --status open,in_progress --json
- Lists seeds whose last update is at least
--daysdays old (default 30), oldest first.--days 0lists everything not closed. - Closed seeds are left out unless
--statusnamesclosed.--statusis repeatable or comma-separated.
--json: a bare array of seed objects, as br prints it.
sd stats
Summary counts for the whole ledger, with optional breakdowns.
sd stats
sd stats --by-type --by-priority --by-assignee --by-label --json
- Summary: total, and counts by status (
blockedcounts the status, as br does, not the dependency graph), whatreadywould list now, open epics whose children are all closed, and the mean hours from creation to close over closed seeds. - Breakdowns count every seed, closed included, as br does: by type,
priority (
P0..P4), assignee ((unassigned)) and label ((no labels)). - seeds has no drafts, tombstones or pins, so those counts are always 0.
br’s activity flags are not implemented: br’s
--jsondoes not report activity either, and accepting a flag that does nothing would mislead.
--json: br’s {summary: {total_issues, open_issues, ..., average_lead_time_hours}, breakdowns: [{dimension, counts: [{key, count}]}]};
breakdowns appears only when one is asked for.
sd q, status, and the verbs that live elsewhere
sd q fix the flaky parser test -p 1 -l ci # prints only the new id
sd status # the same as sd stats
qis quick capture: the title words are joined, and only the id is printed (in--json,{id, title, tx}). It takes-p,-t,-l(repeatable, comma-separated allowed),-d/--body,--parentand-e/--estimate(minutes).statusis an alias of stats, as in br.
br verbs that are not sd’s
Some br verbs belong to another tool of the stack. sd accepts them, does
nothing, prints where the capability lives on stderr, and exits 21
(ELSEWHERE), so a redirect from bd/br records the attempt instead of
failing silently or pretending:
| verb | where it lives |
|---|---|
query | quipu stored queries |
upgrade | caboodle (caboodle update-release --tool seeds) |
gate, scheduler | shuttle |
audit | quipu provenance: every sd write is a transaction with its actor; read past states with sd show --at <tx> |
robot-docs | this book and sd <verb> --help |
sd count
Count seeds (closed ones only with --include-closed or --status closed).
sd count
sd count --by status --include-closed --json
| flag | meaning |
|---|---|
--by | group by status, priority, type, assignee or label |
--status, --type, --assignee | filters |
--include-closed | count closed seeds too |
--json: {"count": N}; with --by, {"total": N, "groups": [{"group", "count"}]}. With --by label a seed counts once per label, so the groups
can sum to more than the total.
--by-status, --by-priority, --by-type, --by-assignee and --by-label
are br’s shorthands for --by <x> (one grouping at a time). --priority,
--title-contains and --unassigned filter what is counted.
sd update
Change one or more seeds. All the named seeds change in one transaction, or none do.
sd update sd-a3f --status in_progress --add-label parser
sd update sd-a3f --claim # atomically take it
sd update sd-a3f sd-b7c --priority 0
sd update sd-a3f --defer 2026-10-15 # hide from ready until then
| flag | meaning |
|---|---|
--title, -d, --description, --notes, --design, --acceptance-criteria (alias --acceptance) | set the text (see below for replacing existing text) |
--force | allow replacing a non-empty description, notes, design, acceptance criteria or agent context with different content |
-s, --status | open, in_progress, blocked, deferred, closed |
-p, --priority | 0-4 or P0-P4 |
--assignee NAME | set the assignee ("" clears it) |
--owner WHO | set the owner ("" clears it) |
--external-ref REF | a reference to the same work elsewhere, br’s external_ref ("" clears it) |
--agent-context JSON | br’s agent_context: inline JSON, @path or @path.yaml/.yml, stored as compact JSON. "" clears it; replacing or clearing a non-empty one needs --force |
--due WHEN | due date, the forms of --defer; a bare date or tomorrow is 09:00 local, stored as a UTC instant ("" clears it) |
--estimate MIN | time estimate in minutes, 0 to 525960 |
--check-acceptance ITEMS | tick acceptance checklist items in place: 1-based numbers (1,4,5) or a text selector matching exactly one item (case-insensitive; an exact match wins, else a unique substring). Repeatable; needs no --force; the rest of the field is kept byte for byte |
--uncheck-acceptance ITEMS | untick items; the same selectors |
--add-acceptance TEXT | append - [ ] TEXT to the checklist (repeatable). Checklist edits and --acceptance-criteria are not combined in one update |
--claim | set assignee to the actor and status to in_progress, only if the seed is open, unclaimed and unblocked |
--add-label, --remove-label | repeatable |
--defer DATE | hide from ready until this date or instant ("" clears it) |
--workflow-run RUN | the shuttle run driving it ("" clears it); see Formulas |
--claim is a compare-and-set. It succeeds for exactly one caller: a
second claimer, or a claim on a blocked or non-open seed, exits 4 and
writes nothing. Claiming your own claim again is a no-op. The actor is
--actor, else SEEDS_ACTOR, else $USER.
Setting status to closed records closed_at; setting it back to anything
else clears closed_at and close_reason. An update that changes nothing
writes nothing.
Explicitly setting a status other than deferred (or using --claim) also
clears an old defer date and retires resume_when: notes markers to
historical resume condition: in that same transaction. The condition and
surrounding evidence remain in the notes. This also repairs stale parking
metadata when the requested status already matches. A non-empty --defer
in the same request deliberately establishes a new hold and preserves its
resume marker. Edits without a status or claim preserve existing holds.
Existing text is not replaced by accident. Setting a description, notes,
design, acceptance criteria or agent context
that already has text to different text (including "") is refused with
exit 5, naming the field, and nothing is written, unless --force is
given. This is br’s guard (br’s --force). Filling an empty field, or setting
the same text again, needs no --force, so re-running an update is safe. The
replaced text stays in the seed’s history (sd history). The check runs in the
shared update path, so a local store and a quipu server refuse alike.
--json: an array of the updated seed objects, each with the tx.
Exit codes: 0; 2 for a bad value or --claim combined with --assignee
or --status; 3 for an unknown id; 4 for a lost claim or a concurrent change;
5 for replacing existing text without --force.
sd update ID -a owner is the short form of --assignee owner.
Rank within a priority
sd update ID --rank 10 assigns an exact decimal ordering key; --rank ""
clears it. --before OTHER and --after OTHER insert only ID beside a ranked,
live item of the same priority, without editing neighbors or deadlines. Moves
accept one ID and cannot change priority or claim work in the same command.
Ranks are canonical decimal strings in JSON (null when absent), with up to nine
fractional digits and magnitude at most 9000000000000000000. No binary floating
point is used. Tied anchors, exhausted precision and boundary exhaustion refuse
without renumbering. Explicit rank assignments may tie; ordering then uses due
date, creation time and ID. Moving is relative to the observed cohort: concurrent
independent moves may receive the same rank. The moved item’s revision is checked
atomically; neighbors are never rewritten.
sd ready defaults to priority,rank; sd list --sort priority,rank requests the
same order. Unranked items sort last within their priority, oldest first. Due
dates remain time limits and are never changed by ranking.
A reviewed bulk migration can use scripts/migrate-ranks.py. Its plan is a JSON
object with quipu, graph and rows; each row names id, canonical rank
(or null), and expected priority, revision, rank and due_at. The default is a
read-only rehearsal. Execution requires --execute --plan-sha256 SHA; every edit
uses --if-revision and touches only rank. The receipt records each completed
edit and a revision-guarded rollback plan. A refusal or unknown outcome stops
without retry or automatic rollback. Inspect pending_id before continuing.
Rehearsal and execution need distinct receipt paths; an existing receipt is
never overwritten. Do not derive ordering from current deadlines implicitly:
review an explicit order and its current target preconditions first.
sd close
Close one or more seeds, in one transaction.
sd close sd-a3f --reason "parser merged in 4d3ac73; 18 tests green"
sd close sd-a3f sd-b7c -r "superseded by sd-c9d"
| flag | meaning |
|---|---|
-r, --reason | why: what landed and how you know |
--outcome | how it ended: done (default), abandoned, superseded or failed. the governed quechua:outcome, stored as a field and shown as outcome in --json, so a workflow branches on it instead of parsing the reason. Reopening clears it |
-f, --force | close even though a blocks dependency is still open |
- Closing without
--reasonwarns on stderr and still closes. The reason is what later readers search, and it cannot be improved after the fact without another write. - A seed with an open blocker is refused (exit 5) unless
--force. - A seed that is already closed is left unchanged, with a warning.
- Closing records
closed_at, the reason, and camayoc’soutcome: done.
--json: an array of the closed seed objects, each with the tx.
sd reopen, defer, undefer
Status transitions br has as their own verbs.
sd reopen sd-a3f -r "the fix regressed in 9c1e2f0"
sd defer sd-a3f sd-b7c --until +1w
sd defer sd-a3f --until 2026-11-01
sd undefer sd-a3f
reopen: closed seeds becomeopen, andclosed_atandclose_reasonare cleared.-ris stored as the commentReopened: <reason>in the same transaction as the status change. A seed that is not closed is skipped.defer: statusdeferred, so the seed leavesready.--untiltakes+30m,+2h,+1d,+1w,tomorrow,YYYY-MM-DDor an RFC 3339 instant (relative forms resolve to UTC). Without--untilthe seed is deferred with no date. Closed seeds are skipped.undefer: deferred seeds becomeopenwith no date. Others are skipped.- Every named seed changes in one transaction; an unknown id is exit 3 and nothing is written. Skipped seeds are reported with br’s reason, never silently.
--json: {"reopened" | "deferred" | "undeferred": [{id, title, previous_status, status, defer_until?}], "skipped": [{id, reason}], "tx"}.
skipped appears only when something was skipped, as in br.
sd delete
Delete seeds. A deleted seed becomes a tombstone: hidden from every
listing and count, but kept, so sync carries the delete like any other change
and --at still reads what was there.
sd delete sd-a3f --reason "duplicate of sd-b7c"
sd delete sd-a3f --dry-run # what would go
sd delete sd-a3f --cascade # and everything that depends on it
sd delete sd-a3f --force # leave dependents pointing at a tombstone
- A seed that other live seeds depend on is not deleted without
--cascadeor--force: sd prints br’s preview, writes nothing, and says so on stderr. --cascadetombstones every live seed that (transitively) depends on the target.--forcedeletes only the target; its dependents are reported as orphaned, and since a tombstone never blocks, they are not stuck.- A tombstone cannot gain a dependency in either direction, and
sd update --status tombstoneis refused:deleteis the only way in. - A delete is not a close: no outcome, no
closed_at. The reason is kept as the commentDeleted: <reason>, written in the same transaction. showstill returns a tombstone;list,ready,search,count,stale,blocked,epicandstatsleave it out (statsreports how many astombstone_issues).--status tombstonelists them explicitly.sd synccarries a delete in both directions without--allow-remote-deletes: to sync, a tombstone is an update, not a removal.
--json: br’s {deleted, deleted_count, dependencies_removed, labels_removed, events_removed, references_updated, orphaned_issues} plus
tx; a preview prints br’s {preview: true, would_delete, cascade_delete, blocked_dependents, orphaned_issues}.
sd blocked
List seeds that are waiting on something.
sd blocked
sd blocked -t bug -t task -p 0 -p 1 --limit 0 --json
| flag | meaning |
|---|---|
-t, --type | only these types (repeatable: any of them) |
-p, --priority | only these priorities (repeatable: any of them) |
-l, --label | only seeds with this label (repeatable: all must match) |
--limit | page size (default 50, as br; 0 = all) |
- A seed is blocked when it is not closed and has at least one open
blocksdependency. Ablockedstatus on its own does not qualify: that is br’s rule. - Closing the last open blocker takes a seed off the list.
- A cut-short page says so, in text and as
has_morein--json.
--json: br’s envelope {issues, total, limit, offset, has_more}; each
seed also carries blocked_by (the open blocker ids) and blocked_by_count.
sd label
Add, remove, rename and list labels. The argument shapes are br’s.
sd label add sd-a3f sd-b7c infra # the last positional is the label
sd label add sd-a3f -l infra # or name it with -l
sd label remove sd-a3f -l infra
sd label rename infra ops # on every seed that carries it
sd label list sd-a3f # one seed's labels
sd label list # every label in use
sd label list-all # every label, with counts
addandremovechange every named seed in one transaction, or none: an unknown id is exit 3 and nothing is written.- Each seed reports br’s status:
addedorexists,removedornot_found. A seed that needs no change is not written. listandlist-allinclude closed seeds, as br does.- A label is one non-empty word without a comma (labels are comma-separated
in
create -l).
--json: add/remove print [{status, issue_id, label, tx}];
list prints an array of strings; list-all prints [{label, count}];
rename prints {old_name, new_name, affected_issues, tx}.
Remote list and list-all use grouped label counts over the effective
project and ephemeral graphs, rather than sorting and loading every fact
on the board. An incomplete or malformed aggregate refuses explicitly;
it never falls back to a full-board read. Named-item list reads only
that item’s facts, including for --at.
sd epic
Epic progress, and closing the epics whose work is done.
sd epic status # every epic that is not closed
sd epic status --eligible-only
sd epic close-eligible --dry-run # what would close
sd epic close-eligible
- An epic’s children are the seeds created with
--parent <epic>. - An epic is eligible to close when it has at least one child and every child is closed. An epic with no children is never eligible (br).
close-eligiblecloses every eligible epic in one transaction, with br’s reasonAll children completed. An eligible epic that still has an open blocker of its own is left open and reported as skipped; it is never force-closed.
--json: status and close-eligible --dry-run print br’s array of
{epic, total_children, closed_children, eligible_for_close};
close-eligible prints {closed: [ids], count, tx}, plus skipped when an
epic was left open.
sd graph
The dependency graph around a seed, br’s way.
sd graph sd-a3f # what sd-a3f unblocks
sd graph sd-a3f --dependencies # what sd-a3f waits on
sd graph --all --json # every active seed, as connected components
sd graph --all --dot | dot -Tsvg > work.svg
- Edges point from the waiter to what it waits on:
B -> Twhen B is blocked by T, andP -> Cwhen parent P waits on its child C. - The default walks dependents over
blocksedges only: what finishing this seed unblocks.--dependencieswalks the other way over both kinds: blockers, and a parent’s children. --alltakes every open, in-progress or blocked seed and splits it into connected components, each rooted at the seeds that wait on nothing.- Depth is the breadth-first distance from the root(s). Deleted seeds take no part.
--compactprints one line per seed;--dotprints Graphviz and overrides text and--json.
--json: {root, nodes: [{id, title, status, priority, depth}], edges: [[from, to]], count}; with --all, {total_components, total_nodes, components: [{nodes, edges, roots}]}.
sd history
Every version of one seed, with the transaction that wrote it.
sd history sd-a3f
sd history sd-a3f --json
sd show sd-a3f --at 12 # read any one version in full
Not br’s
history. br’s verb manages local backup files. In seeds every write is a quipu transaction, so a seed’s history is already in the store;sd historylists it, and says so in its output.
- One entry per version, oldest first: the transaction, the revision, when, the status, and what changed from the previous version.
- Each entry is exactly what
sd show <id> --at <tx>reads. - Works against a local store and a quipu server alike. A quipu server
reports no head transaction and its writes report none either
(aegis-xajsgn), so history finds each version by searching
--atreads, a handful of reads per version. Each remote read binds the exact item in both project graphs; unrelated items and comments are never hydrated. A historical read refused by the server (including attached-store transaction ambiguity) remains a refusal, with no current-state or whole-board fallback. - A detected revision gap or terminal version differing from the current item refuses the result instead of returning a partial version list. This can occur with deletion/recreation or imported revision sequences.
--atdoes not apply: history is every version.
--json: {meaning, id, versions: [<seed object> + tx + changes]}.
sd changelog
What got done: closed seeds, grouped by type.
sd changelog # everything ever closed
sd changelog --since +7d # the last week
sd changelog --since 2026-09-01
sd changelog --since-tag v0.0.2 # since a release tag's commit date
sd changelog --since-commit HEAD~20 --json
- Groups, in order: Epics, Features, Bugs, Tasks, Chores, Docs, Questions; within each, most recently closed first. Deleted seeds are not listed.
--sincetakes a date, an RFC 3339 instant, or a span back from now (+7d,+2w,+12h: br’s relative form).--since-tagand--since-commitask git for that commit’s date, converted to UTC.
--json: br’s {since, until, total_closed, groups: [{issue_type, label, issues: [{id, title, priority, closed_at}]}]}.
sd lint
Seeds whose description lacks the sections their type’s template expects.
sd lint # open seeds
sd lint sd-a3f sd-b7c
sd lint -t bug -s all --json
| type | expected sections |
|---|---|
| bug | ## Steps to Reproduce, ## Acceptance Criteria |
| task, feature | ## Acceptance Criteria |
| epic | ## Success Criteria |
| chore, docs, question | none |
- These are br’s templates and hints. A heading at any level counts, and the match ignores case.
- Warnings, not errors:
lintexits 0 either way. - Without ids it checks open seeds;
-s allchecks every status. Deleted seeds are skipped.
--json: br’s {total (warnings), issues (seeds with warnings), results: [{id, title, type, missing, warnings, suggestions: [{section, hint}]}]}.
sd orphans
Open or in-progress seeds that a git commit mentions: work that may already be done and never closed.
sd orphans
sd orphans --details --json
sd orphans --fix # ask, seed by seed, whether to close it
- Reads
git logof the current repository, newest first, and reports each open or in-progress seed whose id appears in a commit’s subject or body, with the newest such commit. - Ids match as whole tokens, so a commit about
sd-a3f.1does not makesd-a3fan orphan. - Outside a git repository it is a usage error (exit 2).
--fixis br’s interactive close: for each orphan it asks on stderrClose <id> (<title>)? [y/N]and reads a line from stdin. Onlyyoryescloses it, with br’s reasonImplemented (detected by orphans scan); anything else, and no input at all, skips it. Sosd orphans --fix </dev/nullchanges nothing.--atcannot be combined with it (a write).
--json: br’s bare array of {issue_id, title, status, latest_commit, latest_commit_message}; --details adds commit_hash and commit_body; --fix adds fix: closed|skipped
(status is the seed’s status before the fix).
sd dep
sd dep add sd-a3f sd-b7c # sd-a3f is blocked by sd-b7c
sd dep add sd-a3f sd-epic -t parent-child
sd dep remove sd-a3f sd-b7c
sd dep list sd-a3f # what sd-a3f depends on
sd dep list sd-b7c --direction up # what depends on sd-b7c
dep add <issue> <depends-on> records that issue depends on depends-on.
Several targets are accepted: sd dep add sd-a3f sd-b7c sd-c8d adds both
blockers in one transaction. All targets validate before anything is written.
Use sd create --parent EPIC for epic children: parentage is different from
waiting for a blocker. An item can have only one parent.
Types (-t, --type):
| type | meaning | blocks ready? |
|---|---|---|
blocks (default) | issue cannot proceed until depends-on closes | yes |
parent-child | issue is a child of depends-on (one parent) | no |
related | a link | no |
discovered-from | issue was found while working depends-on | no |
- Both seeds must exist (exit 3). A seed cannot depend on itself, and a
blocksedge that would close a cycle is refused with the cycle printed (exit 5). - Adding an edge that already exists is a no-op (
action: unchanged). dep removeof an edge that is not there is exit 3.
--json: dep add and dep remove print {status, issue_id, depends_on_id, type, action, tx} for one target, or an array of those objects
for several targets; dep list prints an array of
{issue_id, depends_on_id, type, title, status, priority}, where the title,
status and priority are the other end’s.
External prerequisites
A seed can wait for a node in another registered Quipu graph. This is separate from an ordinary seed-to-seed dependency; the foreign node is never retyped as a local seed.
sd dep add example-1 --blocker urn:example:release --done-when published
sd dep add example-2 --blocker 'Approval decision' --blocker-graph urn:example:decisions --done-when decided
sd dep add example-3 --blocker urn:example:service --blocker-graph urn:example:operations --ask 'ASK { <urn:example:service> <urn:example:healthy> true }'
Exact labels and graph discovery must be unambiguous. If a node occurs in
several graphs, declare --blocker-graph. The stored graph is pinned; later
reconciliation does not silently switch datasets. Without --done-when or
--ask, supported exact types select closed (Action/WorkItem), merged (PR),
published (Release), exists (Directive), or decided (Decision). An unknown or
ambiguous type requires an explicit condition. Closed without merged is not a
completed PR. IRI strings are validated before generating queries.
sd blockers reconcile --json
sd show example-1 --json
sd graph example-1 --dependencies --json
sd blockers events --json
Reconciliation holds an open seed while any condition is false or unreadable. Only a feature-owned blocked status is automatically restored to open; manual status edits, date holds and other prerequisites retain authority. Ready and claim also reread external conditions, so a stale done observation cannot authorize work. Show and graph distinguish recorded state from current state.
There is no notification sender in Seeds. A shared consumer reads persistent pending unlock intents, retains its delivery receipt, and acknowledges it:
sd blockers ack example-1 seeds:unlock:example-1:1 --receipt retained-receipt-1 --json
sd blockers receipt example-1 seeds:unlock:example-1:1 --json
Generation identifies the actual readiness episode and survives ordinary edits. An unknown delivery must remain pending. If ACK is uncertain, read the retained receipt before a byte-identical retry; queue absence alone is not evidence. No schedule is armed by declaring an edge: configure the shared consumer and reconciler through your installation’s normal schedule lane.
Remove an external prerequisite by exact IRI, then reconcile:
sd dep remove example-1 --blocker urn:example:release
sd blockers reconcile --json
Legacy migration first reports its count and exact revision-bound plan:
sd blockers migrate --dry-run --json
sd blockers migrate --execute --json
Only active resume_when: closed:<id> lines on deferred seeds qualify. Prose
mentions are not conditions. Multiple conditions, independent date holds,
unreadable targets and duplicate declarations refuse execution of the entire
batch. Execution is one validated revision-checked transaction, not a sequence
of per-item writes. It preserves other notes and records normal write provenance.
Review the plan before executing through your governed migration lane.
sd comments
sd comments add sd-a3f "found the off-by-one in the lexer"
sd comments add sd-a3f --file notes.md # safest for long or quoted text
sd comments list sd-a3f --json
comments add takes the text one way: positional words (joined with spaces),
-m/--message (alias --content), or -f/--file. The author is --author,
else the actor. Comments are append-only and numbered 1, 2, 3 per seed; the
number is the comment’s id.
Current remote comments list reads only the named seed and its comments,
including its ephemeral graph. Historical --at reads retain transaction
snapshot semantics. A missing item or an unavailable read remains an error.
--json: one comment (add) or an array (list), each {id, issue_id, author, text, created_at}; add also carries tx.
Use the plural subcommand: sd comments add ID -m "Text". The legacy guess
sd comment ID -m "Text" prints the corrected command. Empty text is refused
with the message/file forms, and adds no comment.
sd export, sd import, sd sync, sd merge-driver
These move a ledger between stores. The modes they serve are described in Storage modes.
sd export
sd export # to the configured [pendant] dir
sd export --to /tmp/ledger # anywhere
Writes the ledger as a pendant (export.nt, shapes.ttl, manifest.json,
manifest.ttl). Works from a local store or a remote one; the export.nt is
the same bytes either way. A pendant that already holds exactly this ledger
under an intact seal is left untouched.
--json: {status, dir, changed, seeds, export_hash, files}.
sd import
sd import /tmp/ledger # add what is new; refuse disagreements
sd import /tmp/ledger --prefer pendant # disagreements: take the pendant's
sd import /tmp/ledger --prefer store # disagreements: keep the store's
sd import /tmp/ledger --replace # make the store exactly the pendant
Reads a pendant into the configured store (local or remote), after validating
the ledger on its own terms. A seed only in the pendant is created; an
identical one is left alone; one that differs is a conflict unless
--prefer names a side. Conflicts are listed and nothing is written (exit 4).
With --prefer pendant, the pendant’s version lands at a revision above both
sides, so no reader holding the store’s old revision can write over it.
--json: {status, created, updated, removed, unchanged, comments_added, tx, wrote}.
sd sync
sd sync # with [sync] remote
sd sync --remote https://quipu.example.org
sd sync --push-only # only items changed locally since the last sync
sd sync --dry-run # what a sync would do; writes nothing
sd sync --status # in-sync, local-ahead, remote-ahead, diverged or conflicted
Three-way merge of the local store and the remote against the ledger as of the
last sync with that remote, written to both sides; conflicts are listed and
nothing is written. Removals (a seed the base had that one side lacks) are
refused with exit 5 unless --allow-remote-deletes.
See Mode 3.
--push-only reads only the remote seeds and comments for items changed
locally since the last successful sync with this remote. It keeps the same
field-level merge, conflict, revision and removal guards for those items.
Remote-only changes on untouched items are not pulled or acknowledged; use a
normal two-way sync to discover them. The first push considers every local
item, never deletes unrelated remote items, and a replay with no local changes
reads no remote items. Both --dry-run and --status can preview this scoped
plan; in-sync with --push-only means no local changes need pushing, not
that the entire remote agrees. The base advances only after all writes succeed.
--json: {status, local: {…}, remote: {…}}, each side shaped like
import’s; wrote says whether that side was written (a quipu server reports no
transaction id, so tx is 0 there).
--dry-run plans from the same merge and stops: no write to either side
and no new sync base. It exits as the sync would, so a conflict (exit 4) or a
refused removal (exit 5) fails the dry run too. Its --json adds
dry_run: true, and wrote is false on both sides.
--status reports where the two stand and never fails on a disagreement:
{status, sync: {state, remote_url, synced_before, conflicts, removals_need_allow, would_refuse, local, remote}}. The state is local-ahead when only the remote
would be written, remote-ahead when only the local store would, diverged
when both would, in-sync when neither would, and conflicted when a sync
would refuse on conflicts.
Neither preview writes anything beyond what every sd command does in mode 1: reconcile the store with its pendant first.
A push larger than the server accepts is split. A quipu server caps a
request body (64 MiB for /update), and the first sync of a whole board is far
over that. A batch is also limited by how many guard clauses it nests:
two per new seed, one per comment, one per updated or removed item. quipu-server
aborts on an /update nesting about two thousand of them, whatever the body
size. When the remote write would exceed SEEDS_MAX_WRITE_BYTES (default
48 MiB) or SEEDS_MAX_WRITE_CLAUSES (default 500), sync pushes it as several batches, each its own transaction, and
prints sd: remote batch N/M landed to stderr as each one commits. The order
keeps every batch valid by itself: a seed lands after the seeds it is blocked
on, seeds in a dependency cycle land together, a comment lands with its seed,
and removals land last. A write that fits is still one transaction.
If a split push stops part way, the error says how many batches landed and
keeps the failure’s own exit code (an unknown outcome is still exit 8, read
it back first). The local store and the sync base are untouched, so running
the same sd sync again continues: what already landed is identical on both
sides and is not written again.
Any write whose request would exceed the limit is refused before it is sent,
with exit 5 and a message starting write too large for the server; so is a
write nesting more guard clauses than the limit, and so is a server’s own HTTP
413. Nothing was written in either case. (Sending it anyway
lets the server close the connection mid-request, which reads as a lost
response, exit 8, for a write that was never evaluated.) A single seed or
dependency cycle over the limit cannot be split and is refused the same way.
br’s other sync flags (--apply, --force, --orphans and the JSONL
export/import modes) act on br’s SQLite/JSONL machinery and have no seeds
counterpart. In particular --force does not lift the removal guard; only
--allow-remote-deletes does.
sd merge-driver
git config merge.seeds.driver "sd merge-driver %O %A %B"
A git merge driver for a pendant’s export.nt: the same field-level merge as
sd sync, with the sides named ours and theirs. Needs no store and no
configuration. See Merging branches.
sd version, where, info
What sd is, and where its ledger lives.
sd version # sd 0.0.x
sd version --short # just the number, for scripts
sd version --check # is a newer release published? exit 0 / 1 / 7
sd where # store file or server, graph, prefix, pendant
sd info --json # exact seed count; comment_count=null, explicitly omitted
sd info --exact-comments # ADMIN exact comments; takes both shared read slots
sd info --schema # plus the shapes' sha256 and target classes
sd info --whats-new # this build's latest changelog section (no store)
sd info --thanks # the projects sd builds on (no store)
version --checkasks for the latest published release (GitHub’s latest release of scbrown/seeds;SEEDS_RELEASES_URLoverrides it) and exits 0 when this build is current, 1 when a newer release exists, as br does, and 7 when it cannot tell: offline, an error status, or a tag that is not a plainx.y.z. It never reports “up to date” for an answer it could not compare.--json:{current_version, latest_version, update_available, source}.versionneeds no configuration. seeds does not embed its commit, branch or compiler, so those--jsonkeys arenullrather than guessed.wherereads the configuration only. It never creates a store or a project id, so it is safe to run anywhere, including before the first write.inforeports the exact seed count, current transaction and local file size.comment_countis explicitlynull, withcomment_count_status: "not_computed". Human output says the comment count was not computed; it never substitutes zero.info --exact-commentsis an administrative opt-in:comment_countis an exact integer and its status is"exact". The measured cold memory cost exceeded 400 MB on a 30k-item board. A warning discloses this cost; routine automation must accept the explicit omission instead of selecting this flag. Exact info takes both shared search slots, excluding title and full-search peers.info --schemaaddsschema: {shapes_sha256, target_classes, vocabularies, json_schemas}. The digest identifies the shapes this build validates a ledger against (the same bytes as a pendant’sshapes.ttl);sd schemaprints the--jsonschemas.info --whats-newandinfo --thanksdescribe the build, not a ledger, so they open no store and work anywhere.--whats-newprints the latest release section of the changelog compiled into this binary ({version, release, changes}).
--json: version prints br’s {version, build, commit, branch, rust_version, target, features}; where prints br’s {path, prefix, database_path, jsonl_path} plus quipu_url, mode, graph, pendant_path
and location_source; info prints br’s {database_path, beads_dir, mode, issue_count, config: {issue_prefix}, db_size, jsonl_path} plus
comment_count, tx, graph and quipu_url. jsonl_path is null: seeds
has no JSONL file.
sd config
The configuration sd actually resolved, from the layers described in
Configuration.
sd config list # every resolved setting
sd config get project.prefix
sd config path # which project and user files, and whether they exist
- Keys are
section.key, matching the TOML files:quipu.store,quipu.url,quipu.location_source,quipu.token,quipu.token_file,quipu.trusted_hosts,quipu.allow_plain_http_hosts,project.prefix,project.graph,project.id_file,pendant.dir,sync.remote. - A token is never printed.
quipu.tokensays only whether one is set, and from where:(set: SEEDS_QUIPU_TOKEN),(set: token_file)or(unset). set,delete(unset) andeditare not built (exit 20) and name the file to edit. Changingproject.prefixorproject.graphmoves the project to a different ledger (see init), so it is a deliberate edit, not a one-liner.
--json: list prints {"<key>": value}; get prints {key, value};
path prints {project: {path, exists}, user: {path, exists}}.
sd capabilities
sd’s machine-readable contract, for agents and tools.
sd capabilities --json
sd capabilities --for "comments add" --json
- commands: every leaf verb with its summary, aliases, flags and
operation:read(pinnable with--at),write, orelsewhere(a br verb that lives in another tool; exit 21). - global_flags, exit_codes (with meanings), env_vars, and the safety guarantees.
- Derived from
sd’s own definitions, not written out: commands and flags from the CLI definition,operationfrom the same flag that decides whether a verb takes the store’s write lock, exit codes from the error kinds. It cannot drift from what the binary does. - Needs no configuration and reads no ledger. For output shapes, see schema.
sd schema
JSON Schemas (draft 2020-12) for sd’s --json output.
sd schema # every schema, plus each verb's top-level shape
sd schema issue-details
sd schema commands # verb -> {shape, item, jq}
Targets: issue, issue-with-counts, issue-details, ready-issue,
stale-issue, blocked-issue, comment, statistics, error, commands,
and all (the default).
- The schemas are generated from the same tables the contract tests pin
(
src/schema.rs), and a test validates realshow,list,blocked,ready,stats,comments addand error output against them, so the published schema cannot drift from whatsdprints. - Objects are closed-world: every listed key is required (absent values are
null), and no other key appears. Writes add atxon top of the object (see the verbs). - Needs no configuration and reads no ledger.
sd doctor
Read-only checks of the configuration and the ledger.
sd doctor
sd doctor --json && echo healthy
sd doctor --quick # the cheap checks only (skips ledger.valid)
sd doctor --robot-triage # one JSON envelope for an agent
| check | what it asserts |
|---|---|
config.resolves | the configuration resolved (and from which layer) |
project.id | .seeds/project-id holds an id (a warning before the first write) |
.gitignore | the local store’s directory ignores the store file |
store.opens | the store or server answers; how many seeds and comments, at which tx |
ledger.valid | the same validation sd import applies: shapes, single-valued fields, dangling blocks edges |
deps.targets_exist | every dependency of every kind points at a seed |
deps.no_cycles | no cycle in blocks (a cycle leaves every seed on it permanently unready) |
- Exit 1 when any check is an error, 0 otherwise (warnings do not fail), so a script or CI can gate on it. Every check is reported either way.
- Read-only:
doctornever repairs. The verbs refuse to create most of these states;doctorfinds the ones a raw write or a bad merge can.
--json: {ok, checks: [{name, status: ok|warn|error|skipped, message}]}.
--quick skips ledger.valid, the one check that exports and validates
the whole ledger (measured on 400 seeds: about 0.18 s with it, 0.01 s
without). It is reported as skipped, never as passed; every other check runs
unchanged.
--robot-triage prints {schema_version: "sd.doctor.triage.v1", summary, findings, actions_planned, recommended_command, quick_ref}. findings lists
every check that is not ok, each with a recommended_command where sd has one
(for example sd init --force for a missing .gitignore, sd dep remove <issue> <depends-on> for a dangling edge). actions_planned is always empty,
because doctor never repairs. Exit codes are those of sd doctor.
br’s --fix (its --repair) and --dry-run (a preview of that repair) have
no counterpart: nothing in a quipu ledger is derived from anything else.
sd key
The key that signs your writes to a quipu server, so no bearer goes on the wire (see Signed writes).
sd key init --introducer <lead> # session defaults to seeds-<host>-<user>
sd key init --introducer <lead> --session seeds-ci --agent urn:ci:seeds
sd key show
The default session uses the kernel hostname or $HOSTNAME, falling back to
hostname(1) on systems such as macOS, plus $USER. If either identity is
unavailable, pass --session explicitly.
sd key init:
- writes a new Ed25519 key to
~/.config/seeds/keys/<session>.key, readable by you only, and never overwrites an existing key; - prints the three
[quipu]lines to add to your user config; - prints the one
quipu attest register … --allow-writecommand your introducer runs on the quipu host (without--allow-writethe key could only vouch for shares, and writes are refused withscope). seeds never registers its own key: a key that vouches for itself proves nothing.
sd key show prints the configured key’s public key, key_id and the same
register command. It refuses a key file that others can read.
Nothing here touches the ledger. Rotation is a new key plus revoking the old session on the server.
--json: {created, key_file, session, introducer, agent, public_key, key_id, register_command, config}.
sd completions
Shell completion scripts.
sd completions bash > ~/.local/share/bash-completion/completions/sd
sd completions zsh -o ~/.zfunc # writes ~/.zfunc/_sd
sd completions fish -o ~/.config/fish/completions
- Shells:
bash,zsh,fish,powershell,elvish, as br. - The script is generated from
sd’s own command definition, so it lists every verb and flag of the binary that printed it and cannot drift. - Needs no configuration and never creates a ledger.
-o <dir>writes the shell’s conventional file name there (sd.bash,_sd,sd.fish,_sd.ps1,sd.elv).
Formulas: shuttle, not a second engine
beads carries its own formula engine: molecules (mol-*) are workflow
templates that stamp out a chain of dependent issues and drive them. seeds
will not build one. Formula support in seeds is integration with
shuttle, the quipu stack’s workflow
engine, and it is planned as a follow-on to v0.1.
What shuttle already is
- A workflow is a JSON definition: a name, an initial state, terminal
states, and transitions
{step, from, to}(shuttle’sexamples/triage.json). - A run is started with a caller-chosen id (
shuttle start <definition> <run> --agent A) and moved one signed transition at a time (shuttle advance <run> --step S --agent A). Each transition is signed with the agent’s ed25519 key. shuttle exportwrites runs into quipu with camayoc’s workflow vocabulary:aegis:WorkflowDefinition,aegis:WorkflowRun(aegis:runOfits definition,aegis:currentState), and an append-onlyaegis:TransitionEventper step. Runs areurn:shuttle:run:<id>, definitionsurn:shuttle:workflow:<name>.
What seeds provides today
Every seed can carry the run that created or drives it:
sd create "Review the release" --workflow-run release-42 # urn:shuttle:run:release-42
sd update sd-a3f --workflow-run urn:shuttle:run:release-42
It is stored as seeds:workflowRun <urn:shuttle:run:…> and appears as
workflow_run in --json. From the run, camayoc’s aegis:runOf reaches the
definition, so “which seeds did this workflow stamp” and “which workflow drives
this seed” are both one query over the graph.
Neither camayoc nor shuttle has a term linking a WorkItem to a WorkflowRun yet.
seeds:workflowRun is a stopgap and a proposal for camayoc; the runs,
definitions and transitions themselves stay in shuttle’s aegis: terms rather
than a parallel seeds vocabulary.
The follow-on, sketched
A formula becomes a shuttle definition whose steps name the work items to
create. Starting a run stamps them: shuttle (or a thin driver around it) calls
the seeds library or sd create --workflow-run <run> --deps … for each step,
so the chain of dependent seeds exists as ordinary seeds with blocks edges,
and sd ready surfaces the first one. As each seed closes, the driver
advances the run (shuttle advance <run> --step <step>); as the run reaches a
terminal state, the driver closes whatever seeds remain with the run’s outcome.
seeds stays a tracker; shuttle stays the engine; the graph joins them.
Reference
The command is sd (crate seeds-ai). Each verb has its own page under
The verbs; this page is the summary.
Global options
Accepted on every verb.
| option | meaning |
|---|---|
--json | output as JSON, in br’s output shape |
--actor <NAME> | actor recorded on writes and used by --claim (env SEEDS_ACTOR, else $USER) |
--store <PATH> | use this local quipu store file (configuration) |
--quipu <URL> | use this quipu server (mode 2) |
--graph <IRI> | the project’s named graph (configuration) |
--at <TX> | read as of this transaction (a pin); refused on writes |
-h, --help / -V, --version | help and version |
Verbs
| verb | flags (a br-compatible subset) |
|---|---|
create [TITLE] | --title, -t/--type, -p/--priority, -d/--description, --description-file, -a/--assignee, -l/--labels, --parent, --deps, --dry-run, --silent |
show <ID>... | |
list | -s/--status, -t/--type, --assignee, --unassigned, -l/--label, -p/--priority, -a/--all, --limit (default 50, 0 = all), --sort |
ready | --limit (default: none), --assignee [NAME], --unassigned, -l/--label, -t/--type, -p/--priority, --parent |
count | --by, --status, --type, --assignee, --include-closed |
update <ID>... | --title, -d/--description, --notes, -s/--status, -p/--priority, --assignee, --claim, --add-label, --remove-label, --defer |
close <ID>... | -r/--reason, -f/--force |
dep add <ISSUE> <DEPENDS_ON> | -t/--type (default blocks) |
dep remove <ISSUE> <DEPENDS_ON> | -t/--type (default blocks) |
dep list <ID> | --direction down|up |
comments add <ID> [TEXT]... | -f/--file, -m/--message (alias --content), --author |
comments list <ID> | |
export | --to <DIR> |
import <DIR> | --prefer pendant|store, --replace |
sync | --remote <URL>, --allow-remote-deletes, --dry-run, --status |
merge-driver <BASE> <OURS> <THEIRS> |
create and update also take --workflow-run <RUN> (Formulas).
Exit codes
Exit codes are a contract. A code is never reused or renumbered.
| code | meaning |
|---|---|
| 0 | success |
| 1 | failed: store I/O or anything without a more specific code |
| 2 | usage: an unknown verb, a bad flag or value, --at on a write |
| 3 | not found: an unknown id, dependency or comment |
| 4 | conflict: a lost --claim, the seed changed since it was read, or two ledgers disagree (import, sync, merge-driver, a pendant that changed alongside the store); nothing was written |
| 5 | refused: the shapes rejected the write, a dependency cycle, closing a seed with open blockers without --force, a write over the request limit (SEEDS_MAX_WRITE_BYTES, SEEDS_MAX_WRITE_CLAUSES or the server’s HTTP 413; nothing was sent) |
| 6 | configuration: contradictory or unreadable config (for example store and url both set) |
| 7 | unreachable: the configured quipu server cannot be reached (seeds never falls back to a local store) |
| 8 | indeterminate: a remote write’s response was lost and a read-back does not show it; it may still land. Check the named ids before doing anything; do not simply retry |
| 10-18 | retired: the v0 shell’s per-verb “not yet implemented” codes. Never reused. |
| 20 | not built: reserved for a configured capability that exists in the design but not in this build (no verb returns it today) |
| 21 | elsewhere: a br verb whose capability lives in another tool of the stack (query, upgrade, gate, scheduler, audit, robot-docs); the message says where |
In --json mode an error prints {"error": {"code": "<NAME>", "message": "..."}} on stdout, where <NAME> is FAILED, USAGE, NOT_FOUND,
CONFLICT, REFUSED, CONFIG, UNREACHABLE, INDETERMINATE, NOT_BUILT or ELSEWHERE. The message
always goes to stderr as well.
Remote request task attribution
Every remote request retains X-Quipu-Client: seeds. Set QUIPU_TASK
explicitly to attach diagnostic X-Quipu-Task metadata. Valid task ids are
1–64 ASCII letters, digits, dots, underscores or hyphens after trimming;
they are lowercased for the server’s bounded task label. Invalid or absent
values send no task header and remain unattributed. This header grants no
authority and does not change the selected graph, actor or signed writes.
Remote health, graph-list and query reads preserve explicit caller, session and
task declarations. With no caller declaration or actual harness context, reads
omit X-Quipu-Agent; the executable is not a crew identity. Inherited
SHANTY_AGENT alone remains insufficient outside a harness. Write provenance,
actors, authentication and signatures retain their existing contract.
Reads separately declare X-Quipu-Source (default sd-show, sd-list,
sd-ready, sd-search, sd-stats, sd-count, sd-create, sd-update,
sd-close, sd-comments, sd-where, sd-info, or sd-other from the parsed
CLI command; library calls outside a CLI scope default to seeds) and
X-Quipu-Operation (finite endpoint name: health, graphs, query). Wrappers
may supply QUIPU_SOURCE and QUIPU_OPERATION, using the same bounded token
syntax as task ids. A supplied wrapper source such as st-anchor remains the
outer attribution. No command arguments or item/message text enter the default
source value. Command scope is restored after a call or failure without changing
the process environment or write actor. These are diagnostic extension headers, not authenticated
identity or new metric labels. Servers that do not expose them still cannot
join a completion to its source operation; header propagation alone does not
prove that server visibility. No request body or credential is logged.
Owner-filtered readiness discovers only explicitly open candidates before checking dependencies. Closed ownership history does not create readiness requests. Deferred inclusion still uses its separate existing read, and all remaining filters and claim checks retain their existing semantics.
What is built, and what is not
Built (v0.1 core)
- Verbs:
create,show,list,ready,count,update(including an atomic--claim),close,dep add|remove|list,comments add|list, each with--jsonin br’s shape. See the verbs. - Storage: work items as facts in a quipu named graph, typed as
schema:Action(schema.org first, Quechua for governance) and validated against the seeds profile on every write. See The storage model. - Pinning:
--at <tx>on every read resolves the project as of that transaction. See Pinning. - Concurrency: writers are serialised by a file lock and every write is a revision-checked compare-and-set; no lost updates, and a lost claim is a clean exit 4.
- Configuration: TOML, project then user then default, with a local store as the default. See Configuration.
- Storage modes: a repo-local pendant committed with the code (mode 1), a
quipu server over HTTP (mode 2), and
sd syncbetween them with a three-way, field-level merge (mode 3);sd export/sd importin any mode; a git merge driver for pendants. See Storage modes. - A link to shuttle runs:
--workflow-runrecords the run that creates or drives a seed. See Formulas. - WebAssembly: the core builds and its tests run on
wasm32-unknown-unknown. See WebAssembly.
Not built yet
| what | why it matters | where |
|---|---|---|
| beads sync: two-way sync between a beads/br store and a seeds graph | reading existing work, and handing work back | a later phase |
| formulas (molecules) | workflow templates that stamp and drive chains of seeds | shuttle integration, sketched in Formulas; seeds will not grow its own engine |
| a cheaper remote write | mode 2 writes cost as much as the server’s whole store (quipu’s /update) | an expected-value precondition on quipu’s /set or /knot |
| pins across a pendant | --at history in a clone | quipu’s full reconstruction share |
| reading quipu’s existing WorkItem records | existing beads readable from day one | camayoc’s tracker projection keeps status on Observations; a read adapter is needed |
ready / blocked / plate as camayoc stored queries | one definition shared by every caller | the ready SPARQL is in src/vocab.rs (ready_query), ready to move |
sd capabilities --json | intent 4 of the intent map | not started |
sd release <id> --if-assignee X | intent 1: a conditional release | update --claim is the claim half |
desire-path redirect (dp alias --cmd bd --replace sd) | agents keep typing bd | lives in desire-path |
| caboodle install and verify | stack installation | lives in caboodle |
| signed or delta pendants | verifiable ledger history between teams | quipu’s attestation and delta shares; seeds writes full, unsigned shares today |
Every bd verb seeds does not know is a usage error (exit 2), which is what
desire-path records as demand.
The stack
seeds is the caboodle stack’s work tracker, and a member of the stack. It is built on the other tools:
| tool | what seeds uses it for |
|---|---|
| quipu | the store, embedded as a library: SPARQL reads, transactions, time travel, pendants |
| camayoc | the governance terms (Quechua sourceKind, outcome, blockedOn) and constraints every write is checked against; the ready query is meant to become one of its stored queries |
| caboodle | installs seeds and proves it in caboodle verify |
| desire-path | redirects bd to seeds, and records every verb seeds refuses |
| shuttle | formulas: the workflow engine that will stamp and drive seeds; a seed records its run today |
| yupana | not a runtime dependency; the counting board seeds are named after |
| bobbin | not a runtime dependency |
A crew harness can also use seeds as a work-item tracker alongside its others, through the same three-method tracker protocol. See How it works.
Docs map
Every document in this repository, and where it lives.
| document | what it is |
|---|---|
README.md | the front page: what seeds is, and a first run |
AGENTS.md, CLAUDE.md | instructions for coding agents working in this repo |
docs/book/ | this book; every page is listed in the sidebar |
shapes/seeds.shapes.ttl | the SHACL shapes every write is validated against (The storage model) |
clippy.toml | the rule that keeps the core wasm-clean (WebAssembly) |
There are no other documents under docs/ yet. When one is added, it is
either folded into a book page or listed here.
For lossless beads JSONL rehearsal and cutover sync, see Cutover.
Replace a specification, then acknowledge it
rescope replaces the description with one consolidated SPEC vN. It appends
a timestamped comment containing the verbatim request, requester, acting
principal, summary and declared session. Description, generation and comment
land in one revision-checked transaction. Prior descriptions remain readable
with sd show ID --at TX; sd history ID identifies those transactions.
sd rescope work-123 --spec-file new.md --quote "Please limit this to the retention audit." \
--requester operator --summary "retention audit" --session session-123
sd spec-state work-123 --json
sd ack work-123 --version 1 --session session-123
The assignee acknowledges the exact generation it read. Another assignee, a stale generation or a conflicting receipt refuses. Repeating an existing ACK returns a null transaction. An ordinary description update cannot bypass rescope once a description has a governed generation.
The description is limited to 4,096 UTF-8 bytes, including its generated heading.
Configure [specs] byte_cap to change this bound. Empty specifications,
oversized input and layering markers (ADDITION, SCOPE EXPANSION,
supersedes the above) refuse before writing. A replacement file may carry a
version heading; the writer computes the authoritative generation. Another
requester’s change requires a non-empty quote. --dry-run validates the same
request and returns the planned generation without committing it. A declared
session describes planned provenance in this mode; other read commands retain
their attribution guard.
Notifications and awareness are separate
spec-events --json returns retained pending notification intents. Their stable
identity is item plus spec generation; polling and ordinary edits cannot mint a
new generation. spec-receipt ID VERSION --json reads a retained delivery
receipt even after the intent stops being pending. The shared notifier confirms
its verified inbox receipt with spec-receipt-ack ID VERSION --receipt RECEIPT.
A response lost after this write is reconciled by exact receipt readback. A
notification receipt does not acknowledge the specification.
spec-changes --json lists unacknowledged changes, including delivered pointers.
These delivery reads are current-state operations and refuse --at.
spec-state and show support historical pins.
An owner start/claim transition carrying a declared session records the spec
generation at that transition. A lead changing somebody else’s status does not
invent an owner start. seed_spec_awareness is the portable stored query in
queries/spec-awareness.json; sd spec-query-definition emits its definition
for Quipu’s validated registry. Load it through the reviewed registry or share
projection, then call it with the exact graph and item IRIs. It returns the
recorded start generation, current generation and explicit owner ACK. Missing
start evidence is UNKNOWN. Its private-store control proves start v1, current
v2 and ACK v2 independently. Query availability on a running server requires
registry projection; source publication alone does not install it.
When using Shantytown, st work item rescope and st work item ack provide the
same operations on the selected Seeds backend. The existing capped briefing
shows unacknowledged generations and the administrator sees their age. The
existing scheduled notifier owns pointer delivery. Default pointers wait for
the recipient’s next Stop boundary; urgent P0 pointers use the normal live
input lane. The recipient adapter reads bounded spec metadata and fences each
generation before emitting, so an uncertain hook output cannot repeatedly wake
an owner. UNKNOWN reads remain UNKNOWN, and the session ceiling outranks this
notice. Enable the reviewed [overdue] spec_changes configuration only after
the corresponding SDK and hook are installed; it defaults to false.
Pause governed spec edits during rollback, preserve items, history and receipts, and remove new configuration before downgrading a parser that does not know it. An older client does not enforce the new replacement protocol. Scheduled receipt, actual recipient arrival and installed identity are separate acceptance proofs.