projectinator 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/bin/projectinator.mjs +21 -0
  4. package/package.json +73 -0
  5. package/src/bakeoff.ts +220 -0
  6. package/src/build-state.ts +46 -0
  7. package/src/burndown.ts +35 -0
  8. package/src/calibration.ts +88 -0
  9. package/src/cost.ts +45 -0
  10. package/src/council.ts +180 -0
  11. package/src/demo.ts +106 -0
  12. package/src/estimate.ts +104 -0
  13. package/src/executor.ts +168 -0
  14. package/src/git.ts +72 -0
  15. package/src/intake.ts +129 -0
  16. package/src/models.ts +92 -0
  17. package/src/narrate.ts +91 -0
  18. package/src/orchestrator.ts +271 -0
  19. package/src/pm.ts +301 -0
  20. package/src/preview.ts +192 -0
  21. package/src/registry-store.ts +41 -0
  22. package/src/registry.ts +118 -0
  23. package/src/research.ts +127 -0
  24. package/src/retro.ts +99 -0
  25. package/src/roles.ts +357 -0
  26. package/src/router.ts +123 -0
  27. package/src/run-bakeoff.ts +77 -0
  28. package/src/run-build.ts +190 -0
  29. package/src/run-dev.ts +83 -0
  30. package/src/run-pm.ts +92 -0
  31. package/src/run-research.ts +74 -0
  32. package/src/run-scout.ts +68 -0
  33. package/src/run-web.ts +87 -0
  34. package/src/scout.ts +121 -0
  35. package/src/session-cost.ts +17 -0
  36. package/src/stack.ts +46 -0
  37. package/src/tui/App.tsx +1739 -0
  38. package/src/tui/BakeOff.tsx +190 -0
  39. package/src/tui/BoardEditor.tsx +248 -0
  40. package/src/tui/EditableBoard.tsx +169 -0
  41. package/src/tui/Frame.tsx +142 -0
  42. package/src/tui/Intake.tsx +111 -0
  43. package/src/tui/Kanban.tsx +155 -0
  44. package/src/tui/Settings.tsx +419 -0
  45. package/src/tui/StackPick.tsx +79 -0
  46. package/src/tui/WebAccounts.tsx +197 -0
  47. package/src/tui/components.tsx +338 -0
  48. package/src/tui/config.ts +134 -0
  49. package/src/tui/deploy.ts +137 -0
  50. package/src/tui/engine.ts +742 -0
  51. package/src/tui/notify.ts +21 -0
  52. package/src/tui/panels.tsx +89 -0
  53. package/src/tui/templates.ts +119 -0
  54. package/src/tui/theme.ts +44 -0
  55. package/src/tui/validate.ts +48 -0
  56. package/src/tui.tsx +63 -0
  57. package/src/types.ts +175 -0
  58. package/src/web/oauth-anthropic.ts +206 -0
  59. package/src/web/session.ts +299 -0
package/src/router.ts ADDED
@@ -0,0 +1,123 @@
1
+ // The Router — deterministic dispatch. No LLM, no network. Given a tagged task
2
+ // and a policy, it resolves backend -> model -> cost, and flags budget overruns.
3
+ //
4
+ // User prompts (which backend? which model on API?) are injected as callbacks so
5
+ // the router stays pure and unit-testable. In the CLI they wrap real prompts;
6
+ // in tests they're stubs.
7
+
8
+ import type {
9
+ Backend,
10
+ RegistryEntry,
11
+ RouteDecision,
12
+ RoutingPolicy,
13
+ Task,
14
+ } from "./types.js";
15
+ import { estimateCost } from "./cost.js";
16
+ import { getModel } from "./models.js";
17
+ import { findEntry, REGISTRY } from "./registry.js";
18
+
19
+ export interface RouterPrompts {
20
+ /** Ask the user which backend to use. Called only when backendMode === "ask". */
21
+ chooseBackend?: (task: Task) => Backend;
22
+ /** Ask the user which model on API. Called only when entry.ask && backend === "api".
23
+ * Return a model id, or undefined to accept the registry default. */
24
+ chooseModel?: (task: Task, entry: RegistryEntry, backend: Backend) => string | undefined;
25
+ }
26
+
27
+ export interface RouteContext {
28
+ policy: RoutingPolicy;
29
+ registry?: RegistryEntry[];
30
+ prompts?: RouterPrompts;
31
+ /** Cumulative spend before this task, USD. */
32
+ runningTotalBefore?: number;
33
+ }
34
+
35
+ /** Resolve which backend to use from the policy (and a prompt, if "ask"). */
36
+ export function resolveBackend(policy: RoutingPolicy, task: Task, prompts?: RouterPrompts): Backend {
37
+ switch (policy.backendMode) {
38
+ case "api":
39
+ return "api";
40
+ case "web":
41
+ return "web";
42
+ case "cost-first":
43
+ // Web-login rides the user's existing subscription -> effectively free.
44
+ return "web";
45
+ case "ask":
46
+ if (!prompts?.chooseBackend) {
47
+ throw new Error('backendMode "ask" requires prompts.chooseBackend');
48
+ }
49
+ return prompts.chooseBackend(task);
50
+ }
51
+ }
52
+
53
+ export function route(task: Task, ctx: RouteContext): RouteDecision {
54
+ const { policy } = ctx;
55
+ const registry = ctx.registry ?? REGISTRY;
56
+ const reasons: string[] = [];
57
+
58
+ // 1. Backend.
59
+ const backend = resolveBackend(policy, task, ctx.prompts);
60
+ reasons.push(`backend=${backend} (mode=${policy.backendMode})`);
61
+
62
+ // 2. Difficulty -> tier.
63
+ const tier = policy.difficultyToTier[task.difficulty];
64
+ reasons.push(`difficulty=${task.difficulty} -> tier=${tier}`);
65
+
66
+ // 3. Registry lookup (with tier fallback).
67
+ const { entry, exactTier } = findEntry(task.capability, tier, registry);
68
+ if (!exactTier) reasons.push(`no ${task.capability}/${tier} entry, fell back to ${entry.tier}`);
69
+
70
+ // 4. Backend-conditional model, with optional per-role prompt on API.
71
+ let modelId = entry.byBackend[backend].model;
72
+ if (backend === "api" && entry.ask && ctx.prompts?.chooseModel) {
73
+ const picked = ctx.prompts.chooseModel(task, entry, backend);
74
+ if (picked && picked !== modelId) {
75
+ reasons.push(`user overrode model ${modelId} -> ${picked}`);
76
+ modelId = picked;
77
+ }
78
+ }
79
+ const model = getModel(modelId);
80
+ reasons.push(`model=${model.id} (${model.provider})`);
81
+
82
+ // 5. Cost.
83
+ const cost = estimateCost(task.estTokens, model);
84
+ const runningTotal = round2((ctx.runningTotalBefore ?? 0) + cost);
85
+ const overCap = runningTotal > policy.budgetCapUSD;
86
+ if (overCap) reasons.push(`OVER CAP: running $${runningTotal} > cap $${policy.budgetCapUSD}`);
87
+
88
+ return {
89
+ taskId: task.id,
90
+ backend,
91
+ provider: model.provider,
92
+ model,
93
+ tier: entry.tier,
94
+ cost,
95
+ runningTotal,
96
+ overCap,
97
+ reasons,
98
+ };
99
+ }
100
+
101
+ /** Route a whole backlog in order, threading the running total. */
102
+ export function routeBacklog(tasks: Task[], ctx: RouteContext): RouteDecision[] {
103
+ const out: RouteDecision[] = [];
104
+ let running = ctx.runningTotalBefore ?? 0;
105
+ for (const task of tasks) {
106
+ const decision = route(task, { ...ctx, runningTotalBefore: running });
107
+ running = decision.runningTotal;
108
+ out.push(decision);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ function round2(n: number): number {
114
+ return Math.round(n * 100) / 100;
115
+ }
116
+
117
+ /** A sensible default policy. */
118
+ export const DEFAULT_POLICY: RoutingPolicy = {
119
+ backendMode: "cost-first",
120
+ budgetCapUSD: 15,
121
+ difficultyToTier: { trivial: "fast", low: "mid", medium: "mid", high: "high" },
122
+ maxFeedbackRounds: 3,
123
+ };
@@ -0,0 +1,77 @@
1
+ // Model bake-off CLI — run one task across models, compare cost/latency/quality.
2
+ //
3
+ // npm run bakeoff -- --capability design "Design a pricing page with 3 tiers"
4
+ // npm run bakeoff -- --capability plan "Plan an MVP task list for a URL shortener"
5
+ // npm run bakeoff -- --models claude-opus-4-8,claude-sonnet-4-6,claude-haiku-4-5 "..."
6
+ //
7
+ // Defaults to the three Anthropic tiers (what most people hold a key for). Add
8
+ // --models to compare any model ids; --provider to set their provider.
9
+
10
+ import type { Capability, Difficulty, Provider } from "./types.js";
11
+ import { runBakeoff, bakeoffTask, type Candidate } from "./bakeoff.js";
12
+
13
+ const args = process.argv.slice(2);
14
+ function opt(name: string): string | undefined {
15
+ const i = args.indexOf(name);
16
+ return i >= 0 ? args[i + 1] : undefined;
17
+ }
18
+
19
+ const capability = (opt("--capability") ?? "design") as Capability;
20
+ const difficulty = (opt("--difficulty") ?? "medium") as Difficulty;
21
+ const provider = (opt("--provider") ?? "anthropic") as Provider;
22
+ const modelsArg = opt("--models");
23
+ const consumed = new Set(["--capability", capability, "--difficulty", difficulty, "--provider", provider, "--models", modelsArg ?? ""].filter(Boolean));
24
+ const prompt = args.filter((a) => !consumed.has(a)).join(" ").trim();
25
+
26
+ if (!prompt) {
27
+ console.log('\n Usage: npm run bakeoff -- --capability design "your task"\n');
28
+ process.exit(1);
29
+ }
30
+ if (capability === "code") {
31
+ console.log("\n Code bake-off isn't supported yet (needs a sandbox + real test scoring per model).");
32
+ console.log(" Try --capability design or plan for now.\n");
33
+ process.exit(1);
34
+ }
35
+
36
+ const models = (modelsArg ?? "claude-opus-4-8,claude-sonnet-4-6,claude-haiku-4-5").split(",").map((s) => s.trim()).filter(Boolean);
37
+ const candidates: Candidate[] = models.map((model) => ({ provider, model }));
38
+
39
+ console.log(`\n Bake-off — ${capability}/${difficulty} ${candidates.length} models\n Task: ${prompt}\n`);
40
+
41
+ const result = await runBakeoff(bakeoffTask(prompt, capability, difficulty), candidates, {
42
+ onProgress: (m) => console.log(" " + m),
43
+ });
44
+
45
+ // ---- report ----
46
+ const scoreOf = new Map(result.scores.map((s) => [s.model, s]));
47
+ const money = (n: number) => `$${n.toFixed(4)}`;
48
+ console.log("\n --- RESULTS ---");
49
+ console.log(" model".padEnd(30) + "score".padEnd(8) + "cost".padEnd(12) + "time".padEnd(8) + "tokens");
50
+ for (const e of result.entries) {
51
+ const key = `${e.provider}/${e.model}`;
52
+ const sc = scoreOf.get(key);
53
+ const scoreStr = e.error ? "ERR" : sc ? `${sc.score}/10` : "—";
54
+ const line =
55
+ (" " + e.model).padEnd(30) +
56
+ scoreStr.padEnd(8) +
57
+ (e.error ? "—" : money(e.cost)).padEnd(12) +
58
+ (e.error ? "—" : `${(e.ms / 1000).toFixed(1)}s`).padEnd(8) +
59
+ (e.error ? "" : String(e.outputTokens));
60
+ console.log(line);
61
+ if (e.error) console.log(` ↳ ${e.error}`);
62
+ }
63
+
64
+ if (result.winner) {
65
+ const w = result.entries.find((e) => `${e.provider}/${e.model}` === result.winner);
66
+ const wc = w?.cost ?? 0;
67
+ const cheapest = result.entries.filter((e) => !e.error).sort((a, b) => a.cost - b.cost)[0];
68
+ console.log(`\n 🏆 Best quality: ${result.winner} (judge: ${result.judge})`);
69
+ if (cheapest && `${cheapest.provider}/${cheapest.model}` !== result.winner) {
70
+ console.log(` 💸 Cheapest: ${cheapest.provider}/${cheapest.model} at ${money(cheapest.cost)} (winner cost ${money(wc)})`);
71
+ }
72
+ for (const s of result.scores.sort((a, b) => b.score - a.score)) {
73
+ console.log(` ${s.score}/10 ${s.model} — ${s.reason}`);
74
+ }
75
+ }
76
+ console.log("");
77
+ process.exit(0);
@@ -0,0 +1,190 @@
1
+ // Phase 4 entry — run a whole build end to end.
2
+ //
3
+ // npm run build -- "an idea" dry: decompose + toposort + routing plan, NO spend
4
+ // npm run build -- --mini dry: tiny fixed 3-task backlog (design->code->test)
5
+ // npm run build -- --live --mini LIVE: run the mini backlog (cheap end-to-end)
6
+ // npm run build -- --live --lock anthropic "an idea" LIVE: full pipeline on one provider
7
+ //
8
+ // --lock <provider> routes every role to one provider (use the one you hold a key for).
9
+ // Default lock is anthropic.
10
+
11
+ import { mkdirSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+ import { AuthStorage } from "@earendil-works/pi-coding-agent";
15
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
16
+ import type { Provider, Task } from "./types.js";
17
+ import { DEFAULT_POLICY } from "./router.js";
18
+ import { toposort, runBacklog, type OrchestratorEvent } from "./orchestrator.js";
19
+ import { lockRegistryToProvider, makePiExecutor } from "./roles.js";
20
+ import { estimateTokens } from "./estimate.js";
21
+ import { route } from "./router.js";
22
+ import { decomposeIdea } from "./pm.js";
23
+ import { newBuildState, saveState, loadState, type BuildState } from "./build-state.js";
24
+ import { initRepo, commitTask } from "./git.js";
25
+
26
+ const args = process.argv.slice(2);
27
+ const live = args.includes("--live");
28
+ const mini = args.includes("--mini");
29
+ const fan = args.includes("--fan");
30
+ const resume = args.includes("--resume");
31
+ const lockIdx = args.indexOf("--lock");
32
+ const lockProvider = (lockIdx >= 0 ? args[lockIdx + 1] : "anthropic") as Provider;
33
+ const concIdx = args.indexOf("--concurrency");
34
+ const concurrency = Math.max(1, parseInt((concIdx >= 0 ? args[concIdx + 1] : "1") ?? "1", 10) || 1);
35
+ const consumed = new Set(
36
+ ["--live", "--mini", "--fan", "--resume", "--lock", lockIdx >= 0 ? args[lockIdx + 1] : "",
37
+ "--concurrency", concIdx >= 0 ? args[concIdx + 1] : ""].filter(Boolean),
38
+ );
39
+ const idea = args.filter((a) => !consumed.has(a)).join(" ").trim() ||
40
+ "A one-page site with a headline and a contact form.";
41
+
42
+ const money = (n: number) => `$${n.toFixed(2)}`;
43
+ const registry = lockRegistryToProvider(lockProvider);
44
+ const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: mini ? 10 : 25 };
45
+
46
+ // --- a tiny fixed backlog for cheap end-to-end proof ---
47
+ function miniBacklog(): Task[] {
48
+ const mk = (id: string, cap: Task["capability"], diff: Task["difficulty"], title: string, deps: string[] = []): Task => ({
49
+ id, title, capability: cap, difficulty: diff, dependsOn: deps, estTokens: estimateTokens(cap, diff),
50
+ });
51
+ return [
52
+ 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
+ mk("C-1", "code", "low", "Create index.html implementing the design spec exactly. Single self-contained file with embedded CSS.", ["D-1"]),
54
+ mk("T-1", "test", "trivial", "Open/read index.html and verify it is valid HTML and matches the design spec (centered card, the headline text, a gradient).", ["C-1"]),
55
+ ];
56
+ }
57
+
58
+ // A fan-out backlog with independent branches, to show parallel execution:
59
+ // two design tasks (independent) -> two code tasks -> one test that joins them.
60
+ function fanBacklog(): Task[] {
61
+ const mk = (id: string, cap: Task["capability"], diff: Task["difficulty"], title: string, deps: string[] = []): Task => ({
62
+ id, title, capability: cap, difficulty: diff, dependsOn: deps, estTokens: estimateTokens(cap, diff),
63
+ });
64
+ return [
65
+ mk("DA", "design", "low", "Design spec for a 'Newsletter signup' card (email input + button)."),
66
+ mk("DB", "design", "low", "Design spec for an 'FAQ' accordion (3 questions)."),
67
+ mk("CA", "code", "low", "Create newsletter.html from the newsletter design spec.", ["DA"]),
68
+ mk("CB", "code", "low", "Create faq.html from the FAQ design spec.", ["DB"]),
69
+ mk("TJ", "test", "trivial", "Verify newsletter.html and faq.html are valid and match their specs.", ["CA", "CB"]),
70
+ ];
71
+ }
72
+
73
+ async function getTasks(): Promise<Task[]> {
74
+ if (fan) return fanBacklog();
75
+ if (mini) return miniBacklog();
76
+ if (!live) {
77
+ // Dry + full idea: we can't call the PM without spending, so show the mini path.
78
+ console.log(" (Full-idea decomposition needs the PM model. Use --mini for a dry preview, or add --live.)\n");
79
+ return miniBacklog();
80
+ }
81
+ console.log(" Decomposing idea with PM...\n");
82
+ const res = await decomposeIdea(idea, {
83
+ backend: "api",
84
+ modelOverride: { provider: lockProvider, model: lockRegistryToProvider(lockProvider).find((e) => e.capability === "plan")!.byBackend.api.model },
85
+ });
86
+ console.log(` PM produced ${res.tasks.length} tasks.\n`);
87
+ return res.tasks;
88
+ }
89
+
90
+ const variant = fan ? " / fan" : mini ? " / mini" : "";
91
+ console.log(`\n Projectinator — Phase 4 full build [${live ? "LIVE" : "DRY"}${variant}] lock=${lockProvider} concurrency=${concurrency}\n`);
92
+ if (!mini && !fan) console.log(` Idea: ${idea}\n`);
93
+
94
+ let tasks = await getTasks();
95
+
96
+ // Dry: show plan only.
97
+ if (!live) {
98
+ const ordered = toposort(tasks);
99
+ console.log(" Execution order (toposorted):");
100
+ let est = 0;
101
+ for (const t of ordered) {
102
+ const d = route(t, { policy, registry, runningTotalBefore: est });
103
+ est = d.runningTotal;
104
+ const dep = t.dependsOn?.length ? ` <- ${t.dependsOn.join(",")}` : "";
105
+ console.log(` ${t.id.padEnd(6)} [${t.capability}/${t.difficulty}] -> ${d.model.name.padEnd(20)} ${money(d.cost)}${dep}`);
106
+ }
107
+ console.log(`\n Estimated total: ${money(est)} (cap ${money(policy.budgetCapUSD)})`);
108
+ console.log(" Dry run. Add --live to actually build.\n");
109
+ process.exit(0);
110
+ }
111
+
112
+ // Live: key check.
113
+ const envKey: Record<Provider, string[]> = {
114
+ anthropic: ["ANTHROPIC_API_KEY"],
115
+ openai: ["OPENAI_API_KEY"],
116
+ google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
117
+ };
118
+ if (!(envKey[lockProvider] ?? []).some((k) => process.env[k])) {
119
+ console.error(` No API key for ${lockProvider}. Set: ${(envKey[lockProvider] ?? []).join(", ")}\n`);
120
+ process.exit(1);
121
+ }
122
+
123
+ const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
124
+ const workspace = join(projectRoot, ".workspace", fan ? "fan-build" : mini ? "mini-build" : "build");
125
+ mkdirSync(workspace, { recursive: true });
126
+ const statePath = join(workspace, "build-state.json");
127
+
128
+ // Resume: load prior state, reuse its saved backlog, seed finished outcomes.
129
+ let state: BuildState;
130
+ let seedOutcomes = undefined;
131
+ const prior = resume ? loadState(statePath) : undefined;
132
+ if (prior) {
133
+ state = prior;
134
+ state.status = "running";
135
+ tasks = prior.tasks; // authoritative backlog from the interrupted run
136
+ seedOutcomes = prior.outcomes;
137
+ const doneCount = new Set(prior.outcomes.map((o) => o.taskId)).size;
138
+ console.log(` Resuming: ${doneCount} task(s) already done, restored ${money(prior.totalCost)}.`);
139
+ } else {
140
+ if (resume) console.log(" (No prior state found — starting fresh.)");
141
+ state = newBuildState(fan ? "fan-build" : mini ? "mini-build" : "build", tasks);
142
+ }
143
+ console.log(` Workspace: ${workspace}\n Building...\n`);
144
+
145
+ // Version the workspace + commit after each task.
146
+ initRepo(workspace);
147
+ const titleById = new Map(tasks.map((t) => [t.id, t.title]));
148
+
149
+ const onEvent = (_e: AgentSessionEvent) => {};
150
+ const executor = makePiExecutor({
151
+ workspace,
152
+ backend: "api",
153
+ onEvent,
154
+ onFallback: (info) => console.log(` ↪ ${info.taskId}: ${info.from} failed — fell back to ${info.to}/${info.model}`),
155
+ });
156
+
157
+ const onProgress = (e: OrchestratorEvent) => {
158
+ if (e.type === "task_start") console.log(` ▶ ${e.task.id} [${e.task.capability}] -> ${e.provider}/${e.modelId} (round ${e.round})`);
159
+ else if (e.type === "task_done") {
160
+ const hash = commitTask(workspace, e.outcome.taskId, titleById.get(e.outcome.taskId) ?? e.outcome.taskId);
161
+ 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}]` : ""}`);
162
+ }
163
+ else if (e.type === "task_skipped") console.log(` · ${e.taskId} skipped (already done)`);
164
+ else if (e.type === "test_failed") console.log(` ✗ ${e.taskId} FAILED (${e.bugs} bugs) — round ${e.round}`);
165
+ else if (e.type === "retry_dev") console.log(` ↻ re-running ${e.taskId} to fix ${e.forTest}`);
166
+ else if (e.type === "budget_halt") console.log(` ⚠ BUDGET HALT at ${money(e.runningTotal)} (cap ${money(e.cap)})`);
167
+ };
168
+
169
+ // Checkpoint after every task so a crash/cancel loses at most one task.
170
+ const onCheckpoint = (outcomes: typeof state.outcomes, totalCost: number) => {
171
+ state.outcomes = outcomes;
172
+ state.totalCost = totalCost;
173
+ saveState(state, statePath);
174
+ };
175
+
176
+ const result = await runBacklog(tasks, { policy, execute: executor, registry, onProgress, seedOutcomes, onCheckpoint, concurrency });
177
+
178
+ state.outcomes = result.outcomes;
179
+ state.totalCost = result.totalCost;
180
+ state.status = result.halted ? "halted" : "complete";
181
+ state.haltReason = result.haltReason;
182
+ saveState(state, statePath);
183
+
184
+ console.log(`\n --- BUILD ${result.halted ? "HALTED" : "COMPLETE"} ---`);
185
+ console.log(` Steps run: ${result.outcomes.length}`);
186
+ console.log(` Total cost: ${money(result.totalCost)}`);
187
+ if (result.haltReason) console.log(` Halt reason: ${result.haltReason} (re-run with --resume to continue)`);
188
+ const finalFiles = result.outcomes.at(-1)?.files ?? [];
189
+ console.log(` Files in workspace: ${finalFiles.join(", ") || "(none)"}`);
190
+ console.log(` State saved: ${statePath}\n`);
package/src/run-dev.ts ADDED
@@ -0,0 +1,83 @@
1
+ // Phase 2 entry — route ONE developer task and (optionally) run it on a real Pi agent.
2
+ //
3
+ // npm run dev:task dry run: resolve model + print plan/prompt, NO api call, NO spend
4
+ // npm run dev:task -- --live actually run the agent (spends money, needs an API key)
5
+ //
6
+ // Dry mode proves the whole wiring offline. Live mode is opt-in and key-gated.
7
+
8
+ import { mkdirSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import { dirname, join } from "node:path";
11
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
12
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
13
+ import type { Task } from "./types.js";
14
+ import { DEFAULT_POLICY, route } from "./router.js";
15
+ import { buildDeveloperPrompt, executeTask, resolvePiModel } from "./executor.js";
16
+
17
+ const TASK: Task = {
18
+ id: "T-DEV1",
19
+ story: "S-1",
20
+ title: "Create a single-file landing page (index.html) for 'Projectinator' with a hero headline, one-line pitch, and a contact form (name, email, message) styled with embedded CSS. No external assets.",
21
+ capability: "code",
22
+ difficulty: "high",
23
+ estTokens: { input: 40_000, output: 12_000, cachedInputFraction: 0.3 },
24
+ };
25
+
26
+ const live = process.argv.includes("--live");
27
+
28
+ // Route it. Web-login backend isn't built yet -> use API.
29
+ const policy = { ...DEFAULT_POLICY, backendMode: "api" as const };
30
+ const decision = route(TASK, { policy });
31
+
32
+ const auth = AuthStorage.create();
33
+ const registry = ModelRegistry.create(auth);
34
+ const piModel = resolvePiModel(registry, decision.provider, decision.model.id); // offline, free
35
+
36
+ const money = (n: number) => `$${n.toFixed(2)}`;
37
+
38
+ console.log(`\n Projectinator — Phase 2 developer run [${live ? "LIVE" : "DRY"}]\n`);
39
+ console.log(` Task ${TASK.id}: ${TASK.title.slice(0, 64)}...`);
40
+ console.log(` Routed ${decision.provider}/${decision.model.id} (tier=${decision.tier}, backend=${decision.backend})`);
41
+ console.log(` Pi model resolved: ${piModel.id} ctx=${piModel.contextWindow} in/out=${piModel.cost?.input}/${piModel.cost?.output}`);
42
+ console.log(` Est cost ${money(decision.cost)}\n`);
43
+
44
+ if (!live) {
45
+ console.log(" --- developer prompt (not sent) ---");
46
+ console.log(buildDeveloperPrompt(TASK).split("\n").map((l) => " | " + l).join("\n"));
47
+ console.log("\n Dry run only. Wiring verified: task routed, Pi model resolved, prompt built.");
48
+ console.log(" Re-run with --live (and an API key) to actually build.\n");
49
+ process.exit(0);
50
+ }
51
+
52
+ // --- LIVE PATH: spends money ---
53
+ const envKey: Record<string, string[]> = {
54
+ anthropic: ["ANTHROPIC_API_KEY"],
55
+ openai: ["OPENAI_API_KEY"],
56
+ google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
57
+ };
58
+ const keyPresent = (envKey[decision.provider] ?? []).some((k) => !!process.env[k]);
59
+ if (!keyPresent) {
60
+ console.error(
61
+ ` No API key for ${decision.provider}. Set one of: ${(envKey[decision.provider] ?? []).join(", ")}\n`,
62
+ );
63
+ process.exit(1);
64
+ }
65
+
66
+ // fileURLToPath decodes %20/%7E etc. — URL.pathname would leave them encoded and
67
+ // create a literally-named "Mobile%20Documents" directory.
68
+ const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
69
+ const workspace = join(projectRoot, ".workspace", TASK.id);
70
+ mkdirSync(workspace, { recursive: true });
71
+ console.log(` Workspace: ${workspace}\n Running agent...\n`);
72
+
73
+ const onEvent = (e: AgentSessionEvent) => {
74
+ process.stdout.write(` · ${e.type}\n`);
75
+ };
76
+
77
+ const result = await executeTask(TASK, decision, { workspace, onEvent });
78
+
79
+ console.log(`\n --- done ---`);
80
+ console.log(` Files: ${result.files.join(", ") || "(none)"}`);
81
+ console.log(` Tokens: in=${result.actual.input} out=${result.actual.output} cacheRead=${result.actual.cacheRead} total=${result.actual.total}`);
82
+ console.log(` Est: ${money(result.estCost)}`);
83
+ console.log(` Actual: ${money(result.actualCost)} (delta ${money(result.costDelta)})\n`);
package/src/run-pm.ts ADDED
@@ -0,0 +1,92 @@
1
+ // Phase 3 entry — decompose an idea into a routed backlog.
2
+ //
3
+ // npm run pm -- "your idea here" dry: show PM model + prompt, NO spend
4
+ // npm run pm -- --live "your idea here" live: decompose + route the backlog
5
+ //
6
+ // Live decomposition needs an API key (PM routes to an OpenAI model by default).
7
+
8
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
9
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
10
+ import { DEFAULT_POLICY, routeBacklog } from "./router.js";
11
+ import { findEntry } from "./registry.js";
12
+ import { resolvePiModel } from "./executor.js";
13
+ import { decomposeIdea, pmSystemPrompt } from "./pm.js";
14
+
15
+ const args = process.argv.slice(2);
16
+ const live = args.includes("--live");
17
+
18
+ // Optional PM model override: --pm <provider/id> (e.g. anthropic/claude-sonnet-4-6).
19
+ // Lets you decompose with whichever provider you hold a key for.
20
+ const pmIdx = args.indexOf("--pm");
21
+ const pmOverride = pmIdx >= 0 ? args[pmIdx + 1] : undefined;
22
+ const consumed = new Set<string>(["--live", "--pm", pmOverride].filter(Boolean) as string[]);
23
+ const idea = args.filter((a) => !consumed.has(a)).join(" ").trim() ||
24
+ "A simple personal task tracker web app: add tasks, mark them done, filter by status.";
25
+
26
+ const backend = "api" as const; // web-login backend not built yet
27
+ const money = (n: number) => `$${n.toFixed(2)}`;
28
+
29
+ const auth = AuthStorage.create();
30
+ const registry = ModelRegistry.create(auth);
31
+ const { entry } = findEntry("plan", "mid");
32
+ const pick = pmOverride
33
+ ? { provider: pmOverride.split("/")[0] as typeof entry.byBackend[typeof backend]["provider"], model: pmOverride.split("/").slice(1).join("/") }
34
+ : entry.byBackend[backend];
35
+ const pm = resolvePiModel(registry, pick.provider, pick.model); // offline
36
+
37
+ console.log(`\n Projectinator — Phase 3 PM decomposer [${live ? "LIVE" : "DRY"}]\n`);
38
+ console.log(` Idea: ${idea}`);
39
+ console.log(` PM model: ${pick.provider}/${pm.id} (tier=${entry.tier}, backend=${backend})\n`);
40
+
41
+ if (!live) {
42
+ console.log(" --- PM system prompt (not sent) ---");
43
+ console.log(pmSystemPrompt().split("\n").map((l) => " | " + l).join("\n"));
44
+ console.log("\n Dry run. Re-run with --live (and an API key) to actually decompose.\n");
45
+ process.exit(0);
46
+ }
47
+
48
+ const envKey: Record<string, string[]> = {
49
+ anthropic: ["ANTHROPIC_API_KEY"],
50
+ openai: ["OPENAI_API_KEY"],
51
+ google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
52
+ };
53
+ if (!(envKey[pick.provider] ?? []).some((k) => process.env[k])) {
54
+ console.error(` No API key for ${pick.provider}. Set: ${(envKey[pick.provider] ?? []).join(", ")}\n`);
55
+ process.exit(1);
56
+ }
57
+
58
+ console.log(" Decomposing...\n");
59
+ const onEvent = (e: AgentSessionEvent) => {
60
+ if (e.type === "tool_execution_end" || e.type === "agent_end") process.stdout.write(` · ${e.type}\n`);
61
+ };
62
+
63
+ const result = await decomposeIdea(idea, {
64
+ backend,
65
+ onEvent,
66
+ modelOverride: pmOverride ? { provider: pick.provider, model: pick.model } : undefined,
67
+ });
68
+
69
+ console.log(`\n --- BACKLOG (${result.provider}/${result.modelId}) ---`);
70
+ for (const t of result.backlog.tasks) {
71
+ const dep = (t.dependsOn ?? []).length ? ` <- ${(t.dependsOn ?? []).join(",")}` : "";
72
+ const grp = t.epic ? `${t.epic}${t.story ? "/" + t.story : ""} ` : "";
73
+ console.log(` ${t.id.padEnd(6)} [${t.capability}/${t.difficulty}] ${grp}${t.title}${dep}`);
74
+ }
75
+ if (result.diagnostics.length) {
76
+ console.log(`\n Normalizer notes:`);
77
+ for (const d of result.diagnostics) console.log(` ! ${d}`);
78
+ }
79
+
80
+ // Route the whole backlog and total the cost.
81
+ const decisions = routeBacklog(result.tasks, { policy: { ...DEFAULT_POLICY, backendMode: backend } });
82
+ console.log(`\n --- ROUTING PLAN ---`);
83
+ console.log(" " + "TASK".padEnd(7) + "CAP/DIFF".padEnd(16) + "MODEL".padEnd(22) + "COST");
84
+ for (const d of decisions) {
85
+ const t = result.tasks.find((x) => x.id === d.taskId)!;
86
+ console.log(
87
+ " " + d.taskId.padEnd(7) +
88
+ `${t.capability}/${t.difficulty}`.padEnd(16) +
89
+ d.model.name.padEnd(22) + money(d.cost),
90
+ );
91
+ }
92
+ console.log(`\n Tasks: ${result.tasks.length} Estimated total: ${money(decisions.at(-1)?.runningTotal ?? 0)}\n`);
@@ -0,0 +1,74 @@
1
+ // Research entry — extract findings from a report, ready for the Scout.
2
+ //
3
+ // npm run research -- report.txt dry: show the extraction plan
4
+ // npm run research -- report.txt --live extract via a model, write findings.json
5
+ // npm run research -- report.txt --live --out f.json --model anthropic/claude-sonnet-4-6
6
+ //
7
+ // Then: npm run scout -- --from findings.json
8
+ //
9
+ // The report is any benchmark write-up — e.g. save the deep-research harness output to a
10
+ // text file and point this at it.
11
+
12
+ import { readFileSync, writeFileSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+ import type { Provider } from "./types.js";
16
+ import { extractFindings, validateFindings, extractionPrompt } from "./research.js";
17
+
18
+ const args = process.argv.slice(2);
19
+ const live = args.includes("--live");
20
+ const outIdx = args.indexOf("--out");
21
+ const modelIdx = args.indexOf("--model");
22
+ const modelArg = (modelIdx >= 0 ? args[modelIdx + 1] : undefined) ?? "anthropic/claude-sonnet-4-6";
23
+ const outArg = outIdx >= 0 ? args[outIdx + 1] : undefined;
24
+
25
+ const consumed = new Set(["--live", "--out", outArg, "--model", modelArg].filter(Boolean) as string[]);
26
+ const reportPath = args.filter((a) => !consumed.has(a))[0];
27
+
28
+ if (!reportPath) {
29
+ console.error("\n Usage: npm run research -- <report.txt> [--live] [--out findings.json] [--model provider/id]\n");
30
+ process.exit(1);
31
+ }
32
+
33
+ const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
34
+ const outPath = outArg ?? join(projectRoot, "findings.json");
35
+ const [provider, ...rest] = modelArg.split("/");
36
+ const modelSpec = { provider: provider as Provider, model: rest.join("/") };
37
+
38
+ const report = readFileSync(reportPath, "utf-8");
39
+
40
+ console.log(`\n Projectinator — Research extractor [${live ? "LIVE" : "DRY"}]`);
41
+ console.log(` Report: ${reportPath} (${report.length} chars)`);
42
+ console.log(` Model: ${modelSpec.provider}/${modelSpec.model}`);
43
+ console.log(` Output: ${outPath}\n`);
44
+
45
+ if (!live) {
46
+ console.log(" --- extraction prompt (not sent, report truncated) ---");
47
+ console.log(extractionPrompt(report.slice(0, 400) + "\n...[truncated]").split("\n").map((l) => " | " + l).join("\n"));
48
+ console.log("\n Dry run. Add --live (and an API key) to extract.\n");
49
+ process.exit(0);
50
+ }
51
+
52
+ const envKey: Record<string, string[]> = {
53
+ anthropic: ["ANTHROPIC_API_KEY"], openai: ["OPENAI_API_KEY"],
54
+ google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
55
+ };
56
+ if (!(envKey[modelSpec.provider] ?? []).some((k) => process.env[k])) {
57
+ console.error(` No API key for ${modelSpec.provider}. Set: ${(envKey[modelSpec.provider] ?? []).join(", ")}\n`);
58
+ process.exit(1);
59
+ }
60
+
61
+ console.log(" Extracting...\n");
62
+ const findings = await extractFindings(report, { model: modelSpec });
63
+ const { ok, issues } = validateFindings(findings);
64
+
65
+ console.log(` Extracted ${findings.length} findings:`);
66
+ for (const f of findings) console.log(` ${f.capability}/${f.tier} [${f.backend}] -> ${f.provider}/${f.model}`);
67
+
68
+ if (!ok) {
69
+ console.log(`\n Validation issues (fix models.ts or the report before applying):`);
70
+ for (const i of issues) console.log(` ! finding ${i.index} (${i.model}): ${i.problem}`);
71
+ }
72
+
73
+ writeFileSync(outPath, JSON.stringify({ findings }, null, 2) + "\n");
74
+ console.log(`\n Wrote ${outPath}. Next: npm run scout -- --from ${outPath}\n`);