@gonrocca/zero-pi 0.1.53 → 0.1.55

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
@@ -74,7 +74,7 @@ into `/forge` for you.
74
74
  | Feature | What it does |
75
75
  | ------- | ------------ |
76
76
  | **Strict TDD** | The build phase drives RED → GREEN → TRIANGULATE → REFACTOR with a TDD Cycle Evidence table; veredicto audits it. On by default, runtime-gated on a test runner; `tdd.mode: "off"` disables it. |
77
- | **`/zero-models`** | Pick the model + provider for each SDD phase — a boxed-window picker, or set one directly. |
77
+ | **`/zero-models`** | Pick the model + provider + thinking level for each SDD phase — a boxed-window picker, or set one directly. |
78
78
  | **Autotune** | Learns which model fits each phase from your run history and re-tunes itself. |
79
79
  | **`/zero-sync` / `/zero-archive`** | Folds each run's spec delta into a canonical, project-wide spec store and archives approved runs. |
80
80
  | **Git / PR / Issues** | `/zero-branch`, `/zero-git-validate`, `/zero-pr`, and `/zero-issue` keep branches and GitHub links audit-ready. |
@@ -92,7 +92,7 @@ into `/forge` for you.
92
92
  | Command | Does |
93
93
  | ------- | ---- |
94
94
  | `/forge <feature>` | Run the SDD pipeline — `--continue [slug]` resumes. |
95
- | `/zero-models [<phase>=[<provider>/]<model>]` | Show or set per-phase models — `autotune=auto\|ask\|off`. |
95
+ | `/zero-models [<phase>=[<provider>/]<model> [thinking=<level>]]` | Show or set per-phase models, providers, and thinking — `thinking=<level>` (`off\|minimal\|low\|medium\|high\|xhigh`); `autotune=auto\|ask\|off`. |
96
96
  | `/zero-sync <slug>` | Fold a run's spec delta into the canonical spec store; a first all-`## ADDED` delta creates the store lazily. |
97
97
  | `/zero-archive <slug>` | Merge an approved run into `.sdd/specs/`, move it to `.sdd/archive/YYYY-MM-DD-<slug>/`, and persist `archivePath`. |
98
98
  | `/zero-validate <slug>` | Validate proposal/spec/design/tasks artifacts, including task schema and per-domain specs. |
@@ -165,9 +165,31 @@ Acceptance criteria:
165
165
 
166
166
  ## 🔧 Configuration
167
167
 
168
- zero-pi keeps its state in `~/.pi/zero.json` (per-phase models + autotune mode)
169
- and `~/.pi/zero-runs.jsonl` (the run-metrics log); per-project artifacts live
170
- under `.sdd/`. Set `ZERO_RESUME=off` to disable the conversation-resume note.
168
+ zero-pi keeps its state in `~/.pi/zero.json` (per-phase `models`, `providers`,
169
+ `thinking`, and autotune mode) and `~/.pi/zero-runs.jsonl` (the run-metrics log);
170
+ per-project artifacts live under `.sdd/`. Set `ZERO_RESUME=off` to disable the
171
+ conversation-resume note.
172
+
173
+ `~/.pi/zero.json` stores three parallel per-phase maps plus the autotune mode:
174
+
175
+ ```json
176
+ {
177
+ "models": { "explore": "claude-haiku-4-5", "build": "claude-sonnet-4-6" },
178
+ "providers": { "explore": "anthropic", "build": "anthropic" },
179
+ "thinking": { "explore": "low", "build": "high" },
180
+ "autotune": "ask"
181
+ }
182
+ ```
183
+
184
+ The `thinking` map sets each phase's pi effort level — one of `off`, `minimal`,
185
+ `low`, `medium`, `high`, `xhigh`. A phase with no entry gets no `thinking:` line
186
+ in its generated sub-agent (no aggressive default). Set it from the picker's
187
+ after-model thinking screen, or directly:
188
+
189
+ ```
190
+ /zero-models build=anthropic/claude-sonnet-4-6 thinking=high
191
+ /zero-models build=anthropic/claude-sonnet-4-6 high # trailing shorthand
192
+ ```
171
193
 
172
194
  `.sdd/config.json` carries the per-project git and TDD settings:
173
195
 
@@ -16,6 +16,9 @@ import { homedir } from "node:os";
16
16
  import { dirname, join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
+ import { isThinkingLevel } from "./zero-models.ts";
20
+ import type { ThinkingLevel } from "./zero-models.ts";
21
+
19
22
  /** The four SDD phases, each backed by a `prompts/phases/<phase>.md`. */
20
23
  export const PHASES = ["explore", "plan", "build", "veredicto"] as const;
21
24
  export type Phase = (typeof PHASES)[number];
@@ -59,6 +62,7 @@ export function buildAgentFile(
59
62
  body: string,
60
63
  description: string,
61
64
  model: string | undefined,
65
+ thinking?: ThinkingLevel,
62
66
  ): string {
63
67
  const front = [
64
68
  "---",
@@ -66,6 +70,11 @@ export function buildAgentFile(
66
70
  `description: ${description || `zero SDD ${phase} phase`}`,
67
71
  ];
68
72
  if (model) front.push(`model: ${model}`);
73
+ // The `thinking:` line sits after the optional `model:` and before
74
+ // `systemPromptMode:`. It is emitted only when a level is present AND valid —
75
+ // defensive: callers already validate, but the file builder must never write
76
+ // a bad level into agent frontmatter.
77
+ if (thinking && isThinkingLevel(thinking)) front.push(`thinking: ${thinking}`);
69
78
  front.push(
70
79
  "systemPromptMode: replace",
71
80
  "inheritProjectContext: true",
@@ -101,6 +110,32 @@ export function phaseModel(data: unknown, phase: Phase): string | undefined {
101
110
  return model;
102
111
  }
103
112
 
113
+ /**
114
+ * Resolve the agent `thinking:` level for a phase from a parsed `zero.json`.
115
+ *
116
+ * The explicit `thinking[phase]` value wins when it is one of the six real pi
117
+ * effort levels. Otherwise a legacy model string of the form `"<model> <level>"`
118
+ * is mined: its trailing whitespace-separated token supplies the level, but
119
+ * only when that token is a valid level. `max`/`ultracode` and any other token
120
+ * resolve to `undefined`, so no `thinking:` line is emitted. Exported for tests.
121
+ */
122
+ export function phaseThinking(data: unknown, phase: Phase): ThinkingLevel | undefined {
123
+ if (!data || typeof data !== "object") return undefined;
124
+ const d = data as {
125
+ thinking?: Record<string, unknown>;
126
+ models?: Record<string, unknown>;
127
+ };
128
+ const direct = d.thinking?.[phase];
129
+ if (isThinkingLevel(direct)) return direct;
130
+ // Legacy recovery: a valid trailing level on "<model> <level>".
131
+ const model = d.models?.[phase];
132
+ if (typeof model === "string" && model.trim().includes(" ")) {
133
+ const tail = model.trim().split(/\s+/).slice(-1)[0];
134
+ if (isThinkingLevel(tail)) return tail;
135
+ }
136
+ return undefined;
137
+ }
138
+
104
139
  /** Read the per-phase model from `~/.pi/zero.json`; `undefined` when absent. */
105
140
  function readPhaseModel(phase: Phase): string | undefined {
106
141
  try {
@@ -113,6 +148,19 @@ function readPhaseModel(phase: Phase): string | undefined {
113
148
  }
114
149
  }
115
150
 
151
+ /** Read the per-phase thinking level from `~/.pi/zero.json`; `undefined` when
152
+ * absent, invalid, or unrecoverable. Mirrors {@link readPhaseModel}. */
153
+ function readPhaseThinking(phase: Phase): ThinkingLevel | undefined {
154
+ try {
155
+ return phaseThinking(
156
+ JSON.parse(readFileSync(join(homedir(), ".pi", "zero.json"), "utf8")),
157
+ phase,
158
+ );
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
116
164
  /**
117
165
  * The pi extension entry point. Generates the four `zero-<phase>` agent files
118
166
  * so `/forge` has real sub-agents to delegate to. Every failure is swallowed —
@@ -131,7 +179,13 @@ export default function register(_pi?: unknown): void {
131
179
  try {
132
180
  const raw = readFileSync(join(phasesDir, `${phase}.md`), "utf8");
133
181
  const { description, body } = splitPhasePrompt(raw);
134
- const file = buildAgentFile(phase, body, description, readPhaseModel(phase));
182
+ const file = buildAgentFile(
183
+ phase,
184
+ body,
185
+ description,
186
+ readPhaseModel(phase),
187
+ readPhaseThinking(phase),
188
+ );
135
189
  writeFileSync(join(agentsDir, `zero-${phase}.md`), file, "utf8");
136
190
  } catch {
137
191
  // A single phase failing must not block the other three.
@@ -12,7 +12,7 @@
12
12
  // new state. Type-only imports are `import type` so `--experimental-strip-types`
13
13
  // erases them with no runtime resolution. Mirrors the `autotune.ts` precedent.
14
14
 
15
- import type { Phase, PhaseModels, PhaseProviders } from "./zero-models.ts";
15
+ import type { Phase, PhaseModels, PhaseProviders, PhaseThinking, ThinkingLevel } from "./zero-models.ts";
16
16
  import type { AutotuneMode } from "./autotune.ts";
17
17
  import type { AutotunePending } from "./autotune-extension.ts";
18
18
 
@@ -24,12 +24,17 @@ const PHASES = ["explore", "plan", "build", "veredicto"] as const;
24
24
  /** The three autotune modes offered on the autotune screen. */
25
25
  const AUTOTUNE_MODES = ["auto", "ask", "off"] as const;
26
26
 
27
+ /** The six real pi effort levels, in ascending order — re-stated locally so
28
+ * the pure module carries no value import. Must stay in lockstep with
29
+ * `THINKING_LEVELS` in `zero-models.ts`. No `max`/`ultracode` aliases. */
30
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
31
+
27
32
  // ---------------------------------------------------------------------------
28
33
  // Screen model
29
34
  // ---------------------------------------------------------------------------
30
35
 
31
36
  /** Which sub-screen the picker is currently showing. */
32
- export type Screen = "main" | "provider" | "model" | "autotune";
37
+ export type Screen = "main" | "provider" | "model" | "thinking" | "autotune";
33
38
 
34
39
  /** One selectable row in the current screen. */
35
40
  export interface MenuEntry {
@@ -43,6 +48,7 @@ export interface MenuEntry {
43
48
  | "custom-provider" // — otro provider (escribir) —
44
49
  | "model" // a concrete model id (model screen)
45
50
  | "custom-model" // — otro modelo (escribir) —
51
+ | "thinking-level" // off | minimal | … | xhigh (thinking screen)
46
52
  | "autotune-mode"; // auto | ask | off (autotune screen)
47
53
  /** The text shown for the row (Spanish, voseo). */
48
54
  label: string;
@@ -60,6 +66,9 @@ export interface StagedEdits {
60
66
  models: PhaseModels;
61
67
  /** From `readProviders` — a mutated copy, parallel to {@link models}. */
62
68
  providers: PhaseProviders;
69
+ /** From `readThinking` — a mutated copy, parallel to {@link models}. A
70
+ * partial map: an absent phase means no thinking level configured. */
71
+ thinking: PhaseThinking;
63
72
  /** The autotune mode, possibly changed from disk. */
64
73
  autotuneMode: AutotuneMode;
65
74
  /** Any phase model/provider changed. */
@@ -95,6 +104,9 @@ export interface PickerState {
95
104
  drillPhase: Phase | null;
96
105
  /** Drill-down context: provider chosen so far (model screen). */
97
106
  drillProvider: string | null;
107
+ /** Drill-down context: the model chosen, awaiting a thinking level
108
+ * (thinking screen). Held out of `edits` until a level commits it. */
109
+ drillModel: string | null;
98
110
  /** When non-null, the component shows an inline text input for this. */
99
111
  textPrompt: { for: "provider" | "model"; label: string } | null;
100
112
  }
@@ -120,15 +132,19 @@ const CUSTOM_MODEL_LABEL = "— otro modelo (escribir) —";
120
132
  /** The save-and-exit row label. */
121
133
  const SAVE_LABEL = "— guardar y salir —";
122
134
 
123
- /** Render a phase's current `provider/model` (provider omitted when empty). */
135
+ /** Render a phase's current `provider/model` (provider omitted when empty),
136
+ * with ` · thinking <level>` appended when a level is staged — mirroring
137
+ * `formatPhases` in `zero-models.ts`. No artifact when no level is set. */
124
138
  function phaseLabel(
125
139
  phase: Phase,
126
140
  models: PhaseModels,
127
141
  providers: PhaseProviders,
142
+ thinking: PhaseThinking,
128
143
  ): string {
129
144
  const provider = providers[phase];
130
145
  const model = provider ? `${provider}/${models[phase]}` : models[phase];
131
- return `${phase} → ${model}`;
146
+ const level = thinking[phase];
147
+ return `${phase} → ${model}${level ? ` · thinking ${level}` : ""}`;
132
148
  }
133
149
 
134
150
  /** Render the `★ aplicar sugerencia` label from the pending adjustments. */
@@ -149,6 +165,7 @@ function applyLabel(pending: readonly AutotunePending[]): string {
149
165
  export function createPickerState(input: {
150
166
  models: PhaseModels;
151
167
  providers: PhaseProviders;
168
+ thinking: PhaseThinking;
152
169
  autotuneMode: AutotuneMode;
153
170
  pending: AutotunePending[];
154
171
  groups: Map<string, string[]>;
@@ -161,6 +178,7 @@ export function createPickerState(input: {
161
178
  edits: {
162
179
  models: { ...input.models },
163
180
  providers: { ...input.providers },
181
+ thinking: { ...input.thinking },
164
182
  autotuneMode: input.autotuneMode,
165
183
  changed: false,
166
184
  autotuneChanged: false,
@@ -171,6 +189,7 @@ export function createPickerState(input: {
171
189
  fallbackModels: input.fallbackModels,
172
190
  drillPhase: null,
173
191
  drillProvider: null,
192
+ drillModel: null,
174
193
  textPrompt: null,
175
194
  };
176
195
  return rebuildEntries(state);
@@ -197,7 +216,12 @@ function mainEntries(state: PickerState): MenuEntry[] {
197
216
  for (const phase of PHASES) {
198
217
  entries.push({
199
218
  kind: "phase",
200
- label: phaseLabel(phase, state.edits.models, state.edits.providers),
219
+ label: phaseLabel(
220
+ phase,
221
+ state.edits.models,
222
+ state.edits.providers,
223
+ state.edits.thinking,
224
+ ),
201
225
  value: phase,
202
226
  });
203
227
  }
@@ -242,6 +266,15 @@ function modelEntries(state: PickerState): MenuEntry[] {
242
266
  return entries;
243
267
  }
244
268
 
269
+ /** Build the rows for the `thinking` screen — the six real pi effort levels. */
270
+ function thinkingEntries(): MenuEntry[] {
271
+ return THINKING_LEVELS.map((level) => ({
272
+ kind: "thinking-level" as const,
273
+ label: level,
274
+ value: level,
275
+ }));
276
+ }
277
+
245
278
  /** Build the rows for the `autotune` screen — the three modes. */
246
279
  function autotuneEntries(): MenuEntry[] {
247
280
  return AUTOTUNE_MODES.map((mode) => ({
@@ -270,6 +303,9 @@ export function rebuildEntries(state: PickerState): PickerState {
270
303
  case "model":
271
304
  state.entries = modelEntries(state);
272
305
  break;
306
+ case "thinking":
307
+ state.entries = thinkingEntries();
308
+ break;
273
309
  case "autotune":
274
310
  state.entries = autotuneEntries();
275
311
  break;
@@ -365,20 +401,34 @@ export function enter(state: PickerState): EnterResult {
365
401
  }
366
402
 
367
403
  case "model": {
368
- if (state.drillPhase !== null) {
404
+ // Selecting a model no longer commits — it stages the model in
405
+ // `drillModel` and advances to the thinking screen, so model + provider
406
+ // + thinking are written together (atomically) only when a level is
407
+ // chosen. An Esc before that leaves `edits` untouched.
408
+ state.drillModel = entry.value;
409
+ state.screen = "thinking";
410
+ state.cursor = 0;
411
+ return { type: "state", state: rebuildEntries(state) };
412
+ }
413
+
414
+ case "thinking-level": {
415
+ // The single commit point: write model + provider + thinking together
416
+ // for the drilled phase, then clear all drill context and return to main.
417
+ const level = entry.value as ThinkingLevel;
418
+ if (state.drillPhase !== null && state.drillModel !== null) {
369
419
  const phase = state.drillPhase;
370
- state.edits.models[phase] = entry.value;
420
+ state.edits.models[phase] = state.drillModel;
371
421
  // `drillProvider` is the provider picked/typed on the provider screen,
372
- // or `null` when the empty-registry skip jumped straight here in
373
- // which case there is no provider, so `""`. (The design's middle
374
- // `resolveProvider` term is unreachable in the pure module: a null
375
- // `drillProvider` only ever co-occurs with an empty `groups`.)
422
+ // or `null` when the empty-registry skip jumped straight to the model
423
+ // screen — in which case there is no provider, so `""`.
376
424
  state.edits.providers[phase] = state.drillProvider ?? "";
425
+ state.edits.thinking[phase] = level;
377
426
  state.edits.changed = true;
378
427
  }
379
428
  state.screen = "main";
380
429
  state.drillPhase = null;
381
430
  state.drillProvider = null;
431
+ state.drillModel = null;
382
432
  state.cursor = 0;
383
433
  return { type: "state", state: rebuildEntries(state) };
384
434
  }
@@ -428,6 +478,10 @@ export function back(state: PickerState): EnterResult {
428
478
  state.screen = "main";
429
479
  state.drillPhase = null;
430
480
  state.drillProvider = null;
481
+ // Clearing `drillModel` here is what makes the model+provider+thinking write
482
+ // atomic: Esc from the thinking screen drops the staged model so no partial
483
+ // edit is ever committed.
484
+ state.drillModel = null;
431
485
  state.textPrompt = null;
432
486
  state.cursor = 0;
433
487
  return { type: "state", state: rebuildEntries(state) };
@@ -470,16 +524,11 @@ export function submitText(state: PickerState, typed: string): PickerState {
470
524
  state.screen = "model";
471
525
  state.cursor = 0;
472
526
  } else {
473
- // prompt.for === "model": commit the typed model into the drilled phase.
474
- if (state.drillPhase !== null) {
475
- const phase = state.drillPhase;
476
- state.edits.models[phase] = value;
477
- state.edits.providers[phase] = state.drillProvider ?? "";
478
- state.edits.changed = true;
479
- }
480
- state.screen = "main";
481
- state.drillPhase = null;
482
- state.drillProvider = null;
527
+ // prompt.for === "model": stage the typed model in `drillModel` and advance
528
+ // to the thinking screen — the commit happens atomically when a level is
529
+ // chosen, never here. Mirrors the `model`-row `enter` path.
530
+ state.drillModel = value;
531
+ state.screen = "thinking";
483
532
  state.cursor = 0;
484
533
  }
485
534
 
@@ -60,6 +60,20 @@ export type PhaseModels = Record<Phase, string>;
60
60
  /** The per-phase provider map — parallel to {@link PhaseModels}. */
61
61
  export type PhaseProviders = Record<Phase, string>;
62
62
 
63
+ /** The six real pi effort levels, in ascending order. The single source of
64
+ * truth for thinking-level validity — no `max`/`ultracode` aliases exist. */
65
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
66
+ /** One of the six real pi effort levels. */
67
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
68
+ /** The per-phase thinking map — partial on purpose: an absent phase means
69
+ * "no thinking configured", which renders to no `thinking:` frontmatter. */
70
+ export type PhaseThinking = Partial<Record<Phase, ThinkingLevel>>;
71
+
72
+ /** Whether a value is one of the six real pi effort levels. */
73
+ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
74
+ return typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value);
75
+ }
76
+
63
77
  /** Fallback models when `~/.pi/zero.json` has none — cheap to explore, strong
64
78
  * to plan and review. */
65
79
  const DEFAULT_MODELS: PhaseModels = {
@@ -105,11 +119,25 @@ export function readModels(data: Record<string, unknown>): PhaseModels {
105
119
  const raw = (data.models ?? {}) as Record<string, unknown>;
106
120
  const models: PhaseModels = { ...DEFAULT_MODELS };
107
121
  for (const phase of PHASES) {
108
- if (typeof raw[phase] === "string") models[phase] = raw[phase] as string;
122
+ if (typeof raw[phase] === "string") models[phase] = stripThinkingSuffix(raw[phase] as string);
109
123
  }
110
124
  return models;
111
125
  }
112
126
 
127
+ /**
128
+ * Strip a single whitespace-separated trailing token from a stored model string
129
+ * only when that token is a valid thinking level (legacy `"<model> <level>"`
130
+ * form). A non-level trailing token is left intact — dropping it would silently
131
+ * lose data — and a plain model id is returned unchanged.
132
+ */
133
+ function stripThinkingSuffix(model: string): string {
134
+ const trimmed = model.trim();
135
+ const idx = trimmed.lastIndexOf(" ");
136
+ if (idx < 0) return model;
137
+ const tail = trimmed.slice(idx + 1);
138
+ return isThinkingLevel(tail) ? trimmed.slice(0, idx).trim() : model;
139
+ }
140
+
113
141
  /**
114
142
  * Extract the per-phase providers from a zero.json object. A missing provider
115
143
  * is an empty string — the consumer resolves or ignores it.
@@ -123,16 +151,83 @@ export function readProviders(data: Record<string, unknown>): PhaseProviders {
123
151
  return providers;
124
152
  }
125
153
 
154
+ /**
155
+ * Extract the per-phase thinking levels from a zero.json object.
156
+ *
157
+ * For each phase the explicit `thinking[phase]` value wins when it is a valid
158
+ * level. Otherwise a legacy model string of the form `"<model> <level>"` is
159
+ * mined: its trailing whitespace-separated token supplies the level, but only
160
+ * when that token is one of the six real levels. The result is a partial map —
161
+ * a phase with no valid or recoverable level is simply absent, never defaulted.
162
+ */
163
+ export function readThinking(data: Record<string, unknown>): PhaseThinking {
164
+ const out: PhaseThinking = {};
165
+ const raw = (data.thinking ?? {}) as Record<string, unknown>;
166
+ const models = data.models as Record<string, unknown> | undefined;
167
+ for (const phase of PHASES) {
168
+ if (isThinkingLevel(raw[phase])) {
169
+ out[phase] = raw[phase];
170
+ continue;
171
+ }
172
+ // Legacy recovery: "<model> <level>" with a valid trailing level.
173
+ const model = models?.[phase];
174
+ if (typeof model === "string") {
175
+ const trimmed = model.trim();
176
+ const tail = trimmed.split(/\s+/).slice(-1)[0];
177
+ if (trimmed.includes(" ") && isThinkingLevel(tail)) out[phase] = tail;
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
126
183
  /** A provider-qualified model assignment from the direct command form. */
127
184
  export interface Assignment {
128
185
  phase: Phase;
129
186
  model: string;
130
187
  provider?: string;
188
+ thinking?: ThinkingLevel;
189
+ }
190
+
191
+ /** The result of mining a thinking token out of an assignment value:
192
+ * the cleaned value plus the level, or the `"invalid"` sentinel when an
193
+ * explicit `thinking=` token names an unknown level. */
194
+ export type ThinkingTokenResult = { value: string; thinking?: ThinkingLevel } | "invalid";
195
+
196
+ /**
197
+ * Mine a thinking level out of an assignment value.
198
+ *
199
+ * Precedence:
200
+ * 1. An explicit `thinking=<level>` token anywhere in the value. When the
201
+ * level is valid it is removed from the value and returned; when it is
202
+ * unknown the whole parse is `"invalid"` so the handler shows usage help.
203
+ * 2. Otherwise a trailing bare `<level>` token — recognized only when it is
204
+ * one of the six real levels; a non-level trailing token stays in the value.
205
+ * 3. Otherwise the value is returned unchanged with no thinking.
206
+ */
207
+ export function parseThinkingToken(value: string): ThinkingTokenResult {
208
+ const tokens = value.trim().split(/\s+/);
209
+ // 1. explicit thinking=<level> anywhere in the value.
210
+ const idx = tokens.findIndex((t) => /^thinking=/i.test(t));
211
+ if (idx >= 0) {
212
+ const level = tokens[idx].slice("thinking=".length);
213
+ if (!isThinkingLevel(level)) return "invalid";
214
+ const rest = tokens.slice(0, idx).concat(tokens.slice(idx + 1));
215
+ return { value: rest.join(" "), thinking: level };
216
+ }
217
+ // 2. trailing bare <level> shorthand.
218
+ if (tokens.length > 1 && isThinkingLevel(tokens[tokens.length - 1])) {
219
+ return { value: tokens.slice(0, -1).join(" "), thinking: tokens[tokens.length - 1] };
220
+ }
221
+ // 3. no thinking token.
222
+ return { value: value.trim() };
131
223
  }
132
224
 
133
225
  /**
134
226
  * Parse a direct `<phase>=<model>` assignment. The value may carry an explicit
135
- * provider as `<provider>/<model>` — the first `/` splits them.
227
+ * provider as `<provider>/<model>` — the first `/` splits them — and an optional
228
+ * thinking level via `thinking=<level>` or a trailing bare `<level>` shorthand.
229
+ * An invalid explicit thinking level makes the whole parse `null` so the handler
230
+ * writes nothing and shows usage help.
136
231
  */
137
232
  export function parseAssignment(arg: string): Assignment | null {
138
233
  const match = arg.trim().match(/^(\w+)\s*[=\s]\s*(.+)$/);
@@ -142,18 +237,40 @@ export function parseAssignment(arg: string): Assignment | null {
142
237
  let value = match[2].trim();
143
238
  if (value === "") return null;
144
239
 
240
+ const parsed = parseThinkingToken(value);
241
+ if (parsed === "invalid") return null;
242
+ value = parsed.value.trim();
243
+ if (value === "") return null;
244
+ const thinking = parsed.thinking;
245
+
145
246
  const slash = value.indexOf("/");
146
247
  if (slash > 0 && slash < value.length - 1) {
147
- return { phase, provider: value.slice(0, slash).trim(), model: value.slice(slash + 1).trim() };
248
+ const out: Assignment = {
249
+ phase,
250
+ provider: value.slice(0, slash).trim(),
251
+ model: value.slice(slash + 1).trim(),
252
+ };
253
+ if (thinking) out.thinking = thinking;
254
+ return out;
148
255
  }
149
- return { phase, model: value };
256
+ const out: Assignment = { phase, model: value };
257
+ if (thinking) out.thinking = thinking;
258
+ return out;
150
259
  }
151
260
 
152
- /** Render the per-phase model map as an aligned `provider/model` block. */
153
- export function formatPhases(models: PhaseModels, providers: PhaseProviders): string {
261
+ /**
262
+ * Render the per-phase model map as an aligned `provider/model` block, with the
263
+ * thinking level appended as ` · thinking <level>` for any phase that has one.
264
+ */
265
+ export function formatPhases(
266
+ models: PhaseModels,
267
+ providers: PhaseProviders,
268
+ thinking: PhaseThinking,
269
+ ): string {
154
270
  return PHASES.map((phase) => {
155
271
  const provider = providers[phase];
156
- const label = provider ? `${provider}/${models[phase]}` : models[phase];
272
+ let label = provider ? `${provider}/${models[phase]}` : models[phase];
273
+ if (thinking[phase]) label += ` · thinking ${thinking[phase]}`;
157
274
  return ` ${phase.padEnd(10)} ${label}`;
158
275
  }).join("\n");
159
276
  }
@@ -541,6 +658,7 @@ export default function register(pi?: PiExtensionAPI): void {
541
658
  const data = readZeroJson();
542
659
  const models = readModels(data);
543
660
  const providers = readProviders(data);
661
+ const thinking = readThinking(data);
544
662
  const groups = providerGroups(ctx.modelRegistry);
545
663
 
546
664
  // Direct form: /zero-models build=claude-opus-4-7
@@ -566,11 +684,23 @@ export default function register(pi?: PiExtensionAPI): void {
566
684
  return;
567
685
  }
568
686
 
687
+ // A malformed explicit `thinking=<level>` gets a targeted usage help
688
+ // and writes nothing — distinct from the generic assignment help.
689
+ const thinkingToken = arg.match(/(?:^|\s)thinking=(\S+)/i);
690
+ if (thinkingToken && !isThinkingLevel(thinkingToken[1])) {
691
+ ctx.ui.notify(
692
+ "uso: thinking=<nivel> (nivel: off | minimal | low | medium | high | xhigh)",
693
+ "warning",
694
+ );
695
+ return;
696
+ }
697
+
569
698
  const assignment = parseAssignment(arg);
570
699
  if (!assignment) {
571
700
  ctx.ui.notify(
572
- "uso: /zero-models —o— /zero-models <fase>=[<provider>/]<modelo> " +
573
- "(fase: explore | plan | build | veredicto) —o— " +
701
+ "uso: /zero-models —o— /zero-models <fase>=[<provider>/]<modelo> [thinking=<nivel>] " +
702
+ "(fase: explore | plan | build | veredicto · " +
703
+ "nivel: off | minimal | low | medium | high | xhigh) —o— " +
574
704
  "/zero-models autotune=<modo>",
575
705
  "warning",
576
706
  );
@@ -581,15 +711,25 @@ export default function register(pi?: PiExtensionAPI): void {
581
711
  assignment.provider ??
582
712
  resolveProvider(ctx.modelRegistry, assignment.model) ??
583
713
  providers[assignment.phase];
584
- writeFileSync(
585
- zeroJsonPath(),
586
- `${JSON.stringify({ ...data, models, providers }, null, 2)}\n`,
587
- "utf8",
588
- );
714
+ // Resolve the thinking map: start from the recovered/persisted levels
715
+ // (legacy suffixes already mined by `readThinking`), apply the new
716
+ // level when given, preserve the prior level when absent. `models` was
717
+ // read via `readModels`, which already stripped any valid legacy
718
+ // suffix — so the written store is normalized (model token only).
719
+ const nextThinking = { ...thinking };
720
+ if (assignment.thinking) nextThinking[assignment.phase] = assignment.thinking;
721
+ const merged: Record<string, unknown> = { ...data, models, providers };
722
+ if (Object.keys(nextThinking).length > 0) merged.thinking = nextThinking;
723
+ else delete merged.thinking;
724
+ writeFileSync(zeroJsonPath(), `${JSON.stringify(merged, null, 2)}\n`, "utf8");
589
725
  const shown = providers[assignment.phase]
590
726
  ? `${providers[assignment.phase]}/${assignment.model}`
591
727
  : assignment.model;
592
- ctx.ui.notify(`zero models: ${assignment.phase} → ${shown}`, "info");
728
+ const level = nextThinking[assignment.phase];
729
+ ctx.ui.notify(
730
+ `zero models: ${assignment.phase} → ${shown}${level ? ` · thinking ${level}` : ""}`,
731
+ "info",
732
+ );
593
733
  return;
594
734
  }
595
735
 
@@ -600,6 +740,7 @@ export default function register(pi?: PiExtensionAPI): void {
600
740
  const initialState = createPickerState({
601
741
  models,
602
742
  providers,
743
+ thinking,
603
744
  autotuneMode,
604
745
  pending,
605
746
  groups,
@@ -618,7 +759,7 @@ export default function register(pi?: PiExtensionAPI): void {
618
759
  // `zero.json` byte-for-byte unchanged, and report the leave-as-is
619
760
  // state — the existing "sin cambios" notification text.
620
761
  ctx.ui.notify(
621
- `zero · modelos SDD (sin cambios):\n${formatPhases(models, providers)}\n` +
762
+ `zero · modelos SDD (sin cambios):\n${formatPhases(models, providers, thinking)}\n` +
622
763
  ` autotune ${autotuneMode}`,
623
764
  "info",
624
765
  );
@@ -634,21 +775,26 @@ export default function register(pi?: PiExtensionAPI): void {
634
775
  models: edits.models,
635
776
  providers: edits.providers,
636
777
  };
778
+ // Persist the staged per-phase thinking map alongside models/
779
+ // providers; omit the key entirely when no phase has a level so the
780
+ // store stays byte-minimal and backward-compatible.
781
+ if (Object.keys(edits.thinking).length > 0) patch.thinking = edits.thinking;
637
782
  if (edits.autotuneChanged) patch.autotune = edits.autotuneMode;
638
783
 
639
784
  const merged = { ...data, ...patch };
785
+ if (Object.keys(edits.thinking).length === 0) delete merged.thinking;
640
786
  if (edits.pendingApplied) delete merged.autotunePending;
641
787
  writeFileSync(zeroJsonPath(), `${JSON.stringify(merged, null, 2)}\n`, "utf8");
642
788
 
643
789
  const summary = [
644
- `zero · modelos SDD guardados:\n${formatPhases(edits.models, edits.providers)}`,
790
+ `zero · modelos SDD guardados:\n${formatPhases(edits.models, edits.providers, edits.thinking)}`,
645
791
  ];
646
792
  summary.push(` autotune ${edits.autotuneMode}`);
647
793
  if (edits.pendingApplied) summary.push("sugerencia aplicada");
648
794
  ctx.ui.notify(summary.join("\n"), "info");
649
795
  } else {
650
796
  ctx.ui.notify(
651
- `zero · modelos SDD (sin cambios):\n${formatPhases(edits.models, edits.providers)}\n` +
797
+ `zero · modelos SDD (sin cambios):\n${formatPhases(edits.models, edits.providers, edits.thinking)}\n` +
652
798
  ` autotune ${edits.autotuneMode}`,
653
799
  "info",
654
800
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow (explore → plan → build → veredicto) with per-phase model autotune and token-efficient batched builds. Adds capability to pi without modifying pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -178,11 +178,19 @@ on the sub-agent to discover it alone.
178
178
  ## Model configuration
179
179
 
180
180
  The per-phase model assignments live in `~/.pi/zero.json`: `models` maps each
181
- phase (`explore`, `plan`, `build`, `veredicto`) to a model id, and the parallel
182
- `providers` map gives the provider that model belongs to. Read that file at the
183
- start of a run and delegate each phase's sub-agent to its configured
184
- provider + model. When the file is absent, a phase is missing, or its provider
185
- entry is empty, fall back to the session's default model.
181
+ phase (`explore`, `plan`, `build`, `veredicto`) to a model id, the parallel
182
+ `providers` map gives the provider that model belongs to, and a parallel
183
+ `thinking` map gives the pi effort level (`off`, `minimal`, `low`, `medium`,
184
+ `high`, `xhigh`) for each phase. Read that file at the start of a run and
185
+ delegate each phase's sub-agent to its configured provider + model. When the
186
+ file is absent, a phase is missing, or its provider entry is empty, fall back to
187
+ the session's default model.
188
+
189
+ The `thinking` map is optional and partial: a phase with no entry simply gets no
190
+ `thinking:` line in its generated `zero-<phase>.md` frontmatter — no aggressive
191
+ default is written. When a phase has a valid level, the generated agent file
192
+ carries a `thinking: <level>` line in its frontmatter (placed after `model:` and
193
+ before `systemPromptMode:`), so the sub-agent runs at that effort level.
186
194
 
187
195
  ## Language Boundary
188
196
 
@@ -392,41 +400,45 @@ Recommended command order for an audit-ready SDD change is: `/zero-branch <slug>
392
400
  - `/zero-git-validate <slug>` checks worktree, branch, remote, `gh auth`, and verdict gating without mutating.
393
401
  - `/zero-archive <slug>` merges approved deltas into `.sdd/specs/` and moves the run to `.sdd/archive/YYYY-MM-DD-<slug>/`.
394
402
 
395
- ## Spec sync & archive
403
+ ## Spec archive
396
404
 
397
405
  The project keeps a **canonical spec store** at `.sdd/specs/requirements.md` —
398
406
  the accepted requirements of every prior run. A `/forge` run's `plan` phase
399
407
  emits a delta `spec.md` against that store; once the run reaches a `pasa`
400
- verdict the delta is folded back into the store.
408
+ verdict the delta is folded back into the store and the run is archived. One
409
+ command does both: **`/zero-archive`**. (The older `/zero-sync` still exists as a
410
+ manual fold-only command, but the pipeline drives `/zero-archive`, which already
411
+ folds the delta itself — never run both for the same run.)
401
412
 
402
413
  **After a `pasa` verdict — and only then.** Alongside the Cortex save and the
403
414
  `zero-runs.jsonl` append, invoke the **`/zero-archive <slug>`** command, passing
404
415
  the run's feature slug explicitly. `/zero-archive` is a real pi command — a
405
- deterministic, unit-tested merge, not a prompt instruction that reads
406
- `.sdd/specs/requirements.md` or per-domain `.sdd/specs/<domain>/requirements.md`
407
- and `.sdd/<slug>/spec.md` or `.sdd/<slug>/specs/<domain>/spec.md`, folds the
408
- delta into the store, writes atomically, moves the run to `.sdd/archive/`, and
409
- records `archivePath` in `links.json`. You only call it; you never edit the store yourself.
410
-
411
- **Never sync on a non-`pasa` outcome.** Do **not** invoke `/zero-sync` for a
412
- `corregir` or `replantear` verdict, or when the iteration cap was reached
413
- without a `pasa` — the store is changed by a `pasa` run only, and no archive
414
- entry is created otherwise. Likewise skip it for a **legacy resumed run** whose
415
- `.sdd/<slug>/` has no `spec.md` (the older artifact shape) that run has no
416
- delta to fold, and `/zero-sync` will report it has nothing to sync.
417
-
418
- **On a guardrail error, surface do not claim a sync.** If `/zero-sync`
416
+ deterministic, unit-tested operation, not a prompt instruction. In one step it
417
+ folds the delta into the store (`.sdd/specs/requirements.md`, or per-domain
418
+ `.sdd/specs/<domain>/requirements.md`), writes the store atomically, moves the
419
+ run to `.sdd/archive/<YYYY-MM-DD>-<slug>/`, and records `archivePath` in
420
+ `links.json`. It guards itself: it refuses to run unless the run's last verdict
421
+ is `pasa`, the worktree is clean (override with `--allow-dirty` when justified),
422
+ and the spec/tasks artifacts validate. Use `--dry-run` to preview the writes
423
+ without touching disk. You only call it; you never edit the store yourself.
424
+
425
+ **Never archive on a non-`pasa` outcome.** Do **not** invoke `/zero-archive`
426
+ for a `corregir` or `replantear` verdict, or when the iteration cap was reached
427
+ without a `pasa` the command refuses it anyway, and no store change or archive
428
+ entry is created. Likewise skip it for a **legacy resumed run** whose
429
+ `.sdd/<slug>/` has no `spec.md` (the older artifact shape): there is no delta to
430
+ fold, so the command reports nothing to archive.
431
+
432
+ **On a guardrail error, surface — do not claim success.** If `/zero-archive`
419
433
  reports a guardrail failure (a duplicate name, an ADDED collision, a MODIFIED or
420
- REMOVED of a missing block, or a malformed store/delta) it writes **nothing**.
421
- Relay the failing requirement name(s) and reason to the user and state plainly
422
- that the canonical store was **not** updated and is out of sync for a manual
423
- fix. The `pasa` verdict still stands — the build shipped; only the store fold
424
- was rejected. Never report the store as updated when `/zero-sync` did not
425
- update it.
426
-
427
- **On success, relay the report.** When `/zero-sync` succeeds it writes the new
428
- store and creates an archive entry at `.sdd/archive/<YYYY-MM-DD>-<slug>/` a
429
- copy of the run's `proposal.md` and `spec.md` plus a `sync.md` report listing
430
- every added, modified, and removed requirement. Include `/zero-sync`'s report in
431
- the run's final summary, calling out the destructive effects (replacements,
432
- deletions) explicitly.
434
+ REMOVED of a missing block, a malformed store/delta, a failed validation, or a
435
+ dirty worktree) it changes **nothing**, and on a mid-write failure it rolls the
436
+ store back. Relay the failing reason to the user and state plainly that the
437
+ canonical store was **not** updated. The `pasa` verdict still stands — the build
438
+ shipped; only the archive step was rejected. Never report the store as updated
439
+ when `/zero-archive` did not update it.
440
+
441
+ **On success, relay the report.** When `/zero-archive` succeeds it writes the
442
+ new store, moves the run to `.sdd/archive/<YYYY-MM-DD>-<slug>/`, and records the
443
+ archive path in `links.json`. Include its report in the run's final summary,
444
+ calling out the destructive effects (replacements, deletions) explicitly.