@gonrocca/zero-pi 0.1.26 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -254
- package/extensions/autotune-extension.ts +2 -2
- package/package.json +2 -4
- package/extensions/startup-banner.ts +0 -280
package/README.md
CHANGED
|
@@ -13,294 +13,89 @@
|
|
|
13
13
|
|
|
14
14
|
**The zero spec-driven development workflow, packaged for [pi](https://pi.dev).**
|
|
15
15
|
|
|
16
|
-
[](https://www.npmjs.com/package/@gonrocca/zero-pi)
|
|
17
|
-
[](https://github.com/gonzalonicolasr/zero-pi)
|
|
18
|
-
[](./LICENSE)
|
|
19
|
-
[](https://nodejs.org)
|
|
20
|
-
|
|
21
|
-
**Repositorio:** [github.com/gonzalonicolasr/zero-pi](https://github.com/gonzalonicolasr/zero-pi)
|
|
16
|
+
[](https://www.npmjs.com/package/@gonrocca/zero-pi) [](https://github.com/gonzalonicolasr/zero-pi) [](./LICENSE) [](https://nodejs.org)
|
|
22
17
|
|
|
23
18
|
</div>
|
|
24
19
|
|
|
25
20
|
---
|
|
26
21
|
|
|
27
|
-
zero-pi
|
|
28
|
-
|
|
29
|
-
**without modifying pi
|
|
30
|
-
zero-pi is a package
|
|
31
|
-
ships inside this one package.
|
|
32
|
-
|
|
33
|
-
## Contents
|
|
34
|
-
|
|
35
|
-
- [Install](#-install)
|
|
36
|
-
- [The SDD workflow](#-the-sdd-workflow)
|
|
37
|
-
- [Quality-of-life extensions](#-quality-of-life-extensions)
|
|
38
|
-
- [Commands](#-commands)
|
|
39
|
-
- [Environment variables](#-environment-variables)
|
|
40
|
-
- [Files it reads & writes](#-files-it-reads--writes)
|
|
41
|
-
- [Relationship to `zero`](#-relationship-to-zero)
|
|
42
|
-
- [Development](#-development)
|
|
43
|
-
|
|
44
|
-
---
|
|
22
|
+
zero-pi is an installable **[pi](https://pi.dev)** package that adds a
|
|
23
|
+
disciplined spec-driven development pipeline — plus a handful of quality-of-life
|
|
24
|
+
extensions — **without modifying pi**. Same idea as `gentle-pi`: pi stays
|
|
25
|
+
untouched; zero-pi is a package it loads.
|
|
45
26
|
|
|
46
27
|
## 📦 Install
|
|
47
28
|
|
|
48
29
|
```
|
|
49
30
|
pi install npm:@gonrocca/zero-pi
|
|
31
|
+
pi install npm:pi-subagents # required — the pipeline delegates to sub-agents
|
|
50
32
|
```
|
|
51
33
|
|
|
52
|
-
|
|
53
|
-
skills, theme, and extensions available in every pi session.
|
|
54
|
-
|
|
55
|
-
| Requirement | Detail |
|
|
56
|
-
| ----------- | ------ |
|
|
57
|
-
| `pi-subagents` | **Required** — the SDD pipeline delegates each phase to a sub-agent. Install with `pi install npm:pi-subagents`. |
|
|
58
|
-
| Node | ≥ 20.6 — pi loads the TypeScript extensions directly. |
|
|
59
|
-
| After upgrade | Restart pi for the new version to take effect. |
|
|
60
|
-
|
|
61
|
-
Remove it with `pi remove npm:@gonrocca/zero-pi`.
|
|
62
|
-
|
|
63
|
-
---
|
|
34
|
+
Needs Node ≥ 20.6. Restart pi after an upgrade.
|
|
64
35
|
|
|
65
|
-
## 🛠
|
|
36
|
+
## 🛠 `/forge` — the SDD pipeline
|
|
66
37
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
A spec-driven development run, driven through four phases in order:
|
|
38
|
+
The core of zero-pi. Run **`/forge <feature>`** and the orchestrator drives the
|
|
39
|
+
work through four phases, each delegated to its own sub-agent:
|
|
70
40
|
|
|
71
41
|
| Phase | Does |
|
|
72
42
|
| ----- | ---- |
|
|
73
43
|
| **explore** | Investigate the codebase read-only; produce findings. |
|
|
74
44
|
| **plan** | Write requirements, design, and an ordered task list. |
|
|
75
45
|
| **build** | Implement the plan. |
|
|
76
|
-
| **veredicto** | Review
|
|
77
|
-
|
|
78
|
-
Start it with `/forge <feature>`. The orchestrator drives phase order and
|
|
79
|
-
enforces a hard build/veredicto iteration cap — a `corregir` verdict re-runs
|
|
80
|
-
`build`, a `replantear` re-runs `plan`, and when the cap is reached without a
|
|
81
|
-
`pasa` the run stops and reports the result as **not verified**.
|
|
82
|
-
`/forge --continue [slug]` resumes an interrupted run from its `.sdd/<slug>/`
|
|
83
|
-
artifacts.
|
|
84
|
-
|
|
85
|
-
A run can also start from natural language: describe the work and signal SDD
|
|
86
|
-
intent — "hacelo con sdd", "usá el pipeline" — and the `sdd-routing` skill
|
|
87
|
-
routes it into `/forge`. It triggers only on a clear signal phrase; `/forge`
|
|
88
|
-
stays the primary, explicit entry point.
|
|
89
|
-
|
|
90
|
-
### Language & output
|
|
91
|
-
|
|
92
|
-
A `/forge` run reads as a short, calm progress stream, in Spanish:
|
|
93
|
-
|
|
94
|
-
- **Language Boundary** — every user-facing message is in Spanish (Rioplatense
|
|
95
|
-
voseo); sub-agent briefs stay in English for token efficiency; identifiers
|
|
96
|
-
(verdicts, slugs, paths, model ids, commands) are kept verbatim.
|
|
97
|
-
- **Output Contract** — each phase reports a bounded summary
|
|
98
|
-
(`Estado` / `Resumen` / `Artefactos` / `Siguiente`), never free-form prose.
|
|
99
|
-
No raw tool output, file dumps, sub-agent listings, or triple-backtick code
|
|
100
|
-
fences reach the chat. The phase-start line names the model and provider the
|
|
101
|
-
phase runs on plus a brief gloss of what it does — so a slow phase reads as
|
|
102
|
-
working, not frozen. The run ends stating `verificado` or `no verificado`.
|
|
103
|
-
|
|
104
|
-
### Review Workload Forecast
|
|
105
|
-
|
|
106
|
-
The `plan` phase keeps tasks reviewable. Every planned task carries a
|
|
107
|
-
`review: ~N changed lines` estimate, and `tasks.md` gains a `## Review Workload`
|
|
108
|
-
section with a bold run total. Tasks are sized against a fixed budget of
|
|
109
|
-
**400 changed lines per task** (borrowed from gentle-ai). A task over budget is
|
|
110
|
-
split; one that genuinely cannot be split stays whole and is recorded as an
|
|
111
|
-
over-budget exception with a reason.
|
|
112
|
-
|
|
113
|
-
### SDD sub-agents — `sdd-agents.ts`
|
|
114
|
-
|
|
115
|
-
`pi-subagents` discovers agents from `~/.pi/agent/agents/**/*.md`, but a
|
|
116
|
-
`pi install` ships only the phase *prompts*. This extension closes the gap: on
|
|
117
|
-
every load it generates the four `zero-<phase>` agent files under
|
|
118
|
-
`~/.pi/agent/agents/zero/` from the package's phase prompts and the per-phase
|
|
119
|
-
models in `~/.pi/zero.json`, so they stay in sync with the prompts and with
|
|
120
|
-
`/zero-models`.
|
|
121
|
-
|
|
122
|
-
### Per-phase models — `/zero-models`
|
|
123
|
-
|
|
124
|
-
A real pi command — a code handler, not an LLM prompt — for the SDD models. Run
|
|
125
|
-
it with no argument for the **provider-aware** interactive picker
|
|
126
|
-
(**phase → provider → model**, sourced from pi's model registry), or set one
|
|
127
|
-
directly:
|
|
128
|
-
|
|
129
|
-
```
|
|
130
|
-
/zero-models interactive picker
|
|
131
|
-
/zero-models build=claude-opus-4-7 set one phase
|
|
132
|
-
/zero-models build=codex/gpt-5-codex set one phase with an explicit provider
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
It reads and writes `~/.pi/zero.json` — a `models` map and a parallel
|
|
136
|
-
`providers` map. The orchestrator picks the change up on the next `/forge` run.
|
|
137
|
-
|
|
138
|
-
### Run memory — Cortex
|
|
139
|
-
|
|
140
|
-
Every run reads from and writes to Cortex, the persistent-memory MCP server.
|
|
141
|
-
Before exploring, the orchestrator recalls prior `zero-run/*` traces; when the
|
|
142
|
-
run ends it saves a run-trace — verdict, correction rounds, gotchas — under
|
|
143
|
-
`topic_key: zero-run/<slug>`. The next run starts from what the last one
|
|
144
|
-
learned. With `--no-mcp`, or when Cortex is unreachable, the loop degrades
|
|
145
|
-
silently and never blocks a run.
|
|
146
|
-
|
|
147
|
-
### Canonical specs & `/zero-sync`
|
|
148
|
-
|
|
149
|
-
zero keeps a **canonical, project-wide spec store** that accumulates accepted
|
|
150
|
-
requirements across runs, so each `/forge` run builds on the last.
|
|
151
|
-
|
|
152
|
-
- **The store** — `.sdd/specs/requirements.md`: a `# ` title followed by
|
|
153
|
-
`### REQ: <name>` blocks. The `plan` phase reads it as the baseline.
|
|
154
|
-
- **The plan artifacts** — every run's `plan` phase writes `proposal.md`,
|
|
155
|
-
`spec.md` (the **delta**: `## ADDED` / `## MODIFIED` / `## REMOVED`),
|
|
156
|
-
`design.md`, and `tasks.md` into `.sdd/<slug>/`.
|
|
157
|
-
- **`/zero-sync <slug>`** — a deterministic, unit-tested merge that folds the
|
|
158
|
-
delta into the store atomically. Guardrails reject a bad delta before
|
|
159
|
-
anything is written. The orchestrator invokes it automatically after a `pasa`
|
|
160
|
-
verdict — never on `corregir`, `replantear`, or a cap-reached run.
|
|
161
|
-
- **The archive** — `.sdd/archive/<YYYY-MM-DD>-<slug>/`: an append-only audit
|
|
162
|
-
trail of every sync.
|
|
163
|
-
|
|
164
|
-
### Adaptive model profiles — autotune
|
|
165
|
-
|
|
166
|
-
zero learns which model fits each SDD phase from your own run history.
|
|
167
|
-
|
|
168
|
-
- **The metrics log** — `~/.pi/zero-runs.jsonl`: every completed run appends one
|
|
169
|
-
JSON line (slug, per-phase models, verdict, round count, per-round verdict
|
|
170
|
-
sequence). Append-only.
|
|
171
|
-
- **Phase attribution** — a `corregir` round blames `build`, a `replantear`
|
|
172
|
-
blames `plan`. Autotune upgrades **only the phase at fault**, one tier at a
|
|
173
|
-
time. `explore` and `veredicto` are never tuned.
|
|
174
|
-
- **Cross-machine sync** — each run pushes its metrics to Cortex; `/forge`
|
|
175
|
-
pulls the shared log back, so autotune sees runs from other machines too.
|
|
46
|
+
| **veredicto** | Review it adversarially and record a verdict. |
|
|
176
47
|
|
|
177
|
-
The `
|
|
48
|
+
The verdict is `pasa` (done), `corregir` (re-run build), or `replantear`
|
|
49
|
+
(re-run plan). A hard iteration cap bounds the build↔veredicto loop — reached
|
|
50
|
+
without a `pasa`, the run stops and is reported as **not verified**.
|
|
51
|
+
`/forge --continue [slug]` resumes an interrupted run.
|
|
178
52
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
| `off` | Records run metrics, but never changes or recommends anything. |
|
|
53
|
+
The run speaks **Spanish**, in a bounded, low-noise format — one short summary
|
|
54
|
+
per phase, naming the model that phase runs on, no raw tool output. Or just
|
|
55
|
+
describe the work and say "hacelo con sdd": the `sdd-routing` skill routes it
|
|
56
|
+
into `/forge` for you.
|
|
184
57
|
|
|
185
|
-
|
|
186
|
-
recommendation shows as a leading `★ aplicar sugerencia` entry in `/zero-models`.
|
|
58
|
+
## ✨ What else it adds
|
|
187
59
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
Gives the agent a closed learning loop — distill a reusable skill from a
|
|
191
|
-
substantial task, store it, surface relevant skills on a new task, refine an
|
|
192
|
-
existing skill instead of duplicating it — so solutions are reused, not
|
|
193
|
-
re-derived.
|
|
194
|
-
|
|
195
|
-
---
|
|
196
|
-
|
|
197
|
-
## ✨ Quality-of-life extensions
|
|
198
|
-
|
|
199
|
-
### Provider guard — `provider-guard.ts`
|
|
200
|
-
|
|
201
|
-
The `anthropic` provider runs two ways: a Claude Pro/Max **subscription** login
|
|
202
|
-
(OAuth, via `/login` — `pi-claude-oauth-adapter` smooths this path) or an **API
|
|
203
|
-
key**, which bills per token from your metered extra-usage pool. Same provider
|
|
204
|
-
id, different billing. The guard watches model switches and reads pi's auth mode
|
|
205
|
-
(`modelRegistry.isUsingOAuth`): when a model runs on `anthropic` with an API key
|
|
206
|
-
it emits a single non-blocking warning suggesting `/login`. On OAuth, or on any
|
|
207
|
-
other provider, it stays silent.
|
|
208
|
-
|
|
209
|
-
### Startup banner — `startup-banner.ts`
|
|
210
|
-
|
|
211
|
-
Renders the `ZERO` wordmark as ASCII-safe Tetris cells in pure ANSI 24-bit
|
|
212
|
-
colour, no runtime dependencies. The cells assemble from the bottom up, settle,
|
|
213
|
-
then run a short **sparkle pass** — a few cells glint bright for about a second.
|
|
214
|
-
The render runs synchronously before pi draws its UI.
|
|
215
|
-
|
|
216
|
-
| `ZERO_BANNER` | Effect |
|
|
217
|
-
| ------------- | ------ |
|
|
218
|
-
| _(unset)_ / `shimmer` | Animated assembly + sparkle, then settle (default) |
|
|
219
|
-
| `static` | Completed banner only, no animation |
|
|
220
|
-
| `off` | Render nothing |
|
|
221
|
-
|
|
222
|
-
Colour is skipped automatically when `NO_COLOR` is set or output is not a TTY.
|
|
223
|
-
|
|
224
|
-
### Working-phrase ticker — `working-phrases.ts`
|
|
225
|
-
|
|
226
|
-
Replaces pi's static `Working...` line with a context-aware, rotating Spanish
|
|
227
|
-
phrase — tool-specific while a tool runs (`Leyendo archivos…`), the SDD phase
|
|
228
|
-
while a sub-agent runs (`Planeando la solución…`), playful verbs while the model
|
|
229
|
-
thinks (`Maquinando…`) — plus a theme-tinted braille spinner.
|
|
230
|
-
|
|
231
|
-
### Conversation resume — `conversation-resume.ts`
|
|
232
|
-
|
|
233
|
-
When pi exits normally, writes a local handoff note at `.pi/zero-resume.md` —
|
|
234
|
-
the exact restore command for the persisted session plus a concise conversation
|
|
235
|
-
tail. Generate it any time with `/zero-resume`. zero-pi creates `.pi/.gitignore`
|
|
236
|
-
so conversation context is never committed. Set `ZERO_RESUME=off` to disable the
|
|
237
|
-
automatic write.
|
|
238
|
-
|
|
239
|
-
### Windows process-tree kill — `win-tree-kill.ts`
|
|
240
|
-
|
|
241
|
-
On Windows, `kill()` terminates only the target process — so aborting a turn can
|
|
242
|
-
leave an orphaned `claude` process streaming (Esc appears to do nothing). This
|
|
243
|
-
extension patches `child_process.spawn` so every later subprocess tree-kills via
|
|
244
|
-
`taskkill /T /F`. No-op on non-Windows. **Keep it enabled on Windows.**
|
|
245
|
-
|
|
246
|
-
### ZERO terminal theme — `themes/zero-sdd.json`
|
|
247
|
-
|
|
248
|
-
A dark, high-contrast pi theme with cyan, amber, mint, rose, and violet accents
|
|
249
|
-
tuned for SDD work. Select it from `/settings`, or set `"theme": "zero-sdd"`.
|
|
250
|
-
|
|
251
|
-
---
|
|
252
|
-
|
|
253
|
-
## ⌨️ Commands
|
|
254
|
-
|
|
255
|
-
| Command | What it does |
|
|
60
|
+
| Feature | What it does |
|
|
256
61
|
| ------- | ------------ |
|
|
257
|
-
|
|
|
258
|
-
|
|
|
259
|
-
|
|
|
260
|
-
|
|
|
261
|
-
|
|
|
262
|
-
|
|
|
62
|
+
| **`/zero-models`** | Pick the model + provider for each SDD phase — interactive or direct. |
|
|
63
|
+
| **Autotune** | Learns which model fits each phase from your run history and re-tunes itself. |
|
|
64
|
+
| **`/zero-sync`** | Folds each run's spec delta into a canonical, project-wide spec store. |
|
|
65
|
+
| **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
|
|
66
|
+
| **Provider guard** | Warns when the `anthropic` provider runs on a metered API key instead of your subscription. |
|
|
67
|
+
| **Working-phrase ticker** | Swaps pi's `Working...` for a context-aware Spanish phrase + spinner. |
|
|
68
|
+
| **Conversation resume** | Writes `.pi/zero-resume.md` on exit — the restore command + a conversation tail. |
|
|
69
|
+
| **Windows tree-kill** | Aborting a turn kills the whole process tree — no orphaned `claude`. |
|
|
70
|
+
| **Skill auto-learning** | Distills reusable skills from substantial tasks and surfaces them later. |
|
|
71
|
+
| **`zero-sdd` theme** | A dark, high-contrast pi theme tuned for SDD work. |
|
|
263
72
|
|
|
264
|
-
##
|
|
73
|
+
## ⌨️ Commands
|
|
265
74
|
|
|
266
|
-
|
|
|
267
|
-
|
|
|
268
|
-
|
|
|
269
|
-
| `
|
|
270
|
-
|
|
|
75
|
+
| Command | Does |
|
|
76
|
+
| ------- | ---- |
|
|
77
|
+
| `/forge <feature>` | Run the SDD pipeline — `--continue [slug]` resumes. |
|
|
78
|
+
| `/zero-models [<phase>=[<provider>/]<model>]` | Show or set per-phase models — `autotune=auto\|ask\|off`. |
|
|
79
|
+
| `/zero-sync <slug>` | Fold a run's delta into the canonical spec store. |
|
|
80
|
+
| `/zero-resume` | Write the session handoff note now. |
|
|
271
81
|
|
|
272
|
-
##
|
|
82
|
+
## 🔧 Configuration
|
|
273
83
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
| `~/.pi/zero-runs.jsonl` | Append-only run-metrics log autotune learns from. |
|
|
278
|
-
| `~/.pi/agent/agents/zero/` | Generated `zero-<phase>` sub-agent files. |
|
|
279
|
-
| `.sdd/<slug>/` | Per-run plan artifacts. |
|
|
280
|
-
| `.sdd/specs/requirements.md` | The canonical, project-wide spec store. |
|
|
281
|
-
| `.sdd/archive/` | Append-only audit trail of every `/zero-sync`. |
|
|
282
|
-
| `.pi/zero-resume.md` | Local session handoff note. |
|
|
84
|
+
zero-pi keeps its state in `~/.pi/zero.json` (per-phase models + autotune mode)
|
|
85
|
+
and `~/.pi/zero-runs.jsonl` (the run-metrics log); per-project artifacts live
|
|
86
|
+
under `.sdd/`. Set `ZERO_RESUME=off` to disable the conversation-resume note.
|
|
283
87
|
|
|
284
88
|
## 🔗 Relationship to `zero`
|
|
285
89
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
directly with `pi install npm:@gonrocca/zero-pi` if you only want the pi layer.
|
|
90
|
+
zero-pi is the pi layer of the **zero** integrator. The `zero` CLI installs it
|
|
91
|
+
onto pi and writes the per-phase model config; you can also install zero-pi on
|
|
92
|
+
its own with the command above.
|
|
290
93
|
|
|
291
|
-
##
|
|
94
|
+
## Development
|
|
292
95
|
|
|
293
96
|
Dependency-free, no build step — pi loads the TypeScript extensions directly.
|
|
294
|
-
Run the test suite with
|
|
97
|
+
Run the test suite with `npm test`.
|
|
295
98
|
|
|
296
|
-
|
|
297
|
-
npm test
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
---
|
|
99
|
+
## License
|
|
301
100
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
MIT © Gonzalo Rocca
|
|
305
|
-
|
|
306
|
-
</div>
|
|
101
|
+
MIT © Gonzalo Rocca · [github.com/gonzalonicolasr/zero-pi](https://github.com/gonzalonicolasr/zero-pi)
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
//
|
|
16
16
|
// All decisions live in `autotune.ts`; this file only reads files, calls those
|
|
17
17
|
// functions, and applies/notifies. The whole handler is wrapped in a swallowing
|
|
18
|
-
// `try/catch` —
|
|
19
|
-
//
|
|
18
|
+
// `try/catch` — a failure must never break a pi session. The package stays
|
|
19
|
+
// dependency-free: `node:fs`/`node:os`/`node:path`
|
|
20
20
|
// only, plus minimal local interfaces for the pi API.
|
|
21
21
|
|
|
22
22
|
import { readFileSync, writeFileSync } from "node:fs";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow,
|
|
3
|
+
"version": "0.1.28",
|
|
4
|
+
"description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow, per-phase model autotune, and skill auto-learning. Adds capability to pi without modifying pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi",
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"./themes"
|
|
24
24
|
],
|
|
25
25
|
"extensions": [
|
|
26
|
-
"./extensions/startup-banner.ts",
|
|
27
26
|
"./extensions/working-phrases.ts",
|
|
28
27
|
"./extensions/win-tree-kill.ts",
|
|
29
28
|
"./extensions/sdd-agents.ts",
|
|
@@ -38,7 +37,6 @@
|
|
|
38
37
|
"prompts",
|
|
39
38
|
"skills",
|
|
40
39
|
"themes",
|
|
41
|
-
"extensions/startup-banner.ts",
|
|
42
40
|
"extensions/working-phrases.ts",
|
|
43
41
|
"extensions/win-tree-kill.ts",
|
|
44
42
|
"extensions/sdd-agents.ts",
|
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
// zero-pi - animated startup banner.
|
|
2
|
-
//
|
|
3
|
-
// A pi extension that renders the ZERO wordmark as ASCII-safe Tetris cells.
|
|
4
|
-
// The animated mode progressively assembles the wordmark from the bottom up,
|
|
5
|
-
// then settles into the complete banner. The extension stays dependency-free
|
|
6
|
-
// and keeps the historical ZERO_BANNER controls.
|
|
7
|
-
|
|
8
|
-
type RGB = [number, number, number];
|
|
9
|
-
|
|
10
|
-
const COLORS: Record<string, RGB> = {
|
|
11
|
-
cyan: [80, 210, 255],
|
|
12
|
-
blue: [116, 151, 255],
|
|
13
|
-
mint: [79, 221, 171],
|
|
14
|
-
amber: [238, 190, 92],
|
|
15
|
-
rose: [255, 106, 122],
|
|
16
|
-
violet: [175, 138, 255],
|
|
17
|
-
peak: [255, 245, 218],
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/** Number of rows in every glyph. */
|
|
21
|
-
const ROWS = 6;
|
|
22
|
-
|
|
23
|
-
// Five-cell-wide glyphs. Non-space characters become visible Tetris cells.
|
|
24
|
-
const FONT: Record<string, string[]> = {
|
|
25
|
-
Z: ["ZZZZZ", " ZZ", " ZZ ", " ZZ ", "ZZ ", "ZZZZZ"],
|
|
26
|
-
E: ["EEEEE", "EE ", "EEEE ", "EE ", "EE ", "EEEEE"],
|
|
27
|
-
R: ["RRRR ", "RR RR", "RRRR ", "RR RR", "RR R", "RR R"],
|
|
28
|
-
O: [" OOO ", "OO OO", "OO OO", "OO OO", "OO OO", " OOO "],
|
|
29
|
-
P: ["PPPP ", "PP PP", "PP PP", "PPPP ", "PP ", "PP "],
|
|
30
|
-
I: ["IIIII", " II ", " II ", " II ", " II ", "IIIII"],
|
|
31
|
-
"-": [" ", " ", "-----", " ", " ", " "],
|
|
32
|
-
" ": [" ", " ", " ", " ", " ", " "],
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const PIECE_COLORS: Record<string, RGB> = {
|
|
36
|
-
Z: COLORS.cyan,
|
|
37
|
-
E: COLORS.amber,
|
|
38
|
-
R: COLORS.mint,
|
|
39
|
-
O: COLORS.rose,
|
|
40
|
-
P: COLORS.violet,
|
|
41
|
-
I: COLORS.blue,
|
|
42
|
-
"-": COLORS.amber,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
interface Matrix {
|
|
46
|
-
rows: string[];
|
|
47
|
-
width: number;
|
|
48
|
-
totalCells: number;
|
|
49
|
-
ranks: Map<string, number>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function glyphFor(ch: string): string[] {
|
|
53
|
-
return FONT[ch.toUpperCase()] ?? FONT[" "];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function matrixForText(text: string): Matrix {
|
|
57
|
-
const rows = Array.from({ length: ROWS }, () => "");
|
|
58
|
-
for (const [index, raw] of Array.from(text).entries()) {
|
|
59
|
-
const glyph = glyphFor(raw);
|
|
60
|
-
for (let r = 0; r < ROWS; r++) {
|
|
61
|
-
rows[r] += (index === 0 ? "" : " ") + glyph[r];
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const cells: Array<{ row: number; col: number }> = [];
|
|
66
|
-
for (let row = 0; row < ROWS; row++) {
|
|
67
|
-
for (let col = 0; col < rows[row].length; col++) {
|
|
68
|
-
if (rows[row][col] !== " ") cells.push({ row, col });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Tetris reads as a pile building upward: bottom rows settle first.
|
|
73
|
-
cells.sort((a, b) => b.row - a.row || a.col - b.col);
|
|
74
|
-
|
|
75
|
-
const ranks = new Map<string, number>();
|
|
76
|
-
for (const [rank, cell] of cells.entries()) {
|
|
77
|
-
ranks.set(`${cell.row}:${cell.col}`, rank);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
rows,
|
|
82
|
-
width: rows[0]?.length ?? 0,
|
|
83
|
-
totalCells: cells.length,
|
|
84
|
-
ranks,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Lay a word out as six rows of ASCII-safe Tetris cells. */
|
|
89
|
-
export function bannerLines(text = "ZERO"): string[] {
|
|
90
|
-
return matrixForText(text).rows.map((row) =>
|
|
91
|
-
Array.from(row)
|
|
92
|
-
.map((cell) => (cell === " " ? " " : "[]"))
|
|
93
|
-
.join(""),
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function lerp(a: number, b: number, t: number): number {
|
|
98
|
-
return a + (b - a) * t;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
|
|
102
|
-
const k = Math.max(0, Math.min(1, t));
|
|
103
|
-
return [
|
|
104
|
-
Math.round(lerp(c1[0], c2[0], k)),
|
|
105
|
-
Math.round(lerp(c1[1], c2[1], k)),
|
|
106
|
-
Math.round(lerp(c1[2], c2[2], k)),
|
|
107
|
-
];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function ansiFg([r, g, b]: RGB, text: string): string {
|
|
111
|
-
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function colorForPiece(piece: string, row: number, rank: number, active: boolean): RGB {
|
|
115
|
-
if (active) return COLORS.peak;
|
|
116
|
-
const base = PIECE_COLORS[piece] ?? COLORS.blue;
|
|
117
|
-
const heightWarmth = (ROWS - 1 - row) / Math.max(1, ROWS - 1);
|
|
118
|
-
const rankPulse = (rank % 7) / 18;
|
|
119
|
-
return lerpColor(base, COLORS.peak, heightWarmth * 0.18 + rankPulse);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function paintMatrixLine(
|
|
123
|
-
matrix: Matrix,
|
|
124
|
-
row: number,
|
|
125
|
-
visibleCells: number,
|
|
126
|
-
sparkles?: ReadonlySet<string>,
|
|
127
|
-
): string {
|
|
128
|
-
let out = "";
|
|
129
|
-
const line = matrix.rows[row] ?? "";
|
|
130
|
-
for (let col = 0; col < matrix.width; col++) {
|
|
131
|
-
const piece = line[col] ?? " ";
|
|
132
|
-
if (piece === " ") {
|
|
133
|
-
out += " ";
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
const key = `${row}:${col}`;
|
|
137
|
-
const rank = matrix.ranks.get(key) ?? Number.POSITIVE_INFINITY;
|
|
138
|
-
if (rank >= visibleCells) {
|
|
139
|
-
out += " ";
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
// A cell glints when it is the assembly's leading cell or when this
|
|
143
|
-
// sparkle frame picked it.
|
|
144
|
-
const glint = rank === visibleCells - 1 || (sparkles?.has(key) ?? false);
|
|
145
|
-
out += ansiFg(colorForPiece(piece, row, rank, glint), "[]");
|
|
146
|
-
}
|
|
147
|
-
return out;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Pick a handful of distinct settled cells to glint for one sparkle frame. */
|
|
151
|
-
function pickSparkles(matrix: Matrix, count: number, rand: () => number): Set<string> {
|
|
152
|
-
const keys = Array.from(matrix.ranks.keys());
|
|
153
|
-
const chosen = new Set<string>();
|
|
154
|
-
const target = Math.min(Math.max(0, count), keys.length);
|
|
155
|
-
for (let guard = 0; chosen.size < target && guard < target * 8; guard++) {
|
|
156
|
-
chosen.add(keys[Math.floor(rand() * keys.length)]);
|
|
157
|
-
}
|
|
158
|
-
return chosen;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
interface OutStream {
|
|
162
|
-
write(chunk: string): unknown;
|
|
163
|
-
isTTY?: boolean;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Whether colour output is appropriate for the given stream. */
|
|
167
|
-
function shouldColor(stream: OutStream): boolean {
|
|
168
|
-
if (process.env.NO_COLOR) return false;
|
|
169
|
-
if (process.env.FORCE_COLOR) return true;
|
|
170
|
-
return Boolean(stream && stream.isTTY);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** The three banner modes, resolved from the ZERO_BANNER environment variable. */
|
|
174
|
-
export type BannerMode = "off" | "static" | "shimmer";
|
|
175
|
-
|
|
176
|
-
/** Resolve the banner mode from an environment-variable value. */
|
|
177
|
-
export function resolveBannerMode(raw: string | undefined): BannerMode {
|
|
178
|
-
const value = (raw ?? "").trim().toLowerCase();
|
|
179
|
-
if (value === "off" || value === "0" || value === "false" || value === "none") return "off";
|
|
180
|
-
if (value === "static" || value === "plain" || value === "no-animate") return "static";
|
|
181
|
-
return "shimmer";
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Atomics.wait gives us a synchronous sleep without adding a dependency. The
|
|
185
|
-
// banner renders before Pi's TUI takes over, so blocking briefly is intentional.
|
|
186
|
-
const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4));
|
|
187
|
-
function sleepSync(ms: number): void {
|
|
188
|
-
if (ms > 0) Atomics.wait(SLEEP_SLOT, 0, 0, ms);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function easeOutCubic(t: number): number {
|
|
192
|
-
return 1 - Math.pow(1 - t, 3);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Options for {@link renderBanner}. */
|
|
196
|
-
export interface RenderOptions {
|
|
197
|
-
mode?: BannerMode;
|
|
198
|
-
text?: string;
|
|
199
|
-
frames?: number;
|
|
200
|
-
frameMs?: number;
|
|
201
|
-
/** Number of post-settle sparkle frames (shimmer mode). `0` disables it. */
|
|
202
|
-
sparkleFrames?: number;
|
|
203
|
-
/** Delay between sparkle frames, in milliseconds. */
|
|
204
|
-
sparkleMs?: number;
|
|
205
|
-
stream?: OutStream;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Render the ZERO banner synchronously.
|
|
210
|
-
*
|
|
211
|
-
* On a non-colour stream the banner is printed plain. Otherwise the animated
|
|
212
|
-
* mode assembles cells frame by frame, then settles on the completed wordmark.
|
|
213
|
-
*/
|
|
214
|
-
export function renderBanner(options: RenderOptions = {}): void {
|
|
215
|
-
const mode = options.mode ?? resolveBannerMode(process.env.ZERO_BANNER);
|
|
216
|
-
if (mode === "off") return;
|
|
217
|
-
|
|
218
|
-
const stream: OutStream = options.stream ?? process.stdout;
|
|
219
|
-
const matrix = matrixForText(options.text ?? "ZERO");
|
|
220
|
-
|
|
221
|
-
if (!shouldColor(stream)) {
|
|
222
|
-
stream.write("\n" + bannerLines(options.text ?? "ZERO").join("\n") + "\n\n");
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
stream.write("\n" + Array.from({ length: ROWS }, () => "").join("\n") + "\n");
|
|
227
|
-
|
|
228
|
-
// Repaint the six rows in place; optionally with this frame's sparkle set.
|
|
229
|
-
const paintRows = (sparkles?: ReadonlySet<string>): void => {
|
|
230
|
-
stream.write(`\x1b[${ROWS}A\r`);
|
|
231
|
-
for (let r = 0; r < ROWS; r++) {
|
|
232
|
-
stream.write("\x1b[2K" + paintMatrixLine(matrix, r, matrix.totalCells, sparkles) + "\n");
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
if (mode === "shimmer") {
|
|
237
|
-
const frames = Math.max(2, options.frames ?? 42);
|
|
238
|
-
const frameMs = Math.max(1, options.frameMs ?? 18);
|
|
239
|
-
for (let f = 0; f < frames; f++) {
|
|
240
|
-
const t = easeOutCubic(f / (frames - 1));
|
|
241
|
-
const visibleCells = Math.max(1, Math.ceil(matrix.totalCells * t));
|
|
242
|
-
stream.write(`\x1b[${ROWS}A\r`);
|
|
243
|
-
for (let r = 0; r < ROWS; r++) {
|
|
244
|
-
stream.write("\x1b[2K" + paintMatrixLine(matrix, r, visibleCells) + "\n");
|
|
245
|
-
}
|
|
246
|
-
sleepSync(frameMs);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Settle on the completed wordmark.
|
|
251
|
-
paintRows();
|
|
252
|
-
|
|
253
|
-
// Sparkle pass — a few settled cells glint bright each frame, then a clean
|
|
254
|
-
// final settle. Shimmer only; `sparkleFrames: 0` disables it.
|
|
255
|
-
if (mode === "shimmer") {
|
|
256
|
-
const sparkleFrames = Math.max(0, options.sparkleFrames ?? 22);
|
|
257
|
-
const sparkleMs = Math.max(1, options.sparkleMs ?? 36);
|
|
258
|
-
for (let f = 0; f < sparkleFrames; f++) {
|
|
259
|
-
paintRows(pickSparkles(matrix, 3 + (f % 4), Math.random));
|
|
260
|
-
sleepSync(sparkleMs);
|
|
261
|
-
}
|
|
262
|
-
if (sparkleFrames > 0) paintRows();
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
stream.write("\n");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* The pi extension entry point.
|
|
270
|
-
*
|
|
271
|
-
* The pi argument is accepted for API compatibility. The banner is rendered
|
|
272
|
-
* defensively: visual sugar should never break an interactive session.
|
|
273
|
-
*/
|
|
274
|
-
export default function register(_pi?: unknown): void {
|
|
275
|
-
try {
|
|
276
|
-
renderBanner();
|
|
277
|
-
} catch {
|
|
278
|
-
// A banner failure must never break a pi session.
|
|
279
|
-
}
|
|
280
|
-
}
|