@gonrocca/zero-pi 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,333 +1,463 @@
1
- // zero-pi — the /zero-models command.
2
- //
3
- // A real pi command — a code handler, not an LLM prompt — for reading and
4
- // changing the per-phase SDD models in `~/.pi/zero.json`. It is deterministic:
5
- // no model is involved, so it does exactly what you pick, every time.
6
- //
7
- // /zero-models interactive — pick a phase, pick a model
8
- // /zero-models build=claude-opus-4-7 set one phase directly
9
- //
10
- // The SDD orchestrator reads `~/.pi/zero.json` at the start of every `/forge`
11
- // run, so a change takes effect on the next run.
12
-
13
- import { readFileSync, writeFileSync } from "node:fs";
14
- import { homedir } from "node:os";
15
- import { join } from "node:path";
16
-
17
- import { readAutotuneMode, type AutotuneMode } from "./autotune.ts";
18
- import type { AutotunePending } from "./autotune-extension.ts";
19
-
20
- /** The SDD phases, in pipeline order. */
21
- export const PHASES = ["explore", "plan", "build", "veredicto"] as const;
22
- export type Phase = (typeof PHASES)[number];
23
-
24
- /** The per-phase model map. */
25
- export type PhaseModels = Record<Phase, string>;
26
-
27
- /** Fallback models when `~/.pi/zero.json` has none — cheap to explore, strong
28
- * to plan and review. */
29
- const DEFAULT_MODELS: PhaseModels = {
30
- explore: "claude-haiku-4-5",
31
- plan: "claude-opus-4-7",
32
- build: "claude-sonnet-4-6",
33
- veredicto: "claude-opus-4-7",
34
- };
35
-
36
- /** Models offered in the interactive picker — the Claude lineup pi-claude-cli
37
- * exposes. Any other model can still be typed via the custom option. */
38
- const MODEL_CHOICES = [
39
- "claude-opus-4-7",
40
- "claude-opus-4-6",
41
- "claude-sonnet-4-6",
42
- "claude-haiku-4-5",
43
- ];
44
-
45
- /** Absolute path of pi's `zero.json` marker. */
46
- function zeroJsonPath(): string {
47
- return join(homedir(), ".pi", "zero.json");
48
- }
49
-
50
- /** Whether a string names an SDD phase. */
51
- export function isPhase(value: string): value is Phase {
52
- return (PHASES as readonly string[]).includes(value);
53
- }
54
-
55
- /** Read `~/.pi/zero.json`, returning an empty object when absent or invalid. */
56
- function readZeroJson(): Record<string, unknown> {
57
- try {
58
- return JSON.parse(readFileSync(zeroJsonPath(), "utf8")) as Record<string, unknown>;
59
- } catch {
60
- return {};
61
- }
62
- }
63
-
64
- /**
65
- * Extract the per-phase models from a zero.json object, filling any gap with
66
- * the default so the picker always has a value to show.
67
- */
68
- export function readModels(data: Record<string, unknown>): PhaseModels {
69
- const raw = (data.models ?? {}) as Record<string, unknown>;
70
- const models: PhaseModels = { ...DEFAULT_MODELS };
71
- for (const phase of PHASES) {
72
- if (typeof raw[phase] === "string") models[phase] = raw[phase] as string;
73
- }
74
- return models;
75
- }
76
-
77
- /** Parse a `<phase>=<model>` (or `<phase> <model>`) assignment. */
78
- export function parseAssignment(arg: string): { phase: Phase; model: string } | null {
79
- const match = arg.trim().match(/^(\w+)\s*[=\s]\s*(.+)$/);
80
- if (!match) return null;
81
- const phase = match[1].toLowerCase();
82
- const model = match[2].trim();
83
- if (!isPhase(phase) || model === "") return null;
84
- return { phase, model };
85
- }
86
-
87
- /** Render the per-phase model map as an aligned block. */
88
- export function formatModels(models: PhaseModels): string {
89
- return PHASES.map((phase) => ` ${phase.padEnd(10)} ${models[phase]}`).join("\n");
90
- }
91
-
92
- /** The valid `autotune` modes a user can set. */
93
- const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
94
-
95
- /**
96
- * Parse the value of a `/zero-models autotune=<mode>` argument.
97
- *
98
- * Accepts only `auto`, `ask`, or `off` — case-insensitive and trimmed.
99
- * Returns `null` for any other value so the caller can emit a usage warning
100
- * and write nothing.
101
- */
102
- export function parseAutotuneArg(arg: string): AutotuneMode | null {
103
- const value = arg.trim().toLowerCase();
104
- return (AUTOTUNE_MODES as readonly string[]).includes(value)
105
- ? (value as AutotuneMode)
106
- : null;
107
- }
108
-
109
- /** A short human label for an autotune mode, used in menus and notifications. */
110
- export function formatAutotune(mode: AutotuneMode): string {
111
- switch (mode) {
112
- case "auto":
113
- return "auto aplica cambios automáticamente";
114
- case "ask":
115
- return "ask sugiere y espera confirmación";
116
- case "off":
117
- return "off no ajusta nada";
118
- }
119
- }
120
-
121
- /** Write the models back into `~/.pi/zero.json`, preserving every other key. */
122
- function writeModels(data: Record<string, unknown>, models: PhaseModels): void {
123
- writeFileSync(zeroJsonPath(), `${JSON.stringify({ ...data, models }, null, 2)}\n`, "utf8");
124
- }
125
-
126
- /**
127
- * Write an updated `~/.pi/zero.json` object, preserving every other key via the
128
- * same `{ ...data }` spread, 2-space indent and trailing newline `writeModels`
129
- * uses. The caller passes the keys it wants to add/override.
130
- */
131
- function writeZeroJson(data: Record<string, unknown>, patch: Record<string, unknown>): void {
132
- writeFileSync(zeroJsonPath(), `${JSON.stringify({ ...data, ...patch }, null, 2)}\n`, "utf8");
133
- }
134
-
135
- /** Whether a value is a non-null, non-array object. */
136
- function isObject(value: unknown): value is Record<string, unknown> {
137
- return typeof value === "object" && value !== null && !Array.isArray(value);
138
- }
139
-
140
- /**
141
- * Extract the `autotunePending` adjustments from a zero.json object.
142
- *
143
- * Returns only well-formed records — an array entry with string `phase`/`from`/
144
- * `to`/`reason` and a recognized phase — so a malformed key never crashes the
145
- * picker. A missing or off-shape key yields `[]`.
146
- */
147
- function readAutotunePending(data: Record<string, unknown>): AutotunePending[] {
148
- const raw = data.autotunePending;
149
- if (!Array.isArray(raw)) return [];
150
- const pending: AutotunePending[] = [];
151
- for (const entry of raw) {
152
- if (!isObject(entry)) continue;
153
- const { phase, from, to, reason } = entry;
154
- if (
155
- typeof phase === "string" &&
156
- isPhase(phase) &&
157
- typeof from === "string" &&
158
- typeof to === "string" &&
159
- typeof reason === "string"
160
- ) {
161
- pending.push({ phase, from, to, reason });
162
- }
163
- }
164
- return pending;
165
- }
166
-
167
- /** The slice of pi's extension API this command uses. */
168
- interface PiUI {
169
- select(prompt: string, options: string[]): Promise<string | undefined>;
170
- input(prompt: string, placeholder?: string): Promise<string | undefined>;
171
- notify(message: string, type?: "info" | "warning" | "error"): void;
172
- }
173
- interface PiCommandContext {
174
- ui: PiUI;
175
- }
176
- interface PiExtensionAPI {
177
- registerCommand(
178
- name: string,
179
- options: {
180
- description?: string;
181
- handler: (args: string, ctx: PiCommandContext) => Promise<void> | void;
182
- },
183
- ): void;
184
- }
185
-
186
- const SAVE_AND_EXIT = "— guardar y salir —";
187
- const CUSTOM_MODEL = "— otro modelo (escribir) —";
188
-
189
- /**
190
- * The pi extension entry point registers the `/zero-models` command.
191
- */
192
- export default function register(pi?: PiExtensionAPI): void {
193
- if (!pi || typeof pi.registerCommand !== "function") return;
194
-
195
- pi.registerCommand("zero-models", {
196
- description: "Show or set the per-phase SDD models — /zero-models [<phase>=<model>]",
197
- handler: async (args: string, ctx: PiCommandContext): Promise<void> => {
198
- try {
199
- const data = readZeroJson();
200
- const models = readModels(data);
201
-
202
- // Direct form: /zero-models build=claude-opus-4-7
203
- const arg = args.trim();
204
- if (arg) {
205
- // Direct form: /zero-models autotune=<mode>
206
- const autotuneMatch = arg.match(/^autotune\s*[=\s]\s*(.+)$/i);
207
- if (autotuneMatch) {
208
- const mode = parseAutotuneArg(autotuneMatch[1]);
209
- if (!mode) {
210
- ctx.ui.notify(
211
- "uso: /zero-models autotune=<modo> (modo: auto | ask | off)",
212
- "warning",
213
- );
214
- return;
215
- }
216
- writeZeroJson(data, { autotune: mode });
217
- ctx.ui.notify(`zero autotune: ${formatAutotune(mode)}`, "info");
218
- return;
219
- }
220
-
221
- const assignment = parseAssignment(arg);
222
- if (!assignment) {
223
- ctx.ui.notify(
224
- "uso: /zero-models —o— /zero-models <fase>=<modelo> " +
225
- "(fase: explore | plan | build | veredicto) —o— " +
226
- "/zero-models autotune=<modo>",
227
- "warning",
228
- );
229
- return;
230
- }
231
- models[assignment.phase] = assignment.model;
232
- writeModels(data, models);
233
- ctx.ui.notify(`zero models: ${assignment.phase} ${assignment.model}`, "info");
234
- return;
235
- }
236
-
237
- // Interactive form: pick a phase, pick a model, repeat until saved.
238
- let changed = false;
239
- let autotuneMode = readAutotuneMode(data);
240
- let autotuneChanged = false;
241
- let pending = readAutotunePending(data);
242
- let pendingApplied = false;
243
- for (;;) {
244
- const applyEntry =
245
- pending.length > 0
246
- ? `★ aplicar sugerencia: ${pending
247
- .map((p) => `${p.phase} → ${p.to}`)
248
- .join(", ")}`
249
- : null;
250
- const autotuneEntry = `autotune → ${autotuneMode}`;
251
-
252
- const phasePick = await ctx.ui.select("zero · modelos SDD — elegí una fase", [
253
- ...(applyEntry ? [applyEntry] : []),
254
- ...PHASES.map((p) => `${p} → ${models[p]}`),
255
- autotuneEntry,
256
- SAVE_AND_EXIT,
257
- ]);
258
- if (!phasePick || phasePick === SAVE_AND_EXIT) break;
259
-
260
- // Apply the pending autotune suggestion.
261
- if (applyEntry && phasePick === applyEntry) {
262
- for (const adj of pending) models[adj.phase] = adj.to;
263
- changed = true;
264
- pendingApplied = true;
265
- pending = [];
266
- continue;
267
- }
268
-
269
- // Change the autotune mode.
270
- if (phasePick === autotuneEntry) {
271
- const modePick = await ctx.ui.select(
272
- "Modo de autotune",
273
- AUTOTUNE_MODES.map((m) => formatAutotune(m)),
274
- );
275
- if (!modePick) continue;
276
- const picked = parseAutotuneArg(modePick.split(/\s/)[0]);
277
- if (picked && picked !== autotuneMode) {
278
- autotuneMode = picked;
279
- autotuneChanged = true;
280
- }
281
- continue;
282
- }
283
-
284
- const phase = phasePick.split(/\s/)[0];
285
- if (!isPhase(phase)) break;
286
-
287
- const modelPick = await ctx.ui.select(`Modelo para «${phase}»`, [
288
- ...MODEL_CHOICES,
289
- CUSTOM_MODEL,
290
- ]);
291
- if (!modelPick) continue;
292
-
293
- let model = modelPick;
294
- if (modelPick === CUSTOM_MODEL) {
295
- const typed = await ctx.ui.input(`Modelo para «${phase}»`, models[phase]);
296
- if (!typed || typed.trim() === "") continue;
297
- model = typed.trim();
298
- }
299
- models[phase] = model;
300
- changed = true;
301
- }
302
-
303
- if (changed || autotuneChanged) {
304
- // Build the patch, preserving every other key via the spread. When
305
- // the pending suggestion was applied, clear the `autotunePending` key.
306
- const patch: Record<string, unknown> = { models };
307
- if (autotuneChanged) patch.autotune = autotuneMode;
308
- if (pendingApplied) patch.autotunePending = undefined;
309
-
310
- const merged = { ...data, ...patch };
311
- if (pendingApplied) delete merged.autotunePending;
312
- writeFileSync(zeroJsonPath(), `${JSON.stringify(merged, null, 2)}\n`, "utf8");
313
-
314
- const summary = [`zero · modelos SDD guardados:\n${formatModels(models)}`];
315
- summary.push(` autotune ${autotuneMode}`);
316
- if (pendingApplied) summary.push("sugerencia aplicada");
317
- ctx.ui.notify(summary.join("\n"), "info");
318
- } else {
319
- ctx.ui.notify(
320
- `zero · modelos SDD (sin cambios):\n${formatModels(models)}\n` +
321
- ` autotune ${autotuneMode}`,
322
- "info",
323
- );
324
- }
325
- } catch (err) {
326
- ctx.ui.notify(
327
- `zero-models: ${err instanceof Error ? err.message : String(err)}`,
328
- "error",
329
- );
330
- }
331
- },
332
- });
333
- }
1
+ // zero-pi — the /zero-models command.
2
+ //
3
+ // A real pi command — a code handler, not an LLM prompt — for reading and
4
+ // changing the per-phase SDD models in `~/.pi/zero.json`. It is deterministic:
5
+ // no model is involved, so it does exactly what you pick, every time.
6
+ //
7
+ // /zero-models interactive — phase, provider, model
8
+ // /zero-models build=claude-opus-4-7 set one phase directly
9
+ // /zero-models build=codex/gpt-5-codex set phase with an explicit provider
10
+ //
11
+ // The interactive picker reads pi's model registry, so every provider you have
12
+ // configured — anthropic, codex, opencode, … — and its models are offered, not
13
+ // just a hardcoded Claude list.
14
+ //
15
+ // The SDD orchestrator reads `~/.pi/zero.json` at the start of every `/forge`
16
+ // run, so a change takes effect on the next run.
17
+
18
+ import { readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ import { readAutotuneMode, type AutotuneMode } from "./autotune.ts";
23
+ import type { AutotunePending } from "./autotune-extension.ts";
24
+
25
+ /** The SDD phases, in pipeline order. */
26
+ export const PHASES = ["explore", "plan", "build", "veredicto"] as const;
27
+ export type Phase = (typeof PHASES)[number];
28
+
29
+ /** The per-phase model map. */
30
+ export type PhaseModels = Record<Phase, string>;
31
+ /** The per-phase provider map — parallel to {@link PhaseModels}. */
32
+ export type PhaseProviders = Record<Phase, string>;
33
+
34
+ /** Fallback models when `~/.pi/zero.json` has none — cheap to explore, strong
35
+ * to plan and review. */
36
+ const DEFAULT_MODELS: PhaseModels = {
37
+ explore: "claude-haiku-4-5",
38
+ plan: "claude-opus-4-7",
39
+ build: "claude-sonnet-4-6",
40
+ veredicto: "claude-opus-4-7",
41
+ };
42
+
43
+ /** Model list used only when pi's model registry is unavailable. */
44
+ const FALLBACK_MODELS = [
45
+ "claude-opus-4-7",
46
+ "claude-opus-4-6",
47
+ "claude-sonnet-4-6",
48
+ "claude-haiku-4-5",
49
+ ];
50
+
51
+ /** Absolute path of pi's `zero.json` marker. */
52
+ function zeroJsonPath(): string {
53
+ return join(homedir(), ".pi", "zero.json");
54
+ }
55
+
56
+ /** Whether a string names an SDD phase. */
57
+ export function isPhase(value: string): value is Phase {
58
+ return (PHASES as readonly string[]).includes(value);
59
+ }
60
+
61
+ /** Read `~/.pi/zero.json`, returning an empty object when absent or invalid. */
62
+ function readZeroJson(): Record<string, unknown> {
63
+ try {
64
+ return JSON.parse(readFileSync(zeroJsonPath(), "utf8")) as Record<string, unknown>;
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Extract the per-phase models from a zero.json object, filling any gap with
72
+ * the default so the picker always has a value to show.
73
+ */
74
+ export function readModels(data: Record<string, unknown>): PhaseModels {
75
+ const raw = (data.models ?? {}) as Record<string, unknown>;
76
+ const models: PhaseModels = { ...DEFAULT_MODELS };
77
+ for (const phase of PHASES) {
78
+ if (typeof raw[phase] === "string") models[phase] = raw[phase] as string;
79
+ }
80
+ return models;
81
+ }
82
+
83
+ /**
84
+ * Extract the per-phase providers from a zero.json object. A missing provider
85
+ * is an empty string — the consumer resolves or ignores it.
86
+ */
87
+ export function readProviders(data: Record<string, unknown>): PhaseProviders {
88
+ const raw = (data.providers ?? {}) as Record<string, unknown>;
89
+ const providers: PhaseProviders = { explore: "", plan: "", build: "", veredicto: "" };
90
+ for (const phase of PHASES) {
91
+ if (typeof raw[phase] === "string") providers[phase] = raw[phase] as string;
92
+ }
93
+ return providers;
94
+ }
95
+
96
+ /** A provider-qualified model assignment from the direct command form. */
97
+ export interface Assignment {
98
+ phase: Phase;
99
+ model: string;
100
+ provider?: string;
101
+ }
102
+
103
+ /**
104
+ * Parse a direct `<phase>=<model>` assignment. The value may carry an explicit
105
+ * provider as `<provider>/<model>` — the first `/` splits them.
106
+ */
107
+ export function parseAssignment(arg: string): Assignment | null {
108
+ const match = arg.trim().match(/^(\w+)\s*[=\s]\s*(.+)$/);
109
+ if (!match) return null;
110
+ const phase = match[1].toLowerCase();
111
+ if (!isPhase(phase)) return null;
112
+ let value = match[2].trim();
113
+ if (value === "") return null;
114
+
115
+ const slash = value.indexOf("/");
116
+ if (slash > 0 && slash < value.length - 1) {
117
+ return { phase, provider: value.slice(0, slash).trim(), model: value.slice(slash + 1).trim() };
118
+ }
119
+ return { phase, model: value };
120
+ }
121
+
122
+ /** Render the per-phase model map as an aligned `provider/model` block. */
123
+ export function formatPhases(models: PhaseModels, providers: PhaseProviders): string {
124
+ return PHASES.map((phase) => {
125
+ const provider = providers[phase];
126
+ const label = provider ? `${provider}/${models[phase]}` : models[phase];
127
+ return ` ${phase.padEnd(10)} ${label}`;
128
+ }).join("\n");
129
+ }
130
+
131
+ /** The valid `autotune` modes a user can set. */
132
+ const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
133
+
134
+ /**
135
+ * Parse the value of a `/zero-models autotune=<mode>` argument.
136
+ *
137
+ * Accepts only `auto`, `ask`, or `off` case-insensitive and trimmed.
138
+ * Returns `null` for any other value so the caller can emit a usage warning
139
+ * and write nothing.
140
+ */
141
+ export function parseAutotuneArg(arg: string): AutotuneMode | null {
142
+ const value = arg.trim().toLowerCase();
143
+ return (AUTOTUNE_MODES as readonly string[]).includes(value)
144
+ ? (value as AutotuneMode)
145
+ : null;
146
+ }
147
+
148
+ /** A short human label for an autotune mode, used in menus and notifications. */
149
+ export function formatAutotune(mode: AutotuneMode): string {
150
+ switch (mode) {
151
+ case "auto":
152
+ return "auto — aplica cambios automáticamente";
153
+ case "ask":
154
+ return "ask — sugiere y espera confirmación";
155
+ case "off":
156
+ return "off — no ajusta nada";
157
+ }
158
+ }
159
+
160
+ /** A pi model entry — only the fields the picker needs. */
161
+ export interface PiModel {
162
+ provider: string;
163
+ id: string;
164
+ name?: string;
165
+ }
166
+
167
+ /**
168
+ * Group model ids by provider, each list sorted and de-duplicated. Malformed
169
+ * entries are skipped so a registry quirk never crashes the picker.
170
+ */
171
+ export function groupByProvider(models: readonly PiModel[]): Map<string, string[]> {
172
+ const map = new Map<string, string[]>();
173
+ for (const m of models) {
174
+ if (!m || typeof m.provider !== "string" || typeof m.id !== "string") continue;
175
+ if (m.provider === "" || m.id === "") continue;
176
+ const list = map.get(m.provider) ?? [];
177
+ if (!list.includes(m.id)) list.push(m.id);
178
+ map.set(m.provider, list);
179
+ }
180
+ for (const list of map.values()) list.sort();
181
+ return map;
182
+ }
183
+
184
+ /** The slice of pi's extension API this command uses. */
185
+ interface PiUI {
186
+ select(prompt: string, options: string[]): Promise<string | undefined>;
187
+ input(prompt: string, placeholder?: string): Promise<string | undefined>;
188
+ notify(message: string, type?: "info" | "warning" | "error"): void;
189
+ }
190
+ /** pi's model registry the source of every provider's model list. */
191
+ interface PiModelRegistry {
192
+ getAll(): PiModel[];
193
+ getAvailable?(): PiModel[];
194
+ }
195
+ interface PiCommandContext {
196
+ ui: PiUI;
197
+ modelRegistry?: PiModelRegistry;
198
+ }
199
+ interface PiExtensionAPI {
200
+ registerCommand(
201
+ name: string,
202
+ options: {
203
+ description?: string;
204
+ handler: (args: string, ctx: PiCommandContext) => Promise<void> | void;
205
+ },
206
+ ): void;
207
+ }
208
+
209
+ const SAVE_AND_EXIT = "— guardar y salir —";
210
+ const CUSTOM_MODEL = "— otro modelo (escribir) —";
211
+ const CUSTOM_PROVIDER = "— otro provider (escribir) ";
212
+
213
+ /**
214
+ * Group the registry's models by provider. Prefers `getAvailable()` (providers
215
+ * with configured auth — what the user actually has) and falls back to the
216
+ * full `getAll()` set, then to an empty map when no registry is present.
217
+ */
218
+ function providerGroups(registry: PiModelRegistry | undefined): Map<string, string[]> {
219
+ if (!registry || typeof registry.getAll !== "function") return new Map();
220
+ try {
221
+ const available = typeof registry.getAvailable === "function" ? registry.getAvailable() : [];
222
+ const source = available && available.length > 0 ? available : registry.getAll();
223
+ return groupByProvider(source ?? []);
224
+ } catch {
225
+ return new Map();
226
+ }
227
+ }
228
+
229
+ /** Find the provider that owns a model id, if the registry knows it. */
230
+ function resolveProvider(
231
+ registry: PiModelRegistry | undefined,
232
+ modelId: string,
233
+ ): string | undefined {
234
+ if (!registry || typeof registry.getAll !== "function") return undefined;
235
+ try {
236
+ for (const m of registry.getAll()) {
237
+ if (m && m.id === modelId && typeof m.provider === "string") return m.provider;
238
+ }
239
+ } catch {
240
+ /* ignore */
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ /**
246
+ * The pi extension entry point — registers the `/zero-models` command.
247
+ */
248
+ export default function register(pi?: PiExtensionAPI): void {
249
+ if (!pi || typeof pi.registerCommand !== "function") return;
250
+
251
+ pi.registerCommand("zero-models", {
252
+ description:
253
+ "Show or set the per-phase SDD models — /zero-models [<phase>=[<provider>/]<model>]",
254
+ handler: async (args: string, ctx: PiCommandContext): Promise<void> => {
255
+ try {
256
+ const data = readZeroJson();
257
+ const models = readModels(data);
258
+ const providers = readProviders(data);
259
+ const groups = providerGroups(ctx.modelRegistry);
260
+
261
+ // Direct form: /zero-models build=claude-opus-4-7
262
+ const arg = args.trim();
263
+ if (arg) {
264
+ // Direct form: /zero-models autotune=<mode>
265
+ const autotuneMatch = arg.match(/^autotune\s*[=\s]\s*(.+)$/i);
266
+ if (autotuneMatch) {
267
+ const mode = parseAutotuneArg(autotuneMatch[1]);
268
+ if (!mode) {
269
+ ctx.ui.notify(
270
+ "uso: /zero-models autotune=<modo> (modo: auto | ask | off)",
271
+ "warning",
272
+ );
273
+ return;
274
+ }
275
+ writeFileSync(
276
+ zeroJsonPath(),
277
+ `${JSON.stringify({ ...data, autotune: mode }, null, 2)}\n`,
278
+ "utf8",
279
+ );
280
+ ctx.ui.notify(`zero autotune: ${formatAutotune(mode)}`, "info");
281
+ return;
282
+ }
283
+
284
+ const assignment = parseAssignment(arg);
285
+ if (!assignment) {
286
+ ctx.ui.notify(
287
+ "uso: /zero-models —o— /zero-models <fase>=[<provider>/]<modelo> " +
288
+ "(fase: explore | plan | build | veredicto) —o— " +
289
+ "/zero-models autotune=<modo>",
290
+ "warning",
291
+ );
292
+ return;
293
+ }
294
+ models[assignment.phase] = assignment.model;
295
+ providers[assignment.phase] =
296
+ assignment.provider ??
297
+ resolveProvider(ctx.modelRegistry, assignment.model) ??
298
+ providers[assignment.phase];
299
+ writeFileSync(
300
+ zeroJsonPath(),
301
+ `${JSON.stringify({ ...data, models, providers }, null, 2)}\n`,
302
+ "utf8",
303
+ );
304
+ const shown = providers[assignment.phase]
305
+ ? `${providers[assignment.phase]}/${assignment.model}`
306
+ : assignment.model;
307
+ ctx.ui.notify(`zero models: ${assignment.phase} ${shown}`, "info");
308
+ return;
309
+ }
310
+
311
+ // Interactive form: pick a phase, a provider, a model — until saved.
312
+ let changed = false;
313
+ let autotuneMode = readAutotuneMode(data);
314
+ let autotuneChanged = false;
315
+ let pending = readAutotunePending(data);
316
+ let pendingApplied = false;
317
+ for (;;) {
318
+ const applyEntry =
319
+ pending.length > 0
320
+ ? `★ aplicar sugerencia: ${pending
321
+ .map((p) => `${p.phase} → ${p.to}`)
322
+ .join(", ")}`
323
+ : null;
324
+ const autotuneEntry = `autotune → ${autotuneMode}`;
325
+
326
+ const phasePick = await ctx.ui.select("zero · modelos SDD — elegí una fase", [
327
+ ...(applyEntry ? [applyEntry] : []),
328
+ ...PHASES.map(
329
+ (p) => `${p} → ${providers[p] ? `${providers[p]}/` : ""}${models[p]}`,
330
+ ),
331
+ autotuneEntry,
332
+ SAVE_AND_EXIT,
333
+ ]);
334
+ if (!phasePick || phasePick === SAVE_AND_EXIT) break;
335
+
336
+ // Apply the pending autotune suggestion.
337
+ if (applyEntry && phasePick === applyEntry) {
338
+ for (const adj of pending) models[adj.phase] = adj.to;
339
+ changed = true;
340
+ pendingApplied = true;
341
+ pending = [];
342
+ continue;
343
+ }
344
+
345
+ // Change the autotune mode.
346
+ if (phasePick === autotuneEntry) {
347
+ const modePick = await ctx.ui.select(
348
+ "Modo de autotune",
349
+ AUTOTUNE_MODES.map((m) => formatAutotune(m)),
350
+ );
351
+ if (!modePick) continue;
352
+ const picked = parseAutotuneArg(modePick.split(/\s/)[0]);
353
+ if (picked && picked !== autotuneMode) {
354
+ autotuneMode = picked;
355
+ autotuneChanged = true;
356
+ }
357
+ continue;
358
+ }
359
+
360
+ const phase = phasePick.split(/\s/)[0];
361
+ if (!isPhase(phase)) break;
362
+
363
+ // Pick a provider — from the registry, or typed when unknown.
364
+ let provider = providers[phase];
365
+ let modelChoices = FALLBACK_MODELS;
366
+ if (groups.size > 0) {
367
+ const providerPick = await ctx.ui.select(`Provider para «${phase}»`, [
368
+ ...[...groups.keys()].sort(),
369
+ CUSTOM_PROVIDER,
370
+ ]);
371
+ if (!providerPick) continue;
372
+ if (providerPick === CUSTOM_PROVIDER) {
373
+ const typed = await ctx.ui.input(
374
+ `Provider para «${phase}»`,
375
+ providers[phase] || "",
376
+ );
377
+ if (!typed || typed.trim() === "") continue;
378
+ provider = typed.trim();
379
+ modelChoices = groups.get(provider) ?? [];
380
+ } else {
381
+ provider = providerPick;
382
+ modelChoices = groups.get(providerPick) ?? [];
383
+ }
384
+ }
385
+
386
+ // Pick a model within that provider — or type one.
387
+ const label = provider ? `Modelo para «${phase}» (${provider})` : `Modelo para «${phase}»`;
388
+ const modelPick = await ctx.ui.select(label, [...modelChoices, CUSTOM_MODEL]);
389
+ if (!modelPick) continue;
390
+
391
+ let model = modelPick;
392
+ if (modelPick === CUSTOM_MODEL) {
393
+ const typed = await ctx.ui.input(label, models[phase]);
394
+ if (!typed || typed.trim() === "") continue;
395
+ model = typed.trim();
396
+ }
397
+ models[phase] = model;
398
+ providers[phase] = provider || resolveProvider(ctx.modelRegistry, model) || "";
399
+ changed = true;
400
+ }
401
+
402
+ if (changed || autotuneChanged) {
403
+ // Build the patch, preserving every other key via the spread. When
404
+ // the pending suggestion was applied, clear the `autotunePending` key.
405
+ const patch: Record<string, unknown> = { models, providers };
406
+ if (autotuneChanged) patch.autotune = autotuneMode;
407
+
408
+ const merged = { ...data, ...patch };
409
+ if (pendingApplied) delete merged.autotunePending;
410
+ writeFileSync(zeroJsonPath(), `${JSON.stringify(merged, null, 2)}\n`, "utf8");
411
+
412
+ const summary = [`zero · modelos SDD guardados:\n${formatPhases(models, providers)}`];
413
+ summary.push(` autotune ${autotuneMode}`);
414
+ if (pendingApplied) summary.push("sugerencia aplicada");
415
+ ctx.ui.notify(summary.join("\n"), "info");
416
+ } else {
417
+ ctx.ui.notify(
418
+ `zero · modelos SDD (sin cambios):\n${formatPhases(models, providers)}\n` +
419
+ ` autotune ${autotuneMode}`,
420
+ "info",
421
+ );
422
+ }
423
+ } catch (err) {
424
+ ctx.ui.notify(
425
+ `zero-models: ${err instanceof Error ? err.message : String(err)}`,
426
+ "error",
427
+ );
428
+ }
429
+ },
430
+ });
431
+ }
432
+
433
+ /** Whether a value is a non-null, non-array object. */
434
+ function isObject(value: unknown): value is Record<string, unknown> {
435
+ return typeof value === "object" && value !== null && !Array.isArray(value);
436
+ }
437
+
438
+ /**
439
+ * Extract the `autotunePending` adjustments from a zero.json object.
440
+ *
441
+ * Returns only well-formed records — an array entry with string `phase`/`from`/
442
+ * `to`/`reason` and a recognized phase — so a malformed key never crashes the
443
+ * picker. A missing or off-shape key yields `[]`.
444
+ */
445
+ function readAutotunePending(data: Record<string, unknown>): AutotunePending[] {
446
+ const raw = data.autotunePending;
447
+ if (!Array.isArray(raw)) return [];
448
+ const pending: AutotunePending[] = [];
449
+ for (const entry of raw) {
450
+ if (!isObject(entry)) continue;
451
+ const { phase, from, to, reason } = entry;
452
+ if (
453
+ typeof phase === "string" &&
454
+ isPhase(phase) &&
455
+ typeof from === "string" &&
456
+ typeof to === "string" &&
457
+ typeof reason === "string"
458
+ ) {
459
+ pending.push({ phase, from, to, reason });
460
+ }
461
+ }
462
+ return pending;
463
+ }