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