projectinator 0.1.5 → 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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/projectinator.svg?color=e0a72d&label=npm)](https://www.npmjs.com/package/projectinator)
8
8
  ![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)
9
9
  ![node](https://img.shields.io/badge/node-%E2%89%A520-brightgreen.svg)
10
- ![tests: 140 passing](https://img.shields.io/badge/tests-140%20passing-brightgreen.svg)
10
+ ![tests: 157 passing](https://img.shields.io/badge/tests-157%20passing-brightgreen.svg)
11
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)
12
12
  ![built on Pi](https://img.shields.io/badge/built%20on-Pi%20agent%20harness-e0a72d.svg)
13
13
 
@@ -49,7 +49,8 @@ npm start
49
49
  </details>
50
50
 
51
51
  > **Optional:** `npx playwright install chromium` lets the tester actually run web apps in a
52
- > headless browser and enables live preview. Everything else works without it.
52
+ > headless browser and enables live preview. Without it the tester can only read the code —
53
+ > such passes show as **PASS\*** with a warning. Everything else works without it.
53
54
 
54
55
  > Projectinator spends **your** API money. Every screen shows the running cost; you set a
55
56
  > budget cap and it halts before crossing it. A tiny landing page is cents; a full app is
@@ -64,13 +65,15 @@ Type an idea → it plans → you approve → it builds, tests, and hands you wo
64
65
  - **Best model per role.** Roles bind to a *capability + tier*, never a model name. A
65
66
  swappable registry maps capabilities to models — new frontier model next month, edit one
66
67
  place, every route updates. Run a **bake-off** to pick empirically.
67
- - **A real pipeline.** PM decomposes → Designer specs → Developer writes files → Tester
68
- **runs the app headless and catches real bugs** → feedback loop re-runs the dev on failure.
68
+ - **A real pipeline.** PM decomposes → Designer specs → Developer writes files → **Reviewer**
69
+ (cheap, read-only) checks the wiring → Tester **runs the app headless and catches real
70
+ bugs** → feedback loop re-runs the dev on failure.
69
71
  - **Multi-file apps.** Vanilla HTML/CSS/JS or **React (CDN, no build)** — your choice.
70
72
  - **The cockpit.** A polished terminal UI: editable board, Kanban, standup, per-task cost,
71
73
  live budget bar, desktop notification when done.
72
74
  - **Honest cost.** Live spend tracking, per-project budget cap + an alert before the cap,
73
- and predicted-vs-actual reporting that sharpens itself over real runs.
75
+ **per-task timeout and cost ceiling**, and predicted-vs-actual reporting that sharpens
76
+ itself over real runs.
74
77
 
75
78
  ## Highlights
76
79
 
@@ -79,13 +82,16 @@ Type an idea → it plans → you approve → it builds, tests, and hands you wo
79
82
  | 🧠 **PM intake** | Vague request? The PM asks 2–4 clarifying questions (with pickable options) before planning. Specific requests skip straight through. |
80
83
  | 🏛 **Deep plan (council)** | Opt-in: architect + product + risk leads propose epics in parallel, a synthesizer merges them, you approve, then they expand into the backlog. |
81
84
  | 🆚 **Model bake-off** | Run one task across models, an LLM judge scores the outputs, compare cost/latency/quality — save the winner to the registry. |
82
- | 🧪 **Real test execution** | The tester loads the built app in headless Chromium and fails on JS/console errors not just by reading the code. |
85
+ | 🔍 **Reviewer** | A cheap read-only model checks each code task's wiring (missing files, unresolved `<script src>`, undefined functions, ES modules on a static site) before the Tester spends a browser run. Fails feed the same Developer fix loop. |
86
+ | 🧪 **Real test execution** | The tester loads the built app in headless Chromium and fails on JS/console errors — not just by reading the code. Without Chromium, passes are marked **PASS\*** so you know the app was never run. |
83
87
  | 👁 **Live preview** | Local server + auto-reload; ES modules and fetch resolve like production. |
84
88
  | 🚀 **Deploy** | One click to Cloudflare Pages, Vercel, or Netlify (their CLI + your login). |
85
89
  | 📤 **Export** | Backlog → Markdown, CSV, **Jira** CSV, **Trello** CSV. |
86
90
  | 📜 **Git per build** | The workspace is a git repo; one commit per task. History view + **undo a task**. |
87
91
  | 📊 **Analytics** | Retro (with optional AI narrative), burndown, cost by epic/model, and estimate accuracy that self-calibrates from real runs. |
88
92
  | 💾 **Templates** | Save a project's brief as a reusable template; import/share as a file. |
93
+ | ⛔ **Task limits** | Per-task timeout and cost ceiling (Settings → Preferences). A runaway task is aborted, billed for what it spent, and the build halts — resumable. |
94
+ | ✎ **Task notes** | Annotate any task on the board (`n`). Yours only — never sent to a model; included in exports. |
89
95
 
90
96
  ## How it works
91
97
 
@@ -95,8 +101,7 @@ idea
95
101
  └─ intake? PM asks clarifying questions if the request is vague
96
102
  └─ plan mode? Quick (one PM) or Deep (planning council → approve epics)
97
103
  └─ decompose → a routed, epic-tagged backlog with a cost estimate
98
- └─ approve auto-run, or gate the backlog / gate again before dev
99
- └─ build toposort deps · design → code → test · Tester→Dev feedback loop
104
+ └─ build toposort deps · design code review test · Reviewer/Tester→Dev feedback loop
100
105
  └─ done working files + retro + deploy/export/preview
101
106
  ```
102
107
 
@@ -120,8 +125,9 @@ npm start # the cockpit (the normal way
120
125
  npm run build -- --live --mini # cheap end-to-end proof (~$0.10)
121
126
  npm run build -- --live --lock anthropic "idea" # full pipeline on one provider
122
127
  npm run build -- --live --mini --resume # resume a halted/finished build (skips done tasks)
128
+ npm run build -- --live --mini --task-cap 0.5 --task-timeout 5 # per-task limits (USD / minutes)
123
129
  npm run bakeoff -- --capability design "Design a pricing page" # model bake-off
124
- npm test # 140 tests
130
+ npm test # 157 tests
125
131
  npm run typecheck
126
132
  ```
127
133
 
@@ -129,7 +135,8 @@ npm run typecheck
129
135
 
130
136
  - **Keys** are stored at `~/.projectinator/config.json` (chmod 0600) — never in the repo.
131
137
  - **Settings** (in the app): API keys, preferred provider, default workflow, default stack,
132
- model assignments, budget cap + alert %, estimate accuracy.
138
+ model assignments (incl. the Reviewer), budget cap + alert %, per-task timeout + cost cap,
139
+ estimate accuracy.
133
140
  - **User data** lives under `~/.projectinator/` (config, calibration, templates, exports).
134
141
  Each build gets its own git-versioned workspace folder (one commit per finished task).
135
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectinator",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Your AI build team in the terminal — hand it an app idea, a PM model plans a Scrum backlog, and the best model per role designs, codes, and tests it into working files. Bring your own API key.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -40,7 +40,8 @@ export function loadState(path: string): BuildState | undefined {
40
40
  }
41
41
  }
42
42
 
43
- /** Which task ids are already finished (last outcome wins). Used to skip on resume. */
43
+ /** Which task ids are already finished (last outcome wins; a failed attempt does not
44
+ * count). Used to skip on resume. */
44
45
  export function completedIds(state: BuildState): Set<string> {
45
- return new Set(state.outcomes.map((o) => o.taskId));
46
+ return new Set(state.outcomes.filter((o) => !o.error).map((o) => o.taskId));
46
47
  }
package/src/cost.ts CHANGED
@@ -22,7 +22,9 @@ function ratesFor(model: Model, inputTokens: number) {
22
22
  }
23
23
 
24
24
  /** Estimate USD cost for one task on a given model.
25
- * A fraction of input can be served from cache at the cheaper cacheRead rate. */
25
+ * A fraction of input can be served from cache at the cheaper cacheRead rate.
26
+ * Kept to 4 decimals, not cents: a backlog of sub-cent tasks would otherwise
27
+ * estimate to $0.00 and under-reserve budget in the parallel scheduler. */
26
28
  export function estimateCost(est: TokenEstimate, model: Model): number {
27
29
  const cachedFraction = clamp01(est.cachedInputFraction ?? 0);
28
30
  const rate = ratesFor(model, est.input);
@@ -33,13 +35,9 @@ export function estimateCost(est: TokenEstimate, model: Model): number {
33
35
  const inputCost = (freshInput / PER_MILLION) * rate.input + (cachedInput / PER_MILLION) * rate.cacheRead;
34
36
  const outputCost = (est.output / PER_MILLION) * rate.output;
35
37
 
36
- return round2(inputCost + outputCost);
38
+ return Math.round((inputCost + outputCost) * 10_000) / 10_000;
37
39
  }
38
40
 
39
41
  function clamp01(n: number): number {
40
42
  return Math.max(0, Math.min(1, n));
41
43
  }
42
-
43
- function round2(n: number): number {
44
- return Math.round(n * 100) / 100;
45
- }
package/src/estimate.ts CHANGED
@@ -37,6 +37,13 @@ const BUCKETS: Record<Capability, Record<Difficulty, Bucket>> = {
37
37
  medium: { input: 25_000, output: 7_000 },
38
38
  high: { input: 60_000, output: 14_000 },
39
39
  },
40
+ // Review reads the same tree the tester does but never runs it — shorter output.
41
+ review: {
42
+ trivial: { input: 15_000, output: 800 },
43
+ low: { input: 25_000, output: 1_500 },
44
+ medium: { input: 50_000, output: 3_000 },
45
+ high: { input: 80_000, output: 5_000 },
46
+ },
40
47
  test: {
41
48
  trivial: { input: 15_000, output: 1_200 },
42
49
  low: { input: 25_000, output: 2_500 },
@@ -8,12 +8,13 @@
8
8
  // The executor is INJECTED (RoleExecutor), so this entire control flow is testable
9
9
  // offline with a fake — no model, no spend. The real Pi executor lives in roles.ts.
10
10
 
11
- import type {
12
- RegistryEntry,
13
- RoleExecutor,
14
- RoutingPolicy,
15
- Task,
16
- TaskOutcome,
11
+ import {
12
+ TaskLimitError,
13
+ type RegistryEntry,
14
+ type RoleExecutor,
15
+ type RoutingPolicy,
16
+ type Task,
17
+ type TaskOutcome,
17
18
  } from "./types.js";
18
19
  import { route } from "./router.js";
19
20
  import { REGISTRY } from "./registry.js";
@@ -62,6 +63,7 @@ export interface RunOptions {
62
63
  export type OrchestratorEvent =
63
64
  | { type: "task_start"; task: Task; round: number; provider: string; modelId: string }
64
65
  | { type: "task_done"; outcome: TaskOutcome; runningTotal: number }
66
+ | { type: "task_failed"; outcome: TaskOutcome; runningTotal: number }
65
67
  | { type: "task_skipped"; taskId: string }
66
68
  | { type: "test_failed"; taskId: string; bugs: number; round: number }
67
69
  | { type: "retry_dev"; taskId: string; forTest: string; round: number }
@@ -107,14 +109,19 @@ export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunRe
107
109
  let running = 0;
108
110
 
109
111
  // Resume: replay prior outcomes so finished tasks are skipped and cost is restored.
112
+ // A failed attempt is billed but never "done" — it is rebuilt.
110
113
  const seed = opts.seedOutcomes ?? [];
111
114
  for (const o of seed) {
112
115
  record.push(o);
113
- outcomes.set(o.taskId, o); // last wins (retries overwrite)
116
+ if (o.error) outcomes.delete(o.taskId); // last wins: a later failure voids an earlier pass
117
+ else outcomes.set(o.taskId, o);
114
118
  running += o.cost;
115
119
  }
116
120
  running = round2(running);
117
- const wasDone = new Set(seed.map((o) => o.taskId));
121
+ const wasDone = new Set(outcomes.keys());
122
+
123
+ let halted = false;
124
+ let haltReason: string | undefined;
118
125
 
119
126
  const emit = opts.onProgress ?? (() => {});
120
127
  const checkpoint = () => opts.onCheckpoint?.(record, round2(running));
@@ -132,39 +139,58 @@ export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunRe
132
139
  const decision = route(task, { policy, registry, runningTotalBefore: running });
133
140
  emit({ type: "task_start", task, round, provider: decision.provider, modelId: decision.model.id });
134
141
  const contextText = contextOverride ?? gatherContext(task, outcomes);
135
- const result = await execute({ task, decision, contextText, round });
136
- const outcome: TaskOutcome = {
137
- ...result,
138
- taskId: task.id,
139
- capability: task.capability,
140
- provider: decision.provider,
141
- modelId: decision.model.id,
142
- round,
143
- };
144
- running += result.cost;
142
+ const meta = { taskId: task.id, capability: task.capability, provider: decision.provider, modelId: decision.model.id, round };
143
+ let outcome: TaskOutcome;
144
+ try {
145
+ outcome = { ...(await execute({ task, decision, contextText, round, limits: policy.taskLimits })), ...meta };
146
+ } catch (e) {
147
+ if (!(e instanceof TaskLimitError)) throw e;
148
+ // Limit breach: bill what was spent, record the failure, halt the build.
149
+ outcome = { finalText: "", files: [], cost: e.costSoFar, error: e.message, ...meta };
150
+ running += outcome.cost;
151
+ record.push(outcome);
152
+ halted = true;
153
+ haltReason = `${task.id} aborted: ${e.message}`;
154
+ emit({ type: "task_failed", outcome, runningTotal: round2(running) });
155
+ return outcome;
156
+ }
157
+ running += outcome.cost;
145
158
  outcomes.set(task.id, outcome);
146
159
  record.push(outcome);
147
160
  emit({ type: "task_done", outcome, runningTotal: round2(running) });
148
161
  return outcome;
149
162
  };
150
163
 
151
- // One task's full lifecycle: run it, then its Tester->Developer feedback loop.
164
+ // One task's full lifecycle: run it, then its Reviewer/Tester -> Developer feedback loop.
152
165
  const runTaskUnit = async (task: Task): Promise<void> => {
153
166
  let outcome = await runOne(task, 0);
154
- if (task.capability === "test" && outcome.verdict && !outcome.verdict.passed) {
155
- const codeDeps = (task.dependsOn ?? [])
156
- .map((id) => byId.get(id))
157
- .filter((t): t is Task => !!t && t.capability === "code");
167
+ const judges = task.capability === "test" || task.capability === "review";
168
+ if (!outcome.error && judges && outcome.verdict && !outcome.verdict.passed) {
169
+ // The code to fix: direct code deps, plus code deps reached through a review
170
+ // (a test depends on the review, which depends on the code).
171
+ const codeDeps: Task[] = [];
172
+ for (const id of task.dependsOn ?? []) {
173
+ const dep = byId.get(id);
174
+ if (!dep) continue;
175
+ if (dep.capability === "code") codeDeps.push(dep);
176
+ else if (dep.capability === "review") {
177
+ for (const id2 of dep.dependsOn ?? []) {
178
+ const d2 = byId.get(id2);
179
+ if (d2?.capability === "code" && !codeDeps.includes(d2)) codeDeps.push(d2);
180
+ }
181
+ }
182
+ }
158
183
 
159
184
  let round = 1;
160
- while (outcome.verdict && !outcome.verdict.passed && round <= policy.maxFeedbackRounds) {
185
+ fix: while (outcome.verdict && !outcome.verdict.passed && round <= policy.maxFeedbackRounds) {
161
186
  emit({ type: "test_failed", taskId: task.id, bugs: outcome.verdict.bugs.length, round });
162
187
  const fixContext = bugReport(outcome.verdict.bugs);
163
188
  for (const dep of codeDeps) {
164
189
  emit({ type: "retry_dev", taskId: dep.id, forTest: task.id, round });
165
- await runOne(dep, round, fixContext);
190
+ if ((await runOne(dep, round, fixContext)).error) break fix;
166
191
  }
167
192
  outcome = await runOne(task, round); // re-test
193
+ if (outcome.error) break;
168
194
  round++;
169
195
  }
170
196
  }
@@ -201,6 +227,7 @@ export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunRe
201
227
  return { outcomes: record, totalCost: round2(running), halted: true, haltReason: "budget cap" };
202
228
  }
203
229
  await runTaskUnit(task);
230
+ if (halted) return { outcomes: record, totalCost: round2(running), halted, haltReason };
204
231
  }
205
232
  return { outcomes: record, totalCost: round2(running), halted: false };
206
233
  }
@@ -215,8 +242,7 @@ export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunRe
215
242
  const inFlight = new Map<string, Promise<void>>();
216
243
  let reserved = 0;
217
244
  let codeInFlight = 0; // code tasks are serialized (they share files) even in parallel mode
218
- let halted = false;
219
- let haltReason: string | undefined;
245
+ let failure: unknown; // first task error; rethrown after in-flight work drains
220
246
 
221
247
  const depsSatisfied = (t: Task) => (t.dependsOn ?? []).every((d) => !remaining.has(d));
222
248
  const readyTasks = () =>
@@ -235,8 +261,10 @@ export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunRe
235
261
  if (inFlight.size >= concurrency) break;
236
262
  // Only one code task builds at a time — they write to the shared workspace.
237
263
  if (task.capability === "code" && codeInFlight >= 1) continue;
238
- const est = route(task, { policy, registry, runningTotalBefore: round2(running + reserved) });
239
- if (round2(running + reserved + est.cost) > policy.budgetCapUSD) {
264
+ // Reservations keep full precision: rounding each one to cents drops sub-cent
265
+ // estimates entirely, so a wide backlog of cheap tasks would under-reserve.
266
+ const est = route(task, { policy, registry, runningTotalBefore: running + reserved });
267
+ if (running + reserved + est.cost > policy.budgetCapUSD) {
240
268
  if (inFlight.size === 0) {
241
269
  emit({ type: "budget_halt", runningTotal: round2(running + est.cost), cap: policy.budgetCapUSD });
242
270
  halted = true;
@@ -244,15 +272,25 @@ export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunRe
244
272
  }
245
273
  break; // wait for in-flight tasks to free budget/capacity
246
274
  }
247
- reserved = round2(reserved + est.cost);
275
+ reserved += est.cost;
248
276
  const cost = est.cost;
249
277
  const isCode = task.capability === "code";
250
278
  if (isCode) codeInFlight++;
251
- const p = runTaskUnit(task).then(() => {
279
+ const settle = () => {
252
280
  if (isCode) codeInFlight--;
253
- reserved = round2(reserved - cost);
281
+ reserved -= cost;
254
282
  remaining.delete(task.id);
255
283
  inFlight.delete(task.id);
284
+ };
285
+ // A rejection must NOT escape through Promise.race below: that abandons the
286
+ // sibling promises, and their later rejections would have no handler attached
287
+ // (unhandled rejection -> the host process dies mid-build). Capture the first
288
+ // failure, stop launching, drain what's running, checkpoint, then rethrow.
289
+ const p = runTaskUnit(task).then(settle, (e: unknown) => {
290
+ settle();
291
+ halted = true;
292
+ haltReason ??= e instanceof Error ? e.message : String(e);
293
+ failure ??= e;
256
294
  });
257
295
  inFlight.set(task.id, p);
258
296
  }
@@ -263,6 +301,7 @@ export async function runBacklog(tasks: Task[], opts: RunOptions): Promise<RunRe
263
301
 
264
302
  await Promise.all(inFlight.values());
265
303
  checkpoint();
304
+ if (failure) throw failure;
266
305
  return { outcomes: record, totalCost: round2(running), halted, haltReason };
267
306
  }
268
307
 
package/src/pm.ts CHANGED
@@ -31,7 +31,7 @@ const TaskSchema = Type.Object(
31
31
  {
32
32
  id: Type.String({ description: "Unique task id, e.g. T-01" }),
33
33
  title: Type.String({ description: "One concrete, buildable unit of work" }),
34
- capability: Type.String({ description: "one of: plan | design | code | test | ops" }),
34
+ capability: Type.String({ description: "one of: plan | design | code | review | test | ops" }),
35
35
  difficulty: Type.String({ description: "one of: trivial | low | medium | high" }),
36
36
  dependsOn: Type.Optional(Type.Array(Type.String(), { description: "task ids that must finish first" })),
37
37
  epic: Type.Optional(Type.String({ description: "optional grouping label" })),
@@ -41,7 +41,7 @@ const TaskSchema = Type.Object(
41
41
  );
42
42
  const BacklogSchema = Type.Object({ tasks: Type.Array(TaskSchema) }, { additionalProperties: true });
43
43
 
44
- const CAPS = new Set<Capability>(["plan", "design", "code", "test", "ops"]);
44
+ const CAPS = new Set<Capability>(["plan", "design", "code", "review", "test", "ops"]);
45
45
  const DIFFS = new Set<Difficulty>(["trivial", "low", "medium", "high"]);
46
46
  function coerceCap(s: string): Capability {
47
47
  const v = s?.toLowerCase().trim() as Capability;
@@ -78,9 +78,10 @@ export function pmSystemPrompt(scope: Scope = "full"): string {
78
78
  scope === "change"
79
79
  ? [
80
80
  "This is a CHANGE to an EXISTING project whose files are already on disk.",
81
- "Produce the FEWEST tasks that accomplish the change — usually 1 code task,",
82
- "plus 1 test task only if the change is risky. Do NOT re-plan the whole project,",
83
- "do NOT add design/setup/deploy tasks. One small tweak = one task.",
81
+ "Produce the FEWEST tasks that accomplish the change — usually 1 code task plus 1",
82
+ "`review` task that dependsOn it (cheap read-only wiring check), plus 1 test task",
83
+ "(dependsOn the review) only if the change is risky. Do NOT re-plan the whole project,",
84
+ "do NOT add design/setup/deploy tasks. One small tweak = code + review.",
84
85
  ]
85
86
  : [
86
87
  "Scale the number of tasks to the request. A tiny page = a few tasks; a full app = many.",
@@ -92,6 +93,9 @@ export function pmSystemPrompt(scope: Scope = "full"): string {
92
93
  "src/components/Header.jsx') and keep file names CONSISTENT across tasks — decide one",
93
94
  "structure and reuse it. When several files must agree, add ONE early design task that",
94
95
  "defines the file tree, and have the code tasks depend on it.",
96
+ "After EVERY code task add one `review` task that dependsOn that code task (a cheap",
97
+ "read-only wiring check). The test task must dependsOn the review task(s), not the code",
98
+ "task(s) directly. Order: design -> code -> review -> test.",
95
99
  ];
96
100
  return [
97
101
  "You are the PROJECT MANAGER on an autonomous software team.",
@@ -100,7 +104,7 @@ export function pmSystemPrompt(scope: Scope = "full"): string {
100
104
  "",
101
105
  "Each TASK must be:",
102
106
  "- atomic: one model can complete it in one focused turn",
103
- "- tagged with a capability: plan | design | code | test | ops",
107
+ "- tagged with a capability: plan | design | code | review | test | ops",
104
108
  "- tagged with a difficulty: trivial | low | medium | high (how hard the thinking is)",
105
109
  "Optional per task: dependsOn (ids that must finish first, e.g. code depends on design),",
106
110
  "and epic/story labels for grouping. Use ids like T-01, unique across the list.",
package/src/preview.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // relative paths all resolve the way they will in production.
11
11
 
12
12
  import { createServer, type Server } from "node:http";
13
- import { readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
14
  import { extname, join, normalize } from "node:path";
15
15
  import { pathToFileURL } from "node:url";
16
16
 
@@ -145,6 +145,20 @@ async function renderOne(
145
145
  }
146
146
  }
147
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
+
148
162
  /** Load a built page in headless Chromium and report what actually happened —
149
163
  * over http (production-like) AND over file:// (how a user double-clicks it). */
150
164
  export async function renderCheck(
package/src/registry.ts CHANGED
@@ -66,6 +66,18 @@ export const REGISTRY: RegistryEntry[] = [
66
66
  updated: "2026-07-15",
67
67
  },
68
68
 
69
+ // --- REVIEW (read-only wiring check before the tester; one row -> every difficulty is cheap) ---
70
+ {
71
+ capability: "review",
72
+ tier: "fast",
73
+ byBackend: {
74
+ web: { provider: "google", model: "gemini-3.1-pro-preview" },
75
+ api: { provider: "google", model: "gemini-3-flash-preview" },
76
+ },
77
+ evidence: "Read-only static check; cheapest tier is adequate, same picks as test",
78
+ updated: "2026-09-15",
79
+ },
80
+
69
81
  // --- TEST (QA / review, high volume -> cheap) ---
70
82
  {
71
83
  capability: "test",
package/src/retro.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // build-state: what passed, what the tester flagged, cost per epic and per
3
3
  // model, retries, and the priciest tasks. No model call.
4
4
 
5
- import type { BuildState } from "./build-state.js";
5
+ import { completedIds, type BuildState } from "./build-state.js";
6
6
  import type { Bug, Difficulty } from "./types.js";
7
7
  import { baselineTokens } from "./estimate.js";
8
8
  import { estimateCost } from "./cost.js";
@@ -30,7 +30,7 @@ export function computeRetro(state: BuildState): RetroReport {
30
30
  const epicById = new Map(state.tasks.map((t) => [t.id, t.epic || "General"]));
31
31
  const diffById = new Map(state.tasks.map((t) => [t.id, t.difficulty]));
32
32
  const outcomes = state.outcomes;
33
- const doneIds = new Set(outcomes.map((o) => o.taskId));
33
+ const doneIds = completedIds(state);
34
34
 
35
35
  // Baseline-predicted cost for each run: static token budget × the model that ran it.
36
36
  let estCost = 0;