@yemi33/minions 0.1.1639 → 0.1.1641

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,9 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1639 (2026-04-30)
3
+ ## 0.1.1641 (2026-04-30)
4
+
5
+ ### Features
6
+ - harden PR attachment contract (#1908)
7
+
8
+ ## 0.1.1640 (2026-04-30)
9
+
10
+ ### Fixes
11
+ - surface unresolved pr branches
12
+
13
+ ## 0.1.1638 (2026-04-30)
4
14
 
5
15
  ### Fixes
6
- - auto-link agent-created PRs to work items (#1904)
7
16
  - Playbook 'fix' / 'review' gates items forever when pr_branch is unresolved (closes #1899) (#1901)
8
17
 
9
18
  ## 0.1.1637 (2026-04-30)
@@ -18,6 +18,9 @@ function prRow(pr) {
18
18
  const buildLabel = pr.buildFixEscalated ? 'escalated (' + (pr.buildFixAttempts || '?') + ' fixes)' : (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
19
19
  const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
20
20
  const statusLabel = pr.status || 'active';
21
+ const branchError = pr._branchResolutionError?.reason || '';
22
+ const branchLabel = pr.branch || (branchError ? 'missing branch' : '—');
23
+ const branchClass = 'pr-branch' + (branchError ? ' branch-missing' : '');
21
24
  const url = pr.url || '#';
22
25
  const prId = pr.id || '—';
23
26
  const pendingReason = pr._pendingReason ? String(pr._pendingReason) : '';
@@ -28,7 +31,7 @@ function prRow(pr) {
28
31
  '<td><span class="pr-id">' + escapeHtml(String(prId)) + '</span></td>' +
29
32
  '<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>' +
30
33
  '<td><span class="pr-agent">' + escapeHtml(pr.agent || '—') + '</span></td>' +
31
- '<td><span class="pr-branch">' + escapeHtml(pr.branch || '') + '</span>' + pendingReasonHtml + '</td>' +
34
+ '<td><span class="' + branchClass + '"' + (branchError ? ' title="' + escapeHtml(branchError) + '"' : '') + '>' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
32
35
  '<td><span class="pr-badge ' + reviewClass + '"' + (reviewTitle ? ' title="' + escapeHtml(reviewTitle) + '"' : '') + '>' + escapeHtml(reviewLabel) + '</span></td>' +
33
36
  '<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>' +
34
37
  '<td><span class="pr-badge ' + buildClass + '">' + escapeHtml(buildLabel) + '</span></td>' +
@@ -262,6 +262,7 @@
262
262
  .pr-id { color: var(--muted); font-family: Consolas, monospace; font-size: var(--text-base); }
263
263
  .pr-agent { font-size: var(--text-base); color: var(--text); }
264
264
  .pr-branch { font-family: Consolas, monospace; font-size: var(--text-sm); color: var(--muted); background: var(--bg); padding: var(--space-1) var(--space-3); border-radius: 3px; border: 1px solid var(--border); }
265
+ .pr-branch.branch-missing { color: var(--red); border-color: var(--red); background: rgba(248,81,73,0.12); }
265
266
  .pr-badge { font-size: var(--text-sm); font-weight: 600; padding: var(--space-1) var(--space-4); border-radius: var(--radius-xl); text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }
266
267
  .pr-badge.draft { background: rgba(139,148,158,0.15); color: var(--muted); border: 1px solid var(--border); }
267
268
  .pr-badge.active { background: rgba(88,166,255,0.15); color: var(--blue); border: 1px solid var(--blue); }
package/dashboard.js CHANGED
@@ -63,6 +63,55 @@ function reloadConfig() {
63
63
  PROJECTS = _getProjects(CONFIG);
64
64
  }
65
65
 
66
+ function getWorkItemIdFromPrLinkContext(context, workItemId) {
67
+ if (typeof workItemId === 'string' && workItemId.trim()) return workItemId.trim();
68
+ if (!context) return null;
69
+ if (typeof context === 'object' && typeof context.workItemId === 'string' && context.workItemId.trim()) return context.workItemId.trim();
70
+ if (typeof context === 'string') {
71
+ const match = context.match(/\b(P-[a-z0-9]{6,}|W-[a-z0-9]{6,}|PL-[a-z0-9]{6,})\b/i);
72
+ return match ? match[1] : null;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function linkPullRequestForTracking({ url, title, project: projectName, autoObserve, context, workItemId }, config = CONFIG) {
78
+ if (!url) {
79
+ const err = new Error('url required');
80
+ err.statusCode = 400;
81
+ throw err;
82
+ }
83
+ const projects = shared.getProjects(config);
84
+ const targetProject = projectName ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) : (projects[0] || null);
85
+ const prPath = targetProject ? shared.projectPrPath(targetProject) : path.join(MINIONS_DIR, 'pull-requests.json');
86
+
87
+ const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
88
+ const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
89
+ const prId = shared.getCanonicalPrId(targetProject, prNum, url);
90
+ const linkedWorkItemId = getWorkItemIdFromPrLinkContext(context, workItemId);
91
+ const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
92
+ const result = shared.upsertPullRequestRecord(prPath, {
93
+ id: prId,
94
+ prNumber: parseInt(prNum, 10) || null,
95
+ title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
96
+ description: '',
97
+ agent: 'human',
98
+ branch: '',
99
+ reviewStatus: 'pending',
100
+ status: 'active',
101
+ created: new Date().toISOString(),
102
+ url,
103
+ prdItems: linkedWorkItemId ? [linkedWorkItemId] : [],
104
+ _manual: true,
105
+ _contextOnly: !autoObserve,
106
+ _autoObserve: !!autoObserve,
107
+ _context: contextText,
108
+ }, {
109
+ project: targetProject,
110
+ itemId: linkedWorkItemId,
111
+ });
112
+ return { ...result, prPath, targetProject, prNum };
113
+ }
114
+
66
115
  function _normalizeSkillDirForCompare(dir) {
67
116
  const resolved = path.resolve(String(dir || '').replace(/\//g, path.sep));
68
117
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
@@ -5840,66 +5889,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5840
5889
  // Agents
5841
5890
  { 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
5891
  const body = await readBody(req);
5843
- const { url, title, project: projectName, autoObserve, context, workItemId } = body;
5892
+ const { url } = body;
5844
5893
  if (!url) return jsonReply(res, 400, { error: 'url required' });
5845
5894
 
5846
- // Determine project
5847
5895
  reloadConfig();
5848
- const projects = shared.getProjects(CONFIG);
5849
- const targetProject = projectName ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) : (projects[0] || null);
5850
- const prPath = targetProject ? shared.projectPrPath(targetProject) : path.join(MINIONS_DIR, 'pull-requests.json');
5851
-
5852
- // Extract PR number from URL
5853
- const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
5854
- const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
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
- || '';
5865
-
5866
- // Atomic check-and-insert to prevent duplicates and races with polling loops
5867
- let duplicate = false;
5868
- mutateJsonFileLocked(prPath, (prs) => {
5869
- if (!Array.isArray(prs)) prs = [];
5870
- if (prs.some(p => p.id === prId || p.url === url)) { duplicate = true; return prs; }
5871
- prs.push({
5872
- id: prId,
5873
- prNumber: parseInt(prNum, 10) || null,
5874
- title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
5875
- description: '',
5876
- agent: 'human',
5877
- branch: '',
5878
- reviewStatus: 'pending',
5879
- status: 'active',
5880
- created: new Date().toISOString(),
5881
- url,
5882
- prdItems: linkedItemId ? [linkedItemId] : [],
5883
- _manual: true,
5884
- _contextOnly: !autoObserve,
5885
- _autoObserve: !!autoObserve,
5886
- _context: contextText,
5887
- });
5888
- return prs;
5889
- }, { defaultValue: [] });
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
- }
5896
+ const { id: prId, prPath, targetProject, prNum, created, linked } = linkPullRequestForTracking(body, CONFIG);
5901
5897
  invalidateStatusCache();
5902
- jsonReply(res, 200, { ok: true, id: prId });
5898
+ jsonReply(res, 200, { ok: true, id: prId, created, linked });
5903
5899
 
5904
5900
  // Async-enrich: fetch title, description, branch, author from GitHub/ADO API
5905
5901
  (async () => {
@@ -5927,6 +5923,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5927
5923
  if (prData.description) pr.description = prData.description.slice(0, 500);
5928
5924
  if (!pr.branch && prData.branch) {
5929
5925
  pr.branch = prData.branch;
5926
+ if (pr._branchResolutionError) delete pr._branchResolutionError;
5930
5927
  if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
5931
5928
  }
5932
5929
  if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
@@ -6425,6 +6422,7 @@ module.exports = {
6425
6422
  _parseDocChatResultText,
6426
6423
  _messageRequestsOrchestration,
6427
6424
  _formatDocChatContext,
6425
+ _linkPullRequestForTracking: linkPullRequestForTracking,
6428
6426
  _resolveSkillReadPath,
6429
6427
  DOC_CHAT_DOCUMENT_DELIMITER,
6430
6428
  };
package/engine/ado.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  const path = require('path');
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS, createThrottleTracker } = shared;
8
+ const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, createThrottleTracker } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const { mutateJsonFileLocked } = shared;
11
11
 
@@ -357,6 +357,14 @@ async function pollPrStatus(config) {
357
357
 
358
358
  const prData = await adoFetch(`${repoBase}?api-version=7.1`, token);
359
359
 
360
+ const sourceBranch = stripRefsHeads(prData.sourceRefName);
361
+ if (sourceBranch && pr.branch !== sourceBranch) {
362
+ pr.branch = sourceBranch;
363
+ if (pr._branchResolutionError) delete pr._branchResolutionError;
364
+ if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
365
+ updated = true;
366
+ }
367
+
360
368
  let newStatus = pr.status;
361
369
  if (prData.status === 'completed') newStatus = PR_STATUS.MERGED;
362
370
  else if (prData.status === 'abandoned') newStatus = PR_STATUS.ABANDONED;
@@ -405,12 +413,6 @@ async function pollPrStatus(config) {
405
413
  pr._adoSourceCommit = sourceCommit;
406
414
  updated = true;
407
415
  }
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
- }
414
416
 
415
417
  const reviewers = prData.reviewers || [];
416
418
  const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
@@ -795,12 +797,24 @@ async function reconcilePrs(config) {
795
797
  }
796
798
  if (existing && !existing.branch && branch) {
797
799
  existing.branch = branch;
800
+ if (existing._branchResolutionError) delete existing._branchResolutionError;
798
801
  if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
799
802
  metadataUpdated++;
800
803
  }
801
804
  // PR already tracked — write link to pr-links.json if we can extract an ID
802
805
  if (confirmedItemId) {
803
- addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
806
+ shared.upsertPullRequestRecord(prPath, existing || {
807
+ id: prId,
808
+ prNumber: adoPr.pullRequestId,
809
+ title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
810
+ agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
811
+ branch,
812
+ reviewStatus: 'pending',
813
+ status: 'active',
814
+ created: adoPr.creationDate || ts(),
815
+ url: prUrl,
816
+ prdItems: [],
817
+ }, { project, itemId: confirmedItemId });
804
818
  if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
805
819
  existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
806
820
  existing.prdItems.push(confirmedItemId);
@@ -815,7 +829,7 @@ async function reconcilePrs(config) {
815
829
  // are human-authored and should not be auto-tracked or auto-reviewed.
816
830
  if (!confirmedItemId) continue;
817
831
 
818
- existingPrs.push({
832
+ const entry = {
819
833
  id: prId,
820
834
  prNumber: adoPr.pullRequestId,
821
835
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
@@ -826,8 +840,9 @@ async function reconcilePrs(config) {
826
840
  created: adoPr.creationDate || ts(),
827
841
  url: prUrl,
828
842
  prdItems: [confirmedItemId],
829
- });
830
- addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
843
+ };
844
+ const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
845
+ existingPrs.push(upserted.record || entry);
831
846
  existingIds.add(prId);
832
847
  projectAdded++;
833
848
  log('info', `PR reconciliation: added ${prId} (branch: ${branch}, linked to ${confirmedItemId}) to ${project.name}`);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-30T16:35:01.979Z"
4
+ "cachedAt": "2026-04-30T17:23:35.113Z"
5
5
  }
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, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker } = shared;
8
+ const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, 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
 
@@ -284,6 +284,7 @@ async function forEachActiveGhPr(config, callback) {
284
284
  if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
285
285
  if (!pr.branch && prData.head?.ref) {
286
286
  pr.branch = prData.head.ref;
287
+ if (pr._branchResolutionError) delete pr._branchResolutionError;
287
288
  if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
288
289
  }
289
290
  }
@@ -327,6 +328,14 @@ async function pollPrStatus(config) {
327
328
 
328
329
  let updated = false;
329
330
 
331
+ const headBranch = prData.head?.ref ? String(prData.head.ref).trim() : '';
332
+ if (headBranch && pr.branch !== headBranch) {
333
+ pr.branch = headBranch;
334
+ if (pr._branchResolutionError) delete pr._branchResolutionError;
335
+ if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
336
+ updated = true;
337
+ }
338
+
330
339
  // Map GitHub PR state to minions status
331
340
  let newStatus = pr.status;
332
341
  if (prData.merged) newStatus = PR_STATUS.MERGED;
@@ -703,11 +712,23 @@ async function reconcilePrs(config) {
703
712
  }
704
713
  if (existing && !existing.branch && branch) {
705
714
  existing.branch = branch;
715
+ if (existing._branchResolutionError) delete existing._branchResolutionError;
706
716
  if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
707
717
  metadataUpdated++;
708
718
  }
709
719
  if (confirmedItemId) {
710
- addPrLink(prId, confirmedItemId, { project, prNumber: ghPr.number, url: prUrl });
720
+ shared.upsertPullRequestRecord(prPath, existing || {
721
+ id: prId,
722
+ prNumber: ghPr.number,
723
+ title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
724
+ agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
725
+ branch,
726
+ reviewStatus: 'pending',
727
+ status: 'active',
728
+ created: ghPr.created_at || ts(),
729
+ url: prUrl,
730
+ prdItems: [],
731
+ }, { project, itemId: confirmedItemId });
711
732
  if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
712
733
  existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
713
734
  existing.prdItems.push(confirmedItemId);
@@ -722,7 +743,7 @@ async function reconcilePrs(config) {
722
743
  // Only auto-track PRs linked to a minions work item — skip human-authored PRs
723
744
  if (!confirmedItemId && !isE2eBranch) continue;
724
745
 
725
- currentPrs.push({
746
+ const entry = {
726
747
  id: prId,
727
748
  prNumber: ghPr.number,
728
749
  title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
@@ -733,8 +754,9 @@ async function reconcilePrs(config) {
733
754
  created: ghPr.created_at || ts(),
734
755
  url: prUrl,
735
756
  prdItems: [confirmedItemId],
736
- });
737
- addPrLink(prId, confirmedItemId, { project, prNumber: ghPr.number, url: prUrl });
757
+ };
758
+ const upserted = shared.upsertPullRequestRecord(prPath, entry, { project, itemId: confirmedItemId });
759
+ currentPrs.push(upserted.record || entry);
738
760
  existingIds.add(prId);
739
761
  projectAdded++;
740
762
 
@@ -7,7 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const shared = require('./shared');
10
- const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks, addPrLink,
10
+ const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, execSilent, execAsync, projectPrPath, getPrLinks,
11
11
  log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
12
12
  ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
13
13
  const { trackEngineUsage } = require('./llm');
@@ -853,53 +853,172 @@ function syncPrsFromOutput(output, agentId, meta, config) {
853
853
  const entryBranch = meta?.branch || '';
854
854
 
855
855
  for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
856
- const linksToPersist = [];
857
- mutateJsonFileLocked(prPath, (data) => {
858
- const prs = Array.isArray(data) ? data : [];
859
- // Normalize legacy YYYY-MM-DD created dates to ISO
860
- for (const p of prs) {
861
- if (p.created && p.created.length === 10) p.created = p.created + 'T00:00:00.000Z';
862
- }
863
- for (const { prId, fullId, entry } of entries) {
864
- if (prs.some(p => p.id === fullId || (p.url && p.url === entry.url))) continue;
865
-
866
- // Branch-level dedup: skip if an active PR already exists on the same branch.
867
- // This prevents duplicate PRs when an agent retries and calls `gh pr create` again
868
- // on the same branch (GitHub allows multiple PRs from one branch).
869
- // Only block when the existing PR is active — abandoned/merged PRs don't conflict.
870
- const branch = entry.branch || entryBranch;
871
- if (branch) {
872
- const existingOnBranch = prs.find(p => p.branch === branch && p.status === PR_STATUS.ACTIVE && p.id !== fullId);
873
- if (existingOnBranch) {
874
- log('warn', `Duplicate PR detected: ${fullId} on branch ${branch} — already tracked as ${existingOnBranch.id}. Skipping.`);
875
- // Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
876
- try {
877
- const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
878
- if (ghSlug) {
879
- execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${existingOnBranch.id} already tracks this branch."`, { timeout: 15000 })
880
- .catch(() => {});
881
- }
882
- } catch { /* best-effort */ }
883
- continue;
856
+ for (const { prId, fullId, entry } of entries) {
857
+ let duplicateOnBranch = null;
858
+ const result = shared.upsertPullRequestRecord(prPath, entry, {
859
+ project: targetProject,
860
+ itemId: meta?.item?.id || null,
861
+ beforeInsert: (prs, normalizedEntry) => {
862
+ // Normalize legacy YYYY-MM-DD created dates to ISO while the file is locked.
863
+ for (const p of prs) {
864
+ if (p.created && p.created.length === 10) p.created = p.created + 'T00:00:00.000Z';
884
865
  }
885
- }
886
-
887
- prs.push(entry);
888
- if (meta?.item?.id) {
889
- linksToPersist.push({ prId: fullId, itemId: meta.item.id, project: targetProject, prNumber: entry.prNumber, url: entry.url });
890
- }
891
- added++;
866
+ // Branch-level dedup: skip if an active PR already exists on the same branch.
867
+ // This prevents duplicate PRs when an agent retries and calls `gh pr create` again
868
+ // on the same branch (GitHub allows multiple PRs from one branch).
869
+ // Only block when the existing PR is active — abandoned/merged PRs don't conflict.
870
+ const branch = normalizedEntry.branch || entryBranch;
871
+ if (!branch) return true;
872
+ duplicateOnBranch = prs.find(p => p.branch === branch && p.status === PR_STATUS.ACTIVE && p.id !== normalizedEntry.id) || null;
873
+ return !duplicateOnBranch;
874
+ },
875
+ });
876
+ if (duplicateOnBranch) {
877
+ log('warn', `Duplicate PR detected: ${fullId} on branch ${entry.branch || entryBranch} — already tracked as ${duplicateOnBranch.id}. Skipping.`);
878
+ // Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
879
+ try {
880
+ const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
881
+ if (ghSlug) {
882
+ execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${duplicateOnBranch.id} already tracks this branch."`, { timeout: 15000 })
883
+ .catch(() => {});
884
+ }
885
+ } catch { /* best-effort */ }
886
+ continue;
892
887
  }
893
- return prs;
894
- });
895
- for (const { prId, itemId, project, prNumber, url } of linksToPersist) {
896
- addPrLink(prId, itemId, { project, prNumber, url });
888
+ if (result.created || result.linked) added++;
897
889
  }
898
890
  log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name}/pull-requests.json`);
899
891
  }
900
892
  return added;
901
893
  }
902
894
 
895
+ function isPrAttachmentRequired(type, item, meta = {}) {
896
+ if (!item?.id || item.skipPr) return false;
897
+ const explicit = item.requiresPr === true
898
+ || item.prRequired === true
899
+ || item.requiresPullRequest === true
900
+ || item.itemType === 'pr';
901
+ if (meta.branchStrategy === 'shared-branch' && item.itemType !== 'pr' && !explicit) return false;
902
+ return explicit
903
+ || type === WORK_TYPE.IMPLEMENT
904
+ || type === WORK_TYPE.IMPLEMENT_LARGE
905
+ || type === WORK_TYPE.FIX
906
+ || type === WORK_TYPE.TEST;
907
+ }
908
+
909
+ function hasCanonicalPrAttachment(itemId, config) {
910
+ if (!itemId) return false;
911
+ if (Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(itemId))) return true;
912
+ const projects = shared.getProjects(config);
913
+ for (const p of projects) {
914
+ const prs = safeJson(shared.projectPrPath(p)) || [];
915
+ if (prs.some(pr => (pr.prdItems || []).includes(itemId))) return true;
916
+ }
917
+ const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
918
+ return centralPrs.some(pr => (pr.prdItems || []).includes(itemId));
919
+ }
920
+
921
+ async function findOpenPrForBranch(meta, config) {
922
+ if (!meta?.branch) return null;
923
+ const projectObj = shared.getProjects(config).find(p => p.name === meta?.project?.name);
924
+ if (!projectObj) return null;
925
+ const host = projectObj.repoHost || 'ado';
926
+ if (host === 'github') {
927
+ const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
928
+ if (!ghSlug) return null;
929
+ for (let attempt = 0; attempt < 3; attempt++) {
930
+ if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
931
+ let raw = '';
932
+ try {
933
+ raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
934
+ const parsed = JSON.parse(raw || '[]');
935
+ const hits = Array.isArray(parsed) ? parsed : [];
936
+ if (hits.length > 0 && hits[0].state === 'OPEN') {
937
+ return { project: projectObj, prNumber: hits[0].number, url: hits[0].url };
938
+ }
939
+ if (attempt === 2) {
940
+ log('warn', `Auto-link fallback: no open PR found on branch ${meta.branch} after 3 attempts (raw: ${(raw || '').slice(0, 200)})`);
941
+ }
942
+ } catch (err) {
943
+ if (attempt === 2) {
944
+ const rawSuffix = raw ? ` (raw: ${raw.slice(0, 200)})` : '';
945
+ log('warn', `Auto-link fallback: gh pr list lookup failed on branch ${meta.branch} after 3 attempts: ${err.message}${rawSuffix}`);
946
+ }
947
+ }
948
+ }
949
+ return null;
950
+ }
951
+ if (host === 'ado') {
952
+ const found = await require('./ado').findOpenPrOnBranch(projectObj, meta.branch);
953
+ return found ? { project: projectObj, prNumber: found.prNumber, url: found.url } : null;
954
+ }
955
+ log('debug', `Skipping branch PR lookup for unsupported repo host "${host}" on ${projectObj.name}`);
956
+ return null;
957
+ }
958
+
959
+ function markMissingPrAttachment(meta, agentId, reason, resultSummary) {
960
+ const noPrWiPath = resolveWorkItemPath(meta);
961
+ if (noPrWiPath) {
962
+ mutateJsonFileLocked(noPrWiPath, data => {
963
+ if (!Array.isArray(data)) return data;
964
+ const w = data.find(i => i.id === meta.item.id);
965
+ if (!w) return data;
966
+ w.status = WI_STATUS.NEEDS_REVIEW;
967
+ w._missingPrAttachment = true;
968
+ w.failReason = reason;
969
+ w._lastReviewReason = reason;
970
+ delete w.completedAt;
971
+ delete w._noPr;
972
+ delete w._noPrReason;
973
+ return data;
974
+ }, { skipWriteIfUnchanged: true });
975
+ }
976
+ shared.writeToInbox('engine', `missing-pr-attachment-${meta.item.id}`,
977
+ `# PR attachment missing for ${meta.item.id}\n\n` +
978
+ `**Agent:** ${agentId}\n` +
979
+ `**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
980
+ `**Type:** ${meta.item.type || 'unknown'}\n` +
981
+ `**Branch:** ${meta.branch || '(none)'}\n\n` +
982
+ `${reason}\n` +
983
+ (resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
984
+ null,
985
+ { sourceItem: meta.item.id, reason: 'missing-pr-attachment' });
986
+ }
987
+
988
+ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary) {
989
+ if (!isPrAttachmentRequired(type, meta?.item, meta)) return null;
990
+ if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
991
+
992
+ const found = await findOpenPrForBranch(meta, config);
993
+ if (found) {
994
+ const entry = {
995
+ id: shared.getCanonicalPrId(found.project, found.prNumber, found.url),
996
+ prNumber: found.prNumber,
997
+ title: meta.item?.title || `PR #${found.prNumber}`,
998
+ agent: agentId,
999
+ branch: meta.branch || '',
1000
+ reviewStatus: 'pending',
1001
+ status: PR_STATUS.ACTIVE,
1002
+ created: ts(),
1003
+ url: found.url,
1004
+ prdItems: [meta.item.id],
1005
+ sourcePlan: meta.item?.sourcePlan || '',
1006
+ itemType: meta.item?.itemType || '',
1007
+ };
1008
+ shared.upsertPullRequestRecord(shared.projectPrPath(found.project), entry, {
1009
+ project: found.project,
1010
+ itemId: meta.item.id,
1011
+ });
1012
+ log('info', `Auto-linked existing PR ${entry.id} on branch ${meta.branch} for ${meta.item.id}`);
1013
+ if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1014
+ }
1015
+
1016
+ const reason = `PR-producing work item ${meta.item.id} completed without a canonically attached PR record. Successful completion requires PR.prdItems/pr-links.json to include the work item; branch names, note URLs, and _context.workItemId metadata are not sufficient.`;
1017
+ markMissingPrAttachment(meta, agentId, reason, resultSummary);
1018
+ log('warn', reason);
1019
+ return { reason, itemId: meta.item.id };
1020
+ }
1021
+
903
1022
  // ─── Post-Completion Hooks ──────────────────────────────────────────────────
904
1023
 
905
1024
  /**
@@ -1688,8 +1807,8 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1688
1807
  log('info', `Structured completion reports PR (${structuredCompletion.pr}) but regex sync found none — PR may already be tracked`);
1689
1808
  }
1690
1809
 
1691
- // Auto-recover: if a failed implement/fix agent created PRs, it likely succeeded before the failure surfaced.
1692
- const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX;
1810
+ // Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
1811
+ const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX || type === WORK_TYPE.TEST;
1693
1812
  const autoRecovered = !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
1694
1813
  if (autoRecovered) {
1695
1814
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
@@ -1799,6 +1918,12 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1799
1918
  }
1800
1919
  }
1801
1920
 
1921
+ let completionContractFailure = null;
1922
+ if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
1923
+ completionContractFailure = await enforcePrAttachmentContract(type, meta, agentId, config, resultSummary);
1924
+ if (completionContractFailure) skipDoneStatus = true;
1925
+ }
1926
+
1802
1927
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
1803
1928
  meta._agentId = agentId;
1804
1929
  updateWorkItemStatus(meta, WI_STATUS.DONE, '');
@@ -1898,131 +2023,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1898
2023
  }
1899
2024
  }
1900
2025
 
1901
- // Detect implement tasks that completed without creating a PR
1902
- if (effectiveSuccess && (type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX) && prsCreatedCount === 0 && meta?.item?.id && !meta?.item?.skipPr && meta?.project?.localPath) {
1903
- // Check if a PR already exists linked to this work item (from a previous attempt)
1904
- let existingPrFound = Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(meta.item.id));
1905
- // Also check pull-requests.json for PRs with matching prdItems or branch
1906
- if (!existingPrFound) {
1907
- const allProjects = shared.getProjects(config);
1908
- for (const p of allProjects) {
1909
- const prs = safeJson(shared.projectPrPath(p)) || [];
1910
- if (prs.some(pr => (pr.prdItems || []).includes(meta.item.id) || (pr.branch && pr.branch.includes(meta.item.id)))) {
1911
- existingPrFound = true;
1912
- break;
1913
- }
1914
- }
1915
- }
1916
- // Last resort: query the platform directly for an open PR on this branch.
1917
- // Handles the case where a prior orphaned dispatch created a PR but the engine
1918
- // never processed its output — so the PR exists on the platform but not in pull-requests.json.
1919
- if (!existingPrFound && meta?.branch) {
1920
- const projectObj = shared.getProjects(config).find(p => p.name === meta?.project?.name);
1921
- if (projectObj) {
1922
- try {
1923
- let found = null;
1924
- const host = projectObj.repoHost || 'ado';
1925
- if (host === 'github') {
1926
- const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
1927
- if (ghSlug) {
1928
- // Retry up to 3 times — newly created PRs can take a few seconds to appear in the API
1929
- for (let attempt = 0; attempt < 3 && !found; attempt++) {
1930
- if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
1931
- let raw = '';
1932
- try {
1933
- raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
1934
- const parsed = JSON.parse(raw || '[]');
1935
- const hits = Array.isArray(parsed) ? parsed : [];
1936
- if (hits.length > 0 && hits[0].state === 'OPEN') {
1937
- found = { prNumber: hits[0].number, url: hits[0].url };
1938
- } else if (attempt === 2) {
1939
- log('warn', `Auto-link fallback: no open PR found on branch ${meta.branch} after 3 attempts (raw: ${(raw || '').slice(0, 200)})`);
1940
- }
1941
- } catch (err) {
1942
- if (attempt === 2) {
1943
- const rawSuffix = raw ? ` (raw: ${raw.slice(0, 200)})` : '';
1944
- log('warn', `Auto-link fallback: gh pr list lookup failed on branch ${meta.branch} after 3 attempts: ${err.message}${rawSuffix}`);
1945
- }
1946
- }
1947
- }
1948
- }
1949
- } else if (host === 'ado') {
1950
- found = await require('./ado').findOpenPrOnBranch(projectObj, meta.branch);
1951
- } else {
1952
- log('debug', `Skipping branch PR lookup for unsupported repo host "${host}" on ${projectObj.name}`);
1953
- }
1954
- if (found) {
1955
- const fullId = shared.getCanonicalPrId(projectObj, found.prNumber, found.url);
1956
- const prPath = shared.projectPrPath(projectObj);
1957
- mutateJsonFileLocked(prPath, prs => {
1958
- if (!Array.isArray(prs)) prs = [];
1959
- const existingPr = prs.find(p => p.id === fullId);
1960
- if (existingPr) {
1961
- if (meta.item?.id) {
1962
- if (!Array.isArray(existingPr.prdItems)) existingPr.prdItems = [];
1963
- if (!existingPr.prdItems.includes(meta.item.id)) existingPr.prdItems.push(meta.item.id);
1964
- }
1965
- return prs;
1966
- }
1967
- prs.push({
1968
- id: fullId, prNumber: found.prNumber, title: meta.item?.title || '',
1969
- agent: agentId, branch: meta.branch, reviewStatus: 'pending',
1970
- status: PR_STATUS.ACTIVE, created: ts(), url: found.url,
1971
- prdItems: meta.item?.id ? [meta.item.id] : [],
1972
- sourcePlan: meta.item?.sourcePlan || '', itemType: meta.item?.itemType || '',
1973
- });
1974
- return prs;
1975
- });
1976
- log('info', `Auto-linked existing PR ${fullId} on branch ${meta.branch} for ${meta.item?.id}`);
1977
- existingPrFound = true;
1978
- }
1979
- } catch (e) { log('warn', `PR lookup for branch ${meta.branch}: ${e.message}`); }
1980
- }
1981
- }
1982
- if (!existingPrFound) {
1983
- const noPrWiPath = resolveWorkItemPath(meta);
1984
- if (noPrWiPath) {
1985
- const hasOutput = stdout && stdout.length > 500;
1986
- let action = null;
1987
- mutateJsonFileLocked(noPrWiPath, data => {
1988
- if (!Array.isArray(data)) return data;
1989
- const w = data.find(i => i.id === meta.item.id);
1990
- if (!w) return data;
1991
- const retries = w._retryCount || 0;
1992
- if (!hasOutput && retries < ENGINE_DEFAULTS.maxRetries) {
1993
- w.status = WI_STATUS.PENDING;
1994
- w._retryCount = retries + 1;
1995
- delete w.dispatched_at;
1996
- delete w.dispatched_to;
1997
- delete w.failReason;
1998
- delete w.noPr;
1999
- action = { type: 'retry', retries: retries + 1 };
2000
- } else if (hasOutput) {
2001
- w.status = WI_STATUS.DONE;
2002
- w.completedAt = ts();
2003
- w._noPr = true;
2004
- w._noPrReason = 'Agent completed without creating a PR (changes may already exist or not be needed)';
2005
- delete w.failReason;
2006
- action = { type: 'done' };
2007
- } else {
2008
- w.status = WI_STATUS.NEEDS_REVIEW;
2009
- w._noPr = true;
2010
- w.failReason = 'Completed without output or PR after ' + ENGINE_DEFAULTS.maxRetries + ' attempts';
2011
- action = { type: 'needs-review' };
2012
- }
2013
- return data;
2014
- }, { skipWriteIfUnchanged: true });
2015
- if (action?.type === 'retry') {
2016
- log('info', `Auto-retry ${action.retries}/${ENGINE_DEFAULTS.maxRetries} for ${meta.item.id} (no output, no PR)`);
2017
- } else if (action?.type === 'done') {
2018
- log('info', `${meta.item.id} completed without PR — marking done (agent produced output)`);
2019
- } else if (action?.type === 'needs-review') {
2020
- log('warn', `${meta.item.id} needs review — no output after ${ENGINE_DEFAULTS.maxRetries} retries`);
2021
- }
2022
- }
2023
- }
2024
- }
2025
-
2026
2026
  // Old plan-to-prd PRD check removed — moved before updateWorkItemStatus(DONE) to fix #893
2027
2027
  // (retryCount was being deleted by done-marking before the check could read it)
2028
2028
  // Review verdict check similarly moved before updateWorkItemStatus(DONE) — same root cause.
@@ -2046,7 +2046,8 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2046
2046
  }
2047
2047
  }
2048
2048
  checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
2049
- if (effectiveSuccess) {
2049
+ const finalResult = completionContractFailure ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
2050
+ if (finalResult === DISPATCH_RESULT.SUCCESS) {
2050
2051
  extractSkillsFromOutput(stdout, agentId, dispatchItem, config);
2051
2052
  // Also scan inbox notes for skill blocks — agents often write skills to inbox, not stdout
2052
2053
  try {
@@ -2061,7 +2062,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2061
2062
  }
2062
2063
  } catch {}
2063
2064
  }
2064
- const finalResult = effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
2065
2065
  updateAgentHistory(agentId, dispatchItem, finalResult);
2066
2066
  // Don't count auto-retries as errors in metrics — only count final outcomes
2067
2067
  const isAutoRetry = !effectiveSuccess && meta?.item?.id && (meta.item._retryCount || 0) < ENGINE_DEFAULTS.maxRetries;
@@ -2074,7 +2074,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2074
2074
  teams.teamsNotifyCompletion(dispatchItem, finalResult, agentId).catch(() => {});
2075
2075
  } catch {}
2076
2076
 
2077
- return { resultSummary, taskUsage, autoRecovered, structuredCompletion };
2077
+ return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure };
2078
2078
  }
2079
2079
 
2080
2080
  // ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
package/engine/shared.js CHANGED
@@ -1896,6 +1896,80 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
1896
1896
  });
1897
1897
  }
1898
1898
 
1899
+ /**
1900
+ * Canonical PR-producing work contract helper.
1901
+ *
1902
+ * Dashboard rendering derives work-item PR columns from PR.prdItems (with
1903
+ * engine/pr-links.json as a compatibility fallback). Any path that discovers or
1904
+ * manually records a PR for a work item must use this helper so the PR record
1905
+ * and the canonical work-item attachment are created together and idempotently.
1906
+ */
1907
+ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null, itemIds = null, beforeInsert = null } = {}) {
1908
+ if (!prPath) throw new Error('prPath required');
1909
+ if (!entry || typeof entry !== 'object') throw new Error('entry required');
1910
+
1911
+ const linkedItemIds = normalizePrLinkItems([
1912
+ ...(Array.isArray(entry.prdItems) ? entry.prdItems : []),
1913
+ ...(Array.isArray(itemIds) ? itemIds : [itemId]),
1914
+ ]);
1915
+ const prNumber = getPrNumber(entry.prNumber ?? entry.id ?? entry.url);
1916
+ const canonicalId = getCanonicalPrId(project, entry.prNumber ?? entry.id ?? entry.url ?? prNumber, entry.url || '');
1917
+ if (!canonicalId) throw new Error('PR id required');
1918
+ const normalizedEntry = {
1919
+ ...entry,
1920
+ id: canonicalId,
1921
+ prNumber: prNumber ?? entry.prNumber ?? null,
1922
+ prdItems: linkedItemIds,
1923
+ };
1924
+
1925
+ let created = false;
1926
+ let linked = false;
1927
+ let skipped = false;
1928
+ let record = null;
1929
+
1930
+ mutatePullRequests(prPath, (prs) => {
1931
+ normalizePrRecords(prs, project);
1932
+ let target = findPrRecord(prs, normalizedEntry, project);
1933
+ if (!target && typeof beforeInsert === 'function' && beforeInsert(prs, normalizedEntry) === false) {
1934
+ skipped = true;
1935
+ return prs;
1936
+ }
1937
+ if (!target) {
1938
+ target = normalizedEntry;
1939
+ prs.push(target);
1940
+ created = true;
1941
+ } else {
1942
+ target.id = canonicalId;
1943
+ if (prNumber != null) target.prNumber = prNumber;
1944
+ for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType']) {
1945
+ if (normalizedEntry[key] != null && normalizedEntry[key] !== '' && (target[key] == null || target[key] === '')) {
1946
+ target[key] = normalizedEntry[key];
1947
+ }
1948
+ }
1949
+ for (const key of ['_manual', '_contextOnly', '_autoObserve', '_context']) {
1950
+ if (normalizedEntry[key] != null) target[key] = normalizedEntry[key];
1951
+ }
1952
+ }
1953
+ target.prdItems = normalizePrLinkItems(target.prdItems || []);
1954
+ for (const linkedItemId of linkedItemIds) {
1955
+ if (!target.prdItems.includes(linkedItemId)) {
1956
+ target.prdItems.push(linkedItemId);
1957
+ linked = true;
1958
+ }
1959
+ }
1960
+ record = { ...target, prdItems: [...target.prdItems] };
1961
+ return prs;
1962
+ });
1963
+
1964
+ if (!skipped) {
1965
+ for (const linkedItemId of linkedItemIds) {
1966
+ addPrLink(canonicalId, linkedItemId, { project, prNumber, url: normalizedEntry.url || '' });
1967
+ }
1968
+ }
1969
+
1970
+ return { id: canonicalId, prNumber, created, linked, skipped, record };
1971
+ }
1972
+
1899
1973
  // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
1900
1974
 
1901
1975
  function killGracefully(proc, graceMs = 5000) {
@@ -2203,6 +2277,7 @@ module.exports = {
2203
2277
  findPrRecord,
2204
2278
  normalizePrRecord,
2205
2279
  normalizePrRecords,
2280
+ upsertPullRequestRecord,
2206
2281
  nextWorkItemId,
2207
2282
  getAdoOrgBase,
2208
2283
  sanitizePath,
package/engine.js CHANGED
@@ -1268,15 +1268,19 @@ async function spawnAgent(dispatchItem, config) {
1268
1268
  }
1269
1269
 
1270
1270
  // Parse output and run all post-completion hooks
1271
- const { resultSummary, autoRecovered } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
1271
+ const { resultSummary, autoRecovered, completionContractFailure } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
1272
1272
 
1273
1273
  // Move from active to completed in dispatch (single source of truth for agent status)
1274
1274
  // autoRecovered: agent failed after creating PRs — treat as success
1275
- const effectiveResult = (code === 0 || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
1276
- const completeOpts = effectiveResult === DISPATCH_RESULT.ERROR && failureClass ? { failureClass } : {};
1275
+ const effectiveResult = completionContractFailure ? DISPATCH_RESULT.ERROR : ((code === 0 || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
1276
+ const completeOpts = completionContractFailure
1277
+ ? { processWorkItemFailure: false }
1278
+ : (effectiveResult === DISPATCH_RESULT.ERROR && failureClass ? { failureClass } : {});
1277
1279
  // Extract last 5 non-empty stderr lines as error context when exit code is non-zero
1278
1280
  let errorReason = '';
1279
- if (effectiveResult === DISPATCH_RESULT.ERROR) {
1281
+ if (completionContractFailure) {
1282
+ errorReason = completionContractFailure.reason || 'PR attachment contract failed';
1283
+ } else if (effectiveResult === DISPATCH_RESULT.ERROR) {
1280
1284
  errorReason = stderr.split('\n').filter(l => l.trim()).slice(-5).join(' | ').trim().slice(0, 300);
1281
1285
  // W-mo3zul9pirjb — when claude CLI exits in <3s with code 1 and no output (the
1282
1286
  // "silent crash" pattern seen during scheduled tasks when the box went to sleep
@@ -1982,56 +1986,99 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
1982
1986
 
1983
1987
  const PR_PENDING_MISSING_BRANCH = 'missing_pr_branch';
1984
1988
 
1985
- function getPrDispatchBranch(pr) {
1986
- return typeof pr?.branch === 'string' ? pr.branch.trim() : '';
1989
+ function normalizePrBranch(value) {
1990
+ const raw = value == null ? '' : String(value).trim();
1991
+ if (!raw) return '';
1992
+ return raw.replace(/^refs\/heads\//i, '');
1987
1993
  }
1988
1994
 
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;
1995
+ function resolvePrBranch(pr) {
1996
+ if (!pr || typeof pr !== 'object') return '';
1997
+ const candidates = [
1998
+ pr.branch,
1999
+ pr.pr_branch,
2000
+ pr.prBranch,
2001
+ pr.sourceRefName,
2002
+ pr.sourceBranch,
2003
+ pr.sourceRef,
2004
+ pr.headRefName,
2005
+ pr.head?.ref,
2006
+ ];
2007
+ for (const candidate of candidates) {
2008
+ const branch = normalizePrBranch(candidate);
2009
+ if (branch) return branch;
2010
+ }
2011
+ return '';
1999
2012
  }
2000
2013
 
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) {
2014
+ // Unified branch-resolution state writer: when branch is found, persist it and
2015
+ // clear BOTH the structured _branchResolutionError (red-badge UI) and the
2016
+ // _pendingReason marker (dashboard pending-reason vocabulary). When branch is
2017
+ // missing, set BOTH fields so the dashboard can surface the gate via either
2018
+ // rendering path.
2019
+ function updatePrBranchResolutionState(project, pr, { branch = '', reason = '' } = {}) {
2020
+ let changed = false;
2021
+ try {
2022
+ mutatePullRequests(projectPrPath(project), prs => {
2023
+ const target = shared.findPrRecord(prs, pr, project);
2024
+ if (!target) return;
2025
+ if (branch) {
2026
+ if (target.branch !== branch) {
2027
+ target.branch = branch;
2028
+ changed = true;
2029
+ }
2030
+ if (target._branchResolutionError) {
2031
+ delete target._branchResolutionError;
2032
+ changed = true;
2033
+ }
2034
+ if (target._pendingReason === PR_PENDING_MISSING_BRANCH) {
2035
+ delete target._pendingReason;
2036
+ changed = true;
2037
+ }
2038
+ return;
2039
+ }
2040
+ if (reason) {
2041
+ const currentReason = target._branchResolutionError?.reason || '';
2042
+ if (currentReason !== reason) {
2043
+ target._branchResolutionError = { reason, at: ts() };
2044
+ changed = true;
2045
+ }
2046
+ if (target._pendingReason !== PR_PENDING_MISSING_BRANCH) {
2047
+ target._pendingReason = PR_PENDING_MISSING_BRANCH;
2048
+ changed = true;
2049
+ }
2050
+ }
2051
+ });
2052
+ } catch (e) {
2053
+ log('warn', `mark PR branch resolution state for ${pr?.id || 'unknown PR'}: ${e.message}`);
2054
+ }
2055
+ if (branch) {
2056
+ pr.branch = branch;
2057
+ if (pr._branchResolutionError) delete pr._branchResolutionError;
2058
+ if (pr._pendingReason === PR_PENDING_MISSING_BRANCH) delete pr._pendingReason;
2059
+ } else if (reason && changed) {
2060
+ pr._branchResolutionError = { reason, at: ts() };
2009
2061
  pr._pendingReason = PR_PENDING_MISSING_BRANCH;
2010
- log('warn', `PR ${pr.id}: cannot dispatch ${action} — missing pr_branch; waiting for PR metadata enrichment`);
2011
2062
  }
2012
2063
  return changed;
2013
2064
  }
2014
2065
 
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;
2066
+ function ensurePrBranchForDispatch(project, pr, automationType) {
2067
+ const branch = resolvePrBranch(pr);
2068
+ if (branch) {
2069
+ if (pr.branch !== branch || pr._branchResolutionError || pr._pendingReason === PR_PENDING_MISSING_BRANCH) {
2070
+ updatePrBranchResolutionState(project, pr, { branch });
2071
+ }
2072
+ return branch;
2073
+ }
2074
+ const reason = `Cannot dispatch ${automationType} for ${shared.getPrDisplayId(pr)}: missing pr_branch/source branch metadata. Link or refresh the PR so the source branch is known.`;
2075
+ if (updatePrBranchResolutionState(project, pr, { reason })) {
2076
+ log('warn', `PR ${pr.id}: ${reason}`);
2030
2077
  }
2031
- markPrMissingBranch(project, pr, action);
2032
- return false;
2078
+ return '';
2033
2079
  }
2034
2080
 
2081
+
2035
2082
  /**
2036
2083
  * Scan pull-requests.json for PRs needing review or fixes
2037
2084
  */
@@ -2073,10 +2120,10 @@ async function discoverFromPrs(config, project) {
2073
2120
  const prDisplayId = shared.getPrDisplayId(pr);
2074
2121
  const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
2075
2122
  if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
2076
- if (getPrDispatchBranch(pr)) clearPrMissingBranch(project, pr);
2123
+ const prBranchForMutex = resolvePrBranch(pr);
2077
2124
  // Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
2078
- if (pr.branch && isBranchActive(pr.branch)) {
2079
- log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${pr.branch} locked by another agent`);
2125
+ if (prBranchForMutex && isBranchActive(prBranchForMutex)) {
2126
+ log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${prBranchForMutex} locked by another agent`);
2080
2127
  continue;
2081
2128
  }
2082
2129
  // Skip human-authored PRs not linked to any work item — only auto-manage agent PRs
@@ -2114,7 +2161,6 @@ async function discoverFromPrs(config, project) {
2114
2161
  if (needsReview) {
2115
2162
  const key = `review-${project?.name || 'default'}-${prDisplayId}`;
2116
2163
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2117
- if (!canDispatchPrBranch(project, pr, 'review')) continue;
2118
2164
 
2119
2165
  // Pre-dispatch live vote check — cached reviewStatus may be stale (poll lag ~6 min)
2120
2166
  try {
@@ -2139,11 +2185,13 @@ async function discoverFromPrs(config, project) {
2139
2185
 
2140
2186
  const agentId = resolveAgent('review', config);
2141
2187
  if (!agentId) continue;
2188
+ const prBranch = ensurePrBranchForDispatch(project, pr, 'review');
2189
+ if (!prBranch) continue;
2142
2190
 
2143
2191
  const item = buildPrDispatch(agentId, config, project, pr, 'review', {
2144
- pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2192
+ pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
2145
2193
  pr_author: pr.agent || '', pr_url: pr.url || '',
2146
- }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2194
+ }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2147
2195
  if (item) { newWork.push(item); }
2148
2196
  }
2149
2197
 
@@ -2158,7 +2206,6 @@ async function discoverFromPrs(config, project) {
2158
2206
  // Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
2159
2207
  // completed-dispatch window would block legitimate re-reviews within the hour after a fix
2160
2208
  if (isOnCooldown(key, cooldownMs)) continue;
2161
- if (!canDispatchPrBranch(project, pr, 're-review')) continue;
2162
2209
 
2163
2210
  // Pre-dispatch live vote check — cached 'waiting' may be stale if reviewer already acted
2164
2211
  try {
@@ -2181,11 +2228,13 @@ async function discoverFromPrs(config, project) {
2181
2228
 
2182
2229
  const agentId = resolveAgent('review', config);
2183
2230
  if (!agentId) continue;
2231
+ const prBranch = ensurePrBranchForDispatch(project, pr, 're-review');
2232
+ if (!prBranch) continue;
2184
2233
 
2185
2234
  const item = buildPrDispatch(agentId, config, project, pr, 'review', {
2186
- pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2235
+ pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
2187
2236
  pr_author: pr.agent || '', pr_url: pr.url || '',
2188
- }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2237
+ }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2189
2238
  if (item) { newWork.push(item); }
2190
2239
  }
2191
2240
 
@@ -2195,14 +2244,15 @@ async function discoverFromPrs(config, project) {
2195
2244
  if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
2196
2245
  const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
2197
2246
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2198
- if (!canDispatchPrBranch(project, pr, 'fix')) continue;
2199
2247
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2200
2248
  if (!agentId) continue;
2249
+ const prBranch = ensurePrBranchForDispatch(project, pr, 'fix');
2250
+ if (!prBranch) continue;
2201
2251
 
2202
2252
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2203
- pr_id: pr.id, pr_branch: pr.branch || '',
2253
+ pr_id: pr.id, pr_branch: prBranch,
2204
2254
  review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
2205
- }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2255
+ }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2206
2256
  if (item) {
2207
2257
  newWork.push(item); setCooldown(key); fixDispatched = true;
2208
2258
  // Increment review→fix cycle counter
@@ -2235,9 +2285,10 @@ async function discoverFromPrs(config, project) {
2235
2285
  }
2236
2286
  continue;
2237
2287
  }
2238
- if (!canDispatchPrBranch(project, pr, 'human-feedback fix')) continue;
2239
2288
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2240
2289
  if (!agentId) continue;
2290
+ const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
2291
+ if (!prBranch) continue;
2241
2292
 
2242
2293
  const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
2243
2294
  let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
@@ -2247,10 +2298,10 @@ async function discoverFromPrs(config, project) {
2247
2298
  }
2248
2299
 
2249
2300
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2250
- pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
2301
+ pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
2251
2302
  reviewer: 'Human Reviewer',
2252
2303
  review_note: reviewNote,
2253
- }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: projMeta });
2304
+ }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
2254
2305
  if (item) { newWork.push(item); fixDispatched = true; }
2255
2306
  }
2256
2307
 
@@ -2283,7 +2334,6 @@ async function discoverFromPrs(config, project) {
2283
2334
 
2284
2335
  const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
2285
2336
  if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2286
- if (!canDispatchPrBranch(project, pr, 'build fix')) continue;
2287
2337
 
2288
2338
  // Pre-dispatch live build check — cached buildStatus may be stale: ADO can
2289
2339
  // recompute the merge commit when master moves and pollPrStatus deliberately
@@ -2319,6 +2369,8 @@ async function discoverFromPrs(config, project) {
2319
2369
 
2320
2370
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2321
2371
  if (!agentId) continue;
2372
+ const prBranch = ensurePrBranchForDispatch(project, pr, 'build-fix');
2373
+ if (!prBranch) continue;
2322
2374
 
2323
2375
  let reviewNote = `Build is failing: ${pr.buildFailReason || 'Check CI pipeline for details'}. Fix the build errors and push.`;
2324
2376
  if (pr.buildErrorLog) {
@@ -2326,9 +2378,9 @@ async function discoverFromPrs(config, project) {
2326
2378
  }
2327
2379
 
2328
2380
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2329
- pr_id: pr.id, pr_branch: pr.branch || '',
2381
+ pr_id: pr.id, pr_branch: prBranch,
2330
2382
  review_note: reviewNote,
2331
- }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2383
+ }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2332
2384
  if (item) {
2333
2385
  newWork.push(item); setCooldown(key); fixDispatched = true;
2334
2386
  // Increment build fix attempts counter
@@ -2368,7 +2420,6 @@ async function discoverFromPrs(config, project) {
2368
2420
  const conflictFixedAt = pr._conflictFixedAt;
2369
2421
  const withinLag = conflictFixedAt && Date.now() - new Date(conflictFixedAt).getTime() < 10 * 60 * 1000;
2370
2422
  if (!withinLag && !fixThrottled && !isAlreadyDispatched(key) && !isOnCooldown(key, cooldownMs)) {
2371
- if (!canDispatchPrBranch(project, pr, 'conflict fix')) continue;
2372
2423
  // Pre-dispatch live conflict check — cached `_mergeConflict` may be
2373
2424
  // stale: ADO/GitHub recompute mergeStatus asynchronously (1–5 min lag),
2374
2425
  // so a successful upstream merge can leave the flag set even after the
@@ -2395,10 +2446,12 @@ async function discoverFromPrs(config, project) {
2395
2446
  if (!liveSkip) {
2396
2447
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2397
2448
  if (agentId) {
2449
+ const prBranch = ensurePrBranchForDispatch(project, pr, 'conflict-fix');
2450
+ if (!prBranch) continue;
2398
2451
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2399
- pr_id: pr.id, pr_branch: pr.branch || '',
2452
+ pr_id: pr.id, pr_branch: prBranch,
2400
2453
  review_note: `This PR has merge conflicts with the target branch. Resolve the conflicts:\n\n1. Pull latest from main/master\n2. Resolve all conflicts (prefer PR branch changes unless main has critical fixes)\n3. Build and test after resolving\n4. Push the resolved branch`,
2401
- }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2454
+ }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
2402
2455
  if (item) {
2403
2456
  newWork.push(item);
2404
2457
  setCooldown(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1639",
3
+ "version": "0.1.1641",
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"