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/pm.ts DELETED
@@ -1,302 +0,0 @@
1
- // Phase 3 — the PM / Decomposer.
2
- // Turns a one-line idea into a tagged backlog (epic -> story -> task).
3
- //
4
- // Structured output: Pi has none built in, so we force it. The PM model is given
5
- // exactly ONE tool — submit_backlog, whose typebox schema IS the backlog shape —
6
- // and must call it. That's far more reliable than parsing JSON out of prose.
7
- //
8
- // Token estimates are NOT asked of the model (it's bad at them); we fill them from
9
- // code buckets after decomposition. The PM only decomposes + tags.
10
-
11
- import {
12
- createAgentSession,
13
- defineTool,
14
- type AgentSession,
15
- } from "@earendil-works/pi-coding-agent";
16
- import { Type, type Static } from "typebox";
17
- import type { Backend, Capability, Difficulty, Provider, Task } from "./types.js";
18
- import { estimateTokens } from "./estimate.js";
19
- import { findEntry } from "./registry.js";
20
- import { piRuntime, resolvePiModel } from "./executor.js";
21
- import { addSessionCost } from "./session-cost.js";
22
-
23
- // ---- typebox schema = the backlog contract ----
24
- // DELIBERATELY PERMISSIVE. A forced-tool call fails INVISIBLY if the args don't
25
- // validate (Pi rejects it, our capture never fires). So: flat list, only id/title/
26
- // capability/difficulty required, capability/difficulty as free strings (coerced in
27
- // code), extra fields allowed. We validate/clean afterwards instead of at the gate.
28
- const TaskSchema = Type.Object(
29
- {
30
- id: Type.String({ description: "Unique task id, e.g. T-01" }),
31
- title: Type.String({ description: "One concrete, buildable unit of work" }),
32
- capability: Type.String({ description: "one of: plan | design | code | review | test | ops" }),
33
- difficulty: Type.String({ description: "one of: trivial | low | medium | high" }),
34
- dependsOn: Type.Optional(Type.Array(Type.String(), { description: "task ids that must finish first" })),
35
- epic: Type.Optional(Type.String({ description: "optional grouping label" })),
36
- story: Type.Optional(Type.String({ description: "optional grouping label" })),
37
- },
38
- { additionalProperties: true },
39
- );
40
- const BacklogSchema = Type.Object({ tasks: Type.Array(TaskSchema) }, { additionalProperties: true });
41
-
42
- const CAPS = new Set<Capability>(["plan", "design", "code", "review", "test", "ops"]);
43
- const DIFFS = new Set<Difficulty>(["trivial", "low", "medium", "high"]);
44
- function coerceCap(s: string): Capability {
45
- const v = s?.toLowerCase().trim() as Capability;
46
- return CAPS.has(v) ? v : "code";
47
- }
48
- function coerceDiff(s: string): Difficulty {
49
- const v = s?.toLowerCase().trim() as Difficulty;
50
- return DIFFS.has(v) ? v : "medium";
51
- }
52
-
53
- export type Backlog = Static<typeof BacklogSchema>;
54
- export type BacklogTask = Static<typeof TaskSchema>;
55
-
56
- // ---- the capture tool ----
57
- export function buildBacklogTool() {
58
- let captured: Backlog | undefined;
59
- const tool = defineTool({
60
- name: "submit_backlog",
61
- label: "Submit Backlog",
62
- description: "Submit the finished backlog as a flat list of tasks. Call this exactly once.",
63
- parameters: BacklogSchema,
64
- execute: async (_id, params) => {
65
- captured = params as Backlog;
66
- return { content: [{ type: "text", text: `Backlog received: ${params.tasks.length} tasks.` }], details: {} };
67
- },
68
- });
69
- return { tool, get: () => captured };
70
- }
71
-
72
- export type Scope = "full" | "change";
73
-
74
- export function pmSystemPrompt(scope: Scope = "full"): string {
75
- const sizing =
76
- scope === "change"
77
- ? [
78
- "This is a CHANGE to an EXISTING project whose files are already on disk.",
79
- "Produce the FEWEST tasks that accomplish the change — usually 1 code task plus 1",
80
- "`review` task that dependsOn it (cheap read-only wiring check), plus 1 test task",
81
- "(dependsOn the review) only if the change is risky. Do NOT re-plan the whole project,",
82
- "do NOT add design/setup/deploy tasks. One small tweak = code + review.",
83
- ]
84
- : [
85
- "Scale the number of tasks to the request. A tiny page = a few tasks; a full app = many.",
86
- "Group tasks under EPICS (big features/areas of the product). Set every task's `epic`",
87
- "field to its epic name, e.g. 'Hero section', 'Contact form', 'Deployment'. Aim for",
88
- "2-5 tasks per epic. Do not pad: never split one obvious unit of work into multiple",
89
- "tasks. Skip design/plan/ops tasks when the request clearly doesn't need them.",
90
- "For a MULTI-FILE app: name the target file(s) in each code task's title (e.g. 'Build",
91
- "src/components/Header.jsx') and keep file names CONSISTENT across tasks — decide one",
92
- "structure and reuse it. When several files must agree, add ONE early design task that",
93
- "defines the file tree, and have the code tasks depend on it.",
94
- "After EVERY code task add one `review` task that dependsOn that code task (a cheap",
95
- "read-only wiring check). The test task must dependsOn the review task(s), not the code",
96
- "task(s) directly. Order: design -> code -> review -> test.",
97
- ];
98
- return [
99
- "You are the PROJECT MANAGER on an autonomous software team.",
100
- "Break the user's request into a flat list of tasks.",
101
- ...sizing,
102
- "",
103
- "Each TASK must be:",
104
- "- atomic: one model can complete it in one focused turn",
105
- "- tagged with a capability: plan | design | code | review | test | ops",
106
- "- tagged with a difficulty: trivial | low | medium | high (how hard the thinking is)",
107
- "Optional per task: dependsOn (ids that must finish first, e.g. code depends on design),",
108
- "and epic/story labels for grouping. Use ids like T-01, unique across the list.",
109
- "Order matters: a design task should precede the code task that implements it; tests come after code.",
110
- "",
111
- "CRITICAL: You have exactly ONE tool — submit_backlog — and you MUST call it with a",
112
- "`tasks` array. Never reply with prose. Never ask the user a question. If anything is",
113
- "unclear, make a reasonable assumption (e.g. the main file is index.html) and submit.",
114
- "Always produce at least one task. Call submit_backlog exactly once.",
115
- ].join("\n");
116
- }
117
-
118
- // ---- pure post-processing (testable, no model) ----
119
-
120
- export interface NormalizeResult {
121
- backlog: Backlog;
122
- diagnostics: string[];
123
- }
124
-
125
- /** Clean a raw backlog: drop duplicate task ids, strip dangling dependsOn refs. */
126
- export function normalizeBacklog(raw: Backlog): NormalizeResult {
127
- const diagnostics: string[] = [];
128
- const seen = new Set<string>();
129
- const allIds = new Set(raw.tasks.map((t) => t.id));
130
-
131
- const tasks = raw.tasks
132
- .filter((t) => {
133
- if (seen.has(t.id)) {
134
- diagnostics.push(`dropped duplicate task id ${t.id}`);
135
- return false;
136
- }
137
- seen.add(t.id);
138
- return true;
139
- })
140
- .map((t) => {
141
- const deps = (t.dependsOn ?? []).filter((d) => {
142
- if (!allIds.has(d)) {
143
- diagnostics.push(`task ${t.id}: dropped dangling dependsOn ${d}`);
144
- return false;
145
- }
146
- return true;
147
- });
148
- return { ...t, dependsOn: deps };
149
- });
150
- return { backlog: { tasks }, diagnostics };
151
- }
152
-
153
- /** Flatten a backlog to routable Tasks, coercing loose tags + filling token estimates. */
154
- export function flattenBacklog(backlog: Backlog): Task[] {
155
- return backlog.tasks.map((t) => {
156
- const capability = coerceCap(t.capability);
157
- const difficulty = coerceDiff(t.difficulty);
158
- return {
159
- id: t.id,
160
- title: t.title,
161
- capability,
162
- difficulty,
163
- dependsOn: t.dependsOn ?? [],
164
- epic: t.epic,
165
- story: t.story,
166
- estTokens: estimateTokens(capability, difficulty),
167
- };
168
- });
169
- }
170
-
171
- // ---- text fallback: parse a task list out of prose if the tool wasn't used ----
172
-
173
- function lastAssistantText(session: AgentSession): string {
174
- const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
175
- for (let i = msgs.length - 1; i >= 0; i--) {
176
- const m = msgs[i];
177
- if (m?.role !== "assistant") continue;
178
- const c = m.content;
179
- if (typeof c === "string") return c;
180
- if (Array.isArray(c)) {
181
- return c
182
- .map((p: unknown) => (typeof p === "string" ? p : p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : ""))
183
- .join("");
184
- }
185
- }
186
- return "";
187
- }
188
-
189
- /** Best-effort: pull a { tasks: [...] } (or a bare [...]) out of a model's text reply. */
190
- export function extractBacklogFromText(text: string): Backlog | undefined {
191
- const tryParse = (s: string): Backlog | undefined => {
192
- try {
193
- const p = JSON.parse(s) as unknown;
194
- if (Array.isArray(p)) return { tasks: p as Backlog["tasks"] };
195
- if (p && typeof p === "object" && Array.isArray((p as { tasks?: unknown }).tasks)) return p as Backlog;
196
- } catch {
197
- /* not json */
198
- }
199
- return undefined;
200
- };
201
- const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
202
- const objMatch = text.match(/\{[\s\S]*"tasks"[\s\S]*\}/);
203
- const arrMatch = text.match(/\[[\s\S]*\]/);
204
- for (const c of [fence?.[1], objMatch?.[0], arrMatch?.[0]]) {
205
- if (!c) continue;
206
- const r = tryParse(c.trim());
207
- if (r?.tasks?.length) return r;
208
- }
209
- return undefined;
210
- }
211
-
212
- // ---- live decomposition (calls the PM model, spends money) ----
213
-
214
- export interface DecomposeOptions {
215
- backend: Backend;
216
- thinkingLevel?: "off" | "low" | "medium" | "high";
217
- onEvent?: Parameters<import("@earendil-works/pi-coding-agent").AgentSession["subscribe"]>[0];
218
- /** Override the PM model (else resolved from registry plan/mid). */
219
- modelOverride?: { provider: Provider; model: string };
220
- /** "change" = minimal tasks against an existing project; "full" = a fresh build. */
221
- scope?: Scope;
222
- /** Summary of the existing project (files + contents) so the PM plans with real context. */
223
- projectContext?: string;
224
- /** Council-approved epics: organize ALL tasks under exactly these. */
225
- epics?: { name: string; rationale: string }[];
226
- }
227
-
228
- export interface DecomposeResult {
229
- provider: Provider;
230
- modelId: string;
231
- backlog: Backlog;
232
- tasks: Task[];
233
- diagnostics: string[];
234
- }
235
-
236
- export async function decomposeIdea(idea: string, opts: DecomposeOptions): Promise<DecomposeResult> {
237
- const runtime = await piRuntime();
238
-
239
- // PM = plan capability, mid tier — unless the caller overrides the model.
240
- const { entry } = findEntry("plan", "mid");
241
- const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
242
- const model = resolvePiModel(runtime, pick.provider, pick.model);
243
-
244
- const { tool, get } = buildBacklogTool();
245
- const { session } = await createAgentSession({
246
- model,
247
- modelRuntime: runtime,
248
- thinkingLevel: opts.thinkingLevel ?? "medium",
249
- noTools: "all",
250
- customTools: [tool],
251
- tools: ["submit_backlog"],
252
- });
253
-
254
- const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
255
- try {
256
- const ctx = opts.projectContext ? `\n\n--- EXISTING PROJECT (plan the change against this) ---\n${opts.projectContext}` : "";
257
- const epicsBlock = opts.epics?.length
258
- ? `\n\n--- APPROVED EPICS (organize ALL tasks under EXACTLY these; set each task's epic field to one of these names) ---\n${opts.epics.map((e) => `- ${e.name}: ${e.rationale}`).join("\n")}`
259
- : "";
260
- await session.prompt(`${pmSystemPrompt(opts.scope ?? "full")}${ctx}${epicsBlock}\n\n--- REQUEST ---\n${idea}`);
261
- let raw = get();
262
- // Nudge up to 3 times if the tool wasn't called (or its args failed validation).
263
- for (let i = 0; i < 3 && !raw; i++) {
264
- await session.prompt(
265
- "You did not call submit_backlog successfully. Call submit_backlog NOW with a `tasks` " +
266
- "array as the tool arguments. Do not write prose. Make reasonable assumptions if needed.",
267
- );
268
- raw = get();
269
- }
270
- // Last resort: the model may have printed the backlog as JSON text — parse it.
271
- if (!raw) raw = extractBacklogFromText(lastAssistantText(session));
272
- if (!raw || !raw.tasks?.length) {
273
- const stats = session.getSessionStats();
274
- if (stats.tokens.total === 0) {
275
- // The provider call returned nothing — bad/inaccessible key, OR (very common)
276
- // the account has no credit/balance so the API rejects the request.
277
- throw new Error(
278
- `The ${pick.provider} model returned nothing (0 tokens). Likely causes: the API key is invalid, ` +
279
- `the account has no credit/balance, or the key lacks access to ${pick.model}. Add credit or set a ` +
280
- `working key, or pick a different provider in Settings → Preferred provider.`,
281
- );
282
- }
283
- const said = lastAssistantText(session).slice(0, 200).replace(/\s+/g, " ").trim();
284
- throw new Error(
285
- `The planner didn't return a task list.${said ? ` It said: "${said}…"` : ""} Try rephrasing the request.`,
286
- );
287
- }
288
-
289
- const { backlog, diagnostics } = normalizeBacklog(raw);
290
- return {
291
- provider: pick.provider,
292
- modelId: pick.model,
293
- backlog,
294
- tasks: flattenBacklog(backlog),
295
- diagnostics,
296
- };
297
- } finally {
298
- addSessionCost(session.getSessionStats().cost);
299
- unsub?.();
300
- session.dispose();
301
- }
302
- }
package/src/preview.ts DELETED
@@ -1,206 +0,0 @@
1
- // Static-serving + headless render check for built web apps.
2
- //
3
- // Two uses:
4
- // - renderCheck(): load the built app in a headless browser, collect JS/console
5
- // errors + the rendered text, so the TESTER role verifies the app actually
6
- // RUNS (not just that the code reads correctly).
7
- // - startStaticServer(): a tiny local file server, reused by live preview.
8
- //
9
- // A real http server (not file://) so ES modules, fetch of local assets, and
10
- // relative paths all resolve the way they will in production.
11
-
12
- import { createServer, type Server } from "node:http";
13
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
- import { extname, join, normalize } from "node:path";
15
- import { pathToFileURL } from "node:url";
16
-
17
- const TYPES: Record<string, string> = {
18
- ".html": "text/html", ".htm": "text/html", ".css": "text/css",
19
- ".js": "text/javascript", ".mjs": "text/javascript", ".json": "application/json",
20
- ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
21
- ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon",
22
- ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
23
- };
24
-
25
- export interface StaticServer {
26
- url: string; // http://127.0.0.1:<port>
27
- port: number;
28
- close: () => Promise<void>;
29
- }
30
-
31
- // Injected into served HTML when liveReload is on: polls /__mtime and reloads
32
- // when any file in the directory changes (so the page refreshes as a build runs).
33
- const RELOAD_SNIPPET = `<script>(function(){let last=null;setInterval(async function(){try{var r=await fetch('/__mtime');var t=await r.text();if(last!==null&&t!==last){location.reload();}last=t;}catch(e){}},1000);})();</script>`;
34
-
35
- /** Newest mtime (ms) across all files in dir — a cheap change signal. */
36
- function maxMtime(dir: string): number {
37
- let max = 0;
38
- const walk = (d: string) => {
39
- let entries: string[];
40
- try { entries = readdirSync(d); } catch { return; }
41
- for (const name of entries) {
42
- if (name.startsWith(".")) continue;
43
- const full = join(d, name);
44
- let st;
45
- try { st = statSync(full); } catch { continue; }
46
- if (st.isDirectory()) walk(full);
47
- else if (st.mtimeMs > max) max = st.mtimeMs;
48
- }
49
- };
50
- walk(dir);
51
- return max;
52
- }
53
-
54
- /** Serve `dir` on a random loopback port. Path traversal is blocked.
55
- * opts.liveReload injects a poller that reloads the page when files change. */
56
- export function startStaticServer(dir: string, opts: { liveReload?: boolean } = {}): Promise<StaticServer> {
57
- return new Promise((resolve, reject) => {
58
- const server: Server = createServer((req, res) => {
59
- try {
60
- const reqPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
61
- if (opts.liveReload && reqPath === "/__mtime") {
62
- res.writeHead(200, { "Content-Type": "text/plain" });
63
- res.end(String(maxMtime(dir)));
64
- return;
65
- }
66
- // Resolve within dir; reject anything that escapes it.
67
- const rel = normalize(reqPath).replace(/^(\.\.[/\\])+/, "");
68
- let filePath = join(dir, rel);
69
- if (!filePath.startsWith(dir)) { res.writeHead(403).end("forbidden"); return; }
70
- let st;
71
- try { st = statSync(filePath); } catch { res.writeHead(404).end("not found"); return; }
72
- if (st.isDirectory()) filePath = join(filePath, "index.html");
73
- const type = TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
74
- if (opts.liveReload && type === "text/html") {
75
- let html = readFileSync(filePath, "utf8");
76
- html = html.includes("</body>") ? html.replace("</body>", `${RELOAD_SNIPPET}</body>`) : html + RELOAD_SNIPPET;
77
- res.writeHead(200, { "Content-Type": type });
78
- res.end(html);
79
- return;
80
- }
81
- res.writeHead(200, { "Content-Type": type });
82
- res.end(readFileSync(filePath));
83
- } catch {
84
- res.writeHead(500).end("error");
85
- }
86
- });
87
- server.on("error", reject);
88
- server.listen(0, "127.0.0.1", () => {
89
- const addr = server.address();
90
- const port = typeof addr === "object" && addr ? addr.port : 0;
91
- resolve({
92
- url: `http://127.0.0.1:${port}`,
93
- port,
94
- close: () => new Promise<void>((r) => server.close(() => r())),
95
- });
96
- });
97
- });
98
- }
99
-
100
- export interface RenderReport {
101
- ok: boolean; // rendered over http with no JS/console errors
102
- file: string;
103
- title: string;
104
- text: string; // rendered body text (trimmed)
105
- errors: string[]; // console errors + uncaught page errors (http)
106
- screenshotPath?: string;
107
- // The way a non-technical user opens the folder: double-click → file://.
108
- // ES modules + relative imports (and fetch of local assets) die here even
109
- // though they work over a server — so we render BOTH and compare.
110
- fileOk: boolean; // rendered over file:// with no errors AND real content
111
- fileText: string; // rendered body text via file://
112
- fileErrors: string[]; // errors seen via file://
113
- // True when the app clearly works over a server but is broken on double-click
114
- // (renders content over http, but blank/erroring over file://). The classic
115
- // "AI shipped an app that only runs behind a server the user won't start".
116
- doubleClickBroken: boolean;
117
- }
118
-
119
- interface OneRender { title: string; text: string; errors: string[]; }
120
-
121
- /** Render a single URL and capture title, visible text, and errors. */
122
- async function renderOne(
123
- browser: import("playwright").Browser,
124
- url: string,
125
- opts: { screenshotPath?: string; timeoutMs?: number } = {},
126
- ): Promise<OneRender> {
127
- const errors: string[] = [];
128
- const page = await browser.newPage();
129
- page.on("console", (m) => { if (m.type() === "error") errors.push(`console.error: ${m.text()}`); });
130
- page.on("pageerror", (e) => errors.push(`uncaught: ${e.message}`));
131
- page.on("requestfailed", (r) => {
132
- const u = r.url();
133
- if (!u.endsWith("/favicon.ico")) errors.push(`failed request: ${u} (${r.failure()?.errorText ?? "?"})`);
134
- });
135
- try {
136
- await page.goto(url, { waitUntil: "networkidle", timeout: opts.timeoutMs ?? 15_000 });
137
- const title = await page.title().catch(() => "");
138
- const text = (await page.locator("body").innerText().catch(() => "")).trim().slice(0, 800);
139
- if (opts.screenshotPath) {
140
- try { await page.screenshot({ path: opts.screenshotPath, fullPage: true }); } catch { /* non-fatal */ }
141
- }
142
- return { title, text, errors };
143
- } finally {
144
- await page.close();
145
- }
146
- }
147
-
148
- /** Whether the tester can actually run apps: Playwright's Chromium is installed.
149
- * No launch, just the executable lookup — cheap enough to call per task.
150
- * Dynamic import on purpose (same as renderCheck): playwright is optional. */
151
- export async function chromiumAvailable(): Promise<boolean> {
152
- try {
153
- const { chromium } = await import("playwright");
154
- return existsSync(chromium.executablePath());
155
- } catch {
156
- return false;
157
- }
158
- }
159
-
160
- export const CHROMIUM_INSTALL_HINT = "run `npx playwright install chromium` to enable real test execution";
161
-
162
- /** Load a built page in headless Chromium and report what actually happened —
163
- * over http (production-like) AND over file:// (how a user double-clicks it). */
164
- export async function renderCheck(
165
- dir: string,
166
- file = "index.html",
167
- opts: { screenshotPath?: string; timeoutMs?: number } = {},
168
- ): Promise<RenderReport> {
169
- const { chromium } = await import("playwright");
170
- const server = await startStaticServer(dir);
171
- let browser;
172
- try {
173
- browser = await chromium.launch({ headless: true });
174
- } catch (e) {
175
- await server.close(); // don't leak the port if Chromium can't launch
176
- throw e;
177
- }
178
- try {
179
- const http = await renderOne(browser, `${server.url}/${file}`, opts);
180
- // file:// gets no screenshot — the http render is the one we keep.
181
- const fileUrl = pathToFileURL(join(dir, file)).href;
182
- const fileR = await renderOne(browser, fileUrl, { timeoutMs: opts.timeoutMs });
183
-
184
- const ok = http.errors.length === 0;
185
- const fileHasContent = fileR.text.length > 0;
186
- const fileOk = fileR.errors.length === 0 && fileHasContent;
187
- // Broken-on-double-click = works served, but blank or erroring as a file.
188
- const doubleClickBroken = ok && http.text.length > 0 && !fileOk;
189
-
190
- return {
191
- ok,
192
- file,
193
- title: http.title,
194
- text: http.text,
195
- errors: http.errors,
196
- screenshotPath: opts.screenshotPath,
197
- fileOk,
198
- fileText: fileR.text,
199
- fileErrors: fileR.errors,
200
- doubleClickBroken,
201
- };
202
- } finally {
203
- await browser.close();
204
- await server.close();
205
- }
206
- }
@@ -1,41 +0,0 @@
1
- // Registry persistence — makes the "swappable brain" actually swappable at runtime.
2
- // The in-code REGISTRY is the seed. An optional registry.overrides.json (written by
3
- // the Scout) merges on top by (capability, tier) key. New frontier model next month
4
- // => Scout edits the JSON => every route updates. No code change.
5
-
6
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
- import type { RegistryEntry } from "./types.js";
8
- import { REGISTRY } from "./registry.js";
9
-
10
- export const OVERRIDES_FILENAME = "registry.overrides.json";
11
-
12
- const key = (e: Pick<RegistryEntry, "capability" | "tier">) => `${e.capability}/${e.tier}`;
13
-
14
- /** Merge overrides onto the seed registry, replacing entries by (capability, tier). */
15
- export function mergeRegistry(seed: RegistryEntry[], overrides: RegistryEntry[]): RegistryEntry[] {
16
- const map = new Map(seed.map((e) => [key(e), e]));
17
- for (const o of overrides) map.set(key(o), o);
18
- return [...map.values()];
19
- }
20
-
21
- /** Load the effective registry: seed + overrides file if present. */
22
- export function loadRegistry(overridesPath: string, seed: RegistryEntry[] = REGISTRY): RegistryEntry[] {
23
- if (!existsSync(overridesPath)) return seed;
24
- try {
25
- const parsed = JSON.parse(readFileSync(overridesPath, "utf-8")) as { entries?: RegistryEntry[] };
26
- if (!parsed.entries?.length) return seed;
27
- return mergeRegistry(seed, parsed.entries);
28
- } catch (e) {
29
- throw new Error(`Bad ${OVERRIDES_FILENAME}: ${e instanceof Error ? e.message : e}`);
30
- }
31
- }
32
-
33
- /** Persist override entries (the full set the Scout wants applied). */
34
- export function saveOverrides(entries: RegistryEntry[], overridesPath: string): void {
35
- writeFileSync(overridesPath, JSON.stringify({ updated: nowStamp(), entries }, null, 2) + "\n");
36
- }
37
-
38
- // Deterministic-ish stamp without Date.now (kept simple; callers may override).
39
- function nowStamp(): string {
40
- return "scout";
41
- }
package/src/registry.ts DELETED
@@ -1,132 +0,0 @@
1
- // The Model Registry — the swappable brain.
2
- // Maps capability + tier -> model, per backend. Change an entry, re-route everything.
3
- // Seeded from the September-2026 verified roster. This is the ONE file the scout edits.
4
-
5
- import type { Capability, RegistryEntry, Tier } from "./types.js";
6
-
7
- const TIER_ORDER: Tier[] = ["fast", "mid", "high"];
8
-
9
- // Backend intent:
10
- // web = user's free web subscription -> use the strongest brand model.
11
- // api = metered -> use the cost-appropriate model; ask the user when it matters.
12
- export const REGISTRY: RegistryEntry[] = [
13
- // --- PLAN (PM / decomposition, long-horizon planning) ---
14
- {
15
- capability: "plan",
16
- tier: "mid",
17
- byBackend: {
18
- web: { provider: "openai", model: "gpt-5.6-sol" },
19
- api: { provider: "openai", model: "gpt-5.6-terra" },
20
- },
21
- evidence: "OpenAI leads DeepPlanning long-horizon planning; Terra repriced to $2/$12 (Sept 2026)",
22
- updated: "2026-09-15",
23
- },
24
-
25
- // --- DESIGN (UI/UX) ---
26
- {
27
- capability: "design",
28
- tier: "high",
29
- byBackend: {
30
- web: { provider: "anthropic", model: "claude-fable-5-1" },
31
- api: { provider: "openai", model: "gpt-5.6-sol" },
32
- },
33
- ask: true,
34
- evidence: "Design Arena Elo — Fable 5 #2, GPT-5.6 Sol #3; Sol repriced to $4/$20 (Sept 2026)",
35
- updated: "2026-09-15",
36
- },
37
-
38
- // --- CODE (development) ---
39
- {
40
- capability: "code",
41
- tier: "high",
42
- byBackend: {
43
- web: { provider: "anthropic", model: "claude-fable-5-1" },
44
- api: { provider: "anthropic", model: "claude-opus-5" }, // same price as Opus 4.8, 96% SWE-bench V
45
- },
46
- ask: true,
47
- evidence: "SWE-bench Verified — Opus 5 96% (Opus 4.8 was 88.6%), same $5/$25",
48
- updated: "2026-09-15",
49
- },
50
- {
51
- capability: "code",
52
- tier: "mid",
53
- byBackend: {
54
- web: { provider: "anthropic", model: "claude-opus-5" },
55
- api: { provider: "anthropic", model: "claude-sonnet-5" },
56
- },
57
- evidence: "Sonnet 5 — 85.2% SWE-bench V, beats Opus 4.8 on Terminal-Bench 2.1, $2/$10 in Pi's table",
58
- updated: "2026-09-15",
59
- },
60
- {
61
- capability: "code",
62
- tier: "fast",
63
- byBackend: {
64
- web: { provider: "anthropic", model: "claude-sonnet-5" },
65
- api: { provider: "google", model: "gemini-3.8-flash" },
66
- },
67
- evidence: "Gemini 3.8 Flash — 90.8% Terminal-Bench 2.1 at $0.75/$3.75",
68
- updated: "2026-09-15",
69
- },
70
-
71
- // --- REVIEW (read-only wiring check before the tester; one row -> every difficulty is cheap) ---
72
- {
73
- capability: "review",
74
- tier: "fast",
75
- byBackend: {
76
- web: { provider: "google", model: "gemini-3.8-flash" },
77
- api: { provider: "google", model: "gemini-3.8-flash" },
78
- },
79
- evidence: "Read-only wiring check; strongest cheap model, same pick as test",
80
- updated: "2026-09-15",
81
- },
82
-
83
- // --- TEST (QA / review, high volume -> cheap) ---
84
- {
85
- capability: "test",
86
- tier: "fast",
87
- byBackend: {
88
- web: { provider: "google", model: "gemini-3.1-pro-preview" },
89
- api: { provider: "google", model: "gemini-3.8-flash" },
90
- },
91
- evidence: "Gemini 3.8 Flash — 90.8% Terminal-Bench 2.1; +50% over 3 Flash for a much stronger tester",
92
- updated: "2026-09-15",
93
- },
94
-
95
- // --- OPS (Runner: terminal / CI / file-driving autonomy) ---
96
- {
97
- capability: "ops",
98
- tier: "high",
99
- byBackend: {
100
- web: { provider: "openai", model: "gpt-5.6-sol" },
101
- api: { provider: "openai", model: "gpt-5.6-sol" },
102
- },
103
- evidence: "GPT-5.6 Sol on Terminal-Bench; GPT-6 Astra scores higher (57.9 vs 37.3 on TB 4.0) but 2.5x the price — ops tasks are rare",
104
- updated: "2026-09-15",
105
- },
106
- ];
107
-
108
- /** Find the best registry entry for a capability at (or near) a tier.
109
- * Exact tier wins; otherwise fall back to the nearest available tier. */
110
- export function findEntry(
111
- capability: Capability,
112
- tier: Tier,
113
- registry: RegistryEntry[] = REGISTRY,
114
- ): { entry: RegistryEntry; exactTier: boolean } {
115
- const forCap = registry.filter((e) => e.capability === capability);
116
- if (forCap.length === 0) {
117
- throw new Error(`No registry entry for capability "${capability}".`);
118
- }
119
-
120
- const exact = forCap.find((e) => e.tier === tier);
121
- if (exact) return { entry: exact, exactTier: true };
122
-
123
- // Nearest tier by distance in TIER_ORDER.
124
- const want = TIER_ORDER.indexOf(tier);
125
- const nearest = forCap
126
- .slice()
127
- .sort(
128
- (a, b) =>
129
- Math.abs(TIER_ORDER.indexOf(a.tier) - want) - Math.abs(TIER_ORDER.indexOf(b.tier) - want),
130
- )[0]!;
131
- return { entry: nearest, exactTier: false };
132
- }