@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 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 to pick a phase and a model
69
- interactively, or set one directly:
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 # interactive picker
73
- /zero-models build=claude-opus-4-7 # set one phase
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
- It reads and writes `~/.pi/zero.json`; the orchestrator picks the change up on
77
- the next `/forge` run.
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
- Per-phase model assignments are read from `~/.pi/zero.json`, which the `zero`
80
- CLI writes when it installs this layer.
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
@@ -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. Never throws.
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 PowerShell/sh. */
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 interactive — pick a phase, pick a model
8
- // /zero-models build=claude-opus-4-7 set one phase directly
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
- /** 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 = [
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
- /** Parse a `<phase>=<model>` (or `<phase> <model>`) assignment. */
78
- export function parseAssignment(arg: string): { phase: Phase; model: string } | null {
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
- const model = match[2].trim();
83
- if (!isPhase(phase) || model === "") return null;
84
- return { phase, model };
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 formatModels(models: PhaseModels): string {
89
- return PHASES.map((phase) => ` ${phase.padEnd(10)} ${models[phase]}`).join("\n");
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
- /** 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");
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
- * 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.
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 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
- }
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
- return pending;
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: "Show or set the per-phase SDD models — /zero-models [<phase>=<model>]",
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
- writeZeroJson(data, { autotune: mode });
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>=<modelo> " +
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
- writeModels(data, models);
233
- ctx.ui.notify(`zero models: ${assignment.phase} → ${assignment.model}`, "info");
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, pick a model, repeat until saved.
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((p) => `${p} → ${models[p]}`),
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
- const modelPick = await ctx.ui.select(`Modelo para «${phase}»`, [
288
- ...MODEL_CHOICES,
289
- CUSTOM_MODEL,
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(`Modelo para «${phase}»`, models[phase]);
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${formatModels(models)}`];
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${formatModels(models)}\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.11",
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": [
@@ -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` under `models`
109
- (keys: `explore`, `plan`, `build`, `veredicto`). Read that file at the start of
110
- a run and delegate each phase's sub-agent to the model listed there. When the
111
- file is absent or a phase is missing, fall back to the session's default model.
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 block a run.
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` —
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: sdd-routing
2
3
  description: Route a natural-language request into the zero SDD pipeline when the user signals SDD intent
3
4
  ---
4
5
 
@@ -1,4 +1,5 @@
1
1
  ---
2
+ name: skill-loop
2
3
  description: zero's skill auto-learning loop — distill, store, surface, and refine reusable skills
3
4
  ---
4
5