@yemi33/minions 0.1.1002 → 0.1.1004

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,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1002 (2026-04-15)
3
+ ## 0.1.1004 (2026-04-15)
4
4
 
5
5
  ### Features
6
6
  - fix stopAfter=0 watch expiry — run forever for all conditions (#1117)
@@ -25,6 +25,7 @@
25
25
  - gate auto-fix conflict dispatch behind autoFixConflicts flag
26
26
 
27
27
  ### Fixes
28
+ - only gate reviews/fixes when no free agent slots remain
28
29
  - extend auto-link fallback to ADO projects
29
30
  - auto-link existing GitHub PR when agent completes without creating one
30
31
  - preserve ordered lists across blank lines in markdown renderer
@@ -44,9 +45,9 @@
44
45
  - move review verdict check before updateWorkItemStatus(DONE)
45
46
  - address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
46
47
  - fix watches feature gaps — human notifications, branch stub, status-change init, unique keys
47
- - skip isAlreadyDispatched in needsReReview to allow re-review within 1hr
48
48
 
49
49
  ### Other
50
+ - refactor: migrate PR links to array format and consolidate shared helpers
50
51
  - docs: update CLAUDE.md with recent Minions architecture changes
51
52
  - chore: stop tracking runtime pipeline state
52
53
  - chore: strengthen prompts and routing
package/dashboard.js CHANGED
@@ -2533,7 +2533,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2533
2533
  const prLinks = shared.getPrLinks();
2534
2534
  const implContext = (plan.missing_features || []).map(f => {
2535
2535
  const wi = planWis.find(w => w.id === f.id);
2536
- const pr = allPrs.find(p => prLinks[p.id] === f.id || (p.prdItems || []).includes(f.id));
2536
+ const pr = allPrs.find(p => (prLinks[p.id] || []).includes(f.id) || (p.prdItems || []).includes(f.id));
2537
2537
  return `- **${f.id}**: ${f.name} [status: ${wi?.status || f.status}]${pr ? ` (PR: ${pr.id}, branch: \`${pr.branch}\`)` : ''}`;
2538
2538
  }).join('\n');
2539
2539
 
package/engine/ado.js CHANGED
@@ -17,6 +17,11 @@ function engine() {
17
17
  }
18
18
 
19
19
  const stripRefsHeads = s => (s || '').replace('refs/heads/', '');
20
+ const getAdoPrUrl = (project, prNumber) => {
21
+ if (project.prUrlBase) return `${project.prUrlBase}${prNumber}`;
22
+ const repoPath = encodeURIComponent(project.repoName || project.repositoryId || '');
23
+ return `https://dev.azure.com/${project.adoOrg}/${project.adoProject}/_git/${repoPath}/pullrequest/${prNumber}`;
24
+ };
20
25
 
21
26
  // ── Build/Review Status Helpers ───────────────────────────────────────────────
22
27
 
@@ -748,16 +753,7 @@ async function reconcilePrs(config) {
748
753
  }
749
754
 
750
755
  // Backfill prdItems from pr-links for any PR with empty array
751
- const prLinks = shared.getPrLinks();
752
- let backfilled = 0;
753
- for (const pr of existingPrs) {
754
- const linked = prLinks[pr.id];
755
- if (linked && !(pr.prdItems || []).includes(linked)) {
756
- pr.prdItems = Array.isArray(pr.prdItems) ? pr.prdItems : [];
757
- pr.prdItems.push(linked);
758
- backfilled++;
759
- }
760
- }
756
+ const backfilled = shared.backfillPrPrdItems(existingPrs, shared.getPrLinks());
761
757
 
762
758
  if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0) {
763
759
  mutateJsonFileLocked(prPath, (currentPrs) => {
@@ -865,7 +861,7 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
865
861
  }
866
862
 
867
863
  const votes = (prData.reviewers || []).map(r => r.vote);
868
- const prUrl = `https://dev.azure.com/${project.adoOrg}/${project.adoProject}/_git/${project.repositoryId}/pullrequest/${prNumber}`;
864
+ const prUrl = getAdoPrUrl(project, prNumber);
869
865
 
870
866
  return {
871
867
  prNumber,
@@ -900,6 +896,10 @@ const getAdoThrottleState = () => _adoThrottle.getState();
900
896
  */
901
897
  async function findOpenPrOnBranch(project, branch) {
902
898
  if (!project.adoOrg || !project.adoProject || !project.repositoryId || !branch) return null;
899
+ if (isAdoThrottled()) {
900
+ log('debug', `[ado] Skipping branch PR lookup for ${project.name || project.repoName || 'unknown project'}:${branch} — throttled`);
901
+ return null;
902
+ }
903
903
  const token = await getAdoToken();
904
904
  if (!token) return null;
905
905
  const orgBase = shared.getAdoOrgBase(project);
@@ -909,7 +909,7 @@ async function findOpenPrOnBranch(project, branch) {
909
909
  const pr = (data.value || [])[0];
910
910
  if (!pr) return null;
911
911
  const prNumber = pr.pullRequestId;
912
- const prUrl = project.prUrlBase ? `${project.prUrlBase}${prNumber}` : `https://dev.azure.com/${project.adoOrg}/${project.adoProject}/_git/${project.repositoryId}/pullrequest/${prNumber}`;
912
+ const prUrl = getAdoPrUrl(project, prNumber);
913
913
  return { prNumber, url: prUrl };
914
914
  }
915
915
 
@@ -940,4 +940,3 @@ module.exports = {
940
940
  _resetAdoThrottle, // exported for testing
941
941
  _setAdoThrottleForTest, // exported for testing
942
942
  };
943
-
package/engine/github.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker } = shared;
8
+ const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const path = require('path');
11
11
 
@@ -691,16 +691,7 @@ async function reconcilePrs(config) {
691
691
  }
692
692
 
693
693
  // Backfill prdItems from pr-links for any PR with empty array
694
- const prLinks = getPrLinks();
695
- let backfilled = 0;
696
- for (const pr of currentPrs) {
697
- const linked = prLinks[pr.id];
698
- if (linked && !(pr.prdItems || []).includes(linked)) {
699
- pr.prdItems = Array.isArray(pr.prdItems) ? pr.prdItems : [];
700
- pr.prdItems.push(linked);
701
- backfilled++;
702
- }
703
- }
694
+ const backfilled = backfillPrPrdItems(currentPrs, getPrLinks());
704
695
 
705
696
  if (projectAdded > 0 || backfilled > 0) {
706
697
  mutateJsonFileLocked(prPath, (lockedPrs) => {
@@ -761,4 +752,3 @@ module.exports = {
761
752
  _ghPollBackoff,
762
753
  _ghThrottle, // exported for testing
763
754
  };
764
-
@@ -111,8 +111,8 @@ function checkPlanCompletion(meta, config) {
111
111
  const prs = safeJson(prPath) || [];
112
112
  const prLinks = getPrLinks();
113
113
  for (const pr of prs) {
114
- const linkedItemId = prLinks[pr.id];
115
- if (linkedItemId && doneItems.find(w => w.id === linkedItemId)) {
114
+ const linkedItemIds = prLinks[pr.id] || [];
115
+ if (linkedItemIds.some(itemId => doneItems.find(w => w.id === itemId))) {
116
116
  prsCreated.push(pr);
117
117
  }
118
118
  }
@@ -199,8 +199,8 @@ function checkPlanCompletion(meta, config) {
199
199
  const prLinks = getPrLinks();
200
200
  const prs = (safeJson(shared.projectPrPath(p)) || [])
201
201
  .filter(pr => {
202
- const linkedId = prLinks[pr.id];
203
- return pr.status === PR_STATUS.ACTIVE && linkedId && doneItems.find(w => w.id === linkedId);
202
+ const linkedIds = prLinks[pr.id] || [];
203
+ return pr.status === PR_STATUS.ACTIVE && linkedIds.some(itemId => doneItems.find(w => w.id === itemId));
204
204
  });
205
205
  if (prs.length > 0) {
206
206
  projectPrs[p.name] = { project: p, prs, mainBranch: shared.resolveMainBranch(p.localPath, p.mainBranch) };
@@ -413,8 +413,8 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
413
413
  const prs = safeJson(shared.projectPrPath(p)) || [];
414
414
  const prLinks = getPrLinks();
415
415
  for (const pr of prs) {
416
- const linkedId = prLinks[pr.id];
417
- if (linkedId && planItems.find(w => w.id === linkedId) && pr.branch) {
416
+ const linkedIds = prLinks[pr.id] || [];
417
+ if (linkedIds.some(itemId => planItems.find(w => w.id === itemId)) && pr.branch) {
418
418
  branchSlugs.add(shared.sanitizeBranch(pr.branch).toLowerCase());
419
419
  }
420
420
  }
@@ -1109,13 +1109,14 @@ async function handlePostMerge(pr, project, config, newStatus) {
1109
1109
  if (newStatus !== PR_STATUS.MERGED) return;
1110
1110
 
1111
1111
  // Resolve linked work item from pr-links or PR branch name
1112
- let mergedItemId = getPrLinks()[pr.id];
1113
- if (!mergedItemId && pr.branch) {
1112
+ const mergedItemIds = [...(getPrLinks()[pr.id] || [])];
1113
+ if (mergedItemIds.length === 0 && pr.branch) {
1114
1114
  const branchMatch = pr.branch.match(/(P-[a-z0-9]{6,})/i) || pr.branch.match(/(W-[a-z0-9]{6,})/i) || pr.branch.match(/(PL-[a-z0-9]{6,})/i);
1115
- if (branchMatch) mergedItemId = branchMatch[1];
1115
+ if (branchMatch) mergedItemIds.push(branchMatch[1]);
1116
1116
  }
1117
1117
 
1118
- if (mergedItemId) {
1118
+ if (mergedItemIds.length > 0) {
1119
+ const mergedItemSet = new Set(mergedItemIds);
1119
1120
  // Mark PRD feature as implemented
1120
1121
  const prdDir = path.join(MINIONS_DIR, 'prd');
1121
1122
  try {
@@ -1124,49 +1125,62 @@ async function handlePostMerge(pr, project, config, newStatus) {
1124
1125
  for (const pf of planFiles) {
1125
1126
  const plan = safeJson(path.join(prdDir, pf));
1126
1127
  if (!plan?.missing_features) continue;
1127
- const feature = plan.missing_features.find(f => f.id === mergedItemId);
1128
- if (feature && feature.status !== WI_STATUS.DONE) {
1129
- feature.status = WI_STATUS.DONE;
1128
+ let changed = false;
1129
+ for (const feature of plan.missing_features) {
1130
+ if (mergedItemSet.has(feature.id) && feature.status !== WI_STATUS.DONE) {
1131
+ feature.status = WI_STATUS.DONE;
1132
+ changed = true;
1133
+ updated++;
1134
+ }
1135
+ }
1136
+ if (changed) {
1130
1137
  shared.safeWrite(path.join(prdDir, pf), plan);
1131
- updated++;
1132
1138
  }
1133
1139
  }
1134
- if (updated > 0) log('info', `Post-merge: marked ${mergedItemId} as done for ${pr.id}`);
1140
+ if (updated > 0) log('info', `Post-merge: marked ${mergedItemIds.join(', ')} as done for ${pr.id}`);
1135
1141
  } catch (err) { log('warn', `Post-merge PRD update: ${err.message}`); }
1136
1142
 
1137
1143
  // Mark work item as done
1138
1144
  const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1139
1145
  for (const p of shared.getProjects(config)) wiPaths.push(shared.projectWorkItemsPath(p));
1146
+ const remainingMergedIds = new Set(mergedItemIds);
1140
1147
  for (const wiPath of wiPaths) {
1141
1148
  try {
1142
- let found = false;
1143
1149
  mutateWorkItems(wiPath, items => {
1144
- const item = items.find(i => i.id === mergedItemId);
1145
- if (item && item.status !== WI_STATUS.DONE) {
1146
- log('info', `Post-merge: marking work item ${mergedItemId} as done (was ${item.status}) for ${pr.id}`);
1147
- item.status = WI_STATUS.DONE;
1148
- item.completedAt = ts();
1149
- item._mergedVia = pr.id;
1150
- found = true;
1150
+ for (const item of items) {
1151
+ if (!remainingMergedIds.has(item.id)) continue;
1152
+ if (item.status !== WI_STATUS.DONE) {
1153
+ log('info', `Post-merge: marking work item ${item.id} as done (was ${item.status}) for ${pr.id}`);
1154
+ item.status = WI_STATUS.DONE;
1155
+ item.completedAt = ts();
1156
+ item._mergedVia = pr.id;
1157
+ }
1158
+ remainingMergedIds.delete(item.id);
1151
1159
  }
1152
1160
  });
1153
- if (found) break;
1161
+ if (remainingMergedIds.size === 0) break;
1154
1162
  } catch (err) { log('warn', `Post-merge work item update: ${err.message}`); }
1155
1163
  }
1156
1164
 
1157
1165
  // Rebase dependent PRs onto main now that this dependency is merged
1158
1166
  try {
1159
- const dependentPrs = findDependentActivePrs(mergedItemId, config);
1160
- for (const { pr: depPr, project: depProject } of dependentPrs) {
1161
- if (isBranchActive(depPr.branch)) {
1162
- queuePendingRebase(depPr, depProject, mergedItemId);
1163
- log('info', `Post-merge rebase deferred: ${depPr.branch} locked by active agent`);
1164
- continue;
1165
- }
1166
- const result = await rebaseBranchOntoMain(depPr, depProject, config);
1167
- if (!result.success) {
1168
- shared.writeToInbox('engine', `rebase-fail-${depPr.id}`,
1169
- `# Rebase Failed: ${depPr.id}\n\nBranch \`${depPr.branch}\` could not be rebased onto main after dependency ${mergedItemId} merged.\n\nError: ${result.error}\n\nManual rebase may be needed.`);
1167
+ const rebasedPrs = new Set();
1168
+ for (const mergedItemId of mergedItemIds) {
1169
+ const dependentPrs = findDependentActivePrs(mergedItemId, config);
1170
+ for (const { pr: depPr, project: depProject } of dependentPrs) {
1171
+ const rebaseKey = `${depProject.name}:${depPr.id}`;
1172
+ if (rebasedPrs.has(rebaseKey)) continue;
1173
+ rebasedPrs.add(rebaseKey);
1174
+ if (isBranchActive(depPr.branch)) {
1175
+ queuePendingRebase(depPr, depProject, mergedItemId);
1176
+ log('info', `Post-merge rebase deferred: ${depPr.branch} locked by active agent`);
1177
+ continue;
1178
+ }
1179
+ const result = await rebaseBranchOntoMain(depPr, depProject, config);
1180
+ if (!result.success) {
1181
+ shared.writeToInbox('engine', `rebase-fail-${depPr.id}`,
1182
+ `# Rebase Failed: ${depPr.id}\n\nBranch \`${depPr.branch}\` could not be rebased onto main after dependency ${mergedItemId} merged.\n\nError: ${result.error}\n\nManual rebase may be needed.`);
1183
+ }
1170
1184
  }
1171
1185
  }
1172
1186
  } catch (err) { log('warn', `Post-merge rebase phase error: ${err.message}`); }
@@ -1780,7 +1794,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1780
1794
  // Detect implement tasks that completed without creating a PR
1781
1795
  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) {
1782
1796
  // Check if a PR already exists linked to this work item (from a previous attempt)
1783
- let existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
1797
+ let existingPrFound = Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(meta.item.id));
1784
1798
  // Also check pull-requests.json for PRs with matching prdItems or branch
1785
1799
  if (!existingPrFound) {
1786
1800
  const allProjects = shared.getProjects(config);
@@ -1800,22 +1814,49 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1800
1814
  if (projectObj) {
1801
1815
  try {
1802
1816
  let found = null;
1803
- if (projectObj.repoHost === 'github') {
1817
+ const host = projectObj.repoHost || 'ado';
1818
+ if (host === 'github') {
1804
1819
  const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
1805
1820
  if (ghSlug) {
1806
- const raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
1807
- const hits = JSON.parse(raw || '[]');
1808
- if (hits.length > 0 && hits[0].state === 'OPEN') found = { prNumber: hits[0].number, url: hits[0].url };
1821
+ // Retry up to 3 times newly created PRs can take a few seconds to appear in the API
1822
+ for (let attempt = 0; attempt < 3 && !found; attempt++) {
1823
+ if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
1824
+ let raw = '';
1825
+ try {
1826
+ raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
1827
+ const parsed = JSON.parse(raw || '[]');
1828
+ const hits = Array.isArray(parsed) ? parsed : [];
1829
+ if (hits.length > 0 && hits[0].state === 'OPEN') {
1830
+ found = { prNumber: hits[0].number, url: hits[0].url };
1831
+ } else if (attempt === 2) {
1832
+ log('warn', `Auto-link fallback: no open PR found on branch ${meta.branch} after 3 attempts (raw: ${(raw || '').slice(0, 200)})`);
1833
+ }
1834
+ } catch (err) {
1835
+ if (attempt === 2) {
1836
+ const rawSuffix = raw ? ` (raw: ${raw.slice(0, 200)})` : '';
1837
+ log('warn', `Auto-link fallback: gh pr list lookup failed on branch ${meta.branch} after 3 attempts: ${err.message}${rawSuffix}`);
1838
+ }
1839
+ }
1840
+ }
1809
1841
  }
1810
- } else {
1842
+ } else if (host === 'ado') {
1811
1843
  found = await require('./ado').findOpenPrOnBranch(projectObj, meta.branch);
1844
+ } else {
1845
+ log('debug', `Skipping branch PR lookup for unsupported repo host "${host}" on ${projectObj.name}`);
1812
1846
  }
1813
1847
  if (found) {
1814
1848
  const fullId = `PR-${found.prNumber}`;
1815
1849
  const prPath = shared.projectPrPath(projectObj);
1816
1850
  mutateJsonFileLocked(prPath, prs => {
1817
1851
  if (!Array.isArray(prs)) prs = [];
1818
- if (prs.some(p => p.id === fullId)) return prs;
1852
+ const existingPr = prs.find(p => p.id === fullId);
1853
+ if (existingPr) {
1854
+ if (meta.item?.id) {
1855
+ if (!Array.isArray(existingPr.prdItems)) existingPr.prdItems = [];
1856
+ if (!existingPr.prdItems.includes(meta.item.id)) existingPr.prdItems.push(meta.item.id);
1857
+ }
1858
+ return prs;
1859
+ }
1819
1860
  prs.push({
1820
1861
  id: fullId, prNumber: found.prNumber, title: meta.item?.title || '',
1821
1862
  agent: agentId, branch: meta.branch, reviewStatus: 'pending',
@@ -1825,7 +1866,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1825
1866
  });
1826
1867
  return prs;
1827
1868
  });
1828
- if (meta.item?.id) addPrLink(fullId, meta.item.id);
1829
1869
  log('info', `Auto-linked existing PR ${fullId} on branch ${meta.branch} for ${meta.item?.id}`);
1830
1870
  existingPrFound = true;
1831
1871
  }
@@ -2069,4 +2109,3 @@ module.exports = {
2069
2109
  processPendingRebases,
2070
2110
  findDependentActivePrs,
2071
2111
  };
2072
-
package/engine/queries.js CHANGED
@@ -949,13 +949,15 @@ function getPrdInfo(config) {
949
949
  for (const pr of allPrs) prById[pr.id] = pr;
950
950
 
951
951
  const prdToPr = {};
952
- const prLinks = shared.getPrLinks(); // { "PR-xxxx": "P-xxxx" }
953
- for (const [prId, itemId] of Object.entries(prLinks)) {
952
+ const prLinks = shared.getPrLinks(); // { "PR-xxxx": ["P-xxxx", "P-yyyy"] }
953
+ for (const [prId, itemIds] of Object.entries(prLinks)) {
954
954
  const pr = prById[prId];
955
955
  const project = projects.find(p => p.name === pr?._project) || projects[0] || null;
956
956
  const url = pr?.url || (project?.prUrlBase ? project.prUrlBase + prId.replace('PR-', '') : '');
957
- if (!prdToPr[itemId]) prdToPr[itemId] = [];
958
- prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
957
+ for (const itemId of (itemIds || [])) {
958
+ if (!prdToPr[itemId]) prdToPr[itemId] = [];
959
+ prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
960
+ }
959
961
  }
960
962
  // Fallback: work item _pr field for anything still missing
961
963
  for (const wi of Object.values(wiById)) {
@@ -1091,4 +1093,3 @@ module.exports = {
1091
1093
  // Work items & PRD
1092
1094
  getWorkItems, getPrdInfo,
1093
1095
  };
1094
-
package/engine/shared.js CHANGED
@@ -859,6 +859,17 @@ function parseSkillFrontmatter(content, filename) {
859
859
  // Stable single-writer file: maps PR IDs → PRD item IDs.
860
860
  // Never touched by polling loops — only written when a PR is first linked to a PRD item.
861
861
 
862
+ function normalizePrLinkItems(value) {
863
+ const items = Array.isArray(value) ? value : [value];
864
+ return [...new Set(items.filter(Boolean))];
865
+ }
866
+
867
+ function mergePrLinkItems(links, prId, itemIds) {
868
+ if (!prId) return;
869
+ const merged = new Set([...(links[prId] || []), ...normalizePrLinkItems(itemIds)]);
870
+ if (merged.size > 0) links[prId] = [...merged];
871
+ }
872
+
862
873
  function getPrLinks() {
863
874
  const links = {};
864
875
  // Primary source: derive from all projects/*/pull-requests.json prdItems
@@ -870,9 +881,7 @@ function getPrLinks() {
870
881
  const prs = JSON.parse(fs.readFileSync(path.join(projectsDir, d.name, 'pull-requests.json'), 'utf8'));
871
882
  for (const pr of prs) {
872
883
  if (!pr.id) continue;
873
- for (const itemId of (pr.prdItems || [])) {
874
- if (itemId) links[pr.id] = itemId;
875
- }
884
+ mergePrLinkItems(links, pr.id, pr.prdItems || []);
876
885
  }
877
886
  } catch { /* missing or invalid */ }
878
887
  }
@@ -881,17 +890,35 @@ function getPrLinks() {
881
890
  try {
882
891
  const static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8'));
883
892
  for (const [k, v] of Object.entries(static_)) {
884
- if (!links[k]) links[k] = v;
893
+ if (!links[k]) mergePrLinkItems(links, k, v);
885
894
  }
886
895
  } catch { /* missing */ }
887
896
  return links;
888
897
  }
889
898
 
899
+ function backfillPrPrdItems(prs, prLinks) {
900
+ let backfilled = 0;
901
+ for (const pr of prs) {
902
+ const linkedItems = prLinks[pr.id] || [];
903
+ if (linkedItems.length > 0) {
904
+ pr.prdItems = Array.isArray(pr.prdItems) ? pr.prdItems : [];
905
+ for (const linked of linkedItems) {
906
+ if (!pr.prdItems.includes(linked)) {
907
+ pr.prdItems.push(linked);
908
+ backfilled++;
909
+ }
910
+ }
911
+ }
912
+ }
913
+ return backfilled;
914
+ }
915
+
890
916
  function addPrLink(prId, itemId) {
891
917
  if (!prId || !itemId) return;
892
- const links = getPrLinks();
893
- if (links[prId] === itemId) return; // already correct, no write needed
894
- links[prId] = itemId;
918
+ const links = safeJson(PR_LINKS_PATH) || {};
919
+ const current = normalizePrLinkItems(links[prId]);
920
+ if (current.includes(itemId)) return; // already correct, no write needed
921
+ links[prId] = [...current, itemId];
895
922
  safeWrite(PR_LINKS_PATH, links);
896
923
  }
897
924
 
@@ -1184,5 +1211,5 @@ module.exports = {
1184
1211
  formatTranscriptEntry,
1185
1212
  _logBuffer, // exported for testing
1186
1213
  createThrottleTracker,
1214
+ backfillPrPrdItems,
1187
1215
  };
1188
-
package/engine.js CHANGED
@@ -24,7 +24,7 @@
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./engine/shared');
27
- const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
27
+ const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
28
28
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, DISPATCH_RESULT, AGENT_STATUS,
29
29
  FAILURE_CLASS } = shared;
30
30
  const queries = require('./engine/queries');
@@ -241,8 +241,8 @@ function _maxTurnsForType(type, engineConfig) {
241
241
  // Priority: per-type config override → global config override → built-in per-type default → global default
242
242
  const perType = engineConfig.maxTurnsByType || {};
243
243
  if (perType[type]) return perType[type];
244
- const globalOverride = engineConfig.maxTurns && engineConfig.maxTurns !== DEFAULTS.maxTurns ? engineConfig.maxTurns : null;
245
- return globalOverride || _MAX_TURNS_BY_TYPE[type] || DEFAULTS.maxTurns;
244
+ const globalOverride = engineConfig.maxTurns && engineConfig.maxTurns !== ENGINE_DEFAULTS.maxTurns ? engineConfig.maxTurns : null;
245
+ return globalOverride || _MAX_TURNS_BY_TYPE[type] || ENGINE_DEFAULTS.maxTurns;
246
246
  }
247
247
 
248
248
  // Resolve dependency plan item IDs to their PR branches
@@ -373,8 +373,8 @@ async function spawnAgent(dispatchItem, config) {
373
373
  let cwd = rootDir;
374
374
  let worktreePath = null;
375
375
  let branchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
376
- const worktreeCreateTimeout = Math.max(60000, Number(engineConfig.worktreeCreateTimeout) || DEFAULTS.worktreeCreateTimeout);
377
- const worktreeCreateRetries = Math.max(0, Math.min(3, Number(engineConfig.worktreeCreateRetries) || DEFAULTS.worktreeCreateRetries));
376
+ const worktreeCreateTimeout = Math.max(60000, Number(engineConfig.worktreeCreateTimeout) || ENGINE_DEFAULTS.worktreeCreateTimeout);
377
+ const worktreeCreateRetries = Math.max(0, Math.min(3, Number(engineConfig.worktreeCreateRetries) || ENGINE_DEFAULTS.worktreeCreateRetries));
378
378
  const _gitOpts = { stdio: 'pipe', timeout: 30000, windowsHide: true, env: shared.gitEnv() };
379
379
  const _worktreeGitOpts = { ..._gitOpts, timeout: worktreeCreateTimeout };
380
380
 
@@ -1021,7 +1021,7 @@ async function spawnAgent(dispatchItem, config) {
1021
1021
  // Build resume args
1022
1022
  const resumeArgs = [
1023
1023
  '--output-format', claudeConfig?.outputFormat || 'stream-json',
1024
- '--max-turns', String(engineConfig?.maxTurns || DEFAULTS.maxTurns),
1024
+ '--max-turns', String(engineConfig?.maxTurns || ENGINE_DEFAULTS.maxTurns),
1025
1025
  '--verbose',
1026
1026
  '--permission-mode', claudeConfig?.permissionMode || 'bypassPermissions',
1027
1027
  '--resume', steerSessionId,
@@ -1427,7 +1427,7 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
1427
1427
 
1428
1428
  let exactPr = allPrs.find(pr => (pr.prdItems || []).includes(wi.id));
1429
1429
  if (!exactPr) {
1430
- const linkedPrId = Object.keys(prLinks).find(prId => prLinks[prId] === wi.id);
1430
+ const linkedPrId = Object.keys(prLinks).find(prId => (prLinks[prId] || []).includes(wi.id));
1431
1431
  if (linkedPrId) exactPr = allPrs.find(pr => pr.id === linkedPrId) || { id: linkedPrId };
1432
1432
  }
1433
1433
  // Branch-based matching: PR branch contains the work item ID (e.g. work/P-k7m2v9a1)
@@ -1901,8 +1901,8 @@ async function discoverFromPrs(config, project) {
1901
1901
  // Resolve poll-enabled per project — stale reviewStatus is untrustworthy without poller
1902
1902
  const isAdoProject = project?.repoHost !== 'github';
1903
1903
  const pollEnabled = isAdoProject
1904
- ? (config.engine?.adoPollEnabled ?? DEFAULTS.adoPollEnabled)
1905
- : (config.engine?.ghPollEnabled ?? DEFAULTS.ghPollEnabled);
1904
+ ? (config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled)
1905
+ : (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled);
1906
1906
  const evalLoopEnabled = config.engine?.evalLoop !== false;
1907
1907
 
1908
1908
  // Collect active PR dispatches to prevent simultaneous review+fix on same PR
@@ -1935,7 +1935,7 @@ async function discoverFromPrs(config, project) {
1935
1935
  const awaitingReReview = reviewStatus === 'waiting' && !!pr.minionsReview?.fixedAt;
1936
1936
 
1937
1937
  // Review→fix cycle cap — stop review/fix dispatch after N iterations, but allow build fixes and conflict fixes
1938
- const evalMax = config.engine?.evalMaxIterations ?? DEFAULTS.evalMaxIterations;
1938
+ const evalMax = config.engine?.evalMaxIterations ?? ENGINE_DEFAULTS.evalMaxIterations;
1939
1939
  const evalCycles = pr._reviewFixCycles || 0;
1940
1940
  const evalEscalated = evalCycles >= evalMax;
1941
1941
  if (evalEscalated && !pr._evalEscalated) {
@@ -2099,12 +2099,12 @@ async function discoverFromPrs(config, project) {
2099
2099
  // Grace period: after a build fix push, wait for CI to run before re-dispatching
2100
2100
  // Skip if build hasn't transitioned since last fix (still showing the old failure)
2101
2101
  if (pr._buildFixPushedAt && pr.buildStatus === 'failing') {
2102
- const gracePeriodMs = config.engine?.buildFixGracePeriod ?? DEFAULTS.buildFixGracePeriod;
2102
+ const gracePeriodMs = config.engine?.buildFixGracePeriod ?? ENGINE_DEFAULTS.buildFixGracePeriod;
2103
2103
  if (Date.now() - new Date(pr._buildFixPushedAt).getTime() < gracePeriodMs) continue;
2104
2104
  }
2105
- const autoFixBuilds = config.engine?.autoFixBuilds ?? DEFAULTS.autoFixBuilds;
2105
+ const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
2106
2106
  if (autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing') {
2107
- const maxBuildFix = config.engine?.maxBuildFixAttempts ?? DEFAULTS.maxBuildFixAttempts;
2107
+ const maxBuildFix = config.engine?.maxBuildFixAttempts ?? ENGINE_DEFAULTS.maxBuildFixAttempts;
2108
2108
 
2109
2109
  // Check if max retry cap reached — escalate to human instead of dispatching another fix
2110
2110
  if ((pr.buildFixAttempts || 0) >= maxBuildFix) {
@@ -2182,7 +2182,7 @@ async function discoverFromPrs(config, project) {
2182
2182
  }
2183
2183
 
2184
2184
  // PRs with merge conflicts — dispatch fix to resolve (gated by autoFixConflicts)
2185
- const autoFixConflicts = config.engine?.autoFixConflicts ?? DEFAULTS.autoFixConflicts;
2185
+ const autoFixConflicts = config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts;
2186
2186
  if (autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched) {
2187
2187
  const key = `conflict-fix-${project?.name || 'default'}-${pr.id}`;
2188
2188
  // Suppress re-dispatch for 10 min after last attempt — ADO/GitHub recomputes
@@ -2808,7 +2808,7 @@ function discoverCentralWorkItems(config) {
2808
2808
  meta: {
2809
2809
  dispatchKey: fanKey, source: 'central-work-item-fanout', item, parentKey: key,
2810
2810
  branch: fanBranch,
2811
- deadline: item.timeout ? Date.now() + item.timeout : Date.now() + (config.engine?.fanOutTimeout || config.engine?.agentTimeout || DEFAULTS.agentTimeout)
2811
+ deadline: item.timeout ? Date.now() + item.timeout : Date.now() + (config.engine?.fanOutTimeout || config.engine?.agentTimeout || ENGINE_DEFAULTS.agentTimeout)
2812
2812
  }
2813
2813
  });
2814
2814
  }
@@ -3081,18 +3081,24 @@ async function discoverWork(config) {
3081
3081
  } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
3082
3082
  }
3083
3083
 
3084
- // Gate reviews and fixes: do not dispatch until all implement items are complete
3084
+ // Gate reviews and fixes: only when at max concurrency idle agents should pick up reviews
3085
+ // even if implement items are in progress (implements get priority via sort order, not by blocking)
3085
3086
  const hasIncompleteImplements = queries.getWorkItems(config).some(i =>
3086
3087
  ['queued', 'pending', 'dispatched'].includes(i.status) && (i.type || '').startsWith('implement')
3087
3088
  );
3088
3089
  if (hasIncompleteImplements) {
3089
- if (allReviews.length > 0) {
3090
- log('info', `Gating ${allReviews.length} reviews — implement items still in progress`);
3091
- allReviews = [];
3092
- }
3093
- if (allFixes.length > 0) {
3094
- log('info', `Gating ${allFixes.length} fixes — implement items still in progress`);
3095
- allFixes = [];
3090
+ const activeCount = (getDispatch().active || []).length;
3091
+ const maxConcurrent = config.engine?.maxConcurrent ?? ENGINE_DEFAULTS.maxConcurrent;
3092
+ const freeSlots = Math.max(0, maxConcurrent - activeCount);
3093
+ if (freeSlots === 0) {
3094
+ if (allReviews.length > 0) {
3095
+ log('info', `Gating ${allReviews.length} reviews — implement items in progress and no free slots`);
3096
+ allReviews = [];
3097
+ }
3098
+ if (allFixes.length > 0) {
3099
+ log('info', `Gating ${allFixes.length} fixes — implement items in progress and no free slots`);
3100
+ allFixes = [];
3101
+ }
3096
3102
  }
3097
3103
  }
3098
3104
 
@@ -3204,10 +3210,10 @@ async function tickInner() {
3204
3210
  });
3205
3211
  }
3206
3212
 
3207
- const adoPollEnabled = config.engine?.adoPollEnabled ?? DEFAULTS.adoPollEnabled;
3208
- const ghPollEnabled = config.engine?.ghPollEnabled ?? DEFAULTS.ghPollEnabled;
3209
- const adoPollStatusEvery = Math.max(1, Number(config.engine?.adoPollStatusEvery) || DEFAULTS.adoPollStatusEvery);
3210
- const adoPollCommentsEvery = Math.max(1, Number(config.engine?.adoPollCommentsEvery) || DEFAULTS.adoPollCommentsEvery);
3213
+ const adoPollEnabled = config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled;
3214
+ const ghPollEnabled = config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled;
3215
+ const adoPollStatusEvery = Math.max(1, Number(config.engine?.adoPollStatusEvery) || ENGINE_DEFAULTS.adoPollStatusEvery);
3216
+ const adoPollCommentsEvery = Math.max(1, Number(config.engine?.adoPollCommentsEvery) || ENGINE_DEFAULTS.adoPollCommentsEvery);
3211
3217
 
3212
3218
  // 2.6. Poll PR status: build, review, merge (every adoPollStatusEvery ticks, default ~6 minutes)
3213
3219
  // Awaited so PR state is consistent before discoverWork reads it
@@ -3410,7 +3416,7 @@ async function tickInner() {
3410
3416
  if (busyAgents.has(item.agent)) {
3411
3417
  // Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
3412
3418
  // try to find an alternative agent via routing. Skip explicitly assigned items.
3413
- const reassignMs = config.engine?.agentBusyReassignMs ?? DEFAULTS.agentBusyReassignMs;
3419
+ const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
3414
3420
  const isExplicitReassign = !!item.meta?.item?.agent;
3415
3421
  if (!isExplicitReassign && reassignMs > 0 && item._agentBusySince) {
3416
3422
  const busySinceMs = new Date(item._agentBusySince).getTime();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1002",
3
+ "version": "0.1.1004",
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"