@yemi33/minions 0.1.153 → 0.1.154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.153 (2026-04-02)
3
+ ## 0.1.154 (2026-04-02)
4
4
 
5
5
  ### Engine
6
6
  - engine.js
@@ -11,12 +11,15 @@
11
11
  ### Dashboard
12
12
  - dashboard.html
13
13
  - dashboard.js
14
+ - dashboard/js/command-center.js
14
15
  - dashboard/js/modal-qa.js
15
16
  - dashboard/js/modal.js
17
+ - dashboard/js/refresh.js
16
18
  - dashboard/js/render-inbox.js
17
19
  - dashboard/js/render-kb.js
18
20
  - dashboard/js/render-plans.js
19
21
  - dashboard/js/render-prd.js
22
+ - dashboard/js/render-work-items.js
20
23
  - dashboard/styles.css
21
24
 
22
25
  ### Documentation
@@ -88,7 +88,7 @@ function ccAddMessage(role, html, skipSave) {
88
88
  const isUser = role === 'user';
89
89
  const div = document.createElement('div');
90
90
  const isAssistant = !isUser;
91
- div.className = isAssistant ? 'cc-msg-assistant' : '';
91
+ div.className = isAssistant ? 'cc-msg-assistant md-content' : '';
92
92
  div.style.cssText = 'padding:8px 12px;border-radius:8px;font-size:12px;line-height:1.6;max-width:95%;' +
93
93
  (isUser ? 'background:var(--blue);color:#fff;align-self:flex-end' : 'background:var(--surface2);color:var(--text);align-self:flex-start;border:1px solid var(--border);position:relative');
94
94
  div.innerHTML = (isAssistant && !html.includes('color:var(--red)') && !html.includes('cc-queued-pill') ? llmCopyBtn() : '') + html;
@@ -205,11 +205,9 @@ async function _ccDoSend(message, skipUserMsg) {
205
205
  ccUpdateSessionIndicator();
206
206
  }
207
207
 
208
- // Render markdown-ish response
208
+ // Render markdown response
209
209
  const ccElapsed = Math.round((Date.now() - ccStartTime) / 1000);
210
- const rendered = (data.text || '').replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
211
- .replace(/`([^`]+)`/g, '<code style="background:var(--surface);padding:1px 4px;border-radius:3px;font-size:11px">$1</code>')
212
- .replace(/\n/g, '<br>');
210
+ const rendered = renderMd(data.text || '');
213
211
  ccAddMessage('assistant', rendered + '<div style="font-size:9px;color:var(--muted);margin-top:6px;display:flex;justify-content:flex-end;padding-right:30px">' + ccElapsed + 's</div>');
214
212
 
215
213
  // Execute actions
@@ -2,6 +2,7 @@
2
2
 
3
3
  // Sidebar activity indicators — detect changes between refreshes
4
4
  let _prevCounts = {};
5
+ let _prevEngineAlert = false;
5
6
  function _detectPageChanges(data) {
6
7
  const counts = {
7
8
  completions: (data.dispatch?.completed || []).length,
@@ -22,9 +23,52 @@ function _detectPageChanges(data) {
22
23
  if (counts.meetingRounds > _prevCounts.meetingRounds) changes.meetings = true;
23
24
  }
24
25
  _prevCounts = counts;
26
+
27
+ // Engine page — only badge for genuine problems, not routine activity
28
+ const engineAlert = _isEngineAlertWorthy(data);
29
+ if (engineAlert && !_prevEngineAlert) changes.engine = true;
30
+ // Clear the engine badge when alert condition resolves
31
+ if (!engineAlert && _prevEngineAlert) {
32
+ const engineLink = document.querySelector('.sidebar-link[data-page="engine"]');
33
+ if (engineLink) clearNotifBadge(engineLink);
34
+ }
35
+ _prevEngineAlert = engineAlert;
36
+
25
37
  return changes;
26
38
  }
27
39
 
40
+ /**
41
+ * Determine if the engine state warrants a notification dot.
42
+ * Returns true only for genuine problems:
43
+ * - Engine stopped, stale, or in error state
44
+ * - 3+ failed work items in the last hour
45
+ * - Agent timeout/crash detected (error results in recent completions)
46
+ */
47
+ function _isEngineAlertWorthy(data) {
48
+ // 1. Engine not running (stopped, stale, or error)
49
+ const engineState = data.engine?.state || 'stopped';
50
+ if (engineState === 'stopped' || engineState === 'error') return true;
51
+ // Stale heartbeat (>2 min old while claiming running)
52
+ if (engineState === 'running' && data.engine?.heartbeat) {
53
+ if (Date.now() - data.engine.heartbeat > 120000) return true;
54
+ }
55
+
56
+ // 2. 3+ failed work items in the last hour
57
+ const oneHourAgo = Date.now() - 3600000;
58
+ const recentFailures = (data.workItems || []).filter(w =>
59
+ w.status === 'failed' && w.updated_at && new Date(w.updated_at).getTime() > oneHourAgo
60
+ );
61
+ if (recentFailures.length >= 3) return true;
62
+
63
+ // 3. Agent timeout/crash — 3+ error results in recent completed dispatches
64
+ const recentErrors = (data.dispatch?.completed || []).filter(d =>
65
+ d.result === 'error' && d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
66
+ );
67
+ if (recentErrors.length >= 3) return true;
68
+
69
+ return false;
70
+ }
71
+
28
72
  function _processStatusUpdate(data) {
29
73
  // Detect fresh install — clear stale browser state if install ID changed
30
74
  if (data.installId) {
@@ -4,6 +4,30 @@ let allWorkItems = [];
4
4
  let wiPage = 0;
5
5
  const WI_PER_PAGE = 20;
6
6
 
7
+ // Track retry state per work item so loading/success/error survives re-renders
8
+ const _wiRetryState = {}; // { [id]: { status: 'pending'|'done'|'error', message?, until? } }
9
+ function setWiRetryState(id, state) { _wiRetryState[id] = state; }
10
+ function getWiRetryState(id) {
11
+ const s = _wiRetryState[id];
12
+ if (!s) return null;
13
+ if (s.until && Date.now() > s.until) { delete _wiRetryState[id]; return null; }
14
+ return s;
15
+ }
16
+
17
+ function wiRetryBtn(item) {
18
+ const rs = getWiRetryState(item.id);
19
+ if (rs && rs.status === 'pending') {
20
+ return '<span style="font-size:9px;padding:1px 6px;color:var(--yellow);border:1px solid rgba(210,153,34,0.35);background:rgba(210,153,34,0.1);border-radius:3px;cursor:wait;margin-left:4px">Retrying\u2026</span>';
21
+ }
22
+ if (rs && rs.status === 'done') {
23
+ return '<span style="font-size:9px;padding:1px 6px;color:var(--green);border:1px solid rgba(63,185,80,0.35);background:rgba(63,185,80,0.1);border-radius:3px;margin-left:4px">Requeued</span>';
24
+ }
25
+ if (rs && rs.status === 'error') {
26
+ return '<span style="font-size:9px;padding:1px 6px;color:var(--red);border:1px solid rgba(248,81,73,0.35);background:rgba(248,81,73,0.1);border-radius:3px;margin-left:4px;cursor:pointer" title="' + escHtml(rs.message || 'Retry failed') + ' — click to try again" onclick="event.stopPropagation();retryWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')">Retry failed</span>';
27
+ }
28
+ return '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')">Retry</button>';
29
+ }
30
+
7
31
  function wiRow(item) {
8
32
  const statusBadge = (s) => {
9
33
  const cls = s === 'failed' ? 'rejected' : s === 'needs-human-review' ? 'needs-review' : s === 'dispatched' ? 'building' : s === 'pending' || s === 'queued' ? 'active' : s === 'done' ? 'approved' : 'draft';
@@ -23,7 +47,7 @@ function wiRow(item) {
23
47
  '<td>' + priBadge(item.priority) + '</td>' +
24
48
  '<td>' + statusBadge(item.status || 'pending') +
25
49
  (item._pendingReason ? ' <span style="font-size:9px;color:var(--muted);margin-left:4px" title="Pending reason: ' + escHtml(item._pendingReason) + '">' + escHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
26
- (item.status === 'failed' ? ' <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\',this)">Retry</button>' : '') +
50
+ (item.status === 'failed' ? ' ' + wiRetryBtn(item) : '') +
27
51
  '</td>' +
28
52
  '<td>' +
29
53
  (item.completedAgents && item.completedAgents.length > 0
@@ -92,7 +116,7 @@ function renderWorkItems(items) {
92
116
  function editWorkItem(id, source) {
93
117
  const item = allWorkItems.find(i => i.id === id);
94
118
  if (!item) return;
95
- const types = ['implement', 'fix', 'review', 'plan', 'verify', 'investigate', 'refactor', 'test', 'docs'];
119
+ const types = ['implement', 'fix', 'review', 'plan', 'verify', 'evaluate', 'decompose', 'meeting', 'investigate', 'refactor', 'test', 'explore', 'ask', 'docs'];
96
120
  const priorities = ['critical', 'high', 'medium', 'low'];
97
121
  const agentOpts = (cmdAgents || []).map(a => '<option value="' + escHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escHtml(a.name) + '</option>').join('');
98
122
  const typeOpts = types.map(t => '<option value="' + t + '"' + ((item.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
@@ -211,23 +235,33 @@ async function toggleWorkItemArchive() {
211
235
  } catch (e) { el.innerHTML = '<p class="empty">Failed to load archive.</p>'; }
212
236
  }
213
237
 
214
- async function retryWorkItem(id, source, btn) {
215
- if (btn) { btn.dataset.origText = btn.textContent; btn.textContent = 'Retrying...'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; }
238
+ async function retryWorkItem(id, source) {
239
+ // Prevent double-click: if already retrying, ignore
240
+ const existing = getWiRetryState(id);
241
+ if (existing && existing.status === 'pending') return;
242
+
243
+ setWiRetryState(id, { status: 'pending' });
244
+ renderWorkItems(allWorkItems);
216
245
  try {
217
246
  const res = await fetch('/api/work-items/retry', {
218
247
  method: 'POST', headers: { 'Content-Type': 'application/json' },
219
248
  body: JSON.stringify({ id, source: source || undefined })
220
249
  });
221
250
  if (res.ok) {
251
+ setWiRetryState(id, { status: 'done', until: Date.now() + 8000 });
222
252
  showToast('cmd-toast', 'Work item ' + id + ' reset to pending', true);
223
253
  wakeEngine();
224
254
  refresh();
225
255
  } else {
226
- if (btn) { btn.textContent = btn.dataset.origText || 'Retry'; btn.style.pointerEvents = ''; btn.style.opacity = ''; }
227
256
  const d = await res.json().catch(() => ({}));
228
- alert('Retry failed: ' + (d.error || 'unknown'));
257
+ const msg = d.error || 'unknown';
258
+ setWiRetryState(id, { status: 'error', message: msg, until: Date.now() + 10000 });
259
+ renderWorkItems(allWorkItems);
229
260
  }
230
- } catch (e) { if (btn) { btn.textContent = btn.dataset.origText || 'Retry'; btn.style.pointerEvents = ''; btn.style.opacity = ''; } alert('Retry error: ' + e.message); }
261
+ } catch (e) {
262
+ setWiRetryState(id, { status: 'error', message: e.message, until: Date.now() + 10000 });
263
+ renderWorkItems(allWorkItems);
264
+ }
231
265
  }
232
266
 
233
267
  function wiPrev() { if (wiPage > 0) { wiPage--; renderWorkItems(allWorkItems); } }
@@ -282,7 +316,7 @@ async function submitFeedback(id, source) {
282
316
  }
283
317
 
284
318
  function openCreateWorkItemModal() {
285
- const typeOpts = ['implement', 'fix', 'explore', 'test', 'review', 'ask', 'plan'].map(t =>
319
+ const typeOpts = ['implement', 'fix', 'explore', 'test', 'review', 'ask', 'plan', 'verify', 'evaluate', 'decompose', 'meeting'].map(t =>
286
320
  '<option value="' + t + '"' + (t === 'implement' ? ' selected' : '') + '>' + t + '</option>'
287
321
  ).join('');
288
322
  const priOpts = ['high', 'medium', 'low'].map(p =>
@@ -505,6 +505,10 @@
505
505
  .dispatch-type.plan { background: rgba(168,85,247,0.15); color: #a855f7; }
506
506
  .dispatch-type.plan-to-prd { background: rgba(168,85,247,0.1); color: #a855f7; }
507
507
  .dispatch-type.ask { background: rgba(63,185,80,0.15); color: var(--green); }
508
+ .dispatch-type.evaluate { background: rgba(248,81,73,0.15); color: var(--red); }
509
+ .dispatch-type.verify { background: rgba(63,185,80,0.2); color: var(--green); }
510
+ .dispatch-type.meeting { background: rgba(88,166,255,0.2); color: var(--blue); }
511
+ .dispatch-type.decompose { background: rgba(188,140,255,0.2); color: var(--purple); }
508
512
  .dispatch-type.manual { background: rgba(139,148,158,0.15); color: var(--muted); }
509
513
  .dispatch-agent { font-weight: 600; color: var(--text); }
510
514
  .dispatch-task { flex: 1; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -603,7 +603,14 @@ function syncPrsFromOutput(output, agentId, meta, config) {
603
603
  dirtyTargets.set(targetName, { prs: safeJson(prPath) || [], prPath });
604
604
  }
605
605
  const entry = dirtyTargets.get(targetName);
606
- if (entry.prs.some(p => p.id === fullId || String(p.id) === String(prId))) continue;
606
+ const existing = entry.prs.find(p => p.id === fullId || String(p.id) === String(prId));
607
+ if (existing) {
608
+ // Backfill prdItems if the entry was added by the poller before syncPrsFromOutput ran
609
+ if (meta?.item?.id && !existing.prdItems?.includes(meta.item.id)) {
610
+ existing.prdItems = [...(existing.prdItems || []), meta.item.id];
611
+ }
612
+ continue;
613
+ }
607
614
 
608
615
  let title = meta?.item?.title || '';
609
616
  const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
@@ -637,10 +644,26 @@ function syncPrsFromOutput(output, agentId, meta, config) {
637
644
 
638
645
  // ─── Post-Completion Hooks ──────────────────────────────────────────────────
639
646
 
647
+ /**
648
+ * Resolve which project's pull-requests.json contains a given PR ID.
649
+ * Returns the project object, or null if not found in any project file.
650
+ */
651
+ function resolveProjectForPr(prId) {
652
+ const config = getConfig();
653
+ for (const p of shared.getProjects(config)) {
654
+ const prs = safeJson(projectPrPath(p)) || [];
655
+ if (prs.some(pr => pr.id === prId)) return p;
656
+ }
657
+ return null;
658
+ }
659
+
640
660
  function updatePrAfterReview(agentId, pr, project) {
641
661
 
642
662
  if (!pr?.id) return;
643
- const prs = getPrs(project);
663
+ // Resolve actual project if not provided — avoids writing merged array to wrong path
664
+ const resolvedProject = project || resolveProjectForPr(pr.id);
665
+ if (!resolvedProject) { log('warn', `updatePrAfterReview: cannot resolve project for ${pr.id}`); return; }
666
+ const prs = getPrs(resolvedProject);
644
667
  const target = prs.find(p => p.id === pr.id);
645
668
  if (!target) return;
646
669
 
@@ -674,7 +697,7 @@ function updatePrAfterReview(agentId, pr, project) {
674
697
  shared.safeWrite(metricsPath, metrics);
675
698
  }
676
699
 
677
- shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
700
+ shared.safeWrite(shared.projectPrPath(resolvedProject), prs);
678
701
  log('info', `Updated ${pr.id} → minions review: ${target.reviewStatus} by ${reviewerName}`);
679
702
  createReviewFeedbackForAuthor(agentId, { ...pr, ...target }, config);
680
703
  }
@@ -682,7 +705,10 @@ function updatePrAfterReview(agentId, pr, project) {
682
705
  function updatePrAfterFix(pr, project, source) {
683
706
 
684
707
  if (!pr?.id) return;
685
- const prs = getPrs(project);
708
+ // Resolve actual project if not provided — avoids writing merged array to wrong path
709
+ const resolvedProject = project || resolveProjectForPr(pr.id);
710
+ if (!resolvedProject) { log('warn', `updatePrAfterFix: cannot resolve project for ${pr.id}`); return; }
711
+ const prs = getPrs(resolvedProject);
686
712
  const target = prs.find(p => p.id === pr.id);
687
713
  if (!target) return;
688
714
 
@@ -697,7 +723,7 @@ function updatePrAfterFix(pr, project, source) {
697
723
  log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
698
724
  }
699
725
 
700
- shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
726
+ shared.safeWrite(shared.projectPrPath(resolvedProject), prs);
701
727
  }
702
728
 
703
729
  // ─── Post-Merge / Post-Close Hooks ───────────────────────────────────────────
@@ -1323,51 +1349,37 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1323
1349
  }
1324
1350
 
1325
1351
  if (!isSuccess && meta?.item?.id) {
1326
- // Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
1327
- let retries = (meta.item._retryCount || 0);
1328
- try {
1329
- const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1330
- ? path.join(MINIONS_DIR, 'work-items.json')
1331
- : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1332
- if (wiPath) {
1333
- const items = safeJson(wiPath) || [];
1334
- const wi = items.find(i => i.id === meta.item.id);
1335
- if (wi) retries = (wi._retryCount || 0); // Use fresh value from file
1336
- }
1337
- } catch { /* optional */ }
1338
-
1339
- if (retries < 3) {
1340
- log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/3`);
1341
- updateWorkItemStatus(meta, 'pending', '');
1352
+ const wiPath = resolveWiPath(meta);
1353
+ if (wiPath) {
1354
+ let finalStatus = null;
1342
1355
  try {
1343
- const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1344
- ? path.join(MINIONS_DIR, 'work-items.json')
1345
- : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1346
- if (wiPath) {
1347
- const items = safeJson(wiPath) || [];
1356
+ mutateJsonFileLocked(wiPath, (items) => {
1357
+ if (!Array.isArray(items)) return items;
1348
1358
  const wi = items.find(i => i.id === meta.item.id);
1349
- if (wi) {
1350
- wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to;
1351
- if (type === 'decompose') delete wi._decomposing; // clear so item can retry decomposition
1352
- shared.safeWrite(wiPath, items);
1359
+ if (!wi) return items;
1360
+
1361
+ const retries = wi._retryCount || 0;
1362
+ if (retries < 3) {
1363
+ log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/3`);
1364
+ wi._retryCount = retries + 1;
1365
+ wi.status = 'pending';
1366
+ delete wi.dispatched_at;
1367
+ delete wi.dispatched_to;
1368
+ finalStatus = 'pending';
1369
+ } else {
1370
+ wi.status = 'failed';
1371
+ wi.failReason = 'Agent failed (3 retries exhausted)';
1372
+ wi.failedAt = ts();
1373
+ finalStatus = 'failed';
1353
1374
  }
1354
- }
1375
+ if (type === 'decompose') delete wi._decomposing;
1376
+ return items;
1377
+ });
1355
1378
  } catch (err) { log('warn', `Retry update: ${err.message}`); }
1356
- } else {
1357
- updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
1358
- }
1359
- // Clear _decomposing flag on failure so item doesn't get permanently stuck
1360
- if (type === 'decompose') {
1361
- try {
1362
- const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1363
- ? path.join(MINIONS_DIR, 'work-items.json')
1364
- : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1365
- if (wiPath) {
1366
- const items = safeJson(wiPath) || [];
1367
- const wi = items.find(i => i.id === meta.item.id);
1368
- if (wi) { delete wi._decomposing; shared.safeWrite(wiPath, items); }
1369
- }
1370
- } catch (err) { log('warn', `Decompose cleanup: ${err.message}`); }
1379
+ // Sync status to PRD outside the work-items lock
1380
+ if (finalStatus) {
1381
+ syncPrdItemStatus(meta.item.id, finalStatus, meta.item?.sourcePlan);
1382
+ }
1371
1383
  }
1372
1384
  }
1373
1385
  // Meeting post-completion: collect findings/debate/conclusion
@@ -1420,7 +1432,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1420
1432
  }
1421
1433
 
1422
1434
  // Detect implement tasks that completed without creating a PR
1423
- if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id) {
1435
+ if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id && !meta?.item?.skipPr) {
1424
1436
  // Check if a PR already exists linked to this work item (from a previous attempt)
1425
1437
  const projects = shared.getProjects(config);
1426
1438
  const existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
@@ -1513,6 +1525,7 @@ module.exports = {
1513
1525
  updateWorkItemStatus,
1514
1526
  syncPrdItemStatus,
1515
1527
  syncPrsFromOutput,
1528
+ resolveProjectForPr,
1516
1529
  updatePrAfterReview,
1517
1530
  updatePrAfterFix,
1518
1531
  handlePostMerge,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.153",
3
+ "version": "0.1.154",
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"