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 toaudit-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 andMEMORY.mdboth 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 butMEMORY.mddoes 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 ispresent. 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 notpresent, but the pool has sessions to mine.noop— the store is notpresentand 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:
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. Forindex-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
stalecandidate) 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
newcandidate) 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:
---
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:
- [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:
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:
### 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
replacedecision 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 areplaceheading that this is the mining-based counterpart to anunverifiableline.
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:
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:
# 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:
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --memory-dir ~/.claude/projects/-home-dev-work-checkout-api/memory
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.mdandretries-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:
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --verify-memories ~/.claude/projects/-home-dev-work-checkout-api/memory-candidate
[
{ "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:
node ${CLAUDE_SKILL_DIR}/scripts/curation-plan-cli.js --memory-dir ~/.claude/projects/-home-dev-work-checkout-api/memory --verify-store sha256:f1e112448fa932752e7413c5e4611254cca8b9488f1b82d4589964b1834b006f
{ "status": "unchanged", "digest": "sha256:f1e112448fa932752e7413c5e4611254cca8b9488f1b82d4589964b1834b006f" }
confirming the input store never changed. The pass closed by printing the adopt and discard commands and ran neither:
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