# Curate Memory

> Runs one curation pass over the current project's memory store — reads the newest session transcripts, filters them to real user and assistant prose, and writes a complete candidate store beside the input plus one report listing every merge, drop, rewrite, and addition with its justification and a citation to the session that supports it. Every retained memory naming a concrete file, command, or flag is also checked against the working tree and reported verified or unverifiable — a failed check only annotates the report, it never drops the memory. The input store is never opened for writing, and the pass adopts nothing — it prints the command to adopt the candidate store and the command to discard it. Use when the user types /curate-memory, or asks to curate, consolidate, clean up, de-duplicate, or prune their memory store or memory files.

- Skill: `bgutschke/curate-memory` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add bgutschke/curate-memory`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bgutschke/curate-memory/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: bgutschke (https://skillmd.com/u/bgutschke)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bgutschke/curate-memory

---


# curate-memory

Perform one **curation pass** over the memory store belonging to the current project, and
write a **candidate store** beside it: duplicates merged, entries contradicted by a later
session replaced with the newest value, guidance repeated in conversation but never
recorded surfaced as a new memory, and every retained memory naming a concrete file,
command, or flag checked against the working tree.

Three properties hold on every run, and everything below is arranged to keep them:

- **The input store is never opened for writing.** It is hashed before the pass and
  re-hashed after, and the pass reports the comparison.
- **The pass adopts nothing.** It writes to a new directory and prints the command to
  adopt it and the command to discard it. Which of those to run is the user's call.
- **A failed working-tree check only annotates.** It never removes a memory or softens its
  wording, because a search that finds nothing is not proof the thing it names is gone.

## Dependencies

Requires `node` to run the bundled plan script (`scripts/curation-plan-cli.js`), which
every step below invokes to resolve the pool, select and batch sessions, verify the input
store, and check retained memories against the working tree.

## When to use

- The user types `/curate-memory`.
- The user asks to curate, consolidate, clean up, de-duplicate, or prune their memory
  store, memory files, or `MEMORY.md`.

## When not to use

- Writing or updating a single memory in the ordinary course of a session. That is the
  harness's own memory behaviour; this skill is the periodic sweep over the whole store.
- Auditing `CLAUDE.md`, `rules/*.md`, or installed skill and agent descriptions. Those are
  hand-authored and shared with other people; a memory store is model-written and belongs
  to one user, which is the reason this pass may write at all. Route that to `audit-rules`.
- Auditing `settings.json`, hooks, or permissions — structured configuration, not prose.
- Curating a project other than the one the working directory sits in. A pass reads one
  store and one project's transcripts, so unrelated corpora are never mixed.

## Degenerate stores

A store isn't always there in the shape the rest of this document assumes. The plan
script's preflight always reports `store.status`, one of:

- **`present`** — memory files and `MEMORY.md` both exist. The ordinary pass below.
- **`absent`** — the store directory does not exist at all.
- **`empty`** — the directory exists but holds no memory files, index or not.
- **`index-less`** — memory files exist but `MEMORY.md` does not.

The last three have nothing to diff a candidate against, so all three run the same way: a
**cold start**. Cold start is not a smaller pass — it reads the same transcripts, dispatches
the same miners, and writes a full candidate store and report — it only differs in that
every surviving candidate is an `add`, because there is no existing memory to merge into,
replace, or drop.

The preflight also reports `mode`, computed from `store.status` together with the pool:

- **`curate`** — the store is `present`. Runs regardless of how many sessions are in the
  pool; re-verifying and re-writing a store unchanged against zero fresh sessions is still a
  meaningful result.
- **`cold-start`** — the store is not `present`, but the pool has sessions to mine.
- **`noop`** — the store is not `present` and the pool is empty too. There is nothing here
  to act on besides inventing an empty store, which is worse than not running at all — see
  Step 1's handling of this case.

## Arguments

`/curate-memory` takes two optional flags, both forwarded to the plan script in Step 1:

- `--token-budget <n>` — override the default prose-token budget of 150,000 (justified in
  Step 1, where the script also states it). Raising it fans out to more batches rather than
  enlarging any one of them, so a deeper pass costs more miners, not a slower one.
- `--dry-run` — resolve the plan and print the preflight, then stop before Step 2. Nothing
  is mined and nothing beyond the digest files is written.

## What one pass reads

Sessions recorded against the current project directory, newest first, plus sessions
recorded against every worktree this repository has — that directory being the one the
working directory encodes to, so a pass reads one project's history and never mixes in
another's.

A git worktree is its own project directory: its sessions are recorded separately from the
parent checkout's. Left there, they would be invisible to a pass run from the main
checkout — and for anyone who works mostly in worktrees, that is most of the history worth
mining, including work on worktrees deleted after their branch merged. `git worktree list`
alone is not enough for this, since it reports only what exists right now and a deleted
worktree drops out of it while its transcripts stay on disk. The plan script therefore
unions three sources of worktree paths — live worktrees from `git worktree list`, worktrees
this project's own transcripts record creating, and worktrees with no creation record found
by matching a session's original working directory back to this repo's toplevel — and folds
each one's transcripts into the same pool, tagged with which of the three found it.

A worktree's own memory *store* is located but never merged into the candidate store — a
memory written on a branch may only ever have been about that branch's task. A non-empty
one is reported in the preflight as an orphan for the user to decide on, never folded into
step 4's output.

Transcript records are filtered by class before anything reads them. Kept: real user prose
and assistant prose. Dropped: tool results and tool calls, assistant reasoning, re-injected
skill bodies, slash-command invocations, local command output, system reminders, and
harness bookkeeping. A system reminder appended to a message that also carries real prose
is stripped from the text rather than discarding the whole message.

Assistant reasoning is dropped rather than kept as prose because it is the model talking to
itself — a memory must never cite it as evidence that the user said or decided anything.

## Step 1: Preflight

Run the plan script. It resolves the store, reads and classifies every transcript, selects
the sessions, splits the selection into batches, and writes one digest file per batch:

```bash
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --memory-dir <memory directory> [--token-budget <n>] [--dry-run]
```

Pass `--memory-dir` the memory directory this session's own context states. Omit the flag
only when no memory directory is stated; the script then derives one by encoding the
repository toplevel to its project directory name.

Sessions are selected newest-first against a **prose-token budget**, defaulting to 150,000
— enough, measured against the project this default was set from, to cover roughly a
month of active work. Pass the user's own `--token-budget <n>` through when their
invocation named one; a bigger budget selects more sessions and therefore more batches
(below), not bigger ones. Underneath the budget, a hard **session cap** of 100 applies as a
secondary guard, for the case where sessions are individually tiny enough that the budget
alone would pull in a very long tail. Neither figure ever sees a session below roughly 500
prose tokens: those are skipped as near-empty first and never count against either one,
since they are aborted sessions or single questions with nothing to mine.

Pass `--dry-run` when the user's invocation asked for one. It changes nothing about what
the script resolves or writes — the preflight and every batch digest are produced exactly
as they would be otherwise — it only means you stop after this step and never reach Step 2.

Read the transcripts through this script and never through shell commands. A user hook
that rewrites `grep` or `ls` can return an empty result for a directory holding a hundred
files, and a pass that reads nothing looks exactly like a pass with nothing to find.

Print the preflight to the user as prose: the resolved store, its `status` (present, absent,
empty, or index-less — see Degenerate stores above), and how many memories it holds, how
many worktrees were found and by which source (`worktrees` in the script's output), how many
sessions are in the pool, how many were selected, how many were skipped as near-empty, how
many were left out by the budget and by the cap, and the prose-token total against the
budget. Then say how many batches (`preflight.batches`) the selection split into and, if
`preflight.reduceEngaged` is true, that the reduce tier is engaging because the batch count
passed `preflight.reduceThresholdMiners` (8 by default). If `orphanStores` is non-empty, name
each one and say plainly that it is not part of this pass and is not being merged.

Check `mode` before deciding whether to continue:

- **`noop`** — stop here. Name the resolved store's actual status (absent, empty, or
  index-less — already printed above) and state plainly that there is also no session
  history to curate or rebuild it from, so there is nothing this pass can act on. For
  `index-less`, say so precisely: the memory files on disk are real, only the index is
  missing, and it is the absence of anything to check them against — not their absence —
  that stops the pass here. Do not create a candidate store, a report, or a session digest
  — writing an empty store would claim a pass happened when nothing was actually curated.
- **`cold-start`** — say plainly that no usable input store was found (naming which of
  absent, empty, or index-less applies) and that this pass will build one from the transcripts
  alone; every change in the eventual report will be an addition. Then continue as below.
- **`curate`** — the ordinary case. Continue as below.

Then **continue without asking**, unless `--dry-run` was passed — everything the pass writes
is additive and lands in a new directory, so there is nothing here to confirm.

Stop and report only if the script exits non-zero — it found no session history at all for
this project, which a pass cannot proceed without.

If `--dry-run` was passed, stop here. Do not dispatch any miners.

## Step 2: Mine the digest

Read the input store in full: every memory file and the index. Skip this when `mode` is
`cold-start` — `store.status` is not `present`, so there is nothing yet to read.

Then dispatch **one miner per batch** — for every entry in `preflight.batches`, a subagent
that reads that batch's own digest file at the entry's `path` and returns candidates. Run
every batch's miner in parallel; a miner never reads another batch's file or the whole
digest, which is what keeps one miner's window bounded by its batch regardless of how many
batches the pass has. Give each one the input store's contents in its prompt, since it needs
to know what already exists to propose a relationship to it — in a cold-start pass, say
plainly in that same prompt that the store is empty, so the miner knows every candidate it
returns should be `new`. Run every miner on this session's model rather than a cheaper tier:
"is this memory stale?" is a nuanced call whose failure is silent.

Each **candidate** carries:

| Field | Meaning |
|---|---|
| `intent` | `new`, `merge`, `replace`, or `stale` |
| `target` | the existing memory it acts on; absent for `new` |
| `type` | `user`, `feedback`, `project`, or `reference` |
| `claim` | one line stating what the candidate asserts |
| `body` | the proposed memory text, written out in full |
| `evidence` | a session id plus a short verbatim quote from that session |

Ask for at most 15 candidates, ranked, de-duplicated within the batch. A bound is what
makes the volume reaching this conversation grow with the number of miners rather than with
the size of the history, which is what lets a later pass read more sessions without
redesign. 15 is set a little above the size of a store worth curating — the store this was
designed against held 9 memories — so one miner can propose a change to every entry and
still add a few, but cannot propose a wholesale rewrite that step 3 could not re-decide
entry by entry in one sitting.

### Reduce, when there are enough miners to need it

Check `preflight.reduceEngaged`. Below `preflight.reduceThresholdMiners` (8 by default) it
is `false` — every batch's raw candidate list goes straight to Step 3, and there is nothing
else to do here. That covers most passes: the reduce tier exists for the case where raising
the token budget fanned out to enough miners that their combined candidate lists would
start competing with the store itself for your attention in Step 3.

When it is `true`, merge candidate lists pairwise before deciding: dispatch a subagent for
each pair of miner outputs, giving it both candidate lists and asking it to return one
merged, de-duplicated list in the same candidate shape — still ranked, still bounded at 15,
every evidence citation carried through unchanged. Repeat pairwise merges over the results
until at most `preflight.reduceThresholdMiners` lists remain, then move to Step 3 with
those. A reduce pass may re-word a `claim` for two candidates that turned out to be the same
proposal from two batches, but it never invents a new one and never drops a citation — that
judgment stays reserved for Step 3.

## Step 3: Decide

Do this yourself; never delegate it. A miner sees one slice of history and cannot know what
a later session said, so its `intent` is a proposal — re-decide every relationship against
the whole store and every candidate. The decision you record is not always spelled the same
as the `intent` that proposed it: a `new` candidate becomes an **add** decision below, and a
`stale` candidate becomes a **drop** decision — `merge` and `replace` carry the same word
through unchanged.

- **merge** two memories only when they state the same lesson. Write the merged sentence
  out; that wording is the thing the user is being asked to approve.
- **replace** when a later session contradicts an existing memory. The newest statement
  wins.
- **drop** (a `stale` candidate) only on evidence that the memory is wrong or has been
  superseded — never on a search that failed to find something a memory names. A search
  that finds nothing is not proof of absence, and a wrongly-dropped memory is gone without
  the user learning it existed.
- **split** a file that has accumulated several facts into one file per fact.
- **add** (a `new` candidate) a memory for guidance a session gave that the store never
  recorded.

Every decision needs a justification and a citation, so discard any candidate whose
evidence you cannot point at in the digest.

In a **cold-start** pass this collapses on its own: with no existing memory to merge into,
replace, or drop, every surviving candidate is an `add`. Nothing here changes — you are
still re-deciding each one against the whole set of candidates and discarding any without a
citation, there is simply nothing on the other side of the relationship.

## Step 4: Write the candidate store

Write a complete store at `paths.candidateStore` — every memory that survives, not only the
ones that changed. It has to stand on its own, because adopting it is a plain move.

Each memory is one file holding one fact, carrying the frontmatter contract the harness
reads:

```markdown
---
name: <short-kebab-case-slug>
description: <one-line summary, used to decide relevance during recall>
metadata:
  type: user | feedback | project | reference
---

<the fact; for feedback and project, follow with **Why:** and **How to apply:** lines>
```

Match the input store's actual shape wherever it differs from this — the store on disk is
the authority on its own contract.

Write no provenance, no verification note, and no pass metadata into any memory. The
frontmatter is a fixed contract, and anything added here becomes permanent content the next
pass has to reason about. All of it belongs in the report.

Then regenerate the index wholesale from the candidate memories you just wrote — one line
per memory, derived from that memory's own frontmatter:

```markdown
- [Title](file.md) — hook
```

Never copy the input index forward and patch it. The index is derived data, and patching is
how it drifts from the files it points at.

## Step 5: Verify against the working tree

A memory can go stale with no session ever contradicting it — it names a file, a command,
or a flag, and the repo moved on without comment. Mining history alone cannot catch that,
so every memory retained in step 4 is checked directly against the working tree:

```bash
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --verify-memories <candidateStore>
```

For each retained memory, this reads every backtick-quoted span in its body — the same
surface a candidate's own `body` field already writes a concrete name into — and checks
each one: a file-shaped span (a path separator, or a filename with an extension) against
the working tree's tracked and untracked-but-not-ignored files; anything else against a
single scan of those same files' contents, so a command or flag mentioned anywhere in the
tree counts. It reports each memory `verified` (every span it names checked out),
`unverifiable` (at least one did not), or `no-targets` (nothing concrete to check). This
runs entirely inside the node process the skill already launched — reading files through
`fs` and invoking `git` directly rather than through a shell — so a hook that rewrites
`grep`, `find`, or `ls` for the Bash tool has nothing here to intercept.

An `unverifiable` result never changes the candidate store. A failed check is not proof
the thing it names is gone — the name may have moved, may be built at runtime, or may
simply be missed by a substring scan — so the memory stays in the candidate store exactly
as step 4 wrote it. Carry each memory's status into step 6's report instead: that is where
the user gets to see the claim and overrule it.

## Step 6: Write the report

One report at `paths.report`, opening with the preflight figures from step 1 — and, when
`mode` was `cold-start`, a line stating that plainly and naming which of absent, empty, or
index-less the input store was, so the user understands up front why every entry below is an
addition rather than a merge, replace, or drop — then one entry per decision, including the
ones taken against a miner's proposal, since a rejected candidate is a decision the user may
want to overturn:

```markdown
### merge: retry-budget + retries-are-capped

**Result:** one memory, `retry-budget`.
**Justification:** both state the same lesson — the retry cap is per request, not per
call. Keeping two costs context every session and asserts the same thing twice.
**Citation:** session 7f2a9c14 — "the cap is per request; three calls inside one request
still share three retries"
```

Every entry states its intent, what the store now holds, why, and the session that supports
it. An entry with no citation does not belong in the report, and the change it describes
does not belong in the candidate store.

Then, for every memory that survives into the candidate store, state which of three
distinct things is true of it — a memory earns exactly one:

- **Verified** — step 5 checked out everything it named. Nothing further to say beyond
  whatever decision entry already covers it, if any.
- **Unverifiable** — step 5 could not find something it named. Name what it named and what
  the check could not find, since that is the one thing the user needs to overrule it in a
  second: `**Unverifiable:** names \`docs/runbooks/deploy.md\`, not found in the working
  tree.`
- **Contradicted by a later session** — the memory carries a `replace` decision above.
  Label it with this exact phrase here too, so a reader scanning the report for what's
  stale and why finds every reason in one place, rather than having to infer from a
  `replace` heading that this is the mining-based counterpart to an `unverifiable` line.

Never merge the second and third into one "stale" label — they come from different
evidence (the working tree versus a later session) and are overruled differently: a session
citation can be checked by reading that session, while an `unverifiable` result can be
checked by looking at the working tree directly.

## Step 7: Verify and hand off

Re-hash the input store and confirm the pass changed nothing, passing the digest the
preflight printed:

```bash
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --memory-dir <memory directory> --verify-store <digest>
```

A non-zero exit means the input store changed during the pass. Say so plainly and name the
candidate store as unsafe to adopt; do not print the adopt command.

Otherwise close by printing both commands, and adopt nothing yourself. When `store.status`
was `absent` there is no prior directory to keep alongside, so the adopt command is just the
second move — printing the first against a path that doesn't exist would fail on the user:

```bash
# adopt — keeps the previous store alongside, so the move is reversible
mv <store> <store>-superseded && mv <candidateStore> <store>

# adopt, when the input store was absent (a cold start with nothing to supersede)
mv <candidateStore> <store>

# discard
rm -rf <candidateStore> <report> <sessionDigestDirectory>
```

## Worked example

Run for real, once, against fixtures built for exactly this purpose: a fabricated
four-memory store belonging to a fabricated project at `/home/dev/work/checkout-api`, and
three fabricated transcripts. The store and the transcripts were deleted once this example
was written; nothing below is a claim about any store that still exists. Every command
shown below is the real invocation, run against those synthetic fixtures rather than
narrated by hand — the only way a worked example can make a pass/fail claim without going
stale the moment a real store changes. Steps 5 and 7 reproduce their commands' literal JSON
output; Step 1's block below is the prose rendering its own instructions call for, built
from that same real run's numbers rather than copied from raw stdout.

The store held `retry-budget.md` and `retries-are-capped.md`, stating the same lesson in
different words; `deploy-window.md`, recording a figure a later session revises; and
`runbook-location.md`, naming a runbook file that existed in the fixture's working tree
when the memory was written and was later moved out from under that path with no session
ever mentioning the move. Three sessions were fabricated to match: one restates the
retry-budget lesson in passing while debugging something else, one records the deploy
window being shortened after an incident, and one is a routine lint cleanup with no
bearing on anything the store holds — included to show the mechanism decline to invent a
candidate, not fail to find one.

Step 1's preflight, from the real command:

```bash
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --memory-dir ~/.claude/projects/-home-dev-work-checkout-api/memory
```

```text
Store: ~/.claude/projects/-home-dev-work-checkout-api/memory (4 memories, present)
Worktrees: none found — no orphan stores
Pool:  3 sessions (current project) — 3 selected, 0 skipped as near-empty,
       0 beyond the 150,000-token budget, 0 beyond the 100-session cap
Prose: 1,555 tokens across 16 messages, in 1 batch (reduce tier dormant — 1 of 8 miners)
Filtered out: 5 tool results, 5 tool calls, 2 thinking blocks, 3 session-metadata records,
              1 skill body, 1 slash command, 1 local command output
```

One miner read the single batch alongside the store's four memories and returned two
candidates:

- **merge** `retry-budget.md` and `retries-are-capped.md` — evidence: "the cap is per
  request, three calls inside one request still share the same three retries."
- **replace** the body of `deploy-window.md` — evidence: "We moved the deploy window to
  15 minutes after the incident."

The lint-cleanup session proposed nothing: nothing in it bears on any existing memory, and
nothing in it rises to guidance worth recording.

Step 3 confirmed both. Step 4 wrote a three-memory candidate store: the merge collapsed two
files into one, `deploy-window.md` now reads 15 minutes, and `runbook-location.md` carried
forward unchanged, since nothing challenged it.

Step 5's verification, the real command and its real output:

```bash
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --verify-memories ~/.claude/projects/-home-dev-work-checkout-api/memory-candidate
```

```json
[
  { "name": "deploy-window.md", "status": "verified" },
  { "name": "retry-budget.md", "status": "verified" },
  { "name": "runbook-location.md", "status": "unverifiable" }
]
```

`retry-budget.md` and `deploy-window.md` verified — each names a value still mentioned
somewhere in the working tree (`RETRY_BUDGET`, `DEPLOY_WINDOW_MINUTES`). `runbook-location.md`
came back unverifiable: `docs/runbooks/deploy.md` does not resolve to any tracked or
untracked file. That result changed nothing in the candidate store — the memory stayed
exactly as step 4 wrote it — but step 6's report carries the one line the user needs to
overrule it: `**Unverifiable:** names \`docs/runbooks/deploy.md\`, not found in the working
tree.`

Step 7's re-hash, the real command and its real output:

```bash
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --memory-dir ~/.claude/projects/-home-dev-work-checkout-api/memory --verify-store sha256:f1e112448fa932752e7413c5e4611254cca8b9488f1b82d4589964b1834b006f
```

```json
{ "status": "unchanged", "digest": "sha256:f1e112448fa932752e7413c5e4611254cca8b9488f1b82d4589964b1834b006f" }
```

confirming the input store never changed. The pass closed by printing the adopt and discard
commands and ran neither:

```bash
mv ~/.claude/projects/-home-dev-work-checkout-api/memory ~/.claude/projects/-home-dev-work-checkout-api/memory-superseded \
  && mv ~/.claude/projects/-home-dev-work-checkout-api/memory-candidate ~/.claude/projects/-home-dev-work-checkout-api/memory

rm -rf ~/.claude/projects/-home-dev-work-checkout-api/memory-candidate \
  ~/.claude/projects/-home-dev-work-checkout-api/memory-candidate-REPORT.md \
  ~/.claude/projects/-home-dev-work-checkout-api/memory-candidate-session-digest
```

