@yemi33/minions 0.1.808 → 0.1.810

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,14 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.808 (2026-04-10)
3
+ ## 0.1.810 (2026-04-10)
4
4
 
5
5
  ### Features
6
+ - CC race condition fixes, doc-chat expand, settings save fix, metric rate
6
7
  - configurable ignored comment authors — auto-filtered, never trigger fixes
7
8
  - add open-source community sources to agentic research pipeline
8
9
  - broaden daily pipeline research from Anthropic-only to full agentic space
9
10
  - cap review→fix cycles at evalMaxIterations (default 3)
10
11
 
11
12
  ### Fixes
13
+ - allow agents to explain rationale instead of blindly fixing review feedback
12
14
  - don't URL-encode ADO branchName filter (refs/heads/ format expected raw)
13
15
  - ADO build query filters by source branch (not all org builds)
14
16
  - adoFetch supports method/body for PATCH requests (auto-complete)
@@ -28,7 +30,6 @@
28
30
  - flush queued CC messages as single combined request
29
31
  - human feedback fixes are never capped (only minion review→fix loop is)
30
32
  - 5 bugs from comprehensive audit
31
- - stale ADO build detection + merge conflict auto-fix for both platforms
32
33
 
33
34
  ## 0.1.782 (2026-04-10)
34
35
 
@@ -379,10 +379,10 @@ async function ccSend() {
379
379
  var wasAborted = await _ccDoSend(message);
380
380
 
381
381
  // Flush queued messages — combine into single request
382
- if (tab._queue && tab._queue.length > 0 && !wasAborted) {
382
+ if (tab._queue && tab._queue.length > 0) {
383
383
  var combined = tab._queue.splice(0).join('\n\n');
384
384
  _renderQueueIndicator();
385
- wasAborted = await _ccDoSend(combined);
385
+ await _ccDoSend(combined);
386
386
  }
387
387
  }
388
388
 
@@ -26,6 +26,7 @@ let _qaHistory = []; // multi-turn conversation history [{role:'user',text:''},{
26
26
  let _qaProcessing = false; // true while waiting for response
27
27
  let _qaAbortController = null;
28
28
  let _qaQueue = []; // queued messages while processing
29
+ const QA_QUEUE_CAP = 10; // max queued messages
29
30
  let _qaSessionKey = ''; // key for current conversation (title or filePath)
30
31
  const _qaSessions = new Map(); // persist conversations across modal open/close {key → {history, threadHtml}}
31
32
  // Restore from localStorage
@@ -103,7 +104,9 @@ function _initQaSession() {
103
104
  _qaHistory = [];
104
105
  document.getElementById('modal-qa-thread').innerHTML = '';
105
106
  var wrap = document.getElementById('modal-qa-thread-wrap');
107
+ var expandBar = document.getElementById('qa-expand-bar');
106
108
  if (wrap) wrap.style.display = 'none';
109
+ if (expandBar) expandBar.style.display = 'none';
107
110
  }
108
111
  }
109
112
 
@@ -113,7 +116,9 @@ function clearQaConversation() {
113
116
  _qaProcessing = false;
114
117
  document.getElementById('modal-qa-thread').innerHTML = '';
115
118
  var wrap = document.getElementById('modal-qa-thread-wrap');
119
+ var expandBar = document.getElementById('qa-expand-bar');
116
120
  if (wrap) wrap.style.display = 'none';
121
+ if (expandBar) expandBar.style.display = 'none';
117
122
  if (_qaSessionKey) _qaSessions.delete(_qaSessionKey);
118
123
  }
119
124
 
@@ -156,10 +161,14 @@ function modalSend() {
156
161
  document.getElementById('modal-qa-pill').style.display = 'none';
157
162
 
158
163
  if (_qaProcessing) {
164
+ if (_qaQueue.length >= QA_QUEUE_CAP) {
165
+ showToast('cmd-toast', 'Queue full — wait for current response', false);
166
+ return;
167
+ }
159
168
  // Queue the message — show it as "queued" in the thread
160
169
  _qaQueue.push({ message, selection });
161
- const preview = message.split(/\s+/).slice(0, 6).join(' ') + (message.split(/\s+/).length > 6 ? '...' : '');
162
- thread.innerHTML += '<div class="modal-qa-loading" style="color:var(--muted);font-size:10px">Queued: "' + escHtml(preview) + '" — will send after current response</div>';
170
+ const preview = escHtml(message.length > 60 ? message.slice(0, 57) + '...' : message);
171
+ thread.innerHTML += '<div class="qa-queued-item" style="color:var(--muted);font-size:10px;padding:4px 8px">Queued: "' + preview + '"</div>';
163
172
  thread.scrollTop = thread.scrollHeight;
164
173
  return;
165
174
  }
@@ -320,10 +329,9 @@ async function _processQaMessage(message, selection) {
320
329
  // Process next queued message
321
330
  if (_qaQueue.length > 0) {
322
331
  const next = _qaQueue.shift();
323
- const queuedEls = thread.querySelectorAll('.modal-qa-loading');
324
- for (const el of queuedEls) {
325
- if (el.textContent.includes('Queued')) { el.remove(); break; }
326
- }
332
+ // Remove the first queued indicator (matches the message being sent now)
333
+ const queuedEl = thread.querySelector('.qa-queued-item');
334
+ if (queuedEl) queuedEl.remove();
327
335
  _processQaMessage(next.message, next.selection);
328
336
  } else if (modalIsOpen) {
329
337
  document.getElementById('modal-qa-input')?.focus();
@@ -339,20 +347,18 @@ function qaAbort() {
339
347
 
340
348
  function toggleDocChat() {
341
349
  var wrap = document.getElementById('modal-qa-thread-wrap');
350
+ var expandBar = document.getElementById('qa-expand-bar');
342
351
  if (!wrap) return;
343
352
  var visible = wrap.style.display !== 'none';
344
353
  wrap.style.display = visible ? 'none' : '';
345
- var btn = document.getElementById('qa-toggle-btn');
346
- if (btn) btn.innerHTML = visible ? '&#x25B2; Expand' : '&#x25BC; Collapse';
354
+ if (expandBar) expandBar.style.display = visible ? '' : 'none';
347
355
  }
348
356
 
349
357
  function _showThreadWrap() {
350
358
  var wrap = document.getElementById('modal-qa-thread-wrap');
351
- if (wrap && wrap.style.display === 'none') {
352
- wrap.style.display = '';
353
- var btn = document.getElementById('qa-toggle-btn');
354
- if (btn) btn.innerHTML = '&#x25BC; Collapse';
355
- }
359
+ var expandBar = document.getElementById('qa-expand-bar');
360
+ if (wrap) wrap.style.display = '';
361
+ if (expandBar) expandBar.style.display = 'none';
356
362
  }
357
363
 
358
364
  window.MinionsQA = { showModalQa, modalAskAboutSelection, clearQaSelection, clearQaConversation, modalSend, qaAbort, toggleDocChat, _showThreadWrap };
@@ -73,8 +73,10 @@ function renderMetrics(metrics) {
73
73
 
74
74
  let html = '<table class="pr-table"><thead><tr><th>Agent</th><th>Done</th><th>Errors</th><th>PRs</th><th>Approved</th><th>Rejected</th><th>Rate</th><th>Reviews</th><th>Avg Runtime</th></tr></thead><tbody>';
75
75
  for (const [id, m] of rows) {
76
- const rate = m.prsCreated > 0 ? Math.round((m.prsApproved / m.prsCreated) * 100) + '%' : '-';
77
- const rateColor = m.prsCreated > 0 ? (m.prsApproved / m.prsCreated >= 0.7 ? 'var(--green)' : 'var(--red)') : 'var(--muted)';
76
+ const totalTasks = (m.tasksCompleted || 0) + (m.tasksErrored || 0);
77
+ const ratio = totalTasks > 0 ? m.tasksCompleted / totalTasks : 0;
78
+ const rate = totalTasks > 0 ? Math.round(ratio * 100) + '%' : '-';
79
+ const rateColor = totalTasks > 0 ? (ratio >= 0.7 ? 'var(--green)' : 'var(--red)') : 'var(--muted)';
78
80
  html += '<tr>' +
79
81
  '<td style="font-weight:600">' + escHtml(id) + '</td>' +
80
82
  '<td style="color:var(--green)">' + (m.tasksCompleted || 0) + '</td>' +
@@ -109,6 +109,9 @@
109
109
  </div>
110
110
  <div class="modal-body" id="modal-body"></div>
111
111
  <div class="modal-qa" id="modal-qa" style="display:none">
112
+ <div id="qa-expand-bar" style="display:none;text-align:center;padding:2px 0">
113
+ <button style="background:none;border:none;color:var(--muted);font-size:10px;cursor:pointer;padding:2px 6px" onclick="toggleDocChat()" title="Expand thread">&#x25B2; Expand chat</button>
114
+ </div>
112
115
  <div id="modal-qa-thread-wrap" style="display:none">
113
116
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
114
117
  <button id="qa-clear-btn" style="background:none;border:none;color:var(--muted);font-size:10px;cursor:pointer;padding:2px 6px" onclick="clearQaConversation()" title="Clear conversation">Clear</button>
package/dashboard.js CHANGED
@@ -746,7 +746,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
746
746
  store: 'doc', sessionKey,
747
747
  extraContext: docContext, label: 'doc-chat',
748
748
  allowedTools: canEdit ? 'Read,Write,Edit,Glob,Grep' : 'Read,Glob,Grep',
749
- maxTurns: canEdit ? 15 : 10,
749
+ maxTurns: canEdit ? 25 : 10,
750
750
  skipStatePreamble: true,
751
751
  ...(model ? { model } : {}),
752
752
  });
@@ -2868,12 +2868,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2868
2868
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2869
2869
  }
2870
2870
 
2871
+ const docChatInFlight = new Set(); // per-document concurrency guard
2871
2872
  async function handleDocChat(req, res) {
2872
2873
  try {
2873
2874
  const body = await readBody(req);
2874
2875
  if (!body.message) return jsonReply(res, 400, { error: 'message required' });
2875
2876
  if (!body.document) return jsonReply(res, 400, { error: 'document required' });
2876
2877
 
2878
+ // Per-document concurrency guard — prevent parallel writes to same file
2879
+ const docKey = body.filePath || body.title || 'default';
2880
+ if (docChatInFlight.has(docKey)) {
2881
+ return jsonReply(res, 429, { error: 'This document is already being processed — wait for the current response.' });
2882
+ }
2883
+ docChatInFlight.add(docKey);
2884
+
2885
+ try {
2877
2886
  const canEdit = !!body.filePath;
2878
2887
  const isJson = body.filePath?.endsWith('.json');
2879
2888
  let currentContent = body.document;
@@ -2999,6 +3008,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2999
3008
  return jsonReply(res, 200, { ok: true, answer, edited: true, content, actions, pausedPrd });
3000
3009
  }
3001
3010
  return jsonReply(res, 200, { ok: true, answer: answer + '\n\n(Read-only — changes not saved)', edited: false, actions });
3011
+ } finally { docChatInFlight.delete(docKey); }
3002
3012
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
3003
3013
  }
3004
3014
 
@@ -3682,6 +3692,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3682
3692
  if (!config.claude) config.claude = {};
3683
3693
  if (!config.agents) config.agents = {};
3684
3694
 
3695
+ const _clamped = [];
3685
3696
  if (body.engine) {
3686
3697
  const e = body.engine;
3687
3698
  const D = shared.ENGINE_DEFAULTS;
@@ -3695,14 +3706,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3695
3706
  versionCheckInterval: [60000],
3696
3707
  maxBuildFixAttempts: [1, 10],
3697
3708
  };
3698
- const clamped = [];
3699
3709
  for (const [key, [min, max]] of Object.entries(numericFields)) {
3700
3710
  if (e[key] !== undefined) {
3701
3711
  let val = Number(e[key]) || D[key];
3702
3712
  const raw = val;
3703
3713
  val = Math.max(min, val);
3704
3714
  if (max !== undefined) val = Math.min(max, val);
3705
- if (val !== raw) clamped.push(`${key}: ${raw} → ${val} (range: ${min}–${max || '∞'})`);
3715
+ if (val !== raw) _clamped.push(`${key}: ${raw} → ${val} (range: ${min}–${max || '∞'})`);
3706
3716
  config.engine[key] = val;
3707
3717
  }
3708
3718
  }
@@ -3766,10 +3776,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3766
3776
  reloadConfig();
3767
3777
  invalidateStatusCache();
3768
3778
  console.log('[settings] Saved config.json — engine keys:', Object.keys(config.engine || {}));
3769
- const msg = clamped.length > 0
3770
- ? 'Settings saved. Some values were adjusted: ' + clamped.join('; ')
3779
+ const msg = (_clamped.length > 0)
3780
+ ? 'Settings saved. Some values were adjusted: ' + _clamped.join('; ')
3771
3781
  : 'Settings saved. Engine picks up changes on next tick.';
3772
- return jsonReply(res, 200, { ok: true, message: msg, clamped });
3782
+ return jsonReply(res, 200, { ok: true, message: msg, clamped: _clamped });
3773
3783
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
3774
3784
  }
3775
3785
 
@@ -4040,13 +4050,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4040
4050
  description: '',
4041
4051
  agent: 'human',
4042
4052
  branch: '',
4043
- reviewStatus: autoObserve ? 'pending' : 'none',
4044
- status: autoObserve ? 'active' : 'linked',
4053
+ reviewStatus: 'pending',
4054
+ status: 'active',
4045
4055
  created: new Date().toISOString(),
4046
4056
  url,
4047
4057
  prdItems: [],
4048
4058
  _manual: true,
4049
- _autoObserve: !!autoObserve,
4059
+ _contextOnly: !autoObserve,
4050
4060
  _context: context || '',
4051
4061
  });
4052
4062
  return prs;
package/engine/ado.js CHANGED
@@ -189,7 +189,7 @@ async function forEachActivePr(config, token, callback) {
189
189
  if (!project.adoOrg || !project.adoProject || !project.repositoryId) continue;
190
190
 
191
191
  const prs = getPrs(project);
192
- const activePrs = prs.filter(pr => pr.status === PR_STATUS.ACTIVE);
192
+ const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status));
193
193
  if (activePrs.length === 0) continue;
194
194
 
195
195
  let projectUpdated = 0;
@@ -212,11 +212,18 @@ async function forEachActivePr(config, token, callback) {
212
212
 
213
213
  if (projectUpdated > 0) {
214
214
  mutateJsonFileLocked(shared.projectPrPath(project), (currentPrs) => {
215
- // Only merge back PRs that the callback actually modified not the entire
216
- // stale snapshot, which would overwrite concurrent writes from other code paths
215
+ // Merge back updated PRs preserve disk values that may have been changed
216
+ // by other writers between poll start and this write
217
217
  for (const updatedPr of activePrs) {
218
218
  const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
219
- if (idx >= 0) currentPrs[idx] = updatedPr;
219
+ if (idx >= 0) {
220
+ // Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
221
+ // The disk version may have been set to 'approved' by another writer after we read
222
+ if (currentPrs[idx].reviewStatus === 'approved' && updatedPr.reviewStatus !== 'approved') {
223
+ updatedPr.reviewStatus = 'approved';
224
+ }
225
+ currentPrs[idx] = updatedPr;
226
+ }
220
227
  // Don't push if not found — it was deleted by another writer, respect that
221
228
  }
222
229
  // Remove duplicates — prefer merged/abandoned over active
@@ -297,6 +304,12 @@ async function pollPrStatus(config) {
297
304
  pr._adoHeadCommit = headCommit;
298
305
  updated = true;
299
306
  }
307
+ // Track source commit separately — detects actual author pushes vs target branch updates
308
+ const sourceCommit = prData.lastMergeSourceCommit?.commitId || '';
309
+ if (sourceCommit && pr._adoSourceCommit !== sourceCommit) {
310
+ pr._adoSourceCommit = sourceCommit;
311
+ updated = true;
312
+ }
300
313
 
301
314
  const reviewers = prData.reviewers || [];
302
315
  const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
@@ -304,6 +317,20 @@ async function pollPrStatus(config) {
304
317
  // Once approved, it stays approved permanently
305
318
  if (pr.reviewStatus === 'approved') {
306
319
  newReviewStatus = 'approved';
320
+ // Re-approve: ADO resets votes when target branch (master) advances, even though
321
+ // the source branch is unchanged. Re-apply the approval vote via API.
322
+ if (!votes.some(v => v >= 5) && sourceCommit && pr._adoSourceCommit === sourceCommit) {
323
+ try {
324
+ const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token).catch(() => null);
325
+ const myId = identityData?.authenticatedUser?.id;
326
+ if (myId) {
327
+ await adoFetch(`${repoBase}/reviewers/${myId}?api-version=7.1`, token, {
328
+ method: 'PUT', body: JSON.stringify({ vote: 10 })
329
+ });
330
+ log('info', `PR ${pr.id}: re-applied approval vote (ADO reset due to target branch update)`);
331
+ }
332
+ } catch (e) { log('warn', `PR ${pr.id}: failed to re-apply approval: ${e.message}`); }
333
+ }
307
334
  } else if (votes.length > 0) {
308
335
  if (votes.some(v => v === -10)) {
309
336
  if (pr.reviewStatus === 'waiting' && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
@@ -334,24 +361,10 @@ async function pollPrStatus(config) {
334
361
  log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
335
362
  pr.reviewStatus = newReviewStatus;
336
363
  updated = true;
337
- // Update author metrics when verdict changes to approved/rejected
338
- if (newReviewStatus === 'approved' || newReviewStatus === 'changes-requested') {
339
- const authorId = (pr.agent || '').toLowerCase();
340
- if (authorId) {
341
- try {
342
- const metricsPath = path.join(__dirname, 'metrics.json');
343
- mutateJsonFileLocked(metricsPath, (metrics) => {
344
- if (!metrics[authorId]) metrics[authorId] = {};
345
- if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
346
- else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
347
- return metrics;
348
- });
349
- } catch (err) { log('warn', `Metrics update: ${err.message}`); }
350
- }
351
- if (newReviewStatus === 'approved') {
352
- delete pr._reviewFixCycles;
353
- delete pr._evalEscalated;
354
- }
364
+ shared.trackReviewMetric(pr, newReviewStatus, config);
365
+ if (newReviewStatus === 'approved') {
366
+ delete pr._reviewFixCycles;
367
+ delete pr._evalEscalated;
355
368
  }
356
369
  }
357
370
 
@@ -507,6 +520,8 @@ async function pollPrHumanComments(config) {
507
520
  const ignoredAuthors = (config.engine?.ignoredCommentAuthors || []).map(a => a.toLowerCase());
508
521
 
509
522
  for (const thread of threads) {
523
+ // Skip resolved/closed threads — only process active (1) and pending (6)
524
+ if (thread.status && thread.status !== 'active' && thread.status !== 1 && thread.status !== 6) continue;
510
525
  for (const comment of (thread.comments || [])) {
511
526
  if (!comment.content || comment.commentType === 'system') continue;
512
527
  const content = comment.content;
package/engine/cleanup.js CHANGED
@@ -582,6 +582,99 @@ function runCleanup(config, verbose = false) {
582
582
  }
583
583
  } catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
584
584
 
585
+ // 10. Prune CC tab sessions — cap at 50 entries, remove oldest beyond cap
586
+ cleaned.ccSessions = 0;
587
+ try {
588
+ const ccSessionsPath = path.join(ENGINE_DIR, 'cc-sessions.json');
589
+ const sessions = shared.safeJsonArr(ccSessionsPath);
590
+ const CC_SESSIONS_CAP = 50;
591
+ if (sessions.length > CC_SESSIONS_CAP) {
592
+ // Sort by lastActiveAt descending, keep newest
593
+ sessions.sort((a, b) => new Date(b.lastActiveAt || 0) - new Date(a.lastActiveAt || 0));
594
+ const pruned = sessions.slice(0, CC_SESSIONS_CAP);
595
+ cleaned.ccSessions = sessions.length - pruned.length;
596
+ safeWrite(ccSessionsPath, pruned);
597
+ }
598
+ } catch (e) { log('warn', 'prune cc-sessions: ' + e.message); }
599
+
600
+ // 10b. Prune doc-chat sessions — cap at 100 entries, remove oldest beyond cap
601
+ cleaned.docSessions = 0;
602
+ try {
603
+ const docSessionsPath = path.join(ENGINE_DIR, 'doc-sessions.json');
604
+ const docSessions = safeJson(docSessionsPath);
605
+ if (docSessions && typeof docSessions === 'object') {
606
+ const entries = Object.entries(docSessions);
607
+ const DOC_SESSIONS_CAP = 100;
608
+ if (entries.length > DOC_SESSIONS_CAP) {
609
+ entries.sort((a, b) => new Date(b[1].lastActiveAt || 0) - new Date(a[1].lastActiveAt || 0));
610
+ const keep = Object.fromEntries(entries.slice(0, DOC_SESSIONS_CAP));
611
+ cleaned.docSessions = entries.length - DOC_SESSIONS_CAP;
612
+ safeWrite(docSessionsPath, keep);
613
+ }
614
+ }
615
+ } catch (e) { log('warn', 'prune doc-sessions: ' + e.message); }
616
+
617
+ // 11. Cap cooldowns.json — keep at most 500 entries (on top of 24h TTL in cooldown.js)
618
+ cleaned.cooldowns = 0;
619
+ try {
620
+ const cooldownPath = path.join(ENGINE_DIR, 'cooldowns.json');
621
+ const cooldowns = safeJson(cooldownPath);
622
+ if (cooldowns && typeof cooldowns === 'object') {
623
+ const entries = Object.entries(cooldowns);
624
+ const COOLDOWN_CAP = 500;
625
+ if (entries.length > COOLDOWN_CAP) {
626
+ // Keep most recent by timestamp
627
+ entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0));
628
+ const keep = Object.fromEntries(entries.slice(0, COOLDOWN_CAP));
629
+ cleaned.cooldowns = entries.length - COOLDOWN_CAP;
630
+ safeWrite(cooldownPath, keep);
631
+ }
632
+ }
633
+ } catch (e) { log('warn', 'cap cooldowns: ' + e.message); }
634
+
635
+ // 12. Clean stale PID files — remove PID files whose process is no longer running
636
+ cleaned.pidFiles = 0;
637
+ try {
638
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
639
+ if (fs.existsSync(tmpDir)) {
640
+ let pidDirEntries;
641
+ try { pidDirEntries = fs.readdirSync(tmpDir); } catch { pidDirEntries = []; }
642
+ const activePids = new Set();
643
+ for (const [, info] of activeProcesses) {
644
+ if (info.proc?.pid) activePids.add(String(info.proc.pid));
645
+ }
646
+ for (const f of pidDirEntries) {
647
+ if (!f.startsWith('pid-') || !f.endsWith('.pid')) continue;
648
+ const fp = path.join(tmpDir, f);
649
+ try {
650
+ const pidStr = fs.readFileSync(fp, 'utf8').trim();
651
+ // Skip if actively tracked
652
+ if (activePids.has(pidStr)) continue;
653
+ // Check if file is stale (>1 hour old)
654
+ const stat = fs.statSync(fp);
655
+ if (stat.mtimeMs < oneHourAgo) {
656
+ fs.unlinkSync(fp);
657
+ cleaned.pidFiles++;
658
+ }
659
+ } catch { /* cleanup */ }
660
+ }
661
+ }
662
+ } catch (e) { log('warn', 'clean stale PID files: ' + e.message); }
663
+
664
+ // 13. Prune test-results.json — keep last 200 entries
665
+ try {
666
+ const testResultsPath = path.join(ENGINE_DIR, 'test-results.json');
667
+ const results = shared.safeJsonArr(testResultsPath);
668
+ const TEST_RESULTS_CAP = 200;
669
+ if (results.length > TEST_RESULTS_CAP) {
670
+ safeWrite(testResultsPath, results.slice(-TEST_RESULTS_CAP));
671
+ }
672
+ } catch { /* optional — file may not exist */ }
673
+
674
+ if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles > 0) {
675
+ log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pidFiles} PID files`);
676
+ }
677
+
585
678
  return cleaned;
586
679
  }
587
680
 
package/engine/github.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, 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, PR_POLLABLE_STATUSES } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const path = require('path');
11
11
 
@@ -164,7 +164,7 @@ async function forEachActiveGhPr(config, callback) {
164
164
  if (isSlugInBackoff(slug)) continue;
165
165
 
166
166
  const prs = getPrs(project);
167
- const activePrs = prs.filter(pr => pr.status === PR_STATUS.ACTIVE);
167
+ const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status));
168
168
  if (activePrs.length === 0) continue;
169
169
 
170
170
  // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
@@ -194,7 +194,13 @@ async function forEachActiveGhPr(config, callback) {
194
194
  // Merge back updated PRs and deduplicate
195
195
  for (const updatedPr of activePrs) {
196
196
  const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
197
- if (idx >= 0) currentPrs[idx] = updatedPr;
197
+ if (idx >= 0) {
198
+ // Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
199
+ if (currentPrs[idx].reviewStatus === 'approved' && updatedPr.reviewStatus !== 'approved') {
200
+ updatedPr.reviewStatus = 'approved';
201
+ }
202
+ currentPrs[idx] = updatedPr;
203
+ }
198
204
  }
199
205
  // Remove duplicates — prefer merged/abandoned over active
200
206
  const bestById = new Map();
@@ -214,7 +220,7 @@ async function forEachActiveGhPr(config, callback) {
214
220
  // Also poll manually-linked PRs from central pull-requests.json (extract slug from URL)
215
221
  const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
216
222
  const centralPrs = safeJson(centralPath) || [];
217
- const activeCentral = centralPrs.filter(pr => pr.status === PR_STATUS.ACTIVE && pr.url);
223
+ const activeCentral = centralPrs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status) && pr.url);
218
224
  let centralUpdated = 0;
219
225
  for (const pr of activeCentral) {
220
226
  const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
@@ -348,21 +354,7 @@ async function pollPrStatus(config) {
348
354
  log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
349
355
  pr.reviewStatus = newReviewStatus;
350
356
  updated = true;
351
- // Update author metrics when verdict changes to approved/rejected
352
- if (newReviewStatus === 'approved' || newReviewStatus === 'changes-requested') {
353
- const authorId = (pr.agent || '').toLowerCase();
354
- if (authorId) {
355
- try {
356
- const metricsPath = path.join(__dirname, 'metrics.json');
357
- mutateJsonFileLocked(metricsPath, (metrics) => {
358
- if (!metrics[authorId]) metrics[authorId] = {};
359
- if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
360
- else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
361
- return metrics;
362
- });
363
- } catch (err) { log('warn', `Metrics update: ${err.message}`); }
364
- }
365
- }
357
+ shared.trackReviewMetric(pr, newReviewStatus, config);
366
358
  // Reset review→fix cycle counter on approval (loop succeeded)
367
359
  if (newReviewStatus === 'approved') {
368
360
  delete pr._reviewFixCycles;
package/engine/shared.js CHANGED
@@ -578,7 +578,24 @@ const PLAN_STATUS = {
578
578
  PAUSED: 'paused', REJECTED: 'rejected', COMPLETED: 'completed',
579
579
  REVISION_REQUESTED: 'revision-requested',
580
580
  };
581
- const PR_STATUS = { ACTIVE: 'active', MERGED: 'merged', ABANDONED: 'abandoned', CLOSED: 'closed' };
581
+ const PR_STATUS = { ACTIVE: 'active', MERGED: 'merged', ABANDONED: 'abandoned', CLOSED: 'closed', LINKED: 'linked' };
582
+ // PRs eligible for polling (status/build/comment checks) — excludes terminal statuses
583
+ const PR_POLLABLE_STATUSES = new Set([PR_STATUS.ACTIVE, PR_STATUS.LINKED]);
584
+
585
+ /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
586
+ function trackReviewMetric(pr, newReviewStatus, config) {
587
+ if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
588
+ const authorId = (pr.agent || '').toLowerCase();
589
+ if (!authorId || !config?.agents?.[authorId]) return;
590
+ try {
591
+ mutateJsonFileLocked(path.join(__dirname, 'metrics.json'), (metrics) => {
592
+ if (!metrics[authorId]) metrics[authorId] = {};
593
+ if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
594
+ else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
595
+ return metrics;
596
+ });
597
+ } catch (err) { log('warn', `Metrics update: ${err.message}`); }
598
+ }
582
599
  const DISPATCH_RESULT = { SUCCESS: 'success', ERROR: 'error', TIMEOUT: 'timeout' };
583
600
  const PIPELINE_STATUS = {
584
601
  PENDING: 'pending', RUNNING: 'running', COMPLETED: 'completed',
@@ -995,7 +1012,7 @@ module.exports = {
995
1012
  KB_CATEGORIES,
996
1013
  classifyInboxItem,
997
1014
  ENGINE_DEFAULTS,
998
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT,
1015
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, PR_POLLABLE_STATUSES, DISPATCH_RESULT, trackReviewMetric,
999
1016
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
1000
1017
  FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
1001
1018
  DEFAULT_AGENT_METRICS,
package/engine/timeout.js CHANGED
@@ -316,6 +316,12 @@ function checkTimeouts(config) {
316
316
  const procInfo = activeProcesses.get(item.id);
317
317
  if (procInfo) {
318
318
  shared.killGracefully(procInfo.proc, 5000);
319
+ // On Unix, also kill child process tree (killGracefully only hits parent PID)
320
+ if (process.platform !== 'win32' && procInfo.proc?.pid) {
321
+ setTimeout(() => {
322
+ try { shared.exec(`pkill -KILL -P ${procInfo.proc.pid}`, { timeout: 3000 }); } catch { /* children may already be dead */ }
323
+ }, 6000); // after grace period
324
+ }
319
325
  activeProcesses.delete(item.id);
320
326
  }
321
327
  // Clear session so retry starts fresh instead of resuming the killed session
package/engine.js CHANGED
@@ -1576,7 +1576,7 @@ async function discoverFromPrs(config, project) {
1576
1576
 
1577
1577
  const knownAgents = new Set(Object.keys(config.agents || {}));
1578
1578
  for (const pr of prs) {
1579
- if (pr.status !== 'active') continue;
1579
+ if (pr.status !== PR_STATUS.ACTIVE || pr._contextOnly) continue;
1580
1580
  if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
1581
1581
  // Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
1582
1582
  if (pr.branch && isBranchActive(pr.branch)) {
@@ -1629,13 +1629,14 @@ async function discoverFromPrs(config, project) {
1629
1629
  const liveStatus = await checkFn(pr, project);
1630
1630
  if (liveStatus && liveStatus !== 'pending') {
1631
1631
  log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was pending) — skipping review`);
1632
- pr.reviewStatus = liveStatus;
1632
+ // Never downgrade from approved
1633
+ if (pr.reviewStatus !== 'approved') pr.reviewStatus = liveStatus;
1633
1634
  // Persist so next tick doesn't re-check
1634
1635
  try {
1635
1636
  mutateJsonFileLocked(projectPrPath(project), data => {
1636
1637
  if (!Array.isArray(data)) return data;
1637
1638
  const target = data.find(p => p.id === pr.id);
1638
- if (target) target.reviewStatus = liveStatus;
1639
+ if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
1639
1640
  return data;
1640
1641
  });
1641
1642
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.808",
3
+ "version": "0.1.810",
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"
package/playbooks/fix.md CHANGED
@@ -29,7 +29,9 @@ Use subagents only for genuinely parallel, independent tasks. For sequential wor
29
29
 
30
30
  1. You are already in the correct worktree on branch `{{pr_branch}}`. Do NOT create additional worktrees.
31
31
 
32
- 2. Fix each issue listed above
32
+ 2. For each issue listed above, use your judgment:
33
+ - **Fix it** if the feedback is valid and improves the code
34
+ - **Explain your rationale** if you believe the current approach is correct — reply on the review thread explaining why, with specific reasoning (e.g., performance, consistency with codebase patterns, intentional design choice). Do NOT silently ignore feedback — always respond.
33
35
 
34
36
  3. Handle merge conflicts if any:
35
37
  - If `git pull` or the PR shows conflicts, resolve them in the worktree
@@ -64,11 +66,11 @@ Do NOT remove the worktree — the engine handles cleanup automatically.
64
66
 
65
67
  ## Resolve Review Comments
66
68
 
67
- After pushing, resolve each review comment/thread that you've addressed:
68
- - **GitHub**: Reply to each review comment confirming the fix, then resolve the conversation if possible
69
- - **ADO**: Reply to each thread with what was fixed, then set the thread status to `fixed` or `closed`
70
-
71
- Do NOT leave review threads open if you've addressed the finding unresolved threads block auto-merge on some repos and create noise for human reviewers.
69
+ After pushing, respond to each review comment/thread:
70
+ - **If you fixed it**: Reply confirming the fix, then resolve the thread
71
+ - **If you chose not to fix it**: Reply with your rationale explaining why the current approach is preferred — leave the thread open for the reviewer to decide
72
+ - **GitHub**: Reply to each review comment, resolve conversations you've fixed
73
+ - **ADO**: Reply to each thread, set status to `fixed` or `closed` for fixes; leave `active` for rationale replies
72
74
 
73
75
  ## When to Stop
74
76
 
@@ -10,9 +10,29 @@ Minions state lives in `{{minions_dir}}/`. Key paths: `config.json` (config), `r
10
10
 
11
11
  ## Role: Orchestrator
12
12
  Default: **delegate to agents**. Agents have full Claude Code + worktrees + MCP tools.
13
- DELEGATE: code changes, fixes, PRs, reviews, exploration, testing, plans, architecture analysis.
14
- SELF: quick file reads, status lookups, notes/plan edits, routing updates, git ops user asked for.
15
- For exploration/investigation/research/audits ALWAYS dispatch an `explore` work item.
13
+
14
+ ### When to delegate (ALWAYS)
15
+ - Code changes, fixes, refactors, new features → `implement` or `fix`
16
+ - Exploration, investigation, research, audits → `explore`
17
+ - Code reviews → `review`
18
+ - Testing → `test`
19
+ - Architecture analysis → `explore`
20
+ - Any task that would require **more than 3 tool calls** or touching **more than 2 files**
21
+
22
+ ### When to do it yourself (ONLY these)
23
+ - Quick status lookups (reading 1-2 state files)
24
+ - Notes, plan edits, KB entries, routing updates
25
+ - Git ops the user explicitly asked CC to do
26
+ - Simple config changes (`set-config`)
27
+ - Answering questions from context you already have
28
+
29
+ ### Size estimation rule
30
+ Before responding, estimate the task size:
31
+ - **Small** (≤3 tool calls, 1-2 files): do it yourself
32
+ - **Medium** (4-10 tool calls, 3+ files): DELEGATE to an agent
33
+ - **Large** (10+ tool calls, cross-cutting): DELEGATE, consider a plan with decomposition
34
+
35
+ When in doubt, delegate. You are the dispatcher, not the worker. Agents have isolated worktrees, full tool access, and no turn limits — they are better suited for real work.
16
36
 
17
37
  ## Actions
18
38
  Append actions at the END of your response. Write your response first, then `===ACTIONS===` on its own line, then a JSON array. No text after the JSON. Omit entirely if no actions needed.
@@ -57,4 +77,4 @@ Terms like schedules, pipelines, agents, inbox, work items, plans, PRD, PRs, dis
57
77
  1. Answer from the state preamble and context first. Only use tools for specific file lookups the user asked about — not to explore or investigate.
58
78
  2. Be specific — cite IDs, names, filenames, line numbers.
59
79
  3. Never modify engine source. Never push to git without user confirmation.
60
- 4. Delegate exploration to agents. You are the dispatcher, not the worker. If answering requires reading more than 2-3 files, dispatch an agent instead.
80
+ 4. Delegate, don't do. If a task involves code changes, multi-file reads, debugging, or any real engineering work dispatch an agent. Your tools are for quick lookups, not for doing the work.