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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stepan Manookian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ <p align="center">
2
+ <img src="docs/logo.svg" alt="Projectinator" width="112" />
3
+ </p>
4
+
5
+ # Projectinator
6
+
7
+ ![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)
8
+ ![tests: 134 passing](https://img.shields.io/badge/tests-134%20passing-brightgreen.svg)
9
+ ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)
10
+ ![built on Pi](https://img.shields.io/badge/built%20on-Pi%20agent%20harness-e0a72d.svg)
11
+
12
+ **You're the PM. Your dev team is a roster of AI models.** Hand Projectinator an idea; a
13
+ project-manager model breaks it into a Scrum backlog, and each task is dispatched to the
14
+ model that's best — and cheapest — for that exact job (planning, design, code, test). You
15
+ watch it happen from a terminal cockpit: a live board, budget bar, and a standup.
16
+
17
+ Built on the [Pi](https://pi.dev) agent harness (Node/TypeScript). Bring your own API key.
18
+
19
+ **Run it — no clone needed** (Node ≥ 20):
20
+
21
+ ```bash
22
+ npx github:smanookian/projectinator
23
+ ```
24
+
25
+ Then, inside the app: **Settings → API keys** and paste an Anthropic, OpenAI, or Gemini key
26
+ (stored at `~/.projectinator`, never in the repo). That's it — pick **New build** and go.
27
+
28
+ <details>
29
+ <summary>Run from source instead</summary>
30
+
31
+ ```bash
32
+ git clone https://github.com/smanookian/projectinator && cd projectinator
33
+ npm install
34
+ npm start
35
+ ```
36
+ </details>
37
+
38
+ > **Optional:** `npx playwright install chromium` lets the tester actually run web apps in a
39
+ > headless browser and enables live preview. Everything else works without it.
40
+
41
+ > Projectinator spends **your** API money. Every screen shows the running cost; you set a
42
+ > budget cap and it halts before crossing it. A tiny landing page is cents; a full app is
43
+ > usually a dollar or two.
44
+
45
+ ---
46
+
47
+ ## What it does
48
+
49
+ Type an idea → it plans → you approve → it builds, tests, and hands you working files.
50
+
51
+ - **Best model per role.** Roles bind to a *capability + tier*, never a model name. A
52
+ swappable registry maps capabilities to models — new frontier model next month, edit one
53
+ place, every route updates. Run a **bake-off** to pick empirically.
54
+ - **A real pipeline.** PM decomposes → Designer specs → Developer writes files → Tester
55
+ **runs the app headless and catches real bugs** → feedback loop re-runs the dev on failure.
56
+ - **Multi-file apps.** Vanilla HTML/CSS/JS or **React (CDN, no build)** — your choice.
57
+ - **The cockpit.** A polished terminal UI: editable board, Kanban, standup, per-task cost,
58
+ live budget bar, desktop notification when done.
59
+ - **Honest cost.** Live spend tracking, per-project budget cap + an alert before the cap,
60
+ and predicted-vs-actual reporting that sharpens itself over real runs.
61
+
62
+ ## Highlights
63
+
64
+ | | |
65
+ |---|---|
66
+ | 🧠 **PM intake** | Vague request? The PM asks 2–4 clarifying questions (with pickable options) before planning. Specific requests skip straight through. |
67
+ | 🏛 **Deep plan (council)** | Opt-in: architect + product + risk leads propose epics in parallel, a synthesizer merges them, you approve, then they expand into the backlog. |
68
+ | 🆚 **Model bake-off** | Run one task across models, an LLM judge scores the outputs, compare cost/latency/quality — save the winner to the registry. |
69
+ | 🧪 **Real test execution** | The tester loads the built app in headless Chromium and fails on JS/console errors — not just by reading the code. |
70
+ | 👁 **Live preview** | Local server + auto-reload; ES modules and fetch resolve like production. |
71
+ | 🚀 **Deploy** | One click to Cloudflare Pages, Vercel, or Netlify (their CLI + your login). |
72
+ | 📤 **Export** | Backlog → Markdown, CSV, **Jira** CSV, **Trello** CSV. |
73
+ | 📜 **Git per build** | The workspace is a git repo; one commit per task. History view + **undo a task**. |
74
+ | 📊 **Analytics** | Retro (with optional AI narrative), burndown, cost-by-epic/model, estimate accuracy, portfolio dashboard. |
75
+ | 💾 **Templates** | Save a project's brief as a reusable template; import/share as a file. |
76
+
77
+ ## How it works
78
+
79
+ ```
80
+ idea
81
+ └─ stack? pick platform + framework (or a saved default)
82
+ └─ intake? PM asks clarifying questions if the request is vague
83
+ └─ plan mode? Quick (one PM) or Deep (planning council → approve epics)
84
+ └─ decompose → a routed, epic-tagged backlog with a cost estimate
85
+ └─ approve → auto-run, or gate the backlog / gate again before dev
86
+ └─ build toposort deps · design → code → test · Tester→Dev feedback loop
87
+ └─ done working files + retro + deploy/export/preview
88
+ ```
89
+
90
+ Each task runs on a real Pi session; Pi reports its own token usage and dollar cost, so
91
+ "actual" cost is authoritative. Estimates live in code (models are bad at guessing their own
92
+ token use) and **self-calibrate** from measured runs.
93
+
94
+ ## Examples
95
+
96
+ Real, unedited output from a full build, kept in [`examples/`](examples/):
97
+
98
+ - **[Tip calculator](examples/tip-calculator/)** — PM → design → 3× code → test, $0.46,
99
+ tester PASS. Three separate files (`index.html`, `styles.css`, `app.js`) that run on
100
+ double-click (`file://`) *and* over http — the regression artifact for the
101
+ ["runs on double-click" guarantee](#caveats-read-before-shipping-publicly).
102
+
103
+ ## CLI (same engine, for scripting/CI)
104
+
105
+ ```bash
106
+ npm start # the cockpit (the normal way to use it)
107
+ npm run build -- --live --mini # cheap end-to-end proof (~$0.10)
108
+ npm run build -- --live --lock anthropic "idea" # full pipeline on one provider
109
+ npm run build -- --live --mini --resume # resume a halted/finished build (skips done tasks)
110
+ npm run bakeoff -- --capability design "Design a pricing page" # model bake-off
111
+ npm test # 134 tests
112
+ npm run typecheck
113
+ ```
114
+
115
+ ## Configuration & data
116
+
117
+ - **Keys** are stored at `~/.projectinator/config.json` (chmod 0600) — never in the repo.
118
+ - **Settings** (in the app): API keys, preferred provider, default workflow, default stack,
119
+ model assignments, budget cap + alert %, estimate accuracy.
120
+ - **User data** lives under `~/.projectinator/` (config, calibration, templates, exports).
121
+ Builds go to `.workspace/` in the repo.
122
+
123
+ ## Caveats (read before shipping publicly)
124
+
125
+ - **It spends your API money.** The cost tracking is honest; keep it visible.
126
+ - **Web-login is experimental and parked.** Driving your paid ChatGPT/Claude/Gemini *web*
127
+ subscriptions violates those providers' ToS and risks your account — vendors actively
128
+ enforce this. It is hidden behind `PROJECTINATOR_WEB=1` and should stay that way. Use the
129
+ API-key path.
130
+ - **React = CDN, no build step** (runs by opening `index.html`). Vite/npm builds and
131
+ mobile/desktop toolchains are future work.
132
+
133
+ ## License
134
+
135
+ MIT — see [LICENSE](LICENSE). © 2026 Stepan Manookian.
136
+
137
+ ## Roadmap
138
+
139
+ See [`TODO.md`](./TODO.md). Internal architecture + how-to: [`docs/INTERNAL.md`](./docs/INTERNAL.md).
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for `projectinator` / `npx github:smanookian/projectinator`.
3
+ // Runs the Ink TUI (TypeScript) through tsx — no build step, no compiled dist.
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname, join } from "node:path";
7
+
8
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const entry = join(root, "src", "tui.tsx");
10
+
11
+ // `node --import tsx <entry>` registers tsx's loader, then runs the TS entry.
12
+ const res = spawnSync(process.execPath, ["--import", "tsx", entry], {
13
+ stdio: "inherit",
14
+ cwd: root,
15
+ });
16
+
17
+ if (res.error) {
18
+ console.error("Failed to launch Projectinator:", res.error.message);
19
+ process.exit(1);
20
+ }
21
+ process.exit(res.status ?? 0);
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "projectinator",
3
+ "version": "0.1.0",
4
+ "description": "Your AI build team in the terminal — hand it an app idea, a PM model plans a Scrum backlog, and the best model per role designs, codes, and tests it into working files. Bring your own API key.",
5
+ "type": "module",
6
+ "private": false,
7
+ "license": "MIT",
8
+ "author": "Stepan Manookian",
9
+ "homepage": "https://github.com/smanookian/projectinator#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/smanookian/projectinator.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/smanookian/projectinator/issues"
16
+ },
17
+ "keywords": [
18
+ "ai",
19
+ "llm",
20
+ "cli",
21
+ "tui",
22
+ "ink",
23
+ "agent",
24
+ "scrum",
25
+ "code-generation",
26
+ "app-builder",
27
+ "anthropic",
28
+ "openai",
29
+ "gemini"
30
+ ],
31
+ "bin": {
32
+ "projectinator": "bin/projectinator.mjs"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "src"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "scripts": {
42
+ "start": "tsx src/tui.tsx",
43
+ "prepublishOnly": "npm run typecheck && npm test",
44
+ "demo": "tsx src/demo.ts",
45
+ "dev:task": "tsx src/run-dev.ts",
46
+ "pm": "tsx src/run-pm.ts",
47
+ "build": "tsx src/run-build.ts",
48
+ "scout": "tsx src/run-scout.ts",
49
+ "research": "tsx src/run-research.ts",
50
+ "web": "tsx src/run-web.ts",
51
+ "bakeoff": "tsx src/run-bakeoff.ts",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest",
54
+ "typecheck": "tsc --noEmit"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^22.10.0",
58
+ "@types/react": "^19.2.17",
59
+ "ink-testing-library": "^4.0.0",
60
+ "typescript": "^5.7.2",
61
+ "vitest": "^2.1.8"
62
+ },
63
+ "dependencies": {
64
+ "@earendil-works/pi-coding-agent": "0.80.7",
65
+ "@inkjs/ui": "2.0.0",
66
+ "ink": "7.1.0",
67
+ "ink-spinner": "5.0.0",
68
+ "playwright": "1.61.1",
69
+ "react": "19.2.7",
70
+ "tsx": "4.23.1",
71
+ "typebox": "1.1.38"
72
+ }
73
+ }
package/src/bakeoff.ts ADDED
@@ -0,0 +1,220 @@
1
+ // Model bake-off — the founding idea: run ONE task across several models, then
2
+ // compare cost, latency, and quality so you can pick the best model per role and
3
+ // feed that back into the routing registry.
4
+ //
5
+ // v1 covers TEXT roles (plan, design, test-reasoning) where the deliverable is
6
+ // text a judge can score. Code bake-off (per-candidate sandbox + real test
7
+ // scoring) is a later step.
8
+
9
+ import {
10
+ AuthStorage,
11
+ ModelRegistry,
12
+ createAgentSession,
13
+ defineTool,
14
+ type AgentSession,
15
+ } from "@earendil-works/pi-coding-agent";
16
+ import { Type, type Static } from "typebox";
17
+ import type { Capability, Difficulty, Provider, Task } from "./types.js";
18
+ import { resolvePiModel } from "./executor.js";
19
+ import { buildRolePrompt } from "./roles.js";
20
+ import { estimateTokens } from "./estimate.js";
21
+ import { addSessionCost } from "./session-cost.js";
22
+
23
+ export interface Candidate {
24
+ provider: Provider;
25
+ model: string;
26
+ }
27
+
28
+ export interface BakeoffEntry {
29
+ provider: Provider;
30
+ model: string;
31
+ output: string;
32
+ cost: number;
33
+ ms: number;
34
+ outputTokens: number;
35
+ error?: string;
36
+ }
37
+
38
+ export interface JudgeScore {
39
+ model: string;
40
+ score: number; // 0-10
41
+ reason: string;
42
+ }
43
+
44
+ export interface BakeoffResult {
45
+ task: Task;
46
+ entries: BakeoffEntry[];
47
+ scores: JudgeScore[];
48
+ winner?: string; // "provider/model"
49
+ judge?: string; // judge model id
50
+ }
51
+
52
+ function lastAssistantText(session: AgentSession): string {
53
+ const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
54
+ for (let i = msgs.length - 1; i >= 0; i--) {
55
+ const m = msgs[i];
56
+ if (m?.role !== "assistant") continue;
57
+ const c = m.content;
58
+ if (typeof c === "string") return c;
59
+ if (Array.isArray(c)) {
60
+ return c
61
+ .map((p: unknown) => (typeof p === "string" ? p : p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : ""))
62
+ .join("")
63
+ .trim();
64
+ }
65
+ }
66
+ return "";
67
+ }
68
+
69
+ const id = (c: Candidate) => `${c.provider}/${c.model}`;
70
+
71
+ /** Run one candidate on the task, capturing output, cost, and latency. */
72
+ async function runCandidate(task: Task, cand: Candidate): Promise<BakeoffEntry> {
73
+ const base: BakeoffEntry = { provider: cand.provider, model: cand.model, output: "", cost: 0, ms: 0, outputTokens: 0 };
74
+ try {
75
+ const authStorage = AuthStorage.create();
76
+ const registry = ModelRegistry.create(authStorage);
77
+ const model = resolvePiModel(registry, cand.provider, cand.model);
78
+ const { session } = await createAgentSession({
79
+ model,
80
+ authStorage,
81
+ modelRegistry: registry,
82
+ thinkingLevel: "medium",
83
+ noTools: "all",
84
+ });
85
+ try {
86
+ const t0 = Date.now();
87
+ await session.prompt(buildRolePrompt(task, ""));
88
+ const ms = Date.now() - t0;
89
+ const stats = session.getSessionStats();
90
+ addSessionCost(stats.cost);
91
+ const out: BakeoffEntry = {
92
+ ...base,
93
+ output: lastAssistantText(session),
94
+ cost: Math.round(stats.cost * 10000) / 10000,
95
+ ms,
96
+ outputTokens: stats.tokens.output,
97
+ };
98
+ if (stats.tokens.total === 0) out.error = "returned 0 tokens (key likely can't access this model)";
99
+ return out;
100
+ } finally {
101
+ session.dispose(); // dispose even when prompt() throws (expected for inaccessible models)
102
+ }
103
+ } catch (e) {
104
+ return { ...base, error: e instanceof Error ? e.message : String(e) };
105
+ }
106
+ }
107
+
108
+ // ---- judge: score every output on one rubric, forced structured output ----
109
+
110
+ const JudgeSchema = Type.Object(
111
+ {
112
+ scores: Type.Array(
113
+ Type.Object({
114
+ option: Type.String({ description: "the option letter, e.g. A" }),
115
+ score: Type.Number({ description: "0-10 quality for this deliverable" }),
116
+ reason: Type.String({ description: "one sentence" }),
117
+ }),
118
+ ),
119
+ winner: Type.String({ description: "the option letter of the best output" }),
120
+ },
121
+ { additionalProperties: true },
122
+ );
123
+ type JudgeRaw = Static<typeof JudgeSchema>;
124
+
125
+ function buildJudgeTool() {
126
+ let captured: JudgeRaw | undefined;
127
+ const tool = defineTool({
128
+ name: "submit_scores",
129
+ label: "Submit Scores",
130
+ description: "Submit a 0-10 quality score and one-sentence reason for every option, plus the winning option letter.",
131
+ parameters: JudgeSchema,
132
+ execute: async (_id, params: JudgeRaw) => {
133
+ captured = params;
134
+ return { content: [{ type: "text", text: `Scored ${params.scores.length} options; winner ${params.winner}.` }], details: {} };
135
+ },
136
+ });
137
+ return { tool, get: () => captured };
138
+ }
139
+
140
+ /** Judge anonymised outputs (A, B, C…) on one rubric for the task's capability. */
141
+ async function judge(task: Task, entries: BakeoffEntry[], judgeCand: Candidate): Promise<{ scores: JudgeScore[]; winner?: string; judgeId: string }> {
142
+ const scored = entries.filter((e) => !e.error && e.output);
143
+ if (scored.length < 2) return { scores: [], winner: undefined, judgeId: id(judgeCand) };
144
+
145
+ const letters = scored.map((_, i) => String.fromCharCode(65 + i)); // A, B, C…
146
+ const blocks = scored.map((e, i) => `### Option ${letters[i]}\n${e.output}`).join("\n\n");
147
+ const authStorage = AuthStorage.create();
148
+ const registry = ModelRegistry.create(authStorage);
149
+ const model = resolvePiModel(registry, judgeCand.provider, judgeCand.model);
150
+ const { tool, get } = buildJudgeTool();
151
+ const { session } = await createAgentSession({
152
+ model,
153
+ authStorage,
154
+ modelRegistry: registry,
155
+ thinkingLevel: "medium",
156
+ noTools: "all",
157
+ customTools: [tool],
158
+ tools: ["submit_scores"],
159
+ });
160
+
161
+ const prompt = [
162
+ `You are judging ${scored.length} anonymous attempts at the same ${task.capability} task. Be a strict, fair critic.`,
163
+ `Task: ${task.title}`,
164
+ "",
165
+ `Score each option 0-10 on how well it delivers a high-quality ${task.capability} result (correctness, completeness, clarity, usefulness). Then pick the single best.`,
166
+ "Call submit_scores exactly once with a score+reason for EVERY option letter and the winner.",
167
+ "",
168
+ blocks,
169
+ ].join("\n");
170
+
171
+ try {
172
+ await session.prompt(prompt);
173
+ let raw = get();
174
+ for (let i = 0; i < 2 && !raw; i++) {
175
+ await session.prompt("Call submit_scores now with a score for every option letter and the winner.");
176
+ raw = get();
177
+ }
178
+ addSessionCost(session.getSessionStats().cost);
179
+ if (!raw) return { scores: [], winner: undefined, judgeId: id(judgeCand) };
180
+
181
+ const byLetter = new Map(letters.map((l, i) => [l, scored[i]!]));
182
+ const scores: JudgeScore[] = raw.scores
183
+ .map((s) => {
184
+ const e = byLetter.get(s.option.trim().toUpperCase().slice(0, 1));
185
+ return e ? { model: id(e), score: s.score, reason: s.reason } : undefined;
186
+ })
187
+ .filter((x): x is JudgeScore => !!x);
188
+ const winEntry = byLetter.get(String(raw.winner).trim().toUpperCase().slice(0, 1));
189
+ return { scores, winner: winEntry ? id(winEntry) : undefined, judgeId: id(judgeCand) };
190
+ } finally {
191
+ session.dispose();
192
+ }
193
+ }
194
+
195
+ export interface BakeoffOptions {
196
+ /** Model that scores the outputs. Defaults to the first candidate. */
197
+ judge?: Candidate;
198
+ onProgress?: (msg: string) => void;
199
+ }
200
+
201
+ /** Run the full bake-off: every candidate on the task, then judge. */
202
+ export async function runBakeoff(task: Task, candidates: Candidate[], opts: BakeoffOptions = {}): Promise<BakeoffResult> {
203
+ const log = opts.onProgress ?? (() => {});
204
+ const entries: BakeoffEntry[] = [];
205
+ for (const c of candidates) {
206
+ log(`running ${id(c)}…`);
207
+ const e = await runCandidate(task, c);
208
+ log(e.error ? ` ${id(c)}: ERROR ${e.error}` : ` ${id(c)}: $${e.cost.toFixed(4)} ${(e.ms / 1000).toFixed(1)}s ${e.outputTokens} tok`);
209
+ entries.push(e);
210
+ }
211
+ const judgeCand = opts.judge ?? candidates[0]!;
212
+ log(`judging with ${id(judgeCand)}…`);
213
+ const { scores, winner, judgeId } = await judge(task, entries, judgeCand);
214
+ return { task, entries, scores, winner, judge: judgeId };
215
+ }
216
+
217
+ /** Convenience: build a one-off Task for a capability/difficulty from a prompt. */
218
+ export function bakeoffTask(prompt: string, capability: Capability, difficulty: Difficulty = "medium"): Task {
219
+ return { id: "BAKE", title: prompt, capability, difficulty, dependsOn: [], estTokens: estimateTokens(capability, difficulty) };
220
+ }
@@ -0,0 +1,46 @@
1
+ // Build persistence — checkpoint a run so a halt/crash/cancel can resume without
2
+ // re-paying for finished tasks. The orchestrator itself stays fs-free; this module
3
+ // (and run-build) own the disk I/O.
4
+
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import type { Task, TaskOutcome } from "./types.js";
7
+
8
+ export interface BuildState {
9
+ id: string;
10
+ /** The original idea/request text, for display in the projects list. */
11
+ idea?: string;
12
+ /** Workflow used: auto-run, or approval-gated. */
13
+ mode?: "auto" | "approval";
14
+ tasks: Task[];
15
+ /** Full record of every task run, including feedback-loop retries (append-only). */
16
+ outcomes: TaskOutcome[];
17
+ totalCost: number;
18
+ status: "running" | "complete" | "halted";
19
+ haltReason?: string;
20
+ /** Per-project budget cap (USD). Overrides the global default when set. */
21
+ budgetCapUSD?: number;
22
+ /** Cached AI retro narrative (generated on demand). */
23
+ retroNarrative?: string;
24
+ }
25
+
26
+ export function newBuildState(id: string, tasks: Task[], idea?: string, mode?: "auto" | "approval"): BuildState {
27
+ return { id, idea, mode, tasks, outcomes: [], totalCost: 0, status: "running" };
28
+ }
29
+
30
+ export function saveState(state: BuildState, path: string): void {
31
+ writeFileSync(path, JSON.stringify(state, null, 2) + "\n");
32
+ }
33
+
34
+ export function loadState(path: string): BuildState | undefined {
35
+ if (!existsSync(path)) return undefined;
36
+ try {
37
+ return JSON.parse(readFileSync(path, "utf-8")) as BuildState;
38
+ } catch (e) {
39
+ throw new Error(`Bad build state at ${path}: ${e instanceof Error ? e.message : e}`);
40
+ }
41
+ }
42
+
43
+ /** Which task ids are already finished (last outcome wins). Used to skip on resume. */
44
+ export function completedIds(state: BuildState): Set<string> {
45
+ return new Set(state.outcomes.map((o) => o.taskId));
46
+ }
@@ -0,0 +1,35 @@
1
+ // Burndown — tasks remaining and cumulative spend across the build. There are no
2
+ // timestamps in build-state, so the X axis is task-completion order (step 1..N),
3
+ // which is the natural timeline for a build. Retries add a step (and cost) without
4
+ // burning down a task, so they show up as flat-remaining / rising-cost.
5
+
6
+ import type { BuildState } from "./build-state.js";
7
+
8
+ export interface BurndownStep {
9
+ taskId: string;
10
+ remaining: number; // distinct tasks still to do after this step
11
+ cumCost: number; // cumulative spend through this step
12
+ retry: boolean; // this step re-ran an already-done task
13
+ }
14
+
15
+ export interface Burndown {
16
+ taskCount: number;
17
+ totalCost: number;
18
+ steps: BurndownStep[];
19
+ }
20
+
21
+ const round2 = (n: number) => Math.round(n * 100) / 100;
22
+
23
+ export function computeBurndown(state: BuildState): Burndown {
24
+ const taskCount = state.tasks.length;
25
+ const done = new Set<string>();
26
+ let cum = 0;
27
+ const steps: BurndownStep[] = [];
28
+ for (const o of state.outcomes) {
29
+ const retry = done.has(o.taskId);
30
+ done.add(o.taskId);
31
+ cum += o.cost;
32
+ steps.push({ taskId: o.taskId, remaining: taskCount - done.size, cumCost: round2(cum), retry });
33
+ }
34
+ return { taskCount, totalCost: round2(cum), steps };
35
+ }
@@ -0,0 +1,88 @@
1
+ // Self-calibrating token estimates. After each real task, we record its measured
2
+ // token usage per (capability, difficulty). estimateTokens uses the running average
3
+ // once there are enough samples, so estimates sharpen with use. Persisted globally.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import type { Capability, Difficulty } from "./types.js";
9
+
10
+ interface Sample {
11
+ input: number; // total input tokens (fresh + cache-read)
12
+ output: number;
13
+ cachedFraction: number; // share of input served from cache
14
+ n: number; // sample count (capped so recent runs still move the average)
15
+ }
16
+
17
+ type Calibration = Record<string, Sample>;
18
+
19
+ const MIN_SAMPLES = 2; // trust calibration only after a couple of runs
20
+ const MAX_N = 20; // cap so old runs don't dominate
21
+
22
+ function calPath(): string {
23
+ return join(homedir(), ".projectinator", "calibration.json");
24
+ }
25
+ const key = (c: Capability, d: Difficulty) => `${c}/${d}`;
26
+
27
+ function load(): Calibration {
28
+ try {
29
+ if (!existsSync(calPath())) return {};
30
+ return JSON.parse(readFileSync(calPath(), "utf-8")) as Calibration;
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function save(cal: Calibration): void {
37
+ try {
38
+ mkdirSync(join(homedir(), ".projectinator"), { recursive: true });
39
+ writeFileSync(calPath(), JSON.stringify(cal, null, 2) + "\n");
40
+ } catch {
41
+ /* best effort — never break a build on a calibration write */
42
+ }
43
+ }
44
+
45
+ /** Fold a real measurement into the running average for its bucket. */
46
+ export function recordActual(
47
+ capability: Capability,
48
+ difficulty: Difficulty,
49
+ inputTotal: number,
50
+ output: number,
51
+ cachedFraction: number,
52
+ ): void {
53
+ if (!(inputTotal > 0)) return;
54
+ const cal = load();
55
+ const k = key(capability, difficulty);
56
+ const prev = cal[k];
57
+ if (!prev) {
58
+ cal[k] = { input: inputTotal, output, cachedFraction, n: 1 };
59
+ } else {
60
+ const n = Math.min(prev.n, MAX_N);
61
+ cal[k] = {
62
+ input: (prev.input * n + inputTotal) / (n + 1),
63
+ output: (prev.output * n + output) / (n + 1),
64
+ cachedFraction: (prev.cachedFraction * n + cachedFraction) / (n + 1),
65
+ n: prev.n + 1,
66
+ };
67
+ }
68
+ save(cal);
69
+ }
70
+
71
+ /** All recorded samples keyed "capability/difficulty" (for the accuracy view). */
72
+ export function allSamples(): Record<string, { input: number; output: number; cachedFraction: number; n: number }> {
73
+ return load();
74
+ }
75
+
76
+ /** Calibrated estimate for a bucket, once enough samples exist. */
77
+ export function calibratedTokens(
78
+ capability: Capability,
79
+ difficulty: Difficulty,
80
+ ): { input: number; output: number; cachedInputFraction: number } | undefined {
81
+ const s = load()[key(capability, difficulty)];
82
+ if (!s || s.n < MIN_SAMPLES) return undefined;
83
+ return {
84
+ input: Math.round(s.input),
85
+ output: Math.round(s.output),
86
+ cachedInputFraction: Math.min(0.95, Math.max(0, s.cachedFraction)),
87
+ };
88
+ }