@yemi33/minions 0.1.1657 → 0.1.1658

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1658 (2026-05-01)
4
+
5
+ ### Fixes
6
+ - trust structured completion status, drop prose regex
7
+
8
+ ### Other
9
+ - Fix soft routing hints
10
+
3
11
  ## 0.1.1657 (2026-05-01)
4
12
 
5
13
  ### Other
package/dashboard.js CHANGED
@@ -31,7 +31,7 @@ const dispatchMod = require('./engine/dispatch');
31
31
  const steering = require('./engine/steering');
32
32
  const os = require('os');
33
33
 
34
- const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
34
+ const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
35
35
  const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
36
36
  getSkills, getInbox, getNotesWithMeta, getPullRequests,
37
37
  getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
@@ -1392,7 +1392,7 @@ async function executeCCActions(actions) {
1392
1392
  try {
1393
1393
  switch (action.type) {
1394
1394
  case 'dispatch': case 'fix': case 'implement': case 'explore': case 'review': case 'test': {
1395
- const workType = action.workType || (action.type !== 'dispatch' ? action.type : 'implement');
1395
+ const workType = routing.normalizeWorkType(action.workType || (action.type !== 'dispatch' ? action.type : WORK_TYPE.IMPLEMENT), WORK_TYPE.IMPLEMENT);
1396
1396
  const id = 'W-' + shared.uid();
1397
1397
  const project = action.project || '';
1398
1398
 
@@ -2733,22 +2733,19 @@ const server = http.createServer(async (req, res) => {
2733
2733
  }
2734
2734
  const id = 'W-' + shared.uid();
2735
2735
  const item = {
2736
- id, title: body.title.trim(), type: body.type || 'implement',
2736
+ id, title: body.title.trim(), type: routing.normalizeWorkType(body.type, WORK_TYPE.IMPLEMENT),
2737
2737
  priority: body.priority || 'medium', description: body.description || '',
2738
2738
  status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
2739
2739
  };
2740
2740
  if (body.scope) item.scope = body.scope;
2741
- // Agent assignment normalization: when the caller (CC, dashboard form,
2742
- // direct API) supplies a single explicit agent either via `agent`
2743
- // (singular) or a one-element `agents` array treat it as a HARD pin
2744
- // by setting `item.agent`. The engine reads `item.agent || resolveAgent(…)`,
2745
- // so a hard-pinned item bypasses routing entirely and queues until that
2746
- // exact agent is free. Multi-agent arrays remain `item.agents` (hints
2747
- // for resolveAgent or fan-out scope).
2741
+ // Agent assignment normalization: `agent` and `agents` are routing hints.
2742
+ // Use agentLock/hardAgent only for the rare case where an item must wait
2743
+ // for one exact agent instead of falling through to another idle agent.
2748
2744
  const _agentsArr = Array.isArray(body.agents) ? body.agents.filter(Boolean) : (typeof body.agents === 'string' && body.agents ? [body.agents] : []);
2749
2745
  if (body.agent) item.agent = String(body.agent);
2750
2746
  else if (_agentsArr.length === 1 && body.scope !== 'fan-out') item.agent = String(_agentsArr[0]);
2751
2747
  if (_agentsArr.length > 0) item.agents = _agentsArr;
2748
+ if (body.agentLock === true || body.hardAgent === true) item.agentLock = true;
2752
2749
  if (body.references) item.references = body.references;
2753
2750
  if (body.acceptanceCriteria) item.acceptanceCriteria = body.acceptanceCriteria;
2754
2751
  if (body.skipPr) item.skipPr = true;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T04:46:00.686Z"
4
+ "cachedAt": "2026-05-01T05:22:39.907Z"
5
5
  }
@@ -81,7 +81,7 @@ function addToDispatch(item) {
81
81
  let added = false;
82
82
  mutateDispatch((dispatch) => {
83
83
  // Dedup: skip if same work item ID is already pending or active
84
- const wiId = item.meta?.item?.id;
84
+ const wiId = item.meta?.source === 'central-work-item-fanout' ? null : item.meta?.item?.id;
85
85
  if (wiId) {
86
86
  const existing = [...dispatch.pending, ...(dispatch.active || [])].find(d => d.meta?.item?.id === wiId);
87
87
  if (existing) {
@@ -1820,70 +1820,39 @@ function normalizeCompletionStatus(status) {
1820
1820
  return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
1821
1821
  }
1822
1822
 
1823
- function isTerminalPendingValue(value) {
1824
- const text = String(value || '').trim().toLowerCase();
1825
- if (!text) return true;
1826
- return /^(?:none|n\/a|na|no|nothing|not-applicable|not applicable|-)$/.test(text)
1827
- || /^no\s+(?:pending|remaining|outstanding)\b/.test(text)
1828
- || /^(?:all\s+)?(?:pending|remaining|outstanding)\s+(?:work|items?|tasks?)?\s*(?:resolved|complete|completed|done|closed)$/.test(text);
1829
- }
1830
-
1831
- function isTerminalPendingLine(line) {
1832
- const text = String(line || '').trim().toLowerCase();
1833
- return /\bno\s+pending\b/.test(text)
1834
- || /\bpending\s*[:=-]\s*(?:none|n\/a|na|no|nothing|not applicable|-)\b/.test(text)
1835
- || /\bpending\s+(?:work|items?|tasks?)?\s*(?:resolved|complete|completed|done|closed)\b/.test(text);
1836
- }
1837
-
1838
- function detectNonTerminalResultSummary(resultSummary, structuredCompletion) {
1839
- const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
1840
- if (completionStatus) {
1841
- if (/^(?:partial|partially-complete|in-progress|pending|deferred|blocked|incomplete|to-be-continued)/.test(completionStatus)) {
1823
+ // Trust the agent's explicit structured `status` field as the only signal that
1824
+ // a completion is non-terminal. Earlier versions also scanned the agent's
1825
+ // resultSummary prose with regex (looking for "pending", "in progress",
1826
+ // "partial", "wake up", etc.), but that produced false positives on benign
1827
+ // phrases like "I checked the pending PRs" or "build is in progress on CI"
1828
+ // and burned 3-9 minutes of agent time per false-positive retry.
1829
+ //
1830
+ // Both structured signals (the JSON completion report at MINIONS_COMPLETION_REPORT
1831
+ // and the fenced ```completion block in stdout) carry a `status` field. If the
1832
+ // agent explicitly says they're not done, honor it; otherwise accept the
1833
+ // dispatch. The PR attachment contract still catches silent-failure cases
1834
+ // for PR-producing work.
1835
+ const NON_TERMINAL_COMPLETION_STATUSES = new Set([
1836
+ 'partial', 'partially-complete', 'in-progress', 'pending', 'deferred',
1837
+ 'blocked', 'incomplete', 'to-be-continued',
1838
+ 'failed', 'failure', 'error',
1839
+ ]);
1840
+
1841
+ function detectNonTerminalResultSummary(_resultSummary, structuredCompletion, completionReport) {
1842
+ const candidates = [completionReport?.status, structuredCompletion?.status];
1843
+ for (const status of candidates) {
1844
+ const norm = normalizeCompletionStatus(status);
1845
+ if (!norm) continue;
1846
+ if (NON_TERMINAL_COMPLETION_STATUSES.has(norm)) {
1847
+ const isFailure = norm === 'failed' || norm === 'failure' || norm === 'error';
1842
1848
  return {
1843
- phrase: `status:${structuredCompletion.status}`,
1844
- reason: `Nonterminal completion summary: structured status is '${structuredCompletion.status}'`,
1849
+ phrase: `status:${status}`,
1850
+ reason: isFailure
1851
+ ? `Nonterminal completion summary: structured status is '${status}', not a successful terminal state`
1852
+ : `Nonterminal completion summary: structured status is '${status}'`,
1845
1853
  };
1846
1854
  }
1847
- if (/^(?:fail|failed|failure|error)/.test(completionStatus)) {
1848
- return {
1849
- phrase: `status:${structuredCompletion.status}`,
1850
- reason: `Nonterminal completion summary: structured status is '${structuredCompletion.status}', not a successful terminal state`,
1851
- };
1852
- }
1853
- }
1854
-
1855
- if (structuredCompletion?.pending && !isTerminalPendingValue(structuredCompletion.pending)) {
1856
- return {
1857
- phrase: 'pending',
1858
- reason: `Nonterminal completion summary: pending work remains (${String(structuredCompletion.pending).slice(0, 160)})`,
1859
- };
1860
1855
  }
1861
-
1862
- const text = String(resultSummary || '').replace(/\r/g, '').trim();
1863
- if (!text) return null;
1864
-
1865
- const patterns = [
1866
- { phrase: 'still running', re: /\b(?:still|currently|continues?\s+to\s+be)\s+(?:running|ongoing|in\s+progress)\b/i },
1867
- { phrase: 'will check later', re: /\b(?:i(?:'|’)ll|i\s+will|we(?:'|’)ll|we\s+will|will)\s+(?:check|verify|review|follow\s+up|revisit)\s+(?:again\s+)?(?:later|soon|in\b|after\b|when\b)/i },
1868
- { phrase: 'wake up', re: /\bwake(?:\s|-)?up\b|\bwake\b.*\b(?:check|verify|review)\b/i },
1869
- { phrase: 'not yet complete', re: /\b(?:not\s+yet|isn(?:'|’)t|not|incomplete|not\s+fully|not\s+completely)\s+(?:complete|completed|done|finished|validated|verified)\b/i },
1870
- { phrase: 'partial', re: /\bpartial(?:ly)?\b/i },
1871
- { phrase: 'to be continued', re: /\bto\s+be\s+continued\b|\btbc\b/i },
1872
- { phrase: 'in progress', re: /\bin\s+progress\b|\bongoing\b|\bincomplete\b/i },
1873
- ];
1874
- for (const { phrase, re } of patterns) {
1875
- if (re.test(text)) {
1876
- return { phrase, reason: `Nonterminal completion summary: matched '${phrase}'` };
1877
- }
1878
- }
1879
-
1880
- const pendingLines = text.split('\n').filter(line => /\bpending\b/i.test(line));
1881
- for (const line of pendingLines) {
1882
- if (!isTerminalPendingLine(line)) {
1883
- return { phrase: 'pending', reason: `Nonterminal completion summary: matched 'pending'` };
1884
- }
1885
- }
1886
-
1887
1856
  return null;
1888
1857
  }
1889
1858
 
@@ -2256,7 +2225,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2256
2225
 
2257
2226
  let completionContractFailure = null;
2258
2227
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
2259
- const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion);
2228
+ const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion);
2260
2229
  if (nonTerminalCompletion) {
2261
2230
  skipDoneStatus = true;
2262
2231
  const reason = deferNonTerminalCompletion(meta, nonTerminalCompletion);
@@ -9,6 +9,7 @@ const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
11
  const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
12
+ const routing = require('./routing');
12
13
  const http = require('http');
13
14
  const { parseCronExpr, shouldRunNow } = require('./scheduler');
14
15
 
@@ -306,10 +307,11 @@ function executeTaskStage(stage, stageState, run, config) {
306
307
  id,
307
308
  title: item.title || stage.title,
308
309
  description: item.description || stage.description || '',
309
- type: item.type || stage.taskType || 'explore',
310
+ type: routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE),
310
311
  priority: item.priority || stage.priority || 'medium',
311
- // Only set agent if explicitly specified otherwise engine routing assigns any available agent
312
+ // Agent is a soft routing hint unless agentLock/hardAgent is set.
312
313
  ...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
314
+ ...(item.agentLock === true || stage.agentLock === true || item.hardAgent === true || stage.hardAgent === true ? { agentLock: true } : {}),
313
315
  status: WI_STATUS.PENDING,
314
316
  created: ts(),
315
317
  createdBy: 'pipeline:' + run.pipelineId,
@@ -587,7 +589,7 @@ function executeMergePrsStage(stage, stageState, run, config) {
587
589
 
588
590
  function executeScheduleStage(stage, stageState, config) {
589
591
  // Create/update schedules in config
590
- const schedules = stage.schedules || [{ id: stage.id + '-sched', cron: stage.cron, title: stage.title, type: stage.taskType || 'implement' }];
592
+ const schedules = stage.schedules || [{ id: stage.id + '-sched', cron: stage.cron, title: stage.title, type: routing.normalizeWorkType(stage.taskType, WORK_TYPE.IMPLEMENT) }];
591
593
  // Write to config via shared
592
594
  for (const sched of schedules) {
593
595
  const existing = (config.schedules || []).find(s => s.id === sched.id);
package/engine/routing.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 { safeJson, safeRead, log, ts } = shared;
11
+ const { safeJson, safeRead, log, ts, WORK_TYPE } = shared;
12
12
  const { ENGINE_DIR, DISPATCH_PATH } = queries;
13
13
 
14
14
  const MINIONS_DIR = shared.MINIONS_DIR;
@@ -116,12 +116,43 @@ function setTempBudget(n) {
116
116
  }
117
117
  function getTempBudget() { return _tempBudget; }
118
118
 
119
- // Centralizes the work-item shape used to derive routing hints. Engine code
120
- // previously inlined `item.preferred_agent || item.agents || null` at four
121
- // call sites; hoisting keeps the contract in one place.
119
+ function normalizeWorkType(workType, fallback = WORK_TYPE.IMPLEMENT) {
120
+ const type = String(workType || fallback || '').trim();
121
+ if (!type) return fallback;
122
+ return type;
123
+ }
124
+
125
+ function routeForWorkType(workType) {
126
+ const routes = getRoutingTableCached();
127
+ return routes[normalizeWorkType(workType)] || routes[WORK_TYPE.IMPLEMENT] || { preferred: '_any_', fallback: '_any_' };
128
+ }
129
+
130
+ function isAgentHardPinned(item) {
131
+ return !!(item && (
132
+ item.agentLock === true ||
133
+ item.agent_locked === true ||
134
+ item.hardAgent === true ||
135
+ item.hard_agent === true
136
+ ));
137
+ }
138
+
139
+ function getHardPinnedAgent(item, agents = null) {
140
+ if (!isAgentHardPinned(item) || !item?.agent) return null;
141
+ const normalized = normalizeAgentHints(item.agent, null, agents);
142
+ return normalized[0] || null;
143
+ }
144
+
145
+ // Centralizes the work-item shape used to derive routing hints. `agent`,
146
+ // `preferred_agent`, and `agents` are soft preferences unless an explicit
147
+ // hard-pin flag is present (agentLock / hardAgent variants).
122
148
  function extractAgentHints(item) {
123
149
  if (!item || typeof item !== 'object') return null;
124
- return item.preferred_agent || item.agents || null;
150
+ const hints = [];
151
+ if (!isAgentHardPinned(item) && item.agent) hints.push(item.agent);
152
+ if (item.preferred_agent) hints.push(item.preferred_agent);
153
+ if (Array.isArray(item.agents)) hints.push(...item.agents);
154
+ else if (item.agents) hints.push(item.agents);
155
+ return hints.length > 0 ? hints : null;
125
156
  }
126
157
 
127
158
  // Normalize a list of agent-hint inputs. Accepts:
@@ -158,8 +189,7 @@ function normalizeAgentHints(agentHints, authorAgent = null, agents = null) {
158
189
 
159
190
  function resolveAgent(workType, config, opts = {}) {
160
191
  const { authorAgent = null, agentHints = null } = opts || {};
161
- const routes = getRoutingTableCached();
162
- const route = routes[workType] || routes['implement'] || { preferred: '_any_', fallback: '_any_' };
192
+ const route = routeForWorkType(workType);
163
193
  const agents = config.agents || {};
164
194
 
165
195
  // Resolve _author_ token
@@ -191,7 +221,6 @@ function resolveAgent(workType, config, opts = {}) {
191
221
  for (const id of hintedAgents) {
192
222
  if (isAvailable(id)) { _claimedAgents.add(id); return id; }
193
223
  }
194
- return null;
195
224
  }
196
225
 
197
226
  // Resolve _any_ token — pick any available agent (#480)
@@ -230,8 +259,7 @@ function resolveAgent(workType, config, opts = {}) {
230
259
 
231
260
  function resolveAgentReservation(workType, config, opts = {}) {
232
261
  const { authorAgent = null, agentHints = null } = opts || {};
233
- const routes = getRoutingTableCached();
234
- const route = routes[workType] || routes['implement'] || { preferred: '_any_', fallback: '_any_' };
262
+ const route = routeForWorkType(workType);
235
263
  const agents = config.agents || {};
236
264
  const hintedAgents = normalizeAgentHints(agentHints, authorAgent, agents);
237
265
 
@@ -274,6 +302,9 @@ module.exports = {
274
302
  isAgentIdle,
275
303
  normalizeAgentHints,
276
304
  extractAgentHints,
305
+ isAgentHardPinned,
306
+ getHardPinnedAgent,
307
+ normalizeWorkType,
277
308
  _claimedAgents,
278
309
  resetClaimedAgents,
279
310
  resolveAgent,
@@ -24,7 +24,8 @@
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./shared');
27
- const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS } = shared;
27
+ const routing = require('./routing');
28
+ const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS, WORK_TYPE } = shared;
28
29
 
29
30
  const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
30
31
 
@@ -167,13 +168,14 @@ function discoverScheduledWork(config) {
167
168
  work.push({
168
169
  id: workItemId,
169
170
  title: resolveScheduleTemplateVars(sched.title),
170
- type: sched.type || 'implement',
171
+ type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
171
172
  priority: sched.priority || 'medium',
172
173
  description: resolveScheduleTemplateVars(sched.description || sched.title),
173
174
  status: WI_STATUS.PENDING,
174
175
  created: ts(),
175
176
  createdBy: 'scheduler',
176
177
  agent: sched.agent || null,
178
+ ...(sched.agentLock === true || sched.hardAgent === true ? { agentLock: true } : {}),
177
179
  project: sched.project || null,
178
180
  _scheduleId: sched.id,
179
181
  });
package/engine/timeout.js CHANGED
@@ -147,7 +147,7 @@ function checkTimeouts(config) {
147
147
  const engineRestartGraceUntil = engine().engineRestartGraceUntil;
148
148
  const engineRestartGraceExempt = engine().engineRestartGraceExempt;
149
149
  const { completeDispatch } = dispatch();
150
- const { runPostCompletionHooks, parseAgentOutput, parseStructuredCompletion, detectNonTerminalResultSummary } = require('./lifecycle');
150
+ const { runPostCompletionHooks, parseAgentOutput, parseStructuredCompletion, parseCompletionReportFile, detectNonTerminalResultSummary } = require('./lifecycle');
151
151
 
152
152
  const timeout = config.engine?.agentTimeout || ENGINE_DEFAULTS.agentTimeout;
153
153
  const defaultStaleOrphanTimeout = config.engine?.heartbeatTimeout || ENGINE_DEFAULTS.heartbeatTimeout;
@@ -265,7 +265,7 @@ function checkTimeouts(config) {
265
265
  outputResultSummary = parseAgentOutput(fullLogForHooks, runtimeName).resultSummary || '';
266
266
  const gateSummary = outputResultSummary || (!fullLogForHooks.includes('"type":') ? fullLogForHooks : '');
267
267
  completionDetection = isSuccess
268
- ? detectNonTerminalResultSummary(gateSummary, parseStructuredCompletion(fullLogForHooks, runtimeName))
268
+ ? detectNonTerminalResultSummary(gateSummary, parseStructuredCompletion(fullLogForHooks, runtimeName), parseCompletionReportFile(item))
269
269
  : null;
270
270
  } catch (e) { log('warn', 'completion summary gate: ' + e.message); }
271
271
 
package/engine.js CHANGED
@@ -2644,7 +2644,7 @@ function discoverFromWorkItems(config, project) {
2644
2644
  skipped.gated++; continue;
2645
2645
  }
2646
2646
 
2647
- let workType = item.type || 'implement';
2647
+ let workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
2648
2648
  if (workType === WORK_TYPE.IMPLEMENT && (item.complexity === 'large' || item.estimated_complexity === 'large')) {
2649
2649
  workType = WORK_TYPE.IMPLEMENT_LARGE;
2650
2650
  }
@@ -2655,16 +2655,17 @@ function discoverFromWorkItems(config, project) {
2655
2655
  needsWrite = true;
2656
2656
  }
2657
2657
  const agentHints = routing.extractAgentHints(item);
2658
- const hasExplicitAgentHint = !!(item.agent || routing.normalizeAgentHints(agentHints, null, config.agents || {}).length);
2659
- let agentId = item.agent || resolveAgent(workType, config, { agentHints });
2658
+ const hardPinRequested = routing.isAgentHardPinned(item);
2659
+ let agentId = routing.getHardPinnedAgent(item, config.agents || {})
2660
+ || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null);
2660
2661
  const cfgAgents = config.agents || {};
2661
2662
  const budgetBlocked = Object.keys(cfgAgents).some(id => {
2662
2663
  const b = cfgAgents[id].monthlyBudgetUsd;
2663
2664
  return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
2664
2665
  });
2665
2666
  if (!agentId) {
2666
- if (!budgetBlocked && !hasExplicitAgentHint) {
2667
- agentId = resolveAgentReservation(workType, config);
2667
+ if (!budgetBlocked && !hardPinRequested) {
2668
+ agentId = resolveAgentReservation(workType, config, { agentHints });
2668
2669
  }
2669
2670
  if (agentId) {
2670
2671
  delete item._pendingReason;
@@ -3104,7 +3105,7 @@ function discoverCentralWorkItems(config) {
3104
3105
  }
3105
3106
  if (isOnCooldown(key, 0)) continue;
3106
3107
 
3107
- const workType = item.type || 'implement';
3108
+ const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
3108
3109
  const isFanOut = item.scope === 'fan-out';
3109
3110
 
3110
3111
  if (isFanOut) {
@@ -3185,10 +3186,10 @@ function discoverCentralWorkItems(config) {
3185
3186
  } else {
3186
3187
  // ─── Normal: single agent dispatch ──────────────────────────────
3187
3188
  const agentHints = routing.extractAgentHints(item);
3188
- const hasExplicitAgentHint = !!(item.agent || routing.normalizeAgentHints(agentHints, null, config.agents || {}).length);
3189
- const agentId = item.agent
3190
- || resolveAgent(workType, config, { agentHints })
3191
- || (!hasExplicitAgentHint ? resolveAgentReservation(workType, config) : null);
3189
+ const hardPinRequested = routing.isAgentHardPinned(item);
3190
+ const agentId = routing.getHardPinnedAgent(item, config.agents || {})
3191
+ || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null)
3192
+ || (!hardPinRequested ? resolveAgentReservation(workType, config, { agentHints }) : null);
3192
3193
  if (!agentId) continue;
3193
3194
 
3194
3195
  const agentName = config.agents[agentId]?.name || agentId;
@@ -3862,7 +3863,7 @@ async function tickInner() {
3862
3863
  // be of type string. Received undefined` and re-queues — every tick. Try to
3863
3864
  // resolve a fallback via routing; if none is available, skip this tick.
3864
3865
  if (!item.agent || typeof item.agent !== 'string') {
3865
- const fallback = resolveAgent(item.type || WORK_TYPE.FIX, config, { agentHints: routing.extractAgentHints(item.meta?.item) });
3866
+ const fallback = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
3866
3867
  if (!fallback) {
3867
3868
  log('warn', `Pending dispatch ${item.id} has no agent and routing returned no fallback — skipping`);
3868
3869
  continue;
@@ -3893,7 +3894,7 @@ async function tickInner() {
3893
3894
  // them eagerly before the busy check so an idle named agent can pick up.
3894
3895
  const isUnspawnedTemp = item.agent?.startsWith('temp-') && !busyAgents.has(item.agent);
3895
3896
  if (isUnspawnedTemp) {
3896
- const altAgent = resolveAgent(item.type, config);
3897
+ const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config);
3897
3898
  if (altAgent && altAgent !== item.agent) {
3898
3899
  const prevAgent = item.agent;
3899
3900
  item.agent = altAgent;
@@ -3920,12 +3921,12 @@ async function tickInner() {
3920
3921
  // Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
3921
3922
  // try to find an alternative agent via routing. Skip explicitly assigned items.
3922
3923
  const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
3923
- const isExplicitReassign = !!(item.meta?.item?.agent || routing.extractAgentHints(item.meta?.item));
3924
- if (!isExplicitReassign && reassignMs > 0 && item._agentBusySince) {
3924
+ const isHardPinned = routing.isAgentHardPinned(item.meta?.item);
3925
+ if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
3925
3926
  const busySinceMs = new Date(item._agentBusySince).getTime();
3926
3927
  if (Date.now() - busySinceMs > reassignMs) {
3927
3928
  const originalAgent = item.agent;
3928
- const altAgent = resolveAgent(item.type, config);
3929
+ const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
3929
3930
  if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
3930
3931
  log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy > ${reassignMs}ms`);
3931
3932
  item.agent = altAgent;
@@ -3959,14 +3960,12 @@ async function tickInner() {
3959
3960
  // Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
3960
3961
  const itemBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
3961
3962
  if (itemBranch && lockedBranches.has(itemBranch)) continue;
3962
- // Items explicitly assigned to an agent bypass concurrency cap — dispatch if agent is free
3963
- const isExplicitAssignment = !!item.meta?.item?.agent;
3964
- if (!isExplicitAssignment && generalSlots <= 0) continue;
3963
+ if (generalSlots <= 0) continue;
3965
3964
  seenPendingIds.add(item.id);
3966
3965
  toDispatch.push(item);
3967
3966
  busyAgents.add(item.agent);
3968
3967
  if (itemBranch) lockedBranches.add(itemBranch);
3969
- if (!isExplicitAssignment) generalSlots--;
3968
+ generalSlots--;
3970
3969
  }
3971
3970
 
3972
3971
  // Dispatch items — spawnAgent moves each from pending→active on disk.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1657",
3
+ "version": "0.1.1658",
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"