SLOPSHOPPER

docket

Claude Code skills for file-based backlog management: a docket/ folder with never-reused item ids, capture and lifecycle via the record skill, grooming and…

newpanecommandtoasttimer
★ 1v0.12.2MITupdated 2026-10-21jabworks/agentic-toolkit/dist/plugins/docket
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · docket
│ ┃ Docket ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ docket none found [ Refresh ] │ ⏺ Read(src/auth.ts) │ ┃ │ r refresh │ ⎿ Read 6 lines │ ┃ │ ──────────────────────────────────────── │ ⏺ Update(src/auth.ts) │ ┃ │ ──────────────────────────────────────── │ ⎿ Added 2 lines, removed 1 line │ ┃ │ ──────────────────────── │ ⏺ Bash(bun test) │ ┃ │ No docket found in /work/app or above │ ⎿ 3 pass, 1 fail │ ┃ │ it. │ │ ┃ │ `docket scaffold` creates one. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ✻ Worked for 42s · done 4:20 PM │ │ › /docket │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Docket
╭──────────────────────────────────────────────────────────╮ │ docket none found [ Refresh ] │ │ r refresh │ │ ──────────────────────────────────────────────────────── │ │ ──────────────────────────────────────────────── │ │ No docket found in /work/app or above it. │ │ `docket scaffold` creates one. │ ╰──────────────────────────────────────────────────────────╯
README

Docket

A project backlog that lives in your repo, not in a tab you forgot to open.

Open items sit in docket/DOCKET.md — one file you can skim end to end. Closed items move to docket/archive/<year>.md with the record of how they were verified. Ids are integers, shared across open and archive, and never reused, so #12 in a commit subject means the same thing forever.

The design bet is that a backlog an agent can edit correctly is worth more than a backlog with a nice UI. Everything is committed markdown, so it diffs, reviews, and travels with the branch — and the two operations that actually corrupt a backlog (id allocation and the close-and-move) go through a CLI instead of hand-editing.


Install

Claude Code

/plugin marketplace add jabworks/agentic-toolkit
/plugin install docket@jabworks-agentic-toolkit

Codex

codex plugin marketplace add jabworks/agentic-toolkit
codex plugin add docket@jabworks-agentic-toolkit

OpenCode / other hosts — npx skills add https://github.com/jabworks/agentic-toolkit/tree/main/dist/opencode/skills -a opencode

The skills work with nothing else done. To drop the per-operation shell prompts by registering the MCP server, see INSTALL.md — one script, one host table, and the doctor that verifies it.


The /docket pane (Claude Code)

Type /docket to open the open board beside the transcript: every item by section, aged and colour-coded by staleness, refreshing on its own when DOCKET.md changes. Expand an item to read it, then Start work (w) puts /condux:workflow #N — <title> in your prompt, or Cite (c) inserts #N. It only reads the docket and adds nothing to the model's context.

It is a Claude Code function-hooks module (early access; built on Claude Code 2.1.296, which auto-update keeps you past). Codex, OpenCode and Cursor never load it — docket's skills, CLI and MCP server work there exactly as before.


The 3 skills

SkillWhat it does
recordItem-level work: add with the next free id, append status updates, split, and close — stamping ✅ and moving to the archive in one action. Also captures deferred ideas mid-conversation ("later", "someday").
groomWhole-backlog passes: the stale sweep, the id-space integrity check, and "what should I work on next". Reports and recommends; never edits items itself.
docket-doctorIs docket actually working on this host? Probes the MCP registration on every host, the CLI fallback, and the installed version.

The split is by trigger shape, not by feature: one skill for "do something to item #7", one for "look at the whole board". Mixing them is what makes a backlog tool guess.


Closing means moving

The failure this tool exists to prevent is the stamped item still sitting in the open file. A — ✅ DONE marker that never moved reads as open work, and the next session spends real time scoping something that already shipped.

So closing is one operation: stamp, record the verification, relocate to the archive. docket.mjs close <id> does all three, or the docket_close MCP tool does. Never by hand — that is where half-moves come from.


Machinery

The skills work with nothing installed: they fall back to node <skill-base>/server/docket.mjs, which needs only Node. Registering the bundled MCP server removes the per-operation shell prompts, and server/install.sh (or the agent-followable server/INSTALL.md) does that for Codex, OpenCode and Cursor — Claude Code gets it from the shipped .mcp.json automatically.

docket.mjs browse renders the whole board to a self-contained HTML file with no network access, and --serve live-reloads it.

Already have a root BACKLOG.md? It is the same contract in older clothes. Every operation works on it in place, and migration is offered once, never forced.


Source and license

Source of truth is skills/ in jabworks/agentic-toolkit — this directory is a generated install mirror. File issues against the main repo.

MIT — see LICENSE.

Source 4 files
hooks/register.tsx 674 lines
1import {
2  type EngineInterface,
3  type PromptFillMode,
4  type Register,
5  type RenderChildren,
6  type Timer,
7  atom,
8  read,
9  update,
10} from 'claude-code';
11
12import { type DocketBoard, type DocketItem, type DocketSection } from '../types';
13import { type Share, BAR_HEIGHT, PALETTE, barSvg, colorFor, ringSvg, sparklineSvg } from './charts';
14import { ageInDays, ancestors, baseName, closesByWeek, joinPath, parseDocket } from './docket';
15
16const PANE = 'docket';
17const POLL_MS = 3000;
18// The desktop app's Code tab never answered `$.ui.open`, leaving /docket spinning; a surface that is slower than this is
19// treated as not placing the pane.
20const OPEN_TIMEOUT_MS = 2000;
21
22const board = atom({ plugin: 'docket', key: 'board' } as const, null);
23const expanded = atom({ plugin: 'docket', key: 'expanded' } as const, []);
24const filter = atom({ plugin: 'docket', key: 'filter' } as const, '');
25
26type Located = { root: string; file: string; layout: 'docket' | 'legacy' };
27
28const locate = async ($: EngineInterface, cwd: string): Promise<Located | null> => {
29  for (const dir of ancestors(cwd)) {
30    const docketFile = joinPath(dir, 'docket', 'DOCKET.md');
31
32    if (await $.fs.exists(docketFile)) {
33      return { root: dir, file: docketFile, layout: 'docket' };
34    }
35
36    const legacyFile = joinPath(dir, 'BACKLOG.md');
37
38    if (await $.fs.exists(legacyFile)) {
39      return { root: dir, file: legacyFile, layout: 'legacy' };
40    }
41
42    // `.git` is the stop, as in the CLI's resolveDocket: a docket in a parent checkout is never adopted by a nested repo.
43    if (await $.fs.exists(joinPath(dir, '.git'))) {
44      return null;
45    }
46  }
47
48  return null;
49};
50
51// A board without a readable docket.json just has no Done segment; it is never an error.
52const readNextId = async ($: EngineInterface, root: string): Promise<number | null> => {
53  try {
54    const config: unknown = JSON.parse(await $.fs.read(joinPath(root, 'docket', 'docket.json')));
55    const nextId = (config as { next_id?: unknown } | null)?.next_id;
56
57    return typeof nextId === 'number' && Number.isInteger(nextId) && nextId > 0 ? nextId : null;
58  } catch {
59    return null;
60  }
61};
62
63// The archive's weekly closes feed the desktop's sparkline; a board with no archive just has none.
64const readWeeklyCloses = async ($: EngineInterface, root: string): Promise<number[] | null> => {
65  try {
66    const dir = joinPath(root, 'docket', 'archive');
67    const entries = await $.fs.list(dir);
68    const texts = await Promise.all(
69      entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.md')).map(entry => $.fs.read(joinPath(dir, entry.name))),
70    );
71
72    return closesByWeek(texts, await $.clock.now());
73  } catch {
74    return null;
75  }
76};
77
78// Missing and unreadable boards are rewritten on every poll; skipping identical writes keeps the pane from redrawing.
79const setBoard = async ($: EngineInterface, value: DocketBoard): Promise<void> => {
80  const current = await read($, board);
81
82  if (JSON.stringify(current) === JSON.stringify(value)) {
83    return;
84  }
85
86  await update($, board, () => value);
87};
88
89const fillPrompt = async ($: EngineInterface, text: string, mode: PromptFillMode, done: string): Promise<void> => {
90  const filled = await $.prompt.fill({ text, mode });
91
92  if (filled.isFilled) {
93    // A press leaves the keys with the pane, so the person needs telling how to get back to what was just filled.
94    $.ui.toast(`${done} Esc returns to the prompt.`);
95
96    return;
97  }
98
99  if (filled.refusal === 'no_composer') {
100    $.ui.toast('This session has no prompt box to fill.');
101
102    return;
103  }
104
105  $.ui.toast('The prompt is not taking text right now. Close the open dialog and press again.');
106};
107
108const cite = ($: EngineInterface, item: DocketItem): Promise<void> =>
109  fillPrompt($, `#${item.id} `, 'insert', `Cited #${item.id}.`);
110
111const startWork = async ($: EngineInterface, item: DocketItem): Promise<void> => {
112  const line = `/condux:workflow #${item.id} — ${item.title}`;
113  const { text } = await $.prompt.read();
114  const draft = text.trim();
115
116  // A slash command only runs from the start of the prompt, so the draft goes after it rather than before.
117  await fillPrompt($, draft === '' ? line : `${line}\n${draft}`, 'replace', `Ready to start #${item.id}.`);
118};
119
120const setFilter = ($: EngineInterface, value: string): Promise<unknown> => update($, filter, () => value);
121
122const toggle = ($: EngineInterface, idPart: string): Promise<unknown> =>
123  update($, expanded, list => (list.includes(idPart) ? list.filter(id => id !== idPart) : [...list, idPart]));
124
125const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
126
127// `2026-09-22` reads as a stamp, not a fact; "added Sep 22 · 21 days ago" says what it means.
128const describeAdded = (date: string, age: number | null): string => {
129  const [year, month, day] = date.split('-').map(Number);
130  const label = `${MONTHS[(month ?? 1) - 1] ?? ''} ${day ?? ''}${year === new Date().getFullYear() ? '' : `, ${year}`}`;
131
132  if (age === null) {
133    return `added ${label}`;
134  }
135
136  if (age === 0) {
137    return `added ${label} · today`;
138  }
139
140  return `added ${label} · ${age} ${age === 1 ? 'day' : 'days'} ago`;
141};
142
143// Ids of items closed since they were expanded would otherwise sit in state for the rest of the session.
144const pruneExpanded = async ($: EngineInterface, sections: DocketSection[]): Promise<void> => {
145  const ids = new Set(sections.flatMap(section => section.items.map(item => item.idPart)));
146  const current = await read($, expanded);
147
148  if (current.every(id => ids.has(id))) {
149    return;
150  }
151
152  await update($, expanded, list => list.filter(id => ids.has(id)));
153};
154
155// The engine follows `$` only into functions declared at the top of the file, so the poll lives here. A hot reload
156// loads the module afresh, which resets these as it would a closure inside `register`.
157let poll: Timer | undefined;
158let inFlight: Promise<void> | undefined;
159let lastCwd: string | undefined;
160let located: Located | null | undefined;
161let lastMtime: number | undefined;
162let failures = 0;
163
164const load = async ($: EngineInterface, isForced: boolean): Promise<void> => {
165  try {
166    const cwd = await $.session.cwd();
167
168    // Re-walk on a cwd change (a worktree switch) and while nothing is found, so a fresh `docket scaffold` shows up.
169    if (isForced || cwd !== lastCwd || !located) {
170      const found = await locate($, cwd);
171
172      if (found?.file !== located?.file) {
173        lastMtime = undefined;
174      }
175
176      lastCwd = cwd;
177      located = found;
178    }
179
180    if (!located) {
181      failures = 0;
182      await setBoard($, { kind: 'missing', searchedFrom: cwd });
183
184      return;
185    }
186
187    const { file, layout, root } = located;
188    const { mtimeMs } = await $.fs.stat(file);
189
190    if (!isForced && mtimeMs === lastMtime) {
191      failures = 0;
192
193      return;
194    }
195
196    const text = await $.fs.read(file);
197    const sections = parseDocket(text);
198    const nextId = layout === 'docket' ? await readNextId($, root) : null;
199    const weeklyCloses = layout === 'docket' ? await readWeeklyCloses($, root) : null;
200    const loaded: DocketBoard = { kind: 'found', root, file, layout, mtimeMs, sections, nextId, weeklyCloses };
201
202    failures = 0;
203    lastMtime = mtimeMs;
204    await update($, board, () => loaded);
205    await pruneExpanded($, sections);
206  } catch (error) {
207    const reason = error instanceof Error ? error.message : String(error);
208    const file = located?.file ?? '';
209
210    failures += 1;
211    lastMtime = undefined;
212    // Walk again next time, so a docket that is gone for good reads as missing rather than as an error.
213    located = undefined;
214
215    // An editor that saves by replacing the file leaves it missing for a moment: one failed poll keeps the board.
216    if (!isForced && failures < 2) {
217      return;
218    }
219
220    await setBoard($, { kind: 'unreadable', file, reason }).catch(() => undefined);
221  }
222};
223
224// One load at a time, so a slow filesystem cannot stack polls; a forced load waits its turn instead of being lost.
225const refresh = async ($: EngineInterface, isForced: boolean): Promise<void> => {
226  if (inFlight) {
227    if (!isForced) {
228      return;
229    }
230
231    await inFlight;
232
233    return refresh($, true);
234  }
235
236  inFlight = load($, isForced).finally(() => {
237    inFlight = undefined;
238  });
239
240  return inFlight;
241};
242
243const stopPolling = (): void => {
244  poll?.cancel();
245  poll = undefined;
246};
247
248// ui.close hooks do not run when the engine unloads a pane whose drawing threw, so each tick checks the pane is up.
249const tick = async ($: EngineInterface): Promise<void> => {
250  const panes = await $.ui.panes().catch(() => undefined);
251
252  if (panes !== undefined && !panes.some(pane => pane.id === PANE)) {
253    stopPolling();
254
255    return;
256  }
257
258  await refresh($, false);
259};
260
261type OpenOutcome = { isPlaced: true } | { isPlaced: false; reason: string };
262
263// The engine follows `$` only into functions declared at the top of the file, so the race lives here.
264const openPane = ($: EngineInterface): Promise<OpenOutcome> =>
265  Promise.race<OpenOutcome>([
266    $.ui.open({ id: PANE, title: 'Docket', focus: true }),
267    $.clock.sleep(OPEN_TIMEOUT_MS).then(() => ({ isPlaced: false, reason: 'this surface did not answer' })),
268  ]);
269
270// The text a surface without the pane gets instead; one line per item, as the pane shows them.
271const describeBoard = (current: DocketBoard | null): string => {
272  if (current === null) {
273    return 'The docket is still loading.';
274  }
275
276  if (current.kind === 'unreadable') {
277    return `Could not read ${current.file}: ${current.reason}`;
278  }
279
280  if (current.kind === 'missing') {
281    return `No docket found in ${current.searchedFrom} or above it. \`docket scaffold\` creates one.`;
282  }
283
284  const blocks = current.sections.map(section => {
285    const rows = section.items.map(item => `  #${item.idPart} ${item.title}`);
286    const threads = section.threads.map(thread => `  - ${thread}`);
287
288    return [`${section.name} (${section.items.length})`, ...rows, ...threads].join('\n');
289  });
290
291  return blocks.join('\n\n');
292};
293
294const startPolling = ($: EngineInterface): void => {
295  if (poll) {
296    return;
297  }
298
299  poll = $.clock.every(POLL_MS, () => {
300    void tick($);
301  });
302};
303
304export const register: Register = on => {
305  on('session.start', async ($, e, next) => {
306    // A literal name lets `claude plugin validate` see that the command.run hook answers only its own command.
307    await $.command.register({ name: 'docket', description: "Show this repo's open docket items in a pane" });
308
309    // Never opened here: repos without a docket see nothing. A pane that survived a hot reload resumes its polling.
310    const panes = await $.ui.panes();
311
312    if (panes.some(pane => pane.id === PANE)) {
313      void refresh($, true);
314      startPolling($);
315    }
316
317    return next(e);
318  });
319
320  on('command.run', { command: 'docket' }, async $ => {
321    const opened = await openPane($);
322
323    await refresh($, true);
324
325    if (!opened.isPlaced) {
326      // No pane here, so nothing polls; the board goes to the transcript once, which does cost the model tokens.
327      const summary = describeBoard(await read($, board));
328
329      return { text: `The docket pane is not available here (${opened.reason}).\n\n${summary}` };
330    }
331
332    startPolling($);
333
334    // No text: the pane is the output, and a transcript line would cost the model tokens for nothing.
335    return {};
336  });
337
338  on('ui.close', ($, e, next) => {
339    if (e.id === PANE) {
340      stopPolling();
341    }
342
343    return next(e);
344  });
345
346  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
347    const elements = $.ui.resolve(e);
348    const { Box, Button, Markdown, Text } = elements;
349    // The terminal draws no vector charts, and typing in a field there would swallow the r / w / c hotkeys; the desktop
350    // gets both. (`in` cannot tell the tables apart: the engine's resolved table answers for every element name.)
351    const isTerminal = e.surface === 'terminal';
352    const Svg = isTerminal ? undefined : (elements as Extract<typeof elements, { Svg: unknown }>).Svg;
353    const Input =
354      e.surface === 'desktop' || e.surface === 'vscode'
355        ? (elements as Extract<typeof elements, { Svg: unknown; Input: unknown }>).Input
356        : undefined;
357    const filterText = await read($, filter);
358    const current = await read($, board);
359    const openIds = await read($, expanded);
360    const now = await $.clock.now();
361
362    // Window frame (2) + its padding (2); each section box spends the same again inside it.
363    const windowWidth = Math.max(20, e.props.bodyColumns - 4);
364    const rowWidth = Math.max(16, windowWidth - 4);
365    // Drawn twice as wide as the pane and clipped to one row: a proportional font (the desktop) makes `─` narrower or
366    // wider than a cell, and an exact-width string wrapped into a second, partial line there.
367    const divider = (glyph: string, width: number) => (
368      <Box
369        height={1}
370        overflow='hidden'
371      >
372        <Text color='subtle'>{glyph.repeat(width * 2)}</Text>
373      </Box>
374    );
375    const rule = divider('─', windowWidth);
376
377    // The hints sit under the header, not below the list: a pane has no fixed footer, its whole body scrolls as one, and
378    // a hint line at the bottom is out of sight until the end of a long list.
379    const frame = (title: string, count: string, body: RenderChildren, hints: string) => (
380      <Box
381        flexDirection='column'
382        borderStyle='round'
383        borderColor='claude'
384        paddingX={1}
385      >
386        <Box justifyContent='space-between'>
387          <Box gap={1}>
388            <Text
389              bold
390              color='claude'
391            >
392              {title}
393            </Text>
394            <Text color='subtle'>{count}</Text>
395          </Box>
396          <Button
397            key='refresh'
398            hotkey='r'
399            label='Refresh'
400            dimColor
401            onPress={() => refresh($, true)}
402          />
403        </Box>
404        <Text color='subtle'>{hints}</Text>
405        {rule}
406        {body}
407      </Box>
408    );
409
410    if (current === null) {
411      return frame('docket', '', <Text color='subtle'>Loading the docket…</Text>, 'r refresh');
412    }
413
414    if (current.kind === 'missing') {
415      return frame(
416        'docket',
417        'none found',
418        <Box flexDirection='column'>
419          <Text>No docket found in {current.searchedFrom} or above it.</Text>
420          <Text color='suggestion'>`docket scaffold` creates one.</Text>
421        </Box>,
422        'r refresh',
423      );
424    }
425
426    if (current.kind === 'unreadable') {
427      return frame(
428        'docket',
429        'unreadable',
430        <Text color='error'>
431          Could not read {current.file}: {current.reason}
432        </Text>,
433        'r retry',
434      );
435    }
436
437    const total = current.sections.reduce((sum, section) => sum + section.items.length, 0);
438    const idWidth = Math.max(
439      3,
440      ...current.sections.flatMap(section => section.items.map(item => item.idPart.length + 1)),
441    );
442    const edited = new Date(current.mtimeMs);
443    const editedAt = `${String(edited.getHours()).padStart(2, '0')}:${String(edited.getMinutes()).padStart(2, '0')}`;
444
445    // Dashed and indented under #N, so it reads as part of the item rather than a section break.
446    const detailRule = divider('┄', Math.max(8, rowWidth - 2));
447
448    const renderItem = (item: DocketItem) => {
449      const isOpen = openIds.includes(item.idPart);
450      const age = item.date === null ? null : ageInDays(item.date, now);
451      const ageText = age === null ? '' : `${age}d`;
452      // Marker + gaps + id column + age column; the title takes what is left and is cut, never wrapped.
453      const titleRoom = Math.max(8, rowWidth - 2 - idWidth - 1 - 5);
454      const title = item.title.length > titleRoom ? `${item.title.slice(0, titleRoom - 1)}…` : item.title;
455      let ageColor = 'text';
456
457      if (age !== null && age <= 7) {
458        ageColor = 'success';
459      } else if (age !== null && age > 30) {
460        ageColor = 'warning';
461      }
462
463      return (
464        <Box flexDirection='column'>
465          <Button
466            key={`item:${item.idPart}`}
467            plain
468            onPress={() => toggle($, item.idPart)}
469          >
470            <Text color='claude'>{isOpen ? '▾ ' : '▸ '}</Text>
471            <Text color='suggestion'>{`#${item.idPart}`.padEnd(idWidth + 1)}</Text>
472            <Text bold={isOpen}>{title.padEnd(titleRoom)}</Text>
473            <Text color={ageColor}>{ageText.padStart(5)}</Text>
474          </Button>
475          {isOpen && (
476            <Box
477              flexDirection='column'
478              // The marker column is 2 cells ('▾ '), so the detail starts under the row's #N.
479              marginLeft={2}
480              marginY={1}
481              gap={1}
482            >
483              {/* Actions lead the detail: below a long body they scroll out of sight and get missed. */}
484              <Box justifyContent='space-between'>
485                <Box gap={1}>
486                  <Button
487                    key={`work:${item.idPart}`}
488                    variant='primary'
489                    hotkey='w'
490                    label='▶ Start work'
491                    onPress={() => startWork($, item)}
492                  />
493                  <Button
494                    key={`cite:${item.idPart}`}
495                    hotkey='c'
496                    label={`Cite #${item.id}`}
497                    onPress={() => cite($, item)}
498                  />
499                </Box>
500                {item.date !== null && <Text color='subtle'>{describeAdded(item.date, age)}</Text>}
501              </Box>
502              {detailRule}
503              {item.body === '' ? <Text color='subtle'>No body.</Text> : <Markdown text={item.body} />}
504              {detailRule}
505            </Box>
506          )}
507        </Box>
508      );
509    };
510
511    // Done leads, then one colour per section in board order; a segment holding nothing takes no share of the bar.
512    // Ids are never reused, so `next_id - 1` is every item ever logged and what is no longer open is done.
513    const done = current.nextId === null ? 0 : Math.max(0, current.nextId - 1 - total);
514    const shares: Share[] = [
515      { name: 'Done', count: done, color: PALETTE[0] ?? '#3f8f5b' },
516      ...current.sections.map((section, index) => ({
517        name: section.name,
518        count: section.items.length + section.threads.length,
519        color: colorFor(index + 1),
520      })),
521    ];
522    const shareTotal = shares.reduce((sum, share) => sum + share.count, 0);
523    const recentCloses = (current.weeklyCloses ?? []).slice(-4).reduce((sum, count) => sum + count, 0);
524
525    const progress =
526      shareTotal === 0 ? null : (
527        <Box
528          flexDirection='column'
529          gap={1}
530        >
531          {Svg === undefined ? (
532            <Box
533              width='100%'
534              height={1}
535            >
536              {shares
537                .filter(share => share.count > 0)
538                .map(share => (
539                  <Box
540                    key={`share:${share.name}`}
541                    flexGrow={share.count}
542                    backgroundColor={share.color}
543                  />
544                ))}
545            </Box>
546          ) : (
547            // Rounded segments are a vector drawing; the terminal's cells cannot round a block. The height is passed as well
548            // as drawn into the markup: the desktop ignores the markup's own size and boxes an unsized Svg far taller than it.
549            <Svg
550              source={barSvg(shares)}
551              alt={`${done} of ${shareTotal} items done`}
552              height={BAR_HEIGHT}
553              isInteractive
554            />
555          )}
556          <Box
557            gap={2}
558            flexWrap='wrap'
559          >
560            {shares
561              .filter(share => share.name !== 'Done' || share.count > 0)
562              .map(share => (
563                <Text key={`legend:${share.name}`}>
564                  <Text color={share.color}>{Svg === undefined ? '■ ' : '● '}</Text>
565                  {`${share.count} ${share.name.toLowerCase()}`}
566                </Text>
567              ))}
568            <Text color='subtle'>{`of ${shareTotal} logged`}</Text>
569          </Box>
570        </Box>
571      );
572
573    const body = (
574      <Box
575        flexDirection='column'
576        marginY={1}
577        gap={1}
578      >
579        {progress}
580        {Svg !== undefined && shareTotal > 0 && (
581          <Box
582            gap={3}
583            alignItems='center'
584          >
585            <Svg
586              source={ringSvg(shares, done)}
587              alt={`${done} of ${shareTotal} items done`}
588              width={96}
589              height={96}
590              isInteractive
591            />
592            {current.weeklyCloses !== null && (
593              <Box
594                flexDirection='column'
595                gap={1}
596              >
597                <Svg
598                  source={sparklineSvg(current.weeklyCloses, PALETTE[0] ?? '#3f8f5b')}
599                  alt={`${recentCloses} items closed in the last 4 weeks`}
600                  width={168}
601                  height={36}
602                  isInteractive
603                />
604                <Text color='subtle'>{`${recentCloses} closed in the last 4 weeks`}</Text>
605              </Box>
606            )}
607          </Box>
608        )}
609        {Input !== undefined && (
610          <Input
611            key='filter'
612            label='Filter'
613            placeholder='id or title'
614            value={filterText}
615            onInput={value => setFilter($, value)}
616            onSubmit={value => setFilter($, value)}
617          />
618        )}
619        {current.sections.map(section => {
620          // Loose threads are entries too: a section holding only threads is not empty.
621          const count = section.items.length + section.threads.length;
622          const tone = count > 0 ? 'suggestion' : 'subtle';
623          const needle = filterText.trim().toLowerCase();
624          const items = section.items.filter(item => `#${item.idPart} ${item.title}`.toLowerCase().includes(needle));
625          const threads = section.threads.filter(thread => thread.toLowerCase().includes(needle));
626          const shown = items.length + threads.length;
627
628          return (
629            <Box
630              flexDirection='column'
631              borderStyle='round'
632              borderColor={tone}
633              paddingX={1}
634            >
635              <Box gap={1}>
636                <Text
637                  bold
638                  color={tone}
639                >
640                  {section.name.toUpperCase()}
641                </Text>
642                <Text color='subtle'>{needle === '' ? count : `${shown} of ${count}`}</Text>
643              </Box>
644              {shown === 0 && (
645                <Text
646                  color='subtle'
647                  italic
648                >
649                  {count === 0 ? 'nothing here' : 'no matches'}
650                </Text>
651              )}
652              {items.map(renderItem)}
653              {/* Markdown drawn dim printed list markers literally, so the bullet is ours and each thread its own row. */}
654              {threads.map(thread => (
655                <Box gap={1}>
656                  <Text color='claude'>•</Text>
657                  <Markdown text={thread} />
658                </Box>
659              ))}
660            </Box>
661          );
662        })}
663      </Box>
664    );
665
666    return frame(
667      baseName(current.root),
668      `· ${total} open`,
669      body,
670      `tab move · ⏎ expand · r refresh · edited ${editedAt}`,
671    );
672  });
673};
674
hooks/charts.ts 139 lines
1// The desktop's vector charts, as plain SVG strings. Mid-tone colours, so one drawing reads in both themes.
2export const PALETTE = ['#3f8f5b', '#c8643c', '#2f6db5', '#b8860b', '#7b5ea7', '#2a9d8f', '#b5527a'];
3
4export type Share = { name: string; count: number; color: string };
5
6const escapeXml = (text: string): string =>
7  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
8
9// Done leads in green; each section after it takes the next colour, so a long board does not repeat one until the
10// palette runs out.
11export const colorFor = (index: number): string => PALETTE[index % PALETTE.length] ?? '#888888';
12
13const GAP = 3;
14
15// Each share's span along a track of `length`, proportional to its count except that none is shorter than `minSpan`: a
16// round-capped segment is at least as wide as it is thick, so a thinner share would spill over its neighbours. What the
17// small ones are lent is taken from the large ones in proportion.
18export const allot = (counts: number[], length: number, minSpan: number): number[] => {
19  const total = counts.reduce((sum, count) => sum + count, 0);
20
21  if (total === 0) {
22    return counts.map(() => 0);
23  }
24
25  const raw = counts.map(count => (count / total) * length);
26  const isSmall = raw.map(span => span < minSpan);
27  const lent = isSmall.filter(Boolean).length * minSpan;
28  const largeTotal = raw.reduce((sum, span, index) => sum + (isSmall[index] ? 0 : span), 0);
29
30  if (largeTotal <= 0 || lent >= length) {
31    return raw;
32  }
33
34  const scale = (length - lent) / largeTotal;
35
36  return raw.map((span, index) => (isSmall[index] ? minSpan : span * scale));
37};
38
39export const ringSvg = (shares: Share[], doneCount: number): string => {
40  const total = shares.reduce((sum, share) => sum + share.count, 0);
41  const stroke = 10;
42  const radius = 38;
43  const circumference = 2 * Math.PI * radius;
44  const percent = total === 0 ? 0 : Math.round((doneCount / total) * 100);
45  const visibleShares = shares.filter(share => share.count > 0);
46
47  // The drawn arc is its `visible` length plus a round cap of half the stroke at each end, centred in the span it owns.
48  const spans = allot(visibleShares.map(share => share.count), circumference, stroke + GAP);
49
50  let offset = 0;
51
52  const arcs = visibleShares
53    .map((share, index) => {
54      const span = spans[index] ?? 0;
55      const title = `<title>${escapeXml(`${share.count} ${share.name.toLowerCase()}`)}</title>`;
56
57      // One segment is the whole ring: a gap would only be a notch.
58      if (visibleShares.length === 1) {
59        return `<circle cx="50" cy="50" r="${radius}" fill="none" stroke="${share.color}" stroke-width="${stroke}">${title}</circle>`;
60      }
61
62      const visible = Math.max(0.01, span - stroke - GAP);
63      const start = offset + (span - visible) / 2;
64      const arc = `<circle cx="50" cy="50" r="${radius}" fill="none" stroke="${share.color}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${visible.toFixed(2)} ${(circumference - visible).toFixed(2)}" stroke-dashoffset="${(-start).toFixed(2)}" transform="rotate(-90 50 50)">${title}</circle>`;
65
66      offset += span;
67
68      return arc;
69    })
70    .join('');
71
72  return [
73    '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">',
74    arcs,
75    `<text x="50" y="47" text-anchor="middle" dominant-baseline="central" font-size="20" font-weight="600" fill="#888888">${percent}%</text>`,
76    '<text x="50" y="65" text-anchor="middle" dominant-baseline="central" font-size="9" fill="#888888">done</text>',
77    '</svg>',
78  ].join('');
79};
80
81// Rounded progress segments: each is a line with round caps, so a share narrower than the bar is tall stays a dot. Drawn
82// large and sized by its own width/height: an SVG with a viewBox alone has no intrinsic size, and the desktop gives it a
83// tall default box instead.
84export const BAR_HEIGHT = 22;
85
86export const barSvg = (shares: Share[], width = 1200, height = BAR_HEIGHT): string => {
87  const total = shares.reduce((sum, share) => sum + share.count, 0);
88  const visibleShares = shares.filter(share => share.count > 0);
89  const middle = height / 2;
90  const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
91
92  if (total === 0) {
93    return `${open}</svg>`;
94  }
95
96  // Each share owns a span of the full width; a round cap overhangs its line by half the height, so the line runs between
97  // the caps' centres and the drawn segment fills the span minus the gap.
98  const gap = visibleShares.length > 1 ? height / 3 : 0;
99
100  const spans = allot(visibleShares.map(share => share.count), width, height + gap);
101
102  let cursor = 0;
103
104  const segments = visibleShares
105    .map((share, index) => {
106      const span = spans[index] ?? 0;
107      const from = cursor + gap / 2 + middle;
108      // A span holding only a cap's worth is a dot: the two ends meet.
109      const to = Math.max(from, cursor + span - gap / 2 - middle);
110
111      cursor += span;
112
113      return `<line x1="${from.toFixed(1)}" y1="${middle}" x2="${to.toFixed(1)}" y2="${middle}" stroke="${share.color}" stroke-width="${height}" stroke-linecap="round"><title>${escapeXml(`${share.count} ${share.name.toLowerCase()}`)}</title></line>`;
114    })
115    .join('');
116
117  return `${open}${segments}</svg>`;
118};
119
120export const sparklineSvg = (counts: number[], color: string): string => {
121  const barWidth = 10;
122  const gap = 4;
123  const height = 36;
124  const width = counts.length * (barWidth + gap) - gap;
125  const max = Math.max(1, ...counts);
126
127  const bars = counts
128    .map((count, index) => {
129      const barHeight = count === 0 ? 2 : Math.max(4, (count / max) * height);
130      const weeksAgo = counts.length - 1 - index;
131      const when = weeksAgo === 0 ? 'this week' : `${weeksAgo} ${weeksAgo === 1 ? 'week' : 'weeks'} ago`;
132
133      return `<rect x="${index * (barWidth + gap)}" y="${(height - barHeight).toFixed(1)}" width="${barWidth}" height="${barHeight.toFixed(1)}" rx="2" fill="${color}" fill-opacity="${count === 0 ? 0.25 : 1}"><title>${count} closed ${when}</title></rect>`;
134    })
135    .join('');
136
137  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${bars}</svg>`;
138};
139
hooks/docket.ts 235 lines
1import { type DocketItem, type DocketSection } from '../types';
2
3// Mirrors skills/record/server/docket-core.mjs; the hooks environment has no Node, so everything here is string work.
4const ITEM_RE = /^###\s+(\d+(?:\s*\([^)]*\))?)\.\s+(.*)$/;
5const ARCHIVE_ENTRY_RE = /^##\s+(\d+(?:\s*\([^)]*\))?)\.\s+/;
6const SECTION_RE = /^##\s+(.+)$/;
7const BLOCK_END_RE = /^#{2,3}\s/;
8const BULLET_RE = /^[-*]\s+(.*)$/;
9const DATE_GROUP_RE = /\s*\((\d{4}-\d{2}-\d{2})[^)]*\)\s*$/;
10const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
11const DRIVE_RE = /^[A-Za-z]:$/;
12const DAY_MS = 86_400_000;
13
14export const splitTitle = (raw: string): { title: string; date: string | null } => {
15  let rest = raw;
16  let date: string | null = null;
17  let match = rest.match(DATE_GROUP_RE);
18
19  while (match) {
20    // Groups peel off right to left, so the last one peeled is the leftmost: the date the item was added.
21    date = match[1] ?? null;
22    rest = rest.slice(0, match.index).trimEnd();
23    match = rest.match(DATE_GROUP_RE);
24  }
25
26  const title = rest.trim();
27
28  if (title === '') {
29    return { title: raw.trim(), date: null };
30  }
31
32  return { title, date };
33};
34
35const trimBlankLines = (lines: string[]): string[] => {
36  let start = 0;
37  let end = lines.length;
38
39  while (start < end && (lines[start] ?? '').trim() === '') {
40    start += 1;
41  }
42
43  while (end > start && (lines[end - 1] ?? '').trim() === '') {
44    end -= 1;
45  }
46
47  return lines.slice(start, end);
48};
49
50export const parseDocket = (text: string): DocketSection[] => {
51  const lines = text.replace(/\r/g, '').split('\n');
52  const sections: DocketSection[] = [];
53
54  let current: DocketSection | null = null;
55  // Index of the thread a following indented line would continue, or -1 outside a bullet run.
56  let openThread = -1;
57
58  for (let i = 0; i < lines.length; i += 1) {
59    const line = lines[i] ?? '';
60    const section = line.match(SECTION_RE);
61
62    if (section && !ARCHIVE_ENTRY_RE.test(line)) {
63      current = { name: (section[1] ?? '').trim(), items: [], threads: [] };
64      sections.push(current);
65      openThread = -1;
66      continue;
67    }
68
69    const item = line.match(ITEM_RE);
70
71    if (item) {
72      let end = i + 1;
73
74      while (end < lines.length && !BLOCK_END_RE.test(lines[end] ?? '')) {
75        end += 1;
76      }
77
78      if (current) {
79        const idPart = (item[1] ?? '').trim();
80        const { title, date } = splitTitle(item[2] ?? '');
81        const entry: DocketItem = {
82          idPart,
83          id: parseInt(idPart, 10),
84          title,
85          date,
86          body: trimBlankLines(lines.slice(i + 1, end)).join('\n'),
87        };
88
89        current.items.push(entry);
90      }
91
92      openThread = -1;
93      i = end - 1;
94      continue;
95    }
96
97    if (!current) {
98      continue;
99    }
100
101    const bullet = line.match(BULLET_RE);
102
103    if (bullet) {
104      current.threads.push((bullet[1] ?? '').trim());
105      openThread = current.threads.length - 1;
106      continue;
107    }
108
109    if (openThread >= 0 && /^\s+\S/.test(line)) {
110      current.threads[openThread] = `${current.threads[openThread] ?? ''} ${line.trim()}`.trim();
111      continue;
112    }
113
114    if (line.trim() !== '') {
115      openThread = -1;
116    }
117  }
118
119  return sections;
120};
121
122const separatorOf = (path: string): string => (path.includes('\\') && !path.includes('/') ? '\\' : '/');
123
124export const ancestors = (dir: string): string[] => {
125  const sep = separatorOf(dir);
126  const parts = dir.split(/[\\/]/).filter(part => part !== '');
127  const isUnc = dir.startsWith('\\\\');
128  const isAbsolute = dir.startsWith('/') || dir.startsWith('\\');
129  const hasDrive = parts.length > 0 && DRIVE_RE.test(parts[0] ?? '');
130  const result: string[] = [];
131
132  let prefix = '';
133
134  if (isUnc) {
135    prefix = '\\\\';
136  } else if (isAbsolute) {
137    prefix = sep;
138  }
139
140  for (let count = parts.length; count >= 1; count -= 1) {
141    // A bare drive (`C:`) is not a directory; the root entry below stands in for it.
142    if (hasDrive && count === 1) {
143      break;
144    }
145
146    result.push(prefix + parts.slice(0, count).join(sep));
147  }
148
149  if (hasDrive) {
150    result.push(`${parts[0]}${sep}`);
151  } else if (isAbsolute && !isUnc) {
152    result.push(sep);
153  }
154
155  return result;
156};
157
158export const joinPath = (dir: string, ...parts: string[]): string => {
159  const sep = separatorOf(dir);
160  const isRoot = dir === '/' || dir === '\\';
161  let result = isRoot ? '' : dir.replace(/[\\/]+$/, '');
162
163  for (const part of parts) {
164    const clean = part.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '');
165
166    if (clean === '') {
167      continue;
168    }
169
170    result = `${result}${sep}${clean}`;
171  }
172
173  return result === '' ? dir : result;
174};
175
176export const baseName = (path: string): string => {
177  const parts = path.split(/[\\/]/).filter(part => part !== '');
178
179  return parts[parts.length - 1] ?? '';
180};
181
182export const ageInDays = (date: string, nowMs: number): number | null => {
183  const match = date.match(DATE_RE);
184
185  if (!match) {
186    return null;
187  }
188
189  const year = Number(match[1]);
190  const month = Number(match[2]);
191  const day = Number(match[3]);
192  const then = new Date(Date.UTC(year, month - 1, day));
193
194  // Date.UTC rolls 2026-02-31 into March; a round-trip mismatch means the text named no real day.
195  if (then.getUTCFullYear() !== year || then.getUTCMonth() !== month - 1 || then.getUTCDate() !== day) {
196    return null;
197  }
198
199  return Math.max(0, Math.floor((nowMs - then.getTime()) / DAY_MS));
200};
201
202const DONE_RE = /\bDONE\s+(\d{4}-\d{2}-\d{2})/;
203const HEADING_RE = /^##\s+(.+)$/;
204
205// Items closed per week, oldest first, the last entry being this week. A combined entry ("## 2 + 17.") closed two items.
206export const closesByWeek = (archives: string[], nowMs: number, weeks = 12): number[] => {
207  const counts = new Array<number>(weeks).fill(0);
208
209  for (const text of archives) {
210    for (const line of text.replace(/\r/g, '').split('\n')) {
211      const heading = line.match(HEADING_RE)?.[1];
212      const date = heading?.match(DONE_RE)?.[1];
213
214      if (heading === undefined || date === undefined) {
215        continue;
216      }
217
218      const age = ageInDays(date, nowMs);
219      const ids = (heading.match(/^(\d[\d\s+]*)\./)?.[1] ?? '').split('+').filter(part => part.trim() !== '').length;
220
221      if (age === null || ids === 0) {
222        continue;
223      }
224
225      const week = Math.floor(age / 7);
226
227      if (week < weeks) {
228        counts[weeks - 1 - week] = (counts[weeks - 1 - week] ?? 0) + ids;
229      }
230    }
231  }
232
233  return counts;
234};
235
types/index.d.ts 53 lines
1// The docket pane's state contract: what the pane draws from, held in $.state so a hot reload keeps it.
2
3export type DocketItem = {
4  // The id slot as written: `89`, or `24 (follow-ups)` for a slice that references #24 without allocating it.
5  idPart: string;
6  id: number;
7  // The heading title with its trailing `(YYYY-MM-DD…)` group stripped, as the HTML board displays it.
8  title: string;
9  // The first date in that stripped group; null when the heading carries none.
10  date: string | null;
11  // Everything under the heading up to the next `##`/`###` heading, `#### Status` follow-ups included, trimmed.
12  body: string;
13};
14
15export type DocketSection = {
16  name: string;
17  items: DocketItem[];
18  // Top-level bullets outside any item (the `## Loose threads` shape), one string per bullet.
19  threads: string[];
20};
21
22export type DocketBoard =
23  | {
24      kind: 'found';
25      // The directory holding `docket/` (or the legacy `BACKLOG.md`).
26      root: string;
27      // Absolute path of the file parsed: `<root>/docket/DOCKET.md` or `<root>/BACKLOG.md`.
28      file: string;
29      layout: 'docket' | 'legacy';
30      mtimeMs: number;
31      sections: DocketSection[];
32      // docket.json's `next_id`: ids are never reused, so everything ever logged is `nextId - 1`. Null for a legacy
33      // BACKLOG.md board, or when docket.json is missing or unreadable.
34      nextId: number | null;
35      // Items closed per week, oldest first, the last being this week, read from the archive; null where there is none.
36      weeklyCloses: number[] | null;
37    }
38  | { kind: 'missing'; searchedFrom: string }
39  | { kind: 'unreadable'; file: string; reason: string };
40
41declare module 'claude-code' {
42  interface PluginState {
43    docket: {
44      // null until the first load finishes: the pane's pending state.
45      board: DocketBoard | null;
46      // The idParts whose bodies are expanded.
47      expanded: string[];
48      // The desktop's filter box: items whose id or title lacks this text are hidden.
49      filter: string;
50    };
51  }
52}
53