claudeboard 2.14.0 → 2.15.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,5 +1,7 @@
1
1
  import { callClaudeJSON } from "./claude-api.js";
2
- import { createEpic, createTask, addLog } from "./board-client.js";
2
+ import { createEpic, createTask } from "./board-client.js";
3
+ import fs from "fs";
4
+ import path from "path";
3
5
 
4
6
  const SYSTEM = `You are a senior mobile app architect specializing in React Native / Expo apps.
5
7
  Your job is to read a PRD and produce a complete, ordered task breakdown.
@@ -10,16 +12,79 @@ Rules:
10
12
  - Be specific — tasks like "implement auth" are too vague. Break into: "create login screen UI", "implement Supabase auth hook", "add protected route navigation"
11
13
  - Always include: project setup, navigation, data layer, each feature screen, error handling, loading states, and final QA tasks
12
14
  - Priority: high = blocking/core, medium = main features, low = polish/nice-to-have
13
- - Types: config (setup/deps), feature (new screen or functionality), bug (fix), refactor, test (QA task)`;
15
+ - Types: config (setup/deps), feature (new screen or functionality), bug (fix), refactor, test (QA task)
16
+ - When building on an existing project: do NOT recreate files that already exist — create tasks that extend or integrate with them`;
14
17
 
15
- export async function runArchitectAgent(prdContent, projectName) {
16
- console.log(" 🏗️ Architect analyzing PRD...");
18
+ /**
19
+ * Build a concise snapshot of the existing project:
20
+ * - Top-level file/folder structure
21
+ * - package.json dependencies
22
+ * - app.json expo config (if present)
23
+ */
24
+ function getProjectSnapshot(projectPath) {
25
+ const lines = [];
26
+
27
+ // Top-level structure (exclude hidden files and node_modules)
28
+ try {
29
+ const entries = fs.readdirSync(projectPath)
30
+ .filter(f => !f.startsWith('.') && f !== 'node_modules')
31
+ .sort();
32
+ lines.push("### Project structure (top level)");
33
+ for (const entry of entries) {
34
+ const fullPath = path.join(projectPath, entry);
35
+ const isDir = fs.statSync(fullPath).isDirectory();
36
+ if (isDir) {
37
+ // List one level deep for key folders
38
+ const children = fs.readdirSync(fullPath)
39
+ .filter(f => !f.startsWith('.') && f !== 'node_modules')
40
+ .slice(0, 15);
41
+ lines.push(`${entry}/`);
42
+ for (const child of children) lines.push(` ${entry}/${child}`);
43
+ } else {
44
+ lines.push(entry);
45
+ }
46
+ }
47
+ } catch {}
48
+
49
+ // package.json
50
+ try {
51
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
52
+ lines.push("\n### package.json dependencies");
53
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
54
+ for (const [name, version] of Object.entries(deps)) {
55
+ lines.push(` ${name}: ${version}`);
56
+ }
57
+ } catch {}
58
+
59
+ // app.json
60
+ try {
61
+ const appJson = JSON.parse(fs.readFileSync(path.join(projectPath, "app.json"), "utf8"));
62
+ lines.push("\n### app.json (expo config)");
63
+ lines.push(JSON.stringify(appJson.expo || appJson, null, 2).slice(0, 1000));
64
+ } catch {}
65
+
66
+ return lines.join("\n");
67
+ }
68
+
69
+ export async function runArchitectAgent(prdContent, projectName, options = {}) {
70
+ const { buildOnExisting = false, projectPath = null } = options;
71
+
72
+ console.log(" Architect analyzing PRD...");
73
+
74
+ const projectSnapshot = (buildOnExisting && projectPath)
75
+ ? getProjectSnapshot(projectPath)
76
+ : null;
77
+
78
+ const existingProjectSection = projectSnapshot
79
+ ? `\n\nEXISTING PROJECT SNAPSHOT:\n${projectSnapshot}\n\nIMPORTANT: Create tasks that BUILD ON top of what already exists. Do NOT recreate files or setup that is already in place. Analyze the snapshot carefully and only create tasks for what is missing or needs to be extended.`
80
+ : "";
17
81
 
18
82
  const result = await callClaudeJSON(SYSTEM, `
19
83
  Project: ${projectName}
20
84
 
21
85
  PRD:
22
86
  ${prdContent}
87
+ ${existingProjectSection}
23
88
 
24
89
  Return this JSON structure:
25
90
  {
@@ -49,7 +114,7 @@ Return this JSON structure:
49
114
  }
50
115
 
51
116
  Order epics from first to last in implementation order.
52
- Include 25-50 tasks total for a complete mobile app.`);
117
+ Include as many tasks as needed for a complete, production-ready mobile app — do not artificially limit the count.`, { maxTokens: 16000 });
53
118
 
54
119
  console.log(` ✓ Architect created ${result.epics.length} epics`);
55
120
 
@@ -57,11 +122,11 @@ Include 25-50 tasks total for a complete mobile app.`);
57
122
  let totalTasks = 0;
58
123
  for (let i = 0; i < result.epics.length; i++) {
59
124
  const epicData = result.epics[i];
60
- const epic = await createEpic(epicData.name);
125
+ const epicId = await createEpic(epicData.name);
61
126
 
62
127
  for (const task of epicData.tasks) {
63
128
  await createTask({
64
- epicId: epic.id,
129
+ epicId,
65
130
  title: task.title,
66
131
  description: `${task.description}\n\nAcceptance: ${task.acceptanceCriteria || ""}`,
67
132
  priority: task.priority,
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const MODEL = "claude-sonnet-4-20250514";
7
- const MAX_TOKENS = 8096; // Max output tokens — input context window is 200k, no limits there
7
+ const MAX_TOKENS = 16000; // Max output tokens — input context window is 200k, no limits there
8
8
 
9
9
  function getHeaders() {
10
10
  const key = process.env.ANTHROPIC_API_KEY;
@@ -67,7 +67,7 @@ RULES:
67
67
  - Use TypeScript if the project uses it
68
68
  - Read existing files first to follow the project's patterns
69
69
  - Install packages with: npx expo install <package>
70
- - After writing files run: npx tsc --noEmit fix any errors you find
70
+ - If tsconfig.json exists, run: npx tsc --noEmit after writing files and fix any errors
71
71
  - If you hit an error, read it carefully and fix it — iterate until it works
72
72
  - Do NOT ask questions or ask for confirmation. Make your best judgment.
73
73
  - Do NOT run npx expo run:ios or npx expo run:android — the orchestrator handles native builds
@@ -75,7 +75,7 @@ RULES:
75
75
  `;
76
76
 
77
77
  // ── Main export ───────────────────────────────────────────────────────────────
78
- export async function runDeveloperAgent(task, projectPath, techStack, retryContext = null) {
78
+ export async function runDeveloperAgent(task, projectPath, techStack, allTasks = [], retryContext = null) {
79
79
  console.log(` 🤖 Claude Code working on: ${task.title}`);
80
80
 
81
81
  if (!CLAUDE_PATH) {
@@ -97,6 +97,27 @@ export async function runDeveloperAgent(task, projectPath, techStack, retryConte
97
97
  ? `\nKnown tech stack: ${JSON.stringify(techStack)}`
98
98
  : "";
99
99
 
100
+ // Build task context for mini-plan
101
+ const doneTasks = allTasks
102
+ .filter(t => t.status === "done")
103
+ .map(t => ` ✓ ${t.title}`)
104
+ .join("\n");
105
+ const upcomingTasks = allTasks
106
+ .filter(t => t.status === "todo" && t.id !== task.id)
107
+ .slice(0, 12)
108
+ .map(t => ` ○ ${t.title}`)
109
+ .join("\n");
110
+
111
+ const taskContextSection = (doneTasks || upcomingTasks) ? `
112
+ ## Implementation Context
113
+
114
+ **Tasks already completed:**
115
+ ${doneTasks || " None yet — this is the first task"}
116
+
117
+ **Upcoming tasks (do NOT implement these, just be aware to avoid conflicts):**
118
+ ${upcomingTasks || " None"}
119
+ ` : "";
120
+
100
121
  const prompt = `## Task to implement
101
122
 
102
123
  **Title:** ${task.title}
@@ -104,14 +125,20 @@ export async function runDeveloperAgent(task, projectPath, techStack, retryConte
104
125
  **Description:**
105
126
  ${task.description || "No additional description provided."}
106
127
  ${techNote}
128
+ ${taskContextSection}
129
+ ## Project Context
130
+ Read CLAUDEBOARD_CONTEXT.md first if it exists — it contains key architectural decisions and patterns from previous tasks.
131
+ After completing this task, UPDATE CLAUDEBOARD_CONTEXT.md with any new decisions, patterns, file structure changes, or naming conventions introduced.
107
132
  ${retryNote}
108
133
 
109
134
  ## Steps
135
+ 0. Read CLAUDEBOARD_CONTEXT.md if it exists. Then write a brief implementation plan: which files you will create/modify and how this fits with the done/upcoming tasks above.
110
136
  1. Explore the project structure to understand existing patterns
111
- 2. Implement the task completely
137
+ 2. Implement the task completely — follow patterns already established
112
138
  3. Install any missing dependencies with: npx expo install <pkg>
113
- 4. Run: npx tsc --noEmit and fix any errors
114
- 5. When done, print: TASK_COMPLETE: <summary>`;
139
+ 4. If tsconfig.json exists, run: npx tsc --noEmit and fix any errors
140
+ 5. Update CLAUDEBOARD_CONTEXT.md with key decisions made
141
+ 6. When done, print: TASK_COMPLETE: <summary>`;
115
142
 
116
143
  try {
117
144
  let toolCallCount = 0;
@@ -89,7 +89,39 @@ export async function runOrchestrator(config) {
89
89
  console.log(chalk.dim(" Force restart — skipping existing tasks (they remain in board)\n"));
90
90
  }
91
91
  console.log(chalk.bold.cyan("[ PHASE 1: ARCHITECTURE ]\n"));
92
- const archResult = await runArchitectAgent(prdContent, projectName);
92
+
93
+ // ── Detect existing project files (besides the PRD) ───────────────────────
94
+ let buildOnExisting = false;
95
+ const EXISTING_CODE_MARKERS = ["package.json", "app.json", "src", "app", "components", "screens"];
96
+ try {
97
+ const prdBasename = path.basename(prdPath);
98
+ const projectFiles = fs.readdirSync(projectPath)
99
+ .filter(f => !f.startsWith(".") && f !== prdBasename && f !== "node_modules");
100
+ const hasExistingCode = projectFiles.some(f => EXISTING_CODE_MARKERS.includes(f));
101
+
102
+ if (hasExistingCode && !config.forceRestart) {
103
+ console.log(chalk.yellow(" ⚠ This project already has files:\n"));
104
+ projectFiles.slice(0, 12).forEach(f => console.log(chalk.dim(` ${f}`)));
105
+ if (projectFiles.length > 12) console.log(chalk.dim(` ... and ${projectFiles.length - 12} more`));
106
+ console.log();
107
+
108
+ const { default: Enquirer } = await import("enquirer");
109
+ const enquirer = new Enquirer();
110
+ const answer = await enquirer.prompt({
111
+ type: "confirm",
112
+ name: "buildOnExisting",
113
+ message: "Build tasks on top of the existing project (instead of starting from scratch)?",
114
+ initial: true,
115
+ });
116
+ buildOnExisting = answer.buildOnExisting;
117
+ console.log();
118
+ }
119
+ } catch {}
120
+
121
+ const archResult = await runArchitectAgent(prdContent, projectName, {
122
+ buildOnExisting,
123
+ projectPath,
124
+ });
93
125
  techStack = archResult.techStack;
94
126
  console.log(chalk.green(`\n ✓ ${archResult.totalTasks} tasks created across ${archResult.epics.length} epics\n`));
95
127
  }
@@ -130,8 +162,11 @@ export async function runOrchestrator(config) {
130
162
  console.log(chalk.bold(`\n → ${task.title}`));
131
163
  console.log(chalk.dim(` Epic: ${task.cb_epics?.name || "—"} | ${task.priority} | ${task.type}`));
132
164
 
165
+ // Fetch all tasks for mini-plan context (done + upcoming)
166
+ const allTasks = await getAllTasks();
167
+
133
168
  // Run developer agent
134
- const devResult = await runDeveloperAgent(task, projectPath, techStack);
169
+ const devResult = await runDeveloperAgent(task, projectPath, techStack, allTasks);
135
170
 
136
171
  if (!devResult.success) {
137
172
  consecutiveFailures++;
@@ -208,6 +243,7 @@ export async function runOrchestrator(config) {
208
243
  { ...task, title: `FIX: ${task.title}`, description: qaResult.fixInstructions },
209
244
  projectPath,
210
245
  techStack,
246
+ allTasks,
211
247
  qaResult.fixInstructions
212
248
  );
213
249
 
package/agents/qa.js CHANGED
@@ -3,8 +3,10 @@ import { addLog, completeTask, failTask } from "./board-client.js";
3
3
  import { runCommand } from "../tools/terminal.js";
4
4
  import { screenshotExpoWeb } from "../tools/screenshot.js";
5
5
  import { screenshotSimulator } from "./expo-health.js";
6
+ import { callClaudeWithImage } from "./claude-api.js";
6
7
  import { listFiles, readFile } from "../tools/filesystem.js";
7
8
  import { execSync } from "child_process";
9
+ import chalk from "chalk";
8
10
  import path from "path";
9
11
  import fs from "fs";
10
12
  import { createConnection } from "net";
@@ -245,15 +247,75 @@ export async function runFullAppQA(projectPath, prdContent, expoPort = 8081) {
245
247
  .map(f => "/" + path.relative(appDir, f).replace(/\.(tsx|ts)$/, "").replace(/index$/, ""));
246
248
 
247
249
  const screenshotDir = path.join(projectPath, ".claudeboard-screenshots", "full-qa");
250
+ if (!fs.existsSync(screenshotDir)) fs.mkdirSync(screenshotDir, { recursive: true });
251
+
248
252
  const screenshots = [];
253
+ const issues = [];
254
+ const prdSummary = prdContent.slice(0, 1200);
249
255
 
250
256
  for (const route of routeFiles) {
251
- const shot = await screenshotExpoWeb(expoPort, screenshotDir, route);
252
- if (shot.success) screenshots.push({ route, ...shot });
257
+ // Try iOS simulator screenshot first, fallback to Expo Web
258
+ const useIOS = process.env.CLAUDEBOARD_IOS === "1";
259
+ let shot = null;
260
+
261
+ if (useIOS) {
262
+ const simPath = path.join(screenshotDir, `sim_fullqa_${route.replace(/\//g, "_")}_${Date.now()}.png`);
263
+ const simShot = await screenshotSimulator(simPath);
264
+ if (simShot.success) shot = simShot;
265
+ }
266
+
267
+ if (!shot) {
268
+ const webShot = await screenshotExpoWeb(expoPort, screenshotDir, route);
269
+ if (webShot.success) shot = webShot;
270
+ }
271
+
272
+ if (!shot) {
273
+ await new Promise(r => setTimeout(r, 800));
274
+ continue;
275
+ }
276
+
277
+ screenshots.push({ route, ...shot });
278
+ console.log(chalk.dim(` Screenshot: ${route}`));
279
+
280
+ // ── Visual analysis with Claude ───────────────────────────────────────
281
+ try {
282
+ const { text } = await callClaudeWithImage(
283
+ "You are a senior mobile QA engineer reviewing screenshots of a React Native / Expo app. Be concise and critical.",
284
+ `Screen route: ${route}
285
+
286
+ PRD context (what the app should do):
287
+ ${prdSummary}
288
+
289
+ Review this screenshot and identify ONLY real issues such as:
290
+ - Broken or overlapping layout
291
+ - Empty states that should have content
292
+ - Placeholder text (lorem ipsum, "TODO", "Coming soon", etc.)
293
+ - Visible error messages or red screens
294
+ - Missing navigation or broken UI components
295
+ - Text cut off or unreadable
296
+
297
+ If the screen looks complete and correct, respond with: OK
298
+ If there are issues, respond with: ISSUE: <brief description>
299
+ Do NOT flag minor styling preferences. Only flag broken or clearly incomplete functionality.`,
300
+ shot.base64
301
+ );
302
+
303
+ const trimmed = text.trim();
304
+ if (trimmed.startsWith("ISSUE:")) {
305
+ const note = trimmed.replace("ISSUE:", "").trim();
306
+ issues.push({ route, note });
307
+ console.log(chalk.yellow(` ⚠ ${route}: ${note}`));
308
+ } else {
309
+ console.log(chalk.dim(` ✓ ${route}: OK`));
310
+ }
311
+ } catch (e) {
312
+ console.log(chalk.dim(` ⚠ Visual analysis failed for ${route}: ${e.message?.slice(0, 60)}`));
313
+ }
314
+
253
315
  await new Promise(r => setTimeout(r, 800));
254
316
  }
255
317
 
256
- return { passed: true, routes: routeFiles, screenshotsCaptures: screenshots.length, issues: [] };
318
+ return { passed: issues.length === 0, routes: routeFiles, screenshotsCaptures: screenshots.length, issues };
257
319
  }
258
320
 
259
321
  // ── Detect truncated files ─────────────────────────────────────────────────────
@@ -1364,15 +1364,17 @@ function patchTaskInMemory(patch) {
1364
1364
  // We track order in JS so we don't depend on DOM state during drag
1365
1365
  let dragColumnOrder = {}; // { status: [id, id, ...] } — snapshot at dragstart
1366
1366
  function onDragStart(e, id) {
1367
- draggedId = id;
1367
+ // Normalize to string so comparisons with dataset values are consistent
1368
+ draggedId = String(id);
1368
1369
 
1369
1370
  // Snapshot current order of all columns from memory (reliable — not DOM-dependent)
1371
+ // IDs are stored as strings to match dataset.id comparisons
1370
1372
  dragColumnOrder = {};
1371
1373
  for (const status of ['todo', 'in_progress', 'done', 'error']) {
1372
1374
  dragColumnOrder[status] = allTasks()
1373
1375
  .filter(t => t.status === status)
1374
1376
  .sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99))
1375
- .map(t => t.id);
1377
+ .map(t => String(t.id));
1376
1378
  }
1377
1379
 
1378
1380
  setTimeout(() => {
@@ -1432,30 +1434,39 @@ async function onDrop(e, status) {
1432
1434
  if (!draggedId) return;
1433
1435
 
1434
1436
  const col = e.currentTarget;
1437
+ // Read insertBefore from the column body's data attribute (set by onDragOver)
1435
1438
  const insertBeforeId = col.dataset.insertBefore || '';
1436
1439
  delete col.dataset.insertBefore;
1437
1440
 
1441
+ // Normalize IDs to strings for consistent comparison (dataset values are always strings)
1442
+ const draggedIdStr = String(draggedId);
1443
+ const insertBeforeIdStr = insertBeforeId ? String(insertBeforeId) : '';
1444
+
1438
1445
  // Use the JS snapshot (taken at dragstart) — not DOM which has .dragging gaps
1439
- const sourceStatus = allTasks().find(t => t.id === draggedId)?.status || status;
1440
- const columnIds = (dragColumnOrder[status] || []).filter(id => id !== draggedId);
1446
+ const sourceStatus = allTasks().find(t => String(t.id) === draggedIdStr)?.status || status;
1447
+
1448
+ // columnIds is already an array of strings (set in onDragStart)
1449
+ const columnIds = (dragColumnOrder[status] || []).filter(id => id !== draggedIdStr);
1441
1450
 
1442
1451
  let newOrderedIds;
1443
- if (!insertBeforeId) {
1444
- newOrderedIds = [...columnIds, draggedId];
1452
+ if (!insertBeforeIdStr) {
1453
+ // Dropped at the end of the column
1454
+ newOrderedIds = [...columnIds, draggedIdStr];
1445
1455
  } else {
1446
- const insertIdx = columnIds.indexOf(insertBeforeId);
1456
+ const insertIdx = columnIds.indexOf(insertBeforeIdStr);
1447
1457
  if (insertIdx === -1) {
1448
- newOrderedIds = [draggedId, ...columnIds];
1458
+ // insertBeforeId not found in snapshot — fall back to appending at end
1459
+ newOrderedIds = [...columnIds, draggedIdStr];
1449
1460
  } else {
1450
- newOrderedIds = [...columnIds.slice(0, insertIdx), draggedId, ...columnIds.slice(insertIdx)];
1461
+ newOrderedIds = [...columnIds.slice(0, insertIdx), draggedIdStr, ...columnIds.slice(insertIdx)];
1451
1462
  }
1452
1463
  }
1453
1464
 
1454
1465
  // Optimistic update in memory so re-render is instant
1455
- const task = allTasks().find(t => t.id === draggedId);
1466
+ const task = allTasks().find(t => String(t.id) === draggedIdStr);
1456
1467
  if (task) task.status = status;
1457
- newOrderedIds.forEach((id, i) => {
1458
- const t = allTasks().find(t => t.id === id);
1468
+ newOrderedIds.forEach((idStr, i) => {
1469
+ const t = allTasks().find(t => String(t.id) === idStr);
1459
1470
  if (t) t.priority_order = i + 1;
1460
1471
  });
1461
1472
  renderKanban();
@@ -1463,14 +1474,14 @@ async function onDrop(e, status) {
1463
1474
 
1464
1475
  // Update status if column changed
1465
1476
  if (sourceStatus !== status) {
1466
- await fetch(`/api/tasks/${draggedId}`, {
1477
+ await fetch(`/api/tasks/${draggedIdStr}`, {
1467
1478
  method: 'PATCH',
1468
1479
  headers: { 'Content-Type': 'application/json' },
1469
1480
  body: JSON.stringify({ status }),
1470
1481
  });
1471
1482
  }
1472
1483
 
1473
- // Persist new order
1484
+ // Persist new order (send as-is — server handles string or numeric IDs)
1474
1485
  await fetch('/api/tasks/reorder', {
1475
1486
  method: 'POST',
1476
1487
  headers: { 'Content-Type': 'application/json' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeboard",
3
- "version": "2.14.0",
3
+ "version": "2.15.1",
4
4
  "description": "AI engineering team — from PRD to working mobile app, autonomously",
5
5
  "type": "module",
6
6
  "bin": {