SLOPSHOPPER

stickers

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

newguard
v0.1.0no licenseupdated 2026-10-23sammer1107/claude-stickers
A shopper browsing a rack in a slop shop
README

Stickers

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.

How it works

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.
  • Writing: Claude (or you) creates, edits and deletes stickers like any other file. There is no special tool. The plugin's skill, stickers:add-sticker, tells Claude and its subagents what stickers are and how to write one.
  • Recall: The first time Claude reads, edits or writes a noted file like 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.

  • Each agent separately: the main conversation and every subagent each sees a file's stickers in full the first time it opens it. Touching the files after a /compact or /clear will show the stickers again.

Where stickers live

FolderWhat it's foron: paths are
.stickers/ in project root or any folderNotes that belong with the project or folderRelative to the folder holding that .stickers/
~/.claude/stickers/Personal notes, kept out of every project, or notes on files outside any projectAbsolute, or starting with ~/; relative paths are ignored
  • Lookup: a file's stickers come from the .stickers/ in its own folder and in every folder above it, plus ~/.claude/stickers/.

Install

claude plugin marketplace add sammer1107/claude-stickers
claude plugin install stickers@claude-stickers

Develop

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

Source 3 files
hooks/register.ts 182 lines
1// 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}
182
hooks/stickers.ts 191 lines
1// 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}
191
types/index.d.ts 11 lines
1/** 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