SLOPSHOPPER

recap

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…

newpaneguardcommandtoasttool
v0.10.2MITupdated 2026-10-13lokki007/claude-code-mods/plugins/recap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · recap
│ ┃ ✨ Next ✕ › fix the failing auth test and add an audit log call │ ┃ 🦉 Nothing to plan yet [ A− ] [ A+ ] │ ⏺ 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 │ │ › /recap │ ⎿ recap: Brewing options for this chat… │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ✨ Next
🦉 Nothing to plan yet [ A− ] [ A+ ]
README

Claude Code mods

Two small mods for Claude Code that make it easier to see what Claude is doing and to decide what to do next.

ModWhat you get
recapAfter 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-statusA 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.

Install

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 install itself; 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).

recap: your next-prompt builder

The ✨ Next pane in the Claude desktop app: 17 next moves, color-coded by direction, with + Pick and ☆ Save on each

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:

  • One line of status: 💚 done, 🟡 in progress or 🟥 blocked, plus a one-sentence summary.
  • Many options, not just one: Claude looks at everything it saw (bugs, loose ends, shortcuts, missed chances), thinks about what your customers would value, and offers 28–40 next moves across eight directions:
DirectionDirection
🐞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 (🤖).

  • 80/20 ranking: the best value for the effort comes first; moves that help several directions at once rank higher, and the top three wear ⭐ 80/20. Click a direction to see only its options.
  • 📦 Packages: the options also come in 3 to 5 ready-made bundles, like "the 2 big ones", a handful of quick wins, or a dozen moves for a bigger push. Use this package picks the whole bundle (the prompt names it); ☆ Save for later sends it to the Saved tab under its name, and every new recap is told what you saved, so it brings back what still matters.
  • It learns, gently: after a few prompts it notices which directions you usually pick and nudges them up a little, never hiding the rest. Reset learning clears it.
  • Save for later: ☆ Save keeps an option in the Saved tab for that project, across chats, so you can pick from it any time (/recap opens it).
  • Build the prompt: press + Pick on a few options. The first is the main job, the rest ride along. Choose Where (keep it local, staging, production) and Questions:
  • Interview me first: Claude asks up to five plain-language multiple-choice questions in the pane; click your answers and press Send answers. You are never asked technical questions.
  • Just do it: no questions, Claude decides and tells you what it chose.
  • Auto: interviews you only when a pick needs your ideas.
  • Send it: Put in prompt, Add to my draft or Send now ▶. A− / A+ change the text size, and your choices are remembered.

It also keeps track for you:

  • Never left guessing: when a turn gets no pane, a short note says why ("nothing was changed, it was just a chat") and that /recap makes one anyway.
  • The latest recap per project: /recap in any chat of the same project shows it straight away; ↻ New options brews fresh ones for that chat.
  • A gentle reminder: open a chat on a project whose last piece of work was left 🟡 or 🟥, and the owl names it with one next step.
  • A progress diary per project: each finished recap adds a plain, dated entry (what was done, what was asked, status) you can share with clients. /recap diary shows where it is.
  • A log for debugging: every attempt, whether it worked and why, in ~/.claude/recap/log.jsonl (/recap log).
  • A plain warning if two copies of the mod end up loaded in one chat, instead of failing quietly. turn-status does the same.

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.

turn-status: a quiet live status band

While Claude works, one row above the prompt shows:

⟳ Building the status band   Running npm test   1:46   ▬▬▬▬▬ 1/5   ● 1 failed
  • what Claude is doing in plain words (the current task when it keeps a task list, else "Editing svg.ts", "Running npm test", "Thinking")
  • a timer whose digits roll over like an odometer
  • task progress, one segment per task, when there is a task list
  • a red count when a tool fails

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.

For tinkerers

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.

License

MIT

Source 5 files
hooks/register.tsx 693 lines
1import { 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}
693
hooks/owl.ts 61 lines
1// 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}
61
hooks/journal.ts 97 lines
1import 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.'
97
hooks/recap.ts 450 lines
1import 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}
450
types/index.d.ts 109 lines
1/** 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