projectinator 0.1.4 → 0.2.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
@@ -12,18 +12,20 @@ import {
12
12
  type AgentSession,
13
13
  } from "@earendil-works/pi-coding-agent";
14
14
  import { Type, type Static } from "typebox";
15
- import type {
16
- Backend,
17
- Capability,
18
- Provider,
19
- RegistryEntry,
20
- RoleExecutor,
21
- RoleResult,
22
- Task,
23
- Verdict,
15
+ import {
16
+ TaskLimitError,
17
+ type Backend,
18
+ type Capability,
19
+ type Provider,
20
+ type RegistryEntry,
21
+ type RoleExecutor,
22
+ type RoleResult,
23
+ type Task,
24
+ type TaskLimits,
25
+ type Verdict,
24
26
  } from "./types.js";
25
27
  import { resolvePiModel } from "./executor.js";
26
- import { renderCheck } from "./preview.js";
28
+ import { renderCheck, chromiumAvailable, CHROMIUM_INSTALL_HINT } from "./preview.js";
27
29
  import { estimateCost } from "./cost.js";
28
30
  import { getModel } from "./models.js";
29
31
  import { addSessionCost } from "./session-cost.js";
@@ -45,6 +47,11 @@ const ROLE_INTRO: Record<Capability, string> = {
45
47
  "reuse and extend existing files, follow the file structure the design spec defines, and make sure files reference each other with correct paths " +
46
48
  "(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
49
  "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.",
50
+ review:
51
+ "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. " +
52
+ "Check: every file the design named exists; every <script src> / <link href> / import resolves to a real file; nothing is referenced but never defined " +
53
+ "(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://); " +
54
+ "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
55
  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
56
  ops: "You are OPS. Perform the operational task (build, config, deploy prep) using your tools. Report what you did as text.",
50
57
  };
@@ -56,7 +63,7 @@ export function buildRolePrompt(task: Task, contextText: string): string {
56
63
  `Task ${task.id}: ${task.title}`,
57
64
  contextText ? `\n${contextText}` : "",
58
65
  "",
59
- task.capability === "test"
66
+ task.capability === "test" || task.capability === "review"
60
67
  ? "When finished, call submit_verdict exactly once."
61
68
  : "Complete the task, then stop. Do not explain at length.",
62
69
  ];
@@ -80,8 +87,10 @@ type VerdictRaw = Static<typeof VerdictSchema>;
80
87
 
81
88
  // ---- tester "run the app" tool: headless render + error capture ----
82
89
 
83
- function buildCheckTool(workspace: string) {
84
- return defineTool({
90
+ /** check_app + a flag telling whether a render actually happened this task. */
91
+ function buildCheckTool(workspace: string, chromium: boolean) {
92
+ let rendered = false;
93
+ const tool = defineTool({
85
94
  name: "check_app",
86
95
  label: "Run the app",
87
96
  description:
@@ -93,8 +102,15 @@ function buildCheckTool(workspace: string) {
93
102
  { additionalProperties: true },
94
103
  ),
95
104
  execute: async (_id, params: { file?: string }) => {
105
+ if (!chromium) {
106
+ return {
107
+ 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.` }],
108
+ details: {},
109
+ };
110
+ }
96
111
  try {
97
112
  const r = await renderCheck(workspace, params.file || "index.html");
113
+ rendered = true;
98
114
  const doubleClick = r.doubleClickBroken
99
115
  ? "BROKEN — renders behind a server but is blank/erroring when opened directly as a file (double-click). "
100
116
  + "Most likely ES modules + relative imports (or fetch of local files), which browsers block on file://. "
@@ -119,9 +135,10 @@ function buildCheckTool(workspace: string) {
119
135
  }
120
136
  },
121
137
  });
138
+ return { tool, rendered: () => rendered };
122
139
  }
123
140
 
124
- function buildVerdictTool() {
141
+ function buildVerdictTool(runtimeChecked: () => boolean) {
125
142
  let captured: Verdict | undefined;
126
143
  const tool = defineTool({
127
144
  name: "submit_verdict",
@@ -129,7 +146,7 @@ function buildVerdictTool() {
129
146
  description: "Submit your pass/fail judgement and any bugs found.",
130
147
  parameters: VerdictSchema,
131
148
  execute: async (_id, params: VerdictRaw) => {
132
- captured = { passed: params.passed, bugs: params.bugs };
149
+ captured = { passed: params.passed, bugs: params.bugs, runtimeChecked: runtimeChecked() };
133
150
  return {
134
151
  content: [{ type: "text", text: `Verdict: ${params.passed ? "PASS" : "FAIL"} (${params.bugs.length} bugs)` }],
135
152
  details: {},
@@ -154,6 +171,7 @@ const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
154
171
  plan: "mid",
155
172
  design: "strong",
156
173
  code: "strong",
174
+ review: "cheap",
157
175
  test: "cheap",
158
176
  ops: "strong",
159
177
  };
@@ -161,7 +179,7 @@ const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
161
179
  /** A registry where every capability routes to one provider's models. */
162
180
  export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
163
181
  const m = PROVIDER_MODELS[provider];
164
- const caps: Capability[] = ["plan", "design", "code", "test", "ops"];
182
+ const caps: Capability[] = ["plan", "design", "code", "review", "test", "ops"];
165
183
  const tiers = ["fast", "mid", "high"] as const;
166
184
  const out: RegistryEntry[] = [];
167
185
  for (const capability of caps) {
@@ -271,13 +289,16 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
271
289
  contextText: string,
272
290
  provider: Provider,
273
291
  modelId: string,
292
+ limits: TaskLimits,
274
293
  ): Promise<{ result: RoleResult; tokensTotal: number }> => {
275
294
  const registry = ModelRegistry.create(authStorage);
276
295
  const model = resolvePiModel(registry, provider, modelId);
277
296
 
278
297
  const isTest = task.capability === "test";
279
- const verdictTool = isTest ? buildVerdictTool() : undefined;
280
- const checkTool = isTest ? buildCheckTool(opts.workspace) : undefined;
298
+ const isReview = task.capability === "review";
299
+ const checkTool = isTest ? buildCheckTool(opts.workspace, await chromiumAvailable()) : undefined;
300
+ // A review never runs the app, so its verdict is never "runtime checked".
301
+ const verdictTool = isTest || isReview ? buildVerdictTool(checkTool ? checkTool.rendered : () => false) : undefined;
281
302
 
282
303
  const { session } = await createAgentSession({
283
304
  model,
@@ -286,16 +307,37 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
286
307
  modelRegistry: registry,
287
308
  thinkingLevel: opts.thinkingLevel ?? "medium",
288
309
  ...(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"] }),
310
+ ? { customTools: [verdictTool!.tool, checkTool!.tool], tools: ["read", "bash", "ls", "grep", "find", "check_app", "submit_verdict"] }
311
+ : isReview
312
+ ? { customTools: [verdictTool!.tool], tools: ["read", "ls", "grep", "find", "submit_verdict"] }
313
+ : { tools: ["read", "write", "edit", "bash", "ls", "grep", "find"] }),
291
314
  });
292
315
 
293
316
  const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
317
+
318
+ // Per-task limits. Cost is checked on every session event (Pi updates its stats as
319
+ // each assistant turn lands); time by a timer. On breach the session is aborted and
320
+ // the awaited prompt settles; we then throw so the orchestrator halts the build.
321
+ let breach: TaskLimitError | undefined;
322
+ const trip = (e: TaskLimitError) => {
323
+ if (breach) return;
324
+ breach = e;
325
+ void session.abort();
326
+ };
327
+ const unsubCost = limits.costCapUSD > 0
328
+ ? session.subscribe(() => {
329
+ const spent = session.getSessionStats().cost;
330
+ if (spent > limits.costCapUSD) trip(new TaskLimitError("cost", task.id, round2(spent), `spent $${spent.toFixed(2)} > per-task cap $${limits.costCapUSD}`));
331
+ })
332
+ : undefined;
333
+ const timer = limits.timeoutMs > 0
334
+ ? setTimeout(() => trip(new TaskLimitError("timeout", task.id, round2(session.getSessionStats().cost), `ran longer than ${Math.round(limits.timeoutMs / 60_000)} min`)), limits.timeoutMs)
335
+ : undefined;
294
336
  try {
295
- // Give code/test the WHOLE current file tree (not just direct-dep files), so a
337
+ // Give code/review/test the WHOLE current file tree (not just direct-dep files), so a
296
338
  // dev building one file knows every other file that already exists to wire into.
297
339
  let fullContext = contextText;
298
- if (task.capability === "code" || task.capability === "test") {
340
+ if (task.capability === "code" || task.capability === "review" || task.capability === "test") {
299
341
  const existing = listFiles(opts.workspace);
300
342
  if (existing.length) {
301
343
  fullContext = [contextText, `Files already in the working directory:\n${existing.map((f) => ` ${f}`).join("\n")}`]
@@ -303,16 +345,18 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
303
345
  .join("\n\n");
304
346
  }
305
347
  }
306
- await session.prompt(buildRolePrompt(task, fullContext));
348
+ // An aborted prompt may reject with Pi's own error; the breach is the real cause.
349
+ await session.prompt(buildRolePrompt(task, fullContext)).catch((e: unknown) => { if (!breach) throw e; });
350
+ if (breach) throw breach;
307
351
 
308
352
  let verdict = verdictTool?.get();
309
- if (isTest && !verdict) {
310
- await session.followUp("Call submit_verdict now with your judgement.");
353
+ if (verdictTool && !verdict) {
354
+ await session.followUp("Call submit_verdict now with your judgement.").catch((e: unknown) => { if (!breach) throw e; });
311
355
  verdict = verdictTool?.get();
312
356
  }
357
+ if (breach) throw breach;
313
358
 
314
359
  const stats = session.getSessionStats();
315
- addSessionCost(stats.cost);
316
360
  // Feed real usage back to sharpen estimates — but only for a real run.
317
361
  if (stats.tokens.total > 0) {
318
362
  const inputTotal = stats.tokens.input + stats.tokens.cacheRead;
@@ -326,24 +370,28 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
326
370
  };
327
371
  return { result, tokensTotal: stats.tokens.total };
328
372
  } finally {
373
+ clearTimeout(timer);
374
+ unsubCost?.();
375
+ addSessionCost(session.getSessionStats().cost); // bill every attempt, aborted or not
329
376
  unsub?.();
330
377
  session.dispose();
331
378
  }
332
379
  };
333
380
 
334
- return async ({ task, decision, contextText }) => {
381
+ return async ({ task, decision, contextText, limits }) => {
335
382
  const chain = fallbackChain(decision.provider, decision.model.id, task.capability);
336
383
  let lastErr: unknown;
337
384
  for (let i = 0; i < chain.length; i++) {
338
385
  const cand = chain[i]!;
339
386
  try {
340
- const att = await runOnce(task, contextText, cand.provider, cand.model);
387
+ const att = await runOnce(task, contextText, cand.provider, cand.model, limits);
341
388
  if (att.tokensTotal > 0) {
342
389
  if (i > 0) opts.onFallback?.({ taskId: task.id, from: decision.provider, to: cand.provider, model: cand.model });
343
390
  return att.result;
344
391
  }
345
392
  lastErr = new Error(`${cand.provider}/${cand.model} returned 0 tokens (invalid key, no account credit/balance, or no access to this model)`);
346
393
  } catch (e) {
394
+ if (e instanceof TaskLimitError) throw e; // a limit breach is final — never retry elsewhere
347
395
  lastErr = e;
348
396
  }
349
397
  }
@@ -356,4 +404,4 @@ function round2(n: number): number {
356
404
  }
357
405
 
358
406
  // exported for tests
359
- export { buildVerdictTool, VerdictSchema, estimateCost, getModel };
407
+ 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
@@ -20,7 +20,7 @@ import { lockRegistryToProvider, makePiExecutor } from "./roles.js";
20
20
  import { estimateTokens } from "./estimate.js";
21
21
  import { route } from "./router.js";
22
22
  import { decomposeIdea } from "./pm.js";
23
- import { newBuildState, saveState, loadState, type BuildState } from "./build-state.js";
23
+ import { newBuildState, saveState, loadState, completedIds, type BuildState } from "./build-state.js";
24
24
  import { initRepo, commitTask } from "./git.js";
25
25
 
26
26
  const args = process.argv.slice(2);
@@ -32,16 +32,25 @@ const lockIdx = args.indexOf("--lock");
32
32
  const lockProvider = (lockIdx >= 0 ? args[lockIdx + 1] : "anthropic") as Provider;
33
33
  const concIdx = args.indexOf("--concurrency");
34
34
  const concurrency = Math.max(1, parseInt((concIdx >= 0 ? args[concIdx + 1] : "1") ?? "1", 10) || 1);
35
+ const capIdx = args.indexOf("--task-cap");
36
+ const tmoIdx = args.indexOf("--task-timeout");
35
37
  const consumed = new Set(
36
38
  ["--live", "--mini", "--fan", "--resume", "--lock", lockIdx >= 0 ? args[lockIdx + 1] : "",
37
- "--concurrency", concIdx >= 0 ? args[concIdx + 1] : ""].filter(Boolean),
39
+ "--concurrency", concIdx >= 0 ? args[concIdx + 1] : "",
40
+ "--task-cap", capIdx >= 0 ? args[capIdx + 1] : "",
41
+ "--task-timeout", tmoIdx >= 0 ? args[tmoIdx + 1] : ""].filter(Boolean),
38
42
  );
39
43
  const idea = args.filter((a) => !consumed.has(a)).join(" ").trim() ||
40
44
  "A one-page site with a headline and a contact form.";
41
45
 
42
46
  const money = (n: number) => `$${n.toFixed(2)}`;
43
47
  const registry = lockRegistryToProvider(lockProvider);
44
- const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: mini ? 10 : 25 };
48
+ // --task-cap USD / --task-timeout MIN override the per-task limits (0 = unlimited).
49
+ const taskLimits = {
50
+ costCapUSD: capIdx >= 0 ? parseFloat(args[capIdx + 1] ?? "") : DEFAULT_POLICY.taskLimits.costCapUSD,
51
+ timeoutMs: tmoIdx >= 0 ? parseFloat(args[tmoIdx + 1] ?? "") * 60_000 : DEFAULT_POLICY.taskLimits.timeoutMs,
52
+ };
53
+ const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: mini ? 10 : 25, taskLimits };
45
54
 
46
55
  // --- a tiny fixed backlog for cheap end-to-end proof ---
47
56
  function miniBacklog(): Task[] {
@@ -51,7 +60,8 @@ function miniBacklog(): Task[] {
51
60
  return [
52
61
  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
62
  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"]),
63
+ mk("R-1", "review", "trivial", "Review index.html against the design spec: file present, no unresolved references, runs on double-click.", ["C-1"]),
64
+ 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
65
  ];
56
66
  }
57
67
 
@@ -66,7 +76,9 @@ function fanBacklog(): Task[] {
66
76
  mk("DB", "design", "low", "Design spec for an 'FAQ' accordion (3 questions)."),
67
77
  mk("CA", "code", "low", "Create newsletter.html from the newsletter design spec.", ["DA"]),
68
78
  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"]),
79
+ mk("RA", "review", "trivial", "Review newsletter.html against its spec.", ["CA"]),
80
+ mk("RB", "review", "trivial", "Review faq.html against its spec.", ["CB"]),
81
+ mk("TJ", "test", "trivial", "Verify newsletter.html and faq.html are valid and match their specs.", ["RA", "RB"]),
70
82
  ];
71
83
  }
72
84
 
@@ -135,7 +147,7 @@ if (prior) {
135
147
  state.status = "running";
136
148
  tasks = prior.tasks; // authoritative backlog from the interrupted run
137
149
  seedOutcomes = prior.outcomes;
138
- const doneCount = new Set(prior.outcomes.map((o) => o.taskId)).size;
150
+ const doneCount = completedIds(prior).size;
139
151
  console.log(` Resuming: ${doneCount} task(s) already done, restored ${money(prior.totalCost)}.`);
140
152
  } else {
141
153
  if (resume) console.log(" (No prior state found — starting fresh.)");
@@ -159,8 +171,9 @@ const onProgress = (e: OrchestratorEvent) => {
159
171
  if (e.type === "task_start") console.log(` ▶ ${e.task.id} [${e.task.capability}] -> ${e.provider}/${e.modelId} (round ${e.round})`);
160
172
  else if (e.type === "task_done") {
161
173
  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}]` : ""}`);
174
+ 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
175
  }
176
+ else if (e.type === "task_failed") console.log(` ⛔ ${e.outcome.taskId} ABORTED (${e.outcome.error}) — billed ${money(e.outcome.cost)}, halting`);
164
177
  else if (e.type === "task_skipped") console.log(` · ${e.taskId} skipped (already done)`);
165
178
  else if (e.type === "test_failed") console.log(` ✗ ${e.taskId} FAILED (${e.bugs} bugs) — round ${e.round}`);
166
179
  else if (e.type === "retry_dev") console.log(` ↻ re-running ${e.taskId} to fix ${e.forTest}`);
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" },
@@ -8,7 +8,7 @@ import { C, ROLE_META, KeyHint, TextField as TextInput } from "./components.js";
8
8
  import { estimateTokens } from "../estimate.js";
9
9
  import { groupByEpic } from "./Kanban.js";
10
10
 
11
- const CAPS: Capability[] = ["plan", "design", "code", "test", "ops"];
11
+ const CAPS: Capability[] = ["plan", "design", "code", "review", "test", "ops"]
12
12
  const DIFFS: Difficulty[] = ["trivial", "low", "medium", "high"];
13
13
 
14
14
  export function EditableBoard({
@@ -25,7 +25,7 @@ export function EditableBoard({
25
25
  const [items, setItems] = useState<Task[]>(() => tasks.map((t) => ({ ...t })));
26
26
  const [cursor, setCursor] = useState(0);
27
27
  const [editing, setEditing] = useState(false);
28
- const [field, setField] = useState<"title" | "epic" | "deps">("title");
28
+ const [field, setField] = useState<"title" | "epic" | "deps" | "notes">("title");
29
29
  const [draft, setDraft] = useState("");
30
30
  const [warn, setWarn] = useState("");
31
31
 
@@ -82,9 +82,10 @@ export function EditableBoard({
82
82
  if (key.escape) return onCancel();
83
83
  if (input === "g") { setField("epic"); setDraft(selected.epic || "General"); setEditing(true); return; }
84
84
  if (input === "D") { setField("deps"); setDraft((selected.dependsOn ?? []).join(" ")); setEditing(true); return; }
85
+ if (input === "n") { setField("notes"); setDraft(selected.notes ?? ""); setEditing(true); return; }
85
86
  // fields below don't change built work
86
87
  if (isDone(selected.id)) {
87
- setWarn(`${selected.id} is already built — only its epic (g) can be changed.`);
88
+ setWarn(`${selected.id} is already built — only its epic (g) and notes (n) can be changed.`);
88
89
  return;
89
90
  }
90
91
  if (input === "e") { setField("title"); setDraft(selected.title); setEditing(true); }
@@ -123,6 +124,11 @@ export function EditableBoard({
123
124
  />
124
125
  </Box>
125
126
  ) : null}
127
+ {editing && field === "notes" && selected ? (
128
+ <Box><Text color={C.accent}>Note for {selected.id} (yours only, never sent to the model): </Text>
129
+ <TextInput value={draft} onChange={setDraft} onSubmit={() => { update(selected.id, { notes: draft.trim() || undefined }); setEditing(false); }} />
130
+ </Box>
131
+ ) : null}
126
132
 
127
133
  {lanes.map((lane) => (
128
134
  <Box key={lane.epic} flexDirection="column" marginTop={1}>
@@ -131,19 +137,22 @@ export function EditableBoard({
131
137
  const sel = selected?.id === t.id;
132
138
  const done = isDone(t.id);
133
139
  return (
134
- <Box key={t.id}>
135
- <Box width={2}><Text color={C.accent}>{sel ? "›" : " "}</Text></Box>
136
- <Box width={2}><Text color={done ? C.good : C.dim}>{done ? "" : ""}</Text></Box>
137
- <Box width={7}><Text color={C.dim}>{t.id}</Text></Box>
138
- <Box width={3}><Text>{ROLE_META[t.capability].emoji}</Text></Box>
139
- <Box width={16}><Text color={sel ? C.accent : C.dim}>{t.capability}/{t.difficulty}</Text></Box>
140
- <Box flexGrow={1}>
141
- {sel && editing && field === "title" ? (
142
- <TextInput value={draft} onChange={setDraft} onSubmit={() => { update(t.id, { title: draft.trim() || t.title }); setEditing(false); }} />
143
- ) : (
144
- <Text color={sel ? C.text : done ? C.dim : C.text} wrap="truncate-end">{t.title}</Text>
145
- )}
140
+ <Box key={t.id} flexDirection="column">
141
+ <Box>
142
+ <Box width={2}><Text color={C.accent}>{sel ? "" : " "}</Text></Box>
143
+ <Box width={2}><Text color={done ? C.good : C.dim}>{done ? "✓" : "○"}</Text></Box>
144
+ <Box width={7}><Text color={C.dim}>{t.id}</Text></Box>
145
+ <Box width={3}><Text>{ROLE_META[t.capability].emoji}</Text></Box>
146
+ <Box width={16}><Text color={sel ? C.accent : C.dim}>{t.capability}/{t.difficulty}</Text></Box>
147
+ <Box flexGrow={1}>
148
+ {sel && editing && field === "title" ? (
149
+ <TextInput value={draft} onChange={setDraft} onSubmit={() => { update(t.id, { title: draft.trim() || t.title }); setEditing(false); }} />
150
+ ) : (
151
+ <Text color={sel ? C.text : done ? C.dim : C.text} wrap="truncate-end">{t.title}</Text>
152
+ )}
153
+ </Box>
146
154
  </Box>
155
+ {t.notes ? <Box marginLeft={30}><Text color={C.dim} wrap="truncate-end">✎ {t.notes}</Text></Box> : null}
147
156
  </Box>
148
157
  );
149
158
  })}
@@ -155,6 +164,7 @@ export function EditableBoard({
155
164
  { keys: "[ ]", label: "reorder" },
156
165
  { keys: "g", label: "epic" },
157
166
  { keys: "D", label: "deps" },
167
+ { keys: "n", label: "note" },
158
168
  { keys: "e", label: "rename" },
159
169
  { keys: "c", label: "cap" },
160
170
  { keys: "f", label: "diff" },