@yemi33/minions 0.1.181 → 0.1.183

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.183 (2026-04-02)
4
+
5
+ ### Dashboard
6
+ - dashboard.js
7
+
8
+ ### Other
9
+ - test/unit.test.js
10
+
11
+ ## 0.1.182 (2026-04-02)
12
+
13
+ ### Engine
14
+ - engine/lifecycle.js
15
+ - engine/shared.js
16
+
17
+ ### Dashboard
18
+ - dashboard.js
19
+ - dashboard/js/render-pipelines.js
20
+
21
+ ### Other
22
+ - test/unit.test.js
23
+
3
24
  ## 0.1.181 (2026-04-02)
4
25
 
5
26
  ### Engine
@@ -1,11 +1,6 @@
1
1
  // render-pipelines.js — Pipeline list, run detail, and create modal
2
2
 
3
3
  let _pipelinesData = [];
4
- const PIPELINES_PER_PAGE = 10;
5
- let _pipelinesPage = 0;
6
-
7
- function _pipelinesPrev() { if (_pipelinesPage > 0) { _pipelinesPage--; refresh(); } }
8
- function _pipelinesNext() { _pipelinesPage++; refresh(); }
9
4
 
10
5
  /**
11
6
  * Render clickable artifact links for a pipeline stage.
@@ -70,8 +65,7 @@ function _collectRunArtifacts(run) {
70
65
  }
71
66
 
72
67
  function renderPipelines(pipelines) {
73
- _pipelinesData = (pipelines || []).filter(function(p) { return !isDeleted('pipeline:' + p.id); });
74
- pipelines = _pipelinesData;
68
+ _pipelinesData = pipelines || [];
75
69
  const el = document.getElementById('pipelines-content');
76
70
  const countEl = document.getElementById('pipelines-count');
77
71
  if (!el) return;
@@ -82,24 +76,7 @@ function renderPipelines(pipelines) {
82
76
  }
83
77
  countEl.textContent = pipelines.length;
84
78
 
85
- const totalPipelinePages = Math.ceil(pipelines.length / PIPELINES_PER_PAGE);
86
- if (_pipelinesPage >= totalPipelinePages) _pipelinesPage = totalPipelinePages - 1;
87
- if (_pipelinesPage < 0) _pipelinesPage = 0;
88
- const pipStart = _pipelinesPage * PIPELINES_PER_PAGE;
89
- const pagePipelines = pipelines.slice(pipStart, pipStart + PIPELINES_PER_PAGE);
90
-
91
- var pipelinePagerHtml = '';
92
- if (pipelines.length > PIPELINES_PER_PAGE) {
93
- pipelinePagerHtml = '<div class="pr-pager">' +
94
- '<span class="pr-page-info">' + (pipStart + 1) + '-' + Math.min(pipStart + PIPELINES_PER_PAGE, pipelines.length) + ' of ' + pipelines.length + '</span>' +
95
- '<div class="pr-pager-btns">' +
96
- '<button class="pr-pager-btn ' + (_pipelinesPage === 0 ? 'disabled' : '') + '" onclick="_pipelinesPrev()">Prev</button>' +
97
- '<button class="pr-pager-btn ' + (_pipelinesPage >= totalPipelinePages - 1 ? 'disabled' : '') + '" onclick="_pipelinesNext()">Next</button>' +
98
- '</div>' +
99
- '</div>';
100
- }
101
-
102
- el.innerHTML = pagePipelines.map(function(p) {
79
+ el.innerHTML = pipelines.map(function(p) {
103
80
  const activeRun = (p.runs || []).find(function(r) { return r.status === 'running'; });
104
81
  const lastRun = (p.runs || []).slice(-1)[0];
105
82
  const statusColor = activeRun ? 'var(--blue)' : lastRun?.status === 'completed' ? 'var(--green)' : lastRun?.status === 'failed' ? 'var(--red)' : 'var(--muted)';
@@ -116,7 +93,7 @@ function renderPipelines(pipelines) {
116
93
 
117
94
  // Build step-progress indicator for pipelines with a run
118
95
  var progressHtml = '';
119
- var displayRun = activeRun;
96
+ var displayRun = activeRun || lastRun;
120
97
  if (displayRun && (p.stages || []).length > 0) {
121
98
  var totalStages = (p.stages || []).length;
122
99
  var completedCount = 0;
@@ -165,7 +142,7 @@ function renderPipelines(pipelines) {
165
142
  '<div style="margin-top:6px;display:flex;gap:4px;align-items:center;flex-wrap:wrap">' + stageFlow + '</div>' +
166
143
  progressHtml +
167
144
  '</div>';
168
- }).join('') + pipelinePagerHtml;
145
+ }).join('');
169
146
  }
170
147
 
171
148
  function openPipelineDetail(id) {
@@ -187,7 +164,7 @@ function openPipelineDetail(id) {
187
164
  '</div>';
188
165
 
189
166
  // Stage detail with progress bar
190
- var detailRun = activeRun;
167
+ var detailRun = activeRun || (p.runs || []).slice(-1)[0];
191
168
  if (detailRun && (p.stages || []).length > 0) {
192
169
  var dtotal = (p.stages || []).length;
193
170
  var ddone = 0, drun = 0, dfail = 0;
@@ -271,8 +248,8 @@ async function _togglePipelineEnabled(id, enabled, btn) {
271
248
  try {
272
249
  var res = await fetch('/api/pipelines/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, enabled: enabled }) });
273
250
  if (res.ok) { showToast('cmd-toast', enabled ? 'Pipeline enabled' : 'Pipeline disabled', true); refresh(); }
274
- else { alert('Failed'); refresh(); }
275
- } catch (e) { alert('Error: ' + e.message); refresh(); }
251
+ else { alert('Failed'); }
252
+ } catch (e) { alert('Error: ' + e.message); }
276
253
  if (btn) { btn.textContent = enabled ? 'Disable' : 'Enable'; btn.style.pointerEvents = ''; }
277
254
  }
278
255
 
@@ -371,13 +348,12 @@ function _updatePlCronPreview() {
371
348
  }
372
349
 
373
350
  async function _submitCreatePipeline() {
374
- var btn = event?.target; if (btn) { btn.disabled = true; btn.textContent = 'Creating...'; }
375
351
  var id = document.getElementById('pl-id')?.value?.trim();
376
352
  var title = document.getElementById('pl-title')?.value?.trim();
377
353
  var useCron = document.getElementById('pl-use-cron')?.checked;
378
354
  var cron = useCron ? (window._plComputedCron || '') : '';
379
355
  var stagesRaw = document.getElementById('pl-stages')?.value?.trim();
380
- if (!id || !title) { if (btn) { btn.disabled = false; btn.textContent = 'Create Pipeline'; } alert('ID and title required'); return; }
356
+ if (!id || !title) { alert('ID and title required'); return; }
381
357
  var stages;
382
358
  try { stages = JSON.parse(stagesRaw); } catch (e) { alert('Invalid JSON in stages: ' + e.message); return; }
383
359
  if (!Array.isArray(stages) || stages.length === 0) { alert('Stages must be a non-empty array'); return; }
package/dashboard.js CHANGED
@@ -370,7 +370,16 @@ I'll save that as a note and dispatch dallas to fix the bug.
370
370
  If no actions are needed (just answering a question, or you handled it directly), do NOT include the ===ACTIONS=== line.
371
371
 
372
372
  Available action types:
373
- - **dispatch**: Create a work item for an agent. Fields: title, workType (ask/explore/fix/review/test/implement/verify), priority (low/medium/high), agents (array of IDs, optional), project, description. Use \`verify\` when the user wants to build PRs locally, merge branches together, start a dev server, and get a localhost URL to test.
373
+ - **dispatch**: Create a work item for an agent. Fields: title, workType, priority (low/medium/high), agents (array of IDs, optional), project, description.
374
+ workType values — choose carefully, this determines the playbook and whether a PR is expected:
375
+ - \`explore\` — research, investigate, read code, gather information, assess quality, write findings (NO PR expected)
376
+ - \`ask\` — answer a question, analyze something, produce a report (NO PR expected)
377
+ - \`implement\` — write new code, add a feature, create something (PR expected)
378
+ - \`fix\` — fix a bug, address review feedback on an existing PR (PR expected)
379
+ - \`review\` — code review an existing PR (NO PR expected)
380
+ - \`test\` — run tests, write test cases (PR expected if new tests written)
381
+ - \`verify\` — build PRs locally, merge branches, start dev server, get localhost URL to test (NO PR expected)
382
+ If unsure, prefer \`explore\` for read-only tasks and \`implement\` for write tasks.
374
383
  - **note**: Save a note/decision. Fields: title, content
375
384
  - **plan**: Create a multi-step plan. Fields: title, description, project, branchStrategy (parallel/shared-branch)
376
385
  - **cancel**: Cancel a running agent. Fields: agent (agent ID), reason
@@ -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'));
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,9 +68,9 @@ 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;
71
+ if (w && (w.status === 'done')) 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'));
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(', ')}`);
@@ -478,7 +478,6 @@ function updateWorkItemStatus(meta, status, reason) {
478
478
  if (status === 'done') {
479
479
  delete target.failReason;
480
480
  delete target.failedAt;
481
- delete target._retryCount;
482
481
  target.completedAt = ts();
483
482
  } else if (status === 'failed') {
484
483
  if (reason) target.failReason = reason;
@@ -495,13 +494,12 @@ function updateWorkItemStatus(meta, status, reason) {
495
494
  }
496
495
 
497
496
  function syncPrdItemStatus(itemId, status, sourcePlan) {
498
- if (!itemId || !sourcePlan) return; // sourcePlan required to prevent cross-PRD contamination
497
+ if (!itemId) return;
499
498
  try {
500
499
  const prdDir = path.join(MINIONS_DIR, 'prd');
501
- const files = [sourcePlan];
500
+ const files = sourcePlan ? [sourcePlan] : require('fs').readdirSync(prdDir).filter(f => f.endsWith('.json'));
502
501
  for (const pf of files) {
503
502
  const fpath = path.join(prdDir, pf);
504
- if (!fs.existsSync(fpath)) continue; // skip archived/deleted PRDs
505
503
  mutateJsonFileLocked(fpath, (plan) => {
506
504
  if (!plan?.missing_features) return plan;
507
505
  const feature = plan.missing_features.find(f => f.id === itemId);
@@ -605,14 +603,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
605
603
  dirtyTargets.set(targetName, { prs: safeJson(prPath) || [], prPath });
606
604
  }
607
605
  const entry = dirtyTargets.get(targetName);
608
- const existing = entry.prs.find(p => p.id === fullId || String(p.id) === String(prId));
609
- if (existing) {
610
- // Backfill prdItems if the entry was added by the poller before syncPrsFromOutput ran
611
- if (meta?.item?.id && !existing.prdItems?.includes(meta.item.id)) {
612
- existing.prdItems = [...(existing.prdItems || []), meta.item.id];
613
- }
614
- continue;
615
- }
606
+ if (entry.prs.some(p => p.id === fullId || String(p.id) === String(prId))) continue;
616
607
 
617
608
  let title = meta?.item?.title || '';
618
609
  const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
@@ -633,7 +624,10 @@ function syncPrsFromOutput(output, agentId, meta, config) {
633
624
  sourcePlan: meta?.item?.sourcePlan || '',
634
625
  itemType: meta?.item?.itemType || ''
635
626
  });
636
- if (meta?.item?.id) addPrLink(fullId, meta.item.id);
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
+ }
637
631
  added++;
638
632
  }
639
633
 
@@ -646,26 +640,10 @@ function syncPrsFromOutput(output, agentId, meta, config) {
646
640
 
647
641
  // ─── Post-Completion Hooks ──────────────────────────────────────────────────
648
642
 
649
- /**
650
- * Resolve which project's pull-requests.json contains a given PR ID.
651
- * Returns the project object, or null if not found in any project file.
652
- */
653
- function resolveProjectForPr(prId) {
654
- const config = getConfig();
655
- for (const p of shared.getProjects(config)) {
656
- const prs = safeJson(projectPrPath(p)) || [];
657
- if (prs.some(pr => pr.id === prId)) return p;
658
- }
659
- return null;
660
- }
661
-
662
643
  function updatePrAfterReview(agentId, pr, project) {
663
644
 
664
645
  if (!pr?.id) return;
665
- // Resolve actual project if not provided — avoids writing merged array to wrong path
666
- const resolvedProject = project || resolveProjectForPr(pr.id);
667
- if (!resolvedProject) { log('warn', `updatePrAfterReview: cannot resolve project for ${pr.id}`); return; }
668
- const prs = getPrs(resolvedProject);
646
+ const prs = getPrs(project);
669
647
  const target = prs.find(p => p.id === pr.id);
670
648
  if (!target) return;
671
649
 
@@ -699,7 +677,7 @@ function updatePrAfterReview(agentId, pr, project) {
699
677
  shared.safeWrite(metricsPath, metrics);
700
678
  }
701
679
 
702
- shared.safeWrite(shared.projectPrPath(resolvedProject), prs);
680
+ shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
703
681
  log('info', `Updated ${pr.id} → minions review: ${target.reviewStatus} by ${reviewerName}`);
704
682
  createReviewFeedbackForAuthor(agentId, { ...pr, ...target }, config);
705
683
  }
@@ -707,10 +685,7 @@ function updatePrAfterReview(agentId, pr, project) {
707
685
  function updatePrAfterFix(pr, project, source) {
708
686
 
709
687
  if (!pr?.id) return;
710
- // Resolve actual project if not provided — avoids writing merged array to wrong path
711
- const resolvedProject = project || resolveProjectForPr(pr.id);
712
- if (!resolvedProject) { log('warn', `updatePrAfterFix: cannot resolve project for ${pr.id}`); return; }
713
- const prs = getPrs(resolvedProject);
688
+ const prs = getPrs(project);
714
689
  const target = prs.find(p => p.id === pr.id);
715
690
  if (!target) return;
716
691
 
@@ -725,7 +700,7 @@ function updatePrAfterFix(pr, project, source) {
725
700
  log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
726
701
  }
727
702
 
728
- shared.safeWrite(shared.projectPrPath(resolvedProject), prs);
703
+ shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
729
704
  }
730
705
 
731
706
  // ─── Post-Merge / Post-Close Hooks ───────────────────────────────────────────
@@ -768,7 +743,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
768
743
  }
769
744
 
770
745
  if (mergedItemId) {
771
- // Mark PRD feature as done
746
+ // Mark PRD feature as implemented
772
747
  const prdDir = path.join(MINIONS_DIR, 'prd');
773
748
  try {
774
749
  const planFiles = fs.readdirSync(prdDir).filter(f => f.endsWith('.json'));
@@ -817,17 +792,13 @@ async function handlePostMerge(pr, project, config, newStatus) {
817
792
 
818
793
  const teamsUrl = process.env.TEAMS_PLAN_FLOW_URL;
819
794
  if (teamsUrl) {
820
- const ac = new AbortController();
821
- const t = setTimeout(() => ac.abort(), 5000);
822
795
  try {
823
796
  await fetch(teamsUrl, {
824
797
  method: 'POST',
825
- signal: ac.signal,
826
798
  headers: { 'Content-Type': 'application/json' },
827
799
  body: JSON.stringify({ text: `PR ${pr.id} merged: ${pr.title} (${project.name}) by ${pr.agent || 'unknown'}` })
828
800
  });
829
801
  } catch (err) { log('warn', `Teams post-merge notify failed: ${err.message}`); }
830
- clearTimeout(t);
831
802
  }
832
803
 
833
804
  log('info', `Post-merge hooks completed for ${pr.id}`);
@@ -1043,8 +1014,7 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
1043
1014
  if (taskUsage.numTurns > cp.maxTurns) cp.maxTurns = taskUsage.numTurns;
1044
1015
  // Check if this dispatch hit the turn limit
1045
1016
  const engineConfig = require('./queries').getConfig()?.engine || {};
1046
- // maxTurns default defined in ENGINE_DEFAULTS (shared.js) avoid hardcoded fallback
1047
- const turnLimit = engineConfig.maxTurns || shared.ENGINE_DEFAULTS.maxTurns;
1017
+ const turnLimit = engineConfig.maxTurns || 100;
1048
1018
  if (taskUsage.numTurns >= turnLimit) cp.turnLimitHits++;
1049
1019
  }
1050
1020
 
@@ -1073,7 +1043,7 @@ function resolveWiPath(meta) {
1073
1043
  }
1074
1044
 
1075
1045
  /**
1076
- * Parse structured eval verdict from review agent output.
1046
+ * Parse structured eval verdict from evaluate agent output.
1077
1047
  * Looks for a JSON block with { pass, build, tests, criteria_met, criteria_failed, feedback }.
1078
1048
  * Returns parsed object or null if not found.
1079
1049
  */
@@ -1193,11 +1163,6 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1193
1163
  ? path.join(MINIONS_DIR, 'work-items.json')
1194
1164
  : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1195
1165
  if (wiPath) {
1196
- // Cost ceiling circuit breaker config — resolved outside lock for clarity
1197
- const engineCfg = config?.engine || {};
1198
- const evalMaxCost = engineCfg.evalMaxCost != null ? engineCfg.evalMaxCost : shared.ENGINE_DEFAULTS.evalMaxCost;
1199
-
1200
- // Single lock: accumulate cost AND check ceiling atomically (no TOCTOU)
1201
1166
  mutateJsonFileLocked(wiPath, (items) => {
1202
1167
  if (!Array.isArray(items)) return items;
1203
1168
  const wi = items.find(i => i.id === meta.item.id);
@@ -1205,17 +1170,29 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1205
1170
  wi._totalCostUsd = (wi._totalCostUsd || 0) + (taskUsage.costUsd || 0);
1206
1171
  wi._totalInputTokens = (wi._totalInputTokens || 0) + (taskUsage.inputTokens || 0);
1207
1172
  wi._totalOutputTokens = (wi._totalOutputTokens || 0) + (taskUsage.outputTokens || 0);
1208
-
1209
- // Cost ceiling circuit breaker — treat like evalMaxIterations exceeded
1210
- if (evalMaxCost != null && evalMaxCost > 0 &&
1211
- wi._totalCostUsd > evalMaxCost && wi.status !== 'needs-human-review') {
1212
- wi.status = 'needs-human-review';
1213
- wi.failReason = `Cumulative cost $${wi._totalCostUsd.toFixed(2)} exceeds evalMaxCost ceiling $${evalMaxCost.toFixed(2)}`;
1214
- log('warn', `Work item ${meta.item.id} exceeded cost ceiling ($${wi._totalCostUsd.toFixed(2)} > $${evalMaxCost.toFixed(2)}) — needs-human-review`);
1215
- }
1216
1173
  }
1217
1174
  return items;
1218
1175
  }, { defaultValue: [] });
1176
+
1177
+ // Cost ceiling circuit breaker — treat like evalMaxIterations exceeded
1178
+ const engineCfg = config?.engine || {};
1179
+ const evalMaxCost = engineCfg.evalMaxCost != null ? engineCfg.evalMaxCost : shared.ENGINE_DEFAULTS.evalMaxCost;
1180
+ if (evalMaxCost != null && evalMaxCost > 0) {
1181
+ const freshItems = safeJson(wiPath) || [];
1182
+ const wi = freshItems.find(i => i.id === meta.item.id);
1183
+ if (wi && wi._totalCostUsd > evalMaxCost && wi.status !== 'needs-human-review') {
1184
+ mutateJsonFileLocked(wiPath, (items) => {
1185
+ if (!Array.isArray(items)) return items;
1186
+ const target = items.find(i => i.id === meta.item.id);
1187
+ if (target) {
1188
+ target.status = 'needs-human-review';
1189
+ target.failReason = `Cumulative cost $${wi._totalCostUsd.toFixed(2)} exceeds evalMaxCost ceiling $${evalMaxCost.toFixed(2)}`;
1190
+ log('warn', `Work item ${meta.item.id} exceeded cost ceiling ($${wi._totalCostUsd.toFixed(2)} > $${evalMaxCost.toFixed(2)}) — needs-human-review`);
1191
+ }
1192
+ return items;
1193
+ }, { defaultValue: [] });
1194
+ }
1195
+ }
1219
1196
  }
1220
1197
  } catch (err) { log('warn', `Cost accumulation: ${err.message}`); }
1221
1198
  }
@@ -1232,8 +1209,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1232
1209
  if (meta?.item?.id) {
1233
1210
  try {
1234
1211
  const engineConfig = (config.engine || {});
1235
- // maxTurns resolved from config, fallback to ENGINE_DEFAULTS (shared.js)
1236
- const turnLimit = engineConfig.maxTurns || shared.ENGINE_DEFAULTS.maxTurns;
1212
+ const turnLimit = engineConfig.maxTurns || 100;
1237
1213
  const turnCount = taskUsage?.numTurns || 0;
1238
1214
  const hitTurnLimit = turnCount >= turnLimit;
1239
1215
  let outputLogSizeBytes = 0;
@@ -1248,48 +1224,40 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1248
1224
 
1249
1225
  if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, 'done', '');
1250
1226
 
1251
- // Auto-dispatch review work item after implement or fix completes successfully
1252
- // For 'fix' items, only trigger when they were created by the eval loop (_evalParentId set)
1253
- const isEvalEligible = type === 'implement' || (type === 'fix' && meta?.item?._evalParentId);
1254
- if (isSuccess && !skipDoneStatus && isEvalEligible && meta?.item?.id) {
1255
- const evalLoop = config.engine?.evalLoop !== false;
1256
- if (evalLoop) {
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) {
1257
1231
  try {
1258
1232
  const wiPath = resolveWiPath(meta);
1259
1233
  if (wiPath) {
1260
1234
  const items = safeJson(wiPath) || [];
1261
- // For fix items, the eval parent is the original implement item
1262
- const evalParentId = type === 'fix' ? meta.item._evalParentId : meta.item.id;
1263
1235
  // Dedup: skip if a review item already exists for this parent
1264
- const existing = items.find(i => i._evalParentId === evalParentId && i.type === 'review' && i.status !== 'done' && i.status !== 'failed');
1236
+ const existing = items.find(i => i._evalParentId === meta.item.id && i.type === 'review');
1265
1237
  if (existing) {
1266
- log('info', `Eval loop: review item ${existing.id} already exists for ${evalParentId}, skipping`);
1238
+ log('info', `Eval loop: review item ${existing.id} already exists for ${meta.item.id}, skipping`);
1267
1239
  } else {
1268
- const parentItem = items.find(i => i.id === evalParentId);
1269
- if (parentItem?._evalDispatched) {
1270
- log('info', `Eval loop: parent ${evalParentId} already has _evalDispatched set, skipping`);
1271
- } else {
1272
- const evalItem = {
1273
- id: 'W-' + shared.uid(),
1274
- title: `Review: ${parentItem?.title || meta.item.title || evalParentId}`,
1275
- type: 'review',
1276
- priority: parentItem?.priority || meta.item.priority || 'high',
1277
- status: 'pending',
1278
- created: ts(),
1279
- createdBy: 'engine:eval-loop',
1280
- project: meta.project?.name || meta.item.project,
1281
- branch_name: parentItem?.branch_name || meta.branch || null,
1282
- pr_url: parentItem?.pr_url || null,
1283
- acceptance_criteria: parentItem?.acceptance_criteria || meta.item.acceptance_criteria || null,
1284
- _evalParentId: evalParentId,
1285
- };
1286
- if (parentItem?.sourcePlan) evalItem.sourcePlan = parentItem.sourcePlan;
1287
- // Mark parent as eval-dispatched before writing to prevent duplicates on re-run
1288
- if (parentItem) parentItem._evalDispatched = true;
1289
- items.push(evalItem);
1290
- shared.safeWrite(wiPath, items);
1291
- log('info', `Eval loop: created ${evalItem.id} for completed ${type} ${meta.item.id} (parent: ${evalParentId})`);
1292
- }
1240
+ const parentItem = items.find(i => i.id === meta.item.id);
1241
+ const evalItem = {
1242
+ id: 'W-' + shared.uid(),
1243
+ title: `Review: ${meta.item.title || meta.item.id}`,
1244
+ type: 'review',
1245
+ priority: meta.item.priority || 'high',
1246
+ status: 'pending',
1247
+ created: ts(),
1248
+ createdBy: 'engine:eval-loop',
1249
+ project: meta.project?.name || meta.item.project,
1250
+ branch_name: parentItem?.branch_name || meta.branch || null,
1251
+ pr_url: parentItem?.pr_url || null,
1252
+ acceptance_criteria: parentItem?.acceptance_criteria || meta.item.acceptance_criteria || null,
1253
+ _evalParentId: meta.item.id,
1254
+ };
1255
+ if (parentItem?.sourcePlan) evalItem.sourcePlan = parentItem.sourcePlan;
1256
+ // Mark parent as eval-dispatched before writing to prevent duplicates on re-run
1257
+ if (parentItem) parentItem._evalDispatched = true;
1258
+ items.push(evalItem);
1259
+ shared.safeWrite(wiPath, items);
1260
+ log('info', `Eval loop: created ${evalItem.id} for completed implement ${meta.item.id}`);
1293
1261
  }
1294
1262
  }
1295
1263
  } catch (err) {
@@ -1302,10 +1270,10 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1302
1270
  if (isSuccess && type === 'review' && meta?.item?._evalParentId) {
1303
1271
  try {
1304
1272
  const verdict = parseEvalVerdict(resultSummary || stdout);
1305
- const evalLoop = config.engine?.evalLoop !== false;
1273
+ const autoReview = config.engine?.autoReview ?? shared.ENGINE_DEFAULTS.autoReview;
1306
1274
  const maxIter = config.engine?.evalMaxIterations ?? shared.ENGINE_DEFAULTS.evalMaxIterations;
1307
1275
 
1308
- if (verdict && !verdict.pass && evalLoop) {
1276
+ if (verdict && !verdict.pass && autoReview) {
1309
1277
  const wiPath = resolveWiPath(meta);
1310
1278
  if (wiPath) {
1311
1279
  const items = safeJson(wiPath) || [];
@@ -1356,44 +1324,52 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1356
1324
  }
1357
1325
 
1358
1326
  if (!isSuccess && meta?.item?.id) {
1359
- // Auto-retry with atomic read-modify-write (no TOCTOU race)
1327
+ // Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
1328
+ let retries = (meta.item._retryCount || 0);
1360
1329
  try {
1361
- const wiPath = resolveWiPath(meta);
1330
+ const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1331
+ ? path.join(MINIONS_DIR, 'work-items.json')
1332
+ : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1362
1333
  if (wiPath) {
1363
- let retriesExhausted = false;
1364
- mutateJsonFileLocked(wiPath, (items) => {
1365
- if (!Array.isArray(items)) return items;
1334
+ const items = safeJson(wiPath) || [];
1335
+ const wi = items.find(i => i.id === meta.item.id);
1336
+ if (wi) retries = (wi._retryCount || 0); // Use fresh value from file
1337
+ }
1338
+ } catch { /* optional */ }
1339
+
1340
+ if (retries < 3) {
1341
+ log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/3`);
1342
+ updateWorkItemStatus(meta, 'pending', '');
1343
+ try {
1344
+ const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1345
+ ? path.join(MINIONS_DIR, 'work-items.json')
1346
+ : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1347
+ if (wiPath) {
1348
+ const items = safeJson(wiPath) || [];
1366
1349
  const wi = items.find(i => i.id === meta.item.id);
1367
- if (!wi) return items;
1368
- const retries = wi._retryCount || 0;
1369
- if (retries < 3) {
1370
- log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/3`);
1371
- wi._retryCount = retries + 1;
1372
- wi.status = 'pending';
1373
- delete wi.dispatched_at;
1374
- delete wi.dispatched_to;
1375
- if (type === 'decompose') delete wi._decomposing;
1376
- } else {
1377
- retriesExhausted = true;
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);
1378
1354
  }
1379
- // Clear _decomposing flag on any decompose failure to prevent permanent stuck
1380
- if (type === 'decompose') delete wi._decomposing;
1381
- return items;
1382
- }, { defaultValue: [] });
1383
- if (retriesExhausted) {
1384
- updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
1385
1355
  }
1386
- } else {
1387
- // No wiPath — can't read retries, fall back to meta snapshot
1388
- const retries = meta.item._retryCount || 0;
1389
- if (retries < 3) {
1390
- log('info', `Agent failed for ${meta.item.id} auto-retry ${retries + 1}/3`);
1391
- updateWorkItemStatus(meta, 'pending', '');
1392
- } else {
1393
- updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
1356
+ } catch (err) { log('warn', `Retry update: ${err.message}`); }
1357
+ } else {
1358
+ updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
1359
+ }
1360
+ // Clear _decomposing flag on failure so item doesn't get permanently stuck
1361
+ if (type === 'decompose') {
1362
+ try {
1363
+ const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1364
+ ? path.join(MINIONS_DIR, 'work-items.json')
1365
+ : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1366
+ 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); }
1394
1370
  }
1395
- }
1396
- } catch (err) { log('warn', `Retry/decompose update: ${err.message}`); }
1371
+ } catch (err) { log('warn', `Decompose cleanup: ${err.message}`); }
1372
+ }
1397
1373
  }
1398
1374
  // Meeting post-completion: collect findings/debate/conclusion
1399
1375
  if (type === 'meeting' && meta?.meetingId) {
@@ -1445,7 +1421,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1445
1421
  }
1446
1422
 
1447
1423
  // Detect implement tasks that completed without creating a PR
1448
- if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id && !meta?.item?.skipPr) {
1424
+ if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id) {
1449
1425
  // Check if a PR already exists linked to this work item (from a previous attempt)
1450
1426
  const projects = shared.getProjects(config);
1451
1427
  const existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
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
- // PR_LINKS_PATH removed — PR linking uses PR.prdItems in pull-requests.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 ────────────────────────────────────────────────────
@@ -68,15 +68,7 @@ function safeWrite(p, data) {
68
68
  const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
69
69
  const tmp = p + '.tmp.' + process.pid + '.' + (++_tmpCounter);
70
70
  try {
71
- try {
72
- fs.writeFileSync(tmp, content);
73
- } catch (writeErr) {
74
- if (writeErr.code === 'ENOSPC') {
75
- try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
76
- throw new Error(`[ENOSPC] Disk full — cannot write ${path.basename(p)}`);
77
- }
78
- throw writeErr;
79
- }
71
+ fs.writeFileSync(tmp, content);
80
72
  // Atomic rename — retry on Windows EPERM (file locking)
81
73
  for (let attempt = 0; attempt < 5; attempt++) {
82
74
  try {
@@ -176,7 +168,7 @@ function mutateJsonFileLocked(filePath, mutateFn, {
176
168
  * Use for filenames that could collide (dispatch IDs, temp files, etc.)
177
169
  */
178
170
  function uid() {
179
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
171
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
180
172
  }
181
173
 
182
174
  /**
@@ -363,9 +355,10 @@ const ENGINE_DEFAULTS = {
363
355
  allowTempAgents: false, // opt-in: spawn ephemeral agents when all permanent agents are busy
364
356
  autoDecompose: true, // auto-decompose implement:large items into sub-tasks
365
357
  autoApprovePlans: false, // auto-approve PRDs without waiting for human approval
358
+ autoReview: true, // auto-dispatch review agents for new PRs (disable for manual review workflow)
366
359
  meetingRoundTimeout: 600000, // 10min per meeting round before auto-advance
367
- evalLoop: true, // enable review→fix loop after implementation completes
368
- evalMaxIterations: 3, // max review→fix cycles before escalating to human
360
+ evalLoop: true, // enable evaluate→fix loop after implementation completes
361
+ evalMaxIterations: 3, // max evaluate→fix cycles before escalating to human
369
362
  evalMaxCost: null, // USD ceiling per work item across all eval iterations; null = no limit (gather baseline data first)
370
363
  };
371
364
 
@@ -411,26 +404,10 @@ function projectStateDir(project) {
411
404
  return dir;
412
405
  }
413
406
 
414
- const CENTRAL_WI_PATH = path.join(MINIONS_DIR, 'work-items.json');
415
-
416
407
  function projectWorkItemsPath(project) {
417
408
  return path.join(projectStateDir(project), 'work-items.json');
418
409
  }
419
410
 
420
- /**
421
- * Resolve work-items.json path from dispatch meta.
422
- * Central items → CENTRAL_WI_PATH; project items → projects/<name>/work-items.json.
423
- */
424
- function resolveWiPath(meta) {
425
- if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
426
- return CENTRAL_WI_PATH;
427
- }
428
- if (meta.project?.name) {
429
- return path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json');
430
- }
431
- return null;
432
- }
433
-
434
411
  function projectPrPath(project) {
435
412
  return path.join(projectStateDir(project), 'pull-requests.json');
436
413
  }
@@ -524,11 +501,10 @@ function parseSkillFrontmatter(content, filename) {
524
501
  // Never touched by polling loops — only written when a PR is first linked to a PRD item.
525
502
 
526
503
  function getPrLinks() {
527
- // Derive from PR.prdItems (single source of truth)
504
+ // Derive from PR.prdItems (single source of truth) + legacy pr-links.json as fallback
528
505
  const links = {};
529
506
  try {
530
- const config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
531
- const projects = getProjects(config);
507
+ const projects = getProjects();
532
508
  for (const project of projects) {
533
509
  const prs = safeJson(projectPrPath(project)) || [];
534
510
  for (const pr of prs) {
@@ -538,16 +514,22 @@ function getPrLinks() {
538
514
  }
539
515
  }
540
516
  } catch { /* optional */ }
517
+ // Merge legacy pr-links.json for items not yet in PR.prdItems
518
+ try {
519
+ const legacy = JSON.parse(require('fs').readFileSync(PR_LINKS_PATH, 'utf8'));
520
+ for (const [prId, itemId] of Object.entries(legacy)) {
521
+ if (!links[prId]) links[prId] = itemId;
522
+ }
523
+ } catch { /* optional */ }
541
524
  return links;
542
525
  }
543
526
 
544
527
  function addPrLink(prId, itemId) {
545
528
  if (!prId || !itemId) return;
546
- try {
547
- const config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
548
- const projects = getProjects(config);
549
- for (const project of projects) { linkPrToItem(project, prId, itemId); }
550
- } catch { /* optional */ }
529
+ const links = getPrLinks();
530
+ if (links[prId] === itemId) return; // already correct, no write needed
531
+ links[prId] = itemId;
532
+ safeWrite(PR_LINKS_PATH, links);
551
533
  }
552
534
 
553
535
  /**
@@ -579,6 +561,7 @@ function linkPrToItem(project, prId, itemId) {
579
561
 
580
562
  module.exports = {
581
563
  MINIONS_DIR,
564
+ PR_LINKS_PATH,
582
565
  LOG_PATH,
583
566
  ts,
584
567
  logTs,
@@ -609,9 +592,7 @@ module.exports = {
609
592
  getProjects,
610
593
  projectRoot,
611
594
  projectStateDir,
612
- CENTRAL_WI_PATH,
613
595
  projectWorkItemsPath,
614
- resolveWiPath,
615
596
  projectPrPath,
616
597
  getPrLinks,
617
598
  addPrLink,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.181",
3
+ "version": "0.1.183",
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"