stack-agent 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/dist/index.js +1506 -263
  3. package/package.json +11 -3
package/dist/index.js CHANGED
@@ -1,46 +1,484 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as p2 from "@clack/prompts";
4
+ import React6 from "react";
5
+ import { withFullScreen } from "fullscreen-ink";
5
6
 
6
- // src/cli/chat.ts
7
- import * as p from "@clack/prompts";
8
- import { Marked } from "marked";
9
- import { markedTerminal } from "marked-terminal";
10
- var marked = new Marked(markedTerminal());
11
- function renderMarkdown(text2) {
12
- return marked.parse(text2).trimEnd();
7
+ // src/cli/app.tsx
8
+ import { useState as useState5, useEffect as useEffect2, useCallback } from "react";
9
+ import { Box as Box7, Text as Text7, useApp, useInput as useInput3 } from "ink";
10
+ import { useScreenSize } from "fullscreen-ink";
11
+
12
+ // src/cli/components/header.tsx
13
+ import { Box, Text } from "ink";
14
+ import { jsx, jsxs } from "react/jsx-runtime";
15
+ function Header({ appName, currentStage, stages, showDots = false }) {
16
+ const stageName = currentStage?.label ?? "Stack Progress";
17
+ return /* @__PURE__ */ jsxs(Box, { borderStyle: "single", borderBottom: false, paddingX: 1, justifyContent: "space-between", children: [
18
+ /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
19
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: appName }),
20
+ /* @__PURE__ */ jsx(Text, { bold: true, children: stageName })
21
+ ] }),
22
+ showDots && /* @__PURE__ */ jsx(Box, { gap: 0, children: stages.map((s, i) => /* @__PURE__ */ jsx(StageDot, { stage: s, isCurrent: s.id === currentStage?.id }, s.id)) })
23
+ ] });
13
24
  }
14
- function intro2() {
15
- p.intro("stack-agent");
25
+ function StageDot({ stage, isCurrent }) {
26
+ if (isCurrent) {
27
+ return /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u25CF" });
28
+ }
29
+ if (stage.status === "complete" && stage.confirmed) {
30
+ return /* @__PURE__ */ jsx(Text, { color: "green", children: "\u25CF" });
31
+ }
32
+ if (stage.status === "complete" && !stage.confirmed) {
33
+ return /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25CF" });
34
+ }
35
+ if (stage.status === "skipped") {
36
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2013" });
37
+ }
38
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u25CB" });
16
39
  }
17
- function outro2(message) {
18
- p.outro(message);
40
+
41
+ // src/cli/components/footer.tsx
42
+ import { Box as Box2, Text as Text2 } from "ink";
43
+ import { jsx as jsx2 } from "react/jsx-runtime";
44
+ function Footer({ progress, stages, terminalWidth, mode = "decisions" }) {
45
+ let display;
46
+ switch (mode) {
47
+ case "stage_list":
48
+ display = "\u2191\u2193 navigate \xB7 Enter select \xB7 Esc back";
49
+ break;
50
+ case "options":
51
+ display = "\u2191\u2193 navigate \xB7 Enter select \xB7 Esc stages";
52
+ break;
53
+ case "input":
54
+ display = "Enter submit \xB7 Esc stages";
55
+ break;
56
+ default:
57
+ display = buildDecisionsDisplay(progress, stages, terminalWidth);
58
+ break;
59
+ }
60
+ return /* @__PURE__ */ jsx2(Box2, { borderStyle: "single", borderTop: false, paddingX: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: display }) });
19
61
  }
20
- function renderError(text2) {
21
- p.log.error(text2);
62
+ function buildDecisionsDisplay(progress, stages, terminalWidth) {
63
+ const decisions = [];
64
+ if (progress.projectName) decisions.push(`Project: ${progress.projectName}`);
65
+ if (progress.frontend) decisions.push(`Frontend: ${progress.frontend.component}`);
66
+ if (progress.backend) decisions.push(`Backend: ${progress.backend.component}`);
67
+ if (progress.database) decisions.push(`DB: ${progress.database.component}`);
68
+ if (progress.auth) decisions.push(`Auth: ${progress.auth.component}`);
69
+ if (progress.payments) decisions.push(`Pay: ${progress.payments.component}`);
70
+ if (progress.ai) decisions.push(`AI: ${progress.ai.component}`);
71
+ if (progress.deployment) decisions.push(`Deploy: ${progress.deployment.component}`);
72
+ const nextStage = stages.find((s) => s.status === "pending");
73
+ const nextText = nextStage ? `Next: ${nextStage.label}` : "";
74
+ const separator = " \u2502 ";
75
+ let display = decisions.map((d) => `\u2713 ${d}`).join(separator);
76
+ if (nextText) {
77
+ display = display ? `${display}${separator}${nextText}` : nextText;
78
+ }
79
+ const maxWidth = terminalWidth - 4;
80
+ if (display.length > maxWidth) {
81
+ display = display.slice(0, maxWidth - 1) + "\u2026";
82
+ }
83
+ return display;
22
84
  }
23
- function renderPlan(plan) {
24
- p.log.info(renderMarkdown(plan));
85
+
86
+ // src/cli/components/conversation.tsx
87
+ import { useState, useEffect } from "react";
88
+ import { Box as Box3, Text as Text3 } from "ink";
89
+ import { Spinner } from "@inkjs/ui";
90
+ import { jsx as jsx3 } from "react/jsx-runtime";
91
+ function ConversationView({ bridge, maxLines }) {
92
+ const [text, setText] = useState("");
93
+ const [isStreaming, setIsStreaming] = useState(false);
94
+ const [showSpinner, setShowSpinner] = useState(false);
95
+ useEffect(() => {
96
+ const unsubs = [
97
+ bridge.subscribe("spinnerStart", () => {
98
+ setShowSpinner(true);
99
+ setIsStreaming(false);
100
+ setText("");
101
+ }),
102
+ bridge.subscribe("streamText", (delta) => {
103
+ setShowSpinner(false);
104
+ setIsStreaming(true);
105
+ setText((prev) => prev + delta);
106
+ }),
107
+ bridge.subscribe("streamEnd", () => {
108
+ setIsStreaming(false);
109
+ })
110
+ ];
111
+ return () => unsubs.forEach((fn) => fn());
112
+ }, [bridge]);
113
+ if (showSpinner) {
114
+ return /* @__PURE__ */ jsx3(Box3, { paddingX: 1, children: /* @__PURE__ */ jsx3(Spinner, { label: "Thinking..." }) });
115
+ }
116
+ const lines = text.split("\n");
117
+ const visible = lines.slice(-maxLines);
118
+ return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", paddingX: 1, children: visible.map((line, i) => /* @__PURE__ */ jsx3(Text3, { children: line }, i)) });
25
119
  }
26
- async function getUserInput(message, placeholder) {
27
- const result = await p.text({
28
- message: message ?? "\u203A",
29
- placeholder: placeholder ?? "Type your message..."
120
+
121
+ // src/cli/components/option-select.tsx
122
+ import { useState as useState2 } from "react";
123
+ import { Box as Box4, Text as Text4, useInput } from "ink";
124
+ import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
125
+ function OptionSelect({ options, onSelect }) {
126
+ const [cursor, setCursor] = useState2(0);
127
+ const [textValue, setTextValue] = useState2("");
128
+ const totalItems = options.length + 1;
129
+ const isOnTextField = cursor === options.length;
130
+ useInput((input, key) => {
131
+ if (key.upArrow) {
132
+ setCursor((c) => Math.max(0, c - 1));
133
+ return;
134
+ }
135
+ if (key.downArrow) {
136
+ setCursor((c) => Math.min(totalItems - 1, c + 1));
137
+ return;
138
+ }
139
+ if (key.return) {
140
+ if (isOnTextField) {
141
+ if (textValue.trim()) {
142
+ onSelect({ kind: "text", value: textValue.trim() });
143
+ }
144
+ } else {
145
+ onSelect({ kind: "select", value: options[cursor].label });
146
+ }
147
+ return;
148
+ }
149
+ if (isOnTextField) {
150
+ if (key.backspace || key.delete) {
151
+ setTextValue((v) => v.slice(0, -1));
152
+ } else if (input && !key.ctrl && !key.meta && input.length === 1) {
153
+ setTextValue((v) => v + input);
154
+ }
155
+ }
30
156
  });
31
- if (p.isCancel(result)) {
157
+ let hasRecommended = false;
158
+ return /* @__PURE__ */ jsxs2(Box4, { flexDirection: "column", paddingX: 1, children: [
159
+ options.map((opt, i) => {
160
+ const isHighlighted = cursor === i;
161
+ const pointer = isHighlighted ? "\u276F " : " ";
162
+ let label = opt.label;
163
+ if (opt.recommended && !hasRecommended) {
164
+ label += " (Recommended)";
165
+ hasRecommended = true;
166
+ }
167
+ return /* @__PURE__ */ jsxs2(Box4, { children: [
168
+ /* @__PURE__ */ jsxs2(Text4, { color: isHighlighted ? "cyan" : void 0, children: [
169
+ pointer,
170
+ label
171
+ ] }),
172
+ opt.description && /* @__PURE__ */ jsxs2(Text4, { dimColor: true, children: [
173
+ " \u2014 ",
174
+ opt.description
175
+ ] })
176
+ ] }, opt.label);
177
+ }),
178
+ /* @__PURE__ */ jsxs2(Box4, { marginTop: 1, children: [
179
+ /* @__PURE__ */ jsx4(Text4, { color: isOnTextField ? "cyan" : void 0, children: isOnTextField ? "\u276F " : " " }),
180
+ isOnTextField ? /* @__PURE__ */ jsxs2(Text4, { children: [
181
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: textValue }),
182
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "\u258A" }),
183
+ !textValue && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " Type a question or suggestion..." })
184
+ ] }) : /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Type a question or suggestion..." })
185
+ ] })
186
+ ] });
187
+ }
188
+
189
+ // src/cli/components/stage-list.tsx
190
+ import { useState as useState3 } from "react";
191
+ import { Box as Box5, Text as Text5, useInput as useInput2 } from "ink";
192
+ import { ConfirmInput } from "@inkjs/ui";
193
+
194
+ // src/agent/progress.ts
195
+ function createProgress() {
196
+ return {
197
+ projectName: null,
198
+ description: null,
199
+ frontend: null,
200
+ backend: null,
201
+ database: null,
202
+ auth: null,
203
+ payments: null,
204
+ ai: null,
205
+ deployment: null,
206
+ extras: []
207
+ };
208
+ }
209
+ function setDecision(progress, category, choice) {
210
+ if (category === "extras") {
211
+ return { ...progress, extras: [...progress.extras, choice] };
212
+ }
213
+ return { ...progress, [category]: choice };
214
+ }
215
+ function clearDecision(progress, category) {
216
+ if (category === "extras") {
217
+ return { ...progress, extras: [] };
218
+ }
219
+ return { ...progress, [category]: null };
220
+ }
221
+ function clearProjectInfo(progress) {
222
+ return { ...progress, projectName: null, description: null };
223
+ }
224
+ function isComplete(progress) {
225
+ return progress.projectName !== null && progress.description !== null && progress.frontend !== null && progress.database !== null && progress.deployment !== null;
226
+ }
227
+ function formatChoice(choice) {
228
+ if (choice === null) return "not yet decided";
229
+ return choice.component;
230
+ }
231
+ function serializeProgress(progress) {
232
+ const lines = [
233
+ `Project Name: ${progress.projectName ?? "not yet decided"}`,
234
+ `Description: ${progress.description ?? "not yet decided"}`,
235
+ `Frontend: ${formatChoice(progress.frontend)}`,
236
+ `Backend: ${formatChoice(progress.backend)}`,
237
+ `Database: ${formatChoice(progress.database)}`,
238
+ `Auth: ${formatChoice(progress.auth)}`,
239
+ `Payments: ${formatChoice(progress.payments)}`,
240
+ `AI/LLM: ${formatChoice(progress.ai)}`,
241
+ `Deployment: ${formatChoice(progress.deployment)}`,
242
+ `Extras: ${progress.extras.length > 0 ? progress.extras.map((e) => e.component).join(", ") : "not yet decided"}`
243
+ ];
244
+ return lines.join("\n");
245
+ }
246
+ function serializeSession(session) {
247
+ return JSON.stringify(session, null, 2);
248
+ }
249
+ function deserializeSession(json) {
250
+ try {
251
+ const data = JSON.parse(json);
252
+ if (data.version !== 1) return null;
253
+ if (!data.progress || !Array.isArray(data.stages) || !Array.isArray(data.messages)) return null;
254
+ return data;
255
+ } catch {
32
256
  return null;
33
257
  }
34
- return result;
35
258
  }
36
- function createSpinner() {
37
- return p.spinner();
259
+
260
+ // src/cli/components/stage-list.tsx
261
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
262
+ function StageListView({ stages, currentStageId, progress, onResult }) {
263
+ const [showConfirm, setShowConfirm] = useState3(false);
264
+ const [showWarning, setShowWarning] = useState3("");
265
+ const [cursor, setCursor] = useState3(0);
266
+ const canBuild = isComplete(progress);
267
+ const itemCount = stages.length + 1;
268
+ useInput2((input, key) => {
269
+ if (showConfirm) return;
270
+ if (key.upArrow) {
271
+ setCursor((c) => Math.max(0, c - 1));
272
+ setShowWarning("");
273
+ }
274
+ if (key.downArrow) {
275
+ setCursor((c) => Math.min(itemCount - 1, c + 1));
276
+ setShowWarning("");
277
+ }
278
+ if (key.return) {
279
+ if (cursor < stages.length) {
280
+ onResult({ kind: "select", stageId: stages[cursor].id });
281
+ } else {
282
+ if (canBuild) {
283
+ setShowConfirm(true);
284
+ } else {
285
+ const missing = getMissingDecisions(progress);
286
+ setShowWarning(`Complete ${missing.join(", ")} first.`);
287
+ }
288
+ }
289
+ }
290
+ if (key.escape) {
291
+ onResult({ kind: "cancel" });
292
+ }
293
+ });
294
+ if (showConfirm) {
295
+ return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", paddingX: 1, children: [
296
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Ready to build this stack?" }),
297
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(
298
+ ConfirmInput,
299
+ {
300
+ defaultChoice: "confirm",
301
+ onConfirm: () => onResult({ kind: "build" }),
302
+ onCancel: () => setShowConfirm(false)
303
+ }
304
+ ) })
305
+ ] });
306
+ }
307
+ return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", paddingX: 1, children: [
308
+ showWarning && /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: showWarning }) }),
309
+ stages.map((stage, i) => {
310
+ const isHighlighted = cursor === i;
311
+ const pointer = isHighlighted ? "\u276F " : " ";
312
+ return /* @__PURE__ */ jsxs3(Box5, { children: [
313
+ /* @__PURE__ */ jsx5(Text5, { children: pointer }),
314
+ /* @__PURE__ */ jsx5(StageLabel, { stage, isCurrent: stage.id === currentStageId })
315
+ ] }, stage.id);
316
+ }),
317
+ /* @__PURE__ */ jsxs3(Box5, { marginTop: 1, children: [
318
+ /* @__PURE__ */ jsx5(Text5, { children: cursor === stages.length ? "\u276F " : " " }),
319
+ /* @__PURE__ */ jsx5(Text5, { color: canBuild ? "green" : "yellow", bold: cursor === stages.length, children: "\u2605 Build" }),
320
+ !canBuild && /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
321
+ " (",
322
+ requiredRemaining(progress),
323
+ " remaining)"
324
+ ] })
325
+ ] })
326
+ ] });
327
+ }
328
+ function StageLabel({ stage, isCurrent }) {
329
+ if (stage.status === "complete" && stage.confirmed) {
330
+ return /* @__PURE__ */ jsxs3(Text5, { children: [
331
+ /* @__PURE__ */ jsxs3(Text5, { color: "green", children: [
332
+ "\u2713 ",
333
+ stage.label
334
+ ] }),
335
+ /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
336
+ " \u2014 ",
337
+ stage.summary
338
+ ] })
339
+ ] });
340
+ }
341
+ if (stage.status === "complete" && !stage.confirmed) {
342
+ return /* @__PURE__ */ jsxs3(Text5, { children: [
343
+ /* @__PURE__ */ jsxs3(Text5, { color: "yellow", children: [
344
+ "\u25C6 ",
345
+ stage.label
346
+ ] }),
347
+ /* @__PURE__ */ jsxs3(Text5, { color: "yellow", dimColor: true, children: [
348
+ " \u2014 ",
349
+ stage.summary,
350
+ " (suggested)"
351
+ ] })
352
+ ] });
353
+ }
354
+ if (stage.status === "skipped") {
355
+ return /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
356
+ "\u2013 ",
357
+ stage.label,
358
+ " \u2014 skipped"
359
+ ] });
360
+ }
361
+ if (isCurrent) {
362
+ return /* @__PURE__ */ jsxs3(Text5, { children: [
363
+ /* @__PURE__ */ jsxs3(Text5, { color: "cyan", bold: true, children: [
364
+ "\u25CF ",
365
+ stage.label
366
+ ] }),
367
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: " \u2190 needs your input" })
368
+ ] });
369
+ }
370
+ return /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
371
+ "\u25CB ",
372
+ stage.label
373
+ ] });
374
+ }
375
+ function requiredRemaining(progress) {
376
+ let count = 0;
377
+ if (!progress.projectName) count++;
378
+ if (!progress.description) count++;
379
+ if (!progress.frontend) count++;
380
+ if (!progress.database) count++;
381
+ if (!progress.deployment) count++;
382
+ return count;
38
383
  }
39
- function writeText(text2) {
40
- process.stdout.write(text2);
384
+ function getMissingDecisions(progress) {
385
+ const missing = [];
386
+ if (!progress.projectName || !progress.description) missing.push("Project Info");
387
+ if (!progress.frontend) missing.push("Frontend");
388
+ if (!progress.database) missing.push("Database");
389
+ if (!progress.deployment) missing.push("Deployment");
390
+ return missing;
41
391
  }
42
- function writeLine() {
43
- process.stdout.write("\n");
392
+
393
+ // src/cli/app.tsx
394
+ import { TextInput as TextInput2, Spinner as Spinner2 } from "@inkjs/ui";
395
+
396
+ // src/cli/components/project-info-form.tsx
397
+ import { useState as useState4 } from "react";
398
+ import { Box as Box6, Text as Text6 } from "ink";
399
+ import { TextInput } from "@inkjs/ui";
400
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
401
+ function ProjectInfoForm({ onSubmit }) {
402
+ const [field, setField] = useState4("name");
403
+ const [name, setName] = useState4("");
404
+ return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
405
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Let's set up your project" }),
406
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
407
+ field === "name" && /* @__PURE__ */ jsxs4(Box6, { children: [
408
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: "Project name: " }),
409
+ /* @__PURE__ */ jsx6(
410
+ TextInput,
411
+ {
412
+ placeholder: "my-app",
413
+ onSubmit: (value) => {
414
+ if (value.trim()) {
415
+ setName(value.trim());
416
+ setField("description");
417
+ }
418
+ }
419
+ }
420
+ )
421
+ ] }),
422
+ field === "description" && /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
423
+ /* @__PURE__ */ jsxs4(Box6, { children: [
424
+ /* @__PURE__ */ jsx6(Text6, { color: "green", children: "\u2713 Project name: " }),
425
+ /* @__PURE__ */ jsx6(Text6, { children: name })
426
+ ] }),
427
+ /* @__PURE__ */ jsxs4(Box6, { children: [
428
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: "What are you building: " }),
429
+ /* @__PURE__ */ jsx6(
430
+ TextInput,
431
+ {
432
+ placeholder: "a task management SaaS",
433
+ onSubmit: (value) => {
434
+ if (value.trim()) {
435
+ onSubmit(name, value.trim());
436
+ }
437
+ }
438
+ }
439
+ )
440
+ ] })
441
+ ] }),
442
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
443
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: field === "name" ? "Enter a name for your project" : "Briefly describe what you're building" })
444
+ ] });
445
+ }
446
+
447
+ // src/cli/bridge.ts
448
+ function createBridge() {
449
+ const listeners = /* @__PURE__ */ new Map();
450
+ let pendingResolve = null;
451
+ function emit(event, ...args2) {
452
+ const set = listeners.get(event);
453
+ if (set) {
454
+ for (const fn of set) fn(...args2);
455
+ }
456
+ }
457
+ return {
458
+ onStreamText: (delta) => emit("streamText", delta),
459
+ onStreamEnd: (fullText) => emit("streamEnd", fullText),
460
+ onPresentOptions: (options) => emit("presentOptions", options),
461
+ onSpinnerStart: () => emit("spinnerStart"),
462
+ onStageComplete: (summary) => emit("stageComplete", summary),
463
+ onError: (error) => emit("error", error),
464
+ waitForInput: () => new Promise((resolve2) => {
465
+ pendingResolve = resolve2;
466
+ }),
467
+ resolveInput: (result) => {
468
+ if (pendingResolve) {
469
+ const resolve2 = pendingResolve;
470
+ pendingResolve = null;
471
+ resolve2(result);
472
+ }
473
+ },
474
+ subscribe: (event, listener) => {
475
+ if (!listeners.has(event)) listeners.set(event, /* @__PURE__ */ new Set());
476
+ listeners.get(event).add(listener);
477
+ return () => {
478
+ listeners.get(event)?.delete(listener);
479
+ };
480
+ }
481
+ };
44
482
  }
45
483
 
46
484
  // src/agent/loop.ts
@@ -48,6 +486,22 @@ import { join as join2 } from "path";
48
486
 
49
487
  // src/llm/client.ts
50
488
  import Anthropic from "@anthropic-ai/sdk";
489
+
490
+ // src/util/logger.ts
491
+ import pino from "pino";
492
+ var logger = pino({
493
+ level: process.env.LOG_LEVEL ?? "info",
494
+ transport: process.env.NODE_ENV !== "production" ? { target: "pino-pretty", options: { destination: 2 } } : void 0
495
+ }, process.env.NODE_ENV === "production" ? pino.destination(2) : void 0);
496
+ function createLogger(component) {
497
+ return logger.child({ component });
498
+ }
499
+
500
+ // src/llm/client.ts
501
+ var log = createLogger("llm");
502
+ function summarizeMessages(messages) {
503
+ return `${messages.length} messages, last: ${messages.at(-1)?.role ?? "none"}`;
504
+ }
51
505
  function getClient() {
52
506
  const apiKey = process.env.ANTHROPIC_API_KEY;
53
507
  if (!apiKey) {
@@ -66,6 +520,8 @@ function client() {
66
520
  }
67
521
  async function chat(options) {
68
522
  const { system, messages, tools, maxTokens, mcpServers } = options;
523
+ log.debug({ maxTokens, toolCount: tools?.length ?? 0, messages: summarizeMessages(messages) }, "chat request");
524
+ let response;
69
525
  if (mcpServers && Object.keys(mcpServers).length > 0) {
70
526
  const mcpServerList = Object.entries(mcpServers).map(([name, config]) => ({
71
527
  type: "url",
@@ -75,13 +531,13 @@ async function chat(options) {
75
531
  authorization_token: config.apiKey
76
532
  }
77
533
  }));
78
- return client().beta.messages.create(
534
+ response = await client().beta.messages.create(
79
535
  {
80
536
  model: "claude-sonnet-4-6",
81
537
  max_tokens: maxTokens,
82
538
  system,
83
539
  messages,
84
- tools,
540
+ ...tools && tools.length > 0 && { tools },
85
541
  mcp_servers: mcpServerList
86
542
  },
87
543
  {
@@ -90,28 +546,43 @@ async function chat(options) {
90
546
  }
91
547
  }
92
548
  );
549
+ } else {
550
+ response = await client().messages.create({
551
+ model: "claude-sonnet-4-6",
552
+ max_tokens: maxTokens,
553
+ system,
554
+ messages,
555
+ ...tools && tools.length > 0 && { tools }
556
+ });
93
557
  }
94
- return client().messages.create({
95
- model: "claude-sonnet-4-6",
96
- max_tokens: maxTokens,
97
- system,
98
- messages,
99
- tools
100
- });
558
+ log.info({
559
+ stopReason: response.stop_reason,
560
+ contentBlocks: response.content.length,
561
+ usage: response.usage
562
+ }, "chat response");
563
+ log.debug({ content: response.content }, "chat response content");
564
+ return response;
101
565
  }
102
566
  async function chatStream(options, callbacks) {
103
567
  const { system, messages, tools, maxTokens } = options;
568
+ log.debug({ maxTokens, toolCount: tools?.length ?? 0, messages: summarizeMessages(messages) }, "chatStream request");
104
569
  const stream = client().messages.stream({
105
570
  model: "claude-sonnet-4-6",
106
571
  max_tokens: maxTokens,
107
572
  system,
108
573
  messages,
109
- tools
574
+ ...tools && tools.length > 0 && { tools }
110
575
  });
111
- stream.on("text", (text2) => {
112
- callbacks.onText(text2);
576
+ stream.on("text", (text) => {
577
+ callbacks.onText(text);
113
578
  });
114
579
  const finalMessage = await stream.finalMessage();
580
+ log.info({
581
+ stopReason: finalMessage.stop_reason,
582
+ contentBlocks: finalMessage.content.length,
583
+ usage: finalMessage.usage
584
+ }, "chatStream response");
585
+ log.debug({ content: finalMessage.content }, "chatStream response content");
115
586
  for (const block of finalMessage.content) {
116
587
  if (block.type === "tool_use") {
117
588
  callbacks.onToolUse(block);
@@ -123,47 +594,6 @@ async function chatStream(options, callbacks) {
123
594
  });
124
595
  }
125
596
 
126
- // src/agent/progress.ts
127
- function createProgress() {
128
- return {
129
- projectName: null,
130
- description: null,
131
- frontend: null,
132
- backend: null,
133
- database: null,
134
- auth: null,
135
- payments: null,
136
- ai: null,
137
- deployment: null,
138
- extras: []
139
- };
140
- }
141
- function setDecision(progress, category, choice) {
142
- if (category === "extras") {
143
- return { ...progress, extras: [...progress.extras, choice] };
144
- }
145
- return { ...progress, [category]: choice };
146
- }
147
- function formatChoice(choice) {
148
- if (choice === null) return "not yet decided";
149
- return choice.component;
150
- }
151
- function serializeProgress(progress) {
152
- const lines = [
153
- `Project Name: ${progress.projectName ?? "not yet decided"}`,
154
- `Description: ${progress.description ?? "not yet decided"}`,
155
- `Frontend: ${formatChoice(progress.frontend)}`,
156
- `Backend: ${formatChoice(progress.backend)}`,
157
- `Database: ${formatChoice(progress.database)}`,
158
- `Auth: ${formatChoice(progress.auth)}`,
159
- `Payments: ${formatChoice(progress.payments)}`,
160
- `AI/LLM: ${formatChoice(progress.ai)}`,
161
- `Deployment: ${formatChoice(progress.deployment)}`,
162
- `Extras: ${progress.extras.length > 0 ? progress.extras.map((e) => e.component).join(", ") : "not yet decided"}`
163
- ];
164
- return lines.join("\n");
165
- }
166
-
167
597
  // src/agent/tools.ts
168
598
  function conversationToolDefinitions() {
169
599
  return [
@@ -185,15 +615,6 @@ function conversationToolDefinitions() {
185
615
  reasoning: {
186
616
  type: "string",
187
617
  description: "Explanation for why this component was chosen."
188
- },
189
- scaffoldTool: {
190
- type: "string",
191
- description: "Optional CLI scaffold tool to use (e.g. create-next-app)."
192
- },
193
- scaffoldArgs: {
194
- type: "array",
195
- items: { type: "string" },
196
- description: "Optional arguments to pass to the scaffold tool."
197
618
  }
198
619
  },
199
620
  required: ["category", "component", "reasoning"]
@@ -236,12 +657,27 @@ function conversationToolDefinitions() {
236
657
  }
237
658
  },
238
659
  {
239
- name: "present_plan",
240
- description: "Signals that all decisions have been made and the plan is ready to present.",
660
+ name: "present_options",
661
+ description: "Presents technology options for the user to choose from. The UI renders these as selectable items.",
241
662
  input_schema: {
242
663
  type: "object",
243
- properties: {},
244
- required: []
664
+ properties: {
665
+ options: {
666
+ type: "array",
667
+ items: {
668
+ type: "object",
669
+ properties: {
670
+ label: { type: "string", description: "Short name of the option (max 30 chars)." },
671
+ description: { type: "string", description: "One-line description (max 80 chars)." },
672
+ recommended: { type: "boolean", description: "Whether this is recommended. At most one true." }
673
+ },
674
+ required: ["label", "description"]
675
+ },
676
+ minItems: 2,
677
+ maxItems: 3
678
+ }
679
+ },
680
+ required: ["options"]
245
681
  }
246
682
  }
247
683
  ];
@@ -288,6 +724,11 @@ function scaffoldToolDefinitions() {
288
724
  additionalProperties: { type: "string" },
289
725
  description: "Map of package names to versions to install as dev dependencies."
290
726
  },
727
+ scripts: {
728
+ type: "object",
729
+ additionalProperties: { type: "string" },
730
+ description: "Map of script names to commands to merge into package.json scripts."
731
+ },
291
732
  envVars: {
292
733
  type: "array",
293
734
  items: { type: "string" },
@@ -304,9 +745,7 @@ function executeConversationTool(name, input, progress, _messages) {
304
745
  const category = input.category;
305
746
  const choice = {
306
747
  component: input.component,
307
- reasoning: input.reasoning,
308
- ...input.scaffoldTool !== void 0 && { scaffoldTool: input.scaffoldTool },
309
- ...input.scaffoldArgs !== void 0 && { scaffoldArgs: input.scaffoldArgs }
748
+ reasoning: input.reasoning
310
749
  };
311
750
  const updatedProgress = setDecision(progress, category, choice);
312
751
  return {
@@ -331,12 +770,8 @@ function executeConversationTool(name, input, progress, _messages) {
331
770
  response: input.summary
332
771
  };
333
772
  }
334
- if (name === "present_plan") {
335
- return {
336
- progress,
337
- response: "Plan is ready to present.",
338
- signal: "present_plan"
339
- };
773
+ if (name === "present_options") {
774
+ return { progress, response: "Options presented to user." };
340
775
  }
341
776
  return {
342
777
  progress,
@@ -344,25 +779,65 @@ function executeConversationTool(name, input, progress, _messages) {
344
779
  };
345
780
  }
346
781
 
782
+ // src/agent/stages.ts
783
+ var DEFAULT_STAGES = [
784
+ { id: "project_info", label: "Project Info", status: "pending", progressKeys: ["projectName", "description"] },
785
+ { id: "frontend", label: "Frontend", status: "pending", progressKeys: ["frontend"] },
786
+ { id: "backend", label: "Backend", status: "pending", progressKeys: ["backend"] },
787
+ { id: "database", label: "Database", status: "pending", progressKeys: ["database"] },
788
+ { id: "auth", label: "Auth", status: "pending", progressKeys: ["auth"] },
789
+ { id: "payments", label: "Payments", status: "pending", progressKeys: ["payments"] },
790
+ { id: "ai", label: "AI/LLM", status: "pending", progressKeys: ["ai"] },
791
+ { id: "deployment", label: "Deployment", status: "pending", progressKeys: ["deployment"] },
792
+ { id: "extras", label: "Extras", status: "pending", progressKeys: ["extras"] }
793
+ ];
794
+ var STAGE_INSTRUCTIONS = {
795
+ project_info: "Ask for the project name and a brief description of what the user is building. Call set_project_info to record them.",
796
+ frontend: "Present 2-3 frontend framework options with trade-offs and your recommendation. Consider the project description when suggesting options.",
797
+ backend: "Present 2-3 backend/API options. Consider the chosen frontend \u2014 if it has built-in API routes (e.g., Next.js), that may be sufficient. If this stage is not needed, explain why and skip it.",
798
+ database: "Present 2-3 database options with ORM/query layer recommendations. Consider the chosen frontend and backend when suggesting options.",
799
+ auth: "Present 2-3 authentication options. If auth is not needed for this project, explain why and skip it.",
800
+ payments: "Present 2-3 payment processing options. If payments are not needed, explain why and skip it.",
801
+ ai: "Present 2-3 AI/LLM integration options. If AI is not needed, explain why and skip it.",
802
+ deployment: "Present 2-3 deployment platform options. Consider the chosen frontend and backend when suggesting options.",
803
+ extras: "Suggest any additional integrations that would benefit this project (analytics, email, monitoring, etc.). If none are needed, explain why and skip it."
804
+ };
805
+
347
806
  // src/agent/system-prompt.ts
348
- function buildConversationPrompt(progress) {
807
+ function buildConversationPrompt(progress, stageId, stages) {
808
+ const stage = stages.find((s) => s.id === stageId);
809
+ const stageLabel = stage?.label ?? stageId;
810
+ const instruction = STAGE_INSTRUCTIONS[stageId] ?? `Discuss the ${stageLabel} stage with the user.`;
811
+ const completedSummaries = stages.filter((s) => s.status === "complete" && s.summary).map((s) => `- ${s.label}: ${s.summary}`).join("\n");
812
+ const contextSection = completedSummaries ? `
813
+
814
+ Context from previous stages:
815
+ ${completedSummaries}` : "";
349
816
  return `You are a senior software architect helping a developer set up a new project.
350
817
 
351
- Your job is to guide the user through selecting their technology stack by having a natural conversation. Work through these categories: frontend, backend, database, auth, payments, ai/llm, deployment, and any extras they might want.
818
+ Current project state:
819
+ ${serializeProgress(progress)}
352
820
 
353
- Guidelines:
354
- - Present 2-3 concrete options per category, plus a "something else" option. Number them (1, 2, 3...) so users can respond quickly.
355
- - For each set of options, explicitly label your top pick with "(Recommended)" next to it and explain WHY it's the best fit for this specific project. Example: "1. Next.js (Recommended) \u2014 server components, built-in API routes...". Then briefly describe the alternatives and their trade-offs. Be opinionated \u2014 you are a senior architect, not a menu.
356
- - Keep the conversation focused and friendly. Ask one category at a time.
357
- - When the user decides on something, call \`set_decision\` to commit that decision before moving on.
358
- - Start by asking for a project name and a brief description of what they're building. Call \`set_project_info\` to record these before moving to stack decisions.
359
- - As conversations get long, call \`summarize_stage\` when completing each category to keep context manageable.
360
- - Once all decisions are made (frontend, database, and deployment are required; backend, auth, payments, and extras are optional), call \`present_plan\` to signal the plan is ready.
821
+ ## Current Stage: ${stageLabel}
361
822
 
362
- Do not ask the user to confirm each tool call \u2014 just make the calls naturally as decisions are reached.
823
+ You are currently discussing the ${stageLabel} stage.
824
+ ${instruction}
363
825
 
364
- Current project state:
365
- ${serializeProgress(progress)}`;
826
+ Response guidelines:
827
+ - When presenting technology choices, call \`present_options\` with 2-3 options. Do NOT write numbered lists in text.
828
+ - Option labels: max 30 characters (just the name).
829
+ - Option descriptions: max 80 characters (one-line trade-off summary).
830
+ - Mark at most one option as recommended.
831
+ - After a user selects an option, confirm in one short sentence (max 60 chars) and call set_decision immediately.
832
+ - When answering questions, keep responses under 500 characters. Most answers should be 1-2 sentences. Only approach 500 chars for genuinely complex comparisons.
833
+ - Never congratulate or explain why a choice is great. Just confirm and move on.
834
+ ${contextSection}
835
+
836
+ Guidelines:
837
+ - Focus on ${stageLabel}. Do not discuss other undecided stages.
838
+ - When the user has made their choice, call \`set_decision\` to commit it, then call \`summarize_stage\` to summarize what was decided.
839
+ - If this stage is not relevant to the project, briefly explain why and call \`summarize_stage\` to skip it.
840
+ - Do not ask the user to confirm each tool call \u2014 just make the calls naturally as decisions are reached.`;
366
841
  }
367
842
  function buildScaffoldPrompt(progress) {
368
843
  return `You are scaffolding a new software project based on an approved plan.
@@ -375,6 +850,26 @@ Instructions:
375
850
  2. After scaffolding, call \`add_integration\` for each integration (database, auth, payments, deployment config, extras) to write necessary files, install dependencies, and declare required environment variables.
376
851
  3. Use MCP tools to look up current documentation for any libraries or frameworks you integrate, ensuring you use up-to-date APIs and configuration patterns.
377
852
  4. Generate complete, working code \u2014 no stubs, no placeholders, no TODO comments. Every file should be production-ready.
853
+ 5. Generate a \`deploy.sh\` script at the project root via \`add_integration\`. The script must:
854
+ - Start with \`set -euo pipefail\`
855
+ - Check that the required CLI tool is installed (exit 1 with a helpful message if not)
856
+ - Check authentication status (exit 1 with auth instructions if not)
857
+ - Print what it is about to do
858
+ - Execute the deploy command for the chosen platform
859
+ - Be 15-30 lines max \u2014 no elaborate bash framework
860
+ Use the \`scripts\` property of \`add_integration\` to add \`"deploy": "bash deploy.sh"\` to package.json.
861
+ 6. As the LAST \`add_integration\` call, generate a comprehensive \`README.md\` with these sections:
862
+ - **Project title and description**
863
+ - **Tech stack overview** \u2014 what was chosen and why
864
+ - **Prerequisites** \u2014 Node.js version, required CLI tools
865
+ - **Local development setup** \u2014 clone, \`npm install\`, configure \`.env\` from \`.env.example\`, \`npm run dev\`
866
+ - **Environment variables** \u2014 table with: variable name, what it is for, where to get it, required vs optional. Clearly state that \`.env\` is for local development only.
867
+ - **Deployment** \u2014 platform-specific instructions:
868
+ - How to install and authenticate the platform CLI
869
+ - How to set env vars ON THE PLATFORM (e.g. \`vercel env add\`, \`gcloud run services update --set-env-vars\`, AWS Parameter Store). Production env vars are NOT set via \`.env\` \u2014 they are configured through platform-native tools.
870
+ - The deploy command: \`npm run deploy\`
871
+ - Post-deploy verification steps
872
+ - **Project structure** \u2014 brief description of key directories and files
378
873
 
379
874
  Do not ask for confirmation. Proceed through all steps automatically.`;
380
875
  }
@@ -383,73 +878,7 @@ Do not ask for confirmation. Proceed through all steps automatically.`;
383
878
  import { execFileSync } from "child_process";
384
879
  import { readdirSync, existsSync } from "fs";
385
880
  import { join } from "path";
386
- var TOOL_ALLOWLIST = /* @__PURE__ */ new Set([
387
- "create-next-app",
388
- "create-vite",
389
- "create-remix",
390
- "create-svelte",
391
- "create-astro",
392
- "nuxi"
393
- ]);
394
- var TOOL_FLAG_ALLOWLISTS = {
395
- "create-next-app": /* @__PURE__ */ new Set([
396
- "--typescript",
397
- "--js",
398
- "--tailwind",
399
- "--no-tailwind",
400
- "--eslint",
401
- "--no-eslint",
402
- "--app",
403
- "--src-dir",
404
- "--no-src-dir",
405
- "--import-alias",
406
- "--use-npm",
407
- "--use-pnpm",
408
- "--use-yarn",
409
- "--use-bun"
410
- ]),
411
- "create-vite": /* @__PURE__ */ new Set(["--template"])
412
- };
413
- var URL_SCHEME_RE = /https?:|git\+|file:/i;
414
- var SHELL_META_RE = /[;&|`$(){}[\]<>!#~*?\n\r]/;
415
- var WHITESPACE_RE = /\s/;
416
- function validateScaffoldTool(tool, approvedTool) {
417
- if (!TOOL_ALLOWLIST.has(tool)) {
418
- throw new Error(`Scaffold tool not in allowlist: "${tool}"`);
419
- }
420
- if (tool !== approvedTool) {
421
- throw new Error(`Scaffold tool "${tool}" does not match approved tool "${approvedTool}"`);
422
- }
423
- }
424
- function validateScaffoldArgs(tool, args) {
425
- const strictAllowlist = TOOL_FLAG_ALLOWLISTS[tool];
426
- for (const arg of args) {
427
- if (URL_SCHEME_RE.test(arg)) {
428
- throw new Error(`Scaffold arg contains a URL scheme: "${arg}"`);
429
- }
430
- if (WHITESPACE_RE.test(arg)) {
431
- throw new Error(`Scaffold arg contains whitespace: "${arg}"`);
432
- }
433
- if (SHELL_META_RE.test(arg)) {
434
- throw new Error(`Scaffold arg contains shell metacharacters: "${arg}"`);
435
- }
436
- if (strictAllowlist !== void 0) {
437
- const isFlag = arg.startsWith("--");
438
- if (isFlag && !strictAllowlist.has(arg)) {
439
- throw new Error(`Scaffold arg "${arg}" is not in the allowlist for tool "${tool}"`);
440
- }
441
- } else {
442
- if (!arg.startsWith("--")) {
443
- throw new Error(
444
- `Scaffold arg "${arg}" must start with "--" for tool "${tool}"`
445
- );
446
- }
447
- }
448
- }
449
- }
450
- function runScaffold(tool, args, approvedTool, projectName, cwd) {
451
- validateScaffoldTool(tool, approvedTool);
452
- validateScaffoldArgs(tool, args);
881
+ function runScaffold(tool, args2, projectName, cwd) {
453
882
  const outputDir = join(cwd, projectName);
454
883
  if (existsSync(outputDir)) {
455
884
  const entries = readdirSync(outputDir);
@@ -459,7 +888,7 @@ function runScaffold(tool, args, approvedTool, projectName, cwd) {
459
888
  );
460
889
  }
461
890
  }
462
- const spawnArgs = [`${tool}@latest`, projectName, ...args];
891
+ const spawnArgs = [`${tool}@latest`, projectName, ...args2];
463
892
  const opts = { cwd, stdio: "pipe" };
464
893
  execFileSync("npx", spawnArgs, opts);
465
894
  return outputDir;
@@ -481,14 +910,14 @@ function validateFilePaths(projectRoot, files) {
481
910
  }
482
911
  }
483
912
  function writeIntegration(projectDir, input) {
484
- const { files, dependencies, devDependencies, envVars } = input;
913
+ const { files, dependencies, devDependencies, scripts, envVars } = input;
485
914
  validateFilePaths(projectDir, files);
486
915
  for (const [filePath, content] of Object.entries(files)) {
487
916
  const fullPath = resolve(projectDir, filePath);
488
917
  mkdirSync(dirname(fullPath), { recursive: true });
489
918
  writeFileSync(fullPath, content, "utf8");
490
919
  }
491
- if (dependencies !== void 0 || devDependencies !== void 0) {
920
+ if (dependencies !== void 0 || devDependencies !== void 0 || scripts !== void 0) {
492
921
  const pkgPath = resolve(projectDir, "package.json");
493
922
  let pkg = {};
494
923
  if (existsSync2(pkgPath)) {
@@ -506,6 +935,12 @@ function writeIntegration(projectDir, input) {
506
935
  ...devDependencies
507
936
  };
508
937
  }
938
+ if (scripts !== void 0) {
939
+ pkg.scripts = {
940
+ ...pkg.scripts,
941
+ ...scripts
942
+ };
943
+ }
509
944
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
510
945
  }
511
946
  if (envVars !== void 0 && envVars.length > 0) {
@@ -516,15 +951,20 @@ function writeIntegration(projectDir, input) {
516
951
  }
517
952
 
518
953
  // src/agent/loop.ts
519
- async function runConversationLoop(mcpServers) {
520
- let progress = createProgress();
521
- const messages = [];
522
- messages.push({ role: "user", content: "I want to start a new project." });
954
+ async function runStageLoop(stage, manager, bridge, mcpServers) {
955
+ const messages = manager.messages;
956
+ let progress = manager.progress;
957
+ if (messages.length === 0) {
958
+ messages.push({ role: "user", content: "I want to start a new project." });
959
+ }
960
+ let hasCalledSetDecision = false;
523
961
  while (true) {
524
- const system = buildConversationPrompt(progress);
962
+ const system = buildConversationPrompt(progress, stage.id, manager.stages);
525
963
  let contentBlocks = [];
526
964
  const collectedToolUse = [];
527
965
  let hasText = false;
966
+ let fullText = "";
967
+ bridge.onSpinnerStart();
528
968
  await chatStream(
529
969
  {
530
970
  system,
@@ -535,11 +975,9 @@ async function runConversationLoop(mcpServers) {
535
975
  },
536
976
  {
537
977
  onText: (delta) => {
538
- if (!hasText) {
539
- hasText = true;
540
- writeText("\n");
541
- }
542
- writeText(delta);
978
+ hasText = true;
979
+ fullText += delta;
980
+ bridge.onStreamText(delta);
543
981
  },
544
982
  onToolUse: (block) => {
545
983
  collectedToolUse.push(block);
@@ -550,18 +988,31 @@ async function runConversationLoop(mcpServers) {
550
988
  }
551
989
  );
552
990
  if (hasText) {
553
- writeLine();
554
- writeLine();
991
+ bridge.onStreamText("\n");
555
992
  }
556
993
  const toolUseBlocks = collectedToolUse;
557
994
  if (toolUseBlocks.length > 0) {
558
995
  messages.push({ role: "assistant", content: contentBlocks });
559
996
  const toolResults = [];
560
- let hasPresentPlan = false;
561
997
  let hasSummarizeStage = false;
562
998
  let summarizeSummary = "";
999
+ let madeDecision = false;
563
1000
  for (const block of toolUseBlocks) {
564
1001
  const toolBlock = block;
1002
+ if (toolBlock.name === "present_options") {
1003
+ const options = toolBlock.input.options;
1004
+ bridge.onPresentOptions(options);
1005
+ const input = await bridge.waitForInput();
1006
+ toolResults.push({
1007
+ type: "tool_result",
1008
+ tool_use_id: toolBlock.id,
1009
+ content: input.kind === "select" ? `User selected: ${input.value}` : input.kind === "text" ? `User wrote: ${input.value}` : "User cancelled."
1010
+ });
1011
+ if (input.kind === "cancel") {
1012
+ return { outcome: "cancel" };
1013
+ }
1014
+ continue;
1015
+ }
565
1016
  const result = executeConversationTool(
566
1017
  toolBlock.name,
567
1018
  toolBlock.input,
@@ -569,13 +1020,15 @@ async function runConversationLoop(mcpServers) {
569
1020
  messages
570
1021
  );
571
1022
  progress = result.progress;
1023
+ manager.progress = progress;
572
1024
  toolResults.push({
573
1025
  type: "tool_result",
574
1026
  tool_use_id: toolBlock.id,
575
1027
  content: result.response
576
1028
  });
577
- if (result.signal === "present_plan") {
578
- hasPresentPlan = true;
1029
+ if (toolBlock.name === "set_decision" || toolBlock.name === "set_project_info") {
1030
+ madeDecision = true;
1031
+ hasCalledSetDecision = true;
579
1032
  }
580
1033
  if (toolBlock.name === "summarize_stage") {
581
1034
  hasSummarizeStage = true;
@@ -583,36 +1036,45 @@ async function runConversationLoop(mcpServers) {
583
1036
  }
584
1037
  }
585
1038
  messages.push({ role: "user", content: toolResults });
1039
+ if (madeDecision || hasSummarizeStage) {
1040
+ manager.save();
1041
+ }
586
1042
  if (hasSummarizeStage) {
587
1043
  const lastAssistant = messages[messages.length - 2];
588
1044
  const lastUser = messages[messages.length - 1];
589
1045
  messages.length = 0;
590
- messages.push({
591
- role: "assistant",
592
- content: summarizeSummary
593
- });
594
- messages.push({
595
- role: "user",
596
- content: "[Continuing]"
597
- });
1046
+ messages.push({ role: "assistant", content: summarizeSummary });
1047
+ messages.push({ role: "user", content: "[Continuing]" });
598
1048
  messages.push(lastAssistant);
599
1049
  messages.push(lastUser);
1050
+ manager.messages = messages;
600
1051
  }
601
- if (hasPresentPlan) {
602
- renderPlan(serializeProgress(progress));
603
- return progress;
1052
+ if (hasSummarizeStage) {
1053
+ if (hasCalledSetDecision || stage.id === "project_info") {
1054
+ return { outcome: "complete", summary: summarizeSummary };
1055
+ }
1056
+ return { outcome: "skipped" };
604
1057
  }
605
1058
  continue;
606
1059
  }
607
- const userInput = await getUserInput("Your response");
608
- if (userInput === null) return null;
1060
+ bridge.onStreamEnd(fullText);
1061
+ const inputResult = await bridge.waitForInput();
1062
+ if (inputResult.kind === "cancel") return { outcome: "cancel" };
1063
+ if (inputResult.kind === "navigate") return { outcome: "navigate" };
609
1064
  messages.push({
610
1065
  role: "assistant",
611
1066
  content: contentBlocks
612
1067
  });
613
- messages.push({ role: "user", content: userInput });
1068
+ messages.push({ role: "user", content: inputResult.value });
614
1069
  }
615
1070
  }
1071
+ function createSimpleSpinner() {
1072
+ return {
1073
+ start: (msg) => process.stdout.write(` ${msg}...`),
1074
+ stop: (msg) => process.stdout.write(` ${msg}
1075
+ `)
1076
+ };
1077
+ }
616
1078
  async function runScaffoldLoop(progress, mcpServers) {
617
1079
  const messages = [];
618
1080
  const system = buildScaffoldPrompt(progress);
@@ -646,22 +1108,20 @@ async function runScaffoldLoop(progress, mcpServers) {
646
1108
  const toolBlock = block;
647
1109
  toolCallCount++;
648
1110
  if (toolCallCount > maxToolCalls) {
649
- renderError(`Tool call limit exceeded (${maxToolCalls}). Stopping scaffold loop.`);
1111
+ console.error(`Tool call limit exceeded (${maxToolCalls}). Stopping scaffold loop.`);
650
1112
  return false;
651
1113
  }
652
- const spinner2 = createSpinner();
1114
+ const spinner = createSimpleSpinner();
653
1115
  try {
654
1116
  if (toolBlock.name === "run_scaffold") {
655
- spinner2.start(`Running scaffold: ${toolBlock.input.tool}`);
656
- const approvedTool = findApprovedScaffoldTool(progress);
1117
+ spinner.start(`Running scaffold: ${toolBlock.input.tool}`);
657
1118
  const outputDir = runScaffold(
658
1119
  toolBlock.input.tool,
659
1120
  toolBlock.input.args,
660
- approvedTool,
661
1121
  projectName,
662
1122
  cwd
663
1123
  );
664
- spinner2.stop(`Scaffold complete: ${outputDir}`);
1124
+ spinner.stop(`Scaffold complete: ${outputDir}`);
665
1125
  toolResults.push({
666
1126
  type: "tool_result",
667
1127
  tool_use_id: toolBlock.id,
@@ -671,21 +1131,22 @@ async function runScaffoldLoop(progress, mcpServers) {
671
1131
  const integrationDesc = Object.keys(
672
1132
  toolBlock.input.files ?? {}
673
1133
  ).join(", ");
674
- spinner2.start(`Adding integration: ${integrationDesc}`);
1134
+ spinner.start(`Adding integration: ${integrationDesc}`);
675
1135
  writeIntegration(projectDir, {
676
1136
  files: toolBlock.input.files ?? {},
677
1137
  dependencies: toolBlock.input.dependencies,
678
1138
  devDependencies: toolBlock.input.devDependencies,
1139
+ scripts: toolBlock.input.scripts,
679
1140
  envVars: toolBlock.input.envVars
680
1141
  });
681
- spinner2.stop("Integration added");
1142
+ spinner.stop("Integration added");
682
1143
  toolResults.push({
683
1144
  type: "tool_result",
684
1145
  tool_use_id: toolBlock.id,
685
1146
  content: "Integration written successfully."
686
1147
  });
687
1148
  } else {
688
- spinner2.stop(`Unknown tool: ${toolBlock.name}`);
1149
+ spinner.stop(`Unknown tool: ${toolBlock.name}`);
689
1150
  toolResults.push({
690
1151
  type: "tool_result",
691
1152
  tool_use_id: toolBlock.id,
@@ -695,7 +1156,7 @@ async function runScaffoldLoop(progress, mcpServers) {
695
1156
  }
696
1157
  } catch (err) {
697
1158
  const errorMessage = err instanceof Error ? err.message : String(err);
698
- spinner2.stop(`Error: ${errorMessage}`);
1159
+ spinner.stop(`Error: ${errorMessage}`);
699
1160
  toolResults.push({
700
1161
  type: "tool_result",
701
1162
  tool_use_id: toolBlock.id,
@@ -707,59 +1168,841 @@ async function runScaffoldLoop(progress, mcpServers) {
707
1168
  messages.push({ role: "user", content: toolResults });
708
1169
  }
709
1170
  }
710
- function findApprovedScaffoldTool(progress) {
1171
+
1172
+ // src/agent/recommend.ts
1173
+ var log2 = createLogger("recommend");
1174
+ var RECOMMEND_PROMPT = `You are a senior software architect. Based on the project description, recommend a complete technology stack.
1175
+
1176
+ For each category, provide your recommendation as a JSON object. If a category is not needed for this project, set it to null.
1177
+
1178
+ Respond with ONLY a JSON object in this exact format:
1179
+ {
1180
+ "frontend": { "component": "Next.js", "reasoning": "Best for SaaS with built-in API routes" },
1181
+ "backend": null,
1182
+ "database": { "component": "Postgres + Drizzle", "reasoning": "Relational with great TypeScript support" },
1183
+ "auth": { "component": "Clerk", "reasoning": "Drop-in auth with good DX" },
1184
+ "payments": null,
1185
+ "ai": null,
1186
+ "deployment": { "component": "Vercel", "reasoning": "Native Next.js support" },
1187
+ "extras": null
1188
+ }
1189
+
1190
+ Rules:
1191
+ - Be opinionated. Pick the best option, not the most popular.
1192
+ - Set categories to null if they are genuinely not needed for this project.
1193
+ - Keep reasoning under 80 characters.
1194
+ - component names should be max 30 characters.`;
1195
+ async function getRecommendations(projectName, description) {
1196
+ try {
1197
+ const response = await chat({
1198
+ system: RECOMMEND_PROMPT,
1199
+ messages: [{
1200
+ role: "user",
1201
+ content: `Project: "${projectName}"
1202
+ Description: ${description}`
1203
+ }],
1204
+ maxTokens: 1024
1205
+ });
1206
+ const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
1207
+ log2.info({ rawLength: text.length }, "received recommendation response");
1208
+ log2.debug({ rawText: text }, "recommendation raw text");
1209
+ const jsonStr = text.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
1210
+ const parsed = JSON.parse(jsonStr);
1211
+ const categories = Object.keys(parsed).filter((k) => parsed[k] !== null);
1212
+ log2.info({ recommended: categories, skipped: Object.keys(parsed).filter((k) => parsed[k] === null) }, "parsed recommendations");
1213
+ return parsed;
1214
+ } catch (err) {
1215
+ log2.error({ err }, "recommendation pass failed");
1216
+ return {};
1217
+ }
1218
+ }
1219
+ function applyRecommendations(progress, stages, recommendations) {
1220
+ let updated = { ...progress };
711
1221
  const categories = [
712
- progress.frontend,
713
- progress.backend,
714
- progress.database,
715
- progress.auth,
716
- progress.payments,
717
- progress.deployment,
718
- ...progress.extras
1222
+ "frontend",
1223
+ "backend",
1224
+ "database",
1225
+ "auth",
1226
+ "payments",
1227
+ "ai",
1228
+ "deployment"
719
1229
  ];
720
- for (const choice of categories) {
721
- if (choice?.scaffoldTool) {
722
- return choice.scaffoldTool;
1230
+ for (const category of categories) {
1231
+ if (!(category in recommendations)) continue;
1232
+ const rec = recommendations[category];
1233
+ const stage = stages.find((s) => s.id === category);
1234
+ if (!stage) continue;
1235
+ if (rec) {
1236
+ updated = setDecision(updated, category, {
1237
+ component: rec.component,
1238
+ reasoning: rec.reasoning
1239
+ });
1240
+ stage.status = "complete";
1241
+ stage.summary = rec.component;
1242
+ stage.confirmed = false;
1243
+ } else {
1244
+ stage.status = "skipped";
1245
+ stage.summary = "not needed";
1246
+ stage.confirmed = false;
723
1247
  }
724
1248
  }
725
- return "";
1249
+ const extrasStage = stages.find((s) => s.id === "extras");
1250
+ if (extrasStage && !recommendations["extras"]) {
1251
+ extrasStage.status = "skipped";
1252
+ extrasStage.summary = "none suggested";
1253
+ extrasStage.confirmed = false;
1254
+ }
1255
+ return { progress: updated, stages };
1256
+ }
1257
+
1258
+ // src/cli/app.tsx
1259
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1260
+ function App({ manager, onBuild, onExit }) {
1261
+ const app = useApp();
1262
+ const { width, height } = useScreenSize();
1263
+ const [view, setView] = useState5(
1264
+ manager.progress.projectName ? "stage_list" : "project_info"
1265
+ );
1266
+ const [bridge] = useState5(() => createBridge());
1267
+ const [currentStage, setCurrentStage] = useState5(manager.currentStage());
1268
+ const [progress, setProgress] = useState5(manager.progress);
1269
+ const [stages, setStages] = useState5([...manager.stages]);
1270
+ const [options, setOptions] = useState5([]);
1271
+ const [errorMsg, setErrorMsg] = useState5("");
1272
+ const syncState = useCallback(() => {
1273
+ setProgress({ ...manager.progress });
1274
+ setStages([...manager.stages]);
1275
+ setCurrentStage(manager.currentStage());
1276
+ }, [manager]);
1277
+ useEffect2(() => {
1278
+ const unsubs = [
1279
+ bridge.subscribe("presentOptions", (opts) => {
1280
+ setOptions(opts);
1281
+ setView("options");
1282
+ }),
1283
+ bridge.subscribe("streamEnd", () => {
1284
+ setView("input");
1285
+ }),
1286
+ bridge.subscribe("spinnerStart", () => {
1287
+ setView("conversation");
1288
+ }),
1289
+ bridge.subscribe("error", (err) => {
1290
+ setErrorMsg(err.message);
1291
+ setView("error");
1292
+ })
1293
+ ];
1294
+ return () => unsubs.forEach((fn) => fn());
1295
+ }, [bridge]);
1296
+ useInput3((_input, key) => {
1297
+ if (key.escape && view !== "stage_list") {
1298
+ bridge.resolveInput({ kind: "cancel" });
1299
+ setView("stage_list");
1300
+ syncState();
1301
+ }
1302
+ }, { isActive: view === "conversation" || view === "error" });
1303
+ const runStage = useCallback(async (stageId) => {
1304
+ const stage = manager.stages.find((s) => s.id === stageId);
1305
+ if (!stage) return;
1306
+ setCurrentStage(stage);
1307
+ setView("conversation");
1308
+ try {
1309
+ const result = await runStageLoop(stage, manager, bridge);
1310
+ switch (result.outcome) {
1311
+ case "complete":
1312
+ manager.completeStage(stageId, result.summary);
1313
+ const completedStage = manager.stages.find((s) => s.id === stageId);
1314
+ if (completedStage) completedStage.confirmed = true;
1315
+ manager.save();
1316
+ break;
1317
+ case "skipped":
1318
+ manager.skipStage(stageId);
1319
+ manager.save();
1320
+ break;
1321
+ case "cancel":
1322
+ manager.restorePendingNavigation();
1323
+ break;
1324
+ }
1325
+ } catch (err) {
1326
+ setErrorMsg(err.message);
1327
+ setView("error");
1328
+ return;
1329
+ }
1330
+ syncState();
1331
+ setView("stage_list");
1332
+ }, [manager, bridge, syncState]);
1333
+ const handleStageResult = useCallback((result) => {
1334
+ if (result.kind === "select") {
1335
+ const stage = manager.stages.find((s) => s.id === result.stageId);
1336
+ if (stage && (stage.status === "complete" || stage.status === "skipped")) {
1337
+ manager.navigateTo(result.stageId);
1338
+ syncState();
1339
+ }
1340
+ runStage(result.stageId);
1341
+ } else if (result.kind === "build") {
1342
+ onBuild();
1343
+ app.exit();
1344
+ } else if (result.kind === "cancel") {
1345
+ manager.save();
1346
+ onExit();
1347
+ app.exit();
1348
+ }
1349
+ }, [manager, runStage, syncState, onBuild, onExit, app]);
1350
+ const handleOptionSelect = useCallback((result) => {
1351
+ setView("conversation");
1352
+ bridge.resolveInput(result);
1353
+ }, [bridge]);
1354
+ const handleTextSubmit = useCallback((value) => {
1355
+ setView("conversation");
1356
+ bridge.resolveInput({ kind: "text", value });
1357
+ }, [bridge]);
1358
+ const handleProjectInfo = useCallback(async (name, description) => {
1359
+ manager.progress = {
1360
+ ...manager.progress,
1361
+ projectName: name,
1362
+ description
1363
+ };
1364
+ const stage = manager.stages.find((s) => s.id === "project_info");
1365
+ if (stage) {
1366
+ stage.status = "complete";
1367
+ stage.confirmed = true;
1368
+ stage.summary = `${name}: ${description}`;
1369
+ }
1370
+ manager.save();
1371
+ syncState();
1372
+ setView("loading");
1373
+ try {
1374
+ const recommendations = await getRecommendations(name, description);
1375
+ const { progress: updatedProgress } = applyRecommendations(
1376
+ manager.progress,
1377
+ manager.stages,
1378
+ recommendations
1379
+ );
1380
+ manager.progress = updatedProgress;
1381
+ manager.save();
1382
+ } catch (err) {
1383
+ console.error("Recommendation pass failed:", err);
1384
+ }
1385
+ syncState();
1386
+ setView("stage_list");
1387
+ }, [manager, syncState]);
1388
+ const footerMode = view === "stage_list" ? "stage_list" : view === "options" ? "options" : view === "input" || view === "project_info" ? "input" : "decisions";
1389
+ const contentHeight = height - 4;
1390
+ return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", width, height, children: [
1391
+ /* @__PURE__ */ jsx7(
1392
+ Header,
1393
+ {
1394
+ appName: "stack-agent",
1395
+ currentStage: view === "stage_list" ? null : currentStage,
1396
+ stages,
1397
+ showDots: view !== "stage_list"
1398
+ }
1399
+ ),
1400
+ /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", height: contentHeight, paddingX: 1, borderStyle: "single", borderTop: false, borderBottom: false, children: [
1401
+ view === "project_info" && /* @__PURE__ */ jsx7(ProjectInfoForm, { onSubmit: handleProjectInfo }),
1402
+ view === "loading" && /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsx7(Spinner2, { label: "Analyzing your project and recommending a stack..." }) }),
1403
+ view === "stage_list" && /* @__PURE__ */ jsx7(
1404
+ StageListView,
1405
+ {
1406
+ stages,
1407
+ currentStageId: currentStage?.id ?? null,
1408
+ progress,
1409
+ onResult: handleStageResult
1410
+ }
1411
+ ),
1412
+ view === "conversation" && /* @__PURE__ */ jsx7(ConversationView, { bridge, maxLines: contentHeight }),
1413
+ view === "input" && /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
1414
+ /* @__PURE__ */ jsx7(ConversationView, { bridge, maxLines: contentHeight - 3 }),
1415
+ /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
1416
+ TextInput2,
1417
+ {
1418
+ placeholder: "Type your response...",
1419
+ onSubmit: handleTextSubmit
1420
+ }
1421
+ ) })
1422
+ ] }),
1423
+ view === "options" && /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
1424
+ /* @__PURE__ */ jsx7(ConversationView, { bridge, maxLines: contentHeight - 8 }),
1425
+ /* @__PURE__ */ jsx7(OptionSelect, { options, onSelect: handleOptionSelect })
1426
+ ] }),
1427
+ view === "error" && /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
1428
+ /* @__PURE__ */ jsxs5(Text7, { color: "red", bold: true, children: [
1429
+ "Error: ",
1430
+ errorMsg
1431
+ ] }),
1432
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Press Esc to return to stage list" })
1433
+ ] })
1434
+ ] }),
1435
+ /* @__PURE__ */ jsx7(
1436
+ Footer,
1437
+ {
1438
+ progress,
1439
+ stages,
1440
+ terminalWidth: width,
1441
+ mode: footerMode
1442
+ }
1443
+ )
1444
+ ] });
1445
+ }
1446
+
1447
+ // src/agent/stage-manager.ts
1448
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync, unlinkSync, existsSync as existsSync3 } from "fs";
1449
+ import { join as join3 } from "path";
1450
+ var SESSION_FILE = ".stack-agent.json";
1451
+ var SESSION_VERSION = 1;
1452
+ var StageManager = class _StageManager {
1453
+ session;
1454
+ filePath;
1455
+ onInvalidate;
1456
+ pendingNavigation;
1457
+ constructor(session, cwd, onInvalidate) {
1458
+ this.session = session;
1459
+ this.filePath = join3(cwd, SESSION_FILE);
1460
+ this.onInvalidate = onInvalidate;
1461
+ }
1462
+ static start(cwd, onInvalidate) {
1463
+ const session = {
1464
+ version: SESSION_VERSION,
1465
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1466
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1467
+ progress: createProgress(),
1468
+ stages: structuredClone(DEFAULT_STAGES),
1469
+ messages: []
1470
+ };
1471
+ return new _StageManager(session, cwd, onInvalidate);
1472
+ }
1473
+ static resume(cwd, onInvalidate) {
1474
+ const session = _StageManager.detect(cwd);
1475
+ if (!session) return null;
1476
+ return new _StageManager(session, cwd, onInvalidate);
1477
+ }
1478
+ static detect(cwd) {
1479
+ const filePath = join3(cwd, SESSION_FILE);
1480
+ if (!existsSync3(filePath)) return null;
1481
+ try {
1482
+ const json = readFileSync2(filePath, "utf-8");
1483
+ return deserializeSession(json);
1484
+ } catch {
1485
+ return null;
1486
+ }
1487
+ }
1488
+ // --- Stage navigation ---
1489
+ currentStage() {
1490
+ return this.session.stages.find((s) => s.status === "pending") ?? null;
1491
+ }
1492
+ completeStage(id, summary) {
1493
+ const stage = this.session.stages.find((s) => s.id === id);
1494
+ if (!stage) return;
1495
+ stage.status = "complete";
1496
+ stage.summary = summary;
1497
+ this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1498
+ if (this.pendingNavigation?.stageId === id) {
1499
+ this.pendingNavigation = void 0;
1500
+ }
1501
+ }
1502
+ skipStage(id) {
1503
+ const stage = this.session.stages.find((s) => s.id === id);
1504
+ if (!stage) return;
1505
+ stage.status = "skipped";
1506
+ this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1507
+ }
1508
+ navigateTo(id) {
1509
+ const stage = this.session.stages.find((s) => s.id === id);
1510
+ if (!stage) return;
1511
+ if (stage.status === "complete") {
1512
+ if (id === "project_info") {
1513
+ this.pendingNavigation = {
1514
+ stageId: id,
1515
+ oldValue: null,
1516
+ oldProjectInfo: {
1517
+ projectName: this.session.progress.projectName,
1518
+ description: this.session.progress.description
1519
+ },
1520
+ oldSummary: stage.summary
1521
+ };
1522
+ } else {
1523
+ const key = stage.progressKeys[0];
1524
+ const oldValue = key === "extras" ? null : this.session.progress[key] ?? null;
1525
+ this.pendingNavigation = {
1526
+ stageId: id,
1527
+ oldValue,
1528
+ oldSummary: stage.summary
1529
+ };
1530
+ }
1531
+ }
1532
+ stage.status = "pending";
1533
+ stage.summary = void 0;
1534
+ }
1535
+ restorePendingNavigation() {
1536
+ if (!this.pendingNavigation) return;
1537
+ const { stageId, oldValue, oldProjectInfo, oldSummary } = this.pendingNavigation;
1538
+ const stage = this.session.stages.find((s) => s.id === stageId);
1539
+ if (!stage) return;
1540
+ if (stageId === "project_info" && oldProjectInfo) {
1541
+ this.session.progress = {
1542
+ ...this.session.progress,
1543
+ projectName: oldProjectInfo.projectName,
1544
+ description: oldProjectInfo.description
1545
+ };
1546
+ } else if (stageId !== "project_info") {
1547
+ const category = stageId;
1548
+ if (oldValue) {
1549
+ this.session.progress = { ...this.session.progress, [category]: oldValue };
1550
+ }
1551
+ }
1552
+ stage.status = "complete";
1553
+ stage.summary = oldSummary;
1554
+ this.pendingNavigation = void 0;
1555
+ }
1556
+ isNavigating() {
1557
+ return this.pendingNavigation !== void 0;
1558
+ }
1559
+ getPendingOldValue() {
1560
+ return this.pendingNavigation?.oldValue ?? null;
1561
+ }
1562
+ // --- Dynamic stages ---
1563
+ addStage(entry, afterId) {
1564
+ const idx = this.session.stages.findIndex((s) => s.id === afterId);
1565
+ if (idx === -1) {
1566
+ this.session.stages.push(entry);
1567
+ } else {
1568
+ this.session.stages.splice(idx + 1, 0, entry);
1569
+ }
1570
+ this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1571
+ }
1572
+ removeStage(id) {
1573
+ this.session.stages = this.session.stages.filter((s) => s.id !== id);
1574
+ this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1575
+ }
1576
+ // --- Cascading invalidation ---
1577
+ async invalidateAfter(changedId, oldValue) {
1578
+ if (!this.onInvalidate) return;
1579
+ const stage = this.session.stages.find((s) => s.id === changedId);
1580
+ if (!stage) return;
1581
+ const key = stage.progressKeys[0];
1582
+ const newValue = key === "extras" || key === "projectName" || key === "description" ? null : this.session.progress[key] ?? null;
1583
+ const result = await this.onInvalidate(
1584
+ changedId,
1585
+ oldValue,
1586
+ newValue,
1587
+ this.session.progress,
1588
+ this.session.stages
1589
+ );
1590
+ const changedIdx = this.session.stages.findIndex((s) => s.id === changedId);
1591
+ for (const clearId of result.clear) {
1592
+ const clearIdx = this.session.stages.findIndex((s) => s.id === clearId);
1593
+ if (clearIdx <= changedIdx) continue;
1594
+ const clearStage = this.session.stages[clearIdx];
1595
+ if (!clearStage) continue;
1596
+ for (const pKey of clearStage.progressKeys) {
1597
+ if (pKey === "projectName" || pKey === "description") {
1598
+ this.session.progress = clearProjectInfo(this.session.progress);
1599
+ } else if (pKey === "extras") {
1600
+ this.session.progress = clearDecision(this.session.progress, "extras");
1601
+ } else {
1602
+ this.session.progress = clearDecision(this.session.progress, pKey);
1603
+ }
1604
+ }
1605
+ clearStage.status = "pending";
1606
+ clearStage.summary = void 0;
1607
+ }
1608
+ for (const removeId of result.remove) {
1609
+ const removeIdx = this.session.stages.findIndex((s) => s.id === removeId);
1610
+ if (removeIdx <= changedIdx) continue;
1611
+ this.removeStage(removeId);
1612
+ }
1613
+ let insertAfterId = changedId;
1614
+ for (const newStage of result.add) {
1615
+ this.addStage(newStage, insertAfterId);
1616
+ insertAfterId = newStage.id;
1617
+ }
1618
+ this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1619
+ }
1620
+ // --- Persistence ---
1621
+ save() {
1622
+ this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1623
+ const json = serializeSession(this.session);
1624
+ if (json.length > 5e5) {
1625
+ console.warn("Warning: session file exceeds 500KB. Consider summarizing more aggressively.");
1626
+ }
1627
+ try {
1628
+ const tmpPath = this.filePath + ".tmp";
1629
+ writeFileSync2(tmpPath, json, "utf-8");
1630
+ renameSync(tmpPath, this.filePath);
1631
+ } catch (err) {
1632
+ console.warn(`Warning: failed to save session: ${err instanceof Error ? err.message : String(err)}`);
1633
+ }
1634
+ }
1635
+ cleanup() {
1636
+ try {
1637
+ if (existsSync3(this.filePath)) unlinkSync(this.filePath);
1638
+ const tmpPath = this.filePath + ".tmp";
1639
+ if (existsSync3(tmpPath)) unlinkSync(tmpPath);
1640
+ } catch {
1641
+ }
1642
+ }
1643
+ // --- Accessors ---
1644
+ get progress() {
1645
+ return this.session.progress;
1646
+ }
1647
+ set progress(value) {
1648
+ this.session.progress = value;
1649
+ }
1650
+ get messages() {
1651
+ return this.session.messages;
1652
+ }
1653
+ set messages(value) {
1654
+ this.session.messages = value;
1655
+ }
1656
+ get stages() {
1657
+ return this.session.stages;
1658
+ }
1659
+ get updatedAt() {
1660
+ return this.session.updatedAt;
1661
+ }
1662
+ };
1663
+
1664
+ // src/cli/chat.ts
1665
+ import { Marked } from "marked";
1666
+ import { markedTerminal } from "marked-terminal";
1667
+ var marked = new Marked(markedTerminal());
1668
+ function renderPostScaffold(projectName, readiness) {
1669
+ const localSteps = [
1670
+ `cd ${projectName}`,
1671
+ "cp .env.example .env # fill in your values",
1672
+ "npm install",
1673
+ "npm run dev"
1674
+ ];
1675
+ console.log("Local Development\n " + localSteps.join("\n "));
1676
+ if (readiness === null) return;
1677
+ const lines = [];
1678
+ if (readiness.cliInstalled && readiness.authenticated === true) {
1679
+ lines.push("\u2713 Ready to deploy");
1680
+ lines.push(`\u2192 ${readiness.deployCmd}`);
1681
+ } else if (!readiness.cliInstalled) {
1682
+ lines.push(`\u2717 ${readiness.cliName || "CLI"} not found`);
1683
+ if (readiness.installCmd) lines.push(` Install: ${readiness.installCmd}`);
1684
+ if (readiness.authCmd) lines.push(` Then: ${readiness.authCmd}`);
1685
+ lines.push(` Then: ${readiness.deployCmd}`);
1686
+ } else {
1687
+ lines.push(`\u2713 ${readiness.cliName} CLI installed`);
1688
+ if (readiness.authenticated === false) {
1689
+ lines.push(`\u2717 Not authenticated`);
1690
+ lines.push(` Run: ${readiness.authCmd}`);
1691
+ } else {
1692
+ lines.push("? Authentication status unknown");
1693
+ lines.push(` Try: ${readiness.authCmd}`);
1694
+ }
1695
+ lines.push(` Then: ${readiness.deployCmd}`);
1696
+ }
1697
+ lines.push("");
1698
+ if (readiness.envVarCmd) {
1699
+ lines.push(`\u2139 Set production env vars with: ${readiness.envVarCmd}`);
1700
+ }
1701
+ lines.push("\u2139 See README.md \u2192 Deployment for full instructions");
1702
+ console.log(`Deployment (${readiness.platform})
1703
+ ${lines.join("\n ")}`);
1704
+ }
1705
+
1706
+ // src/deploy/readiness.ts
1707
+ import { execFileSync as execFileSync2 } from "child_process";
1708
+ var PLATFORMS = [
1709
+ {
1710
+ platform: "AWS Amplify",
1711
+ cliName: "amplify",
1712
+ cliBinary: "amplify",
1713
+ authCheckCmd: ["amplify", "status"],
1714
+ installCmd: "npm i -g @aws-amplify/cli",
1715
+ authCmd: "amplify configure",
1716
+ deployCmd: "npm run deploy",
1717
+ envVarCmd: "See AWS Amplify console for environment variables"
1718
+ },
1719
+ {
1720
+ platform: "AWS CDK",
1721
+ cliName: "cdk",
1722
+ cliBinary: "cdk",
1723
+ authCheckCmd: ["aws", "sts", "get-caller-identity"],
1724
+ installCmd: "npm i -g aws-cdk",
1725
+ authCmd: "aws configure",
1726
+ deployCmd: "npm run deploy",
1727
+ envVarCmd: "aws ssm put-parameter --name KEY --value VAL --type String"
1728
+ },
1729
+ {
1730
+ platform: "AWS SST",
1731
+ cliName: "sst",
1732
+ cliBinary: "npx",
1733
+ authCheckCmd: ["aws", "sts", "get-caller-identity"],
1734
+ installCmd: "(uses npx \u2014 no global install needed)",
1735
+ authCmd: "aws configure",
1736
+ deployCmd: "npm run deploy",
1737
+ envVarCmd: "aws ssm put-parameter --name KEY --value VAL --type String"
1738
+ },
1739
+ {
1740
+ platform: "Vercel",
1741
+ cliName: "vercel",
1742
+ cliBinary: "vercel",
1743
+ authCheckCmd: ["vercel", "whoami"],
1744
+ installCmd: "npm i -g vercel",
1745
+ authCmd: "vercel login",
1746
+ deployCmd: "npm run deploy",
1747
+ envVarCmd: "vercel env add"
1748
+ },
1749
+ {
1750
+ platform: "AWS",
1751
+ cliName: "aws",
1752
+ cliBinary: "aws",
1753
+ authCheckCmd: ["aws", "sts", "get-caller-identity"],
1754
+ installCmd: "See https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html",
1755
+ authCmd: "aws configure",
1756
+ deployCmd: "npm run deploy",
1757
+ envVarCmd: "aws ssm put-parameter --name KEY --value VAL --type String"
1758
+ },
1759
+ {
1760
+ platform: "GCP",
1761
+ cliName: "gcloud",
1762
+ cliBinary: "gcloud",
1763
+ authCheckCmd: ["gcloud", "auth", "print-identity-token"],
1764
+ installCmd: "See https://cloud.google.com/sdk/docs/install",
1765
+ authCmd: "gcloud auth login",
1766
+ deployCmd: "npm run deploy",
1767
+ envVarCmd: "gcloud run services update SERVICE --set-env-vars KEY=VAL"
1768
+ },
1769
+ {
1770
+ platform: "Docker",
1771
+ cliName: "docker",
1772
+ cliBinary: "docker",
1773
+ authCheckCmd: ["docker", "info"],
1774
+ installCmd: "See https://docs.docker.com/get-docker/",
1775
+ authCmd: "docker login",
1776
+ deployCmd: "npm run deploy",
1777
+ envVarCmd: "Set variables in docker-compose.yml or .env"
1778
+ },
1779
+ {
1780
+ platform: "Railway",
1781
+ cliName: "railway",
1782
+ cliBinary: "railway",
1783
+ authCheckCmd: ["railway", "whoami"],
1784
+ installCmd: "npm i -g @railway/cli",
1785
+ authCmd: "railway login",
1786
+ deployCmd: "npm run deploy",
1787
+ envVarCmd: "railway variables set KEY=VAL"
1788
+ },
1789
+ {
1790
+ platform: "Fly.io",
1791
+ cliName: "fly",
1792
+ cliBinary: "fly",
1793
+ authCheckCmd: ["fly", "auth", "whoami"],
1794
+ installCmd: "See https://fly.io/docs/flyctl/install/",
1795
+ authCmd: "fly auth login",
1796
+ deployCmd: "npm run deploy",
1797
+ envVarCmd: "fly secrets set KEY=VAL"
1798
+ }
1799
+ ];
1800
+ var KEYWORD_MATCHERS = (() => {
1801
+ const byPlatform = (name) => PLATFORMS.find((p) => p.platform === name);
1802
+ return [
1803
+ { test: (s) => s.includes("amplify"), platform: byPlatform("AWS Amplify") },
1804
+ { test: (s) => s.includes("cdk"), platform: byPlatform("AWS CDK") },
1805
+ { test: (s) => s.includes("sst"), platform: byPlatform("AWS SST") },
1806
+ { test: (s) => s.includes("vercel"), platform: byPlatform("Vercel") },
1807
+ { test: (s) => s.includes("aws") || s.includes("lambda") || s.includes("ec2"), platform: byPlatform("AWS") },
1808
+ { test: (s) => s.includes("gcp") || s.includes("google cloud") || s.includes("cloud run"), platform: byPlatform("GCP") },
1809
+ { test: (s) => s.includes("docker") || s.includes("container"), platform: byPlatform("Docker") },
1810
+ { test: (s) => s.includes("railway"), platform: byPlatform("Railway") },
1811
+ { test: (s) => s.includes("fly.io") || s === "fly" || s.startsWith("fly "), platform: byPlatform("Fly.io") }
1812
+ ];
1813
+ })();
1814
+ var FALLBACK_CONFIG = {
1815
+ platform: "Unknown",
1816
+ cliName: "",
1817
+ cliBinary: "",
1818
+ authCheckCmd: [],
1819
+ installCmd: "",
1820
+ authCmd: "",
1821
+ deployCmd: "npm run deploy",
1822
+ envVarCmd: "See README.md for environment variable instructions"
1823
+ };
1824
+ function normalizePlatform(deploymentComponent) {
1825
+ const lower = deploymentComponent.toLowerCase();
1826
+ for (const matcher of KEYWORD_MATCHERS) {
1827
+ if (matcher.test(lower)) {
1828
+ return matcher.platform;
1829
+ }
1830
+ }
1831
+ return FALLBACK_CONFIG;
1832
+ }
1833
+ function isCliInstalled(binary) {
1834
+ try {
1835
+ execFileSync2("which", [binary], { stdio: "pipe", timeout: 5e3 });
1836
+ return true;
1837
+ } catch {
1838
+ return false;
1839
+ }
1840
+ }
1841
+ function checkAuth(cmd) {
1842
+ if (cmd.length === 0) return null;
1843
+ try {
1844
+ execFileSync2(cmd[0], cmd.slice(1), { stdio: "pipe", timeout: 5e3 });
1845
+ return true;
1846
+ } catch (err) {
1847
+ const nodeErr = err;
1848
+ if (nodeErr.code === "ENOENT" || nodeErr.code === "ETIMEDOUT") {
1849
+ return null;
1850
+ }
1851
+ return false;
1852
+ }
1853
+ }
1854
+ function checkDeployReadiness(deploymentComponent) {
1855
+ const config = normalizePlatform(deploymentComponent);
1856
+ if (config.platform === "Unknown" || config.cliBinary === "") {
1857
+ return {
1858
+ platform: config.platform,
1859
+ cliInstalled: false,
1860
+ cliName: config.cliName,
1861
+ authenticated: null,
1862
+ installCmd: config.installCmd,
1863
+ authCmd: config.authCmd,
1864
+ deployCmd: config.deployCmd,
1865
+ envVarCmd: config.envVarCmd
1866
+ };
1867
+ }
1868
+ const cliInstalled = isCliInstalled(config.cliBinary);
1869
+ let authenticated = null;
1870
+ if (cliInstalled) {
1871
+ authenticated = checkAuth(config.authCheckCmd);
1872
+ }
1873
+ return {
1874
+ platform: config.platform,
1875
+ cliInstalled,
1876
+ cliName: config.cliName,
1877
+ authenticated,
1878
+ installCmd: config.installCmd,
1879
+ authCmd: config.authCmd,
1880
+ deployCmd: config.deployCmd,
1881
+ envVarCmd: config.envVarCmd
1882
+ };
726
1883
  }
727
1884
 
728
1885
  // src/index.ts
729
- async function main() {
730
- intro2();
731
- const progress = await runConversationLoop();
732
- if (!progress) {
733
- outro2("Setup cancelled.");
734
- return;
735
- }
736
- const confirmed = await p2.confirm({
737
- message: "Ready to build this stack?"
1886
+ var INVALIDATION_PROMPT = `You are evaluating whether changing a technology stack decision affects other decisions.
1887
+
1888
+ The user changed their decision. Given the current state of all decisions, determine which OTHER decisions (if any) are now invalid and should be reconsidered.
1889
+
1890
+ Rules:
1891
+ - Only include stages that are GENUINELY affected by the change.
1892
+ - Only affect stages AFTER the changed stage in the ordered list.
1893
+ - Consider whether each decision was dependent on the changed decision.
1894
+ - If nothing needs to change, return empty arrays.
1895
+
1896
+ Examples:
1897
+
1898
+ Changed frontend from Next.js to Astro (backend was "Next.js API routes"):
1899
+ {"clear":["backend"],"add":[],"remove":[]}
1900
+ Reason: Backend was tied to Next.js. If backend had been "Express" (independent), it would NOT be cleared.
1901
+
1902
+ Changed auth from Clerk to Auth.js:
1903
+ {"clear":[],"add":[],"remove":[]}
1904
+ Reason: Swapping auth providers doesn't affect other decisions.
1905
+
1906
+ Changed frontend from Next.js to static HTML:
1907
+ {"clear":["backend","auth","ai"],"add":[],"remove":["payments"]}
1908
+ Reason: Static site fundamentally changes what's viable.
1909
+
1910
+ Respond with ONLY a JSON object: {"clear": [...], "add": [...], "remove": [...]}
1911
+ `;
1912
+ function createInvalidationFn() {
1913
+ return async (changedId, oldValue, newValue, progress, stages) => {
1914
+ const stageList = stages.map((s) => `${s.id} (${s.status}): ${s.summary ?? "no decision"}`).join("\n");
1915
+ const userPrompt = `The user changed "${changedId}" from "${oldValue?.component ?? "none"}" to "${newValue?.component ?? "none"}".
1916
+
1917
+ Current decisions:
1918
+ ${serializeProgress(progress)}
1919
+
1920
+ Current stages:
1921
+ ${stageList}
1922
+
1923
+ What needs to change?`;
1924
+ try {
1925
+ const response = await chat({
1926
+ system: INVALIDATION_PROMPT,
1927
+ messages: [{ role: "user", content: userPrompt }],
1928
+ maxTokens: 1024
1929
+ });
1930
+ const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
1931
+ const parsed = JSON.parse(text);
1932
+ return {
1933
+ clear: Array.isArray(parsed.clear) ? parsed.clear : [],
1934
+ add: Array.isArray(parsed.add) ? parsed.add : [],
1935
+ remove: Array.isArray(parsed.remove) ? parsed.remove : []
1936
+ };
1937
+ } catch {
1938
+ return { clear: [], add: [], remove: [] };
1939
+ }
1940
+ };
1941
+ }
1942
+ async function main(fresh = false) {
1943
+ const cwd = process.cwd();
1944
+ const invalidationFn = createInvalidationFn();
1945
+ process.on("SIGINT", () => {
1946
+ process.exit(0);
738
1947
  });
739
- if (p2.isCancel(confirmed) || !confirmed) {
740
- outro2("No problem \u2014 run stack-agent again to start over.");
741
- return;
742
- }
743
- const success = await runScaffoldLoop(progress);
744
- if (success) {
745
- const nextSteps = [`cd ${progress.projectName}`];
746
- nextSteps.push("cp .env.example .env # fill in your values");
747
- nextSteps.push("npm run dev");
748
- p2.log.step("Next steps:\n " + nextSteps.join("\n "));
749
- outro2("Happy building!");
1948
+ if (fresh) {
1949
+ const tempManager = StageManager.resume(cwd);
1950
+ tempManager?.cleanup();
1951
+ console.log("Session cleared. Starting fresh.\n");
1952
+ }
1953
+ let manager;
1954
+ const existingSession = fresh ? null : StageManager.detect(cwd);
1955
+ if (existingSession) {
1956
+ console.log(`
1957
+ Found saved progress for "${existingSession.progress.projectName ?? "unnamed"}"`);
1958
+ console.log("Run with --fresh to start over.\n");
1959
+ const resumed = StageManager.resume(cwd, invalidationFn);
1960
+ if (!resumed) {
1961
+ console.log("Could not restore session. Starting fresh.");
1962
+ manager = StageManager.start(cwd, invalidationFn);
1963
+ } else {
1964
+ manager = resumed;
1965
+ }
750
1966
  } else {
751
- renderError("Scaffolding encountered errors. Check the output above.");
752
- outro2("You may need to fix issues manually.");
1967
+ manager = StageManager.start(cwd, invalidationFn);
1968
+ }
1969
+ let shouldBuild = false;
1970
+ const ink = withFullScreen(
1971
+ React6.createElement(App, {
1972
+ manager,
1973
+ onBuild: () => {
1974
+ shouldBuild = true;
1975
+ },
1976
+ onExit: () => {
1977
+ shouldBuild = false;
1978
+ }
1979
+ })
1980
+ );
1981
+ await ink.start();
1982
+ await ink.waitUntilExit();
1983
+ if (shouldBuild) {
1984
+ console.log("\nScaffolding your project...\n");
1985
+ const success = await runScaffoldLoop(manager.progress);
1986
+ if (success) {
1987
+ const readiness = manager.progress.deployment ? checkDeployReadiness(manager.progress.deployment.component) : null;
1988
+ renderPostScaffold(manager.progress.projectName, readiness);
1989
+ manager.cleanup();
1990
+ console.log("\nHappy building!\n");
1991
+ } else {
1992
+ console.error("\nScaffolding encountered errors. Check the output above.\n");
1993
+ }
753
1994
  }
754
1995
  }
755
- var command = process.argv[2];
756
- if (!command || command === "init") {
757
- main().catch((err) => {
1996
+ var args = process.argv.slice(2);
1997
+ var command = args[0];
1998
+ var isFresh = args.includes("--fresh");
1999
+ if (!command || command === "init" || command === "--fresh") {
2000
+ main(isFresh).catch((err) => {
758
2001
  console.error(err);
759
2002
  process.exit(1);
760
2003
  });
761
2004
  } else {
762
2005
  console.error(`Unknown command: ${command}`);
763
- console.error("Usage: stack-agent [init]");
2006
+ console.error("Usage: stack-agent [init] [--fresh]");
764
2007
  process.exit(1);
765
2008
  }