@gonrocca/zero-pi 0.1.4 → 0.1.7
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 +49 -0
- package/extensions/autotune-extension.ts +250 -0
- package/extensions/autotune.ts +385 -0
- package/extensions/zero-models.ts +153 -9
- package/package.json +5 -2
- package/prompts/forge.md +11 -0
- package/prompts/orchestrator.md +110 -0
- package/prompts/phases/build.md +9 -0
- package/prompts/phases/explore.md +6 -0
- package/prompts/phases/plan.md +29 -0
- package/prompts/phases/veredicto.md +9 -0
package/README.md
CHANGED
|
@@ -46,6 +46,16 @@ 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
|
+
**Review Workload Forecast** — the plan phase keeps tasks reviewable. Every
|
|
50
|
+
planned task carries a `review: ~N changed lines` estimate, and `tasks.md` gains
|
|
51
|
+
a `## Review Workload` section with the per-task estimates and a bold run total.
|
|
52
|
+
Tasks are sized against a fixed budget of **400 changed lines per task** — an
|
|
53
|
+
internal, non-configurable default (borrowed from gentle-ai), so "small task"
|
|
54
|
+
means the same number on every run. A task whose estimate exceeds the budget is
|
|
55
|
+
split into smaller, individually verifiable tasks; one that genuinely cannot be
|
|
56
|
+
split stays whole and is recorded as an over-budget exception with a reason. The
|
|
57
|
+
orchestrator's plan-phase summary reports the run total and any exceptions.
|
|
58
|
+
|
|
49
59
|
### Per-phase models (`/zero-models`)
|
|
50
60
|
|
|
51
61
|
`/zero-models` is a real pi command — a code handler, not an LLM prompt — for
|
|
@@ -70,6 +80,45 @@ correction rounds, and the gotchas — under `topic_key: zero-run/<slug>`. The
|
|
|
70
80
|
next run on related work starts from what the last one learned. With `--no-mcp`
|
|
71
81
|
the loop degrades silently.
|
|
72
82
|
|
|
83
|
+
### Adaptive model profiles
|
|
84
|
+
|
|
85
|
+
zero learns which model fits each SDD phase from your own run history and can
|
|
86
|
+
re-tune `~/.pi/zero.json` for you.
|
|
87
|
+
|
|
88
|
+
**The metrics log — `~/.pi/zero-runs.jsonl`.** Every completed SDD run appends
|
|
89
|
+
one JSON line to this file: the feature slug, the per-phase models the run used,
|
|
90
|
+
the final verdict, and the build/veredicto round count. It is append-only and
|
|
91
|
+
never rewritten. This local log is the only thing zero learns from — a run
|
|
92
|
+
abandoned before it reaches a verdict adds no line.
|
|
93
|
+
|
|
94
|
+
**Autotune modes.** At each pi session start zero aggregates the log and, once a
|
|
95
|
+
phase has accumulated enough run data to cross a confidence threshold, decides
|
|
96
|
+
whether that phase's model should change. The `autotune` mode in `~/.pi/zero.json`
|
|
97
|
+
controls what happens next:
|
|
98
|
+
|
|
99
|
+
| Mode | Behaviour |
|
|
100
|
+
| ---- | --------- |
|
|
101
|
+
| `auto` _(default)_ | zero applies the adjustment to `~/.pi/zero.json` and notifies you exactly what changed. |
|
|
102
|
+
| `ask` | zero records the recommendation but changes nothing — you apply it from `/zero-models`. |
|
|
103
|
+
| `off` | zero still records run metrics, but never changes or recommends anything. |
|
|
104
|
+
|
|
105
|
+
A change always takes effect on the *next* `/forge` run, and every applied
|
|
106
|
+
change is announced — autotune is never silent.
|
|
107
|
+
|
|
108
|
+
**Setting the mode.** Set it directly, or pick it from the interactive
|
|
109
|
+
`/zero-models` menu (which shows the current mode as its own entry):
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
/zero-models autotune=ask # auto | ask | off
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Applying a pending suggestion.** In `ask` mode, when a recommendation is
|
|
116
|
+
waiting, running `/zero-models` shows a leading `★ aplicar sugerencia` entry;
|
|
117
|
+
selecting it applies the change and clears the pending suggestion. Note that a
|
|
118
|
+
pending suggestion is *refreshed* — an unactioned suggestion is overwritten by
|
|
119
|
+
the next `ask`-mode session with fresher data, so `/zero-models` always reflects
|
|
120
|
+
the most recent recommendation.
|
|
121
|
+
|
|
73
122
|
### Skill auto-learning (`skills/`)
|
|
74
123
|
|
|
75
124
|
`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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.1.7",
|
|
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
|
],
|
package/prompts/forge.md
CHANGED
|
@@ -4,6 +4,17 @@ description: Run an automatic spec-driven development pipeline for a feature req
|
|
|
4
4
|
|
|
5
5
|
Run the zero SDD pipeline for the feature request in the arguments.
|
|
6
6
|
|
|
7
|
+
**Parse the arguments first.** If the arguments start with `--continue`, this is
|
|
8
|
+
a **resume** run, not a fresh one:
|
|
9
|
+
|
|
10
|
+
- `--continue` with no slug → resume mode: hand control to the orchestrator's
|
|
11
|
+
`## Resuming a run` section, which scans `.sdd/*/` for an unfinished run.
|
|
12
|
+
- `--continue <slug>` → resume mode targeting `.sdd/<slug>/` directly. If that
|
|
13
|
+
directory does not exist, report "no such run: <slug>" and stop — do **not**
|
|
14
|
+
start a fresh run under that slug.
|
|
15
|
+
- Anything else (a feature request, or no arguments) → a fresh run, exactly
|
|
16
|
+
today's behaviour: the arguments are the feature request.
|
|
17
|
+
|
|
7
18
|
Follow the zero SDD orchestrator instructions: drive the run through the
|
|
8
19
|
explore → plan → build → veredicto phases, honour the build/veredicto iteration
|
|
9
20
|
cap, and use the execution mode (interactive or automatic) the user chose.
|
package/prompts/orchestrator.md
CHANGED
|
@@ -26,6 +26,74 @@ whim. Drive the run through four phases, in order:
|
|
|
26
26
|
The orchestrator code controls phase order and the round count. The cap is not
|
|
27
27
|
optional and the model does not get to extend it.
|
|
28
28
|
|
|
29
|
+
## Resuming a run
|
|
30
|
+
|
|
31
|
+
`/forge --continue` resumes an interrupted run instead of starting fresh. Resume
|
|
32
|
+
is derived purely from the `.sdd/<feature-slug>/` artifacts — `requirements.md`,
|
|
33
|
+
`design.md`, and `tasks.md` with its `[ ]`/`[x]` checklist. There is no separate
|
|
34
|
+
state file; the artifacts are the run's durable state.
|
|
35
|
+
|
|
36
|
+
**Selecting the run.**
|
|
37
|
+
|
|
38
|
+
- `--continue <slug>` — skip the scan and target `.sdd/<slug>/` directly. Never
|
|
39
|
+
disambiguate. (`forge.md` already reports "no such run" and stops if the
|
|
40
|
+
directory is absent.)
|
|
41
|
+
- `--continue` with no slug — scan `.sdd/*/` and classify every run by its
|
|
42
|
+
resume-point state (below). "Unfinished" = state `no-plan`, `building`, or
|
|
43
|
+
`built` (anything except `done`).
|
|
44
|
+
- Exactly one unfinished run → resume it silently.
|
|
45
|
+
- More than one → list each unfinished run with its slug and detected resume
|
|
46
|
+
point, and ask the user which to resume.
|
|
47
|
+
- Zero → state "nothing to resume" and stop. Do **not** start a fresh run.
|
|
48
|
+
|
|
49
|
+
**Resume-point algorithm.** For the selected `<slug>`:
|
|
50
|
+
|
|
51
|
+
1. If `.sdd/<slug>/requirements.md` is missing → state `no-plan`; resume at
|
|
52
|
+
**explore**, then plan (the run barely started; rebuild the plan artifacts).
|
|
53
|
+
2. Else if `.sdd/<slug>/design.md` or `.sdd/<slug>/tasks.md` is missing → state
|
|
54
|
+
`no-plan`; resume at **plan** (requirements survived; finish the plan).
|
|
55
|
+
3. Else (all three plan artifacts exist):
|
|
56
|
+
- If `tasks.md` has at least one `[ ]` task → state `building`; resume at
|
|
57
|
+
**build**, starting at the first `[ ]` task. Already-`[x]` tasks are done —
|
|
58
|
+
do not redo them.
|
|
59
|
+
- Else (every task `[x]`):
|
|
60
|
+
- Look for best-effort proof of a prior `pasa` verdict, in order: the
|
|
61
|
+
Cortex `zero-run/<slug>` trace (`memoria_search` for that `topic_key`)
|
|
62
|
+
reporting a `pasa` final verdict, then a line in `~/.pi/zero-runs.jsonl`
|
|
63
|
+
with `"feature":"<slug>"` and `"verdict":"pasa"`.
|
|
64
|
+
- Proof found → state `done`; report the run already completed
|
|
65
|
+
successfully and do nothing — no re-run, no clobber.
|
|
66
|
+
- No proof (Cortex unreachable, file absent, `--no-mcp`) → state `built`;
|
|
67
|
+
resume at **veredicto** and let it confirm the verdict. An all-`[x]`
|
|
68
|
+
`tasks.md` proves build finished but **never** proves a `pasa` verdict —
|
|
69
|
+
absence of proof always resolves toward re-verification.
|
|
70
|
+
|
|
71
|
+
**Sanity-checking artifacts on resume.** A phase may have been killed
|
|
72
|
+
mid-write, leaving a truncated `design.md` or `tasks.md`. When you brief the
|
|
73
|
+
resumed phase's sub-agent, instruct it to sanity-check the artifacts it depends
|
|
74
|
+
on (plan checks `requirements.md`/`design.md` look complete; build checks
|
|
75
|
+
`tasks.md` parses as a checklist) and rebuild an obviously-incomplete one rather
|
|
76
|
+
than trust it.
|
|
77
|
+
|
|
78
|
+
**Pipeline guarantees on resume.** Resume enters the same loop at a later
|
|
79
|
+
phase — every existing guarantee still holds: phase order proceeds forward from
|
|
80
|
+
the resume phase with no downstream phase skipped; the build/veredicto iteration
|
|
81
|
+
cap still bounds the resumed segment (the spent round count is not recoverable,
|
|
82
|
+
so a resumed `building`/`built` run starts its counter at 1); the veredicto gate
|
|
83
|
+
still stands — `pasa` is reported only on a `pasa` verdict, and the `done`
|
|
84
|
+
short-circuit is the sole exception because it required positive proof of a
|
|
85
|
+
prior `pasa`. Ask for the execution mode (interactive / automatic) at resume
|
|
86
|
+
time exactly as a fresh run does — mode is per-invocation, not persisted — and
|
|
87
|
+
announce the slug, the detected resume phase, and (for `building`) the first
|
|
88
|
+
unchecked task number before entering the pipeline.
|
|
89
|
+
|
|
90
|
+
**Fresh `/forge` against an existing slug.** At the start of a *fresh* run
|
|
91
|
+
(no `--continue`), if `.sdd/<slug>/` already exists and is non-empty, do **not**
|
|
92
|
+
silently clobber it and do **not** silently resume. Ask the user to choose:
|
|
93
|
+
(a) resume it instead, (b) start over — discarding the existing artifacts, which
|
|
94
|
+
the user must explicitly confirm — or (c) pick a different slug. An empty or
|
|
95
|
+
non-existent `.sdd/<slug>/` proceeds as a fresh run with no prompt.
|
|
96
|
+
|
|
29
97
|
## Sub-agent delegation
|
|
30
98
|
|
|
31
99
|
Each phase runs as its own sub-agent — `zero-explore`, `zero-plan`,
|
|
@@ -55,6 +123,11 @@ At the start of a run, ask the user which mode they want:
|
|
|
55
123
|
|
|
56
124
|
Cache the mode for the run.
|
|
57
125
|
|
|
126
|
+
When summarising the plan phase, report the run's total changed-lines forecast
|
|
127
|
+
from the `## Review Workload` section of `tasks.md`, name each over-budget
|
|
128
|
+
exception with its reason, and — when there are no exceptions — state that all
|
|
129
|
+
tasks are within the per-task budget.
|
|
130
|
+
|
|
58
131
|
## Visibility
|
|
59
132
|
|
|
60
133
|
While a phase runs, keep the user informed of progress — never run silently for
|
|
@@ -89,3 +162,40 @@ Use the project name Cortex derives from the working directory.
|
|
|
89
162
|
|
|
90
163
|
If Cortex is unavailable — installed with `--no-mcp`, or the server is down —
|
|
91
164
|
skip recall and persist silently. The memory loop must never block a run.
|
|
165
|
+
|
|
166
|
+
## Run metrics
|
|
167
|
+
|
|
168
|
+
zero tunes itself from a local outcome log. At the **end of every run** that
|
|
169
|
+
reached a verdict, append exactly one line to `~/.pi/zero-runs.jsonl` recording
|
|
170
|
+
how the run went. This is separate from — and additional to — the "## Run
|
|
171
|
+
memory" Cortex save above; do both.
|
|
172
|
+
|
|
173
|
+
The line is one `RunRecord` JSON object, serialized with no pretty-printing,
|
|
174
|
+
followed by a single newline. Build it from facts you already hold:
|
|
175
|
+
|
|
176
|
+
- `v`: the schema version — always the integer `1`.
|
|
177
|
+
- `ts`: the run-end timestamp, ISO 8601 (e.g. `2026-05-17T14:03:22.000Z`).
|
|
178
|
+
- `feature`: the SDD feature slug for this run.
|
|
179
|
+
- `phases`: an object with the four keys `explore`, `plan`, `build`,
|
|
180
|
+
`veredicto`, each mapped to `{ "model": "<model id>" }` — the per-phase model
|
|
181
|
+
ids you read from `~/.pi/zero.json` at the start of the run.
|
|
182
|
+
- `verdict`: `"pasa"` if the run reached a `pasa` verdict, or `"cap-reached"`
|
|
183
|
+
if the iteration cap was hit without one. No other values.
|
|
184
|
+
- `rounds`: the number of build/veredicto rounds (`1` for a clean first-pass
|
|
185
|
+
run).
|
|
186
|
+
|
|
187
|
+
Exact one-line shape to emit:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{"v":1,"ts":"2026-05-17T14:03:22.000Z","feature":"adaptive-model-profiles","phases":{"explore":{"model":"claude-haiku-4-5"},"plan":{"model":"claude-opus-4-7"},"build":{"model":"claude-sonnet-4-6"},"veredicto":{"model":"claude-opus-4-7"}},"verdict":"pasa","rounds":2}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Rules:
|
|
194
|
+
|
|
195
|
+
- **Append only.** Add one line per run. Create `~/.pi/zero-runs.jsonl` if it
|
|
196
|
+
does not exist. Never rewrite, reorder, or delete existing lines.
|
|
197
|
+
- **Never block the run.** If the write fails for any reason, emit a
|
|
198
|
+
non-blocking warning and continue — the run's result stands regardless.
|
|
199
|
+
- **No record without a verdict.** If the run was aborted before `veredicto`
|
|
200
|
+
ever produced a verdict, write nothing — only a `pasa` or `cap-reached` run
|
|
201
|
+
is recorded.
|
package/prompts/phases/build.md
CHANGED
|
@@ -4,6 +4,15 @@ description: SDD build phase — implement the planned tasks, test-first, and ma
|
|
|
4
4
|
|
|
5
5
|
You run the **build** phase of a zero SDD pipeline.
|
|
6
6
|
|
|
7
|
+
**Locating artifacts.** If you are invoked with a feature slug, operate on
|
|
8
|
+
`.sdd/<slug>/`. With no slug and exactly one candidate run on disk, use it; with
|
|
9
|
+
no slug and an ambiguous target, ask which run before acting. Read `tasks.md`
|
|
10
|
+
and continue from the first `[ ]` task — already-`[x]` tasks are done, leave
|
|
11
|
+
them untouched. Update each checkbox to `[x]` as its task completes so a later
|
|
12
|
+
resume sees the progress. Sanity-check that `tasks.md` parses as a checklist
|
|
13
|
+
before trusting it. If `tasks.md` is missing, report the missing prerequisite
|
|
14
|
+
and stop — do **not** fabricate a plan.
|
|
15
|
+
|
|
7
16
|
Implement the planned tasks in order, test-first where practical. Keep every
|
|
8
17
|
change within the plan's scope — do not expand it on your own initiative.
|
|
9
18
|
|
|
@@ -4,6 +4,12 @@ description: SDD explore phase — investigate the codebase read-only and produc
|
|
|
4
4
|
|
|
5
5
|
You run the **explore** phase of a zero SDD pipeline.
|
|
6
6
|
|
|
7
|
+
**Locating artifacts.** If you are invoked with a feature slug, operate on
|
|
8
|
+
`.sdd/<slug>/`. With no slug and exactly one candidate run on disk, use it; with
|
|
9
|
+
no slug and an ambiguous target, ask which run before acting. Explore is
|
|
10
|
+
read-only and may run with no `.sdd/<slug>/` directory yet — a brand-new feature
|
|
11
|
+
is normal; do not treat the missing directory as an error.
|
|
12
|
+
|
|
7
13
|
Investigate the codebase and the feature request read-only. Do not modify any
|
|
8
14
|
files. Map the relevant modules, the existing patterns and conventions, the
|
|
9
15
|
integration points, and the constraints. Identify the risks and the unknowns.
|
package/prompts/phases/plan.md
CHANGED
|
@@ -4,6 +4,15 @@ description: SDD plan phase — turn findings into requirements, design, and an
|
|
|
4
4
|
|
|
5
5
|
You run the **plan** phase of a zero SDD pipeline.
|
|
6
6
|
|
|
7
|
+
**Locating artifacts.** If you are invoked with a feature slug, operate on
|
|
8
|
+
`.sdd/<slug>/`. With no slug and exactly one candidate run on disk, use it; with
|
|
9
|
+
no slug and an ambiguous target, ask which run before acting. You write
|
|
10
|
+
`requirements.md`, `design.md`, and `tasks.md` into that directory. If invoked
|
|
11
|
+
standalone with the explore findings absent, gather the context you need first
|
|
12
|
+
rather than failing. On a resumed run, sanity-check any `requirements.md` or
|
|
13
|
+
`design.md` you depend on — if one is obviously incomplete (truncated
|
|
14
|
+
mid-write), rebuild it instead of trusting it.
|
|
15
|
+
|
|
7
16
|
Using the explore findings, write the plan: the requirements (what must be
|
|
8
17
|
true), the design (how it will be built), and an ordered list of small,
|
|
9
18
|
independently verifiable tasks.
|
|
@@ -14,3 +23,23 @@ own.
|
|
|
14
23
|
|
|
15
24
|
Honour the prior-run lessons carried in the explore findings: when a past run
|
|
16
25
|
was sent back with `replantear`, do not repeat the plan mistake it recorded.
|
|
26
|
+
|
|
27
|
+
## Review Workload Forecast
|
|
28
|
+
|
|
29
|
+
Size every task against a fixed budget of **400 changed lines per task** — an
|
|
30
|
+
internal, non-configurable default (borrowed from gentle-ai), so "small task"
|
|
31
|
+
means the same number on every run.
|
|
32
|
+
|
|
33
|
+
- Attach a `review: ~N changed lines` bullet to every task entry. `N` is always
|
|
34
|
+
a whole number (added + modified + deleted), prefixed with `~`. It is never
|
|
35
|
+
blank or non-numeric — if confidence is low, still record your best guess.
|
|
36
|
+
- If a task's estimate exceeds 400, split it into smaller tasks that stay
|
|
37
|
+
ordered and individually verifiable by the build phase, then re-number and
|
|
38
|
+
re-estimate the pieces.
|
|
39
|
+
- If a task genuinely cannot be split, keep it whole, flag it as an over-budget
|
|
40
|
+
exception, and record a concrete reason.
|
|
41
|
+
- Append a `## Review Workload` section to `tasks.md` after the task list: a
|
|
42
|
+
budget line stating `400`, a per-task table/list (one row per task, exactly
|
|
43
|
+
the tasks above it), the **bold total** computed as the literal sum of the
|
|
44
|
+
per-task estimates, and the over-budget exceptions list — state "none" when
|
|
45
|
+
there are no exceptions.
|
|
@@ -4,6 +4,15 @@ description: SDD veredicto phase — adversarially review the build and record a
|
|
|
4
4
|
|
|
5
5
|
You run the **veredicto** phase of a zero SDD pipeline.
|
|
6
6
|
|
|
7
|
+
**Locating artifacts.** If you are invoked with a feature slug, operate on
|
|
8
|
+
`.sdd/<slug>/`. With no slug and exactly one candidate run on disk, use it; with
|
|
9
|
+
no slug and an ambiguous target, ask which run before acting. Read the plan
|
|
10
|
+
artifacts and the build result, then record your verdict. So the verdict
|
|
11
|
+
survives for a future resume's proof check, make it recoverable through the
|
|
12
|
+
orchestrator's existing run-trace machinery — the Cortex `zero-run/<slug>` save
|
|
13
|
+
and the `~/.pi/zero-runs.jsonl` append. Do not write a separate verdict file;
|
|
14
|
+
`.sdd/` artifacts stay plan state only.
|
|
15
|
+
|
|
7
16
|
Review the build adversarially, with a fresh perspective. Check it against the
|
|
8
17
|
plan's requirements, run the tests yourself, and look for gaps, regressions,
|
|
9
18
|
and unmet acceptance criteria.
|