claudeboard 2.13.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,14 +67,15 @@ 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
+ - Do NOT run npx expo run:ios or npx expo run:android — the orchestrator handles native builds
73
74
  - When fully done, print EXACTLY this line: TASK_COMPLETE: <one sentence summary>
74
75
  `;
75
76
 
76
77
  // ── Main export ───────────────────────────────────────────────────────────────
77
- export async function runDeveloperAgent(task, projectPath, techStack, retryContext = null) {
78
+ export async function runDeveloperAgent(task, projectPath, techStack, allTasks = [], retryContext = null) {
78
79
  console.log(` 🤖 Claude Code working on: ${task.title}`);
79
80
 
80
81
  if (!CLAUDE_PATH) {
@@ -96,6 +97,27 @@ export async function runDeveloperAgent(task, projectPath, techStack, retryConte
96
97
  ? `\nKnown tech stack: ${JSON.stringify(techStack)}`
97
98
  : "";
98
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
+
99
121
  const prompt = `## Task to implement
100
122
 
101
123
  **Title:** ${task.title}
@@ -103,14 +125,20 @@ export async function runDeveloperAgent(task, projectPath, techStack, retryConte
103
125
  **Description:**
104
126
  ${task.description || "No additional description provided."}
105
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.
106
132
  ${retryNote}
107
133
 
108
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.
109
136
  1. Explore the project structure to understand existing patterns
110
- 2. Implement the task completely
137
+ 2. Implement the task completely — follow patterns already established
111
138
  3. Install any missing dependencies with: npx expo install <pkg>
112
- 4. Run: npx tsc --noEmit and fix any errors
113
- 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>`;
114
142
 
115
143
  try {
116
144
  let toolCallCount = 0;
@@ -4,12 +4,15 @@ import { createTask, createEpic, addLog } from "./board-client.js";
4
4
  import chalk from "chalk";
5
5
  import fs from "fs";
6
6
  import path from "path";
7
+ import crypto from "crypto";
7
8
  import { createRequire } from "module";
8
9
  import { createConnection } from "net";
9
10
  import { spawn as _spawn, execSync } from "child_process";
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
  const MAX_FIX_ATTEMPTS = 5;
14
+ const BUILD_HASH_FILE = ".claudeboard-build-hash.txt";
15
+ const BUILD_TIMEOUT_MS = 10 * 60 * 1000; // 10 min max for expo run:ios
13
16
 
14
17
  /**
15
18
  * Expo Health Check Agent — runs BEFORE the development loop.
@@ -17,9 +20,10 @@ const MAX_FIX_ATTEMPTS = 5;
17
20
  * Strategy:
18
21
  * 1. Try npm install (--legacy-peer-deps, then --force if needed)
19
22
  * 2. If install fails with ETARGET/missing version → ask Claude for fix → apply → retry
20
- * 3. Start Expo, wait for Metro to be truly ready (not just port open)
21
- * 4. If Metro crashes with module/dep errors ask Claude apply retry
22
- * 5. If still broken after MAX attemptsinject a BLOCKER task into the board
23
+ * 3. If iOS mode: ensure a dev build exists in the simulator (builds if needed)
24
+ * 4. Start Expo, wait for Metro to be truly ready (not just port open)
25
+ * 5. If Metro crashes with module/dep errorsask Claude apply retry
26
+ * 6. If still broken after MAX attempts → inject a BLOCKER task into the board
23
27
  * so the developer agent fixes it before any other task runs
24
28
  *
25
29
  * Returns { ready: boolean, process: ChildProcess|null }
@@ -44,7 +48,22 @@ export async function runExpoHealthCheck(projectPath, port = 8081) {
44
48
  return { ready: false, process: null };
45
49
  }
46
50
 
47
- // ── 2. Start Expo + verify Metro is error-free ─────────────────────────────
51
+ // ── 2. Dev build (iOS mode only) ───────────────────────────────────────────
52
+ const useIOS = process.env.CLAUDEBOARD_IOS === "1" || isSimulatorAvailableSync();
53
+ if (useIOS) {
54
+ const buildOk = await ensureDevBuild(projectPath);
55
+ if (!buildOk) {
56
+ await injectFixTask(projectPath,
57
+ "FIX: Dev build failed — fix native compilation errors",
58
+ "npx expo run:ios failed. Fix any native dependency or Podfile issues so the app compiles.\n" +
59
+ "Run: npx expo run:ios --simulator and fix all errors."
60
+ );
61
+ console.log(chalk.yellow(" ✗ Dev build failed — injected fix task into board\n"));
62
+ return { ready: false, process: null };
63
+ }
64
+ }
65
+
66
+ // ── 3. Start Expo + verify Metro is error-free ─────────────────────────────
48
67
  for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS; attempt++) {
49
68
  console.log(chalk.dim(` Starting Expo (attempt ${attempt}/${MAX_FIX_ATTEMPTS})...`));
50
69
 
@@ -84,6 +103,217 @@ export async function runExpoHealthCheck(projectPath, port = 8081) {
84
103
  return { ready: false, process: null };
85
104
  }
86
105
 
106
+ // ── Dev Build management ───────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Compute a short hash of package.json to detect dependency changes.
110
+ */
111
+ export function packageJsonHash(projectPath) {
112
+ try {
113
+ const content = fs.readFileSync(path.join(projectPath, "package.json"), "utf8");
114
+ return crypto.createHash("md5").update(content).digest("hex").slice(0, 12);
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Returns true if a rebuild is needed:
122
+ * - No saved hash (never built), OR
123
+ * - package.json changed since last build
124
+ */
125
+ export function needsRebuild(projectPath) {
126
+ const hashFile = path.join(projectPath, BUILD_HASH_FILE);
127
+ if (!fs.existsSync(hashFile)) return true;
128
+ try {
129
+ const saved = fs.readFileSync(hashFile, "utf8").trim();
130
+ return saved !== packageJsonHash(projectPath);
131
+ } catch {
132
+ return true;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Persist the current package.json hash after a successful build.
138
+ */
139
+ function saveBuildHash(projectPath) {
140
+ try {
141
+ const hash = packageJsonHash(projectPath);
142
+ if (hash) fs.writeFileSync(path.join(projectPath, BUILD_HASH_FILE), hash, "utf8");
143
+ } catch {}
144
+ }
145
+
146
+ /**
147
+ * Check if the app's dev build is already installed in the booted simulator.
148
+ */
149
+ function isDevBuildInstalled(projectPath) {
150
+ try {
151
+ const bundleId = getBundleId(projectPath);
152
+ if (!bundleId) return false;
153
+ const output = execSync(`xcrun simctl get_app_container booted "${bundleId}" 2>&1`, { encoding: "utf8" });
154
+ return output.trim().length > 0 && !output.includes("No such file");
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Read the iOS bundle identifier from app.json / app.config.js.
162
+ * Falls back to deriving it from the project slug.
163
+ */
164
+ function getBundleId(projectPath) {
165
+ try {
166
+ const appJsonPath = path.join(projectPath, "app.json");
167
+ if (fs.existsSync(appJsonPath)) {
168
+ const cfg = JSON.parse(fs.readFileSync(appJsonPath, "utf8"));
169
+ const bundleId = cfg?.expo?.ios?.bundleIdentifier;
170
+ if (bundleId) return bundleId;
171
+ // Derive from slug if no explicit bundleIdentifier
172
+ const slug = cfg?.expo?.slug;
173
+ if (slug) return `com.anonymous.${slug.replace(/[^a-zA-Z0-9]/g, "")}`;
174
+ }
175
+ } catch {}
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * Boot the first available iOS simulator if none is booted.
181
+ */
182
+ function ensureSimulatorBooted() {
183
+ try {
184
+ const booted = execSync("xcrun simctl list devices booted 2>&1", { encoding: "utf8" });
185
+ if (booted.includes("Booted")) return true;
186
+
187
+ // Find a suitable device to boot
188
+ const devices = execSync("xcrun simctl list devices available 2>&1", { encoding: "utf8" });
189
+ const match = devices.match(/iPhone \d[^(]*\(([A-F0-9-]{36})\)/i);
190
+ if (!match) return false;
191
+
192
+ console.log(chalk.dim(` Booting simulator ${match[1]}...`));
193
+ execSync(`xcrun simctl boot "${match[1]}" 2>&1`, { encoding: "utf8" });
194
+ // Give the simulator a moment to fully boot
195
+ execSync("sleep 5");
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Build and install the dev build in the simulator using `expo run:ios`.
204
+ * Only rebuilds when package.json changed or the app is not installed.
205
+ * Returns true on success, false on failure.
206
+ */
207
+ export async function ensureDevBuild(projectPath) {
208
+ const installed = isDevBuildInstalled(projectPath);
209
+ const rebuild = needsRebuild(projectPath);
210
+
211
+ if (installed && !rebuild) {
212
+ console.log(chalk.dim(" ✓ Dev build up to date — skipping rebuild"));
213
+ return true;
214
+ }
215
+
216
+ const reason = !installed ? "app not installed" : "dependencies changed";
217
+ console.log(chalk.cyan(` Building dev build (${reason})...`));
218
+ console.log(chalk.dim(" This may take several minutes on first run."));
219
+
220
+ // Make sure a simulator is running
221
+ const simReady = ensureSimulatorBooted();
222
+ if (!simReady) {
223
+ console.log(chalk.yellow(" ✗ No iOS simulator available"));
224
+ return false;
225
+ }
226
+
227
+ return new Promise((resolve) => {
228
+ let output = "";
229
+ let resolved = false;
230
+
231
+ const done = (ok) => {
232
+ if (resolved) return;
233
+ resolved = true;
234
+ if (ok) {
235
+ saveBuildHash(projectPath);
236
+ console.log(chalk.green(" ✓ Dev build installed in simulator"));
237
+ }
238
+ resolve(ok);
239
+ };
240
+
241
+ const timeout = setTimeout(() => {
242
+ console.log(chalk.yellow(" ✗ Dev build timed out after 10 minutes"));
243
+ done(false);
244
+ }, BUILD_TIMEOUT_MS);
245
+
246
+ try {
247
+ const proc = _spawn("npx", ["expo", "run:ios", "--simulator"], {
248
+ cwd: projectPath,
249
+ env: { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1" },
250
+ stdio: "pipe",
251
+ });
252
+
253
+ const onData = (d) => {
254
+ const text = d.toString();
255
+ output += text;
256
+ // Log progress lines that are meaningful
257
+ const lines = text.split("\n").filter(l => l.trim() && !l.includes("\u001b["));
258
+ for (const line of lines) {
259
+ if (
260
+ line.includes("Installing") ||
261
+ line.includes("Building") ||
262
+ line.includes("Compiling") ||
263
+ line.includes("Linking") ||
264
+ line.includes("error:") ||
265
+ line.includes("warning:")
266
+ ) {
267
+ console.log(chalk.dim(` ${line.trim().slice(0, 100)}`));
268
+ }
269
+ }
270
+
271
+ if (
272
+ text.includes("Installed on") ||
273
+ text.includes("Opening on") ||
274
+ text.includes("Successfully built") ||
275
+ text.includes("BUILD SUCCEEDED")
276
+ ) {
277
+ clearTimeout(timeout);
278
+ done(true);
279
+ }
280
+
281
+ if (
282
+ text.includes("BUILD FAILED") ||
283
+ text.includes("error: ") ||
284
+ text.includes("Command failed")
285
+ ) {
286
+ // Wait a bit to collect full error output
287
+ setTimeout(() => {
288
+ clearTimeout(timeout);
289
+ saveLastError(projectPath, output.slice(-3000));
290
+ done(false);
291
+ }, 2000);
292
+ }
293
+ };
294
+
295
+ proc.stdout.on("data", onData);
296
+ proc.stderr.on("data", onData);
297
+
298
+ proc.on("close", (code) => {
299
+ clearTimeout(timeout);
300
+ if (!resolved) {
301
+ if (code === 0) {
302
+ done(true);
303
+ } else {
304
+ saveLastError(projectPath, output.slice(-3000));
305
+ done(false);
306
+ }
307
+ }
308
+ });
309
+
310
+ } catch (e) {
311
+ clearTimeout(timeout);
312
+ done(false);
313
+ }
314
+ });
315
+ }
316
+
87
317
  // ── npm install with progressive fallback ─────────────────────────────────
88
318
  async function installDeps(projectPath) {
89
319
  const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
@@ -150,7 +380,7 @@ async function tryStartExpo(projectPath, port) {
150
380
  done(false, `Expo did not become ready within 60s.\n${output.slice(-800)}`), 60000);
151
381
 
152
382
  try {
153
- // Use iOS simulator if available, fallback to web
383
+ // iOS mode: use dev build (--go is intentionally omitted so the installed dev build opens)
154
384
  const useIOS = process.env.CLAUDEBOARD_IOS === "1" || isSimulatorAvailableSync();
155
385
  const expoArgs = useIOS
156
386
  ? ["expo", "start", "--ios", "--port", String(port)]
@@ -5,7 +5,7 @@ import path from "path";
5
5
  import { runArchitectAgent } from "./architect.js";
6
6
  import { runDeveloperAgent } from "./developer.js";
7
7
  import { runQAAgent, runFullAppQA } from "./qa.js";
8
- import { runExpoHealthCheck } from "./expo-health.js";
8
+ import { runExpoHealthCheck, ensureDevBuild, packageJsonHash } from "./expo-health.js";
9
9
  import { createConnection } from "net";
10
10
 
11
11
  function isPortOpen(port) {
@@ -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
  }
@@ -112,6 +144,9 @@ export async function runOrchestrator(config) {
112
144
  let consecutiveFailures = 0;
113
145
  const MAX_CONSECUTIVE_FAILURES = 3;
114
146
 
147
+ // Track package.json hash to detect native dependency changes that need a rebuild
148
+ let lastPkgHash = packageJsonHash(projectPath);
149
+
115
150
  while (true) {
116
151
  const task = await getNextTask();
117
152
 
@@ -127,8 +162,11 @@ export async function runOrchestrator(config) {
127
162
  console.log(chalk.bold(`\n → ${task.title}`));
128
163
  console.log(chalk.dim(` Epic: ${task.cb_epics?.name || "—"} | ${task.priority} | ${task.type}`));
129
164
 
165
+ // Fetch all tasks for mini-plan context (done + upcoming)
166
+ const allTasks = await getAllTasks();
167
+
130
168
  // Run developer agent
131
- const devResult = await runDeveloperAgent(task, projectPath, techStack);
169
+ const devResult = await runDeveloperAgent(task, projectPath, techStack, allTasks);
132
170
 
133
171
  if (!devResult.success) {
134
172
  consecutiveFailures++;
@@ -153,6 +191,29 @@ export async function runOrchestrator(config) {
153
191
  expoProcess = recheck.process;
154
192
  }
155
193
 
194
+ // ── Detect native dependency changes → rebuild dev build if needed ────────
195
+ if (useIOS) {
196
+ const currentHash = packageJsonHash(projectPath);
197
+ if (currentHash && currentHash !== lastPkgHash) {
198
+ lastPkgHash = currentHash;
199
+ console.log(chalk.cyan(" 📦 package.json changed — checking if dev build rebuild is needed..."));
200
+ if (expoProcess) {
201
+ try { expoProcess.kill(); } catch {}
202
+ expoProcess = null;
203
+ expoReady = false;
204
+ }
205
+ const buildOk = await ensureDevBuild(projectPath);
206
+ if (buildOk) {
207
+ // Restart Metro after rebuild
208
+ const recheck = await runExpoHealthCheck(projectPath, expoPort);
209
+ expoReady = recheck.ready;
210
+ expoProcess = recheck.process;
211
+ } else {
212
+ console.log(chalk.yellow(" ✗ Rebuild failed — continuing without live preview"));
213
+ }
214
+ }
215
+ }
216
+
156
217
  // ── Re-check Expo health if it's supposed to be running but isn't ────────
157
218
  if (expoReady) {
158
219
  const portOpen = await isPortOpen(expoPort);
@@ -182,6 +243,7 @@ export async function runOrchestrator(config) {
182
243
  { ...task, title: `FIX: ${task.title}`, description: qaResult.fixInstructions },
183
244
  projectPath,
184
245
  techStack,
246
+ allTasks,
185
247
  qaResult.fixInstructions
186
248
  );
187
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' },
@@ -59,7 +59,11 @@ function broadcastExpoStatus() {
59
59
  // ── SUPABASE REALTIME ─────────────────────────────────────────────────────────
60
60
  supabase
61
61
  .channel("cb_changes")
62
- .on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (p) => broadcast("task_update", p))
62
+ .on("postgres_changes", { event: "*", schema: "public", table: "cb_tasks" }, (p) => {
63
+ if (p.eventType === "UPDATE" && p.new) broadcast("task_update", p.new);
64
+ else if (p.eventType === "INSERT" && p.new) broadcast("task_added", p.new);
65
+ else if (p.eventType === "DELETE") broadcast("task_deleted", { id: p.old?.id });
66
+ })
63
67
  .on("postgres_changes", { event: "*", schema: "public", table: "cb_logs" }, (p) => broadcast("log", p.new))
64
68
  .subscribe();
65
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeboard",
3
- "version": "2.13.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": {