@gonrocca/zero-pi 0.1.9 → 0.1.11

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.
@@ -0,0 +1,295 @@
1
+ // zero-pi — hybrid working-phrase ticker and themed spinner.
2
+ //
3
+ // Pi shows a single static "Working..." line while the agent is busy. This
4
+ // extension replaces it with a context-aware, rotating phrase:
5
+ //
6
+ // • a tool-specific line while a tool runs ("Leyendo archivos…")
7
+ // • an SDD-phase line while a zero sub-agent runs ("Construyendo…")
8
+ // • a rotation of playful verbs while the model thinks ("Maquinando…")
9
+ //
10
+ // It also installs a braille spinner tinted with the active theme.
11
+ //
12
+ // Built only on pi's public extension API — no pi internals are touched. Every
13
+ // handler is defensive: visual sugar must never break an interactive session.
14
+
15
+ /** A pi theme — only the colouring helper is used here. */
16
+ interface Theme {
17
+ fg(color: string, text: string): string;
18
+ }
19
+
20
+ /** The slice of pi's UI API this extension drives. */
21
+ interface PiUI {
22
+ theme: Theme;
23
+ setWorkingMessage(message?: string): void;
24
+ setWorkingIndicator(options?: { frames: string[]; intervalMs?: number }): void;
25
+ }
26
+
27
+ interface Ctx {
28
+ ui: PiUI;
29
+ }
30
+
31
+ /** The subset of the extension API this extension subscribes to. */
32
+ interface PiAPI {
33
+ on(event: string, handler: (event: unknown, ctx: Ctx) => unknown): void;
34
+ }
35
+
36
+ // ── Phrase pools ───────────────────────────────────────────────────────────
37
+
38
+ /** Playful verbs shown while the model is thinking on ordinary work. */
39
+ const THINKING_PLAYFUL = [
40
+ "Maquinando",
41
+ "Rumiando",
42
+ "Pensando",
43
+ "Cocinando",
44
+ "Tramando",
45
+ "Conjurando",
46
+ "Cavilando",
47
+ "Hilando ideas",
48
+ "Procesando",
49
+ "Razonando",
50
+ "Atando cabos",
51
+ "Calibrando",
52
+ ];
53
+
54
+ /** Thinking verbs biased toward SDD work — used once an SDD run is detected. */
55
+ const THINKING_SDD = [
56
+ "Explorando ideas",
57
+ "Trazando el plan",
58
+ "Puliendo el diseño",
59
+ "Revisando supuestos",
60
+ "Ordenando las tareas",
61
+ "Cuadrando el spec",
62
+ "Maquinando",
63
+ "Razonando",
64
+ ];
65
+
66
+ /** SDD phase labels keyed by the zero sub-agent that owns the phase. */
67
+ const SDD_PHASE: Record<string, string> = {
68
+ "zero-explore": "Explorando el código",
69
+ "zero-plan": "Planeando la solución",
70
+ "zero-build": "Construyendo la implementación",
71
+ "zero-veredicto": "Revisando el veredicto",
72
+ };
73
+
74
+ /** Tool-name patterns → the phrase shown while that kind of tool runs. */
75
+ const TOOL_RULES: Array<[RegExp, string]> = [
76
+ [/^(read|cat|view|open)/i, "Leyendo archivos"],
77
+ [/(multi.?edit|str.?replace|edit|patch|apply)/i, "Aplicando cambios"],
78
+ [/(write|create)/i, "Escribiendo archivos"],
79
+ [/(bash|shell|exec|run|terminal|cmd)/i, "Ejecutando comandos"],
80
+ [/(grep|search|ripgrep|^rg)/i, "Buscando en el código"],
81
+ [/(glob|find|^ls$|list|tree)/i, "Rastreando archivos"],
82
+ [/(web|fetch|http|url|browse|curl)/i, "Navegando la web"],
83
+ [/(subagent|custom-agent|^task$|agent)/i, "Coordinando sub-agentes"],
84
+ [/(todo|plan)/i, "Ordenando el plan"],
85
+ ];
86
+
87
+ // ── Pure helpers (exported for tests) ──────────────────────────────────────
88
+
89
+ /** Resolve the phrase for a tool by its name; falls back to the raw name. */
90
+ export function toolPhrase(toolName: string): string {
91
+ const name = (toolName ?? "").trim();
92
+ if (name === "") return "Trabajando";
93
+ // MCP tools come through as `mcp__server__tool` — name the server, not pi.
94
+ if (name.includes("__") || /^mcp[._-]/i.test(name)) return "Consultando un MCP";
95
+ for (const [pattern, phrase] of TOOL_RULES) {
96
+ if (pattern.test(name)) return phrase;
97
+ }
98
+ return `Usando ${name}`;
99
+ }
100
+
101
+ /**
102
+ * If a sub-agent invocation targets a zero SDD phase agent, return that phase's
103
+ * label; otherwise return `null`. Accepts the single, chain, and parallel
104
+ * shapes of the `subagent` tool's arguments.
105
+ */
106
+ export function sddPhase(args: unknown): string | null {
107
+ const names = new Set<string>();
108
+ const collect = (value: unknown): void => {
109
+ if (typeof value === "string") names.add(value);
110
+ };
111
+ if (args && typeof args === "object") {
112
+ const a = args as Record<string, unknown>;
113
+ collect(a.agent);
114
+ for (const key of ["chain", "tasks"] as const) {
115
+ const list = a[key];
116
+ if (Array.isArray(list)) {
117
+ for (const entry of list) {
118
+ if (entry && typeof entry === "object") {
119
+ const e = entry as Record<string, unknown>;
120
+ collect(e.agent);
121
+ if (Array.isArray(e.parallel)) {
122
+ for (const p of e.parallel) {
123
+ if (p && typeof p === "object") collect((p as Record<string, unknown>).agent);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ for (const name of names) {
132
+ const key = name.toLowerCase().split(".").pop() ?? name;
133
+ if (SDD_PHASE[key]) return SDD_PHASE[key];
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /** Whether a piece of user input signals an SDD run is starting. */
139
+ export function looksLikeSdd(text: string): boolean {
140
+ return /\/forge\b|\bforge\b|\bsdd\b|zero[\s-]?sdd/i.test(text ?? "");
141
+ }
142
+
143
+ /** Pick a thinking phrase, avoiding an immediate repeat of `previous`. */
144
+ export function pickThinking(previous: string | undefined, sddBias: boolean): string {
145
+ const pool = sddBias ? THINKING_SDD : THINKING_PLAYFUL;
146
+ const choices = pool.length > 1 ? pool.filter((p) => p !== previous) : pool;
147
+ return choices[Math.floor(Math.random() * choices.length)];
148
+ }
149
+
150
+ // ── Spinner ────────────────────────────────────────────────────────────────
151
+
152
+ /** Braille spinner glyphs, in rotation order. */
153
+ const SPIN_GLYPHS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
154
+
155
+ /** A per-frame colour cycle that makes the spinner gently breathe. */
156
+ const SPIN_COLORS = ["dim", "muted", "accent", "accent", "accent", "muted"];
157
+
158
+ /** Build the themed spinner frames. */
159
+ export function spinnerFrames(theme: Theme): string[] {
160
+ return SPIN_GLYPHS.map((glyph, i) => {
161
+ const color = SPIN_COLORS[i % SPIN_COLORS.length];
162
+ try {
163
+ return theme.fg(color, glyph);
164
+ } catch {
165
+ return glyph;
166
+ }
167
+ });
168
+ }
169
+
170
+ // ── Extension entry point ──────────────────────────────────────────────────
171
+
172
+ export default function register(pi?: PiAPI): void {
173
+ if (!pi || typeof pi.on !== "function") return;
174
+
175
+ // The last UI handle seen on any event — the rotation timer uses it.
176
+ let ui: PiUI | undefined;
177
+ // The phrase-rotation interval; defined only while the agent is busy.
178
+ let timer: ReturnType<typeof setInterval> | undefined;
179
+ // Active tools, keyed by tool-call id, in start order. The most recently
180
+ // started tool's phrase wins the indicator.
181
+ const activeTools = new Map<string, string>();
182
+ // The phrase the model-thinking rotation last produced.
183
+ let lastThinking: string | undefined;
184
+ // Whether the most recent prompt looks like an SDD run.
185
+ let sddBias = false;
186
+
187
+ /** The phrase to show right now, given current state. */
188
+ const currentPhrase = (): string => {
189
+ if (activeTools.size > 0) {
190
+ // Last inserted entry — most recently started tool.
191
+ let phrase = "Trabajando";
192
+ for (const value of activeTools.values()) phrase = value;
193
+ return phrase;
194
+ }
195
+ lastThinking = pickThinking(lastThinking, sddBias);
196
+ return lastThinking;
197
+ };
198
+
199
+ /** Push the current phrase into pi's working indicator. */
200
+ const render = (): void => {
201
+ if (!ui) return;
202
+ try {
203
+ ui.setWorkingMessage(`${currentPhrase()}… (esc)`);
204
+ } catch {
205
+ // Indicator failures must never surface.
206
+ }
207
+ };
208
+
209
+ /** Begin the rotation if it is not already running. */
210
+ const start = (): void => {
211
+ render();
212
+ if (timer) return;
213
+ timer = setInterval(render, 2400);
214
+ // Don't keep the process alive purely for the ticker.
215
+ (timer as { unref?: () => void }).unref?.();
216
+ };
217
+
218
+ /** Stop the rotation and restore pi's default working message. */
219
+ const stop = (): void => {
220
+ if (timer) {
221
+ clearInterval(timer);
222
+ timer = undefined;
223
+ }
224
+ activeTools.clear();
225
+ try {
226
+ ui?.setWorkingMessage();
227
+ } catch {
228
+ // ignore
229
+ }
230
+ };
231
+
232
+ /** Install the themed spinner once a UI handle is available. */
233
+ const installSpinner = (): void => {
234
+ if (!ui) return;
235
+ try {
236
+ ui.setWorkingIndicator({ frames: spinnerFrames(ui.theme), intervalMs: 90 });
237
+ } catch {
238
+ // A spinner failure is harmless — pi keeps its default.
239
+ }
240
+ };
241
+
242
+ const capture = (ctx: Ctx): void => {
243
+ if (ctx && ctx.ui) ui = ctx.ui;
244
+ };
245
+
246
+ pi.on("session_start", (_event, ctx) => {
247
+ capture(ctx);
248
+ installSpinner();
249
+ });
250
+
251
+ pi.on("input", (event, ctx) => {
252
+ capture(ctx);
253
+ const text = (event as { text?: string })?.text ?? "";
254
+ if (text) sddBias = looksLikeSdd(text);
255
+ });
256
+
257
+ pi.on("agent_start", (_event, ctx) => {
258
+ capture(ctx);
259
+ start();
260
+ });
261
+
262
+ pi.on("message_update", (_event, ctx) => {
263
+ capture(ctx);
264
+ });
265
+
266
+ pi.on("tool_execution_start", (event, ctx) => {
267
+ capture(ctx);
268
+ const e = event as { toolCallId?: string; toolName?: string; args?: unknown };
269
+ const id = e.toolCallId ?? `${activeTools.size}`;
270
+ const phrase = sddPhase(e.args) ?? toolPhrase(e.toolName ?? "");
271
+ activeTools.set(id, phrase);
272
+ sddBias = sddBias || sddPhase(e.args) !== null;
273
+ render();
274
+ });
275
+
276
+ pi.on("tool_execution_end", (event, ctx) => {
277
+ capture(ctx);
278
+ const id = (event as { toolCallId?: string }).toolCallId;
279
+ if (id !== undefined) activeTools.delete(id);
280
+ render();
281
+ });
282
+
283
+ pi.on("agent_end", (_event, ctx) => {
284
+ capture(ctx);
285
+ stop();
286
+ });
287
+
288
+ // Tidy up if the session is torn down mid-run.
289
+ for (const lifecycle of ["session_shutdown", "session_before_switch"]) {
290
+ pi.on(lifecycle, (_event, ctx) => {
291
+ capture(ctx);
292
+ stop();
293
+ });
294
+ }
295
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/zero-pi",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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": [
@@ -19,22 +19,33 @@
19
19
  "skills": [
20
20
  "./skills"
21
21
  ],
22
+ "themes": [
23
+ "./themes"
24
+ ],
22
25
  "extensions": [
23
26
  "./extensions/startup-banner.ts",
27
+ "./extensions/working-phrases.ts",
28
+ "./extensions/conversation-resume.ts",
24
29
  "./extensions/zero-models.ts",
25
30
  "./extensions/autotune-extension.ts",
26
- "./extensions/spec-merge-extension.ts"
31
+ "./extensions/spec-merge-extension.ts",
32
+ "./extensions/provider-guard-extension.ts"
27
33
  ]
28
34
  },
29
35
  "files": [
30
36
  "prompts",
31
37
  "skills",
38
+ "themes",
32
39
  "extensions/startup-banner.ts",
40
+ "extensions/working-phrases.ts",
41
+ "extensions/conversation-resume.ts",
33
42
  "extensions/zero-models.ts",
34
43
  "extensions/autotune.ts",
35
44
  "extensions/autotune-extension.ts",
36
45
  "extensions/spec-merge.ts",
37
46
  "extensions/spec-merge-extension.ts",
47
+ "extensions/provider-guard.ts",
48
+ "extensions/provider-guard-extension.ts",
38
49
  "README.md",
39
50
  "LICENSE"
40
51
  ],
@@ -173,7 +173,7 @@ memory" Cortex save above; do both.
173
173
  The line is one `RunRecord` JSON object, serialized with no pretty-printing,
174
174
  followed by a single newline. Build it from facts you already hold:
175
175
 
176
- - `v`: the schema version — always the integer `1`.
176
+ - `v`: the schema version — always the integer `2`.
177
177
  - `ts`: the run-end timestamp, ISO 8601 (e.g. `2026-05-17T14:03:22.000Z`).
178
178
  - `feature`: the SDD feature slug for this run.
179
179
  - `phases`: an object with the four keys `explore`, `plan`, `build`,
@@ -183,11 +183,18 @@ followed by a single newline. Build it from facts you already hold:
183
183
  if the iteration cap was hit without one. No other values.
184
184
  - `rounds`: the number of build/veredicto rounds (`1` for a clean first-pass
185
185
  run).
186
+ - `verdicts`: the ordered per-round verdict sequence — one entry per round, in
187
+ chronological order, accumulated as `veredicto` returns each round's verdict.
188
+ Every entry is one of `"corregir"`, `"replantear"`, or `"pasa"`;
189
+ `"cap-reached"` is a run-level terminal state and never appears inside this
190
+ array. `verdicts.length` must equal `rounds` (one verdict per round,
191
+ including the final cap-reaching round). A `pasa` run ends with exactly one
192
+ `"pasa"`, as the last entry; a `cap-reached` run contains no `"pasa"` at all.
186
193
 
187
194
  Exact one-line shape to emit:
188
195
 
189
196
  ```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}
197
+ {"v":2,"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,"verdicts":["corregir","pasa"]}
191
198
  ```
192
199
 
193
200
  Rules:
@@ -0,0 +1,76 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3
+ "name": "zero-sdd",
4
+ "vars": {
5
+ "cyan": "#50d2ff",
6
+ "blue": "#7497ff",
7
+ "mint": "#4fddab",
8
+ "amber": "#eebe5c",
9
+ "rose": "#ff6a7a",
10
+ "violet": "#af8aff",
11
+ "steel": "#8f98a8",
12
+ "dimSteel": "#5f6878",
13
+ "panel": "#171b22",
14
+ "selected": "#26313d",
15
+ "okPanel": "#17251f",
16
+ "errPanel": "#2a171b"
17
+ },
18
+ "colors": {
19
+ "accent": "cyan",
20
+ "border": "blue",
21
+ "borderAccent": "amber",
22
+ "borderMuted": "dimSteel",
23
+ "success": "mint",
24
+ "error": "rose",
25
+ "warning": "amber",
26
+ "muted": "steel",
27
+ "dim": "dimSteel",
28
+ "text": "",
29
+ "thinkingText": "steel",
30
+ "selectedBg": "selected",
31
+ "userMessageBg": "panel",
32
+ "userMessageText": "",
33
+ "customMessageBg": "panel",
34
+ "customMessageText": "",
35
+ "customMessageLabel": "amber",
36
+ "toolPendingBg": "panel",
37
+ "toolSuccessBg": "okPanel",
38
+ "toolErrorBg": "errPanel",
39
+ "toolTitle": "cyan",
40
+ "toolOutput": "steel",
41
+ "mdHeading": "amber",
42
+ "mdLink": "cyan",
43
+ "mdLinkUrl": "dimSteel",
44
+ "mdCode": "mint",
45
+ "mdCodeBlock": "",
46
+ "mdCodeBlockBorder": "blue",
47
+ "mdQuote": "steel",
48
+ "mdQuoteBorder": "dimSteel",
49
+ "mdHr": "dimSteel",
50
+ "mdListBullet": "amber",
51
+ "toolDiffAdded": "mint",
52
+ "toolDiffRemoved": "rose",
53
+ "toolDiffContext": "steel",
54
+ "syntaxComment": "dimSteel",
55
+ "syntaxKeyword": "violet",
56
+ "syntaxFunction": "cyan",
57
+ "syntaxVariable": "amber",
58
+ "syntaxString": "mint",
59
+ "syntaxNumber": "rose",
60
+ "syntaxType": "blue",
61
+ "syntaxOperator": "violet",
62
+ "syntaxPunctuation": "steel",
63
+ "thinkingOff": "dimSteel",
64
+ "thinkingMinimal": "steel",
65
+ "thinkingLow": "blue",
66
+ "thinkingMedium": "cyan",
67
+ "thinkingHigh": "violet",
68
+ "thinkingXhigh": "rose",
69
+ "bashMode": "amber"
70
+ },
71
+ "export": {
72
+ "pageBg": "#111419",
73
+ "cardBg": "#171b22",
74
+ "infoBg": "#22202a"
75
+ }
76
+ }