@yemi33/minions 0.1.1004 → 0.1.1005

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.1004 (2026-04-15)
3
+ ## 0.1.1005 (2026-04-16)
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
+ - harden repo-scoped PR identity handling
28
29
  - only gate reviews/fixes when no free agent slots remain
29
30
  - extend auto-link fallback to ADO projects
30
31
  - auto-link existing GitHub PR when agent completes without creating one
@@ -44,7 +45,6 @@
44
45
  - defer review setCooldown to post-gating in discoverWork
45
46
  - move review verdict check before updateWorkItemStatus(DONE)
46
47
  - address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
47
- - fix watches feature gaps — human notifications, branch stub, status-change init, unique keys
48
48
 
49
49
  ### Other
50
50
  - refactor: migrate PR links to array format and consolidate shared helpers
package/dashboard.js CHANGED
@@ -2172,7 +2172,11 @@ const server = http.createServer(async (req, res) => {
2172
2172
  }
2173
2173
 
2174
2174
  // Build a manifest of all KB entries with their content (skip pinned — user wants to keep them)
2175
- const pinnedKeys = new Set(body.pinnedKeys || []);
2175
+ const requestPinnedKeys = Array.isArray(body.pinnedKeys)
2176
+ ? body.pinnedKeys.filter(k => typeof k === 'string' && k.startsWith('knowledge/'))
2177
+ : [];
2178
+ const serverPinnedKeys = shared.getPinnedItems().filter(k => k.startsWith('knowledge/'));
2179
+ const pinnedKeys = new Set([...serverPinnedKeys, ...requestPinnedKeys]);
2176
2180
  const manifest = [];
2177
2181
  for (const e of entries) {
2178
2182
  if (pinnedKeys.has('knowledge/' + e.cat + '/' + e.file)) continue;
@@ -2180,6 +2184,11 @@ const server = http.createServer(async (req, res) => {
2180
2184
  if (!content) continue;
2181
2185
  manifest.push({ category: e.cat, file: e.file, title: e.title, agent: e.agent, date: e.date, content: content.slice(0, 3000) });
2182
2186
  }
2187
+ if (manifest.length < 2) {
2188
+ global._kbSweepLastResult = { ok: true, summary: 'nothing to sweep (< 2 unpinned entries)' };
2189
+ global._kbSweepLastCompletedAt = Date.now();
2190
+ return;
2191
+ }
2183
2192
 
2184
2193
  const { callLLM, trackEngineUsage } = require('./engine/llm');
2185
2194
  const BATCH_SIZE = 30; // ~30 entries per batch to stay within Haiku context
@@ -4364,7 +4373,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4364
4373
  // Extract PR number from URL
4365
4374
  const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
4366
4375
  const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
4367
- const prId = 'PR-' + prNum;
4376
+ const prId = shared.getCanonicalPrId(targetProject, prNum, url);
4368
4377
 
4369
4378
  // Atomic check-and-insert to prevent duplicates and races with polling loops
4370
4379
  let duplicate = false;
@@ -4373,6 +4382,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4373
4382
  if (prs.some(p => p.id === prId || p.url === url)) { duplicate = true; return prs; }
4374
4383
  prs.push({
4375
4384
  id: prId,
4385
+ prNumber: parseInt(prNum, 10) || null,
4376
4386
  title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
4377
4387
  description: '',
4378
4388
  agent: 'human',
@@ -57,7 +57,8 @@ const prNumber = parseInt(prNumberArg, 10);
57
57
  function findInCache(projects) {
58
58
  for (const project of projects) {
59
59
  const prs = safeJsonArr(projectPrPath(project));
60
- const pr = prs.find(p => p.prNumber === prNumber || p.id === `PR-${prNumber}`);
60
+ shared.normalizePrRecords(prs, project);
61
+ const pr = prs.find(p => p.prNumber === prNumber || p.id === shared.getCanonicalPrId(project, prNumber) || shared.getPrDisplayId(p) === `PR-${prNumber}`);
61
62
  if (!pr) continue;
62
63
  const out = {
63
64
  prNumber: pr.prNumber || prNumber,
package/engine/ado.js CHANGED
@@ -255,7 +255,7 @@ async function forEachActivePr(config, token, callback) {
255
255
  for (let i = 0; i < activePrs.length; i += CONCURRENCY) {
256
256
  const batch = activePrs.slice(i, i + CONCURRENCY);
257
257
  const results = await Promise.allSettled(batch.map(async (pr) => {
258
- const prNum = (pr.id || '').replace('PR-', '');
258
+ const prNum = shared.getPrNumber(pr);
259
259
  if (!prNum) return false;
260
260
  return callback(project, pr, prNum, orgBase);
261
261
  }));
@@ -680,6 +680,7 @@ async function reconcilePrs(config) {
680
680
 
681
681
  const prPath = shared.projectPrPath(project);
682
682
  const existingPrs = shared.safeJson(prPath) || [];
683
+ shared.normalizePrRecords(existingPrs, project);
683
684
  const existingIds = new Set(existingPrs.map(p => p.id));
684
685
  let projectAdded = 0;
685
686
 
@@ -692,7 +693,8 @@ async function reconcilePrs(config) {
692
693
 
693
694
  let projectUpdated = 0;
694
695
  for (const adoPr of adoPrs) {
695
- const prId = `PR-${adoPr.pullRequestId}`;
696
+ const prUrl = getAdoPrUrl(project, adoPr.pullRequestId);
697
+ const prId = shared.getCanonicalPrId(project, adoPr.pullRequestId, prUrl);
696
698
  const branch = stripRefsHeads(adoPr.sourceRefName);
697
699
  const title = adoPr.title || '';
698
700
  // Extract item ID from branch name or PR title (e.g., feat(P-2cafdc2a): ...)
@@ -710,7 +712,7 @@ async function reconcilePrs(config) {
710
712
  }
711
713
  // PR already tracked — write link to pr-links.json if we can extract an ID
712
714
  if (confirmedItemId) {
713
- addPrLink(prId, confirmedItemId);
715
+ addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
714
716
  if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
715
717
  existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
716
718
  existing.prdItems.push(confirmedItemId);
@@ -725,7 +727,6 @@ async function reconcilePrs(config) {
725
727
  // are human-authored and should not be auto-tracked or auto-reviewed.
726
728
  if (!confirmedItemId) continue;
727
729
 
728
- const prUrl = project.prUrlBase ? project.prUrlBase + adoPr.pullRequestId : '';
729
730
  existingPrs.push({
730
731
  id: prId,
731
732
  prNumber: adoPr.pullRequestId,
@@ -738,7 +739,7 @@ async function reconcilePrs(config) {
738
739
  url: prUrl,
739
740
  prdItems: [confirmedItemId],
740
741
  });
741
- addPrLink(prId, confirmedItemId);
742
+ addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
742
743
  existingIds.add(prId);
743
744
  projectAdded++;
744
745
  log('info', `PR reconciliation: added ${prId} (branch: ${branch}, linked to ${confirmedItemId}) to ${project.name}`);
@@ -747,7 +748,7 @@ async function reconcilePrs(config) {
747
748
  // Backfill prNumber from pr.id for any PR missing it (e.g. created before prNumber was stored)
748
749
  for (const pr of existingPrs) {
749
750
  if (pr.prNumber == null) {
750
- const derived = parseInt((pr.id || '').replace(/^PR-/, ''), 10);
751
+ const derived = shared.getPrNumber(pr);
751
752
  if (derived) pr.prNumber = derived;
752
753
  }
753
754
  }
@@ -786,7 +787,8 @@ async function checkLiveReviewStatus(pr, project) {
786
787
  const token = await getAdoToken();
787
788
  if (!token) return null;
788
789
  const orgBase = shared.getAdoOrgBase(project);
789
- const prNum = (pr.id || '').replace(/^PR-/, '');
790
+ const prNum = shared.getPrNumber(pr);
791
+ if (!prNum) return null;
790
792
  const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}?api-version=7.1`;
791
793
  const result = await execAsync(`curl -s --max-time 4 -H "Authorization: Bearer ${token}" "${url}"`, { encoding: 'utf-8', timeout: 5000, windowsHide: true });
792
794
  const prData = JSON.parse(result);
@@ -851,7 +853,7 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
851
853
  }));
852
854
  const logParts = [];
853
855
  const seenBuildIds = new Set();
854
- const pr = { id: `PR-${prNumber}`, branch: stripRefsHeads(prData.sourceRefName) };
856
+ const pr = { id: shared.getCanonicalPrId(project, prNumber, getAdoPrUrl(project, prNumber)), branch: stripRefsHeads(prData.sourceRefName) };
855
857
  for (const fb of failedBuilds.slice(0, 3)) {
856
858
  const errorLog = await fetchAdoBuildErrorLog(orgBase, project, fb, token, pr, seenBuildIds);
857
859
  if (errorLog) logParts.push(errorLog);
@@ -25,7 +25,8 @@ function consolidateInbox(config) {
25
25
 
26
26
  const { ENGINE_DEFAULTS } = shared;
27
27
  const threshold = config.engine?.inboxConsolidateThreshold || ENGINE_DEFAULTS.inboxConsolidateThreshold;
28
- const files = getInboxFiles().filter(f => !_processingFiles.has(f));
28
+ const pinnedInboxKeys = new Set(shared.getPinnedItems().filter(k => k.startsWith('notes/inbox/')));
29
+ const files = getInboxFiles().filter(f => !_processingFiles.has(f) && !pinnedInboxKeys.has('notes/inbox/' + f));
29
30
  if (files.length < threshold) return;
30
31
  // Auto-reset stale flag if consolidation has been running for >5 minutes (process died without cleanup)
31
32
  if (_consolidationInFlight && (Date.now() - _consolidationStartedAt) > 300000) {
@@ -485,4 +486,3 @@ module.exports = {
485
486
  classifyToKnowledgeBase,
486
487
  checkDuplicateHash,
487
488
  };
488
-
@@ -223,7 +223,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
223
223
  try {
224
224
  const prsPath = projectPrPath(project);
225
225
  mutatePullRequests(prsPath, prs => {
226
- const target = prs.find(p => p.id === prId);
226
+ const target = shared.findPrRecord(prs, { id: prId }, project);
227
227
  if (target?.humanFeedback) target.humanFeedback.pendingFix = true;
228
228
  });
229
229
  log('info', `Restored pendingFix=true on ${prId} after failed human-feedback fix`);
@@ -246,9 +246,10 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
246
246
 
247
247
  function writeInboxAlert(slug, content) {
248
248
  try {
249
- const file = path.join(INBOX_DIR, `engine-alert-${slug}-${dateStamp()}.md`);
249
+ const safeSlug = shared.safeSlugComponent(slug, 100);
250
+ const file = path.join(INBOX_DIR, `engine-alert-${safeSlug}-${dateStamp()}.md`);
250
251
  // Dedupe: don't write the same alert twice in the same day
251
- const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${slug}-${dateStamp()}`));
252
+ const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${safeSlug}-${dateStamp()}`));
252
253
  if (existing) return;
253
254
  safeWrite(file, content);
254
255
  } catch (e) { log('warn', 'write inbox alert: ' + e.message); }
package/engine/github.js CHANGED
@@ -199,7 +199,7 @@ async function forEachActiveGhPr(config, callback) {
199
199
  let projectUpdated = 0;
200
200
 
201
201
  for (const pr of activePrs) {
202
- const prNum = (pr.id || '').replace('PR-', '');
202
+ const prNum = shared.getPrNumber(pr);
203
203
  if (!prNum) continue;
204
204
 
205
205
  try {
@@ -621,6 +621,7 @@ async function reconcilePrs(config) {
621
621
 
622
622
  const prPath = projectPrPath(project);
623
623
  const currentPrs = safeJson(prPath) || [];
624
+ shared.normalizePrRecords(currentPrs, project);
624
625
  const existingIds = new Set(currentPrs.map(p => p.id));
625
626
  let projectAdded = 0;
626
627
 
@@ -632,7 +633,8 @@ async function reconcilePrs(config) {
632
633
  const allItems = [...workItems, ...centralItems];
633
634
 
634
635
  for (const ghPr of ghPrs) {
635
- const prId = `PR-${ghPr.number}`;
636
+ const prUrl = project.prUrlBase ? project.prUrlBase + ghPr.number : ghPr.html_url || '';
637
+ const prId = shared.getCanonicalPrId(project, ghPr.number, prUrl);
636
638
  const branch = ghPr.head?.ref || '';
637
639
  const wiMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(W-[a-z0-9]{6,})/i) || branch.match(/(PL-[a-z0-9]{6,})/i);
638
640
  const linkedItemId = wiMatch ? wiMatch[1] : null;
@@ -646,7 +648,7 @@ async function reconcilePrs(config) {
646
648
  existing.prNumber = ghPr.number;
647
649
  }
648
650
  if (confirmedItemId) {
649
- addPrLink(prId, confirmedItemId);
651
+ addPrLink(prId, confirmedItemId, { project, prNumber: ghPr.number, url: prUrl });
650
652
  if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
651
653
  existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
652
654
  existing.prdItems.push(confirmedItemId);
@@ -661,8 +663,6 @@ async function reconcilePrs(config) {
661
663
  // Only auto-track PRs linked to a minions work item — skip human-authored PRs
662
664
  if (!confirmedItemId && !isE2eBranch) continue;
663
665
 
664
- const prUrl = project.prUrlBase ? project.prUrlBase + ghPr.number : ghPr.html_url || '';
665
-
666
666
  currentPrs.push({
667
667
  id: prId,
668
668
  prNumber: ghPr.number,
@@ -675,7 +675,7 @@ async function reconcilePrs(config) {
675
675
  url: prUrl,
676
676
  prdItems: [confirmedItemId],
677
677
  });
678
- addPrLink(prId, confirmedItemId);
678
+ addPrLink(prId, confirmedItemId, { project, prNumber: ghPr.number, url: prUrl });
679
679
  existingIds.add(prId);
680
680
  projectAdded++;
681
681
 
@@ -685,7 +685,7 @@ async function reconcilePrs(config) {
685
685
  // Backfill prNumber from pr.id for any PR missing it (e.g. created before prNumber was stored)
686
686
  for (const pr of currentPrs) {
687
687
  if (pr.prNumber == null) {
688
- const derived = parseInt((pr.id || '').replace(/^PR-/, ''), 10);
688
+ const derived = shared.getPrNumber(pr);
689
689
  if (derived) pr.prNumber = derived;
690
690
  }
691
691
  }
@@ -719,7 +719,8 @@ async function checkLiveReviewStatus(pr, project) {
719
719
  try {
720
720
  const slug = getRepoSlug(project);
721
721
  if (!slug) return null;
722
- const prNum = (pr.id || '').replace(/^PR-/, '');
722
+ const prNum = shared.getPrNumber(pr);
723
+ if (!prNum) return null;
723
724
  const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
724
725
  if (!reviews || !Array.isArray(reviews)) return null;
725
726
  const latestByUser = new Map();
@@ -781,10 +781,11 @@ function syncPrsFromOutput(output, agentId, meta, config) {
781
781
  const newPrsByPath = new Map(); // prPath -> [{ prId, newEntry }]
782
782
 
783
783
  for (const prId of prMatches) {
784
- const fullId = `PR-${prId}`;
785
784
  const targetProject = useCentral ? null : resolveProjectForPr(prId);
786
785
  const targetName = targetProject ? targetProject.name : '_central';
787
786
  const prPath = targetProject ? shared.projectPrPath(targetProject) : centralPrPath;
787
+ const prUrl = extractPrUrl(prId);
788
+ const fullId = shared.getCanonicalPrId(targetProject, prId, prUrl);
788
789
 
789
790
  let title = meta?.item?.title || '';
790
791
  const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
@@ -793,7 +794,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
793
794
  title = meta?.item?.title || '';
794
795
  }
795
796
 
796
- if (!newPrsByPath.has(prPath)) newPrsByPath.set(prPath, { name: targetName, entries: [] });
797
+ if (!newPrsByPath.has(prPath)) newPrsByPath.set(prPath, { name: targetName, project: targetProject, entries: [] });
797
798
  newPrsByPath.get(prPath).entries.push({
798
799
  prId, fullId,
799
800
  entry: {
@@ -805,7 +806,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
805
806
  reviewStatus: 'pending',
806
807
  status: PR_STATUS.ACTIVE,
807
808
  created: ts(),
808
- url: extractPrUrl(prId),
809
+ url: prUrl,
809
810
  prdItems: meta?.item?.id ? [meta.item.id] : [],
810
811
  sourcePlan: meta?.item?.sourcePlan || '',
811
812
  itemType: meta?.item?.itemType || ''
@@ -813,7 +814,8 @@ function syncPrsFromOutput(output, agentId, meta, config) {
813
814
  });
814
815
  }
815
816
 
816
- for (const [prPath, { name, entries }] of newPrsByPath) {
817
+ for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
818
+ const linksToPersist = [];
817
819
  mutateJsonFileLocked(prPath, (data) => {
818
820
  const prs = Array.isArray(data) ? data : [];
819
821
  // Normalize legacy YYYY-MM-DD created dates to ISO
@@ -821,13 +823,18 @@ function syncPrsFromOutput(output, agentId, meta, config) {
821
823
  if (p.created && p.created.length === 10) p.created = p.created + 'T00:00:00.000Z';
822
824
  }
823
825
  for (const { prId, fullId, entry } of entries) {
824
- if (prs.some(p => p.id === fullId || String(p.id) === String(prId))) continue;
826
+ if (prs.some(p => p.id === fullId || (p.url && p.url === entry.url))) continue;
825
827
  prs.push(entry);
826
- if (meta?.item?.id) addPrLink(fullId, meta.item.id);
828
+ if (meta?.item?.id) {
829
+ linksToPersist.push({ prId: fullId, itemId: meta.item.id, project: targetProject, prNumber: entry.prNumber, url: entry.url });
830
+ }
827
831
  added++;
828
832
  }
829
833
  return prs;
830
834
  });
835
+ for (const { prId, itemId, project, prNumber, url } of linksToPersist) {
836
+ addPrLink(prId, itemId, { project, prNumber, url });
837
+ }
831
838
  log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name}/pull-requests.json`);
832
839
  }
833
840
  return added;
@@ -894,7 +901,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary)
894
901
  let updatedTarget = null;
895
902
  shared.mutateJsonFileLocked(prPath, (prs) => {
896
903
  if (!Array.isArray(prs)) return prs;
897
- const target = prs.find(p => p.id === pr.id);
904
+ const target = shared.findPrRecord(prs, pr, project);
898
905
  if (!target) return prs;
899
906
  // Once approved, stays approved — only changes-requested can override
900
907
  if (postReviewStatus) {
@@ -938,7 +945,7 @@ function updatePrAfterFix(pr, project, source) {
938
945
  const prPath = project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json');
939
946
  shared.mutateJsonFileLocked(prPath, (prs) => {
940
947
  if (!Array.isArray(prs)) return prs;
941
- const target = prs.find(p => p.id === pr.id);
948
+ const target = shared.findPrRecord(prs, pr, project);
942
949
  if (!target) return prs;
943
950
  // Never downgrade from approved — fix was dispatched but PR is already approved
944
951
  if (target.reviewStatus !== 'approved') target.reviewStatus = 'waiting';
@@ -1083,7 +1090,7 @@ async function processPendingRebases(config) {
1083
1090
 
1084
1091
  async function handlePostMerge(pr, project, config, newStatus) {
1085
1092
 
1086
- const prNum = (pr.id || '').replace('PR-', '');
1093
+ const prNum = shared.getPrNumber(pr);
1087
1094
 
1088
1095
  if (pr.branch && project) {
1089
1096
  const root = path.resolve(project.localPath);
@@ -1329,7 +1336,8 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config) {
1329
1336
  const reviewFiles = inboxFiles.filter(f => f.includes(reviewerAgentId) && f.includes(today));
1330
1337
  if (reviewFiles.length === 0) return;
1331
1338
  const reviewContent = reviewFiles.map(f => safeRead(path.join(INBOX_DIR, f))).filter(Boolean).join('\n\n');
1332
- const feedbackFile = `feedback-${authorAgentId}-from-${reviewerAgentId}-${pr.id}-${today}.md`;
1339
+ const prSlug = shared.safeSlugComponent(pr.id, 60);
1340
+ const feedbackFile = `feedback-${authorAgentId}-from-${reviewerAgentId}-${prSlug}-${today}.md`;
1333
1341
  const feedbackPath = shared.uniquePath(path.join(INBOX_DIR, feedbackFile));
1334
1342
  const content = `# Review Feedback for ${config.agents[authorAgentId]?.name || authorAgentId}\n\n` +
1335
1343
  `**PR:** ${pr.id} — ${pr.title || ''}\n` +
@@ -1845,7 +1853,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1845
1853
  log('debug', `Skipping branch PR lookup for unsupported repo host "${host}" on ${projectObj.name}`);
1846
1854
  }
1847
1855
  if (found) {
1848
- const fullId = `PR-${found.prNumber}`;
1856
+ const fullId = shared.getCanonicalPrId(projectObj, found.prNumber, found.url);
1849
1857
  const prPath = shared.projectPrPath(projectObj);
1850
1858
  mutateJsonFileLocked(prPath, prs => {
1851
1859
  if (!Array.isArray(prs)) prs = [];
@@ -703,7 +703,7 @@ function isStageComplete(stage, stageState, run, config) {
703
703
  for (const project of projects) {
704
704
  const prs = safeJson(shared.projectPrPath(project)) || [];
705
705
  for (const prId of prIds) {
706
- const pr = prs.find(p => p.id === prId);
706
+ const pr = shared.findPrRecord(prs, prId, project);
707
707
  if (pr && pr.status !== PR_STATUS.MERGED && pr.status !== PR_STATUS.ABANDONED) return false;
708
708
  }
709
709
  }
package/engine/queries.js CHANGED
@@ -419,7 +419,11 @@ function getAgentDetail(id) {
419
419
  // ── Pull Requests ───────────────────────────────────────────────────────────
420
420
 
421
421
  function getPrs(project) {
422
- if (project) return safeJson(projectPrPath(project)) || [];
422
+ if (project) {
423
+ const prs = safeJson(projectPrPath(project)) || [];
424
+ shared.normalizePrRecords(prs, project);
425
+ return prs;
426
+ }
423
427
  const config = getConfig();
424
428
  const all = [];
425
429
  for (const p of getProjects(config)) all.push(...getPrs(p));
@@ -433,9 +437,13 @@ function getPullRequests(config) {
433
437
  for (const project of projects) {
434
438
  const prs = safeJson(projectPrPath(project));
435
439
  if (!prs) continue;
440
+ shared.normalizePrRecords(prs, project);
436
441
  const base = project.prUrlBase || '';
437
442
  for (const pr of prs) {
438
- if (!pr.url && base && pr.id) pr.url = base + String(pr.id).replace('PR-', '');
443
+ if (!pr.url && base) {
444
+ const prNumber = shared.getPrNumber(pr);
445
+ if (prNumber != null) pr.url = base + prNumber;
446
+ }
439
447
  pr._project = project.name || 'Project';
440
448
  allPrs.push(pr);
441
449
  }
@@ -444,6 +452,7 @@ function getPullRequests(config) {
444
452
  const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
445
453
  const centralPrs = safeJson(centralPath);
446
454
  if (centralPrs) {
455
+ shared.normalizePrRecords(centralPrs, null);
447
456
  for (const pr of centralPrs) {
448
457
  if (!allPrs.some(p => p.id === pr.id)) {
449
458
  pr._project = 'central';
@@ -737,9 +746,16 @@ function getWorkItems(config) {
737
746
  const allPrs = getPullRequests(config);
738
747
  for (const item of allItems) {
739
748
  if (item._pr && !item._prUrl) {
740
- const prId = String(item._pr).replace('PR-', '');
741
- const pr = allPrs.find(p => String(p.id).includes(prId));
742
- if (pr) item._prUrl = pr.url;
749
+ const project = projects.find(p => p.name === item.project || p.name === item._source) || null;
750
+ const canonicalPrId = shared.getCanonicalPrId(project, item._pr);
751
+ const displayPrId = shared.getPrDisplayId(item._pr);
752
+ const exactPr = allPrs.find(p => p.id === canonicalPrId);
753
+ const displayMatches = exactPr ? [] : allPrs.filter(p => shared.getPrDisplayId(p) === displayPrId);
754
+ const pr = exactPr || (displayMatches.length === 1 ? displayMatches[0] : null);
755
+ if (pr) {
756
+ item._pr = pr.id;
757
+ item._prUrl = pr.url;
758
+ }
743
759
  }
744
760
  if (!item._pr) {
745
761
  // Derive from PR.prdItems (single source of truth)
@@ -953,7 +969,8 @@ function getPrdInfo(config) {
953
969
  for (const [prId, itemIds] of Object.entries(prLinks)) {
954
970
  const pr = prById[prId];
955
971
  const project = projects.find(p => p.name === pr?._project) || projects[0] || null;
956
- const url = pr?.url || (project?.prUrlBase ? project.prUrlBase + prId.replace('PR-', '') : '');
972
+ const prNumber = shared.getPrNumber(pr || prId);
973
+ const url = pr?.url || (project?.prUrlBase && prNumber != null ? project.prUrlBase + prNumber : '');
957
974
  for (const itemId of (itemIds || [])) {
958
975
  if (!prdToPr[itemId]) prdToPr[itemId] = [];
959
976
  prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
@@ -962,10 +979,14 @@ function getPrdInfo(config) {
962
979
  // Fallback: work item _pr field for anything still missing
963
980
  for (const wi of Object.values(wiById)) {
964
981
  if (!wi._pr || prdToPr[wi.id]?.length) continue;
965
- const pr = prById[wi._pr];
966
- const project = projects.find(p => p.name === wi.project || p.name === wi._source);
967
- const url = pr?.url || (project?.prUrlBase ? project.prUrlBase + wi._pr.replace('PR-', '') : '');
968
- prdToPr[wi.id] = [{ id: wi._pr, url, title: pr?.title || '', status: pr?.status || 'active', _project: project?.name || '' }];
982
+ const project = projects.find(p => p.name === wi.project || p.name === wi._source) || null;
983
+ const canonicalPrId = shared.getCanonicalPrId(project, wi._pr);
984
+ const exactPr = prById[canonicalPrId] || null;
985
+ const displayMatches = exactPr ? [] : Object.values(prById).filter(candidate => shared.getPrDisplayId(candidate) === shared.getPrDisplayId(wi._pr));
986
+ const pr = exactPr || (displayMatches.length === 1 ? displayMatches[0] : null);
987
+ const prNumber = shared.getPrNumber(pr || wi._pr);
988
+ const url = pr?.url || (project?.prUrlBase && prNumber != null ? project.prUrlBase + prNumber : '');
989
+ prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: pr?.status || 'active', _project: project?.name || '' }];
969
990
  }
970
991
  // Aggregate sub-task PRs to decomposed parent (sub-tasks aren't PRD items but their PRs should show)
971
992
  for (const pr of allPrs) {
@@ -978,7 +999,8 @@ function getPrdInfo(config) {
978
999
  if (!prdToPr[parentId]) prdToPr[parentId] = [];
979
1000
  if (!prdToPr[parentId].some(p => p.id === pr.id)) {
980
1001
  const project = projects.find(p => p.name === pr._project) || projects[0] || null;
981
- const url = pr.url || (project?.prUrlBase ? project.prUrlBase + pr.id.replace('PR-', '') : '');
1002
+ const prNumber = shared.getPrNumber(pr);
1003
+ const url = pr.url || (project?.prUrlBase && prNumber != null ? project.prUrlBase + prNumber : '');
982
1004
  prdToPr[parentId].push({ id: pr.id, url, title: pr.title || '', status: pr.status || 'active', _project: pr._project || '' });
983
1005
  }
984
1006
  }
package/engine/shared.js CHANGED
@@ -5,9 +5,11 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
+ const crypto = require('crypto');
8
9
 
9
10
  const MINIONS_DIR = process.env.MINIONS_TEST_DIR || path.resolve(__dirname, '..');
10
11
  const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
12
+ const PINNED_ITEMS_PATH = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
11
13
  const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
12
14
 
13
15
  // ── Timestamps & Logging ────────────────────────────────────────────────────
@@ -244,6 +246,9 @@ function mutateJsonFileLocked(filePath, mutateFn, {
244
246
  // Back up last-known-good state before mutation (best-effort)
245
247
  const backupPath = filePath + '.backup';
246
248
  try { if (fs.existsSync(filePath)) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
249
+ if (path.basename(filePath) === 'pull-requests.json' && Array.isArray(data)) {
250
+ normalizePrRecords(data, resolveProjectForPrPath(filePath));
251
+ }
247
252
  const next = mutateFn(data);
248
253
  const finalData = next === undefined ? data : next;
249
254
  safeWrite(filePath, finalData);
@@ -298,7 +303,8 @@ function parseNoteId(content) {
298
303
  function writeToInbox(agentId, slug, content, _inboxDir, metadata) {
299
304
  try {
300
305
  const inboxDir = _inboxDir || path.join(MINIONS_DIR, 'notes', 'inbox');
301
- const prefix = `${agentId}-${slug}-${dateStamp()}`;
306
+ const safeSlug = safeSlugComponent(slug, 80);
307
+ const prefix = `${agentId}-${safeSlug}-${dateStamp()}`;
302
308
  const existing = safeReadDir(inboxDir).find(f => f.startsWith(prefix));
303
309
  if (existing) return false;
304
310
  const noteId = `NOTE-${uid()}`;
@@ -738,6 +744,7 @@ const DEFAULT_CLAUDE = {
738
744
  // ── Project Helpers ──────────────────────────────────────────────────────────
739
745
 
740
746
  function getProjects(config) {
747
+ if (!config) config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
741
748
  if (config && config.projects && Array.isArray(config.projects)) {
742
749
  return config.projects.filter(p => {
743
750
  if (!p || typeof p !== 'object') return false;
@@ -771,6 +778,14 @@ function projectPrPath(project) {
771
778
  return path.join(projectStateDir(project), 'pull-requests.json');
772
779
  }
773
780
 
781
+ function resolveProjectForPrPath(filePath, config = null) {
782
+ const resolvedPath = path.resolve(filePath);
783
+ for (const project of getProjects(config)) {
784
+ if (path.resolve(projectPrPath(project)) === resolvedPath) return project;
785
+ }
786
+ return null;
787
+ }
788
+
774
789
  // ── ID Generation ────────────────────────────────────────────────────────────
775
790
 
776
791
  function nextWorkItemId(items, prefix) {
@@ -859,9 +874,151 @@ function parseSkillFrontmatter(content, filename) {
859
874
  // Stable single-writer file: maps PR IDs → PRD item IDs.
860
875
  // Never touched by polling loops — only written when a PR is first linked to a PRD item.
861
876
 
877
+ function normalizePrScopeSegment(value) {
878
+ return String(value || '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
879
+ }
880
+
881
+ function parseCanonicalPrId(value) {
882
+ const match = String(value || '').trim().match(/^(github|ado):(.+?)#(\d+)$/i);
883
+ if (!match) return null;
884
+ const scope = `${match[1].toLowerCase()}:${match[2].split('/').map(normalizePrScopeSegment).join('/')}`;
885
+ return { scope, prNumber: parseInt(match[3], 10) };
886
+ }
887
+
888
+ function parseGitHubPrUrl(url) {
889
+ const match = String(url || '').match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
890
+ if (!match) return null;
891
+ return {
892
+ scope: `github:${normalizePrScopeSegment(match[1])}/${normalizePrScopeSegment(match[2])}`,
893
+ prNumber: parseInt(match[3], 10),
894
+ };
895
+ }
896
+
897
+ function parseAdoPrUrl(url) {
898
+ const devAzure = String(url || '').match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
899
+ if (devAzure) {
900
+ return {
901
+ scope: `ado:${normalizePrScopeSegment(devAzure[1])}/${normalizePrScopeSegment(devAzure[2])}/${normalizePrScopeSegment(decodeURIComponent(devAzure[3]))}`,
902
+ prNumber: parseInt(devAzure[4], 10),
903
+ };
904
+ }
905
+ const visualStudio = String(url || '').match(/https?:\/\/([^/.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
906
+ if (!visualStudio) return null;
907
+ return {
908
+ scope: `ado:${normalizePrScopeSegment(visualStudio[1])}/${normalizePrScopeSegment(visualStudio[2])}/${normalizePrScopeSegment(decodeURIComponent(visualStudio[3]))}`,
909
+ prNumber: parseInt(visualStudio[4], 10),
910
+ };
911
+ }
912
+
913
+ function getProjectPrScope(project) {
914
+ if (!project) return '';
915
+ const host = String(project.repoHost || '').toLowerCase();
916
+ if (host === 'github') {
917
+ const parsed = parseGitHubPrUrl(project.prUrlBase || '');
918
+ if (parsed?.scope) return parsed.scope;
919
+ const owner = normalizePrScopeSegment(project.adoOrg);
920
+ const repo = normalizePrScopeSegment(project.repoName);
921
+ return owner && repo ? `github:${owner}/${repo}` : '';
922
+ }
923
+ if (host === 'ado' || !host) {
924
+ const parsed = parseAdoPrUrl(project.prUrlBase || '');
925
+ if (parsed?.scope) return parsed.scope;
926
+ const org = normalizePrScopeSegment(project.adoOrg);
927
+ const adoProject = normalizePrScopeSegment(project.adoProject);
928
+ const repo = normalizePrScopeSegment(project.repoName || project.repositoryId);
929
+ return org && adoProject && repo ? `ado:${org}/${adoProject}/${repo}` : '';
930
+ }
931
+ return '';
932
+ }
933
+
934
+ function getPrNumber(value) {
935
+ if (value && typeof value === 'object') {
936
+ if (value.prNumber != null && /^\d+$/.test(String(value.prNumber))) {
937
+ return parseInt(String(value.prNumber), 10);
938
+ }
939
+ return getPrNumber(value.id || value.url || '');
940
+ }
941
+ const raw = String(value || '').trim();
942
+ if (!raw) return null;
943
+ const canonical = parseCanonicalPrId(raw);
944
+ if (canonical) return canonical.prNumber;
945
+ const parsedUrl = parseGitHubPrUrl(raw) || parseAdoPrUrl(raw);
946
+ if (parsedUrl) return parsedUrl.prNumber;
947
+ const legacy = raw.match(/^PR-(\d+)$/i) || raw.match(/^(\d+)$/);
948
+ return legacy ? parseInt(legacy[1], 10) : null;
949
+ }
950
+
951
+ function getPrDisplayId(value, fallbackPrNumber = null) {
952
+ const prNumber = getPrNumber(value) ?? getPrNumber(fallbackPrNumber);
953
+ if (prNumber != null) return `PR-${prNumber}`;
954
+ return typeof value === 'object' ? String(value?.id || '') : String(value || '');
955
+ }
956
+
957
+ function getCanonicalPrId(project, prRef, url = '') {
958
+ const isObjectRef = !!prRef && typeof prRef === 'object';
959
+ const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
960
+ const canonical = parseCanonicalPrId(rawId);
961
+ if (canonical) return `${canonical.scope}#${canonical.prNumber}`;
962
+ const parsedUrl = parseGitHubPrUrl(url || (isObjectRef ? prRef.url || '' : ''))
963
+ || parseAdoPrUrl(url || (isObjectRef ? prRef.url || '' : ''));
964
+ const prNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
965
+ if (prNumber == null) return rawId;
966
+ const scope = getProjectPrScope(project) || parsedUrl?.scope || '';
967
+ return scope ? `${scope}#${prNumber}` : `PR-${prNumber}`;
968
+ }
969
+
970
+ function findPrRecord(prs, prRef, project = null) {
971
+ if (!Array.isArray(prs) || !prRef) return null;
972
+ const isObjectRef = typeof prRef === 'object';
973
+ const rawId = isObjectRef ? String(prRef.id || '') : String(prRef || '');
974
+ const refUrl = isObjectRef ? String(prRef.url || '') : '';
975
+ const canonicalId = getCanonicalPrId(project, prRef, refUrl);
976
+ if (canonicalId) {
977
+ const canonicalMatch = prs.find(pr => pr?.id === canonicalId);
978
+ if (canonicalMatch) return canonicalMatch;
979
+ }
980
+ if (rawId) {
981
+ const rawMatch = prs.find(pr => pr?.id === rawId);
982
+ if (rawMatch) return rawMatch;
983
+ }
984
+ if (refUrl) {
985
+ const urlMatch = prs.find(pr => pr?.url === refUrl);
986
+ if (urlMatch) return urlMatch;
987
+ }
988
+ const refNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
989
+ if (refNumber == null) return null;
990
+ const numberMatches = prs.filter(pr => getPrNumber(pr) === refNumber);
991
+ return numberMatches.length === 1 ? numberMatches[0] : null;
992
+ }
993
+
994
+ function normalizePrRecord(pr, project = null) {
995
+ if (!pr || typeof pr !== 'object') return false;
996
+ let changed = false;
997
+ const prNumber = getPrNumber(pr.prNumber ?? pr.id ?? pr.url);
998
+ if (prNumber != null && pr.prNumber !== prNumber) {
999
+ pr.prNumber = prNumber;
1000
+ changed = true;
1001
+ }
1002
+ const canonicalId = getCanonicalPrId(project, pr, pr.url || '');
1003
+ if (canonicalId && pr.id !== canonicalId) {
1004
+ pr.id = canonicalId;
1005
+ changed = true;
1006
+ }
1007
+ return changed;
1008
+ }
1009
+
1010
+ function normalizePrRecords(prs, project = null) {
1011
+ if (!Array.isArray(prs)) return 0;
1012
+ let changed = 0;
1013
+ for (const pr of prs) {
1014
+ if (normalizePrRecord(pr, project)) changed++;
1015
+ }
1016
+ return changed;
1017
+ }
1018
+
862
1019
  function normalizePrLinkItems(value) {
863
1020
  const items = Array.isArray(value) ? value : [value];
864
- return [...new Set(items.filter(Boolean))];
1021
+ return [...new Set(items.filter(item => typeof item === 'string' && item))];
865
1022
  }
866
1023
 
867
1024
  function mergePrLinkItems(links, prId, itemIds) {
@@ -872,25 +1029,56 @@ function mergePrLinkItems(links, prId, itemIds) {
872
1029
 
873
1030
  function getPrLinks() {
874
1031
  const links = {};
1032
+ const knownPrIdsByDisplay = new Map();
1033
+ const registerPrId = (pr) => {
1034
+ if (!pr?.id) return;
1035
+ const displayId = getPrDisplayId(pr);
1036
+ if (!displayId) return;
1037
+ if (!knownPrIdsByDisplay.has(displayId)) knownPrIdsByDisplay.set(displayId, new Set());
1038
+ knownPrIdsByDisplay.get(displayId).add(pr.id);
1039
+ };
875
1040
  // Primary source: derive from all projects/*/pull-requests.json prdItems
876
1041
  const projectsDir = path.join(MINIONS_DIR, 'projects');
1042
+ const projectsByName = new Map(getProjects().map(project => [project.name || path.basename(project.localPath || ''), project]));
877
1043
  try {
878
1044
  for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
879
1045
  if (!d.isDirectory()) continue;
880
1046
  try {
881
1047
  const prs = JSON.parse(fs.readFileSync(path.join(projectsDir, d.name, 'pull-requests.json'), 'utf8'));
1048
+ const project = projectsByName.get(d.name) || null;
1049
+ normalizePrRecords(prs, project);
882
1050
  for (const pr of prs) {
883
1051
  if (!pr.id) continue;
1052
+ registerPrId(pr);
884
1053
  mergePrLinkItems(links, pr.id, pr.prdItems || []);
885
1054
  }
886
1055
  } catch { /* missing or invalid */ }
887
1056
  }
888
1057
  } catch { /* projects dir missing */ }
1058
+ try {
1059
+ const centralPrs = JSON.parse(fs.readFileSync(path.join(MINIONS_DIR, 'pull-requests.json'), 'utf8'));
1060
+ normalizePrRecords(centralPrs, null);
1061
+ for (const pr of centralPrs) {
1062
+ if (!pr.id) continue;
1063
+ registerPrId(pr);
1064
+ mergePrLinkItems(links, pr.id, pr.prdItems || []);
1065
+ }
1066
+ } catch { /* central file optional */ }
889
1067
  // Fallback: static pr-links.json for entries not covered above
890
1068
  try {
891
1069
  const static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8'));
892
1070
  for (const [k, v] of Object.entries(static_)) {
893
- if (!links[k]) mergePrLinkItems(links, k, v);
1071
+ const canonical = parseCanonicalPrId(k);
1072
+ let normalizedKey = canonical ? `${canonical.scope}#${canonical.prNumber}` : k;
1073
+ if (!canonical) {
1074
+ const candidates = knownPrIdsByDisplay.get(getPrDisplayId(k));
1075
+ if (candidates?.size === 1) normalizedKey = [...candidates][0];
1076
+ else if (candidates?.size > 1) {
1077
+ log('warn', `Skipping ambiguous legacy PR link "${k}" — multiple canonical PR IDs share display ID ${getPrDisplayId(k)}`);
1078
+ continue;
1079
+ }
1080
+ }
1081
+ if (!links[normalizedKey]) mergePrLinkItems(links, normalizedKey, v);
894
1082
  }
895
1083
  } catch { /* missing */ }
896
1084
  return links;
@@ -913,13 +1101,48 @@ function backfillPrPrdItems(prs, prLinks) {
913
1101
  return backfilled;
914
1102
  }
915
1103
 
916
- function addPrLink(prId, itemId) {
917
- if (!prId || !itemId) return;
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];
922
- safeWrite(PR_LINKS_PATH, links);
1104
+ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } = {}) {
1105
+ const canonicalPrId = getCanonicalPrId(project, prNumber ?? prId, url);
1106
+ const effectivePrId = canonicalPrId || prId;
1107
+ if (!effectivePrId || !itemId) return;
1108
+ const legacyPrId = String(prId || '');
1109
+ mutateJsonFileLocked(PR_LINKS_PATH, (links) => {
1110
+ if (!links || Array.isArray(links) || typeof links !== 'object') links = {};
1111
+ const mergedCurrent = new Set(normalizePrLinkItems(links[effectivePrId]));
1112
+ if (legacyPrId && legacyPrId !== effectivePrId && links[legacyPrId]) {
1113
+ for (const linkedItem of normalizePrLinkItems(links[legacyPrId])) mergedCurrent.add(linkedItem);
1114
+ delete links[legacyPrId];
1115
+ }
1116
+ if (!mergedCurrent.has(itemId)) mergedCurrent.add(itemId);
1117
+ links[effectivePrId] = [...mergedCurrent];
1118
+ return links;
1119
+ }, { defaultValue: {} });
1120
+
1121
+ if (!project) return;
1122
+ const prPath = projectPrPath(project);
1123
+ const effectivePrNumber = getPrNumber(prNumber ?? effectivePrId);
1124
+ const prLockPath = `${prPath}.lock`;
1125
+ withFileLock(prLockPath, () => {
1126
+ if (!fs.existsSync(prPath)) return;
1127
+ let prs = safeJson(prPath);
1128
+ if (!Array.isArray(prs)) prs = [];
1129
+ normalizePrRecords(prs, project);
1130
+ const existingPr = prs.find(pr =>
1131
+ pr?.id === effectivePrId
1132
+ || (url && pr?.url === url)
1133
+ || (effectivePrNumber != null && getPrNumber(pr) === effectivePrNumber)
1134
+ );
1135
+ if (!existingPr) return;
1136
+ const backupPath = prPath + '.backup';
1137
+ try { if (fs.existsSync(prPath)) fs.copyFileSync(prPath, backupPath); } catch { /* backup is best-effort */ }
1138
+ existingPr.prdItems = Array.isArray(existingPr.prdItems) ? existingPr.prdItems : [];
1139
+ if (existingPr.prdItems.includes(itemId)) return;
1140
+ existingPr.prdItems.push(itemId);
1141
+ safeWrite(prPath, prs);
1142
+ }, {
1143
+ retries: ENGINE_DEFAULTS.lockRetries,
1144
+ retryBackoffMs: ENGINE_DEFAULTS.lockRetryBackoffMs
1145
+ });
923
1146
  }
924
1147
 
925
1148
  // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
@@ -1089,10 +1312,24 @@ function slugify(text, maxLen = 50) {
1089
1312
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
1090
1313
  }
1091
1314
 
1315
+ function safeSlugComponent(text, maxLen = 80) {
1316
+ const raw = String(text || '').trim();
1317
+ if (!raw) return 'item';
1318
+ if (/^[A-Za-z0-9._-]+$/.test(raw) && raw.length <= maxLen) return raw;
1319
+ const hash = crypto.createHash('md5').update(raw).digest('hex').slice(0, 8);
1320
+ const base = slugify(raw, Math.max(8, maxLen - 9)) || 'item';
1321
+ return `${base}-${hash}`.slice(0, maxLen);
1322
+ }
1323
+
1092
1324
  function formatTranscriptEntry(t) {
1093
1325
  return '### ' + (t.agent || 'agent') + ' (' + (t.type || '') + ', Round ' + (t.round || '?') + ')\n\n' + (t.content || '');
1094
1326
  }
1095
1327
 
1328
+ function getPinnedItems() {
1329
+ const pins = safeJson(PINNED_ITEMS_PATH);
1330
+ return Array.isArray(pins) ? pins.filter(item => typeof item === 'string' && item) : [];
1331
+ }
1332
+
1096
1333
  // ── Throttle Tracker Factory ────────────────────────────────────────────────
1097
1334
  // Generic rate-limit tracker reusable by both ADO and GitHub integrations.
1098
1335
  // Returns an object with recordThrottle, recordSuccess, isThrottled, getState.
@@ -1149,6 +1386,7 @@ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32
1149
1386
  module.exports = {
1150
1387
  MINIONS_DIR,
1151
1388
  PR_LINKS_PATH,
1389
+ PINNED_ITEMS_PATH,
1152
1390
  LOG_PATH,
1153
1391
  ts,
1154
1392
  logTs,
@@ -1193,6 +1431,14 @@ module.exports = {
1193
1431
  projectPrPath,
1194
1432
  getPrLinks,
1195
1433
  addPrLink,
1434
+ parseCanonicalPrId,
1435
+ getProjectPrScope,
1436
+ getPrNumber,
1437
+ getPrDisplayId,
1438
+ getCanonicalPrId,
1439
+ findPrRecord,
1440
+ normalizePrRecord,
1441
+ normalizePrRecords,
1196
1442
  nextWorkItemId,
1197
1443
  getAdoOrgBase,
1198
1444
  sanitizePath,
@@ -1208,7 +1454,9 @@ module.exports = {
1208
1454
  LOCK_STALE_MS,
1209
1455
  flushLogs,
1210
1456
  slugify,
1457
+ safeSlugComponent,
1211
1458
  formatTranscriptEntry,
1459
+ getPinnedItems,
1212
1460
  _logBuffer, // exported for testing
1213
1461
  createThrottleTracker,
1214
1462
  backfillPrPrdItems,
package/engine/teams.js CHANGED
@@ -543,7 +543,7 @@ async function teamsNotifyPrEvent(pr, event, project, prFilePath) {
543
543
  if (prFilePath) {
544
544
  mutateJsonFileLocked(prFilePath, (prs) => {
545
545
  if (!Array.isArray(prs)) return prs;
546
- const target = prs.find(p => p.id === pr.id);
546
+ const target = shared.findPrRecord(prs, pr);
547
547
  if (target) {
548
548
  if (!target._teamsNotifiedEvents) target._teamsNotifiedEvents = [];
549
549
  if (!target._teamsNotifiedEvents.includes(event)) {
package/engine/watches.js CHANGED
@@ -20,6 +20,14 @@ function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watche
20
20
  // so watches are checked every N ticks where N = ceil(interval / tickInterval).
21
21
  const DEFAULT_WATCH_INTERVAL = 300000;
22
22
 
23
+ /** Find a PR by target (prNumber, canonical ID, or display ID). */
24
+ function findPrByTarget(pullRequests, target) {
25
+ const t = String(target);
26
+ return (pullRequests || []).find(p =>
27
+ String(p.prNumber) === t || p.id === t || shared.getPrDisplayId(p) === t
28
+ );
29
+ }
30
+
23
31
  /**
24
32
  * Read all watches from disk.
25
33
  * @returns {Array<object>}
@@ -131,9 +139,7 @@ function evaluateWatch(watch, state) {
131
139
  const { target, targetType, condition } = watch;
132
140
 
133
141
  if (targetType === WATCH_TARGET_TYPE.PR) {
134
- const pr = (state.pullRequests || []).find(p =>
135
- String(p.prNumber) === String(target) || p.id === target
136
- );
142
+ const pr = findPrByTarget(state.pullRequests, target);
137
143
  if (!pr) return { triggered: false, message: `PR ${target} not found` };
138
144
 
139
145
  // Store previous state for status-change detection
@@ -290,9 +296,7 @@ function checkWatches(config, state) {
290
296
  */
291
297
  function _captureState(watch, state) {
292
298
  if (watch.targetType === WATCH_TARGET_TYPE.PR) {
293
- const pr = (state.pullRequests || []).find(p =>
294
- String(p.prNumber) === String(watch.target) || p.id === watch.target
295
- );
299
+ const pr = findPrByTarget(state.pullRequests, watch.target);
296
300
  if (pr) return { status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus, lastCommentDate: pr.humanFeedback?.lastProcessedCommentDate || null };
297
301
  }
298
302
  if (watch.targetType === WATCH_TARGET_TYPE.WORK_ITEM) {
package/engine.js CHANGED
@@ -1878,7 +1878,7 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
1878
1878
  try {
1879
1879
  const prsPath = projectPrPath(projectMeta);
1880
1880
  mutatePullRequests(prsPath, prs => {
1881
- const target = prs.find(p => p.id === prId);
1881
+ const target = shared.findPrRecord(prs, prId, projectMeta);
1882
1882
  if (!target?.humanFeedback?.pendingFix) return;
1883
1883
  target.humanFeedback.pendingFix = false;
1884
1884
  });
@@ -1892,11 +1892,12 @@ async function discoverFromPrs(config, project) {
1892
1892
  const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
1893
1893
  if (!src?.enabled) return [];
1894
1894
 
1895
- const prs = safeJson(projectPrPath(project)) || [];
1895
+ const prs = queries.getPrs(project);
1896
1896
  const cooldownMs = (src.cooldownMinutes || 30) * 60 * 1000;
1897
1897
  const newWork = [];
1898
1898
 
1899
1899
  const projMeta = { name: project?.name, localPath: project?.localPath };
1900
+ const projectsByName = new Map(shared.getProjects(config).map(p => [p.name, p]));
1900
1901
 
1901
1902
  // Resolve poll-enabled per project — stale reviewStatus is untrustworthy without poller
1902
1903
  const isAdoProject = project?.repoHost !== 'github';
@@ -1908,12 +1909,21 @@ async function discoverFromPrs(config, project) {
1908
1909
  // Collect active PR dispatches to prevent simultaneous review+fix on same PR
1909
1910
  const dispatch = getDispatch();
1910
1911
  const activePrIds = new Set(
1911
- (dispatch.active || []).filter(d => d.meta?.pr?.id).map(d => d.meta.pr.id)
1912
+ (dispatch.active || [])
1913
+ .filter(d => d.meta?.pr?.id)
1914
+ .map(d => {
1915
+ const dispatchProject = d.meta?.project?.name
1916
+ ? (projectsByName.get(d.meta.project.name) || d.meta.project)
1917
+ : (d.meta?.project || null);
1918
+ return shared.getCanonicalPrId(dispatchProject, d.meta.pr, d.meta.pr?.url || '');
1919
+ })
1920
+ .filter(Boolean)
1912
1921
  );
1913
1922
 
1914
1923
  const knownAgents = new Set(Object.keys(config.agents || {}));
1915
1924
  for (const pr of prs) {
1916
1925
  if (pr.status !== PR_STATUS.ACTIVE || pr._contextOnly) continue;
1926
+ const prDisplayId = shared.getPrDisplayId(pr);
1917
1927
  if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
1918
1928
  // Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
1919
1929
  if (pr.branch && isBranchActive(pr.branch)) {
@@ -1925,7 +1935,7 @@ async function discoverFromPrs(config, project) {
1925
1935
  const isAgentPr = knownAgents.has((pr.agent || '').toLowerCase()) || (pr.prdItems && pr.prdItems.length > 0) || pr._autoObserve;
1926
1936
  if (!isAgentPr) continue;
1927
1937
 
1928
- const prNumber = (pr.id || '').replace(/^PR-/, '');
1938
+ const prNumber = shared.getPrNumber(pr);
1929
1939
  // Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
1930
1940
  // minionsReview tracks metadata (reviewer, note) but not the authoritative status
1931
1941
  const reviewStatus = pr.reviewStatus || 'pending';
@@ -1939,13 +1949,13 @@ async function discoverFromPrs(config, project) {
1939
1949
  const evalCycles = pr._reviewFixCycles || 0;
1940
1950
  const evalEscalated = evalCycles >= evalMax;
1941
1951
  if (evalEscalated && !pr._evalEscalated) {
1942
- writeInboxAlert(`eval-escalated-${pr.agent || 'unassigned'}-${pr.id}`,
1952
+ writeInboxAlert(`eval-escalated-${pr.agent || 'unassigned'}-${prDisplayId}`,
1943
1953
  `# Review Loop Escalation\n\n**PR ${pr.id}**: ${pr.title || ''} on branch \`${pr.branch || 'unknown'}\` has gone through **${evalCycles}** review→fix cycles without approval.\n\n` +
1944
1954
  `Last review: ${pr.minionsReview?.note ? pr.minionsReview.note.slice(0, 200) : 'See PR thread'}\n\n` +
1945
1955
  `Auto-dispatch of reviews and fixes has been suspended. Please review the PR manually.`);
1946
1956
  try {
1947
1957
  mutatePullRequests(projectPrPath(project), prs => {
1948
- const target = prs.find(p => p.id === pr.id);
1958
+ const target = shared.findPrRecord(prs, pr, project);
1949
1959
  if (target) target._evalEscalated = true;
1950
1960
  });
1951
1961
  } catch (e) { log('warn', 'mark eval escalated: ' + e.message); }
@@ -1957,7 +1967,7 @@ async function discoverFromPrs(config, project) {
1957
1967
  const alreadyReviewed = pr.lastReviewedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.lastReviewedAt);
1958
1968
  const needsReview = reviewEnabled && reviewStatus === 'pending' && !alreadyReviewed && !evalEscalated;
1959
1969
  if (needsReview) {
1960
- const key = `review-${project?.name || 'default'}-${pr.id}`;
1970
+ const key = `review-${project?.name || 'default'}-${prDisplayId}`;
1961
1971
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
1962
1972
 
1963
1973
  // Pre-dispatch live vote check — cached reviewStatus may be stale (poll lag ~6 min)
@@ -1972,7 +1982,7 @@ async function discoverFromPrs(config, project) {
1972
1982
  try {
1973
1983
  mutateJsonFileLocked(projectPrPath(project), data => {
1974
1984
  if (!Array.isArray(data)) return data;
1975
- const target = data.find(p => p.id === pr.id);
1985
+ const target = shared.findPrRecord(data, pr, project);
1976
1986
  if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
1977
1987
  return data;
1978
1988
  });
@@ -1998,7 +2008,7 @@ async function discoverFromPrs(config, project) {
1998
2008
  const needsReReview = reviewEnabled && reviewStatus === 'waiting' &&
1999
2009
  fixedAfterReview && !evalEscalated;
2000
2010
  if (needsReReview) {
2001
- const key = `rereview-${project?.name || 'default'}-${pr.id}`;
2011
+ const key = `rereview-${project?.name || 'default'}-${prDisplayId}`;
2002
2012
  // Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
2003
2013
  // completed-dispatch window would block legitimate re-reviews within the hour after a fix
2004
2014
  if (isOnCooldown(key, cooldownMs)) continue;
@@ -2013,7 +2023,7 @@ async function discoverFromPrs(config, project) {
2013
2023
  try {
2014
2024
  mutateJsonFileLocked(projectPrPath(project), data => {
2015
2025
  if (!Array.isArray(data)) return data;
2016
- const target = data.find(p => p.id === pr.id);
2026
+ const target = shared.findPrRecord(data, pr, project);
2017
2027
  if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
2018
2028
  return data;
2019
2029
  });
@@ -2036,7 +2046,7 @@ async function discoverFromPrs(config, project) {
2036
2046
  // Gate on evalLoopEnabled — the review→fix cycle is the eval loop
2037
2047
  let fixDispatched = false;
2038
2048
  if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
2039
- const key = `fix-${project?.name || 'default'}-${pr.id}`;
2049
+ const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
2040
2050
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2041
2051
  const agentId = resolveAgent('fix', config, pr.agent);
2042
2052
  if (!agentId) continue;
@@ -2050,7 +2060,7 @@ async function discoverFromPrs(config, project) {
2050
2060
  // Increment review→fix cycle counter
2051
2061
  try {
2052
2062
  mutatePullRequests(projectPrPath(project), prs => {
2053
- const target = prs.find(p => p.id === pr.id);
2063
+ const target = shared.findPrRecord(prs, pr, project);
2054
2064
  if (target) target._reviewFixCycles = (target._reviewFixCycles || 0) + 1;
2055
2065
  });
2056
2066
  } catch (e) { log('warn', 'increment review-fix cycles: ' + e.message); }
@@ -2058,7 +2068,7 @@ async function discoverFromPrs(config, project) {
2058
2068
  }
2059
2069
 
2060
2070
  // PRs with pending human feedback (skip if review-fix already dispatched above)
2061
- const humanFixKey = `human-fix-${project?.name || 'default'}-${pr.id}`;
2071
+ const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
2062
2072
  const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
2063
2073
  if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !awaitingReReview && !fixDispatched) {
2064
2074
  const key = humanFixKey;
@@ -2109,7 +2119,7 @@ async function discoverFromPrs(config, project) {
2109
2119
  // Check if max retry cap reached — escalate to human instead of dispatching another fix
2110
2120
  if ((pr.buildFixAttempts || 0) >= maxBuildFix) {
2111
2121
  if (!pr.buildFixEscalated) {
2112
- writeInboxAlert(`build-fix-escalated-${pr.agent || 'unassigned'}-${pr.id}`,
2122
+ writeInboxAlert(`build-fix-escalated-${pr.agent || 'unassigned'}-${prDisplayId}`,
2113
2123
  `# Build Fix Escalation\n\n` +
2114
2124
  `**PR ${pr.id}**: ${pr.title || ''} on branch \`${pr.branch || 'unknown'}\` has failed **${pr.buildFixAttempts}** consecutive auto-fix attempts.\n` +
2115
2125
  `**Last failure:** ${pr.buildFailReason || 'Check CI pipeline for details'}\n\n` +
@@ -2118,7 +2128,7 @@ async function discoverFromPrs(config, project) {
2118
2128
  try {
2119
2129
  const prPath = projectPrPath(project);
2120
2130
  mutatePullRequests(prPath, prs => {
2121
- const target = prs.find(p => p.id === pr.id);
2131
+ const target = shared.findPrRecord(prs, pr, project);
2122
2132
  if (target) target.buildFixEscalated = true;
2123
2133
  });
2124
2134
  } catch (e) { log('warn', 'mark build fix escalated: ' + e.message); }
@@ -2127,7 +2137,7 @@ async function discoverFromPrs(config, project) {
2127
2137
  continue;
2128
2138
  }
2129
2139
 
2130
- const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
2140
+ const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
2131
2141
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2132
2142
  const agentId = resolveAgent('fix', config, pr.agent);
2133
2143
  if (!agentId) continue;
@@ -2147,7 +2157,7 @@ async function discoverFromPrs(config, project) {
2147
2157
  try {
2148
2158
  const prPath = projectPrPath(project);
2149
2159
  mutatePullRequests(prPath, prs => {
2150
- const target = prs.find(p => p.id === pr.id);
2160
+ const target = shared.findPrRecord(prs, pr, project);
2151
2161
  if (target) {
2152
2162
  target.buildFixAttempts = (target.buildFixAttempts || 0) + 1;
2153
2163
  target._buildFixPushedAt = ts();
@@ -2167,12 +2177,12 @@ async function discoverFromPrs(config, project) {
2167
2177
  alertBody += `**Error preview:**\n\`\`\`\n${logPreview}\n\`\`\`\n\n`;
2168
2178
  }
2169
2179
  alertBody += `A fix agent has been dispatched to address this. Review the fix when complete.\n`;
2170
- writeInboxAlert(`build-fail-${pr.agent}-${pr.id}`, alertBody);
2180
+ writeInboxAlert(`build-fail-${pr.agent}-${prDisplayId}`, alertBody);
2171
2181
  // Mark notified to prevent duplicate alerts
2172
2182
  try {
2173
2183
  const prPath = projectPrPath(project);
2174
2184
  mutatePullRequests(prPath, prs => {
2175
- const target = prs.find(p => p.id === pr.id);
2185
+ const target = shared.findPrRecord(prs, pr, project);
2176
2186
  if (target) {
2177
2187
  target._buildFailNotified = true;
2178
2188
  }
@@ -2184,7 +2194,7 @@ async function discoverFromPrs(config, project) {
2184
2194
  // PRs with merge conflicts — dispatch fix to resolve (gated by autoFixConflicts)
2185
2195
  const autoFixConflicts = config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts;
2186
2196
  if (autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched) {
2187
- const key = `conflict-fix-${project?.name || 'default'}-${pr.id}`;
2197
+ const key = `conflict-fix-${project?.name || 'default'}-${prDisplayId}`;
2188
2198
  // Suppress re-dispatch for 10 min after last attempt — ADO/GitHub recomputes
2189
2199
  // mergeStatus asynchronously (1–5 min lag), so the flag may stay set even after
2190
2200
  // a successful push. _conflictFixedAt is cleared when the poller confirms clean status.
@@ -2203,7 +2213,7 @@ async function discoverFromPrs(config, project) {
2203
2213
  // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
2204
2214
  try {
2205
2215
  mutatePullRequests(projectPrPath(project), prs => {
2206
- const target = prs.find(p => p.id === pr.id);
2216
+ const target = shared.findPrRecord(prs, pr, project);
2207
2217
  if (target) target._conflictFixedAt = new Date().toISOString();
2208
2218
  });
2209
2219
  } catch (e) { log('warn', `conflict-fix timestamp: ${e.message}`); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1004",
3
+ "version": "0.1.1005",
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"