@yemi33/minions 0.1.1003 → 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.1003 (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,9 +45,9 @@
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
+ - 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
@@ -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
@@ -2533,7 +2542,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2533
2542
  const prLinks = shared.getPrLinks();
2534
2543
  const implContext = (plan.missing_features || []).map(f => {
2535
2544
  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));
2545
+ const pr = allPrs.find(p => (prLinks[p.id] || []).includes(f.id) || (p.prdItems || []).includes(f.id));
2537
2546
  return `- **${f.id}**: ${f.name} [status: ${wi?.status || f.status}]${pr ? ` (PR: ${pr.id}, branch: \`${pr.branch}\`)` : ''}`;
2538
2547
  }).join('\n');
2539
2548
 
@@ -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
@@ -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
 
@@ -250,7 +255,7 @@ async function forEachActivePr(config, token, callback) {
250
255
  for (let i = 0; i < activePrs.length; i += CONCURRENCY) {
251
256
  const batch = activePrs.slice(i, i + CONCURRENCY);
252
257
  const results = await Promise.allSettled(batch.map(async (pr) => {
253
- const prNum = (pr.id || '').replace('PR-', '');
258
+ const prNum = shared.getPrNumber(pr);
254
259
  if (!prNum) return false;
255
260
  return callback(project, pr, prNum, orgBase);
256
261
  }));
@@ -675,6 +680,7 @@ async function reconcilePrs(config) {
675
680
 
676
681
  const prPath = shared.projectPrPath(project);
677
682
  const existingPrs = shared.safeJson(prPath) || [];
683
+ shared.normalizePrRecords(existingPrs, project);
678
684
  const existingIds = new Set(existingPrs.map(p => p.id));
679
685
  let projectAdded = 0;
680
686
 
@@ -687,7 +693,8 @@ async function reconcilePrs(config) {
687
693
 
688
694
  let projectUpdated = 0;
689
695
  for (const adoPr of adoPrs) {
690
- const prId = `PR-${adoPr.pullRequestId}`;
696
+ const prUrl = getAdoPrUrl(project, adoPr.pullRequestId);
697
+ const prId = shared.getCanonicalPrId(project, adoPr.pullRequestId, prUrl);
691
698
  const branch = stripRefsHeads(adoPr.sourceRefName);
692
699
  const title = adoPr.title || '';
693
700
  // Extract item ID from branch name or PR title (e.g., feat(P-2cafdc2a): ...)
@@ -705,7 +712,7 @@ async function reconcilePrs(config) {
705
712
  }
706
713
  // PR already tracked — write link to pr-links.json if we can extract an ID
707
714
  if (confirmedItemId) {
708
- addPrLink(prId, confirmedItemId);
715
+ addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
709
716
  if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
710
717
  existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
711
718
  existing.prdItems.push(confirmedItemId);
@@ -720,7 +727,6 @@ async function reconcilePrs(config) {
720
727
  // are human-authored and should not be auto-tracked or auto-reviewed.
721
728
  if (!confirmedItemId) continue;
722
729
 
723
- const prUrl = project.prUrlBase ? project.prUrlBase + adoPr.pullRequestId : '';
724
730
  existingPrs.push({
725
731
  id: prId,
726
732
  prNumber: adoPr.pullRequestId,
@@ -733,7 +739,7 @@ async function reconcilePrs(config) {
733
739
  url: prUrl,
734
740
  prdItems: [confirmedItemId],
735
741
  });
736
- addPrLink(prId, confirmedItemId);
742
+ addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
737
743
  existingIds.add(prId);
738
744
  projectAdded++;
739
745
  log('info', `PR reconciliation: added ${prId} (branch: ${branch}, linked to ${confirmedItemId}) to ${project.name}`);
@@ -742,22 +748,13 @@ async function reconcilePrs(config) {
742
748
  // Backfill prNumber from pr.id for any PR missing it (e.g. created before prNumber was stored)
743
749
  for (const pr of existingPrs) {
744
750
  if (pr.prNumber == null) {
745
- const derived = parseInt((pr.id || '').replace(/^PR-/, ''), 10);
751
+ const derived = shared.getPrNumber(pr);
746
752
  if (derived) pr.prNumber = derived;
747
753
  }
748
754
  }
749
755
 
750
756
  // 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
- }
757
+ const backfilled = shared.backfillPrPrdItems(existingPrs, shared.getPrLinks());
761
758
 
762
759
  if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0) {
763
760
  mutateJsonFileLocked(prPath, (currentPrs) => {
@@ -790,7 +787,8 @@ async function checkLiveReviewStatus(pr, project) {
790
787
  const token = await getAdoToken();
791
788
  if (!token) return null;
792
789
  const orgBase = shared.getAdoOrgBase(project);
793
- const prNum = (pr.id || '').replace(/^PR-/, '');
790
+ const prNum = shared.getPrNumber(pr);
791
+ if (!prNum) return null;
794
792
  const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}?api-version=7.1`;
795
793
  const result = await execAsync(`curl -s --max-time 4 -H "Authorization: Bearer ${token}" "${url}"`, { encoding: 'utf-8', timeout: 5000, windowsHide: true });
796
794
  const prData = JSON.parse(result);
@@ -855,7 +853,7 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
855
853
  }));
856
854
  const logParts = [];
857
855
  const seenBuildIds = new Set();
858
- const pr = { id: `PR-${prNumber}`, branch: stripRefsHeads(prData.sourceRefName) };
856
+ const pr = { id: shared.getCanonicalPrId(project, prNumber, getAdoPrUrl(project, prNumber)), branch: stripRefsHeads(prData.sourceRefName) };
859
857
  for (const fb of failedBuilds.slice(0, 3)) {
860
858
  const errorLog = await fetchAdoBuildErrorLog(orgBase, project, fb, token, pr, seenBuildIds);
861
859
  if (errorLog) logParts.push(errorLog);
@@ -865,7 +863,7 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
865
863
  }
866
864
 
867
865
  const votes = (prData.reviewers || []).map(r => r.vote);
868
- const prUrl = `https://dev.azure.com/${project.adoOrg}/${project.adoProject}/_git/${project.repositoryId}/pullrequest/${prNumber}`;
866
+ const prUrl = getAdoPrUrl(project, prNumber);
869
867
 
870
868
  return {
871
869
  prNumber,
@@ -900,6 +898,10 @@ const getAdoThrottleState = () => _adoThrottle.getState();
900
898
  */
901
899
  async function findOpenPrOnBranch(project, branch) {
902
900
  if (!project.adoOrg || !project.adoProject || !project.repositoryId || !branch) return null;
901
+ if (isAdoThrottled()) {
902
+ log('debug', `[ado] Skipping branch PR lookup for ${project.name || project.repoName || 'unknown project'}:${branch} — throttled`);
903
+ return null;
904
+ }
903
905
  const token = await getAdoToken();
904
906
  if (!token) return null;
905
907
  const orgBase = shared.getAdoOrgBase(project);
@@ -909,7 +911,7 @@ async function findOpenPrOnBranch(project, branch) {
909
911
  const pr = (data.value || [])[0];
910
912
  if (!pr) return null;
911
913
  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}`;
914
+ const prUrl = getAdoPrUrl(project, prNumber);
913
915
  return { prNumber, url: prUrl };
914
916
  }
915
917
 
@@ -940,4 +942,3 @@ module.exports = {
940
942
  _resetAdoThrottle, // exported for testing
941
943
  _setAdoThrottleForTest, // exported for testing
942
944
  };
943
-
@@ -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
@@ -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
 
@@ -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,22 +685,13 @@ 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
  }
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) => {
@@ -728,7 +719,8 @@ async function checkLiveReviewStatus(pr, project) {
728
719
  try {
729
720
  const slug = getRepoSlug(project);
730
721
  if (!slug) return null;
731
- const prNum = (pr.id || '').replace(/^PR-/, '');
722
+ const prNum = shared.getPrNumber(pr);
723
+ if (!prNum) return null;
732
724
  const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
733
725
  if (!reviews || !Array.isArray(reviews)) return null;
734
726
  const latestByUser = new Map();
@@ -761,4 +753,3 @@ module.exports = {
761
753
  _ghPollBackoff,
762
754
  _ghThrottle, // exported for testing
763
755
  };
764
-