claudeboard 2.16.0 → 3.1.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.
@@ -0,0 +1,800 @@
1
+ // src/orchestrator.js
2
+ const { spawn } = require('child_process');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { createTask, updateTask, getTask, getTasks, savePRD, getPRD, getProjectPath, appendChatHistory, saveChatImage, writeHandoff, readHandoffs } = require('./store');
7
+ const { runVerifier } = require('./verifier');
8
+ const { notify } = require('./notifier');
9
+ const { scanProject } = require('./scanner');
10
+
11
+ // spawnClaude: writes prompt to temp file and pipes via PowerShell (Win) or sh (Mac/Linux)
12
+ // This is the only reliable cross-platform way to pass long prompts to `claude --print`
13
+ function spawnClaude(prompt, cwd) {
14
+ const tmpFile = path.join(os.tmpdir(), `cb-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
15
+ fs.writeFileSync(tmpFile, prompt, { encoding: 'utf8' });
16
+
17
+ let child;
18
+ if (process.platform === 'win32') {
19
+ // PowerShell pipes file content reliably on Windows (no cmd line length limit, no char escaping issues)
20
+ const psCmd = `Get-Content -Raw -Encoding UTF8 '${tmpFile}' | claude --permission-mode bypassPermissions --print`;
21
+ child = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], {
22
+ cwd,
23
+ stdio: ['ignore', 'pipe', 'pipe'],
24
+ shell: false,
25
+ });
26
+ } else {
27
+ child = spawn('sh', ['-c', `claude --permission-mode bypassPermissions --print < '${tmpFile}'`], {
28
+ cwd,
29
+ stdio: ['ignore', 'pipe', 'pipe'],
30
+ shell: false,
31
+ });
32
+ }
33
+
34
+ child.on('close', () => { try { fs.unlinkSync(tmpFile); } catch (_) {} });
35
+ return child;
36
+ }
37
+ const MAX_INPUT_LEN = 10000; // max user input chars
38
+ const AGENT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
39
+
40
+ // Assign the most qualified role based on task content
41
+ function assignAgentRole(task) {
42
+ const text = `${task.title} ${task.description}`.toLowerCase();
43
+
44
+ if (/hero|css|design|visual|ui|ux|layout|style|color|font|frontend|html|landing|responsive|animation|gradient|button|card|navbar|section|spacing|padding|margin|typograph|flex|grid|tailwind|sass|scss/.test(text))
45
+ return 'Frontend Dev';
46
+ if (/icon|logo|illustration|figma|branding|brand|mockup|asset|image|svg|banner/.test(text))
47
+ return 'UI/UX Dev';
48
+ if (/api|server|database|db|endpoint|route|auth|login|session|token|middleware|express|node|backend|prisma|sql|mongo|redis|cache/.test(text))
49
+ return 'Backend Dev';
50
+ if (/deploy|ci\/cd|docker|nginx|infrastructure|pipeline|env|devops|vercel|netlify|hosting/.test(text))
51
+ return 'DevOps';
52
+ if (/test|qa|quality|verify|coverage|spec|unit|e2e|cypress|jest|vitest/.test(text))
53
+ return 'QA Engineer';
54
+ if (/security|permission|xss|injection|csrf|vulnerab|sanitiz|encrypt/.test(text))
55
+ return 'Security Dev';
56
+ if (/data|analytic|migration|schema|model|orm|import|export|csv|etl/.test(text))
57
+ return 'Data Engineer';
58
+
59
+ return 'Lead Developer';
60
+ }
61
+
62
+ let activeAgents = new Map(); // taskId -> { proc, timer }
63
+ let broadcast = null;
64
+ let maxAgents = 3;
65
+ let agentSlot = 0;
66
+ let qaAgentRan = false;
67
+ let qaBuffer = '';
68
+ let serverPort = 3000;
69
+ let awaitingSupabaseCreds = false; // pauses queue until user provides Supabase credentials
70
+
71
+ function setBroadcast(fn) { broadcast = fn; }
72
+ function setMaxAgents(n) { maxAgents = n; }
73
+ function setServerPort(p) { serverPort = p; }
74
+
75
+ // Detect if a task involves Supabase operations
76
+ function isSupabaseTask(task) {
77
+ const text = `${task.title} ${task.description}`.toLowerCase();
78
+ return /supabase/.test(text);
79
+ }
80
+
81
+ // Strip null bytes and enforce length limit before any input reaches the CLI
82
+ function sanitizeInput(str) {
83
+ if (typeof str !== 'string') return '';
84
+ return str.replace(/\0/g, '').slice(0, MAX_INPUT_LEN);
85
+ }
86
+
87
+ // In-memory conversation state (not persisted to disk)
88
+ let conversationHistory = [];
89
+ let orchBuffer = '';
90
+ let projectContext = null;
91
+
92
+ function buildGreeting(ctx) {
93
+ if (ctx.isNewProject) {
94
+ return `¡Hola! Soy tu Orquestador. Contame qué querés construir y coordino tu equipo de agentes IA.`;
95
+ }
96
+
97
+ // Existing project
98
+ return `¡Bienvenido de vuelta! Ya tenés un PRD guardado. ¿Querés continuar, agregar tareas o empezar de nuevo?`;
99
+ }
100
+
101
+ function startOrchestrator() {
102
+ conversationHistory = [];
103
+ orchBuffer = '';
104
+
105
+ try {
106
+ projectContext = scanProject(getProjectPath());
107
+ } catch (err) {
108
+ console.warn('[orchestrator] scanner error:', err.message);
109
+ projectContext = null;
110
+ }
111
+
112
+ const greeting = projectContext
113
+ ? buildGreeting(projectContext)
114
+ : "¡Hola! Soy tu Orquestador. Contame qué querés construir y coordino tu equipo de agentes IA.";
115
+
116
+ if (broadcast) broadcast({ type: 'orchestrator:chunk', chunk: greeting });
117
+ }
118
+
119
+ function handleOrchOutput(text) {
120
+ orchBuffer += text;
121
+
122
+ const prdMatch = orchBuffer.match(/<PRD>([\s\S]*?)<\/PRD>/);
123
+ if (prdMatch) {
124
+ savePRD(prdMatch[1].trim());
125
+ notify('prd:generated', { taskTitle: null, status: 'generated' });
126
+ }
127
+
128
+ // Parse tasks when </TASKS> closes the block
129
+ if (orchBuffer.includes('</TASKS>')) {
130
+ let taskDefs = [];
131
+
132
+ // Primary: <TASK> key:value blocks
133
+ const taskBlocks = [...orchBuffer.matchAll(/<TASK>([\s\S]*?)<\/TASK>/g)];
134
+ if (taskBlocks.length > 0) {
135
+ taskDefs = taskBlocks.map(m => {
136
+ const block = m[1];
137
+ const get = (key) => {
138
+ const match = block.match(new RegExp(`^${key}:\\s*(.+)$`, 'mi'));
139
+ return match ? match[1].trim() : '';
140
+ };
141
+ return { title: get('title'), description: get('description'), successCriteria: get('successCriteria'), priority: get('priority') || 'medium' };
142
+ }).filter(t => t.title);
143
+ }
144
+
145
+ // Fallback: JSON array inside <TASKS>...</TASKS>
146
+ if (taskDefs.length === 0) {
147
+ try {
148
+ const jsonMatch = orchBuffer.match(/<TASKS>\s*(\[[\s\S]*?\])\s*<\/TASKS>/);
149
+ if (jsonMatch) {
150
+ const parsed = JSON.parse(jsonMatch[1]);
151
+ if (Array.isArray(parsed)) {
152
+ taskDefs = parsed.filter(t => t && t.title).map(t => ({
153
+ title: String(t.title || '').slice(0, 200),
154
+ description: String(t.description || '').slice(0, 2000),
155
+ successCriteria: String(t.successCriteria || '').slice(0, 1000),
156
+ priority: t.priority || 'medium',
157
+ }));
158
+ }
159
+ }
160
+ } catch { /* malformed JSON — skip */ }
161
+ }
162
+
163
+ if (taskDefs.length > 0) {
164
+ const created = taskDefs.map(t => createTask(t));
165
+ if (broadcast) broadcast({ type: 'tasks:created', tasks: created });
166
+ notify('tasks:created', { taskTitle: null, status: 'created' });
167
+ qaAgentRan = false;
168
+ orchBuffer = '';
169
+ // If any task requires Supabase, pause queue and ask user for credentials
170
+ const needsSupabase = taskDefs.some(isSupabaseTask);
171
+ if (needsSupabase && !awaitingSupabaseCreds) {
172
+ awaitingSupabaseCreds = true;
173
+ if (broadcast) broadcast({
174
+ type: 'supabase:preflight',
175
+ taskTitles: created.map(t => t.title),
176
+ });
177
+ } else {
178
+ processQueue();
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ function sendMessage(rawMessage, imageData = null) {
185
+ const message = sanitizeInput(rawMessage);
186
+ if (!message) return;
187
+
188
+ orchBuffer = ''; // reset buffer for each new message exchange
189
+ conversationHistory.push({ role: 'user', content: message });
190
+ appendChatHistory({ role: 'user', content: message });
191
+ if (broadcast) broadcast({ type: 'orchestrator:thinking' });
192
+
193
+ // Save attached image to disk so Claude can read it
194
+ let imagePath = null;
195
+ if (imageData) {
196
+ try { imagePath = saveChatImage(imageData); } catch (err) {
197
+ console.warn('[orchestrator] failed to save chat image:', err.message);
198
+ }
199
+ }
200
+
201
+ // Build project context section for the system prompt
202
+ let projectSection = '';
203
+ if (projectContext) {
204
+ const lines = [];
205
+ lines.push(`## Project Context`);
206
+ lines.push(`- **Name**: ${projectContext.projectName}`);
207
+ if (projectContext.techStack.length > 0) {
208
+ lines.push(`- **Tech stack**: ${projectContext.techStack.join(', ')}`);
209
+ }
210
+ if (projectContext.pkgDescription) {
211
+ lines.push(`- **Description**: ${projectContext.pkgDescription}`);
212
+ }
213
+ if (projectContext.readme) {
214
+ lines.push(`\n### README (excerpt)\n${projectContext.readme.slice(0, 1000)}`);
215
+ }
216
+ if (projectContext.existingPrd) {
217
+ lines.push(`\n### Existing PRD\n${projectContext.existingPrd.slice(0, 2000)}`);
218
+ }
219
+ if (projectContext.contextMd) {
220
+ lines.push(`\n### Brand guidelines / context.md\n${projectContext.contextMd.slice(0, 2000)}`);
221
+ }
222
+ lines.push(`\n### File tree\n\`\`\`\n${projectContext.fileTree.slice(0, 2000)}\n\`\`\``);
223
+ projectSection = '\n\n' + lines.join('\n');
224
+ }
225
+
226
+ const systemPrompt = `Sos el orquestador de ClaudeBoard. Español argentino, muy directo.${projectSection}
227
+
228
+ Tu trabajo: traducir el pedido del usuario en tareas para agentes especialistas (Frontend Dev, Backend Dev, UI/UX Dev, etc.).
229
+
230
+ CUÁNTAS TAREAS CREAR:
231
+ - Si el usuario pide UN cambio o mejora puntual → 1 tarea.
232
+ - Solo dividir en múltiples tareas si los cambios son en partes del proyecto claramente independientes (ej: frontend + backend por separado).
233
+ - Nunca dividir algo que un solo agente puede resolver en una sola pasada.
234
+
235
+ CÓMO ESCRIBIR LA DESCRIPCIÓN:
236
+ - Reproducir el pedido del usuario casi literalmente, con contexto del proyecto si aplica.
237
+ - Sin inventar implementación. Sin decirle AL AGENTE cómo hacerlo (eso lo decide él).
238
+ - El agente va a leer el proyecto, entender el código y resolver por su cuenta.
239
+
240
+ FORMATO EXACTO (no agregar nada más):
241
+
242
+ Listo, [N] tarea(s) en el board.
243
+
244
+ <PRD>
245
+ Una oración con el objetivo.
246
+ </PRD>
247
+ <TASKS>
248
+ <TASK>
249
+ title: Qué hay que lograr
250
+ description: El pedido del usuario en sus propias palabras + contexto relevante del proyecto. Sin instrucciones técnicas de implementación.
251
+ successCriteria: Cómo se ve el resultado correcto para el usuario final
252
+ priority: high|medium|low
253
+ </TASK>
254
+ </TASKS>`;
255
+
256
+ const historyText = conversationHistory
257
+ .map(m => `${m.role === 'user' ? 'Usuario' : 'Orquestador'}: ${m.content}`)
258
+ .join('\n\n');
259
+
260
+ const imageSection = imagePath
261
+ ? `\n\nEl usuario adjuntó una imagen del estado actual del proyecto en: ${imagePath}\nPodés leerla para ver cómo se ve visualmente y tenerlo en cuenta al crear las tareas.`
262
+ : '';
263
+
264
+ const fullPrompt = `${systemPrompt}\n\n--- CONVERSACIÓN ACTUAL ---\n${historyText}${imageSection}\n\nOrquestador:`;
265
+
266
+ const proc = spawnClaude(fullPrompt, process.cwd());
267
+
268
+ let response = '';
269
+
270
+ proc.stdout.on('data', (data) => {
271
+ const chunk = data.toString('utf-8');
272
+ response += chunk;
273
+ if (broadcast) broadcast({ type: 'orchestrator:chunk', chunk });
274
+ handleOrchOutput(chunk);
275
+ });
276
+
277
+ proc.stderr.on('data', () => {
278
+ // Swallow — don't expose internal details or system paths to the client
279
+ });
280
+
281
+ proc.on('close', () => {
282
+ if (response) {
283
+ const assistantContent = response.slice(0, MAX_INPUT_LEN);
284
+ conversationHistory.push({ role: 'assistant', content: assistantContent });
285
+ appendChatHistory({ role: 'assistant', content: assistantContent });
286
+ }
287
+ });
288
+
289
+ proc.on('error', (err) => {
290
+ console.error('[orchestrator] spawn error:', err.message);
291
+ if (broadcast) broadcast({ type: 'orchestrator:error', message: 'Orchestrator failed to start. Is the claude CLI installed and logged in?' });
292
+ });
293
+ }
294
+
295
+ function spawnTaskAgent(task) {
296
+ if (activeAgents.has(task.id)) return;
297
+
298
+ // Assign the most qualified role based on task content
299
+ const agentLabel = assignAgentRole(task);
300
+
301
+ const projectPath = getProjectPath();
302
+
303
+ // Read .claudeboard/context.md if present
304
+ let contextMdContent = '';
305
+ try {
306
+ const contextMdPath = path.join(projectPath, '.claudeboard', 'context.md');
307
+ if (fs.existsSync(contextMdPath)) {
308
+ contextMdContent = fs.readFileSync(contextMdPath, 'utf-8').slice(0, 8000);
309
+ }
310
+ } catch { /* ignore */ }
311
+
312
+ // Detect file path mentioned in task description
313
+ let fileContent = '';
314
+ const filePathMatch = task.description && task.description.match(/[\w/\\.\-]+\.(html|css|js|ts|jsx|tsx|json|md)/i);
315
+ if (filePathMatch) {
316
+ try {
317
+ const mentionedPath = filePathMatch[0];
318
+ const absPath = path.isAbsolute(mentionedPath)
319
+ ? mentionedPath
320
+ : path.join(projectPath, mentionedPath);
321
+ if (fs.existsSync(absPath)) {
322
+ fileContent = fs.readFileSync(absPath, 'utf-8').slice(0, 8000);
323
+ }
324
+ } catch { /* ignore */ }
325
+ }
326
+
327
+ // File tree from project context
328
+ const fileTree = projectContext ? projectContext.fileTree : '';
329
+
330
+ const contextMdSection = contextMdContent
331
+ ? `\n## Guías de marca / Design tokens\n${contextMdContent}`
332
+ : '';
333
+
334
+ const fileTreeSection = fileTree
335
+ ? `\n## Contexto del proyecto\n\`\`\`\n${fileTree.slice(0, 3000)}\n\`\`\``
336
+ : '';
337
+
338
+ const fileContentSection = fileContent
339
+ ? `\n## Contenido actual del archivo relevante\n\`\`\`\n${fileContent}\n\`\`\``
340
+ : '';
341
+
342
+ // Error memory: tell the agent what went wrong in a previous attempt
343
+ const retrySection = (task.retryCount > 0 && task.previousOutput)
344
+ ? `\n\n## ⚠ Intento anterior fallido (intento ${task.retryCount})\nEsto es lo que salió en el intento anterior — **no repitas el mismo enfoque**:\n\`\`\`\n${task.previousOutput.slice(-2000)}\n\`\`\``
345
+ : '';
346
+
347
+ // Agent handoffs: context from previous agents that already worked on this project
348
+ const handoffs = readHandoffs(task.id);
349
+ const handoffSection = handoffs.length > 0
350
+ ? `\n\n## Contexto de agentes anteriores\n${handoffs.join('\n\n---\n\n').slice(0, 4000)}`
351
+ : '';
352
+
353
+ const prompt = `Sos un agente de desarrollo de software. Completá la siguiente tarea exactamente como se describe.
354
+
355
+ ## Tarea
356
+ Título: ${task.title}
357
+ Descripción: ${task.description}
358
+ Criterio de éxito: ${task.successCriteria}
359
+ Prioridad: ${task.priority || 'medium'}
360
+
361
+ ## Directorio de trabajo
362
+ ${projectPath}
363
+ ${fileTreeSection}${contextMdSection}${fileContentSection}${handoffSection}${retrySection}
364
+
365
+ Trabajá en el directorio actual. Sé meticuloso, seguí el estilo existente del código y las guías de marca si están disponibles.
366
+
367
+ Cuando termines, escribí un bloque <HANDOFF> con lo que hiciste para que el próximo agente lo sepa:
368
+ <HANDOFF>
369
+ Archivos modificados: [lista de archivos]
370
+ Decisiones tomadas: [notas clave sobre el enfoque]
371
+ Lo que falta: [si algo quedó pendiente, si no nada]
372
+ </HANDOFF>`;
373
+
374
+ updateTask(task.id, { status: 'in_progress', agentLabel });
375
+ if (broadcast) broadcast({ type: 'task:started', taskId: task.id, agentLabel });
376
+
377
+ const proc = spawnClaude(prompt, projectPath);
378
+
379
+ const timer = setTimeout(() => {
380
+ if (!proc.killed) {
381
+ console.warn(`[orchestrator] agent for task ${task.id} timed out — killing`);
382
+ proc.kill('SIGTERM');
383
+ }
384
+ }, AGENT_TIMEOUT_MS);
385
+
386
+ activeAgents.set(task.id, { proc, timer });
387
+
388
+ proc.stdout.on('data', (data) => {
389
+ const chunk = data.toString('utf-8');
390
+ const current = getTask(task.id);
391
+ if (current) {
392
+ updateTask(task.id, { output: (current.output || '') + chunk });
393
+ }
394
+ if (broadcast) broadcast({ type: 'task:output', taskId: task.id, chunk });
395
+ });
396
+
397
+ proc.stderr.on('data', () => {
398
+ // Swallow stderr — don't expose system paths or tokens
399
+ });
400
+
401
+ proc.on('close', () => {
402
+ clearTimeout(timer);
403
+ activeAgents.delete(task.id);
404
+ const current = getTask(task.id);
405
+ // Extract and save handoff note for future agents
406
+ if (current) {
407
+ const outputText = Array.isArray(current.output)
408
+ ? current.output.map(e => e.chunk || '').join('')
409
+ : (current.output || '');
410
+ const handoffMatch = outputText.match(/<HANDOFF>([\s\S]*?)<\/HANDOFF>/);
411
+ if (handoffMatch) writeHandoff(task.id, handoffMatch[1].trim());
412
+ }
413
+ if (current && current.status === 'in_progress') {
414
+ runVerifier(current, broadcast, processQueue);
415
+ }
416
+ processQueue();
417
+ });
418
+
419
+ proc.on('error', (err) => {
420
+ clearTimeout(timer);
421
+ activeAgents.delete(task.id);
422
+ updateTask(task.id, { status: 'error' });
423
+ if (broadcast) broadcast({ type: 'task:error', taskId: task.id, reason: 'Agent process failed to start' });
424
+ console.error(`[orchestrator] spawn error for task ${task.id}:`, err.message);
425
+ processQueue();
426
+ });
427
+ }
428
+
429
+ function processQueue() {
430
+ // Don't start tasks while waiting for user to provide required credentials
431
+ if (awaitingSupabaseCreds) return;
432
+
433
+ const tasks = getTasks();
434
+ const backlog = tasks.filter(t => t.status === 'backlog');
435
+ // Always run 1 task at a time so each agent sees the previous agent's handoff context
436
+ const slots = 1 - activeAgents.size;
437
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
438
+ const toStart = backlog
439
+ .sort((a, b) => (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1))
440
+ .slice(0, Math.max(0, slots));
441
+ toStart.forEach(t => spawnTaskAgent(t));
442
+
443
+ // Trigger QA when all tasks are done and none are running
444
+ if (toStart.length === 0 && activeAgents.size === 0 && !qaAgentRan) {
445
+ const allTasks = getTasks();
446
+ const pending = allTasks.filter(t => ['backlog', 'in_progress', 'verifying'].includes(t.status));
447
+ const done = allTasks.filter(t => t.status === 'done');
448
+ if (pending.length === 0 && done.length > 0) {
449
+ qaAgentRan = true;
450
+ spawnQAAgent(allTasks);
451
+ }
452
+ }
453
+ }
454
+
455
+ function startTask(taskId) {
456
+ const task = getTask(taskId);
457
+ if (!task) return;
458
+ if (task.status === 'error') {
459
+ updateTask(taskId, { status: 'backlog', output: '', verifierOutput: '' });
460
+ const reset = getTask(taskId);
461
+ if (broadcast) broadcast({ type: 'task:updated', task: reset });
462
+ spawnTaskAgent(reset);
463
+ } else if (task.status === 'backlog') {
464
+ spawnTaskAgent(task);
465
+ }
466
+ }
467
+
468
+ function spawnQAAgent(allTasks) {
469
+ const prd = (() => { try { return getPRD() || ''; } catch { return ''; } })();
470
+ const doneSummary = allTasks.filter(t => t.status === 'done').map(t => `- ${t.title}`).join('\n');
471
+ const errorSummary = allTasks.filter(t => t.status === 'error').map(t => `- ${t.title}`).join('\n');
472
+
473
+ const targetUrl = `http://127.0.0.1:${serverPort}`;
474
+ const screenshotPath = path.join(process.cwd(), '.claudeboard', 'qa-screenshot.png');
475
+ const screenshotPathEscaped = screenshotPath.replace(/\\/g, '/');
476
+
477
+ // Platform hints for finding Chrome
478
+ let findChromeHint, chromeFlagHint;
479
+ if (process.platform === 'win32') {
480
+ findChromeHint = `Run this to find Chrome: cmd /c "where chrome 2>nul & where msedge 2>nul & dir \\"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\\" 2>nul & dir \\"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe\\" 2>nul"`;
481
+ chromeFlagHint = `--headless=new --disable-gpu --no-sandbox --screenshot="${screenshotPathEscaped}" --window-size=1280,900`;
482
+ } else if (process.platform === 'darwin') {
483
+ findChromeHint = `Run: which google-chrome || ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 2>/dev/null`;
484
+ chromeFlagHint = `--headless=new --disable-gpu --no-sandbox --screenshot="${screenshotPath}" --window-size=1280,900`;
485
+ } else {
486
+ findChromeHint = `Run: which google-chrome || which chromium-browser || which chromium`;
487
+ chromeFlagHint = `--headless=new --disable-gpu --no-sandbox --screenshot="${screenshotPath}" --window-size=1280,900`;
488
+ }
489
+
490
+ const prompt = `You are a QA Engineer doing a visual review of a web project.
491
+ The app is running at: ${targetUrl}
492
+
493
+ PRD / Objective:
494
+ ${prd || 'No PRD available — review the completed tasks below.'}
495
+
496
+ Completed tasks:
497
+ ${doneSummary || '(none)'}
498
+ ${errorSummary ? `\nFailed tasks (not fixed):\n${errorSummary}` : ''}
499
+
500
+ == YOUR STEPS (follow in order) ==
501
+
502
+ STEP 1 — Find Chrome on this machine.
503
+ ${findChromeHint}
504
+ If Chrome is not found, try Microsoft Edge (msedge) as a fallback.
505
+
506
+ STEP 2 — Take a screenshot.
507
+ Use the Chrome (or Edge) path you found and run:
508
+ [chrome-path] ${chromeFlagHint} ${targetUrl}
509
+ This saves the screenshot to: ${screenshotPath}
510
+ NOTE: This runs headless — no window will open. It silently saves the PNG file.
511
+
512
+ STEP 3 — Read the screenshot file.
513
+ Use your file reading tool to read the image at: ${screenshotPath}
514
+ Look carefully at: layout, colors, spacing, broken elements, missing content, typography.
515
+
516
+ STEP 4 — Review the source files.
517
+ Also read the main HTML/CSS/JS files of the project to spot any code-level issues.
518
+
519
+ STEP 5 — Report results.
520
+ If you find visual or functional issues, output EXACTLY this format (no JSON, no markdown fences):
521
+
522
+ <TASKS>
523
+ <TASK>
524
+ title: Fix: [describe the issue concisely]
525
+ description: [exact file path + what to change]
526
+ successCriteria: [how to verify it looks/works correctly]
527
+ priority: high
528
+ </TASK>
529
+ </TASKS>
530
+
531
+ If everything is correct, say only: QA PASSED — all objectives met.`;
532
+
533
+ if (broadcast) broadcast({ type: 'qa:started' });
534
+
535
+ const projectPath = getProjectPath();
536
+ const proc = spawnClaude(prompt, projectPath);
537
+ qaBuffer = '';
538
+
539
+ proc.stdout.on('data', (data) => {
540
+ const chunk = data.toString('utf-8');
541
+ if (broadcast) broadcast({ type: 'qa:output', chunk });
542
+ qaBuffer += chunk;
543
+ if (qaBuffer.includes('</TASKS>')) {
544
+ const taskBlocks = [...qaBuffer.matchAll(/<TASK>([\s\S]*?)<\/TASK>/g)];
545
+ if (taskBlocks.length > 0) {
546
+ const taskDefs = taskBlocks.map(m => {
547
+ const block = m[1];
548
+ const get = (key) => {
549
+ const match = block.match(new RegExp(`^${key}:\\s*(.+)$`, 'mi'));
550
+ return match ? match[1].trim() : '';
551
+ };
552
+ return { title: get('title'), description: get('description'), successCriteria: get('successCriteria'), priority: get('priority') || 'high' };
553
+ }).filter(t => t.title);
554
+ if (taskDefs.length > 0) {
555
+ const created = taskDefs.map(t => createTask(t));
556
+ if (broadcast) broadcast({ type: 'tasks:created', tasks: created });
557
+ qaAgentRan = false; // new QA tasks → allow QA to run again after they complete
558
+ qaBuffer = '';
559
+ processQueue();
560
+ }
561
+ }
562
+ }
563
+ });
564
+
565
+ proc.on('close', () => {
566
+ if (broadcast) broadcast({ type: 'qa:done' });
567
+ // Send screenshot inline to chat (cap at 2 MB)
568
+ try {
569
+ if (fs.existsSync(screenshotPath)) {
570
+ const stats = fs.statSync(screenshotPath);
571
+ if (stats.size > 0 && stats.size < 2 * 1024 * 1024) {
572
+ const base64 = fs.readFileSync(screenshotPath).toString('base64');
573
+ if (broadcast) broadcast({ type: 'qa:screenshot', image: `data:image/png;base64,${base64}` });
574
+ }
575
+ }
576
+ } catch { /* ignore — screenshot is optional */ }
577
+ });
578
+
579
+ proc.on('error', (err) => {
580
+ console.error('[qa] spawn error:', err.message);
581
+ if (broadcast) broadcast({ type: 'qa:done' });
582
+ });
583
+ }
584
+
585
+ // Production checklist agent — web or mobile depending on project deps
586
+ function spawnChecklistAgent() {
587
+ const projectPath = getProjectPath();
588
+ let isMobile = false;
589
+ try {
590
+ const pkgPath = path.join(projectPath, 'package.json');
591
+ if (fs.existsSync(pkgPath)) {
592
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
593
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
594
+ isMobile = !!(deps['react-native'] || deps['expo'] || deps['@capacitor/core'] || deps['flutter']);
595
+ }
596
+ } catch { /* ignore */ }
597
+
598
+ const checklist = isMobile ? `
599
+ 1. App icon: app.json/expo has a valid icon path, not placeholder
600
+ 2. Bundle ID: bundleIdentifier/packageName is NOT "com.example" or placeholder
601
+ 3. No hardcoded localhost URLs in API calls or config files
602
+ 4. Permissions declared only if actually used
603
+ 5. App store metadata: name and description filled in app.json/expo.json
604
+ 6. No console.log in production code
605
+ 7. Version/build number is set (not 1.0.0 default)` : `
606
+ 1. Favicon: <link rel="icon"> present in index.html, file exists
607
+ 2. Viewport meta: <meta name="viewport" content="width=device-width, initial-scale=1">
608
+ 3. Description meta: <meta name="description" content="..."> with real text, not empty
609
+ 4. All images have alt attributes
610
+ 5. No http:// links (use https:// or relative)
611
+ 6. Form submit handlers present
612
+ 7. No console.log in production code
613
+ 8. Title tag not "React App", "Vite App" or other placeholder default`;
614
+
615
+ const prompt = `You are a production readiness agent. Review this ${isMobile ? 'mobile' : 'web'} project for production readiness.
616
+
617
+ Project directory: ${projectPath}
618
+
619
+ PRODUCTION CHECKLIST (${isMobile ? 'MOBILE' : 'WEB'}):
620
+ ${checklist}
621
+
622
+ INSTRUCTIONS:
623
+ 1. Read the relevant project files (index.html, package.json, app.json, source files).
624
+ 2. Check each checklist item one by one.
625
+ 3. If you find issues, output EXACTLY this format:
626
+
627
+ <TASKS>
628
+ <TASK>
629
+ title: Fix: [concise issue description]
630
+ description: [exact file and what to change]
631
+ successCriteria: [how to verify it's fixed]
632
+ priority: high
633
+ </TASK>
634
+ </TASKS>
635
+
636
+ If everything passes, say only: CHECKLIST PASSED — project is production ready.`;
637
+
638
+ if (broadcast) broadcast({ type: 'checklist:started', isMobile });
639
+
640
+ const proc = spawnClaude(prompt, projectPath);
641
+ let checklistBuffer = '';
642
+
643
+ proc.stdout.on('data', (data) => {
644
+ const chunk = data.toString('utf-8');
645
+ if (broadcast) broadcast({ type: 'checklist:output', chunk });
646
+ checklistBuffer += chunk;
647
+ if (checklistBuffer.includes('</TASKS>')) {
648
+ const taskBlocks = [...checklistBuffer.matchAll(/<TASK>([\s\S]*?)<\/TASK>/g)];
649
+ if (taskBlocks.length > 0) {
650
+ const taskDefs = taskBlocks.map(m => {
651
+ const block = m[1];
652
+ const get = (key) => { const match = block.match(new RegExp(`^${key}:\\s*(.+)$`, 'mi')); return match ? match[1].trim() : ''; };
653
+ return { title: get('title'), description: get('description'), successCriteria: get('successCriteria'), priority: 'high' };
654
+ }).filter(t => t.title);
655
+ if (taskDefs.length > 0) {
656
+ const created = taskDefs.map(t => createTask(t));
657
+ if (broadcast) broadcast({ type: 'tasks:created', tasks: created });
658
+ checklistBuffer = '';
659
+ processQueue();
660
+ }
661
+ }
662
+ }
663
+ });
664
+
665
+ proc.on('close', () => { if (broadcast) broadcast({ type: 'checklist:done', passed: checklistBuffer.includes('CHECKLIST PASSED') }); });
666
+ proc.on('error', (err) => { console.error('[checklist] spawn error:', err.message); if (broadcast) broadcast({ type: 'checklist:done', passed: false }); });
667
+ }
668
+
669
+ // Deploy to Netlify
670
+ function spawnDeployAgent() {
671
+ const projectPath = getProjectPath();
672
+ let buildCmd = 'npm run build';
673
+ let distDir = 'dist';
674
+ try {
675
+ const pkgPath = path.join(projectPath, 'package.json');
676
+ if (fs.existsSync(pkgPath)) {
677
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
678
+ if (pkg.scripts && !pkg.scripts.build) buildCmd = 'echo "No build step"';
679
+ }
680
+ for (const dir of ['dist', 'build', 'out', '.next']) {
681
+ if (fs.existsSync(path.join(projectPath, dir))) { distDir = dir; break; }
682
+ }
683
+ } catch { /* ignore */ }
684
+
685
+ const prompt = `You are a DevOps agent. Deploy this project to Netlify.
686
+
687
+ Project directory: ${projectPath}
688
+ Likely build command: ${buildCmd}
689
+ Likely dist directory: ${distDir}
690
+
691
+ STEPS:
692
+ 1. Check if netlify CLI is installed: run \`netlify --version\`. If not found, run \`npm install -g netlify-cli\`.
693
+ 2. Check if already linked: look for .netlify/state.json in project directory.
694
+ 3. Build the project: run \`${buildCmd}\` in the project directory.
695
+ 4. Deploy: run \`netlify deploy --prod --dir=${distDir}\` in the project directory.
696
+ 5. Report the live URL.
697
+
698
+ If any step fails, clearly explain what failed and why.
699
+ At the end, output the deployment URL exactly like this: DEPLOYED: https://your-site.netlify.app`;
700
+
701
+ if (broadcast) broadcast({ type: 'deploy:started' });
702
+
703
+ const proc = spawnClaude(prompt, projectPath);
704
+ let deployBuffer = '';
705
+
706
+ proc.stdout.on('data', (data) => {
707
+ const chunk = data.toString('utf-8');
708
+ if (broadcast) broadcast({ type: 'deploy:output', chunk });
709
+ deployBuffer += chunk;
710
+ });
711
+
712
+ proc.on('close', () => {
713
+ const urlMatch = deployBuffer.match(/DEPLOYED:\s*(https?:\/\/\S+)/);
714
+ if (broadcast) broadcast({ type: 'deploy:done', url: urlMatch ? urlMatch[1] : null });
715
+ });
716
+
717
+ proc.on('error', (err) => {
718
+ console.error('[deploy] spawn error:', err.message);
719
+ if (broadcast) broadcast({ type: 'deploy:done', url: null });
720
+ });
721
+ }
722
+
723
+ function stopTask(taskId) {
724
+ const agent = activeAgents.get(taskId);
725
+ if (agent) {
726
+ clearTimeout(agent.timer);
727
+ if (!agent.proc.killed) agent.proc.kill('SIGTERM');
728
+ activeAgents.delete(taskId);
729
+ }
730
+ updateTask(taskId, { status: 'backlog' });
731
+ if (broadcast) broadcast({ type: 'task:stopped', taskId });
732
+ }
733
+
734
+ function pauseAll() {
735
+ for (const [taskId, { proc, timer }] of activeAgents) {
736
+ clearTimeout(timer);
737
+ if (!proc.killed) proc.kill('SIGTERM');
738
+ updateTask(taskId, { status: 'backlog' });
739
+ if (broadcast) broadcast({ type: 'task:stopped', taskId });
740
+ }
741
+ activeAgents.clear();
742
+ }
743
+
744
+ function killAll() {
745
+ for (const [, { proc, timer }] of activeAgents) {
746
+ clearTimeout(timer);
747
+ if (!proc.killed) proc.kill('SIGTERM');
748
+ }
749
+ activeAgents.clear();
750
+ }
751
+
752
+ // Manual QA trigger — can be called regardless of task state
753
+ function runQA() {
754
+ qaAgentRan = false;
755
+ spawnQAAgent(getTasks());
756
+ }
757
+
758
+ // Upload PRD from a markdown file — sends it through the orchestrator to generate tasks
759
+ function uploadPRD(content) {
760
+ const trimmed = content.slice(0, 20000);
761
+ sendMessage(`Tengo mi PRD listo. Analizalo y creá las tareas necesarias para implementarlo:\n\n${trimmed}`);
762
+ }
763
+
764
+ // Called when the user submits Supabase credentials from the modal
765
+ function provideCredentials({ supabaseUrl, anonKey, serviceKey, writeDotEnv }) {
766
+ awaitingSupabaseCreds = false;
767
+
768
+ if (writeDotEnv && supabaseUrl) {
769
+ try {
770
+ const projectPath = getProjectPath();
771
+ const envPath = path.join(projectPath, '.env');
772
+ let envContent = '';
773
+ try { envContent = fs.readFileSync(envPath, 'utf-8'); } catch { /* file doesn't exist yet */ }
774
+
775
+ const vars = {
776
+ SUPABASE_URL: supabaseUrl || '',
777
+ SUPABASE_ANON_KEY: anonKey || '',
778
+ };
779
+ if (serviceKey) vars['SUPABASE_SERVICE_ROLE_KEY'] = serviceKey;
780
+
781
+ for (const [key, val] of Object.entries(vars)) {
782
+ if (!val) continue;
783
+ const regex = new RegExp(`^${key}=.*$`, 'm');
784
+ if (regex.test(envContent)) {
785
+ envContent = envContent.replace(regex, `${key}=${val}`);
786
+ } else {
787
+ envContent += `\n${key}=${val}`;
788
+ }
789
+ }
790
+ fs.writeFileSync(envPath, envContent.trim() + '\n', 'utf-8');
791
+ if (broadcast) broadcast({ type: 'supabase:env_saved' });
792
+ } catch (err) {
793
+ console.error('[orchestrator] failed to write .env:', err.message);
794
+ }
795
+ }
796
+
797
+ processQueue();
798
+ }
799
+
800
+ module.exports = { setBroadcast, setMaxAgents, setServerPort, sendMessage, startTask, processQueue, startOrchestrator, killAll, stopTask, pauseAll, runQA, spawnChecklistAgent, spawnDeployAgent, uploadPRD, provideCredentials };