SLOPSHOPPER

memory-lint

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

newguard
v0.1.0no licenseupdated 2026-10-13cpeaustriajc/cj-stack/mods/memory-lint
A shopper browsing a rack in a slop shop
README

memory-lint

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.

Why

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.

Install

/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.

Example

  1. Claude writes Currently migrating auth to OAuth; next step is the callback route. to a file in ~/.claude/projects/<project>/memory/.
  2. The note holds task state, so the mod denies the write.

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.

What it does

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:

CheckExample
Task state"currently", "next step", "today", "just fixed"
One-off reference"PR #412", a commit hash, a claude/ branch
Repo detailsrc/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"
Sizea 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.

Settings

OptionDefaultMeaning
memoryDiremptyYour 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.

Limits

  • It checks word patterns. A harmless note can match, and a stale note in other words can pass.
  • It checks the Write and Edit tools only. A shell command that writes a memory file passes.
  • If the check itself throws an error, the write passes.

More

Source 1 files
hooks/register.ts 96 lines
1import 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