@yemi33/minions 0.1.1666 → 0.1.1667

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1667 (2026-05-01)
4
+
5
+ ### Other
6
+ - Harden PR attachment completion handling
7
+
3
8
  ## 0.1.1666 (2026-05-01)
4
9
 
5
10
  ### 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-01T21:02:29.699Z"
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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1666",
3
+ "version": "0.1.1667",
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"