@yemi33/minions 0.1.412 → 0.1.413

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,8 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.412 (2026-04-06)
3
+ ## 0.1.413 (2026-04-06)
4
4
 
5
5
  ### Fixes
6
+ - auto-recover timed-out agents that created PRs before dying
6
7
  - resolve npm path from Node binary dir — not PATH
7
8
 
8
9
  ## 0.1.411 (2026-04-06)
@@ -923,14 +923,17 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config) {
923
923
  const proj = shared.getProjects(config).find(p => p.name === project);
924
924
  if (proj) {
925
925
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
926
- const items = safeJson(centralPath) || [];
927
- const alreadyExists = items.some(i => i.title === `Add skill: ${name}` && i.status !== WI_STATUS.FAILED);
928
- if (!alreadyExists) {
929
- const skillId = `SK${String(items.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
930
- items.push({ id: skillId, type: 'implement', title: `Add skill: ${name}`,
926
+ let skillId = null;
927
+ mutateJsonFileLocked(centralPath, data => {
928
+ data = data || [];
929
+ if (data.some(i => i.title === `Add skill: ${name}` && i.status !== WI_STATUS.FAILED)) return data;
930
+ skillId = `SK${String(data.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
931
+ data.push({ id: skillId, type: 'implement', title: `Add skill: ${name}`,
931
932
  description: `Create project-level skill \`${filename}\` in ${project}.\n\nWrite this file to \`${proj.localPath}/.claude/skills/${filename}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
932
933
  priority: 'low', status: WI_STATUS.QUEUED, created: ts(), createdBy: `engine:skill-extraction:${agentName}` });
933
- shared.safeWrite(centralPath, items);
934
+ return data;
935
+ });
936
+ if (skillId) {
934
937
  log('info', `Queued work item ${skillId} to PR project skill "${name}" into ${project}`);
935
938
  }
936
939
  }
@@ -1006,49 +1009,50 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config) {
1006
1009
  function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount, model) {
1007
1010
 
1008
1011
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
1009
- const metrics = safeJson(metricsPath) || {};
1010
- if (!metrics[agentId]) {
1011
- metrics[agentId] = { tasksCompleted: 0, tasksErrored: 0, prsCreated: 0, prsApproved: 0, prsRejected: 0,
1012
- reviewsDone: 0, lastTask: null, lastCompleted: null, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCacheRead: 0 };
1013
- }
1014
- const m = metrics[agentId];
1015
- m.lastTask = dispatchItem.task;
1016
- m.lastCompleted = ts();
1017
- if (model) m.model = model;
1018
- if (result === DISPATCH_RESULT.SUCCESS) {
1019
- m.tasksCompleted++;
1020
- if (prsCreatedCount > 0) m.prsCreated = (m.prsCreated || 0) + prsCreatedCount;
1021
- if (dispatchItem.type === WORK_TYPE.REVIEW) m.reviewsDone++;
1022
- } else if (result === 'retry') {
1023
- // Auto-retry: count cost but not as a final outcome
1024
- m.tasksRetried = (m.tasksRetried || 0) + 1;
1025
- } else {
1026
- m.tasksErrored++;
1027
- }
1028
- if (taskUsage) {
1029
- m.totalCostUsd = (m.totalCostUsd || 0) + (taskUsage.costUsd || 0);
1030
- m.totalInputTokens = (m.totalInputTokens || 0) + (taskUsage.inputTokens || 0);
1031
- m.totalOutputTokens = (m.totalOutputTokens || 0) + (taskUsage.outputTokens || 0);
1032
- m.totalCacheRead = (m.totalCacheRead || 0) + (taskUsage.cacheRead || 0);
1033
- }
1034
- const today = dateStamp();
1035
- if (!metrics._daily) metrics._daily = {};
1036
- if (!metrics._daily[today]) metrics._daily[today] = { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, tasks: 0 };
1037
- const daily = metrics._daily[today];
1038
- daily.tasks++;
1039
- if (taskUsage) {
1040
- daily.costUsd += taskUsage.costUsd || 0;
1041
- daily.inputTokens += taskUsage.inputTokens || 0;
1042
- daily.outputTokens += taskUsage.outputTokens || 0;
1043
- daily.cacheRead += taskUsage.cacheRead || 0;
1044
- }
1045
- const cutoff = new Date();
1046
- cutoff.setDate(cutoff.getDate() - 30);
1047
- const cutoffStr = cutoff.toISOString().slice(0, 10);
1048
- for (const day of Object.keys(metrics._daily)) {
1049
- if (day < cutoffStr) delete metrics._daily[day];
1050
- }
1051
- shared.safeWrite(metricsPath, metrics);
1012
+ mutateJsonFileLocked(metricsPath, metrics => {
1013
+ metrics = metrics || {};
1014
+ if (!metrics[agentId]) {
1015
+ metrics[agentId] = { tasksCompleted: 0, tasksErrored: 0, prsCreated: 0, prsApproved: 0, prsRejected: 0,
1016
+ reviewsDone: 0, lastTask: null, lastCompleted: null, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCacheRead: 0 };
1017
+ }
1018
+ const m = metrics[agentId];
1019
+ m.lastTask = dispatchItem.task;
1020
+ m.lastCompleted = ts();
1021
+ if (model) m.model = model;
1022
+ if (result === DISPATCH_RESULT.SUCCESS) {
1023
+ m.tasksCompleted++;
1024
+ if (prsCreatedCount > 0) m.prsCreated = (m.prsCreated || 0) + prsCreatedCount;
1025
+ if (dispatchItem.type === WORK_TYPE.REVIEW) m.reviewsDone++;
1026
+ } else if (result === 'retry') {
1027
+ m.tasksRetried = (m.tasksRetried || 0) + 1;
1028
+ } else {
1029
+ m.tasksErrored++;
1030
+ }
1031
+ if (taskUsage) {
1032
+ m.totalCostUsd = (m.totalCostUsd || 0) + (taskUsage.costUsd || 0);
1033
+ m.totalInputTokens = (m.totalInputTokens || 0) + (taskUsage.inputTokens || 0);
1034
+ m.totalOutputTokens = (m.totalOutputTokens || 0) + (taskUsage.outputTokens || 0);
1035
+ m.totalCacheRead = (m.totalCacheRead || 0) + (taskUsage.cacheRead || 0);
1036
+ }
1037
+ const today = dateStamp();
1038
+ if (!metrics._daily) metrics._daily = {};
1039
+ if (!metrics._daily[today]) metrics._daily[today] = { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, tasks: 0 };
1040
+ const daily = metrics._daily[today];
1041
+ daily.tasks++;
1042
+ if (taskUsage) {
1043
+ daily.costUsd += taskUsage.costUsd || 0;
1044
+ daily.inputTokens += taskUsage.inputTokens || 0;
1045
+ daily.outputTokens += taskUsage.outputTokens || 0;
1046
+ daily.cacheRead += taskUsage.cacheRead || 0;
1047
+ }
1048
+ const cutoff = new Date();
1049
+ cutoff.setDate(cutoff.getDate() - 30);
1050
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
1051
+ for (const day of Object.keys(metrics._daily)) {
1052
+ if (day < cutoffStr) delete metrics._daily[day];
1053
+ }
1054
+ return metrics;
1055
+ });
1052
1056
  }
1053
1057
 
1054
1058
  // ─── Agent Output Parsing ────────────────────────────────────────────────────
@@ -1095,38 +1099,42 @@ function handleDecompositionResult(stdout, meta, config) {
1095
1099
  for (const p of projects) allPaths.push(shared.projectWorkItemsPath(p));
1096
1100
 
1097
1101
  for (const wiPath of allPaths) {
1098
- const items = safeJson(wiPath) || [];
1099
- const parent = items.find(i => i.id === parentId);
1100
- if (!parent) continue;
1101
-
1102
- // Mark parent as decomposed
1103
- parent.status = WI_STATUS.DECOMPOSED;
1104
- parent._decomposed = true;
1105
- delete parent._decomposing;
1106
- parent._subItemIds = subItems.map(s => s.id);
1107
-
1108
- // Create child work items
1109
- for (const sub of subItems) {
1110
- if (items.some(i => i.id === sub.id)) continue; // dedupe
1111
- items.push({
1112
- id: sub.id,
1113
- title: sub.name || sub.title || `Sub-task of ${parentId}`,
1114
- type: (sub.estimated_complexity === 'large') ? 'implement:large' : 'implement',
1115
- priority: sub.priority || parent.priority || 'medium',
1116
- description: sub.description || '',
1117
- status: WI_STATUS.PENDING,
1118
- complexity: sub.estimated_complexity || 'medium',
1119
- depends_on: sub.depends_on || [],
1120
- parent_id: parentId,
1121
- sourcePlan: parent.sourcePlan,
1122
- branchStrategy: parent.branchStrategy,
1123
- featureBranch: parent.featureBranch,
1124
- created: new Date().toISOString(),
1125
- createdBy: 'decomposition',
1126
- });
1127
- }
1128
-
1129
- safeWrite(wiPath, items);
1102
+ let found = false;
1103
+ mutateJsonFileLocked(wiPath, data => {
1104
+ if (!Array.isArray(data)) return data;
1105
+ const p = data.find(i => i.id === parentId);
1106
+ if (!p) return data;
1107
+ found = true;
1108
+
1109
+ // Mark parent as decomposed
1110
+ p.status = WI_STATUS.DECOMPOSED;
1111
+ p._decomposed = true;
1112
+ delete p._decomposing;
1113
+ p._subItemIds = subItems.map(s => s.id);
1114
+
1115
+ // Create child work items
1116
+ for (const sub of subItems) {
1117
+ if (data.some(i => i.id === sub.id)) continue; // dedupe
1118
+ data.push({
1119
+ id: sub.id,
1120
+ title: sub.name || sub.title || `Sub-task of ${parentId}`,
1121
+ type: (sub.estimated_complexity === 'large') ? 'implement:large' : 'implement',
1122
+ priority: sub.priority || p.priority || 'medium',
1123
+ description: sub.description || '',
1124
+ status: WI_STATUS.PENDING,
1125
+ complexity: sub.estimated_complexity || 'medium',
1126
+ depends_on: sub.depends_on || [],
1127
+ parent_id: parentId,
1128
+ sourcePlan: p.sourcePlan,
1129
+ branchStrategy: p.branchStrategy,
1130
+ featureBranch: p.featureBranch,
1131
+ created: new Date().toISOString(),
1132
+ createdBy: 'decomposition',
1133
+ });
1134
+ }
1135
+ return data;
1136
+ });
1137
+ if (!found) continue;
1130
1138
  log('info', `Decomposition: ${parentId} → ${subItems.length} sub-items: ${subItems.map(s => s.id).join(', ')}`);
1131
1139
  return subItems.length;
1132
1140
  }
@@ -1152,25 +1160,37 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1152
1160
  } catch (err) { log('warn', `Session save: ${err.message}`); }
1153
1161
  }
1154
1162
 
1163
+ // Always attempt PR sync — even failed/timed-out agents may have created PRs before dying
1164
+ let prsCreatedCount = 0;
1165
+ try {
1166
+ prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
1167
+ } catch (err) { log('warn', `PR sync from output: ${err.message}`); }
1168
+
1169
+ // Auto-recover: if a failed implement/fix agent created PRs, it likely succeeded before being killed (e.g. heartbeat timeout)
1170
+ const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX;
1171
+ const autoRecovered = !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
1172
+ if (autoRecovered) {
1173
+ log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
1174
+ }
1175
+ const effectiveSuccess = isSuccess || autoRecovered;
1176
+
1155
1177
  // Handle decomposition results — create sub-items from decompose agent output
1156
1178
  let skipDoneStatus = false;
1157
- if (type === WORK_TYPE.DECOMPOSE && isSuccess && meta?.item?.id) {
1179
+ if (type === WORK_TYPE.DECOMPOSE && effectiveSuccess && meta?.item?.id) {
1158
1180
  const subCount = handleDecompositionResult(stdout, meta, config);
1159
1181
  if (subCount > 0) skipDoneStatus = true; // parent already marked 'decomposed' by handler
1160
1182
  // If decomposition produced nothing, fall through to mark parent as done
1161
1183
  }
1162
1184
 
1163
- if (isSuccess && meta?.item?.id && !skipDoneStatus) {
1185
+ if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
1164
1186
  meta._agentId = agentId;
1165
1187
  updateWorkItemStatus(meta, WI_STATUS.DONE, '');
1166
1188
  }
1167
- if (!isSuccess && meta?.item?.id) {
1189
+ if (!effectiveSuccess && meta?.item?.id) {
1168
1190
  // Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
1169
1191
  let retries = (meta.item._retryCount || 0);
1192
+ const wiPath = resolveWorkItemPath(meta);
1170
1193
  try {
1171
- const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1172
- ? path.join(MINIONS_DIR, 'work-items.json')
1173
- : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1174
1194
  if (wiPath) {
1175
1195
  const items = safeJson(wiPath) || [];
1176
1196
  const wi = items.find(i => i.id === meta.item.id);
@@ -1182,22 +1202,20 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1182
1202
  log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/${ENGINE_DEFAULTS.maxRetries}`);
1183
1203
  updateWorkItemStatus(meta, WI_STATUS.PENDING, '');
1184
1204
  try {
1185
- const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1186
- ? path.join(MINIONS_DIR, 'work-items.json')
1187
- : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1188
1205
  if (wiPath) {
1189
- const items = safeJson(wiPath) || [];
1190
- const wi = items.find(i => i.id === meta.item.id);
1191
- if (wi) {
1206
+ mutateJsonFileLocked(wiPath, data => {
1207
+ if (!Array.isArray(data)) return data;
1208
+ const wi = data.find(i => i.id === meta.item.id);
1209
+ if (!wi) return data;
1192
1210
  // Don't revert if already completed by another code path
1193
1211
  if (wi.status === WI_STATUS.DONE || wi.completedAt) {
1194
1212
  log('info', `Skip retry for ${meta.item.id} — already completed`);
1195
- } else {
1196
- wi._retryCount = retries + 1; wi.status = WI_STATUS.PENDING; delete wi.dispatched_at; delete wi.dispatched_to; delete wi.failReason;
1197
- if (type === WORK_TYPE.DECOMPOSE) delete wi._decomposing;
1198
- shared.safeWrite(wiPath, items);
1213
+ return data;
1199
1214
  }
1200
- }
1215
+ wi._retryCount = retries + 1; wi.status = WI_STATUS.PENDING; delete wi.dispatched_at; delete wi.dispatched_to; delete wi.failReason;
1216
+ if (type === WORK_TYPE.DECOMPOSE) delete wi._decomposing;
1217
+ return data;
1218
+ });
1201
1219
  }
1202
1220
  } catch (err) { log('warn', `Retry update: ${err.message}`); }
1203
1221
  } else {
@@ -1206,13 +1224,13 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1206
1224
  // Clear _decomposing flag on failure so item doesn't get permanently stuck
1207
1225
  if (type === WORK_TYPE.DECOMPOSE) {
1208
1226
  try {
1209
- const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
1210
- ? path.join(MINIONS_DIR, 'work-items.json')
1211
- : meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
1212
1227
  if (wiPath) {
1213
- const items = safeJson(wiPath) || [];
1214
- const wi = items.find(i => i.id === meta.item.id);
1215
- if (wi) { delete wi._decomposing; shared.safeWrite(wiPath, items); }
1228
+ mutateJsonFileLocked(wiPath, data => {
1229
+ if (!Array.isArray(data)) return data;
1230
+ const wi = data.find(i => i.id === meta.item.id);
1231
+ if (wi) delete wi._decomposing;
1232
+ return data;
1233
+ });
1216
1234
  }
1217
1235
  } catch (err) { log('warn', `Decompose cleanup: ${err.message}`); }
1218
1236
  }
@@ -1226,13 +1244,10 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1226
1244
  }
1227
1245
 
1228
1246
  // Plan chaining removed — user must explicitly execute plan-to-prd after reviewing the plan
1229
- if (isSuccess && meta?.item?.sourcePlan) checkPlanCompletion(meta, config);
1230
-
1231
- let prsCreatedCount = 0;
1232
- if (isSuccess) prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
1247
+ if (effectiveSuccess && meta?.item?.sourcePlan) checkPlanCompletion(meta, config);
1233
1248
 
1234
1249
  // After verify completes, archive the plan
1235
- if (isSuccess && meta?.item?.itemType === 'verify' && meta?.item?.sourcePlan) {
1250
+ if (effectiveSuccess && meta?.item?.itemType === 'verify' && meta?.item?.sourcePlan) {
1236
1251
  try {
1237
1252
  const vPlanFile = meta.item.sourcePlan;
1238
1253
  const vPlanPath = path.join(PRD_DIR, vPlanFile);
@@ -1280,7 +1295,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1280
1295
  }
1281
1296
 
1282
1297
  // Detect implement tasks that completed without creating a PR
1283
- if (isSuccess && (type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX) && prsCreatedCount === 0 && meta?.item?.id && !meta?.item?.skipPr && meta?.project?.localPath) {
1298
+ if (effectiveSuccess && (type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX) && prsCreatedCount === 0 && meta?.item?.id && !meta?.item?.skipPr && meta?.project?.localPath) {
1284
1299
  // Check if a PR already exists linked to this work item (from a previous attempt)
1285
1300
  let existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
1286
1301
  // Also check pull-requests.json for PRs with matching prdItems or branch
@@ -1295,44 +1310,44 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1295
1310
  }
1296
1311
  }
1297
1312
  if (!existingPrFound) {
1298
- let wiPath;
1299
- if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
1300
- wiPath = path.join(MINIONS_DIR, 'work-items.json');
1301
- } else if (meta.project?.localPath) {
1302
- wiPath = shared.projectWorkItemsPath(meta.project);
1303
- }
1304
- if (wiPath) {
1305
- const items = safeJson(wiPath) || [];
1306
- const wi = items.find(i => i.id === meta.item.id);
1307
- if (wi) {
1308
- const retries = wi._retryCount || 0;
1309
- // Check if agent produced meaningful output (not just MCP timeout)
1310
- const hasOutput = stdout && stdout.length > 500;
1313
+ const noPrWiPath = resolveWorkItemPath(meta);
1314
+ if (noPrWiPath) {
1315
+ const hasOutput = stdout && stdout.length > 500;
1316
+ let action = null;
1317
+ mutateJsonFileLocked(noPrWiPath, data => {
1318
+ if (!Array.isArray(data)) return data;
1319
+ const w = data.find(i => i.id === meta.item.id);
1320
+ if (!w) return data;
1321
+ const retries = w._retryCount || 0;
1311
1322
  if (!hasOutput && retries < ENGINE_DEFAULTS.maxRetries) {
1312
- // No meaningful output — likely MCP stall or startup failure, retry
1313
- wi.status = WI_STATUS.PENDING;
1314
- wi._retryCount = retries + 1;
1315
- delete wi.dispatched_at;
1316
- delete wi.dispatched_to;
1317
- delete wi.failReason;
1318
- delete wi.noPr;
1319
- log('info', `Auto-retry ${retries + 1}/${ENGINE_DEFAULTS.maxRetries} for ${meta.item.id} (no output, no PR)`);
1323
+ w.status = WI_STATUS.PENDING;
1324
+ w._retryCount = retries + 1;
1325
+ delete w.dispatched_at;
1326
+ delete w.dispatched_to;
1327
+ delete w.failReason;
1328
+ delete w.noPr;
1329
+ action = { type: 'retry', retries: retries + 1 };
1320
1330
  } else if (hasOutput) {
1321
- // Agent ran successfully but chose not to create a PR — mark done
1322
- wi.status = WI_STATUS.DONE;
1323
- wi.completedAt = ts();
1324
- wi._noPr = true;
1325
- wi._noPrReason = 'Agent completed without creating a PR (changes may already exist or not be needed)';
1326
- delete wi.failReason;
1327
- log('info', `${meta.item.id} completed without PR — marking done (agent produced output)`);
1331
+ w.status = WI_STATUS.DONE;
1332
+ w.completedAt = ts();
1333
+ w._noPr = true;
1334
+ w._noPrReason = 'Agent completed without creating a PR (changes may already exist or not be needed)';
1335
+ delete w.failReason;
1336
+ action = { type: 'done' };
1328
1337
  } else {
1329
- // No output after max retries — mark as needs review
1330
- wi.status = WI_STATUS.NEEDS_REVIEW;
1331
- wi._noPr = true;
1332
- wi.failReason = 'Completed without output or PR after ' + ENGINE_DEFAULTS.maxRetries + ' attempts';
1333
- log('warn', `${meta.item.id} needs review — no output after ${ENGINE_DEFAULTS.maxRetries} retries`);
1338
+ w.status = WI_STATUS.NEEDS_REVIEW;
1339
+ w._noPr = true;
1340
+ w.failReason = 'Completed without output or PR after ' + ENGINE_DEFAULTS.maxRetries + ' attempts';
1341
+ action = { type: 'needs-review' };
1334
1342
  }
1335
- shared.safeWrite(wiPath, items);
1343
+ return data;
1344
+ });
1345
+ if (action?.type === 'retry') {
1346
+ log('info', `Auto-retry ${action.retries}/${ENGINE_DEFAULTS.maxRetries} for ${meta.item.id} (no output, no PR)`);
1347
+ } else if (action?.type === 'done') {
1348
+ log('info', `${meta.item.id} completed without PR — marking done (agent produced output)`);
1349
+ } else if (action?.type === 'needs-review') {
1350
+ log('warn', `${meta.item.id} needs review — no output after ${ENGINE_DEFAULTS.maxRetries} retries`);
1336
1351
  }
1337
1352
  }
1338
1353
  }
@@ -1341,14 +1356,15 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1341
1356
  if (type === WORK_TYPE.REVIEW) updatePrAfterReview(agentId, meta?.pr, meta?.project);
1342
1357
  if (type === WORK_TYPE.FIX) updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
1343
1358
  checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
1344
- if (isSuccess) extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
1345
- updateAgentHistory(agentId, dispatchItem, result);
1359
+ if (effectiveSuccess) extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
1360
+ const finalResult = effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
1361
+ updateAgentHistory(agentId, dispatchItem, finalResult);
1346
1362
  // Don't count auto-retries as errors in metrics — only count final outcomes
1347
- const isAutoRetry = !isSuccess && meta?.item?.id && (meta.item._retryCount || 0) < ENGINE_DEFAULTS.maxRetries;
1348
- const metricsResult = isAutoRetry ? 'retry' : result;
1363
+ const isAutoRetry = !effectiveSuccess && meta?.item?.id && (meta.item._retryCount || 0) < ENGINE_DEFAULTS.maxRetries;
1364
+ const metricsResult = isAutoRetry ? 'retry' : finalResult;
1349
1365
  updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
1350
1366
 
1351
- return { resultSummary, taskUsage };
1367
+ return { resultSummary, taskUsage, autoRecovered };
1352
1368
  }
1353
1369
 
1354
1370
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
@@ -1357,25 +1373,28 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1357
1373
  // (e.g., manually raised PRs, cross-plan PRs, or PRs created while engine was paused).
1358
1374
  function syncPrdFromPrs(config) {
1359
1375
  try {
1360
- const { getProjects, projectWorkItemsPath, projectPrPath, safeJson, safeWrite } = require('./shared');
1361
1376
  const { reconcileItemsWithPrs } = require('../engine');
1362
1377
  config = config || queries.getConfig();
1363
- const allProjects = getProjects(config);
1378
+ const allProjects = shared.getProjects(config);
1364
1379
 
1365
1380
  // Exact prdItems match only — no fuzzy matching
1366
- const allPrs = allProjects.flatMap(p => safeJson(projectPrPath(p)) || []);
1381
+ const allPrs = allProjects.flatMap(p => safeJson(shared.projectPrPath(p)) || []);
1367
1382
 
1368
1383
  let totalReconciled = 0;
1369
1384
  for (const project of allProjects) {
1370
- const wiPath = projectWorkItemsPath(project);
1385
+ const wiPath = shared.projectWorkItemsPath(project);
1371
1386
  const items = safeJson(wiPath) || [];
1372
1387
  const hasPending = items.some(wi => wi.status === WI_STATUS.PENDING && !wi._pr);
1373
1388
  if (!hasPending) continue;
1374
- const reconciled = reconcileItemsWithPrs(items, allPrs);
1389
+ let reconciled = 0;
1390
+ const reconciledItems = mutateJsonFileLocked(wiPath, data => {
1391
+ if (!Array.isArray(data)) return data;
1392
+ reconciled = reconcileItemsWithPrs(data, allPrs);
1393
+ return data;
1394
+ });
1375
1395
  if (reconciled > 0) {
1376
- safeWrite(wiPath, items);
1377
1396
  // Sync done status to PRD JSON for each newly reconciled item
1378
- for (const wi of items) {
1397
+ for (const wi of (reconciledItems || [])) {
1379
1398
  if (wi.status === WI_STATUS.DONE) syncPrdItemStatus(wi.id, WI_STATUS.DONE, wi.sourcePlan);
1380
1399
  }
1381
1400
  totalReconciled += reconciled;
package/engine/meeting.js CHANGED
@@ -234,11 +234,10 @@ function collectMeetingFindings(meetingId, agentId, roundName, output) {
234
234
  }
235
235
 
236
236
  // Check if all participants have submitted for this round
237
- const allSubmitted = meeting.participants.every(p => {
238
- if (meeting.status === 'investigating') return !!meeting.findings[p];
239
- if (meeting.status === 'debating') return !!meeting.debate[p];
240
- return true;
241
- });
237
+ const participantCount = meeting.participants.length;
238
+ const allSubmitted =
239
+ (meeting.status === 'investigating' && Object.keys(meeting.findings || {}).length >= participantCount) ||
240
+ (meeting.status === 'debating' && Object.keys(meeting.debate || {}).length >= participantCount);
242
241
 
243
242
  if (allSubmitted) {
244
243
  // Advance to next round
package/engine/queries.js CHANGED
@@ -648,13 +648,13 @@ function getPrdInfo(config) {
648
648
  for (const project of projects) {
649
649
  try {
650
650
  const workItems = safeJson(projectWorkItemsPath(project)) || [];
651
- for (const wi of workItems) { if (!wi.id) { console.warn(`[queries] Skipping work item without id in ${project.name}:`, JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan) wiById[wi.id] = wi; }
651
+ for (const wi of workItems) { if (!wi?.id) { console.warn(`[queries] Skipping work item without id in ${project.name}:`, JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan) wiById[wi.id] = wi; }
652
652
  } catch { /* optional */ }
653
653
  }
654
654
  // Also check central work-items.json
655
655
  try {
656
656
  const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
657
- for (const wi of centralWi) { if (!wi.id) { console.warn('[queries] Skipping central work item without id:', JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi; }
657
+ for (const wi of centralWi) { if (!wi?.id) { console.warn('[queries] Skipping central work item without id:', JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi; }
658
658
  } catch { /* optional */ }
659
659
 
660
660
  // PR-to-PRD linking — derived from PR.prdItems (single source of truth)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.412",
3
+ "version": "0.1.413",
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"