@yemi33/minions 0.1.10 → 0.1.12

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/engine/ado.js CHANGED
@@ -149,6 +149,20 @@ async function pollPrStatus(config) {
149
149
  e.log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
150
150
  pr.reviewStatus = newReviewStatus;
151
151
  updated = true;
152
+ // Update author metrics when verdict changes to approved/rejected
153
+ if (newReviewStatus === 'approved' || newReviewStatus === 'changes-requested') {
154
+ const authorId = (pr.agent || '').toLowerCase();
155
+ if (authorId) {
156
+ try {
157
+ const metricsPath = path.join(__dirname, 'metrics.json');
158
+ const metrics = shared.safeJson(metricsPath) || {};
159
+ if (!metrics[authorId]) metrics[authorId] = {};
160
+ if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
161
+ else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
162
+ shared.safeWrite(metricsPath, metrics);
163
+ } catch {}
164
+ }
165
+ }
152
166
  }
153
167
 
154
168
  if (newStatus !== 'active') return updated;
package/engine/cli.js CHANGED
@@ -306,6 +306,17 @@ const commands = {
306
306
 
307
307
  // Start tick loop
308
308
  const tickTimer = setInterval(() => e.tick(), interval);
309
+
310
+ // Fast poll for immediate wakeup signals (checks control.json every 2s)
311
+ setInterval(() => {
312
+ const ctrl = getControl();
313
+ if (ctrl._wakeupAt && Date.now() - ctrl._wakeupAt < 5000) {
314
+ delete ctrl._wakeupAt;
315
+ safeWrite(CONTROL_PATH, ctrl);
316
+ e.tick();
317
+ }
318
+ }, 2000);
319
+
309
320
  console.log(`Tick interval: ${interval / 1000}s | Max concurrent: ${config.engine?.maxConcurrent || 5}`);
310
321
  console.log('Press Ctrl+C to stop');
311
322
 
package/engine/github.js CHANGED
@@ -127,6 +127,20 @@ async function pollPrStatus(config) {
127
127
  e.log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
128
128
  pr.reviewStatus = newReviewStatus;
129
129
  updated = true;
130
+ // Update author metrics when verdict changes to approved/rejected
131
+ if (newReviewStatus === 'approved' || newReviewStatus === 'changes-requested') {
132
+ const authorId = (pr.agent || '').toLowerCase();
133
+ if (authorId) {
134
+ try {
135
+ const metricsPath = path.join(__dirname, 'metrics.json');
136
+ const metrics = shared.safeJson(metricsPath) || {};
137
+ if (!metrics[authorId]) metrics[authorId] = {};
138
+ if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
139
+ else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
140
+ shared.safeWrite(metricsPath, metrics);
141
+ } catch {}
142
+ }
143
+ }
130
144
  }
131
145
  }
132
146
 
@@ -617,28 +617,25 @@ function updatePrAfterReview(agentId, pr, project) {
617
617
  const dispatch = getDispatch();
618
618
  const completedEntry = (dispatch.completed || []).find(d => d.agent === agentId && d.type === 'review');
619
619
 
620
+ // Set reviewStatus to 'waiting' (single source of truth — synced from ADO/GitHub votes on next poll)
621
+ target.reviewStatus = 'waiting';
620
622
  target.minionsReview = {
621
- status: 'waiting',
622
623
  reviewer: reviewerName,
623
624
  reviewedAt: e.ts(),
624
625
  note: completedEntry?.task || ''
625
626
  };
626
- const minionsVerdict = target.minionsReview.status;
627
+ // Metrics update: don't track 'waiting' as a verdict — metrics are updated
628
+ // when pollPrStatus syncs the actual vote to minionsReview.status.
629
+ // The reviewer's reviewsDone counter is incremented in the main updateMetrics call.
627
630
 
631
+ // Track reviewer for metrics purposes
628
632
  const authorAgentId = (pr.agent || '').toLowerCase();
629
633
  if (authorAgentId && config.agents?.[authorAgentId]) {
630
634
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
631
635
  const metrics = safeJson(metricsPath) || {};
632
636
  if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
633
- if (!metrics[authorAgentId]._reviewedPrs) metrics[authorAgentId]._reviewedPrs = {};
634
- const prevVerdict = metrics[authorAgentId]._reviewedPrs[pr.id];
635
- if (prevVerdict !== minionsVerdict) {
636
- if (prevVerdict === 'approved') metrics[authorAgentId].prsApproved = Math.max(0, (metrics[authorAgentId].prsApproved || 0) - 1);
637
- else if (prevVerdict === 'changes-requested') metrics[authorAgentId].prsRejected = Math.max(0, (metrics[authorAgentId].prsRejected || 0) - 1);
638
- if (minionsVerdict === 'approved') metrics[authorAgentId].prsApproved++;
639
- else if (minionsVerdict === 'changes-requested') metrics[authorAgentId].prsRejected++;
640
- metrics[authorAgentId]._reviewedPrs[pr.id] = minionsVerdict;
641
- }
637
+ if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
638
+ metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
642
639
  shared.safeWrite(metricsPath, metrics);
643
640
  }
644
641
 
@@ -654,25 +651,15 @@ function updatePrAfterFix(pr, project, source) {
654
651
  const target = prs.find(p => p.id === pr.id);
655
652
  if (!target) return;
656
653
 
654
+ // Reset reviewStatus to 'waiting' for re-review (single source of truth)
655
+ target.reviewStatus = 'waiting';
657
656
  if (source === 'pr-human-feedback') {
658
- // Human feedback fix: clear pendingFix AND reset to waiting for re-review
659
657
  if (target.humanFeedback) target.humanFeedback.pendingFix = false;
660
- target.minionsReview = {
661
- ...target.minionsReview,
662
- status: 'waiting',
663
- note: 'Fixed human feedback, awaiting re-review',
664
- fixedAt: e.ts()
665
- };
658
+ target.minionsReview = { ...target.minionsReview, note: 'Fixed human feedback, awaiting re-review', fixedAt: e.ts() };
666
659
  e.log('info', `Updated ${pr.id} → cleared humanFeedback.pendingFix, reset to waiting for re-review`);
667
660
  } else {
668
- // Review fix: reset to waiting for re-review
669
- target.minionsReview = {
670
- ...target.minionsReview,
671
- status: 'waiting',
672
- note: 'Fixed, awaiting re-review',
673
- fixedAt: e.ts()
674
- };
675
- e.log('info', `Updated ${pr.id} → minions review: waiting (fix pushed)`);
661
+ target.minionsReview = { ...target.minionsReview, note: 'Fixed, awaiting re-review', fixedAt: e.ts() };
662
+ e.log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
676
663
  }
677
664
 
678
665
  shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
@@ -932,8 +919,8 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
932
919
  // ─── Agent Output Parsing ────────────────────────────────────────────────────
933
920
 
934
921
  function parseAgentOutput(stdout) {
935
- const parsed = shared.parseStreamJsonOutput(stdout, { maxTextLength: 500 });
936
- return { resultSummary: parsed.text, taskUsage: parsed.usage };
922
+ const { text, usage, sessionId } = shared.parseStreamJsonOutput(stdout, { maxTextLength: 2000 });
923
+ return { resultSummary: text, taskUsage: usage, sessionId };
937
924
  }
938
925
 
939
926
  /**
@@ -1018,19 +1005,26 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1018
1005
  const meta = dispatchItem.meta;
1019
1006
  const isSuccess = code === 0;
1020
1007
  const result = isSuccess ? 'success' : 'error';
1021
- const { resultSummary, taskUsage } = parseAgentOutput(stdout);
1008
+ const { resultSummary, taskUsage, sessionId } = parseAgentOutput(stdout);
1009
+
1010
+ // Save session for potential resume on next dispatch
1011
+ if (isSuccess && sessionId && agentId && !agentId.startsWith('temp-')) {
1012
+ try {
1013
+ shared.safeWrite(path.join(AGENTS_DIR, agentId, 'session.json'), {
1014
+ sessionId, dispatchId: dispatchItem.id, savedAt: new Date().toISOString()
1015
+ });
1016
+ } catch {}
1017
+ }
1022
1018
 
1023
1019
  // Handle decomposition results — create sub-items from decompose agent output
1020
+ let skipDoneStatus = false;
1024
1021
  if (type === 'decompose' && isSuccess && meta?.item?.id) {
1025
1022
  const subCount = handleDecompositionResult(stdout, meta, config);
1026
- if (subCount > 0) {
1027
- // Parent is marked 'decomposed' by handler don't overwrite with 'done'
1028
- return { resultSummary: `Decomposed into ${subCount} sub-items`, taskUsage };
1029
- }
1030
- // Fallback: if decomposition produced nothing, mark parent as done to avoid stuck state
1023
+ if (subCount > 0) skipDoneStatus = true; // parent already marked 'decomposed' by handler
1024
+ // If decomposition produced nothing, fall through to mark parent as done
1031
1025
  }
1032
1026
 
1033
- if (isSuccess && meta?.item?.id) updateWorkItemStatus(meta, 'done', '');
1027
+ if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, 'done', '');
1034
1028
  if (!isSuccess && meta?.item?.id) {
1035
1029
  // Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
1036
1030
  let retries = (meta.item._retryCount || 0);
@@ -1055,12 +1049,29 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1055
1049
  if (wiPath) {
1056
1050
  const items = safeJson(wiPath) || [];
1057
1051
  const wi = items.find(i => i.id === meta.item.id);
1058
- if (wi) { wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to; shared.safeWrite(wiPath, items); }
1052
+ if (wi) {
1053
+ wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to;
1054
+ if (type === 'decompose') delete wi._decomposing; // clear so item can retry decomposition
1055
+ shared.safeWrite(wiPath, items);
1056
+ }
1059
1057
  }
1060
1058
  } catch {}
1061
1059
  } else {
1062
1060
  updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
1063
1061
  }
1062
+ // Clear _decomposing flag on failure so item doesn't get permanently stuck
1063
+ if (type === 'decompose') {
1064
+ try {
1065
+ const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1066
+ ? path.join(MINIONS_DIR, 'work-items.json')
1067
+ : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1068
+ if (wiPath) {
1069
+ const items = safeJson(wiPath) || [];
1070
+ const wi = items.find(i => i.id === meta.item.id);
1071
+ if (wi) { delete wi._decomposing; shared.safeWrite(wiPath, items); }
1072
+ }
1073
+ } catch {}
1074
+ }
1064
1075
  }
1065
1076
  // Plan chaining removed — user must explicitly execute plan-to-prd after reviewing the plan
1066
1077
  if (isSuccess && meta?.item?.sourcePlan) checkPlanCompletion(meta, config);
@@ -96,16 +96,11 @@ function shouldRunNow(schedule, lastRunAt) {
96
96
  const now = new Date();
97
97
  if (!cron.matches(now)) return false;
98
98
 
99
- // Don't fire again if already ran in this minute window
99
+ // Don't fire again if already ran within the last 55 seconds
100
+ // (uses elapsed time instead of field comparison to handle DST/clock adjustments)
100
101
  if (lastRunAt) {
101
102
  const last = new Date(lastRunAt);
102
- if (last.getFullYear() === now.getFullYear() &&
103
- last.getMonth() === now.getMonth() &&
104
- last.getDate() === now.getDate() &&
105
- last.getHours() === now.getHours() &&
106
- last.getMinutes() === now.getMinutes()) {
107
- return false; // already fired this minute
108
- }
103
+ if (Date.now() - last.getTime() < 55000) return false;
109
104
  }
110
105
 
111
106
  return true;
@@ -120,38 +115,34 @@ function discoverScheduledWork(config) {
120
115
  const schedules = config.schedules;
121
116
  if (!Array.isArray(schedules) || schedules.length === 0) return [];
122
117
 
123
- const runs = safeJson(SCHEDULE_RUNS_PATH) || {};
118
+ // Use file-locked mutation to prevent race conditions on rapid calls
124
119
  const work = [];
125
-
126
- for (const sched of schedules) {
127
- if (!sched.id || !sched.cron || !sched.title) continue;
128
- if (sched.enabled === false) continue;
129
-
130
- const lastRun = runs[sched.id] || null;
131
- if (!shouldRunNow(sched, lastRun)) continue;
132
-
133
- work.push({
134
- id: `sched-${sched.id}-${Date.now()}`,
135
- title: sched.title,
136
- type: sched.type || 'implement',
137
- priority: sched.priority || 'medium',
138
- description: sched.description || sched.title,
139
- status: 'pending',
140
- created: new Date().toISOString(),
141
- createdBy: 'scheduler',
142
- agent: sched.agent || null,
143
- project: sched.project || null,
144
- _scheduleId: sched.id,
145
- });
146
-
147
- // Record run time
148
- runs[sched.id] = new Date().toISOString();
149
- }
150
-
151
- // Persist run times if any schedules fired
152
- if (work.length > 0) {
153
- safeWrite(SCHEDULE_RUNS_PATH, runs);
154
- }
120
+ mutateJsonFileLocked(SCHEDULE_RUNS_PATH, (runs) => {
121
+ for (const sched of schedules) {
122
+ if (!sched.id || !sched.cron || !sched.title) continue;
123
+ if (sched.enabled === false) continue;
124
+
125
+ const lastRun = runs[sched.id] || null;
126
+ if (!shouldRunNow(sched, lastRun)) continue;
127
+
128
+ work.push({
129
+ id: `sched-${sched.id}-${Date.now()}`,
130
+ title: sched.title,
131
+ type: sched.type || 'implement',
132
+ priority: sched.priority || 'medium',
133
+ description: sched.description || sched.title,
134
+ status: 'pending',
135
+ created: new Date().toISOString(),
136
+ createdBy: 'scheduler',
137
+ agent: sched.agent || null,
138
+ project: sched.project || null,
139
+ _scheduleId: sched.id,
140
+ });
141
+
142
+ // Record run time inside the lock
143
+ runs[sched.id] = new Date().toISOString();
144
+ }
145
+ }, { defaultValue: {} });
155
146
 
156
147
  return work;
157
148
  }
package/engine.js CHANGED
@@ -166,6 +166,25 @@ function getRoutingTableCached() {
166
166
  return _routingCache;
167
167
  }
168
168
 
169
+ function getMonthlySpend(agentId) {
170
+ const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
171
+ const daily = metrics._daily || {};
172
+ const now = new Date();
173
+ const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
174
+ let total = 0;
175
+ for (const [date, data] of Object.entries(daily)) {
176
+ if (date.startsWith(monthPrefix)) {
177
+ total += (data.perAgent?.[agentId]?.costUsd || 0);
178
+ }
179
+ }
180
+ // Fallback: if no per-agent daily data, use cumulative (less accurate for monthly)
181
+ if (total === 0 && metrics[agentId]?.totalCostUsd) {
182
+ // Can't distinguish monthly from cumulative — treat as monthly estimate
183
+ // This path is for backward compat before per-agent daily tracking was added
184
+ }
185
+ return total;
186
+ }
187
+
169
188
  function getAgentErrorRate(agentId) {
170
189
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
171
190
  const metrics = safeJson(metricsPath) || {};
@@ -194,7 +213,15 @@ function resolveAgent(workType, config, authorAgent = null) {
194
213
  let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
195
214
  let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
196
215
 
197
- const isAvailable = (id) => agents[id] && isAgentIdle(id) && !_claimedAgents.has(id);
216
+ const isAvailable = (id) => {
217
+ if (!agents[id] || !isAgentIdle(id) || _claimedAgents.has(id)) return false;
218
+ // Budget check — no budget means infinite (no limit)
219
+ const budget = agents[id].monthlyBudgetUsd;
220
+ if (budget && budget > 0) {
221
+ if (getMonthlySpend(id) >= budget) return false;
222
+ }
223
+ return true;
224
+ };
198
225
 
199
226
  // Check preferred and fallback first (routing table order)
200
227
  if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
@@ -905,6 +932,20 @@ function spawnAgent(dispatchItem, config) {
905
932
  args.push('--allowedTools', claudeConfig.allowedTools);
906
933
  }
907
934
 
935
+ // Session resume: reuse last session if recent enough (< 2 hours)
936
+ if (!agentId.startsWith('temp-')) {
937
+ try {
938
+ const sessionFile = safeJson(path.join(AGENTS_DIR, agentId, 'session.json'));
939
+ if (sessionFile?.sessionId && sessionFile.savedAt) {
940
+ const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
941
+ if (sessionAge < 2 * 60 * 60 * 1000) { // 2 hour TTL
942
+ args.push('--resume', sessionFile.sessionId);
943
+ log('info', `Resuming session ${sessionFile.sessionId} for ${agentId} (age: ${Math.round(sessionAge / 60000)}min)`);
944
+ }
945
+ }
946
+ } catch {}
947
+ }
948
+
908
949
  // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
909
950
  // No --mcp-config needed — avoids redundant config and ensures agents always have latest servers.
910
951
 
@@ -1968,6 +2009,27 @@ function setCooldown(key) {
1968
2009
  saveCooldowns();
1969
2010
  }
1970
2011
 
2012
+ function setCooldownWithContext(key, context) {
2013
+ const existing = dispatchCooldowns.get(key);
2014
+ const pendingContexts = existing?.pendingContexts || [];
2015
+ if (context) pendingContexts.push(context);
2016
+ dispatchCooldowns.set(key, {
2017
+ timestamp: Date.now(),
2018
+ failures: existing?.failures || 0,
2019
+ pendingContexts
2020
+ });
2021
+ saveCooldowns();
2022
+ }
2023
+
2024
+ function getCoalescedContexts(key) {
2025
+ const entry = dispatchCooldowns.get(key);
2026
+ const contexts = entry?.pendingContexts || [];
2027
+ if (contexts.length > 0 && entry) {
2028
+ entry.pendingContexts = []; // Clear after retrieval
2029
+ }
2030
+ return contexts;
2031
+ }
2032
+
1971
2033
  function setCooldownFailure(key) {
1972
2034
  const existing = dispatchCooldowns.get(key);
1973
2035
  const failures = (existing?.failures || 0) + 1;
@@ -2390,19 +2452,16 @@ function discoverFromPrs(config, project) {
2390
2452
  if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
2391
2453
 
2392
2454
  const prNumber = (pr.id || '').replace(/^PR-/, '');
2393
- const minionsStatus = pr.minionsReview?.status;
2455
+ // Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
2456
+ // minionsReview tracks metadata (reviewer, note) but not the authoritative status
2457
+ const reviewStatus = pr.reviewStatus || 'pending';
2394
2458
 
2395
- // PRs needing review
2396
- const needsReview = !minionsStatus || minionsStatus === 'waiting';
2459
+ // PRs needing review: pending or waiting (review dispatched but no verdict yet)
2460
+ const needsReview = reviewStatus === 'pending' || reviewStatus === 'waiting';
2397
2461
  if (needsReview) {
2398
2462
  const key = `review-${project?.name || 'default'}-${pr.id}`;
2399
2463
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2400
- // No self-review: exclude the PR author from review assignment
2401
- const prAuthor = (pr.agent || '').toLowerCase();
2402
- let agentId = resolveAgent('review', config);
2403
- if (agentId && agentId === prAuthor) {
2404
- agentId = resolveAgent('review', config); // retry — prAuthor now claimed, gets skipped
2405
- }
2464
+ const agentId = resolveAgent('review', config);
2406
2465
  if (!agentId) continue;
2407
2466
 
2408
2467
  const item = buildPrDispatch(agentId, config, project, pr, 'review', {
@@ -2413,7 +2472,7 @@ function discoverFromPrs(config, project) {
2413
2472
  }
2414
2473
 
2415
2474
  // PRs with changes requested → route back to author for fix
2416
- if (minionsStatus === 'changes-requested') {
2475
+ if (reviewStatus === 'changes-requested') {
2417
2476
  const key = `fix-${project?.name || 'default'}-${pr.id}`;
2418
2477
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2419
2478
  const agentId = resolveAgent('fix', config, pr.agent);
@@ -2426,22 +2485,37 @@ function discoverFromPrs(config, project) {
2426
2485
  if (item) { newWork.push(item); setCooldown(key); }
2427
2486
  }
2428
2487
 
2429
- // PRs with pending human feedback
2430
- if (pr.humanFeedback?.pendingFix) {
2431
- const key = `human-fix-${project?.name || 'default'}-${pr.id}`;
2432
- if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2488
+ // PRs with pending human feedback (or coalesced comments from while agent was fixing)
2489
+ const humanFixKey = `human-fix-${project?.name || 'default'}-${pr.id}`;
2490
+ const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
2491
+ if (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) {
2492
+ const key = humanFixKey;
2493
+ if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) {
2494
+ // Coalesce: save feedback for next dispatch
2495
+ if (pr.humanFeedback?.feedbackContent) {
2496
+ setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: new Date().toISOString() });
2497
+ }
2498
+ continue;
2499
+ }
2433
2500
  const agentId = resolveAgent('fix', config, pr.agent);
2434
2501
  if (!agentId) continue;
2435
2502
 
2503
+ const coalesced = getCoalescedContexts(key);
2504
+ let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
2505
+ if (coalesced.length > 0) {
2506
+ const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
2507
+ if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
2508
+ }
2509
+
2436
2510
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2437
2511
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2438
2512
  reviewer: 'Human Reviewer',
2439
- review_note: pr.humanFeedback.feedbackContent || 'See PR thread comments',
2513
+ review_note: reviewNote,
2440
2514
  }, `Fix PR ${pr.id} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: projMeta });
2441
2515
  if (item) { newWork.push(item); setCooldown(key); }
2442
2516
  }
2443
2517
 
2444
- // PRs with build failures
2518
+ // PRs with build failures — any agent can pick this up
2445
2519
  if (pr.status === 'active' && pr.buildStatus === 'failing') {
2446
2520
  const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
2447
2521
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
@@ -2565,7 +2639,17 @@ function discoverFromWorkItems(config, project) {
2565
2639
  }
2566
2640
  const agentId = item.agent || resolveAgent(workType, config);
2567
2641
  if (!agentId) {
2568
- if (item._pendingReason !== 'no_agent') { item._pendingReason = 'no_agent'; needsWrite = true; }
2642
+ // Check if reason is budget
2643
+ const cfgAgents = config.agents || {};
2644
+ const budgetBlocked = Object.keys(cfgAgents).some(id => {
2645
+ const b = cfgAgents[id].monthlyBudgetUsd;
2646
+ return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
2647
+ });
2648
+ if (budgetBlocked) {
2649
+ if (item._pendingReason !== 'budget_exceeded') { item._pendingReason = 'budget_exceeded'; needsWrite = true; }
2650
+ } else {
2651
+ if (item._pendingReason !== 'no_agent') { item._pendingReason = 'no_agent'; needsWrite = true; }
2652
+ }
2569
2653
  skipped.noAgent++; continue;
2570
2654
  }
2571
2655
 
@@ -2626,8 +2710,8 @@ function discoverFromWorkItems(config, project) {
2626
2710
  newWork.push({
2627
2711
  type: workType,
2628
2712
  agent: agentId,
2629
- agentName: config.agents[agentId]?.name,
2630
- agentRole: config.agents[agentId]?.role,
2713
+ agentName: config.agents[agentId]?.name || tempAgents.get(agentId)?.name || agentId,
2714
+ agentRole: config.agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent',
2631
2715
  task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
2632
2716
  prompt,
2633
2717
  meta: { dispatchKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(item.branchStrategy === 'shared-branch' && item.featureBranch), item, project: { name: project?.name, localPath: project?.localPath } }
@@ -3064,16 +3148,18 @@ function discoverWork(config) {
3064
3148
  try {
3065
3149
  const { discoverScheduledWork } = require('./engine/scheduler');
3066
3150
  const scheduledWork = discoverScheduledWork(config);
3067
- for (const item of scheduledWork) {
3068
- // Write scheduled items to central work-items.json so they persist across ticks
3151
+ if (scheduledWork.length > 0) {
3069
3152
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
3070
3153
  const items = safeJson(centralPath) || [];
3071
- // Dedupe: don't re-create if same schedule already has a pending/dispatched item
3072
- if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== 'done' && i.status !== 'failed')) {
3073
- items.push(item);
3074
- safeWrite(centralPath, items);
3075
- log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
3154
+ let added = 0;
3155
+ for (const item of scheduledWork) {
3156
+ if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== 'done' && i.status !== 'failed')) {
3157
+ items.push(item);
3158
+ added++;
3159
+ log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
3160
+ }
3076
3161
  }
3162
+ if (added > 0) safeWrite(centralPath, items);
3077
3163
  }
3078
3164
  } catch {}
3079
3165
 
@@ -3394,7 +3480,10 @@ module.exports = {
3394
3480
  updateWorkItemStatus, runCleanup, handlePostMerge,
3395
3481
 
3396
3482
  // Cooldowns
3397
- loadCooldowns,
3483
+ loadCooldowns, setCooldownWithContext, getCoalescedContexts,
3484
+
3485
+ // Budget
3486
+ getMonthlySpend,
3398
3487
 
3399
3488
  // Tick
3400
3489
  tick,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"
package/routing.md CHANGED
@@ -27,7 +27,7 @@ Notes:
27
27
  ## Rules
28
28
 
29
29
  1. **Eager by default** — spawn all agents who can start work, not one at a time
30
- 2. **No self-review** — author cannot review their own PR
30
+ 2. **Self-review is allowed** — agents can review their own PRs (useful for single-agent setups)
31
31
  3. **Exploration gates implementation** — when exploring, finish before implementing
32
32
  4. **Implementation informs PRD** — Lambert reads build summaries before writing PRD
33
33
  5. **All rules in `notes.md` apply** — engine injects them into every playbook