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.
@@ -1,306 +0,0 @@
1
- import chalk from "chalk";
2
- import ora from "ora";
3
- import fs from "fs";
4
- import path from "path";
5
- import { runArchitectAgent } from "./architect.js";
6
- import { runDeveloperAgent } from "./developer.js";
7
- import { runQAAgent, runFullAppQA } from "./qa.js";
8
- import { runAppHealthCheck, ensureDevBuild, packageJsonHash } from "./expo-health.js";
9
- import { createConnection } from "net";
10
-
11
- function isPortOpen(port) {
12
- return new Promise(resolve => {
13
- const sock = createConnection({ port, host: "127.0.0.1" });
14
- sock.setTimeout(600);
15
- sock.once("connect", () => { sock.destroy(); resolve(true); });
16
- sock.once("error", () => resolve(false));
17
- sock.once("timeout", () => resolve(false));
18
- });
19
- }
20
- import {
21
- initBoard,
22
- getNextTask,
23
- getAllTasks,
24
- getStats,
25
- addLog,
26
- createTask,
27
- createEpic,
28
- hasTasks,
29
- resetStuckTasks,
30
- } from "./board-client.js";
31
- import { initSupabaseReader } from "../tools/supabase-reader.js";
32
- import { runCommand, startProcess, waitForPort } from "../tools/terminal.js";
33
-
34
- const PHASES = {
35
- ARCHITECTURE: "architecture",
36
- DEVELOPMENT: "development",
37
- QA_FINAL: "qa_final",
38
- COMPLETE: "complete",
39
- };
40
-
41
- export async function runOrchestrator(config) {
42
- const {
43
- prdPath,
44
- projectPath,
45
- supabaseUrl,
46
- supabaseKey,
47
- projectName,
48
- appType = "mobile",
49
- expoPort = 8081,
50
- skipArchitect = false,
51
- useIOS = false,
52
- } = config;
53
-
54
- // Set env var so expo-health and QA agents know to use iOS simulator
55
- if (useIOS) process.env.CLAUDEBOARD_IOS = "1";
56
-
57
- console.log(chalk.cyan("\n╔═══════════════════════════════════════╗"));
58
- console.log(chalk.cyan("║") + chalk.bold(" 🤖 CLAUDEBOARD ORCHESTRATOR STARTING ") + chalk.cyan("║"));
59
- console.log(chalk.cyan("╚═══════════════════════════════════════╝\n"));
60
-
61
- // Init board connection
62
- initBoard(supabaseUrl, supabaseKey, projectName);
63
- initSupabaseReader(supabaseUrl, supabaseKey);
64
-
65
- // Read PRD
66
- const prdContent = fs.readFileSync(prdPath, "utf8");
67
- console.log(chalk.dim(` PRD: ${prdPath} (${prdContent.length} chars)`));
68
- console.log(chalk.dim(` Project: ${projectPath}\n`));
69
-
70
- let techStack = {};
71
- let isResume = false;
72
-
73
- // ── DETECT: RESUME or FRESH START ─────────────────────────────────────────
74
- const existingTasks = await hasTasks();
75
-
76
- if (existingTasks && !config.forceRestart) {
77
- isResume = true;
78
- const stuck = await resetStuckTasks();
79
- const stats = await getStats();
80
-
81
- console.log(chalk.yellow(" ↩️ Resuming existing session\n"));
82
- console.log(chalk.dim(` Found ${stats.total} tasks — ${stats.done} done, ${stats.todo} todo, ${stats.error} failed`));
83
- if (stuck > 0) {
84
- console.log(chalk.dim(` Reset ${stuck} stuck task(s) back to todo`));
85
- }
86
- console.log();
87
- } else {
88
- // ── PHASE 1: ARCHITECTURE ────────────────────────────────────────────────
89
- if (config.forceRestart) {
90
- console.log(chalk.dim(" Force restart — skipping existing tasks (they remain in board)\n"));
91
- }
92
- console.log(chalk.bold.cyan("[ PHASE 1: ARCHITECTURE ]\n"));
93
-
94
- // ── Detect existing project files (besides the PRD) ───────────────────────
95
- let buildOnExisting = false;
96
- const EXISTING_CODE_MARKERS = ["package.json", "app.json", "src", "app", "components", "screens"];
97
- try {
98
- const prdBasename = path.basename(prdPath);
99
- const projectFiles = fs.readdirSync(projectPath)
100
- .filter(f => !f.startsWith(".") && f !== prdBasename && f !== "node_modules");
101
- const hasExistingCode = projectFiles.some(f => EXISTING_CODE_MARKERS.includes(f));
102
-
103
- if (hasExistingCode && !config.forceRestart) {
104
- console.log(chalk.yellow(" ⚠ This project already has files:\n"));
105
- projectFiles.slice(0, 12).forEach(f => console.log(chalk.dim(` ${f}`)));
106
- if (projectFiles.length > 12) console.log(chalk.dim(` ... and ${projectFiles.length - 12} more`));
107
- console.log();
108
-
109
- const { default: Enquirer } = await import("enquirer");
110
- const enquirer = new Enquirer();
111
- const answer = await enquirer.prompt({
112
- type: "confirm",
113
- name: "buildOnExisting",
114
- message: "Build tasks on top of the existing project (instead of starting from scratch)?",
115
- initial: true,
116
- });
117
- buildOnExisting = answer.buildOnExisting;
118
- console.log();
119
- }
120
- } catch {}
121
-
122
- const archResult = await runArchitectAgent(prdContent, projectName, {
123
- buildOnExisting,
124
- projectPath,
125
- appType,
126
- });
127
- techStack = archResult.techStack;
128
- console.log(chalk.green(`\n ✓ ${archResult.totalTasks} tasks created across ${archResult.epics.length} epics\n`));
129
- }
130
-
131
- // ── START EXPO ─────────────────────────────────────────────────────────────
132
- let expoProcess = null;
133
- let expoReady = false;
134
- const logFile = path.join(projectPath, ".claudeboard-logs.txt");
135
- const logStream = fs.createWriteStream(logFile, { flags: "a" });
136
-
137
- // ── APP HEALTH CHECK: install deps + start dev server + auto-fix errors ───
138
- // Runs before development loop so the app is visible from task #1
139
- const devPort = appType === "web" ? (expoPort !== 8081 ? expoPort : 5173) : expoPort;
140
- const expoHealth = await runAppHealthCheck(projectPath, devPort, appType);
141
- expoReady = expoHealth.ready;
142
- expoProcess = expoHealth.process;
143
-
144
- // ── PHASE 2: DEVELOPMENT LOOP ─────────────────────────────────────────────
145
- console.log(chalk.bold.cyan("[ PHASE 2: DEVELOPMENT ]\n"));
146
-
147
- let consecutiveFailures = 0;
148
- const MAX_CONSECUTIVE_FAILURES = 3;
149
-
150
- // Track package.json hash to detect native dependency changes that need a rebuild
151
- let lastPkgHash = packageJsonHash(projectPath);
152
-
153
- while (true) {
154
- const task = await getNextTask();
155
-
156
- if (!task) {
157
- console.log(chalk.green("\n ✓ All tasks complete!\n"));
158
- break;
159
- }
160
-
161
- const stats = await getStats();
162
- const pct = stats.pct;
163
- const bar = "█".repeat(Math.floor(pct / 5)) + "░".repeat(20 - Math.floor(pct / 5));
164
- console.log(chalk.dim(`\n [${bar}] ${pct}% — ${stats.done}/${stats.total} tasks done`));
165
- console.log(chalk.bold(`\n → ${task.title}`));
166
- console.log(chalk.dim(` Epic: ${task.cb_epics?.name || "—"} | ${task.priority} | ${task.type}`));
167
-
168
- // Fetch all tasks for mini-plan context (done + upcoming)
169
- const allTasks = await getAllTasks();
170
-
171
- // Run developer agent
172
- const devResult = await runDeveloperAgent(task, projectPath, techStack, allTasks);
173
-
174
- if (!devResult.success) {
175
- consecutiveFailures++;
176
- console.log(chalk.red(` ✗ Dev failed (${consecutiveFailures} consecutive)`));
177
-
178
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
179
- console.log(chalk.yellow("\n ⚠️ Too many consecutive failures — pausing for human review"));
180
- console.log(chalk.yellow(" Check the dashboard for blocked tasks.\n"));
181
- break;
182
- }
183
- continue;
184
- }
185
-
186
- consecutiveFailures = 0;
187
-
188
- // ── If this was an Expo fix task, clear the error and re-health-check ────
189
- if ((task.title?.toLowerCase().includes("expo") || task.title?.toLowerCase().includes("vite")) && task.type === "bug") {
190
- try { fs.unlinkSync(path.join(projectPath, ".claudeboard-expo-error.txt")); } catch {}
191
- console.log(chalk.dim(" Dev server fix completed — re-running health check..."));
192
- const recheck = await runAppHealthCheck(projectPath, devPort, appType);
193
- expoReady = recheck.ready;
194
- expoProcess = recheck.process;
195
- }
196
-
197
- // ── Detect native dependency changes → rebuild dev build if needed ────────
198
- if (useIOS) {
199
- const currentHash = packageJsonHash(projectPath);
200
- if (currentHash && currentHash !== lastPkgHash) {
201
- lastPkgHash = currentHash;
202
- console.log(chalk.cyan(" 📦 package.json changed — checking if dev build rebuild is needed..."));
203
- if (expoProcess) {
204
- try { expoProcess.kill(); } catch {}
205
- expoProcess = null;
206
- expoReady = false;
207
- }
208
- const buildOk = await ensureDevBuild(projectPath);
209
- if (buildOk) {
210
- // Restart Metro after rebuild
211
- const recheck = await runExpoHealthCheck(projectPath, expoPort);
212
- expoReady = recheck.ready;
213
- expoProcess = recheck.process;
214
- } else {
215
- console.log(chalk.yellow(" ✗ Rebuild failed — continuing without live preview"));
216
- }
217
- }
218
- }
219
-
220
- // ── Re-check Expo health if it's supposed to be running but isn't ────────
221
- if (expoReady) {
222
- const portOpen = await isPortOpen(devPort);
223
- if (!portOpen) {
224
- console.log(chalk.yellow(" ⚠ Dev server seems to have crashed — running health check..."));
225
- const recheck = await runAppHealthCheck(projectPath, devPort, appType);
226
- expoReady = recheck.ready;
227
- expoProcess = recheck.process;
228
- }
229
- }
230
-
231
- // Run QA agent (only if expo is running and this is a UI/feature task)
232
- const shouldRunQA = expoReady && ["feature", "bug"].includes(task.type);
233
-
234
- if (shouldRunQA) {
235
- // Give dev server a moment to hot-reload
236
- await new Promise((r) => setTimeout(r, 3000));
237
-
238
- const qaResult = await runQAAgent(task, devResult, projectPath, prdContent, devPort);
239
-
240
- if (!qaResult.passed && qaResult.fixInstructions) {
241
- console.log(chalk.yellow(" ↩️ QA failed — sending back to developer..."));
242
-
243
- // Create a fix task right after current
244
- await createEpicIfNeeded();
245
- const fixedByDev = await runDeveloperAgent(
246
- { ...task, title: `FIX: ${task.title}`, description: qaResult.fixInstructions },
247
- projectPath,
248
- techStack,
249
- allTasks,
250
- qaResult.fixInstructions
251
- );
252
-
253
- if (fixedByDev.success) {
254
- await new Promise((r) => setTimeout(r, 3000));
255
- await runQAAgent(task, fixedByDev, projectPath, prdContent, devPort);
256
- }
257
- }
258
- }
259
-
260
- // Small delay between tasks
261
- await new Promise((r) => setTimeout(r, 500));
262
- }
263
-
264
- // ── PHASE 3: FINAL QA ─────────────────────────────────────────────────────
265
- if (expoReady) {
266
- console.log(chalk.bold.cyan("\n[ PHASE 3: FINAL QA ]\n"));
267
- const fullQAReport = await runFullAppQA(projectPath, prdContent, devPort);
268
-
269
- if (fullQAReport.issues.length > 0) {
270
- console.log(chalk.yellow(` ⚠️ Final QA found ${fullQAReport.issues.length} issues:`));
271
- fullQAReport.issues.forEach((issue) => {
272
- console.log(chalk.dim(` ${issue.route}: ${issue.note}`));
273
- });
274
-
275
- // Create fix tasks for final issues
276
- for (const issue of fullQAReport.issues) {
277
- await createTask({
278
- title: `Final QA fix: ${issue.route}`,
279
- description: issue.note,
280
- priority: "high",
281
- type: "bug",
282
- });
283
- }
284
-
285
- console.log(chalk.yellow("\n Fix tasks added to board. Run claudeboard run --skip-architect to continue.\n"));
286
- } else {
287
- console.log(chalk.green(` ✓ Full QA passed — ${fullQAReport.screenshotsCaptures} screens checked\n`));
288
- }
289
- }
290
-
291
- // ── COMPLETE ───────────────────────────────────────────────────────────────
292
- const finalStats = await getStats();
293
- console.log(chalk.cyan("\n╔═══════════════════════════════════════╗"));
294
- console.log(chalk.cyan("║") + chalk.bold.green(` ✓ COMPLETE — ${finalStats.done}/${finalStats.total} tasks done (${finalStats.pct}%) `) + chalk.cyan("║"));
295
- console.log(chalk.cyan("╚═══════════════════════════════════════╝\n"));
296
-
297
- if (expoProcess) {
298
- expoProcess.kill();
299
- logStream.end();
300
- }
301
- }
302
-
303
- async function createEpicIfNeeded() {
304
- // Dummy — fix tasks go into a "Fixes" epic
305
- // In practice the board-client handles this
306
- }
package/agents/qa.js DELETED
@@ -1,336 +0,0 @@
1
- import { query } from "@anthropic-ai/claude-agent-sdk";
2
- import { addLog, completeTask, failTask } from "./board-client.js";
3
- import { runCommand } from "../tools/terminal.js";
4
- import { screenshotExpoWeb } from "../tools/screenshot.js";
5
- import { screenshotSimulator } from "./expo-health.js";
6
- import { callClaudeWithImage } from "./claude-api.js";
7
- import { listFiles, readFile } from "../tools/filesystem.js";
8
- import chalk from "chalk";
9
- import path from "path";
10
- import fs from "fs";
11
- import { createConnection } from "net";
12
- import { resolveClaudePath, buildEnv } from "./claude-resolver.js";
13
-
14
- const CLAUDE_PATH = resolveClaudePath();
15
-
16
- const QA_TOOLS = ["Read", "Glob", "Grep", "Bash"];
17
-
18
- // ── Main QA entry point ───────────────────────────────────────────────────────
19
- export async function runQAAgent(task, devResult, projectPath, prdContent, expoPort = 8081) {
20
- console.log(` 🔍 QA checking: ${task.title}`);
21
- await addLog(task.id, "QA starting", "progress");
22
-
23
- // ── 1. Check for truncated files (cheap, no Claude needed) ────────────────
24
- const truncated = await checkForTruncatedFiles(task, projectPath);
25
- if (truncated.length > 0) {
26
- const msg = `Incomplete files detected: ${truncated.join(", ")}`;
27
- await addLog(task.id, msg, "error");
28
- return {
29
- passed: false,
30
- issues: truncated,
31
- fixInstructions: `These files are truncated/incomplete:\n${truncated.join("\n")}\n\nRe-implement them completely.`,
32
- };
33
- }
34
-
35
- // ── 2. Check Metro health (if Expo is running) ────────────────────────────
36
- const expoRunning = await isPortOpen(expoPort);
37
- if (expoRunning) {
38
- const metroErr = await getMetroError(projectPath);
39
- if (metroErr) {
40
- await addLog(task.id, `Metro error: ${metroErr.slice(0, 100)}`, "error");
41
- return {
42
- passed: false,
43
- issues: [`Metro crash: ${metroErr.slice(0, 200)}`],
44
- fixInstructions: `Metro is crashing:\n${metroErr}\n\nFix this so the app loads on device without errors.`,
45
- };
46
- }
47
- }
48
-
49
- // ── 3. Take screenshot — iOS simulator first, fallback to web ────────────
50
- let screenshotBase64 = null;
51
- let screenshotPath = null;
52
- if (expoRunning) {
53
- await addLog(task.id, "Taking screenshot...", "progress");
54
- const screenshotDir = path.join(projectPath, ".claudeboard-screenshots");
55
- if (!fs.existsSync(screenshotDir)) fs.mkdirSync(screenshotDir, { recursive: true });
56
-
57
- // Try iOS simulator first (more accurate for native apps)
58
- const simPath = path.join(screenshotDir, `sim_${task.id}_${Date.now()}.png`);
59
- const simShot = await screenshotSimulator(simPath);
60
- if (simShot.success) {
61
- screenshotBase64 = simShot.base64;
62
- screenshotPath = simShot.path;
63
- await addLog(task.id, "📱 iOS simulator screenshot taken", "progress");
64
- } else {
65
- // Fallback to Expo Web screenshot
66
- const shot = await screenshotExpoWeb(expoPort, screenshotDir);
67
- if (shot.success && shot.base64) {
68
- screenshotBase64 = shot.base64;
69
- screenshotPath = shot.path;
70
- }
71
- }
72
- }
73
-
74
- // ── 4. Ask Claude Code to review the implementation ──────────────────────
75
- const qaResult = await runClaudeCodeQA(task, projectPath, prdContent, expoRunning, screenshotBase64, screenshotPath);
76
-
77
- if (qaResult.passed) {
78
- await addLog(task.id, `✓ QA passed: ${qaResult.summary}`, "complete");
79
- console.log(` ✓ QA passed: ${task.title}`);
80
- } else {
81
- await addLog(task.id, `✗ QA failed: ${qaResult.summary}`, "error");
82
- console.log(` ✗ QA failed: ${task.title} — ${qaResult.issues?.slice(0, 2).join(", ")}`);
83
- }
84
-
85
- return qaResult;
86
- }
87
-
88
- // ── Claude Code QA review ─────────────────────────────────────────────────────
89
- async function runClaudeCodeQA(task, projectPath, prdContent, expoRunning, screenshotBase64, screenshotPath) {
90
- const screenshotNote = screenshotBase64
91
- ? `A screenshot of the app (from iOS Simulator or Expo Web) has been saved to: ${screenshotPath}\nRead it with the Read tool and evaluate the visual result — check layout, text, colors, spacing.`
92
- : expoRunning
93
- ? "Expo is running but screenshot failed — evaluate code only."
94
- : "Expo is not running — evaluate code quality only (TypeScript, completeness, correctness).";
95
-
96
- const prompt = `You are a senior QA engineer reviewing a React Native / Expo implementation.
97
-
98
- TASK THAT WAS IMPLEMENTED:
99
- Title: ${task.title}
100
- Description: ${task.description}
101
-
102
- ${screenshotNote}
103
-
104
- YOUR JOB:
105
- 1. Run: npx tsc --noEmit 2>&1 | head -50
106
- - If TypeScript errors exist → FAIL with specific error details
107
- 2. Find and read the files related to this task (use Glob + Read)
108
- - Check for: incomplete implementations, missing imports, wrong logic
109
- 3. ${screenshotBase64 ? "Read the screenshot file and check if the UI matches what was expected" : "Evaluate the code for correctness based on the description"}
110
-
111
- PASS criteria:
112
- - TypeScript is clean (no errors)
113
- - Files are complete (not truncated)
114
- - Implementation matches the task description
115
-
116
- FAIL criteria:
117
- - TypeScript errors
118
- - Files clearly incomplete or truncated
119
- - Implementation completely missing or wrong
120
-
121
- When done, output EXACTLY one of these lines:
122
- QA_PASS: <one sentence summary of what was verified>
123
- QA_FAIL: <specific reason> | FIX: <exact instructions for the developer>`;
124
-
125
- if (!CLAUDE_PATH) {
126
- console.log(" ⚠ Claude Code not found — skipping QA");
127
- return { passed: true, summary: "Skipped (Claude Code not found)", issues: [] };
128
- }
129
-
130
- try {
131
- let output = "";
132
- let passed = null;
133
- let summary = "";
134
- let fixInstructions = "";
135
-
136
- for await (const message of query({
137
- prompt,
138
- options: {
139
- cwd: projectPath,
140
- tools: QA_TOOLS,
141
- permissionMode: "bypassPermissions",
142
- maxTurns: 20,
143
- pathToClaudeCodeExecutable: CLAUDE_PATH,
144
- env: buildEnv(),
145
- },
146
- })) {
147
- if (message.type === "assistant") {
148
- for (const block of message.message?.content || []) {
149
- if (block.type === "text") {
150
- const text = block.text;
151
- output += text + "\n";
152
-
153
- if (text.includes("QA_PASS:")) {
154
- passed = true;
155
- summary = text.split("QA_PASS:")[1]?.trim().split("\n")[0] || task.title;
156
- } else if (text.includes("QA_FAIL:")) {
157
- passed = false;
158
- const parts = text.split("QA_FAIL:")[1]?.split("| FIX:") || [];
159
- summary = parts[0]?.trim() || "QA failed";
160
- fixInstructions = parts[1]?.trim() || summary;
161
- }
162
- }
163
- if (block.type === "tool_use") {
164
- await addLog(task.id, `QA: ${block.name} ${JSON.stringify(block.input).slice(0, 60)}`, "progress");
165
- }
166
- }
167
- }
168
-
169
- if (message.type === "result") {
170
- // Check result text too
171
- const text = message.result || "";
172
- if (!passed && text.includes("QA_PASS:")) {
173
- passed = true;
174
- summary = text.split("QA_PASS:")[1]?.trim().split("\n")[0] || task.title;
175
- } else if (passed === null && text.includes("QA_FAIL:")) {
176
- passed = false;
177
- const parts = text.split("QA_FAIL:")[1]?.split("| FIX:") || [];
178
- summary = parts[0]?.trim() || "QA failed";
179
- fixInstructions = parts[1]?.trim() || summary;
180
- }
181
- // Default to pass if Claude Code ran but didn't signal explicitly
182
- if (passed === null) passed = true;
183
- }
184
- }
185
-
186
- return {
187
- passed: passed ?? true,
188
- summary: summary || task.title,
189
- issues: passed ? [] : [summary],
190
- fixInstructions: passed ? null : fixInstructions,
191
- };
192
-
193
- } catch (e) {
194
- // If Claude Code fails for any reason, don't block development
195
- console.log(` ⚠ QA error: ${e.message?.slice(0, 80)} — skipping`);
196
- return { passed: true, summary: "QA skipped (error)", issues: [] };
197
- }
198
- }
199
-
200
- // ── Full app QA after all tasks complete ──────────────────────────────────────
201
- export async function runFullAppQA(projectPath, prdContent, expoPort = 8081) {
202
- console.log("\n 🔍 Running full app QA...");
203
-
204
- if (!await isPortOpen(expoPort)) {
205
- console.log(" ⚠️ Expo not running — skipping visual QA");
206
- return { passed: true, routes: [], screenshotsCaptures: 0, issues: [] };
207
- }
208
-
209
- const appDir = path.join(projectPath, "app");
210
- if (!fs.existsSync(appDir)) return { passed: true, routes: [], screenshotsCaptures: 0, issues: [] };
211
-
212
- const routeFiles = listFiles(appDir, [".tsx", ".ts"])
213
- .filter(f => !f.includes("_layout") && !f.includes("_error"))
214
- .map(f => "/" + path.relative(appDir, f).replace(/\.(tsx|ts)$/, "").replace(/index$/, ""));
215
-
216
- const screenshotDir = path.join(projectPath, ".claudeboard-screenshots", "full-qa");
217
- if (!fs.existsSync(screenshotDir)) fs.mkdirSync(screenshotDir, { recursive: true });
218
-
219
- const screenshots = [];
220
- const issues = [];
221
- const prdSummary = prdContent.slice(0, 1200);
222
-
223
- for (const route of routeFiles) {
224
- // Try iOS simulator screenshot first, fallback to Expo Web
225
- const useIOS = process.env.CLAUDEBOARD_IOS === "1";
226
- let shot = null;
227
-
228
- if (useIOS) {
229
- const simPath = path.join(screenshotDir, `sim_fullqa_${route.replace(/\//g, "_")}_${Date.now()}.png`);
230
- const simShot = await screenshotSimulator(simPath);
231
- if (simShot.success) shot = simShot;
232
- }
233
-
234
- if (!shot) {
235
- const webShot = await screenshotExpoWeb(expoPort, screenshotDir, route);
236
- if (webShot.success) shot = webShot;
237
- }
238
-
239
- if (!shot) {
240
- await new Promise(r => setTimeout(r, 800));
241
- continue;
242
- }
243
-
244
- screenshots.push({ route, ...shot });
245
- console.log(chalk.dim(` Screenshot: ${route}`));
246
-
247
- // ── Visual analysis with Claude ───────────────────────────────────────
248
- try {
249
- const { text } = await callClaudeWithImage(
250
- "You are a senior mobile QA engineer reviewing screenshots of a React Native / Expo app. Be concise and critical.",
251
- `Screen route: ${route}
252
-
253
- PRD context (what the app should do):
254
- ${prdSummary}
255
-
256
- Review this screenshot and identify ONLY real issues such as:
257
- - Broken or overlapping layout
258
- - Empty states that should have content
259
- - Placeholder text (lorem ipsum, "TODO", "Coming soon", etc.)
260
- - Visible error messages or red screens
261
- - Missing navigation or broken UI components
262
- - Text cut off or unreadable
263
-
264
- If the screen looks complete and correct, respond with: OK
265
- If there are issues, respond with: ISSUE: <brief description>
266
- Do NOT flag minor styling preferences. Only flag broken or clearly incomplete functionality.`,
267
- shot.base64
268
- );
269
-
270
- const trimmed = text.trim();
271
- if (trimmed.startsWith("ISSUE:")) {
272
- const note = trimmed.replace("ISSUE:", "").trim();
273
- issues.push({ route, note });
274
- console.log(chalk.yellow(` ⚠ ${route}: ${note}`));
275
- } else {
276
- console.log(chalk.dim(` ✓ ${route}: OK`));
277
- }
278
- } catch (e) {
279
- console.log(chalk.dim(` ⚠ Visual analysis failed for ${route}: ${e.message?.slice(0, 60)}`));
280
- }
281
-
282
- await new Promise(r => setTimeout(r, 800));
283
- }
284
-
285
- return { passed: issues.length === 0, routes: routeFiles, screenshotsCaptures: screenshots.length, issues };
286
- }
287
-
288
- // ── Detect truncated files ─────────────────────────────────────────────────────
289
- async function checkForTruncatedFiles(task, projectPath) {
290
- const issues = [];
291
- const keywords = task.title.toLowerCase().split(" ").filter(w => w.length > 4);
292
- const files = listFiles(projectPath, [".ts", ".tsx"]).filter(f => {
293
- const rel = path.relative(projectPath, f).toLowerCase();
294
- if (rel.includes("node_modules") || rel.includes(".claudeboard")) return false;
295
- return keywords.some(kw => rel.includes(kw));
296
- });
297
-
298
- for (const f of files) {
299
- const content = readFile(f);
300
- if (!content) continue;
301
- const rel = path.relative(projectPath, f);
302
- const lastLines = content.split("\n").slice(-5).join("\n").trim();
303
- const openBraces = (content.match(/\{/g) || []).length;
304
- const closeBraces = (content.match(/\}/g) || []).length;
305
- const truncated =
306
- lastLines.endsWith("{") || lastLines.endsWith("(") || lastLines.endsWith(",") ||
307
- openBraces > closeBraces + 3 ||
308
- (content.length < 80 && (rel.includes("store") || rel.includes("hook") || rel.includes("screen")));
309
- if (truncated) issues.push(`${rel} (ends: "${lastLines.slice(-80)}")`);
310
- }
311
- return issues;
312
- }
313
-
314
- // ── Helpers ───────────────────────────────────────────────────────────────────
315
- function isPortOpen(port) {
316
- return new Promise(resolve => {
317
- const sock = createConnection({ port, host: "127.0.0.1" });
318
- sock.setTimeout(800);
319
- sock.once("connect", () => { sock.destroy(); resolve(true); });
320
- sock.once("error", () => resolve(false));
321
- sock.once("timeout", () => resolve(false));
322
- });
323
- }
324
-
325
- async function getMetroError(projectPath) {
326
- try {
327
- const f = path.join(projectPath, ".claudeboard-expo-error.txt");
328
- if (!fs.existsSync(f)) return null;
329
- const stat = fs.statSync(f);
330
- if (Date.now() - stat.mtimeMs > 5 * 60 * 1000) return null; // older than 5min
331
- const content = fs.readFileSync(f, "utf8");
332
- return content.includes("Unable to resolve") || content.includes("Cannot find module")
333
- ? content.slice(-800)
334
- : null;
335
- } catch { return null; }
336
- }