The whole tool reduces to one rule: pnpm check is the definition of done. Exit 0 means complete. Anything else means it is not.
Three things outside the project: Node ≥ 22.18, a package manager (pnpm ≥ 9 is the default; npm, yarn, and bun work too), and git. Everything else — oxlint, ast-grep, fallow, vitest — is an ordinary project devDependency. A repo without Node needs mise in their place: it installs checkride, its Node and the language's own tools (see a new Go or Rust project, and Languages). Verify any time:
doctor is read-only: it checks Node/pnpm/git, that the project is installed, and reports every slot's status. Exit 0 means you are ready to run.
init is additive: it inventories the tools you already have, writes only what is missing (a config, a check script alias, the AGENTS.md stanza, the agent hooks), gitignores .check/, and never overwrites an existing tool config. For a repo with real existing findings, prefer init --baseline — see the baseline. To scaffold a tool you don't have yet, name its slot: init --add struct,dead, then pnpm add -D @ast-grep/cli fallow.
A root without a package.json is existing too when it marks another pack — a go.mod or go.work, a Cargo.toml, a pyproject.toml, setup.cfg or setup.py, or a .terraform.lock.hcl or .tf file — and init adopts it through that pack. In a repo of several parts it also proposes the targets the files suggest: a ts target at the root when it holds a package.json or pnpm-workspace.yaml, one go, rust, python or terraform target per root, and a bash target for shell scripts no other target holds. It prints them as a targets block and writes them as target add would (--dry-run prints and writes nothing); a config that already declares targets is left alone. Each target then checks its part from its own root, its checks named <target>:<slot>, such as web:lint.
Under another package manager the two steps are the same shape — npm install -D -E checkride && npx checkride init, yarn add -D -E checkride && yarn checkride init, or bun add -d -E checkride && bunx checkride init. init detects the manager and spells the alias, stanza, and hooks for it.
From an empty directory, init generates a complete repo, green out of the box. Pick a shape: flat (single package, deep-modules layout under src/), monorepo (a pnpm workspace of apps/* and libs/*), or hybrid (root app plus internal packages/*). Preview with --dry-run.
Unit tests sit in a __tests__/ folder beside the code they cover, and tests that span modules go under test/, in integration/, e2e/, contract/, helpers/ or fixtures/; the struct check holds the layout. The checkride.config.json it writes names every check it set up, so each slot's status line ends in [config]. One entry fills no slot: typecheck-tests, a [custom] check that type-checks the tests outside src/.
The generated project is a pnpm project: its engines field and pnpm-workspace.yaml assume it. Under npm, yarn, or bun, create the package.json first and take the existing-repository path above.
--lang go or --lang rust scaffolds a project with no package.json, which mise runs. Pick a shape with --shape: module or workspace for Go (--module names the module, example.com/<name> by default), crate or workspace for Rust. Each writes a mise.toml that pins checkride as npm:checkride beside a node and every binary tool its checks run, and a checkride.config.json with one target, app, at the root, naming every slot, with requirePins: true, so a tool nothing pins fails its check instead of running whatever PATH holds. The struct check runs the pack's dialect rules from rules/go/ or rules/rust/. A check never fetches anything, so init prints the steps that need the network.
mise trust comes first because checkride's pin sets a tool option, allow_low_downloads = true, which mise won't load from a file it hasn't trusted. The daily command is then mise exec -- checkride, and the stanza and the gate spell it that way. A Python or Terraform repo, or a Go or Rust one you already have, adopts through init in place; Installing checkride shows the mise.toml pin to add first.
Once set up, daily usage is one command regardless of the tool's name, because init writes a check script alias.
That runs the full pipeline — types, lint, structure, dead code, tests, docs, links, spelling — cheapest checks first. Exit 0 and you are done. Each check that runs ends its status line on stderr with a tag that says why it runs: [config] when checkride.config.json names it, [detected: <file>] when detection picked its tool, [built-in] or [custom].
--only and --skip each take a list of tokens: a slot, a custom check, a target, or <target>:<slot>. A token that names nothing exits 2 rather than running less than you asked for. Run the full pnpm check once at the end to confirm green. Some failures have a one-shot fix — checkride fix runs every active adapter's fix command (oxlint --fix, markdownlint-cli2 --fix, …).
A new project's package.json gets all seven. In an existing repo, init adds only check, and leaves a check script you already have alone. Under npm, yarn or bun the same scripts run as npm run check, yarn run check or bun run check. A repo with no package.json has no scripts to alias: run mise exec -- checkride with the same flags, as the stanza says.
Every run writes machine-readable output to .check/. The debugging path is always the same four steps.
--digest writes .check/digest.md — a token-bounded Markdown excerpt of the failing slots (ten findings per slot, 8 kB overall, roughly two thousand tokens), each section linking the authoritative raw file. It truncates, never normalizes; a green run leaves no digest, so its presence always means "this run failed". A red run also prints it on stderr, below the report, so an agent sees what failed in the run's own output.
checkride is an agent harness: the goal of init is that a coding agent adopts the "exit 0 = done" rule on its own — and can't finish without it.
init writes the contract into two files. AGENTS.md gets a stanza (between <!-- checkride:begin --> / <!-- checkride:end --> markers) stating the rule, how to read .check/ on failure, the module-boundary conventions, and the tight-loop commands. CLAUDE.md gets an @AGENTS.md import, one line added to the end of yours if you already have one, because Claude Code reads CLAUDE.md in AGENTS.md's place once one exists. Claude Code, Codex, Cursor, and Amp load these at session start — the agent runs pnpm check because it read the instruction. That's guidance; the hook below is enforcement. Keep your own edits outside the markers — init rewrites the stanza in place. Not behind your back, though: the begin marker carries a hash of what checkride wrote, so a stanza you have since edited stops the run (exit 2, nothing written) instead of being overwritten. Pass --force to discard the edits and refresh.
checkride writes three agent hooks, for Claude Code and (when detected) Cursor. The stop-gate is the centerpiece: a generated script the harness fires when the agent tries to finish, which runs checkride gate and blocks the stop while the pipeline is red — feeding the message back so the agent keeps working until green. Beside it, dirty marks turns that actually edited (so a no-edit turn can skip the run), and protect denies edits to checkride.baseline.json and .check/ — "never add to the baseline to make a check pass" as enforcement, not advice. To add them to a repo you already set up:
Both commands are idempotent and take --harness <a,b>. agent-setup picks hooks with --hook <a,b> or skips them with --no-hook; hooks add takes the names as a list, as in hooks add gate,dirty. Under Claude Code the Stop entry points at the generated script:
The script runs checkride gate, which runs the repo's check with --strict --digest — <pm> run check under your detected package manager where there's a package.json, mise exec -- checkride where a root mise.toml pins checkride, and the checkride on PATH otherwise — and answers in the harness's own protocol — under Claude Code, exit 2 with the verdict on stderr is what blocks (a plain exit 1 would be treated as non-blocking); Cursor instead reads a JSON reply on stdout. Three verdicts: green (the turn may end), red (blocked, naming what to read), and "could not run" — nothing executed, so it names the environment cause (usually the hook's Node vs an engines pin) instead of pretending the code is red. The fail-closed --strict run also belongs in CI, which protects the branch.
The stanza and the hook both want pnpm check, so a naive setup runs the pipeline twice. The generated stanza already applies the simplest fix: when a stop gate is configured, the hook owns the final full run — the agent iterates with the narrow commands and lets the hook run the authoritative pipeline once at the end. Two levers tune the cost further: the dirty hook plus gate --if-dirty skip the run entirely on turns that edited nothing, and a gate key in checkride.config.json narrows what the stop hook runs (only, skip, changed) — every verdict then says so, so a narrowed green is never reported as a full one. And don't delete the AGENTS.md block to dodge the duplicate — it also teaches the agent how to fix what the hook flags, and it's what non-Claude agents have.
Going deeper — the bundled /checkride:check and /checkride:qa skills, where Claude Code and Cursor genuinely differ, and what to do when the gate says "could not run" — has its own page: the plugin & harnesses →
Adopting checkride on an existing repo shouldn't be a cleanup project. A baseline grandfathers the diagnostics a repo has today so day-one runs pass, while any new diagnostic still fails — "don't make it worse" as the definition of done for legacy code.
checkride.baseline.json lives at the repo root and is committed. It records per-slot fingerprints — a stable file:rule:message key that survives line moves — not raw output. Adopting from scratch? checkride init --baseline does this as part of setup instead of disabling failing slots.
baseline diff compares the working tree's checkride.baseline.json with the one committed at a ref (HEAD by default) and writes nothing. Run it before you commit a re-baseline: every added key is debt you chose to keep.
Never add to the baseline to make a check pass — fix the finding, or re-run checkride baseline deliberately and read the diff in the PR like code (it re-records everything currently failing, including brand-new debt). The file is canonical (sorted slots and keys), so parallel branches usually merge cleanly; when they conflict, keep both sides' entries and run a full checkride — the ratchet prunes anything already fixed, and a dropped entry that still fails simply resurfaces red.
When a merge goes worse than that — entries deleted wholesale, or the file mangled past parsing — the run says so: a stderr warning for an unparseable file, and a hint: line on a red run naming the commit the "new" findings were grandfathered until. checkride recover then rebuilds candidate states from the file's git history and restores one additively — a plain working-tree edit to review and commit, never a git checkout. Don't fix that with checkride baseline: a re-capture grandfathers brand-new debt along with the lost entries.
Only slots whose tool has a parser the baseline keys with participate: tsc for types, oxlint, eslint, biome, golangci-lint, staticcheck, clippy, shellcheck, ruff and tflint for lint, ast-grep for struct, markdownlint-cli2 and rumdl for docs, cspell and typos for spell, vale for prose, and fallow for dead, dupes and health. A tsc key leaves out what tsc fills into its message, the types and names it quotes, its counts and the properties a type lacks, which change on edits elsewhere, and a tsc run that a syntax error or a broken tsconfig stopped short masks nothing and prunes nothing. The errors of the other type checkers, each of which can stop before it reports every one, and tests are never grandfathered. A target's check keys under <target>:<slot>, as web:lint, and names each file from the repo root. A crash or empty output is never masked: a slot only goes green when there are findings and all of them are grandfathered.
One job, one command, exit 0 = done. Copy-paste for a pnpm repo on GitHub Actions — and always pass --strict in CI: a gate should never mistake "nothing ran" for "everything passed".
Replace the pnpm steps; the checkride invocation is identical.
A Go, Rust, Python or Terraform repo with no package.json pins checkride in its mise.toml, so CI installs it with mise. Checks never download, so fetch the language's dependencies first. MISE_TRUSTED_CONFIG_PATHS lets mise read the repo's own mise.toml without a mise trust step.
The security slot (pnpm audit) is pnpm-specific and reports itself unavailable under the other package managers; a go, rust or terraform target fills it with govulncheck, cargo-deny or trivy instead.
A pull-request reviewer, a CI job or an agent often runs checkride over a checkout it didn't write, with its own config, defaults, pins and output directory, and reads summary.json and the raw artifacts as evidence. The repo's own gate is unchanged by any of it.
None of it rewrites the repo. A run with --defaults, --pins, --discover or --static-only, or with a --config or --out outside the repo, never ratchets checkride.baseline.json, and the stop gate strips every one of these variables from the check it spawns, so a consumer's environment never reaches the repo's own verdict.
The first pass runs before the repo is installed or sandboxed and reads it as data, with your own tools. The second runs after the install, inside the sandbox, with the repo's own pins:
In the first pass, each check that might run the repo's code or need its install skips, and its reason says why: static-only: tsc isn't a static check, say. Each check that remains runs your pinned copy wherever your mise.toml names the tool, and records pin_source: "override". A pass that skips every check verified nothing, and --strict makes it exit 2. doctor takes the same flags and runs no check, so it previews what a pass would skip and which pinned versions mise hasn't installed.
summary.json says which checks failed, why each ran (origin), which copy ran (pin_source, tool_version) and exactly what it spawned (argv), and its run and git say what the run covered and which commit it read. checkride/findings turns each check's artifact into findings with a file, a line, a rule and whether it can fail the check, through the same parsers the baseline keys with; the raw artifact stays the authority. checkride baseline diff "$BASE_SHA" --json lists the keys a pull request adds to checkride.baseline.json: findings it hides instead of fixing.
checkride has no runtime dependency on any tool it runs. For a TypeScript slot it spawns <pm> exec <tool>, so the tools are ordinary project devDependencies, pinned and owned by your repository, and "installing a missing tool" means adding an npm package.
The table below is the ts and repo packs' tools. The bash, go, rust, python and terraform packs fill the same slots with their own — golangci-lint, clippy, ruff, tflint, shellcheck and the rest — each resolved through its pack's pin chain: a go.mod tool directive, rust-toolchain.toml, uv.lock, .terraform-version, then a mise.toml, then PATH. Packs and pins lists every pack's slots, tools and pin sources, and Languages sums them up.
A slot runs only when its detect file is present, or, for some tools, when package.json lists the package or a script (built-in checks always run), or when the config names its tool. Note the npm package names that differ from the binaries: ast-grep ships in @ast-grep/cli, stryker in @stryker-mutator/core, attw in @arethetypeswrong/cli, vale in @vvago/vale.
A Rust-native codebase-intelligence tool (unused code, duplication, cycles, complexity, architecture drift). checkride splits it across three slots sharing one fallow.toml: dead (default), and opt-in dupes and health. Uniquely, checkride owns the pass/fail decision here: fallow's JSON modes exit 0 even with findings, so checkride reads the report and gates on the issue count — and an unrecognized report fails loudly rather than passing silently. Needs fallow ≥ 3.5.
Four opt-in library slots gate the artifact a consumer actually installs, with zero new devDependencies: build (wave 10) runs your build so the rest inspect fresh output; then pack (the tarball ships the right files) and smoke (the built package imports cleanly, every export live) share wave 20 with publint and attw. snippets type-checks the tagged doc fences against your source in any wave, or, as snippets-dist, against the built .d.ts in wave 20. Enable with --include build,pack,smoke,snippets or by naming them in config — the bundle orders itself.
If the config exists but the package is gone (fresh clone, pruned node_modules), it's just pnpm install. A slot that's irrelevant to the repo is disabled in config — "spell": false — and doctor reports it as such instead of a failure.