Dunstan holds a coding agent’s report to the record.
An agent finishes and writes a handback: files changed, tests run, references closed, head commit, merged at. Dunstan reads a block the agent declares in that handback and checks each claim against the repository’s own record, with deterministic checks. It writes a record anyone can re-run and get the same verdict.
Three rules
- No model decides pass or fail.
- Fail closed: a claim the record cannot answer is
unverifiable, never a pass by absence. - Evidence, not a gate: Dunstan returns a record, and your branch protection decides what blocks.
What it checks
- Head: the head commit the agent names is the pull request's head.
- Scope: the files the agent declares are the files the pull request changed, both ways.
- References: the issues, pull requests and commits it names exist and relate as declared.
- Counts: test and check counts match the CI record they name.
- Time: merged and deployed times match the record.
Each claim is pass, fail or unverifiable, with a reason code. The record is an in-toto statement whose digests let anyone recompute every verdict offline.
Quick start: the GitHub Action
Three steps. No checkout, no secrets, and nothing leaves GitHub.
1. Add the workflow
Save this as .github/workflows/dunstan.yml. The Action is pinned to the full commit of v0.1.6.
name: Dunstan
on:
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]
permissions:
contents: read
pull-requests: read
issues: read
deployments: read
checks: write
actions: read
jobs:
handback:
runs-on: ubuntu-latest
steps:
- uses: BargLabs/dunstan@e0ec7ed05272e09fee21f0f29d70395f846547ac # v0.1.6
with:
report-source: pr-body2. Have the agent declare its claims
The agent puts one block in its pull request description. dunstan template writes most of it from local git, and dunstan suggest lists any claim in the prose that the block does not declare.
```dunstan-handback
{
"dunstan": "0.1",
"headCommit": "c0ffee12345678abcdef0123456789abcdef0123",
"filesChanged": ["src/cache.ts", "src/cache.test.ts"],
"references": [{ "issue": "#41", "relation": "closes" }]
}
```3. Require the check
In branch protection or a ruleset, require the status check dunstan. Only pass concludes success. fail and unverifiable conclude failure, unless you set unverifiable-conclusion: neutral. Dunstan never comments, labels, reviews or merges. The full reference is in docs/action.md.
Quick start: the CLI
git clone --branch v0.1.6 https://github.com/BargLabs/dunstan.git cd dunstan && pnpm install && pnpm build # offline: recompute an example record's verdict and digests node dist/dunstan.mjs verify spec/examples/records/fail.json # online: check a pull request against a report that holds a block GITHUB_TOKEN=<read-only token> node dist/dunstan.mjs check \ --repo <owner/name> --pr <n> --report-file report.md --out record.json
Here is the output of that last command, verbatim, on a public pull request, run on 8 October 2026 with the public v0.1.6 build and a block taken from the demo:
dunstan 0.1.6 cdcseacave/openMVS#1246 head 66e9ec39994c81e416b2970fb3d158760389f567 report: file report.md (sha256 28e5cf87fc33372e57c78dd0fd7de22f9b7c128fbaabdf537c92a8635dc39c78) block: found (sha256 f911a1b6cf1b7cb17586c59570b1ac13b10c7cac1a0f6c7c9260705f05c5941d) VERDICT CLAIM DECLARED OBSERVED REASON pass head:/headCommit 66e9ec39994c81e416b2970fb3d158760389f567 66e9ec39994c81e416b2970fb3d158760389f567 pass scope:/filesChanged/0 apps/Tests/Tests.cpp apps/Tests/Tests.cpp pass scope:/filesChanged/1 libs/IO/ImageTIFF.cpp libs/IO/ImageTIFF.cpp pass scope:/filesChanged/2 libs/IO/ImageTIFF.h libs/IO/ImageTIFF.h pass time:/mergedAt 2026-09-11T23:57:04Z 2026-09-11T23:57:04Z verdict: pass record: record.json
Exit codes: 0 for pass, 1 for fail, 2 for unverifiable. dunstan rerun record.json re-reads the sources and reports any evidence that has changed. dunstan mcp serves the same checker as an MCP tool, so an agent can check its block before it says it is done. See the README at v0.1.6 and docs/agents.md.
What a result looks like
Every record carries a claims table like these, and the Action writes the same table as the summary of its dunstan check run. The first two are real public pull requests from the demo; the third is the specification’s own constructed example.
pass: every claim holds
cdcseacave/openMVS#1246, written by an agent (Google Jules). Record: demo/2026-10/4.
| Verdict | Claim | Declared | Observed | Reason |
|---|---|---|---|---|
| pass | head:/headCommit | 66e9ec39… | 66e9ec39… | |
| pass | scope:/filesChanged/0 | apps/Tests/Tests.cpp | apps/Tests/Tests.cpp | |
| pass | scope:/filesChanged/1 | libs/IO/ImageTIFF.cpp | libs/IO/ImageTIFF.cpp | |
| pass | scope:/filesChanged/2 | libs/IO/ImageTIFF.h | libs/IO/ImageTIFF.h | |
| pass | time:/mergedAt | 2026-09-11T23:57:04Z | 2026-09-11T23:57:04Z |
unverifiable: the record cannot answer one claim
QuantEcon/QuantEcon.py#798, written by an agent (GitHub Copilot coding agent). Eleven claims hold. The report says 605 tests passed but names no machine-readable test record, so Dunstan has nothing to check that count against. It says so instead of passing it. Record: demo/2026-10/5.
| Verdict | Claim | Declared | Observed | Reason |
|---|---|---|---|---|
| pass | head:/headCommit | 2fcb68c3… | 2fcb68c3… | |
| pass | scope:/filesChanged/0–6 | 7 test files | the same 7 files | |
| pass | reference:/references/0 | cites #787 | exists | |
| pass | reference:/references/1 | cites #790 | exists | |
| unverifiable | count:/tests/0/count | 605 | — | no_comparable_record_field |
| pass | time:/mergedAt | 2026-09-10T01:54:40Z | 2026-09-10T01:54:40Z |
fail: the record contradicts the report
A constructed example from the specification (example-org/example-repo, not a real pull request): the agent left a changed file undeclared and claimed 40 tests where the CI record shows 38. Its closes #21 is unverifiable because GitHub has not yet linked the issue to the open pull request. A fail decides the verdict even beside an unverifiable row. Record: spec/examples/records/fail.json.
| Verdict | Claim | Declared | Observed | Reason |
|---|---|---|---|---|
| pass | head:/headCommit | c0ffee12… | c0ffee12… | |
| pass | scope:/filesChanged/0 | src/cache.ts | src/cache.ts | |
| pass | scope:/filesChanged/1 | src/cache.test.ts | src/cache.test.ts | |
| fail | scope:undeclared:package.json | — | package.json | undeclared_file |
| unverifiable | reference:/references/0 | closes #21 | — | closing_link_unsettled |
| fail | count:/tests/0/count | 40 | 38 | count_mismatch |
The specification
The claim format, the block an agent declares and the record Dunstan writes, is an open specification: claim-format.md (Apache-2.0). The checker is AGPL-3.0. JSON Schemas are served at their identifiers:
Record predicate types: /dunstan/record/v0.1 and /dunstan/record/v0.2-draft.
Evidence for the claims we make
A five-pull-request demo, chosen by a rule committed before any search, with its records and an erratum, is in the repository’s demo/2026-10/. Measured figures for the advisory prose reader, with their method and the corpus each was measured on, are in docs/advisory.md. On constructed reports, extractor 0.1.3 flags 280 of 380 planted false claims (73.7%), with 280 of its 315 flags on the planted claim; all of its misses are one sentence shape it does not read. Two studies on public agent-written pull requests, each preregistered before any pull request was selected and published with every label, found that 87% to 97% of what the reader proposes is a real claim, and that about a third of its disagreement notes on 300 pull requests marked a claim the record contradicts. They also found that fixing how it reads file claims does not generalise: see study 1 and study 2. That is why the gate checks only what an agent declares. Why a reader that finds claims in prose is not enough: Reading, not checking on cejel.dev.
Hosted
A hosted GitHub App that runs the same checker is built but not yet offered. If you want early access, ask us.