@yemi33/minions 0.1.1640 → 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,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1641 (2026-04-30)
4
+
5
+ ### Features
6
+ - harden PR attachment contract (#1908)
7
+
3
8
  ## 0.1.1640 (2026-04-30)
4
9
 
5
10
  ### Fixes
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 () => {
@@ -6426,6 +6422,7 @@ module.exports = {
6426
6422
  _parseDocChatResultText,
6427
6423
  _messageRequestsOrchestration,
6428
6424
  _formatDocChatContext,
6425
+ _linkPullRequestForTracking: linkPullRequestForTracking,
6429
6426
  _resolveSkillReadPath,
6430
6427
  DOC_CHAT_DOCUMENT_DELIMITER,
6431
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
 
@@ -803,7 +803,18 @@ async function reconcilePrs(config) {
803
803
  }
804
804
  // PR already tracked — write link to pr-links.json if we can extract an ID
805
805
  if (confirmedItemId) {
806
- 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 });
807
818
  if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
808
819
  existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
809
820
  existing.prdItems.push(confirmedItemId);
@@ -818,7 +829,7 @@ async function reconcilePrs(config) {
818
829
  // are human-authored and should not be auto-tracked or auto-reviewed.
819
830
  if (!confirmedItemId) continue;
820
831
 
821
- existingPrs.push({
832
+ const entry = {
822
833
  id: prId,
823
834
  prNumber: adoPr.pullRequestId,
824
835
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
@@ -829,8 +840,9 @@ async function reconcilePrs(config) {
829
840
  created: adoPr.creationDate || ts(),
830
841
  url: prUrl,
831
842
  prdItems: [confirmedItemId],
832
- });
833
- 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);
834
846
  existingIds.add(prId);
835
847
  projectAdded++;
836
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-30T17:09:32.809Z"
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
 
@@ -717,7 +717,18 @@ async function reconcilePrs(config) {
717
717
  metadataUpdated++;
718
718
  }
719
719
  if (confirmedItemId) {
720
- 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 });
721
732
  if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
722
733
  existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
723
734
  existing.prdItems.push(confirmedItemId);
@@ -732,7 +743,7 @@ async function reconcilePrs(config) {
732
743
  // Only auto-track PRs linked to a minions work item — skip human-authored PRs
733
744
  if (!confirmedItemId && !isE2eBranch) continue;
734
745
 
735
- currentPrs.push({
746
+ const entry = {
736
747
  id: prId,
737
748
  prNumber: ghPr.number,
738
749
  title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
@@ -743,8 +754,9 @@ async function reconcilePrs(config) {
743
754
  created: ghPr.created_at || ts(),
744
755
  url: prUrl,
745
756
  prdItems: [confirmedItemId],
746
- });
747
- 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);
748
760
  existingIds.add(prId);
749
761
  projectAdded++;
750
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1640",
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"