@yemi33/minions 0.1.1957 → 0.1.1959

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.
@@ -380,6 +380,54 @@ function buildKeepProcessesHint(opts) {
380
380
  '',
381
381
  'If you do NOT write the file, the engine will kill ALL of your descendant processes when you exit (today\'s default). Do not write the file unless you are intentionally leaving a process behind for the human or follow-up work.',
382
382
  '',
383
+ '### How to spawn long-running processes (CRITICAL on Windows)',
384
+ '',
385
+ 'On Windows, child processes spawned with stdio inherited or piped from your agent process die SECONDS after you exit, because the next time they write to stdout the pipe is closed and they get EPIPE. Vite, bun run dev, file watchers, and HMR all log on every event, so they crash within seconds.',
386
+ '',
387
+ 'Use this PowerShell pattern -- it gives the child its own log file descriptor (not a pipe) so it survives your exit:',
388
+ '',
389
+ '```powershell',
390
+ '# Adjust $log, $cmd, $args, $cwd for your target.',
391
+ '$log = "D:\\logs\\<service-name>.log"',
392
+ 'New-Item -Path (Split-Path $log) -ItemType Directory -Force | Out-Null',
393
+ '$proc = Start-Process -FilePath "bun" -ArgumentList "run","dev" `',
394
+ ' -WorkingDirectory "<absolute-cwd>" `',
395
+ ' -RedirectStandardOutput $log -RedirectStandardError $log `',
396
+ ' -WindowStyle Hidden -PassThru',
397
+ 'Write-Host "spawned PID $($proc.Id) -> $log"',
398
+ '```',
399
+ '',
400
+ 'For Node.js / bun spawn() callers, use:',
401
+ '',
402
+ '```js',
403
+ 'const fs = require(\'fs\');',
404
+ 'const { spawn } = require(\'child_process\');',
405
+ 'const logFd = fs.openSync(\'D:/logs/<service-name>.log\', \'a\');',
406
+ 'const proc = spawn(\'bun\', [\'run\', \'dev\'], {',
407
+ ' cwd: \'<absolute-cwd>\',',
408
+ ' detached: true,',
409
+ ' stdio: [\'ignore\', logFd, logFd], // file fds, NOT pipes',
410
+ ' windowsHide: true,',
411
+ '});',
412
+ 'proc.unref();',
413
+ 'console.log(\'spawned PID\', proc.pid);',
414
+ '```',
415
+ '',
416
+ 'Key requirements (DO NOT skip any):',
417
+ '1. **stdio MUST be a file descriptor** (`-RedirectStandardOutput` in PowerShell, or `fs.openSync` + numeric fd in Node). NEVER `inherit`, `pipe`, or omit.',
418
+ '2. **`-WindowStyle Hidden`** in PowerShell, **`windowsHide: true`** in Node -- keeps the child off your desktop.',
419
+ '3. **`detached: true` + `proc.unref()`** in Node so the parent can exit without taking the child down.',
420
+ '4. **Capture the spawned PID immediately** -- that\'s what goes into `keep-pids.json`.',
421
+ '5. **Verify it actually bound the port before you exit.** Run `Get-NetTCPConnection -LocalPort <port> -State Listen` (PowerShell) or `netstat -an | findstr :<port>` and confirm the port is listening. If not, read the log file (`Get-Content $log -Tail 100`) to diagnose. Don\'t exit claiming success if the port isn\'t listening.',
422
+ '',
423
+ 'What NOT to do:',
424
+ '- `Start-Process bun run dev` (no `-RedirectStandardOutput` -> child inherits your console handle -> dies on exit).',
425
+ '- `bun run dev &` (PowerShell `&` is the call operator, not background -- runs synchronously).',
426
+ '- `Start-Job { bun run dev }` (PS jobs die when the host exits, which is you).',
427
+ '- `child_process.spawn(...)` with default stdio (`pipe`) -- same EPIPE problem.',
428
+ '',
429
+ 'If you hit a problem the recipe doesn\'t cover (e.g. Vite needs `VITE_HOST=127.0.0.1` because it\'s defaulting to `::1` and not binding), fix the env in the spawn call (`-Environment` in PowerShell or `env:` in Node spawn opts), don\'t hack around the spawn pattern itself.',
430
+ '',
383
431
  'After you write the file, the engine\'s sweep removes it automatically when its TTL fires (it kills the kept PIDs at that point) or when all declared PIDs are already dead. Humans can also kill any kept PID early from the dashboard.',
384
432
  '',
385
433
  '## Verify before exit (REQUIRED)',
package/engine/routing.js CHANGED
@@ -190,7 +190,7 @@ function normalizeAgentHints(agentHints, authorAgent = null, agents = null) {
190
190
  }
191
191
 
192
192
  function resolveAgent(workType, config, opts = {}) {
193
- const { authorAgent = null, agentHints = null, dryRun = false } = opts || {};
193
+ const { authorAgent = null, agentHints = null, dryRun = false, excludeAgent = null } = opts || {};
194
194
  const route = routeForWorkType(workType);
195
195
  const agents = config.agents || {};
196
196
 
@@ -198,7 +198,18 @@ function resolveAgent(workType, config, opts = {}) {
198
198
  let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
199
199
  let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
200
200
 
201
+ // Self-review ban (W-mp7jl5w3001e8c7e): when caller passes excludeAgent, that
202
+ // agent ID is filtered out of every selection path (preferred, fallback,
203
+ // hinted, anyIdle, temp). The auto-routing PR-review path passes
204
+ // excludeAgent=pr.agent so we never auto-dispatch a review to the author.
205
+ // Explicit-override callers (work-item with agent hint, dashboard force) MUST
206
+ // NOT pass excludeAgent — operator intent wins. Comparison is lowercased so
207
+ // case mismatches between pr.agent and config keys can't slip through.
208
+ const excludeKey = excludeAgent ? String(excludeAgent).toLowerCase() : null;
209
+ const isExcluded = (id) => excludeKey != null && String(id || '').toLowerCase() === excludeKey;
210
+
201
211
  const isAvailable = (id) => {
212
+ if (isExcluded(id)) return false;
202
213
  if (!agents[id] || !isAgentIdle(id) || (!dryRun && _claimedAgents.has(id))) return false;
203
214
  // Budget check — no budget means infinite (no limit)
204
215
  const budget = agents[id].monthlyBudgetUsd;
@@ -212,7 +223,7 @@ function resolveAgent(workType, config, opts = {}) {
212
223
  const pickAnyIdle = (exclude = []) => {
213
224
  const excludeSet = new Set(exclude.filter(Boolean));
214
225
  const idle = Object.keys(agents)
215
- .filter(id => !excludeSet.has(id) && isAvailable(id))
226
+ .filter(id => !excludeSet.has(id) && !isExcluded(id) && isAvailable(id))
216
227
  .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
217
228
  if (idle[0]) { if (!dryRun) _claimedAgents.add(idle[0]); return idle[0]; }
218
229
  return null;
@@ -221,6 +232,7 @@ function resolveAgent(workType, config, opts = {}) {
221
232
  const hintedAgents = normalizeAgentHints(agentHints, authorAgent, agents);
222
233
  if (hintedAgents.length > 0) {
223
234
  for (const id of hintedAgents) {
235
+ if (isExcluded(id)) continue;
224
236
  if (isAvailable(id)) { if (!dryRun) _claimedAgents.add(id); return id; }
225
237
  }
226
238
  }
@@ -236,7 +248,9 @@ function resolveAgent(workType, config, opts = {}) {
236
248
  const anyIdle = pickAnyIdle([preferred, fallback]);
237
249
  if (anyIdle) return anyIdle;
238
250
 
239
- // No idle configured agent — try temp agent if enabled
251
+ // No idle configured agent — try temp agent if enabled. Temp agent IDs
252
+ // (`temp-<uid>`) are by construction never equal to a named author agent, so
253
+ // they're always valid non-author reviewers under excludeAgent.
240
254
  if (config.engine?.allowTempAgents) {
241
255
  // Enforce per-tick temp-agent budget so temps count against maxConcurrent.
242
256
  // Without this guard, a mass-discovery pass (e.g. 20 PR build failures) would
@@ -261,13 +275,17 @@ function resolveAgent(workType, config, opts = {}) {
261
275
  }
262
276
 
263
277
  function resolveAgentReservation(workType, config, opts = {}) {
264
- const { authorAgent = null, agentHints = null } = opts || {};
278
+ const { authorAgent = null, agentHints = null, excludeAgent = null } = opts || {};
265
279
  const route = routeForWorkType(workType);
266
280
  const agents = config.agents || {};
267
281
  const hintedAgents = normalizeAgentHints(agentHints, authorAgent, agents);
268
282
 
283
+ const excludeKey = excludeAgent ? String(excludeAgent).toLowerCase() : null;
284
+ const isExcluded = (id) => excludeKey != null && String(id || '').toLowerCase() === excludeKey;
285
+
269
286
  const hasBudget = (id) => {
270
287
  if (!agents[id]) return false;
288
+ if (isExcluded(id)) return false;
271
289
  const budget = agents[id].monthlyBudgetUsd;
272
290
  return !(budget && budget > 0 && getMonthlySpend(id) >= budget);
273
291
  };
package/engine.js CHANGED
@@ -3599,11 +3599,32 @@ async function discoverFromPrs(config, project) {
3599
3599
  }
3600
3600
  } catch (e) { log('warn', `Pre-dispatch vote check for ${pr.id}: ${e.message} — skipping dispatch`); continue; }
3601
3601
 
3602
- const agentId = resolveAgent('review', config);
3603
- if (!agentId) continue;
3602
+ // Self-review ban (W-mp7jl5w3001e8c7e): exclude the PR author from the
3603
+ // candidate pool. If no non-author reviewer is available right now, defer
3604
+ // by queueing a pending entry with _pendingReason: 'no_non_author_reviewer'
3605
+ // so the per-tick dispatcher re-evaluates and promotes once a non-author
3606
+ // becomes idle (modeled on the work-item _pendingReason=no_agent gate).
3607
+ const reviewAuthor = pr.agent || null;
3608
+ const agentId = resolveAgent('review', config, reviewAuthor ? { excludeAgent: reviewAuthor } : {});
3604
3609
  const prBranch = ensurePrBranchForDispatch(project, pr, 'review');
3605
3610
  if (!prBranch) continue;
3606
3611
 
3612
+ if (!agentId) {
3613
+ const deferred = buildPrDispatch(routing.ANY_AGENT, config, project, pr, 'review', {
3614
+ pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
3615
+ pr_author: pr.agent || '', pr_url: pr.url || '',
3616
+ }, `Review ${pr.id}: ${pr.title}`, {
3617
+ dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta,
3618
+ deferReviewerResolution: true,
3619
+ });
3620
+ if (deferred) {
3621
+ deferred._pendingReason = 'no_non_author_reviewer';
3622
+ log('info', `Review for ${pr.id} deferred: no non-author reviewer available (author=${reviewAuthor || '<unknown>'}) — queued pending`);
3623
+ newWork.push(deferred);
3624
+ }
3625
+ continue;
3626
+ }
3627
+
3607
3628
  const item = buildPrDispatch(agentId, config, project, pr, 'review', {
3608
3629
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
3609
3630
  pr_author: pr.agent || '', pr_url: pr.url || '',
@@ -3729,11 +3750,30 @@ async function discoverFromPrs(config, project) {
3729
3750
  }
3730
3751
  } catch (e) { log('warn', `Pre-dispatch vote check for ${pr.id}: ${e.message} — skipping dispatch`); continue; }
3731
3752
 
3732
- const agentId = resolveAgent('review', config);
3733
- if (!agentId) continue;
3753
+ // Self-review ban (W-mp7jl5w3001e8c7e): exclude PR author from re-review
3754
+ // candidate pool; defer with no_non_author_reviewer if no eligible
3755
+ // reviewer is available right now (re-evaluated each tick).
3756
+ const reviewAuthor = pr.agent || null;
3757
+ const agentId = resolveAgent('review', config, reviewAuthor ? { excludeAgent: reviewAuthor } : {});
3734
3758
  const prBranch = ensurePrBranchForDispatch(project, pr, 're-review');
3735
3759
  if (!prBranch) continue;
3736
3760
 
3761
+ if (!agentId) {
3762
+ const deferred = buildPrDispatch(routing.ANY_AGENT, config, project, pr, 'review', {
3763
+ pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
3764
+ pr_author: pr.agent || '', pr_url: pr.url || '',
3765
+ }, `Review ${pr.id}: ${pr.title}`, {
3766
+ dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta,
3767
+ deferReviewerResolution: true,
3768
+ });
3769
+ if (deferred) {
3770
+ deferred._pendingReason = 'no_non_author_reviewer';
3771
+ log('info', `Re-review for ${pr.id} deferred: no non-author reviewer available (author=${reviewAuthor || '<unknown>'}) — queued pending`);
3772
+ newWork.push(deferred);
3773
+ }
3774
+ continue;
3775
+ }
3776
+
3737
3777
  const item = buildPrDispatch(agentId, config, project, pr, 'review', {
3738
3778
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
3739
3779
  pr_author: pr.agent || '', pr_url: pr.url || '',
@@ -4061,6 +4101,42 @@ function refreshDeferredWorkItemPrompt(item, config) {
4061
4101
  item.meta.deferAgentResolution = false;
4062
4102
  }
4063
4103
 
4104
+ // Self-review ban (W-mp7jl5w3001e8c7e): re-render the deferred PR review
4105
+ // prompt once a real reviewer is assigned. PR review dispatches that hit the
4106
+ // no_non_author_reviewer gate are queued with agent=ANY_AGENT and a placeholder
4107
+ // prompt; this rebuilds the prompt with the actual agent's identity so the
4108
+ // playbook's {{agent_id}}/{{agent_name}}/{{agent_role}} vars resolve correctly.
4109
+ function refreshDeferredReviewPrompt(item, config) {
4110
+ if (!item?.meta?.deferReviewerResolution) return;
4111
+ if (!item.agent || item.agent === routing.ANY_AGENT) return;
4112
+ if (item.type !== WORK_TYPE.REVIEW) return;
4113
+ const pr = item.meta?.pr;
4114
+ if (!pr) return;
4115
+ const project = projectFromDispatchMeta(item.meta?.project, config);
4116
+ if (!project) return;
4117
+ const prBranch = item.meta.branch || pr.branch || '';
4118
+ const prNumber = shared.getPrNumber(pr);
4119
+ const extraVars = {
4120
+ pr_id: pr.id,
4121
+ pr_number: prNumber,
4122
+ pr_title: pr.title || '',
4123
+ pr_branch: prBranch,
4124
+ pr_author: pr.agent || '',
4125
+ pr_url: pr.url || '',
4126
+ };
4127
+ const rebuilt = buildPrDispatch(
4128
+ item.agent, config, project, pr, WORK_TYPE.REVIEW,
4129
+ extraVars, `Review ${pr.id}: ${pr.title || ''}`, item.meta
4130
+ );
4131
+ if (rebuilt && rebuilt.prompt) {
4132
+ item.prompt = rebuilt.prompt;
4133
+ item.task = rebuilt.task;
4134
+ if (rebuilt.agentName) item.agentName = rebuilt.agentName;
4135
+ if (rebuilt.agentRole) item.agentRole = rebuilt.agentRole;
4136
+ }
4137
+ item.meta.deferReviewerResolution = false;
4138
+ }
4139
+
4064
4140
  function discoverFromWorkItems(config, project) {
4065
4141
  const src = project?.workSources?.workItems || config.workSources?.workItems;
4066
4142
  if (!src?.enabled) {
@@ -5139,6 +5215,16 @@ function getPendingDispatchRoutingOpts(item) {
5139
5215
  const opts = { agentHints: routing.extractAgentHints(item?.meta?.item) };
5140
5216
  const authorAgent = item?.meta?.pr?.agent;
5141
5217
  if (authorAgent) opts.authorAgent = authorAgent;
5218
+ // Self-review ban (W-mp7jl5w3001e8c7e): when re-routing a deferred review
5219
+ // dispatch, exclude the PR author from the candidate pool. Operator-explicit
5220
+ // dispatches (meta.explicitAgent === true) bypass this gate.
5221
+ if (
5222
+ routing.normalizeWorkType(item?.type, WORK_TYPE.IMPLEMENT) === WORK_TYPE.REVIEW
5223
+ && authorAgent
5224
+ && !item?.meta?.explicitAgent
5225
+ ) {
5226
+ opts.excludeAgent = authorAgent;
5227
+ }
5142
5228
  return opts;
5143
5229
  }
5144
5230
 
@@ -5157,6 +5243,16 @@ function assignPendingDispatchAgent(item, agentId, config) {
5157
5243
  item.agentRole = agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent';
5158
5244
  delete item._agentBusySince;
5159
5245
  delete item.skipReason;
5246
+ // Self-review ban (W-mp7jl5w3001e8c7e): re-render the deferred review prompt
5247
+ // for the actual reviewer. Centralized here so EVERY reassignment path
5248
+ // (initial ANY_AGENT routing, busy-agent reroute, unspawned-temp swap)
5249
+ // refreshes the prompt before spawn — otherwise the placeholder ANY_AGENT
5250
+ // prompt could leak into the agent context.
5251
+ refreshDeferredReviewPrompt(item, config);
5252
+ // Clear the pending-reason marker once the gate is satisfied so the
5253
+ // dashboard / log surfaces no longer show 'no_non_author_reviewer' for an
5254
+ // item that just got assigned a real reviewer.
5255
+ if (item._pendingReason === 'no_non_author_reviewer') delete item._pendingReason;
5160
5256
  }
5161
5257
 
5162
5258
  function clearPendingDispatchAgent(item) {
@@ -5175,11 +5271,15 @@ function persistPendingDispatchAgent(item) {
5175
5271
  p.agent = item.agent;
5176
5272
  p.agentName = item.agentName;
5177
5273
  p.agentRole = item.agentRole;
5274
+ if (item.prompt) p.prompt = item.prompt;
5275
+ if (item.task) p.task = item.task;
5178
5276
  } else {
5179
5277
  delete p.agent;
5180
5278
  delete p.agentName;
5181
5279
  delete p.agentRole;
5182
5280
  }
5281
+ if (item._pendingReason) p._pendingReason = item._pendingReason;
5282
+ else delete p._pendingReason;
5183
5283
  delete p._agentBusySince;
5184
5284
  delete p.skipReason;
5185
5285
  }
@@ -5664,18 +5764,38 @@ async function tickInner() {
5664
5764
  log('warn', `Duplicate dispatch ID ${item.id} in pending queue — skipping`);
5665
5765
  continue;
5666
5766
  }
5767
+ // Self-review ban (W-mp7jl5w3001e8c7e): defensive pre-spawn guard. A pending
5768
+ // review entry assigned to the PR author (legacy queue, manual edit, race)
5769
+ // must NOT spawn under the author. Clear the agent so it falls into the
5770
+ // ANY_AGENT re-route below (which excludes the author via
5771
+ // resolvePendingDispatchAgent). Operator overrides (meta.explicitAgent) are
5772
+ // honored — no clear, just a warning.
5773
+ if (
5774
+ routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT) === WORK_TYPE.REVIEW
5775
+ && typeof item.agent === 'string'
5776
+ && item.agent
5777
+ && item.agent !== routing.ANY_AGENT
5778
+ && item.meta?.pr?.agent
5779
+ && String(item.agent).toLowerCase() === String(item.meta.pr.agent).toLowerCase()
5780
+ ) {
5781
+ if (item.meta.explicitAgent) {
5782
+ log('warn', `Operator override: dispatching self-review for ${item.meta.pr.id || item.id} to author ${item.agent}`);
5783
+ } else {
5784
+ log('info', `Self-review guard: clearing author ${item.agent} from pending review ${item.id} (${item.meta.pr.id || ''}) — will re-route to a non-author reviewer`);
5785
+ clearPendingDispatchAgent(item);
5786
+ item.agent = routing.ANY_AGENT;
5787
+ item.meta.deferReviewerResolution = true;
5788
+ item._pendingReason = 'no_non_author_reviewer';
5789
+ try { persistPendingDispatchAgent(item); } catch (e) { log('warn', `Persist self-review clear for ${item.id} failed: ${e.message}`); }
5790
+ }
5791
+ }
5667
5792
  if (item.agent === routing.ANY_AGENT) {
5668
- const routedAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
5793
+ const routedAgent = resolvePendingDispatchAgent(item, config);
5669
5794
  if (!routedAgent) {
5670
5795
  log('debug', `Pending dispatch ${item.id} is waiting for any available agent`);
5671
5796
  continue;
5672
5797
  }
5673
- item.agent = routedAgent;
5674
- item.agentName = config.agents[routedAgent]?.name || tempAgents.get(routedAgent)?.name || routedAgent;
5675
- item.agentRole = config.agents[routedAgent]?.role || tempAgents.get(routedAgent)?.role || 'Agent';
5676
- delete item._agentBusySince;
5677
- delete item.skipReason;
5678
- refreshDeferredWorkItemPrompt(item, config);
5798
+ assignPendingDispatchAgent(item, routedAgent, config);
5679
5799
  try {
5680
5800
  if (_isTickStale(myGeneration)) return;
5681
5801
  mutateDispatch((dp) => {
@@ -5685,7 +5805,10 @@ async function tickInner() {
5685
5805
  p.agentName = item.agentName;
5686
5806
  p.agentRole = item.agentRole;
5687
5807
  p.prompt = item.prompt;
5808
+ p.task = item.task;
5688
5809
  if (item.meta) p.meta = item.meta;
5810
+ if (item._pendingReason) p._pendingReason = item._pendingReason;
5811
+ else delete p._pendingReason;
5689
5812
  delete p._agentBusySince;
5690
5813
  delete p.skipReason;
5691
5814
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1957",
3
+ "version": "0.1.1959",
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
@@ -32,7 +32,7 @@ Notes:
32
32
  ## Rules
33
33
 
34
34
  1. **Eager by default** — spawn all agents who can start work, not one at a time
35
- 2. **Self-review is allowed** — agents can review their own PRs (useful for single-agent setups)
35
+ 2. **Self-review is forbidden** — auto review dispatch must always pick an agent other than the PR author. When no non-author reviewer is available, the dispatch waits in `dispatch.pending` with `_pendingReason: 'no_non_author_reviewer'` and is re-evaluated each tick (it promotes to active automatically once a non-author becomes idle). Explicit operator dispatch (e.g. a work item with `agent: <author>`) is honored as an override but is logged as a warning.
36
36
  3. **Exploration gates implementation** — when exploring, finish before implementing
37
37
  4. **Implementation informs PRD** — Lambert reads build summaries before writing PRD
38
38
  5. **All rules in `notes.md` apply** — engine injects them into every playbook