claudeboard 2.13.0 → 2.14.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.
@@ -70,6 +70,7 @@ RULES:
70
70
  - After writing files run: npx tsc --noEmit — fix any errors you find
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
 
@@ -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) {
@@ -112,6 +112,9 @@ export async function runOrchestrator(config) {
112
112
  let consecutiveFailures = 0;
113
113
  const MAX_CONSECUTIVE_FAILURES = 3;
114
114
 
115
+ // Track package.json hash to detect native dependency changes that need a rebuild
116
+ let lastPkgHash = packageJsonHash(projectPath);
117
+
115
118
  while (true) {
116
119
  const task = await getNextTask();
117
120
 
@@ -153,6 +156,29 @@ export async function runOrchestrator(config) {
153
156
  expoProcess = recheck.process;
154
157
  }
155
158
 
159
+ // ── Detect native dependency changes → rebuild dev build if needed ────────
160
+ if (useIOS) {
161
+ const currentHash = packageJsonHash(projectPath);
162
+ if (currentHash && currentHash !== lastPkgHash) {
163
+ lastPkgHash = currentHash;
164
+ console.log(chalk.cyan(" 📦 package.json changed — checking if dev build rebuild is needed..."));
165
+ if (expoProcess) {
166
+ try { expoProcess.kill(); } catch {}
167
+ expoProcess = null;
168
+ expoReady = false;
169
+ }
170
+ const buildOk = await ensureDevBuild(projectPath);
171
+ if (buildOk) {
172
+ // Restart Metro after rebuild
173
+ const recheck = await runExpoHealthCheck(projectPath, expoPort);
174
+ expoReady = recheck.ready;
175
+ expoProcess = recheck.process;
176
+ } else {
177
+ console.log(chalk.yellow(" ✗ Rebuild failed — continuing without live preview"));
178
+ }
179
+ }
180
+ }
181
+
156
182
  // ── Re-check Expo health if it's supposed to be running but isn't ────────
157
183
  if (expoReady) {
158
184
  const portOpen = await isPortOpen(expoPort);
@@ -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.14.0",
4
4
  "description": "AI engineering team — from PRD to working mobile app, autonomously",
5
5
  "type": "module",
6
6
  "bin": {