Skip to content

Repository files navigation

project-notes

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.

Visual overview

A full walkthrough — the idea, the architecture, the five hooks, the note format, and the design guarantees — is rendered as a standalone page:

project-notes visual walkthrough

▶ Open the full visual walkthrough

Served via GitHub Pages — enable it once under Settings → Pages (source: main branch, root folder) and the link goes live. Prefer no setup? The raw file project-notes-explained.html is self-contained — download it and open in any browser, or view it rendered via htmlpreview.

How it works

  • Notes live at .project-notes/ inside your project, as a sibling of .git — .git decides 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 .git wins, 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-stamped updated:) plus distilled, pointer-rich understanding.
  • INDEX.md is 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 with grep and edits with sed -i, none of which look like Read/Edit to a hook. The PostToolUse hook matches Bash as 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.

Is it actually helping?

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.

The project-notes dashboard: good score rate, mean score and covers-hit rate as summary tiles, a score distribution coloured by how much the notes helped, mean score split by whether the turn edited code, a mean-score-over-time line, and the latest ten-word remarks.

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.

Install

Requirement: Node.js on PATH

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.

Then

/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.

Opting out of a project

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.

What it deliberately doesn't do

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.

Contributing

  • 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-in node: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 in lib/.
  • lib/ holds pure logic (note format, matching, backups, state); hooks/ are thin adapters over it; skills/project-notes/SKILL.md is the protocol Claude follows.
  • Never split a shell command on a bare separator. ;, | and & occur inside quoted sed scripts. A naive split(/[;|&]/) tore sed -i '' 's/^const TTL_MS = 60_000;$/.../' src/cache.js in half: the real target was missed and the script's own words (TTL_MS, =, 60_000) were recorded as edited files. segmentsOf in lib/bash-targets.js is 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.js records 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 the file_path a tool reports can be two spellings of one location — macOS routes /tmp, /var and /etc through symlinks into /private, and a symlinked project dir (~/code -> /Volumes/Data/code) is common. Go through relativeTo/canonical in lib/paths.js, which resolve both sides first (and handle a path that does not exist yet, since PreToolUse fires before the file is written). A raw compare answers ../../private/..., the file reads as outside the project, and edits are dropped silently.

About

A Claude Code plugin that gives Claude persistent, self-maintained project notes across sessions

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages