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…

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.
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.
/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.
| Skill | What it does |
|---|---|
record | Item-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"). |
groom | Whole-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-doctor | Is 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.
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.
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 of truth is skills/ in jabworks/agentic-toolkit — this directory is a generated install mirror. File issues against the main repo.
MIT — see LICENSE.
hooks/register.tsx 674 lines1import {
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};
674hooks/charts.ts 139 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
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};
139hooks/docket.ts 235 lines1import { 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};
235types/index.d.ts 53 lines1// 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