@gonrocca/zero-pi 0.1.9 → 0.1.10

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
@@ -134,14 +134,24 @@ re-tune `~/.pi/zero.json` for you.
134
134
 
135
135
  **The metrics log — `~/.pi/zero-runs.jsonl`.** Every completed SDD run appends
136
136
  one JSON line to this file: the feature slug, the per-phase models the run used,
137
- the final verdict, and the build/veredicto round count. It is append-only and
138
- never rewritten. This local log is the only thing zero learns from — a run
139
- abandoned before it reaches a verdict adds no line.
137
+ the final verdict, the build/veredicto round count, and the ordered per-round
138
+ verdict sequence. It is append-only and never rewritten. This local log is the
139
+ only thing zero learns from — a run abandoned before it reaches a verdict adds
140
+ no line.
141
+
142
+ **Phase attribution (v2).** The per-round verdict sequence makes blame precise:
143
+ a `corregir` round re-runs — and so blames — the `build` phase, and a
144
+ `replantear` round blames the `plan` phase. Autotune aggregates that sequence
145
+ per phase and upgrades **only the phase at fault**, one tier at a time — a
146
+ `build` problem no longer drags `plan` up with it. The `explore` and `veredicto`
147
+ phases are never tuned.
140
148
 
141
149
  **Autotune modes.** At each pi session start zero aggregates the log and, once a
142
150
  phase has accumulated enough run data to cross a confidence threshold, decides
143
- whether that phase's model should change. The `autotune` mode in `~/.pi/zero.json`
144
- controls what happens next:
151
+ whether that phase's model should change. Until a phase has accumulated enough
152
+ v2 runs of its own, autotune deliberately stays quiet — a one-time, silent
153
+ cold-start after upgrading, not a regression. The `autotune` mode in
154
+ `~/.pi/zero.json` controls what happens next:
145
155
 
146
156
  | Mode | Behaviour |
147
157
  | ---- | --------- |
@@ -12,8 +12,9 @@
12
12
  import { readFileSync } from "node:fs";
13
13
 
14
14
  /** Schema version of one `~/.pi/zero-runs.jsonl` record. A record carrying any
15
- * other `v` is dropped by `parseRunLine` rather than mis-aggregated. */
16
- export const RUN_SCHEMA_VERSION = 1;
15
+ * other `v` is dropped by `parseRunLine` rather than mis-aggregated. v2 adds
16
+ * the per-round `verdicts` sequence; `parseRunLine` still accepts v1 records. */
17
+ export const RUN_SCHEMA_VERSION = 2;
17
18
 
18
19
  /** The SDD phases a run record carries a model for, in pipeline order. */
19
20
  const RECORD_PHASES = ["explore", "plan", "build", "veredicto"] as const;
@@ -25,6 +26,14 @@ const VERDICTS = ["pasa", "cap-reached"] as const;
25
26
  /** A run's terminal verdict. */
26
27
  export type RunVerdict = (typeof VERDICTS)[number];
27
28
 
29
+ /** Per-round verdicts the `veredicto` phase can return. `corregir` re-runs
30
+ * build, `replantear` re-runs plan, `pasa` ends the run. `cap-reached` is a
31
+ * run-level terminal state and is deliberately *not* a round verdict. */
32
+ const ROUND_VERDICTS = ["corregir", "replantear", "pasa"] as const;
33
+
34
+ /** A single round's verdict from the `veredicto` phase. */
35
+ export type RunRoundVerdict = (typeof ROUND_VERDICTS)[number];
36
+
28
37
  /** The model a single phase ran on for a given run. An object (not a bare
29
38
  * string) so the schema can grow additive fields without a version bump. */
30
39
  export interface PhaseRun {
@@ -48,6 +57,11 @@ export interface RunRecord {
48
57
  verdict: RunVerdict;
49
58
  /** Count of build/veredicto rounds — `1` for a clean first-pass run. */
50
59
  rounds: number;
60
+ /** The chronological per-round verdict sequence. Present on `v:2` records
61
+ * (`verdicts.length === rounds`); `undefined` on `v:1` records, which carry
62
+ * only the run-level verdict and so cannot be phase-attributed. Its
63
+ * presence is the single discriminator `aggregate` uses for v2 attribution. */
64
+ verdicts?: RunRoundVerdict[];
51
65
  }
52
66
 
53
67
  /** How aggressively zero applies a learned profile — stored in `zero.json`.
@@ -60,15 +74,56 @@ function isObject(value: unknown): value is Record<string, unknown> {
60
74
  return typeof value === "object" && value !== null && !Array.isArray(value);
61
75
  }
62
76
 
77
+ /**
78
+ * Validate a parsed `verdicts` value against the v2 invariants.
79
+ *
80
+ * Returns the typed array when the value is a non-empty `RunRoundVerdict[]`
81
+ * with `length === rounds` and a sequence consistent with the run-level
82
+ * `verdict`: a `pasa` run ends with exactly one `pasa` (last entry), a
83
+ * `cap-reached` run contains no `pasa`. Returns `null` for any violation —
84
+ * the caller drops the whole record rather than mis-attribute.
85
+ */
86
+ function validateVerdicts(
87
+ value: unknown,
88
+ verdict: RunVerdict,
89
+ rounds: number,
90
+ ): RunRoundVerdict[] | null {
91
+ if (!Array.isArray(value)) return null;
92
+ if (value.length < 1) return null;
93
+ if (value.length !== rounds) return null;
94
+
95
+ const verdicts: RunRoundVerdict[] = [];
96
+ for (const entry of value) {
97
+ if (typeof entry !== "string") return null;
98
+ if (!(ROUND_VERDICTS as readonly string[]).includes(entry)) return null;
99
+ verdicts.push(entry as RunRoundVerdict);
100
+ }
101
+
102
+ const passCount = verdicts.filter((e) => e === "pasa").length;
103
+ if (verdict === "pasa") {
104
+ // Exactly one `pasa`, and it is the final entry.
105
+ if (passCount !== 1) return null;
106
+ if (verdicts[verdicts.length - 1] !== "pasa") return null;
107
+ } else {
108
+ // `cap-reached`: the run never passed.
109
+ if (passCount !== 0) return null;
110
+ }
111
+
112
+ return verdicts;
113
+ }
114
+
63
115
  /**
64
116
  * Parse one JSONL line into a `RunRecord`, validating the shape.
65
117
  *
66
- * Returns `null` never throws for anything off-shape: invalid JSON, a
67
- * non-object, an unexpected `v`, a missing/non-string `feature` or `ts`, a
68
- * non-integer `rounds`, a `verdict` outside the enum, or a `phases` map
69
- * missing any of the four phases or carrying a non-string model. This is the
70
- * deliberate defense against malformed LLM-emitted records: a bad emission
71
- * degrades to "one missing sample", never a crash.
118
+ * Accepts both `v:1` records (no `verdicts`) and `v:2` records (a fully
119
+ * validated per-round `verdicts` sequence). Returns `null` never throws —
120
+ * for anything off-shape: invalid JSON, a non-object, a `v` outside
121
+ * `{1, 2}`, a missing/non-string `feature` or `ts`, a non-integer `rounds`, a
122
+ * `verdict` outside the enum, a `phases` map missing any of the four phases
123
+ * or carrying a non-string model, or — for `v:2` — a missing/non-array/
124
+ * inconsistent `verdicts`. This is the deliberate defense against malformed
125
+ * LLM-emitted records: a bad emission degrades to "one missing sample",
126
+ * never a crash.
72
127
  */
73
128
  export function parseRunLine(line: string): RunRecord | null {
74
129
  let parsed: unknown;
@@ -80,12 +135,13 @@ export function parseRunLine(line: string): RunRecord | null {
80
135
 
81
136
  if (!isObject(parsed)) return null;
82
137
 
83
- if (parsed.v !== RUN_SCHEMA_VERSION) return null;
138
+ if (parsed.v !== 1 && parsed.v !== 2) return null;
84
139
  if (typeof parsed.ts !== "string" || parsed.ts === "") return null;
85
140
  if (typeof parsed.feature !== "string" || parsed.feature === "") return null;
86
141
  if (typeof parsed.rounds !== "number" || !Number.isInteger(parsed.rounds)) return null;
87
142
  if (typeof parsed.verdict !== "string") return null;
88
143
  if (!(VERDICTS as readonly string[]).includes(parsed.verdict)) return null;
144
+ const verdict = parsed.verdict as RunVerdict;
89
145
 
90
146
  if (!isObject(parsed.phases)) return null;
91
147
  const phases = {} as RunRecord["phases"];
@@ -96,13 +152,23 @@ export function parseRunLine(line: string): RunRecord | null {
96
152
  phases[phase] = { model: phaseRun.model };
97
153
  }
98
154
 
155
+ // v2 records must carry a fully consistent `verdicts` sequence; v1 records
156
+ // legitimately lack the field (the discriminator is `v`, not its presence).
157
+ let verdicts: RunRoundVerdict[] | undefined;
158
+ if (parsed.v === 2) {
159
+ const validated = validateVerdicts(parsed.verdicts, verdict, parsed.rounds);
160
+ if (validated === null) return null;
161
+ verdicts = validated;
162
+ }
163
+
99
164
  return {
100
- v: RUN_SCHEMA_VERSION,
165
+ v: parsed.v,
101
166
  ts: parsed.ts,
102
167
  feature: parsed.feature,
103
168
  phases,
104
- verdict: parsed.verdict as RunVerdict,
169
+ verdict,
105
170
  rounds: parsed.rounds,
171
+ ...(verdicts !== undefined ? { verdicts } : {}),
106
172
  };
107
173
  }
108
174
 
@@ -149,6 +215,16 @@ export interface PhaseModelStat {
149
215
  /** Mean `rounds` averaged ONLY over runs that reached `pasa`; `null` when no
150
216
  * `pasa` run exists for the pair. */
151
217
  avgRounds: number | null;
218
+ // --- v2 phase attribution ---
219
+ /** Count of `v:2` records contributing to this pair — the dormancy-gate
220
+ * denominator. v1 records do not increment this. */
221
+ v2Samples: number;
222
+ /** Mean count of `corregir` verdicts per v2 run for this pair — only
223
+ * meaningful for `phase === "build"`. `null` when `v2Samples === 0`. */
224
+ avgCorregir: number | null;
225
+ /** Mean count of `replantear` verdicts per v2 run for this pair — only
226
+ * meaningful for `phase === "plan"`. `null` when `v2Samples === 0`. */
227
+ avgReplantear: number | null;
152
228
  }
153
229
 
154
230
  /** Map key for a `(phase, model)` bucket. */
@@ -159,12 +235,19 @@ function statKey(phase: string, model: string): string {
159
235
  /**
160
236
  * Aggregate run records into per-`(phase, model)` statistics.
161
237
  *
162
- * For every record, each of the four phases contributes one sample to the
163
- * bucket of the model that phase ran on (a phase whose model is missing or
164
- * non-string is skipped). `avgRounds` is averaged exclusively over runs that
165
- * reached `pasa`; non-positive `rounds` values (which `parseRunLine` does not
166
- * reject) are ignored when computing that average so a bad `0`/negative entry
167
- * never skews or breaks the math. An empty input yields an empty map.
238
+ * For every record (v1 and v2 alike), each of the four phases contributes one
239
+ * sample to the bucket of the model that phase ran on (a phase whose model is
240
+ * missing or non-string is skipped). `avgRounds` is averaged exclusively over
241
+ * runs that reached `pasa`; non-positive `rounds` values (which `parseRunLine`
242
+ * does not reject) are ignored when computing that average so a bad
243
+ * `0`/negative entry never skews or breaks the math.
244
+ *
245
+ * Only records carrying a `verdicts` sequence (v2) contribute phase
246
+ * attribution: each such record increments `v2Samples` for every phase, and
247
+ * folds its per-run `corregir` count into the `build` model's `avgCorregir`
248
+ * accumulator and its per-run `replantear` count into the `plan` model's
249
+ * `avgReplantear` accumulator. `avgCorregir`/`avgReplantear` are `null` when a
250
+ * pair has no v2 evidence. An empty input yields an empty map.
168
251
  */
169
252
  export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
170
253
  interface Acc {
@@ -172,11 +255,22 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
172
255
  passes: number;
173
256
  passRounds: number;
174
257
  passRoundCount: number;
258
+ v2Samples: number;
259
+ corregirTotal: number;
260
+ replantearTotal: number;
175
261
  }
176
262
  const acc = new Map<string, { phase: (typeof RECORD_PHASES)[number]; model: string; data: Acc }>();
177
263
 
178
264
  for (const record of records) {
179
265
  const isPass = record.verdict === "pasa";
266
+ const isV2 = record.verdicts !== undefined;
267
+ const corregirCount = isV2
268
+ ? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "corregir").length
269
+ : 0;
270
+ const replantearCount = isV2
271
+ ? (record.verdicts as RunRoundVerdict[]).filter((v) => v === "replantear").length
272
+ : 0;
273
+
180
274
  for (const phase of RECORD_PHASES) {
181
275
  const phaseRun = record.phases[phase];
182
276
  const model = phaseRun?.model;
@@ -185,7 +279,19 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
185
279
  const key = statKey(phase, model);
186
280
  let bucket = acc.get(key);
187
281
  if (bucket === undefined) {
188
- bucket = { phase, model, data: { samples: 0, passes: 0, passRounds: 0, passRoundCount: 0 } };
282
+ bucket = {
283
+ phase,
284
+ model,
285
+ data: {
286
+ samples: 0,
287
+ passes: 0,
288
+ passRounds: 0,
289
+ passRoundCount: 0,
290
+ v2Samples: 0,
291
+ corregirTotal: 0,
292
+ replantearTotal: 0,
293
+ },
294
+ };
189
295
  acc.set(key, bucket);
190
296
  }
191
297
  bucket.data.samples += 1;
@@ -198,18 +304,29 @@ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
198
304
  bucket.data.passRoundCount += 1;
199
305
  }
200
306
  }
307
+ if (isV2) {
308
+ bucket.data.v2Samples += 1;
309
+ // `corregir` blames the build phase's model, `replantear` the plan
310
+ // phase's. The mapping is the pipeline contract, fixed and known.
311
+ if (phase === "build") bucket.data.corregirTotal += corregirCount;
312
+ if (phase === "plan") bucket.data.replantearTotal += replantearCount;
313
+ }
201
314
  }
202
315
  }
203
316
 
204
317
  const stats = new Map<string, PhaseModelStat>();
205
318
  for (const [key, bucket] of acc) {
206
- const { samples, passes, passRounds, passRoundCount } = bucket.data;
319
+ const { samples, passes, passRounds, passRoundCount, v2Samples, corregirTotal, replantearTotal } =
320
+ bucket.data;
207
321
  stats.set(key, {
208
322
  phase: bucket.phase,
209
323
  model: bucket.model,
210
324
  samples,
211
325
  passRate: samples > 0 ? passes / samples : 0,
212
326
  avgRounds: passRoundCount > 0 ? passRounds / passRoundCount : null,
327
+ v2Samples,
328
+ avgCorregir: v2Samples > 0 ? corregirTotal / v2Samples : null,
329
+ avgReplantear: v2Samples > 0 ? replantearTotal / v2Samples : null,
213
330
  });
214
331
  }
215
332
  return stats;
@@ -292,6 +409,17 @@ export const HIGH_AVG_ROUNDS = 2.5;
292
409
  /** Pass-rate at or over this value marks a phase as reliable — stay put. */
293
410
  export const RELIABLE_PASS_RATE = 0.85;
294
411
 
412
+ /** Below this many `v:2` records a phase is left dormant — too little
413
+ * attributed evidence to act on (Story 7). Reuses v1's `MIN_SAMPLES` value. */
414
+ export const MIN_V2_SAMPLES = 5;
415
+
416
+ /** Mean `corregir`/run strictly above this value blames the `build` model. */
417
+ export const HIGH_AVG_CORREGIR = 1.0;
418
+
419
+ /** Mean `replantear`/run strictly above this value blames the `plan` model.
420
+ * Lower than `HIGH_AVG_CORREGIR` because a `replantear` is rarer and costlier. */
421
+ export const HIGH_AVG_REPLANTEAR = 0.5;
422
+
295
423
  /**
296
424
  * A proposed change of model for one SDD phase. `reason` is a human-readable
297
425
  * string used verbatim in notifications.
@@ -307,22 +435,33 @@ export interface Adjustment {
307
435
  reason: string;
308
436
  }
309
437
 
438
+ /** The phases v2 autotune can attribute blame to and adjust. `explore` and
439
+ * `veredicto` are structurally excluded — no round verdict ever blames them. */
440
+ const ATTRIBUTABLE_PHASES = ["build", "plan"] as const;
441
+
310
442
  /**
311
- * Decide model adjustments from aggregated statistics.
443
+ * Decide model adjustments from aggregated statistics — v2 phase-attributed.
312
444
  *
313
- * For each phase, looks up the `(phase, currentModel)` stat and applies the
314
- * adjustment rules:
315
- * - absent stat or `samples < MIN_SAMPLES` no decision (too little data);
316
- * - under-performing (`passRate <= LOW_PASS_RATE`, or a `pasa`-only
317
- * `avgRounds > HIGH_AVG_ROUNDS`) → propose one tier up via `stepUp`;
318
- * - reliable (`passRate >= RELIABLE_PASS_RATE` and `avgRounds` not high)
319
- * keep the model;
320
- * - otherwise (middling) no change.
445
+ * Only the `build` and `plan` phases are considered: a `corregir` verdict
446
+ * re-runs build and a `replantear` verdict re-runs plan, so those are the only
447
+ * phases the verdict sequence can blame. `explore` and `veredicto` are
448
+ * structurally never iterated and so never adjusted (Story 6).
449
+ *
450
+ * For each attributable phase, looks up the `(phase, currentModel)` stat and:
451
+ * - skips an absent stat;
452
+ * - skips a phase with `v2Samples < MIN_V2_SAMPLES` — too little attributed
453
+ * evidence (the dormancy gate, Story 7);
454
+ * - reads the phase's blame measure (`build` → `avgCorregir` vs
455
+ * `HIGH_AVG_CORREGIR`, `plan` → `avgReplantear` vs `HIGH_AVG_REPLANTEAR`)
456
+ * and skips when it is `null` or not strictly above its threshold;
457
+ * - otherwise proposes one tier up via `stepUp`.
321
458
  *
322
459
  * Safety caps are baked in: a proposal is emitted only when `stepUp` returns a
323
460
  * model (so a phase already at `opus` or on an untierable model is left
324
461
  * alone), `stepUp` never jumps more than one tier, and it never returns a
325
- * model outside `knownModels`-or-the-fixed-representative set.
462
+ * model outside `knownModels`-or-the-fixed-representative set. v1's
463
+ * `LOW_PASS_RATE`/`HIGH_AVG_ROUNDS`/`RELIABLE_PASS_RATE` constants remain
464
+ * exported but no longer drive this function.
326
465
  */
327
466
  export function decideAdjustments(
328
467
  stats: Map<string, PhaseModelStat>,
@@ -331,29 +470,25 @@ export function decideAdjustments(
331
470
  ): Adjustment[] {
332
471
  const adjustments: Adjustment[] = [];
333
472
 
334
- for (const phase of RECORD_PHASES) {
473
+ for (const phase of ATTRIBUTABLE_PHASES) {
335
474
  const currentModel = currentModels[phase];
336
475
  if (typeof currentModel !== "string" || currentModel === "") continue;
337
476
 
338
477
  const stat = stats.get(statKey(phase, currentModel));
339
- if (stat === undefined || stat.samples < MIN_SAMPLES) continue;
478
+ if (stat === undefined) continue;
340
479
 
341
- const highRounds = stat.avgRounds !== null && stat.avgRounds > HIGH_AVG_ROUNDS;
342
- const underPerforming = stat.passRate <= LOW_PASS_RATE || highRounds;
343
- const reliable =
344
- stat.passRate >= RELIABLE_PASS_RATE &&
345
- (stat.avgRounds === null || stat.avgRounds <= HIGH_AVG_ROUNDS);
480
+ // Dormancy gate: too few v2 records to attribute blame.
481
+ if (stat.v2Samples < MIN_V2_SAMPLES) continue;
346
482
 
347
- if (reliable) continue;
348
- if (!underPerforming) continue;
483
+ const measure = phase === "build" ? stat.avgCorregir : stat.avgReplantear;
484
+ const threshold = phase === "build" ? HIGH_AVG_CORREGIR : HIGH_AVG_REPLANTEAR;
485
+ if (measure === null || !(measure > threshold)) continue;
349
486
 
350
487
  const to = stepUp(currentModel, knownModels);
351
488
  if (to === null) continue; // already at top tier, or untierable
352
489
 
353
- const reason =
354
- stat.passRate <= LOW_PASS_RATE
355
- ? `pass-rate ${stat.passRate.toFixed(2)} over ${stat.samples} runs`
356
- : `avg ${(stat.avgRounds as number).toFixed(1)} rounds-to-pass over ${stat.samples} runs`;
490
+ const blame = phase === "build" ? "corregir" : "replantear";
491
+ const reason = `avg ${measure.toFixed(1)} ${blame}/run over ${stat.v2Samples} v2 runs`;
357
492
 
358
493
  adjustments.push({ phase, from: currentModel, to, reason });
359
494
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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": [
@@ -173,7 +173,7 @@ memory" Cortex save above; do both.
173
173
  The line is one `RunRecord` JSON object, serialized with no pretty-printing,
174
174
  followed by a single newline. Build it from facts you already hold:
175
175
 
176
- - `v`: the schema version — always the integer `1`.
176
+ - `v`: the schema version — always the integer `2`.
177
177
  - `ts`: the run-end timestamp, ISO 8601 (e.g. `2026-05-17T14:03:22.000Z`).
178
178
  - `feature`: the SDD feature slug for this run.
179
179
  - `phases`: an object with the four keys `explore`, `plan`, `build`,
@@ -183,11 +183,18 @@ followed by a single newline. Build it from facts you already hold:
183
183
  if the iteration cap was hit without one. No other values.
184
184
  - `rounds`: the number of build/veredicto rounds (`1` for a clean first-pass
185
185
  run).
186
+ - `verdicts`: the ordered per-round verdict sequence — one entry per round, in
187
+ chronological order, accumulated as `veredicto` returns each round's verdict.
188
+ Every entry is one of `"corregir"`, `"replantear"`, or `"pasa"`;
189
+ `"cap-reached"` is a run-level terminal state and never appears inside this
190
+ array. `verdicts.length` must equal `rounds` (one verdict per round,
191
+ including the final cap-reaching round). A `pasa` run ends with exactly one
192
+ `"pasa"`, as the last entry; a `cap-reached` run contains no `"pasa"` at all.
186
193
 
187
194
  Exact one-line shape to emit:
188
195
 
189
196
  ```json
190
- {"v":1,"ts":"2026-05-17T14:03:22.000Z","feature":"adaptive-model-profiles","phases":{"explore":{"model":"claude-haiku-4-5"},"plan":{"model":"claude-opus-4-7"},"build":{"model":"claude-sonnet-4-6"},"veredicto":{"model":"claude-opus-4-7"}},"verdict":"pasa","rounds":2}
197
+ {"v":2,"ts":"2026-05-17T14:03:22.000Z","feature":"adaptive-model-profiles","phases":{"explore":{"model":"claude-haiku-4-5"},"plan":{"model":"claude-opus-4-7"},"build":{"model":"claude-sonnet-4-6"},"veredicto":{"model":"claude-opus-4-7"}},"verdict":"pasa","rounds":2,"verdicts":["corregir","pasa"]}
191
198
  ```
192
199
 
193
200
  Rules: