/rewrite: turns a rough draft prompt into a thorough one, asking clarifying questions first, without adding anything to the conversation

A /rewrite command for omp and Claude Code: it turns a rough draft prompt into a thorough, unambiguous one, asking you about its direction first.
It works like /btw: it runs as a side request that sees the current conversation, so the rewrite can reference files, errors and decisions already discussed in the session, but nothing is added to the conversation. The result goes into the prompt box for you to review. It uses the session's model unless you pick another one.
The same repository is an omp extension (index.ts) and a Claude Code mod (hooks/register.ts); both share the prompts and the settings logic.
ctx.runEphemeralTurn. On older versions /rewrite reports the required version and stops. /rewrite-settings and the Rewrite model role need 18.3.1./rewrite reports the required version and stops.omp -p, claude -p) there is no prompt box to receive the result, so /rewrite fails immediately without calling the model.Install it only one way at a time per host: each copy registers the same /rewrite command.
/plugin marketplace add zPeppOz/prompt-rewrite
/plugin install prompt-rewrite@prompt-rewrite
Or from the shell: claude plugin marketplace add zPeppOz/prompt-rewrite, then claude plugin install prompt-rewrite@prompt-rewrite. Run /reload-plugins in a session that was already open. /plugin then shows 1 mod active · prompt-rewrite (or more, with other mods).
To try a local clone for one session: claude --plugin-dir ./prompt-rewrite.
/marketplace add zPeppOz/prompt-rewrite
/marketplace install prompt-rewrite@prompt-rewrite
Or from the shell: omp plugin marketplace add zPeppOz/prompt-rewrite, then omp plugin install prompt-rewrite@prompt-rewrite. Restart omp afterwards: extension modules load when a session starts.
To update, run /marketplace update prompt-rewrite, then /marketplace upgrade prompt-rewrite@prompt-rewrite and restart. To remove it, run /marketplace uninstall prompt-rewrite@prompt-rewrite.
From git: omp plugin install github:zPeppOz/prompt-rewrite. From a local clone: omp plugin link ./prompt-rewrite.
Upgrading from omp-rewrite (0.4.0 and earlier): the plugin and its marketplace are now named prompt-rewrite. Remove the old copy (/marketplace uninstall omp-rewrite@omp-rewrite, or delete the omp-rewrite link) and install the new one; the settings below keep their names.
/rewrite <draft>
The draft can span several lines: end a line with \ (or press Shift+Enter where the terminal supports it) to continue on the next. In omp you can also run /rewrite with no arguments to open an editor for a longer draft; closing it with Esc cancels.
While the model works, a line above the prompt names the step and the model. omp also streams the last lines of the rewrite there; Claude Code gets the reply in one piece, so it shows the seconds elapsed instead.
Every message starts with the command name (/rewrite: or /rewrite-settings:), for example /rewrite: cancelled; draft restored to the prompt.
By default /rewrite uses the session's model and its thinking level or effort, as a side request that shares the session's prompt cache.
Set the plugin's Rewrite model option, a row in /config: an alias (haiku, sonnet, opus) or a full model id, resolved like --model. Rewrite effort sets the effort for that model (low to max); default leaves the model's own. Empty model: the session's model. The values are stored in ~/.claude/settings.json:
{
"pluginConfigs": {
"prompt-rewrite@prompt-rewrite": {
"options": { "model": "haiku", "effort": "low" }
}
}
}
The model must be one your session's provider serves: /rewrite calls it with the session's credentials. A model the provider doesn't know, or one your organization blocks, gives a warning and /rewrite uses the session's model.
Assign the rewrite role in omp's model selector:
/model and open the Roles view: the extension adds a Rewrite role there.omp saves the choice like any other role, as modelRoles.rewrite in ~/.omp/agent/config.yml (in the project's .omp/config.yml if you use modelRoleStorage: project). It doesn't change the session's model or the ctrl+p cycle. Press x on the role row to clear it. You can also set it by hand:
modelRoles:
rewrite: anthropic/claude-haiku-4-5:low # thinking level optional
:low). Without one, or with :auto, the session's level, adapted to what the model supports./rewrite warns and uses the session's model.secrets.enabled, secrets are obfuscated as in the session.omp and Claude Code both run side requests on the session's model only, so on another model /rewrite sends the two requests itself. The model gets the session's system prompt and the conversation as a text transcript (after the latest compaction, tool outputs cut to 2000 characters, thinking left out). The role or option is read at the start of every /rewrite.
Add your own instructions to the prompt that rewrites the draft, globally and per project.
| Key | Type | Default |
|---|---|---|
| instructions | text | empty |
| instructions mode | append or replace | append |
Order. When both scopes have text, both apply: global first, then project, and the prompt tells the model the project instructions win on conflict. If one is empty or missing, only the other applies. With neither, the prompt is the default one.
Mode. One effective value: the project's, else the global one, else append.
append: your instructions are added to the default rewrite rules. If one conflicts with a default rule, yours wins; the output is still only the rewritten prompt.replace: your instructions replace the default rewrite rules. What always stays: the draft, your answers to the questions, and a short frame saying what the draft is, that it must not be executed or answered, and that only the rewritten prompt is output (it goes into the prompt box verbatim). With no instructions at all, replace has nothing to replace with and the default prompt is used.The model receives your text inside a <custom_instructions> block, with a <global> and a <project> section, before the draft. Custom instructions apply to the rewrite step only, not to the direction questions. An invalid value is ignored with a warning.
/rewrite-settings [global|project] [instructions]
/rewrite-settings alone asks for the scope, then puts /rewrite-settings <scope> <current text> in the prompt box: edit the text there (on several lines if you like) and press Enter./rewrite-settings <scope> <text> asks for the mode, then saves. /rewrite-settings <scope> with no text clears that scope.| Scope | Where |
|---|---|
| Global | The plugin's Rewrite instructions and Rewrite instructions mode options: rows in /config, stored under pluginConfigs in ~/.claude/settings.json. Saving them reloads the mod. |
| Project | <cwd>/.claude/rewrite.json, which you can commit or edit by hand: { "instructions": "...", "instructionsMode": "append" }. Other keys in it are kept. A file that isn't a JSON object is left untouched: /rewrite-settings reports an error and /rewrite ignores it with a warning. |
/rewrite-settings
Pick a scope, edit the text in the editor (saving it empty clears that scope), then pick the mode. Esc at any step cancels without changing anything. The field is a command because omp's /settings panel can't show extension fields. The values are ordinary omp settings:
| Scope | File | Written by /rewrite-settings |
|---|---|---|
| Global | ~/.omp/agent/config.yml | Through omp's own settings store, like /settings |
| Project | <cwd>/.omp/config.yml | Directly by the extension |
rewrite:
instructions: |-
Always write the prompt in English.
Keep it under 200 words.
instructionsMode: append # or "replace"; default "append"
/rewrite runs at a time; starting another one while it runs shows /rewrite: already running./rewrite sends the two requests to the session's model as on another model./rewrite waits for a running turn to end, like other commands..claude/rewrite.json is written in place, not atomically.claude -p mods draw nothing; /rewrite needs the terminal or the desktop app./rewrite-settings writes <cwd>/.omp/config.yml itself, because omp never writes project keys. Other keys in that file are kept, but the file is re-serialized, so YAML comments in it are lost. A file that isn't a YAML mapping, or doesn't parse, is left untouched and the command reports an error.bun run test # shared logic and the omp extension (*.spec.ts)
claude plugin test # the Claude Code mod (tests/*.test.ts)
claude plugin validate --strict .
rewrite.ts and instructions.ts have no host dependencies: the prompts, the transcript framing and serialization for another model, the parsing of the model's questions and their Claude Code dialog format, the preview layout, and how the global and project settings combine.index.ts wires the commands (dialogs, side turns, widget, composer); model.ts handles the rewrite role; storage.ts writes the settings.hooks/register.ts is the mod (.claude-plugin/plugin.json is its manifest, hooks/hooks.json points to it). It may import only files inside the repository, never omp's packages. Loading it with --plugin-dir writes the mods API types for your Claude Code version to .claude-plugin/types/ and a tsconfig.json (both ignored by git).claude plugin test runs every *.test.ts, so the bun tests are named *.spec.ts.The project has no runtime dependencies.
To release, bump version in package.json, .omp-plugin/marketplace.json and .claude-plugin/plugin.json (marketplace.spec.ts checks they match) and add a CHANGELOG entry. Both hosts compare that version to find updates, so a missed bump means users don't get the update.
hooks/register.ts 424 lines1import type { EngineInterface, ModelEffort, ModelForkResult, PluginOptions, Register } from "claude-code";
2import {
3 applyEdit,
4 DEFAULT_MODE,
5 describeLayer,
6 INSTRUCTIONS_KEY,
7 type InstructionsEdit,
8 isRecord,
9 type LayerValues,
10 MODE_KEY,
11 type ModeChoice,
12 modeChoices,
13 NAMESPACE,
14 readLayer,
15 resolveInstructions,
16 type Scope,
17} from "../instructions";
18import {
19 type Answer,
20 type AskUserQuestion,
21 parseQuestions,
22 type Question,
23 questionsPrompt,
24 readAskAnswers,
25 rewritePrompt,
26 serializeTranscript,
27 toAskUserQuestions,
28 withTranscript,
29} from "../rewrite";
30
31/**
32 * prompt-rewrite come mod di Claude Code: /rewrite e /rewrite-settings con la logica
33 * condivisa con l'estensione omp (`rewrite.ts`, `instructions.ts`).
34 *
35 * /rewrite <bozza>: domande di direzione (0–4) → riscrittura → prompt. Le richieste sono
36 * fork della sessione (`$.model.fork`: stesso modello, system prompt e cache, niente tool)
37 * o, con il campo `model` impostato, `$.model.complete` con la conversazione come
38 * trascrizione. Un comando che risponde `{}` non lascia nulla nella conversazione.
39 *
40 * /rewrite-settings [global|project] [istruzioni]: istruzioni personalizzate, globali nei
41 * campi `userConfig` (righe di /config), di progetto in `.claude/rewrite.json`.
42 */
43
44// Prima versione di Claude Code con l'API usata qui: `$.model.*` che risolve `isAnswered`/`reason`, `effort`.
45const MIN_VERSION = "2.1.289";
46// Relativo alla directory di lavoro della sessione.
47const PROJECT_FILE = ".claude/rewrite.json";
48// Tetto della risposta di `$.model.complete` (default 1024): un prompt riscritto, con margine per il ragionamento.
49const MAX_TOKENS = 16_000;
50const EFFORTS: readonly ModelEffort[] = ["low", "medium", "high", "xhigh", "max"];
51const CANCELLED = "/rewrite: cancelled; draft restored to the prompt";
52const SETTINGS_USAGE = "/rewrite-settings: usage: /rewrite-settings [global|project] [instructions]";
53
54/** Dove vanno le richieste: il fork della sessione, o un modello che riceve la conversazione come trascrizione. */
55type Target = { kind: "session" } | { kind: "model"; model: string; effort: ModelEffort | undefined; system: string; transcript: string };
56
57let busy = false;
58/** Riga sopra il prompt mentre /rewrite aspetta il modello; `undefined` altrimenti. */
59let progress: { label: string; since: number } | undefined;
60/**
61 * Le domande di un /rewrite: `$.ui.ask` ne apre una sola, senza descrizioni, e il hook
62 * `tool.call` mette tutte queste nel dialogo; `answers` sono le risposte a tutte.
63 */
64let pending: { questions: AskUserQuestion[]; answers?: Readonly<Record<string, unknown>> } | undefined;
65
66/** Un'informazione è un toast; un avviso o un errore resta come riga della trascrizione, che il modello non legge. */
67function notify($: EngineInterface, text: string, level: "info" | "warning" | "error"): void {
68 if (level === "info") $.ui.toast(text);
69 else $.ui.log(text);
70}
71
72/** `version` (`2.1.289`, `2.1.290-dev…`) è almeno `minimum`? Contano le parti numeriche di `minimum`. */
73function isAtLeast(version: string, minimum: string): boolean {
74 const own = version.split(/[.-]/).map(Number);
75 for (const [index, part] of minimum.split(".").map(Number).entries()) {
76 const have = own[index] ?? 0;
77 if (have !== part) return have > part;
78 }
79 return true;
80}
81
82/** Perché un comando non può partire in questa sessione; `undefined` se può. */
83async function blocker($: EngineInterface, command: string): Promise<{ text: string; headless: boolean } | undefined> {
84 if ((await $.session.surfaces()).length === 0) {
85 return { text: `${command}: needs an interactive session (terminal or desktop app); the result goes to the prompt box`, headless: true };
86 }
87 const { version } = await $.session.version();
88 if (!isAtLeast(version, MIN_VERSION)) return { text: `${command}: requires Claude Code ${MIN_VERSION} or newer (this is ${version})`, headless: false };
89 return undefined;
90}
91
92/** Il livello globale come lo legge `readLayer`: i campi `userConfig` della mod. */
93function globalLayer(options: PluginOptions): Record<string, unknown> {
94 return { [NAMESPACE]: { [INSTRUCTIONS_KEY]: options.instructions, [MODE_KEY]: options.instructions_mode } };
95}
96
97/** `.claude/rewrite.json`; `undefined` se manca. Un file che non è un oggetto JSON è un errore e non viene mai sovrascritto. */
98async function readProjectFile($: EngineInterface): Promise<Record<string, unknown> | undefined> {
99 if (!(await $.fs.exists(PROJECT_FILE))) return undefined;
100 const text = await $.fs.read(PROJECT_FILE);
101 let parsed: unknown;
102 try {
103 parsed = text.trim() ? JSON.parse(text) : {};
104 } catch (err) {
105 throw new Error(`can't read ${PROJECT_FILE}: ${err instanceof Error ? err.message : String(err)}`);
106 }
107 if (!isRecord(parsed)) throw new Error(`${PROJECT_FILE} is not a JSON object; fix it by hand first`);
108 return parsed;
109}
110
111/** Mette il testo nel prompt senza cancellare quello che l'utente ha scritto nel frattempo. */
112async function putInPrompt($: EngineInterface, text: string): Promise<void> {
113 const { text: current } = await $.prompt.read();
114 const { isFilled } = await $.prompt.fill({ text: current.trim() ? `${current.trimEnd()}\n\n${text}` : text });
115 // Il prompt è occupato da un dialogo o non c'è: il testo resta almeno nella trascrizione.
116 if (!isFilled) $.ui.log(`/rewrite: the prompt box didn't take the text; here it is:\n${text}`);
117}
118
119/** Domande nel dialogo di Claude Code, tutte insieme; `undefined` = l'utente ha annullato. */
120async function askQuestions($: EngineInterface, questions: readonly Question[]): Promise<Answer[] | undefined> {
121 const asked = toAskUserQuestions(questions);
122 const [first] = asked;
123 if (!first) return [];
124 pending = { questions: asked };
125 try {
126 const answer = await $.ui.ask(first.question, {
127 options: first.options.map(o => o.label),
128 header: first.header,
129 multiSelect: first.multiSelect ? true : undefined,
130 });
131 // Se un'altra mod ha risposto al dialogo prima del nostro hook, c'è solo la prima risposta.
132 return readAskAnswers(asked, pending.answers ?? { [first.question]: answer });
133 } catch {
134 // Dialogo chiuso con Esc o con "Chat about this".
135 return undefined;
136 } finally {
137 pending = undefined;
138 }
139}
140
141/** Il modello scelto riceve la conversazione come trascrizione e il system prompt della sessione. */
142async function modelTarget($: EngineInterface, model: string, effort: ModelEffort | undefined): Promise<Target> {
143 const [messages, { sections }] = await Promise.all([$.session.messages({ as: "api" }), $.prompt.compose()]);
144 return { kind: "model", model, effort, system: sections.map(s => s.text).join("\n\n"), transcript: serializeTranscript(messages) };
145}
146
147/** Il campo `model` vuoto, o uguale al modello della sessione senza `effort`, vale il fork della sessione. */
148async function pickTarget($: EngineInterface, options: PluginOptions): Promise<Target> {
149 const model = typeof options.model === "string" ? options.model.trim() : "";
150 const effort = EFFORTS.find(level => level === options.effort);
151 if (!model || (model === (await $.session.model()) && !effort)) return { kind: "session" };
152 return modelTarget($, model, effort);
153}
154
155/** Una richiesta, con l'avanzamento sopra il prompt (la risposta non arriva in streaming). */
156async function request($: EngineInterface, target: Target, label: string, promptText: string): Promise<ModelForkResult> {
157 progress = { label: target.kind === "model" ? `${label} · ${target.model}` : label, since: await $.clock.now() };
158 $.ui.invalidate("ui.render");
159 const timer = $.clock.every(1000, () => $.ui.invalidate("ui.render"));
160 try {
161 if (target.kind === "session") return await $.model.fork({ prompt: promptText });
162 return await $.model.complete({
163 model: target.model,
164 system: target.system,
165 prompt: withTranscript(target.transcript, promptText),
166 maxTokens: MAX_TOKENS,
167 effort: target.effort,
168 });
169 } finally {
170 timer.cancel();
171 progress = undefined;
172 $.ui.invalidate("ui.render");
173 }
174}
175
176/** Testo della risposta; `undefined` = annullata (Esc). Un errore dell'API diventa un'eccezione. */
177function replyText(result: ModelForkResult): string | undefined {
178 if (result.isAnswered) return result.text;
179 switch (result.reason) {
180 case "aborted":
181 return undefined;
182 case "empty-reply":
183 return "";
184 case "api-error":
185 throw new Error(`the model request failed: ${result.error}${result.status === null ? "" : ` (HTTP ${result.status})`}`);
186 case "nothing-to-fork":
187 throw new Error("the session has nothing to fork yet");
188 }
189}
190
191/** Bozza → domande → riscrittura → prompt. Su annullamento o errore la bozza torna nel prompt. */
192async function rewrite($: EngineInterface, options: PluginOptions, draft: string): Promise<void> {
193 const cancel = async () => {
194 await putInPrompt($, draft);
195 notify($, CANCELLED, "info");
196 };
197 try {
198 let target = await pickTarget($, options);
199 const analyze = async () => {
200 const prompt = questionsPrompt(draft);
201 try {
202 const result = await request($, target, "analyzing the draft", prompt);
203 // Un id che il provider non conosce: come per un ruolo omp senza modello, si usa la sessione.
204 if (target.kind === "model" && !result.isAnswered && result.reason === "api-error" && result.status === 404) {
205 throw new Error(result.error);
206 }
207 return result;
208 } catch (err) {
209 // Rifiutata prima di partire (modello bloccato, tetto troppo alto) o modello sconosciuto.
210 if (target.kind !== "model") throw err;
211 const reason = err instanceof Error ? err.message : String(err);
212 notify($, `/rewrite: the model "${target.model}" can't be used (${reason}); using the session's model`, "warning");
213 target = { kind: "session" };
214 return request($, target, "analyzing the draft", prompt);
215 }
216 };
217 let analysis = await analyze();
218 if (!analysis.isAnswered && analysis.reason === "nothing-to-fork") {
219 // Sessione appena iniziata: non c'è un turno da biforcare, la richiesta va al modello della sessione.
220 target = await modelTarget($, await $.session.model(), undefined);
221 analysis = await request($, target, "analyzing the draft", questionsPrompt(draft));
222 }
223 const analysisText = replyText(analysis);
224 if (analysisText === undefined) return await cancel();
225
226 const questions = parseQuestions(analysisText);
227 if (!questions) notify($, "/rewrite: couldn't read the model's questions; rewriting without them", "warning");
228 let answers: Answer[] = [];
229 if (questions?.length) {
230 const answered = await askQuestions($, questions);
231 if (!answered) return await cancel();
232 answers = answered;
233 }
234
235 // Un file di progetto illeggibile non ferma la riscrittura: vale come assente.
236 let project: Record<string, unknown> | undefined;
237 try {
238 project = await readProjectFile($);
239 } catch (err) {
240 notify($, `/rewrite: ignoring the project instructions (${err instanceof Error ? err.message : String(err)})`, "warning");
241 }
242 const { custom, warnings } = resolveInstructions(globalLayer(options), { [NAMESPACE]: project });
243 for (const warning of warnings) notify($, warning, "warning");
244
245 const rewritten = replyText(await request($, target, "rewriting the prompt", rewritePrompt(draft, answers, custom)));
246 if (rewritten === undefined) return await cancel();
247 if (!rewritten.trim()) throw new Error("the model returned an empty rewrite");
248
249 await putInPrompt($, rewritten.trim());
250 notify($, "/rewrite: prompt rewritten into the prompt box; review it and press Enter", "info");
251 } catch (err) {
252 const reason = err instanceof Error ? err.message : String(err);
253 await putInPrompt($, draft);
254 notify($, `/rewrite: failed (${reason}); draft restored to the prompt`, "error");
255 }
256}
257
258/** Una scelta nel dialogo; `undefined` = annullato. Il testo libero torna così com'è. */
259async function choose($: EngineInterface, question: Question): Promise<string | undefined> {
260 const answers = await askQuestions($, [question]);
261 return answers === undefined ? undefined : (answers[0]?.answer ?? "");
262}
263
264/** Scrive `rewrite.*` globale nei campi `userConfig`: come cambiarli in /config, e la mod si ricarica. */
265async function saveGlobal($: EngineInterface, edit: InstructionsEdit): Promise<void> {
266 const values: [string, string][] = [
267 ["instructions", edit.instructions ?? ""],
268 ["instructions_mode", edit.mode ?? DEFAULT_MODE],
269 ];
270 for (const [field, value] of values) {
271 const result = await $.config.set({ key: `${$.plugin.name}.${field}`, value });
272 if (result.deny !== undefined) throw new Error(`/config refused ${field}: ${result.deny}`);
273 }
274}
275
276/** Scrive `.claude/rewrite.json`, tenendo le altre chiavi; svuotare un file che non c'è non lo crea. */
277async function saveProject($: EngineInterface, edit: InstructionsEdit): Promise<string> {
278 const current = await readProjectFile($);
279 const next = applyEdit({ [NAMESPACE]: current ?? {} }, edit)[NAMESPACE];
280 const block = isRecord(next) ? next : {};
281 if (current !== undefined || Object.keys(block).length > 0) await $.fs.write(PROJECT_FILE, `${JSON.stringify(block, null, 2)}\n`);
282 return `${await $.session.cwd()}/${PROJECT_FILE}`;
283}
284
285/**
286 * /rewrite-settings: senza argomenti chiede l'ambito e mette nel prompt il comando con il
287 * testo attuale, da modificare su più righe; con ambito e testo chiede la modalità e salva.
288 * Un ambito senza testo lo svuota. Esc in un dialogo annulla senza modificare nulla.
289 */
290async function editInstructions($: EngineInterface, options: PluginOptions, args: string): Promise<void> {
291 const project = await readProjectFile($);
292 const layers: Record<Scope, LayerValues> = {
293 global: readLayer(globalLayer(options), "global"),
294 project: readLayer({ [NAMESPACE]: project }, "project"),
295 };
296 const [, word = "", rest = ""] = /^(\S*)\s*([\s\S]*)$/.exec(args.trim()) ?? [];
297 let scope: Scope | undefined = word.toLowerCase() === "global" ? "global" : word.toLowerCase() === "project" ? "project" : undefined;
298 if (word && !scope) return notify($, SETTINGS_USAGE, "warning");
299
300 if (!scope) {
301 const choice = await choose($, {
302 id: "scope",
303 header: "Scope",
304 question: "Which custom /rewrite instructions do you want to edit?",
305 options: [
306 { label: "Global", description: `${describeLayer(layers.global)}; applies to every project` },
307 { label: "Project", description: `${describeLayer(layers.project)}; ${PROJECT_FILE}` },
308 ],
309 multi: false,
310 });
311 if (choice === undefined) return;
312 scope = choice.toLowerCase() === "global" ? "global" : choice.toLowerCase() === "project" ? "project" : undefined;
313 if (!scope) return notify($, SETTINGS_USAGE, "warning");
314 const { isFilled } = await $.prompt.fill({ text: `/rewrite-settings ${scope} ${layers[scope].instructions ?? ""}` });
315 if (!isFilled) return notify($, "/rewrite-settings: the prompt box didn't take the text; type /rewrite-settings <scope> <instructions>", "warning");
316 return notify($, `/rewrite-settings: edit the ${scope} instructions in the prompt and press Enter; leave them empty to clear`, "info");
317 }
318
319 const instructions = rest.trim();
320 let edit: InstructionsEdit = { instructions: undefined, mode: undefined };
321 if (instructions) {
322 const { options: modes, current } = modeChoices(scope, layers[scope].mode);
323 const choice = await choose($, {
324 id: "mode",
325 header: "Mode",
326 question: `How should the ${scope} instructions combine with the default rewrite rules?`,
327 options: modes,
328 multi: false,
329 recommended: current,
330 });
331 if (choice === undefined) return;
332 const mode = modes.find(m => m.label === choice.trim().toLowerCase())?.label;
333 if (!mode) return notify($, `/rewrite-settings: unknown mode "${choice}"; nothing changed`, "warning");
334 edit = { instructions, mode: mode === "inherit" ? undefined : mode };
335 }
336
337 let where: string;
338 if (scope === "global") {
339 await saveGlobal($, edit);
340 where = "the global settings (/config)";
341 } else {
342 where = await saveProject($, edit);
343 }
344 const mode: ModeChoice | "inherited" = edit.mode ?? (scope === "global" ? DEFAULT_MODE : "inherited");
345 notify($, `/rewrite-settings: ${scope} instructions ${instructions ? `saved (mode ${mode})` : "cleared"} in ${where}`, "info");
346}
347
348export const register: Register = (on, options) => {
349 on("session.start", async ($, e, next) => {
350 const commands = [
351 { name: "rewrite", description: "Rewrite a draft into a thorough prompt, asking about its direction first", argumentHint: "<draft>" },
352 {
353 name: "rewrite-settings",
354 description: "Edit the custom instructions added to the /rewrite prompt (global or project)",
355 argumentHint: "[global|project] [instructions]",
356 },
357 ];
358 for (const command of commands) {
359 // Un nome rifiutato (preso da un comando incorporato) non deve impedire l'altro.
360 try {
361 await $.command.register(command);
362 } catch (err) {
363 $.ui.log(`prompt-rewrite: /${command.name} not registered (${err instanceof Error ? err.message : String(err)})`);
364 }
365 }
366 return next(e);
367 });
368
369 on("command.run", { command: "rewrite" }, async ($, e) => {
370 const problem = await blocker($, "/rewrite");
371 if (problem?.headless) return { text: problem.text };
372 if (problem) notify($, problem.text, "error");
373 else if (busy) notify($, "/rewrite: already running", "warning");
374 else if (!e.args.trim()) notify($, "/rewrite: empty draft; usage: /rewrite <draft>", "warning");
375 else {
376 busy = true;
377 try {
378 await rewrite($, options, e.args.trim());
379 } finally {
380 busy = false;
381 }
382 }
383 return {};
384 });
385
386 on("command.run", { command: "rewrite-settings" }, async ($, e) => {
387 const problem = await blocker($, "/rewrite-settings");
388 if (problem?.headless) return { text: problem.text };
389 if (problem) notify($, problem.text, "error");
390 else {
391 try {
392 await editInstructions($, options, e.args);
393 } catch (err) {
394 notify($, `/rewrite-settings: failed (${err instanceof Error ? err.message : String(err)})`, "error");
395 }
396 }
397 return {};
398 });
399
400 // Il dialogo aperto da `askQuestions` mostra tutte le domande, con le descrizioni delle opzioni.
401 on("tool.call", { tool: "AskUserQuestion" }, async ($, e, next) => {
402 const batch = pending;
403 if (!batch || e.questions[0]?.question !== batch.questions[0]?.question) return next(e);
404 const result = await next({ ...e, questions: batch.questions });
405 if ("result" in result && isRecord(result.result) && isRecord(result.result.answers)) batch.answers = result.result.answers;
406 return result;
407 });
408
409 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
410 const shown = progress;
411 if (!shown) return next(e);
412 const { Box, Text } = $.ui.resolve(e);
413 const seconds = Math.floor(((await $.clock.now()) - shown.since) / 1000);
414 return Box({
415 flexDirection: "column",
416 children: [
417 Text({ bold: true, wrap: "truncate", children: `✎ /rewrite · ${shown.label}` }),
418 Text({ dimColor: true, children: `${seconds}s · Esc to cancel` }),
419 await next(e),
420 ],
421 });
422 });
423};
424instructions.ts 142 lines1/**
2 * Istruzioni personalizzate di /rewrite, senza dipendenze dall'host: lettura dei
3 * livelli grezzi delle impostazioni (globale e progetto), risoluzione dell'ordine
4 * di combinazione e modifica di un livello.
5 */
6
7export type Scope = "global" | "project";
8export type InstructionsMode = "append" | "replace";
9
10export const INSTRUCTIONS_MODES: readonly InstructionsMode[] = ["append", "replace"];
11export const DEFAULT_MODE: InstructionsMode = "append";
12
13// Chiavi annidate come tutte le impostazioni omp: `rewrite: { instructions, instructionsMode }`.
14export const NAMESPACE = "rewrite";
15export const INSTRUCTIONS_KEY = "instructions";
16export const MODE_KEY = "instructionsMode";
17export const INSTRUCTIONS_ID = `${NAMESPACE}.${INSTRUCTIONS_KEY}`;
18export const MODE_ID = `${NAMESPACE}.${MODE_KEY}`;
19
20/** Istruzioni effettive: testi per ambito (già ripuliti, mai vuoti) e modalità di combinazione. */
21export interface CustomInstructions {
22 global?: string;
23 project?: string;
24 mode: InstructionsMode;
25}
26
27/** Valori `rewrite.*` di un solo livello; `warnings` segnala i valori non validi ignorati. */
28export interface LayerValues {
29 instructions?: string;
30 mode?: InstructionsMode;
31 warnings: string[];
32}
33
34export interface InstructionsEdit {
35 /** Testo vuoto o `undefined` = rimuove la chiave. */
36 instructions: string | undefined;
37 /** `undefined` = rimuove la chiave (il livello eredita). */
38 mode: InstructionsMode | undefined;
39}
40
41/** Unica guardia di "mappa YAML/JSON" del pacchetto: i campi restano `unknown`. */
42export function isRecord(value: unknown): value is Record<string, unknown> {
43 return typeof value === "object" && value !== null && !Array.isArray(value);
44}
45
46function isMode(value: unknown): value is InstructionsMode {
47 return INSTRUCTIONS_MODES.includes(value as InstructionsMode);
48}
49
50/**
51 * Valori di un livello grezzo. Come per le chiavi native di omp, un valore non
52 * valido viene ignorato con un avviso e `null` vale "non impostato".
53 */
54export function readLayer(raw: unknown, scope: Scope): LayerValues {
55 const values: LayerValues = { warnings: [] };
56 const block = isRecord(raw) ? raw[NAMESPACE] : undefined;
57 if (!isRecord(block)) return values;
58
59 const text = block[INSTRUCTIONS_KEY];
60 if (typeof text === "string") values.instructions = text.trim() || undefined;
61 else if (text != null) values.warnings.push(`/rewrite: ignoring ${INSTRUCTIONS_ID} in the ${scope} settings (expected text)`);
62
63 const mode = block[MODE_KEY];
64 if (isMode(mode)) values.mode = mode;
65 else if (mode != null) {
66 const expected = INSTRUCTIONS_MODES.map(m => `"${m}"`).join(" or ");
67 values.warnings.push(`/rewrite: ignoring ${MODE_ID} in the ${scope} settings (expected ${expected})`);
68 }
69 return values;
70}
71
72/**
73 * Combina i due livelli: testi globale → progetto (un testo vuoto o assente lascia
74 * solo l'altro), modalità progetto → globale → `append`. `custom` è `undefined`
75 * quando nessun ambito ha testo, e il prompt resta quello di default.
76 */
77export function resolveInstructions(
78 globalRaw: unknown,
79 projectRaw: unknown,
80): { custom: CustomInstructions | undefined; warnings: string[] } {
81 const global = readLayer(globalRaw, "global");
82 const project = readLayer(projectRaw, "project");
83 const warnings = [...global.warnings, ...project.warnings];
84 if (!global.instructions && !project.instructions) return { custom: undefined, warnings };
85 return {
86 custom: {
87 global: global.instructions,
88 project: project.instructions,
89 mode: project.mode ?? global.mode ?? DEFAULT_MODE,
90 },
91 warnings,
92 };
93}
94
95/**
96 * Copia di un livello grezzo con `rewrite.*` aggiornato. Le altre chiavi restano
97 * dove sono; un blocco `rewrite` rimasto vuoto sparisce.
98 */
99export function applyEdit(raw: Record<string, unknown>, edit: InstructionsEdit): Record<string, unknown> {
100 const previous = raw[NAMESPACE];
101 const block: Record<string, unknown> = isRecord(previous) ? { ...previous } : {};
102 const text = edit.instructions?.trim();
103 if (text) block[INSTRUCTIONS_KEY] = text;
104 else delete block[INSTRUCTIONS_KEY];
105 if (edit.mode) block[MODE_KEY] = edit.mode;
106 else delete block[MODE_KEY];
107 const keep = Object.keys(block).length > 0;
108
109 const next: Record<string, unknown> = {};
110 for (const [key, value] of Object.entries(raw)) {
111 if (key !== NAMESPACE) next[key] = value;
112 else if (keep) next[key] = block;
113 }
114 if (keep && !(NAMESPACE in next)) next[NAMESPACE] = block;
115 return next;
116}
117
118/** Modalità scelta in /rewrite-settings: `inherit` toglie quella del progetto, che eredita la globale. */
119export type ModeChoice = InstructionsMode | "inherit";
120
121/**
122 * Le modalità tra cui scegliere per un ambito, con la loro spiegazione (`inherit` solo per
123 * il progetto), e l'indice di quella che corrisponde al livello com'è ora.
124 */
125export function modeChoices(
126 scope: Scope,
127 mode: InstructionsMode | undefined,
128): { options: { label: ModeChoice; description: string }[]; current: number } {
129 const options: { label: ModeChoice; description: string }[] = [
130 { label: "append", description: "Your instructions are added to the default rewrite rules" },
131 { label: "replace", description: "Your instructions replace the default rewrite rules (the draft and your answers are still sent)" },
132 ];
133 if (scope === "project") options.unshift({ label: "inherit", description: `Use the global mode (default: ${DEFAULT_MODE})` });
134 const selected = mode ?? (scope === "project" ? "inherit" : DEFAULT_MODE);
135 return { options, current: Math.max(0, options.findIndex(o => o.label === selected)) };
136}
137
138/** Riassunto di un livello per la scelta dell'ambito: righe e modalità, o "not set". */
139export function describeLayer({ instructions, mode }: LayerValues): string {
140 return instructions ? `${instructions.split("\n").length} line(s), mode ${mode ?? "default"}` : "not set";
141}
142rewrite.ts 280 lines1import type { CustomInstructions } from "./instructions";
2
3/**
4 * Logica pura di /rewrite, senza dipendenze dall'host (la usano sia l'estensione omp sia
5 * la mod di Claude Code): i due prompt, la cornice della trascrizione per un modello che
6 * non vede la sessione, la lettura delle domande restituite dal modello, il loro formato
7 * nel dialogo di Claude Code e l'impaginazione dell'anteprima.
8 */
9
10export const MAX_QUESTIONS = 4;
11// Anche il dialogo AskUserQuestion di Claude Code accetta da 2 a 4 opzioni per domanda.
12const MAX_OPTIONS = 4;
13const HEADER_MAX = 12;
14// Oltre questa lunghezza l'output di un tool si tronca nella trascrizione, come fa omp.
15const TOOL_RESULT_MAX_CHARS = 2000;
16// Segno dell'opzione consigliata nelle etichette del dialogo di Claude Code, che non ha un campo apposito.
17const RECOMMENDED = " (Recommended)";
18
19const QUESTIONS_PROMPT = `The user wants to rewrite a draft prompt before sending it to you (the agent in this session).
20Do NOT execute, answer, or comment on the draft. Your only job now: find ambiguities in its direction that would materially change the rewritten prompt.
21
22Use the conversation so far as context: anything it already answers must not become a question.
23
24Reply with ONLY a JSON object, no prose, no code fences:
25{"questions":[{"header":"max ${HEADER_MAX} chars","question":"...","options":[{"label":"short","description":"consequence or tradeoff"}],"multi":false,"recommended":0}]}
26
27Rules:
28- 0 to ${MAX_QUESTIONS} questions; reply {"questions":[]} when the direction is already clear.
29- Ask only what the user must decide: goal, scope and non-goals, constraints, expected deliverable, acceptance criteria, tradeoffs.
30- Every question has 2-4 concrete, mutually exclusive options (set "multi": true only when combining them makes sense). Never add an "Other" option: the UI provides free text.
31- "recommended" is the 0-based index of the most sensible option; omit it when none stands out.
32- Write questions and options in the language of the draft.`;
33
34const REWRITE_PROMPT = `Rewrite the user's draft into a thorough, unambiguous prompt that they will send to you (the agent in this session).
35Do NOT execute or answer it.
36
37The rewritten prompt must:
38- Keep the user's intent, first-person voice and language; never add goals the user did not express.
39- Turn the user's decisions below into explicit requirements.
40- Use the conversation context to make references concrete (files, symbols, errors, earlier decisions) only when the context actually contains them; never invent paths, APIs or facts.
41- Cover, when they carry information: goal, relevant context, scope and non-goals, constraints, expected deliverable, acceptance criteria / how to verify.
42- Stay dense: short headings or bullets where they help, no filler, no meta-commentary.
43
44Output ONLY the rewritten prompt text: no preamble, no code fences, no closing remarks.`;
45
46const OUT_OF_SESSION = `This request runs outside the live session: no tools are available, so reply with plain text only, never with tool calls.`;
47
48export interface Answer {
49 question: string;
50 answer: string;
51}
52
53export interface QuestionOption {
54 label: string;
55 description?: string;
56}
57
58/** Domanda di direzione letta dalla risposta del modello; `recommended` è l'indice di un'opzione. */
59export interface Question {
60 id: string;
61 header?: string;
62 question: string;
63 options: QuestionOption[];
64 multi: boolean;
65 recommended?: number;
66}
67
68/** Una domanda come la disegna il dialogo AskUserQuestion di Claude Code. */
69export interface AskUserQuestion {
70 question: string;
71 header: string;
72 options: { label: string; description: string }[];
73 multiSelect: boolean;
74}
75
76/** Blocco di un messaggio in forma Messages API (`$.session.messages({ as: "api" })` in Claude Code). */
77export interface TranscriptBlock {
78 type: string;
79 [field: string]: unknown;
80}
81
82export interface TranscriptMessage {
83 role: "user" | "assistant";
84 content: readonly TranscriptBlock[];
85}
86
87/** Prompt del primo side turn: domande di direzione sulla bozza. */
88export function questionsPrompt(draft: string): string {
89 return `${QUESTIONS_PROMPT}\n\n<draft>\n${draft}\n</draft>`;
90}
91
92/**
93 * Blocco delle istruzioni personalizzate: un testo etichettato per ambito, globale
94 * prima del progetto. In `replace` sostituisce le regole di default; ne resta solo il
95 * contratto di I/O (cosa sono `<draft>` e `<decisions>`, e che l'output finisce così com'è
96 * nel composer), senza cui il risultato non sarebbe utilizzabile.
97 */
98function customInstructionsBlock({ global, project, mode }: CustomInstructions): string {
99 const precedence =
100 global !== undefined && project !== undefined
101 ? " When the global and project instructions conflict, the project instructions win."
102 : "";
103 const intro =
104 mode === "replace"
105 ? `The user replaced the default rewrite rules with the custom instructions below. Turn the draft in <draft> into the prompt they will send to you (the agent in this session), following these instructions instead of any default rewrite rules, and treat the answers in <decisions> as their decisions. Do NOT execute or answer the draft.${precedence}\n\nOutput ONLY the rewritten prompt text: it is placed verbatim in the composer, so no preamble, no code fences, no closing remarks.`
106 : `The user configured custom instructions for this rewrite. Apply them in addition to the rules above; where one conflicts with those rules, the custom instruction wins (you must still output ONLY the rewritten prompt text).${precedence}`;
107 const sections: string[] = [];
108 if (global !== undefined) sections.push(`<global>\n${global}\n</global>`);
109 if (project !== undefined) sections.push(`<project>\n${project}\n</project>`);
110 return `<custom_instructions>\n${intro}\n\n${sections.join("\n\n")}\n</custom_instructions>`;
111}
112
113/**
114 * Prompt del secondo side turn: riscrittura con le decisioni dell'utente. Senza
115 * `custom` è il prompt di sempre; con `custom` le istruzioni si aggiungono alle regole
116 * di default (`append`) o le sostituiscono (`replace`), sempre prima dei dati.
117 */
118export function rewritePrompt(draft: string, answers: readonly Answer[], custom?: CustomInstructions): string {
119 const decisions = answers.length
120 ? answers.map(a => `- Q: ${a.question}\n A: ${a.answer}`).join("\n")
121 : "(none)";
122 const data = `<draft>\n${draft}\n</draft>\n\n<decisions>\n${decisions}\n</decisions>`;
123 if (!custom) return `${REWRITE_PROMPT}\n\n${data}`;
124 const block = customInstructionsBlock(custom);
125 return custom.mode === "replace" ? `${block}\n\n${data}` : `${REWRITE_PROMPT}\n\n${block}\n\n${data}`;
126}
127
128/**
129 * Richiesta per un modello che non vede la sessione (il ruolo `rewrite` in omp, il campo
130 * `model` in Claude Code): la conversazione arriva come trascrizione (`[User]`, `[Assistant]`,
131 * `[Tool Call]`, `[Tool Result]`) prima del prompt delle domande o della riscrittura, che
132 * resta lo stesso del side turn.
133 */
134export function withTranscript(transcript: string, promptText: string): string {
135 const conversation = transcript.trim()
136 ? `The conversation so far between the user and you, as a transcript ([Assistant] is you; long tool outputs are truncated):\n<conversation>\n${transcript.trim()}\n</conversation>`
137 : "The conversation has no messages yet.";
138 return `${OUT_OF_SESSION}\n\n${conversation}\n\n${promptText}`;
139}
140
141/**
142 * Conversazione in forma Messages API come trascrizione, nel formato di omp: `[User]`,
143 * `[Assistant]`, `[Tool Call]` (`nome(arg=json, ...)`) e `[Tool Result]` troncato a 2000
144 * caratteri. Il thinking e i media restano fuori; i tag `<conversation>` nel testo vengono
145 * neutralizzati, così non chiudono la cornice di `withTranscript`.
146 */
147export function serializeTranscript(messages: readonly TranscriptMessage[]): string {
148 const parts: string[] = [];
149 for (const { role, content } of messages) {
150 const texts: string[] = [];
151 const calls: string[] = [];
152 const results: string[] = [];
153 for (const block of content) {
154 if (block.type === "text" && typeof block.text === "string") {
155 texts.push(block.text);
156 } else if (block.type === "tool_use" && typeof block.name === "string") {
157 const input = typeof block.input === "object" && block.input !== null ? Object.entries(block.input) : [];
158 calls.push(`${block.name}(${input.map(([key, value]) => `${key}=${JSON.stringify(value) ?? "null"}`).join(", ")})`);
159 } else if (block.type === "tool_result") {
160 const raw = block.content;
161 const text =
162 typeof raw === "string"
163 ? raw
164 : Array.isArray(raw)
165 ? raw.map(item => (typeof item === "object" && item !== null && item.type === "text" && typeof item.text === "string" ? item.text : "")).join("")
166 : "";
167 const cut = text.length - TOOL_RESULT_MAX_CHARS;
168 if (text) results.push(cut > 0 ? `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${cut} more characters truncated]` : text);
169 }
170 }
171 // Come in omp: i risultati dei tool precedono il testo dell'utente, le chiamate seguono quello dell'assistente.
172 for (const result of results) parts.push(`[Tool Result]: ${result}`);
173 if (texts.length) parts.push(`[${role === "user" ? "User" : "Assistant"}]: ${texts.join("\n")}`);
174 if (calls.length) parts.push(`[Tool Call]: ${calls.join("; ")}`);
175 }
176 return parts.join("\n\n").replace(/<\s*\/?\s*conversation\s*>/gi, tag => `<${tag.slice(1)}`);
177}
178
179/**
180 * Domande dal JSON del modello; `undefined` se la risposta non contiene un oggetto
181 * `{"questions": [...]}` leggibile. Ogni domanda è validata da sola: una domanda
182 * malformata viene scartata senza perdere le altre, e i campi accessori non validi
183 * (`header`, `multi`, `recommended`) vengono ignorati invece di invalidare la domanda.
184 */
185export function parseQuestions(text: string): Question[] | undefined {
186 const start = text.indexOf("{");
187 const end = text.lastIndexOf("}");
188 if (start < 0 || end <= start) return undefined;
189 let data: unknown;
190 try {
191 data = JSON.parse(text.slice(start, end + 1));
192 } catch {
193 return undefined;
194 }
195 if (typeof data !== "object" || data === null || !("questions" in data) || !Array.isArray(data.questions)) {
196 return undefined;
197 }
198
199 const questions: Question[] = [];
200 for (const raw of data.questions) {
201 if (typeof raw !== "object" || raw === null) continue;
202 const question = "question" in raw && typeof raw.question === "string" ? raw.question.trim() : "";
203 const options = "options" in raw && Array.isArray(raw.options) ? raw.options.flatMap(parseOption).slice(0, MAX_OPTIONS) : [];
204 if (!question || options.length < 2) continue;
205 const header = "header" in raw && typeof raw.header === "string" ? raw.header.trim().slice(0, HEADER_MAX).trimEnd() : "";
206 const recommended = "recommended" in raw ? raw.recommended : undefined;
207 questions.push({
208 // id posizionale: unico per costruzione.
209 id: `q${questions.length + 1}`,
210 header: header || undefined,
211 question,
212 options,
213 multi: "multi" in raw && raw.multi === true,
214 recommended:
215 typeof recommended === "number" && Number.isInteger(recommended) && recommended >= 0 && recommended < options.length
216 ? recommended
217 : undefined,
218 });
219 if (questions.length === MAX_QUESTIONS) break;
220 }
221 return questions;
222}
223
224/** Opzione `{label, description?}` o stringa semplice; `[]` se non ha un'etichetta. */
225function parseOption(raw: unknown): QuestionOption[] {
226 if (typeof raw === "string") return raw.trim() ? [{ label: raw.trim() }] : [];
227 if (typeof raw !== "object" || raw === null || !("label" in raw) || typeof raw.label !== "string") return [];
228 const label = raw.label.trim();
229 if (!label) return [];
230 const description = "description" in raw && typeof raw.description === "string" ? raw.description.trim() : "";
231 return [{ label, description: description || undefined }];
232}
233
234/**
235 * Domande nel formato del dialogo AskUserQuestion di Claude Code: l'intestazione è
236 * obbligatoria e l'opzione consigliata porta il segno ` (Recommended)` nell'etichetta.
237 */
238export function toAskUserQuestions(questions: readonly Question[]): AskUserQuestion[] {
239 return questions.map((q, index) => ({
240 question: q.question,
241 header: q.header ?? `Question ${index + 1}`,
242 options: q.options.map((o, i) => ({ label: i === q.recommended ? `${o.label}${RECOMMENDED}` : o.label, description: o.description ?? "" })),
243 multiSelect: q.multi,
244 }));
245}
246
247/**
248 * Decisioni dalle risposte del dialogo (testo della domanda → etichette scelte, separate
249 * da virgole, o testo libero), senza il segno della consigliata. Una domanda lasciata
250 * senza risposta non diventa una decisione.
251 */
252export function readAskAnswers(questions: readonly AskUserQuestion[], answers: Readonly<Record<string, unknown>>): Answer[] {
253 return questions.flatMap(({ question }) => {
254 const value = answers[question];
255 const answer = typeof value === "string" ? value.replaceAll(RECOMMENDED, "").trim() : "";
256 return answer ? [{ question, answer }] : [];
257 });
258}
259
260/**
261 * Ultime `rows` righe visive di `text` mandate a capo entro `width` caratteri,
262 * spezzando sugli spazi quando possibile: l'anteprima segue il testo appena
263 * arrivato anche dentro un paragrafo più largo del terminale.
264 */
265export function previewRows(text: string, width: number, rows: number): string[] {
266 const out: string[] = [];
267 // Ogni riga logica produce almeno una riga visiva: bastano le ultime `rows`.
268 for (const line of text.trimEnd().split("\n").slice(-rows)) {
269 let rest = line;
270 while (rest.length > width) {
271 const space = rest.lastIndexOf(" ", width);
272 const cut = space > 0 ? space : width;
273 out.push(rest.slice(0, cut));
274 rest = rest.slice(space > 0 ? cut + 1 : cut);
275 }
276 out.push(rest);
277 }
278 return out.slice(-rows);
279}
280