@yemi33/minions 0.1.143 → 0.1.145

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.145 (2026-04-01)
4
+
5
+ ### Engine
6
+ - engine.js
7
+ - engine/ado.js
8
+ - engine/cleanup.js
9
+ - engine/cooldown.js
10
+ - engine/github.js
11
+ - engine/lifecycle.js
12
+ - engine/meeting.js
13
+ - engine/playbook.js
14
+ - engine/routing.js
15
+ - engine/timeout.js
16
+
17
+ ### Dashboard
18
+ - dashboard/js/render-other.js
19
+ - dashboard/pages/engine.html
20
+
21
+ ### Other
22
+ - test/unit.test.js
23
+
3
24
  ## 0.1.143 (2026-04-01)
4
25
 
5
26
  ### Engine
@@ -44,6 +44,7 @@ function renderMetrics(metrics) {
44
44
  if (agents.length === 0) {
45
45
  el.innerHTML = '<p class="empty">No metrics yet. Metrics appear after agents complete tasks.</p>';
46
46
  renderTokenUsage(metrics);
47
+ renderContextPressure(metrics);
47
48
  return;
48
49
  }
49
50
  let html = '<table class="pr-table"><thead><tr><th>Agent</th><th>Done</th><th>Errors</th><th>PRs</th><th>Approved</th><th>Rejected</th><th>Rate</th><th>Reviews</th></tr></thead><tbody>';
@@ -64,6 +65,7 @@ function renderMetrics(metrics) {
64
65
  html += '</tbody></table>';
65
66
  el.innerHTML = html;
66
67
  renderTokenUsage(metrics);
68
+ renderContextPressure(metrics);
67
69
  }
68
70
 
69
71
  function renderTokenUsage(metrics) {
@@ -183,4 +185,25 @@ function renderTokenUsage(metrics) {
183
185
  el.innerHTML = html;
184
186
  }
185
187
 
186
- window.MinionsOther = { renderProjects, renderMcpServers, renderMetrics, renderTokenUsage };
188
+ function renderContextPressure(metrics) {
189
+ const el = document.getElementById('context-pressure-content');
190
+ if (!el) return;
191
+ const cp = metrics._contextPressure;
192
+ if (!cp || !cp.dispatches) {
193
+ el.innerHTML = '<p class="empty">No context pressure data yet. Data appears after agents complete tasks.</p>';
194
+ return;
195
+ }
196
+ const avgTurns = (cp.totalTurns / cp.dispatches).toFixed(1);
197
+ const turnLimitPct = ((cp.turnLimitHits / cp.dispatches) * 100).toFixed(1);
198
+ const pctColor = parseFloat(turnLimitPct) > 20 ? 'var(--red)' : parseFloat(turnLimitPct) > 5 ? 'var(--yellow, orange)' : 'var(--green)';
199
+
200
+ let html = '<div class="token-tiles">';
201
+ html += '<div class="token-tile"><div class="token-tile-label">Avg Turns</div><div class="token-tile-value">' + avgTurns + '</div><div class="token-tile-sub">per dispatch</div></div>';
202
+ html += '<div class="token-tile"><div class="token-tile-label">Max Turns</div><div class="token-tile-value">' + cp.maxTurns + '</div></div>';
203
+ html += '<div class="token-tile"><div class="token-tile-label">Hit Turn Limit</div><div class="token-tile-value" style="color:' + pctColor + '">' + turnLimitPct + '%</div><div class="token-tile-sub">' + cp.turnLimitHits + ' of ' + cp.dispatches + ' dispatches</div></div>';
204
+ html += '<div class="token-tile"><div class="token-tile-label">Total Dispatches</div><div class="token-tile-value">' + cp.dispatches + '</div></div>';
205
+ html += '</div>';
206
+ el.innerHTML = html;
207
+ }
208
+
209
+ window.MinionsOther = { renderProjects, renderMcpServers, renderMetrics, renderTokenUsage, renderContextPressure };
@@ -10,3 +10,7 @@
10
10
  <h2>Token Usage</h2>
11
11
  <div id="token-usage-content"><p class="empty">No usage data yet.</p></div>
12
12
  </section>
13
+ <section>
14
+ <h2>Context Pressure</h2>
15
+ <div id="context-pressure-content"><p class="empty">No context pressure data yet. Data appears after agents complete tasks.</p></div>
16
+ </section>
package/engine/ado.js CHANGED
@@ -5,10 +5,10 @@
5
5
 
6
6
  const path = require('path');
7
7
  const shared = require('./shared');
8
- const { exec, getAdoOrgBase, addPrLink } = shared;
8
+ const { exec, getAdoOrgBase, addPrLink, log, dateStamp } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
 
11
- // Lazy require to avoid circular dependency
11
+ // Lazy require to avoid circular dependency — only needed for engine().handlePostMerge
12
12
  let _engine = null;
13
13
  function engine() {
14
14
  if (!_engine) _engine = require('../engine');
@@ -37,7 +37,7 @@ function getAdoToken() {
37
37
  return token;
38
38
  }
39
39
  } catch (e) {
40
- engine().log('warn', `Failed to get ADO token: ${e.message}`);
40
+ log('warn', `Failed to get ADO token: ${e.message}`);
41
41
  }
42
42
  // Back off for 10 minutes to avoid spamming browser auth popups
43
43
  _adoTokenFailedUntil = Date.now() + 10 * 60 * 1000;
@@ -57,7 +57,7 @@ async function adoFetch(url, token, _retryCount = 0) {
57
57
  if (_retryCount < MAX_RETRIES) {
58
58
  const freshToken = getAdoToken();
59
59
  if (freshToken) {
60
- engine().log('info', 'ADO token expired mid-session — refreshed and retrying');
60
+ log('info', 'ADO token expired mid-session — refreshed and retrying');
61
61
  return adoFetch(url, freshToken, _retryCount + 1);
62
62
  }
63
63
  }
@@ -94,7 +94,7 @@ async function forEachActivePr(config, token, callback) {
94
94
  const updated = await callback(project, pr, prNum, orgBase);
95
95
  if (updated) projectUpdated++;
96
96
  } catch (err) {
97
- try { engine().log('warn', `Failed to poll status for ${pr.id}: ${err.message}`); } catch { /* engine not available */ }
97
+ log('warn', `Failed to poll status for ${pr.id}: ${err.message}`);
98
98
  }
99
99
  }
100
100
 
@@ -110,10 +110,9 @@ async function forEachActivePr(config, token, callback) {
110
110
  // ─── PR Status Polling ───────────────────────────────────────────────────────
111
111
 
112
112
  async function pollPrStatus(config) {
113
- const e = engine();
114
113
  const token = getAdoToken();
115
114
  if (!token) {
116
- e.log('warn', 'Skipping PR status poll — no ADO token available');
115
+ log('warn', 'Skipping PR status poll — no ADO token available');
117
116
  return;
118
117
  }
119
118
 
@@ -129,14 +128,14 @@ async function pollPrStatus(config) {
129
128
  else if (prData.status === 'active') newStatus = 'active';
130
129
 
131
130
  if (pr.status !== newStatus) {
132
- e.log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
131
+ log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
133
132
  pr.status = newStatus;
134
133
  updated = true;
135
134
 
136
135
  if (newStatus === 'merged' || newStatus === 'abandoned') {
137
136
  if (pr.reviewStatus === 'waiting') {
138
137
  pr.reviewStatus = newStatus === 'merged' ? 'approved' : 'pending';
139
- e.log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
138
+ log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
140
139
  }
141
140
  await engine().handlePostMerge(pr, project, config, newStatus);
142
141
  }
@@ -153,7 +152,7 @@ async function pollPrStatus(config) {
153
152
  }
154
153
 
155
154
  if (pr.reviewStatus !== newReviewStatus) {
156
- e.log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
155
+ log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
157
156
  pr.reviewStatus = newReviewStatus;
158
157
  updated = true;
159
158
  // Update author metrics when verdict changes to approved/rejected
@@ -167,7 +166,7 @@ async function pollPrStatus(config) {
167
166
  if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
168
167
  else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
169
168
  shared.safeWrite(metricsPath, metrics);
170
- } catch (err) { try { engine().log('warn', `Metrics update: ${err.message}`); } catch { /* engine not available */ } }
169
+ } catch (err) { log('warn', `Metrics update: ${err.message}`); }
171
170
  }
172
171
  }
173
172
  }
@@ -209,7 +208,7 @@ async function pollPrStatus(config) {
209
208
  }
210
209
 
211
210
  if (pr.buildStatus !== buildStatus) {
212
- e.log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
211
+ log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
213
212
  pr.buildStatus = buildStatus;
214
213
  if (buildFailReason) pr.buildFailReason = buildFailReason;
215
214
  else delete pr.buildFailReason;
@@ -221,14 +220,13 @@ async function pollPrStatus(config) {
221
220
  });
222
221
 
223
222
  if (totalUpdated > 0) {
224
- e.log('info', `PR status poll: updated ${totalUpdated} PR(s)`);
223
+ log('info', `PR status poll: updated ${totalUpdated} PR(s)`);
225
224
  }
226
225
  }
227
226
 
228
227
  // ─── Poll Human Comments on PRs ──────────────────────────────────────────────
229
228
 
230
229
  async function pollPrHumanComments(config) {
231
- const e = engine();
232
230
  const token = getAdoToken();
233
231
  if (!token) return;
234
232
 
@@ -285,12 +283,12 @@ async function pollPrHumanComments(config) {
285
283
  feedbackContent
286
284
  };
287
285
 
288
- e.log('info', `PR ${pr.id}: ${newHumanComments.length} new comment(s), ${allHumanComments.length} total — full thread context provided`);
286
+ log('info', `PR ${pr.id}: ${newHumanComments.length} new comment(s), ${allHumanComments.length} total — full thread context provided`);
289
287
  return true;
290
288
  });
291
289
 
292
290
  if (totalUpdated > 0) {
293
- e.log('info', `PR comment poll: found human feedback on ${totalUpdated} PR(s)`);
291
+ log('info', `PR comment poll: found human feedback on ${totalUpdated} PR(s)`);
294
292
  }
295
293
  }
296
294
 
@@ -301,10 +299,9 @@ async function pollPrHumanComments(config) {
301
299
  * in pull-requests.json, and add them. Matches PRs to work items by branch name.
302
300
  */
303
301
  async function reconcilePrs(config) {
304
- const e = engine();
305
302
  const token = getAdoToken();
306
303
  if (!token) {
307
- e.log('warn', 'Skipping PR reconciliation — no ADO token available');
304
+ log('warn', 'Skipping PR reconciliation — no ADO token available');
308
305
  return;
309
306
  }
310
307
 
@@ -322,7 +319,7 @@ async function reconcilePrs(config) {
322
319
  try {
323
320
  prData = await adoFetch(url, token);
324
321
  } catch (err) {
325
- e.log('warn', `PR reconciliation failed for ${project.name}: ${err.message}`);
322
+ log('warn', `PR reconciliation failed for ${project.name}: ${err.message}`);
326
323
  continue;
327
324
  }
328
325
 
@@ -379,14 +376,14 @@ async function reconcilePrs(config) {
379
376
  branch,
380
377
  reviewStatus: 'pending',
381
378
  status: 'active',
382
- created: (adoPr.creationDate || '').slice(0, 10) || e.dateStamp(),
379
+ created: (adoPr.creationDate || '').slice(0, 10) || dateStamp(),
383
380
  url: prUrl,
384
381
  prdItems: confirmedItemId ? [confirmedItemId] : [],
385
382
  });
386
383
  if (confirmedItemId) addPrLink(prId, confirmedItemId);
387
384
  existingIds.add(prId);
388
385
  projectAdded++;
389
- e.log('info', `PR reconciliation: added ${prId} (branch: ${branch}${confirmedItemId ? ', linked to ' + confirmedItemId : ''}) to ${project.name}`);
386
+ log('info', `PR reconciliation: added ${prId} (branch: ${branch}${confirmedItemId ? ', linked to ' + confirmedItemId : ''}) to ${project.name}`);
390
387
  }
391
388
 
392
389
  // Backfill prdItems from pr-links for any PR with empty array
@@ -404,12 +401,12 @@ async function reconcilePrs(config) {
404
401
  if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0) {
405
402
  shared.safeWrite(prPath, existingPrs);
406
403
  totalAdded += projectAdded;
407
- if (projectUpdated > 0) e.log('info', `PR reconciliation: linked ${projectUpdated} existing PR(s) to PRD items in ${project.name}`);
404
+ if (projectUpdated > 0) log('info', `PR reconciliation: linked ${projectUpdated} existing PR(s) to PRD items in ${project.name}`);
408
405
  }
409
406
  }
410
407
 
411
408
  if (totalAdded > 0) {
412
- e.log('info', `PR reconciliation: added ${totalAdded} missing PR(s) across projects`);
409
+ log('info', `PR reconciliation: added ${totalAdded} missing PR(s) across projects`);
413
410
  }
414
411
  }
415
412
 
package/engine/cleanup.js CHANGED
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
- const { exec, execSilent } = shared;
11
+ const { exec, execSilent, log, ts } = shared;
12
12
  const { safeJson, safeWrite, safeReadDir, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
@@ -20,10 +20,9 @@ const PRD_DIR = queries.PRD_DIR;
20
20
  const PLANS_DIR = queries.PLANS_DIR;
21
21
 
22
22
  // Lazy require to break circular dependency with engine.js
23
+ // Only needed for engine().activeProcesses — log/ts come from shared.js
23
24
  let _engine = null;
24
25
  function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
25
- function log(level, msg, meta) { return engine().log(level, msg, meta); }
26
- function ts() { return engine().ts(); }
27
26
 
28
27
  // Lazy require for dispatch module
29
28
  let _dispatch = null;
@@ -7,13 +7,9 @@ const path = require('path');
7
7
  const shared = require('./shared');
8
8
  const queries = require('./queries');
9
9
 
10
- const { safeJson, safeWrite } = shared;
10
+ const { safeJson, safeWrite, log } = shared;
11
11
  const { ENGINE_DIR } = queries;
12
12
 
13
- // Lazy require to avoid circular dependency with engine.js
14
- let _engine = null;
15
- function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
16
-
17
13
  const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
18
14
  const dispatchCooldowns = new Map(); // key → { timestamp, failures }
19
15
 
@@ -27,7 +23,7 @@ function loadCooldowns() {
27
23
  dispatchCooldowns.set(k, v);
28
24
  }
29
25
  }
30
- engine().log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
26
+ log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
31
27
  }
32
28
 
33
29
  let _cooldownWriteTimer = null;
@@ -85,7 +81,7 @@ function setCooldownFailure(key) {
85
81
  const failures = (existing?.failures || 0) + 1;
86
82
  dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
87
83
  if (failures >= 3) {
88
- engine().log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 8)}x`);
84
+ log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 8)}x`);
89
85
  }
90
86
  saveCooldowns();
91
87
  }
package/engine/github.js CHANGED
@@ -5,11 +5,11 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, MINIONS_DIR, addPrLink, getPrLinks } = shared;
8
+ const { exec, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, MINIONS_DIR, addPrLink, getPrLinks, log, dateStamp } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const path = require('path');
11
11
 
12
- // Lazy require to avoid circular dependency
12
+ // Lazy require to avoid circular dependency — only needed for engine().handlePostMerge
13
13
  let _engine = null;
14
14
  function engine() {
15
15
  if (!_engine) _engine = require('../engine');
@@ -37,7 +37,7 @@ function ghApi(endpoint, slug) {
37
37
  const result = exec(cmd, { timeout: 30000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
38
38
  return JSON.parse(result);
39
39
  } catch (e) {
40
- engine().log('warn', `GitHub API error (${endpoint}): ${e.message}`);
40
+ log('warn', `GitHub API error (${endpoint}): ${e.message}`);
41
41
  return null;
42
42
  }
43
43
  }
@@ -66,7 +66,7 @@ async function forEachActiveGhPr(config, callback) {
66
66
  const updated = await callback(project, pr, prNum, slug);
67
67
  if (updated) projectUpdated++;
68
68
  } catch (err) {
69
- try { engine().log('warn', `GitHub: failed to poll PR ${pr.id}: ${err.message}`); } catch { /* engine not available */ }
69
+ log('warn', `GitHub: failed to poll PR ${pr.id}: ${err.message}`);
70
70
  }
71
71
  }
72
72
 
@@ -101,7 +101,7 @@ async function forEachActiveGhPr(config, callback) {
101
101
  centralUpdated++;
102
102
  }
103
103
  } catch (err) {
104
- try { engine().log('warn', `GitHub: failed to poll central PR ${pr.id}: ${err.message}`); } catch { /* engine not available */ }
104
+ log('warn', `GitHub: failed to poll central PR ${pr.id}: ${err.message}`);
105
105
  }
106
106
  }
107
107
  if (centralUpdated > 0) {
@@ -115,8 +115,6 @@ async function forEachActiveGhPr(config, callback) {
115
115
  // ─── PR Status Polling ──────────────────────────────────────────────────────
116
116
 
117
117
  async function pollPrStatus(config) {
118
- const e = engine();
119
-
120
118
  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
121
119
  const prData = ghApi(`/pulls/${prNum}`, slug);
122
120
  if (!prData) return false;
@@ -130,7 +128,7 @@ async function pollPrStatus(config) {
130
128
  else if (prData.state === 'open') newStatus = 'active';
131
129
 
132
130
  if (pr.status !== newStatus) {
133
- e.log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
131
+ log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
134
132
  pr.status = newStatus;
135
133
  updated = true;
136
134
 
@@ -138,7 +136,7 @@ async function pollPrStatus(config) {
138
136
  // Resolve stale 'waiting' review status — won't be polled again after this
139
137
  if (pr.reviewStatus === 'waiting') {
140
138
  pr.reviewStatus = newStatus === 'merged' ? 'approved' : 'pending';
141
- e.log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
139
+ log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
142
140
  }
143
141
  await engine().handlePostMerge(pr, project, config, newStatus);
144
142
  }
@@ -162,7 +160,7 @@ async function pollPrStatus(config) {
162
160
  else if (states.length > 0) newReviewStatus = 'pending';
163
161
 
164
162
  if (pr.reviewStatus !== newReviewStatus) {
165
- e.log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
163
+ log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
166
164
  pr.reviewStatus = newReviewStatus;
167
165
  updated = true;
168
166
  // Update author metrics when verdict changes to approved/rejected
@@ -176,7 +174,7 @@ async function pollPrStatus(config) {
176
174
  if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
177
175
  else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
178
176
  shared.safeWrite(metricsPath, metrics);
179
- } catch (err) { try { engine().log('warn', `Metrics update: ${err.message}`); } catch { /* engine not available */ } }
177
+ } catch (err) { log('warn', `Metrics update: ${err.message}`); }
180
178
  }
181
179
  }
182
180
  }
@@ -207,7 +205,7 @@ async function pollPrStatus(config) {
207
205
  }
208
206
 
209
207
  if (pr.buildStatus !== buildStatus) {
210
- e.log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
208
+ log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
211
209
  pr.buildStatus = buildStatus;
212
210
  if (buildFailReason) pr.buildFailReason = buildFailReason;
213
211
  else delete pr.buildFailReason;
@@ -221,15 +219,13 @@ async function pollPrStatus(config) {
221
219
  });
222
220
 
223
221
  if (totalUpdated > 0) {
224
- e.log('info', `GitHub PR status poll: updated ${totalUpdated} PR(s)`);
222
+ log('info', `GitHub PR status poll: updated ${totalUpdated} PR(s)`);
225
223
  }
226
224
  }
227
225
 
228
226
  // ─── Poll Human Comments on PRs ─────────────────────────────────────────────
229
227
 
230
228
  async function pollPrHumanComments(config) {
231
- const e = engine();
232
-
233
229
  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
234
230
  // Get issue comments (general PR comments)
235
231
  const comments = ghApi(`/issues/${prNum}/comments`, slug);
@@ -292,19 +288,18 @@ async function pollPrHumanComments(config) {
292
288
  feedbackContent
293
289
  };
294
290
 
295
- e.log('info', `PR ${pr.id}: ${newComments.length} new comment(s), ${allCommentEntries.length} total — full thread context provided`);
291
+ log('info', `PR ${pr.id}: ${newComments.length} new comment(s), ${allCommentEntries.length} total — full thread context provided`);
296
292
  return true;
297
293
  });
298
294
 
299
295
  if (totalUpdated > 0) {
300
- e.log('info', `GitHub PR comment poll: found human feedback on ${totalUpdated} PR(s)`);
296
+ log('info', `GitHub PR comment poll: found human feedback on ${totalUpdated} PR(s)`);
301
297
  }
302
298
  }
303
299
 
304
300
  // ─── PR Reconciliation ──────────────────────────────────────────────────────
305
301
 
306
302
  async function reconcilePrs(config) {
307
- const e = engine();
308
303
  const projects = getProjects(config).filter(isGitHub);
309
304
  const branchPatterns = [/^work\//i, /^feat\//i, /^user\/yemishin\//i];
310
305
  let totalAdded = 0;
@@ -365,7 +360,7 @@ async function reconcilePrs(config) {
365
360
  branch,
366
361
  reviewStatus: 'pending',
367
362
  status: 'active',
368
- created: (ghPr.created_at || '').slice(0, 10) || e.dateStamp(),
363
+ created: (ghPr.created_at || '').slice(0, 10) || dateStamp(),
369
364
  url: prUrl,
370
365
  prdItems: confirmedItemId ? [confirmedItemId] : [],
371
366
  });
@@ -373,7 +368,7 @@ async function reconcilePrs(config) {
373
368
  existingIds.add(prId);
374
369
  projectAdded++;
375
370
 
376
- e.log('info', `GitHub PR reconciliation: added ${prId} (branch: ${branch}${confirmedItemId ? ', linked to ' + confirmedItemId : ''}) to ${project.name}`);
371
+ log('info', `GitHub PR reconciliation: added ${prId} (branch: ${branch}${confirmedItemId ? ', linked to ' + confirmedItemId : ''}) to ${project.name}`);
377
372
  }
378
373
 
379
374
  // Backfill prdItems from pr-links for any PR with empty array
@@ -395,7 +390,7 @@ async function reconcilePrs(config) {
395
390
  }
396
391
 
397
392
  if (totalAdded > 0) {
398
- e.log('info', `GitHub PR reconciliation: added ${totalAdded} missing PR(s)`);
393
+ log('info', `GitHub PR reconciliation: added ${totalAdded} missing PR(s)`);
399
394
  }
400
395
  }
401
396
 
@@ -932,6 +932,30 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config) {
932
932
  if (wrote) log('info', `Created review feedback for ${authorAgentId} from ${reviewerAgentId} on ${pr.id}`);
933
933
  }
934
934
 
935
+ function recordContextPressureOnWorkItem(meta, turnCount, outputLogSizeBytes, hitTurnLimit) {
936
+ const itemId = meta.item?.id;
937
+ if (!itemId) return;
938
+
939
+ let wiPath;
940
+ if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
941
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
942
+ } else if (meta.source === 'work-item' && meta.project?.name) {
943
+ wiPath = path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json');
944
+ }
945
+ if (!wiPath) return;
946
+
947
+ shared.mutateJsonFileLocked(wiPath, (items) => {
948
+ if (!Array.isArray(items)) return items;
949
+ const target = items.find(i => i.id === itemId);
950
+ if (target) {
951
+ target._turnCount = turnCount;
952
+ target._outputLogSizeBytes = outputLogSizeBytes;
953
+ target._hitTurnLimit = hitTurnLimit;
954
+ }
955
+ return items;
956
+ }, { defaultValue: [] });
957
+ }
958
+
935
959
  function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount, model) {
936
960
 
937
961
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
@@ -977,6 +1001,20 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
977
1001
  for (const day of Object.keys(metrics._daily)) {
978
1002
  if (day < cutoffStr) delete metrics._daily[day];
979
1003
  }
1004
+
1005
+ // Update contextPressure aggregate
1006
+ if (taskUsage && taskUsage.numTurns > 0) {
1007
+ if (!metrics._contextPressure) metrics._contextPressure = { totalTurns: 0, dispatches: 0, maxTurns: 0, turnLimitHits: 0 };
1008
+ const cp = metrics._contextPressure;
1009
+ cp.totalTurns += taskUsage.numTurns;
1010
+ cp.dispatches++;
1011
+ if (taskUsage.numTurns > cp.maxTurns) cp.maxTurns = taskUsage.numTurns;
1012
+ // Check if this dispatch hit the turn limit
1013
+ const engineConfig = require('./queries').getConfig()?.engine || {};
1014
+ const turnLimit = engineConfig.maxTurns || 100;
1015
+ if (taskUsage.numTurns >= turnLimit) cp.turnLimitHits++;
1016
+ }
1017
+
980
1018
  shared.safeWrite(metricsPath, metrics);
981
1019
  }
982
1020
 
@@ -1089,6 +1127,23 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1089
1127
  // If decomposition produced nothing, fall through to mark parent as done
1090
1128
  }
1091
1129
 
1130
+ // Record context utilization metrics on the work item
1131
+ if (meta?.item?.id) {
1132
+ try {
1133
+ const engineConfig = (config.engine || {});
1134
+ const turnLimit = engineConfig.maxTurns || 100;
1135
+ const turnCount = taskUsage?.numTurns || 0;
1136
+ const hitTurnLimit = turnCount >= turnLimit;
1137
+ let outputLogSizeBytes = 0;
1138
+ try {
1139
+ const liveLogPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
1140
+ const stat = fs.statSync(liveLogPath);
1141
+ outputLogSizeBytes = stat.size;
1142
+ } catch { /* file may not exist */ }
1143
+ recordContextPressureOnWorkItem(meta, turnCount, outputLogSizeBytes, hitTurnLimit);
1144
+ } catch (err) { log('warn', `Context pressure record: ${err.message}`); }
1145
+ }
1146
+
1092
1147
  if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, 'done', '');
1093
1148
  if (!isSuccess && meta?.item?.id) {
1094
1149
  // Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
@@ -1289,6 +1344,7 @@ module.exports = {
1289
1344
  updateAgentHistory,
1290
1345
  createReviewFeedbackForAuthor,
1291
1346
  updateMetrics,
1347
+ recordContextPressureOnWorkItem,
1292
1348
  parseAgentOutput,
1293
1349
  runPostCompletionHooks,
1294
1350
  syncPrdFromPrs,
package/engine/meeting.js CHANGED
@@ -14,8 +14,7 @@ const { renderPlaybook } = require('./playbook');
14
14
  /** Patterns that indicate an agent returned no meaningful output */
15
15
  const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
16
16
 
17
- let _engine = null;
18
- function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
17
+ // No lazy require needed — log comes from shared.js, no engine-specific APIs used
19
18
 
20
19
  const MEETINGS_DIR = path.join(__dirname, '..', 'meetings');
21
20
 
@@ -79,8 +78,12 @@ function discoverMeetingWork(config) {
79
78
  const agents = config.agents || {};
80
79
 
81
80
  if (roundName === 'concluding') {
82
- // Only one agent concludes (first participant)
83
- const concluder = meeting.participants[0];
81
+ // Pick the first non-busy participant as concluder (fallback to any participant)
82
+ const busyAgents = new Set(
83
+ (dispatch.active || []).map(d => d.agent).filter(Boolean)
84
+ );
85
+ const concluder = meeting.participants.find(p => !busyAgents.has(p))
86
+ || meeting.participants[0];
84
87
  if (!concluder) continue;
85
88
  const key = `meeting-${meeting.id}-r${round}-${concluder}`;
86
89
  if (activeKeys.has(key)) continue;
@@ -179,7 +182,6 @@ function discoverMeetingWork(config) {
179
182
  * Called from runPostCompletionHooks when type === 'meeting'.
180
183
  */
181
184
  function collectMeetingFindings(meetingId, agentId, roundName, output) {
182
- const e = engine();
183
185
  const meeting = getMeeting(meetingId);
184
186
  if (!meeting) return;
185
187
 
@@ -188,7 +190,7 @@ function collectMeetingFindings(meetingId, agentId, roundName, output) {
188
190
 
189
191
  // Validate output — reject empty or placeholder responses
190
192
  if (!rawContent || EMPTY_OUTPUT_PATTERNS.includes(rawContent)) {
191
- e.log('warn', `Meeting ${meetingId}: agent ${agentId} returned empty output for ${roundName} — rejecting`);
193
+ log('warn', `Meeting ${meetingId}: agent ${agentId} returned empty output for ${roundName} — rejecting`);
192
194
  // Don't record it — agent will be re-dispatched on next tick
193
195
  saveMeeting(meeting);
194
196
  return;
@@ -304,7 +306,6 @@ function deleteMeeting(id) {
304
306
  * Called from engine.js tick cycle.
305
307
  */
306
308
  function checkMeetingTimeouts(config) {
307
- const e = engine();
308
309
  const meetings = getMeetings();
309
310
  const timeout = (config.engine || {}).meetingRoundTimeout
310
311
  || ENGINE_DEFAULTS.meetingRoundTimeout;
@@ -324,21 +325,21 @@ function checkMeetingTimeouts(config) {
324
325
  const totalCount = meeting.participants.length;
325
326
 
326
327
  if (meeting.status === 'investigating') {
327
- e.log('warn', `Meeting ${meeting.id}: round 1 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to debate`);
328
+ log('warn', `Meeting ${meeting.id}: round 1 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to debate`);
328
329
  meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 1 timed out — ${respondedCount}/${totalCount} findings received`, at: new Date().toISOString() });
329
330
  meeting.status = 'debating';
330
331
  meeting.round = 2;
331
332
  meeting.roundStartedAt = new Date().toISOString();
332
333
  saveMeeting(meeting);
333
334
  } else if (meeting.status === 'debating') {
334
- e.log('warn', `Meeting ${meeting.id}: round 2 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to conclusion`);
335
+ log('warn', `Meeting ${meeting.id}: round 2 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to conclusion`);
335
336
  meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 2 timed out — ${respondedCount}/${totalCount} debate responses received`, at: new Date().toISOString() });
336
337
  meeting.status = 'concluding';
337
338
  meeting.round = 3;
338
339
  meeting.roundStartedAt = new Date().toISOString();
339
340
  saveMeeting(meeting);
340
341
  } else if (meeting.status === 'concluding') {
341
- e.log('warn', `Meeting ${meeting.id}: conclusion round timed out after ${Math.round(elapsed / 60000)}min — ending meeting without conclusion`);
342
+ log('warn', `Meeting ${meeting.id}: conclusion round timed out after ${Math.round(elapsed / 60000)}min — ending meeting without conclusion`);
342
343
  meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: 'Conclusion round timed out — meeting ended without conclusion', at: new Date().toISOString() });
343
344
  meeting.status = 'completed';
344
345
  meeting.completedAt = new Date().toISOString();
@@ -206,9 +206,25 @@ function resolveTaskContext(item, config) {
206
206
  return resolved;
207
207
  }
208
208
 
209
+ // ─── Critical Variable Definitions ─────────────────────────────────────────
210
+ // Variables that MUST resolve to non-empty values for dispatch to proceed.
211
+ // If any critical variable is empty or unresolved, renderPlaybook returns null.
212
+ const CRITICAL_VARS = {
213
+ 'implement': ['task_description', 'branch_name'],
214
+ 'implement-shared': ['task_description', 'branch_name'],
215
+ 'fix': ['task_description', 'branch_name'],
216
+ 'work-item': ['task_description'],
217
+ };
218
+
219
+ // Module-level error state — callers check via getLastRenderError() after null return
220
+ let _lastRenderError = null;
221
+
222
+ function getLastRenderError() { return _lastRenderError; }
223
+
209
224
  // ─── Playbook Renderer ──────────────────────────────────────────────────────
210
225
 
211
226
  function renderPlaybook(type, vars) {
227
+ _lastRenderError = null;
212
228
  const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
213
229
  let content;
214
230
  try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
@@ -290,15 +306,27 @@ function renderPlaybook(type, vars) {
290
306
  .filter(([, val]) => String(val) === '')
291
307
  .map(([key]) => key);
292
308
  if (emptyVars.length > 0) {
293
- const msg = `Playbook "${type}": template variables resolved to empty string: ${emptyVars.join(', ')}`;
294
- try { engine().log('warn', msg); } catch { /* engine not ready */ }
309
+ log('warn', `Playbook "${type}": template variables resolved to empty string: ${emptyVars.join(', ')}`);
295
310
  }
296
311
 
297
312
  // Warn on any remaining unresolved {{variable}} placeholders
298
313
  const unresolved = [...new Set((content.match(/\{\{(\w+)\}\}/g) || []).map(m => m.slice(2, -2)))];
299
314
  if (unresolved.length > 0) {
300
- const msg = `Playbook "${type}": unresolved template variables: ${unresolved.join(', ')}`;
301
- try { engine().log('warn', msg); } catch { /* engine not ready */ }
315
+ log('warn', `Playbook "${type}": unresolved template variables: ${unresolved.join(', ')}`);
316
+ }
317
+
318
+ // Block dispatch if critical variables are empty or unresolved
319
+ const criticalVars = CRITICAL_VARS[type] || [];
320
+ if (criticalVars.length > 0) {
321
+ const emptySet = new Set(emptyVars);
322
+ const unresolvedSet = new Set(unresolved);
323
+ const criticalMissing = criticalVars.filter(v => emptySet.has(v) || unresolvedSet.has(v));
324
+ if (criticalMissing.length > 0) {
325
+ const msg = `Playbook "${type}": critical variables empty or unresolved: ${criticalMissing.join(', ')} — blocking dispatch`;
326
+ log('warn', msg);
327
+ _lastRenderError = { reason: 'critical_vars_missing', vars: criticalMissing, message: msg };
328
+ return null;
329
+ }
302
330
  }
303
331
 
304
332
  return content;
@@ -472,6 +500,8 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
472
500
 
473
501
  module.exports = {
474
502
  renderPlaybook,
503
+ getLastRenderError,
504
+ CRITICAL_VARS,
475
505
  buildSystemPrompt,
476
506
  buildAgentContext,
477
507
  selectPlaybook,
package/engine/routing.js CHANGED
@@ -8,16 +8,12 @@ const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
- const { safeJson, safeRead } = shared;
11
+ const { safeJson, safeRead, log, ts } = shared;
12
12
  const { ENGINE_DIR, DISPATCH_PATH } = queries;
13
13
 
14
14
  const MINIONS_DIR = path.resolve(__dirname, '..');
15
15
  const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
16
16
 
17
- // Lazy require to avoid circular dependency with engine.js
18
- let _engine = null;
19
- function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
20
-
21
17
  // ─── Temp Agents ─────────────────────────────────────────────────────────────
22
18
 
23
19
  const tempAgents = new Map(); // tempAgentId → { name, role, createdAt }
@@ -140,8 +136,8 @@ function resolveAgent(workType, config, authorAgent = null) {
140
136
  if (config.engine?.allowTempAgents) {
141
137
  const tempId = `temp-${shared.uid()}`;
142
138
  _claimedAgents.add(tempId);
143
- tempAgents.set(tempId, { name: `Temp-${tempId.slice(5, 9)}`, role: 'Temporary Agent', createdAt: engine().ts() });
144
- engine().log('info', `Spawning temp agent ${tempId} — all permanent agents busy`);
139
+ tempAgents.set(tempId, { name: `Temp-${tempId.slice(5, 9)}`, role: 'Temporary Agent', createdAt: ts() });
140
+ log('info', `Spawning temp agent ${tempId} — all permanent agents busy`);
145
141
  return tempId;
146
142
  }
147
143
 
package/engine/timeout.js CHANGED
@@ -8,16 +8,15 @@ const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
- const { safeRead, safeWrite, safeJson, getProjects, projectWorkItemsPath, ENGINE_DEFAULTS: DEFAULTS } = shared;
11
+ const { safeRead, safeWrite, safeJson, getProjects, projectWorkItemsPath, log, ts, ENGINE_DEFAULTS: DEFAULTS } = shared;
12
12
  const { getDispatch, getAgentStatus } = queries;
13
13
  const AGENTS_DIR = queries.AGENTS_DIR;
14
14
  const MINIONS_DIR = shared.MINIONS_DIR;
15
15
 
16
16
  // Lazy require to break circular dependency with engine.js
17
+ // Only needed for engine().activeProcesses and engine().engineRestartGraceUntil — log/ts come from shared.js
17
18
  let _engine = null;
18
19
  function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
19
- function log(level, msg, meta) { return engine().log(level, msg, meta); }
20
- function ts() { return engine().ts(); }
21
20
 
22
21
  // Lazy require for dispatch module (also circular via engine)
23
22
  let _dispatch = null;
package/engine.js CHANGED
@@ -120,7 +120,7 @@ const { getRouting, parseRoutingTable, getRoutingTableCached, getMonthlySpend,
120
120
 
121
121
  // ─── Playbook, system prompt, agent context (extracted to engine/playbook.js) ─
122
122
 
123
- const { renderPlaybook, buildSystemPrompt, buildAgentContext, selectPlaybook,
123
+ const { renderPlaybook, getLastRenderError, buildSystemPrompt, buildAgentContext, selectPlaybook,
124
124
  buildBaseVars, buildPrDispatch, resolveTaskContext,
125
125
  getRepoHostLabel, getRepoHostToolRule } = require('./engine/playbook');
126
126
 
@@ -1493,9 +1493,17 @@ function discoverFromWorkItems(config, project) {
1493
1493
  if (playbookName === 'work-item' && workType === 'review') {
1494
1494
  log('info', `Work item ${item.id} is type "review" but has no PR — using work-item playbook`);
1495
1495
  }
1496
- const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
1496
+ const rendered = renderPlaybook(playbookName, vars);
1497
+ const renderError = getLastRenderError();
1498
+ // If critical vars are missing, block dispatch entirely — don't fall through to work-item playbook
1499
+ const prompt = item.prompt || rendered || (renderError ? null : (renderPlaybook('work-item', vars) || item.description));
1497
1500
  if (!prompt) {
1498
- log('warn', `No playbook rendered for ${item.id} (type: ${workType}, playbook: ${playbookName}) — skipping`);
1501
+ if (renderError) {
1502
+ log('warn', `Skipping ${item.id}: ${renderError.message}`);
1503
+ if (item._pendingReason !== 'critical_vars_missing') { item._pendingReason = 'critical_vars_missing'; needsWrite = true; }
1504
+ } else {
1505
+ log('warn', `No playbook rendered for ${item.id} (type: ${workType}, playbook: ${playbookName}) — skipping`);
1506
+ }
1499
1507
  continue;
1500
1508
  }
1501
1509
 
@@ -1782,9 +1790,16 @@ function discoverCentralWorkItems(config) {
1782
1790
  }
1783
1791
 
1784
1792
  const playbookName = selectPlaybook(workType, item);
1785
- const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
1793
+ const rendered = renderPlaybook(playbookName, vars);
1794
+ const renderError = getLastRenderError();
1795
+ const prompt = rendered || (renderError ? null : renderPlaybook('work-item', vars));
1786
1796
  if (!prompt) {
1787
- log('warn', `Fan-out: playbook '${playbookName}' failed to render for ${item.id} → ${agent.id}, skipping`);
1797
+ if (renderError) {
1798
+ log('warn', `Fan-out: ${item.id} → ${agent.id}: ${renderError.message}`);
1799
+ if (item._pendingReason !== 'critical_vars_missing') { item._pendingReason = 'critical_vars_missing'; }
1800
+ } else {
1801
+ log('warn', `Fan-out: playbook '${playbookName}' failed to render for ${item.id} → ${agent.id}, skipping`);
1802
+ }
1788
1803
  continue;
1789
1804
  }
1790
1805
 
@@ -1894,9 +1909,16 @@ function discoverCentralWorkItems(config) {
1894
1909
  }
1895
1910
 
1896
1911
  const playbookName = selectPlaybook(workType, item);
1897
- const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
1912
+ const rendered = renderPlaybook(playbookName, vars);
1913
+ const renderError = getLastRenderError();
1914
+ const prompt = rendered || (renderError ? null : renderPlaybook('work-item', vars));
1898
1915
  if (!prompt) {
1899
- log('warn', `Dispatch: playbook '${playbookName}' failed to render for ${item.id}, resetting to pending`);
1916
+ if (renderError) {
1917
+ log('warn', `Dispatch: ${item.id}: ${renderError.message}`);
1918
+ item._pendingReason = 'critical_vars_missing';
1919
+ } else {
1920
+ log('warn', `Dispatch: playbook '${playbookName}' failed to render for ${item.id}, resetting to pending`);
1921
+ }
1900
1922
  item.status = 'pending';
1901
1923
  continue;
1902
1924
  }
@@ -2378,6 +2400,7 @@ module.exports = {
2378
2400
 
2379
2401
  // Playbooks
2380
2402
  renderPlaybook,
2403
+ getLastRenderError,
2381
2404
 
2382
2405
  // Timeout / Steering / Idle (re-exported from engine/timeout.js)
2383
2406
  checkTimeouts, checkSteering, checkIdleThreshold,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.143",
3
+ "version": "0.1.145",
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"