forgecraft 1.0.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/CHANGELOG.md +105 -0
- package/LICENSE +21 -0
- package/README.md +458 -0
- package/dist/chunk-2HFEPXBV.js +2808 -0
- package/dist/chunk-2HFEPXBV.js.map +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1405 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +389 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/templates/forge.config.json +11 -0
|
@@ -0,0 +1,2808 @@
|
|
|
1
|
+
// src/core/adapters/nextjs.ts
|
|
2
|
+
var nextjsAdapter = {
|
|
3
|
+
id: "nextjs",
|
|
4
|
+
name: "Next.js",
|
|
5
|
+
language: "typescript",
|
|
6
|
+
scaffoldCommands: [
|
|
7
|
+
"npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias '@/*' --use-npm --no-turbopack"
|
|
8
|
+
],
|
|
9
|
+
buildCommand: "npm run build",
|
|
10
|
+
lintCommand: "npm run lint",
|
|
11
|
+
typecheckCommand: "npx tsc --noEmit",
|
|
12
|
+
devCommand: "npm run dev",
|
|
13
|
+
devPort: 3e3,
|
|
14
|
+
designSupport: true,
|
|
15
|
+
packageManager: "npm",
|
|
16
|
+
requiredFiles: ["package.json", "tsconfig.json", "next.config.ts", "tailwind.config.ts"],
|
|
17
|
+
buildPromptAdditions: `
|
|
18
|
+
FOR NEXT.JS:
|
|
19
|
+
- Use App Router (not Pages Router)
|
|
20
|
+
- Server Components by default \u2014 only use "use client" when genuinely needed
|
|
21
|
+
- Server Actions for mutations
|
|
22
|
+
- Proper metadata and SEO in layout.tsx
|
|
23
|
+
- Dynamic imports for heavy components
|
|
24
|
+
- Use next/image for all images with width, height, alt
|
|
25
|
+
- Use next/link for all internal navigation
|
|
26
|
+
- Use next/font for custom fonts (no Google Fonts CDN)
|
|
27
|
+
|
|
28
|
+
ASSETS & SEO (REQUIRED):
|
|
29
|
+
- Create favicon.ico, icon.svg, and apple-touch-icon.png in public/
|
|
30
|
+
- Create og.png (1200x630 placeholder) for Open Graph sharing
|
|
31
|
+
- Add complete metadata in layout.tsx: title, description, Open Graph tags, Twitter cards
|
|
32
|
+
- Create robots.txt, sitemap.xml, and manifest.json in public/
|
|
33
|
+
- Ensure every page has a unique <title> and meta description
|
|
34
|
+
`.trim(),
|
|
35
|
+
designPromptAdditions: `
|
|
36
|
+
NEXT.JS DESIGN:
|
|
37
|
+
- Components use shadcn/ui + Tailwind CSS
|
|
38
|
+
- Use CSF3 format for Storybook stories
|
|
39
|
+
- Include viewport decorators for mobile (375px) and desktop (1440px)
|
|
40
|
+
`.trim(),
|
|
41
|
+
fileStructure: `
|
|
42
|
+
src/
|
|
43
|
+
\u251C\u2500\u2500 app/
|
|
44
|
+
\u2502 \u251C\u2500\u2500 layout.tsx # Root layout with metadata
|
|
45
|
+
\u2502 \u251C\u2500\u2500 page.tsx # Home page
|
|
46
|
+
\u2502 \u251C\u2500\u2500 globals.css # Global styles + Tailwind
|
|
47
|
+
\u2502 \u2514\u2500\u2500 [route]/
|
|
48
|
+
\u2502 \u2514\u2500\u2500 page.tsx # Route pages
|
|
49
|
+
\u251C\u2500\u2500 components/
|
|
50
|
+
\u2502 \u251C\u2500\u2500 ui/ # shadcn/ui components
|
|
51
|
+
\u2502 \u2514\u2500\u2500 [feature]/ # Feature-specific components
|
|
52
|
+
\u251C\u2500\u2500 lib/
|
|
53
|
+
\u2502 \u2514\u2500\u2500 utils.ts # Shared utilities
|
|
54
|
+
\u2514\u2500\u2500 types/
|
|
55
|
+
\u2514\u2500\u2500 index.ts # TypeScript types
|
|
56
|
+
public/
|
|
57
|
+
\u251C\u2500\u2500 favicon.ico
|
|
58
|
+
\u251C\u2500\u2500 og.png
|
|
59
|
+
\u251C\u2500\u2500 robots.txt
|
|
60
|
+
\u2514\u2500\u2500 sitemap.xml
|
|
61
|
+
`.trim()
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// src/core/adapters/react-vite.ts
|
|
65
|
+
var reactViteAdapter = {
|
|
66
|
+
id: "react",
|
|
67
|
+
name: "React + Vite",
|
|
68
|
+
language: "typescript",
|
|
69
|
+
scaffoldCommands: [
|
|
70
|
+
"npm create vite@latest . -- --template react-ts",
|
|
71
|
+
"npm install",
|
|
72
|
+
"npm install -D tailwindcss @tailwindcss/vite",
|
|
73
|
+
"npm install react-router-dom"
|
|
74
|
+
],
|
|
75
|
+
buildCommand: "npm run build",
|
|
76
|
+
lintCommand: "npm run lint",
|
|
77
|
+
typecheckCommand: "npx tsc --noEmit",
|
|
78
|
+
devCommand: "npm run dev",
|
|
79
|
+
devPort: 5173,
|
|
80
|
+
designSupport: true,
|
|
81
|
+
packageManager: "npm",
|
|
82
|
+
requiredFiles: ["package.json", "tsconfig.json", "vite.config.ts", "index.html"],
|
|
83
|
+
buildPromptAdditions: `
|
|
84
|
+
FOR REACT + VITE:
|
|
85
|
+
- Use React Router v6 with createBrowserRouter for routing
|
|
86
|
+
- Functional components only \u2014 no class components
|
|
87
|
+
- Use TypeScript strict mode throughout
|
|
88
|
+
- Tailwind CSS for styling (configured via @tailwindcss/vite plugin)
|
|
89
|
+
- Use React.lazy() and Suspense for code splitting
|
|
90
|
+
- State management: React context + useReducer for simple state, or Zustand if complex
|
|
91
|
+
- No SSR \u2014 this is a client-side SPA
|
|
92
|
+
- Put entry point in src/main.tsx, root component in src/App.tsx
|
|
93
|
+
- Environment variables: import.meta.env.VITE_* prefix
|
|
94
|
+
|
|
95
|
+
VITE CONFIG:
|
|
96
|
+
- Configure in vite.config.ts
|
|
97
|
+
- Add @tailwindcss/vite plugin
|
|
98
|
+
- Set resolve aliases: "@/" -> "./src/"
|
|
99
|
+
|
|
100
|
+
ASSETS & SEO:
|
|
101
|
+
- favicon.ico and icon.svg in public/
|
|
102
|
+
- og.png (1200x630) in public/
|
|
103
|
+
- Update index.html with proper <title>, meta description, og tags
|
|
104
|
+
- robots.txt in public/
|
|
105
|
+
`.trim(),
|
|
106
|
+
designPromptAdditions: `
|
|
107
|
+
REACT + VITE DESIGN:
|
|
108
|
+
- Components use Tailwind CSS utility classes
|
|
109
|
+
- Use CSF3 format for Storybook stories
|
|
110
|
+
- No Next.js-specific features (no next/image, next/link, etc.)
|
|
111
|
+
- Use standard <img>, <a> tags or react-router <Link>
|
|
112
|
+
`.trim(),
|
|
113
|
+
fileStructure: `
|
|
114
|
+
src/
|
|
115
|
+
\u251C\u2500\u2500 main.tsx # Entry point
|
|
116
|
+
\u251C\u2500\u2500 App.tsx # Root component with router
|
|
117
|
+
\u251C\u2500\u2500 index.css # Global styles + Tailwind
|
|
118
|
+
\u251C\u2500\u2500 components/
|
|
119
|
+
\u2502 \u251C\u2500\u2500 ui/ # Reusable UI components
|
|
120
|
+
\u2502 \u2514\u2500\u2500 [feature]/ # Feature-specific components
|
|
121
|
+
\u251C\u2500\u2500 pages/
|
|
122
|
+
\u2502 \u2514\u2500\u2500 [PageName].tsx # Route pages
|
|
123
|
+
\u251C\u2500\u2500 hooks/
|
|
124
|
+
\u2502 \u2514\u2500\u2500 use[Hook].ts # Custom hooks
|
|
125
|
+
\u251C\u2500\u2500 lib/
|
|
126
|
+
\u2502 \u2514\u2500\u2500 utils.ts # Shared utilities
|
|
127
|
+
\u2514\u2500\u2500 types/
|
|
128
|
+
\u2514\u2500\u2500 index.ts # TypeScript types
|
|
129
|
+
public/
|
|
130
|
+
\u251C\u2500\u2500 favicon.ico
|
|
131
|
+
\u251C\u2500\u2500 og.png
|
|
132
|
+
\u2514\u2500\u2500 robots.txt
|
|
133
|
+
`.trim()
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// src/core/adapters/django.ts
|
|
137
|
+
var djangoAdapter = {
|
|
138
|
+
id: "django",
|
|
139
|
+
name: "Django",
|
|
140
|
+
language: "python",
|
|
141
|
+
scaffoldCommands: [
|
|
142
|
+
"python3 -m venv venv",
|
|
143
|
+
"venv/bin/pip install django djangorestframework django-cors-headers python-dotenv",
|
|
144
|
+
"venv/bin/django-admin startproject config .",
|
|
145
|
+
"venv/bin/python manage.py startapp core"
|
|
146
|
+
],
|
|
147
|
+
buildCommand: "venv/bin/python manage.py check --deploy",
|
|
148
|
+
lintCommand: "venv/bin/python -m py_compile manage.py",
|
|
149
|
+
typecheckCommand: "echo 'Type checking skipped (Python)'",
|
|
150
|
+
devCommand: "venv/bin/python manage.py runserver",
|
|
151
|
+
devPort: 8e3,
|
|
152
|
+
designSupport: false,
|
|
153
|
+
packageManager: "pip",
|
|
154
|
+
requiredFiles: ["manage.py", "config/settings.py", "config/urls.py", "requirements.txt"],
|
|
155
|
+
buildPromptAdditions: `
|
|
156
|
+
FOR DJANGO:
|
|
157
|
+
- Use Django 5.x with Django REST Framework for APIs
|
|
158
|
+
- Class-Based Views for CRUD, function-based views for custom logic
|
|
159
|
+
- Use Django models with proper field types, validators, and Meta classes
|
|
160
|
+
- Migrations: always run makemigrations + migrate after model changes
|
|
161
|
+
- URL patterns in config/urls.py, app-level urls in each app's urls.py
|
|
162
|
+
- Use Django templates (Jinja-style) for server-rendered pages
|
|
163
|
+
- Static files in static/, templates in templates/
|
|
164
|
+
- Settings: use python-dotenv for environment variables
|
|
165
|
+
- Always create a superuser: python manage.py createsuperuser --noinput
|
|
166
|
+
(set DJANGO_SUPERUSER_USERNAME, DJANGO_SUPERUSER_PASSWORD, DJANGO_SUPERUSER_EMAIL)
|
|
167
|
+
- Register models in admin.py for Django Admin access
|
|
168
|
+
- Use django-cors-headers for API CORS configuration
|
|
169
|
+
|
|
170
|
+
SECURITY:
|
|
171
|
+
- CSRF protection enabled by default \u2014 don't disable it
|
|
172
|
+
- Use Django's built-in auth system (User model, login/logout views)
|
|
173
|
+
- Set DEBUG=False in production settings
|
|
174
|
+
- Configure ALLOWED_HOSTS properly
|
|
175
|
+
|
|
176
|
+
AFTER WRITING CODE:
|
|
177
|
+
1. Run: venv/bin/python manage.py makemigrations
|
|
178
|
+
2. Run: venv/bin/python manage.py migrate
|
|
179
|
+
3. Run: venv/bin/python manage.py check --deploy
|
|
180
|
+
4. Fix any warnings or errors before proceeding
|
|
181
|
+
|
|
182
|
+
ALWAYS generate a requirements.txt with: venv/bin/pip freeze > requirements.txt
|
|
183
|
+
`.trim(),
|
|
184
|
+
designPromptAdditions: `
|
|
185
|
+
DJANGO DESIGN:
|
|
186
|
+
- No Storybook \u2014 Django uses server-rendered templates
|
|
187
|
+
- Design phase is skipped for Django projects
|
|
188
|
+
- Focus on clean, functional UI with Django templates + CSS
|
|
189
|
+
`.trim(),
|
|
190
|
+
fileStructure: `
|
|
191
|
+
config/
|
|
192
|
+
\u251C\u2500\u2500 __init__.py
|
|
193
|
+
\u251C\u2500\u2500 settings.py # Django settings
|
|
194
|
+
\u251C\u2500\u2500 urls.py # Root URL configuration
|
|
195
|
+
\u251C\u2500\u2500 wsgi.py # WSGI entry point
|
|
196
|
+
\u2514\u2500\u2500 asgi.py # ASGI entry point
|
|
197
|
+
core/
|
|
198
|
+
\u251C\u2500\u2500 __init__.py
|
|
199
|
+
\u251C\u2500\u2500 admin.py # Admin registrations
|
|
200
|
+
\u251C\u2500\u2500 apps.py # App config
|
|
201
|
+
\u251C\u2500\u2500 models.py # Database models
|
|
202
|
+
\u251C\u2500\u2500 views.py # Views (CBV/FBV)
|
|
203
|
+
\u251C\u2500\u2500 urls.py # App URL patterns
|
|
204
|
+
\u251C\u2500\u2500 serializers.py # DRF serializers
|
|
205
|
+
\u251C\u2500\u2500 forms.py # Django forms
|
|
206
|
+
\u251C\u2500\u2500 tests.py # Tests
|
|
207
|
+
\u251C\u2500\u2500 migrations/
|
|
208
|
+
\u2502 \u2514\u2500\u2500 __init__.py
|
|
209
|
+
\u2514\u2500\u2500 templates/
|
|
210
|
+
\u2514\u2500\u2500 core/ # App templates
|
|
211
|
+
static/
|
|
212
|
+
\u251C\u2500\u2500 css/
|
|
213
|
+
\u251C\u2500\u2500 js/
|
|
214
|
+
\u2514\u2500\u2500 images/
|
|
215
|
+
templates/
|
|
216
|
+
\u2514\u2500\u2500 base.html # Base template
|
|
217
|
+
manage.py # Django CLI
|
|
218
|
+
requirements.txt # Python dependencies
|
|
219
|
+
.env # Environment variables
|
|
220
|
+
`.trim()
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// src/core/adapters/index.ts
|
|
224
|
+
var adapters = {
|
|
225
|
+
nextjs: nextjsAdapter,
|
|
226
|
+
react: reactViteAdapter,
|
|
227
|
+
django: djangoAdapter
|
|
228
|
+
};
|
|
229
|
+
function getAdapter(framework) {
|
|
230
|
+
const adapter = adapters[framework];
|
|
231
|
+
if (!adapter) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Unknown framework "${framework}". Supported: ${Object.keys(adapters).join(", ")}`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return adapter;
|
|
237
|
+
}
|
|
238
|
+
function listAdapters() {
|
|
239
|
+
return Object.values(adapters);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/core/utils/sound.ts
|
|
243
|
+
import { exec } from "child_process";
|
|
244
|
+
import { platform } from "os";
|
|
245
|
+
function playSound() {
|
|
246
|
+
if (!process.stdout.isTTY) return;
|
|
247
|
+
switch (platform()) {
|
|
248
|
+
case "darwin":
|
|
249
|
+
exec("afplay /System/Library/Sounds/Blow.aiff");
|
|
250
|
+
break;
|
|
251
|
+
case "linux":
|
|
252
|
+
exec(
|
|
253
|
+
"paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || printf '\\x07'"
|
|
254
|
+
);
|
|
255
|
+
break;
|
|
256
|
+
default:
|
|
257
|
+
process.stdout.write("\x07");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/core/orchestrator/index.ts
|
|
262
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
263
|
+
import chalk from "chalk";
|
|
264
|
+
|
|
265
|
+
// src/core/orchestrator/prompts.ts
|
|
266
|
+
var ORCHESTRATOR_SYSTEM_PROMPT = `
|
|
267
|
+
You are the Orchestrator \u2014 a senior tech lead managing an AI development sprint for the "Forge" framework.
|
|
268
|
+
|
|
269
|
+
YOUR ROLE:
|
|
270
|
+
You NEVER write code. You plan, delegate, and review. You are the brain of the operation.
|
|
271
|
+
|
|
272
|
+
RESPONSIBILITIES:
|
|
273
|
+
1. Break user requirements into epics and stories
|
|
274
|
+
2. Craft detailed, specific prompts for the Worker agent
|
|
275
|
+
3. Route user feedback to the correct Worker mode
|
|
276
|
+
4. Review Worker output and decide next steps
|
|
277
|
+
5. Maintain project context and sprint state
|
|
278
|
+
|
|
279
|
+
WHEN CREATING STORIES:
|
|
280
|
+
- Each story must be independently buildable
|
|
281
|
+
- UI stories need design phase; backend-only stories skip it
|
|
282
|
+
- Order stories by dependency (foundation/setup first, then data layer, then UI, then integration)
|
|
283
|
+
- Each story should result in working, testable code
|
|
284
|
+
- Story IDs should be kebab-case: "auth-login", "dashboard-layout"
|
|
285
|
+
- First story should always be project setup/scaffolding
|
|
286
|
+
|
|
287
|
+
WHEN CRAFTING WORKER PROMPTS:
|
|
288
|
+
- Include EXACT file paths to create or modify
|
|
289
|
+
- Reference approved designs when available
|
|
290
|
+
- Specify the framework patterns to follow (App Router, Server Components, etc.)
|
|
291
|
+
- Include acceptance criteria \u2014 what "done" looks like
|
|
292
|
+
- Never be vague \u2014 the Worker should not have to guess anything
|
|
293
|
+
- Include the full project context the Worker needs (but not more)
|
|
294
|
+
|
|
295
|
+
WHEN ROUTING USER FEEDBACK:
|
|
296
|
+
- Visual tweak (color, spacing, font, border) \u2192 Worker fix mode, direct code change
|
|
297
|
+
- Layout/UX redesign (page structure, navigation, flow) \u2192 Worker design mode first, then build
|
|
298
|
+
- Bug fix (something broken, error, crash) \u2192 Worker fix mode with debug focus
|
|
299
|
+
- New feature (something that doesn't exist yet) \u2192 Create new story, full pipeline
|
|
300
|
+
- Content change (text, labels, copy) \u2192 Worker fix mode, string replacement
|
|
301
|
+
- Question about the project \u2192 Answer directly, don't route
|
|
302
|
+
- If Worker is currently busy \u2192 Queue the change for after current task
|
|
303
|
+
|
|
304
|
+
WHEN REVIEWING WORKER OUTPUT:
|
|
305
|
+
- Does the output match what was requested?
|
|
306
|
+
- Did the Worker run build/lint/typecheck?
|
|
307
|
+
- Are there any obvious issues?
|
|
308
|
+
- Minor issues: send back to Worker fix mode
|
|
309
|
+
- Major issues: send back to Worker build mode with specific instructions
|
|
310
|
+
|
|
311
|
+
OUTPUT FORMAT:
|
|
312
|
+
Always respond with valid JSON when asked for structured data.
|
|
313
|
+
When answering questions, respond in plain text.
|
|
314
|
+
Never include markdown code fences around JSON output.
|
|
315
|
+
|
|
316
|
+
DESIGN QUALITY STANDARDS:
|
|
317
|
+
When the Worker is in design mode, ensure designs reference real products:
|
|
318
|
+
- Finance/Banking \u2192 Stripe, Mercury, Wise
|
|
319
|
+
- Dashboard/Admin \u2192 Linear, Vercel, Raycast
|
|
320
|
+
- E-commerce \u2192 Shopify Admin, Gumroad
|
|
321
|
+
- Social/Community \u2192 Discord, Slack
|
|
322
|
+
- Content/Blog \u2192 Medium, Ghost, Substack
|
|
323
|
+
- SaaS/Productivity \u2192 Notion, Figma
|
|
324
|
+
- Healthcare/Church/Nonprofit \u2192 warm, trustworthy, accessible
|
|
325
|
+
|
|
326
|
+
NEVER accept generic AI-looking designs (purple gradients, Inter font, rounded white cards on gray).
|
|
327
|
+
`.trim();
|
|
328
|
+
var ROUTING_CLASSIFICATION_PROMPT = `
|
|
329
|
+
Classify the user's message into one of these categories:
|
|
330
|
+
|
|
331
|
+
1. VISUAL_TWEAK \u2014 Small CSS/styling change
|
|
332
|
+
Examples: "make it blue", "bigger font", "add shadow", "more padding"
|
|
333
|
+
|
|
334
|
+
2. REDESIGN \u2014 Significant layout or UX change
|
|
335
|
+
Examples: "redesign the dashboard", "switch to sidebar nav", "make it look like Stripe"
|
|
336
|
+
|
|
337
|
+
3. BUG_FIX \u2014 Something is broken or not working
|
|
338
|
+
Examples: "button doesn't work", "page is blank", "getting an error"
|
|
339
|
+
|
|
340
|
+
4. NEW_FEATURE \u2014 Something that doesn't exist yet
|
|
341
|
+
Examples: "add dark mode", "add recurring donations", "add export to PDF"
|
|
342
|
+
|
|
343
|
+
5. CONTENT_CHANGE \u2014 Text, labels, or copy changes
|
|
344
|
+
Examples: "change 'Submit' to 'Send'", "update the footer text"
|
|
345
|
+
|
|
346
|
+
6. QUESTION \u2014 Asking for information
|
|
347
|
+
Examples: "what stack are we using?", "how many stories left?", "show me the schema"
|
|
348
|
+
|
|
349
|
+
7. PRIORITY_CHANGE \u2014 Wants to reorder or skip stories
|
|
350
|
+
Examples: "do reports next", "skip the settings page for now"
|
|
351
|
+
|
|
352
|
+
Respond with ONLY the category name and a brief routing instruction.
|
|
353
|
+
`.trim();
|
|
354
|
+
|
|
355
|
+
// src/core/orchestrator/index.ts
|
|
356
|
+
var Orchestrator = class {
|
|
357
|
+
config;
|
|
358
|
+
plan = null;
|
|
359
|
+
constructor(config) {
|
|
360
|
+
this.config = config;
|
|
361
|
+
}
|
|
362
|
+
// ── Plan Generation ───────────────────────────────────────
|
|
363
|
+
async generatePlan(description) {
|
|
364
|
+
const adapter = getAdapter(this.config.framework);
|
|
365
|
+
const prompt = `
|
|
366
|
+
The user wants to build the following application:
|
|
367
|
+
"${description}"
|
|
368
|
+
|
|
369
|
+
Framework: ${adapter.name} (${this.config.framework})
|
|
370
|
+
Language: ${adapter.language}
|
|
371
|
+
Design support: ${adapter.designSupport ? "yes (Storybook)" : "no"}
|
|
372
|
+
|
|
373
|
+
Break this down into epics and stories. Each story should be:
|
|
374
|
+
- Small enough to build in one agent session
|
|
375
|
+
- Independently testable
|
|
376
|
+
- Ordered by dependency (foundation first)
|
|
377
|
+
|
|
378
|
+
Story types:
|
|
379
|
+
- "ui" = has a visual component ${adapter.designSupport ? "(needs design phase)" : "(no design phase for this framework)"}
|
|
380
|
+
- "backend" = API/database only (skip design phase)
|
|
381
|
+
- "fullstack" = both UI and backend
|
|
382
|
+
|
|
383
|
+
Return ONLY valid JSON with this structure:
|
|
384
|
+
{
|
|
385
|
+
"project": "project name",
|
|
386
|
+
"framework": "${this.config.framework}",
|
|
387
|
+
"description": "brief description",
|
|
388
|
+
"epics": [
|
|
389
|
+
{
|
|
390
|
+
"id": "epic-id",
|
|
391
|
+
"title": "Epic Title",
|
|
392
|
+
"stories": [
|
|
393
|
+
{
|
|
394
|
+
"id": "story-id",
|
|
395
|
+
"title": "Story Title",
|
|
396
|
+
"description": "What to build",
|
|
397
|
+
"type": "ui" | "backend" | "fullstack",
|
|
398
|
+
"priority": 1,
|
|
399
|
+
"dependencies": ["other-story-id"]
|
|
400
|
+
}
|
|
401
|
+
]
|
|
402
|
+
}
|
|
403
|
+
]
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
No explanation, just the JSON.
|
|
407
|
+
`;
|
|
408
|
+
const resultText = await this.runQuery(prompt, { maxTurns: 3 });
|
|
409
|
+
let rawPlan;
|
|
410
|
+
try {
|
|
411
|
+
rawPlan = JSON.parse(this.cleanJson(resultText));
|
|
412
|
+
} catch {
|
|
413
|
+
throw new Error(
|
|
414
|
+
"Failed to parse plan. The AI returned invalid JSON. Try again with a clearer description."
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (!rawPlan.epics || !Array.isArray(rawPlan.epics)) {
|
|
418
|
+
throw new Error("Invalid plan: missing epics array. Try regenerating.");
|
|
419
|
+
}
|
|
420
|
+
const plan = {
|
|
421
|
+
project: rawPlan.project || "Untitled",
|
|
422
|
+
framework: rawPlan.framework || this.config.framework,
|
|
423
|
+
description: rawPlan.description || description,
|
|
424
|
+
created: (/* @__PURE__ */ new Date()).toISOString(),
|
|
425
|
+
epics: rawPlan.epics.map((epic) => ({
|
|
426
|
+
id: epic.id || `epic-${Date.now()}`,
|
|
427
|
+
title: epic.title || "Untitled Epic",
|
|
428
|
+
status: "planned",
|
|
429
|
+
stories: (epic.stories || []).map((story) => ({
|
|
430
|
+
id: story.id || `story-${Date.now()}`,
|
|
431
|
+
title: story.title || "Untitled Story",
|
|
432
|
+
description: story.description || "",
|
|
433
|
+
type: story.type || "fullstack",
|
|
434
|
+
status: "planned",
|
|
435
|
+
branch: null,
|
|
436
|
+
designApproved: false,
|
|
437
|
+
tags: [],
|
|
438
|
+
priority: story.priority || 1,
|
|
439
|
+
dependencies: story.dependencies || []
|
|
440
|
+
}))
|
|
441
|
+
}))
|
|
442
|
+
};
|
|
443
|
+
this.plan = plan;
|
|
444
|
+
return plan;
|
|
445
|
+
}
|
|
446
|
+
// ── User Input Routing ────────────────────────────────────
|
|
447
|
+
async routeUserInput(message, currentState) {
|
|
448
|
+
const routingPrompt = `
|
|
449
|
+
Current sprint state:
|
|
450
|
+
- Phase: ${currentState.currentPhase}
|
|
451
|
+
- Current story: ${currentState.currentStory || "none"}
|
|
452
|
+
- Worker mode: ${currentState.workerMode || "idle"}
|
|
453
|
+
- Framework: ${this.config.framework}
|
|
454
|
+
|
|
455
|
+
The user said: "${message}"
|
|
456
|
+
|
|
457
|
+
Classify this input and decide what to do.
|
|
458
|
+
Return ONLY valid JSON with this structure:
|
|
459
|
+
{
|
|
460
|
+
"action": "route-to-worker" | "add-story" | "reprioritize" | "answer" | "queue-change",
|
|
461
|
+
"workerMode": "design" | "build" | "review" | "fix" (if routing to worker),
|
|
462
|
+
"response": "your response to the user" (if answering directly),
|
|
463
|
+
"prompt": "detailed prompt for the worker" (if routing to worker),
|
|
464
|
+
"story": { "title": "...", "description": "...", "type": "..." } (if adding a new story)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
Routing rules:
|
|
468
|
+
- Visual tweak (color, spacing, font) \u2192 route-to-worker, mode: fix
|
|
469
|
+
- Layout/UX redesign \u2192 route-to-worker, mode: design
|
|
470
|
+
- Bug fix \u2192 route-to-worker, mode: fix
|
|
471
|
+
- New feature \u2192 add-story (include title, description, type)
|
|
472
|
+
- Content change (text, labels) \u2192 route-to-worker, mode: fix
|
|
473
|
+
- Question \u2192 answer directly
|
|
474
|
+
- If worker is currently active \u2192 queue-change (apply after current task)
|
|
475
|
+
`;
|
|
476
|
+
const resultText = await this.runQuery(routingPrompt, { maxTurns: 1 });
|
|
477
|
+
try {
|
|
478
|
+
return JSON.parse(this.cleanJson(resultText));
|
|
479
|
+
} catch {
|
|
480
|
+
return { action: "answer", response: resultText };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// ── Prompt Crafting ───────────────────────────────────────
|
|
484
|
+
craftWorkerPrompt(story, mode, context) {
|
|
485
|
+
const adapter = getAdapter(this.config.framework);
|
|
486
|
+
switch (mode) {
|
|
487
|
+
case "design":
|
|
488
|
+
return this.craftDesignPrompt(story, context, adapter);
|
|
489
|
+
case "build":
|
|
490
|
+
return this.craftBuildPrompt(story, context, adapter);
|
|
491
|
+
case "review":
|
|
492
|
+
return this.craftReviewPrompt(story, context, adapter);
|
|
493
|
+
case "fix":
|
|
494
|
+
return this.craftFixPrompt(story, context, adapter);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
craftDesignPrompt(story, context, adapter) {
|
|
498
|
+
const refsDir = ".forge/designs/references";
|
|
499
|
+
return `
|
|
500
|
+
You are designing the UI for: "${story.title}"
|
|
501
|
+
|
|
502
|
+
Description: ${story.description}
|
|
503
|
+
App: ${context.plan.project} (${adapter.name})
|
|
504
|
+
Full app context: ${context.plan.description}
|
|
505
|
+
|
|
506
|
+
DESIGN REFERENCES:
|
|
507
|
+
Check if ${refsDir}/ exists and contains reference images.
|
|
508
|
+
If so, read them and match their visual style, color palette, typography, and layout.
|
|
509
|
+
|
|
510
|
+
Create a Storybook story file that renders this component/page.
|
|
511
|
+
The file should be at: stories/${story.id}.stories.tsx
|
|
512
|
+
|
|
513
|
+
Requirements:
|
|
514
|
+
- Must be responsive (mobile-first: 375px, then 768px, then 1440px)
|
|
515
|
+
- Use Tailwind CSS for styling
|
|
516
|
+
- Include realistic placeholder data (not lorem ipsum)
|
|
517
|
+
- Show default, loading, empty, and error states as separate stories
|
|
518
|
+
- Use a distinctive, professional design (not generic AI aesthetics)
|
|
519
|
+
- Choose fonts and colors that match the app's purpose
|
|
520
|
+
|
|
521
|
+
DO NOT write the actual app code. Only the Storybook preview.
|
|
522
|
+
`;
|
|
523
|
+
}
|
|
524
|
+
craftBuildPrompt(story, context, adapter) {
|
|
525
|
+
const designRef = context.designMeta ? `
|
|
526
|
+
Approved design: Follow the design in stories/${story.id}.stories.tsx exactly.` : "";
|
|
527
|
+
const existingRef = context.existingFiles?.length ? `
|
|
528
|
+
Existing files to reference for patterns:
|
|
529
|
+
${context.existingFiles.map((f) => ` - ${f}`).join("\n")}` : "";
|
|
530
|
+
return `
|
|
531
|
+
Implement: "${story.title}"
|
|
532
|
+
|
|
533
|
+
Description: ${story.description}
|
|
534
|
+
App: ${context.plan.project} (${adapter.name})
|
|
535
|
+
${designRef}
|
|
536
|
+
${existingRef}
|
|
537
|
+
|
|
538
|
+
Expected project structure:
|
|
539
|
+
${adapter.fileStructure}
|
|
540
|
+
|
|
541
|
+
Technical requirements:
|
|
542
|
+
- Follow existing code patterns in the project
|
|
543
|
+
- Small, focused components/functions
|
|
544
|
+
- Proper error handling
|
|
545
|
+
- Responsive design (mobile-first)
|
|
546
|
+
|
|
547
|
+
After writing code:
|
|
548
|
+
1. Run: ${adapter.buildCommand} (fix any errors)
|
|
549
|
+
2. Run: ${adapter.lintCommand} (fix any warnings)
|
|
550
|
+
3. Run: ${adapter.typecheckCommand} (fix any type errors)
|
|
551
|
+
|
|
552
|
+
If any command fails, read the error, fix it, and re-run.
|
|
553
|
+
Do NOT leave broken code.
|
|
554
|
+
`;
|
|
555
|
+
}
|
|
556
|
+
craftReviewPrompt(story, context, adapter) {
|
|
557
|
+
return `
|
|
558
|
+
Review the code for: "${story.title}"
|
|
559
|
+
|
|
560
|
+
Framework: ${adapter.name}
|
|
561
|
+
|
|
562
|
+
Check:
|
|
563
|
+
1. Does the implementation match the story description?
|
|
564
|
+
${context.designMeta ? "2. Does it match the approved design?" : ""}
|
|
565
|
+
3. Code quality \u2014 no shortcuts, proper types
|
|
566
|
+
4. Responsive design \u2014 works on mobile and desktop
|
|
567
|
+
5. Error handling \u2014 graceful failures, loading states
|
|
568
|
+
6. Accessibility \u2014 semantic HTML, ARIA labels
|
|
569
|
+
7. No debug logs, no commented-out code, no TODOs
|
|
570
|
+
|
|
571
|
+
Run:
|
|
572
|
+
- ${adapter.buildCommand}
|
|
573
|
+
- ${adapter.lintCommand}
|
|
574
|
+
- ${adapter.typecheckCommand}
|
|
575
|
+
|
|
576
|
+
If you find issues:
|
|
577
|
+
- Minor (formatting, missing type): fix them directly
|
|
578
|
+
- Major (broken logic, missing feature): list them and do NOT fix
|
|
579
|
+
|
|
580
|
+
Return a summary of what you found and what you fixed.
|
|
581
|
+
`;
|
|
582
|
+
}
|
|
583
|
+
craftFixPrompt(story, context, adapter) {
|
|
584
|
+
return `
|
|
585
|
+
Fix an issue in: "${story.title}"
|
|
586
|
+
Framework: ${adapter.name}
|
|
587
|
+
|
|
588
|
+
Make the smallest possible change. Do not refactor.
|
|
589
|
+
After fixing, run ${adapter.buildCommand} and ${adapter.typecheckCommand} to verify.
|
|
590
|
+
`;
|
|
591
|
+
}
|
|
592
|
+
// ── SDK Query Helper ──────────────────────────────────────
|
|
593
|
+
async runQuery(prompt, opts = {}) {
|
|
594
|
+
const maxRetries = 3;
|
|
595
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
596
|
+
try {
|
|
597
|
+
return await this.executeQuery(prompt, opts);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
600
|
+
if (this.isAuthError(msg) && attempt < maxRetries) {
|
|
601
|
+
playSound();
|
|
602
|
+
console.log(chalk.yellow("\n Authentication expired"));
|
|
603
|
+
console.log(chalk.dim(" Run: claude login"));
|
|
604
|
+
console.log(chalk.dim(` Retrying in 30s (attempt ${attempt}/${maxRetries})...
|
|
605
|
+
`));
|
|
606
|
+
await new Promise((r) => setTimeout(r, 3e4));
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
throw new Error("Max retries exceeded");
|
|
613
|
+
}
|
|
614
|
+
async executeQuery(prompt, opts = {}) {
|
|
615
|
+
let resultText = "";
|
|
616
|
+
for await (const msg of query({
|
|
617
|
+
prompt,
|
|
618
|
+
options: {
|
|
619
|
+
model: this.config.model,
|
|
620
|
+
systemPrompt: ORCHESTRATOR_SYSTEM_PROMPT,
|
|
621
|
+
maxTurns: opts.maxTurns,
|
|
622
|
+
allowedTools: []
|
|
623
|
+
}
|
|
624
|
+
})) {
|
|
625
|
+
if (msg.type === "result") {
|
|
626
|
+
if ("result" in msg && typeof msg.result === "string") {
|
|
627
|
+
resultText = msg.result;
|
|
628
|
+
} else if ("errors" in msg && Array.isArray(msg.errors)) {
|
|
629
|
+
throw new Error(`Agent error: ${msg.errors.join(", ")}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (msg.type === "assistant" && msg.message?.content) {
|
|
633
|
+
const textBlocks = msg.message.content.filter((b) => b.type === "text").map((b) => b.text);
|
|
634
|
+
if (textBlocks.length > 0) {
|
|
635
|
+
resultText = textBlocks.join("\n");
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (!resultText) {
|
|
640
|
+
throw new Error("No response from agent");
|
|
641
|
+
}
|
|
642
|
+
return resultText;
|
|
643
|
+
}
|
|
644
|
+
isAuthError(msg) {
|
|
645
|
+
const lower = msg.toLowerCase();
|
|
646
|
+
return lower.includes("401") || lower.includes("unauthorized") || lower.includes("auth") || lower.includes("token expired") || lower.includes("session expired") || lower.includes("not authenticated");
|
|
647
|
+
}
|
|
648
|
+
cleanJson(text) {
|
|
649
|
+
return text.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// src/core/worker/index.ts
|
|
654
|
+
import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
|
|
655
|
+
import chalk2 from "chalk";
|
|
656
|
+
|
|
657
|
+
// src/core/worker/prompts/index.ts
|
|
658
|
+
function getDesignPrompt(framework) {
|
|
659
|
+
const adapter = framework ? getAdapter(framework) : null;
|
|
660
|
+
const additions = adapter?.designPromptAdditions || "";
|
|
661
|
+
return `
|
|
662
|
+
You are a senior UI/UX designer working within the Forge development framework.
|
|
663
|
+
Your job is to create beautiful, professional component previews as Storybook stories.
|
|
664
|
+
|
|
665
|
+
DESIGN PRINCIPLES:
|
|
666
|
+
- Mobile-first: design for 375px, then scale up to 768px and 1440px
|
|
667
|
+
- Use the project's design system (shadcn/ui + Tailwind CSS)
|
|
668
|
+
- Choose distinctive typography \u2014 NEVER use Inter, Arial, or Roboto
|
|
669
|
+
- Commit to a cohesive color palette with a dominant color and sharp accents
|
|
670
|
+
- Add micro-interactions (hover states, transitions)
|
|
671
|
+
- Use realistic placeholder data (real names, real numbers, not "Lorem ipsum")
|
|
672
|
+
- Include all states: default, loading, empty, error, success
|
|
673
|
+
|
|
674
|
+
STORYBOOK OUTPUT FORMAT:
|
|
675
|
+
- Create .stories.tsx files in the stories/ directory
|
|
676
|
+
- Each story file should export a default meta and named story variants
|
|
677
|
+
- Use CSF3 format (Component Story Format)
|
|
678
|
+
- Include args/controls for interactive props
|
|
679
|
+
- Add viewport decorators for mobile/desktop
|
|
680
|
+
|
|
681
|
+
QUALITY BAR:
|
|
682
|
+
Your designs should look like they came from a professional design agency.
|
|
683
|
+
Reference real, well-designed products:
|
|
684
|
+
- Finance \u2192 Stripe, Mercury, Wise
|
|
685
|
+
- Dashboards \u2192 Linear, Vercel
|
|
686
|
+
- E-commerce \u2192 Shopify, Gumroad
|
|
687
|
+
- Social \u2192 Discord, Slack
|
|
688
|
+
|
|
689
|
+
NEVER produce:
|
|
690
|
+
- Purple gradient backgrounds
|
|
691
|
+
- Generic rounded white cards on gray
|
|
692
|
+
- Stock-photo placeholder images
|
|
693
|
+
- Inconsistent spacing or alignment
|
|
694
|
+
- Missing hover/focus states
|
|
695
|
+
|
|
696
|
+
${additions}
|
|
697
|
+
`.trim();
|
|
698
|
+
}
|
|
699
|
+
function getBuildPrompt(framework) {
|
|
700
|
+
const adapter = framework ? getAdapter(framework) : null;
|
|
701
|
+
const additions = adapter?.buildPromptAdditions || "";
|
|
702
|
+
const buildCmd = adapter?.buildCommand || "npm run build";
|
|
703
|
+
const lintCmd = adapter?.lintCommand || "npm run lint";
|
|
704
|
+
const typecheckCmd = adapter?.typecheckCommand || "npx tsc --noEmit";
|
|
705
|
+
return `
|
|
706
|
+
You are a senior fullstack developer working within the Forge development framework.
|
|
707
|
+
Your job is to implement features based on approved designs and story requirements.
|
|
708
|
+
|
|
709
|
+
CODING STANDARDS:
|
|
710
|
+
- Small, focused components and functions (< 100 lines per file)
|
|
711
|
+
- Proper error handling \u2014 try/catch, error boundaries, loading states
|
|
712
|
+
- Responsive design \u2014 mobile-first with Tailwind breakpoints
|
|
713
|
+
- Semantic HTML with accessibility (ARIA labels, keyboard navigation)
|
|
714
|
+
- Clean imports, no circular dependencies
|
|
715
|
+
|
|
716
|
+
${additions}
|
|
717
|
+
|
|
718
|
+
AFTER WRITING CODE:
|
|
719
|
+
1. Run: ${buildCmd} \u2014 fix ALL errors before proceeding
|
|
720
|
+
2. Run: ${lintCmd} \u2014 fix warnings
|
|
721
|
+
3. Run: ${typecheckCmd} \u2014 fix type errors
|
|
722
|
+
|
|
723
|
+
If any command fails:
|
|
724
|
+
- Read the full error output
|
|
725
|
+
- Identify the root cause
|
|
726
|
+
- Fix it
|
|
727
|
+
- Re-run the command
|
|
728
|
+
- Repeat until all checks pass
|
|
729
|
+
|
|
730
|
+
NEVER leave code in a broken state. Every commit must build successfully.
|
|
731
|
+
|
|
732
|
+
GIT:
|
|
733
|
+
- Make small, atomic changes
|
|
734
|
+
- Write descriptive file names and function names
|
|
735
|
+
`.trim();
|
|
736
|
+
}
|
|
737
|
+
function getReviewPrompt(framework) {
|
|
738
|
+
const adapter = framework ? getAdapter(framework) : null;
|
|
739
|
+
const buildCmd = adapter?.buildCommand || "npm run build";
|
|
740
|
+
const lintCmd = adapter?.lintCommand || "npm run lint";
|
|
741
|
+
const typecheckCmd = adapter?.typecheckCommand || "npx tsc --noEmit";
|
|
742
|
+
const lang = adapter?.language || "typescript";
|
|
743
|
+
return `
|
|
744
|
+
You are a QA engineer reviewing code within the Forge development framework.
|
|
745
|
+
Your job is to catch issues before code is merged to main.
|
|
746
|
+
|
|
747
|
+
REVIEW CHECKLIST:
|
|
748
|
+
1. Implementation matches the story description
|
|
749
|
+
2. If design was approved \u2014 implementation matches the design
|
|
750
|
+
${lang === "typescript" ? "3. TypeScript strict compliance (no any, no ts-ignore, no ts-expect-error)" : "3. Python code quality (no bare except, proper type hints where used)"}
|
|
751
|
+
4. Responsive design works at 375px, 768px, 1440px
|
|
752
|
+
5. Error handling \u2014 what happens when things fail?
|
|
753
|
+
6. Loading states \u2014 what does the user see while waiting?
|
|
754
|
+
7. Empty states \u2014 what if there's no data?
|
|
755
|
+
8. Accessibility \u2014 semantic HTML, focus management, screen reader support
|
|
756
|
+
9. No debug logs (console.log / print() for debugging)
|
|
757
|
+
10. No commented-out code
|
|
758
|
+
11. No TODO/FIXME/HACK comments left behind
|
|
759
|
+
12. No hardcoded values that should be configurable
|
|
760
|
+
|
|
761
|
+
RUN THESE COMMANDS:
|
|
762
|
+
- ${buildCmd}
|
|
763
|
+
- ${lintCmd}
|
|
764
|
+
- ${typecheckCmd}
|
|
765
|
+
- npm run test (if tests exist)
|
|
766
|
+
|
|
767
|
+
ISSUE CLASSIFICATION:
|
|
768
|
+
- MINOR: formatting, missing type annotation, unused import
|
|
769
|
+
\u2192 Fix it directly, note what you fixed
|
|
770
|
+
- MAJOR: broken logic, missing error handling, security issue, missing feature
|
|
771
|
+
\u2192 List the issue with file path and line, do NOT attempt to fix
|
|
772
|
+
|
|
773
|
+
OUTPUT:
|
|
774
|
+
Provide a structured review summary:
|
|
775
|
+
- Files reviewed
|
|
776
|
+
- Issues found (with severity)
|
|
777
|
+
- Issues auto-fixed
|
|
778
|
+
- Commands run and their results
|
|
779
|
+
- PASS or FAIL recommendation
|
|
780
|
+
`.trim();
|
|
781
|
+
}
|
|
782
|
+
function getFixPrompt(framework) {
|
|
783
|
+
const adapter = framework ? getAdapter(framework) : null;
|
|
784
|
+
const buildCmd = adapter?.buildCommand || "npm run build";
|
|
785
|
+
const typecheckCmd = adapter?.typecheckCommand || "npx tsc --noEmit";
|
|
786
|
+
return `
|
|
787
|
+
You are a debugger and problem solver within the Forge development framework.
|
|
788
|
+
Your job is to make targeted fixes without breaking anything else.
|
|
789
|
+
|
|
790
|
+
PRINCIPLES:
|
|
791
|
+
- Make the SMALLEST possible change to fix the issue
|
|
792
|
+
- Do NOT refactor surrounding code \u2014 you're here to fix, not improve
|
|
793
|
+
- Read the existing code carefully before changing anything
|
|
794
|
+
- Understand the full context of what you're changing
|
|
795
|
+
|
|
796
|
+
FOR VISUAL TWEAKS:
|
|
797
|
+
- Change only the specific CSS/Tailwind classes needed
|
|
798
|
+
- Verify the change doesn't break other viewports
|
|
799
|
+
- Check both mobile and desktop after changing
|
|
800
|
+
|
|
801
|
+
FOR BUG FIXES:
|
|
802
|
+
- Read error messages and stack traces carefully
|
|
803
|
+
- Trace the issue to its ROOT CAUSE
|
|
804
|
+
- Fix the cause, not the symptom
|
|
805
|
+
- Check if the same bug pattern exists elsewhere
|
|
806
|
+
- Verify the fix by running the relevant command
|
|
807
|
+
|
|
808
|
+
AFTER EVERY FIX:
|
|
809
|
+
1. Run: ${buildCmd}
|
|
810
|
+
2. Run: ${typecheckCmd}
|
|
811
|
+
3. Verify the fix works
|
|
812
|
+
|
|
813
|
+
If your fix introduces new errors, undo it and try a different approach.
|
|
814
|
+
`.trim();
|
|
815
|
+
}
|
|
816
|
+
var DESIGN_SYSTEM_PROMPT = getDesignPrompt();
|
|
817
|
+
var BUILD_SYSTEM_PROMPT = getBuildPrompt();
|
|
818
|
+
var REVIEW_SYSTEM_PROMPT = getReviewPrompt();
|
|
819
|
+
var FIX_SYSTEM_PROMPT = getFixPrompt();
|
|
820
|
+
|
|
821
|
+
// src/core/worker/index.ts
|
|
822
|
+
var MODE_TOOLS = {
|
|
823
|
+
design: ["Read", "Write", "Glob", "LS"],
|
|
824
|
+
build: ["Read", "Write", "Edit", "Bash", "Glob", "LS", "Grep"],
|
|
825
|
+
review: ["Read", "Bash", "Glob", "LS", "Grep"],
|
|
826
|
+
fix: ["Read", "Write", "Edit", "Bash", "Glob", "LS", "Grep"]
|
|
827
|
+
};
|
|
828
|
+
function getModePrompt(mode, framework) {
|
|
829
|
+
switch (mode) {
|
|
830
|
+
case "design":
|
|
831
|
+
return getDesignPrompt(framework);
|
|
832
|
+
case "build":
|
|
833
|
+
return getBuildPrompt(framework);
|
|
834
|
+
case "review":
|
|
835
|
+
return getReviewPrompt(framework);
|
|
836
|
+
case "fix":
|
|
837
|
+
return getFixPrompt(framework);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
var MODE_MAX_TURNS = {
|
|
841
|
+
design: 30,
|
|
842
|
+
build: 50,
|
|
843
|
+
review: 20,
|
|
844
|
+
fix: 15
|
|
845
|
+
};
|
|
846
|
+
var MAX_AUTH_RETRIES = 3;
|
|
847
|
+
var AUTH_RETRY_DELAY_MS = 3e4;
|
|
848
|
+
var Worker = class {
|
|
849
|
+
config;
|
|
850
|
+
sandboxOpts;
|
|
851
|
+
mute;
|
|
852
|
+
constructor(config, sandboxOpts = {}, mute = false) {
|
|
853
|
+
this.config = config;
|
|
854
|
+
this.sandboxOpts = sandboxOpts;
|
|
855
|
+
this.mute = mute;
|
|
856
|
+
}
|
|
857
|
+
async run(mode, prompt, options = {}) {
|
|
858
|
+
for (let attempt = 1; attempt <= MAX_AUTH_RETRIES; attempt++) {
|
|
859
|
+
const result = await this.executeQuery(mode, prompt, options);
|
|
860
|
+
if (!result.success && this.isAuthError(result.errors)) {
|
|
861
|
+
if (attempt < MAX_AUTH_RETRIES) {
|
|
862
|
+
this.notifyAuthError(attempt);
|
|
863
|
+
await this.waitForReauth();
|
|
864
|
+
result.errors = result.errors.filter((e) => !this.isAuthErrorMsg(e));
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
result.errors.push(
|
|
868
|
+
"Authentication failed after retries. Run: claude login"
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
return result;
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
success: false,
|
|
875
|
+
filesCreated: [],
|
|
876
|
+
filesModified: [],
|
|
877
|
+
errors: ["Max retries exceeded"],
|
|
878
|
+
summary: "",
|
|
879
|
+
usage: { inputTokens: 0, outputTokens: 0, costUsd: 0, durationMs: 0 }
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
async executeQuery(mode, prompt, options = {}) {
|
|
883
|
+
let workingDir = options.workingDir || this.sandboxOpts.workingDir;
|
|
884
|
+
if (!workingDir) {
|
|
885
|
+
try {
|
|
886
|
+
workingDir = process.cwd();
|
|
887
|
+
} catch {
|
|
888
|
+
return {
|
|
889
|
+
success: false,
|
|
890
|
+
filesCreated: [],
|
|
891
|
+
filesModified: [],
|
|
892
|
+
errors: ["Working directory no longer exists. Please cd to your project and retry."],
|
|
893
|
+
summary: "",
|
|
894
|
+
usage: { inputTokens: 0, outputTokens: 0, costUsd: 0, durationMs: 0 }
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const { onProgress } = options;
|
|
899
|
+
const result = {
|
|
900
|
+
success: false,
|
|
901
|
+
filesCreated: [],
|
|
902
|
+
filesModified: [],
|
|
903
|
+
errors: [],
|
|
904
|
+
summary: "",
|
|
905
|
+
usage: { inputTokens: 0, outputTokens: 0, costUsd: 0, durationMs: 0 }
|
|
906
|
+
};
|
|
907
|
+
const sdkOptions = {
|
|
908
|
+
model: this.config.model,
|
|
909
|
+
systemPrompt: getModePrompt(mode, this.config.framework),
|
|
910
|
+
allowedTools: MODE_TOOLS[mode],
|
|
911
|
+
cwd: workingDir,
|
|
912
|
+
maxTurns: MODE_MAX_TURNS[mode]
|
|
913
|
+
};
|
|
914
|
+
if (this.sandboxOpts.sandbox) {
|
|
915
|
+
sdkOptions.permissionMode = "bypassPermissions";
|
|
916
|
+
sdkOptions.sandbox = {
|
|
917
|
+
enabled: true,
|
|
918
|
+
autoAllowBashIfSandboxed: true,
|
|
919
|
+
filesystem: {
|
|
920
|
+
allowWrite: [workingDir]
|
|
921
|
+
},
|
|
922
|
+
network: {
|
|
923
|
+
allowedDomains: this.sandboxOpts.allowedDomains || [
|
|
924
|
+
"registry.npmjs.org",
|
|
925
|
+
"api.anthropic.com"
|
|
926
|
+
]
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
for await (const msg of query2({ prompt, options: sdkOptions })) {
|
|
932
|
+
if (msg.type === "assistant") {
|
|
933
|
+
if (msg.message?.usage) {
|
|
934
|
+
result.usage.inputTokens += msg.message.usage.input_tokens || 0;
|
|
935
|
+
result.usage.outputTokens += msg.message.usage.output_tokens || 0;
|
|
936
|
+
}
|
|
937
|
+
for (const block of msg.message?.content || []) {
|
|
938
|
+
if (block.type === "text") {
|
|
939
|
+
onProgress?.({ type: "thinking", content: block.text.slice(0, 120) });
|
|
940
|
+
}
|
|
941
|
+
if (block.type === "tool_use") {
|
|
942
|
+
const detail = this.describeToolUse(block);
|
|
943
|
+
onProgress?.({ type: "tool_use", content: detail, tool: block.name });
|
|
944
|
+
if (block.name === "Write" && block.input?.file_path) {
|
|
945
|
+
result.filesCreated.push(block.input.file_path);
|
|
946
|
+
}
|
|
947
|
+
if (block.name === "Edit" && block.input?.file_path) {
|
|
948
|
+
result.filesModified.push(block.input.file_path);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (msg.type === "tool_progress") {
|
|
954
|
+
onProgress?.({
|
|
955
|
+
type: "tool_running",
|
|
956
|
+
content: msg.tool_name,
|
|
957
|
+
tool: msg.tool_name,
|
|
958
|
+
elapsed: msg.elapsed_time_seconds
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
if (msg.type === "tool_use_summary") {
|
|
962
|
+
onProgress?.({ type: "tool_done", content: msg.summary });
|
|
963
|
+
}
|
|
964
|
+
if (msg.type === "result") {
|
|
965
|
+
if ("result" in msg && typeof msg.result === "string") {
|
|
966
|
+
result.summary = msg.result;
|
|
967
|
+
}
|
|
968
|
+
if ("errors" in msg && Array.isArray(msg.errors) && msg.errors.length > 0) {
|
|
969
|
+
result.errors.push(...msg.errors);
|
|
970
|
+
}
|
|
971
|
+
if ("is_error" in msg && msg.is_error) {
|
|
972
|
+
result.errors.push(msg.subtype || "unknown error");
|
|
973
|
+
}
|
|
974
|
+
if (msg.usage) {
|
|
975
|
+
result.usage.inputTokens = msg.usage.input_tokens || 0;
|
|
976
|
+
result.usage.outputTokens = msg.usage.output_tokens || 0;
|
|
977
|
+
}
|
|
978
|
+
if (typeof msg.total_cost_usd === "number") {
|
|
979
|
+
result.usage.costUsd = msg.total_cost_usd;
|
|
980
|
+
}
|
|
981
|
+
if (typeof msg.duration_ms === "number") {
|
|
982
|
+
result.usage.durationMs = msg.duration_ms;
|
|
983
|
+
}
|
|
984
|
+
if ("is_error" in msg && msg.subtype === "error_max_turns") {
|
|
985
|
+
result.errors.push(`Hit ${MODE_MAX_TURNS[mode]}-turn safety cap \u2014 story may be incomplete`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
result.success = result.errors.length === 0;
|
|
990
|
+
if (!result.summary) {
|
|
991
|
+
result.summary = "Completed without summary.";
|
|
992
|
+
}
|
|
993
|
+
} catch (error) {
|
|
994
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
995
|
+
result.success = false;
|
|
996
|
+
result.errors.push(msg);
|
|
997
|
+
}
|
|
998
|
+
return result;
|
|
999
|
+
}
|
|
1000
|
+
// ── Auth Error Detection ──────────────────────────────────
|
|
1001
|
+
isAuthErrorMsg(msg) {
|
|
1002
|
+
const lower = msg.toLowerCase();
|
|
1003
|
+
return lower.includes("401") || lower.includes("unauthorized") || lower.includes("auth") || lower.includes("token expired") || lower.includes("session expired") || lower.includes("not authenticated") || lower.includes("login required") || lower.includes("credential");
|
|
1004
|
+
}
|
|
1005
|
+
isAuthError(errors) {
|
|
1006
|
+
return errors.some((e) => this.isAuthErrorMsg(e));
|
|
1007
|
+
}
|
|
1008
|
+
notifyAuthError(attempt) {
|
|
1009
|
+
if (!this.mute) playSound();
|
|
1010
|
+
console.log("");
|
|
1011
|
+
console.log(chalk2.yellow(" \u26A0 Authentication expired"));
|
|
1012
|
+
console.log(chalk2.dim(" Your Claude session token has expired."));
|
|
1013
|
+
console.log(chalk2.dim(" Please re-authenticate:"));
|
|
1014
|
+
console.log(chalk2.white(" claude login"));
|
|
1015
|
+
console.log(chalk2.dim(`
|
|
1016
|
+
Waiting to retry (attempt ${attempt}/${MAX_AUTH_RETRIES})...`));
|
|
1017
|
+
console.log(chalk2.dim(" Forge will resume automatically once auth is renewed.\n"));
|
|
1018
|
+
}
|
|
1019
|
+
waitForReauth() {
|
|
1020
|
+
return new Promise((resolve) => setTimeout(resolve, AUTH_RETRY_DELAY_MS));
|
|
1021
|
+
}
|
|
1022
|
+
/** Turn a tool_use block into a short human-readable string */
|
|
1023
|
+
describeToolUse(block) {
|
|
1024
|
+
const name = block.name;
|
|
1025
|
+
const input = block.input || {};
|
|
1026
|
+
switch (name) {
|
|
1027
|
+
case "Write":
|
|
1028
|
+
return `Write ${this.shortPath(input.file_path)}`;
|
|
1029
|
+
case "Edit":
|
|
1030
|
+
return `Edit ${this.shortPath(input.file_path)}`;
|
|
1031
|
+
case "Read":
|
|
1032
|
+
return `Read ${this.shortPath(input.file_path)}`;
|
|
1033
|
+
case "Bash":
|
|
1034
|
+
return `Run ${(input.command || "").slice(0, 60)}`;
|
|
1035
|
+
case "Glob":
|
|
1036
|
+
return `Search ${input.pattern || ""}`;
|
|
1037
|
+
case "Grep":
|
|
1038
|
+
return `Grep ${input.pattern || ""}`;
|
|
1039
|
+
default:
|
|
1040
|
+
return name;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
shortPath(p) {
|
|
1044
|
+
if (!p) return "";
|
|
1045
|
+
const parts = p.split("/");
|
|
1046
|
+
return parts.length > 2 ? ".../" + parts.slice(-2).join("/") : p;
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
// src/core/git/index.ts
|
|
1051
|
+
import simpleGit from "simple-git";
|
|
1052
|
+
import fs from "fs/promises";
|
|
1053
|
+
import path from "path";
|
|
1054
|
+
var GitManager = class {
|
|
1055
|
+
git;
|
|
1056
|
+
basePath;
|
|
1057
|
+
cachedBranch = null;
|
|
1058
|
+
repoVerified = false;
|
|
1059
|
+
constructor(basePath = process.cwd()) {
|
|
1060
|
+
this.basePath = basePath;
|
|
1061
|
+
this.git = simpleGit(basePath);
|
|
1062
|
+
}
|
|
1063
|
+
// ── Branch Operations ─────────────────────────────────────
|
|
1064
|
+
async createBranch(name) {
|
|
1065
|
+
await this.stashDirtyState();
|
|
1066
|
+
const currentBranch = await this.getCurrentBranch();
|
|
1067
|
+
if (currentBranch !== "main") {
|
|
1068
|
+
await this.git.checkout("main");
|
|
1069
|
+
}
|
|
1070
|
+
await this.git.checkoutLocalBranch(name);
|
|
1071
|
+
}
|
|
1072
|
+
async checkout(branch) {
|
|
1073
|
+
await this.stashDirtyState();
|
|
1074
|
+
await this.git.checkout(branch);
|
|
1075
|
+
this.cachedBranch = branch;
|
|
1076
|
+
}
|
|
1077
|
+
async merge(branch) {
|
|
1078
|
+
await this.git.merge([branch, "--no-ff"]);
|
|
1079
|
+
}
|
|
1080
|
+
async deleteBranch(branch) {
|
|
1081
|
+
await this.git.deleteLocalBranch(branch, true);
|
|
1082
|
+
}
|
|
1083
|
+
async getCurrentBranch() {
|
|
1084
|
+
if (this.cachedBranch) return this.cachedBranch;
|
|
1085
|
+
try {
|
|
1086
|
+
const status = await this.git.status();
|
|
1087
|
+
if (status.current) {
|
|
1088
|
+
this.cachedBranch = status.current;
|
|
1089
|
+
return this.cachedBranch;
|
|
1090
|
+
}
|
|
1091
|
+
} catch {
|
|
1092
|
+
}
|
|
1093
|
+
return "main";
|
|
1094
|
+
}
|
|
1095
|
+
async listBranches() {
|
|
1096
|
+
const result = await this.git.branchLocal();
|
|
1097
|
+
return result.all;
|
|
1098
|
+
}
|
|
1099
|
+
// ── Commit Operations ─────────────────────────────────────
|
|
1100
|
+
async commitAll(message) {
|
|
1101
|
+
await this.git.add(".");
|
|
1102
|
+
try {
|
|
1103
|
+
await this.git.commit(message);
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
if (!err?.message?.includes("nothing to commit")) throw err;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
async commitState(message) {
|
|
1109
|
+
await this.git.add(".forge/");
|
|
1110
|
+
await this.git.add("forge.config.json");
|
|
1111
|
+
const status = await this.git.status();
|
|
1112
|
+
if (status.staged.length > 0) {
|
|
1113
|
+
await this.git.commit(`forge: ${message}`);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// ── Tag Operations ────────────────────────────────────────
|
|
1117
|
+
async tag(name) {
|
|
1118
|
+
await this.git.addTag(name);
|
|
1119
|
+
}
|
|
1120
|
+
async listTags() {
|
|
1121
|
+
const result = await this.git.tags();
|
|
1122
|
+
return result.all.filter((t) => t.startsWith("forge/"));
|
|
1123
|
+
}
|
|
1124
|
+
async checkoutTag(tagName) {
|
|
1125
|
+
await this.stashDirtyState();
|
|
1126
|
+
await this.git.checkout(tagName);
|
|
1127
|
+
}
|
|
1128
|
+
// ── Diff & History ────────────────────────────────────────
|
|
1129
|
+
async getDiff(branch) {
|
|
1130
|
+
return this.git.diff(["main", branch]);
|
|
1131
|
+
}
|
|
1132
|
+
/** Diff between any two refs (tags, commits, branches) */
|
|
1133
|
+
async getDiff2(ref1, ref2) {
|
|
1134
|
+
return this.git.diff([ref1, ref2]);
|
|
1135
|
+
}
|
|
1136
|
+
async getLog(count = 10) {
|
|
1137
|
+
const log = await this.git.log({ maxCount: count });
|
|
1138
|
+
return log.all;
|
|
1139
|
+
}
|
|
1140
|
+
/** Get the current HEAD commit hash */
|
|
1141
|
+
async getHead() {
|
|
1142
|
+
const log = await this.git.log({ maxCount: 1 });
|
|
1143
|
+
return log.latest?.hash || "";
|
|
1144
|
+
}
|
|
1145
|
+
/** Revert a specific commit (creates a new revert commit) */
|
|
1146
|
+
async revertCommit(hash) {
|
|
1147
|
+
await this.git.raw(["revert", "--no-edit", hash]);
|
|
1148
|
+
}
|
|
1149
|
+
/** Get forge-related commits (feat:, fix:, forge:, docs:, ci:) */
|
|
1150
|
+
async getForgeLog(count = 20) {
|
|
1151
|
+
const log = await this.git.log({ maxCount: count });
|
|
1152
|
+
return log.all.map((entry) => ({
|
|
1153
|
+
hash: entry.hash,
|
|
1154
|
+
date: entry.date,
|
|
1155
|
+
message: entry.message
|
|
1156
|
+
}));
|
|
1157
|
+
}
|
|
1158
|
+
/** Get tags pointing at a specific commit */
|
|
1159
|
+
async getTagsAtCommit(hash) {
|
|
1160
|
+
try {
|
|
1161
|
+
const result = await this.git.raw(["tag", "--points-at", hash]);
|
|
1162
|
+
return result.trim().split("\n").filter(Boolean);
|
|
1163
|
+
} catch {
|
|
1164
|
+
return [];
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
// ── Push Operations ─────────────────────────────────────
|
|
1168
|
+
/** Check if a remote named 'origin' exists */
|
|
1169
|
+
async hasRemote() {
|
|
1170
|
+
try {
|
|
1171
|
+
const remotes = await this.git.getRemotes();
|
|
1172
|
+
return remotes.some((r) => r.name === "origin");
|
|
1173
|
+
} catch {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
/** Push current branch to origin */
|
|
1178
|
+
async push() {
|
|
1179
|
+
const branch = await this.getCurrentBranch();
|
|
1180
|
+
await this.git.push("origin", branch, ["--set-upstream"]);
|
|
1181
|
+
}
|
|
1182
|
+
/** Push tags to origin */
|
|
1183
|
+
async pushTags() {
|
|
1184
|
+
await this.git.pushTags("origin");
|
|
1185
|
+
}
|
|
1186
|
+
// ── Status ────────────────────────────────────────────────
|
|
1187
|
+
async isClean() {
|
|
1188
|
+
const status = await this.git.status();
|
|
1189
|
+
return status.isClean();
|
|
1190
|
+
}
|
|
1191
|
+
async hasUncommittedChanges() {
|
|
1192
|
+
const status = await this.git.status();
|
|
1193
|
+
return !status.isClean();
|
|
1194
|
+
}
|
|
1195
|
+
// ── Init (for new projects) ───────────────────────────────
|
|
1196
|
+
async ensureRepo() {
|
|
1197
|
+
if (this.repoVerified) return;
|
|
1198
|
+
const isRepo = await this.git.checkIsRepo();
|
|
1199
|
+
if (!isRepo) {
|
|
1200
|
+
await this.git.init();
|
|
1201
|
+
}
|
|
1202
|
+
await this.ensureGitignore();
|
|
1203
|
+
const log = await this.git.log().catch(() => null);
|
|
1204
|
+
if (!log || log.total === 0) {
|
|
1205
|
+
await this.git.add(".");
|
|
1206
|
+
await this.git.commit("Initial commit");
|
|
1207
|
+
}
|
|
1208
|
+
this.repoVerified = true;
|
|
1209
|
+
}
|
|
1210
|
+
async ensureMainBranch() {
|
|
1211
|
+
const current = await this.getCurrentBranch();
|
|
1212
|
+
if (current !== "main") {
|
|
1213
|
+
const branches = await this.listBranches();
|
|
1214
|
+
if (!branches.includes("main")) {
|
|
1215
|
+
await this.git.branch(["-M", "main"]);
|
|
1216
|
+
this.cachedBranch = "main";
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// ── Internal ──────────────────────────────────────────────
|
|
1221
|
+
/** Commit any uncommitted changes before branch operations */
|
|
1222
|
+
async stashDirtyState() {
|
|
1223
|
+
const status = await this.git.status();
|
|
1224
|
+
if (status.isClean()) return;
|
|
1225
|
+
await this.git.add(".");
|
|
1226
|
+
await this.git.commit("forge: save state before branch switch");
|
|
1227
|
+
}
|
|
1228
|
+
/** Ensure .gitignore covers build artifacts and forge internals */
|
|
1229
|
+
async ensureGitignore() {
|
|
1230
|
+
const gitignorePath = path.join(this.basePath, ".gitignore");
|
|
1231
|
+
const requiredEntries = [
|
|
1232
|
+
"node_modules/",
|
|
1233
|
+
".next/",
|
|
1234
|
+
".forge/snapshots/",
|
|
1235
|
+
"dist/",
|
|
1236
|
+
".env",
|
|
1237
|
+
".env.local"
|
|
1238
|
+
];
|
|
1239
|
+
let content = "";
|
|
1240
|
+
try {
|
|
1241
|
+
content = await fs.readFile(gitignorePath, "utf-8");
|
|
1242
|
+
} catch {
|
|
1243
|
+
}
|
|
1244
|
+
const missing = requiredEntries.filter((e) => !content.includes(e));
|
|
1245
|
+
if (missing.length > 0) {
|
|
1246
|
+
const addition = (content ? "\n" : "") + missing.join("\n") + "\n";
|
|
1247
|
+
await fs.appendFile(gitignorePath, addition);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
// src/state/index.ts
|
|
1253
|
+
import fs2 from "fs/promises";
|
|
1254
|
+
import path2 from "path";
|
|
1255
|
+
var FORGE_DIR = ".forge";
|
|
1256
|
+
var PLAN_FILE = "plan.json";
|
|
1257
|
+
var STATE_FILE = "state.json";
|
|
1258
|
+
var CONFIG_FILE = "forge.config.json";
|
|
1259
|
+
var SNAPSHOTS_DIR = "snapshots";
|
|
1260
|
+
var StateManager = class {
|
|
1261
|
+
basePath;
|
|
1262
|
+
cache = /* @__PURE__ */ new Map();
|
|
1263
|
+
dirCreated = /* @__PURE__ */ new Set();
|
|
1264
|
+
constructor(basePath = process.cwd()) {
|
|
1265
|
+
this.basePath = basePath;
|
|
1266
|
+
}
|
|
1267
|
+
/** Clear all caches (call when basePath changes) */
|
|
1268
|
+
clearCache() {
|
|
1269
|
+
this.cache.clear();
|
|
1270
|
+
this.dirCreated.clear();
|
|
1271
|
+
}
|
|
1272
|
+
// ── Paths ─────────────────────────────────────────────────
|
|
1273
|
+
forgePath(...parts) {
|
|
1274
|
+
return path2.join(this.basePath, FORGE_DIR, ...parts);
|
|
1275
|
+
}
|
|
1276
|
+
rootPath(...parts) {
|
|
1277
|
+
return path2.join(this.basePath, ...parts);
|
|
1278
|
+
}
|
|
1279
|
+
// ── Plan ──────────────────────────────────────────────────
|
|
1280
|
+
async savePlan(plan) {
|
|
1281
|
+
await this.writeJson(this.forgePath(PLAN_FILE), plan);
|
|
1282
|
+
}
|
|
1283
|
+
async getPlan() {
|
|
1284
|
+
return this.readJson(this.forgePath(PLAN_FILE));
|
|
1285
|
+
}
|
|
1286
|
+
async hasPlan() {
|
|
1287
|
+
return this.fileExists(this.forgePath(PLAN_FILE));
|
|
1288
|
+
}
|
|
1289
|
+
// ── State ─────────────────────────────────────────────────
|
|
1290
|
+
async getState() {
|
|
1291
|
+
const state = await this.readJson(
|
|
1292
|
+
this.forgePath(STATE_FILE)
|
|
1293
|
+
);
|
|
1294
|
+
return state || {
|
|
1295
|
+
currentPhase: "init",
|
|
1296
|
+
currentStory: null,
|
|
1297
|
+
workerMode: null,
|
|
1298
|
+
queue: [],
|
|
1299
|
+
history: []
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
async updatePhase(phase) {
|
|
1303
|
+
const state = await this.getState();
|
|
1304
|
+
state.currentPhase = phase;
|
|
1305
|
+
await this.writeJson(this.forgePath(STATE_FILE), state);
|
|
1306
|
+
}
|
|
1307
|
+
async updateState(updates) {
|
|
1308
|
+
const state = await this.getState();
|
|
1309
|
+
Object.assign(state, updates);
|
|
1310
|
+
await this.writeJson(this.forgePath(STATE_FILE), state);
|
|
1311
|
+
}
|
|
1312
|
+
async addHistoryEntry(entry) {
|
|
1313
|
+
const state = await this.getState();
|
|
1314
|
+
state.history.push({
|
|
1315
|
+
...entry,
|
|
1316
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1317
|
+
snapshotId: entry.snapshotId || null
|
|
1318
|
+
});
|
|
1319
|
+
await this.writeJson(this.forgePath(STATE_FILE), state);
|
|
1320
|
+
}
|
|
1321
|
+
// ── Config ────────────────────────────────────────────────
|
|
1322
|
+
async getConfig() {
|
|
1323
|
+
return this.readJson(this.rootPath(CONFIG_FILE));
|
|
1324
|
+
}
|
|
1325
|
+
async isInitialized() {
|
|
1326
|
+
return this.fileExists(this.rootPath(CONFIG_FILE));
|
|
1327
|
+
}
|
|
1328
|
+
// ── Snapshots ─────────────────────────────────────────────
|
|
1329
|
+
async saveSnapshot(data) {
|
|
1330
|
+
const state = await this.getState();
|
|
1331
|
+
const id = String(state.history.length + 1).padStart(3, "0");
|
|
1332
|
+
const snapshot = {
|
|
1333
|
+
id,
|
|
1334
|
+
action: data.action,
|
|
1335
|
+
storyId: data.storyId,
|
|
1336
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1337
|
+
files: [],
|
|
1338
|
+
branch: data.branch,
|
|
1339
|
+
commitBefore: data.commitBefore || ""
|
|
1340
|
+
};
|
|
1341
|
+
await this.writeJson(
|
|
1342
|
+
this.forgePath(SNAPSHOTS_DIR, `${id}-pre-${data.action}-${data.storyId || "global"}.json`),
|
|
1343
|
+
snapshot
|
|
1344
|
+
);
|
|
1345
|
+
await this.addHistoryEntry({
|
|
1346
|
+
action: data.action,
|
|
1347
|
+
storyId: data.storyId,
|
|
1348
|
+
details: `Snapshot taken before ${data.action}`,
|
|
1349
|
+
snapshotId: id
|
|
1350
|
+
});
|
|
1351
|
+
return id;
|
|
1352
|
+
}
|
|
1353
|
+
async getSnapshot(id) {
|
|
1354
|
+
const dir = this.forgePath(SNAPSHOTS_DIR);
|
|
1355
|
+
const files = await fs2.readdir(dir).catch(() => []);
|
|
1356
|
+
const match = files.find((f) => f.startsWith(id));
|
|
1357
|
+
if (!match) return null;
|
|
1358
|
+
return this.readJson(path2.join(dir, match));
|
|
1359
|
+
}
|
|
1360
|
+
async listSnapshots() {
|
|
1361
|
+
const dir = this.forgePath(SNAPSHOTS_DIR);
|
|
1362
|
+
const files = await fs2.readdir(dir).catch(() => []);
|
|
1363
|
+
const snapshots = [];
|
|
1364
|
+
for (const file of files.filter((f) => f.endsWith(".json"))) {
|
|
1365
|
+
const snapshot = await this.readJson(path2.join(dir, file));
|
|
1366
|
+
if (snapshot) snapshots.push(snapshot);
|
|
1367
|
+
}
|
|
1368
|
+
return snapshots.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1369
|
+
}
|
|
1370
|
+
// ── Design References ──────────────────────────────────────
|
|
1371
|
+
async saveDesignReferences(files) {
|
|
1372
|
+
const refDir = this.forgePath("designs", "references");
|
|
1373
|
+
await fs2.mkdir(refDir, { recursive: true });
|
|
1374
|
+
const manifest = [];
|
|
1375
|
+
for (const file of files) {
|
|
1376
|
+
const basename = path2.basename(file);
|
|
1377
|
+
const dest = path2.join(refDir, basename);
|
|
1378
|
+
await fs2.copyFile(file, dest);
|
|
1379
|
+
manifest.push({ original: file, saved: dest });
|
|
1380
|
+
}
|
|
1381
|
+
await this.writeJson(this.forgePath("designs", "references.json"), manifest);
|
|
1382
|
+
}
|
|
1383
|
+
async getDesignReferences() {
|
|
1384
|
+
const manifest = await this.readJson(
|
|
1385
|
+
this.forgePath("designs", "references.json")
|
|
1386
|
+
);
|
|
1387
|
+
return manifest?.map((r) => r.saved) || [];
|
|
1388
|
+
}
|
|
1389
|
+
// ── Helpers ───────────────────────────────────────────────
|
|
1390
|
+
async readJson(filePath) {
|
|
1391
|
+
try {
|
|
1392
|
+
const stat = await fs2.stat(filePath);
|
|
1393
|
+
const mtime = stat.mtimeMs;
|
|
1394
|
+
const cached = this.cache.get(filePath);
|
|
1395
|
+
if (cached && cached.mtime === mtime) {
|
|
1396
|
+
return cached.data;
|
|
1397
|
+
}
|
|
1398
|
+
const content = await fs2.readFile(filePath, "utf-8");
|
|
1399
|
+
const data = JSON.parse(content);
|
|
1400
|
+
this.cache.set(filePath, { data, mtime });
|
|
1401
|
+
return data;
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
if (err?.code === "ENOENT") return null;
|
|
1404
|
+
if (err instanceof SyntaxError) return null;
|
|
1405
|
+
throw err;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
async writeJson(filePath, data) {
|
|
1409
|
+
const dir = path2.dirname(filePath);
|
|
1410
|
+
if (!this.dirCreated.has(dir)) {
|
|
1411
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
1412
|
+
this.dirCreated.add(dir);
|
|
1413
|
+
}
|
|
1414
|
+
const json = JSON.stringify(data, null, 2);
|
|
1415
|
+
await fs2.writeFile(filePath, json);
|
|
1416
|
+
const stat = await fs2.stat(filePath);
|
|
1417
|
+
this.cache.set(filePath, { data, mtime: stat.mtimeMs });
|
|
1418
|
+
}
|
|
1419
|
+
async fileExists(filePath) {
|
|
1420
|
+
try {
|
|
1421
|
+
await fs2.access(filePath);
|
|
1422
|
+
return true;
|
|
1423
|
+
} catch {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
var stateManager = new StateManager();
|
|
1429
|
+
|
|
1430
|
+
// src/core/pipeline/index.ts
|
|
1431
|
+
import chalk3 from "chalk";
|
|
1432
|
+
import ora from "ora";
|
|
1433
|
+
import inquirer from "inquirer";
|
|
1434
|
+
var MAX_REGEN_ATTEMPTS = 5;
|
|
1435
|
+
var Pipeline = class {
|
|
1436
|
+
orchestrator;
|
|
1437
|
+
worker;
|
|
1438
|
+
git;
|
|
1439
|
+
config;
|
|
1440
|
+
plan = null;
|
|
1441
|
+
changeQueue = [];
|
|
1442
|
+
constructor(config) {
|
|
1443
|
+
this.config = config;
|
|
1444
|
+
this.orchestrator = new Orchestrator(config);
|
|
1445
|
+
this.worker = new Worker(config, {});
|
|
1446
|
+
this.git = new GitManager();
|
|
1447
|
+
}
|
|
1448
|
+
// ── Full Sprint (all phases) ──────────────────────────────
|
|
1449
|
+
async runSprint(description) {
|
|
1450
|
+
console.log(chalk3.bold("\n forge") + chalk3.dim(" sprint\n"));
|
|
1451
|
+
await this.git.ensureRepo();
|
|
1452
|
+
await this.git.ensureMainBranch();
|
|
1453
|
+
this.plan = await this.runPlanPhase(description);
|
|
1454
|
+
if (!this.plan) return;
|
|
1455
|
+
const adapter = getAdapter(this.config.framework);
|
|
1456
|
+
if (adapter.designSupport) {
|
|
1457
|
+
await this.runDesignPhase(this.plan);
|
|
1458
|
+
} else {
|
|
1459
|
+
console.log(chalk3.dim(`
|
|
1460
|
+
Design skipped (${adapter.name} \u2014 no Storybook support)
|
|
1461
|
+
`));
|
|
1462
|
+
}
|
|
1463
|
+
await this.runBuildPhase(this.plan);
|
|
1464
|
+
await this.runReviewPhase(this.plan);
|
|
1465
|
+
console.log(chalk3.dim("\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1466
|
+
console.log(chalk3.bold(" Sprint complete\n"));
|
|
1467
|
+
console.log(chalk3.dim(" Run your app and test it."));
|
|
1468
|
+
console.log(chalk3.dim(" Use " + chalk3.white('forge fix "description"') + " to make changes."));
|
|
1469
|
+
console.log(chalk3.dim(" Use " + chalk3.white("forge undo") + " to revert.\n"));
|
|
1470
|
+
}
|
|
1471
|
+
// ── Phase 1: Plan ────────────────────────────────────────
|
|
1472
|
+
async runPlanPhase(description, attempt = 1) {
|
|
1473
|
+
if (attempt === 1) {
|
|
1474
|
+
console.log(chalk3.bold(" Plan\n"));
|
|
1475
|
+
}
|
|
1476
|
+
const spinner = ora({ text: "Analyzing requirements...", indent: 2 }).start();
|
|
1477
|
+
let plan;
|
|
1478
|
+
try {
|
|
1479
|
+
plan = await this.orchestrator.generatePlan(description);
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
spinner.fail("Plan generation failed");
|
|
1482
|
+
console.log(chalk3.red(` ${err instanceof Error ? err.message : err}`));
|
|
1483
|
+
return null;
|
|
1484
|
+
}
|
|
1485
|
+
spinner.succeed("Plan generated");
|
|
1486
|
+
this.displayPlan(plan);
|
|
1487
|
+
const { action } = await inquirer.prompt([
|
|
1488
|
+
{
|
|
1489
|
+
type: "list",
|
|
1490
|
+
name: "action",
|
|
1491
|
+
message: "Action",
|
|
1492
|
+
choices: [
|
|
1493
|
+
{ name: "Approve plan", value: "approve" },
|
|
1494
|
+
{ name: "Edit (describe changes)", value: "edit" },
|
|
1495
|
+
{ name: "Regenerate", value: "regen" },
|
|
1496
|
+
{ name: "Cancel", value: "cancel" }
|
|
1497
|
+
]
|
|
1498
|
+
}
|
|
1499
|
+
]);
|
|
1500
|
+
switch (action) {
|
|
1501
|
+
case "approve":
|
|
1502
|
+
await stateManager.savePlan(plan);
|
|
1503
|
+
await stateManager.updatePhase("plan");
|
|
1504
|
+
await this.git.commitState("Sprint plan approved");
|
|
1505
|
+
await this.git.tag("forge/v0.0-plan");
|
|
1506
|
+
console.log(chalk3.green(" Plan saved\n"));
|
|
1507
|
+
return plan;
|
|
1508
|
+
case "edit": {
|
|
1509
|
+
if (attempt >= MAX_REGEN_ATTEMPTS) {
|
|
1510
|
+
console.log(chalk3.yellow(` Max edit attempts (${MAX_REGEN_ATTEMPTS}) reached. Approving current plan.`));
|
|
1511
|
+
await stateManager.savePlan(plan);
|
|
1512
|
+
return plan;
|
|
1513
|
+
}
|
|
1514
|
+
const { changes } = await inquirer.prompt([
|
|
1515
|
+
{ type: "input", name: "changes", message: "Describe changes:" }
|
|
1516
|
+
]);
|
|
1517
|
+
console.log(chalk3.dim(" Re-planning..."));
|
|
1518
|
+
return this.runPlanPhase(`${description}
|
|
1519
|
+
|
|
1520
|
+
User edits: ${changes}`, attempt + 1);
|
|
1521
|
+
}
|
|
1522
|
+
case "regen": {
|
|
1523
|
+
if (attempt >= MAX_REGEN_ATTEMPTS) {
|
|
1524
|
+
console.log(chalk3.yellow(` Max regeneration attempts (${MAX_REGEN_ATTEMPTS}) reached.`));
|
|
1525
|
+
return null;
|
|
1526
|
+
}
|
|
1527
|
+
return this.runPlanPhase(description, attempt + 1);
|
|
1528
|
+
}
|
|
1529
|
+
case "cancel":
|
|
1530
|
+
console.log(chalk3.dim(" Cancelled."));
|
|
1531
|
+
return null;
|
|
1532
|
+
default:
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
// ── Phase 2: Design ──────────────────────────────────────
|
|
1537
|
+
async runDesignPhase(plan) {
|
|
1538
|
+
const uiStories = this.getStoriesByType(plan, ["ui", "fullstack"]);
|
|
1539
|
+
if (uiStories.length === 0) {
|
|
1540
|
+
console.log(chalk3.dim("\n No UI stories, skipping design.\n"));
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
console.log(chalk3.bold(`
|
|
1544
|
+
Design`) + chalk3.dim(` \xB7 ${uiStories.length} stories
|
|
1545
|
+
`));
|
|
1546
|
+
for (const story of uiStories) {
|
|
1547
|
+
const spinner = ora({ text: story.title, indent: 4 }).start();
|
|
1548
|
+
const prompt = this.orchestrator.craftWorkerPrompt(story, "design", { plan });
|
|
1549
|
+
const result = await this.worker.run("design", prompt, {
|
|
1550
|
+
onProgress: (event) => {
|
|
1551
|
+
if (event.type === "tool_use") {
|
|
1552
|
+
spinner.text = event.content;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
if (result.success) {
|
|
1557
|
+
spinner.succeed(story.title);
|
|
1558
|
+
} else {
|
|
1559
|
+
spinner.fail(story.title);
|
|
1560
|
+
console.log(chalk3.red(` ${result.errors.join(", ")}`));
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
const adapter = getAdapter(this.config.framework);
|
|
1564
|
+
console.log(chalk3.dim(` Preview: http://localhost:${adapter.devPort}
|
|
1565
|
+
`));
|
|
1566
|
+
const { approval } = await inquirer.prompt([
|
|
1567
|
+
{
|
|
1568
|
+
type: "list",
|
|
1569
|
+
name: "approval",
|
|
1570
|
+
message: `Approve "${story.title}"?`,
|
|
1571
|
+
choices: [
|
|
1572
|
+
{ name: "Approve", value: "approve" },
|
|
1573
|
+
{ name: "Request changes", value: "change" },
|
|
1574
|
+
{ name: "Skip", value: "skip" }
|
|
1575
|
+
]
|
|
1576
|
+
}
|
|
1577
|
+
]);
|
|
1578
|
+
if (approval === "approve") {
|
|
1579
|
+
story.designApproved = true;
|
|
1580
|
+
story.status = "design-approved";
|
|
1581
|
+
console.log(chalk3.green(` Approved
|
|
1582
|
+
`));
|
|
1583
|
+
} else if (approval === "change") {
|
|
1584
|
+
const { feedback } = await inquirer.prompt([
|
|
1585
|
+
{ type: "input", name: "feedback", message: "Describe changes:" }
|
|
1586
|
+
]);
|
|
1587
|
+
const fixResult = await this.worker.run("fix", `Revise design for "${story.title}": ${feedback}`);
|
|
1588
|
+
if (fixResult.success) {
|
|
1589
|
+
console.log(chalk3.dim(" Revised.\n"));
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
await stateManager.savePlan(plan);
|
|
1594
|
+
await stateManager.updatePhase("design");
|
|
1595
|
+
await this.git.commitAll("forge: designs reviewed");
|
|
1596
|
+
await this.git.tag("forge/v0.1-designs");
|
|
1597
|
+
console.log(chalk3.dim(" Design phase complete\n"));
|
|
1598
|
+
}
|
|
1599
|
+
// ── Phase 3: Build ───────────────────────────────────────
|
|
1600
|
+
async runBuildPhase(plan) {
|
|
1601
|
+
const buildableStories = this.getAllStories(plan).filter(
|
|
1602
|
+
(s) => s.status === "design-approved" || s.status === "planned"
|
|
1603
|
+
);
|
|
1604
|
+
if (buildableStories.length === 0) {
|
|
1605
|
+
console.log(chalk3.dim("\n No stories to build.\n"));
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
console.log(chalk3.bold(`
|
|
1609
|
+
Build`) + chalk3.dim(` \xB7 ${buildableStories.length} stories
|
|
1610
|
+
`));
|
|
1611
|
+
for (const story of buildableStories) {
|
|
1612
|
+
const spinner = ora({ text: story.title, indent: 4 }).start();
|
|
1613
|
+
const headBefore = await this.git.getHead();
|
|
1614
|
+
await stateManager.saveSnapshot({
|
|
1615
|
+
action: "build",
|
|
1616
|
+
storyId: story.id,
|
|
1617
|
+
branch: "main",
|
|
1618
|
+
commitBefore: headBefore
|
|
1619
|
+
});
|
|
1620
|
+
story.status = "building";
|
|
1621
|
+
await stateManager.savePlan(plan);
|
|
1622
|
+
const prompt = this.orchestrator.craftWorkerPrompt(story, "build", {
|
|
1623
|
+
plan,
|
|
1624
|
+
designMeta: story.designApproved ? { storyId: story.id } : void 0
|
|
1625
|
+
});
|
|
1626
|
+
const result = await this.worker.run("build", prompt, {
|
|
1627
|
+
onProgress: (event) => {
|
|
1628
|
+
if (event.type === "tool_use") {
|
|
1629
|
+
spinner.text = event.content;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
if (result.success) {
|
|
1634
|
+
await this.git.commitAll(`feat: ${story.title}`);
|
|
1635
|
+
story.status = "reviewing";
|
|
1636
|
+
spinner.succeed(story.title + chalk3.dim(` \xB7 ${result.filesCreated.length} files`));
|
|
1637
|
+
} else {
|
|
1638
|
+
story.status = "blocked";
|
|
1639
|
+
spinner.fail(story.title);
|
|
1640
|
+
for (const error of result.errors) {
|
|
1641
|
+
console.log(chalk3.red(` ${error}`));
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
await stateManager.savePlan(plan);
|
|
1645
|
+
await this.processQueue(plan);
|
|
1646
|
+
}
|
|
1647
|
+
await stateManager.updatePhase("build");
|
|
1648
|
+
console.log(chalk3.dim("\n Build phase complete\n"));
|
|
1649
|
+
}
|
|
1650
|
+
// ── Phase 4: Review ──────────────────────────────────────
|
|
1651
|
+
async runReviewPhase(plan) {
|
|
1652
|
+
const reviewableStories = this.getAllStories(plan).filter(
|
|
1653
|
+
(s) => s.status === "reviewing"
|
|
1654
|
+
);
|
|
1655
|
+
if (reviewableStories.length === 0) return;
|
|
1656
|
+
console.log(chalk3.bold(`
|
|
1657
|
+
Review`) + chalk3.dim(` \xB7 ${reviewableStories.length} stories
|
|
1658
|
+
`));
|
|
1659
|
+
for (const story of reviewableStories) {
|
|
1660
|
+
const spinner = ora({ text: story.title, indent: 4 }).start();
|
|
1661
|
+
const prompt = this.orchestrator.craftWorkerPrompt(story, "review", {
|
|
1662
|
+
plan,
|
|
1663
|
+
designMeta: story.designApproved ? { storyId: story.id } : void 0
|
|
1664
|
+
});
|
|
1665
|
+
let result;
|
|
1666
|
+
try {
|
|
1667
|
+
result = await this.worker.run("review", prompt);
|
|
1668
|
+
} catch (err) {
|
|
1669
|
+
spinner.fail(story.title + chalk3.dim(" \u2014 review error"));
|
|
1670
|
+
console.log(chalk3.red(` ${err instanceof Error ? err.message : err}`));
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
if (result.success) {
|
|
1674
|
+
const tagName = `forge/v0.${this.getNextTagNumber()}-${story.id}`;
|
|
1675
|
+
await this.git.tag(tagName);
|
|
1676
|
+
story.tags.push(tagName);
|
|
1677
|
+
story.status = "done";
|
|
1678
|
+
spinner.succeed(story.title + chalk3.dim(` [${tagName}]`));
|
|
1679
|
+
} else {
|
|
1680
|
+
spinner.fail(story.title);
|
|
1681
|
+
console.log(chalk3.dim(` ${result.summary}`));
|
|
1682
|
+
}
|
|
1683
|
+
await stateManager.savePlan(plan);
|
|
1684
|
+
}
|
|
1685
|
+
await stateManager.updatePhase("review");
|
|
1686
|
+
console.log(chalk3.dim("\n Review phase complete\n"));
|
|
1687
|
+
}
|
|
1688
|
+
// ── Change Queue ─────────────────────────────────────────
|
|
1689
|
+
queueChange(change) {
|
|
1690
|
+
this.changeQueue.push(change);
|
|
1691
|
+
console.log(chalk3.dim(` Queued: "${change.message}"`));
|
|
1692
|
+
}
|
|
1693
|
+
async processQueue(plan) {
|
|
1694
|
+
if (this.changeQueue.length === 0) return;
|
|
1695
|
+
console.log(chalk3.dim(`
|
|
1696
|
+
Processing ${this.changeQueue.length} queued changes...
|
|
1697
|
+
`));
|
|
1698
|
+
for (const change of this.changeQueue) {
|
|
1699
|
+
const decision = await this.orchestrator.routeUserInput(
|
|
1700
|
+
change.message,
|
|
1701
|
+
await stateManager.getState()
|
|
1702
|
+
);
|
|
1703
|
+
if (decision.action === "route-to-worker" && decision.prompt) {
|
|
1704
|
+
const mode = decision.workerMode || "fix";
|
|
1705
|
+
const spinner = ora({ text: change.message, indent: 4 }).start();
|
|
1706
|
+
const result = await this.worker.run(mode, decision.prompt);
|
|
1707
|
+
if (result.success) {
|
|
1708
|
+
await this.git.commitAll(`fix: ${change.message}`);
|
|
1709
|
+
spinner.succeed(change.message);
|
|
1710
|
+
} else {
|
|
1711
|
+
spinner.fail(change.message);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
this.changeQueue = [];
|
|
1716
|
+
}
|
|
1717
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
1718
|
+
displayPlan(plan) {
|
|
1719
|
+
console.log(chalk3.bold(`
|
|
1720
|
+
${plan.project}`) + chalk3.dim(` \xB7 ${plan.framework}`));
|
|
1721
|
+
console.log(chalk3.dim(` ${plan.description}
|
|
1722
|
+
`));
|
|
1723
|
+
for (const epic of plan.epics) {
|
|
1724
|
+
console.log(chalk3.dim(` ${epic.title}`));
|
|
1725
|
+
for (const story of epic.stories) {
|
|
1726
|
+
const tag = story.type === "ui" ? "ui" : story.type === "backend" ? "api" : "full";
|
|
1727
|
+
console.log(` ${chalk3.dim(`[${tag}]`)} ${story.id} ${chalk3.dim("\u2014")} ${story.title}`);
|
|
1728
|
+
}
|
|
1729
|
+
console.log("");
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
getAllStories(plan) {
|
|
1733
|
+
return plan.epics.flatMap((epic) => epic.stories);
|
|
1734
|
+
}
|
|
1735
|
+
getStoriesByType(plan, types) {
|
|
1736
|
+
return this.getAllStories(plan).filter((s) => types.includes(s.type));
|
|
1737
|
+
}
|
|
1738
|
+
tagCounter = 2;
|
|
1739
|
+
getNextTagNumber() {
|
|
1740
|
+
return this.tagCounter++;
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1744
|
+
// src/core/pipeline/auto.ts
|
|
1745
|
+
import chalk4 from "chalk";
|
|
1746
|
+
import ora2 from "ora";
|
|
1747
|
+
import readline from "readline";
|
|
1748
|
+
import inquirer2 from "inquirer";
|
|
1749
|
+
|
|
1750
|
+
// src/core/github/index.ts
|
|
1751
|
+
import { spawnSync } from "child_process";
|
|
1752
|
+
var GitHubSync = class {
|
|
1753
|
+
repo;
|
|
1754
|
+
constructor(repo) {
|
|
1755
|
+
this.repo = repo;
|
|
1756
|
+
}
|
|
1757
|
+
/** Check if `gh` CLI is installed and authenticated */
|
|
1758
|
+
static isAvailable() {
|
|
1759
|
+
const result = spawnSync("gh", ["auth", "status"], { stdio: "ignore" });
|
|
1760
|
+
return result.status === 0;
|
|
1761
|
+
}
|
|
1762
|
+
/** Create GitHub labels for forge story tracking */
|
|
1763
|
+
async ensureLabels() {
|
|
1764
|
+
const labels = [
|
|
1765
|
+
{ name: "forge", color: "000000", description: "Managed by ForgeAI" },
|
|
1766
|
+
{ name: "type:ui", color: "7057ff", description: "UI component" },
|
|
1767
|
+
{ name: "type:backend", color: "0075ca", description: "Backend/API" },
|
|
1768
|
+
{ name: "type:fullstack", color: "008672", description: "Full stack" },
|
|
1769
|
+
{ name: "status:planned", color: "d4c5f9", description: "Planned" },
|
|
1770
|
+
{ name: "status:in-progress", color: "fbca04", description: "Building" },
|
|
1771
|
+
{ name: "status:review", color: "f9d0c4", description: "In review" },
|
|
1772
|
+
{ name: "status:done", color: "0e8a16", description: "Complete" },
|
|
1773
|
+
{ name: "status:blocked", color: "e11d48", description: "Blocked" }
|
|
1774
|
+
];
|
|
1775
|
+
for (const label of labels) {
|
|
1776
|
+
spawnSync("gh", [
|
|
1777
|
+
"label",
|
|
1778
|
+
"create",
|
|
1779
|
+
label.name,
|
|
1780
|
+
"--repo",
|
|
1781
|
+
this.repo,
|
|
1782
|
+
"--color",
|
|
1783
|
+
label.color,
|
|
1784
|
+
"--description",
|
|
1785
|
+
label.description,
|
|
1786
|
+
"--force"
|
|
1787
|
+
], { stdio: "ignore" });
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
/** Sync all stories in a plan to GitHub Issues */
|
|
1791
|
+
async syncPlan(plan) {
|
|
1792
|
+
let created = 0;
|
|
1793
|
+
let updated = 0;
|
|
1794
|
+
for (const epic of plan.epics) {
|
|
1795
|
+
for (const story of epic.stories) {
|
|
1796
|
+
const result = await this.syncStory(story, epic.title);
|
|
1797
|
+
if (result === "created") created++;
|
|
1798
|
+
else if (result === "updated") updated++;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
return { created, updated };
|
|
1802
|
+
}
|
|
1803
|
+
/** Sync a single story to a GitHub Issue */
|
|
1804
|
+
async syncStory(story, epicTitle) {
|
|
1805
|
+
const title = `[${epicTitle}] ${story.title}`;
|
|
1806
|
+
const labels = this.getLabels(story);
|
|
1807
|
+
const body = [
|
|
1808
|
+
`**Story ID:** \`${story.id}\``,
|
|
1809
|
+
`**Type:** ${story.type}`,
|
|
1810
|
+
`**Priority:** ${story.priority}`,
|
|
1811
|
+
`**Status:** ${story.status}`,
|
|
1812
|
+
"",
|
|
1813
|
+
story.description,
|
|
1814
|
+
"",
|
|
1815
|
+
"---",
|
|
1816
|
+
"_Managed by [ForgeAI](https://github.com/joeljohn159/forgeai)_"
|
|
1817
|
+
].join("\n");
|
|
1818
|
+
const searchResult = spawnSync("gh", [
|
|
1819
|
+
"issue",
|
|
1820
|
+
"list",
|
|
1821
|
+
"--repo",
|
|
1822
|
+
this.repo,
|
|
1823
|
+
"--search",
|
|
1824
|
+
story.id,
|
|
1825
|
+
"--label",
|
|
1826
|
+
"forge",
|
|
1827
|
+
"--json",
|
|
1828
|
+
"number",
|
|
1829
|
+
"--jq",
|
|
1830
|
+
".[0].number",
|
|
1831
|
+
"--state",
|
|
1832
|
+
"all"
|
|
1833
|
+
], { encoding: "utf-8" });
|
|
1834
|
+
const existingNumber = searchResult.stdout?.trim();
|
|
1835
|
+
if (existingNumber && /^\d+$/.test(existingNumber)) {
|
|
1836
|
+
const args = [
|
|
1837
|
+
"issue",
|
|
1838
|
+
"edit",
|
|
1839
|
+
existingNumber,
|
|
1840
|
+
"--repo",
|
|
1841
|
+
this.repo,
|
|
1842
|
+
"--title",
|
|
1843
|
+
title,
|
|
1844
|
+
"--body",
|
|
1845
|
+
body
|
|
1846
|
+
];
|
|
1847
|
+
for (const label of labels) {
|
|
1848
|
+
args.push("--add-label", label);
|
|
1849
|
+
}
|
|
1850
|
+
spawnSync("gh", args, { stdio: "ignore" });
|
|
1851
|
+
if (story.status === "done") {
|
|
1852
|
+
spawnSync("gh", [
|
|
1853
|
+
"issue",
|
|
1854
|
+
"close",
|
|
1855
|
+
existingNumber,
|
|
1856
|
+
"--repo",
|
|
1857
|
+
this.repo
|
|
1858
|
+
], { stdio: "ignore" });
|
|
1859
|
+
}
|
|
1860
|
+
return "updated";
|
|
1861
|
+
}
|
|
1862
|
+
const createArgs = [
|
|
1863
|
+
"issue",
|
|
1864
|
+
"create",
|
|
1865
|
+
"--repo",
|
|
1866
|
+
this.repo,
|
|
1867
|
+
"--title",
|
|
1868
|
+
title,
|
|
1869
|
+
"--body",
|
|
1870
|
+
body
|
|
1871
|
+
];
|
|
1872
|
+
for (const label of labels) {
|
|
1873
|
+
createArgs.push("--label", label);
|
|
1874
|
+
}
|
|
1875
|
+
const createResult = spawnSync("gh", createArgs, { stdio: "ignore" });
|
|
1876
|
+
if (createResult.status === 0) {
|
|
1877
|
+
return "created";
|
|
1878
|
+
}
|
|
1879
|
+
return "skipped";
|
|
1880
|
+
}
|
|
1881
|
+
/** Map story status to GitHub labels */
|
|
1882
|
+
getLabels(story) {
|
|
1883
|
+
const labels = ["forge", `type:${story.type}`];
|
|
1884
|
+
switch (story.status) {
|
|
1885
|
+
case "planned":
|
|
1886
|
+
case "designing":
|
|
1887
|
+
case "design-approved":
|
|
1888
|
+
labels.push("status:planned");
|
|
1889
|
+
break;
|
|
1890
|
+
case "building":
|
|
1891
|
+
labels.push("status:in-progress");
|
|
1892
|
+
break;
|
|
1893
|
+
case "reviewing":
|
|
1894
|
+
labels.push("status:review");
|
|
1895
|
+
break;
|
|
1896
|
+
case "done":
|
|
1897
|
+
labels.push("status:done");
|
|
1898
|
+
break;
|
|
1899
|
+
case "blocked":
|
|
1900
|
+
labels.push("status:blocked");
|
|
1901
|
+
break;
|
|
1902
|
+
}
|
|
1903
|
+
return labels;
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
// src/core/pipeline/auto.ts
|
|
1908
|
+
var AutoPipeline = class {
|
|
1909
|
+
orchestrator;
|
|
1910
|
+
worker;
|
|
1911
|
+
git;
|
|
1912
|
+
config;
|
|
1913
|
+
plan = null;
|
|
1914
|
+
options;
|
|
1915
|
+
chatQueue = [];
|
|
1916
|
+
rl = null;
|
|
1917
|
+
startTime = 0;
|
|
1918
|
+
totalUsage = { inputTokens: 0, outputTokens: 0, costUsd: 0, durationMs: 0 };
|
|
1919
|
+
shuttingDown = false;
|
|
1920
|
+
activeSpinner = null;
|
|
1921
|
+
tagCounter = 1;
|
|
1922
|
+
constructor(config, options = {}) {
|
|
1923
|
+
this.config = config;
|
|
1924
|
+
this.options = options;
|
|
1925
|
+
this.orchestrator = new Orchestrator(config);
|
|
1926
|
+
this.worker = new Worker(
|
|
1927
|
+
config,
|
|
1928
|
+
{
|
|
1929
|
+
sandbox: options.sandbox ?? true,
|
|
1930
|
+
workingDir: options.workingDir,
|
|
1931
|
+
allowedDomains: options.allowedDomains
|
|
1932
|
+
},
|
|
1933
|
+
options.mute
|
|
1934
|
+
);
|
|
1935
|
+
this.git = new GitManager();
|
|
1936
|
+
this.setupGracefulShutdown();
|
|
1937
|
+
}
|
|
1938
|
+
// ── Graceful Shutdown ────────────────────────────────────
|
|
1939
|
+
setupGracefulShutdown() {
|
|
1940
|
+
const handler = async () => {
|
|
1941
|
+
if (this.shuttingDown) return;
|
|
1942
|
+
this.shuttingDown = true;
|
|
1943
|
+
if (this.activeSpinner?.isSpinning) {
|
|
1944
|
+
this.activeSpinner.stop();
|
|
1945
|
+
}
|
|
1946
|
+
console.log(chalk4.yellow("\n\n Interrupted \u2014 saving progress..."));
|
|
1947
|
+
try {
|
|
1948
|
+
if (this.plan) {
|
|
1949
|
+
await stateManager.savePlan(this.plan);
|
|
1950
|
+
await this.git.commitAll("forge: save progress (interrupted)");
|
|
1951
|
+
console.log(chalk4.dim(" Progress saved. Resume with: forge resume\n"));
|
|
1952
|
+
}
|
|
1953
|
+
} catch {
|
|
1954
|
+
console.log(chalk4.dim(" Could not save progress.\n"));
|
|
1955
|
+
}
|
|
1956
|
+
this.stopChatListener();
|
|
1957
|
+
process.exit(130);
|
|
1958
|
+
};
|
|
1959
|
+
process.on("SIGINT", handler);
|
|
1960
|
+
process.on("SIGTERM", handler);
|
|
1961
|
+
}
|
|
1962
|
+
// ── Resume an interrupted sprint ──────────────────────────
|
|
1963
|
+
async resume(existingPlan) {
|
|
1964
|
+
const errors = [];
|
|
1965
|
+
this.startTime = Date.now();
|
|
1966
|
+
this.plan = existingPlan;
|
|
1967
|
+
console.log(chalk4.bold("\n forge") + chalk4.dim(" resume"));
|
|
1968
|
+
console.log(chalk4.dim(` Continuing from last checkpoint
|
|
1969
|
+
`));
|
|
1970
|
+
this.startChatListener();
|
|
1971
|
+
await this.git.ensureRepo();
|
|
1972
|
+
await this.git.ensureMainBranch();
|
|
1973
|
+
const allStories = this.getAllStories(this.plan);
|
|
1974
|
+
const needsDesign = allStories.some(
|
|
1975
|
+
(s) => s.status === "planned" && (s.type === "ui" || s.type === "fullstack")
|
|
1976
|
+
);
|
|
1977
|
+
const needsBuild = allStories.some(
|
|
1978
|
+
(s) => s.status === "planned" || s.status === "design-approved"
|
|
1979
|
+
);
|
|
1980
|
+
const needsReview = allStories.some((s) => s.status === "reviewing");
|
|
1981
|
+
if (!needsDesign && !needsBuild && !needsReview) {
|
|
1982
|
+
console.log(chalk4.green(" Nothing to resume \u2014 all stories are complete or blocked.\n"));
|
|
1983
|
+
this.stopChatListener();
|
|
1984
|
+
return { success: true, plan: this.plan, errors };
|
|
1985
|
+
}
|
|
1986
|
+
if (needsDesign && !this.options.skipDesign) {
|
|
1987
|
+
try {
|
|
1988
|
+
await this.runDesignPhase(this.plan);
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
await this.saveProgressOnError("design");
|
|
1991
|
+
errors.push(`Design: ${err instanceof Error ? err.message : err}`);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
if (needsBuild) {
|
|
1995
|
+
try {
|
|
1996
|
+
await this.runBuildPhase(this.plan);
|
|
1997
|
+
} catch (err) {
|
|
1998
|
+
await this.saveProgressOnError("build");
|
|
1999
|
+
errors.push(`Build: ${err instanceof Error ? err.message : err}`);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
if (needsReview || needsBuild) {
|
|
2003
|
+
const gateAction = await this.reviewGate();
|
|
2004
|
+
if (gateAction === "abort") {
|
|
2005
|
+
this.stopChatListener();
|
|
2006
|
+
this.printSummary(errors);
|
|
2007
|
+
return { success: false, plan: this.plan, errors: [...errors, "Aborted"] };
|
|
2008
|
+
}
|
|
2009
|
+
if (gateAction !== "skip") {
|
|
2010
|
+
try {
|
|
2011
|
+
await this.runReviewPhase(this.plan);
|
|
2012
|
+
} catch (err) {
|
|
2013
|
+
await this.saveProgressOnError("review");
|
|
2014
|
+
errors.push(`Review: ${err instanceof Error ? err.message : err}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
await this.autoPush();
|
|
2019
|
+
this.stopChatListener();
|
|
2020
|
+
this.printSummary(errors);
|
|
2021
|
+
await stateManager.updatePhase("done");
|
|
2022
|
+
const failedCount = allStories.filter((s) => s.status === "blocked").length;
|
|
2023
|
+
return { success: errors.length === 0 && failedCount === 0, plan: this.plan, errors };
|
|
2024
|
+
}
|
|
2025
|
+
// ── Full Autonomous Sprint ─────────────────────────────────
|
|
2026
|
+
async run(description) {
|
|
2027
|
+
const errors = [];
|
|
2028
|
+
this.startTime = Date.now();
|
|
2029
|
+
console.log(chalk4.bold("\n forge") + chalk4.dim(" auto"));
|
|
2030
|
+
console.log(chalk4.dim(` sandbox ${this.options.sandbox !== false ? "on" : "off"} \xB7 type a message anytime to queue feedback
|
|
2031
|
+
`));
|
|
2032
|
+
this.startChatListener();
|
|
2033
|
+
await this.git.ensureRepo();
|
|
2034
|
+
await this.git.ensureMainBranch();
|
|
2035
|
+
const planSpinner = ora2({ text: `${this.elapsed()} Planning...`, indent: 2 }).start();
|
|
2036
|
+
this.activeSpinner = planSpinner;
|
|
2037
|
+
try {
|
|
2038
|
+
this.plan = await this.orchestrator.generatePlan(description);
|
|
2039
|
+
planSpinner.succeed(`${this.elapsed()} Plan ready`);
|
|
2040
|
+
this.activeSpinner = null;
|
|
2041
|
+
this.displayPlan(this.plan);
|
|
2042
|
+
await stateManager.savePlan(this.plan);
|
|
2043
|
+
await stateManager.updatePhase("plan");
|
|
2044
|
+
await this.git.commitState("Sprint plan");
|
|
2045
|
+
await this.git.tag("forge/v0.0-plan");
|
|
2046
|
+
await this.syncToGitHub(this.plan);
|
|
2047
|
+
} catch (err) {
|
|
2048
|
+
planSpinner.fail(`${this.elapsed()} Planning failed`);
|
|
2049
|
+
this.activeSpinner = null;
|
|
2050
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2051
|
+
console.log(chalk4.red(`
|
|
2052
|
+
${msg}`));
|
|
2053
|
+
if (err instanceof Error && err.stack) {
|
|
2054
|
+
console.log(chalk4.dim(` ${err.stack.split("\n").slice(1, 3).join("\n ")}`));
|
|
2055
|
+
}
|
|
2056
|
+
this.stopChatListener();
|
|
2057
|
+
return { success: false, plan: null, errors: [`Plan: ${msg}`] };
|
|
2058
|
+
}
|
|
2059
|
+
this.showInputHint();
|
|
2060
|
+
if (this.options.skipDesign) {
|
|
2061
|
+
for (const story of this.getAllStories(this.plan)) {
|
|
2062
|
+
if (story.status === "planned") {
|
|
2063
|
+
story.designApproved = true;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
console.log(chalk4.dim(`
|
|
2067
|
+
Design skipped (--skip-design)
|
|
2068
|
+
`));
|
|
2069
|
+
} else {
|
|
2070
|
+
try {
|
|
2071
|
+
await this.runDesignPhase(this.plan);
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
await this.saveProgressOnError("design");
|
|
2074
|
+
errors.push(`Design: ${err instanceof Error ? err.message : err}`);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
try {
|
|
2078
|
+
await this.runBuildPhase(this.plan);
|
|
2079
|
+
} catch (err) {
|
|
2080
|
+
await this.saveProgressOnError("build");
|
|
2081
|
+
errors.push(`Build: ${err instanceof Error ? err.message : err}`);
|
|
2082
|
+
}
|
|
2083
|
+
try {
|
|
2084
|
+
await this.generateReadme(this.plan);
|
|
2085
|
+
} catch (err) {
|
|
2086
|
+
errors.push(`README: ${err instanceof Error ? err.message : err}`);
|
|
2087
|
+
}
|
|
2088
|
+
const gateAction = await this.reviewGate();
|
|
2089
|
+
if (gateAction === "abort") {
|
|
2090
|
+
this.stopChatListener();
|
|
2091
|
+
this.printSummary(errors);
|
|
2092
|
+
await stateManager.updatePhase("done");
|
|
2093
|
+
return { success: false, plan: this.plan, errors: [...errors, "Aborted by user"] };
|
|
2094
|
+
}
|
|
2095
|
+
if (gateAction !== "skip") {
|
|
2096
|
+
try {
|
|
2097
|
+
await this.runReviewPhase(this.plan);
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
await this.saveProgressOnError("review");
|
|
2100
|
+
errors.push(`Review: ${err instanceof Error ? err.message : err}`);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
if (this.options.deploy) {
|
|
2104
|
+
try {
|
|
2105
|
+
await this.runDeployPhase(this.plan);
|
|
2106
|
+
} catch (err) {
|
|
2107
|
+
errors.push(`Deploy: ${err instanceof Error ? err.message : err}`);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
await this.autoPush();
|
|
2111
|
+
this.stopChatListener();
|
|
2112
|
+
this.printSummary(errors);
|
|
2113
|
+
await stateManager.updatePhase("done");
|
|
2114
|
+
const allStories = this.getAllStories(this.plan);
|
|
2115
|
+
const failedCount = allStories.filter((s) => s.status === "blocked").length;
|
|
2116
|
+
return { success: errors.length === 0 && failedCount === 0, plan: this.plan, errors };
|
|
2117
|
+
}
|
|
2118
|
+
/** Save current progress when a phase fails — prevents data loss */
|
|
2119
|
+
async saveProgressOnError(phase) {
|
|
2120
|
+
try {
|
|
2121
|
+
if (this.plan) {
|
|
2122
|
+
await stateManager.savePlan(this.plan);
|
|
2123
|
+
await stateManager.updatePhase(phase);
|
|
2124
|
+
await this.git.commitAll(`forge: save progress (${phase} interrupted)`);
|
|
2125
|
+
console.log(chalk4.dim(` Progress saved. Resume with: forge resume
|
|
2126
|
+
`));
|
|
2127
|
+
}
|
|
2128
|
+
} catch {
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
// ── Elapsed Timer ─────────────────────────────────────────
|
|
2132
|
+
elapsed() {
|
|
2133
|
+
const sec = Math.floor((Date.now() - this.startTime) / 1e3);
|
|
2134
|
+
const minutes = Math.floor(sec / 60);
|
|
2135
|
+
const seconds = sec % 60;
|
|
2136
|
+
return chalk4.dim(`[${minutes}:${seconds.toString().padStart(2, "0")}]`);
|
|
2137
|
+
}
|
|
2138
|
+
// ── Progress Helper with Timer ────────────────────────────
|
|
2139
|
+
makeProgress(spinner, storyTitle) {
|
|
2140
|
+
if (this.options.quiet) {
|
|
2141
|
+
return { onProgress: () => {
|
|
2142
|
+
}, stop: () => {
|
|
2143
|
+
} };
|
|
2144
|
+
}
|
|
2145
|
+
let lastDetail = "";
|
|
2146
|
+
const update = () => {
|
|
2147
|
+
if (!spinner.isSpinning) return;
|
|
2148
|
+
const prefix = this.elapsed();
|
|
2149
|
+
const detail = lastDetail ? " " + chalk4.dim(lastDetail) : "";
|
|
2150
|
+
spinner.text = `${prefix} ${storyTitle}${detail}`;
|
|
2151
|
+
};
|
|
2152
|
+
const interval = setInterval(update, 5e3);
|
|
2153
|
+
const onProgress = (event) => {
|
|
2154
|
+
switch (event.type) {
|
|
2155
|
+
case "tool_use":
|
|
2156
|
+
lastDetail = event.content;
|
|
2157
|
+
break;
|
|
2158
|
+
case "tool_running":
|
|
2159
|
+
if (event.elapsed && event.elapsed > 3) {
|
|
2160
|
+
lastDetail = `${event.tool} (${Math.round(event.elapsed)}s)`;
|
|
2161
|
+
}
|
|
2162
|
+
break;
|
|
2163
|
+
case "tool_done":
|
|
2164
|
+
lastDetail = event.content.slice(0, 80);
|
|
2165
|
+
break;
|
|
2166
|
+
}
|
|
2167
|
+
update();
|
|
2168
|
+
};
|
|
2169
|
+
return {
|
|
2170
|
+
onProgress,
|
|
2171
|
+
stop: () => clearInterval(interval)
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
// ── Input Hint ────────────────────────────────────────────
|
|
2175
|
+
showInputHint() {
|
|
2176
|
+
if (process.stdout.isTTY && !this.options.quiet) {
|
|
2177
|
+
const wasSpinning = this.activeSpinner?.isSpinning ?? false;
|
|
2178
|
+
const spinnerText = this.activeSpinner?.text ?? "";
|
|
2179
|
+
if (wasSpinning) this.activeSpinner.stop();
|
|
2180
|
+
process.stdout.write(chalk4.dim("\n \u2500 type a message and press enter \u2500\n > "));
|
|
2181
|
+
if (wasSpinning && this.activeSpinner) {
|
|
2182
|
+
this.activeSpinner.start(spinnerText);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
// ── Dependency Grouping ───────────────────────────────────
|
|
2187
|
+
groupByDependency(stories) {
|
|
2188
|
+
const batches = [];
|
|
2189
|
+
const completed = /* @__PURE__ */ new Set();
|
|
2190
|
+
const remaining = new Set(stories.map((s) => s.id));
|
|
2191
|
+
const storyMap = new Map(stories.map((s) => [s.id, s]));
|
|
2192
|
+
if (this.plan) {
|
|
2193
|
+
for (const s of this.getAllStories(this.plan)) {
|
|
2194
|
+
if (s.status === "done" || s.status === "reviewing") {
|
|
2195
|
+
completed.add(s.id);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
let iterations = 0;
|
|
2200
|
+
const maxIterations = stories.length + 1;
|
|
2201
|
+
while (remaining.size > 0 && iterations < maxIterations) {
|
|
2202
|
+
iterations++;
|
|
2203
|
+
const batch = [];
|
|
2204
|
+
for (const id of remaining) {
|
|
2205
|
+
const story = storyMap.get(id);
|
|
2206
|
+
const depsReady = story.dependencies.every(
|
|
2207
|
+
(dep) => completed.has(dep) || !remaining.has(dep)
|
|
2208
|
+
// dep done or not in buildable set
|
|
2209
|
+
);
|
|
2210
|
+
if (depsReady) {
|
|
2211
|
+
batch.push(story);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
if (batch.length === 0) {
|
|
2215
|
+
for (const id of remaining) {
|
|
2216
|
+
batches.push([storyMap.get(id)]);
|
|
2217
|
+
}
|
|
2218
|
+
break;
|
|
2219
|
+
}
|
|
2220
|
+
batches.push(batch);
|
|
2221
|
+
for (const s of batch) {
|
|
2222
|
+
completed.add(s.id);
|
|
2223
|
+
remaining.delete(s.id);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
return batches;
|
|
2227
|
+
}
|
|
2228
|
+
// ── Design Phase (parallel) ───────────────────────────────
|
|
2229
|
+
async runDesignPhase(plan) {
|
|
2230
|
+
const uiStories = this.getStoriesByType(plan, ["ui", "fullstack"]).filter(
|
|
2231
|
+
(s) => s.status === "planned"
|
|
2232
|
+
);
|
|
2233
|
+
if (uiStories.length === 0) return;
|
|
2234
|
+
console.log(chalk4.bold(`
|
|
2235
|
+
Design`) + chalk4.dim(` \xB7 ${uiStories.length} stories
|
|
2236
|
+
`));
|
|
2237
|
+
if (uiStories.length > 1) {
|
|
2238
|
+
await Promise.all(
|
|
2239
|
+
uiStories.map(
|
|
2240
|
+
(story, i) => this.designSingleStory(story, plan, i + 1, uiStories.length)
|
|
2241
|
+
)
|
|
2242
|
+
);
|
|
2243
|
+
} else {
|
|
2244
|
+
await this.designSingleStory(uiStories[0], plan, 1, 1);
|
|
2245
|
+
}
|
|
2246
|
+
await stateManager.savePlan(plan);
|
|
2247
|
+
await stateManager.updatePhase("design");
|
|
2248
|
+
await this.git.commitState("Designs complete");
|
|
2249
|
+
await this.git.tag("forge/v0.1-designs");
|
|
2250
|
+
await this.processChatQueue();
|
|
2251
|
+
this.showInputHint();
|
|
2252
|
+
}
|
|
2253
|
+
async designSingleStory(story, plan, index, total) {
|
|
2254
|
+
const label = `[${index}/${total}] ${story.title}`;
|
|
2255
|
+
const spinner = ora2({ text: `${this.elapsed()} ${label}`, indent: 2 }).start();
|
|
2256
|
+
this.activeSpinner = spinner;
|
|
2257
|
+
const progress = this.makeProgress(spinner, label);
|
|
2258
|
+
try {
|
|
2259
|
+
story.status = "designing";
|
|
2260
|
+
const prompt = this.orchestrator.craftWorkerPrompt(story, "design", { plan });
|
|
2261
|
+
const result = await this.worker.run("design", prompt, {
|
|
2262
|
+
onProgress: progress.onProgress
|
|
2263
|
+
});
|
|
2264
|
+
this.addUsage(result.usage);
|
|
2265
|
+
const tokens = chalk4.dim(
|
|
2266
|
+
`${this.formatTokens(result.usage.inputTokens)} in / ${this.formatTokens(result.usage.outputTokens)} out`
|
|
2267
|
+
);
|
|
2268
|
+
if (result.success) {
|
|
2269
|
+
spinner.succeed(`${this.elapsed()} ${label} ${tokens}`);
|
|
2270
|
+
story.designApproved = true;
|
|
2271
|
+
story.status = "design-approved";
|
|
2272
|
+
} else {
|
|
2273
|
+
spinner.warn(`${this.elapsed()} ${label}` + chalk4.dim(" skipped") + ` ${tokens}`);
|
|
2274
|
+
story.designApproved = false;
|
|
2275
|
+
story.status = "design-approved";
|
|
2276
|
+
}
|
|
2277
|
+
} finally {
|
|
2278
|
+
progress.stop();
|
|
2279
|
+
this.activeSpinner = null;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
// ── Build Phase (parallel by dependency) ──────────────────
|
|
2283
|
+
async runBuildPhase(plan) {
|
|
2284
|
+
const buildable = this.getAllStories(plan).filter(
|
|
2285
|
+
(s) => s.status === "design-approved" || s.status === "planned"
|
|
2286
|
+
);
|
|
2287
|
+
if (buildable.length === 0) return;
|
|
2288
|
+
console.log(chalk4.bold(`
|
|
2289
|
+
Build`) + chalk4.dim(` \xB7 ${buildable.length} stories
|
|
2290
|
+
`));
|
|
2291
|
+
const batches = this.groupByDependency(buildable);
|
|
2292
|
+
let storyIndex = 0;
|
|
2293
|
+
for (const batch of batches) {
|
|
2294
|
+
if (batch.length === 1) {
|
|
2295
|
+
storyIndex++;
|
|
2296
|
+
await this.buildSingleStory(batch[0], plan, storyIndex, buildable.length, false);
|
|
2297
|
+
} else {
|
|
2298
|
+
console.log(chalk4.dim(` ${this.elapsed()} parallel: ${batch.map((s) => s.title).join(", ")}`));
|
|
2299
|
+
const indexedBatch = batch.map((story) => {
|
|
2300
|
+
storyIndex++;
|
|
2301
|
+
return { story, index: storyIndex };
|
|
2302
|
+
});
|
|
2303
|
+
await Promise.all(
|
|
2304
|
+
indexedBatch.map(
|
|
2305
|
+
({ story, index }) => this.buildSingleStory(story, plan, index, buildable.length, true)
|
|
2306
|
+
)
|
|
2307
|
+
);
|
|
2308
|
+
const builtTitles = batch.filter((s) => s.status === "reviewing").map((s) => s.title);
|
|
2309
|
+
if (builtTitles.length > 0) {
|
|
2310
|
+
await this.git.commitAll(`feat: ${builtTitles.join(", ")}`);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
await stateManager.savePlan(plan);
|
|
2314
|
+
await this.processChatQueue();
|
|
2315
|
+
this.showInputHint();
|
|
2316
|
+
}
|
|
2317
|
+
await stateManager.updatePhase("build");
|
|
2318
|
+
}
|
|
2319
|
+
async buildSingleStory(story, plan, index, total, parallel) {
|
|
2320
|
+
const label = `[${index}/${total}] ${story.title}`;
|
|
2321
|
+
const spinner = ora2({ text: `${this.elapsed()} ${label}`, indent: 2 }).start();
|
|
2322
|
+
this.activeSpinner = spinner;
|
|
2323
|
+
const progress = this.makeProgress(spinner, label);
|
|
2324
|
+
try {
|
|
2325
|
+
const headBefore = await this.git.getHead();
|
|
2326
|
+
await stateManager.saveSnapshot({
|
|
2327
|
+
action: "build",
|
|
2328
|
+
storyId: story.id,
|
|
2329
|
+
branch: "main",
|
|
2330
|
+
commitBefore: headBefore
|
|
2331
|
+
});
|
|
2332
|
+
story.status = "building";
|
|
2333
|
+
const prompt = this.orchestrator.craftWorkerPrompt(story, "build", {
|
|
2334
|
+
plan,
|
|
2335
|
+
designMeta: story.designApproved ? { storyId: story.id } : void 0
|
|
2336
|
+
});
|
|
2337
|
+
const result = await this.worker.run("build", prompt, {
|
|
2338
|
+
onProgress: progress.onProgress
|
|
2339
|
+
});
|
|
2340
|
+
this.addUsage(result.usage);
|
|
2341
|
+
const tokens = chalk4.dim(
|
|
2342
|
+
`${this.formatTokens(result.usage.inputTokens)} in / ${this.formatTokens(result.usage.outputTokens)} out`
|
|
2343
|
+
);
|
|
2344
|
+
if (result.success) {
|
|
2345
|
+
if (!parallel) {
|
|
2346
|
+
await this.git.commitAll(`feat: ${story.title}`);
|
|
2347
|
+
}
|
|
2348
|
+
story.status = "reviewing";
|
|
2349
|
+
spinner.succeed(
|
|
2350
|
+
`${this.elapsed()} ${label}` + chalk4.dim(` \xB7 ${result.filesCreated.length} files`) + ` ${tokens}`
|
|
2351
|
+
);
|
|
2352
|
+
} else {
|
|
2353
|
+
story.status = "blocked";
|
|
2354
|
+
spinner.fail(`${this.elapsed()} ${label} ${tokens}`);
|
|
2355
|
+
for (const error of result.errors) {
|
|
2356
|
+
console.log(chalk4.red(` ${error}`));
|
|
2357
|
+
}
|
|
2358
|
+
if (result.errors.some((e) => e.includes("safety cap"))) {
|
|
2359
|
+
console.log(chalk4.yellow(` Tip: Story may be too large. Try splitting it into smaller stories.`));
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
} finally {
|
|
2363
|
+
progress.stop();
|
|
2364
|
+
this.activeSpinner = null;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
// ── README Generation ─────────────────────────────────────
|
|
2368
|
+
async generateReadme(plan) {
|
|
2369
|
+
const builtStories = this.getAllStories(plan).filter(
|
|
2370
|
+
(s) => s.status === "reviewing" || s.status === "done"
|
|
2371
|
+
);
|
|
2372
|
+
if (builtStories.length === 0) return;
|
|
2373
|
+
const label = "README.md";
|
|
2374
|
+
const spinner = ora2({ text: `${this.elapsed()} Generating ${label}`, indent: 2 }).start();
|
|
2375
|
+
this.activeSpinner = spinner;
|
|
2376
|
+
const progress = this.makeProgress(spinner, label);
|
|
2377
|
+
try {
|
|
2378
|
+
const storyList = builtStories.map((s) => `- ${s.title}: ${s.description}`).join("\n");
|
|
2379
|
+
const prompt = `
|
|
2380
|
+
Generate a README.md for this project.
|
|
2381
|
+
|
|
2382
|
+
Project: ${plan.project}
|
|
2383
|
+
Description: ${plan.description}
|
|
2384
|
+
Framework: ${plan.framework}
|
|
2385
|
+
|
|
2386
|
+
Features built:
|
|
2387
|
+
${storyList}
|
|
2388
|
+
|
|
2389
|
+
Read the actual codebase to understand the project structure.
|
|
2390
|
+
|
|
2391
|
+
Include these sections:
|
|
2392
|
+
- Project name and description
|
|
2393
|
+
- Key features (from the stories above)
|
|
2394
|
+
- Tech stack
|
|
2395
|
+
- Getting started (install, dev server, build)
|
|
2396
|
+
- Project structure (based on actual files you can see)
|
|
2397
|
+
|
|
2398
|
+
Write the README.md file in the project root.
|
|
2399
|
+
Keep it concise and professional.
|
|
2400
|
+
`;
|
|
2401
|
+
const result = await this.worker.run("build", prompt, {
|
|
2402
|
+
onProgress: progress.onProgress
|
|
2403
|
+
});
|
|
2404
|
+
this.addUsage(result.usage);
|
|
2405
|
+
if (result.success) {
|
|
2406
|
+
await this.git.commitAll("docs: generate README.md");
|
|
2407
|
+
spinner.succeed(`${this.elapsed()} ${label}`);
|
|
2408
|
+
} else {
|
|
2409
|
+
spinner.warn(`${this.elapsed()} ${label}` + chalk4.dim(" skipped"));
|
|
2410
|
+
}
|
|
2411
|
+
} finally {
|
|
2412
|
+
progress.stop();
|
|
2413
|
+
this.activeSpinner = null;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
// ── Review Gate ───────────────────────────────────────────
|
|
2417
|
+
async reviewGate() {
|
|
2418
|
+
if (!process.stdout.isTTY) return "continue";
|
|
2419
|
+
if (!this.options.mute) {
|
|
2420
|
+
playSound();
|
|
2421
|
+
}
|
|
2422
|
+
if (this.activeSpinner?.isSpinning) {
|
|
2423
|
+
this.activeSpinner.stop();
|
|
2424
|
+
this.activeSpinner = null;
|
|
2425
|
+
}
|
|
2426
|
+
this.stopChatListener();
|
|
2427
|
+
console.log("");
|
|
2428
|
+
const { action } = await inquirer2.prompt([
|
|
2429
|
+
{
|
|
2430
|
+
type: "list",
|
|
2431
|
+
name: "action",
|
|
2432
|
+
message: `${this.elapsed()} Build complete. What next?`,
|
|
2433
|
+
choices: [
|
|
2434
|
+
{ name: "Continue to review", value: "continue" },
|
|
2435
|
+
{ name: "Skip review", value: "skip" },
|
|
2436
|
+
{ name: "Abort", value: "abort" }
|
|
2437
|
+
]
|
|
2438
|
+
}
|
|
2439
|
+
]);
|
|
2440
|
+
this.startChatListener();
|
|
2441
|
+
return action;
|
|
2442
|
+
}
|
|
2443
|
+
// ── Review Phase ──────────────────────────────────────────
|
|
2444
|
+
async runReviewPhase(plan) {
|
|
2445
|
+
const reviewable = this.getAllStories(plan).filter((s) => s.status === "reviewing");
|
|
2446
|
+
if (reviewable.length === 0) return;
|
|
2447
|
+
console.log(chalk4.bold(`
|
|
2448
|
+
Review`) + chalk4.dim(` \xB7 ${reviewable.length} stories
|
|
2449
|
+
`));
|
|
2450
|
+
for (let i = 0; i < reviewable.length; i++) {
|
|
2451
|
+
const story = reviewable[i];
|
|
2452
|
+
const label = `[${i + 1}/${reviewable.length}] ${story.title}`;
|
|
2453
|
+
const spinner = ora2({ text: `${this.elapsed()} ${label}`, indent: 2 }).start();
|
|
2454
|
+
this.activeSpinner = spinner;
|
|
2455
|
+
const progress = this.makeProgress(spinner, label);
|
|
2456
|
+
try {
|
|
2457
|
+
const prompt = this.orchestrator.craftWorkerPrompt(story, "review", {
|
|
2458
|
+
plan,
|
|
2459
|
+
designMeta: story.designApproved ? { storyId: story.id } : void 0
|
|
2460
|
+
});
|
|
2461
|
+
const result = await this.worker.run("review", prompt, {
|
|
2462
|
+
onProgress: progress.onProgress
|
|
2463
|
+
});
|
|
2464
|
+
this.addUsage(result.usage);
|
|
2465
|
+
const tokens = chalk4.dim(
|
|
2466
|
+
`${this.formatTokens(result.usage.inputTokens)} in / ${this.formatTokens(result.usage.outputTokens)} out`
|
|
2467
|
+
);
|
|
2468
|
+
if (result.success) {
|
|
2469
|
+
const tagName = `forge/v0.${this.tagCounter++}-${story.id}`;
|
|
2470
|
+
await this.git.tag(tagName);
|
|
2471
|
+
story.tags.push(tagName);
|
|
2472
|
+
story.status = "done";
|
|
2473
|
+
spinner.succeed(`${this.elapsed()} ${label}` + chalk4.dim(` [${tagName}]`) + ` ${tokens}`);
|
|
2474
|
+
} else {
|
|
2475
|
+
spinner.text = `${this.elapsed()} ${label}` + chalk4.dim(" fixing issues...");
|
|
2476
|
+
const fixed = await this.autoFix(story, plan, result.summary, spinner, label);
|
|
2477
|
+
if (fixed) {
|
|
2478
|
+
const tagName = `forge/v0.${this.tagCounter++}-${story.id}`;
|
|
2479
|
+
await this.git.tag(tagName);
|
|
2480
|
+
story.tags.push(tagName);
|
|
2481
|
+
story.status = "done";
|
|
2482
|
+
spinner.succeed(`${this.elapsed()} ${label}` + chalk4.dim(` [${tagName}] fixed`));
|
|
2483
|
+
} else {
|
|
2484
|
+
story.status = "blocked";
|
|
2485
|
+
spinner.fail(`${this.elapsed()} ${label}` + chalk4.dim(" blocked"));
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
} finally {
|
|
2489
|
+
progress.stop();
|
|
2490
|
+
this.activeSpinner = null;
|
|
2491
|
+
}
|
|
2492
|
+
await stateManager.savePlan(plan);
|
|
2493
|
+
await this.processChatQueue();
|
|
2494
|
+
this.showInputHint();
|
|
2495
|
+
}
|
|
2496
|
+
await stateManager.updatePhase("review");
|
|
2497
|
+
}
|
|
2498
|
+
// ── Auto-Fix ──────────────────────────────────────────────
|
|
2499
|
+
async autoFix(story, _plan, reviewSummary, spinner, label) {
|
|
2500
|
+
const fixPrompt = `
|
|
2501
|
+
The review found these issues:
|
|
2502
|
+
${reviewSummary}
|
|
2503
|
+
|
|
2504
|
+
Fix them. Make minimal changes. Then re-run build/lint/typecheck to verify.
|
|
2505
|
+
`;
|
|
2506
|
+
const fixProgress = this.makeProgress(spinner, label + chalk4.dim(" fix"));
|
|
2507
|
+
try {
|
|
2508
|
+
const result = await this.worker.run("fix", fixPrompt, {
|
|
2509
|
+
onProgress: fixProgress.onProgress
|
|
2510
|
+
});
|
|
2511
|
+
if (result.success) {
|
|
2512
|
+
await this.git.commitAll(`fix: review issues in ${story.title}`);
|
|
2513
|
+
return true;
|
|
2514
|
+
}
|
|
2515
|
+
return false;
|
|
2516
|
+
} finally {
|
|
2517
|
+
fixProgress.stop();
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
// ── Deploy Phase (optional) ───────────────────────────────
|
|
2521
|
+
async runDeployPhase(plan) {
|
|
2522
|
+
console.log(chalk4.bold(`
|
|
2523
|
+
Deploy`) + chalk4.dim(` \xB7 GitHub Pages
|
|
2524
|
+
`));
|
|
2525
|
+
const label = "GitHub Pages";
|
|
2526
|
+
const spinner = ora2({ text: `${this.elapsed()} Configuring ${label}`, indent: 2 }).start();
|
|
2527
|
+
this.activeSpinner = spinner;
|
|
2528
|
+
const progress = this.makeProgress(spinner, label);
|
|
2529
|
+
try {
|
|
2530
|
+
const framework = plan.framework || "Next.js";
|
|
2531
|
+
const prompt = `
|
|
2532
|
+
Set up GitHub Pages deployment for this ${framework} project.
|
|
2533
|
+
|
|
2534
|
+
1. Update next.config.js or next.config.ts to add: output: "export"
|
|
2535
|
+
(merge with existing config, do not overwrite other settings)
|
|
2536
|
+
2. Create .github/workflows/deploy.yml for GitHub Pages deployment
|
|
2537
|
+
3. The workflow should:
|
|
2538
|
+
- Trigger on push to main
|
|
2539
|
+
- Install dependencies with npm ci
|
|
2540
|
+
- Build with npm run build
|
|
2541
|
+
- Use actions/configure-pages, actions/upload-pages-artifact, actions/deploy-pages
|
|
2542
|
+
|
|
2543
|
+
Do NOT run any git commands. Just create/update the configuration files.
|
|
2544
|
+
`;
|
|
2545
|
+
const result = await this.worker.run("build", prompt, {
|
|
2546
|
+
onProgress: progress.onProgress
|
|
2547
|
+
});
|
|
2548
|
+
this.addUsage(result.usage);
|
|
2549
|
+
if (result.success) {
|
|
2550
|
+
await this.git.commitAll("ci: add GitHub Pages deployment");
|
|
2551
|
+
spinner.succeed(`${this.elapsed()} ${label} configured`);
|
|
2552
|
+
} else {
|
|
2553
|
+
spinner.warn(`${this.elapsed()} ${label}` + chalk4.dim(" skipped"));
|
|
2554
|
+
}
|
|
2555
|
+
} finally {
|
|
2556
|
+
progress.stop();
|
|
2557
|
+
this.activeSpinner = null;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
// ── Chat Queue ────────────────────────────────────────────
|
|
2561
|
+
startChatListener() {
|
|
2562
|
+
if (!process.stdin.isTTY) return;
|
|
2563
|
+
if (this.rl) return;
|
|
2564
|
+
this.rl = readline.createInterface({
|
|
2565
|
+
input: process.stdin,
|
|
2566
|
+
terminal: false
|
|
2567
|
+
});
|
|
2568
|
+
this.rl.on("line", (line) => {
|
|
2569
|
+
const msg = line.trim();
|
|
2570
|
+
if (!msg) return;
|
|
2571
|
+
this.chatQueue.push({
|
|
2572
|
+
type: "content-change",
|
|
2573
|
+
message: msg,
|
|
2574
|
+
queuedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2575
|
+
});
|
|
2576
|
+
const count = this.chatQueue.length;
|
|
2577
|
+
const wasSpinning = this.activeSpinner?.isSpinning ?? false;
|
|
2578
|
+
const spinnerText = this.activeSpinner?.text ?? "";
|
|
2579
|
+
if (wasSpinning) this.activeSpinner.stop();
|
|
2580
|
+
if (process.stdout.isTTY) {
|
|
2581
|
+
process.stdout.write(`\r\x1B[K`);
|
|
2582
|
+
}
|
|
2583
|
+
console.log(
|
|
2584
|
+
chalk4.green(` [queued${count > 1 ? ` #${count}` : ""}]`) + chalk4.dim(` ${msg}`)
|
|
2585
|
+
);
|
|
2586
|
+
console.log(chalk4.dim(" > "));
|
|
2587
|
+
if (wasSpinning && this.activeSpinner) {
|
|
2588
|
+
this.activeSpinner.start(spinnerText);
|
|
2589
|
+
}
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
stopChatListener() {
|
|
2593
|
+
if (this.rl) {
|
|
2594
|
+
this.rl.close();
|
|
2595
|
+
this.rl = null;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
async processChatQueue() {
|
|
2599
|
+
if (this.chatQueue.length === 0) return;
|
|
2600
|
+
if (this.activeSpinner?.isSpinning) {
|
|
2601
|
+
this.activeSpinner.stop();
|
|
2602
|
+
}
|
|
2603
|
+
console.log(chalk4.dim(`
|
|
2604
|
+
Processing ${this.chatQueue.length} queued message${this.chatQueue.length > 1 ? "s" : ""}...
|
|
2605
|
+
`));
|
|
2606
|
+
const messages = [...this.chatQueue];
|
|
2607
|
+
this.chatQueue = [];
|
|
2608
|
+
for (const change of messages) {
|
|
2609
|
+
try {
|
|
2610
|
+
const state = await stateManager.getState();
|
|
2611
|
+
const decision = await this.orchestrator.routeUserInput(change.message, state);
|
|
2612
|
+
if (decision.action === "answer" && decision.response) {
|
|
2613
|
+
console.log(chalk4.white(` > ${change.message}`));
|
|
2614
|
+
console.log(chalk4.dim(` ${decision.response}
|
|
2615
|
+
`));
|
|
2616
|
+
} else if (decision.action === "route-to-worker" && decision.prompt) {
|
|
2617
|
+
const mode = decision.workerMode || "fix";
|
|
2618
|
+
const spinner = ora2({ text: change.message, indent: 2 }).start();
|
|
2619
|
+
const result = await this.worker.run(mode, decision.prompt);
|
|
2620
|
+
if (result.success) {
|
|
2621
|
+
await this.git.commitAll(`fix: ${change.message}`);
|
|
2622
|
+
spinner.succeed(change.message);
|
|
2623
|
+
} else {
|
|
2624
|
+
spinner.fail(change.message);
|
|
2625
|
+
}
|
|
2626
|
+
} else if (decision.action === "add-story" && this.plan) {
|
|
2627
|
+
const newStory = {
|
|
2628
|
+
id: `story-${Date.now()}`,
|
|
2629
|
+
title: decision.story?.title || change.message,
|
|
2630
|
+
description: decision.story?.description || change.message,
|
|
2631
|
+
type: decision.story?.type || "fullstack",
|
|
2632
|
+
status: "planned",
|
|
2633
|
+
branch: null,
|
|
2634
|
+
designApproved: false,
|
|
2635
|
+
tags: [],
|
|
2636
|
+
priority: 99,
|
|
2637
|
+
dependencies: []
|
|
2638
|
+
};
|
|
2639
|
+
if (this.plan.epics.length > 0) {
|
|
2640
|
+
this.plan.epics[this.plan.epics.length - 1].stories.push(newStory);
|
|
2641
|
+
console.log(chalk4.dim(` + Story added: ${newStory.title}`));
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
} catch (err) {
|
|
2645
|
+
console.log(chalk4.dim(` Could not process: ${change.message}`));
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
// ── Summary ───────────────────────────────────────────────
|
|
2650
|
+
printSummary(errors) {
|
|
2651
|
+
const elapsedSec = (Date.now() - this.startTime) / 1e3;
|
|
2652
|
+
const elapsed = elapsedSec >= 60 ? `${Math.floor(elapsedSec / 60)}m ${Math.round(elapsedSec % 60)}s` : `${Math.round(elapsedSec)}s`;
|
|
2653
|
+
const allStories = this.getAllStories(this.plan);
|
|
2654
|
+
const done = allStories.filter((s) => s.status === "done").length;
|
|
2655
|
+
const blocked = allStories.filter((s) => s.status === "blocked").length;
|
|
2656
|
+
const total = allStories.length;
|
|
2657
|
+
if (!this.options.mute) {
|
|
2658
|
+
playSound();
|
|
2659
|
+
}
|
|
2660
|
+
console.log(chalk4.dim("\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
2661
|
+
console.log(chalk4.bold(" Done") + chalk4.dim(` in ${elapsed} \xB7 ${done}/${total} stories`));
|
|
2662
|
+
const u = this.totalUsage;
|
|
2663
|
+
if (u.inputTokens > 0 || u.outputTokens > 0) {
|
|
2664
|
+
console.log(chalk4.dim(
|
|
2665
|
+
` ${this.formatTokens(u.inputTokens)} in / ${this.formatTokens(u.outputTokens)} out` + (u.costUsd > 0 ? ` \xB7 ${this.formatCost(u.costUsd)}` : "")
|
|
2666
|
+
));
|
|
2667
|
+
}
|
|
2668
|
+
if (blocked > 0) {
|
|
2669
|
+
console.log(chalk4.yellow(` ${blocked} blocked`) + chalk4.dim(" \u2014 " + chalk4.white("forge status")));
|
|
2670
|
+
}
|
|
2671
|
+
for (const e of errors) {
|
|
2672
|
+
console.log(chalk4.dim(` ! ${e}`));
|
|
2673
|
+
}
|
|
2674
|
+
console.log("");
|
|
2675
|
+
}
|
|
2676
|
+
// ── Usage Tracking ────────────────────────────────────────
|
|
2677
|
+
addUsage(usage) {
|
|
2678
|
+
this.totalUsage.inputTokens += usage.inputTokens;
|
|
2679
|
+
this.totalUsage.outputTokens += usage.outputTokens;
|
|
2680
|
+
this.totalUsage.costUsd += usage.costUsd;
|
|
2681
|
+
this.totalUsage.durationMs += usage.durationMs;
|
|
2682
|
+
}
|
|
2683
|
+
formatTokens(n) {
|
|
2684
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
2685
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
|
2686
|
+
return String(n);
|
|
2687
|
+
}
|
|
2688
|
+
formatCost(usd) {
|
|
2689
|
+
return "$" + usd.toFixed(4);
|
|
2690
|
+
}
|
|
2691
|
+
// ── GitHub Sync ────────────────────────────────────────────
|
|
2692
|
+
async syncToGitHub(plan) {
|
|
2693
|
+
if (!this.config.githubSync || !this.config.githubRepo) return;
|
|
2694
|
+
if (!GitHubSync.isAvailable()) return;
|
|
2695
|
+
try {
|
|
2696
|
+
const gh = new GitHubSync(this.config.githubRepo);
|
|
2697
|
+
await gh.ensureLabels();
|
|
2698
|
+
const { created, updated } = await gh.syncPlan(plan);
|
|
2699
|
+
if (created > 0 || updated > 0) {
|
|
2700
|
+
console.log(chalk4.dim(` GitHub: ${created} issues created, ${updated} updated`));
|
|
2701
|
+
}
|
|
2702
|
+
} catch {
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
// ── Auto-Push ───────────────────────────────────────────
|
|
2706
|
+
async autoPush() {
|
|
2707
|
+
const hasRemote = await this.git.hasRemote();
|
|
2708
|
+
if (!hasRemote) return;
|
|
2709
|
+
const spinner = ora2({ text: `${this.elapsed()} Pushing to GitHub...`, indent: 2 }).start();
|
|
2710
|
+
this.activeSpinner = spinner;
|
|
2711
|
+
try {
|
|
2712
|
+
await this.git.push();
|
|
2713
|
+
await this.git.pushTags();
|
|
2714
|
+
spinner.succeed(`${this.elapsed()} Pushed to GitHub`);
|
|
2715
|
+
} catch {
|
|
2716
|
+
spinner.warn(`${this.elapsed()} Push failed` + chalk4.dim(" \u2014 run forge push to retry"));
|
|
2717
|
+
} finally {
|
|
2718
|
+
this.activeSpinner = null;
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
// ── Helpers ───────────────────────────────────────────────
|
|
2722
|
+
displayPlan(plan) {
|
|
2723
|
+
console.log(chalk4.bold(`
|
|
2724
|
+
${plan.project}`) + chalk4.dim(` \xB7 ${plan.framework}`));
|
|
2725
|
+
const total = this.getAllStories(plan).length;
|
|
2726
|
+
console.log(chalk4.dim(` ${total} stories across ${plan.epics.length} epics
|
|
2727
|
+
`));
|
|
2728
|
+
for (const epic of plan.epics) {
|
|
2729
|
+
console.log(chalk4.dim(` ${epic.title}`));
|
|
2730
|
+
for (const story of epic.stories) {
|
|
2731
|
+
const tag = story.type === "ui" ? "ui" : story.type === "backend" ? "api" : "full";
|
|
2732
|
+
console.log(` ${chalk4.dim(`[${tag}]`)} ${story.title}`);
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
console.log("");
|
|
2736
|
+
}
|
|
2737
|
+
getAllStories(plan) {
|
|
2738
|
+
return plan.epics.flatMap((e) => e.stories);
|
|
2739
|
+
}
|
|
2740
|
+
getStoriesByType(plan, types) {
|
|
2741
|
+
return this.getAllStories(plan).filter((s) => types.includes(s.type));
|
|
2742
|
+
}
|
|
2743
|
+
};
|
|
2744
|
+
|
|
2745
|
+
// src/core/utils/config.ts
|
|
2746
|
+
import chalk5 from "chalk";
|
|
2747
|
+
function validateConfig(config) {
|
|
2748
|
+
const errors = [];
|
|
2749
|
+
const warnings = [];
|
|
2750
|
+
if (!config || typeof config !== "object") {
|
|
2751
|
+
return { valid: false, errors: ["Config is empty or not an object"], warnings: [] };
|
|
2752
|
+
}
|
|
2753
|
+
const frameworks = listAdapters().map((a) => a.id);
|
|
2754
|
+
if (!config.framework) {
|
|
2755
|
+
errors.push("Missing 'framework' field");
|
|
2756
|
+
} else if (!frameworks.includes(config.framework)) {
|
|
2757
|
+
errors.push(
|
|
2758
|
+
`Unknown framework "${config.framework}". Supported: ${frameworks.join(", ")}`
|
|
2759
|
+
);
|
|
2760
|
+
}
|
|
2761
|
+
const validModels = ["sonnet", "opus", "haiku"];
|
|
2762
|
+
if (!config.model) {
|
|
2763
|
+
errors.push("Missing 'model' field");
|
|
2764
|
+
} else if (!validModels.includes(config.model)) {
|
|
2765
|
+
warnings.push(
|
|
2766
|
+
`Unknown model "${config.model}". Expected: ${validModels.join(", ")}`
|
|
2767
|
+
);
|
|
2768
|
+
}
|
|
2769
|
+
if (config.githubSync && !config.githubRepo) {
|
|
2770
|
+
warnings.push("GitHub sync enabled but no repo configured");
|
|
2771
|
+
}
|
|
2772
|
+
return {
|
|
2773
|
+
valid: errors.length === 0,
|
|
2774
|
+
errors,
|
|
2775
|
+
warnings
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
function loadAndValidateConfig(raw) {
|
|
2779
|
+
const result = validateConfig(raw);
|
|
2780
|
+
if (result.warnings.length > 0) {
|
|
2781
|
+
for (const w of result.warnings) {
|
|
2782
|
+
console.log(chalk5.yellow(` warning: ${w}`));
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
if (!result.valid) {
|
|
2786
|
+
for (const e of result.errors) {
|
|
2787
|
+
console.log(chalk5.red(` error: ${e}`));
|
|
2788
|
+
}
|
|
2789
|
+
console.log(chalk5.dim("\n Fix forge.config.json or run: forge init\n"));
|
|
2790
|
+
return null;
|
|
2791
|
+
}
|
|
2792
|
+
return raw;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
export {
|
|
2796
|
+
getAdapter,
|
|
2797
|
+
listAdapters,
|
|
2798
|
+
playSound,
|
|
2799
|
+
Orchestrator,
|
|
2800
|
+
Worker,
|
|
2801
|
+
GitManager,
|
|
2802
|
+
stateManager,
|
|
2803
|
+
Pipeline,
|
|
2804
|
+
AutoPipeline,
|
|
2805
|
+
validateConfig,
|
|
2806
|
+
loadAndValidateConfig
|
|
2807
|
+
};
|
|
2808
|
+
//# sourceMappingURL=chunk-2HFEPXBV.js.map
|