@gonrocca/zero-pi 0.1.56 → 0.1.58
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 +7 -3
- package/extensions/format-tokens.ts +14 -0
- package/extensions/zero-cost-extension.ts +59 -0
- package/extensions/zero-cost.ts +220 -0
- package/extensions/zero-doctor-extension.ts +42 -0
- package/extensions/zero-doctor.ts +291 -0
- package/extensions/zero-models.ts +86 -0
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -74,8 +74,10 @@ 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 + thinking level 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. Direct assignments are validated against pi's model registry when available. |
|
|
78
78
|
| **Autotune** | Learns which model fits each phase from your run history and re-tunes itself. |
|
|
79
|
+
| **`/zero-doctor`** | Preflight diagnostics for zero-pi: package install, Node version, pi-subagents, generated phase agents, model config, `.sdd/config`, run history, git, and `gh` auth. |
|
|
80
|
+
| **`/zero-cost`** | Aggregates a run's sub-agent `meta.json` files into a per-phase token/cost/duration report — no schema change, reads what pi already writes. |
|
|
79
81
|
| **`/zero-sync` / `/zero-archive`** | Folds each run's spec delta into a canonical, project-wide spec store and archives approved runs. |
|
|
80
82
|
| **Git / PR / Issues** | `/zero-branch`, `/zero-git-validate`, `/zero-pr`, and `/zero-issue` keep branches and GitHub links audit-ready. |
|
|
81
83
|
| **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
|
|
@@ -84,7 +86,7 @@ into `/forge` for you.
|
|
|
84
86
|
| **Working-phrase ticker** | Swaps pi's `Working...` for a context-aware Spanish phrase + spinner. |
|
|
85
87
|
| **Conversation resume** | Writes `.pi/zero-resume.md` on exit — the restore command + a conversation tail. |
|
|
86
88
|
| **Windows tree-kill** | Aborting a turn kills the whole process tree — no orphaned `claude`. |
|
|
87
|
-
| **
|
|
89
|
+
| **SDD routing skill** | Natural-language requests that say "hacelo con sdd" route into `/forge` without remembering the slash command. |
|
|
88
90
|
| **`zero-sdd` theme** | A dark, high-contrast pi theme tuned for SDD work. |
|
|
89
91
|
| **`zero-sunset` theme** | A warm sunset variant — gold/coral/magenta accents over warm-dark panels, with one cool tone kept for syntax legibility. Activate with `/theme zero-sunset`. |
|
|
90
92
|
|
|
@@ -93,11 +95,13 @@ into `/forge` for you.
|
|
|
93
95
|
| Command | Does |
|
|
94
96
|
| ------- | ---- |
|
|
95
97
|
| `/forge <feature>` | Run the SDD pipeline — `--continue [slug]` resumes. |
|
|
96
|
-
| `/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`. |
|
|
98
|
+
| `/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`. Direct assignments fail fast when pi's registry says the provider/model is unknown or ambiguous. |
|
|
99
|
+
| `/zero-doctor` | Run zero-pi preflight diagnostics: package, Node, pi-subagents, generated agents/support modules, model config, `.sdd/config`, run history, git, and `gh`. |
|
|
97
100
|
| `/zero-sync <slug>` | Fold a run's spec delta into the canonical spec store; a first all-`## ADDED` delta creates the store lazily. |
|
|
98
101
|
| `/zero-archive <slug>` | Merge an approved run into `.sdd/specs/`, move it to `.sdd/archive/YYYY-MM-DD-<slug>/`, and persist `archivePath`. |
|
|
99
102
|
| `/zero-validate <slug>` | Validate proposal/spec/design/tasks artifacts, including task schema and per-domain specs. |
|
|
100
103
|
| `/zero-status` | Show each `.sdd/` run's artifact, sync, latest-verdict, and GitHub-link status. |
|
|
104
|
+
| `/zero-cost [<slug>]` | Report tokens (in/out/cache), USD cost, duration, and tool-count per SDD phase for a run — `<slug>` for a specific run, no argument for the most recent. |
|
|
101
105
|
| `/zero-branch <slug>` | Create/reuse the configured SDD Git branch and persist `branch`/`baseBranch`. |
|
|
102
106
|
| `/zero-git-validate <slug>` | Check worktree, branch, remote, `gh auth`, and verdict gating before PR/archive. |
|
|
103
107
|
| `/zero-pr <slug>` | Create an audit-ready GitHub PR from a run that already has verdict `pasa`. |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Pure helper: compact token count to a short human string.
|
|
2
|
+
// 1234 → "1.2k", 4_695_040 → "4.7M", 999 → "999".
|
|
3
|
+
//
|
|
4
|
+
// Intentionally separate from `formatTokenCount` in zero-statusline.ts,
|
|
5
|
+
// which uses a different contract; this helper requires lowercase `k`,
|
|
6
|
+
// uppercase `M`, and uppercase `B` suffixes.
|
|
7
|
+
|
|
8
|
+
export function formatTokens(n: number): string {
|
|
9
|
+
if (!Number.isFinite(n) || n < 0) return "0";
|
|
10
|
+
if (n < 1000) return `${Math.floor(n)}`;
|
|
11
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
12
|
+
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
13
|
+
return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { parseMeta, selectRunMetas, aggregateRun, formatReport, type PhaseMeta } from "./zero-cost.ts";
|
|
6
|
+
|
|
7
|
+
type NotifyType = "info" | "warning" | "error";
|
|
8
|
+
interface PiCommandContext { ui: { notify(message: string, type?: NotifyType): void } }
|
|
9
|
+
interface PiExtensionAPI { registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> }): void }
|
|
10
|
+
|
|
11
|
+
/** Read every sub-agent `meta.json` across all pi session dirs and parse the
|
|
12
|
+
* zero-<phase> ones. Scanning every session keeps us decoupled from pi's
|
|
13
|
+
* cwd-encoding scheme; selection by slug/timestamp narrows to one run. */
|
|
14
|
+
function readAllPhaseMetas(): PhaseMeta[] {
|
|
15
|
+
const root = join(homedir(), ".pi", "agent", "sessions");
|
|
16
|
+
if (!existsSync(root)) return [];
|
|
17
|
+
const out: PhaseMeta[] = [];
|
|
18
|
+
for (const session of readdirSync(root, { withFileTypes: true })) {
|
|
19
|
+
if (!session.isDirectory()) continue;
|
|
20
|
+
const artifacts = join(root, session.name, "subagent-artifacts");
|
|
21
|
+
if (!existsSync(artifacts)) continue;
|
|
22
|
+
for (const f of readdirSync(artifacts)) {
|
|
23
|
+
if (!f.endsWith("_meta.json")) continue;
|
|
24
|
+
try {
|
|
25
|
+
const meta = parseMeta(JSON.parse(readFileSync(join(artifacts, f), "utf8")));
|
|
26
|
+
if (meta) out.push(meta);
|
|
27
|
+
} catch { /* skip unreadable / malformed meta */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function runCost(args: string, ctx: PiCommandContext): void {
|
|
34
|
+
const notify = (m: string, t?: NotifyType) => { try { ctx.ui.notify(m, t); } catch {} };
|
|
35
|
+
const slug = args.trim() || null;
|
|
36
|
+
const metas = readAllPhaseMetas();
|
|
37
|
+
const selected = selectRunMetas(metas, slug);
|
|
38
|
+
if (selected.length === 0) {
|
|
39
|
+
notify(
|
|
40
|
+
slug
|
|
41
|
+
? `zero-cost: no encontré datos de costo para "${slug}".`
|
|
42
|
+
: "zero-cost: no encontré runs con datos de costo todavía.",
|
|
43
|
+
"info",
|
|
44
|
+
);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
notify(formatReport(aggregateRun(selected, slug ?? selected[0]?.slug ?? null)), "info");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
51
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
52
|
+
pi.registerCommand("zero-cost", {
|
|
53
|
+
description: "Reporta tokens, costo y duración por fase de un run SDD (slug opcional; sin slug = el más reciente)",
|
|
54
|
+
handler: (args: string, ctx: PiCommandContext): void => {
|
|
55
|
+
try { if (ctx?.ui?.notify) runCost(args ?? "", ctx); }
|
|
56
|
+
catch (err) { try { ctx.ui.notify(`zero-cost: ${err instanceof Error ? err.message : String(err)}`, "error"); } catch {} }
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// zero-pi — per-run cost/usage report, pure-logic module.
|
|
2
|
+
//
|
|
3
|
+
// A `/forge` run delegates each phase to a sub-agent that writes a
|
|
4
|
+
// `*_meta.json` under `~/.pi/agent/sessions/<session>/subagent-artifacts/`
|
|
5
|
+
// carrying real `usage` (tokens + cost), `model`, `durationMs` and
|
|
6
|
+
// `toolCount`. `~/.pi/zero-runs.jsonl` records the verdict/rounds/model but
|
|
7
|
+
// never cost — so there is no aggregate view of what a run cost, by phase.
|
|
8
|
+
//
|
|
9
|
+
// This module turns those raw meta records into a per-phase + total report.
|
|
10
|
+
// Every decision (phase mapping, slug extraction, selection, aggregation,
|
|
11
|
+
// formatting) lives here as plain, dependency-free TypeScript so it is
|
|
12
|
+
// testable in isolation. The pi wiring lives in `zero-cost-extension.ts`.
|
|
13
|
+
|
|
14
|
+
import { formatTokens } from "./format-tokens.ts";
|
|
15
|
+
|
|
16
|
+
/** The SDD phases a run is composed of, in pipeline order. */
|
|
17
|
+
export const COST_PHASES = ["explore", "plan", "build", "veredicto"] as const;
|
|
18
|
+
export type CostPhase = (typeof COST_PHASES)[number];
|
|
19
|
+
|
|
20
|
+
/** Token + cost usage of a single sub-agent run. */
|
|
21
|
+
export interface PhaseUsage {
|
|
22
|
+
input: number;
|
|
23
|
+
output: number;
|
|
24
|
+
cacheRead: number;
|
|
25
|
+
cacheWrite: number;
|
|
26
|
+
cost: number;
|
|
27
|
+
turns: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One normalized sub-agent `meta.json` belonging to a phase. */
|
|
31
|
+
export interface PhaseMeta {
|
|
32
|
+
runId: string;
|
|
33
|
+
phase: CostPhase;
|
|
34
|
+
/** Feature slug extracted from the sub-agent task, or `null`. */
|
|
35
|
+
slug: string | null;
|
|
36
|
+
model: string;
|
|
37
|
+
usage: PhaseUsage;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
toolCount: number;
|
|
40
|
+
/** Epoch ms the meta was written (newest wins for selection/model). */
|
|
41
|
+
timestamp: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One phase row: summed usage of every sub-agent that ran that phase. */
|
|
45
|
+
export interface PhaseAggregate extends PhaseUsage {
|
|
46
|
+
phase: CostPhase;
|
|
47
|
+
model: string;
|
|
48
|
+
subAgents: number;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
toolCount: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The aggregated cost of a whole run. */
|
|
54
|
+
export interface RunCost {
|
|
55
|
+
slug: string | null;
|
|
56
|
+
phases: PhaseAggregate[];
|
|
57
|
+
total: PhaseUsage & { durationMs: number; toolCount: number; subAgents: number };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PHASE_INDEX: Record<CostPhase, number> = { explore: 0, plan: 1, build: 2, veredicto: 3 };
|
|
61
|
+
|
|
62
|
+
/** Map a sub-agent name `zero-<phase>` to its phase, or `null`. */
|
|
63
|
+
export function phaseFromAgent(agent: unknown): CostPhase | null {
|
|
64
|
+
if (typeof agent !== "string") return null;
|
|
65
|
+
const m = /^zero-(explore|plan|build|veredicto)$/.exec(agent);
|
|
66
|
+
return m ? (m[1] as CostPhase) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract the feature slug from the first `.sdd/<slug>/` in a task string.
|
|
70
|
+
* The internal `specs` and `archive` directories are never slugs. */
|
|
71
|
+
export function extractSlug(task: unknown): string | null {
|
|
72
|
+
if (typeof task !== "string") return null;
|
|
73
|
+
const re = /\.sdd\/([A-Za-z0-9._-]+)\//g;
|
|
74
|
+
let m: RegExpExecArray | null;
|
|
75
|
+
while ((m = re.exec(task)) !== null) {
|
|
76
|
+
const slug = m[1];
|
|
77
|
+
if (slug && slug !== "specs" && slug !== "archive") return slug;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function num(v: unknown): number {
|
|
83
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asUsage(raw: unknown): PhaseUsage | null {
|
|
87
|
+
if (!raw || typeof raw !== "object") return null;
|
|
88
|
+
const u = raw as Record<string, unknown>;
|
|
89
|
+
// A usage object must carry at least the token fields as numbers.
|
|
90
|
+
if (typeof u.input !== "number" && typeof u.output !== "number") return null;
|
|
91
|
+
return {
|
|
92
|
+
input: num(u.input),
|
|
93
|
+
output: num(u.output),
|
|
94
|
+
cacheRead: num(u.cacheRead),
|
|
95
|
+
cacheWrite: num(u.cacheWrite),
|
|
96
|
+
cost: num(u.cost),
|
|
97
|
+
turns: num(u.turns),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Parse one raw `meta.json` object into a `PhaseMeta`, or `null` if it is not
|
|
102
|
+
* a zero-<phase> sub-agent or lacks a usage object. */
|
|
103
|
+
export function parseMeta(raw: unknown): PhaseMeta | null {
|
|
104
|
+
if (!raw || typeof raw !== "object") return null;
|
|
105
|
+
const r = raw as Record<string, unknown>;
|
|
106
|
+
const phase = phaseFromAgent(r.agent);
|
|
107
|
+
if (!phase) return null;
|
|
108
|
+
const usage = asUsage(r.usage);
|
|
109
|
+
if (!usage) return null;
|
|
110
|
+
return {
|
|
111
|
+
runId: typeof r.runId === "string" ? r.runId : "",
|
|
112
|
+
phase,
|
|
113
|
+
slug: extractSlug(r.task),
|
|
114
|
+
model: typeof r.model === "string" ? r.model : "",
|
|
115
|
+
usage,
|
|
116
|
+
durationMs: num(r.durationMs),
|
|
117
|
+
toolCount: num(r.toolCount),
|
|
118
|
+
timestamp: num(r.timestamp),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Select the phase-metas for one run: the given slug, or — by default — the
|
|
123
|
+
* slug whose newest meta has the greatest timestamp. Slug-less metas are
|
|
124
|
+
* ignored for the default pick. */
|
|
125
|
+
export function selectRunMetas(metas: readonly PhaseMeta[], slug?: string | null): PhaseMeta[] {
|
|
126
|
+
if (slug) return metas.filter((m) => m.slug === slug);
|
|
127
|
+
let bestSlug: string | null = null;
|
|
128
|
+
let bestTs = -Infinity;
|
|
129
|
+
for (const m of metas) {
|
|
130
|
+
if (m.slug && m.timestamp > bestTs) {
|
|
131
|
+
bestTs = m.timestamp;
|
|
132
|
+
bestSlug = m.slug;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (bestSlug === null) return [];
|
|
136
|
+
return metas.filter((m) => m.slug === bestSlug);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Aggregate selected phase-metas into per-phase rows (pipeline order) plus a
|
|
140
|
+
* run total. A phase that ran multiple sub-agents sums them; its model is the
|
|
141
|
+
* newest meta's model. */
|
|
142
|
+
export function aggregateRun(metas: readonly PhaseMeta[], slug: string | null): RunCost {
|
|
143
|
+
const byPhase = new Map<CostPhase, PhaseAggregate & { _modelTs: number }>();
|
|
144
|
+
for (const m of metas) {
|
|
145
|
+
let agg = byPhase.get(m.phase);
|
|
146
|
+
if (!agg) {
|
|
147
|
+
agg = {
|
|
148
|
+
phase: m.phase,
|
|
149
|
+
model: m.model,
|
|
150
|
+
subAgents: 0,
|
|
151
|
+
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0,
|
|
152
|
+
durationMs: 0, toolCount: 0,
|
|
153
|
+
_modelTs: -Infinity,
|
|
154
|
+
};
|
|
155
|
+
byPhase.set(m.phase, agg);
|
|
156
|
+
}
|
|
157
|
+
agg.subAgents += 1;
|
|
158
|
+
agg.input += m.usage.input;
|
|
159
|
+
agg.output += m.usage.output;
|
|
160
|
+
agg.cacheRead += m.usage.cacheRead;
|
|
161
|
+
agg.cacheWrite += m.usage.cacheWrite;
|
|
162
|
+
agg.cost += m.usage.cost;
|
|
163
|
+
agg.turns += m.usage.turns;
|
|
164
|
+
agg.durationMs += m.durationMs;
|
|
165
|
+
agg.toolCount += m.toolCount;
|
|
166
|
+
if (m.timestamp >= agg._modelTs) {
|
|
167
|
+
agg._modelTs = m.timestamp;
|
|
168
|
+
agg.model = m.model;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const phases = [...byPhase.values()]
|
|
172
|
+
.sort((a, b) => PHASE_INDEX[a.phase] - PHASE_INDEX[b.phase])
|
|
173
|
+
.map(({ _modelTs, ...row }) => row);
|
|
174
|
+
|
|
175
|
+
const total = phases.reduce(
|
|
176
|
+
(t, p) => {
|
|
177
|
+
t.input += p.input; t.output += p.output; t.cacheRead += p.cacheRead;
|
|
178
|
+
t.cacheWrite += p.cacheWrite; t.cost += p.cost; t.turns += p.turns;
|
|
179
|
+
t.durationMs += p.durationMs; t.toolCount += p.toolCount; t.subAgents += p.subAgents;
|
|
180
|
+
return t;
|
|
181
|
+
},
|
|
182
|
+
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, durationMs: 0, toolCount: 0, subAgents: 0 },
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return { slug, phases, total };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Human-readable duration: `12s`, `5m31s`. */
|
|
189
|
+
export function formatDuration(ms: number): string {
|
|
190
|
+
const safe = Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
191
|
+
const totalSec = Math.round(safe / 1000);
|
|
192
|
+
const min = Math.floor(totalSec / 60);
|
|
193
|
+
const sec = totalSec % 60;
|
|
194
|
+
return min > 0 ? `${min}m${sec.toString().padStart(2, "0")}s` : `${sec}s`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** USD with two decimals: `$2.48`. */
|
|
198
|
+
export function formatUsd(n: number): string {
|
|
199
|
+
const safe = Number.isFinite(n) ? n : 0;
|
|
200
|
+
return `$${safe.toFixed(2)}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Render an aligned per-phase + total report. */
|
|
204
|
+
export function formatReport(run: RunCost): string {
|
|
205
|
+
if (run.phases.length === 0) return "zero-cost: no encontré datos de costo para ese run.";
|
|
206
|
+
const head = `zero-cost: ${run.slug ?? "(run reciente)"}`;
|
|
207
|
+
const cols = `fase sub in out cache tools dur costo`;
|
|
208
|
+
const lines = [head, cols];
|
|
209
|
+
const row = (
|
|
210
|
+
label: string, subs: string, inn: number, out: number, cache: number,
|
|
211
|
+
tools: number, dur: number, cost: number,
|
|
212
|
+
): string =>
|
|
213
|
+
`${label.padEnd(10)} ${subs.padStart(3)} ${formatTokens(inn).padStart(6)} ${formatTokens(out).padStart(6)} ${formatTokens(cache).padStart(7)} ${String(tools).padStart(5)} ${formatDuration(dur).padStart(6)} ${formatUsd(cost).padStart(7)}`;
|
|
214
|
+
for (const p of run.phases) {
|
|
215
|
+
lines.push(row(p.phase, String(p.subAgents), p.input, p.output, p.cacheRead, p.toolCount, p.durationMs, p.cost));
|
|
216
|
+
}
|
|
217
|
+
const t = run.total;
|
|
218
|
+
lines.push(row("TOTAL", String(t.subAgents), t.input, t.output, t.cacheRead, t.toolCount, t.durationMs, t.cost));
|
|
219
|
+
return lines.join("\n");
|
|
220
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createDefaultDoctorHost, formatDoctorReport, runDoctor } from "./zero-doctor.ts";
|
|
2
|
+
import type { PiModel } from "./zero-models.ts";
|
|
3
|
+
|
|
4
|
+
type NotifyType = "info" | "warning" | "error";
|
|
5
|
+
|
|
6
|
+
interface PiCommandContext {
|
|
7
|
+
ui: { notify(message: string, type?: NotifyType): void };
|
|
8
|
+
modelRegistry?: { getAll(): PiModel[] };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PiExtensionAPI {
|
|
12
|
+
registerCommand(
|
|
13
|
+
name: string,
|
|
14
|
+
options: { description?: string; handler: (args: string, ctx: PiCommandContext) => void | Promise<void> },
|
|
15
|
+
): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function registryModels(ctx: PiCommandContext): PiModel[] | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const models = ctx.modelRegistry?.getAll?.();
|
|
21
|
+
return Array.isArray(models) ? models : undefined;
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function register(pi?: PiExtensionAPI): void {
|
|
28
|
+
if (!pi || typeof pi.registerCommand !== "function") return;
|
|
29
|
+
pi.registerCommand("zero-doctor", {
|
|
30
|
+
description: "Diagnostica instalación, modelos, sub-agentes, git/gh y estado SDD de zero-pi",
|
|
31
|
+
handler: (_args: string, ctx: PiCommandContext): void => {
|
|
32
|
+
try {
|
|
33
|
+
const report = runDoctor(createDefaultDoctorHost(registryModels(ctx)));
|
|
34
|
+
ctx.ui.notify(formatDoctorReport(report), report.ok ? "info" : "error");
|
|
35
|
+
} catch (err) {
|
|
36
|
+
try {
|
|
37
|
+
ctx.ui.notify(`zero-doctor: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
groupByProvider,
|
|
9
|
+
PHASES,
|
|
10
|
+
readModels,
|
|
11
|
+
readProviders,
|
|
12
|
+
validateAssignment,
|
|
13
|
+
type PiModel,
|
|
14
|
+
} from "./zero-models.ts";
|
|
15
|
+
|
|
16
|
+
export type DoctorLevel = "ok" | "warn" | "fail";
|
|
17
|
+
|
|
18
|
+
export interface DoctorCheck {
|
|
19
|
+
name: string;
|
|
20
|
+
level: DoctorLevel;
|
|
21
|
+
message: string;
|
|
22
|
+
hint?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DoctorReport {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
checks: DoctorCheck[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommandResult {
|
|
31
|
+
status: number | null;
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DoctorHost {
|
|
38
|
+
cwd: string;
|
|
39
|
+
home: string;
|
|
40
|
+
nodeVersion: string;
|
|
41
|
+
models?: readonly PiModel[];
|
|
42
|
+
exists(path: string): boolean;
|
|
43
|
+
readText(path: string): string | null;
|
|
44
|
+
listDir(path: string): string[];
|
|
45
|
+
run(command: string, args: readonly string[], cwd: string): CommandResult;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function currentPackageDir(): string {
|
|
49
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readText(path: string): string | null {
|
|
53
|
+
try {
|
|
54
|
+
return readFileSync(path, "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaultRun(command: string, args: readonly string[], cwd: string): CommandResult {
|
|
61
|
+
try {
|
|
62
|
+
const out = spawnSync(command, [...args], {
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
timeout: 5000,
|
|
66
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
status: out.status,
|
|
70
|
+
stdout: typeof out.stdout === "string" ? out.stdout : "",
|
|
71
|
+
stderr: typeof out.stderr === "string" ? out.stderr : "",
|
|
72
|
+
error: out.error?.message,
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { status: null, stdout: "", stderr: "", error: err instanceof Error ? err.message : String(err) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createDefaultDoctorHost(models?: readonly PiModel[]): DoctorHost {
|
|
80
|
+
return {
|
|
81
|
+
cwd: process.cwd(),
|
|
82
|
+
home: homedir(),
|
|
83
|
+
nodeVersion: process.version,
|
|
84
|
+
models,
|
|
85
|
+
exists: existsSync,
|
|
86
|
+
readText,
|
|
87
|
+
listDir(path: string): string[] {
|
|
88
|
+
try {
|
|
89
|
+
return readdirSync(path);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
run: defaultRun,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function check(name: string, level: DoctorLevel, message: string, hint?: string): DoctorCheck {
|
|
99
|
+
return hint ? { name, level, message, hint } : { name, level, message };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseVersion(version: string): number[] {
|
|
103
|
+
return version.replace(/^v/, "").split(".").map((p) => Number.parseInt(p, 10)).map((n) => (Number.isFinite(n) ? n : 0));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function versionAtLeast(version: string, minimum: string): boolean {
|
|
107
|
+
const a = parseVersion(version);
|
|
108
|
+
const b = parseVersion(minimum);
|
|
109
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
110
|
+
const av = a[i] ?? 0;
|
|
111
|
+
const bv = b[i] ?? 0;
|
|
112
|
+
if (av > bv) return true;
|
|
113
|
+
if (av < bv) return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseJson(text: string | null): { ok: true; value: Record<string, unknown> } | { ok: false; error: string } | null {
|
|
119
|
+
if (text === null) return null;
|
|
120
|
+
try {
|
|
121
|
+
const value = JSON.parse(text);
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
123
|
+
? { ok: true, value: value as Record<string, unknown> }
|
|
124
|
+
: { ok: false, error: "JSON root is not an object" };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function settingsPackages(settings: Record<string, unknown>): string[] {
|
|
131
|
+
const raw = settings.packages;
|
|
132
|
+
if (!Array.isArray(raw)) return [];
|
|
133
|
+
const out: string[] = [];
|
|
134
|
+
for (const entry of raw) {
|
|
135
|
+
if (typeof entry === "string") out.push(entry);
|
|
136
|
+
else if (entry && typeof entry === "object" && typeof (entry as { source?: unknown }).source === "string") out.push((entry as { source: string }).source);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function packageSourceName(source: string): string {
|
|
142
|
+
return source.replace(/^npm:/, "").replace(/@[^/@]+$/, "");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasPackageSource(packages: readonly string[], name: string): boolean {
|
|
146
|
+
return packages.some((p) => packageSourceName(p) === name || packageSourceName(p).endsWith(`/${name}`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkNode(host: DoctorHost): DoctorCheck {
|
|
150
|
+
return versionAtLeast(host.nodeVersion, "20.6.0")
|
|
151
|
+
? check("node", "ok", `${host.nodeVersion} (>=20.6.0)`)
|
|
152
|
+
: check("node", "fail", `${host.nodeVersion} es viejo`, "zero-pi requiere Node >=20.6.0");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function checkPiSubagents(host: DoctorHost, settings: Record<string, unknown> | null): DoctorCheck {
|
|
156
|
+
const installed = host.exists(join(host.home, ".pi", "agent", "npm", "node_modules", "pi-subagents", "package.json"));
|
|
157
|
+
const configured = settings ? hasPackageSource(settingsPackages(settings), "pi-subagents") : false;
|
|
158
|
+
if (installed || configured) return check("pi-subagents", "ok", installed ? "package instalado" : "declarado en settings");
|
|
159
|
+
return check(
|
|
160
|
+
"pi-subagents",
|
|
161
|
+
"fail",
|
|
162
|
+
"no lo encontré instalado ni declarado",
|
|
163
|
+
"corré: pi install npm:pi-subagents",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function checkZeroJson(host: DoctorHost): { check: DoctorCheck; data: Record<string, unknown> | null } {
|
|
168
|
+
const path = join(host.home, ".pi", "zero.json");
|
|
169
|
+
const parsed = parseJson(host.readText(path));
|
|
170
|
+
if (parsed === null) return { check: check("zero.json", "warn", "~/.pi/zero.json no existe", "se crea al usar /zero-models"), data: null };
|
|
171
|
+
if (!parsed.ok) return { check: check("zero.json", "fail", `JSON inválido: ${parsed.error}`), data: null };
|
|
172
|
+
|
|
173
|
+
const groups = groupByProvider(host.models ?? []);
|
|
174
|
+
const models = readModels(parsed.value);
|
|
175
|
+
const providers = readProviders(parsed.value);
|
|
176
|
+
const failures: string[] = [];
|
|
177
|
+
if (groups.size > 0) {
|
|
178
|
+
for (const phase of PHASES) {
|
|
179
|
+
const assignment = { phase, model: models[phase], provider: providers[phase] || undefined };
|
|
180
|
+
const validation = validateAssignment(assignment, groups);
|
|
181
|
+
if (!validation.ok) failures.push(`${phase}: ${validation.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (failures.length > 0) return { check: check("zero.json", "fail", failures.join(" · ")), data: parsed.value };
|
|
185
|
+
return { check: check("zero.json", "ok", groups.size > 0 ? "parseable y modelos válidos" : "parseable (sin registry para validar modelos)"), data: parsed.value };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function checkSettings(host: DoctorHost): { check: DoctorCheck; data: Record<string, unknown> | null } {
|
|
189
|
+
const path = join(host.home, ".pi", "agent", "settings.json");
|
|
190
|
+
const parsed = parseJson(host.readText(path));
|
|
191
|
+
if (parsed === null) return { check: check("settings", "warn", "~/.pi/agent/settings.json no existe"), data: null };
|
|
192
|
+
if (!parsed.ok) return { check: check("settings", "fail", `JSON inválido: ${parsed.error}`), data: null };
|
|
193
|
+
return { check: check("settings", "ok", "parseable"), data: parsed.value };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkGeneratedAgents(host: DoctorHost): DoctorCheck {
|
|
197
|
+
const dir = join(host.home, ".pi", "agent", "agents", "zero");
|
|
198
|
+
const missing = PHASES.map((p) => `zero-${p}.md`).filter((file) => !host.exists(join(dir, file)));
|
|
199
|
+
if (missing.length === 0) return check("agents", "ok", "sub-agentes zero generados");
|
|
200
|
+
return check("agents", "warn", `faltan ${missing.join(", ")}`, "reiniciá pi para que zero-pi regenere ~/.pi/agent/agents/zero/");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function checkSupportModules(host: DoctorHost): DoctorCheck {
|
|
204
|
+
const dir = join(host.home, ".pi", "agent", "agents", "zero", "support");
|
|
205
|
+
const required = ["strict-tdd.md", "strict-tdd-verify.md"];
|
|
206
|
+
const missing = required.filter((file) => !host.exists(join(dir, file)));
|
|
207
|
+
if (missing.length === 0) return check("support", "ok", "Strict TDD support modules presentes");
|
|
208
|
+
return check("support", "warn", `faltan ${missing.join(", ")}`, "reiniciá pi o reinstalá zero-pi");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function checkSddConfig(host: DoctorHost): DoctorCheck {
|
|
212
|
+
const path = join(host.cwd, ".sdd", "config.json");
|
|
213
|
+
const parsed = parseJson(host.readText(path));
|
|
214
|
+
if (parsed === null) return check(".sdd/config", "ok", "no existe (opcional)");
|
|
215
|
+
if (!parsed.ok) return check(".sdd/config", "fail", `JSON inválido: ${parsed.error}`);
|
|
216
|
+
return check(".sdd/config", "ok", "parseable");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function checkZeroRuns(host: DoctorHost): DoctorCheck {
|
|
220
|
+
const path = join(host.home, ".pi", "zero-runs.jsonl");
|
|
221
|
+
const text = host.readText(path);
|
|
222
|
+
if (text === null) return check("zero-runs", "warn", "~/.pi/zero-runs.jsonl no existe todavía", "se crea después de runs /forge");
|
|
223
|
+
let lines = 0;
|
|
224
|
+
let bad = 0;
|
|
225
|
+
for (const line of text.split(/\r?\n/)) {
|
|
226
|
+
if (!line.trim()) continue;
|
|
227
|
+
lines += 1;
|
|
228
|
+
try { JSON.parse(line); } catch { bad += 1; }
|
|
229
|
+
}
|
|
230
|
+
if (bad > 0) return check("zero-runs", "warn", `${bad}/${lines} líneas no parsean como JSONL`);
|
|
231
|
+
return check("zero-runs", "ok", `${lines} records parseables`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function checkGit(host: DoctorHost): DoctorCheck {
|
|
235
|
+
const root = host.run("git", ["rev-parse", "--show-toplevel"], host.cwd);
|
|
236
|
+
if (root.status !== 0) return check("git", "warn", "este cwd no parece estar dentro de un repo git", root.stderr.trim() || root.error);
|
|
237
|
+
const repoRoot = root.stdout.trim() || host.cwd;
|
|
238
|
+
const remote = host.run("git", ["remote", "get-url", "origin"], repoRoot);
|
|
239
|
+
if (remote.status === 0) return check("git", "ok", `origin: ${remote.stdout.trim()}`);
|
|
240
|
+
return check("git", "warn", "repo git sin remote origin", remote.stderr.trim() || remote.error);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function checkGh(host: DoctorHost): DoctorCheck {
|
|
244
|
+
const gh = host.run("gh", ["auth", "status"], host.cwd);
|
|
245
|
+
if (gh.status === 0) return check("gh", "ok", "GitHub CLI autenticado");
|
|
246
|
+
const reason = (gh.stderr || gh.stdout || gh.error || "gh auth status falló").trim().split("\n")[0];
|
|
247
|
+
return check("gh", "warn", reason || "gh auth status falló", "necesario para /zero-pr y /zero-issue");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function checkPackageManifest(host: DoctorHost): DoctorCheck {
|
|
251
|
+
const pkg = parseJson(host.readText(join(currentPackageDir(), "package.json")));
|
|
252
|
+
if (!pkg || !pkg.ok) return check("zero-pi package", "warn", "no pude leer package.json del paquete");
|
|
253
|
+
const version = typeof pkg.value.version === "string" ? pkg.value.version : "unknown";
|
|
254
|
+
return check("zero-pi package", "ok", `@gonrocca/zero-pi v${version}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function runDoctor(host: DoctorHost = createDefaultDoctorHost()): DoctorReport {
|
|
258
|
+
const checks: DoctorCheck[] = [];
|
|
259
|
+
checks.push(checkPackageManifest(host));
|
|
260
|
+
checks.push(checkNode(host));
|
|
261
|
+
const settings = checkSettings(host);
|
|
262
|
+
checks.push(settings.check);
|
|
263
|
+
checks.push(checkPiSubagents(host, settings.data));
|
|
264
|
+
const zero = checkZeroJson(host);
|
|
265
|
+
checks.push(zero.check);
|
|
266
|
+
checks.push(checkGeneratedAgents(host));
|
|
267
|
+
checks.push(checkSupportModules(host));
|
|
268
|
+
checks.push(checkSddConfig(host));
|
|
269
|
+
checks.push(checkZeroRuns(host));
|
|
270
|
+
checks.push(checkGit(host));
|
|
271
|
+
checks.push(checkGh(host));
|
|
272
|
+
return { ok: checks.every((c) => c.level !== "fail"), checks };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function icon(level: DoctorLevel): string {
|
|
276
|
+
if (level === "ok") return "✅";
|
|
277
|
+
if (level === "warn") return "⚠️";
|
|
278
|
+
return "❌";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function formatDoctorReport(report: DoctorReport): string {
|
|
282
|
+
const ok = report.checks.filter((c) => c.level === "ok").length;
|
|
283
|
+
const warn = report.checks.filter((c) => c.level === "warn").length;
|
|
284
|
+
const fail = report.checks.filter((c) => c.level === "fail").length;
|
|
285
|
+
const lines = [`zero-doctor: ${report.ok ? "ok" : "falló"} · ${ok} ok · ${warn} warn · ${fail} fail`];
|
|
286
|
+
for (const c of report.checks) {
|
|
287
|
+
lines.push(`${icon(c.level)} ${c.name}: ${c.message}`);
|
|
288
|
+
if (c.hint) lines.push(` ↳ ${c.hint}`);
|
|
289
|
+
}
|
|
290
|
+
return lines.join("\n");
|
|
291
|
+
}
|
|
@@ -328,6 +328,85 @@ export function groupByProvider(models: readonly PiModel[]): Map<string, string[
|
|
|
328
328
|
return map;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/** Result of validating a direct `/zero-models <phase>=...` assignment
|
|
332
|
+
* against pi's model registry. */
|
|
333
|
+
export type AssignmentValidation =
|
|
334
|
+
| { ok: true; provider?: string }
|
|
335
|
+
| { ok: false; message: string };
|
|
336
|
+
|
|
337
|
+
function containsModel(list: readonly string[] | undefined, model: string): boolean {
|
|
338
|
+
return Boolean(list?.includes(model));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function modelProviders(groups: Map<string, string[]>, model: string): string[] {
|
|
342
|
+
const providers: string[] = [];
|
|
343
|
+
for (const [provider, models] of groups) if (models.includes(model)) providers.push(provider);
|
|
344
|
+
return providers.sort();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function suggestModels(groups: Map<string, string[]>, model: string): string[] {
|
|
348
|
+
const needle = model.toLowerCase();
|
|
349
|
+
const scored: string[] = [];
|
|
350
|
+
for (const [provider, models] of groups) {
|
|
351
|
+
for (const id of models) {
|
|
352
|
+
const hay = id.toLowerCase();
|
|
353
|
+
if (hay.includes(needle) || needle.includes(hay)) scored.push(`${provider}/${id}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return scored.slice(0, 5);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Validate a direct assignment against pi's model registry.
|
|
361
|
+
*
|
|
362
|
+
* Empty registry = permissive fallback (old behaviour), because tests and some
|
|
363
|
+
* headless contexts may not expose `ctx.modelRegistry`. When the registry is
|
|
364
|
+
* present, the command writes only provider/model pairs pi can actually
|
|
365
|
+
* resolve. Bare ambiguous model ids are rejected with a provider-qualified hint.
|
|
366
|
+
*/
|
|
367
|
+
export function validateAssignment(
|
|
368
|
+
assignment: Assignment,
|
|
369
|
+
groups: Map<string, string[]>,
|
|
370
|
+
): AssignmentValidation {
|
|
371
|
+
if (groups.size === 0) return { ok: true, provider: assignment.provider };
|
|
372
|
+
|
|
373
|
+
if (assignment.provider) {
|
|
374
|
+
const providerModels = groups.get(assignment.provider);
|
|
375
|
+
if (!providerModels) {
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
message: `provider desconocido: ${assignment.provider}. Usá uno de: ${[...groups.keys()].sort().join(", ")}`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (!containsModel(providerModels, assignment.model)) {
|
|
382
|
+
const suggestions = suggestModels(groups, assignment.model);
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
message:
|
|
386
|
+
`modelo desconocido para ${assignment.provider}: ${assignment.model}` +
|
|
387
|
+
(suggestions.length > 0 ? `. Quizás: ${suggestions.join(", ")}` : ""),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
return { ok: true, provider: assignment.provider };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const providers = modelProviders(groups, assignment.model);
|
|
394
|
+
if (providers.length === 1) return { ok: true, provider: providers[0] };
|
|
395
|
+
if (providers.length > 1) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
message: `modelo ambiguo: ${assignment.model}. Usá provider/model: ${providers.map((p) => `${p}/${assignment.model}`).join(", ")}`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const suggestions = suggestModels(groups, assignment.model);
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
message:
|
|
405
|
+
`modelo desconocido: ${assignment.model}` +
|
|
406
|
+
(suggestions.length > 0 ? `. Quizás: ${suggestions.join(", ")}` : ""),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
331
410
|
/** The pi-TUI host handed to a `ctx.ui.custom()` factory — only the one
|
|
332
411
|
* method the picker shell uses. */
|
|
333
412
|
interface PiTui {
|
|
@@ -706,8 +785,15 @@ export default function register(pi?: PiExtensionAPI): void {
|
|
|
706
785
|
);
|
|
707
786
|
return;
|
|
708
787
|
}
|
|
788
|
+
const validation = validateAssignment(assignment, groups);
|
|
789
|
+
if (!validation.ok) {
|
|
790
|
+
ctx.ui.notify(`zero-models: ${validation.message}`, "warning");
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
709
794
|
models[assignment.phase] = assignment.model;
|
|
710
795
|
providers[assignment.phase] =
|
|
796
|
+
validation.provider ??
|
|
711
797
|
assignment.provider ??
|
|
712
798
|
resolveProvider(ctx.modelRegistry, assignment.model) ??
|
|
713
799
|
providers[assignment.phase];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.58",
|
|
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": [
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
"./extensions/spec-merge-extension.ts",
|
|
36
36
|
"./extensions/zero-validate-extension.ts",
|
|
37
37
|
"./extensions/zero-status-extension.ts",
|
|
38
|
+
"./extensions/zero-doctor-extension.ts",
|
|
39
|
+
"./extensions/zero-cost-extension.ts",
|
|
38
40
|
"./extensions/zero-pr-extension.ts",
|
|
39
41
|
"./extensions/zero-branch-extension.ts",
|
|
40
42
|
"./extensions/zero-git-validate-extension.ts",
|
|
@@ -66,6 +68,11 @@
|
|
|
66
68
|
"extensions/zero-validate-extension.ts",
|
|
67
69
|
"extensions/zero-status.ts",
|
|
68
70
|
"extensions/zero-status-extension.ts",
|
|
71
|
+
"extensions/zero-doctor.ts",
|
|
72
|
+
"extensions/zero-doctor-extension.ts",
|
|
73
|
+
"extensions/zero-cost.ts",
|
|
74
|
+
"extensions/zero-cost-extension.ts",
|
|
75
|
+
"extensions/format-tokens.ts",
|
|
69
76
|
"extensions/pr-body.ts",
|
|
70
77
|
"extensions/sdd-links.ts",
|
|
71
78
|
"extensions/sdd-config.ts",
|