@yemi33/minions 0.1.1637 → 0.1.1639

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1639 (2026-04-30)
4
+
5
+ ### Fixes
6
+ - auto-link agent-created PRs to work items (#1904)
7
+ - Playbook 'fix' / 'review' gates items forever when pr_branch is unresolved (closes #1899) (#1901)
8
+
3
9
  ## 0.1.1637 (2026-04-30)
4
10
 
5
11
  ### Other
@@ -20,11 +20,15 @@ function prRow(pr) {
20
20
  const statusLabel = pr.status || 'active';
21
21
  const url = pr.url || '#';
22
22
  const prId = pr.id || '—';
23
+ const pendingReason = pr._pendingReason ? String(pr._pendingReason) : '';
24
+ const pendingReasonHtml = pendingReason
25
+ ? '<div style="font-size:9px;color:var(--muted);margin-top:2px" title="Pending reason: ' + escapeHtml(pendingReason) + '">' + escapeHtml(pendingReason.replace(/_/g, ' ')) + '</div>'
26
+ : '';
23
27
  return '<tr>' +
24
28
  '<td><span class="pr-id">' + escapeHtml(String(prId)) + '</span></td>' +
25
29
  '<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
26
30
  '<td><span class="pr-agent">' + escapeHtml(pr.agent || '—') + '</span></td>' +
27
- '<td><span class="pr-branch">' + escapeHtml(pr.branch || '—') + '</span></td>' +
31
+ '<td><span class="pr-branch">' + escapeHtml(pr.branch || '—') + '</span>' + pendingReasonHtml + '</td>' +
28
32
  '<td><span class="pr-badge ' + reviewClass + '"' + (reviewTitle ? ' title="' + escapeHtml(reviewTitle) + '"' : '') + '>' + escapeHtml(reviewLabel) + '</span></td>' +
29
33
  '<td>' + (sq.reviewer && sq.status !== 'waiting' ? '<span class="pr-agent" title="' + escapeHtml(sq.note || '') + '">' + escapeHtml(sq.reviewer) + '</span>' : sq.reviewer && sq.status === 'waiting' ? '<span class="pr-agent" style="color:var(--muted)" title="Vote pending confirmation">' + escapeHtml(sq.reviewer) + '…</span>' : pr.reviewedBy && pr.reviewedBy.length ? '<span class="pr-agent">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>' : '<span style="color:var(--muted);font-size:11px">—</span>') + '</td>' +
30
34
  '<td><span class="pr-badge ' + buildClass + '">' + escapeHtml(buildLabel) + '</span></td>' +
package/dashboard.js CHANGED
@@ -5838,9 +5838,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5838
5838
  // /api/prd/regenerate removed — use /api/plans/approve which does diff-aware update
5839
5839
 
5840
5840
  // Agents
5841
- { method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, autoObserve?, context?', handler: async (req, res) => {
5841
+ { method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, autoObserve?, context?, workItemId?', handler: async (req, res) => {
5842
5842
  const body = await readBody(req);
5843
- const { url, title, project: projectName, autoObserve, context } = body;
5843
+ const { url, title, project: projectName, autoObserve, context, workItemId } = body;
5844
5844
  if (!url) return jsonReply(res, 400, { error: 'url required' });
5845
5845
 
5846
5846
  // Determine project
@@ -5853,6 +5853,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5853
5853
  const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
5854
5854
  const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
5855
5855
  const prId = shared.getCanonicalPrId(targetProject, prNum, url);
5856
+ const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
5857
+
5858
+ // Resolve a work-item association from either the top-level workItemId
5859
+ // field (preferred) or context.workItemId (legacy CC payload shape).
5860
+ // Without this, manually-linked PRs end up with prdItems=[] and the
5861
+ // Work Items page renders no PR even though _context records the ID.
5862
+ const linkedItemId = (typeof workItemId === 'string' && workItemId.trim())
5863
+ || (context && typeof context === 'object' && typeof context.workItemId === 'string' && context.workItemId.trim())
5864
+ || '';
5856
5865
 
5857
5866
  // Atomic check-and-insert to prevent duplicates and races with polling loops
5858
5867
  let duplicate = false;
@@ -5870,15 +5879,25 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5870
5879
  status: 'active',
5871
5880
  created: new Date().toISOString(),
5872
5881
  url,
5873
- prdItems: [],
5882
+ prdItems: linkedItemId ? [linkedItemId] : [],
5874
5883
  _manual: true,
5875
5884
  _contextOnly: !autoObserve,
5876
5885
  _autoObserve: !!autoObserve,
5877
- _context: context || '',
5886
+ _context: contextText,
5878
5887
  });
5879
5888
  return prs;
5880
5889
  }, { defaultValue: [] });
5881
5890
  if (duplicate) return jsonReply(res, 400, { error: 'PR already tracked' });
5891
+ // Persist the work-item ↔ PR association in pr-links.json so
5892
+ // queries.getWorkItems() can render item._pr / item._prUrl. addPrLink
5893
+ // is idempotent and handles the central / project-scoped split.
5894
+ if (linkedItemId) {
5895
+ try {
5896
+ shared.addPrLink(prId, linkedItemId, { project: targetProject, prNumber: parseInt(prNum, 10) || null, url });
5897
+ } catch (e) {
5898
+ shared.log('warn', `PR link addPrLink failed for ${prId} → ${linkedItemId}: ${e.message}`);
5899
+ }
5900
+ }
5882
5901
  invalidateStatusCache();
5883
5902
  jsonReply(res, 200, { ok: true, id: prId });
5884
5903
 
@@ -5906,7 +5925,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5906
5925
  // Remote title always wins — any user-supplied title is a placeholder (closes #1283)
5907
5926
  if (prData.title) pr.title = prData.title.slice(0, 120);
5908
5927
  if (prData.description) pr.description = prData.description.slice(0, 500);
5909
- if (!pr.branch && prData.branch) pr.branch = prData.branch;
5928
+ if (!pr.branch && prData.branch) {
5929
+ pr.branch = prData.branch;
5930
+ if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
5931
+ }
5910
5932
  if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
5911
5933
  return prs;
5912
5934
  }, { defaultValue: [] });
package/engine/ado.js CHANGED
@@ -405,6 +405,12 @@ async function pollPrStatus(config) {
405
405
  pr._adoSourceCommit = sourceCommit;
406
406
  updated = true;
407
407
  }
408
+ const sourceBranch = stripRefsHeads(prData.sourceRefName);
409
+ if (!pr.branch && sourceBranch) {
410
+ pr.branch = sourceBranch;
411
+ if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
412
+ updated = true;
413
+ }
408
414
 
409
415
  const reviewers = prData.reviewers || [];
410
416
  const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
@@ -759,6 +765,7 @@ async function reconcilePrs(config) {
759
765
  shared.normalizePrRecords(existingPrs, project);
760
766
  const existingIds = new Set(existingPrs.map(p => p.id));
761
767
  let projectAdded = 0;
768
+ let metadataUpdated = 0;
762
769
 
763
770
  // Load work items to match branches to work item IDs
764
771
  const wiPath = shared.projectWorkItemsPath(project);
@@ -786,6 +793,11 @@ async function reconcilePrs(config) {
786
793
  if (existing && existing.prNumber == null) {
787
794
  existing.prNumber = adoPr.pullRequestId;
788
795
  }
796
+ if (existing && !existing.branch && branch) {
797
+ existing.branch = branch;
798
+ if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
799
+ metadataUpdated++;
800
+ }
789
801
  // PR already tracked — write link to pr-links.json if we can extract an ID
790
802
  if (confirmedItemId) {
791
803
  addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
@@ -832,7 +844,7 @@ async function reconcilePrs(config) {
832
844
  // Backfill prdItems from pr-links for any PR with empty array
833
845
  const backfilled = shared.backfillPrPrdItems(existingPrs, shared.getPrLinks());
834
846
 
835
- if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0) {
847
+ if (projectAdded > 0 || projectUpdated > 0 || backfilled > 0 || metadataUpdated > 0) {
836
848
  mutateJsonFileLocked(prPath, (currentPrs) => {
837
849
  // Merge reconciled PRs into the locked copy by ID
838
850
  for (const pr of existingPrs) {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-30T15:02:24.638Z"
4
+ "cachedAt": "2026-04-30T16:35:01.979Z"
5
5
  }
package/engine/github.js CHANGED
@@ -282,7 +282,10 @@ async function forEachActiveGhPr(config, callback) {
282
282
  }
283
283
  if (pr.description === undefined) pr.description = (prData.body || '').slice(0, 500);
284
284
  if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
285
- if (!pr.branch && prData.head?.ref) pr.branch = prData.head.ref;
285
+ if (!pr.branch && prData.head?.ref) {
286
+ pr.branch = prData.head.ref;
287
+ if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
288
+ }
286
289
  }
287
290
  }
288
291
  centralUpdated++;
@@ -673,6 +676,7 @@ async function reconcilePrs(config) {
673
676
  const currentPrs = safeJson(prPath) || [];
674
677
  shared.normalizePrRecords(currentPrs, project);
675
678
  const existingIds = new Set(currentPrs.map(p => p.id));
679
+ let metadataUpdated = 0;
676
680
  let projectAdded = 0;
677
681
 
678
682
  // Load work items to match branches
@@ -697,6 +701,11 @@ async function reconcilePrs(config) {
697
701
  if (existing && existing.prNumber == null) {
698
702
  existing.prNumber = ghPr.number;
699
703
  }
704
+ if (existing && !existing.branch && branch) {
705
+ existing.branch = branch;
706
+ if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
707
+ metadataUpdated++;
708
+ }
700
709
  if (confirmedItemId) {
701
710
  addPrLink(prId, confirmedItemId, { project, prNumber: ghPr.number, url: prUrl });
702
711
  if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
@@ -743,7 +752,7 @@ async function reconcilePrs(config) {
743
752
  // Backfill prdItems from pr-links for any PR with empty array
744
753
  const backfilled = backfillPrPrdItems(currentPrs, getPrLinks());
745
754
 
746
- if (projectAdded > 0 || backfilled > 0) {
755
+ if (projectAdded > 0 || backfilled > 0 || metadataUpdated > 0) {
747
756
  mutateJsonFileLocked(prPath, (lockedPrs) => {
748
757
  for (const pr of currentPrs) {
749
758
  const idx = lockedPrs.findIndex(p => p.id === pr.id);
@@ -744,13 +744,33 @@ function syncPrsFromOutput(output, agentId, meta, config) {
744
744
  }
745
745
  } catch {}
746
746
 
747
+ // prId → URL captured from inbox notes. Populated alongside prMatches so
748
+ // extractPrUrl below has a fallback when the agent's stdout doesn't contain
749
+ // the URL (the W-moljyu60wuzr / #1902 case — gh pr create ran in a sibling
750
+ // dispatch and only the inbox note carries the link).
751
+ const inboxUrls = new Map();
747
752
  const today = dateStamp();
748
753
  const inboxFiles = getInboxFiles().filter(f => f.includes(agentId) && f.includes(today));
749
754
  for (const f of inboxFiles) {
750
755
  const content = safeRead(path.join(INBOX_DIR, f));
751
756
  if (!content) continue;
752
- const prHeaderPattern = /\*\*PR[:\*]*\*?\s*[#-]*\s*(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
753
- while ((match = prHeaderPattern.exec(content)) !== null) prMatches.add(match[1] || match[2]);
757
+ // Match a PR declaration line in the agent's findings note: optional bold,
758
+ // optional "Pull Request" spelling, line-anchored so "see PR https://..."
759
+ // mid-paragraph mentions don't trigger a false-positive. The protocol
760
+ // and host prefix is optional so "PR: https://github.com/..." ,
761
+ // "**PR:** github.com/...", etc. all match.
762
+ const prHeaderPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request)[:\*]*\*?\s*[#-]*\s*(?:https?:\/\/)?[^\s"]*?(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
763
+ while ((match = prHeaderPattern.exec(content)) !== null) {
764
+ const prId = match[1] || match[2];
765
+ prMatches.add(prId);
766
+ // Pull the URL substring out of the matched chunk so we can hand it to
767
+ // extractPrUrl as a fallback. Prefer the first inbox URL we see for a
768
+ // given prId — later notes don't override the canonical record.
769
+ if (!inboxUrls.has(prId)) {
770
+ const urlMatch = match[0].match(/https?:\/\/[^\s"\\)]+/);
771
+ if (urlMatch) inboxUrls.set(prId, urlMatch[0].replace(/[.,;:]+$/, ''));
772
+ }
773
+ }
754
774
  }
755
775
 
756
776
  if (prMatches.size === 0) return 0;
@@ -773,7 +793,10 @@ function syncPrsFromOutput(output, agentId, meta, config) {
773
793
  return defaultProject;
774
794
  }
775
795
 
776
- // Extract PR URL directly from agent output — no manual construction
796
+ // Extract PR URL directly from agent output — no manual construction.
797
+ // Falls back to the URL captured from the inbox note when the agent stdout
798
+ // doesn't contain the link (gh pr create may have run in a sibling dispatch
799
+ // whose stdout was rotated; the inbox note is the durable artifact).
777
800
  function extractPrUrl(prId) {
778
801
  // Stop at backslash in addition to whitespace/quotes — raw JSONL encodes newlines as \n (literal
779
802
  // backslash-n), so without this the regex would capture e.g. "pull/1804\n/usr/bin/bash".
@@ -781,7 +804,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
781
804
  if (ghMatch) return ghMatch[0].replace(/[.,;:]+$/, '');
782
805
  const adoMatch = output.match(new RegExp(`https?://(?:dev\\.azure\\.com|[^/]+\\.visualstudio\\.com)[^\\s"'\\)\\]\\\\]*?pullrequest/${prId}(?:[^\\s"'\\)\\]\\\\]*)`, 'i'));
783
806
  if (adoMatch) return adoMatch[0].replace(/[.,;:]+$/, '');
784
- return '';
807
+ return inboxUrls.get(prId) || '';
785
808
  }
786
809
 
787
810
  const agentName = config.agents?.[agentId]?.name || agentId;
package/engine.js CHANGED
@@ -1980,6 +1980,58 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
1980
1980
  } catch (e) { log('warn', 'clear pending human feedback flag: ' + e.message); }
1981
1981
  }
1982
1982
 
1983
+ const PR_PENDING_MISSING_BRANCH = 'missing_pr_branch';
1984
+
1985
+ function getPrDispatchBranch(pr) {
1986
+ return typeof pr?.branch === 'string' ? pr.branch.trim() : '';
1987
+ }
1988
+
1989
+ function mutatePrPendingReason(project, pr, mutator) {
1990
+ const prPath = projectPrPath(project);
1991
+ let changed = false;
1992
+ mutatePullRequests(prPath, prs => {
1993
+ const target = shared.findPrRecord(prs, pr, project);
1994
+ if (!target) return prs;
1995
+ changed = mutator(target) === true;
1996
+ return prs;
1997
+ });
1998
+ return changed;
1999
+ }
2000
+
2001
+ function markPrMissingBranch(project, pr, action) {
2002
+ if (pr._pendingReason === PR_PENDING_MISSING_BRANCH) return false;
2003
+ const changed = mutatePrPendingReason(project, pr, target => {
2004
+ if (target._pendingReason === PR_PENDING_MISSING_BRANCH) return false;
2005
+ target._pendingReason = PR_PENDING_MISSING_BRANCH;
2006
+ return true;
2007
+ });
2008
+ if (changed) {
2009
+ pr._pendingReason = PR_PENDING_MISSING_BRANCH;
2010
+ log('warn', `PR ${pr.id}: cannot dispatch ${action} — missing pr_branch; waiting for PR metadata enrichment`);
2011
+ }
2012
+ return changed;
2013
+ }
2014
+
2015
+ function clearPrMissingBranch(project, pr) {
2016
+ if (pr._pendingReason !== PR_PENDING_MISSING_BRANCH) return false;
2017
+ const changed = mutatePrPendingReason(project, pr, target => {
2018
+ if (target._pendingReason !== PR_PENDING_MISSING_BRANCH) return false;
2019
+ delete target._pendingReason;
2020
+ return true;
2021
+ });
2022
+ if (changed) delete pr._pendingReason;
2023
+ return changed;
2024
+ }
2025
+
2026
+ function canDispatchPrBranch(project, pr, action) {
2027
+ if (getPrDispatchBranch(pr)) {
2028
+ clearPrMissingBranch(project, pr);
2029
+ return true;
2030
+ }
2031
+ markPrMissingBranch(project, pr, action);
2032
+ return false;
2033
+ }
2034
+
1983
2035
  /**
1984
2036
  * Scan pull-requests.json for PRs needing review or fixes
1985
2037
  */
@@ -2021,6 +2073,7 @@ async function discoverFromPrs(config, project) {
2021
2073
  const prDisplayId = shared.getPrDisplayId(pr);
2022
2074
  const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
2023
2075
  if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
2076
+ if (getPrDispatchBranch(pr)) clearPrMissingBranch(project, pr);
2024
2077
  // Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
2025
2078
  if (pr.branch && isBranchActive(pr.branch)) {
2026
2079
  log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${pr.branch} locked by another agent`);
@@ -2061,6 +2114,7 @@ async function discoverFromPrs(config, project) {
2061
2114
  if (needsReview) {
2062
2115
  const key = `review-${project?.name || 'default'}-${prDisplayId}`;
2063
2116
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2117
+ if (!canDispatchPrBranch(project, pr, 'review')) continue;
2064
2118
 
2065
2119
  // Pre-dispatch live vote check — cached reviewStatus may be stale (poll lag ~6 min)
2066
2120
  try {
@@ -2104,6 +2158,7 @@ async function discoverFromPrs(config, project) {
2104
2158
  // Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
2105
2159
  // completed-dispatch window would block legitimate re-reviews within the hour after a fix
2106
2160
  if (isOnCooldown(key, cooldownMs)) continue;
2161
+ if (!canDispatchPrBranch(project, pr, 're-review')) continue;
2107
2162
 
2108
2163
  // Pre-dispatch live vote check — cached 'waiting' may be stale if reviewer already acted
2109
2164
  try {
@@ -2140,6 +2195,7 @@ async function discoverFromPrs(config, project) {
2140
2195
  if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
2141
2196
  const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
2142
2197
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2198
+ if (!canDispatchPrBranch(project, pr, 'fix')) continue;
2143
2199
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2144
2200
  if (!agentId) continue;
2145
2201
 
@@ -2179,6 +2235,7 @@ async function discoverFromPrs(config, project) {
2179
2235
  }
2180
2236
  continue;
2181
2237
  }
2238
+ if (!canDispatchPrBranch(project, pr, 'human-feedback fix')) continue;
2182
2239
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2183
2240
  if (!agentId) continue;
2184
2241
 
@@ -2226,6 +2283,7 @@ async function discoverFromPrs(config, project) {
2226
2283
 
2227
2284
  const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
2228
2285
  if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2286
+ if (!canDispatchPrBranch(project, pr, 'build fix')) continue;
2229
2287
 
2230
2288
  // Pre-dispatch live build check — cached buildStatus may be stale: ADO can
2231
2289
  // recompute the merge commit when master moves and pollPrStatus deliberately
@@ -2310,6 +2368,7 @@ async function discoverFromPrs(config, project) {
2310
2368
  const conflictFixedAt = pr._conflictFixedAt;
2311
2369
  const withinLag = conflictFixedAt && Date.now() - new Date(conflictFixedAt).getTime() < 10 * 60 * 1000;
2312
2370
  if (!withinLag && !fixThrottled && !isAlreadyDispatched(key) && !isOnCooldown(key, cooldownMs)) {
2371
+ if (!canDispatchPrBranch(project, pr, 'conflict fix')) continue;
2313
2372
  // Pre-dispatch live conflict check — cached `_mergeConflict` may be
2314
2373
  // stale: ADO/GitHub recompute mergeStatus asynchronously (1–5 min lag),
2315
2374
  // so a successful upstream merge can leave the flag set even after the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1637",
3
+ "version": "0.1.1639",
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"