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/README.md +89 -93
- package/bin/cli.js +198 -238
- package/bin/init-context.js +22 -0
- package/package.json +25 -43
- package/public/app.js +1411 -0
- package/public/index.html +250 -0
- package/public/style.css +1872 -0
- package/src/context-template.md +20 -0
- package/src/notifier.js +65 -0
- package/src/orchestrator.js +939 -0
- package/src/scanner.js +153 -0
- package/src/server.js +205 -0
- package/src/store.js +182 -0
- package/src/verifier.js +131 -0
- package/agents/architect.js +0 -166
- package/agents/board-client.js +0 -126
- package/agents/claude-api.js +0 -124
- package/agents/claude-resolver.js +0 -167
- package/agents/developer.js +0 -224
- package/agents/expo-health.js +0 -727
- package/agents/orchestrator.js +0 -306
- package/agents/qa.js +0 -336
- package/dashboard/index.html +0 -1980
- package/dashboard/server.js +0 -412
- package/sql/setup.sql +0 -57
- package/tools/filesystem.js +0 -95
- package/tools/screenshot.js +0 -74
- package/tools/supabase-reader.js +0 -74
- package/tools/terminal.js +0 -63
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
|
+
};
|
package/src/verifier.js
ADDED
|
@@ -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 };
|