# pushkin
Schema-first enforcement harness that gates AI coding agents' file writes
against project contracts, deterministically. An agent write that violates a
contract, touches protected surface, or edits a committed test suite is
denied at the hook — with the reason, the fix hint, and an auditable event
trail. Humans stay un-gated where it matters; everything fails open loudly,
never silently.
## Documentation map
| `DESIGN-FINDINGS.md` | canonical spec, the research it rests on, and the F-Ledger |
| `docs/TESTING.md` | **testing design and infrastructure** — the layers, the red→green protocol, read-only suites, fixtures, the floor and its accounting, CI, and the evidence for why it is shaped this way |
| `docs/TESTING-RUST-FOR-PYTHON-ENGINEERS.md` | the toolchain and test model taught from a Python baseline; start here if `cargo` and `clippy` are new |
| `docs/RESEARCH-SOURCES.md` | every external figure this repo cites, with URLs and verification dates — and an honest list of the claims that still have no source |
| `AGENT-INSTRUCTIONS.md` | builder conduct — the N-rules and the pass protocol |
| `docs/STANDING-RULES.md` | rules that apply to every pass (V2, V3, V5, N10/§4.1) |
| `docs/CONTINUATION-GUIDE.md` | area-by-area orientation for someone picking the repo up |
| `docs/ROADMAP.md` | current state, open findings, what is queued |
| `PHASE-LOG.md` | append-only record of every pass |
| `PUSHKIN-AGENT-INTEGRATION.md` | per-agent hook mechanics for all five adapters |
## Install
```sh
cargo install pushkin
```
Or from source:
```sh
cargo install --path crates/pushkin-cli
```
No Rust toolchain? The same binary ships on PyPI as `pushkin-cli` (macOS and
Linux wheels, nothing but the executable inside):
```sh
uv tool install pushkin-cli # or: pipx install pushkin-cli / pip install pushkin-cli
```
Use the installed binary for hooks, not `uvx pushkin-cli` — `uvx` resolves an
environment on every call, which the warm-path latency gate does not budget for.
Requires the binary on `PATH` (hooks resolve `pushkin` from `PATH` by
design — nothing bakes an absolute path). `pushkin doctor` names it if
resolution would fail.
> **After any change to gate behaviour, reinstall.** A binary older than the
> manifest schema fails every manifest-reading verb at exit 1 *before* any gate
> logic runs, and a hook exiting 1 is non-blocking — so version skew silently
> disables the write-time gate while `doctor` still reports healthy. Tracked as
> **F71**; see `docs/ROADMAP.md`.
## Quick start
```sh
cd your-repo # carrying a pushkin.toml manifest
pushkin init # Claude Code hooks + versioned consent
pushkin init --agent git # native pre-commit shim (no other tools needed)
pushkin doctor # verify the install; --repair fixes what it owns
pushkin floor # run the repo's declared mechanical floor
```
### Which manifest governs
Resolution is pinned to the repository, not to your shell's working directory:
1. **`PUSHKIN_MANIFEST`**, if set — that file, wherever it is. If it names a file that cannot be
read, pushkin fails with a named error; it never falls back to the repo's manifest behind
your back.
2. Otherwise **`<git rev-parse --show-toplevel>/pushkin.toml`** — the repository root's manifest,
whatever directory you invoke from.
3. Otherwise **`./pushkin.toml`**, and pushkin says so on stderr.
Case 3 is the honest edge: where `git` is missing or the tree is not a repository, pushkin cannot
tell whether a root exists, so it uses the working directory and tells you it did. In that
situation a `pushkin.toml` in a subdirectory still governs — `pushkin doctor` names every nested
manifest it finds, and carries a standing info line when `git` is unavailable.
Every verdict names the manifest it was decided under: repo-relative in the agent-facing prose,
absolute in `--json` and in the `pushkin floor` header.
Adapters: `--agent claude | codex | auggie | hermes | opencode`, plus two
pre-commit floors and a managed instructions block (`--agent agents-md`).
`--remove-agent <name>` uninstalls only what pushkin owns.
## The pre-commit floors
Two interchangeable floors run `pushkin check --staged --json` before every
commit; both carry the same guard: **fail open only on positively probed
absence** (no binary, or no `pushkin.toml`), with a loud actionable notice —
a teammate who never installed pushkin is not blocked by a hook they never
opted into, and a check that RUNS is never second-guessed.
- `pushkin init --agent lefthook` — merges a marker-bracketed block into
`lefthook.yml`, preserving every foreign command byte-for-byte. Any prior
pushkin marker generation upgrades in place; uninstall leaves no litter.
- `pushkin init --agent git` — writes `.git/hooks/pre-commit` directly, no
hook-manager dependency. Detects `core.hooksPath` overrides and foreign
hooks and prints the integration snippet instead of clobbering.
## Team enforcement: CI is the backstop
The hooks above bind only machines that opted in. A teammate who never
installed pushkin (or lefthook) commits ungated — the fail-open floor is
deliberate, so local hooks alone cannot enforce the gate team-wide. The
backstop is CI: run the gate as a required status check with the
`pushkin-gate` composite action this repo ships
(`.github/actions/pushkin-gate`), and unprovisioned pushes are caught at
the pull request instead of landing silently.
```yaml
name: pushkin-gate
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # affected mode needs history (F19)
- uses: acoletti/pushkin/.github/actions/pushkin-gate@<TAG-OR-SHA>
with:
mode: affected # or `check` for the full repo
base-ref: origin/main
pushkin-ref: <TAG-OR-SHA> # pin; a moving ref is non-reproducible
```
`affected` gates only the diff against `base-ref`; `check` sweeps every
mapped file. Optional `db-drift` / `db-rls` inputs add the schema gates
(see the action's own docs for their prerequisites). Make the job a
required check on the default branch and local provisioning becomes a
latency optimization, not the enforcement boundary.
## What the gate enforces
- **Contract conformance** at mapped boundaries (`contract.boundary.*`).
- **Protected paths** (`pushkin.protected_path`): the manifest, schemas,
CI, the floors — agent-denied at write time, unwaivable by construction.
Staged protected changes draw a loud advisory at the floor, never a block
(the floor runs for humans too).
- **Committed tests are read-only** (`pushkin.read_only_path`): files in
git HEAD under `read_only_paths` globs deny agent edits; NEW test files
and uncommitted iteration flow freely. Unwaivable.
- **Suppression comments** (`pushkin.suppression.new`) per the manifest.
- **Waivers** (`pushkin waive`): scoped, expiring, decision-logged — for
rules that allow them; doctor lints stale grants.
Read-only test surfaces are not a stylistic preference. Public measurement of
frontier models finds test modification is one of four systematic cheating
strategies, and that *"Read-only access provides a middle ground: it restores
legitimate performance while preventing test modification attempts"* where hiding
the tests degrades performance on the original benchmark
([ImpossibleBench, arXiv:2510.20270](https://arxiv.org/abs/2510.20270)).
`docs/TESTING.md` §2 collects that evidence alongside this repo's own recorded
incidents; `docs/RESEARCH-SOURCES.md` carries the full citations.
## The mechanical floor
`pushkin floor` runs the `[floor]` table declared in `pushkin.toml` — **one
committed list of commands** that `pushkin floor`, the `Makefile`,
`scripts/floor.sh` and CI all read, so "CI runs the same checks you do" is a
fact rather than a promise someone has to remember.
```sh
make floor # the canonical green — cite this, not a cargo count
pushkin floor --skip bench # named omission; prints EXCLUDED, NOT a full floor
```
Three properties worth knowing:
- **Ignored tests are accounted for.** A command declaring `reconcile_ignored`
must have its ignored count matched *exactly* by a command declaring
`covers_ignored_of`, or the floor is RED. Self-coverage is rejected at parse
time, and a coverer excluded by `--skip` covers nothing. This exists because
a cited floor number once silently omitted two `#[ignore]`d benchmarks — one
of which was red at the time.
- **The exit contract is inverted** relative to ESLint/ruff, and disclosed in
`--help`: **0** clean, **2** findings, **1** the gate itself could not run.
- **Network-dependent verdicts are disclosed.** `inputs = "network"` on a
command (e.g. `cargo deny check`, which consults the RustSec advisory DB)
prints a note that the verdict is not a pure function of the commit.
`docs/TESTING.md` §6–§7 documents the table, the accounting and the exit codes
in full.
## The schema-only tier (no JS runtime)
Gate-time enforcement is pure Rust. A repo that commits its canonical
schemas needs **no bun, node, or JS toolchain** to be gated: install the
binary (`cargo install pushkin`, or `uv tool install pushkin-cli` with no
Rust toolchain either), run `pushkin init`, done. Bun is a *compile-time* tool only —
`pushkin compile` regenerates canonical schemas and bindings from Zod
sources, and repos that author schemas directly (or vendor them) never
need it. If you never run `compile`, you never need a JS runtime.
## Doctor
`pushkin doctor` verifies every installed surface (adapters, both floors,
binding epochs) and exits 1 on findings; `--repair` regenerates only what
pushkin owns — foreign configs are reported, never rewritten, and
advisory findings (like an unresolvable binary) never trigger a rewrite.
Repair exit code: 0 when every repair that could run succeeded; deferral
messages name the manual remedy without failing the run.
## Release status
`pushkin` is published on crates.io — `cargo install pushkin` installs the
latest release. All four crates version in lockstep with exact internal pins
(`=0.2.1` at the current workspace version), and `Cargo.lock` is a release
artifact: `cargo install --locked` reproduces the exact dependency graph a
release was tested against.
Published versions are immutable. A correction to shipped content — including
this README, which renders on the crate page from the published tarball —
requires a new patch release; it cannot be fixed by editing `develop`.
Versioning policy:
- **Patch** (`0.1.x`) — fixes that do not change emitted output: docs, CI, the
gate action, internal refactors.
- **Minor** (`0.x.0`) — new schema capability or changed emitter output. Any
change under `crates/pushkin-compiler/src/**` is epoch-sensitive and bumps
`schema_epoch` in `pushkin.toml`, so it is at least a minor.
- **Tags** — every published version carries an annotated `vX.Y.Z` tag on the
exact commit whose tree was published.
## Contributing
Tests are written first, committed before the implementation, and read-only
once committed. `make floor` must be green at every commit. The full protocol,
the fixture patterns, and the reasoning are in **`docs/TESTING.md`**; if Rust is
new to you, read **`docs/TESTING-RUST-FOR-PYTHON-ENGINEERS.md`** first.
## Phase 0 reference implementation
The original Bun/TS spike (Claude Code gate) still lives in `src/` and
`tests/`: `make test && make lint`. The Rust workspace is the product;
`make floor` runs its full declared mechanical floor — currently seven
commands: `fmt`, `clippy`, `test`, `bench`, `deny`, `tsc`, `bun-test`.