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

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
/next asks over the whole conversation instead of the last turn: smarter, any time.How it works:
/next: forks your session and asks the same question with the full context. The fork is served from the session's prompt cache.Cost:
/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.
hooks/register.tsx 196 lines1import { 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}
196types/index.d.ts 18 lines1export 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