SLOPSHOPPER

next-tasks

After longer turns, suggests 3 next tasks for the project with a Do it button each, and a Dismiss.

newbandguardcommandpromptmodel
v0.1.0no licenseupdated 2026-10-09eshin087/claude-code-mods/next-tasks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-tasks
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /next ⎿ next-tasks: Working out 3 next tasks from the whole conversation… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Next Tasks

What it does: after a longer piece of work, suggests the 3 best next tasks for the project, ready to run with one click.

Where you see it: after any turn of 1 minute or more, a small card opens above the prompt:

Next                                   ✕
[1] Add a seam test for tiles
[2] Write an ADR: tiles in repo vs R2
[3] Profile far-tile blits
  • Click the boxed number or the task to do it: its full instruction goes into your prompt box, or is sent where the app has no box it can fill.
  • ✕ dismisses the suggestions.
  • It stays until you pick a task, click ✕ or send your next prompt. Suggestions still on their way when you send a prompt are dropped: they were for the turn before.
  • /next asks over the whole conversation instead of the last turn: smarter, any time.

How it works:

  • Automatic: Haiku reads a short summary of the turn (what you asked, files changed, commands run, the final report) and replies with three tasks: one to continue the work, one quality step (tests, review, docs, refactor), and one forward-looking step.
  • /next: forks your session and asks the same question with the full context. The fork is served from the session's prompt cache.

Cost:

  • Automatic: one small Haiku call per long turn, about 2-3k tokens in and 300 out (well under a cent).
  • /next: re-reads your conversation from cache on the session's model. Cheap per token, but a big context adds up, so it's on demand only.

Data saved: none.

Settings: MIN_TURN_MS, MODEL and AUTO_HIDE_MS at the top of hooks/register.tsx.

Source 2 files
hooks/register.tsx 196 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { NextCard, NextItem } from '../types'
5
6// After a main turn of a minute or more, Haiku reads a short summary of it
7// (what you asked, files changed, commands run, the final report) and
8// suggests three next tasks. /next asks the same question over the whole
9// conversation instead (a fork of the session, served from its prompt cache).
10// The card stays above the prompt until you pick a task, click ✕ or send your
11// next prompt.
12
13const MIN_TURN_MS = 60_000
14const MODEL = 'haiku'
15
16const card = atom({ plugin: 'next-tasks', key: 'card' } as const, null)
17
18type TurnLog = { turnId: string; prompt: string; files: Set<string>; commands: string[] }
19
20let turn: TurnLog | null = null
21// Bumped by each prompt you send: a request still out when you send one is
22// stale, and its answer is dropped.
23let asked = 0
24
25const INSTRUCTIONS = [
26  'Suggest the 3 best next tasks for this software project, given the work just done.',
27  'Be concrete and specific to this project; never suggest something already done.',
28  'Mix them: 1) continue or finish the current work, 2) a quality step (tests, review, docs, refactor, performance),',
29  '3) a forward-looking step (next feature, risk to remove, or something to verify).',
30  'Reply with JSON only, no prose: [{"title": "...", "why": "...", "prompt": "..."}].',
31  'title: imperative, at most 60 characters. why: at most 90 characters, the payoff.',
32  'prompt: the exact instruction to give a coding agent to do it, 1-3 sentences.',
33].join(' ')
34
35const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
36
37const projectOf = (dir: string) => {
38  const main = dir.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/i, '')
39  return main.split(/[\\/]/).filter(Boolean).pop() ?? main
40}
41
42const parseItems = (text: string): NextItem[] => {
43  const start = text.indexOf('[')
44  const end = text.lastIndexOf(']')
45  if (start < 0 || end <= start) return []
46  try {
47    const raw = JSON.parse(text.slice(start, end + 1)) as Partial<NextItem>[]
48    return raw
49      .filter(x => typeof x?.title === 'string' && typeof x?.prompt === 'string')
50      .slice(0, 3)
51      .map(x => ({ title: clip(x.title!.trim(), 60), why: clip((x.why ?? '').trim(), 110), prompt: x.prompt!.trim() }))
52  } catch {
53    return []
54  }
55}
56
57const suggestFromSummary = async ($: EngineInterface, log: TurnLog, answer: string) => {
58  const mine = asked
59  const project = projectOf(await $.session.cwd())
60  await update($, card, () => ({ project, items: [], isLoading: true, source: 'auto' as const, error: null, isOpen: false }))
61  const files = [...log.files].slice(0, 25)
62  const prompt = [
63    `Project: ${project}`,
64    `The person asked: ${clip(log.prompt, 1500)}`,
65    files.length > 0 ? `Files changed: ${files.join(', ')}` : 'Files changed: none',
66    log.commands.length > 0 ? `Commands run: ${log.commands.slice(-12).join(' | ')}` : '',
67    `The agent's final report:\n${clip(answer, 3500)}`,
68  ]
69    .filter(Boolean)
70    .join('\n\n')
71  const r = await $.model.complete({ model: MODEL, system: INSTRUCTIONS, prompt, maxTokens: 700, timeoutMs: 45_000 })
72  const items = r.isAnswered ? parseItems(r.text) : []
73  void logAttempt($, { source: 'auto', isAnswered: r.isAnswered, reason: r.isAnswered ? null : r.reason, items: items.length, head: r.isAnswered ? r.text.slice(0, 200) : null }).catch(() => undefined)
74  if (mine !== asked) return
75  await update($, card, () =>
76    items.length > 0
77      ? { project, items, isLoading: false, source: 'auto' as const, error: null, isOpen: true }
78      : null,
79  )
80}
81
82const suggestFromConversation = async ($: EngineInterface) => {
83  const mine = asked
84  const project = projectOf(await $.session.cwd())
85  await update($, card, () => ({ project, items: [], isLoading: true, source: 'full' as const, error: null, isOpen: false }))
86  const r = await $.model.fork({ prompt: INSTRUCTIONS })
87  const items = r.isAnswered ? parseItems(r.text) : []
88  void logAttempt($, { source: 'full', isAnswered: r.isAnswered, reason: r.isAnswered ? null : r.reason, items: items.length, head: r.isAnswered ? r.text.slice(0, 200) : null }).catch(() => undefined)
89  if (mine !== asked) return
90  const error = r.isAnswered ? (items.length === 0 ? 'the reply had no usable suggestions' : null) : `no answer (${r.reason})`
91  await update($, card, () => ({ project, items, isLoading: false, source: 'full' as const, error, isOpen: true }))
92}
93
94// The last attempt's outcome, in mods-data/next-tasks/last.json, for when no card shows.
95const logAttempt = async ($: EngineInterface, entry: Record<string, unknown>) => {
96  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
97  const at = new Date(await $.clock.now()).toISOString()
98  await $.fs.write(`${home.replace(/\\/g, '/')}/.claude/mods-data/next-tasks/last.json`, JSON.stringify({ at, ...entry }, null, 2))
99}
100
101const take = async ($: EngineInterface, item: NextItem) => {
102  await update($, card, () => null)
103  // Put it in the prompt box to review; where the app has no fillable box, send it.
104  const filled = await $.prompt.fill({ text: item.prompt }).catch(() => ({ isFilled: false }))
105  if (!filled.isFilled) await $.prompt.submit({ text: item.prompt, asUser: true })
106}
107
108export const register: Register = on => {
109  on('session.start', async ($, e, next) => {
110    await $.command.register({ name: 'next', description: 'Suggest 3 next tasks from the whole conversation' })
111    // A load (or reload) drops whatever request the previous code had in flight;
112    // a card still "loading" would wait forever, so it goes.
113    await update($, card, c => (c?.isLoading ? null : c))
114    return next(e)
115  })
116
117  on('prompt.submit', async ($, e, next) => {
118    // Your next prompt moves on: the card goes, and so does a request still out.
119    if (e.origin.kind !== 'plugin') {
120      asked += 1
121      if ((await read($, card)) !== null) await update($, card, () => null)
122    }
123    return next(e)
124  })
125
126  on('turn.start', ($, e, next) => {
127    turn = { turnId: e.turnId, prompt: e.text, files: new Set(), commands: [] }
128    return next(e)
129  })
130
131  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell'] }, ($, e, next) => {
132    if (turn && e.agentId === undefined) {
133      if (e.tool === 'Edit' || e.tool === 'Write') turn.files.add(e.file_path.replace(/\\/g, '/').split('/').slice(-3).join('/'))
134      else if (e.tool === 'NotebookEdit') turn.files.add(e.notebook_path.replace(/\\/g, '/').split('/').pop() ?? '')
135      else if (e.tool === 'Bash' || e.tool === 'PowerShell') turn.commands.push(clip(e.command.split('\n')[0] ?? '', 80))
136    }
137    return next(e)
138  })
139
140  on('turn.complete', async ($, e, next) => {
141    const done = await next(e)
142    const log = turn
143    if (e.agentId !== undefined) return done
144    turn = null
145    if (!log || log.turnId !== e.turnId || e.reason !== 'answer' || e.durationMs < MIN_TURN_MS || log.prompt.trim() === '') return done
146    // Off the turn's own dispatch, so the reply is never held up.
147    $.clock.after(10, () => void suggestFromSummary($, log, e.answer).catch(() => update($, card, () => null)))
148    return done
149  })
150
151  on('command.run', { command: 'next' }, async $ => {
152    $.clock.after(10, () => void suggestFromConversation($).catch(() => update($, card, () => null)))
153    return { text: 'Working out 3 next tasks from the whole conversation…' }
154  })
155
156  // /mods-review: work out suggestions when there are none yet.
157  on('state.set', { plugin: 'mod-hub', key: 'signal' }, async ($, e, next) => {
158    const done = await next(e)
159    const sig = e.value as { target?: string; action?: string } | null
160    if (done.value?.isSet === true && sig?.action === 'review' && !(await read($, card))) {
161      $.clock.after(10, () => void suggestFromConversation($).catch(() => update($, card, () => null)))
162    }
163    return done
164  })
165
166  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
167    if (e.props.hasSurvey) return next(e)
168    const c = await read($, card)
169    if (!c || !c.isOpen) return next(e)
170    const below = await next(e)
171    const { Box, Text, Button } = $.ui.resolve(e)
172    return (
173      <Box flexDirection="column">
174        <Box key="next:card" flexDirection="column" marginBottom={1}>
175          <Box flexDirection="row" gap={1}>
176            <Text bold color="suggestion">
177              Next
178            </Text>
179            <Box flexGrow={1} />
180            <Button key="dismiss" label="✕" plain onPress={() => update($, card, () => null)} />
181          </Box>
182          {c.error !== null && <Text color="warning">Could not suggest: {c.error}</Text>}
183          {c.items.map((item, i) => (
184            // A bordered number and the task, one line; either one does the task.
185            <Box key={`row:${i}`} flexDirection="row" gap={1} alignItems="center">
186              <Button key={`num:${i}`} label={String(i + 1)} onPress={() => take($, item)} />
187              <Button key={`do:${i}`} label={item.title} plain onPress={() => take($, item)} />
188            </Box>
189          ))}
190        </Box>
191        {below}
192      </Box>
193    )
194  })
195}
196
types/index.d.ts 18 lines
1export type NextItem = { title: string; why: string; prompt: string }
2
3export type NextCard = {
4  project: string
5  items: NextItem[]
6  isLoading: boolean
7  source: 'auto' | 'full'
8  error: string | null
9  /** Expanded above the dock; collapsed it is just the dock's Next badge. */
10  isOpen: boolean
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'next-tasks': { card: NextCard | null }
16  }
17}
18