claudeboard 2.3.0 → 2.8.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/agents/claude-api.js +1 -1
- package/agents/expo-health.js +240 -0
- package/agents/orchestrator.js +28 -22
- package/agents/qa.js +160 -139
- package/bin/cli.js +11 -1
- package/dashboard/index.html +213 -70
- package/dashboard/server.js +89 -35
- package/package.json +1 -1
package/agents/qa.js
CHANGED
|
@@ -1,133 +1,103 @@
|
|
|
1
1
|
import { callClaude, callClaudeJSON, callClaudeWithImage } from "./claude-api.js";
|
|
2
|
-
import { addLog
|
|
3
|
-
import { readFile, listFiles
|
|
4
|
-
import { runCommand
|
|
2
|
+
import { addLog } from "./board-client.js";
|
|
3
|
+
import { readFile, listFiles } from "../tools/filesystem.js";
|
|
4
|
+
import { runCommand } from "../tools/terminal.js";
|
|
5
5
|
import { screenshotExpoWeb } from "../tools/screenshot.js";
|
|
6
|
-
import {
|
|
6
|
+
import { readErrorLogs } from "../tools/supabase-reader.js";
|
|
7
7
|
import path from "path";
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
|
|
10
|
-
const SYSTEM_QA = `You are a senior QA engineer for
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
You receive:
|
|
14
|
-
- The original PRD description of a feature
|
|
15
|
-
- Console logs from the app
|
|
16
|
-
- A screenshot of the current state
|
|
17
|
-
- TypeScript/build errors if any
|
|
18
|
-
|
|
19
|
-
You must determine:
|
|
20
|
-
1. Does the feature work as described in the PRD?
|
|
21
|
-
2. Does the UI look polished and correct?
|
|
22
|
-
3. Are there any errors in the logs?
|
|
23
|
-
4. What bugs or issues need to be fixed?
|
|
24
|
-
|
|
25
|
-
Be specific about what's wrong and what needs to change.`;
|
|
10
|
+
const SYSTEM_QA = `You are a senior QA engineer for React Native / Expo apps.
|
|
11
|
+
Verify that implemented features work correctly. Be specific about actual issues.
|
|
12
|
+
If Expo is not running, evaluate purely on code quality and completeness.`;
|
|
26
13
|
|
|
27
14
|
export async function runQAAgent(task, devResult, projectPath, prdContent, expoPort = 8081) {
|
|
28
15
|
console.log(` 🔍 QA checking: ${task.title}`);
|
|
29
|
-
await addLog(task.id, "QA
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
16
|
+
await addLog(task.id, "QA starting", "progress");
|
|
17
|
+
|
|
18
|
+
// ── 1. TypeScript check ────────────────────────────────────────────────────
|
|
19
|
+
const tsResult = await runCommand("npx tsc --noEmit 2>&1", projectPath, 60000);
|
|
20
|
+
const tsErrors = tsResult.stdout.includes("error TS") ? tsResult.stdout : null;
|
|
21
|
+
if (tsErrors) await addLog(task.id, `TypeScript errors detected`, "error");
|
|
22
|
+
|
|
23
|
+
// ── 2. Check for truncated/incomplete files ────────────────────────────────
|
|
24
|
+
const truncationIssues = await checkForTruncatedFiles(task, projectPath);
|
|
25
|
+
if (truncationIssues.length > 0) {
|
|
26
|
+
const msg = `Incomplete files: ${truncationIssues.join(", ")}`;
|
|
27
|
+
await addLog(task.id, msg, "error");
|
|
28
|
+
return {
|
|
29
|
+
passed: false,
|
|
30
|
+
issues: truncationIssues,
|
|
31
|
+
fixInstructions: `These files are truncated/incomplete:\n${truncationIssues.join("\n")}\n\nRe-implement them completely from scratch.`,
|
|
32
|
+
screenshotPath: null,
|
|
33
|
+
};
|
|
43
34
|
}
|
|
44
35
|
|
|
45
|
-
//
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
36
|
+
// ── 3. Screenshot (only if Expo is running) ────────────────────────────────
|
|
37
|
+
let visualVerdict = null;
|
|
38
|
+
const expoRunning = await isPortOpen(expoPort);
|
|
39
|
+
|
|
40
|
+
if (expoRunning) {
|
|
41
|
+
await addLog(task.id, "Taking screenshot...", "progress");
|
|
42
|
+
const screenshotDir = path.join(projectPath, ".claudeboard-screenshots");
|
|
43
|
+
const screenshot = await screenshotExpoWeb(expoPort, screenshotDir);
|
|
44
|
+
if (screenshot.success && screenshot.base64) {
|
|
45
|
+
const visionResult = await callClaudeWithImage(
|
|
46
|
+
SYSTEM_QA,
|
|
47
|
+
`Task: ${task.title}\nExpected: ${task.description}\n\nDoes the UI look correct? Any visual bugs?`,
|
|
48
|
+
screenshot.base64
|
|
49
|
+
);
|
|
50
|
+
visualVerdict = visionResult.text;
|
|
51
|
+
await addLog(task.id, `Visual: ${visualVerdict}`, "progress");
|
|
52
|
+
} else {
|
|
53
|
+
await addLog(task.id, `Screenshot failed: ${screenshot.error}`, "progress");
|
|
52
54
|
}
|
|
53
|
-
} catch {}
|
|
54
|
-
|
|
55
|
-
// 3. Read Supabase error logs if project uses Supabase
|
|
56
|
-
let supabaseLogs = [];
|
|
57
|
-
try {
|
|
58
|
-
supabaseLogs = await readErrorLogs("logs", 10);
|
|
59
|
-
} catch {}
|
|
60
|
-
|
|
61
|
-
// 4. Take screenshot
|
|
62
|
-
await addLog(task.id, "Taking screenshot of Expo Web...", "progress");
|
|
63
|
-
const screenshotDir = path.join(projectPath, ".claudeboard-screenshots");
|
|
64
|
-
const screenshot = await screenshotExpoWeb(expoPort, screenshotDir);
|
|
65
|
-
|
|
66
|
-
if (screenshot.success) {
|
|
67
|
-
qaReport.screenshotPath = screenshot.imagePath;
|
|
68
|
-
await addLog(task.id, `Screenshot captured: ${path.basename(screenshot.imagePath)}`, "progress");
|
|
69
55
|
} else {
|
|
70
|
-
await addLog(task.id,
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 5. Visual QA via Claude Vision
|
|
74
|
-
let visualVerdict = null;
|
|
75
|
-
if (screenshot.success && screenshot.base64) {
|
|
76
|
-
const visualPrompt = `
|
|
77
|
-
This is a screenshot of a React Native / Expo app screen.
|
|
78
|
-
|
|
79
|
-
Task that was just implemented: ${task.title}
|
|
80
|
-
Expected behavior from PRD: ${task.description}
|
|
81
|
-
|
|
82
|
-
Evaluate:
|
|
83
|
-
1. Does the screen look implemented? (not blank, not error screen)
|
|
84
|
-
2. Does the UI look polished? (proper layout, readable text, no obvious visual bugs)
|
|
85
|
-
3. Does it appear to match what was described in the task?
|
|
86
|
-
4. Any visual issues? (overlapping elements, cut off text, missing components)
|
|
87
|
-
|
|
88
|
-
Be concise and specific.`;
|
|
89
|
-
|
|
90
|
-
const visionResult = await callClaudeWithImage(SYSTEM_QA, visualPrompt, screenshot.base64);
|
|
91
|
-
visualVerdict = visionResult.text;
|
|
92
|
-
await addLog(task.id, `Visual QA: ${visualVerdict.slice(0, 200)}`, "progress");
|
|
56
|
+
await addLog(task.id, "Expo not running — code-only QA", "progress");
|
|
93
57
|
}
|
|
94
58
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const rel = path.relative(projectPath, f).toLowerCase();
|
|
99
|
-
return task.title.toLowerCase().split(" ").some((w) => w.length > 4 && rel.includes(w));
|
|
100
|
-
})
|
|
101
|
-
.slice(0, 5);
|
|
102
|
-
|
|
59
|
+
// ── 4. Read ALL relevant code — no artificial limits ──────────────────────
|
|
60
|
+
// Claude Code already wrote these files — we read them fully for QA review
|
|
61
|
+
const relevantFiles = getRelevantFiles(task, projectPath);
|
|
103
62
|
let codeContext = "";
|
|
104
63
|
for (const f of relevantFiles) {
|
|
105
64
|
const content = readFile(f);
|
|
106
65
|
if (content) {
|
|
107
|
-
|
|
66
|
+
// No slice — read the complete file
|
|
67
|
+
codeContext += `\n### ${path.relative(projectPath, f)}\n${content}\n`;
|
|
108
68
|
}
|
|
109
69
|
}
|
|
110
70
|
|
|
111
|
-
|
|
71
|
+
// ── 5. App logs (full) ─────────────────────────────────────────────────────
|
|
72
|
+
let appLogs = "";
|
|
73
|
+
try {
|
|
74
|
+
const logFile = path.join(projectPath, ".claudeboard-logs.txt");
|
|
75
|
+
if (fs.existsSync(logFile)) appLogs = fs.readFileSync(logFile, "utf8");
|
|
76
|
+
} catch {}
|
|
77
|
+
|
|
78
|
+
// ── 6. Supabase errors ─────────────────────────────────────────────────────
|
|
79
|
+
let supabaseLogs = [];
|
|
80
|
+
try { supabaseLogs = await readErrorLogs("logs", 20); } catch {}
|
|
81
|
+
|
|
82
|
+
// ── 7. Functional verdict ─────────────────────────────────────────────────
|
|
83
|
+
const verdict = await callClaudeJSON(
|
|
112
84
|
SYSTEM_QA,
|
|
113
|
-
`
|
|
114
|
-
Task: ${task.title}
|
|
85
|
+
`Task: ${task.title}
|
|
115
86
|
Description: ${task.description}
|
|
116
87
|
|
|
117
|
-
|
|
88
|
+
Implemented code (complete files):
|
|
118
89
|
${codeContext || "Could not read relevant files"}
|
|
119
90
|
|
|
120
|
-
|
|
121
|
-
${appLogs || "
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
${supabaseLogs.length > 0 ? JSON.stringify(supabaseLogs) : "None"}
|
|
91
|
+
TypeScript: ${tsErrors ? `ERRORS:\n${tsErrors}` : "Clean — no errors"}
|
|
92
|
+
App logs: ${appLogs || "None"}
|
|
93
|
+
Supabase errors: ${supabaseLogs.length > 0 ? JSON.stringify(supabaseLogs, null, 2) : "None"}
|
|
94
|
+
Visual QA: ${visualVerdict || "No screenshot (Expo not running — evaluate code only)"}
|
|
125
95
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
96
|
+
EVALUATION RULES:
|
|
97
|
+
- PASS if: code is complete, TypeScript is clean, implementation matches description
|
|
98
|
+
- FAIL only if: code is incomplete/truncated, has TS errors, or implementation is clearly wrong
|
|
99
|
+
- Do NOT fail just because Expo isn't running
|
|
100
|
+
- Do NOT fail for minor style preferences
|
|
131
101
|
|
|
132
102
|
Respond with JSON:
|
|
133
103
|
{
|
|
@@ -135,74 +105,125 @@ Respond with JSON:
|
|
|
135
105
|
"confidence": 0-100,
|
|
136
106
|
"issues": ["specific issue 1", "specific issue 2"],
|
|
137
107
|
"summary": "One sentence verdict",
|
|
138
|
-
"fixInstructions": "
|
|
139
|
-
}
|
|
140
|
-
{ maxTokens: 2000 }
|
|
108
|
+
"fixInstructions": "Specific fix instructions if failed, null if passed"
|
|
109
|
+
}`
|
|
141
110
|
);
|
|
142
111
|
|
|
143
|
-
qaReport
|
|
144
|
-
|
|
145
|
-
|
|
112
|
+
const qaReport = {
|
|
113
|
+
passed: verdict.passed,
|
|
114
|
+
issues: verdict.issues || [],
|
|
115
|
+
fixInstructions: verdict.fixInstructions,
|
|
116
|
+
screenshotPath: null,
|
|
117
|
+
};
|
|
146
118
|
|
|
147
119
|
if (qaReport.passed) {
|
|
148
|
-
await addLog(task.id, `✓ QA passed (${
|
|
120
|
+
await addLog(task.id, `✓ QA passed (${verdict.confidence}%): ${verdict.summary}`, "complete");
|
|
149
121
|
console.log(` ✓ QA passed: ${task.title}`);
|
|
150
122
|
} else {
|
|
151
|
-
await addLog(
|
|
152
|
-
|
|
153
|
-
`✗ QA failed: ${functionalVerdict.summary}. Issues: ${qaReport.issues.slice(0, 2).join("; ")}`,
|
|
154
|
-
"error"
|
|
155
|
-
);
|
|
156
|
-
console.log(` ✗ QA failed: ${task.title}`);
|
|
157
|
-
console.log(` Issues: ${qaReport.issues.join(", ")}`);
|
|
123
|
+
await addLog(task.id, `✗ QA failed: ${verdict.summary}`, "error");
|
|
124
|
+
console.log(` ✗ QA failed: ${task.title} — ${verdict.issues?.slice(0,2).join(", ")}`);
|
|
158
125
|
}
|
|
159
126
|
|
|
160
127
|
return qaReport;
|
|
161
128
|
}
|
|
162
129
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
130
|
+
// ── Detect truncated/incomplete files ──────────────────────────────────────────
|
|
131
|
+
async function checkForTruncatedFiles(task, projectPath) {
|
|
132
|
+
const issues = [];
|
|
133
|
+
const keywords = task.title.toLowerCase().split(" ").filter(w => w.length > 4);
|
|
134
|
+
|
|
135
|
+
const files = listFiles(projectPath, [".ts", ".tsx"])
|
|
136
|
+
.filter(f => {
|
|
137
|
+
const rel = path.relative(projectPath, f).toLowerCase();
|
|
138
|
+
if (rel.includes("node_modules") || rel.includes(".claudeboard")) return false;
|
|
139
|
+
return keywords.some(kw => rel.includes(kw));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
for (const f of files) {
|
|
143
|
+
const content = readFile(f);
|
|
144
|
+
if (!content) continue;
|
|
145
|
+
const rel = path.relative(projectPath, f);
|
|
146
|
+
const lastLines = content.split("\n").slice(-5).join("\n").trim();
|
|
147
|
+
const openBraces = (content.match(/\{/g) || []).length;
|
|
148
|
+
const closeBraces = (content.match(/\}/g) || []).length;
|
|
149
|
+
|
|
150
|
+
const truncated =
|
|
151
|
+
lastLines.endsWith("{") || lastLines.endsWith("(") || lastLines.endsWith(",") ||
|
|
152
|
+
lastLines.match(/^(import|\/\/) *$/) ||
|
|
153
|
+
openBraces > closeBraces + 3 ||
|
|
154
|
+
(content.length < 80 && (rel.includes("store") || rel.includes("hook") || rel.includes("screen")));
|
|
155
|
+
|
|
156
|
+
if (truncated) {
|
|
157
|
+
issues.push(`${rel} (ends: "${lastLines.slice(-80)}")`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return issues;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Full app QA ────────────────────────────────────────────────────────────────
|
|
167
164
|
export async function runFullAppQA(projectPath, prdContent, expoPort = 8081) {
|
|
168
165
|
console.log("\n 🔍 Running full app QA...");
|
|
169
166
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
167
|
+
if (!await isPortOpen(expoPort)) {
|
|
168
|
+
console.log(" ⚠️ Expo not running — skipping visual QA");
|
|
169
|
+
return { passed: true, routes: [], screenshotsCaptures: 0, issues: [] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const appDir = path.join(projectPath, "app");
|
|
173
|
+
if (!fs.existsSync(appDir)) return { passed: true, routes: [], screenshotsCaptures: 0, issues: [] };
|
|
174
|
+
|
|
175
|
+
const routeFiles = listFiles(appDir, [".tsx", ".ts"])
|
|
176
|
+
.filter(f => !f.includes("_layout") && !f.includes("_error"))
|
|
177
|
+
.map(f => "/" + path.relative(appDir, f).replace(/\.(tsx|ts)$/, "").replace(/index$/, ""));
|
|
177
178
|
|
|
178
179
|
const screenshotDir = path.join(projectPath, ".claudeboard-screenshots", "full-qa");
|
|
179
180
|
const screenshots = [];
|
|
180
181
|
|
|
181
|
-
for (const route of routeFiles
|
|
182
|
+
for (const route of routeFiles) {
|
|
182
183
|
const shot = await screenshotExpoWeb(expoPort, screenshotDir, route);
|
|
183
184
|
if (shot.success) screenshots.push({ route, ...shot });
|
|
184
|
-
await new Promise(
|
|
185
|
+
await new Promise(r => setTimeout(r, 800));
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
|
|
188
|
-
const report = {
|
|
189
|
-
passed: screenshots.length > 0,
|
|
190
|
-
routes: routeFiles,
|
|
191
|
-
screenshotsCaptures: screenshots.length,
|
|
192
|
-
issues: [],
|
|
193
|
-
};
|
|
188
|
+
const report = { passed: true, routes: routeFiles, screenshotsCaptures: screenshots.length, issues: [] };
|
|
194
189
|
|
|
195
190
|
for (const shot of screenshots) {
|
|
196
191
|
if (!shot.base64) continue;
|
|
197
192
|
const verdict = await callClaudeWithImage(
|
|
198
193
|
SYSTEM_QA,
|
|
199
|
-
`Route: ${shot.route}\n\
|
|
194
|
+
`Route: ${shot.route}\n\nFull PRD:\n${prdContent}\n\nAny obvious issues?`,
|
|
200
195
|
shot.base64
|
|
201
196
|
);
|
|
202
197
|
if (verdict.text.toLowerCase().includes("issue") || verdict.text.toLowerCase().includes("problem")) {
|
|
203
|
-
report.issues.push({ route: shot.route, note: verdict.text
|
|
198
|
+
report.issues.push({ route: shot.route, note: verdict.text });
|
|
204
199
|
}
|
|
205
200
|
}
|
|
206
201
|
|
|
207
202
|
return report;
|
|
208
203
|
}
|
|
204
|
+
|
|
205
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
206
|
+
function getRelevantFiles(task, projectPath) {
|
|
207
|
+
const keywords = task.title.toLowerCase().split(" ").filter(w => w.length > 4);
|
|
208
|
+
return listFiles(projectPath, [".ts", ".tsx"])
|
|
209
|
+
.filter(f => {
|
|
210
|
+
const rel = path.relative(projectPath, f).toLowerCase();
|
|
211
|
+
if (rel.includes("node_modules") || rel.includes(".claudeboard")) return false;
|
|
212
|
+
return keywords.some(kw => rel.includes(kw));
|
|
213
|
+
});
|
|
214
|
+
// No .slice() — return all matching files
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function isPortOpen(port) {
|
|
218
|
+
try {
|
|
219
|
+
const { default: net } = await import("net");
|
|
220
|
+
return new Promise(resolve => {
|
|
221
|
+
const sock = new net.Socket();
|
|
222
|
+
sock.setTimeout(800);
|
|
223
|
+
sock.once("connect", () => { sock.destroy(); resolve(true); });
|
|
224
|
+
sock.once("error", () => resolve(false));
|
|
225
|
+
sock.once("timeout", () => resolve(false));
|
|
226
|
+
sock.connect(port, "127.0.0.1");
|
|
227
|
+
});
|
|
228
|
+
} catch { return false; }
|
|
229
|
+
}
|
package/bin/cli.js
CHANGED
|
@@ -166,11 +166,21 @@ program
|
|
|
166
166
|
process.exit(1);
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
const resolvedProject = path.resolve(opts.project);
|
|
170
|
+
|
|
171
|
+
// Persist the project path so `claudeboard start` uses the right directory for Expo
|
|
172
|
+
const configPath = path.join(process.cwd(), ".claudeboard.json");
|
|
173
|
+
try {
|
|
174
|
+
const saved = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
175
|
+
saved.projectDir = resolvedProject;
|
|
176
|
+
fs.writeFileSync(configPath, JSON.stringify(saved, null, 2));
|
|
177
|
+
} catch {}
|
|
178
|
+
|
|
169
179
|
const { runOrchestrator } = await import("../agents/orchestrator.js");
|
|
170
180
|
|
|
171
181
|
await runOrchestrator({
|
|
172
182
|
prdPath: path.resolve(opts.prd),
|
|
173
|
-
projectPath:
|
|
183
|
+
projectPath: resolvedProject,
|
|
174
184
|
supabaseUrl: config.supabaseUrl,
|
|
175
185
|
supabaseKey: config.supabaseKey,
|
|
176
186
|
projectName: config.projectName,
|