Reusable skills, plugins, and agent configs for AI-assisted development workflows.

Personal collection of agentic coding skills. Compatible with Claude Code, Codex, OpenCode, Cursor, Gemini CLI, and 40+ other tools via npx skills add.
npx skills add auto-detects the running agent and installs to the right directory:
npx skills add jabworks/agentic-toolkit
This works inside Claude Code, Codex, OpenCode, Cursor, and most other agentic tools — no flags needed.
The marketplace install is only half the job for condux, docket and concord — each finishes with a host-specific step no marketplace can perform. Every one of them ships an INSTALL.md written to be followed by an agent, so the shortest path is to hand the whole thing to the agent you already have open. Paste this:
Install plugins from the jabworks/agentic-toolkit marketplace on this machine.
1. Detect the host you are running in — Claude Code (~/.claude), Codex
($CODEX_HOME or ~/.codex), or OpenCode (~/.config/opencode).
2. Add the marketplace and install the plugins I named. If I did not name any,
ask me first — do not install all of them.
Claude Code /plugin marketplace add jabworks/agentic-toolkit
/plugin install <plugin>@jabworks-agentic-toolkit
Codex codex plugin marketplace add jabworks/agentic-toolkit
codex plugin add <plugin>@jabworks-agentic-toolkit
OpenCode add "@jabworks/condux" to the plugin array in opencode.json
3. Three plugins need a step the marketplace cannot do. Read the INSTALL.md
next to each one you installed, then run its installer:
condux <plugin-root>/install.mjs
docket <plugin-root>/skills/docket/record/server/install.sh
concord <plugin-root>/skills/concord/remember/references/install-codex-hook.sh
Show me the --dry-run plan before writing anything.
4. Verify rather than assume — run the doctor for each plugin and show me the
report as it printed:
/condux:condux-doctor /docket:docket-doctor /concord:concord-doctor
5. Report one row per host: what you changed, what was already correct, and
what any doctor flagged. If one warns that another installed skill library
conflicts, show me its removal command but do not remove anything.
Every step is safe to re-run, and each installer is read-modify-write on one key — none of them rewrites config another plugin owns.
condux conflicts with obra/superpowers — the library it reworks. Both register a SessionStart hook on the same matcher, each injecting a router claiming every dev task, and 11 of superpowers' 14 skills overlap 8 of condux's. Run one or the other. install.mjs and /condux:condux-doctor both detect it and print the removal command without running it; the overlap table and reasoning are in the condux README, and the registry driving the check is skills/condux-doctor/conflicts.json.
No other conflict is currently detected. The rest of the toolkit's skills — git, release, spec, session — occupy their own ground.
Alternatively, register as a plugin marketplace to install individual skills:
Via CLI:
claude plugin marketplace add jabworks/agentic-toolkit
claude plugin install session-handoff@jabworks-agentic-toolkit # claude plugin install session-handoff
claude plugin install toolkit-ops@jabworks-agentic-toolkit # claude plugin install toolkit-ops
Via Claude Code CLI:
/plugin marketplace add jabworks/agentic-toolkit
/plugin install session-handoff@jabworks-agentic-toolkit # /plugin install session-handoff
/plugin install toolkit-ops@jabworks-agentic-toolkit # /plugin install toolkit-ops
Via Claude Code CLI plugin menu:
/plugin
-> Marketplaces tab -> Add Marketplace -> jabworks/agentic-toolkit
-> Discover tab -> Search plugin name
This repo is also Codex plugin compatible:
Via CLI:
codex plugin marketplace add jabworks/agentic-toolkit
codex plugin add session-handoff@jabworks-agentic-toolkit
codex plugin add toolkit-ops@jabworks-agentic-toolkit
Via Codex CLI plugins menu:
/plugins
-> Add Marketplace -> jabworks/agentic-toolkit
-> Select jabworks/agentic-toolkit tab to add plugins
For the condux workflow, one plugin line is the whole install. The @jabworks/condux plugin bundles the 16 condux skills, injects the specialist agents (coder / explorer / planner / researcher), and wires an opt-in plan-review listener:
// opencode.json
{
"plugin": ["@jabworks/condux"]
}
The bundled skills register themselves on config.skills.paths, so no separate skills install is needed for condux.
For the rest of the toolkit skills (git-commit, session-handoff, release, spec-browser, coding-directive, …), install the OpenCode-facing variants. These fold each skill's when_to_use trigger conditions into its description — OpenCode surfaces only description when deciding which skill to load, so the plain skills/ tree would lose them:
npx skills add https://github.com/jabworks/agentic-toolkit/tree/main/dist/opencode/skills -a opencode
This copies skills into ~/.config/opencode/skills/. It also includes the condux skills, but the plugin already provides those — OpenCode dedupes by name, so installing both is harmless.
Cursor loads SKILL.md natively (since Cursor 2.4) but surfaces only description when deciding which skill to load — it never reads when_to_use. Install the Cursor-facing variants, which fold the trigger conditions in (same transform as the OpenCode tree):
npx skills add https://github.com/jabworks/agentic-toolkit/tree/main/dist/cursor/skills -a cursor
Run it in your project — skills land in .agents/skills/, which Cursor picks up per-project. Prefer project installs over --global: the CLI currently writes global skills to ~/.agents/skills/ against its own docs (vercel-labs/skills#421), and on a WSL setup that's the WSL home while Windows-side Cursor reads C:\Users\<you>\.agents\skills\ — the install silently vanishes.
Most plugins are also spec-conformant Agent Plugins (root plugin.json, flat skills/, docket MCP via spec mcp.json) — Cursor loads them directly. To try one without a marketplace: copy dist/plugins/<name> into ~/.cursor/plugins/local/<name> and fully restart Cursor. Plugin installs ship raw skill frontmatter, so the trigger-quality caveat below applies; the dist/cursor/skills/ install remains the best path for auto-triggering.
condux and concord are not Agent Plugins and won't load by that path. Codex picks its plugin loader by root-manifest presence, and the Agent Plugins loader has no hooks — shipping the manifest silently disabled every Codex hook those two have. Hooks are the point of both, so they ship without it. On Cursor use dist/cursor/skills/ for them, which is the better channel there anyway.
Verified end-to-end 2026-08-14 (Cursor on Windows, WSL remote):
| Status | |
|---|---|
Skills install, list, and invoke (/workflow etc.) | ✅ works — project scope, from dist/cursor/skills/ |
Plugins load as Agent Plugins (root plugin.json, flat skills/) | ✅ works — verified via ~/.cursor/plugins/local; excludes condux and concord since 2026-08-21, which ship no root manifest so their Codex hooks survive |
| Docket MCP server | ✅ works — auto-imported from an existing Claude Code plugin install, or manually via .cursor/mcp.json (see docket INSTALL.md) |
| Docket CLI fallback | ✅ works as-is (dependency-free) |
Condux /workflow routing | ⚠️ degrades — no SessionStart hook on Cursor, so routing relies on the skill descriptions instead of the injected routing rule |
| plan-review auto-capture, named agents (explorer/researcher/planner/coder/scout) | ❌ absent — Cursor has no ExitPlanMode/Stop hook or custom-subagent surface |
Bonus: if Claude Code is installed on the same machine, Cursor picks up its plugin ecosystem by itself — already-installed plugins (skills and MCP servers, docket included) just appear, and registered marketplaces surface in Cursor's Customize panel with one-click Add. Marketplace installs ship the raw SKILL.md, whose when_to_use Cursor ignores — fine for MCP-bearing plugins like docket, but for reliable skill triggering prefer the dist/cursor/skills/ install above. Cursor reads the marketplace clone on its own side of a WSL split, which can lag — update the marketplace in Claude Code if the listing looks stale.
If running outside an agent environment, clone and copy to your tool's skills directory:
| Tool | Skills directory |
|---|---|
| Claude Code | ~/.claude/skills/ |
| Codex | ~/.codex/skills/ |
| OpenCode | ~/.config/opencode/skills/ |
| Cursor | ~/.cursor/skills/ |
| Gemini CLI | ~/.gemini/skills/ |
| Most others | .agents/skills/ |
git clone https://github.com/jabworks/agentic-toolkit /tmp/agentic-toolkit
cp -r /tmp/agentic-toolkit/skills/<name> <skills-directory>/
<!-- catalog:begin readme-skills -->
| Skill | Description |
|---|---|
| session-report | Generate an explorable HTML report of session usage — tokens, cache, cost, subagents, skills |
| session-handoff | Preserve and restore session context across agentic coding sessions |
| adapting-skills | Stack and style priors for adapting skills to the jabworks conventions (opinionated — fork the priors for your own stack) |
| git-commit | Conventional-commit message from the diff, run safely — review before staging, no blind git add . |
| git-operations | Decision router for git — pick the right operation (undo/discard/stash/merge/push) with an undo path |
| git-worktree | Decision router for git worktrees — isolated workspaces, native-first, with cleanup and recovery paths |
| spec-browser | Catalog and browse a specs/ tree — markdown index + folder-grouped doc site |
| release | Cut releases safely — machinery detection (changesets/toolkit/GitHub), dry-run first, rollback paths |
| coding-directive | House style for @jabworks repos — TypeScript, React, formatting, imports, naming, CSS (personal) |
| remember | Continuous memory for Codex (concord plugin) — captures each session from the rollout, ages it into tiers, recalls it |
| concord-doctor | Health check for the concord plugin — probes its Codex hooks and memory store on this host |
| condux-doctor | Health check for the condux plugin — runs the SessionStart routing hook and checks the agents shipped |
| docket-doctor | Health check for the docket plugin — probes the MCP registration on every host and the CLI fallback |
<!-- catalog:end readme-skills -->
Lean agentic workflow plugin (condux). Install the full workflow bundle as a unit:
/plugin install condux@jabworks-agentic-toolkit
Inspired by obra/superpowers — condux reworks its skill-orchestration ideas around a lean, proportional-effort philosophy (tiered routing, lazy loading, soft gates) rather than always-on maximalism.
Installing is the whole setup: condux ships a SessionStart hook (Claude Code and Codex both) that puts its routing rule in context before your first prompt, so /workflow is reached as the entry point instead of being inferred from the skill catalog. That inference sits at roughly 80% on its own, and the misses are condux's own siblings winning the query — root-cause-analysis on a crash report, draft-plan on "write the plan" — which no description can fix without stealing their trigger space. The payload is ~390 tokens per session and lives in skills/workflow/hooks/routing.md; delete or edit that file to change or disable it.
Most workflow frameworks are maximal — every gate runs on every task, so a typo fix pays the same ceremony as a cross-cutting refactor. Condux inverts that with five operating rules:
The payoff: no over-engineered button changes, no under-planned refactors, predictable checkpoints where you drive every transition, and a small context footprint because only the skills a tier actually needs ever load.
Every dev task enters through /workflow. It infers a tier from the task (file count, design clarity, boundary crossings), confirms it with you, then runs the matching pipeline:
| Tier | Looks like | Flow |
|---|---|---|
| SMALL | isolated change, 1–3 files, clear requirements | implement → /preflight → /finalize |
| MEDIUM | multi-file, some design needed, known boundaries | inline plan → implement → /preflight → /finalize |
| LARGE | cross-cutting, unclear scope, multiple subsystems | /discovery → /draft-plan → implement → /preflight → /finalize |
flowchart TD
T([dev task]) --> W{"/workflow — infer tier, confirm"}
W -->|SMALL| S[implement]
W -->|MEDIUM| M["inline plan"] --> M2[implement]
W -->|LARGE| L["/discovery"] --> L2["/draft-plan"] --> L3[implement]
S --> F["/preflight → /finalize"]
M2 --> F
L3 --> F
Every tier ends with /preflight (an "am I actually done?" checklist) and /finalize (typecheck → lint → format → test, in order, once, stopping on the first failure) — quality checks run at the end, not scattered mid-implementation.
Checkpoints (MEDIUM/LARGE only): at each phase boundary the agent stops and presents a menu — after the plan (start implementing / tests-first / spawn agents / revise), after implementation (verify & finalize / code review / keep building), and after everything is green (review / commit / release / done). The agent never auto-advances past a checkpoint; SMALL runs linear with no menus.
Named agents: five specialists ship with the bundle — explorer (read-only codebase navigation), researcher (external API/library verification), planner (design → executable plan), coder (executes a provided plan), and scout (one angle of a /condux:research survey). Pipeline: explorer/researcher gather → planner plans → coder executes → finalize validates. The default is still to implement directly — agents are opt-in at checkpoints or justified by genuinely parallel work.
| Skill | Description |
|---|
<!-- catalog:begin readme-condux -->
| Skill | Description |
|---|---|
| /workflow | Tier router — infer Small/Medium/Large, confirm with user, load only the skills the tier needs |
| /discovery | Design gate — goal-round questions, alternatives, then a post-approach detail round (contracts, mappings, edge cases); the design doc builds section by section in a live browser preview, and sign-off signs it off plus, default-on, a structured tech spec |
| /blueprint | Design-time visual clarity — fires on the question a decision turns on, not on whether a feature has a UI; HTML wireframes and full renders in the surface-kit token language (two modes, one skeleton), inline-SVG diagrams (data models, flows, architecture, state machines); dependency-free |
| /draft-plan | Lean task-card plan (what/why/gotchas/deps), Markdown, LARGE tasks only |
| /test-first-development | Opt-in tests-first — one upfront consent, then red-green-refactor; asks before editing existing specs |
| /subagent-execution | Named specialist agents for LARGE plans, only when justified, never to fill time |
| /subagent-deployment | Fan out independent tasks across named agents in one message — ad-hoc, not a formal plan |
| /research | Cited multi-source research for dev questions — the main session leads, parallel scout agents write cited notes, and a decision-ready report lands in .condux/research/; sequential where no agents exist |
| /finalize | End-of-task quality gate — typecheck → lint → format → test, once, stop on first failure |
| /live-verification | Run the change and watch it work — drives the real UI or endpoint after finalize, light mode then dark, reports claim → evidence → verdict and names what it couldn't verify |
| /code-review | On-request diagnostic report (Critical/Important/Minor), never auto-triggers, never fixes |
| /preflight | "Am I actually done?" checklist before finalize — catches skipped steps and regressions, and drift-checks the implementation against the task's spec |
| /root-cause-analysis | Root-cause-first bug investigation — enforces the 4-phase sequence before any fix |
| /technical-spec | Scaffold and persist feature specs (decisions, API, fields, quirks) with a live HTML preview |
| /plan-review | Annotate a plan in a local browser with a categorized comment toolbar, then return approve/revise/deny to the agent — auto via a Claude Code ExitPlanMode hook or a Codex Stop hook, or manually. Self-contained, no egress |
<!-- catalog:end readme-condux -->
Continuous memory for Codex (concord) — the remember skill plus its doctor:
/plugin install concord@jabworks-agentic-toolkit
Three Codex hooks share one idempotent operation — sync the rollout forward from its recorded position — so capture is exactly-once regardless of hook order, and a hard-killed session is recovered at next start rather than lost. Memory ages through buffer, daily, recent, and archive tiers; explicitly pinned facts are never auto-compressed. references/install-codex-hook.sh wires the hooks; concord-doctor tells you whether they are still wired.
File-based project backlog plugin (docket) — two skills plus machinery:
/plugin install docket@jabworks-agentic-toolkit
Open items live in docket/DOCKET.md, closed items move to docket/archive/<year>.md with verification records, and ids are never reused — #N in a commit subject refers to the docket, not GitHub. The record skill captures and closes items; groom sweeps for stale and ghost work and recommends what to pick next. A dependency-free CLI (server/docket.mjs) keeps id allocation and archive moves byte-exact, a bundled MCP server exposes the same ops as tools (auto-registered on Claude Code; server/install.sh or the agent-followable server/INSTALL.md registers it for Codex/OpenCode), and docket.mjs browse renders a self-contained HTML board. Legacy root BACKLOG.md layouts are detected and work in place — migration is offered, never forced.
Repo-maintenance bundle (toolkit-ops) for working on this toolkit itself:
/plugin install toolkit-ops@jabworks-agentic-toolkit
| Skill | Description |
|---|
<!-- catalog:begin readme-toolkit-ops -->
| Skill | Description |
|---|---|
| toolkit-orientation | Zero-context map of the repo — trees, bundles, manifest pairing, docs trust order |
| toolkit-foundry | Create and maintain skills — scaffold, register, sync, publish (formerly standalone plugin-foundry) |
| toolkit-change-control | Classify a change, pick the version bump, gate on the publish checklist |
| toolkit-skill-standards | Frontmatter budgets, trigger contract, progressive disclosure, collision scan |
| toolkit-debugging-playbook | Symptom → discriminating command → root cause for skill/plugin problems |
| toolkit-failure-archaeology | Git-evidenced incident ledger — don't re-fight settled battles |
| toolkit-plugin-reference | Verified plugin.json / marketplace.json schema, Claude↔Codex divergences |
| toolkit-research-frontier | Open problems, assets, next steps, and the library-health campaign |
<!-- catalog:end readme-toolkit-ops -->
Skills that write files into your project follow one rule: durable output goes to a normal project path; working state goes to .<plugin-name>/ at the git root, gitignored.
<git-root>/
specs/ # durable — tech specs, committed, browsable via spec-browser
.condux/ # working state — designs/ plans/ progress/ scratch/
.session-handoff/ # working state — handoff docs
.session-report/ # working state — generated HTML usage reports
The spec is what you keep; the design and plan are scaffolding it was built from. Directories are named for the owning plugin, not the skill and not the artifac
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