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/README.md +18 -11
- 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/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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
290
|
-
:
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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] : ""
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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 =
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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 }))}
|
package/src/tui/BoardEditor.tsx
CHANGED
|
@@ -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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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" },
|