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
@@ -0,0 +1,1269 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Projectinator TUI — the whole flow: setup -> idea -> plan -> build -> done.
3
+ // Dead simple: type what you want, confirm the cost, watch it build.
4
+ import { useEffect, useState } from "react";
5
+ import { Box, Text, useApp, useInput } from "ink";
6
+ import { Spinner, StatusMessage } from "@inkjs/ui";
7
+ import InkSpinner from "ink-spinner"; // a Text-based spinner, safe as an inline glyph inside <Text>
8
+ import { C, BudgetBar, Panel, Chip, Menu as SelectInput, GroupedMenu, KeyHint, useTermRows, TextField as TextInput, ROLE_META } from "./components.js";
9
+ import { Kanban } from "./Kanban.js";
10
+ import { BoardEditor } from "./BoardEditor.js";
11
+ import { Team, Standup } from "./panels.js";
12
+ import { EditableBoard } from "./EditableBoard.js";
13
+ import { AppFrame } from "./Frame.js";
14
+ import { Settings } from "./Settings.js";
15
+ import { BakeOff } from "./BakeOff.js";
16
+ import { Intake } from "./Intake.js";
17
+ import { enrichBrief } from "../intake.js";
18
+ import { StackPick } from "./StackPick.js";
19
+ import { stackInstruction } from "../stack.js";
20
+ import { allTemplates, saveUserTemplate, deleteUserTemplate, exportTemplate, importTemplate } from "./templates.js";
21
+ import { getPrefs, getDefaultMode, getNotify, getWebhookUrl, getPreferredStack } from "./config.js";
22
+ import { notifyBuildDone, postWebhook } from "./notify.js";
23
+ import { availableProviders, chooseRegistry, planBuild, assessBuild, councilBuild, setProjectBudget, startBuild, estimateTasks, costMatrix, listProjects, openInBrowser, mainFileOf, addAsset, importProject, renameProject, duplicateProject, deleteProject, saveProjectTasks, exportProject, exportJira, exportTrello, projectHistory, undoLastTask, projectRetro, projectBurndown, getRetroNarrative, generateRetroNarrative, breakdownEpic, modelLabel, PROVIDER_LABEL, } from "./engine.js";
24
+ import { deploy, DEPLOY_META } from "./deploy.js";
25
+ import { startStaticServer, CHROMIUM_INSTALL_HINT } from "../preview.js";
26
+ import { completedIds } from "../build-state.js";
27
+ import { isStuck } from "../stuck.js";
28
+ import { commitDiff } from "../git.js";
29
+ /** Board label: PASS* = a TEST that passed without ever running the app (Chromium missing).
30
+ * Reviews never run anything, so they are plain PASS/FAIL. */
31
+ const verdictLabel = (v, capability) => !v.passed ? "FAIL" : v.runtimeChecked || capability !== "test" ? "PASS" : "PASS*";
32
+ export default function App() {
33
+ const { exit } = useApp();
34
+ const [phase, setPhase] = useState("setup");
35
+ const [providers, setProviders] = useState(() => availableProviders());
36
+ const [scope, setScope] = useState("full");
37
+ const [mode, setMode] = useState(() => getDefaultMode());
38
+ const [idea, setIdea] = useState("");
39
+ const [plan, setPlan] = useState(null);
40
+ const [error, setError] = useState("");
41
+ const [tasks, setTasks] = useState([]);
42
+ const [spent, setSpent] = useState(0);
43
+ const [gate, setGate] = useState(null);
44
+ const [buildResult, setBuildResult] = useState(null);
45
+ // Existing-project context (open/resume/make-changes).
46
+ const [projects, setProjects] = useState([]);
47
+ const [selected, setSelected] = useState(null);
48
+ const [targetWorkspace, setTargetWorkspace] = useState(undefined);
49
+ const [seed, setSeed] = useState(undefined);
50
+ const [assetPath, setAssetPath] = useState("");
51
+ const [assetMsg, setAssetMsg] = useState(null);
52
+ const [assetKey, setAssetKey] = useState(0); // bump to remount (clear) the add-asset input
53
+ const [assetReturn, setAssetReturn] = useState("projectActions");
54
+ const [renameDraft, setRenameDraft] = useState("");
55
+ const [viewMode, setViewMode] = useState("board");
56
+ const [epicFilter, setEpicFilter] = useState(null);
57
+ const [flash, setFlash] = useState("");
58
+ const [intakeQs, setIntakeQs] = useState([]);
59
+ const [approvedEpics, setApprovedEpics] = useState(null);
60
+ const [council, setCouncil] = useState({ loading: false, result: null, error: "" });
61
+ // Keep `idea` as the raw user input; derive the brief from these so re-entering
62
+ // the stack/intake steps never double-appends (bug: back-nav re-appended text).
63
+ const [stackChoice, setStackChoice] = useState(null);
64
+ const [intakeAnswers, setIntakeAnswers] = useState(null);
65
+ const [narr, setNarr] = useState({ loading: false, text: "", error: "" });
66
+ const [tplName, setTplName] = useState("");
67
+ const [tplPath, setTplPath] = useState("");
68
+ const [tplSel, setTplSel] = useState(null);
69
+ const [projectCap, setProjectCap] = useState(null); // per-build cap override
70
+ const [capDraft, setCapDraft] = useState("");
71
+ const [capReturn, setCapReturn] = useState("plan");
72
+ const [deployState, setDeployState] = useState(null);
73
+ const [preview, setPreview] = useState(null);
74
+ // Wall clock for the live board (elapsed per running task); ticks only while building.
75
+ const [now, setNow] = useState(() => Date.now());
76
+ useEffect(() => {
77
+ if (phase !== "building")
78
+ return;
79
+ const id = setInterval(() => setNow(Date.now()), 1000);
80
+ return () => clearInterval(id);
81
+ }, [phase]);
82
+ const [transcript, setTranscript] = useState(null);
83
+ const [diffView, setDiffView] = useState(null);
84
+ const termRows = useTermRows();
85
+ // Reload the projects list and re-point `selected` at the given dir (after a mutation).
86
+ const reselect = (dir) => {
87
+ const list = listProjects();
88
+ setProjects(list);
89
+ setSelected(dir ? list.find((p) => p.dir === dir) ?? null : null);
90
+ return list;
91
+ };
92
+ const resetBuildContext = () => {
93
+ setIdea("");
94
+ setPlan(null);
95
+ setTasks([]);
96
+ setSpent(0);
97
+ setBuildResult(null);
98
+ setTargetWorkspace(undefined);
99
+ setSeed(undefined);
100
+ setSelected(null);
101
+ setMode(getDefaultMode());
102
+ setGate(null);
103
+ setProjectCap(null);
104
+ setApprovedEpics(null);
105
+ setCouncil({ loading: false, result: null, error: "" });
106
+ setStackChoice(null);
107
+ setIntakeAnswers(null);
108
+ setEpicFilter(null);
109
+ };
110
+ // The brief the planner sees: raw idea + stack instruction (+ intake answers).
111
+ // Pure function of state, so it's idempotent no matter how the user navigates.
112
+ const composeBrief = (withIntake) => {
113
+ const base = idea + (stackChoice ? stackInstruction(stackChoice) : "");
114
+ return withIntake ? enrichBrief(base, intakeAnswers ?? []) : base;
115
+ };
116
+ // Esc goes back one screen. Phases with their own Esc handling (board editors,
117
+ // settings) are left out so we don't double-fire.
118
+ const goBack = () => {
119
+ switch (phase) {
120
+ case "idea":
121
+ resetBuildContext();
122
+ return setPhase("home");
123
+ case "stack":
124
+ resetBuildContext();
125
+ return setPhase("home");
126
+ case "assessing":
127
+ resetBuildContext();
128
+ return setPhase("home");
129
+ case "intake": return setPhase("idea");
130
+ case "planMode":
131
+ resetBuildContext();
132
+ return setPhase("home");
133
+ case "approveEpics": return setPhase("planMode");
134
+ case "setCap": return setPhase(capReturn);
135
+ case "templates": return setPhase("home");
136
+ case "saveTemplate": return setPhase("projectActions");
137
+ case "importTemplate":
138
+ setFlash("");
139
+ return setPhase("templates");
140
+ case "importProject": return setPhase("home");
141
+ case "myTemplates":
142
+ setFlash("");
143
+ return setPhase("templates");
144
+ case "tplActions": return setPhase("myTemplates");
145
+ case "change": return setPhase(selected ? "projectActions" : buildResult ? "done" : "home");
146
+ case "rename": return setPhase("projectActions");
147
+ case "addAsset": return setPhase(assetReturn);
148
+ case "plan": return setPhase("idea");
149
+ case "confirmDelete": return setPhase("projectActions");
150
+ case "exportMenu": return setPhase("projectActions");
151
+ case "deployMenu": return setPhase("projectActions");
152
+ case "history": return setPhase("projectActions");
153
+ case "diff":
154
+ setDiffView(null);
155
+ return setPhase("history");
156
+ case "transcripts": return setPhase("projectActions");
157
+ case "transcript":
158
+ setTranscript(null);
159
+ return setPhase("transcripts");
160
+ case "retro": return setPhase("projectActions");
161
+ case "burndown": return setPhase("projectActions");
162
+ case "kanban": return setPhase("projectActions");
163
+ case "preview": {
164
+ if (preview && "server" in preview)
165
+ void preview.server.close();
166
+ setPreview(null);
167
+ return setPhase("projectActions");
168
+ }
169
+ case "filterEpic": return setPhase("projectActions");
170
+ case "projectActions": return setPhase("projects");
171
+ case "projects": return setPhase("home");
172
+ case "home": return setPhase("setup");
173
+ case "done":
174
+ resetBuildContext();
175
+ return setPhase("home");
176
+ case "error": return setPhase("home");
177
+ }
178
+ };
179
+ // global quit (not while typing an idea/change)
180
+ // Any phase that hosts a text/number input must be here, or the App-level
181
+ // useInput below quits on a "q" keystroke (both handlers see every key).
182
+ const typing = phase === "idea" || phase === "change" || phase === "addAsset" || phase === "rename" || phase === "importProject" ||
183
+ phase === "bakeoff" || phase === "intake" || phase === "setCap" || phase === "stack" ||
184
+ phase === "saveTemplate" || phase === "importTemplate" || phase === "settings" ||
185
+ phase === "editBoard" || phase === "board";
186
+ useInput((input, key) => {
187
+ if (key.ctrl && input === "c")
188
+ return exit();
189
+ if (input === "q" && !typing)
190
+ return exit();
191
+ if (key.escape)
192
+ goBack();
193
+ // Pagers share one key handler; the page size matches the viewer's (frame chrome + 1 slack).
194
+ const pager = phase === "transcript" && transcript ? transcript : phase === "diff" && diffView ? diffView : null;
195
+ if (pager) {
196
+ const page = Math.max(4, termRows - 18);
197
+ const step = key.upArrow ? -1 : key.downArrow ? 1 : key.pageUp ? -page : key.pageDown ? page : 0;
198
+ if (!step)
199
+ return;
200
+ const scroll = Math.max(0, pager.scroll + step);
201
+ if (phase === "transcript")
202
+ setTranscript({ ...transcript, scroll });
203
+ else
204
+ setDiffView({ ...diffView, scroll });
205
+ }
206
+ });
207
+ // ---- stack effect: apply the default stack (skip the picker) when one is set ----
208
+ useEffect(() => {
209
+ if (phase !== "stack")
210
+ return;
211
+ const pref = getPreferredStack();
212
+ if (pref !== "ask") {
213
+ setStackChoice({ platform: "web", framework: pref });
214
+ setPhase("assessing");
215
+ }
216
+ }, [phase]);
217
+ // ---- intake effect: for a fresh build, ask the PM if it needs clarification ----
218
+ useEffect(() => {
219
+ if (phase !== "assessing")
220
+ return;
221
+ let alive = true;
222
+ // Only interview for fresh full builds; targeted changes go straight to planning.
223
+ if (scope !== "full" || targetWorkspace) {
224
+ setPhase("planning");
225
+ return;
226
+ }
227
+ assessBuild(composeBrief(false), providers) // intake not answered yet
228
+ .then((qs) => {
229
+ if (!alive)
230
+ return;
231
+ if (qs.length) {
232
+ setIntakeQs(qs);
233
+ setPhase("intake");
234
+ }
235
+ else {
236
+ setPhase("planMode");
237
+ }
238
+ })
239
+ .catch(() => { if (alive)
240
+ setPhase("planMode"); }); // never block on intake
241
+ return () => { alive = false; };
242
+ }, [phase, idea, providers, scope, targetWorkspace]);
243
+ // ---- council effect: run the deep plan when entering "council" ----
244
+ useEffect(() => {
245
+ if (phase !== "council")
246
+ return;
247
+ let alive = true;
248
+ setCouncil({ loading: true, result: null, error: "" });
249
+ councilBuild(composeBrief(true), providers)
250
+ .then((result) => {
251
+ if (!alive)
252
+ return;
253
+ if (result.epics.length) {
254
+ setCouncil({ loading: false, result, error: "" });
255
+ setPhase("approveEpics");
256
+ }
257
+ else {
258
+ setApprovedEpics(null); // council came back empty — fall back to normal planning
259
+ setPhase("planning");
260
+ }
261
+ })
262
+ .catch(() => { if (alive) {
263
+ setApprovedEpics(null);
264
+ setPhase("planning");
265
+ } });
266
+ return () => { alive = false; };
267
+ }, [phase, idea, providers]);
268
+ // ---- planning effect ----
269
+ useEffect(() => {
270
+ if (phase !== "planning")
271
+ return;
272
+ let alive = true;
273
+ planBuild(composeBrief(true), providers, scope, targetWorkspace, approvedEpics ?? undefined)
274
+ .then((p) => {
275
+ if (!alive)
276
+ return;
277
+ setPlan(p);
278
+ setTasks(p.tasks.map((t) => ({ id: t.id, capability: t.capability, status: "pending", title: t.title })));
279
+ setPhase("plan");
280
+ })
281
+ .catch((e) => {
282
+ if (!alive)
283
+ return;
284
+ setError(e instanceof Error ? e.message : String(e));
285
+ setPhase("error");
286
+ });
287
+ return () => {
288
+ alive = false;
289
+ };
290
+ }, [phase, idea, providers, scope, targetWorkspace]);
291
+ // ---- building effect ----
292
+ useEffect(() => {
293
+ if (phase !== "building" || !plan)
294
+ return;
295
+ const onEvent = (e) => {
296
+ if (e.type === "task_start") {
297
+ setTasks((ts) => ts.map((t) => (t.id === e.task.id ? { ...t, status: "running", model: e.modelId, startedAt: Date.now() } : t)));
298
+ }
299
+ else if (e.type === "task_done") {
300
+ setSpent(e.runningTotal);
301
+ setTasks((ts) => ts.map((t) => t.id === e.outcome.taskId
302
+ ? {
303
+ ...t,
304
+ status: "done",
305
+ cost: (t.cost ?? 0) + e.outcome.cost,
306
+ verdict: e.outcome.verdict ? verdictLabel(e.outcome.verdict, e.outcome.capability) : t.verdict,
307
+ }
308
+ : t));
309
+ }
310
+ else if (e.type === "task_failed") {
311
+ setSpent(e.runningTotal);
312
+ setTasks((ts) => ts.map((t) => (t.id === e.outcome.taskId ? { ...t, status: "failed", cost: (t.cost ?? 0) + e.outcome.cost } : t)));
313
+ }
314
+ else if (e.type === "task_skipped") {
315
+ setTasks((ts) => ts.map((t) => (t.id === e.taskId ? { ...t, status: "skipped" } : t)));
316
+ }
317
+ else if (e.type === "test_failed") {
318
+ setTasks((ts) => ts.map((t) => (t.id === e.taskId ? { ...t, status: "failed", verdict: "FAIL" } : t)));
319
+ }
320
+ else if (e.type === "retry_dev") {
321
+ setTasks((ts) => ts.map((t) => (t.id === e.taskId ? { ...t, status: "running" } : t)));
322
+ }
323
+ };
324
+ const prefs = getPrefs();
325
+ // In approval mode, pause once before development starts (design → dev gate).
326
+ const onGate = mode === "approval"
327
+ ? () => new Promise((resolve) => setGate({ resolve }))
328
+ : undefined;
329
+ const handle = startBuild(idea, plan, {
330
+ concurrency: prefs.concurrency,
331
+ budgetCapUSD: projectCap ?? prefs.budgetCapUSD,
332
+ taskLimits: { timeoutMs: prefs.taskTimeoutMin * 60_000, costCapUSD: prefs.taskCostCapUSD },
333
+ onEvent,
334
+ workspace: targetWorkspace,
335
+ seedOutcomes: seed,
336
+ mode,
337
+ onGate,
338
+ });
339
+ let alive = true;
340
+ handle.promise
341
+ .then((r) => {
342
+ if (!alive)
343
+ return;
344
+ setSpent(r.totalCost);
345
+ setBuildResult({ halted: r.halted, haltReason: r.haltReason, files: r.files, workspace: handle.workspace });
346
+ setPhase("done");
347
+ if (getNotify()) {
348
+ notifyBuildDone("Projectinator", `Build ${r.halted ? "halted" : "complete"} · $${r.totalCost.toFixed(2)}${r.files.length ? ` · ${r.files.length} file${r.files.length === 1 ? "" : "s"}` : ""}`);
349
+ }
350
+ const hook = getWebhookUrl();
351
+ if (hook)
352
+ void postWebhook(hook, { event: "build.finished", status: r.halted ? "halted" : "complete", haltReason: r.haltReason, idea, totalCost: r.totalCost, files: r.files, workspace: handle.workspace, at: new Date().toISOString() });
353
+ })
354
+ .catch((e) => {
355
+ if (!alive)
356
+ return;
357
+ setError(e instanceof Error ? e.message : String(e));
358
+ setPhase("error");
359
+ });
360
+ return () => {
361
+ alive = false;
362
+ };
363
+ }, [phase, plan, idea]);
364
+ // ================= screens =================
365
+ // Each phase renders its own content; AppFrame (below) wraps the result in the
366
+ // persistent frame (status bar pinned to the bottom on every screen).
367
+ const screen = (() => {
368
+ if (phase === "setup") {
369
+ const ready = providers.length > 0;
370
+ const mode = chooseRegistry(providers).lock
371
+ ? `locked to ${PROVIDER_LABEL[providers[0]]}`
372
+ : "best model per role";
373
+ return (_jsx(Box, { flexDirection: "column", children: ready ? (_jsxs(Panel, { title: "Ready to build", children: [_jsx(Text, { color: C.textMuted, children: "Your team is connected." }), _jsx(Box, { marginTop: 1, gap: 1, flexWrap: "wrap", children: providers.map((p) => (_jsx(Chip, { label: PROVIDER_LABEL[p], dotColor: C.good }, p))) }), _jsx(Text, { color: C.textSubtle, children: `\nRouting: ${mode}.` }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
374
+ { label: "Start a build", value: "go" },
375
+ { label: "Settings", value: "settings" },
376
+ { label: "Quit", value: "quit" },
377
+ ], onSelect: (i) => (i.value === "go" ? setPhase("home") : i.value === "settings" ? setPhase("settings") : exit()) }) })] })) : (_jsxs(Panel, { title: "Connect a provider", borderColor: C.warn, children: [_jsx(Text, { color: C.textMuted, children: "No API key yet \u2014 add one in Settings (no file editing)." }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: "Settings — add a key", value: "settings" }, { label: "Quit", value: "quit" }], onSelect: (i) => (i.value === "settings" ? setPhase("settings") : exit()) }) })] })) }));
378
+ }
379
+ if (phase === "home") {
380
+ const projs = listProjects();
381
+ const items = [
382
+ { label: "New build", value: "new" },
383
+ { label: "Start from a template", value: "templates" },
384
+ { label: "Import an existing folder", value: "import" },
385
+ ...(projs.length ? [{ label: `Projects (${projs.length})`, value: "open" }] : []),
386
+ { label: "Compare models (bake-off)", value: "bakeoff" },
387
+ { label: "Settings", value: "settings" },
388
+ { label: "Quit", value: "quit" },
389
+ ];
390
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Panel, { title: "What would you like to do?", children: _jsx(SelectInput, { items: items, onSelect: (i) => {
391
+ if (i.value === "new") {
392
+ resetBuildContext();
393
+ setScope("full");
394
+ setPhase("idea");
395
+ }
396
+ else if (i.value === "templates") {
397
+ setPhase("templates");
398
+ }
399
+ else if (i.value === "import") {
400
+ setAssetPath("");
401
+ setAssetMsg(null);
402
+ setAssetKey((k) => k + 1);
403
+ setPhase("importProject");
404
+ }
405
+ else if (i.value === "open") {
406
+ setProjects(projs);
407
+ setPhase("projects");
408
+ }
409
+ else if (i.value === "bakeoff") {
410
+ setPhase("bakeoff");
411
+ }
412
+ else if (i.value === "settings") {
413
+ setPhase("settings");
414
+ }
415
+ else
416
+ exit();
417
+ } }) }) }));
418
+ }
419
+ if (phase === "settings") {
420
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Settings, { onExit: () => {
421
+ const next = availableProviders();
422
+ setProviders(next);
423
+ setPhase(next.length ? "home" : "setup");
424
+ } }) }));
425
+ }
426
+ if (phase === "bakeoff") {
427
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(BakeOff, { onExit: () => setPhase("home") }) }));
428
+ }
429
+ if (phase === "projects") {
430
+ const mark = (s) => (s === "complete" ? "✓" : s === "halted" ? "⚠" : "·");
431
+ const items = [
432
+ ...projects.map((p) => ({
433
+ label: `${mark(p.status)} ${p.idea.slice(0, 46)}${p.idea.length > 46 ? "…" : ""} $${p.totalCost.toFixed(2)}`,
434
+ value: p.slug,
435
+ })),
436
+ { label: "Back", value: "__back" },
437
+ ];
438
+ const total = projects.reduce((a, p) => a + p.totalCost, 0);
439
+ const nComplete = projects.filter((p) => p.status === "complete").length;
440
+ const nHalted = projects.filter((p) => p.status === "halted").length;
441
+ const nRunning = projects.filter((p) => p.status === "running").length;
442
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Your projects", children: [_jsxs(Box, { gap: 1, flexWrap: "wrap", marginBottom: 1, children: [_jsx(Chip, { label: `${projects.length} projects` }), _jsx(Chip, { label: `$${total.toFixed(2)} spent`, dotColor: C.accent }), nComplete ? _jsx(Chip, { label: `${nComplete} done`, dotColor: C.good }) : null, nHalted ? _jsx(Chip, { label: `${nHalted} halted`, dotColor: C.warn, active: true }) : null, nRunning ? _jsx(Chip, { label: `${nRunning} running`, dotColor: C.info }) : null] }), _jsx(SelectInput, { items: items, onSelect: (i) => {
443
+ if (i.value === "__back")
444
+ setPhase("home");
445
+ else {
446
+ const proj = projects.find((p) => p.slug === i.value) ?? null;
447
+ setSelected(proj);
448
+ setProjectCap(proj?.state.budgetCapUSD ?? null); // carry the saved cap into resume/change
449
+ setEpicFilter(null); // don't carry a filter from a previously-opened project
450
+ setPhase("projectActions");
451
+ }
452
+ } })] }) }));
453
+ }
454
+ if (phase === "projectActions" && selected) {
455
+ const doneIds = completedIds(selected.state);
456
+ // Buildable if it was halted OR the backlog has tasks that were never built.
457
+ const hasUnbuilt = selected.state.tasks.some((t) => !doneIds.has(t.id));
458
+ const canResume = selected.status === "halted" || hasUnbuilt;
459
+ const costById = new Map();
460
+ const modelById = new Map();
461
+ for (const o of selected.state.outcomes) {
462
+ costById.set(o.taskId, (costById.get(o.taskId) ?? 0) + o.cost);
463
+ modelById.set(o.taskId, o.modelId);
464
+ }
465
+ const allBoard = selected.state.tasks.map((t) => ({
466
+ id: t.id,
467
+ capability: t.capability,
468
+ title: t.title,
469
+ epic: t.epic,
470
+ dependsOn: t.dependsOn,
471
+ notes: t.notes,
472
+ status: doneIds.has(t.id) ? "done" : "pending",
473
+ cost: costById.get(t.id),
474
+ assignee: modelById.has(t.id) ? modelLabel(modelById.get(t.id)) : undefined,
475
+ }));
476
+ const statusChip = selected.status === "complete"
477
+ ? { label: "complete", dot: C.good }
478
+ : selected.status === "halted"
479
+ ? { label: "halted", dot: C.warn }
480
+ : { label: "running", dot: C.info };
481
+ const menuGroups = [
482
+ { title: "Work", items: [
483
+ { label: "Add to backlog (describe it, the PM plans it)", value: "change" },
484
+ { label: "Board (view · add · reorder · edit)", value: "kanban" },
485
+ { label: "Add a file / image", value: "asset" },
486
+ ...(canResume ? [{ label: selected.status === "halted" ? "Resume build" : `Build the backlog (${selected.state.tasks.filter((t) => !doneIds.has(t.id)).length} to do)`, value: "resume" }] : []),
487
+ ] },
488
+ { title: "See it", items: [
489
+ { label: "Preview in browser (live server, auto-reload)", value: "preview" },
490
+ ] },
491
+ { title: "Reports", items: [
492
+ { label: "Retro (build summary)", value: "retro" },
493
+ { label: "Burndown (progress + spend)", value: "burndown" },
494
+ { label: "History (per-task commits)", value: "history" },
495
+ { label: "Transcripts (what each role said)", value: "transcripts" },
496
+ ] },
497
+ { title: "Ship", items: [
498
+ { label: "Deploy (Cloudflare, Vercel, Netlify)", value: "deploy" },
499
+ { label: "Export (Markdown, CSV, Jira, Trello)", value: "export" },
500
+ ] },
501
+ { title: "Manage", items: [
502
+ { label: `Budget cap: ${selected.state.budgetCapUSD != null ? `$${selected.state.budgetCapUSD}` : "global default"}`, value: "cap" },
503
+ { label: "Save as template", value: "saveTpl" },
504
+ { label: "Rename", value: "rename" },
505
+ { label: "Duplicate", value: "duplicate" },
506
+ { label: "Delete", value: "delete" },
507
+ { label: "Back", value: "back" },
508
+ ] },
509
+ ];
510
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { flexGrow: 1, marginRight: 2, children: _jsx(Text, { bold: true, wrap: "truncate-end", children: selected.idea }) }), _jsx(Text, { color: statusChip.dot, children: "\u25CF " }), _jsx(Text, { color: C.textMuted, children: statusChip.label })] }), flash ? _jsx(Box, { marginTop: 1, children: _jsx(StatusMessage, { variant: "success", children: flash }) }) : null, _jsx(Box, { marginTop: 1, children: _jsx(Standup, { tasks: allBoard, spent: selected.totalCost }) }), _jsx(Box, { marginTop: 1, children: _jsx(Team, {}) }), _jsx(Box, { marginTop: 1, children: _jsx(GroupedMenu, { groups: menuGroups, maxRows: Math.max(6, termRows - 20), onSelect: (i) => {
511
+ if (i.value !== "export")
512
+ setFlash("");
513
+ if (i.value === "editBoard")
514
+ setPhase("editBoard");
515
+ else if (i.value === "kanban") {
516
+ setFlash("");
517
+ setPhase("kanban");
518
+ }
519
+ else if (i.value === "export") {
520
+ setFlash("");
521
+ setPhase("exportMenu");
522
+ }
523
+ else if (i.value === "deploy") {
524
+ setFlash("");
525
+ setPhase("deployMenu");
526
+ }
527
+ else if (i.value === "view")
528
+ setViewMode((v) => (v === "board" ? "list" : "board"));
529
+ else if (i.value === "filter")
530
+ setPhase("filterEpic");
531
+ else if (i.value === "open")
532
+ openInBrowser(mainFileOf(selected.dir));
533
+ else if (i.value === "history") {
534
+ setFlash("");
535
+ setPhase("history");
536
+ }
537
+ else if (i.value === "transcripts") {
538
+ setFlash("");
539
+ setPhase("transcripts");
540
+ }
541
+ else if (i.value === "retro") {
542
+ setFlash("");
543
+ setNarr({ loading: false, text: getRetroNarrative(selected.dir) ?? "", error: "" });
544
+ setPhase("retro");
545
+ }
546
+ else if (i.value === "burndown") {
547
+ setFlash("");
548
+ setPhase("burndown");
549
+ }
550
+ else if (i.value === "cap") {
551
+ setCapReturn("projectActions");
552
+ setCapDraft(selected.state.budgetCapUSD != null ? String(selected.state.budgetCapUSD) : "");
553
+ setPhase("setCap");
554
+ }
555
+ else if (i.value === "saveTpl") {
556
+ setFlash("");
557
+ setTplName(selected.idea.slice(0, 40));
558
+ setPhase("saveTemplate");
559
+ }
560
+ else if (i.value === "preview") {
561
+ const dir = selected.dir;
562
+ setPreview(null);
563
+ setPhase("preview");
564
+ startStaticServer(dir, { liveReload: true })
565
+ .then((server) => {
566
+ setPreview({ server });
567
+ const main = mainFileOf(dir).split("/").pop() || "index.html";
568
+ openInBrowser(`${server.url}/${main}`);
569
+ })
570
+ .catch((e) => setPreview({ error: e instanceof Error ? e.message : String(e) }));
571
+ }
572
+ else if (i.value === "back")
573
+ setPhase("projects");
574
+ else if (i.value === "rename") {
575
+ setRenameDraft(selected.idea);
576
+ setPhase("rename");
577
+ }
578
+ else if (i.value === "duplicate") {
579
+ const newDir = duplicateProject(selected.dir);
580
+ reselect(newDir);
581
+ setPhase("projectActions");
582
+ }
583
+ else if (i.value === "delete") {
584
+ setPhase("confirmDelete");
585
+ }
586
+ else if (i.value === "asset") {
587
+ setTargetWorkspace(selected.dir);
588
+ setAssetPath("");
589
+ setAssetMsg(null);
590
+ setAssetReturn("projectActions");
591
+ setPhase("addAsset");
592
+ }
593
+ else if (i.value === "change") {
594
+ setTargetWorkspace(selected.dir);
595
+ setSeed(undefined);
596
+ setIdea("");
597
+ setScope("change");
598
+ setPhase("change");
599
+ }
600
+ else if (i.value === "resume") {
601
+ const { registry, lock } = chooseRegistry(providers);
602
+ const done = completedIds(selected.state);
603
+ setPlan({
604
+ tasks: selected.state.tasks,
605
+ provider: lock ?? providers[0],
606
+ modelId: "",
607
+ estCost: estimateTasks(selected.state.tasks, registry).total,
608
+ registry,
609
+ lock,
610
+ });
611
+ setTasks(selected.state.tasks.map((t) => ({
612
+ id: t.id, capability: t.capability, title: t.title,
613
+ status: done.has(t.id) ? "done" : "pending",
614
+ })));
615
+ setSpent(selected.state.totalCost);
616
+ setTargetWorkspace(selected.dir);
617
+ setSeed(selected.state.outcomes);
618
+ setIdea(selected.idea);
619
+ setPhase("building");
620
+ }
621
+ } }) })] }));
622
+ }
623
+ if (phase === "editBoard" && selected) {
624
+ const doneIds = completedIds(selected.state);
625
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(EditableBoard, { tasks: selected.state.tasks, doneIds: doneIds, onSave: (t) => {
626
+ saveProjectTasks(selected.dir, t);
627
+ reselect(selected.dir);
628
+ setPhase("kanban"); // back to the board view
629
+ }, onCancel: () => setPhase("kanban") }) }));
630
+ }
631
+ if (phase === "exportMenu" && selected) {
632
+ const dir = selected.dir;
633
+ const run = (fn, done) => {
634
+ try {
635
+ const out = fn();
636
+ const paths = Array.isArray(out) ? out : [out];
637
+ reselect(dir);
638
+ setFlash(`Exported → ${paths.map((p) => p.split("/").pop()).join(", ")} (in the project folder)`);
639
+ }
640
+ catch (e) {
641
+ setFlash(e instanceof Error ? e.message : "Export failed.");
642
+ }
643
+ done();
644
+ };
645
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Panel, { title: "Export backlog", children: [_jsx(Text, { color: C.textMuted, children: "Writes files into the project folder. Import them into your tool of choice." }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
646
+ { label: "Markdown + CSV (readable + spreadsheet)", value: "md" },
647
+ { label: "Jira CSV (Jira → External System Import → CSV)", value: "jira" },
648
+ { label: "Trello CSV (Trello CSV-import Power-Up)", value: "trello" },
649
+ { label: "All formats", value: "all" },
650
+ { label: "Back", value: "back" },
651
+ ], onSelect: (i) => {
652
+ const back = () => setPhase("projectActions");
653
+ if (i.value === "back")
654
+ return back();
655
+ if (i.value === "md")
656
+ return run(() => { const { md, csv } = exportProject(dir); return [md, csv]; }, back);
657
+ if (i.value === "jira")
658
+ return run(() => exportJira(dir), back);
659
+ if (i.value === "trello")
660
+ return run(() => exportTrello(dir), back);
661
+ if (i.value === "all")
662
+ return run(() => {
663
+ const { md, csv } = exportProject(dir);
664
+ return [md, csv, exportJira(dir), exportTrello(dir)];
665
+ }, back);
666
+ } }) })] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Esc", label: "go back" }] }) })] }));
667
+ }
668
+ if (phase === "deployMenu" && selected) {
669
+ const dir = selected.dir;
670
+ const name = selected.idea || dir.split("/").pop() || "app";
671
+ const start = (target) => {
672
+ setDeployState({ target, status: "running", log: [] });
673
+ setPhase("deploying");
674
+ deploy(target, dir, name, (line) => setDeployState((prev) => (prev ? { ...prev, log: [...prev.log.slice(-60), line] } : prev)))
675
+ .then((res) => setDeployState((prev) => (prev ? { ...prev, status: "done", url: res.url } : prev)))
676
+ .catch((e) => setDeployState((prev) => (prev ? { ...prev, status: "error", error: e instanceof Error ? e.message : String(e) } : prev)));
677
+ };
678
+ const targets = ["cloudflare", "vercel", "netlify"];
679
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Panel, { title: "Deploy", children: [_jsx(Text, { color: C.textMuted, children: "Publishes the built site publicly. Each target needs its CLI installed and signed in." }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
680
+ ...targets.map((t) => ({ label: `${DEPLOY_META[t].label} (${DEPLOY_META[t].cli})`, value: t })),
681
+ { label: "Back", value: "back" },
682
+ ], onSelect: (i) => {
683
+ if (i.value === "back")
684
+ return setPhase("projectActions");
685
+ start(i.value);
686
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: C.textSubtle, children: "Sign-in per target (one-time):" }), targets.map((t) => (_jsxs(Text, { color: C.textSubtle, children: [" ", DEPLOY_META[t].label, ": ", DEPLOY_META[t].auth] }, t)))] })] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Esc", label: "go back" }] }) })] }));
687
+ }
688
+ if (phase === "deploying" && deployState) {
689
+ const ds = deployState;
690
+ const meta = DEPLOY_META[ds.target];
691
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: `Deploy → ${meta.label}`, children: [ds.status === "running" ? _jsx(Spinner, { label: "Deploying\u2026 (first run can take a minute)" }) : null, ds.log.length ? (_jsx(Box, { marginTop: 1, flexDirection: "column", children: ds.log.slice(-8).map((l, idx) => (_jsx(Text, { color: C.textSubtle, wrap: "truncate-end", children: l }, idx))) })) : null, ds.status === "done" ? (_jsx(Box, { marginTop: 1, children: ds.url
692
+ ? _jsxs(StatusMessage, { variant: "success", children: ["Live at ", ds.url] })
693
+ : _jsx(StatusMessage, { variant: "success", children: "Deployed. (No URL parsed \u2014 check the log above.)" }) })) : null, ds.status === "error" ? (_jsx(Box, { marginTop: 1, children: _jsx(StatusMessage, { variant: "error", children: ds.error }) })) : null, ds.status !== "running" ? (_jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: "Back", value: "back" }], onSelect: () => { setDeployState(null); setPhase("projectActions"); } }) })) : null] }) }));
694
+ }
695
+ if (phase === "burndown" && selected) {
696
+ const b = projectBurndown(selected.dir);
697
+ const W = 18;
698
+ const money = (n) => `$${n.toFixed(2)}`;
699
+ const maxCost = b ? Math.max(0.0001, ...b.steps.map((s) => s.cumCost)) : 1;
700
+ const remBar = (rem) => "█".repeat(Math.round((rem / (b?.taskCount || 1)) * W)).padEnd(W, "·");
701
+ const costBar = (c) => "█".repeat(Math.max(0, Math.round((c / maxCost) * W))).padEnd(W, " ");
702
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: `Burndown — ${selected.idea}`, children: [!b || b.steps.length === 0 ? (_jsx(Text, { color: C.textMuted, children: "No steps yet \u2014 run the build." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: C.textMuted, children: ["Tasks remaining after each step (X = completion order, ", b.taskCount, " total)"] }), b.steps.map((s, i) => (_jsxs(Text, { wrap: "truncate-end", children: [`${(i + 1).toString().padStart(2)} ${s.taskId}`.padEnd(9), " ", _jsx(Text, { color: C.accent, children: remBar(s.remaining) }), " ", String(s.remaining).padStart(2), s.retry ? _jsx(Text, { color: C.warn, children: " \u21BB retry" }) : null] }, i))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: C.dim, children: ["Cumulative spend (total ", money(b.totalCost), ")"] }), b.steps.map((s, i) => (_jsxs(Text, { wrap: "truncate-end", children: [`${(i + 1).toString().padStart(2)} ${s.taskId}`.padEnd(9), " ", _jsx(Text, { color: C.good, children: costBar(s.cumCost) }), " ", money(s.cumCost)] }, i)))] })] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: "Back", value: "back" }], onSelect: () => setPhase("projectActions") }) })] }) }));
703
+ }
704
+ if (phase === "kanban" && selected) {
705
+ const done = completedIds(selected.state);
706
+ const costBy = new Map();
707
+ const modelBy = new Map();
708
+ for (const o of selected.state.outcomes) {
709
+ costBy.set(o.taskId, (costBy.get(o.taskId) ?? 0) + o.cost);
710
+ modelBy.set(o.taskId, o.modelId);
711
+ }
712
+ const tasks = selected.state.tasks.map((t) => ({
713
+ id: t.id,
714
+ capability: t.capability,
715
+ title: t.title,
716
+ epic: t.epic,
717
+ dependsOn: t.dependsOn,
718
+ notes: t.notes,
719
+ status: done.has(t.id) ? "done" : "pending",
720
+ cost: costBy.get(t.id),
721
+ assignee: modelBy.has(t.id) ? modelLabel(modelBy.get(t.id)) : undefined,
722
+ }));
723
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: selected.idea }), _jsx(Box, { marginTop: 1, children: _jsx(Standup, { tasks: tasks, spent: selected.totalCost }) }), _jsx(Box, { marginTop: 1, children: _jsx(Panel, { title: "Board", children: _jsx(Kanban, { tasks: tasks, compact: true, maxPerCol: Math.max(4, termRows - 14) }) }) }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
724
+ { label: "Edit board (add · reorder · deps · rename)", value: "edit" },
725
+ { label: "Back", value: "back" },
726
+ ], onSelect: (i) => setPhase(i.value === "edit" ? "editBoard" : "projectActions") }) })] }));
727
+ }
728
+ if (phase === "retro" && selected) {
729
+ const r = projectRetro(selected.dir);
730
+ const money = (n) => `$${n.toFixed(2)}`;
731
+ const maxEpic = r ? Math.max(1, ...r.byEpic.map((e) => e.cost)) : 1;
732
+ const bar = (cost) => "█".repeat(Math.max(1, Math.round((cost / maxEpic) * 16)));
733
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: `Retro — ${selected.idea}`, children: [!r ? (_jsx(Text, { color: C.textMuted, children: "No build data yet." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: C.textMuted, children: "Status " }), r.status, _jsx(Text, { color: C.dim, children: " \u00B7 Cost " }), _jsx(Text, { color: C.accent, children: money(r.totalCost) }), _jsx(Text, { color: C.dim, children: " \u00B7 Tasks " }), r.doneCount, "/", r.taskCount, _jsx(Text, { color: C.dim, children: " \u00B7 Tests " }), _jsxs(Text, { color: C.good, children: [r.tests.passed, "\u2713"] }), " ", _jsxs(Text, { color: r.tests.failed ? (C.bad) : C.dim, children: [r.tests.failed, "\u2717"] })] }), r.estCost > 0 ? (() => {
734
+ const delta = r.estCost > 0 ? Math.round(((r.totalCost - r.estCost) / r.estCost) * 100) : 0;
735
+ const under = r.totalCost <= r.estCost;
736
+ return (_jsxs(Text, { children: [_jsx(Text, { color: C.dim, children: "Predicted " }), money(r.estCost), _jsx(Text, { color: C.dim, children: " \u2192 actual " }), _jsx(Text, { color: C.accent, children: money(r.totalCost) }), _jsxs(Text, { color: under ? (C.good) : C.warn, children: [" ", delta >= 0 ? "+" : "", delta, "% ", under ? "under" : "over"] })] }));
737
+ })() : null, r.byEpic.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: C.dim, children: "Cost by epic" }), r.byEpic.map((e) => (_jsxs(Text, { wrap: "truncate-end", children: [" ", _jsx(Text, { color: C.accent, children: bar(e.cost) }), " ", money(e.cost).padEnd(7), " ", _jsxs(Text, { color: C.dim, children: [e.epic, " (", e.tasks, ")"] })] }, e.epic)))] })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: C.dim, children: "Cost by model" }), r.byModel.map((m) => (_jsxs(Text, { wrap: "truncate-end", children: [" ", money(m.cost).padEnd(7), " ", _jsxs(Text, { color: C.dim, children: [modelLabel(m.model), " (", m.tasks, ")"] })] }, m.model)))] }), r.topCost.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: C.dim, children: "Priciest tasks" }), r.topCost.map((t) => (_jsxs(Text, { wrap: "truncate-end", children: [" ", money(t.cost).padEnd(7), " ", _jsx(Text, { color: C.dim, children: t.taskId }), " ", t.title] }, t.taskId)))] })) : null, r.retries.length ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: C.warn, children: ["Rebuilds: ", r.retries.map((x) => `${x.taskId}×${x.rounds}`).join(", ")] }) })) : null, r.bugs.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: C.dim, children: ["Tester flagged ", r.bugs.length, " issue", r.bugs.length === 1 ? "" : "s", ":"] }), r.bugs.slice(0, 5).map((b, i) => (_jsxs(Text, { wrap: "truncate-end", children: [" ", _jsxs(Text, { color: b.severity === "high" ? (C.bad) : C.warn, children: ["[", b.severity, "]"] }), " ", b.description] }, i)))] })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: C.good, children: "No open issues flagged by the tester." }) })), narr.loading ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Writing the retro narrative\u2026" }) })) : narr.error ? (_jsx(Box, { marginTop: 1, children: _jsx(StatusMessage, { variant: "error", children: narr.error }) })) : narr.text ? (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { title: "AI narrative", children: narr.text.split("\n").map((line, i) => _jsx(Text, { wrap: "wrap", children: line }, i)) }) })) : null] })), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
738
+ ...(r && !narr.loading ? [{ label: narr.text ? "Regenerate AI narrative" : "Generate AI narrative", value: "narrate" }] : []),
739
+ { label: "Back", value: "back" },
740
+ ], onSelect: (i) => {
741
+ if (i.value === "narrate") {
742
+ setNarr({ loading: true, text: "", error: "" });
743
+ generateRetroNarrative(selected.dir, providers)
744
+ .then((text) => setNarr({ loading: false, text, error: "" }))
745
+ .catch((e) => setNarr({ loading: false, text: "", error: e instanceof Error ? e.message : String(e) }));
746
+ }
747
+ else {
748
+ setPhase("projectActions");
749
+ }
750
+ } }) })] }) }));
751
+ }
752
+ if (phase === "history" && selected) {
753
+ const dir = selected.dir;
754
+ const commits = projectHistory(dir);
755
+ const canUndo = commits.length > 1; // more than the initial commit
756
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: `History — ${selected.idea}`, children: [_jsx(Text, { color: C.textMuted, children: "One commit per finished task, newest first. Pick one to see its diff." }), flash ? _jsx(Box, { marginTop: 1, children: _jsx(StatusMessage, { variant: "success", children: flash }) }) : null, _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
757
+ ...commits.slice(0, 20).map((c) => ({ label: `${c.hash} ${c.msg}`, value: c.hash })),
758
+ ...(commits.length ? [] : [{ label: "No history yet (this project predates git-per-build, or git isn't installed).", value: "back" }]),
759
+ ...(canUndo ? [{ label: "Undo last task (revert files + reopen it to rebuild)", value: "undo" }] : []),
760
+ { label: "Back", value: "back" },
761
+ ], onSelect: (i) => {
762
+ if (i.value === "undo") {
763
+ const r = undoLastTask(dir);
764
+ reselect(dir);
765
+ setFlash(r.ok ? `Undid ${r.taskId ?? "last task"}. Resume the build to rebuild it.` : (r.error ?? "Undo failed."));
766
+ }
767
+ else if (i.value === "back") {
768
+ setFlash("");
769
+ setPhase("projectActions");
770
+ }
771
+ else {
772
+ const c = commits.find((x) => x.hash === i.value);
773
+ const d = commitDiff(dir, c.hash);
774
+ const lines = d.stat.length ? [...d.stat, "", ...d.patch] : ["(empty commit — no file changes)"];
775
+ setFlash("");
776
+ setDiffView({ hash: c.hash, msg: c.msg, lines, scroll: 0 });
777
+ setPhase("diff");
778
+ }
779
+ } }) })] }) }));
780
+ }
781
+ if (phase === "diff" && selected && diffView) {
782
+ const page = Math.max(4, termRows - 18);
783
+ const { lines } = diffView;
784
+ const scroll = Math.min(diffView.scroll, Math.max(0, lines.length - page));
785
+ const color = (l) => l.startsWith("+++") || l.startsWith("---") ? C.textMuted
786
+ : l.startsWith("+") ? C.good
787
+ : l.startsWith("-") ? C.bad
788
+ : l.startsWith("@@") ? C.accent
789
+ : l.startsWith("diff ") ? C.textSubtle
790
+ : C.text;
791
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: `${diffView.hash} · ${diffView.msg}`, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: selected.idea }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: lines.slice(scroll, scroll + page).map((l, i) => _jsx(Text, { color: color(l), wrap: "truncate-end", children: l || " " }, scroll + i)) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: C.textSubtle, children: lines.length > page ? `lines ${scroll + 1}–${Math.min(scroll + page, lines.length)} of ${lines.length} ` : "" }), _jsx(KeyHint, { hints: [{ keys: "↑↓ PgUp PgDn", label: "scroll" }, { keys: "Esc", label: "back" }] })] })] }) }));
792
+ }
793
+ if (phase === "transcripts" && selected) {
794
+ const outcomes = selected.state.outcomes;
795
+ const titleById = new Map(selected.state.tasks.map((t) => [t.id, t.title]));
796
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: `Transcripts — ${selected.idea}`, children: [_jsx(Text, { color: C.textMuted, children: "Every run, in order \u2014 retries included. Pick one to read what the role said." }), _jsx(Box, { marginTop: 1, children: outcomes.length ? (_jsx(SelectInput, { items: [
797
+ ...outcomes.map((o, i) => ({
798
+ label: `${o.taskId.padEnd(6)} ${ROLE_META[o.capability].emoji} ${o.capability.padEnd(7)}${o.round ? ` r${o.round}` : " "} $${o.cost.toFixed(2).padStart(5)} ${o.error ? "ABORTED" : o.verdict ? verdictLabel(o.verdict, o.capability) : ""}`.padEnd(40) + ` ${(titleById.get(o.taskId) ?? "").slice(0, 40)}`,
799
+ value: String(i),
800
+ })),
801
+ { label: "Back", value: "back" },
802
+ ], onSelect: (i) => {
803
+ if (i.value === "back")
804
+ return setPhase("projectActions");
805
+ setTranscript({ outcome: outcomes[Number(i.value)], scroll: 0 });
806
+ setPhase("transcript");
807
+ } })) : (_jsx(Text, { color: C.textMuted, children: "Nothing has run yet." })) })] }) }));
808
+ }
809
+ if (phase === "transcript" && selected && transcript) {
810
+ const o = transcript.outcome;
811
+ const title = selected.state.tasks.find((t) => t.id === o.taskId)?.title ?? o.taskId;
812
+ const lines = [];
813
+ if (o.error)
814
+ lines.push(`⛔ Aborted: ${o.error}`, "");
815
+ if (o.verdict) {
816
+ lines.push(`Verdict: ${verdictLabel(o.verdict, o.capability)}${o.capability === "test" && !o.verdict.runtimeChecked ? " (app not executed — no Chromium)" : ""}`);
817
+ for (const b of o.verdict.bugs)
818
+ lines.push(` • [${b.severity}] ${b.description}${b.file ? ` (${b.file})` : ""}`);
819
+ lines.push("");
820
+ }
821
+ lines.push(...(o.finalText.trim() || "(the role produced no text — files only)").split("\n"));
822
+ if (o.files.length)
823
+ lines.push("", `Files after this run: ${o.files.join(", ")}`);
824
+ const page = Math.max(4, termRows - 18);
825
+ const scroll = Math.min(transcript.scroll, Math.max(0, lines.length - page));
826
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: `${o.taskId} · ${ROLE_META[o.capability].label}${o.round ? ` · round ${o.round}` : ""} · ${modelLabel(o.modelId)} · $${o.cost.toFixed(2)}`, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: title }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: lines.slice(scroll, scroll + page).map((l, i) => _jsx(Text, { wrap: "truncate-end", children: l || " " }, scroll + i)) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: C.textSubtle, children: lines.length > page ? `lines ${scroll + 1}–${Math.min(scroll + page, lines.length)} of ${lines.length} ` : "" }), _jsx(KeyHint, { hints: [{ keys: "↑↓ PgUp PgDn", label: "scroll" }, { keys: "Esc", label: "back" }] })] })] }) }));
827
+ }
828
+ if (phase === "preview") {
829
+ const stop = () => {
830
+ if (preview && "server" in preview)
831
+ void preview.server.close();
832
+ setPreview(null);
833
+ setPhase("projectActions");
834
+ };
835
+ const url = preview && "server" in preview ? preview.server.url : null;
836
+ const err = preview && "error" in preview ? preview.error : null;
837
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Live preview", children: [_jsxs(Box, { flexDirection: "column", children: [err ? _jsx(StatusMessage, { variant: "error", children: err }) : null, !err && !url ? _jsx(Spinner, { label: "Starting local server\u2026" }) : null, url ? (_jsxs(_Fragment, { children: [_jsxs(StatusMessage, { variant: "success", children: ["Serving at ", url] }), _jsx(Text, { color: C.textMuted, children: "Opened in your browser. It auto-reloads when the files change" }), _jsx(Text, { color: C.textMuted, children: "(e.g. after a resume/change build). ES modules + fetch work here," }), _jsx(Text, { color: C.textMuted, children: "unlike opening the file directly." })] })) : null] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: "Stop preview & go back", value: "stop" }], onSelect: stop }) })] }) }));
838
+ }
839
+ if (phase === "filterEpic" && selected) {
840
+ const epics = [...new Set(selected.state.tasks.map((t) => t.epic || "General"))];
841
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Panel, { title: "Filter by epic", children: _jsx(SelectInput, { items: [
842
+ { label: "All epics", value: "__all" },
843
+ ...epics.map((e) => ({ label: e, value: e })),
844
+ ], onSelect: (i) => {
845
+ setEpicFilter(i.value === "__all" ? null : i.value);
846
+ setPhase("projectActions");
847
+ } }) }) }));
848
+ }
849
+ if (phase === "rename" && selected) {
850
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Rename project", children: [_jsxs(Box, { children: [_jsx(Text, { color: C.accent, children: "› " }), _jsx(TextInput, { value: renameDraft, onChange: setRenameDraft, onSubmit: () => {
851
+ if (renameDraft.trim())
852
+ renameProject(selected.dir, renameDraft.trim());
853
+ reselect(selected.dir);
854
+ setPhase("projectActions");
855
+ } })] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Enter", label: "save" }, { keys: "Esc", label: "go back" }] }) })] }) }));
856
+ }
857
+ if (phase === "confirmDelete" && selected) {
858
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Delete this project?", borderColor: C.bad, children: [_jsx(Text, { color: C.textMuted, wrap: "truncate-end", children: selected.idea }), _jsx(Text, { color: C.textSubtle, children: "This permanently removes its folder and files. Can't be undone." }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
859
+ { label: "No, keep it", value: "no" },
860
+ { label: "Yes, delete", value: "yes" },
861
+ ], onSelect: (i) => {
862
+ if (i.value === "yes") {
863
+ deleteProject(selected.dir);
864
+ const list = reselect(null);
865
+ setPhase(list.length ? "projects" : "home");
866
+ }
867
+ else
868
+ setPhase("projectActions");
869
+ } }) })] }) }));
870
+ }
871
+ if (phase === "addAsset") {
872
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Add a file to this project", children: [_jsx(Text, { color: C.textMuted, children: "Paste the full path to an image or file. Tip: drag the file into the terminal to paste its path. ~ works." }), assetMsg ? _jsxs(Text, { color: assetMsg.ok ? C.good : C.bad, children: ["\n", assetMsg.ok ? "✓ " : "✗ ", assetMsg.text] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: C.accent, children: "› " }), _jsx(TextInput, { value: assetPath, onChange: setAssetPath, placeholder: "/Users/you/Pictures/avatar.png", onSubmit: () => {
873
+ if (!assetPath.trim()) {
874
+ setPhase(assetReturn);
875
+ return;
876
+ }
877
+ const r = targetWorkspace
878
+ ? addAsset(targetWorkspace, assetPath)
879
+ : { ok: false, error: "No project selected." };
880
+ setAssetMsg(r.ok ? { ok: true, text: `Added ${r.name}. You can now ask to use it in “Make changes”.` } : { ok: false, text: r.error });
881
+ if (r.ok) {
882
+ setAssetPath("");
883
+ setAssetKey((k) => k + 1);
884
+ }
885
+ } }, assetKey)] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Enter", label: "add" }, { keys: "Esc", label: "go back" }] }) })] }) }));
886
+ }
887
+ if (phase === "importProject") {
888
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Import an existing folder as a project", children: [_jsx(Text, { color: C.textMuted, children: "Paste the path to a folder. Its files are copied into a new project (node_modules," }), _jsx(Text, { color: C.textMuted, children: ".git, dist skipped) and versioned; then \u201CAdd to backlog\u201D plans changes against them." }), _jsx(Text, { color: C.textMuted, children: "Tip: drag the folder into the terminal to paste its path. ~ works." }), assetMsg ? _jsxs(Text, { color: assetMsg.ok ? C.good : C.bad, children: ["\n", assetMsg.ok ? "✓ " : "✗ ", assetMsg.text] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: C.accent, children: "› " }), _jsx(TextInput, { value: assetPath, onChange: setAssetPath, placeholder: "~/code/my-landing-page", onSubmit: () => {
889
+ if (!assetPath.trim())
890
+ return setPhase("home");
891
+ const r = importProject(assetPath);
892
+ if (!r.ok) {
893
+ setAssetMsg({ ok: false, text: r.error });
894
+ return;
895
+ }
896
+ reselect(r.dir);
897
+ setFlash(`Imported ${r.files} file${r.files === 1 ? "" : "s"}. Use “Add to backlog” to plan a change.`);
898
+ setPhase("projectActions");
899
+ } }, assetKey)] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Enter", label: "import" }, { keys: "Esc", label: "back" }] }) })] }) }));
900
+ }
901
+ if (phase === "change") {
902
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Add to the backlog", children: [_jsx(Text, { color: C.textMuted, children: "Describe what to add or change \u2014 the PM plans it into new tasks against this project's files." }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: C.accent, children: "› " }), _jsx(TextInput, { value: idea, onChange: setIdea, onSubmit: () => idea.trim() && setPhase("assessing"), placeholder: "make the header dark blue and add a footer with a copyright line" })] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Enter", label: "continue" }, { keys: "Esc", label: "go back" }] }) })] }) }));
903
+ }
904
+ if (phase === "saveTemplate" && selected) {
905
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Save as template", children: [_jsx(Text, { color: C.textMuted, children: "Reuse this project's brief as a starting point for future builds." }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: C.accent, children: "name › " }), _jsx(TextInput, { value: tplName, onChange: setTplName, onSubmit: () => {
906
+ const name = tplName.trim();
907
+ if (!name) {
908
+ setPhase("projectActions");
909
+ return;
910
+ }
911
+ saveUserTemplate({ name, blurb: selected.idea.slice(0, 48), idea: selected.state.idea ?? selected.idea });
912
+ setFlash(`Saved template “${name}”.`);
913
+ setPhase("projectActions");
914
+ } })] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Enter", label: "save" }, { keys: "Esc", label: "cancel" }] }) })] }) }));
915
+ }
916
+ if (phase === "importTemplate") {
917
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Import a shared template", children: [_jsx(Text, { color: C.textMuted, children: "Paste the path to a .pitemplate.json file someone shared." }), flash ? _jsx(Box, { marginTop: 1, children: _jsx(StatusMessage, { variant: "error", children: flash }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: C.accent, children: "› " }), _jsx(TextInput, { value: tplPath, onChange: setTplPath, onSubmit: () => {
918
+ if (!tplPath.trim()) {
919
+ setPhase("templates");
920
+ return;
921
+ }
922
+ try {
923
+ const t = importTemplate(tplPath);
924
+ setFlash(`Imported “${t.name}”.`);
925
+ setPhase("templates");
926
+ }
927
+ catch (e) {
928
+ setFlash(e instanceof Error ? e.message : "Import failed.");
929
+ }
930
+ } })] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Enter", label: "import" }, { keys: "Esc", label: "cancel" }] }) })] }) }));
931
+ }
932
+ if (phase === "myTemplates") {
933
+ const mine = allTemplates().filter((t) => !t.builtin);
934
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "My templates", children: [flash ? _jsx(Box, { marginBottom: 1, children: _jsx(StatusMessage, { variant: "success", children: flash }) }) : null, _jsx(SelectInput, { items: [
935
+ ...mine.map((t) => ({ label: `${t.name} — ${t.blurb}`, value: t.name })),
936
+ { label: "Back", value: "__back" },
937
+ ], onSelect: (i) => {
938
+ if (i.value === "__back") {
939
+ setFlash("");
940
+ setPhase("templates");
941
+ return;
942
+ }
943
+ setTplSel(mine.find((t) => t.name === i.value) ?? null);
944
+ setPhase("tplActions");
945
+ } }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Esc", label: "go back" }] }) })] }) }));
946
+ }
947
+ if (phase === "tplActions" && tplSel) {
948
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: tplSel.name, children: [flash ? _jsx(Box, { marginBottom: 1, children: _jsx(StatusMessage, { variant: "success", children: flash }) }) : null, _jsx(SelectInput, { items: [
949
+ { label: "Share — export to a file", value: "export" },
950
+ { label: "Delete", value: "delete" },
951
+ { label: "Back", value: "back" },
952
+ ], onSelect: (i) => {
953
+ if (i.value === "export") {
954
+ const p = exportTemplate(tplSel);
955
+ setFlash(`Shared → ${p}`);
956
+ setPhase("myTemplates");
957
+ }
958
+ else if (i.value === "delete") {
959
+ deleteUserTemplate(tplSel.name);
960
+ setFlash(`Deleted “${tplSel.name}”.`);
961
+ setPhase("myTemplates");
962
+ }
963
+ else
964
+ setPhase("myTemplates");
965
+ } })] }) }));
966
+ }
967
+ if (phase === "templates") {
968
+ const tpls = allTemplates();
969
+ const hasUser = tpls.some((t) => !t.builtin);
970
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Start from a template", children: [_jsx(Text, { color: C.textMuted, children: "Pick one \u2014 you can edit the tasks on the board after. \u2605 = yours." }), flash ? _jsx(Box, { marginTop: 1, children: _jsx(StatusMessage, { variant: "success", children: flash }) }) : null, _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
971
+ ...tpls.map((t) => ({ label: `${t.builtin ? " " : "★ "}${t.name} — ${t.blurb}`, value: `t:${t.name}` })),
972
+ { label: "Import a shared template…", value: "__import" },
973
+ ...(hasUser ? [{ label: "Manage my templates", value: "__manage" }] : []),
974
+ { label: "Back", value: "__back" },
975
+ ], onSelect: (i) => {
976
+ if (i.value === "__back") {
977
+ setFlash("");
978
+ setPhase("home");
979
+ return;
980
+ }
981
+ if (i.value === "__import") {
982
+ setFlash("");
983
+ setTplPath("");
984
+ setPhase("importTemplate");
985
+ return;
986
+ }
987
+ if (i.value === "__manage") {
988
+ setFlash("");
989
+ setPhase("myTemplates");
990
+ return;
991
+ }
992
+ const name = i.value.slice(2);
993
+ const tpl = tpls.find((t) => t.name === name);
994
+ resetBuildContext();
995
+ setScope("full");
996
+ setIdea(tpl.idea);
997
+ setPhase("stack");
998
+ } }) }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Esc", label: "go back" }] }) })] }) }));
999
+ }
1000
+ if (phase === "idea") {
1001
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "What do you want to build?", children: [_jsxs(Box, { children: [_jsx(Text, { color: C.accent, children: "› " }), _jsx(TextInput, { value: idea, onChange: setIdea, onSubmit: () => idea.trim() && setPhase("stack"), placeholder: "a landing page for a coffee shop with a menu and contact form" })] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Enter", label: "continue" }, { keys: "Esc", label: "go back" }] }) })] }) }));
1002
+ }
1003
+ if (phase === "stack") {
1004
+ // When a default stack is set, the effect above skips straight to assessing.
1005
+ if (getPreferredStack() !== "ask") {
1006
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Spinner, { label: "Preparing\u2026" }) }));
1007
+ }
1008
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What should I build it with?" }), _jsx(Box, { marginTop: 1, children: _jsx(StackPick, { onDone: (choice) => {
1009
+ setStackChoice(choice);
1010
+ setPhase("assessing");
1011
+ } }) })] }));
1012
+ }
1013
+ if (phase === "assessing") {
1014
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Spinner, { label: "Reading your request\u2026" }), _jsxs(Text, { color: C.dim, children: ["\n", "\u201C", idea, "\u201D"] })] }));
1015
+ }
1016
+ if (phase === "intake") {
1017
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "A few quick questions" }), _jsx(Text, { color: C.dim, children: "The PM needs a little more to build the right thing." }), _jsx(Box, { marginTop: 1, children: _jsx(Intake, { questions: intakeQs, onDone: (answers) => {
1018
+ setIntakeAnswers(answers);
1019
+ setPhase("planMode");
1020
+ }, onCancel: () => setPhase("idea") }) })] }));
1021
+ }
1022
+ if (phase === "planMode") {
1023
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "How should the team plan this?", children: [_jsx(SelectInput, { items: [
1024
+ { label: "Quick plan — one PM breaks it into tasks", value: "quick" },
1025
+ { label: "Deep plan — a council debates epics first, you approve (costs more)", value: "deep" },
1026
+ ], onSelect: (i) => {
1027
+ if (i.value === "deep")
1028
+ setPhase("council");
1029
+ else {
1030
+ setApprovedEpics(null);
1031
+ setPhase("planning");
1032
+ }
1033
+ } }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: C.textSubtle, children: "Deep plan is worth it for bigger/complex builds." }), _jsx(KeyHint, { hints: [{ keys: "Esc", label: "go back" }] })] })] }) }));
1034
+ }
1035
+ if (phase === "council") {
1036
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Planning council in session\u2026" }), _jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Architect, product, and risk leads are proposing epics, then synthesizing\u2026" }) })] }));
1037
+ }
1038
+ if (phase === "approveEpics" && council.result) {
1039
+ const epics = council.result.epics;
1040
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: `Proposed epics (${epics.length})`, children: [_jsx(Text, { color: C.textMuted, children: "The council merged the architect / product / risk views. Approve to expand into tasks." }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: epics.map((e, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { children: _jsxs(Text, { color: C.accent, children: [i + 1, ". ", e.name] }) }), _jsxs(Text, { color: C.textSubtle, wrap: "wrap", children: [" ", e.rationale] })] }, i))) }), _jsx(SelectInput, { items: [
1041
+ { label: "Approve — expand these epics into tasks", value: "approve" },
1042
+ { label: "Skip epics — quick plan instead", value: "quick" },
1043
+ { label: "Back", value: "back" },
1044
+ ], onSelect: (i) => {
1045
+ if (i.value === "approve") {
1046
+ setApprovedEpics(epics);
1047
+ setPhase("planning");
1048
+ }
1049
+ else if (i.value === "quick") {
1050
+ setApprovedEpics(null);
1051
+ setPhase("planning");
1052
+ }
1053
+ else
1054
+ setPhase("planMode");
1055
+ } })] }) }));
1056
+ }
1057
+ if (phase === "planning") {
1058
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Spinner, { label: approvedEpics ? "Expanding the approved epics into tasks…" : "Breaking your idea into a plan…" }), _jsxs(Text, { color: C.dim, children: ["\n", "\u201C", idea, "\u201D"] })] }));
1059
+ }
1060
+ if (phase === "setCap") {
1061
+ const globalCap = getPrefs().budgetCapUSD;
1062
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Panel, { title: "Budget cap for this project", children: [_jsxs(Text, { color: C.textMuted, children: ["The build halts if spend passes this. Leave blank to use the global default ($", globalCap, ")."] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: C.accent, children: "$ " }), _jsx(TextInput, { value: capDraft, onChange: setCapDraft, onSubmit: () => {
1063
+ const v = capDraft.trim();
1064
+ const parsed = v === "" ? null : parseFloat(v);
1065
+ const cap = parsed != null && parsed > 0 ? parsed : null;
1066
+ if (capReturn === "projectActions" && selected) {
1067
+ setProjectBudget(selected.dir, cap ?? undefined);
1068
+ setProjectCap(cap);
1069
+ reselect(selected.dir);
1070
+ setPhase("projectActions");
1071
+ }
1072
+ else {
1073
+ setProjectCap(cap);
1074
+ setPhase("plan");
1075
+ }
1076
+ } })] }), _jsx(Box, { marginTop: 1, children: _jsx(KeyHint, { hints: [{ keys: "Enter", label: "save" }, { keys: "Esc", label: "cancel" }] }) })] }) }));
1077
+ }
1078
+ if (phase === "plan" && plan) {
1079
+ const prefs = getPrefs();
1080
+ const effCap = projectCap ?? prefs.budgetCapUSD;
1081
+ const overCap = plan.estCost > effCap;
1082
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: C.good, children: "\u2713 Plan ready" }), _jsx(Box, { marginTop: 1, children: _jsx(Team, {}) }), _jsxs(Box, { marginTop: 1, gap: 1, flexWrap: "wrap", children: [_jsx(Chip, { label: `${plan.tasks.length} tasks`, dotColor: C.accent }), _jsx(Chip, { label: `est $${plan.estCost.toFixed(2)}`, dotColor: overCap ? C.bad : C.good, active: overCap }), _jsx(Chip, { label: `cap $${effCap}${projectCap != null ? " · project" : ""}` })] }), _jsxs(Text, { color: C.textSubtle, children: [" ", "Runs ", prefs.concurrency, " at once \u00B7 ", targetWorkspace ? "edits this project's files" : "output to a fresh folder", " \u00B7 ", mode === "approval" ? "approval-gated" : "auto-run"] }), overCap && _jsxs(Text, { color: C.bad, children: ["\n", "Estimate exceeds the $", effCap, " cap \u2014 it may halt partway."] }), (() => {
1083
+ const rows = costMatrix(plan.tasks, plan.registry, providers, plan.lock);
1084
+ if (rows.length < 2)
1085
+ return null;
1086
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: C.textMuted, children: "Same backlog, other rosters (change under Settings \u2192 Preferred provider):" }), rows.map((r) => (_jsxs(Text, { children: [" ", _jsxs(Text, { color: r.current ? C.accent : C.text, children: [r.current ? "▶ " : " ", r.label.padEnd(24)] }), _jsxs(Text, { color: r.current ? C.accent : C.dim, children: ["$", r.total.toFixed(2)] })] }, r.label)))] }));
1087
+ })(), _jsx(Box, { marginTop: 1, children: _jsx(Panel, { title: "Ready?", children: _jsx(SelectInput, { items: [
1088
+ // When adding to an existing project, let the user just queue the
1089
+ // new tasks without building them right now.
1090
+ ...(scope === "change" && selected
1091
+ ? [{ label: `Add ${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"} to the backlog (don't build yet)`, value: "backlog" }]
1092
+ : []),
1093
+ ...(mode === "approval"
1094
+ ? [
1095
+ { label: `Open the board & approve (${plan.tasks.length}) →`, value: "board" },
1096
+ { label: `Budget cap: $${effCap}`, value: "cap" },
1097
+ { label: "Change idea", value: "idea" },
1098
+ { label: "Switch to auto-run", value: "auto" },
1099
+ { label: "Quit", value: "quit" },
1100
+ ]
1101
+ : [
1102
+ { label: `Plan the sprint on the board (${plan.tasks.length} in backlog) →`, value: "board" },
1103
+ { label: `Build everything now ($${plan.estCost.toFixed(2)})`, value: "build" },
1104
+ { label: `Budget cap: $${effCap}`, value: "cap" },
1105
+ { label: "Change idea", value: "idea" },
1106
+ { label: "Switch to approval-gated", value: "approval" },
1107
+ { label: "Quit", value: "quit" },
1108
+ ]),
1109
+ ], onSelect: (i) => {
1110
+ if (i.value === "backlog" && selected) {
1111
+ // Merge the new tasks into the project's backlog without building.
1112
+ // Give them clean sequential ids (T-03, T-04, …) so they read well.
1113
+ const existing = selected.state.tasks;
1114
+ const used = new Set(existing.map((t) => t.id));
1115
+ let n = 0;
1116
+ const nextId = () => {
1117
+ let id;
1118
+ do {
1119
+ id = `T-${String(++n).padStart(2, "0")}`;
1120
+ } while (used.has(id));
1121
+ used.add(id);
1122
+ return id;
1123
+ };
1124
+ const idMap = new Map();
1125
+ const reided = plan.tasks.map((t) => {
1126
+ const nid = nextId();
1127
+ idMap.set(t.id, nid);
1128
+ return { ...t, id: nid };
1129
+ });
1130
+ const merged = [
1131
+ ...existing,
1132
+ ...reided.map((t) => ({ ...t, dependsOn: (t.dependsOn ?? []).map((d) => idMap.get(d) ?? d) })),
1133
+ ];
1134
+ saveProjectTasks(selected.dir, merged);
1135
+ reselect(selected.dir);
1136
+ setFlash(`Added ${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"} to the backlog.`);
1137
+ setPhase("kanban");
1138
+ }
1139
+ else if (i.value === "build")
1140
+ setPhase("building");
1141
+ else if (i.value === "board")
1142
+ setPhase("board");
1143
+ else if (i.value === "cap") {
1144
+ setCapReturn("plan");
1145
+ setCapDraft(String(effCap));
1146
+ setPhase("setCap");
1147
+ }
1148
+ else if (i.value === "auto")
1149
+ setMode("auto");
1150
+ else if (i.value === "approval")
1151
+ setMode("approval");
1152
+ else if (i.value === "idea") {
1153
+ setIdea("");
1154
+ // A change build must go back to the change screen (keeps its
1155
+ // targetWorkspace + scope), not the fresh-build idea/stack path.
1156
+ setPhase(scope === "change" ? "change" : "idea");
1157
+ }
1158
+ else
1159
+ exit();
1160
+ } }) }) })] }));
1161
+ }
1162
+ if (phase === "board" && plan) {
1163
+ const commit = (newTasks) => {
1164
+ const safe = newTasks.length ? newTasks : plan.tasks; // never build an empty board
1165
+ const { total } = estimateTasks(safe, plan.registry);
1166
+ setPlan({ ...plan, tasks: safe, estCost: total });
1167
+ setTasks(safe.map((t) => ({ id: t.id, capability: t.capability, status: "pending", title: t.title })));
1168
+ // In approval mode, approving on the board IS the go-ahead → build now.
1169
+ setPhase(mode === "approval" ? "building" : "plan");
1170
+ };
1171
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(BoardEditor, { tasks: plan.tasks, onDone: commit, onCancel: () => setPhase("plan"), onBreakdown: (epic, current) => breakdownEpic(epic, current, providers, targetWorkspace) }) }));
1172
+ }
1173
+ if (phase === "building") {
1174
+ const running = tasks.filter((t) => t.status === "running").length;
1175
+ const metaById = new Map((plan?.tasks ?? []).map((t) => [t.id, { deps: t.dependsOn ?? [], epic: t.epic, notes: t.notes, difficulty: t.difficulty }]));
1176
+ const timeoutMs = getPrefs().taskTimeoutMin * 60_000;
1177
+ const board = tasks.map((t) => {
1178
+ const meta = metaById.get(t.id);
1179
+ const elapsedMs = t.status === "running" && t.startedAt ? now - t.startedAt : undefined;
1180
+ return {
1181
+ id: t.id,
1182
+ capability: t.capability,
1183
+ title: t.title,
1184
+ epic: meta?.epic,
1185
+ dependsOn: meta?.deps,
1186
+ notes: meta?.notes,
1187
+ status: t.status,
1188
+ cost: t.cost,
1189
+ verdict: t.verdict,
1190
+ assignee: t.model ? modelLabel(t.model) : undefined,
1191
+ elapsedSec: elapsedMs !== undefined ? Math.floor(elapsedMs / 1000) : undefined,
1192
+ stuck: elapsedMs !== undefined && isStuck(elapsedMs, t.capability, meta?.difficulty ?? "medium", t.model, timeoutMs),
1193
+ };
1194
+ });
1195
+ const slow = board.filter((b) => b.stuck).length;
1196
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(InkSpinner, { type: "dots" }) }), _jsxs(Text, { bold: true, children: [" ", "Building"] }), _jsx(Text, { color: C.textSubtle, children: ` ${running} running` }), slow ? _jsx(Text, { color: C.warn, children: ` · ${slow} slow (over the usual time — the per-task timeout still applies)` }) : null] }), gate ? (_jsx(Box, { marginTop: 1, children: _jsxs(Panel, { title: "Review gate \u2014 design done", borderColor: C.accent, children: [_jsx(Text, { children: "Design is complete. Approve to start development, or stop here." }), _jsxs(Text, { color: C.dim, children: ["Spent so far: $", spent.toFixed(2), ". Open the files to review before deciding."] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
1197
+ { label: "Approve — start development", value: "continue" },
1198
+ { label: "Stop here", value: "stop" },
1199
+ ], onSelect: (i) => {
1200
+ gate.resolve(i.value);
1201
+ setGate(null);
1202
+ } }) })] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Panel, { title: "Board", children: _jsx(Kanban, { tasks: board, compact: true, maxPerCol: Math.max(2, Math.floor((termRows - (gate ? 20 : 12)) / 2)) }) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Standup, { tasks: board, spent: spent }) }), (() => {
1203
+ const prefs = getPrefs();
1204
+ const cap = projectCap ?? prefs.budgetCapUSD;
1205
+ const pct = prefs.budgetAlertPct;
1206
+ const alerting = spent >= cap * (pct / 100) && spent < cap;
1207
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(BudgetBar, { spent: spent, cap: cap }), alerting ? (_jsxs(Text, { color: C.warn, children: ["\u26A0 ", Math.round((spent / cap) * 100), "% of the $", cap, " cap spent \u2014 nearing the limit."] })) : null, board.some((t) => t.verdict === "PASS*") ? (_jsxs(Text, { color: C.warn, children: ["\u26A0 Tester could not run the app (no headless Chromium) \u2014 PASS* verdicts are code-reading only. ", CHROMIUM_INSTALL_HINT, "."] })) : null] }));
1208
+ })()] }));
1209
+ }
1210
+ if (phase === "done" && buildResult) {
1211
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: buildResult.halted ? C.warn : C.good, children: buildResult.halted ? `⚠ Build halted (${buildResult.haltReason ?? "budget cap"})` : "✓ Build complete" }), tasks.some((t) => t.verdict === "PASS*") ? (_jsxs(Text, { color: C.warn, children: ["\u26A0 Tests marked PASS* were never executed in a browser \u2014 ", CHROMIUM_INSTALL_HINT, "."] })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Standup, { tasks: tasks.map((t) => ({ id: t.id, capability: t.capability, title: t.title, status: t.status, cost: t.cost, verdict: t.verdict })), spent: spent }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Panel, { title: "Result", children: [_jsxs(Box, { gap: 1, flexWrap: "wrap", marginBottom: 1, children: [_jsx(Chip, { label: `$${spent.toFixed(2)} total`, dotColor: C.accent }), _jsx(Chip, { label: `${buildResult.files.length} file${buildResult.files.length === 1 ? "" : "s"}`, dotColor: C.good })] }), _jsxs(Text, { color: C.textSubtle, wrap: "truncate-end", children: ["Location: ", buildResult.workspace] }), buildResult.halted && _jsx(Text, { color: C.textMuted, children: "Choose \u201CResume build\u201D from your projects to continue." })] }) }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
1212
+ ...(buildResult.files.some((f) => f.endsWith(".html")) ? [{ label: "Open in browser", value: "open" }] : []),
1213
+ { label: "Add to backlog (describe it, the PM plans it)", value: "change" },
1214
+ { label: "Add a file / image", value: "asset" },
1215
+ { label: "New build", value: "new" },
1216
+ { label: "Home", value: "home" },
1217
+ { label: "Quit", value: "quit" },
1218
+ ], onSelect: (i) => {
1219
+ if (i.value === "open")
1220
+ openInBrowser(mainFileOf(buildResult.workspace));
1221
+ else if (i.value === "asset") {
1222
+ setTargetWorkspace(buildResult.workspace);
1223
+ setAssetPath("");
1224
+ setAssetMsg(null);
1225
+ setAssetReturn("done");
1226
+ setPhase("addAsset");
1227
+ }
1228
+ else if (i.value === "change") {
1229
+ setTargetWorkspace(buildResult.workspace);
1230
+ setSeed(undefined);
1231
+ setIdea("");
1232
+ setScope("change");
1233
+ setPhase("change");
1234
+ }
1235
+ else if (i.value === "new") {
1236
+ resetBuildContext();
1237
+ setScope("full");
1238
+ setPhase("idea");
1239
+ }
1240
+ else if (i.value === "home") {
1241
+ resetBuildContext();
1242
+ setPhase("home");
1243
+ }
1244
+ else
1245
+ exit();
1246
+ } }) })] }));
1247
+ }
1248
+ if (phase === "error") {
1249
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: C.bad, children: "Something went wrong:" }), _jsxs(Text, { color: C.dim, children: [" ", error] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
1250
+ { label: "Try again", value: "retry" },
1251
+ { label: "Home", value: "home" },
1252
+ { label: "Quit", value: "quit" },
1253
+ ], onSelect: (i) => {
1254
+ if (i.value === "retry")
1255
+ setPhase(scope === "change" ? "change" : "idea");
1256
+ else if (i.value === "home") {
1257
+ resetBuildContext();
1258
+ setScope("full");
1259
+ setPhase("home");
1260
+ }
1261
+ else
1262
+ exit();
1263
+ } }) })] }));
1264
+ }
1265
+ return (_jsx(Box, { children: _jsx(Spinner, { label: "Loading\u2026" }) }));
1266
+ })();
1267
+ return (_jsx(AppFrame, { projectName: selected?.idea, phase: phase, children: screen }));
1268
+ }
1269
+ //# sourceMappingURL=App.js.map