@yemi33/minions 0.1.99 → 0.1.101

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,14 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.99 (2026-04-01)
3
+ ## 0.1.101 (2026-04-01)
4
4
 
5
5
  ### Engine
6
+ - engine.js
6
7
  - engine/ado.js
8
+ - engine/cleanup.js
9
+ - engine/consolidation.js
10
+ - engine/dispatch.js
7
11
  - engine/github.js
8
12
  - engine/lifecycle.js
9
13
  - engine/preflight.js
14
+ - engine/queries.js
10
15
  - engine/shared.js
11
16
 
17
+ ### Dashboard
18
+ - dashboard.js
19
+
12
20
  ### Other
13
21
  - test/unit.test.js
14
22
 
package/dashboard.js CHANGED
@@ -34,14 +34,19 @@ function reloadConfig() {
34
34
  const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
35
35
 
36
36
  // Resolve a plan/PRD file path: .json files live in prd/, .md files in plans/
37
+ // Validates that the file stays within the expected directory to prevent path traversal.
37
38
  function resolvePlanPath(file) {
38
39
  if (file.endsWith('.json')) {
40
+ // Validate against both prd/ and prd/archive/
41
+ shared.sanitizePath(file, PRD_DIR);
39
42
  const active = path.join(PRD_DIR, file);
40
43
  if (fs.existsSync(active)) return active;
41
44
  const archived = path.join(PRD_DIR, 'archive', file);
42
45
  if (fs.existsSync(archived)) return archived;
43
46
  return active;
44
47
  }
48
+ // Validate against both plans/ and plans/archive/
49
+ shared.sanitizePath(file, PLANS_DIR);
45
50
  const active = path.join(PLANS_DIR, file);
46
51
  if (fs.existsSync(active)) return active;
47
52
  const archived = path.join(PLANS_DIR, 'archive', file);
@@ -743,12 +748,13 @@ function spawnEngine() {
743
748
  }
744
749
 
745
750
  function killEnginePid(pid) {
746
- const { execSync } = require('child_process');
751
+ const { execFileSync } = require('child_process');
747
752
  try {
753
+ const safePid = shared.validatePid(pid);
748
754
  if (process.platform === 'win32') {
749
- execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 5000 });
755
+ execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000 });
750
756
  } else {
751
- process.kill(pid, 'SIGKILL');
757
+ process.kill(safePid, 'SIGKILL');
752
758
  }
753
759
  } catch { /* process may be dead */ }
754
760
  }
@@ -783,6 +789,7 @@ const server = http.createServer(async (req, res) => {
783
789
  try {
784
790
  const body = await readBody(req);
785
791
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
792
+ shared.sanitizePath(body.file, PRD_DIR);
786
793
 
787
794
  // Find the PRD — check active and archive
788
795
  const prdDir = path.join(MINIONS_DIR, 'prd');
@@ -1255,11 +1262,14 @@ const server = http.createServer(async (req, res) => {
1255
1262
  try {
1256
1263
  const status = JSON.parse(safeRead(statusPath) || '{}');
1257
1264
  if (status.pid) {
1258
- if (process.platform === 'win32') {
1259
- try { require('child_process').execSync('taskkill /PID ' + status.pid + ' /F /T', { stdio: 'pipe', timeout: 5000 }); } catch { /* process may be dead */ }
1260
- } else {
1261
- try { process.kill(status.pid, 'SIGTERM'); } catch { /* process may be dead */ }
1262
- }
1265
+ try {
1266
+ const safePid = shared.validatePid(status.pid);
1267
+ if (process.platform === 'win32') {
1268
+ require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000 });
1269
+ } else {
1270
+ process.kill(safePid, 'SIGTERM');
1271
+ }
1272
+ } catch { /* process may be dead or invalid PID */ }
1263
1273
  }
1264
1274
  status.status = 'idle';
1265
1275
  delete status.currentTask;
@@ -1418,10 +1428,9 @@ const server = http.createServer(async (req, res) => {
1418
1428
  const cat = match[1];
1419
1429
  const file = decodeURIComponent(match[2]);
1420
1430
  // Prevent path traversal
1421
- if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) {
1422
- return jsonReply(res, 400, { error: 'invalid file name' });
1423
- }
1424
- const content = safeRead(path.join(MINIONS_DIR, 'knowledge', cat, file));
1431
+ const kbCatDir = path.join(MINIONS_DIR, 'knowledge', cat);
1432
+ try { shared.sanitizePath(file, kbCatDir); } catch { return jsonReply(res, 400, { error: 'invalid file name' }); }
1433
+ const content = safeRead(path.join(kbCatDir, file));
1425
1434
  if (content === null) return jsonReply(res, 404, { error: 'not found' });
1426
1435
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1427
1436
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -1853,11 +1862,14 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1853
1862
  try {
1854
1863
  const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
1855
1864
  if (agentStatus.pid) {
1856
- if (process.platform === 'win32') {
1857
- try { require('child_process').execSync('taskkill /PID ' + agentStatus.pid + ' /F /T', { stdio: 'pipe', timeout: 5000 }); } catch { /* process may be dead */ }
1858
- } else {
1859
- try { process.kill(agentStatus.pid, 'SIGTERM'); } catch { /* process may be dead */ }
1860
- }
1865
+ try {
1866
+ const safePid = shared.validatePid(agentStatus.pid);
1867
+ if (process.platform === 'win32') {
1868
+ require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000 });
1869
+ } else {
1870
+ process.kill(safePid, 'SIGTERM');
1871
+ }
1872
+ } catch { /* process may be dead or invalid PID */ }
1861
1873
  }
1862
1874
  agentStatus.status = 'idle';
1863
1875
  delete agentStatus.currentTask;
@@ -1906,7 +1918,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1906
1918
  try {
1907
1919
  const body = await readBody(req);
1908
1920
  if (!body.file) return jsonReply(res, 400, { error: 'file is required' });
1909
- if (body.file.includes('..') || body.file.includes('\0')) return jsonReply(res, 400, { error: 'invalid file path' });
1921
+ shared.sanitizePath(body.file, PRD_DIR);
1910
1922
 
1911
1923
  const prdPath = path.join(PRD_DIR, body.file);
1912
1924
  const plan = safeJson(prdPath);
@@ -1975,6 +1987,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1975
1987
  const body = await readBody(req);
1976
1988
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1977
1989
  if (!body.file.endsWith('.md')) return jsonReply(res, 400, { error: 'only .md plans can be executed' });
1990
+ shared.sanitizePath(body.file, PLANS_DIR);
1978
1991
  const planPath = path.join(MINIONS_DIR, 'plans', body.file);
1979
1992
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
1980
1993
 
@@ -2081,9 +2094,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
2081
2094
  try {
2082
2095
  const body = await readBody(req);
2083
2096
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
2084
- if (body.file.includes('..') || body.file.includes('\0') || body.file.includes('/') || body.file.includes('\\')) {
2085
- return jsonReply(res, 400, { error: 'invalid filename' });
2086
- }
2097
+ shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR);
2087
2098
  const planPath = resolvePlanPath(body.file);
2088
2099
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
2089
2100
  // Read PRD content before deleting to get source_plan for cleanup
@@ -2143,9 +2154,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
2143
2154
  try {
2144
2155
  const body = await readBody(req);
2145
2156
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
2146
- if (body.file.includes('..') || body.file.includes('\0') || body.file.includes('/') || body.file.includes('\\')) {
2147
- return jsonReply(res, 400, { error: 'invalid filename' });
2148
- }
2157
+ shared.sanitizePath(body.file, body.file.endsWith('.json') ? PRD_DIR : PLANS_DIR);
2149
2158
  const planPath = resolvePlanPath(body.file);
2150
2159
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
2151
2160
 
@@ -2493,8 +2502,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2493
2502
  let currentContent = body.document;
2494
2503
  let fullPath = null;
2495
2504
  if (canEdit) {
2505
+ try { shared.sanitizePath(body.filePath, MINIONS_DIR); } catch { return jsonReply(res, 400, { error: 'path must be under minions directory' }); }
2496
2506
  fullPath = path.resolve(MINIONS_DIR, body.filePath);
2497
- if (!fullPath.startsWith(path.resolve(MINIONS_DIR))) return jsonReply(res, 400, { error: 'path must be under minions directory' });
2498
2507
  const diskContent = safeRead(fullPath);
2499
2508
  if (diskContent !== null) currentContent = diskContent;
2500
2509
  }
@@ -2554,11 +2563,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2554
2563
  try {
2555
2564
  const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
2556
2565
  if (agentStatus.pid) {
2557
- if (process.platform === 'win32') {
2558
- try { require('child_process').execSync('taskkill /PID ' + agentStatus.pid + ' /F /T', { stdio: 'pipe', timeout: 5000 }); } catch { /* process may be dead */ }
2559
- } else {
2560
- try { process.kill(agentStatus.pid, 'SIGTERM'); } catch { /* process may be dead */ }
2561
- }
2566
+ try {
2567
+ const safePid = shared.validatePid(agentStatus.pid);
2568
+ if (process.platform === 'win32') {
2569
+ require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000 });
2570
+ } else {
2571
+ process.kill(safePid, 'SIGTERM');
2572
+ }
2573
+ } catch { /* process may be dead or invalid PID */ }
2562
2574
  }
2563
2575
  agentStatus.status = 'idle';
2564
2576
  delete agentStatus.currentTask;
@@ -2607,7 +2619,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2607
2619
  const body = await readBody(req);
2608
2620
  const { name } = body;
2609
2621
  if (!name) return jsonReply(res, 400, { error: 'name required' });
2610
- if (name.includes('..') || name.includes('\0')) return jsonReply(res, 400, { error: 'Invalid file name' });
2622
+ shared.sanitizePath(name, path.join(MINIONS_DIR, 'notes', 'inbox'));
2611
2623
 
2612
2624
  const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
2613
2625
  const content = safeRead(inboxPath);
package/engine/cleanup.js CHANGED
@@ -272,6 +272,7 @@ function runCleanup(config, verbose = false) {
272
272
  return allWiIds.has(itemId);
273
273
  });
274
274
  }
275
+ return dp;
275
276
  });
276
277
  }
277
278
  } catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const crypto = require('crypto');
9
10
  const shared = require('./shared');
10
11
  const { safeRead, safeWrite, safeUnlink, runFile, cleanChildEnv,
11
12
  parseStreamJsonOutput, classifyInboxItem, KB_CATEGORIES, log, dateStamp } = shared;
@@ -116,6 +117,23 @@ function consolidateWithLLM(items, existingNotes, files, config) {
116
117
  _consolidationStartedAt = Date.now();
117
118
  for (const f of files) _processingFiles.add(f);
118
119
 
120
+ // ─── Content-hash circuit breaker: skip LLM if >80% items are near-duplicates
121
+ const dupCheck = checkDuplicateHash(items);
122
+ if (dupCheck.isDuplicate) {
123
+ log('info', `Skipped LLM consolidation: ${dupCheck.count}/${dupCheck.total} items are duplicates (hash: ${dupCheck.hash.slice(0, 8)})`);
124
+ // Archive duplicate files directly
125
+ if (!fs.existsSync(ARCHIVE_DIR)) fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
126
+ for (const f of files) {
127
+ try {
128
+ fs.renameSync(path.join(INBOX_DIR, f), shared.uniquePath(path.join(ARCHIVE_DIR, `${dateStamp()}-${f}`)));
129
+ } catch (err) { log('warn', `Inbox archive (dup skip): ${err.message}`); }
130
+ }
131
+ for (const f of files) _processingFiles.delete(f);
132
+ _consolidationInFlight = false;
133
+ _consolidationStartedAt = 0;
134
+ return;
135
+ }
136
+
119
137
  const kbPaths = items.map(item => {
120
138
  const cat = classifyInboxItem(item.name, item.content);
121
139
  const agentMatch = item.name.match(/^(\w+)-/);
@@ -179,8 +197,8 @@ function consolidateWithLLM(items, existingNotes, files, config) {
179
197
 
180
198
  proc.on('close', (code) => {
181
199
  clearTimeout(timeout);
182
- safeUnlink(promptPath);
183
- safeUnlink(sysPromptPath);
200
+ try { safeUnlink(promptPath); } catch (err) { log('warn', `Temp file cleanup failed: ${promptPath} — ${err.message}`); }
201
+ try { safeUnlink(sysPromptPath); } catch (err) { log('warn', `Temp file cleanup failed: ${sysPromptPath} — ${err.message}`); }
184
202
 
185
203
  const parsed = parseStreamJsonOutput(stdout);
186
204
  const extractedText = parsed.text;
@@ -231,8 +249,8 @@ function consolidateWithLLM(items, existingNotes, files, config) {
231
249
  proc.on('error', (err) => {
232
250
  clearTimeout(timeout);
233
251
  log('warn', `LLM consolidation spawn error: ${err.message} — falling back to regex`);
234
- safeUnlink(promptPath);
235
- safeUnlink(sysPromptPath);
252
+ try { safeUnlink(promptPath); } catch (unlinkErr) { log('warn', `Temp file cleanup failed: ${promptPath} — ${unlinkErr.message}`); }
253
+ try { safeUnlink(sysPromptPath); } catch (unlinkErr) { log('warn', `Temp file cleanup failed: ${sysPromptPath} — ${unlinkErr.message}`); }
236
254
  consolidateWithRegex(items, files);
237
255
  _clearProcessingState();
238
256
  });
@@ -296,13 +314,15 @@ function consolidateWithRegex(items, files) {
296
314
  const deduped = [];
297
315
  for (const insight of allInsights) {
298
316
  const fpWords = insight.fingerprint.split(' ').filter(w => w.length > 4).slice(0, 5);
299
- if (fpWords.length >= 3 && fpWords.every(w => existingNotes.includes(w))) continue;
317
+ // Use word-boundary regex to avoid substring false positives (e.g. 'fix' matching 'prefix')
318
+ if (fpWords.length >= 3 && fpWords.every(w => new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(existingNotes))) continue;
300
319
  const existing = seen.get(insight.fingerprint);
301
320
  if (existing) { if (!existing.sources.includes(insight.agent)) existing.sources.push(insight.agent); continue; }
302
321
  let isDup = false;
303
322
  for (const [fp, entry] of seen) {
304
- const a = new Set(fp.split(' ')), b = new Set(insight.fingerprint.split(' '));
305
- // Require at least 3 words in both fingerprints for meaningful similarity check
323
+ // Filter to meaningful words (>4 chars) to avoid short-word false positives like 'fix' vs 'prefix'
324
+ const a = new Set(fp.split(' ').filter(w => w.length > 2)), b = new Set(insight.fingerprint.split(' ').filter(w => w.length > 2));
325
+ // Require at least 3 meaningful words in both fingerprints for similarity check
306
326
  if (a.size >= 3 && b.size >= 3 && [...a].filter(w => b.has(w)).length / Math.max(a.size, b.size) > 0.7) {
307
327
  if (!entry.sources.includes(insight.agent)) entry.sources.push(insight.agent); isDup = true; break;
308
328
  }
@@ -352,7 +372,9 @@ function classifyToKnowledgeBase(items) {
352
372
  if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
353
373
 
354
374
  const categoryDirs = {};
355
- for (const cat of KB_CATEGORIES) {
375
+ // Include 'general' as fallback category even if not in KB_CATEGORIES
376
+ const allCategories = KB_CATEGORIES.includes('general') ? KB_CATEGORIES : [...KB_CATEGORIES, 'general'];
377
+ for (const cat of allCategories) {
356
378
  categoryDirs[cat] = path.join(KNOWLEDGE_DIR, cat);
357
379
  if (!fs.existsSync(categoryDirs[cat])) fs.mkdirSync(categoryDirs[cat], { recursive: true });
358
380
  }
@@ -360,7 +382,12 @@ function classifyToKnowledgeBase(items) {
360
382
  let classified = 0;
361
383
  for (const item of items) {
362
384
  const content = item.content || '';
363
- const category = classifyInboxItem(item.name, content);
385
+ const rawCategory = classifyInboxItem(item.name, content);
386
+ // Fallback to 'general' if the classified category isn't in our known category map
387
+ const category = categoryDirs[rawCategory] ? rawCategory : 'general';
388
+ if (rawCategory !== category) {
389
+ log('warn', `Unknown KB category '${rawCategory}' for ${item.name} — falling back to 'general'`);
390
+ }
364
391
 
365
392
  const agentMatch = item.name.match(/^(\w+)-/);
366
393
  const agent = agentMatch ? agentMatch[1] : 'unknown';
@@ -403,8 +430,30 @@ function archiveInboxFiles(files) {
403
430
  }
404
431
  }
405
432
 
433
+ /**
434
+ * Check if >80% of items share the same content hash (first 200 chars + length).
435
+ * Returns { isDuplicate, hash, count, total } or { isDuplicate: false }.
436
+ * Exported for testing.
437
+ */
438
+ function checkDuplicateHash(items) {
439
+ if (!items || items.length === 0) return { isDuplicate: false };
440
+ const hashCounts = new Map();
441
+ for (const item of items) {
442
+ const content = item.content || '';
443
+ const hash = crypto.createHash('sha256').update(content.slice(0, 200) + ':' + content.length).digest('hex');
444
+ hashCounts.set(hash, (hashCounts.get(hash) || 0) + 1);
445
+ }
446
+ for (const [hash, count] of hashCounts) {
447
+ if (count / items.length > 0.8) {
448
+ return { isDuplicate: true, hash, count, total: items.length };
449
+ }
450
+ }
451
+ return { isDuplicate: false };
452
+ }
453
+
406
454
  module.exports = {
407
455
  consolidateInbox,
408
456
  classifyToKnowledgeBase,
457
+ checkDuplicateHash,
409
458
  };
410
459
 
@@ -38,6 +38,7 @@ function addToDispatch(item) {
38
38
  item.created_at = ts();
39
39
  mutateDispatch((dispatch) => {
40
40
  dispatch.pending.push(item);
41
+ return dispatch;
41
42
  });
42
43
  log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
43
44
  return item.id;
@@ -79,7 +80,7 @@ function completeDispatch(id, result = 'success', reason = '', resultSummary = '
79
80
  if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
80
81
  }
81
82
 
82
- if (!item) return;
83
+ if (!item) return dispatch;
83
84
  item.completed_at = ts();
84
85
  item.result = result;
85
86
  if (reason) item.reason = reason;
@@ -89,6 +90,7 @@ function completeDispatch(id, result = 'success', reason = '', resultSummary = '
89
90
  dispatch.completed = dispatch.completed.slice(-99);
90
91
  }
91
92
  dispatch.completed.push(item);
93
+ return dispatch;
92
94
  });
93
95
 
94
96
  if (item) {
@@ -6,8 +6,8 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
- const { safeRead, safeJson, safeWrite, execSilent, projectPrPath, getPrLinks, addPrLink,
10
- log, ts, dateStamp } = shared;
9
+ const { safeRead, safeJson, safeWrite, safeReadDir, execSilent, projectPrPath, getPrLinks, addPrLink,
10
+ mutateJsonFileLocked, log, ts, dateStamp } = shared;
11
11
  const { trackEngineUsage } = require('./llm');
12
12
  const queries = require('./queries');
13
13
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
@@ -21,7 +21,12 @@ function checkPlanCompletion(meta, config) {
21
21
  const planPath = path.join(PRD_DIR, planFile);
22
22
  const plan = safeJson(planPath);
23
23
  if (!plan?.missing_features) return;
24
- if (plan.status === 'completed') return;
24
+ if (plan.status === 'completed') {
25
+ // Idempotency guard: if we already sent the completion notification, skip entirely.
26
+ // If _completionNotified is NOT set, fall through — crash recovery path:
27
+ // engine crashed after setting status=completed but before creating verify/PR items.
28
+ if (plan._completionNotified) return;
29
+ }
25
30
 
26
31
  const projects = shared.getProjects(config);
27
32
 
@@ -132,10 +137,26 @@ function checkPlanCompletion(meta, config) {
132
137
  ...uniquePrs.map(pr => `- ${pr.id}: ${pr.title || ''} ${pr.url || ''}`),
133
138
  ].filter(Boolean).join('\n');
134
139
 
135
- // Write summary to notes/inbox
136
- const summaryFile = `prd-completion-${planFile.replace('.json', '')}-${ts().slice(0, 10)}.md`;
137
- shared.safeWrite(shared.uniquePath(path.join(MINIONS_DIR, 'notes', 'inbox', summaryFile)), summary);
138
- log('info', `PRD completion summary written to notes/inbox/${summaryFile}`);
140
+ // Write summary to notes/inbox (slug+date dedup — same pattern as writeInboxAlert in dispatch.js)
141
+ const summarySlug = `prd-completion-${planFile.replace('.json', '')}`;
142
+ const summaryFile = `${summarySlug}-${dateStamp()}.md`;
143
+ const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
144
+ const existing = safeReadDir(inboxDir).find(f => f.startsWith(`${summarySlug}-${dateStamp()}`));
145
+ if (!existing) {
146
+ shared.safeWrite(path.join(inboxDir, summaryFile), summary);
147
+ log('info', `PRD completion summary written to notes/inbox/${summaryFile}`);
148
+ } else {
149
+ log('info', `PRD completion summary already exists for today: ${existing}, skipping inbox write`);
150
+ }
151
+
152
+ // Persist _completionNotified flag atomically BEFORE creating work items.
153
+ // This prevents duplicate inbox notes on re-entry. Work item creation below has its own
154
+ // existingPrItem/existingVerify guards, so the flag does NOT block crash recovery of those.
155
+ plan._completionNotified = true;
156
+ mutateJsonFileLocked(planPath, (data) => {
157
+ data._completionNotified = true;
158
+ return data;
159
+ });
139
160
 
140
161
  // Resolve the primary project for writing new work items (PR, verify)
141
162
  const projectName = plan.project;
package/engine/queries.js CHANGED
@@ -540,8 +540,8 @@ function getPrdInfo(config) {
540
540
  const planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
541
541
  for (const pf of planFiles) {
542
542
  try {
543
- const plan = JSON.parse(fs.readFileSync(path.join(dir, pf), 'utf8'));
544
- if (!plan.missing_features) continue;
543
+ const plan = safeJson(path.join(dir, pf));
544
+ if (!plan || !plan.missing_features) continue;
545
545
  const stat = fs.statSync(path.join(dir, pf));
546
546
  if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
547
547
  // Staleness: compare source plan mtime to recorded sourcePlanModifiedAt
@@ -577,13 +577,13 @@ function getPrdInfo(config) {
577
577
  for (const project of projects) {
578
578
  try {
579
579
  const workItems = safeJson(projectWorkItemsPath(project)) || [];
580
- for (const wi of workItems) { if (wi.sourcePlan) wiById[wi.id] = wi; }
580
+ for (const wi of workItems) { if (!wi.id) { console.warn(`[queries] Skipping work item without id in ${project.name}:`, JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan) wiById[wi.id] = wi; }
581
581
  } catch { /* optional */ }
582
582
  }
583
583
  // Also check central work-items.json
584
584
  try {
585
585
  const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
586
- for (const wi of centralWi) { if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi; }
586
+ for (const wi of centralWi) { if (!wi.id) { console.warn('[queries] Skipping central work item without id:', JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi; }
587
587
  } catch { /* optional */ }
588
588
 
589
589
  // PR-to-PRD linking — primary source is pr-links.json (single-writer, never clobbered by polling)
@@ -595,7 +595,7 @@ function getPrdInfo(config) {
595
595
  const prLinks = shared.getPrLinks(); // { "PR-xxxx": "P-xxxx" }
596
596
  for (const [prId, itemId] of Object.entries(prLinks)) {
597
597
  const pr = prById[prId];
598
- const project = projects.find(p => p.name === pr?._project) || projects[0];
598
+ const project = projects.find(p => p.name === pr?._project) || projects[0] || null;
599
599
  const url = pr?.url || (project?.prUrlBase ? project.prUrlBase + prId.replace('PR-', '') : '');
600
600
  if (!prdToPr[itemId]) prdToPr[itemId] = [];
601
601
  prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
package/engine/shared.js CHANGED
@@ -25,7 +25,7 @@ function log(level, msg, meta = {}) {
25
25
  let logData = safeJson(LOG_PATH) || [];
26
26
  if (!Array.isArray(logData)) logData = logData.entries || [];
27
27
  logData.push(entry);
28
- if (logData.length > 2000) logData.splice(0, logData.length - 2000);
28
+ if (logData.length >= 2500) logData.splice(0, logData.length - 2000);
29
29
  safeWrite(LOG_PATH, logData);
30
30
  }
31
31
 
@@ -387,18 +387,37 @@ function getAdoOrgBase(project) {
387
387
  // ── Path Sanitization ───────────────────────────────────────────────────────
388
388
 
389
389
  /**
390
- * Resolve a user-supplied path relative to a base directory and verify the
391
- * result stays within the base. Throws if the resolved path escapes baseDir.
392
- * Use to prevent path-traversal attacks on any user-facing file endpoint.
390
+ * Validate that a user-supplied filename stays within the given base directory.
391
+ * Rejects path traversal (../, encoded variants), null bytes, and absolute paths.
392
+ * Returns the resolved absolute path or throws with a descriptive message.
393
393
  */
394
- function sanitizePath(baseDir, userInput) {
395
- const resolvedBase = path.resolve(baseDir);
396
- const resolvedFull = path.resolve(resolvedBase, userInput);
397
- // Append path.sep so "/foo" doesn't match "/foobar"
398
- if (!resolvedFull.startsWith(resolvedBase + path.sep) && resolvedFull !== resolvedBase) {
399
- throw new Error(`Path traversal blocked: ${userInput} resolves outside ${baseDir}`);
394
+ function sanitizePath(file, baseDir) {
395
+ if (!file || typeof file !== 'string') throw new Error('file parameter is required');
396
+ // Reject null bytes
397
+ if (file.includes('\0')) throw new Error('invalid file path: null byte');
398
+ // Reject obvious traversal patterns (including URL-encoded variants)
399
+ const decoded = decodeURIComponent(file);
400
+ if (decoded.includes('..') || file.includes('..')) throw new Error('invalid file path: directory traversal');
401
+ // Reject absolute paths (Unix and Windows)
402
+ if (path.isAbsolute(file) || /^[a-zA-Z]:/.test(file)) throw new Error('invalid file path: absolute path not allowed');
403
+ const resolved = path.resolve(baseDir, file);
404
+ const normalizedBase = path.resolve(baseDir);
405
+ if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
406
+ throw new Error('invalid file path: outside allowed directory');
400
407
  }
401
- return resolvedFull;
408
+ return resolved;
409
+ }
410
+
411
+ /**
412
+ * Validate that a PID value is a positive integer. Returns the numeric PID.
413
+ * Throws if the value could be used for command injection.
414
+ */
415
+ function validatePid(pid) {
416
+ const s = String(pid);
417
+ if (!/^\d+$/.test(s)) throw new Error('Invalid PID: must be numeric');
418
+ const n = parseInt(s, 10);
419
+ if (n <= 0 || !Number.isFinite(n)) throw new Error('Invalid PID: must be a positive integer');
420
+ return n;
402
421
  }
403
422
 
404
423
  // ── Branch Sanitization ──────────────────────────────────────────────────────
@@ -483,6 +502,7 @@ module.exports = {
483
502
  getAdoOrgBase,
484
503
  sanitizePath,
485
504
  sanitizeBranch,
505
+ validatePid,
486
506
  parseSkillFrontmatter,
487
507
  sleepMs,
488
508
  LOCK_STALE_MS,
package/engine.js CHANGED
@@ -708,13 +708,14 @@ function spawnAgent(dispatchItem, config) {
708
708
  // Move pending -> active under a lock to avoid cross-process lost updates (engine/dashboard)
709
709
  mutateDispatch((dispatch) => {
710
710
  const idx = dispatch.pending.findIndex(d => d.id === id);
711
- if (idx < 0) return;
711
+ if (idx < 0) return dispatch;
712
712
  const item = dispatch.pending.splice(idx, 1)[0];
713
713
  item.started_at = startedAt;
714
714
  delete item.skipReason;
715
715
  if (!dispatch.active.some(d => d.id === id)) {
716
716
  dispatch.active.push(item);
717
717
  }
718
+ return dispatch;
718
719
  });
719
720
 
720
721
  return proc;
@@ -1382,9 +1383,8 @@ function discoverFromWorkItems(config, project) {
1382
1383
  // This protects against persisted state drift from old runtime versions.
1383
1384
  try {
1384
1385
  mutateDispatch((dp) => {
1385
- const before = Array.isArray(dp.completed) ? dp.completed.length : 0;
1386
1386
  dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== key) : [];
1387
- return dp.completed.length !== before ? dp : undefined;
1387
+ return dp;
1388
1388
  });
1389
1389
  dispatchCooldowns.delete(key);
1390
1390
  } catch (e) { log('warn', 'self-heal dispatch state: ' + e.message); }
@@ -1973,19 +1973,29 @@ function discoverWork(config) {
1973
1973
 
1974
1974
  // Periodic plan completion sweep — catch PRDs that completed while engine was down
1975
1975
  // or where checkPlanCompletion missed the completion event
1976
- try {
1977
- const lifecycle = require('./engine/lifecycle');
1978
- const prdDir = path.join(MINIONS_DIR, 'prd');
1979
- if (fs.existsSync(prdDir)) {
1980
- for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
1981
- const plan = safeJson(path.join(prdDir, f));
1982
- if (!plan?.missing_features || plan.status === 'completed') continue;
1983
- if (plan.status !== 'approved' && plan.status !== 'active') continue;
1984
- // Simulate the meta object checkPlanCompletion expects
1985
- lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
1976
+ // Throttled to every 10 ticks (~5 min) to reduce call volume (P3 decision)
1977
+ if (tickCount % 10 === 0) {
1978
+ try {
1979
+ const lifecycle = require('./engine/lifecycle');
1980
+ const prdDir = path.join(MINIONS_DIR, 'prd');
1981
+ if (fs.existsSync(prdDir)) {
1982
+ for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
1983
+ if (completedPlanCache.has(f)) continue;
1984
+ const plan = safeJson(path.join(prdDir, f));
1985
+ if (!plan?.missing_features || plan.status === 'completed') {
1986
+ if (plan?.status === 'completed') completedPlanCache.add(f);
1987
+ continue;
1988
+ }
1989
+ if (plan.status !== 'approved' && plan.status !== 'active') continue;
1990
+ // Simulate the meta object checkPlanCompletion expects
1991
+ lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
1992
+ // If plan transitioned to completed, cache it
1993
+ const after = safeJson(path.join(prdDir, f));
1994
+ if (after?.status === 'completed') completedPlanCache.add(f);
1995
+ }
1986
1996
  }
1987
- }
1988
- } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
1997
+ } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
1998
+ }
1989
1999
 
1990
2000
  // Gate reviews and fixes: do not dispatch until all implement items are complete
1991
2001
  const hasIncompleteImplements = projects.some(project => {
@@ -2023,6 +2033,10 @@ function discoverWork(config) {
2023
2033
 
2024
2034
  let tickCount = 0;
2025
2035
 
2036
+ // In-memory cache of plan filenames confirmed completed — avoids redundant
2037
+ // checkPlanCompletion calls. Cleared automatically on engine restart.
2038
+ const completedPlanCache = new Set();
2039
+
2026
2040
  let tickRunning = false;
2027
2041
 
2028
2042
  async function tick() {
@@ -2086,9 +2100,15 @@ async function tickInner() {
2086
2100
  try {
2087
2101
  const prdFiles = safeReadDir(PRD_DIR).filter(f => f.endsWith('.json'));
2088
2102
  for (const file of prdFiles) {
2103
+ if (completedPlanCache.has(file)) continue;
2089
2104
  const plan = safeJson(path.join(PRD_DIR, file));
2090
2105
  if (plan && plan.missing_features && plan.status !== 'completed') {
2091
2106
  checkPlanCompletion({ item: { sourcePlan: file } }, config);
2107
+ // If plan transitioned to completed, cache it
2108
+ const after = safeJson(path.join(PRD_DIR, file));
2109
+ if (after?.status === 'completed') completedPlanCache.add(file);
2110
+ } else if (plan?.status === 'completed') {
2111
+ completedPlanCache.add(file);
2092
2112
  }
2093
2113
  }
2094
2114
  } catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
@@ -2146,9 +2166,8 @@ async function tickInner() {
2146
2166
  try {
2147
2167
  const key = `work-${project.name}-${item.id}`;
2148
2168
  mutateDispatch((dp) => {
2149
- const before = dp.completed.length;
2150
2169
  dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2151
- if (dp.completed.length !== before) return dp;
2170
+ return dp;
2152
2171
  });
2153
2172
  } catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
2154
2173
 
@@ -2182,6 +2201,7 @@ async function tickInner() {
2182
2201
  const key = `work-${project.name}-${dep.id}`;
2183
2202
  mutateDispatch((dp) => {
2184
2203
  dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2204
+ return dp;
2185
2205
  });
2186
2206
  } catch (e) { log('warn', 'stall recovery clear dependent dispatch: ' + e.message); }
2187
2207
  }
@@ -2226,6 +2246,7 @@ async function tickInner() {
2226
2246
  mutateDispatch((dp) => {
2227
2247
  dp.pending = dispatch.pending;
2228
2248
  dp.active = dispatch.active || dp.active;
2249
+ return dp;
2229
2250
  });
2230
2251
 
2231
2252
  // Only dispatch to agents that aren't already busy (one task per agent at a time).
@@ -2244,8 +2265,39 @@ async function tickInner() {
2244
2265
  const dispatched = new Set();
2245
2266
  for (const item of toDispatch) {
2246
2267
  if (!dispatched.has(item.id)) {
2247
- spawnAgent(item, config);
2248
- dispatched.add(item.id);
2268
+ const proc = spawnAgent(item, config);
2269
+ if (proc === null) {
2270
+ // spawnAgent failed (e.g., worktree creation error). It already called
2271
+ // completeDispatch internally which handles retry logic, but log at the
2272
+ // dispatch-loop level for visibility and handle any edge cases where
2273
+ // completeDispatch wasn't called.
2274
+ log('error', `spawnAgent returned null for ${item.id} (${item.type} → ${item.agent}) — spawn failed`);
2275
+ // Defensive: ensure the work item is re-queued if completeDispatch didn't fire
2276
+ if (item.meta?.item?.id) {
2277
+ try {
2278
+ const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
2279
+ ? path.join(ENGINE_DIR, '..', 'work-items.json')
2280
+ : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
2281
+ if (wiPath) {
2282
+ const items = safeJson(wiPath) || [];
2283
+ const wi = items.find(i => i.id === item.meta.item.id);
2284
+ if (wi && wi.status === 'dispatched') {
2285
+ // completeDispatch didn't update the work item — re-queue manually
2286
+ wi.status = 'pending';
2287
+ wi._retryCount = (wi._retryCount || 0) + 1;
2288
+ wi._lastRetryReason = 'spawnAgent returned null';
2289
+ wi._lastRetryAt = ts();
2290
+ delete wi.dispatched_at;
2291
+ delete wi.dispatched_to;
2292
+ safeWrite(wiPath, items);
2293
+ log('info', `Re-queued ${item.meta.item.id} as pending (retry ${wi._retryCount})`);
2294
+ }
2295
+ }
2296
+ } catch (e) { log('warn', `Failed to re-queue work item after spawn failure: ${e.message}`); }
2297
+ }
2298
+ } else {
2299
+ dispatched.add(item.id);
2300
+ }
2249
2301
  }
2250
2302
  }
2251
2303
 
@@ -2268,7 +2320,7 @@ async function tickInner() {
2268
2320
  }
2269
2321
  }
2270
2322
  if (skipReasonChanged) {
2271
- mutateDispatch((dp) => { dp.pending = postDispatch.pending; });
2323
+ mutateDispatch((dp) => { dp.pending = postDispatch.pending; return dp; });
2272
2324
  }
2273
2325
  }
2274
2326
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.99",
3
+ "version": "0.1.101",
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"