SLOPSHOPPER

prompt-rewrite

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

newbandguardcommandtoastmodel
v0.5.0MITupdated 2026-10-05zPeppOz/prompt-rewrite
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-rewrite
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ prompt-rewrite │ ● prompt-rewrite: /rewrite: empty draft; usage: /rewrite <draft> │ /rewrite-settings: edit the global │ ⏺ Read(src/auth.ts) │ instructions in the prompt and press │ ⎿ Read 6 lines │ Enter; leave them empty to clear │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rewrite ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prompt-rewrite

CI

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.

Requirements

  • omp: 18.3.0 or newer, the first release that gives extensions ctx.runEphemeralTurn. On older versions /rewrite reports the required version and stops. /rewrite-settings and the Rewrite model role need 18.3.1.
  • Claude Code: 2.1.289 or newer, with mods turned on (the default). On older versions /rewrite reports the required version and stops.
  • An interactive session: the terminal or desktop app, or for omp an RPC client that answers extension dialogs. In print mode (omp -p, claude -p) there is no prompt box to receive the result, so /rewrite fails immediately without calling the model.

Installation

Install it only one way at a time per host: each copy registers the same /rewrite command.

Claude Code

/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.

omp

/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.

Usage

/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.

How it works

  1. Direction questions: the model reads the draft together with the session context and asks up to 4 questions about goal, scope, constraints, deliverable and acceptance criteria, each with suggested options, a recommended choice, and a free-form answer. Anything the conversation already answers is not asked; if the direction is already clear, this step is skipped. If the model's reply can't be read, you get a warning and the rewrite goes ahead without questions.
  2. Rewrite: the model rewrites the draft using your answers and your custom instructions, if any.
  3. Result in the prompt box: the rewritten prompt is placed in the prompt box, so you can review or edit it before pressing Enter. Text you typed in the meantime is kept and the rewrite is appended after it.
  4. Cancel: press Esc while the model is working, or close the questions dialog. On cancel or error, your original draft is put back in the prompt box.

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.

Choosing the model

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.

Claude Code

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.

omp

Assign the rewrite role in omp's model selector:

  1. Run /model and open the Roles view: the extension adds a Rewrite role there.
  2. Select it, press Enter, pick the model, then the thinking level.

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
  • Thinking level: the one in the role (:low). Without one, or with :auto, the session's level, adapted to what the model supports.
  • A model that isn't available (provider logged out, model removed): /rewrite warns and uses the session's model.
  • With secrets.enabled, secrets are obfuscated as in the session.

On another model

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.

Custom instructions

Add your own instructions to the prompt that rewrites the draft, globally and per project.

KeyTypeDefault
instructionstextempty
instructions modeappend or replaceappend

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.

Claude Code

/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.
  • Esc in a dialog cancels without changing anything.
ScopeWhere
GlobalThe 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.

omp

/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:

ScopeFileWritten by /rewrite-settings
Global~/.omp/agent/config.ymlThrough omp's own settings store, like /settings
Project<cwd>/.omp/config.ymlDirectly by the extension
rewrite:
  instructions: |-
    Always write the prompt in English.
    Keep it under 200 words.
  instructionsMode: append # or "replace"; default "append"

Known limitations

  • Each rewrite makes two model calls (questions, then rewrite), so with a slow or expensive model it is slow or expensive. On the session's model they reuse its prompt cache. On another model, each call sends the system prompt and the whole transcript again, and a conversation longer than that model's context window fails (the draft is restored).
  • On another model the conversation arrives as a transcript, so the model doesn't see long tool outputs past 2000 characters or a reply the agent is still writing.
  • In multiple-choice questions, a question left with no option selected is treated as unanswered and ignored.
  • Only one /rewrite runs at a time; starting another one while it runs shows /rewrite: already running.
  • Claude Code:
  • In a session that hasn't had a reply yet there is nothing to fork, so /rewrite sends the two requests to the session's model as on another model.
  • Esc cuts the request, but what the model already generated may still be billed.
  • A typed /rewrite waits for a running turn to end, like other commands.
  • The rewrite model must be one the session's provider serves. The Claude Code options have no thinking-level suffix: use Rewrite effort.
  • .claude/rewrite.json is written in place, not atomically.
  • In the VS Code extension's chat panel and in claude -p mods draw nothing; /rewrite needs the terminal or the desktop app.
  • omp:
  • Hosts without omp's ask dialog (RPC and ACP clients) show one select per question: the recommended option is marked in its description, "Other…" opens a free-text input, and multiple-choice questions accept a single option. Esc cancellation is not available there.
  • /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.

Development

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.
  • omp: index.ts wires the commands (dialogs, side turns, widget, composer); model.ts handles the rewrite role; storage.ts writes the settings.
  • Claude Code: 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.

License

MIT

Source 3 files
hooks/register.ts 424 lines
1import 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};
424
instructions.ts 142 lines
1/**
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}
142
rewrite.ts 280 lines
1import 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 => `&lt;${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