Denies an auto-memory write that holds task state, one-off references or repo detail, so memory keeps only what is worth relearning

A mod (a plugin that adds a hook to Claude Code) that denies a memory write holding task state, a one-off reference or repo detail. Memory then keeps only what is worth relearning.
Auto memory (notes Claude saves between sessions) fills with entries that go stale: "currently migrating auth", "fixed in PR 412", "see src/api.ts:42". Later sessions read them as facts.
memory-lint checks each memory write. If the note holds one of these, the mod denies the write and tells Claude what memory is for.
/plugin marketplace add cpeaustriajc/cj-stack
/plugin install memory-lint@cj-stack
To see that it is on, run claude plugin list and look for memory-lint. The mod is optional: cj-stack does not install it.
Currently migrating auth to OAuth; next step is the callback route. to a file in ~/.claude/projects/<project>/memory/.Claude reads this deny reason:
memory-lint: memory not saved. It holds task state (in progress, next step, this session, today). Memory keeps durable facts that would otherwise be relearned: the user's corrections and preferences, and non-obvious quirks of this project or environment that will still hold in a month. If this is one, restate it without the flagged parts. If it is task state, a finding, or readable from the repo, do not save it. A rule the team shares goes in AGENTS.md or a checked-in rule; a decision goes in the team's decision log.
The mod checks the Write and Edit tools on Markdown files in the memory directory. It denies a write that matches one of these checks:
| Check | Example |
|---|---|
| Task state | "currently", "next step", "today", "just fixed" |
| One-off reference | "PR #412", a commit hash, a claude/ branch |
| Repo detail | src/api/client.ts:42, a code block |
| Short-lived fact | "next week", "still pending", a /tmp path, a plan path |
| Dated status | "since 2026-09-04", "as of 2026" |
| Size | a topic file or a MEMORY.md line over a set length |
The size limits are in hooks/register.ts. A Write is checked whole, without its frontmatter. An Edit is checked only on the lines it adds, so removing or shortening memory always passes.
The default memory directory is ~/.claude/projects/<project>/memory. A file outside the memory directory passes.
| Option | Default | Meaning |
|---|---|---|
memoryDir | empty | Your autoMemoryDirectory setting, if you moved memory out of the default directory. |
With the default, the mod checks only the default directory. Run claude plugin configure memory-lint@cj-stack to show the options and which are unset.
To turn the mod off, run claude plugin disable memory-lint@cj-stack.
hooks/register.ts 96 lines1import type { Register } from 'claude-code'
2
3const MOD = 'memory-lint'
4const DEFAULT_DIR = /\/projects\/[^/]+\/memory\//
5const INDEX = /(^|\/)MEMORY\.md$/
6const MAX_TOPIC = 1200
7const MAX_INDEX_LINE = 200
8// Only code files: a bare `\.\w+:\d+` also matches hosts with ports (127.0.0.1:8000).
9const CODE_EXT = 'ts|tsx|js|jsx|mjs|cjs|php|py|rb|go|rs|swift|kt|java|vue|svelte|css|scss|sql|sh|c|h|cpp|cs|ex|exs'
10
11// Each rule names one kind of note that is not worth carrying into later sessions.
12const RULES: { why: string; test: (text: string) => boolean }[] = [
13 {
14 why: 'task state (in progress, next step, this session, today)',
15 test: t => /\b(currently|in progress|this session|today|yesterday|right now|for now|next steps?|todo|wip|working on|just (fixed|added|merged|pushed|finished))\b/i.test(t),
16 },
17 {
18 why: 'a one-off reference (PR, issue, commit or claude/ branch)',
19 test: t =>
20 /\b(pr|pull request|issue)\s*#?\d+\b/i.test(t) ||
21 /(^|\s)#\d{2,}\b/.test(t) ||
22 /(^|[\s(`'"])claude\/[\w.-]+/.test(t) ||
23 (t.match(/\b[0-9a-f]{7,40}\b/g) ?? []).some(h => /\d/.test(h) && /[a-f]/.test(h)),
24 },
25 {
26 why: 'repo detail Claude can read again (a file:line pointer or a code block)',
27 test: t => new RegExp(`[\\w/-]+\\.(${CODE_EXT}):\\d+`).test(t) || /^\s*```/m.test(t),
28 },
29 {
30 why: 'something short-lived that changes within days (pending work, a branch, a plan or /tmp path)',
31 test: t =>
32 /\b(tomorrow|tonight|this week|next week|last week|this sprint|still pending|is pending|pending (a|an|the|client|research|review|approval|input))\b/i.test(t) ||
33 /\bbranch\s+`?[\w.-]+\/[\w./-]+/i.test(t) ||
34 /\.claude\/(plans|jobs|todos)\/|(^|[\s`(])\/(private\/)?tmp\//.test(t),
35 },
36 {
37 why: 'dated status ("since 2026-09-04", "as of 2026") that goes stale unseen',
38 test: t => /\b20\d{2}-[01]\d-[0-3]\d\b/.test(t) || /\bas of (19|20)\d{2}\b/i.test(t),
39 },
40]
41
42const BAR =
43 'Memory keeps durable facts that would otherwise be relearned: the user\'s corrections and preferences, ' +
44 'and non-obvious quirks of this project or environment that will still hold in a month. If this is one, restate it without the flagged parts. ' +
45 'If it is task state, a finding, or readable from the repo, do not save it. ' +
46 'A rule the team shares goes in AGENTS.md or a checked-in rule; a decision goes in the team\'s decision log.'
47
48function body(text: string): string {
49 return text.replace(/^---\n[\s\S]*?\n---\n?/, '')
50}
51
52// For an Edit, only the lines it adds: removing or shortening memory always passes.
53function added(oldText: string, newText: string): string {
54 const before = new Set(oldText.split('\n'))
55 return newText.split('\n').filter(l => !before.has(l)).join('\n')
56}
57
58function problems(path: string, text: string, whole: boolean): string[] {
59 const found = RULES.filter(r => r.test(text)).map(r => r.why)
60 if (INDEX.test(path)) {
61 if (text.split('\n').some(l => l.length > MAX_INDEX_LINE)) found.push(`an index line over ${MAX_INDEX_LINE} characters (put detail in a topic file)`)
62 } else if (whole && text.length > MAX_TOPIC) {
63 found.push(`a topic file over ${MAX_TOPIC} characters (one memory per file)`)
64 }
65 return found
66}
67
68function isMemory(path: string, configured: string, home: string): boolean {
69 if (!path.endsWith('.md')) return false
70 if (DEFAULT_DIR.test(path)) return true
71 if (!configured) return false
72 const dir = configured.startsWith('~/') ? `${home}/${configured.slice(2)}` : configured
73 return path.startsWith(dir)
74}
75
76export const register: Register = (on, options) => {
77 const raw = typeof options.memoryDir === 'string' ? options.memoryDir.trim() : ''
78 const configured = raw && raw.replace(/\/?$/, '/')
79
80 const deny = (found: string[]) => ({
81 deny: `${MOD}: memory not saved. It holds ${found.join('; ')}. ${BAR}`,
82 })
83
84 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
85 if (!isMemory(e.file_path, configured, (await $.env.get('HOME')) ?? '')) return next(e)
86 const found = problems(e.file_path, body(e.content), true)
87 return found.length ? deny(found) : next(e)
88 }).catch(($, e, next) => next(e))
89
90 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
91 if (!isMemory(e.file_path, configured, (await $.env.get('HOME')) ?? '')) return next(e)
92 const found = problems(e.file_path, added(e.old_string, e.new_string), false)
93 return found.length ? deny(found) : next(e)
94 }).catch(($, e, next) => next(e))
95}
96