projectinator 0.3.1 → 0.4.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 (171) hide show
  1. package/README.md +7 -6
  2. package/bin/projectinator.mjs +22 -15
  3. package/dist/bakeoff.js +163 -0
  4. package/dist/bakeoff.js.map +1 -0
  5. package/dist/build-state.js +26 -0
  6. package/dist/build-state.js.map +1 -0
  7. package/dist/burndown.js +19 -0
  8. package/dist/burndown.js.map +1 -0
  9. package/dist/calibration.js +86 -0
  10. package/dist/calibration.js.map +1 -0
  11. package/dist/cli.js +289 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/cost.js +35 -0
  14. package/dist/cost.js.map +1 -0
  15. package/dist/council.js +147 -0
  16. package/dist/council.js.map +1 -0
  17. package/dist/demo.js +93 -0
  18. package/dist/demo.js.map +1 -0
  19. package/dist/estimate.js +98 -0
  20. package/dist/estimate.js.map +1 -0
  21. package/dist/executor.js +118 -0
  22. package/dist/executor.js.map +1 -0
  23. package/dist/git.js +82 -0
  24. package/dist/git.js.map +1 -0
  25. package/dist/intake.js +98 -0
  26. package/dist/intake.js.map +1 -0
  27. package/dist/models.js +171 -0
  28. package/dist/models.js.map +1 -0
  29. package/dist/narrate.js +76 -0
  30. package/dist/narrate.js.map +1 -0
  31. package/dist/openrouter.js +112 -0
  32. package/dist/openrouter.js.map +1 -0
  33. package/dist/orchestrator.js +273 -0
  34. package/dist/orchestrator.js.map +1 -0
  35. package/dist/pm.js +245 -0
  36. package/dist/pm.js.map +1 -0
  37. package/dist/preview.js +194 -0
  38. package/dist/preview.js.map +1 -0
  39. package/dist/registry-store.js +38 -0
  40. package/dist/registry-store.js.map +1 -0
  41. package/dist/registry.js +115 -0
  42. package/dist/registry.js.map +1 -0
  43. package/dist/research.js +98 -0
  44. package/dist/research.js.map +1 -0
  45. package/dist/retro.js +83 -0
  46. package/dist/retro.js.map +1 -0
  47. package/dist/roles.js +357 -0
  48. package/dist/roles.js.map +1 -0
  49. package/dist/router.js +95 -0
  50. package/dist/router.js.map +1 -0
  51. package/dist/run-bakeoff.js +68 -0
  52. package/dist/run-bakeoff.js.map +1 -0
  53. package/dist/run-build.js +193 -0
  54. package/dist/run-build.js.map +1 -0
  55. package/{src/run-dev.ts → dist/run-dev.js} +23 -39
  56. package/dist/run-dev.js.map +1 -0
  57. package/{src/run-pm.ts → dist/run-pm.js} +34 -46
  58. package/dist/run-pm.js.map +1 -0
  59. package/{src/run-research.ts → dist/run-research.js} +19 -30
  60. package/dist/run-research.js.map +1 -0
  61. package/{src/run-scout.ts → dist/run-scout.js} +17 -27
  62. package/dist/run-scout.js.map +1 -0
  63. package/dist/run-web.js +93 -0
  64. package/dist/run-web.js.map +1 -0
  65. package/dist/scout.js +81 -0
  66. package/dist/scout.js.map +1 -0
  67. package/dist/session-cost.js +15 -0
  68. package/dist/session-cost.js.map +1 -0
  69. package/dist/stack.js +34 -0
  70. package/dist/stack.js.map +1 -0
  71. package/dist/stuck.js +21 -0
  72. package/dist/stuck.js.map +1 -0
  73. package/dist/tui/App.js +1269 -0
  74. package/dist/tui/App.js.map +1 -0
  75. package/dist/tui/BakeOff.js +95 -0
  76. package/dist/tui/BakeOff.js.map +1 -0
  77. package/dist/tui/BoardEditor.js +183 -0
  78. package/dist/tui/BoardEditor.js.map +1 -0
  79. package/dist/tui/EditableBoard.js +140 -0
  80. package/dist/tui/EditableBoard.js.map +1 -0
  81. package/dist/tui/Frame.js +78 -0
  82. package/dist/tui/Frame.js.map +1 -0
  83. package/dist/tui/Intake.js +59 -0
  84. package/dist/tui/Intake.js.map +1 -0
  85. package/dist/tui/Kanban.js +75 -0
  86. package/dist/tui/Kanban.js.map +1 -0
  87. package/dist/tui/Settings.js +299 -0
  88. package/dist/tui/Settings.js.map +1 -0
  89. package/dist/tui/StackPick.js +44 -0
  90. package/dist/tui/StackPick.js.map +1 -0
  91. package/dist/tui/WebAccounts.js +131 -0
  92. package/dist/tui/WebAccounts.js.map +1 -0
  93. package/dist/tui/components.js +178 -0
  94. package/dist/tui/components.js.map +1 -0
  95. package/dist/tui/config.js +129 -0
  96. package/dist/tui/config.js.map +1 -0
  97. package/dist/tui/deploy.js +114 -0
  98. package/dist/tui/deploy.js.map +1 -0
  99. package/dist/tui/engine.js +710 -0
  100. package/dist/tui/engine.js.map +1 -0
  101. package/dist/tui/notify.js +47 -0
  102. package/dist/tui/notify.js.map +1 -0
  103. package/dist/tui/panels.js +42 -0
  104. package/dist/tui/panels.js.map +1 -0
  105. package/dist/tui/templates.js +106 -0
  106. package/dist/tui/templates.js.map +1 -0
  107. package/dist/tui/theme.js +42 -0
  108. package/dist/tui/theme.js.map +1 -0
  109. package/dist/tui/validate.js +51 -0
  110. package/dist/tui/validate.js.map +1 -0
  111. package/{src/tui.tsx → dist/tui.js} +23 -33
  112. package/dist/tui.js.map +1 -0
  113. package/dist/types.js +19 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/web/oauth-anthropic.js +179 -0
  116. package/dist/web/oauth-anthropic.js.map +1 -0
  117. package/dist/web/session.js +274 -0
  118. package/dist/web/session.js.map +1 -0
  119. package/package.json +5 -4
  120. package/src/bakeoff.ts +0 -214
  121. package/src/build-state.ts +0 -47
  122. package/src/burndown.ts +0 -35
  123. package/src/calibration.ts +0 -88
  124. package/src/cli.ts +0 -263
  125. package/src/cost.ts +0 -43
  126. package/src/council.ts +0 -175
  127. package/src/demo.ts +0 -106
  128. package/src/estimate.ts +0 -111
  129. package/src/executor.ts +0 -169
  130. package/src/git.ts +0 -72
  131. package/src/intake.ts +0 -125
  132. package/src/models.ts +0 -175
  133. package/src/narrate.ts +0 -87
  134. package/src/openrouter.ts +0 -119
  135. package/src/orchestrator.ts +0 -310
  136. package/src/pm.ts +0 -302
  137. package/src/preview.ts +0 -206
  138. package/src/registry-store.ts +0 -41
  139. package/src/registry.ts +0 -132
  140. package/src/research.ts +0 -123
  141. package/src/retro.ts +0 -99
  142. package/src/roles.ts +0 -401
  143. package/src/router.ts +0 -120
  144. package/src/run-bakeoff.ts +0 -77
  145. package/src/run-build.ts +0 -203
  146. package/src/run-web.ts +0 -87
  147. package/src/scout.ts +0 -121
  148. package/src/session-cost.ts +0 -17
  149. package/src/stack.ts +0 -46
  150. package/src/tui/App.tsx +0 -1760
  151. package/src/tui/BakeOff.tsx +0 -190
  152. package/src/tui/BoardEditor.tsx +0 -260
  153. package/src/tui/EditableBoard.tsx +0 -179
  154. package/src/tui/Frame.tsx +0 -142
  155. package/src/tui/Intake.tsx +0 -111
  156. package/src/tui/Kanban.tsx +0 -158
  157. package/src/tui/Settings.tsx +0 -513
  158. package/src/tui/StackPick.tsx +0 -79
  159. package/src/tui/WebAccounts.tsx +0 -197
  160. package/src/tui/components.tsx +0 -340
  161. package/src/tui/config.ts +0 -150
  162. package/src/tui/deploy.ts +0 -137
  163. package/src/tui/engine.ts +0 -749
  164. package/src/tui/notify.ts +0 -21
  165. package/src/tui/panels.tsx +0 -89
  166. package/src/tui/templates.ts +0 -119
  167. package/src/tui/theme.ts +0 -44
  168. package/src/tui/validate.ts +0 -53
  169. package/src/types.ts +0 -208
  170. package/src/web/oauth-anthropic.ts +0 -206
  171. package/src/web/session.ts +0 -299
package/src/roles.ts DELETED
@@ -1,401 +0,0 @@
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
- createAgentSession,
9
- defineTool,
10
- type AgentSession,
11
- } from "@earendil-works/pi-coding-agent";
12
- import { Type, type Static } from "typebox";
13
- import {
14
- TaskLimitError,
15
- type Backend,
16
- type Capability,
17
- type Provider,
18
- type RegistryEntry,
19
- type RoleExecutor,
20
- type RoleResult,
21
- type Task,
22
- type TaskLimits,
23
- type Verdict,
24
- } from "./types.js";
25
- import { piRuntime, resolvePiModel } from "./executor.js"
26
- import { renderCheck, chromiumAvailable, CHROMIUM_INSTALL_HINT } 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
- review:
49
- "You are the REVIEWER. Do NOT edit files and do NOT run the app. Read the task, the design context, and the files in the working directory. " +
50
- "Check: every file the design named exists; every <script src> / <link href> / import resolves to a real file; nothing is referenced but never defined " +
51
- "(functions, element ids, CSS classes the JS relies on); a plain static site uses no ES modules or fetch() of local files (both break on double-click / file://); " +
52
- "and the task's stated deliverable is actually present. Report only real defects a developer must fix — not style. Then call submit_verdict exactly once.",
53
- 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.",
54
- ops: "You are OPS. Perform the operational task (build, config, deploy prep) using your tools. Report what you did as text.",
55
- };
56
-
57
- export function buildRolePrompt(task: Task, contextText: string): string {
58
- const lines = [
59
- ROLE_INTRO[task.capability],
60
- "",
61
- `Task ${task.id}: ${task.title}`,
62
- contextText ? `\n${contextText}` : "",
63
- "",
64
- task.capability === "test" || task.capability === "review"
65
- ? "When finished, call submit_verdict exactly once."
66
- : "Complete the task, then stop. Do not explain at length.",
67
- ];
68
- return lines.filter((l) => l !== "").join("\n");
69
- }
70
-
71
- // ---- tester verdict tool (forced structured output) ----
72
-
73
- const VerdictSchema = Type.Object({
74
- passed: Type.Boolean({ description: "true if the build satisfies the task with no serious bugs" }),
75
- bugs: Type.Array(
76
- Type.Object({
77
- severity: Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]),
78
- description: Type.String(),
79
- file: Type.Optional(Type.String()),
80
- }),
81
- { description: "empty if passed" },
82
- ),
83
- });
84
- type VerdictRaw = Static<typeof VerdictSchema>;
85
-
86
- // ---- tester "run the app" tool: headless render + error capture ----
87
-
88
- /** check_app + a flag telling whether a render actually happened this task. */
89
- function buildCheckTool(workspace: string, chromium: boolean) {
90
- let rendered = false;
91
- const tool = defineTool({
92
- name: "check_app",
93
- label: "Run the app",
94
- description:
95
- "Render a built web page in a headless browser and report its title, the visible text, " +
96
- "and any JavaScript/console errors or failed asset requests. Use this on web apps to confirm " +
97
- "the app actually RUNS and renders before you judge it — do not rely on reading the code alone.",
98
- parameters: Type.Object(
99
- { file: Type.Optional(Type.String({ description: "HTML entry file to load; default index.html" })) },
100
- { additionalProperties: true },
101
- ),
102
- execute: async (_id, params: { file?: string }) => {
103
- if (!chromium) {
104
- return {
105
- content: [{ type: "text", text: `check_app is UNAVAILABLE: headless Chromium is not installed (${CHROMIUM_INSTALL_HINT}). You cannot run the app. Review the files by reading them, say so in your verdict, and do not claim the app was executed.` }],
106
- details: {},
107
- };
108
- }
109
- try {
110
- const r = await renderCheck(workspace, params.file || "index.html");
111
- rendered = true;
112
- const doubleClick = r.doubleClickBroken
113
- ? "BROKEN — renders behind a server but is blank/erroring when opened directly as a file (double-click). "
114
- + "Most likely ES modules + relative imports (or fetch of local files), which browsers block on file://. "
115
- + "This is a real defect for a user who just opens the folder. Fix: use a classic non-module <script>, "
116
- + "or ship a README with a run command (e.g. `python3 -m http.server`)."
117
- : r.fileOk
118
- ? "OK (works on double-click too)"
119
- : `over file://: ${r.fileErrors.length ? r.fileErrors.join("; ") : "(empty page)"}`;
120
- const text = [
121
- `rendered (served over http): ${r.ok ? "OK (no JS errors)" : "with errors"}`,
122
- `title: ${r.title || "(none)"}`,
123
- `errors: ${r.errors.length ? "\n - " + r.errors.join("\n - ") : "none"}`,
124
- `opened as a file (double-click / file://): ${doubleClick}`,
125
- `visible text:\n${r.text || "(empty page — nothing rendered)"}`,
126
- ].join("\n");
127
- return { content: [{ type: "text", text }], details: {} };
128
- } catch (e) {
129
- return {
130
- 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.` }],
131
- details: {},
132
- };
133
- }
134
- },
135
- });
136
- return { tool, rendered: () => rendered };
137
- }
138
-
139
- function buildVerdictTool(runtimeChecked: () => boolean) {
140
- let captured: Verdict | undefined;
141
- const tool = defineTool({
142
- name: "submit_verdict",
143
- label: "Submit Verdict",
144
- description: "Submit your pass/fail judgement and any bugs found.",
145
- parameters: VerdictSchema,
146
- execute: async (_id, params: VerdictRaw) => {
147
- captured = { passed: params.passed, bugs: params.bugs, runtimeChecked: runtimeChecked() };
148
- return {
149
- content: [{ type: "text", text: `Verdict: ${params.passed ? "PASS" : "FAIL"} (${params.bugs.length} bugs)` }],
150
- details: {},
151
- };
152
- },
153
- });
154
- return { tool, get: () => captured };
155
- }
156
-
157
- // ---- provider lock: run the whole pipeline on one provider ----
158
- // Useful when you hold a key for only one provider. Maps each capability+tier to
159
- // that provider's sensible model, so route() resolves everything to it.
160
-
161
- const PROVIDER_MODELS: Record<Provider, { strong: string; mid: string; cheap: string }> = {
162
- anthropic: { strong: "claude-opus-5", mid: "claude-sonnet-5", cheap: "claude-haiku-4-5" },
163
- openai: { strong: "gpt-5.6-sol", mid: "gpt-5.6-terra", cheap: "gpt-5.6-luna" },
164
- google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3.8-flash" },
165
- openrouter: { strong: "anthropic/claude-opus-5", mid: "anthropic/claude-sonnet-5", cheap: "google/gemini-3.8-flash" },
166
- };
167
-
168
- const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
169
- plan: "mid",
170
- design: "strong",
171
- code: "strong",
172
- review: "cheap",
173
- test: "cheap",
174
- ops: "strong",
175
- };
176
-
177
- /** A registry where every capability routes to one provider's models. */
178
- export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
179
- const m = PROVIDER_MODELS[provider];
180
- const caps: Capability[] = ["plan", "design", "code", "review", "test", "ops"];
181
- const tiers = ["fast", "mid", "high"] as const;
182
- const out: RegistryEntry[] = [];
183
- for (const capability of caps) {
184
- const modelId = m[CAP_STRENGTH[capability]];
185
- for (const tier of tiers) {
186
- out.push({
187
- capability,
188
- tier,
189
- byBackend: { web: { provider, model: modelId }, api: { provider, model: modelId } },
190
- updated: "provider-lock",
191
- });
192
- }
193
- }
194
- return out;
195
- }
196
-
197
- // ---- the real Pi executor ----
198
-
199
- export interface PiExecutorOptions {
200
- workspace: string;
201
- backend: Backend;
202
- thinkingLevel?: "off" | "low" | "medium" | "high";
203
- onEvent?: Parameters<AgentSession["subscribe"]>[0];
204
- /** Called when a task falls back from its routed provider to another one. */
205
- onFallback?: (info: { taskId: string; from: Provider; to: Provider; model: string }) => void;
206
- }
207
-
208
- // Env vars that hold each provider's key (mirrors run-build's check).
209
- const ENV_KEYS: Record<Provider, string[]> = {
210
- anthropic: ["ANTHROPIC_API_KEY"],
211
- openai: ["OPENAI_API_KEY"],
212
- google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
213
- openrouter: ["OPENROUTER_API_KEY"],
214
- };
215
-
216
- function providersWithKeys(): Provider[] {
217
- return (Object.keys(ENV_KEYS) as Provider[]).filter((p) => ENV_KEYS[p].some((k) => process.env[k]));
218
- }
219
-
220
- /** The routed model first, then the same-strength model on every OTHER provider
221
- * that has a key — so a 0-token / errored provider falls back automatically. */
222
- function fallbackChain(primary: Provider, primaryModel: string, cap: Capability): { provider: Provider; model: string }[] {
223
- const chain: { provider: Provider; model: string }[] = [{ provider: primary, model: primaryModel }];
224
- for (const p of providersWithKeys()) {
225
- if (p === primary) continue;
226
- chain.push({ provider: p, model: PROVIDER_MODELS[p][CAP_STRENGTH[cap]] });
227
- }
228
- return chain;
229
- }
230
-
231
- /** Extract the last assistant text from a session, tolerant of content shape. */
232
- function lastAssistantText(session: AgentSession): string {
233
- const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
234
- for (let i = msgs.length - 1; i >= 0; i--) {
235
- const m = msgs[i];
236
- if (m?.role !== "assistant") continue;
237
- const c = m.content;
238
- if (typeof c === "string") return c;
239
- if (Array.isArray(c)) {
240
- return c
241
- .map((part: unknown) => {
242
- if (typeof part === "string") return part;
243
- if (part && typeof part === "object" && "text" in part) return String((part as { text: unknown }).text);
244
- return "";
245
- })
246
- .join("")
247
- .trim();
248
- }
249
- }
250
- return "";
251
- }
252
-
253
- function listFiles(dir: string): string[] {
254
- const out: string[] = [];
255
- const skip = new Set([".pi", ".git", "node_modules"]);
256
- const walk = (d: string) => {
257
- let entries: string[];
258
- try {
259
- entries = readdirSync(d);
260
- } catch {
261
- return;
262
- }
263
- for (const name of entries) {
264
- if (skip.has(name)) continue;
265
- const full = join(d, name);
266
- // Tolerate broken symlinks / files removed mid-build; skip, don't abort the run.
267
- let st;
268
- try { st = statSync(full); } catch { continue; }
269
- if (st.isDirectory()) walk(full);
270
- else out.push(relative(dir, full));
271
- }
272
- };
273
- walk(dir);
274
- return out.sort();
275
- }
276
-
277
- /** Build a real RoleExecutor backed by Pi. Each call spends money. Falls back to
278
- * another key-holding provider when the routed one errors or returns 0 tokens. */
279
- export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
280
- // One attempt on a specific provider/model. Returns the result + total tokens
281
- // (0 tokens = the provider call didn't really happen → treat as a failure).
282
- const runOnce = async (
283
- task: Task,
284
- contextText: string,
285
- provider: Provider,
286
- modelId: string,
287
- limits: TaskLimits,
288
- ): Promise<{ result: RoleResult; tokensTotal: number }> => {
289
- const runtime = await piRuntime();
290
- const model = resolvePiModel(runtime, provider, modelId);
291
-
292
- const isTest = task.capability === "test";
293
- const isReview = task.capability === "review";
294
- const checkTool = isTest ? buildCheckTool(opts.workspace, await chromiumAvailable()) : undefined;
295
- // A review never runs the app, so its verdict is never "runtime checked".
296
- const verdictTool = isTest || isReview ? buildVerdictTool(checkTool ? checkTool.rendered : () => false) : undefined;
297
-
298
- const { session } = await createAgentSession({
299
- model,
300
- cwd: opts.workspace,
301
- modelRuntime: runtime,
302
- thinkingLevel: opts.thinkingLevel ?? "medium",
303
- ...(isTest
304
- ? { customTools: [verdictTool!.tool, checkTool!.tool], tools: ["read", "bash", "ls", "grep", "find", "check_app", "submit_verdict"] }
305
- : isReview
306
- ? { customTools: [verdictTool!.tool], tools: ["read", "ls", "grep", "find", "submit_verdict"] }
307
- : { tools: ["read", "write", "edit", "bash", "ls", "grep", "find"] }),
308
- });
309
-
310
- const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
311
-
312
- // Per-task limits. Cost is checked on every session event (Pi updates its stats as
313
- // each assistant turn lands); time by a timer. On breach the session is aborted and
314
- // the awaited prompt settles; we then throw so the orchestrator halts the build.
315
- let breach: TaskLimitError | undefined;
316
- const trip = (e: TaskLimitError) => {
317
- if (breach) return;
318
- breach = e;
319
- void session.abort();
320
- };
321
- const unsubCost = limits.costCapUSD > 0
322
- ? session.subscribe(() => {
323
- const spent = session.getSessionStats().cost;
324
- if (spent > limits.costCapUSD) trip(new TaskLimitError("cost", task.id, round2(spent), `spent $${spent.toFixed(2)} > per-task cap $${limits.costCapUSD}`));
325
- })
326
- : undefined;
327
- const timer = limits.timeoutMs > 0
328
- ? setTimeout(() => trip(new TaskLimitError("timeout", task.id, round2(session.getSessionStats().cost), `ran longer than ${Math.round(limits.timeoutMs / 60_000)} min`)), limits.timeoutMs)
329
- : undefined;
330
- try {
331
- // Give code/review/test the WHOLE current file tree (not just direct-dep files), so a
332
- // dev building one file knows every other file that already exists to wire into.
333
- let fullContext = contextText;
334
- if (task.capability === "code" || task.capability === "review" || task.capability === "test") {
335
- const existing = listFiles(opts.workspace);
336
- if (existing.length) {
337
- fullContext = [contextText, `Files already in the working directory:\n${existing.map((f) => ` ${f}`).join("\n")}`]
338
- .filter(Boolean)
339
- .join("\n\n");
340
- }
341
- }
342
- // An aborted prompt may reject with Pi's own error; the breach is the real cause.
343
- await session.prompt(buildRolePrompt(task, fullContext)).catch((e: unknown) => { if (!breach) throw e; });
344
- if (breach) throw breach;
345
-
346
- let verdict = verdictTool?.get();
347
- if (verdictTool && !verdict) {
348
- await session.followUp("Call submit_verdict now with your judgement.").catch((e: unknown) => { if (!breach) throw e; });
349
- verdict = verdictTool?.get();
350
- }
351
- if (breach) throw breach;
352
-
353
- const stats = session.getSessionStats();
354
- // Feed real usage back to sharpen estimates — but only for a real run.
355
- if (stats.tokens.total > 0) {
356
- const inputTotal = stats.tokens.input + stats.tokens.cacheRead;
357
- recordActual(task.capability, task.difficulty, inputTotal, stats.tokens.output, inputTotal > 0 ? stats.tokens.cacheRead / inputTotal : 0);
358
- }
359
- const result: RoleResult = {
360
- finalText: lastAssistantText(session),
361
- files: listFiles(opts.workspace),
362
- cost: round2(stats.cost),
363
- verdict,
364
- };
365
- return { result, tokensTotal: stats.tokens.total };
366
- } finally {
367
- clearTimeout(timer);
368
- unsubCost?.();
369
- addSessionCost(session.getSessionStats().cost); // bill every attempt, aborted or not
370
- unsub?.();
371
- session.dispose();
372
- }
373
- };
374
-
375
- return async ({ task, decision, contextText, limits }) => {
376
- const chain = fallbackChain(decision.provider, decision.model.id, task.capability);
377
- let lastErr: unknown;
378
- for (let i = 0; i < chain.length; i++) {
379
- const cand = chain[i]!;
380
- try {
381
- const att = await runOnce(task, contextText, cand.provider, cand.model, limits);
382
- if (att.tokensTotal > 0) {
383
- if (i > 0) opts.onFallback?.({ taskId: task.id, from: decision.provider, to: cand.provider, model: cand.model });
384
- return att.result;
385
- }
386
- lastErr = new Error(`${cand.provider}/${cand.model} returned 0 tokens (invalid key, no account credit/balance, or no access to this model)`);
387
- } catch (e) {
388
- if (e instanceof TaskLimitError) throw e; // a limit breach is final — never retry elsewhere
389
- lastErr = e;
390
- }
391
- }
392
- throw lastErr instanceof Error ? lastErr : new Error("all providers failed");
393
- };
394
- }
395
-
396
- function round2(n: number): number {
397
- return Math.round(n * 100) / 100;
398
- }
399
-
400
- // exported for tests
401
- export { buildCheckTool, buildVerdictTool, VerdictSchema, estimateCost, getModel };
package/src/router.ts DELETED
@@ -1,120 +0,0 @@
1
- // The Router — deterministic dispatch. No LLM, no network. Given a tagged task
2
- // and a policy, it resolves backend -> model -> cost, and flags budget overruns.
3
- //
4
- // User prompts (which backend? which model on API?) are injected as callbacks so
5
- // the router stays pure and unit-testable. In the CLI they wrap real prompts;
6
- // in tests they're stubs.
7
-
8
- import type {
9
- Backend,
10
- RegistryEntry,
11
- RouteDecision,
12
- RoutingPolicy,
13
- Task,
14
- } from "./types.js";
15
- import { estimateCost } from "./cost.js";
16
- import { getModel } from "./models.js";
17
- import { findEntry, REGISTRY } from "./registry.js";
18
-
19
- export interface RouterPrompts {
20
- /** Ask the user which backend to use. Called only when backendMode === "ask". */
21
- chooseBackend?: (task: Task) => Backend;
22
- /** Ask the user which model on API. Called only when entry.ask && backend === "api".
23
- * Return a model id, or undefined to accept the registry default. */
24
- chooseModel?: (task: Task, entry: RegistryEntry, backend: Backend) => string | undefined;
25
- }
26
-
27
- export interface RouteContext {
28
- policy: RoutingPolicy;
29
- registry?: RegistryEntry[];
30
- prompts?: RouterPrompts;
31
- /** Cumulative spend before this task, USD. */
32
- runningTotalBefore?: number;
33
- }
34
-
35
- /** Resolve which backend to use from the policy (and a prompt, if "ask"). */
36
- export function resolveBackend(policy: RoutingPolicy, task: Task, prompts?: RouterPrompts): Backend {
37
- switch (policy.backendMode) {
38
- case "api":
39
- return "api";
40
- case "web":
41
- return "web";
42
- case "cost-first":
43
- // Web-login rides the user's existing subscription -> effectively free.
44
- return "web";
45
- case "ask":
46
- if (!prompts?.chooseBackend) {
47
- throw new Error('backendMode "ask" requires prompts.chooseBackend');
48
- }
49
- return prompts.chooseBackend(task);
50
- }
51
- }
52
-
53
- export function route(task: Task, ctx: RouteContext): RouteDecision {
54
- const { policy } = ctx;
55
- const registry = ctx.registry ?? REGISTRY;
56
- const reasons: string[] = [];
57
-
58
- // 1. Backend.
59
- const backend = resolveBackend(policy, task, ctx.prompts);
60
- reasons.push(`backend=${backend} (mode=${policy.backendMode})`);
61
-
62
- // 2. Difficulty -> tier.
63
- const tier = policy.difficultyToTier[task.difficulty];
64
- reasons.push(`difficulty=${task.difficulty} -> tier=${tier}`);
65
-
66
- // 3. Registry lookup (with tier fallback).
67
- const { entry, exactTier } = findEntry(task.capability, tier, registry);
68
- if (!exactTier) reasons.push(`no ${task.capability}/${tier} entry, fell back to ${entry.tier}`);
69
-
70
- // 4. Backend-conditional model, with optional per-role prompt on API.
71
- let modelId = entry.byBackend[backend].model;
72
- if (backend === "api" && entry.ask && ctx.prompts?.chooseModel) {
73
- const picked = ctx.prompts.chooseModel(task, entry, backend);
74
- if (picked && picked !== modelId) {
75
- reasons.push(`user overrode model ${modelId} -> ${picked}`);
76
- modelId = picked;
77
- }
78
- }
79
- const model = getModel(modelId);
80
- reasons.push(`model=${model.id} (${model.provider})`);
81
-
82
- // 5. Cost.
83
- const cost = estimateCost(task.estTokens, model);
84
- const runningTotal = Math.round(((ctx.runningTotalBefore ?? 0) + cost) * 10_000) / 10_000;
85
- const overCap = runningTotal > policy.budgetCapUSD;
86
- if (overCap) reasons.push(`OVER CAP: running $${runningTotal} > cap $${policy.budgetCapUSD}`);
87
-
88
- return {
89
- taskId: task.id,
90
- backend,
91
- provider: model.provider,
92
- model,
93
- tier: entry.tier,
94
- cost,
95
- runningTotal,
96
- overCap,
97
- reasons,
98
- };
99
- }
100
-
101
- /** Route a whole backlog in order, threading the running total. */
102
- export function routeBacklog(tasks: Task[], ctx: RouteContext): RouteDecision[] {
103
- const out: RouteDecision[] = [];
104
- let running = ctx.runningTotalBefore ?? 0;
105
- for (const task of tasks) {
106
- const decision = route(task, { ...ctx, runningTotalBefore: running });
107
- running = decision.runningTotal;
108
- out.push(decision);
109
- }
110
- return out;
111
- }
112
-
113
- /** A sensible default policy. */
114
- export const DEFAULT_POLICY: RoutingPolicy = {
115
- backendMode: "cost-first",
116
- budgetCapUSD: 15,
117
- difficultyToTier: { trivial: "fast", low: "mid", medium: "mid", high: "high" },
118
- maxFeedbackRounds: 3,
119
- taskLimits: { timeoutMs: 10 * 60_000, costCapUSD: 3 },
120
- };
@@ -1,77 +0,0 @@
1
- // Model bake-off CLI — run one task across models, compare cost/latency/quality.
2
- //
3
- // npm run bakeoff -- --capability design "Design a pricing page with 3 tiers"
4
- // npm run bakeoff -- --capability plan "Plan an MVP task list for a URL shortener"
5
- // npm run bakeoff -- --models claude-opus-4-8,claude-sonnet-4-6,claude-haiku-4-5 "..."
6
- //
7
- // Defaults to the three Anthropic tiers (what most people hold a key for). Add
8
- // --models to compare any model ids; --provider to set their provider.
9
-
10
- import type { Capability, Difficulty, Provider } from "./types.js";
11
- import { runBakeoff, bakeoffTask, type Candidate } from "./bakeoff.js";
12
-
13
- const args = process.argv.slice(2);
14
- function opt(name: string): string | undefined {
15
- const i = args.indexOf(name);
16
- return i >= 0 ? args[i + 1] : undefined;
17
- }
18
-
19
- const capability = (opt("--capability") ?? "design") as Capability;
20
- const difficulty = (opt("--difficulty") ?? "medium") as Difficulty;
21
- const provider = (opt("--provider") ?? "anthropic") as Provider;
22
- const modelsArg = opt("--models");
23
- const consumed = new Set(["--capability", capability, "--difficulty", difficulty, "--provider", provider, "--models", modelsArg ?? ""].filter(Boolean));
24
- const prompt = args.filter((a) => !consumed.has(a)).join(" ").trim();
25
-
26
- if (!prompt) {
27
- console.log('\n Usage: npm run bakeoff -- --capability design "your task"\n');
28
- process.exit(1);
29
- }
30
- if (capability === "code") {
31
- console.log("\n Code bake-off isn't supported yet (needs a sandbox + real test scoring per model).");
32
- console.log(" Try --capability design or plan for now.\n");
33
- process.exit(1);
34
- }
35
-
36
- const models = (modelsArg ?? "claude-opus-4-8,claude-sonnet-4-6,claude-haiku-4-5").split(",").map((s) => s.trim()).filter(Boolean);
37
- const candidates: Candidate[] = models.map((model) => ({ provider, model }));
38
-
39
- console.log(`\n Bake-off — ${capability}/${difficulty} ${candidates.length} models\n Task: ${prompt}\n`);
40
-
41
- const result = await runBakeoff(bakeoffTask(prompt, capability, difficulty), candidates, {
42
- onProgress: (m) => console.log(" " + m),
43
- });
44
-
45
- // ---- report ----
46
- const scoreOf = new Map(result.scores.map((s) => [s.model, s]));
47
- const money = (n: number) => `$${n.toFixed(4)}`;
48
- console.log("\n --- RESULTS ---");
49
- console.log(" model".padEnd(30) + "score".padEnd(8) + "cost".padEnd(12) + "time".padEnd(8) + "tokens");
50
- for (const e of result.entries) {
51
- const key = `${e.provider}/${e.model}`;
52
- const sc = scoreOf.get(key);
53
- const scoreStr = e.error ? "ERR" : sc ? `${sc.score}/10` : "—";
54
- const line =
55
- (" " + e.model).padEnd(30) +
56
- scoreStr.padEnd(8) +
57
- (e.error ? "—" : money(e.cost)).padEnd(12) +
58
- (e.error ? "—" : `${(e.ms / 1000).toFixed(1)}s`).padEnd(8) +
59
- (e.error ? "" : String(e.outputTokens));
60
- console.log(line);
61
- if (e.error) console.log(` ↳ ${e.error}`);
62
- }
63
-
64
- if (result.winner) {
65
- const w = result.entries.find((e) => `${e.provider}/${e.model}` === result.winner);
66
- const wc = w?.cost ?? 0;
67
- const cheapest = result.entries.filter((e) => !e.error).sort((a, b) => a.cost - b.cost)[0];
68
- console.log(`\n 🏆 Best quality: ${result.winner} (judge: ${result.judge})`);
69
- if (cheapest && `${cheapest.provider}/${cheapest.model}` !== result.winner) {
70
- console.log(` 💸 Cheapest: ${cheapest.provider}/${cheapest.model} at ${money(cheapest.cost)} (winner cost ${money(wc)})`);
71
- }
72
- for (const s of result.scores.sort((a, b) => b.score - a.score)) {
73
- console.log(` ${s.score}/10 ${s.model} — ${s.reason}`);
74
- }
75
- }
76
- console.log("");
77
- process.exit(0);