projectinator 0.3.0 → 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 (170) hide show
  1. package/README.md +30 -11
  2. package/bin/projectinator.mjs +56 -11
  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/cost.ts +0 -43
  125. package/src/council.ts +0 -175
  126. package/src/demo.ts +0 -106
  127. package/src/estimate.ts +0 -111
  128. package/src/executor.ts +0 -169
  129. package/src/git.ts +0 -72
  130. package/src/intake.ts +0 -125
  131. package/src/models.ts +0 -175
  132. package/src/narrate.ts +0 -87
  133. package/src/openrouter.ts +0 -119
  134. package/src/orchestrator.ts +0 -310
  135. package/src/pm.ts +0 -302
  136. package/src/preview.ts +0 -206
  137. package/src/registry-store.ts +0 -41
  138. package/src/registry.ts +0 -132
  139. package/src/research.ts +0 -123
  140. package/src/retro.ts +0 -99
  141. package/src/roles.ts +0 -401
  142. package/src/router.ts +0 -120
  143. package/src/run-bakeoff.ts +0 -77
  144. package/src/run-build.ts +0 -203
  145. package/src/run-web.ts +0 -87
  146. package/src/scout.ts +0 -121
  147. package/src/session-cost.ts +0 -17
  148. package/src/stack.ts +0 -46
  149. package/src/tui/App.tsx +0 -1760
  150. package/src/tui/BakeOff.tsx +0 -190
  151. package/src/tui/BoardEditor.tsx +0 -260
  152. package/src/tui/EditableBoard.tsx +0 -179
  153. package/src/tui/Frame.tsx +0 -142
  154. package/src/tui/Intake.tsx +0 -111
  155. package/src/tui/Kanban.tsx +0 -158
  156. package/src/tui/Settings.tsx +0 -513
  157. package/src/tui/StackPick.tsx +0 -79
  158. package/src/tui/WebAccounts.tsx +0 -197
  159. package/src/tui/components.tsx +0 -340
  160. package/src/tui/config.ts +0 -150
  161. package/src/tui/deploy.ts +0 -137
  162. package/src/tui/engine.ts +0 -749
  163. package/src/tui/notify.ts +0 -21
  164. package/src/tui/panels.tsx +0 -89
  165. package/src/tui/templates.ts +0 -119
  166. package/src/tui/theme.ts +0 -44
  167. package/src/tui/validate.ts +0 -53
  168. package/src/types.ts +0 -208
  169. package/src/web/oauth-anthropic.ts +0 -206
  170. package/src/web/session.ts +0 -299
package/src/models.ts DELETED
@@ -1,175 +0,0 @@
1
- // Model pricing table. Rates USD per 1,000,000 tokens — copied verbatim from Pi's
2
- // bundled catalog (pi-coding-agent 0.85.1, Sept 2026); test/executor.test.ts pins every
3
- // entry against it. Shape mirrors Pi's models.json `cost` block.
4
-
5
- import type { Model } from "./types.js";
6
- import { findOpenRouterModel } from "./openrouter.js";
7
-
8
- export const MODELS: Record<string, Model> = {
9
- // ---- OpenAI: GPT-5.6 family (prices cut Sept 2026; past 272k input costs 2x) ----
10
- "gpt-5.6-sol": {
11
- id: "gpt-5.6-sol",
12
- provider: "openai",
13
- name: "GPT-5.6 Sol",
14
- contextWindow: 272_000,
15
- cost: { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5, tiers: [{ inputTokensAbove: 272_000, input: 8, output: 30, cacheRead: 0.8, cacheWrite: 10 }] },
16
- },
17
- "gpt-5.6-terra": {
18
- id: "gpt-5.6-terra",
19
- provider: "openai",
20
- name: "GPT-5.6 Terra",
21
- contextWindow: 272_000,
22
- cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5, tiers: [{ inputTokensAbove: 272_000, input: 4, output: 18, cacheRead: 0.4, cacheWrite: 5 }] },
23
- },
24
- "gpt-5.6-luna": {
25
- id: "gpt-5.6-luna",
26
- provider: "openai",
27
- name: "GPT-5.6 Luna",
28
- contextWindow: 272_000,
29
- cost: { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25, tiers: [{ inputTokensAbove: 272_000, input: 0.4, output: 1.8, cacheRead: 0.04, cacheWrite: 0.5 }] },
30
- },
31
-
32
- // ---- Anthropic: Claude ----
33
- "claude-fable-5-1": {
34
- id: "claude-fable-5-1",
35
- provider: "anthropic",
36
- name: "Claude Fable 5.1",
37
- contextWindow: 1_000_000,
38
- cost: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
39
- },
40
- "claude-fable-5": {
41
- id: "claude-fable-5",
42
- provider: "anthropic",
43
- name: "Claude Fable 5",
44
- contextWindow: 1_000_000,
45
- cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
46
- },
47
- "claude-opus-5": {
48
- id: "claude-opus-5",
49
- provider: "anthropic",
50
- name: "Claude Opus 5",
51
- contextWindow: 1_000_000,
52
- cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
53
- },
54
- "claude-opus-4-8": {
55
- id: "claude-opus-4-8",
56
- provider: "anthropic",
57
- name: "Claude Opus 4.8",
58
- contextWindow: 1_000_000,
59
- cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
60
- },
61
- "claude-sonnet-5": {
62
- id: "claude-sonnet-5",
63
- provider: "anthropic",
64
- name: "Claude Sonnet 5",
65
- contextWindow: 1_000_000,
66
- cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
67
- },
68
- "claude-sonnet-4-6": {
69
- id: "claude-sonnet-4-6",
70
- provider: "anthropic",
71
- name: "Claude Sonnet 4.6",
72
- contextWindow: 1_000_000,
73
- cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
74
- },
75
- "claude-haiku-4-5": {
76
- id: "claude-haiku-4-5",
77
- provider: "anthropic",
78
- name: "Claude Haiku 4.5",
79
- contextWindow: 200_000,
80
- cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
81
- },
82
-
83
- // ---- Google: Gemini ----
84
- // NOTE: ids match Pi's built-in registry exactly (provider "google"), so they
85
- // resolve directly via ModelRegistry.find() with no alias layer.
86
- "gemini-3.1-pro-preview": {
87
- id: "gemini-3.1-pro-preview",
88
- provider: "google",
89
- name: "Gemini 3.1 Pro",
90
- contextWindow: 1_000_000,
91
- cost: {
92
- input: 2,
93
- output: 12,
94
- cacheRead: 0.2,
95
- cacheWrite: 2.5,
96
- // Google charges more past 200k input tokens.
97
- tiers: [{ inputTokensAbove: 200_000, input: 4, output: 18, cacheRead: 0.4, cacheWrite: 5 }],
98
- },
99
- },
100
- "gemini-3.8-flash": {
101
- id: "gemini-3.8-flash",
102
- provider: "google",
103
- name: "Gemini 3.8 Flash",
104
- contextWindow: 1_000_000,
105
- cost: { input: 0.75, output: 3.75, cacheRead: 0.075 },
106
- },
107
- "gemini-3-flash-preview": {
108
- id: "gemini-3-flash-preview",
109
- provider: "google",
110
- name: "Gemini 3 Flash",
111
- contextWindow: 1_000_000,
112
- cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 },
113
- },
114
-
115
- // ---- OpenRouter (one key → frontier models). ids are Pi's OpenRouter-catalog
116
- // slugs (vendor/model). Pricing mirrors the underlying model (OpenRouter passes
117
- // it through, ~small margin); ACTUAL cost still comes from Pi per run.
118
- "anthropic/claude-opus-5": {
119
- id: "anthropic/claude-opus-5",
120
- provider: "openrouter",
121
- name: "Claude Opus 5 (OpenRouter)",
122
- contextWindow: 1_000_000,
123
- cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
124
- },
125
- "anthropic/claude-opus-4.8": {
126
- id: "anthropic/claude-opus-4.8",
127
- provider: "openrouter",
128
- name: "Claude Opus 4.8 (OpenRouter)",
129
- contextWindow: 1_000_000,
130
- cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
131
- },
132
- "anthropic/claude-sonnet-5": {
133
- id: "anthropic/claude-sonnet-5",
134
- provider: "openrouter",
135
- name: "Claude Sonnet 5 (OpenRouter)",
136
- contextWindow: 1_000_000,
137
- cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
138
- },
139
- "anthropic/claude-sonnet-4.6": {
140
- id: "anthropic/claude-sonnet-4.6",
141
- provider: "openrouter",
142
- name: "Claude Sonnet 4.6 (OpenRouter)",
143
- contextWindow: 1_000_000,
144
- cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
145
- },
146
- "google/gemini-3.8-flash": {
147
- id: "google/gemini-3.8-flash",
148
- provider: "openrouter",
149
- name: "Gemini 3.8 Flash (OpenRouter)",
150
- contextWindow: 1_000_000,
151
- cost: { input: 0.75, output: 3.75, cacheRead: 0.075 },
152
- },
153
- "openai/gpt-5.6-luna": {
154
- id: "openai/gpt-5.6-luna",
155
- provider: "openrouter",
156
- name: "GPT-5.6 Luna (OpenRouter)",
157
- contextWindow: 1_050_000,
158
- cost: { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
159
- },
160
- };
161
-
162
- export function getModel(id: string): Model {
163
- const m = MODELS[id];
164
- if (m) return m;
165
- // OpenRouter slugs (vendor/model) aren't in the static table — price them from
166
- // the OpenRouter catalog (live cache or Pi's built-in list). Fall back to a
167
- // rough estimate so a build never crashes on an unpriced model (actual cost
168
- // still comes from Pi per run).
169
- if (id.includes("/")) {
170
- const or = findOpenRouterModel(id);
171
- if (or) return { ...or, provider: "openrouter" };
172
- return { id, provider: "openrouter", name: id, contextWindow: 200_000, cost: { input: 1, output: 3, cacheRead: 0.1, cacheWrite: 1.25 } };
173
- }
174
- throw new Error(`Unknown model id: "${id}". Add it to src/models.ts.`);
175
- }
package/src/narrate.ts DELETED
@@ -1,87 +0,0 @@
1
- // AI narrative for a build retro. Feeds the data-driven RetroReport to a model
2
- // and gets back a short "what went well / what to improve" write-up. On-demand
3
- // (costs a small call) and cached on the build state by the caller.
4
-
5
- import {
6
- createAgentSession,
7
- type AgentSession,
8
- } from "@earendil-works/pi-coding-agent";
9
- import type { Backend, Provider } from "./types.js";
10
- import type { RetroReport } from "./retro.js";
11
- import { findEntry } from "./registry.js";
12
- import { piRuntime, resolvePiModel } from "./executor.js"
13
- import { addSessionCost } from "./session-cost.js";
14
-
15
- function lastAssistantText(session: AgentSession): string {
16
- const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
17
- for (let i = msgs.length - 1; i >= 0; i--) {
18
- const m = msgs[i];
19
- if (m?.role !== "assistant") continue;
20
- const c = m.content;
21
- if (typeof c === "string") return c;
22
- if (Array.isArray(c)) {
23
- return c
24
- .map((p: unknown) => (typeof p === "string" ? p : p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : ""))
25
- .join("")
26
- .trim();
27
- }
28
- }
29
- return "";
30
- }
31
-
32
- /** Compact fact sheet the model reasons over — numbers, not prose. */
33
- function facts(r: RetroReport): string {
34
- const lines = [
35
- `Idea: ${r.idea}`,
36
- `Status: ${r.status}. Tasks done: ${r.doneCount}/${r.taskCount}.`,
37
- `Cost: predicted $${r.estCost.toFixed(2)}, actual $${r.totalCost.toFixed(2)}.`,
38
- `Tests: ${r.tests.passed} passed, ${r.tests.failed} failed.`,
39
- r.retries.length ? `Rebuilds: ${r.retries.map((x) => `${x.taskId}×${x.rounds}`).join(", ")}.` : "Rebuilds: none.",
40
- `Cost by epic: ${r.byEpic.map((e) => `${e.epic} $${e.cost}`).join(", ") || "n/a"}.`,
41
- `Cost by model: ${r.byModel.map((m) => `${m.model} $${m.cost} (${m.tasks})`).join(", ") || "n/a"}.`,
42
- `Priciest tasks: ${r.topCost.map((t) => `${t.taskId} $${t.cost}`).join(", ") || "n/a"}.`,
43
- r.bugs.length
44
- ? `Tester flags: ${r.bugs.map((b) => `[${b.severity}] ${b.description}`).slice(0, 6).join("; ")}.`
45
- : "Tester flags: none.",
46
- ];
47
- return lines.join("\n");
48
- }
49
-
50
- const PROMPT = [
51
- "You are a pragmatic engineering lead writing a SHORT retro for this build.",
52
- "Use the facts below — reference the actual numbers. Do not invent anything.",
53
- "Write exactly three sections, terse bullets, under 140 words total:",
54
- "**What went well** (2-3 bullets)",
55
- "**What to improve** (2-3 bullets)",
56
- "**Next time** (one line)",
57
- "No preamble, no closing remarks.",
58
- ].join("\n");
59
-
60
- export interface NarrateOptions {
61
- backend: Backend;
62
- modelOverride?: { provider: Provider; model: string };
63
- }
64
-
65
- /** Generate the narrative. Throws on failure (caller shows the error). */
66
- export async function narrateRetro(report: RetroReport, opts: NarrateOptions): Promise<string> {
67
- const runtime = await piRuntime();
68
- const { entry } = findEntry("plan", "mid");
69
- const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
70
- const model = resolvePiModel(runtime, pick.provider, pick.model);
71
-
72
- const { session } = await createAgentSession({
73
- model,
74
- modelRuntime: runtime,
75
- thinkingLevel: "low",
76
- noTools: "all",
77
- });
78
- try {
79
- await session.prompt(`${PROMPT}\n\n--- FACTS ---\n${facts(report)}`);
80
- const text = lastAssistantText(session);
81
- if (!text) throw new Error("The model returned no narrative.");
82
- return text;
83
- } finally {
84
- addSessionCost(session.getSessionStats().cost);
85
- session.dispose();
86
- }
87
- }
package/src/openrouter.ts DELETED
@@ -1,119 +0,0 @@
1
- // The OpenRouter model catalog — so users can pick ANY OpenRouter model by name
2
- // (Kimi, DeepSeek, Qwen, …) instead of typing slugs.
3
- //
4
- // - refreshOpenRouterModels(): live-fetch openrouter.ai/api/v1/models (freshest
5
- // names + pricing), cache to disk. Falls back to Pi's built-in OR catalog.
6
- // - openRouterModels() / findOpenRouterModel(): SYNC reads (disk cache, else Pi's
7
- // built-in list) so cost estimation (getModel) can price any picked model.
8
-
9
- import { piRuntime } from "./executor.js";
10
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
11
- import { homedir } from "node:os";
12
- import { join } from "node:path";
13
- import type { Model, ModelCost } from "./types.js";
14
-
15
- /** A pickable OpenRouter model: our Model shape minus the provider (always "openrouter"). */
16
- export type ORModel = Omit<Model, "provider">;
17
-
18
- const CACHE = join(homedir(), ".projectinator", "openrouter-models.json");
19
-
20
- let builtinMemo: ORModel[] | null = null;
21
- let diskMemo: ORModel[] | null | undefined; // undefined = not read yet, null = no cache
22
-
23
- /** Pi's built-in OpenRouter catalog — offline, names + pricing. Sync read of the memo;
24
- * call warmBuiltinOpenRouterModels() once at startup to fill it (Pi's runtime is async). */
25
- export function builtinOpenRouterModels(): ORModel[] {
26
- return builtinMemo ?? [];
27
- }
28
-
29
- /** Load Pi's built-in OpenRouter list into the memo. Safe to call repeatedly. */
30
- export async function warmBuiltinOpenRouterModels(): Promise<ORModel[]> {
31
- if (builtinMemo) return builtinMemo;
32
- try {
33
- const runtime = await piRuntime();
34
- builtinMemo = runtime
35
- .getModels("openrouter")
36
- .filter((m) => !!m.cost)
37
- .map((m) => ({
38
- id: m.id,
39
- name: m.name ?? m.id,
40
- contextWindow: m.contextWindow ?? 200_000,
41
- cost: m.cost,
42
- }))
43
- .sort((a, b) => a.name.localeCompare(b.name));
44
- } catch {
45
- builtinMemo = [];
46
- }
47
- return builtinMemo;
48
- }
49
-
50
- function readDiskCache(): ORModel[] | null {
51
- if (diskMemo !== undefined) return diskMemo;
52
- try {
53
- if (existsSync(CACHE)) {
54
- const parsed = JSON.parse(readFileSync(CACHE, "utf8")) as { models?: ORModel[] };
55
- diskMemo = Array.isArray(parsed.models) && parsed.models.length ? parsed.models : null;
56
- } else diskMemo = null;
57
- } catch {
58
- diskMemo = null;
59
- }
60
- return diskMemo;
61
- }
62
-
63
- /** The list to show / price against: live-fetched disk cache if present, else Pi's built-in. */
64
- export function openRouterModels(): ORModel[] {
65
- const cached = readDiskCache();
66
- return cached && cached.length ? cached : builtinOpenRouterModels();
67
- }
68
-
69
- /** Look up one model's pricing/name by slug (cache first, then built-in). Sync. */
70
- export function findOpenRouterModel(id: string): ORModel | undefined {
71
- return openRouterModels().find((m) => m.id === id) ?? builtinOpenRouterModels().find((m) => m.id === id);
72
- }
73
-
74
- /** Map OpenRouter's API pricing (USD per token, strings) to our per-1M-token cost. */
75
- function mapApiModel(m: {
76
- id: string;
77
- name?: string;
78
- context_length?: number;
79
- pricing?: { prompt?: string; completion?: string; input_cache_read?: string; input_cache_write?: string };
80
- }): ORModel | null {
81
- const p = m.pricing ?? {};
82
- const input = Number(p.prompt) * 1e6;
83
- const output = Number(p.completion) * 1e6;
84
- if (!Number.isFinite(input) || !Number.isFinite(output)) return null;
85
- const cacheRead = p.input_cache_read != null ? Number(p.input_cache_read) * 1e6 : input * 0.1;
86
- const cacheWrite = p.input_cache_write != null ? Number(p.input_cache_write) * 1e6 : input * 1.25;
87
- return {
88
- id: m.id,
89
- name: m.name ?? m.id,
90
- contextWindow: m.context_length ?? 200_000,
91
- cost: { input, output, cacheRead, cacheWrite },
92
- };
93
- }
94
-
95
- /** Live-fetch the catalog and cache it. Falls back to the current list on any failure. */
96
- export async function refreshOpenRouterModels(timeoutMs = 8000): Promise<ORModel[]> {
97
- const ctrl = new AbortController();
98
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
99
- try {
100
- const res = await fetch("https://openrouter.ai/api/v1/models", { signal: ctrl.signal });
101
- if (!res.ok) return openRouterModels();
102
- const json = (await res.json()) as { data?: Array<Parameters<typeof mapApiModel>[0]> };
103
- const list = (json.data ?? []).map(mapApiModel).filter((m): m is ORModel => m !== null)
104
- .sort((a, b) => a.name.localeCompare(b.name));
105
- if (list.length) {
106
- try {
107
- mkdirSync(join(homedir(), ".projectinator"), { recursive: true });
108
- writeFileSync(CACHE, JSON.stringify({ fetchedAt: Date.now(), models: list }));
109
- diskMemo = list;
110
- } catch { /* cache write is best-effort */ }
111
- return list;
112
- }
113
- return openRouterModels();
114
- } catch {
115
- return openRouterModels();
116
- } finally {
117
- clearTimeout(timer);
118
- }
119
- }
@@ -1,310 +0,0 @@
1
- // Phase 4 — the Orchestrator. Runs a whole backlog end to end.
2
- //
3
- // - Topologically orders tasks by dependsOn (respects design->code->test->deploy).
4
- // - Threads handoff context: a task sees the final output of its dependencies.
5
- // - Runs a Tester -> Developer feedback loop, bounded by maxFeedbackRounds.
6
- // - Tracks cost and halts on the budget cap.
7
- //
8
- // The executor is INJECTED (RoleExecutor), so this entire control flow is testable
9
- // offline with a fake — no model, no spend. The real Pi executor lives in roles.ts.
10
-
11
- import {
12
- TaskLimitError,
13
- type RegistryEntry,
14
- type RoleExecutor,
15
- type RoutingPolicy,
16
- type Task,
17
- type TaskOutcome,
18
- } from "./types.js";
19
- import { route } from "./router.js";
20
- import { REGISTRY } from "./registry.js";
21
-
22
- /** Order tasks so every task comes after its dependencies. Throws on a cycle. */
23
- export function toposort(tasks: Task[]): Task[] {
24
- const byId = new Map(tasks.map((t) => [t.id, t]));
25
- const state = new Map<string, "visiting" | "done">();
26
- const out: Task[] = [];
27
-
28
- const visit = (t: Task, trail: string[]) => {
29
- const s = state.get(t.id);
30
- if (s === "done") return;
31
- if (s === "visiting") {
32
- throw new Error(`Dependency cycle: ${[...trail, t.id].join(" -> ")}`);
33
- }
34
- state.set(t.id, "visiting");
35
- for (const dep of t.dependsOn ?? []) {
36
- const d = byId.get(dep);
37
- if (d) visit(d, [...trail, t.id]); // unknown deps already stripped by normalize
38
- }
39
- state.set(t.id, "done");
40
- out.push(t);
41
- };
42
-
43
- for (const t of tasks) visit(t, []);
44
- return out;
45
- }
46
-
47
- export interface RunOptions {
48
- policy: RoutingPolicy;
49
- execute: RoleExecutor;
50
- /** Registry to route against. Swap this to lock every role to one provider. */
51
- registry?: RegistryEntry[];
52
- onProgress?: (event: OrchestratorEvent) => void;
53
- /** Prior outcomes to resume from (append-only record). Their tasks are skipped. */
54
- seedOutcomes?: TaskOutcome[];
55
- /** Called after each task settles, with the full record + running total, for persistence. */
56
- onCheckpoint?: (outcomes: TaskOutcome[], totalCost: number) => void;
57
- /** Max tasks to run at once. 1 (default) = sequential. >1 runs independent tasks in parallel. */
58
- concurrency?: number;
59
- /** Optional human gate before development begins (design done → dev). Resolve "stop" to halt. */
60
- onGate?: (info: { stage: string }) => Promise<"continue" | "stop">;
61
- }
62
-
63
- export type OrchestratorEvent =
64
- | { type: "task_start"; task: Task; round: number; provider: string; modelId: string }
65
- | { type: "task_done"; outcome: TaskOutcome; runningTotal: number }
66
- | { type: "task_failed"; outcome: TaskOutcome; runningTotal: number }
67
- | { type: "task_skipped"; taskId: string }
68
- | { type: "test_failed"; taskId: string; bugs: number; round: number }
69
- | { type: "retry_dev"; taskId: string; forTest: string; round: number }
70
- | { type: "budget_halt"; runningTotal: number; cap: number }
71
- | { type: "gate"; stage: string }
72
- | { type: "cycle_or_error"; message: string };
73
-
74
- export interface RunResult {
75
- outcomes: TaskOutcome[];
76
- totalCost: number;
77
- halted: boolean;
78
- haltReason?: string;
79
- }
80
-
81
- /** Build handoff text from a task's dependency outcomes. */
82
- function gatherContext(task: Task, outcomes: Map<string, TaskOutcome>): string {
83
- const deps = task.dependsOn ?? [];
84
- if (deps.length === 0) return "";
85
- const parts: string[] = [];
86
- for (const depId of deps) {
87
- const o = outcomes.get(depId);
88
- if (!o) continue;
89
- const snippet = o.finalText.trim();
90
- if (snippet) parts.push(`### From ${depId} (${o.capability}):\n${snippet}`);
91
- if (o.files.length) parts.push(`### Files from ${depId}: ${o.files.join(", ")}`);
92
- }
93
- return parts.length ? `Context from upstream work:\n\n${parts.join("\n\n")}` : "";
94
- }
95
-
96
- function bugReport(bugs: { severity: string; description: string; file?: string }[]): string {
97
- return [
98
- "The tester found these issues. Fix them, then stop:",
99
- ...bugs.map((b) => `- [${b.severity}]${b.file ? ` (${b.file})` : ""} ${b.description}`),
100
- ].join("\n");
101
- }
102
-
103
- export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunResult> {
104
- const { policy, execute } = opts;
105
- const registry = opts.registry ?? REGISTRY;
106
- const byId = new Map(tasks.map((t) => [t.id, t]));
107
- const outcomes = new Map<string, TaskOutcome>();
108
- const record: TaskOutcome[] = [];
109
- let running = 0;
110
-
111
- // Resume: replay prior outcomes so finished tasks are skipped and cost is restored.
112
- // A failed attempt is billed but never "done" — it is rebuilt.
113
- const seed = opts.seedOutcomes ?? [];
114
- for (const o of seed) {
115
- record.push(o);
116
- if (o.error) outcomes.delete(o.taskId); // last wins: a later failure voids an earlier pass
117
- else outcomes.set(o.taskId, o);
118
- running += o.cost;
119
- }
120
- running = round2(running);
121
- const wasDone = new Set(outcomes.keys());
122
-
123
- let halted = false;
124
- let haltReason: string | undefined;
125
-
126
- const emit = opts.onProgress ?? (() => {});
127
- const checkpoint = () => opts.onCheckpoint?.(record, round2(running));
128
-
129
- let ordered: Task[];
130
- try {
131
- ordered = toposort(tasks);
132
- } catch (e) {
133
- const message = e instanceof Error ? e.message : String(e);
134
- emit({ type: "cycle_or_error", message });
135
- return { outcomes: [], totalCost: 0, halted: true, haltReason: message };
136
- }
137
-
138
- const runOne = async (task: Task, round: number, contextOverride?: string): Promise<TaskOutcome> => {
139
- const decision = route(task, { policy, registry, runningTotalBefore: running });
140
- emit({ type: "task_start", task, round, provider: decision.provider, modelId: decision.model.id });
141
- const contextText = contextOverride ?? gatherContext(task, outcomes);
142
- const meta = { taskId: task.id, capability: task.capability, provider: decision.provider, modelId: decision.model.id, round };
143
- let outcome: TaskOutcome;
144
- try {
145
- outcome = { ...(await execute({ task, decision, contextText, round, limits: policy.taskLimits })), ...meta };
146
- } catch (e) {
147
- if (!(e instanceof TaskLimitError)) throw e;
148
- // Limit breach: bill what was spent, record the failure, halt the build.
149
- outcome = { finalText: "", files: [], cost: e.costSoFar, error: e.message, ...meta };
150
- running += outcome.cost;
151
- record.push(outcome);
152
- halted = true;
153
- haltReason = `${task.id} aborted: ${e.message}`;
154
- emit({ type: "task_failed", outcome, runningTotal: round2(running) });
155
- return outcome;
156
- }
157
- running += outcome.cost;
158
- outcomes.set(task.id, outcome);
159
- record.push(outcome);
160
- emit({ type: "task_done", outcome, runningTotal: round2(running) });
161
- return outcome;
162
- };
163
-
164
- // One task's full lifecycle: run it, then its Reviewer/Tester -> Developer feedback loop.
165
- const runTaskUnit = async (task: Task): Promise<void> => {
166
- let outcome = await runOne(task, 0);
167
- const judges = task.capability === "test" || task.capability === "review";
168
- if (!outcome.error && judges && outcome.verdict && !outcome.verdict.passed) {
169
- // The code to fix: direct code deps, plus code deps reached through a review
170
- // (a test depends on the review, which depends on the code).
171
- const codeDeps: Task[] = [];
172
- for (const id of task.dependsOn ?? []) {
173
- const dep = byId.get(id);
174
- if (!dep) continue;
175
- if (dep.capability === "code") codeDeps.push(dep);
176
- else if (dep.capability === "review") {
177
- for (const id2 of dep.dependsOn ?? []) {
178
- const d2 = byId.get(id2);
179
- if (d2?.capability === "code" && !codeDeps.includes(d2)) codeDeps.push(d2);
180
- }
181
- }
182
- }
183
-
184
- let round = 1;
185
- fix: while (outcome.verdict && !outcome.verdict.passed && round <= policy.maxFeedbackRounds) {
186
- emit({ type: "test_failed", taskId: task.id, bugs: outcome.verdict.bugs.length, round });
187
- const fixContext = bugReport(outcome.verdict.bugs);
188
- for (const dep of codeDeps) {
189
- emit({ type: "retry_dev", taskId: dep.id, forTest: task.id, round });
190
- if ((await runOne(dep, round, fixContext)).error) break fix;
191
- }
192
- outcome = await runOne(task, round); // re-test
193
- if (outcome.error) break;
194
- round++;
195
- }
196
- }
197
- checkpoint();
198
- };
199
-
200
- // Human gate: pause once before the first development (code) task begins.
201
- let gateDone = false;
202
- const passGate = async (): Promise<boolean> => {
203
- if (gateDone || !opts.onGate) return true;
204
- gateDone = true;
205
- emit({ type: "gate", stage: "design→dev" });
206
- const decision = await opts.onGate({ stage: "design→dev" });
207
- return decision !== "stop";
208
- };
209
-
210
- const concurrency = Math.max(1, Math.floor(opts.concurrency ?? 1));
211
-
212
- // ---- sequential path (concurrency 1) — unchanged behavior ----
213
- if (concurrency === 1) {
214
- for (const task of ordered) {
215
- if (wasDone.has(task.id)) {
216
- emit({ type: "task_skipped", taskId: task.id });
217
- continue;
218
- }
219
- if (task.capability === "code" && !(await passGate())) {
220
- checkpoint();
221
- return { outcomes: record, totalCost: round2(running), halted: true, haltReason: "stopped at review gate" };
222
- }
223
- const est = route(task, { policy, registry, runningTotalBefore: running });
224
- if (est.overCap) {
225
- emit({ type: "budget_halt", runningTotal: round2(running + est.cost), cap: policy.budgetCapUSD });
226
- checkpoint();
227
- return { outcomes: record, totalCost: round2(running), halted: true, haltReason: "budget cap" };
228
- }
229
- await runTaskUnit(task);
230
- if (halted) return { outcomes: record, totalCost: round2(running), halted, haltReason };
231
- }
232
- return { outcomes: record, totalCost: round2(running), halted: false };
233
- }
234
-
235
- // ---- parallel path (concurrency > 1) — ready-set scheduler ----
236
- // JS is single-threaded, so mutations between awaits are atomic (no locks needed).
237
- // Independent tasks (deps satisfied) run concurrently up to `concurrency`. A budget
238
- // reservation on in-flight estimates prevents launches that could cross the cap.
239
- const remaining = new Set(ordered.filter((t) => !wasDone.has(t.id)).map((t) => t.id));
240
- for (const id of wasDone) emit({ type: "task_skipped", taskId: id });
241
-
242
- const inFlight = new Map<string, Promise<void>>();
243
- let reserved = 0;
244
- let codeInFlight = 0; // code tasks are serialized (they share files) even in parallel mode
245
- let failure: unknown; // first task error; rethrown after in-flight work drains
246
-
247
- const depsSatisfied = (t: Task) => (t.dependsOn ?? []).every((d) => !remaining.has(d));
248
- const readyTasks = () =>
249
- ordered.filter((t) => remaining.has(t.id) && !inFlight.has(t.id) && depsSatisfied(t));
250
-
251
- while (remaining.size > 0 && !halted) {
252
- // Gate before any development task launches.
253
- if (!gateDone && opts.onGate && readyTasks().some((t) => t.capability === "code")) {
254
- if (!(await passGate())) {
255
- halted = true;
256
- haltReason = "stopped at review gate";
257
- break;
258
- }
259
- }
260
- for (const task of readyTasks()) {
261
- if (inFlight.size >= concurrency) break;
262
- // Only one code task builds at a time — they write to the shared workspace.
263
- if (task.capability === "code" && codeInFlight >= 1) continue;
264
- // Reservations keep full precision: rounding each one to cents drops sub-cent
265
- // estimates entirely, so a wide backlog of cheap tasks would under-reserve.
266
- const est = route(task, { policy, registry, runningTotalBefore: running + reserved });
267
- if (running + reserved + est.cost > policy.budgetCapUSD) {
268
- if (inFlight.size === 0) {
269
- emit({ type: "budget_halt", runningTotal: round2(running + est.cost), cap: policy.budgetCapUSD });
270
- halted = true;
271
- haltReason = "budget cap";
272
- }
273
- break; // wait for in-flight tasks to free budget/capacity
274
- }
275
- reserved += est.cost;
276
- const cost = est.cost;
277
- const isCode = task.capability === "code";
278
- if (isCode) codeInFlight++;
279
- const settle = () => {
280
- if (isCode) codeInFlight--;
281
- reserved -= cost;
282
- remaining.delete(task.id);
283
- inFlight.delete(task.id);
284
- };
285
- // A rejection must NOT escape through Promise.race below: that abandons the
286
- // sibling promises, and their later rejections would have no handler attached
287
- // (unhandled rejection -> the host process dies mid-build). Capture the first
288
- // failure, stop launching, drain what's running, checkpoint, then rethrow.
289
- const p = runTaskUnit(task).then(settle, (e: unknown) => {
290
- settle();
291
- halted = true;
292
- haltReason ??= e instanceof Error ? e.message : String(e);
293
- failure ??= e;
294
- });
295
- inFlight.set(task.id, p);
296
- }
297
-
298
- if (inFlight.size === 0) break; // nothing running and nothing launchable -> done or halted
299
- await Promise.race(inFlight.values());
300
- }
301
-
302
- await Promise.all(inFlight.values());
303
- checkpoint();
304
- if (failure) throw failure;
305
- return { outcomes: record, totalCost: round2(running), halted, haltReason };
306
- }
307
-
308
- function round2(n: number): number {
309
- return Math.round(n * 100) / 100;
310
- }