@yemi33/minions 0.1.205 → 0.1.207

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,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.207 (2026-04-02)
4
+
5
+ ### Engine
6
+ - engine.js
7
+ - engine/lifecycle.js
8
+ - engine/queries.js
9
+ - engine/shared.js
10
+ - engine/timeout.js
11
+
12
+ ### Dashboard
13
+ - dashboard.js
14
+ - dashboard/js/refresh.js
15
+ - dashboard/js/render-plans.js
16
+ - dashboard/js/settings.js
17
+
18
+ ## 0.1.206 (2026-04-02)
19
+
20
+ ### Other
21
+ - minions.js
22
+
3
23
  ## 0.1.205 (2026-04-02)
4
24
 
5
25
  ### Engine
@@ -1,90 +1,30 @@
1
1
  // refresh.js — Main refresh loop and initialization extracted from dashboard.html
2
2
 
3
3
  // Sidebar activity indicators — detect changes between refreshes
4
+ // Registry: add one line per page. Counter returns a value; badge shows when value increases.
5
+ const _pageCounters = {
6
+ home: function(d) { return (d.dispatch?.completed || []).length; },
7
+ work: function(d) { return (d.workItems || []).length + '|' + (d.workItems || []).filter(function(w) { return w.status === 'done' || w.status === 'failed'; }).length; },
8
+ plans: function(d) { return (d.prdProgress?.complete || 0) + '|' + (d.plans || []).length; },
9
+ prs: function(d) { return (d.pullRequests || []).filter(function(p) { return p.status === 'merged'; }).length; },
10
+ inbox: function(d) { return (d.inbox?.items || []).length; },
11
+ meetings: function(d) { return (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
12
+ pipelines: function(d) { return (d.pipelines || []).reduce(function(s, p) { return s + (p.runs || []).length; }, 0); },
13
+ schedule: function(d) { return (d.schedules || []).length; },
14
+ engine: function(d) { return (d.dispatch?.active || []).length; },
15
+ };
4
16
  let _prevCounts = {};
5
- let _prevEngineAlert = false;
6
17
  function _detectPageChanges(data) {
7
- const counts = {
8
- completions: (data.dispatch?.completed || []).length,
9
- activeDispatches: (data.dispatch?.active || []).length,
10
- workDone: (data.workItems || []).filter(w => w.status === 'done' || w.status === 'failed').length,
11
- workTotal: (data.workItems || []).length,
12
- workDispatched: (data.workItems || []).filter(w => w.status === 'dispatched').length,
13
- prdComplete: data.prdProgress?.complete || 0,
14
- prdInProgress: data.prdProgress?.inProgress || 0,
15
- plansTotal: (data.plans || []).length,
16
- prsTotal: (data.pullRequests || []).length,
17
- prsMerged: (data.pullRequests || []).filter(p => p.status === 'merged').length,
18
- prsReviewed: (data.pullRequests || []).filter(p => p.reviewStatus === 'approved' || p.reviewStatus === 'changes-requested').length,
19
- inbox: (data.inbox?.items || []).length,
20
- kbTotal: Object.values(data.knowledgeBase || {}).reduce((s, v) => s + (Array.isArray(v) ? v.length : 0), 0),
21
- skillsTotal: (data.skills || []).length,
22
- mcpTotal: (data.mcpServers || []).length,
23
- scheduleTotal: (data.schedules || []).length,
24
- pipelineRuns: (data.pipelines || []).reduce((s, p) => s + (p.runs || []).length, 0),
25
- pipelineActive: (data.pipelines || []).filter(p => (p.runs || []).some(r => r.status === 'running')).length,
26
- meetingRounds: (data.meetings || []).reduce((s, m) => s + (m.round || 0), 0),
27
- meetingTotal: (data.meetings || []).length,
28
- };
29
- const changes = {};
30
- if (_prevCounts.completions !== undefined) {
31
- if (counts.completions > _prevCounts.completions || counts.activeDispatches > _prevCounts.activeDispatches) changes.home = true;
32
- if (counts.workDone > _prevCounts.workDone || counts.workTotal > _prevCounts.workTotal || counts.workDispatched !== _prevCounts.workDispatched) changes.work = true;
33
- if (counts.prdComplete > _prevCounts.prdComplete || counts.prdInProgress > _prevCounts.prdInProgress || counts.plansTotal > _prevCounts.plansTotal) changes.plans = true;
34
- if (counts.prsTotal > _prevCounts.prsTotal || counts.prsMerged > _prevCounts.prsMerged || counts.prsReviewed > _prevCounts.prsReviewed) changes.prs = true;
35
- if (counts.inbox > _prevCounts.inbox || counts.kbTotal > _prevCounts.kbTotal) changes.inbox = true;
36
- if (counts.skillsTotal > _prevCounts.skillsTotal || counts.mcpTotal > _prevCounts.mcpTotal) changes.tools = true;
37
- if (counts.scheduleTotal > _prevCounts.scheduleTotal) changes.schedule = true;
38
- if (counts.pipelineRuns > _prevCounts.pipelineRuns || counts.pipelineActive > _prevCounts.pipelineActive) changes.pipelines = true;
39
- if (counts.meetingRounds > _prevCounts.meetingRounds || counts.meetingTotal > _prevCounts.meetingTotal) changes.meetings = true;
18
+ var changes = {};
19
+ var counts = {};
20
+ for (var page in _pageCounters) {
21
+ counts[page] = String(_pageCounters[page](data));
22
+ if (_prevCounts[page] !== undefined && counts[page] !== _prevCounts[page]) changes[page] = true;
40
23
  }
41
24
  _prevCounts = counts;
42
-
43
- // Engine page — only badge for genuine problems, not routine activity
44
- const engineAlert = _isEngineAlertWorthy(data);
45
- if (engineAlert && !_prevEngineAlert) changes.engine = true;
46
- // Clear the engine badge when alert condition resolves
47
- if (!engineAlert && _prevEngineAlert) {
48
- const engineLink = document.querySelector('.sidebar-link[data-page="engine"]');
49
- if (engineLink) clearNotifBadge(engineLink);
50
- }
51
- _prevEngineAlert = engineAlert;
52
-
53
25
  return changes;
54
26
  }
55
27
 
56
- /**
57
- * Determine if the engine state warrants a notification dot.
58
- * Returns true only for genuine problems:
59
- * - Engine stopped, stale, or in error state
60
- * - 3+ failed work items in the last hour
61
- * - Agent timeout/crash detected (error results in recent completions)
62
- */
63
- function _isEngineAlertWorthy(data) {
64
- // 1. Engine not running (stopped, stale, or error)
65
- const engineState = data.engine?.state || 'stopped';
66
- if (engineState === 'stopped' || engineState === 'error') return true;
67
- // Stale heartbeat (>2 min old while claiming running)
68
- if (engineState === 'running' && data.engine?.heartbeat) {
69
- if (Date.now() - data.engine.heartbeat > 120000) return true;
70
- }
71
-
72
- // 2. 3+ failed work items in the last hour
73
- const oneHourAgo = Date.now() - 3600000;
74
- const recentFailures = (data.workItems || []).filter(w =>
75
- w.status === 'failed' && w.updated_at && new Date(w.updated_at).getTime() > oneHourAgo
76
- );
77
- if (recentFailures.length >= 3) return true;
78
-
79
- // 3. Agent timeout/crash — 3+ error results in recent completed dispatches
80
- const recentErrors = (data.dispatch?.completed || []).filter(d =>
81
- d.result === 'error' && d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
82
- );
83
- if (recentErrors.length >= 3) return true;
84
-
85
- return false;
86
- }
87
-
88
28
  function _processStatusUpdate(data) {
89
29
  // Detect fresh install — clear stale browser state if install ID changed
90
30
  if (data.installId) {
@@ -97,24 +37,15 @@ function _processStatusUpdate(data) {
97
37
  }
98
38
  document.getElementById('ts').textContent = new Date(data.timestamp).toLocaleTimeString();
99
39
  const engineState = (data.engine && data.engine.state) ? data.engine.state : 'stopped';
100
-
101
- // Engine status FIRST — most critical indicator, must not be blocked by other render failures
102
- renderEngineStatus(data.engine);
103
-
104
- // Show setup banner if no projects linked (the key onboarding step)
105
- const noProjects = !data.projects || data.projects.length === 0;
106
- document.getElementById('setup-banner').style.display = (noProjects && engineState !== 'stopped') ? 'block' : 'none';
107
-
108
- // Each render call wrapped so one failure doesn't block the rest
109
- const _r = (fn) => { try { fn(); } catch (e) { console.error('render error:', e.message); } };
110
- _r(() => renderAgents(data.agents));
111
- _r(() => renderPrdProgress(data.prdProgress));
112
- _r(() => _cachePrdItems(data.prdProgress));
113
- _r(() => renderInbox(data.inbox));
114
- _r(() => cmdUpdateAgentList(data.agents));
115
- _r(() => cmdUpdateProjectList(data.projects || []));
116
- _r(() => renderNotes(data.notes));
117
- _r(() => renderPrd(data.prd, data.prdProgress));
40
+ document.getElementById('setup-banner').style.display = (!data.initialized && engineState !== 'stopped') ? 'block' : 'none';
41
+ renderAgents(data.agents);
42
+ renderPrdProgress(data.prdProgress);
43
+ _cachePrdItems(data.prdProgress);
44
+ renderInbox(data.inbox);
45
+ cmdUpdateAgentList(data.agents);
46
+ cmdUpdateProjectList(data.projects || []);
47
+ renderNotes(data.notes);
48
+ renderPrd(data.prd, data.prdProgress);
118
49
  // Auto-approve badge
119
50
  const autoEl = document.getElementById('auto-approve-badge');
120
51
  if (autoEl) autoEl.innerHTML = data.autoMode?.approvePlans
@@ -123,23 +54,24 @@ function _processStatusUpdate(data) {
123
54
  // Inbox consolidation threshold from config
124
55
  const threshEl = document.getElementById('inbox-threshold');
125
56
  if (threshEl && data.autoMode?.inboxThreshold) threshEl.textContent = data.autoMode.inboxThreshold;
126
- _r(() => renderPrs(data.pullRequests || []));
127
- _r(() => renderArchiveButtons(data.archivedPrds || []));
128
- _r(() => renderDispatch(data.dispatch));
57
+ renderPrs(data.pullRequests || []);
58
+ renderArchiveButtons(data.archivedPrds || []);
59
+ renderEngineStatus(data.engine);
60
+ renderDispatch(data.dispatch);
129
61
  window._lastDispatch = data.dispatch;
130
62
  window._lastWorkItems = data.workItems || [];
131
63
  window._lastStatus = data;
132
- _r(() => prunePrdRequeueState(window._lastWorkItems));
133
- _r(() => renderEngineLog(data.engineLog || []));
134
- _r(() => renderProjects(data.projects || []));
135
- _r(() => renderMetrics(data.metrics || {}));
136
- _r(() => renderWorkItems(data.workItems || []));
137
- _r(() => renderSkills(data.skills || []));
138
- _r(() => renderMcpServers(data.mcpServers || []));
139
- _r(() => renderSchedules(data.schedules || []));
140
- _r(() => renderMeetings(data.meetings || []));
141
- _r(() => { if (typeof renderPipelines === 'function') renderPipelines(data.pipelines || []); });
142
- _r(() => renderPinned(data.pinned || []));
64
+ prunePrdRequeueState(window._lastWorkItems);
65
+ renderEngineLog(data.engineLog || []);
66
+ renderProjects(data.projects || []);
67
+ renderMetrics(data.metrics || {});
68
+ renderWorkItems(data.workItems || []);
69
+ renderSkills(data.skills || []);
70
+ renderMcpServers(data.mcpServers || []);
71
+ renderSchedules(data.schedules || []);
72
+ renderMeetings(data.meetings || []);
73
+ if (typeof renderPipelines === 'function') renderPipelines(data.pipelines || []);
74
+ renderPinned(data.pinned || []);
143
75
  // Update sidebar counts
144
76
  const swi = document.getElementById('sidebar-wi');
145
77
  if (swi) swi.textContent = (data.workItems || []).length || '';
@@ -170,16 +102,7 @@ async function refresh() {
170
102
  refresh();
171
103
 
172
104
  // Poll for status updates (SSE caused HTTP/1.1 connection exhaustion — CC fetch would fail)
173
- var _refreshTimer = setInterval(refresh, 4000);
174
- document.addEventListener('visibilitychange', function() {
175
- if (document.hidden) {
176
- clearInterval(_refreshTimer);
177
- _refreshTimer = null;
178
- } else if (!_refreshTimer) {
179
- refresh();
180
- _refreshTimer = setInterval(refresh, 4000);
181
- }
182
- });
105
+ setInterval(refresh, 4000);
183
106
 
184
107
  // Wire sidebar navigation
185
108
  document.querySelectorAll('.sidebar-link').forEach(link => {
@@ -223,7 +223,7 @@ function renderPlans(plans) {
223
223
  actions = '<div class="plan-card-meta" style="margin-top:6px;color:var(--purple,#a855f7)">Revision in progress: ' + escHtml((p.revisionFeedback || '').slice(0, 100)) + '</div>';
224
224
  }
225
225
 
226
- const executeBtn = isDraft && effectiveStatus === 'active' && !isArchived && !prdFile ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);font-weight:600" ' +
226
+ const executeBtn = isDraft && (effectiveStatus === 'active' || effectiveStatus === 'draft') && !isArchived && !prdFile ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);font-weight:600" ' +
227
227
  'onclick="event.stopPropagation();planExecute(\'' + escHtml(p.file) + '\',\'' + escHtml(p.project) + '\',this)">Execute</button>' : '';
228
228
  const showPause = effectiveStatus === 'in-progress' && prdFile && !isArchived;
229
229
  const showResume = (effectiveStatus === 'paused' || effectiveStatus === 'awaiting-approval') && prdFile && !isArchived;
@@ -54,6 +54,15 @@ async function openSettings() {
54
54
  settingsField('Output Format', 'set-outputFormat', c.outputFormat || 'stream-json', '', '') +
55
55
  settingsField('Allowed Tools', 'set-allowedTools', c.allowedTools || '', '', 'Comma-separated (empty = all)') +
56
56
  '</div>' +
57
+ '<div style="margin-bottom:16px">' +
58
+ '<label style="font-size:10px;color:var(--muted);display:block;margin-bottom:2px">Permission Mode <span style="opacity:0.6">(how agents handle tool approvals)</span></label>' +
59
+ '<select id="set-permissionMode" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px">' +
60
+ '<option value="bypassPermissions"' + ((c.permissionMode || 'bypassPermissions') === 'bypassPermissions' ? ' selected' : '') + '>Bypass (recommended) — agents run without permission prompts</option>' +
61
+ '<option value="auto"' + ((c.permissionMode) === 'auto' ? ' selected' : '') + '>Auto — agents auto-approve safe tools, prompt for risky ones</option>' +
62
+ '<option value="default"' + ((c.permissionMode) === 'default' ? ' selected' : '') + '>Default — agents prompt for every tool (will hang without a human)</option>' +
63
+ '</select>' +
64
+ '<div style="font-size:9px;color:var(--muted);margin-top:2px">Non-bypass modes require a human watching the agent terminal to approve tool calls</div>' +
65
+ '</div>' +
57
66
 
58
67
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Agents</h3>' +
59
68
  '<table style="width:100%;border-collapse:collapse;margin-bottom:16px;font-size:11px">' +
@@ -138,6 +147,7 @@ async function saveSettings() {
138
147
  const claudePayload = {
139
148
  outputFormat: document.getElementById('set-outputFormat').value,
140
149
  allowedTools: document.getElementById('set-allowedTools').value,
150
+ permissionMode: document.getElementById('set-permissionMode').value,
141
151
  };
142
152
 
143
153
  const agentsPayload = {};
package/dashboard.js CHANGED
@@ -2711,7 +2711,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2711
2711
  const { execSync: ex } = require('child_process');
2712
2712
  const detected = { name: path.basename(target), _found: [] };
2713
2713
  try {
2714
- const head = ex('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || git symbolic-ref HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
2714
+ let head = '';
2715
+ try {
2716
+ head = ex('git symbolic-ref refs/remotes/origin/HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
2717
+ } catch {
2718
+ head = ex('git symbolic-ref HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
2719
+ }
2715
2720
  detected.mainBranch = head.replace('refs/remotes/origin/', '').replace('refs/heads/', '');
2716
2721
  } catch { detected.mainBranch = 'main'; }
2717
2722
  try {
@@ -2982,6 +2987,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2982
2987
  for (const key of ['allowedTools', 'outputFormat']) {
2983
2988
  if (body.claude[key] !== undefined) config.claude[key] = String(body.claude[key]);
2984
2989
  }
2990
+ if (body.claude.permissionMode !== undefined) {
2991
+ const valid = ['bypassPermissions', 'auto', 'default'];
2992
+ config.claude.permissionMode = valid.includes(body.claude.permissionMode) ? body.claude.permissionMode : 'bypassPermissions';
2993
+ }
2985
2994
  }
2986
2995
 
2987
2996
  if (body.agents) {
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
- const { safeRead, safeJson, safeWrite, safeReadDir, execSilent, projectPrPath, getPrLinks,
9
+ const { safeRead, safeJson, safeWrite, safeReadDir, execSilent, projectPrPath, getPrLinks, addPrLink,
10
10
  mutateJsonFileLocked, log, ts, dateStamp } = shared;
11
11
  const { trackEngineUsage } = require('./llm');
12
12
  const queries = require('./queries');
@@ -58,7 +58,7 @@ function checkPlanCompletion(meta, config) {
58
58
  const unmaterialized = [...planFeatureIds].filter(id => {
59
59
  if (workItemById[id]) return false;
60
60
  const prdItem = (plan.missing_features || []).find(f => f.id === id);
61
- return !(prdItem && (prdItem.status === 'done'));
61
+ return !(prdItem && (prdItem.status === 'done' || prdItem.status === 'in-pr'));
62
62
  });
63
63
  if (unmaterialized.length > 0) {
64
64
  log('info', `Plan ${planFile}: ${unmaterialized.length}/${planFeatureIds.size} feature(s) not yet materialized as work items: ${unmaterialized.join(', ')}`);
@@ -68,16 +68,16 @@ function checkPlanCompletion(meta, config) {
68
68
  // Check 2: every feature's work item must be done (or PRD item marked done externally)
69
69
  const notDone = [...planFeatureIds].filter(id => {
70
70
  const w = workItemById[id];
71
- if (w && (w.status === 'done')) return false; // in-pr accepted for backward compat
71
+ if (w && (w.status === 'done' || w.status === 'in-pr')) return false; // in-pr accepted for backward compat
72
72
  const prdItem = (plan.missing_features || []).find(f => f.id === id);
73
- return !(prdItem && (prdItem.status === 'done'));
73
+ return !(prdItem && (prdItem.status === 'done' || prdItem.status === 'in-pr'));
74
74
  });
75
75
  if (notDone.length > 0) {
76
76
  log('info', `Plan ${planFile}: waiting for done on ${notDone.length}/${planFeatureIds.size} item(s): ${notDone.join(', ')}`);
77
77
  return;
78
78
  }
79
79
 
80
- const doneItems = planItems.filter(w => w.status === 'done');
80
+ const doneItems = planItems.filter(w => w.status === 'done' || w.status === 'in-pr');
81
81
  const failedItems = planItems.filter(w => w.status === 'failed');
82
82
 
83
83
  // 1. Mark plan as completed
@@ -475,6 +475,7 @@ function updateWorkItemStatus(meta, status, reason) {
475
475
  }
476
476
  } else {
477
477
  target.status = status;
478
+ delete target._pendingReason;
478
479
  if (status === 'done') {
479
480
  delete target.failReason;
480
481
  delete target.failedAt;
@@ -534,7 +535,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
534
535
  for (const block of content) {
535
536
  if (block.type === 'tool_result' && block.content) {
536
537
  const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
537
- if (text.includes('pullRequestId') || text.includes('create_pull_request')) {
538
+ if (text.includes('pullRequestId') || text.includes('create_pull_request') || text.includes('/pull/') || text.includes('pullrequest/')) {
538
539
  while ((match = urlPattern.exec(text)) !== null) prMatches.add(match[1] || match[2]);
539
540
  }
540
541
  }
@@ -624,10 +625,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
624
625
  sourcePlan: meta?.item?.sourcePlan || '',
625
626
  itemType: meta?.item?.itemType || ''
626
627
  });
627
- if (meta?.item?.id) {
628
- const project = config ? shared.getProjects(config)[0] : null;
629
- if (project) shared.linkPrToItem(project, fullId, meta.item.id);
630
- }
628
+ if (meta?.item?.id) addPrLink(fullId, meta.item.id);
631
629
  added++;
632
630
  }
633
631
 
@@ -735,7 +733,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
735
733
  // Mark review as approved since it was merged
736
734
  pr.reviewStatus = 'approved';
737
735
 
738
- // Resolve linked work item from PR.prdItems or PR branch name
736
+ // Resolve linked work item from pr-links or PR branch name
739
737
  let mergedItemId = getPrLinks()[pr.id];
740
738
  if (!mergedItemId && pr.branch) {
741
739
  const branchMatch = pr.branch.match(/(P-[a-z0-9]{6,})/i) || pr.branch.match(/(W-[a-z0-9]+)/i);
@@ -752,13 +750,13 @@ async function handlePostMerge(pr, project, config, newStatus) {
752
750
  const plan = safeJson(path.join(prdDir, pf));
753
751
  if (!plan?.missing_features) continue;
754
752
  const feature = plan.missing_features.find(f => f.id === mergedItemId);
755
- if (feature && feature.status !== 'done') {
756
- feature.status = 'done';
753
+ if (feature && feature.status !== 'implemented') {
754
+ feature.status = 'implemented';
757
755
  shared.safeWrite(path.join(prdDir, pf), plan);
758
756
  updated++;
759
757
  }
760
758
  }
761
- if (updated > 0) log('info', `Post-merge: marked ${mergedItemId} as done for ${pr.id}`);
759
+ if (updated > 0) log('info', `Post-merge: marked ${mergedItemId} as implemented for ${pr.id}`);
762
760
  } catch (err) { log('warn', `Post-merge PRD update: ${err.message}`); }
763
761
 
764
762
  // Mark work item as done
@@ -1224,40 +1222,42 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1224
1222
 
1225
1223
  if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, 'done', '');
1226
1224
 
1227
- // Auto-dispatch review work item after implement completes successfully
1228
- if (isSuccess && !skipDoneStatus && type === 'implement' && meta?.item?.id) {
1229
- const autoReview = config.engine?.autoReview ?? shared.ENGINE_DEFAULTS.autoReview;
1230
- if (autoReview) {
1225
+ // Auto-dispatch evaluate work item after implement or fix completes successfully
1226
+ if (isSuccess && !skipDoneStatus && (type === 'implement' || (type === 'fix' && meta?.item?._evalParentId)) && meta?.item?.id) {
1227
+ const evalLoop = config.engine?.evalLoop ?? shared.ENGINE_DEFAULTS.evalLoop;
1228
+ if (evalLoop) {
1231
1229
  try {
1232
1230
  const wiPath = resolveWiPath(meta);
1233
1231
  if (wiPath) {
1234
1232
  const items = safeJson(wiPath) || [];
1235
- // Dedup: skip if a review item already exists for this parent
1236
- const existing = items.find(i => i._evalParentId === meta.item.id && i.type === 'review');
1233
+ // For fix items, target the original implement parent; for implement, target self
1234
+ const evalTargetId = type === 'fix' ? meta.item._evalParentId : meta.item.id;
1235
+ // Dedup: skip if an evaluate item already exists for this parent
1236
+ const existing = items.find(i => i._evalParentId === evalTargetId && i.type === 'evaluate' && i.status === 'pending');
1237
1237
  if (existing) {
1238
- log('info', `Eval loop: review item ${existing.id} already exists for ${meta.item.id}, skipping`);
1238
+ log('info', `Eval loop: evaluate item ${existing.id} already exists for ${evalTargetId}, skipping`);
1239
1239
  } else {
1240
- const parentItem = items.find(i => i.id === meta.item.id);
1240
+ const parentItem = items.find(i => i.id === evalTargetId);
1241
1241
  const evalItem = {
1242
1242
  id: 'W-' + shared.uid(),
1243
- title: `Review: ${meta.item.title || meta.item.id}`,
1244
- type: 'review',
1243
+ title: `Evaluate: ${parentItem?.title || meta.item.title || evalTargetId}`,
1244
+ type: 'evaluate',
1245
1245
  priority: meta.item.priority || 'high',
1246
1246
  status: 'pending',
1247
1247
  created: ts(),
1248
1248
  createdBy: 'engine:eval-loop',
1249
1249
  project: meta.project?.name || meta.item.project,
1250
- branch_name: parentItem?.branch_name || meta.branch || null,
1251
- pr_url: parentItem?.pr_url || null,
1250
+ branch_name: parentItem?.branch_name || meta.item.branch_name || meta.branch || null,
1251
+ pr_url: parentItem?.pr_url || meta.item.pr_url || null,
1252
1252
  acceptance_criteria: parentItem?.acceptance_criteria || meta.item.acceptance_criteria || null,
1253
- _evalParentId: meta.item.id,
1253
+ _evalParentId: evalTargetId,
1254
1254
  };
1255
1255
  if (parentItem?.sourcePlan) evalItem.sourcePlan = parentItem.sourcePlan;
1256
1256
  // Mark parent as eval-dispatched before writing to prevent duplicates on re-run
1257
1257
  if (parentItem) parentItem._evalDispatched = true;
1258
1258
  items.push(evalItem);
1259
1259
  shared.safeWrite(wiPath, items);
1260
- log('info', `Eval loop: created ${evalItem.id} for completed implement ${meta.item.id}`);
1260
+ log('info', `Eval loop: created ${evalItem.id} for completed ${type} ${meta.item.id} (parent: ${evalTargetId})`);
1261
1261
  }
1262
1262
  }
1263
1263
  } catch (err) {
@@ -1266,14 +1266,14 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1266
1266
  }
1267
1267
  }
1268
1268
 
1269
- // Review completion: parse verdict and handle review→fix iteration loop
1270
- if (isSuccess && type === 'review' && meta?.item?._evalParentId) {
1269
+ // Evaluate completion: parse verdict and handle eval→fix iteration loop
1270
+ if (isSuccess && type === 'evaluate' && meta?.item?._evalParentId) {
1271
1271
  try {
1272
1272
  const verdict = parseEvalVerdict(resultSummary || stdout);
1273
- const autoReview = config.engine?.autoReview ?? shared.ENGINE_DEFAULTS.autoReview;
1273
+ const evalLoop = config.engine?.evalLoop ?? shared.ENGINE_DEFAULTS.evalLoop;
1274
1274
  const maxIter = config.engine?.evalMaxIterations ?? shared.ENGINE_DEFAULTS.evalMaxIterations;
1275
1275
 
1276
- if (verdict && !verdict.pass && autoReview) {
1276
+ if (verdict && !verdict.pass && evalLoop) {
1277
1277
  const wiPath = resolveWiPath(meta);
1278
1278
  if (wiPath) {
1279
1279
  const items = safeJson(wiPath) || [];
@@ -1420,8 +1420,9 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1420
1420
  }
1421
1421
  }
1422
1422
 
1423
- // Detect implement tasks that completed without creating a PR
1424
- if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id) {
1423
+ // Detect implement tasks that completed without creating a PR.
1424
+ // Exempt: tasks operating on an existing PR (meta.pr set) or human feedback fix tasks.
1425
+ if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id && !meta.pr && meta.source !== 'pr-human-feedback') {
1425
1426
  // Check if a PR already exists linked to this work item (from a previous attempt)
1426
1427
  const projects = shared.getProjects(config);
1427
1428
  const existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
package/engine/queries.js CHANGED
@@ -339,15 +339,21 @@ function collectSkillFiles(config) {
339
339
  try { return fs.statSync(path.join(claudeSkillsDir, d)).isDirectory(); } catch { return false; }
340
340
  });
341
341
  for (const d of dirs) {
342
+ // Check both <name>/SKILL.md and <name>/skills/SKILL.md (Claude Code uses both)
342
343
  const skillFile = path.join(claudeSkillsDir, d, 'SKILL.md');
344
+ const nestedSkillFile = path.join(claudeSkillsDir, d, 'skills', 'SKILL.md');
343
345
  if (fs.existsSync(skillFile)) {
344
346
  skillFiles.push({ file: 'SKILL.md', dir: path.join(claudeSkillsDir, d), scope: 'claude-code', skillName: d });
345
347
  seen.add(d);
348
+ } else if (fs.existsSync(nestedSkillFile)) {
349
+ skillFiles.push({ file: 'SKILL.md', dir: path.join(claudeSkillsDir, d, 'skills'), scope: 'claude-code', skillName: d });
350
+ seen.add(d);
346
351
  }
347
352
  }
348
353
  } catch { /* optional */ }
349
354
 
350
- // 1b. Installed plugin skills: ~/.claude/plugins/installed_plugins.json → cache/<marketplace>/<plugin>/<version>/commands/*.md
355
+ // 1b. Installed plugin skills: ~/.claude/plugins/installed_plugins.json
356
+ // Plugins use commands/*.md and/or skills/<name>/SKILL.md and/or skills/SKILL.md
351
357
  try {
352
358
  const pluginsFile = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
353
359
  const registry = JSON.parse(safeRead(pluginsFile) || '{}');
@@ -355,16 +361,49 @@ function collectSkillFiles(config) {
355
361
  if (!Array.isArray(installs) || installs.length === 0) continue;
356
362
  const install = installs[0];
357
363
  if (!install.installPath) continue;
364
+ const pluginName = pluginKey.split('@')[0];
365
+
366
+ // commands/*.md (older style)
358
367
  const commandsDir = path.join(install.installPath, 'commands');
359
368
  try {
360
369
  const commands = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
361
370
  for (const cmd of commands) {
362
- const name = pluginKey.split('@')[0] + ':' + cmd.replace('.md', '');
371
+ const name = pluginName + ':' + cmd.replace('.md', '');
363
372
  if (seen.has(name)) continue;
364
373
  skillFiles.push({ file: cmd, dir: commandsDir, scope: 'plugin', skillName: name });
365
374
  seen.add(name);
366
375
  }
367
376
  } catch { /* optional */ }
377
+
378
+ // skills/<name>/SKILL.md or skills/SKILL.md (newer style)
379
+ const skillsDir = path.join(install.installPath, 'skills');
380
+ try {
381
+ const entries = fs.readdirSync(skillsDir);
382
+ for (const entry of entries) {
383
+ const entryPath = path.join(skillsDir, entry);
384
+ if (entry === 'SKILL.md') {
385
+ // Flat: skills/SKILL.md
386
+ const name = pluginName;
387
+ if (!seen.has(name)) {
388
+ skillFiles.push({ file: 'SKILL.md', dir: skillsDir, scope: 'plugin', skillName: name });
389
+ seen.add(name);
390
+ }
391
+ } else {
392
+ try {
393
+ if (!fs.statSync(entryPath).isDirectory()) continue;
394
+ } catch { continue; }
395
+ // Nested: skills/<name>/SKILL.md
396
+ const nestedSkill = path.join(entryPath, 'SKILL.md');
397
+ if (fs.existsSync(nestedSkill)) {
398
+ const name = pluginName + ':' + entry;
399
+ if (!seen.has(name)) {
400
+ skillFiles.push({ file: 'SKILL.md', dir: entryPath, scope: 'plugin', skillName: name });
401
+ seen.add(name);
402
+ }
403
+ }
404
+ }
405
+ }
406
+ } catch { /* optional */ }
368
407
  }
369
408
  } catch { /* optional */ }
370
409
 
package/engine/shared.js CHANGED
@@ -7,7 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
 
9
9
  const MINIONS_DIR = path.resolve(__dirname, '..');
10
- const CENTRAL_WI_PATH = path.join(MINIONS_DIR, 'work-items.json');
10
+ const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
11
11
  const LOG_PATH = path.join(__dirname, 'log.json');
12
12
 
13
13
  // ── Timestamps & Logging ────────────────────────────────────────────────────
@@ -258,7 +258,6 @@ function gitEnv() {
258
258
  * Single source of truth — used by llm.js, consolidation.js, and lifecycle.js.
259
259
  */
260
260
  function parseStreamJsonOutput(raw, { maxTextLength = 0 } = {}) {
261
- if (typeof raw !== 'string') raw = '';
262
261
  let text = '';
263
262
  let usage = null;
264
263
  let sessionId = null;
@@ -501,52 +500,45 @@ function parseSkillFrontmatter(content, filename) {
501
500
  // Never touched by polling loops — only written when a PR is first linked to a PRD item.
502
501
 
503
502
  function getPrLinks() {
504
- // Derive from PR.prdItems (single source of truth)
505
503
  const links = {};
504
+ // Primary source: derive from all projects/*/pull-requests.json prdItems
505
+ const projectsDir = path.join(MINIONS_DIR, 'projects');
506
506
  try {
507
- const projects = getProjects();
508
- for (const project of projects) {
509
- const prs = safeJson(projectPrPath(project)) || [];
510
- for (const pr of prs) {
511
- for (const itemId of (pr.prdItems || [])) {
512
- if (!links[pr.id]) links[pr.id] = itemId;
507
+ const fs = require('fs');
508
+ for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
509
+ if (!d.isDirectory()) continue;
510
+ try {
511
+ const prs = JSON.parse(fs.readFileSync(path.join(projectsDir, d.name, 'pull-requests.json'), 'utf8'));
512
+ for (const pr of prs) {
513
+ if (!pr.id) continue;
514
+ for (const itemId of (pr.prdItems || [])) {
515
+ if (itemId) links[pr.id] = itemId;
516
+ }
513
517
  }
514
- }
518
+ } catch { /* missing or invalid */ }
519
+ }
520
+ } catch { /* projects dir missing */ }
521
+ // Fallback: static pr-links.json for entries not covered above
522
+ try {
523
+ const static_ = JSON.parse(require('fs').readFileSync(PR_LINKS_PATH, 'utf8'));
524
+ for (const [k, v] of Object.entries(static_)) {
525
+ if (!links[k]) links[k] = v;
515
526
  }
516
- } catch { /* optional */ }
527
+ } catch { /* missing */ }
517
528
  return links;
518
529
  }
519
530
 
520
- /**
521
- * Locked mutation of a project's pull-requests.json.
522
- * Single source of truth for PR data including prdItems links.
523
- */
524
- function mutatePrs(project, mutateFn) {
525
- const prPath = projectPrPath(project);
526
- return mutateJsonFileLocked(prPath, (prs) => {
527
- return mutateFn(Array.isArray(prs) ? prs : []);
528
- }, { defaultValue: [] });
529
- }
530
-
531
- /**
532
- * Link a PR to a work item via PR.prdItems (single source of truth).
533
- * Uses file-locked mutation to prevent race conditions.
534
- */
535
- function linkPrToItem(project, prId, itemId) {
531
+ function addPrLink(prId, itemId) {
536
532
  if (!prId || !itemId) return;
537
- mutatePrs(project, (prs) => {
538
- const pr = prs.find(p => p.id === prId);
539
- if (pr) {
540
- pr.prdItems = Array.isArray(pr.prdItems) ? pr.prdItems : [];
541
- if (!pr.prdItems.includes(itemId)) pr.prdItems.push(itemId);
542
- }
543
- return prs;
544
- });
533
+ const links = getPrLinks();
534
+ if (links[prId] === itemId) return; // already correct, no write needed
535
+ links[prId] = itemId;
536
+ safeWrite(PR_LINKS_PATH, links);
545
537
  }
546
538
 
547
539
  module.exports = {
548
540
  MINIONS_DIR,
549
- CENTRAL_WI_PATH,
541
+ PR_LINKS_PATH,
550
542
  LOG_PATH,
551
543
  ts,
552
544
  logTs,
@@ -580,8 +572,7 @@ module.exports = {
580
572
  projectWorkItemsPath,
581
573
  projectPrPath,
582
574
  getPrLinks,
583
- mutatePrs,
584
- linkPrToItem,
575
+ addPrLink,
585
576
  nextWorkItemId,
586
577
  getAdoOrgBase,
587
578
  sanitizePath,
package/engine/timeout.js CHANGED
@@ -256,11 +256,13 @@ function checkTimeouts(config) {
256
256
  item._retryCount = retries + 1;
257
257
  delete item.dispatched_at;
258
258
  delete item.dispatched_to;
259
+ delete item._pendingReason;
259
260
  } else {
260
261
  log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
261
262
  item.status = 'failed';
262
263
  item.failReason = 'Agent died or was killed (3 retries exhausted)';
263
264
  item.failedAt = ts();
265
+ delete item._pendingReason;
264
266
  }
265
267
  changed = true;
266
268
  }
package/engine.js CHANGED
@@ -396,6 +396,7 @@ function spawnAgent(dispatchItem, config) {
396
396
  } else {
397
397
  log('error', `Failed to create worktree for ${branchName}: ${err.message}${err.stderr ? '\n' + err.stderr.toString().slice(0, 500) : ''}`);
398
398
  completeDispatch(id, 'error', 'Worktree creation failed: ' + (err.message || '').slice(0, 200));
399
+ try { updateMetrics(agentId, dispatchItem, 'error', null, 0, null); } catch { /* optional */ }
399
400
  return null;
400
401
  }
401
402
  }
package/minions.js CHANGED
@@ -281,25 +281,34 @@ function findGitRepos(rootDir, maxDepth = 3) {
281
281
  }
282
282
 
283
283
  async function scanAndAdd({ root, depth } = {}) {
284
- let scanRoot;
284
+ let scanRoots;
285
285
  if (root) {
286
- scanRoot = path.resolve(root);
286
+ scanRoots = [path.resolve(root)];
287
287
  } else {
288
288
  const homeDir = process.env.USERPROFILE || process.env.HOME || '';
289
- const answer = await ask('Where are your projects?', homeDir);
290
- scanRoot = path.resolve(answer);
289
+ const answer = await ask('Where are your projects? (comma-separate for multiple)', homeDir);
290
+ scanRoots = answer.split(',').map(s => path.resolve(s.trim())).filter(Boolean);
291
291
  }
292
- if (!fs.existsSync(scanRoot)) {
293
- console.log(` Directory not found: ${scanRoot}\n`);
292
+ // Validate all paths
293
+ const validRoots = [];
294
+ for (const r of scanRoots) {
295
+ if (fs.existsSync(r)) { validRoots.push(r); }
296
+ else { console.log(` Directory not found (skipping): ${r}`); }
297
+ }
298
+ if (validRoots.length === 0) {
299
+ console.log(' No valid directories to scan.\n');
294
300
  rl.close();
295
301
  return;
296
302
  }
297
303
  const maxDepth = depth !== undefined ? (parseInt(depth, 10) || 4) : 4;
298
304
 
299
- console.log(`\n Scanning for git repos in: ${scanRoot}`);
300
- console.log(` Max depth: ${maxDepth}\n`);
301
-
302
- const repos = findGitRepos(scanRoot, maxDepth);
305
+ let repos = [];
306
+ for (const scanRoot of validRoots) {
307
+ console.log(`\n Scanning: ${scanRoot} (depth ${maxDepth})`);
308
+ repos.push(...findGitRepos(scanRoot, maxDepth));
309
+ }
310
+ // Deduplicate by resolved path
311
+ repos = [...new Map(repos.map(r => [path.resolve(r), r])).values()];
303
312
  if (repos.length === 0) {
304
313
  console.log(' No git repositories found.\n');
305
314
  rl.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.205",
3
+ "version": "0.1.207",
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"