Next-prompt builder after real work: 28-40 customer-focused options in eight color-coded directions, ranked 80/20, with ready-made packages to take whole or…

Two small mods for Claude Code that make it easier to see what Claude is doing and to decide what to do next.
| Mod | What you get |
|---|---|
| recap | After Claude finishes a real piece of work, a side pane lists 14–20 possible next moves, color-coded by direction and ranked 80/20. Pick a few and it writes your next prompt for you. |
| turn-status | A quiet one-line band above the prompt while Claude works: what it is doing right now, how long it has been going, task progress and failures. It hides itself when Claude is idle. |
In a terminal, start claude and paste the line for the mod you want:
/plugin install recap --marketplace lokki007/claude-code-mods
/plugin install turn-status --marketplace lokki007/claude-code-mods
Claude Code asks whether to add the marketplace: answer y, then press Enter to install it for your user. The mod works straight away in that terminal session, and in every new chat after that, including the desktop app's Code tab. Restart chats that were already open.
The desktop app's Code tab can't run
/plugin installitself; install once from a terminal and the desktop app picks it up.
Needs a recent Claude Code (one that supports hooks-module plugins, 2.1.29x or later).

The ✨ Next pane in the Claude desktop app, beside the chat: 17 options, filterable by direction, ranked 80/20, each with + Pick and ☆ Save.
When a turn edits files or digs through research, the ✨ Next pane opens beside the chat:
| Direction | Direction | ||
|---|---|---|---|
| 🐞 | Fix | 💰 | Money |
| 🧹 | Tech debt | 📣 | Growth & leads |
| ✨ | Features | 🚀 | Ship & ops |
| 🎨 | UI & design | 🧪 | Wild ideas |
Each option has a colored bar per direction it serves, a rough size (quick, medium, big), and whether it needs your ideas (💬) or Claude can decide it alone (🤖).
/recap opens it).It also keeps track for you:
/recap makes one anyway./recap in any chat of the same project shows it straight away; ↻ New options brews fresh ones for that chat./recap diary shows where it is.~/.claude/recap/log.jsonl (/recap log).Commands: /recap opens the pane, /recap again brews fresh options, /recap diary and /recap log show those files. Newer Claude versions have a built-in /recap; there the same commands start with /next instead (/next, /next again, and so on), and the mod tells you once.
What's next for it: a daily digest across all your projects (a sketch).
How it works: the options come from a short follow-up question to Claude about the conversation that just happened, so they can point at real code. It costs a little extra per qualifying turn (most of the conversation is served from the prompt cache); if that fails it falls back to the small, fast model.
While Claude works, one row above the prompt shows:
⟳ Building the status band Running npm test 1:46 ▬▬▬▬▬ 1/5 ● 1 failed
When the turn ends it shows "✓ Done in 1:46" for a few seconds and then gets out of the way. In the desktop app it is drawn as a small animated graphic; in a terminal, as text.
Each mod is a Claude Code hooks-module plugin under plugins/<mod>: hooks/register.tsx is the entry point, types/index.d.ts declares the state it keeps, and hooks/*.test.ts are its tests. Run them with:
claude plugin validate plugins/recap
claude plugin test plugins/recap
To work on a mod locally, add this folder as a marketplace once (claude plugin marketplace add <path to this repo>), install from it, then release changes with:
node scripts/release.mjs recap [patch|minor|major] [--publish]
It bumps the version, validates and tests, commits the release, refreshes the installed copy, prunes old cached copies, and with --publish pushes here.
MIT
hooks/register.tsx 693 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Autonomy, BacklogItem, Deploy, Interview, Job, Lane, Learned, Option, Package, Picks, Prefs, Recap, View } from '../types'
5import { owlSvg } from './owl'
6import type { Instance, ProjectLast } from './journal'
7import {
8 appendLine, dataDir, diaryEntry, diaryFile, diaryHeader, duplicateWarning, otherCopies, projectKey, reminderFor,
9 skipReason, withInstance,
10} from './journal'
11import type { HistoryRow, Takes, TurnLog } from './recap'
12import {
13 AUTONOMY, DEPLOY, INTERVIEW_DESCRIPTION, INTERVIEW_SCHEMA, LANES, LANE_KEYS, SYSTEM, answer, answersText,
14 bestInLane, composePrompt, digestFrom, favourites, forkPrompt, isSaved, isWorthRecap, learnFrom, logTool, newInterview,
15 md, packageOf, parseQuestions, parseTakes, promptFor, ranked, savePackage, toggleSaved, topPicks, willInterview,
16} from './recap'
17
18const PANE = 'recap'
19const TITLE = '✨ Next'
20const PANE_ROWS = 30
21const TOOL = 'mcp__recap__interview'
22const DEFAULT_PREFS: Prefs = { size: 2, deploy: 'local', autonomy: 'auto' }
23
24const current = atom({ plugin: 'recap', key: 'current' } as const, null)
25const lastJob = atom({ plugin: 'recap', key: 'job' } as const, null)
26const picksAtom = atom({ plugin: 'recap', key: 'picks' } as const, null)
27const prefsAtom = atom({ plugin: 'recap', key: 'prefs' } as const, DEFAULT_PREFS)
28const backlogAtom = atom({ plugin: 'recap', key: 'backlog' } as const, [])
29const learnedAtom = atom({ plugin: 'recap', key: 'learned' } as const, {})
30const interviewAtom = atom({ plugin: 'recap', key: 'interview' } as const, null)
31const viewAtom = atom({ plugin: 'recap', key: 'view' } as const, 'options')
32const warningAtom = atom({ plugin: 'recap', key: 'warning' } as const, null)
33
34const EMPTY_LOG: TurnLog = { ask: '', steps: [], edits: 0, research: 0, tools: 0 }
35const VERDICT = { done: '💚', in_progress: '🟡', blocked: '🟥' } as const
36const EFFORT = { S: 'quick', M: 'medium', L: 'big' } as const
37/** Heading level per text size: the only way to draw larger text in the pane. */
38const TITLE_H = { 1: '', 2: '#### ', 3: '### ' } as const
39const HEAD_H = { 1: '#### ', 2: '### ', 3: '## ' } as const
40const ORDINAL = '②③④⑤⑥⑦⑧⑨'
41
42/** The store key of this project's backlog: one list per folder the chat runs in. */
43const backlogKey = (cwd: string) => `backlog:${cwd.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()}`
44
45const freshPicks = (turnId: string): Picks => ({ turnId, chosen: [], lane: 'all' })
46
47/** Used when Claude refuses /recap (newer versions have a built-in one). */
48const FALLBACK_COMMAND = 'next'
49
50/** This chat's paths and project, filled in at session start. */
51const here = { dir: '', cwd: '', project: '', command: 'recap' }
52
53/** Appends one line to the debug log: every recap attempt, whether it worked, and why. Never throws. */
54async function note($: EngineInterface, event: string, detail: Record<string, unknown> = {}) {
55 if (!here.dir) return
56 try {
57 const path = `${here.dir}/log.jsonl`
58 const existing = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
59 const line = JSON.stringify({ at: new Date(await $.clock.now()).toISOString(), project: here.project, event, ...detail })
60 await $.fs.write(path, appendLine(existing, line))
61 } catch {
62 // The log is a debugging aid; losing a line must never break the recap.
63 }
64}
65
66/** What /recap (or its fallback name) does: open the pane, show the project's latest, or brew fresh options. */
67async function runCommand($: EngineInterface, args: string): Promise<{ text: string }> {
68 const job = await read($, lastJob)
69 const backlog = await read($, backlogAtom)
70 await $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS, focus: true })
71 const arg = args.trim()
72 if (arg === 'diary') {
73 const path = diaryFile(here.dir, here.cwd)
74 return { text: (await $.fs.exists(path)) ? `This project's progress log: ${path}` : 'No diary entries yet: one is added each time a recap finishes.' }
75 }
76 if (arg === 'log') return { text: `The recap log: ${here.dir}/log.jsonl` }
77 let hasRecap = (await read($, current)) !== null
78 // No recap in this chat yet, but the project has one from an earlier chat: show it straight away.
79 if (!hasRecap && arg !== 'again') {
80 const last = (await $.store.get(`last:${here.project}`)) as ProjectLast | undefined
81 if (last && Array.isArray(last.recap?.options)) {
82 const earlier: Recap = { ...last.recap, origin: 'earlier' }
83 await update($, current, () => earlier)
84 await update($, picksAtom, () => freshPicks(earlier.turnId))
85 await update($, viewAtom, () => 'options')
86 await note($, 'shown-earlier', { turnId: earlier.turnId })
87 hasRecap = true
88 return { text: 'Showing this project\'s latest recap. ↻ New options brews fresh ones for this chat.' }
89 }
90 }
91 // No recap yet (the last turn was small, or the chat began before the mod loaded): recap the chat as it stands.
92 if (arg === 'again' || !hasRecap) {
93 const now = await $.clock.now()
94 const target: Job = job ?? { turnId: `manual-${now.toString(36)}`, log: { ...EMPTY_LOG, ask: 'this conversation so far' }, answer: '' }
95 if (job === null) await update($, lastJob, () => target)
96 await update($, viewAtom, () => 'options')
97 $.clock.after(0, () => void brew($, target.turnId, target.log, target.answer))
98 return { text: hasRecap ? 'Brewing fresh options…' : 'Brewing options for this chat…' }
99 }
100 if (backlog.length > 0 && (await read($, current))?.status !== 'ready') await update($, viewAtom, () => 'backlog')
101 return { text: 'Next-prompt pane opened.' }
102}
103
104/** Registers /recap, or /next when the name is taken; returns the name that works. */
105async function registerCommand($: EngineInterface): Promise<string> {
106 const what = 'Show the next-prompt pane; add "again" for new options, "diary" or "log" for those files'
107 try {
108 await $.command.register({ name: 'recap', description: what })
109 return 'recap'
110 } catch (err) {
111 await note($, 'command-refused', { name: 'recap', why: err instanceof Error ? err.message : String(err) })
112 }
113 try {
114 await $.command.register({ name: FALLBACK_COMMAND, description: what })
115 return FALLBACK_COMMAND
116 } catch (err) {
117 await note($, 'command-refused', { name: FALLBACK_COMMAND, why: err instanceof Error ? err.message : String(err) })
118 return ''
119 }
120}
121
122/** Keeps a ready recap: as the project's latest (for /recap in any chat) and as a diary entry. */
123async function keep($: EngineInterface, r: Recap) {
124 if (!here.project) return
125 const at = await $.clock.now()
126 await $.store.set(`last:${here.project}`, { recap: { ...r, origin: undefined }, at } satisfies ProjectLast)
127 // One diary entry per finished piece of work: a fresh take on the same turn does not add another.
128 const seenKey = `diary-turn:${here.project}`
129 if ((await $.store.get(seenKey)) === r.turnId) return
130 await $.store.set(seenKey, r.turnId)
131 try {
132 const path = diaryFile(here.dir, here.cwd)
133 const existing = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : diaryHeader(here.cwd)
134 await $.fs.write(path, existing + diaryEntry(r, at))
135 } catch (err) {
136 await note($, 'diary-failed', { why: err instanceof Error ? err.message : String(err) })
137 }
138}
139
140/**
141 * Asks for the recap. A fork reads the real conversation (code, decisions), so
142 * the options can be specific to what was seen; if there is nothing to fork or
143 * it fails, the small model works from the turn's digest instead.
144 */
145async function brew($: EngineInterface, turnId: string, turnLog: TurnLog, answerText: string) {
146 await update($, current, () => ({
147 status: 'brewing', turnId, ask: turnLog.ask, tldr: '', verdict: 'in_progress', verdictWhy: '', options: [], packages: [], error: '',
148 }) satisfies Recap)
149 let takes: Takes | null = null
150 let why = ''
151 let source = 'conversation'
152 try {
153 const saved = await read($, backlogAtom)
154 const forked = await $.model.fork({ prompt: forkPrompt(answerText, saved) })
155 takes = forked.isAnswered ? parseTakes(forked.text) : null
156 if (takes === null) {
157 source = 'history fallback'
158 await note($, 'fork-skipped', { turnId, why: forked.isAnswered ? 'reply was not readable' : forked.reason })
159 // The fork could not answer. Without a recorded turn (a /recap right after the mod loaded) the
160 // small model would know nothing, so give it the chat's real history instead.
161 let digest = { log: turnLog, answer: answerText }
162 if (turnLog.steps.length === 0 || !answerText.trim()) {
163 const rows = await $.session.messages()
164 if (Array.isArray(rows)) digest = digestFrom(rows as unknown as HistoryRow[])
165 }
166 const reply = await $.model.complete({
167 model: 'haiku', system: SYSTEM, prompt: promptFor(digest.log, digest.answer, saved), maxTokens: 8000, effort: 'low',
168 })
169 takes = reply.isAnswered ? parseTakes(reply.text) : null
170 why = reply.isAnswered ? 'the owl mumbled something unreadable' : `the model said no (${reply.reason})`
171 }
172 } catch (err) {
173 why = `the request was refused (${err instanceof Error ? err.message : String(err)})`
174 }
175 let ready: Recap | null = null
176 await update($, current, r => {
177 if (r === null || r.turnId !== turnId) return r // a newer turn took over
178 if (takes === null) return { ...r, status: 'error' as const, error: why }
179 ready = { ...r, status: 'ready' as const, ...takes }
180 return ready
181 })
182 await note($, ready === null ? 'recap-failed' : 'recap-ready', ready === null ? { turnId, why } : { turnId, source, options: (ready as Recap).options.length })
183 if (ready !== null) {
184 await keep($, ready)
185 const fresh = freshPicks(turnId)
186 await update($, picksAtom, () => fresh)
187 await update($, viewAtom, v => (v === 'interview' ? v : 'options'))
188 // The top pick waits in the prompt box as the dim suggestion (Tab takes it).
189 const top = ranked((ready as Recap).options, await read($, learnedAtom))[0]
190 if (top) void $.prompt.suggest({ text: top.prompt })
191 }
192}
193
194export const register: Register = on => {
195 let log: TurnLog = EMPTY_LOG
196 let storeKey = backlogKey('')
197
198 on('session.start', async ($, e, next) => {
199 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
200 here.cwd = await $.session.cwd()
201 here.project = projectKey(here.cwd)
202 here.dir = home ? dataDir(home) : ''
203 here.command = await registerCommand($)
204 // Said once: Claude has a /recap of its own now, so the pane answers to /next.
205 if (here.command === FALLBACK_COMMAND && (await $.store.get('told-fallback')) !== true) {
206 await $.store.set('told-fallback', true)
207 $.ui.toast(`Claude now has its own /recap, so the ✨ Next pane opens with /${FALLBACK_COMMAND} instead.`, { timeoutMs: 12000 })
208 }
209 try {
210 await $.tool.register({ name: 'interview', description: INTERVIEW_DESCRIPTION, inputSchema: INTERVIEW_SCHEMA, isDeferred: false })
211 } catch (err) {
212 await note($, 'tool-refused', { why: err instanceof Error ? err.message : String(err) })
213 }
214 storeKey = backlogKey(await $.session.cwd())
215 const prefs = (await $.store.get('prefs')) as Partial<Prefs> | undefined
216 if (prefs) await update($, prefsAtom, p => ({ ...p, ...prefs }))
217 const learned = (await $.store.get('learned')) as Learned | undefined
218 if (learned) await update($, learnedAtom, () => learned)
219 const backlog = (await $.store.get(storeKey)) as BacklogItem[] | undefined
220 if (Array.isArray(backlog)) await update($, backlogAtom, () => backlog)
221 await update($, warningAtom, () => null)
222 await note($, 'session-start', { root: $.plugin.root, command: here.command })
223
224 // A second copy of this mod in the same chat: each copy announces itself, then looks for the other.
225 if (here.dir) {
226 const me: Instance = { session: await $.session.id(), token: crypto.randomUUID(), root: $.plugin.root, at: await $.clock.now() }
227 const registry = `${here.dir}/instances.json`
228 const announce = async () => {
229 const raw = (await $.fs.exists(registry)) ? String(await $.fs.read(registry)) : '[]'
230 await $.fs.write(registry, JSON.stringify(withInstance(raw, me)))
231 }
232 await announce().catch(err => note($, 'registry-failed', { why: err instanceof Error ? err.message : String(err) }))
233 // Two copies writing at the same instant can drop each other: write again twice, a little apart.
234 $.clock.after(300 + Math.floor(Math.random() * 300), () => void announce())
235 $.clock.after(900 + Math.floor(Math.random() * 400), () => void announce())
236 $.clock.after(2000, async () => {
237 const raw = (await $.fs.exists(registry)) ? String(await $.fs.read(registry)) : '[]'
238 const others = otherCopies(withInstance(raw, me), me)
239 if (others.length === 0) return
240 const warning = duplicateWarning('recap', me, others)
241 await update($, warningAtom, () => warning)
242 await note($, 'duplicate-copy', { roots: [me.root, ...others.map(o => o.root)] })
243 // Every copy sees the others; the one that started last says so (its check runs last), so the warning shows once.
244 if ([me, ...others].sort((a, b) => b.at - a.at || a.token.localeCompare(b.token))[0]!.token === me.token) {
245 $.ui.toast(warning, { timeoutMs: 12000 })
246 }
247 })
248 }
249
250 // Unfinished work from an earlier chat in this project: one gentle nudge with one next step.
251 const last = (await $.store.get(`last:${here.project}`)) as ProjectLast | undefined
252 const top = last && Array.isArray(last.recap?.options) ? ranked(last.recap.options, await read($, learnedAtom))[0] : undefined
253 const nudge = reminderFor(last, await $.clock.now(), top?.title)
254 if (nudge) {
255 $.ui.toast(nudge, { timeoutMs: 10000 })
256 await note($, 'reminder', { tldr: last!.recap.tldr })
257 }
258 return next(e)
259 })
260
261 on('turn.start', ($, e, next) => {
262 log = { ...EMPTY_LOG, ask: e.text }
263 return next(e)
264 })
265
266 // Claude's interview questions land in the pane as buttons; the answers come back as the person's next message.
267 on('tool.call', { tool: TOOL as 'Read' }, async ($, e) => {
268 const questions = parseQuestions(e as unknown)
269 if (questions === null) {
270 return { result: 'No questions were shown: give 1 to 5 questions, each with at least 2 answer options.' }
271 }
272 const iv = newInterview(questions, await $.clock.now())
273 await update($, interviewAtom, () => iv)
274 await update($, viewAtom, () => 'interview')
275 void $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS })
276 return {
277 result: `Showed ${questions.length} question${questions.length === 1 ? '' : 's'} in the person's recap pane. `
278 + 'End your turn now without doing the work; their answers arrive as their next message.',
279 }
280 })
281
282 on('tool.call', ($, e, next) => {
283 if (e.agentId === undefined && (e.tool as string) !== TOOL) log = logTool(log, e as any)
284 return next(e)
285 })
286
287 on('turn.complete', async ($, e, next) => {
288 if (e.agentId === undefined && isWorthRecap(log, e.answer, e.isAborted)) {
289 const job: Job = { turnId: e.turnId, log, answer: e.answer }
290 await update($, lastJob, () => job)
291 void $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS })
292 // Brew after the turn has handed control back, so the prompt is never held up.
293 $.clock.after(0, () => void brew($, job.turnId, job.log, job.answer))
294 await note($, 'turn-recapped', { turnId: e.turnId, edits: log.edits, research: log.research, tools: log.tools })
295 } else if (e.agentId === undefined) {
296 const why = skipReason(log, e.answer, e.isAborted) ?? 'it did not qualify'
297 $.ui.toast(`No recap this time: ${why}.${here.command ? ` /${here.command} makes one anyway.` : ''}`, { timeoutMs: 5000 })
298 await note($, 'turn-skipped', { turnId: e.turnId, why, edits: log.edits, research: log.research, tools: log.tools })
299 }
300 return next(e)
301 })
302
303 on('command.run', { command: 'recap' }, ($, e) => runCommand($, e.args))
304 on('command.run', { command: FALLBACK_COMMAND }, ($, e) => runCommand($, e.args))
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const saved = await read($, current)
308 // Anything malformed in state (it should not happen) shows as no recap rather than breaking the pane.
309 const r: Recap | null = saved !== null && Array.isArray(saved.options) ? saved : null
310 const prefs: Prefs = { ...DEFAULT_PREFS, ...(await read($, prefsAtom)) }
311 const backlog = await read($, backlogAtom)
312 const learned = await read($, learnedAtom)
313 const iv = await read($, interviewAtom)
314 const warning = await read($, warningAtom)
315 const hasInterview = iv !== null && iv.status === 'open'
316 const hasOptions = r !== null && r.status === 'ready'
317 let view: View = await read($, viewAtom)
318 if (view === 'interview' && !hasInterview) view = hasOptions ? 'options' : 'backlog'
319 if (view === 'options' && !hasOptions && (backlog.length > 0 || r === null)) view = 'backlog'
320
321 const isDesktop = e.surface === 'desktop'
322 const els = $.ui.resolve(e)
323 const { Box, Text, Button, Markdown } = els
324 // The mobile app draws no text field yet.
325 const Input = 'Input' in els ? els.Input : null
326
327 const setPrefs = async (patch: Partial<Prefs>) => {
328 const nextPrefs = { ...prefs, ...patch }
329 await update($, prefsAtom, () => nextPrefs)
330 await $.store.set('prefs', nextPrefs)
331 }
332 const setView = (v: View) => update($, viewAtom, () => v)
333
334 // ---- header: one line of status, text size, new options --------------------------------
335 const mood = r === null || r.status === 'error' ? 'oops' : r.status === 'brewing' ? 'brewing' : 'happy'
336 let mascot = <Text>🦉</Text>
337 if (isDesktop) {
338 const { Svg } = $.ui.resolve({ ...e, surface: e.surface })
339 const owl = owlSvg(hasInterview ? 'happy' : mood)
340 mascot = <Svg source={owl.source} alt={owl.alt} width={32} height={32} />
341 }
342 const headline = hasInterview
343 ? 'Claude has a few questions for you'
344 : r === null
345 ? backlog.length > 0 ? 'Your saved options' : 'Nothing to plan yet'
346 : r.status === 'brewing'
347 ? 'Brewing options…'
348 : r.status === 'error'
349 ? `Couldn't plan: ${md(r.error)}`
350 : `${VERDICT[r.verdict]} ${md(r.tldr)}${r.origin === 'earlier' ? ' _(from an earlier chat)_' : ''}`
351 const header = (
352 <Box flexDirection="row" alignItems="center" gap={1}>
353 {mascot}
354 <Box flexShrink={1} flexGrow={1}>
355 <Markdown text={`${HEAD_H[prefs.size]}${headline}`} />
356 </Box>
357 <Button key="smaller" variant="secondary" onPress={() => setPrefs({ size: Math.max(1, prefs.size - 1) as Prefs['size'] })}>A−</Button>
358 <Button key="bigger" variant="secondary" onPress={() => setPrefs({ size: Math.min(3, prefs.size + 1) as Prefs['size'] })}>A+</Button>
359 {r !== null && r.status !== 'brewing'
360 ? (
361 <Button key="again" variant="secondary" onPress={async () => {
362 // No turn recorded in this chat (say, a recap from an earlier chat): recap this chat as it stands.
363 const job = (await read($, lastJob))
364 ?? { turnId: `manual-${(await $.clock.now()).toString(36)}`, log: { ...EMPTY_LOG, ask: 'this conversation so far' }, answer: '' }
365 $.clock.after(0, () => void brew($, job.turnId, job.log, job.answer))
366 }}>
367 ↻ New options
368 </Button>
369 )
370 : null}
371 </Box>
372 )
373 const warned = warning
374 ? <Box flexDirection="column">{header}<Text color="#E5A84D">{warning}</Text></Box>
375 : header
376
377 const tabs = (
378 <Box flexDirection="row" flexWrap="wrap" gap={1}>
379 {hasInterview
380 ? <Button key="tab-interview" variant={view === 'interview' ? 'primary' : 'secondary'} onPress={() => setView('interview')}>💬 Questions</Button>
381 : null}
382 {hasOptions
383 ? <Button key="tab-options" variant={view === 'options' ? 'primary' : 'secondary'} onPress={() => setView('options')}>{`Options ${r!.options.length}`}</Button>
384 : null}
385 <Button key="tab-backlog" variant={view === 'backlog' ? 'primary' : 'secondary'} onPress={() => setView('backlog')}>{`☆ Saved ${backlog.length}`}</Button>
386 </Box>
387 )
388
389 // ---- the interview: Claude's questions as buttons --------------------------------------
390 if (view === 'interview' && iv !== null) {
391 const setIv = (fn: (x: Interview) => Interview) => update($, interviewAtom, x => (x === null ? x : fn(x)))
392 const answered = iv.answers.filter(a => a.length > 0).length
393 return (
394 <Box flexDirection="column" gap={1} paddingX={isDesktop ? 1 : 0}>
395 {warned}
396 {tabs}
397 {iv.questions.map((q, qi) => (
398 <Box key={`q${qi}`} flexDirection="column">
399 <Markdown text={`${TITLE_H[prefs.size] || '**'}${qi + 1}. ${md(q.question)}${TITLE_H[prefs.size] ? '' : '**'}`} />
400 <Box flexDirection="row" flexWrap="wrap" gap={1}>
401 {q.options.map((o, oi) => {
402 const isChosen = (iv.answers[qi] ?? []).includes(oi)
403 return (
404 <Button key={`q${qi}-o${oi}`} variant={isChosen ? 'primary' : 'secondary'} onPress={() => setIv(x => answer(x, qi, oi))}>
405 {`${isChosen ? (q.isMulti ? '☑' : '●') : (q.isMulti ? '☐' : '○')} ${o.label}${o.isRecommended ? ' ★' : ''}`}
406 </Button>
407 )
408 })}
409 <Button key={`q${qi}-x`} variant={(iv.answers[qi] ?? []).includes(-1) ? 'primary' : 'secondary'} onPress={() => setIv(x => answer(x, qi, -1))}>
410 You decide
411 </Button>
412 </Box>
413 {q.options.some(o => o.hint)
414 ? <Markdown dimColor text={q.options.filter(o => o.hint).map(o => `**${md(o.label)}**: ${md(o.hint)}`).join(' · ')} />
415 : null}
416 </Box>
417 ))}
418 {Input
419 ? (
420 <Input
421 key="iv-note"
422 placeholder="Anything to add? (optional)"
423 value={iv.note}
424 onInput={(value: string) => setIv(x => ({ ...x, note: value }))}
425 onSubmit={(value: string) => setIv(x => ({ ...x, note: value }))}
426 />
427 )
428 : null}
429 <Box flexDirection="row" gap={1}>
430 <Button key="iv-send" variant="primary" onPress={async () => {
431 const text = answersText(iv)
432 await setIv(x => ({ ...x, status: 'sent' }))
433 await setView(hasOptions ? 'options' : 'backlog')
434 void $.prompt.submit({ text })
435 }}>
436 {`Send answers ▶ (${answered}/${iv.questions.length})`}
437 </Button>
438 <Button key="iv-dismiss" variant="secondary" onPress={() => setIv(x => ({ ...x, status: 'sent' }))}>Dismiss</Button>
439 </Box>
440 <Text dimColor>Unanswered questions are left to Claude.</Text>
441 </Box>
442 )
443 }
444
445 if (!hasOptions && backlog.length === 0) {
446 return <Box flexDirection="column" gap={1} paddingX={isDesktop ? 1 : 0}>{warned}{r === null ? null : tabs}</Box>
447 }
448
449 // ---- picking: options or the saved backlog, then the builder ---------------------------
450 const turnId = hasOptions ? r!.turnId : ''
451 const stored = await read($, picksAtom)
452 const picks = stored !== null && stored.turnId === turnId ? stored : freshPicks(turnId)
453 const setPicks = (fn: (p: Picks) => Picks) =>
454 update($, picksAtom, p => fn(p !== null && p.turnId === turnId ? p : freshPicks(turnId)))
455 const togglePick = (id: string) =>
456 setPicks(p => ({ ...p, chosen: p.chosen.includes(id) ? p.chosen.filter(x => x !== id) : [...p.chosen, id] }))
457 const toggleSave = async (o: Option) => {
458 const now = await $.clock.now()
459 const nextBacklog = toggleSaved(backlog, o, now)
460 await update($, backlogAtom, () => nextBacklog)
461 await $.store.set(storeKey, nextBacklog)
462 }
463
464 const options = hasOptions ? r!.options : []
465 const pool: Option[] = [...options, ...backlog]
466 const picked = picks.chosen.map(id => pool.find(o => o.id === id)).filter((o): o is Option => o !== undefined)
467 const packages: Package[] = hasOptions && Array.isArray(r!.packages) ? r!.packages : []
468 const composed = composePrompt(pool, picks.chosen, prefs.deploy, prefs.autonomy, packageOf(packages, picks.chosen))
469
470 const send = async (how: 'replace' | 'append' | 'submit') => {
471 if (!composed) {
472 $.ui.toast('Pick at least one option first')
473 return
474 }
475 const nextLearned = learnFrom(learned, picked)
476 await update($, learnedAtom, () => nextLearned)
477 await $.store.set('learned', nextLearned)
478 if (how === 'submit') {
479 void $.prompt.submit({ text: composed })
480 return
481 }
482 const draft = how === 'append' ? (await $.prompt.read()).text.trimEnd() : ''
483 await $.prompt.fill({ text: draft ? `${draft}\n\n${composed}` : composed, mode: 'replace' })
484 $.ui.toast('In your prompt box: edit it or press Enter')
485 }
486
487 const pickButton = (o: Option) => {
488 const order = picks.chosen.indexOf(o.id)
489 const label = order === -1 ? '+ Pick' : order === 0 ? '✓ 1 main' : `✓ ${order + 1}`
490 return (
491 <Button key={`pick-${o.id}`} variant={order === -1 ? 'secondary' : 'primary'} onPress={() => togglePick(o.id)}>
492 {label}
493 </Button>
494 )
495 }
496 const optionBody = (o: Option, badges: string, extra?: string) => (
497 <Box flexDirection="column" flexShrink={1}>
498 <Markdown key={`opt-${o.id}`} text={`${TITLE_H[prefs.size]}${md(o.title)}${badges ? ` ${badges}` : ''}`} />
499 <Markdown
500 dimColor
501 text={`${o.lanes.map(l => LANES[l].icon).join('')} ${md(o.detail)} · ${EFFORT[o.effort]} · ${o.needsYou ? '💬 your call' : '🤖 Claude can decide'}${extra ? ` · ${extra}` : ''}`}
502 />
503 </Box>
504 )
505 const bars = (o: Option) => (
506 <Box flexDirection="row">
507 {o.lanes.map(l => <Text key={`${o.id}-${l}`} color={LANES[l].color}>▌</Text>)}
508 </Box>
509 )
510
511 let listing
512 if (view === 'backlog') {
513 listing = backlog.length === 0
514 ? <Text dimColor>Nothing saved yet. Press ☆ Save on any option to keep it here for this project, across chats.</Text>
515 : (
516 <Box flexDirection="column" gap={1}>
517 {backlog.map(o => (
518 <Box key={`row-${o.id}`} flexDirection="row" gap={1} alignItems="flex-start">
519 {pickButton(o)}
520 <Button key={`unsave-${o.id}`} variant="secondary" onPress={() => toggleSave(o)}>✕</Button>
521 {bars(o)}
522 {optionBody(o, '', `${o.pack ? `📦 ${md(o.pack)} · ` : ''}saved ${new Date(o.savedAt).toISOString().slice(0, 10)}`)}
523 </Box>
524 ))}
525 </Box>
526 )
527 } else {
528 const all = ranked(options, learned)
529 const top = topPicks(options, learned)
530 const best = bestInLane(options, learned)
531 const lanesPresent = LANE_KEYS.filter(l => all.some(o => o.lanes.includes(l)))
532 const shown = picks.lane === 'all' ? all : all.filter(o => o.lanes.includes(picks.lane as Lane))
533 const fav = favourites(learned)
534 const hasLearned = Object.values(learned).some(n => (n ?? 0) > 0)
535 listing = (
536 <Box flexDirection="column" gap={1}>
537 <Box flexDirection="row" flexWrap="wrap" gap={1}>
538 <Button key="lane-all" variant={picks.lane === 'all' ? 'primary' : 'secondary'} onPress={() => setPicks(p => ({ ...p, lane: 'all' }))}>
539 {`All ${all.length}`}
540 </Button>
541 {lanesPresent.map(l => (
542 <Button key={`lane-${l}`} variant={picks.lane === l ? 'primary' : 'secondary'} onPress={() => setPicks(p => ({ ...p, lane: p.lane === l ? 'all' : l }))}>
543 {`${LANES[l].icon} ${LANES[l].name} ${all.filter(o => o.lanes.includes(l)).length}`}
544 </Button>
545 ))}
546 </Box>
547 {packages.length > 0
548 ? (
549 <Box flexDirection="column" gap={1}>
550 <Markdown text={`${TITLE_H[prefs.size] || '**'}📦 Packages${TITLE_H[prefs.size] ? '' : '**'}`} />
551 <Box flexDirection="row" flexWrap="wrap" gap={1}>
552 {packages.map((p, i) => {
553 const isUsed = packageOf([p], picks.chosen) !== undefined
554 const allSaved = p.ids.every(id => {
555 const o = options.find(x => x.id === id)
556 return o !== undefined && isSaved(backlog, o)
557 })
558 const lanes = [...new Set(p.ids.flatMap(id => options.find(o => o.id === id)?.lanes ?? []))]
559 return (
560 <Box key={`pkg-${i}`} width={e.props.bodyColumns >= 120 ? '32%' : '100%'} flexDirection="column" borderStyle="round" borderColor={isUsed ? '#A487E0' : undefined} borderDimColor={!isUsed} paddingX={1}>
561 <Text bold>{`${p.name} · ${p.ids.length}`}</Text>
562 <Markdown dimColor text={`${lanes.slice(0, 4).map(l => LANES[l].icon).join('')} ${md(p.pitch)}`} />
563 <Box flexDirection="row" flexWrap="wrap" gap={1}>
564 <Button key={`pkg-use-${i}`} variant={isUsed ? 'primary' : 'secondary'} onPress={() => setPicks(pk => ({ ...pk, chosen: isUsed ? [] : [...p.ids] }))}>
565 {isUsed ? '✓ Using this' : 'Use this package'}
566 </Button>
567 <Button key={`pkg-save-${i}`} variant={allSaved ? 'primary' : 'secondary'} onPress={async () => {
568 if (allSaved) return
569 const now = await $.clock.now()
570 const nextBacklog = savePackage(backlog, options, p, now)
571 await update($, backlogAtom, () => nextBacklog)
572 await $.store.set(storeKey, nextBacklog)
573 $.ui.toast(`Saved "${p.name}" for later: it's in ☆ Saved, and new recaps will consider it`)
574 }}>
575 {allSaved ? '★ Saved' : '☆ Save for later'}
576 </Button>
577 </Box>
578 </Box>
579 )
580 })}
581 </Box>
582 </Box>
583 )
584 : null}
585 {hasLearned
586 ? (
587 <Box flexDirection="row" gap={1} alignItems="center">
588 <Box flexShrink={1} flexGrow={1}>
589 <Text dimColor>
590 {fav.length > 0
591 ? `Learned from your picks: ${fav.map(l => `${LANES[l].icon} ${LANES[l].name}`).join(', ')} rank a little higher`
592 : 'Learning which directions you pick…'}
593 </Text>
594 </Box>
595 <Button key="reset-learning" variant="secondary" onPress={async () => {
596 await update($, learnedAtom, () => ({}))
597 await $.store.set('learned', {})
598 $.ui.toast('Ranking reset: no more nudges from past picks')
599 }}>
600 Reset learning
601 </Button>
602 </Box>
603 )
604 : null}
605 {shown.map(o => {
606 const badges = [
607 top.has(o.id) ? '⭐ 80/20' : '',
608 picks.lane !== 'all' && best.get(picks.lane as Lane) === o.id ? '★ best here' : '',
609 ].filter(Boolean).join(' ')
610 const isKept = isSaved(backlog, o)
611 return (
612 <Box key={`row-${o.id}`} flexDirection="row" gap={1} alignItems="flex-start">
613 {pickButton(o)}
614 <Button key={`save-${o.id}`} variant={isKept ? 'primary' : 'secondary'} onPress={() => toggleSave(o)}>
615 {isKept ? '★ Saved' : '☆ Save'}
616 </Button>
617 {bars(o)}
618 {optionBody(o, badges)}
619 </Box>
620 )
621 })}
622 </Box>
623 )
624 }
625
626 const willAsk = willInterview(picked, prefs.autonomy)
627 const builder = (
628 <Box flexDirection="column" borderStyle="round" borderColor="#A487E0" borderDimColor paddingX={1}>
629 <Markdown text={`${HEAD_H[prefs.size]}Your next prompt`} />
630 {picked.length === 0
631 ? <Text dimColor>Press + Pick on options or saved ones. The first one you pick is the main job; the rest ride along.</Text>
632 : picked.map((o, i) => (
633 <Box key={`pk-${o.id}`} flexDirection="row" gap={1}>
634 <Text>{i === 0 ? '① main' : `${ORDINAL[i - 1] ?? '•'} also`}</Text>
635 <Box flexShrink={1} flexGrow={1}><Text>{o.title}</Text></Box>
636 {i > 0
637 ? <Button key={`up-${o.id}`} variant="secondary" onPress={() => setPicks(p => ({ ...p, chosen: [o.id, ...p.chosen.filter(x => x !== o.id)] }))}>make main</Button>
638 : null}
639 <Button key={`rm-${o.id}`} variant="secondary" onPress={() => togglePick(o.id)}>✕</Button>
640 </Box>
641 ))}
642
643 <Box marginTop={1}><Text bold>Where</Text></Box>
644 <Box flexDirection="row" flexWrap="wrap" gap={1}>
645 {(Object.keys(DEPLOY) as Deploy[]).map(d => (
646 <Button key={`dep-${d}`} variant={prefs.deploy === d ? 'primary' : 'secondary'} onPress={() => setPrefs({ deploy: d })}>
647 {`${prefs.deploy === d ? '◉' : '○'} ${DEPLOY[d].label}`}
648 </Button>
649 ))}
650 </Box>
651
652 <Box marginTop={1}><Text bold>Questions</Text></Box>
653 <Box flexDirection="row" flexWrap="wrap" gap={1}>
654 {(Object.keys(AUTONOMY) as Autonomy[]).map(a => (
655 <Button key={`aut-${a}`} variant={prefs.autonomy === a ? 'primary' : 'secondary'} onPress={() => setPrefs({ autonomy: a })}>
656 {`${prefs.autonomy === a ? '◉' : '○'} ${AUTONOMY[a].label}`}
657 </Button>
658 ))}
659 </Box>
660 <Text dimColor>
661 {prefs.autonomy === 'auto'
662 ? picked.length === 0 ? AUTONOMY.auto.hint : willAsk ? '→ will interview you: a pick needs your ideas' : '→ will just do it: nothing needs your ideas'
663 : AUTONOMY[prefs.autonomy].hint}
664 </Text>
665
666 <Box borderStyle="single" borderDimColor paddingX={1} marginTop={1} flexDirection="column">
667 {composed ? <Text>{composed}</Text> : <Text dimColor>Your prompt builds itself here.</Text>}
668 </Box>
669 <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
670 <Button key="fill" variant="primary" onPress={() => send('replace')}>Put in prompt</Button>
671 <Button key="append" variant="secondary" onPress={() => send('append')}>Add to my draft</Button>
672 <Button key="send" variant="secondary" onPress={() => send('submit')}>Send now ▶</Button>
673 {picked.length > 0
674 ? <Button key="clear" variant="secondary" onPress={() => setPicks(p => ({ ...p, chosen: [] }))}>Clear</Button>
675 : null}
676 </Box>
677 </Box>
678 )
679
680 const isWide = e.props.bodyColumns >= 120
681 return (
682 <Box flexDirection="column" gap={1} paddingX={isDesktop ? 1 : 0}>
683 {warned}
684 {tabs}
685 <Box flexDirection={isWide ? 'row' : 'column'} gap={2}>
686 <Box flexDirection="column" gap={1} width={isWide ? '60%' : '100%'}>{listing}</Box>
687 <Box flexDirection="column" width={isWide ? '38%' : '100%'}>{builder}</Box>
688 </Box>
689 </Box>
690 )
691 })
692}
693hooks/owl.ts 61 lines1// The pane's mascot: a small owl in a wizard hat, drawn as an SVG image with
2// SMIL motion. It blinks now and then, bobs and sparkles while a recap brews,
3// and blushes when one is ready.
4
5export type Mood = 'brewing' | 'happy' | 'oops'
6
7const SPARKLE = 'M0 -5 L1.2 -1.2 L5 0 L1.2 1.2 L0 5 L-1.2 1.2 L-5 0 L-1.2 -1.2 Z'
8
9const sparkle = (x: number, y: number, delay: number, color: string) => `
10 <path d="${SPARKLE}" transform="translate(${x} ${y})" fill="${color}" opacity="0">
11 <animate attributeName="opacity" values="0;1;0" dur="1.4s" begin="${delay}s" repeatCount="indefinite"/>
12 <animateTransform attributeName="transform" type="scale" additive="sum" values=".4;1;.4" dur="1.4s" begin="${delay}s" repeatCount="indefinite"/>
13 </path>`
14
15export const owlSvg = (mood: Mood): { source: string; width: number; height: number; alt: string } => {
16 const bob = mood === 'brewing'
17 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="1.2s" repeatCount="indefinite" calcMode="spline" keySplines="0.4 0 0.6 1;0.4 0 0.6 1"/>`
18 : mood === 'happy'
19 ? `<animateTransform attributeName="transform" type="translate" values="0 6;0 -2;0 0" keyTimes="0;.55;1" dur=".6s" fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.2 1;0.4 0 0.6 1"/>`
20 : ''
21 const blink = (cx: number) => `
22 <ellipse cx="${cx}" cy="44" rx="9.5" ry="0" fill="#B98A63">
23 <animate attributeName="ry" values="0;0;9.5;0" keyTimes="0;.9;.95;1" dur="4.2s" repeatCount="indefinite"/>
24 </ellipse>`
25 const eye = (cx: number) => mood === 'oops'
26 ? `<circle cx="${cx}" cy="44" r="9" fill="#FFF8EE"/>
27 <path d="M${cx - 3.5} 40.5 l7 7 M${cx + 3.5} 40.5 l-7 7" stroke="#2B2320" stroke-width="2.2" stroke-linecap="round"/>`
28 : `<circle cx="${cx}" cy="44" r="9" fill="#FFF8EE"/>
29 <circle cx="${cx + 1}" cy="45" r="4.6" fill="#2B2320"/>
30 <circle cx="${cx + 2.6}" cy="43.2" r="1.5" fill="#fff"/>${blink(cx)}`
31 const blush = mood === 'happy'
32 ? `<ellipse cx="25" cy="54" rx="4" ry="2.4" fill="#F29BB5" opacity=".75"/><ellipse cx="55" cy="54" rx="4" ry="2.4" fill="#F29BB5" opacity=".75"/>`
33 : ''
34 const extras = mood === 'brewing'
35 ? sparkle(10, 24, 0, '#F6C453') + sparkle(70, 30, .5, '#C9A7F5') + sparkle(66, 8, 1, '#F6C453')
36 : mood === 'happy'
37 ? sparkle(9, 20, 0, '#F6C453') + sparkle(71, 26, .7, '#F29BB5')
38 : `<path d="M64 30 q3 5 0 7 q-3 -2 0 -7z" fill="#7DC4F0"/>`
39 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80">
40 <ellipse cx="40" cy="77" rx="18" ry="2.5" fill="#000" opacity=".18"/>
41 ${extras}
42 <g>${bob}
43 <ellipse cx="17" cy="56" rx="7" ry="13" fill="#A47552" transform="rotate(18 17 56)"/>
44 <ellipse cx="63" cy="56" rx="7" ry="13" fill="#A47552" transform="rotate(-18 63 56)"/>
45 <ellipse cx="40" cy="52" rx="24" ry="23" fill="#B98A63"/>
46 <ellipse cx="40" cy="60" rx="15" ry="13" fill="#EBD3B5"/>
47 <path d="M33 58 q2 2 4 0 M43 58 q2 2 4 0 M38 64 q2 2 4 0" stroke="#C9A27E" stroke-width="1.4" fill="none" stroke-linecap="round"/>
48 ${eye(30)}${eye(50)}
49 <path d="M37.5 50 L42.5 50 L40 55 Z" fill="#F0A04B" stroke="#E08A33" stroke-width=".8" stroke-linejoin="round"/>
50 ${blush}
51 <path d="M34 75 l-2 3 M36 75 l0 3.5 M44 75 l0 3.5 M46 75 l2 3" stroke="#F0A04B" stroke-width="1.8" stroke-linecap="round"/>
52 <path d="M24 31 Q31 27 40 27 Q49 27 56 31 Q49 33 40 33 Q31 33 24 31 Z" fill="#5B3E9E"/>
53 <path d="M29 30 Q36 4 50 3 Q45 10 51 30 Z" fill="#6E4FC4"/>
54 <path d="M50 3 Q55 4 56 9" stroke="#6E4FC4" stroke-width="3" fill="none" stroke-linecap="round"/>
55 <path d="${SPARKLE}" transform="translate(40 19) scale(.8)" fill="#F6C453"/>
56 </g>
57</svg>`
58 const alt = mood === 'brewing' ? 'An owl in a wizard hat, brewing' : mood === 'happy' ? 'A happy owl in a wizard hat' : 'A puzzled owl in a wizard hat'
59 return { source, width: 80, height: 80, alt }
60}
61hooks/journal.ts 97 lines1import type { Recap } from '../types'
2import type { TurnLog } from './recap'
3
4// Pure parts of the recap's memory: why a turn got no recap, the per-project
5// diary and reminder, the debug log, and spotting a second copy of the mod.
6
7/** Where the mod keeps its files: the log, the diaries and the copy check. */
8export const dataDir = (home: string) => `${home.replace(/\\/g, '/').replace(/\/+$/, '')}/.claude/recap`
9
10/** One key per project folder, whatever the slashes or case. */
11export const projectKey = (cwd: string) => cwd.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
12
13/** The project's folder name, for headings and file names. */
14export const projectName = (cwd: string) => cwd.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || 'project'
15
16/** A file-safe name for the project's diary: its folder name plus a short hash of the full path. */
17export const diaryFile = (dir: string, cwd: string) => {
18 const key = projectKey(cwd)
19 let h = 0
20 for (const c of key) h = (h * 31 + c.charCodeAt(0)) >>> 0
21 const name = projectName(cwd).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project'
22 return `${dir}/diary/${name}-${h.toString(36)}.md`
23}
24
25/** Why a finished turn got no recap pane, in a few plain words, or null when it qualifies. */
26export const skipReason = (log: TurnLog, answer: string, isAborted: boolean): string | null => {
27 if (isAborted) return 'the turn was interrupted'
28 if (!answer.trim()) return 'Claude gave no reply'
29 if (log.edits >= 1 || log.research >= 3 || log.tools >= 5) return null
30 if (log.tools === 0) return 'nothing was changed, it was just a chat'
31 return 'too little was changed or looked up'
32}
33
34const DAY = 86_400_000
35const when = (at: number) => new Date(at).toISOString().replace('T', ' ').slice(0, 16)
36const LABEL = { done: 'Done', in_progress: 'In progress', blocked: 'Blocked' } as const
37
38/** The diary's opening lines, written once per project. */
39export const diaryHeader = (cwd: string) =>
40 `# ${projectName(cwd)}: progress log\n\nWhat was worked on, newest last. Each entry is written when a piece of work finishes.\n`
41
42/** One diary entry: plain, client-readable, no internal jargon. */
43export const diaryEntry = (r: Recap, at: number) => {
44 const asked = r.ask.trim() && !r.ask.startsWith('this conversation') ? `\n- **Asked for:** ${r.ask.replace(/\s+/g, ' ').slice(0, 240)}` : ''
45 const why = r.verdictWhy ? ` (${r.verdictWhy})` : ''
46 return `\n## ${when(at)} UTC\n\n- **What was done:** ${r.tldr}${asked}\n- **Status:** ${LABEL[r.verdict]}${why}\n`
47}
48
49/** The most recent recap of a project, kept across chats. */
50export type ProjectLast = { recap: Recap; at: number }
51
52/**
53 * A gentle nudge when a chat opens on a project whose last piece of work was
54 * left unfinished: what it was and one next step. Null when there is nothing
55 * to nudge about (it was done, or it is more than two weeks old).
56 */
57export const reminderFor = (last: ProjectLast | undefined, now: number, topTitle?: string): string | null => {
58 if (!last || last.recap.status !== 'ready' || last.recap.verdict === 'done') return null
59 const age = now - last.at
60 if (age < 0 || age > 14 * DAY) return null
61 const days = Math.floor(age / DAY)
62 const ago = days === 0 ? 'earlier today' : days === 1 ? 'yesterday' : `${days} days ago`
63 const state = last.recap.verdict === 'blocked' ? 'was blocked' : 'was left in progress'
64 const step = topTitle ? ` Next step: ${topTitle}.` : ''
65 return `🦉 ${ago} here: "${last.recap.tldr}" ${state}.${step} /recap shows it.`
66}
67
68/** The log with one more line, keeping the newest lines only. */
69export const appendLine = (existing: string, line: string, keep = 2000) => {
70 const lines = existing.split('\n').filter(Boolean)
71 lines.push(line)
72 return `${lines.slice(-keep).join('\n')}\n`
73}
74
75/** A copy of the mod announcing itself in a chat. */
76export type Instance = { session: string; token: string; root: string; at: number }
77
78/** The copy registry with this copy added and day-old entries dropped. */
79export const withInstance = (raw: string, me: Instance): Instance[] => {
80 let list: Instance[] = []
81 try {
82 const parsed = JSON.parse(raw)
83 if (Array.isArray(parsed)) list = parsed.filter(i => i && typeof i.token === 'string')
84 } catch {
85 list = []
86 }
87 return [...list.filter(i => i.token !== me.token && me.at - i.at < DAY), me]
88}
89
90/** Other copies that started in the same chat at the same moment: a second copy of the mod. */
91export const otherCopies = (list: readonly Instance[], me: Instance, windowMs = 5000): Instance[] =>
92 list.filter(i => i.session === me.session && i.token !== me.token && Math.abs(i.at - me.at) < windowMs)
93
94export const duplicateWarning = (mod: string, me: Instance, others: readonly Instance[]) =>
95 `⚠ ${mod} is loaded twice in this chat (${[me, ...others].map(i => i.root).join(' and ')}). `
96 + 'Two copies can clash; remove one from your plugins and restart the chat.'
97hooks/recap.ts 450 lines1import type { Autonomy, BacklogItem, Deploy, Interview, Lane, Learned, Option, Package, Question, Verdict } from '../types'
2
3// Pure parts: when a turn earns a recap, what to ask the model, reading its
4// answer, ranking the options, and building the next prompt from the picks.
5
6export type TurnLog = {
7 ask: string
8 steps: string[]
9 edits: number
10 research: number
11 tools: number
12}
13
14const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
15const RESEARCH_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Agent', 'Task'])
16
17const base = (p: unknown) => (typeof p === 'string' ? (p.split(/[\\/]/).pop() ?? p) : '')
18const one = (t: unknown, max: number) => {
19 const s = String(t ?? '').replace(/\s+/g, ' ').trim()
20 return s.length > max ? `${s.slice(0, max - 1)}…` : s
21}
22
23/** One step of the turn in a few words, for the model to read. */
24export const stepOf = (e: { tool: string } & Record<string, any>): string => {
25 switch (e.tool) {
26 case 'Read':
27 case 'Edit':
28 case 'Write':
29 return `${e.tool} ${base(e.file_path)}`
30 case 'Bash':
31 case 'PowerShell':
32 return `ran: ${one(e.command, 80)}`
33 case 'Grep':
34 return `searched code for "${one(e.pattern, 40)}"`
35 case 'Glob':
36 return `listed files ${one(e.pattern, 40)}`
37 case 'WebFetch':
38 return `read web page ${one(e.url, 80)}`
39 case 'WebSearch':
40 return `searched the web for "${one(e.query, 60)}"`
41 case 'Agent':
42 case 'Task':
43 return `delegated: ${one(e.description, 60)}`
44 default:
45 return e.tool
46 }
47}
48
49export const logTool = (log: TurnLog, e: { tool: string } & Record<string, any>): TurnLog => ({
50 ...log,
51 steps: [...log.steps, stepOf(e)].slice(-40),
52 tools: log.tools + 1,
53 edits: log.edits + (EDIT_TOOLS.has(e.tool) ? 1 : 0),
54 research: log.research + (RESEARCH_TOOLS.has(e.tool) ? 1 : 0),
55})
56
57/** Worth a recap: something was changed, or real digging happened. */
58export const isWorthRecap = (log: TurnLog, answer: string, isAborted: boolean): boolean =>
59 !isAborted && answer.trim().length > 0 && (log.edits >= 1 || log.research >= 3 || log.tools >= 5)
60
61/** The lanes, in the order the pane lists them: icon, name, colour. */
62export const LANES: Record<Lane, { icon: string; name: string; color: string }> = {
63 fix: { icon: '🐞', name: 'Fix', color: '#F2777B' },
64 debt: { icon: '🧹', name: 'Tech debt', color: '#9AA4B2' },
65 feature: { icon: '✨', name: 'Features', color: '#6A9BCC' },
66 design: { icon: '🎨', name: 'UI & design', color: '#E88AB4' },
67 money: { icon: '💰', name: 'Money', color: '#3FB950' },
68 growth: { icon: '📣', name: 'Growth & leads', color: '#E5A84D' },
69 ship: { icon: '🚀', name: 'Ship & ops', color: '#56C1C9' },
70 wild: { icon: '🧪', name: 'Wild ideas', color: '#A487E0' },
71}
72export const LANE_KEYS = Object.keys(LANES) as Lane[]
73
74const SHAPE = `Reply with JSON only, no code fence, exactly these keys:
75"tldr": at most 20 words: what was done and the outcome
76"verdict": "done" when the goal the person asked for is fully reached and verified, "in_progress" when part of it remains, "blocked" when it cannot go on without the person
77"verdict_why": at most 12 words
78"options": 28 to 40 possible next moves for this project, as varied and creative as you can make them, so the person can scan them all and pick the vital few. Think first from the point of view of the project's customers or users: what would make them happier, trust it more, come back, pay or tell a friend. Use everything you saw in the code and the conversation: bugs, inconsistencies, shortcuts, missed opportunities, and where the project could go. Cover at least 6 of these lanes:
79 "fix": bugs, broken or unverified things
80 "debt": refactors, inconsistencies, cleanup, missing tests
81 "feature": new or better product capability
82 "design": UI, UX, look and feel, wording
83 "money": revenue, pricing, monetisation
84 "growth": users, leads, marketing, distribution
85 "ship": deploying, releasing, monitoring, operations
86 "wild": fresh, bold or creative bets the person probably has not thought of (at least 6 of these)
87Each option is {"title": at most 9 plain words, "detail": at most 18 words on what it is and why it matters, "lanes": 1 to 3 lane keys, most related first, "impact": 1 to 5 for the value if done, "effort": "S", "M" or "L", "needs_you": true when it turns on the person's own taste, ideas or business knowledge (a new feature, a design direction, pricing), false when it can be done well from the code alone (bugs, refactors, tests, deploys), "prompt": the prompt that asks for it, first person, imperative, at most 35 words}.
88"packages": 3 to 5 ready-made bundles of the options above that make sense done together, each a different size and aim: for example "the 2 big ones" (two major moves), a pack of about 7 quick wins, and a pack of about 12 for a bigger push (a launch, a redesign, a growth sprint). Each is {"name": at most 4 words, "pitch": at most 18 words on what the customer gets from it, "options": the option numbers in it (1 is the first option above), in the order to do them}. Packages may share options.
89The person is not very technical: write titles and details in plain language, and never plan to ask them technical questions.`
90
91/** For a fork of the session: the model already has the whole conversation. */
92/** The person's saved-for-later list, for the model to weigh: re-include the ones that still matter, same titles. */
93export const savedNote = (saved: readonly { title: string; pack?: string }[]): string =>
94 saved.length === 0
95 ? ''
96 : `\nThe person saved these for later (newest first). Bring back, with the exact same title, any that still matter after this turn, and use them in packages where they fit:\n${saved.slice(0, 30).map(s => `- ${s.title}${s.pack ? ` (saved with "${s.pack}")` : ''}`).join('\n')}\n`
97
98export const forkPrompt = (answer: string, saved: readonly { title: string; pack?: string }[] = []): string =>
99 `[Recap request from the person's recap pane, not a new task. Do not continue the work.]
100Recap the turn that just finished against the goal the person asked for, then lay out what could come next. Your last reply follows, in case it is not above:
101"""
102${answer.slice(0, 6000)}
103"""
104Be accurate to what was actually done and verified; never invent results.${savedNote(saved)}
105${SHAPE}`
106
107export const SYSTEM = `You recap a coding assistant's finished work against the goal the person asked for, then lay out what could come next.
108Be accurate to what was actually done and verified; never invent results.
109${SHAPE}`
110
111export const promptFor = (log: TurnLog, answer: string, saved: readonly { title: string; pack?: string }[] = []): string => {
112 const steps = log.steps.length > 0 ? log.steps.map(s => `- ${s}`).join('\n') : '- (no tools)'
113 return `The person asked:\n${one(log.ask, 1200) || '(continuation of earlier work)'}\n\nWhat the assistant did, in order:\n${steps}\n\nThe assistant's final reply:\n${answer.slice(0, 6000)}${savedNote(saved)}`
114}
115
116export type Takes = { tldr: string; verdict: Verdict; verdictWhy: string; options: Option[]; packages: Package[] }
117
118/**
119 * Model-written text made safe to show and to send: links and images reduced
120 * to their words, HTML tags dropped, control characters and line breaks turned
121 * into spaces, and capped. The model's text can echo what it read (a web page,
122 * a file), so none of it may carry a hidden instruction or a live link.
123 */
124export const plain = (x: unknown, max = 400): string => {
125 if (typeof x !== 'string') return ''
126 const s = x
127 .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images → their alt words
128 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links → their words
129 .replace(/<[^>]*>/g, ' ') // HTML tags
130 .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ') // control, zero-width and direction marks
131 .replace(/\s+/g, ' ')
132 .trim()
133 return s.length > max ? `${s.slice(0, max - 1)}…` : s
134}
135
136/** Plain text escaped for a Markdown element, so nothing in it becomes a link, image or markup. */
137export const md = (s: string): string => s.replace(/[\\`*_{}\[\]()#+\-.!<>|~]/g, c => `\\${c}`)
138
139const str = (x: unknown): string => plain(x)
140const list = (x: unknown): any[] => (Array.isArray(x) ? x : [])
141const VERDICTS = new Set<Verdict>(['done', 'in_progress', 'blocked'])
142const EFFORTS = new Set(['S', 'M', 'L'])
143
144/** Reads the model's JSON, tolerating a code fence or chatter around it. */
145export const parseTakes = (text: string): Takes | null => {
146 const start = text.indexOf('{')
147 const end = text.lastIndexOf('}')
148 if (start < 0 || end <= start) return null
149 try {
150 const j = JSON.parse(text.slice(start, end + 1))
151 const tldr = plain(j.tldr, 200)
152 if (!tldr || !VERDICTS.has(j.verdict)) return null
153 const options: Option[] = list(j.options)
154 .map((o, i) => {
155 const lanes = [...new Set(list(o?.lanes).filter((l): l is Lane => l in LANES))].slice(0, 3)
156 const impact = Math.min(5, Math.max(1, Math.round(Number(o?.impact) || 3)))
157 return {
158 id: `o${i}`,
159 title: plain(o?.title, 90),
160 detail: plain(o?.detail, 200),
161 lanes: lanes.length > 0 ? lanes : (['feature'] as Lane[]),
162 impact,
163 effort: EFFORTS.has(o?.effort) ? o.effort : 'M',
164 needsYou: o?.needs_you === true,
165 prompt: plain(o?.prompt, 300),
166 }
167 })
168 .filter(o => o.title && o.prompt)
169 .slice(0, 40)
170 if (options.length === 0) return null
171 // Packages name options by their number in the reply; keep the ones that still exist after tidying.
172 const byNumber = new Map(list(j.options).map((o, i) => [i + 1, plain(o?.title, 90)]))
173 const packages: Package[] = list(j.packages)
174 .map(p => {
175 const ids = [...new Set(list(p?.options).map(n => byNumber.get(Number(n))).filter(Boolean))]
176 .map(title => options.find(o => o.title === title)?.id)
177 .filter((id): id is string => Boolean(id))
178 return { name: plain(p?.name, 40), pitch: plain(p?.pitch, 160), ids }
179 })
180 .filter(p => p.name && p.ids.length >= 2)
181 .slice(0, 5)
182 return { tldr, verdict: j.verdict, verdictWhy: plain(j.verdict_why, 120), options, packages }
183 } catch {
184 return null
185 }
186}
187
188const EFFORT_COST = { S: 1, M: 1.25, L: 1.6 } as const
189
190/** At most this much lift from the person's habits: a nudge, never a takeover. */
191const LEARN_LIFT = 0.3
192/** Picks it takes before the habits count at all. */
193export const LEARN_MIN = 3
194
195/** How much the person leans to each lane, 0 to 1 against their favourite. */
196export const affinity = (learned: Learned): Partial<Record<Lane, number>> => {
197 const counts = Object.values(learned).filter((n): n is number => typeof n === 'number')
198 const total = counts.reduce((a, b) => a + b, 0)
199 const top = Math.max(0, ...counts)
200 if (total < LEARN_MIN || top === 0) return {}
201 const out: Partial<Record<Lane, number>> = {}
202 for (const l of LANE_KEYS) if (learned[l]) out[l] = (learned[l] as number) / top
203 return out
204}
205
206/**
207 * The 80/20 score: value per effort, lifted for options that serve several
208 * directions at once, since those move more of the project with one change,
209 * and lifted a little for the directions the person usually picks.
210 */
211export const score = (o: Option, learned: Learned = {}): number => {
212 const lean = affinity(learned)
213 const habit = Math.max(0, ...o.lanes.map(l => lean[l] ?? 0))
214 return ((o.impact * (1 + 0.4 * (o.lanes.length - 1))) / EFFORT_COST[o.effort]) * (1 + LEARN_LIFT * habit)
215}
216
217/** Options best-first; ties keep the model's order. */
218export const ranked = (options: readonly Option[], learned: Learned = {}): Option[] =>
219 options.map((o, i) => ({ o, i, s: score(o, learned) })).sort((a, b) => b.s - a.s || a.i - b.i).map(x => x.o)
220
221/** The package the picks are exactly, if any: same options, any order. */
222export const packageOf = (packages: readonly Package[], chosen: readonly string[]): Package | undefined =>
223 packages.find(p => p.ids.length === chosen.length && p.ids.every(id => chosen.includes(id)))
224
225/** The vital few: the top three overall. */
226export const topPicks = (options: readonly Option[], learned: Learned = {}): Set<string> =>
227 new Set(ranked(options, learned).slice(0, 3).map(o => o.id))
228
229/** The best option in each lane it serves. */
230export const bestInLane = (options: readonly Option[], learned: Learned = {}): Map<Lane, string> => {
231 const best = new Map<Lane, string>()
232 for (const o of ranked(options, learned)) for (const l of o.lanes) if (!best.has(l)) best.set(l, o.id)
233 return best
234}
235
236/** The habits after the person sends a prompt built from these picks. */
237export const learnFrom = (learned: Learned, picked: readonly Option[]): Learned => {
238 const out: Learned = { ...learned }
239 for (const o of picked) for (const l of o.lanes) out[l] = (out[l] ?? 0) + 1
240 return out
241}
242
243/** The lanes the person leans to most, for the pane to say so. */
244export const favourites = (learned: Learned): Lane[] => {
245 const lean = affinity(learned)
246 return (Object.keys(lean) as Lane[])
247 .filter(l => (lean[l] ?? 0) >= 0.6)
248 .sort((a, b) => (lean[b] ?? 0) - (lean[a] ?? 0))
249 .slice(0, 3)
250}
251
252/** Whether a backlog already holds this option (same title and prompt). */
253export const isSaved = (backlog: readonly BacklogItem[], o: Option): boolean =>
254 backlog.some(b => b.title === o.title && b.prompt === o.prompt)
255
256/** The backlog with this option added (newest first), or taken out when already there. */
257export const toggleSaved = (backlog: readonly BacklogItem[], o: Option, now: number): BacklogItem[] =>
258 isSaved(backlog, o)
259 ? backlog.filter(b => !(b.title === o.title && b.prompt === o.prompt))
260 : [{ ...o, id: freshId(backlog, now), savedAt: now }, ...backlog].slice(0, 100)
261
262/** The backlog with every option of a package added (ones already saved stay as they are). */
263export const savePackage = (backlog: readonly BacklogItem[], options: readonly Option[], pack: Package, now: number): BacklogItem[] => {
264 let out = [...backlog]
265 for (const id of [...pack.ids].reverse()) {
266 const o = options.find(x => x.id === id)
267 if (o && !isSaved(out, o)) out = [{ ...o, id: freshId(out, now), savedAt: now, pack: pack.name }, ...out]
268 }
269 return out.slice(0, 100)
270}
271
272/** An id no saved option has yet: two saves in the same millisecond must not share one, or the pane cannot draw. */
273const freshId = (backlog: readonly BacklogItem[], now: number): string => {
274 const taken = new Set(backlog.map(b => b.id))
275 let n = 0
276 while (taken.has(`bk-${now.toString(36)}-${n}`)) n++
277 return `bk-${now.toString(36)}-${n}`
278}
279
280export const DEPLOY: Record<Deploy, { label: string; text: string }> = {
281 local: { label: 'Keep it local', text: 'Keep it local for now; do not deploy anything.' },
282 staging: { label: 'Staging', text: 'Once it works locally, deploy to staging and check it there.' },
283 production: { label: 'Production', text: 'Once it works, deploy to production and check it is live and working.' },
284}
285
286export const AUTONOMY: Record<Autonomy, { label: string; hint: string }> = {
287 auto: { label: 'Auto', hint: 'interview me only when a pick needs my ideas' },
288 interview: { label: 'Interview me first', hint: 'questions before work' },
289 solo: { label: 'Just do it', hint: 'no questions, your call' },
290}
291
292const INTERVIEW = 'Before you start, interview me with the recap interview tool: up to 5 short, plain-language multiple-choice questions about what I want (goals, taste, priorities). I am not technical, so make every technical decision yourself.'
293const SOLO = 'Do not stop to ask me questions: make sensible decisions yourself and tell me briefly what you chose.'
294
295const sentence = (t: string) => (/[.!?]$/.test(t) ? t : `${t}.`)
296const clause = (t: string) => t.replace(/[.!?]$/, '')
297
298/** Whether the prompt will ask for an interview, given the picks. */
299export const willInterview = (picked: readonly Option[], autonomy: Autonomy): boolean =>
300 autonomy === 'interview' || (autonomy === 'auto' && picked.some(o => o.needsYou))
301
302/**
303 * The next prompt: the first pick is the main job, the rest ride along, then
304 * where it should end up and whether to interview first.
305 */
306export const composePrompt = (
307 /** every option that can be picked: this recap's and the backlog's */
308 options: readonly Option[],
309 chosen: readonly string[],
310 deploy: Deploy,
311 autonomy: Autonomy,
312 /** set when the picks are exactly one package: the prompt names it */
313 pack?: Package,
314): string => {
315 const picked = chosen.map(id => options.find(o => o.id === id)).filter((o): o is Option => o !== undefined)
316 const [main, ...rest] = picked
317 if (main === undefined) return ''
318 if (pack && picked.length > 1) {
319 return [
320 `Let's do the "${pack.name}" package: ${pack.pitch.replace(/[.!?]$/, '')}. In this order:`,
321 picked.map((o, i) => `${i + 1}. ${clause(o.prompt)}`).join('\n'),
322 DEPLOY[deploy].text,
323 willInterview(picked, autonomy) ? INTERVIEW : SOLO,
324 ].join('\n\n')
325 }
326 const parts = [sentence(main.prompt)]
327 const [only] = rest
328 if (rest.length === 1 && only) parts.push(`Also ${clause(only.prompt).replace(/^./, c => c.toLowerCase())}.`)
329 else if (rest.length > 1) parts.push(`Also:\n${rest.map(o => `- ${clause(o.prompt)}`).join('\n')}`)
330 parts.push(DEPLOY[deploy].text)
331 parts.push(willInterview(picked, autonomy) ? INTERVIEW : SOLO)
332 return parts.join('\n\n')
333}
334
335/** The interview tool's input schema, as the model sees it. */
336export const INTERVIEW_SCHEMA = {
337 type: 'object',
338 properties: {
339 questions: {
340 type: 'array',
341 minItems: 1,
342 maxItems: 5,
343 description: 'Up to 5 short questions in plain, non-technical language about goals, taste and priorities.',
344 items: {
345 type: 'object',
346 properties: {
347 question: { type: 'string', description: 'The question, at most 15 plain words.' },
348 options: {
349 type: 'array',
350 minItems: 2,
351 maxItems: 5,
352 items: {
353 type: 'object',
354 properties: {
355 label: { type: 'string', description: 'The answer, at most 6 words.' },
356 hint: { type: 'string', description: 'Optional: what choosing it means, at most 12 words.' },
357 recommended: { type: 'boolean', description: 'True for the one answer you would suggest.' },
358 },
359 required: ['label'],
360 },
361 },
362 multi: { type: 'boolean', description: 'True when several answers can be chosen together.' },
363 },
364 required: ['question', 'options'],
365 },
366 },
367 },
368 required: ['questions'],
369}
370
371export const INTERVIEW_DESCRIPTION = 'Ask the person multiple-choice questions in their recap pane, where they answer with a click. '
372 + 'Use it whenever you need their input before starting (instead of asking in plain text): goals, taste, priorities, never technical questions. '
373 + 'After calling it, end your turn without doing the work: their answers arrive as their next message.'
374
375/** Reads the model's questions; null when there is nothing askable. */
376export const parseQuestions = (input: unknown): Question[] | null => {
377 const questions = list((input as any)?.questions)
378 .map(q => {
379 const options = list(q?.options)
380 .map(o => (typeof o === 'string'
381 ? { label: plain(o, 60), hint: '', isRecommended: false }
382 : { label: plain(o?.label, 60), hint: plain(o?.hint, 120), isRecommended: o?.recommended === true }))
383 .filter(o => o.label)
384 .slice(0, 6)
385 return { question: plain(q?.question, 160), options, isMulti: q?.multi === true }
386 })
387 .filter(q => q.question && q.options.length >= 2)
388 .slice(0, 5)
389 return questions.length > 0 ? questions : null
390}
391
392export const newInterview = (questions: Question[], now: number): Interview => ({
393 id: `iv-${now.toString(36)}`,
394 questions,
395 answers: questions.map(() => []),
396 note: '',
397 status: 'open',
398})
399
400/** The answers after a click: a single-choice question swaps, a multi-choice one toggles; -1 is "you decide". */
401export const answer = (iv: Interview, qi: number, oi: number): Interview => {
402 const q = iv.questions[qi]
403 if (!q) return iv
404 const was = iv.answers[qi] ?? []
405 let now: number[]
406 if (oi === -1 || !q.isMulti) now = was.length === 1 && was[0] === oi ? [] : [oi]
407 else now = (was.includes(oi) ? was.filter(x => x !== oi) : [...was.filter(x => x !== -1), oi]).sort((a, b) => a - b)
408 return { ...iv, answers: iv.answers.map((a, i) => (i === qi ? now : a)) }
409}
410
411/** The reply the person sends: each question with what they chose; unanswered ones are left to Claude. */
412export const answersText = (iv: Interview): string => {
413 const lines = iv.questions.map((q, i) => {
414 const picked = iv.answers[i] ?? []
415 const said = picked.length === 0 || picked.includes(-1)
416 ? 'you decide'
417 : picked.map(oi => q.options[oi]?.label).filter(Boolean).join(', ')
418 return `${i + 1}. ${q.question}\n → ${said}`
419 })
420 const note = plain(iv.note, 500) ? `\n\nAlso: ${plain(iv.note, 500)}` : ''
421 return `My answers:\n\n${lines.join('\n')}${note}\n\nGo ahead, and make every technical decision yourself.`
422}
423
424/** One row of the session's history, as `$.session.messages()` hands it. */
425export type HistoryRow = {
426 role: 'user' | 'assistant'
427 text: string
428 toolUses?: { tool: string; input: Record<string, unknown> }[]
429 toolResults?: unknown[]
430}
431
432/**
433 * The chat as it stands, for the small model when the fork cannot answer: the
434 * person's recent requests, the steps taken since the first of them, and the
435 * assistant's last reply. Built from the real history, so a recap made with
436 * /recap (no turn recorded since the mod loaded) is still about the real work.
437 */
438export const digestFrom = (rows: readonly HistoryRow[]): { log: TurnLog; answer: string } => {
439 const recent = rows.slice(-80)
440 // A person's message is one with words and no tool results riding in it.
441 const asks = recent.filter(r => r.role === 'user' && r.text.trim() && !(r.toolResults?.length)).map(r => r.text)
442 let log: TurnLog = { ask: asks.slice(-3).map(a => one(a, 600)).join('\n…then: '), steps: [], edits: 0, research: 0, tools: 0 }
443 for (const r of recent) {
444 if (r.role !== 'assistant') continue
445 for (const u of r.toolUses ?? []) log = logTool(log, { tool: u.tool, ...u.input })
446 }
447 const answer = [...recent].reverse().find(r => r.role === 'assistant' && r.text.trim())?.text ?? ''
448 return { log, answer }
449}
450types/index.d.ts 109 lines1/** Did the turn reach the goal the person asked for. */
2export type Verdict = 'done' | 'in_progress' | 'blocked'
3
4/** The kinds of direction a next move can push the project in; an option can serve several. */
5export type Lane = 'fix' | 'debt' | 'feature' | 'design' | 'money' | 'growth' | 'ship' | 'wild'
6
7/** One possible next move. */
8export type Option = {
9 id: string
10 title: string
11 /** what it is and why it matters, in plain words */
12 detail: string
13 /** the directions it serves, most related first */
14 lanes: Lane[]
15 /** value if done, 1 to 5 */
16 impact: number
17 effort: 'S' | 'M' | 'L'
18 /** true when it turns on the person's own taste, ideas or business knowledge */
19 needsYou: boolean
20 /** the prompt that asks for it */
21 prompt: string
22}
23
24/** A ready-made bundle of options: take it whole, or save it for later. */
25export type Package = {
26 name: string
27 /** what the customer gets from it, in a sentence */
28 pitch: string
29 /** the options in it, by id, in the order they should be done */
30 ids: string[]
31}
32
33export type Recap = {
34 status: 'brewing' | 'ready' | 'error'
35 turnId: string
36 /** what the person asked for, as they typed it */
37 ask: string
38 tldr: string
39 verdict: Verdict
40 verdictWhy: string
41 options: Option[]
42 packages: Package[]
43 error: string
44 /** set when /recap shows the project's latest recap from an earlier chat */
45 origin?: 'earlier'
46}
47
48/** An option saved for later, kept per project across chats. */
49export type BacklogItem = Option & { savedAt: number; /** the package it was saved with, if any */ pack?: string }
50
51/** Where the work should end up. */
52export type Deploy = 'local' | 'staging' | 'production'
53
54/** Whether Claude interviews the person first or works on its own; `auto` decides per pick. */
55export type Autonomy = 'auto' | 'interview' | 'solo'
56
57/** The person's picks: option ids (from the recap or the backlog) in the order picked, and the lane in view. */
58export type Picks = { turnId: string; chosen: string[]; lane: Lane | 'all' }
59
60/** Settings that outlive a recap (kept in the plugin's store too, across sessions). */
61export type Prefs = { size: 1 | 2 | 3; deploy: Deploy; autonomy: Autonomy }
62
63/** Which tab of the pane is showing. */
64export type View = 'options' | 'backlog' | 'interview'
65
66/** One multiple-choice question Claude asked through the interview tool. */
67export type Question = {
68 question: string
69 options: { label: string; hint: string; isRecommended: boolean }[]
70 isMulti: boolean
71}
72
73/** Claude's questions and the person's answers so far. */
74export type Interview = {
75 id: string
76 questions: Question[]
77 /** per question, the indexes of the chosen options; -1 means "you decide" */
78 answers: number[][]
79 note: string
80 status: 'open' | 'sent'
81}
82
83/** How often each lane has been in what the person sent: the gentle ranking nudge. */
84export type Learned = Partial<Record<Lane, number>>
85
86/** The last turn worth a recap, kept so a new take works after a reload too. */
87export type Job = {
88 turnId: string
89 log: { ask: string; steps: string[]; edits: number; research: number; tools: number }
90 answer: string
91}
92
93declare module 'claude-code' {
94 interface PluginState {
95 recap: {
96 current: Recap | null
97 job: Job | null
98 picks: Picks | null
99 prefs: Prefs
100 backlog: BacklogItem[]
101 learned: Learned
102 interview: Interview | null
103 view: View
104 /** a plain warning for the pane header, such as a second copy of the mod */
105 warning: string | null
106 }
107 }
108}
109