SLOPSHOPPER

prompt-coach

A sidebar that reviews each prompt, tracks your prompting habits, and improves your draft before you send it

newpanebandguardcommandtoast
v0.6.1no licenseupdated 2026-10-16OlivierAlter/prompt-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-coach
│ ┃ Prompt coach ✕ › fix the failing auth test and add an audit log call │ ┃ Prompt coach r: view-review h: view-habits │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ Welcome to Prompt coach │ ⏺ Bash(bun test) │ ┃ │ After each answer, the coach reviews the │ ⎿ 3 pass, 1 fail │ ┃ │ prompt you sent and suggests a better │ │ ┃ │ one. That is one sonnet call, about half │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ a cent; short follow-ups like "yes" are │ │ ┃ │ skipped. │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ u uses the better prompt · i improves │ │ ┃ │ your draft before you send · h shows │ › /prompt-coach │ ┃ │ your habits. Press ctrl+x tab to give │ ⎿ prompt-coach: opened. │ ┃ │ the sidebar the keys. │ │ ┃ │ [ Got it ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ #1 · 42s · 9 calls · 99k tok │ ┃ “fix the failing auth test and add an audit │ ┃ log call” │ ┃ │ ┃ The coach replied in a shape it could not │ ┃ read. │ ┃ [ Review it ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt coach
Prompt coach r: view-review h: view-habits ──────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Welcome to Prompt coach │ │ After each answer, the coach reviews the prompt you sent │ │ and suggests a better one. That is one sonnet call, │ │ about half a cent; short follow-ups like "yes" are │ │ skipped. │ │ u uses the better prompt · i improves your draft before │ │ you send · h shows your habits. Press ctrl+x tab to give │ │ the sidebar the keys. │ │ [ Got it ] │ ╰──────────────────────────────────────────────────────────╯ #1 · 42s · 9 calls · 99k tok “fix the failing auth test and add an audit log call” The coach replied in a shape it could not read. [ Review it ] Trail · p: app (show all) ▸ ──────────────────────────────────────────────────────── ctrl+x tab to use keys i: Improve draft
README

Prompt coach

A Claude Code mod that helps you write better prompts. A sidebar keeps a trail of your prompts and Claude's answers. After each answer it reviews the prompt you sent, says the one change that would have helped most, and offers a better version you can use with one key.

It has two goals:

  1. Habits. You pick up the habits that make Claude Code work well, and keep them.
  2. Results. Each prompt gets a better result, ideally before you send it.

The prompt coach sidebar reviewing a prompt: the next step, a better prompt with the added words in green, and the habits it missed

The screenshots use a made-up project, acme-shop.

Install

At the prompt of a Claude Code terminal session:

/plugin install prompt-coach --marketplace OlivierAlter/prompt-coach

Answer y to add the marketplace, then pick a scope (user scope is first; press Enter).

From a shell:

claude plugin install prompt-coach --marketplace OlivierAlter/prompt-coach

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, then Add marketplace with OlivierAlter/prompt-coach, then Add plugin.

What it does

  • Reviews each prompt after Claude answers. The review leads with a → next step, then a better prompt shown as a diff of yours, then the reasons. Short follow-ups like "yes" or "go on" are skipped, and you can still press Review it on one.
  • Tracks seven habits: target, goal, evidence, verify, constraints, scope and plan. The Habits view shows your hit rate and trend for each habit, and picks one to focus on. Keep your focus habit for 3 prompts in a row and it graduates (★).

The Habits view: a focus habit, a hit-rate bar for each habit, and a CLAUDE.md suggestion

  • Improves your draft before you send it. Missing facts become [placeholders] rather than guesses, and Undo puts your draft back.
  • Checks your draft as you type, with no model call. It flags a draft that names no file, pastes no error, gives Claude no way to check its work, or asks for a big change without a plan.

The draft check above the prompt box: "no file named · big change: ask for a plan", with an Improve draft button

  • Suggests CLAUDE.md lines. When you give the same standing instruction twice ("use pnpm, not npm"), it offers to have Claude add it to CLAUDE.md.
  • Learns from your ratings. Mark a review Helpful (y) or Not helpful (n), and the coach takes your recent ratings into account.

History is kept per project; press p to show all projects. Habits are counted across all projects.

Keys

When the sidebar has focus (press ctrl+x tab to give it the keys):

KeyAction
uUse the better prompt (fills the prompt box)
cCopy the better prompt
iImprove the draft in the prompt box
h / rHabits view / Review view
j / kNewer / older prompt
vReview it, or Retry a slow review
y / nRate the review Helpful / Not helpful
pThis project or all projects
eExpand a quoted prompt on a narrow sidebar

The /prompt-coach command opens the sidebar. /prompt-coach habits opens the Habits view, and /prompt-coach clear clears the history.

Settings and cost

SettingDefault
modelsonnetThe model that reviews prompts and improves drafts: sonnet, haiku or opus
autoReviewtrueReview every answered prompt. Turn it off to review only when you press Review it

Each review is one model call, about half a cent with Sonnet. Improve draft costs one call per press. The draft check as you type is free.

Privacy

The coach sends your prompt, the end of Claude's answer, and a list of the tools Claude used to the model you chose, through your own Claude Code session. History is saved on your machine in Claude Code's plugin store and is sent nowhere else.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

The review logic is in hooks/coach.ts, the hooks and sidebar are in hooks/register.tsx, and the state contract is in types/index.d.ts. See ROADMAP.md for what has shipped and what is next.

Source 3 files
hooks/register.tsx 947 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CoachEntry, HabitId } from '../types'
5import {
6  average,
7  buildImprovePrompt,
8  buildPrompt,
9  claudeMdPrompt,
10  cleanRewrite,
11  diffWords,
12  formatTokens,
13  habitName,
14  habitStats,
15  IMPROVE_SYSTEM,
16  isTrivial,
17  knownRules,
18  lintDraft,
19  oneLine,
20  parseReview,
21  placeholders,
22  relTime,
23  ruleSuggestions,
24  sessionLabel,
25  stepLine,
26  streak,
27  STREAK_TO_GRADUATE,
28  SYSTEM,
29  weakHabits,
30} from './coach'
31
32const PANE = 'prompt-coach'
33const TITLE = 'Prompt coach'
34const KEEP = 300
35const STORE_KEY = 'history'
36const DISMISSED_KEY = 'dismissedRules'
37const WELCOMED_KEY = 'welcomed'
38const GRADUATED_KEY = 'graduated'
39// A review running longer than this offers Retry.
40const SLOW_MS = 30_000
41// Below this many columns the sidebar stacks and shortens.
42const NARROW = 45
43
44const ENTRIES = atom({ plugin: 'prompt-coach', key: 'entries' } as const, [])
45const SELECTED = atom({ plugin: 'prompt-coach', key: 'selected' } as const, null)
46const VIEW = atom({ plugin: 'prompt-coach', key: 'view' } as const, 'review')
47const SCOPE = atom({ plugin: 'prompt-coach', key: 'scope' } as const, 'project')
48const DISMISSED = atom({ plugin: 'prompt-coach', key: 'dismissedRules' } as const, [])
49const DRAFT = atom({ plugin: 'prompt-coach', key: 'draft' } as const, null)
50const BOX = atom({ plugin: 'prompt-coach', key: 'box' } as const, { hasText: false, hints: [] })
51const PANE_OPEN = atom({ plugin: 'prompt-coach', key: 'paneOpen' } as const, false)
52const BAND_HIDDEN = atom({ plugin: 'prompt-coach', key: 'bandHidden' } as const, false)
53const EXPANDED = atom({ plugin: 'prompt-coach', key: 'expanded' } as const, false)
54const WELCOMED = atom({ plugin: 'prompt-coach', key: 'welcomed' } as const, false)
55const GRADUATED = atom({ plugin: 'prompt-coach', key: 'graduated' } as const, [])
56const TICK = atom({ plugin: 'prompt-coach', key: 'tick' } as const, 0)
57
58// The prompt of the turn now running, and what it ran. Module variables:
59// a reload mid-turn only loses that one turn's review.
60let pending: { prompt: string; tools: string[]; steps: string[] } | null = null
61let counter = 0
62// The session's directory and start, read at its start; entries carry both.
63let project = ''
64let sessionStart = 0
65// The latest attempt per entry: a retried review's older reply is dropped.
66const attempts = new Map<string, number>()
67// Redraws the elapsed time while a review runs.
68let ticker: Timer | null = null
69// What the box last held, so a keystroke that changes nothing writes nothing.
70let lastBoxKey = 'empty'
71
72// Set from the plugin's options each time register runs.
73let model = 'sonnet'
74let isAutoReview = true
75
76const scoreColor = (score: number) => (score >= 4 ? 'green' : 'yellow')
77const bar = (rate: number, width = 10) => {
78  const full = Math.round(rate * width)
79
80  return '█'.repeat(full) + '░'.repeat(width - full)
81}
82const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
83const projectName = (dir: string) => dir.split('/').filter(Boolean).pop() ?? dir
84const isCommandLine = (text: string) => text.startsWith('/') || text.startsWith('!')
85
86async function save($: EngineInterface, entries: CoachEntry[]) {
87  await $.store.set(
88    STORE_KEY,
89    entries.filter(e => e.status !== 'reviewing').slice(-KEEP),
90  )
91}
92
93async function openPane($: EngineInterface) {
94  const r = await $.ui.open({ id: PANE, title: TITLE })
95  await update($, PANE_OPEN, () => r.isPlaced)
96}
97
98async function tickOnce($: EngineInterface) {
99  const isReviewing = (await read($, ENTRIES)).some(e => e.status === 'reviewing')
100  if (!isReviewing) {
101    ticker?.cancel()
102    ticker = null
103
104    return
105  }
106  const now = await $.clock.now()
107  await update($, TICK, () => now)
108}
109
110function startTicker($: EngineInterface) {
111  if (!ticker) {
112    ticker = $.clock.every(1000, () =>
113      // A module the host unloaded can no longer write: stop asking.
114      void tickOnce($).catch(() => {
115        ticker?.cancel()
116        ticker = null
117      }),
118    )
119  }
120}
121
122async function review($: EngineInterface, id: string) {
123  const attempt = (attempts.get(id) ?? 0) + 1
124  attempts.set(id, attempt)
125  const startedAt = await $.clock.now()
126  const all = await update($, ENTRIES, list =>
127    list.map(e => (e.id === id ? { ...e, status: 'reviewing' as const, reviewStartedAt: startedAt } : e)),
128  )
129  await update($, TICK, () => startedAt)
130  startTicker($)
131  const at = all.findIndex(e => e.id === id)
132  const entry = all[at]
133  if (!entry) return
134  const earlier = all.slice(0, at)
135  const sameProject = earlier.filter(e => e.project === entry.project)
136  const graduated = await read($, GRADUATED)
137  const [focus] = weakHabits(earlier, 1, graduated)
138  let patch: Partial<CoachEntry>
139  let previousUnclear = ''
140  try {
141    const r = await $.model.complete({
142      model,
143      system: [{ text: SYSTEM, cache: true }],
144      prompt: buildPrompt(entry, sameProject, { focus, rules: knownRules(sameProject), ratings: all.filter(e => e.rating) }),
145      maxTokens: 1000,
146      effort: 'low',
147      timeoutMs: 90_000,
148    })
149    const parsed = r.isAnswered ? parseReview(r.text) : undefined
150    if (parsed) {
151      const { previousUnclear: note, ...rest } = parsed
152      previousUnclear = note
153      patch = { status: 'done', error: undefined, ...rest }
154    } else {
155      patch = {
156        status: 'failed',
157        error: r.isAnswered ? 'The coach replied in a shape it could not read.' : `No review: ${r.reason}`,
158      }
159    }
160  } catch (err) {
161    patch = { status: 'failed', error: errorText(err) }
162  }
163  // A retry started since: its reply is the one that counts.
164  if (attempts.get(id) !== attempt) return
165  const previous = sameProject[sameProject.length - 1]
166  const next = await update($, ENTRIES, list =>
167    list.map(e => {
168      if (e.id === id) return { ...e, ...patch, reviewStartedAt: undefined }
169      if (previousUnclear && previous && e.id === previous.id) return { ...e, followup: previousUnclear }
170
171      return e
172    }),
173  )
174  await save($, next)
175
176  // Kept the focus habit enough prompts in a row: it graduates.
177  if (focus && patch.habits?.[focus] === 'shown' && streak(next, focus) >= STREAK_TO_GRADUATE) {
178    const grads = await update($, GRADUATED, list => (list.includes(focus) ? list : [...list, focus]))
179    await $.store.set(GRADUATED_KEY, grads)
180    const [nextFocus] = weakHabits(next, 1, grads)
181    $.ui.toast(
182      `Coach: ${STREAK_TO_GRADUATE} in a row: "${habitName(focus)}" is a habit now.${nextFocus ? ` Next focus: ${habitName(nextFocus)}.` : ''}`,
183    )
184  }
185
186  // Say so once, when an instruction first repeats: it belongs in CLAUDE.md.
187  const given = new Set((patch.rules ?? []).map(r => r.toLowerCase()))
188  if (given.size > 0) {
189    const dismissed = await read($, DISMISSED)
190    const repeated = ruleSuggestions(next.filter(e => e.project === entry.project), dismissed).find(
191      s => s.count === 2 && given.has(s.text.toLowerCase()),
192    )
193    if (repeated) $.ui.toast(`Coach: you've asked "${repeated.text}" twice. Habits view can add it to CLAUDE.md.`)
194  }
195}
196
197async function rate($: EngineInterface, id: string, rating: 'up' | 'down') {
198  const next = await update($, ENTRIES, list =>
199    list.map(e => (e.id === id ? { ...e, rating: e.rating === rating ? undefined : rating } : e)),
200  )
201  await save($, next)
202}
203
204async function dismissRule($: EngineInterface, text: string) {
205  const next = await update($, DISMISSED, list => [...list, text.toLowerCase()])
206  await $.store.set(DISMISSED_KEY, next)
207}
208
209async function dismissWelcome($: EngineInterface) {
210  await update($, WELCOMED, () => true)
211  await $.store.set(WELCOMED_KEY, true)
212}
213
214// Puts a review's better prompt in the box; a draft it replaces can come back.
215async function useBetter($: EngineInterface, text: string) {
216  const box = await $.prompt.read()
217  const filled = await $.prompt.fill({ text })
218  const kept = box.text.trim()
219  if (filled.isFilled && kept !== '' && kept !== text.trim()) {
220    await update($, DRAFT, () => ({ phase: 'improved' as const, original: box.text, source: 'use' as const }))
221  }
222}
223
224async function improveDraft($: EngineInterface) {
225  if ((await read($, DRAFT))?.phase === 'improving') return
226  const box = await $.prompt.read()
227  const draft = box.text.trim()
228  if (draft === '' || isCommandLine(draft)) {
229    await update($, DRAFT, () => ({ phase: 'failed' as const, error: 'Type a prompt in the box first, then press Improve draft.' }))
230
231    return
232  }
233  await update($, DRAFT, () => ({ phase: 'improving' as const }))
234  const entries = await read($, ENTRIES)
235  const here = entries.filter(e => e.project === project)
236  const graduated = await read($, GRADUATED)
237  try {
238    const r = await $.model.complete({
239      model,
240      system: [{ text: IMPROVE_SYSTEM, cache: true }],
241      prompt: buildImprovePrompt(draft, here, weakHabits(entries, 2, graduated)),
242      maxTokens: 1500,
243      effort: 'low',
244      timeoutMs: 90_000,
245    })
246    const improved = r.isAnswered ? cleanRewrite(r.text) : ''
247    if (!r.isAnswered || improved === '') {
248      await update($, DRAFT, () => ({
249        phase: 'failed' as const,
250        error: r.isAnswered ? 'The coach returned an empty draft.' : `Could not improve the draft: ${r.reason}`,
251      }))
252
253      return
254    }
255    // Never overwrite what the person typed while the coach worked.
256    if ((await $.prompt.read()).text.trim() !== draft) {
257      await update($, DRAFT, () => ({ phase: 'failed' as const, error: 'Your draft changed meanwhile; press Improve draft again.' }))
258
259      return
260    }
261    const filled = await $.prompt.fill({ text: improved })
262    await update($, DRAFT, () =>
263      filled.isFilled
264        ? { phase: 'improved' as const, original: box.text, source: 'improve' as const }
265        : { phase: 'failed' as const, error: 'The prompt box could not take the draft.' },
266    )
267  } catch (err) {
268    await update($, DRAFT, () => ({ phase: 'failed' as const, error: errorText(err) }))
269  }
270}
271
272async function undoDraft($: EngineInterface) {
273  const draft = await read($, DRAFT)
274  if (draft?.phase === 'improved') await $.prompt.fill({ text: draft.original })
275  await update($, DRAFT, () => null)
276}
277
278async function noteBox($: EngineInterface, text: string) {
279  const t = text.trim()
280  const hasText = t !== '' && !isCommandLine(t)
281  const hints = hasText ? lintDraft(t) : []
282  const key = hasText ? `text:${hints.join('|')}` : 'empty'
283  if (key === lastBoxKey) return
284  lastBoxKey = key
285  await update($, BOX, () => ({ hasText, hints }))
286}
287
288export const register: Register = (on, options) => {
289  model = typeof options.model === 'string' && options.model.trim() !== '' ? options.model : 'sonnet'
290  isAutoReview = options.autoReview !== false
291
292  on('session.start', async ($, e, next) => {
293    const r = await next(e)
294    project = e.cwd
295    sessionStart = await $.clock.now()
296    await $.command.register({
297      name: 'prompt-coach',
298      description: 'Open the prompt coach (args: habits, clear)',
299    })
300    if ((await read($, ENTRIES)).length === 0) {
301      const saved = await $.store.get(STORE_KEY)
302      if (Array.isArray(saved)) await update($, ENTRIES, () => saved as CoachEntry[])
303      const dismissed = await $.store.get(DISMISSED_KEY)
304      if (Array.isArray(dismissed)) await update($, DISMISSED, () => dismissed.filter((d): d is string => typeof d === 'string'))
305      const graduated = await $.store.get(GRADUATED_KEY)
306      if (Array.isArray(graduated)) await update($, GRADUATED, () => graduated as HabitId[])
307      if ((await $.store.get(WELCOMED_KEY)) === true) await update($, WELCOMED, () => true)
308    }
309    // A reload drops the reviews the old module had in flight, and their
310    // entries would read "Reviewing…" for good: run the recent ones again.
311    const stuck = (await read($, ENTRIES)).filter(x => x.status === 'reviewing')
312    const retry = new Set(stuck.slice(-3).map(x => x.id))
313    if (stuck.length > retry.size) {
314      await update($, ENTRIES, list =>
315        list.map(x =>
316          x.status === 'reviewing' && !retry.has(x.id)
317            ? { ...x, status: 'failed' as const, error: 'The review was cut off by a reload. Press Review it.' }
318            : x,
319        ),
320      )
321    }
322    for (const id of retry) void review($, id)
323    if (e.isInteractive) void openPane($)
324
325    return r
326  })
327
328  on('command.run', { command: 'prompt-coach' }, async ($, e) => {
329    const arg = e.args.trim()
330    if (arg === 'clear') {
331      await update($, ENTRIES, () => [])
332      await update($, SELECTED, () => null)
333      await $.store.delete(STORE_KEY)
334
335      return { text: 'history cleared.' }
336    }
337    await update($, VIEW, () => (arg === 'habits' ? 'habits' : 'review'))
338    await update($, BAND_HIDDEN, () => false)
339    await openPane($)
340
341    return { text: arg === 'habits' ? 'showing your habits.' : 'opened.' }
342  })
343
344  on('ui.close', { id: PANE }, async ($, e, next) => {
345    await update($, PANE_OPEN, () => false)
346
347    return next(e)
348  })
349
350  // Typing reaches the box as prompt.edit; prompt.change carries the rest
351  // (history recall, a fill). The check runs after the key lands, unawaited.
352  on('prompt.edit', async ($, e, next) => {
353    const r = await next(e)
354    void noteBox($, r.text)
355
356    return r
357  })
358
359  on('prompt.change', async ($, e, next) => {
360    const r = await next(e)
361    await noteBox($, r.text)
362
363    return r
364  })
365
366  on('prompt.submit', async ($, e, next) => {
367    const isYours = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
368    if (isYours && !e.command && !e.isMeta && e.text.trim() !== '') {
369      // A prompt typed while a turn runs joins that turn's prompt.
370      if (e.turnId && pending) pending.prompt += `\n\n${e.text}`
371      else pending = { prompt: e.text, tools: [], steps: [] }
372      if (await read($, DRAFT)) await update($, DRAFT, () => null)
373    }
374    await noteBox($, '')
375
376    return next(e)
377  })
378
379  on('tool.call', async ($, e, next) => {
380    if (pending && !e.agentId) {
381      pending.tools.push(e.tool)
382      pending.steps.push(stepLine(e.tool, e as unknown as Record<string, unknown>))
383    }
384
385    return next(e)
386  })
387
388  on('turn.complete', async ($, e, next) => {
389    const r = await next(e)
390    if (e.agentId || !pending) return r
391    const turn = pending
392    pending = null
393    const interrupted = e.reason === 'aborted'
394    if (e.reason !== 'answer' && !interrupted) return r
395    // An interrupt before Claude did anything leaves nothing to learn from.
396    if (interrupted && turn.tools.length === 0 && e.answer.trim() === '') return r
397
398    const now = await $.clock.now()
399    if (!project) project = await $.session.cwd().catch(() => '')
400    const u = e.usage
401    const tokens = u ? u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
402    const isSkipped = !isAutoReview || (!interrupted && isTrivial(turn.prompt, turn.tools.length))
403    const entry: CoachEntry = {
404      id: `${now}-${counter++}`,
405      at: now,
406      prompt: turn.prompt,
407      answer: e.answer,
408      tools: turn.tools,
409      steps: turn.steps,
410      durationMs: e.durationMs,
411      status: isSkipped ? 'skipped' : 'reviewing',
412      project,
413      ...(sessionStart ? { session: sessionStart } : {}),
414      ...(tokens > 0 ? { tokens } : {}),
415      ...(interrupted ? { interrupted: true } : {}),
416    }
417    const list = await update($, ENTRIES, l => [...l, entry].slice(-KEEP))
418    await update($, SELECTED, () => null)
419    if (isSkipped) await save($, list)
420    else void review($, entry.id)
421
422    return r
423  })
424
425  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
426    if (e.props.hasSurvey) return next(e)
427    const [entries, draft, box, isPaneOpen, isHidden, graduated] = await Promise.all([
428      read($, ENTRIES),
429      read($, DRAFT),
430      read($, BOX),
431      read($, PANE_OPEN),
432      read($, BAND_HIDDEN),
433      read($, GRADUATED),
434    ])
435    const { Box, Text, Button } = $.ui.resolve(e)
436
437    // What a press just did comes first, hidden or not: it holds the Undo.
438    if (draft?.phase === 'improving') return <Text dimColor>Coach: improving your draft…</Text>
439    if (draft?.phase === 'improved') {
440      return (
441        <Box flexDirection="row" gap={1}>
442          <Text color="green">
443            {draft.source === 'use' ? 'Coach: replaced your draft with the better prompt.' : 'Coach: draft improved.'}
444          </Text>
445          {draft.source === 'improve' ? <Text dimColor>Fill in any [placeholders], then send.</Text> : null}
446          <Button key="undo-draft" hotkey="u" onPress={() => void undoDraft($)}>
447            Undo
448          </Button>
449        </Box>
450      )
451    }
452    if (draft?.phase === 'failed') {
453      return (
454        <Box flexDirection="row" gap={1}>
455          <Text color="yellow">Coach: {draft.error}</Text>
456          <Button key="dismiss-draft" hotkey="x" onPress={() => void update($, DRAFT, () => null)}>
457            Dismiss
458          </Button>
459        </Box>
460      )
461    }
462    if (isHidden) return next(e)
463
464    const hide = (
465      <Button key="hide-band" plain dimColor onPress={() => void update($, BAND_HIDDEN, () => true)}>
466        ✕
467      </Button>
468    )
469
470    // A draft in the box: check it as it is typed, and offer to improve it.
471    if (box.hasText) {
472      return (
473        <Box flexDirection="row" gap={1}>
474          {box.hints.length > 0 ? (
475            <Text>
476              <Text dimColor>Draft:</Text> <Text color="yellow">{box.hints.join(' · ')}</Text>
477            </Text>
478          ) : (
479            <Text dimColor>Draft looks clear.</Text>
480          )}
481          <Button key="improve-draft" hotkey="i" onPress={() => void improveDraft($)}>
482            Improve draft
483          </Button>
484          {hide}
485        </Box>
486      )
487    }
488
489    const avg = average(entries)
490    const [focus] = weakHabits(entries, 1, graduated)
491    if (avg === undefined && !focus && isPaneOpen) return next(e)
492
493    return (
494      <Box flexDirection="row" gap={1}>
495        <Text dimColor>
496          Coach{avg === undefined ? '' : ` · avg ${avg.toFixed(1)}/5`}
497          {focus ? ' · focus: ' : ''}
498        </Text>
499        {focus ? (
500          <Text color="yellow">
501            {habitName(focus)}
502            <Text dimColor>
503              {' '}
504              {streak(entries, focus)}/{STREAK_TO_GRADUATE}
505            </Text>
506          </Text>
507        ) : null}
508        {isPaneOpen ? null : (
509          <Button key="open-coach" hotkey="o" dimColor onPress={() => void openPane($)}>
510            Open
511          </Button>
512        )}
513        {hide}
514      </Box>
515    )
516  })
517
518  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
519    const { Box, Text, Button } = $.ui.resolve(e)
520    const [all, selectedId, view, scope, dismissed, isExpanded, isWelcomed, graduated, tick] = await Promise.all([
521      read($, ENTRIES),
522      read($, SELECTED),
523      read($, VIEW),
524      read($, SCOPE),
525      read($, DISMISSED),
526      read($, EXPANDED),
527      read($, WELCOMED),
528      read($, GRADUATED),
529      read($, TICK),
530    ])
531    const now = Math.max(tick, await $.clock.now())
532    const width = Math.max(20, e.props.bodyColumns ?? (e.viewport?.columns ?? 50) - 2)
533    const rows = e.viewport?.rows ?? 30
534    const isNarrow = width < NARROW
535    const here = all.filter(x => x.project === project)
536    const avg = average(all)
537    const reviewed = all.filter(x => x.status === 'done').length
538    const [focus] = weakHabits(all, 1, graduated)
539    const rules = ruleSuggestions(here, dismissed)
540    const habitsBadge = (focus ? 1 : 0) + rules.length
541
542    const tab = (key: string, hotkey: string, label: string, isActive: boolean, badge = 0) => (
543      <Button key={key} hotkey={hotkey} plain onPress={() => void update($, VIEW, () => (key === 'view-habits' ? 'habits' : 'review') as 'habits' | 'review')}>
544        {isActive ? (
545          <Text bold underline>
546            {label}
547          </Text>
548        ) : (
549          <Text dimColor>{label}</Text>
550        )}
551        {badge > 0 ? <Text color="yellow">·{badge}</Text> : ''}
552      </Button>
553    )
554
555    const header = (
556      <Box flexDirection="column">
557        <Box flexDirection="row" justifyContent="space-between">
558          <Text bold>{TITLE}</Text>
559          <Box flexDirection="row" gap={2}>
560            {tab('view-review', 'r', 'Review', view === 'review')}
561            {tab('view-habits', 'h', 'Habits', view === 'habits', habitsBadge)}
562          </Box>
563        </Box>
564        <Text dimColor>{'─'.repeat(Math.min(width, 60))}</Text>
565      </Box>
566    )
567
568    const footer = (
569      <Box flexDirection="column">
570        <Text dimColor>{'─'.repeat(Math.min(width, 60))}</Text>
571        <Box flexDirection="row" gap={1} flexWrap="wrap">
572          {e.props.isFocused ? (
573            <Text dimColor wrap="wrap">
574              {view === 'review'
575                ? 'u use · c copy · j/k move · e expand · y/n rate · i improve · h habits'
576                : 'a add rule · d dismiss · i improve · r review'}
577            </Text>
578          ) : (
579            <Text dimColor>ctrl+x tab to use keys</Text>
580          )}
581          <Button key="improve" hotkey="i" plain dimColor onPress={() => void improveDraft($)}>
582            Improve draft
583          </Button>
584        </Box>
585      </Box>
586    )
587
588    const welcome = isWelcomed ? null : (
589      <Box flexDirection="column" borderStyle="round" paddingX={1}>
590        <Text bold>Welcome to Prompt coach</Text>
591        <Text wrap="wrap">
592          After each answer, the coach reviews the prompt you sent and suggests a better one. That is one {model} call, about
593          half a cent; short follow-ups like "yes" are skipped.
594        </Text>
595        <Text wrap="wrap">
596          <Text bold>u</Text> uses the better prompt · <Text bold>i</Text> improves your draft before you send ·{' '}
597          <Text bold>h</Text> shows your habits. Press ctrl+x tab to give the sidebar the keys.
598        </Text>
599        <Button key="welcome-ok" hotkey="g" onPress={() => void dismissWelcome($)}>
600          Got it
601        </Button>
602      </Box>
603    )
604
605    if (all.length === 0) {
606      return (
607        <Box flexDirection="column" gap={1}>
608          {header}
609          {welcome}
610          <Text dimColor wrap="wrap">
611            Send a prompt. After Claude answers, a review of your prompt and a better version of it appear here.
612          </Text>
613          {footer}
614        </Box>
615      )
616    }
617
618    if (view === 'habits') {
619      const stats = habitStats(all)
620      const focusStat = stats.find(s => s.id === focus)
621      const focusStreak = focus ? streak(all, focus) : 0
622      const saving =
623        focusStat?.tokensShown !== undefined && focusStat.tokensMissed !== undefined && focusStat.tokensMissed > focusStat.tokensShown
624          ? Math.round((1 - focusStat.tokensShown / focusStat.tokensMissed) * 100)
625          : undefined
626
627      return (
628        <Box flexDirection="column" gap={1}>
629          {header}
630          {welcome}
631          {focusStat ? (
632            <Box flexDirection="column">
633              <Text bold color="yellow">
634                Focus: {focusStat.name}
635              </Text>
636              <Text wrap="wrap">{focusStat.advice}</Text>
637              <Text>
638                <Text color="green">{'●'.repeat(focusStreak)}</Text>
639                <Text dimColor>{'○'.repeat(Math.max(0, STREAK_TO_GRADUATE - focusStreak))}</Text>
640                <Text dimColor>
641                  {' '}
642                  {focusStreak === 0
643                    ? `keep it ${STREAK_TO_GRADUATE} prompts in a row to make it a habit`
644                    : `${STREAK_TO_GRADUATE - focusStreak} more in a row to make it a habit`}
645                </Text>
646              </Text>
647              {saving !== undefined && saving >= 10 ? (
648                <Text color="green" wrap="wrap">
649                  Your turns used {saving}% fewer tokens when you did this.
650                </Text>
651              ) : null}
652            </Box>
653          ) : (
654            <Text dimColor wrap="wrap">
655              {reviewed < 3 ? 'A few more reviewed prompts and your habits show here.' : 'No weak habit right now. Keep it up.'}
656            </Text>
657          )}
658          <Box flexDirection="column">
659            <Text dimColor>
660              Last {Math.min(30, reviewed)} reviewed{avg === undefined ? '' : ` · avg ${avg.toFixed(1)}/5`}
661            </Text>
662            {stats.map(s => (
663              <Text>
664                <Text color={s.rate === undefined ? undefined : s.rate >= 0.8 ? 'green' : 'yellow'}>
665                  {s.rate === undefined ? '░'.repeat(isNarrow ? 6 : 10) : bar(s.rate, isNarrow ? 6 : 10)}
666                </Text>{' '}
667                {s.id === focus ? <Text bold>{s.name}</Text> : s.name}
668                <Text dimColor>
669                  {s.rate === undefined
670                    ? '  n/a'
671                    : `  ${s.shown}/${s.shown + s.missed}${s.trend === undefined ? '' : s.trend > 0.1 ? ' ↑' : s.trend < -0.1 ? ' ↓' : ' →'}`}
672                </Text>
673                {graduated.includes(s.id) ? <Text color="green"> ★</Text> : ''}
674              </Text>
675            ))}
676            {graduated.length > 0 ? <Text dimColor>★ kept 3 in a row: a habit now</Text> : null}
677          </Box>
678          {rules.length > 0 && (
679            <Box flexDirection="column">
680              <Text bold>You keep repeating these: add them to CLAUDE.md</Text>
681              {rules.slice(0, 4).map((r, i) => (
682                <Box flexDirection="column">
683                  <Text wrap="wrap">
684                    “{r.text}” <Text dimColor>· {r.count} prompts</Text>
685                  </Text>
686                  <Box flexDirection={isNarrow ? 'column' : 'row'} gap={isNarrow ? 0 : 1}>
687                    <Button
688                      key={`rule-add-${i}`}
689                      hotkey={i === 0 ? 'a' : undefined}
690                      onPress={() => void $.prompt.fill({ text: claudeMdPrompt(r.text) })}
691                    >
692                      Ask Claude to add it
693                    </Button>
694                    <Button key={`rule-dismiss-${i}`} hotkey={i === 0 ? 'd' : undefined} dimColor onPress={() => void dismissRule($, r.text)}>
695                      Dismiss
696                    </Button>
697                  </Box>
698                </Box>
699              ))}
700            </Box>
701          )}
702          {footer}
703        </Box>
704      )
705    }
706
707    const entries = scope === 'project' && here.length > 0 ? here : all
708    const found = selectedId === null ? -1 : entries.findIndex(x => x.id === selectedId)
709    const index = found < 0 ? entries.length - 1 : found
710    const shown = entries[index] ?? entries[entries.length - 1]!
711    const select = (i: number) => {
712      const target = entries[i]
713      if (target) void update($, SELECTED, () => (i === entries.length - 1 ? null : target.id))
714    }
715    const missed = Object.entries(shown.habits ?? {}).filter(([, m]) => m === 'missed').map(([id]) => id as HabitId)
716    const kept = Object.values(shown.habits ?? {}).filter(m => m === 'shown').length
717    const isClear = shown.status === 'done' && (shown.score ?? 0) >= 5 && !shown.better
718    const headline = shown.nextStep || shown.feedback?.[0] || ''
719    const points = (shown.feedback ?? []).filter(f => f !== headline)
720    const quote = oneLine(shown.prompt)
721    const quoteMax = isNarrow && !isExpanded ? (width - 2) * 2 : isExpanded ? 4000 : 400
722    const elapsed = shown.reviewStartedAt !== undefined ? Math.max(0, now - shown.reviewStartedAt) : 0
723
724    // The trail takes the rows the detail leaves free.
725    const room = Math.max(3, rows - (isClear || shown.status !== 'done' ? 16 : 30))
726    const start = Math.max(0, Math.min(index - Math.floor(room / 2), entries.length - room))
727    const trail = entries.slice(start, start + room)
728
729    const actions = shown.status === 'done' && shown.better ? (
730      <Box flexDirection={isNarrow ? 'column' : 'row'} gap={isNarrow ? 0 : 1}>
731        <Button key="use" hotkey="u" variant="primary" onPress={() => void useBetter($, shown.better ?? '')}>
732          Use it
733        </Button>
734        <Button key="copy" hotkey="c" onPress={press => void $.ui.copy({ text: shown.better ?? '', surface: press.surface })}>
735          Copy
736        </Button>
737      </Box>
738    ) : null
739
740    return (
741      <Box flexDirection="column" gap={1}>
742        {header}
743        {welcome}
744
745        <Box flexDirection="column">
746          <Box flexDirection="row" justifyContent="space-between">
747            <Text dimColor>
748              #{index + 1} · {Math.round(shown.durationMs / 1000)}s · {shown.tools.length} call{shown.tools.length === 1 ? '' : 's'}
749              {shown.tokens && !isNarrow ? ` · ${formatTokens(shown.tokens)} tok` : ''}
750              {shown.interrupted ? ' · interrupted' : ''}
751              {scope === 'all' && shown.project && !isNarrow ? ` · ${projectName(shown.project)}` : ''}
752            </Text>
753            {shown.status === 'done' && shown.score !== undefined ? (
754              <Text color={scoreColor(shown.score)} dimColor={shown.score >= 4}>
755                {shown.score}/5
756              </Text>
757            ) : null}
758          </Box>
759          <Text wrap="wrap">“{quote.length > quoteMax ? `${quote.slice(0, quoteMax - 1)}…` : quote}”</Text>
760          {quote.length > quoteMax || isExpanded ? (
761            <Button key="expand" hotkey="e" plain dimColor onPress={() => void update($, EXPANDED, x => !x)}>
762              {isExpanded ? 'show less' : 'show all'}
763            </Button>
764          ) : null}
765        </Box>
766
767        {shown.status === 'reviewing' ? (
768          <Box flexDirection="column">
769            <Text dimColor>Reviewing… {Math.round(elapsed / 1000)}s</Text>
770            {elapsed > SLOW_MS ? (
771              <Box flexDirection="row" gap={1}>
772                <Text color="yellow">Taking longer than usual.</Text>
773                <Button key="retry" hotkey="v" onPress={() => void review($, shown.id)}>
774                  Retry
775                </Button>
776              </Box>
777            ) : null}
778          </Box>
779        ) : null}
780        {shown.status === 'skipped' || shown.status === 'failed' ? (
781          <Box flexDirection="column">
782            {shown.status === 'skipped' ? (
783              <Text dimColor wrap="wrap">
784                {isAutoReview ? '· A short follow-up: not reviewed, to save a call.' : 'Automatic review is off.'}
785              </Text>
786            ) : (
787              <Text color="yellow" wrap="wrap">
788                {shown.error ?? 'The review failed.'}
789              </Text>
790            )}
791            <Button key="review-it" hotkey="v" onPress={() => void review($, shown.id)}>
792              Review it
793            </Button>
794          </Box>
795        ) : null}
796
797        {isClear ? (
798          <Text wrap="wrap">
799            <Text color="green">✓ Clear prompt.</Text> <Text dimColor>{shown.verdict}</Text>
800          </Text>
801        ) : null}
802
803        {shown.status === 'done' && !isClear ? (
804          <Box flexDirection="column" gap={1}>
805            <Box flexDirection="column">
806              {headline ? (
807                <Text bold wrap="wrap">
808                  → {headline}
809                </Text>
810              ) : null}
811              {shown.verdict ? (
812                <Text dimColor wrap="wrap">
813                  {shown.verdict}
814                </Text>
815              ) : null}
816            </Box>
817            {shown.better ? (
818              <Box flexDirection="column">
819                <Text dimColor>
820                  Better prompt <Text color="green">+added</Text> <Text color="yellow">[fill in]</Text>
821                </Text>
822                <Text wrap="wrap">
823                  {diffWords(shown.prompt, shown.better).flatMap(part =>
824                    placeholders(part.text).map(run =>
825                      run.isPlaceholder ? (
826                        <Text color="yellow">{run.text}</Text>
827                      ) : part.isAdded ? (
828                        <Text color="green">{run.text}</Text>
829                      ) : (
830                        <Text>{run.text}</Text>
831                      ),
832                    ),
833                  )}
834                </Text>
835              </Box>
836            ) : null}
837            {actions}
838            {missed.length > 0 || kept > 0 ? (
839              <Text wrap="wrap">
840                {missed.map(id => (
841                  <Text color="yellow">✗ {habitName(id)}{'  '}</Text>
842                ))}
843                {kept > 0 ? <Text color="green">+{kept} ✓</Text> : ''}
844              </Text>
845            ) : null}
846            {points.length > 0 ? (
847              <Box flexDirection="column">
848                {points.map(f => (
849                  <Text wrap="wrap" dimColor>
850                    • {f}
851                  </Text>
852                ))}
853              </Box>
854            ) : null}
855            {shown.followup ? (
856              <Text color="yellow" wrap="wrap">
857                ↳ Your next prompt had to correct this: {shown.followup}
858              </Text>
859            ) : null}
860            {shown.tip ? (
861              <Text dimColor wrap="wrap">
862                Tip: {shown.tip}
863              </Text>
864            ) : null}
865            {(shown.rules ?? []).length > 0 ? (
866              <Text dimColor wrap="wrap">
867                Standing instructions: {(shown.rules ?? []).map(r => `“${r}”`).join(', ')}
868              </Text>
869            ) : null}
870          </Box>
871        ) : null}
872
873        {shown.status === 'done' ? (
874          <Box flexDirection="row" gap={1}>
875            <Button key="rate-up" hotkey="y" plain dimColor={shown.rating !== 'up'} onPress={() => void rate($, shown.id, 'up')}>
876              {shown.rating === 'up' ? '✓ Helpful' : 'Helpful'}
877            </Button>
878            <Text dimColor>·</Text>
879            <Button key="rate-down" hotkey="n" plain dimColor={shown.rating !== 'down'} onPress={() => void rate($, shown.id, 'down')}>
880              {shown.rating === 'down' ? '✓ Not helpful' : 'Not helpful'}
881            </Button>
882          </Box>
883        ) : null}
884
885        <Box flexDirection="column">
886          <Box flexDirection="row" gap={1}>
887            <Text dimColor>Trail ·</Text>
888            <Button
889              key="scope"
890              hotkey="p"
891              plain
892              dimColor
893              onPress={() => void update($, SCOPE, s => (s === 'project' ? ('all' as const) : ('project' as const)))}
894            >
895              {scope === 'project' && here.length > 0 ? `${projectName(project)} (show all)` : 'all projects (show this one)'}
896            </Button>
897          </Box>
898          {trail.flatMap((x, i) => {
899            const n = start + i
900            const before = entries[n - 1]
901            const isNewSession = x.session !== undefined && before !== undefined && before.session !== x.session
902            const time = relTime(now - x.at)
903            const textRoom = Math.max(8, width - 6 - time.length)
904            const prompt = oneLine(x.prompt)
905            const label = prompt.length > textRoom ? `${prompt.slice(0, textRoom - 1)}…` : prompt.padEnd(textRoom)
906            const mark =
907              x.status === 'reviewing' ? (
908                <Text dimColor>… </Text>
909              ) : x.status === 'skipped' ? (
910                <Text dimColor>·  </Text>
911              ) : x.status === 'failed' ? (
912                <Text color="yellow">!  </Text>
913              ) : (
914                <Text color={scoreColor(x.score ?? 0)}>●{x.score ?? '–'} </Text>
915              )
916            const row = (
917              <Button key={`row-${x.id}`} plain dimColor={n !== index} onPress={() => select(n)}>
918                {n === index ? '▸' : ' '}
919                {mark}
920                {label} <Text dimColor>{time}</Text>
921              </Button>
922            )
923
924            return isNewSession && x.session !== undefined
925              ? [<Text dimColor>{`── ${sessionLabel(x.session, now)} ──`}</Text>, row]
926              : [row]
927          })}
928          <Box flexDirection="row" gap={2}>
929            {index < entries.length - 1 ? (
930              <Button key="newer" hotkey="j" plain dimColor onPress={() => select(index + 1)}>
931                newer
932              </Button>
933            ) : null}
934            {index > 0 ? (
935              <Button key="older" hotkey="k" plain dimColor onPress={() => select(index - 1)}>
936                older
937              </Button>
938            ) : null}
939          </Box>
940        </Box>
941
942        {footer}
943      </Box>
944    )
945  })
946}
947
hooks/coach.ts 438 lines
1import type { CoachEntry, HabitId, HabitMark } from '../types'
2
3export const HABITS: readonly { id: HabitId; name: string; advice: string }[] = [
4  { id: 'target', name: 'Point at the code', advice: 'Name the files, functions or commands involved, or @-mention them.' },
5  { id: 'goal', name: 'Say what done looks like', advice: 'State the outcome you want, not just the area to work on.' },
6  { id: 'evidence', name: 'Paste the evidence', advice: 'Include the error, stack trace, output or steps to reproduce.' },
7  { id: 'verify', name: 'Say how to check', advice: 'Tell Claude which test, build or command proves it works.' },
8  { id: 'constraints', name: 'Set the limits', advice: "Say what not to touch, and which libraries or style to keep." },
9  { id: 'scope', name: 'One task at a time', advice: 'Split unrelated asks; /clear between unrelated tasks.' },
10  { id: 'plan', name: 'Plan big changes first', advice: 'Ask for a plan (shift+tab for plan mode) before large or risky edits.' },
11]
12
13const HABIT_IDS = new Set<string>(HABITS.map(h => h.id))
14
15export const habitName = (id: HabitId) => HABITS.find(h => h.id === id)?.name ?? id
16
17const HABIT_LINES = HABITS.map(h => `- ${h.id}: ${h.name}. ${h.advice}`).join('\n')
18
19export const SYSTEM = `You are a Claude Code coach. You review one exchange between a developer and Claude Code (an agentic coding CLI) and help the developer build habits that get better results.
20
21Judge the developer's PROMPT, not Claude's work. Use what Claude did as evidence of what the prompt left unclear: many searches or file reads mean the prompt did not say where to look; clarifying questions in the answer mean the goal or constraints were missing; a long turn on a small ask means the scope was vague; an interrupted turn usually means Claude went the wrong way.
22
23Read the earlier prompts and the end of Claude's previous answer first. A short prompt that follows clearly from them (answering Claude's question, "now do the same for the tests") is a GOOD prompt: the context carries it.
24
25The habits, by id:
26${HABIT_LINES}
27
28For each habit that APPLIES to this prompt, mark it "shown" or "missed". Leave out a habit that does not apply: "plan" applies only to large or risky changes, "evidence" only to bugs and failures, "verify" only to changes that can be checked, "target" only when the code involved was not already clear from the context.
29
30Scores:
315 = Claude could act right away with no guessing. 4 = one small gap. 3 = Claude had to explore or guess at something the developer knew. 2 = vague: several gaps, Claude searched widely or asked back. 1 = Claude could not tell what was wanted.
32
33Examples (prompt -> score, habits):
34- "the login is broken fix it" -> 2; target missed, evidence missed, verify missed, goal missed
35- "Login returns 401 for valid users since the token refactor (error below). Fix it in @src/auth/session.ts without changing the public API, then run npm test -- auth. [error]" -> 5; target, evidence, goal, constraints, verify shown
36- "yes, and do the same for the signup form" after Claude offered to -> 5; no habits apply
37- "refactor the whole data layer to use the repository pattern" -> 2; plan missed, scope missed, goal missed, verify missed
38- "add a --json flag to the export command in cli/export.ts" -> 4; target shown, goal shown, verify missed
39
40The developer's focus habit, when given, is the one they are working on: when the prompt shows it, say so first in the feedback ("You said how to check: good."); when it misses it, make that the first point.
41
42If the prompt gives a STANDING instruction, one that would hold for future tasks in this project too (tooling, conventions, style, what never to touch: "use pnpm", "don't edit the generated files"), list it in "standing_instructions", each one short imperative line fit for CLAUDE.md, at most 3. A detail of this one task is not one. When one matches a known standing instruction given, copy that one's text exactly. Otherwise leave the list empty.
43
44The developer's ratings of your past reviews, when given, show what feedback helps them: give more of what they rated helpful, less of what they rated unhelpful.
45
46If THIS prompt corrects or redirects the previous one ("no, I meant...", "that's the wrong file"), set "previous_unclear" to one short line on what the previous prompt should have said; otherwise leave it empty.
47
48Reply with JSON only, no prose, no code fence:
49{"score": 1-5, "verdict": "<one short line>", "next_step": "<the single change that would have helped most, as an instruction to the developer, one line; empty if the prompt was a 5>", "feedback": ["<at most 3 short, specific points; name the actual files, errors or commands from the exchange>"], "better": "<the prompt rewritten as the developer should have sent it, keeping their language and intent; empty if it was already a 5>", "tip": "<one Claude Code feature or habit that fits this exchange, one line>", "habits": {"<habit id>": "shown" | "missed"}, "previous_unclear": "", "standing_instructions": []}
50
51Claude Code features worth suggesting when they fit: plan mode (shift+tab), @file mentions, CLAUDE.md for instructions you repeat, /clear between unrelated tasks, /compact, subagents for parallel research, ! to run a shell command yourself so its output lands in context, custom slash commands or skills for repeated workflows, hooks for automatic checks.`
52
53export const IMPROVE_SYSTEM = `You rewrite a developer's draft prompt for Claude Code (an agentic coding CLI) so Claude can act on it right away.
54
55Keep the developer's intent, language and voice. Keep it short: a few sentences, never a spec. Use what the conversation so far makes clear: name the files, functions, errors and commands it mentions. Never invent a fact or a decision you were not given: no new requirements, names or values the developer did not choose. Where a needed fact or decision is missing, put a short bracketed placeholder the developer fills in, like [paste the error] or [env var or config file?]. Add one line on how to verify when the change can be checked. Ask for a plan first only when the change is large or risky.
56
57The habits a strong prompt shows:
58${HABIT_LINES}
59
60Reply with the rewritten prompt alone: no preamble, no quotes, no code fence.`
61
62export type Review = Pick<CoachEntry, 'score' | 'verdict' | 'nextStep' | 'feedback' | 'better' | 'tip' | 'habits' | 'rules'> & {
63  previousUnclear: string
64}
65
66const ACK =
67  /^(y|yes|yep|yeah|ya|no|nope|ok|okay|k|sure|go|go ahead|go on|continue|proceed|do it|please do|thanks|thank you|ty|lgtm|sounds good|perfect|great|nice|cool|done|next|retry|try again|keep going|\d+|[a-d])[.!\s]*$/i
68
69// A follow-up a review could only call fine: an acknowledgement, or two words
70// that set off little work. "fix the bug" is three words and is reviewed.
71export function isTrivial(prompt: string, tools: number): boolean {
72  const text = prompt.trim()
73  if (ACK.test(text)) return true
74  const words = text.split(/\s+/).filter(Boolean).length
75
76  return words <= 2 && tools <= 3
77}
78
79export const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
80
81const clipEnd = (text: string, max: number) =>
82  text.length > max ? `${text.slice(0, max)}\n[... ${text.length - max} more characters]` : text
83
84// Keeps the opening and, longer, the end: where Claude asks back or sums up.
85export function clipMiddle(text: string, max: number): string {
86  if (text.length <= max) return text
87  const head = Math.floor(max * 0.35)
88  const tail = max - head
89
90  return `${text.slice(0, head)}\n[... ${text.length - max} characters left out ...]\n${text.slice(-tail)}`
91}
92
93const clipTail = (text: string, max: number) => (text.length > max ? `[...]${text.slice(-max)}` : text)
94
95const ARG_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'description', 'prompt', 'skill'] as const
96
97// One short line for a tool call: its name and its main argument.
98export function stepLine(tool: string, args: Readonly<Record<string, unknown>>): string {
99  for (const key of ARG_KEYS) {
100    const value = args[key]
101    if (typeof value === 'string' && value.trim() !== '') {
102      const arg = oneLine(value)
103
104      return `${tool}: ${arg.length > 80 ? `${arg.slice(0, 77)}...` : arg}`
105    }
106  }
107
108  return tool
109}
110
111export function toolSummary(tools: readonly string[]): string {
112  if (tools.length === 0) return 'none'
113  const counts = new Map<string, number>()
114  for (const t of tools) counts.set(t, (counts.get(t) ?? 0) + 1)
115
116  return [...counts].map(([t, n]) => (n > 1 ? `${t} ×${n}` : t)).join(', ')
117}
118
119function context(earlier: readonly CoachEntry[]): string[] {
120  const prompts = earlier
121    .slice(-3)
122    .map(e => `- ${clipEnd(oneLine(e.prompt), 300)}`)
123    .join('\n')
124  const last = earlier[earlier.length - 1]
125
126  return [
127    prompts ? `<earlier_prompts oldest_first="true">\n${prompts}\n</earlier_prompts>` : '',
128    last && last.answer ? `<end_of_previous_answer>\n${clipTail(last.answer, 1500)}\n</end_of_previous_answer>` : '',
129  ]
130}
131
132export type ReviewContext = {
133  // The habit the developer is working on.
134  focus?: HabitId
135  // Standing instructions already seen, so a repeat is worded the same.
136  rules?: readonly string[]
137  // Past reviews the developer rated.
138  ratings?: readonly CoachEntry[]
139}
140
141export function formatTokens(tokens: number): string {
142  return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
143}
144
145export function buildPrompt(entry: CoachEntry, earlier: readonly CoachEntry[], ctx: ReviewContext = {}): string {
146  const { focus, rules = [], ratings = [] } = ctx
147  const rated = ratings
148    .filter(e => e.rating && e.verdict)
149    .slice(-6)
150    .map(e => `- ${e.rating === 'up' ? 'helpful' : 'unhelpful'}: ${e.verdict}${e.feedback?.[0] ? ` | ${e.feedback[0]}` : ''}`)
151    .join('\n')
152
153  const steps = entry.steps ?? []
154  const shown = steps.slice(0, 25).join('\n')
155  const more = steps.length > 25 ? `\n[... ${steps.length - 25} more]` : ''
156
157  return [
158    ...context(earlier),
159    focus ? `<developer_focus_habit>${focus}: ${habitName(focus)}</developer_focus_habit>` : '',
160    rules.length > 0 ? `<known_standing_instructions>\n${rules.map(r => `- ${r}`).join('\n')}\n</known_standing_instructions>` : '',
161    rated ? `<developer_ratings_of_past_reviews>\n${rated}\n</developer_ratings_of_past_reviews>` : '',
162    `<prompt>\n${clipEnd(entry.prompt, 4000)}\n</prompt>`,
163    `<what_claude_did tool_calls="${entry.tools.length}" summary="${toolSummary(entry.tools)}">\n${shown}${more}\n</what_claude_did>`,
164    `<turn_seconds>${Math.round(entry.durationMs / 1000)}</turn_seconds>`,
165    entry.tokens ? `<turn_tokens>${entry.tokens}</turn_tokens>` : '',
166    entry.interrupted ? '<interrupted_by_developer>true</interrupted_by_developer>' : '',
167    `<answer>\n${clipMiddle(entry.answer, 6000)}\n</answer>`,
168  ]
169    .filter(Boolean)
170    .join('\n\n')
171}
172
173export function buildImprovePrompt(draft: string, earlier: readonly CoachEntry[], weak: readonly HabitId[]): string {
174  return [
175    ...context(earlier),
176    weak.length > 0 ? `<habits_this_developer_often_misses>${weak.join(', ')}</habits_this_developer_often_misses>` : '',
177    `<draft>\n${clipEnd(draft, 6000)}\n</draft>`,
178  ]
179    .filter(Boolean)
180    .join('\n\n')
181}
182
183// Strips a fence or quotes a model put around a rewritten prompt.
184export function cleanRewrite(text: string): string {
185  let t = text.trim()
186  const fence = /^```[a-z]*\n([\s\S]*?)\n```$/.exec(t)
187  if (fence?.[1] !== undefined) t = fence[1].trim()
188  if (t.length > 1 && /^["“]/.test(t) && /["”]$/.test(t)) t = t.slice(1, -1).trim()
189
190  return t
191}
192
193export function parseReview(text: string): Review | undefined {
194  const start = text.indexOf('{')
195  const end = text.lastIndexOf('}')
196  if (start < 0 || end <= start) return undefined
197  let raw: unknown
198  try {
199    raw = JSON.parse(text.slice(start, end + 1))
200  } catch {
201    return undefined
202  }
203  if (typeof raw !== 'object' || raw === null) return undefined
204  const r = raw as Record<string, unknown>
205  const score = Number(r.score)
206  if (!Number.isFinite(score)) return undefined
207
208  const habits: Partial<Record<HabitId, HabitMark>> = {}
209  if (typeof r.habits === 'object' && r.habits !== null) {
210    for (const [id, mark] of Object.entries(r.habits as Record<string, unknown>)) {
211      if (HABIT_IDS.has(id) && (mark === 'shown' || mark === 'missed')) habits[id as HabitId] = mark
212    }
213  }
214
215  return {
216    score: Math.min(5, Math.max(1, Math.round(score))),
217    verdict: typeof r.verdict === 'string' ? r.verdict.trim() : '',
218    nextStep: typeof r.next_step === 'string' ? r.next_step.trim() : '',
219    feedback: Array.isArray(r.feedback)
220      ? r.feedback.filter((f): f is string => typeof f === 'string' && f.trim() !== '').slice(0, 3)
221      : [],
222    better: typeof r.better === 'string' ? r.better.trim() : '',
223    tip: typeof r.tip === 'string' ? r.tip.trim() : '',
224    habits,
225    previousUnclear: typeof r.previous_unclear === 'string' ? r.previous_unclear.trim() : '',
226    rules: Array.isArray(r.standing_instructions)
227      ? r.standing_instructions
228          .filter((x): x is string => typeof x === 'string' && x.trim() !== '')
229          .map(oneLine)
230          .slice(0, 3)
231      : [],
232  }
233}
234
235export function average(entries: readonly CoachEntry[]): number | undefined {
236  const scored = entries.filter(e => e.status === 'done' && e.score !== undefined)
237  if (scored.length === 0) return undefined
238
239  return scored.reduce((sum, e) => sum + (e.score ?? 0), 0) / scored.length
240}
241
242export type HabitStat = {
243  id: HabitId
244  name: string
245  advice: string
246  shown: number
247  missed: number
248  // Share shown, 0 to 1; undefined before the habit applied to any prompt.
249  rate: number | undefined
250  // The newer half's rate minus the older half's, when both halves have marks.
251  trend: number | undefined
252  // Mean tokens per turn when the habit was shown, and when missed: what it saves.
253  tokensShown: number | undefined
254  tokensMissed: number | undefined
255}
256
257const WINDOW = 30
258// A habit is a focus candidate once it applied to this many recent prompts.
259const MIN_MARKS = 3
260
261export function habitStats(entries: readonly CoachEntry[]): HabitStat[] {
262  const recent = entries.filter(e => e.status === 'done' && e.habits).slice(-WINDOW)
263
264  return HABITS.map(h => {
265    const marks = recent.map(e => e.habits?.[h.id]).filter((m): m is HabitMark => m !== undefined)
266    const meanTokens = (mark: HabitMark) => {
267      const ts = recent.filter(e => e.habits?.[h.id] === mark && e.tokens).map(e => e.tokens ?? 0)
268
269      return ts.length >= 2 ? ts.reduce((a, b) => a + b, 0) / ts.length : undefined
270    }
271    const shown = marks.filter(m => m === 'shown').length
272    const rateOf = (ms: HabitMark[]) => (ms.length === 0 ? undefined : ms.filter(m => m === 'shown').length / ms.length)
273    const half = Math.floor(marks.length / 2)
274    const older = rateOf(marks.slice(0, half))
275    const newer = rateOf(marks.slice(half))
276
277    return {
278      ...h,
279      shown,
280      missed: marks.length - shown,
281      rate: rateOf(marks),
282      trend: marks.length >= 4 && older !== undefined && newer !== undefined ? newer - older : undefined,
283      tokensShown: meanTokens('shown'),
284      tokensMissed: meanTokens('missed'),
285    }
286  })
287}
288
289// The habits missed most, worst first: the first is the one to work on. A
290// graduated habit comes back only once it slips below half.
291export function weakHabits(entries: readonly CoachEntry[], count = 2, graduated: readonly HabitId[] = []): HabitId[] {
292  return habitStats(entries)
293    .filter(s => s.rate !== undefined && s.shown + s.missed >= MIN_MARKS && s.rate < 0.8)
294    .filter(s => !graduated.includes(s.id) || (s.rate ?? 1) < 0.5)
295    .sort((a, b) => (a.rate ?? 1) - (b.rate ?? 1) || b.missed - a.missed)
296    .slice(0, count)
297    .map(s => s.id)
298}
299
300// The distinct standing instructions seen, newest spelling of each, for the coach to reuse.
301export function knownRules(entries: readonly CoachEntry[], max = 15): string[] {
302  const byKey = new Map<string, string>()
303  for (const e of entries) for (const rule of e.rules ?? []) byKey.set(rule.toLowerCase(), rule)
304
305  return [...byKey.values()].slice(-max)
306}
307
308export type RuleSuggestion = { text: string; count: number }
309
310// Standing instructions given in two or more prompts: lines for CLAUDE.md.
311export function ruleSuggestions(entries: readonly CoachEntry[], dismissed: readonly string[]): RuleSuggestion[] {
312  const counts = new Map<string, RuleSuggestion>()
313  for (const e of entries) {
314    // Counted once per prompt, however often it says it.
315    for (const rule of new Set(e.rules ?? [])) {
316      const key = rule.toLowerCase()
317      counts.set(key, { text: rule, count: (counts.get(key)?.count ?? 0) + 1 })
318    }
319  }
320
321  return [...counts]
322    .filter(([key, r]) => r.count >= 2 && !dismissed.includes(key))
323    .map(([, r]) => r)
324    .sort((a, b) => b.count - a.count)
325}
326
327export const claudeMdPrompt = (rule: string) =>
328  `Add this line to CLAUDE.md, in the section where it fits (create the file at the project root if there is none): "${rule}"`
329
330// How many of the most recent prompts the habit applied to showed it, in a row.
331export function streak(entries: readonly CoachEntry[], habit: HabitId): number {
332  let n = 0
333  for (let i = entries.length - 1; i >= 0; i--) {
334    const mark = entries[i]?.status === 'done' ? entries[i]?.habits?.[habit] : undefined
335    if (mark === undefined) continue
336    if (mark === 'missed') break
337    n++
338  }
339
340  return n
341}
342
343export const STREAK_TO_GRADUATE = 3
344
345// Cheap checks on a draft as it is typed: no model call. At most two, the
346// most useful first; none for short drafts, commands and shell lines.
347export function lintDraft(text: string): string[] {
348  const t = text.trim()
349  if (t.startsWith('/') || t.startsWith('!')) return []
350  const words = t.split(/\s+/).filter(Boolean).length
351  if (words < 4) return []
352  const lower = t.toLowerCase()
353  const hints: string[] = []
354  const isBug = /\b(fix|bug|broken|error|errors|fail|fails|failing|crash|crashes|exception|wrong|issue)\b/.test(lower)
355  const hasEvidence = /```|\n\s+at |error:|exception:|traceback|stack|expected .* (got|received)|"[^"]{12,}"|`[^`]{12,}`/i.test(t)
356  const hasTarget = /@\S+|[\w-]+\/[\w./-]+|\b[\w-]+\.(ts|tsx|js|jsx|py|go|rs|rb|java|kt|swift|md|json|ya?ml|css|html|sql|sh)\b|`[^`]+`|\b\w+\(\)/i.test(t)
357  const isChange = /\b(add|fix|implement|build|create|refactor|change|update|make|rename|remove|delete|migrate|write)\b/.test(lower)
358  const hasCheck = /\b(test|tests|run|check|verify|build|lint|typecheck|confirm|ensure|expect|should pass|prove)\b/.test(lower)
359  const isBig = /\b(refactor|rewrite|migrate|redesign|overhaul|whole|entire|everything|all the)\b/.test(lower)
360  const hasPlan = /\b(plan|first propose|outline|step by step|before (you )?(change|edit|start))\b/.test(lower)
361
362  if (isBug && !hasEvidence) hints.push('no error pasted')
363  if (!hasTarget) hints.push('no file named')
364  if (isBig && !hasPlan) hints.push('big change: ask for a plan')
365  if (isChange && !hasCheck) hints.push('no way to check')
366
367  return hints.slice(0, 2)
368}
369
370export type DiffPart = { text: string; isAdded: boolean }
371
372// The words of `after` marked new where they are not in `before`, by a
373// longest common subsequence over words; a long pair is not diffed.
374export function diffWords(before: string, after: string): DiffPart[] {
375  const a = before.split(/(\s+)/).filter(x => x !== '')
376  const b = after.split(/(\s+)/).filter(x => x !== '')
377  if (a.length * b.length > 250_000) return [{ text: after, isAdded: false }]
378  const key = (w: string) => w.toLowerCase().replace(/[^\p{L}\p{N}@./_-]/gu, '')
379  const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
380  for (let i = a.length - 1; i >= 0; i--) {
381    for (let j = b.length - 1; j >= 0; j--) {
382      lcs[i]![j] =
383        key(a[i]!) === key(b[j]!) && key(a[i]!) !== '' ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
384    }
385  }
386  const parts: DiffPart[] = []
387  const push = (text: string, isAdded: boolean) => {
388    // Whitespace joins whichever run it sits in.
389    const added = /^\s+$/.test(text) ? (parts[parts.length - 1]?.isAdded ?? false) : isAdded
390    const last = parts[parts.length - 1]
391    if (last && last.isAdded === added) last.text += text
392    else parts.push({ text, isAdded: added })
393  }
394  let i = 0
395  let j = 0
396  while (j < b.length) {
397    if (i < a.length && key(a[i]!) === key(b[j]!) && key(a[i]!) !== '') {
398      push(b[j]!, false)
399      i++
400      j++
401    } else if (i < a.length && lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
402      i++
403    } else {
404      push(b[j]!, true)
405      j++
406    }
407  }
408
409  return parts
410}
411
412// Splits text into runs, marking [placeholders] the developer fills in.
413export function placeholders(text: string): { text: string; isPlaceholder: boolean }[] {
414  return text
415    .split(/(\[[^\]\n]{1,60}\])/)
416    .filter(x => x !== '')
417    .map(x => ({ text: x, isPlaceholder: /^\[[^\]]+\]$/.test(x) }))
418}
419
420export function relTime(ms: number): string {
421  const s = Math.max(0, Math.round(ms / 1000))
422  if (s < 60) return 'now'
423  if (s < 3600) return `${Math.round(s / 60)}m`
424  if (s < 86_400) return `${Math.round(s / 3600)}h`
425
426  return `${Math.round(s / 86_400)}d`
427}
428
429const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
430
431export function sessionLabel(start: number, now: number): string {
432  const d = new Date(start)
433  const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
434  const sameDay = new Date(now).toDateString() === d.toDateString()
435
436  return sameDay ? `today ${time}` : `${MONTHS[d.getMonth()]} ${d.getDate()} ${time}`
437}
438
types/index.d.ts 83 lines
1export type CoachStatus = 'reviewing' | 'done' | 'failed' | 'skipped'
2
3export type HabitId = 'target' | 'goal' | 'evidence' | 'verify' | 'constraints' | 'scope' | 'plan'
4
5export type HabitMark = 'shown' | 'missed'
6
7export type CoachEntry = {
8  id: string
9  at: number
10  prompt: string
11  answer: string
12  // Tool names, one per call, for the counts.
13  tools: string[]
14  // One short line per call with its main argument (`Read: src/a.ts`).
15  steps?: string[]
16  durationMs: number
17  status: CoachStatus
18  // The turn was interrupted by the person.
19  interrupted?: boolean
20  score?: number
21  verdict?: string
22  feedback?: string[]
23  better?: string
24  tip?: string
25  // The habits this prompt showed or missed; a habit that did not apply is absent.
26  habits?: Partial<Record<HabitId, HabitMark>>
27  // Set when the next prompt had to correct this one.
28  followup?: string
29  // The directory the session ran in.
30  project?: string
31  // What the turn cost, all input and output tokens summed.
32  tokens?: number
33  // Standing instructions the prompt gave, each worded for CLAUDE.md.
34  rules?: string[]
35  // The person's rating of this review.
36  rating?: 'up' | 'down'
37  // The one change that would have helped most: the review's headline.
38  nextStep?: string
39  // When the review in flight started, for its elapsed time.
40  reviewStartedAt?: number
41  // The start time of the session the prompt was sent in.
42  session?: number
43  error?: string
44}
45
46// Improve draft's progress, and the draft it replaced, for Undo.
47export type DraftState =
48  | { phase: 'improving' }
49  // `use`: Use it replaced the draft with a review's better prompt.
50  | { phase: 'improved'; original: string; source: 'improve' | 'use' }
51  | { phase: 'failed'; error: string }
52
53declare module 'claude-code' {
54  interface PluginState {
55    'prompt-coach': {
56      entries: CoachEntry[]
57      // The id the pane shows in detail; null follows the newest.
58      selected: string | null
59      view: 'review' | 'habits'
60      // Which prompts the review trail lists.
61      scope: 'project' | 'all'
62      // Repeated instructions the person dismissed, lowercased.
63      dismissedRules: string[]
64      draft: DraftState | null
65      // What the prompt box holds now: whether it has a draft, and the
66      // local checks it fails.
67      box: { hasText: boolean; hints: string[] }
68      // Whether the sidebar is showing.
69      paneOpen: boolean
70      // The line above the prompt, hidden for this session.
71      bandHidden: boolean
72      // The quoted prompt shown whole on a narrow sidebar.
73      expanded: boolean
74      // The first-run card was dismissed.
75      welcomed: boolean
76      // Habits kept three prompts in a row, no longer the focus.
77      graduated: HabitId[]
78      // The time, advanced each second while a review runs, to redraw it.
79      tick: number
80    }
81  }
82}
83