@yemi33/minions 0.1.1666 → 0.1.1668

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1668 (2026-05-01)
4
+
5
+ ### Fixes
6
+ - route stranded work items to available agents
7
+
8
+ ## 0.1.1667 (2026-05-01)
9
+
10
+ ### Other
11
+ - Harden PR attachment completion handling
12
+
3
13
  ## 0.1.1666 (2026-05-01)
4
14
 
5
15
  ### Other
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T20:53:59.616Z"
4
+ "cachedAt": "2026-05-01T23:20:43.938Z"
5
5
  }
@@ -943,7 +943,8 @@ function isPrAttachmentRequired(type, item, meta = {}) {
943
943
  || item.prRequired === true
944
944
  || item.requiresPullRequest === true
945
945
  || item.itemType === 'pr';
946
- if (meta.branchStrategy === 'shared-branch' && item.itemType !== 'pr' && !explicit) return false;
946
+ const branchStrategy = meta.branchStrategy || item.branchStrategy;
947
+ if (branchStrategy === 'shared-branch' && item.itemType !== 'pr' && !explicit) return false;
947
948
 
948
949
  // Fix/test work items dispatched against an existing PR don't produce a new
949
950
  // PR — the agent updates meta.pr in place. Only require fresh PR attachment
@@ -960,21 +961,144 @@ function isPrAttachmentRequired(type, item, meta = {}) {
960
961
  || type === WORK_TYPE.TEST;
961
962
  }
962
963
 
964
+ function readOptionalJsonStrict(filePath, label, validate) {
965
+ if (!filePath) return null;
966
+ let raw;
967
+ try {
968
+ raw = fs.readFileSync(filePath, 'utf8');
969
+ } catch (err) {
970
+ if (err.code === 'ENOENT') return null;
971
+ throw new Error(`Cannot read ${label} JSON at ${filePath}: ${err.message}`);
972
+ }
973
+ let parsed;
974
+ try {
975
+ parsed = JSON.parse(raw);
976
+ } catch (err) {
977
+ throw new Error(`Corrupt ${label} JSON at ${filePath}: ${err.message}`);
978
+ }
979
+ if (validate && !validate(parsed)) {
980
+ throw new Error(`Invalid ${label} JSON shape at ${filePath}`);
981
+ }
982
+ return parsed;
983
+ }
984
+
963
985
  function hasCanonicalPrAttachment(itemId, config) {
964
986
  if (!itemId) return false;
987
+ // Cheapest probe first — getPrLinks() is in-process cached and merges canonical IDs.
965
988
  if (Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(itemId))) return true;
966
989
  const projects = shared.getProjects(config);
967
990
  for (const p of projects) {
968
- const prs = safeJson(shared.projectPrPath(p)) || [];
991
+ const prs = readOptionalJsonStrict(shared.projectPrPath(p), 'project pull-requests', Array.isArray) || [];
969
992
  if (prs.some(pr => (pr.prdItems || []).includes(itemId))) return true;
970
993
  }
971
- const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
994
+ const centralPrs = readOptionalJsonStrict(path.join(MINIONS_DIR, 'pull-requests.json'), 'central pull-requests', Array.isArray) || [];
972
995
  return centralPrs.some(pr => (pr.prdItems || []).includes(itemId));
973
996
  }
974
997
 
998
+ function resolvePrFallbackProject(meta, config) {
999
+ const projects = shared.getProjects(config);
1000
+ if (meta?.project?.name) {
1001
+ const match = projects.find(p => p.name === meta.project.name);
1002
+ if (match) return match;
1003
+ }
1004
+ if (meta?.project?.localPath) {
1005
+ const metaPath = path.resolve(meta.project.localPath);
1006
+ const match = projects.find(p => p.localPath && path.resolve(p.localPath) === metaPath);
1007
+ if (match) return match;
1008
+ }
1009
+ if (meta?.item?.project) {
1010
+ const match = projects.find(p => p.name === meta.item.project);
1011
+ if (match) return match;
1012
+ }
1013
+ return projects.length === 1 ? projects[0] : null;
1014
+ }
1015
+
1016
+ function runFileCapture(file, args, opts = {}) {
1017
+ const { timeout = 30000, ...spawnOpts } = opts;
1018
+ const MAX_BUFFER = 4 * 1024 * 1024; // 4MB — generous for gh/git output
1019
+ return new Promise((resolve, reject) => {
1020
+ let child;
1021
+ let settled = false;
1022
+ let timedOut = false;
1023
+ let killedForBuffer = false;
1024
+ let stdout = '';
1025
+ let stderr = '';
1026
+ let hardKillTimer = null;
1027
+ const finish = (fn, value) => {
1028
+ if (settled) return;
1029
+ settled = true;
1030
+ if (timer) clearTimeout(timer);
1031
+ if (hardKillTimer) clearTimeout(hardKillTimer);
1032
+ fn(value);
1033
+ };
1034
+ const timer = timeout ? setTimeout(() => {
1035
+ timedOut = true;
1036
+ if (!child) return;
1037
+ shared.killGracefully(child, 1000);
1038
+ // Hard-kill fallback if the child ignores SIGTERM. Cleared on close.
1039
+ hardKillTimer = setTimeout(() => {
1040
+ try { shared.killImmediate(child); } catch {}
1041
+ }, 2500);
1042
+ }, timeout) : null;
1043
+ try {
1044
+ child = shared.runFile(file, args, { ...spawnOpts, stdio: ['ignore', 'pipe', 'pipe'] });
1045
+ } catch (err) {
1046
+ finish(reject, err);
1047
+ return;
1048
+ }
1049
+ child.stdout?.setEncoding('utf8');
1050
+ child.stderr?.setEncoding('utf8');
1051
+ child.stdout?.on('data', chunk => {
1052
+ if (stdout.length + chunk.length > MAX_BUFFER) {
1053
+ if (!killedForBuffer) { killedForBuffer = true; try { shared.killImmediate(child); } catch {} }
1054
+ return;
1055
+ }
1056
+ stdout += chunk;
1057
+ });
1058
+ child.stderr?.on('data', chunk => {
1059
+ if (stderr.length + chunk.length > MAX_BUFFER) {
1060
+ if (!killedForBuffer) { killedForBuffer = true; try { shared.killImmediate(child); } catch {} }
1061
+ return;
1062
+ }
1063
+ stderr += chunk;
1064
+ });
1065
+ child.on('error', err => {
1066
+ err.stdout = stdout;
1067
+ err.stderr = stderr;
1068
+ finish(reject, err);
1069
+ });
1070
+ child.once('close', () => {
1071
+ if (hardKillTimer) { clearTimeout(hardKillTimer); hardKillTimer = null; }
1072
+ });
1073
+ child.on('close', code => {
1074
+ if (code === 0 && !timedOut && !killedForBuffer) {
1075
+ finish(resolve, stdout);
1076
+ return;
1077
+ }
1078
+ let message;
1079
+ let errCode;
1080
+ if (timedOut) {
1081
+ message = `${file} timed out after ${timeout}ms`;
1082
+ errCode = 'ETIMEDOUT';
1083
+ } else if (killedForBuffer) {
1084
+ message = `${file} exceeded max buffer of ${MAX_BUFFER} bytes`;
1085
+ errCode = 'ERR_OUT_OF_RANGE';
1086
+ } else {
1087
+ message = `${file} exited with code ${code}`;
1088
+ errCode = code;
1089
+ }
1090
+ const err = new Error(message);
1091
+ err.code = errCode;
1092
+ err.stdout = stdout;
1093
+ err.stderr = stderr;
1094
+ finish(reject, err);
1095
+ });
1096
+ });
1097
+ }
1098
+
975
1099
  async function findOpenPrForBranch(meta, config) {
976
1100
  if (!meta?.branch) return null;
977
- const projectObj = shared.getProjects(config).find(p => p.name === meta?.project?.name);
1101
+ const projectObj = resolvePrFallbackProject(meta, config);
978
1102
  if (!projectObj) return null;
979
1103
  const host = projectObj.repoHost || 'ado';
980
1104
  if (host === 'github') {
@@ -984,7 +1108,7 @@ async function findOpenPrForBranch(meta, config) {
984
1108
  if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
985
1109
  let raw = '';
986
1110
  try {
987
- raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
1111
+ raw = await runFileCapture('gh', ['pr', 'list', '--head', String(meta.branch), '--repo', ghSlug, '--json', 'number,url,state', '--limit', '1'], { timeout: 15000 });
988
1112
  const parsed = JSON.parse(raw || '[]');
989
1113
  const hits = Array.isArray(parsed) ? parsed : [];
990
1114
  if (hits.length > 0 && hits[0].state === 'OPEN') {
@@ -1024,6 +1148,7 @@ function _outputContainsPrUrl(output) {
1024
1148
  function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity) {
1025
1149
  const noPrWiPath = resolveWorkItemPath(meta);
1026
1150
  const isHard = severity !== 'soft';
1151
+ let syncNeedsReviewToPrd = false;
1027
1152
  if (noPrWiPath) {
1028
1153
  mutateJsonFileLocked(noPrWiPath, data => {
1029
1154
  if (!Array.isArray(data)) return data;
@@ -1034,6 +1159,7 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
1034
1159
  w._missingPrAttachment = true;
1035
1160
  w.failReason = reason;
1036
1161
  w._lastReviewReason = reason;
1162
+ syncNeedsReviewToPrd = !!meta.item?.sourcePlan;
1037
1163
  delete w.completedAt;
1038
1164
  delete w._noPr;
1039
1165
  delete w._noPrReason;
@@ -1047,6 +1173,9 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
1047
1173
  return data;
1048
1174
  }, { skipWriteIfUnchanged: true });
1049
1175
  }
1176
+ if (isHard && syncNeedsReviewToPrd) {
1177
+ syncPrdItemStatus(meta.item.id, WI_STATUS.NEEDS_REVIEW, meta.item.sourcePlan);
1178
+ }
1050
1179
  if (isHard) {
1051
1180
  shared.writeToInbox('engine', `missing-pr-attachment-${meta.item.id}`,
1052
1181
  `# PR attachment missing for ${meta.item.id}\n\n` +
@@ -1075,9 +1204,50 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
1075
1204
  }
1076
1205
  }
1077
1206
 
1207
+ function markPrAttachmentVerificationError(meta, agentId, reason, resultSummary) {
1208
+ const wiPath = resolveWorkItemPath(meta);
1209
+ let syncNeedsReviewToPrd = false;
1210
+ if (wiPath) {
1211
+ mutateJsonFileLocked(wiPath, data => {
1212
+ if (!Array.isArray(data)) return data;
1213
+ const w = data.find(i => i.id === meta.item.id);
1214
+ if (!w) return data;
1215
+ w.status = WI_STATUS.NEEDS_REVIEW;
1216
+ w._prAttachmentStateError = true;
1217
+ w.failReason = reason;
1218
+ w._lastReviewReason = reason;
1219
+ syncNeedsReviewToPrd = !!meta.item?.sourcePlan;
1220
+ delete w.completedAt;
1221
+ delete w._missingPrAttachment;
1222
+ delete w._unverifiedPrAttachment;
1223
+ return data;
1224
+ }, { skipWriteIfUnchanged: true });
1225
+ }
1226
+ if (syncNeedsReviewToPrd) {
1227
+ syncPrdItemStatus(meta.item.id, WI_STATUS.NEEDS_REVIEW, meta.item.sourcePlan);
1228
+ }
1229
+ shared.writeToInbox('engine', `pr-attachment-state-error-${meta.item.id}`,
1230
+ `# PR attachment verification blocked for ${meta.item.id}\n\n` +
1231
+ `**Agent:** ${agentId}\n` +
1232
+ `**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
1233
+ `**Type:** ${meta.item.type || 'unknown'}\n` +
1234
+ `**Branch:** ${meta.branch || '(none)'}\n\n` +
1235
+ `${reason}\n` +
1236
+ (resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
1237
+ null,
1238
+ { sourceItem: meta.item.id, reason: 'pr-attachment-state-error' });
1239
+ }
1240
+
1078
1241
  async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary, output) {
1079
1242
  if (!isPrAttachmentRequired(type, meta?.item, meta)) return null;
1080
- if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1243
+ try {
1244
+ if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1245
+ } catch (err) {
1246
+ const reason = `${meta.item.id} completed but PR attachment verification could not read PR tracking state: ${err.message}`;
1247
+ markPrAttachmentVerificationError(meta, agentId, reason, resultSummary);
1248
+ log('warn', reason);
1249
+ return { reason, itemId: meta.item.id, severity: 'hard', stateError: true };
1250
+ }
1081
1251
 
1082
1252
  const found = await findOpenPrForBranch(meta, config);
1083
1253
  if (found) {
@@ -1100,7 +1270,14 @@ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSu
1100
1270
  itemId: meta.item.id,
1101
1271
  });
1102
1272
  log('info', `Auto-linked existing PR ${entry.id} on branch ${meta.branch} for ${meta.item.id}`);
1103
- if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1273
+ try {
1274
+ if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1275
+ } catch (err) {
1276
+ const reason = `${meta.item.id} auto-linked a PR but PR attachment verification could not read PR tracking state: ${err.message}`;
1277
+ markPrAttachmentVerificationError(meta, agentId, reason, resultSummary);
1278
+ log('warn', reason);
1279
+ return { reason, itemId: meta.item.id, severity: 'hard', stateError: true };
1280
+ }
1104
1281
  }
1105
1282
 
1106
1283
  // Distinguish "agent never claimed a PR" (hard — silent failure the contract
@@ -1737,13 +1914,7 @@ function parseStructuredCompletion(stdout, runtimeName) {
1737
1914
  if (!stdout || typeof stdout !== 'string') return null;
1738
1915
 
1739
1916
  // Extract text from stream-json output if needed
1740
- let text = stdout;
1741
- if (stdout.includes('"type":')) {
1742
- try {
1743
- const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
1744
- if (parsed.text) text = parsed.text;
1745
- } catch {}
1746
- }
1917
+ const text = extractCompletionText(stdout, runtimeName);
1747
1918
 
1748
1919
  // Find all ```completion blocks, take the last one
1749
1920
  const blockPattern = /```completion\s*\n([\s\S]*?)```/g;
@@ -1760,6 +1931,22 @@ function parseStructuredCompletion(stdout, runtimeName) {
1760
1931
  return parseCompletionKeyValues(lastMatch);
1761
1932
  }
1762
1933
 
1934
+ function extractCompletionText(stdout, runtimeName) {
1935
+ let text = stdout;
1936
+ if (typeof stdout === 'string' && stdout.includes('"type":')) {
1937
+ try {
1938
+ const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
1939
+ if (parsed.text) text = parsed.text;
1940
+ } catch {}
1941
+ }
1942
+ return text;
1943
+ }
1944
+
1945
+ function hasCompletionFence(stdout, runtimeName) {
1946
+ const text = extractCompletionText(stdout, runtimeName);
1947
+ return /```completion\s*\n[\s\S]*?```/.test(text);
1948
+ }
1949
+
1763
1950
  function extractTaskCompleteSummary(stdout) {
1764
1951
  if (!stdout || typeof stdout !== 'string') return '';
1765
1952
  let summary = '';
@@ -1817,6 +2004,29 @@ function parseCompletionKeyValues(text) {
1817
2004
  return result;
1818
2005
  }
1819
2006
 
2007
+ function parseCompletionFieldSummary(text) {
2008
+ if (!text || typeof text !== 'string') return null;
2009
+
2010
+ const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
2011
+ const result = {};
2012
+ for (const rawLine of text.split(/\r?\n/)) {
2013
+ const line = rawLine.trim().replace(/^[-*]\s+/, '');
2014
+ const colonIdx = line.indexOf(':');
2015
+ if (colonIdx < 1) continue;
2016
+ const key = line.slice(0, colonIdx).trim().toLowerCase().replace(/[\s-]+/g, '_');
2017
+ if (!allowedFields.has(key)) continue;
2018
+ const value = line.slice(colonIdx + 1).trim().replace(/^["'`]+|["'`]+$/g, '');
2019
+ if (value) result[key] = value;
2020
+ }
2021
+
2022
+ if (!result.status) return null;
2023
+ const fieldCount = Object.keys(result).length;
2024
+ const status = normalizeCompletionStatus(result.status);
2025
+ const explicitlyFailed = status.startsWith('fail') || status === 'error';
2026
+ if (fieldCount < 2 && !explicitlyFailed) return null;
2027
+ return result;
2028
+ }
2029
+
1820
2030
  function parseCompletionReportFile(dispatchItem, opts = {}) {
1821
2031
  const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
1822
2032
  if (!reportPath || !fs.existsSync(reportPath)) {
@@ -1875,10 +2085,11 @@ function normalizeCompletionStatus(status) {
1875
2085
  // and burned 3-9 minutes of agent time per false-positive retry.
1876
2086
  //
1877
2087
  // Both structured signals (the JSON completion report at MINIONS_COMPLETION_REPORT
1878
- // and the fenced ```completion block in stdout) carry a `status` field. If the
1879
- // agent explicitly says they're not done, honor it; otherwise accept the
1880
- // dispatch. The PR attachment contract still catches silent-failure cases
1881
- // for PR-producing work.
2088
+ // and the fenced ```completion block in stdout) carry a `status` field. A plain
2089
+ // task_complete summary made only of completion fields is accepted as a narrow
2090
+ // compatibility fallback. If the agent explicitly says they're not done, honor
2091
+ // it; otherwise accept the dispatch. The PR attachment contract still catches
2092
+ // silent-failure cases for PR-producing work.
1882
2093
  const NON_TERMINAL_COMPLETION_STATUSES = new Set([
1883
2094
  'partial', 'partially-complete', 'in-progress', 'pending', 'deferred',
1884
2095
  'blocked', 'incomplete', 'to-be-continued',
@@ -2110,8 +2321,11 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2110
2321
 
2111
2322
  // Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
2112
2323
  const reportCompletion = parseCompletionReportFile(dispatchItem, { warnIfMissing: true });
2113
- const fallbackCompletion = reportCompletion ? null : parseStructuredCompletion(stdout, runtimeName);
2114
- const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, 'fenced-completion');
2324
+ const fencedCompletion = reportCompletion ? null : parseStructuredCompletion(stdout, runtimeName);
2325
+ const summaryCompletion = reportCompletion || fencedCompletion ? null : parseCompletionFieldSummary(resultSummary);
2326
+ const fallbackCompletion = fencedCompletion || summaryCompletion;
2327
+ const fallbackSource = fencedCompletion && hasCompletionFence(stdout, runtimeName) ? 'fenced-completion' : 'summary-completion';
2328
+ const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, fallbackSource);
2115
2329
  if (structuredCompletion) {
2116
2330
  if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
2117
2331
  log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
@@ -2625,6 +2839,7 @@ module.exports = {
2625
2839
  parseReviewVerdict,
2626
2840
  isReviewBailout,
2627
2841
  parseStructuredCompletion,
2842
+ parseCompletionFieldSummary,
2628
2843
  detectNonTerminalResultSummary,
2629
2844
  parseCompletionReportFile,
2630
2845
  persistCompletionReport,
package/engine.js CHANGED
@@ -32,7 +32,7 @@ const queries = require('./engine/queries');
32
32
 
33
33
  // ─── Paths ──────────────────────────────────────────────────────────────────
34
34
 
35
- const MINIONS_DIR = __dirname;
35
+ const MINIONS_DIR = shared.MINIONS_DIR;
36
36
  const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
37
37
  const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
38
38
  const ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
@@ -1365,13 +1365,15 @@ async function spawnAgent(dispatchItem, config) {
1365
1365
  let errorReason = '';
1366
1366
  if (hardContractFail) {
1367
1367
  errorReason = completionContractFailure.reason || 'PR attachment contract failed';
1368
- } else if (agentReportedFailure && structuredCompletion) {
1369
- errorReason = String(
1370
- structuredCompletion.summary
1371
- || structuredCompletion.pending
1372
- || structuredCompletion.failure_class
1373
- || `Agent reported ${structuredCompletion.status || 'failure'}`
1374
- ).slice(0, 300);
1368
+ } else if (agentReportedFailure) {
1369
+ errorReason = structuredCompletion
1370
+ ? String(
1371
+ structuredCompletion.summary
1372
+ || structuredCompletion.pending
1373
+ || structuredCompletion.failure_class
1374
+ || `Agent reported ${structuredCompletion.status || 'failure'}`
1375
+ ).slice(0, 300)
1376
+ : 'Agent reported failure';
1375
1377
  } else if (effectiveResult === DISPATCH_RESULT.ERROR) {
1376
1378
  errorReason = stderr.split('\n').filter(l => l.trim()).slice(-5).join(' | ').trim().slice(0, 300);
1377
1379
  // W-mo3zul9pirjb — when claude CLI exits in <3s with code 1 and no output (the
@@ -2743,9 +2745,9 @@ function discoverFromWorkItems(config, project) {
2743
2745
  }
2744
2746
  const agentHints = routing.extractAgentHints(item);
2745
2747
  const hasAgentHints = Array.isArray(agentHints) && agentHints.length > 0;
2746
- const hardPinRequested = routing.isAgentHardPinned(item);
2747
- let agentId = routing.getHardPinnedAgent(item, config.agents || {})
2748
- || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null);
2748
+ const hardPinnedAgent = routing.getHardPinnedAgent(item, config.agents || {});
2749
+ const hardPinRequested = !!hardPinnedAgent;
2750
+ let agentId = hardPinnedAgent || resolveAgent(workType, config, { agentHints });
2749
2751
  let reservedAgentId = agentId;
2750
2752
  const cfgAgents = config.agents || {};
2751
2753
  const budgetBlocked = Object.keys(cfgAgents).some(id => {
@@ -2863,6 +2865,17 @@ function discoverFromWorkItems(config, project) {
2863
2865
  return newWork;
2864
2866
  }
2865
2867
 
2868
+
2869
+ function getSyntheticCentralProject() {
2870
+ const base = path.basename(MINIONS_DIR);
2871
+ const root = base === '.minions' ? path.dirname(MINIONS_DIR) : MINIONS_DIR;
2872
+ return { name: 'root', localPath: root, repoHost: 'github', repoName: path.basename(root) || 'root', mainBranch: 'main', _synthetic: true };
2873
+ }
2874
+
2875
+ function getCentralDispatchProjects(projects) {
2876
+ return projects.length > 0 ? projects : [getSyntheticCentralProject()];
2877
+ }
2878
+
2866
2879
  /**
2867
2880
  * Build the multi-project context section for central work items.
2868
2881
  * Inserted into the playbook via {{scope_section}}.
@@ -3129,6 +3142,7 @@ function discoverCentralWorkItems(config) {
3129
3142
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
3130
3143
  const items = safeJson(centralPath) || [];
3131
3144
  const projects = getProjects(config);
3145
+ const dispatchProjects = getCentralDispatchProjects(projects);
3132
3146
  const newWork = [];
3133
3147
  // Collect mutations to apply atomically inside lock callback (avoids TOCTOU)
3134
3148
  const mutations = new Map(); // item.id → { field: value, ... }
@@ -3188,15 +3202,14 @@ function discoverCentralWorkItems(config) {
3188
3202
 
3189
3203
  const assignments = idleAgents.map((agent, i) => ({
3190
3204
  agent,
3191
- assignedProject: projects.length > 0 ? projects[i % projects.length] : null
3205
+ assignedProject: dispatchProjects[i % dispatchProjects.length]
3192
3206
  }));
3193
3207
 
3194
3208
  for (const { agent, assignedProject } of assignments) {
3195
3209
  const fanKey = `${key}-${agent.id}`;
3196
3210
  if (isAlreadyDispatched(fanKey)) continue;
3197
3211
 
3198
- const ap = assignedProject || (projects.length > 0 ? projects[0] : null);
3199
- if (!ap) { log('warn', `Fan-out: skipping ${fanKey} — no projects configured`); continue; }
3212
+ const ap = assignedProject || dispatchProjects[0];
3200
3213
  const fanBranch = `fan/${item.id}/${agent.id}`;
3201
3214
  const vars = {
3202
3215
  ...buildBaseVars(agent.id, config, ap),
@@ -3206,7 +3219,7 @@ function discoverCentralWorkItems(config) {
3206
3219
  item_description: item.description || '',
3207
3220
  work_type: workType,
3208
3221
  additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
3209
- scope_section: buildProjectContext(projects, assignedProject, true, agent.name, agent.role),
3222
+ scope_section: buildProjectContext(dispatchProjects, assignedProject, true, agent.name, agent.role),
3210
3223
  project_path: ap?.localPath || '',
3211
3224
  branch_name: fanBranch,
3212
3225
  };
@@ -3250,16 +3263,16 @@ function discoverCentralWorkItems(config) {
3250
3263
  } else {
3251
3264
  // ─── Normal: single agent dispatch ──────────────────────────────
3252
3265
  const agentHints = routing.extractAgentHints(item);
3253
- const hardPinRequested = routing.isAgentHardPinned(item);
3254
- const agentId = routing.getHardPinnedAgent(item, config.agents || {})
3255
- || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null)
3266
+ const hardPinnedAgent = routing.getHardPinnedAgent(item, config.agents || {});
3267
+ const hardPinRequested = !!hardPinnedAgent;
3268
+ const agentId = hardPinnedAgent
3269
+ || resolveAgent(workType, config, { agentHints })
3256
3270
  || (!hardPinRequested && workType !== WORK_TYPE.FIX ? resolveAgentReservation(workType, config, { agentHints }) : null);
3257
3271
  if (!agentId) continue;
3258
3272
 
3259
3273
  const agentName = config.agents[agentId]?.name || agentId;
3260
3274
  const agentRole = config.agents[agentId]?.role || 'Agent';
3261
- const firstProject = projects.length > 0 ? projects[0] : null;
3262
- if (!firstProject) { log('warn', `Dispatch: skipping ${item.id} — no projects configured`); continue; }
3275
+ const firstProject = dispatchProjects[0];
3263
3276
 
3264
3277
  // Branch mutex: skip if target branch is locked by an active dispatch
3265
3278
  const centralBranch = item.branch || item.featureBranch || `work/${item.id}`;
@@ -3280,7 +3293,7 @@ function discoverCentralWorkItems(config) {
3280
3293
  task_id: item.id,
3281
3294
  work_type: workType,
3282
3295
  additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
3283
- scope_section: buildProjectContext(projects, null, false, agentName, agentRole),
3296
+ scope_section: buildProjectContext(dispatchProjects, null, false, agentName, agentRole),
3284
3297
  project_path: firstProject?.localPath || '',
3285
3298
  branch_name: centralBranch,
3286
3299
  };
@@ -3382,7 +3395,7 @@ function discoverCentralWorkItems(config) {
3382
3395
  agentRole,
3383
3396
  task: item.title || item.description?.slice(0, 80) || item.id,
3384
3397
  prompt,
3385
- meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}` }
3398
+ meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}`, project: { name: firstProject.name, localPath: firstProject.localPath } }
3386
3399
  });
3387
3400
 
3388
3401
  setCooldown(key);
@@ -4024,7 +4037,19 @@ async function tickInner() {
4024
4037
  persistPendingDispatchAgent(item);
4025
4038
  } catch (e) { log('warn', `Persist agent resolution for ${item.id} failed: ${e.message}`); }
4026
4039
  }
4027
- // #1204: Pre-assigned unspawned temp agents never unblock naturally.
4040
+ // Unknown configured agent: string ID that is neither configured nor a known temp agent
4041
+ const isUnknownAssignedAgent = typeof item.agent === 'string' && !item.agent.startsWith('temp-') && !config.agents?.[item.agent] && !tempAgents.has(item.agent);
4042
+ if (isUnknownAssignedAgent) {
4043
+ const fallback = resolvePendingDispatchAgent(item, config);
4044
+ if (!fallback) {
4045
+ log('warn', `Pending dispatch ${item.id} has unknown agent ${item.agent} and no fallback available — skipping`);
4046
+ continue;
4047
+ }
4048
+ log('info', `Pending dispatch ${item.id} unknown agent ${item.agent}; routed → ${fallback}`);
4049
+ assignPendingDispatchAgent(item, fallback, config);
4050
+ persistPendingDispatchAgent(item);
4051
+ }
4052
+ // #1204: Pre-assigned unspawned temp agents never unblock naturally.
4028
4053
  // When a batch discovery saturates maxConcurrent, resolveAgent hands out temp
4029
4054
  // IDs that get stamped onto pending items. Because those temp IDs are never
4030
4055
  // in busyAgents (they were never spawned), the agent-busy reassignment path
@@ -4042,43 +4067,28 @@ async function tickInner() {
4042
4067
  }
4043
4068
  }
4044
4069
  if (busyAgents.has(item.agent)) {
4045
- // Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
4046
- // try to find an alternative agent via routing. Skip explicitly assigned items.
4047
- const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
4048
- const isHardPinned = routing.isAgentHardPinned(item.meta?.item);
4049
- if (isSoftFixDispatch(item)) {
4050
- const originalAgent = item.agent;
4051
- const altAgent = resolvePendingDispatchAgent(item, config);
4052
- if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
4053
- log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent}soft fix suggestion unavailable`);
4054
- assignPendingDispatchAgent(item, altAgent, config);
4055
- persistPendingDispatchAgent(item);
4056
- // Fall through to branch mutex / concurrency checks below.
4057
- } else {
4058
- log('info', `Clearing busy soft fix agent on ${item.id} (${originalAgent}) — waiting for any available agent`);
4059
- clearPendingDispatchAgent(item);
4060
- persistPendingDispatchAgent(item);
4061
- continue;
4062
- }
4063
- } else if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
4064
- const busySinceMs = new Date(item._agentBusySince).getTime();
4065
- if (Date.now() - busySinceMs > reassignMs) {
4066
- const originalAgent = item.agent;
4067
- const altAgent = resolvePendingDispatchAgent(item, config);
4068
- if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
4069
- log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy > ${reassignMs}ms`);
4070
- assignPendingDispatchAgent(item, altAgent, config);
4071
- // Persist reassignment to dispatch.json
4072
- persistPendingDispatchAgent(item);
4073
- // Fall through to branch mutex / concurrency checks below
4074
- } else {
4075
- continue; // No alternative agent available — keep waiting
4076
- }
4077
- } else {
4078
- continue; // Below threshold — keep waiting
4079
- }
4070
+ // Agent busy reassignment: compute hard-pin status; if hard-pinned keep waiting.
4071
+ // For all non-hard-pinned items, reroute immediately (no threshold wait).
4072
+ const originalAgent = item.agent;
4073
+ const hardPinnedAgent = routing.getHardPinnedAgent(item.meta?.item, config.agents || {});
4074
+ const isHardPinned = !!hardPinnedAgent && hardPinnedAgent === originalAgent;
4075
+ if (isHardPinned) {
4076
+ continue; // Valid hard pin — keep waiting for pinned agent
4077
+ }
4078
+ // agent busy and idle alternative availablereroute immediately (no threshold)
4079
+ const altAgent = resolvePendingDispatchAgent(item, config);
4080
+ if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
4081
+ log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} agent busy and idle alternative available`);
4082
+ assignPendingDispatchAgent(item, altAgent, config);
4083
+ persistPendingDispatchAgent(item);
4084
+ // Fall through to branch mutex / concurrency checks below
4085
+ } else if (isSoftFixDispatch(item)) {
4086
+ log('info', `Clearing busy soft fix agent on ${item.id} (${originalAgent}) — waiting for any available agent`);
4087
+ clearPendingDispatchAgent(item);
4088
+ persistPendingDispatchAgent(item);
4089
+ continue;
4080
4090
  } else {
4081
- continue; // No _agentBusySince set yet or explicitly assigned — skip
4091
+ continue; // No alternative agent available keep waiting
4082
4092
  }
4083
4093
  }
4084
4094
  // Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
@@ -4215,7 +4225,7 @@ module.exports = {
4215
4225
  spawnAgent, resolveAgent,
4216
4226
 
4217
4227
  // Discovery
4218
- discoverWork, discoverFromPrs, discoverFromWorkItems,
4228
+ discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
4219
4229
  materializePlansAsWorkItems,
4220
4230
 
4221
4231
  // Shared helpers (used by lifecycle.js and tests)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1666",
3
+ "version": "0.1.1668",
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"