A Claude Code work-in-progress (WIP) pane that tracks the defects, issues, tasks and decisions your sessions turn up, grouped by category and priority, with a…

A Claude Code mod: a side pane that tracks the defects, issues, follow-up tasks and decisions your sessions turn up, so they stop scrolling away in the transcript.
Long-running sessions end every iteration with a list of things left over. This plugin gives Claude a place to record them and gives you a pane to triage them: grouped by category, sorted by priority, with a detail view where you decide, direct or dismiss each one.
WIP stands for work in progress. The list holds what your own sessions leave behind; it does not replace a team backlog in GitHub or Azure DevOps.
/plugin install wip --marketplace endjin/endjin-marketplace
This needs Claude Code 2.1.275 or later. On an earlier version, add the marketplace with /plugin marketplace add endjin/endjin-marketplace, then run /plugin install wip@endjin.
| Piece | What it does |
|---|---|
/wip | Opens and focuses the pane |
| WIP pane | Open items grouped as Decisions, Defects, Issues and Tasks, each sorted critical to low; press a heading to fold its section to the heading and its count, or to unfold it, and the choice is kept across sessions |
| Detail view | The item's full context, Claude's recommendation, its options, and the actions below |
| Row above the prompt | wip: 4 open, » 1 in progress, ● 2 new, 1 to decide for the current project, on the terminal and desktop, while it has open items; press wip: to open or close the pane. VS Code and mobile show the counts on a plain status line instead, also when they attach to a terminal session |
| Ids in Claude's replies | On the desktop and in the fullscreen terminal, an item's id is a link that opens the pane on that item |
mcp__wip__add | Claude records one or more items; an open item with the same title is refreshed, not duplicated: it takes the category, priority, detail, options and recommendation given, so a defect recorded again as a decision becomes one, and the reply says it moved |
mcp__wip__update | Claude marks an item in progress or done, re-prioritises it, or appends a note; several items change in one call, as items, with one record written per item and the pane redrawn once |
mcp__wip__list | Claude lists the WIP items, or reads one item in full; new items are flagged new, and an item in progress for a day or more says for how long |
A short system-prompt section tells Claude to record what a piece of work leaves behind, to write each item so it stands alone, to mark an item in progress when it starts work on it, and to mark items done when it settles them.
Two marks stand before an item's title in the pane:
| Mark | Means |
|---|---|
» | In progress: a session is working on it. Fix now, an option, Accept recommendation and Direct set this, and so does Claude when it takes up an item itself |
● | New: the item is open and was recorded in the last 10 minutes, by this session or another |
An item in progress is never also marked new. A new item loses its mark on the first five-second poll after its 10 minutes are up. Reopening an item ends its in-progress mark, and does not make it new again unless it was recorded in the last 10 minutes.
The pane header counts what the list shows, and its marks are the legend: 3 open, » 1 in progress, ● 2 new, 1 to decide. The row above the prompt gives the same counts, with the same marks, for the current project, whatever the pane shows; VS Code and mobile have no such row, so their status line gives the counts without the marks. The row gives way to a survey, and is not drawn while the project has nothing open.
A session can draw on several surfaces at once. The status line is set while any of them has no row, so a phone or VS Code that attaches to a terminal session gets the counts as well, until the last such surface leaves. One status line serves the whole session, so while one is attached the terminal shows the counts twice: in its row and on its status line.
The detail view of an item in progress says since when, and whether this session or another one is working on it; the later session to take it up is the one shown.
An item in progress goes back to open in three ways, so a » does not outlive the work:
Released: the session working on it ended. Items another session has since taken up are left alone.3d before its title in the list, In progress since 2026-10-03 (3 days) in its detail view, and in progress for 3 days when Claude lists the WIP items. Release in the detail view sets it back to open. The age moves on the first five-second poll past each whole day.Open an item in the pane, then:
Deciding, directing and fixing each send the item to the session whose pane you pressed the button in, as a prompt that runs once that session is idle. The item is marked in progress, and Claude is asked to mark it done with a one-line resolution when it finishes, or to set it back to open with a note when it cannot.
An action the WIP folder does not take, because it cannot be read or written, changes nothing and says why in a toast; press it again once the folder is back. When an item was sent to Claude but could not be marked in progress, the toast says that instead.
Claude's replies name items by id, such as iqzyou3r. On the desktop and in the fullscreen terminal, the id of every item on the WIP list, in any project and open or closed, is a link: click it to open the pane on that item. On the terminal's main screen, and in VS Code and on mobile, ids stay plain text: a click there would open a browser, not the pane. An id inside a code block or a code span, or inside a link Claude already wrote, is left as it is. An item that has left the WIP list since the reply was drawn says so in a toast.
A reply that names no item is left as the engine draws it, at no cost beyond a scan of its words, and is not drawn again when an item is recorded: an id is named once its item exists. A reply that names one is drawn again when the ids change, so a link to an item that has left loses it.
The WIP list is a folder of change records, ~/.claude/wip/items/ (under CLAUDE_CONFIG_DIR when that is set). Every session with the plugin enabled reads and writes that folder, re-reads it every five seconds, and shows a toast when another session adds an item. After a minute of polls that find nothing new, the folder is re-read every thirty seconds instead, until a re-read finds a change, the session writes, or you send a prompt; the marks for new items and for the age of a claim still move on the five-second poll, which reads no file for them. When none of CLAUDE_CONFIG_DIR, USERPROFILE and HOME is set, an empty value counting as unset, there is no folder: the session logs why, and Claude's calls are refused, saying nothing was recorded. The same refusal answers a call when the folder cannot be read or written. An add of several items that fails part way through is refused differently: it lists the items recorded before the failure and names the ones to send again.
Each change to an item is a small JSON file of its own, named <id>.<time>.<token>.json, that says which fields it sets and which note it appends. A file is written once and never rewritten. An item is its records applied in time order. So two sessions that change the same item never overwrite each other: a note from one and a status from another both stand, and when both set the same field the later one wins.
The folder is kept small by compaction. A closed item that has rested for an hour has its records folded into one file, which names the files it replaces, and those are removed; a session that sees both folds the item once. A change made meanwhile, which the compacted file does not name, still applies after it. Three items are compacted a poll, the compacted file is written before anything is removed, and a removal the file system refuses is tried again ten minutes later. The removal runs rm -f, or del on Windows, since the engine's own file calls cannot remove a file; a host without either leaves the files in place, read as before.
The pane lists the current project's items; All projects shows the rest. An item from another project says so in its detail view, because acting on it runs in the session you are in, not the one that recorded it.
Items are never deleted, only marked done or dismissed. To clear the WIP list, delete the files.
The plugin was called backlog before, and kept its records in ~/.claude/backlog/items/. Each session start moves any records it finds there into the WIP folder. A record the WIP folder already has is not written again. The old files are removed once every copy is written, and then the old folders are removed if they are empty. A toast says how many records moved. A removal the host refuses is logged and left for the next session start, which moves the records again.
Most limits are enforced when Claude writes, by refusing with the reason, so Claude can shorten or split what it sent:
| Limit | Over it |
|---|---|
50 items in one add or update call | The call is refused whole |
| Title: 200 characters, counted with its whitespace collapsed | add skips that item and records the rest; update of one item is refused, and of several skips that item and changes the rest |
| Options: nine with a label | add skips that item and records the rest |
| Detail: 20,000 characters | add skips that item and records the rest |
| Note: 10,000 characters | update of one item is refused, and of several skips that item and changes the rest |
Four shorter fields are cut to their limit instead, without a message: an option's label at 120 characters, an option's detail at 1,000, and a recommendation and a resolution at 2,000 each.
A resolution the pane writes, Accepted the recommendation: ... or Directed: ..., is kept whole up to that 2,000, and ends with an ellipsis when it is cut. The direction itself reaches Claude whole.
hooks/register.tsx), which other agents that read this marketplace do not run./wip.CLAUDE_CODE_NO_FLICKER=0): the pane is a box above the prompt, never beside the transcript. It does not open by itself there, so it does not take over the screen at every start. Run /wip or press wip: to open it. It opens tall enough to show the whole list, up to what the screen can spare; when the list is longer, focus the pane and scroll with the arrow keys.claude plugin validate ./plugins/wip # manifest, hooks and state contract
claude plugin test ./plugins/wip # tests/wip.test.ts, against the engine
claude --plugin-dir ./plugins/wip # load it from disk, hot-reloading on save
Loading it with --plugin-dir makes the engine write its type declarations into .claude-plugin/types/ (ignored by git), which tsconfig.json extends, so an editor and tsc -p plugins/wip type-check the module.
| Path | Holds |
|---|---|
hooks/register.tsx | The hooks module: tools, command, system-prompt section, storage, the pane and the links to items in replies |
types/index.d.ts | The item and change-record shapes, and the $.state contract |
tests/wip.test.ts | Behaviour tests over an in-memory folder, on the terminal, desktop, VS Code and mobile surfaces |
hooks/register.tsx 2799 lines1import { atom, read, update } from 'claude-code'
2import type {
3 ElementConstructor,
4 EngineInterface,
5 Register,
6 RenderSurface,
7 TextProps,
8 Timer,
9} from 'claude-code'
10
11import type {
12 WipCategory,
13 WipChange,
14 WipFields,
15 WipItem,
16 WipOption,
17 WipPriority,
18 WipStatus,
19 WipView,
20} from '../types'
21
22const PANE = 'wip'
23const POLL_MS = 5000
24// After this many polls in a row that list the folder and find nothing new,
25// it is listed on every SLOW_EVERY-th poll alone (every thirty seconds), until
26// a listing finds a change, this session writes, or the person sends a prompt.
27// The other checks of a poll, which read no file, run on every one.
28const IDLE_POLLS = 12
29const SLOW_EVERY = 6
30const BATCH_MAX = 50
31const TITLE_MAX = 200
32const OPTIONS_MAX = 9
33const DETAIL_MAX = 20000
34const NOTE_MAX = 10000
35const RESOLUTION_MAX = 2000
36// A Markdown element draws at most 10,000 characters: longer text is drawn as
37// several, cut at paragraph breaks.
38const MARKDOWN_MAX = 10000
39const CHUNK = 9000
40
41const CATEGORIES = ['decision', 'defect', 'issue', 'task'] as const
42const PRIORITIES = ['critical', 'high', 'medium', 'low'] as const
43const STATUSES = ['open', 'in_progress', 'done', 'dismissed'] as const
44
45const HEADING: Record<WipCategory, string> = {
46 decision: 'Decisions',
47 defect: 'Defects',
48 issue: 'Issues',
49 task: 'Tasks',
50}
51const TAG: Record<WipPriority, string> = {
52 critical: 'CRIT',
53 high: 'HIGH',
54 medium: 'MED ',
55 low: 'LOW ',
56}
57const STATUS: Record<WipStatus, string> = {
58 open: 'open',
59 in_progress: 'in progress',
60 done: 'done',
61 dismissed: 'dismissed',
62}
63const MARK: Record<WipStatus, string> = {
64 open: '',
65 in_progress: '',
66 done: '✓ ',
67 dismissed: 'x ',
68}
69// Drawn before a row's title, and in the header's counts as their legend. The
70// glyph carries the meaning; the colour only adds to it.
71const WORKING = { mark: '»', color: 'suggestion' } as const
72const FRESH = { mark: '●', color: 'success' } as const
73// The surfaces that raise the band above the prompt, where the counts are
74// drawn in place of the status line.
75const BANDED: readonly RenderSurface[] = ['terminal', 'desktop']
76// An open item is new for this long after it was recorded.
77const FRESH_MS = 10 * 60 * 1000
78const DAY_MS = 24 * 60 * 60 * 1000
79// The note a session leaves on each item it had in progress when it ends.
80const RELEASED = 'Released: the session working on it ended.'
81
82// A closed item's records are folded into one file once the item has rested
83// this long, a few items a poll; a removal the host refuses is tried again
84// after a wait.
85const COMPACT_AFTER_MS = 60 * 60 * 1000
86const COMPACT_MAX = 3
87const COMPACT_RETRY_MS = 10 * 60 * 1000
88// Paths handed to one removal command: well under a command line's room.
89const REMOVE_MAX = 40
90// The rows an item's detail view asks for while inline above the prompt: more
91// than most screens spare, so it takes what the layout gives.
92const DETAIL_ROWS = 60
93// Where no mod answers the press, the surface opens this page: a link not
94// https, http or file draws as text.
95const HREF_BASE =
96 'https://github.com/endjin/endjin-marketplace/blob/main/plugins/wip/README.md'
97// The most links one Markdown answers, the engine's own limit.
98const LINKS_MAX = 256
99// The longest reply linked here: the engine draws 100,000 characters in one
100// drawing, and linking adds about 95 per id.
101const DRAW_MAX = 90000
102// The bullet opening a reply, drawn here as the engine draws its own. It
103// mirrors the engine's, not read from it: check it live.
104const BULLET = '⏺ '
105
106const GUIDANCE = `# Work in progress
107
108The person keeps a work-in-progress (WIP) list in a side pane, shared by all their Claude Code sessions. When a piece of work ends and leaves defects, issues, follow-up tasks or decisions only the person can make, record each with mcp__wip__add as well as mentioning it in your reply; do the same when you reach a choice that is theirs to make. Do not record what you are about to do in this same turn.
109
110The person reads an item later, away from this conversation, and may act on it from another session. Write its detail to stand alone: what you observed, where (file and line), why it matters. A decision carries its options with their trade-offs and your recommendation.
111
112When you start work on an item that is already on the WIP list, call mcp__wip__update with status "in_progress", so the pane shows it is being worked on. When you fix or settle an item, call mcp__wip__update with status "done" and a one-line resolution. When you stop work on an item without finishing it, set its status back to "open" with a note saying what is left. Several items changing at once go in one mcp__wip__update call, as \`items\`, not one call per item.`
113
114const items = atom({ plugin: 'wip', key: 'items' } as const, [])
115const view = atom({ plugin: 'wip', key: 'view' } as const, {
116 selected: null,
117 scope: 'project',
118 showClosed: false,
119 collapsed: [],
120})
121
122// The store key under which the folded sections are kept across sessions.
123const COLLAPSED = 'collapsed'
124
125// The categories among `raw`, each once, in their fixed order.
126const toCategories = (raw: unknown): WipCategory[] =>
127 CATEGORIES.filter(
128 category => Array.isArray(raw) && raw.includes(category),
129 )
130
131// Folds a section of the list to its heading, or unfolds it, and remembers
132// the choice past the session. The view a session holds may be one an earlier
133// version of this code wrote, which a reload keeps whole: the list of folded
134// sections is read through `toCategories`, never trusted to be there.
135const collapse = async (
136 $: EngineInterface,
137 category: WipCategory,
138): Promise<void> => {
139 const held = await update($, view, one => {
140 const collapsed = toCategories(one.collapsed)
141
142 return {
143 ...one,
144 collapsed: collapsed.includes(category)
145 ? collapsed.filter(other => other !== category)
146 : toCategories([...collapsed, category]),
147 }
148 })
149
150 try {
151 await $.store.set(COLLAPSED, held.collapsed)
152 } catch (error) {
153 $.ui.log(`wip: could not remember the folded sections: ${reason(error)}`, {
154 to: 'debug',
155 })
156 }
157}
158const project = atom({ plugin: 'wip', key: 'project' } as const, '')
159const fresh = atom({ plugin: 'wip', key: 'fresh' } as const, [])
160const aged = atom({ plugin: 'wip', key: 'aged' } as const, {})
161const ids = atom({ plugin: 'wip', key: 'ids' } as const, [])
162
163// File name to the change record last read from it; `stamp` is the listing's
164// time and size, '' for a file this session just wrote.
165const records = new Map<string, { stamp: string; change: WipChange }>()
166const unreadable = new Map<string, string>()
167// The items as `records` fold, kept until a record is read, written or
168// dropped: a poll that finds nothing changed folds nothing.
169let folded: WipItem[] | undefined
170// The ids of every item as of the last publish, read by the replies that
171// link ids without a trip to the host; empty until the folder was first read.
172let knownIds: ReadonlySet<string> = new Set()
173// The folder's path once resolved; the environment does not change in-session.
174let home = ''
175let hasLoaded = false
176let lastAt = 0
177let queue: Promise<unknown> = Promise.resolve()
178let polling: Timer | undefined
179// Set at session start until a band drawing says whether its surface docks a
180// pane: the pane opens unasked only where it would sit beside the transcript.
181let isOpening = false
182// Listing polls in a row that found nothing new, and polls skipped since the
183// last listing while idle.
184let idle = 0
185let skipped = 0
186// When compaction may next run: after a refused removal, not before a wait.
187let compactAfter = 0
188// Files a removal this session ran reported gone: one that is listed again
189// is not removed again, so a file the host holds on to costs no more commands.
190const swept = new Set<string>()
191
192// Listings of the folder finished so far: a call that waited in line reads
193// whether one finished meanwhile, and takes it as its own.
194let listed = 0
195
196// What a piece of work is told as its turn comes.
197type Turn = {
198 // A listing finished while the work waited: the records are level with
199 // the folder as of after the work was asked for, so it need not list again.
200 isListed: boolean
201}
202
203// The error a piece of work rejects with when its call was given up before
204// its turn came. The hook's handler has answered by then; nothing reads it.
205const abandoned = (): Error =>
206 new Error('the call was given up before its turn came')
207
208// Reads and writes of the folder run one at a time within this session.
209// Across sessions nothing needs a lock: every write is a new file.
210//
211// A hook's budget runs while it waits here: the engine stops the clock for
212// a `$` call of the hook's own, not for another call's work. A call whose
213// budget ran out, or whose dispatch the person interrupted, has been answered
214// already, so its work is not run: `signal` is the hook's `next.signal`.
215const inTurn = <T,>(
216 work: (turn: Turn) => Promise<T>,
217 signal?: AbortSignal,
218): Promise<T> => {
219 const before = listed
220 const start = (): Promise<T> =>
221 signal?.aborted === true
222 ? Promise.reject(abandoned())
223 : work({ isListed: listed > before })
224 const run = queue.then(start, start)
225 queue = run.catch(() => undefined)
226
227 return run
228}
229
230const clean = (text: unknown, limit: number): string =>
231 typeof text === 'string'
232 ? text
233 .replace(/\r\n?/g, '\n')
234 .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
235 .trim()
236 .slice(0, limit)
237 : ''
238
239const oneLine = (text: unknown, limit: number): string =>
240 clean(text, limit * 2)
241 .replace(/\s+/g, ' ')
242 .slice(0, limit)
243
244const record = (raw: unknown): Record<string, unknown> =>
245 typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
246
247// A path with forward slashes and no trailing one, except a root: `/` and
248// `C:/` keep theirs, as trimmed they would name nothing, or a drive alone.
249const slash = (path: string): string => {
250 const trimmed = path.replace(/\\/g, '/').replace(/\/+$/, '')
251
252 return (trimmed === '' && path !== '') || /^[A-Za-z]:$/.test(trimmed)
253 ? `${trimmed}/`
254 : trimmed
255}
256
257// `under` beneath `base`, with one slash between them whether or not `base`
258// ends in one.
259const join = (base: string, under: string): string =>
260 base.endsWith('/') ? `${base}${under}` : `${base}/${under}`
261
262// A project root as items are compared by it. A path keeps its inner
263// whitespace: `/work/my app` and `/work/my app` are two projects.
264const toPath = (path: string): string => slash(clean(path, 1000))
265
266// The options `raw` gives that have a label, the first `most` of them. A
267// record read from the folder is held to the limit; a draft is counted whole,
268// and refused when over it.
269const toOptions = (raw: unknown, most = OPTIONS_MAX): WipOption[] =>
270 (Array.isArray(raw) ? raw : [])
271 .map(one => {
272 const fields = typeof one === 'string' ? { label: one } : record(one)
273
274 return {
275 label: oneLine(fields.label, 120),
276 detail: clean(fields.detail, 1000),
277 }
278 })
279 .filter(option => option.label !== '')
280 .slice(0, most)
281
282// The fields `raw` names, each held to its shape; one it leaves out, or names
283// with a value of the wrong kind, is left out.
284const toFields = (raw: Record<string, unknown>): Partial<WipFields> => {
285 const set: Partial<WipFields> = {}
286 const title = oneLine(raw.title, TITLE_MAX)
287 const category = CATEGORIES.find(one => one === raw.category)
288 const priority = PRIORITIES.find(one => one === raw.priority)
289 const status = STATUSES.find(one => one === raw.status)
290
291 if (title !== '') {
292 set.title = title
293 }
294
295 if (category !== undefined) {
296 set.category = category
297 }
298
299 if (priority !== undefined) {
300 set.priority = priority
301 }
302
303 if (status !== undefined) {
304 set.status = status
305 }
306
307 if (typeof raw.detail === 'string') {
308 set.detail = clean(raw.detail, DETAIL_MAX)
309 }
310
311 if (Array.isArray(raw.options)) {
312 set.options = toOptions(raw.options)
313 }
314
315 if (typeof raw.recommendation === 'string') {
316 set.recommendation = clean(raw.recommendation, RESOLUTION_MAX)
317 }
318
319 if (typeof raw.resolution === 'string') {
320 set.resolution = clean(raw.resolution, RESOLUTION_MAX)
321 }
322
323 if (typeof raw.project === 'string') {
324 set.project = toPath(raw.project)
325 }
326
327 return set
328}
329
330// What one update asks, as `raw` gives it: the item's id, the fields to set
331// and the note to append; or why the entry is skipped, said with its id
332// where it has one. The note and the title are refused over their limits,
333// never cut.
334const toEdit = (raw: Record<string, unknown>): Edit | { skipped: string } => {
335 const id = oneLine(raw.id, 12)
336
337 if (id === '') {
338 return { skipped: 'an entry with no id' }
339 }
340
341 if (typeof raw.note === 'string' && raw.note.length > NOTE_MAX) {
342 return {
343 skipped: `${id}: its note is ${raw.note.length} characters and the limit is ${NOTE_MAX}`,
344 }
345 }
346
347 const title = oneLine(raw.title, Infinity)
348
349 if (title.length > TITLE_MAX) {
350 return {
351 skipped: `${id}: its title is ${title.length} characters and the limit is ${TITLE_MAX}`,
352 }
353 }
354
355 const note = clean(raw.note, NOTE_MAX)
356 const resolution = clean(raw.resolution, RESOLUTION_MAX)
357 const set = toFields({
358 title: raw.title,
359 status: raw.status,
360 priority: raw.priority,
361 category: raw.category,
362 ...(resolution === '' ? {} : { resolution }),
363 })
364
365 if (note === '' && Object.keys(set).length === 0) {
366 return {
367 skipped: `${id}: nothing to change; give a status, priority, category, title, resolution or note`,
368 }
369 }
370
371 return { id, set, note }
372}
373
374const ID = /^[a-z0-9]{4,12}$/
375
376// A time as this mod writes one: milliseconds since 1970, a number. Null, '',
377// [] or true would convert to 0 or 1, and sort before every real record.
378const isTime = (at: unknown): at is number =>
379 typeof at === 'number' && Number.isFinite(at) && at > 0
380
381const toChange = (raw: unknown): WipChange | undefined => {
382 const fields = record(raw)
383 const id = typeof fields.id === 'string' && ID.test(fields.id) ? fields.id : ''
384 // The first version of this mod kept an item as one whole file, `<id>.json`,
385 // rewritten on every change. Such a file reads as one record that sets every
386 // field, as of its last rewrite (its creation when the rewrite has no good
387 // time); changes made since are records beside it.
388 const isWhole = fields.set === undefined && typeof fields.title === 'string'
389 const at = (
390 isWhole ? [fields.updatedAt, fields.createdAt] : [fields.at]
391 ).find(isTime)
392
393 if (id === '' || at === undefined) {
394 return undefined
395 }
396
397 const change: WipChange = {
398 id,
399 at,
400 sessionId: oneLine(fields.sessionId, 100),
401 set: toFields(isWhole ? fields : record(fields.set)),
402 note: isWhole ? '' : clean(fields.note, NOTE_MAX),
403 }
404
405 // A record that compacts the item replaces files of the item's own alone.
406 if (Array.isArray(fields.folds)) {
407 change.folds = fields.folds.filter(
408 (name): name is string =>
409 typeof name === 'string' &&
410 name.startsWith(`${id}.`) &&
411 RECORD_NAME.test(name),
412 )
413 change.notes = (Array.isArray(fields.notes) ? fields.notes : [])
414 .map(record)
415 .filter(note => isTime(note.at) && typeof note.text === 'string')
416 .map(note => ({ at: note.at as number, text: clean(note.text, NOTE_MAX) }))
417 .filter(note => note.text !== '')
418
419 if (isTime(fields.createdAt)) {
420 change.createdAt = fields.createdAt
421 }
422 }
423
424 return change
425}
426
427// A record file's name: `<id>.<time>.<token>.json`, or the first version's
428// `<id>.json`.
429const RECORD_NAME = /^[a-z0-9]{4,12}(?:\.[a-z0-9]+\.[a-z0-9]+)?\.json$/
430
431// The fields a compacting record sets: every one, as the item stands.
432const fieldsOf = (item: WipItem): WipFields => ({
433 title: item.title,
434 category: item.category,
435 priority: item.priority,
436 status: item.status,
437 detail: item.detail,
438 options: item.options,
439 recommendation: item.recommendation,
440 resolution: item.resolution,
441 project: item.project,
442})
443
444// Every item, each the fold of its records in `at` order. Records set only
445// the fields they name, so a note from one session and a status from another
446// both stand, whichever was written first. Folded once per change to
447// `records`; the callers never alter what they are given.
448const fold = (): WipItem[] => {
449 folded ??= foldAll()
450
451 return folded
452}
453
454const foldAll = (): WipItem[] => {
455 // A file a compacting record replaces applies no more, though it is still
456 // listed: a session that sees both folds the item once. At one time the
457 // compacting record goes first, so a change made at that moment by a
458 // session that did not see it folds after it.
459 const replaced = new Set<string>()
460
461 for (const { change } of records.values()) {
462 for (const name of change.folds ?? []) {
463 replaced.add(name)
464 }
465 }
466
467 const rank = (change: WipChange) => (change.folds === undefined ? 1 : 0)
468 const ordered = [...records.entries()]
469 .filter(([name]) => !replaced.has(name))
470 .sort(
471 ([nameA, a], [nameB, b]) =>
472 a.change.at - b.change.at ||
473 rank(a.change) - rank(b.change) ||
474 (nameA < nameB ? -1 : 1),
475 )
476 const byId = new Map<string, WipItem>()
477
478 for (const [, { change }] of ordered) {
479 const found: WipItem = byId.get(change.id) ?? {
480 id: change.id,
481 title: '',
482 category: 'task',
483 priority: 'medium',
484 status: 'open',
485 detail: '',
486 options: [],
487 recommendation: '',
488 resolution: '',
489 notes: [],
490 project: '',
491 sessionId: change.sessionId,
492 createdAt: change.at,
493 updatedAt: change.at,
494 workingSince: 0,
495 workingIn: '',
496 }
497 // A compacting record carries the notes and the first time whole, in
498 // place of what the files it replaced built up.
499 const held: WipItem =
500 change.folds === undefined
501 ? found
502 : {
503 ...found,
504 notes: change.notes ?? found.notes,
505 createdAt: change.createdAt ?? found.createdAt,
506 }
507 // A record that names the status in progress claims the item, the later
508 // claim winning; one that leaves the status out leaves the claim held.
509 const isWorking = (change.set.status ?? held.status) === 'in_progress'
510 const isClaim = change.set.status === 'in_progress'
511
512 byId.set(change.id, {
513 ...held,
514 ...change.set,
515 notes:
516 change.note === ''
517 ? held.notes
518 : [...held.notes, { at: change.at, text: change.note }],
519 updatedAt: change.at,
520 workingSince: !isWorking ? 0 : isClaim ? change.at : held.workingSince,
521 workingIn: !isWorking ? '' : isClaim ? change.sessionId : held.workingIn,
522 })
523 }
524
525 return [...byId.values()].filter(item => item.title !== '')
526}
527
528// A project's name: the last part of its path, or the root itself for a
529// session rooted at one, `/` or `C:/`.
530const nameOf = (path: string): string => {
531 const parts = path.split('/').filter(part => part !== '')
532
533 return parts.length <= 1 && path.endsWith('/')
534 ? path
535 : (parts.at(-1) ?? path)
536}
537
538const isOpen = (item: WipItem): boolean =>
539 item.status === 'open' || item.status === 'in_progress'
540
541// Working outranks new: an item in progress is never also marked new.
542const isFresh = (item: WipItem, now: number): boolean =>
543 item.status === 'open' && now - item.createdAt < FRESH_MS
544
545// How many whole days an item has been in progress, 0 under one day. A claim
546// that old may be one a session left behind when it crashed.
547const daysHeld = (item: WipItem, now: number): number =>
548 item.status === 'in_progress' && item.workingSince > 0
549 ? Math.max(0, Math.floor((now - item.workingSince) / DAY_MS))
550 : 0
551
552const days = (count: number): string =>
553 `${count} ${count === 1 ? 'day' : 'days'}`
554
555const fit = (text: string, width: number): string =>
556 text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
557
558const day = (at: number): string =>
559 at > 0 ? new Date(at).toISOString().slice(0, 10) : ''
560
561const byUrgency = (a: WipItem, b: WipItem): number =>
562 Number(isOpen(b)) - Number(isOpen(a)) ||
563 PRIORITIES.indexOf(a.priority) - PRIORITIES.indexOf(b.priority) ||
564 b.updatedAt - a.updatedAt
565
566const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
567
568const token = (length: number): string =>
569 [...crypto.getRandomValues(new Uint8Array(length))]
570 .map(byte => ALPHABET.charAt(byte % ALPHABET.length))
571 .join('')
572
573// A code block open at some line: the run of ` or ~ that opened it, and the
574// line a piece cut inside it opens it again with, '' when it cannot.
575type Fence = { mark: string; reopen: string }
576
577// The most a piece repeats of a block's opening line. A longer info string is
578// cut short when repeated, and a block whose run alone is longer is cut with
579// no fence lines added, so a piece is at most CHUNK + 402 characters.
580const FENCE_MAX = 200
581
582// The block open after `line`, given the one open before it, by CommonMark's
583// rule: a run of three or more ` or ~ opens a block (a ` run only with no `
584// after it); only a run of the same character, at least as long, with nothing
585// after it but whitespace, closes it. Any indent is taken, as a fence inside a
586// list item has.
587const fenceAfter = (line: string, open?: Fence): Fence | undefined => {
588 const [, run = '', rest = ''] = /^\s*(`{3,}|~{3,})(.*)$/.exec(line) ?? []
589
590 if (open !== undefined) {
591 return run.startsWith(open.mark) && rest.trim() === '' ? undefined : open
592 }
593
594 if (run === '' || (run.startsWith('`') && rest.includes('`'))) {
595 return undefined
596 }
597
598 return {
599 mark: run,
600 reopen:
601 run.length > FENCE_MAX
602 ? ''
603 : `${run}${rest}`.trim().slice(0, FENCE_MAX).trimEnd(),
604 }
605}
606
607// `text` whole, in pieces a Markdown element can draw: cut at the last blank
608// line outside a code block, or else before a block's opening line, or else
609// between lines. A cut inside a block closes it, and the next piece opens it
610// again, so each piece draws its code as code.
611const chunks = (text: string, limit = CHUNK): string[] => {
612 const parts: string[] = []
613 let current = ''
614 let safe = 0
615 // Where in `current` its last line starts when that line opens a block.
616 let opening = -1
617 let fence: Fence | undefined
618 // A block's opening line alone is no piece: the line after it joins it,
619 // though the two come to more than the limit.
620 const isBare = () => opening === 0 && current.length <= FENCE_MAX + 1
621 const cut = (upTo: number) => {
622 parts.push(current.slice(0, upTo).trimEnd())
623 current = current.slice(upTo)
624 opening -= upTo
625 safe = 0
626 }
627 // Ends the piece between lines, closing the block `open` when there is one
628 // to open again.
629 const between = (open?: Fence) => {
630 if (open === undefined || open.reopen === '') {
631 cut(current.length)
632
633 return
634 }
635
636 parts.push(`${current.trimEnd()}\n${open.mark}`)
637 current = `${open.reopen}\n`
638 opening = 0
639 safe = 0
640 }
641 const end = (open?: Fence) => {
642 if (safe > 0) {
643 cut(safe)
644 } else if (opening > 0) {
645 cut(opening)
646 } else {
647 between(open)
648 }
649 }
650
651 for (const whole of text.split('\n')) {
652 const before = fence
653 fence = fenceAfter(whole, fence)
654
655 for (let from = 0; from === 0 || from < whole.length; from += limit) {
656 const line = whole.slice(from, from + limit)
657 // Before a line, the block open before it; within a line too long for
658 // one piece, the one open after it.
659 const open = from === 0 ? before : fence
660
661 // Twice: what is left after the cut at a blank line can still be too
662 // long.
663 if (current.length + line.length > limit && !isBare()) {
664 end(open)
665 }
666
667 if (current.length + line.length > limit && !isBare()) {
668 end(open)
669 }
670
671 opening =
672 from === 0 && before === undefined && fence !== undefined
673 ? current.length
674 : -1
675 current += `${line}\n`
676 }
677
678 if (fence === undefined && whole.trim() === '') {
679 safe = current.length
680 }
681 }
682
683 parts.push(current.trimEnd())
684
685 return parts.filter(part => part.trim() !== '')
686}
687
688// The runs of 4 to 12 of an id's characters standing alone in a text: what
689// may name an item. Most words of prose are one.
690const CANDIDATES = /(?<![a-z0-9])[a-z0-9]{4,12}(?![a-z0-9])/g
691
692// Whether `text` names an item of `known` anywhere, code included: a cheap
693// first look that spares a block naming none, which most are, the state read
694// that would subscribe it to every change of the ids, and the scan.
695const names = (text: string, known: ReadonlySet<string>): boolean => {
696 for (const [run] of text.matchAll(CANDIDATES)) {
697 if (known.has(run)) {
698 return true
699 }
700 }
701
702 return false
703}
704
705// What a line's scan steps over whole, and the ids it may link: an inline code
706// span (its opening run not part of a longer one), a link or image already
707// written (one level of brackets in its text and parentheses in its target),
708// an autolink, a bare address, then a candidate id (group 2). No letter, digit,
709// `_` or `-` touches the id, nor a `/`, `.`, `@`, `#` or `:` with a letter or
710// digit beyond it, as in a path, a file name, a host or an address.
711const SPANS =
712 /(?<!`)(`+)(?!`)[^\n]*?(?<!`)\1(?!`)|!?\[(?:[^[\]\n]|\[[^[\]\n]*\])*\]\([^()\n]*(?:\([^()\n]*\)[^()\n]*)*\)|<[A-Za-z][A-Za-z0-9+.-]*:[^<>\s]*>|[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+|\bwww\.\S+|(?<![A-Za-z0-9_-])(?<![A-Za-z0-9][/.@#:])([a-z0-9]{4,12})(?![A-Za-z0-9_-]|[/.@#:][A-Za-z0-9])/g
713
714const hrefOf = (id: string): string => `${HREF_BASE}#${id}`
715
716// The id a link this mod drew points at, '' for any other link.
717const idOfHref = (href: string): string => {
718 const id = href.startsWith(`${HREF_BASE}#`)
719 ? href.slice(HREF_BASE.length + 1)
720 : ''
721
722 return ID.test(id) ? id : ''
723}
724
725// `text` with each id of `known` written as a link to its item, and the links'
726// targets, at most LINKS_MAX of them: one Markdown's worth, as each piece of a
727// reply is linked on its own. Code, in a block or a span, and links already
728// written are left as they are.
729const linkIds = (
730 text: string,
731 known: ReadonlySet<string>,
732): { text: string; hrefs: string[] } => {
733 const hrefs = new Set<string>()
734 let fence: Fence | undefined
735 const lines = text.split('\n').map(line => {
736 const before = fence
737 fence = fenceAfter(line, fence)
738
739 // A block's opening and closing lines are its own too.
740 if (before !== undefined || fence !== undefined) {
741 return line
742 }
743
744 return line.replace(
745 SPANS,
746 (whole: string, _run: string | undefined, id: string | undefined) => {
747 if (id === undefined || !known.has(id)) {
748 return whole
749 }
750
751 const href = hrefOf(id)
752
753 if (!hrefs.has(href) && hrefs.size >= LINKS_MAX) {
754 return whole
755 }
756
757 hrefs.add(href)
758
759 return `[${id}](${href})`
760 },
761 )
762 })
763
764 return { text: lines.join('\n'), hrefs: [...hrefs] }
765}
766
767// `text` cut into pieces first and each piece linked, so no link straddles a
768// cut. A piece its links push past what a Markdown draws is cut again, finer.
769const linkedPieces = (
770 text: string,
771 known: ReadonlySet<string>,
772 limit = CHUNK,
773): { text: string; hrefs: string[] }[] =>
774 chunks(text, limit).flatMap(part => {
775 const linked = linkIds(part, known)
776
777 return linked.text.length > MARKDOWN_MAX && limit > CHUNK / 8
778 ? linkedPieces(part, known, Math.floor(limit / 2))
779 : [linked]
780 })
781
782// The first of these that is set and not empty holds the WIP list. With none,
783// there is no WIP list: a guess, such as `/.claude`, would be a folder no other
784// session reads.
785const folder = async ($: EngineInterface): Promise<string> => {
786 if (home !== '') {
787 return home
788 }
789
790 const places = [
791 [await $.env.get('CLAUDE_CONFIG_DIR'), 'wip/items'],
792 [await $.env.get('USERPROFILE'), '.claude/wip/items'],
793 [await $.env.get('HOME'), '.claude/wip/items'],
794 ] as const
795
796 for (const [base, under] of places) {
797 if (base !== undefined && base !== '') {
798 home = join(slash(base), under)
799
800 return home
801 }
802 }
803
804 throw new Error(
805 'no home directory was found: CLAUDE_CONFIG_DIR, USERPROFILE and HOME are all unset or empty',
806 )
807}
808
809// The folder the records were kept in while the plugin was called `backlog`,
810// beside the one `folder` names.
811const legacyOf = (dir: string): string =>
812 `${dir.slice(0, -'wip/items'.length)}backlog/items`
813
814// What went wrong, without the `Error:` its text starts with.
815const reason = (error: unknown): string =>
816 String(error).replace(/^\w*Error: /, '')
817
818// What a hook refused `$.<call>` with, without the engine's lead naming who
819// called what.
820const why = (error: unknown, call: string): string => {
821 const text = reason(error)
822 const lead = `$.${call}: `
823 const at = text.lastIndexOf(lead)
824
825 return at < 0 ? text : text.slice(at + lead.length)
826}
827
828// Answers a tool call whose hook failed, the folder unreadable or unwritable.
829// Left skipped, the hook would have the engine fail the call as having no
830// implementation, which tells Claude nothing.
831const refuse = (
832 _: unknown,
833 __: unknown,
834 next: { error: { message?: string } },
835) => ({
836 deny: `The WIP list could not be read or written: ${reason(next.error.message ?? 'it did not answer in time')}. Nothing was recorded or changed.`,
837})
838
839const load = async (
840 $: EngineInterface,
841 path: string,
842): Promise<WipChange | undefined> => {
843 try {
844 return toChange(JSON.parse(await $.fs.read(path)))
845 } catch {
846 return undefined
847 }
848}
849
850// Keeps the session's project root in state level with the host, and says
851// whether it changed. Read each time, not once at session.start: a session that
852// is resumed gets its state after that hook ran, so a root written there is
853// gone, and `/cd` or a worktree move changes the root.
854const place = async ($: EngineInterface): Promise<boolean> => {
855 const root = toPath(await $.session.root())
856
857 if ((await read($, project)) === root) {
858 return false
859 }
860
861 await update($, project, () => root)
862
863 return true
864}
865
866// The ids of the items new as of `now`, in a stable order.
867const freshOf = (all: WipItem[], now: number): string[] =>
868 all
869 .filter(one => isFresh(one, now))
870 .map(one => one.id)
871 .sort()
872
873// Whether the host already holds `marked` as the new items. Compared with the
874// host, not a copy here: a resumed session loses what it wrote while starting.
875const isFreshLevel = async (
876 $: EngineInterface,
877 marked: string[],
878): Promise<boolean> => (await read($, fresh)).join(' ') === marked.join(' ')
879
880// How many whole days each item in progress has been so, by id in a stable
881// order; an item under one day has no entry.
882const agedOf = (all: WipItem[], now: number): Record<string, number> =>
883 Object.fromEntries(
884 all
885 .map(one => [one.id, daysHeld(one, now)] as const)
886 .filter(([, claimed]) => claimed > 0)
887 .sort(([a], [b]) => (a < b ? -1 : 1)),
888 )
889
890// Whether the host already holds `ages`, compared with the host as the new
891// items are.
892const isAgedLevel = async (
893 $: EngineInterface,
894 ages: Record<string, number>,
895): Promise<boolean> =>
896 JSON.stringify(await read($, aged)) === JSON.stringify(ages)
897
898type Counts = {
899 open: number
900 working: number
901 recent: number
902 decisions: number
903}
904
905// How many of `open` there are, and of them in progress, new and to decide.
906const tally = (open: WipItem[], freshIds: readonly string[]): Counts => ({
907 open: open.length,
908 working: open.filter(one => one.status === 'in_progress').length,
909 recent: open.filter(one => freshIds.includes(one.id)).length,
910 decisions: open.filter(one => one.category === 'decision').length,
911})
912
913// This project's open items counted, as the host holds them now. Read while
914// drawing, it redraws the drawing when they change.
915const countsHere = async ($: EngineInterface): Promise<Counts> => {
916 const here = await read($, project)
917 const freshIds = await read($, fresh)
918 const all = await read($, items)
919
920 return tally(
921 all.filter(one => isOpen(one) && one.project === here),
922 freshIds,
923 )
924}
925
926// The counts as one line, `4 open, 1 in progress, 2 new, 1 to decide`; with
927// `isMarked`, the in progress and new parts led by their marks.
928const spell = (counts: Counts, isMarked: boolean): string =>
929 [
930 `${counts.open} open`,
931 counts.working > 0
932 ? `${isMarked ? `${WORKING.mark} ` : ''}${counts.working} in progress`
933 : '',
934 counts.recent > 0
935 ? `${isMarked ? `${FRESH.mark} ` : ''}${counts.recent} new`
936 : '',
937 counts.decisions > 0 ? `${counts.decisions} to decide` : '',
938 ]
939 .filter(part => part !== '')
940 .join(', ')
941
942// The counts as the pane's header and the band draw them: `spell` with its
943// marks, each mark in its colour.
944const drawCounts = (Text: ElementConstructor<TextProps>, counts: Counts) => [
945 `${counts.open} open`,
946 counts.working > 0 && ', ',
947 counts.working > 0 && <Text color={WORKING.color}>{WORKING.mark}</Text>,
948 counts.working > 0 && ` ${counts.working} in progress`,
949 counts.recent > 0 && ', ',
950 counts.recent > 0 && <Text color={FRESH.color}>{FRESH.mark}</Text>,
951 counts.recent > 0 && ` ${counts.recent} new`,
952 counts.decisions > 0 && `, ${counts.decisions} to decide`,
953]
954
955// Sets the status line: this project's counts while a surface the session
956// draws on has no band to show them, and nothing while every one has, or
957// nothing is open. The surfaces are read each time, not once at session.start:
958// a phone or an editor attaches to a terminal session, and leaves it. While
959// one is attached the terminal shows the counts twice, in its band and here.
960const flag = async ($: EngineInterface): Promise<void> => {
961 const counts = await countsHere($)
962 const surfaces = await $.session.surfaces()
963 const isBanded =
964 surfaces.length > 0 && surfaces.every(one => BANDED.includes(one))
965
966 // The engine leads the line with the plugin's name.
967 $.ui.status(counts.open === 0 || isBanded ? undefined : spell(counts, false))
968}
969
970const publish = async ($: EngineInterface): Promise<WipItem[]> => {
971 const all = fold()
972 const now = await $.clock.now()
973 await update($, items, () => all)
974
975 // Written only when an id appears or leaves, so a change to an item does
976 // not redraw every reply that links ids.
977 const every = all.map(one => one.id).sort()
978 knownIds = new Set(every)
979
980 if ((await read($, ids)).join(' ') !== every.join(' ')) {
981 await update($, ids, () => every)
982 }
983
984 const marked = freshOf(all, now)
985
986 if (!(await isFreshLevel($, marked))) {
987 await update($, fresh, () => marked)
988 }
989
990 const ages = agedOf(all, now)
991
992 if (!(await isAgedLevel($, ages))) {
993 await update($, aged, () => ages)
994 }
995
996 await flag($)
997
998 return all
999}
1000
1001// Writes one change as a file of its own. Its name carries 8 random
1002// characters, so no session's write lands on another's, and its time is past
1003// every record of the item this session has read, so it folds after them.
1004const append = async (
1005 $: EngineInterface,
1006 id: string,
1007 set: Partial<WipFields>,
1008 note = '',
1009): Promise<void> => {
1010 const seen = [...records.values()]
1011 .filter(held => held.change.id === id)
1012 .map(held => held.change.at + 1)
1013 const at = Math.max(await $.clock.now(), lastAt + 1, ...seen)
1014 lastAt = at
1015
1016 const name = `${id}.${at.toString(36)}.${token(8)}.json`
1017 const change: WipChange = {
1018 id,
1019 at,
1020 sessionId: await $.session.id(),
1021 set,
1022 note,
1023 }
1024 await $.fs.write(
1025 `${await folder($)}/${name}`,
1026 `${JSON.stringify(change, null, 2)}\n`,
1027 )
1028 records.set(name, { stamp: '', change })
1029 folded = undefined
1030 idle = 0
1031}
1032
1033// Brings `records` level with the folder, which every session writes to, and
1034// the state level with `records`; resolves whether anything changed. Without
1035// `isListing` the folder is taken as unchanged and the checks that read no
1036// file run alone. Runs inside the queue.
1037const level = async (
1038 $: EngineInterface,
1039 isListing = true,
1040): Promise<boolean> => {
1041 const dir = await folder($)
1042 const entries =
1043 isListing && (await $.fs.exists(dir)) ? await $.fs.list(dir) : []
1044 const names = new Set<string>()
1045 const before = new Set(fold().map(one => one.id))
1046 let hasChanged = !hasLoaded
1047
1048 for (const entry of entries) {
1049 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) {
1050 continue
1051 }
1052
1053 names.add(entry.name)
1054 const stamp = `${entry.mtimeMs}:${entry.size}`
1055
1056 if (
1057 records.get(entry.name)?.stamp === stamp ||
1058 unreadable.get(entry.name) === stamp
1059 ) {
1060 continue
1061 }
1062
1063 const change = await load($, `${dir}/${entry.name}`)
1064
1065 if (change === undefined) {
1066 // Half-written, or not a record: read again once the file changes.
1067 unreadable.set(entry.name, stamp)
1068 continue
1069 }
1070
1071 unreadable.delete(entry.name)
1072 records.set(entry.name, { stamp, change })
1073 folded = undefined
1074 hasChanged = true
1075 }
1076
1077 for (const name of [...records.keys()]) {
1078 if (isListing && !names.has(name)) {
1079 records.delete(name)
1080 folded = undefined
1081 hasChanged = true
1082 }
1083 }
1084
1085 if (isListing) {
1086 listed += 1
1087 }
1088
1089 if (await place($)) {
1090 hasChanged = true
1091 }
1092
1093 // The host holds the items for the session. A resumed session starts with
1094 // none, though the records here are level with the folder. The ids, one per
1095 // item, are the cheap value to count.
1096 if (!hasChanged && (await read($, ids)).length !== before.size) {
1097 hasChanged = true
1098 }
1099
1100 // An item stops being new with no record written: its mark, and the counts,
1101 // go on the first poll past its ten minutes. A claim's age moves the same
1102 // way, on the first poll past each whole day.
1103 if (!hasChanged) {
1104 const now = await $.clock.now()
1105
1106 if (
1107 !(await isFreshLevel($, freshOf(fold(), now))) ||
1108 !(await isAgedLevel($, agedOf(fold(), now)))
1109 ) {
1110 hasChanged = true
1111 }
1112 }
1113
1114 if (hasChanged) {
1115 const arrived = (await publish($)).filter(one => !before.has(one.id))
1116 const first = arrived[0]
1117
1118 if (hasLoaded && first !== undefined) {
1119 $.ui.toast(
1120 arrived.length === 1
1121 ? `WIP: ${fit(first.title, 60)} (${nameOf(first.project)})`
1122 : `WIP: ${arrived.length} new items from other sessions`,
1123 )
1124 }
1125 }
1126
1127 hasLoaded = true
1128
1129 return hasChanged
1130}
1131
1132// Brings the state level with the folder: lists it, unless a listing finished
1133// while the call waited its turn.
1134const sync = ($: EngineInterface, signal?: AbortSignal): Promise<void> =>
1135 inTurn(turn => level($, !turn.isListed).then(() => undefined), signal)
1136
1137// One poll: the folder listed, or while idle on every SLOW_EVERY-th poll
1138// alone, then the compaction. Runs inside the queue.
1139const poll = async ($: EngineInterface): Promise<void> => {
1140 skipped += 1
1141 const isListing = idle < IDLE_POLLS || skipped >= SLOW_EVERY
1142
1143 if (isListing) {
1144 skipped = 0
1145 }
1146
1147 const hasChanged = await level($, isListing)
1148
1149 if (isListing) {
1150 idle = hasChanged ? 0 : idle + 1
1151 await compact($)
1152 }
1153}
1154
1155// Removes files of the folder by name through the host: the engine's own file
1156// calls write and read, and never remove. `del` on Windows, `rm` elsewhere.
1157// Neither's exit code is trusted (`del` exits 0 whatever it did): each file is
1158// looked for afterwards, and one still there fails the call.
1159const remove = (
1160 $: EngineInterface,
1161 dir: string,
1162 names: string[],
1163): Promise<void> =>
1164 erase($, dir, names, name => {
1165 records.delete(name)
1166 unreadable.delete(name)
1167 swept.add(name)
1168 })
1169
1170const isWindowsAt = async ($: EngineInterface, dir: string): Promise<boolean> =>
1171 (await $.env.get('OS')) === 'Windows_NT' || /^[A-Za-z]:\//.test(dir)
1172
1173// Removes files of `dir` by name, as `remove` says, and calls `gone` with each
1174// one it finds removed.
1175const erase = async (
1176 $: EngineInterface,
1177 dir: string,
1178 names: string[],
1179 gone: (name: string) => void,
1180): Promise<void> => {
1181 const isWindows = await isWindowsAt($, dir)
1182
1183 for (let from = 0; from < names.length; from += REMOVE_MAX) {
1184 const batch = names.slice(from, from + REMOVE_MAX)
1185 const paths = batch.map(name => `${dir}/${name}`)
1186 const ran = await $.process.run(
1187 isWindows
1188 ? [
1189 'cmd',
1190 '/c',
1191 'del',
1192 '/f',
1193 '/q',
1194 ...paths.map(path => path.replace(/\//g, '\\')),
1195 ]
1196 : ['rm', '-f', ...paths],
1197 )
1198 const left: string[] = []
1199
1200 for (const [index, path] of paths.entries()) {types/index.d.ts 115 lines1export type WipCategory = 'decision' | 'defect' | 'issue' | 'task'
2
3export type WipPriority = 'critical' | 'high' | 'medium' | 'low'
4
5export type WipStatus = 'open' | 'in_progress' | 'done' | 'dismissed'
6
7/** One choice of a decision item; `detail` is '' when the label says it all. */
8export type WipOption = { label: string; detail: string }
9
10/** Markdown appended to an item after it was recorded, and when. */
11export type WipNote = { at: number; text: string }
12
13/** One item, as its change records under `<config>/wip/items/` fold. */
14export type WipItem = {
15 id: string
16 title: string
17 category: WipCategory
18 priority: WipPriority
19 status: WipStatus
20 /** Markdown, written to stand alone: what, where, why it matters. */
21 detail: string
22 options: WipOption[]
23 /** What Claude would do; '' when it recorded none. */
24 recommendation: string
25 /** What was decided or how it ended; '' until then. */
26 resolution: string
27 notes: WipNote[]
28 /** The project root of the session that recorded it, forward slashes. */
29 project: string
30 /** The session that recorded it. */
31 sessionId: string
32 /** When its first and its latest change record were written. */
33 createdAt: number
34 updatedAt: number
35 /**
36 * When the record that put it in progress was written, and by which session;
37 * 0 and '' when it is not in progress. Folded from the records, never stored.
38 */
39 workingSince: number
40 workingIn: string
41}
42
43/** The fields of an item a change record may set. */
44export type WipFields = Pick<
45 WipItem,
46 | 'title'
47 | 'category'
48 | 'priority'
49 | 'status'
50 | 'detail'
51 | 'options'
52 | 'recommendation'
53 | 'resolution'
54 | 'project'
55>
56
57/**
58 * One change to one item: a file `<id>.<time>.<token>.json`, written once and
59 * never rewritten, so two sessions changing an item never overwrite each other.
60 * An item is its records in `at` order, each setting the fields it names.
61 */
62export type WipChange = {
63 id: string
64 at: number
65 sessionId: string
66 set: Partial<WipFields>
67 /** Appended to the item's notes; '' for none. */
68 note: string
69 /**
70 * On a record that compacts a closed item: the files it replaces, which
71 * apply no more while it stands; the item's notes whole, in place of those
72 * the replaced records appended; and when the item was first recorded. Its
73 * `at` is the last replaced record's, so a change made since folds after
74 * it. Absent on a plain change.
75 */
76 folds?: string[]
77 notes?: WipNote[]
78 createdAt?: number
79}
80
81export type WipView = {
82 /** The item open in the detail view, or null for the list. */
83 selected: string | null
84 scope: 'project' | 'all'
85 showClosed: boolean
86 /** The categories whose section the list shows folded to its heading. */
87 collapsed: WipCategory[]
88}
89
90declare module 'claude-code' {
91 interface PluginState {
92 wip: {
93 items: WipItem[]
94 view: WipView
95 project: string
96 /**
97 * The ids of the items marked new: open, and recorded in the last ten
98 * minutes. Held here so the pane redraws when one ages out.
99 */
100 fresh: string[]
101 /**
102 * How many whole days each item in progress has been so, by id; an item
103 * under one day has no entry. Held here so the pane redraws as a claim
104 * ages.
105 */
106 aged: Record<string, number>
107 /**
108 * The ids of every item, sorted. Held apart from `items` so the replies
109 * that link ids redraw only when an id appears or leaves.
110 */
111 ids: string[]
112 }
113 }
114}
115