@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 +9 -1
- package/dashboard.js +43 -31
- package/engine/cleanup.js +1 -0
- package/engine/consolidation.js +58 -9
- package/engine/dispatch.js +3 -1
- package/engine/lifecycle.js +28 -7
- package/engine/queries.js +5 -5
- package/engine/shared.js +31 -11
- package/engine.js +72 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
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 {
|
|
751
|
+
const { execFileSync } = require('child_process');
|
|
747
752
|
try {
|
|
753
|
+
const safePid = shared.validatePid(pid);
|
|
748
754
|
if (process.platform === 'win32') {
|
|
749
|
-
|
|
755
|
+
execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000 });
|
|
750
756
|
} else {
|
|
751
|
-
process.kill(
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
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
|
-
|
|
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
package/engine/consolidation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/engine/dispatch.js
CHANGED
|
@@ -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) {
|
package/engine/lifecycle.js
CHANGED
|
@@ -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')
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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 =
|
|
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
|
|
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
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
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(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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