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.
- package/README.md +7 -6
- package/bin/projectinator.mjs +22 -15
- package/dist/bakeoff.js +163 -0
- package/dist/bakeoff.js.map +1 -0
- package/dist/build-state.js +26 -0
- package/dist/build-state.js.map +1 -0
- package/dist/burndown.js +19 -0
- package/dist/burndown.js.map +1 -0
- package/dist/calibration.js +86 -0
- package/dist/calibration.js.map +1 -0
- package/dist/cli.js +289 -0
- package/dist/cli.js.map +1 -0
- package/dist/cost.js +35 -0
- package/dist/cost.js.map +1 -0
- package/dist/council.js +147 -0
- package/dist/council.js.map +1 -0
- package/dist/demo.js +93 -0
- package/dist/demo.js.map +1 -0
- package/dist/estimate.js +98 -0
- package/dist/estimate.js.map +1 -0
- package/dist/executor.js +118 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.js +82 -0
- package/dist/git.js.map +1 -0
- package/dist/intake.js +98 -0
- package/dist/intake.js.map +1 -0
- package/dist/models.js +171 -0
- package/dist/models.js.map +1 -0
- package/dist/narrate.js +76 -0
- package/dist/narrate.js.map +1 -0
- package/dist/openrouter.js +112 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/orchestrator.js +273 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/pm.js +245 -0
- package/dist/pm.js.map +1 -0
- package/dist/preview.js +194 -0
- package/dist/preview.js.map +1 -0
- package/dist/registry-store.js +38 -0
- package/dist/registry-store.js.map +1 -0
- package/dist/registry.js +115 -0
- package/dist/registry.js.map +1 -0
- package/dist/research.js +98 -0
- package/dist/research.js.map +1 -0
- package/dist/retro.js +83 -0
- package/dist/retro.js.map +1 -0
- package/dist/roles.js +357 -0
- package/dist/roles.js.map +1 -0
- package/dist/router.js +95 -0
- package/dist/router.js.map +1 -0
- package/dist/run-bakeoff.js +68 -0
- package/dist/run-bakeoff.js.map +1 -0
- package/dist/run-build.js +193 -0
- package/dist/run-build.js.map +1 -0
- package/{src/run-dev.ts → dist/run-dev.js} +23 -39
- package/dist/run-dev.js.map +1 -0
- package/{src/run-pm.ts → dist/run-pm.js} +34 -46
- package/dist/run-pm.js.map +1 -0
- package/{src/run-research.ts → dist/run-research.js} +19 -30
- package/dist/run-research.js.map +1 -0
- package/{src/run-scout.ts → dist/run-scout.js} +17 -27
- package/dist/run-scout.js.map +1 -0
- package/dist/run-web.js +93 -0
- package/dist/run-web.js.map +1 -0
- package/dist/scout.js +81 -0
- package/dist/scout.js.map +1 -0
- package/dist/session-cost.js +15 -0
- package/dist/session-cost.js.map +1 -0
- package/dist/stack.js +34 -0
- package/dist/stack.js.map +1 -0
- package/dist/stuck.js +21 -0
- package/dist/stuck.js.map +1 -0
- package/dist/tui/App.js +1269 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/BakeOff.js +95 -0
- package/dist/tui/BakeOff.js.map +1 -0
- package/dist/tui/BoardEditor.js +183 -0
- package/dist/tui/BoardEditor.js.map +1 -0
- package/dist/tui/EditableBoard.js +140 -0
- package/dist/tui/EditableBoard.js.map +1 -0
- package/dist/tui/Frame.js +78 -0
- package/dist/tui/Frame.js.map +1 -0
- package/dist/tui/Intake.js +59 -0
- package/dist/tui/Intake.js.map +1 -0
- package/dist/tui/Kanban.js +75 -0
- package/dist/tui/Kanban.js.map +1 -0
- package/dist/tui/Settings.js +299 -0
- package/dist/tui/Settings.js.map +1 -0
- package/dist/tui/StackPick.js +44 -0
- package/dist/tui/StackPick.js.map +1 -0
- package/dist/tui/WebAccounts.js +131 -0
- package/dist/tui/WebAccounts.js.map +1 -0
- package/dist/tui/components.js +178 -0
- package/dist/tui/components.js.map +1 -0
- package/dist/tui/config.js +129 -0
- package/dist/tui/config.js.map +1 -0
- package/dist/tui/deploy.js +114 -0
- package/dist/tui/deploy.js.map +1 -0
- package/dist/tui/engine.js +710 -0
- package/dist/tui/engine.js.map +1 -0
- package/dist/tui/notify.js +47 -0
- package/dist/tui/notify.js.map +1 -0
- package/dist/tui/panels.js +42 -0
- package/dist/tui/panels.js.map +1 -0
- package/dist/tui/templates.js +106 -0
- package/dist/tui/templates.js.map +1 -0
- package/dist/tui/theme.js +42 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/validate.js +51 -0
- package/dist/tui/validate.js.map +1 -0
- package/{src/tui.tsx → dist/tui.js} +23 -33
- package/dist/tui.js.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/web/oauth-anthropic.js +179 -0
- package/dist/web/oauth-anthropic.js.map +1 -0
- package/dist/web/session.js +274 -0
- package/dist/web/session.js.map +1 -0
- package/package.json +5 -4
- package/src/bakeoff.ts +0 -214
- package/src/build-state.ts +0 -47
- package/src/burndown.ts +0 -35
- package/src/calibration.ts +0 -88
- package/src/cli.ts +0 -263
- package/src/cost.ts +0 -43
- package/src/council.ts +0 -175
- package/src/demo.ts +0 -106
- package/src/estimate.ts +0 -111
- package/src/executor.ts +0 -169
- package/src/git.ts +0 -72
- package/src/intake.ts +0 -125
- package/src/models.ts +0 -175
- package/src/narrate.ts +0 -87
- package/src/openrouter.ts +0 -119
- package/src/orchestrator.ts +0 -310
- package/src/pm.ts +0 -302
- package/src/preview.ts +0 -206
- package/src/registry-store.ts +0 -41
- package/src/registry.ts +0 -132
- package/src/research.ts +0 -123
- package/src/retro.ts +0 -99
- package/src/roles.ts +0 -401
- package/src/router.ts +0 -120
- package/src/run-bakeoff.ts +0 -77
- package/src/run-build.ts +0 -203
- package/src/run-web.ts +0 -87
- package/src/scout.ts +0 -121
- package/src/session-cost.ts +0 -17
- package/src/stack.ts +0 -46
- package/src/tui/App.tsx +0 -1760
- package/src/tui/BakeOff.tsx +0 -190
- package/src/tui/BoardEditor.tsx +0 -260
- package/src/tui/EditableBoard.tsx +0 -179
- package/src/tui/Frame.tsx +0 -142
- package/src/tui/Intake.tsx +0 -111
- package/src/tui/Kanban.tsx +0 -158
- package/src/tui/Settings.tsx +0 -513
- package/src/tui/StackPick.tsx +0 -79
- package/src/tui/WebAccounts.tsx +0 -197
- package/src/tui/components.tsx +0 -340
- package/src/tui/config.ts +0 -150
- package/src/tui/deploy.ts +0 -137
- package/src/tui/engine.ts +0 -749
- package/src/tui/notify.ts +0 -21
- package/src/tui/panels.tsx +0 -89
- package/src/tui/templates.ts +0 -119
- package/src/tui/theme.ts +0 -44
- package/src/tui/validate.ts +0 -53
- package/src/types.ts +0 -208
- package/src/web/oauth-anthropic.ts +0 -206
- package/src/web/session.ts +0 -299
package/dist/tui/App.js
ADDED
|
@@ -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
|