Custom checks — amont.conf

A repository can declare checks of its own. Put a amont.conf at the repository root, commit it, and the hooks run it alongside the built-ins.

# stage       name        scope         severity  command
pre-commit    lint-shell  *.sh          block     scripts/lint-shell.sh
pre-commit    protos      *.proto,*.pb  block     buf lint
pre-push      smoke       *             warn      make smoke

Five whitespace-separated fields, in file order. Alignment is cosmetic; tabs and single spaces parse identically.

Why the file is committed

.git/hooks is not committed, so a hook script dropped in there can never actually be shared: every member of the team has to install it by hand, and nothing tells them when it changes. A committed manifest is reviewed like any other change, arrives with a git pull, and is visible in the fleet dashboard.

The fields

stage — pre-commit or pre-push.

name — the short name. Together with the stage it forms the check's id, <stage>-<name>, exactly as a built-in has: a line reading pre-commit lint-shell … declares pre-commit-lint-shell.

That id is what amont list and the dashboard show, and either the id or the short name addresses it in hook.skip and in a severity override — the same three-way vocabulary the built-ins take:

git config hook.skip pre-commit-lint-shell   # that check
git config hook.skip lint-shell              # that check, on either stage
git config hook.skip pre-commit              # every pre-commit check, declared ones included

The same name on both stages is two checks, and that is allowed: show-unicorn on pre-commit and on pre-push gives you pre-commit-show-unicorn and pre-push-show-unicorn, each separately skippable and separately downgradable. See What a repository cannot do for the limits.

scope — * for every change, or a comma-separated list mixing *.<ext> extensions and bare filenames: *.ts,package.json,.prettierrc. A bare token matches the path's basename exactly, anywhere in the tree — an extension list cannot say package.json without also matching not-package.json, which is why this is its own kind of token.

A directory is a token of the form dir/**/*.ext: a path is in scope when it sits under dir/ and ends with .ext. claude-plugin/**/*.md covers claude-plugin/skills/x/SKILL.md and never claude-plugin-old/SKILL.md. The directory is whole segments, with no glob characters, no empty segment and no . or ..; the extension is a plain suffix. Anything else containing a / or a glob (claude-*/**/*.md, claude-plugin/*.md, x/**/*) is refused as a parse error. Evaluated against the files staged for a commit, or against the range being pushed. This gate is real: a *.sh check does not run on a commit that touches no shell.

A + adds the second half — what the repository must carry:

pre-commit  rubocop  *.rb+.rubocop.yml  block  rubocop

"a staged .rb, and this repository has a .rubocop.yml." Both sides are comma-separated and each is an OR — any trigger, any one of the opt-ins — while the two halves are an AND. With nothing on the left, +Gemfile reads "any change, in a repository that carries a Gemfile".

The separator is the one amont list already prints between the halves:

○ rubocop (declared)   inert here — needs .rb + .rubocop.yml

What you read in the listing is what you write in the file.

Why it exists. Every builtin has this: clippy stays inert without a Cargo.toml, yamllint without a .yamllint.yaml. Declarations did not, because the manifest was the opt-in — you typed the line, so you wanted the check. amont add ended that: a vendored line is in your file because you took a whole pack, and without a condition a packaged rubocop fires on every .rb in a repository that never wanted it and just errors.

A + that names no file (*.rb+) is refused rather than read as "no condition" — that is the opposite of what it was reaching for. A filename containing + is not expressible.

severity — block fails the stage; warn runs the check, prints whatever it prints, and lets the commit through. It is your choice, per check.

command — the rest of the line, split on whitespace and executed directly from the repository root, through the same program resolution the built-ins use — so npx and friends work on Windows, where a bare Command::new cannot start a .cmd. On pre-push it runs once per pushed ref, over that ref's changed files, and — with amont.testPushedTree — in a throwaway checkout of that ref's tip rather than your working tree, exactly as the built-in test suites do.

Your command gets the file list

Two ways, both carrying exactly the paths the scope matched — the same set the gate above judged, which a wrapper re-running git diff --cached can diverge from (amont run --all-files overrides the set in-process):

  • $AMONT_FILES, always: the matched paths, newline-separated, relative to the repository root. Empty when the set would not fit in an environment variable (very large change sets) — treat empty as "derive it yourself".
  • the files marker, opt-in: prefix the command with files and the matched paths are appended to the argv, the way a built-in hands its tool the staged list:
pre-commit  lint-shell  *.sh  block  files scripts/lint-shell.sh --strict

With files, a commit whose matched set is empty does not run the command at all — most linters error on an empty argv, and a commit must not be blocked over nothing.

  • the docs-skip marker, opt-in, pre-commit only: prefix the command with docs-skip and the check does not run when the staged change only edits existing documentation. Documentation is a .md, .mdx, .rst or .adoc file, outside adr/, that is a modification: an added, deleted or renamed file, a code file, a .txt (a requirements.txt is a dependency manifest) or a decision record always runs the check. The skip is printed as skipped — the commit only edits existing documentation. A docs-skip on a pre-push line is a parse error.

    This is a trade, and the adr line in amont's own amont.conf takes it: a documentation edit that adds a link to a file that does not exist will pass commit time. A skipped check is not recorded as having passed: it earns no commit-time stamp, so a same-named pre-push declaration still runs and an attestation never tells CI the gate is covered. CI and the push-side checks see it.

There is no shell

No pipes, no redirection, no globbing, no quoting. make smoke works; find . | xargs foo does not — put that in a script and invoke the script.

Two reasons, and the second decided it. Windows has no sh, and every emulation of one this project has tried has been a source of bugs. And a manifest line that silently gained shell semantics would be a much larger thing to have introduced than it looks.

Exit codes

0 passes. Anything else fails, and whether that stops you depends on the severity column.

A command that cannot be started at all — a typo'd path, a tool nobody installed — is neither. It is reported as a gap:

⚠ amont.conf: lint-shell could not run scripts/lint-shel.sh — No such file
⚠ 1 check(s) could not run: lint-shell

It does not block, because a command that never ran has not judged anything; reporting it as a lint failure sends someone hunting for an error that does not exist. But it is never silent, because a check that has quietly never executed is the one failure this whole design is arranged against.

A line nobody can parse is not skipped

The same rule applies to the manifest itself. A malformed line still produces a check — one that reports on every commit and says which line and what was wrong:

⚠ amont.conf: oops — line 3: severity "LOUD" must be `block` or `warn`

Silently ignoring it would mean a check somebody committed months ago has never run once and nothing ever said so.

Repo policy — severity, skip, and set lines

The manifest can also carry the TEAM's decisions about the built-ins, so "clippy is warn-only here" is a committed, reviewed line instead of sixty people running the same git config incantation:

severity  clippy          warn     # runs, reports, does not block — here
severity  pre-push-pytest block    # and this one always blocks
skip      yamllint                 # never runs in this repository

Targets use the same three-way naming as hook.skip — full id, short name, or a whole trigger — and a target that names no check here is reported once per run, with its line number, rather than silently doing nothing.

Trust-gated, like everything else this file says. Untrusted policy is inert and announced (amont.conf policy not applied: …) — a repository you cloned to read cannot weaken your safety net until you consent, and the trust prompt shows the policy lines you are consenting to.

Precedence is a specificity ladder, per key: built-in default < system config < global config < policy < local config < worktree < command (git -c). Your git config --global preferences yield to the team's committed decision; your LOCAL config in that repository still beats it — the developer owns their machine, and every documented escape hatch keeps working. Between DIFFERENT keys naming the same check, key specificity decides regardless of source: a policy severity pre-commit-clippy … outranks a local amont.severity.pre-commit, and vice versa a local full id outranks a policy trigger. Skips are a union of all sources — nothing can un-skip, so there is no conflict to order.

(On a git too old for --show-scope, this degrades fail-safe: ALL git config beats policy. See configuration.)

set — committed thresholds and commit style

The same file can carry the numeric and style knobs the checks consult:

set  largeFileWarn     1      # warn above 1 MB, in this repository
set  commit.subjectMax 50     # the whole team's subject budget

set <key> <value> takes the key exactly as you would write it after git config amont. — matched case-insensitively, values parsed by git itself, so set largeFileBlock 2k means what git config would mean by it and a bad value complains the same way. The same ladder applies: a policy value beats your global config, and your local config in that repository still beats the policy.

Only these keys are settable: largeFileWarn, largeFileBlock, commit.gitmoji, commit.subjectMax, commit.descriptionMax, commit.bodyWrap, autoRebase, timeout, lockWait, testPushedTree, snapshotPrepare, snapshotDeps, snapshotCarry, order, treeLint, treeLintSlack, treeLintWait, treeLintRehearsalTimeout, snapshotPrepareOutputs, and minVersion — the last being the team's version floor: a binary older than set minVersion 1.11.0 says so once per stage, warn-only, instead of silently lacking the checks the team added since.

The three snapshot* keys say how a checkout of the commit becomes runnable, which the repository knows and every clone would otherwise have to discover by hand: snapshotDeps how dependencies arrive (install, reuse, off), snapshotCarry which untracked files are copied in, and snapshotPrepare anything else. snapshotPrepare is the one whose value is a whole command. It runs in the snapshot, through the shell, before any suite, and consent covers it exactly as it covers a declared check's command: policy binds only on a trusted manifest, the trust prompt prints the command, and editing the line revokes the trust. See configuration.

A set value runs to the end of the line, so a command with spaces and flags is written plainly:

set snapshotPrepare pnpm prisma generate
set snapshotCarry .env

Any other key is refused with its line number — most deliberately amont.fix, because a committed file must not change what already-trusted commands may DO to your working tree (see below). One caveat worth reading before committing it: set timeout accepts the full configured range, including 86400 and 0 (which disables the per-check deadline entirely) — a review of that line is a review of how long a hook may hang everyone.

What a repository cannot do

Take a built-in's id. pre-push branch-protect … is refused: it would either shadow pre-push-branch-protect or silently lose to it, and a text file should not be able to do either. The same name on the other stage is fine — pre-commit branch-protect … is a different check and shadows nothing.

Write the stage into the name. pre-commit pre-commit-clippy … is refused. It would declare a check whose short name is another check's full id, so a single hook.skip pre-commit-clippy would silence both and no rule could pick between them. So is a name that simply is a stage. The stage column already says which one this is.

Declare the same id twice. The second is refused: it could not be addressed by hook.skip or by a severity override, so it would run anonymously. Two lines with the same name on different stages are two ids, and both run.

Grant its own trust, or reach the machine-level knobs. Policy stops at severities, skips, and the allowlisted set keys: amont.fix (rewriting your working tree), the trust decision itself, amont.conventions, and the observability opt-outs stay per-machine — a committed file must not change what already-approved commands are permitted to DO, which is a different consent than "I read these commands".

Run before the built-ins. Externals are appended to each stage, always. A third-party command must not be able to delay pre-push-branch-protect, and appending is the only arrangement in which it cannot. On pre-push, which stops at the first blocking failure, that means a built-in failure means your check does not get a turn.

A manifest is inert until you trust it

A repository you cloned can declare checks, and running them is a decision you make — not one git clone makes for you. So nothing in amont.conf runs until somebody accepts it here:

amont trust            # shows what it declares, then records it
amont trust --show     # what it declares, and whether it is trusted
amont trust --revoke

amont install asks once, with the declarations in view. Declining still installs the built-ins.

Until then the checks are reported, not dropped — the point is that you can see there is a decision waiting:

⚠ amont.conf: lint-shell — declared in an untrusted amont.conf …
⚠ 1 check(s) could not run: lint-shell

Acceptance is recorded against the file's CONTENT (git hash-object, which you can run yourself), so a git pull that adds a command does not inherit the trust you gave the file before it. When that happens the message says so — changed since it was trusted — because "somebody edited this" is a different thing to be told than "you have not looked at this yet".

This is a floor, not a ceiling. A built-in check still runs your repository's own toolchain: prettier and eslint are taken from node_modules/.bin when present, so a hostile node_modules needs no manifest at all. That is the same exposure npm install already carries.

Pinning tool versions

The checks run whatever prettier, ruff or shellcheck this machine has — and when two machines disagree, the hook "passes here, fails in CI", which reads as flakiness and trains people toward --no-verify. A tool line turns that skew into a printed fact:

# tool  <program>  <version-substring>
tool  ruff  0.6.
tool  shellcheck  0.10

Once per hook run (both stages), each pinned tool's --version first line is checked for the substring; a mismatch or an unrunnable tool warns, naming both sides. Warn-only, always — skew never blocks a commit, because the fix is a human decision about which side to move.

A substring, not a semver range: 0.6. pins a minor, 0.6.3 a patch, and the point is agreement between machines, not range arithmetic. Pins are trust-gated like every declaration — verifying one executes <program> --version for a name the repository chose, which is exactly the consent amont trust collects.

Whole-tree gates — tree lines

A tree line declares a whole-tree check whose pass may be attested, so CI skips the step that runs the same command (ADR-0024 in the fleet's decisions, ci.attested-skip):

# tree  name         tool     scope  attest  [options]           command
tree    eslint       eslint   *      attest                      npm run lint -- {cache}
tree    ruff-check   ruff     *      attest                      uvx ruff@0.16.0 check packages
tree    ruff-format  ruff     *      attest                      uvx ruff@0.16.0 format --check packages
tree    pyright      pyright  *      attest  cwd=services/api    uv run pyright

A tree gate never decides a commit. It is not a pre-commit or pre-push check, so it has no severity column: attest stands where the severity would be, and says what the line is for. The commit is judged by the checks it always was.

  • name — the gate's token in the attestation note is tree-<name>, which is what CI's if: names. It matches [A-Za-z0-9][A-Za-z0-9._-]* (59 characters at most) and, like every name here, must not start with a trigger.

  • tool — one of eslint, prettier, ruff, pyright, gofmt. It is declared, never guessed from the command: npm run lint does not say what it runs, and amont needs to know what {cache} expands to and which version the gate really executes.

  • scope — the same column as a check's.

  • options — lowercase key=value tokens directly after attest:

    • cwd=<dir>: the directory the command runs in, relative to the repository root;
    • inputs=<path>,...: extra paths that belong to the gate's cache namespace.

    Paths are literal. A glob, an absolute path or a .. is refused. An uppercase NAME=value is not an option; it begins the command, so NODE_OPTIONS=… npm run lint is still expressible.

  • command — the exact command the CI step runs. {cache} is the only thing amont may add, and it is allowed once, for eslint and prettier only. It expands to the tool's cache flags, which never change a verdict.

The command CI must run is the normalized declaration: {cache} removed, then a dangling trailing --, then whitespace collapsed. npm run lint -- {cache} becomes npm run lint. See the CI backstop for how the two are kept identical.

A tree line is trust-gated like every other line: until you trust the manifest, no tree gate runs. A pack may not carry one.

When a tree gate runs, and what it proves

At commit, a tree gate starts beside the pre-commit checks. It gets at most amont.treeLintSlack seconds (default 2) after those checks finish. It starts only when its cache is warm: a full run already completed in the gate's current namespace. The namespace is a hash of:

  • the command;
  • the version of the tool it runs;
  • every lockfile and config-like file (*.json, *.toml, *.yaml, *config*, .*rc*, .*ignore, by basename);
  • the gate's inputs=.

A plugin upgrade therefore starts a new, cold namespace, and the old cache is deleted. A warm gate also has to fit. amont remembers how long each gate's last run took, and how long the repository's own declared commit checks took. When nothing long is in scope, as on a docs-only commit where no test run covers it, a gate that would outlast the slack is skipped, not started and cancelled: the commit stays fast, and CI lints. It shows up as slow in the evidence. A cold gate does not slow the commit either: amont starts amont warm --worker in the background (log: .git/amont-warm.log), and the next commit can prove it. amont warm does the same in the foreground. There is no background warm-up on Windows, where tree gates do not run at all.

When it passes on exactly the committed tree, the commit and its tree are stamped tree:<name>. The push then attests tree-<name>, and CI skips its step. No gate starts, and nothing is stamped, when:

  • tracked files have unstaged edits;
  • an untracked file is present;
  • an ignored file is present outside snapshotPrepareOutputs, which admits tool caches (node_modules/, .venv/, __pycache__/, …) by default. Add the repository's own reproducible outputs, the ones CI recreates too, with set snapshotPrepareOutputs build/ .react-router/;
  • a merge, rebase, cherry-pick or revert is in progress.

A failing, slow or cancelled gate never blocks: it prints one line, and CI lints.

Letting a check fix what it finds

Prefix the command with fix and the check may rewrite files, with whatever it changed re-staged:

pre-commit  format  *.js  block  fix npx prettier --write

Two conditions, both deliberate:

  • Off unless you ask. git config amont.fix true, per repository — and the consequence is worth stating in bold: with amont.fix off, a fix-declared check does not run at all, not even in check-only mode. It reports "not run" (a warn, never a block) on every commit, because the one command you declared is a rewriting command and running it would edit files nobody asked to have edited. A team that commits a fix declaration gets zero enforcement from every member who has not personally opted in — if you need the check to always judge, declare a second, non-fix line with the tool's check mode. A hook that edits your files without being asked is a larger surprise than one that complains.
  • pre-commit only. fix on a pre-push line is a parse error, reported on every commit like any other bad line. A pre-push hook must not modify the worktree or index: the pushed commit would then differ from the tree you are looking at.

Re-staging is safe because the pre-commit stage holds your unstaged changes aside first, so the tree contains what you staged and nothing else — anything a formatter touches is by definition part of this commit. Work you deliberately kept back is never swept in.

The built-in prettier check does this too, under the same amont.fix gate.

Turning one off

Exactly as for a built-in:

git config hook.skip lint-shell                 # do not run it
git config amont.severity.lint-shell warn    # run it, do not let it block

Both surfaces read the same three names, and nothing matches by substring: the full id (pre-commit-lint-shell), the short name (lint-shell, on either trigger), and the trigger (pre-commit, meaning all of them). Three exact comparisons, in runtime::names_check — hook.skip = e reaches nothing at all, and skipping lint-js leaves lint-json-yaml alone.

Prefer the severity downgrade anyway. The two do different things:

runsreportsblocks
amont.severity.<key> warnyesyesno
hook.skip <key>nono (only that it was skipped)no

A downgrade keeps the check working and keeps you looking at what it finds; you have decided the finding should not stop a commit, not that you no longer want to know. A skip removes the signal along with the block, so the problem it was watching for grows silently until somebody turns it back on. Reach for skip when a check is genuinely inapplicable to a repository, and for severity when it applies but should not be a gate.

Seeing what you declared

amont list
pre-commit
  ● merge-conflict
  ● lint-shell (declared)
  ✗ oops (declared)            amont.conf line 3: severity "LOUD" …
pre-push
  ● branch-protect
  ● smoke (declared)

  ● runs here   ○ inert   ⊘ skipped via hook.skip   ✗ declaration unusable

Across the fleet, amont-fleet has a DECL column — 2 for two declared checks, 2!1 when one of them cannot run — and lists them per repository in the detail pane.

Shipping a check — packs

A check you wrote could always be run; until amont add it could not be shared, except by pasting a line into somebody's manifest or upstreaming it into amont itself.

A pack is any git repository with an amont.pack at its root, written in exactly the syntax above:

# amont-pack-java — Spotless for a JVM repository, Maven or Gradle
pre-commit  spotless-maven   *.java+pom.xml                        block  mvn -q spotless:check
pre-commit  spotless-gradle  *.java+build.gradle,build.gradle.kts  block  ./gradlew -q spotlessCheck
$ amont add github:fredericrous/amont-pack-java@v1
github:fredericrous/amont-pack-java @ 2f5dbd9 declares:
    pre-commit  spotless-maven   *.java+pom.xml                        block  mvn -q spotless:check
    pre-commit  spotless-gradle  *.java+build.gradle,build.gradle.kts  block  ./gradlew -q spotlessCheck

amont.conf changed — these commands cannot run until you review them:
    amont trust

<source> is github:owner/repo, forgejo:host/owner/repo, or any git URL — including a local path, which is all a test fixture needs. --dry-run shows without writing.

That pack is real, and it is the worked example for everything below: fredericrous/amont-pack-java. Its README is the long-form version of this section.

What ships is text, not execution

This is the whole design, and it is what separates it from the ecosystem model pre-commit built. pre-commit clones a repository and executes it, building an isolated environment per hook. amont add copies rows into your amont.conf, and stops:

  • the rows land between # amont:pack:start / # amont:pack:end markers with the pack's commit id, so what you got is recorded next to what you run;
  • the manifest's content changed, so its fingerprint no longer matches and every declared check — the pack's and your own — is inert until you trust it;
  • editing a vendored row by hand revokes consent exactly like editing any other line. A pack's rows are not privileged.

Adding a pack is therefore never the moment anything becomes runnable. It is a way to avoid typing.

Pinning, and what "verify" means here

@v2 is a tag, and a tag moves. amont add resolves it with git ls-remote before fetching, then refuses whatever arrives unless it is that commit — so a ref that moves between the two steps is an error, not a surprise. The commit id, never the tag, is what gets written into your manifest.

That is also why the transport is git and not HTTP. amont links no crates and has no TLS stack; git is already a hard dependency, it is content-addressed, and it brings SSH, private repositories and self-hosted forges with it for free.

Making a packaged check well-behaved

A pack's rows land in somebody else's repository, so gate them on that repository actually using the tool:

pre-commit  rubocop        *.rb+.rubocop.yml  block  rubocop
pre-commit  terraform-fmt  *.tf               block  terraform fmt -check

The first is inert in a Ruby repository with no rubocop config; the second needs no opt-in because a .tf file in the diff already says everything. Without that condition a pack is a promise to run somebody's linter on every matching file whether or not they configured it — which is how a useful pack becomes an uninstalled one.

What a pack may not carry

Checks, and nothing else. tool pins, severity, skip and set lines are refused, and the whole pack with them.

Those lines are policy about the repository installing the pack — see What a repository cannot do. A skip could silence your secrets scan; a set could raise your large-file ceiling. A third party proposing commands you will read is one thing; a third party quietly changing what your existing checks do is another, and the second is not on offer.

A pack is refused whole: one bad row and nothing is written, because a half-applied pack is a manifest neither side asked for.

Publishing one

A pack repository is the amont.pack and nothing else — no schema to satisfy, no registry to join, no build step. The example above is three files, and two of them are the README and the licence:

amont-pack-java/
├── amont.pack
├── README.md
└── LICENSE

Test it before it goes anywhere. A local path is a source, so the whole loop is:

amont add ../my-pack --dry-run

Then tag it, and let people pin the tag:

git tag v1.0.0 && git push origin v1.0.0
git tag -f v1 && git push -f origin v1     # moving major alias, optional

A moving alias is safe here in a way it is not for a CI action, because what lands in a consumer's manifest is the commit id and never the tag: @v1 resolves once, at install time, and then stops moving. The cost of that is that a consumer does not get your fixes by pulling — they get them by running amont add again, which is also a fresh amont trust. That is the trade the whole design makes, and it is the right way round: nothing you publish later can start running on somebody's machine without them reading it first.

Updating and removing

amont add the same source again and its block is replaced — not appended, which would declare the same id twice and break the manifest. To remove a pack, delete its block; it is plain text, and the markers say where it ends.

Why this format and not TOML

TOML would be nicer to write and costs a dependency tree that would then run on every commit in ninety-six repositories. For four fields and a command, twenty lines of std parsing wins. See scripts/check-no-deps.sh for the reasoning behind that default; it is a judgement about the commit path's supply chain, not a prohibition, and a genuinely rich format would be worth reopening it for.