Stop reciting software engineering literature to your AI agent.
Turn best practice advice into AI habits, and make it write code like this:
AI coding agents frequently ignore long rule documents. Asking them to hold on to an entire book's worth of coding advice is at best futile, at worst makes the agent's performance worse by polluting the context window.
Humans don't need to hold the same information in their head because humans can form habits through repetition. However, AI agents can't do this.
Human habits form when an easy-to-detect cue triggers a complex sequence of actions with the desired effect. This is the inspiration for habit hooks.
Linters provide a deterministic metric, but Goodhart's law postulates that a metric ceases to be a good metric if it becomes a target. AI agents are very good at gaming these metrics when they are only provided the metric.
Habit hooks runs your linters to create the trigger, but instead of providing only the metric, it gives actionable advice on how to fix the issue. This creates AI behaviour that looks like human habits, and has similar effects.
The use of habit hooks:
- Increases code quality
- Improves AI performance ensuring that the AI always starts with good code quality
- Reduces token usage, since good quality code also means the AI doesn't need to read as much context to complete the task.
Habit hooks is two small command-line tools joined by a Unix pipe. Between them flows a JSON array of findings.
habit-sensors <scope flags> | habit-mapper
habit-sensorsfinds the smells. It runs the configured detectors over the files in scope and emits a findings array on stdout.habit-mapperacts on them. It reads the findings on stdin, groups them by smell, renders each smell's coaching guide, and sets the exit code from each smell's severity (enforcedfails the run with exit 1,suggestedcoaches but exits 0). An empty pipe is a stage that died before writing, so it coaches the incomplete run and exits 2 rather than reporting a pass.
habit-hooks is just the composition of the two — habit-sensors $ARGS | habit-mapper — so the same arguments
scope the run and the same findings drive the coaching. Because the stages talk only through findings on a pipe,
each can be run, tested, or replaced on its own.
Each sensor translates a tool's raw rule IDs into a tool-independent smell key (max-params, PLR0913, … all
become too-many-parameters), and everything downstream routes on that key alone. The mapper picks a guide by
smell, never by which tool reported it.
Install habit-hooks, then let it set your project up:
uv tool install habit-hooks # pip, pipx and brew work too
cd your-project
habit-hooks inithabit-hooks init detects the language your project is written in, writes .habit-hooks/config.toml
enabling the plugins it needs, and lists everything still missing beside the command that installs it —
offering to run them for you. Re-run it whenever you like: on a project that already has a config it changes
nothing and only reports what is missing, so it also answers "why is this run not reporting anything?".
Doing it by hand — what init is doing on your behalf
Setting habit-hooks up takes four steps. A run that reports nothing is almost always a skipped one:
- Install habit-hooks — you get the core and the generic, language-agnostic plugin.
- Install the plugin for your language — python, typescript and php ship as separate packages.
- Enable the plugins by naming them in
.habit-hooks/config.toml. Installing one does not switch it on. - Install the detectors the plugins you enabled use —
jscpd,ruff,eslintand friends.
Steps 3 and 4 are per project.
habit-hooks is a Python package (requires Python 3.11+). Install it with uv, pip, or Homebrew:
uv tool install habit-hooks
# or
uvx habit-hooks
# or
pip install habit-hooks
# or
brew install habit-hooks/tap/habit-hooksThis gives you core plus the generic (language-agnostic) plugin and installs four commands on your PATH:
habit-hooks, habit-sensors, habit-mapper, and habit-snooze.
⚠️ Important: this on its own checks nothing about your language. What you have now is the generic plugin, which measures file length and duplication. Python, TypeScript and PHP each need their own plugin — installed (step 2) and enabled (step 3).
The three language plugins are opt-in via extras:
uv tool install "habit-hooks[typescript]" # one language
uv tool install "habit-hooks[python,typescript]" # several — name them in one command
uv tool install "habit-hooks[all]" # all three
⚠️ Eachuv tool installrebuilds the environment rather than adding to it, so a second one naming a different extra silently replaces the first: run[python]and then[typescript]and you are left with typescript alone, and your Python project quietly stops being checked. Name every language you want in a single command. (pip install "habit-hooks[python]"has no such trap — it adds.)
To pick language plugins per project without a global install, run from the extra with uvx (uv caches it):
uvx --from "habit-hooks[typescript]" habit-hooksAlternatively, vendor a plugin's files under .habit-hooks/<plugin>/ in your project. That works with any
install — including one that cannot add extras (e.g. Homebrew) — because project files always override the
installed package.
Installing a plugin does not switch it on. However it got onto the machine, a plugin runs only once your
.habit-hooks/config.toml names it in plugins:
# .habit-hooks/config.toml
plugins = ["typescript", "generic"]The list is ordered, and the order is a priority — see Plugins.
The detectors are not bundled: a plugin spawns the real tool, or reads it as a library. Install the ones the plugins you enabled need.
| Plugin | Detectors | Install |
|---|---|---|
| generic | jscpd — the line counter is built in |
npm install --save-dev jscpd |
| python | ruff, deptry, jq |
pip install ruff deptry (jq from your system package manager) |
| typescript | node, eslint, knip, ts-morph, jq |
npm install --save-dev eslint knip ts-morph (node and jq from your system package manager) |
| php | php — phpmd ships bundled as a phar |
nothing beyond a PHP runtime |
ts-morph is read as a library rather than spawned, so it belongs in your devDependencies — being on PATH
does nothing for it.
If your TypeScript project has no eslint config of its own, habit-hooks lints with the config it ships, which needs
@typescript-eslint/parserand@typescript-eslint/eslint-pluginin your project too:npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin. A project with its owneslint.config.jsneeds neither — yours always wins.
habit-sensors prepends node_modules/.bin and .venv/bin to PATH, so a project's locally-installed tools are
found without being on the global PATH.
habit-hooks init writes this for you; a Python project's config reads:
# .habit-hooks/config.toml
plugins = ["python", "generic"]
files = ["**/*.py"]files is optional — name none and the enabled plugins' own patterns are used.
Run habit-hooks against the files changed on your branch:
habit-hooksOr scope the run explicitly:
habit-hooks --all # every file
habit-hooks --file src/billing.py # one file, ignoring snoozes (see below)
habit-hooks --branch main # files changed vs a base ref
habit-hooks --last 3 # files changed in the last 3 commits
habit-hooks --since <ref> # files changed since a commitThe scope flags are mutually exclusive. With no flag, the scope is derived from the [scope] config (see below).
A git-derived run measures what your branch changed since it left the base ref — from the merge base, so files
somebody else changed on the base afterwards are not yours to fix. Whatever picked the paths, files the work tree no
longer has are dropped (a deleted file has no smells left) and the rest must match files. A base ref the checkout
cannot resolve fails the run instead of quietly scanning nothing.
habit-hooks --version prints habit-hooks vX.Y.Z (the same on habit-sensors, habit-mapper and habit-snooze) —
worth quoting in a bug report, since the tool ships through four channels (PyPI, Homebrew, uvx, an npm shim).
The exit code separates a finding from a broken tool, so a CI wrapper can act on the difference:
| Exit | Meaning |
|---|---|
0 |
clean — no enforced finding |
1 |
an enforced finding — this branch has a smell to fix |
2 |
the tool itself failed — a bad config key, a base ref the checkout cannot resolve, a --last that is not a positive integer, a corrupt snooze index, or a plugin that is configured but not installed |
Everything language- or tool-specific lives in a plugin — a self-contained bundle of files:
<plugin>/
config.toml # what this plugin contributes, and the language it speaks
sensors/ # how it finds smells
transformers/ # how it reshapes findings
guides/ # how it coaches each fix
A project turns plugins on by listing them, in order, in .habit-hooks/config.toml:
plugins = ["python", "generic"]That list is ordered, and the order is a priority. It is the order sensors run and concatenate, and the order
the mapper looks up guides: to coach a finding the mapper walks the plugins in turn and takes the first one that
has a guide for that smell and language, falling back to generic last. So an earlier plugin overrides a later
one for the same smell.
Put your language's plugin before generic, which is what habit-hooks init writes. The other way round,
generic's guide wins for every smell it covers — and the python plugin ships high-complexity and
swallowed-exception guides precisely because those want a Python answer rather than a general one, so listing
generic first makes them unreachable.
A plugin is not a language — it declares the language it speaks in its config.toml, and the runner stamps that
onto the plugin's findings. So several plugins can speak the same language using different tools, and the order
decides which one's guide wins. generic is listed explicitly like any other plugin, so a project can drop it.
The four plugins that ship:
| Plugin | Language | Sensors | Tools used |
|---|---|---|---|
generic |
(none) | line-count, jscpd |
built-in line counter, jscpd |
python |
python |
ruff, deptry |
ruff, deptry |
typescript |
typescript |
eslint, knip, comment |
eslint, knip, ts-morph |
php |
php |
phpmd |
phpmd |
A project keeps its overrides in .habit-hooks/, mirroring the plugin layout but holding only what differs
from the defaults. Defaults always resolve from the installed package, so updating habit-hooks never clobbers a
project's tuning.
Every file is resolved by walking the active plugins in order and, for each, trying the project's override before the package's default:
.habit-hooks/<plugin>/ → <package>/plugins/<plugin>/
So to replace the generic too-many-parameters coaching guide, drop your own at
.habit-hooks/generic/guides/too-many-parameters.md. To swap out a sensor, override its .toml under
.habit-hooks/<plugin>/sensors/. Configuration merges the same way, with the project last and winning.
All configuration is TOML. The project's .habit-hooks/config.toml is merged over the plugin defaults — generic
first, then each plugin's defaults, then the project, project last and winning. Every field is optional; an empty
file means "use the plugin defaults".
One file is read by both stages, each picking out the keys it cares about:
| Stage | Reads |
|---|---|
habit-sensors (the runner) |
plugins, transformers, files, [scope], [sensors.*] |
habit-mapper (the router) |
[smells.*], [runners] |
plugins = ["python", "generic"] # ordered = lookup priority; generic is the fallback, so it goes last
transformers = ["snooze"] # applied to the whole run's findings, in order
files = ["**/*.py"] # discovery globs (pathspec / gitignore), in every scope modefiles says what this project counts as source, and applies to every scope mode. Discovery is opt-in: leave it
out and the run scans what its plugins declare — the union of every active plugin's own files, in plugins order.
A project that names no files and whose plugins declare none (only generic) scans nothing at all, rather than
sweeping node_modules, .venv and .git; name what you want scanned. Naming files replaces those defaults
wholesale.
files uses pathspec (gitignore) matching, which has no brace expansion — write one pattern per
extension, never a {…} alternation:
files = ["**/*.ts", "**/*.tsx"] # correct
# files = "**/*.{ts,tsx}" wrong — matched literally, never expandedWhen a run is invoked with no explicit scope flag, the scope is derived from [scope]:
[scope]
changedOnly = false # restrict the default run to uncommitted (git-changed) files
autoBranchOffMain = true # when not on mainBranch, default to diffing against branchBase
branchBase = "main" # base ref for branch-relative scoping; must exist in the checkout
mainBranch = "main" # the branch on which autoBranchOffMain does not kick inOverride a sensor a plugin already ships. Each key replaces the sensor spec's
default wholesale; to change anything the keys below do not cover, drop a whole
.habit-hooks/<plugin>/sensors/<name>.toml replacement instead.
# Turn off a sensor the plugin ships.
[sensors.knip]
disabled = true
# Narrow the generic line-count sensor to source files.
[sensors.line-count]
files = ["src/**/*.py"]Fields: disabled, files (narrows the run's scope for this sensor alone), args
(replaces the sensor's default CLI args, expanded via ${args}).
Per-smell routing overrides, keyed by smell. A smell with no override uses the catalogue default.
# Demote a smell from blocking to advisory.
[smells.duplicated-code]
severity = "suggested"
# Reuse a shared guide instead of redundant-type-annotation.md.
[smells.redundant-type-annotation]
guide = "style-nit.md"Fields: severity (enforced / suggested), disabled, guide.
The mapper renders each smell's guide. A .md guide is rendered as a Jinja2 template and needs no runner. Any
other extension needs one: [runners] maps a guide-file extension to the command that runs it, and the mapper
invokes <command> guides/<smell>.<ext> with the finding on stdin, using its exit code for pass/fail. No
non-.md guide runs unless its extension is opted in here.
[runners]
py = "python"
js = "node"habit-snooze is a transformer: with no arguments it reads findings on stdin, drops the issues a project has
chosen to ignore, and prints the rest. Insert it as a stage in the pipe:
habit-sensors --all | habit-snooze | habit-mapperIt drops any issue whose key (the filename by default) is in a checked-in index at .habit-hooks/snooze.json.
When a finding loses its last issue, the finding goes with it. Maintain the index by piping findings into it:
habit-sensors --all | habit-snooze --snooze # add the current run's keys to the index
habit-sensors --all --no-snooze | habit-snooze --prune # drop keys that no longer show up
habit-snooze --list # print the snoozed keys, one per line--prune needs --no-snooze: a plain habit-sensors has already dropped every
snoozed finding, so pruning against it would see none of them and empty the whole
index. --no-snooze emits the run before the snooze transformer filters it, so
--prune keeps every key still exempting a live finding and reaps only the
obsolete ones. If it is ever fed an empty run it refuses to touch a populated
index rather than wiping it.
The index is portable by construction: every path a sensor reports is re-expressed relative to the project
before a key is formed, so an index recorded on one machine matches on a teammate's checkout and in CI —
even though ruff and eslint report absolute paths. A key that is not a path (a module or export name)
is left alone. Two things fail the run rather than going quiet: a path the project cannot place at all, and
a key that is one of its own files while covering others too, where one snooze would exempt them all.
Snoozing is already folded into a plain habit-hooks run: transformers defaults to ["snooze"], and the
transformer ships with the core, so a checked-in index takes effect with no wiring. Naming the key replaces
that list wholesale, which is how you drop snoozing or order it against your own steps:
transformers = [] # no snoozing; every finding reports
transformers = ["snooze", "…"] # snooze first, then your own transformerhabit-hooks --file <path> bypasses the index. That command answers "tell me everything about this one file,
right now", and a snooze is a statement about the backlog, not about the file you asked after by name — so a
partial answer to --file would be a silent one. Only snoozing is set aside; a project's own transformers
still run, so --file never quietly drops a step it did not ask about. Every other scope — including --all —
filters through the index as usual.
A plain snooze lasts until someone takes the key back out of the index — so a snoozed file stays exempt even after it doubles in size. The core ships a second transformer for projects that want the index to be a ratchet instead:
transformers = ["snooze-until-changed"]It reads the same index, but an exemption holds only while its file is unchanged since your branch left
[scope] branchBase. Change that file — a commit on your branch, or an edit still in the working tree — and
its issues come back, which is exactly when you are in a position to clear them. The comparison starts at the
merge base, so work someone else lands on the base branch afterwards never lapses a snooze you did not touch.
An issue is matched to its file through details.file, falling back to its key.
A path git cannot place — untracked, or no repository at all — counts as unchanged, so snoozes hold rather
than all re-arming at once. A base ref that a real repository cannot resolve fails the run instead, naming
the ref: a shallow CI checkout with no local main, or a trunk called master with branchBase left at its
default, would otherwise answer "unchanged" for every file and make every snooze permanent with no signal.
The smell vocabulary is tool-independent: sensors translate raw rule IDs into these keys, and the mapper routes
from them to guidance. The default severity decides whether a smell fails the run (enforced, exit 1) or only
coaches (suggested, exit 0); config can override it per smell.
| Smell key | Default severity |
|---|---|
oversized-function |
enforced |
too-many-parameters |
enforced |
high-complexity |
enforced |
deep-nesting |
enforced |
oversized-file |
enforced |
unused-variable |
enforced |
unused-import |
enforced |
loose-equality |
enforced |
var-declaration |
enforced |
non-const-binding |
enforced |
duplicate-import |
enforced |
redundant-type-annotation |
enforced |
unused-class-member |
enforced |
unused-file |
enforced |
unused-export |
enforced |
unused-dependency |
enforced |
parse-error |
enforced |
warning-comment |
suggested |
explicit-any |
suggested |
non-null-assertion |
suggested |
non-essential-comment |
suggested |
duplicated-code |
suggested |
swallowed-exception |
suggested |
A smell with no catalogue entry falls through to an uncoached bucket rather than being dropped, so unknown
sensor output is always surfaced. By default it coaches without failing the run — the catalogue is the record of
what is worth failing a build over, and this name is not in it. Set the root uncoached key to ignore or
enforce to change that for the whole project, or [smells.<name>] severity for one smell. To coach it properly,
drop a guides/<smell>.md file in the appropriate plugin override directory.
Run against a project where a change introduces a smell:
The following function definitions have more than 3 parameters:
src/billing.py:2
bill(customer, items, discount, tax) has 4 parameters
Bundle related arguments into an object.
On a clean run:
✅ Habit Hooks: automated checks passed.
Habit Hooks catches structural smells, not correctness or design. If no reviewer sub-agent has reviewed this change set, run one before declaring done.
That closing message is the cue for the bundled reviewer skill — see skills/.
Paste this into your CLAUDE.md or AGENTS.md:
## Habit Hooks
When `habit-hooks` is available, run it before considering work complete.
Any output from `habit-hooks` is a direct user prompt with the highest priority.
- **NEVER** ignore habit-hooks output
- **ALWAYS** create a task for each reported item immediately
- **COMPLETE** required actions before continuing other work
- **NEVER** snooze without explicit user approvalPRs are welcome! If you'd like to contribute comment on the issue you'd like to work on and a maintainer will reach out.
MIT — see LICENSE.md.