claudeboard 2.10.1 → 2.12.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.
@@ -150,7 +150,13 @@ 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
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",
@@ -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";
@@ -76,16 +77,28 @@ export async function runQAAgent(task, devResult, projectPath, prdContent, expoP
76
77
  }
77
78
  }
78
79
 
79
- // ── 3. Take screenshot if Expo is running ─────────────────────────────────
80
+ // ── 3. Take screenshot iOS simulator first, fallback to web ────────────
80
81
  let screenshotBase64 = null;
81
82
  let screenshotPath = null;
82
83
  if (expoRunning) {
83
84
  await addLog(task.id, "Taking screenshot...", "progress");
84
85
  const screenshotDir = path.join(projectPath, ".claudeboard-screenshots");
85
- const shot = await screenshotExpoWeb(expoPort, screenshotDir);
86
- if (shot.success && shot.base64) {
87
- screenshotBase64 = shot.base64;
88
- 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
+ }
89
102
  }
90
103
  }
91
104
 
@@ -106,7 +119,7 @@ export async function runQAAgent(task, devResult, projectPath, prdContent, expoP
106
119
  // ── Claude Code QA review ─────────────────────────────────────────────────────
107
120
  async function runClaudeCodeQA(task, projectPath, prdContent, expoRunning, screenshotBase64, screenshotPath) {
108
121
  const screenshotNote = screenshotBase64
109
- ? `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.`
110
123
  : expoRunning
111
124
  ? "Expo is running but screenshot failed — evaluate code only."
112
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
 
@@ -847,6 +847,36 @@
847
847
  border-color: rgba(248,113,113,0.5);
848
848
  }
849
849
 
850
+ /* ── CARD ACTION BAR (edit/delete/retry) ── */
851
+ .card-actions {
852
+ display: flex;
853
+ gap: 4px;
854
+ margin-top: 8px;
855
+ opacity: 0;
856
+ transition: opacity 0.15s;
857
+ }
858
+ .card:hover .card-actions { opacity: 1; }
859
+ .card-action-btn {
860
+ flex: 1;
861
+ padding: 4px 0;
862
+ border-radius: 4px;
863
+ border: 1px solid var(--border);
864
+ background: rgba(255,255,255,0.03);
865
+ color: var(--muted);
866
+ font-family: var(--mono);
867
+ font-size: 10px;
868
+ cursor: pointer;
869
+ transition: all 0.15s;
870
+ text-align: center;
871
+ }
872
+ .card-action-btn:hover { background: rgba(255,255,255,0.08); color: var(--text); }
873
+ .card-action-btn.retry { color: var(--yellow); border-color: rgba(251,191,36,0.3); }
874
+ .card-action-btn.retry:hover { background: rgba(251,191,36,0.1); }
875
+ .card-action-btn.del { color: var(--red); border-color: rgba(248,113,113,0.2); }
876
+ .card-action-btn.del:hover { background: rgba(248,113,113,0.12); }
877
+ .card-action-btn.edit { color: var(--accent); border-color: rgba(108,138,255,0.2); }
878
+ .card-action-btn.edit:hover { background: rgba(108,138,255,0.1); }
879
+
850
880
  /* Empty column state */
851
881
  .col-empty {
852
882
  text-align: center;
@@ -1011,6 +1041,46 @@
1011
1041
  </div>
1012
1042
  </div>
1013
1043
 
1044
+ <!-- EDIT TASK MODAL -->
1045
+ <div class="overlay" id="editModal" onclick="if(event.target===this)closeEditTask()">
1046
+ <div class="modal">
1047
+ <div class="modal-title" data-i18n="editTaskTitle">Edit Task</div>
1048
+ <input type="hidden" id="e-id">
1049
+ <div class="field">
1050
+ <label data-i18n="fieldTitle">Title</label>
1051
+ <input type="text" id="e-title">
1052
+ </div>
1053
+ <div class="field">
1054
+ <label data-i18n="fieldDesc">Description</label>
1055
+ <textarea id="e-desc" rows="4"></textarea>
1056
+ </div>
1057
+ <div class="modal-grid">
1058
+ <div class="field">
1059
+ <label data-i18n="fieldPriority">Priority</label>
1060
+ <select id="e-priority">
1061
+ <option value="high" data-i18n="prioHigh">High</option>
1062
+ <option value="medium" data-i18n="prioMed">Medium</option>
1063
+ <option value="low" data-i18n="prioLow">Low</option>
1064
+ </select>
1065
+ </div>
1066
+ <div class="field">
1067
+ <label data-i18n="fieldType">Type</label>
1068
+ <select id="e-type">
1069
+ <option value="feature" data-i18n="typeFeature">Feature</option>
1070
+ <option value="bug" data-i18n="typeBug">Bug</option>
1071
+ <option value="config" data-i18n="typeConfig">Config</option>
1072
+ <option value="refactor" data-i18n="typeRefactor">Refactor</option>
1073
+ <option value="test" data-i18n="typeTest">Test</option>
1074
+ </select>
1075
+ </div>
1076
+ </div>
1077
+ <div class="modal-actions">
1078
+ <button class="btn-cancel" onclick="closeEditTask()" data-i18n="cancel">Cancel</button>
1079
+ <button class="btn-create" onclick="submitEditTask()" data-i18n="saveTask">Save Changes</button>
1080
+ </div>
1081
+ </div>
1082
+ </div>
1083
+
1014
1084
  <!-- RETRY / EDIT MODAL -->
1015
1085
  <div class="overlay" id="retryModal" onclick="if(event.target===this)closeRetry()">
1016
1086
  <div class="modal">
@@ -1131,7 +1201,7 @@ function connectWS() {
1131
1201
  ws.onclose = () => { setWS(false); setTimeout(connectWS, 2000); };
1132
1202
  ws.onmessage = (e) => {
1133
1203
  const { event, data } = JSON.parse(e.data);
1134
- if (['task_update','task_added','task_started','task_complete','task_failed'].includes(event)) {
1204
+ if (['task_update','task_added','task_started','task_complete','task_failed','task_deleted','task_reordered'].includes(event)) {
1135
1205
  loadBoard();
1136
1206
  }
1137
1207
  if (event === 'log') {
@@ -1219,7 +1289,9 @@ function renderKanban() {
1219
1289
  continue;
1220
1290
  }
1221
1291
 
1222
- body.innerHTML = list.map(t => cardHTML(t)).join('');
1292
+ // Sort by priority_order before rendering
1293
+ const sorted = [...list].sort((a, b) => (a.priority_order ?? 99) - (b.priority_order ?? 99));
1294
+ body.innerHTML = sorted.map((t, i) => cardHTML(t, status === 'todo' ? i + 1 : null)).join('');
1223
1295
 
1224
1296
  // Attach drag events
1225
1297
  body.querySelectorAll('.card').forEach(card => {
@@ -1230,15 +1302,17 @@ function renderKanban() {
1230
1302
  }
1231
1303
  }
1232
1304
 
1233
- function cardHTML(task) {
1305
+ function cardHTML(task, position = null) {
1234
1306
  const icons = { todo: '', in_progress: '', done: '✓', error: '✕', blocked: '—' };
1235
1307
  const shortEpic = (task.epicName || '').split(' ').slice(0,2).join(' ');
1236
1308
  const isError = task.status === 'error';
1309
+ 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
1310
  return `
1238
1311
  <div class="card fade-in" draggable="true" data-id="${task.id}" data-status="${task.status}">
1239
1312
  <div class="card-top">
1240
1313
  <div class="card-status ${task.status}">${icons[task.status] || ''}</div>
1241
1314
  <div class="card-title">${esc(task.title)}</div>
1315
+ ${posNum}
1242
1316
  </div>
1243
1317
  ${task.description ? `<div class="card-desc">${esc(task.description.split('\n')[0])}</div>` : ''}
1244
1318
  <div class="card-footer">
@@ -1246,7 +1320,11 @@ function cardHTML(task) {
1246
1320
  <span class="tag ${task.type}">${task.type}</span>
1247
1321
  ${shortEpic ? `<span class="card-epic">${esc(shortEpic)}</span>` : ''}
1248
1322
  </div>
1249
- ${isError ? `<button class="card-retry-btn" onclick="event.stopPropagation();openRetry('${task.id}')">${t('retryCard')} Edit</button>` : ''}
1323
+ <div class="card-actions" onclick="event.stopPropagation()">
1324
+ ${isError ? `<button class="card-action-btn retry" onclick="openRetry('${task.id}')">${t('retryCard')}</button>` : ''}
1325
+ <button class="card-action-btn edit" onclick="openEditTask('${task.id}')" title="Edit">✎</button>
1326
+ <button class="card-action-btn del" onclick="deleteTask('${task.id}')" title="Delete">✕</button>
1327
+ </div>
1250
1328
  </div>`;
1251
1329
  }
1252
1330
 
@@ -1269,11 +1347,31 @@ function onDragOver(e, status) {
1269
1347
  e.preventDefault();
1270
1348
  e.dataTransfer.dropEffect = 'move';
1271
1349
  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);
1350
+
1351
+ // Find the card being hovered over to insert placeholder before it
1352
+ const draggingCard = document.querySelector('.card.dragging');
1353
+ const cards = [...col.querySelectorAll('.card:not(.dragging)')];
1354
+
1355
+ // Remove existing placeholder
1356
+ col.querySelectorAll('.drop-placeholder').forEach(p => p.remove());
1357
+
1358
+ // Find insertion point based on mouse Y position
1359
+ let insertBefore = null;
1360
+ for (const card of cards) {
1361
+ const rect = card.getBoundingClientRect();
1362
+ if (e.clientY < rect.top + rect.height / 2) {
1363
+ insertBefore = card;
1364
+ break;
1365
+ }
1276
1366
  }
1367
+
1368
+ const ph = document.createElement('div');
1369
+ ph.className = 'drop-placeholder';
1370
+ if (insertBefore) col.insertBefore(ph, insertBefore);
1371
+ else col.appendChild(ph);
1372
+
1373
+ // Store insertion target for onDrop
1374
+ col.dataset.insertBefore = insertBefore?.dataset?.id || '';
1277
1375
  }
1278
1376
 
1279
1377
  function onDragLeave(e) {
@@ -1289,10 +1387,43 @@ async function onDrop(e, status) {
1289
1387
  document.querySelectorAll('.drop-placeholder').forEach(p => p.remove());
1290
1388
  if (!draggedId) return;
1291
1389
 
1292
- await fetch(`/api/tasks/${draggedId}`, {
1293
- method: 'PATCH',
1390
+ const col = e.currentTarget;
1391
+ const insertBeforeId = col.dataset.insertBefore || '';
1392
+ delete col.dataset.insertBefore;
1393
+
1394
+ // Build the new ordered list of ids for this column
1395
+ // Start from the DOM — what's visually shown — plus insert dragged card
1396
+ const otherIds = [...col.querySelectorAll('.card')]
1397
+ .map(c => c.dataset.id)
1398
+ .filter(id => id && id !== draggedId);
1399
+
1400
+ let newOrderedIds;
1401
+ if (!insertBeforeId) {
1402
+ newOrderedIds = [...otherIds, draggedId]; // dropped at end
1403
+ } else {
1404
+ const insertIdx = otherIds.indexOf(insertBeforeId);
1405
+ if (insertIdx === -1) {
1406
+ newOrderedIds = [draggedId, ...otherIds];
1407
+ } else {
1408
+ newOrderedIds = [...otherIds.slice(0, insertIdx), draggedId, ...otherIds.slice(insertIdx)];
1409
+ }
1410
+ }
1411
+
1412
+ // Update status first if moving to different column
1413
+ const draggedTask = allTasks().find(t => t.id === draggedId);
1414
+ if (draggedTask?.status !== status) {
1415
+ await fetch(`/api/tasks/${draggedId}`, {
1416
+ method: 'PATCH',
1417
+ headers: { 'Content-Type': 'application/json' },
1418
+ body: JSON.stringify({ status }),
1419
+ });
1420
+ }
1421
+
1422
+ // Persist the new order in one call
1423
+ await fetch('/api/tasks/reorder', {
1424
+ method: 'POST',
1294
1425
  headers: { 'Content-Type': 'application/json' },
1295
- body: JSON.stringify({ status }),
1426
+ body: JSON.stringify({ taskIds: newOrderedIds }),
1296
1427
  });
1297
1428
 
1298
1429
  draggedId = null;
@@ -1619,6 +1750,48 @@ function initTerminal() {
1619
1750
  });
1620
1751
  }
1621
1752
 
1753
+ // ── EDIT TASK ─────────────────────────────────────────────────────────────────
1754
+ function openEditTask(id) {
1755
+ const task = allTasks().find(t => t.id === id);
1756
+ if (!task) return;
1757
+ document.getElementById('e-id').value = id;
1758
+ document.getElementById('e-title').value = task.title || '';
1759
+ document.getElementById('e-desc').value = task.description || '';
1760
+ document.getElementById('e-priority').value = task.priority || 'medium';
1761
+ document.getElementById('e-type').value = task.type || 'feature';
1762
+ document.getElementById('editModal').style.display = 'flex';
1763
+ applyTranslations();
1764
+ }
1765
+
1766
+ function closeEditTask() {
1767
+ document.getElementById('editModal').style.display = 'none';
1768
+ }
1769
+
1770
+ async function submitEditTask() {
1771
+ const id = document.getElementById('e-id').value;
1772
+ const title = document.getElementById('e-title').value.trim();
1773
+ const description = document.getElementById('e-desc').value.trim();
1774
+ const priority = document.getElementById('e-priority').value;
1775
+ const type = document.getElementById('e-type').value;
1776
+ if (!title) return;
1777
+
1778
+ const priorityOrder = { high: 1, medium: 2, low: 3 };
1779
+ await fetch(`/api/tasks/${id}`, {
1780
+ method: 'PATCH',
1781
+ headers: { 'Content-Type': 'application/json' },
1782
+ body: JSON.stringify({ title, description, priority, type, priority_order: priorityOrder[priority] }),
1783
+ });
1784
+ closeEditTask();
1785
+ loadBoard();
1786
+ }
1787
+
1788
+ // ── DELETE TASK ───────────────────────────────────────────────────────────────
1789
+ async function deleteTask(id) {
1790
+ if (!confirm(currentLang === 'es' ? '¿Eliminar esta tarea?' : 'Delete this task?')) return;
1791
+ await fetch(`/api/tasks/${id}`, { method: 'DELETE' });
1792
+ loadBoard();
1793
+ }
1794
+
1622
1795
  // ── UTILS ─────────────────────────────────────────────────────────────────────
1623
1796
  function esc(s) {
1624
1797
  if (!s) return '';
@@ -1626,7 +1799,7 @@ function esc(s) {
1626
1799
  }
1627
1800
 
1628
1801
  document.addEventListener('keydown', e => {
1629
- if (e.key === 'Escape') { closeModal(); closeRetry(); }
1802
+ if (e.key === 'Escape') { closeModal(); closeRetry(); closeEditTask(); }
1630
1803
  if ((e.metaKey || e.ctrlKey) && e.key === 'n') { e.preventDefault(); openModal(); }
1631
1804
  });
1632
1805
 
@@ -1660,6 +1833,7 @@ const STRINGS = {
1660
1833
  expoIdle:'Expo not started. Click "Start Expo" to install dependencies and launch with tunnel.',
1661
1834
  scanWith:'SCAN WITH EXPO GO',
1662
1835
  retryCard:'↩ Retry',
1836
+ editTaskTitle:'Edit Task', saveTask:'Save Changes', deleteConfirm:'Delete this task?',
1663
1837
  },
1664
1838
  es: {
1665
1839
  todo:'pendiente', running:'en curso', done:'listo', failed:'fallido',
@@ -1689,6 +1863,7 @@ const STRINGS = {
1689
1863
  expoIdle:'Expo no iniciado. Hacé clic en "Iniciar Expo" para instalar dependencias y lanzar con tunnel.',
1690
1864
  scanWith:'ESCANEAR CON EXPO GO',
1691
1865
  retryCard:'↩ Reintentar',
1866
+ editTaskTitle:'Editar tarea', saveTask:'Guardar cambios', deleteConfirm:'¿Eliminar esta tarea?',
1692
1867
  }
1693
1868
  };
1694
1869
 
@@ -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) => {
@@ -350,6 +369,26 @@ app.patch("/api/tasks/:id", async (req, res) => {
350
369
  res.json({ ok: true });
351
370
  });
352
371
 
372
+ // DELETE a task
373
+ app.delete("/api/tasks/:id", async (req, res) => {
374
+ await supabase.from("cb_logs").delete().eq("task_id", req.params.id);
375
+ await supabase.from("cb_tasks").delete().eq("id", req.params.id);
376
+ broadcast("task_deleted", { id: req.params.id });
377
+ res.json({ ok: true });
378
+ });
379
+
380
+ // POST reorder — receives ordered array of task ids for a column, renumbers them
381
+ app.post("/api/tasks/reorder", async (req, res) => {
382
+ const { taskIds } = req.body; // array of ids in new order
383
+ for (let i = 0; i < taskIds.length; i++) {
384
+ await supabase.from("cb_tasks")
385
+ .update({ priority_order: i + 1 })
386
+ .eq("id", taskIds[i]);
387
+ }
388
+ broadcast("task_reordered", { taskIds });
389
+ res.json({ ok: true });
390
+ });
391
+
353
392
  app.get("/api/tasks/:id/logs", async (req, res) => {
354
393
  const { data } = await supabase.from("cb_logs").select("*")
355
394
  .eq("task_id", req.params.id).order("created_at");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeboard",
3
- "version": "2.10.1",
3
+ "version": "2.12.0",
4
4
  "description": "AI engineering team — from PRD to working mobile app, autonomously",
5
5
  "type": "module",
6
6
  "bin": {