projectinator 0.3.0 → 0.4.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 +30 -11
- package/bin/projectinator.mjs +56 -11
- package/dist/bakeoff.js +163 -0
- package/dist/bakeoff.js.map +1 -0
- package/dist/build-state.js +26 -0
- package/dist/build-state.js.map +1 -0
- package/dist/burndown.js +19 -0
- package/dist/burndown.js.map +1 -0
- package/dist/calibration.js +86 -0
- package/dist/calibration.js.map +1 -0
- package/dist/cli.js +289 -0
- package/dist/cli.js.map +1 -0
- package/dist/cost.js +35 -0
- package/dist/cost.js.map +1 -0
- package/dist/council.js +147 -0
- package/dist/council.js.map +1 -0
- package/dist/demo.js +93 -0
- package/dist/demo.js.map +1 -0
- package/dist/estimate.js +98 -0
- package/dist/estimate.js.map +1 -0
- package/dist/executor.js +118 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.js +82 -0
- package/dist/git.js.map +1 -0
- package/dist/intake.js +98 -0
- package/dist/intake.js.map +1 -0
- package/dist/models.js +171 -0
- package/dist/models.js.map +1 -0
- package/dist/narrate.js +76 -0
- package/dist/narrate.js.map +1 -0
- package/dist/openrouter.js +112 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/orchestrator.js +273 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/pm.js +245 -0
- package/dist/pm.js.map +1 -0
- package/dist/preview.js +194 -0
- package/dist/preview.js.map +1 -0
- package/dist/registry-store.js +38 -0
- package/dist/registry-store.js.map +1 -0
- package/dist/registry.js +115 -0
- package/dist/registry.js.map +1 -0
- package/dist/research.js +98 -0
- package/dist/research.js.map +1 -0
- package/dist/retro.js +83 -0
- package/dist/retro.js.map +1 -0
- package/dist/roles.js +357 -0
- package/dist/roles.js.map +1 -0
- package/dist/router.js +95 -0
- package/dist/router.js.map +1 -0
- package/dist/run-bakeoff.js +68 -0
- package/dist/run-bakeoff.js.map +1 -0
- package/dist/run-build.js +193 -0
- package/dist/run-build.js.map +1 -0
- package/{src/run-dev.ts → dist/run-dev.js} +23 -39
- package/dist/run-dev.js.map +1 -0
- package/{src/run-pm.ts → dist/run-pm.js} +34 -46
- package/dist/run-pm.js.map +1 -0
- package/{src/run-research.ts → dist/run-research.js} +19 -30
- package/dist/run-research.js.map +1 -0
- package/{src/run-scout.ts → dist/run-scout.js} +17 -27
- package/dist/run-scout.js.map +1 -0
- package/dist/run-web.js +93 -0
- package/dist/run-web.js.map +1 -0
- package/dist/scout.js +81 -0
- package/dist/scout.js.map +1 -0
- package/dist/session-cost.js +15 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/stack.js +34 -0
- package/dist/stack.js.map +1 -0
- package/dist/stuck.js +21 -0
- package/dist/stuck.js.map +1 -0
- package/dist/tui/App.js +1269 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/BakeOff.js +95 -0
- package/dist/tui/BakeOff.js.map +1 -0
- package/dist/tui/BoardEditor.js +183 -0
- package/dist/tui/BoardEditor.js.map +1 -0
- package/dist/tui/EditableBoard.js +140 -0
- package/dist/tui/EditableBoard.js.map +1 -0
- package/dist/tui/Frame.js +78 -0
- package/dist/tui/Frame.js.map +1 -0
- package/dist/tui/Intake.js +59 -0
- package/dist/tui/Intake.js.map +1 -0
- package/dist/tui/Kanban.js +75 -0
- package/dist/tui/Kanban.js.map +1 -0
- package/dist/tui/Settings.js +299 -0
- package/dist/tui/Settings.js.map +1 -0
- package/dist/tui/StackPick.js +44 -0
- package/dist/tui/StackPick.js.map +1 -0
- package/dist/tui/WebAccounts.js +131 -0
- package/dist/tui/WebAccounts.js.map +1 -0
- package/dist/tui/components.js +178 -0
- package/dist/tui/components.js.map +1 -0
- package/dist/tui/config.js +129 -0
- package/dist/tui/config.js.map +1 -0
- package/dist/tui/deploy.js +114 -0
- package/dist/tui/deploy.js.map +1 -0
- package/dist/tui/engine.js +710 -0
- package/dist/tui/engine.js.map +1 -0
- package/dist/tui/notify.js +47 -0
- package/dist/tui/notify.js.map +1 -0
- package/dist/tui/panels.js +42 -0
- package/dist/tui/panels.js.map +1 -0
- package/dist/tui/templates.js +106 -0
- package/dist/tui/templates.js.map +1 -0
- package/dist/tui/theme.js +42 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/validate.js +51 -0
- package/dist/tui/validate.js.map +1 -0
- package/{src/tui.tsx → dist/tui.js} +23 -33
- package/dist/tui.js.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/web/oauth-anthropic.js +179 -0
- package/dist/web/oauth-anthropic.js.map +1 -0
- package/dist/web/session.js +274 -0
- package/dist/web/session.js.map +1 -0
- package/package.json +5 -4
- package/src/bakeoff.ts +0 -214
- package/src/build-state.ts +0 -47
- package/src/burndown.ts +0 -35
- package/src/calibration.ts +0 -88
- package/src/cost.ts +0 -43
- package/src/council.ts +0 -175
- package/src/demo.ts +0 -106
- package/src/estimate.ts +0 -111
- package/src/executor.ts +0 -169
- package/src/git.ts +0 -72
- package/src/intake.ts +0 -125
- package/src/models.ts +0 -175
- package/src/narrate.ts +0 -87
- package/src/openrouter.ts +0 -119
- package/src/orchestrator.ts +0 -310
- package/src/pm.ts +0 -302
- package/src/preview.ts +0 -206
- package/src/registry-store.ts +0 -41
- package/src/registry.ts +0 -132
- package/src/research.ts +0 -123
- package/src/retro.ts +0 -99
- package/src/roles.ts +0 -401
- package/src/router.ts +0 -120
- package/src/run-bakeoff.ts +0 -77
- package/src/run-build.ts +0 -203
- package/src/run-web.ts +0 -87
- package/src/scout.ts +0 -121
- package/src/session-cost.ts +0 -17
- package/src/stack.ts +0 -46
- package/src/tui/App.tsx +0 -1760
- package/src/tui/BakeOff.tsx +0 -190
- package/src/tui/BoardEditor.tsx +0 -260
- package/src/tui/EditableBoard.tsx +0 -179
- package/src/tui/Frame.tsx +0 -142
- package/src/tui/Intake.tsx +0 -111
- package/src/tui/Kanban.tsx +0 -158
- package/src/tui/Settings.tsx +0 -513
- package/src/tui/StackPick.tsx +0 -79
- package/src/tui/WebAccounts.tsx +0 -197
- package/src/tui/components.tsx +0 -340
- package/src/tui/config.ts +0 -150
- package/src/tui/deploy.ts +0 -137
- package/src/tui/engine.ts +0 -749
- package/src/tui/notify.ts +0 -21
- package/src/tui/panels.tsx +0 -89
- package/src/tui/templates.ts +0 -119
- package/src/tui/theme.ts +0 -44
- package/src/tui/validate.ts +0 -53
- package/src/types.ts +0 -208
- package/src/web/oauth-anthropic.ts +0 -206
- package/src/web/session.ts +0 -299
package/src/research.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
// Auto-feed the Scout — turn a research report into structured findings.
|
|
2
|
-
//
|
|
3
|
-
// The deep-research harness (or any benchmark write-up) produces prose. This module
|
|
4
|
-
// extracts it into clean Finding[] the Scout can consume: a model reads the report and
|
|
5
|
-
// calls a forced typebox tool. That's reliable because it's reformatting text we give
|
|
6
|
-
// it, not recalling facts from memory.
|
|
7
|
-
//
|
|
8
|
-
// Flow: research report (text) -> extractFindings() -> findings.json -> scout --from
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
createAgentSession,
|
|
12
|
-
defineTool,
|
|
13
|
-
type AgentSession,
|
|
14
|
-
} from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { Type, type Static } from "typebox";
|
|
16
|
-
import type { Provider } from "./types.js";
|
|
17
|
-
import type { Finding } from "./scout.js";
|
|
18
|
-
import { piRuntime, resolvePiModel } from "./executor.js"
|
|
19
|
-
import { MODELS } from "./models.js";
|
|
20
|
-
|
|
21
|
-
const FindingsSchema = Type.Object({
|
|
22
|
-
findings: Type.Array(
|
|
23
|
-
Type.Object({
|
|
24
|
-
capability: Type.Union([
|
|
25
|
-
Type.Literal("plan"), Type.Literal("design"), Type.Literal("code"),
|
|
26
|
-
Type.Literal("test"), Type.Literal("ops"),
|
|
27
|
-
]),
|
|
28
|
-
tier: Type.Union([Type.Literal("fast"), Type.Literal("mid"), Type.Literal("high")]),
|
|
29
|
-
backend: Type.Union([Type.Literal("web"), Type.Literal("api")]),
|
|
30
|
-
provider: Type.Union([Type.Literal("anthropic"), Type.Literal("openai"), Type.Literal("google")]),
|
|
31
|
-
model: Type.String({ description: "exact model id, e.g. claude-opus-4-8" }),
|
|
32
|
-
evidence: Type.String({ description: "one-line benchmark/source justification" }),
|
|
33
|
-
}),
|
|
34
|
-
),
|
|
35
|
-
});
|
|
36
|
-
type FindingsRaw = Static<typeof FindingsSchema>;
|
|
37
|
-
|
|
38
|
-
export interface ValidationIssue {
|
|
39
|
-
index: number;
|
|
40
|
-
model: string;
|
|
41
|
-
problem: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Pure check: does each finding reference a real model with a matching provider? */
|
|
45
|
-
export function validateFindings(findings: Finding[]): { ok: boolean; issues: ValidationIssue[] } {
|
|
46
|
-
const issues: ValidationIssue[] = [];
|
|
47
|
-
findings.forEach((f, index) => {
|
|
48
|
-
const m = MODELS[f.model];
|
|
49
|
-
if (!m) {
|
|
50
|
-
issues.push({ index, model: f.model, problem: "model not in models.ts" });
|
|
51
|
-
} else if (m.provider !== f.provider) {
|
|
52
|
-
issues.push({ index, model: f.model, problem: `provider mismatch (models.ts says ${m.provider})` });
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
return { ok: issues.length === 0, issues };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function buildFindingsTool() {
|
|
59
|
-
let captured: Finding[] | undefined;
|
|
60
|
-
const tool = defineTool({
|
|
61
|
-
name: "submit_findings",
|
|
62
|
-
label: "Submit Findings",
|
|
63
|
-
description: "Submit the extracted model-per-role findings.",
|
|
64
|
-
parameters: FindingsSchema,
|
|
65
|
-
execute: async (_id, params: FindingsRaw) => {
|
|
66
|
-
captured = params.findings as Finding[];
|
|
67
|
-
return { content: [{ type: "text", text: `Extracted ${params.findings.length} findings.` }], details: {} };
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
return { tool, get: () => captured };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function extractionPrompt(report: string): string {
|
|
74
|
-
return [
|
|
75
|
-
"You are a data extractor. From the research report below, extract the single best",
|
|
76
|
-
"model for each role the report covers, as structured findings.",
|
|
77
|
-
"",
|
|
78
|
-
"For each finding set: capability (plan|design|code|test|ops), tier (fast|mid|high),",
|
|
79
|
-
"backend (usually 'api' for benchmark-driven picks), provider, the EXACT model id,",
|
|
80
|
-
"and a one-line evidence note. Only include roles the report actually supports.",
|
|
81
|
-
"Do not invent models. When done, call submit_findings once.",
|
|
82
|
-
"",
|
|
83
|
-
"--- REPORT ---",
|
|
84
|
-
report,
|
|
85
|
-
].join("\n");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface ExtractOptions {
|
|
89
|
-
model: { provider: Provider; model: string };
|
|
90
|
-
onEvent?: Parameters<AgentSession["subscribe"]>[0];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Extract findings from a report via a model. Spends money (one model call). */
|
|
94
|
-
export async function extractFindings(report: string, opts: ExtractOptions): Promise<Finding[]> {
|
|
95
|
-
const runtime = await piRuntime();
|
|
96
|
-
const model = resolvePiModel(runtime, opts.model.provider, opts.model.model);
|
|
97
|
-
|
|
98
|
-
const { tool, get } = buildFindingsTool();
|
|
99
|
-
const { session } = await createAgentSession({
|
|
100
|
-
model, modelRuntime: runtime,
|
|
101
|
-
thinkingLevel: "low",
|
|
102
|
-
noTools: "all",
|
|
103
|
-
customTools: [tool],
|
|
104
|
-
tools: ["submit_findings"],
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
|
|
108
|
-
try {
|
|
109
|
-
await session.prompt(extractionPrompt(report));
|
|
110
|
-
let out = get();
|
|
111
|
-
if (!out) {
|
|
112
|
-
await session.followUp("Call submit_findings now.");
|
|
113
|
-
out = get();
|
|
114
|
-
}
|
|
115
|
-
if (!out) throw new Error("Extractor did not call submit_findings.");
|
|
116
|
-
return out;
|
|
117
|
-
} finally {
|
|
118
|
-
unsub?.();
|
|
119
|
-
session.dispose();
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export { FindingsSchema, buildFindingsTool };
|
package/src/retro.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
// Build retro — a free, data-driven summary of a finished build, computed from
|
|
2
|
-
// build-state: what passed, what the tester flagged, cost per epic and per
|
|
3
|
-
// model, retries, and the priciest tasks. No model call.
|
|
4
|
-
|
|
5
|
-
import { completedIds, type BuildState } from "./build-state.js";
|
|
6
|
-
import type { Bug, Difficulty } from "./types.js";
|
|
7
|
-
import { baselineTokens } from "./estimate.js";
|
|
8
|
-
import { estimateCost } from "./cost.js";
|
|
9
|
-
import { getModel } from "./models.js";
|
|
10
|
-
|
|
11
|
-
export interface RetroReport {
|
|
12
|
-
idea: string;
|
|
13
|
-
status: BuildState["status"];
|
|
14
|
-
totalCost: number;
|
|
15
|
-
estCost: number; // baseline predicted cost for the tasks that ran
|
|
16
|
-
taskCount: number;
|
|
17
|
-
doneCount: number;
|
|
18
|
-
tests: { passed: number; failed: number };
|
|
19
|
-
bugs: Bug[]; // everything the tester flagged during the build
|
|
20
|
-
retries: { taskId: string; title: string; rounds: number }[];
|
|
21
|
-
byEpic: { epic: string; cost: number; tasks: number }[];
|
|
22
|
-
byModel: { model: string; cost: number; tasks: number }[];
|
|
23
|
-
topCost: { taskId: string; title: string; cost: number }[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
27
|
-
|
|
28
|
-
export function computeRetro(state: BuildState): RetroReport {
|
|
29
|
-
const titleById = new Map(state.tasks.map((t) => [t.id, t.title]));
|
|
30
|
-
const epicById = new Map(state.tasks.map((t) => [t.id, t.epic || "General"]));
|
|
31
|
-
const diffById = new Map(state.tasks.map((t) => [t.id, t.difficulty]));
|
|
32
|
-
const outcomes = state.outcomes;
|
|
33
|
-
const doneIds = completedIds(state);
|
|
34
|
-
|
|
35
|
-
// Baseline-predicted cost for each run: static token budget × the model that ran it.
|
|
36
|
-
let estCost = 0;
|
|
37
|
-
for (const o of outcomes) {
|
|
38
|
-
const diff = (diffById.get(o.taskId) ?? "medium") as Difficulty;
|
|
39
|
-
try {
|
|
40
|
-
const model = getModel(o.modelId);
|
|
41
|
-
const tokens = baselineTokens(o.capability, diff);
|
|
42
|
-
estCost += estimateCost({ input: tokens.input, output: tokens.output, cachedInputFraction: 0.55 }, model);
|
|
43
|
-
} catch {
|
|
44
|
-
/* unknown model — skip its estimate */
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Tests: judge each test task by its LAST outcome (final state after retries).
|
|
49
|
-
const lastTestByTask = new Map<string, (typeof outcomes)[number]>();
|
|
50
|
-
for (const o of outcomes) if (o.capability === "test") lastTestByTask.set(o.taskId, o);
|
|
51
|
-
let passed = 0;
|
|
52
|
-
let failed = 0;
|
|
53
|
-
for (const o of lastTestByTask.values()) {
|
|
54
|
-
if (o.verdict?.passed) passed++;
|
|
55
|
-
else if (o.verdict) failed++;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Bugs the tester flagged anywhere during the build (signal, even if later fixed).
|
|
59
|
-
const bugs: Bug[] = [];
|
|
60
|
-
for (const o of outcomes) if (o.verdict?.bugs) bugs.push(...o.verdict.bugs);
|
|
61
|
-
|
|
62
|
-
// Retries: any outcome past round 0 means a Tester→Developer rebuild happened.
|
|
63
|
-
const roundsByTask = new Map<string, number>();
|
|
64
|
-
for (const o of outcomes) if (o.round > 0) roundsByTask.set(o.taskId, Math.max(roundsByTask.get(o.taskId) ?? 0, o.round));
|
|
65
|
-
const retries = [...roundsByTask.entries()].map(([taskId, rounds]) => ({ taskId, title: titleById.get(taskId) ?? taskId, rounds }));
|
|
66
|
-
|
|
67
|
-
// Cost per epic + per model.
|
|
68
|
-
const epicCost = new Map<string, { cost: number; tasks: number }>();
|
|
69
|
-
const modelCost = new Map<string, { cost: number; tasks: number }>();
|
|
70
|
-
for (const o of outcomes) {
|
|
71
|
-
const e = epicById.get(o.taskId) ?? "General";
|
|
72
|
-
const ec = epicCost.get(e) ?? { cost: 0, tasks: 0 };
|
|
73
|
-
epicCost.set(e, { cost: ec.cost + o.cost, tasks: ec.tasks + 1 });
|
|
74
|
-
const mc = modelCost.get(o.modelId) ?? { cost: 0, tasks: 0 };
|
|
75
|
-
modelCost.set(o.modelId, { cost: mc.cost + o.cost, tasks: mc.tasks + 1 });
|
|
76
|
-
}
|
|
77
|
-
const byEpic = [...epicCost.entries()].map(([epic, v]) => ({ epic, cost: round2(v.cost), tasks: v.tasks })).sort((a, b) => b.cost - a.cost);
|
|
78
|
-
const byModel = [...modelCost.entries()].map(([model, v]) => ({ model, cost: round2(v.cost), tasks: v.tasks })).sort((a, b) => b.cost - a.cost);
|
|
79
|
-
|
|
80
|
-
const topCost = [...outcomes]
|
|
81
|
-
.sort((a, b) => b.cost - a.cost)
|
|
82
|
-
.slice(0, 3)
|
|
83
|
-
.map((o) => ({ taskId: o.taskId, title: titleById.get(o.taskId) ?? o.taskId, cost: round2(o.cost) }));
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
idea: state.idea ?? state.id,
|
|
87
|
-
status: state.status,
|
|
88
|
-
totalCost: round2(state.totalCost),
|
|
89
|
-
estCost: round2(estCost),
|
|
90
|
-
taskCount: state.tasks.length,
|
|
91
|
-
doneCount: doneIds.size,
|
|
92
|
-
tests: { passed, failed },
|
|
93
|
-
bugs,
|
|
94
|
-
retries,
|
|
95
|
-
byEpic,
|
|
96
|
-
byModel,
|
|
97
|
-
topCost,
|
|
98
|
-
};
|
|
99
|
-
}
|
package/src/roles.ts
DELETED
|
@@ -1,401 +0,0 @@
|
|
|
1
|
-
// Phase 4 — role definitions + the real Pi-backed executor.
|
|
2
|
-
//
|
|
3
|
-
// Each capability becomes a role with its own prompt and tool set. The Tester uses
|
|
4
|
-
// a forced typebox verdict tool (like the PM) so its pass/fail is structured, which
|
|
5
|
-
// the orchestrator's feedback loop depends on.
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
createAgentSession,
|
|
9
|
-
defineTool,
|
|
10
|
-
type AgentSession,
|
|
11
|
-
} from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { Type, type Static } from "typebox";
|
|
13
|
-
import {
|
|
14
|
-
TaskLimitError,
|
|
15
|
-
type Backend,
|
|
16
|
-
type Capability,
|
|
17
|
-
type Provider,
|
|
18
|
-
type RegistryEntry,
|
|
19
|
-
type RoleExecutor,
|
|
20
|
-
type RoleResult,
|
|
21
|
-
type Task,
|
|
22
|
-
type TaskLimits,
|
|
23
|
-
type Verdict,
|
|
24
|
-
} from "./types.js";
|
|
25
|
-
import { piRuntime, resolvePiModel } from "./executor.js"
|
|
26
|
-
import { renderCheck, chromiumAvailable, CHROMIUM_INSTALL_HINT } from "./preview.js";
|
|
27
|
-
import { estimateCost } from "./cost.js";
|
|
28
|
-
import { getModel } from "./models.js";
|
|
29
|
-
import { addSessionCost } from "./session-cost.js";
|
|
30
|
-
import { recordActual } from "./calibration.js";
|
|
31
|
-
import { readdirSync, statSync } from "node:fs";
|
|
32
|
-
import { join, relative } from "node:path";
|
|
33
|
-
|
|
34
|
-
// ---- role prompts ----
|
|
35
|
-
|
|
36
|
-
const ROLE_INTRO: Record<Capability, string> = {
|
|
37
|
-
plan: "You are the PLANNER. Produce a concise plan or decision for this task as text.",
|
|
38
|
-
design:
|
|
39
|
-
"You are the DESIGNER. Produce a clear, concrete design spec (layout, components, colours, states) as text. " +
|
|
40
|
-
"If the product spans multiple files, also specify the intended FILE STRUCTURE — name each file and say what it holds (e.g. index.html, styles.css, app.js, or a src/ tree). Do not write code files. " +
|
|
41
|
-
"For a plain static site with no build step, DO NOT spec ES modules with relative imports (`<script type=\"module\">` + `import './x.js'`): browsers block those when the user double-clicks the file (file://), so the app looks dead. Prefer one classic `<script src>` (or a few, loaded in order) so it runs on double-click.",
|
|
42
|
-
code:
|
|
43
|
-
"You are the DEVELOPER. Write real, working files into the working directory — minimal, correct, no placeholders, no TODO stubs. " +
|
|
44
|
-
"This is often a MULTI-FILE project: FIRST inspect what already exists (use ls, then read the relevant files) and BUILD ON it — " +
|
|
45
|
-
"reuse and extend existing files, follow the file structure the design spec defines, and make sure files reference each other with correct paths " +
|
|
46
|
-
"(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
|
-
"MUST-RUN-ON-DOUBLE-CLICK: for a plain static site with no bundler/build step, the app has to work when the user just opens index.html as a file (file://). Do NOT use `<script type=\"module\">` with relative `import`s, and do not `fetch()` local files — browsers block both on file://, leaving a blank page. Split code with several plain `<script src>` tags in dependency order (globals), not ES modules. If the app genuinely needs a server (a real backend, bundler, or framework), write a short README.md with the exact run command.",
|
|
48
|
-
review:
|
|
49
|
-
"You are the REVIEWER. Do NOT edit files and do NOT run the app. Read the task, the design context, and the files in the working directory. " +
|
|
50
|
-
"Check: every file the design named exists; every <script src> / <link href> / import resolves to a real file; nothing is referenced but never defined " +
|
|
51
|
-
"(functions, element ids, CSS classes the JS relies on); a plain static site uses no ES modules or fetch() of local files (both break on double-click / file://); " +
|
|
52
|
-
"and the task's stated deliverable is actually present. Report only real defects a developer must fix — not style. Then call submit_verdict exactly once.",
|
|
53
|
-
test: "You are the TESTER. For a web app, FIRST call check_app to actually run it in a headless browser — it reports how the app renders BOTH served over http AND opened directly as a file (double-click / file://). Confirm it renders, shows the expected content, and has no JavaScript/console errors. The app MUST also work on double-click (file://) UNLESS a README documents how to run it — if check_app says double-click is BROKEN and there is no README with a run command, that is a HIGH-severity bug (report it, describe the file:// failure). Then inspect the files against the task and check multi-file wiring (referenced files exist, paths/imports resolve). Then call submit_verdict with pass/fail and any bugs. A blank render or a JS error is a high-severity bug. Do not fix anything yourself.",
|
|
54
|
-
ops: "You are OPS. Perform the operational task (build, config, deploy prep) using your tools. Report what you did as text.",
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export function buildRolePrompt(task: Task, contextText: string): string {
|
|
58
|
-
const lines = [
|
|
59
|
-
ROLE_INTRO[task.capability],
|
|
60
|
-
"",
|
|
61
|
-
`Task ${task.id}: ${task.title}`,
|
|
62
|
-
contextText ? `\n${contextText}` : "",
|
|
63
|
-
"",
|
|
64
|
-
task.capability === "test" || task.capability === "review"
|
|
65
|
-
? "When finished, call submit_verdict exactly once."
|
|
66
|
-
: "Complete the task, then stop. Do not explain at length.",
|
|
67
|
-
];
|
|
68
|
-
return lines.filter((l) => l !== "").join("\n");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---- tester verdict tool (forced structured output) ----
|
|
72
|
-
|
|
73
|
-
const VerdictSchema = Type.Object({
|
|
74
|
-
passed: Type.Boolean({ description: "true if the build satisfies the task with no serious bugs" }),
|
|
75
|
-
bugs: Type.Array(
|
|
76
|
-
Type.Object({
|
|
77
|
-
severity: Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]),
|
|
78
|
-
description: Type.String(),
|
|
79
|
-
file: Type.Optional(Type.String()),
|
|
80
|
-
}),
|
|
81
|
-
{ description: "empty if passed" },
|
|
82
|
-
),
|
|
83
|
-
});
|
|
84
|
-
type VerdictRaw = Static<typeof VerdictSchema>;
|
|
85
|
-
|
|
86
|
-
// ---- tester "run the app" tool: headless render + error capture ----
|
|
87
|
-
|
|
88
|
-
/** check_app + a flag telling whether a render actually happened this task. */
|
|
89
|
-
function buildCheckTool(workspace: string, chromium: boolean) {
|
|
90
|
-
let rendered = false;
|
|
91
|
-
const tool = defineTool({
|
|
92
|
-
name: "check_app",
|
|
93
|
-
label: "Run the app",
|
|
94
|
-
description:
|
|
95
|
-
"Render a built web page in a headless browser and report its title, the visible text, " +
|
|
96
|
-
"and any JavaScript/console errors or failed asset requests. Use this on web apps to confirm " +
|
|
97
|
-
"the app actually RUNS and renders before you judge it — do not rely on reading the code alone.",
|
|
98
|
-
parameters: Type.Object(
|
|
99
|
-
{ file: Type.Optional(Type.String({ description: "HTML entry file to load; default index.html" })) },
|
|
100
|
-
{ additionalProperties: true },
|
|
101
|
-
),
|
|
102
|
-
execute: async (_id, params: { file?: string }) => {
|
|
103
|
-
if (!chromium) {
|
|
104
|
-
return {
|
|
105
|
-
content: [{ type: "text", text: `check_app is UNAVAILABLE: headless Chromium is not installed (${CHROMIUM_INSTALL_HINT}). You cannot run the app. Review the files by reading them, say so in your verdict, and do not claim the app was executed.` }],
|
|
106
|
-
details: {},
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
try {
|
|
110
|
-
const r = await renderCheck(workspace, params.file || "index.html");
|
|
111
|
-
rendered = true;
|
|
112
|
-
const doubleClick = r.doubleClickBroken
|
|
113
|
-
? "BROKEN — renders behind a server but is blank/erroring when opened directly as a file (double-click). "
|
|
114
|
-
+ "Most likely ES modules + relative imports (or fetch of local files), which browsers block on file://. "
|
|
115
|
-
+ "This is a real defect for a user who just opens the folder. Fix: use a classic non-module <script>, "
|
|
116
|
-
+ "or ship a README with a run command (e.g. `python3 -m http.server`)."
|
|
117
|
-
: r.fileOk
|
|
118
|
-
? "OK (works on double-click too)"
|
|
119
|
-
: `over file://: ${r.fileErrors.length ? r.fileErrors.join("; ") : "(empty page)"}`;
|
|
120
|
-
const text = [
|
|
121
|
-
`rendered (served over http): ${r.ok ? "OK (no JS errors)" : "with errors"}`,
|
|
122
|
-
`title: ${r.title || "(none)"}`,
|
|
123
|
-
`errors: ${r.errors.length ? "\n - " + r.errors.join("\n - ") : "none"}`,
|
|
124
|
-
`opened as a file (double-click / file://): ${doubleClick}`,
|
|
125
|
-
`visible text:\n${r.text || "(empty page — nothing rendered)"}`,
|
|
126
|
-
].join("\n");
|
|
127
|
-
return { content: [{ type: "text", text }], details: {} };
|
|
128
|
-
} catch (e) {
|
|
129
|
-
return {
|
|
130
|
-
content: [{ type: "text", text: `check_app could not run (${e instanceof Error ? e.message : e}). If this isn't a web app with an HTML page, inspect the files directly instead.` }],
|
|
131
|
-
details: {},
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
},
|
|
135
|
-
});
|
|
136
|
-
return { tool, rendered: () => rendered };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function buildVerdictTool(runtimeChecked: () => boolean) {
|
|
140
|
-
let captured: Verdict | undefined;
|
|
141
|
-
const tool = defineTool({
|
|
142
|
-
name: "submit_verdict",
|
|
143
|
-
label: "Submit Verdict",
|
|
144
|
-
description: "Submit your pass/fail judgement and any bugs found.",
|
|
145
|
-
parameters: VerdictSchema,
|
|
146
|
-
execute: async (_id, params: VerdictRaw) => {
|
|
147
|
-
captured = { passed: params.passed, bugs: params.bugs, runtimeChecked: runtimeChecked() };
|
|
148
|
-
return {
|
|
149
|
-
content: [{ type: "text", text: `Verdict: ${params.passed ? "PASS" : "FAIL"} (${params.bugs.length} bugs)` }],
|
|
150
|
-
details: {},
|
|
151
|
-
};
|
|
152
|
-
},
|
|
153
|
-
});
|
|
154
|
-
return { tool, get: () => captured };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ---- provider lock: run the whole pipeline on one provider ----
|
|
158
|
-
// Useful when you hold a key for only one provider. Maps each capability+tier to
|
|
159
|
-
// that provider's sensible model, so route() resolves everything to it.
|
|
160
|
-
|
|
161
|
-
const PROVIDER_MODELS: Record<Provider, { strong: string; mid: string; cheap: string }> = {
|
|
162
|
-
anthropic: { strong: "claude-opus-5", mid: "claude-sonnet-5", cheap: "claude-haiku-4-5" },
|
|
163
|
-
openai: { strong: "gpt-5.6-sol", mid: "gpt-5.6-terra", cheap: "gpt-5.6-luna" },
|
|
164
|
-
google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3.8-flash" },
|
|
165
|
-
openrouter: { strong: "anthropic/claude-opus-5", mid: "anthropic/claude-sonnet-5", cheap: "google/gemini-3.8-flash" },
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
|
|
169
|
-
plan: "mid",
|
|
170
|
-
design: "strong",
|
|
171
|
-
code: "strong",
|
|
172
|
-
review: "cheap",
|
|
173
|
-
test: "cheap",
|
|
174
|
-
ops: "strong",
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
/** A registry where every capability routes to one provider's models. */
|
|
178
|
-
export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
|
|
179
|
-
const m = PROVIDER_MODELS[provider];
|
|
180
|
-
const caps: Capability[] = ["plan", "design", "code", "review", "test", "ops"];
|
|
181
|
-
const tiers = ["fast", "mid", "high"] as const;
|
|
182
|
-
const out: RegistryEntry[] = [];
|
|
183
|
-
for (const capability of caps) {
|
|
184
|
-
const modelId = m[CAP_STRENGTH[capability]];
|
|
185
|
-
for (const tier of tiers) {
|
|
186
|
-
out.push({
|
|
187
|
-
capability,
|
|
188
|
-
tier,
|
|
189
|
-
byBackend: { web: { provider, model: modelId }, api: { provider, model: modelId } },
|
|
190
|
-
updated: "provider-lock",
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return out;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ---- the real Pi executor ----
|
|
198
|
-
|
|
199
|
-
export interface PiExecutorOptions {
|
|
200
|
-
workspace: string;
|
|
201
|
-
backend: Backend;
|
|
202
|
-
thinkingLevel?: "off" | "low" | "medium" | "high";
|
|
203
|
-
onEvent?: Parameters<AgentSession["subscribe"]>[0];
|
|
204
|
-
/** Called when a task falls back from its routed provider to another one. */
|
|
205
|
-
onFallback?: (info: { taskId: string; from: Provider; to: Provider; model: string }) => void;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Env vars that hold each provider's key (mirrors run-build's check).
|
|
209
|
-
const ENV_KEYS: Record<Provider, string[]> = {
|
|
210
|
-
anthropic: ["ANTHROPIC_API_KEY"],
|
|
211
|
-
openai: ["OPENAI_API_KEY"],
|
|
212
|
-
google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
|
213
|
-
openrouter: ["OPENROUTER_API_KEY"],
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
function providersWithKeys(): Provider[] {
|
|
217
|
-
return (Object.keys(ENV_KEYS) as Provider[]).filter((p) => ENV_KEYS[p].some((k) => process.env[k]));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** The routed model first, then the same-strength model on every OTHER provider
|
|
221
|
-
* that has a key — so a 0-token / errored provider falls back automatically. */
|
|
222
|
-
function fallbackChain(primary: Provider, primaryModel: string, cap: Capability): { provider: Provider; model: string }[] {
|
|
223
|
-
const chain: { provider: Provider; model: string }[] = [{ provider: primary, model: primaryModel }];
|
|
224
|
-
for (const p of providersWithKeys()) {
|
|
225
|
-
if (p === primary) continue;
|
|
226
|
-
chain.push({ provider: p, model: PROVIDER_MODELS[p][CAP_STRENGTH[cap]] });
|
|
227
|
-
}
|
|
228
|
-
return chain;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Extract the last assistant text from a session, tolerant of content shape. */
|
|
232
|
-
function lastAssistantText(session: AgentSession): string {
|
|
233
|
-
const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
|
|
234
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
235
|
-
const m = msgs[i];
|
|
236
|
-
if (m?.role !== "assistant") continue;
|
|
237
|
-
const c = m.content;
|
|
238
|
-
if (typeof c === "string") return c;
|
|
239
|
-
if (Array.isArray(c)) {
|
|
240
|
-
return c
|
|
241
|
-
.map((part: unknown) => {
|
|
242
|
-
if (typeof part === "string") return part;
|
|
243
|
-
if (part && typeof part === "object" && "text" in part) return String((part as { text: unknown }).text);
|
|
244
|
-
return "";
|
|
245
|
-
})
|
|
246
|
-
.join("")
|
|
247
|
-
.trim();
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return "";
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function listFiles(dir: string): string[] {
|
|
254
|
-
const out: string[] = [];
|
|
255
|
-
const skip = new Set([".pi", ".git", "node_modules"]);
|
|
256
|
-
const walk = (d: string) => {
|
|
257
|
-
let entries: string[];
|
|
258
|
-
try {
|
|
259
|
-
entries = readdirSync(d);
|
|
260
|
-
} catch {
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
for (const name of entries) {
|
|
264
|
-
if (skip.has(name)) continue;
|
|
265
|
-
const full = join(d, name);
|
|
266
|
-
// Tolerate broken symlinks / files removed mid-build; skip, don't abort the run.
|
|
267
|
-
let st;
|
|
268
|
-
try { st = statSync(full); } catch { continue; }
|
|
269
|
-
if (st.isDirectory()) walk(full);
|
|
270
|
-
else out.push(relative(dir, full));
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
walk(dir);
|
|
274
|
-
return out.sort();
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Build a real RoleExecutor backed by Pi. Each call spends money. Falls back to
|
|
278
|
-
* another key-holding provider when the routed one errors or returns 0 tokens. */
|
|
279
|
-
export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
|
|
280
|
-
// One attempt on a specific provider/model. Returns the result + total tokens
|
|
281
|
-
// (0 tokens = the provider call didn't really happen → treat as a failure).
|
|
282
|
-
const runOnce = async (
|
|
283
|
-
task: Task,
|
|
284
|
-
contextText: string,
|
|
285
|
-
provider: Provider,
|
|
286
|
-
modelId: string,
|
|
287
|
-
limits: TaskLimits,
|
|
288
|
-
): Promise<{ result: RoleResult; tokensTotal: number }> => {
|
|
289
|
-
const runtime = await piRuntime();
|
|
290
|
-
const model = resolvePiModel(runtime, provider, modelId);
|
|
291
|
-
|
|
292
|
-
const isTest = task.capability === "test";
|
|
293
|
-
const isReview = task.capability === "review";
|
|
294
|
-
const checkTool = isTest ? buildCheckTool(opts.workspace, await chromiumAvailable()) : undefined;
|
|
295
|
-
// A review never runs the app, so its verdict is never "runtime checked".
|
|
296
|
-
const verdictTool = isTest || isReview ? buildVerdictTool(checkTool ? checkTool.rendered : () => false) : undefined;
|
|
297
|
-
|
|
298
|
-
const { session } = await createAgentSession({
|
|
299
|
-
model,
|
|
300
|
-
cwd: opts.workspace,
|
|
301
|
-
modelRuntime: runtime,
|
|
302
|
-
thinkingLevel: opts.thinkingLevel ?? "medium",
|
|
303
|
-
...(isTest
|
|
304
|
-
? { customTools: [verdictTool!.tool, checkTool!.tool], tools: ["read", "bash", "ls", "grep", "find", "check_app", "submit_verdict"] }
|
|
305
|
-
: isReview
|
|
306
|
-
? { customTools: [verdictTool!.tool], tools: ["read", "ls", "grep", "find", "submit_verdict"] }
|
|
307
|
-
: { tools: ["read", "write", "edit", "bash", "ls", "grep", "find"] }),
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
const unsub = opts.onEvent ? session.subscribe(opts.onEvent) : undefined;
|
|
311
|
-
|
|
312
|
-
// Per-task limits. Cost is checked on every session event (Pi updates its stats as
|
|
313
|
-
// each assistant turn lands); time by a timer. On breach the session is aborted and
|
|
314
|
-
// the awaited prompt settles; we then throw so the orchestrator halts the build.
|
|
315
|
-
let breach: TaskLimitError | undefined;
|
|
316
|
-
const trip = (e: TaskLimitError) => {
|
|
317
|
-
if (breach) return;
|
|
318
|
-
breach = e;
|
|
319
|
-
void session.abort();
|
|
320
|
-
};
|
|
321
|
-
const unsubCost = limits.costCapUSD > 0
|
|
322
|
-
? session.subscribe(() => {
|
|
323
|
-
const spent = session.getSessionStats().cost;
|
|
324
|
-
if (spent > limits.costCapUSD) trip(new TaskLimitError("cost", task.id, round2(spent), `spent $${spent.toFixed(2)} > per-task cap $${limits.costCapUSD}`));
|
|
325
|
-
})
|
|
326
|
-
: undefined;
|
|
327
|
-
const timer = limits.timeoutMs > 0
|
|
328
|
-
? setTimeout(() => trip(new TaskLimitError("timeout", task.id, round2(session.getSessionStats().cost), `ran longer than ${Math.round(limits.timeoutMs / 60_000)} min`)), limits.timeoutMs)
|
|
329
|
-
: undefined;
|
|
330
|
-
try {
|
|
331
|
-
// Give code/review/test the WHOLE current file tree (not just direct-dep files), so a
|
|
332
|
-
// dev building one file knows every other file that already exists to wire into.
|
|
333
|
-
let fullContext = contextText;
|
|
334
|
-
if (task.capability === "code" || task.capability === "review" || task.capability === "test") {
|
|
335
|
-
const existing = listFiles(opts.workspace);
|
|
336
|
-
if (existing.length) {
|
|
337
|
-
fullContext = [contextText, `Files already in the working directory:\n${existing.map((f) => ` ${f}`).join("\n")}`]
|
|
338
|
-
.filter(Boolean)
|
|
339
|
-
.join("\n\n");
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
// An aborted prompt may reject with Pi's own error; the breach is the real cause.
|
|
343
|
-
await session.prompt(buildRolePrompt(task, fullContext)).catch((e: unknown) => { if (!breach) throw e; });
|
|
344
|
-
if (breach) throw breach;
|
|
345
|
-
|
|
346
|
-
let verdict = verdictTool?.get();
|
|
347
|
-
if (verdictTool && !verdict) {
|
|
348
|
-
await session.followUp("Call submit_verdict now with your judgement.").catch((e: unknown) => { if (!breach) throw e; });
|
|
349
|
-
verdict = verdictTool?.get();
|
|
350
|
-
}
|
|
351
|
-
if (breach) throw breach;
|
|
352
|
-
|
|
353
|
-
const stats = session.getSessionStats();
|
|
354
|
-
// Feed real usage back to sharpen estimates — but only for a real run.
|
|
355
|
-
if (stats.tokens.total > 0) {
|
|
356
|
-
const inputTotal = stats.tokens.input + stats.tokens.cacheRead;
|
|
357
|
-
recordActual(task.capability, task.difficulty, inputTotal, stats.tokens.output, inputTotal > 0 ? stats.tokens.cacheRead / inputTotal : 0);
|
|
358
|
-
}
|
|
359
|
-
const result: RoleResult = {
|
|
360
|
-
finalText: lastAssistantText(session),
|
|
361
|
-
files: listFiles(opts.workspace),
|
|
362
|
-
cost: round2(stats.cost),
|
|
363
|
-
verdict,
|
|
364
|
-
};
|
|
365
|
-
return { result, tokensTotal: stats.tokens.total };
|
|
366
|
-
} finally {
|
|
367
|
-
clearTimeout(timer);
|
|
368
|
-
unsubCost?.();
|
|
369
|
-
addSessionCost(session.getSessionStats().cost); // bill every attempt, aborted or not
|
|
370
|
-
unsub?.();
|
|
371
|
-
session.dispose();
|
|
372
|
-
}
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
return async ({ task, decision, contextText, limits }) => {
|
|
376
|
-
const chain = fallbackChain(decision.provider, decision.model.id, task.capability);
|
|
377
|
-
let lastErr: unknown;
|
|
378
|
-
for (let i = 0; i < chain.length; i++) {
|
|
379
|
-
const cand = chain[i]!;
|
|
380
|
-
try {
|
|
381
|
-
const att = await runOnce(task, contextText, cand.provider, cand.model, limits);
|
|
382
|
-
if (att.tokensTotal > 0) {
|
|
383
|
-
if (i > 0) opts.onFallback?.({ taskId: task.id, from: decision.provider, to: cand.provider, model: cand.model });
|
|
384
|
-
return att.result;
|
|
385
|
-
}
|
|
386
|
-
lastErr = new Error(`${cand.provider}/${cand.model} returned 0 tokens (invalid key, no account credit/balance, or no access to this model)`);
|
|
387
|
-
} catch (e) {
|
|
388
|
-
if (e instanceof TaskLimitError) throw e; // a limit breach is final — never retry elsewhere
|
|
389
|
-
lastErr = e;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
throw lastErr instanceof Error ? lastErr : new Error("all providers failed");
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function round2(n: number): number {
|
|
397
|
-
return Math.round(n * 100) / 100;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// exported for tests
|
|
401
|
-
export { buildCheckTool, buildVerdictTool, VerdictSchema, estimateCost, getModel };
|