SLOPSHOPPER

left-undone

A sidebar listing todos Claude left undone; press a letter to flag one, then send Claude back to finish it.

newpaneguardcommandtoastmodel
v0.1.0no licenseupdated 2026-10-07adammhal/claude-mods/left-undone
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · left-undone
│ ┃ Left undone ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing yet: todos and Claude's replies show │ ┃ here. ⏺ Read(src/auth.ts) │ ┃ [ Export to Markdown ] ⎿ Read 6 lines │ ┃ [ Import latest export ] ⏺ Update(src/auth.ts) │ ┃ letter = flag, 1 = send, 2 = done, 3 = ⎿ Added 2 lines, removed 1 line │ ┃ export, 4 = import ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /left-undone │ ⎿ left-undone: Left undone pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Left undone
Nothing yet: todos and Claude's replies show here. [ Export to Markdown ] [ Import latest export ] letter = flag, 1 = send, 2 = done, 3 = export, 4 = import
README

claude-mods

Claude Code mods.

left-undone

A sidebar that tracks what Claude said it would do and has not done yet. After each turn a small model audits Claude's reply: it adds new promises and closes the ones that were kept. Unfinished TodoWrite items show too.

Keys in the pane: a letter flags an entry, 1 sends the flagged ones back to Claude as "You left this undone: ...", 2 marks them done, 3 exports the list to ~/.claude/left-undone/latest.md, 4 imports it. /left-undone-export and /left-undone-import [path] do the same. The list is per session.

Install, typed at a Claude Code prompt:

/plugin install left-undone --marketplace <owner>/<repo>

Answer y to add the marketplace, then pick the user scope to have it in every session.

approve-blocked (experimental, untested)

When auto mode's classifier blocks a tool call, this retries the identical call up to three times so Claude Code's own permission prompt appears after three blocks in a row. It retries calls the classifier denied, so read its source before installing. Whether the retries count toward Claude Code's threshold is not confirmed.

/plugin install approve-blocked --marketplace <owner>/<repo>
Source 2 files
hooks/register.tsx 249 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import type { Undone } from '../types'
5
6const PANE = 'left-undone'
7const LETTERS = 'abcdefghijklmnopqrstuvwxyz'
8const items = atom({ plugin: 'left-undone', key: 'items' } as const, [])
9
10// Promises live in the session's own state: each agent keeps its own list.
11// Export and import carry them across a restart.
12const change = ($: Engine, fn: (old: Undone[]) => Undone[]) => update($, items, fn)
13
14export const toMarkdown = (list: Undone[], when: string): string =>
15  `# Left undone\n\nExported ${when}\n\n${list.map(i => `- [ ] ${i.text}`).join('\n')}\n`
16
17export const fromMarkdown = (text: string): string[] =>
18  text
19    .split('\n')
20    .map(line => line.match(/^\s*[-*]\s+\[[ xX]\]\s+(.+?)\s*$/)?.[1])
21    .filter((t): t is string => t !== undefined && t.length > 0)
22    .map(t => t.slice(0, 240))
23
24const DIR = '.claude/left-undone'
25const LATEST = 'latest.md'
26const home = async ($: Engine) => (await $.process.run(['printenv', 'HOME'])).stdout.trim()
27
28const exportList = async ($: Engine) => {
29  const list = (await read($, items)).filter(i => i.text.length > 0)
30  if (list.length === 0) return 'Nothing to export.'
31  const dir = `${await home($)}/${DIR}`
32  await $.process.run(['mkdir', '-p', dir])
33  const now = new Date()
34  const stamp = now.toISOString().slice(0, 16).replace('T', ' ')
35  const text = toMarkdown(list, stamp)
36  await $.fs.write(`${dir}/${LATEST}`, text)
37  await $.fs.write(`${dir}/${stamp.replace(/[ :]/g, '-')}.md`, text)
38
39  return `Exported ${list.length} to ~/${DIR}/${LATEST}`
40}
41
42const importList = async ($: Engine, path?: string) => {
43  const file = path?.trim() || `${await home($)}/${DIR}/${LATEST}`
44  if (!(await $.fs.exists(file))) return `No export found at ${file}`
45  const added = fromMarkdown(await $.fs.read(file))
46  await change($, old => [
47    ...old,
48    ...[...new Set(added)]
49      .filter(text => !old.some(o => o.text === text))
50      .map(text => ({ text, kind: 'open' as const, isFlagged: false })),
51  ].slice(-MAX_OPEN))
52
53  return `Imported ${added.length} from ${file}`
54}
55
56const MAX_OPEN = 300
57const RULES = `You audit an AI assistant's promises. You are given PROMISES it made earlier that are still open, and its latest REPLY, both inside tags. Answer with a single JSON object and nothing else: {"done": [numbers], "add": ["..."]}
58- "done": numbers of open promises that the reply shows were now carried out, or are clearly dropped or no longer applicable.
59- "add": new promises the ASSISTANT made in the reply and has NOT carried out by the end of it: "I'll do X", "next I will Y", "I can add Z if you want" (an offer), "still to do on my side". At most 5, each under 140 characters, phrased as the assistant's own task.
60Never add things the USER has to do or decide, questions for the user, descriptions of work already done, or explanations. If there is nothing, answer {"done": [], "add": []}. Never ask for input and never explain: the data is already provided.`
61
62const ask = (current: string[], reply: string) => `<open_promises>
63${current.length ? current.map((t, i) => `${i + 1}. ${t}`).join('\n') : '(none)'}
64</open_promises>
65
66<reply>
67${reply}
68</reply>
69
70Output the JSON object now.`
71
72export const parseAudit = (reply: string): { done: number[]; add: string[] } => {
73  const from = reply.indexOf('{')
74  const to = reply.lastIndexOf('}')
75  if (from < 0 || to < from) return { done: [], add: [] }
76  try {
77    const r: { done?: unknown; add?: unknown } = JSON.parse(reply.slice(from, to + 1))
78    const done = Array.isArray(r.done) ? r.done.filter((x): x is number => Number.isInteger(x)) : []
79    const add = Array.isArray(r.add)
80      ? r.add.filter((x): x is string => typeof x === 'string' && x.trim().length > 0).map(x => x.trim().slice(0, 240)).slice(0, 5)
81      : []
82    return { done, add }
83  } catch {
84    return { done: [], add: [] }
85  }
86}
87
88export const register: Register = on => {
89  on('session.start', async ($, e, next) => {
90    await $.command.register({
91      name: 'left-undone',
92      description: 'Show the list of todos Claude left undone',
93    })
94    await $.command.register({
95      name: 'left-undone-export',
96      description: 'Export the left-undone list to a Markdown file',
97    })
98    await $.command.register({
99      name: 'left-undone-import',
100      description: 'Import a left-undone Markdown export (default: the latest)',
101    })
102    void $.ui.open({ id: PANE, title: 'Left undone' })
103
104    return next(e)
105  })
106
107  on('command.run', { command: 'left-undone' }, async $ => {
108    await $.ui.open({ id: PANE, title: 'Left undone' })
109
110    return { text: 'Left undone pane opened.' }
111  })
112
113  on('command.run', { command: 'left-undone-export' }, async $ => ({ text: await exportList($) }))
114
115  on('command.run', { command: 'left-undone-import' }, async ($, e) => ({
116    text: await importList($, e.args),
117  }))
118
119  // Every TodoWrite replaces the todo entries; whatever is not completed is
120  // "undone". Flags survive a rewrite when the todo's text is unchanged.
121  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
122    const ran = await next(e)
123    await change($, old => [
124      ...e.todos
125        .filter(t => t.status !== 'completed')
126        .map(t => ({
127          text: t.content,
128          kind: 'todo' as const,
129          isFlagged: old.some(o => o.kind === 'todo' && o.text === t.content && o.isFlagged),
130        })),
131      ...old.filter(o => o.kind === 'open'),
132    ])
133
134    return ran
135  })
136
137  // After each turn a small model reads the answer against the promises still
138  // open: it closes the ones the answer kept and adds the new ones it made.
139  on('turn.complete', async ($, e, next) => {
140    const done = await next(e)
141    if (e.agentId === undefined && e.reason === 'answer' && done.text.trim().length > 40) {
142      const open = (await read($, items)).filter(i => i.kind === 'open')
143      const r = await $.model.complete({
144        model: 'haiku',
145        system: RULES,
146        prompt: ask(open.map(i => i.text), done.text.slice(0, 6000)),
147        effort: 'low',
148        maxTokens: 500,
149        timeoutMs: 10000,
150      })
151      const audit = r.isAnswered ? parseAudit(r.text) : { done: [], add: [] }
152      if (!r.isAnswered) $.ui.toast(`left-undone: model call failed (${r.reason})`)
153      const closed = new Set(audit.done.map(n => open[n - 1]?.text))
154      if (closed.size > 0 || audit.add.length > 0) {
155        await change($, old => [
156          ...old.filter(o => !(o.kind === 'open' && closed.has(o.text))),
157          ...audit.add
158            .filter(text => !old.some(o => o.text === text))
159            .map(text => ({ text, kind: 'open' as const, isFlagged: false })),
160        ].slice(-MAX_OPEN))
161      }
162    }
163
164    return done
165  })
166
167  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
168    const { Box, Text, Button } = $.ui.resolve(e)
169    const all: Undone[] = await read($, items)
170    const todos = all.filter(i => i.kind === 'todo').slice(0, LETTERS.length)
171    // Flagged lines stay pinned; the rest of the letters go to the newest lines.
172    const room = LETTERS.length - todos.length
173    const saidAll = all.filter(i => i.kind === 'open')
174    const pinned = saidAll.filter(i => i.isFlagged).slice(-room)
175    const recent = saidAll.filter(i => !i.isFlagged).slice(-(room - pinned.length))
176    const said = saidAll.filter(i => pinned.includes(i) || recent.includes(i))
177    const list = [...todos, ...said]
178    const flagged = list.filter(i => i.isFlagged)
179
180    const toggle = (target: Undone) =>
181      change($, cur =>
182        cur.map(one =>
183          one.kind === target.kind && one.text === target.text
184            ? { ...one, isFlagged: !one.isFlagged }
185            : one,
186        ),
187      )
188
189    const send = async () => {
190      if (flagged.length === 0) return
191      const text = flagged.map(i => `You left this undone: ${i.text}`).join('\n')
192      await change($, all => all.map(one => ({ ...one, isFlagged: false })))
193      await $.prompt.submit({ text, asUser: true })
194    }
195
196    const markDone = async () => {
197      if (flagged.length === 0) return
198      await change($, cur =>
199        cur.filter(one => !flagged.some(f => f.kind === one.kind && f.text === one.text)),
200      )
201    }
202
203    return (
204      <Box flexDirection="column">
205        {list.length === 0 && <Text dimColor>Nothing yet: todos and Claude's replies show here.</Text>}
206        {list.map((item, i) => (
207          <Button
208            key={`item-${i}`}
209            plain
210            hotkey={LETTERS[i]}
211            label={`${item.isFlagged ? '[x]' : '[ ]'} ${item.kind === 'todo' ? '(todo) ' : ''}${item.text}`}
212            onPress={() => void toggle(item)}
213          />
214        ))}
215        {flagged.length > 0 && (
216          <Button
217            key="done"
218            hotkey="2"
219            label={`Mark ${flagged.length} flagged as done`}
220            onPress={() => void markDone()}
221          />
222        )}
223        {flagged.length > 0 && (
224          <Button
225            key="send"
226            variant="primary"
227            hotkey="1"
228            label={`Send ${flagged.length} flagged to Claude`}
229            onPress={() => void send()}
230          />
231        )}
232        <Button
233          key="export"
234          hotkey="3"
235          label="Export to Markdown"
236          onPress={() => void exportList($).then(t => $.ui.toast(t))}
237        />
238        <Button
239          key="import"
240          hotkey="4"
241          label="Import latest export"
242          onPress={() => void importList($).then(t => $.ui.toast(t))}
243        />
244        <Text dimColor>letter = flag, 1 = send, 2 = done, 3 = export, 4 = import</Text>
245      </Box>
246    )
247  })
248}
249
types/index.d.ts 8 lines
1export type Undone = { text: string; kind: 'todo' | 'open'; isFlagged: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'left-undone': { items: Undone[] }
6  }
7}
8