adrinaline: specification
adrinaline ("ADRs in a line") manages Architecture Decision Records the way
ticket manages tickets: plain Markdown files
in the repository, a fast CLI named adr, and output that humans and coding
agents both read reliably.
1. Goals and non-goals
Goals
- G1: One command bootstraps an ADR practice in any repository (
adr init). - G2: Every ADR is a plain Markdown file with YAML frontmatter; hand edits are first-class and the tool never becomes the only way to change a record.
- G3: ADRs are numbered sequentially (
0001,0002, …). Collisions between branches are detected early and repaired with one command. - G4: The CLI is the agent interface ("bash is all you need"): every command
supports
--json, errors are machine-readable, andadr primegives an agent everything it needs at session start. - G5:
adr checkvalidates the whole ADR set and is suitable for CI and pre-commit. - G6: Git-aware but read-only: the tool never stages, commits or fetches.
- G7: The ADR set is a typed graph. One in-memory graph backs every query,
validation and export, and
adr graphrenders it as text, Mermaid, DOT or JSON. - G8: ADRs connect to code and history:
scopeglobs tie decisions to files (adr for,adr check --diff), and every read command works against any git revision (--at REF). - G9: Decision health is measurable: review dates and churn over an ADR's
scopeshow which decisions are overdue or stale (adr drift,adr review,checkwarnings), deterministically and from git alone. - G10: Commits cite the decisions they follow:
ADR:andADR-Exempt:trailers (§13) feedadr traceand theuncited-changecheck.
Non-goals (v1)
- Importing existing ADR sets (adr-tools, Nygard style).
adr checknames, per file, the frontmatter that is missing (status and date, read from the adr-tools header where present); fixing an import is manual (check --fixonly repairs mechanical findings, §11). - TOML frontmatter.
- A plugin system, an MCP server, a web UI or a static-site generator.
- An interactive graph viewer. Planned for v2 as
adr graph --format html(one self-contained page built on the JSON export). - A generated index file on disk (
adr indexprints to stdout only). - Standalone binaries. Distribution is PyPI only.
2. Prior art and reuse decision
Both wedow/ticket (bash, MIT) and its Go port radutopala/ticket (MIT) were
reviewed. Neither is forked: their ID model (random) differs from ADR
numbering (sequential), most of their features (priorities, claims, bulk,
stats) do not apply, and the bash original parses YAML with sed/awk, which is
fragile. No code is copied. Ideas adopted:
| Idea | Source |
|---|---|
Short agent hint plus a comprehensive help |
ticket |
| Partial ID resolution, whitespace-tolerant | ticket |
| Directory discovery by walking up, env override | ticket |
| Typed relations with tree view and cycle checks | ticket |
Global --json flag through one output helper |
Go port |
| Library core with a thin CLI layer | Go port |
| Black-box tests of the CLI in a subprocess | Go port |
Shipped agent skill (SKILL.md) |
Go port |
.adr-dir file for locating the ADR directory |
adr-tools |
3. Stack and packaging
- Python 3.14+, managed with
uv. Relied on:PurePath.full_matchfor**glob matching (3.13+) without a dependency. - Runtime dependencies:
pydantic(ADR model and validation),ruamel.yaml(round-trip YAML that preserves comments, key order and quoting on rewrite). CLI parsing uses stdlibargparse. - Dev tooling:
pytest,ruff,ty. - PyPI distribution
adrinaline, console scriptsadrandadrinaline(identical). Install viauv tool install adrinalineor run viauvx adrinaline.
Package layout
src/adrinaline/
__init__.py version
model.py Adr, Status; pydantic models and transition rules
frontmatter.py split/parse/render Markdown + YAML frontmatter (ruamel)
repo.py locate ADR dir, load all ADRs, resolve references,
allocate numbers, atomic writes, relation updates
graph.py AdrGraph: nodes, typed edges, derived inverses,
traversal, cycles, rank (PageRank), scope matching,
renderers (text, mermaid, dot, json)
prime.py briefing: scoring and budgeted selection
drift.py decision health: churn per ADR since its baseline, review findings
check.py validation rules returning Finding objects
fix.py repairs for mechanical findings (`check --fix`)
git.py thin wrappers over the `git` executable (read-only)
output.py human vs JSON rendering, error envelope, exit codes
cli.py argparse wiring; one small function per command
(`cmd_<name>`, so `adr for` maps to `cmd_for`; accept,
reject and deprecate share `_set_status()`)
data/ template.md, 0001 ADR text, skills/adr/SKILL.md, references/
tests/
cli.py keeps the domain rules in repo, check, graph and fix: a command
is mostly a call into them plus a call into output. It does hold the agent
wiring of §14 (_skill_dirs, _install_skill, _installed_skills) and the
supersede and cycle precheck of cmd_new. repo loads the files once per command into an
AdrGraph; check, prime, graph, show, renumber and relation
commands all query that graph instead of reading files themselves.
4. Storage format
Directory and filenames
- ADR directory:
docs/adr/by default (see §5). - ADR file:
NNNN-slug.md.NNNNis a 4-digit, zero-padded positive integer; numbers above 9999 widen naturally (10000-…).slugis lowercase ASCII, words joined by-, derived from the title at creation. - Template:
docs/adr/.template.md(hidden, so it never appears as an ADR). - Files in the directory that do not match
^\d{4,}-[a-z0-9]+(-[a-z0-9]+)*\.md$(no leading, trailing or doubled hyphens in the slug) are ignored by all commands exceptcheck, which reports them as warnings (README.mdand dotfiles are exempt). - Number and slug live only in the filename; they are never duplicated in frontmatter.
Frontmatter
YAML between --- lines at the top of the file. Keys follow MADR 4 where MADR
defines them.
---
status: proposed # required
date: YYYY-MM-DD # required, date of the last status change
decision-makers: [Jane Doe] # MADR, optional
consulted: [] # MADR, optional
informed: [] # MADR, optional
tags: [storage] # optional
scope: ["src/db/**"] # optional, globs relative to the repository root
review-by: YYYY-MM-DD # optional, when the decision should be revisited
reviewed: YYYY-MM-DD # optional, date of the last review (`adr review`)
revisit-if: "…" # optional, the assumption that would invalidate it
supersedes: [] # relations, optional, lists of ADR numbers
superseded-by: [] # the only stored inverse (see §8)
amends: []
depends-on: []
relates-to: []
---
- Relation values are integers (
[3, 7]). Empty relation keys are omitted when the tool writes a file. review-byandreviewedare ISO dates (bad-dateotherwise);revisit-ifis a string (bad-fieldotherwise). All three are absent unless set by hand,adr new --review-byoradr review.- Unknown keys are preserved on rewrite and ignored otherwise.
- The title is the first
#heading of the body. It is required.
Body
New ADRs are rendered from the template (MADR 4 minimal):
# {title}
## Context and Problem Statement
## Considered Options
## Decision Outcome
Chosen option: "…", because …
### Consequences
* Good, because …
* Bad, because …
{title} is the only placeholder. A project may edit .template.md freely,
including its frontmatter defaults (for example tags or decision-makers);
status and date are always set by the tool. adr new validates the
rendered ADR before writing it; a template that yields an invalid ADR fails
with bad-template and writes nothing.
Writing files
- Every write goes to a temporary file in the same directory followed by
os.replace, so a crash never leaves a half-written ADR. - Rewrites change only the keys being edited; ruamel round-trip keeps the rest byte-stable where possible.
- New ADR files are created with exclusive create (
O_EXCL); if the number is taken by a concurrent process, the next free number is tried. - Block-style YAML lists are re-indented (and flow lists kept) on rewrite; this formatting change is accepted.
5. Locating the ADR directory
First match wins:
ADR_DIRenvironment variable (absolute, or relative to the current directory).dirin[tool.adrinaline]of the nearestpyproject.toml, or in.adrinaline.toml(top-level keydir), searching from the current directory up to the git root. Relative to the file that defines it. If both files exist in the same directory,.adrinaline.tomlwins.- An
.adr-dirfile (adr-tools convention), searched the same way; its single line is a path relative to the directory containing it. docs/adrrelative to the git root, if it exists.
Outside a git repository, steps 2–3 search up to the filesystem root and step 4
uses the current directory. If nothing exists, every command except init
fails with error code no-adr-dir. adr init creates the directory resolved
by steps 1–3, else <git root or cwd>/docs/adr.
The configuration file found in step 2 (.adrinaline.toml or
[tool.adrinaline]) may also set template (path to the template, relative
to that file, default <dir>/.template.md) and drift-commits (integer,
default 0: the churn at which check reports stale-decision, §11; 0
disables the warning). There is no other configuration. An unreadable or
mistyped configuration fails with bad-config.
6. ADR references
Wherever a command takes an ADR reference REF:
- An integer in any padding (
7,07,0007) matches by number. - Anything else is slugified (§4) and matched against the slugs: an exact
slug match wins; otherwise every slug containing it matches. So
"Use PostgreSQL",use-postgresqlandpostgresall find0007-use-postgresql.md. - Leading/trailing whitespace is stripped.
- Zero matches: error
not-found; its message anddetails.candidatesname up to three close slugs (difflib, cutoff 0.6), so a typo is never resolved silently. More than one: errorambiguous, listing candidates. A duplicated number isambiguoustoo.
7. Status lifecycle
Statuses: proposed, accepted, rejected, deprecated, superseded.
| From | To | Command |
|---|---|---|
| (new) | proposed | adr new |
| (new) | accepted | adr new --accepted |
| proposed | accepted | adr accept |
| proposed | rejected | adr reject |
| accepted | deprecated | adr deprecate |
| accepted | superseded | adr supersede |
Any other transition fails with invalid-transition. --force permits it and
is meant for repairs. Each transition sets date to today (local date,
ISO 8601).
8. Relations
| Relation (stored) | Inverse (derived) | Kind | Status effect |
|---|---|---|---|
supersedes |
superseded-by (also stored) |
directional | target becomes superseded |
amends |
amended-by |
directional | none |
depends-on |
dependency-of |
directional | none |
relates-to |
relates-to |
symmetric | none |
- Only forward relations are stored, on the ADR that declares them.
relates-tois stored once, on the ADR where it was added. - Exception:
superseded-byis also stored on the superseded ADR, so a reader of that file alone sees what replaced it.adr supersedewrites both sides. - All inverses are derived by the graph and appear in
show,graphand JSON output as if stored. supersedesmay only target anacceptedADR (--forceoverrides).- Old ADRs are never touched when a new ADR amends, depends on or relates to them, which keeps merge conflicts on old files rare.
- An ADR may not relate to itself.
supersedesanddepends-onmust not form cycles; commands refuse to create one (cycle), andcheckreports cycles introduced by hand edits.
Graph model
AdrGraph holds one node per ADR (number, slug, title, status, date, tags,
path) and one typed, directed edge per stored relation (relates-to edges
are treated as undirected in queries). Plain dicts; cycle detection uses
stdlib graphlib. Derived views:
- In force:
acceptedADRs with nosuperseded-by. - Current successor: follow
superseded-byto the end of the chain. - Impact of REF: ADRs that reach REF through
depends-onoramends(who is affected if REF changes). - Rank: PageRank over the graph (damping 0.85, power iteration until the
change is below 1e-9 or 100 iterations). Edges
depends-onandamendspass weight to their target,relates-toboth ways;supersedesis excluded so a replaced ADR does not inherit importance. Hand-rolled. High rank means many decisions build on this one ("keystone"). - Governing ADRs of a path: ADRs in force whose
scopecontains a glob matching the path (PurePath.full_match, repository-relative, POSIX separators). - Drift of an ADR: its baseline is the later of
dateandreviewed; its churn is the set of non-merge commits with a committer date after the baseline date (a later day, so a review today clears today's commits) that touched at least one file matching itsscope(same matching as above). Onegit log --since=<earliest baseline> --name-only --no-mergescall over the whole repository serves every ADR (--sincestarts a day early to allow for committer time zones;drift.computemakes the exact comparison); with--at REFthe log starts at REF instead ofHEAD. ADRs without ascopehave no drift.
Revisions (--at REF)
Every read command accepts the global flag --at REF (any git revision:
commit, tag, branch). The ADR set is then loaded from git instead of the
working tree: git ls-tree lists the ADR directory at REF and one
git cat-file --batch process reads all files. The ADR directory is resolved
in the working tree (§5) and looked up at the same path in REF. Mutating
commands reject --at with a usage error. Typical use: "which decisions were
in force at release v1.2" (adr list --at v1.2 --status accepted).
9. Commands
Global flags: --json (machine output), --dir PATH (overrides §5),
--at REF (read commands only, §8),
-q/--quiet (no human output on success), --version, -h/--help.
| Command | Effect |
|---|---|
adr init [--force] [--agents] |
Create the ADR dir, .template.md and 0001-record-architecture-decisions.md (status accepted). Idempotent: existing files are left alone unless --force. Human output ends with a Next steps block (adr new, adr prime, adr skill --install DIR for each detected skill dir (§14) that has no skill); --json adds "next". On a terminal (stdin and stdout are TTYs, no --json/-q) it then offers the agent wiring of §14 as two yes/no prompts; --agents performs both without asking. --json adds "agents": {"instructions": path \| null, "skill": [paths]} when wiring ran. |
adr new [TITLE] [--accepted] [--force] [--tag T]... [--scope GLOB]... [--review-by DATE] [--supersedes REF]... [--amends REF]... [--depends-on REF]... [--relates-to REF]... [--body-file PATH\|-] [--edit] |
Allocate the next number (§10), render the template, write the file, apply relations. --body-file replaces the template body below the title (- reads stdin). --edit opens $VISUAL/$EDITOR afterwards. --scope sets a repository-relative glob in the scope list; absolute or .. globs fail with bad-scope before any file is written. --review-by sets the review date (bad-date if not ISO). --force lets --supersedes target an ADR that is not accepted. Prints the new path. Without TITLE on a terminal (and without --json/-q), prompts for the title (until non-blank), then for --supersedes and --scope unless given; otherwise a missing TITLE is a usage error. Closed input at any prompt fails with io. |
adr show REF [--raw] |
Show one ADR: metadata (including the review line: review-by, reviewed, revisit-if when set), resolved relation titles, body. --raw prints the file verbatim. |
adr list [--status S]... [--tag T]... |
One line per ADR, ordered by number: number, status, date, title. |
adr index |
Print a Markdown table (number with link, title, status, date) for pasting into docs. |
adr accept REF [--force], adr reject REF [--force], adr deprecate REF [--force] |
Status transitions (§7); --force allows any transition. |
adr supersede OLD --by NEW [--force] |
Add NEW supersedes OLD, mark OLD superseded. --force allows an OLD that is not accepted. |
adr link REF RELATION REF / adr unlink REF RELATION REF |
Add/remove amends, depends-on or relates-to on the first ADR only (§8). |
adr review REF [--until DATE] |
Record a review: set reviewed to today and, with --until, review-by to DATE (bad-date if not ISO). Any status. Prints the ADR by number and title with its review date; --json prints the ADR object. |
adr drift [REF]... |
Churn per ADR in force that has a scope (§8), or only for the given REFs: one line per ADR, highest churn first: number, title, commits and distinct files since the baseline, and the date and subject of the last commit. A closing line counts the ADRs in force without a scope (omitted when there are none). --json is a list of {"number", "id", "title", "baseline", "review-by", "revisit-if", "commits", "files": [...], "last": {"commit", "date", "subject"} \| null}. Fails with no-git outside a repository. |
adr graph [REF] [--relation R]... [--depth N] [--reverse] [--rank] [--format text\|mermaid\|dot\|json] |
Render the graph: the whole set, or the subgraph reachable from REF (--reverse follows inverse edges, by default only depends-on and amends, giving the impact set). text (default) is an indented, deduplicated tree; mermaid renders in GitHub/GitLab Markdown; dot feeds Graphviz; json is {"nodes": [{"number", "id", "slug", "title", "status", "date", "tags", "path"}], "edges": [{"from", "to", "relation"}]}. Node styling encodes status in mermaid and dot. --rank orders the nodes by rank: text output becomes a flat list, one rank NNNN title [status] line per node, and JSON nodes gain a rank value; --rank with mermaid or dot is a usage error unless --json is given. |
adr check [--strict] [--fix] [--diff REF] [--format text\|github\|gitlab] |
Validate the set (§11). Exit 1 if any error (or any warning with --strict); notes never fail. --diff REF also reports every changed file (git diff --name-only $(git merge-base REF HEAD), plus untracked files, so commits on REF after the branch point are not counted; exit 3 bad-revision when REF shares no history with HEAD) governed by an ADR in force, as scoped-change findings naming the ADRs to review (a file whose commits all cite an ADR governing it or are exempt, and that has no uncommitted change, is not reported), and judges every non-merge commit in REF..HEAD that touches such a file by its trailers (§13): uncited-change or exempt-change. CI gate: adr check --diff origin/main --strict. --format prints CI annotations (see CI annotations). --fix repairs mechanical findings first (§11). |
adr trace REF |
Commits whose ADR: trailer (§13) names REF, newest first: date, short hash, subject. Only non-merge commits reachable from HEAD (with --at REV, from REV). No citing commit prints nothing and exits 0. --json is a list of {"commit", "date", "subject", "adrs": [numbers]}. Fails with no-git outside a repository. |
adr for PATH... [--all] |
List the ADRs in force that govern each path (§8). --all includes proposed. |
adr renumber REF --to N |
Rename the file, rewrite every relation that points at the old number, and rewrite Markdown links of the form NNNN-slug.md in all ADR bodies. Refuses if N is taken. If the old number is duplicated, numeric relations are left alone (they keep pointing at the remaining file) and only filename links are rewritten. |
adr log REF |
git log --follow for the ADR file (date, short commit hash, author, subject). With --at REV, the ADR is looked up at REV and only commits reachable from REV are listed. |
adr prime [--budget N] |
Print the agent briefing (§12), optionally limiting the decisions-in-force list to about N words. |
adr skill [--install DIR] |
Print the bundled SKILL.md. With --install, write SKILL.md and references/frontmatter.md into the skill directory DIR (created with parents) and print the written paths. |
Every mutating command prints a short summary of what changed in human mode:
the written paths (init, new, unlink, renumber) or the affected ADRs
by number, with titles for accept, reject, deprecate, supersede and
review (link prints numbers only). With --json it prints the changed ADRs or
paths as one JSON document.
10. Number allocation and collisions
- Next number = 1 + the maximum of (a) numbers in the working tree and (b)
numbers in the ADR directory on every local branch and remote-tracking ref
(
git for-each-ref refs/heads refs/remotes+git ls-tree --name-only). Numbers used only on other refs are reported as a note: "skipped 0007, taken on origin/feature-x". - No network access: refs are only as fresh as the last fetch.
- Outside git, only (a) applies.
- Collisions that still happen (two branches merged) are found by
adr check(duplicate-number) and repaired withadr renumber.
11. Validation (adr check)
Each finding has a stable code, a severity (error, warning or
note), a path (repository-relative, empty for set-wide findings) and a
message. Notes inform only: they never affect the exit code, not even with
--strict.
| Code | Severity | Condition |
|---|---|---|
bad-filename |
warning | File in the ADR dir does not match the naming rule |
no-frontmatter |
error | Missing or unparsable YAML frontmatter; for a file with none, the message names the status and date to add, read from an adr-tools or Nygard header where present |
missing-field |
error | status or date missing |
bad-field |
error | A known key has the wrong type (for example a relation that is not a list of numbers) |
bad-status |
error | Status not in the allowed set |
bad-date |
error | date, review-by or reviewed not an ISO date |
no-title |
error | No # heading |
duplicate-number |
error | Two files share a number |
dangling-relation |
error | Relation points to a non-existent number |
asymmetric-relation |
error | supersedes and superseded-by disagree |
self-relation |
error | ADR relates to itself |
cycle |
error | Cycle in supersedes or depends-on |
status-mismatch |
error | superseded-by set but status ≠ superseded, or vice versa |
number-gap |
warning | A number between 1 and the maximum is missing and not present on any local branch or remote-tracking ref (§10; refs are not consulted with --at) |
no-template |
warning | .template.md missing |
bad-scope |
error | scope is not a list of relative glob strings |
unknown-relation-key |
warning | Frontmatter uses a snake_case key (superseded_by, depends_on, relates_to, review_by, revisit_if); the hyphenated key is meant |
scope-matches-nothing |
warning | A scope glob matches no tracked or untracked, non-ignored file (git ls-files --cached --others --exclude-standard; with --at, the files at that revision) |
scoped-change |
warning | With --diff only: a changed file is governed by an ADR in force, unless it has no uncommitted change, at least one commit in REF..HEAD touched it, and every such commit cites an ADR governing that file or carries ADR-Exempt: (§13) |
uncited-change |
warning | With --diff only: a commit in REF..HEAD touches a file governed by an ADR in force, its ADR: trailers name none of the ADRs governing the files it touched, and it has no ADR-Exempt: trailer. Path is the first governed file; the message names the short hash, subject, governing ADRs and the trailer to add |
exempt-change |
note | With --diff only: such a commit cites none of its governing ADRs but carries ADR-Exempt:; the message names the short hash, subject and reason |
review-overdue |
warning | An ADR in force has a review-by before today; the message names the date and adr review N |
stale-decision |
warning | Only when drift-commits (§5) is above 0 and there is a git repository: an ADR in force has churn (§8) of at least drift-commits commits; the message names the count, the baseline and adr review N |
stale-skill |
warning | A SKILL.md in .claude/skills/adr, .agents/skills/adr, .cursor/skills/adr or .github/skills/adr at the git root (or current directory) has no metadata.version or one whose release (its leading numeric segments, so 0.3.1.dev16+gabc counts as 0.3.1) differs from the running adr's; a metadata.version of "{version}" (left unfilled by npx skills add and the Claude Code plugin, whose own update commands refresh the skill) gives no finding; path is that SKILL.md, the message names both versions and adr skill --install DIR. No installed skill, no finding |
adr check --fix repairs asymmetric-relation (writes the missing inverse), status-mismatch (sets superseded when superseded-by is set, keeping date), unknown-relation-key (renames the key in place unless the hyphenated key exists) and no-template (recreates it), then re-checks; the exit code comes from what remains (ADR 0010). Text output prints fixed <code> <path>: <what> per repair; --json adds a fixed list; --format github|gitlab print only the report. Not combinable with --at.
12. Agent interface
JSON output
- With
--json, stdout carries exactly one JSON document; nothing else is printed to stdout. Diagnostics go to stderr. - ADR object:
{"number": 7, "id": "0007", "slug": "…", "title": "…", "status": "…", "date": "YYYY-MM-DD", "path": "docs/adr/0007-….md", "tags": [], "scope": [], "review-by": "YYYY-MM-DD" | null, "reviewed": "YYYY-MM-DD" | null, "revisit-if": "…" | null, "relations": {"supersedes": [3], …}, "meta": {…other keys}}.showadds"body", and"successor"(number at the end of thesuperseded-bychain) when the ADR is superseded.newadds"skipped"({"0003": "origin/main"}: numbers taken on other refs, §10;{}if none). - Lists are JSON arrays.
checkreturns{"ok": bool, "findings": […]}. primereturns{"dir", "counts" (ADRs per status present), "in_force" (ADR objects shown), "omitted" (in-force ADRs left out by--budget), "needs_review" (findings), "proposed" (ADR objects), "rules" (the rules text of the briefing's last section)}.- Errors:
{"error": {"code": "not-found", "message": "…", "details": {…}}}.
Human output
Human output (without --json) uses ANSI colour for statuses, check
severities (including note) and its ok, and drift ids when stdout is a terminal, NO_COLOR is unset and
TERM is not dumb. Piped output, --json, prime and index never contain
escape codes.
CI annotations
adr check --format github|gitlab prints the findings for a CI system
instead of the human text; the exit code is unchanged, -q does not silence
it, and combining it with --json is a usage error.
github: one GitHub Actions workflow command per finding,::LEVEL file=PATH,line=1,title=CODE::MESSAGE, where LEVEL iserror,warningornotice(for a note). Set-wide findings omitfileandline. Values use the workflow-command escaping. A clean run prints nothing.gitlab: a GitLab Code Quality report, a JSON array ([]when clean) of{"description", "check_name", "fingerprint", "severity", "location": {"path", "lines": {"begin": 1}}}.check_nameis the code; severity ismajorfor errors,minorfor warnings andinfofor notes; the fingerprint hashes code, path and the finding's index among those with the same code and path, so it survives message changes such as a new commit hash; set-wide findings use the ADR directory as path.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | check found problems |
| 2 | Usage error (argparse) |
| 3 | Domain error, reported as an error envelope (not-found, ambiguous, invalid-transition, cycle, self-relation, no-adr-dir, exists, no-git, bad-revision, bad-scope, bad-config, bad-template, bad-filename, bad-relation, io, or a §11 parse code such as no-title when new --edit re-reads the edited file) |
adr prime
Prints a compact briefing in Markdown (JSON with --json):
- Where the ADRs live and how many exist per status.
- A one-line-per-ADR list of the ADRs in force (§8; number, title, tags),
with their
depends-on/amendsedges inline. - Needs review: one line per ADR in force that is
review-overdueorstale-decision(§11; the same conditions and config ascheck), naming the reason andadr review N. Omitted when empty. - Open
proposedADRs. - A command cheat sheet and the rules: when to write an ADR, never edit an
accepted ADR's decision (supersede or amend instead), run
adr review Nafter revisiting a decision, cite the ADRs a commit follows with anADR: Ntrailer (orADR-Exempt: reason), runadr checkbefore finishing.
With --budget N, sections 1, 3, 4 and 5 are always printed in full; section 2
is limited to about N words and filled with ADRs in force in descending
score. The top-scored ADR is always shown, even if its line alone exceeds N:
score = 0.5 · rank (normalised to 0–1)
+ 0.3 · scope hit (1 if the ADR governs a file changed in the working
tree, from `git status --porcelain`, else 0)
+ 0.2 · recency (1 for the newest `date`, 0 for the oldest, linear)
Candidates are popped from a heapq heap ordered by (score, number); each
ADR is added if its line still fits (smaller ones may fill remaining space),
and a closing line names how many were left out and how to get them
(adr list --status accepted). Weights are constants, not configuration.
Intended use: a single line in AGENTS.md/CLAUDE.md ("Run adr prime at
session start") or a session-start hook.
Skill
data/skills/adr/SKILL.md follows the Agent Skills format (name, description,
instructions). It tells agents when a decision deserves an ADR, how to write
one with adr new --body-file -, how to supersede or amend, and how to record
a review with adr review, and how to cite ADRs in commit trailers.
adr skill prints it.
13. Git integration (read-only)
Git is invoked as a subprocess; if git is missing or the directory is not a
repository, git features degrade silently (no root detection beyond §5, no
cross-branch allocation, adr log fails with no-git).
Used for: repository root (git rev-parse --show-toplevel), default
decision-makers entry on init (git config user.name, omitted if unset),
cross-branch numbers (§10), adr log, drift (§8: adr drift,
stale-decision, adr prime), commit trailers (adr trace,
uncited-change). Never: add, commit, fetch, checkout,
or any other command that writes.
Commit trailers
A commit cites ADRs with one or more ADR: trailers, each holding ADR
numbers separated by commas or spaces (ADR: 7, ADR: 0007, 0012); tokens
that are not numbers are ignored. ADR-Exempt: reason declares a governed
change that follows no ADR. Trailers are read with git's own parser
(git log --format=%(trailers:key=ADR,valueonly)), so they must sit in the
final paragraph of the message as git defines it. A number that names no ADR
cites nothing.
14. adr init content
.template.md: the template from §4.0001-record-architecture-decisions.md: statusaccepted, MADR 4 body stating that the project records architecturally significant decisions as ADRs in this directory, in MADR 4 format with YAML frontmatter, managed withadr; that accepted ADRs are immutable except for status and relations; and that changes happen by superseding or amending.- Agent wiring (prompted on a terminal, or
--agents): append the lineRun `adr prime` at the start of every session and follow the ADRs it lists.toAGENTS.mdat the git root (or current directory), or toCLAUDE.mdwhen only that file exists; createAGENTS.mdwhen neither exists; skip when the file already mentionsadr prime. Then install the skill (§12) into the skill dir of each agent detected at that root:.claude/skills/adrwhenCLAUDE.mdor.claude/exists, or the user-wide~/.claude/CLAUDE.md;.agents/skills/adr(read by Codex, Cursor and Copilot) whenAGENTS.md,.agents/,.cursor/or.github/exists, or when nothing is detected. Each dir is written at most once. Both steps are idempotent.
15. Testing and quality
pytest. Unit tests forfrontmatter,model,repo,check.- CLI tests run the CLI as a subprocess (
python -m adrinaline, the same entry point asadr) inside temporary git repositories created per test, covering each command in human and--jsonmode, including error codes. ruff check,ruff format --check,ty checkmust pass.- CI (GitHub Actions): on pull requests, the commit-message, lint, type and
adr check --strictgates and a strict docs build run onubuntu-latest; tests run onubuntu-latestandmacos-latestwith Python 3.14. Every push tomainreleases when commitizen finds a bump:uv build, maintainer approval in thereleaseenvironment (ADR 0007), tag, PyPI trusted publishing, GitHub Release (ADR 0006). Docs are built with zensical and published to GitHub Pages.
16. Acceptance criteria
Each criterion is an automated test.
- In an empty git repo,
adr initcreatesdocs/adr/.template.mdanddocs/adr/0001-record-architecture-decisions.md;adr checkexits 0. - Running
adr inittwice changes nothing. adr new "Use PostgreSQL"createsdocs/adr/0002-use-postgresql.mdwithstatus: proposed, today's date and title heading; run from a subdirectory it writes to the same directory.ADR_DIR,.adrinaline.toml,[tool.adrinaline]and.adr-direach redirect the directory, in the precedence of §5.- With a branch
otherholding0003-x.md,adr newonmain(which ends at 0002) creates0004-…and reports the skip. adr show 2,adr show 0002andadr show postgresprint the same ADR;adr show zzzexits 3 with codenot-found.adr accept 2succeeds; a secondadr accept 2exits 3 withinvalid-transition.adr supersede 2 --by 3writessupersedes: [2]to 0003,superseded-by: [3]andstatus: supersededto 0002.adr link 3 depends-on 2followed byadr link 2 depends-on 3exits 3 withcycle.- Each
checkcode in §11 is triggered by a fixture and reported with its code;--jsonoutput validates against the documented shape. - Duplicate 0004 files:
adr checkexits 1 withduplicate-number;adr renumber <slug> --to 5fixes it, rewrites relations and body links, andadr checkexits 0. - Every command with
--jsonemits a single parsable JSON document on stdout, both on success and on domain errors. - A hand-added YAML comment and an unknown key survive
adr accept. adr primelists exactly the ADRs in force and the proposed ones.- No test observes a git write:
git status --porcelainshows only ADR file changes andgit logis unchanged after every command. adr link 3 amends 2changes only 0003;adr show 2 --jsonlists 3 underamended-by.adr graph --format jsonreturns every ADR as a node and every stored relation as an edge;--format mermaidand--format dotoutput parse (mermaid and dot compared against snapshot files; dot additionally throughdot -Tsvgwhen Graphviz is installed, skipped otherwise).adr graph 2 --reverselists exactly the ADRs that depend on or amend 0002, directly or transitively.adr for src/db/conn.pylists exactly the ADRs in force whosescopematches it; a superseded ADR with the same scope is not listed.adr new "X" --scope "src/db/**"thenadr acceptmakesadr for src/db/conn.pylist it; an absolute or..glob exits withbad-scopeand writes no file.- After modifying a governed file,
adr check --diff HEADreportsscoped-changenaming the governing ADR and exits 0; with--strictit exits 1. - After superseding 0002 in a new commit,
adr list --at HEAD~1shows 0002 asaccepted, andadr accept 3 --at HEAD~1exits 2. - On a fixture where 0002 is the target of three
depends-onedges,adr graph --rank --jsonranks 0002 first; ranks sum to 1 within 1e-6. adr prime --budget 150on a 30-ADR fixture prints at most 150 words of ADR entries (unless the top-scored entry alone is longer), always includes the top-scored ADR, and ends with the left-out count.adr review 2 --until 2099-01-01writesreviewed: <today>andreview-by: 2099-01-01to 0002 and changes nothing else;adr review 2 --until 2099-13-01exits 3 withbad-dateand writes nothing;adr new "X" --review-by 2099-01-01writesreview-by.- An ADR in force with
review-byin the past makesadr checkreportreview-overdueand exit 0 (1 with--strict); aproposedorsupersededADR with the same date does not. - On a fixture where 0002 has
scope: ["src/db/**"], two commits after itsdatetouchsrc/db/and one touchessrc/other/,adr drift --jsonreportscommits: 2for 0002 with both files; afteradr review 2and a commit,adr driftreports 0;adr drift --at HEAD~1reports the earlier state; an ADR withoutscopeis not listed and is counted in the closing line. - With
drift-commits = 2in.adrinaline.toml, the fixture of 26 makesadr checkreportstale-decisionfor 0002 naming the count; without the key, and withdrift-commits = 3, it does not. adr primeon the fixtures of 25 and 27 prints a "Needs review" section naming both ADRs and their repair command; without them the section is absent and--budgetoutput is unchanged.adr init --agentsin a fresh repo appends theadr primeline toAGENTS.md(toCLAUDE.mdwhen only that exists) and writes.agents/skills/adr/SKILL.md(.claude/skills/adr/SKILL.mdwhen onlyCLAUDE.mdexists; both when.claude/exists too); a second run leavesAGENTS.mdbyte-identical. Plainadr initoff a terminal writes nothing outside the ADR directory and prints a Next steps block. On a terminal,adr initasks before each wiring step andadr newwithout TITLE prompts for the title, supersedes and scope.adr show "Use PostgreSQL"andadr show use-postgresqlresolve0002-use-postgresql.mdeven when0003-use-postgresql-17.mdexists;adr show use-postgrsfails withnot-foundnaming0002-use-postgresqlas a candidate.- On a fixture with commits citing
ADR: 2,ADR: 0002, 0003,ADR: 3and none,adr trace 2 --jsonlists exactly the first two, newest first;adr trace 2 --at HEAD~3lists only the first. - With 0002 in force and
scope: ["src/db/**"], a commit after REF that touchessrc/db/x.pymakesadr check --diff REFreportuncited-changewithout a trailer and withADR: 3(an ADR not governing it), and withADR: 2neitheruncited-changenorscoped-change, soadr check --diff REF --strictexits 0; an uncommitted edit tosrc/db/x.pybringsscoped-changeback. - The same commit with
ADR-Exempt: generated codeis reported asexempt-changewith severitynote, andadr check --diff REF --strictexits 0. - Each code
adr check --fixrepairs is fixed on its fixture and re-checks clean; a second run fixes nothing; statussupersededwithoutsuperseded-byand a snake_case key next to its hyphenated twin are left byte-identical.