@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 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 phasePick = await ctx.ui.select(
151
- "zero · modelos SDD — elegí una fase",
152
- [...PHASES.map((p) => `${p} → ${models[p]}`), SAVE_AND_EXIT],
153
- );
244
+ const applyEntry =
245
+ pending.length > 0
246
+ ? `★ aplicar sugerencia: ${pending
247
+ .map((p) => `${p.phase} → ${p.to}`)
248
+ .join(", ")}`
249
+ : null;
250
+ const autotuneEntry = `autotune → ${autotuneMode}`;
251
+
252
+ const phasePick = await ctx.ui.select("zero · modelos SDD — elegí una fase", [
253
+ ...(applyEntry ? [applyEntry] : []),
254
+ ...PHASES.map((p) => `${p} → ${models[p]}`),
255
+ autotuneEntry,
256
+ SAVE_AND_EXIT,
257
+ ]);
154
258
  if (!phasePick || phasePick === SAVE_AND_EXIT) break;
155
259
 
260
+ // Apply the pending autotune suggestion.
261
+ if (applyEntry && phasePick === applyEntry) {
262
+ for (const adj of pending) models[adj.phase] = adj.to;
263
+ changed = true;
264
+ pendingApplied = true;
265
+ pending = [];
266
+ continue;
267
+ }
268
+
269
+ // Change the autotune mode.
270
+ if (phasePick === autotuneEntry) {
271
+ const modePick = await ctx.ui.select(
272
+ "Modo de autotune",
273
+ AUTOTUNE_MODES.map((m) => formatAutotune(m)),
274
+ );
275
+ if (!modePick) continue;
276
+ const picked = parseAutotuneArg(modePick.split(/\s/)[0]);
277
+ if (picked && picked !== autotuneMode) {
278
+ autotuneMode = picked;
279
+ autotuneChanged = true;
280
+ }
281
+ continue;
282
+ }
283
+
156
284
  const phase = phasePick.split(/\s/)[0];
157
285
  if (!isPhase(phase)) break;
158
286
 
@@ -172,11 +300,27 @@ export default function register(pi?: PiExtensionAPI): void {
172
300
  changed = true;
173
301
  }
174
302
 
175
- if (changed) {
176
- writeModels(data, models);
177
- ctx.ui.notify(`zero · modelos SDD guardados:\n${formatModels(models)}`, "info");
303
+ if (changed || autotuneChanged) {
304
+ // Build the patch, preserving every other key via the spread. When
305
+ // the pending suggestion was applied, clear the `autotunePending` key.
306
+ const patch: Record<string, unknown> = { models };
307
+ if (autotuneChanged) patch.autotune = autotuneMode;
308
+ if (pendingApplied) patch.autotunePending = undefined;
309
+
310
+ const merged = { ...data, ...patch };
311
+ if (pendingApplied) delete merged.autotunePending;
312
+ writeFileSync(zeroJsonPath(), `${JSON.stringify(merged, null, 2)}\n`, "utf8");
313
+
314
+ const summary = [`zero · modelos SDD guardados:\n${formatModels(models)}`];
315
+ summary.push(` autotune ${autotuneMode}`);
316
+ if (pendingApplied) summary.push("sugerencia aplicada");
317
+ ctx.ui.notify(summary.join("\n"), "info");
178
318
  } else {
179
- ctx.ui.notify(`zero · modelos SDD (sin cambios):\n${formatModels(models)}`, "info");
319
+ ctx.ui.notify(
320
+ `zero · modelos SDD (sin cambios):\n${formatModels(models)}\n` +
321
+ ` autotune ${autotuneMode}`,
322
+ "info",
323
+ );
180
324
  }
181
325
  } catch (err) {
182
326
  ctx.ui.notify(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.4",
3
+ "version": "0.1.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.
@@ -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.
@@ -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.
@@ -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.