Repository navigation
Releases: AndrewBogdanovTSS/evidence-layer
Release list
v0.14.0
evidence-layer 0.14.0
A command's kind is read from what runs, not from the arguments it was given.
Nothing here changes what an exit code means.
Artifacts
classifyCommandignores paths, quoted strings and leadingNAME=value
environment assignments. It matched against the whole command line, so a
lint run oversrc/typecheck.tswas classifiedtypes, and a test run over
src/lint/was classifiedlint. Besides the misleading id prefix and
suggested claim ("Typecheck reports no errors" for a lint run), a test or lint
run classified as anything else was exempt from the work-summary rules: a
success claim on it was never held to its counts. Now
pnpm test src/typecheck.test.tsistests, and its success claims are.- A file extension at the end of a token (
tsconfig.json,a...b) or a path
separator anywhere in it marks the token as an argument. Program names,
subcommands and flags are still read:node node_modules/nuxt/bin/nuxt.mjs typecheckistypes,pnpm install --frozen-lockfileislockfile. - An unquoted, path-free argument is still read as part of the invocation:
pnpm test -t typecheckistypes. Quote it.
Behaviour changes to check when upgrading
- Some artifact ids change. The kind is the id's prefix, so a command whose
kind came from an argument - for examplepnpm eslint src/typecheck.ts, once
types-…, nowlint-…- gets a different id. An artifact generated with an
earlier version and verified with this one fails its address check for those
commands only; regenerate the evidence. Commands whose kind did not change
keep their ids. - Success claims on those runs are now held to their work summary, as any
other lint or test run's are.
Repository
- The suite is 503 tests across 38 files.
Full Changelog: v0.13.0...v0.14.0
v0.13.0
evidence-layer 0.13.0
Misses can be recorded from the history of a given ref instead of HEAD.
Nothing here changes what an exit code means.
Misses
recordNewMisses(repo, journal, { ref })andfindMisses(repo, { ref })
read the history ofref- typically the branch a pull request targets. A
branch merged by squash loses its own commits: a miss recorded from a pull
request branch's own commit would name a SHA the main branch never contains,
and the squash commit carrying the same trailer would later be recorded as a
second miss. Recording from the target's history counts each miss once, by
the commit that landed.- A ref git cannot resolve throws instead of returning no misses: "none" and
"could not read the history" are different answers. Without a ref, a
directory that is not a repository still yields none, as before. - History is read through
git()with an argument array and
--end-of-options, so a ref is never seen by a shell or read as a flag.
API
- New:
MissesOptions.findMissesandrecordNewMissestake an optional
MissesOptions.
Repository
- The suite is 489 tests across 38 files.
Full Changelog: v0.12.0...v0.13.0
v0.12.0
evidence-layer 0.12.0
Recording a miss is now deterministic. Every field of a journal line made from
a Missed-By: trailer comes from the fixing commit, so any checkout of the
same history writes the same lines in the same order. A project whose main
branch only takes pull requests can record misses from each pull request
branch instead of from one machine. Nothing here changes what an exit code
means.
Misses
- A miss's id is its commit.
recordNewMisseswrites the fixing commit's
first eight hex digits as the entry id (missEntryId), and its committer
date asts. Random ids made the same miss a different line on every
machine that recorded it, so two branches that both recorded it merged two
entries and the floor counted it twice. Identical lines from two branches
merge as one. - Oldest first. New misses are appended in commit order, not
git log's
newest-first order, so the journal reads in time order. - Misses recorded before this release keep their random ids; the dedupe still
keys on the commit, so nothing is recorded twice.
API
- New:
missEntryId.ExtractedMisshas an optionaldate;
parseMissesFromLogreads%H<FS>%cI<FS>%B<RS>and still reads the older
two-field form. appendEntryaccepts an optionalid; without one it generates a random id
as before.
Repository
- The suite is 487 tests across 38 files.
Full Changelog: v0.11.0...v0.12.0
v0.11.0
evidence-layer 0.11.0
A review posted as a pull request comment no longer has to open on command
logs. The claim and sample skeletons can put each artifact block, and the
Sample integrity table, in a <details> fold, with the Claim and Grounding
lines left outside it. The option is off by default, so existing output is
unchanged, and nothing here changes what an exit code means.
Folded skeletons
generateClaimSkeleton(artifacts, { fold: true })
generateSampleSkeleton(samples, omittedCount, { fold: true })- Each artifact block sits under its Grounding line in a fold summarised as
📄 Output - <command> - exit N, L lines, where L counts the block's lines
between its fences and a stopped run readsexit 124 (timeout). - The Sample integrity table's fold summary takes the label's place and says
how many files it quotes, never which.parseReceiptfinds the table by its
header row, so a folded receipt verifies as before. - A summary never names a path. It sits outside every fence, so a verifier
reading prose for citations would take a path there for a file the review
cites.summaryCommandkeeps environment assignments and drops
NODE_OPTIONS,--flagsand path-like words; the full command is still the
block's first line. - Every fold has an 8px spacer under its summary and blank lines around its
content: CommonMark ends an HTML block only at a blank line, so without them
the fence or table inside would not be read as Markdown. The native
disclosure triangle is kept - it is the control's only affordance for
keyboard and screen reader users.
API
- New:
SkeletonOptions,foldDetails,summaryCommand.
generateClaimSkeletonandgenerateSampleSkeletontake an optional
SkeletonOptions.
Repository
- The suite is 483 tests across 38 files.
Full Changelog: v0.10.0...v0.11.0
v0.10.0
evidence-layer 0.10.0
Fixes from an independent review of the runtime gate, the gatherer and the
seal - the path that runs inside an agent's session. The gate now says each
thing once, names the command and base it wants gathered with, and costs one
tree fingerprint per stop instead of one per checked text. The gatherer's idea
of "the change" now includes what is not committed yet, and each step can have
its own timeout. Nothing here changes what an exit code means.
Four changes in the same review alter what the gate accepts rather than fix a
bug - what a stop checks, which runs a claim may cite, a restamp command,
and what a fence's commit label means. They are drafted as a proposal in
docs/decisions/0010-gate-scope-and-run-identity.md, not implemented.
The runtime gate
-
Warnings get their own lines. An allow whose only findings were warnings
(claims/shotgun, a stalegit diffblock) printed "not everything could be
checked:" and then nothing. Every non-pass finding is now listed, worst
first, and an allow without unverifiable findings says "these need a look". -
An exhausted episode speaks once. After a retry episode spent its blocks,
every later stop repeated the samesystemMessageand journalled another
retry-exhaustedallow. While the checked texts, the sealed run, the tree
and the findings are what they were at the last verdict, a stop now says
nothing and journals nothing. Any change - an edit to the review, a new
gather, an edit to the code a cited run describes, a fetch that changes a
receipt's base check - and it speaks again. -
A stop with nothing left to check closes the episode. When the claims
that opened an episode are gone (the review deleted or put back, the final
message making none), the episode closes and is journalled with the new
outcomeclosed-unchecked- anunverifiableentry, never a clean check.
A later failure starts a fresh episode, with fresh blocks. -
Untagged blocks are listed, for the person.
journal report --include-gatelists every block not tagged yet, newest first - id, time,
scope, first finding - sojournal tag <id> real|false --gateneeds no
reading of the JSONL. It also countsclosed-uncheckedepisodes on their own
line: an upper bound on blocks escaped by dropping the claims, since a change
of subject after a block counts too. The id is never put in a block's
reason: the agent reads the reason, and the blocked party must not be handed
the command that grades its own block. -
The gather command is named, with its base. Session start, a block and
seal/no-runsay which command gathers evidence instead of "the project's
evidence script". Set it once in package.json:{ "evidenceLayer": { "gather": "pnpm evidence --base origin/main" } }Without it, the gate names the command the current run recorded, or - for a
project with anevidencescript -<package manager> run evidence, from
itspackageManagerfield (npm when there is none). When a run exists, the
base it was gathered against is named too. -
One fingerprint per stop. Checking a review and a final message used to
fingerprint the tree once per text, and find the repository and its state
directory again for each check and each journal write. A stop now resolves
them once. -
seal/not-gatheredsays where the block came from. It names the kept run
that produced the cited id, when one is still kept, and the id the current
run gave the same command. A final message citing an unknown id is told the
id is not in "the current sealed run", not in "this document".
The gatherer
- Uncommitted and untracked changes are part of the change. The plan (a
scopedlintCommand,testGate) andGatherResult.changedFilesused to see
onlygit diff base...head. They now also get every tracked edit and
untracked file on top of the reviewed commit, minus the review and the
bundle. The diff block keeps describing the commits, so its id does not
move; tracked edits get a## uncommittedblock (git diff --stat HEAD),
and new files are in the working-tree block.GatherResult.committedFiles
holds the commits' part alone. The header says how many files are not
committed. - Per-step timeouts.
GatherOptions.timeoutMstakes one number for every
step or one per step name ({ test: 900_000 }); the default stays 300
seconds. The README's "at your project's own gates and timeouts" is now
true. - The bundle header names the gather command and base - from
GatherOptions.gatherCommandor package.json, as above - instead of always
"pnpm evidence", and the seal records both (Seal.gatherCommand,
Seal.base) for the gate to name. - Each artifact block is printed once. A block that backs a suggested claim
is printed under Suggested claims with that claim; its step's section
points there. Two steps running one command give one claim. - A bundle written to another Windows drive is sealed. With
--outon a
drive other than the repository's, the path was not recognised as outside
the repository, git refused to leave it out of the fingerprint, and the
gather ended "not sealed: could not drop the excluded paths". Such a path is
now left out like any other outside path.
Behaviour changes to check when upgrading
- A scoped lint or test gate that relied on seeing committed files only now
also gets uncommitted and untracked ones;committedFileshas the old list. - A dirty tree adds a
## uncommittedblock to the bundle. - A tool that read a step's block out of its own section of the bundle finds
a pointer there now; the block is under Suggested claims. - Sample-integrity quotes are pre-filled for committed files only, since
receiptchecks them at the reviewed commit. summariseGatecountsclosed-uncheckedasclosedUnchecked, not among
degradedAllows.
API
- New:
gatherCommandFor(root, recorded?),GatherOptions.gatherCommand,
GatherOptions.timeoutMs,GatherResult.committedFiles,
Seal.gatherCommand,Seal.base,LintOptions.artifactSourceName,
untaggedBlocks(entries),GateSummary.closedUnchecked, and the gate
outcomeclosed-unchecked. sourceFingerprinttakes the repository root andreadCurrentRunthe state
directory,checkFreshnessa fingerprint already taken - each optional, for
a caller that has it.
Repository
- The suite is 473 tests across 38 files.
Full Changelog: v0.9.0...v0.10.0
v0.9.0
evidence-layer 0.9.0
Claims in a project's own review format - a risk table's cells, a list - can
now be graded by the same rules as **Claim**: blocks, the distance a grade may
sit below its claim is the caller's to set, and runAsync takes its arguments
as an array. Each removes a workaround a consuming project had to write around
this package. Nothing here changes what an exit code means.
Inline claims
lintClaims(markdown, 'review.md', {
inlineClaims: [{ line: 14, text: 'store leaks per request VERIFIED[tests-a3f91c: failed=0]' }],
})- The caller finds the claims - its table grammar stays its own - and they get
the address, commit, predicate, outcome and shotgun rules a**Claim**:
block gets. A bareVERIFIEDlooks below its line for its block, as a block
claim's does. - An inline claim has no lines below it to borrow: a DOCUMENTED claim cites
inside its own text, and an INFERRED one carries its reason beside the tag
([INFERRED: fewer unique URLs -> more cache reuse]). Two new reason codes:
claims/reasonedandclaims/no-reason. - Inline claims count towards
claims/shotguntogether with the blocks, and a
document whose only claims are inline is not reported asclaims/none. - A
lineoutside the document is aRangeError: a finding that points
nowhere helps nobody.
Where a grade may sit: gradeWindow
lintClaims(md, file, { gradeWindow: 5 })lets the grade sit up to that many
lines of prose below the**Claim**:line. The default stays 2
(GRADE_WINDOW);0means the claim line only.
runAsync(program, { args })
- With
args, nothing in an argument reaches a shell on POSIX. On Windows,
wherepnpmis a.cmdand needs one, each argument is quoted for cmd.exe;
one containing"or%cannot be quoted reliably there and rejects the
promise instead of running as something else. - The result's
commandis the same line on every platform - POSIX-quoted
where an argument needs it - becauseartifactIdhashes it. - Without
args, nothing changes.
Expiry dates, written down
- A governance exception expires, and a decision record goes stale, at 00:00
UTC on the listed date - the listed day is the first one not covered, in
every time zone. This was always the behaviour; it is now documented in
docs/governance-exceptions.mdanddocs/evidence-layer.md, and the
boundary is pinned by tests.
Behaviour changes to check when upgrading
- A fenced block between a claim and its grade no longer counts against the
window, and cannot supply the grade. A grade written below a code example
used to be out of reach (claims/untagged); it is now found. A grade-like
line inside a fence used to be taken as the grade; it no longer is. - The next
**Claim**:line ends the search. An untagged claim directly
above a tagged one used to take that one's grade; it is now
claims/untagged, which is what it was.
API
- New:
GRADE_WINDOW,INLINE_REASON_WORDS,InlineClaim;
LintOptions.gradeWindowandLintOptions.inlineClaims;
RunAsyncOptions.args.findClaimstakes an optional window. - Reason codes
claims/reasonedandclaims/no-reason.
Repository
- The suite is 453 tests across 37 files.
Full Changelog: v0.8.0...v0.9.0
v0.8.0
evidence-layer 0.8.0
The runtime gate now runs in Codex as well as Claude Code, a gathered test
step records how many tests it ran, and init --review no longer writes a
command a shell could be turned against. Nothing here changes what a review
must contain or what any exit code means.
Security: init --review could write a command that runs code
In 0.5.0 to 0.7.0, evidence-layer init claude --review <paths> put the value
on the hook's command line inside double quotes, which a POSIX shell still
expands. --review '$(touch x)' wrote hooks that ran touch x every time
one of them ran, for everyone who checked the settings out. The value
is an argument to init, so it takes someone - or an agent - running init
with a crafted value, and the result is committed where others run it.
init claude|codex now refuses a --review value with any character other
than letters, digits and . _ / , @ + -: it exits 2 and writes nothing.
What to do: look at the --review part of the evidence-layer commands in
.claude/settings.json (and .claude/settings.local.json). Plain paths are
fine. Anything else - $, a backtick, ;, |, &, a quote - should be
removed: run init again with plain paths, which replaces evidence-layer's
own entries and keeps every other hook.
The rest of the hook path was tested against the same attack and needed no
fix: for both hosts and every event, a payload in every field the agent
writes - a session id, a written path, a patch's paths, a review's claims
and receipt, the final message - and a repository directory itself named
$(...), with nothing run (test/host-injection.test.ts).
The runtime gate in Codex
npx evidence-layer init codex # writes .codex/hooks.json; approve the hooks in /hooks- The same three hooks as Claude Code: session start, before a file write,
and stop, with the same decisions. DEC-0009
records why Codex is supported now. - Codex edits files with
apply_patch, so the guard on the evidence ledger
reads every path a patch adds, updates, deletes or moves a file to. - Codex gives a hook no project directory, so the command finds the package
fromgit rev-parse --show-toplevel, as Codex's documentation does; on
Windows it usesnpx --no-install. Codex runs a project's hooks only once
they are trusted, and again afterinitchanges them.
Work summaries: how much a test step checked
VERIFIED[tests-…: passed>=N] reads a [SUMMARY tests: …] line, and until
now nothing wrote one. DEC-0008:
import { gatherEvidence } from 'evidence-layer'
import { summariseVitest } from 'evidence-layer/adapters/test-reports'
await gatherEvidence({ repo, base, summaries: { test: summariseVitest() } })- New entry point
evidence-layer/adapters/test-reports:summariseJUnit
(JUnit XML - pytest, Maven, Gradle,dotnet test, go-junit-report,
jest-junit),summarisePytest,summariseVitestandsummariseNodeTest
(node --test), and the parsers under them. A JUnit report older than the
step is refused, so an earlier run's counts are never credited to this one. - The bundle's suggested claims then carry the counts:
VERIFIED[tests-…: exit=0, passed>=3, failed=0]. PlanOptionstakestestCommandandtypecheckCommand, so a project not
on pnpm can gather. pytest, rspec, phpunit, mypy, pyright, ruff, flake8
and pylint are recognised for what they are; pytest used to be a plain
command, held to none of the test rules.
Fixed
- The GitHub Action ref the README shows (
@v0.7.0) would have stayed behind
at every release.check:docsnow holds it to the package version and the
release rewrites it, so the snippet above names this release. - The build's type-resolution check (attw) only warned; it fails now. The
boundary test now also seesexport … fromandimport().
The example project
examples/plain-node-project is set up the way a project using this package
should be: an evidence script with work summaries, the action in CI, and the
hooks for both hosts. Inside this repository it is a template - git answers
for evidence-layer there - so npm run standalone copies it out as a
repository of its own, where the gate and the seal work on it.
Repository
check:ladderruns in CI on every commit a push or pull request brings.- The action's download-the-release path runs in CI, through real
npx, in
a workspace with nothing installed. - Freshness is tested for deletes, renames, mode changes and symlinks.
- The suite is 425 tests across 34 files.
Full Changelog: v0.7.0...v0.8.0
v0.7.0
evidence-layer 0.7.0
The checks now run in GitHub Actions as one workflow step, and put each
finding on the line it is about. Until now CI meant a run: step and a reader
opening the log to find out why the job was red. The decision is
DEC-0007.
Nothing here changes what a review must contain or what any exit code means.
The GitHub Action
- uses: actions/checkout@v5
- uses: AndrewBogdanovTSS/evidence-layer@v0.7.0
with:
claims: review.md # comma- or newline-separated
# receipt: review.md # needs fetch-depth: 0 and the target branch
# governance: 'true' # also run governance --enforce
# strict: 'true' # warnings and unverifiable fail too- Every finding that is not a pass becomes an annotation on its line, titled
by its reason code,
and the job summary lists them.unverifiableandflakyare warnings
whose title says which, so "nobody could check this" does not read as a nit. - It runs the evidence-layer the project installed, so CI and a laptop give
the same verdict. With none installed, it runs the exact version the action
was released with -@v0.7.0runs 0.7.0, next year too - or theversion
input; neverlatest. - Outputs
exit-codeandconclusion(success,failure,flaky,
misconfigured). Nothing to check, a missing review file, an installed
package too old to annotate, or a version that is not exact is
misconfigured, exit 2, and outranks a failure - a step that could not run
never passes. - No token and no permissions: annotations are workflow commands, so they land
on the job's own check run with nothing to grant. - A plain Node script with no dependencies and no build, so the action runs
straight from this repository at the tag a workflow names.
--format github
claims,receiptandgovernancetake--format githubfor anyrun:
step: the annotations, then the usual text report, then a job summary when
GITHUB_STEP_SUMMARYis set. The exit code is the text mode's.- A review cannot give the runner instructions. It is text written by an
agent or by the author of a pull request from a fork, and the runner obeys
any stdout line that reads like::add-mask::.... Annotation values are
escaped the way the runner un-escapes them, so a newline cannot start a
second command, and the text report - which quotes the review - runs under
::stop-commands::with a token chosen at random for each run.
Publishing
- Releases are published through npm's Trusted Publisher: the workflow's OIDC
token is the only credential, and there is no long-lived npm token to leak
or renew. 0.6.0 was the first release published this way; with the token
gone, a broken Trusted Publisher fails the publish rather than falling back.
API
- New exports:
annotations,escapeData,escapeProperty,stopTokenand
renderFindingsSummary;printFindings, which every check uses to print in
the format asked for.Formatgains'github'. package.jsondeclares"evidenceLayer": { "outputFormats": ["text", "json", "github"] }alongsidehookProtocol- the capability the action
checks instead of comparing versions.
Repository
- CI runs the action on a fixture review with
uses: ./, so each commit's
action and checks are tested together. - The suite is 377 tests across 29 files.
Full Changelog: v0.6.0...v0.7.0
v0.6.0
evidence-layer 0.6.0
Every finding now says which rule produced it, in a word a program can match,
and the checks can print their findings as JSON. Until now a tool that wanted
to act on a finding had the level - how bad, not what - or the English
sentence, which changes whenever someone improves it. The decision is
DEC-0006.
Nothing here changes what a review must contain or what any exit code means.
Reason codes
- Every finding from
claims,receipt,claims --sealandgovernance
carries acodenaming the rule behind it:claims/predicate-unmet,
claims/unknown-artifact,receipt/wrong-base,seal/stale,
governance/unreachedand 65 more. The full list, with what each means, is
indocs/evidence-layer.md. - A code names a rule, not an outcome. The five levels are unchanged; a rule
that can pass or fail has one code for the pass and one for each way it
fails, so the code alone says what went wrong. - A released code keeps its meaning. A rule whose meaning changes gets a new
code; the suite fails if a released one is removed or renamed.claimand
detailremain prose and may be reworded in any release. - The package's own checks cannot produce a finding without a registered code:
it is a type error.Finding.codestays optional for a project's own checks.
--format json
npx evidence-layer claims review.md --format json
npx evidence-layer receipt review.md --format json
npx evidence-layer governance --format json- Stdout is one JSON document and nothing else - no text, no delimiters - and
the exit code is the one the text mode gives. - The document is the report
governancealready printed between its
---GOVERNANCE-JSON-BEGIN---delimiters, now schema 4: each finding has
code, and the report hascheck(claims,receipt,governance). Both
fields are additions; a reader of schema 3 still reads it. - An unknown format is bad usage (exit 2), never a quiet fall back to text.
The runtime gate runs in this repository
evidence-layer init claude, run in evidence-layer's own repository, wires
the plugin's launcher instead ofnode_modules/evidence-layer, and the
launcher treats a directory whose ownpackage.jsonis evidence-layer as
the package. A checkout that has not been built says the gate is inactive
and suggestspnpm build, rather than failing the hook. The repository now
commits its.claude/settings.json, so every Claude Code session in it runs
the gate.
Fixes
- A
path:0citation reported "resolves" while failing. It now says lines are
numbered from 1.
API
- New exports:
CODES,Code,CodedFinding;formatOfandFormat;
printJsonReport.buildReporttakes an optionalcheck. lintClaims,checkReceipt,checkProvenance,checkFreshness,
checkReachability,checkExceptionsandcheckDecisionHygienereturn
CodedFinding- aFindingwithcodeset, so existing callers compile
unchanged.REPORT_SCHEMAis 4.
Repository
AGENTS.mdis the source of truth for contributors, people and agents;
there is deliberately noCLAUDE.md, because Claude Code readsAGENTS.md
when none exists.- The suite is 365 tests across 27 files.
Full Changelog: v0.5.0...v0.6.0
v0.5.0
evidence-layer 0.5.0
Until now every check ran in CI, after the change and its account were
written - and usually after someone had started reading them. 0.5.0 adds the
moment before that: a runtime gate that checks an agent's claims when it tries
to stop, so the agent that wrote "the suite passes" is the one that fixes it.
It also closes a shell-injection hole in receipt that every earlier version
has. The decision, its threat model and its invariants are
DEC-0005;
why each choice was made is in the
implementation report.
Security
receiptno longer runs anything a review says. It built git commands by
concatenating the receipt'sTarget branchand every Sample integrity path
into a shell string, so a review containing$(...)executed it on the
machine checking it - in CI, a pull request from a fork. Git is now called
with an argument array and no shell (git()/gitCapture()in the API),
gatherEvidencepasses caller-supplied revisions after--end-of-options,
and changed paths are read NUL-separated. Upgrade if you runreceipton
reviews you did not write. There will be no 0.4.x patch: the fix is in
0.5.0 and later only.
Predicates: a claim names the numbers its artifact must show
**Claim**: the suite runs 71 passing tests.
**Grounding**: VERIFIED[tests-a3f91c: exit=0, passed>=71, failed=0]- Keys are a closed set read off the cited block itself -
exit,stopped
(none/timeout/overflow/killed), and the work-summary counts
(passed failed skipped todo,files ignored errors warnings) - so a
predicate checks identically locally and in CI. - Nothing is dropped: an unknown key, a fragment that does not parse, a key
given twice, a count the artifact does not carry, a non-decimal or unsafe
number, or an ordering comparison onstoppedis an error. - A
VERIFIED[...]bracket that is not an address is an error, instead of
silently falling back to the legacy bare-VERIFIEDproximity check. - Predicates are checked in addition to the existing outcome rules, never
instead of them, and the tag's text is removed before a claim's wording is
read -passed>=3inside a failure claim no longer reads as success. - Two different artifact blocks under one id are now an error (identical
copies are fine). Previously the second was never looked at. - The skeleton writes the predicates for you, read off the run it gathered.
- New negative fixture:
examples/plain-node-project/reviews/predicate-mismatch.md.
The seal: was this block gathered, and is it still about this code?
- Every
gatherEvidence()now publishes a sealed run into the checkout's
git directory (git rev-parse --git-path evidence-layer): the bundle, a
canonical hash of every artifact block, and a fingerprint of the working
directory's content - a git tree id computed through a throwaway index, so
the real index is never touched. Committing unchanged content does not move
it; writing the review does not either; editing any other non-ignored file
does. - A run becomes current only once it is complete. A gather that fails - or
whose own steps write into the files they check, like a formatter or code
generator - is never published; the bundle names the step and the paths, and
GatherResultreportssealed,runId,treeChangedandcoverage. evidence-layer claims review.md --sealadds two checks: every cited block
must be one the current run produced, unedited, and the code must still be
what that run saw. Editing a file after gathering is an error that names the
file. An untracked file too large to hash, a sparse checkout or a split index
makes freshnessunverifiable- never a pass.gatherEvidence({ seal: false })turns it off;reviewPathsnames the
review files to leave out of the fingerprint (defaultreview.md).- Not an attestation: it catches the default failure, not a writer who means to
fake it. CI, gathering its own evidence, stays the independent party.
The runtime gate, for Claude Code
-
Session start tells the agent the claim grammar and snapshots the review
files, so a later stop checks only what this session wrote. -
Before each file write, a write into the gate's own ledger is refused -
an accidental-write guard, not a sandbox. -
At stop,
claims,receipt, provenance and freshness run over every
review file the session changed, and over the**Claim**:lines of the
agent's final reply, resolved against the sealed bundle. Ordinary prose is
not graded. Errors block the stop, with the findings as the reason. -
A retry episode blocks at most twice, then lets the agent through with
the errors attached; only a clean check closes it. The gate fails open by
default;--fail-closed,--keep-blockingand--strictare the stricter
trades. -
Every decision goes into a journal in the checkout's git directory. An allow
that checked nothing is never recorded as a pass. -
Install with
npx evidence-layer init claude(merges into
.claude/settings.json, or--localforsettings.local.json; keeps every
hook that is not its own; idempotent), or as a plugin:/plugin marketplace add AndrewBogdanovTSS/evidence-layer /plugin install evidence-layer@evidence-layer -
evidence-layer hook claude <event>always exits 0 and answers in Claude
Code's own protocol, because Claude Code reads an exit code as a decision and
this package's2(bad usage) is Claude Code's "block". -
The pre-write hook loads only what it needs (~55 ms on the development
machine); session start and stop measured 70-100 ms. In a live Claude Code
2.1.302 session the gate blocked an untagged claim, the agent fixed it, and
the next stop went through.
New commands and options
evidence-layer hook <host> <event>- the gate, as a host's hook runs it.evidence-layer init claude [--local] [--dry-run]- set the gate up.evidence-layer claims --seal [--repo <path>]- hold cited blocks to the
sealed run.evidence-layer journal report --include-gate- the gate's own counts:
blocks (tagged real or false), clean stops, stops let through without a clean
check, refused writes, median decision time.evidence-layer journal tag <id> real|false --gate- tag a gate block.
API
- New exports:
parsePredicates,evaluatePredicates,suggestPredicates;
sourceFingerprint,publishRun,readCurrentRun,checkProvenance,
checkFreshness,canonicalBlockand the rest ofseal;decideand the
gate types;gitandgitCapture. lintClaimsoptions:artifactSource(resolve ids against another
document, such as a sealed bundle) andprose: false(grade only**Claim**:
lines).package.jsondeclares"evidenceLayer": { "hookProtocol": 1 }, which the
plugin's launcher checks instead of comparing version strings.
Performance
- The CLI loads each command only when it is the one asked for. Smoke prints
the median cold start against a 100 ms budget (~51 ms on the development
machine), as a number rather than a gate - timing on shared runners is noise
as often as signal.
Behaviour changes to check when upgrading
gatherEvidencewrites into the repository's git directory by default. Pass
seal: falseif that is unwanted.- A
VERIFIED[...]bracket that is not a valid address used to pass as bare
VERIFIEDwith a warning; it is now an error. - Two different blocks under one artifact id in one review are now an error.
Governance
- DEC-0005
records eight invariants, each enforced bypnpm testorpnpm check:docs. check:docsnow holds the README's supported-hosts table to an adapter with
recorded fixtures.- The boundary test covers
src/hosts/: it imports only builtins, the core and
its own contract, and the core never imports it. - The suite is 356 tests across 26 files.
Full Changelog: v0.4.0...v0.5.0