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.
- package/agents/developer.js +4 -1
- package/agents/expo-health.js +48 -3
- package/agents/orchestrator.js +4 -0
- package/agents/qa.js +23 -7
- package/bin/cli.js +2 -0
- package/dashboard/index.html +71 -7
- package/dashboard/server.js +21 -2
- package/package.json +1 -1
package/agents/developer.js
CHANGED
|
@@ -44,7 +44,10 @@ function buildEnv() {
|
|
|
44
44
|
].filter(Boolean);
|
|
45
45
|
|
|
46
46
|
const fullPath = [...new Set(pathParts.join(":").split(":"))].join(":");
|
|
47
|
-
|
|
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();
|
package/agents/expo-health.js
CHANGED
|
@@ -150,9 +150,15 @@ async function tryStartExpo(projectPath, port) {
|
|
|
150
150
|
|
|
151
151
|
try {
|
|
152
152
|
const { spawn } = require("child_process");
|
|
153
|
-
|
|
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
|
+
}
|
package/agents/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
package/dashboard/index.html
CHANGED
|
@@ -1219,7 +1219,9 @@ function renderKanban() {
|
|
|
1219
1219
|
continue;
|
|
1220
1220
|
}
|
|
1221
1221
|
|
|
1222
|
-
|
|
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
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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);
|
package/dashboard/server.js
CHANGED
|
@@ -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(*)")
|
|
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:
|
|
313
|
+
res.json({ epics: sortedEpics, logs: logs || [], project: PROJECT });
|
|
295
314
|
});
|
|
296
315
|
|
|
297
316
|
app.get("/api/tasks/next", async (req, res) => {
|