Claude Code plugin · v0.1.0

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
5
Pure lib modules
0
npm deps
88
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, 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 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)

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.

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

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.

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 exploration: stamps updated: on written notes, regenerates INDEX.md, and records what happened this turn — code edits, note writes, exploration count.

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

stop

the freshness guarantee

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

fires: Stop · matcher: all · threshold: 5 exploration tools
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: [], explorationCount: 0 }.

3
PostToolUse · every tool Claude runs

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.

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

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, explorationCount; resets each turn and prunes files older than 7 days.
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.
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.

Correctness & distribution

Tested at two seams, shipped as a marketplace

88 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   # 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 .