A small, deterministic CLI that keeps agent-written project memory honest.
memkeep is the reference implementation of the Earned Memory convention:
a memory store where every entry is a
falsifiable claim with provenance — what was learned, how it was earned, and
what would make it wrong. Staleness is computed mechanically from git, with
no LLM and no global re-verification. The harness owns epistemics; the agent
owns ergonomics.
Status: Slices 1–3 shipped — the read path (
index/status), fulllintdepth, and theverifywrite path (per-type bumps,--all-safebatch heal,--dry-runpreview) are live and dogfooded in this repo's ownagent-memory/store. The opt-inref_missingreferential signal is live; multi-repo federation (sibling_missing, multi-anchor verify) is spec'd but not yet built.
Two failure modes bracket agent memory: document-everything instructions produce context-file slop that measurably hurts task success, and free-form memory rots silently — diverging from reality without any visible error.
Earned Memory keeps the free-form body (that freedom is what worked) and
formalizes the envelope around it: each claim carries evidence, a git anchor,
and a scope answering "what would have to change for this to become wrong?"
memkeep then flags claims suspect — never silently, never auto-deleting —
the moment a change touches their scope.
Requires Node.js 20+ and a working git on PATH.
npm install -g . # build + link the `memkeep` command on $PATH
memkeep status # run from inside a repo with an agent-memory/ storeOr build without installing:
npm run build # → dist/index.js (runnable via `node dist/index.js status`)This repo dogfoods itself: agent-memory/ is a real store describing
memkeep's own behavior.
Every command resolves a store (an INDEX.md + entries/ directory),
walks up from cwd git-style to find it, and exits 0 clean / 1
violation-or-suspect / 2 tool error.
| Command | What it does | Exit 1 when |
|---|---|---|
memkeep index |
Regenerates INDEX.md from the entries' frontmatter. |
— |
memkeep status |
Computes the suspect set and prints a summary (--json for machine output; --hook <event> for the Claude Code lifecycle hooks). |
any entry is suspect (plain / --json; --hook <event> always exits 0) |
memkeep lint |
Validates frontmatter schema, the type/status taxonomies (incl. model and tombstone rules), the explains dependency graph, status-conditional evidence, scope globs, size caps, and INDEX consistency. --fix regenerates INDEX on drift. |
any violation |
memkeep verify <id> |
Re-runs an entry's check (or a model's safe predictions), heals on pass (anchor := HEAD, verified_at := today), marks stale on fail. --all-safe batch-heals every suspect entry with a safe check; --dry-run previews the grouped suspect set without running or writing; --manual records an out-of-band re-test. |
a safe check fails (exit 1); a tool error is exit 2 and leaves the entry untouched |
memkeep status --json
memkeep lint
memkeep verify npm-package
memkeep verify --all-safe --dry-run
memkeep index --store ./somewhere/else
Global flags (precedence: explicit > --store-dir > .memkeep.yml > default agent-memory):
--store <path>(orMEMKEEP_STORE) — use a store at an explicit path, bypassing discovery.--store-dir <name>— the store directory name at the repo root.-C <dir>— run as if started indir(for store discovery).
store_dir: agent-memory # folder name for discovery
siblings: # multi-repo: repo name → checkout path (context-repo entries)
microservice-a: ../microservice-a
referential_check: false # opt-in DOCER-style second signal (off by default; see below)memkeep computes the suspect set mechanically — zero LLM, near-zero cost —
as a fixpoint over the entries' explains graph:
suspect(e) =
scope(e) ∩ files_changed(anchor(e)..HEAD) ≠ ∅ # scope_hit
∨ today − verified_at(e) > ttl_days(e) # ttl_expired
∨ ∃ d ∈ explains(e): suspect(d) ∨ status(d)=stale # dependency_suspect
∨ anchor unreachable from HEAD # anchor_unreachable
∨ referential_check ∧ body path ref ∉ HEAD tree # ref_missing (opt-in)
Implemented: scope_hit, ttl_expired, anchor_unreachable, and
dependency_suspect (transitive, cycle-safe by monotonicity — terminates in ≤ N
passes even on a cyclic graph), plus the opt-in ref_missing (DESIGN §3.6).
Spec'd, not yet built: sibling_missing (multi-repo).
suspect is never stored — it is derived fresh on every read, so the flag
can never itself go stale. Editing an entry never self-flags it (the entry's
own path is excluded from its scope match).
ref_missing is opt-in (referential_check: true in .memkeep.yml): it
flags an entry suspect when a file path referenced in its code-marked body spans
(backticks / fenced blocks) no longer exists at HEAD — a cheap second signal for
wrong-scope escapes the scope globs miss. Expect false positives (~24% in the
nearest study), which is exactly why flags mean suspect, never stale.
One verdict per reason.
status --jsonemits a suspect object per (entry × reason), so an entry suspect for two reasons appears twice;summary.suspectis the deduped entry count.
agent-memory/ # name is configurable
├── INDEX.md # generated — one line per entry: id | type | status | claim
└── entries/
└── exit-codes.md # one claim = one file
An entry is markdown with a YAML envelope and a free-form body — the first non-empty body line is the claim sentence (≤140 chars, lint-enforced) and the source of the INDEX line.
---
schema_version: 1
id: exit-codes
type: fact # fact | gotcha | recipe | topology | model
status: verified # verified | observed | inferred | stale | tombstone
written_at: 2026-07-14
verified_at: 2026-07-14 # last time the claim itself was confirmed (≠ last edit)
anchor: 864fd19c47d... # commit at which the claim was last confirmed
scope: # "what would have to change for this to become wrong?"
- src/cli/**
evidence: | # the anti-slop core: what earned the claim
Encoded in src/cli/cli.ts: the lint handler returns 1 on any violation ...
---
memkeep uses exit codes 0 pass / 1 violation-or-suspect / 2 tool error; verify propagates the wrapped check's own exit.Claim bodies stay free-form; only the envelope is normative. Agents may add
their own frontmatter fields, link entries, and structure entries/ however
serves retrieval — lint validates reserved fields and ignores unknown ones.
| Path | Responsibility |
|---|---|
src/index.ts |
Bin entry — thin wrapper over cli.run. |
src/cli |
Subcommand dispatch, handlers, text/JSON rendering, exit mapping. |
src/entry |
Frontmatter model, parse/serialize, content-field classification. |
src/glob |
Root-anchored, positive-only gitignore-style matcher. |
src/stale |
The pure staleness core (RepoView/Clock interfaces + fixpoint compute). |
src/refs |
Pure body path-ref extraction for the opt-in ref_missing signal (code-marked spans → path refs). |
src/contracts |
Reason, Verdict, Summary, StatusReport JSON types. |
src/git |
Shell-out RepoView (changedFiles, ancestorOf, head, trackedFiles). |
src/check |
The check runner — runs an entry's check command; maps exit 0→pass / 1→fail / other→toolError (pure over a Runner seam; ShellRunner shells out via sh -c). |
src/store |
Store discovery, precedence, .memkeep.yml, entry/INDEX IO. |
src/lint |
Schema and contract rules + INDEX consistency. |
src/verify |
The write path — the heal bump planner and the byte-stable frontmatter writer that verify heals with. |
src/merr |
Categorized error (Tool/Violation) → exit-code mapping. |
agent-memory/ |
Dogfood store (memkeep's memory about itself). |
npm run build # tsup → dist/index.js
npm run typecheck # tsc --noEmit
npm test # vitest: unit + real-git + subprocess golden + dogfoodBuilt test-first throughout. src/stale is pure and depends only on the
RepoView/Clock interfaces, so staleness is unit-tested with fakes; real-git
behavior is covered by src/git integration tests; the CLI's exit-code
contract is pinned by subprocess golden tests in test/clitest.test.ts.
.claude/settings.json wires three read-only, non-blocking hooks that
inject the suspect summary for the working agent:
SessionStart→memkeep status || true— every session opens with the staleness picture (verbose even when clean).PreCompact→memkeep status --hook PreCompact || true— the suspect set is re-injected right before compaction, so it survives instead of being evicted.Stop→memkeep status --hook Stop || true— the suspect set is re-surfaced before the agent finishes, as a verify-before-finishing reminder.
--hook <event> emits a Claude Code additionalContext envelope (carrying <event>
as the required hookEventName) and always exits 0 —
it never blocks the session. A blocking Stop (exit 2) would hard-loop on a store
with chronic suspects; the non-blocking nudge surfaces state and lets the agent
judge. --hook <event> is silent when the store is clean, so a healthy repo adds no
noise. The hooks invoke the command by name, so run npm install -g . once per
machine before they will fire.
memkeep is built slice by slice:
- Slice 1: read-path MVP —
index/status/ minimallint+ the pure staleness core, dogfooded in-tree. (shipped) - Slice 2: full
lintdepth —model/tombstonerules, theexplainsdependency graph, thettl_daysstatus cap, status-conditional evidence, andlint --fix. (shipped) - Slice 3: the
verifywrite path — per-type bumps, tool-error isolation,--all-safebatch heal,--dry-runpreview, the reservedenvironment:field. (shipped) - Slice 4a (shipped): the opt-in
ref_missingreferential staleness signal (DESIGN §3.6) — body path refs checked against the HEAD tree. - Slice 4 (next): multi-repo federation — the
sibling_missingsignal and multi-anchor (context-repo) verify.