@gonrocca/zero-pi 0.1.4 → 0.1.5

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
@@ -70,6 +70,45 @@ correction rounds, and the gotchas — under `topic_key: zero-run/<slug>`. The
70
70
  next run on related work starts from what the last one learned. With `--no-mcp`
71
71
  the loop degrades silently.
72
72
 
73
+ ### Adaptive model profiles
74
+
75
+ zero learns which model fits each SDD phase from your own run history and can
76
+ re-tune `~/.pi/zero.json` for you.
77
+
78
+ **The metrics log — `~/.pi/zero-runs.jsonl`.** Every completed SDD run appends
79
+ one JSON line to this file: the feature slug, the per-phase models the run used,
80
+ the final verdict, and the build/veredicto round count. It is append-only and
81
+ never rewritten. This local log is the only thing zero learns from — a run
82
+ abandoned before it reaches a verdict adds no line.
83
+
84
+ **Autotune modes.** At each pi session start zero aggregates the log and, once a
85
+ phase has accumulated enough run data to cross a confidence threshold, decides
86
+ whether that phase's model should change. The `autotune` mode in `~/.pi/zero.json`
87
+ controls what happens next:
88
+
89
+ | Mode | Behaviour |
90
+ | ---- | --------- |
91
+ | `auto` _(default)_ | zero applies the adjustment to `~/.pi/zero.json` and notifies you exactly what changed. |
92
+ | `ask` | zero records the recommendation but changes nothing — you apply it from `/zero-models`. |
93
+ | `off` | zero still records run metrics, but never changes or recommends anything. |
94
+
95
+ A change always takes effect on the *next* `/forge` run, and every applied
96
+ change is announced — autotune is never silent.
97
+
98
+ **Setting the mode.** Set it directly, or pick it from the interactive
99
+ `/zero-models` menu (which shows the current mode as its own entry):
100
+
101
+ ```
102
+ /zero-models autotune=ask # auto | ask | off
103
+ ```
104
+
105
+ **Applying a pending suggestion.** In `ask` mode, when a recommendation is
106
+ waiting, running `/zero-models` shows a leading `★ aplicar sugerencia` entry;
107
+ selecting it applies the change and clears the pending suggestion. Note that a
108
+ pending suggestion is *refreshed* — an unactioned suggestion is overwritten by
109
+ the next `ask`-mode session with fresher data, so `/zero-models` always reflects
110
+ the most recent recommendation.
111
+
73
112
  ### Skill auto-learning (`skills/`)
74
113
 
75
114
  `skill-loop.md` gives the agent a closed learning loop so solutions are reused
@@ -0,0 +1,250 @@
1
+ // zero-pi — adaptive model profiles, pi wiring.
2
+ //
3
+ // A thin pi extension that wires the pure decision logic in `autotune.ts` to
4
+ // the `session_start` hook. On every session start it reads the local outcome
5
+ // log (`~/.pi/zero-runs.jsonl`), aggregates it, decides per-phase model
6
+ // adjustments, and — depending on the `autotune` mode stored in
7
+ // `~/.pi/zero.json` — either applies them (`auto`), records them as a pending
8
+ // suggestion (`ask`), or does nothing (`off`).
9
+ //
10
+ // `session_start` is the chosen trigger: it runs deterministic code and
11
+ // applies the freshly-learned profile *before* the next `/forge` run reads
12
+ // `zero.json`. A run's own record only influences the *next* session — the
13
+ // one-run lag the requirements explicitly model ("a change takes effect on the
14
+ // next run").
15
+ //
16
+ // All decisions live in `autotune.ts`; this file only reads files, calls those
17
+ // functions, and applies/notifies. The whole handler is wrapped in a swallowing
18
+ // `try/catch` — exactly like `startup-banner.ts`, a failure must never break a
19
+ // pi session. The package stays dependency-free: `node:fs`/`node:os`/`node:path`
20
+ // only, plus minimal local interfaces for the pi API.
21
+
22
+ import { readFileSync, writeFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ import {
27
+ aggregate,
28
+ decideAdjustments,
29
+ readAutotuneMode,
30
+ readRunRecords,
31
+ type Adjustment,
32
+ } from "./autotune.ts";
33
+
34
+ /** The SDD phases, in pipeline order. Mirrors `zero-models.ts`. */
35
+ const PHASES = ["explore", "plan", "build", "veredicto"] as const;
36
+ type Phase = (typeof PHASES)[number];
37
+
38
+ /**
39
+ * One pending adjustment recorded under the `autotunePending` key of
40
+ * `~/.pi/zero.json` in `ask` mode. It mirrors `autotune.ts`'s `Adjustment`
41
+ * exactly — a flat `{ phase, from, to, reason }` record — so `/zero-models`
42
+ * (Task 6) can read the key, surface the suggestion, apply it, and clear it.
43
+ *
44
+ * `autotunePending` is the JSON shape `Adjustment[]`: an array of these
45
+ * records. It is never read by the orchestrator.
46
+ */
47
+ export interface AutotunePending {
48
+ /** SDD phase the suggested change applies to. */
49
+ phase: Phase;
50
+ /** Model the phase currently runs on. */
51
+ from: string;
52
+ /** Model the phase is suggested to move to (always one tier above `from`). */
53
+ to: string;
54
+ /** Human-readable justification, used verbatim when surfacing the suggestion. */
55
+ reason: string;
56
+ }
57
+
58
+ /** Absolute path of pi's `zero.json` marker. Mirrors `zero-models.ts`. */
59
+ function zeroJsonPath(): string {
60
+ return join(homedir(), ".pi", "zero.json");
61
+ }
62
+
63
+ /** Absolute path of the append-only run metrics log. */
64
+ function zeroRunsPath(): string {
65
+ return join(homedir(), ".pi", "zero-runs.jsonl");
66
+ }
67
+
68
+ /** Whether a value is a non-null, non-array object. */
69
+ function isObject(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+
73
+ /**
74
+ * Read `~/.pi/zero.json`.
75
+ *
76
+ * Returns `null` — distinct from an empty object — when the file is absent or
77
+ * unparseable. The autotune flow treats `null` as "no profile to safely tune":
78
+ * it must not synthesize a `models` map (AC 6.4). A present-but-`{}` file is a
79
+ * valid object and is returned as such.
80
+ */
81
+ function readZeroJson(): Record<string, unknown> | null {
82
+ let parsed: unknown;
83
+ try {
84
+ parsed = JSON.parse(readFileSync(zeroJsonPath(), "utf8"));
85
+ } catch {
86
+ return null;
87
+ }
88
+ return isObject(parsed) ? parsed : null;
89
+ }
90
+
91
+ /**
92
+ * Extract the per-phase models actually present in a `zero.json` object. Only
93
+ * string entries for known phases are kept; missing or non-string entries are
94
+ * omitted (no defaults are synthesized — `decideAdjustments` skips a phase with
95
+ * no current model).
96
+ */
97
+ function readCurrentModels(data: Record<string, unknown>): Partial<Record<Phase, string>> {
98
+ const raw = isObject(data.models) ? data.models : {};
99
+ const models: Partial<Record<Phase, string>> = {};
100
+ for (const phase of PHASES) {
101
+ const value = raw[phase];
102
+ if (typeof value === "string" && value !== "") models[phase] = value;
103
+ }
104
+ return models;
105
+ }
106
+
107
+ /**
108
+ * Derive `knownModels` — the distinct, non-empty model ids the autotune logic
109
+ * may step toward. Per the design this is the union of every model seen in the
110
+ * run log plus the current per-phase models, so `stepUp` prefers a model the
111
+ * user already uses anywhere.
112
+ */
113
+ function deriveKnownModels(
114
+ currentModels: Partial<Record<Phase, string>>,
115
+ loggedModels: Iterable<string>,
116
+ ): string[] {
117
+ const known = new Set<string>();
118
+ for (const phase of PHASES) {
119
+ const model = currentModels[phase];
120
+ if (typeof model === "string" && model !== "") known.add(model);
121
+ }
122
+ for (const model of loggedModels) {
123
+ if (typeof model === "string" && model !== "") known.add(model);
124
+ }
125
+ return [...known];
126
+ }
127
+
128
+ /** The slice of pi's UI surface the autotune extension uses. */
129
+ interface PiUI {
130
+ notify(message: string, type?: "info" | "warning" | "error"): void;
131
+ }
132
+ interface PiSessionContext {
133
+ ui: PiUI;
134
+ }
135
+ /** The slice of pi's extension API the autotune extension uses. */
136
+ interface PiExtensionAPI {
137
+ on(event: string, handler: (ctx: PiSessionContext) => void): void;
138
+ }
139
+
140
+ /**
141
+ * The `session_start` evaluate-and-tune handler.
142
+ *
143
+ * Runs entirely inside `register`'s swallowing `try/catch`, but is also written
144
+ * to never throw on the expected paths: a missing log, an absent `zero.json`,
145
+ * and an `off` mode all return cleanly.
146
+ */
147
+ function evaluateAndTune(ctx: PiSessionContext): void {
148
+ const notify = (message: string, type?: "info" | "warning" | "error"): void => {
149
+ try {
150
+ ctx.ui.notify(message, type);
151
+ } catch {
152
+ // A notification failure must not break the session either.
153
+ }
154
+ };
155
+
156
+ const data = readZeroJson();
157
+ if (data === null) {
158
+ // Absent or unparseable `zero.json` — no profile to safely tune. Skip with
159
+ // a single non-blocking warning; never synthesize a `models` map (AC 6.4).
160
+ notify("zero autotune: ~/.pi/zero.json missing or unreadable — skipping", "warning");
161
+ return;
162
+ }
163
+
164
+ const mode = readAutotuneMode(data);
165
+ if (mode === "off") return; // metrics still captured by the orchestrator; nothing changes here.
166
+
167
+ const currentModels = readCurrentModels(data);
168
+
169
+ const records = readRunRecords(zeroRunsPath());
170
+ const loggedModels: string[] = [];
171
+ for (const record of records) {
172
+ for (const phase of PHASES) {
173
+ const model = record.phases[phase]?.model;
174
+ if (typeof model === "string" && model !== "") loggedModels.push(model);
175
+ }
176
+ }
177
+ const knownModels = deriveKnownModels(currentModels, loggedModels);
178
+
179
+ const stats = aggregate(records);
180
+ const adjustments: Adjustment[] = decideAdjustments(stats, currentModels, knownModels);
181
+ if (adjustments.length === 0) return; // nothing to do — return silently.
182
+
183
+ if (mode === "auto") {
184
+ // Apply every adjustment, write `zero.json` ONCE preserving all other keys,
185
+ // then emit one notification per change (AC 5.4, 6.1 — never silent).
186
+ const models: Record<string, unknown> = isObject(data.models) ? { ...data.models } : {};
187
+ for (const adj of adjustments) {
188
+ models[adj.phase] = adj.to;
189
+ }
190
+ writeFileSync(
191
+ zeroJsonPath(),
192
+ `${JSON.stringify({ ...data, models }, null, 2)}\n`,
193
+ "utf8",
194
+ );
195
+ for (const adj of adjustments) {
196
+ notify(`zero autotune: ${adj.phase} ${adj.from} → ${adj.to} (${adj.reason})`, "info");
197
+ }
198
+ return;
199
+ }
200
+
201
+ // mode === "ask" — record the recommendations under `autotunePending` without
202
+ // touching `models`, and tell the user to run /zero-models to apply.
203
+ const pending: AutotunePending[] = adjustments.map((adj) => ({
204
+ phase: adj.phase,
205
+ from: adj.from,
206
+ to: adj.to,
207
+ reason: adj.reason,
208
+ }));
209
+ writeFileSync(
210
+ zeroJsonPath(),
211
+ `${JSON.stringify({ ...data, autotunePending: pending }, null, 2)}\n`,
212
+ "utf8",
213
+ );
214
+ for (const adj of pending) {
215
+ notify(
216
+ `zero autotune suggests: ${adj.phase} → ${adj.to} — run /zero-models to apply`,
217
+ "info",
218
+ );
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The pi extension entry point.
224
+ *
225
+ * pi calls this once when the extension loads. It wires the evaluate-and-tune
226
+ * handler to the `session_start` event. The whole body is wrapped in a
227
+ * swallowing `try/catch`, and the handler itself is wrapped again, so neither
228
+ * registration nor a later failure can ever break a pi session. Called with no
229
+ * or an invalid `pi`, it no-ops cleanly.
230
+ */
231
+ export default function register(pi?: unknown): void {
232
+ try {
233
+ if (
234
+ !pi ||
235
+ typeof (pi as PiExtensionAPI).on !== "function"
236
+ ) {
237
+ return;
238
+ }
239
+ (pi as PiExtensionAPI).on("session_start", (ctx: PiSessionContext): void => {
240
+ try {
241
+ if (!ctx || !ctx.ui || typeof ctx.ui.notify !== "function") return;
242
+ evaluateAndTune(ctx);
243
+ } catch {
244
+ // An evaluate-and-tune failure must never break a pi session.
245
+ }
246
+ });
247
+ } catch {
248
+ // Registration itself must never break a pi session.
249
+ }
250
+ }
@@ -0,0 +1,385 @@
1
+ // zero-pi — adaptive model profiles, pure-logic module.
2
+ //
3
+ // zero learns which Claude model fits each SDD phase by accumulating a local,
4
+ // append-only outcome log (`~/.pi/zero-runs.jsonl`) and tuning `~/.pi/zero.json`
5
+ // from aggregated statistics. Every *decision* — parsing, aggregation, tier
6
+ // math, adjustment — lives here in plain, dependency-free TypeScript so it is
7
+ // testable and reproducible. The pi wiring lives in `autotune-extension.ts`.
8
+ //
9
+ // This file has no pi imports and no side-effecting top-level code; it only
10
+ // touches the filesystem through the explicit `readRunRecords` reader.
11
+
12
+ import { readFileSync } from "node:fs";
13
+
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;
17
+
18
+ /** The SDD phases a run record carries a model for, in pipeline order. */
19
+ const RECORD_PHASES = ["explore", "plan", "build", "veredicto"] as const;
20
+
21
+ /** Terminal states a run can be recorded with. `pasa` = success;
22
+ * `cap-reached` = the round cap was hit without a `pasa`. */
23
+ const VERDICTS = ["pasa", "cap-reached"] as const;
24
+
25
+ /** A run's terminal verdict. */
26
+ export type RunVerdict = (typeof VERDICTS)[number];
27
+
28
+ /** The model a single phase ran on for a given run. An object (not a bare
29
+ * string) so the schema can grow additive fields without a version bump. */
30
+ export interface PhaseRun {
31
+ model: string;
32
+ }
33
+
34
+ /**
35
+ * One line of `~/.pi/zero-runs.jsonl` — a single completed SDD run. The
36
+ * orchestrator prompt emits exactly this shape at run end.
37
+ */
38
+ export interface RunRecord {
39
+ /** Schema version — always `RUN_SCHEMA_VERSION`. */
40
+ v: number;
41
+ /** ISO 8601 run-end timestamp. */
42
+ ts: string;
43
+ /** SDD feature slug. */
44
+ feature: string;
45
+ /** The model each phase ran on for this run. */
46
+ phases: Record<(typeof RECORD_PHASES)[number], PhaseRun>;
47
+ /** The run's terminal verdict. */
48
+ verdict: RunVerdict;
49
+ /** Count of build/veredicto rounds — `1` for a clean first-pass run. */
50
+ rounds: number;
51
+ }
52
+
53
+ /** How aggressively zero applies a learned profile — stored in `zero.json`.
54
+ * `auto` applies and notifies; `ask` records a pending suggestion; `off`
55
+ * changes nothing. */
56
+ export type AutotuneMode = "auto" | "ask" | "off";
57
+
58
+ /** Whether a value is a non-null object (and not an array). */
59
+ function isObject(value: unknown): value is Record<string, unknown> {
60
+ return typeof value === "object" && value !== null && !Array.isArray(value);
61
+ }
62
+
63
+ /**
64
+ * Parse one JSONL line into a `RunRecord`, validating the shape.
65
+ *
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.
72
+ */
73
+ export function parseRunLine(line: string): RunRecord | null {
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(line);
77
+ } catch {
78
+ return null;
79
+ }
80
+
81
+ if (!isObject(parsed)) return null;
82
+
83
+ if (parsed.v !== RUN_SCHEMA_VERSION) return null;
84
+ if (typeof parsed.ts !== "string" || parsed.ts === "") return null;
85
+ if (typeof parsed.feature !== "string" || parsed.feature === "") return null;
86
+ if (typeof parsed.rounds !== "number" || !Number.isInteger(parsed.rounds)) return null;
87
+ if (typeof parsed.verdict !== "string") return null;
88
+ if (!(VERDICTS as readonly string[]).includes(parsed.verdict)) return null;
89
+
90
+ if (!isObject(parsed.phases)) return null;
91
+ const phases = {} as RunRecord["phases"];
92
+ for (const phase of RECORD_PHASES) {
93
+ const phaseRun = parsed.phases[phase];
94
+ if (!isObject(phaseRun)) return null;
95
+ if (typeof phaseRun.model !== "string" || phaseRun.model === "") return null;
96
+ phases[phase] = { model: phaseRun.model };
97
+ }
98
+
99
+ return {
100
+ v: RUN_SCHEMA_VERSION,
101
+ ts: parsed.ts,
102
+ feature: parsed.feature,
103
+ phases,
104
+ verdict: parsed.verdict as RunVerdict,
105
+ rounds: parsed.rounds,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Read `~/.pi/zero-runs.jsonl` (or any path) into a list of valid `RunRecord`s.
111
+ *
112
+ * A missing or unreadable file yields `[]`. The file is split on `\n`; empty
113
+ * lines are skipped, and any line `parseRunLine` rejects (a malformed or
114
+ * half-written record) is dropped. Never throws.
115
+ */
116
+ export function readRunRecords(path: string): RunRecord[] {
117
+ let contents: string;
118
+ try {
119
+ contents = readFileSync(path, "utf8");
120
+ } catch {
121
+ return [];
122
+ }
123
+
124
+ const records: RunRecord[] = [];
125
+ for (const line of contents.split("\n")) {
126
+ if (line.trim() === "") continue;
127
+ const record = parseRunLine(line);
128
+ if (record !== null) records.push(record);
129
+ }
130
+ return records;
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Aggregation
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Aggregated outcome statistics for one `(phase, model)` pair.
139
+ */
140
+ export interface PhaseModelStat {
141
+ /** SDD phase this stat is for. */
142
+ phase: (typeof RECORD_PHASES)[number];
143
+ /** Model id this stat is for. */
144
+ model: string;
145
+ /** Total runs recorded for the pair. */
146
+ samples: number;
147
+ /** Fraction of runs that reached `pasa` — `0` when `samples` is `0`. */
148
+ passRate: number;
149
+ /** Mean `rounds` averaged ONLY over runs that reached `pasa`; `null` when no
150
+ * `pasa` run exists for the pair. */
151
+ avgRounds: number | null;
152
+ }
153
+
154
+ /** Map key for a `(phase, model)` bucket. */
155
+ function statKey(phase: string, model: string): string {
156
+ return `${phase} ${model}`;
157
+ }
158
+
159
+ /**
160
+ * Aggregate run records into per-`(phase, model)` statistics.
161
+ *
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.
168
+ */
169
+ export function aggregate(records: RunRecord[]): Map<string, PhaseModelStat> {
170
+ interface Acc {
171
+ samples: number;
172
+ passes: number;
173
+ passRounds: number;
174
+ passRoundCount: number;
175
+ }
176
+ const acc = new Map<string, { phase: (typeof RECORD_PHASES)[number]; model: string; data: Acc }>();
177
+
178
+ for (const record of records) {
179
+ const isPass = record.verdict === "pasa";
180
+ for (const phase of RECORD_PHASES) {
181
+ const phaseRun = record.phases[phase];
182
+ const model = phaseRun?.model;
183
+ if (typeof model !== "string" || model === "") continue;
184
+
185
+ const key = statKey(phase, model);
186
+ let bucket = acc.get(key);
187
+ if (bucket === undefined) {
188
+ bucket = { phase, model, data: { samples: 0, passes: 0, passRounds: 0, passRoundCount: 0 } };
189
+ acc.set(key, bucket);
190
+ }
191
+ bucket.data.samples += 1;
192
+ if (isPass) {
193
+ bucket.data.passes += 1;
194
+ // Guard against non-positive `rounds`: a clean run is `>= 1`, so a
195
+ // `0`/negative value is malformed and must not enter the average.
196
+ if (typeof record.rounds === "number" && record.rounds > 0) {
197
+ bucket.data.passRounds += record.rounds;
198
+ bucket.data.passRoundCount += 1;
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ const stats = new Map<string, PhaseModelStat>();
205
+ for (const [key, bucket] of acc) {
206
+ const { samples, passes, passRounds, passRoundCount } = bucket.data;
207
+ stats.set(key, {
208
+ phase: bucket.phase,
209
+ model: bucket.model,
210
+ samples,
211
+ passRate: samples > 0 ? passes / samples : 0,
212
+ avgRounds: passRoundCount > 0 ? passRounds / passRoundCount : null,
213
+ });
214
+ }
215
+ return stats;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Model tier ladder
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /** Three Claude tiers, ordered `haiku < sonnet < opus`. */
223
+ const TIER = { haiku: 0, sonnet: 1, opus: 2 } as const;
224
+
225
+ /** A model's tier index, or `null` for an unrecognized (untierable) model. */
226
+ export type Tier = (typeof TIER)[keyof typeof TIER];
227
+
228
+ /** A single hardcoded representative model id per tier, used as the fallback
229
+ * step-up target when the user has no known model at the next tier. */
230
+ const TIER_REPRESENTATIVE: Record<Tier, string> = {
231
+ [TIER.haiku]: "claude-haiku-4-5",
232
+ [TIER.sonnet]: "claude-sonnet-4-6",
233
+ [TIER.opus]: "claude-opus-4-7",
234
+ };
235
+
236
+ /**
237
+ * Classify a model id into a tier by substring match — deliberate so future
238
+ * point releases (`claude-sonnet-4-7`, etc.) classify with no code change.
239
+ * Returns `null` for any id that is not recognizably haiku/sonnet/opus.
240
+ */
241
+ export function tierOf(modelId: string): Tier | null {
242
+ if (typeof modelId !== "string") return null;
243
+ const id = modelId.toLowerCase();
244
+ if (id.includes("haiku")) return TIER.haiku;
245
+ if (id.includes("sonnet")) return TIER.sonnet;
246
+ if (id.includes("opus")) return TIER.opus;
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * Step a model up exactly one tier.
252
+ *
253
+ * Among `knownModels` (the models the user already uses anywhere in their
254
+ * `models` map) it picks one whose tier is exactly `tierOf(model) + 1`,
255
+ * preferring deterministically the smallest id when several qualify. If the
256
+ * user has no known model at the next tier, it falls back to the single
257
+ * hardcoded representative for that tier. Never returns an arbitrary id, and
258
+ * never steps more than one tier.
259
+ *
260
+ * Returns `null` when `model` is already at `opus` (no higher tier) or is
261
+ * untierable (an unrecognized model id).
262
+ */
263
+ export function stepUp(model: string, knownModels: readonly string[]): string | null {
264
+ const tier = tierOf(model);
265
+ if (tier === null) return null;
266
+ if (tier === TIER.opus) return null;
267
+
268
+ const nextTier = (tier + 1) as Tier;
269
+
270
+ const candidates = knownModels
271
+ .filter((m) => typeof m === "string" && tierOf(m) === nextTier)
272
+ .sort();
273
+ if (candidates.length > 0) return candidates[0];
274
+
275
+ return TIER_REPRESENTATIVE[nextTier];
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Adjustment rules
280
+ // ---------------------------------------------------------------------------
281
+
282
+ /** Below this many samples a `(phase, model)` pair is ignored — too little
283
+ * evidence to act on (AC 5.1). */
284
+ export const MIN_SAMPLES = 5;
285
+
286
+ /** Pass-rate at or under this value marks a phase as under-performing. */
287
+ export const LOW_PASS_RATE = 0.6;
288
+
289
+ /** Average rounds-to-pass over this value marks a phase as struggling. */
290
+ export const HIGH_AVG_ROUNDS = 2.5;
291
+
292
+ /** Pass-rate at or over this value marks a phase as reliable — stay put. */
293
+ export const RELIABLE_PASS_RATE = 0.85;
294
+
295
+ /**
296
+ * A proposed change of model for one SDD phase. `reason` is a human-readable
297
+ * string used verbatim in notifications.
298
+ */
299
+ export interface Adjustment {
300
+ /** SDD phase the change applies to. */
301
+ phase: (typeof RECORD_PHASES)[number];
302
+ /** Model the phase currently runs on. */
303
+ from: string;
304
+ /** Model the phase is proposed to move to (always one tier above `from`). */
305
+ to: string;
306
+ /** Human-readable justification, e.g. `"pass-rate 0.40 over 7 runs"`. */
307
+ reason: string;
308
+ }
309
+
310
+ /**
311
+ * Decide model adjustments from aggregated statistics.
312
+ *
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.
321
+ *
322
+ * Safety caps are baked in: a proposal is emitted only when `stepUp` returns a
323
+ * model (so a phase already at `opus` or on an untierable model is left
324
+ * alone), `stepUp` never jumps more than one tier, and it never returns a
325
+ * model outside `knownModels`-or-the-fixed-representative set.
326
+ */
327
+ export function decideAdjustments(
328
+ stats: Map<string, PhaseModelStat>,
329
+ currentModels: Partial<Record<(typeof RECORD_PHASES)[number], string>>,
330
+ knownModels: readonly string[],
331
+ ): Adjustment[] {
332
+ const adjustments: Adjustment[] = [];
333
+
334
+ for (const phase of RECORD_PHASES) {
335
+ const currentModel = currentModels[phase];
336
+ if (typeof currentModel !== "string" || currentModel === "") continue;
337
+
338
+ const stat = stats.get(statKey(phase, currentModel));
339
+ if (stat === undefined || stat.samples < MIN_SAMPLES) continue;
340
+
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);
346
+
347
+ if (reliable) continue;
348
+ if (!underPerforming) continue;
349
+
350
+ const to = stepUp(currentModel, knownModels);
351
+ if (to === null) continue; // already at top tier, or untierable
352
+
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`;
357
+
358
+ adjustments.push({ phase, from: currentModel, to, reason });
359
+ }
360
+
361
+ return adjustments;
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Autotune mode
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /** The valid stored values of the `autotune` key, used for membership checks. */
369
+ const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
370
+
371
+ /**
372
+ * Read the `autotune` mode out of a parsed `~/.pi/zero.json` object.
373
+ *
374
+ * Returns the stored value only when it is exactly `"auto"`, `"ask"`, or
375
+ * `"off"`. A missing key, a non-string value, or any other string degrades to
376
+ * the safe default `"auto"` — never throws. This is the single point of truth
377
+ * for "absent ⇒ auto" (AC 3.2).
378
+ */
379
+ export function readAutotuneMode(data: Record<string, unknown>): AutotuneMode {
380
+ const value = data.autotune;
381
+ if (typeof value === "string" && (AUTOTUNE_MODES as readonly string[]).includes(value)) {
382
+ return value as AutotuneMode;
383
+ }
384
+ return "auto";
385
+ }
@@ -14,6 +14,9 @@ import { readFileSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
 
17
+ import { readAutotuneMode, type AutotuneMode } from "./autotune.ts";
18
+ import type { AutotunePending } from "./autotune-extension.ts";
19
+
17
20
  /** The SDD phases, in pipeline order. */
18
21
  export const PHASES = ["explore", "plan", "build", "veredicto"] as const;
19
22
  export type Phase = (typeof PHASES)[number];
@@ -86,11 +89,81 @@ export function formatModels(models: PhaseModels): string {
86
89
  return PHASES.map((phase) => ` ${phase.padEnd(10)} ${models[phase]}`).join("\n");
87
90
  }
88
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
+
89
121
  /** Write the models back into `~/.pi/zero.json`, preserving every other key. */
90
122
  function writeModels(data: Record<string, unknown>, models: PhaseModels): void {
91
123
  writeFileSync(zeroJsonPath(), `${JSON.stringify({ ...data, models }, null, 2)}\n`, "utf8");
92
124
  }
93
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
+
94
167
  /** The slice of pi's extension API this command uses. */
95
168
  interface PiUI {
96
169
  select(prompt: string, options: string[]): Promise<string | undefined>;
@@ -129,11 +202,28 @@ export default function register(pi?: PiExtensionAPI): void {
129
202
  // Direct form: /zero-models build=claude-opus-4-7
130
203
  const arg = args.trim();
131
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
+
132
221
  const assignment = parseAssignment(arg);
133
222
  if (!assignment) {
134
223
  ctx.ui.notify(
135
224
  "uso: /zero-models —o— /zero-models <fase>=<modelo> " +
136
- "(fase: explore | plan | build | veredicto)",
225
+ "(fase: explore | plan | build | veredicto) —o— " +
226
+ "/zero-models autotune=<modo>",
137
227
  "warning",
138
228
  );
139
229
  return;
@@ -146,13 +236,51 @@ export default function register(pi?: PiExtensionAPI): void {
146
236
 
147
237
  // Interactive form: pick a phase, pick a model, repeat until saved.
148
238
  let changed = false;
239
+ let autotuneMode = readAutotuneMode(data);
240
+ let autotuneChanged = false;
241
+ let pending = readAutotunePending(data);
242
+ let pendingApplied = false;
149
243
  for (;;) {
150
- const phasePick = await ctx.ui.select(
151
- "zero · modelos SDD — elegí una fase",
152
- [...PHASES.map((p) => `${p} → ${models[p]}`), SAVE_AND_EXIT],
153
- );
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
+ ]);
154
258
  if (!phasePick || phasePick === SAVE_AND_EXIT) break;
155
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
+
156
284
  const phase = phasePick.split(/\s/)[0];
157
285
  if (!isPhase(phase)) break;
158
286
 
@@ -172,11 +300,27 @@ export default function register(pi?: PiExtensionAPI): void {
172
300
  changed = true;
173
301
  }
174
302
 
175
- if (changed) {
176
- writeModels(data, models);
177
- ctx.ui.notify(`zero · modelos SDD guardados:\n${formatModels(models)}`, "info");
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");
178
318
  } else {
179
- ctx.ui.notify(`zero · modelos SDD (sin cambios):\n${formatModels(models)}`, "info");
319
+ ctx.ui.notify(
320
+ `zero · modelos SDD (sin cambios):\n${formatModels(models)}\n` +
321
+ ` autotune ${autotuneMode}`,
322
+ "info",
323
+ );
180
324
  }
181
325
  } catch (err) {
182
326
  ctx.ui.notify(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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": [
@@ -21,7 +21,8 @@
21
21
  ],
22
22
  "extensions": [
23
23
  "./extensions/startup-banner.ts",
24
- "./extensions/zero-models.ts"
24
+ "./extensions/zero-models.ts",
25
+ "./extensions/autotune-extension.ts"
25
26
  ]
26
27
  },
27
28
  "files": [
@@ -29,6 +30,8 @@
29
30
  "skills",
30
31
  "extensions/startup-banner.ts",
31
32
  "extensions/zero-models.ts",
33
+ "extensions/autotune.ts",
34
+ "extensions/autotune-extension.ts",
32
35
  "README.md",
33
36
  "LICENSE"
34
37
  ],
@@ -89,3 +89,40 @@ Use the project name Cortex derives from the working directory.
89
89
 
90
90
  If Cortex is unavailable — installed with `--no-mcp`, or the server is down —
91
91
  skip recall and persist silently. The memory loop must never block a run.
92
+
93
+ ## Run metrics
94
+
95
+ zero tunes itself from a local outcome log. At the **end of every run** that
96
+ reached a verdict, append exactly one line to `~/.pi/zero-runs.jsonl` recording
97
+ how the run went. This is separate from — and additional to — the "## Run
98
+ memory" Cortex save above; do both.
99
+
100
+ The line is one `RunRecord` JSON object, serialized with no pretty-printing,
101
+ followed by a single newline. Build it from facts you already hold:
102
+
103
+ - `v`: the schema version — always the integer `1`.
104
+ - `ts`: the run-end timestamp, ISO 8601 (e.g. `2026-05-17T14:03:22.000Z`).
105
+ - `feature`: the SDD feature slug for this run.
106
+ - `phases`: an object with the four keys `explore`, `plan`, `build`,
107
+ `veredicto`, each mapped to `{ "model": "<model id>" }` — the per-phase model
108
+ ids you read from `~/.pi/zero.json` at the start of the run.
109
+ - `verdict`: `"pasa"` if the run reached a `pasa` verdict, or `"cap-reached"`
110
+ if the iteration cap was hit without one. No other values.
111
+ - `rounds`: the number of build/veredicto rounds (`1` for a clean first-pass
112
+ run).
113
+
114
+ Exact one-line shape to emit:
115
+
116
+ ```json
117
+ {"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}
118
+ ```
119
+
120
+ Rules:
121
+
122
+ - **Append only.** Add one line per run. Create `~/.pi/zero-runs.jsonl` if it
123
+ does not exist. Never rewrite, reorder, or delete existing lines.
124
+ - **Never block the run.** If the write fails for any reason, emit a
125
+ non-blocking warning and continue — the run's result stands regardless.
126
+ - **No record without a verdict.** If the run was aborted before `veredicto`
127
+ ever produced a verdict, write nothing — only a `pasa` or `cap-reached` run
128
+ is recorded.