@gonrocca/zero-pi 0.1.11 → 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/README.md +13 -8
- package/extensions/autotune.ts +40 -2
- package/extensions/conversation-resume.ts +3 -2
- package/extensions/zero-models.ts +198 -68
- package/package.json +1 -1
- package/prompts/orchestrator.md +51 -5
- package/skills/{sdd-routing.md → sdd-routing/SKILL.md} +1 -0
- package/skills/{skill-loop.md → skill-loop/SKILL.md} +1 -0
package/README.md
CHANGED
|
@@ -65,19 +65,24 @@ orchestrator's plan-phase summary reports the run total and any exceptions.
|
|
|
65
65
|
### Per-phase models (`/zero-models`)
|
|
66
66
|
|
|
67
67
|
`/zero-models` is a real pi command — a code handler, not an LLM prompt — for
|
|
68
|
-
the SDD models. Run it with no argument
|
|
69
|
-
|
|
68
|
+
the SDD models. Run it with no argument for the interactive picker, or set one
|
|
69
|
+
directly:
|
|
70
70
|
|
|
71
71
|
```
|
|
72
|
-
/zero-models
|
|
73
|
-
/zero-models build=claude-opus-4-7
|
|
72
|
+
/zero-models # interactive picker
|
|
73
|
+
/zero-models build=claude-opus-4-7 # set one phase
|
|
74
|
+
/zero-models build=codex/gpt-5-codex # set one phase with an explicit provider
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
the
|
|
77
|
+
The interactive picker is **provider-aware**: it reads pi's model registry, so
|
|
78
|
+
the flow is **phase → provider → model** and every provider you have configured
|
|
79
|
+
(anthropic, codex, opencode, …) and its models are offered — not a hardcoded
|
|
80
|
+
list. An `— otro provider —` / `— otro modelo —` entry still lets you type
|
|
81
|
+
anything by hand.
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
It reads and writes `~/.pi/zero.json` — `models` (the per-phase model id) plus a
|
|
84
|
+
parallel `providers` map. The orchestrator picks the change up on the next
|
|
85
|
+
`/forge` run. The `zero` CLI also writes this file when it installs the layer.
|
|
81
86
|
|
|
82
87
|
**Run memory** — every SDD run reads from and writes to Cortex (the memory MCP
|
|
83
88
|
server). Before exploring, the orchestrator recalls prior `zero-run/*` traces
|
package/extensions/autotune.ts
CHANGED
|
@@ -172,12 +172,50 @@ export function parseRunLine(line: string): RunRecord | null {
|
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Drop duplicate run records by their `(feature, ts)` identity, keeping the
|
|
177
|
+
* FIRST occurrence in input order.
|
|
178
|
+
*
|
|
179
|
+
* The `~/.pi/zero-runs.jsonl` log is a local cache of a shared run log: a
|
|
180
|
+
* careless Cortex PULL can re-append a record already present (even a machine
|
|
181
|
+
* re-pulling its own push), so the same `(feature, ts)` may appear more than
|
|
182
|
+
* once. This pass collapses each identity to a single sample so `aggregate`
|
|
183
|
+
* never counts a run twice.
|
|
184
|
+
*
|
|
185
|
+
* The identity key is `` `${feature}\0${ts}` `` — a NUL separator, which can
|
|
186
|
+
* appear neither in a feature slug nor an ISO 8601 timestamp, so the two
|
|
187
|
+
* fields can never collide ambiguously (`feature:"a-b", ts:"c"` vs
|
|
188
|
+
* `feature:"a", ts:"b-c"` stay distinct). Identity is `(feature, ts)` alone,
|
|
189
|
+
* independent of `v`/`verdicts` — v1 and v2 records dedupe identically.
|
|
190
|
+
*
|
|
191
|
+
* Keeping the FIRST occurrence is deterministic and stable under append: the
|
|
192
|
+
* local PUSH writes a run's line before any later PULL can re-fetch it, so the
|
|
193
|
+
* first copy is the one closest to the origin; and appending more duplicates
|
|
194
|
+
* never changes which record survives. The kept set depends only on the
|
|
195
|
+
* identities present, not on the count or order of duplicates. Pure and never
|
|
196
|
+
* throws.
|
|
197
|
+
*/
|
|
198
|
+
export function dedupeRunRecords(records: RunRecord[]): RunRecord[] {
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
const out: RunRecord[] = [];
|
|
201
|
+
for (const record of records) {
|
|
202
|
+
const key = `${record.feature}\0${record.ts}`;
|
|
203
|
+
if (seen.has(key)) continue;
|
|
204
|
+
seen.add(key);
|
|
205
|
+
out.push(record);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
175
210
|
/**
|
|
176
211
|
* Read `~/.pi/zero-runs.jsonl` (or any path) into a list of valid `RunRecord`s.
|
|
177
212
|
*
|
|
178
213
|
* A missing or unreadable file yields `[]`. The file is split on `\n`; empty
|
|
179
214
|
* lines are skipped, and any line `parseRunLine` rejects (a malformed or
|
|
180
|
-
* half-written record) is dropped.
|
|
215
|
+
* half-written record) is dropped. The parsed records are then passed through
|
|
216
|
+
* `dedupeRunRecords`, so the returned array is already de-duplicated by
|
|
217
|
+
* `(feature, ts)` — the aggregation call site never sees a duplicate sample.
|
|
218
|
+
* Never throws.
|
|
181
219
|
*/
|
|
182
220
|
export function readRunRecords(path: string): RunRecord[] {
|
|
183
221
|
let contents: string;
|
|
@@ -193,7 +231,7 @@ export function readRunRecords(path: string): RunRecord[] {
|
|
|
193
231
|
const record = parseRunLine(line);
|
|
194
232
|
if (record !== null) records.push(record);
|
|
195
233
|
}
|
|
196
|
-
return records;
|
|
234
|
+
return dedupeRunRecords(records);
|
|
197
235
|
}
|
|
198
236
|
|
|
199
237
|
// ---------------------------------------------------------------------------
|
|
@@ -91,9 +91,10 @@ interface PiAPI {
|
|
|
91
91
|
): void;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/** Convert a shell argument into a single-quoted literal for
|
|
94
|
+
/** Convert a shell argument into a single-quoted literal for the host shell. */
|
|
95
95
|
export function quoteShellArg(value: string): string {
|
|
96
|
-
return `'${value.replace(/'/g, "''")}'`;
|
|
96
|
+
if (process.platform === "win32") return `'${value.replace(/'/g, "''")}'`;
|
|
97
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/** The project-local resume path. */
|
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
// changing the per-phase SDD models in `~/.pi/zero.json`. It is deterministic:
|
|
5
5
|
// no model is involved, so it does exactly what you pick, every time.
|
|
6
6
|
//
|
|
7
|
-
// /zero-models
|
|
8
|
-
// /zero-models build=claude-opus-4-7
|
|
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.
|
|
9
14
|
//
|
|
10
15
|
// The SDD orchestrator reads `~/.pi/zero.json` at the start of every `/forge`
|
|
11
16
|
// run, so a change takes effect on the next run.
|
|
@@ -23,6 +28,8 @@ export type Phase = (typeof PHASES)[number];
|
|
|
23
28
|
|
|
24
29
|
/** The per-phase model map. */
|
|
25
30
|
export type PhaseModels = Record<Phase, string>;
|
|
31
|
+
/** The per-phase provider map — parallel to {@link PhaseModels}. */
|
|
32
|
+
export type PhaseProviders = Record<Phase, string>;
|
|
26
33
|
|
|
27
34
|
/** Fallback models when `~/.pi/zero.json` has none — cheap to explore, strong
|
|
28
35
|
* to plan and review. */
|
|
@@ -33,9 +40,8 @@ const DEFAULT_MODELS: PhaseModels = {
|
|
|
33
40
|
veredicto: "claude-opus-4-7",
|
|
34
41
|
};
|
|
35
42
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
const MODEL_CHOICES = [
|
|
43
|
+
/** Model list used only when pi's model registry is unavailable. */
|
|
44
|
+
const FALLBACK_MODELS = [
|
|
39
45
|
"claude-opus-4-7",
|
|
40
46
|
"claude-opus-4-6",
|
|
41
47
|
"claude-sonnet-4-6",
|
|
@@ -74,19 +80,52 @@ export function readModels(data: Record<string, unknown>): PhaseModels {
|
|
|
74
80
|
return models;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
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 {
|
|
79
108
|
const match = arg.trim().match(/^(\w+)\s*[=\s]\s*(.+)$/);
|
|
80
109
|
if (!match) return null;
|
|
81
110
|
const phase = match[1].toLowerCase();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 };
|
|
85
120
|
}
|
|
86
121
|
|
|
87
|
-
/** Render the per-phase model map as an aligned block. */
|
|
88
|
-
export function
|
|
89
|
-
return PHASES.map((phase) =>
|
|
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");
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
/** The valid `autotune` modes a user can set. */
|
|
@@ -118,50 +157,28 @@ export function formatAutotune(mode: AutotuneMode): string {
|
|
|
118
157
|
}
|
|
119
158
|
}
|
|
120
159
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
160
|
+
/** A pi model entry — only the fields the picker needs. */
|
|
161
|
+
export interface PiModel {
|
|
162
|
+
provider: string;
|
|
163
|
+
id: string;
|
|
164
|
+
name?: string;
|
|
124
165
|
}
|
|
125
166
|
|
|
126
167
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* uses. The caller passes the keys it wants to add/override.
|
|
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.
|
|
130
170
|
*/
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
}
|
|
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);
|
|
163
179
|
}
|
|
164
|
-
|
|
180
|
+
for (const list of map.values()) list.sort();
|
|
181
|
+
return map;
|
|
165
182
|
}
|
|
166
183
|
|
|
167
184
|
/** The slice of pi's extension API this command uses. */
|
|
@@ -170,8 +187,14 @@ interface PiUI {
|
|
|
170
187
|
input(prompt: string, placeholder?: string): Promise<string | undefined>;
|
|
171
188
|
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
172
189
|
}
|
|
190
|
+
/** pi's model registry — the source of every provider's model list. */
|
|
191
|
+
interface PiModelRegistry {
|
|
192
|
+
getAll(): PiModel[];
|
|
193
|
+
getAvailable?(): PiModel[];
|
|
194
|
+
}
|
|
173
195
|
interface PiCommandContext {
|
|
174
196
|
ui: PiUI;
|
|
197
|
+
modelRegistry?: PiModelRegistry;
|
|
175
198
|
}
|
|
176
199
|
interface PiExtensionAPI {
|
|
177
200
|
registerCommand(
|
|
@@ -185,6 +208,39 @@ interface PiExtensionAPI {
|
|
|
185
208
|
|
|
186
209
|
const SAVE_AND_EXIT = "— guardar y salir —";
|
|
187
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
|
+
}
|
|
188
244
|
|
|
189
245
|
/**
|
|
190
246
|
* The pi extension entry point — registers the `/zero-models` command.
|
|
@@ -193,11 +249,14 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
193
249
|
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
194
250
|
|
|
195
251
|
pi.registerCommand("zero-models", {
|
|
196
|
-
description:
|
|
252
|
+
description:
|
|
253
|
+
"Show or set the per-phase SDD models — /zero-models [<phase>=[<provider>/]<model>]",
|
|
197
254
|
handler: async (args: string, ctx: PiCommandContext): Promise<void> => {
|
|
198
255
|
try {
|
|
199
256
|
const data = readZeroJson();
|
|
200
257
|
const models = readModels(data);
|
|
258
|
+
const providers = readProviders(data);
|
|
259
|
+
const groups = providerGroups(ctx.modelRegistry);
|
|
201
260
|
|
|
202
261
|
// Direct form: /zero-models build=claude-opus-4-7
|
|
203
262
|
const arg = args.trim();
|
|
@@ -213,7 +272,11 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
213
272
|
);
|
|
214
273
|
return;
|
|
215
274
|
}
|
|
216
|
-
|
|
275
|
+
writeFileSync(
|
|
276
|
+
zeroJsonPath(),
|
|
277
|
+
`${JSON.stringify({ ...data, autotune: mode }, null, 2)}\n`,
|
|
278
|
+
"utf8",
|
|
279
|
+
);
|
|
217
280
|
ctx.ui.notify(`zero autotune: ${formatAutotune(mode)}`, "info");
|
|
218
281
|
return;
|
|
219
282
|
}
|
|
@@ -221,7 +284,7 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
221
284
|
const assignment = parseAssignment(arg);
|
|
222
285
|
if (!assignment) {
|
|
223
286
|
ctx.ui.notify(
|
|
224
|
-
"uso: /zero-models —o— /zero-models <fase
|
|
287
|
+
"uso: /zero-models —o— /zero-models <fase>=[<provider>/]<modelo> " +
|
|
225
288
|
"(fase: explore | plan | build | veredicto) —o— " +
|
|
226
289
|
"/zero-models autotune=<modo>",
|
|
227
290
|
"warning",
|
|
@@ -229,12 +292,23 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
229
292
|
return;
|
|
230
293
|
}
|
|
231
294
|
models[assignment.phase] = assignment.model;
|
|
232
|
-
|
|
233
|
-
|
|
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");
|
|
234
308
|
return;
|
|
235
309
|
}
|
|
236
310
|
|
|
237
|
-
// Interactive form: pick a phase,
|
|
311
|
+
// Interactive form: pick a phase, a provider, a model — until saved.
|
|
238
312
|
let changed = false;
|
|
239
313
|
let autotuneMode = readAutotuneMode(data);
|
|
240
314
|
let autotuneChanged = false;
|
|
@@ -251,7 +325,9 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
251
325
|
|
|
252
326
|
const phasePick = await ctx.ui.select("zero · modelos SDD — elegí una fase", [
|
|
253
327
|
...(applyEntry ? [applyEntry] : []),
|
|
254
|
-
...PHASES.map(
|
|
328
|
+
...PHASES.map(
|
|
329
|
+
(p) => `${p} → ${providers[p] ? `${providers[p]}/` : ""}${models[p]}`,
|
|
330
|
+
),
|
|
255
331
|
autotuneEntry,
|
|
256
332
|
SAVE_AND_EXIT,
|
|
257
333
|
]);
|
|
@@ -284,40 +360,62 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
284
360
|
const phase = phasePick.split(/\s/)[0];
|
|
285
361
|
if (!isPhase(phase)) break;
|
|
286
362
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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]);
|
|
291
389
|
if (!modelPick) continue;
|
|
292
390
|
|
|
293
391
|
let model = modelPick;
|
|
294
392
|
if (modelPick === CUSTOM_MODEL) {
|
|
295
|
-
const typed = await ctx.ui.input(
|
|
393
|
+
const typed = await ctx.ui.input(label, models[phase]);
|
|
296
394
|
if (!typed || typed.trim() === "") continue;
|
|
297
395
|
model = typed.trim();
|
|
298
396
|
}
|
|
299
397
|
models[phase] = model;
|
|
398
|
+
providers[phase] = provider || resolveProvider(ctx.modelRegistry, model) || "";
|
|
300
399
|
changed = true;
|
|
301
400
|
}
|
|
302
401
|
|
|
303
402
|
if (changed || autotuneChanged) {
|
|
304
403
|
// Build the patch, preserving every other key via the spread. When
|
|
305
404
|
// the pending suggestion was applied, clear the `autotunePending` key.
|
|
306
|
-
const patch: Record<string, unknown> = { models };
|
|
405
|
+
const patch: Record<string, unknown> = { models, providers };
|
|
307
406
|
if (autotuneChanged) patch.autotune = autotuneMode;
|
|
308
|
-
if (pendingApplied) patch.autotunePending = undefined;
|
|
309
407
|
|
|
310
408
|
const merged = { ...data, ...patch };
|
|
311
409
|
if (pendingApplied) delete merged.autotunePending;
|
|
312
410
|
writeFileSync(zeroJsonPath(), `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
313
411
|
|
|
314
|
-
const summary = [`zero · modelos SDD guardados:\n${
|
|
412
|
+
const summary = [`zero · modelos SDD guardados:\n${formatPhases(models, providers)}`];
|
|
315
413
|
summary.push(` autotune ${autotuneMode}`);
|
|
316
414
|
if (pendingApplied) summary.push("sugerencia aplicada");
|
|
317
415
|
ctx.ui.notify(summary.join("\n"), "info");
|
|
318
416
|
} else {
|
|
319
417
|
ctx.ui.notify(
|
|
320
|
-
`zero · modelos SDD (sin cambios):\n${
|
|
418
|
+
`zero · modelos SDD (sin cambios):\n${formatPhases(models, providers)}\n` +
|
|
321
419
|
` autotune ${autotuneMode}`,
|
|
322
420
|
"info",
|
|
323
421
|
);
|
|
@@ -331,3 +429,35 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
331
429
|
},
|
|
332
430
|
});
|
|
333
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow, skill auto-learning, and an animated ZERO startup banner. Adds capability to pi without modifying pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/prompts/orchestrator.md
CHANGED
|
@@ -105,10 +105,12 @@ count — the sub-agents only carry out their own phase.
|
|
|
105
105
|
|
|
106
106
|
## Model configuration
|
|
107
107
|
|
|
108
|
-
The per-phase model assignments live in `~/.pi/zero.json
|
|
109
|
-
(
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
The per-phase model assignments live in `~/.pi/zero.json`: `models` maps each
|
|
109
|
+
phase (`explore`, `plan`, `build`, `veredicto`) to a model id, and the parallel
|
|
110
|
+
`providers` map gives the provider that model belongs to. Read that file at the
|
|
111
|
+
start of a run and delegate each phase's sub-agent to its configured
|
|
112
|
+
provider + model. When the file is absent, a phase is missing, or its provider
|
|
113
|
+
entry is empty, fall back to the session's default model.
|
|
112
114
|
|
|
113
115
|
## Execution mode
|
|
114
116
|
|
|
@@ -160,8 +162,31 @@ the iteration cap reached — save one run-trace memory with `memoria_save`:
|
|
|
160
162
|
|
|
161
163
|
Use the project name Cortex derives from the working directory.
|
|
162
164
|
|
|
165
|
+
**Pull run metrics — at run start, alongside recall.** When `/forge` starts,
|
|
166
|
+
together with the recall above, pull the shared run-metrics log from Cortex into
|
|
167
|
+
the local `~/.pi/zero-runs.jsonl` so the autotune sees runs from other machines:
|
|
168
|
+
|
|
169
|
+
- Query Cortex with `memoria_search` / `memoria_recent` over the **fixed**
|
|
170
|
+
`zero-metrics` project namespace, filtering `type:"metric"`. Bound the result
|
|
171
|
+
to the **200 most-recent records** — if Cortex cannot apply an exact limit,
|
|
172
|
+
fetch the newest batch and truncate to 200.
|
|
173
|
+
- For each record, extract the `what` field — the verbatim `RunRecord` JSON
|
|
174
|
+
line. Do not re-serialize or reformat it; use the string as-is.
|
|
175
|
+
- **Naive append:** append each extracted line, followed by a single `\n`, to
|
|
176
|
+
`~/.pi/zero-runs.jsonl`. Create the file if absent. Never rewrite, reorder, or
|
|
177
|
+
delete existing lines, and do not compare against what is already in the file
|
|
178
|
+
— de-duplication is handled deterministically by the reader (`dedupeRunRecords`
|
|
179
|
+
in `autotune.ts`), so appending an already-present record is safe.
|
|
180
|
+
- If the pull finds no records, leave the file unchanged and continue.
|
|
181
|
+
|
|
182
|
+
**One-session lag (intended).** This pull runs at `/forge` start; the autotune
|
|
183
|
+
extension reads `~/.pi/zero-runs.jsonl` at the *next* `session_start`. So a
|
|
184
|
+
record pulled now influences tuning one session later — consistent with the
|
|
185
|
+
autotune's existing one-run lag.
|
|
186
|
+
|
|
163
187
|
If Cortex is unavailable — installed with `--no-mcp`, or the server is down —
|
|
164
|
-
skip recall and persist silently. The memory loop must never
|
|
188
|
+
skip recall, the metrics pull, and persist silently. The memory loop must never
|
|
189
|
+
block a run.
|
|
165
190
|
|
|
166
191
|
## Run metrics
|
|
167
192
|
|
|
@@ -207,6 +232,27 @@ Rules:
|
|
|
207
232
|
ever produced a verdict, write nothing — only a `pasa` or `cap-reached` run
|
|
208
233
|
is recorded.
|
|
209
234
|
|
|
235
|
+
**Push to Cortex.** After appending the local line, also save the same
|
|
236
|
+
`RunRecord` to Cortex so other machines' autotune can pull it. This is separate
|
|
237
|
+
from — and additional to — the `session_summary` save in "## Run memory"; do
|
|
238
|
+
both, and it does not rewrite the local line. Call `memoria_save` with:
|
|
239
|
+
|
|
240
|
+
- `project`: `"zero-metrics"` — the fixed, dedicated namespace. NOT the
|
|
241
|
+
cwd-derived project.
|
|
242
|
+
- `type`: `"metric"`.
|
|
243
|
+
- `topic_key`: `zero-metric/<feature>/<ts>` — unique per run record, so two
|
|
244
|
+
distinct runs never upsert over each other.
|
|
245
|
+
- `title`: `zero metric — <feature> @ <ts>`.
|
|
246
|
+
- `what`: the `RunRecord` JSON line **verbatim** — the exact same one-line
|
|
247
|
+
string just written to `~/.pi/zero-runs.jsonl`, not reformatted or
|
|
248
|
+
pretty-printed.
|
|
249
|
+
- `why`: `"zero run metrics — synced for cross-machine autotune"`.
|
|
250
|
+
|
|
251
|
+
No verdict → no local line and no push (consistent with "No record without a
|
|
252
|
+
verdict" above). If the push fails for any reason, or zero runs with `--no-mcp`
|
|
253
|
+
or Cortex is down — emit a non-blocking warning and continue, never block the
|
|
254
|
+
run. The local line already stands.
|
|
255
|
+
|
|
210
256
|
## Spec sync & archive
|
|
211
257
|
|
|
212
258
|
The project keeps a **canonical spec store** at `.sdd/specs/requirements.md` —
|