@yemi33/minions 0.1.226 → 0.1.228

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.228 (2026-04-03)
4
+
5
+ ### Features
6
+ - scan for projects UI — modal with checkbox multi-select
7
+
8
+ ### Fixes
9
+ - CC system prompt now defaults to delegating work to agents
10
+
3
11
  ## 0.1.226 (2026-04-03)
4
12
 
5
13
  ### Features
@@ -16,7 +16,8 @@ function renderProjects(projects) {
16
16
  (p.description ? '<span style="color:var(--muted);font-weight:400;margin-left:6px;font-size:10px">' + escHtml(p.description.slice(0, 60)) + (p.description.length > 60 ? '...' : '') + '</span>' : '') +
17
17
  '</span>'
18
18
  ).join('') +
19
- '<span onclick="addProject()" style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:3px 10px;color:var(--muted);font-weight:500;cursor:pointer;border-style:dashed">+ Add</span>';
19
+ '<span onclick="addProject()" style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:3px 10px;color:var(--muted);font-weight:500;cursor:pointer;border-style:dashed">+ Add</span>' +
20
+ '<span onclick="openScanProjectsModal()" style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:3px 10px;color:var(--blue);font-weight:500;cursor:pointer;border-style:dashed;font-size:10px">Scan</span>';
20
21
 
21
22
  }
22
23
 
@@ -206,4 +207,99 @@ function renderContextPressure(metrics) {
206
207
  el.innerHTML = html;
207
208
  }
208
209
 
209
- window.MinionsOther = { renderProjects, renderMcpServers, renderMetrics, renderTokenUsage, renderContextPressure };
210
+ async function openScanProjectsModal() {
211
+ document.getElementById('modal-title').textContent = 'Scan for Projects';
212
+ document.getElementById('modal-body').innerHTML =
213
+ '<div style="display:flex;flex-direction:column;gap:12px">' +
214
+ '<div style="display:flex;gap:8px;align-items:flex-end">' +
215
+ '<label style="flex:1;color:var(--text);font-size:var(--text-md)">Directory to scan' +
216
+ '<input id="scan-path" value="' + escHtml((typeof os !== 'undefined' ? os.homedir() : '~') || '~') + '" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)">' +
217
+ '</label>' +
218
+ '<label style="width:60px;color:var(--text);font-size:var(--text-md)">Depth' +
219
+ '<input id="scan-depth" type="number" value="3" min="1" max="6" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)">' +
220
+ '</label>' +
221
+ '<button onclick="_runProjectScan()" style="padding:6px 16px;background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;white-space:nowrap">Scan</button>' +
222
+ '</div>' +
223
+ '<div id="scan-results" style="color:var(--muted);font-size:12px">Click Scan to find git repos in the directory.</div>' +
224
+ '</div>';
225
+ document.getElementById('modal-body').style.whiteSpace = 'normal';
226
+ document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
227
+ document.getElementById('modal').classList.add('open');
228
+ }
229
+
230
+ async function _runProjectScan() {
231
+ var scanPath = document.getElementById('scan-path')?.value?.trim();
232
+ var depth = document.getElementById('scan-depth')?.value || '3';
233
+ var resultsEl = document.getElementById('scan-results');
234
+ if (!scanPath) { alert('Enter a path'); return; }
235
+ resultsEl.innerHTML = '<span style="color:var(--blue)">Scanning...</span>';
236
+
237
+ try {
238
+ var res = await fetch('/api/projects/scan', {
239
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({ path: scanPath, depth: Number(depth) })
241
+ });
242
+ var data = await res.json();
243
+ if (!res.ok) { resultsEl.innerHTML = '<span style="color:var(--red)">Error: ' + escHtml(data.error) + '</span>'; return; }
244
+ var repos = data.repos || [];
245
+ if (repos.length === 0) { resultsEl.innerHTML = '<span style="color:var(--muted)">No git repos found in ' + escHtml(scanPath) + '</span>'; return; }
246
+
247
+ var html = '<div style="margin-bottom:8px;font-size:11px;color:var(--muted)">' + repos.length + ' repos found — select to add:</div>';
248
+ html += '<div style="max-height:400px;overflow-y:auto;display:flex;flex-direction:column;gap:4px">';
249
+ repos.forEach(function(r, i) {
250
+ var linked = r.linked;
251
+ var hostBadge = '<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:' +
252
+ (r.host === 'GitHub' ? 'rgba(88,166,255,0.15);color:var(--blue)' : r.host === 'ADO' ? 'rgba(188,140,255,0.15);color:var(--purple)' : 'var(--surface2);color:var(--muted)') +
253
+ '">' + escHtml(r.host) + '</span>';
254
+ html += '<label style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;cursor:' + (linked ? 'default' : 'pointer') + ';opacity:' + (linked ? '0.5' : '1') + '">' +
255
+ '<input type="checkbox" data-scan-idx="' + i + '" ' + (linked ? 'disabled checked' : '') + ' style="accent-color:var(--blue);width:16px;height:16px">' +
256
+ '<div style="flex:1;min-width:0">' +
257
+ '<div style="font-weight:600;font-size:12px">' + escHtml(r.name) + ' ' + hostBadge + (linked ? ' <span style="font-size:9px;color:var(--green)">linked</span>' : '') + '</div>' +
258
+ '<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(r.path) + '</div>' +
259
+ (r.description ? '<div style="font-size:10px;color:var(--muted)">' + escHtml(r.description) + '</div>' : '') +
260
+ '</div>' +
261
+ '</label>';
262
+ });
263
+ html += '</div>';
264
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">' +
265
+ '<div style="display:flex;gap:8px">' +
266
+ '<span onclick="_scanSelectAll()" style="font-size:10px;color:var(--blue);cursor:pointer;text-decoration:underline">Select all</span>' +
267
+ '<span onclick="_scanSelectNone()" style="font-size:10px;color:var(--muted);cursor:pointer;text-decoration:underline">Clear</span>' +
268
+ '</div>' +
269
+ '<button onclick="_addSelectedProjects()" style="padding:6px 16px;background:var(--green);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer">Add Selected</button>' +
270
+ '</div>';
271
+ resultsEl.innerHTML = html;
272
+ window._scanRepos = repos;
273
+ } catch (e) { resultsEl.innerHTML = '<span style="color:var(--red)">Error: ' + escHtml(e.message) + '</span>'; }
274
+ }
275
+
276
+ function _scanSelectAll() {
277
+ document.querySelectorAll('[data-scan-idx]').forEach(function(cb) { if (!cb.disabled) cb.checked = true; });
278
+ }
279
+ function _scanSelectNone() {
280
+ document.querySelectorAll('[data-scan-idx]').forEach(function(cb) { if (!cb.disabled) cb.checked = false; });
281
+ }
282
+
283
+ async function _addSelectedProjects() {
284
+ var checkboxes = document.querySelectorAll('[data-scan-idx]:checked:not(:disabled)');
285
+ if (checkboxes.length === 0) { alert('Select at least one repo'); return; }
286
+ var repos = window._scanRepos || [];
287
+ var added = 0;
288
+ for (var cb of checkboxes) {
289
+ var repo = repos[parseInt(cb.dataset.scanIdx)];
290
+ if (!repo) continue;
291
+ try {
292
+ var res = await fetch('/api/projects/add', {
293
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
294
+ body: JSON.stringify({ path: repo.path })
295
+ });
296
+ if (res.ok) { added++; cb.disabled = true; cb.closest('label').style.opacity = '0.5'; }
297
+ } catch { /* continue with next */ }
298
+ }
299
+ if (added > 0) {
300
+ showToast('cmd-toast', added + ' project(s) added', true);
301
+ refresh();
302
+ }
303
+ }
304
+
305
+ window.MinionsOther = { renderProjects, renderMcpServers, renderMetrics, renderTokenUsage, renderContextPressure, openScanProjectsModal };
@@ -52,8 +52,8 @@
52
52
  <div style="font-size:18px;margin-bottom:6px;text-align:center">Getting Started</div>
53
53
  <div style="color:var(--muted);font-size:13px;margin-bottom:12px;text-align:center">No projects linked yet. Link a project so agents know what code to work on.</div>
54
54
  <div style="display:flex;flex-direction:column;gap:8px;align-items:center">
55
- <code style="background:var(--bg);padding:6px 16px;border-radius:4px;font-size:13px;color:var(--blue);border:1px solid var(--border)">minions add &lt;path-to-your-repo&gt;</code>
56
- <div style="font-size:11px;color:var(--muted)">Then try: <code style="background:var(--bg);padding:2px 6px;border-radius:3px;font-size:11px">minions work "Explore the codebase"</code> or use the Command Center above</div>
55
+ <button onclick="openScanProjectsModal()" style="padding:8px 20px;background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer;font-size:13px;font-weight:600">Scan for Projects</button>
56
+ <div style="font-size:11px;color:var(--muted)">Or via CLI: <code style="background:var(--bg);padding:2px 6px;border-radius:3px;font-size:11px">minions add &lt;path-to-your-repo&gt;</code></div>
57
57
  </div>
58
58
  </div>
59
59
 
package/dashboard.js CHANGED
@@ -340,19 +340,26 @@ ${MINIONS_DIR}/
340
340
 
341
341
  Projects are configured in \`config.json\` under \`projects[]\`. Per-project state lives centrally in \`${MINIONS_DIR}/projects/{name}/\` — NOT inside project repos. There are no \`.minions/\` folders inside project repos.
342
342
 
343
- ## Direct Execution
343
+ ## Default: Delegate to Agents
344
344
 
345
- You have Bash, Write, Edit, and all standard tools. Use them directly when the task is straightforward:
346
- - **Build & run projects** — \`cd <project> && npm install && npm run dev\`
347
- - **Inspect code** — read files, grep, explore
348
- - **Edit project files** — fix configs, update docs, tweak settings
349
- - **Git operations** — fetch, checkout, merge, diff (but do NOT push without the user confirming)
350
- - **Start dev servers** — for long-running servers, use detached processes so they survive after you finish
345
+ Your primary job is to **orchestrate**, not implement. For most requests, dispatch work to agents rather than doing it yourself. Agents have full Claude Code sessions with project context, worktrees, and MCP tools they are better equipped for real work.
351
346
 
352
- **When to do it yourself vs delegate to an agent:**
353
- - Quick, one-shot tasks (build, read, check, install, start a server) → do it yourself
354
- - Complex multi-file code changes, PR creation, code review → dispatch to an agent
355
- - Anything that needs deep codebase knowledge or iterative coding → dispatch to an agent
347
+ **Delegate to agents (default):**
348
+ - Code changes of any size even "small" fixes
349
+ - Bug fixes, feature implementation, refactoring
350
+ - PR creation and code review
351
+ - Testing and verification
352
+ - Codebase exploration and architecture analysis
353
+ - Plan creation and execution
354
+
355
+ **Do it yourself (only these):**
356
+ - Reading a specific file or status to answer a question
357
+ - Quick lookups (check a config value, find a file, read an error log)
358
+ - Creating notes, editing plans, updating routing/charters
359
+ - Starting a dev server or running a build command the user asked for
360
+ - Git operations the user explicitly asked YOU to do
361
+
362
+ When in doubt, **dispatch an agent**. The user is talking to you because they want work delegated, not because they want you to be the one coding.
356
363
 
357
364
  ## Minions Actions (Delegation)
358
365
 
@@ -2904,6 +2911,52 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2904
2911
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
2905
2912
  }
2906
2913
 
2914
+ async function handleProjectsScan(req, res) {
2915
+ try {
2916
+ const body = await readBody(req);
2917
+ const scanRoot = path.resolve(body.path || os.homedir());
2918
+ const maxDepth = Math.min(Number(body.depth) || 3, 6);
2919
+ if (!fs.existsSync(scanRoot)) return jsonReply(res, 400, { error: 'path does not exist' });
2920
+
2921
+ // Find git repos recursively (same logic as minions.js findGitRepos)
2922
+ const skipDirs = new Set(['node_modules', '.git', '.hg', 'AppData', '$Recycle.Bin', 'Windows',
2923
+ 'Program Files', 'Program Files (x86)', '.cache', '.npm', '.yarn', '.nuget', 'worktrees', '.minions', '.squad']);
2924
+ const repos = [];
2925
+ function walk(dir, depth) {
2926
+ if (depth > maxDepth) return;
2927
+ let entries;
2928
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
2929
+ for (const e of entries) {
2930
+ if (!e.isDirectory() || (e.name.startsWith('.') && e.name !== '.git') || skipDirs.has(e.name)) continue;
2931
+ if (e.name === '.git') { repos.push(dir); return; }
2932
+ walk(path.join(dir, e.name), depth + 1);
2933
+ }
2934
+ }
2935
+ walk(scanRoot, 0);
2936
+
2937
+ // Enrich each repo with metadata
2938
+ const existingPaths = new Set(PROJECTS.map(p => path.resolve(p.localPath)));
2939
+ const results = repos.map(repoPath => {
2940
+ const result = { path: repoPath.replace(/\\/g, '/'), name: path.basename(repoPath), host: 'git', linked: existingPaths.has(path.resolve(repoPath)) };
2941
+ try {
2942
+ const remoteUrl = require('child_process').execSync('git remote get-url origin', { cwd: repoPath, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
2943
+ const gh = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
2944
+ const ado = remoteUrl.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/\s]+)/) || remoteUrl.match(/([^.]+)\.visualstudio\.com.*?\/([^/]+)\/_git\/([^/\s]+)/);
2945
+ if (gh) { result.host = 'GitHub'; result.org = gh[1]; result.name = gh[2]; }
2946
+ else if (ado) { result.host = 'ADO'; result.org = ado[1]; result.name = ado[3] || ado[2]; }
2947
+ } catch { /* no remote */ }
2948
+ try {
2949
+ const pkg = JSON.parse(fs.readFileSync(path.join(repoPath, 'package.json'), 'utf8'));
2950
+ if (pkg.name) result.name = pkg.name.replace(/@[^/]+\//, '');
2951
+ if (pkg.description) result.description = pkg.description.slice(0, 100);
2952
+ } catch { /* no package.json */ }
2953
+ return result;
2954
+ });
2955
+
2956
+ return jsonReply(res, 200, { repos: results });
2957
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
2958
+ }
2959
+
2907
2960
  async function handleCommandCenterNewSession(req, res) {
2908
2961
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
2909
2962
  ccInFlight = false; // Reset concurrency guard so a stuck request doesn't block new sessions
@@ -3444,6 +3497,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3444
3497
 
3445
3498
  // Projects
3446
3499
  { method: 'POST', path: '/api/projects/browse', desc: 'Open folder picker dialog, return selected path', handler: handleProjectsBrowse },
3500
+ { method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
3447
3501
  { method: 'POST', path: '/api/projects/add', desc: 'Auto-discover and add a project to config', params: 'path, name?', handler: handleProjectsAdd },
3448
3502
 
3449
3503
  // Command Center
@@ -630,7 +630,22 @@ function syncPrsFromOutput(output, agentId, meta, config) {
630
630
  }
631
631
 
632
632
  for (const [name, entry] of dirtyTargets) {
633
- shared.safeWrite(entry.prPath, entry.prs);
633
+ mutateJsonFileLocked(entry.prPath, (existingPrs) => {
634
+ // Merge new PRs into existing data to avoid losing concurrent writes
635
+ for (const pr of entry.prs) {
636
+ const idx = existingPrs.findIndex(p => p.id === pr.id);
637
+ if (idx >= 0) {
638
+ // Backfill prdItems if needed
639
+ if (pr.prdItems?.length) {
640
+ const merged = new Set([...(existingPrs[idx].prdItems || []), ...pr.prdItems]);
641
+ existingPrs[idx].prdItems = [...merged];
642
+ }
643
+ } else {
644
+ existingPrs.push(pr);
645
+ }
646
+ }
647
+ return existingPrs;
648
+ }, { defaultValue: [] });
634
649
  log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name}/pull-requests.json`);
635
650
  }
636
651
  return added;
@@ -668,11 +683,12 @@ function updatePrAfterReview(agentId, pr, project) {
668
683
  const authorAgentId = (pr.agent || '').toLowerCase();
669
684
  if (authorAgentId && config.agents?.[authorAgentId]) {
670
685
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
671
- const metrics = safeJson(metricsPath) || {};
672
- if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
673
- if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
674
- metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
675
- shared.safeWrite(metricsPath, metrics);
686
+ mutateJsonFileLocked(metricsPath, (metrics) => {
687
+ if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
688
+ if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
689
+ metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
690
+ return metrics;
691
+ });
676
692
  }
677
693
 
678
694
  shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
@@ -747,12 +763,16 @@ async function handlePostMerge(pr, project, config, newStatus) {
747
763
  const planFiles = fs.readdirSync(prdDir).filter(f => f.endsWith('.json'));
748
764
  let updated = 0;
749
765
  for (const pf of planFiles) {
750
- const plan = safeJson(path.join(prdDir, pf));
766
+ const prdPath = path.join(prdDir, pf);
767
+ const plan = safeJson(prdPath);
751
768
  if (!plan?.missing_features) continue;
752
769
  const feature = plan.missing_features.find(f => f.id === mergedItemId);
753
770
  if (feature && feature.status !== 'implemented') {
754
- feature.status = 'implemented';
755
- shared.safeWrite(path.join(prdDir, pf), plan);
771
+ mutateJsonFileLocked(prdPath, (data) => {
772
+ const feat = data.missing_features?.find(f => f.id === mergedItemId);
773
+ if (feat && feat.status !== 'implemented') feat.status = 'implemented';
774
+ return data;
775
+ });
756
776
  updated++;
757
777
  }
758
778
  }
@@ -764,17 +784,19 @@ async function handlePostMerge(pr, project, config, newStatus) {
764
784
  for (const p of shared.getProjects(config)) wiPaths.push(shared.projectWorkItemsPath(p));
765
785
  for (const wiPath of wiPaths) {
766
786
  try {
767
- const items = safeJson(wiPath);
768
- if (!items) continue;
769
- const item = items.find(i => i.id === mergedItemId);
770
- if (item && item.status !== 'done') {
771
- log('info', `Post-merge: marking work item ${mergedItemId} as done (was ${item.status}) for ${pr.id}`);
772
- item.status = 'done';
773
- item.completedAt = ts();
774
- item._mergedVia = pr.id;
775
- shared.safeWrite(wiPath, items);
776
- break;
777
- }
787
+ let found = false;
788
+ mutateJsonFileLocked(wiPath, (items) => {
789
+ const item = items.find(i => i.id === mergedItemId);
790
+ if (item && item.status !== 'done') {
791
+ log('info', `Post-merge: marking work item ${mergedItemId} as done (was ${item.status}) for ${pr.id}`);
792
+ item.status = 'done';
793
+ item.completedAt = ts();
794
+ item._mergedVia = pr.id;
795
+ found = true;
796
+ }
797
+ return items;
798
+ }, { defaultValue: [] });
799
+ if (found) break;
778
800
  } catch (err) { log('warn', `Post-merge work item update: ${err.message}`); }
779
801
  }
780
802
  }
@@ -782,10 +804,11 @@ async function handlePostMerge(pr, project, config, newStatus) {
782
804
  const agentId = (pr.agent || '').toLowerCase();
783
805
  if (agentId && config.agents?.[agentId]) {
784
806
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
785
- const metrics = safeJson(metricsPath) || {};
786
- if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, prsMerged:0, reviewsDone:0, lastTask:null, lastCompleted:null };
787
- metrics[agentId].prsMerged = (metrics[agentId].prsMerged || 0) + 1;
788
- shared.safeWrite(metricsPath, metrics);
807
+ mutateJsonFileLocked(metricsPath, (metrics) => {
808
+ if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, prsMerged:0, reviewsDone:0, lastTask:null, lastCompleted:null };
809
+ metrics[agentId].prsMerged = (metrics[agentId].prsMerged || 0) + 1;
810
+ return metrics;
811
+ });
789
812
  }
790
813
 
791
814
  const teamsUrl = process.env.TEAMS_PLAN_FLOW_URL;
@@ -1229,14 +1252,15 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1229
1252
  try {
1230
1253
  const wiPath = resolveWiPath(meta);
1231
1254
  if (wiPath) {
1232
- const items = safeJson(wiPath) || [];
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
- if (existing) {
1238
- log('info', `Eval loop: evaluate item ${existing.id} already exists for ${evalTargetId}, skipping`);
1239
- } else {
1255
+ mutateJsonFileLocked(wiPath, (items) => {
1256
+ // For fix items, target the original implement parent; for implement, target self
1257
+ const evalTargetId = type === 'fix' ? meta.item._evalParentId : meta.item.id;
1258
+ // Dedup: skip if an evaluate item already exists for this parent
1259
+ const existing = items.find(i => i._evalParentId === evalTargetId && i.type === 'evaluate' && i.status === 'pending');
1260
+ if (existing) {
1261
+ log('info', `Eval loop: evaluate item ${existing.id} already exists for ${evalTargetId}, skipping`);
1262
+ return items;
1263
+ }
1240
1264
  const parentItem = items.find(i => i.id === evalTargetId);
1241
1265
  const evalItem = {
1242
1266
  id: 'W-' + shared.uid(),
@@ -1256,9 +1280,9 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1256
1280
  // Mark parent as eval-dispatched before writing to prevent duplicates on re-run
1257
1281
  if (parentItem) parentItem._evalDispatched = true;
1258
1282
  items.push(evalItem);
1259
- shared.safeWrite(wiPath, items);
1260
1283
  log('info', `Eval loop: created ${evalItem.id} for completed ${type} ${meta.item.id} (parent: ${evalTargetId})`);
1261
- }
1284
+ return items;
1285
+ }, { defaultValue: [] });
1262
1286
  }
1263
1287
  } catch (err) {
1264
1288
  log('warn', `Eval loop dispatch error: ${err.message}`);
@@ -1276,9 +1300,9 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1276
1300
  if (verdict && !verdict.pass && evalLoop) {
1277
1301
  const wiPath = resolveWiPath(meta);
1278
1302
  if (wiPath) {
1279
- const items = safeJson(wiPath) || [];
1280
- const parent = items.find(i => i.id === meta.item._evalParentId);
1281
- if (parent) {
1303
+ mutateJsonFileLocked(wiPath, (items) => {
1304
+ const parent = items.find(i => i.id === meta.item._evalParentId);
1305
+ if (!parent) return items;
1282
1306
  const iterations = (parent._evalIterations || 0) + 1;
1283
1307
  parent._evalIterations = iterations;
1284
1308
 
@@ -1286,7 +1310,6 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1286
1310
  // Max iterations reached — escalate to human review
1287
1311
  parent.status = 'needs-human-review';
1288
1312
  parent._evalEscalatedAt = ts();
1289
- shared.safeWrite(wiPath, items);
1290
1313
  log('info', `Eval loop: ${parent.id} reached ${iterations}/${maxIter} iterations — escalated to needs-human-review`);
1291
1314
  } else {
1292
1315
  // Create fix work item with evaluator feedback
@@ -1312,10 +1335,10 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1312
1335
  // Parent stays 'done' — fix item picks up from here
1313
1336
  parent.status = 'done';
1314
1337
  items.push(fixItem);
1315
- shared.safeWrite(wiPath, items);
1316
1338
  log('info', `Eval loop: created fix ${fixItem.id} for failed eval on ${parent.id} (iteration ${iterations}/${maxIter})`);
1317
1339
  }
1318
- }
1340
+ return items;
1341
+ }, { defaultValue: [] });
1319
1342
  }
1320
1343
  }
1321
1344
  } catch (err) {
@@ -1345,13 +1368,14 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1345
1368
  ? path.join(MINIONS_DIR, 'work-items.json')
1346
1369
  : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1347
1370
  if (wiPath) {
1348
- const items = safeJson(wiPath) || [];
1349
- const wi = items.find(i => i.id === meta.item.id);
1350
- if (wi) {
1351
- wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to;
1352
- if (type === 'decompose') delete wi._decomposing; // clear so item can retry decomposition
1353
- shared.safeWrite(wiPath, items);
1354
- }
1371
+ mutateJsonFileLocked(wiPath, (items) => {
1372
+ const wi = items.find(i => i.id === meta.item.id);
1373
+ if (wi) {
1374
+ wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to;
1375
+ if (type === 'decompose') delete wi._decomposing; // clear so item can retry decomposition
1376
+ }
1377
+ return items;
1378
+ }, { defaultValue: [] });
1355
1379
  }
1356
1380
  } catch (err) { log('warn', `Retry update: ${err.message}`); }
1357
1381
  } else {
@@ -1364,9 +1388,11 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1364
1388
  ? path.join(MINIONS_DIR, 'work-items.json')
1365
1389
  : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1366
1390
  if (wiPath) {
1367
- const items = safeJson(wiPath) || [];
1368
- const wi = items.find(i => i.id === meta.item.id);
1369
- if (wi) { delete wi._decomposing; shared.safeWrite(wiPath, items); }
1391
+ mutateJsonFileLocked(wiPath, (items) => {
1392
+ const wi = items.find(i => i.id === meta.item.id);
1393
+ if (wi) { delete wi._decomposing; }
1394
+ return items;
1395
+ }, { defaultValue: [] });
1370
1396
  }
1371
1397
  } catch (err) { log('warn', `Decompose cleanup: ${err.message}`); }
1372
1398
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.226",
3
+ "version": "0.1.228",
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"