projectinator 0.1.5 → 0.3.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.
package/src/roles.ts CHANGED
@@ -5,25 +5,25 @@
5
5
  // the orchestrator's feedback loop depends on.
6
6
 
7
7
  import {
8
- AuthStorage,
9
- ModelRegistry,
10
8
  createAgentSession,
11
9
  defineTool,
12
10
  type AgentSession,
13
11
  } from "@earendil-works/pi-coding-agent";
14
12
  import { Type, type Static } from "typebox";
15
- import type {
16
- Backend,
17
- Capability,
18
- Provider,
19
- RegistryEntry,
20
- RoleExecutor,
21
- RoleResult,
22
- Task,
23
- Verdict,
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
24
  } from "./types.js";
25
- import { resolvePiModel } from "./executor.js";
26
- import { renderCheck } from "./preview.js";
25
+ import { piRuntime, resolvePiModel } from "./executor.js"
26
+ import { renderCheck, chromiumAvailable, CHROMIUM_INSTALL_HINT } from "./preview.js";
27
27
  import { estimateCost } from "./cost.js";
28
28
  import { getModel } from "./models.js";
29
29
  import { addSessionCost } from "./session-cost.js";
@@ -45,6 +45,11 @@ const ROLE_INTRO: Record<Capability, string> = {
45
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
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
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.",
48
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.",
49
54
  ops: "You are OPS. Perform the operational task (build, config, deploy prep) using your tools. Report what you did as text.",
50
55
  };
@@ -56,7 +61,7 @@ export function buildRolePrompt(task: Task, contextText: string): string {
56
61
  `Task ${task.id}: ${task.title}`,
57
62
  contextText ? `\n${contextText}` : "",
58
63
  "",
59
- task.capability === "test"
64
+ task.capability === "test" || task.capability === "review"
60
65
  ? "When finished, call submit_verdict exactly once."
61
66
  : "Complete the task, then stop. Do not explain at length.",
62
67
  ];
@@ -80,8 +85,10 @@ type VerdictRaw = Static<typeof VerdictSchema>;
80
85
 
81
86
  // ---- tester "run the app" tool: headless render + error capture ----
82
87
 
83
- function buildCheckTool(workspace: string) {
84
- return defineTool({
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({
85
92
  name: "check_app",
86
93
  label: "Run the app",
87
94
  description:
@@ -93,8 +100,15 @@ function buildCheckTool(workspace: string) {
93
100
  { additionalProperties: true },
94
101
  ),
95
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
+ }
96
109
  try {
97
110
  const r = await renderCheck(workspace, params.file || "index.html");
111
+ rendered = true;
98
112
  const doubleClick = r.doubleClickBroken
99
113
  ? "BROKEN — renders behind a server but is blank/erroring when opened directly as a file (double-click). "
100
114
  + "Most likely ES modules + relative imports (or fetch of local files), which browsers block on file://. "
@@ -119,9 +133,10 @@ function buildCheckTool(workspace: string) {
119
133
  }
120
134
  },
121
135
  });
136
+ return { tool, rendered: () => rendered };
122
137
  }
123
138
 
124
- function buildVerdictTool() {
139
+ function buildVerdictTool(runtimeChecked: () => boolean) {
125
140
  let captured: Verdict | undefined;
126
141
  const tool = defineTool({
127
142
  name: "submit_verdict",
@@ -129,7 +144,7 @@ function buildVerdictTool() {
129
144
  description: "Submit your pass/fail judgement and any bugs found.",
130
145
  parameters: VerdictSchema,
131
146
  execute: async (_id, params: VerdictRaw) => {
132
- captured = { passed: params.passed, bugs: params.bugs };
147
+ captured = { passed: params.passed, bugs: params.bugs, runtimeChecked: runtimeChecked() };
133
148
  return {
134
149
  content: [{ type: "text", text: `Verdict: ${params.passed ? "PASS" : "FAIL"} (${params.bugs.length} bugs)` }],
135
150
  details: {},
@@ -144,16 +159,17 @@ function buildVerdictTool() {
144
159
  // that provider's sensible model, so route() resolves everything to it.
145
160
 
146
161
  const PROVIDER_MODELS: Record<Provider, { strong: string; mid: string; cheap: string }> = {
147
- anthropic: { strong: "claude-opus-4-8", mid: "claude-sonnet-4-6", cheap: "claude-haiku-4-5" },
162
+ anthropic: { strong: "claude-opus-5", mid: "claude-sonnet-5", cheap: "claude-haiku-4-5" },
148
163
  openai: { strong: "gpt-5.6-sol", mid: "gpt-5.6-terra", cheap: "gpt-5.6-luna" },
149
- google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3-flash-preview" },
150
- openrouter: { strong: "anthropic/claude-opus-4.8", mid: "anthropic/claude-sonnet-4.6", cheap: "openai/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" },
151
166
  };
152
167
 
153
168
  const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
154
169
  plan: "mid",
155
170
  design: "strong",
156
171
  code: "strong",
172
+ review: "cheap",
157
173
  test: "cheap",
158
174
  ops: "strong",
159
175
  };
@@ -161,7 +177,7 @@ const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
161
177
  /** A registry where every capability routes to one provider's models. */
162
178
  export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
163
179
  const m = PROVIDER_MODELS[provider];
164
- const caps: Capability[] = ["plan", "design", "code", "test", "ops"];
180
+ const caps: Capability[] = ["plan", "design", "code", "review", "test", "ops"];
165
181
  const tiers = ["fast", "mid", "high"] as const;
166
182
  const out: RegistryEntry[] = [];
167
183
  for (const capability of caps) {
@@ -183,7 +199,6 @@ export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
183
199
  export interface PiExecutorOptions {
184
200
  workspace: string;
185
201
  backend: Backend;
186
- authStorage?: AuthStorage;
187
202
  thinkingLevel?: "off" | "low" | "medium" | "high";
188
203
  onEvent?: Parameters<AgentSession["subscribe"]>[0];
189
204
  /** Called when a task falls back from its routed provider to another one. */
@@ -262,8 +277,6 @@ function listFiles(dir: string): string[] {
262
277
  /** Build a real RoleExecutor backed by Pi. Each call spends money. Falls back to
263
278
  * another key-holding provider when the routed one errors or returns 0 tokens. */
264
279
  export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
265
- const authStorage = opts.authStorage ?? AuthStorage.create();
266
-
267
280
  // One attempt on a specific provider/model. Returns the result + total tokens
268
281
  // (0 tokens = the provider call didn't really happen → treat as a failure).
269
282
  const runOnce = async (
@@ -271,31 +284,54 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
271
284
  contextText: string,
272
285
  provider: Provider,
273
286
  modelId: string,
287
+ limits: TaskLimits,
274
288
  ): Promise<{ result: RoleResult; tokensTotal: number }> => {
275
- const registry = ModelRegistry.create(authStorage);
276
- const model = resolvePiModel(registry, provider, modelId);
289
+ const runtime = await piRuntime();
290
+ const model = resolvePiModel(runtime, provider, modelId);
277
291
 
278
292
  const isTest = task.capability === "test";
279
- const verdictTool = isTest ? buildVerdictTool() : undefined;
280
- const checkTool = isTest ? buildCheckTool(opts.workspace) : undefined;
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;
281
297
 
282
298
  const { session } = await createAgentSession({
283
299
  model,
284
300
  cwd: opts.workspace,
285
- authStorage,
286
- modelRegistry: registry,
301
+ modelRuntime: runtime,
287
302
  thinkingLevel: opts.thinkingLevel ?? "medium",
288
303
  ...(isTest
289
- ? { customTools: [verdictTool!.tool, checkTool!], tools: ["read", "bash", "ls", "grep", "find", "check_app", "submit_verdict"] }
290
- : { tools: ["read", "write", "edit", "bash", "ls", "grep", "find"] }),
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"] }),
291
308
  });
292
309
 
293
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;
294
330
  try {
295
- // Give code/test the WHOLE current file tree (not just direct-dep files), so a
331
+ // Give code/review/test the WHOLE current file tree (not just direct-dep files), so a
296
332
  // dev building one file knows every other file that already exists to wire into.
297
333
  let fullContext = contextText;
298
- if (task.capability === "code" || task.capability === "test") {
334
+ if (task.capability === "code" || task.capability === "review" || task.capability === "test") {
299
335
  const existing = listFiles(opts.workspace);
300
336
  if (existing.length) {
301
337
  fullContext = [contextText, `Files already in the working directory:\n${existing.map((f) => ` ${f}`).join("\n")}`]
@@ -303,16 +339,18 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
303
339
  .join("\n\n");
304
340
  }
305
341
  }
306
- await session.prompt(buildRolePrompt(task, fullContext));
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;
307
345
 
308
346
  let verdict = verdictTool?.get();
309
- if (isTest && !verdict) {
310
- await session.followUp("Call submit_verdict now with your judgement.");
347
+ if (verdictTool && !verdict) {
348
+ await session.followUp("Call submit_verdict now with your judgement.").catch((e: unknown) => { if (!breach) throw e; });
311
349
  verdict = verdictTool?.get();
312
350
  }
351
+ if (breach) throw breach;
313
352
 
314
353
  const stats = session.getSessionStats();
315
- addSessionCost(stats.cost);
316
354
  // Feed real usage back to sharpen estimates — but only for a real run.
317
355
  if (stats.tokens.total > 0) {
318
356
  const inputTotal = stats.tokens.input + stats.tokens.cacheRead;
@@ -326,24 +364,28 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
326
364
  };
327
365
  return { result, tokensTotal: stats.tokens.total };
328
366
  } finally {
367
+ clearTimeout(timer);
368
+ unsubCost?.();
369
+ addSessionCost(session.getSessionStats().cost); // bill every attempt, aborted or not
329
370
  unsub?.();
330
371
  session.dispose();
331
372
  }
332
373
  };
333
374
 
334
- return async ({ task, decision, contextText }) => {
375
+ return async ({ task, decision, contextText, limits }) => {
335
376
  const chain = fallbackChain(decision.provider, decision.model.id, task.capability);
336
377
  let lastErr: unknown;
337
378
  for (let i = 0; i < chain.length; i++) {
338
379
  const cand = chain[i]!;
339
380
  try {
340
- const att = await runOnce(task, contextText, cand.provider, cand.model);
381
+ const att = await runOnce(task, contextText, cand.provider, cand.model, limits);
341
382
  if (att.tokensTotal > 0) {
342
383
  if (i > 0) opts.onFallback?.({ taskId: task.id, from: decision.provider, to: cand.provider, model: cand.model });
343
384
  return att.result;
344
385
  }
345
386
  lastErr = new Error(`${cand.provider}/${cand.model} returned 0 tokens (invalid key, no account credit/balance, or no access to this model)`);
346
387
  } catch (e) {
388
+ if (e instanceof TaskLimitError) throw e; // a limit breach is final — never retry elsewhere
347
389
  lastErr = e;
348
390
  }
349
391
  }
@@ -356,4 +398,4 @@ function round2(n: number): number {
356
398
  }
357
399
 
358
400
  // exported for tests
359
- export { buildVerdictTool, VerdictSchema, estimateCost, getModel };
401
+ export { buildCheckTool, buildVerdictTool, VerdictSchema, estimateCost, getModel };
package/src/router.ts CHANGED
@@ -81,7 +81,7 @@ export function route(task: Task, ctx: RouteContext): RouteDecision {
81
81
 
82
82
  // 5. Cost.
83
83
  const cost = estimateCost(task.estTokens, model);
84
- const runningTotal = round2((ctx.runningTotalBefore ?? 0) + cost);
84
+ const runningTotal = Math.round(((ctx.runningTotalBefore ?? 0) + cost) * 10_000) / 10_000;
85
85
  const overCap = runningTotal > policy.budgetCapUSD;
86
86
  if (overCap) reasons.push(`OVER CAP: running $${runningTotal} > cap $${policy.budgetCapUSD}`);
87
87
 
@@ -110,14 +110,11 @@ export function routeBacklog(tasks: Task[], ctx: RouteContext): RouteDecision[]
110
110
  return out;
111
111
  }
112
112
 
113
- function round2(n: number): number {
114
- return Math.round(n * 100) / 100;
115
- }
116
-
117
113
  /** A sensible default policy. */
118
114
  export const DEFAULT_POLICY: RoutingPolicy = {
119
115
  backendMode: "cost-first",
120
116
  budgetCapUSD: 15,
121
117
  difficultyToTier: { trivial: "fast", low: "mid", medium: "mid", high: "high" },
122
118
  maxFeedbackRounds: 3,
119
+ taskLimits: { timeoutMs: 10 * 60_000, costCapUSD: 3 },
123
120
  };
package/src/run-build.ts CHANGED
@@ -11,7 +11,6 @@
11
11
  import { mkdirSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { dirname, join } from "node:path";
14
- import { AuthStorage } from "@earendil-works/pi-coding-agent";
15
14
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
16
15
  import type { Provider, Task } from "./types.js";
17
16
  import { DEFAULT_POLICY } from "./router.js";
@@ -20,7 +19,7 @@ import { lockRegistryToProvider, makePiExecutor } from "./roles.js";
20
19
  import { estimateTokens } from "./estimate.js";
21
20
  import { route } from "./router.js";
22
21
  import { decomposeIdea } from "./pm.js";
23
- import { newBuildState, saveState, loadState, type BuildState } from "./build-state.js";
22
+ import { newBuildState, saveState, loadState, completedIds, type BuildState } from "./build-state.js";
24
23
  import { initRepo, commitTask } from "./git.js";
25
24
 
26
25
  const args = process.argv.slice(2);
@@ -32,16 +31,25 @@ const lockIdx = args.indexOf("--lock");
32
31
  const lockProvider = (lockIdx >= 0 ? args[lockIdx + 1] : "anthropic") as Provider;
33
32
  const concIdx = args.indexOf("--concurrency");
34
33
  const concurrency = Math.max(1, parseInt((concIdx >= 0 ? args[concIdx + 1] : "1") ?? "1", 10) || 1);
34
+ const capIdx = args.indexOf("--task-cap");
35
+ const tmoIdx = args.indexOf("--task-timeout");
35
36
  const consumed = new Set(
36
37
  ["--live", "--mini", "--fan", "--resume", "--lock", lockIdx >= 0 ? args[lockIdx + 1] : "",
37
- "--concurrency", concIdx >= 0 ? args[concIdx + 1] : ""].filter(Boolean),
38
+ "--concurrency", concIdx >= 0 ? args[concIdx + 1] : "",
39
+ "--task-cap", capIdx >= 0 ? args[capIdx + 1] : "",
40
+ "--task-timeout", tmoIdx >= 0 ? args[tmoIdx + 1] : ""].filter(Boolean),
38
41
  );
39
42
  const idea = args.filter((a) => !consumed.has(a)).join(" ").trim() ||
40
43
  "A one-page site with a headline and a contact form.";
41
44
 
42
45
  const money = (n: number) => `$${n.toFixed(2)}`;
43
46
  const registry = lockRegistryToProvider(lockProvider);
44
- const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: mini ? 10 : 25 };
47
+ // --task-cap USD / --task-timeout MIN override the per-task limits (0 = unlimited).
48
+ const taskLimits = {
49
+ costCapUSD: capIdx >= 0 ? parseFloat(args[capIdx + 1] ?? "") : DEFAULT_POLICY.taskLimits.costCapUSD,
50
+ timeoutMs: tmoIdx >= 0 ? parseFloat(args[tmoIdx + 1] ?? "") * 60_000 : DEFAULT_POLICY.taskLimits.timeoutMs,
51
+ };
52
+ const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: mini ? 10 : 25, taskLimits };
45
53
 
46
54
  // --- a tiny fixed backlog for cheap end-to-end proof ---
47
55
  function miniBacklog(): Task[] {
@@ -51,7 +59,8 @@ function miniBacklog(): Task[] {
51
59
  return [
52
60
  mk("D-1", "design", "low", "Write a short design spec for a centered card that says 'Hello from Projectinator' on a soft gradient background."),
53
61
  mk("C-1", "code", "low", "Create index.html implementing the design spec exactly. Single self-contained file with embedded CSS.", ["D-1"]),
54
- mk("T-1", "test", "trivial", "Open/read index.html and verify it is valid HTML and matches the design spec (centered card, the headline text, a gradient).", ["C-1"]),
62
+ mk("R-1", "review", "trivial", "Review index.html against the design spec: file present, no unresolved references, runs on double-click.", ["C-1"]),
63
+ mk("T-1", "test", "trivial", "Open/read index.html and verify it is valid HTML and matches the design spec (centered card, the headline text, a gradient).", ["R-1"]),
55
64
  ];
56
65
  }
57
66
 
@@ -66,7 +75,9 @@ function fanBacklog(): Task[] {
66
75
  mk("DB", "design", "low", "Design spec for an 'FAQ' accordion (3 questions)."),
67
76
  mk("CA", "code", "low", "Create newsletter.html from the newsletter design spec.", ["DA"]),
68
77
  mk("CB", "code", "low", "Create faq.html from the FAQ design spec.", ["DB"]),
69
- mk("TJ", "test", "trivial", "Verify newsletter.html and faq.html are valid and match their specs.", ["CA", "CB"]),
78
+ mk("RA", "review", "trivial", "Review newsletter.html against its spec.", ["CA"]),
79
+ mk("RB", "review", "trivial", "Review faq.html against its spec.", ["CB"]),
80
+ mk("TJ", "test", "trivial", "Verify newsletter.html and faq.html are valid and match their specs.", ["RA", "RB"]),
70
81
  ];
71
82
  }
72
83
 
@@ -135,7 +146,7 @@ if (prior) {
135
146
  state.status = "running";
136
147
  tasks = prior.tasks; // authoritative backlog from the interrupted run
137
148
  seedOutcomes = prior.outcomes;
138
- const doneCount = new Set(prior.outcomes.map((o) => o.taskId)).size;
149
+ const doneCount = completedIds(prior).size;
139
150
  console.log(` Resuming: ${doneCount} task(s) already done, restored ${money(prior.totalCost)}.`);
140
151
  } else {
141
152
  if (resume) console.log(" (No prior state found — starting fresh.)");
@@ -159,8 +170,9 @@ const onProgress = (e: OrchestratorEvent) => {
159
170
  if (e.type === "task_start") console.log(` ▶ ${e.task.id} [${e.task.capability}] -> ${e.provider}/${e.modelId} (round ${e.round})`);
160
171
  else if (e.type === "task_done") {
161
172
  const hash = commitTask(workspace, e.outcome.taskId, titleById.get(e.outcome.taskId) ?? e.outcome.taskId);
162
- console.log(` ✓ ${e.outcome.taskId} ${money(e.outcome.cost)} running ${money(e.runningTotal)}${e.outcome.verdict ? ` verdict=${e.outcome.verdict.passed ? "PASS" : "FAIL"}` : ""}${hash ? ` [${hash}]` : ""}`);
173
+ console.log(` ✓ ${e.outcome.taskId} ${money(e.outcome.cost)} running ${money(e.runningTotal)}${e.outcome.verdict ? ` verdict=${e.outcome.verdict.passed ? (e.outcome.verdict.runtimeChecked ? "PASS" : "PASS* (app not executed — no Chromium)") : "FAIL"}` : ""}${hash ? ` [${hash}]` : ""}`);
163
174
  }
175
+ else if (e.type === "task_failed") console.log(` ⛔ ${e.outcome.taskId} ABORTED (${e.outcome.error}) — billed ${money(e.outcome.cost)}, halting`);
164
176
  else if (e.type === "task_skipped") console.log(` · ${e.taskId} skipped (already done)`);
165
177
  else if (e.type === "test_failed") console.log(` ✗ ${e.taskId} FAILED (${e.bugs} bugs) — round ${e.round}`);
166
178
  else if (e.type === "retry_dev") console.log(` ↻ re-running ${e.taskId} to fix ${e.forTest}`);
package/src/run-dev.ts CHANGED
@@ -8,11 +8,10 @@
8
8
  import { mkdirSync } from "node:fs";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { dirname, join } from "node:path";
11
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
12
11
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
13
12
  import type { Task } from "./types.js";
14
13
  import { DEFAULT_POLICY, route } from "./router.js";
15
- import { buildDeveloperPrompt, executeTask, resolvePiModel } from "./executor.js";
14
+ import { buildDeveloperPrompt, executeTask, piRuntime, resolvePiModel } from "./executor.js";
16
15
 
17
16
  const TASK: Task = {
18
17
  id: "T-DEV1",
@@ -29,9 +28,7 @@ const live = process.argv.includes("--live");
29
28
  const policy = { ...DEFAULT_POLICY, backendMode: "api" as const };
30
29
  const decision = route(TASK, { policy });
31
30
 
32
- const auth = AuthStorage.create();
33
- const registry = ModelRegistry.create(auth);
34
- const piModel = resolvePiModel(registry, decision.provider, decision.model.id); // offline, free
31
+ const piModel = resolvePiModel(await piRuntime(), decision.provider, decision.model.id); // offline, free
35
32
 
36
33
  const money = (n: number) => `$${n.toFixed(2)}`;
37
34
 
package/src/run-pm.ts CHANGED
@@ -5,11 +5,10 @@
5
5
  //
6
6
  // Live decomposition needs an API key (PM routes to an OpenAI model by default).
7
7
 
8
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
9
8
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
10
9
  import { DEFAULT_POLICY, routeBacklog } from "./router.js";
11
10
  import { findEntry } from "./registry.js";
12
- import { resolvePiModel } from "./executor.js";
11
+ import { piRuntime, resolvePiModel } from "./executor.js"
13
12
  import { decomposeIdea, pmSystemPrompt } from "./pm.js";
14
13
 
15
14
  const args = process.argv.slice(2);
@@ -26,13 +25,12 @@ const idea = args.filter((a) => !consumed.has(a)).join(" ").trim() ||
26
25
  const backend = "api" as const; // web-login backend not built yet
27
26
  const money = (n: number) => `$${n.toFixed(2)}`;
28
27
 
29
- const auth = AuthStorage.create();
30
- const registry = ModelRegistry.create(auth);
28
+ const runtime = await piRuntime();
31
29
  const { entry } = findEntry("plan", "mid");
32
30
  const pick = pmOverride
33
31
  ? { provider: pmOverride.split("/")[0] as typeof entry.byBackend[typeof backend]["provider"], model: pmOverride.split("/").slice(1).join("/") }
34
32
  : entry.byBackend[backend];
35
- const pm = resolvePiModel(registry, pick.provider, pick.model); // offline
33
+ const pm = resolvePiModel(runtime, pick.provider, pick.model); // offline
36
34
 
37
35
  console.log(`\n Projectinator — Phase 3 PM decomposer [${live ? "LIVE" : "DRY"}]\n`);
38
36
  console.log(` Idea: ${idea}`);
package/src/tui/App.tsx CHANGED
@@ -57,8 +57,15 @@ import {
57
57
  type ProjectInfo,
58
58
  } from "./engine.js";
59
59
  import { deploy, DEPLOY_META, type DeployTarget } from "./deploy.js";
60
- import { startStaticServer, type StaticServer } from "../preview.js";
61
- import type { Task, TaskOutcome } from "../types.js";
60
+ import { startStaticServer, CHROMIUM_INSTALL_HINT, type StaticServer } from "../preview.js";
61
+ import type { Capability, Task, TaskOutcome } from "../types.js";
62
+ import { completedIds } from "../build-state.js";
63
+ import type { Verdict } from "../types.js";
64
+
65
+ /** Board label: PASS* = a TEST that passed without ever running the app (Chromium missing).
66
+ * Reviews never run anything, so they are plain PASS/FAIL. */
67
+ const verdictLabel = (v: Verdict, capability: Capability): "PASS" | "PASS*" | "FAIL" =>
68
+ !v.passed ? "FAIL" : v.runtimeChecked || capability !== "test" ? "PASS" : "PASS*";
62
69
 
63
70
  type Phase =
64
71
  | "setup" | "home" | "settings" | "projects" | "projectActions" | "addAsset" | "rename" | "confirmDelete" | "filterEpic" | "editBoard" | "kanban" | "templates" | "exportMenu" | "deployMenu" | "deploying" | "preview" | "bakeoff" | "history" | "retro" | "burndown" | "saveTemplate" | "importTemplate" | "myTemplates" | "tplActions"
@@ -77,7 +84,7 @@ export default function App(): React.ReactElement {
77
84
  const [tasks, setTasks] = useState<TaskView[]>([]);
78
85
  const [spent, setSpent] = useState(0);
79
86
  const [gate, setGate] = useState<{ resolve: (d: "continue" | "stop") => void } | null>(null);
80
- const [buildResult, setBuildResult] = useState<{ halted: boolean; files: string[]; workspace: string } | null>(null);
87
+ const [buildResult, setBuildResult] = useState<{ halted: boolean; haltReason?: string; files: string[]; workspace: string } | null>(null);
81
88
 
82
89
  // Existing-project context (open/resume/make-changes).
83
90
  const [projects, setProjects] = useState<ProjectInfo[]>([]);
@@ -295,11 +302,14 @@ export default function App(): React.ReactElement {
295
302
  ...t,
296
303
  status: "done",
297
304
  cost: (t.cost ?? 0) + e.outcome.cost,
298
- verdict: e.outcome.verdict ? (e.outcome.verdict.passed ? "PASS" : "FAIL") : t.verdict,
305
+ verdict: e.outcome.verdict ? verdictLabel(e.outcome.verdict, e.outcome.capability) : t.verdict,
299
306
  }
300
307
  : t,
301
308
  ),
302
309
  );
310
+ } else if (e.type === "task_failed") {
311
+ setSpent(e.runningTotal);
312
+ setTasks((ts) => ts.map((t) => (t.id === e.outcome.taskId ? { ...t, status: "failed", cost: (t.cost ?? 0) + e.outcome.cost } : t)));
303
313
  } else if (e.type === "task_skipped") {
304
314
  setTasks((ts) => ts.map((t) => (t.id === e.taskId ? { ...t, status: "skipped" } : t)));
305
315
  } else if (e.type === "test_failed") {
@@ -318,6 +328,7 @@ export default function App(): React.ReactElement {
318
328
  const handle = startBuild(idea, plan, {
319
329
  concurrency: prefs.concurrency,
320
330
  budgetCapUSD: projectCap ?? prefs.budgetCapUSD,
331
+ taskLimits: { timeoutMs: prefs.taskTimeoutMin * 60_000, costCapUSD: prefs.taskCostCapUSD },
321
332
  onEvent,
322
333
  workspace: targetWorkspace,
323
334
  seedOutcomes: seed,
@@ -329,7 +340,7 @@ export default function App(): React.ReactElement {
329
340
  .then((r) => {
330
341
  if (!alive) return;
331
342
  setSpent(r.totalCost);
332
- setBuildResult({ halted: r.halted, files: r.files, workspace: handle.workspace });
343
+ setBuildResult({ halted: r.halted, haltReason: r.haltReason, files: r.files, workspace: handle.workspace });
333
344
  setPhase("done");
334
345
  if (getNotify()) {
335
346
  notifyBuildDone(
@@ -496,7 +507,7 @@ export default function App(): React.ReactElement {
496
507
  }
497
508
 
498
509
  if (phase === "projectActions" && selected) {
499
- const doneIds = new Set(selected.state.outcomes.map((o) => o.taskId));
510
+ const doneIds = completedIds(selected.state);
500
511
  // Buildable if it was halted OR the backlog has tasks that were never built.
501
512
  const hasUnbuilt = selected.state.tasks.some((t) => !doneIds.has(t.id));
502
513
  const canResume = selected.status === "halted" || hasUnbuilt;
@@ -512,6 +523,7 @@ export default function App(): React.ReactElement {
512
523
  title: t.title,
513
524
  epic: t.epic,
514
525
  dependsOn: t.dependsOn,
526
+ notes: t.notes,
515
527
  status: doneIds.has(t.id) ? "done" : "pending",
516
528
  cost: costById.get(t.id),
517
529
  assignee: modelById.has(t.id) ? modelLabel(modelById.get(t.id)!) : undefined,
@@ -612,7 +624,7 @@ export default function App(): React.ReactElement {
612
624
  setPhase("change");
613
625
  } else if (i.value === "resume") {
614
626
  const { registry, lock } = chooseRegistry(providers);
615
- const done = new Set(selected.state.outcomes.map((o) => o.taskId));
627
+ const done = completedIds(selected.state);
616
628
  setPlan({
617
629
  tasks: selected.state.tasks,
618
630
  provider: lock ?? providers[0]!,
@@ -641,7 +653,7 @@ export default function App(): React.ReactElement {
641
653
  }
642
654
 
643
655
  if (phase === "editBoard" && selected) {
644
- const doneIds = new Set(selected.state.outcomes.map((o) => o.taskId));
656
+ const doneIds = completedIds(selected.state);
645
657
  return (
646
658
  <Box flexDirection="column">
647
659
  <EditableBoard
@@ -817,7 +829,7 @@ export default function App(): React.ReactElement {
817
829
  }
818
830
 
819
831
  if (phase === "kanban" && selected) {
820
- const done = new Set(selected.state.outcomes.map((o) => o.taskId));
832
+ const done = completedIds(selected.state);
821
833
  const costBy = new Map<string, number>();
822
834
  const modelBy = new Map<string, string>();
823
835
  for (const o of selected.state.outcomes) {
@@ -830,6 +842,7 @@ export default function App(): React.ReactElement {
830
842
  title: t.title,
831
843
  epic: t.epic,
832
844
  dependsOn: t.dependsOn,
845
+ notes: t.notes,
833
846
  status: done.has(t.id) ? "done" : "pending",
834
847
  cost: costBy.get(t.id),
835
848
  assignee: modelBy.has(t.id) ? modelLabel(modelBy.get(t.id)!) : undefined,
@@ -1572,13 +1585,14 @@ export default function App(): React.ReactElement {
1572
1585
 
1573
1586
  if (phase === "building") {
1574
1587
  const running = tasks.filter((t) => t.status === "running").length;
1575
- const metaById = new Map((plan?.tasks ?? []).map((t) => [t.id, { deps: t.dependsOn ?? [], epic: t.epic }]));
1588
+ const metaById = new Map((plan?.tasks ?? []).map((t) => [t.id, { deps: t.dependsOn ?? [], epic: t.epic, notes: t.notes }]));
1576
1589
  const board: BoardTask[] = tasks.map((t) => ({
1577
1590
  id: t.id,
1578
1591
  capability: t.capability,
1579
1592
  title: t.title,
1580
1593
  epic: metaById.get(t.id)?.epic,
1581
1594
  dependsOn: metaById.get(t.id)?.deps,
1595
+ notes: metaById.get(t.id)?.notes,
1582
1596
  status: t.status,
1583
1597
  cost: t.cost,
1584
1598
  verdict: t.verdict,
@@ -1630,6 +1644,9 @@ export default function App(): React.ReactElement {
1630
1644
  {alerting ? (
1631
1645
  <Text color={C.warn}>⚠ {Math.round((spent / cap) * 100)}% of the ${cap} cap spent — nearing the limit.</Text>
1632
1646
  ) : null}
1647
+ {board.some((t) => t.verdict === "PASS*") ? (
1648
+ <Text color={C.warn}>⚠ Tester could not run the app (no headless Chromium) — PASS* verdicts are code-reading only. {CHROMIUM_INSTALL_HINT}.</Text>
1649
+ ) : null}
1633
1650
  </Box>
1634
1651
  );
1635
1652
  })()}
@@ -1641,8 +1658,11 @@ export default function App(): React.ReactElement {
1641
1658
  return (
1642
1659
  <Box flexDirection="column">
1643
1660
  <Text bold color={buildResult.halted ? C.warn : C.good}>
1644
- {buildResult.halted ? "⚠ Build halted (budget cap)" : "✓ Build complete"}
1661
+ {buildResult.halted ? `⚠ Build halted (${buildResult.haltReason ?? "budget cap"})` : "✓ Build complete"}
1645
1662
  </Text>
1663
+ {tasks.some((t) => t.verdict === "PASS*") ? (
1664
+ <Text color={C.warn}>⚠ Tests marked PASS* were never executed in a browser — {CHROMIUM_INSTALL_HINT}.</Text>
1665
+ ) : null}
1646
1666
  <Box marginTop={1}>
1647
1667
  <Standup
1648
1668
  tasks={tasks.map((t) => ({ id: t.id, capability: t.capability, title: t.title, status: t.status, cost: t.cost, verdict: t.verdict }))}
@@ -11,7 +11,7 @@ import { cleanDeps } from "./engine.js";
11
11
  import { estimateTokens } from "../estimate.js";
12
12
  import { groupByEpic } from "./Kanban.js";
13
13
 
14
- const CAPS: Capability[] = ["plan", "design", "code", "test", "ops"];
14
+ const CAPS: Capability[] = ["plan", "design", "code", "review", "test", "ops"]
15
15
  const DIFFS: Difficulty[] = ["trivial", "low", "medium", "high"];
16
16
 
17
17
  interface Card extends Task {
@@ -34,7 +34,7 @@ export function BoardEditor({
34
34
  const [items, setItems] = useState<Card[]>(() => tasks.map((t) => ({ ...t, parked: true })));
35
35
  const [cursor, setCursor] = useState(0);
36
36
  const [editing, setEditing] = useState(false);
37
- const [editField, setEditField] = useState<"title" | "epic" | "deps">("title");
37
+ const [editField, setEditField] = useState<"title" | "epic" | "deps" | "notes">("title");
38
38
  const [draft, setDraft] = useState("");
39
39
  const [warn, setWarn] = useState("");
40
40
  const [busy, setBusy] = useState<string>(""); // epic being broken down
@@ -134,6 +134,10 @@ export function BoardEditor({
134
134
  setEditField("deps");
135
135
  setDraft((selected.dependsOn ?? []).join(" "));
136
136
  setEditing(true);
137
+ } else if (input === "n") {
138
+ setEditField("notes");
139
+ setDraft(selected.notes ?? "");
140
+ setEditing(true);
137
141
  } else if (input === "c") {
138
142
  const cap = CAPS[(CAPS.indexOf(selected.capability) + 1) % CAPS.length]!;
139
143
  update(selected.id, { capability: cap, estTokens: estimateTokens(cap, selected.difficulty) });
@@ -170,6 +174,7 @@ export function BoardEditor({
170
174
  )}
171
175
  </Box>
172
176
  {(c.dependsOn ?? []).length ? <Text color={C.dim}> ↖ {(c.dependsOn ?? []).join(", ")}</Text> : null}
177
+ {c.notes ? <Text color={C.dim} wrap="truncate-end"> ✎ {c.notes}</Text> : null}
173
178
  </Box>
174
179
  );
175
180
  })}
@@ -202,6 +207,12 @@ export function BoardEditor({
202
207
  />
203
208
  </Box>
204
209
  ) : null}
210
+ {editing && editField === "notes" && selected ? (
211
+ <Box>
212
+ <Text color={C.accent}>Note for {selected.id} (yours only, never sent to the model): </Text>
213
+ <TextInput value={draft} onChange={setDraft} onSubmit={() => { update(selected.id, { notes: draft.trim() || undefined }); setEditing(false); }} />
214
+ </Box>
215
+ ) : null}
205
216
 
206
217
  {/* column headers */}
207
218
  <Box marginTop={1}>
@@ -233,6 +244,7 @@ export function BoardEditor({
233
244
  { keys: "e", label: "edit" },
234
245
  { keys: "g", label: "epic" },
235
246
  { keys: "D", label: "deps" },
247
+ { keys: "n", label: "note" },
236
248
  { keys: "c", label: "cap" },
237
249
  { keys: "f", label: "diff" },
238
250
  { keys: "b", label: "break" },