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