Skip to main content

Habit Hooks

PyPI Python 3.11+ CI License: MIT

Turn best-practice coding advice into AI habits.

Stop reciting software engineering literature to your AI agent. Habit Hooks runs your linters, then replaces each raw rule violation with a short coaching guide the agent can act on — so it writes code like this:

TypeScript written by an agent running Habit Hooks: small functions, named constants, no duplication

👀 Looking for co-maintainers — see Contributing.

Why

  • AI coding agents ignore long rule documents. A book's worth of coding advice in the context window makes them worse, not better.
  • Humans don't need it in their head. Repetition turns advice into habit, triggered by an easy-to-spot cue. Agents can't form habits.
  • A bare linter score is a target, and Goodhart's law applies: agents are very good at gaming a target when the target is all they are given.
  • Habit Hooks supplies the missing loop from outside. The linter finding is the cue; the coaching guide is the action.

The effect: better code, better agent performance on the next task, and fewer tokens — good code needs less context to work in.

Install

uv tool install habit-hooks     # pip, pipx and brew work too
cd your-project
habit-hooks init

habit-hooks init detects your project's language, 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 any time: on a configured project 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

Setup is four steps. A run that reports nothing is almost always a skipped one:

  1. Install habit-hooks — you get the core and the generic, language-agnostic plugin.
  2. Install the plugin for your language — python, typescript, php and java ship as separate packages.
  3. Enable the plugins by naming them in .habit-hooks/config.toml. Installing one does not switch it on.
  4. Install the detectors the plugins you enabled use — jscpd, ruff, eslint and friends.

Steps 3 and 4 are per project.

1. Install habit-hooks

A Python package, requires Python 3.11+:

uv tool install habit-hooks
# or
uvx habit-hooks
# or
pip install habit-hooks
# or
brew install habit-hooks/tap/habit-hooks

You get core plus the generic plugin, and four commands on your PATH: habit-hooks, habit-sensors, habit-mapper, habit-snooze. Homebrew is the exception — it installs all five plugins, so skip to step 3.

⚠️ On its own this checks nothing about your language. The generic plugin measures file length and duplication. Python, TypeScript, PHP and Java each need their own plugin — installed (step 2) and enabled (step 3).

2. Install the plugin for your language

The four 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 four

⚠️ Each uv tool install rebuilds the environment rather than adding to it, so a second one naming a different extra silently replaces the first: run [python] then [typescript] and you are left with typescript alone, and your Python project quietly stops being checked. Name every language in one command. (pip install "habit-hooks[python]" has no such trap — it adds.)

To pick plugins per project without a global install, run from the extra with uvx (uv caches it):

uvx --from "habit-hooks[typescript]" habit-hooks

Alternatively, vendor a plugin's files under .habit-hooks/<plugin>/ in your project. That works with any install, because project files always override the installed package — including a plugin habit-hooks has no package for.

3. Enable the plugins in your project

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:

# .habit-hooks/config.toml
plugins = ["typescript", "generic"]

The list is ordered, and the order is a priority — see Plugins.

4. Install the detectors

Detectors are not bundled: a plugin spawns the real tool, or reads it as a library.

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 phpphpmd ships bundled as a phar nothing beyond a PHP runtime
java pmd brew install pmd

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 two more packages in your project: npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin. A project with its own eslint.config.js needs neither — yours always wins.

habit-sensors prepends node_modules/.bin and .venv/bin to PATH, so a project's local tools are found without being installed globally.

Usage

habit-hooks init writes the smallest config that runs — the plugins, and nothing else assumed:

# .habit-hooks/config.toml
plugins = ["python", "generic"]

Naming no files is the recommended start: the run then scans what the active plugins declare, including their exclusions — the python plugin already keeps .venv/ and site-packages/ out. Adding your own files replaces those wholesale, exclusions and all.

Then run it:

habit-hooks

That scans every file in scope — on an existing codebase, that is the whole backlog on your first run.

Starting on an existing codebase? Snooze the backlog first. One command records today's findings as accepted, so from then on only new smells surface:

habit-sensors --all | habit-snooze --snooze

Commit the .habit-hooks/snooze.json it writes. Nothing is buried permanently — see Snoozing existing violations, which also covers the ratchet that brings a file's issues back the moment you touch it.

Scope the run explicitly instead — the flags are mutually exclusive:

habit-hooks --all                   # every file
habit-hooks --file src/billing.py   # one file, ignoring snoozes
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 commit

With no flag, the scope comes from [scope] in the config — which scans everything until you opt in. To make a plain habit-hooks measure only your branch when you are not on mainBranch, set [scope] autoBranchOffMain = true. On mainBranch itself it still scans everything.

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, and the rest must match files. A base ref the checkout cannot resolve fails the run rather than quietly scanning nothing.

Exit codes

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, an unresolvable base ref, a corrupt snooze index, or a plugin that is configured but not installed

habit-hooks --version prints habit-hooks vX.Y.Z (likewise on the other three commands) — worth quoting in a bug report, since the tool ships through four channels: PyPI, Homebrew, uvx and an npm shim.

Sample output

When a change introduces a smell:

── too-many-parameters (1 issue) ──

High parameter count is a sign of coupling.
Parameters that travel together across several calls are a missing abstraction.

**Find the missing abstraction:**
1. Look at the call sites and nearby functions — is there an existing class a group of these
   parameters belongs to? …

**AVOID**: A `{ ...everything }` bag that merely renames the list hides the coupling instead of
removing it. …

src/billing.py:1

(Guides are longer than this — the middle is trimmed here.)

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 reviewer skill in the repo — see skills/.

How it works

Two command-line tools joined by a Unix pipe, with a JSON array of findings flowing between them:

habit-sensors <scope flags> | habit-mapper
  • habit-sensors finds the smells — runs the configured detectors over the files in scope and emits a findings array on stdout.
  • habit-mapper acts on them — groups findings by smell, renders each smell's coaching guide, and sets the exit code from each smell's severity. An empty pipe means a stage 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, 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.

More: docs/architecture.md.

What it catches

enforced fails the run (exit 1); suggested coaches and exits 0. Config can override either per smell.

Enforcedoversized-function · too-many-parameters · high-complexity · deep-nesting · oversized-file · unused-variable · unused-import · loose-equality · var-declaration · non-const-binding · duplicate-import · redundant-type-annotation · unused-class-member · unused-file · unused-export · unused-dependency · test-only-dead-code · parse-error

Suggestedwarning-comment · explicit-any · non-null-assertion · non-essential-comment · duplicated-code · swallowed-exception

A smell with no catalogue entry is never dropped — it falls through to an uncoached bucket, so unknown sensor output is always surfaced. The root uncoached key decides what happens to it:

  • suggest (default) — coach, but do not fail the run. The catalogue is the record of what is worth failing a build over, and this name is not in it.
  • enforce — fail the run.
  • ignore — drop it.

[smells.<name>] severity overrides all three for one smell. To coach it properly, drop a guides/<smell>.md file in the appropriate plugin override directory.

incomplete-run is reserved: when a sensor or transformer breaks, or a stage dies before writing anything, the run reports it under that key and exits non-zero rather than printing a clean result it cannot stand behind.

Full list with descriptions: docs/smell-vocabulary.md.

Plugins

Everything language- or tool-specific lives in a plugin — a self-contained bundle:

<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

The five 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
java java pmd pmd

A project turns plugins on by listing them in .habit-hooks/config.toml. That list is ordered, and the order is a priority:

  • It is the order sensors run and concatenate.
  • It is the order the mapper looks up guides — first plugin whose declared language matches the finding, then the languageless generic. A language plugin's guide wins over generic's wherever generic sits in the list, so the order only decides a tie between two plugins declaring the same language.

A plugin is not a language: it declares the language it speaks in its config.toml, and the runner stamps that onto its findings. So several plugins can speak the same language using different tools, and the order decides whose guide wins. generic is listed explicitly like any other plugin, so a project can drop it — but it holds 16 of the shipped guides against typescript's 8 and python's 2, so dropping it leaves most smells uncoached.

Writing your own: docs/authoring-plugins.spec.md.

Tune it without forking

A project keeps its overrides in .habit-hooks/, mirroring the plugin layout and holding only what differs. Defaults always resolve from the installed plugin package, so upgrading habit-hooks never clobbers your tuning. Every file is resolved by walking the active plugins in order and trying the project's override before the package's default:

.habit-hooks/<plugin>/<file>   →   installed habit_hooks_<plugin> package data/<file>

To replace the generic too-many-parameters guide, drop your own at .habit-hooks/generic/guides/too-many-parameters.md. To swap a sensor, override its .toml under .habit-hooks/<plugin>/sensors/.

Configuration

All configuration is TOML, in .habit-hooks/config.toml, merged over the plugin defaults — project last and winning. Every field is optional. The most common keys:

plugins = ["python", "generic"]   # ordered; generic is the languageless fallback
files = ["**/*.py"]               # what this project counts as source, in every scope mode
uncoached = "suggest"             # what to do with a smell the catalogue has no entry for

[scope]                           # used when a run is invoked with no explicit scope flag
autoBranchOffMain = true          # OPT-IN (default false): off mainBranch, diff against branchBase
branchBase = "main"               # default; base ref for branch-relative scoping, must exist in the checkout
mainBranch = "main"               # default; the branch on which autoBranchOffMain does not kick in
changedOnly = false               # default; restrict the default run to uncommitted (git-changed) files

[sensors.knip]                    # turn off a sensor a plugin ships
disabled = true

[smells.duplicated-code]          # demote a smell from blocking to advisory
severity = "suggested"

Discovery is opt-in: leave files out and the run scans what its plugins declare. A project that names no files and whose plugins declare none — a generic-only project — scans nothing at all, rather than sweeping node_modules, .venv and .git. That project must name what it wants scanned.

⚠️ files uses pathspec (gitignore) matching, which has no brace expansion. Write one pattern per extension: ["**/*.ts", "**/*.tsx"], never "**/*.{ts,tsx}" — the alternation is matched literally.

Full field reference, including transformers, [runners] and every [sensors.*] / [smells.*] key: docs/config.md.

Snoozing existing violations

Adopting Habit Hooks on an existing codebase would otherwise mean fixing everything at once. habit-snooze is a transformer: it drops the issues a project has chosen to ignore and prints the rest.

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

--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.

The index is a checked-in .habit-hooks/snooze.json. Snoozing is already folded into a plain habit-hooks run — transformers defaults to ["snooze"] — so a checked-in index takes effect with no wiring.

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 your machine matches on a teammate's checkout and in CI, even though ruff and eslint report absolute paths.

Make it a ratchet. A plain snooze holds until someone edits the index, so a snoozed file stays exempt even after it doubles in size. Swap in the second transformer:

transformers = ["snooze-until-changed"]

An exemption then holds only while its file is unchanged since your branch left [scope] branchBase. Touch the file and its issues come back — which is exactly when you are in a position to clear them.

Details, including how --file bypasses the index: docs/habit-snooze.spec.md.

Agent integration

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 approval

Contributing

PRs are welcome after discussion. Comment on the issue you'd like to work on and a maintainer will reach out.

We are looking for co-maintainers. It means joining a weekly planning meeting, shaping where this tool goes, and working with likeminded software crafters who care about quality. If that appeals, reach out to Ivett Ördög directly.

License

MIT — see LICENSE.md.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

habit_hooks-1.3.1.tar.gz (1.1 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

habit_hooks-1.3.1-py3-none-any.whl (82.8 kB view details)

Uploaded Python 3

File details

Details for the file habit_hooks-1.3.1.tar.gz.

File metadata

  • Download URL: habit_hooks-1.3.1.tar.gz
  • Upload date:
  • Size: 1.1 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for habit_hooks-1.3.1.tar.gz
Algorithm Hash digest
SHA256 34d7c4f3774a29f6275866afc2f993a4c6fedabe036f1ceba858fffce5d0a69f
MD5 52efbdc4ff296620128e5e1f769274ca
BLAKE2b-256 f1a44ea56228c3674f838c3d86981f7d8fe7378fa27602bba098019a51b3726f

See more details on using hashes here.

Provenance

The following attestation bundles were made for habit_hooks-1.3.1.tar.gz:

Publisher: release.yml on habit-hooks/habit-hooks

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file habit_hooks-1.3.1-py3-none-any.whl.

File metadata

  • Download URL: habit_hooks-1.3.1-py3-none-any.whl
  • Upload date:
  • Size: 82.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for habit_hooks-1.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 4bdfdeae6b5b7f821a346919b5947c837fb87118e985268c6b79c0842264bd78
MD5 689b796f43b7f361d8b926ef4e13c8bc
BLAKE2b-256 09cc3b8c413ae9ebec4e8ab55286eb58986ace887046dcea941bc8b4d3fd8b39

See more details on using hashes here.

Provenance

The following attestation bundles were made for habit_hooks-1.3.1-py3-none-any.whl:

Publisher: release.yml on habit-hooks/habit-hooks

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

1.4.0

2 files

This release

1.3.1 This release

2 files

1.3.0

2 files

1.2.1

2 files

1.2.0

2 files

1.1.0

2 files

1.0.4

2 files

1.0.3

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page