@yemi33/minions 0.1.531 → 0.1.533
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 +10 -0
- package/dashboard/js/render-meetings.js +2 -1
- package/dashboard.js +176 -187
- package/engine/ado.js +13 -12
- package/engine/github.js +15 -15
- package/engine/lifecycle.js +4 -4
- package/engine/shared.js +27 -1
- package/engine/timeout.js +2 -2
- package/engine.js +46 -35
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.533 (2026-04-07)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
- convert blocking spawnSync/execSync to async execAsync (#447)
|
|
7
|
+
|
|
8
|
+
## 0.1.532 (2026-04-07)
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
- meeting cards show doc-chat processing dots and notification badges
|
|
12
|
+
|
|
3
13
|
## 0.1.531 (2026-04-07)
|
|
4
14
|
|
|
5
15
|
### Fixes
|
|
@@ -48,7 +48,7 @@ function renderMeetings(meetings) {
|
|
|
48
48
|
const dt = m.completedAt || m.createdAt;
|
|
49
49
|
const timeStr = dt ? new Date(dt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
|
50
50
|
|
|
51
|
-
return '<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;cursor:pointer" onclick="openMeetingDetail(\'' + escHtml(m.id) + '\')">' +
|
|
51
|
+
return '<div data-file="meetings/' + escHtml(m.id) + '.json" style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;cursor:pointer;position:relative" onclick="openMeetingDetail(\'' + escHtml(m.id) + '\')">' +
|
|
52
52
|
'<div style="display:flex;justify-content:space-between;align-items:center">' +
|
|
53
53
|
'<strong style="font-size:13px">' + escHtml(m.title) + '</strong>' +
|
|
54
54
|
'<div style="display:flex;align-items:center;gap:8px">' +
|
|
@@ -78,6 +78,7 @@ function renderMeetings(meetings) {
|
|
|
78
78
|
el.innerHTML += '<div style="text-align:center;margin-top:8px"><button class="pr-pager-btn" style="font-size:10px" onclick="_toggleArchivedMeetings()">' +
|
|
79
79
|
(_showArchived ? 'Hide' : 'Show') + ' ' + archived.length + ' archived</button></div>';
|
|
80
80
|
}
|
|
81
|
+
restoreNotifBadges();
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
function _mtgPrev() { if (_mtgPage > 0) { _mtgPage--; refresh(); } }
|
package/dashboard.js
CHANGED
|
@@ -22,7 +22,7 @@ const shared = require('./engine/shared');
|
|
|
22
22
|
const queries = require('./engine/queries');
|
|
23
23
|
const os = require('os');
|
|
24
24
|
|
|
25
|
-
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, getProjects: _getProjects, DONE_STATUSES, WI_STATUS } = shared;
|
|
25
|
+
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS } = shared;
|
|
26
26
|
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
27
27
|
getSkills, getInbox, getNotesWithMeta, getPullRequests,
|
|
28
28
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
|
|
@@ -1381,9 +1381,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
1381
1381
|
if (!body.title || !body.title.trim()) return jsonReply(res, 400, { error: 'title is required' });
|
|
1382
1382
|
// Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
|
|
1383
1383
|
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1384
|
-
let items = [];
|
|
1385
|
-
const existing = safeRead(wiPath);
|
|
1386
|
-
if (existing) { try { items = JSON.parse(existing); } catch {} }
|
|
1387
1384
|
const id = 'W-' + shared.uid();
|
|
1388
1385
|
const item = {
|
|
1389
1386
|
id, title: body.title, type: 'plan',
|
|
@@ -1393,8 +1390,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1393
1390
|
};
|
|
1394
1391
|
if (body.project) item.project = body.project;
|
|
1395
1392
|
if (body.agent) item.agent = body.agent;
|
|
1396
|
-
items.push(item);
|
|
1397
|
-
safeWrite(wiPath, items);
|
|
1393
|
+
mutateWorkItems(wiPath, items => { items.push(item); });
|
|
1398
1394
|
return jsonReply(res, 200, { ok: true, id, agent: body.agent || '' });
|
|
1399
1395
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1400
1396
|
}
|
|
@@ -1465,18 +1461,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
1465
1461
|
}
|
|
1466
1462
|
for (const wiPath of wiSyncPaths) {
|
|
1467
1463
|
try {
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1464
|
+
mutateWorkItems(wiPath, items => {
|
|
1465
|
+
const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
|
|
1466
|
+
if (wi && wi.status === 'pending') {
|
|
1467
|
+
if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
|
|
1468
|
+
if (body.description !== undefined) wi.description = body.description;
|
|
1469
|
+
if (body.priority !== undefined) wi.priority = body.priority;
|
|
1470
|
+
if (body.estimated_complexity !== undefined) {
|
|
1471
|
+
wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
|
|
1472
|
+
}
|
|
1473
|
+
workItemSynced = true;
|
|
1476
1474
|
}
|
|
1477
|
-
|
|
1478
|
-
workItemSynced = true;
|
|
1479
|
-
}
|
|
1475
|
+
});
|
|
1480
1476
|
} catch (e) { console.error('work item sync:', e.message); }
|
|
1481
1477
|
}
|
|
1482
1478
|
|
|
@@ -1500,26 +1496,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1500
1496
|
|
|
1501
1497
|
// Also remove any materialized work item for this plan item
|
|
1502
1498
|
let cancelled = false;
|
|
1499
|
+
const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
1503
1500
|
for (const proj of PROJECTS) {
|
|
1504
|
-
|
|
1501
|
+
allWiPaths.push(shared.projectWorkItemsPath(proj));
|
|
1502
|
+
}
|
|
1503
|
+
for (const wiPath of allWiPaths) {
|
|
1505
1504
|
try {
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
}
|
|
1505
|
+
mutateWorkItems(wiPath, items => {
|
|
1506
|
+
const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
|
|
1507
|
+
if (filtered.length < items.length) {
|
|
1508
|
+
cancelled = true;
|
|
1509
|
+
return filtered;
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1513
1512
|
} catch (e) { console.error('work item cleanup:', e.message); }
|
|
1514
1513
|
}
|
|
1515
|
-
// Also check central work-items
|
|
1516
|
-
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1517
|
-
try {
|
|
1518
|
-
const items = safeJson(centralPath);
|
|
1519
|
-
const before = items.length;
|
|
1520
|
-
const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
|
|
1521
|
-
if (filtered.length < before) { safeWrite(centralPath, filtered); cancelled = true; }
|
|
1522
|
-
} catch (e) { console.error('central work item cleanup:', e.message); }
|
|
1523
1514
|
|
|
1524
1515
|
// Clean dispatch entries for this item
|
|
1525
1516
|
cleanDispatchEntries(d =>
|
|
@@ -2130,52 +2121,51 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2130
2121
|
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
2131
2122
|
for (const wiPath of wiPaths) {
|
|
2132
2123
|
try {
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2124
|
+
mutateWorkItems(wiPath, items => {
|
|
2125
|
+
let changed = false;
|
|
2126
|
+
for (const w of items) {
|
|
2127
|
+
if (w.sourcePlan !== body.file) continue;
|
|
2128
|
+
// Keep completed items as-is, reset everything else to pending.
|
|
2129
|
+
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
2130
|
+
|
|
2131
|
+
if (w.status === WI_STATUS.DISPATCHED) {
|
|
2132
|
+
// Kill the agent working on this item, if any.
|
|
2133
|
+
const activeEntry = (dispatch.active || []).find(d => d.meta?.item?.id === w.id || d.meta?.dispatchKey?.includes(w.id));
|
|
2134
|
+
if (activeEntry) {
|
|
2135
|
+
const statusPath = path.join(MINIONS_DIR, 'agents', activeEntry.agent, 'status.json');
|
|
2136
|
+
try {
|
|
2137
|
+
const agentStatus = JSON.parse(safeRead(statusPath) || '{}');
|
|
2138
|
+
if (agentStatus.pid) {
|
|
2139
|
+
try {
|
|
2140
|
+
const safePid = shared.validatePid(agentStatus.pid);
|
|
2141
|
+
if (process.platform === 'win32') {
|
|
2142
|
+
require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
|
|
2143
|
+
} else {
|
|
2144
|
+
process.kill(safePid, 'SIGTERM');
|
|
2145
|
+
}
|
|
2146
|
+
} catch { /* process may be dead or invalid PID */ }
|
|
2147
|
+
}
|
|
2148
|
+
agentStatus.status = 'idle';
|
|
2149
|
+
delete agentStatus.currentTask;
|
|
2150
|
+
delete agentStatus.dispatched;
|
|
2151
|
+
safeWrite(statusPath, agentStatus);
|
|
2152
|
+
} catch (e) { console.error('agent reset:', e.message); }
|
|
2153
|
+
killedAgents.add(activeEntry.agent);
|
|
2154
|
+
}
|
|
2164
2155
|
}
|
|
2165
|
-
}
|
|
2166
2156
|
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2157
|
+
if (w.status !== WI_STATUS.PAUSED) reset++;
|
|
2158
|
+
w.status = WI_STATUS.PAUSED;
|
|
2159
|
+
w._pausedBy = 'prd-pause';
|
|
2160
|
+
delete w._resumedAt;
|
|
2161
|
+
delete w.dispatched_at;
|
|
2162
|
+
delete w.dispatched_to;
|
|
2163
|
+
delete w.failReason;
|
|
2164
|
+
delete w.failedAt;
|
|
2165
|
+
changed = true;
|
|
2166
|
+
if (w.id) resetItemIds.add(w.id);
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2179
2169
|
} catch (e) { console.error('reset work items:', e.message); }
|
|
2180
2170
|
}
|
|
2181
2171
|
|
|
@@ -2220,13 +2210,15 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2220
2210
|
const config = queries.getConfig();
|
|
2221
2211
|
for (const p of getProjects(config)) {
|
|
2222
2212
|
const projWiPath = projectWorkItemsPath(p);
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2213
|
+
try {
|
|
2214
|
+
mutateWorkItems(projWiPath, items => {
|
|
2215
|
+
const filtered = items.filter(w => {
|
|
2216
|
+
if (w.sourcePlan !== body.file) return true; // different plan, keep
|
|
2217
|
+
return completedStatuses.has(w.status); // keep completed, remove pending/failed
|
|
2218
|
+
});
|
|
2219
|
+
if (filtered.length < items.length) return filtered;
|
|
2220
|
+
});
|
|
2221
|
+
} catch { /* project may not have work items */ }
|
|
2230
2222
|
}
|
|
2231
2223
|
|
|
2232
2224
|
// Delete old PRD — agent will write replacement at same path
|
|
@@ -2234,30 +2226,29 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2234
2226
|
|
|
2235
2227
|
// Queue plan-to-prd regeneration with instructions to preserve completed items
|
|
2236
2228
|
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2237
|
-
let items = [];
|
|
2238
|
-
const existing = safeRead(wiPath);
|
|
2239
|
-
if (existing) { try { items = JSON.parse(existing); } catch {} }
|
|
2240
|
-
|
|
2241
|
-
// Dedup: check if already queued
|
|
2242
|
-
const alreadyQueued = items.find(w =>
|
|
2243
|
-
w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
|
|
2244
|
-
);
|
|
2245
|
-
if (alreadyQueued) return jsonReply(res, 200, { id: alreadyQueued.id, alreadyQueued: true });
|
|
2246
2229
|
|
|
2247
2230
|
const completedContext = completedItems.length > 0
|
|
2248
2231
|
? `\n\n**Previously completed items (preserve their status in the new PRD):**\n${completedItems.map(i => `- ${i.id}: ${i.name} [${i.status}]`).join('\n')}`
|
|
2249
2232
|
: '';
|
|
2250
2233
|
|
|
2251
2234
|
const id = 'W-' + shared.uid();
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2235
|
+
let alreadyQueuedId = null;
|
|
2236
|
+
mutateWorkItems(wiPath, items => {
|
|
2237
|
+
// Dedup: check if already queued
|
|
2238
|
+
const alreadyQueued = items.find(w =>
|
|
2239
|
+
w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
|
|
2240
|
+
);
|
|
2241
|
+
if (alreadyQueued) { alreadyQueuedId = alreadyQueued.id; return; }
|
|
2242
|
+
items.push({
|
|
2243
|
+
id, title: `Regenerate PRD: ${plan.plan_summary || plan.source_plan}`,
|
|
2244
|
+
type: 'plan-to-prd', priority: 'high',
|
|
2245
|
+
description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${body.file}\nRegeneration requested by user after plan revision.${completedContext}`,
|
|
2246
|
+
status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:regenerate',
|
|
2247
|
+
project: plan.project || '', planFile: plan.source_plan,
|
|
2248
|
+
_targetPrdFile: body.file,
|
|
2249
|
+
});
|
|
2259
2250
|
});
|
|
2260
|
-
|
|
2251
|
+
if (alreadyQueuedId) return jsonReply(res, 200, { id: alreadyQueuedId, alreadyQueued: true });
|
|
2261
2252
|
return jsonReply(res, 200, { id, file: plan.source_plan });
|
|
2262
2253
|
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
2263
2254
|
}
|
|
@@ -2334,27 +2325,26 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2334
2325
|
|
|
2335
2326
|
for (const wiInfo of wiPaths) {
|
|
2336
2327
|
try {
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2328
|
+
mutateWorkItems(wiInfo.path, items => {
|
|
2329
|
+
const filtered = [];
|
|
2330
|
+
for (const w of items) {
|
|
2331
|
+
if (w.sourcePlan === body.source) {
|
|
2332
|
+
materializedPlanItemIds.add(w.id);
|
|
2333
|
+
if (w.status === 'pending' || w.status === 'failed') {
|
|
2334
|
+
// Delete — will re-materialize on next tick with updated plan data
|
|
2335
|
+
reset++;
|
|
2336
|
+
deletedItemIds.push(w.id);
|
|
2337
|
+
} else {
|
|
2338
|
+
// dispatched or done — leave alone
|
|
2339
|
+
kept++;
|
|
2340
|
+
filtered.push(w);
|
|
2341
|
+
}
|
|
2346
2342
|
} else {
|
|
2347
|
-
// dispatched or done — leave alone
|
|
2348
|
-
kept++;
|
|
2349
2343
|
filtered.push(w);
|
|
2350
2344
|
}
|
|
2351
|
-
} else {
|
|
2352
|
-
filtered.push(w);
|
|
2353
2345
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
safeWrite(wiInfo.path, filtered);
|
|
2357
|
-
}
|
|
2346
|
+
if (filtered.length < items.length) return filtered;
|
|
2347
|
+
});
|
|
2358
2348
|
} catch (e) { console.error('work item sync:', e.message); }
|
|
2359
2349
|
}
|
|
2360
2350
|
|
|
@@ -2396,13 +2386,13 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2396
2386
|
}
|
|
2397
2387
|
for (const wiPath of wiPaths) {
|
|
2398
2388
|
try {
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
}
|
|
2389
|
+
mutateWorkItems(wiPath, items => {
|
|
2390
|
+
const filtered = items.filter(w => w.sourcePlan !== body.file);
|
|
2391
|
+
if (filtered.length < items.length) {
|
|
2392
|
+
cleaned += items.length - filtered.length;
|
|
2393
|
+
return filtered;
|
|
2394
|
+
}
|
|
2395
|
+
});
|
|
2406
2396
|
} catch (e) { console.error('plan cleanup:', e.message); }
|
|
2407
2397
|
}
|
|
2408
2398
|
|
|
@@ -2417,16 +2407,14 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2417
2407
|
if (prdSourcePlan) {
|
|
2418
2408
|
try {
|
|
2419
2409
|
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
changed = true;
|
|
2410
|
+
mutateWorkItems(centralPath, items => {
|
|
2411
|
+
for (const w of items) {
|
|
2412
|
+
if (w.type === 'plan-to-prd' && w.status === 'done' && w.planFile === prdSourcePlan) {
|
|
2413
|
+
w.status = 'cancelled';
|
|
2414
|
+
w._cancelledBy = 'prd-deleted';
|
|
2415
|
+
}
|
|
2427
2416
|
}
|
|
2428
|
-
}
|
|
2429
|
-
if (changed) safeWrite(centralPath, centralItems);
|
|
2417
|
+
});
|
|
2430
2418
|
} catch (e) { console.error('plan-to-prd cleanup:', e.message); }
|
|
2431
2419
|
}
|
|
2432
2420
|
|
|
@@ -2521,19 +2509,17 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2521
2509
|
|
|
2522
2510
|
// Create a work item to revise the plan
|
|
2523
2511
|
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2524
|
-
let items = [];
|
|
2525
|
-
const existing = safeRead(wiPath);
|
|
2526
|
-
if (existing) { try { items = JSON.parse(existing); } catch {} }
|
|
2527
2512
|
const id = 'W-' + shared.uid();
|
|
2528
|
-
items
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2513
|
+
mutateWorkItems(wiPath, items => {
|
|
2514
|
+
items.push({
|
|
2515
|
+
id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
|
|
2516
|
+
type: 'plan-to-prd', priority: 'high',
|
|
2517
|
+
description: 'Revision requested on plan file: ' + (body.file.endsWith('.json') ? 'prd/' : 'plans/') + body.file + '\n\nFeedback:\n' + body.feedback + '\n\nRevise the plan to address this feedback. Read the existing plan, apply the feedback, and overwrite the file with the updated version. Set status back to "awaiting-approval".',
|
|
2518
|
+
status: 'pending', created: new Date().toISOString(), createdBy: 'dashboard:revision',
|
|
2519
|
+
project: plan.project || '',
|
|
2520
|
+
planFile: body.file,
|
|
2521
|
+
});
|
|
2535
2522
|
});
|
|
2536
|
-
safeWrite(wiPath, items);
|
|
2537
2523
|
return jsonReply(res, 200, { ok: true, status: 'revision-requested', workItemId: id });
|
|
2538
2524
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
2539
2525
|
}
|
|
@@ -2624,22 +2610,23 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2624
2610
|
const deletedItemIds = [];
|
|
2625
2611
|
for (const wiInfo of wiPaths) {
|
|
2626
2612
|
try {
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2613
|
+
mutateWorkItems(wiInfo.path, items => {
|
|
2614
|
+
const filtered = [];
|
|
2615
|
+
for (const w of items) {
|
|
2616
|
+
if (w.sourcePlan === body.source) {
|
|
2617
|
+
if (w.status === 'pending' || w.status === 'failed') {
|
|
2618
|
+
reset++;
|
|
2619
|
+
deletedItemIds.push(w.id);
|
|
2620
|
+
} else {
|
|
2621
|
+
kept++;
|
|
2622
|
+
filtered.push(w);
|
|
2623
|
+
}
|
|
2634
2624
|
} else {
|
|
2635
|
-
kept++;
|
|
2636
2625
|
filtered.push(w);
|
|
2637
2626
|
}
|
|
2638
|
-
} else {
|
|
2639
|
-
filtered.push(w);
|
|
2640
2627
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2628
|
+
if (filtered.length < items.length) return filtered;
|
|
2629
|
+
});
|
|
2643
2630
|
} catch (e) { console.error('work item deletion:', e.message); }
|
|
2644
2631
|
}
|
|
2645
2632
|
for (const itemId of deletedItemIds) {
|
|
@@ -2650,22 +2637,21 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
|
|
|
2650
2637
|
|
|
2651
2638
|
// Step 4: Dispatch plan-to-prd to regenerate PRD from revised plan
|
|
2652
2639
|
const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2653
|
-
let centralItems = [];
|
|
2654
|
-
try { centralItems = JSON.parse(safeRead(centralWiPath) || '[]'); } catch {}
|
|
2655
2640
|
const wiId = 'W-' + shared.uid();
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2641
|
+
mutateWorkItems(centralWiPath, items => {
|
|
2642
|
+
items.push({
|
|
2643
|
+
id: wiId,
|
|
2644
|
+
title: 'Regenerate PRD from revised plan: ' + sourcePlanFile,
|
|
2645
|
+
type: 'plan-to-prd',
|
|
2646
|
+
priority: 'high',
|
|
2647
|
+
description: `The source plan \`${sourcePlanFile}\` has been revised. Convert it into a fresh PRD JSON.\n\nRevision instruction: ${body.instruction}\n\nRead the revised plan, generate updated PRD items (missing_features), and write to \`prd/${body.source}\`. Set status to "approved". Include \`"source_plan": "${sourcePlanFile}"\` in the JSON root.\n\nPreserve items that are already done (status "implemented" or "complete"). Reset or replace items that were pending/failed.`,
|
|
2648
|
+
status: 'pending',
|
|
2649
|
+
created: new Date().toISOString(),
|
|
2650
|
+
createdBy: 'dashboard:revise-and-regenerate',
|
|
2651
|
+
project: prd.project || '',
|
|
2652
|
+
planFile: sourcePlanFile,
|
|
2653
|
+
});
|
|
2667
2654
|
});
|
|
2668
|
-
safeWrite(centralWiPath, centralItems);
|
|
2669
2655
|
|
|
2670
2656
|
return jsonReply(res, 200, {
|
|
2671
2657
|
ok: true,
|
|
@@ -3728,24 +3714,27 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3728
3714
|
const projects = shared.getProjects(CONFIG);
|
|
3729
3715
|
const paths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
3730
3716
|
for (const p of projects) paths.push(shared.projectWorkItemsPath(p));
|
|
3717
|
+
let found = null;
|
|
3731
3718
|
for (const wiPath of paths) {
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
'**Item:** ' + (item.title || id) + '\n' +
|
|
3741
|
-
'**Agent:** ' + agent + '\n' +
|
|
3742
|
-
(comment ? '**Feedback:** ' + comment + '\n' : '');
|
|
3743
|
-
const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', agent + '-feedback-' + new Date().toISOString().slice(0, 10) + '-' + shared.uid().slice(0, 4) + '.md');
|
|
3744
|
-
safeWrite(inboxPath, feedbackNote);
|
|
3745
|
-
invalidateStatusCache();
|
|
3746
|
-
return jsonReply(res, 200, { ok: true });
|
|
3719
|
+
mutateWorkItems(wiPath, items => {
|
|
3720
|
+
const item = items.find(i => i.id === id);
|
|
3721
|
+
if (item && !found) {
|
|
3722
|
+
item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
|
|
3723
|
+
found = { agent: item.dispatched_to || item.agent || 'unknown', title: item.title || id };
|
|
3724
|
+
}
|
|
3725
|
+
});
|
|
3726
|
+
if (found) break;
|
|
3747
3727
|
}
|
|
3748
|
-
return jsonReply(res, 404, { error: 'Work item not found' });
|
|
3728
|
+
if (!found) return jsonReply(res, 404, { error: 'Work item not found' });
|
|
3729
|
+
const feedbackNote = '# Human Feedback on ' + id + '\n\n' +
|
|
3730
|
+
'**Rating:** ' + (rating === 'up' ? '👍 Good' : '👎 Needs improvement') + '\n' +
|
|
3731
|
+
'**Item:** ' + found.title + '\n' +
|
|
3732
|
+
'**Agent:** ' + found.agent + '\n' +
|
|
3733
|
+
(comment ? '**Feedback:** ' + comment + '\n' : '');
|
|
3734
|
+
const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', found.agent + '-feedback-' + new Date().toISOString().slice(0, 10) + '-' + shared.uid().slice(0, 4) + '.md');
|
|
3735
|
+
safeWrite(inboxPath, feedbackNote);
|
|
3736
|
+
invalidateStatusCache();
|
|
3737
|
+
return jsonReply(res, 200, { ok: true });
|
|
3749
3738
|
}},
|
|
3750
3739
|
|
|
3751
3740
|
// Pinned notes
|
package/engine/ado.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const shared = require('./shared');
|
|
8
|
-
const { exec, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS } = shared;
|
|
8
|
+
const { exec, execAsync, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
10
|
const { mutateJsonFileLocked } = shared;
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ function engine() {
|
|
|
21
21
|
let _adoTokenCache = { token: null, expiresAt: 0 };
|
|
22
22
|
let _adoTokenFailedUntil = 0; // backoff: skip azureauth calls until this timestamp
|
|
23
23
|
|
|
24
|
-
function getAdoToken() {
|
|
24
|
+
async function getAdoToken() {
|
|
25
25
|
if (_adoTokenCache.token && Date.now() < _adoTokenCache.expiresAt) {
|
|
26
26
|
return _adoTokenCache.token;
|
|
27
27
|
}
|
|
@@ -30,8 +30,9 @@ function getAdoToken() {
|
|
|
30
30
|
try {
|
|
31
31
|
// azureauth supports multiple --mode flags as an ordered fallback chain:
|
|
32
32
|
// tries IWA (Integrated Windows Auth) first, falls back to broker if unavailable.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Uses execAsync to avoid blocking the event loop on Windows (spawnSync ETIMEDOUT).
|
|
34
|
+
const token = (await execAsync('azureauth ado token --mode iwa --mode broker --output token --timeout 1', {
|
|
35
|
+
timeout: 15000, encoding: 'utf-8', windowsHide: true })).trim();
|
|
35
36
|
if (token && token.startsWith('eyJ')) {
|
|
36
37
|
_adoTokenCache = { token, expiresAt: Date.now() + 30 * 60 * 1000 };
|
|
37
38
|
_adoTokenFailedUntil = 0;
|
|
@@ -56,7 +57,7 @@ async function adoFetch(url, token, _retryCount = 0) {
|
|
|
56
57
|
// Invalidate cached token — it's likely expired
|
|
57
58
|
_adoTokenCache = { token: null, expiresAt: 0 };
|
|
58
59
|
if (_retryCount < MAX_RETRIES) {
|
|
59
|
-
const freshToken = getAdoToken();
|
|
60
|
+
const freshToken = await getAdoToken();
|
|
60
61
|
if (freshToken) {
|
|
61
62
|
log('info', 'ADO token expired mid-session — refreshed and retrying');
|
|
62
63
|
return adoFetch(url, freshToken, _retryCount + 1);
|
|
@@ -132,7 +133,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
132
133
|
// ─── PR Status Polling ───────────────────────────────────────────────────────
|
|
133
134
|
|
|
134
135
|
async function pollPrStatus(config) {
|
|
135
|
-
const token = getAdoToken();
|
|
136
|
+
const token = await getAdoToken();
|
|
136
137
|
if (!token) {
|
|
137
138
|
log('warn', 'Skipping PR status poll — no ADO token available');
|
|
138
139
|
return;
|
|
@@ -287,7 +288,7 @@ async function pollPrStatus(config) {
|
|
|
287
288
|
// ─── Poll Human Comments on PRs ──────────────────────────────────────────────
|
|
288
289
|
|
|
289
290
|
async function pollPrHumanComments(config) {
|
|
290
|
-
const token = getAdoToken();
|
|
291
|
+
const token = await getAdoToken();
|
|
291
292
|
if (!token) return;
|
|
292
293
|
|
|
293
294
|
const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase) => {
|
|
@@ -361,7 +362,7 @@ async function pollPrHumanComments(config) {
|
|
|
361
362
|
* in pull-requests.json, and add them. Matches PRs to work items by branch name.
|
|
362
363
|
*/
|
|
363
364
|
async function reconcilePrs(config) {
|
|
364
|
-
const token = getAdoToken();
|
|
365
|
+
const token = await getAdoToken();
|
|
365
366
|
if (!token) {
|
|
366
367
|
log('warn', 'Skipping PR reconciliation — no ADO token available');
|
|
367
368
|
return;
|
|
@@ -486,19 +487,19 @@ async function reconcilePrs(config) {
|
|
|
486
487
|
}
|
|
487
488
|
|
|
488
489
|
/**
|
|
489
|
-
* Fetch live review status for a single PR from ADO (
|
|
490
|
+
* Fetch live review status for a single PR from ADO (async).
|
|
490
491
|
* Returns 'approved', 'changes-requested', 'waiting', or 'pending'.
|
|
491
492
|
* Returns null if the check fails (token unavailable, API error).
|
|
492
493
|
* Used as a pre-dispatch gate to avoid dispatching reviews for already-approved PRs.
|
|
493
494
|
*/
|
|
494
|
-
function checkLiveReviewStatus(pr, project) {
|
|
495
|
+
async function checkLiveReviewStatus(pr, project) {
|
|
495
496
|
try {
|
|
496
|
-
const token = getAdoToken();
|
|
497
|
+
const token = await getAdoToken();
|
|
497
498
|
if (!token) return null;
|
|
498
499
|
const orgBase = shared.getAdoOrgBase(project);
|
|
499
500
|
const prNum = (pr.id || '').replace(/^PR-/, '');
|
|
500
501
|
const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}?api-version=7.1`;
|
|
501
|
-
const result =
|
|
502
|
+
const result = await execAsync(`curl -s --max-time 4 -H "Authorization: Bearer ${token}" "${url}"`, { encoding: 'utf-8', timeout: 5000, windowsHide: true });
|
|
502
503
|
const prData = JSON.parse(result);
|
|
503
504
|
const votes = (prData.reviewers || []).map(r => r.vote).filter(v => v !== undefined);
|
|
504
505
|
if (votes.length === 0) return 'pending';
|
package/engine/github.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const shared = require('./shared');
|
|
8
|
-
const { exec, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS } = shared;
|
|
8
|
+
const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
|
|
@@ -69,10 +69,10 @@ function resetSlugBackoff(slug) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Run a `gh api` call and parse JSON result. Returns null on failure. */
|
|
72
|
-
function ghApi(endpoint, slug) {
|
|
72
|
+
async function ghApi(endpoint, slug) {
|
|
73
73
|
try {
|
|
74
74
|
const cmd = `gh api "repos/${slug}${endpoint}"`;
|
|
75
|
-
const result =
|
|
75
|
+
const result = await execAsync(cmd, { timeout: 30000, encoding: 'utf-8' });
|
|
76
76
|
return JSON.parse(result);
|
|
77
77
|
} catch (e) {
|
|
78
78
|
log('warn', `GitHub API error (${endpoint}): ${e.message}`);
|
|
@@ -84,8 +84,8 @@ function ghApi(endpoint, slug) {
|
|
|
84
84
|
* Run a `gh api` call with per-slug backoff tracking. Returns null on failure.
|
|
85
85
|
* On success, resets the slug's backoff. On failure, increments it.
|
|
86
86
|
*/
|
|
87
|
-
function ghApiWithBackoff(endpoint, slug) {
|
|
88
|
-
const result = ghApi(endpoint, slug);
|
|
87
|
+
async function ghApiWithBackoff(endpoint, slug) {
|
|
88
|
+
const result = await ghApi(endpoint, slug);
|
|
89
89
|
if (result === null) {
|
|
90
90
|
recordSlugFailure(slug);
|
|
91
91
|
} else {
|
|
@@ -112,7 +112,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
112
112
|
if (activePrs.length === 0) continue;
|
|
113
113
|
|
|
114
114
|
// Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
|
|
115
|
-
const probe = ghApi('', slug);
|
|
115
|
+
const probe = await ghApi('', slug);
|
|
116
116
|
if (probe === null) {
|
|
117
117
|
recordSlugFailure(slug);
|
|
118
118
|
continue;
|
|
@@ -171,7 +171,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
171
171
|
if (updated) {
|
|
172
172
|
// Also update title/author/branch if still placeholder
|
|
173
173
|
if (pr.title.includes('polling...') || pr.agent === 'human') {
|
|
174
|
-
const prData = ghApi(`/pulls/${prNum}`, slug);
|
|
174
|
+
const prData = await ghApi(`/pulls/${prNum}`, slug);
|
|
175
175
|
if (prData) {
|
|
176
176
|
if (pr.title.includes('polling...')) pr.title = (prData.title || pr.title).slice(0, 120);
|
|
177
177
|
if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
|
|
@@ -203,7 +203,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
203
203
|
|
|
204
204
|
async function pollPrStatus(config) {
|
|
205
205
|
const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
|
|
206
|
-
const prData = ghApi(`/pulls/${prNum}`, slug);
|
|
206
|
+
const prData = await ghApi(`/pulls/${prNum}`, slug);
|
|
207
207
|
if (!prData) return false;
|
|
208
208
|
|
|
209
209
|
let updated = false;
|
|
@@ -243,7 +243,7 @@ async function pollPrStatus(config) {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
// Review status from GitHub reviews
|
|
246
|
-
const reviews = ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
246
|
+
const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
247
247
|
if (reviews && Array.isArray(reviews)) {
|
|
248
248
|
// Get latest review per user
|
|
249
249
|
const latestByUser = new Map();
|
|
@@ -306,7 +306,7 @@ async function pollPrStatus(config) {
|
|
|
306
306
|
|
|
307
307
|
// Check status / checks
|
|
308
308
|
if (prData.state === 'open' && prData.head?.sha) {
|
|
309
|
-
const checksData = ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
|
|
309
|
+
const checksData = await ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
|
|
310
310
|
if (checksData && checksData.check_runs) {
|
|
311
311
|
const runs = checksData.check_runs;
|
|
312
312
|
let buildStatus = 'none';
|
|
@@ -352,11 +352,11 @@ async function pollPrStatus(config) {
|
|
|
352
352
|
async function pollPrHumanComments(config) {
|
|
353
353
|
const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
|
|
354
354
|
// Get issue comments (general PR comments)
|
|
355
|
-
const comments = ghApi(`/issues/${prNum}/comments`, slug);
|
|
355
|
+
const comments = await ghApi(`/issues/${prNum}/comments`, slug);
|
|
356
356
|
if (!comments || !Array.isArray(comments)) return false;
|
|
357
357
|
|
|
358
358
|
// Also get review comments (inline code comments)
|
|
359
|
-
const reviewComments = ghApi(`/pulls/${prNum}/comments`, slug);
|
|
359
|
+
const reviewComments = await ghApi(`/pulls/${prNum}/comments`, slug);
|
|
360
360
|
const allComments = [
|
|
361
361
|
...(comments || []).map(c => ({ ...c, _type: 'issue' })),
|
|
362
362
|
...(Array.isArray(reviewComments) ? reviewComments : []).map(c => ({ ...c, _type: 'review' }))
|
|
@@ -438,7 +438,7 @@ async function reconcilePrs(config) {
|
|
|
438
438
|
if (isSlugInBackoff(slug)) continue;
|
|
439
439
|
|
|
440
440
|
// Fetch open PRs
|
|
441
|
-
const prsData = ghApi('/pulls?state=open&per_page=100', slug);
|
|
441
|
+
const prsData = await ghApi('/pulls?state=open&per_page=100', slug);
|
|
442
442
|
if (!prsData || !Array.isArray(prsData)) {
|
|
443
443
|
recordSlugFailure(slug);
|
|
444
444
|
continue;
|
|
@@ -541,12 +541,12 @@ async function reconcilePrs(config) {
|
|
|
541
541
|
* Fetch live review status for a single PR from GitHub. Returns 'approved', 'changes-requested',
|
|
542
542
|
* 'waiting', or 'pending'. Returns null if the check fails.
|
|
543
543
|
*/
|
|
544
|
-
function checkLiveReviewStatus(pr, project) {
|
|
544
|
+
async function checkLiveReviewStatus(pr, project) {
|
|
545
545
|
try {
|
|
546
546
|
const slug = getRepoSlug(project);
|
|
547
547
|
if (!slug) return null;
|
|
548
548
|
const prNum = (pr.id || '').replace(/^PR-/, '');
|
|
549
|
-
const reviews = ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
549
|
+
const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
550
550
|
if (!reviews || !Array.isArray(reviews)) return null;
|
|
551
551
|
const latestByUser = new Map();
|
|
552
552
|
for (const r of reviews) {
|
package/engine/lifecycle.js
CHANGED
|
@@ -689,7 +689,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
689
689
|
|
|
690
690
|
// ─── Post-Completion Hooks ──────────────────────────────────────────────────
|
|
691
691
|
|
|
692
|
-
function updatePrAfterReview(agentId, pr, project, config) {
|
|
692
|
+
async function updatePrAfterReview(agentId, pr, project, config) {
|
|
693
693
|
|
|
694
694
|
if (!pr?.id) return;
|
|
695
695
|
|
|
@@ -707,7 +707,7 @@ function updatePrAfterReview(agentId, pr, project, config) {
|
|
|
707
707
|
const checkFn = host === 'github'
|
|
708
708
|
? require('./github').checkLiveReviewStatus
|
|
709
709
|
: require('./ado').checkLiveReviewStatus;
|
|
710
|
-
const liveStatus = checkFn(pr, projectObj);
|
|
710
|
+
const liveStatus = await checkFn(pr, projectObj);
|
|
711
711
|
// Use live status only if it's a decisive verdict (not 'pending' — review may not have propagated yet)
|
|
712
712
|
if (liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
|
|
713
713
|
}
|
|
@@ -1142,7 +1142,7 @@ function handleDecompositionResult(stdout, meta, config) {
|
|
|
1142
1142
|
return 0;
|
|
1143
1143
|
}
|
|
1144
1144
|
|
|
1145
|
-
function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
1145
|
+
async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
1146
1146
|
|
|
1147
1147
|
const type = dispatchItem.type;
|
|
1148
1148
|
const meta = dispatchItem.meta;
|
|
@@ -1319,7 +1319,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1319
1319
|
}
|
|
1320
1320
|
}
|
|
1321
1321
|
|
|
1322
|
-
if (type === WORK_TYPE.REVIEW) updatePrAfterReview(agentId, meta?.pr, meta?.project, config);
|
|
1322
|
+
if (type === WORK_TYPE.REVIEW) await updatePrAfterReview(agentId, meta?.pr, meta?.project, config);
|
|
1323
1323
|
if (type === WORK_TYPE.FIX) updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
|
|
1324
1324
|
checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
|
|
1325
1325
|
if (effectiveSuccess) {
|
package/engine/shared.js
CHANGED
|
@@ -304,7 +304,7 @@ function writeToInbox(agentId, slug, content, _inboxDir) {
|
|
|
304
304
|
// ── Process Spawning ────────────────────────────────────────────────────────
|
|
305
305
|
// All child process calls go through these to ensure windowsHide: true
|
|
306
306
|
|
|
307
|
-
const { execSync: _execSync, spawnSync: _spawnSync, spawn: _spawn } = require('child_process');
|
|
307
|
+
const { execSync: _execSync, spawnSync: _spawnSync, spawn: _spawn, exec: _cbExec } = require('child_process');
|
|
308
308
|
|
|
309
309
|
function exec(cmd, opts = {}) {
|
|
310
310
|
return _execSync(cmd, { windowsHide: true, ...opts });
|
|
@@ -322,6 +322,31 @@ function execSilent(cmd, opts = {}) {
|
|
|
322
322
|
return _execSync(cmd, { stdio: 'pipe', windowsHide: true, ...opts });
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Async version of exec() — runs a shell command without blocking the event loop.
|
|
327
|
+
* Returns a Promise that resolves with { stdout, stderr } or rejects on error/timeout.
|
|
328
|
+
* Drop-in replacement for sync `exec()` in async contexts.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} cmd - Shell command to run
|
|
331
|
+
* @param {object} opts - Options (same as child_process.exec: timeout, cwd, encoding, env, etc.)
|
|
332
|
+
* @returns {Promise<string>} stdout (trimmed if encoding is set)
|
|
333
|
+
*/
|
|
334
|
+
function execAsync(cmd, opts = {}) {
|
|
335
|
+
const { timeout, ...rest } = opts;
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
const child = _cbExec(cmd, { windowsHide: true, encoding: 'utf8', ...rest, timeout: timeout || 30000 }, (err, stdout, stderr) => {
|
|
338
|
+
if (err) {
|
|
339
|
+
err.stderr = stderr;
|
|
340
|
+
err.stdout = stdout;
|
|
341
|
+
return reject(err);
|
|
342
|
+
}
|
|
343
|
+
resolve(stdout);
|
|
344
|
+
});
|
|
345
|
+
// Safety: ensure child is killed if parent process exits
|
|
346
|
+
child.unref && child.unref();
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
325
350
|
/**
|
|
326
351
|
* Detect the default branch for a git repo. Tries in order:
|
|
327
352
|
* 1. The configured mainBranch (if it exists as a local or remote ref)
|
|
@@ -787,6 +812,7 @@ module.exports = {
|
|
|
787
812
|
uniquePath,
|
|
788
813
|
writeToInbox,
|
|
789
814
|
exec,
|
|
815
|
+
execAsync,
|
|
790
816
|
execSilent,
|
|
791
817
|
resolveMainBranch,
|
|
792
818
|
run,
|
package/engine/timeout.js
CHANGED
|
@@ -149,8 +149,8 @@ function checkTimeouts(config) {
|
|
|
149
149
|
|
|
150
150
|
completeDispatch(item.id, isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR, 'Completed (detected from output)');
|
|
151
151
|
|
|
152
|
-
// Run post-completion hooks via shared helper
|
|
153
|
-
runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
|
|
152
|
+
// Run post-completion hooks via shared helper (async — fire and forget in timeout context)
|
|
153
|
+
runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config).catch(e => log('warn', 'post-completion hooks: ' + e.message));
|
|
154
154
|
|
|
155
155
|
if (hasProcess) {
|
|
156
156
|
shared.killImmediate(activeProcesses.get(item.id)?.proc);
|
package/engine.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const shared = require('./engine/shared');
|
|
27
|
-
const { exec, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
|
|
27
|
+
const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
|
|
28
28
|
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
|
|
29
29
|
const queries = require('./engine/queries');
|
|
30
30
|
|
|
@@ -141,6 +141,10 @@ const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt
|
|
|
141
141
|
// tempAgents imported from engine/routing.js
|
|
142
142
|
let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
|
|
143
143
|
|
|
144
|
+
// Per-tick cache of refs that failed to fetch — avoids repeating 30s ETIMEDOUT for same missing ref
|
|
145
|
+
// Cleared at the start of each tick cycle (see tickInner)
|
|
146
|
+
const _failedRefCache = new Set();
|
|
147
|
+
|
|
144
148
|
// Resolve dependency plan item IDs to their PR branches
|
|
145
149
|
function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
146
150
|
const results = []; // [{ branch, prId }]
|
|
@@ -171,9 +175,9 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
// Find an existing worktree already checked out on a given branch
|
|
174
|
-
function findExistingWorktree(repoDir, branchName) {
|
|
178
|
+
async function findExistingWorktree(repoDir, branchName) {
|
|
175
179
|
try {
|
|
176
|
-
const out =
|
|
180
|
+
const out = await execAsync(`git worktree list --porcelain`, { cwd: repoDir, timeout: 10000 });
|
|
177
181
|
const branchRef = `branch refs/heads/${branchName}`;
|
|
178
182
|
const lines = out.split('\n');
|
|
179
183
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -215,17 +219,17 @@ function removeStaleIndexLock(rootDir) {
|
|
|
215
219
|
} catch (e) { log('warn', 'git: ' + e.message); }
|
|
216
220
|
}
|
|
217
221
|
|
|
218
|
-
function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
|
|
222
|
+
async function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
|
|
219
223
|
let lastErr = null;
|
|
220
224
|
const retries = Math.max(0, Number(worktreeCreateRetries) || 0);
|
|
221
225
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
222
226
|
try {
|
|
223
227
|
if (attempt > 0) {
|
|
224
|
-
try {
|
|
228
|
+
try { await execAsync('git worktree prune', { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
225
229
|
removeStaleIndexLock(rootDir);
|
|
226
230
|
log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
|
|
227
231
|
}
|
|
228
|
-
|
|
232
|
+
await execAsync(`git worktree add "${worktreePath}" ${args}`, { ...gitOpts, cwd: rootDir });
|
|
229
233
|
return;
|
|
230
234
|
} catch (err) {
|
|
231
235
|
lastErr = err;
|
|
@@ -235,14 +239,14 @@ function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetr
|
|
|
235
239
|
if (lastErr) throw lastErr;
|
|
236
240
|
}
|
|
237
241
|
|
|
238
|
-
function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
|
|
242
|
+
async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
|
|
239
243
|
if (!branchName) return false;
|
|
240
|
-
const existingWt = findExistingWorktree(rootDir, branchName);
|
|
244
|
+
const existingWt = await findExistingWorktree(rootDir, branchName);
|
|
241
245
|
if (existingWt && fs.existsSync(existingWt)) return true;
|
|
242
246
|
if (!fs.existsSync(worktreePath)) return false;
|
|
243
247
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
await execAsync(`git -C "${worktreePath}" rev-parse --is-inside-work-tree`, { ...gitOpts, timeout: 10000 });
|
|
249
|
+
await execAsync(`git -C "${worktreePath}" rev-parse --abbrev-ref HEAD`, { ...gitOpts, timeout: 10000 });
|
|
246
250
|
log('warn', `Recovered partially-created worktree for ${branchName} at ${worktreePath}`);
|
|
247
251
|
return true;
|
|
248
252
|
} catch {
|
|
@@ -250,7 +254,7 @@ function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
|
|
|
250
254
|
}
|
|
251
255
|
}
|
|
252
256
|
|
|
253
|
-
function spawnAgent(dispatchItem, config) {
|
|
257
|
+
async function spawnAgent(dispatchItem, config) {
|
|
254
258
|
const { id, agent: agentId, prompt: taskPrompt, type, meta } = dispatchItem;
|
|
255
259
|
const claudeConfig = config.claude || {};
|
|
256
260
|
const engineConfig = config.engine || {};
|
|
@@ -279,12 +283,12 @@ function spawnAgent(dispatchItem, config) {
|
|
|
279
283
|
worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
|
|
280
284
|
|
|
281
285
|
// If branch is already checked out in an existing worktree, reuse it
|
|
282
|
-
const existingWt = findExistingWorktree(rootDir, branchName);
|
|
286
|
+
const existingWt = await findExistingWorktree(rootDir, branchName);
|
|
283
287
|
if (existingWt) {
|
|
284
288
|
worktreePath = existingWt;
|
|
285
289
|
log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
|
|
286
|
-
try {
|
|
287
|
-
try {
|
|
290
|
+
try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
291
|
+
try { await execAsync(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
288
292
|
} else if (['meeting', 'ask', 'explore', 'plan-to-prd', 'plan'].includes(type)) {
|
|
289
293
|
// Read-only tasks — no worktree needed, run in rootDir
|
|
290
294
|
log('info', `${type}: read-only task, no worktree needed — running in rootDir`);
|
|
@@ -295,18 +299,18 @@ function spawnAgent(dispatchItem, config) {
|
|
|
295
299
|
if (!fs.existsSync(worktreePath)) {
|
|
296
300
|
const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
|
|
297
301
|
// Prune stale worktree entries before creating (handles leftover entries from crashed runs)
|
|
298
|
-
try {
|
|
302
|
+
try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
299
303
|
// Remove stale index.lock before creating worktree (Windows crashes can leave this behind)
|
|
300
304
|
removeStaleIndexLock(rootDir);
|
|
301
305
|
|
|
302
306
|
if (isSharedBranch) {
|
|
303
307
|
log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
|
|
304
|
-
try {
|
|
308
|
+
try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
305
309
|
try {
|
|
306
|
-
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
310
|
+
await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
307
311
|
} catch (eShared) {
|
|
308
312
|
if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
|
|
309
|
-
const existingWtPath = findExistingWorktree(rootDir, branchName);
|
|
313
|
+
const existingWtPath = await findExistingWorktree(rootDir, branchName);
|
|
310
314
|
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
311
315
|
log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
|
|
312
316
|
worktreePath = existingWtPath;
|
|
@@ -315,42 +319,42 @@ function spawnAgent(dispatchItem, config) {
|
|
|
315
319
|
// Branch doesn't exist yet (first item in plan) — create it from main
|
|
316
320
|
const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
|
|
317
321
|
log('info', `Shared branch ${branchName} not found — creating from ${mainRef}`);
|
|
318
|
-
runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
322
|
+
await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
319
323
|
} else { throw eShared; }
|
|
320
324
|
}
|
|
321
325
|
} else {
|
|
322
326
|
log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
|
|
323
327
|
const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
|
|
324
328
|
try {
|
|
325
|
-
runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
329
|
+
await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
326
330
|
} catch (e1) {
|
|
327
331
|
const branchExists = e1.message?.includes('already exists');
|
|
328
332
|
log('warn', `Worktree -b failed for ${branchName}: ${e1.message?.split('\n')[0]}`);
|
|
329
333
|
if (!branchExists) {
|
|
330
334
|
// Transient error (lock, timeout) — prune, clean, and retry -b once more
|
|
331
335
|
log('info', `Retrying -b create after prune for ${branchName}`);
|
|
332
|
-
try {
|
|
336
|
+
try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
|
|
333
337
|
removeStaleIndexLock(rootDir);
|
|
334
338
|
// Clean up partial worktree directory from failed attempt
|
|
335
339
|
try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* optional */ }
|
|
336
340
|
try {
|
|
337
|
-
runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, 0);
|
|
341
|
+
await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, 0);
|
|
338
342
|
} catch (e1b) {
|
|
339
343
|
log('error', `Worktree -b retry also failed for ${branchName}: ${e1b.message?.split('\n')[0]}`);
|
|
340
344
|
throw e1b;
|
|
341
345
|
}
|
|
342
346
|
} else {
|
|
343
347
|
// Branch already exists — try checkout without -b
|
|
344
|
-
try {
|
|
348
|
+
try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
345
349
|
try {
|
|
346
|
-
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
350
|
+
await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
347
351
|
log('info', `Reusing existing branch: ${branchName}`);
|
|
348
352
|
} catch (e2) {
|
|
349
353
|
// "already checked out" or "already used by worktree" — find and reuse or recover
|
|
350
354
|
const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
|
|
351
355
|
|| e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
|
|
352
356
|
if (alreadyUsed) {
|
|
353
|
-
const existingWtPath = findExistingWorktree(rootDir, branchName);
|
|
357
|
+
const existingWtPath = await findExistingWorktree(rootDir, branchName);
|
|
354
358
|
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
355
359
|
// Bug fix: read dispatch under file lock so check-and-act is atomic
|
|
356
360
|
let activelyUsed = false;
|
|
@@ -369,12 +373,12 @@ function spawnAgent(dispatchItem, config) {
|
|
|
369
373
|
worktreePath = existingWtPath;
|
|
370
374
|
} else if (existingWtPath && !fs.existsSync(existingWtPath)) {
|
|
371
375
|
log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
|
|
372
|
-
try {
|
|
373
|
-
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
376
|
+
try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
377
|
+
await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
374
378
|
log('info', `Recovered worktree for ${branchName} after stale entry prune`);
|
|
375
379
|
} else {
|
|
376
|
-
try {
|
|
377
|
-
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
380
|
+
try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
381
|
+
await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
378
382
|
}
|
|
379
383
|
} else {
|
|
380
384
|
throw e2;
|
|
@@ -385,10 +389,10 @@ function spawnAgent(dispatchItem, config) {
|
|
|
385
389
|
}
|
|
386
390
|
} else if (meta?.branchStrategy === 'shared-branch') {
|
|
387
391
|
log('info', `Pulling latest on shared branch ${branchName}`);
|
|
388
|
-
try {
|
|
392
|
+
try { await execAsync(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: worktreePath }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
389
393
|
}
|
|
390
394
|
} catch (err) {
|
|
391
|
-
if (recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
|
|
395
|
+
if (await recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
|
|
392
396
|
cwd = worktreePath;
|
|
393
397
|
log('warn', `Proceeding with recovered worktree after add failure for ${branchName}`);
|
|
394
398
|
} else {
|
|
@@ -407,11 +411,17 @@ function spawnAgent(dispatchItem, config) {
|
|
|
407
411
|
try {
|
|
408
412
|
const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
|
|
409
413
|
for (const { branch: depBranch, prId } of depBranches) {
|
|
414
|
+
// Skip refs already known to be missing this tick (avoids repeated 30s ETIMEDOUT)
|
|
415
|
+
if (_failedRefCache.has(depBranch)) {
|
|
416
|
+
log('warn', `Skipping dependency ${depBranch} — already failed to fetch this tick`);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
410
419
|
try {
|
|
411
|
-
|
|
412
|
-
|
|
420
|
+
await execAsync(`git fetch origin "${depBranch}"`, { ..._gitOpts, cwd: rootDir });
|
|
421
|
+
await execAsync(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
|
|
413
422
|
log('info', `Merged dependency branch ${depBranch} (${prId}) into worktree ${branchName}`);
|
|
414
423
|
} catch (mergeErr) {
|
|
424
|
+
_failedRefCache.add(depBranch);
|
|
415
425
|
log('warn', `Failed to merge dependency ${depBranch} into ${branchName}: ${mergeErr.message}`);
|
|
416
426
|
}
|
|
417
427
|
}
|
|
@@ -704,7 +714,7 @@ function spawnAgent(dispatchItem, config) {
|
|
|
704
714
|
}
|
|
705
715
|
|
|
706
716
|
// Parse output and run all post-completion hooks
|
|
707
|
-
const { resultSummary, autoRecovered } = runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
|
|
717
|
+
const { resultSummary, autoRecovered } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
|
|
708
718
|
|
|
709
719
|
// Move from active to completed in dispatch (single source of truth for agent status)
|
|
710
720
|
// autoRecovered: agent failed (e.g. heartbeat timeout) but created PRs — treat as success
|
|
@@ -2351,6 +2361,7 @@ async function tickInner() {
|
|
|
2351
2361
|
|
|
2352
2362
|
const config = getConfig();
|
|
2353
2363
|
tickCount++;
|
|
2364
|
+
_failedRefCache.clear(); // Reset per-tick failed-ref cache
|
|
2354
2365
|
|
|
2355
2366
|
// Helper: run a phase, log + continue on error
|
|
2356
2367
|
const safe = (label, fn) => { try { fn(); } catch (e) { log('warn', `${label}: ${e.message}`); } };
|
|
@@ -2563,7 +2574,7 @@ async function tickInner() {
|
|
|
2563
2574
|
for (const item of toDispatch) {
|
|
2564
2575
|
if (!dispatched.has(item.id)) {
|
|
2565
2576
|
let proc;
|
|
2566
|
-
try { proc = spawnAgent(item, config); } catch (spawnErr) {
|
|
2577
|
+
try { proc = await spawnAgent(item, config); } catch (spawnErr) {
|
|
2567
2578
|
log('error', `spawnAgent exception for ${item.id}: ${spawnErr.message}`);
|
|
2568
2579
|
proc = null;
|
|
2569
2580
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.533",
|
|
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"
|