Checkpoints for coding agents. aether makes an agent stop at the four moments a careful colleague would — before implementing, while editing, before committing, before pushing — and gives each one a command that does the checking.
| The problem · Does it hold up · Quick start | why, whether to believe it, and getting it running |
| How it works · Configuration · Trust | the part you touch daily |
Monorepos · aether check |
one declarative place a repo says how to test itself |
| Commands · The development workflow | what to run, and when |
| CLI reference · Bypass · Roadmap | reference |
Claude Code will write code, commit it, and open a PR without stopping to check anything — and it is very good at making that look finished. The failure mode is not bad code. It is that nothing interrupts at the points where a human would.
You ask for rate limiting on an API. Same request, both columns:
| without aether | with aether | |
|---|---|---|
| plan | — | /critique-plan → 🔴 an in-process counter does nothing behind a load balancer |
| edit | sed -i s/RateLimit/Limiter/ *.py — misses the re-export in api/__init__.py |
bonsai: use pyrename, sed misses re-exports |
| commit | "add rate limiting" |
/critique-diff → 🔴 the limit is never released when the handler raises |
| push | pushed, unreviewed | the review you skipped, named — in full, and on its own |
| PR body | "add rate limiting" |
/draft-pr writes it from the diff, so it names the Redis dependency you added |
Left column: you find out in review, three days later, or in production. Right column: at the point where fixing it is a sentence, not a revert.
Each checkpoint is a slash command you can run yourself, plus a hook that reminds you when you forget.
Everything the hook does is advice. It prints; the command then runs. Pushing
unreviewed code and committing to a path you marked critical are the two verdicts that
print in full and alone — nothing else shares the output with them — but they do not
stop the command, and # aether:skip silences the rest for one call. A PreToolUse
hook can only stop a tool call by exiting 2, and aether never does: one corrupt gate
file would otherwise lock you out of every command you type.
Unless you turn strict mode on, in which case those two verdicts become a permission prompt — and the agent cannot answer it, you do. See Strict mode and Bypass for what that is and is not worth.
A tool that tells you to check your work should be checkable. 710 assertions across
10 files, plus an acceptance layer that covers what a unit test cannot: nine hostile
hook payloads where none may exit 2 or write to stderr, --dry-run leaving $HOME
byte-identical, four consecutive installs producing identical state, and an upgrade from
the previous release with no dangling hooks. The hook's cost is measured against that
release rather than an absolute budget, so a loaded machine cannot raise a false alarm.
It runs on itself. This repo's own .aether/config extends
critical_paths with hooks/|bin/aether, because a two-line change there has a blast
radius unrelated to its size — which is the argument the tool makes, applied to the tool.
Tests has the detail.
| Plugin | Stage | What it does |
|---|---|---|
| whetstone | Plan | Gates commits when a plan exists but hasn't been critiqued with /critique-plan |
| bonsai | Build | Nudges toward AST tools (pyrename, tsmove, pyfindrefs) instead of sed/grep/mv on source files |
| temper | Review | Calls out large or critical-path commits and unreviewed pushes, unbudgeted and on their own; /critique-pr reviews a whole PR before merge |
| cairn | Ship | Nudges toward /draft-commit, /draft-pr, and /draft-changelog at every git boundary; /draft-pr --apply pushes the description to the PR |
| trellis | Setup | /draft-config surveys the repo and writes the config the other four read. No hook, no gate — it runs when you ask |
And underneath all five, the part that is useful with no gates installed at all:
one declarative place where a repo says how to test, lint, typecheck and build
itself — per toolchain in a monorepo, with per-key provenance, behind
a direnv-style content-hash trust boundary. aether check is the single point
that runs any of it, which is what lets the critics measure instead of guess.
git clone https://github.com/ValentinFigue/aether && cd aether
bash install.sh --global --claude-mdThen once per repository — trellis reads your CI workflows and git history and writes the config the critics use:
cd ~/your-project
/draft-config # writes .aether/config: your test command, lint, git conventions
aether trust # review what it found, then let the critics run itThat is the whole setup. The hooks now nudge at each checkpoint, and the commands
are there when you want them: /critique-plan, /critique-diff, /draft-commit,
/draft-pr.
bash install.sh # this project only
bash install.sh --global --no-bonsai # skip the one plugin needing uv/node/npm
bash install.sh --global --dry-run # print every step, change nothingKeep the clone. bonsai registers its MCP servers by absolute path into it, so
moving or deleting the clone breaks them — re-run install.sh from the new location.
Needs bash and one of python3/node/jq; bonsai also needs uv, node and
npm, and is skipped with an explanation if they are missing. There is no
curl | bash — the installer copies files out of the clone, so piping it could
never have worked.
Three moving parts, and each has one job.
plugins/<name>/aether.plugin declares everything a plugin puts on your machine:
its slash commands, its hooks, its CLI, its skills, its MCP servers, the
permissions it needs, its CLAUDE.md block, and its config schema.
name: cairn
scopes: local global
commands: draft-commit.md draft-pr.md draft-changelog.md draft-summary.md
legacy_commands: cairn-commit.md cairn-pr.md ...
hook.1: PreToolUse | Bash | hooks/enforce-cairn.sh | suite_owned
hook.2: PostToolUse | Bash\|Write\|Edit | hooks/post-cairn.sh
cli: cairn
permissions: Bash Read Write
config.style.default: conventional
config.style.doc: Commit format — Conventional Commits, or a plain subject
config.style.used_by: /draft-commit
The format is key: value, not JSON, deliberately: bash parses it with no
interpreter, so a machine without python3, node or jq still gets its commands,
CLI and templates installed. Only the settings.json steps need one.
bin/aether reads those manifests and performs five operations: copy a file,
copy a tree, symlink, upsert a JSON key, splice a sentinel block. Two escape
hatches cover the rest — build for bonsai's uv/npm step, and link for
the assets it points at the clone instead of copying.
Uninstall is the same engine reading the same manifest in reverse. That is the point of the design: install and uninstall cannot disagree, because there is only one description of what exists.
install.sh and every plugins/*/install.sh are one-line wrappers around it.
enforce-suite.sh is a dispatcher with no rules of its own. It sources each
plugin's gate from gates/ and calls the gate_<plugin> function it defines,
so a rule lives in exactly one file whether the plugin runs standalone or under
the suite.
It also sources aether-config.sh, the single config parser, which the CLI
shares. There were six independent parsers of the same format before it, and two
of the four plugin copies were missing head -1 — so a duplicated key resolved
to a multi-line value in half the suite.
manifests ──▶ bin/aether (engine) ──▶ ~/.claude/commands/
│ ~/.claude/settings.json
│ ~/.claude.json (MCP)
└──────────────────▶ <root>/hooks/enforce-suite.sh
<root>/hooks/gates/enforce-*.sh
<root>/hooks/aether-config.sh
Write a manifest and put the assets beside it. There is no installer to write —
aether install <name> and aether uninstall <name> work from the manifest
alone, and the plugin appears in aether status because it exists.
trellis is the proof: a manifest and one command file, with
no install.sh and no uninstall.sh. If the engine needed one, trellis could
not be installed at all, which is asserted in the test suite.
Two directories, and the split between them is the rule: .claude/ is what
Claude Code reads; .aether/ is what aether owns. Both scopes have the same
shape — ~/.aether/ globally, .aether/ in a project.
<root>/config your settings, one [section] per plugin
<root>/rules.md prose for the critics
<root>/hooks/ enforce-suite.sh · gates/ · aether-config.sh
<root>/out/ CRITIQUE.md · TEMPER.md
<root>/manifest what the installer put here
In .claude/, which belongs to Claude Code: one PreToolUse entry in
settings.json (matcher Bash\|Write\|Edit\|MultiEdit\|ExitPlanMode, the union of
every suite_owned matcher the manifests declare), the permissions each
installed plugin declares, the slash commands, and the CLAUDE.md block with
--claude-md. bonsai's MCP servers go in ~/.claude.json. The aether binary
goes in ~/.local/bin/. $AETHER_HOME overrides the global root.
settings.json and CLAUDE.md are copied to .bak before the first change.
Per-plugin PreToolUse hooks are removed on install, since enforce-suite.sh
supersedes them — but PostToolUse hooks are not, because the suite hook has no
equivalent for them.
aether install migrates on the way past: four <plugin>.config files per scope
become one sectioned config, whetstone.config.md and any pr.rules_file become
rules.md, and .claude/plans/{CRITIQUE,TEMPER}.md move to out/. Every old file
is copied to .bak, the pass is idempotent, and where both exist the new value
wins. aether migrate runs it alone. Until you migrate the old files are still
read when the new one has no value for a key.
One file per scope, sectioned, plain text. ~/.aether/config globally,
.aether/config in a project — /draft-config writes it for you.
enabled: true
[project] # things to RUN — this is what makes the critics measure
test: uv run pytest
lint: uvx ruff check
typecheck: npx tsc --noEmit
[git] # things to WRITE — house rules for commits and PRs
scopes: api, web, infra
ticket: TK-[0-9]+
[temper]
auto_nudge_lines: 200
critical_paths: *auth*|*token*|migrations/|*.sql # pipe-separated
[cairn]
style: conventional
Keys outside a section are suite-wide. Sections named after a plugin hold its
settings. The two that describe the repository split on a clear line: [project]
is things to run, [git] is things to write.
[project] |
effect |
|---|---|
test |
Coverage runs your suite and quotes real failures instead of reading the diff |
lint · format · typecheck |
Correctness reports actual violations with file and line |
build |
/critique-pr confirms the branch still builds |
coverage · coverage_min |
Coverage compares a number against a threshold |
[git] |
example | effect |
|---|---|---|
scopes · types |
api, web |
/draft-commit picks from your real ones instead of inventing |
ticket |
TK-[0-9]+ |
pulled from the branch into the subject; /critique-pr flags a PR without one |
trailers |
Signed-off-by |
always emitted |
base |
main |
/draft-pr stops auto-detecting |
Global, then project, then flags — resolved per key, so a project that sets one threshold keeps the global value of every other:
global [temper] auto_nudge_lines: 200 resolved auto_nudge_lines: 400 (project)
severity: red, yellow severity: red, yellow (global)
project [temper] auto_nudge_lines: 400
The schema lives in each plugin's manifest, so a key is documented by being
declared — and aether config show answers "if I change this, what happens?":
$ aether config show temper
[temper]
auto_nudge_lines 400 project · .aether/config:14
Nudge before a commit whose staged diff exceeds this many lines
used by: enforce-temper.sh
severity red, yellow global · ~/.aether/config:6
aether config doctor catches what hand-editing produces — a typo is otherwise
silently ignored, the default applies, and the setting appears to do nothing:
$ aether config doctor
✗ [temper] auto_nudge_line — unknown key (.aether/config:14)
did you mean auto_nudge_lines?
! [project] lint: git ls-files | xargs shellcheck
not on PATH: shellcheck — that step will be skipped
It checks every command position, not just the first word: git ls-files | xargs shellcheck starts with git, so checking one word would report nothing while
shellcheck was silently skipped.
Anything that writes a config emits the doc line as a comment above each key, so
the file explains itself. This repo's own .aether/config is the
worked example — every value records where it came from, and two keys are
deliberately unset with a note on why the obvious value would be wrong.
/critique-plan records its findings inside the plan, behind a marker holding a
hash of the plan with that block removed:
<!-- aether:critique sha=3f9a1c… date=2026-07-30 blockers=0 -->
## Critique
…
<!-- /aether:critique -->The plan file is the only thing writable in plan mode, so it is the only place a critique made there can go — and a per-plan record means critiquing one plan no longer satisfies the gate for every other. Re-saving a plan keeps its critique valid; adding a section does not.
$ aether plan status
plan: ~/.claude/plans/rate-limiting.md
! the plan changed after its last critique fix: /critique-plan
whetstone nudges when you present the plan, on the first source write, and at
git commit — once per uncritiqued plan, not once per project. Leaving plan mode is
the one place it is structurally incapable of interfering: that branch prints and
returns 0 unconditionally.
[project] assumes one test command for one tree. A monorepo has several, so it gets
one area per toolchain, named for the directory its commands run in:
[project:backend]
test: uv run --frozen pytest
lint: uv run --frozen ruff check ./
typecheck: uv run --frozen mypy ./
check.lockfile: uv lock --check # a real CI check that fits no standard key
[project:web]
lint: bun run lint-ci
typecheck: bun run tsc # no `test` key — web has no test script
Files map to areas by longest matching path prefix, the way a CI paths filter
does, so a diff touching only web/ never runs backend's pytest:
$ aether check web/src/App.tsx backend/src/api.py
project:backend test ✓ 41s
project:web lint ✓ 2s
project:web typecheck ✗ bun run tsc
src/App.tsx(14,3): error TS2345: …
project:canvas_processor not touched — skipped
aether check is the only thing that runs these commands — /critique-diff and
/critique-pr call it rather than running them themselves, so trust has one
enforcement point. It defaults to files changed against the base branch; --all runs
every area, --raw is the machine-readable form the critics parse.
Command keys are not inherited from [project]. A command written for the repo
root has no correct meaning inside a subdirectory, so each area states its own.
Non-command keys like coverage_min do inherit. Which is which comes from the
schema — anything declared type: command.
Already installed aether inside a subfolder to get this? aether migrate folds it in:
backend/.aether/ becomes [project:backend], only within the same git work tree, and
where a non-[project] key differs it keeps the parent's and tells you. The old
directory is left at backend/.aether.bak.
A project's config can set thresholds the moment you clone it; none of that can execute anything. Two things can, and both wait for you:
| why it waits | |
|---|---|
[project] commands |
a critic would execute them |
rules.md prose |
it reaches a critic's context — an injection vector with no log |
Until you trust a project those two are ignored, global config and prose are used
instead, and the critic says so in its report. aether trust prints the
commands and the prose before recording anything:
$ aether trust
Trusting /Users/you/Code/thing
Commands the critics would run:
test uv run pytest
Prose that would reach a critic (.aether/rules.md, 4 line(s)):
[critique-diff]
We use event sourcing — flag anything that bypasses the event log.
Trust is a content hash, so hand-editing either file asks again — loudly,
rather than silently falling back to global. aether config set re-hashes
automatically, because you made the change through the tool. That asymmetry is
direnv's model and the reason it is safe. Global config and prose are always
trusted: you wrote them.
Free text beside the config, one section per command. Prose concatenates global-then-project rather than overriding — losing your global writing rules because a repo added a line would be the wrong default.
[all]
This is a bash project. Prefer POSIX-compatible constructs.
[critique-diff]
We use event sourcing — flag anything that bypasses the event log.Eight slash commands. Every one reads its defaults from config, and every one takes flags that override config for that run.
/critique-plan # three critics: impl, arch, risk
/critique-plan --only=impl,arch # just those two
/critique-plan --skip=risk # all defaults except one
/critique-plan --severity=red # only blockers
/critique-plan --off # skip it this onceFour more critics are opt-in because they are not always relevant: testing,
complexity, api, cost. Ask for them by name — /critique-plan --only=impl,api.
The findings are written into the plan, behind an aether:critique marker — the
only file plan mode lets anything write, and the record the gate reads
(Plans and their critiques). The same report is also
appended to .aether/out/CRITIQUE.md with a date header, which accumulates a history
across plans. A 🔴 means don't start implementing yet.
bonsai has no slash command. It is 13 MCP tools plus a gate that notices when you
reach for sed on a .py file:
python (8) pyrename · pymove · pymovesymbol · pysignature
pyfindrefs · pycallers · pyfindunused · pygrep
typescript (5) tsrename · tsmove · tsmovesymbol · tssignature · tsfindrefs
Ask for the operation and Claude picks the tool: "rename parse_config to
load_config everywhere" uses pyrename, which follows re-exports and aliased
imports that a text replace silently misses. Always dry-run a mutating tool first.
/critique-diff # five critics over the staged diff
/critique-diff --diff=all # staged + unstaged
/critique-diff --target=src/auth.py # one file
/critique-diff --only=correctness,risk
/critique-diff --severity=red,yellow
/critique-pr # the PR for the current branch
/critique-pr --pr=42
/critique-pr --severity=red # blockers only, before mergingThe five critics are Correctness, Design, Risk, Coverage and Documentation — the last
asking whether any sentence describing a behaviour this diff changes is still true.
/critique-pr adds a sixth that only makes sense for a PR: whether the description
still matches the code. An omitted change is weighted above an inaccurate one — a reviewer who
trusts the description will not go looking for what it does not name.
With [project] set and the repo trusted, Correctness and Coverage run your real
tooling and quote real failures. Without it they read the diff and say so.
/draft-commit # message from the staged diff
/draft-commit --style=plain # no Conventional Commits prefix
/draft-commit --raw # just the message, nothing else
/draft-pr # title + description from the branch diff
/draft-pr --apply # …and push it to the PR with `gh pr edit`
/draft-pr --apply --title # also replace the title (opt-in)
/draft-pr --base=develop --pr=42
/draft-changelog --version=1.2.0 # entry from the last tag to HEAD
/draft-changelog --from=v1.0.0 --to=HEAD
/draft-summary # standup notes from the last day
/draft-summary --format=slack --from=v1.0.0
/draft-summary --format=paragraph --author=you@example.com--apply is the one that writes to something outside your checkout. Everything
else prints and lets you paste.
/draft-config # detect, ask about gaps, write .aether/config
/draft-config --global # write ~/.aether/config instead
/draft-config --dry-run # print what it would write
/draft-config --only=project,git # just those sections
/draft-config --force # overwrite keys you already setRun this once per repo. It never runs what it detects — aether trust is a
separate, explicit step.
One pass through a change, and where each command earns its place.
cd ~/Code/my-project
/draft-config # writes .aether/config from CI, manifests, git history
aether config show # read it back: values, sources, what each key does
aether trust # review the commands, then allow the critics to run them
git add .aether/config .aether/rules.md && git commit -m "chore: add aether config"Committing .aether/config is the point — it is a description of the project, so
your colleagues get the same thresholds and the same test command. out/ and
manifest are per-developer and should stay ignored.
Describe the change; Claude proposes a plan in .claude/plans/<name>.md. Then:
/critique-plan🔴 findings mean the plan is wrong, not the code. Fixing a plan costs a
conversation; fixing the same problem after implementation costs a refactor.
whetstone's gate enforces this at git commit: a plan on disk with no critique
newer than it produces a nudge.
Nothing to invoke. Ask for the change you want. Two things happen on their own:
- Reach for
sed/grep/mvon a.py,.ts,.tsx,.jsor.jsxfile and bonsai's gate points at the AST tool that handles re-exports and aliased imports. - After a rename-shaped edit,
post-bonsai.shre-checks references and says if something now dangles.
git add -p
/critique-diff # Coverage runs your test command; Correctness runs lint and typecheckFix the 🔴s. 🟡 either gets fixed or written down. temper's gate speaks up on a
git push that has had no review, and on a commit touching a critical path — auth,
migrations, secrets, schemas — regardless of size. Those two are the only messages that
never share the output with another plugin. They do not stop the command.
/draft-commit # reads the diff, not your memory of it
git commit -m "<paste>"
/draft-pr --apply # opens or updates the PR descriptionThen, once the PR is open and CI has gone green — which is exactly when nobody re-reads it:
/critique-pr # the same five critics over the whole PR, plus description accuracyCommits landed after the description was written? Then the description is the most likely thing in the PR to be wrong, and that fifth critic is the one that matters.
/draft-changelog --version=1.2.0
/draft-summary --format=slack --from=v1.1.0The gates are advice, not policy. One marker turns any of them off for one command:
git commit -m "wip" # aether:skip
git push origin main # temper:skip
grep -r TODO ./src # bonsai:skipgit commit / git push / Write source file / leaving plan mode
│
▼
enforce-suite.sh ← dispatcher only; no rules of its own
│
├── sources gates/enforce-whetstone.sh → gate_whetstone
├── sources gates/enforce-bonsai.sh → gate_bonsai
├── sources gates/enforce-temper.sh → gate_temper
└── sources gates/enforce-cairn.sh → gate_cairn
│
┌────┴──────────────────┬──────────────────────┐
│ │ │
▼ ▼ ▼
Bash tool Write / Edit / MultiEdit ExitPlanMode
│ │ │
├─ gate_whetstone └─ gate_whetstone └─ gate_whetstone
│ (plan exists, (first source (presenting an
│ no critique?) write, no uncritiqued plan?
│ critique?) prints, never gates)
│
├─ gate_bonsai
│ (text tools on source files?)
│
├─ gate_temper
│ (large diff? critical path? push?)
│
└─ gate_cairn
(weak commit message? push?)
Each gate is defined once, in its own plugin's hooks/enforce-<plugin>.sh, and runs either standalone or sourced by the suite.
enforce-suite.sh skips any plugin whose config says enabled: false, and any gate that is not installed.
Every gate is advisory: the hook exits 0 or 1 and the tool call proceeds either way. Only
exit 2 stops a call in Claude Code, and nothing here uses it — a gate that is corrupt or
half-written would then lock you out of every command, so the whole chain is built to
fail open and tests/acceptance.sh asserts no payload can make it exit 2. temper's two
high-risk verdicts (push without review, critical-path commit) are unbudgeted rather
than blocking: they print in full, alone, and suppress everything else.
One nudge per tool call. Gates run top to bottom, but their output does not
accumulate: the hook prints the earliest stage with something to say and names the rest
on one line. A git commit that tripped whetstone, temper and cairn used to print
fifteen lines from three plugins, and nudge fatigue is how guardrails die — the fastest
way to stop the noise becomes # aether:skip on everything.
Whetstone: a plan exists but has not been critiqued yet.
.claude/plans/p.md
Run /critique-plan before committing to surface blockers now.
Append # whetstone:skip to your git command to bypass.
+ temper and cairn also had notes — `aether status --notes` to see them.
temper's two high-risk verdicts are exempt from the budget: each prints in full and
alone, and the nudges beside it are dropped rather than appended. Being told your push
had no review is the only thing worth reading at that moment. Whatever was held back
goes to .aether/out/.notes, overwritten each call.
One interpreter per tool call. The hook used to start three python3 processes on a
git commit — its own stdin parse plus temper's and cairn's rules — at roughly 18ms
each, on a path that runs on every Bash, Write and Edit of every session. There is now
a single parse in aether_parse_command, and every rule is bash, git and awk.
tests/test_hookcost.sh counts the interpreters rather than timing them, so a loaded
CI machine cannot produce a false alarm.
aether install [plugin...] [global] [--claude-md] [--no-bonsai] [--dry-run]
aether uninstall [plugin...] [global] [--claude-md]
aether status Plugin state, gates, clone, version
aether status --notes Nudges the hook's budget held back
aether doctor [--fix] [--deep] Check the install against the manifests
aether docs Check the docs against the code and config
aether config show [section] [--values|--raw]
aether config explain <section>.<key>
aether config doctor Just the config and trust half
aether config set|unset <section>.<key> [value] [global]
aether config path|edit [global]
aether check [path...] [--all] [--raw] Run this project's [project] commands
aether project for <file...> Which monorepo areas those files touch
aether plan [status|path|hash] The plan the gate sees, and its critique state
aether review [status|show|list|record] Reviews: was it reviewed, and what did it say
aether plan critique [--history] whetstone's critique for the plan the gate sees
aether trust [status|list|forget|prune]
aether rules The prose the critics will read
aether migrate Move a pre-1.0 layout into ~/.aether/
aether enable|disable [local|global] All plugins
aether hook enable|disable <plugin> Stop a gate loading at all
aether update git pull the clone, re-run the installer
aether version · aether help
Every plugin answers to its own name — aether cairn status — and the cairn,
temper, whetstone and bonsai binaries are 24-line shims that exec exactly
that, so there is one implementation.
A plugin's config subcommand takes the same verbs as aether config and resolves the
section from the schema, so you do not have to know which one a key lives in — including
the keys cairn declares under [git]:
cairn config set trailers Signed-off-by ≡ aether config set git.trailers Signed-off-by
temper config set auto_nudge_lines 300 ≡ aether config set temper.auto_nudge_lines 300
enable/disable writes enabled: false for the gate to read; hook enable/disable stops the gate being loaded. The soft mute is usually what you
want.
$ aether status
aether v1.8.0
bonsai enabled MCP: bonsai-py bonsai-ts
whetstone enabled
temper enabled
cairn enabled
trellis enabled
Suite hook: enforce-suite.sh registered (global)
Gates: 4 loaded from /Users/you/.aether/hooks/gates
Clone: /Users/you/Code/aether
Installed version: 1.8.0
status says what is installed; doctor says what is wrong with it. Every check
compares the state on disk against what the manifests declare, and every finding
names its fix.
$ aether doctor
install (global)
✗ PostToolUse: ~/.local/share/cairn/post-cairn.sh is registered but does not exist
Claude Code tries to run it on every matching tool call
fix: aether doctor --fix
✗ post-cairn.sh is registered more than once — it fires once per registration
! 2 command(s) from a pre-1.0 install are still in the palette
✓ 14 manifest entries all present on disk
✓ permissions match what the installed plugins declare
✓ 4 gates parse
config
✗ [temper] auto_nudge_line — unknown key (.aether/config:14)
trust
! 1 entry(ies) point at a directory that no longer exists
fix: aether trust prune
3 problem(s), 2 warning(s).
It exists because every bug that mattered in 1.1.0 shared a shape: the tool
reported success and had done nothing. The engine registered no MCP servers for
six commits. An upgrade left a hook pointing into a deleted directory.
post-cairn.sh fired twice. All invisible from outside — in a tool whose job is
noticing problems.
--fix performs only the three repairs where the right action is unambiguous:
deregister a hook whose script is gone, drop a duplicate registration, prune dead
trust entries. Everything else prints the command. --deep also handshakes each
MCP server, which spawns uv and node.
doctor checks the install; docs checks what your documentation claims. Every defect
in the 1.5.0 documentation audit was found by hand, and most of them were mechanically
detectable — this is the mechanism, and it works in any repository, not just this one.
$ aether docs
README.md
✗ README.md:230 [temper] critical_paths is pipe-separated; this example is
space-separated and matches nothing
fix: join the patterns with |
! README.md:88 [project] test runs this as `uv run --frozen pytest`
the doc omits the wrapper, so following it skips what the wrapper does
1 problem(s), 1 warning(s) across 7 file(s).
Four families, in rough order of what they are worth:
Prose contradicting [project] |
Your config already declares how the repo really runs. A README saying pytest where the config says uv run --frozen pytest is what a new joiner follows on day one. Per area, so a monorepo command that is right for web/ and wrong for backend/ is caught |
| Commands that do not exist | npm run X with no such script, make Y with no such target, bash scripts/z.sh that moved |
| Dead references | Relative links, and ](#anchor) against the headings actually in the file |
| aether's own claims | Config keys and values against the manifest schema, subcommands and flags against the engine, retired paths outside a migration note |
Deliberately narrow: nothing is inferred. Every check compares a documented string
against a declared or on-disk fact, because a checker that reports plausible-but-wrong
findings gets switched off within a week. Judgement — is this sentence still true after
my change? — belongs to the Documentation critic in /critique-diff.
Scope it with [docs], and wire it into review with one line:
[docs]
paths: README.md docs/ plugins/*/README.md # default: *.md at the root, plus docs/
ignore: CHANGELOG.md # a changelog is meant to describe old behaviour
[project]
check.docs: aether docs
aether check runs any check.<name> key, and /critique-diff and /critique-pr call
aether check — so that one line puts the check in front of every review. It exits
non-zero on problems, so CI can use it too.
[project] commands are ignored until you run aether trust; without it the first
family cannot run, and aether docs says so rather than quietly checking less.
The critics write to .aether/out/ and the files accumulate. One repository reached 21
reviews in 357 lines, at which point seeing the latest meant
awk '/^# Review/{n++} n==21'.
$ aether review list
.aether/out/TEMPER.md
# when scope 🔴 🟡 🟢
21 2026-07-31T12:04Z all 0 1 4
20 2026-07-29 — 0 0 0
2 2026-05-07 — 0 1 3
1 2026-05-07 — 0 1 5
21 entries. `aether review show <#>` for one of them.
$ aether review show # the latest
$ aether review show 16 # one of them
$ aether review show --raw | glow -
aether plan critique does the same for whetstone, reading the block inside the plan
— the authoritative copy, and the one the gate hashes. It says so when the plan has
changed since the critique was written. --history reads the accumulated
.aether/out/CRITIQUE.md instead.
Reports written from 1.8.0 carry a marker with a timestamp, the scope and the counts. Older ones are still listed and shown, from their heading — they simply have no scope and no time of day, which is why two reviews on the same day used to be indistinguishable and why the number, not the date, is what you address.
Everything above is advice: the hook prints, and the command runs. Strict mode changes that for temper's two strongest verdicts — an unreviewed push, and a commit touching a path you marked critical.
# ~/.aether/config
[temper]
strict: blocks # off | blocks — default off
They stop being printed and become a permission prompt the agent cannot answer. You do. That is the point: for a tool whose threat model is "the agent writes the command the hook inspects", this is the first mechanism that takes the decision somewhere the agent cannot reach. Approving the prompt is the bypass, so there is no new marker to learn.
It runs on evidence, not on suspicion. The push verdict used to fire on every push,
which is tolerable as a nudge and useless as a prompt — a question with the same answer
every time gets clicked through within a week. /critique-diff now records what it
reviewed, and the verdict consults it:
$ aether review status
no — 2 of 3 commit(s) being pushed have not been reviewed.
fix: /critique-diff
The record hashes the content of the diff, not the commit, so it survives committing,
amending and rebasing — the flows that would otherwise make it wrong immediately. A third
state, unknown, covers a machine with no sha256 tool, a project with no .aether/, and
a branch with no base to compare against. unknown never escalates: absence of
evidence is not evidence, and a prompt nobody can satisfy is the fastest way to get this
switched off.
Three deliberate limits:
- Global-only. A project-level switch would sit in the tree the agent is editing. Turning it off should mean writing outside the work, where it is conspicuous.
- It escalates, it does not refuse.
ask, notdeny. - It is still not a boundary. The agent can edit
~/.aether/configor deregister the hook. This raises the cost of a bypass from appending a comment to editing a file outside the repository. For an actual boundary, use a pre-receive hook or a required CI check — BYPASS.md says so at more length.
Full specification: BYPASS.md
| Marker | Effect |
|---|---|
# aether:skip |
Silence all gates |
# suite:skip |
Alias for aether:skip |
# whetstone:skip |
Silence whetstone gate only |
# bonsai:skip |
Silence bonsai gate only |
# temper:skip |
Silence temper gate only |
# cairn:skip |
Silence cairn gate only |
git push origin main # aether:skip
git commit -m "wip" # temper:skip cairn:skip
grep -r "TODO" ./src # bonsai:skipA marker only counts in a trailing comment — after a # that starts a word, outside
quotes, with nothing but further markers behind it. Until v1.5.0 each gate grepped for
its own marker anywhere in the command, so a commit documenting one silenced the suite:
git commit -m "docs: explain the # aether:skip marker" # ran every gate; used to run noneYou can install one plugin instead of all five. Each plugin's installer lives beside it:
aether install cairn global --claude-md
aether install temper whetstone global
bash plugins/cairn/install.sh global # equivalent; a wrapper"Standalone" means one plugin's assets, not a machine without aether. Every install
goes through the same engine, so it also lays down the aether CLI, enforce-suite.sh
and the shared aether-config.sh — the way an MCP server needs a host. The suite hook
is always the one registered; a plugin never registers a PreToolUse hook of its own,
because its manifest marks that hook suite_owned and the engine installs in suite mode
on every path. --claude-md is what controls whether the CLAUDE.md block is written.
What is standalone is the gate. Each one is a gate_<plugin> function in its own
file, so the same rule runs whether enforce-suite.sh sources it or the file is
executed directly — which is what the dual-mode equivalence tests assert.
Two layers. The unit suite asserts behaviour; the acceptance script exercises the things that only appear in a real install.
bash tests/run.sh # 710 assertions across 10 files, ~3 min
bash tests/run.sh doctor # one file
bash tests/run.sh config # the config, trust and migration tests
bash tests/run.sh hookcost # interpreter count, bypass precision, the budget
bash tests/run.sh rules # what each gate's rule decides, case by case
bash tests/acceptance.sh # end to end against a throwaway HOME, ~4 min
bash tests/acceptance.sh --full # also build bonsai and handshake its MCP servers
bash tests/acceptance.sh --perf-only # just what the hook costs per tool callacceptance.sh covers what a unit test cannot: a path with spaces, nine hostile
hook inputs (none may exit 2, which would block the tool call, and none may write to
stderr), --dry-run leaving the home directory byte-identical with bonsai included,
four consecutive installs producing identical state, upgrading from the last release
with no dangling hooks, aether doctor clean both before and after that upgrade, and
the hook's per-tool-call cost measured against that release rather than an
absolute budget — so a loaded machine does not raise a false alarm. It never touches
your real $HOME.
Plain bash and python3, no packages. The unit suite covers dual-mode gate equivalence (each gate behaves identically standalone and under the dispatcher), bypass markers, fail-open on malformed input, per-key config resolution, trust, migration, install idempotence, and uninstall.
Deliberately short, and ordered by what is actually in the way. Everything here is a gap someone has hit, not a feature idea.
Five people, one week. Nobody outside this repo has run the suite on a repository they care about. Every item below is a guess until they have, and their week would reorder this list more reliably than any amount of reasoning about which command to build next. It is first because it is the cheapest way to find out that the rest is wrong.
A distribution story. Today it is git clone plus a script, and the clone has to
stay put forever because bonsai's MCP servers reference it by absolute path — move it
and they break. A published package fixes that. A plugin marketplace entry was
previously written off as premature, which is backwards: at zero stars the constraint is
not that the tool is unready, it is that nobody can find it, and the marketplace is where
Claude Code users look.
Strict mode. Nothing here stops a command today, because a PreToolUse hook can only
do that by exiting 2 and one corrupt gate file would then lock you out of everything. An
opt-in strict: true would let temper's two high-risk verdicts exit 2 for real, and stop
honouring a bypass marker on them. Worth being precise about what that buys: the agent
could still edit the config, deregister the hook, or route around the matcher. It raises
the cost of a bypass from appending a comment — invisible and deniable — to editing a
tracked file, which shows up in a diff. It is not a boundary. A pre-receive hook or a
required CI check is a boundary, and BYPASS.md says so.
One trigger surface. whetstone, temper and cairn all fire on the same event, and v1.5.0 needed a lifecycle-ordered budget to stop them talking over each other. That budget is the evidence: three plugins expressing one idea — stop before git — through three CLAUDE.md sentinels, three config sections and three gate files. Not a decision to merge them; the budget is the experiment, and once it has been lived with either the separation earns its keep or it collapses into one gate with three critics.
Close the loop between review and fix. A critic finds something and a human retypes
it. /critique-diff --fix applying only the mechanical findings — the ones with a file,
a line and one obvious edit — would remove the retyping without removing the judgement.
Work with any agent, not just Claude Code. The checking is already portable:
bonsai's tools are plain MCP, the config and prose are plain text, the CLI is bash,
and the commands are markdown prompts. What is Claude Code specific is the automatic
interruption — the gates register in its settings.json and parse its PreToolUse
payload. Each rule already lives in one file behind a gate_<plugin> function, so
another host is payload translation rather than a rewrite. The intent is to support
Cursor, Windsurf, Zed and anything else that grows an equivalent hook.
Merge discipline. aether merge gating on the things worth blocking a merge
for — critique run, description accurate, CI green on the actual head — rather
than leaving them to whoever remembers.
Dependencies. Nothing in the suite looks at what a change pulls in.
/critique-deps for a new or bumped dependency: is it maintained, does it need
network at runtime, does the licence fit.
Decisions. /draft-adr from a critiqued plan. The plan already contains the
alternatives and why they were rejected, which is the expensive half of an ADR,
and it is currently thrown away once the code lands.
# Remove suite hook, gates and CLI (plugins remain installed)
bash uninstall.sh --global
# Also remove the CLAUDE.md block
bash uninstall.sh --global --claude-mdOr via the CLI: aether uninstall global --claude-md
The four plugins stay installed and usable on their own; their slash commands and PostToolUse hooks are left in place. Remove them with plugins/<name>/uninstall.sh.
MIT