@zinwave/memlint
v0.1.1
Published
Lint your AI agent memory files. Finds stale paths, dead commands, contradictions and duplication in CLAUDE.md / AGENTS.md / .cursorrules by checking them against your actual repo. Offline, deterministic, zero network.
Maintainers
Readme
memlint
Your CLAUDE.md is lying to your agent.
AI coding agents load CLAUDE.md, AGENTS.md and .cursorrules at the start of
every session and believe every word. Those files rot: paths get renamed, scripts
get deleted, teams migrate off a library, two files end up contradicting each
other. A stale memory file is worse than no memory file — the agent acts
confidently on something that stopped being true six months ago.
memlint checks what your memory files claim against what is actually in your repository. Fully offline, deterministic, and it never executes anything it finds.
Quickstart
npx @zinwave/memlintNo install, no config, no account. It finds your memory files, checks them, and tells you what is no longer true.
Here is memlint running on its own repository, before the memory files were cleaned up:
$ npx @zinwave/memlint
AGENTS.md
7:1 warn This rule is worded differently in CLAUDE.md (58% similar). drift
→ Keep the wording in AGENTS.md and reference it from CLAUDE.md, so there is one source of truth.
↳ the other wording: "Run `npm run typecheck` and `npm test` before committing an…" CLAUDE.md:23:1
⤷ fixable with memlint fix
11:3 warn This rule is worded differently in CLAUDE.md (62% similar). drift
→ Keep the wording in AGENTS.md and reference it from CLAUDE.md, so there is one source of truth.
↳ the other wording: "Precision over recall: when the extractor is not confident,…" CLAUDE.md:31:3
⤷ fixable with memlint fix
17:41 error Path `docs/adrs` does not exist. broken-path
→ Did you mean `docs/adr`?
⤷ fixable with memlint fix
CLAUDE.md
11:5 error Path `src/core/loader.ts` does not exist. broken-path
→ Did you mean `src/core/load.ts`?
⤷ fixable with memlint fix
42:1 info This entry is marked as temporary ("For now"). stale-entry
→ Temporary notes outlive their reason. Confirm it is still true.
✖ 2 errors, 2 warnings, 1 info in 2 memory files
4 issues are auto-fixable — run memlint fix to preview.Every one of those was a real mistake. src/core/loader.ts had been renamed to
load.ts; docs/adrs was really docs/adr; the two files had drifted apart
saying the same thing two ways.
What it checks
memlint reads your memory files, extracts typed claims from them — paths, commands, dependencies, versions, plain assertions — and verifies each one against the repository.
| Rule | Default | Fixable | What it catches |
| -------------------------------------------------------- | ------- | ------- | --------------------------------------------------------------------- |
| broken-path | error | yes | A referenced file or directory does not exist |
| dead-command | error | yes | A referenced script, make target or just recipe does not exist |
| phantom-dependency | warn | no | A package the memory file tells the agent to use is not installed |
| version-mismatch | warn | no | A stated Node version or default branch disagrees with the repository |
| contradiction | error | no | Two entries give opposing instructions about the same thing |
| duplicate | warn | yes | The same instruction appears more than once |
| drift | warn | yes | Two files state the same rule in different words |
| stale-entry | info | no | An entry marked temporary, or dated, that has not been revisited |
| oversize | warn | no | A memory file is large enough to cost real context every session |
| import-cycle | error | no | An @import chain loops back on itself |
| external-import | info | no | An @import points outside the repository |
| possible-secret | warn | no | A memory file line looks like it contains a credential |
| parse-error | error | no | A memory file could not be parsed |
| unused-suppression | info | no | A memlint-disable comment silences nothing |
The table above is generated from the rules' own metadata by
npm run docs:rules, and CI fails if it drifts. A tool that lectures you about
stale documentation should not ship stale documentation.
Surfaces it finds
Automatically, anywhere in the repository:
| Surface | Notes |
| --------------------------------- | ------------------------------------------ |
| CLAUDE.md | root and every subdirectory |
| CLAUDE.local.md | deprecated upstream, still in the wild |
| AGENTS.md | root and every subdirectory |
| .claude/**/*.md | auto-memory — scanned even when gitignored |
| .cursor/rules/*.md, *.mdc | Cursor rules |
| .cursorrules | legacy Cursor rules |
| .github/copilot-instructions.md | GitHub Copilot |
| ~/.claude/CLAUDE.md | only with --include-user |
Claude Code @path imports are followed transitively (max depth 5, cycles
detected). Add your own with surfaces in the config, or --surface <glob>.
Your home directory is never read without --include-user. Nothing outside the
repository root is ever read or written.
Commands
memlint # check the current repository (default)
memlint packages/api # check one subtree
memlint fix # preview deterministic fixes as diffs
memlint fix --write # apply them
memlint list # what your agent loads, and what it costs
memlint rules # the rule reference
memlint init --github # write config and a CI workflow
memlint summary # one line, for editor and agent hooksUseful flags
| Flag | Effect |
| ------------------------------ | ----------------------------------------------------- |
| --format pretty\|json\|sarif | output format; json is a stable versioned schema |
| --fail-on error\|warn\|never | lowest severity that exits non-zero (default error) |
| --only <ids> | run only these rules |
| --ignore-rule <ids> | turn these rules off |
| --max-warnings <n> | exit non-zero above this many warnings |
| --surface <glob> | restrict the scan (repeatable) |
| --include-user | also scan ~/.claude/CLAUDE.md |
| --no-color | disable colour (NO_COLOR is honoured too) |
| --debug | verbose progress on stderr, never stdout |
Exit codes
| Code | Meaning |
| ---- | --------------------------------------------------------- |
| 0 | no issues at or above --fail-on |
| 1 | issues found |
| 2 | memlint itself failed — bad config, unreadable repository |
memlint list
The question nobody can currently answer: what is my agent actually loading, and what does it cost me on every single request?
$ npx @zinwave/memlint list
FILE LINES SIZE TOKENS MODIFIED
CLAUDE.md 46 1.7K ~440 2026-07-23 imported by AGENTS.md:6
AGENTS.md 13 0.4K ~104 2026-07-23
2 surfaces, 2.1K, ~544 tokens loaded per session (estimated at 4 chars/token)
Listed in load order: files further down take precedence when rules disagree.Fixing
memlint fix is a dry run. It prints a unified diff of what it would change and
writes nothing until you pass --write.
It only fixes things with exactly one correct answer: a path with a single candidate at edit distance ≤ 2, an exact duplicate, a case mismatch. Anything requiring judgement stays a suggestion — see ADR 003 for why. Applying twice is a no-op, and that is a test, not a promise.
Configuration
.memlint.json at the repository root, or a memlint key in package.json.
{
"$schema": "https://unpkg.com/@zinwave/memlint/schema.json",
"surfaces": ["docs/ai/*.md"], // extra globs to treat as memory files
"ignore": ["**/vendor/**"], // exclude from discovery and indexing
"staleDays": 180, // when a dated entry is worth revisiting
"failOn": "error", // error | warn | never
"rules": {
"stale-entry": "off",
"oversize": { "severity": "error", "maxLines": 150 },
},
}Unknown keys are errors, not warnings. A misspelled key that gets silently ignored is how you end up believing a rule is off when it is not. CLI flags override the config file.
Run memlint init to write a starter config, memlint rules to see every rule
and its options.
Suppressions
<!-- memlint-disable-next-line broken-path -->
The archive lives in `legacy/notes.md`.
<!-- memlint-disable duplicate -->
…intentionally repeated block…
<!-- memlint-enable -->
<!-- memlint-disable-file stale-entry -->Omit the rule ids to suppress everything. A suppression that stops silencing
anything is itself reported, as unused-suppression — a stale suppression is
the same problem memlint exists to find.
CI
memlint init --githubwrites a workflow that uploads SARIF, so findings appear as annotations on the memory files in a pull request. Actions are pinned by commit SHA.
Or, minimally:
- run: npx --yes @zinwave/memlint --format sarif --fail-on never > memlint.sarif
- uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
sarif_file: memlint.sarifClaude Code hook
memlint init --claude-hookadds a SessionStart hook that prints one line at the top of each session:
memlint: 2 broken-path, 1 dead-command in CLAUDE.md — run npx @zinwave/memlintPre-commit
memlint installs nothing for you. Add to .husky/pre-commit:
npx --no-install memlint --fail-on erroror, with pre-commit:
repos:
- repo: local
hooks:
- id: memlint
name: memlint
entry: npx --yes @zinwave/memlint --fail-on error
language: system
pass_filenames: falsePrivacy and the no-network guarantee
memlint makes zero network calls. No telemetry, no analytics, no update check, no error reporting. Your memory files and source code never leave your machine.
That is not a promise, it is a build gate: scripts/no-network-gate.mjs scans
the source for network primitives and HTTP clients and fails CI on a hit, and
also refuses any runtime dependency with an install script. You can verify the
claim yourself in about thirty seconds.
memlint also never executes anything it finds. dead-command verifies
npm run build by reading package.json as text, not by running it — the naive
implementation of that rule is a remote code execution vector triggered by
cloning a repository and typing npx @zinwave/memlint.
The only subprocess memlint ever spawns is git, via execFile with a fixed
argument array and no shell.
Security
memlint reads untrusted repository content. Reads are confined to the repository
root (the sole exception being ~/.claude/CLAUDE.md under --include-user,
which is read-only); writes are confined with no exception. Path traversal,
symlink escapes, ReDoS and resource exhaustion are all bounded and tested.
possible-secret never prints the value it matched.
Full threat model and reporting policy: SECURITY.md.
Programmatic use
import { runMemlint } from 'memlint';
const report = await runMemlint({ cwd: process.cwd() });
for (const issue of report.issues) {
console.log(`${issue.file}:${issue.line} ${issue.ruleId} ${issue.message}`);
}The --format json payload carries schemaVersion (currently 1.0) and is a
stable contract: fields are never removed or repurposed within a schema major.
FAQ
Why not just ask the agent to clean up its own memory?
Because the agent trusts the memory. It has no independent way to know that
src/routes.ts was renamed — the file that would have told it is the file that
is wrong. External, deterministic verification is the entire point.
Does it send my code anywhere? No, and you do not have to take our word for it. See the no-network gate above.
Is this a memory store, or an MCP server? No. memlint has no server, no accounts, no storage, and does not manage memory. It is a linter. It complements tools that write agent memory by checking that what they wrote is still true.
Will it delete things I want to keep?
Only fix --write modifies anything, only for issues with exactly one correct
answer, and every change is printed as a diff first. Use suppression comments
for deliberate exceptions.
Why is my legitimate path reported? That is a bug, and the highest-priority kind. Please open a false-positive report.
Roadmap
- v1.1 — opt-in
--aisemantic pass (bring your own key, off by default, strictly outside the offline core) - v1.2 —
memlint gc, interactive consolidation of bloated memory files - Full Python, Go and Rust manifest support (the
DependencyProviderinterface is in place; only npm/pnpm/yarn are implemented today) - Editor extension with inline diagnostics
Contributing
Adding a rule is a one-file pull request. See CONTRIBUTING.md for the walkthrough, and docs/adr/ for the decisions behind the design.
