@yemi33/minions 0.1.100 → 0.1.102

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,6 +1,14 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.100 (2026-04-01)
3
+ ## 0.1.102 (2026-04-01)
4
+
5
+ ### Engine
6
+ - engine/scheduler.js
7
+
8
+ ### Other
9
+ - test/unit.test.js
10
+
11
+ ## 0.1.101 (2026-04-01)
4
12
 
5
13
  ### Engine
6
14
  - engine.js
@@ -14,6 +22,9 @@
14
22
  - engine/queries.js
15
23
  - engine/shared.js
16
24
 
25
+ ### Dashboard
26
+ - dashboard.js
27
+
17
28
  ### Other
18
29
  - test/unit.test.js
19
30
 
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);
@@ -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+)-/);
@@ -412,8 +430,30 @@ function archiveInboxFiles(files) {
412
430
  }
413
431
  }
414
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
+
415
454
  module.exports = {
416
455
  consolidateInbox,
417
456
  classifyToKnowledgeBase,
457
+ checkDuplicateHash,
418
458
  };
419
459
 
@@ -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;
@@ -61,11 +61,11 @@ function parseCronField(field, min, max) {
61
61
  function parseCronExpr(expr) {
62
62
  if (!expr || typeof expr !== 'string') return null;
63
63
  const parts = expr.trim().split(/\s+/);
64
- if (parts.length < 2 || parts.length > 3) return null;
64
+ if (parts.length !== 3) return null;
65
65
 
66
66
  const minuteMatcher = parseCronField(parts[0], 0, 59);
67
67
  const hourMatcher = parseCronField(parts[1], 0, 23);
68
- const dowMatcher = parts[2] ? parseCronField(parts[2], 0, 6) : () => true;
68
+ const dowMatcher = parseCronField(parts[2], 0, 6);
69
69
 
70
70
  return {
71
71
  matches(date) {
package/engine/shared.js CHANGED
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.100",
3
+ "version": "0.1.102",
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"