alisorcorp/warrant

Attach re-runnable checks to the factual claims in a markdown document. STALE means you know it's wrong; BROKEN means you no longer know.

7

stars

10

commits

Python

primary language

Aug 27, 2026

updated

README

warrant

Make claims in a document re-checkable.

A prose claim starts going stale as soon as the thing underneath it changes. Usually nobody comes back and checks. warrant puts the command that establishes a claim right next to the claim, runs it again when asked, and gives you one of four results:

verdictmeaning
VERIFIEDthe check ran and agreed. You know it's true.
STALEthe check ran and disagreed. You know it's wrong.
BROKENthe check could not run. You no longer know.
ASSERTEDdeliberately unwarranted, with a recorded reason.

A lot of tooling treats the middle two as the same thing: "failed." They aren't. STALE means the check worked and taught you something. BROKEN means the check itself didn't work, so you've lost information.

That distinction matters. Exit 126 and 127 are BROKEN, never STALE. Timeouts are BROKEN too. If a file is missing, a sha256 warrant is BROKEN because there's nothing to hash. An exists warrant is different: there, absence is exactly what you asked the check to determine, so the result is STALE.

No model is involved. Same inputs, same verdict, every time.

Why not just tests

Tests check code. This checks prose about code.

Think handoff notes, READMEs, ADRs, an agent's long-term memory file. Those are exactly the places where stale numbers tend to sit around doing damage, because CI doesn't touch them and people rarely reread them closely.

The closest thing I've found in the wild is Google's Open Knowledge Format v0.2. Section 10, "Attested Computation," describes an executor that produces a receipt and a deterministic, non-model attester that inspects it. That's the right general shape. The implementations I've read store those fields but don't actually run anything, which leaves you with a signature over an assertion.

warrant runs the computation.

Install

It's one file. Python 3.10+, standard library only, and no network access at any point.

uv tool install git+https://github.com/alisorcorp/warrant     # or pipx
warrant install

warrant install merges a SessionStart hook into ~/.claude/settings.json and writes the skill to ~/.claude/skills/warrant/.

That settings file is treated like something worth preserving, not disposable config. Existing keys stay. Hooks already attached to the event stay too. The installer makes a backup, then replaces the file atomically. If the JSON is malformed, it refuses to overwrite it. Run the installer again later and it upgrades in place rather than quietly appending another copy.

warrant install --dry-run     # show what would change, change nothing
warrant install --hook-only   # or --skill-only
warrant install --uninstall   # removes ours, leaves anyone else's alone
warrant install --doc STATUS.md --event SessionStart

You don't need a package manager either. It's still just one file with no dependencies:

cp warrant.py ~/.local/bin/warrant && chmod +x ~/.local/bin/warrant

Use

Put the check next to the claim it establishes:

The gate passes with 0 failures. <!-- warrant: run="./scripts/gate.sh" contains="0 failures" -->

HEAD is `a1b2c3d`. <!-- warrant: run="git rev-parse --short HEAD" contains="a1b2c3d" -->

The engine is unchanged. <!-- warrant: file="engine.py" sha256="9f2a1c4e7b30" -->

The design is sound. <!-- warrant: none reason="a judgment call, no check settles it" -->

Then run whichever check you need:

warrant check HANDOFF.md              # re-run everything
warrant check HANDOFF.md --quiet      # silent unless something is wrong
warrant check HANDOFF.md --dry-run    # list what would run, run nothing
warrant audit HANDOFF.md              # rot-prone lines carrying no warrant
warrant audit HANDOFF.md --since main # only the ones this branch added
warrant suggest HANDOFF.md            # propose warrants for them
warrant receipt HANDOFF.md -o r.json
warrant check HANDOFF.md --against r.json

Exit code 0 means clean. 1 means something went STALE. 2 means something is BROKEN. STALE outranks BROKEN, deliberately, so a real disagreement doesn't disappear behind a missing tool somewhere else in the run.

--since is probably the option worth remembering.

Run a whole-file audit on a mature document and you'll often get dozens of old lines reported every single time. Pretty soon that becomes background noise. Scope the audit to the diff and it reports what this change introduced instead, which is usually the part you can do something about.

The ref you pass decides the baseline. --since main covers everything the branch added. --since HEAD covers what you've edited but haven't committed yet, which is the one you want inside a post-edit hook.

If there isn't a usable baseline (outside a repo, untracked file, bad ref), it falls back to checking the whole file and tells you. It does not hand back a suspiciously clean zero and pretend the comparison happened.

suggest is there because a blank page is the annoying part. When it can infer a warrant honestly from something concrete, such as a file path or a commit-shaped hex token, it does. When it can't, it prints a stub marked INCOMPLETE.

It won't make up a command that merely looks convincing. A warrant that can't fail is worse than having no warrant at all, because somebody will eventually read it as verification.

Syntax

<!-- warrant: KEY="VALUE" ... -->

Put it inline after the claim or on the line underneath. Warrants inside fenced code blocks or backticks are examples, so they never run.

In the report, an inline warrant is named by the line it sits on, and a warrant on its own line is named by the prose above it. Stack several under one paragraph and none of them get that paragraph: each is named by its own id and command instead, so six different checks don't print as six copies of the same sentence.

Source, pick one: run="cmd" (shell, stdout and stderr captured) · file="path" · none reason="...".

Assertions: contains="s" · absent="s" · matches="regex" · equals="s" (exact, stripped) · exit="N" · sha256="hex" (a prefix is fine) · exists.

You can combine assertions. If you do, all of them have to hold.

A run warrant with no assertion means "this command must exit 0." Once you assert against command output, the exit code is no longer checked implicitly. That's intentional: some useful commands legitimately return non-zero. warrant reports that situation as a note instead of quietly swallowing it.

Wiring it into an agent

The checking part can be mechanical. The writing part can't, and pretending otherwise muddies the point.

See hooks/README.md for hook recipes, a CI workflow, and the one CLAUDE.md line that covers the cases hooks don't.

What this deliberately doesn't do

  • audit is a heuristic, not a completeness claim. It looks for lines containing a number, a hash-like token, or a file path. Claims without those things are invisible to it, and plenty of lines it does flag won't need a warrant anyway. It's a prompt to look, nothing more.
  • Coverage isn't truth. "All verified" means the warranted claims held. It says nothing about everything else in the document. That's why the unwarranted count sits next to the result instead of getting tucked away somewhere.
  • It runs shell out of a document. Treat a document containing warrants the same way you'd treat a Makefile from the same source. If you didn't write it, --dry-run is there for a reason.
  • No signing. A signed receipt from a real run could mean something, and that isn't built. Signing the claim itself would only prove that somebody asserted it.

Tests

python3 test_warrant.py

Every verdict class has a mutation pair behind it: two runs where the underlying reality is the only thing that changes, followed by an assertion that the verdict changes with it.

A suite that only checks the happy path can't really be shown wrong. That's the same failure this tool is meant to catch.

The tests paid for themselves on the first run. The positive matches case caught the value parser stripping backslashes out of regular expressions and silently turning v\d+ into vd+.

MIT.

Contributors

alisorcorp

10 commits

alisorcorp/warrant

Attach re-runnable checks to the factual claims in a markdown document. STALE means you know it's wrong; BROKEN means you no longer know.

7

stars

10

commits

Python

primary language

Aug 27, 2026

updated

README

warrant

Make claims in a document re-checkable.

A prose claim starts going stale as soon as the thing underneath it changes. Usually nobody comes back and checks. warrant puts the command that establishes a claim right next to the claim, runs it again when asked, and gives you one of four results:

verdictmeaning
VERIFIEDthe check ran and agreed. You know it's true.
STALEthe check ran and disagreed. You know it's wrong.
BROKENthe check could not run. You no longer know.
ASSERTEDdeliberately unwarranted, with a recorded reason.

A lot of tooling treats the middle two as the same thing: "failed." They aren't. STALE means the check worked and taught you something. BROKEN means the check itself didn't work, so you've lost information.

That distinction matters. Exit 126 and 127 are BROKEN, never STALE. Timeouts are BROKEN too. If a file is missing, a sha256 warrant is BROKEN because there's nothing to hash. An exists warrant is different: there, absence is exactly what you asked the check to determine, so the result is STALE.

No model is involved. Same inputs, same verdict, every time.

Why not just tests

Tests check code. This checks prose about code.

Think handoff notes, READMEs, ADRs, an agent's long-term memory file. Those are exactly the places where stale numbers tend to sit around doing damage, because CI doesn't touch them and people rarely reread them closely.

The closest thing I've found in the wild is Google's Open Knowledge Format v0.2. Section 10, "Attested Computation," describes an executor that produces a receipt and a deterministic, non-model attester that inspects it. That's the right general shape. The implementations I've read store those fields but don't actually run anything, which leaves you with a signature over an assertion.

warrant runs the computation.

Install

It's one file. Python 3.10+, standard library only, and no network access at any point.

uv tool install git+https://github.com/alisorcorp/warrant     # or pipx
warrant install

warrant install merges a SessionStart hook into ~/.claude/settings.json and writes the skill to ~/.claude/skills/warrant/.

That settings file is treated like something worth preserving, not disposable config. Existing keys stay. Hooks already attached to the event stay too. The installer makes a backup, then replaces the file atomically. If the JSON is malformed, it refuses to overwrite it. Run the installer again later and it upgrades in place rather than quietly appending another copy.

warrant install --dry-run     # show what would change, change nothing
warrant install --hook-only   # or --skill-only
warrant install --uninstall   # removes ours, leaves anyone else's alone
warrant install --doc STATUS.md --event SessionStart

You don't need a package manager either. It's still just one file with no dependencies:

cp warrant.py ~/.local/bin/warrant && chmod +x ~/.local/bin/warrant

Use

Put the check next to the claim it establishes:

The gate passes with 0 failures. <!-- warrant: run="./scripts/gate.sh" contains="0 failures" -->

HEAD is `a1b2c3d`. <!-- warrant: run="git rev-parse --short HEAD" contains="a1b2c3d" -->

The engine is unchanged. <!-- warrant: file="engine.py" sha256="9f2a1c4e7b30" -->

The design is sound. <!-- warrant: none reason="a judgment call, no check settles it" -->

Then run whichever check you need:

warrant check HANDOFF.md              # re-run everything
warrant check HANDOFF.md --quiet      # silent unless something is wrong
warrant check HANDOFF.md --dry-run    # list what would run, run nothing
warrant audit HANDOFF.md              # rot-prone lines carrying no warrant
warrant audit HANDOFF.md --since main # only the ones this branch added
warrant suggest HANDOFF.md            # propose warrants for them
warrant receipt HANDOFF.md -o r.json
warrant check HANDOFF.md --against r.json

Exit code 0 means clean. 1 means something went STALE. 2 means something is BROKEN. STALE outranks BROKEN, deliberately, so a real disagreement doesn't disappear behind a missing tool somewhere else in the run.

--since is probably the option worth remembering.

Run a whole-file audit on a mature document and you'll often get dozens of old lines reported every single time. Pretty soon that becomes background noise. Scope the audit to the diff and it reports what this change introduced instead, which is usually the part you can do something about.

The ref you pass decides the baseline. --since main covers everything the branch added. --since HEAD covers what you've edited but haven't committed yet, which is the one you want inside a post-edit hook.

If there isn't a usable baseline (outside a repo, untracked file, bad ref), it falls back to checking the whole file and tells you. It does not hand back a suspiciously clean zero and pretend the comparison happened.

suggest is there because a blank page is the annoying part. When it can infer a warrant honestly from something concrete, such as a file path or a commit-shaped hex token, it does. When it can't, it prints a stub marked INCOMPLETE.

It won't make up a command that merely looks convincing. A warrant that can't fail is worse than having no warrant at all, because somebody will eventually read it as verification.

Syntax

<!-- warrant: KEY="VALUE" ... -->

Put it inline after the claim or on the line underneath. Warrants inside fenced code blocks or backticks are examples, so they never run.

In the report, an inline warrant is named by the line it sits on, and a warrant on its own line is named by the prose above it. Stack several under one paragraph and none of them get that paragraph: each is named by its own id and command instead, so six different checks don't print as six copies of the same sentence.

Source, pick one: run="cmd" (shell, stdout and stderr captured) · file="path" · none reason="...".

Assertions: contains="s" · absent="s" · matches="regex" · equals="s" (exact, stripped) · exit="N" · sha256="hex" (a prefix is fine) · exists.

You can combine assertions. If you do, all of them have to hold.

A run warrant with no assertion means "this command must exit 0." Once you assert against command output, the exit code is no longer checked implicitly. That's intentional: some useful commands legitimately return non-zero. warrant reports that situation as a note instead of quietly swallowing it.

Wiring it into an agent

The checking part can be mechanical. The writing part can't, and pretending otherwise muddies the point.

See hooks/README.md for hook recipes, a CI workflow, and the one CLAUDE.md line that covers the cases hooks don't.

What this deliberately doesn't do

  • audit is a heuristic, not a completeness claim. It looks for lines containing a number, a hash-like token, or a file path. Claims without those things are invisible to it, and plenty of lines it does flag won't need a warrant anyway. It's a prompt to look, nothing more.
  • Coverage isn't truth. "All verified" means the warranted claims held. It says nothing about everything else in the document. That's why the unwarranted count sits next to the result instead of getting tucked away somewhere.
  • It runs shell out of a document. Treat a document containing warrants the same way you'd treat a Makefile from the same source. If you didn't write it, --dry-run is there for a reason.
  • No signing. A signed receipt from a real run could mean something, and that isn't built. Signing the claim itself would only prove that somebody asserted it.

Tests

python3 test_warrant.py

Every verdict class has a mutation pair behind it: two runs where the underlying reality is the only thing that changes, followed by an assertion that the verdict changes with it.

A suite that only checks the happy path can't really be shown wrong. That's the same failure this tool is meant to catch.

The tests paid for themselves on the first run. The positive matches case caught the value parser stripping backslashes out of regular expressions and silently turning v\d+ into vd+.

MIT.

See what people are saying

Contributors

alisorcorp

10 commits

Languages

Python

100.0%