Claude Code plugin · v0.2.1

project⁠-⁠notes

Claude forgets everything when a session ends. This plugin gives it a notebook that survives — distilled notes it writes for its own future self, kept fresh by hooks, invisible to git.

5
Lifecycle hooks
6
Pure lib modules
0
npm deps
126
Tests passing
16+
Node version
The idea

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 and refreshed on every prompt, 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 freedom principle

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.

How it's built

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.

skills/project-notes/SKILL.mdthe protocol Claude follows — the contract, what to write, when
one topic per filedistill, don't transcribeprune & merge freely
hooks/thin adapters — parse the event, call lib, emit the verdict
session-start.js pre-tool-use.js post-tool-use.js user-prompt-submit.js stop.js
lib/pure logic, zero deps, unit-tested in isolation
notes.js — format & index match.js — covers globs session-state.js — per-turn memory backup.js — version ring metrics.js — effectiveness record hook-io.js — stdin/opt-out/errors

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)
└── .metrics/             # the effectiveness record
    ├── events.jsonl     # append-only log, one line per turn (bounded at 4000)
    ├── dashboard.html   # static page — open it in a browser, no server needed
    └── data.js          # the only file rewritten per turn; the page never changes

The dot-prefixed .state/, .backups/ and .metrics/ are runtime plumbing — the index generator ignores anything starting with a dot, so only real topic notes ever get indexed. That single rule is what lets new runtime directories be added without touching the note format.

The engine

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

bootstrap

Creates .project-notes/, hides it from git, prunes stale state, regenerates the index, and injects the notebook protocol + current index into Claude's context.

fires: SessionStart · matcher: all

user-prompt-submit

reset + index

A 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, then re-injects the freshly regenerated index (skipped when the notebook is empty) so every message sees notes written earlier this session.

fires: UserPromptSubmit · matcher: all

pre-tool-use

backup

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

fires: PreToolUse · matcher: Edit|Write|MultiEdit|NotebookEdit

post-tool-use

record + index

After every edit or read: stamps updated: on written notes, regenerates INDEX.md, and records what happened this turn — code edits, note writes, exploration count, and which notes were opened. Reading a topic note counts as a consult; reading anything else is exploration.

fires: PostToolUse · matcher: …Edit|Write|Read|Grep|Glob

stop

the freshness guarantee

When Claude tries to finish a turn, this hook is the enforcer — and it gets exactly one block per turn, so three jobs are strictly ranked. 1. Edited code covered by notes that weren't refreshed → blocked, with the stale topics named. 2. Otherwise, if the turn opened a note → a non-declinable request for one 0–10 score of what those notes supplied. 3. Otherwise, heavy exploration with nothing written → a single declinable nudge. Everything else passes silently, and it never loops.

fires: Stop · matcher: all · threshold: 5 exploration tools · also logs every turn
A turn, start to finish

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.

1
SessionStart · once per session

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.

2
UserPromptSubmit · you send a message

A fresh turn starts

The per-turn state is reset to empty: { codeEdits: [], noteWrites: [], noteReads: [], explorationCount: 0 }. Then the index is regenerated and re-injected into context (unless the notebook is empty), so this turn sees any notes written earlier in the session.

3
PostToolUse · every tool Claude runs

The turn is recorded as it happens

Opening a topic note records a consult, deduped so re-reading one note counts once. Any other Read, plus Grep and Glob, bumps the exploration count. Editing a code file records the path — unless it's inside .project-notes/, which is the notebook's own plumbing, not your code. Writing a note stamps its timestamp, rebuilds the index, and marks that topic freshened.

4
PreToolUse · right before each note overwrite

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.

5
Stop · Claude tries to end the turn

The turn is judged, then recorded

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. Otherwise, opened a note? A required score for how much it helped. Otherwise, explored a lot and wrote nothing? A single declinable nudge. Either way the turn is appended to the effectiveness log and the dashboard is refreshed — and if metrics writing fails, it's swallowed, so it can never cost you the freshness block.

The note format

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.

.project-notes/auth-flow.md
---
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.
summary:

One line. Becomes this topic's line in the generated index.

covers:

Code paths this note explains. When Claude edits code under one of these, the Stop hook requires the note to be refreshed.

updated:

Never written by hand — post-tool-use stamps it. Hand-editing INDEX.md is likewise pointless; it's regenerated.

↓ generates one index line
- auth-flow — How a request is
  authenticated… [covers: src/auth/,
  middleware/session.ts] (updated: …)

How covers: matches an edited file

Pattern formExampleMatches
Directory prefixsrc/auth/any file whose path starts with src/auth/
Exact filemiddleware/session.tsthat one file, exactly
Single-segment globsrc/*.ts* matches within one path segment (no /)
Cross-segment globsrc/**/*.ts** crosses directories; **/ matches zero or more segments
The core

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.

notes.jsthe note format
Parses the tolerant YAML subset (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.
match.jscovers globs
Translates a 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.
session-state.jsper-turn memory
The bridge from post-tool-use to stop. One JSON file per session under .state/, written atomically (temp-then-rename). Tracks codeEdits, noteWrites, noteReads, explorationCount; resets each turn and prunes files older than 7 days. Each field is validated independently on load, so state written by an older version still loads.
metrics.jsthe effectiveness record
Owns the append-only event log, the pure aggregation over it, and the two files the dashboard is made of. Drops orphan scores that match no turn, keeps only the last score per turn, and returns null rather than 0% when a rate has no eligible turns. Skips unparseable lines so a torn write costs one turn, not the history.
backup.jsversion ring
Because notes are outside git, a bad rewrite is otherwise gone. Keeps a bounded ring of the 5 most recent versions per topic under .backups/<topic>/, pruning the oldest past the limit.
hook-io.jsthe safety contract
Shared plumbing every hook runs through: read & parse the stdin event, honor the opt-out marker, and wrap main so any error goes to stderr with exit 1 — never a throw that breaks the session.
Does it actually help?

The notebook keeps score on itself

A memory system that nobody can measure is a memory system you have to take on faith. On every turn where Claude opens a note, it's required to rate what that note gave it about the project that your prompt and the code did not — and the hooks record what they can see without asking.

The dashboard: good score rate, mean score and covers-hit rate as tiles; a score distribution coloured from orange (added nothing) through grey to blue (knowledge Claude could not have derived); 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 and every number's derivation are the real thing — the turns behind them are synthetic, because one project's actual figures would tell you nothing about yours. Open yours at .project-notes/.metrics/dashboard.html.

Two record types, joined by turn

// written by the hook, every turn — this is what gives you a denominator
{"t":"turn", "id":"a1b2-0007", "noteCount":2, "coversHit":true,
 "notesRead":["turn-lifecycle","note-format"], "edits":3, "blocked":"none"}

// written by Claude, only when asked — a whole-file write, never an append
{"t":"score", "id":"a1b2-0007", "score":8, "comment":"index pointed at the right file"}

Claude hands its score over by writing a small pending.json, which the hook folds into the log. It is never asked to append to the log itself — one careless whole-file write there would erase the entire history.

Observed, not claimed

Covers hit comes from hook data and can't be talked up: the note Claude opened actually covered the file the turn went on to change — the index pointed at the right note, not merely at a note. Good score rate — the share of scored turns rated above 6 — is derived from the self-reported score, so read it with the caveat beside it.

The score's built-in caveat

The score asks what the notes supplied, not how the turn turned out — the notebook is a helper, not a replacement for Claude's own work. But it is still Claude grading its own reading, and ratings bunch high. So the dashboard plots the distribution itself: if it's compressed at 7–9, that's visible rather than hidden behind a mean.

Not a note-quality review

It measures one thing: whether the notebook helps work get done. No staleness flags, no per-note grades — deliberately, so the measurement can never start shaping what Claude chooses to write down.

What it promises

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.

Nothing is sent anywhere

The effectiveness log is a local file in your project, read by a local HTML page with no network access of any kind. There is no telemetry, no upload, and no cross-project aggregation — note names and file paths would leak your repo's structure, so they never leave the machine. The log is bounded at 4000 events, oldest dropped, and the opt-out marker disables it along with everything else.

Correctness & distribution

Tested at two seams, shipped as a marketplace

118 tests, two seams

  • Pure-function unitslib/ 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   # 13 files · 118 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 .