@yemi33/minions 0.1.292 → 0.1.294

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/CHANGELOG.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.292 (2026-04-03)
3
+ ## 0.1.294 (2026-04-03)
4
4
 
5
5
  ### Features
6
+ - fix dashboard.js race conditions, input validation, and watcher leaks
7
+ - fix cleanup.js — worktree TOCTOU, readdirSync isolation, KB restore verify
6
8
  - harden shared.js — backup verification, lock TOCTOU, docs
7
9
  - all doc-chats use Sonnet with full tools (agent change)
8
10
 
9
11
  ### Fixes
12
+ - address review feedback on PR-122
10
13
  - add behavioral tests for CRITICAL propagation and stale lock ENOENT
11
14
  - CRITICAL errors in safeJson now propagate to callers
12
15
  - defer plan archiving until verify completes, add 20 verify tests
package/dashboard.js CHANGED
@@ -1172,28 +1172,25 @@ const server = http.createServer(async (req, res) => {
1172
1172
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
1173
1173
  const planPath = resolvePlanPath(body.source);
1174
1174
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
1175
- const plan = safeJson(planPath);
1176
- const item = (plan.missing_features || []).find(f => f.id === body.itemId);
1177
- if (!item) return jsonReply(res, 404, { error: 'item not found in plan' });
1178
-
1179
- // Update allowed fields
1180
- if (body.name !== undefined) item.name = body.name;
1181
- if (body.description !== undefined) item.description = body.description;
1182
- if (body.priority !== undefined) item.priority = body.priority;
1183
- if (body.estimated_complexity !== undefined) item.estimated_complexity = body.estimated_complexity;
1184
- if (body.status !== undefined) item.status = body.status;
1185
-
1186
- // Re-read plan before writing to minimize race window with engine
1187
- const freshPlan = safeJson(planPath) || plan;
1188
- const freshItem = (freshPlan.missing_features || []).find(f => f.id === body.itemId);
1189
- if (freshItem) {
1190
- if (body.name !== undefined) freshItem.name = body.name;
1191
- if (body.description !== undefined) freshItem.description = body.description;
1192
- if (body.priority !== undefined) freshItem.priority = body.priority;
1193
- if (body.estimated_complexity !== undefined) freshItem.estimated_complexity = body.estimated_complexity;
1194
- if (body.status !== undefined) freshItem.status = body.status;
1195
- }
1196
- safeWrite(planPath, freshPlan);
1175
+ // Pre-check: verify item exists before taking the lock
1176
+ const preCheck = safeJson(planPath);
1177
+ const preItem = (preCheck.missing_features || []).find(f => f.id === body.itemId);
1178
+ if (!preItem) return jsonReply(res, 404, { error: 'item not found in plan' });
1179
+
1180
+ // Atomically read-modify-write under file lock
1181
+ let item;
1182
+ mutateJsonFileLocked(planPath, (plan) => {
1183
+ const target = (plan.missing_features || []).find(f => f.id === body.itemId);
1184
+ if (target) {
1185
+ if (body.name !== undefined) target.name = body.name;
1186
+ if (body.description !== undefined) target.description = body.description;
1187
+ if (body.priority !== undefined) target.priority = body.priority;
1188
+ if (body.estimated_complexity !== undefined) target.estimated_complexity = body.estimated_complexity;
1189
+ if (body.status !== undefined) target.status = body.status;
1190
+ item = target;
1191
+ }
1192
+ return plan;
1193
+ }, { defaultValue: preCheck });
1197
1194
 
1198
1195
  // Feature 3: Sync edits to materialized work item if still pending
1199
1196
  let workItemSynced = false;
@@ -1365,24 +1362,31 @@ const server = http.createServer(async (req, res) => {
1365
1362
 
1366
1363
  fs.watchFile(liveLogPath, { interval: 500 }, watcher);
1367
1364
 
1365
+ // Cleanup helper to prevent handle leaks
1366
+ const cleanup = () => {
1367
+ try { clearInterval(doneCheck); } catch { /* optional */ }
1368
+ try { fs.unwatchFile(liveLogPath, watcher); } catch { /* optional */ }
1369
+ };
1370
+
1368
1371
  // Check if agent is still active (poll every 5s)
1369
1372
  const doneCheck = setInterval(() => {
1370
- const dispatch = getDispatchQueue();
1371
- const isActive = (dispatch.active || []).some(d => d.agent === agentId);
1372
- if (!isActive) {
1373
- watcher(); // flush final content
1374
- res.write(`event: done\ndata: complete\n\n`);
1375
- clearInterval(doneCheck);
1376
- fs.unwatchFile(liveLogPath, watcher);
1377
- res.end();
1373
+ try {
1374
+ const dispatch = getDispatchQueue();
1375
+ const isActive = (dispatch.active || []).some(d => d.agent === agentId);
1376
+ if (!isActive) {
1377
+ watcher(); // flush final content
1378
+ res.write(`event: done\ndata: complete\n\n`);
1379
+ cleanup();
1380
+ res.end();
1381
+ }
1382
+ } catch (e) {
1383
+ cleanup();
1384
+ try { res.end(); } catch { /* optional */ }
1378
1385
  }
1379
1386
  }, 5000);
1380
1387
 
1381
1388
  // Cleanup on client disconnect
1382
- req.on('close', () => {
1383
- clearInterval(doneCheck);
1384
- fs.unwatchFile(liveLogPath, watcher);
1385
- });
1389
+ req.on('close', cleanup);
1386
1390
 
1387
1391
  return;
1388
1392
  }
@@ -1398,7 +1402,9 @@ const server = http.createServer(async (req, res) => {
1398
1402
  } else {
1399
1403
  // Return last N bytes via ?tail=N param (default last 8KB)
1400
1404
  const params = new URL(req.url, 'http://localhost').searchParams;
1401
- const tailBytes = parseInt(params.get('tail')) || 8192;
1405
+ const rawTail = parseInt(params.get('tail'));
1406
+ if (params.has('tail') && isNaN(rawTail)) return jsonReply(res, 400, { error: 'tail must be a number' });
1407
+ const tailBytes = isNaN(rawTail) ? 8192 : Math.max(1, Math.min(10000, rawTail));
1402
1408
  res.end(content.length > tailBytes ? content.slice(-tailBytes) : content);
1403
1409
  }
1404
1410
  return;
@@ -1425,7 +1431,7 @@ const server = http.createServer(async (req, res) => {
1425
1431
  async function handleNotesSave(req, res) {
1426
1432
  try {
1427
1433
  const body = await readBody(req);
1428
- if (!body.content && body.content !== '') return jsonReply(res, 400, { error: 'content required' });
1434
+ if (body.content == null) return jsonReply(res, 400, { error: 'content required' });
1429
1435
  const file = body.file || 'notes.md';
1430
1436
  // Only allow saving notes.md (prevent arbitrary file writes)
1431
1437
  if (file !== 'notes.md') return jsonReply(res, 400, { error: 'only notes.md can be edited' });
@@ -1819,74 +1825,72 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
1819
1825
  wiPaths.push(shared.projectWorkItemsPath(proj));
1820
1826
  }
1821
1827
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
1822
- const dispatch = JSON.parse(safeRead(dispatchPath) || '{}');
1823
1828
  const killedAgents = new Set();
1824
1829
  const resetItemIds = new Set();
1825
1830
 
1826
- for (const wiPath of wiPaths) {
1827
- try {
1828
- const items = safeJson(wiPath);
1829
- if (!items) continue;
1830
- let changed = false;
1831
- for (const w of items) {
1832
- if (w.sourcePlan !== body.file) continue;
1833
- // Keep completed items as-is, reset everything else to pending.
1834
- if (w.status === 'done' || w.status === 'implemented' || w.status === 'complete' || w.status === 'in-pr') continue;
1835
-
1836
- if (w.status === 'dispatched') {
1837
- // Kill the agent working on this item, if any.
1838
- const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
1839
- if (activeEntry) {
1840
- const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
1841
- try {
1842
- const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
1843
- if (agentStatus.pid) {
1844
- try {
1845
- const safePid = shared.validatePid(agentStatus.pid);
1846
- if (process.platform === 'win32') {
1847
- require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
1848
- } else {
1849
- process.kill(safePid, 'SIGTERM');
1850
- }
1851
- } catch { /* process may be dead or invalid PID */ }
1852
- }
1853
- agentStatus.status = 'idle';
1854
- delete agentStatus.currentTask;
1855
- delete agentStatus.dispatched;
1856
- safeWrite(statusPath, agentStatus);
1857
- } catch (e) { console.error('agent reset:', e.message); }
1858
- killedAgents.add(activeEntry.agent);
1831
+ // Read dispatch inside the lock so PID list is consistent with state being modified
1832
+ mutateJsonFileLocked(dispatchPath, (dispatch) => {
1833
+ for (const wiPath of wiPaths) {
1834
+ try {
1835
+ const items = safeJson(wiPath);
1836
+ if (!items) continue;
1837
+ let changed = false;
1838
+ for (const w of items) {
1839
+ if (w.sourcePlan !== body.file) continue;
1840
+ // Keep completed items as-is, reset everything else to pending.
1841
+ if (w.status === 'done' || w.status === 'implemented' || w.status === 'complete' || w.status === 'in-pr') continue;
1842
+
1843
+ if (w.status === 'dispatched') {
1844
+ // Kill the agent working on this item, if any.
1845
+ const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
1846
+ if (activeEntry) {
1847
+ const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
1848
+ try {
1849
+ const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
1850
+ if (agentStatus.pid) {
1851
+ try {
1852
+ const safePid = shared.validatePid(agentStatus.pid);
1853
+ if (process.platform === 'win32') {
1854
+ require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
1855
+ } else {
1856
+ process.kill(safePid, 'SIGTERM');
1857
+ }
1858
+ } catch { /* process may be dead or invalid PID */ }
1859
+ }
1860
+ agentStatus.status = 'idle';
1861
+ delete agentStatus.currentTask;
1862
+ delete agentStatus.dispatched;
1863
+ safeWrite(statusPath, agentStatus);
1864
+ } catch (e) { console.error('agent reset:', e.message); }
1865
+ killedAgents.add(activeEntry.agent);
1866
+ }
1859
1867
  }
1860
- }
1861
1868
 
1862
- if (w.status !== 'pending') reset++;
1863
- w.status = 'pending';
1864
- delete w._pausedBy;
1865
- delete w._resumedAt;
1866
- delete w.dispatched_at;
1867
- delete w.dispatched_to;
1868
- delete w.failReason;
1869
- delete w.failedAt;
1870
- changed = true;
1871
- if (w.id) resetItemIds.add(w.id);
1872
- }
1873
- if (changed) safeWrite(wiPath, items);
1874
- } catch (e) { console.error('reset work items:', e.message); }
1875
- }
1869
+ if (w.status !== 'pending') reset++;
1870
+ w.status = 'pending';
1871
+ delete w._pausedBy;
1872
+ delete w._resumedAt;
1873
+ delete w.dispatched_at;
1874
+ delete w.dispatched_to;
1875
+ delete w.failReason;
1876
+ delete w.failedAt;
1877
+ changed = true;
1878
+ if (w.id) resetItemIds.add(w.id);
1879
+ }
1880
+ if (changed) safeWrite(wiPath, items);
1881
+ } catch (e) { console.error('reset work items:', e.message); }
1882
+ }
1876
1883
 
1877
- // Remove dispatch active entries for reset items or killed agents.
1878
- if (resetItemIds.size > 0 || killedAgents.size > 0) {
1879
- mutateJsonFileLocked(dispatchPath, (dp) => {
1880
- dp.active = Array.isArray(dp.active) ? dp.active : [];
1881
- dp.active = dp.active.filter(d => {
1882
- const itemId = d.meta?.item?.id;
1883
- if (itemId && resetItemIds.has(itemId)) return false;
1884
- if (killedAgents.has(d.agent)) return false;
1885
- return true;
1886
- });
1887
- return dp;
1888
- }, { defaultValue: { pending: [], active: [], completed: [] } });
1889
- }
1884
+ // Remove dispatch active entries for reset items or killed agents.
1885
+ dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
1886
+ dispatch.active = dispatch.active.filter(d => {
1887
+ const itemId = d.meta?.item?.id;
1888
+ if (itemId && resetItemIds.has(itemId)) return false;
1889
+ if (killedAgents.has(d.agent)) return false;
1890
+ return true;
1891
+ });
1892
+ return dispatch;
1893
+ }, { defaultValue: { pending: [], active: [], completed: [] } });
1890
1894
 
1891
1895
  invalidateStatusCache();
1892
1896
  return jsonReply(res, 200, { ok: true, status: 'paused', resetWorkItems: reset });
package/engine/cleanup.js CHANGED
@@ -28,6 +28,17 @@ function engine() { if (!_engine) _engine = require('../engine'); return _engine
28
28
  let _dispatch = null;
29
29
  function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
30
30
 
31
+ // ─── Helpers ────────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Check if a worktree directory name matches a branch via sanitized slug comparison.
35
+ * Eliminates 3x duplication of the branch matching logic (review feedback: Rebecca).
36
+ */
37
+ function worktreeDirMatchesBranch(dirLower, branch) {
38
+ const branchSlug = sanitizeBranch(branch).toLowerCase();
39
+ return dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug);
40
+ }
41
+
31
42
  // ─── Cleanup Orchestrator ────────────────────────────────────────────────────
32
43
 
33
44
  function runCleanup(config, verbose = false) {
@@ -37,27 +48,33 @@ function runCleanup(config, verbose = false) {
37
48
 
38
49
  // 1. Clean stale temp prompt/sysprompt files and orphaned safeWrite .tmp.* files (older than 1 hour)
39
50
  const oneHourAgo = Date.now() - 3600000;
40
- try {
41
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
42
- const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
43
- for (const dir of scanDirs) {
44
- for (const f of fs.readdirSync(dir)) {
45
- const isPromptTemp = f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-');
46
- const isSafeWriteTemp = /\.tmp\.\d+\.\d+$/.test(f);
47
- if (isPromptTemp || isSafeWriteTemp) {
48
- const fp = path.join(dir, f);
49
- try {
50
- const stat = fs.statSync(fp);
51
- if (stat.mtimeMs < oneHourAgo) {
52
- fs.unlinkSync(fp);
53
- cleaned.tempFiles++;
54
- if (isSafeWriteTemp) log('info', `Cleaned orphaned temp file: ${f}`);
55
- }
56
- } catch { /* cleanup */ }
57
- }
51
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
52
+ const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
53
+ for (const dir of scanDirs) {
54
+ // Each directory gets its own try-catch so one failure doesn't abort other directories (Bug #27)
55
+ let dirEntries;
56
+ try {
57
+ dirEntries = fs.readdirSync(dir);
58
+ } catch (e) {
59
+ log('warn', `cleanup temp files: failed to read ${dir} — ${e.message}`);
60
+ continue;
61
+ }
62
+ for (const f of dirEntries) {
63
+ const isPromptTemp = f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-');
64
+ const isSafeWriteTemp = /\.tmp\.\d+\.\d+$/.test(f);
65
+ if (isPromptTemp || isSafeWriteTemp) {
66
+ const fp = path.join(dir, f);
67
+ try {
68
+ const stat = fs.statSync(fp);
69
+ if (stat.mtimeMs < oneHourAgo) {
70
+ fs.unlinkSync(fp);
71
+ cleaned.tempFiles++;
72
+ if (isSafeWriteTemp) log('info', `Cleaned orphaned temp file: ${f}`);
73
+ }
74
+ } catch { /* cleanup */ }
58
75
  }
59
76
  }
60
- } catch (e) { log('warn', 'cleanup temp files: ' + e.message); }
77
+ }
61
78
 
62
79
  // 2. Clean live-output.log for idle agents (not currently working)
63
80
  for (const [agentId] of Object.entries(config.agents || {})) {
@@ -134,8 +151,7 @@ function runCleanup(config, verbose = false) {
134
151
  // Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
135
152
  const dirLower = dir.toLowerCase();
136
153
  for (const branch of mergedBranches) {
137
- const branchSlug = sanitizeBranch(branch).toLowerCase();
138
- if (dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug)) {
154
+ if (worktreeDirMatchesBranch(dirLower, branch)) {
139
155
  shouldClean = true;
140
156
  break;
141
157
  }
@@ -202,8 +218,38 @@ function runCleanup(config, verbose = false) {
202
218
  }
203
219
 
204
220
  // Remove all marked worktrees
221
+ // Re-read PR status immediately before deletion — a PR can be reopened between
222
+ // the initial status check and the actual deletion (Bug #15: TOCTOU race)
223
+ const freshPrs = safeJson(projectPrPath(project)) || [];
224
+ const freshMergedBranches = new Set();
225
+ for (const pr of freshPrs) {
226
+ if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
227
+ if (pr.branch) freshMergedBranches.add(pr.branch);
228
+ }
229
+ }
230
+
205
231
  for (const entry of wtEntries) {
206
232
  if (entry.shouldClean) {
233
+ // Verify the branch is still merged/closed — skip if PR was reopened since initial check
234
+ const entryDirLower = entry.dir.toLowerCase();
235
+ let stillMerged = false;
236
+ for (const branch of freshMergedBranches) {
237
+ if (worktreeDirMatchesBranch(entryDirLower, branch)) {
238
+ stillMerged = true;
239
+ break;
240
+ }
241
+ }
242
+ // If originally marked due to merged branch but PR was reopened, skip deletion
243
+ if (!stillMerged) {
244
+ // Check if it was marked for age/cap cleanup (not branch-based) — those are still valid
245
+ const wasMarkedByBranch = [...mergedBranches].some(branch => worktreeDirMatchesBranch(entryDirLower, branch));
246
+ if (wasMarkedByBranch) {
247
+ if (verbose) console.log(` Skipping worktree ${entry.dir}: PR was reopened since initial check`);
248
+ log('info', `Worktree deletion skipped — PR reopened: ${entry.dir}`);
249
+ continue;
250
+ }
251
+ }
252
+
207
253
  try {
208
254
  exec(`git worktree remove "${entry.wtPath}" --force`, { cwd: root, stdio: 'pipe', timeout: 30000 });
209
255
  cleaned.worktrees++;
@@ -312,7 +358,14 @@ function runCleanup(config, verbose = false) {
312
358
  const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
313
359
  if (fs.existsSync(sweptDir)) {
314
360
  const sevenDaysAgo = Date.now() - 7 * 86400000;
315
- for (const f of fs.readdirSync(sweptDir)) {
361
+ let sweptEntries;
362
+ try {
363
+ sweptEntries = fs.readdirSync(sweptDir);
364
+ } catch (e) {
365
+ log('warn', `cleanup swept KB: failed to read ${sweptDir} — ${e.message}`);
366
+ sweptEntries = [];
367
+ }
368
+ for (const f of sweptEntries) {
316
369
  try {
317
370
  const fp = path.join(sweptDir, f);
318
371
  if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
@@ -334,7 +387,11 @@ function runCleanup(config, verbose = false) {
334
387
  let current = 0;
335
388
  for (const cat of cats) {
336
389
  const d = path.join(knowledgeDir, cat);
337
- if (fs.existsSync(d)) current += fs.readdirSync(d).length;
390
+ try {
391
+ if (fs.existsSync(d)) current += fs.readdirSync(d).length;
392
+ } catch (e) {
393
+ log('warn', `KB watchdog: failed to read ${cat} directory — ${e.message}`);
394
+ }
338
395
  }
339
396
  if (current < checkpoint.count) {
340
397
  log('warn', `KB watchdog: file count dropped ${checkpoint.count} → ${current}, restoring from git`);
@@ -343,8 +400,29 @@ function runCleanup(config, verbose = false) {
343
400
  if (!trackedCheck) {
344
401
  log('warn', 'KB watchdog: knowledge/ is not tracked in git HEAD — skipping restore');
345
402
  } else {
346
- execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
347
- log('info', 'KB watchdog: restored knowledge/ from git HEAD');
403
+ // Bug #29: Check exit code and verify restore succeeded
404
+ let restoreOutput;
405
+ try {
406
+ restoreOutput = execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
407
+ } catch (restoreErr) {
408
+ log('warn', `KB watchdog: git checkout exited with error — ${restoreErr.message}`);
409
+ restoreOutput = null;
410
+ }
411
+ if (restoreOutput !== null) {
412
+ // Verify the restore actually recovered files
413
+ let postRestoreCount = 0;
414
+ for (const cat of cats) {
415
+ const d = path.join(knowledgeDir, cat);
416
+ try {
417
+ if (fs.existsSync(d)) postRestoreCount += fs.readdirSync(d).length;
418
+ } catch { /* count what we can */ }
419
+ }
420
+ if (postRestoreCount < checkpoint.count) {
421
+ log('warn', `KB watchdog: restore incomplete — expected ${checkpoint.count} files, got ${postRestoreCount}`);
422
+ } else {
423
+ log('info', `KB watchdog: restored knowledge/ from git HEAD (${postRestoreCount} files)`);
424
+ }
425
+ }
348
426
  }
349
427
  } catch (err) {
350
428
  log('error', `KB watchdog: git restore failed — ${err.message}`);
@@ -393,7 +471,14 @@ function runCleanup(config, verbose = false) {
393
471
  } catch (e) { log('warn', 'migrate central legacy statuses: ' + e.message); }
394
472
  // PRD items (missing_features[].status)
395
473
  try {
396
- const prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
474
+ let prdDirEntries;
475
+ try {
476
+ prdDirEntries = fs.readdirSync(PRD_DIR);
477
+ } catch (e) {
478
+ log('warn', `migrate PRD statuses: failed to read ${PRD_DIR} — ${e.message}`);
479
+ prdDirEntries = [];
480
+ }
481
+ const prdFiles = prdDirEntries.filter(f => f.endsWith('.json'));
397
482
  for (const pf of prdFiles) {
398
483
  const prdPath = path.join(PRD_DIR, pf);
399
484
  const prd = safeJson(prdPath);
@@ -419,4 +504,5 @@ function runCleanup(config, verbose = false) {
419
504
 
420
505
  module.exports = {
421
506
  runCleanup,
507
+ worktreeDirMatchesBranch, // exported for testing
422
508
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.292",
3
+ "version": "0.1.294",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"