A notebook Claude keeps for itself
Every Claude Code session starts from zero. What it learned last time — how a subsystem works, which files matter, the gotcha that cost an hour — evaporates when the context window closes.
project-notes fixes that with a simple loop: Claude keeps distilled topic notes in your project at .project-notes/. An index of them is injected into context at the start of every session, so Claude picks the few notes relevant to the task instead of re-reading the whole codebase. Hooks make sure the notes stay honest as the code changes.
The hooks enforce that the notebook stays trustworthy — index integrity, freshness, backups. Everything about what it says — which topics exist, what goes in them — is Claude's own judgment, the way a person keeps notes of what will help them later. The notes are written for a future model to read, not for you.
Three layers, one direction of dependency
Pure logic at the bottom knows nothing about Claude Code. Thin hook adapters wire it to session events. A skill tells Claude the protocol.
Where everything lives on disk
.project-notes/ # at your project root; created on session start
├── auth-flow.md # a topic note (frontmatter + distilled prose)
├── build-and-test.md # another topic — one file per subsystem/concept
├── INDEX.md # generated from every note's frontmatter; never hand-edited
├── .state/ # per-turn scratch, one JSON per session (pruned after 7 days)
└── .backups/ # bounded ring of prior note versions (5 per topic)
The dot-prefixed .state/ and .backups/ are runtime plumbing — the index generator ignores anything starting with a dot, so only real topic notes ever get indexed.
Five hooks, each with one job
Claude Code fires events across a session's life. Each hook is a small Node script: JSON event on stdin → a verdict (or nothing) on stdout. Errors go to stderr and exit non-zero, so a bug never breaks your session.
session-start
bootstrapCreates .project-notes/, hides it from git, prunes stale state, regenerates the index, and injects the notebook protocol + current index into Claude's context.
user-prompt-submit
resetA new prompt begins a new turn. Wipes the per-turn state so edits from the last turn don't leak into this turn's freshness check.
pre-tool-use
backupJust before a note file is overwritten, snapshots its current content into the backup ring. A brand-new note has nothing to save, so it's skipped naturally.
post-tool-use
record + indexAfter every edit or exploration: stamps updated: on written notes, regenerates INDEX.md, and records what happened this turn — code edits, note writes, exploration count.
stop
the freshness guaranteeWhen Claude tries to finish a turn, this hook is the enforcer. If the turn edited code covered by topic notes that weren't refreshed, it blocks once and names exactly the stale topics. If the turn was heavy exploration with nothing written down, it issues a single declinable nudge. Everything else passes silently. It never loops — a stop_hook_active flag means it blocks at most once per turn.
How the hooks cooperate across one turn
The hooks don't act alone — they pass a small per-turn state file to each other, written by post-tool-use and read by stop. Here's the whole handoff.
The notebook wakes up
Directory ensured, git-exclusion applied, old state pruned, index rebuilt and injected. Claude begins the session already knowing what past sessions learned.
A fresh turn starts
The per-turn state is reset to empty: { codeEdits: [], noteWrites: [], explorationCount: 0 }.
The turn is recorded as it happens
Read/Grep/Glob bump the exploration count. Editing a code file records the path. Writing a note stamps its timestamp, rebuilds the index, and marks that topic as freshened.
The old version is preserved
Because notes are excluded from git, an in-place rewrite would otherwise be unrecoverable. The prior content is copied into the backup ring first.
The freshness check runs
It reads the turn's state and every note's covers:. Edited a covered file but didn't refresh its note? Blocked, with the exact stale topics named. Explored a lot and wrote nothing? A single, declinable nudge. Otherwise the turn ends cleanly.
One topic, one file, a tiny contract
The only mechanical rule is the YAML frontmatter — the hooks rely on it. The prose below it is free-form distilled understanding: how a thing works, why, the non-obvious gotchas, and file:line pointers instead of pasted code.
---
summary: How a request is authenticated and where sessions live.
covers: [src/auth/, middleware/session.ts]
updated: 2026-07-04T09:12:00Z # stamped automatically
---
Entry point: `middleware/session.ts:20` reads the `sid`
cookie and loads the session via `src/auth/store.ts:44`.
Public routes are the allow-list in `routes.ts:8`.
Gotcha: tokens are validated but NOT refreshed here;
refresh is a separate cron. An expired-but-present
token still 401s — surprised me, cost an hour.
One line. Becomes this topic's line in the generated index.
Code paths this note explains. When Claude edits code under one of these, the Stop hook requires the note to be refreshed.
Never written by hand — post-tool-use stamps it. Hand-editing INDEX.md is likewise pointless; it's regenerated.
- auth-flow — How a request is
authenticated… [covers: src/auth/,
middleware/session.ts] (updated: …)
How covers: matches an edited file
| Pattern form | Example | Matches |
|---|---|---|
| Directory prefix | src/auth/ | any file whose path starts with src/auth/ |
| Exact file | middleware/session.ts | that one file, exactly |
| Single-segment glob | src/*.ts | * matches within one path segment (no /) |
| Cross-segment glob | src/**/*.ts | ** crosses directories; **/ matches zero or more segments |
Inside lib/
All the real logic lives here as pure functions — no session knowledge, so it's tested directly against temp directories. The hooks are just thin wiring on top.
summary, covers, updated — inline and block lists, BOM-safe), upserts the updated: stamp while preserving line endings, and renders INDEX.md from every note's frontmatter. Also owns the shared constants: .project-notes, INDEX.md, the write-tool list.covers: pattern to a regex char-by-char (handling *, **, **/ and directory prefixes), then classifyEdits maps this turn's edited paths onto topics — returning which notes are stale and which edits are covered by no topic at all..state/, written atomically (temp-then-rename). Tracks codeEdits, noteWrites, explorationCount; resets each turn and prunes files older than 7 days..backups/<topic>/, pruning the oldest past the limit.main so any error goes to stderr with exit 1 — never a throw that breaks the session.Design guarantees
Invisible to git — without .gitignore
The notes are added to .git/info/exclude, the local-only ignore file. Teammates, diffs, and commits never see them, and your .gitignore stays untouched. Works in plain repos, linked worktrees, and non-git folders alike.
Never breaks your session
Every hook is wrapped so a failure exits non-zero to stderr and is ignored by Claude Code. A bug in the plugin can degrade note-keeping — it can't stop you working.
Notes never expire
The 7-day pruner only touches throwaway .state/ scratch. Topic notes and the index are never aged out — they persist until Claude deliberately edits or deletes them.
One-file opt-out
Drop a .project-notes-off file at the project root and every hook becomes a no-op — no directory, no injection, no tracking, no blocks. Delete it to re-enable.
Tested at two seams, shipped as a marketplace
88 tests, two seams
- Pure-function units —
lib/logic tested directly against temp dirs. - Hook-process boundary — real Node processes spawned with real event JSON, no mocks.
- Zero dependencies to install — the runner uses Node's built-in
node:test.
node tests/run-all.js # 10 files · 88 tests · green
Install from the marketplace
The repo doubles as its own single-plugin marketplace via .claude-plugin/marketplace.json.
/plugin marketplace add <user>/project-notes
/plugin install project-notes@project-notes
Or try it locally with no install: claude --plugin-dir .