SLOPSHOPPER

WIP

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…

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wip
│ ┃ WIP ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ 0 open ⏺ Read(src/auth.ts) │ ┃ [ All projects ] [ Show closed ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Nothing open. Claude records defects, ⎿ Added 2 lines, removed 1 line │ ┃ issues, tasks and decisions here as work ⏺ Bash(bun test) │ ┃ turns them up. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wip │ ⎿ wip: WIP pane opened: 0 open in app. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · WIP
app 0 open [ All projects ] [ Show closed ] Nothing open. Claude records defects, issues, tasks and decisions here as work turns them up.
README

wip

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.

Install

/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.

What you get

PieceWhat it does
/wipOpens and focuses the pane
WIP paneOpen 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 viewThe item's full context, Claude's recommendation, its options, and the actions below
Row above the promptwip: 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 repliesOn the desktop and in the fullscreen terminal, an item's id is a link that opens the pane on that item
mcp__wip__addClaude 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__updateClaude 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__listClaude 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.

What is being worked on, and what is new

Two marks stand before an item's title in the pane:

MarkMeans
»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.

When work on an item stops

An item in progress goes back to open in three ways, so a » does not outlive the work:

  • The session ends. A session that exits, is cleared or is replaced by a resumed one gives back every item it has in progress, each with the note Released: the session working on it ended. Items another session has since taken up are left alone.
  • Claude stops. Claude is told to set an item back to open, with a note saying what is left, when it stops work on it without finishing.
  • You release it. A session that crashes writes nothing, so its items stay in progress. An item in progress for a day or more says so: 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.

Acting on an item

Open an item in the pane, then:

  • Pick an option on a decision, or Accept recommendation.
  • Fix now on a defect, issue or task.
  • Type into Direct to tell Claude what to do about it in your own words.
  • Mark done, Dismiss, Reopen, or change its priority.
  • Release an item in progress, to set it back to open.

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.

Ids in the transcript

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.

One WIP list across sessions

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:

LimitOver it
50 items in one add or update callThe call is refused whole
Title: 200 characters, counted with its whitespace collapsedadd 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 labeladd skips that item and records the rest
Detail: 20,000 charactersadd skips that item and records the rest
Note: 10,000 charactersupdate 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.

Requirements and limits

  • Claude Code with mod support (function hooks). Built and tested against 2.1.289; the API is early access and may change between releases.
  • Claude Code only. The plugin is a hooks module (hooks/register.tsx), which other agents that read this marketplace do not run.
  • Where the pane sits depends on the terminal's layout:
  • Fullscreen layout (the default): the pane is docked beside the transcript from 110 columns. It opens by itself at session start in a terminal at least 144 columns wide. Below that, run /wip.
  • Main screen (under tmux by default, or with 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.
  • The detail view has no keyboard shortcuts: use Tab, Enter and the mouse.

Developing

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.

PathHolds
hooks/register.tsxThe hooks module: tools, command, system-prompt section, storage, the pane and the links to items in replies
types/index.d.tsThe item and change-record shapes, and the $.state contract
tests/wip.test.tsBehaviour tests over an in-memory folder, on the terminal, desktop, VS Code and mobile surfaces
Source 2 files
hooks/register.tsx 2799 lines
1import { 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 lines
1export 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