A Claude Code plugin that gives Claude a persistent, self-maintained notebook in every project it works in — so each new session starts already knowing what previous sessions learned, without you prompting it.
Claude Code sessions are amnesiac: knowledge earned in one session (how a
subsystem works, which files matter, why a decision was made) vanishes when the
context window closes. project-notes fixes that. Claude keeps distilled topic
notes in your project, an index of them is injected at the start of every
session and refreshed on every prompt, and hooks make sure the notes stay
current as the code changes.
A full walkthrough — the idea, the architecture, the five hooks, the note format, and the design guarantees — is rendered as a standalone page:
▶ Open the full visual walkthrough
Served via GitHub Pages — enable it once under Settings → Pages (source:
mainbranch, root folder) and the link goes live. Prefer no setup? The raw fileproject-notes-explained.htmlis self-contained — download it and open in any browser, or view it rendered via htmlpreview.
- Notes live at
.project-notes/inside your project, as a sibling of.git—.gitdecides the root, so a session started in a subfolder writes to the one notebook at the top and a stray notebook deeper in the tree can never capture it. The nearest.gitwins, so nested repos and linked worktrees each get their own; without git, an existing notebook above you is adopted instead. Either way they travel with the folder. They're kept invisible to git via.git/info/exclude(never.gitignore), so teammates, diffs, and commits never see them. Non-git projects work identically. - One topic per file, each with YAML frontmatter (
summary:,covers:, and an auto-stampedupdated:) plus distilled, pointer-rich understanding. INDEX.mdis generated from the notes' frontmatter and injected into context at the start of every session and refreshed on every prompt — so it reflects notes written mid-session. Claude reads only the notes it needs.- Hooks enforce freshness. Edit code that a topic covers, and the Stop hook won't let the turn end until you refresh that note. Explore heavily without writing anything down, and it gives a single, declinable nudge.
- Backups. Every note is snapshotted before it's overwritten (bounded ring
under
.project-notes/.backups/), so a bad rewrite can't destroy knowledge that git can't recover. - Bash-routed file access is tracked too. With auto mode (or any Bash-first
habit) Claude reads with
cat, explores withgrepand edits withsed -i, none of which look likeRead/Editto a hook. The PostToolUse hook matchesBashas well: notes changed by any means are detected from their mtime rather than by parsing the command, so a note written by heredoc is still stamped and indexed, and writes to covered code still reach the freshness check. See limits. - Content is Claude's judgment. The plugin guarantees that notes stay honest; what they say — topics, organization, pruning — is up to Claude, like a person's own notebook.
Every turn where Claude opens a note, it's asked for one 0–10 score — not for how the turn turned out, but for what the notes told it about this project that your prompt and the code in front of it did not — plus a ten-word comment. The notebook is a helper, not a replacement, so still having to do the work is no markdown. Alongside it the hooks record what they can observe on their own — whether the note consulted turned out to cover the file the turn went on to edit. The headline figure is the share of scored turns rated above 6: how often the notebook helped when it was used.
Illustrative data. The layout is the real dashboard, rendered from the real aggregation code — the turns behind it are synthetic, since a screenshot of one project's actual numbers would say nothing about yours.
To see it, open this file in a browser:
.project-notes/.metrics/dashboard.html
It's a plain local file — no server, no build, no network. It stays current on its
own: the page itself is written once and only a small sibling data.js is
rewritten as turns accumulate, so there's nothing to run before you open it.
Scope is deliberately narrow — it measures whether the notebook helps the work
get done, and records nothing about whether the notes are correct or well
written. The score is still Claude grading its own reading, so the dashboard shows
its own distribution and labels which figures are observed rather than claimed. The log is bounded (4000 events, oldest dropped) and never leaves
the machine; .project-notes-off disables it with everything else.
The five hooks are Node scripts, invoked as node <script>. There are no
package dependencies — but the node binary itself has to be resolvable by the
non-interactive shell Claude Code runs hooks in (sh -c on macOS/Linux; Git
Bash, or PowerShell if Git Bash is absent, on Windows). Node 16 or newer.
node --version # if this prints nothing, the hooks cannot run
On Windows the Node MSI puts node on the system PATH, so this is usually
already true. On macOS it is not automatic — Claude Code installed via Homebrew
cask is a native binary and brings no Node of its own:
brew install node
If you manage Node with nvm, fnm, or asdf, note that those put
node on your interactive shell's PATH only. A hook runs in a
non-interactive shell that never sources your ~/.zshrc, so node can be
missing there even though it works when you type it. Either install a
system-wide Node as above, or symlink your managed one somewhere already on the
default PATH (e.g. ln -s "$(which node)" /usr/local/bin/node).
When node cannot be found, every hook exits with command not found and the
plugin does nothing at all: no notebook is created, no index is injected, no
freshness block ever fires. It looks installed and is inert — so check
node --version first.
/plugin marketplace add git-aditya-star/project-notes
/plugin install project-notes@project-notes
To develop or try it locally without installing:
claude --plugin-dir /path/to/project-notes
The plugin registers its own hooks; you never hand-edit settings files.
Create a file named .project-notes-off at the project root. Every hook then
does nothing there — no directory, no injection, no tracking, no blocks. Delete
the file to re-enable.
No embeddings/semantic search (a markdown index is enough at this scale), no session journals or history archaeology, no cross-project/team knowledge, and no human-facing note UI — the notes are written for a model to read.
Bash write detection is bounded, on purpose. lib/bash-targets.js is not a
shell parser. It recognizes a fixed set of high-confidence writers — output
redirection, tee, sed -i, mv/cp/install, touch, dd of= — skips
/dev/null and fd duplication, and strips heredoc bodies so a note's own
markdown is never scanned for commands. Writes performed by an interpreter
(python -c, node -e, awk) are out of reach and report nothing rather than
guessing. The asymmetry is deliberate: a missed write costs at most a nudge,
while a wrong one blocks a turn for no reason.
Note changes do not depend on that scan — they are detected from mtime against a ledger seeded at SessionStart, so any means of writing a note is caught.
Two sessions in one project share some state. Per-turn state is keyed by
session id, so the freshness guarantee is unaffected — each session is judged on
its own edits and blocks independently (there is a test for exactly this). Two
things are genuinely shared: the mtime ledger, and the events.jsonl metrics
log. The ledger is shared on purpose — the note on disk is shared too — so
stamping and indexing happen once, for whichever session notices first; credit
for the write is decided separately, from the command that names the note, so a
bystander session cannot absorb another's note write. The metrics log is
append-only (O_APPEND), so simultaneous Stop hooks interleave whole lines
instead of clobbering each other — 60 forced-parallel writes lose nothing and
tear nothing. Trimming to MAX_EVENTS is a separate, rare rewrite that runs
only once the cap is exceeded, and always after the new record is already on
disk. The score handover is one file per session for the same reason.
The one remaining shared-state race is benign by construction: two sessions reconciling the mtime ledger at once can stamp a note twice, never leave one unstamped, because a lost ledger entry makes the note look changed again rather than unchanged.
- Hook scripts are plain Node.js, zero dependencies, Node 16-compatible, cross-platform. Keep them that way — the plugin must run for users with no install step.
- Tests run with
node tests/run-all.js(uses the built-innode:test; no dependencies to install). Two seams: the hook-process boundary (real processes against temp dirs, no mocks) and direct unit tests of the pure functions inlib/. lib/holds pure logic (note format, matching, backups, state);hooks/are thin adapters over it;skills/project-notes/SKILL.mdis the protocol Claude follows.- Never split a shell command on a bare separator.
;,|and&occur inside quotedsedscripts. A naivesplit(/[;|&]/)toresed -i '' 's/^const TTL_MS = 60_000;$/.../' src/cache.jsin half: the real target was missed and the script's own words (TTL_MS,=,60_000) were recorded as edited files.segmentsOfinlib/bash-targets.jsis quote-aware — go through it. - The note ledger is a ledger, not a clock comparison. Stamping a note
rewrites it and bumps its own mtime, so comparing mtime against
updated:re-stamps forever.lib/note-sync.jsrecords the post-stamp mtime instead, and a missing ledger adopts silently rather than claiming every note as written-this-turn — a false note-write would suppress the staleness block, which is worse than a missing stamp. - Never compare paths with a bare
path.relative. The project root and thefile_patha tool reports can be two spellings of one location — macOS routes/tmp,/varand/etcthrough symlinks into/private, and a symlinked project dir (~/code -> /Volumes/Data/code) is common. Go throughrelativeTo/canonicalinlib/paths.js, which resolve both sides first (and handle a path that does not exist yet, sincePreToolUsefires before the file is written). A raw compare answers../../private/..., the file reads as outside the project, and edits are dropped silently.
