SLOPSHOPPER

sticky-board

A small pane in Claude Code with your project's current phase and two to-do lists: one for Claude, one for you

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-05Magazem/sticky-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sticky-board
│ ┃ Sticky Board ✕ › fix the failing auth test and add an audit log call │ ┃ app x: × │ ┃ no phase set ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 1: AI 2: You 3: Bugs ⏺ Update(src/auth.ts) │ ┃ ────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ nothing queued ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sticky │ ⎿ sticky-board: Sticky Board: app │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sticky Board
app x: × no phase set 1: AI 2: You 3: Bugs ────────────────────────────────────── nothing queued
README

Sticky Board

A small pane in Claude Code that keeps the state of the project you're in where you can see it:

  • the current phase, in one line
  • AI: what Claude still has to do
  • You: what only you can do
  • Bugs: known defects

Claude keeps the board up to date itself. You read it at a glance and click an item to tick it off.

 shop                                   ×
 phase 2: public beta

 AI 3   You 1   Bugs 2
 ──────────────────────────────────────
 ! rotate leaked webhook secret
 · wire retries into uploader  api
 · paginate the orders page
 ✓ fix login redirect

Install

Needs Claude Code 2.1.287 or newer. The board is a Claude Code mod (function hooks), which is an early-access API.

  1. Clone the repo anywhere:
   git clone https://github.com/Magazem/sticky-board ~/sticky-board
  1. Point Claude Code at it in ~/.claude/settings.json:
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/sticky-board"
     }
   }

Use an absolute path (C:/Users/you/sticky-board on Windows). To list several folders, separate them with :, or with ; on Windows.

  1. Restart Claude Code.

To try it for one session without installing: claude --plugin-dir ~/sticky-board.

Use

  • The pane opens by itself when the terminal is wide enough (144 columns, or 110 once you've opened it yourself). /sticky shows or hides it at any width.
  • Click an item to tick it off. Click it again to undo.
  • With the pane focused, 1 2 3 switch lists and x closes it.
  • Closing it is remembered across sessions. While it's hidden, the counts stay under the prompt: ▤ AI 3 · You 1.

How Claude uses it

  • Claude gets a board tool to add items, tick them off, set the phase and read the board. It only writes the board file, so it never asks for permission.
  • At the start of a session, and after you've changed the board yourself, Claude gets a short summary of the board along with your message. It knows the phase and what's open without being asked. Nothing is added while the board is empty.
  • When Claude puts something on your list, you get a toast.

You can just talk to it: "put that on the board", "what's left for me?", "we're in beta now, update the phase", "review this branch and file what you find".

One board per project

Each git repository gets one board. Subfolders and worktrees share it. Outside git, each folder gets its own board.

Data

Every board lives in one plain JSON file, ~/.sticky/notes.json. Set STICKY_HOME to keep it somewhere else. The file is an array of notes:

{
  "id": "4f1c…",
  "project": "/users/you/shop",
  "project_name": "shop",
  "lane": "todo",
  "title": "wire retries into uploader",
  "priority": 2,
  "area": "api",
  "status": "open",
  "created_at": "2026-10-05T08:04:49.464Z",
  "updated_at": "2026-10-05T08:04:49.464Z"
}
  • lane: scope (the phase), todo (AI), human (you) or bug
  • priority: 1 to 3, where 1 means it blocks work
  • project: the repository's path. On Windows it's lowercase with backslashes.

Scripts can write the file too, and the pane picks up changes within two seconds.

Uninstall

Remove the CLAUDE_CODE_PLUGIN_DIRS entry and delete the folder. Your boards stay in ~/.sticky until you delete that too.

Development

claude plugin validate .
claude plugin test .

Once Claude Code has loaded the mod, it writes the API types into .claude-plugin/types/ and a tsconfig.json next to them, so tsc -p . type-checks the module.

License

MIT

Source 2 files
hooks/register.tsx 457 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Note } from '../types'
5
6// Sticky Board: a small pane with the current project's phase and three short
7// lists: what Claude still has to do, what only you can do, and known bugs.
8//
9// Claude keeps it current through the `board` tool registered below; you read it
10// at a glance and click an item to tick it off. Everything lives in one JSON file,
11// ~/.sticky/notes.json (or $STICKY_HOME/notes.json), one board per project.
12
13const PANE = 'sticky-board'
14const TOOL = 'mcp__sticky-board__board'
15const POLL_MS = 2000
16const WIDTH = 40 // columns asked for when docked beside a fullscreen transcript
17const MAX_ROWS = 10 // the list never grows past this; the rest is "+N more"
18const MAX_DONE = 2 // recently ticked items stay visible, dim, so a mis-click undoes
19const MAX_SEEN = 8 // items per list in what Claude is shown
20
21type Lane = 'todo' | 'human' | 'bug'
22
23const LISTS: { key: Lane; name: string; label: string; hotkey: string; empty: string }[] = [
24  { key: 'todo', name: 'ai', label: 'AI', hotkey: '1', empty: 'nothing queued' },
25  { key: 'human', name: 'you', label: 'You', hotkey: '2', empty: 'nothing needs you' },
26  { key: 'bug', name: 'bugs', label: 'Bugs', hotkey: '3', empty: 'no known bugs' },
27]
28const LANE_OF: Record<string, Lane> = { ai: 'todo', you: 'human', bugs: 'bug' }
29const LABEL_OF: Record<string, string> = { todo: 'AI to-do', human: 'You', bug: 'Bugs' }
30
31const board = atom({ plugin: 'sticky-board', key: 'board' } as const, {
32  projectName: '',
33  notes: [],
34  error: null,
35})
36const focus = atom({ plugin: 'sticky-board', key: 'focus' } as const, 'todo')
37
38const DESCRIPTION = `The Sticky Board: a small pane the user keeps open beside this session, showing this project's current phase and three short lists. The user reads it at a glance, so keep it accurate and terse.
39
40Lists:
41- ai: work you still have to do in this project
42- you: things only the user can do (decide, rotate a key, test by hand, create an account, review)
43- bugs: known defects not yet fixed
44
45Actions:
46- add: file an item (list and title; optional priority 1-3 where 1 blocks work, area such as ui or api). Adding a title that is already open updates it.
47- done: tick an item off by its title (a unique part of it is enough). Do this as soon as you finish one of your items.
48- reopen: undo a done.
49- scope: set the one-line phase, e.g. "phase 2: public beta". It replaces the previous line.
50- list: read the board.
51
52File when something durable happens: a bug found, work discovered or deferred, something handed to the user, the phase changing. At the end of a code review, file each real finding under bugs. If the board has no phase yet and you understand what the project is doing, set one. Do not file routine progress, passing tests or restatements of a plan.
53
54Style: titles are fragments of 3-6 words with no trailing punctuation ("webhook fires twice on 500s"), never sentences.`
55
56const SCHEMA = {
57  type: 'object',
58  properties: {
59    action: { type: 'string', enum: ['add', 'done', 'reopen', 'scope', 'list'] },
60    list: { type: 'string', enum: ['ai', 'you', 'bugs'], description: 'Which list, for add; optional for done and reopen.' },
61    title: { type: 'string', description: 'A 3-6 word fragment.' },
62    priority: { type: 'integer', minimum: 1, maximum: 3, description: '1 blocks work; 3 is the default.' },
63    area: { type: 'string', description: 'Optional short tag, e.g. ui, api, build.' },
64  },
65  required: ['action'],
66  additionalProperties: false,
67}
68
69// Mirrors how projects were always named: a path, lowercased on Windows with
70// backslashes, so one project stays one board however it was opened.
71const SPLITS = ['\\.claude\\worktrees\\', '\\.claude\\jobs\\', '/.claude/worktrees/', '/.claude/jobs/']
72
73function projectOf(dir: string) {
74  let p = dir.trim()
75  for (const marker of SPLITS) {
76    const at = p.indexOf(marker)
77    if (at !== -1) p = p.slice(0, at)
78  }
79  p = p.replace(/[\\/]+$/, '')
80  const isWindows = /^[A-Za-z]:/.test(p) || p.includes('\\')
81  if (isWindows) p = p.replace(/\//g, '\\')
82  const name = p.split(/[\\/]/).pop() || p
83
84  return { id: isWindows ? p.toLowerCase() : p, name }
85}
86
87// A board belongs to the repository, not to the folder Claude was started in: a
88// subfolder or a worktree of a repo shares the repo's board.
89async function resolveProject($: EngineInterface, cwd: string) {
90  const git = async (...args: string[]) => {
91    const ran = await $.process.run(['git', ...args], { timeoutMs: 5000 }).catch(() => undefined)
92    return ran?.exitCode === 0 ? ran.stdout.trim() : ''
93  }
94  const common = await git('rev-parse', '--path-format=absolute', '--git-common-dir')
95  if (/[\\/]\.git$/.test(common)) return projectOf(common.replace(/[\\/]\.git$/, ''))
96  const top = await git('rev-parse', '--show-toplevel')
97
98  return projectOf(top || cwd)
99}
100
101const norm = (s: string) =>
102  s
103    .toLowerCase()
104    .replace(/[^\p{L}\p{N}]+/gu, ' ')
105    .trim()
106
107const byPriority = (a: Note, b: Note) =>
108  a.priority - b.priority || a.created_at.localeCompare(b.created_at)
109
110const isOpenIn = (lane: string) => (n: Note) => n.lane === lane && n.status !== 'done'
111
112function newId() {
113  const c = (globalThis as any).crypto
114  if (c?.randomUUID) return c.randomUUID() as string
115  return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
116}
117
118// Module variables start over on a hot reload; session.start sets them again.
119let root = ''
120let project = { id: '', name: '' }
121let lastMtime = -1
122let lastSeen = '' // the board as Claude last saw it, so it is only re-sent on change
123let lastStatus: string | undefined | null = null
124let writes: Promise<unknown> = Promise.resolve()
125
126const notesPath = () => `${root}/notes.json`
127
128function toNote(n: any): Note {
129  return {
130    id: String(n.id),
131    lane: n.lane ?? 'cue',
132    title: String(n.title ?? ''),
133    detail: n.detail ?? null,
134    area: n.area ?? null,
135    priority: Number.isFinite(n.priority) ? n.priority : 3,
136    status: n.status ?? 'open',
137    created_at: String(n.created_at ?? ''),
138    updated_at: String(n.updated_at ?? n.created_at ?? ''),
139  }
140}
141
142// A missing file is an empty board; an unreadable one throws, so a write can never
143// replace a board it failed to read.
144async function loadAll($: EngineInterface): Promise<any[]> {
145  if (!(await $.fs.exists(notesPath()))) return []
146  const all = JSON.parse((await $.fs.read(notesPath())).replace(/^/, ''))
147  if (!Array.isArray(all)) throw new Error('notes.json is not a list')
148  return all
149}
150
151async function refresh($: EngineInterface, force = false) {
152  const stat = await $.fs.stat(notesPath()).catch(() => undefined)
153  if (!stat) {
154    lastMtime = 0
155    await update($, board, () => ({ projectName: project.name, notes: [], error: null }))
156    return
157  }
158  if (!force && stat.mtimeMs === lastMtime) return
159
160  try {
161    const all = await loadAll($)
162    lastMtime = stat.mtimeMs
163    const notes = all.filter(n => (n.project ?? null) === (project.id || null)).map(toNote)
164    await update($, board, () => ({ projectName: project.name, notes, error: null }))
165  } catch {
166    // A half-written or corrupt file keeps the last good board on screen.
167    await update($, board, b => ({ ...b, error: 'notes.json unreadable' }))
168  }
169}
170
171// Every write re-reads the file, changes it and writes it back, one at a time.
172function mutate<T>($: EngineInterface, change: (all: any[], now: string) => T): Promise<T> {
173  const run = writes.then(async () => {
174    const all = await loadAll($)
175    const now = new Date(await $.clock.now()).toISOString()
176    const out = change(all, now)
177    await $.fs.write(notesPath(), JSON.stringify(all, null, 2) + '\n')
178    await refresh($, true)
179    return out
180  })
181  writes = run.catch(() => undefined)
182
183  return run
184}
185
186const toggle = ($: EngineInterface, id: string) =>
187  mutate($, (all, now) => {
188    const n = all.find(x => x.id === id)
189    if (!n) return
190    n.status = n.status === 'done' ? 'open' : 'done'
191    n.updated_at = now
192  })
193
194function snapshot(b: Board) {
195  const scope = b.notes.filter(isOpenIn('scope')).sort((x, y) => y.created_at.localeCompare(x.created_at))[0]
196  const line = (lane: Lane) => {
197    const items = b.notes.filter(isOpenIn(lane)).sort(byPriority)
198    const shown = items.slice(0, MAX_SEEN).map(n => (n.priority === 1 ? '! ' : '') + n.title + (n.area ? ` [${n.area}]` : ''))
199    const more = items.length > MAX_SEEN ? [`+${items.length - MAX_SEEN} more`] : []
200
201    return `${LABEL_OF[lane]} (${items.length}): ${items.length ? [...shown, ...more].join('; ') : 'none'}`
202  }
203
204  return [
205    `Sticky Board for ${b.projectName || 'this folder'}`,
206    `Phase: ${scope?.title ?? 'not set'}`,
207    line('todo'),
208    line('human'),
209    line('bug'),
210  ].join('\n')
211}
212
213const isBlank = (b: Board) => !b.notes.some(n => n.status !== 'done' && ['scope', 'todo', 'human', 'bug'].includes(n.lane))
214
215async function runTool($: EngineInterface, input: Record<string, unknown>): Promise<string> {
216  const action = String(input.action ?? '')
217  const title = String(input.title ?? '').trim().replace(/[.!]+$/, '')
218  const lane = input.list === undefined ? undefined : LANE_OF[String(input.list)]
219  const here = (n: any) => (n.project ?? null) === (project.id || null)
220
221  if (action === 'list') {
222    const b: Board = await read($, board)
223    lastSeen = snapshot(b)
224    return lastSeen
225  }
226  if (!title) throw new Error(`${action} needs a title`)
227
228  if (action === 'scope') {
229    await mutate($, (all, now) => {
230      for (const n of all) {
231        if (here(n) && n.lane === 'scope' && n.status !== 'done') {
232          n.status = 'done'
233          n.updated_at = now
234        }
235      }
236      all.push({
237        id: newId(), created_at: now, updated_at: now, lane: 'scope', project: project.id || null,
238        project_name: project.name || null, area: null, priority: 1, category: 'scope', title,
239        detail: null, status: 'open', session_id: 'claude',
240      })
241    })
242    lastSeen = snapshot(await read($, board))
243    return `Phase set: ${title}`
244  }
245
246  if (action === 'add') {
247    if (!lane) throw new Error('add needs list: ai, you or bugs')
248    const priority = Number.isInteger(input.priority) ? Math.min(3, Math.max(1, input.priority as number)) : undefined
249    const area = typeof input.area === 'string' && input.area.trim() ? input.area.trim() : undefined
250    const isNew = await mutate($, (all, now) => {
251      const same = all.find(n => here(n) && n.lane === lane && n.status !== 'done' && norm(String(n.title ?? '')) === norm(title))
252      if (same) {
253        if (priority) same.priority = priority
254        if (area) same.area = area
255        same.updated_at = now
256        return false
257      }
258      all.push({
259        id: newId(), created_at: now, updated_at: now, lane, project: project.id || null,
260        project_name: project.name || null, area: area ?? null, priority: priority ?? 3, category: lane,
261        title, detail: null, status: 'open', session_id: 'claude',
262      })
263      return true
264    })
265    if (isNew && lane === 'human') $.ui.toast(`For you: ${title}`, { timeoutMs: 8000 })
266    lastSeen = snapshot(await read($, board))
267    return `${isNew ? 'Added to' : 'Updated in'} ${LABEL_OF[lane]}: ${title}`
268  }
269
270  if (action === 'done' || action === 'reopen') {
271    const wantsDone = action === 'done'
272    const b: Board = await read($, board)
273    const pool = b.notes.filter(
274      n => ['todo', 'human', 'bug'].includes(n.lane) && (lane ? n.lane === lane : true) && (n.status === 'done') !== wantsDone,
275    )
276    const exact = pool.filter(n => norm(n.title) === norm(title))
277    const partial = pool.filter(n => norm(n.title).includes(norm(title)))
278    const hits = exact.length ? exact : partial
279    if (hits.length !== 1) {
280      const names = (hits.length ? hits : pool).slice(0, 12).map(n => `${n.title} (${LABEL_OF[n.lane]})`)
281      throw new Error(
282        `${hits.length ? 'several items match' : 'no item matches'} "${title}". ${wantsDone ? 'Open' : 'Done'} items: ${names.join('; ') || 'none'}`,
283      )
284    }
285    await toggle($, hits[0]!.id)
286    lastSeen = snapshot(await read($, board))
287    return `${wantsDone ? 'Done' : 'Reopened'}: ${hits[0]!.title}`
288  }
289
290  throw new Error(`unknown action "${action}"`)
291}
292
293// Without the pane on screen (closed, or a terminal too narrow for it), one line
294// under the prompt keeps the counts in view.
295async function syncStatus($: EngineInterface) {
296  const b: Board = await read($, board)
297  const panes = await $.ui.panes().catch(() => [])
298  const isShown = panes.some(p => p.id === PANE && p.isPlaced && p.isShown)
299  const parts = LISTS.map(l => [l.label, b.notes.filter(isOpenIn(l.key)).length] as const)
300    .filter(([, count]) => count > 0)
301    .map(([label, count]) => `${label} ${count}`)
302  const text = isShown || parts.length === 0 ? undefined : `▤ ${parts.join(' · ')}`
303  if (text === lastStatus) return
304  lastStatus = text
305  $.ui.status(text)
306}
307
308async function openPane($: EngineInterface) {
309  const b: Board = await read($, board)
310  const lane = await read($, focus)
311  const open = b.notes.filter(isOpenIn(lane)).length
312  const rows = 5 + Math.min(Math.max(open, 1), MAX_ROWS)
313
314  return $.ui.open({ id: PANE, title: 'Sticky Board', columns: WIDTH, rows })
315}
316
317export const register: Register = on => {
318  on('session.start', async ($, e, next) => {
319    const home = (await $.env.get('STICKY_HOME')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''}/.sticky`
320    root = home.replace(/[\\/]+$/, '')
321    project = await resolveProject($, e.cwd)
322    lastMtime = -1
323    lastSeen = ''
324    lastStatus = null
325
326    await $.tool.register({ name: 'board', description: DESCRIPTION, inputSchema: SCHEMA })
327    await $.command.register({
328      name: 'sticky',
329      description: 'Show or hide the Sticky Board for this project',
330      argumentHint: '[open|close]',
331    })
332    await refresh($, true)
333    $.clock.every(POLL_MS, async () => {
334      await refresh($)
335      await syncStatus($)
336    })
337    if ((await $.store.get('autoOpen')) !== false) void openPane($)
338
339    return next(e)
340  })
341
342  // Claude sees the board on its first prompt, and again whenever it changed by
343  // some other hand (a click in the pane, another session) since it last looked.
344  on('prompt.submit', async ($, e, next) => {
345    const b: Board = await read($, board)
346    const snap = snapshot(b)
347    if (isBlank(b) || snap === lastSeen) return next(e)
348    lastSeen = snap
349
350    return next({ ...e, context: [...(e.context ?? []), `<sticky-board>\n${snap}\n</sticky-board>`] })
351  })
352
353  on('tool.call', { tool: TOOL }, async ($, e) => {
354    try {
355      return { result: await runTool($, e as unknown as Record<string, unknown>) }
356    } catch (err) {
357      return { deny: `Sticky Board: ${err instanceof Error ? err.message : String(err)}` }
358    }
359  })
360
361  // A close by hand, by the × or by /sticky is remembered: the pane stays shut in
362  // later sessions until /sticky opens it again.
363  on('ui.close', { id: PANE }, async ($, e, next) => {
364    if (e.origin.kind !== 'unload') await $.store.set('autoOpen', false)
365    lastStatus = null
366    return next(e)
367  })
368
369  on('command.run', { command: 'sticky' }, async ($, e) => {
370    const arg = e.args.trim().toLowerCase()
371    const panes = await $.ui.panes()
372    const isShown = panes.some(p => p.id === PANE && p.isPlaced && p.isShown)
373    const wantsOpen = arg === 'open' || (arg !== 'close' && !isShown)
374
375    if (!wantsOpen) {
376      await $.ui.close({ id: PANE })
377      await syncStatus($)
378      return { text: 'Sticky Board hidden. /sticky brings it back.' }
379    }
380    await $.store.set('autoOpen', true)
381    await refresh($, true)
382    await openPane($)
383    await syncStatus($)
384
385    return { text: `Sticky Board: ${project.name || 'this folder'}` }
386  })
387
388  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
389    const { Box, Text, Button } = $.ui.resolve(e)
390    const b: Board = await read($, board)
391    const focused = await read($, focus)
392    const list = LISTS.find(l => l.key === focused) ?? LISTS[0]!
393
394    const scope = b.notes.filter(isOpenIn('scope')).sort((x, y) => y.created_at.localeCompare(x.created_at))[0]
395    const open = b.notes.filter(isOpenIn(list.key)).sort(byPriority)
396    const done = b.notes
397      .filter(n => n.lane === list.key && n.status === 'done')
398      .sort((x, y) => y.updated_at.localeCompare(x.updated_at))
399      .slice(0, MAX_DONE)
400
401    const room = Math.max(3, Math.min(MAX_ROWS, (e.viewport?.rows ?? 20) - 6))
402    const shown = open.slice(0, room)
403    const hidden = open.length - shown.length
404    const width = Math.max(8, Math.min(WIDTH, e.viewport?.columns ?? WIDTH))
405
406    const item = (n: Note) => {
407      const isDone = n.status === 'done'
408      const isUrgent = !isDone && n.priority === 1
409
410      return (
411        <Box key={`row-${n.id}`}>
412          <Text color={isUrgent ? 'warning' : 'inactive'}>{isDone ? '✓ ' : isUrgent ? '! ' : '· '}</Text>
413          <Button key={`toggle-${n.id}`} plain dimColor={isDone} label={n.title} onPress={() => toggle($, n.id)} />
414          {n.area && !isDone && <Text dimColor> {n.area}</Text>}
415        </Box>
416      )
417    }
418
419    return (
420      <Box flexDirection="column">
421        <Box justifyContent="space-between">
422          <Text dimColor>{b.projectName || 'this folder'}</Text>
423          <Button key="close" plain dimColor role="dismiss" hotkey="x" label="×" onPress={() => $.ui.close({ id: PANE })} />
424        </Box>
425        {scope ? <Text wrap="truncate-end">{scope.title}</Text> : <Text dimColor>no phase set</Text>}
426        {b.error && <Text color="error">{b.error}</Text>}
427
428        <Box marginTop={1}>
429          {LISTS.map(l => {
430            const count = b.notes.filter(isOpenIn(l.key)).length
431            const isFocused = l.key === list.key
432
433            return (
434              <Box key={`tab-box-${l.key}`} marginRight={2}>
435                <Button
436                  key={`tab-${l.key}`}
437                  plain
438                  hotkey={l.hotkey}
439                  dimColor={!isFocused}
440                  label={count ? `${l.label} ${count}` : l.label}
441                  onPress={() => update($, focus, () => l.key)}
442                />
443              </Box>
444            )
445          })}
446        </Box>
447        <Text color="inactive">{'─'.repeat(width - 2)}</Text>
448
449        {open.length === 0 && <Text dimColor>{list.empty}</Text>}
450        {shown.map(item)}
451        {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
452        {done.map(item)}
453      </Box>
454    )
455  })
456}
457
types/index.d.ts 24 lines
1export type Note = {
2  id: string
3  lane: string
4  title: string
5  detail: string | null
6  area: string | null
7  priority: number
8  status: string
9  created_at: string
10  updated_at: string
11}
12
13export type Board = {
14  projectName: string
15  notes: Note[]
16  error: string | null
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'sticky-board': { board: Board; focus: string }
22  }
23}
24