claudeboard 2.16.0 → 3.1.1

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/src/scanner.js ADDED
@@ -0,0 +1,153 @@
1
+ // src/scanner.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const EXCLUDE_DIRS = new Set([
6
+ 'node_modules', '.git', '.claudeboard', 'dist', 'build',
7
+ '.next', 'coverage', '.turbo', 'out', '.cache',
8
+ ]);
9
+
10
+ const MAX_DEPTH = 3;
11
+ const MAX_ENTRIES_PER_DIR = 50;
12
+
13
+ function buildFileTree(dir, depth = 0, prefix = '') {
14
+ if (depth >= MAX_DEPTH) return [];
15
+
16
+ let entries;
17
+ try {
18
+ entries = fs.readdirSync(dir, { withFileTypes: true });
19
+ } catch {
20
+ return [];
21
+ }
22
+
23
+ const filtered = entries
24
+ .filter(e => !EXCLUDE_DIRS.has(e.name) && !e.name.startsWith('.'))
25
+ .slice(0, MAX_ENTRIES_PER_DIR);
26
+
27
+ const lines = [];
28
+ filtered.forEach((entry, idx) => {
29
+ const isLast = idx === filtered.length - 1;
30
+ const connector = isLast ? '└── ' : '├── ';
31
+ lines.push(prefix + connector + entry.name + (entry.isDirectory() ? '/' : ''));
32
+
33
+ if (entry.isDirectory()) {
34
+ const childPrefix = prefix + (isLast ? ' ' : '│ ');
35
+ lines.push(...buildFileTree(path.join(dir, entry.name), depth + 1, childPrefix));
36
+ }
37
+ });
38
+
39
+ return lines;
40
+ }
41
+
42
+ function detectTechStack(pkgJson, projectDir) {
43
+ const stack = [];
44
+
45
+ if (pkgJson) {
46
+ const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
47
+
48
+ if (deps['next']) stack.push('Next.js');
49
+ else if (deps['react']) stack.push('React');
50
+ if (deps['vue']) stack.push('Vue');
51
+ if (deps['svelte']) stack.push('Svelte');
52
+ if (deps['@angular/core']) stack.push('Angular');
53
+ if (deps['express']) stack.push('Express');
54
+ if (deps['fastify']) stack.push('Fastify');
55
+ if (deps['koa']) stack.push('Koa');
56
+ if (deps['@nestjs/core']) stack.push('NestJS');
57
+ if (deps['typescript'] || deps['ts-node']) stack.push('TypeScript');
58
+ if (deps['tailwindcss']) stack.push('Tailwind CSS');
59
+ if (deps['prisma'] || deps['@prisma/client']) stack.push('Prisma');
60
+ if (deps['mongoose']) stack.push('MongoDB/Mongoose');
61
+ if (deps['sequelize']) stack.push('Sequelize');
62
+ if (deps['graphql']) stack.push('GraphQL');
63
+ if (deps['electron']) stack.push('Electron');
64
+ if (deps['jest'] || deps['vitest']) stack.push('Testing');
65
+ }
66
+
67
+ // Detect from files in root
68
+ try {
69
+ const files = fs.readdirSync(projectDir);
70
+ if (files.some(f => f.endsWith('.py') || f === 'requirements.txt' || f === 'pyproject.toml'))
71
+ stack.push('Python');
72
+ if (files.some(f => f.endsWith('.rs') || f === 'Cargo.toml'))
73
+ stack.push('Rust');
74
+ if (files.some(f => f.endsWith('.go') || f === 'go.mod'))
75
+ stack.push('Go');
76
+ if (files.some(f => f.endsWith('.java') || f === 'pom.xml'))
77
+ stack.push('Java');
78
+ if (files.some(f => f === 'Gemfile'))
79
+ stack.push('Ruby');
80
+ if (files.some(f => f === 'composer.json'))
81
+ stack.push('PHP');
82
+ } catch { /* ignore */ }
83
+
84
+ // Dedupe
85
+ return [...new Set(stack)];
86
+ }
87
+
88
+ function scanProject(projectDir) {
89
+ if (!projectDir) projectDir = process.cwd();
90
+ // boardDir is always where ClaudeBoard stores its state (.claudeboard/)
91
+ const boardDir = process.cwd();
92
+
93
+ // Check for existing PRD — in ClaudeBoard's own data dir
94
+ let existingPrd = null;
95
+ try {
96
+ const prdPath = path.join(boardDir, '.claudeboard', 'prd.md');
97
+ if (fs.existsSync(prdPath)) {
98
+ existingPrd = fs.readFileSync(prdPath, 'utf-8');
99
+ }
100
+ } catch { /* ignore */ }
101
+
102
+ // Read package.json from USER's project dir
103
+ let pkgJson = null;
104
+ let projectName = path.basename(projectDir);
105
+ try {
106
+ const pkgPath = path.join(projectDir, 'package.json');
107
+ if (fs.existsSync(pkgPath)) {
108
+ pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
109
+ if (pkgJson.name) projectName = pkgJson.name;
110
+ }
111
+ } catch { /* ignore */ }
112
+
113
+ // Read README from USER's project dir (cap at 3 KB)
114
+ let readme = null;
115
+ try {
116
+ const readmePath = path.join(projectDir, 'README.md');
117
+ if (fs.existsSync(readmePath)) {
118
+ readme = fs.readFileSync(readmePath, 'utf-8').slice(0, 3000);
119
+ }
120
+ } catch { /* ignore */ }
121
+
122
+ // Read context.md — check both project dir and board dir (cap at 8 KB)
123
+ let contextMd = null;
124
+ try {
125
+ for (const base of [projectDir, boardDir]) {
126
+ const contextPath = path.join(base, '.claudeboard', 'context.md');
127
+ if (fs.existsSync(contextPath)) {
128
+ contextMd = fs.readFileSync(contextPath, 'utf-8').slice(0, 8000);
129
+ break;
130
+ }
131
+ }
132
+ } catch { /* ignore */ }
133
+
134
+ // Build file tree
135
+ const treeLines = buildFileTree(projectDir);
136
+ const fileTree = treeLines.join('\n') || '(empty directory)';
137
+
138
+ // Detect tech stack
139
+ const techStack = detectTechStack(pkgJson, projectDir);
140
+
141
+ return {
142
+ isNewProject: existingPrd === null,
143
+ existingPrd,
144
+ fileTree,
145
+ techStack,
146
+ projectName,
147
+ readme,
148
+ contextMd,
149
+ pkgDescription: pkgJson?.description || null,
150
+ };
151
+ }
152
+
153
+ module.exports = { scanProject };
package/src/server.js ADDED
@@ -0,0 +1,205 @@
1
+ // src/server.js
2
+ const express = require('express');
3
+ const http = require('http');
4
+ const os = require('os');
5
+ const WebSocket = require('ws');
6
+ const path = require('path');
7
+ const { getTasks, getConfig, setConfig, getPRD, getProjectPath, deleteTask, clearTasks } = require('./store');
8
+ const { setBroadcast, setMaxAgents, setServerPort, sendMessage, startTask, processQueue, startOrchestrator, killAll, stopTask, pauseAll, runQA, spawnChecklistAgent, spawnDeployAgent, uploadPRD, provideCredentials } = require('./orchestrator');
9
+
10
+ const WS_RATE_LIMIT = 10; // max WS messages per second per connection
11
+ const TASK_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
12
+
13
+ function createServer(options = {}) {
14
+ const { port = 3000, maxAgents = 3, webhook = null, openBrowser = true } = options;
15
+
16
+ if (webhook) setConfig({ webhook });
17
+ setMaxAgents(maxAgents);
18
+
19
+ const app = express();
20
+ app.use(express.json({ limit: '64kb' }));
21
+ app.use(express.static(path.join(__dirname, '..', 'public')));
22
+
23
+ // Read-only REST endpoints
24
+ app.get('/api/tasks', (req, res) => {
25
+ try { res.json(getTasks()); }
26
+ catch { res.status(500).json({ error: 'Failed to read tasks' }); }
27
+ });
28
+
29
+ app.get('/api/prd', (req, res) => {
30
+ try { res.json({ prd: getPRD() }); }
31
+ catch { res.status(500).json({ error: 'Failed to read PRD' }); }
32
+ });
33
+
34
+ // Create a task externally (e.g. from scripts or other tools)
35
+ app.post('/api/tasks', (req, res) => {
36
+ try {
37
+ const { title, description, successCriteria, priority } = req.body || {};
38
+ if (!title || !description) return res.status(400).json({ error: 'title and description are required' });
39
+ const task = createTask({
40
+ title: String(title).slice(0, 200),
41
+ description: String(description).slice(0, 4000),
42
+ successCriteria: successCriteria ? String(successCriteria).slice(0, 1000) : '',
43
+ priority: ['high','medium','low'].includes(priority) ? priority : 'medium',
44
+ });
45
+ if (wss) wss.clients.forEach(c => { if (c.readyState === 1) c.send(JSON.stringify({ type: 'tasks:created', tasks: [task] })); });
46
+ processQueue();
47
+ res.json(task);
48
+ } catch (e) {
49
+ res.status(500).json({ error: 'Failed to create task' });
50
+ }
51
+ });
52
+
53
+ // Start a specific backlog task — ID validated before acting
54
+ app.post('/api/tasks/:id/start', (req, res) => {
55
+ const { id } = req.params;
56
+ if (!TASK_ID_RE.test(id)) return res.status(400).json({ error: 'Invalid task id' });
57
+ startTask(id);
58
+ res.json({ ok: true });
59
+ });
60
+
61
+ // System stats endpoint (CPU + RAM)
62
+ let lastCpuUsage = process.cpuUsage();
63
+ let lastCpuTime = Date.now();
64
+ // Warm up: re-baseline after 2s so first API call gets a meaningful delta
65
+ setTimeout(() => { lastCpuUsage = process.cpuUsage(); lastCpuTime = Date.now(); }, 2000);
66
+ app.get('/api/system', (req, res) => {
67
+ const now = Date.now();
68
+ const elapsed = (now - lastCpuTime) * 1000; // microseconds
69
+ const usage = process.cpuUsage(lastCpuUsage);
70
+ const cpuPercent = elapsed > 0 ? Math.round(((usage.user + usage.system) / elapsed) * 100) : 0;
71
+ lastCpuUsage = process.cpuUsage();
72
+ lastCpuTime = now;
73
+
74
+ const totalRam = os.totalmem();
75
+ const freeRam = os.freemem();
76
+ const usedRam = totalRam - freeRam;
77
+ res.json({
78
+ cpu: Math.min(cpuPercent, 100),
79
+ ramUsed: Math.round(usedRam / 1024 / 1024),
80
+ ramTotal: Math.round(totalRam / 1024 / 1024),
81
+ ramPercent: Math.round((usedRam / totalRam) * 100),
82
+ });
83
+ });
84
+
85
+ const server = http.createServer(app);
86
+ const wss = new WebSocket.Server({ server });
87
+
88
+ const clients = new Set();
89
+
90
+ function broadcast(data) {
91
+ const msg = JSON.stringify(data);
92
+ for (const ws of clients) {
93
+ if (ws.readyState === WebSocket.OPEN) ws.send(msg);
94
+ }
95
+ }
96
+
97
+ setBroadcast(broadcast);
98
+
99
+ wss.on('connection', (ws) => {
100
+ clients.add(ws);
101
+
102
+ // Per-connection rate-limit state
103
+ let msgCount = 0;
104
+ let windowStart = Date.now();
105
+
106
+ try {
107
+ ws.send(JSON.stringify({ type: 'init', tasks: getTasks(), prd: getPRD() }));
108
+ } catch { /* ignore */ }
109
+
110
+ ws.on('message', (raw) => {
111
+ // Rate limit: max WS_RATE_LIMIT messages per second per client
112
+ const now = Date.now();
113
+ if (now - windowStart >= 1000) {
114
+ msgCount = 0;
115
+ windowStart = now;
116
+ }
117
+ msgCount++;
118
+ if (msgCount > WS_RATE_LIMIT) {
119
+ ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded — slow down' }));
120
+ return;
121
+ }
122
+
123
+ let msg;
124
+ try { msg = JSON.parse(raw.toString()); }
125
+ catch { return; }
126
+
127
+ if (msg.type === 'orchestrator:message' && typeof msg.text === 'string') {
128
+ // Accept optional image (base64 DataURL), cap at 5 MB to prevent abuse
129
+ const imageData = (typeof msg.image === 'string' && msg.image.length < 5 * 1024 * 1024) ? msg.image : null;
130
+ sendMessage(msg.text, imageData); // sanitized inside sendMessage
131
+ } else if (msg.type === 'errors:retry-all') {
132
+ getTasks().filter(t => t.status === 'error').forEach(t => startTask(t.id));
133
+ } else if (msg.type === 'task:start' && typeof msg.taskId === 'string') {
134
+ if (TASK_ID_RE.test(msg.taskId)) startTask(msg.taskId);
135
+ } else if (msg.type === 'task:stop' && typeof msg.taskId === 'string') {
136
+ if (TASK_ID_RE.test(msg.taskId)) stopTask(msg.taskId);
137
+ } else if (msg.type === 'task:resolve' && typeof msg.taskId === 'string') {
138
+ // Human marked a blocked task as resolved → restart it
139
+ if (TASK_ID_RE.test(msg.taskId)) startTask(msg.taskId);
140
+ } else if (msg.type === 'queue:process') {
141
+ processQueue();
142
+ } else if (msg.type === 'agents:pause') {
143
+ pauseAll();
144
+ } else if (msg.type === 'qa:run') {
145
+ runQA();
146
+ } else if (msg.type === 'checklist:run') {
147
+ spawnChecklistAgent();
148
+ } else if (msg.type === 'deploy:run') {
149
+ spawnDeployAgent();
150
+ } else if (msg.type === 'task:delete' && typeof msg.taskId === 'string') {
151
+ if (TASK_ID_RE.test(msg.taskId)) {
152
+ deleteTask(msg.taskId);
153
+ broadcast({ type: 'task:deleted', taskId: msg.taskId });
154
+ }
155
+ } else if (msg.type === 'tasks:clear') {
156
+ pauseAll();
157
+ clearTasks();
158
+ broadcast({ type: 'tasks:cleared' });
159
+ } else if (msg.type === 'prd:upload' && typeof msg.content === 'string') {
160
+ const content = msg.content.slice(0, 20000);
161
+ uploadPRD(content);
162
+ } else if (msg.type === 'supabase:credentials') {
163
+ const supabaseUrl = typeof msg.supabaseUrl === 'string' ? msg.supabaseUrl.slice(0, 500) : '';
164
+ const anonKey = typeof msg.anonKey === 'string' ? msg.anonKey.slice(0, 500) : '';
165
+ const serviceKey = typeof msg.serviceKey === 'string' ? msg.serviceKey.slice(0, 500) : '';
166
+ const writeDotEnv = msg.writeDotEnv !== false;
167
+ provideCredentials({ supabaseUrl, anonKey, serviceKey, writeDotEnv });
168
+ }
169
+ });
170
+
171
+ ws.on('close', () => clients.delete(ws));
172
+ ws.on('error', () => clients.delete(ws));
173
+ });
174
+
175
+ // Bind ONLY to 127.0.0.1 — never 0.0.0.0
176
+ server.listen(port, '127.0.0.1', async () => {
177
+ console.log(`ClaudeBoard running at http://127.0.0.1:${port}`);
178
+ setServerPort(port);
179
+ startOrchestrator();
180
+
181
+ if (openBrowser) {
182
+ try {
183
+ const open = await import('open');
184
+ await open.default(`http://127.0.0.1:${port}`);
185
+ } catch {
186
+ console.log(`Open your browser at http://127.0.0.1:${port}`);
187
+ }
188
+ }
189
+ });
190
+
191
+ // Graceful shutdown — kill all child processes before exiting
192
+ function shutdown(signal) {
193
+ console.log(`\n[server] ${signal} received — shutting down`);
194
+ killAll();
195
+ server.close(() => process.exit(0));
196
+ setTimeout(() => process.exit(1), 5000).unref();
197
+ }
198
+
199
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
200
+ process.on('SIGINT', () => shutdown('SIGINT'));
201
+
202
+ return server;
203
+ }
204
+
205
+ module.exports = { createServer };
package/src/store.js ADDED
@@ -0,0 +1,182 @@
1
+ // src/store.js
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MB — hard cap on stored agent output
6
+
7
+ function getBoardDir() {
8
+ // Always relative to process.cwd() — never a system directory
9
+ return path.join(process.cwd(), '.claudeboard');
10
+ }
11
+
12
+ function ensureDir() {
13
+ const dir = getBoardDir();
14
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
15
+ return dir;
16
+ }
17
+
18
+ function readJSON(filename, defaultVal = []) {
19
+ const dir = ensureDir();
20
+ const filePath = path.join(dir, filename);
21
+ if (!fs.existsSync(filePath)) return defaultVal;
22
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); }
23
+ catch { return defaultVal; }
24
+ }
25
+
26
+ function writeJSON(filename, data) {
27
+ const dir = ensureDir();
28
+ fs.writeFileSync(path.join(dir, filename), JSON.stringify(data, null, 2));
29
+ }
30
+
31
+ // Tasks CRUD
32
+ function getTasks() { return readJSON('tasks.json', []); }
33
+ function saveTasks(tasks) { writeJSON('tasks.json', tasks); }
34
+
35
+ function createTask(task) {
36
+ const tasks = getTasks();
37
+ const newTask = {
38
+ id: Date.now().toString(),
39
+ title: String(task.title || 'Untitled').slice(0, 200),
40
+ description: String(task.description || '').slice(0, 2000),
41
+ successCriteria: String(task.successCriteria || '').slice(0, 1000),
42
+ priority: task.priority || 'medium',
43
+ status: 'backlog',
44
+ agentId: null,
45
+ output: '',
46
+ createdAt: new Date().toISOString(),
47
+ updatedAt: new Date().toISOString(),
48
+ };
49
+ tasks.push(newTask);
50
+ saveTasks(tasks);
51
+ return newTask;
52
+ }
53
+
54
+ function updateTask(id, updates) {
55
+ const tasks = getTasks();
56
+ const idx = tasks.findIndex(t => t.id === id);
57
+ if (idx === -1) return null;
58
+
59
+ // Enforce 1 MB cap on stored output
60
+ if (typeof updates.output === 'string' && Buffer.byteLength(updates.output, 'utf-8') > MAX_OUTPUT_BYTES) {
61
+ updates.output = updates.output.slice(0, MAX_OUTPUT_BYTES) + '\n[output truncated at 1 MB]';
62
+ }
63
+
64
+ tasks[idx] = { ...tasks[idx], ...updates, updatedAt: new Date().toISOString() };
65
+ saveTasks(tasks);
66
+ return tasks[idx];
67
+ }
68
+
69
+ function getTask(id) { return getTasks().find(t => t.id === id) || null; }
70
+
71
+ function deleteTask(id) {
72
+ saveTasks(getTasks().filter(t => t.id !== id));
73
+ }
74
+
75
+ function clearTasks() {
76
+ saveTasks([]);
77
+ }
78
+
79
+ // Agents CRUD
80
+ function getAgents() { return readJSON('agents.json', []); }
81
+ function saveAgents(agents) { writeJSON('agents.json', agents); }
82
+
83
+ function createAgent(agent) {
84
+ const agents = getAgents();
85
+ const newAgent = { id: Date.now().toString(), ...agent, createdAt: new Date().toISOString() };
86
+ agents.push(newAgent);
87
+ saveAgents(agents);
88
+ return newAgent;
89
+ }
90
+
91
+ function updateAgent(id, updates) {
92
+ const agents = getAgents();
93
+ const idx = agents.findIndex(a => a.id === id);
94
+ if (idx === -1) return null;
95
+ agents[idx] = { ...agents[idx], ...updates };
96
+ saveAgents(agents);
97
+ return agents[idx];
98
+ }
99
+
100
+ function removeAgent(id) {
101
+ saveAgents(getAgents().filter(a => a.id !== id));
102
+ }
103
+
104
+ // Config — read-only from code; write only via CLI flags at startup
105
+ function getConfig() { return readJSON('config.json', {}); }
106
+ function setConfig(updates) {
107
+ const config = getConfig();
108
+ const newConfig = { ...config, ...updates };
109
+ writeJSON('config.json', newConfig);
110
+ return newConfig;
111
+ }
112
+
113
+ // PRD
114
+ function savePRD(content) {
115
+ const dir = ensureDir();
116
+ fs.writeFileSync(path.join(dir, 'prd.md'), String(content).slice(0, 500 * 1024)); // cap at 500 KB
117
+ }
118
+ function getPRD() {
119
+ const dir = ensureDir();
120
+ const p = path.join(dir, 'prd.md');
121
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : null;
122
+ }
123
+
124
+ // The project is always the directory where claudeboard was launched from.
125
+ // No config override — avoids cross-project data leakage.
126
+ function getProjectPath() {
127
+ return process.cwd();
128
+ }
129
+
130
+ // Chat history — persisted to disk
131
+ function getChatHistory() {
132
+ return readJSON('chat-history.json', []);
133
+ }
134
+
135
+ function appendChatHistory(entry) {
136
+ const history = getChatHistory();
137
+ history.push({ ...entry, timestamp: new Date().toISOString() });
138
+ writeJSON('chat-history.json', history.slice(-500)); // keep last 500 messages
139
+ }
140
+
141
+ // Save base64 image from chat to disk, returns absolute path
142
+ function saveChatImage(base64Data) {
143
+ const match = typeof base64Data === 'string' && base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
144
+ if (!match) return null;
145
+ const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
146
+ const filename = `chat-image-${Date.now()}.${ext}`;
147
+ const dir = ensureDir();
148
+ const filepath = path.join(dir, filename);
149
+ fs.writeFileSync(filepath, Buffer.from(match[2], 'base64'));
150
+ return filepath;
151
+ }
152
+
153
+ // Agent handoffs — written by agents after completing a task, read by successors
154
+ function writeHandoff(taskId, content) {
155
+ const dir = ensureDir();
156
+ const handoffsDir = path.join(dir, 'handoffs');
157
+ if (!fs.existsSync(handoffsDir)) fs.mkdirSync(handoffsDir, { recursive: true });
158
+ fs.writeFileSync(path.join(handoffsDir, `${taskId}.md`), String(content).slice(0, 8000), 'utf-8');
159
+ }
160
+
161
+ function readHandoffs(excludeTaskId) {
162
+ const dir = ensureDir();
163
+ const handoffsDir = path.join(dir, 'handoffs');
164
+ if (!fs.existsSync(handoffsDir)) return [];
165
+ try {
166
+ return fs.readdirSync(handoffsDir)
167
+ .filter(f => f.endsWith('.md') && f !== `${excludeTaskId}.md`)
168
+ .map(f => { try { return fs.readFileSync(path.join(handoffsDir, f), 'utf-8'); } catch { return null; } })
169
+ .filter(Boolean)
170
+ .slice(-5); // last 5 handoffs only
171
+ } catch { return []; }
172
+ }
173
+
174
+ module.exports = {
175
+ getTasks, saveTasks, createTask, updateTask, getTask, deleteTask, clearTasks,
176
+ getAgents, createAgent, updateAgent, removeAgent,
177
+ getConfig, setConfig,
178
+ savePRD, getPRD,
179
+ getProjectPath,
180
+ getChatHistory, appendChatHistory, saveChatImage,
181
+ writeHandoff, readHandoffs,
182
+ };
@@ -0,0 +1,131 @@
1
+ // src/verifier.js
2
+ const { spawn } = require('child_process');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { updateTask, createTask, getTask, getProjectPath } = require('./store');
7
+ const { notify } = require('./notifier');
8
+
9
+ function spawnClaude(prompt, cwd) {
10
+ const tmpFile = path.join(os.tmpdir(), `cb-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
11
+ fs.writeFileSync(tmpFile, prompt, { encoding: 'utf8' });
12
+ let child;
13
+ if (process.platform === 'win32') {
14
+ const psCmd = `Get-Content -Raw -Encoding UTF8 '${tmpFile}' | claude --permission-mode bypassPermissions --print`;
15
+ child = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], {
16
+ cwd, stdio: ['ignore', 'pipe', 'pipe'], shell: false,
17
+ });
18
+ } else {
19
+ child = spawn('sh', ['-c', `claude --permission-mode bypassPermissions --print < '${tmpFile}'`], {
20
+ cwd, stdio: ['ignore', 'pipe', 'pipe'], shell: false,
21
+ });
22
+ }
23
+ child.on('close', () => { try { fs.unlinkSync(tmpFile); } catch (_) {} });
24
+ return child;
25
+ }
26
+
27
+ const MAX_FIELD_LEN = 2000;
28
+ const MAX_RETRIES = 2;
29
+
30
+ function sanitizeField(str) {
31
+ if (typeof str !== 'string') return '';
32
+ return str.replace(/\0/g, '').slice(0, MAX_FIELD_LEN);
33
+ }
34
+
35
+ function runVerifier(task, broadcast, onRetry) {
36
+ const title = sanitizeField(task.title);
37
+ const description = sanitizeField(task.description);
38
+ const successCriteria = sanitizeField(task.successCriteria);
39
+ const agentOutput = sanitizeField(typeof task.output === 'string' ? task.output.slice(-2000) : '');
40
+ const projectPath = getProjectPath();
41
+
42
+ const prompt = `You are a code verifier. Check if this task was completed correctly.
43
+
44
+ Task: ${title}
45
+ Success criteria: ${successCriteria}
46
+ Project directory: ${projectPath}
47
+
48
+ What the agent did:
49
+ ${agentOutput || '(no output)'}
50
+
51
+ Instructions:
52
+ 1. Read the relevant files in the project directory to check the actual result.
53
+ 2. Your ENTIRE response must be ONE of these two formats, nothing else:
54
+ VERIFIED
55
+ FAILED: <one sentence reason>
56
+
57
+ Do not add explanations, greetings, or any other text. Start your response with VERIFIED or FAILED.`;
58
+
59
+ broadcast({ type: 'task:verifying', taskId: task.id });
60
+ updateTask(task.id, { status: 'verifying' });
61
+
62
+ const proc = spawnClaude(prompt, projectPath);
63
+ let output = '';
64
+
65
+ proc.stdout.on('data', (data) => {
66
+ const chunk = data.toString('utf-8');
67
+ output += chunk;
68
+ broadcast({ type: 'task:verifier_output', taskId: task.id, chunk });
69
+ });
70
+
71
+ proc.stderr.on('data', () => {});
72
+
73
+ proc.on('close', () => {
74
+ const trimmed = output.trim();
75
+ // Flexible parsing: check anywhere in the first 300 chars
76
+ const head = trimmed.slice(0, 300).toUpperCase();
77
+ const isVerified = head.includes('VERIFIED') && !head.includes('FAILED');
78
+
79
+ if (isVerified) {
80
+ updateTask(task.id, { status: 'done', verifierOutput: trimmed.slice(0, 500) });
81
+ broadcast({ type: 'task:done', taskId: task.id });
82
+ notify('task:completed', { taskTitle: task.title, status: 'done' });
83
+ return;
84
+ }
85
+
86
+ // Extract failure reason
87
+ const failedMatch = trimmed.match(/FAILED[:\s]+(.+?)(?:\n|$)/i);
88
+ const reason = failedMatch
89
+ ? failedMatch[1].trim().slice(0, 300)
90
+ : trimmed.slice(0, 300) || 'Verificación sin respuesta esperada';
91
+
92
+ const retryCount = (task.retryCount || 0) + 1;
93
+
94
+ if (retryCount <= MAX_RETRIES) {
95
+ // Save last output before clearing so the retry agent knows what went wrong
96
+ const previousOutput = typeof task.output === 'string'
97
+ ? task.output.slice(-2000)
98
+ : (Array.isArray(task.output) ? task.output.map(e => e.chunk).join('').slice(-2000) : '');
99
+ const enrichedDesc = `[Reintento ${retryCount}/${MAX_RETRIES}] Intento anterior falló: ${reason}\n\nTarea original: ${task.description}`;
100
+ updateTask(task.id, {
101
+ status: 'backlog',
102
+ output: '',
103
+ previousOutput,
104
+ verifierOutput: reason,
105
+ retryCount,
106
+ description: enrichedDesc.slice(0, 2000),
107
+ });
108
+ broadcast({ type: 'task:updated', task: getTask(task.id) });
109
+ notify('task:failed', { taskTitle: task.title, status: 'retrying' });
110
+ if (onRetry) onRetry();
111
+ } else {
112
+ // Human intervention needed
113
+ updateTask(task.id, {
114
+ status: 'error',
115
+ needsHuman: true,
116
+ humanReason: reason,
117
+ verifierOutput: trimmed.slice(0, 500),
118
+ });
119
+ broadcast({ type: 'task:error', taskId: task.id, reason, needsHuman: true });
120
+ notify('task:failed', { taskTitle: task.title, status: 'error' });
121
+ }
122
+ });
123
+
124
+ proc.on('error', (err) => {
125
+ console.error('[verifier] spawn error:', err.message);
126
+ updateTask(task.id, { status: 'error', needsHuman: false });
127
+ broadcast({ type: 'task:error', taskId: task.id, reason: 'Verifier process failed to start' });
128
+ });
129
+ }
130
+
131
+ module.exports = { runVerifier };