Notes stuck on files: when the agent reads, edits or writes a file, its stickers are shown automatically.

Add sticky notes to files, for Claude Code. When Claude reads, edits or writes a file, the stickers on that file are shown to it automatically, so that the agent does not miss important gotchas or lessons related to that file. The agent does not need to remember manually looking up related knowledge stored separately.
A sticker is a markdown file (any name) in a .stickers/ folder, starting with a header that names the file or files it is stuck on:
---
on: src/auth/session.py, src/auth/tokens.py
---
Do NOT reorder the imports. tokens.py monkey-patches time.time() on import; if session.py loads first, every token is issued in 1970 and expires instantly, but only in production, because tests mock the clock.
stickers:add-sticker, tells Claude and its subagents what stickers are and how to write one.src/auth/session.py, it automatically sees: Stickers on src/auth/session.py (1):
- .stickers/import-order.md (edited 2 h ago; also on src/auth/tokens.py)
Do NOT reorder the imports. tokens.py monkey-patches time.time() on import; if session.py loads first, every token is issued in 1970 and expires instantly, but only in production, because tests mock the clock.
Later reads do not see the same note repeated, instead: Stickers on src/auth/session.py: 1, unchanged since shown. Past 4,000 characters only the sticker paths are listed.
/compact or /clear will show the stickers again.| Folder | What it's for | on: paths are |
|---|---|---|
.stickers/ in project root or any folder | Notes that belong with the project or folder | Relative to the folder holding that .stickers/ |
~/.claude/stickers/ | Personal notes, kept out of every project, or notes on files outside any project | Absolute, or starting with ~/; relative paths are ignored |
.stickers/ in its own folder and in every folder above it, plus ~/.claude/stickers/.claude plugin marketplace add sammer1107/claude-stickers
claude plugin install stickers@claude-stickers
Stickers is a Claude Code mod: a TypeScript hooks module (hooks/register.ts) with its logic in hooks/stickers.ts, plus a skill (skills/add-sticker).
npm install # TypeScript, for type-checking
claude plugin validate . # what the engine will load, and what it would refuse
claude plugin test . # tests/*.test.ts, run in the engine's own test kit
To run your clone, use claude plugin marketplace add /path/to/claude-stickers instead.
Type-checking (npx tsc -p .) works once Claude Code has loaded the mod from this folder at least once; loading it generates the type files in .claude-plugin/types/.
hooks/register.ts 182 lines1// Shows an agent the stickers on a file whenever it reads, edits or writes that file. How to
2// write one is in the plugin's skill (skills/add-sticker), whose description every agent and
3// subagent sees; a system prompt section would be dropped where an organization seats
4// sec-default. No tool of its own: stickers are plain files written with the normal file tools.
5
6import type { EngineInterface, Register } from 'claude-code'
7import type { Shown } from '../types'
8import {
9 type Places,
10 type Sticker,
11 absolutePath,
12 displayPath,
13 formatStickers,
14 formatUnchanged,
15 isStickerFile,
16 isWindowsRoot,
17 matchKey,
18 parseSticker,
19 signature,
20 stickerFolders,
21} from './stickers'
22
23const SHOWN = { plugin: 'stickers', key: 'shown' } as const
24const MAIN = 'main' // the main conversation's loop, which carries no agentId
25
26export const register: Register = on => {
27 // After the tool has run, add the file's stickers to what the model reads (the user never sees it).
28 // Each agent (main conversation, each subagent) has its own context, so its own "already shown".
29 // Only a Read gets the short "unchanged" reminder: an Edit or Write always follows a Read of
30 // the file, and the reminder would arrive after the change was already made.
31 // If anything here fails, the tool's own result goes through unchanged.
32 on('tool.call', { tool: 'Read' }, ($, e, next) => withStickers($, e.agentId ?? MAIN, e.file_path, true, next(e)))
33 .catch(($, e, next) => next(e))
34 on('tool.call', { tool: 'Edit' }, ($, e, next) => withStickers($, e.agentId ?? MAIN, e.file_path, false, next(e)))
35 .catch(($, e, next) => next(e))
36 on('tool.call', { tool: 'Write' }, ($, e, next) => withStickers($, e.agentId ?? MAIN, e.file_path, false, next(e)))
37 .catch(($, e, next) => next(e))
38
39 // Compaction summarises away what was shown, so that agent sees its stickers in full again.
40 // (A precomputed compaction counts too: at worst one extra full display, never a missed note.)
41 on('session.compact', async ($, e, next) => {
42 const result = await next(e)
43 if (result.messages) await forget($, e.agentId ?? MAIN)
44 return result
45 }).catch(($, e, next) => next(e)) // never get in the way of a compaction
46 // /clear starts the conversation over: nothing has been shown to anyone.
47 on('session.end', async ($, e, next) => {
48 if (e.reason === 'clear') await $.state.set(SHOWN, {})
49 return next(e)
50 })
51}
52
53/** Drops everything shown to one agent, so its next opens show stickers in full. */
54async function forget($: EngineInterface, agent: string): Promise<void> {
55 const shown: Shown = (await $.state.get(SHOWN)).value ?? {}
56 const kept = Object.fromEntries(Object.entries(shown).filter(([key]) => !key.startsWith(`${agent}|`)))
57 await $.state.set(SHOWN, kept)
58}
59
60async function withStickers<R extends { deny?: string; isError?: boolean; context?: readonly string[] }>(
61 $: EngineInterface,
62 agent: string,
63 filePath: string,
64 remind: boolean,
65 running: Promise<R>,
66): Promise<R> {
67 const ran = await running
68 if (ran.deny !== undefined || ran.isError) return ran
69 let note: string | null = null
70 try {
71 note = await stickerNote($, agent, filePath, remind)
72 } catch {
73 return ran // stickers must never break a file tool
74 }
75 return note ? { ...ran, context: [...(ran.context ?? []), note] } : ran
76}
77
78/**
79 * The text to show for the file at `filePath`, or null when there is nothing to show: no
80 * stickers, or nothing new and no reminder wanted (`remind` is false).
81 */
82async function stickerNote(
83 $: EngineInterface,
84 agent: string,
85 filePath: string,
86 remind: boolean,
87): Promise<string | null> {
88 const root = await $.session.root()
89 const places: Places = {
90 root,
91 home: (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? null,
92 windows: isWindowsRoot(root),
93 }
94 const file = absolutePath(filePath, root, places.home)
95 if (!file) return null
96 if (isStickerFile(file, places)) {
97 // A sticker was read or written: its folder's listing may be stale now.
98 folderCache.delete(matchKey(file.slice(0, file.lastIndexOf('/')), places.windows))
99 return null
100 }
101 const fileKey = matchKey(file, places.windows)
102
103 const stickers = (await readAllStickers($, file, places)).filter(s =>
104 s.targets.some(t => matchKey(t, places.windows) === fileKey),
105 )
106 if (stickers.length === 0) return null
107
108 // Full text the first time this agent opens the file, and whenever a sticker changed;
109 // otherwise a short reminder, or nothing.
110 const target = displayPath(file, places)
111 const shown: Shown = (await $.state.get(SHOWN)).value ?? {}
112 const key = `${agent}|${fileKey}`
113 const sig = signature(stickers)
114 if (shown[key] === sig) return remind ? formatUnchanged(target, stickers.length) : null
115 await $.state.set(SHOWN, { ...shown, [key]: sig })
116 return formatStickers(target, fileKey, stickers, await $.clock.now(), places.windows)
117}
118
119/**
120 * The stickers that may apply to `file`: from the `.stickers/` in its folder and every folder
121 * above it (`on:` relative to the folder holding that `.stickers/`), and the user's (`on:`
122 * absolute or `~/...`; a relative path there is ignored, as it names no one project).
123 */
124async function readAllStickers($: EngineInterface, file: string, places: Places): Promise<Sticker[]> {
125 const stickers: Sticker[] = []
126 for (const { folder, base } of stickerFolders(file, places)) {
127 stickers.push(...(await readStickers($, folder, base, places)))
128 }
129 return stickers
130}
131
132// Folder listings, reused for FOLDER_TTL_MS: reading many files checks each ancestor folder
133// about once, not once per file. A missing folder is cached too (as null). The agent's own
134// changes to a sticker drop its folder's entry at once (see stickerNote); changes made
135// elsewhere show within the TTL.
136const FOLDER_TTL_MS = 10_000
137type Listing = Awaited<ReturnType<EngineInterface['fs']['list']>>
138const folderCache = new Map<string, { at: number; entries: Listing | null }>()
139
140async function listFolder($: EngineInterface, folder: string, windows: boolean): Promise<Listing | null> {
141 const key = matchKey(folder, windows)
142 const now = await $.clock.now()
143 const hit = folderCache.get(key)
144 if (hit && now - hit.at < FOLDER_TTL_MS) return hit.entries
145 let entries: Listing | null
146 try {
147 entries = await $.fs.list(folder)
148 } catch {
149 entries = null // no stickers folder here
150 }
151 folderCache.set(key, { at: now, entries })
152 return entries
153}
154
155// Parsed stickers by path, reused while a file's modification time is unchanged.
156const cache = new Map<string, { mtimeMs: number; parsed: { on: string[]; body: string } | null }>()
157
158async function readStickers($: EngineInterface, folder: string, base: string | null, places: Places): Promise<Sticker[]> {
159 const entries = await listFolder($, folder, places.windows)
160 if (!entries) return []
161 const stickers: Sticker[] = []
162 for (const entry of entries) {
163 if (entry.kind !== 'file' || !entry.name.toLowerCase().endsWith('.md')) continue
164 const path = `${folder}/${entry.name}`
165 let hit = cache.get(path)
166 if (!hit || hit.mtimeMs !== entry.mtimeMs) {
167 const text = await $.fs.read(path)
168 hit = { mtimeMs: entry.mtimeMs, parsed: typeof text === 'string' ? parseSticker(text) : null }
169 cache.set(path, hit)
170 }
171 if (!hit.parsed) continue
172 stickers.push({
173 path: displayPath(path, places),
174 mtimeMs: entry.mtimeMs,
175 on: hit.parsed.on,
176 targets: hit.parsed.on.map(p => absolutePath(p, base, places.home) ?? ''),
177 body: hit.parsed.body,
178 })
179 }
180 return stickers
181}
182hooks/stickers.ts 191 lines1// Stickers: notes stuck on files. Pure logic, no engine calls: parsing a sticker's header,
2// resolving and matching file paths, and the text the model reads when it opens a file.
3//
4// Stickers live in `.stickers/` folders: a file's stickers can come from the `.stickers/` in
5// its own folder or in any folder above it (each one's `on:` paths relative to the folder that
6// holds it), so a file in another project or a monorepo package finds its own. The user's
7// `~/.claude/stickers/` (its `on:` paths absolute or `~/...`) holds notes kept out of every
8// project. Every path is compared in absolute form, so all match a file however a tool spells it.
9
10export const STICKERS_DIR = '.stickers'
11export const USER_STICKERS_DIR = '~/.claude/stickers'
12export const MAX_SHOWN_CHARS = 4000
13
14/** Where paths are resolved from. */
15export type Places = {
16 /** The session's project root, absolute. */
17 root: string
18 /** The user's home folder, absolute, or null when unknown (no user stickers then). */
19 home: string | null
20 /** Windows paths: case is ignored, as the file system does. */
21 windows: boolean
22}
23
24export type Sticker = {
25 /** The sticker's own path as shown: `.stickers/x.md` or `~/.claude/stickers/x.md`. */
26 path: string
27 /** The files it is stuck on, as written in its `on:` header. */
28 on: string[]
29 /** The same files, absolute and normalised (see `absolutePath`); unusable entries dropped. */
30 targets: string[]
31 /** Its text, without the header. */
32 body: string
33 mtimeMs: number
34}
35
36/**
37 * Reads a sticker file: a front-matter header naming the files it is stuck on, then its text.
38 *
39 * ---
40 * on: src/auth/session.py, src/auth/tokens.py
41 * ---
42 * Do NOT reorder the imports. tokens.py monkey-patches time.time() on import ...
43 *
44 * Returns null when there is no header or no `on:` line, so a stray file is ignored.
45 */
46export function parseSticker(text: string): { on: string[]; body: string } | null {
47 const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text)
48 if (!match) return null
49 const onLine = (match[1] ?? '').split(/\r?\n/).find(line => /^on\s*:/.test(line))
50 if (!onLine) return null
51 const on = onLine
52 .replace(/^on\s*:/, '')
53 .split(',')
54 .map(p => p.trim().replace(/^["']|["']$/g, ''))
55 .filter(Boolean)
56 if (on.length === 0) return null
57 return { on, body: text.slice(match[0].length).trim() }
58}
59
60/** True for a Windows project root like `D:\work\app` or `D:/work/app`. */
61export function isWindowsRoot(root: string): boolean {
62 return /^[A-Za-z]:[\\/]/.test(root)
63}
64
65const clean = (p: string) => p.trim().replace(/\\/g, '/').replace(/\/+/g, '/')
66const isAbsolute = (p: string) => /^[A-Za-z]:\//.test(p) || p.startsWith('/')
67
68/**
69 * A path in one absolute, comparable spelling: forward slashes, no `.` or `..`, no trailing
70 * slash. `~` means the home folder; a relative path is resolved against `base`, or rejected
71 * when `base` is null. Returns null when it can't be resolved.
72 */
73export function absolutePath(path: string, base: string | null, home: string | null): string | null {
74 let p = clean(path)
75 if (p === '~' || p.startsWith('~/')) {
76 if (!home) return null
77 p = clean(home) + p.slice(1)
78 } else if (!isAbsolute(p)) {
79 if (!base) return null
80 p = `${clean(base)}/${p}`
81 }
82 const drive = /^[A-Za-z]:/.exec(p)?.[0] ?? ''
83 const parts: string[] = []
84 for (const part of p.slice(drive.length).split('/')) {
85 if (part === '' || part === '.') continue
86 if (part === '..') {
87 if (parts.length === 0) return null
88 parts.pop()
89 } else {
90 parts.push(part)
91 }
92 }
93 return `${drive}/${parts.join('/')}`
94}
95
96/** The key two absolute paths are compared by: case is ignored on Windows. */
97export function matchKey(absolute: string, windows: boolean): string {
98 return windows ? absolute.toLowerCase() : absolute
99}
100
101/** True when `absolute` is `dir` itself or inside it. */
102export function isUnder(absolute: string, dir: string, windows: boolean): boolean {
103 const a = matchKey(absolute, windows)
104 const d = matchKey(dir, windows).replace(/\/$/, '')
105 return a === d || a.startsWith(d + '/')
106}
107
108/** How a file is named to the model: relative inside the project, `~/...` in the home folder. */
109export function displayPath(absolute: string, places: Places): string {
110 const root = absolutePath(places.root, null, null)
111 if (root && isUnder(absolute, root, places.windows) && absolute.length > root.length) {
112 return absolute.slice(root.length + 1)
113 }
114 const home = places.home && absolutePath(places.home, null, null)
115 if (home && isUnder(absolute, home, places.windows) && absolute.length > home.length) {
116 return `~/${absolute.slice(home.length + 1)}`
117 }
118 return absolute
119}
120
121/** True for the sticker files themselves (inside any `.stickers/` or the user's folder), which never get stickers. */
122export function isStickerFile(absolute: string, places: Places): boolean {
123 const user = absolutePath(USER_STICKERS_DIR, null, places.home)
124 if (user && isUnder(absolute, user, places.windows)) return true
125 const folders = absolute.split('/').slice(0, -1)
126 return folders.some(part => matchKey(part, places.windows) === matchKey(STICKERS_DIR, places.windows))
127}
128
129/** A folder that may hold stickers, and what its stickers' relative `on:` paths resolve against. */
130export type StickerFolder = { folder: string; base: string | null }
131
132/**
133 * Every folder that may hold stickers for `file`: the `.stickers/` in its own folder and in each
134 * folder above it, nearest first, then the user's (whose relative `on:` paths are ignored).
135 */
136export function stickerFolders(file: string, places: Places): StickerFolder[] {
137 const parts = file.split('/')
138 const folders: StickerFolder[] = []
139 for (let n = parts.length - 1; n >= 1; n--) {
140 const dir = parts.slice(0, n).join('/') || '' // '' is the POSIX root
141 folders.push({ folder: `${dir}/${STICKERS_DIR}`, base: dir || '/' })
142 }
143 const user = absolutePath(USER_STICKERS_DIR, null, places.home)
144 if (user) folders.push({ folder: user, base: null })
145 return folders
146}
147
148/** How long ago, roughly: "just now", "5 min ago", "2 h ago", "3 days ago". */
149export function age(mtimeMs: number, nowMs: number): string {
150 const s = Math.max(0, (nowMs - mtimeMs) / 1000)
151 if (s < 60) return 'just now'
152 if (s < 3600) return `${Math.floor(s / 60)} min ago`
153 if (s < 86400) return `${Math.floor(s / 3600)} h ago`
154 const days = Math.floor(s / 86400)
155 return days === 1 ? '1 day ago' : `${days} days ago`
156}
157
158/** Changes whenever a sticker on the file is added, removed or edited. */
159export function signature(stickers: Sticker[]): string {
160 return stickers.map(s => `${s.path}@${s.mtimeMs}`).sort().join('|')
161}
162
163/**
164 * The full display, shown the first time (and again after any change): each sticker's path
165 * to edit, when it was last edited, the other files it is on, and its text. Past
166 * MAX_SHOWN_CHARS only the paths are listed, to be Read when needed.
167 */
168export function formatStickers(target: string, targetKey: string, stickers: Sticker[], nowMs: number, windows: boolean): string {
169 const others = (s: Sticker) => s.on.filter((_, i) => s.targets[i] === undefined || matchKey(s.targets[i]!, windows) !== targetKey)
170 const header = (s: Sticker) => {
171 const also = others(s)
172 return `- ${s.path} (edited ${age(s.mtimeMs, nowMs)}${also.length ? `; also on ${also.join(', ')}` : ''})`
173 }
174 const title = `Stickers on ${target} (${stickers.length}):`
175 const full = [title, ...stickers.map(s => `${header(s)}\n${indent(s.body || '(empty)')}`)].join('\n')
176 if (full.length <= MAX_SHOWN_CHARS) return full
177 return [title, ...stickers.map(header), '(Too long to show here; Read a sticker to see it.)'].join('\n')
178}
179
180/** The short reminder for a later read when nothing changed since the full display. */
181export function formatUnchanged(target: string, count: number): string {
182 return `Stickers on ${target}: ${count}, unchanged since shown.`
183}
184
185function indent(text: string): string {
186 return text
187 .split(/\r?\n/)
188 .map(line => ` ${line}`)
189 .join('\n')
190}
191types/index.d.ts 11 lines1/** Per agent and file (`<agentId or main>|<path, case-folded on Windows>`): a signature of the stickers last shown in full. */
2export type Shown = Record<string, string>
3
4declare module 'claude-code' {
5 interface PluginState {
6 stickers: {
7 shown: Shown
8 }
9 }
10}
11