@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.
- package/LICENSE +21 -21
- package/README.md +296 -258
- package/extensions/autotune-extension.ts +250 -250
- package/extensions/autotune.ts +558 -520
- package/extensions/conversation-resume.ts +400 -399
- package/extensions/provider-guard-extension.ts +195 -195
- package/extensions/provider-guard.ts +183 -183
- package/extensions/spec-merge-extension.ts +286 -286
- package/extensions/spec-merge.ts +373 -373
- package/extensions/startup-banner.ts +237 -237
- package/extensions/working-phrases.ts +295 -295
- package/extensions/zero-models.ts +463 -333
- package/package.json +73 -73
- package/prompts/forge.md +34 -34
- package/prompts/orchestrator.md +292 -246
- package/prompts/phases/build.md +20 -20
- package/prompts/phases/explore.md +22 -22
- package/prompts/phases/plan.md +82 -82
- package/prompts/phases/veredicto.md +30 -30
- package/skills/{sdd-routing.md → sdd-routing/SKILL.md} +51 -51
- package/skills/{skill-loop.md → skill-loop/SKILL.md} +29 -29
- package/themes/zero-sdd.json +76 -76
|
@@ -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
|
|
8
|
-
// /zero-models build=claude-opus-4-7
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"claude-
|
|
40
|
-
"claude-opus-4-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
if (!
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}
|