@gonrocca/zero-pi 0.1.10 → 0.1.12
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 +258 -214
- package/extensions/autotune-extension.ts +250 -250
- package/extensions/autotune.ts +520 -520
- package/extensions/conversation-resume.ts +399 -0
- package/extensions/provider-guard-extension.ts +195 -0
- package/extensions/provider-guard.ts +183 -0
- package/extensions/spec-merge-extension.ts +286 -286
- package/extensions/spec-merge.ts +373 -373
- package/extensions/startup-banner.ts +237 -268
- package/extensions/working-phrases.ts +295 -0
- package/extensions/zero-models.ts +333 -333
- package/package.json +73 -62
- package/prompts/forge.md +34 -34
- package/prompts/orchestrator.md +246 -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 +52 -51
- package/skills/skill-loop.md +30 -29
- package/themes/zero-sdd.json +76 -0
|
@@ -1,333 +1,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 — 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 — 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
|
+
}
|