Lets clear prompts through untouched and rewrites rough ones into the prompt box for you to check before sending.

Rough prompt in. Clear prompt in your box. You press Enter.

sharpprompt is a Claude Code mod that looks at each prompt you type before it goes out, and lets most of them through untouched. When one is rough (vague, missing what you want back, leaning on "that thing above"), it rewrites it with the conversation in view and puts the rewrite in your prompt box. You press Enter, edit it, or take your own text back; it never sends anything for you.
From a real session. The conversation had just covered hooks/gate.ts, and this was typed:
that question thing u mentioned, is it gonna break when replies end with a code block or smth, look into it
The prompt was held back, and the box filled with:
Will `endsWithQuestion()` in hooks/gate.ts break when Claude's reply ends with a code block or something similar?
Look into it and tell me which endings it gets wrong, with an example for each. Don't change anything yet.
Above the box:
sharpprompt rewrote: that question thing u mentioned, is it gonna break when replies end wi...
Enter sends it, or edit it in the box. [ back to mine ]
"u mentioned" became the function and file the conversation was about.

Recorded with vhs on Claude Code 2.1.295.

# lines, prompts under 40 characters, pastes over 20,000, anything you did not type (task notifications, other sessions, plugins), and your answer to a question Claude just asked. It takes about 17 microseconds per prompt on a desktop CPU (mean of 100,000 calls; see tests/gate.bench.test.ts).Anything that fails sends your prompt as typed.
Inside Claude Code:
/plugin marketplace add ondrhn/sharpprompt
/plugin install sharpprompt@ondrhn
Or from a clone:
git clone https://github.com/ondrhn/sharpprompt ~/sharpprompt
claude --plugin-dir ~/sharpprompt
To load the clone in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
/sharpprompt, or /sharp for short:
status | on or off, the mode, what happened to the last prompt | |||
on, off | for this session | |||
| `mode fill\ | replace\ | context\ | off` | for this session; the default is set in /config |
undo | puts your last original back in the box | |||
stats | what sharpprompt has done so far (see Measurements) | |||
try <prompt> | shows the rewrite without sending anything |
Ask before sending and Session facts in /config turn the 0.2 helpers off. The Rewrite language setting in /config keeps the rewrite in the language you wrote in (same, the default) or always writes it in English (en), for when you type in another language and want Claude to get a clear English prompt.
Modes: fill (default) puts the rewrite in the box. replace sends the rewrite and shows your original above the box with a way back. context sends your prompt as typed and gives Claude the rewrite beside it as a note.

claude plugin validate --strict . on this repository:
Validating marketplace manifest: .claude-plugin/marketplace.json
Validating plugin: .claude-plugin/plugin.json
❯ types ./types/index.d.ts declares on $: nothing (no EngineInterface member)
❯ types ./types/index.d.ts declares state: sharpprompt.pending, sharpprompt.isOff, sharpprompt.mode, sharpprompt.lastDecision
Validating hooks: hooks/hooks.json
❯ ./register.tsx hooks: session.start, command.run{command=sharpprompt}, command.run{command=sharp}, prompt.submit, tool.call, turn.complete, ui.render{component=AbovePrompt}
❯ ./register.tsx answers its own command: command.run{command=sharpprompt}
❯ ./register.tsx answers its own command: command.run{command=sharp}
❯ ./register.tsx gating hook with .catch: prompt.submit
❯ ./register.tsx gating hook with .catch: tool.call
❯ ./register.tsx calls: $.clock.after (via deliver), $.clock.now (via decide, rewrite), $.clock.sleep (via race), $.command.register, $.model.classify (via classify), $.model.complete (via rewrite), $.model.fork (via rewrite), $.prompt.fill (via deliver, restoreOriginal), $.prompt.read (via deliver), $.session.messages (via lastReply, recent, rewriteOptions), $.session.model (via record, rewrite), $.session.surface, $.state.get, $.state.set, $.store.get (via bump, exemplarsOf, push, readList, runCommand), $.store.set (via bump, deliver, push, settlePending), $.ui.ask (via askUser), $.ui.resolve
❯ ./register.tsx state writes: sharpprompt.isOff, sharpprompt.lastDecision, sharpprompt.mode, sharpprompt.pending
❯ ./register.tsx state reads: sharpprompt.isOff, sharpprompt.lastDecision, sharpprompt.mode, sharpprompt.pending
✔ Validation passed
That is the whole list: the model, the prompt box, the question dialog, the session's messages and model, its own state and store, the clock, its two commands and the band above the box. A prompt rewriter sees everything you type, so you should be able to check that it cannot send it anywhere.
The rewriter gets a short rule set chosen by your session's model family:
Each rule is in docs/rules with a link to the Anthropic page it comes from. Two are ours rather than Anthropic's and say so: no role lines ("You are an expert..."), and a length limit (twice your prompt or 60 words, whichever is more, at most 180). docs/shapes has what a good prompt of each kind carries (fix, investigate, build, refactor, research, review, write, ask), with one example each.
/sharp stats shows the tokens per rough prompt. The Helper model setting picks only the classifier and the fallback used before the conversation has a first reply./sharp stats counts these.claude -p, the SDK) have no box to fill, so fill acts as context there. Mods do not run in a Desktop session that uses WSL.r puts it in the box, Enter sends it. sharpprompt does not send it for you because a prompt a plugin sends shows under the plugin's name in the transcript and skips @file expansion. The band's buttons answer to their keys once the band has focus: ctrl+x tab, or a click.| version | date | prompts | gate (mean) | classifier p50 | rewrite p50 | timeouts |
|---|---|---|---|---|---|---|
| 0.1.0 | 8 Oct 2026 | 19 typed, 8 rewritten | 17 us | 1.0 s (n=9) | 3.1 s (n=6) | 0 |
Benchmark, 30 cases, the prompt as typed against the rewrite: no measurable difference on Fable 5.1 (26 paired cases, report) or on Sonnet 5.5 (30 paired cases, report). A blind judge found none either. With Turkish drafts rewritten into English (Sonnet 5.5), task success and judge scores stayed the same while output tokens and time went down, mostly because the answers came back in English (report). On a second corpus of 30 cases built so the model lacks something only the user knows (an unstated requirement, a vague pointer back into a long conversation, the session's last test run), the rewrite, with its questions answered by a model that knows what the user meant, raised passing checks from 13 to 25 on Sonnet 5.5 and from 12 to 25 on Fable 5.1, all of it in the first two kinds of case (Sonnet, Fable).
Each version gets a file in docs/measurements with the method and the raw table, and a row here. On ordinary prompts the rewrite made no measurable difference. When the prompt leaves out something only you know, the rewrite's questions recovered about half of it (hidden requirement) or all of it (a vague pointer back into the conversation); see the v2 reports. /sharp stats keeps the same numbers for your own sessions, on your machine only, and compares turns that started from your text with turns that started from a rewrite.
Tests: 76, in tests/, run with claude plugin test ..
No. In the default mode the rewrite waits in your box until you press Enter. Only replace mode, which you turn on yourself, sends the rewrite in place of your text.
Most prompts pass: short ones, commands, answers to Claude's questions, and anything the classifier calls clear. /sharp status says which reason applied to the last one.
One classifier call on the helper model for each prompt that passes the gate, and for a rough prompt one fork on your session's model. In the 0.1.0 measurements that fork used about 2,500 input tokens outside the cache, 64,000 read from the cache and 230 output tokens. /sharp stats shows your own numbers.
Start the prompt with raw:. The prefix is removed and the rest goes out as typed. /sharp off turns it off for the session.
The hooks run, but VS Code has no box for the mod to fill and no band, so the rewrite goes to Claude as a note beside your prompt (context mode) and your prompt goes out as typed.
Types come from the Claude Code build you run. Open an interactive session with the plugin once (claude --plugin-dir .) and the engine writes them to .claude-plugin/types/ (git-ignored); tsconfig.json extends the one written there. After a Claude Code update, open it again.
Rules and shapes live in docs/; node scripts/build-bank.mjs compiles them into hooks/bank.ts, because a mod cannot read files at run time. --check fails when the two drift.
Checks: claude plugin validate --strict ., claude plugin test ., tsc -p ., node scripts/build-bank.mjs --check.
Benchmark: node --experimental-strip-types --no-warnings --import ./scripts/ts-resolve.mjs scripts/bench.mjs --help; the corpus and how to run it are in docs/bench.
MIT. See LICENSE.
hooks/register.tsx 456 lines1import type { EngineInterface, ModelUsage, PromptSubmitInput, PromptSubmitResult, Register } from 'claude-code'
2import type { SharppromptDecision, SharppromptMode, SharppromptPending, SharppromptRewrite, SharppromptVerdict } from '../types'
3import { contextNote, describe, DROP_NOTE, EDIT_OVERLAP, HELP, overlap, parseCommand } from './flow'
4import { sessionFacts } from './facts'
5import { endsWithQuestion, gate } from './gate'
6import { applyAnswers, askInput, recommended, splitReply, type Question } from './questions'
7import { countKey, lastRewriteLine, MAX_RECORDS, summary, tokens, wordCount, type ClassifyRecord, type Counts, type RewriteRecord, type TurnRecord } from './stats'
8import { clean, completePrompt, familyOf, forkPrompt, type Exemplar, type Recent, type RewriteOptions } from './rewrite'
9
10// The engine checks that $ never leaves this file, so everything that calls
11// it lives here and gate.ts stays pure.
12
13const isOff = { plugin: 'sharpprompt', key: 'isOff' } as const
14const lastDecision = { plugin: 'sharpprompt', key: 'lastDecision' } as const
15const pending = { plugin: 'sharpprompt', key: 'pending' } as const
16const modeOverride = { plugin: 'sharpprompt', key: 'mode' } as const
17
18// The turn now running and how its prompt got there; tool calls counted on
19// the main thread. Module state: a reload mid-turn loses one turn's record.
20let outgoing: TurnRecord['prompt'] | null = null
21let toolCalls = 0
22
23type Options = Readonly<Record<string, unknown>>
24
25export const CLASSIFY_MS = 2_500
26
27// The labels the classifier picks from; the wording is what it judges by.
28const CLEAR = 'clear and specific'
29const ROUGH = 'rough: vague or missing what to deliver'
30export const REWRITE_MS = 5_000
31
32// Tokens a fork spent after it lost its race: it finished in the background
33// and was billed anyway. Read out by the stats in a later step.
34export const late = { forks: 0, usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } }
35
36function addLate(u: ModelUsage) {
37 late.forks++
38 late.usage.input_tokens += u.input_tokens
39 late.usage.output_tokens += u.output_tokens
40 late.usage.cache_read_input_tokens += u.cache_read_input_tokens
41 late.usage.cache_creation_input_tokens += u.cache_creation_input_tokens
42}
43
44const TIMEOUT: unique symbol = Symbol('timeout')
45
46// fork and classify take no time limit, so we race them against the clock.
47// The loser keeps running: a fork that lost still finishes in the background
48// and is billed (README, Limits).
49async function race<T>($: EngineInterface, work: Promise<T>, ms: number): Promise<T | typeof TIMEOUT> {
50 const stop = new AbortController()
51 // A sleep that fails for its own reasons must not read as a timeout: the
52 // work then runs unraced, and the engine's hook budget is the backstop.
53 const timer: Promise<typeof TIMEOUT> = $.clock.sleep(ms, { signal: stop.signal }).then(
54 () => TIMEOUT,
55 () => new Promise<never>(() => {}),
56 )
57 try {
58 return await Promise.race([work, timer])
59 } finally {
60 stop.abort()
61 }
62}
63
64async function classify($: EngineInterface, text: string, model: string): Promise<SharppromptVerdict> {
65 try {
66 const label = await race($, $.model.classify(text, [CLEAR, ROUGH], { model }), CLASSIFY_MS)
67 if (label === TIMEOUT) return 'timeout'
68 if (label === CLEAR) return 'clear'
69 if (label === ROUGH) return 'rough'
70 return 'error'
71 } catch {
72 return 'error'
73 }
74}
75
76async function recent($: EngineInterface): Promise<Recent[]> {
77 const rows = await $.session.messages()
78 return rows
79 .filter(r => r.text.trim() !== '')
80 .slice(-4)
81 .map(r => ({ role: r.role, text: r.text }))
82}
83
84async function exemplarsOf($: EngineInterface): Promise<Exemplar[]> {
85 const v = await $.store.get('exemplars')
86 return Array.isArray(v) ? (v as Exemplar[]) : []
87}
88
89// Fork first: it reads the conversation from the prompt cache. With nothing
90// to fork yet (first turn, after /clear) a plain completion with the last few
91// messages pasted in. Both race the clock; losing means no rewrite.
92async function rewrite($: EngineInterface, draft: string, helper: string, opts: RewriteOptions = {}): Promise<SharppromptRewrite> {
93 const started = await $.clock.now()
94 const family = familyOf(await $.session.model())
95 const examples = await exemplarsOf($)
96 const took = async () => (await $.clock.now()) - started
97
98 const forking = $.model.fork({ prompt: forkPrompt(draft, family, examples, opts) })
99 const forked = await race($, forking, REWRITE_MS)
100 if (forked === TIMEOUT) {
101 void forking.then(r => ('usage' in r ? addLate(r.usage) : undefined), () => {})
102 return { outcome: 'timeout', via: 'fork', ms: await took() }
103 }
104
105 let reply = forked
106 let via: 'fork' | 'complete' = 'fork'
107 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
108 via = 'complete'
109 reply = await $.model.complete({
110 model: helper,
111 prompt: completePrompt(draft, family, await recent($), examples, opts),
112 maxTokens: 600,
113 effort: 'low',
114 timeoutMs: REWRITE_MS,
115 })
116 if (!reply.isAnswered && reply.reason === 'aborted') return { outcome: 'timeout', via, ms: await took() }
117 }
118 if (!reply.isAnswered) return { outcome: 'error', via, ms: await took(), detail: reply.reason }
119
120 const { body, questions } = opts.ask ? splitReply(reply.text) : { body: reply.text, questions: [] }
121 const c = clean(body, draft)
122 const ms = await took()
123 if ('rejected' in c) return { outcome: c.rejected, via, ms, usage: reply.usage }
124 return { outcome: 'rewritten', via, ms, usage: reply.usage, text: c.rewritten, ...(questions.length ? { questions } : {}) }
125}
126
127async function lastReply($: EngineInterface): Promise<string> {
128 const rows = await $.session.messages()
129 for (let i = rows.length - 1; i >= 0; i--) {
130 const row = rows[i]
131 if (row?.role === 'assistant' && row.text.trim() !== '') return row.text
132 }
133 return ''
134}
135
136const helperModel = (options: Options) => (typeof options.optimizerModel === 'string' ? options.optimizerModel : 'haiku')
137// Both v0.2 helpers are on unless the user turned them off.
138async function rewriteOptions($: EngineInterface, options: Options): Promise<RewriteOptions> {
139 const facts = options.sessionFacts === false ? '' : sessionFacts(await $.session.messages())
140 return {
141 language: options.rewriteLanguage === 'en' ? 'en' : 'same',
142 ask: options.askBeforeSend !== false,
143 ...(facts ? { facts } : {}),
144 }
145}
146
147// The engine shows a plugin's questions one dialog at a time ($.ui.ask), so
148// the rewriter asks at most two. null when the person closes either dialog
149// or it cannot be shown: the prompt then goes out as typed.
150async function askUser($: EngineInterface, questions: readonly Question[]): Promise<Record<string, string> | null> {
151 const answers: Record<string, string> = {}
152 try {
153 for (const [i, q] of askInput(questions).questions.entries()) {
154 const header = questions.length > 1 ? `${q.header.slice(0, 8)} ${i + 1}/${questions.length}` : q.header
155 answers[q.question] = await $.ui.ask(q.question, { options: q.options.map(o => o.label), header })
156 }
157 return answers
158 } catch {
159 return null
160 }
161}
162
163async function modeOf($: EngineInterface, options: Options): Promise<SharppromptMode> {
164 const o = (await $.state.get(modeOverride)).value
165 if (o) return o
166 const m = options.mode
167 return m === 'replace' || m === 'context' || m === 'off' ? m : 'fill'
168}
169
170// What sharpprompt would do with this prompt. Anything without a rewritten
171// text means the prompt goes out exactly as typed.
172async function decide($: EngineInterface, e: PromptSubmitInput, options: Options, mode: SharppromptMode): Promise<SharppromptDecision> {
173 const off = mode === 'off' || (await $.state.get(isOff)).value === true
174 const g = gate({
175 text: e.text,
176 origin: e.origin,
177 minChars: typeof options.minChars === 'number' ? options.minChars : 40,
178 isOff: off,
179 })
180 if (g.pass) return { verdict: 'skip', reason: g.reason, text: g.text }
181
182 if (endsWithQuestion(await lastReply($))) return { verdict: 'skip', reason: 'answer', text: e.text }
183
184 const t0 = await $.clock.now()
185 const verdict = await classify($, e.text, helperModel(options))
186 const classifyMs = (await $.clock.now()) - t0
187 if (verdict !== 'rough') return { verdict, text: e.text, classifyMs }
188 return { verdict, text: e.text, classifyMs, rewrite: await rewrite($, e.text, helperModel(options), await rewriteOptions($, options)) }
189}
190
191// A prompt sent while our suggestion is waiting is the user's answer to it,
192// not a new draft. When they edited it first, the edit is kept as an example
193// of how they like their prompts.
194async function settlePending($: EngineInterface, text: string): Promise<'as-is' | 'edited' | null> {
195 const p = (await $.state.get(pending)).value
196 if (!p || p.kind !== 'filled') return null
197 await $.state.set(pending, null)
198 if (text === p.rewritten) return 'as-is'
199 if (overlap(text, p.rewritten) < EDIT_OVERLAP) return null
200 const list = await exemplarsOf($)
201 await $.store.set('exemplars', [...list, { original: p.rewritten, sent: text }].slice(-20))
202 return 'edited'
203}
204
205async function bump($: EngineInterface, add: Counts) {
206 const v = await $.store.get('counts')
207 const counts: Counts = v && typeof v === 'object' ? { ...(v as Counts) } : {}
208 for (const [k, n] of Object.entries(add)) counts[k] = (counts[k] ?? 0) + n
209 await $.store.set('counts', counts)
210}
211
212async function push<T>($: EngineInterface, key: 'rewrites' | 'turns' | 'classified', item: T) {
213 const v = await $.store.get(key)
214 const list = Array.isArray(v) ? (v as T[]) : []
215 await $.store.set(key, [...list, item].slice(-MAX_RECORDS))
216}
217
218async function readList<T>($: EngineInterface, key: 'rewrites' | 'turns' | 'classified'): Promise<T[]> {
219 const v = await $.store.get(key)
220 return Array.isArray(v) ? (v as T[]) : []
221}
222
223async function record($: EngineInterface, d: SharppromptDecision) {
224 await bump($, { [countKey(d)]: 1 })
225 if ('classifyMs' in d && d.classifyMs !== undefined) {
226 const item: ClassifyRecord = { verdict: d.verdict, ms: d.classifyMs, words: wordCount(d.text) }
227 await push($, 'classified', item)
228 }
229 if (!('rewrite' in d) || !d.rewrite) return
230 const r = d.rewrite
231 const item: RewriteRecord = {
232 outcome: r.outcome,
233 via: r.via,
234 ms: r.ms,
235 classifyMs: d.classifyMs,
236 usage: tokens(r.usage),
237 words: r.text ? [wordCount(d.text), wordCount(r.text)] : [wordCount(d.text)],
238 model: await $.session.model(),
239 }
240 await push($, 'rewrites', item)
241}
242
243async function deliver($: EngineInterface, e: PromptSubmitInput, rewritten: string, mode: SharppromptMode, next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>): Promise<PromptSubmitResult> {
244 // VS Code and headless sessions draw no box and no band: there the rewrite
245 // can only ride along as context.
246 const surface = await $.session.surface()
247 const drawn = surface === 'terminal' || surface === 'desktop'
248 const how = mode === 'fill' && !drawn ? 'context' : mode
249
250 if (how === 'context') {
251 outgoing = 'context'
252 return next({ ...e, context: [...(e.context ?? []), contextNote(rewritten)] })
253 }
254
255 if (how === 'replace') {
256 await $.store.set('lastOriginal', e.text)
257 await $.state.set(pending, { kind: 'replaced', original: e.text, rewritten })
258 outgoing = 'rewritten'
259 return next({ ...e, text: rewritten })
260 }
261
262 const filled = await $.prompt.fill({ text: rewritten, mode: 'replace' })
263 if (!filled.isFilled) {
264 outgoing = 'typed'
265 return next(e)
266 }
267 await $.store.set('lastOriginal', e.text)
268 await $.state.set(pending, { kind: 'filled', original: e.text, rewritten })
269 // Since Claude Code 2.1.295 a dropped prompt is put back in the box after
270 // our fill, so the box reads rewrite + original. The engine does that before
271 // the next timer tick, so one tick later we set the rewrite again. On 2.1.293
272 // the box already holds the rewrite alone and nothing happens.
273 $.clock.after(0, async () => {
274 const p = (await $.state.get(pending)).value
275 if (!p || p.kind !== 'filled' || p.rewritten !== rewritten) return
276 if ((await $.prompt.read()).text !== rewritten) await $.prompt.fill({ text: rewritten, mode: 'replace' })
277 })
278 return { drop: DROP_NOTE }
279}
280
281// Puts the user's own text back in the box. Sent as it stands, it goes out
282// untouched (prompt.submit checks it against pending); edited, it is a new
283// prompt. We never send it for them: a prompt a plugin submits shows in the
284// transcript under the plugin's name and skips @file expansion. pending is
285// written before the fill so an Enter right after it already sees it.
286async function restoreOriginal($: EngineInterface, p: SharppromptPending): Promise<boolean> {
287 await $.state.set(pending, { ...p, kind: 'restored' })
288 await bump($, { 'answer:original': 1 })
289 const r = await $.prompt.fill({ text: p.original, mode: 'replace' })
290 if (!r.isFilled) await $.state.set(pending, null)
291 return r.isFilled
292}
293
294// True when this is the user's own text we put back, sent unchanged.
295async function isRestored($: EngineInterface, text: string): Promise<boolean> {
296 const p = (await $.state.get(pending)).value
297 if (!p || p.kind !== 'restored') return false
298 await $.state.set(pending, null)
299 return text === p.original
300}
301
302async function runCommand($: EngineInterface, args: string, options: Options): Promise<string> {
303 const c = parseCommand(args)
304 switch (c.kind) {
305 case 'status': {
306 const mode = await modeOf($, options)
307 const off = (await $.state.get(isOff)).value === true
308 const last = (await readList<RewriteRecord>($, 'rewrites')).at(-1)
309 return `${off ? 'Off' : 'On'} for this session, mode ${mode}.\nLast prompt: ${describe((await $.state.get(lastDecision)).value ?? null)}\n${lastRewriteLine(last)}`
310 }
311 case 'on':
312 await $.state.set(isOff, false)
313 return 'On for this session.'
314 case 'off':
315 await $.state.set(isOff, true)
316 return 'Off for this session. Prompts go out as typed.'
317 case 'mode':
318 await $.state.set(modeOverride, c.mode)
319 return `Mode is ${c.mode} for this session.`
320 case 'undo': {
321 const original = await $.store.get('lastOriginal')
322 if (typeof original !== 'string') return 'Nothing to undo.'
323 const ok = await restoreOriginal($, { kind: 'restored', original, rewritten: '' })
324 return ok ? 'Your original is back in the prompt box; sent as it stands, it goes out untouched.' : `Could not reach the prompt box. Your original was:\n${original}`
325 }
326 case 'stats': {
327 const v = await $.store.get('counts')
328 const counts = v && typeof v === 'object' ? (v as Counts) : {}
329 return summary(counts, await readList<RewriteRecord>($, 'rewrites'), await readList<TurnRecord>($, 'turns'), await readList<ClassifyRecord>($, 'classified'))
330 }
331 case 'try': {
332 const verdict = await classify($, c.text, helperModel(options))
333 const r = await rewrite($, c.text, helperModel(options), await rewriteOptions($, options))
334 const asks = r.questions?.length ? `\n\nWould ask:\n${r.questions.map(q => `- ${q.question} ${q.options.map(o => o.label).join(' / ')}`).join('\n')}` : ''
335 return `classify: ${verdict}\nrewrite: ${r.outcome} via ${r.via} in ${r.ms} ms${r.text ? `\n\n${r.text}` : ''}${asks}`
336 }
337 case 'help':
338 return `${c.error ? `${c.error}\n` : ''}${HELP}`
339 }
340}
341
342// Whatever happens in here, the prompt goes out. A thrown error lands in
343// .catch, which sends it untouched.
344export const register: Register = (on, options) => {
345 on('session.start', async ($, e, next) => {
346 await $.command.register({ name: 'sharpprompt', description: 'Prompt rewriting: status, on, off, mode, undo, stats, try', argumentHint: '[status|on|off|mode|undo|stats|try]' })
347 await $.command.register({ name: 'sharp', description: 'Short for /sharpprompt', argumentHint: '[status|on|off|mode|undo|stats|try]' })
348 return next(e)
349 })
350
351 on('command.run', { command: 'sharpprompt' }, async ($, e) => ({ text: await runCommand($, e.args, options) }))
352 on('command.run', { command: 'sharp' }, async ($, e) => ({ text: await runCommand($, e.args, options) }))
353
354 on('prompt.submit', async ($, e, next) => {
355 const typedByUser = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
356 outgoing = null
357 toolCalls = 0
358 if (typedByUser) {
359 if (await isRestored($, e.text)) {
360 const d: SharppromptDecision = { verdict: 'skip', reason: 'back-to-mine', text: e.text }
361 await $.state.set(lastDecision, d)
362 await record($, d)
363 outgoing = 'typed'
364 return next(e)
365 }
366 const answered = await settlePending($, e.text)
367 if (answered) {
368 await $.state.set(lastDecision, { verdict: 'skip', reason: 'suggested', text: e.text })
369 await bump($, { [`answer:${answered}`]: 1 })
370 outgoing = answered === 'as-is' ? 'rewritten' : 'edited'
371 return next(e)
372 }
373 }
374 const mode = await modeOf($, options)
375 const d = await decide($, e, options, mode)
376 await $.state.set(lastDecision, d)
377 if (typedByUser) await record($, d)
378 let rewritten = 'rewrite' in d ? d.rewrite?.text : undefined
379 const questions = ('rewrite' in d ? d.rewrite?.questions : undefined) ?? []
380 if (rewritten && questions.length) {
381 const surface = await $.session.surface()
382 if (surface === 'terminal' || surface === 'desktop') {
383 const answers = await askUser($, questions)
384 if (!answers) {
385 await bump($, { 'ask:dismissed': 1 })
386 if (typedByUser) outgoing = 'typed'
387 return next(e)
388 }
389 await bump($, { 'ask:answered': 1 })
390 rewritten = applyAnswers(rewritten, questions, answers)
391 } else {
392 rewritten = recommended(rewritten, questions)
393 }
394 }
395 if (rewritten) return deliver($, e, rewritten, mode, next)
396 if (typedByUser) outgoing = 'typed'
397 return next(d.text !== e.text ? { ...e, text: d.text } : e)
398 }).catch(($, e, next) => next(e))
399
400 on('tool.call', ($, e, next) => {
401 if (!e.agentId) toolCalls++
402 return next(e)
403 }).catch(($, e, next) => next(e))
404
405 // One record per turn a typed prompt started, for /sharp stats. Subagent
406 // turns and turns nobody typed are left out.
407 on('turn.complete', async ($, e, next) => {
408 if (!e.agentId && outgoing) {
409 const turn: TurnRecord = {
410 prompt: outgoing,
411 durationMs: e.durationMs,
412 tools: toolCalls,
413 asked: endsWithQuestion(e.answer),
414 usage: tokens(e.usage),
415 aborted: e.isAborted,
416 }
417 outgoing = null
418 await push($, 'turns', turn)
419 }
420 if (late.forks > 0) {
421 const add = { 'late:forks': late.forks, 'late:input': late.usage.input_tokens + late.usage.cache_read_input_tokens + late.usage.cache_creation_input_tokens, 'late:output': late.usage.output_tokens }
422 late.forks = 0
423 late.usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }
424 await bump($, add)
425 }
426 return next(e)
427 })
428
429 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
430 const p = (await $.state.get(pending)).value
431 if (!p || p.kind === 'restored' || e.props.hasSurvey) return next(e)
432 const { Box, Button, Text } = $.ui.resolve(e)
433 const was = p.original.length > 70 ? `${p.original.slice(0, 70)}...` : p.original
434 if (p.kind === 'replaced') {
435 return (
436 <Box flexDirection="column">
437 <Text dimColor>sharpprompt sent a rewrite of: {was}</Text>
438 <Box>
439 <Button key="undo" hotkey="u" label="put my original in the box" onPress={() => restoreOriginal($, p)} />
440 <Button key="dismiss" hotkey="x" label="ok" role="dismiss" onPress={() => $.state.set(pending, null)} />
441 </Box>
442 </Box>
443 )
444 }
445 return (
446 <Box flexDirection="column">
447 <Text dimColor>sharpprompt rewrote: {was}</Text>
448 <Box>
449 <Text dimColor>Enter sends it, or edit it in the box. </Text>
450 <Button key="raw" hotkey="r" label="back to mine" onPress={() => restoreOriginal($, p)} />
451 </Box>
452 </Box>
453 )
454 })
455}
456hooks/flow.ts 77 lines1import type { SharppromptDecision, SharppromptMode } from '../types'
2
3// Pure helpers for delivery and the slash command.
4
5export const MODES: readonly SharppromptMode[] = ['fill', 'replace', 'context', 'off']
6
7const bag = (s: string) => new Set(s.toLowerCase().split(/\W+/).filter(Boolean))
8
9// Word overlap of two texts, 0 to 1. Above EDIT_OVERLAP a prompt sent while
10// our suggestion sits in the box counts as the suggestion, edited.
11export function overlap(a: string, b: string): number {
12 const x = bag(a)
13 const y = bag(b)
14 if (x.size === 0 && y.size === 0) return 1
15 let both = 0
16 for (const w of x) if (y.has(w)) both++
17 return both / (x.size + y.size - both)
18}
19
20export const EDIT_OVERLAP = 0.4
21
22export function contextNote(rewritten: string): string {
23 return `The sharpprompt plugin read the user's request above as the following. Use it only where it matches what the user wrote:\n${rewritten}`
24}
25
26export const DROP_NOTE = 'sharpprompt put a clearer version in the box. Enter sends it, ctrl+x tab then r puts yours back.'
27
28export type Command =
29 | { kind: 'status' }
30 | { kind: 'on' }
31 | { kind: 'off' }
32 | { kind: 'mode'; mode: SharppromptMode }
33 | { kind: 'undo' }
34 | { kind: 'stats' }
35 | { kind: 'try'; text: string }
36 | { kind: 'help'; error?: string }
37
38export function parseCommand(args: string): Command {
39 const [word = '', ...rest] = args.trim().split(/\s+/)
40 const tail = args.trim().slice(word.length).trim()
41 switch (word.toLowerCase()) {
42 case '':
43 case 'status':
44 return { kind: 'status' }
45 case 'on':
46 return { kind: 'on' }
47 case 'off':
48 return { kind: 'off' }
49 case 'undo':
50 return { kind: 'undo' }
51 case 'stats':
52 return { kind: 'stats' }
53 case 'try':
54 return tail ? { kind: 'try', text: tail } : { kind: 'help', error: 'try needs a prompt to try' }
55 case 'mode': {
56 const m = rest[0] as SharppromptMode | undefined
57 return m && MODES.includes(m) ? { kind: 'mode', mode: m } : { kind: 'help', error: `mode is one of ${MODES.join(', ')}` }
58 }
59 default:
60 return { kind: 'help', error: `unknown: ${word}` }
61 }
62}
63
64export const HELP = `/sharpprompt (or /sharp) status | on | off | mode fill|replace|context|off | undo | stats | try <prompt>
65Start a prompt with raw: to send it untouched.`
66
67export function describe(d: SharppromptDecision | null): string {
68 if (!d) return 'no prompt seen yet'
69 if (d.verdict === 'skip') return `passed untouched (${d.reason})`
70 const words = (t: string) => t.split(/\s+/).filter(Boolean).length
71 const classify = d.classifyMs === undefined ? '' : ` in ${d.classifyMs} ms`
72 if (!('rewrite' in d) || !d.rewrite) return `classified ${d.verdict}${classify}, sent as typed`
73 const r = d.rewrite
74 const size = r.text ? `, ${words(d.text)} -> ${words(r.text)} words` : `, ${words(d.text)} words`
75 return `classified rough${classify}, rewrite ${r.outcome} via ${r.via} in ${r.ms} ms${size}`
76}
77hooks/facts.ts 46 lines1import type { SessionMessage } from 'claude-code'
2
3// What this session has touched, read from the transcript alone (no disk):
4// files read or changed, the last command that failed, and the gist of
5// Claude's last reply. Kept under about 300 tokens.
6
7const FILE_TOOLS: Record<string, 'read' | 'changed'> = { Read: 'read', Edit: 'changed', Write: 'changed', MultiEdit: 'changed', NotebookEdit: 'changed' }
8const MAX_CHARS = 1200
9
10const tail = (s: string, n: number) => (s.length > n ? `...${s.slice(-n)}` : s)
11const head = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}...` : s)
12
13export function sessionFacts(rows: readonly SessionMessage[]): string {
14 const read = new Set<string>()
15 const changed = new Set<string>()
16 let failed: { command: string; output: string } | null = null
17 let lastReply = ''
18
19 for (const row of rows) {
20 if (row.role === 'assistant' && row.text.trim()) lastReply = row.text.trim()
21 for (const use of row.toolUses ?? []) {
22 const kind = FILE_TOOLS[use.tool]
23 const path = typeof use.input?.file_path === 'string' ? use.input.file_path : typeof use.input?.notebook_path === 'string' ? use.input.notebook_path : null
24 if (kind && path) {
25 if (kind === 'changed') changed.add(path)
26 else read.add(path)
27 }
28 if (use.tool === 'Bash' && typeof use.input?.command === 'string') {
29 const out = use.text ?? ''
30 // "0 failed" is a pass; "2 failed", FAIL, a traceback or an error line is not.
31 const bad = use.isError === true || /\b(FAIL|FAILED|Traceback)\b|\w*Error\b|\b[1-9]\d* failed\b/.test(out)
32 failed = bad ? { command: use.input.command, output: out } : null
33 }
34 }
35 }
36 for (const p of changed) read.delete(p)
37
38 const lines: string[] = []
39 if (changed.size) lines.push(`Files changed this session: ${[...changed].slice(-8).join(', ')}`)
40 if (read.size) lines.push(`Files read this session: ${[...read].slice(-8).join(', ')}`)
41 if (failed) lines.push(`Last command failed: ${head(failed.command, 120)}\nIts output ended with:\n${tail(failed.output.trim(), 400)}`)
42 if (lastReply) lines.push(`Claude's last reply began: ${head(lastReply.replace(/\s+/g, ' '), 240)}`)
43 const text = lines.join('\n')
44 return text.length > MAX_CHARS ? head(text, MAX_CHARS) : text
45}
46hooks/gate.ts 58 lines1import type { PromptOrigin } from 'claude-code'
2import type { SharppromptSkip } from '../types'
3
4// The cheap part of the decision: no model, no awaits. Anything this lets
5// through still has to be called rough by the classifier before we touch it.
6
7export type GateInput = {
8 text: string
9 origin: PromptOrigin
10 minChars: number
11 isOff: boolean
12}
13
14export type GateDecision =
15 | { pass: true; reason: SharppromptSkip; text: string }
16 | { pass: false }
17
18export const MAX_CHARS = 20_000
19
20const RAW_PREFIX = /^raw:\s?/i
21const HARNESS_TAG = /<(task-notification|system-reminder|command-[a-z-]+|local-command-[a-z-]+)[\s>]/
22
23// Only what a person typed (or sent from their phone) is ours to look at.
24const TYPED: ReadonlySet<PromptOrigin['kind']> = new Set(['composer', 'bridge'])
25
26export function gate(input: GateInput): GateDecision {
27 const { text, origin, minChars, isOff } = input
28 const pass = (reason: SharppromptSkip, out = text): GateDecision => ({ pass: true, reason, text: out })
29
30 if (!TYPED.has(origin.kind)) return pass('not-typed')
31 if (RAW_PREFIX.test(text)) return pass('raw', text.replace(RAW_PREFIX, ''))
32 if (isOff) return pass('off')
33
34 const trimmed = text.trim()
35 if (trimmed.startsWith('/')) return pass('command')
36 if (trimmed.startsWith('#')) return pass('heading')
37 if (trimmed.length < minChars) return pass('too-short')
38 if (trimmed.length > MAX_CHARS) return pass('too-long')
39 if (HARNESS_TAG.test(trimmed)) return pass('harness-tag')
40
41 return { pass: false }
42}
43
44// When Claude's last reply ended on a question, the next prompt is almost
45// always the answer to it, and rewriting an answer only gets in the way.
46// A code block at the very end ("Want me to run it?" then the command) is
47// skipped, so the question before it counts and a "?" inside it does not.
48export function endsWithQuestion(reply: string): boolean {
49 let text = reply.trimEnd()
50 while (text.endsWith('```')) {
51 const open = text.lastIndexOf('```', text.length - 4)
52 if (open < 0) break
53 text = text.slice(0, open).trimEnd()
54 }
55 const tail = text.replace(/[*_`)\]"'”’»\s]+$/u, '')
56 return tail.endsWith('?') || tail.endsWith('?')
57}
58hooks/questions.ts 70 lines1// The questions a rewrite may come back with, and how answers turn into
2// sentences of the prompt. No $ here.
3
4export type QuestionOption = { label: string; adds: string }
5export type Question = { question: string; header: string; options: QuestionOption[] }
6
7// Each question is its own dialog, so two at most.
8export const MAX_QUESTIONS = 2
9
10// The rewriter writes its rewrite, then optionally a QUESTIONS: line with a
11// JSON array. Anything malformed means no questions, never a broken rewrite.
12export function splitReply(reply: string): { body: string; questions: Question[] } {
13 const at = reply.search(/\n\s*QUESTIONS:\s*/)
14 if (at < 0) return { body: reply, questions: [] }
15 const body = reply.slice(0, at)
16 const json = reply.slice(at).replace(/^\s*QUESTIONS:\s*/, '').trim()
17 let raw: unknown
18 try {
19 raw = JSON.parse(json.replace(/^```(?:json)?\s*|\s*```$/g, ''))
20 } catch {
21 return { body, questions: [] }
22 }
23 if (!Array.isArray(raw)) return { body, questions: [] }
24 const questions: Question[] = []
25 for (const q of raw) {
26 if (questions.length === MAX_QUESTIONS) break
27 if (!q || typeof q.question !== 'string' || !Array.isArray(q.options)) continue
28 const options = q.options
29 .filter((o: unknown): o is QuestionOption => !!o && typeof (o as QuestionOption).label === 'string' && typeof (o as QuestionOption).adds === 'string')
30 .slice(0, 4)
31 .map((o: QuestionOption) => ({ label: o.label.trim().slice(0, 60), adds: o.adds.trim() }))
32 if (options.length < 2) continue
33 const header = typeof q.header === 'string' && q.header.trim() ? q.header.trim().slice(0, 12) : 'Question'
34 questions.push({ question: q.question.trim(), header, options })
35 }
36 return { body, questions }
37}
38
39// The AskUserQuestion input: the first option is the rewriter's
40// recommendation and says so.
41export function askInput(questions: readonly Question[]) {
42 return {
43 questions: questions.map(q => ({
44 question: q.question,
45 header: q.header,
46 multiSelect: false,
47 options: q.options.map((o, i) => ({ label: i === 0 ? `${o.label} (recommended)` : o.label, description: o.adds })),
48 })),
49 }
50}
51
52// Answers keyed by question text, as AskUserQuestion returns them. A picked
53// option adds its sentence; anything else typed under "Other" is added as
54// the user wrote it. An unanswered question adds the recommended option.
55export function applyAnswers(rewrite: string, questions: readonly Question[], answers: Readonly<Record<string, unknown>>): string {
56 const lines = questions.map(q => {
57 const a = typeof answers[q.question] === 'string' ? (answers[q.question] as string).trim() : ''
58 const picked = q.options.find((o, i) => a === o.label || a === `${o.label} (recommended)` || (i === 0 && a === ''))
59 if (picked) return picked.adds
60 return `${q.header}: ${a}.`
61 })
62 return [rewrite.trim(), ...lines].join(' ')
63}
64
65// With no one to ask (VS Code, headless), each question takes its
66// recommended option.
67export function recommended(rewrite: string, questions: readonly Question[]): string {
68 return applyAnswers(rewrite, questions, {})
69}
70hooks/stats.ts 100 lines1import type { SharppromptDecision } from '../types'
2
3// What we keep per turn and how /sharp stats sums it up. Everything stays in
4// $.store on the user's machine.
5
6export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
7
8export type TurnRecord = {
9 // How the prompt that started the turn got there.
10 prompt: 'typed' | 'rewritten' | 'edited' | 'context'
11 durationMs: number
12 tools: number
13 // Claude's final text ended on a question: it needed more from the user.
14 asked: boolean
15 usage?: Tokens
16 aborted: boolean
17}
18
19export type RewriteRecord = {
20 outcome: string
21 via: 'fork' | 'complete'
22 ms: number
23 classifyMs?: number
24 usage?: Tokens
25 // Words in the draft and in the rewrite, when there was one.
26 words?: [number, number?]
27 // The session model the fork ran on.
28 model?: string
29}
30
31// Every classified prompt, so short-but-rough patterns show once n grows.
32export type ClassifyRecord = { verdict: string; ms: number; words: number }
33
34export type Counts = Record<string, number>
35
36export const MAX_RECORDS = 500
37
38export const wordCount = (t: string) => t.split(/\s+/).filter(Boolean).length
39
40export function lastRewriteLine(r: RewriteRecord | undefined): string {
41 if (!r) return 'Last rewrite: none yet'
42 const w = r.words ? `, ${r.words[0]}${r.words[1] === undefined ? '' : ` -> ${r.words[1]}`} words` : ''
43 const c = r.classifyMs === undefined ? '' : `classify ${r.classifyMs} ms, `
44 return `Last rewrite: ${r.outcome}, ${c}${r.via} ${r.ms} ms${w}`
45}
46
47export function tokens(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined): Tokens | undefined {
48 if (!u) return undefined
49 return { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens }
50}
51
52// The counter a decision bumps: why it passed, or what the classifier said.
53export function countKey(d: SharppromptDecision): string {
54 if (d.verdict === 'skip') return `skip:${d.reason}`
55 return `verdict:${d.verdict}`
56}
57
58export function pct(values: readonly number[], p: number): number | undefined {
59 if (values.length === 0) return undefined
60 const sorted = [...values].sort((a, b) => a - b)
61 return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]
62}
63
64const avg = (xs: readonly number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : undefined)
65const fmt = (x: number | undefined, unit = '') => (x === undefined ? '-' : `${Math.round(x)}${unit}`)
66const spread = (xs: readonly number[]) => (xs.length ? `${fmt(pct(xs, 50))} / ${fmt(pct(xs, 95))} ms (n=${xs.length})` : '-')
67const share = (n: number, of: number) => (of ? `${Math.round((100 * n) / of)}%` : '-')
68
69function turnLine(label: string, turns: readonly TurnRecord[]): string {
70 const done = turns.filter(t => !t.aborted)
71 return `${label}: n=${turns.length}, tools ${fmt(avg(done.map(t => t.tools)))}, ${fmt(avg(done.map(t => t.durationMs / 1000)), ' s')}, Claude asked back ${share(done.filter(t => t.asked).length, done.length)}, output ${fmt(avg(done.map(t => t.usage?.output ?? 0)), ' tok')}`
72}
73
74export function summary(counts: Counts, rewrites: readonly RewriteRecord[], turns: readonly TurnRecord[], classified: readonly ClassifyRecord[] = []): string {
75 const c = (k: string) => counts[k] ?? 0
76 const skips = Object.entries(counts)
77 .filter(([k]) => k.startsWith('skip:'))
78 .sort((a, b) => b[1] - a[1])
79 .map(([k, v]) => `${k.slice(5)} ${v}`)
80 const seen = Object.entries(counts).filter(([k]) => k.startsWith('skip:') || k.startsWith('verdict:')).reduce((a, [, v]) => a + v, 0)
81 const ok = rewrites.filter(r => r.outcome === 'rewritten')
82 const forks = rewrites.filter(r => r.via === 'fork' && r.outcome !== 'timeout')
83 const rough = c('verdict:rough')
84 const spent = rewrites.map(r => r.usage).filter((u): u is Tokens => !!u)
85
86 return [
87 `Prompts seen: ${seen}. Passed by the gate: ${skips.join(', ') || 'none'}.`,
88 `Classified: clear ${c('verdict:clear')}, rough ${rough}, timeout ${c('verdict:timeout')}, error ${c('verdict:error')}.`,
89 `Rewrites: ${ok.length} made, ${rewrites.length - ok.length} not (${['keep', 'same', 'too-long', 'empty', 'timeout', 'error'].map(o => `${o} ${rewrites.filter(r => r.outcome === o).length}`).join(', ')}).`,
90 `What you did with them: sent as is ${c('answer:as-is')}, edited ${c('answer:edited')}, took yours back ${c('answer:original')}.`,
91 `Wait (p50 / p95): classify ${spread(classified.map(x => x.ms))}, fork ${spread(forks.map(r => r.ms))}, fallback completion ${spread(rewrites.filter(r => r.via === 'complete').map(r => r.ms))}.`,
92 `Cost per rough prompt: ${fmt(avg(spent.map(u => u.input)))} in, ${fmt(avg(spent.map(u => u.cacheRead)))} from cache, ${fmt(avg(spent.map(u => u.output)))} out. Forks that lost the race and finished anyway: ${c('late:forks')}, ${c('late:output')} output tokens, ${c('late:input')} input.`,
93 turnLine('Turns after a typed prompt', turns.filter(t => t.prompt === 'typed')),
94 turnLine('Turns after a rewrite', turns.filter(t => t.prompt !== 'typed')),
95 turns.length < 30 ? 'Too few turns to compare yet; these numbers are not evidence.' : '',
96 ]
97 .filter(Boolean)
98 .join('\n')
99}
100hooks/rewrite.ts 135 lines1import { RULES, SHAPES, type Family } from './bank'
2
3// Everything about the rewrite that needs no $: which rules apply, the text
4// we send the model, and what we accept back.
5
6export const MAX_WORDS = 180
7export const MIN_ROOM = 60
8export const KEEP = 'KEEP'
9
10export type Exemplar = { original: string; sent: string }
11
12// 'same' keeps the user's language; 'en' asks for English whatever the
13// draft is written in (the rewriteLanguage setting).
14export type RewriteOptions = {
15 language?: 'same' | 'en'
16 // May the rewriter ask about gaps before the prompt goes out?
17 ask?: boolean
18 // What the session touched, from the transcript (see facts.ts).
19 facts?: string
20 // How many of the last messages the fallback completion sees (default 4).
21 // The benchmark passes them all, standing in for the fork it cannot use.
22 window?: number
23}
24
25const ASK = `If the draft leaves out something that the conversation does not answer and that would change the work (which file or function, which of two behaviours, a format or limit), you may ask about it, at most 2 questions. Do not ask what you can reasonably infer, and never ask whether to add tests (the answer is no). If you ask, leave those points out of the rewrite, because the answers will be added after it. Then, after the rewrite, write a line QUESTIONS: followed by a JSON array, each item {"question": "...?", "header": "<12 characters", "options": [{"label": "<1-5 words>", "adds": "<the sentence to add to the prompt if picked>"}]}, 2 to 4 options each, your recommended option first. If nothing needs asking, write no QUESTIONS line.`
26
27const ENGLISH = `Write the rewrite in English, whatever language the draft is in; this overrides the rules about the user's language. Keep file names, function names, commands and quoted text exactly as written. If the draft is not in English, do not reply ${'KEEP'}: translate it while you make it clear.`
28
29export function familyOf(model: string): Family {
30 const m = model.toLowerCase()
31 for (const f of ['fable', 'opus', 'sonnet', 'haiku'] as const) {
32 if (m.includes(f)) return f
33 }
34 return 'common'
35}
36
37const words = (s: string) => s.split(/\s+/).filter(Boolean).length
38
39const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}...` : s)
40
41function rulesFor(family: Family): string {
42 const list = family === 'common' ? RULES.common : [...RULES.common, ...RULES[family]]
43 return list.map(r => `- ${r.text}`).join('\n')
44}
45
46function shapes(): string {
47 return SHAPES.map(s =>
48 [
49 `${s.name}: ${s.summary} A good one carries: ${s.fields}`,
50 ` Before: ${s.before}`,
51 ` After: ${s.after}`,
52 ].join('\n'),
53 ).join('\n')
54}
55
56// The user's own edits of earlier suggestions: what they changed our
57// rewrite into is the best signal of how they like their prompts.
58function exemplars(list: readonly Exemplar[]): string {
59 if (list.length === 0) return ''
60 const items = list
61 .slice(-20)
62 .map(x => `<example>\nSuggested: ${clip(x.original, 400)}\nUser sent instead: ${clip(x.sent, 400)}\n</example>`)
63 .join('\n')
64 return `\nWhen this user edited a suggestion before sending, this is what they changed it to. Match their taste:\n<examples>\n${items}\n</examples>\n`
65}
66
67// How long a rewrite of this draft may be: twice the draft or 60 words,
68// whichever is more, never past 180. The model gets this number, not 180:
69// told 180, it wrote 77 and 125 words for 16 and 14 word drafts.
70export function wordLimit(draft: string): number {
71 return Math.min(Math.max(2 * words(draft), MIN_ROOM), MAX_WORDS)
72}
73
74function instructions(family: Family, list: readonly Exemplar[], limit: number, opts: RewriteOptions): string {
75 return `Rules:
76${rulesFor(family)}
77${opts.language === 'en' ? `\n${ENGLISH}\n` : ''}${opts.ask ? `\n${ASK}\n` : ''}
78Task shapes, to see what a good prompt of each kind carries. Pick the closest one, or none:
79${shapes()}
80${exemplars(list)}
81If the draft is already clear enough to act on, reply with exactly ${KEEP}.
82Give the rewritten prompt and nothing else, at most ${limit} words.`
83}
84
85// For $.model.fork: the model sees the whole conversation before this, so
86// "the file above" can be resolved without us quoting anything.
87export function forkPrompt(draft: string, family: Family, list: readonly Exemplar[] = [], opts: RewriteOptions = {}): string {
88 return `This message is from the sharpprompt plugin, not the user, and is not a task to carry out. The user has typed the draft below as their next message to you and has not sent it yet. Rewrite it so it is clear for you to act on in this conversation, keeping their intent and voice.
89${factsBlock(opts)}
90<draft>
91${draft}
92</draft>
93
94${instructions(family, list, wordLimit(draft), opts)}`
95}
96
97export type Recent = { role: 'user' | 'assistant'; text: string }
98
99function factsBlock(opts: RewriteOptions): string {
100 return opts.facts ? `\nWhat this session has touched, from its transcript:\n<session_facts>\n${opts.facts}\n</session_facts>\n` : ''
101}
102
103// For $.model.complete when there is nothing to fork yet: no history, so we
104// hand over the last few messages ourselves, cut short.
105export function completePrompt(draft: string, family: Family, recent: readonly Recent[], list: readonly Exemplar[] = [], opts: RewriteOptions = {}): string {
106 const convo = recent
107 .slice(-(opts.window ?? 4))
108 .map(m => `<${m.role}>${clip(m.text, 600)}</${m.role}>`)
109 .join('\n')
110 return `A user of Claude Code has typed the draft below as their next message and has not sent it yet. Rewrite it so it is clear for Claude to act on, keeping their intent and voice.
111${convo ? `\nThe last messages of the conversation, cut short:\n<conversation>\n${convo}\n</conversation>\n` : ''}${factsBlock(opts)}
112<draft>
113${draft}
114</draft>
115
116${instructions(family, list, wordLimit(draft), opts)}`
117}
118
119export type Cleaned = { rewritten: string } | { rejected: 'keep' | 'empty' | 'too-long' | 'same' }
120
121// What the model sent back, made safe to put in the prompt box: fences and
122// wrapping quotes off, and turned down when it is no rewrite at all.
123export function clean(reply: string, draft: string): Cleaned {
124 let t = reply.trim()
125 t = t.replace(/^```[a-z]*\n([\s\S]*?)\n```$/i, '$1').trim()
126 t = t.replace(/^<draft>\s*([\s\S]*?)\s*<\/draft>$/i, '$1').trim()
127 if (/^["“].*["”]$/s.test(t)) t = t.slice(1, -1).trim()
128 if (t === '') return { rejected: 'empty' }
129 if (t === KEEP || t.replace(/[.!]$/, '') === KEEP) return { rejected: 'keep' }
130 // A little slack so a near miss still reaches the user.
131 if (words(t) > wordLimit(draft) + 15) return { rejected: 'too-long' }
132 if (t === draft.trim()) return { rejected: 'same' }
133 return { rewritten: t }
134}
135hooks/bank.ts 171 lines1// Generated by scripts/build-bank.mjs from docs/rules and docs/shapes.
2// Edit those files, not this one.
3
4export type Family = 'common' | 'fable' | 'opus' | 'sonnet' | 'haiku'
5export type Rule = { id: string; text: string }
6export type Shape = { name: string; summary: string; fields: string; context: string; before: string; after: string }
7
8export const RULES: Record<Family, readonly Rule[]> = {
9 "common": [
10 {
11 "id": "keep-intent",
12 "text": "Keep what the user asked for. Do not add tasks, change the goal, or answer the prompt yourself. Resolve references to the conversation (\"the second item above\", \"that file\", \"same as before\") into the concrete names they point to, and invent nothing the conversation does not show: no file names, numbers, deadlines or requirements. Keep the user's own uncertainty (\"I guess\", \"maybe\") where they wrote it, and never add guesses of your own."
13 },
14 {
15 "id": "give-the-reason",
16 "text": "When the conversation or the prompt shows why the user wants this or who it is for, say it in one plain sentence. If the reason is not knowable, leave it out rather than guess."
17 },
18 {
19 "id": "name-the-deliverable",
20 "text": "Make the output explicit: what should exist or be answered when Claude is done, and how the user will tell it is done (tests pass, a file exists, a question answered). Use a check that already exists (the tests, a command) as the done signal; do not add work the user did not ask for, such as new tests. Use a short numbered list only when the order of steps matters."
21 },
22 {
23 "id": "question-stays-a-question",
24 "text": "If the user is asking a question, describing a problem or thinking out loud, the rewrite asks for an assessment and says not to change anything yet. Never turn a question into an order to fix."
25 },
26 {
27 "id": "state-what-is-out",
28 "text": "When the prompt or conversation makes clear what must not be touched, say it in one sentence (files, behaviour, scope). Do not invent restrictions."
29 },
30 {
31 "id": "ideas-mean-ideas",
32 "text": "If the user asks for ideas, options or a plan, the rewrite asks for that and says to wait before building anything."
33 },
34 {
35 "id": "no-reasoning-echo",
36 "text": "Never ask Claude to show, write out or explain its thinking or reasoning, and never add \"think step by step\". Recent models may refuse such requests (the reasoning_extraction category). Asking for a short explanation of the answer is fine."
37 },
38 {
39 "id": "calm-language",
40 "text": "No capital-letter emphasis, no \"CRITICAL\" or \"MUST\", no threats or bribes, no urgency. Current models follow plain instructions and over-apply shouted ones."
41 },
42 {
43 "id": "no-role-filler",
44 "text": "Do not open with a role (\"You are an expert ...\"). The prompt is a user turn inside Claude Code, which already has its system prompt; a role line there only adds length."
45 },
46 {
47 "id": "stay-short",
48 "text": "The rewrite is at most twice the length of the original or 60 words, whichever is more, and never more than 180 words. Write it in the user's language and voice (or in English when the rewriteLanguage setting says en), first person, as if they had typed it carefully. No headings, no XML tags, no preamble."
49 },
50 {
51 "id": "latest-reference",
52 "text": "When a reference in the draft (\"that function\", \"the file\", \"the test\") fits more than one thing in the conversation, take the most recent one and name it, with its file when there is one."
53 }
54 ],
55 "fable": [
56 {
57 "id": "fable-brief-is-enough",
58 "text": "Prefer one short instruction over a list of cases. Fable follows brief instructions well and over-prescriptive prompts can make its output worse."
59 },
60 {
61 "id": "fable-ask-for-updates",
62 "text": "If the task is long and the user wants to follow along, ask for a line before starting and a short recap at the end. Fable 5.1 writes few updates between tool calls unless asked. Do not ask it to keep updates brief."
63 },
64 {
65 "id": "fable-ambiguity",
66 "text": "Where the request can be read two ways and the conversation does not settle it, ask Claude to take the most direct reading and state that assumption, rather than build for both."
67 },
68 {
69 "id": "fable-bug-phrasing",
70 "text": "Turn \"does this compile?\" into \"are there any bugs in this?\". The compile-check phrasing trips safety classifiers more often."
71 }
72 ],
73 "opus": [
74 {
75 "id": "opus-no-verify-step",
76 "text": "Do not add \"verify your work\" or \"add a final verification step\". Opus 5 verifies on its own and extra verification instructions waste tokens."
77 },
78 {
79 "id": "opus-hold-scope",
80 "text": "For a narrow task, say plainly to deliver what was asked at that scope and to mention, not do, anything beyond it. Opus 5 can widen a task on its own."
81 },
82 {
83 "id": "opus-length",
84 "text": "If the user wants a document or reply of a certain size, say so. Opus 5 writes longer replies and documents by default, and effort does not reliably shorten them."
85 }
86 ],
87 "sonnet": [
88 {
89 "id": "sonnet-literal-scope",
90 "text": "Spell out how far an instruction reaches (\"every file under src/, not just this one\"). Sonnet 5 reads prompts literally and does not generalise an instruction from one item to the rest."
91 },
92 {
93 "id": "sonnet-no-extras",
94 "text": "When the user wants only the change itself, say not to add tests, docs or files that were not asked for. Sonnet 5.5 tends to add them."
95 }
96 ],
97 "haiku": [
98 {
99 "id": "haiku-run-a-check",
100 "text": "For a code change that can be run, built or type-checked, ask for a real check (the tests, the type-checker, the build) before calling it done. Haiku 5.5 at low effort sometimes reports a change as done without checking it."
101 }
102 ]
103}
104
105export const SHAPES: readonly Shape[] = [
106 {
107 "name": "fix",
108 "summary": "Something is broken and should work again.",
109 "fields": "what goes wrong and where it shows, what should happen instead, what must stay as it is, how to tell it is fixed.",
110 "context": "the last test run showed test_login_redirect failing after the session refactor in auth/session.py.",
111 "before": "login broken again after your change, fix",
112 "after": "test_login_redirect started failing after the session refactor in auth/session.py. Find out why and fix it so a correct password lands on the dashboard again. Keep the refactor's new structure. Done when test_login_redirect and the rest of the auth tests pass."
113 },
114 {
115 "name": "investigate",
116 "summary": "Find out why something happens, without changing it yet.",
117 "fields": "the observation, where to look, what a useful answer looks like, a clear note that nothing should change yet.",
118 "context": "the user pasted a chart showing nightly job runtime going from 4 to 19 minutes on 2 October.",
119 "before": "why is the nightly thing so slow now",
120 "after": "The nightly job went from about 4 to 19 minutes on 2 October. Find out what changed around then that explains it and tell me the likely cause with the evidence. Don't change any code yet; I want the diagnosis first."
121 },
122 {
123 "name": "build",
124 "summary": "Add something new.",
125 "fields": "what it does for whom, where it lives, inputs and outputs, what is out of scope, how to tell it works.",
126 "context": "the project is a FastAPI service; the user has been discussing exporting reports.",
127 "before": "add csv export for reports",
128 "after": "Add a CSV export for reports so analysts can open them in a spreadsheet: a GET endpoint next to the existing report routes that returns the same rows as the JSON report, one column per field. No new dependencies. Done when the new endpoint returns the same rows as the JSON report for an existing report and the current tests still pass."
129 },
130 {
131 "name": "refactor",
132 "summary": "Change how code is organised without changing what it does.",
133 "fields": "the code in question, the target structure, the reason, the behaviour that must not change, how to check that.",
134 "context": "the conversation showed billing.py at 1,400 lines with tax, invoice and discount logic mixed together.",
135 "before": "split up billing its a mess",
136 "after": "Split billing.py (about 1,400 lines) into separate modules for tax, invoices and discounts, so each can be changed without reading the others. Behaviour must stay exactly the same: no renamed public functions, no logic changes. Done when the existing billing tests pass unchanged."
137 },
138 {
139 "name": "research",
140 "summary": "Learn something from outside the codebase.",
141 "fields": "the question, why it matters here, what sources count, the form of the answer.",
142 "context": "the team is choosing a queue for a Python service and already runs Redis.",
143 "before": "celery vs rq which one",
144 "after": "We need a task queue for our Python service and already run Redis. Compare Celery and RQ for that setup: setup effort, retries and scheduling, monitoring, and how actively each is maintained. Give me a recommendation with the two or three reasons that decide it, and link your sources."
145 },
146 {
147 "name": "review",
148 "summary": "Look over work and report problems.",
149 "fields": "what to review, what kind of problems matter, what to leave alone, the form of the findings.",
150 "context": "the user just finished a branch adding rate limiting to the API gateway.",
151 "before": "review my changes",
152 "after": "Review the rate limiting changes on this branch for bugs: wrong limits, race conditions, requests that slip through, and errors that would reach users. Skip style and naming. List each finding with the file and line and why it is a problem, most serious first. Don't change the code."
153 },
154 {
155 "name": "write",
156 "summary": "Produce prose: docs, a message, a description.",
157 "fields": "who reads it, what they should do or know after, length, tone, what it must include.",
158 "context": "the conversation just merged a migration that renames the users table to accounts.",
159 "before": "write something for the team about the migration",
160 "after": "Write a short Slack message for the backend team about the migration we just merged that renames the users table to accounts. They need to know what breaks in their local setups and the one command to fix it. Five sentences at most, plain tone."
161 },
162 {
163 "name": "ask",
164 "summary": "A question that wants an answer, not a change.",
165 "fields": "the question, the context it is about, how deep the answer should go.",
166 "context": "the conversation has been about a React app using useEffect for data fetching.",
167 "before": "is useeffect fine for this or no",
168 "after": "Is useEffect a reasonable way to fetch the dashboard data in this app, or would a data-fetching library serve better here? Give me a short answer with the main trade-off; don't change anything."
169 }
170]
171types/index.d.ts 59 lines1export type SharppromptMode = 'fill' | 'replace' | 'context' | 'off'
2
3// Why the cheap gate let a prompt through untouched. 'suggested' is a prompt
4// sent while our suggestion was in the box: the user's answer to it.
5export type SharppromptSkip =
6 | 'off'
7 | 'not-typed'
8 | 'raw'
9 | 'command'
10 | 'heading'
11 | 'too-short'
12 | 'too-long'
13 | 'harness-tag'
14 | 'answer'
15 | 'suggested'
16 | 'back-to-mine'
17
18// What the classifier said; 'timeout' and 'error' send the prompt as typed.
19export type SharppromptVerdict = 'clear' | 'rough' | 'timeout' | 'error'
20
21// How a rewrite went. Only 'rewritten' carries text; every other outcome
22// sends the prompt as typed.
23export type SharppromptQuestion = { question: string; header: string; options: { label: string; adds: string }[] }
24
25export type SharppromptRewrite = {
26 outcome: 'rewritten' | 'keep' | 'same' | 'empty' | 'too-long' | 'timeout' | 'error'
27 via: 'fork' | 'complete'
28 ms: number
29 text?: string
30 detail?: string
31 // Gaps the rewriter wanted to ask about; their answers are added to text.
32 questions?: SharppromptQuestion[]
33 usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
34}
35
36export type SharppromptDecision =
37 | { verdict: 'skip'; reason: SharppromptSkip; text: string }
38 | { verdict: SharppromptVerdict; text: string; classifyMs?: number; rewrite?: SharppromptRewrite }
39
40// A rewrite the user has not acted on yet: in the box (filled) or already
41// sent (replaced), shown in the band; or the user's own text put back in the
42// box (restored), which goes out untouched if sent as it stands.
43export type SharppromptPending = {
44 kind: 'filled' | 'replaced' | 'restored'
45 original: string
46 rewritten: string
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 sharpprompt: {
52 pending: SharppromptPending | null
53 isOff: boolean
54 mode: SharppromptMode | null
55 lastDecision: SharppromptDecision | null
56 }
57 }
58}
59