SLOPSHOPPER

prompt-sharpen

/prompt-sharpen <prompt> opens a pane that asks a few clarifying questions with recommended answers, then rewrites the prompt clearer and more complete with…

newpanecommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-sharpen
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prompt-sharpen ⎿ prompt-sharpen: Usage: /prompt-sharpen <prompt> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prompt-sharpen

/prompt-sharpen <prompt> opens a pane that walks your prompt through three steps: Questions, Rewrite, Review.

Questions

Asks up to three multiple-choice questions about what the prompt leaves open. Each has its recommended answer already picked: change it, type your own with Other, or Skip.

Questions step

Rewrite

Rewrites the prompt clearer and more complete with the same intent, keeping every detail of the original. It adds only what came from your answers, the conversation, or structure and output format.

  • An answer you left on its recommended option is written as an assumption.
  • Anything still unknown stays a [placeholder].

Review

Shows the sharpened prompt first, then what was added, with your original as a single line below.

Review step

ActionWhat it does
Run itSends it (with a warning and Run anyway while a [placeholder] is left)
EditPuts it in the prompt to change first
CopyCopies it
Send originalSends what you typed
Cancel / EscPuts your original back in the prompt

Notes

  • It asks the session's own model over this conversation, or without it in a new or cleared one.
  • Where the pane cannot open, your prompt goes back in the prompt untouched.

Install

/plugin install prompt-sharpen --marketplace thitiwats/claude-mods

Develop

claude plugin validate plugins/prompt-sharpen
claude plugin test plugins/prompt-sharpen

Grade the questions and rewrites with a real model and a judge model (about $1 a run on Opus; results go to eval/results-<model>.json):

bun plugins/prompt-sharpen/eval/run.ts [--model opus] [--judge opus] [--case <name>]
Source 2 files
hooks/register.tsx 432 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Answer, Ask, Question, Rewrite, Upgrade } from '../types'
5
6const PANE = 'prompt-sharpen'
7const USAGE = 'Usage: /prompt-sharpen <prompt>'
8
9const MAX_QUESTIONS = 3
10const MAX_OPTIONS = 4
11
12const upgrade = atom({ plugin: 'prompt-sharpen', key: 'upgrade' } as const, null as Upgrade | null)
13
14const IDLE = { status: 'idle' } as const
15const LOADING = { status: 'loading' } as const
16
17// Translated jargon reads oddly to developers writing in Thai and other languages, who keep it in English.
18const LANGUAGE =
19  'Write in the language the prompt is written in, the way a developer who speaks it writes: keep technical terms in English ' +
20  '(code identifiers, file names, commands, tools, libraries, and jargon such as commit, pull request, deploy, refactor, endpoint), never translated or transliterated.'
21
22const questionsPrompt = (original: string) =>
23  [
24    'The user wants to send the prompt below to you, and asked to improve it first.',
25    'Ask at most 3 multiple-choice clarifying questions about what the prompt leaves open that would most change the result.',
26    'Ask one only when the user\'s intent could go two ways that lead to clearly different results, and neither the prompt nor this conversation settles it.',
27    'Never ask about what you could find by reading the code or files, edge cases or implementation details you would sensibly decide yourself, or work the prompt does not mention (tests, docs, error handling).',
28    'When the prompt already says what to do and where, ask none and reply {"questions":[]}: that is the expected answer for a clear prompt.',
29    'Give each question 2 to 4 short options. Put first the option you judge most likely matches what the user means,',
30    'given this conversation: it is shown to them as the recommended one.',
31    `Questions and options: ${LANGUAGE}`,
32    'Reply with JSON only, no markdown: {"questions":[{"question":"...","options":["...","..."]}]}',
33    '',
34    '<prompt>',
35    original,
36    '</prompt>',
37  ].join('\n')
38
39const rewritePrompt = (original: string, answered: string) =>
40  [
41    'Rewrite the user\'s prompt below into a clearer, more complete prompt for you, with the same intent.',
42    'Keep every detail, constraint, name, path, quote, and code snippet of the original; never drop or weaken one.',
43    'Add only what comes from the user\'s answers, from this conversation, or structure and output format.',
44    'Never add work the user did not ask for (tests, docs, secrets, extra checks) or implementation choices (resources, libraries, outputs); if one seems worth suggesting, write it as an assumption.',
45    'Answers marked "unconfirmed" are your own guesses, which the user did not pick: write each as an assumption the user can change, not as a requirement.',
46    'Never invent facts: write anything still unknown as a [placeholder].',
47    LANGUAGE,
48    'Keep it under 300 words, or 1.5 times the original\'s length when that is longer; shorter is fine when the task is simple.',
49    'Also list each detail the rewrite states that the original did not, including those inside examples and steps, a few words each, in the same language.',
50    'Reply with JSON only, no markdown: {"prompt":"...","added":["..."]}',
51    '',
52    '<prompt>',
53    original,
54    '</prompt>',
55    ...(answered ? ['', '<answers>', answered, '</answers>'] : []),
56  ].join('\n')
57
58// Models wrap JSON in a fence or a sentence now and then; read the outermost object.
59const parseJson = (text: string): unknown => {
60  const start = text.indexOf('{')
61  const end = text.lastIndexOf('}')
62  if (start < 0 || end < start) return undefined
63  try {
64    return JSON.parse(text.slice(start, end + 1))
65  } catch {
66    return undefined
67  }
68}
69
70const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
71
72const asQuestions = (text: string): Question[] | undefined => {
73  const list = (parseJson(text) as { questions?: unknown } | undefined)?.questions
74  if (!Array.isArray(list)) return undefined
75  return list
76    .map(q => ({ text: (q as { question?: unknown }).question, options: (q as { options?: unknown }).options }))
77    .filter((q): q is { text: string; options: unknown[] } => isText(q.text) && Array.isArray(q.options))
78    .map(q => ({ text: q.text.trim(), options: q.options.filter(isText).map(o => o.trim()).slice(0, MAX_OPTIONS) }))
79    .filter(q => q.options.length > 0)
80    .slice(0, MAX_QUESTIONS)
81}
82
83const asRewrite = (text: string): Rewrite | undefined => {
84  const reply = parseJson(text) as { prompt?: unknown; added?: unknown } | undefined
85  if (!isText(reply?.prompt)) return undefined
86  return { text: reply.prompt.trim(), added: Array.isArray(reply.added) ? reply.added.filter(isText) : [] }
87}
88
89const answerText = (q: Question, a: Answer | undefined) =>
90  a?.kind === 'option' ? q.options[a.index] : a?.kind === 'other' && a.text.trim() ? a.text.trim() : undefined
91
92const formatAnswers = (questions: Question[], answers: Answer[]) =>
93  questions
94    .map((q, i) => ({ a: answerText(q, answers[i]), guess: answers[i]?.kind === 'option' && answers[i].isGuess, q }))
95    .filter(({ a }) => a !== undefined)
96    .map(({ q, a, guess }) => `Q: ${q.text}\nA: ${a}${guess ? ' (unconfirmed)' : ''}`)
97    .join('\n')
98
99const words = (text: string) => text.split(/\s+/).filter(Boolean).length
100
101const isThai = (text: string) => /[\u0E00-\u0E7F]/.test(text)
102
103const placeholderWarning = (count: number, original: string) =>
104  isThai(original)
105    ? `! ยังเหลือ ${count} [placeholder] ที่ต้องเติม: กด Edit ก่อน หรือ Run anyway`
106    : `! Still has ${count} [placeholder] to fill: use Edit first, or Run anyway`
107
108// The fork sees this conversation; a new or cleared one has nothing to fork, so ask the same model without it.
109const ask = async ($: EngineInterface, prompt: string) => {
110  const forked = await $.model.fork({ prompt })
111  if (forked.isAnswered || forked.reason !== 'nothing-to-fork') return forked
112  return $.model.complete({ model: await $.session.model(), prompt })
113}
114
115// Writes a step's result only while its run is still the one in the pane.
116const settle = ($: EngineInterface, id: number, change: (u: Upgrade) => Upgrade) =>
117  update($, upgrade, u => (u?.id === id ? change(u) : u))
118
119// Reads the answers as they stand now, not as a drawing captured them.
120const rewrite = async ($: EngineInterface, id: number) => {
121  let u: Upgrade | undefined
122  await settle($, id, x => ((u = x), { ...x, rewrite: LOADING }))
123  if (!u) return
124  const questions = u.questions.status === 'ready' ? u.questions.value : []
125  const reply = await ask($, rewritePrompt(u.original, formatAnswers(questions, u.answers)))
126  const value = reply.isAnswered ? asRewrite(reply.text) : undefined
127  const result: Ask<Rewrite> = value
128    ? { status: 'ready', value }
129    : { status: 'failed', reason: reply.isAnswered ? 'unreadable reply' : reply.reason }
130  await settle($, u.id, x => ({ ...x, rewrite: result }))
131}
132
133const askQuestions = async ($: EngineInterface, u: Upgrade) => {
134  await settle($, u.id, x => ({ ...x, questions: LOADING }))
135  const reply = await ask($, questionsPrompt(u.original))
136  const value = reply.isAnswered ? asQuestions(reply.text) : undefined
137  if (!value) {
138    const reason = reply.isAnswered ? 'unreadable reply' : reply.reason
139    await settle($, u.id, x => ({ ...x, questions: { status: 'failed', reason } }))
140    return
141  }
142  // Each question starts on its recommended option, so accepting them all is one press.
143  const ready: Upgrade = { ...u, questions: { status: 'ready', value }, answers: value.map(() => ({ kind: 'option', index: 0, isGuess: true })) }
144  await settle($, u.id, () => ready)
145  if (value.length === 0) await rewrite($, u.id)
146}
147
148// Clears the run before closing, so the close hook knows the pane was not dismissed.
149const finish = async ($: EngineInterface) => {
150  await update($, upgrade, () => null)
151  await $.ui.close({ id: PANE })
152}
153
154export const register: Register = on => {
155  on('session.start', async ($, e, next) => {
156    await $.command.register({
157      name: 'prompt-sharpen',
158      description: 'Ask a few questions, then rewrite a prompt clearer and more complete with the same intent',
159      argumentHint: '<prompt>',
160    })
161    return next(e)
162  })
163
164  on('command.run', { command: 'prompt-sharpen' }, async ($, e) => {
165    const original = e.args.trim()
166    if (!original) return { text: USAGE }
167
168    const previous = await read($, upgrade)
169    const u: Upgrade = { id: (previous?.id ?? 0) + 1, original, questions: IDLE, answers: [], rewrite: IDLE }
170    await update($, upgrade, () => u)
171    const opened = await $.ui.open({ id: PANE, title: 'Prompt upgrade', focus: true, closeOnEscape: true })
172    if (!opened.isPlaced) {
173      await finish($)
174      await $.prompt.fill({ text: original })
175      $.ui.toast(`Prompt upgrade skipped: ${opened.reason}`)
176      return {}
177    }
178    await askQuestions($, u)
179    return {}
180  })
181
182  // Esc or the close mark leaves the original prompt where the user can still send it.
183  on('ui.close', { id: PANE }, async ($, e, next) => {
184    const u = await read($, upgrade)
185    if (u) {
186      await update($, upgrade, () => null)
187      await $.prompt.fill({ text: u.original })
188    }
189    return next(e)
190  })
191
192  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
193    const rest = await next(e)
194    const u = await read($, upgrade)
195    if (!u) return rest
196
197    const elements = $.ui.resolve(e)
198    const { Box, Button, Text } = elements
199    // Mobile draws no text field, so it offers no typed answer.
200    const Input = 'Input' in elements ? elements.Input : undefined
201    const step = u.rewrite.status === 'ready' ? 2 : u.rewrite.status !== 'idle' ? 1 : 0
202    const STEPS = ['Questions', 'Rewrite', 'Review']
203
204    const stats = u.rewrite.status === 'ready' ? `${words(u.original)} → ${words(u.rewrite.value.text)} words · ${u.rewrite.value.added.length} added` : ''
205
206    const steps = (
207      <Box columnGap={1} flexWrap="wrap" justifyContent="space-between">
208        <Box columnGap={1}>
209          {STEPS.map((name, i) => (
210            <Text key={name} color={i < step ? 'success' : i === step ? 'warning' : undefined} dimColor={i > step} bold={i === step}>
211              {name}
212              {i < STEPS.length - 1 ? ' ›' : ''}
213            </Text>
214          ))}
215        </Box>
216        <Text dimColor>{stats}</Text>
217      </Box>
218    )
219
220    // One dim line instead of a box: the user just typed it.
221    const original = (
222      <Text wrap="truncate-end">
223        <Text dimColor>{u.rewrite.status === 'ready' ? 'Original  ' : 'Prompt  '}</Text>
224        {u.original.replace(/\s+/g, ' ')}
225      </Text>
226    )
227
228    const box = (title: string, text: string) => (
229      <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
230        <Text bold color="warning">
231          {title}
232        </Text>
233        <Text>{text}</Text>
234      </Box>
235    )
236
237    const cancel = (
238      <Button
239        key="cancel"
240        plain
241        dimColor
242        label="Cancel"
243        onPress={async () => {
244          await finish($)
245          await $.prompt.fill({ text: u.original })
246        }}
247      />
248    )
249    const sendOriginal = (
250      <Button
251        key="original"
252        plain
253        dimColor
254        label="Send original"
255        onPress={async () => {
256          await finish($)
257          await $.prompt.submit({ text: u.original, asUser: true })
258        }}
259      />
260    )
261
262    const failure = (reason: string, retry: () => Promise<void>) => (
263      <Box flexDirection="column" rowGap={1}>
264        {original}
265        <Text color="error">✗ Could not upgrade the prompt: {reason}</Text>
266        <Box columnGap={1}>
267          <Button key="retry" variant="primary" label="Retry" onPress={retry} />
268          {sendOriginal}
269          {cancel}
270        </Box>
271      </Box>
272    )
273
274    let body: RenderChildren
275    if (u.questions.status === 'failed') {
276      body = failure(u.questions.reason, () => askQuestions($, u))
277    } else if (u.rewrite.status === 'failed') {
278      body = failure(u.rewrite.reason, () => rewrite($, u.id))
279    } else if (u.rewrite.status === 'ready') {
280      const { text, added } = u.rewrite.value
281      const placeholders = text.match(/\[[^\]\n]+\]/g) ?? []
282      body = (
283        <Box flexDirection="column" rowGap={1}>
284          {box('Upgraded prompt', text)}
285          {added.length > 0 && (
286            <Box flexDirection="column">
287              <Text dimColor bold>
288                Added
289              </Text>
290              {added.map((detail, i) => (
291                <Text key={`added-${i}`}>
292                  {'  '}
293                  <Text color="success">+ </Text>
294                  {detail}
295                </Text>
296              ))}
297            </Box>
298          )}
299          {placeholders.length > 0 && (
300            <Text color="warning">{placeholderWarning(placeholders.length, u.original)}</Text>
301          )}
302          {original}
303          <Box columnGap={1} flexWrap="wrap">
304            <Button
305              key="run"
306              variant={placeholders.length > 0 ? undefined : 'primary'}
307              label={placeholders.length > 0 ? '▶ Run anyway' : '▶ Run it'}
308              onPress={async () => {
309                await finish($)
310                await $.prompt.submit({ text, asUser: true })
311              }}
312            />
313            <Button
314              key="edit"
315              variant={placeholders.length > 0 ? 'primary' : undefined}
316              label="Edit"
317              onPress={async () => {
318                await finish($)
319                await $.prompt.fill({ text })
320              }}
321            />
322            <Button
323              key="copy"
324              label="Copy"
325              onPress={async press => {
326                const copied = await $.ui.copy({ text, surface: press.surface })
327                $.ui.toast(copied.isCopied ? 'Copied' : `Could not copy: ${copied.reason}`)
328              }}
329            />
330            {sendOriginal}
331            {cancel}
332          </Box>
333        </Box>
334      )
335    } else if (u.rewrite.status === 'loading') {
336      body = (
337        <Box flexDirection="column" rowGap={1}>
338          {original}
339          <Text bold>
340            <Text color="warning">◆</Text> Writing your upgraded prompt… <Text dimColor>(a few seconds)</Text>
341          </Text>
342          <Box>{cancel}</Box>
343        </Box>
344      )
345    } else if (u.questions.status === 'ready') {
346      const questions = u.questions.value
347      const pick = (i: number, answer: Answer) =>
348        update($, upgrade, x => (x?.id === u.id ? { ...x, answers: x.answers.map((a, j) => (j === i ? answer : a)) } : x))
349      body = (
350        <Box flexDirection="column" rowGap={1}>
351          {original}
352          <Text dimColor bold>
353            Answer a few questions <Text dimColor>· recommended is picked; change any, or skip</Text>
354          </Text>
355          {questions.map((q, i) => {
356            const a = u.answers[i]
357            return (
358              <Box key={`q${i}`} flexDirection="column">
359                <Text bold>
360                  <Text color="warning">{i + 1}</Text> {q.text}
361                </Text>
362                <Box flexDirection="column" paddingLeft={2}>
363                  {q.options.map((option, j) => (
364                    <Box key={`o${j}`} columnGap={1} flexWrap="wrap">
365                      <Button
366                        key={`q${i}-${j}`}
367                        plain
368                        label={`${a?.kind === 'option' && a.index === j ? '◉' : '○'} ${option}`}
369                        onPress={() => pick(i, { kind: 'option', index: j })}
370                      />
371                      {j === 0 && <Text dimColor>recommended</Text>}
372                    </Box>
373                  ))}
374                  {Input && (
375                    <Button
376                      key={`q${i}-other`}
377                      plain
378                      label={`${a?.kind === 'other' ? '◉' : '○'} Other…`}
379                      onPress={() => pick(i, { kind: 'other', text: a?.kind === 'other' ? a.text : '' })}
380                    />
381                  )}
382                  <Button
383                    key={`q${i}-skip`}
384                    plain
385                    dimColor
386                    label={`${a?.kind === 'skip' ? '◉' : '○'} Skip`}
387                    onPress={() => pick(i, { kind: 'skip' })}
388                  />
389                </Box>
390                {Input && a?.kind === 'other' && (
391                  <Box paddingLeft={2}>
392                    <Input
393                      key={`q${i}-text`}
394                      placeholder="Type your answer"
395                      value={a.text}
396                      submitLabel="keep"
397                      onInput={text => pick(i, { kind: 'other', text })}
398                      onSubmit={text => pick(i, { kind: 'other', text })}
399                    />
400                  </Box>
401                )}
402              </Box>
403            )
404          })}
405          <Box columnGap={1}>
406            <Button key="upgrade" variant="primary" label="Upgrade now" onPress={() => rewrite($, u.id)} />
407            {cancel}
408          </Box>
409        </Box>
410      )
411    } else {
412      body = (
413        <Box flexDirection="column" rowGap={1}>
414          {original}
415          <Text dimColor>Reading your prompt…</Text>
416          <Box>{cancel}</Box>
417        </Box>
418      )
419    }
420
421    return (
422      <Box flexDirection="column">
423        {rest}
424        <Box flexDirection="column" rowGap={1}>
425          {steps}
426          {body}
427        </Box>
428      </Box>
429    )
430  })
431}
432
types/index.d.ts 33 lines
1/** A model call the pane waits on: idle, running, its value, or why it failed. */
2export type Ask<T> =
3  | { status: 'idle' }
4  | { status: 'loading' }
5  | { status: 'ready'; value: T }
6  | { status: 'failed'; reason: string }
7
8/** A clarifying question; its first option is the recommended option. */
9export type Question = { text: string; options: string[] }
10
11/** The user's answer to one clarifying question: an option by index (`isGuess` while it is still the recommended one nobody picked), their own text, or skipped. */
12export type Answer = { kind: 'option'; index: number; isGuess?: boolean } | { kind: 'other'; text: string } | { kind: 'skip' }
13
14/** The upgraded prompt and the added details it states that the original prompt did not. */
15export type Rewrite = { text: string; added: string[] }
16
17/** One run of /prompt-sharpen, from the original prompt to the upgraded one; `id` tells a stale reply from the current run. */
18export type Upgrade = {
19  id: number
20  original: string
21  questions: Ask<Question[]>
22  answers: Answer[]
23  rewrite: Ask<Rewrite>
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'prompt-sharpen': {
29      upgrade: Upgrade | null
30    }
31  }
32}
33