whyrule
Claude isn't ignoring your CLAUDE.md. It never got it.
You wrote the rule down. You wrote it clearly. Claude does the opposite anyway, and there's no error, no warning, nothing in the logs — so you rewrite the rule in capitals and hope.
Usually the rule never arrived. Things that make text vanish between your file and the model, none of which are visible by reading the file:
- an instruction inside an
<!-- HTML comment -->, which is stripped before injection @docs/setup.mdpointing at a file that moved — imports that resolve to nothing load nothing, and say nothing- an
AGENTS.mddoing the work, which Claude Code does not read - a
.claude/rules/file whosepaths:globs match no file in the repo, so it never loads - your project rule and your
~/.claude/CLAUDE.mdrule flatly contradicting each other — they're concatenated, not overridden, so Claude picks one arbitrarily
whyrule finds all of it. There is nothing to install to try it:
git clone https://github.com/hjalti-hub/whyrule && cd whyrule
python3 -m whyrule
No API key. No model calls. No dependencies. Just python3.
Once you want it to run without being asked, install it properly and it hooks itself in — after that you never type it again.
$ whyrule
CLAUDE.md
18: error REACH001 Instruction inside an HTML comment never reaches Claude: '- Always use `pnpm`, never `npm`, since the lockfile is p...'
fix: Move the instruction outside the comment. Keep comments for notes meant only
for human maintainers, which is what the stripping is for.
3: error IMPORT001 `@docs/architecture.md` imports docs/architecture.md, which does not exist - nothing is loaded
fix: Point it at a file that exists. Relative paths resolve against ./, the
directory of the file the import is written in - not the working directory.
4: error IMPORT001 `@CONVENTIONS.md` imports CONVENTIONS.md, which does not exist - nothing is loaded
fix: Point it at a file that exists. Relative paths resolve against ./, the
directory of the file the import is written in - not the working directory.
8: warning IMPORT004 `@types/node` reads as a package scope, but is parsed as an import of a file that does not exist
fix: Wrap it in backticks - `` `@types/node` `` keeps the text literal and stops
it being read as an import.
... 14 more findings, across this file, AGENTS.md,
.claude/rules/api.md and CLAUDE.local.md
5 error(s) · 12 warning(s) · 1 note(s) across 4 memory files (3 loaded at launch)
Run with --explain to see why each one matters.
And the question you actually have:
$ whyrule why "run npm lint before committing"
Always run `npm run lint` before committing.
CLAUDE.md:13 (project)
This arrives, but something else in context disagrees.
warning CONFLICT001 Contradicts an instruction in CLAUDE.md: 'Never run `npm run lint` before committing; it is too slow on...' against 'Always run `npm run lint` before committing.'
why: All discovered memory files are concatenated into context rather than
overriding each other, so both of these arrive together. The documentation is
explicit about what happens next: if two rules contradict each other, Claude may
pick one arbitrarily.
fix: Delete one, or scope them apart so only one applies at a time - state the
condition each holds under, or move the narrower one into a path-scoped
`.claude/rules/` file.
warning ENFORCE001 Promises to run at a fixed point, which CLAUDE.md cannot guarantee: 'Always run `npm run lint` before committing.'
why: CLAUDE.md is context, not enforced configuration. The documentation's own
answer for this shape of instruction is a hook: if the instruction is something
that must run at a specific point, such as before every commit or after each file
edit, write it as a hook instead. Hooks execute as shell commands at fixed
lifecycle events and apply regardless of what Claude decides.
fix: Move it to a hook - a PostToolUse hook on Edit|Write for after-edit work, or
a PreToolUse hook on Bash for pre-commit work. Leave the reasoning here if it is
worth the context.
warning ENFORCE005 `npm run lint` cannot run - package.json defines no 'lint'
why: An instruction naming a command that does not exist is followed by running it
and watching it fail, which costs a turn every session. These usually date from a
rename that updated the scripts and not the CLAUDE.md.
fix: Update the command, or add the script to package.json.
Never run `npm run lint` before committing; it is too slow on this machine.
CLAUDE.local.md:4 (local)
This arrives, but something else in context disagrees.
warning CONFLICT001 Contradicts an instruction in CLAUDE.md: 'Never run `npm run lint` before committing; it is too slow on...' against 'Always run `npm run lint` before committing.'
why: All discovered memory files are concatenated into context rather than
overriding each other, so both of these arrive together. The documentation is
explicit about what happens next: if two rules contradict each other, Claude may
pick one arbitrarily.
fix: Delete one, or scope them apart so only one applies at a time - state the
condition each holds under, or move the narrower one into a path-scoped
`.claude/rules/` file.
warning ENFORCE001 Promises to run at a fixed point, which CLAUDE.md cannot guarantee: 'Never run `npm run lint` before committing; it is too slow on this mac'
why: CLAUDE.md is context, not enforced configuration. The documentation's own
answer for this shape of instruction is a hook: if the instruction is something
that must run at a specific point, such as before every commit or after each file
edit, write it as a hook instead. Hooks execute as shell commands at fixed
lifecycle events and apply regardless of what Claude decides.
fix: Move it to a hook - a PostToolUse hook on Edit|Write for after-edit work, or
a PreToolUse hook on Bash for pre-commit work. Leave the reasoning here if it is
worth the context.
warning ENFORCE005 `npm run lint` cannot run - package.json defines no 'lint'
why: An instruction naming a command that does not exist is followed by running it
and watching it fail, which costs a turn every session. These usually date from a
rename that updated the scripts and not the CLAUDE.md.
fix: Update the command, or add the script to package.json.
Run `npm run typecheck` after each file edit.
CLAUDE.md:14 (project)
This arrives, but CLAUDE.md cannot guarantee it.
warning ENFORCE001 Promises to run at a fixed point, which CLAUDE.md cannot guarantee: 'Run `npm run typecheck` after each file edit.'
why: CLAUDE.md is context, not enforced configuration. The documentation's own
answer for this shape of instruction is a hook: if the instruction is something
that must run at a specific point, such as before every commit or after each file
edit, write it as a hook instead. Hooks execute as shell commands at fixed
lifecycle events and apply regardless of what Claude decides.
fix: Move it to a hook - a PostToolUse hook on Edit|Write for after-edit work, or
a PreToolUse hook on Bash for pre-commit work. Leave the reasoning here if it is
worth the context.
Three different reasons one rule isn't being followed, none of which produce an error anywhere.
What actually loads
Memory files do not override each other — they are concatenated, root-first, and
all of it arrives before your first message. whyrule list shows the pile:
$ whyrule list
CLAUDE.md project 28 lines
.claude/rules/api.md project rule 10 lines
CLAUDE.local.md local 5 lines
· AGENTS.md not read by Claude Code 5 lines
43 lines load at launch.
Lines marked · are not in the launch context.
Running itself
A linter you have to remember to run is a linter you stop running. whyrule
registers itself as a Claude Code hook, so the harness runs it instead of you:
whyrule install # this project
whyrule install --user # every project on this machine
That writes two entries into settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "whyrule hook",
"timeout": 20,
"statusMessage": "Checking memory files…"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "whyrule hook",
"timeout": 30
}
]
}
]
}
}
PostToolUse fires the moment a CLAUDE.md, a CLAUDE.local.md or a
.claude/rules/*.md is written. On an error it exits 2, which puts the message
in front of Claude in the same turn — so a rule written into an HTML comment
gets caught by the turn that wrote it.
SessionStart fires when a session opens, and its stdout is injected as
context. Claude is told which of its own instructions never arrived. That is the
one piece of information it cannot work out for itself.
Both are silent when nothing is wrong, so a healthy setup costs no context.
whyrule install --print shows the resulting file without writing it,
--status reports whether it's in, and --uninstall takes it back out. Your
existing hooks are preserved and the previous file is backed up.
If whyrule isn't importable from outside its own checkout, install refuses
to write the hook and tells you why — a hook that silently never runs is
exactly the failure this tool exists to catch.
Why another CLAUDE.md linter?
Most of them check style: heading structure, word counts, whether you used bullets. Useful, but a well-structured rule that never reaches the model is still a rule that never reaches the model.
whyrule only reports things that change whether an instruction arrives or
can be acted on, and every rule is traceable to documented Claude Code
behaviour:
- It reads the whole loaded set — managed policy,
~/.claude/CLAUDE.md,~/.claude/rules/, every ancestor directory, the project file, the local file,.claude/rules/. A contradiction is a fact about two files; you cannot see it from either one. - It follows imports the way the loader does: skipping code spans and fences, resolving relative paths against the importing file rather than the working directory, and stopping at four hops.
- It reads your settings, because a
claudeMdExcludesglob in a layer you didn't write can drop your file with no sign in the session. - It never estimates. Sizes and line counts are measured; there are no invented token numbers anywhere in the output.
Install
Try it with no install at all:
git clone https://github.com/hjalti-hub/whyrule && cd whyrule
python3 -m whyrule
To use it anywhere, and to install the hooks:
pipx install git+https://github.com/hjalti-hub/whyrule
# or
pip install git+https://github.com/hjalti-hub/whyrule
Python 3.9+. No dependencies.
Use
whyrule # this project plus your personal memory files
whyrule list # what loads, in the order it is concatenated
whyrule why "always use pnpm" # why one instruction is not landing
whyrule rules # every rule, grouped
whyrule --explain # the documented behaviour behind each finding
whyrule --json # machine-readable
whyrule --sarif > out.sarif # CI annotations
Useful flags:
| Flag | Effect |
|---|---|
--no-user |
Skip ~/.claude. Contradictions with your personal rules go undetected. |
--no-subdirs |
Skip CLAUDE.md files in subdirectories. |
--project PATH |
Resolve memory files and paths: globs against another root. |
--overlap N |
Similarity threshold for the CONFLICT rules, 0..1 (default 0.45). |
--disable IDS |
Suppress rules, e.g. --disable WEIGHT002,ENFORCE003. |
--fail-on LEVEL |
Lowest severity that exits non-zero (default error). |
What it checks
Every rule below describes a failure that produces no error message. Run
whyrule rules for the same list in the terminal.
Loading — does the file reach Claude at all?
| Rule | |
|---|---|
LOAD000 |
Memory file cannot be read |
LOAD001 |
File is over the 4 MiB limit, so Claude Code skips it whole |
LOAD002 |
A claudeMdExcludes pattern in settings quietly excludes this file |
LOAD003 |
An instruction file Claude Code does not read, with no CLAUDE.md importing it |
LOAD004 |
claudeMd is set in a settings layer where the key has no effect |
LOAD001 is the one people don't believe: past 4 MiB the file is not truncated,
it is skipped entirely.
Reach — does the text inside it survive to context?
| Rule | |
|---|---|
REACH001 |
An instruction sits in an HTML comment, which is stripped before injection |
Block-level HTML comments are removed before the file becomes context. Comments inside fenced code blocks are kept — and when you open the file with the Read tool, the comment is visible again, which is why this one survives review.
Imports — do the @ references resolve?
| Rule | |
|---|---|
IMPORT001 |
@import target does not exist, so it contributes nothing |
IMPORT002 |
Import sits deeper than four hops and never enters context |
IMPORT003 |
Imports form a cycle |
IMPORT004 |
Prose that reads as a package scope or handle is parsed as an import |
IMPORT005 |
A project import resolves outside the working directory and needs approval |
IMPORT004 catches the sentence you didn't know was code. Import parsing skips
code spans and fenced blocks and nothing else, so writing we pin @types/node
in prose is an import attempt. Backticks make it literal.
Scoping — does a path-scoped rule ever match?
| Rule | |
|---|---|
SCOPE001 |
paths: globs match no file in the project, so the rule never loads |
SCOPE002 |
paths: brace expansion exceeds its 1,000-pattern budget and matches nothing |
SCOPE003 |
A paths: pattern has an unclosed [, so it matches nothing |
The usual cause of SCOPE001 is * not crossing directory separators:
src/*.ts misses src/api/handler.ts, which src/**/*.ts matches.
Conflict — do two instructions fight each other?
| Rule | |
|---|---|
CONFLICT001 |
Two loaded instructions about one subject contradict each other |
CONFLICT002 |
One setting is given two different values |
CONFLICT003 |
The same instruction is repeated in two loaded files |
The documentation states the consequence outright: "if two rules contradict each other, Claude may pick one arbitrarily." Nothing overrides anything — your personal file and your project file both arrive.
Enforceability — can the instruction be acted on?
| Rule | |
|---|---|
ENFORCE001 |
A guarantee about when something runs, which only a hook can make |
ENFORCE002 |
An absolute prohibition, which only permissions or a hook can enforce |
ENFORCE003 |
An instruction with nothing concrete enough to verify |
ENFORCE004 |
References a path that does not exist |
ENFORCE005 |
Tells Claude to run a command the project does not define |
ENFORCE001 and ENFORCE002 are not style opinions. CLAUDE.md is delivered as
a user message, not as configuration — "always run the tests before committing"
is a request, and the documentation's own answer for that shape of instruction
is to write it as a hook. whyrule tells you which of your rules are wearing
the wrong mechanism.
Weight — is there too much of it to follow?
| Rule | |
|---|---|
WEIGHT001 |
File is over the documented 200-line target, which reduces adherence |
WEIGHT002 |
Launch-loaded memory is large enough to compete with itself |
WEIGHT003 |
Imports load at launch too, so splitting the file did not reduce context |
WEIGHT003 exists because splitting a long CLAUDE.md into @ imports feels
like it should help and does not: imported files are expanded into context at
launch. .claude/rules/ with paths: frontmatter is the mechanism that
actually defers loading.
In CI
name: claude-md
on: [push, pull_request]
jobs:
whyrule:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pipx install git+https://github.com/hjalti-hub/whyrule
- run: whyrule --no-user --fail-on warning
For inline annotations on the diff, emit SARIF and upload it:
- run: whyrule --no-user --sarif --fail-on never > whyrule.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: whyrule.sarif
Pass --no-user in CI: there is no ~/.claude on a runner, and the machine's
personal rules aren't the repository's business.
Examples
examples/broken/ is a small project carrying one instance of most failure
modes, each broken on purpose and each verified by CI to still be broken:
$ whyrule --project examples/broken --no-user
5 error(s) · 12 warning(s) · 1 note(s) across 4 memory files (3 loaded at launch)
examples/clean/ is the same shape with nothing wrong. Both are worth reading —
the broken one looks completely fine.
Library use
import whyrule
files, findings = whyrule.check(project=".")
for finding in findings:
print(finding.rule, finding.severity.value, finding.path, finding.message)
Contributing
The bar for a new rule is specific: it must describe a failure that produces no error message, and it must be traceable to documented Claude Code behaviour. Rules encoding a preference belong in a style linter instead.
If you add one, add its documented basis to whyrule/spec.py, its summary to
whyrule/rules/catalog.py, and a test asserting both directions — that it fires
on the broken case and stays silent on the reasonable one. A rule that only
proves it fires is how a linter becomes noise.
python3 -m unittest discover -s tests -t .
python3 tests/check_catalog.py
Accuracy
If a rule here contradicts Claude Code's actual behaviour, that is a bug worth
reporting — the value of this tool is entirely in being right about mechanics.
Behaviour also changes between versions; every rule is derived from the memory
documentation as of September 2026, and whyrule/spec.py records the sentence
each constant comes from.
Related
- whyskill — the same idea for skills that never load, never get chosen, or are shadowed by another skill.
- deadweight — finds the skills, subagents and MCP servers that load every session and are never used.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file whyrule-0.1.0.tar.gz.
File metadata
- Download URL: whyrule-0.1.0.tar.gz
- Upload date:
- Size: 66.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2ba4bc3733731716e7ce871660a4d50b91c898de210a6e6d50b9d9466f6852b4
|
|
| MD5 |
d6782bf99070f377113c554c8182bb3e
|
|
| BLAKE2b-256 |
bd63dc046570c65533b8c80da1f27099bf41742e8b4e77715c3fec375789e90e
|
Provenance
The following attestation bundles were made for whyrule-0.1.0.tar.gz:
Publisher:
release.yml on hjalti-hub/whyrule
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whyrule-0.1.0.tar.gz -
Subject digest:
2ba4bc3733731716e7ce871660a4d50b91c898de210a6e6d50b9d9466f6852b4 - Sigstore transparency entry: 2759715260
- Sigstore integration time:
-
Permalink:
hjalti-hub/whyrule@867bfdac6cf311741f5ab099689c0ef76887e9ba -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/hjalti-hub
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@867bfdac6cf311741f5ab099689c0ef76887e9ba -
Trigger Event:
push
-
Statement type:
File details
Details for the file whyrule-0.1.0-py3-none-any.whl.
File metadata
- Download URL: whyrule-0.1.0-py3-none-any.whl
- Upload date:
- Size: 63.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
02c57367275534140cd404b993a2662588d9c300170f8c6ea0fdd503ff37dfa8
|
|
| MD5 |
54b801a602b64571724c7dea0887c697
|
|
| BLAKE2b-256 |
c83e2846c7ce40442a8833fbb969f847f9652cb7fc7bf63c31453d69c1214a2e
|
Provenance
The following attestation bundles were made for whyrule-0.1.0-py3-none-any.whl:
Publisher:
release.yml on hjalti-hub/whyrule
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whyrule-0.1.0-py3-none-any.whl -
Subject digest:
02c57367275534140cd404b993a2662588d9c300170f8c6ea0fdd503ff37dfa8 - Sigstore transparency entry: 2759716059
- Sigstore integration time:
-
Permalink:
hjalti-hub/whyrule@867bfdac6cf311741f5ab099689c0ef76887e9ba -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/hjalti-hub
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@867bfdac6cf311741f5ab099689c0ef76887e9ba -
Trigger Event:
push
-
Statement type: