projectinator 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -11
- package/bin/projectinator.mjs +56 -11
- 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/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/src/tui/App.tsx
DELETED
|
@@ -1,1760 +0,0 @@
|
|
|
1
|
-
// Projectinator TUI — the whole flow: setup -> idea -> plan -> build -> done.
|
|
2
|
-
// Dead simple: type what you want, confirm the cost, watch it build.
|
|
3
|
-
|
|
4
|
-
import React, { 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 type { Provider } from "../types.js";
|
|
9
|
-
import type { OrchestratorEvent } from "../orchestrator.js";
|
|
10
|
-
import { C, BudgetBar, Panel, Chip, Menu as SelectInput, GroupedMenu, KeyHint, useTermRows, TextField as TextInput, type TaskView, type MenuGroup } from "./components.js";
|
|
11
|
-
import { Kanban, type BoardTask } from "./Kanban.js";
|
|
12
|
-
import { BoardEditor } from "./BoardEditor.js";
|
|
13
|
-
import { Team, Standup, ListView } from "./panels.js";
|
|
14
|
-
import { EditableBoard } from "./EditableBoard.js";
|
|
15
|
-
import { AppFrame } from "./Frame.js";
|
|
16
|
-
import { Settings } from "./Settings.js";
|
|
17
|
-
import { BakeOff } from "./BakeOff.js";
|
|
18
|
-
import { Intake, type Answer } from "./Intake.js";
|
|
19
|
-
import { enrichBrief } from "../intake.js";
|
|
20
|
-
import type { IntakeQuestion } from "../intake.js";
|
|
21
|
-
import { StackPick } from "./StackPick.js";
|
|
22
|
-
import { stackInstruction, type StackChoice } from "../stack.js";
|
|
23
|
-
import type { Epic, CouncilResult } from "../council.js";
|
|
24
|
-
import { allTemplates, saveUserTemplate, deleteUserTemplate, exportTemplate, importTemplate, type Template } from "./templates.js";
|
|
25
|
-
import { getPrefs, getDefaultMode, getNotify, getPreferredStack, type WorkflowMode } from "./config.js";
|
|
26
|
-
import { notifyBuildDone } from "./notify.js";
|
|
27
|
-
import {
|
|
28
|
-
availableProviders,
|
|
29
|
-
chooseRegistry,
|
|
30
|
-
planBuild,
|
|
31
|
-
assessBuild,
|
|
32
|
-
councilBuild,
|
|
33
|
-
setProjectBudget,
|
|
34
|
-
startBuild,
|
|
35
|
-
estimateTasks,
|
|
36
|
-
listProjects,
|
|
37
|
-
openInBrowser,
|
|
38
|
-
mainFileOf,
|
|
39
|
-
addAsset,
|
|
40
|
-
renameProject,
|
|
41
|
-
duplicateProject,
|
|
42
|
-
deleteProject,
|
|
43
|
-
saveProjectTasks,
|
|
44
|
-
exportProject,
|
|
45
|
-
exportJira,
|
|
46
|
-
exportTrello,
|
|
47
|
-
projectHistory,
|
|
48
|
-
undoLastTask,
|
|
49
|
-
projectRetro,
|
|
50
|
-
projectBurndown,
|
|
51
|
-
getRetroNarrative,
|
|
52
|
-
generateRetroNarrative,
|
|
53
|
-
breakdownEpic,
|
|
54
|
-
modelLabel,
|
|
55
|
-
PROVIDER_LABEL,
|
|
56
|
-
type PlanResult,
|
|
57
|
-
type ProjectInfo,
|
|
58
|
-
} from "./engine.js";
|
|
59
|
-
import { deploy, DEPLOY_META, type DeployTarget } from "./deploy.js";
|
|
60
|
-
import { startStaticServer, CHROMIUM_INSTALL_HINT, type StaticServer } from "../preview.js";
|
|
61
|
-
import type { Capability, Task, TaskOutcome } from "../types.js";
|
|
62
|
-
import { completedIds } from "../build-state.js";
|
|
63
|
-
import type { Verdict } from "../types.js";
|
|
64
|
-
|
|
65
|
-
/** Board label: PASS* = a TEST that passed without ever running the app (Chromium missing).
|
|
66
|
-
* Reviews never run anything, so they are plain PASS/FAIL. */
|
|
67
|
-
const verdictLabel = (v: Verdict, capability: Capability): "PASS" | "PASS*" | "FAIL" =>
|
|
68
|
-
!v.passed ? "FAIL" : v.runtimeChecked || capability !== "test" ? "PASS" : "PASS*";
|
|
69
|
-
|
|
70
|
-
type Phase =
|
|
71
|
-
| "setup" | "home" | "settings" | "projects" | "projectActions" | "addAsset" | "rename" | "confirmDelete" | "filterEpic" | "editBoard" | "kanban" | "templates" | "exportMenu" | "deployMenu" | "deploying" | "preview" | "bakeoff" | "history" | "retro" | "burndown" | "saveTemplate" | "importTemplate" | "myTemplates" | "tplActions"
|
|
72
|
-
| "idea" | "change" | "stack" | "assessing" | "intake" | "planMode" | "council" | "approveEpics" | "planning" | "plan" | "board" | "building" | "done" | "error" | "setCap";
|
|
73
|
-
|
|
74
|
-
export default function App(): React.ReactElement {
|
|
75
|
-
const { exit } = useApp();
|
|
76
|
-
const [phase, setPhase] = useState<Phase>("setup");
|
|
77
|
-
const [providers, setProviders] = useState<Provider[]>(() => availableProviders());
|
|
78
|
-
const [scope, setScope] = useState<"full" | "change">("full");
|
|
79
|
-
const [mode, setMode] = useState<WorkflowMode>(() => getDefaultMode());
|
|
80
|
-
const [idea, setIdea] = useState("");
|
|
81
|
-
const [plan, setPlan] = useState<PlanResult | null>(null);
|
|
82
|
-
const [error, setError] = useState<string>("");
|
|
83
|
-
|
|
84
|
-
const [tasks, setTasks] = useState<TaskView[]>([]);
|
|
85
|
-
const [spent, setSpent] = useState(0);
|
|
86
|
-
const [gate, setGate] = useState<{ resolve: (d: "continue" | "stop") => void } | null>(null);
|
|
87
|
-
const [buildResult, setBuildResult] = useState<{ halted: boolean; haltReason?: string; files: string[]; workspace: string } | null>(null);
|
|
88
|
-
|
|
89
|
-
// Existing-project context (open/resume/make-changes).
|
|
90
|
-
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
91
|
-
const [selected, setSelected] = useState<ProjectInfo | null>(null);
|
|
92
|
-
const [targetWorkspace, setTargetWorkspace] = useState<string | undefined>(undefined);
|
|
93
|
-
const [seed, setSeed] = useState<TaskOutcome[] | undefined>(undefined);
|
|
94
|
-
const [assetPath, setAssetPath] = useState("");
|
|
95
|
-
const [assetMsg, setAssetMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
96
|
-
const [assetKey, setAssetKey] = useState(0); // bump to remount (clear) the add-asset input
|
|
97
|
-
const [assetReturn, setAssetReturn] = useState<Phase>("projectActions");
|
|
98
|
-
const [renameDraft, setRenameDraft] = useState("");
|
|
99
|
-
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
|
100
|
-
const [epicFilter, setEpicFilter] = useState<string | null>(null);
|
|
101
|
-
const [flash, setFlash] = useState<string>("");
|
|
102
|
-
const [intakeQs, setIntakeQs] = useState<IntakeQuestion[]>([]);
|
|
103
|
-
const [approvedEpics, setApprovedEpics] = useState<Epic[] | null>(null);
|
|
104
|
-
const [council, setCouncil] = useState<{ loading: boolean; result: CouncilResult | null; error: string }>({ loading: false, result: null, error: "" });
|
|
105
|
-
// Keep `idea` as the raw user input; derive the brief from these so re-entering
|
|
106
|
-
// the stack/intake steps never double-appends (bug: back-nav re-appended text).
|
|
107
|
-
const [stackChoice, setStackChoice] = useState<StackChoice | null>(null);
|
|
108
|
-
const [intakeAnswers, setIntakeAnswers] = useState<Answer[] | null>(null);
|
|
109
|
-
const [narr, setNarr] = useState<{ loading: boolean; text: string; error: string }>({ loading: false, text: "", error: "" });
|
|
110
|
-
const [tplName, setTplName] = useState("");
|
|
111
|
-
const [tplPath, setTplPath] = useState("");
|
|
112
|
-
const [tplSel, setTplSel] = useState<Template | null>(null);
|
|
113
|
-
const [projectCap, setProjectCap] = useState<number | null>(null); // per-build cap override
|
|
114
|
-
const [capDraft, setCapDraft] = useState("");
|
|
115
|
-
const [capReturn, setCapReturn] = useState<Phase>("plan");
|
|
116
|
-
const [deployState, setDeployState] = useState<{
|
|
117
|
-
target: DeployTarget;
|
|
118
|
-
status: "running" | "done" | "error";
|
|
119
|
-
url?: string;
|
|
120
|
-
error?: string;
|
|
121
|
-
log: string[];
|
|
122
|
-
} | null>(null);
|
|
123
|
-
const [preview, setPreview] = useState<{ server: StaticServer; error?: string } | { error: string } | null>(null);
|
|
124
|
-
|
|
125
|
-
const termRows = useTermRows();
|
|
126
|
-
|
|
127
|
-
// Reload the projects list and re-point `selected` at the given dir (after a mutation).
|
|
128
|
-
const reselect = (dir: string | null) => {
|
|
129
|
-
const list = listProjects();
|
|
130
|
-
setProjects(list);
|
|
131
|
-
setSelected(dir ? list.find((p) => p.dir === dir) ?? null : null);
|
|
132
|
-
return list;
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const resetBuildContext = () => {
|
|
136
|
-
setIdea("");
|
|
137
|
-
setPlan(null);
|
|
138
|
-
setTasks([]);
|
|
139
|
-
setSpent(0);
|
|
140
|
-
setBuildResult(null);
|
|
141
|
-
setTargetWorkspace(undefined);
|
|
142
|
-
setSeed(undefined);
|
|
143
|
-
setSelected(null);
|
|
144
|
-
setMode(getDefaultMode());
|
|
145
|
-
setGate(null);
|
|
146
|
-
setProjectCap(null);
|
|
147
|
-
setApprovedEpics(null);
|
|
148
|
-
setCouncil({ loading: false, result: null, error: "" });
|
|
149
|
-
setStackChoice(null);
|
|
150
|
-
setIntakeAnswers(null);
|
|
151
|
-
setEpicFilter(null);
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
// The brief the planner sees: raw idea + stack instruction (+ intake answers).
|
|
155
|
-
// Pure function of state, so it's idempotent no matter how the user navigates.
|
|
156
|
-
const composeBrief = (withIntake: boolean): string => {
|
|
157
|
-
const base = idea + (stackChoice ? stackInstruction(stackChoice) : "");
|
|
158
|
-
return withIntake ? enrichBrief(base, intakeAnswers ?? []) : base;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
// Esc goes back one screen. Phases with their own Esc handling (board editors,
|
|
162
|
-
// settings) are left out so we don't double-fire.
|
|
163
|
-
const goBack = () => {
|
|
164
|
-
switch (phase) {
|
|
165
|
-
case "idea": resetBuildContext(); return setPhase("home");
|
|
166
|
-
case "stack": resetBuildContext(); return setPhase("home");
|
|
167
|
-
case "assessing": resetBuildContext(); return setPhase("home");
|
|
168
|
-
case "intake": return setPhase("idea");
|
|
169
|
-
case "planMode": resetBuildContext(); return setPhase("home");
|
|
170
|
-
case "approveEpics": return setPhase("planMode");
|
|
171
|
-
case "setCap": return setPhase(capReturn);
|
|
172
|
-
case "templates": return setPhase("home");
|
|
173
|
-
case "saveTemplate": return setPhase("projectActions");
|
|
174
|
-
case "importTemplate": setFlash(""); return setPhase("templates");
|
|
175
|
-
case "myTemplates": setFlash(""); return setPhase("templates");
|
|
176
|
-
case "tplActions": return setPhase("myTemplates");
|
|
177
|
-
case "change": return setPhase(selected ? "projectActions" : buildResult ? "done" : "home");
|
|
178
|
-
case "rename": return setPhase("projectActions");
|
|
179
|
-
case "addAsset": return setPhase(assetReturn);
|
|
180
|
-
case "plan": return setPhase("idea");
|
|
181
|
-
case "confirmDelete": return setPhase("projectActions");
|
|
182
|
-
case "exportMenu": return setPhase("projectActions");
|
|
183
|
-
case "deployMenu": return setPhase("projectActions");
|
|
184
|
-
case "history": return setPhase("projectActions");
|
|
185
|
-
case "retro": return setPhase("projectActions");
|
|
186
|
-
case "burndown": return setPhase("projectActions");
|
|
187
|
-
case "kanban": return setPhase("projectActions");
|
|
188
|
-
case "preview": {
|
|
189
|
-
if (preview && "server" in preview) void preview.server.close();
|
|
190
|
-
setPreview(null);
|
|
191
|
-
return setPhase("projectActions");
|
|
192
|
-
}
|
|
193
|
-
case "filterEpic": return setPhase("projectActions");
|
|
194
|
-
case "projectActions": return setPhase("projects");
|
|
195
|
-
case "projects": return setPhase("home");
|
|
196
|
-
case "home": return setPhase("setup");
|
|
197
|
-
case "done": resetBuildContext(); return setPhase("home");
|
|
198
|
-
case "error": return setPhase("home");
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
// global quit (not while typing an idea/change)
|
|
203
|
-
// Any phase that hosts a text/number input must be here, or the App-level
|
|
204
|
-
// useInput below quits on a "q" keystroke (both handlers see every key).
|
|
205
|
-
const typing =
|
|
206
|
-
phase === "idea" || phase === "change" || phase === "addAsset" || phase === "rename" ||
|
|
207
|
-
phase === "bakeoff" || phase === "intake" || phase === "setCap" || phase === "stack" ||
|
|
208
|
-
phase === "saveTemplate" || phase === "importTemplate" || phase === "settings" ||
|
|
209
|
-
phase === "editBoard" || phase === "board";
|
|
210
|
-
useInput((input, key) => {
|
|
211
|
-
if (key.ctrl && input === "c") return exit();
|
|
212
|
-
if (input === "q" && !typing) return exit();
|
|
213
|
-
if (key.escape) goBack();
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
// ---- stack effect: apply the default stack (skip the picker) when one is set ----
|
|
217
|
-
useEffect(() => {
|
|
218
|
-
if (phase !== "stack") return;
|
|
219
|
-
const pref = getPreferredStack();
|
|
220
|
-
if (pref !== "ask") {
|
|
221
|
-
setStackChoice({ platform: "web", framework: pref });
|
|
222
|
-
setPhase("assessing");
|
|
223
|
-
}
|
|
224
|
-
}, [phase]);
|
|
225
|
-
|
|
226
|
-
// ---- intake effect: for a fresh build, ask the PM if it needs clarification ----
|
|
227
|
-
useEffect(() => {
|
|
228
|
-
if (phase !== "assessing") return;
|
|
229
|
-
let alive = true;
|
|
230
|
-
// Only interview for fresh full builds; targeted changes go straight to planning.
|
|
231
|
-
if (scope !== "full" || targetWorkspace) {
|
|
232
|
-
setPhase("planning");
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
assessBuild(composeBrief(false), providers) // intake not answered yet
|
|
236
|
-
.then((qs) => {
|
|
237
|
-
if (!alive) return;
|
|
238
|
-
if (qs.length) {
|
|
239
|
-
setIntakeQs(qs);
|
|
240
|
-
setPhase("intake");
|
|
241
|
-
} else {
|
|
242
|
-
setPhase("planMode");
|
|
243
|
-
}
|
|
244
|
-
})
|
|
245
|
-
.catch(() => { if (alive) setPhase("planMode"); }); // never block on intake
|
|
246
|
-
return () => { alive = false; };
|
|
247
|
-
}, [phase, idea, providers, scope, targetWorkspace]);
|
|
248
|
-
|
|
249
|
-
// ---- council effect: run the deep plan when entering "council" ----
|
|
250
|
-
useEffect(() => {
|
|
251
|
-
if (phase !== "council") return;
|
|
252
|
-
let alive = true;
|
|
253
|
-
setCouncil({ loading: true, result: null, error: "" });
|
|
254
|
-
councilBuild(composeBrief(true), providers)
|
|
255
|
-
.then((result) => {
|
|
256
|
-
if (!alive) return;
|
|
257
|
-
if (result.epics.length) {
|
|
258
|
-
setCouncil({ loading: false, result, error: "" });
|
|
259
|
-
setPhase("approveEpics");
|
|
260
|
-
} else {
|
|
261
|
-
setApprovedEpics(null); // council came back empty — fall back to normal planning
|
|
262
|
-
setPhase("planning");
|
|
263
|
-
}
|
|
264
|
-
})
|
|
265
|
-
.catch(() => { if (alive) { setApprovedEpics(null); setPhase("planning"); } });
|
|
266
|
-
return () => { alive = false; };
|
|
267
|
-
}, [phase, idea, providers]);
|
|
268
|
-
|
|
269
|
-
// ---- planning effect ----
|
|
270
|
-
useEffect(() => {
|
|
271
|
-
if (phase !== "planning") return;
|
|
272
|
-
let alive = true;
|
|
273
|
-
planBuild(composeBrief(true), providers, scope, targetWorkspace, approvedEpics ?? undefined)
|
|
274
|
-
.then((p) => {
|
|
275
|
-
if (!alive) return;
|
|
276
|
-
setPlan(p);
|
|
277
|
-
setTasks(p.tasks.map((t) => ({ id: t.id, capability: t.capability, status: "pending", title: t.title })));
|
|
278
|
-
setPhase("plan");
|
|
279
|
-
})
|
|
280
|
-
.catch((e) => {
|
|
281
|
-
if (!alive) return;
|
|
282
|
-
setError(e instanceof Error ? e.message : String(e));
|
|
283
|
-
setPhase("error");
|
|
284
|
-
});
|
|
285
|
-
return () => {
|
|
286
|
-
alive = false;
|
|
287
|
-
};
|
|
288
|
-
}, [phase, idea, providers, scope, targetWorkspace]);
|
|
289
|
-
|
|
290
|
-
// ---- building effect ----
|
|
291
|
-
useEffect(() => {
|
|
292
|
-
if (phase !== "building" || !plan) return;
|
|
293
|
-
const onEvent = (e: OrchestratorEvent) => {
|
|
294
|
-
if (e.type === "task_start") {
|
|
295
|
-
setTasks((ts) => ts.map((t) => (t.id === e.task.id ? { ...t, status: "running", model: e.modelId } : t)));
|
|
296
|
-
} else if (e.type === "task_done") {
|
|
297
|
-
setSpent(e.runningTotal);
|
|
298
|
-
setTasks((ts) =>
|
|
299
|
-
ts.map((t) =>
|
|
300
|
-
t.id === e.outcome.taskId
|
|
301
|
-
? {
|
|
302
|
-
...t,
|
|
303
|
-
status: "done",
|
|
304
|
-
cost: (t.cost ?? 0) + e.outcome.cost,
|
|
305
|
-
verdict: e.outcome.verdict ? verdictLabel(e.outcome.verdict, e.outcome.capability) : t.verdict,
|
|
306
|
-
}
|
|
307
|
-
: t,
|
|
308
|
-
),
|
|
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
|
-
} else if (e.type === "task_skipped") {
|
|
314
|
-
setTasks((ts) => ts.map((t) => (t.id === e.taskId ? { ...t, status: "skipped" } : t)));
|
|
315
|
-
} else if (e.type === "test_failed") {
|
|
316
|
-
setTasks((ts) => ts.map((t) => (t.id === e.taskId ? { ...t, status: "failed", verdict: "FAIL" } : t)));
|
|
317
|
-
} else if (e.type === "retry_dev") {
|
|
318
|
-
setTasks((ts) => ts.map((t) => (t.id === e.taskId ? { ...t, status: "running" } : t)));
|
|
319
|
-
}
|
|
320
|
-
};
|
|
321
|
-
|
|
322
|
-
const prefs = getPrefs();
|
|
323
|
-
// In approval mode, pause once before development starts (design → dev gate).
|
|
324
|
-
const onGate =
|
|
325
|
-
mode === "approval"
|
|
326
|
-
? () => new Promise<"continue" | "stop">((resolve) => setGate({ resolve }))
|
|
327
|
-
: undefined;
|
|
328
|
-
const handle = startBuild(idea, plan, {
|
|
329
|
-
concurrency: prefs.concurrency,
|
|
330
|
-
budgetCapUSD: projectCap ?? prefs.budgetCapUSD,
|
|
331
|
-
taskLimits: { timeoutMs: prefs.taskTimeoutMin * 60_000, costCapUSD: prefs.taskCostCapUSD },
|
|
332
|
-
onEvent,
|
|
333
|
-
workspace: targetWorkspace,
|
|
334
|
-
seedOutcomes: seed,
|
|
335
|
-
mode,
|
|
336
|
-
onGate,
|
|
337
|
-
});
|
|
338
|
-
let alive = true;
|
|
339
|
-
handle.promise
|
|
340
|
-
.then((r) => {
|
|
341
|
-
if (!alive) return;
|
|
342
|
-
setSpent(r.totalCost);
|
|
343
|
-
setBuildResult({ halted: r.halted, haltReason: r.haltReason, files: r.files, workspace: handle.workspace });
|
|
344
|
-
setPhase("done");
|
|
345
|
-
if (getNotify()) {
|
|
346
|
-
notifyBuildDone(
|
|
347
|
-
"Projectinator",
|
|
348
|
-
`Build ${r.halted ? "halted" : "complete"} · $${r.totalCost.toFixed(2)}${r.files.length ? ` · ${r.files.length} file${r.files.length === 1 ? "" : "s"}` : ""}`,
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
})
|
|
352
|
-
.catch((e) => {
|
|
353
|
-
if (!alive) return;
|
|
354
|
-
setError(e instanceof Error ? e.message : String(e));
|
|
355
|
-
setPhase("error");
|
|
356
|
-
});
|
|
357
|
-
return () => {
|
|
358
|
-
alive = false;
|
|
359
|
-
};
|
|
360
|
-
}, [phase, plan, idea]);
|
|
361
|
-
|
|
362
|
-
// ================= screens =================
|
|
363
|
-
// Each phase renders its own content; AppFrame (below) wraps the result in the
|
|
364
|
-
// persistent frame (status bar pinned to the bottom on every screen).
|
|
365
|
-
const screen: React.ReactElement = (() => {
|
|
366
|
-
|
|
367
|
-
if (phase === "setup") {
|
|
368
|
-
const ready = providers.length > 0;
|
|
369
|
-
const mode = chooseRegistry(providers).lock
|
|
370
|
-
? `locked to ${PROVIDER_LABEL[providers[0]!]}`
|
|
371
|
-
: "best model per role";
|
|
372
|
-
return (
|
|
373
|
-
<Box flexDirection="column">
|
|
374
|
-
{ready ? (
|
|
375
|
-
<Panel title="Ready to build">
|
|
376
|
-
<Text color={C.textMuted}>Your team is connected.</Text>
|
|
377
|
-
<Box marginTop={1} gap={1} flexWrap="wrap">
|
|
378
|
-
{providers.map((p) => (
|
|
379
|
-
<Chip key={p} label={PROVIDER_LABEL[p]} dotColor={C.good} />
|
|
380
|
-
))}
|
|
381
|
-
</Box>
|
|
382
|
-
<Text color={C.textSubtle}>{`\nRouting: ${mode}.`}</Text>
|
|
383
|
-
<Box marginTop={1}>
|
|
384
|
-
<SelectInput
|
|
385
|
-
items={[
|
|
386
|
-
{ label: "Start a build", value: "go" },
|
|
387
|
-
{ label: "Settings", value: "settings" },
|
|
388
|
-
{ label: "Quit", value: "quit" },
|
|
389
|
-
]}
|
|
390
|
-
onSelect={(i) => (i.value === "go" ? setPhase("home") : i.value === "settings" ? setPhase("settings") : exit())}
|
|
391
|
-
/>
|
|
392
|
-
</Box>
|
|
393
|
-
</Panel>
|
|
394
|
-
) : (
|
|
395
|
-
<Panel title="Connect a provider" borderColor={C.warn}>
|
|
396
|
-
<Text color={C.textMuted}>No API key yet — add one in Settings (no file editing).</Text>
|
|
397
|
-
<Box marginTop={1}>
|
|
398
|
-
<SelectInput
|
|
399
|
-
items={[{ label: "Settings — add a key", value: "settings" }, { label: "Quit", value: "quit" }]}
|
|
400
|
-
onSelect={(i) => (i.value === "settings" ? setPhase("settings") : exit())}
|
|
401
|
-
/>
|
|
402
|
-
</Box>
|
|
403
|
-
</Panel>
|
|
404
|
-
)}
|
|
405
|
-
</Box>
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (phase === "home") {
|
|
410
|
-
const projs = listProjects();
|
|
411
|
-
const items = [
|
|
412
|
-
{ label: "New build", value: "new" },
|
|
413
|
-
{ label: "Start from a template", value: "templates" },
|
|
414
|
-
...(projs.length ? [{ label: `Projects (${projs.length})`, value: "open" }] : []),
|
|
415
|
-
{ label: "Compare models (bake-off)", value: "bakeoff" },
|
|
416
|
-
{ label: "Settings", value: "settings" },
|
|
417
|
-
{ label: "Quit", value: "quit" },
|
|
418
|
-
];
|
|
419
|
-
return (
|
|
420
|
-
<Box flexDirection="column">
|
|
421
|
-
<Panel title="What would you like to do?">
|
|
422
|
-
<SelectInput
|
|
423
|
-
items={items}
|
|
424
|
-
onSelect={(i) => {
|
|
425
|
-
if (i.value === "new") {
|
|
426
|
-
resetBuildContext();
|
|
427
|
-
setScope("full");
|
|
428
|
-
setPhase("idea");
|
|
429
|
-
} else if (i.value === "templates") {
|
|
430
|
-
setPhase("templates");
|
|
431
|
-
} else if (i.value === "open") {
|
|
432
|
-
setProjects(projs);
|
|
433
|
-
setPhase("projects");
|
|
434
|
-
} else if (i.value === "bakeoff") {
|
|
435
|
-
setPhase("bakeoff");
|
|
436
|
-
} else if (i.value === "settings") {
|
|
437
|
-
setPhase("settings");
|
|
438
|
-
} else exit();
|
|
439
|
-
}}
|
|
440
|
-
/>
|
|
441
|
-
</Panel>
|
|
442
|
-
</Box>
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (phase === "settings") {
|
|
447
|
-
return (
|
|
448
|
-
<Box flexDirection="column">
|
|
449
|
-
<Settings
|
|
450
|
-
onExit={() => {
|
|
451
|
-
const next = availableProviders();
|
|
452
|
-
setProviders(next);
|
|
453
|
-
setPhase(next.length ? "home" : "setup");
|
|
454
|
-
}}
|
|
455
|
-
/>
|
|
456
|
-
</Box>
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (phase === "bakeoff") {
|
|
461
|
-
return (
|
|
462
|
-
<Box flexDirection="column">
|
|
463
|
-
<BakeOff onExit={() => setPhase("home")} />
|
|
464
|
-
</Box>
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (phase === "projects") {
|
|
469
|
-
const mark = (s: ProjectInfo["status"]) => (s === "complete" ? "✓" : s === "halted" ? "⚠" : "·");
|
|
470
|
-
const items = [
|
|
471
|
-
...projects.map((p) => ({
|
|
472
|
-
label: `${mark(p.status)} ${p.idea.slice(0, 46)}${p.idea.length > 46 ? "…" : ""} $${p.totalCost.toFixed(2)}`,
|
|
473
|
-
value: p.slug,
|
|
474
|
-
})),
|
|
475
|
-
{ label: "Back", value: "__back" },
|
|
476
|
-
];
|
|
477
|
-
const total = projects.reduce((a, p) => a + p.totalCost, 0);
|
|
478
|
-
const nComplete = projects.filter((p) => p.status === "complete").length;
|
|
479
|
-
const nHalted = projects.filter((p) => p.status === "halted").length;
|
|
480
|
-
const nRunning = projects.filter((p) => p.status === "running").length;
|
|
481
|
-
return (
|
|
482
|
-
<Box flexDirection="column">
|
|
483
|
-
<Panel title="Your projects">
|
|
484
|
-
<Box gap={1} flexWrap="wrap" marginBottom={1}>
|
|
485
|
-
<Chip label={`${projects.length} projects`} />
|
|
486
|
-
<Chip label={`$${total.toFixed(2)} spent`} dotColor={C.accent} />
|
|
487
|
-
{nComplete ? <Chip label={`${nComplete} done`} dotColor={C.good} /> : null}
|
|
488
|
-
{nHalted ? <Chip label={`${nHalted} halted`} dotColor={C.warn} active /> : null}
|
|
489
|
-
{nRunning ? <Chip label={`${nRunning} running`} dotColor={C.info} /> : null}
|
|
490
|
-
</Box>
|
|
491
|
-
<SelectInput
|
|
492
|
-
items={items}
|
|
493
|
-
onSelect={(i) => {
|
|
494
|
-
if (i.value === "__back") setPhase("home");
|
|
495
|
-
else {
|
|
496
|
-
const proj = projects.find((p) => p.slug === i.value) ?? null;
|
|
497
|
-
setSelected(proj);
|
|
498
|
-
setProjectCap(proj?.state.budgetCapUSD ?? null); // carry the saved cap into resume/change
|
|
499
|
-
setEpicFilter(null); // don't carry a filter from a previously-opened project
|
|
500
|
-
setPhase("projectActions");
|
|
501
|
-
}
|
|
502
|
-
}}
|
|
503
|
-
/>
|
|
504
|
-
</Panel>
|
|
505
|
-
</Box>
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
if (phase === "projectActions" && selected) {
|
|
510
|
-
const doneIds = completedIds(selected.state);
|
|
511
|
-
// Buildable if it was halted OR the backlog has tasks that were never built.
|
|
512
|
-
const hasUnbuilt = selected.state.tasks.some((t) => !doneIds.has(t.id));
|
|
513
|
-
const canResume = selected.status === "halted" || hasUnbuilt;
|
|
514
|
-
const costById = new Map<string, number>();
|
|
515
|
-
const modelById = new Map<string, string>();
|
|
516
|
-
for (const o of selected.state.outcomes) {
|
|
517
|
-
costById.set(o.taskId, (costById.get(o.taskId) ?? 0) + o.cost);
|
|
518
|
-
modelById.set(o.taskId, o.modelId);
|
|
519
|
-
}
|
|
520
|
-
const allBoard: BoardTask[] = selected.state.tasks.map((t) => ({
|
|
521
|
-
id: t.id,
|
|
522
|
-
capability: t.capability,
|
|
523
|
-
title: t.title,
|
|
524
|
-
epic: t.epic,
|
|
525
|
-
dependsOn: t.dependsOn,
|
|
526
|
-
notes: t.notes,
|
|
527
|
-
status: doneIds.has(t.id) ? "done" : "pending",
|
|
528
|
-
cost: costById.get(t.id),
|
|
529
|
-
assignee: modelById.has(t.id) ? modelLabel(modelById.get(t.id)!) : undefined,
|
|
530
|
-
}));
|
|
531
|
-
const statusChip = selected.status === "complete"
|
|
532
|
-
? { label: "complete", dot: C.good }
|
|
533
|
-
: selected.status === "halted"
|
|
534
|
-
? { label: "halted", dot: C.warn }
|
|
535
|
-
: { label: "running", dot: C.info };
|
|
536
|
-
const menuGroups: MenuGroup[] = [
|
|
537
|
-
{ title: "Work", items: [
|
|
538
|
-
{ label: "Add to backlog (describe it, the PM plans it)", value: "change" },
|
|
539
|
-
{ label: "Board (view · add · reorder · edit)", value: "kanban" },
|
|
540
|
-
{ label: "Add a file / image", value: "asset" },
|
|
541
|
-
...(canResume ? [{ label: selected.status === "halted" ? "Resume build" : `Build the backlog (${selected.state.tasks.filter((t) => !doneIds.has(t.id)).length} to do)`, value: "resume" }] : []),
|
|
542
|
-
] },
|
|
543
|
-
{ title: "See it", items: [
|
|
544
|
-
{ label: "Preview in browser (live server, auto-reload)", value: "preview" },
|
|
545
|
-
] },
|
|
546
|
-
{ title: "Reports", items: [
|
|
547
|
-
{ label: "Retro (build summary)", value: "retro" },
|
|
548
|
-
{ label: "Burndown (progress + spend)", value: "burndown" },
|
|
549
|
-
{ label: "History (per-task commits)", value: "history" },
|
|
550
|
-
] },
|
|
551
|
-
{ title: "Ship", items: [
|
|
552
|
-
{ label: "Deploy (Cloudflare, Vercel, Netlify)", value: "deploy" },
|
|
553
|
-
{ label: "Export (Markdown, CSV, Jira, Trello)", value: "export" },
|
|
554
|
-
] },
|
|
555
|
-
{ title: "Manage", items: [
|
|
556
|
-
{ label: `Budget cap: ${selected.state.budgetCapUSD != null ? `$${selected.state.budgetCapUSD}` : "global default"}`, value: "cap" },
|
|
557
|
-
{ label: "Save as template", value: "saveTpl" },
|
|
558
|
-
{ label: "Rename", value: "rename" },
|
|
559
|
-
{ label: "Duplicate", value: "duplicate" },
|
|
560
|
-
{ label: "Delete", value: "delete" },
|
|
561
|
-
{ label: "Back", value: "back" },
|
|
562
|
-
] },
|
|
563
|
-
];
|
|
564
|
-
return (
|
|
565
|
-
<Box flexDirection="column">
|
|
566
|
-
<Box>
|
|
567
|
-
<Box flexGrow={1} marginRight={2}><Text bold wrap="truncate-end">{selected.idea}</Text></Box>
|
|
568
|
-
<Text color={statusChip.dot}>● </Text><Text color={C.textMuted}>{statusChip.label}</Text>
|
|
569
|
-
</Box>
|
|
570
|
-
{flash ? <Box marginTop={1}><StatusMessage variant="success">{flash}</StatusMessage></Box> : null}
|
|
571
|
-
<Box marginTop={1}><Standup tasks={allBoard} spent={selected.totalCost} /></Box>
|
|
572
|
-
<Box marginTop={1}><Team /></Box>
|
|
573
|
-
<Box marginTop={1}>
|
|
574
|
-
<GroupedMenu
|
|
575
|
-
groups={menuGroups}
|
|
576
|
-
maxRows={Math.max(6, termRows - 20)}
|
|
577
|
-
onSelect={(i) => {
|
|
578
|
-
if (i.value !== "export") setFlash("");
|
|
579
|
-
if (i.value === "editBoard") setPhase("editBoard");
|
|
580
|
-
else if (i.value === "kanban") { setFlash(""); setPhase("kanban"); }
|
|
581
|
-
else if (i.value === "export") { setFlash(""); setPhase("exportMenu"); }
|
|
582
|
-
else if (i.value === "deploy") { setFlash(""); setPhase("deployMenu"); }
|
|
583
|
-
else if (i.value === "view") setViewMode((v) => (v === "board" ? "list" : "board"));
|
|
584
|
-
else if (i.value === "filter") setPhase("filterEpic");
|
|
585
|
-
else if (i.value === "open") openInBrowser(mainFileOf(selected.dir));
|
|
586
|
-
else if (i.value === "history") { setFlash(""); setPhase("history"); }
|
|
587
|
-
else if (i.value === "retro") { setFlash(""); setNarr({ loading: false, text: getRetroNarrative(selected.dir) ?? "", error: "" }); setPhase("retro"); }
|
|
588
|
-
else if (i.value === "burndown") { setFlash(""); setPhase("burndown"); }
|
|
589
|
-
else if (i.value === "cap") { setCapReturn("projectActions"); setCapDraft(selected.state.budgetCapUSD != null ? String(selected.state.budgetCapUSD) : ""); setPhase("setCap"); }
|
|
590
|
-
else if (i.value === "saveTpl") { setFlash(""); setTplName(selected.idea.slice(0, 40)); setPhase("saveTemplate"); }
|
|
591
|
-
else if (i.value === "preview") {
|
|
592
|
-
const dir = selected.dir;
|
|
593
|
-
setPreview(null);
|
|
594
|
-
setPhase("preview");
|
|
595
|
-
startStaticServer(dir, { liveReload: true })
|
|
596
|
-
.then((server) => {
|
|
597
|
-
setPreview({ server });
|
|
598
|
-
const main = mainFileOf(dir).split("/").pop() || "index.html";
|
|
599
|
-
openInBrowser(`${server.url}/${main}`);
|
|
600
|
-
})
|
|
601
|
-
.catch((e) => setPreview({ error: e instanceof Error ? e.message : String(e) }));
|
|
602
|
-
}
|
|
603
|
-
else if (i.value === "back") setPhase("projects");
|
|
604
|
-
else if (i.value === "rename") {
|
|
605
|
-
setRenameDraft(selected.idea);
|
|
606
|
-
setPhase("rename");
|
|
607
|
-
} else if (i.value === "duplicate") {
|
|
608
|
-
const newDir = duplicateProject(selected.dir);
|
|
609
|
-
reselect(newDir);
|
|
610
|
-
setPhase("projectActions");
|
|
611
|
-
} else if (i.value === "delete") {
|
|
612
|
-
setPhase("confirmDelete");
|
|
613
|
-
} else if (i.value === "asset") {
|
|
614
|
-
setTargetWorkspace(selected.dir);
|
|
615
|
-
setAssetPath("");
|
|
616
|
-
setAssetMsg(null);
|
|
617
|
-
setAssetReturn("projectActions");
|
|
618
|
-
setPhase("addAsset");
|
|
619
|
-
} else if (i.value === "change") {
|
|
620
|
-
setTargetWorkspace(selected.dir);
|
|
621
|
-
setSeed(undefined);
|
|
622
|
-
setIdea("");
|
|
623
|
-
setScope("change");
|
|
624
|
-
setPhase("change");
|
|
625
|
-
} else if (i.value === "resume") {
|
|
626
|
-
const { registry, lock } = chooseRegistry(providers);
|
|
627
|
-
const done = completedIds(selected.state);
|
|
628
|
-
setPlan({
|
|
629
|
-
tasks: selected.state.tasks,
|
|
630
|
-
provider: lock ?? providers[0]!,
|
|
631
|
-
modelId: "",
|
|
632
|
-
estCost: estimateTasks(selected.state.tasks, registry).total,
|
|
633
|
-
registry,
|
|
634
|
-
lock,
|
|
635
|
-
});
|
|
636
|
-
setTasks(
|
|
637
|
-
selected.state.tasks.map((t) => ({
|
|
638
|
-
id: t.id, capability: t.capability, title: t.title,
|
|
639
|
-
status: done.has(t.id) ? "done" : "pending",
|
|
640
|
-
})),
|
|
641
|
-
);
|
|
642
|
-
setSpent(selected.state.totalCost);
|
|
643
|
-
setTargetWorkspace(selected.dir);
|
|
644
|
-
setSeed(selected.state.outcomes);
|
|
645
|
-
setIdea(selected.idea);
|
|
646
|
-
setPhase("building");
|
|
647
|
-
}
|
|
648
|
-
}}
|
|
649
|
-
/>
|
|
650
|
-
</Box>
|
|
651
|
-
</Box>
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
if (phase === "editBoard" && selected) {
|
|
656
|
-
const doneIds = completedIds(selected.state);
|
|
657
|
-
return (
|
|
658
|
-
<Box flexDirection="column">
|
|
659
|
-
<EditableBoard
|
|
660
|
-
tasks={selected.state.tasks}
|
|
661
|
-
doneIds={doneIds}
|
|
662
|
-
onSave={(t) => {
|
|
663
|
-
saveProjectTasks(selected.dir, t);
|
|
664
|
-
reselect(selected.dir);
|
|
665
|
-
setPhase("kanban"); // back to the board view
|
|
666
|
-
}}
|
|
667
|
-
onCancel={() => setPhase("kanban")}
|
|
668
|
-
/>
|
|
669
|
-
</Box>
|
|
670
|
-
);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if (phase === "exportMenu" && selected) {
|
|
674
|
-
const dir = selected.dir;
|
|
675
|
-
const run = (fn: () => string | string[], done: () => void) => {
|
|
676
|
-
try {
|
|
677
|
-
const out = fn();
|
|
678
|
-
const paths = Array.isArray(out) ? out : [out];
|
|
679
|
-
reselect(dir);
|
|
680
|
-
setFlash(`Exported → ${paths.map((p) => p.split("/").pop()).join(", ")} (in the project folder)`);
|
|
681
|
-
} catch (e) {
|
|
682
|
-
setFlash(e instanceof Error ? e.message : "Export failed.");
|
|
683
|
-
}
|
|
684
|
-
done();
|
|
685
|
-
};
|
|
686
|
-
return (
|
|
687
|
-
<Box flexDirection="column">
|
|
688
|
-
<Panel title="Export backlog">
|
|
689
|
-
<Text color={C.textMuted}>Writes files into the project folder. Import them into your tool of choice.</Text>
|
|
690
|
-
<Box marginTop={1}>
|
|
691
|
-
<SelectInput
|
|
692
|
-
items={[
|
|
693
|
-
{ label: "Markdown + CSV (readable + spreadsheet)", value: "md" },
|
|
694
|
-
{ label: "Jira CSV (Jira → External System Import → CSV)", value: "jira" },
|
|
695
|
-
{ label: "Trello CSV (Trello CSV-import Power-Up)", value: "trello" },
|
|
696
|
-
{ label: "All formats", value: "all" },
|
|
697
|
-
{ label: "Back", value: "back" },
|
|
698
|
-
]}
|
|
699
|
-
onSelect={(i) => {
|
|
700
|
-
const back = () => setPhase("projectActions");
|
|
701
|
-
if (i.value === "back") return back();
|
|
702
|
-
if (i.value === "md") return run(() => { const { md, csv } = exportProject(dir); return [md, csv]; }, back);
|
|
703
|
-
if (i.value === "jira") return run(() => exportJira(dir), back);
|
|
704
|
-
if (i.value === "trello") return run(() => exportTrello(dir), back);
|
|
705
|
-
if (i.value === "all") return run(() => {
|
|
706
|
-
const { md, csv } = exportProject(dir);
|
|
707
|
-
return [md, csv, exportJira(dir), exportTrello(dir)];
|
|
708
|
-
}, back);
|
|
709
|
-
}}
|
|
710
|
-
/>
|
|
711
|
-
</Box>
|
|
712
|
-
</Panel>
|
|
713
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Esc", label: "go back" }]} /></Box>
|
|
714
|
-
</Box>
|
|
715
|
-
);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
if (phase === "deployMenu" && selected) {
|
|
719
|
-
const dir = selected.dir;
|
|
720
|
-
const name = selected.idea || dir.split("/").pop() || "app";
|
|
721
|
-
const start = (target: DeployTarget) => {
|
|
722
|
-
setDeployState({ target, status: "running", log: [] });
|
|
723
|
-
setPhase("deploying");
|
|
724
|
-
deploy(target, dir, name, (line) =>
|
|
725
|
-
setDeployState((prev) => (prev ? { ...prev, log: [...prev.log.slice(-60), line] } : prev)),
|
|
726
|
-
)
|
|
727
|
-
.then((res) => setDeployState((prev) => (prev ? { ...prev, status: "done", url: res.url } : prev)))
|
|
728
|
-
.catch((e) => setDeployState((prev) => (prev ? { ...prev, status: "error", error: e instanceof Error ? e.message : String(e) } : prev)));
|
|
729
|
-
};
|
|
730
|
-
const targets: DeployTarget[] = ["cloudflare", "vercel", "netlify"];
|
|
731
|
-
return (
|
|
732
|
-
<Box flexDirection="column">
|
|
733
|
-
<Panel title="Deploy">
|
|
734
|
-
<Text color={C.textMuted}>Publishes the built site publicly. Each target needs its CLI installed and signed in.</Text>
|
|
735
|
-
<Box marginTop={1}>
|
|
736
|
-
<SelectInput
|
|
737
|
-
items={[
|
|
738
|
-
...targets.map((t) => ({ label: `${DEPLOY_META[t].label} (${DEPLOY_META[t].cli})`, value: t })),
|
|
739
|
-
{ label: "Back", value: "back" },
|
|
740
|
-
]}
|
|
741
|
-
onSelect={(i) => {
|
|
742
|
-
if (i.value === "back") return setPhase("projectActions");
|
|
743
|
-
start(i.value as DeployTarget);
|
|
744
|
-
}}
|
|
745
|
-
/>
|
|
746
|
-
</Box>
|
|
747
|
-
<Box marginTop={1} flexDirection="column">
|
|
748
|
-
<Text color={C.textSubtle}>Sign-in per target (one-time):</Text>
|
|
749
|
-
{targets.map((t) => (
|
|
750
|
-
<Text key={t} color={C.textSubtle}> {DEPLOY_META[t].label}: {DEPLOY_META[t].auth}</Text>
|
|
751
|
-
))}
|
|
752
|
-
</Box>
|
|
753
|
-
</Panel>
|
|
754
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Esc", label: "go back" }]} /></Box>
|
|
755
|
-
</Box>
|
|
756
|
-
);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
if (phase === "deploying" && deployState) {
|
|
760
|
-
const ds = deployState;
|
|
761
|
-
const meta = DEPLOY_META[ds.target];
|
|
762
|
-
return (
|
|
763
|
-
<Box flexDirection="column">
|
|
764
|
-
<Panel title={`Deploy → ${meta.label}`}>
|
|
765
|
-
{ds.status === "running" ? <Spinner label="Deploying… (first run can take a minute)" /> : null}
|
|
766
|
-
{ds.log.length ? (
|
|
767
|
-
<Box marginTop={1} flexDirection="column">
|
|
768
|
-
{ds.log.slice(-8).map((l, idx) => (
|
|
769
|
-
<Text key={idx} color={C.textSubtle} wrap="truncate-end">{l}</Text>
|
|
770
|
-
))}
|
|
771
|
-
</Box>
|
|
772
|
-
) : null}
|
|
773
|
-
{ds.status === "done" ? (
|
|
774
|
-
<Box marginTop={1}>
|
|
775
|
-
{ds.url
|
|
776
|
-
? <StatusMessage variant="success">Live at {ds.url}</StatusMessage>
|
|
777
|
-
: <StatusMessage variant="success">Deployed. (No URL parsed — check the log above.)</StatusMessage>}
|
|
778
|
-
</Box>
|
|
779
|
-
) : null}
|
|
780
|
-
{ds.status === "error" ? (
|
|
781
|
-
<Box marginTop={1}><StatusMessage variant="error">{ds.error}</StatusMessage></Box>
|
|
782
|
-
) : null}
|
|
783
|
-
{ds.status !== "running" ? (
|
|
784
|
-
<Box marginTop={1}>
|
|
785
|
-
<SelectInput items={[{ label: "Back", value: "back" }]} onSelect={() => { setDeployState(null); setPhase("projectActions"); }} />
|
|
786
|
-
</Box>
|
|
787
|
-
) : null}
|
|
788
|
-
</Panel>
|
|
789
|
-
</Box>
|
|
790
|
-
);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
if (phase === "burndown" && selected) {
|
|
794
|
-
const b = projectBurndown(selected.dir);
|
|
795
|
-
const W = 18;
|
|
796
|
-
const money = (n: number) => `$${n.toFixed(2)}`;
|
|
797
|
-
const maxCost = b ? Math.max(0.0001, ...b.steps.map((s) => s.cumCost)) : 1;
|
|
798
|
-
const remBar = (rem: number) => "█".repeat(Math.round((rem / (b?.taskCount || 1)) * W)).padEnd(W, "·");
|
|
799
|
-
const costBar = (c: number) => "█".repeat(Math.max(0, Math.round((c / maxCost) * W))).padEnd(W, " ");
|
|
800
|
-
return (
|
|
801
|
-
<Box flexDirection="column">
|
|
802
|
-
<Panel title={`Burndown — ${selected.idea}`}>
|
|
803
|
-
{!b || b.steps.length === 0 ? (
|
|
804
|
-
<Text color={C.textMuted}>No steps yet — run the build.</Text>
|
|
805
|
-
) : (
|
|
806
|
-
<Box flexDirection="column">
|
|
807
|
-
<Text color={C.textMuted}>Tasks remaining after each step (X = completion order, {b.taskCount} total)</Text>
|
|
808
|
-
{b.steps.map((s, i) => (
|
|
809
|
-
<Text key={i} wrap="truncate-end">
|
|
810
|
-
{`${(i + 1).toString().padStart(2)} ${s.taskId}`.padEnd(9)} <Text color={C.accent}>{remBar(s.remaining)}</Text> {String(s.remaining).padStart(2)}{s.retry ? <Text color={C.warn}> ↻ retry</Text> : null}
|
|
811
|
-
</Text>
|
|
812
|
-
))}
|
|
813
|
-
<Box marginTop={1} flexDirection="column">
|
|
814
|
-
<Text color={C.dim}>Cumulative spend (total {money(b.totalCost)})</Text>
|
|
815
|
-
{b.steps.map((s, i) => (
|
|
816
|
-
<Text key={i} wrap="truncate-end">
|
|
817
|
-
{`${(i + 1).toString().padStart(2)} ${s.taskId}`.padEnd(9)} <Text color={C.good}>{costBar(s.cumCost)}</Text> {money(s.cumCost)}
|
|
818
|
-
</Text>
|
|
819
|
-
))}
|
|
820
|
-
</Box>
|
|
821
|
-
</Box>
|
|
822
|
-
)}
|
|
823
|
-
<Box marginTop={1}>
|
|
824
|
-
<SelectInput items={[{ label: "Back", value: "back" }]} onSelect={() => setPhase("projectActions")} />
|
|
825
|
-
</Box>
|
|
826
|
-
</Panel>
|
|
827
|
-
</Box>
|
|
828
|
-
);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
if (phase === "kanban" && selected) {
|
|
832
|
-
const done = completedIds(selected.state);
|
|
833
|
-
const costBy = new Map<string, number>();
|
|
834
|
-
const modelBy = new Map<string, string>();
|
|
835
|
-
for (const o of selected.state.outcomes) {
|
|
836
|
-
costBy.set(o.taskId, (costBy.get(o.taskId) ?? 0) + o.cost);
|
|
837
|
-
modelBy.set(o.taskId, o.modelId);
|
|
838
|
-
}
|
|
839
|
-
const tasks: BoardTask[] = selected.state.tasks.map((t) => ({
|
|
840
|
-
id: t.id,
|
|
841
|
-
capability: t.capability,
|
|
842
|
-
title: t.title,
|
|
843
|
-
epic: t.epic,
|
|
844
|
-
dependsOn: t.dependsOn,
|
|
845
|
-
notes: t.notes,
|
|
846
|
-
status: done.has(t.id) ? "done" : "pending",
|
|
847
|
-
cost: costBy.get(t.id),
|
|
848
|
-
assignee: modelBy.has(t.id) ? modelLabel(modelBy.get(t.id)!) : undefined,
|
|
849
|
-
}));
|
|
850
|
-
return (
|
|
851
|
-
<Box flexDirection="column">
|
|
852
|
-
<Text bold wrap="truncate-end">{selected.idea}</Text>
|
|
853
|
-
<Box marginTop={1}><Standup tasks={tasks} spent={selected.totalCost} /></Box>
|
|
854
|
-
<Box marginTop={1}>
|
|
855
|
-
<Panel title="Board">
|
|
856
|
-
<Kanban tasks={tasks} compact maxPerCol={Math.max(4, termRows - 14)} />
|
|
857
|
-
</Panel>
|
|
858
|
-
</Box>
|
|
859
|
-
<Box marginTop={1}>
|
|
860
|
-
<SelectInput
|
|
861
|
-
items={[
|
|
862
|
-
{ label: "Edit board (add · reorder · deps · rename)", value: "edit" },
|
|
863
|
-
{ label: "Back", value: "back" },
|
|
864
|
-
]}
|
|
865
|
-
onSelect={(i) => setPhase(i.value === "edit" ? "editBoard" : "projectActions")}
|
|
866
|
-
/>
|
|
867
|
-
</Box>
|
|
868
|
-
</Box>
|
|
869
|
-
);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
if (phase === "retro" && selected) {
|
|
873
|
-
const r = projectRetro(selected.dir);
|
|
874
|
-
const money = (n: number) => `$${n.toFixed(2)}`;
|
|
875
|
-
const maxEpic = r ? Math.max(1, ...r.byEpic.map((e) => e.cost)) : 1;
|
|
876
|
-
const bar = (cost: number) => "█".repeat(Math.max(1, Math.round((cost / maxEpic) * 16)));
|
|
877
|
-
return (
|
|
878
|
-
<Box flexDirection="column">
|
|
879
|
-
<Panel title={`Retro — ${selected.idea}`}>
|
|
880
|
-
{!r ? (
|
|
881
|
-
<Text color={C.textMuted}>No build data yet.</Text>
|
|
882
|
-
) : (
|
|
883
|
-
<Box flexDirection="column">
|
|
884
|
-
<Text>
|
|
885
|
-
<Text color={C.textMuted}>Status </Text>{r.status}
|
|
886
|
-
<Text color={C.dim}> · Cost </Text><Text color={C.accent}>{money(r.totalCost)}</Text>
|
|
887
|
-
<Text color={C.dim}> · Tasks </Text>{r.doneCount}/{r.taskCount}
|
|
888
|
-
<Text color={C.dim}> · Tests </Text><Text color={C.good}>{r.tests.passed}✓</Text> <Text color={r.tests.failed ? (C.bad) : C.dim}>{r.tests.failed}✗</Text>
|
|
889
|
-
</Text>
|
|
890
|
-
{r.estCost > 0 ? (() => {
|
|
891
|
-
const delta = r.estCost > 0 ? Math.round(((r.totalCost - r.estCost) / r.estCost) * 100) : 0;
|
|
892
|
-
const under = r.totalCost <= r.estCost;
|
|
893
|
-
return (
|
|
894
|
-
<Text>
|
|
895
|
-
<Text color={C.dim}>Predicted </Text>{money(r.estCost)}
|
|
896
|
-
<Text color={C.dim}> → actual </Text><Text color={C.accent}>{money(r.totalCost)}</Text>
|
|
897
|
-
<Text color={under ? (C.good) : C.warn}> {delta >= 0 ? "+" : ""}{delta}% {under ? "under" : "over"}</Text>
|
|
898
|
-
</Text>
|
|
899
|
-
);
|
|
900
|
-
})() : null}
|
|
901
|
-
|
|
902
|
-
{r.byEpic.length ? (
|
|
903
|
-
<Box flexDirection="column" marginTop={1}>
|
|
904
|
-
<Text color={C.dim}>Cost by epic</Text>
|
|
905
|
-
{r.byEpic.map((e) => (
|
|
906
|
-
<Text key={e.epic} wrap="truncate-end"> <Text color={C.accent}>{bar(e.cost)}</Text> {money(e.cost).padEnd(7)} <Text color={C.dim}>{e.epic} ({e.tasks})</Text></Text>
|
|
907
|
-
))}
|
|
908
|
-
</Box>
|
|
909
|
-
) : null}
|
|
910
|
-
|
|
911
|
-
<Box flexDirection="column" marginTop={1}>
|
|
912
|
-
<Text color={C.dim}>Cost by model</Text>
|
|
913
|
-
{r.byModel.map((m) => (
|
|
914
|
-
<Text key={m.model} wrap="truncate-end"> {money(m.cost).padEnd(7)} <Text color={C.dim}>{modelLabel(m.model)} ({m.tasks})</Text></Text>
|
|
915
|
-
))}
|
|
916
|
-
</Box>
|
|
917
|
-
|
|
918
|
-
{r.topCost.length ? (
|
|
919
|
-
<Box flexDirection="column" marginTop={1}>
|
|
920
|
-
<Text color={C.dim}>Priciest tasks</Text>
|
|
921
|
-
{r.topCost.map((t) => (
|
|
922
|
-
<Text key={t.taskId} wrap="truncate-end"> {money(t.cost).padEnd(7)} <Text color={C.dim}>{t.taskId}</Text> {t.title}</Text>
|
|
923
|
-
))}
|
|
924
|
-
</Box>
|
|
925
|
-
) : null}
|
|
926
|
-
|
|
927
|
-
{r.retries.length ? (
|
|
928
|
-
<Box marginTop={1}><Text color={C.warn}>Rebuilds: {r.retries.map((x) => `${x.taskId}×${x.rounds}`).join(", ")}</Text></Box>
|
|
929
|
-
) : null}
|
|
930
|
-
|
|
931
|
-
{r.bugs.length ? (
|
|
932
|
-
<Box flexDirection="column" marginTop={1}>
|
|
933
|
-
<Text color={C.dim}>Tester flagged {r.bugs.length} issue{r.bugs.length === 1 ? "" : "s"}:</Text>
|
|
934
|
-
{r.bugs.slice(0, 5).map((b, i) => (
|
|
935
|
-
<Text key={i} wrap="truncate-end"> <Text color={b.severity === "high" ? (C.bad) : C.warn}>[{b.severity}]</Text> {b.description}</Text>
|
|
936
|
-
))}
|
|
937
|
-
</Box>
|
|
938
|
-
) : (
|
|
939
|
-
<Box marginTop={1}><Text color={C.good}>No open issues flagged by the tester.</Text></Box>
|
|
940
|
-
)}
|
|
941
|
-
|
|
942
|
-
{narr.loading ? (
|
|
943
|
-
<Box marginTop={1}><Spinner label="Writing the retro narrative…" /></Box>
|
|
944
|
-
) : narr.error ? (
|
|
945
|
-
<Box marginTop={1}><StatusMessage variant="error">{narr.error}</StatusMessage></Box>
|
|
946
|
-
) : narr.text ? (
|
|
947
|
-
<Box marginTop={1}>
|
|
948
|
-
<Panel title="AI narrative">
|
|
949
|
-
{narr.text.split("\n").map((line, i) => <Text key={i} wrap="wrap">{line}</Text>)}
|
|
950
|
-
</Panel>
|
|
951
|
-
</Box>
|
|
952
|
-
) : null}
|
|
953
|
-
</Box>
|
|
954
|
-
)}
|
|
955
|
-
<Box marginTop={1}>
|
|
956
|
-
<SelectInput
|
|
957
|
-
items={[
|
|
958
|
-
...(r && !narr.loading ? [{ label: narr.text ? "Regenerate AI narrative" : "Generate AI narrative", value: "narrate" }] : []),
|
|
959
|
-
{ label: "Back", value: "back" },
|
|
960
|
-
]}
|
|
961
|
-
onSelect={(i) => {
|
|
962
|
-
if (i.value === "narrate") {
|
|
963
|
-
setNarr({ loading: true, text: "", error: "" });
|
|
964
|
-
generateRetroNarrative(selected.dir, providers)
|
|
965
|
-
.then((text) => setNarr({ loading: false, text, error: "" }))
|
|
966
|
-
.catch((e) => setNarr({ loading: false, text: "", error: e instanceof Error ? e.message : String(e) }));
|
|
967
|
-
} else {
|
|
968
|
-
setPhase("projectActions");
|
|
969
|
-
}
|
|
970
|
-
}}
|
|
971
|
-
/>
|
|
972
|
-
</Box>
|
|
973
|
-
</Panel>
|
|
974
|
-
</Box>
|
|
975
|
-
);
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
if (phase === "history" && selected) {
|
|
979
|
-
const dir = selected.dir;
|
|
980
|
-
const commits = projectHistory(dir);
|
|
981
|
-
const canUndo = commits.length > 1; // more than the initial commit
|
|
982
|
-
return (
|
|
983
|
-
<Box flexDirection="column">
|
|
984
|
-
<Panel title={`History — ${selected.idea}`}>
|
|
985
|
-
<Text color={C.textMuted}>One commit per finished task, newest first. Diff/checkout in the project folder with git.</Text>
|
|
986
|
-
{flash ? <Box marginTop={1}><StatusMessage variant="success">{flash}</StatusMessage></Box> : null}
|
|
987
|
-
<Box marginTop={1} flexDirection="column">
|
|
988
|
-
{commits.length ? (
|
|
989
|
-
commits.slice(0, 20).map((c) => (
|
|
990
|
-
<Text key={c.hash} wrap="truncate-end"><Text color={C.accent}>{c.hash}</Text> {c.msg}</Text>
|
|
991
|
-
))
|
|
992
|
-
) : (
|
|
993
|
-
<Text color={C.textMuted}>No history yet (this project predates git-per-build, or git isn't installed).</Text>
|
|
994
|
-
)}
|
|
995
|
-
</Box>
|
|
996
|
-
<Box marginTop={1}>
|
|
997
|
-
<SelectInput
|
|
998
|
-
items={[
|
|
999
|
-
...(canUndo ? [{ label: "Undo last task (revert files + reopen it to rebuild)", value: "undo" }] : []),
|
|
1000
|
-
{ label: "Back", value: "back" },
|
|
1001
|
-
]}
|
|
1002
|
-
onSelect={(i) => {
|
|
1003
|
-
if (i.value === "undo") {
|
|
1004
|
-
const r = undoLastTask(dir);
|
|
1005
|
-
reselect(dir);
|
|
1006
|
-
setFlash(r.ok ? `Undid ${r.taskId ?? "last task"}. Resume the build to rebuild it.` : (r.error ?? "Undo failed."));
|
|
1007
|
-
} else {
|
|
1008
|
-
setFlash("");
|
|
1009
|
-
setPhase("projectActions");
|
|
1010
|
-
}
|
|
1011
|
-
}}
|
|
1012
|
-
/>
|
|
1013
|
-
</Box>
|
|
1014
|
-
</Panel>
|
|
1015
|
-
</Box>
|
|
1016
|
-
);
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
if (phase === "preview") {
|
|
1020
|
-
const stop = () => {
|
|
1021
|
-
if (preview && "server" in preview) void preview.server.close();
|
|
1022
|
-
setPreview(null);
|
|
1023
|
-
setPhase("projectActions");
|
|
1024
|
-
};
|
|
1025
|
-
const url = preview && "server" in preview ? preview.server.url : null;
|
|
1026
|
-
const err = preview && "error" in preview ? preview.error : null;
|
|
1027
|
-
return (
|
|
1028
|
-
<Box flexDirection="column">
|
|
1029
|
-
<Panel title="Live preview">
|
|
1030
|
-
<Box flexDirection="column">
|
|
1031
|
-
{err ? <StatusMessage variant="error">{err}</StatusMessage> : null}
|
|
1032
|
-
{!err && !url ? <Spinner label="Starting local server…" /> : null}
|
|
1033
|
-
{url ? (
|
|
1034
|
-
<>
|
|
1035
|
-
<StatusMessage variant="success">Serving at {url}</StatusMessage>
|
|
1036
|
-
<Text color={C.textMuted}>Opened in your browser. It auto-reloads when the files change</Text>
|
|
1037
|
-
<Text color={C.textMuted}>(e.g. after a resume/change build). ES modules + fetch work here,</Text>
|
|
1038
|
-
<Text color={C.textMuted}>unlike opening the file directly.</Text>
|
|
1039
|
-
</>
|
|
1040
|
-
) : null}
|
|
1041
|
-
</Box>
|
|
1042
|
-
<Box marginTop={1}>
|
|
1043
|
-
<SelectInput items={[{ label: "Stop preview & go back", value: "stop" }]} onSelect={stop} />
|
|
1044
|
-
</Box>
|
|
1045
|
-
</Panel>
|
|
1046
|
-
</Box>
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
if (phase === "filterEpic" && selected) {
|
|
1051
|
-
const epics = [...new Set(selected.state.tasks.map((t) => t.epic || "General"))];
|
|
1052
|
-
return (
|
|
1053
|
-
<Box flexDirection="column">
|
|
1054
|
-
<Panel title="Filter by epic">
|
|
1055
|
-
<SelectInput
|
|
1056
|
-
items={[
|
|
1057
|
-
{ label: "All epics", value: "__all" },
|
|
1058
|
-
...epics.map((e) => ({ label: e, value: e })),
|
|
1059
|
-
]}
|
|
1060
|
-
onSelect={(i) => {
|
|
1061
|
-
setEpicFilter(i.value === "__all" ? null : i.value);
|
|
1062
|
-
setPhase("projectActions");
|
|
1063
|
-
}}
|
|
1064
|
-
/>
|
|
1065
|
-
</Panel>
|
|
1066
|
-
</Box>
|
|
1067
|
-
);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
if (phase === "rename" && selected) {
|
|
1071
|
-
return (
|
|
1072
|
-
<Box flexDirection="column">
|
|
1073
|
-
<Panel title="Rename project">
|
|
1074
|
-
<Box>
|
|
1075
|
-
<Text color={C.accent}>{"› "}</Text>
|
|
1076
|
-
<TextInput
|
|
1077
|
-
value={renameDraft}
|
|
1078
|
-
onChange={setRenameDraft}
|
|
1079
|
-
onSubmit={() => {
|
|
1080
|
-
if (renameDraft.trim()) renameProject(selected.dir, renameDraft.trim());
|
|
1081
|
-
reselect(selected.dir);
|
|
1082
|
-
setPhase("projectActions");
|
|
1083
|
-
}}
|
|
1084
|
-
/>
|
|
1085
|
-
</Box>
|
|
1086
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Enter", label: "save" }, { keys: "Esc", label: "go back" }]} /></Box>
|
|
1087
|
-
</Panel>
|
|
1088
|
-
</Box>
|
|
1089
|
-
);
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
if (phase === "confirmDelete" && selected) {
|
|
1093
|
-
return (
|
|
1094
|
-
<Box flexDirection="column">
|
|
1095
|
-
<Panel title="Delete this project?" borderColor={C.bad}>
|
|
1096
|
-
<Text color={C.textMuted} wrap="truncate-end">{selected.idea}</Text>
|
|
1097
|
-
<Text color={C.textSubtle}>This permanently removes its folder and files. Can't be undone.</Text>
|
|
1098
|
-
<Box marginTop={1}>
|
|
1099
|
-
<SelectInput
|
|
1100
|
-
items={[
|
|
1101
|
-
{ label: "No, keep it", value: "no" },
|
|
1102
|
-
{ label: "Yes, delete", value: "yes" },
|
|
1103
|
-
]}
|
|
1104
|
-
onSelect={(i) => {
|
|
1105
|
-
if (i.value === "yes") {
|
|
1106
|
-
deleteProject(selected.dir);
|
|
1107
|
-
const list = reselect(null);
|
|
1108
|
-
setPhase(list.length ? "projects" : "home");
|
|
1109
|
-
} else setPhase("projectActions");
|
|
1110
|
-
}}
|
|
1111
|
-
/>
|
|
1112
|
-
</Box>
|
|
1113
|
-
</Panel>
|
|
1114
|
-
</Box>
|
|
1115
|
-
);
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
if (phase === "addAsset") {
|
|
1119
|
-
return (
|
|
1120
|
-
<Box flexDirection="column">
|
|
1121
|
-
<Panel title="Add a file to this project">
|
|
1122
|
-
<Text color={C.textMuted}>Paste the full path to an image or file. Tip: drag the file into the terminal to paste its path. ~ works.</Text>
|
|
1123
|
-
{assetMsg ? <Text color={assetMsg.ok ? C.good : C.bad}>{"\n"}{assetMsg.ok ? "✓ " : "✗ "}{assetMsg.text}</Text> : null}
|
|
1124
|
-
<Box marginTop={1}>
|
|
1125
|
-
<Text color={C.accent}>{"› "}</Text>
|
|
1126
|
-
<TextInput
|
|
1127
|
-
key={assetKey}
|
|
1128
|
-
value={assetPath}
|
|
1129
|
-
onChange={setAssetPath}
|
|
1130
|
-
placeholder="/Users/you/Pictures/avatar.png"
|
|
1131
|
-
onSubmit={() => {
|
|
1132
|
-
if (!assetPath.trim()) {
|
|
1133
|
-
setPhase(assetReturn);
|
|
1134
|
-
return;
|
|
1135
|
-
}
|
|
1136
|
-
const r = targetWorkspace
|
|
1137
|
-
? addAsset(targetWorkspace, assetPath)
|
|
1138
|
-
: ({ ok: false, error: "No project selected." } as const);
|
|
1139
|
-
setAssetMsg(r.ok ? { ok: true, text: `Added ${r.name}. You can now ask to use it in “Make changes”.` } : { ok: false, text: r.error });
|
|
1140
|
-
if (r.ok) { setAssetPath(""); setAssetKey((k) => k + 1); }
|
|
1141
|
-
}}
|
|
1142
|
-
/>
|
|
1143
|
-
</Box>
|
|
1144
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Enter", label: "add" }, { keys: "Esc", label: "go back" }]} /></Box>
|
|
1145
|
-
</Panel>
|
|
1146
|
-
</Box>
|
|
1147
|
-
);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
if (phase === "change") {
|
|
1151
|
-
return (
|
|
1152
|
-
<Box flexDirection="column">
|
|
1153
|
-
<Panel title="Add to the backlog">
|
|
1154
|
-
<Text color={C.textMuted}>Describe what to add or change — the PM plans it into new tasks against this project's files.</Text>
|
|
1155
|
-
<Box marginTop={1}>
|
|
1156
|
-
<Text color={C.accent}>{"› "}</Text>
|
|
1157
|
-
<TextInput
|
|
1158
|
-
value={idea}
|
|
1159
|
-
onChange={setIdea}
|
|
1160
|
-
onSubmit={() => idea.trim() && setPhase("assessing")}
|
|
1161
|
-
placeholder="make the header dark blue and add a footer with a copyright line"
|
|
1162
|
-
/>
|
|
1163
|
-
</Box>
|
|
1164
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Enter", label: "continue" }, { keys: "Esc", label: "go back" }]} /></Box>
|
|
1165
|
-
</Panel>
|
|
1166
|
-
</Box>
|
|
1167
|
-
);
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
if (phase === "saveTemplate" && selected) {
|
|
1171
|
-
return (
|
|
1172
|
-
<Box flexDirection="column">
|
|
1173
|
-
<Panel title="Save as template">
|
|
1174
|
-
<Text color={C.textMuted}>Reuse this project's brief as a starting point for future builds.</Text>
|
|
1175
|
-
<Box marginTop={1}>
|
|
1176
|
-
<Text color={C.accent}>{"name › "}</Text>
|
|
1177
|
-
<TextInput
|
|
1178
|
-
value={tplName}
|
|
1179
|
-
onChange={setTplName}
|
|
1180
|
-
onSubmit={() => {
|
|
1181
|
-
const name = tplName.trim();
|
|
1182
|
-
if (!name) { setPhase("projectActions"); return; }
|
|
1183
|
-
saveUserTemplate({ name, blurb: selected.idea.slice(0, 48), idea: selected.state.idea ?? selected.idea });
|
|
1184
|
-
setFlash(`Saved template “${name}”.`);
|
|
1185
|
-
setPhase("projectActions");
|
|
1186
|
-
}}
|
|
1187
|
-
/>
|
|
1188
|
-
</Box>
|
|
1189
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Enter", label: "save" }, { keys: "Esc", label: "cancel" }]} /></Box>
|
|
1190
|
-
</Panel>
|
|
1191
|
-
</Box>
|
|
1192
|
-
);
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
if (phase === "importTemplate") {
|
|
1196
|
-
return (
|
|
1197
|
-
<Box flexDirection="column">
|
|
1198
|
-
<Panel title="Import a shared template">
|
|
1199
|
-
<Text color={C.textMuted}>Paste the path to a .pitemplate.json file someone shared.</Text>
|
|
1200
|
-
{flash ? <Box marginTop={1}><StatusMessage variant="error">{flash}</StatusMessage></Box> : null}
|
|
1201
|
-
<Box marginTop={1}>
|
|
1202
|
-
<Text color={C.accent}>{"› "}</Text>
|
|
1203
|
-
<TextInput
|
|
1204
|
-
value={tplPath}
|
|
1205
|
-
onChange={setTplPath}
|
|
1206
|
-
onSubmit={() => {
|
|
1207
|
-
if (!tplPath.trim()) { setPhase("templates"); return; }
|
|
1208
|
-
try {
|
|
1209
|
-
const t = importTemplate(tplPath);
|
|
1210
|
-
setFlash(`Imported “${t.name}”.`);
|
|
1211
|
-
setPhase("templates");
|
|
1212
|
-
} catch (e) {
|
|
1213
|
-
setFlash(e instanceof Error ? e.message : "Import failed.");
|
|
1214
|
-
}
|
|
1215
|
-
}}
|
|
1216
|
-
/>
|
|
1217
|
-
</Box>
|
|
1218
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Enter", label: "import" }, { keys: "Esc", label: "cancel" }]} /></Box>
|
|
1219
|
-
</Panel>
|
|
1220
|
-
</Box>
|
|
1221
|
-
);
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
if (phase === "myTemplates") {
|
|
1225
|
-
const mine = allTemplates().filter((t) => !t.builtin);
|
|
1226
|
-
return (
|
|
1227
|
-
<Box flexDirection="column">
|
|
1228
|
-
<Panel title="My templates">
|
|
1229
|
-
{flash ? <Box marginBottom={1}><StatusMessage variant="success">{flash}</StatusMessage></Box> : null}
|
|
1230
|
-
<SelectInput
|
|
1231
|
-
items={[
|
|
1232
|
-
...mine.map((t) => ({ label: `${t.name} — ${t.blurb}`, value: t.name })),
|
|
1233
|
-
{ label: "Back", value: "__back" },
|
|
1234
|
-
]}
|
|
1235
|
-
onSelect={(i) => {
|
|
1236
|
-
if (i.value === "__back") { setFlash(""); setPhase("templates"); return; }
|
|
1237
|
-
setTplSel(mine.find((t) => t.name === i.value) ?? null);
|
|
1238
|
-
setPhase("tplActions");
|
|
1239
|
-
}}
|
|
1240
|
-
/>
|
|
1241
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Esc", label: "go back" }]} /></Box>
|
|
1242
|
-
</Panel>
|
|
1243
|
-
</Box>
|
|
1244
|
-
);
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
if (phase === "tplActions" && tplSel) {
|
|
1248
|
-
return (
|
|
1249
|
-
<Box flexDirection="column">
|
|
1250
|
-
<Panel title={tplSel.name}>
|
|
1251
|
-
{flash ? <Box marginBottom={1}><StatusMessage variant="success">{flash}</StatusMessage></Box> : null}
|
|
1252
|
-
<SelectInput
|
|
1253
|
-
items={[
|
|
1254
|
-
{ label: "Share — export to a file", value: "export" },
|
|
1255
|
-
{ label: "Delete", value: "delete" },
|
|
1256
|
-
{ label: "Back", value: "back" },
|
|
1257
|
-
]}
|
|
1258
|
-
onSelect={(i) => {
|
|
1259
|
-
if (i.value === "export") { const p = exportTemplate(tplSel); setFlash(`Shared → ${p}`); setPhase("myTemplates"); }
|
|
1260
|
-
else if (i.value === "delete") { deleteUserTemplate(tplSel.name); setFlash(`Deleted “${tplSel.name}”.`); setPhase("myTemplates"); }
|
|
1261
|
-
else setPhase("myTemplates");
|
|
1262
|
-
}}
|
|
1263
|
-
/>
|
|
1264
|
-
</Panel>
|
|
1265
|
-
</Box>
|
|
1266
|
-
);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
if (phase === "templates") {
|
|
1270
|
-
const tpls = allTemplates();
|
|
1271
|
-
const hasUser = tpls.some((t) => !t.builtin);
|
|
1272
|
-
return (
|
|
1273
|
-
<Box flexDirection="column">
|
|
1274
|
-
<Panel title="Start from a template">
|
|
1275
|
-
<Text color={C.textMuted}>Pick one — you can edit the tasks on the board after. ★ = yours.</Text>
|
|
1276
|
-
{flash ? <Box marginTop={1}><StatusMessage variant="success">{flash}</StatusMessage></Box> : null}
|
|
1277
|
-
<Box marginTop={1}>
|
|
1278
|
-
<SelectInput
|
|
1279
|
-
items={[
|
|
1280
|
-
...tpls.map((t) => ({ label: `${t.builtin ? " " : "★ "}${t.name} — ${t.blurb}`, value: `t:${t.name}` })),
|
|
1281
|
-
{ label: "Import a shared template…", value: "__import" },
|
|
1282
|
-
...(hasUser ? [{ label: "Manage my templates", value: "__manage" }] : []),
|
|
1283
|
-
{ label: "Back", value: "__back" },
|
|
1284
|
-
]}
|
|
1285
|
-
onSelect={(i) => {
|
|
1286
|
-
if (i.value === "__back") { setFlash(""); setPhase("home"); return; }
|
|
1287
|
-
if (i.value === "__import") { setFlash(""); setTplPath(""); setPhase("importTemplate"); return; }
|
|
1288
|
-
if (i.value === "__manage") { setFlash(""); setPhase("myTemplates"); return; }
|
|
1289
|
-
const name = i.value.slice(2);
|
|
1290
|
-
const tpl = tpls.find((t) => t.name === name)!;
|
|
1291
|
-
resetBuildContext();
|
|
1292
|
-
setScope("full");
|
|
1293
|
-
setIdea(tpl.idea);
|
|
1294
|
-
setPhase("stack");
|
|
1295
|
-
}}
|
|
1296
|
-
/>
|
|
1297
|
-
</Box>
|
|
1298
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Esc", label: "go back" }]} /></Box>
|
|
1299
|
-
</Panel>
|
|
1300
|
-
</Box>
|
|
1301
|
-
);
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
if (phase === "idea") {
|
|
1305
|
-
return (
|
|
1306
|
-
<Box flexDirection="column">
|
|
1307
|
-
<Panel title="What do you want to build?">
|
|
1308
|
-
<Box>
|
|
1309
|
-
<Text color={C.accent}>{"› "}</Text>
|
|
1310
|
-
<TextInput value={idea} onChange={setIdea} onSubmit={() => idea.trim() && setPhase("stack")} placeholder="a landing page for a coffee shop with a menu and contact form" />
|
|
1311
|
-
</Box>
|
|
1312
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Enter", label: "continue" }, { keys: "Esc", label: "go back" }]} /></Box>
|
|
1313
|
-
</Panel>
|
|
1314
|
-
</Box>
|
|
1315
|
-
);
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
if (phase === "stack") {
|
|
1319
|
-
// When a default stack is set, the effect above skips straight to assessing.
|
|
1320
|
-
if (getPreferredStack() !== "ask") {
|
|
1321
|
-
return (
|
|
1322
|
-
<Box flexDirection="column">
|
|
1323
|
-
<Spinner label="Preparing…" />
|
|
1324
|
-
</Box>
|
|
1325
|
-
);
|
|
1326
|
-
}
|
|
1327
|
-
return (
|
|
1328
|
-
<Box flexDirection="column">
|
|
1329
|
-
<Text bold>What should I build it with?</Text>
|
|
1330
|
-
<Box marginTop={1}>
|
|
1331
|
-
<StackPick
|
|
1332
|
-
onDone={(choice: StackChoice) => {
|
|
1333
|
-
setStackChoice(choice);
|
|
1334
|
-
setPhase("assessing");
|
|
1335
|
-
}}
|
|
1336
|
-
/>
|
|
1337
|
-
</Box>
|
|
1338
|
-
</Box>
|
|
1339
|
-
);
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
if (phase === "assessing") {
|
|
1343
|
-
return (
|
|
1344
|
-
<Box flexDirection="column">
|
|
1345
|
-
<Spinner label="Reading your request…" />
|
|
1346
|
-
<Text color={C.dim}>{"\n"}“{idea}”</Text>
|
|
1347
|
-
</Box>
|
|
1348
|
-
);
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
if (phase === "intake") {
|
|
1352
|
-
return (
|
|
1353
|
-
<Box flexDirection="column">
|
|
1354
|
-
<Text bold>A few quick questions</Text>
|
|
1355
|
-
<Text color={C.dim}>The PM needs a little more to build the right thing.</Text>
|
|
1356
|
-
<Box marginTop={1}>
|
|
1357
|
-
<Intake
|
|
1358
|
-
questions={intakeQs}
|
|
1359
|
-
onDone={(answers: Answer[]) => {
|
|
1360
|
-
setIntakeAnswers(answers);
|
|
1361
|
-
setPhase("planMode");
|
|
1362
|
-
}}
|
|
1363
|
-
onCancel={() => setPhase("idea")}
|
|
1364
|
-
/>
|
|
1365
|
-
</Box>
|
|
1366
|
-
</Box>
|
|
1367
|
-
);
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
if (phase === "planMode") {
|
|
1371
|
-
return (
|
|
1372
|
-
<Box flexDirection="column">
|
|
1373
|
-
<Panel title="How should the team plan this?">
|
|
1374
|
-
<SelectInput
|
|
1375
|
-
items={[
|
|
1376
|
-
{ label: "Quick plan — one PM breaks it into tasks", value: "quick" },
|
|
1377
|
-
{ label: "Deep plan — a council debates epics first, you approve (costs more)", value: "deep" },
|
|
1378
|
-
]}
|
|
1379
|
-
onSelect={(i) => {
|
|
1380
|
-
if (i.value === "deep") setPhase("council");
|
|
1381
|
-
else { setApprovedEpics(null); setPhase("planning"); }
|
|
1382
|
-
}}
|
|
1383
|
-
/>
|
|
1384
|
-
<Box flexDirection="column" marginTop={1}>
|
|
1385
|
-
<Text color={C.textSubtle}>Deep plan is worth it for bigger/complex builds.</Text>
|
|
1386
|
-
<KeyHint hints={[{ keys: "Esc", label: "go back" }]} />
|
|
1387
|
-
</Box>
|
|
1388
|
-
</Panel>
|
|
1389
|
-
</Box>
|
|
1390
|
-
);
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
if (phase === "council") {
|
|
1394
|
-
return (
|
|
1395
|
-
<Box flexDirection="column">
|
|
1396
|
-
<Text bold>Planning council in session…</Text>
|
|
1397
|
-
<Box marginTop={1}><Spinner label="Architect, product, and risk leads are proposing epics, then synthesizing…" /></Box>
|
|
1398
|
-
</Box>
|
|
1399
|
-
);
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
if (phase === "approveEpics" && council.result) {
|
|
1403
|
-
const epics = council.result.epics;
|
|
1404
|
-
return (
|
|
1405
|
-
<Box flexDirection="column">
|
|
1406
|
-
<Panel title={`Proposed epics (${epics.length})`}>
|
|
1407
|
-
<Text color={C.textMuted}>The council merged the architect / product / risk views. Approve to expand into tasks.</Text>
|
|
1408
|
-
<Box marginTop={1} flexDirection="column">
|
|
1409
|
-
{epics.map((e, i) => (
|
|
1410
|
-
<Box key={i} flexDirection="column" marginBottom={1}>
|
|
1411
|
-
<Text><Text color={C.accent}>{i + 1}. {e.name}</Text></Text>
|
|
1412
|
-
<Text color={C.textSubtle} wrap="wrap"> {e.rationale}</Text>
|
|
1413
|
-
</Box>
|
|
1414
|
-
))}
|
|
1415
|
-
</Box>
|
|
1416
|
-
<SelectInput
|
|
1417
|
-
items={[
|
|
1418
|
-
{ label: "Approve — expand these epics into tasks", value: "approve" },
|
|
1419
|
-
{ label: "Skip epics — quick plan instead", value: "quick" },
|
|
1420
|
-
{ label: "Back", value: "back" },
|
|
1421
|
-
]}
|
|
1422
|
-
onSelect={(i) => {
|
|
1423
|
-
if (i.value === "approve") { setApprovedEpics(epics); setPhase("planning"); }
|
|
1424
|
-
else if (i.value === "quick") { setApprovedEpics(null); setPhase("planning"); }
|
|
1425
|
-
else setPhase("planMode");
|
|
1426
|
-
}}
|
|
1427
|
-
/>
|
|
1428
|
-
</Panel>
|
|
1429
|
-
</Box>
|
|
1430
|
-
);
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
if (phase === "planning") {
|
|
1434
|
-
return (
|
|
1435
|
-
<Box flexDirection="column">
|
|
1436
|
-
<Spinner label={approvedEpics ? "Expanding the approved epics into tasks…" : "Breaking your idea into a plan…"} />
|
|
1437
|
-
<Text color={C.dim}>{"\n"}“{idea}”</Text>
|
|
1438
|
-
</Box>
|
|
1439
|
-
);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
if (phase === "setCap") {
|
|
1443
|
-
const globalCap = getPrefs().budgetCapUSD;
|
|
1444
|
-
return (
|
|
1445
|
-
<Box flexDirection="column">
|
|
1446
|
-
<Panel title="Budget cap for this project">
|
|
1447
|
-
<Text color={C.textMuted}>The build halts if spend passes this. Leave blank to use the global default (${globalCap}).</Text>
|
|
1448
|
-
<Box marginTop={1}>
|
|
1449
|
-
<Text color={C.accent}>{"$ "}</Text>
|
|
1450
|
-
<TextInput
|
|
1451
|
-
value={capDraft}
|
|
1452
|
-
onChange={setCapDraft}
|
|
1453
|
-
onSubmit={() => {
|
|
1454
|
-
const v = capDraft.trim();
|
|
1455
|
-
const parsed = v === "" ? null : parseFloat(v);
|
|
1456
|
-
const cap = parsed != null && parsed > 0 ? parsed : null;
|
|
1457
|
-
if (capReturn === "projectActions" && selected) {
|
|
1458
|
-
setProjectBudget(selected.dir, cap ?? undefined);
|
|
1459
|
-
setProjectCap(cap);
|
|
1460
|
-
reselect(selected.dir);
|
|
1461
|
-
setPhase("projectActions");
|
|
1462
|
-
} else {
|
|
1463
|
-
setProjectCap(cap);
|
|
1464
|
-
setPhase("plan");
|
|
1465
|
-
}
|
|
1466
|
-
}}
|
|
1467
|
-
/>
|
|
1468
|
-
</Box>
|
|
1469
|
-
<Box marginTop={1}><KeyHint hints={[{ keys: "Enter", label: "save" }, { keys: "Esc", label: "cancel" }]} /></Box>
|
|
1470
|
-
</Panel>
|
|
1471
|
-
</Box>
|
|
1472
|
-
);
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
if (phase === "plan" && plan) {
|
|
1476
|
-
const prefs = getPrefs();
|
|
1477
|
-
const effCap = projectCap ?? prefs.budgetCapUSD;
|
|
1478
|
-
const overCap = plan.estCost > effCap;
|
|
1479
|
-
return (
|
|
1480
|
-
<Box flexDirection="column">
|
|
1481
|
-
<Text bold color={C.good}>✓ Plan ready</Text>
|
|
1482
|
-
<Box marginTop={1}><Team /></Box>
|
|
1483
|
-
<Box marginTop={1} gap={1} flexWrap="wrap">
|
|
1484
|
-
<Chip label={`${plan.tasks.length} tasks`} dotColor={C.accent} />
|
|
1485
|
-
<Chip label={`est $${plan.estCost.toFixed(2)}`} dotColor={overCap ? C.bad : C.good} active={overCap} />
|
|
1486
|
-
<Chip label={`cap $${effCap}${projectCap != null ? " · project" : ""}`} />
|
|
1487
|
-
</Box>
|
|
1488
|
-
<Text color={C.textSubtle}>
|
|
1489
|
-
{" "}Runs {prefs.concurrency} at once · {targetWorkspace ? "edits this project's files" : "output to a fresh folder"} · {mode === "approval" ? "approval-gated" : "auto-run"}
|
|
1490
|
-
</Text>
|
|
1491
|
-
{overCap && <Text color={C.bad}>{"\n"}Estimate exceeds the ${effCap} cap — it may halt partway.</Text>}
|
|
1492
|
-
<Box marginTop={1}>
|
|
1493
|
-
<Panel title="Ready?">
|
|
1494
|
-
<SelectInput
|
|
1495
|
-
items={[
|
|
1496
|
-
// When adding to an existing project, let the user just queue the
|
|
1497
|
-
// new tasks without building them right now.
|
|
1498
|
-
...(scope === "change" && selected
|
|
1499
|
-
? [{ label: `Add ${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"} to the backlog (don't build yet)`, value: "backlog" }]
|
|
1500
|
-
: []),
|
|
1501
|
-
...(mode === "approval"
|
|
1502
|
-
? [
|
|
1503
|
-
{ label: `Open the board & approve (${plan.tasks.length}) →`, value: "board" },
|
|
1504
|
-
{ label: `Budget cap: $${effCap}`, value: "cap" },
|
|
1505
|
-
{ label: "Change idea", value: "idea" },
|
|
1506
|
-
{ label: "Switch to auto-run", value: "auto" },
|
|
1507
|
-
{ label: "Quit", value: "quit" },
|
|
1508
|
-
]
|
|
1509
|
-
: [
|
|
1510
|
-
{ label: `Plan the sprint on the board (${plan.tasks.length} in backlog) →`, value: "board" },
|
|
1511
|
-
{ label: `Build everything now ($${plan.estCost.toFixed(2)})`, value: "build" },
|
|
1512
|
-
{ label: `Budget cap: $${effCap}`, value: "cap" },
|
|
1513
|
-
{ label: "Change idea", value: "idea" },
|
|
1514
|
-
{ label: "Switch to approval-gated", value: "approval" },
|
|
1515
|
-
{ label: "Quit", value: "quit" },
|
|
1516
|
-
]),
|
|
1517
|
-
]}
|
|
1518
|
-
onSelect={(i) => {
|
|
1519
|
-
if (i.value === "backlog" && selected) {
|
|
1520
|
-
// Merge the new tasks into the project's backlog without building.
|
|
1521
|
-
// Give them clean sequential ids (T-03, T-04, …) so they read well.
|
|
1522
|
-
const existing = selected.state.tasks;
|
|
1523
|
-
const used = new Set(existing.map((t) => t.id));
|
|
1524
|
-
let n = 0;
|
|
1525
|
-
const nextId = () => {
|
|
1526
|
-
let id: string;
|
|
1527
|
-
do { id = `T-${String(++n).padStart(2, "0")}`; } while (used.has(id));
|
|
1528
|
-
used.add(id);
|
|
1529
|
-
return id;
|
|
1530
|
-
};
|
|
1531
|
-
const idMap = new Map<string, string>();
|
|
1532
|
-
const reided = plan.tasks.map((t) => {
|
|
1533
|
-
const nid = nextId();
|
|
1534
|
-
idMap.set(t.id, nid);
|
|
1535
|
-
return { ...t, id: nid };
|
|
1536
|
-
});
|
|
1537
|
-
const merged = [
|
|
1538
|
-
...existing,
|
|
1539
|
-
...reided.map((t) => ({ ...t, dependsOn: (t.dependsOn ?? []).map((d) => idMap.get(d) ?? d) })),
|
|
1540
|
-
];
|
|
1541
|
-
saveProjectTasks(selected.dir, merged);
|
|
1542
|
-
reselect(selected.dir);
|
|
1543
|
-
setFlash(`Added ${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"} to the backlog.`);
|
|
1544
|
-
setPhase("kanban");
|
|
1545
|
-
}
|
|
1546
|
-
else if (i.value === "build") setPhase("building");
|
|
1547
|
-
else if (i.value === "board") setPhase("board");
|
|
1548
|
-
else if (i.value === "cap") { setCapReturn("plan"); setCapDraft(String(effCap)); setPhase("setCap"); }
|
|
1549
|
-
else if (i.value === "auto") setMode("auto");
|
|
1550
|
-
else if (i.value === "approval") setMode("approval");
|
|
1551
|
-
else if (i.value === "idea") {
|
|
1552
|
-
setIdea("");
|
|
1553
|
-
// A change build must go back to the change screen (keeps its
|
|
1554
|
-
// targetWorkspace + scope), not the fresh-build idea/stack path.
|
|
1555
|
-
setPhase(scope === "change" ? "change" : "idea");
|
|
1556
|
-
} else exit();
|
|
1557
|
-
}}
|
|
1558
|
-
/>
|
|
1559
|
-
</Panel>
|
|
1560
|
-
</Box>
|
|
1561
|
-
</Box>
|
|
1562
|
-
);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
if (phase === "board" && plan) {
|
|
1566
|
-
const commit = (newTasks: Task[]) => {
|
|
1567
|
-
const safe = newTasks.length ? newTasks : plan.tasks; // never build an empty board
|
|
1568
|
-
const { total } = estimateTasks(safe, plan.registry);
|
|
1569
|
-
setPlan({ ...plan, tasks: safe, estCost: total });
|
|
1570
|
-
setTasks(safe.map((t) => ({ id: t.id, capability: t.capability, status: "pending", title: t.title })));
|
|
1571
|
-
// In approval mode, approving on the board IS the go-ahead → build now.
|
|
1572
|
-
setPhase(mode === "approval" ? "building" : "plan");
|
|
1573
|
-
};
|
|
1574
|
-
return (
|
|
1575
|
-
<Box flexDirection="column">
|
|
1576
|
-
<BoardEditor
|
|
1577
|
-
tasks={plan.tasks}
|
|
1578
|
-
onDone={commit}
|
|
1579
|
-
onCancel={() => setPhase("plan")}
|
|
1580
|
-
onBreakdown={(epic, current) => breakdownEpic(epic, current, providers, targetWorkspace)}
|
|
1581
|
-
/>
|
|
1582
|
-
</Box>
|
|
1583
|
-
);
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
if (phase === "building") {
|
|
1587
|
-
const running = tasks.filter((t) => t.status === "running").length;
|
|
1588
|
-
const metaById = new Map((plan?.tasks ?? []).map((t) => [t.id, { deps: t.dependsOn ?? [], epic: t.epic, notes: t.notes }]));
|
|
1589
|
-
const board: BoardTask[] = tasks.map((t) => ({
|
|
1590
|
-
id: t.id,
|
|
1591
|
-
capability: t.capability,
|
|
1592
|
-
title: t.title,
|
|
1593
|
-
epic: metaById.get(t.id)?.epic,
|
|
1594
|
-
dependsOn: metaById.get(t.id)?.deps,
|
|
1595
|
-
notes: metaById.get(t.id)?.notes,
|
|
1596
|
-
status: t.status,
|
|
1597
|
-
cost: t.cost,
|
|
1598
|
-
verdict: t.verdict,
|
|
1599
|
-
assignee: t.model ? modelLabel(t.model) : undefined,
|
|
1600
|
-
}));
|
|
1601
|
-
return (
|
|
1602
|
-
<Box flexDirection="column">
|
|
1603
|
-
<Box>
|
|
1604
|
-
<Text color="cyan"><InkSpinner type="dots" /></Text>
|
|
1605
|
-
<Text bold>{" "}Building</Text>
|
|
1606
|
-
<Text color={C.textSubtle}>{` ${running} running`}</Text>
|
|
1607
|
-
</Box>
|
|
1608
|
-
{gate ? (
|
|
1609
|
-
<Box marginTop={1}>
|
|
1610
|
-
<Panel title="Review gate — design done" borderColor={C.accent}>
|
|
1611
|
-
<Text>Design is complete. Approve to start development, or stop here.</Text>
|
|
1612
|
-
<Text color={C.dim}>Spent so far: ${spent.toFixed(2)}. Open the files to review before deciding.</Text>
|
|
1613
|
-
<Box marginTop={1}>
|
|
1614
|
-
<SelectInput
|
|
1615
|
-
items={[
|
|
1616
|
-
{ label: "Approve — start development", value: "continue" },
|
|
1617
|
-
{ label: "Stop here", value: "stop" },
|
|
1618
|
-
]}
|
|
1619
|
-
onSelect={(i) => {
|
|
1620
|
-
gate.resolve(i.value as "continue" | "stop");
|
|
1621
|
-
setGate(null);
|
|
1622
|
-
}}
|
|
1623
|
-
/>
|
|
1624
|
-
</Box>
|
|
1625
|
-
</Panel>
|
|
1626
|
-
</Box>
|
|
1627
|
-
) : null}
|
|
1628
|
-
<Box marginTop={1}>
|
|
1629
|
-
<Panel title="Board">
|
|
1630
|
-
<Kanban tasks={board} compact maxPerCol={Math.max(2, Math.floor((termRows - (gate ? 20 : 12)) / 2))} />
|
|
1631
|
-
</Panel>
|
|
1632
|
-
</Box>
|
|
1633
|
-
<Box marginTop={1}>
|
|
1634
|
-
<Standup tasks={board} spent={spent} />
|
|
1635
|
-
</Box>
|
|
1636
|
-
{(() => {
|
|
1637
|
-
const prefs = getPrefs();
|
|
1638
|
-
const cap = projectCap ?? prefs.budgetCapUSD;
|
|
1639
|
-
const pct = prefs.budgetAlertPct;
|
|
1640
|
-
const alerting = spent >= cap * (pct / 100) && spent < cap;
|
|
1641
|
-
return (
|
|
1642
|
-
<Box marginTop={1} flexDirection="column">
|
|
1643
|
-
<BudgetBar spent={spent} cap={cap} />
|
|
1644
|
-
{alerting ? (
|
|
1645
|
-
<Text color={C.warn}>⚠ {Math.round((spent / cap) * 100)}% of the ${cap} cap spent — nearing the limit.</Text>
|
|
1646
|
-
) : null}
|
|
1647
|
-
{board.some((t) => t.verdict === "PASS*") ? (
|
|
1648
|
-
<Text color={C.warn}>⚠ Tester could not run the app (no headless Chromium) — PASS* verdicts are code-reading only. {CHROMIUM_INSTALL_HINT}.</Text>
|
|
1649
|
-
) : null}
|
|
1650
|
-
</Box>
|
|
1651
|
-
);
|
|
1652
|
-
})()}
|
|
1653
|
-
</Box>
|
|
1654
|
-
);
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
if (phase === "done" && buildResult) {
|
|
1658
|
-
return (
|
|
1659
|
-
<Box flexDirection="column">
|
|
1660
|
-
<Text bold color={buildResult.halted ? C.warn : C.good}>
|
|
1661
|
-
{buildResult.halted ? `⚠ Build halted (${buildResult.haltReason ?? "budget cap"})` : "✓ Build complete"}
|
|
1662
|
-
</Text>
|
|
1663
|
-
{tasks.some((t) => t.verdict === "PASS*") ? (
|
|
1664
|
-
<Text color={C.warn}>⚠ Tests marked PASS* were never executed in a browser — {CHROMIUM_INSTALL_HINT}.</Text>
|
|
1665
|
-
) : null}
|
|
1666
|
-
<Box marginTop={1}>
|
|
1667
|
-
<Standup
|
|
1668
|
-
tasks={tasks.map((t) => ({ id: t.id, capability: t.capability, title: t.title, status: t.status, cost: t.cost, verdict: t.verdict }))}
|
|
1669
|
-
spent={spent}
|
|
1670
|
-
/>
|
|
1671
|
-
</Box>
|
|
1672
|
-
<Box marginTop={1}>
|
|
1673
|
-
<Panel title="Result">
|
|
1674
|
-
<Box gap={1} flexWrap="wrap" marginBottom={1}>
|
|
1675
|
-
<Chip label={`$${spent.toFixed(2)} total`} dotColor={C.accent} />
|
|
1676
|
-
<Chip label={`${buildResult.files.length} file${buildResult.files.length === 1 ? "" : "s"}`} dotColor={C.good} />
|
|
1677
|
-
</Box>
|
|
1678
|
-
<Text color={C.textSubtle} wrap="truncate-end">Location: {buildResult.workspace}</Text>
|
|
1679
|
-
{buildResult.halted && <Text color={C.textMuted}>Choose “Resume build” from your projects to continue.</Text>}
|
|
1680
|
-
</Panel>
|
|
1681
|
-
</Box>
|
|
1682
|
-
<Box marginTop={1}>
|
|
1683
|
-
<SelectInput
|
|
1684
|
-
items={[
|
|
1685
|
-
...(buildResult.files.some((f) => f.endsWith(".html")) ? [{ label: "Open in browser", value: "open" }] : []),
|
|
1686
|
-
{ label: "Add to backlog (describe it, the PM plans it)", value: "change" },
|
|
1687
|
-
{ label: "Add a file / image", value: "asset" },
|
|
1688
|
-
{ label: "New build", value: "new" },
|
|
1689
|
-
{ label: "Home", value: "home" },
|
|
1690
|
-
{ label: "Quit", value: "quit" },
|
|
1691
|
-
]}
|
|
1692
|
-
onSelect={(i) => {
|
|
1693
|
-
if (i.value === "open") openInBrowser(mainFileOf(buildResult.workspace));
|
|
1694
|
-
else if (i.value === "asset") {
|
|
1695
|
-
setTargetWorkspace(buildResult.workspace);
|
|
1696
|
-
setAssetPath("");
|
|
1697
|
-
setAssetMsg(null);
|
|
1698
|
-
setAssetReturn("done");
|
|
1699
|
-
setPhase("addAsset");
|
|
1700
|
-
} else if (i.value === "change") {
|
|
1701
|
-
setTargetWorkspace(buildResult.workspace);
|
|
1702
|
-
setSeed(undefined);
|
|
1703
|
-
setIdea("");
|
|
1704
|
-
setScope("change");
|
|
1705
|
-
setPhase("change");
|
|
1706
|
-
} else if (i.value === "new") {
|
|
1707
|
-
resetBuildContext();
|
|
1708
|
-
setScope("full");
|
|
1709
|
-
setPhase("idea");
|
|
1710
|
-
} else if (i.value === "home") {
|
|
1711
|
-
resetBuildContext();
|
|
1712
|
-
setPhase("home");
|
|
1713
|
-
} else exit();
|
|
1714
|
-
}}
|
|
1715
|
-
/>
|
|
1716
|
-
</Box>
|
|
1717
|
-
</Box>
|
|
1718
|
-
);
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
if (phase === "error") {
|
|
1722
|
-
return (
|
|
1723
|
-
<Box flexDirection="column">
|
|
1724
|
-
<Text color={C.bad}>Something went wrong:</Text>
|
|
1725
|
-
<Text color={C.dim}>{" "}{error}</Text>
|
|
1726
|
-
<Box marginTop={1}>
|
|
1727
|
-
<SelectInput
|
|
1728
|
-
items={[
|
|
1729
|
-
{ label: "Try again", value: "retry" },
|
|
1730
|
-
{ label: "Home", value: "home" },
|
|
1731
|
-
{ label: "Quit", value: "quit" },
|
|
1732
|
-
]}
|
|
1733
|
-
onSelect={(i) => {
|
|
1734
|
-
if (i.value === "retry") setPhase(scope === "change" ? "change" : "idea");
|
|
1735
|
-
else if (i.value === "home") {
|
|
1736
|
-
resetBuildContext();
|
|
1737
|
-
setScope("full");
|
|
1738
|
-
setPhase("home");
|
|
1739
|
-
} else exit();
|
|
1740
|
-
}}
|
|
1741
|
-
/>
|
|
1742
|
-
</Box>
|
|
1743
|
-
</Box>
|
|
1744
|
-
);
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
return (
|
|
1748
|
-
<Box>
|
|
1749
|
-
<Spinner label="Loading…" />
|
|
1750
|
-
</Box>
|
|
1751
|
-
);
|
|
1752
|
-
|
|
1753
|
-
})();
|
|
1754
|
-
|
|
1755
|
-
return (
|
|
1756
|
-
<AppFrame projectName={selected?.idea} phase={phase}>
|
|
1757
|
-
{screen}
|
|
1758
|
-
</AppFrame>
|
|
1759
|
-
);
|
|
1760
|
-
}
|