@hyuga/habit
v0.4.5
Published
Tells your coding agent when you edited what it wrote, so it stops silently reverting your fixes. Claude Code hooks, no daemon, no network. The accumulated edits distill into rules, the rules are handed back at session start, and the ledger says whether t
Maintainers
Readme
habit
習い — when you fix something your coding agent wrote, the agent has no idea. Next time it
writes that file, it writes its version again and your fix is gone. habit closes that loop.
habit: report.md is not what you last wrote (2026-07-31T03:06:27Z).
Nothing you did through a tool accounts for the difference, so it came from
outside this agent — most likely the user, by hand.
What you wrote → what is there now:
- ## 🎉 Great news everyone
- We're thrilled to share some amazing results!
+ ## Monthly figures
+ Traffic rose 12% month over month. The breakdown follows.
That edit was deliberate. Read it before writing, and do not quietly revert it.
If you believe it should be undone, say why and ask first.That text reaches the agent before it writes, rather than after — which is the whole difference between a warning it can act on and a report of what it already did.
It does not stop the write. habit returns context, not a permission decision; it never denies the tool call. Whether the revert happens is still up to the model, and sometimes it will revert anyway. What changes is that it can no longer do so without knowing — the diff and the instruction are in front of it at the moment it decides. Until 0.4.0 this paragraph said "so the revert never happens", which was a claim about a mechanism that does not exist.
Corrections come in two shapes
Some people fix the file themselves. Many never touch it — they say "no, drop the emoji" and let the agent do the editing. No hand edit ever happens, but a correction certainly did. habit catches both.
| What you did | How it is detected | |---|---| | edited the file yourself | the file no longer matches what the agent last wrote, and no tool call of the agent's accounts for it | | told the agent to change it | the agent rewrote its own output across a turn boundary |
That second clause on the first row is load-bearing, and until 0.4.0 it was not
there. An agent edits files by routes that are not Write — sed -i, a heredoc, a
formatter, a codemod, a subagent — and every one of those left the record stale, so
the next write was told the user reached in by hand. The diff was then filed as a
correction, and corrections become rules: the agent's own shell command came back in
a later session as a preference you never expressed. The hook sync entry in the
install block below is what closes it, by re-reading tracked files after any tool
runs. It is not optional.
The second is the stronger signal: it comes with your reason, in your own words. The turn
boundary is structural, not guessed — a rewrite under the same prompt_id is the agent still
working; a rewrite under a new one means you spoke in between.
{
"kind": "instructed",
"askedFor": "drop the emoji, and give the figure instead of an adjective",
"removed": ["## 🎉 Great news everyone"],
"added": ["## Monthly figures"]
}instructed also catches you asking for the next thing. "You spoke in between" is a fact
about turns, not about what you said, so a session that keeps working on one file across several
of your messages files a correction at each boundary — and being asked to carry on is not being
corrected. Measured on one machine: of 15 corrections, 6 were this, and their askedFor was a
path to a file the user had pasted in. Distillation is where they get dropped, on the rule that
your words beat the diff: a sentence that does not ask for a change is not evidence of a habit,
whatever the diff next to it shows.
This is deliberately not filtered at recording time. The tests that would do it — a short message, one with no imperative in it — throw away real corrections too, because "shorter" and "no, the other one" are how people actually correct things. A diluted pile that a reader can sort beats a clean one with your corrections quietly missing from it.
How it works
Hooks only. No daemon, no watcher, no LLM in the hot path.
| Hook | When | What it does |
|---|---|---|
| PostToolUse on Write/Edit | right after the agent writes | records hash and contents; notices a cross-turn rewrite of its own output |
| PostToolUse on everything else | after any other tool | re-reads the tracked files. A change here belongs to the agent, so it never gets reported back as yours |
| PreToolUse on Write/Edit | right before it writes again | compares against disk. Different, and no tool of the agent's explains it? You edited it. Show the diff. |
| PermissionDenied | a call was blocked | records what was refused (program name only, never arguments) |
| PostToolUseFailure | a call failed | records the shape of the failure |
| SubagentStart | a subagent spawns | hands it what has been learned, so it doesn't repeat what you already corrected |
| SessionStart | a session begins | hands the same thing to the main agent — the one that actually writes your files |
The comparison itself is microseconds. Measured end to end it is about 18 ms per write, and
essentially all of that is one git check-ignore subprocess — the check that keeps git-ignored
files from being stored. HABIT_HASH_ONLY=1 skips it (no bodies are kept at all, so there is
nothing to decide) and brings the hook back under a microsecond.
It works on any file the agent writes — source, Markdown, HTML, CSV, config. The problem isn't specific to code, and neither is this.
Install
npm i -g @hyuga/habitThe package is scoped; habit is only the bin name. Bare npx habit looks up a
package that does not exist and 404s — npm refuses the unscoped name as too close
to an existing one.
Then add the hooks to your Claude Code settings.json. The first three are the product; the
rest are what makes it learn rather than only warn:
{
"hooks": {
"PostToolUse": [{ "matcher": "Write|Edit", "hooks": [
{ "type": "command", "command": "npx @hyuga/habit hook post", "timeout": 10 }]},
{ "hooks": [
{ "type": "command", "command": "npx @hyuga/habit hook sync", "timeout": 10 }]}],
"PreToolUse": [{ "matcher": "Write|Edit", "hooks": [
{ "type": "command", "command": "npx @hyuga/habit hook pre", "timeout": 10 }]}],
"SessionStart": [{ "hooks": [
{ "type": "command", "command": "npx @hyuga/habit hook session", "timeout": 10 }]}],
"SubagentStart": [{ "hooks": [
{ "type": "command", "command": "npx @hyuga/habit hook subagent", "timeout": 10 }]}],
"PermissionDenied": [{ "hooks": [
{ "type": "command", "command": "npx @hyuga/habit hook denied", "timeout": 10 }]}],
"PostToolUseFailure": [{ "hooks": [
{ "type": "command", "command": "npx @hyuga/habit hook failed", "timeout": 10 }]}]
}
}Every hook always exits 0. If habit breaks, your editing session does not.
What it keeps, and where
Everything stays on your machine, under ~/.claude/habit/ (habit where prints the path).
Nothing here makes a network call — not the hooks, not the learning half, not to Anthropic, not anywhere. You can check that yourself, and it is worth checking before you let anything watch your files:
src/habit.mjsimports only Node built-ins (fs,path,crypto,os,url,child_process) and has zero runtime dependencies- the one subprocess it starts is
git check-ignore, which reads and returns an exit code - there is no
fetch, no HTTP client, and no API key anywhere in the package — not in the hooks, not in the learning half. Grep for it.
To show a diff, the previous contents have to be kept. Some files should never be kept, so they aren't:
- anything matching
.env*,*.pem,*.key,id_rsa*,.npmrc,.netrc, or a path containingsecret/credential/password/token/apikey - anything
git check-ignoresays is ignored — usually local config and build output - anything over 512 KB
Those files are still watched: the hash is recorded, so an edit is still detected. You just
get "the contents were not kept, read the file before writing over it" instead of a diff. Set
HABIT_HASH_ONLY=1 to get that behaviour for every file.
Two things habit keeps are not files
Those rules judge a path, which covers a file named for a credential and nothing else. But habit also stores the sentence you typed (taken from the transcript, so a rule can cite the reason in your own words) and the text of a failed call. Paste a key into the chat and no path rule ever sees it.
So both go through a second check that matches the shape of a credential in free text —
sk-…, ghp_…, AKIA…, Bearer …, password=, パスワード:, api_key=, a private-key
header, a URL with user:pass@. When one matches, the whole sentence is dropped and the
record says why:
{ "askedFor": null, "askedForWithheld": "secret-like", "removed": ["..."], "added": ["..."] }The diff survives either way, so the correction is still usable evidence — just weaker. This
will sometimes drop a sentence that merely discusses a password, and that is the right way
to be wrong. habit doctor counts how often it has happened; a run of them means credentials
are being typed into the chat. HABIT_NO_PROMPTS=1 drops every sentence, secret or not.
A file that holds a secret but isn't named like one — config.js, docker-compose.yml —
is still stored in full. Pattern matching has holes by construction; treat the store as
sensitive as the code it watches.
Keeping it from growing forever
habit prune # what could go
habit prune --apply # drop it
habit prune --days 90 --applyA stored body exists for exactly one purpose: to diff against the next write of that same
file. A body whose file has been deleted can never be used again, and one untouched for a month
probably won't be. prune drops those bodies and keeps the hash, so nothing stops being
detected — the next write to that path just reads the file first instead of showing a diff.
Nothing runs on a schedule and nothing deletes on its own. It is a command, it defaults to a
dry run, and habit doctor reports the total so you can see when it is worth running.
Seeing what it has learned
habit loghabit: 23 hand-edit(s) recorded (showing the last 20)
2026-07-31 03:06 report.md (−2 +2)
− ## 🎉 Great news everyone
+ ## Monthly figures
...
Corrected most often:
9x report.md
5x index.htmlTurning edits into rules
Every detected edit is a small piece of evidence about how you actually want things done. Once
enough accumulate, the habit-learn skill turns them into rules you can put in AGENTS.md.
The agent you already have does the reading. There is no API key and no second subscription:
habit corpus # the corrections, laid out for reading
habit validate rules.json # exits 1 if a rule cannot be backed up
habit validate rules.json --save # only once it exits 0One constraint is enforced in code rather than in the instructions: a rule must cite at least two corrections that produced it, by id. A rule backed by one correction is dropped. A rule citing an id that does not exist is dropped. You get "removes emoji from headings (3 corrections)", never "prefers a concise style" — because the second kind cannot be checked, so nothing can ever catch it being wrong.
That check is the reason validate exists as a command. An agent asked to cite its evidence
will usually comply, and the times it does not are exactly the times the rule was invented.
Once saved, the rules are handed to the agent at the start of every session — the same thing
SubagentStart already did for subagents, extended to the one that actually writes your files.
You can still paste them into AGENTS.md; you no longer have to for them to have any effect.
The one thing habit says without being asked. Distilling needs a model, and a model may not run inside a hook, so something has to raise the subject — otherwise the corrections pile up and nothing ever reads them. Once at least ten are waiting, the pile has grown since last time, and a week has passed, one line appears at session start:
habit: 14 correction(s) recorded, not yet distilled. The habit-learn skill turns them into rules.It is addressed to the agent, which can act on it, rather than to you, who would have to remember. Those three gates are the whole design: an ambient line that repeats is an ambient line you turn off.
Checking it is still working
habit doctorhabit is a set of couplings to fields another program decides to send. When one stops arriving nothing breaks — the hook still runs, still exits 0, still writes a record. It writes a record with a hole in it, and the tool quietly gets worse at its job.
So doctor reports what has actually been observed, never what is expected:
Fields the hooks depend on, as actually delivered:
artifacts carry prompt_id 65/68 partial
instructed ones carry askedFor 11/11
failures carry an error 0/34 DEAD — a failure signal records that something
failed and nothing about what [harness]That last line is the point. Nothing in the source looks wrong; the field name was simply
never the right one, and no amount of reading the code would say so. It can only be counted.
doctor writes nothing and reads everything, so it costs nothing to run on a hunch.
Did the rules actually do anything
habit scoreEvery saved rule goes into a ledger as a prediction: apply this and corrections of this kind
stop. score checks it against the corrections that have arrived since.
A rule is only scorable when the corrections behind it share an actual repeated line — that line becomes the marker to watch for. When they share nothing literal, which is common and true of most style habits, the rule reports unscorable: it still applies, it just cannot be graded. The alternative is fuzzy matching, which fires on unrelated edits and turns the ledger into noise.
No hit rate is printed, deliberately. A rule with no recurrence may be working, or the situation may never have come up — nothing in the data separates those. And since habit now injects these same rules at session start, it is treating the behaviour it is measuring, so any ratio would be pinned toward a perfect score by its own hand. You get the rows and the dates. A scoreboard whose mistakes flatter it is a sales pitch.
What it is not
It does not judge your edit, and it does not stop the agent. It makes the agent aware that a human changed something, and leaves the decision where it belongs.
License
MIT
