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 +17 -10
- package/package.json +1 -1
- package/src/build-state.ts +3 -2
- package/src/cost.ts +4 -6
- package/src/estimate.ts +7 -0
- package/src/orchestrator.ts +71 -32
- package/src/pm.ts +10 -6
- package/src/preview.ts +15 -1
- package/src/registry.ts +12 -0
- package/src/retro.ts +2 -2
- package/src/roles.ts +77 -29
- package/src/router.ts +2 -5
- package/src/run-build.ts +20 -7
- package/src/tui/App.tsx +31 -11
- package/src/tui/BoardEditor.tsx +14 -2
- package/src/tui/EditableBoard.tsx +25 -15
- package/src/tui/Kanban.tsx +5 -2
- package/src/tui/Settings.tsx +34 -8
- package/src/tui/components.tsx +5 -3
- package/src/tui/config.ts +17 -2
- package/src/tui/engine.ts +12 -7
- package/src/tui/panels.tsx +1 -1
- package/src/types.ts +34 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://www.npmjs.com/package/projectinator)
|
|
8
8
|

|
|
9
9
|

|
|
10
|
-

|
|
11
11
|

|
|
12
12
|

|
|
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.
|
|
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 →
|
|
68
|
-
**runs the app headless and catches real
|
|
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
|
|
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
|
-
|
|
|
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
|
-
└─
|
|
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 #
|
|
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 %,
|
|
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.
|
|
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,
|
package/src/build-state.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 },
|
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
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(
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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
|
|
275
|
+
reserved += est.cost;
|
|
248
276
|
const cost = est.cost;
|
|
249
277
|
const isCode = task.capability === "code";
|
|
250
278
|
if (isCode) codeInFlight++;
|
|
251
|
-
const
|
|
279
|
+
const settle = () => {
|
|
252
280
|
if (isCode) codeInFlight--;
|
|
253
|
-
reserved
|
|
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
|
-
"
|
|
83
|
-
"
|
|
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
|
|
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 =
|
|
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;
|