One binary, luria, dispatching to plain functions. Every command takes --help. Flags are shown GNU-style; the CLI (python-fire) also accepts --flag=value and positional forms.

commandone line
luria initscaffold a record into a repository
luria configwrite a starting luria.yaml, without scaffolding
luria newfile a new entry of any configured kind
luria relatewrite a relation into an existing document
luria repairwrite every mechanical source repair
luria indexrender every generated view
luria linkturn bare codes and wikilinks into links
luria lintenforce the record’s invariants
luria reportswrite the status reports
luria collectassemble fragments into their target
luria concretizegive temporary codes real numbers
luria remotesinspect and verify foreign references
luria migrateexecute a rename/move spec
luria sitestage the record as a publishable site
luria exportwrite the record as a SQLite database, for querying
luria upgradecarry a record across a version boundary

luria init

luria init [INTO] [--issue-url URL] [--schemes S] [--journals J]
           [--config FILE] [--dry-run]

Scaffolds the default record — templates, stubs, tag vocabulary, principle seeds, a docs index, a CLAUDE.md, and CI workflows — into INTO (default: the project root, found via luria.yaml, then .git).

  • Existing files are always skipped, never overwritten; each is reported. Re-running on a grown project is safe.

  • --issue-url seeds issue_url in the scaffolded luria.yaml; append {n} yourself or let init place it. Left out, it is inferred from the origin remote — git@github.com:acme/widgets.git becomes https://github.com/acme/widgets/issues/{n}, and init says so as it goes. That one value also gives site its title, its Pages URL and the base a link falls back to, so a repository with a remote needs no configuration at all. GitHub and GitLab are recognised; any other host infers nothing, because a wrong issue URL renders a broken link on every entry that carries an issue.

  • --schemes and --journals add families to the shipped shape, for a project that wants the defaults plus a little:

    $ luria init --schemes "RFC,SPEC:document" --journals "incidents:day"

    Each entry is NAME or NAME:kind — index or document for a scheme (which one?), year, month or day for a journal. Paths follow the prefix, so RFC gives record/rfcs.d rendered into docs/rfcs; rename them afterwards if the family is better called something other than what its codes spell.

    The shorthand is an argument, not a stored format: what it writes is the ordinary commented table, so nothing reads it back and the config looks like every other project’s. It is additive — the template’s own ADR and DP tables stay, which is what keeps them alive given that a declared family replaces the shipped one whole. Removing a default means deleting a table.

  • --config FILE scaffolds from your own luria.yaml instead of the shipped one — this is how you init a record with no ADR scheme at all, rather than one with an extra family. Refused if a luria.yaml already exists (merge by hand instead), as are --schemes/--journals: where a config exists the shape is declared, and a flag should not edit it.

  • --dry-run prints the plan and writes nothing.

luria config

luria config [INTO] [--schemes S] [--journals J] [--issue-url URL] [--stdout]

Writes the luria.yaml that luria init would have written, and stops.

The shorthand covers the two things projects usually vary. A project that also wants a different directory, a narrowed status vocabulary or a tag group has to edit the config — and editing it after a scaffold means moving directories the first run already created. This is the order that avoids that:

$ luria config --schemes "RFC,SPEC:document"
luria.yaml
 
Edit it, then `luria init` to scaffold the shape it declares.
$ $EDITOR luria.yaml
$ luria init
  • Takes the same --schemes, --journals and --issue-url as init, and infers the issue URL from the origin remote the same way.
  • Refuses to overwrite. A config that exists has already started.
  • --stdout prints instead of writing, which also works where a config exists — looking is not writing.

luria new

luria new [KIND] [--title T] [--status S] [--summary S] [--tags a,b]
          [--influenced_by ADR-1,ADR-2] [--body TEXT] [--name N]
luria new [KIND] --draft FILE

Files one new entry and prints its path. KIND is any name the project’s luria.yaml gives the machinery, lower-cased:

  • a scheme prefix (adr, rfc, …) — scaffolds the next document from the scheme’s _template.md, with the number allocated (or a temporary code minted, if the scheme allocates on merge) and today’s date stamped;
  • a journal name (devlog, incidents, …) — creates dir/yyyy/mm/dd/hhmmss.md stamped with the current time;
  • a fragment directory’s short name (changelog for record/changelog.d) — creates a timestamped fragment, or a named one with --name;
  • migration — scaffolds a numbered migration spec in record/migrations.d/.

With no KIND and exactly one journal configured, the journal is the default — luria new --title "…" is the cheapest possible filing. The field flags (--title, --status, --summary, --tags, --influenced_by) pre-fill the scaffolded frontmatter; --influenced_by is written as the list of codes the index reads, and a scheme’s own declared reference fields are accepted by name the same way. --body TEXT hands over the prose: it replaces the template’s body below the # CODE: title heading of a scheme document, the placeholder paragraph of a journal entry, or the whole of a fragment. The heading stays luria’s — it is derived from the title — so a body that opens with one has it dropped rather than doubled.

--draft FILE files what a tool wrote instead of flags: one draft object ({"scheme": "ADR", "title": …, "tags": […], "summary": …, "influenced_by": […], "body": …}) or a luria-drafts document holding several ({"format": "luria-drafts", "drafts": […]}), which is what strata-g’s Record — luria drafts (JSON) export writes for the entries drafted on its canvas. Each draft files into the kind its scheme names, with the same validation the flags get — a key the scheme has no opinion about is refused by name — and one path is printed per draft. The generated view of your own config lists every kind your project accepts (see the record for this one).

luria relate

luria relate SOURCE FIELD TARGET
luria relate --draft FILE

Writes one relation into a document that already exists: TARGET joins FIELD of the document SOURCE names, and the path is printed. The edit is one line in the frontmatter; the form’s comments and every other field stay as they were.

  • FIELD is a relation the source’s scheme reads: influenced_by, the successor field (superseded_by unless the scheme renames it), or any field declared under the scheme’s references:. A prose mention is not a field, so cites is refused by name.
  • TARGET has to resolve — a local code, or a remote one where the field allows any scheme — and has to be of the scheme a typed field names. An unresolvable code is refused, not written.
  • A relation already present is reported, never duplicated. A scalar field already set to something else is refused rather than replaced.
  • Naming the successor on a document still in force says so: the status is yours to set, and the lint will ask for it.
  • A declared converse is not written here; luria repair completes it, as for every other one-sided relation, and the command says so.

--draft FILE reads the relations list of a luria-drafts document ({"source": …, "field": …, "target": …} by code) — the shape a canvas exports for a relation drawn between two filed documents — and files each entry in turn.

luria repair

luria repair

Writes every mechanical repair to the sources — each one a state the lint reports with this command as its remedy: bare codes in prose become links (what luria link --fix does, over every file), a journal entry filed without created: gets the timestamp its path already implies, and a configuration reference Luria no longer renders here is removed. Prints what changed. Idempotent: a second run changes nothing.

Repairs are a command apart from the views because they land in a different place. A repair touches only the files a branch itself authored, so the generate action commits it onto the branch, where the review reads it; a view is a shared file every branch would rewrite, so it is committed on the default branch only.

luria index

luria index [--check]

Renders every generated view from the sources: scheme indexes and tag pages (or the single concatenated page for render = "document" schemes), journal books, the status reports, the record and configuration reference pages, and the README badge region. Also deletes orphaned files from view directories. Writes views only; the sources are luria repair’s.

--check writes nothing and exits non-zero if any committed view differs from what would be generated, a file sits in a view directory the generator never wrote, or the README’s generated region has drifted — the one staleness check there is, run by the generation job on the default branch right after it regenerates. luria lint asks no staleness question, so it runs on a branch as it is. (Views listed in .gitignore are exempt: an uncommitted view cannot be stale.)

luria link [PATHS…] [--fix] [--links-only]

Spells out what the record left implicit, in two places.

Links. Finds every linkable reference in the given files (default: every non-generated markdown file the record knows about): bare codes (ADR-012, DP-3), temporary codes, remote codes (LU-ADR-013), issue numbers, and [[wikilinks]]. Prints per-file counts; with --fix, rewrites them into links whose relative targets are computed for the directory where each piece of prose ultimately renders — which is why hand-writing targets is the one thing the workflow forbids.

References inside backticks, fences, existing links, HTML comments, and frontmatter (except designated prose fields) are left alone.

Back-references. A reference field may declare its converse — the field holding the same relation read backwards:

schemes:
  LIT:
    references:
      extends:
        scheme: LIT
        many: true
        converse: extended_by
      extended_by:
        scheme: LIT
        many: true
        converse: extends
      compared_against:
        scheme: LIT
        many: true
        converse: compared_against

A relation naming itself is what symmetry is, so there is one rule and not two. --fix writes the side that is missing, in either direction, so the author states the relation once on whichever document they were holding. One side holding it alone is the one-sided-relations finding.

A relation with no declared converse is left entirely alone — nothing written, nothing reported. Its reverse edge would be a guess, and a guess in the record is worse than an absence, because an absence looks like one.

Adding and removing are both propagated. A one-sided pair means one of two opposite things — somebody wrote the relation and the other side has not caught up, or somebody deleted it and the other side is stale — and the working tree holds neither answer. Which side changed since the last commit does, so --fix reads it from git:

since HEAD--fix does
a side gained the relationwrites it to the other side
a side lost itremoves it from the other side
one side gained while the other lostnothing — reports the conflict
nothing changedwrites the missing side

The last row is what a corpus predating the fixer needs. It can read a deletion wrong when that deletion was committed before --fix ran, and it is self-correcting: delete it once more and the deletion is a change. With no repository or no commit yet, every relation reads as added, which is right for a document git has never seen.

--fix never makes luria lint worse. The fixer edits frontmatter and frontmatter is what the contract judges, so a repair can move a document from satisfying its scheme to violating it — a back-reference added into a field group that permits only one of two fields, or a stale one removed out of a field the document’s status requires. Any repair that would introduce a new violation is not applied; the pair stays one-sided and the finding says which two rules disagree. A document already in breach elsewhere still gets its back-references, or one unrelated mistake would freeze every relation it stands in.

The one thing --fix will not touch is a contradiction: a document naming another in both directions of one pair, two documents each claiming to come first, or a relation withdrawn on one side and asserted on the other in the same working tree. Nothing is missing there — two incompatible things are present, and the data does not say which was meant.

Completion reads every document of a scheme, because that is what “missing” is defined against, so PATHS narrows the linking only. --links-only skips it entirely — the behaviour --fix had before completion existed, for a run that must touch nothing but link text.

luria lint

luria lint

The contract, in two halves.

Violations (exit 1), each with the file and line:

  • a scheme document without frontmatter, status:, title:, or tags:; a status outside the vocabulary (Active, Proposed, Deferred, Superseded, Rejected; a note still riding in status: rather than in status_note:; Superseded with no superseded_by:) or undeclared in the scheme’s vocabulary; a missing field the scheme requires, or one its required_when demands at the status the document is at; a title: disagreeing with the body heading
  • a constraint the scheme declares, broken: tag-group rules (exactly-one, at-most-one, excluded_by), a field group’s at-least-one, a value outside a closed vocabulary, a reference field holding something that is not a resolvable code of the scheme it names, a relation’s declared invariant disagreeing across the edge, or a field marked unique whose value appears on a second document of the same scheme
  • a version: above 1 with no history:, or history that ends on a different version than the document claims
  • a journal entry with no derivable created:, or filed at a path its timestamp says is wrong
  • a number collision inside a scheme, an alias two documents both claim, or a code on the reserved tmp prefix
  • a stray hand-written file in a view directory (whether a committed view is current is luria index --check’s question, asked in the generation job on the default branch; a branch carries no view of its own)
  • a bare code or unexpanded wikilink that luria link --fix would rewrite, or a wikilink that resolves to nothing
  • a docs page missing from the docs index (docs/README.md)
  • luria.yaml naming a warning class that does not exist in fail_on or mute, or the same class in both

Warnings, printed but passing — each is a judgement call, surfaced with its acknowledgement route (see directives) and listed in full in the reports:

retired-citations · unresolved-codes · unresolved-citations · hand-written-urls · broken-targets · remote-drift · source-mismatch · source-unchecked · inert-status · legacy-spellings · narrow-titles · stale-directives · template-drift · broken-chains · one-sided-relations · spent-upgrades · pending-documents · unlinted-files · workflow-temp-codes · unlinked-site

Any of those class names listed in lint.fail_on fails the build instead. Only unacknowledged findings ever reach a class, so acknowledgements keep working under enforcement.

lint.mute is the other direction: a class named there is not reported at all. Where fail_on changes what a finding costs, mute decides whether it is heard — the blunter instrument, for a check a project has decided is not useful to it, where an acknowledgement directive is the right shape when the finding is about one document and carries its reason at the citing site. Every failable class is mutable, plus acknowledged-uniformity, which is mutable only: a project cannot promote its own acknowledgement to a failure. Naming a class in both is refused rather than resolved — a record cannot both enforce a check and refuse to hear it, and guessing which was meant would make one of the two settings a lie. luria reports renders the full accounting either way, so muting changes what a run prints and not what the record says.

lint.network promotes one class on its own: under network = "require", source-unchecked fails without being named in fail_on, because the setting already said that a green run means the references were verified.

luria reports

luria reports [--out DIR]

Writes the two status reports (default: the configured reports path, docs/reports/): pending-decisions — every Proposed/Deferred document with age and citation counts — and reference-status — retired documents cited unacknowledged, codes resolving to nothing, files opted out of checking, and directives that no longer apply. luria index writes these too; the standalone command exists for CI jobs that want only the reports.

luria collect

luria collect [DIR] [--commit]

For each configured fragment directory (or just DIR): read every fragment in the order it entered git history, insert the non-empty ones at the target’s <!-- luria-insert-here --> marker (the changelog style adds a dated heading; the default appends), and delete the fragments. --commit stages and commits the result with [skip ci] — the shape a scheduled CI job wants.

luria concretize

luria concretize [--check]

For schemes with allocate = "merge": assign each temporary code (ADR-tmp3kf9x) the next real number, rename the file, rewrite every reference in docs and scanned code, and record the old spelling under formerly: so stale spellings keep resolving. Run it where merges serialize — the push-to-main CI job — and never on a PR branch, which would re-create the collision the temporary codes exist to avoid. --check exits non-zero while any temporary code is pending.

luria remotes

luria remotes [--refresh] [--check] [--pin [CODE]] [--resolve [CODE]]

Prints every foreign code the record cites, per remote, with the URL each resolves to and the evidence behind it (explicit template, discovered filename, or bare convention). --refresh re-discovers each remote’s actual filenames via the GitHub API and writes remotes.lock.json — committed, so CI and offline checkouts resolve identically. --check HEAD-probes every cited URL and reports what a reader would find: broken, absent from the remote, or unverifiable because the repository is not readable anonymously.

--pin endorses remote content: it fetches each document, hashes it, and stores the hash in the same lockfile. A remote document has no status this project can read, but a change in its bytes is knowable — --refresh re-observes every pinned document, and luria lint reports each one whose content moved on since its endorsement (the remote-drift warning class, promotable via fail_on). Review the change, then luria remotes --pin CODE endorses it again.

Every pin has a registration — the thing that says it should exist, and whose removal retires it. pin = true on a remote (or one of its schemes) registers the whole code family: each cited reference is pinned automatically, and the lint reports any the lockfile has not endorsed yet. A pin: comment directive registers one arbitrary URL where it is cited. An explicit --pin CODE registers one ad-hoc pin, whose lockfile entry is its own registration. A bare --pin syncs the lockfile to the registrations — endorsing what is newly registered, re-observing what exists, dropping what nothing cites or flags — and it never re-endorses drifted content: that always takes the explicit command, so a scheduled sweep cannot quietly launder a drift finding.

--resolve asks what each identifier actually is. Where --pin watches for a document changing, this watches for a citation that was never right: it fetches the title behind every arxiv:/doi: field — any frontmatter key named after a configured remote — and records it in the lockfile, which luria lint compares against the title the document claims. A disagreement is the source-mismatch warning class; source-ok: acknowledges a deliberate one, which the legitimate cases need — a nickname the project prefers (AdamW: Decoupled Weight Decay Regularization), a subtitle trimmed, a v1 title that changed between versions.

The lockfile is a cache with an endorsement in it, not the boundary of what may be known, and lint.network says how far the lint may go:

auto (default)ask about identifiers the lockfile has no answer for — normally the one citation a contribution just added — and write what comes back. Falls back to source-unchecked when the network is not there.
neveranswer only from the lockfile. The hermetic build.
requirenot being able to ask is a finding, promoted to a failure without needing fail_on. A green CI run then means the references were verified rather than remembered.

An identifier nothing has verified is source-unchecked rather than silent, because a citation is likeliest wrong in the minutes after it is typed — exactly when nothing has resolved it yet.

Failure kinds are told apart by status: 404/410 is upstream saying the identifier names nothing (an answer, recorded and not retried), while 429/503 is retried with backoff, honouring Retry-After, and reported as unchecked if it persists rather than written down as an absence.

A remote declares how to ask, for the same reason pin_url is declared rather than derived — only the project can vouch that a URL serves metadata worth trusting:

remotes:
  ARXIV:
    uris:
      title: https://export.arxiv.org/api/query?id_list={1}.{2}
    title_re: <entry>.*?<title>(.*?)</title>
  DOI:
    uris:
      title: https://api.crossref.org/works/{uid}
    title_re: '"title":\s*\[\s*"(.*?)"'

title_re’s first capture group is the title, matched with re.DOTALL. A remote that declares neither is skipped entirely: most remotes are records, not metadata APIs, and with no way to ask the check has no opinion.

What gets hashed is the construction’s stable bytes, not the page a reader lands on. A GitHub file construction qualifies on its own; any other remote declares where its stable bytes live with a pin_url template — arXiv’s immutable e-print archive behind its abstract page, a forge’s own raw scheme — because a rendered page’s markup churns under identical content, and a hash of it would cry wolf. Without either, the command says so rather than storing a hash that would drift on its own. Under the hood these are two entries in one table: a code relates to a set of named URIs (read, bytes, and any name a project declares in remotes.X.uris), each a template over one vocabulary — see the configuration reference.

An arbitrary URL — a spec, a dataset card, a post the design leans on — is pinned by flagging it where it is cited (<!-- pin: https://… — why it matters -->, see comment directives) and running the same --pin. Deleting the flag retires the pin.

luria migrate

luria migrate SPEC [--dry-run] [--commit]

Executes a migration spec from record/migrations.d/ (SPEC can be a path, a filename, or a unique prefix like 0001). Two operations:

  • rename_scheme — rename a prefix wholesale: files move (git mv), every reference and anchor in the repository is swept to the new spelling, config tables are renamed, and each moved document is stamped formerly: with its old code.
  • move_doc — move one document into another scheme, where it gets a temporary code for the next concretize.

Either operation takes strategy = "supersede" to copy instead of move, leaving a tombstone (status: Superseded, superseded_by: …) at the old code. --dry-run prints the full plan. --commit commits the sweep and appends the commit to .git-blame-ignore-revs, so blame reads through the rename. The spec itself is never swept: its mapping is the durable memory of the old names.

luria site

luria site [--out build/site]

Stages the record as a Quartz vault: every publishable page at its repository path (so all relative links keep working), every page titled (CODE: title for a scheme document), the record’s facts (status · version · issue · influenced by · the typed edges in and out of it) written into the staged frontmatter as properties Quartz renders, codes registered as aliases, README as the landing page, links to unpublished files redirected to the repository, and the theme/branding from site rendered into Quartz config. The published site gets search, backlinks, and a local graph per page. The actions/site composite action builds the staged vault with a pinned Quartz for GitHub Pages — see adopting.

luria export

luria export [--out build/record.sqlite]

Writes the record as one SQLite file: a row per scheme document (code, scheme, number, title, status, version, date, path, the whole frontmatter as JSON, and the body), a row per field value with list fields exploded into positioned rows, the typed edges (superseded_by:, influenced_by:, every declared reference field), every citation the lint’s scanner counts — where it sits, whether it resolves, whether a directive excuses it — and every journal entry with its tags. Rebuilt from scratch on each run, so a row never outlives its source; refuses to overwrite a file it did not write. It is a generated view for asking the record questions with sqlite3, Datasette or pandas — never a source, and not committed (ADR-116).

luria upgrade

luria upgrade [NAME] [--dry-run] [--root DIR]

Writes what a new version of Luria requires into a record that predates it. With no NAME, lists the upgrades that exist and what each is waiting on:

$ luria upgrade
temporary — each is deleted once every record has run it:
 
  luria upgrade yaml
      convert `luria.toml` and the per-scheme vocabulary files into one
      `luria.yaml`
      remove when: 1.0.0, or when no record on TOML remains.
  • yaml — folds luria.toml and the per-scheme vocabulary files into a single luria.yaml, carrying the comments across. This is the only way over that boundary: the current version does not read TOML at all.
  • statuses — declares status: as the controlled vocabulary it always was, for a record filed before it became one.

--dry-run prints what would be written. --root points the run at a tree that is not the working directory, for upgrading a record from outside it.

Every command here is temporary by construction. An upgrade exists to move records that predate a change onto it; once they have moved it is dead code that still has to be read, tested and explained. Each one carries the condition for its own deletion, and luria lint reports an upgrade this record no longer needs as spent-upgrades — so the question of removing it comes up on its own rather than waiting to be remembered. Nothing here goes through the config loader: the config an upgrade repairs is the config the new version refuses to load, so a command that needed it would be unrunnable in exactly the situation it exists for.

Environment

  • LURIA_ROOT — overrides project-root discovery; how CI and the test suite point a run at a tree that is not the working directory.
  • LURIA_JOBS — caps the thread pool used for rendering, scanning, and URL probing. LURIA_JOBS=1 is the deterministic escape hatch.

Every module is also runnable standalone (python -m luria.ref_status --all) for projects that vendor a file rather than installing the package.