@leondixon/agent-standards
v0.3.0
Published
Portable coding standards: one rule source for AGENTS.md, lint configs, and Claude Code hooks
Maintainers
Readme
agent-standards
Portable coding standards with one source of truth per rule. Prose (in
AGENTS.md), lint rules (ESLint and Oxlint for TypeScript; Cargo lint levels,
Clippy and ast-grep for Rust), and Claude Code hooks are all generated from the
same directory, so they cannot drift apart.
npx @leondixon/agent-standards initThat scaffolds the project and adds itself as a pinned devDependency, so later syncs are reproducible:
npx standards sync # apply rule updates after a version bump
npx standards check # CI — exits non-zero when out of dateWire the generated ESLint config into your own:
// eslint.config.js
import { standardsConfig } from './.standards/eslint.config.js'
export default standardsConfigOxlint gets the same rules. Own implementations load through Oxlint's JS plugin
API; mapped ESLint rules become native Oxlint rules (ts/… → typescript/…).
The generated file only enables standards rules — it does not change Oxlint's
default categories:
// .oxlintrc.json
{
"extends": ["./.standards/.oxlintrc.json"]
}Commands
| Command | Does |
|---|---|
| init [dir] | Detect languages and stack, infer a layer map, install everything |
| sync [dir] | Add missing rules, update stale ones, prompt on local edits |
| check [dir] | Report drift without writing; exits 1 when out of date (CI) |
| resolve <rule> | Mark a conflict merged after editing the file |
| list | Show every rule in the source |
| build | Regenerate the ESLint plugin after adding or changing rules |
How a rule is stored
One directory per standard. See docs/rule-schema.md.
standards/typescript/base/no-type-assertions/
├─ rule.md prose + frontmatter — the source of truth
├─ rule.js optional ESLint / Oxlint implementation
├─ hook.sh optional agent-time nudge
├─ rule.yml optional ast-grep rule (Rust)
└─ __fixtures__/ valid/ + invalid/From that, sync generates:
| Artefact | Where |
|---|---|
| Every rule's full text, one managed block per rule, and how to resolve sync conflicts | AGENTS.md |
| Hooks, as managed entries alongside your own settings | .claude/settings.json |
| Flat ESLint and Oxlint configs | .standards/ |
Sync adds its blocks after whatever the project already has in AGENTS.md, and
never touches text outside them. Each rule block is tracked on its own, so editing
one rule to fit the project pins that rule only.
Claude Code reads AGENTS.md only when the project has no CLAUDE.md. If yours
has one, add a line with @AGENTS.md to it; sync warns until you do.
In .claude/settings.json, sync owns only the hooks that run a script from
.standards/hooks/; permissions, env and your own hooks are left alone.
For Rust it also writes a managed lint block in Cargo.toml, a clippy.toml,
ast-grep rules, and a quality gate that runs when the agent stops — see
Rust.
Layers, not paths
A rule declares what kind of code it governs — any, backend, frontend,
schema, test — never a path. Each project maps those layers to its own globs in
.standards/config.json, so the same rule works in a single-package repo and a
monorepo. A project with no backend layer never installs backend rules.
Languages
init detects every language present and stores them in .standards/config.json.
A project can have more than one.
| Marker file | Language |
|---|---|
| Cargo.toml | rust |
| package.json | typescript |
Rules are selected in three passes — language, then preset, then layer:
standards/core/** always installed, whatever the languages
standards/<language>/** every detected language's treeA Rust project installs standards/core/ plus standards/rust/. A repo with both
Cargo.toml and package.json installs core, rust and typescript — each
tree keeps its own rules, and layer globs are the union, so TypeScript rules match
.ts files and Rust rules match .rs files. An id both trees define, such as
no-banner-comments, gets one block per language, titled with the language.
In a polyglot project the shared principles carry both idioms:
# Prefer get over find for queries
## In Rust
Applies to functions and module filenames (`get_searches.rs`, …)
## In TypeScript
Applies to functions and module filenames (`get-searches.ts`, …)Single-language projects skip those headings. Edit the languages array in
.standards/config.json to override the detection.
Current coverage
| Language | Core | Own rules |
|---|---|---|
| TypeScript | 8 | 38 across 10 presets |
| Rust | 8 | 12 across 2 presets (base, bevy) |
A Rust project gets the 8 cross-language principles with Rust examples, plus:
| Rule | Enforced by |
|---|---|
| strict-clippy | Clippy pedantic at warn, priority -1 |
| no-lint-suppression | allow_attributes, allow_attributes_without_reason — exceptions are #[expect(lint, reason = "…")] |
| error-handling | unwrap_used, expect_used, panic, map_err_ignore, let_underscore_must_use; allowed in tests via clippy.toml |
| no-debug-leftovers | dbg_macro, todo, unimplemented; print_stdout, print_stderr at warn |
| no-unsafe-code | rustc unsafe_code forbid |
| no-deprecated-apis | rustc deprecated deny |
| no-lossy-casts | the cast_* lints, except cast_precision_loss |
| no-banner-comments | ast-grep |
| private-submodules | ast-grep: pub mod only in src/lib.rs |
| bevy-system-params | allows needless_pass_by_value, too_many_arguments, type_complexity |
| bevy-fallible-systems | bevy_lint panicking_methods deny |
| bevy-lints | bevy_lint pedantic and missing_reflect at warn |
TypeScript rules deliberately not translated to Rust:
| Rule | Why not |
|---|---|
| no-null | Rust has no null; Option already is the single absent value |
| no-single-property-params | Rust has no object-param idiom to police |
| no-handler-response-type, no-to-response-helper | Backend handler rules; there is no Rust web preset (such as axum) yet |
| no-result-type | Folded into error-handling: use Result/Option, never an { ok, error } struct |
| no-type-assertions | Becomes no-lossy-casts, the Rust cast that silently changes a value |
| no-cross-module-deep-import | Becomes private-submodules |
Cross-language principles
Rules under standards/core/ are language-neutral: the principle is written once,
with per-language expression files supplying the examples.
standards/core/prefer-get-over-find/
├─ rule.md the principle
└─ languages/
├─ typescript.md getAccounts, get-accounts.ts
└─ rust.md get_accounts, get_accounts.rsSync appends the expression for each of the project's languages, so the principle
reads in the idiom of the language it lands in. Editing rule.md updates every
language at once; a missing expression file falls back to the principle alone.
Rust
A Rust rule enforces itself in one of three ways, chosen by the cheapest tool that can see the problem:
| Output | Lands in | Use for |
|---|---|---|
| cargo-lints | managed block in Cargo.toml | rustc, Clippy and bevy_lint lint levels |
| clippy-config | clippy.toml | Clippy settings such as allow-unwrap-in-tests |
| ast-grep | .standards/ast-grep/rules/ | syntactic checks no lint covers |
Lint levels go in Cargo.toml rather than rustflags: rustflags collide with
Bevy's .cargo/config.toml and rebuild every dependency whenever they change.
Sync owns only the block between these markers and leaves the rest of the
manifest alone:
# >>> standards (managed by `standards sync`)
…
# <<< standardsIn a workspace, the block sets [workspace.lints.*]; each member crate opts in
with [lints] workspace = true. Sync refuses to write a table the manifest
already defines — move a hand-written [lints.clippy] into a rule instead.
When checks run
After each edit —
.standards/hooks/ast-grep.shscans the edited.rsfile with the ast-grep rules only. It is fast enough to run on every edit.When Claude stops —
.standards/hooks/rust-gate.sh, registered as aStophook, runs in order and stops at the first failure:cargo fmt --all --checkcargo clippy --workspace --all-targets -- -D warningsast-grep scan -c .standards/sgconfig.ymlbevy lint, with thebevypreset; any warning failscargo nextest run --workspacecargo machetetypos
The gate skips when no
.rsfile orCargo.tomlhas changed. A failure exits 2 with the output on stderr, which sends Claude back to work on it. Claude Code ends the turn anyway after eight blocks in a row.
The gate treats a missing tool as a failure and prints how to install it:
rustup component add rustfmt clippy
cargo install ast-grep cargo-nextest cargo-machete typos-cli --locked
# bevy preset only
cargo install --git https://github.com/TheBevyFlock/bevy_cli --tag cli-v0.1.0-alpha.2 --locked bevy_cli
bevy lint install v0.6.0Adding a language
mkdir -p standards/<language>/base- Add
standards/<language>/presets.jsonwith at least{"base": {"always": true}} - Add a
languages/<language>.mdexpression to each rule instandards/core/ - Extend
LANGUAGE_LAYERSinpackages/cli/src/lib/layers.jswith its layer globs - Extend
LANGUAGE_MARKERSinpackages/cli/src/lib/detect.jswith its marker file - Teach
detectDependenciesinpackages/cli/src/lib/detect.jsto read its manifest, so presets can be detected (Rust readsCargo.toml) - Give its rules an enforcement output. Prefer the language's own lint
configuration, the way Rust uses
cargo-lints; add a region-managed artefact when that configuration lives in a file the project also edits by hand
Only step 1 and 2 are required to make the language selectable; the rest improve what it installs.
Drift and conflicts
Every file sync writes is recorded in .standards/lock.json as two hashes — the
file as it stands, and the upstream text it was reconciled against:
".standards/eslint.config.js": {
"local": "498e953d51112a69",
"source": "e7ab21768adbd20e"
}A managed part of a larger file is tracked on its own, keyed as
AGENTS.md#no-null, Cargo.toml#standards or .claude/settings.json#standards:
only that part is hashed, and a conflict carries only that part.
Those two are what make each state distinguishable, per rule. Bumping a version only touches rules whose content actually changed; the rest stay silent.
| Your file | Upstream | Result | |---|---|---| | untouched | changed | stale — applied silently | | edited | unchanged | pinned — left alone | | edited | changed | conflict — needs a merge |
Anything sync no longer generates — a retired rule's block, or the .cursor/ and
.claude/rules/ files of earlier versions — is removed if it still matches the
lock. An edited one is left in place and sync says so.
Resolving a conflict
Sync writes every conflict to .standards/conflicts.json and exits 2:
1 conflict(s) — these rules changed upstream and locally:
! no-null
Ask your agent to resolve the standards conflicts — AGENTS.md explains how —
or merge by hand and run standards resolve <rule>.Each conflict carries mine, theirs, and base — the upstream text you last
reconciled against — so the merge is genuinely three-way rather than a guess. The
procedure is written into AGENTS.md, so any agent can follow it without being
told.
After merging, standards resolve <rule> records the merged file against the
upstream text it was merged with. That rule then stays quiet until it changes
again — at which point it conflicts once more, showing only the new change.
Presets
base is always installed. The rest are detected from dependencies and can be
opted into early:
TypeScript: prisma · hono · react · react-query · react-hook-form ·
tailwind · testing · zod · next
Rust: bevy
TypeScript presets are detected from package.json; Rust presets from the
[dependencies], [dev-dependencies] and [workspace.dependencies] of the root
Cargo.toml.
Presets are scoped to a language, so hono only ever applies to TypeScript files.
In a polyglot project each preset is labelled with the language that supplies it.
Publishing
See docs/publishing.md — npm login, the pre-publish check, version semantics, and CI tokens.
Tests
npx vitest runEvery ESLint rule ships RuleTester fixtures, and every ast-grep rule's fixtures run
through ast-grep scan. Drift-state transitions are covered
directly, since that logic decides whether someone's local edit survives a sync.
