projectinator 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +30 -11
  2. package/bin/projectinator.mjs +56 -11
  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/cost.ts +0 -43
  125. package/src/council.ts +0 -175
  126. package/src/demo.ts +0 -106
  127. package/src/estimate.ts +0 -111
  128. package/src/executor.ts +0 -169
  129. package/src/git.ts +0 -72
  130. package/src/intake.ts +0 -125
  131. package/src/models.ts +0 -175
  132. package/src/narrate.ts +0 -87
  133. package/src/openrouter.ts +0 -119
  134. package/src/orchestrator.ts +0 -310
  135. package/src/pm.ts +0 -302
  136. package/src/preview.ts +0 -206
  137. package/src/registry-store.ts +0 -41
  138. package/src/registry.ts +0 -132
  139. package/src/research.ts +0 -123
  140. package/src/retro.ts +0 -99
  141. package/src/roles.ts +0 -401
  142. package/src/router.ts +0 -120
  143. package/src/run-bakeoff.ts +0 -77
  144. package/src/run-build.ts +0 -203
  145. package/src/run-web.ts +0 -87
  146. package/src/scout.ts +0 -121
  147. package/src/session-cost.ts +0 -17
  148. package/src/stack.ts +0 -46
  149. package/src/tui/App.tsx +0 -1760
  150. package/src/tui/BakeOff.tsx +0 -190
  151. package/src/tui/BoardEditor.tsx +0 -260
  152. package/src/tui/EditableBoard.tsx +0 -179
  153. package/src/tui/Frame.tsx +0 -142
  154. package/src/tui/Intake.tsx +0 -111
  155. package/src/tui/Kanban.tsx +0 -158
  156. package/src/tui/Settings.tsx +0 -513
  157. package/src/tui/StackPick.tsx +0 -79
  158. package/src/tui/WebAccounts.tsx +0 -197
  159. package/src/tui/components.tsx +0 -340
  160. package/src/tui/config.ts +0 -150
  161. package/src/tui/deploy.ts +0 -137
  162. package/src/tui/engine.ts +0 -749
  163. package/src/tui/notify.ts +0 -21
  164. package/src/tui/panels.tsx +0 -89
  165. package/src/tui/templates.ts +0 -119
  166. package/src/tui/theme.ts +0 -44
  167. package/src/tui/validate.ts +0 -53
  168. package/src/types.ts +0 -208
  169. package/src/web/oauth-anthropic.ts +0 -206
  170. package/src/web/session.ts +0 -299
package/src/tui/engine.ts DELETED
@@ -1,749 +0,0 @@
1
- // Engine glue for the TUI — keeps all orchestration out of the React components.
2
- // The UI calls these; they reuse the same core the CLI does.
3
-
4
- import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
5
- import { spawn } from "node:child_process";
6
- import { homedir } from "node:os";
7
- import { fileURLToPath } from "node:url";
8
- import { basename, dirname, join } from "node:path";
9
- import type { Capability, Provider, RegistryEntry, Task, TaskLimits, TaskOutcome, Tier } from "../types.js";
10
- import { DEFAULT_POLICY, route } from "../router.js";
11
- import { REGISTRY } from "../registry.js";
12
- import { MODELS } from "../models.js";
13
- import { loadRegistry, saveOverrides, OVERRIDES_FILENAME } from "../registry-store.js";
14
- import { loadConfig } from "./config.js";
15
- import { lockRegistryToProvider, makePiExecutor } from "../roles.js";
16
- import { runBacklog, type OrchestratorEvent } from "../orchestrator.js";
17
- import { initRepo, commitTask, undoLastCommit, history as gitHistory, type Commit } from "../git.js";
18
- import { computeRetro, type RetroReport } from "../retro.js";
19
- import { computeBurndown, type Burndown } from "../burndown.js";
20
- import { narrateRetro } from "../narrate.js";
21
- import { decomposeIdea } from "../pm.js";
22
- import { assessIntake, type IntakeQuestion } from "../intake.js";
23
- import { councilEpics, type Epic, type CouncilResult } from "../council.js";
24
- import { newBuildState, loadState, saveState, completedIds, type BuildState } from "../build-state.js";
25
-
26
- const PROVIDER_KEYS: Record<Provider, string[]> = {
27
- anthropic: ["ANTHROPIC_API_KEY"],
28
- openai: ["OPENAI_API_KEY"],
29
- google: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
30
- openrouter: ["OPENROUTER_API_KEY"],
31
- };
32
-
33
- export const PROVIDER_LABEL: Record<Provider, string> = {
34
- anthropic: "Anthropic (Claude)",
35
- openai: "OpenAI (GPT)",
36
- google: "Google (Gemini)",
37
- openrouter: "OpenRouter",
38
- };
39
-
40
- /** Which providers have a usable API key right now (presence only). */
41
- export function availableProviders(): Provider[] {
42
- return (Object.keys(PROVIDER_KEYS) as Provider[]).filter((p) =>
43
- PROVIDER_KEYS[p].some((k) => !!process.env[k]),
44
- );
45
- }
46
-
47
- /** A locked-to-one-provider registry that still honors per-role model overrides
48
- * (from Settings) when the chosen model belongs to that provider. */
49
- function lockedRegistry(provider: Provider): RegistryEntry[] {
50
- const base = lockRegistryToProvider(provider);
51
- const overrides = loadRegistry(join(projectRoot(), OVERRIDES_FILENAME), REGISTRY);
52
- return base.map((e) => {
53
- const o = overrides.find((x) => x.capability === e.capability && x.tier === e.tier);
54
- if (o && o.byBackend.api.provider === provider) {
55
- const pick = { provider, model: o.byBackend.api.model };
56
- return { ...e, byBackend: { web: pick, api: pick } };
57
- }
58
- return e;
59
- });
60
- }
61
-
62
- /** Preferred provider wins (if it has a key); else one provider locks; else best-of-breed. */
63
- export function chooseRegistry(providers: Provider[]): { registry: RegistryEntry[]; lock?: Provider } {
64
- const root = projectRoot();
65
- const seed = loadRegistry(join(root, OVERRIDES_FILENAME), REGISTRY);
66
- const pref = loadConfig().preferredProvider;
67
- if (pref && providers.includes(pref)) return { registry: lockedRegistry(pref), lock: pref };
68
- if (providers.length === 1) return { registry: lockedRegistry(providers[0]!), lock: providers[0] };
69
- return { registry: seed };
70
- }
71
-
72
- export function projectRoot(): string {
73
- // dist-agnostic: this file lives at <root>/src/tui/engine.ts
74
- return dirname(dirname(dirname(fileURLToPath(import.meta.url))));
75
- }
76
-
77
- // ---- role -> model assignments (Settings) ----
78
-
79
- /** The headline role slots shown in Settings (each capability at its main tier). */
80
- export const ROLE_TIERS: { capability: Capability; tier: Tier; label: string }[] = [
81
- { capability: "plan", tier: "mid", label: "Project manager" },
82
- { capability: "design", tier: "high", label: "Designer" },
83
- { capability: "code", tier: "high", label: "Developer" },
84
- { capability: "review", tier: "fast", label: "Reviewer" },
85
- { capability: "test", tier: "fast", label: "Tester" },
86
- { capability: "ops", tier: "high", label: "Runner / ops" },
87
- ];
88
-
89
- function overridesPath(): string {
90
- return join(projectRoot(), OVERRIDES_FILENAME);
91
- }
92
-
93
- export interface RoleAssignment {
94
- capability: Capability;
95
- tier: Tier;
96
- label: string;
97
- provider?: Provider;
98
- model?: string;
99
- }
100
-
101
- export function roleAssignments(): RoleAssignment[] {
102
- const reg = loadRegistry(overridesPath(), REGISTRY);
103
- return ROLE_TIERS.map((rt) => {
104
- const e = reg.find((x) => x.capability === rt.capability && x.tier === rt.tier);
105
- return { ...rt, provider: e?.byBackend.api.provider, model: e?.byBackend.api.model };
106
- });
107
- }
108
-
109
- /** The team as it will ACTUALLY run — honors the preferred/locked provider. */
110
- export function effectiveRoster(): RoleAssignment[] {
111
- const { registry } = chooseRegistry(availableProviders());
112
- return ROLE_TIERS.map((rt) => {
113
- const e = registry.find((x) => x.capability === rt.capability && x.tier === rt.tier);
114
- return { ...rt, provider: e?.byBackend.api.provider, model: e?.byBackend.api.model };
115
- });
116
- }
117
-
118
- export function allModels(): { id: string; provider: Provider; name: string }[] {
119
- return Object.values(MODELS).map((m) => ({ id: m.id, provider: m.provider, name: m.name }));
120
- }
121
-
122
- /** Friendly model name for display (e.g. "claude-opus-4-8" -> "Claude Opus 4.8"). */
123
- export function modelLabel(id: string): string {
124
- return MODELS[id]?.name ?? id;
125
- }
126
-
127
- /** Reassign a role's model across ALL tiers of that capability (so the role uses one
128
- * model regardless of task difficulty) and persist to registry.overrides.json. */
129
- export function setRoleModel(capability: Capability, _tier: Tier, modelId: string): void {
130
- const m = MODELS[modelId];
131
- if (!m) return;
132
- const reg: RegistryEntry[] = loadRegistry(overridesPath(), REGISTRY).map((e) => ({
133
- ...e,
134
- byBackend: { web: { ...e.byBackend.web }, api: { ...e.byBackend.api } },
135
- }));
136
- const pick = { provider: m.provider, model: modelId };
137
- for (const tier of ["fast", "mid", "high"] as Tier[]) {
138
- const entry = reg.find((x) => x.capability === capability && x.tier === tier);
139
- if (entry) {
140
- entry.byBackend.api = { ...pick };
141
- entry.evidence = "set in Settings";
142
- } else {
143
- reg.push({ capability, tier, byBackend: { web: { ...pick }, api: { ...pick } }, evidence: "set in Settings" });
144
- }
145
- }
146
- saveOverrides(reg, overridesPath());
147
- }
148
-
149
- export function slugify(idea: string): string {
150
- return idea.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "build";
151
- }
152
-
153
- export interface PlanResult {
154
- tasks: Task[];
155
- provider: Provider;
156
- modelId: string;
157
- estCost: number;
158
- registry: RegistryEntry[];
159
- lock?: Provider;
160
- }
161
-
162
- /** Per-task + total cost estimate for a set of tasks against a registry. */
163
- export function estimateTasks(tasks: Task[], registry: RegistryEntry[]): { total: number; per: Map<string, number> } {
164
- const per = new Map<string, number>();
165
- let total = 0;
166
- for (const t of tasks) {
167
- const d = route(t, { policy: { ...DEFAULT_POLICY, backendMode: "api" }, registry, runningTotalBefore: total });
168
- per.set(t.id, d.cost);
169
- total = d.runningTotal;
170
- }
171
- return { total, per };
172
- }
173
-
174
- /** Drop dependsOn refs to tasks that no longer exist (after removals). */
175
- export function cleanDeps(tasks: Task[]): Task[] {
176
- const valid = new Set(tasks.map((t) => t.id));
177
- return tasks.map((t) => ({ ...t, dependsOn: (t.dependsOn ?? []).filter((d) => valid.has(d)) }));
178
- }
179
-
180
- /** Decompose an idea and estimate the whole build's cost. Spends money (PM call). */
181
- function planModelOverride(providers: Provider[]) {
182
- const { registry, lock } = chooseRegistry(providers);
183
- const modelOverride = lock
184
- ? { provider: lock, model: registry.find((e) => e.capability === "plan")!.byBackend.api.model }
185
- : undefined;
186
- return { registry, lock, modelOverride };
187
- }
188
-
189
- /** PM intake: clarifying questions for a vague request ([] = clear enough). */
190
- export async function assessBuild(idea: string, providers: Provider[]): Promise<IntakeQuestion[]> {
191
- return assessIntake(idea, { backend: "api", modelOverride: planModelOverride(providers).modelOverride });
192
- }
193
-
194
- /** Council: propose + synthesize epics for a "deep plan". */
195
- export async function councilBuild(idea: string, providers: Provider[]): Promise<CouncilResult> {
196
- return councilEpics(idea, { backend: "api", modelOverride: planModelOverride(providers).modelOverride });
197
- }
198
-
199
- export async function planBuild(
200
- idea: string,
201
- providers: Provider[],
202
- scope: "full" | "change" = "full",
203
- workspace?: string,
204
- epics?: Epic[],
205
- ): Promise<PlanResult> {
206
- const { registry, lock, modelOverride } = planModelOverride(providers);
207
-
208
- // On a change, hand the PM the existing project so it plans against what's really there.
209
- const projectContext = scope === "change" && workspace ? buildProjectContext(workspace) : undefined;
210
- const res = await decomposeIdea(idea, { backend: "api", modelOverride, scope, projectContext, epics });
211
- const { total } = estimateTasks(res.tasks, registry);
212
- return { tasks: res.tasks, provider: res.provider, modelId: res.modelId, estCost: total, registry, lock };
213
- }
214
-
215
- // ---- past projects (the start-screen list + resume) ----
216
-
217
- export interface ProjectInfo {
218
- slug: string;
219
- dir: string;
220
- idea: string;
221
- status: "running" | "complete" | "halted";
222
- totalCost: number;
223
- taskCount: number;
224
- files: string[];
225
- mtimeMs: number;
226
- state: BuildState;
227
- }
228
-
229
- function tuiRoot(): string {
230
- return join(projectRoot(), ".workspace", "tui");
231
- }
232
-
233
- /** List past builds, newest first. */
234
- export function listProjects(): ProjectInfo[] {
235
- const root = tuiRoot();
236
- let slugs: string[];
237
- try {
238
- slugs = readdirSync(root);
239
- } catch {
240
- return [];
241
- }
242
- const out: ProjectInfo[] = [];
243
- for (const slug of slugs) {
244
- const dir = join(root, slug);
245
- const statePath = join(dir, "build-state.json");
246
- let state: BuildState | undefined;
247
- try {
248
- if (!statSync(dir).isDirectory()) continue;
249
- state = loadState(statePath);
250
- } catch {
251
- continue;
252
- }
253
- if (!state) continue;
254
- out.push({
255
- slug,
256
- dir,
257
- idea: state.idea ?? slug,
258
- status: state.status,
259
- totalCost: state.totalCost,
260
- taskCount: state.tasks.length,
261
- files: projectFiles(dir),
262
- mtimeMs: safeMtime(statePath),
263
- state,
264
- });
265
- }
266
- return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
267
- }
268
-
269
- function safeMtime(p: string): number {
270
- try {
271
- return statSync(p).mtimeMs;
272
- } catch {
273
- return 0;
274
- }
275
- }
276
-
277
- /** Built files in a project (excludes the state file). */
278
- export function projectFiles(dir: string): string[] {
279
- try {
280
- return readdirSync(dir).filter((f) => f !== "build-state.json" && !f.startsWith("."));
281
- } catch {
282
- return [];
283
- }
284
- }
285
-
286
- // ---- projects CRUD ----
287
-
288
- /** Rename a project's display name (its idea/label). Keeps the folder + files. */
289
- export function renameProject(dir: string, newIdea: string): void {
290
- const p = join(dir, "build-state.json");
291
- const s = loadState(p);
292
- if (s) {
293
- s.idea = newIdea.trim() || s.idea;
294
- saveState(s, p);
295
- }
296
- }
297
-
298
- function uniqueDir(baseSlug: string): string {
299
- const root = tuiRoot();
300
- let slug = baseSlug || "build";
301
- let n = 2;
302
- while (existsSync(join(root, slug))) slug = `${baseSlug}-${n++}`;
303
- return join(root, slug);
304
- }
305
-
306
- /** Copy a project into a new folder so changes don't touch the original. */
307
- export function duplicateProject(dir: string): string {
308
- const s = loadState(join(dir, "build-state.json"));
309
- const newIdea = (s?.idea ?? basename(dir)) + " (copy)";
310
- const newDir = uniqueDir(slugify(newIdea));
311
- cpSync(dir, newDir, { recursive: true });
312
- const np = join(newDir, "build-state.json");
313
- const ns = loadState(np);
314
- if (ns) {
315
- ns.idea = newIdea;
316
- ns.id = basename(newDir);
317
- saveState(ns, np);
318
- }
319
- return newDir;
320
- }
321
-
322
- /** Persist an edited task list back to a project's build-state (keeps outcomes/status). */
323
- export function saveProjectTasks(dir: string, tasks: Task[]): void {
324
- const p = join(dir, "build-state.json");
325
- const s = loadState(p);
326
- if (s) {
327
- s.tasks = tasks;
328
- saveState(s, p);
329
- }
330
- }
331
-
332
- /** Export a project's backlog as Markdown + CSV into its folder. Returns the paths. */
333
- export function exportProject(dir: string): { md: string; csv: string } {
334
- const state = loadState(join(dir, "build-state.json"));
335
- if (!state) throw new Error("No project data to export.");
336
- const done = completedIds(state);
337
- const cost = new Map<string, number>();
338
- for (const o of state.outcomes) cost.set(o.taskId, (cost.get(o.taskId) ?? 0) + o.cost);
339
-
340
- // group by epic, first-seen order
341
- const order: string[] = [];
342
- const byEpic = new Map<string, typeof state.tasks>();
343
- for (const t of state.tasks) {
344
- const e = t.epic || "General";
345
- if (!byEpic.has(e)) { byEpic.set(e, []); order.push(e); }
346
- byEpic.get(e)!.push(t);
347
- }
348
-
349
- const md: string[] = [
350
- `# ${state.idea ?? state.id}`,
351
- "",
352
- `**Status:** ${state.status} · **Total:** $${state.totalCost.toFixed(2)} · **Tasks:** ${state.tasks.length}`,
353
- "",
354
- ];
355
- for (const epic of order) {
356
- md.push(`## ${epic}`);
357
- for (const t of byEpic.get(epic)!) {
358
- const mark = done.has(t.id) ? "x" : " ";
359
- const c = cost.has(t.id) ? ` — $${cost.get(t.id)!.toFixed(2)}` : "";
360
- const dep = (t.dependsOn ?? []).length ? ` _(after ${(t.dependsOn ?? []).join(", ")})_` : "";
361
- md.push(`- [${mark}] \`${t.id}\` **${t.capability}/${t.difficulty}** — ${t.title}${c}${dep}`);
362
- if (t.notes) md.push(` - ✎ ${t.notes}`);
363
- }
364
- md.push("");
365
- }
366
-
367
- const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
368
- const csv = [
369
- "id,epic,capability,difficulty,status,cost,dependsOn,title,notes",
370
- ...state.tasks.map((t) =>
371
- [
372
- t.id,
373
- esc(t.epic || "General"),
374
- t.capability,
375
- t.difficulty,
376
- done.has(t.id) ? "done" : "todo",
377
- (cost.get(t.id) ?? 0).toFixed(2),
378
- esc((t.dependsOn ?? []).join(" ")),
379
- esc(t.title),
380
- esc(t.notes ?? ""),
381
- ].join(","),
382
- ),
383
- ].join("\n");
384
-
385
- const mdPath = join(dir, "export.md");
386
- const csvPath = join(dir, "export.csv");
387
- writeFileSync(mdPath, md.join("\n") + "\n");
388
- writeFileSync(csvPath, csv + "\n");
389
- return { md: mdPath, csv: csvPath };
390
- }
391
-
392
- // Shared: load state + a done-set + per-task cost, or throw.
393
- function projectRows(dir: string) {
394
- const state = loadState(join(dir, "build-state.json"));
395
- if (!state) throw new Error("No project data to export.");
396
- const done = completedIds(state);
397
- const cost = new Map<string, number>();
398
- for (const o of state.outcomes) cost.set(o.taskId, (cost.get(o.taskId) ?? 0) + o.cost);
399
- return { state, done, cost };
400
- }
401
-
402
- const csvEsc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
403
-
404
- /** Export the backlog as a Jira-importable CSV (one Epic row per epic + linked
405
- * Task rows). Import via Jira → System → External System Import → CSV. */
406
- export function exportJira(dir: string): string {
407
- const { state, done, cost } = projectRows(dir);
408
- const epics = [...new Set(state.tasks.map((t) => t.epic || "General"))];
409
- const header = "Issue Type,Summary,Epic Name,Epic Link,Status,Labels,Description";
410
- const rows: string[] = [header];
411
- // Epic rows first so Epic Link resolves by name on import.
412
- for (const e of epics) {
413
- rows.push(["Epic", csvEsc(e), csvEsc(e), "", "To Do", "projectinator", ""].join(","));
414
- }
415
- for (const t of state.tasks) {
416
- const epic = t.epic || "General";
417
- const deps = (t.dependsOn ?? []).length ? `Depends on: ${(t.dependsOn ?? []).join(", ")}. ` : "";
418
- const c = cost.has(t.id) ? `Cost: $${cost.get(t.id)!.toFixed(2)}. ` : "";
419
- const desc = `${deps}${c}[${t.id}]`;
420
- rows.push(
421
- [
422
- "Task",
423
- csvEsc(t.title),
424
- "",
425
- csvEsc(epic),
426
- done.has(t.id) ? "Done" : "To Do",
427
- csvEsc(`${t.capability} ${t.difficulty}`),
428
- csvEsc(desc),
429
- ].join(","),
430
- );
431
- }
432
- const p = join(dir, "jira-import.csv");
433
- writeFileSync(p, rows.join("\n") + "\n");
434
- return p;
435
- }
436
-
437
- /** Export the backlog as a Trello-importable CSV (lists = epics, one card per
438
- * task). Import via a Trello CSV-import Power-Up / board import. */
439
- export function exportTrello(dir: string): string {
440
- const { state, done, cost } = projectRows(dir);
441
- const header = "List Name,Card Name,Card Description,Labels";
442
- const rows: string[] = [header];
443
- for (const t of state.tasks) {
444
- const epic = t.epic || "General";
445
- const deps = (t.dependsOn ?? []).length ? `Depends on: ${(t.dependsOn ?? []).join(", ")}. ` : "";
446
- const c = cost.has(t.id) ? `Cost: $${cost.get(t.id)!.toFixed(2)}. ` : "";
447
- const name = `${done.has(t.id) ? "✓ " : ""}${t.title}`;
448
- rows.push(
449
- [
450
- csvEsc(epic),
451
- csvEsc(name),
452
- csvEsc(`${deps}${c}[${t.id}]`),
453
- csvEsc(`${t.capability},${t.difficulty},${done.has(t.id) ? "done" : "todo"}`),
454
- ].join(","),
455
- );
456
- }
457
- const p = join(dir, "trello-import.csv");
458
- writeFileSync(p, rows.join("\n") + "\n");
459
- return p;
460
- }
461
-
462
- /** Per-task git history for a project (newest first). Empty if not versioned. */
463
- export function projectHistory(dir: string): Commit[] {
464
- return gitHistory(dir);
465
- }
466
-
467
- /** Data-driven retro for a finished build, or null if there's no state. */
468
- export function projectRetro(dir: string): RetroReport | null {
469
- const state = loadState(join(dir, "build-state.json"));
470
- return state ? computeRetro(state) : null;
471
- }
472
-
473
- /** Burndown series (tasks remaining + cumulative cost per step), or null. */
474
- export function projectBurndown(dir: string): Burndown | null {
475
- const state = loadState(join(dir, "build-state.json"));
476
- return state ? computeBurndown(state) : null;
477
- }
478
-
479
- /** Cached AI retro narrative for a project (null if never generated). */
480
- export function getRetroNarrative(dir: string): string | null {
481
- return loadState(join(dir, "build-state.json"))?.retroNarrative ?? null;
482
- }
483
-
484
- /** Generate (or regenerate) the AI retro narrative and cache it on the state. */
485
- export async function generateRetroNarrative(dir: string, providers: Provider[]): Promise<string> {
486
- const statePath = join(dir, "build-state.json");
487
- const state = loadState(statePath);
488
- if (!state) throw new Error("No build data to narrate.");
489
- const { registry, lock } = chooseRegistry(providers);
490
- const modelOverride = lock
491
- ? { provider: lock, model: registry.find((e) => e.capability === "plan")!.byBackend.api.model }
492
- : undefined;
493
- const text = await narrateRetro(computeRetro(state), { backend: "api", modelOverride });
494
- state.retroNarrative = text;
495
- saveState(state, statePath);
496
- return text;
497
- }
498
-
499
- /** Set (or clear, when undefined) a project's per-build budget cap. */
500
- export function setProjectBudget(dir: string, cap: number | undefined): void {
501
- const statePath = join(dir, "build-state.json");
502
- const state = loadState(statePath);
503
- if (!state) return;
504
- state.budgetCapUSD = cap;
505
- saveState(state, statePath);
506
- }
507
-
508
- /** Undo the last task: revert its file changes (git reset) AND roll back the
509
- * build-state so the task is no longer "done" and can be rebuilt via Resume. */
510
- export function undoLastTask(dir: string): { ok: boolean; taskId?: string; error?: string } {
511
- const { ok, taskId } = undoLastCommit(dir);
512
- if (!ok) return { ok: false, error: "Nothing to undo (only the initial commit, or git isn't available)." };
513
- const statePath = join(dir, "build-state.json");
514
- const state = loadState(statePath);
515
- if (state) {
516
- // Drop EVERY outcome for the reverted task — a task that went through the
517
- // Tester→Developer loop has several (rounds 0,1,…); leaving any behind keeps
518
- // its id in the resume done-set, so Resume would skip it while its files are
519
- // already gone. If we couldn't parse a task id, fall back to the last outcome.
520
- state.outcomes = taskId
521
- ? state.outcomes.filter((o) => o.taskId !== taskId)
522
- : state.outcomes.slice(0, -1);
523
- state.totalCost = Math.round(state.outcomes.reduce((a, o) => a + o.cost, 0) * 100) / 100;
524
- state.status = "halted";
525
- state.haltReason = `undid ${taskId ?? "last task"} — resume to rebuild`;
526
- saveState(state, statePath);
527
- }
528
- return { ok: true, taskId };
529
- }
530
-
531
- /** Permanently delete a project's folder. */
532
- export function deleteProject(dir: string): void {
533
- // Safety: only ever delete inside our own tui workspace root.
534
- if (!dir.startsWith(tuiRoot())) throw new Error("refusing to delete outside the projects folder");
535
- rmSync(dir, { recursive: true, force: true });
536
- }
537
-
538
- /** Summarize an existing project for the PM: original idea + files + their contents.
539
- * Gives the (otherwise blind) PM real context when planning a change. */
540
- export function buildProjectContext(dir: string): string {
541
- const files = projectFiles(dir);
542
- const parts: string[] = [];
543
- try {
544
- const state = loadState(join(dir, "build-state.json"));
545
- if (state?.idea) parts.push(`This project was originally: "${state.idea}"`);
546
- } catch {
547
- /* ignore */
548
- }
549
- parts.push(`Files currently in the project: ${files.join(", ") || "(none)"}`);
550
- const TEXT = /\.(html|css|js|jsx|ts|tsx|md|json|txt|svg)$/i;
551
- let budget = 8000; // cap total context chars
552
- for (const f of files) {
553
- if (!TEXT.test(f) || budget <= 0) continue;
554
- try {
555
- const content = readFileSync(join(dir, f), "utf-8");
556
- const snippet = content.slice(0, Math.min(2000, budget));
557
- budget -= snippet.length;
558
- parts.push(`--- ${f} ---\n${snippet}${content.length > snippet.length ? "\n…[truncated]" : ""}`);
559
- } catch {
560
- /* ignore unreadable file */
561
- }
562
- }
563
- return parts.join("\n\n");
564
- }
565
-
566
- /** The file to open when viewing a project (index.html preferred, else first html, else the folder). */
567
- export function mainFileOf(dir: string): string {
568
- const files = projectFiles(dir);
569
- const index = files.find((f) => f === "index.html");
570
- const html = files.find((f) => f.endsWith(".html"));
571
- return join(dir, index ?? html ?? "");
572
- }
573
-
574
- /** Copy a file from anywhere on the computer into a project's folder, so the
575
- * build can use it (images, logos, fonts…). Returns the copied filename. */
576
- export function addAsset(dir: string, rawSrc: string): { ok: true; name: string } | { ok: false; error: string } {
577
- let src = rawSrc.trim();
578
- // Terminals wrap dragged paths in quotes or backslash-escape special chars
579
- // (space, ~, parens, &, …). Strip quotes, then un-escape every "\x" -> "x".
580
- if ((src.startsWith('"') && src.endsWith('"')) || (src.startsWith("'") && src.endsWith("'"))) src = src.slice(1, -1);
581
- else src = src.replace(/\\(.)/g, "$1");
582
- if (src.startsWith("~")) src = homedir() + src.slice(1);
583
- try {
584
- if (!existsSync(src)) return { ok: false, error: `File not found: ${src}` };
585
- if (statSync(src).isDirectory()) return { ok: false, error: "That's a folder — pick a single file." };
586
- const name = basename(src);
587
- mkdirSync(dir, { recursive: true });
588
- copyFileSync(src, join(dir, name));
589
- return { ok: true, name };
590
- } catch (e) {
591
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
592
- }
593
- }
594
-
595
- /** Open a file or folder in the OS default app (macOS `open`). Fire-and-forget. */
596
- export function openInBrowser(target: string): void {
597
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
598
- const child = spawn(cmd, [target], { detached: true, stdio: "ignore" });
599
- child.unref();
600
- }
601
-
602
- /** Break an epic into more tasks via the PM. Returns new tasks tagged with the epic. */
603
- export async function breakdownEpic(
604
- epic: string,
605
- current: Task[],
606
- providers: Provider[],
607
- workspace?: string,
608
- ): Promise<Task[]> {
609
- const { registry, lock } = chooseRegistry(providers);
610
- const modelOverride = lock
611
- ? { provider: lock, model: registry.find((e) => e.capability === "plan")!.byBackend.api.model }
612
- : undefined;
613
- const existing = current
614
- .map((t) => `- ${t.id} [${t.capability}/${t.difficulty}] ${t.title} (epic: ${t.epic || "General"})`)
615
- .join("\n");
616
- const request = [
617
- `Break the epic "${epic}" into concrete, atomic tasks for this project.`,
618
- `Set every new task's epic field to "${epic}".`,
619
- `Do NOT repeat any of these existing tasks:`,
620
- existing || "(none yet)",
621
- ].join("\n");
622
- const projectContext = workspace ? buildProjectContext(workspace) : undefined;
623
- const res = await decomposeIdea(request, { backend: "api", modelOverride, scope: "full", projectContext });
624
- return res.tasks.map((t) => ({ ...t, epic }));
625
- }
626
-
627
- export interface RunHandle {
628
- workspace: string;
629
- promise: Promise<{ totalCost: number; halted: boolean; haltReason?: string; files: string[] }>;
630
- }
631
-
632
- /** Run a planned build, streaming orchestrator events to the UI. Spends money.
633
- * Pass `workspace` to build into an existing project (changes/resume); pass
634
- * `seedOutcomes` to resume a halted run without re-paying for finished tasks. */
635
- export function startBuild(
636
- idea: string,
637
- plan: PlanResult,
638
- opts: {
639
- concurrency: number;
640
- budgetCapUSD: number;
641
- taskLimits: TaskLimits;
642
- onEvent: (e: OrchestratorEvent) => void;
643
- workspace?: string;
644
- seedOutcomes?: TaskOutcome[];
645
- mode?: "auto" | "approval";
646
- onGate?: (info: { stage: string }) => Promise<"continue" | "stop">;
647
- },
648
- ): RunHandle {
649
- const workspace = opts.workspace ?? join(projectRoot(), ".workspace", "tui", slugify(idea));
650
- mkdirSync(workspace, { recursive: true });
651
- const statePath = join(workspace, "build-state.json");
652
- // Reuse the existing state when resuming/changing an existing project so we keep
653
- // its id and cached retro narrative; only start fresh for a brand-new build.
654
- const prior = opts.workspace ? loadState(statePath) : undefined;
655
- const state = prior ?? newBuildState(slugify(idea), plan.tasks, idea, opts.mode);
656
- if (prior) {
657
- state.tasks = plan.tasks; // authoritative backlog for this run
658
- state.status = "running";
659
- state.haltReason = undefined;
660
- state.mode = opts.mode ?? state.mode;
661
- }
662
- state.budgetCapUSD = opts.budgetCapUSD; // remember this project's cap
663
-
664
- const executor = makePiExecutor({ workspace, backend: "api" });
665
- const policy = { ...DEFAULT_POLICY, backendMode: "api" as const, budgetCapUSD: opts.budgetCapUSD, taskLimits: opts.taskLimits };
666
-
667
- // Version the workspace: init a repo, then commit after each finished task.
668
- initRepo(workspace);
669
- const titleById = new Map(plan.tasks.map((t) => [t.id, t.title]));
670
- const onProgress = (e: OrchestratorEvent) => {
671
- opts.onEvent(e);
672
- if (e.type === "task_done") commitTask(workspace, e.outcome.taskId, titleById.get(e.outcome.taskId) ?? e.outcome.taskId);
673
- };
674
-
675
- const promise = runBacklog(plan.tasks, {
676
- policy,
677
- execute: executor,
678
- registry: plan.registry,
679
- concurrency: opts.concurrency,
680
- seedOutcomes: opts.seedOutcomes,
681
- onGate: opts.onGate,
682
- onProgress,
683
- onCheckpoint: (outcomes, totalCost) => {
684
- state.outcomes = outcomes;
685
- state.totalCost = totalCost;
686
- saveState(state, statePath);
687
- },
688
- }).then((result) => {
689
- state.outcomes = result.outcomes;
690
- state.totalCost = result.totalCost;
691
- state.status = result.halted ? "halted" : "complete";
692
- state.haltReason = result.haltReason; // keep why it stopped (budget cap / gate)
693
- saveState(state, statePath);
694
- if (!result.halted) ensureRunInstructions(workspace); // finished builds ship with how-to-run
695
- return {
696
- totalCost: result.totalCost,
697
- halted: result.halted,
698
- haltReason: result.haltReason,
699
- files: (result.outcomes.at(-1)?.files ?? []).filter((f) => f !== "build-state.json"),
700
- };
701
- });
702
-
703
- return { workspace, promise };
704
- }
705
-
706
- /** After a static web build, make sure the folder ships with how-to-run notes.
707
- * A user who just double-clicks index.html hits a blank page when the app uses
708
- * ES modules / fetches local files (browsers block those on file://). We can't
709
- * rewrite their code here, but we can guarantee the folder tells them how to run
710
- * it — so the export is never a mystery. No-op if there's no index.html or a
711
- * README already exists. */
712
- function ensureRunInstructions(workspace: string): void {
713
- const index = join(workspace, "index.html");
714
- if (!existsSync(index)) return; // not a static site → nothing to say
715
- const hasReadme = ["README.md", "readme.md", "README.txt"].some((n) => existsSync(join(workspace, n)));
716
- if (hasReadme) return; // the developer/tester already documented it
717
-
718
- let usesModules = false;
719
- try {
720
- const html = readFileSync(index, "utf8");
721
- usesModules = /<script[^>]+type=["']module["']/.test(html);
722
- } catch { /* unreadable → fall through with the generic note */ }
723
-
724
- const serverNote = [
725
- "## Run it",
726
- "",
727
- usesModules
728
- ? "This app uses ES modules, so it must be served over http — opening `index.html` directly (double-click) will show a blank page. From this folder, run one of:"
729
- : "Open `index.html` in your browser (double-click works). If anything looks off, serve it over http instead — from this folder run one of:",
730
- "",
731
- "```",
732
- "python3 -m http.server 8000 # then open http://localhost:8000",
733
- "npx serve . # if you have Node installed",
734
- "```",
735
- "",
736
- ].join("\n");
737
-
738
- const body = [
739
- "# Your app",
740
- "",
741
- "Built by Projectinator.",
742
- "",
743
- serverNote,
744
- ].join("\n");
745
-
746
- try {
747
- writeFileSync(join(workspace, "README.md"), body);
748
- } catch { /* best-effort; never fail a build over docs */ }
749
- }