projectinator 0.1.0

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/bin/projectinator.mjs +21 -0
  4. package/package.json +73 -0
  5. package/src/bakeoff.ts +220 -0
  6. package/src/build-state.ts +46 -0
  7. package/src/burndown.ts +35 -0
  8. package/src/calibration.ts +88 -0
  9. package/src/cost.ts +45 -0
  10. package/src/council.ts +180 -0
  11. package/src/demo.ts +106 -0
  12. package/src/estimate.ts +104 -0
  13. package/src/executor.ts +168 -0
  14. package/src/git.ts +72 -0
  15. package/src/intake.ts +129 -0
  16. package/src/models.ts +92 -0
  17. package/src/narrate.ts +91 -0
  18. package/src/orchestrator.ts +271 -0
  19. package/src/pm.ts +301 -0
  20. package/src/preview.ts +192 -0
  21. package/src/registry-store.ts +41 -0
  22. package/src/registry.ts +118 -0
  23. package/src/research.ts +127 -0
  24. package/src/retro.ts +99 -0
  25. package/src/roles.ts +357 -0
  26. package/src/router.ts +123 -0
  27. package/src/run-bakeoff.ts +77 -0
  28. package/src/run-build.ts +190 -0
  29. package/src/run-dev.ts +83 -0
  30. package/src/run-pm.ts +92 -0
  31. package/src/run-research.ts +74 -0
  32. package/src/run-scout.ts +68 -0
  33. package/src/run-web.ts +87 -0
  34. package/src/scout.ts +121 -0
  35. package/src/session-cost.ts +17 -0
  36. package/src/stack.ts +46 -0
  37. package/src/tui/App.tsx +1739 -0
  38. package/src/tui/BakeOff.tsx +190 -0
  39. package/src/tui/BoardEditor.tsx +248 -0
  40. package/src/tui/EditableBoard.tsx +169 -0
  41. package/src/tui/Frame.tsx +142 -0
  42. package/src/tui/Intake.tsx +111 -0
  43. package/src/tui/Kanban.tsx +155 -0
  44. package/src/tui/Settings.tsx +419 -0
  45. package/src/tui/StackPick.tsx +79 -0
  46. package/src/tui/WebAccounts.tsx +197 -0
  47. package/src/tui/components.tsx +338 -0
  48. package/src/tui/config.ts +134 -0
  49. package/src/tui/deploy.ts +137 -0
  50. package/src/tui/engine.ts +742 -0
  51. package/src/tui/notify.ts +21 -0
  52. package/src/tui/panels.tsx +89 -0
  53. package/src/tui/templates.ts +119 -0
  54. package/src/tui/theme.ts +44 -0
  55. package/src/tui/validate.ts +48 -0
  56. package/src/tui.tsx +63 -0
  57. package/src/types.ts +175 -0
  58. package/src/web/oauth-anthropic.ts +206 -0
  59. package/src/web/session.ts +299 -0
@@ -0,0 +1,127 @@
1
+ // Auto-feed the Scout — turn a research report into structured findings.
2
+ //
3
+ // The deep-research harness (or any benchmark write-up) produces prose. This module
4
+ // extracts it into clean Finding[] the Scout can consume: a model reads the report and
5
+ // calls a forced typebox tool. That's reliable because it's reformatting text we give
6
+ // it, not recalling facts from memory.
7
+ //
8
+ // Flow: research report (text) -> extractFindings() -> findings.json -> scout --from
9
+
10
+ import {
11
+ AuthStorage,
12
+ ModelRegistry,
13
+ createAgentSession,
14
+ defineTool,
15
+ type AgentSession,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import { Type, type Static } from "typebox";
18
+ import type { Provider } from "./types.js";
19
+ import type { Finding } from "./scout.js";
20
+ import { resolvePiModel } from "./executor.js";
21
+ import { MODELS } from "./models.js";
22
+
23
+ const FindingsSchema = Type.Object({
24
+ findings: Type.Array(
25
+ Type.Object({
26
+ capability: Type.Union([
27
+ Type.Literal("plan"), Type.Literal("design"), Type.Literal("code"),
28
+ Type.Literal("test"), Type.Literal("ops"),
29
+ ]),
30
+ tier: Type.Union([Type.Literal("fast"), Type.Literal("mid"), Type.Literal("high")]),
31
+ backend: Type.Union([Type.Literal("web"), Type.Literal("api")]),
32
+ provider: Type.Union([Type.Literal("anthropic"), Type.Literal("openai"), Type.Literal("google")]),
33
+ model: Type.String({ description: "exact model id, e.g. claude-opus-4-8" }),
34
+ evidence: Type.String({ description: "one-line benchmark/source justification" }),
35
+ }),
36
+ ),
37
+ });
38
+ type FindingsRaw = Static<typeof FindingsSchema>;
39
+
40
+ export interface ValidationIssue {
41
+ index: number;
42
+ model: string;
43
+ problem: string;
44
+ }
45
+
46
+ /** Pure check: does each finding reference a real model with a matching provider? */
47
+ export function validateFindings(findings: Finding[]): { ok: boolean; issues: ValidationIssue[] } {
48
+ const issues: ValidationIssue[] = [];
49
+ findings.forEach((f, index) => {
50
+ const m = MODELS[f.model];
51
+ if (!m) {
52
+ issues.push({ index, model: f.model, problem: "model not in models.ts" });
53
+ } else if (m.provider !== f.provider) {
54
+ issues.push({ index, model: f.model, problem: `provider mismatch (models.ts says ${m.provider})` });
55
+ }
56
+ });
57
+ return { ok: issues.length === 0, issues };
58
+ }
59
+
60
+ function buildFindingsTool() {
61
+ let captured: Finding[] | undefined;
62
+ const tool = defineTool({
63
+ name: "submit_findings",
64
+ label: "Submit Findings",
65
+ description: "Submit the extracted model-per-role findings.",
66
+ parameters: FindingsSchema,
67
+ execute: async (_id, params: FindingsRaw) => {
68
+ captured = params.findings as Finding[];
69
+ return { content: [{ type: "text", text: `Extracted ${params.findings.length} findings.` }], details: {} };
70
+ },
71
+ });
72
+ return { tool, get: () => captured };
73
+ }
74
+
75
+ export function extractionPrompt(report: string): string {
76
+ return [
77
+ "You are a data extractor. From the research report below, extract the single best",
78
+ "model for each role the report covers, as structured findings.",
79
+ "",
80
+ "For each finding set: capability (plan|design|code|test|ops), tier (fast|mid|high),",
81
+ "backend (usually 'api' for benchmark-driven picks), provider, the EXACT model id,",
82
+ "and a one-line evidence note. Only include roles the report actually supports.",
83
+ "Do not invent models. When done, call submit_findings once.",
84
+ "",
85
+ "--- REPORT ---",
86
+ report,
87
+ ].join("\n");
88
+ }
89
+
90
+ export interface ExtractOptions {
91
+ model: { provider: Provider; model: string };
92
+ authStorage?: AuthStorage;
93
+ onEvent?: Parameters<AgentSession["subscribe"]>[0];
94
+ }
95
+
96
+ /** Extract findings from a report via a model. Spends money (one model call). */
97
+ export async function extractFindings(report: string, opts: ExtractOptions): Promise<Finding[]> {
98
+ const authStorage = opts.authStorage ?? AuthStorage.create();
99
+ const registry = ModelRegistry.create(authStorage);
100
+ const model = resolvePiModel(registry, opts.model.provider, opts.model.model);
101
+
102
+ const { tool, get } = buildFindingsTool();
103
+ const { session } = await createAgentSession({
104
+ model, authStorage, modelRegistry: registry,
105
+ thinkingLevel: "low",
106
+ noTools: "all",
107
+ customTools: [tool],
108
+ tools: ["submit_findings"],
109
+ });
110
+
111
+ const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
112
+ try {
113
+ await session.prompt(extractionPrompt(report));
114
+ let out = get();
115
+ if (!out) {
116
+ await session.followUp("Call submit_findings now.");
117
+ out = get();
118
+ }
119
+ if (!out) throw new Error("Extractor did not call submit_findings.");
120
+ return out;
121
+ } finally {
122
+ unsub?.();
123
+ session.dispose();
124
+ }
125
+ }
126
+
127
+ export { FindingsSchema, buildFindingsTool };
package/src/retro.ts ADDED
@@ -0,0 +1,99 @@
1
+ // Build retro — a free, data-driven summary of a finished build, computed from
2
+ // build-state: what passed, what the tester flagged, cost per epic and per
3
+ // model, retries, and the priciest tasks. No model call.
4
+
5
+ import type { BuildState } from "./build-state.js";
6
+ import type { Bug, Difficulty } from "./types.js";
7
+ import { baselineTokens } from "./estimate.js";
8
+ import { estimateCost } from "./cost.js";
9
+ import { getModel } from "./models.js";
10
+
11
+ export interface RetroReport {
12
+ idea: string;
13
+ status: BuildState["status"];
14
+ totalCost: number;
15
+ estCost: number; // baseline predicted cost for the tasks that ran
16
+ taskCount: number;
17
+ doneCount: number;
18
+ tests: { passed: number; failed: number };
19
+ bugs: Bug[]; // everything the tester flagged during the build
20
+ retries: { taskId: string; title: string; rounds: number }[];
21
+ byEpic: { epic: string; cost: number; tasks: number }[];
22
+ byModel: { model: string; cost: number; tasks: number }[];
23
+ topCost: { taskId: string; title: string; cost: number }[];
24
+ }
25
+
26
+ const round2 = (n: number) => Math.round(n * 100) / 100;
27
+
28
+ export function computeRetro(state: BuildState): RetroReport {
29
+ const titleById = new Map(state.tasks.map((t) => [t.id, t.title]));
30
+ const epicById = new Map(state.tasks.map((t) => [t.id, t.epic || "General"]));
31
+ const diffById = new Map(state.tasks.map((t) => [t.id, t.difficulty]));
32
+ const outcomes = state.outcomes;
33
+ const doneIds = new Set(outcomes.map((o) => o.taskId));
34
+
35
+ // Baseline-predicted cost for each run: static token budget × the model that ran it.
36
+ let estCost = 0;
37
+ for (const o of outcomes) {
38
+ const diff = (diffById.get(o.taskId) ?? "medium") as Difficulty;
39
+ try {
40
+ const model = getModel(o.modelId);
41
+ const tokens = baselineTokens(o.capability, diff);
42
+ estCost += estimateCost({ input: tokens.input, output: tokens.output, cachedInputFraction: 0.55 }, model);
43
+ } catch {
44
+ /* unknown model — skip its estimate */
45
+ }
46
+ }
47
+
48
+ // Tests: judge each test task by its LAST outcome (final state after retries).
49
+ const lastTestByTask = new Map<string, (typeof outcomes)[number]>();
50
+ for (const o of outcomes) if (o.capability === "test") lastTestByTask.set(o.taskId, o);
51
+ let passed = 0;
52
+ let failed = 0;
53
+ for (const o of lastTestByTask.values()) {
54
+ if (o.verdict?.passed) passed++;
55
+ else if (o.verdict) failed++;
56
+ }
57
+
58
+ // Bugs the tester flagged anywhere during the build (signal, even if later fixed).
59
+ const bugs: Bug[] = [];
60
+ for (const o of outcomes) if (o.verdict?.bugs) bugs.push(...o.verdict.bugs);
61
+
62
+ // Retries: any outcome past round 0 means a Tester→Developer rebuild happened.
63
+ const roundsByTask = new Map<string, number>();
64
+ for (const o of outcomes) if (o.round > 0) roundsByTask.set(o.taskId, Math.max(roundsByTask.get(o.taskId) ?? 0, o.round));
65
+ const retries = [...roundsByTask.entries()].map(([taskId, rounds]) => ({ taskId, title: titleById.get(taskId) ?? taskId, rounds }));
66
+
67
+ // Cost per epic + per model.
68
+ const epicCost = new Map<string, { cost: number; tasks: number }>();
69
+ const modelCost = new Map<string, { cost: number; tasks: number }>();
70
+ for (const o of outcomes) {
71
+ const e = epicById.get(o.taskId) ?? "General";
72
+ const ec = epicCost.get(e) ?? { cost: 0, tasks: 0 };
73
+ epicCost.set(e, { cost: ec.cost + o.cost, tasks: ec.tasks + 1 });
74
+ const mc = modelCost.get(o.modelId) ?? { cost: 0, tasks: 0 };
75
+ modelCost.set(o.modelId, { cost: mc.cost + o.cost, tasks: mc.tasks + 1 });
76
+ }
77
+ const byEpic = [...epicCost.entries()].map(([epic, v]) => ({ epic, cost: round2(v.cost), tasks: v.tasks })).sort((a, b) => b.cost - a.cost);
78
+ const byModel = [...modelCost.entries()].map(([model, v]) => ({ model, cost: round2(v.cost), tasks: v.tasks })).sort((a, b) => b.cost - a.cost);
79
+
80
+ const topCost = [...outcomes]
81
+ .sort((a, b) => b.cost - a.cost)
82
+ .slice(0, 3)
83
+ .map((o) => ({ taskId: o.taskId, title: titleById.get(o.taskId) ?? o.taskId, cost: round2(o.cost) }));
84
+
85
+ return {
86
+ idea: state.idea ?? state.id,
87
+ status: state.status,
88
+ totalCost: round2(state.totalCost),
89
+ estCost: round2(estCost),
90
+ taskCount: state.tasks.length,
91
+ doneCount: doneIds.size,
92
+ tests: { passed, failed },
93
+ bugs,
94
+ retries,
95
+ byEpic,
96
+ byModel,
97
+ topCost,
98
+ };
99
+ }
package/src/roles.ts ADDED
@@ -0,0 +1,357 @@
1
+ // Phase 4 — role definitions + the real Pi-backed executor.
2
+ //
3
+ // Each capability becomes a role with its own prompt and tool set. The Tester uses
4
+ // a forced typebox verdict tool (like the PM) so its pass/fail is structured, which
5
+ // the orchestrator's feedback loop depends on.
6
+
7
+ import {
8
+ AuthStorage,
9
+ ModelRegistry,
10
+ createAgentSession,
11
+ defineTool,
12
+ type AgentSession,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { Type, type Static } from "typebox";
15
+ import type {
16
+ Backend,
17
+ Capability,
18
+ Provider,
19
+ RegistryEntry,
20
+ RoleExecutor,
21
+ RoleResult,
22
+ Task,
23
+ Verdict,
24
+ } from "./types.js";
25
+ import { resolvePiModel } from "./executor.js";
26
+ import { renderCheck } from "./preview.js";
27
+ import { estimateCost } from "./cost.js";
28
+ import { getModel } from "./models.js";
29
+ import { addSessionCost } from "./session-cost.js";
30
+ import { recordActual } from "./calibration.js";
31
+ import { readdirSync, statSync } from "node:fs";
32
+ import { join, relative } from "node:path";
33
+
34
+ // ---- role prompts ----
35
+
36
+ const ROLE_INTRO: Record<Capability, string> = {
37
+ plan: "You are the PLANNER. Produce a concise plan or decision for this task as text.",
38
+ design:
39
+ "You are the DESIGNER. Produce a clear, concrete design spec (layout, components, colours, states) as text. " +
40
+ "If the product spans multiple files, also specify the intended FILE STRUCTURE — name each file and say what it holds (e.g. index.html, styles.css, app.js, or a src/ tree). Do not write code files. " +
41
+ "For a plain static site with no build step, DO NOT spec ES modules with relative imports (`<script type=\"module\">` + `import './x.js'`): browsers block those when the user double-clicks the file (file://), so the app looks dead. Prefer one classic `<script src>` (or a few, loaded in order) so it runs on double-click.",
42
+ code:
43
+ "You are the DEVELOPER. Write real, working files into the working directory — minimal, correct, no placeholders, no TODO stubs. " +
44
+ "This is often a MULTI-FILE project: FIRST inspect what already exists (use ls, then read the relevant files) and BUILD ON it — " +
45
+ "reuse and extend existing files, follow the file structure the design spec defines, and make sure files reference each other with correct paths " +
46
+ "(imports/requires, <script src> and <link href>, relative paths). Create only the files this task needs; never delete or clobber files unrelated to your task. " +
47
+ "MUST-RUN-ON-DOUBLE-CLICK: for a plain static site with no bundler/build step, the app has to work when the user just opens index.html as a file (file://). Do NOT use `<script type=\"module\">` with relative `import`s, and do not `fetch()` local files — browsers block both on file://, leaving a blank page. Split code with several plain `<script src>` tags in dependency order (globals), not ES modules. If the app genuinely needs a server (a real backend, bundler, or framework), write a short README.md with the exact run command.",
48
+ test: "You are the TESTER. For a web app, FIRST call check_app to actually run it in a headless browser — it reports how the app renders BOTH served over http AND opened directly as a file (double-click / file://). Confirm it renders, shows the expected content, and has no JavaScript/console errors. The app MUST also work on double-click (file://) UNLESS a README documents how to run it — if check_app says double-click is BROKEN and there is no README with a run command, that is a HIGH-severity bug (report it, describe the file:// failure). Then inspect the files against the task and check multi-file wiring (referenced files exist, paths/imports resolve). Then call submit_verdict with pass/fail and any bugs. A blank render or a JS error is a high-severity bug. Do not fix anything yourself.",
49
+ ops: "You are OPS. Perform the operational task (build, config, deploy prep) using your tools. Report what you did as text.",
50
+ };
51
+
52
+ export function buildRolePrompt(task: Task, contextText: string): string {
53
+ const lines = [
54
+ ROLE_INTRO[task.capability],
55
+ "",
56
+ `Task ${task.id}: ${task.title}`,
57
+ contextText ? `\n${contextText}` : "",
58
+ "",
59
+ task.capability === "test"
60
+ ? "When finished, call submit_verdict exactly once."
61
+ : "Complete the task, then stop. Do not explain at length.",
62
+ ];
63
+ return lines.filter((l) => l !== "").join("\n");
64
+ }
65
+
66
+ // ---- tester verdict tool (forced structured output) ----
67
+
68
+ const VerdictSchema = Type.Object({
69
+ passed: Type.Boolean({ description: "true if the build satisfies the task with no serious bugs" }),
70
+ bugs: Type.Array(
71
+ Type.Object({
72
+ severity: Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]),
73
+ description: Type.String(),
74
+ file: Type.Optional(Type.String()),
75
+ }),
76
+ { description: "empty if passed" },
77
+ ),
78
+ });
79
+ type VerdictRaw = Static<typeof VerdictSchema>;
80
+
81
+ // ---- tester "run the app" tool: headless render + error capture ----
82
+
83
+ function buildCheckTool(workspace: string) {
84
+ return defineTool({
85
+ name: "check_app",
86
+ label: "Run the app",
87
+ description:
88
+ "Render a built web page in a headless browser and report its title, the visible text, " +
89
+ "and any JavaScript/console errors or failed asset requests. Use this on web apps to confirm " +
90
+ "the app actually RUNS and renders before you judge it — do not rely on reading the code alone.",
91
+ parameters: Type.Object(
92
+ { file: Type.Optional(Type.String({ description: "HTML entry file to load; default index.html" })) },
93
+ { additionalProperties: true },
94
+ ),
95
+ execute: async (_id, params: { file?: string }) => {
96
+ try {
97
+ const r = await renderCheck(workspace, params.file || "index.html");
98
+ const doubleClick = r.doubleClickBroken
99
+ ? "BROKEN — renders behind a server but is blank/erroring when opened directly as a file (double-click). "
100
+ + "Most likely ES modules + relative imports (or fetch of local files), which browsers block on file://. "
101
+ + "This is a real defect for a user who just opens the folder. Fix: use a classic non-module <script>, "
102
+ + "or ship a README with a run command (e.g. `python3 -m http.server`)."
103
+ : r.fileOk
104
+ ? "OK (works on double-click too)"
105
+ : `over file://: ${r.fileErrors.length ? r.fileErrors.join("; ") : "(empty page)"}`;
106
+ const text = [
107
+ `rendered (served over http): ${r.ok ? "OK (no JS errors)" : "with errors"}`,
108
+ `title: ${r.title || "(none)"}`,
109
+ `errors: ${r.errors.length ? "\n - " + r.errors.join("\n - ") : "none"}`,
110
+ `opened as a file (double-click / file://): ${doubleClick}`,
111
+ `visible text:\n${r.text || "(empty page — nothing rendered)"}`,
112
+ ].join("\n");
113
+ return { content: [{ type: "text", text }], details: {} };
114
+ } catch (e) {
115
+ return {
116
+ content: [{ type: "text", text: `check_app could not run (${e instanceof Error ? e.message : e}). If this isn't a web app with an HTML page, inspect the files directly instead.` }],
117
+ details: {},
118
+ };
119
+ }
120
+ },
121
+ });
122
+ }
123
+
124
+ function buildVerdictTool() {
125
+ let captured: Verdict | undefined;
126
+ const tool = defineTool({
127
+ name: "submit_verdict",
128
+ label: "Submit Verdict",
129
+ description: "Submit your pass/fail judgement and any bugs found.",
130
+ parameters: VerdictSchema,
131
+ execute: async (_id, params: VerdictRaw) => {
132
+ captured = { passed: params.passed, bugs: params.bugs };
133
+ return {
134
+ content: [{ type: "text", text: `Verdict: ${params.passed ? "PASS" : "FAIL"} (${params.bugs.length} bugs)` }],
135
+ details: {},
136
+ };
137
+ },
138
+ });
139
+ return { tool, get: () => captured };
140
+ }
141
+
142
+ // ---- provider lock: run the whole pipeline on one provider ----
143
+ // Useful when you hold a key for only one provider. Maps each capability+tier to
144
+ // that provider's sensible model, so route() resolves everything to it.
145
+
146
+ const PROVIDER_MODELS: Record<Provider, { strong: string; mid: string; cheap: string }> = {
147
+ anthropic: { strong: "claude-opus-4-8", mid: "claude-sonnet-4-6", cheap: "claude-haiku-4-5" },
148
+ openai: { strong: "gpt-5.6-sol", mid: "gpt-5.6-terra", cheap: "gpt-5.6-luna" },
149
+ google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3-flash-preview" },
150
+ };
151
+
152
+ const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
153
+ plan: "mid",
154
+ design: "strong",
155
+ code: "strong",
156
+ test: "cheap",
157
+ ops: "strong",
158
+ };
159
+
160
+ /** A registry where every capability routes to one provider's models. */
161
+ export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
162
+ const m = PROVIDER_MODELS[provider];
163
+ const caps: Capability[] = ["plan", "design", "code", "test", "ops"];
164
+ const tiers = ["fast", "mid", "high"] as const;
165
+ const out: RegistryEntry[] = [];
166
+ for (const capability of caps) {
167
+ const modelId = m[CAP_STRENGTH[capability]];
168
+ for (const tier of tiers) {
169
+ out.push({
170
+ capability,
171
+ tier,
172
+ byBackend: { web: { provider, model: modelId }, api: { provider, model: modelId } },
173
+ updated: "provider-lock",
174
+ });
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ // ---- the real Pi executor ----
181
+
182
+ export interface PiExecutorOptions {
183
+ workspace: string;
184
+ backend: Backend;
185
+ authStorage?: AuthStorage;
186
+ thinkingLevel?: "off" | "low" | "medium" | "high";
187
+ onEvent?: Parameters<AgentSession["subscribe"]>[0];
188
+ /** Called when a task falls back from its routed provider to another one. */
189
+ onFallback?: (info: { taskId: string; from: Provider; to: Provider; model: string }) => void;
190
+ }
191
+
192
+ // Env vars that hold each provider's key (mirrors run-build's check).
193
+ const ENV_KEYS: Record<Provider, string[]> = {
194
+ anthropic: ["ANTHROPIC_API_KEY"],
195
+ openai: ["OPENAI_API_KEY"],
196
+ google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
197
+ };
198
+
199
+ function providersWithKeys(): Provider[] {
200
+ return (Object.keys(ENV_KEYS) as Provider[]).filter((p) => ENV_KEYS[p].some((k) => process.env[k]));
201
+ }
202
+
203
+ /** The routed model first, then the same-strength model on every OTHER provider
204
+ * that has a key — so a 0-token / errored provider falls back automatically. */
205
+ function fallbackChain(primary: Provider, primaryModel: string, cap: Capability): { provider: Provider; model: string }[] {
206
+ const chain: { provider: Provider; model: string }[] = [{ provider: primary, model: primaryModel }];
207
+ for (const p of providersWithKeys()) {
208
+ if (p === primary) continue;
209
+ chain.push({ provider: p, model: PROVIDER_MODELS[p][CAP_STRENGTH[cap]] });
210
+ }
211
+ return chain;
212
+ }
213
+
214
+ /** Extract the last assistant text from a session, tolerant of content shape. */
215
+ function lastAssistantText(session: AgentSession): string {
216
+ const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
217
+ for (let i = msgs.length - 1; i >= 0; i--) {
218
+ const m = msgs[i];
219
+ if (m?.role !== "assistant") continue;
220
+ const c = m.content;
221
+ if (typeof c === "string") return c;
222
+ if (Array.isArray(c)) {
223
+ return c
224
+ .map((part: unknown) => {
225
+ if (typeof part === "string") return part;
226
+ if (part && typeof part === "object" && "text" in part) return String((part as { text: unknown }).text);
227
+ return "";
228
+ })
229
+ .join("")
230
+ .trim();
231
+ }
232
+ }
233
+ return "";
234
+ }
235
+
236
+ function listFiles(dir: string): string[] {
237
+ const out: string[] = [];
238
+ const skip = new Set([".pi", ".git", "node_modules"]);
239
+ const walk = (d: string) => {
240
+ let entries: string[];
241
+ try {
242
+ entries = readdirSync(d);
243
+ } catch {
244
+ return;
245
+ }
246
+ for (const name of entries) {
247
+ if (skip.has(name)) continue;
248
+ const full = join(d, name);
249
+ // Tolerate broken symlinks / files removed mid-build; skip, don't abort the run.
250
+ let st;
251
+ try { st = statSync(full); } catch { continue; }
252
+ if (st.isDirectory()) walk(full);
253
+ else out.push(relative(dir, full));
254
+ }
255
+ };
256
+ walk(dir);
257
+ return out.sort();
258
+ }
259
+
260
+ /** Build a real RoleExecutor backed by Pi. Each call spends money. Falls back to
261
+ * another key-holding provider when the routed one errors or returns 0 tokens. */
262
+ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
263
+ const authStorage = opts.authStorage ?? AuthStorage.create();
264
+
265
+ // One attempt on a specific provider/model. Returns the result + total tokens
266
+ // (0 tokens = the provider call didn't really happen → treat as a failure).
267
+ const runOnce = async (
268
+ task: Task,
269
+ contextText: string,
270
+ provider: Provider,
271
+ modelId: string,
272
+ ): Promise<{ result: RoleResult; tokensTotal: number }> => {
273
+ const registry = ModelRegistry.create(authStorage);
274
+ const model = resolvePiModel(registry, provider, modelId);
275
+
276
+ const isTest = task.capability === "test";
277
+ const verdictTool = isTest ? buildVerdictTool() : undefined;
278
+ const checkTool = isTest ? buildCheckTool(opts.workspace) : undefined;
279
+
280
+ const { session } = await createAgentSession({
281
+ model,
282
+ cwd: opts.workspace,
283
+ authStorage,
284
+ modelRegistry: registry,
285
+ thinkingLevel: opts.thinkingLevel ?? "medium",
286
+ ...(isTest
287
+ ? { customTools: [verdictTool!.tool, checkTool!], tools: ["read", "bash", "ls", "grep", "find", "check_app", "submit_verdict"] }
288
+ : { tools: ["read", "write", "edit", "bash", "ls", "grep", "find"] }),
289
+ });
290
+
291
+ const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
292
+ try {
293
+ // Give code/test the WHOLE current file tree (not just direct-dep files), so a
294
+ // dev building one file knows every other file that already exists to wire into.
295
+ let fullContext = contextText;
296
+ if (task.capability === "code" || task.capability === "test") {
297
+ const existing = listFiles(opts.workspace);
298
+ if (existing.length) {
299
+ fullContext = [contextText, `Files already in the working directory:\n${existing.map((f) => ` ${f}`).join("\n")}`]
300
+ .filter(Boolean)
301
+ .join("\n\n");
302
+ }
303
+ }
304
+ await session.prompt(buildRolePrompt(task, fullContext));
305
+
306
+ let verdict = verdictTool?.get();
307
+ if (isTest && !verdict) {
308
+ await session.followUp("Call submit_verdict now with your judgement.");
309
+ verdict = verdictTool?.get();
310
+ }
311
+
312
+ const stats = session.getSessionStats();
313
+ addSessionCost(stats.cost);
314
+ // Feed real usage back to sharpen estimates — but only for a real run.
315
+ if (stats.tokens.total > 0) {
316
+ const inputTotal = stats.tokens.input + stats.tokens.cacheRead;
317
+ recordActual(task.capability, task.difficulty, inputTotal, stats.tokens.output, inputTotal > 0 ? stats.tokens.cacheRead / inputTotal : 0);
318
+ }
319
+ const result: RoleResult = {
320
+ finalText: lastAssistantText(session),
321
+ files: listFiles(opts.workspace),
322
+ cost: round2(stats.cost),
323
+ verdict,
324
+ };
325
+ return { result, tokensTotal: stats.tokens.total };
326
+ } finally {
327
+ unsub?.();
328
+ session.dispose();
329
+ }
330
+ };
331
+
332
+ return async ({ task, decision, contextText }) => {
333
+ const chain = fallbackChain(decision.provider, decision.model.id, task.capability);
334
+ let lastErr: unknown;
335
+ for (let i = 0; i < chain.length; i++) {
336
+ const cand = chain[i]!;
337
+ try {
338
+ const att = await runOnce(task, contextText, cand.provider, cand.model);
339
+ if (att.tokensTotal > 0) {
340
+ if (i > 0) opts.onFallback?.({ taskId: task.id, from: decision.provider, to: cand.provider, model: cand.model });
341
+ return att.result;
342
+ }
343
+ lastErr = new Error(`${cand.provider}/${cand.model} returned 0 tokens (key likely can't access this model)`);
344
+ } catch (e) {
345
+ lastErr = e;
346
+ }
347
+ }
348
+ throw lastErr instanceof Error ? lastErr : new Error("all providers failed");
349
+ };
350
+ }
351
+
352
+ function round2(n: number): number {
353
+ return Math.round(n * 100) / 100;
354
+ }
355
+
356
+ // exported for tests
357
+ export { buildVerdictTool, VerdictSchema, estimateCost, getModel };