Skip to content

Latest commit

 

History

2,116 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Batten

Batten is an agent-era completion gate: repo-state conformance checks — is this ref on main? did the required checks conclude green for this exact SHA? — enforced at the agent's tool call, and re-checked in CI and at pre-commit so the verdict cannot be bypassed.

Your agent says "done." The repository knows otherwise: the branch never landed, the checks never ran on that SHA, the tests were edited until they passed. Batten is the deterministic check at the moment of the claim — the throughput stays, the false "done" dies.

"Done" here is the minimum falsifiable completion predicate: landed on main by fast-forward, with the required checks green for that exact SHA. It kills false success. It does not certify correctness, and review still gates release.

Status: early scaffold. The command surface is being filled in against the project plan; see Roadmap. The crate is not yet published to a registry, so install from a release archive — distribution to a registry is a recorded, deferred decision on the project board.

Install

Binary first: a release archive holds a single static executable, and every package manager below is a convenience over the same asset. One line, every environmentcurl and tar are the only requirements, so no Rust toolchain, no package manager, no clone, and nothing harness-specific:

curl -fsSL https://raw.githubusercontent.com/button-inc/batten/main/install.sh | sh

It detects your platform, downloads that target's archive, verifies it against the SHA-256 digest the release reports and refuses to install on a mismatch, and puts batten in ${XDG_BIN_HOME:-$HOME/.local/bin}. BATTEN_VERSION selects a tag other than the latest, BATTEN_INSTALL_DIR a different destination, and BATTEN_TARGET overrides platform detection — Linux resolves to the statically linked musl build, which runs whatever the host's glibc version.

The script comes from main and the binary comes from the latest release, so what installs is a tested artifact rather than a branch tip.

Three behaviours matter wherever that line runs unattended — a CI runner, a container's setup step, an agent sandbox:

  • A token is read if one is set, from BATTEN_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN or GITHUB_PERSONAL_ACCESS_TOKEN, in that order. A public release needs none of them. This repository is private today, so a fetch needs a token with release-read scope until that changes; a host carrying several tokens that are not equivalent names the working one through BATTEN_GITHUB_TOKEN, which wins.
  • A proxy that re-terminates TLS is handled by honouring the CA bundle the environment already declares — CURL_CA_BUNDLE, else SSL_CERT_FILE. Nothing is disabled and an unproxied host is untouched. Set one of those rather than reaching for NO_PROXY.
  • Requests retry with backoff (BATTEN_RETRIES, default 3) and carry connect and total timeouts, so a transient failure is retried and a hung connection fails rather than hanging the caller.

Installing somewhere not on PATH is a refusal, not a warning: every hook registration names batten bare, so a binary the shell cannot resolve is indistinguishable from no binary. Point BATTEN_INSTALL_DIR at a directory on PATH, or set BATTEN_ALLOW_OFF_PATH=1 when a staging destination is deliberate.

cargo binstall reads the same assets through [package.metadata.binstall]:

cargo binstall --git https://github.com/button-inc/batten batten

The plain cargo binstall batten form needs the crate on a registry, which the distribution decision defers.

Binaries are never committed to this repository; they come from a release, and mise run install-check is the gate that keeps every reader of an asset name agreeing with the one that writes it.

Why

Repo-config-driven conformance gates that can judge an agent's tool call before execution are new, and earlier tooling was built for humans at commit time rather than agents operating mid-trajectory. The hook layer itself is deliberately boring: the major harnesses have converged on one wire shape — a JSON payload on stdin, a block returned as exit code 2, a JSON verdict on stdout — so Batten's normalized envelope and thin per-host shims are cheap insurance against divergence, not the product. Batten's own layer is the one behind the hook: one engine rendering the same verdict from the same committed config at the agent's tool call — and again in CI and at pre-commit, so the verdict cannot be bypassed — with completion predicates (landed, verified, CI-green) as first-class rules.

The hook is the binding surface because it fires on events the agent cannot decline, and because it reads committed, out-of-band config that the model's context cannot influence. Any surface the model must choose to consult loses to the primitive it already trusts.

Cheap to consult, so it gets consulted

A gate an agent routes around is a gate that does not run, and what agents route around is expense. Three pains compound in an agent's context, and a tool that answers with a dump makes every one of them worse:

  • Tail-calling. The output did not fit, so the agent runs the command again to see a different slice — paying twice for one answer, often for the wrong slice.
  • Lost-in-the-middle. A two-thousand-line dump buries the one line that mattered exactly where retrieval is weakest.
  • Context rot. Every avoidable byte crowds out the working state the agent needs to finish the task it was actually doing.

Batten's output contract answers all three at once. A finding is a pointer, not a payload — a count and a path:line, never the matched content — so a wrapped tool's two thousand lines become one. Output is byte-stable, so an unchanged repository renders identical bytes and the agent's prefix cache stays warm instead of being invalidated by a reordered map or a timestamp.

And a refusal points at the fix — the class name IS the pointer. A mediated deny emits one line: a declared three-word class and the pointers it applies to, as in shell edit refused mise-tasks/land.sh:845. The reason, the routes out (the escape hatch and the override alike) and the class's full definition are one hop away, at batten policy explain "shell edit refused".

That is a decision rather than an omission, and it is the same argument as the three pains above turned on the tool's own output. The reason and the remedy do not change between firings, and a mediated refusal fires hundreds of times in a long session, so inlining them means paying per firing for text that was declared once. The name carries the class because the names are a declared vocabulary rather than free text — three positional words, each glossed — which is what makes one hop cheap and the elision honest rather than merely shorter. The pointer, which DOES change per firing, stays inline: this shortens the prose, never the operand a reader acts on. The ceiling on that line is declared in batten.toml and gated, so "one line" is a property of the data and not of an author's restraint.

Magnitude belongs to the benchmark, not to this page. A benchmark is the proof, measured per capability against a named workload with a stated baseline and run count. No figure is published here until it has been measured that way; a capability with no defensible number reports "not measured" rather than borrowing one.

Design principles

  • Agent-neutral operation rather than a bespoke interposition layer.
  • Deterministic verification with byte-stable machine-readable output — the same input produces identical bytes, keeping agent caches warm.
  • Rules ship with their mechanism. A prose rule without a gate is feedforward-only; a defect log without a gate is sensor-only. Both are half a harness.
  • The CLI is data. A single usage spec is the source of truth for completions, man pages, and markdown, and effects are annotated once and reused (the agent read-only allowlist is derived from those annotations and emitted by batten spec as read_only_allowlist, so a consumer reads it rather than re-deriving it).
  • Narrow configuration. A two-layer TOML model — a repo file plus env and flag overrides — with no upward walk and no conf.d merge surface.
  • Opt-in scaffolding. batten init writes a starter batten.toml and refuses to overwrite an existing one; batten doctor --json checks what it wrote. check on built-in defaults is still planned (CLOUD-70) — today it requires a batten.toml in the working directory, which is what makes init the first command a new repository runs.
  • Gates decide, they do not estimate. A predicate that only approximates its own question — a model judgement, or a match over open-ended content — may advise; it never blocks.
  • Consumer #1 is Batten itself — its own checked-in batten.toml runs against its own repository.

Scope and limits

Batten evaluates narrow content predicates and wraps linters, scanners, and hook runners as evidence sources — a rule kind exists to gate on a tool's verdict, never to replace the tool, so the boundary holds even as rule kinds grow. Its threat model is honest agent or human error: acting on the wrong entity, at the wrong time, or with the wrong completion signal — where "wrong entity" means a call's argument values, judged against committed, out-of-band config. It cannot reliably catch attacker- or error-chosen parameters of otherwise-permitted calls, harmful composition of individually legal steps, cross-session poisoning, or errors in its own spec. And a green check certifies exactly its predicates, nothing more: over-trusting it is the misuse cost of any gate that works, which is why review still gates release.

Build and test

mise install     # the pinned toolchain — see CONTRIBUTING.md for one-time setup
mise run ci      # the same gate CI runs

Everything goes through mise tasks so local runs, git hooks, and CI execute byte-identical commands; CONTRIBUTING.md has the per-clone setup and the task tour.

Performance

Batten runs as a PreToolUse hook, so its cost is paid on every mediated tool call rather than once per commit. These are measured numbers, not targets — mise run perf reproduces them, and mise run perf-assert fails when a measured p95 leaves its budget.

wired is the number an agent actually waits on: the entry point .claude/settings.json invokes, launcher included, derived from that file at measure time rather than hardcoded — so the published figure describes what is installed rather than a binary nothing calls. hook stays measured beside it so the launcher's own share is attributable.

path what it does p50 p95 budget
noop process start, command tree, render 2.1 ms 2.4 ms ≤ 100 ms
check + config load, trust resolution, one-rule tree 2.3 ms 2.7 ms ≤ 100 ms
hook + envelope decode, adjudication, decision write 2.8 ms 3.0 ms ≤ 100 ms
passthrough a call no rule selects — decode, allow, no config load ≤ 100 ms
posttool a PostToolUse call — decode, capture the response ≤ 100 ms
wired the hook as .claude/settings.json invokes it 8.0 ms 8.4 ms ≤ 100 ms

posttool is the same envelope decode plus the response capture, and its cells are for passthrough's reason: the series above comes from a quieter machine, and a figure from a noisier one published in that column reads as a step change rather than as a different room. Measured against its own contemporaneous hook on one container, 100 runs each, the capture costs 1.25x at p50 and 1.12x at p95 — 16.3 ms and 21.1 ms against that machine's own 13.0 ms and 18.9 ms for hook, so about 5x inside the 100 ms budget on a noisy container and further inside it on the machine the table above reports.

perf-compare reads a different pairing and gets a different number, and both are honest: it runs this arm against the MERGE BASE's binary, which performs no capture at all, so its 1.806x prices the feature rather than a drift in the cost of the same work. That arm carries an exemption in perf-compare until main holds a capturing binary and the comparison is like with like; the absolute budget is what gates it in the meantime.

100 ms is the Command Line Interface Guidelines' floor for a response that reads as instant. It is an absolute ceiling rather than a tight band around the measured value: a shared runner's p95 moves by more than a percentage band between two runs of identical bytes, so a tighter gate would fire on noise instead of on regressions. check is measured and deliberately not budgeted — its cost is bounded by the repository it is pointed at, not by Batten, and no ceiling here could tell a large tree apart from a regression.

Measured 2026-08-12 on a 4-core x86_64 Linux container: release build, 10 warmup runs discarded, 100 timed runs per path, p95 from the sorted run times. Your machine will differ; the budget is what the gate holds, and the schedule in .github/workflows/perf.yml is what keeps holding it.

A correction, kept because the mistake is the instructive part. The previous revision of this table published hook at 16.6 ms and blamed a concurrently-loaded container. That explanation was wrong. The cost was a real regression — one receipt row made every mediated call resolve receipts, four git subprocesses' worth, including calls no receipt rule could ever match (CLOUD-460). Measured on the command that exposed it, the fix is 3.44× ± 0.30 faster: 9.4 ms → 2.7 ms. It sat inside the ≤ 100 ms budget the whole time, so no gate went red — which is exactly why a wrong explanation in a performance note is worse than none: it tells the next reader the number is environmental and not to look.

Exit-code contract

One table, total, with no per-verb exception. batten --help prints the same table, and each meaning below is asserted against the binary's own rendering, so this section cannot drift from the codes the binary returns.

Code Meaning
0 clean — nothing to report; a mediated call is allowed
1 config or usage error — fail loud, do not block
2 policy verdict — a violation, or a mediated call denied
3 internal error — fail loud, do not block

The numbering is chosen so the mediation channel needs no translation: hosts with a pre-tool hook read 0 as allow, 2 as deny with stderr as the reason, and anything else as "the hook itself failed, let the call through". A deny and a violation share a code because they are the same kind of answer, and failing open is structural — the only codes a Batten failure can produce are ones every harness already treats as non-blocking.

The channel varies by harness even though the number does not: a host whose only decision channel is process status is denied by exit 2, while a host that reads an in-band decision document is denied by that document with exit 0.

batten doctor is the post-install self-check: it reports whether Batten can run in this repository, with --json for a byte-stable machine reading. It is a diagnostic, so it never returns 2 — every failure it can report is the config-or-usage class, and a harness must never read "this checkout is misconfigured" as a policy denial.

Extending Batten: ten rule kinds, and which to reach for

Any predicate you can express as a command plus an exit code is expressible in Batten — and most of them need no command at all. The failure mode is picking the wrong kind, so the boundary matters more than the mechanics.

Reach for the narrowest kind that fits. A command rule spawns a process, which can read any file and reach the network; every other kind is decided from facts the boundary already resolved. That is why a command rule runs only under batten enforce while the rest are admitted to the read-only check surface.

What you are gating on Reach for Where it is configured
A literal string banned from matched files forbid [[rule]] with kind="forbid"
A file's contents, judged by a program you supply command [[rule]] with kind="command"
A command line an agent is about to run shape [[rule]] with kind="shape"
A count that must not grow — a budget you are paying down ratchet [[rule]] with kind="ratchet"
Whether a verification receipt exists and still answers receipt [[rule]] with kind="receipt"
The shape of a pipeline — how a call is composed pipeline [[rule]] with kind="pipeline"
A judgement a model makes, recorded with its own no-fix reason judge [[rule]] with kind="judge"
Credentials reaching a file, via a pinned scanner secrets [[rule]] with kind="secrets"
A document's own structure document [[rule]] with kind="document"
A relationship between facts no single row can express policy [[rule]] with kind="policy"

Two surfaces are not rule kinds and are configured on their own:

What you are gating on Reach for Where it is configured
A command's output, when the tool lies about exit 0 exec output predicates [[exec_pattern]]
An existing warn finding, to make it block fail_on_warning a top-level key

Gating on a relationship between facts — a policy rule

The other kinds are each one predicate over one object. A policy rule is a Rego module deciding over the whole resolved fact set, which is what makes a predicate over the relationship between facts expressible at all — the engine's own rule loop is flat, and no row can consume another's verdict.

[[rule]]
id = "no-orphan-workflow"
kind = "policy"
scope = "tree"
sources = [".github/workflows/*.yml"]
module = "policy/no-orphan-workflow.rego"
severity = "deny"

A module is deny-only by construction — there is no allow spelling — so enabling one can never weaken policy, which is what preserves the raise-only invariant above. A refusal it raises is {rule, verdict, subjects}, and the verdict is a declared class rather than free prose, so batten policy explain <class> reaches the remedy from any refusal.

Presets: the batteries, and how to switch one on

Batten ships policy modules for common practices, compiled into the binary. They are the reason a new repository does not have to author every predicate from scratch — the same shape Conftest, Semgrep, ESLint and Clippy all take.

[[rule]]
id = "trunk-based"
kind = "policy"
scope = "mediated_call"
preset = "trunk-based"
severity = "deny"

batten config show lists the presets this binary ships. Each declares the scope its modules decide, and enabling one at the other scope is refused at load rather than quietly deciding nothing.

There is no network and no registry: a preset's bytes ship inside the binary you already trust, under the same checksum as the rest of it.

Everything a consumer adds is raise-only (§8): a git-ignored batten.local.toml may add a rule or a pattern, never redefine or remove one the committed authority declares. A weakening is refused with exit 1, not applied.

Gating on a file's contents — a command rule

glob selects the files, check names the command, and {{files}} is substituted with the matched paths. Exit 0 passes; any non-zero exit is a violation.

[[rule]]
id = "single-entrypoint"
kind = "command"
glob = "src/**/*.rs"
check = "./scripts/one-entrypoint {{files}}"
severity = "deny"

The key is check rather than run because the kind carries a check/fix duality (§9): check is the inspection-only gate, and an optional fix names the mutating command that repairs what it condemned. Enforcement is always the check side. fix is reserved, not yet executed — serialised fix execution is not a capability this engine has, so batten enforce refuses a rule declaring one with a usage error rather than accepting a repair that would silently never run.

A command rule runs under batten enforce only. batten check refuses it with a usage error rather than running it, which is what keeps check's read-only effect honest — the read-only surface never reaches user-supplied code.

Don't reach for this when you want to gate a command's output: the child's streams are discarded here, deliberately. The exit code is the whole predicate.

Gating on a command's output — an exec output predicate

For a tool that exits 0 while its own output says the work is not really done, and has no severity knob of its own to make it fail.

[[exec_pattern]]
id = "no-unfailed-duplicate"
pattern = "warning[duplicate]"
stream = "both"
reason = "set the tool's own severity to deny; do not let a warning ride an exit 0"
$ batten exec -- cargo deny check
stdout:14 no-unfailed-duplicate
exec: 1 output match(es)
no-unfailed-duplicate: set the tool's own severity to deny; …

A match always fails. There is no severity field on a pattern and no dependence on fail_on_warning, because the only surface an agent acts on is the exit code: a warn-but-pass match would be invisible to it, which is the exact false green the predicate exists to kill.

Batten only ever adds failure — a child that already exited non-zero passes its code through untouched.

Don't reach for this when the tool has its own severity model. Configure that instead; re-implementing a tool's severity as output-scraping is the thing this surface should not become.

Making an existing warn finding block — fail_on_warning

A warn-severity rule reports and does not fail the run. fail_on_warning promotes it, and it is the only promotion knob: no verb carries its own.

fail_on_warning = true

batten exec is deliberately not a consumer — an exec output match already fails unconditionally, so there is nothing for a promotion to promote.

The two promotion paths answer different questions, so they carry different codes

Not promoted Promoted
a warn finding from check/enforce exit 0 exit 2
an exec output match exit 1

Read that table as two questions rather than one question with two answers. A rule finding is a policy verdict about the repository, which is exit 2 on every surface that renders one. An exec output match is a statement about the invocation — the wrapped command reported success while its own output betrays that it is not done — which is what exit 1 means in the table above. Neither is an exception to §7; they are §7 applied to two different claims.

The codes are not interchangeable here, and 1 is not a placeholder for a 2 that would be tidier. exec is a transparent passthrough, so every other code on that channel is the child's, and the one thing that makes such a channel readable is that Batten never mints a 2 on it: a 2 out of exec came from the wrapped command, and nothing can mistake it for a verdict. Renumbering an output match to 2 would spend that guarantee for a symmetry the table does not ask for. Decided on CLOUD-292, with the three rejected alternatives recorded there; crates/batten/src/exit.rs carries the reasoning and crates/batten/tests/it/extension_surfaces.rs gates it.

Whatever you reach for: output is a pointer, never the payload

This is a project-wide law, not a quirk of one adapter. Every check Batten runs reports a count, a path:line, or a boolean — never the bytes it read. A forbid finding names the line number and not the line; an exec match names stream:line <id> and not the matched text; a command rule's child has both its streams discarded, so there is nothing to leak in the first place.

The reason is that a policy engine reads the places secrets live. Findings travel into CI logs, PR comments and model context, and a check that echoed what it matched would publish the thing it was gating. So the law binds your checks too: a command rule you write should exit non-zero and say where, not print what it found.

$ batten check
src/config.rs:41 no-hardcoded-token   # the pointer
                                      # never the token

crates/batten/tests/it/pointer_only.rs decides this rather than asserting it. It seeds a corpus in which every byte a check can read is a unique canary, runs every leaf verb of the command surface over it, and fails if a canary reaches either channel. A verb added to the surface must declare which side of the law it sits on before the suite will pass, so the guarantee stays total as the surface grows.

Every example above is executed, not just written

crates/batten/tests/it/extension_surfaces.rs runs each command in this section against the compiled binary and asserts the exit code it claims. A drifted example fails CI, so this documentation cannot rot into fiction.

Running Batten in GitHub Actions

The shipped Action works with an empty with: block — that is the bar it is held to, not a convenience it happens to offer:

- uses: actions/checkout@v5
- uses: button-inc/batten@v0.0.61

That runs batten check in the workspace against the batten.toml committed there, and fails the step on a policy verdict. Every input has a default, and the defaults are the useful configuration.

The version is not a fourth thing to keep in sync: with version unset the Action reads its own crate version from the Cargo.toml beside it, so the ref you pin selects the binary. @v0.0.61 runs Batten 0.0.61. There is no latest resolution to let the two disagree, and a tag whose release published no asset fails loudly rather than substituting another version.

Inputs

input default meaning
command check the verb to run
args "" extra arguments, split on whitespace
working-directory . where the verb runs, and therefore which batten.toml governs
stdin "" text piped to the verb; hook reads its payload there
version "" empty means this action ref's own version
github-token ${{ github.token }} reads the release asset
cache true restore and save the downloaded binary
fail true fail the step on a non-zero code; false reports it as an output

Outputs

output meaning
exit-code Batten's code, under the one contract in the table above
version the version that ran
binary absolute path to the binary, for a later step to invoke

fail: false is how a caller asserts an exact code rather than merely "the step went red" — the run continues and the code arrives on exit-code:

- uses: button-inc/batten@v0.0.61
  id: batten
  with:
    fail: false
- run: test "${{ steps.batten.outputs.exit-code }}" = "2"

Which matters most where 2 sits: a violation and a denied mediated call are the same code because they are the same kind of answer, so a caller that wants to tell "policy said no" from "Batten could not run" is reading 1 and 3, not the absence of 0. The Action's own self-test asserts all four exactly (.github/workflows/test.yml), including the 3 that a file Batten cannot read produces — a check that could not look is not a check that found nothing.

Mediating a call with hook

hook adjudicates one command rather than walking a tree, and it reads the harness payload on stdin — which is what the stdin input is for:

- uses: button-inc/batten@v0.0.61
  id: guard
  with:
    command: hook
    args: --harness exit-code
    stdin: '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"gh pr merge 42"}}'
    fail: false
- run: test "${{ steps.guard.outputs.exit-code }}" = "2"

With stdin empty the Action closes the descriptor rather than leaving it open, so a verb that reads standard input ends at EOF instead of blocking the job.

The Action does not prepend its install directory to PATH. A $GITHUB_PATH write changes how every later step in the job resolves a command, which is a hazard disproportionate to the convenience; use the binary output instead.

The cache key

The downloaded binary is cached under

key:  batten-<version>-<target>
path: ~/.cache/batten/<version>/<target>

where <target> is the Rust target triple the runner maps to — x86_64/aarch64 -unknown-linux-musl on Linux (the statically linked build, so it runs on any image regardless of glibc), -apple-darwin on macOS, x86_64-pc-windows-gnu on Windows.

Both the version and the target are in the key and there are no restore keys: a near-miss would restore a different version's binary under this one's name, which is precisely the confusion a policy engine must not create. An entry is therefore valid for exactly as long as its version is. Set cache: false to skip restore and save entirely; the download is a little over a megabyte.

The plain CLI alternative

Nothing above requires the Action. The same thing by hand, with no third-party action in the path:

- name: Install Batten
  env:
    GH_TOKEN: ${{ github.token }}
    VERSION: 0.0.61
    TARGET: x86_64-unknown-linux-musl
  run: |
    set -euo pipefail
    asset="batten-v$VERSION-$TARGET.tar.gz"
    id=$(gh api "repos/button-inc/batten/releases/tags/v$VERSION" \
      --jq ".assets[] | select(.name == \"$asset\") | .id")
    gh api "repos/button-inc/batten/releases/assets/$id" \
      -H "Accept: application/octet-stream" > "$asset"
    tar -xzf "$asset" -C /usr/local/bin batten
- run: batten check

The asset name is a contract, not a convenience — mise-tasks/dist.sh builds it and the release workflow uploads it under exactly that name — so this stays correct independently of the Action.

Tokens, and what a GITHUB_TOKEN will not do

github-token defaults to ${{ github.token }}, which needs contents: read. Inside this repository that is enough to read a release asset. From another repository it is not: the job token is scoped to the repository running the workflow, so a consumer reading releases on button-inc/batten from elsewhere passes a token of their own.

The note worth carrying past the install step: events created with GITHUB_TOKEN do not trigger further workflow runs. GitHub suppresses them deliberately, to stop a workflow recursing into itself. So if you wire Batten's result into something that pushes a commit, opens a pull request, or files an issue, that downstream event will start no workflow of its own while the default token is in play. Reaching for a PAT is the documented way around it, and it is a deliberate choice with the recursion the suppression exists to prevent — not a configuration detail.

The Action self-tests, and the test is allowed to fail

.github/workflows/test.yml checks this repository out into a subdirectory, materializes a fixture repository from crates/batten/tests/fixtures/repos/ at the workspace root, and invokes the Action with no with: key at all — so the empty-with: claim above is executed on a real runner rather than asserted here.

Roadmap

Work is tracked on the project board across phases:

  1. Pre-implementation blockers and decisions
  2. Foundation — scaffold, config, core extraction, fixtures
  3. Contracts and checks — CLI contract and rule/check engine
  4. Enforcement and capabilities — hook layer, advisory subsystem
  5. Packaging and distribution
  6. Consumer #1 migration

License

Licensed under the Apache License, Version 2.0. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.

About

Agent-era completion gate: repo-state conformance checks enforced at the agent's tool call.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages