claudeboard 2.10.0 → 2.11.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.
@@ -44,7 +44,10 @@ function buildEnv() {
44
44
  ].filter(Boolean);
45
45
 
46
46
  const fullPath = [...new Set(pathParts.join(":").split(":"))].join(":");
47
- return { ...process.env, PATH: fullPath, HOME: process.env.HOME };
47
+ const env = { ...process.env, PATH: fullPath, HOME: process.env.HOME };
48
+ // Remove API key so Claude Code uses the Claude subscription (not API credits)
49
+ delete env.ANTHROPIC_API_KEY;
50
+ return env;
48
51
  }
49
52
 
50
53
  const CLAUDE_PATH = resolveClaudePath();
@@ -150,9 +150,15 @@ async function tryStartExpo(projectPath, port) {
150
150
 
151
151
  try {
152
152
  const { spawn } = require("child_process");
153
- proc = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
153
+ // Use iOS simulator if available, fallback to web
154
+ const useIOS = process.env.CLAUDEBOARD_IOS === "1" || await isSimulatorAvailable();
155
+ const expoArgs = useIOS
156
+ ? ["expo", "start", "--ios", "--port", String(port)]
157
+ : ["expo", "start", "--web", "--port", String(port)];
158
+
159
+ proc = spawn("npx", expoArgs, {
154
160
  cwd: projectPath,
155
- env: { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1", EXPO_NO_DOTENV: "0" },
161
+ env: (() => { const e = { ...process.env, CI: "1", EXPO_NO_INTERACTIVE: "1", EXPO_NO_DOTENV: "0" }; delete e.ANTHROPIC_API_KEY; return e; })(),
156
162
  stdio: "pipe",
157
163
  });
158
164
 
@@ -178,7 +184,10 @@ async function tryStartExpo(projectPath, port) {
178
184
  text.includes("Metro waiting on") ||
179
185
  text.includes(`http://localhost:${port}`) ||
180
186
  text.includes("Bundling complete") ||
181
- text.includes("Web is waiting on")
187
+ text.includes("Web is waiting on") ||
188
+ text.includes("Opening on iOS") ||
189
+ text.includes("Installed on iOS") ||
190
+ text.includes("Building JavaScript bundle")
182
191
  ) {
183
192
  setTimeout(() => {
184
193
  // If we haven't already failed, declare success
@@ -309,3 +318,39 @@ function hasFatalNpmError(output) {
309
318
  function getFatalError(output) {
310
319
  return output.split("\n").find(l => l.includes("npm error") && !l.includes("peer")) || output.slice(-200);
311
320
  }
321
+
322
+ // ── iOS Simulator detection ───────────────────────────────────────────────────
323
+ async function isSimulatorAvailable() {
324
+ try {
325
+ const { execSync } = await import("child_process");
326
+ const output = execSync("xcrun simctl list devices booted 2>&1", { encoding: "utf8" });
327
+ // Check if any device is already booted
328
+ if (output.includes("Booted")) return true;
329
+ // Check if any iOS runtime is available
330
+ const runtimes = execSync("xcrun simctl list runtimes 2>&1", { encoding: "utf8" });
331
+ return runtimes.includes("iOS") && !runtimes.includes("unavailable");
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+
337
+ // Take a screenshot of the iOS simulator
338
+ export async function screenshotSimulator(outputPath) {
339
+ try {
340
+ const { execSync } = await import("child_process");
341
+ execSync(`xcrun simctl io booted screenshot "${outputPath}"`, { stdio: "pipe" });
342
+ const data = fs.readFileSync(outputPath);
343
+ return { success: true, base64: data.toString("base64"), path: outputPath };
344
+ } catch (e) {
345
+ return { success: false, error: e.message };
346
+ }
347
+ }
348
+
349
+ // Tap on the iOS simulator at given coordinates
350
+ export async function tapSimulator(x, y) {
351
+ try {
352
+ const { execSync } = await import("child_process");
353
+ execSync(`xcrun simctl io booted tap ${x} ${y}`, { stdio: "pipe" });
354
+ return true;
355
+ } catch { return false; }
356
+ }
@@ -47,8 +47,12 @@ export async function runOrchestrator(config) {
47
47
  projectName,
48
48
  expoPort = 8081,
49
49
  skipArchitect = false,
50
+ useIOS = false,
50
51
  } = config;
51
52
 
53
+ // Set env var so expo-health and QA agents know to use iOS simulator
54
+ if (useIOS) process.env.CLAUDEBOARD_IOS = "1";
55
+
52
56
  console.log(chalk.cyan("\n╔═══════════════════════════════════════╗"));
53
57
  console.log(chalk.cyan("║") + chalk.bold(" 🤖 CLAUDEBOARD ORCHESTRATOR STARTING ") + chalk.cyan("║"));
54
58
  console.log(chalk.cyan("╚═══════════════════════════════════════╝\n"));
package/agents/qa.js CHANGED
@@ -2,6 +2,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  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
+ import { screenshotSimulator } from "./expo-health.js";
5
6
  import { listFiles, readFile } from "../tools/filesystem.js";
6
7
  import { execSync } from "child_process";
7
8
  import path from "path";
@@ -35,7 +36,10 @@ function buildEnv() {
35
36
  `${process.env.HOME}/.nvm/versions/node/current/bin`,
36
37
  ].filter(Boolean);
37
38
  const fullPath = [...new Set(pathParts.join(":").split(":"))].join(":");
38
- return { ...process.env, PATH: fullPath, HOME: process.env.HOME };
39
+ const env = { ...process.env, PATH: fullPath, HOME: process.env.HOME };
40
+ // Remove API key so Claude Code uses the Claude subscription (not API credits)
41
+ delete env.ANTHROPIC_API_KEY;
42
+ return env;
39
43
  }
40
44
 
41
45
  const CLAUDE_PATH = resolveClaudePath();
@@ -73,16 +77,28 @@ export async function runQAAgent(task, devResult, projectPath, prdContent, expoP
73
77
  }
74
78
  }
75
79
 
76
- // ── 3. Take screenshot if Expo is running ─────────────────────────────────
80
+ // ── 3. Take screenshot iOS simulator first, fallback to web ────────────
77
81
  let screenshotBase64 = null;
78
82
  let screenshotPath = null;
79
83
  if (expoRunning) {
80
84
  await addLog(task.id, "Taking screenshot...", "progress");
81
85
  const screenshotDir = path.join(projectPath, ".claudeboard-screenshots");
82
- const shot = await screenshotExpoWeb(expoPort, screenshotDir);
83
- if (shot.success && shot.base64) {
84
- screenshotBase64 = shot.base64;
85
- screenshotPath = shot.path;
86
+ if (!fs.existsSync(screenshotDir)) fs.mkdirSync(screenshotDir, { recursive: true });
87
+
88
+ // Try iOS simulator first (more accurate for native apps)
89
+ const simPath = path.join(screenshotDir, `sim_${task.id}_${Date.now()}.png`);
90
+ const simShot = await screenshotSimulator(simPath);
91
+ if (simShot.success) {
92
+ screenshotBase64 = simShot.base64;
93
+ screenshotPath = simShot.path;
94
+ await addLog(task.id, "📱 iOS simulator screenshot taken", "progress");
95
+ } else {
96
+ // Fallback to Expo Web screenshot
97
+ const shot = await screenshotExpoWeb(expoPort, screenshotDir);
98
+ if (shot.success && shot.base64) {
99
+ screenshotBase64 = shot.base64;
100
+ screenshotPath = shot.path;
101
+ }
86
102
  }
87
103
  }
88
104
 
@@ -103,7 +119,7 @@ export async function runQAAgent(task, devResult, projectPath, prdContent, expoP
103
119
  // ── Claude Code QA review ─────────────────────────────────────────────────────
104
120
  async function runClaudeCodeQA(task, projectPath, prdContent, expoRunning, screenshotBase64, screenshotPath) {
105
121
  const screenshotNote = screenshotBase64
106
- ? `A screenshot of the app has been saved to: ${screenshotPath}\nRead it with the Read tool and evaluate the visual result.`
122
+ ? `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.`
107
123
  : expoRunning
108
124
  ? "Expo is running but screenshot failed — evaluate code only."
109
125
  : "Expo is not running — evaluate code quality only (TypeScript, completeness, correctness).";
package/bin/cli.js CHANGED
@@ -137,6 +137,7 @@ program
137
137
  .requiredOption("--project <path>", "Path to your app project directory")
138
138
  .option("--restart", "Force restart from scratch (ignore existing tasks in board)")
139
139
  .option("--expo-port <port>", "Expo Web port for QA screenshots", "8081")
140
+ .option("--ios", "Use iOS Simulator instead of Expo Web for QA (requires Xcode)")
140
141
  .action(async (opts) => {
141
142
  console.log(LOGO);
142
143
  const config = loadConfig();
@@ -190,6 +191,7 @@ program
190
191
  projectName: config.projectName,
191
192
  expoPort: parseInt(opts.expoPort),
192
193
  forceRestart: !!opts.restart,
194
+ useIOS: !!opts.ios,
193
195
  });
194
196
  });
195
197
 
@@ -1219,7 +1219,9 @@ function renderKanban() {
1219
1219
  continue;
1220
1220
  }
1221
1221
 
1222
- body.innerHTML = list.map(t => cardHTML(t)).join('');
1222
+ // Sort by priority_order before rendering
1223
+ const sorted = [...list].sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99));
1224
+ body.innerHTML = sorted.map((t, i) => cardHTML(t, status === 'todo' ? i + 1 : null)).join('');
1223
1225
 
1224
1226
  // Attach drag events
1225
1227
  body.querySelectorAll('.card').forEach(card => {
@@ -1230,15 +1232,17 @@ function renderKanban() {
1230
1232
  }
1231
1233
  }
1232
1234
 
1233
- function cardHTML(task) {
1235
+ function cardHTML(task, position = null) {
1234
1236
  const icons = { todo: '', in_progress: '', done: '✓', error: '✕', blocked: '—' };
1235
1237
  const shortEpic = (task.epicName || '').split(' ').slice(0,2).join(' ');
1236
1238
  const isError = task.status === 'error';
1239
+ const posNum = position ? `<span style="font-family:var(--mono);font-size:9px;color:var(--dim);background:rgba(255,255,255,0.05);border-radius:3px;padding:1px 5px;flex-shrink:0">#${position}</span>` : '';
1237
1240
  return `
1238
1241
  <div class="card fade-in" draggable="true" data-id="${task.id}" data-status="${task.status}">
1239
1242
  <div class="card-top">
1240
1243
  <div class="card-status ${task.status}">${icons[task.status] || ''}</div>
1241
1244
  <div class="card-title">${esc(task.title)}</div>
1245
+ ${posNum}
1242
1246
  </div>
1243
1247
  ${task.description ? `<div class="card-desc">${esc(task.description.split('\n')[0])}</div>` : ''}
1244
1248
  <div class="card-footer">
@@ -1269,11 +1273,31 @@ function onDragOver(e, status) {
1269
1273
  e.preventDefault();
1270
1274
  e.dataTransfer.dropEffect = 'move';
1271
1275
  const col = e.currentTarget;
1272
- if (!col.querySelector('.drop-placeholder')) {
1273
- const ph = document.createElement('div');
1274
- ph.className = 'drop-placeholder';
1275
- col.appendChild(ph);
1276
+
1277
+ // Find the card being hovered over to insert placeholder before it
1278
+ const draggingCard = document.querySelector('.card.dragging');
1279
+ const cards = [...col.querySelectorAll('.card:not(.dragging)')];
1280
+
1281
+ // Remove existing placeholder
1282
+ col.querySelectorAll('.drop-placeholder').forEach(p => p.remove());
1283
+
1284
+ // Find insertion point based on mouse Y position
1285
+ let insertBefore = null;
1286
+ for (const card of cards) {
1287
+ const rect = card.getBoundingClientRect();
1288
+ if (e.clientY < rect.top + rect.height / 2) {
1289
+ insertBefore = card;
1290
+ break;
1291
+ }
1276
1292
  }
1293
+
1294
+ const ph = document.createElement('div');
1295
+ ph.className = 'drop-placeholder';
1296
+ if (insertBefore) col.insertBefore(ph, insertBefore);
1297
+ else col.appendChild(ph);
1298
+
1299
+ // Store insertion target for onDrop
1300
+ col.dataset.insertBefore = insertBefore?.dataset?.id || '';
1277
1301
  }
1278
1302
 
1279
1303
  function onDragLeave(e) {
@@ -1289,16 +1313,56 @@ async function onDrop(e, status) {
1289
1313
  document.querySelectorAll('.drop-placeholder').forEach(p => p.remove());
1290
1314
  if (!draggedId) return;
1291
1315
 
1316
+ const col = e.currentTarget;
1317
+ const insertBeforeId = col.dataset.insertBefore || '';
1318
+ delete col.dataset.insertBefore;
1319
+
1320
+ // Calculate new priority_order based on drop position
1321
+ const colKey = { todo: 'todo', in_progress: 'prog', done: 'done', error: 'err' }[status] || status;
1322
+ const tasks = allTasks().filter(t => t.status === status && t.id !== draggedId);
1323
+
1324
+ let newOrder;
1325
+ if (!insertBeforeId) {
1326
+ // Dropped at end
1327
+ const maxOrder = tasks.length ? Math.max(...tasks.map(t => t.priority_order || 0)) : 0;
1328
+ newOrder = maxOrder + 1;
1329
+ } else {
1330
+ const targetTask = tasks.find(t => t.id === insertBeforeId);
1331
+ const targetOrder = targetTask?.priority_order ?? 1;
1332
+ // Shift tasks after insertion point
1333
+ newOrder = targetOrder - 0.5;
1334
+ }
1335
+
1292
1336
  await fetch(`/api/tasks/${draggedId}`, {
1293
1337
  method: 'PATCH',
1294
1338
  headers: { 'Content-Type': 'application/json' },
1295
- body: JSON.stringify({ status }),
1339
+ body: JSON.stringify({ status, priority_order: newOrder }),
1296
1340
  });
1297
1341
 
1342
+ // Re-normalize priority_order for all tasks in this column after drop
1343
+ await normalizeColumnOrder(status);
1344
+
1298
1345
  draggedId = null;
1299
1346
  loadBoard();
1300
1347
  }
1301
1348
 
1349
+ async function normalizeColumnOrder(status) {
1350
+ // Get all tasks in column sorted by current priority_order, renumber 1..N
1351
+ const tasks = allTasks()
1352
+ .filter(t => t.status === status)
1353
+ .sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99));
1354
+
1355
+ for (let i = 0; i < tasks.length; i++) {
1356
+ if (tasks[i].priority_order !== i + 1) {
1357
+ await fetch(`/api/tasks/${tasks[i].id}`, {
1358
+ method: 'PATCH',
1359
+ headers: { 'Content-Type': 'application/json' },
1360
+ body: JSON.stringify({ priority_order: i + 1 }),
1361
+ });
1362
+ }
1363
+ }
1364
+ }
1365
+
1302
1366
  // ── CARD SELECT ───────────────────────────────────────────────────────────────
1303
1367
  async function selectCard(id) {
1304
1368
  const task = allTasks().find(t => t.id === id);
@@ -287,11 +287,30 @@ app.post("/api/supabase/query", async (req, res) => {
287
287
 
288
288
  app.get("/api/board", async (req, res) => {
289
289
  const { data: epics } = await supabase
290
- .from("cb_epics").select("*, cb_tasks(*)").eq("project", PROJECT).order("created_at");
290
+ .from("cb_epics").select("*, cb_tasks(*)")
291
+ .eq("project", PROJECT).order("created_at");
292
+
293
+ // Orphan tasks (manually created, no epic_id)
294
+ const { data: orphanTasks } = await supabase
295
+ .from("cb_tasks").select("*")
296
+ .eq("project", PROJECT).is("epic_id", null)
297
+ .order("priority_order", { ascending: true });
298
+
299
+ // Sort tasks within each epic by priority_order
300
+ const sortedEpics = (epics || []).map(epic => ({
301
+ ...epic,
302
+ cb_tasks: (epic.cb_tasks || []).sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99)),
303
+ }));
304
+
305
+ // Orphan tasks appear as a virtual "Manual Tasks" epic
306
+ if (orphanTasks?.length) {
307
+ sortedEpics.push({ id: "manual", name: "— Manual Tasks", cb_tasks: orphanTasks });
308
+ }
309
+
291
310
  const { data: logs } = await supabase
292
311
  .from("cb_logs").select("*").eq("project", PROJECT)
293
312
  .order("created_at", { ascending: false }).limit(50);
294
- res.json({ epics: epics || [], logs: logs || [], project: PROJECT });
313
+ res.json({ epics: sortedEpics, logs: logs || [], project: PROJECT });
295
314
  });
296
315
 
297
316
  app.get("/api/tasks/next", async (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeboard",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "AI engineering team — from PRD to working mobile app, autonomously",
5
5
  "type": "module",
6
6
  "bin": {