projectinator 0.3.1 → 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.
Files changed (171) hide show
  1. package/README.md +7 -6
  2. package/bin/projectinator.mjs +22 -15
  3. package/dist/bakeoff.js +163 -0
  4. package/dist/bakeoff.js.map +1 -0
  5. package/dist/build-state.js +26 -0
  6. package/dist/build-state.js.map +1 -0
  7. package/dist/burndown.js +19 -0
  8. package/dist/burndown.js.map +1 -0
  9. package/dist/calibration.js +86 -0
  10. package/dist/calibration.js.map +1 -0
  11. package/dist/cli.js +289 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/cost.js +35 -0
  14. package/dist/cost.js.map +1 -0
  15. package/dist/council.js +147 -0
  16. package/dist/council.js.map +1 -0
  17. package/dist/demo.js +93 -0
  18. package/dist/demo.js.map +1 -0
  19. package/dist/estimate.js +98 -0
  20. package/dist/estimate.js.map +1 -0
  21. package/dist/executor.js +118 -0
  22. package/dist/executor.js.map +1 -0
  23. package/dist/git.js +82 -0
  24. package/dist/git.js.map +1 -0
  25. package/dist/intake.js +98 -0
  26. package/dist/intake.js.map +1 -0
  27. package/dist/models.js +171 -0
  28. package/dist/models.js.map +1 -0
  29. package/dist/narrate.js +76 -0
  30. package/dist/narrate.js.map +1 -0
  31. package/dist/openrouter.js +112 -0
  32. package/dist/openrouter.js.map +1 -0
  33. package/dist/orchestrator.js +273 -0
  34. package/dist/orchestrator.js.map +1 -0
  35. package/dist/pm.js +245 -0
  36. package/dist/pm.js.map +1 -0
  37. package/dist/preview.js +194 -0
  38. package/dist/preview.js.map +1 -0
  39. package/dist/registry-store.js +38 -0
  40. package/dist/registry-store.js.map +1 -0
  41. package/dist/registry.js +115 -0
  42. package/dist/registry.js.map +1 -0
  43. package/dist/research.js +98 -0
  44. package/dist/research.js.map +1 -0
  45. package/dist/retro.js +83 -0
  46. package/dist/retro.js.map +1 -0
  47. package/dist/roles.js +357 -0
  48. package/dist/roles.js.map +1 -0
  49. package/dist/router.js +95 -0
  50. package/dist/router.js.map +1 -0
  51. package/dist/run-bakeoff.js +68 -0
  52. package/dist/run-bakeoff.js.map +1 -0
  53. package/dist/run-build.js +193 -0
  54. package/dist/run-build.js.map +1 -0
  55. package/{src/run-dev.ts → dist/run-dev.js} +23 -39
  56. package/dist/run-dev.js.map +1 -0
  57. package/{src/run-pm.ts → dist/run-pm.js} +34 -46
  58. package/dist/run-pm.js.map +1 -0
  59. package/{src/run-research.ts → dist/run-research.js} +19 -30
  60. package/dist/run-research.js.map +1 -0
  61. package/{src/run-scout.ts → dist/run-scout.js} +17 -27
  62. package/dist/run-scout.js.map +1 -0
  63. package/dist/run-web.js +93 -0
  64. package/dist/run-web.js.map +1 -0
  65. package/dist/scout.js +81 -0
  66. package/dist/scout.js.map +1 -0
  67. package/dist/session-cost.js +15 -0
  68. package/dist/session-cost.js.map +1 -0
  69. package/dist/stack.js +34 -0
  70. package/dist/stack.js.map +1 -0
  71. package/dist/stuck.js +21 -0
  72. package/dist/stuck.js.map +1 -0
  73. package/dist/tui/App.js +1269 -0
  74. package/dist/tui/App.js.map +1 -0
  75. package/dist/tui/BakeOff.js +95 -0
  76. package/dist/tui/BakeOff.js.map +1 -0
  77. package/dist/tui/BoardEditor.js +183 -0
  78. package/dist/tui/BoardEditor.js.map +1 -0
  79. package/dist/tui/EditableBoard.js +140 -0
  80. package/dist/tui/EditableBoard.js.map +1 -0
  81. package/dist/tui/Frame.js +78 -0
  82. package/dist/tui/Frame.js.map +1 -0
  83. package/dist/tui/Intake.js +59 -0
  84. package/dist/tui/Intake.js.map +1 -0
  85. package/dist/tui/Kanban.js +75 -0
  86. package/dist/tui/Kanban.js.map +1 -0
  87. package/dist/tui/Settings.js +299 -0
  88. package/dist/tui/Settings.js.map +1 -0
  89. package/dist/tui/StackPick.js +44 -0
  90. package/dist/tui/StackPick.js.map +1 -0
  91. package/dist/tui/WebAccounts.js +131 -0
  92. package/dist/tui/WebAccounts.js.map +1 -0
  93. package/dist/tui/components.js +178 -0
  94. package/dist/tui/components.js.map +1 -0
  95. package/dist/tui/config.js +129 -0
  96. package/dist/tui/config.js.map +1 -0
  97. package/dist/tui/deploy.js +114 -0
  98. package/dist/tui/deploy.js.map +1 -0
  99. package/dist/tui/engine.js +710 -0
  100. package/dist/tui/engine.js.map +1 -0
  101. package/dist/tui/notify.js +47 -0
  102. package/dist/tui/notify.js.map +1 -0
  103. package/dist/tui/panels.js +42 -0
  104. package/dist/tui/panels.js.map +1 -0
  105. package/dist/tui/templates.js +106 -0
  106. package/dist/tui/templates.js.map +1 -0
  107. package/dist/tui/theme.js +42 -0
  108. package/dist/tui/theme.js.map +1 -0
  109. package/dist/tui/validate.js +51 -0
  110. package/dist/tui/validate.js.map +1 -0
  111. package/{src/tui.tsx → dist/tui.js} +23 -33
  112. package/dist/tui.js.map +1 -0
  113. package/dist/types.js +19 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/web/oauth-anthropic.js +179 -0
  116. package/dist/web/oauth-anthropic.js.map +1 -0
  117. package/dist/web/session.js +274 -0
  118. package/dist/web/session.js.map +1 -0
  119. package/package.json +5 -4
  120. package/src/bakeoff.ts +0 -214
  121. package/src/build-state.ts +0 -47
  122. package/src/burndown.ts +0 -35
  123. package/src/calibration.ts +0 -88
  124. package/src/cli.ts +0 -263
  125. package/src/cost.ts +0 -43
  126. package/src/council.ts +0 -175
  127. package/src/demo.ts +0 -106
  128. package/src/estimate.ts +0 -111
  129. package/src/executor.ts +0 -169
  130. package/src/git.ts +0 -72
  131. package/src/intake.ts +0 -125
  132. package/src/models.ts +0 -175
  133. package/src/narrate.ts +0 -87
  134. package/src/openrouter.ts +0 -119
  135. package/src/orchestrator.ts +0 -310
  136. package/src/pm.ts +0 -302
  137. package/src/preview.ts +0 -206
  138. package/src/registry-store.ts +0 -41
  139. package/src/registry.ts +0 -132
  140. package/src/research.ts +0 -123
  141. package/src/retro.ts +0 -99
  142. package/src/roles.ts +0 -401
  143. package/src/router.ts +0 -120
  144. package/src/run-bakeoff.ts +0 -77
  145. package/src/run-build.ts +0 -203
  146. package/src/run-web.ts +0 -87
  147. package/src/scout.ts +0 -121
  148. package/src/session-cost.ts +0 -17
  149. package/src/stack.ts +0 -46
  150. package/src/tui/App.tsx +0 -1760
  151. package/src/tui/BakeOff.tsx +0 -190
  152. package/src/tui/BoardEditor.tsx +0 -260
  153. package/src/tui/EditableBoard.tsx +0 -179
  154. package/src/tui/Frame.tsx +0 -142
  155. package/src/tui/Intake.tsx +0 -111
  156. package/src/tui/Kanban.tsx +0 -158
  157. package/src/tui/Settings.tsx +0 -513
  158. package/src/tui/StackPick.tsx +0 -79
  159. package/src/tui/WebAccounts.tsx +0 -197
  160. package/src/tui/components.tsx +0 -340
  161. package/src/tui/config.ts +0 -150
  162. package/src/tui/deploy.ts +0 -137
  163. package/src/tui/engine.ts +0 -749
  164. package/src/tui/notify.ts +0 -21
  165. package/src/tui/panels.tsx +0 -89
  166. package/src/tui/templates.ts +0 -119
  167. package/src/tui/theme.ts +0 -44
  168. package/src/tui/validate.ts +0 -53
  169. package/src/types.ts +0 -208
  170. package/src/web/oauth-anthropic.ts +0 -206
  171. package/src/web/session.ts +0 -299
package/src/cost.ts DELETED
@@ -1,43 +0,0 @@
1
- // Cost estimation. Pure math over a token estimate + a model's price block.
2
-
3
- import type { Model, TokenEstimate } from "./types.js";
4
-
5
- const PER_MILLION = 1_000_000;
6
-
7
- /** Resolve the applicable input/output/cache rates, honoring volume tiers.
8
- * Highest matching `inputTokensAbove` threshold wins (Pi semantics). */
9
- function ratesFor(model: Model, inputTokens: number) {
10
- const base = model.cost;
11
- let rate = { input: base.input, output: base.output, cacheRead: base.cacheRead ?? base.input };
12
- for (const tier of base.tiers ?? []) {
13
- if (inputTokens > tier.inputTokensAbove) {
14
- rate = {
15
- input: tier.input,
16
- output: tier.output,
17
- cacheRead: tier.cacheRead ?? tier.input,
18
- };
19
- }
20
- }
21
- return rate;
22
- }
23
-
24
- /** Estimate USD cost for one task on a given model.
25
- * A fraction of input can be served from cache at the cheaper cacheRead rate.
26
- * Kept to 4 decimals, not cents: a backlog of sub-cent tasks would otherwise
27
- * estimate to $0.00 and under-reserve budget in the parallel scheduler. */
28
- export function estimateCost(est: TokenEstimate, model: Model): number {
29
- const cachedFraction = clamp01(est.cachedInputFraction ?? 0);
30
- const rate = ratesFor(model, est.input);
31
-
32
- const freshInput = est.input * (1 - cachedFraction);
33
- const cachedInput = est.input * cachedFraction;
34
-
35
- const inputCost = (freshInput / PER_MILLION) * rate.input + (cachedInput / PER_MILLION) * rate.cacheRead;
36
- const outputCost = (est.output / PER_MILLION) * rate.output;
37
-
38
- return Math.round((inputCost + outputCost) * 10_000) / 10_000;
39
- }
40
-
41
- function clamp01(n: number): number {
42
- return Math.max(0, Math.min(1, n));
43
- }
package/src/council.ts DELETED
@@ -1,175 +0,0 @@
1
- // Council planning (opt-in "deep plan"). Three perspectives propose epics from
2
- // different lenses in parallel, then a synthesizer merges them into one ordered
3
- // epic list. The user approves the epics; expansion into tasks happens after,
4
- // via the normal decomposer seeded with these epics.
5
-
6
- import {
7
- createAgentSession,
8
- defineTool,
9
- type AgentSession,
10
- } from "@earendil-works/pi-coding-agent";
11
- import { Type, type Static } from "typebox";
12
- import type { Backend, Provider } from "./types.js";
13
- import { findEntry } from "./registry.js";
14
- import { piRuntime, resolvePiModel } from "./executor.js"
15
- import { addSessionCost } from "./session-cost.js";
16
-
17
- export interface Epic {
18
- name: string;
19
- rationale: string;
20
- }
21
-
22
- const EpicsSchema = Type.Object(
23
- {
24
- epics: Type.Array(
25
- Type.Object(
26
- { name: Type.String(), rationale: Type.String({ description: "one line: why this epic exists" }) },
27
- { additionalProperties: true },
28
- ),
29
- ),
30
- },
31
- { additionalProperties: true },
32
- );
33
- type EpicsRaw = Static<typeof EpicsSchema>;
34
-
35
- function buildEpicsTool(toolName: string) {
36
- let captured: EpicsRaw | undefined;
37
- const tool = defineTool({
38
- name: toolName,
39
- label: "Submit Epics",
40
- description: "Submit the epic list. Call exactly once.",
41
- parameters: EpicsSchema,
42
- execute: async (_id, params) => {
43
- captured = params as EpicsRaw;
44
- return { content: [{ type: "text", text: `Got ${params.epics.length} epics.` }], details: {} };
45
- },
46
- });
47
- return { tool, get: () => captured };
48
- }
49
-
50
- function lastAssistantText(session: AgentSession): string {
51
- const msgs = session.messages as Array<{ role?: string; content?: unknown }>;
52
- for (let i = msgs.length - 1; i >= 0; i--) {
53
- const m = msgs[i];
54
- if (m?.role !== "assistant") continue;
55
- const c = m.content;
56
- if (typeof c === "string") return c;
57
- if (Array.isArray(c)) return c.map((p: unknown) => (p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : "")).join("");
58
- }
59
- return "";
60
- }
61
-
62
- interface Ctx {
63
- backend: Backend;
64
- modelOverride?: { provider: Provider; model: string };
65
- }
66
-
67
- async function runEpicAgent(idea: string, system: string, toolName: string, ctx: Ctx): Promise<Epic[]> {
68
- const runtime = await piRuntime();
69
- const { entry } = findEntry("plan", "mid");
70
- const pick = ctx.modelOverride ?? entry.byBackend[ctx.backend];
71
- try {
72
- const model = resolvePiModel(runtime, pick.provider, pick.model);
73
- const { tool, get } = buildEpicsTool(toolName);
74
- const { session } = await createAgentSession({
75
- model,
76
- modelRuntime: runtime,
77
- thinkingLevel: "low",
78
- noTools: "all",
79
- customTools: [tool],
80
- tools: [toolName],
81
- });
82
- try {
83
- await session.prompt(`${system}\n\n--- REQUEST ---\n${idea}`);
84
- let raw = get();
85
- if (!raw) { await session.prompt(`Call ${toolName} now.`); raw = get(); }
86
- addSessionCost(session.getSessionStats().cost);
87
- return (raw?.epics ?? [])
88
- .map((e) => ({ name: String(e.name ?? "").trim(), rationale: String(e.rationale ?? "").trim() }))
89
- .filter((e) => e.name);
90
- } finally {
91
- session.dispose();
92
- }
93
- } catch {
94
- return [];
95
- }
96
- }
97
-
98
- const LENSES: { key: string; system: string }[] = [
99
- {
100
- key: "architect",
101
- system:
102
- "You are the ARCHITECT on a planning council. Propose the EPICS (big areas of work) for this build " +
103
- "from a TECHNICAL structure lens — components, data, integration, scaffolding. 3-6 epics, each a short " +
104
- "name + one-line rationale. Call submit_epics_architect once.",
105
- },
106
- {
107
- key: "product",
108
- system:
109
- "You are the PRODUCT lead on a planning council. Propose the EPICS from a USER-VALUE lens — the features " +
110
- "and flows a user needs, in priority order. 3-6 epics, each a short name + one-line rationale. Call " +
111
- "submit_epics_product once.",
112
- },
113
- {
114
- key: "risk",
115
- system:
116
- "You are the RISK/QA lead on a planning council. Propose the EPICS from a WHAT-COULD-GO-WRONG lens — " +
117
- "validation, edge cases, error/empty states, testing, and anything easy to forget. 3-6 epics, each a " +
118
- "short name + one-line rationale. Call submit_epics_risk once.",
119
- },
120
- ];
121
-
122
- const SYNTH_SYSTEM = [
123
- "You are the PROJECT MANAGER chairing a planning council. Three leads proposed epics from different lenses",
124
- "(architect, product, risk). Merge them into ONE clean, ordered epic list for the build: dedupe overlaps,",
125
- "keep what matters, drop noise, and order them the way the work should proceed. Aim for 3-7 epics. Each epic:",
126
- "a short name + a one-line rationale that folds in the strongest point(s) from the leads. Call submit_epics once.",
127
- ].join("\n");
128
-
129
- export interface CouncilResult {
130
- epics: Epic[];
131
- proposals: { lens: string; epics: Epic[] }[];
132
- }
133
-
134
- /** Run the council: 3 lenses in parallel, then synthesize. Falls back to a single
135
- * lens's epics if synthesis fails; empty only if everything fails. */
136
- export async function councilEpics(idea: string, ctx: Ctx): Promise<CouncilResult> {
137
- const proposalsRaw = await Promise.all(
138
- LENSES.map((l) => runEpicAgent(idea, l.system, `submit_epics_${l.key}`, ctx).then((epics) => ({ lens: l.key, epics }))),
139
- );
140
- const proposals = proposalsRaw.filter((p) => p.epics.length);
141
- if (!proposals.length) return { epics: [], proposals: [] };
142
-
143
- // Synthesize.
144
- const runtime = await piRuntime();
145
- const { entry } = findEntry("plan", "mid");
146
- const pick = ctx.modelOverride ?? entry.byBackend[ctx.backend];
147
- try {
148
- const model = resolvePiModel(runtime, pick.provider, pick.model);
149
- const { tool, get } = buildEpicsTool("submit_epics");
150
- const { session } = await createAgentSession({
151
- model, modelRuntime: runtime, thinkingLevel: "low",
152
- noTools: "all", customTools: [tool], tools: ["submit_epics"],
153
- });
154
- try {
155
- const block = proposals
156
- .map((p) => `### ${p.lens}\n${p.epics.map((e) => `- ${e.name}: ${e.rationale}`).join("\n")}`)
157
- .join("\n\n");
158
- await session.prompt(`${SYNTH_SYSTEM}\n\n--- REQUEST ---\n${idea}\n\n--- LEAD PROPOSALS ---\n${block}`);
159
- let raw = get();
160
- if (!raw) { await session.prompt("Call submit_epics now."); raw = get(); }
161
- addSessionCost(session.getSessionStats().cost);
162
- const epics = (raw?.epics ?? [])
163
- .map((e) => ({ name: String(e.name ?? "").trim(), rationale: String(e.rationale ?? "").trim() }))
164
- .filter((e) => e.name);
165
- // Fall back to the largest single proposal if synthesis produced nothing.
166
- const best = proposals.slice().sort((a, b) => b.epics.length - a.epics.length)[0]!.epics;
167
- return { epics: epics.length ? epics : best, proposals };
168
- } finally {
169
- session.dispose();
170
- }
171
- } catch {
172
- const best = proposals.slice().sort((a, b) => b.epics.length - a.epics.length)[0]!.epics;
173
- return { epics: best, proposals };
174
- }
175
- }
package/src/demo.ts DELETED
@@ -1,106 +0,0 @@
1
- // Phase 1 demo — proves the brain before wiring any agents.
2
- // Feeds a fake "landing page + contact form" backlog through the router and prints
3
- // backend, model, and estimated cost per task, plus the total.
4
- //
5
- // Run: npm run demo (cost-first / web backend)
6
- // npm run demo -- api (force API backend)
7
- // npm run demo -- ask (interactive-style; here stubbed to demonstrate)
8
-
9
- import { fileURLToPath } from "node:url";
10
- import { dirname, join } from "node:path";
11
- import type { RoutingPolicy, Task } from "./types.js";
12
- import { DEFAULT_POLICY, routeBacklog } from "./router.js";
13
- import { loadRegistry, OVERRIDES_FILENAME } from "./registry-store.js";
14
-
15
- const BACKLOG: Task[] = [
16
- {
17
- id: "T-01", epic: "E-1", story: "S-1", title: "Break brief into backlog",
18
- capability: "plan", difficulty: "medium",
19
- estTokens: { input: 10_000, output: 5_000 },
20
- },
21
- {
22
- id: "T-02", epic: "E-1", story: "S-2", title: "Design hero + layout system",
23
- capability: "design", difficulty: "high",
24
- estTokens: { input: 20_000, output: 15_000 },
25
- },
26
- {
27
- id: "T-03", epic: "E-2", story: "S-3", title: "Build page + contact form",
28
- capability: "code", difficulty: "high", dependsOn: ["T-02"],
29
- estTokens: { input: 400_000, output: 60_000, cachedInputFraction: 0.6 },
30
- },
31
- {
32
- id: "T-04", epic: "E-2", story: "S-4", title: "Wire form handler",
33
- capability: "code", difficulty: "low", dependsOn: ["T-03"],
34
- estTokens: { input: 30_000, output: 8_000, cachedInputFraction: 0.5 },
35
- },
36
- {
37
- id: "T-05", epic: "E-3", story: "S-5", title: "Review + test the build",
38
- capability: "test", difficulty: "trivial", dependsOn: ["T-03", "T-04"],
39
- estTokens: { input: 80_000, output: 15_000 },
40
- },
41
- {
42
- id: "T-06", epic: "E-3", story: "S-6", title: "Run it, drive the CI/preview",
43
- capability: "ops", difficulty: "high", dependsOn: ["T-04"],
44
- estTokens: { input: 100_000, output: 20_000, cachedInputFraction: 0.4 },
45
- },
46
- ];
47
-
48
- const arg = process.argv[2];
49
- const policy: RoutingPolicy = { ...DEFAULT_POLICY };
50
- if (arg === "api") policy.backendMode = "api";
51
- if (arg === "web") policy.backendMode = "web";
52
-
53
- // For "ask" mode we'd wire real prompts; here we stub a chooser so the demo runs headless.
54
- const prompts =
55
- arg === "ask"
56
- ? {
57
- chooseBackend: () => "api" as const,
58
- chooseModel: (_t: Task, _e: unknown, _b: unknown) => undefined, // accept defaults
59
- }
60
- : undefined;
61
- if (arg === "ask") policy.backendMode = "ask";
62
-
63
- // Load the effective registry (seed + any Scout overrides), so applied Scout
64
- // changes are reflected here.
65
- const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
66
- const registry = loadRegistry(join(projectRoot, OVERRIDES_FILENAME));
67
-
68
- const decisions = routeBacklog(BACKLOG, { policy, registry, prompts });
69
-
70
- const pad = (s: string, n: number) => s.padEnd(n);
71
- const money = (n: number) => `$${n.toFixed(2)}`;
72
-
73
- console.log(`\n Projectinator — routing plan [backendMode=${policy.backendMode}, cap=${money(policy.budgetCapUSD)}]\n`);
74
- console.log(
75
- " " +
76
- pad("TASK", 7) + pad("CAPABILITY", 11) + pad("DIFF", 9) +
77
- pad("BACKEND", 9) + pad("MODEL", 20) + pad("TIER", 6) + pad("COST", 9) + "RUNNING",
78
- );
79
- console.log(" " + "-".repeat(78));
80
-
81
- for (const d of decisions) {
82
- const task = BACKLOG.find((t) => t.id === d.taskId)!;
83
- const flag = d.overCap ? " ⚠ OVER CAP" : "";
84
- console.log(
85
- " " +
86
- pad(d.taskId, 7) +
87
- pad(task.capability, 11) +
88
- pad(task.difficulty, 9) +
89
- pad(d.backend, 9) +
90
- pad(d.model.name, 20) +
91
- pad(d.tier, 6) +
92
- pad(money(d.cost), 9) +
93
- money(d.runningTotal) +
94
- flag,
95
- );
96
- }
97
-
98
- const total = decisions.at(-1)?.runningTotal ?? 0;
99
- console.log(" " + "-".repeat(78));
100
- console.log(` TOTAL ESTIMATED: ${money(total)}\n`);
101
-
102
- console.log(" Decision trail for T-03 (the developer task):");
103
- for (const r of decisions.find((d) => d.taskId === "T-03")!.reasons) {
104
- console.log(" · " + r);
105
- }
106
- console.log("");
package/src/estimate.ts DELETED
@@ -1,111 +0,0 @@
1
- // Token estimation lives in CODE, not in the model.
2
- // The Phase 2 live run proved models estimate their own token use terribly
3
- // (a "high" single-file task used ~1.5k output, not the 12k a model guessed).
4
- // So the PM only tags capability + difficulty; we map that to a token budget here.
5
- //
6
- // CALIBRATION (2026-07-15) — retuned against real getSessionStats from live runs:
7
- // Phase 2 dev (code, single-file): output ~1.5k, input almost entirely cached
8
- // (fresh 4 of ~3.3k tokens).
9
- // Phase 4 mini: design/low, code/low ~ $0.03-0.04 each; test/trivial ~ $0.02.
10
- // Takeaways baked in below:
11
- // - a "task" is ONE atomic unit (a file / component / spec), not a whole feature,
12
- // so outputs are small — and output dominates cost (priced ~5-6x input).
13
- // - Pi caches context aggressively, so most input bills at the cheap cacheRead rate.
14
- // These stay heuristic; wire measured actuals back in for self-calibration later.
15
-
16
- import type { Capability, Difficulty, TokenEstimate } from "./types.js";
17
- import { calibratedTokens, allSamples } from "./calibration.js";
18
-
19
- type Bucket = { input: number; output: number };
20
-
21
- const BUCKETS: Record<Capability, Record<Difficulty, Bucket>> = {
22
- plan: {
23
- trivial: { input: 2_000, output: 800 },
24
- low: { input: 4_000, output: 1_500 },
25
- medium: { input: 8_000, output: 3_000 },
26
- high: { input: 12_000, output: 5_000 },
27
- },
28
- design: {
29
- trivial: { input: 4_000, output: 2_000 },
30
- low: { input: 6_000, output: 3_500 },
31
- medium: { input: 10_000, output: 6_000 },
32
- high: { input: 15_000, output: 9_000 },
33
- },
34
- code: {
35
- trivial: { input: 6_000, output: 1_500 },
36
- low: { input: 10_000, output: 3_000 },
37
- medium: { input: 25_000, output: 7_000 },
38
- high: { input: 60_000, output: 14_000 },
39
- },
40
- // Review reads the same tree the tester does but never runs it — shorter output.
41
- review: {
42
- trivial: { input: 15_000, output: 800 },
43
- low: { input: 25_000, output: 1_500 },
44
- medium: { input: 50_000, output: 3_000 },
45
- high: { input: 80_000, output: 5_000 },
46
- },
47
- test: {
48
- trivial: { input: 15_000, output: 1_200 },
49
- low: { input: 25_000, output: 2_500 },
50
- medium: { input: 50_000, output: 5_000 },
51
- high: { input: 80_000, output: 9_000 },
52
- },
53
- ops: {
54
- trivial: { input: 10_000, output: 2_000 },
55
- low: { input: 20_000, output: 4_000 },
56
- medium: { input: 40_000, output: 7_000 },
57
- high: { input: 70_000, output: 12_000 },
58
- },
59
- };
60
-
61
- // Pi auto-caches system prompt + context aggressively (live run: fresh input 4 tokens
62
- // of ~3.3k). Real cached share is often 80-99%; 0.55 is a conservative planning value.
63
- const DEFAULT_CACHED_INPUT_FRACTION = 0.55;
64
-
65
- export function estimateTokens(capability: Capability, difficulty: Difficulty): TokenEstimate {
66
- // Prefer the learned average once we have enough real samples; else the static bucket.
67
- const learned = calibratedTokens(capability, difficulty);
68
- if (learned) return learned;
69
- const b = BUCKETS[capability][difficulty];
70
- return { input: b.input, output: b.output, cachedInputFraction: DEFAULT_CACHED_INPUT_FRACTION };
71
- }
72
-
73
- /** The static baseline (pre-calibration) token budget for a bucket. */
74
- export function baselineTokens(capability: Capability, difficulty: Difficulty): Bucket {
75
- return BUCKETS[capability][difficulty];
76
- }
77
-
78
- export interface AccuracyRow {
79
- capability: Capability;
80
- difficulty: Difficulty;
81
- baseOutput: number;
82
- actualOutput: number;
83
- baseInput: number;
84
- actualInput: number;
85
- n: number;
86
- active: boolean; // true once calibration overrides the baseline (enough samples)
87
- }
88
-
89
- /** Baseline vs measured tokens for every bucket that has real samples. */
90
- export function estimateAccuracy(): AccuracyRow[] {
91
- const samples = allSamples();
92
- const rows: AccuracyRow[] = [];
93
- for (const cap of Object.keys(BUCKETS) as Capability[]) {
94
- for (const diff of Object.keys(BUCKETS[cap]) as Difficulty[]) {
95
- const s = samples[`${cap}/${diff}`];
96
- if (!s) continue;
97
- const b = BUCKETS[cap][diff];
98
- rows.push({
99
- capability: cap,
100
- difficulty: diff,
101
- baseOutput: b.output,
102
- actualOutput: Math.round(s.output),
103
- baseInput: b.input,
104
- actualInput: Math.round(s.input),
105
- n: s.n,
106
- active: !!calibratedTokens(cap, diff),
107
- });
108
- }
109
- }
110
- return rows;
111
- }
package/src/executor.ts DELETED
@@ -1,169 +0,0 @@
1
- // Phase 2 — the Executor. Runs ONE task on a real Pi agent session with the model
2
- // the router chose. Writes real files to a workspace dir, then reads back Pi's own
3
- // token usage + cost so we can compare estimated vs actual.
4
- //
5
- // Constructing a session and resolving models is offline + free. Only session.prompt()
6
- // hits the provider API and spends money — that path is guarded by the caller.
7
-
8
- import {
9
- ModelRuntime,
10
- createAgentSession,
11
- type AgentSession,
12
- type AgentSessionEvent,
13
- } from "@earendil-works/pi-coding-agent";
14
- import { readdirSync, statSync } from "node:fs";
15
- import { join, relative } from "node:path";
16
- import type { Provider, RouteDecision, Task } from "./types.js";
17
- import { estimateCost } from "./cost.js";
18
- import { getModel } from "./models.js";
19
- import { addSessionCost } from "./session-cost.js";
20
-
21
- /** Pi's own Model type, derived so we don't depend on a deep sub-path import. */
22
- export type PiModel = NonNullable<ReturnType<ModelRuntime["getModel"]>>;
23
-
24
- /** Pi's model/auth runtime: built-in catalog + env keys / ~/.pi/agent/auth.json.
25
- * Created per call on purpose — a key added in Settings must apply to the next session. */
26
- export function piRuntime(): Promise<ModelRuntime> {
27
- return ModelRuntime.create();
28
- }
29
-
30
- /** Resolve a Projectinator (provider, modelId) to Pi's executable Model.
31
- * Offline + free — reads Pi's built-in registry. Throws with a clear message
32
- * if the id isn't one Pi knows (our ids are kept identical to Pi's on purpose). */
33
- export function resolvePiModel(
34
- runtime: ModelRuntime,
35
- provider: Provider,
36
- modelId: string,
37
- ): PiModel {
38
- const m = runtime.getModel(provider, modelId);
39
- if (!m) {
40
- throw new Error(
41
- `Pi has no model "${provider}/${modelId}". ` +
42
- `Check src/models.ts uses Pi's exact built-in id.`,
43
- );
44
- }
45
- return m;
46
- }
47
-
48
- export interface ExecuteOptions {
49
- /** Directory the agent builds into (its cwd). */
50
- workspace: string;
51
- /** Extended-thinking level. Pi clamps to model capability. */
52
- thinkingLevel?: "off" | "low" | "medium" | "high";
53
- /** Optional progress hook — receives raw Pi session events. */
54
- onEvent?: (event: AgentSessionEvent) => void;
55
- /** Tools the agent may use. Default: the coding set. */
56
- tools?: string[];
57
- }
58
-
59
- export interface ExecuteResult {
60
- taskId: string;
61
- provider: Provider;
62
- modelId: string;
63
- /** Files present in the workspace after the run (repo-relative). */
64
- files: string[];
65
- /** Pi's own measured usage for the run. */
66
- actual: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
67
- /** Pi's own computed dollar cost. */
68
- actualCost: number;
69
- /** What we predicted before running, for calibration. */
70
- estCost: number;
71
- /** actualCost - estCost. Positive = we under-estimated. */
72
- costDelta: number;
73
- }
74
-
75
- /** Build the Developer role's instruction for a single task. */
76
- export function buildDeveloperPrompt(task: Task): string {
77
- return [
78
- `You are the DEVELOPER on an autonomous build team. Complete exactly this task and nothing more.`,
79
- ``,
80
- `Task ${task.id}: ${task.title}`,
81
- task.story ? `Story: ${task.story}` : ``,
82
- ``,
83
- `Rules:`,
84
- `- Write real files into the current working directory using your file tools.`,
85
- `- Keep it minimal and correct; no placeholders, no TODOs.`,
86
- `- Do not explain at length. Build, then stop.`,
87
- ]
88
- .filter(Boolean)
89
- .join("\n");
90
- }
91
-
92
- /**
93
- * Execute one task on a live Pi session. THIS SPENDS MONEY (session.prompt).
94
- * Caller is responsible for gating on a live flag + present API key.
95
- */
96
- export async function executeTask(
97
- task: Task,
98
- decision: RouteDecision,
99
- opts: ExecuteOptions,
100
- ): Promise<ExecuteResult> {
101
- const runtime = await piRuntime();
102
- const model = resolvePiModel(runtime, decision.provider, decision.model.id);
103
-
104
- const { session } = await createAgentSession({
105
- model,
106
- cwd: opts.workspace,
107
- modelRuntime: runtime,
108
- thinkingLevel: opts.thinkingLevel ?? "medium",
109
- tools: opts.tools ?? ["read", "write", "edit", "bash", "ls", "grep", "find"],
110
- });
111
-
112
- let unsubscribe: (() => void) | undefined;
113
- if (opts.onEvent) unsubscribe = session.subscribe(opts.onEvent);
114
-
115
- try {
116
- await session.prompt(buildDeveloperPrompt(task));
117
- const stats = session.getSessionStats();
118
- addSessionCost(stats.cost);
119
-
120
- const estCost = estimateCost(task.estTokens, getModel(decision.model.id));
121
- const actualCost = round2(stats.cost);
122
-
123
- return {
124
- taskId: task.id,
125
- provider: decision.provider,
126
- modelId: decision.model.id,
127
- files: listFiles(opts.workspace),
128
- actual: {
129
- input: stats.tokens.input,
130
- output: stats.tokens.output,
131
- cacheRead: stats.tokens.cacheRead,
132
- cacheWrite: stats.tokens.cacheWrite,
133
- total: stats.tokens.total,
134
- },
135
- actualCost,
136
- estCost,
137
- costDelta: round2(actualCost - estCost),
138
- };
139
- } finally {
140
- unsubscribe?.();
141
- session.dispose();
142
- }
143
- }
144
-
145
- /** List files in a workspace, skipping Pi/session/VCS noise. */
146
- function listFiles(dir: string): string[] {
147
- const out: string[] = [];
148
- const skip = new Set([".pi", ".git", "node_modules"]);
149
- const walk = (d: string) => {
150
- let entries: string[];
151
- try {
152
- entries = readdirSync(d);
153
- } catch {
154
- return;
155
- }
156
- for (const name of entries) {
157
- if (skip.has(name)) continue;
158
- const full = join(d, name);
159
- if (statSync(full).isDirectory()) walk(full);
160
- else out.push(relative(dir, full));
161
- }
162
- };
163
- walk(dir);
164
- return out.sort();
165
- }
166
-
167
- function round2(n: number): number {
168
- return Math.round(n * 100) / 100;
169
- }
package/src/git.ts DELETED
@@ -1,72 +0,0 @@
1
- // Git-per-build: init a repo in the build workspace and commit after each task,
2
- // so every build has history + diffs (and a foundation for undo). Best-effort —
3
- // if git isn't available or a command fails, builds carry on uninterrupted.
4
-
5
- import { spawnSync } from "node:child_process";
6
- import { existsSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
-
9
- interface GitOut { ok: boolean; out: string; }
10
-
11
- function git(dir: string, args: string[]): GitOut {
12
- try {
13
- const r = spawnSync("git", args, { cwd: dir, encoding: "utf8" });
14
- return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
15
- } catch {
16
- return { ok: false, out: "" };
17
- }
18
- }
19
-
20
- export function isRepo(dir: string): boolean {
21
- return existsSync(join(dir, ".git"));
22
- }
23
-
24
- /** git init + a local identity + an initial commit. Idempotent. Returns success. */
25
- export function initRepo(dir: string): boolean {
26
- if (isRepo(dir)) return true;
27
- const gi = join(dir, ".gitignore");
28
- if (!existsSync(gi)) writeFileSync(gi, ".deploy/\nbuild-state.json\nnode_modules/\n");
29
- if (!git(dir, ["init"]).ok) return false;
30
- // Local identity so commits work even when the user has no global git config.
31
- git(dir, ["config", "user.email", "bot@projectinator.local"]);
32
- git(dir, ["config", "user.name", "Projectinator"]);
33
- git(dir, ["add", "-A"]);
34
- git(dir, ["commit", "-m", "chore: initial workspace", "--allow-empty"]);
35
- return true;
36
- }
37
-
38
- /** Commit whatever a task produced. Returns the short hash, or null on failure. */
39
- export function commitTask(dir: string, taskId: string, title: string): string | null {
40
- if (!isRepo(dir)) return null;
41
- git(dir, ["add", "-A"]);
42
- const msg = `${taskId}: ${title}`.replace(/\s+/g, " ").slice(0, 72);
43
- const c = git(dir, ["commit", "-m", msg, "--allow-empty"]);
44
- if (!c.ok) return null;
45
- const h = git(dir, ["rev-parse", "--short", "HEAD"]);
46
- return h.ok ? h.out : null;
47
- }
48
-
49
- /** Hard-reset one commit back (undo the last task's file changes). Returns the
50
- * reverted task id parsed from the commit message. Refuses to undo past the
51
- * initial commit. */
52
- export function undoLastCommit(dir: string): { ok: boolean; taskId?: string } {
53
- if (!isRepo(dir)) return { ok: false };
54
- const commits = history(dir);
55
- if (commits.length <= 1) return { ok: false }; // nothing but the initial commit
56
- const taskId = (commits[0]?.msg.split(":")[0] ?? "").trim() || undefined;
57
- const r = git(dir, ["reset", "--hard", "HEAD~1"]);
58
- return { ok: r.ok, taskId };
59
- }
60
-
61
- export interface Commit { hash: string; msg: string; }
62
-
63
- /** Commit log, newest first. */
64
- export function history(dir: string): Commit[] {
65
- if (!isRepo(dir)) return [];
66
- const r = git(dir, ["log", "--oneline", "--no-decorate", "--no-color"]);
67
- if (!r.ok || !r.out) return [];
68
- return r.out.split("\n").filter(Boolean).map((line) => {
69
- const i = line.indexOf(" ");
70
- return i < 0 ? { hash: line, msg: "" } : { hash: line.slice(0, i), msg: line.slice(i + 1) };
71
- });
72
- }