@gonrocca/zero-pi 0.1.3 → 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 +52 -3
- package/extensions/autotune-extension.ts +250 -0
- package/extensions/autotune.ts +385 -0
- package/extensions/zero-models.ts +333 -0
- package/package.json +7 -2
- package/prompts/forge.md +6 -26
- package/prompts/orchestrator.md +37 -0
package/README.md
CHANGED
|
@@ -46,9 +46,19 @@ Run it with the `/forge <feature>` prompt. The orchestrator drives phase order
|
|
|
46
46
|
and enforces a hard build/veredicto iteration cap. Each phase has its own prompt
|
|
47
47
|
under `prompts/phases/` so it can be delegated to a dedicated sub-agent.
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
### Per-phase models (`/zero-models`)
|
|
50
|
+
|
|
51
|
+
`/zero-models` is a real pi command — a code handler, not an LLM prompt — for
|
|
52
|
+
the SDD models. Run it with no argument to pick a phase and a model
|
|
53
|
+
interactively, or set one directly:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
/zero-models # interactive picker
|
|
57
|
+
/zero-models build=claude-opus-4-7 # set one phase
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It reads and writes `~/.pi/zero.json`; the orchestrator picks the change up on
|
|
61
|
+
the next `/forge` run.
|
|
52
62
|
|
|
53
63
|
Per-phase model assignments are read from `~/.pi/zero.json`, which the `zero`
|
|
54
64
|
CLI writes when it installs this layer.
|
|
@@ -60,6 +70,45 @@ correction rounds, and the gotchas — under `topic_key: zero-run/<slug>`. The
|
|
|
60
70
|
next run on related work starts from what the last one learned. With `--no-mcp`
|
|
61
71
|
the loop degrades silently.
|
|
62
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
|
+
|
|
63
112
|
### Skill auto-learning (`skills/`)
|
|
64
113
|
|
|
65
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
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// zero-pi — the /zero-models command.
|
|
2
|
+
//
|
|
3
|
+
// A real pi command — a code handler, not an LLM prompt — for reading and
|
|
4
|
+
// changing the per-phase SDD models in `~/.pi/zero.json`. It is deterministic:
|
|
5
|
+
// no model is involved, so it does exactly what you pick, every time.
|
|
6
|
+
//
|
|
7
|
+
// /zero-models interactive — pick a phase, pick a model
|
|
8
|
+
// /zero-models build=claude-opus-4-7 set one phase directly
|
|
9
|
+
//
|
|
10
|
+
// The SDD orchestrator reads `~/.pi/zero.json` at the start of every `/forge`
|
|
11
|
+
// run, so a change takes effect on the next run.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { readAutotuneMode, type AutotuneMode } from "./autotune.ts";
|
|
18
|
+
import type { AutotunePending } from "./autotune-extension.ts";
|
|
19
|
+
|
|
20
|
+
/** The SDD phases, in pipeline order. */
|
|
21
|
+
export const PHASES = ["explore", "plan", "build", "veredicto"] as const;
|
|
22
|
+
export type Phase = (typeof PHASES)[number];
|
|
23
|
+
|
|
24
|
+
/** The per-phase model map. */
|
|
25
|
+
export type PhaseModels = Record<Phase, string>;
|
|
26
|
+
|
|
27
|
+
/** Fallback models when `~/.pi/zero.json` has none — cheap to explore, strong
|
|
28
|
+
* to plan and review. */
|
|
29
|
+
const DEFAULT_MODELS: PhaseModels = {
|
|
30
|
+
explore: "claude-haiku-4-5",
|
|
31
|
+
plan: "claude-opus-4-7",
|
|
32
|
+
build: "claude-sonnet-4-6",
|
|
33
|
+
veredicto: "claude-opus-4-7",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Models offered in the interactive picker — the Claude lineup pi-claude-cli
|
|
37
|
+
* exposes. Any other model can still be typed via the custom option. */
|
|
38
|
+
const MODEL_CHOICES = [
|
|
39
|
+
"claude-opus-4-7",
|
|
40
|
+
"claude-opus-4-6",
|
|
41
|
+
"claude-sonnet-4-6",
|
|
42
|
+
"claude-haiku-4-5",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Absolute path of pi's `zero.json` marker. */
|
|
46
|
+
function zeroJsonPath(): string {
|
|
47
|
+
return join(homedir(), ".pi", "zero.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether a string names an SDD phase. */
|
|
51
|
+
export function isPhase(value: string): value is Phase {
|
|
52
|
+
return (PHASES as readonly string[]).includes(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read `~/.pi/zero.json`, returning an empty object when absent or invalid. */
|
|
56
|
+
function readZeroJson(): Record<string, unknown> {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(zeroJsonPath(), "utf8")) as Record<string, unknown>;
|
|
59
|
+
} catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract the per-phase models from a zero.json object, filling any gap with
|
|
66
|
+
* the default so the picker always has a value to show.
|
|
67
|
+
*/
|
|
68
|
+
export function readModels(data: Record<string, unknown>): PhaseModels {
|
|
69
|
+
const raw = (data.models ?? {}) as Record<string, unknown>;
|
|
70
|
+
const models: PhaseModels = { ...DEFAULT_MODELS };
|
|
71
|
+
for (const phase of PHASES) {
|
|
72
|
+
if (typeof raw[phase] === "string") models[phase] = raw[phase] as string;
|
|
73
|
+
}
|
|
74
|
+
return models;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parse a `<phase>=<model>` (or `<phase> <model>`) assignment. */
|
|
78
|
+
export function parseAssignment(arg: string): { phase: Phase; model: string } | null {
|
|
79
|
+
const match = arg.trim().match(/^(\w+)\s*[=\s]\s*(.+)$/);
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
const phase = match[1].toLowerCase();
|
|
82
|
+
const model = match[2].trim();
|
|
83
|
+
if (!isPhase(phase) || model === "") return null;
|
|
84
|
+
return { phase, model };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Render the per-phase model map as an aligned block. */
|
|
88
|
+
export function formatModels(models: PhaseModels): string {
|
|
89
|
+
return PHASES.map((phase) => ` ${phase.padEnd(10)} ${models[phase]}`).join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The valid `autotune` modes a user can set. */
|
|
93
|
+
const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse the value of a `/zero-models autotune=<mode>` argument.
|
|
97
|
+
*
|
|
98
|
+
* Accepts only `auto`, `ask`, or `off` — case-insensitive and trimmed.
|
|
99
|
+
* Returns `null` for any other value so the caller can emit a usage warning
|
|
100
|
+
* and write nothing.
|
|
101
|
+
*/
|
|
102
|
+
export function parseAutotuneArg(arg: string): AutotuneMode | null {
|
|
103
|
+
const value = arg.trim().toLowerCase();
|
|
104
|
+
return (AUTOTUNE_MODES as readonly string[]).includes(value)
|
|
105
|
+
? (value as AutotuneMode)
|
|
106
|
+
: null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A short human label for an autotune mode, used in menus and notifications. */
|
|
110
|
+
export function formatAutotune(mode: AutotuneMode): string {
|
|
111
|
+
switch (mode) {
|
|
112
|
+
case "auto":
|
|
113
|
+
return "auto — aplica cambios automáticamente";
|
|
114
|
+
case "ask":
|
|
115
|
+
return "ask — sugiere y espera confirmación";
|
|
116
|
+
case "off":
|
|
117
|
+
return "off — no ajusta nada";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Write the models back into `~/.pi/zero.json`, preserving every other key. */
|
|
122
|
+
function writeModels(data: Record<string, unknown>, models: PhaseModels): void {
|
|
123
|
+
writeFileSync(zeroJsonPath(), `${JSON.stringify({ ...data, models }, null, 2)}\n`, "utf8");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write an updated `~/.pi/zero.json` object, preserving every other key via the
|
|
128
|
+
* same `{ ...data }` spread, 2-space indent and trailing newline `writeModels`
|
|
129
|
+
* uses. The caller passes the keys it wants to add/override.
|
|
130
|
+
*/
|
|
131
|
+
function writeZeroJson(data: Record<string, unknown>, patch: Record<string, unknown>): void {
|
|
132
|
+
writeFileSync(zeroJsonPath(), `${JSON.stringify({ ...data, ...patch }, null, 2)}\n`, "utf8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a value is a non-null, non-array object. */
|
|
136
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
137
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Extract the `autotunePending` adjustments from a zero.json object.
|
|
142
|
+
*
|
|
143
|
+
* Returns only well-formed records — an array entry with string `phase`/`from`/
|
|
144
|
+
* `to`/`reason` and a recognized phase — so a malformed key never crashes the
|
|
145
|
+
* picker. A missing or off-shape key yields `[]`.
|
|
146
|
+
*/
|
|
147
|
+
function readAutotunePending(data: Record<string, unknown>): AutotunePending[] {
|
|
148
|
+
const raw = data.autotunePending;
|
|
149
|
+
if (!Array.isArray(raw)) return [];
|
|
150
|
+
const pending: AutotunePending[] = [];
|
|
151
|
+
for (const entry of raw) {
|
|
152
|
+
if (!isObject(entry)) continue;
|
|
153
|
+
const { phase, from, to, reason } = entry;
|
|
154
|
+
if (
|
|
155
|
+
typeof phase === "string" &&
|
|
156
|
+
isPhase(phase) &&
|
|
157
|
+
typeof from === "string" &&
|
|
158
|
+
typeof to === "string" &&
|
|
159
|
+
typeof reason === "string"
|
|
160
|
+
) {
|
|
161
|
+
pending.push({ phase, from, to, reason });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return pending;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The slice of pi's extension API this command uses. */
|
|
168
|
+
interface PiUI {
|
|
169
|
+
select(prompt: string, options: string[]): Promise<string | undefined>;
|
|
170
|
+
input(prompt: string, placeholder?: string): Promise<string | undefined>;
|
|
171
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
172
|
+
}
|
|
173
|
+
interface PiCommandContext {
|
|
174
|
+
ui: PiUI;
|
|
175
|
+
}
|
|
176
|
+
interface PiExtensionAPI {
|
|
177
|
+
registerCommand(
|
|
178
|
+
name: string,
|
|
179
|
+
options: {
|
|
180
|
+
description?: string;
|
|
181
|
+
handler: (args: string, ctx: PiCommandContext) => Promise<void> | void;
|
|
182
|
+
},
|
|
183
|
+
): void;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const SAVE_AND_EXIT = "— guardar y salir —";
|
|
187
|
+
const CUSTOM_MODEL = "— otro modelo (escribir) —";
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The pi extension entry point — registers the `/zero-models` command.
|
|
191
|
+
*/
|
|
192
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
193
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
194
|
+
|
|
195
|
+
pi.registerCommand("zero-models", {
|
|
196
|
+
description: "Show or set the per-phase SDD models — /zero-models [<phase>=<model>]",
|
|
197
|
+
handler: async (args: string, ctx: PiCommandContext): Promise<void> => {
|
|
198
|
+
try {
|
|
199
|
+
const data = readZeroJson();
|
|
200
|
+
const models = readModels(data);
|
|
201
|
+
|
|
202
|
+
// Direct form: /zero-models build=claude-opus-4-7
|
|
203
|
+
const arg = args.trim();
|
|
204
|
+
if (arg) {
|
|
205
|
+
// Direct form: /zero-models autotune=<mode>
|
|
206
|
+
const autotuneMatch = arg.match(/^autotune\s*[=\s]\s*(.+)$/i);
|
|
207
|
+
if (autotuneMatch) {
|
|
208
|
+
const mode = parseAutotuneArg(autotuneMatch[1]);
|
|
209
|
+
if (!mode) {
|
|
210
|
+
ctx.ui.notify(
|
|
211
|
+
"uso: /zero-models autotune=<modo> (modo: auto | ask | off)",
|
|
212
|
+
"warning",
|
|
213
|
+
);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
writeZeroJson(data, { autotune: mode });
|
|
217
|
+
ctx.ui.notify(`zero autotune: ${formatAutotune(mode)}`, "info");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const assignment = parseAssignment(arg);
|
|
222
|
+
if (!assignment) {
|
|
223
|
+
ctx.ui.notify(
|
|
224
|
+
"uso: /zero-models —o— /zero-models <fase>=<modelo> " +
|
|
225
|
+
"(fase: explore | plan | build | veredicto) —o— " +
|
|
226
|
+
"/zero-models autotune=<modo>",
|
|
227
|
+
"warning",
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
models[assignment.phase] = assignment.model;
|
|
232
|
+
writeModels(data, models);
|
|
233
|
+
ctx.ui.notify(`zero models: ${assignment.phase} → ${assignment.model}`, "info");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Interactive form: pick a phase, pick a model, repeat until saved.
|
|
238
|
+
let changed = false;
|
|
239
|
+
let autotuneMode = readAutotuneMode(data);
|
|
240
|
+
let autotuneChanged = false;
|
|
241
|
+
let pending = readAutotunePending(data);
|
|
242
|
+
let pendingApplied = false;
|
|
243
|
+
for (;;) {
|
|
244
|
+
const applyEntry =
|
|
245
|
+
pending.length > 0
|
|
246
|
+
? `★ aplicar sugerencia: ${pending
|
|
247
|
+
.map((p) => `${p.phase} → ${p.to}`)
|
|
248
|
+
.join(", ")}`
|
|
249
|
+
: null;
|
|
250
|
+
const autotuneEntry = `autotune → ${autotuneMode}`;
|
|
251
|
+
|
|
252
|
+
const phasePick = await ctx.ui.select("zero · modelos SDD — elegí una fase", [
|
|
253
|
+
...(applyEntry ? [applyEntry] : []),
|
|
254
|
+
...PHASES.map((p) => `${p} → ${models[p]}`),
|
|
255
|
+
autotuneEntry,
|
|
256
|
+
SAVE_AND_EXIT,
|
|
257
|
+
]);
|
|
258
|
+
if (!phasePick || phasePick === SAVE_AND_EXIT) break;
|
|
259
|
+
|
|
260
|
+
// Apply the pending autotune suggestion.
|
|
261
|
+
if (applyEntry && phasePick === applyEntry) {
|
|
262
|
+
for (const adj of pending) models[adj.phase] = adj.to;
|
|
263
|
+
changed = true;
|
|
264
|
+
pendingApplied = true;
|
|
265
|
+
pending = [];
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Change the autotune mode.
|
|
270
|
+
if (phasePick === autotuneEntry) {
|
|
271
|
+
const modePick = await ctx.ui.select(
|
|
272
|
+
"Modo de autotune",
|
|
273
|
+
AUTOTUNE_MODES.map((m) => formatAutotune(m)),
|
|
274
|
+
);
|
|
275
|
+
if (!modePick) continue;
|
|
276
|
+
const picked = parseAutotuneArg(modePick.split(/\s/)[0]);
|
|
277
|
+
if (picked && picked !== autotuneMode) {
|
|
278
|
+
autotuneMode = picked;
|
|
279
|
+
autotuneChanged = true;
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const phase = phasePick.split(/\s/)[0];
|
|
285
|
+
if (!isPhase(phase)) break;
|
|
286
|
+
|
|
287
|
+
const modelPick = await ctx.ui.select(`Modelo para «${phase}»`, [
|
|
288
|
+
...MODEL_CHOICES,
|
|
289
|
+
CUSTOM_MODEL,
|
|
290
|
+
]);
|
|
291
|
+
if (!modelPick) continue;
|
|
292
|
+
|
|
293
|
+
let model = modelPick;
|
|
294
|
+
if (modelPick === CUSTOM_MODEL) {
|
|
295
|
+
const typed = await ctx.ui.input(`Modelo para «${phase}»`, models[phase]);
|
|
296
|
+
if (!typed || typed.trim() === "") continue;
|
|
297
|
+
model = typed.trim();
|
|
298
|
+
}
|
|
299
|
+
models[phase] = model;
|
|
300
|
+
changed = true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (changed || autotuneChanged) {
|
|
304
|
+
// Build the patch, preserving every other key via the spread. When
|
|
305
|
+
// the pending suggestion was applied, clear the `autotunePending` key.
|
|
306
|
+
const patch: Record<string, unknown> = { models };
|
|
307
|
+
if (autotuneChanged) patch.autotune = autotuneMode;
|
|
308
|
+
if (pendingApplied) patch.autotunePending = undefined;
|
|
309
|
+
|
|
310
|
+
const merged = { ...data, ...patch };
|
|
311
|
+
if (pendingApplied) delete merged.autotunePending;
|
|
312
|
+
writeFileSync(zeroJsonPath(), `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
313
|
+
|
|
314
|
+
const summary = [`zero · modelos SDD guardados:\n${formatModels(models)}`];
|
|
315
|
+
summary.push(` autotune ${autotuneMode}`);
|
|
316
|
+
if (pendingApplied) summary.push("sugerencia aplicada");
|
|
317
|
+
ctx.ui.notify(summary.join("\n"), "info");
|
|
318
|
+
} else {
|
|
319
|
+
ctx.ui.notify(
|
|
320
|
+
`zero · modelos SDD (sin cambios):\n${formatModels(models)}\n` +
|
|
321
|
+
` autotune ${autotuneMode}`,
|
|
322
|
+
"info",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
ctx.ui.notify(
|
|
327
|
+
`zero-models: ${err instanceof Error ? err.message : String(err)}`,
|
|
328
|
+
"error",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
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": [
|
|
@@ -20,13 +20,18 @@
|
|
|
20
20
|
"./skills"
|
|
21
21
|
],
|
|
22
22
|
"extensions": [
|
|
23
|
-
"./extensions/startup-banner.ts"
|
|
23
|
+
"./extensions/startup-banner.ts",
|
|
24
|
+
"./extensions/zero-models.ts",
|
|
25
|
+
"./extensions/autotune-extension.ts"
|
|
24
26
|
]
|
|
25
27
|
},
|
|
26
28
|
"files": [
|
|
27
29
|
"prompts",
|
|
28
30
|
"skills",
|
|
29
31
|
"extensions/startup-banner.ts",
|
|
32
|
+
"extensions/zero-models.ts",
|
|
33
|
+
"extensions/autotune.ts",
|
|
34
|
+
"extensions/autotune-extension.ts",
|
|
30
35
|
"README.md",
|
|
31
36
|
"LICENSE"
|
|
32
37
|
],
|
package/prompts/forge.md
CHANGED
|
@@ -1,31 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Run
|
|
2
|
+
description: Run an automatic spec-driven development pipeline for a feature request
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## `/forge models` — configure the SDD models
|
|
8
|
-
|
|
9
|
-
When the argument is exactly `models`, do **not** run the pipeline. Configure
|
|
10
|
-
the per-phase models instead:
|
|
11
|
-
|
|
12
|
-
1. Read `~/.pi/zero.json`. Show the user the current `models` mapping — the
|
|
13
|
-
model assigned to each phase: `explore`, `plan`, `build`, `veredicto`.
|
|
14
|
-
2. Ask which phases they want to change and to what. Offer the models the
|
|
15
|
-
active provider exposes — run `pi --list-models` if you need the list.
|
|
16
|
-
Guidance: a cheap, fast model suits `explore`; a strong model suits `plan`
|
|
17
|
-
and the adversarial `veredicto`; `build` sits in between.
|
|
18
|
-
3. Write the chosen models back into `~/.pi/zero.json`, replacing only the
|
|
19
|
-
`models` block and preserving every other key (`version`, `installedAt`).
|
|
20
|
-
Validate each model name against `pi --list-models` before writing.
|
|
21
|
-
4. Confirm the new mapping back to the user.
|
|
22
|
-
|
|
23
|
-
This changes the models for every later `/forge` run — the orchestrator reads
|
|
24
|
-
`~/.pi/zero.json` at the start of each run.
|
|
25
|
-
|
|
26
|
-
## `/forge <feature request>` — run the SDD pipeline
|
|
27
|
-
|
|
28
|
-
For any other argument, run the zero SDD pipeline for that feature request.
|
|
5
|
+
Run the zero SDD pipeline for the feature request in the arguments.
|
|
29
6
|
|
|
30
7
|
Follow the zero SDD orchestrator instructions: drive the run through the
|
|
31
8
|
explore → plan → build → veredicto phases, honour the build/veredicto iteration
|
|
@@ -40,4 +17,7 @@ In interactive mode, pause after each phase with a summary and ask before
|
|
|
40
17
|
continuing. Never report success unless the veredicto phase returned a `pasa`
|
|
41
18
|
verdict; if the cap is reached first, report that the result is not verified.
|
|
42
19
|
|
|
43
|
-
|
|
20
|
+
To change the per-phase models, the user runs the `/zero-models` command — do
|
|
21
|
+
not handle model configuration here.
|
|
22
|
+
|
|
23
|
+
Feature request: $ARGUMENTS
|
package/prompts/orchestrator.md
CHANGED
|
@@ -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.
|