Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 sync merges them, the core builds for WebAssembly, and --at pins 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>) carries sd-<tag>-<target>.tar.gz for x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, aarch64-apple-darwin and x86_64-apple-darwin, plus a SHA256SUMS.txt covering 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-ai refuses to overwrite an existing ~/.cargo/bin/sd; --force replaces it.
  • With both installed in different directories, PATH order decides which runs. which -a sd shows 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 … yet on 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] url or SEEDS_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. If sd --version does not print sd <version>, it is a different sd.

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

  1. 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.
  2. 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.
  3. 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 bd into raw SQL are served by mapping the intent of each call, not its syntax.
  • bd callers move over without changes. desire-path redirects bd to sd, 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.

#intentwhat callers do todaywhat seeds provides
1Conditional claim / releaseUPDATE … WHERE status='in_progress' AND assignee=X, plus a revision tokena compare-and-swap: sd update <id> --claim, sd release <id> --if-assignee X
2Project identityread or upsert a project-id metadata row; ask for the active branch; probe for the issues tablethe project is a named-graph IRI, so identity comes built in
3Commit / cursorstage, commit, count dirty tables, read the head hashgoes away: every knot is a transaction and the transaction id is the cursor
4Capability / schema proberead the max migration version; list tables and columnssd capabilities --json, derived from the loaded quipu shapes
5Health / livenesslist databases, read the process list and data dir, start or stop the SQL servergoes away: quipu’s own health endpoint; there is no server lifecycle
6Maintenancegarbage-collect, purge dropped databases, compactgoes away: quipu’s own job; a no-op in seeds
7Read projectionsCOUNT, list and ready union queriesSPARQL 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 expected field 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

#intentstatus
1conditional claim / releaseupdate --claim is built (a compare-and-set, exit 4 on a lost claim); release --if-assignee is not
2project identitybuilt: one project is one named graph
3commit / cursorbuilt: every write prints its transaction, and --at reads from it
4capability probenot built
5health / livenessnothing to build locally; the store is a file
6maintenancenothing to build
7read projectionsbuilt: 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/ outside src/native/). The work-item model, the verbs, the ready computation and the JSON output. It reads no clock, file or environment: time arrives in a Ctx, storage as a Backend. That is what lets it build for wasm32. See WebAssembly.
  • sd, the native CLI (src/native/). Its clap definitions follow br, 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 the bd JSON shape.
  • quipu is the store, embedded as a library. Reads are SPARQL (ready runs 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:Action carrying camayoc’s governance terms (Quechua sourceKind, 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 --json shapes are br’s, so a tracker adapter written for br reads them.
  • caboodle and desire-path integration (install and verify; the bd redirect) 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.

prefixnamespace
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.

fieldpredicateobject
typerdf:typeschema:Action
provenancequechua:sourceKind"declared" (an agent or person said so)
idschema:identifier"sd-a3f"
titleschema:name and rdfs:labelthe same string in both (quipu’s label floor and /search read rdfs:label; the shapes hold them equal)
descriptionschema:descriptionstring
statusseeds:statusopen, hooked, in_progress, blocked, deferred, closed, tombstone: the field the claim compare-and-set reads
public statusschema:actionStatusDERIVED 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 tokenschema:versioninteger, 1 at create, +1 per write
priorityseeds:priorityinteger 0-4
typeseeds:issueTypetask, bug, feature, epic, chore, docs, question
assigneeschema:agentprincipal IRI https://seeds.local/principal/<name>
creator, ownerschema:creator, schema:accountablePersonprincipal IRI
labelsschema:keywordsone fact per label
created, updated, closedschema:dateCreated, schema:dateModified, schema:endTimexsd:dateTime
dueical:duexsd:date when a bare YYYY-MM-DD, else xsd:dateTime
deferschema:scheduledTimexsd:date or xsd:dateTime, as for due
estimateschema:timeRequiredxsd:duration of the minutes, canonical (PT30M, PT1H30M, P1D)
close reasonschema:resultstring
outcomequechua:outcome"done" (the default), abandoned, superseded or failed, only when closed
blocks dependencyquechua:blockedOnthe blocker’s item IRI
parent-child, related, discovered-fromschema:isPartOf, dcterms:relation, prov:wasDerivedFromitem IRI
notes, design, acceptance criteria, agent context, external refseeds:notes, seeds:design, seeds:acceptanceCriteria, seeds:agentContext, seeds:externalRefstring (an external ref need not be a URL)
driving workflow runseeds:workflowRuna 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 one sourceKind, an outcome from the closed list, and blockedOn pointing at an Action the graph actually holds (camayoc’s constraints, carried onto schema:Action);
  • seeds:SeedShape: exactly one identifier, schema:name (equal to the one rdfs:label), status, priority (0-4), type and version; typed dates; principal IRIs; and an sh:xone that holds status, outcome and schema:actionStatus in 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 by skos: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

  1. The verb reads a snapshot of the project.
  2. 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”.
  3. 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):

  1. A lost-update-free write needs a precondition. /knot has none. /update’s WHERE clause 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.
  2. Out of the box means local. sd create in an empty directory works with no server, no credentials and no configuration.
  3. The same core runs on wasm32. quipu’s library builds for wasm32-unknown-unknown with an in-memory store; see WebAssembly.
  4. 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.

  • store is 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.
  • url is an http:// or https:// 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 stroe fails loudly instead of being ignored.

Precedence

Highest first. A layer is consulted only when every layer above it says nothing about that setting.

#layerlocation keysother keys
1flags--store <path>, --quipu <url>--graph
2environmentSEEDS_QUIPU_STORE, SEEDS_QUIPU_URLSEEDS_GRAPH, SEEDS_PREFIX, SEEDS_ACTOR, SEEDS_PENDANT_DIR, SEEDS_SYNC_REMOTE, SEEDS_QUIPU_TOKEN, SEEDS_MAX_WRITE_BYTES, SEEDS_MAX_WRITE_CLAUSES
3project filethe nearest .seeds/config.toml, walking up from the current directory like git[project], [pendant], [sync], token_file
4user file$XDG_CONFIG_HOME/seeds/config.toml, else ~/.config/seeds/config.tomlthe same
5defaulta local store at <project>/.seeds/seeds.dbprefix 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:

headerfirst of
X-Quipu-AgentQUIPU_AGENT; SHANTY_AGENT inside an agent session; seeds
X-Quipu-HarnessQUIPU_HARNESS; claude (CLAUDECODE=1); codex (CODEX_HOME); cli
X-Quipu-ModelQUIPU_MODEL; SHANTY_MODEL
X-Quipu-SessionQUIPU_SESSION; CLAUDE_CODE_SESSION_ID
X-Quipu-HostQUIPU_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_hosts and allow_plain_http_hosts are honoured only in your user config (or SEEDS_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 in allow_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:port admits 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/create is 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, or quipu attest allow-write <session> later). A key registered only to trust someone’s shares is refused with scope.
  • 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.

modeconfigthe ledger livesreads needwrites go
local (default)nothing, or [quipu] storea quipu store file, .seeds/seeds.dbnothingthe file
1. repo-local pendant[pendant] dir = ".seeds/pendant"a pendant committed in the repository, plus a local working storenothing: clone the repo and you have the boardthe working store, then exported to the pendant in the same command
2. remote[quipu] url = "https://…"a quipu serverthe serverthe server, live, over HTTP
3. synca local store (with or without a pendant) and [sync] remote = "https://…"bothnothinglocally; 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:

filewhat it is
export.ntthe ledger: every current fact of the project graph as RDFC-1.0 canonical N-Triples, one fact per line, sorted
shapes.ttlthe shapes the ledger conforms to
manifest.json, manifest.ttlthe share manifest: hashes of the two files above, the producing store and transaction, the share id
.gitattributeshow 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

` reads one into the configured store.

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 markerpendant since the markerwhat sd does
unchanged (or no store yet: a fresh clone)changed: a pull, a checkout, a mergeloads the pendant into the store
changedunchangedexports the store to the pendant
changedchangedrefuses (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.nt conflicted, 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.nt carries 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 sync runs 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): the sd CLI 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

verbwritespage
inityes (files)init
createyescreate
shownoshow
listnolist
readynoready
searchnosearch
stalenostale
stats (alias status)nostats
qyesq
query, upgrade, gate, scheduler, audit, robot-docsno (exit 21, pointer)elsewhere
countnocount
updateyesupdate
closeyesclose
reopen, defer, undeferyesreopen, defer, undefer
deleteyes (tombstone)delete
blockednoblocked
label add, label remove, label rename, label list, label list-alladd/remove/renamelabel
graphnograph
historynohistory
changelognochangelog
lintnolint
orphansnoorphans
epic status, epic close-eligibleclose-eligibleepic
dep add, dep remove, dep listadd/removedep
comments add, comments listaddcomments
version, where, infonoversion, where, info
config list, get, pathnoconfig
capabilitiesnocapabilities
schemanoschema
doctornodoctor
keynokey
completionsnocompletions
export, import, sync, merge-driverimport, sync, merge-driverexport, import, sync

Behaviour change (#41): list and search hide deferred seeds unless --deferred, --all or --status is 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-id and .seeds/.gitignore in 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:
    • init in a project that already has an id is refused (exit 4) and writes nothing.
    • --force only restores missing files. It keeps the existing id, and asking it to change a configured prefix is refused (exit 5).
  • Commit config.toml and project-id.
  • The default prefix is $SEEDS_PREFIX, else sd.
  • Explicit --store, --quipu and --graph overrides are refused (exit 2) before any files are written. init configures 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
flagmeaning
TITLE / --titlethe title (one of the two, not both)
-t, --typetask (default), bug, feature, epic, chore, docs, question
-p, --priority0-4 or P0-P4 (default 2)
-d, --description / --description-file PATHthe description
-a, --assigneeassignee
--ownerwho owns the work (br’s owner, usually an email)
--acceptance-criteria TEXT (alias --acceptance)acceptance criteria, often a - [ ] checklist
--external-ref REFa reference to the same work elsewhere (br’s external_ref)
--agent-context JSONgoverning 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)
--ephemeralkeep 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 SLUGembed 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 WHENdue 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 MINtime estimate in minutes, 0 to 525960 (br’s estimated_minutes)
-l, --labelscomma-separated labels
--parent IDparent seed; the new id is <parent>.<n>
--depscomma-separated: ID (a blocks dependency) or TYPE:ID
--workflow-run RUNthe shuttle run that creates or drives it: a run IRI, or a bare id (becomes urn:shuttle:run:<id>); see Formulas
--step STEPthe 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 Nwhich entry into --step this is, from 1 (default 1); a step the run enters again gets a new seed
--dry-runprint what would be created, write nothing
--silentprint 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
flagmeaning
-s, --statusonly this status
-t, --typeonly this type
--assignee NAME / --unassignedonly this assignee / only unassigned
-l, --labelonly seeds carrying this label (repeatable; all must match)
-p, --priorityonly this priority
-a, --allinclude closed seeds
--limit Npage size, default 50; 0 lists everything
--sortpriority (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.

flagmeaning
--limit Ncut the list at N, and say so
--assignee [NAME]only this assignee; bare --assignee means the current actor
--unassignedonly unassigned
-l, --labelonly with this label (repeatable)
-t, --type, -p, --priorityonly this type / priority
--parent IDonly 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.
  • --full also 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; --full takes both slots. If the budget remains busy for 30 seconds, a search refuses with board search busy. Wait/refuse events are appended to $HOME/.config/seeds/search-admission.jsonl for observation; failed telemetry does not weaken admission.
  • Closed matches are hidden and counted unless --all or --status: the text says how many, and --json carries hidden_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 --days days old (default 30), oldest first. --days 0 lists everything not closed.
  • Closed seeds are left out unless --status names closed. --status is 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 (blocked counts the status, as br does, not the dependency graph), what ready would 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 --json does 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
  • q is 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, --parent and -e/--estimate (minutes).
  • status is 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:

verbwhere it lives
queryquipu stored queries
upgradecaboodle (caboodle update-release --tool seeds)
gate, schedulershuttle
auditquipu provenance: every sd write is a transaction with its actor; read past states with sd show --at <tx>
robot-docsthis 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
flagmeaning
--bygroup by status, priority, type, assignee or label
--status, --type, --assigneefilters
--include-closedcount 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
flagmeaning
--title, -d, --description, --notes, --design, --acceptance-criteria (alias --acceptance)set the text (see below for replacing existing text)
--forceallow replacing a non-empty description, notes, design, acceptance criteria or agent context with different content
-s, --statusopen, in_progress, blocked, deferred, closed
-p, --priority0-4 or P0-P4
--assignee NAMEset the assignee ("" clears it)
--owner WHOset the owner ("" clears it)
--external-ref REFa reference to the same work elsewhere, br’s external_ref ("" clears it)
--agent-context JSONbr’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 WHENdue date, the forms of --defer; a bare date or tomorrow is 09:00 local, stored as a UTC instant ("" clears it)
--estimate MINtime estimate in minutes, 0 to 525960
--check-acceptance ITEMStick 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 ITEMSuntick items; the same selectors
--add-acceptance TEXTappend - [ ] TEXT to the checklist (repeatable). Checklist edits and --acceptance-criteria are not combined in one update
--claimset assignee to the actor and status to in_progress, only if the seed is open, unclaimed and unblocked
--add-label, --remove-labelrepeatable
--defer DATEhide from ready until this date or instant ("" clears it)
--workflow-run RUNthe 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"
flagmeaning
-r, --reasonwhy: what landed and how you know
--outcomehow 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, --forceclose even though a blocks dependency is still open
  • Closing without --reason warns 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’s outcome: 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 become open, and closed_at and close_reason are cleared. -r is stored as the comment Reopened: <reason> in the same transaction as the status change. A seed that is not closed is skipped.
  • defer: status deferred, so the seed leaves ready. --until takes +30m, +2h, +1d, +1w, tomorrow, YYYY-MM-DD or an RFC 3339 instant (relative forms resolve to UTC). Without --until the seed is deferred with no date. Closed seeds are skipped.
  • undefer: deferred seeds become open with 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 --cascade or --force: sd prints br’s preview, writes nothing, and says so on stderr.
  • --cascade tombstones every live seed that (transitively) depends on the target. --force deletes 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 tombstone is refused: delete is the only way in.
  • A delete is not a close: no outcome, no closed_at. The reason is kept as the comment Deleted: <reason>, written in the same transaction.
  • show still returns a tombstone; list, ready, search, count, stale, blocked, epic and stats leave it out (stats reports how many as tombstone_issues). --status tombstone lists them explicitly.
  • sd sync carries 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
flagmeaning
-t, --typeonly these types (repeatable: any of them)
-p, --priorityonly these priorities (repeatable: any of them)
-l, --labelonly seeds with this label (repeatable: all must match)
--limitpage size (default 50, as br; 0 = all)
  • A seed is blocked when it is not closed and has at least one open blocks dependency. A blocked status 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_more in --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
  • add and remove change every named seed in one transaction, or none: an unknown id is exit 3 and nothing is written.
  • Each seed reports br’s status: added or exists, removed or not_found. A seed that needs no change is not written.
  • list and list-all include 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-eligible closes every eligible epic in one transaction, with br’s reason All 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 -> T when B is blocked by T, and P -> C when parent P waits on its child C.
  • The default walks dependents over blocks edges only: what finishing this seed unblocks. --dependencies walks the other way over both kinds: blockers, and a parent’s children.
  • --all takes 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.
  • --compact prints one line per seed; --dot prints 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 history lists 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 --at reads, 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.
  • --at does 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.
  • --since takes a date, an RFC 3339 instant, or a span back from now (+7d, +2w, +12h: br’s relative form). --since-tag and --since-commit ask 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
typeexpected sections
bug## Steps to Reproduce, ## Acceptance Criteria
task, feature## Acceptance Criteria
epic## Success Criteria
chore, docs, questionnone
  • These are br’s templates and hints. A heading at any level counts, and the match ignores case.
  • Warnings, not errors: lint exits 0 either way.
  • Without ids it checks open seeds; -s all checks 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 log of 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.1 does not make sd-a3f an orphan.
  • Outside a git repository it is a usage error (exit 2).
  • --fix is br’s interactive close: for each orphan it asks on stderr Close <id> (<title>)? [y/N] and reads a line from stdin. Only y or yes closes it, with br’s reason Implemented (detected by orphans scan); anything else, and no input at all, skips it. So sd orphans --fix </dev/null changes nothing. --at cannot 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):

typemeaningblocks ready?
blocks (default)issue cannot proceed until depends-on closesyes
parent-childissue is a child of depends-on (one parent)no
relateda linkno
discovered-fromissue was found while working depends-onno
  • Both seeds must exist (exit 3). A seed cannot depend on itself, and a blocks edge 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 remove of 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 --check asks for the latest published release (GitHub’s latest release of scbrown/seeds; SEEDS_RELEASES_URL overrides 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 plain x.y.z. It never reports “up to date” for an answer it could not compare. --json: {current_version, latest_version, update_available, source}.
  • version needs no configuration. seeds does not embed its commit, branch or compiler, so those --json keys are null rather than guessed.
  • where reads the configuration only. It never creates a store or a project id, so it is safe to run anywhere, including before the first write.
  • info reports the exact seed count, current transaction and local file size. comment_count is explicitly null, with comment_count_status: "not_computed". Human output says the comment count was not computed; it never substitutes zero.
  • info --exact-comments is an administrative opt-in: comment_count is 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 --schema adds schema: {shapes_sha256, target_classes, vocabularies, json_schemas}. The digest identifies the shapes this build validates a ledger against (the same bytes as a pendant’s shapes.ttl); sd schema prints the --json schemas.
  • info --whats-new and info --thanks describe the build, not a ledger, so they open no store and work anywhere. --whats-new prints 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.token says only whether one is set, and from where: (set: SEEDS_QUIPU_TOKEN), (set: token_file) or (unset).
  • set, delete (unset) and edit are not built (exit 20) and name the file to edit. Changing project.prefix or project.graph moves 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, or elsewhere (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, operation from 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 real show, list, blocked, ready, stats, comments add and error output against them, so the published schema cannot drift from what sd prints.
  • Objects are closed-world: every listed key is required (absent values are null), and no other key appears. Writes add a tx on 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
checkwhat it asserts
config.resolvesthe configuration resolved (and from which layer)
project.id.seeds/project-id holds an id (a warning before the first write)
.gitignorethe local store’s directory ignores the store file
store.opensthe store or server answers; how many seeds and comments, at which tx
ledger.validthe same validation sd import applies: shapes, single-valued fields, dangling blocks edges
deps.targets_existevery dependency of every kind points at a seed
deps.no_cyclesno 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: doctor never repairs. The verbs refuse to create most of these states; doctor finds 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-write command your introducer runs on the quipu host (without --allow-write the key could only vouch for shares, and writes are refused with scope). 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’s examples/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 export writes runs into quipu with camayoc’s workflow vocabulary: aegis:WorkflowDefinition, aegis:WorkflowRun (aegis:runOf its definition, aegis:currentState), and an append-only aegis:TransitionEvent per step. Runs are urn:shuttle:run:<id>, definitions urn: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.

optionmeaning
--jsonoutput 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, --versionhelp and version

Verbs

verbflags (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.

codemeaning
0success
1failed: store I/O or anything without a more specific code
2usage: an unknown verb, a bad flag or value, --at on a write
3not found: an unknown id, dependency or comment
4conflict: 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
5refused: 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)
6configuration: contradictory or unreadable config (for example store and url both set)
7unreachable: the configured quipu server cannot be reached (seeds never falls back to a local store)
8indeterminate: 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-18retired: the v0 shell’s per-verb “not yet implemented” codes. Never reused.
20not built: reserved for a configured capability that exists in the design but not in this build (no verb returns it today)
21elsewhere: 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 --json in 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 sync between them with a three-way, field-level merge (mode 3); sd export / sd import in any mode; a git merge driver for pendants. See Storage modes.
  • A link to shuttle runs: --workflow-run records 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

whatwhy it matterswhere
beads sync: two-way sync between a beads/br store and a seeds graphreading existing work, and handing work backa later phase
formulas (molecules)workflow templates that stamp and drive chains of seedsshuttle integration, sketched in Formulas; seeds will not grow its own engine
a cheaper remote writemode 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 clonequipu’s full reconstruction share
reading quipu’s existing WorkItem recordsexisting beads readable from day onecamayoc’s tracker projection keeps status on Observations; a read adapter is needed
ready / blocked / plate as camayoc stored queriesone definition shared by every callerthe ready SPARQL is in src/vocab.rs (ready_query), ready to move
sd capabilities --jsonintent 4 of the intent mapnot started
sd release <id> --if-assignee Xintent 1: a conditional releaseupdate --claim is the claim half
desire-path redirect (dp alias --cmd bd --replace sd)agents keep typing bdlives in desire-path
caboodle install and verifystack installationlives in caboodle
signed or delta pendantsverifiable ledger history between teamsquipu’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:

toolwhat seeds uses it for
quiputhe store, embedded as a library: SPARQL reads, transactions, time travel, pendants
camayocthe 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
caboodleinstalls seeds and proves it in caboodle verify
desire-pathredirects bd to seeds, and records every verb seeds refuses
shuttleformulas: the workflow engine that will stamp and drive seeds; a seed records its run today
yupananot a runtime dependency; the counting board seeds are named after
bobbinnot 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.

documentwhat it is
README.mdthe front page: what seeds is, and a first run
AGENTS.md, CLAUDE.mdinstructions for coding agents working in this repo
docs/book/this book; every page is listed in the sidebar
shapes/seeds.shapes.ttlthe SHACL shapes every write is validated against (The storage model)
clippy.tomlthe 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.