- On Python 3.13 a plugin's
search_pathscould name a directory outside your project. A detector names the directories under the project its tool is looked for in, and an entry that climbs out of it is refused — except a Windows-rooted one like\tools, which stopped being refused when CPython 3.13 changed what counts as a rooted path there. On Windows that entry resolves toC:\tools, so a tool could be found, and spawned, from outside the project; on POSIX it named a directory under the project and was harmless.search_pathsis declared in a plugin'sconfig.toml, which a project can also carry itself under.habit-hooks/<plugin>/. It is refused again, on every interpreter. (#166)
Ruby. A Gemfile or a .rubocop.yml in your project and habit-hooks now
coaches your Ruby the way it already coached your Python and TypeScript.
-
The
rubyplugin.pip install "habit-hooks[ruby]", add"ruby"toplugins, and arubocopsensor translates cop names into the same smell vocabulary every other plugin speaks:too-many-parameters,oversized-function,oversized-block,high-complexity,deep-nesting,unused-variable,swallowed-exceptionandparse-error. All three Metrics complexity cops —CyclomaticComplexity,PerceivedComplexityandAbcSize— coach ashigh-complexity, since a nesting-heavy or call-heavy method can trip one without the others.Your
.rubocop.ymldecides everything. The sensor passes no--onlyand no--config, so RuboCop discovers your config by its own upward walk and your habit-hooks run is the run you get from RuboCop directly. A cop this plugin has no smell for is forwarded under its own name rather than dropped, because a cop only fires if your config switched it on.--force-exclusionis the one flag the sensor adds, soAllCops: Exclude:keeps applying once habit-hooks names files on the command line. -
A detector can name the directories its own tool lives in, through
search_pathsin a plugin'sconfig.toml. The ruby plugin's rubocop detector namesbin, wherebundle binstubswrites binstubs, and for Ruby that matters more than a version difference usually does: a.rubocop.ymlnamingrubocop-railsorrubocop-rspeccannot be read at all by a rubocop outside the bundle those gems live in. It stays off the default search path, which every tool shares, becausebinis a name a project may keep its own scripts under. -
Java reports
deep-nesting. PMD'sAvoidDeeplyNestedIfStmts, pinned toproblemDepth=3in the bundled ruleset, so the third nestedifis the first one reported. A project running its own PMD ruleset gets the smell only when that ruleset enables the rule itself. Contributed by @renanfranca (#160).
-
A wrapped tool that never started no longer reads as a clean file. The
rubocopbinstub begins#!/usr/bin/env ruby, so pointing it at an interpreter it is not installed into makes it die infind_spec_for_exeand exit 1 — the code that otherwise means "I found offences". The sensor judges the report rather than the exit code: RuboCop prints its JSON envelope on every run it completed, so no envelope means no run, and the run fails instead of reporting nothing wrong. -
An uncoached smell no longer coaches in another language. A smell no plugin ships a guide for falls back to the
uncoachedguide, and that fallback was resolved across every active plugin rather than the ones the finding's own language names — so a project running two language plugins could be handed the wrong language's wording for its own file. The fallback now follows the same language-filtered order every other guide does.
Native Windows support, plus everything else the two Windows reporters found on the way — none of which is a Windows bug; all of it reproduces on any platform. Reported by @FrankRaiser (#133, #140, #143) and @imalliaras (#134, #142), neither of whom could get a run to complete when they started.
-
habit-hooks runs on native Windows. Every sensor was spawned through
bash -c, which Windows has not got — and wherebashdoes resolve there it is usuallyC:\Windows\System32\bash.exe, the WSL launcher, which answers about a different filesystem. Nine of the eleven shipped sensors and transformers never needed a shell; the other two only needed one to reachjq. All eleven now spellargvand are spawned directly. -
Nothing decodes in the console's codepage any more. Every guide, config and spec file this tool reads is UTF-8 and now says so. On a cp1252 console the first guide it rendered killed the run with a
UnicodeDecodeError(#133); the same bug hit any non-UTF-8 locale, includingLC_ALL=Con Linux. The streams it writes are UTF-8 too, so a printed block's box-drawing characters cannot fail on the way out. -
A tool installed as
.cmdor.batis found. Windows appends only.exeto a bare command name, sojscpd.CMD,knip.cmdandpmd.batwere cleared at setup and unreachable at run time. One lookup now answers both questions. -
initno longer offers an install it cannot run. Its install command went throughshell=True, which on Windows iscmd.exe, andcmd.exedoes not understand the quotes inuv tool install 'habit-hooks[python]'. -
A wedged sensor still says why. Windows raises its timeout carrying no output at all, so the notice quoted back nothing where the tool had explained itself.
-
A mistyped
argvin a sensor spec answers with a sentence.argv = "ruff"is a string, and a string is iterable: it spawned four one-character arguments and the run reported needing thercommand. A non-string element raised a traceback. Both are now refused by name at exit 2 — the code for a broken tool — instead of exit 1, the code reserved for a finding to fix. -
A sensor no longer dies without a word when its tool has a lot to say. Node caps a captured stream at 1 MB unless told otherwise, and the eslint sensor never told it otherwise: past that, the spawn came back with no exit status, a truncated report and an empty stderr, and the sensor forwarded the empty stderr. The whole notice was a command line. Around 100 files with findings is enough to cross it, on any platform (#142). What a project tool's run answered — how much of it there may be, whether it broke, and what to say about it — is now one seam's question rather than each caller's, so the two callers cannot answer it differently again. A complaint can no longer be empty.
-
--allno longer scans what git ignores. Every git-derived mode asks git and so had never seen a build artifact; a whole-project run walked the directory tree and measured everything it found. One project, two universes: a real pnpm monorepo held 321 tracked.ts/.tsxfiles and 843 on disk, and its owner had to hand-write!**/dist/**into[files]before a run was usable (#142). A project git cannot answer for — outside a repository, on a machine with no git, or one that is itself ignored by the repository around it — still measures what is on disk, because a run that silently scans nothing is worse than one that over-scans. A submodule's files leave the scope, as they always had from every git-derived mode, and the run now says which submodule rather than quietly measuring less. -
A scan that measured nothing says so. A
[files]that matches no file — because it is too narrow, or because git ignores the tree it was written for — used to render the clean guide over a run that had read nothing at all. -
A smell is coached once, not once per sensor that saw it. Two sensors can report one smell — eslint's
max-linesand the line counter both meanoversized-file— and the mapper printed a block per finding, so the same ~200-word guide arrived twice (#140). Findings that resolve to the same guide are now one finding. A sensor's own issue list is never thinned: two unused variables declared on one line are still two. -
habit-hooks no longer reports its own tools as unused dependencies. It asks a project to install
eslint,knip,ts-morph,jscpdand the two@typescript-eslintpackages, and then reported all of them as declared and imported nowhere (#143). The shipped knip config overlooks them. A project with its own knip config is unaffected, as it is for every other setting. -
A failing tool's own words are bounded within a line, not only across lines. The notice quoted back at most 20 lines and any number of bytes, and
eslint -f jsonis one line however many megabytes it holds — so a sensor that died mid-report put the whole report into a coding agent's context. Both ends of a long line now survive, with the middle named. -
A
[runners]command nobody installed answers in one line. Routing a smell to an executable guide instead of a Markdown one, and naming a command that is not there, printed a Python stack trace — the same first-contact mistake #114 answered everywhere else, in the one place that sweep did not reach. -
The knip sensor reports a column under the name the contract gives it. knip spells it
col; every other sensor anddocs/sensor-interface.spec.mdspell itcolumn, so the position was invisible to anything asking by name.
-
jqis no longer a dependency of any plugin. Theruffandeslintsensors piped their tool through it; both are now helpers beside their spec, like every other sensor here. The only install hint we could offer wasbrew install jq. -
A sensor spec may spell
argv = [...]. It is spawned as written, with no shell, and it is the only form that runs on native Windows.command = "..."remains for a plugin needing syntax a list cannot carry, and is refused off POSIX rather than spawned into a shell that is not there. -
A sensor spec may name the tool it wraps.
${detector:<name>}expands to the file this project runs for a tool the plugin declared in itsdetectors, so the sensor is handed a path rather than a name something has to look up again. Only the first word of a spawn was ever resolved, and no shipped sensor spells its wrapped tool there — each is a helper that spawnsruff,deptry,jscpd,pmdorphpone process further in, which is exactly where a.cmdor.batshim went unfound. A declared tool that is not installed is now answered before the sensor is spawned, so the notice naming it no longer depends on each plugin's helper producing that answer itself. -
eslintandknipare found in the project, not onPATH. They are resolved fromnode_modulesand run through Node directly, so a globally installed copy is no longer used — the shipped eslint config could never have worked with one, since it resolves its plugins from the project.initnamesnpm install --save-dev eslint knipwhen they are missing. -
An argument that
cmd.exewould read as syntax is refused. A.bator.cmdis run throughcmd.exe, and sensor arguments are filenames out of whatever branch is checked out. The question is asked of every program those arguments reach, the tool a sensor's helper spawns included — which is wherepmd.batand npm'sjscpd.CMDactually are.
- The executable documentation under
docs/is POSIX shell and is skipped on Windows (#137). The tool's own test suite runs there in full.
Discoverability and documentation. No behaviour changes.
-
Every package now has a PyPI page. All six shipped with an empty one: no README, no keywords, no classifiers, no project links, no license. Each plugin gains a README of its own, and the core's README is now its long description.
-
The README says what the tool actually does. It had been wrong about six things, five of them for several releases:
- A plain
habit-hooksrun scans every file, not your branch's changes.[scope] autoBranchOffMaindefaults tofalse, andinitwrites no[scope]block. initwritespluginsand nothing else. The config the README showed addedfiles = ["**/*.py"], which replaces the python plugin's exclusions wholesale — so--allthen walked the reader's.venv.- A language plugin's guide wins over
generic's wherevergenericsits inplugins, so ordering it last was never load-bearing. - The override chain resolves to installed package data, not to a sibling
plugins/directory. - The sample output was invented. It is now a real run.
test-only-dead-codewas missing from the enforced list.
- A plain
-
Onboarding says how to start on a codebase that already has smells.
habit-sensors --all | habit-snooze --snoozenow appears where the first run happens, rather than two hundred lines below it. -
docs/config.md's[scope]table gains a Default column. Both its examples setautoBranchOffMain = truewhile the default isfalse, and neither said so.
Java. pom.xml or build.gradle in your project and habit-hooks now coaches
your Java the way it already coached your Python and TypeScript.
-
The
javaplugin.pip install "habit-hooks[java]", add"java"toplugins, and apmdsensor translates PMD 7's rule names into the same smell vocabulary every other plugin speaks:too-many-parameters,high-complexity,oversized-function,unused-variable,unused-importandswallowed-exception. PMD exits 4 when it finds violations and 5 when it cannot parse a file, so the sensor tells those apart — a crashed PMD fails the run rather than reporting clean.Your own PMD ruleset wins. PMD never looks one up by itself, so the sensor does: a
--rulesetsyou pass through[sensors.pmd] argsfirst, then the conventional places a Java project keeps one (src/main/resources/pmd/ruleset.xml,pmd/ruleset.xml,ruleset.xml,pmd.xml). The ruleset the plugin ships is only the answer to "this project has none". Any other PMD flag —--aux-classpath,--minimum-priority— goes throughargsto PMD untouched.Generated code is left alone: Maven's
target/and Gradle'sbuild/are excluded from what the plugin calls source.habit-hooks initrecognises a Java project and namespmdamong the tools to install.With thanks to @pfichtner, who wrote it.
- The
too-many-parametersguide names the concept, not the bag. The default fix for a long parameter list is an options object named after the function that takes it — the report clears and the missing abstraction stays unnamed. The guide now says to search wider than the file that fired, because values that keep appearing side by side are usually one of the domain's own nouns; to use that entity everywhere it fits, since a call passing three of its fields is the same concept under the threshold; and that aFooPropsis the{ ...everything }bag under another name.
-
A Python project that declares no dependencies could never get a clean run. With no
pyproject.tomland norequirements.txtthere is nothing for deptry to check, and it says so by aborting — which the sensor read as a broken tool. Every run came back as an incomplete one, and no change to the code could make it green. A project that declares no dependencies has no unused ones, so the sensor now reports nothing and the run completes. Every other way deptry can fail still fails loudly. -
A broken tool was quoted back by the half of its output with nothing in it. Past twenty lines, only the first twenty were shown — but a Python traceback names what went wrong on its last line, so every sensor that wraps a tool in Python answered a crash with framework internals and never the error itself. Both ends are quoted now and the middle is counted, so the diagnosis survives whichever end it landed on. The incomplete-run message no longer promises a fuller diagnosis on stderr either, which was never there.
- Upgrading with
pipleft the plugins behind.pip install -U habit-hooksupgrades a dependency only when the new core stops being satisfied by the installed one, and the core accepted any 1.x plugin — so an upgrade to 1.2.0 gave you the new core with 1.1.0 plugins, and none of that release's fixes, nearly all of which are in the plugins. Each plugin is now floored at the core's own minor, and a test keeps the floor moving with it.uv pip install --upgrade,uv tool upgradeandbrew upgradewere never affected; if you upgraded withpipalready, upgrading again is enough.
Everything 10BC8 here is about the first ten minutes. Installing habit-hooks was the most frequent question the project got, and working through one user's report found that three of the steps they had to figure out for themselves were our bugs, not their setup.
-
habit-hooks init. It detects the language your project is written in, writes.habit-hooks/config.tomlenabling the plugins it needs, and lists what is still missing — the plugins themselves, and the tools they reach for — beside the command that installs each, offering to run them. The install command it prints matches how habit-hooks was installed, so a plugin cannot land in some other Python than the one habit-hooks runs from.Run it again on a project that already has a config and it changes nothing, only reporting what is missing — so the same command answers "why is this run not reporting anything?". For a language habit-hooks has no plugin for, it prints the prompt to hand to your coding agent to build one.
-
A plugin declares the tools it needs and how to install them. Each plugin's config now carries a
detectorslist — the tool's name, whether it is a command onPATHor a package read as a library, and the command that installs it. A third-party plugin gets this without the core learning its vocabulary. Writing it down found two requirements nothing had ever stated: the typescript plugin needsnodeitself, and the python plugin needsjq, which its ruff sensor has always piped through.
The first ten minutes
-
The typescript plugin's comment sensor now looks for
ts-morphin your project rather than beside itself, so it no longer dies with a Node stack trace on every install that puts habit-hooks outside your repository —pip,uv tooland Homebrew alike. A project that has not installedts-morphis told to, in one line, instead of being shown the module loader's. -
ts-morphis listed among the typescript plugin's detectors in the README, which never mentioned it. -
A TypeScript project with no eslint config of its own is linted with the config habit-hooks ships — which needs typescript-eslint in the project, something neither the README nor the error said. The config now names itself and what to install instead of failing with a module-loader stack trace, and the README lists it.
-
A
knipnobody installed is named the wayjscpd,deptryandphpalready were — "install it, or disable the sensor" — instead ofError: spawnSync knip ENOENT. It was the one detector spawned from Node, and the only one still missing that answer. -
A plugin's declared
filesreached into your dependencies. A project that names nofilesof its own scans what its plugins declare, andhabit-hooks initwrites exactly such a config — so a bare**/*.tsmade the first run report onnode_modules. On a small TypeScript project that was 23,573 findings in other people's packages hiding the eight that were the project's own. The typescript plugin now excludesnode_modules(nested copies in a monorepo too), python excludessite-packagesand a virtualenv under either of the two names one usually goes by, and php excludesvendor. An exclusion holds for every active plugin, so a project running two languages cannot have one plugin's globs hand back what another just excluded. -
Every
pluginsexample listedgenericfirst, which is the wrong way round: the list is a lookup priority andgenericis the fallback, so listing it first makes a language plugin's own guides unreachable — the python plugin shipshigh-complexityandswallowed-exceptionguides exactly because those want a Python answer, and neither could ever fire. The prose always said "falling back togenericlast"; only the examples disagreed.habit-hooks initwrites the right order. -
Naming two
uv tool installextras one after another loses the first. Each one rebuilds the environment rather than adding to it, so following the README's[python]line and then its[typescript]line left typescript alone — and a Python project quietly stopped being checked. The README now names them in one command and says why.piphas no such trap.
- Install leads with
habit-hooks init; the four manual steps — install, add your language's plugin, enable it, install the detectors — are still there for anyone who wants them, because skipping one is the usual reason a first run reports nothing. Every detector is listed with the command that installs it, php included.
Nearly every fix here is the same bug in a different place: something failed, and the check still reported green.
- A filename could execute arbitrary code (also released as 1.0.4). Scoped
paths were substituted into a sensor's shell command unquoted, so a file named
src/a$(...).pyhad its contents run — on any machine that checked it, including CI on a fork's pull request. Paths are now quoted. The same fix ends two silent failures: a path containing a space was never scanned, and a path likereport(1).pykilled its sensor and dropped every finding it had.
Each of these changes what your next run reports.
- Snoozing is on by default. A checked-in
.habit-hooks/snooze.jsonnow takes effect with no wiring. Previously the documentedtransformers = ["snooze"]failed outright, so nobody had a working index. Opt out withtransformers = []. - Re-snooze once, then run
habit-snooze --prune. Keys recorded fromruff,eslintorcommentwere absolute paths and never matched on a teammate's machine or in CI. They are now repo-relative. - A run that scanned nothing, or whose tool crashed, now fails. If CI was
passing on a bad
[scope] branchBase, a shallow clone, or a sensor that could not start, it will now say so. [files]applies to every mode, not only--all, so git-scoped runs see fewer files.- The TypeScript plugin works out of the box, and may turn a green build red. It ships configs for eslint and knip and never used them — eslint failed to start, knip ran on its own defaults. Both now use the shipped config when your project has none of its own, so a project that was quietly checking nothing will start reporting real findings (#113, #120).
- jscpd obeys your own
.jscpd.json. Ours was passed unconditionally and silently won; your thresholds now apply (#125). - A smell we have no coaching for no longer fails the build. It is reported
without blocking.
uncoached = "enforce"restores the old behaviour,uncoached = "ignore"drops them entirely (#111). - knip reports fewer issue types. Types the plugin cannot translate into a
smell —
binaries,duplicates,catalog— are dropped instead of arriving under knip's own name with no guidance, so snooze entries for them go stale. Three that were being lost now report properly: unused types, namespace exports and enum members (#111, #124).
snooze-until-changed, an opt-in transformer that turns the index into a ratchet: an exemption holds only while its file is unchanged against[scope] branchBase, so debt stays exempt until you are editing the file anyway. The defaultsnoozeis unchanged (#80).uncoached, deciding what happens to a smell the catalogue does not name:suggest(the default),ignore, orenforce(#111).- Plugin-declared
filesdefaults are read at last, so a project that names nofilesof its own no longer scansnode_modules(#81).
Checks that reported green while broken
- A failing transformer no longer discards every finding and reports a pass (#84).
- A sensor that crashed with no output is no longer indistinguishable from one that found nothing (#78).
habit-hooksno longer reports success when its first stage failed, and an empty result is treated as an incomplete run rather than a clean one.- jscpd's own failure is no longer a clean run — including in this repo, where it turned out never to have run at all.
The first ten minutes
habit-hooks --helpprints usage instead of crashing on its own output (#114).- A malformed
.habit-hooks/config.tomlgets a clear refusal and exits 2, the code meaning "the tool failed", rather than 1, which told CI your code had a smell (#114). - A tool you have not installed is named, with the sensor that wanted it and how
to switch that sensor off —
jscpd,deptryandphpalike (#114). - Installing a plugin does not enable it, so the hint now names the config line to add instead of repeating the install command you just ran.
TypeScript plugin
- It works in a project declaring
"type": "module", the default for a new TypeScript project. Two of its three sensors used to die on their first line (#112). - The shipped eslint config had its unused-variable rules the wrong way round, reporting an interface's method parameters as unused when removing them is not valid TypeScript (#113).
- The
commentandknipsensors no longer truncate output over ~64KB into invalid JSON (#82). - The eslint sensor no longer dies when a single ignored file is in scope (#83).
Settings that did nothing
[sensors.<name>] argsreaches the tool. It was the documented way to pass your own arguments and worked for one of the eight shipped sensors; the rest accepted it and dropped it. Setting it on a sensor that still cannot take arguments now stops the run instead of being ignored.- A project's own transformer can read the config again. A required argument had broken every caller outside this repository, and one project's ratchet silently re-published every finding it had snoozed (#109).
Findings and scope
- Paths in findings are anchored to the project, so a snooze index is portable between a checkout and CI (#79).
- Deleted files no longer reach the sensors, and
[files]narrows every mode, so a lockfile bump stops reporting as an oversized file (#81). - A base ref your repository does not have is an error, not an empty diff that reported everything clean.
- Scope and snooze measure from the same merge base, so work someone else lands on the base branch is neither scanned as yours nor able to lapse your snoozes.
--fileaccepts absolute paths, and says so when the named file falls outside[files].- A failing tool's output is bounded, so a sensor dying mid-warning-storm cannot flood a coding agent's context.
- CI runs
habit-hooksagainst its own source and installs jscpd at the project root; the duplication check had never run against this repo. - Merge-base and
git diffplumbing is shared by the scope and snooze paths instead of implemented twice, where the copies had already drifted. pathspec's deprecatedgitwildmatchreplaced withgitignore, verified to select identically across every shipped pattern set.
- TypeScript plugin: the bundled
eslint.config.mjs/knip.jsondefaults no longer exempt test files (*.test.ts/*.spec.ts/tests/**) from size and complexity rules — test code is now held to the same thresholds as production code. knipentry/projectalso broadened to cover.tsxand.spec.*files (#75). - Generic plugin: the bundled
.jscpd.jsonno longer ignores*.test.ts, so duplication inside test files is now detected (#75).
- The core mapper builds the Jinja environment once per finding render instead of once per markdown template.
- The repo dogfoods the Python plugin's recommended ruff structural thresholds (
C901/PLR0913/PLR0915, complexity 10 / max-args 3) and enforces them in CI.
- The core config loader uses
attrsinstead ofpydantic, dropping the compiledpydantic-core(Rust) dependency so the core is pure Python — enabling fast, Rust-free Homebrew bottles.
- Bundled Python sensors (
line-count,jscpd,deptry,phpmd) now invoke the interpreter via the new${python}placeholder (sys.executable) instead of a barepython, so they run on environments withoutpythononPATH(stock macOS, clean CI, Homebrew installs).
- The npm
habit-hookspackage is now a deprecation shim pointing at the PyPI / Homebrew distributions. - Added the Homebrew install path (
habit-hooks/tap/habit-hooks); GitHub Actions pinned to Node 24-native versions.
- Default install is now core + generic; the four language plugins (
generic/python/typescript/php) are installable dists discovered via thehabit_hooks.pluginsentry-point group. Language plugins beyond generic install as extras. - All workspace packages publish to PyPI via trusted publishing, each in its own per-package GitHub environment, on a
v*tag.
- Consumer-defined sensors (#16): the
sensorsconfig map is now the single way sensors are assembled — built-in and custom alike. Each entry is one of three mutually exclusive modes:use(reference a bundled sensor by id —eslint/comment/jscpd/knip/ruff/deptry/line-count/needs-extraction), a wrapper script (command+producesprinting bag JSON), or a declarative adapter (command+produces+items/fields/group/map). The sensor id is the map key;dependsOnwires multi sensors. Seedocs/sensors.md. - Authoritative
sensorssemantics: whensensorsis present it replaces the language preset entirely (no merge), so removing a built-in is just deleting its entry. Whensensorsis absent the preset is used and a deprecation warning is emitted — this implicit fallback is removed in the 1.0.0 release. - Consumer-defined languages (#16):
languageaccepts any string. The built-ins (typescript/python) keep their preset + default file globs; any other value relies on the openfilesdiscovery globs plus asensorsmap. A non-built-in language with nofilesemits a warning and discovers no source files. - A custom sensor must be declared as a pair — its
sensors.<id>entry and a matchingsmells.<smell>entry (a custom smell needsid+source: "custom"+severity). New config validation rejects malformed sensor specs (mode mixing, missing required fields) and badfilesglobs.
- Habit Hooks is now a smell-agnostic, config-driven coach: a three-stage pipeline (sensor → mapper → guide) connected by a JSON bag. Sensors detect findings and translate them into a canonical, tool-independent smell vocabulary; the mapper routes each smell to a fix; the guide coaches the agent and sets the exit code.
- Two language presets ship out of the box — TypeScript/JavaScript (ESLint + knip + jscpd + a ts-morph comment scan) and Python (ruff + jscpd + deptry + a line-count sensor). No sensors run by default;
initenables the preset for the project's language. - The smell catalogue, language presets, and per-language tool config drive behaviour. Concrete smell knowledge lives only in config and the language initializers — the runner, mapper, sensors, and checks are smell-agnostic.
habit-hooksruns the configured sensors over a project, groups findings by smell, prints each smell's coaching, and sets the process exit code — non-zero when an enforced smell fires, zero on a clean run or suggested-only findings.- Git-aware scope flags restrict a run to a change set:
--last <n>(files changed in the last N commits),--branch [name](vs a branch, defaultscope.branchBase),--since <hash>(since a commit), and--all(force every file). The four are mutually exclusive; the default scope and per-rulechangedFilesOnlycome from config.--config <path>points at an explicit config file;--versionprints the version. habit-hooks.config.{ts,js,mjs}is intentionally small: per-smell/per-ruleinclude/excludeglobs,severityoverrides,disabled, andchangedFilesOnly; ascopeblock (onlyChangedFiles,branchBase); apromptsdirectory for custom/override coaching text; andcommentCheckthresholds. All tool thresholds stay in the consumer's own eslint/knip/jscpd/ruff config.
- Habit Hooks drives the consumer's own installed eslint / knip / jscpd, surfacing whatever rules and thresholds those configs define; it falls back to the bundled binaries only when the project has none. The coaching layer (why-it's-a-smell + how-to-fix) is what Habit Hooks adds on top.
- The bundled coaching prompts for the size/complexity smells are adapted from the refakts refactoring-quality system — keeping its analyse-first / anti-mechanical-fix structure — with the remaining prompts explaining why each smell matters rather than restating the threshold.
- knip 5 and 6 are both supported: the consumer's installed major version is auto-detected so v5's
classMembersand v6's per-issuefiles/exports/dependenciesshapes are each read correctly, and v6 no longer loses every knip check over a rejected flag.
- A canonical, tool-independent catalogue (kebab-case keys, see
docs/smell-vocabulary.md).enforcedsmells fail the run (exit 1);suggestedsmells coach but exit 0; the mapper config can override severity per project. - Enforced size/complexity smells:
oversized-function,too-many-parameters,high-complexity,deep-nesting,oversized-file. - Enforced correctness smells:
unused-variable,loose-equality,var-declaration,non-const-binding,duplicate-import,redundant-type-annotation, and the unused-code family from knip/deptry (unused-file,unused-export,unused-dependency,unused-class-member,unused-import). - Suggested smells:
warning-comment,explicit-any,non-null-assertion,non-essential-comment,duplicated-code. needs-extraction(enforced) is a composite smell;parse-error(enforced) is a supplemental smell for ESLint fatals with no catalogue rule.- Each sensor owns its raw rule ID → smell translation; the mapper and prompts only ever key off the canonical smell, never the tool.
- TypeScript/JavaScript preset: ESLint (size/complexity/correctness/TS smells +
parse-error), knip (unused-file/unused-export/unused-dependency/unused-class-member), jscpd (duplicated-code), and an in-process ts-morph scan (non-essential-comment).comment-checkstill runs in-process via ts-morph — it is not a shell-out sensor. - Python preset: ruff (
high-complexity/too-many-parameters/oversized-function/unused-variable/unused-import), jscpd on.py(duplicated-code), deptry (unused-dependency), and a language-agnostic line-count sensor (oversized-file). - Preset thresholds come from the consumer's own tool config (e.g. ESLint
max-lines/complexity, ruffmccabe.max-complexity/pylint.max-args), not from Habit Hooks. - Composite sensors via
dependsOn(#17): a multi sensor declares the smells it consumes, receives their issues inctx.deps, and emits a derived smell.needs-extractionfires when one file is bothoversized-fileandduplicated-code. It augments by default (all three smells show);needsExtraction.replace: truesuppresses the two inputs so onlyneeds-extractionremains. The augment-vs-replace switch runs in the sensor stage, keeping the mapper a pure single-smell function. Wired into the TS preset and the Python preset. - deep-nesting (#26): new enforced TS smell via ESLint
max-depth. Pythondeep-nesting(ruffPLR1702) is deferred while that rule is preview/unstable. - Python
oversized-file(#19): a language-agnostic line-count sensor emits it for files over a threshold (max-module-lines, default 200). ruff has noC0302port and rejects an unknownmax-module-lineskey under[tool.ruff], so the threshold is read by a no-TOML-parser text scan of the consumer's ruff config +pyproject.toml; set it in a ruff-ignored location such as[tool.habit-hooks]. commandfix action (#18): a smell's fix can be a script instead of a prompt. The guide runs the command once per smell that has issues, streams its output into that smell's section, and folds its exit code into the run's exit code.- Declarative adapter: a tool that already emits JSON can be wired as a sensor by declaring how to read it (
group/items/fields/map, up to two levels of array nesting) — no wrapper script needed. Anything it can't express falls back to a wrapper script. - Sensor failures fail the run (#25): a sensor spawn/timeout failure now exits 1 (instead of a false-clean) while still rendering every successful sensor's output. Failures travel on a shared
SensorSink; the failure notice is shown on stderr.
- A file-level baseline (snooze) is committed to the repo at
.habit-hooks-baseline.json, so a whole team shares one snapshot of legacy violations. A snoozed file stays snoozed for every sensor only while it appears in the baseline, its last-commit hash matches, and its working tree is clean — touching the file re-arms every smell, so you cannot silently drift past snoozed violations. habit-hooks baselinesubcommands manage it:generate(record current violations),status(list snoozed files and freshness),snooze <files...>,forget <files...>, andprune(drop stale/resolved entries).- Auto-prune of dead snooze entries (#11): on a full-repo run, Habit Hooks re-scans baseline-free and reaps snooze entries whose file is present but no longer produces the smell, printing the pruned set. Scoped runs never mutate the baseline (a file can look clean only because its smell is outside the diff), so they are a guaranteed no-op. Auto-prune shares one reaper with the manual
baseline prunecommand. - A memoized, batched snooze index collapses the per-rule git spawns to O(1) status + O(files) memoized log calls.
habit-hooks init [language]onboards a project for its language. With no argument it detects the language and prints a report-only message; an explicit language threads through with no re-detect, and an unsupported language exits 2 before any side effect.- Detects which tools are already installed/configured and scaffolds starter configs only for the missing pieces: an ESLint flat config (TS) or
ruff.toml+.jscpd.json(Python), with package-manager install commands spanning pip and node ecosystems. - The scaffolded ESLint config writes tunable thresholds including
max-depth: 4(deep-nesting) alongside the other size/complexity rules, and exempts test files from size rules. Test globs derive from a single shared exclude list. - Recommended thresholds mirror across languages from one source (ESLint
complexity 10/max-params 3↔ ruffmccabe.max-complexity 10/pylint.max-args 3); a freshly-scaffolded config is pinned to satisfy the drift check. - Drift detection is additive — a recommended value is flagged only when its key is absent, never when you've tuned it.
--accept-recommendationsruns the install commands and additively merges absent recommended keys into Habit-Hooks-owned config, never overwriting user values or editing user-ownedruff.toml/pyproject.toml/ESLint config. - Prompts cover package.json scripts, a pre-commit hook, and the bundled
habit-hooks-reviewskill.--dry-runprints every intended write without touching disk.
- Config-driven, smell-agnostic (#24): all tool/smell knowledge lives in
src/config/tool-smells.ts— the ESLint raw→smell map, the eslint/knip/jscpd/commentproduces, and the ruff + deptry adapter specs. ESLint/jscpd/comment data is derived from the catalogue, so adding a smell there auto-wires its translation and produces; the runner, sensors, checks, and rules registry import these instead of hardcoding any smell id.deep-nesting(#26) was added by touching only the catalogue and the init ESLint template — the live proof of #24. - Single source for tool config discovery:
TOOL_CONFIG_FILENAMES/TOOL_PACKAGE_JSON_KEYSinsrc/detect/tool.ts. - A routed smell with no tuned
<smell>.mdtemplate falls back to a genericuncoached.mdbody while keeping its severity; a truly unknown smell (no routing at all) goes to the uncoached bucket and never escalates the exit code.
- The bundled "default rule set" and programmatic tool pinning of the beta are gone. Behaviour is driven by the smell catalogue plus the consumer's own tool configs;
knipis no longer version-pinned, so the consumer's installed version determines available issue types. - The
rulesconfig field is deprecated in favour ofsmells(#21).rulesis still accepted and folded in (withsmellswinning on conflict), but a config using it now emits a deprecation warning on stderr. Hard removal ofrulesis scheduled for a release after 0.2.0.