@yemi33/minions 0.1.2028 → 0.1.2029
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/dashboard/js/refresh.js +9 -2
- package/dashboard/js/render-pipelines.js +40 -5
- package/dashboard/js/render-schedules.js +29 -7
- package/dashboard/js/render-watches.js +30 -5
- package/dashboard.js +49 -14
- package/engine/lifecycle.js +104 -0
- package/engine/qa-runs.js +42 -0
- package/engine/queries.js +26 -39
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
// Registry: add one line per page. Counter returns a value; badge shows when value increases.
|
|
5
5
|
const _pageCounters = {
|
|
6
6
|
home: function(d) { return (d.dispatch?.completed || []).length; },
|
|
7
|
-
work:
|
|
7
|
+
// work signature: total + in-flight count. State transitions (pending /
|
|
8
|
+
// dispatched → done) flip the dot; per-item title/updatedAt churn doesn't (F9/S4).
|
|
9
|
+
work: function(d) { return (d.workItems || []).length + '|' + (d.workItems || []).filter(function(w) { return w.status === 'pending' || w.status === 'dispatched'; }).length; },
|
|
8
10
|
plans: function(d) { return (d.prdProgress?.complete || 0) + '|' + (d.plans || []).length + '|' + (d.plans || []).map(function(p) { return p.status || ''; }).join(','); },
|
|
9
11
|
prs: function(d) { return (d.pullRequests || []).length + '|' + (d.pullRequests || []).filter(function(p) { return p.status === 'merged'; }).length; },
|
|
10
12
|
inbox: function(d) { return (d.inbox || []).length + '|' + (d.notes?.content || '').length; },
|
|
11
|
-
|
|
13
|
+
// watches signature: count + max(last_triggered). Dot fires when any watch
|
|
14
|
+
// triggers or the count changes; triggerCount removed because it advances
|
|
15
|
+
// on the same event as last_triggered (F9/S4).
|
|
16
|
+
watches: function(d) { return (d.watches || []).length + '|' + (d.watches || []).reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0); },
|
|
12
17
|
meetings: function(d) { return (d.meetings || []).length + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
|
|
13
18
|
pipelines: function(d) { return (d.pipelines || []).length + '|' + (d.pipelines || []).reduce(function(s, p) { return s + (p.runs || []).length; }, 0); },
|
|
14
19
|
schedule: function(d) { return (d.schedules || []).length; },
|
|
20
|
+
// tools signature: skills count + mcp servers count.
|
|
21
|
+
tools: function(d) { return (d.skills || []).length + '|' + (d.mcpServers || []).length; },
|
|
15
22
|
engine: function(d) { return (d.dispatch?.completed || []).filter(function(c) { return c.result === 'error'; }).length; },
|
|
16
23
|
qa: function(d) { return (d.qaRuns?.total || 0) + '|' + (d.qaRuns?.sig || ''); },
|
|
17
24
|
};
|
|
@@ -6,6 +6,9 @@ let _pipelinePollInterval = null;
|
|
|
6
6
|
const PIPELINE_PER_PAGE = 25;
|
|
7
7
|
let _pipelinePage = 0;
|
|
8
8
|
let _pipelineTotalPages = 1;
|
|
9
|
+
// F7: pipeline ids whose enable/disable POST is in flight — used to render
|
|
10
|
+
// the toggle button as `disabled` so a second click can't race the first.
|
|
11
|
+
const _pipelineToggleInFlight = new Set();
|
|
9
12
|
function _stopPipelinePoll() { if (_pipelinePollInterval) { clearInterval(_pipelinePollInterval); _pipelinePollInterval = null; } _pipelinePollId = null; }
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -389,7 +392,7 @@ function openPipelineDetail(id) {
|
|
|
389
392
|
'<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--yellow);border-color:var(--yellow)" onclick="_retriggerPipeline(\'' + escHtml(id) + '\',this)">Retrigger</button>'
|
|
390
393
|
: '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_triggerPipeline(\'' + escHtml(id) + '\',this)">Run Now</button>') +
|
|
391
394
|
'<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--blue);border-color:var(--blue)" onclick="openEditPipelineModal(\'' + escHtml(id) + '\')">Edit</button>' +
|
|
392
|
-
'<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" onclick="_togglePipelineEnabled(\'' + escHtml(id) + '\',' + !p.enabled + ',this)">' + (p.enabled !== false ? 'Disable' : 'Enable') + '</button>' +
|
|
395
|
+
'<button class="pr-pager-btn' + (_pipelineToggleInFlight.has(id) ? ' disabled' : '') + '" style="font-size:9px;padding:2px 8px" onclick="_togglePipelineEnabled(\'' + escHtml(id) + '\',' + !p.enabled + ',this)">' + (p.enabled !== false ? 'Disable' : 'Enable') + '</button>' +
|
|
393
396
|
'<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red);border-color:var(--red)" onclick="_deletePipelineConfirm(\'' + escHtml(id) + '\')">Delete</button>' +
|
|
394
397
|
'</div>' +
|
|
395
398
|
'</div>';
|
|
@@ -566,14 +569,46 @@ async function _retriggerPipeline(id, btn) {
|
|
|
566
569
|
}
|
|
567
570
|
|
|
568
571
|
async function _togglePipelineEnabled(id, enabled, btn) {
|
|
572
|
+
if (_pipelineToggleInFlight.has(id)) return; // F7: prevent double-fires
|
|
573
|
+
// F7: optimistic — flip cached `enabled` and re-render both the pipeline
|
|
574
|
+
// card list (so the DISABLED badge appears/clears) and the detail modal
|
|
575
|
+
// (so the toggle button label switches) immediately. Rollback on failure.
|
|
576
|
+
var p = _pipelinesData.find(function(x) { return x.id === id; });
|
|
577
|
+
var prevEnabled = p ? p.enabled : undefined;
|
|
578
|
+
if (p) p.enabled = enabled;
|
|
579
|
+
_pipelineToggleInFlight.add(id);
|
|
569
580
|
if (btn) { btn.textContent = enabled ? 'Enabling...' : 'Disabling...'; btn.style.pointerEvents = 'none'; }
|
|
581
|
+
renderPipelines(_pipelinesData);
|
|
582
|
+
if (document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id) {
|
|
583
|
+
openPipelineDetail(id);
|
|
584
|
+
}
|
|
570
585
|
showToast('cmd-toast', enabled ? 'Pipeline enabled' : 'Pipeline disabled', true);
|
|
571
586
|
try {
|
|
572
587
|
var res = await fetch('/api/pipelines/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, enabled: enabled }) });
|
|
573
|
-
if (res.ok) {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
588
|
+
if (res.ok) {
|
|
589
|
+
_pipelineToggleInFlight.delete(id);
|
|
590
|
+
renderPipelines(_pipelinesData);
|
|
591
|
+
refresh();
|
|
592
|
+
await _refreshPipelineDetail(id);
|
|
593
|
+
} else {
|
|
594
|
+
if (p) p.enabled = prevEnabled;
|
|
595
|
+
_pipelineToggleInFlight.delete(id);
|
|
596
|
+
renderPipelines(_pipelinesData);
|
|
597
|
+
if (document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id) {
|
|
598
|
+
openPipelineDetail(id);
|
|
599
|
+
}
|
|
600
|
+
showToast('cmd-toast', 'Failed to ' + (enabled ? 'enable' : 'disable') + ' pipeline', false);
|
|
601
|
+
}
|
|
602
|
+
} catch (e) {
|
|
603
|
+
if (p) p.enabled = prevEnabled;
|
|
604
|
+
_pipelineToggleInFlight.delete(id);
|
|
605
|
+
renderPipelines(_pipelinesData);
|
|
606
|
+
if (document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id) {
|
|
607
|
+
openPipelineDetail(id);
|
|
608
|
+
}
|
|
609
|
+
showToast('cmd-toast', 'Error: ' + e.message, false);
|
|
610
|
+
}
|
|
611
|
+
if (btn) { btn.style.pointerEvents = ''; }
|
|
577
612
|
}
|
|
578
613
|
|
|
579
614
|
async function _continuePipeline(id, stageId, btn) {
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
const _DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
6
6
|
const _DAY_NAMES = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays'];
|
|
7
7
|
|
|
8
|
+
// F7: schedule ids whose enable/disable POST is in flight — used to render
|
|
9
|
+
// the toggle button as `disabled` so a second click can't race the first.
|
|
10
|
+
const _schedToggleInFlight = new Set();
|
|
11
|
+
|
|
8
12
|
function _formatCronDowList(days, timeStr, fallback) {
|
|
9
13
|
const normalized = [...new Set(days)].sort((a, b) => a - b);
|
|
10
14
|
const key = normalized.join(',');
|
|
@@ -346,7 +350,7 @@ function renderSchedules(schedules) {
|
|
|
346
350
|
'<td><span class="pr-date">' + escHtml(lastRun) + '</span></td>' +
|
|
347
351
|
'<td style="white-space:nowrap">' +
|
|
348
352
|
'<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();runScheduleNow(\'' + escHtml(s.id) + '\',this)" title="Run now">Run now</button>' +
|
|
349
|
-
'<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';border-color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';margin-right:4px" onclick="event.stopPropagation();toggleScheduleEnabled(\'' + escHtml(s.id) + '\',' + !s.enabled + ')" title="' + (s.enabled ? 'Disable' : 'Enable') + '">' + (s.enabled ? '⏸' : '▶') + '</button>' +
|
|
353
|
+
'<button class="pr-pager-btn' + (_schedToggleInFlight.has(s.id) ? ' disabled' : '') + '" style="font-size:9px;padding:1px 6px;color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';border-color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';margin-right:4px" onclick="event.stopPropagation();toggleScheduleEnabled(\'' + escHtml(s.id) + '\',' + !s.enabled + ')" title="' + (s.enabled ? 'Disable' : 'Enable') + '">' + (s.enabled ? '⏸' : '▶') + '</button>' +
|
|
350
354
|
'<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();openEditScheduleModal(\'' + escHtml(s.id) + '\')" title="Edit">✎</button>' +
|
|
351
355
|
'<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteSchedule(\'' + escHtml(s.id) + '\')" title="Delete">✕</button>' +
|
|
352
356
|
'</td>' +
|
|
@@ -574,19 +578,37 @@ function _findSchedRow(id) {
|
|
|
574
578
|
}
|
|
575
579
|
|
|
576
580
|
async function toggleScheduleEnabled(id, enabled) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
581
|
+
if (_schedToggleInFlight.has(id)) return; // F7: prevent double-fires
|
|
582
|
+
// F7: optimistic — flip cached `enabled` on the schedule + re-render so the
|
|
583
|
+
// badge AND the toggle button switch synchronously. Rollback on failure.
|
|
584
|
+
const list = window._lastSchedules || [];
|
|
585
|
+
const s = list.find(x => x.id === id);
|
|
586
|
+
const prevEnabled = s ? s.enabled : undefined;
|
|
587
|
+
if (s) s.enabled = enabled;
|
|
588
|
+
_schedToggleInFlight.add(id);
|
|
589
|
+
renderSchedules(list);
|
|
580
590
|
try {
|
|
581
591
|
const res = await fetch('/api/schedules/update', {
|
|
582
592
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
583
593
|
body: JSON.stringify({ id, enabled })
|
|
584
594
|
});
|
|
585
|
-
if (res.ok) {
|
|
595
|
+
if (res.ok) {
|
|
596
|
+
_schedToggleInFlight.delete(id);
|
|
597
|
+
renderSchedules(list);
|
|
598
|
+
refresh();
|
|
599
|
+
} else {
|
|
586
600
|
const d = await res.json().catch(() => ({}));
|
|
587
|
-
|
|
601
|
+
if (s) s.enabled = prevEnabled;
|
|
602
|
+
_schedToggleInFlight.delete(id);
|
|
603
|
+
renderSchedules(list);
|
|
604
|
+
showToast('cmd-toast', 'Toggle failed: ' + (d.error || 'unknown'), false);
|
|
588
605
|
}
|
|
589
|
-
} catch (e) {
|
|
606
|
+
} catch (e) {
|
|
607
|
+
if (s) s.enabled = prevEnabled;
|
|
608
|
+
_schedToggleInFlight.delete(id);
|
|
609
|
+
renderSchedules(list);
|
|
610
|
+
showToast('cmd-toast', 'Toggle error: ' + e.message, false);
|
|
611
|
+
}
|
|
590
612
|
}
|
|
591
613
|
|
|
592
614
|
async function runScheduleNow(id, btn) {
|
|
@@ -9,6 +9,10 @@ const _WATCH_STATUS_BADGES = {
|
|
|
9
9
|
expired: '<span class="pr-badge" style="background:rgba(139,148,158,0.15);color:var(--muted);border-color:var(--muted)">expired</span>',
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
// F7: ids whose pause/resume POST is in flight — used to render the toggle
|
|
13
|
+
// button as `disabled` so a second click can't race the first request.
|
|
14
|
+
const _watchToggleInFlight = new Set();
|
|
15
|
+
|
|
12
16
|
// Cache of target types fetched from /api/watches/target-types. Populated on
|
|
13
17
|
// first modal open and refreshed per modal show. Each entry:
|
|
14
18
|
// { value, label, conditions: [...], description }
|
|
@@ -158,11 +162,12 @@ function renderWatches(watchesData) {
|
|
|
158
162
|
'<td><span class="pr-date">' + escHtml(lastChecked) + '</span></td>' +
|
|
159
163
|
'<td style="white-space:nowrap">';
|
|
160
164
|
|
|
161
|
-
// Pause/Resume button
|
|
165
|
+
// Pause/Resume button (F7: marks as `disabled` while POST in flight)
|
|
166
|
+
var pauseBusyCls = _watchToggleInFlight.has(w.id) ? ' disabled' : '';
|
|
162
167
|
if (w.status === 'active') {
|
|
163
|
-
html += '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',true)" title="Pause">⏸</button>';
|
|
168
|
+
html += '<button class="pr-pager-btn' + pauseBusyCls + '" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',true)" title="Pause">⏸</button>';
|
|
164
169
|
} else if (w.status === 'paused') {
|
|
165
|
-
html += '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',false)" title="Resume">▶</button>';
|
|
170
|
+
html += '<button class="pr-pager-btn' + pauseBusyCls + '" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',false)" title="Resume">▶</button>';
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
// Delete button
|
|
@@ -343,7 +348,16 @@ function openWatchDetail(id) {
|
|
|
343
348
|
// ─── CRUD Actions ───────────────────────────────────────────────────────────
|
|
344
349
|
|
|
345
350
|
function toggleWatchPause(id, pause) {
|
|
351
|
+
if (_watchToggleInFlight.has(id)) return; // F7: prevent double-fires
|
|
346
352
|
var newStatus = pause ? 'paused' : 'active';
|
|
353
|
+
var list = window._lastWatches || [];
|
|
354
|
+
var w = list.find(function(x) { return x.id === id; });
|
|
355
|
+
var prevStatus = w ? w.status : null;
|
|
356
|
+
// F7: optimistic — flip cached status + re-render so the badge AND the
|
|
357
|
+
// pause/resume action button switch synchronously. Rollback on failure.
|
|
358
|
+
if (w) { w.status = newStatus; }
|
|
359
|
+
_watchToggleInFlight.add(id);
|
|
360
|
+
renderWatches(list);
|
|
347
361
|
showToast('cmd-toast', (pause ? 'Pausing' : 'Resuming') + ' watch...', true);
|
|
348
362
|
fetch('/api/watches/update', {
|
|
349
363
|
method: 'POST',
|
|
@@ -351,9 +365,20 @@ function toggleWatchPause(id, pause) {
|
|
|
351
365
|
body: JSON.stringify({ id: id, status: newStatus })
|
|
352
366
|
}).then(async function(res) {
|
|
353
367
|
var data = await res.json().catch(function() { return {}; });
|
|
354
|
-
if (!res.ok || data.error)
|
|
355
|
-
|
|
368
|
+
if (!res.ok || data.error) {
|
|
369
|
+
if (w) { w.status = prevStatus; }
|
|
370
|
+
_watchToggleInFlight.delete(id);
|
|
371
|
+
renderWatches(list);
|
|
372
|
+
showToast('cmd-toast', 'Error: ' + (data.error || ('HTTP ' + res.status)), false);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
_watchToggleInFlight.delete(id);
|
|
376
|
+
renderWatches(list);
|
|
377
|
+
if (typeof refresh === 'function') refresh();
|
|
356
378
|
}).catch(function(err) {
|
|
379
|
+
if (w) { w.status = prevStatus; }
|
|
380
|
+
_watchToggleInFlight.delete(id);
|
|
381
|
+
renderWatches(list);
|
|
357
382
|
showToast('cmd-toast', 'Error: ' + err.message, false);
|
|
358
383
|
});
|
|
359
384
|
}
|
package/dashboard.js
CHANGED
|
@@ -25,6 +25,8 @@ const ado = require('./engine/ado');
|
|
|
25
25
|
const gh = require('./engine/github');
|
|
26
26
|
const issues = require('./engine/issues');
|
|
27
27
|
const watchesMod = require('./engine/watches');
|
|
28
|
+
const meetingMod = require('./engine/meeting');
|
|
29
|
+
const qaRunsMod = require('./engine/qa-runs');
|
|
28
30
|
const routing = require('./engine/routing');
|
|
29
31
|
const playbook = require('./engine/playbook');
|
|
30
32
|
const dispatchMod = require('./engine/dispatch');
|
|
@@ -1593,10 +1595,27 @@ const _slowMtimeTrackedFiles = () => queries.getStatusSlowStateMtimePaths(CONFIG
|
|
|
1593
1595
|
let _lastMtimes = {}; // { filePath: mtimeMs } — fast-state baseline
|
|
1594
1596
|
let _lastSlowMtimes = {}; // { filePath: mtimeMs } — slow-state baseline
|
|
1595
1597
|
|
|
1598
|
+
// Stat a tracked path with transient-error tolerance. ENOENT (file/dir doesn't
|
|
1599
|
+
// exist) is normal — fresh installs, deleted projects, empty PRD dirs all hit
|
|
1600
|
+
// this — and maps to 0 so the entry just doesn't bust the cache. EBUSY /
|
|
1601
|
+
// EACCES / EPERM are transient on Windows (OneDrive sync, antivirus scan,
|
|
1602
|
+
// file replication service); falling through to 0 would produce oscillating
|
|
1603
|
+
// mtimes (real, then 0, then real again) that look like a real change on
|
|
1604
|
+
// every other poll. Preserving the previous successful mtime on those errors
|
|
1605
|
+
// keeps the cache stable across short lock windows.
|
|
1606
|
+
function _statMtimeMs(fp, prevSnapshot) {
|
|
1607
|
+
try {
|
|
1608
|
+
return fs.statSync(fp).mtimeMs;
|
|
1609
|
+
} catch (e) {
|
|
1610
|
+
if (e && e.code === 'ENOENT') return 0;
|
|
1611
|
+
return prevSnapshot && (fp in prevSnapshot) ? prevSnapshot[fp] : 0;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1596
1615
|
function _getMtimes() {
|
|
1597
1616
|
const result = {};
|
|
1598
1617
|
for (const fp of _mtimeTrackedFiles()) {
|
|
1599
|
-
|
|
1618
|
+
result[fp] = _statMtimeMs(fp, _lastMtimes);
|
|
1600
1619
|
}
|
|
1601
1620
|
return result;
|
|
1602
1621
|
}
|
|
@@ -1604,7 +1623,7 @@ function _getMtimes() {
|
|
|
1604
1623
|
function _getSlowMtimes() {
|
|
1605
1624
|
const result = {};
|
|
1606
1625
|
for (const fp of _slowMtimeTrackedFiles()) {
|
|
1607
|
-
|
|
1626
|
+
result[fp] = _statMtimeMs(fp, _lastSlowMtimes);
|
|
1608
1627
|
}
|
|
1609
1628
|
return result;
|
|
1610
1629
|
}
|
|
@@ -1677,25 +1696,41 @@ function _buildStatusFastState() {
|
|
|
1677
1696
|
metrics: getMetrics(),
|
|
1678
1697
|
workItems: getWorkItems(),
|
|
1679
1698
|
watches: watchesMod.getWatches(),
|
|
1680
|
-
meetings: (() =>
|
|
1699
|
+
meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
|
|
1681
1700
|
// QA runs — surfaced for the sidebar activity-dot counter and any future
|
|
1682
1701
|
// CC/aggregate view. Tab-level rendering keeps its own /api/qa/runs poll
|
|
1683
1702
|
// (5 s while the QA page is mounted). qa-runs.json is in the mtime tracker
|
|
1684
1703
|
// so a new run lights the dot within one /api/status poll cycle (~4 s).
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
// sidebar counter advances on status flips AND on new entries.
|
|
1692
|
-
sig: runs.slice(0, 20).map(r => (r && r.id || '') + ':' + (r && r.status || '')).join(','),
|
|
1693
|
-
};
|
|
1694
|
-
} catch { return { total: 0, sig: '' }; }
|
|
1695
|
-
})(),
|
|
1704
|
+
// Uses the unsorted summary helper rather than listRuns({limit:50}) — the
|
|
1705
|
+
// latter reads + sorts the FULL qa-runs.json on every fast-state rebuild
|
|
1706
|
+
// and would charge O(N log N) to the /api/status hot path. The summary
|
|
1707
|
+
// helper returns { total, sig } without sorting; that's all the sidebar
|
|
1708
|
+
// counter needs to detect new runs and status flips.
|
|
1709
|
+
qaRuns: _safeStatusSlice('qaRuns', () => qaRunsMod.summarizeRunsForStatus(), { total: 0, sig: '' }),
|
|
1696
1710
|
};
|
|
1697
1711
|
}
|
|
1698
1712
|
|
|
1713
|
+
// Run a status-slice producer with rate-limited error logging. The lazy-
|
|
1714
|
+
// require IIFEs this replaces silently degraded to fallback values if the
|
|
1715
|
+
// underlying module had a syntax error or got renamed — sidebar dots went
|
|
1716
|
+
// dark with no diagnostic. We keep returning the fallback so the rest of
|
|
1717
|
+
// the status payload survives, but we log once per minute per slice so a
|
|
1718
|
+
// persistent error doesn't spam the engine log AND isn't invisible.
|
|
1719
|
+
const _statusSliceErrorLastLogged = new Map();
|
|
1720
|
+
function _safeStatusSlice(name, fn, fallback) {
|
|
1721
|
+
try {
|
|
1722
|
+
return fn();
|
|
1723
|
+
} catch (e) {
|
|
1724
|
+
const now = Date.now();
|
|
1725
|
+
const last = _statusSliceErrorLastLogged.get(name) || 0;
|
|
1726
|
+
if (now - last > 60000) {
|
|
1727
|
+
_statusSliceErrorLastLogged.set(name, now);
|
|
1728
|
+
console.error('[status] slice ' + name + ' failed: ' + (e && e.message || e));
|
|
1729
|
+
}
|
|
1730
|
+
return fallback;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1699
1734
|
// Build the slow-state slice (rarely-changing data: ~60s TTL).
|
|
1700
1735
|
function _buildStatusSlowState() {
|
|
1701
1736
|
const prdInfo = getPrdInfo();
|
package/engine/lifecycle.js
CHANGED
|
@@ -2128,6 +2128,75 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
|
|
|
2128
2128
|
return result;
|
|
2129
2129
|
}
|
|
2130
2130
|
|
|
2131
|
+
// W-mph6br6a0006a2b9 (F9): record an agent-error fix completion onto the PR
|
|
2132
|
+
// so the engine.js same-head guard relaxes for the next tick. A fix dispatch
|
|
2133
|
+
// that crashed (non-zero exit, hard contract failure, nonce mismatch) never
|
|
2134
|
+
// reached updatePrAfterFix and therefore left _lastDispatchByCause[cause]
|
|
2135
|
+
// either absent (no entry) or — worse — frozen at a prior noop record. The
|
|
2136
|
+
// guard at engine.js:~4254/4445 reads `outcome === 'noop' && !indeterminate`
|
|
2137
|
+
// to suppress re-dispatch on the same head; once a noop record exists it
|
|
2138
|
+
// stays in place permanently because the SHA can't advance until the fix
|
|
2139
|
+
// runs. Reuses the W-mpg6wptq0011cc68 sibling-flag escape-hatch: setting
|
|
2140
|
+
// `indeterminate: true` is sufficient to release the guard. Preserves any
|
|
2141
|
+
// prior `outcome` value verbatim (success/noop/etc) so downstream debugging
|
|
2142
|
+
// can still see what the last known classification was.
|
|
2143
|
+
function updatePrAfterFixError(pr, project, source, options = {}) {
|
|
2144
|
+
if (!pr?.id) return null;
|
|
2145
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) return null;
|
|
2146
|
+
const prPath = project ? shared.projectPrPath(project) : centralPrPath();
|
|
2147
|
+
const dispatchItem = options.dispatchItem || null;
|
|
2148
|
+
const errorMessage = String(options.errorMessage || '').slice(0, 200);
|
|
2149
|
+
const errorClass = String(options.errorClass || 'agent-error');
|
|
2150
|
+
const cause = shared.getPrFixAutomationCause({
|
|
2151
|
+
dispatchKey: dispatchItem?.meta?.dispatchKey,
|
|
2152
|
+
source,
|
|
2153
|
+
task: dispatchItem?.task,
|
|
2154
|
+
});
|
|
2155
|
+
let result = null;
|
|
2156
|
+
shared.mutateJsonFileLocked(prPath, (prs) => {
|
|
2157
|
+
if (!Array.isArray(prs)) return prs;
|
|
2158
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
2159
|
+
if (!target) return prs;
|
|
2160
|
+
target._lastDispatchByCause = target._lastDispatchByCause
|
|
2161
|
+
&& typeof target._lastDispatchByCause === 'object' ? target._lastDispatchByCause : {};
|
|
2162
|
+
const prior = target._lastDispatchByCause[cause] || null;
|
|
2163
|
+
const now = ts();
|
|
2164
|
+
const baselineHead = getPrFixBaselineHead(target);
|
|
2165
|
+
// Preserve the prior `outcome` verbatim (could be 'noop' from a previous
|
|
2166
|
+
// indeterminate noop, or undefined if no prior record). The same-head
|
|
2167
|
+
// guard only suppresses on `outcome === 'noop' && !indeterminate`; setting
|
|
2168
|
+
// `indeterminate: true` releases it regardless of outcome value.
|
|
2169
|
+
const next = {
|
|
2170
|
+
...(prior || {}),
|
|
2171
|
+
indeterminate: true,
|
|
2172
|
+
errorClass,
|
|
2173
|
+
error: errorMessage,
|
|
2174
|
+
errorAt: now,
|
|
2175
|
+
dispatchedAt: now,
|
|
2176
|
+
dispatchId: dispatchItem?.id || prior?.dispatchId || null,
|
|
2177
|
+
};
|
|
2178
|
+
// Refresh headSha when we know the baseline; never clobber a prior value
|
|
2179
|
+
// with empty string (loses guard fidelity for operator-readable history).
|
|
2180
|
+
if (baselineHead) next.headSha = baselineHead;
|
|
2181
|
+
else if (prior?.headSha) next.headSha = prior.headSha;
|
|
2182
|
+
// Preserve the human-feedback comment id from the prior record OR refresh
|
|
2183
|
+
// from the live PR record. Symmetric with recordPrNoOpFixAttempt so the
|
|
2184
|
+
// engine.js human-feedback guard's commentId match still works once
|
|
2185
|
+
// indeterminate flips back off (which it doesn't here, but state shape
|
|
2186
|
+
// stays consistent across paths).
|
|
2187
|
+
if (cause === shared.PR_FIX_CAUSE.HUMAN_FEEDBACK) {
|
|
2188
|
+
const commentId = prior?.lastProcessedCommentId
|
|
2189
|
+
|| String(target.humanFeedback?.lastProcessedCommentId || '');
|
|
2190
|
+
if (commentId) next.lastProcessedCommentId = commentId;
|
|
2191
|
+
}
|
|
2192
|
+
target._lastDispatchByCause[cause] = next;
|
|
2193
|
+
result = { cause, indeterminate: true, errorClass };
|
|
2194
|
+
log('warn', `Updated ${pr.id} → recorded ${cause} agent-error fix attempt (indeterminate=true) — same-head guard relaxed for next tick${errorMessage ? ` (${errorMessage.slice(0, 80)})` : ''}`);
|
|
2195
|
+
return prs;
|
|
2196
|
+
}, { defaultValue: [] });
|
|
2197
|
+
return result;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2131
2200
|
// ─── Post-Merge Rebase ──────────────────────────────────────────────────────
|
|
2132
2201
|
|
|
2133
2202
|
/**
|
|
@@ -4365,6 +4434,40 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
4365
4434
|
}
|
|
4366
4435
|
} catch (err) { log('warn', `PRD sync after fix: ${err.message}`); }
|
|
4367
4436
|
}
|
|
4437
|
+
} else if (type === WORK_TYPE.FIX && meta?.pr?.id) {
|
|
4438
|
+
// W-mph6br6a0006a2b9 (F9): the fix dispatch did not complete cleanly
|
|
4439
|
+
// (agent crash, non-zero exit, hard contract failure, or nonce mismatch),
|
|
4440
|
+
// so updatePrAfterFix above was skipped. Without a write here the PR's
|
|
4441
|
+
// `_lastDispatchByCause[cause]` either stays empty OR — if a prior
|
|
4442
|
+
// indeterminate noop existed — keeps a stale record around. Stamp an
|
|
4443
|
+
// agent-error indeterminate record so:
|
|
4444
|
+
// 1. The same-head guard at engine.js:~4254/4445 relaxes for the next
|
|
4445
|
+
// tick (`indeterminate: true` is the W-mpg6wptq0011cc68 escape).
|
|
4446
|
+
// 2. Operators reading dashboard PR detail can distinguish "agent
|
|
4447
|
+
// crashed" (errorClass='agent-error', errorAt set) from the
|
|
4448
|
+
// "indeterminate noop" case (no errorClass, branchChange === null).
|
|
4449
|
+
// 3. Prior `outcome` is preserved verbatim — no state-shape churn.
|
|
4450
|
+
try {
|
|
4451
|
+
const failureClass = nonceMismatch?.failureClass
|
|
4452
|
+
|| completionContractFailure?.failureClass
|
|
4453
|
+
|| (structuredCompletion && typeof structuredCompletion.failure_class === 'string'
|
|
4454
|
+
? structuredCompletion.failure_class
|
|
4455
|
+
: '');
|
|
4456
|
+
const summary = (structuredCompletion && typeof structuredCompletion.summary === 'string'
|
|
4457
|
+
? structuredCompletion.summary
|
|
4458
|
+
: '') || resultSummary || '';
|
|
4459
|
+
const errorMessage = [
|
|
4460
|
+
failureClass ? `failure_class=${failureClass}` : '',
|
|
4461
|
+
summary || `agent exited with code ${code}`,
|
|
4462
|
+
].filter(Boolean).join(' — ');
|
|
4463
|
+
updatePrAfterFixError(meta.pr, meta?.project, meta?.source, {
|
|
4464
|
+
dispatchItem,
|
|
4465
|
+
errorMessage,
|
|
4466
|
+
errorClass: 'agent-error',
|
|
4467
|
+
});
|
|
4468
|
+
} catch (err) {
|
|
4469
|
+
log('warn', `PR agent-error fix record for ${meta?.pr?.id || 'unknown PR'}: ${err.message}`);
|
|
4470
|
+
}
|
|
4368
4471
|
}
|
|
4369
4472
|
checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
|
|
4370
4473
|
if (finalResult === DISPATCH_RESULT.SUCCESS) {
|
|
@@ -4558,6 +4661,7 @@ module.exports = {
|
|
|
4558
4661
|
syncPrsFromOutput,
|
|
4559
4662
|
updatePrAfterReview,
|
|
4560
4663
|
updatePrAfterFix,
|
|
4664
|
+
updatePrAfterFixError,
|
|
4561
4665
|
fixCompletionChangedBranch,
|
|
4562
4666
|
handlePostMerge,
|
|
4563
4667
|
checkForLearnings,
|
package/engine/qa-runs.js
CHANGED
|
@@ -28,6 +28,16 @@ const path = require('path');
|
|
|
28
28
|
const shared = require('./shared');
|
|
29
29
|
const { mutateJsonFileLocked, uid, ts, log } = shared;
|
|
30
30
|
|
|
31
|
+
// Cap qa-runs.json so the file doesn't grow unboundedly over months of nightly
|
|
32
|
+
// QA dispatch. Without a cap, listRuns + summarizeRunsForStatus pay O(N) on
|
|
33
|
+
// every read, and /api/status's fast-state slice runs the summary on every
|
|
34
|
+
// rebuild — at 10k+ historical runs the JSON parse alone starts eating into
|
|
35
|
+
// the W-mpehsyhv event-loop budget that CC SSE isolation depends on. Mirrors
|
|
36
|
+
// the 2500-entry cap on engine/log.json. createRun trims oldest-by-createdAt
|
|
37
|
+
// when crossing the threshold; terminal-status runs that have already shipped
|
|
38
|
+
// completion notifications are safe to drop.
|
|
39
|
+
const QA_RUNS_MAX_RECORDS = 2000;
|
|
40
|
+
|
|
31
41
|
const QA_RUN_STATUS = Object.freeze({
|
|
32
42
|
PENDING: 'pending',
|
|
33
43
|
RUNNING: 'running',
|
|
@@ -141,6 +151,12 @@ function createRun({ runbookId, targetName, project, workItemId } = {}) {
|
|
|
141
151
|
mutateJsonFileLocked(qaRunsPath(), (runs) => {
|
|
142
152
|
if (!Array.isArray(runs)) runs = [];
|
|
143
153
|
runs.push(run);
|
|
154
|
+
// Rotation: drop oldest-by-createdAt when over the cap. Cheap because
|
|
155
|
+
// this runs only on createRun, not on every read.
|
|
156
|
+
if (runs.length > QA_RUNS_MAX_RECORDS) {
|
|
157
|
+
runs.sort((a, b) => ((a && a.createdAt) || '').localeCompare((b && b.createdAt) || ''));
|
|
158
|
+
runs = runs.slice(runs.length - QA_RUNS_MAX_RECORDS);
|
|
159
|
+
}
|
|
144
160
|
return runs;
|
|
145
161
|
}, { defaultValue: [] });
|
|
146
162
|
|
|
@@ -299,6 +315,30 @@ function setRunWorkItemId(id, workItemId) {
|
|
|
299
315
|
return captured;
|
|
300
316
|
}
|
|
301
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Cheap summary helper for the dashboard /api/status fast-state slice. Returns
|
|
320
|
+
* `{ total, sig }` without sorting the run list — the sidebar activity-dot
|
|
321
|
+
* counter only needs to detect (a) when total advances (new run created) and
|
|
322
|
+
* (b) when any status flips. `sig` joins id:status across all current runs;
|
|
323
|
+
* any change to either advances the string, which is enough signal for the
|
|
324
|
+
* counter. We deliberately skip the sort that listRuns() does because this
|
|
325
|
+
* runs on every fast-state rebuild (~every 10 s + every mtime-tracked write),
|
|
326
|
+
* and an O(N log N) sort on a 2 k-entry file would eat into the event-loop
|
|
327
|
+
* budget that CC SSE isolation (W-mpehsyhv) depends on.
|
|
328
|
+
*
|
|
329
|
+
* @returns {{ total: number, sig: string }}
|
|
330
|
+
*/
|
|
331
|
+
function summarizeRunsForStatus() {
|
|
332
|
+
const runs = shared.safeJsonArr(qaRunsPath());
|
|
333
|
+
if (!Array.isArray(runs) || runs.length === 0) return { total: 0, sig: '' };
|
|
334
|
+
let sig = '';
|
|
335
|
+
for (const r of runs) {
|
|
336
|
+
if (!r) continue;
|
|
337
|
+
sig += (r.id || '') + ':' + (r.status || '') + ',';
|
|
338
|
+
}
|
|
339
|
+
return { total: runs.length, sig };
|
|
340
|
+
}
|
|
341
|
+
|
|
302
342
|
module.exports = {
|
|
303
343
|
QA_RUN_STATUS,
|
|
304
344
|
TERMINAL_STATUSES,
|
|
@@ -313,7 +353,9 @@ module.exports = {
|
|
|
313
353
|
getRun,
|
|
314
354
|
listRuns,
|
|
315
355
|
getRunsForWorkItem,
|
|
356
|
+
summarizeRunsForStatus,
|
|
316
357
|
// Exposed for tests:
|
|
317
358
|
validateTransition,
|
|
318
359
|
isValidStatus,
|
|
360
|
+
QA_RUNS_MAX_RECORDS,
|
|
319
361
|
};
|
package/engine/queries.js
CHANGED
|
@@ -1885,10 +1885,14 @@ function resetProjectGitStatusCache() {
|
|
|
1885
1885
|
* rebases.json`, `agents/<id>/managed-spawn.json` — not in the
|
|
1886
1886
|
* `/api/status` payload.
|
|
1887
1887
|
* - `pinned.md`, `schedules`, `pipeline-runs.json`, `schedule-runs.json`,
|
|
1888
|
-
* PRD JSON — slow-state only.
|
|
1889
|
-
*
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
1888
|
+
* PRD JSON — slow-state only (see `getStatusSlowStateMtimePaths`).
|
|
1889
|
+
*
|
|
1890
|
+
* Files tracked PER-FILE rather than via dir mtime:
|
|
1891
|
+
* - `meetings/<id>.json` — round transitions edit each file in-place via
|
|
1892
|
+
* `engine/meeting.js#mutateMeeting`. The parent dir's mtime does not
|
|
1893
|
+
* advance on Windows NTFS for in-place edits inside an existing file,
|
|
1894
|
+
* so tracking each meeting JSON individually catches round/status flips
|
|
1895
|
+
* that dir mtime would miss. Bounded by active meeting count.
|
|
1892
1896
|
*
|
|
1893
1897
|
* Performance: `_getMtimes()` in dashboard.js does `fs.statSync` per path
|
|
1894
1898
|
* per `getStatus()` call. Roughly N=4 engine paths + 2 per project today,
|
|
@@ -1928,17 +1932,13 @@ function getStatusFastStateMtimePaths(config) {
|
|
|
1928
1932
|
// meetings/<id>.json (surfaced by meeting.getMeetings) — round transitions
|
|
1929
1933
|
// edit each file in-place via mutateMeeting, so the parent dir's mtime
|
|
1930
1934
|
// does NOT advance on Windows. Tracking each file individually catches
|
|
1931
|
-
// in-file edits. Bounded by meeting count
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
1934
|
-
// non-*.json entries so corrupted state can't pollute the registry.
|
|
1935
|
+
// in-file edits. Bounded by active meeting count; safeWrite's `.json.backup`
|
|
1936
|
+
// tempfile sidecars are excluded by the `.json` suffix check (a path
|
|
1937
|
+
// ending in `.json.backup` does not end in `.json`).
|
|
1935
1938
|
try {
|
|
1936
1939
|
const meetingsDir = path.join(MINIONS_DIR, 'meetings');
|
|
1937
|
-
const
|
|
1938
|
-
|
|
1939
|
-
if (f.endsWith('.json') && !f.endsWith('.backup.json')) {
|
|
1940
|
-
files.push(path.join(meetingsDir, f));
|
|
1941
|
-
}
|
|
1940
|
+
for (const f of fs.readdirSync(meetingsDir)) {
|
|
1941
|
+
if (f.endsWith('.json')) files.push(path.join(meetingsDir, f));
|
|
1942
1942
|
}
|
|
1943
1943
|
} catch { /* meetings dir absent → no meetings to track */ }
|
|
1944
1944
|
// Per-project work-items (surfaced by getWorkItems) and pull-requests
|
|
@@ -1989,24 +1989,25 @@ function getStatusFastStateMtimePaths(config) {
|
|
|
1989
1989
|
* - `mcpServers`, version, autoMode, installId — change only on human/
|
|
1990
1990
|
* CLI edits, which already pop the slow-state via reloadConfig + the
|
|
1991
1991
|
* 60 s TTL.
|
|
1992
|
-
* - `~/.claude/skills/`, `~/.copilot/skills
|
|
1993
|
-
* `extractSkillsFromOutput` writes
|
|
1994
|
-
* which already calls
|
|
1992
|
+
* - `~/.claude/skills/`, `~/.copilot/skills/`, `<project>/.claude/skills/`,
|
|
1993
|
+
* `<project>/.github/skills/` — `extractSkillsFromOutput` writes here
|
|
1994
|
+
* from the agent-close path, which already calls
|
|
1995
|
+
* `invalidateStatusCache({includeSlow: true})` directly. Tracking the
|
|
1996
|
+
* user-home dir is additionally harmful because it's shared with every
|
|
1997
|
+
* Claude Code session on the machine; non-Minions activity would
|
|
1998
|
+
* otherwise bust this fleet's dashboard cache.
|
|
1995
1999
|
* - project git state — already invalidated via the
|
|
1996
2000
|
* `_setOnProjectGitStatusChanged` callback into `invalidateStatusCache`
|
|
1997
2001
|
* (W-mpgrk5cy fix); also tracked in fast-state via `.git/logs/HEAD`.
|
|
1998
2002
|
*/
|
|
1999
|
-
function getStatusSlowStateMtimePaths(
|
|
2000
|
-
|
|
2001
|
-
|
|
2003
|
+
function getStatusSlowStateMtimePaths(_config) {
|
|
2004
|
+
// _config accepted for symmetry with getStatusFastStateMtimePaths but
|
|
2005
|
+
// unused — every entry below is a fleet-global path.
|
|
2006
|
+
return [
|
|
2002
2007
|
// prd/*.json (surfaced by getPrdInfo) — engine writes via syncPrdFromPrs,
|
|
2003
|
-
// the materializer, and plan-to-prd outputs.
|
|
2004
|
-
// add/remove; in-file edits use getPrdInfo's own per-file mtime cache so
|
|
2005
|
-
// small-content flips still surface via the 10 s TTL backstop, but the
|
|
2006
|
-
// big "new PRD created" event surfaces immediately.
|
|
2008
|
+
// the materializer, and plan-to-prd outputs.
|
|
2007
2009
|
PRD_DIR,
|
|
2008
|
-
// prd/archive/*.json — manual archive moves PRDs here
|
|
2009
|
-
// the move.
|
|
2010
|
+
// prd/archive/*.json — manual archive moves PRDs here.
|
|
2010
2011
|
path.join(PRD_DIR, 'archive'),
|
|
2011
2012
|
// prd/guides/*.md — verify agent writes new files here on E2E completion.
|
|
2012
2013
|
path.join(MINIONS_DIR, 'prd', 'guides'),
|
|
@@ -2016,26 +2017,12 @@ function getStatusSlowStateMtimePaths(config) {
|
|
|
2016
2017
|
// stage transition (the most user-visible slow-state lag pre-fix).
|
|
2017
2018
|
path.join(ENGINE_DIR, 'pipeline-runs.json'),
|
|
2018
2019
|
// pipelines/*.json — pipeline definitions, edited by humans + plan agents.
|
|
2019
|
-
// Dir mtime is fine because pipeline edits are wholesale file replacements
|
|
2020
|
-
// (no in-place tweaks once a pipeline is authored).
|
|
2021
2020
|
path.join(MINIONS_DIR, 'pipelines'),
|
|
2022
2021
|
// pinned.md — single file, dashboard-side writes already call
|
|
2023
2022
|
// invalidateStatusCache({includeSlow:true}); tracker entry catches any
|
|
2024
2023
|
// CLI/editor edit that bypasses the API.
|
|
2025
2024
|
path.join(MINIONS_DIR, 'pinned.md'),
|
|
2026
|
-
// engine/skill-states/ — engine writes when agents extract new skills.
|
|
2027
|
-
// Dir mtime catches new skill files. Skips per-skill in-place edits which
|
|
2028
|
-
// the agent-close invalidate already covers.
|
|
2029
|
-
SKILLS_DIR,
|
|
2030
2025
|
];
|
|
2031
|
-
// Per-project local skill dirs — agents extract project-scoped skills here.
|
|
2032
|
-
for (const p of projects) {
|
|
2033
|
-
if (p && p.localPath) {
|
|
2034
|
-
files.push(path.join(p.localPath, '.claude', 'skills'));
|
|
2035
|
-
files.push(path.join(p.localPath, '.github', 'skills'));
|
|
2036
|
-
}
|
|
2037
|
-
}
|
|
2038
|
-
return files;
|
|
2039
2026
|
}
|
|
2040
2027
|
|
|
2041
2028
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2029",
|
|
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"
|