@yemi33/minions 0.1.1653 → 0.1.1655

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1655 (2026-05-01)
4
+
5
+ ### Features
6
+ - ADO manual PR link metadata and branch race
7
+
8
+ ## 0.1.1654 (2026-05-01)
9
+
10
+ ### Fixes
11
+ - harden orchestration reliability
12
+
3
13
  ## 0.1.1653 (2026-05-01)
4
14
 
5
15
  ### Fixes
@@ -98,6 +98,10 @@ async function openSettings() {
98
98
  '<div style="margin-left:20px;padding-left:10px;border-left:2px solid var(--border);display:flex;flex-direction:column;gap:4px">' +
99
99
  settingsToggle('Auto-fix Builds', 'set-autoFixBuilds', e.autoFixBuilds !== false, 'Dispatch gate: auto-fix agent when build fails (downstream of ADO Polling)') +
100
100
  settingsToggle('Auto-fix Conflicts', 'set-autoFixConflicts', e.autoFixConflicts !== false, 'Dispatch gate: auto-fix agent when merge conflict detected (downstream of ADO Polling)') +
101
+ settingsToggle('Auto-review PRs', 'set-autoReviewPrs', e.autoReviewPrs !== false, 'Dispatch gate: review agent for newly opened agent PRs (throttle-aware)') +
102
+ settingsToggle('Auto-re-review PRs', 'set-autoReReviewPrs', e.autoReReviewPrs !== false, 'Dispatch gate: review agent after a fix push is awaiting re-review (throttle-aware)') +
103
+ settingsToggle('Auto-fix Review Feedback', 'set-autoFixReviewFeedback', e.autoFixReviewFeedback !== false, 'Dispatch gate: fix agent for minions changes-requested verdicts (throttle-aware)') +
104
+ settingsToggle('Auto-fix Human Comments', 'set-autoFixHumanComments', e.autoFixHumanComments !== false, 'Dispatch gate: fix agent for actionable human PR comments (throttle-aware)') +
101
105
  '</div>' +
102
106
  '</div>' +
103
107
  settingsToggle('GitHub Polling', 'set-ghPollEnabled', e.ghPollEnabled !== false, 'Keeps GitHub PR build results, votes, and comments fresh each tick (reconciliation always runs regardless)') +
@@ -547,6 +551,10 @@ async function saveSettings() {
547
551
  autoArchive: document.getElementById('set-autoArchive').checked,
548
552
  autoFixBuilds: document.getElementById('set-autoFixBuilds').checked,
549
553
  autoFixConflicts: document.getElementById('set-autoFixConflicts').checked,
554
+ autoReviewPrs: document.getElementById('set-autoReviewPrs').checked,
555
+ autoReReviewPrs: document.getElementById('set-autoReReviewPrs').checked,
556
+ autoFixReviewFeedback: document.getElementById('set-autoFixReviewFeedback').checked,
557
+ autoFixHumanComments: document.getElementById('set-autoFixHumanComments').checked,
550
558
  autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
551
559
  adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
552
560
  ghPollEnabled: document.getElementById('set-ghPollEnabled').checked,
package/dashboard.js CHANGED
@@ -75,7 +75,42 @@ function getWorkItemIdFromPrLinkContext(context, workItemId) {
75
75
  return null;
76
76
  }
77
77
 
78
- function linkPullRequestForTracking({ url, title, project: projectName, autoObserve, context, workItemId }, config = CONFIG) {
78
+ function decodeUrlSegment(segment) {
79
+ try { return decodeURIComponent(segment); } catch { return segment; }
80
+ }
81
+
82
+ function parseAdoPrMetadataTarget(url) {
83
+ const raw = String(url || '');
84
+ const devAzure = raw.match(/https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
85
+ if (devAzure) {
86
+ return {
87
+ adoOrg: decodeUrlSegment(devAzure[1]),
88
+ adoProj: decodeUrlSegment(devAzure[2]),
89
+ adoRepo: decodeUrlSegment(devAzure[3]),
90
+ prNum: devAzure[4],
91
+ };
92
+ }
93
+ const visualStudio = raw.match(/https?:\/\/([^/.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
94
+ if (!visualStudio) return null;
95
+ return {
96
+ adoOrg: `${decodeUrlSegment(visualStudio[1])}.visualstudio.com`,
97
+ adoProj: decodeUrlSegment(visualStudio[2]),
98
+ adoRepo: decodeUrlSegment(visualStudio[3]),
99
+ prNum: visualStudio[4],
100
+ };
101
+ }
102
+
103
+ function normalizePrMetadata(metadata) {
104
+ if (!metadata || typeof metadata !== 'object') return null;
105
+ return {
106
+ title: typeof metadata.title === 'string' ? metadata.title.trim() : '',
107
+ description: typeof metadata.description === 'string' ? metadata.description : '',
108
+ branch: typeof metadata.branch === 'string' ? metadata.branch.trim().replace(/^refs\/heads\//i, '') : '',
109
+ author: typeof metadata.author === 'string' ? metadata.author.trim() : '',
110
+ };
111
+ }
112
+
113
+ function linkPullRequestForTracking({ url, title, project: projectName, autoObserve, context, workItemId }, config = CONFIG, options = {}) {
79
114
  if (!url) {
80
115
  const err = new Error('url required');
81
116
  err.statusCode = 400;
@@ -90,13 +125,14 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
90
125
  const prId = shared.getCanonicalPrId(targetProject, prNum, url);
91
126
  const linkedWorkItemId = getWorkItemIdFromPrLinkContext(context, workItemId);
92
127
  const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
128
+ const metadata = normalizePrMetadata(options.metadata);
93
129
  const result = shared.upsertPullRequestRecord(prPath, {
94
130
  id: prId,
95
131
  prNumber: parseInt(prNum, 10) || null,
96
- title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
97
- description: '',
98
- agent: 'human',
99
- branch: '',
132
+ title: (metadata?.title || title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
133
+ description: metadata?.description ? metadata.description.slice(0, 500) : '',
134
+ agent: metadata?.author || 'human',
135
+ branch: metadata?.branch || '',
100
136
  reviewStatus: 'pending',
101
137
  status: 'active',
102
138
  created: new Date().toISOString(),
@@ -5994,7 +6030,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5994
6030
  if (!url) return jsonReply(res, 400, { error: 'url required' });
5995
6031
 
5996
6032
  reloadConfig();
5997
- const { id: prId, prPath, targetProject, prNum, created, linked } = linkPullRequestForTracking(body, CONFIG);
6033
+ const adoTarget = parseAdoPrMetadataTarget(url);
6034
+ let initialPrData = null;
6035
+ if (adoTarget) {
6036
+ try {
6037
+ initialPrData = await ado.fetchAdoPrMetadata(adoTarget.prNum, adoTarget.adoOrg, adoTarget.adoProj, adoTarget.adoRepo);
6038
+ } catch (e) {
6039
+ shared.log('warn', `ADO PR link metadata fetch failed for ${url}: ${e.message}`);
6040
+ }
6041
+ }
6042
+ const { id: prId, prPath, prNum, created, linked } = linkPullRequestForTracking(body, CONFIG, { metadata: initialPrData });
5998
6043
  invalidateStatusCache();
5999
6044
  jsonReply(res, 200, { ok: true, id: prId, created, linked });
6000
6045
 
@@ -6003,16 +6048,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6003
6048
  try {
6004
6049
  let prData = null;
6005
6050
  const ghMatch = url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
6006
- const adoMatch = url.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/);
6007
6051
  if (ghMatch) {
6008
6052
  const slug = ghMatch[1];
6009
6053
  const result = await shared.execAsync(`gh api "repos/${slug}/pulls/${prNum}"`, { timeout: 15000, encoding: 'utf-8' });
6010
6054
  const d = JSON.parse(result);
6011
6055
  prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
6012
- } else if (adoMatch) {
6013
- const [, adoOrg, adoProj, adoRepo] = adoMatch;
6056
+ } else if (adoTarget && !initialPrData) {
6014
6057
  try {
6015
- prData = await ado.fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo);
6058
+ prData = await ado.fetchAdoPrMetadata(adoTarget.prNum, adoTarget.adoOrg, adoTarget.adoProj, adoTarget.adoRepo);
6016
6059
  } catch { /* ADO token may not be available */ }
6017
6060
  }
6018
6061
  if (!prData) return;
@@ -6134,6 +6177,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6134
6177
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/, desc: 'Tail live output for a working agent', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
6135
6178
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/, desc: 'Fetch final output.log for an agent', handler: handleAgentOutput },
6136
6179
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)$/, desc: 'Get detailed agent info', handler: handleAgentDetail },
6180
+ { method: 'GET', path: /^\/api\/dispatch\/([\w.-]+)\/completion-report$/, desc: 'Read structured completion report for a dispatch', handler: (req, res, match) => {
6181
+ const id = match && match[1];
6182
+ const payload = queries.getDispatchCompletionReport(id);
6183
+ if (!payload) return jsonReply(res, 404, { error: 'completion report not found' }, req);
6184
+ return jsonReply(res, 200, payload, req);
6185
+ } },
6137
6186
  { method: 'GET', path: '/api/agent-output', desc: 'Read agent output log file', params: 'file', handler: async (req, res) => {
6138
6187
  const file = new URL(req.url, 'http://localhost').searchParams.get('file');
6139
6188
  if (!file || file.includes('..') || file.includes('\0') || !file.startsWith('agents/')) return jsonReply(res, 400, { error: 'invalid file' });
package/engine/ado.js CHANGED
@@ -138,6 +138,48 @@ function clearBuildStatusStale(pr) {
138
138
  if (pr._buildStatusDetail) delete pr._buildStatusDetail;
139
139
  }
140
140
 
141
+ function isPlaceholderPrTitle(title, prNum) {
142
+ const text = String(title || '').trim();
143
+ if (!text) return true;
144
+ if (/\bpolling\.\.\./i.test(text)) return true;
145
+ if (String(prNum || '') && (text === `PR #${prNum}` || text === `PR-${prNum}`)) return true;
146
+ if (/[{}"\[\]]/.test(text)) return true;
147
+ return /^[0-9a-f-]{8,}$/i.test(text);
148
+ }
149
+
150
+ function applyAdoPrMetadata(pr, prData, prNum) {
151
+ if (!pr || !prData) return false;
152
+ let updated = false;
153
+
154
+ const sourceBranch = stripRefsHeads(prData.sourceRefName);
155
+ if (sourceBranch && (pr.branch !== sourceBranch || pr._branchResolutionError || pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH)) {
156
+ pr.branch = sourceBranch;
157
+ if (pr._branchResolutionError) delete pr._branchResolutionError;
158
+ if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
159
+ updated = true;
160
+ }
161
+
162
+ const title = String(prData.title || '').trim();
163
+ if (title && isPlaceholderPrTitle(pr.title, prNum) && pr.title !== title.slice(0, 120)) {
164
+ pr.title = title.slice(0, 120);
165
+ updated = true;
166
+ }
167
+
168
+ const description = typeof prData.description === 'string' ? prData.description : '';
169
+ if (description && (pr.description == null || pr.description === '')) {
170
+ pr.description = description.slice(0, 500);
171
+ updated = true;
172
+ }
173
+
174
+ const author = String(prData.createdBy?.displayName || '').trim();
175
+ if (author && pr.agent === 'human') {
176
+ pr.agent = author;
177
+ updated = true;
178
+ }
179
+
180
+ return updated;
181
+ }
182
+
141
183
  // ── Build/Review Status Helpers ───────────────────────────────────────────────
142
184
 
143
185
  /** Classify an array of ADO build records into a single status string. */
@@ -454,13 +496,7 @@ async function pollPrStatus(config) {
454
496
 
455
497
  const prData = await adoFetch(`${repoBase}?api-version=7.1`, token);
456
498
 
457
- const sourceBranch = stripRefsHeads(prData.sourceRefName);
458
- if (sourceBranch && pr.branch !== sourceBranch) {
459
- pr.branch = sourceBranch;
460
- if (pr._branchResolutionError) delete pr._branchResolutionError;
461
- if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
462
- updated = true;
463
- }
499
+ if (applyAdoPrMetadata(pr, prData, prNum)) updated = true;
464
500
 
465
501
  let newStatus = pr.status;
466
502
  if (prData.status === 'completed') newStatus = PR_STATUS.MERGED;
@@ -1121,7 +1157,8 @@ async function checkLiveBuildAndConflict(pr, project) {
1121
1157
  async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
1122
1158
  const token = await getAdoToken();
1123
1159
  if (!token) return null;
1124
- const url = `https://dev.azure.com/${adoOrg}/${adoProj}/_apis/git/repositories/${adoRepo}/pullrequests/${prNum}?api-version=7.1`;
1160
+ const orgBase = getAdoOrgBase({ adoOrg });
1161
+ const url = `${orgBase}/${encodeURIComponent(adoProj)}/_apis/git/repositories/${encodeURIComponent(adoRepo)}/pullrequests/${encodeURIComponent(String(prNum))}?api-version=7.1`;
1125
1162
  const pr = await adoFetch(url, token);
1126
1163
  if (!pr) return null;
1127
1164
  return {
package/engine/cleanup.js CHANGED
@@ -724,8 +724,63 @@ function runCleanup(config, verbose = false) {
724
724
  // 14. Scrub stale temp agent keys from metrics.json
725
725
  try { scrubStaleMetrics(); } catch { /* best-effort cleanup */ }
726
726
 
727
- if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed + cleaned.notesArchive > 0) {
728
- log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.notesArchive} archived notes, ${cleaned.pidFiles} PID files`);
727
+ // 15. Evict old completion reports keep reports durable beyond the capped
728
+ // dispatch history, but bound disk growth by age/count.
729
+ cleaned.completionReports = 0;
730
+ try {
731
+ const dispatch = getDispatch();
732
+ const protectedReportFiles = new Set();
733
+ for (const queue of ['pending', 'active', 'completed']) {
734
+ for (const entry of dispatch[queue] || []) {
735
+ if (!entry?.id) continue;
736
+ const reportPath = shared.dispatchCompletionReportPath(entry.id);
737
+ if (reportPath) protectedReportFiles.add(path.basename(reportPath));
738
+ }
739
+ }
740
+ const configuredRetentionDays = Number(config?.engine?.completionReportRetentionDays ?? ENGINE_DEFAULTS.completionReportRetentionDays);
741
+ const configuredMaxReports = Number(config?.engine?.completionReportMaxFiles ?? ENGINE_DEFAULTS.completionReportMaxFiles);
742
+ const retentionDays = Number.isFinite(configuredRetentionDays) ? configuredRetentionDays : ENGINE_DEFAULTS.completionReportRetentionDays;
743
+ const maxReports = Number.isFinite(configuredMaxReports) ? configuredMaxReports : ENGINE_DEFAULTS.completionReportMaxFiles;
744
+ const retentionMs = retentionDays > 0 ? retentionDays * 24 * 60 * 60 * 1000 : 0;
745
+ const cutoffMs = retentionMs > 0 ? Date.now() - retentionMs : 0;
746
+ const completionsDir = path.join(ENGINE_DIR, 'completions');
747
+ if (fs.existsSync(completionsDir)) {
748
+ const reports = fs.readdirSync(completionsDir)
749
+ .filter(f => f.endsWith('.json'))
750
+ .map(f => {
751
+ const fp = path.join(completionsDir, f);
752
+ let mtimeMs = 0;
753
+ try { mtimeMs = fs.statSync(fp).mtimeMs; } catch {}
754
+ return { file: f, path: fp, mtimeMs, protected: protectedReportFiles.has(f) };
755
+ })
756
+ .filter(r => r.mtimeMs > 0)
757
+ .sort((a, b) => a.mtimeMs - b.mtimeMs);
758
+ const removeReport = (report) => {
759
+ try { fs.unlinkSync(report.path); cleaned.completionReports++; return true; } catch { return false; }
760
+ };
761
+ for (const report of reports) {
762
+ if (report.protected) continue;
763
+ if (cutoffMs > 0 && report.mtimeMs < cutoffMs) {
764
+ removeReport(report);
765
+ }
766
+ }
767
+ if (maxReports > 0) {
768
+ const remaining = reports.filter(r => fs.existsSync(r.path));
769
+ let overflow = remaining.length - maxReports;
770
+ for (const report of remaining) {
771
+ if (overflow <= 0) break;
772
+ if (report.protected) continue;
773
+ if (removeReport(report)) overflow--;
774
+ }
775
+ }
776
+ if (cleaned.completionReports > 0) {
777
+ log('info', `Cleanup: removed ${cleaned.completionReports} old completion report(s)`);
778
+ }
779
+ }
780
+ } catch (e) { log('warn', `cleanupCompletionReports: ${e.message}`); }
781
+
782
+ if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed + cleaned.notesArchive + cleaned.completionReports > 0) {
783
+ log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.notesArchive} archived notes, ${cleaned.pidFiles} PID files, ${cleaned.completionReports} completion reports`);
729
784
  }
730
785
 
731
786
  return cleaned;
@@ -117,12 +117,17 @@ function setCooldownWithContext(key, context) {
117
117
  saveCooldowns();
118
118
  }
119
119
 
120
- function getCoalescedContexts(key) {
120
+ // Drain pending coalesced contexts for a key, clearing the entry's
121
+ // pendingContexts and persisting the change. Returns [] for unknown / empty
122
+ // keys without side effects (no save, no phantom entry creation).
123
+ function drainCoalescedContexts(key) {
121
124
  const entry = dispatchCooldowns.get(key);
122
- const contexts = entry?.pendingContexts || [];
123
- if (contexts.length > 0 && entry) {
124
- entry.pendingContexts = []; // Clear after retrieval
125
+ if (!entry || !Array.isArray(entry.pendingContexts) || entry.pendingContexts.length === 0) {
126
+ return [];
125
127
  }
128
+ const contexts = entry.pendingContexts;
129
+ entry.pendingContexts = [];
130
+ saveCooldowns();
126
131
  return contexts;
127
132
  }
128
133
 
@@ -181,7 +186,7 @@ module.exports = {
181
186
  isOnCooldown,
182
187
  setCooldown,
183
188
  setCooldownWithContext,
184
- getCoalescedContexts,
189
+ drainCoalescedContexts,
185
190
  setCooldownFailure,
186
191
  clearCooldown,
187
192
  isAlreadyDispatched,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T02:44:10.693Z"
4
+ "cachedAt": "2026-05-01T04:20:15.660Z"
5
5
  }
@@ -62,6 +62,10 @@ function mutateDispatch(mutator) {
62
62
  function addToDispatch(item) {
63
63
  item.id = item.id || `${item.agent}-${item.type}-${shared.uid()}`;
64
64
  item.created_at = ts();
65
+ item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
66
+ if (!item.meta.completionReportPath) {
67
+ item.meta.completionReportPath = shared.dispatchCompletionReportPath(item.id);
68
+ }
65
69
  let added = false;
66
70
  mutateDispatch((dispatch) => {
67
71
  // Dedup: skip if same work item ID is already pending or active
@@ -262,6 +266,16 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
262
266
  if (reason) item.reason = reason;
263
267
  if (resultSummary) item.resultSummary = resultSummary;
264
268
  if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
269
+ item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
270
+ if (opts.completionReportPath && !item.meta.completionReportPath) {
271
+ item.meta.completionReportPath = opts.completionReportPath;
272
+ }
273
+ if (opts.structuredCompletion && typeof opts.structuredCompletion === 'object') {
274
+ item.structuredCompletion = opts.structuredCompletion;
275
+ if (opts.structuredCompletion._path && !item.meta.completionReportPath) {
276
+ item.meta.completionReportPath = opts.structuredCompletion._path;
277
+ }
278
+ }
265
279
  // Drop prompt (and sidecar file, if any) — completed entries don't need
266
280
  // replayable content and it would accumulate forever (#1167).
267
281
  try { deleteDispatchPromptSidecar(item); } catch { /* best-effort */ }
@@ -706,8 +706,9 @@ function reconcilePrdStatuses(config) {
706
706
 
707
707
  // ─── PR Sync from Output ─────────────────────────────────────────────────────
708
708
 
709
- function syncPrsFromOutput(output, agentId, meta, config) {
710
-
709
+ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
710
+ const { structuredCompletion = null } = opts;
711
+ const outputText = String(output || '');
711
712
  const prEvidence = new Map();
712
713
  const trustedPrCreateToolIds = new Set();
713
714
  const prUrlPattern = /(https?:\/\/github\.com\/[^\s"'\\)\]]+\/[^\s"'\\)\]]+\/pull\/(\d+)(?:[^\s"'\\)\]]*)?|https?:\/\/(?:dev\.azure\.com|[^/\s"'\\)\]]+\.visualstudio\.com)[^\s"'\\)\]]*?pullrequest\/(\d+)(?:[^\s"'\\)\]]*)?)/gi;
@@ -735,6 +736,21 @@ function syncPrsFromOutput(output, agentId, meta, config) {
735
736
  }
736
737
  }
737
738
 
739
+ function addStructuredPrEvidence(completion) {
740
+ const raw = completion?.pr ?? completion?.pull_request ?? completion?.pullRequest;
741
+ if (raw == null) return;
742
+ const values = Array.isArray(raw) ? raw : [raw];
743
+ for (const value of values) {
744
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value || '');
745
+ if (!text || /^(?:n\/a|na|none|null|no pr|-)\s*$/i.test(text.trim())) continue;
746
+ const before = prEvidence.size;
747
+ addPrUrlEvidence(text);
748
+ if (prEvidence.size > before) continue;
749
+ const idMatch = text.match(/\b(?:PR|pull\s*request)?\s*#?\s*(\d{1,10})\b/i);
750
+ if (idMatch && !prEvidence.has(idMatch[1])) prEvidence.set(idMatch[1], '');
751
+ }
752
+ }
753
+
738
754
  function isTrustedPrCreateToolUse(block) {
739
755
  const name = String(block?.name || '');
740
756
  if (/(?:create|open|submit)[_-]?(?:pull[_-]?request|pr)|(?:pull[_-]?request|pr)[_-]?(?:create|open|submit)/i.test(name)) {
@@ -749,7 +765,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
749
765
  }
750
766
 
751
767
  try {
752
- const lines = output.split('\n');
768
+ const lines = outputText.split('\n');
753
769
  for (const line of lines) {
754
770
  try {
755
771
  if (!line.includes('"type":"assistant"') && !line.includes('"type":"result"') && !line.includes('"type":"user"')) continue;
@@ -779,13 +795,15 @@ function syncPrsFromOutput(output, agentId, meta, config) {
779
795
  }
780
796
  } catch {}
781
797
 
798
+ addStructuredPrEvidence(structuredCompletion);
799
+
782
800
  // Accept inbox fallback ONLY when the agent's stdout is empty (rotated/lost).
783
801
  // The inbox note is the durable artifact for the "gh pr create ran in a sibling
784
802
  // dispatch whose stdout was rotated" case. When stdout has actual content (even
785
803
  // without PR evidence — e.g. the agent ran gh issue view but didn't create a PR),
786
804
  // we must NOT pull in PR URLs from leftover inbox files of prior dispatches —
787
805
  // those would falsely attribute unrelated PRs to this run.
788
- if (!output || !String(output).trim()) {
806
+ if (!outputText.trim()) {
789
807
  const today = dateStamp();
790
808
  const inboxFiles = getInboxFiles().filter(f => f.includes(agentId) && f.includes(today));
791
809
  const currentItemId = meta?.item?.id ? String(meta.item.id) : '';
@@ -813,13 +831,15 @@ function syncPrsFromOutput(output, agentId, meta, config) {
813
831
 
814
832
  // Match each PR to its correct project by finding which repo URL appears near the PR number in output
815
833
  function resolveProjectForPr(prId) {
834
+ const evidenceUrl = prEvidence.get(prId) || '';
835
+ const evidenceText = `${outputText}\n${evidenceUrl}`;
816
836
  for (const p of projects) {
817
837
  if (!p.prUrlBase) continue;
818
838
  const urlFragment = p.prUrlBase.replace(/pullrequest\/$/, '');
819
- if (output.includes(urlFragment + 'pullrequest/' + prId) || output.includes(urlFragment + prId)) return p;
839
+ if (evidenceText.includes(urlFragment + 'pullrequest/' + prId) || evidenceText.includes(urlFragment + prId)) return p;
820
840
  }
821
841
  for (const p of projects) {
822
- if (p.repoName && output.includes(`_git/${p.repoName}/pullrequest/${prId}`)) return p;
842
+ if (p.repoName && evidenceText.includes(`_git/${p.repoName}/pullrequest/${prId}`)) return p;
823
843
  }
824
844
  return defaultProject;
825
845
  }
@@ -847,7 +867,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
847
867
  const fullId = shared.getCanonicalPrId(targetProject, prId, prUrl);
848
868
 
849
869
  let title = meta?.item?.title || '';
850
- const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
870
+ const titleMatch = outputText.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
851
871
  if (titleMatch) title = titleMatch[1].trim();
852
872
  if (title.includes('session_id') || title.includes('is_error') || title.includes('uuid') || title.length > 120 || /[{}"\[\]]/.test(title) || /^[0-9a-f-]{8,}$/i.test(title)) {
853
873
  title = meta?.item?.title || '';
@@ -902,7 +922,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
902
922
  log('warn', `Duplicate PR detected: ${fullId} on branch ${entry.branch || entryBranch} — already tracked as ${duplicateOnBranch.id}. Skipping.`);
903
923
  // Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
904
924
  try {
905
- const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
925
+ const ghSlug = outputText.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
906
926
  if (ghSlug) {
907
927
  execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${duplicateOnBranch.id} already tracks this branch."`, { timeout: 15000 })
908
928
  .catch(() => {});
@@ -1750,11 +1770,16 @@ function parseStructuredCompletion(stdout, runtimeName) {
1750
1770
  return result;
1751
1771
  }
1752
1772
 
1753
- function parseCompletionReportFile(dispatchItem) {
1773
+ function parseCompletionReportFile(dispatchItem, opts = {}) {
1754
1774
  const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
1755
- if (!reportPath || !fs.existsSync(reportPath)) return null;
1775
+ if (!reportPath || !fs.existsSync(reportPath)) {
1776
+ if (opts.warnIfMissing && dispatchItem?.id) {
1777
+ log('warn', `Completion report missing for ${dispatchItem.id}: ${reportPath || '(no path)'}`);
1778
+ }
1779
+ return null;
1780
+ }
1756
1781
  const report = safeJson(reportPath);
1757
- if (!report || typeof report !== 'object' || Array.isArray(report)) {
1782
+ if (!shared.isPlainObject(report)) {
1758
1783
  log('warn', `Ignoring malformed completion report for ${dispatchItem?.id || 'unknown'}: ${reportPath}`);
1759
1784
  return null;
1760
1785
  }
@@ -1768,6 +1793,29 @@ function parseCompletionReportFile(dispatchItem) {
1768
1793
  return report;
1769
1794
  }
1770
1795
 
1796
+ function persistCompletionReport(dispatchItem, completion, source = 'fallback') {
1797
+ if (!dispatchItem?.id || !completion || typeof completion !== 'object') return completion;
1798
+ const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem.id);
1799
+ if (!reportPath) return completion;
1800
+ const report = {
1801
+ ...completion,
1802
+ status: completion.status || completion.outcome || 'unknown',
1803
+ _source: source,
1804
+ _path: reportPath,
1805
+ dispatchId: dispatchItem.id,
1806
+ agent: dispatchItem.agent || null,
1807
+ type: dispatchItem.type || null,
1808
+ completedAt: ts(),
1809
+ };
1810
+ try {
1811
+ safeWrite(reportPath, report);
1812
+ log('info', `Persisted ${source} completion report for ${dispatchItem.id}: ${reportPath}`);
1813
+ } catch (err) {
1814
+ log('warn', `Persist fallback completion report for ${dispatchItem.id}: ${err.message}`);
1815
+ }
1816
+ return report;
1817
+ }
1818
+
1771
1819
  function normalizeCompletionStatus(status) {
1772
1820
  return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
1773
1821
  }
@@ -2045,8 +2093,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2045
2093
  let { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
2046
2094
 
2047
2095
  // Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
2048
- const reportCompletion = parseCompletionReportFile(dispatchItem);
2049
- const structuredCompletion = reportCompletion || parseStructuredCompletion(stdout, runtimeName);
2096
+ const reportCompletion = parseCompletionReportFile(dispatchItem, { warnIfMissing: true });
2097
+ const fallbackCompletion = reportCompletion ? null : parseStructuredCompletion(stdout, runtimeName);
2098
+ const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, 'fenced-completion');
2050
2099
  if (structuredCompletion) {
2051
2100
  if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
2052
2101
  log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
@@ -2073,7 +2122,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2073
2122
  // Always attempt PR sync — even failed/timed-out agents may have created PRs before dying
2074
2123
  let prsCreatedCount = 0;
2075
2124
  try {
2076
- prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config) || 0;
2125
+ prsCreatedCount = syncPrsFromOutput(stdout, agentId, meta, config, { structuredCompletion }) || 0;
2077
2126
  } catch (err) { log('warn', `PR sync from output: ${err.message}`); }
2078
2127
 
2079
2128
  // Structured completion may report PR even when regex didn't find it
@@ -2559,6 +2608,7 @@ module.exports = {
2559
2608
  parseStructuredCompletion,
2560
2609
  detectNonTerminalResultSummary,
2561
2610
  parseCompletionReportFile,
2611
+ persistCompletionReport,
2562
2612
  runPostCompletionHooks,
2563
2613
  syncPrdFromPrs,
2564
2614
  resolveWorkItemPath,
package/engine/queries.js CHANGED
@@ -168,14 +168,121 @@ function getDispatch() {
168
168
  }
169
169
  function invalidateDispatchCache() { _dispatchCache = null; _dispatchCacheAt = 0; }
170
170
 
171
+ function _relativeStatePath(filePath) {
172
+ if (!filePath) return '';
173
+ try { return path.relative(MINIONS_DIR, filePath).replace(/\\/g, '/'); } catch { return filePath; }
174
+ }
175
+
176
+ function _findDispatchEntry(dispatch, id) {
177
+ for (const listName of ['pending', 'active', 'completed']) {
178
+ const entry = (dispatch[listName] || []).find(d => d.id === id);
179
+ if (entry) return entry;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ function _completionReportPathForEntry(entryOrId) {
185
+ const id = typeof entryOrId === 'string' ? entryOrId : entryOrId?.id;
186
+ return (typeof entryOrId === 'object' && entryOrId?.meta?.completionReportPath)
187
+ || shared.dispatchCompletionReportPath(id);
188
+ }
189
+
190
+ // In-memory cache for completion report summaries, keyed by dispatch id.
191
+ // /api/state polls at ~1Hz and re-reads ~40 completion-report files per call;
192
+ // this cache turns those into stat-only checks unless mtime changed.
193
+ const _completionReportCache = new Map();
194
+
195
+ function _getCachedCompletionSummary(id) {
196
+ if (!id) return null;
197
+ const reportPath = shared.dispatchCompletionReportPath(id);
198
+ let stat;
199
+ try { stat = fs.statSync(reportPath); }
200
+ catch { _completionReportCache.delete(id); return null; }
201
+ const cached = _completionReportCache.get(id);
202
+ if (cached && cached.mtime === stat.mtimeMs) return cached.summary;
203
+ const report = safeJson(reportPath);
204
+ if (!shared.isPlainObject(report)) {
205
+ _completionReportCache.delete(id);
206
+ return null;
207
+ }
208
+ const summary = {
209
+ available: true,
210
+ path: _relativeStatePath(reportPath),
211
+ status: report.status || report.outcome || '',
212
+ summary: String(report.summary || '').slice(0, 500),
213
+ verdict: report.verdict || report.review_verdict || report.reviewVerdict || '',
214
+ pr: report.pr || report.pull_request || report.pullRequest || '',
215
+ source: report._source || '',
216
+ };
217
+ if (Array.isArray(report.artifacts)) summary.artifacts = report.artifacts.slice(0, 20);
218
+ _completionReportCache.set(id, { mtime: stat.mtimeMs, summary });
219
+ return summary;
220
+ }
221
+
222
+ function _pruneCompletionReportCache(activeIds) {
223
+ if (_completionReportCache.size < 200) return;
224
+ for (const id of _completionReportCache.keys()) {
225
+ if (!activeIds.has(id)) _completionReportCache.delete(id);
226
+ }
227
+ }
228
+
229
+ function getDispatchCompletionReport(id) {
230
+ if (!id) return null;
231
+ const dispatch = getDispatch();
232
+ const entry = _findDispatchEntry(dispatch, id);
233
+ const reportPath = _completionReportPathForEntry(entry || id);
234
+ // TOCTOU-safe: skip existsSync gate; treat null/non-object safeJson result as not-found.
235
+ const report = safeJson(reportPath);
236
+ if (!shared.isPlainObject(report)) return null;
237
+ return {
238
+ id,
239
+ path: _relativeStatePath(reportPath),
240
+ report,
241
+ dispatch: entry ? {
242
+ id: entry.id,
243
+ agent: entry.agent || '',
244
+ type: entry.type || '',
245
+ task: entry.task || '',
246
+ result: entry.result || '',
247
+ completed_at: entry.completed_at || '',
248
+ } : null,
249
+ };
250
+ }
251
+
252
+ function _completionReportSummary(entry) {
253
+ if (!entry?.id) return null;
254
+ const cached = _getCachedCompletionSummary(entry.id);
255
+ if (cached) return cached;
256
+ // Cache miss / file absent — return a "not available" stub keyed off the canonical path.
257
+ const reportPath = _completionReportPathForEntry(entry);
258
+ return { available: false, path: _relativeStatePath(reportPath) };
259
+ }
260
+
261
+ function _withCompletionReportSummary(entry) {
262
+ if (!entry || typeof entry !== 'object') return entry;
263
+ return { ...entry, completionReport: _completionReportSummary(entry) };
264
+ }
265
+
171
266
  function getDispatchQueue() {
172
267
  const d = getDispatch();
173
268
  const allCompleted = d.completed || [];
174
269
  // Lifetime total from metrics (dispatch.completed is capped at 100)
175
270
  const metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
176
- d.completedTotal = Object.entries(metrics).filter(([k]) => !k.startsWith('_')).reduce((sum, [, m]) => sum + (m.tasksCompleted || 0) + (m.tasksErrored || 0), 0);
177
- d.completed = allCompleted.slice(-20);
178
- return d;
271
+ // Periodically prune cache entries for dispatches that have rotated out of the queue.
272
+ if (_completionReportCache.size >= 200) {
273
+ const activeIds = new Set();
274
+ for (const list of [d.pending, d.active, allCompleted]) {
275
+ for (const entry of list || []) { if (entry?.id) activeIds.add(entry.id); }
276
+ }
277
+ _pruneCompletionReportCache(activeIds);
278
+ }
279
+ return {
280
+ ...d,
281
+ pending: (d.pending || []).map(_withCompletionReportSummary),
282
+ active: (d.active || []).map(_withCompletionReportSummary),
283
+ completed: allCompleted.slice(-20).map(_withCompletionReportSummary),
284
+ completedTotal: Object.entries(metrics).filter(([k]) => !k.startsWith('_')).reduce((sum, [, m]) => sum + (m.tasksCompleted || 0) + (m.tasksErrored || 0), 0),
285
+ };
179
286
  }
180
287
 
181
288
  function getNotes() {
@@ -207,7 +314,7 @@ function getMetrics() {
207
314
  if (agentId.startsWith('_')) continue;
208
315
  metrics[agentId] = {
209
316
  ...DEFAULT_AGENT_METRICS,
210
- ...(m && typeof m === 'object' && !Array.isArray(m) ? m : {}),
317
+ ...(shared.isPlainObject(m) ? m : {}),
211
318
  };
212
319
  }
213
320
 
@@ -352,6 +459,7 @@ function getAgentStatus(agentId) {
352
459
  started_at: latest.started_at || null,
353
460
  completed_at: latest.completed_at,
354
461
  resultSummary: latest.resultSummary || latest.reason || '',
462
+ completionReport: _completionReportSummary(latest),
355
463
  };
356
464
  }
357
465
  }
@@ -484,6 +592,7 @@ function getAgentDetail(id) {
484
592
  id: d.id, task: d.task || '', type: d.type || '',
485
593
  result: d.result || '', reason: d.reason || '',
486
594
  started_at: d.started_at || '', completed_at: d.completed_at || '',
595
+ completionReport: _completionReportSummary(d),
487
596
  }));
488
597
  } catch { /* optional */ }
489
598
 
@@ -1234,7 +1343,7 @@ module.exports = {
1234
1343
  invalidateKnowledgeBaseCache,
1235
1344
 
1236
1345
  // Core state
1237
- getConfig, getControl, getDispatch, getDispatchQueue, invalidateDispatchCache,
1346
+ getConfig, getControl, getDispatch, getDispatchQueue, getDispatchCompletionReport, invalidateDispatchCache,
1238
1347
  getNotes, getNotesWithMeta, getEngineLog, getMetrics,
1239
1348
 
1240
1349
  // Inbox
package/engine/shared.js CHANGED
@@ -720,6 +720,12 @@ const ENGINE_DEFAULTS = {
720
720
  autoArchive: false, // opt-in: auto-archive plans after verify completes (false = mark ready, user archives manually)
721
721
  autoFixConflicts: true, // auto-dispatch fix agents when a PR has merge conflicts
722
722
  autoFixBuilds: true, // auto-dispatch fix agents when a PR build fails
723
+ autoReviewPrs: true, // auto-dispatch review agents for newly opened agent PRs
724
+ autoReReviewPrs: true, // auto-dispatch review agents after a PR fix is pushed
725
+ autoFixReviewFeedback: true, // auto-dispatch fix agents for minions review changes-requested verdicts
726
+ autoFixHumanComments: true, // auto-dispatch fix agents for actionable human PR comments
727
+ completionReportRetentionDays: 90, // retain completion report sidecars beyond capped dispatch history
728
+ completionReportMaxFiles: 5000, // hard cap for completion report sidecars during cleanup
723
729
  meetingRoundTimeout: 900000, // 15min per meeting round before auto-advance
724
730
  evalLoop: true, // enable review→fix loop after implementation completes
725
731
  evalMaxIterations: 3, // legacy UI/config field; engine discovery no longer enforces review→fix cycle caps
@@ -1177,7 +1183,7 @@ const ESCALATION_POLICY = {
1177
1183
  };
1178
1184
 
1179
1185
  // Structured completion protocol — fields agents must produce in ```completion blocks
1180
- const COMPLETION_FIELDS = ['status', 'summary', 'files_changed', 'tests', 'pr', 'pending', 'failure_class', 'retryable', 'needs_rerun', 'verdict'];
1186
+ const COMPLETION_FIELDS = ['status', 'summary', 'files_changed', 'tests', 'pr', 'pending', 'failure_class', 'retryable', 'needs_rerun', 'verdict', 'artifacts'];
1181
1187
 
1182
1188
  const DEFAULT_AGENT_METRICS = {
1183
1189
  tasksCompleted: 0, tasksErrored: 0,
@@ -1793,9 +1799,11 @@ function _jsonEqual(a, b) {
1793
1799
  return JSON.stringify(a) === JSON.stringify(b);
1794
1800
  }
1795
1801
 
1796
- function _isPlainObject(value) {
1802
+ function isPlainObject(value) {
1797
1803
  return !!value && typeof value === 'object' && !Array.isArray(value);
1798
1804
  }
1805
+ // Backwards-compat alias for legacy in-file callers.
1806
+ const _isPlainObject = isPlainObject;
1799
1807
 
1800
1808
  function applyPrFieldDelta(target, before, after) {
1801
1809
  if (!target || typeof target !== 'object' || !after || typeof after !== 'object') return target;
@@ -2322,6 +2330,7 @@ module.exports = {
2322
2330
  mutatePullRequests,
2323
2331
  uid,
2324
2332
  uniquePath,
2333
+ isPlainObject,
2325
2334
  truncateTextBytes,
2326
2335
  tailTextBytes,
2327
2336
  appendTextTail,
package/engine.js CHANGED
@@ -486,7 +486,7 @@ async function spawnAgent(dispatchItem, config) {
486
486
  '',
487
487
  `Before exiting, write a JSON completion report to: \`${completionReportPath}\``,
488
488
  '',
489
- 'Use this shape: {"status":"success|partial|failed","summary":"...","verdict":"approved|changes-requested|null","pr":"PR URL or id if relevant","failure_class":"...","retryable":true|false,"needs_rerun":true|false}.',
489
+ 'Use this shape: {"status":"success|partial|failed","summary":"...","verdict":"approved|changes-requested|null","pr":"PR URL or id if relevant","failure_class":"...","retryable":true|false,"needs_rerun":true|false,"artifacts":[{"type":"note|plan|prd|pr|file","path":"relative/path/or/url","title":"short label"}]}.',
490
490
  'This report is the primary completion signal; fenced completion blocks are only a fallback.',
491
491
  '',
492
492
  ].join('\n') : '';
@@ -1336,13 +1336,19 @@ async function spawnAgent(dispatchItem, config) {
1336
1336
  const hardContractFail = completionContractFailure?.severity === 'hard'
1337
1337
  || completionContractFailure?.nonTerminal === true;
1338
1338
  const effectiveResult = hardContractFail ? DISPATCH_RESULT.ERROR : (((code === 0 && !agentReportedFailure) || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
1339
+ const completionReportPath = structuredCompletion?._path || dispatchItem.meta?.completionReportPath || shared.dispatchCompletionReportPath(id);
1340
+ const completionOpts = {
1341
+ ...(completionReportPath ? { completionReportPath } : {}),
1342
+ ...(structuredCompletion ? { structuredCompletion } : {}),
1343
+ };
1339
1344
  const completeOpts = hardContractFail
1340
- ? { processWorkItemFailure: false }
1345
+ ? { ...completionOpts, processWorkItemFailure: false }
1341
1346
  : (effectiveResult === DISPATCH_RESULT.ERROR ? {
1347
+ ...completionOpts,
1342
1348
  ...(failureClass ? { failureClass } : {}),
1343
1349
  ...(typeof retryableDecision === 'boolean' ? { agentRetryable: retryableDecision } : {}),
1344
1350
  ...(structuredCompletion?.failure_class ? { failureClass: structuredCompletion.failure_class } : {}),
1345
- } : {});
1351
+ } : completionOpts);
1346
1352
  // Extract last 5 non-empty stderr lines as error context when exit code is non-zero
1347
1353
  let errorReason = '';
1348
1354
  if (hardContractFail) {
@@ -1679,7 +1685,7 @@ function updateSnapshot(config) {
1679
1685
  // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
1680
1686
 
1681
1687
  const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
1682
- isOnCooldown, setCooldown, setCooldownWithContext, getCoalescedContexts,
1688
+ isOnCooldown, setCooldown, setCooldownWithContext, drainCoalescedContexts,
1683
1689
  setCooldownFailure, clearCooldown, isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
1684
1690
 
1685
1691
 
@@ -2174,6 +2180,11 @@ async function discoverFromPrs(config, project) {
2174
2180
  ? (config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled)
2175
2181
  : (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled);
2176
2182
  const evalLoopEnabled = config.engine?.evalLoop !== false;
2183
+ const fixThrottled = isAdoProject ? isAdoThrottled() : isGhThrottled();
2184
+ const autoReviewPrs = config.engine?.autoReviewPrs ?? ENGINE_DEFAULTS.autoReviewPrs;
2185
+ const autoReReviewPrs = config.engine?.autoReReviewPrs ?? ENGINE_DEFAULTS.autoReReviewPrs;
2186
+ const autoFixReviewFeedback = config.engine?.autoFixReviewFeedback ?? ENGINE_DEFAULTS.autoFixReviewFeedback;
2187
+ const autoFixHumanComments = config.engine?.autoFixHumanComments ?? ENGINE_DEFAULTS.autoFixHumanComments;
2177
2188
 
2178
2189
  // Collect active PR dispatches to prevent simultaneous review+fix on same PR
2179
2190
  const dispatch = getDispatch();
@@ -2217,12 +2228,13 @@ async function discoverFromPrs(config, project) {
2217
2228
  const awaitingReReview = reviewStatus === 'waiting' && !!pr.minionsReview?.fixedAt;
2218
2229
 
2219
2230
  // PRs needing review: evalLoop gates the entire review+fix cycle; pollEnabled ensures reviewStatus is fresh
2220
- const reviewEnabled = evalLoopEnabled && pollEnabled;
2231
+ const reviewEnabled = evalLoopEnabled && pollEnabled && autoReviewPrs;
2232
+ const reReviewEnabled = evalLoopEnabled && pollEnabled && autoReReviewPrs;
2221
2233
  const alreadyReviewed = pr.lastReviewedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.lastReviewedAt);
2222
2234
  const needsReview = reviewEnabled && reviewStatus === 'pending' && !alreadyReviewed;
2223
2235
  if (needsReview) {
2224
2236
  const key = `review-${project?.name || 'default'}-${prDisplayId}`;
2225
- if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2237
+ if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2226
2238
 
2227
2239
  // Pre-dispatch live vote check — cached reviewStatus may be stale (poll lag ~6 min)
2228
2240
  try {
@@ -2263,21 +2275,29 @@ async function discoverFromPrs(config, project) {
2263
2275
  // awaiting a stale-vote re-review or has build-fix retries escalated.
2264
2276
  const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
2265
2277
  const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
2266
- if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched) {
2278
+ if (autoFixHumanComments && (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched) {
2267
2279
  const key = humanFixKey;
2268
2280
  let staleCoalesced = [];
2269
2281
  const alreadyDispatched = isAlreadyDispatched(key);
2270
2282
  const blockedByCooldown = isOnCooldown(key, cooldownMs);
2283
+ const currentFeedback = pr.humanFeedback?.pendingFix ? pr.humanFeedback.feedbackContent : '';
2284
+ const coalesceCurrentHumanFeedback = () => {
2285
+ if (!currentFeedback) return;
2286
+ setCooldownWithContext(key, { feedbackContent: currentFeedback, timestamp: ts() });
2287
+ clearPendingHumanFeedbackFlag(projMeta, pr.id);
2288
+ };
2289
+ if (fixThrottled) {
2290
+ coalesceCurrentHumanFeedback();
2291
+ continue;
2292
+ }
2271
2293
  if (blockedByCooldown && !alreadyDispatched) {
2272
- staleCoalesced = getCoalescedContexts(key);
2294
+ staleCoalesced = drainCoalescedContexts(key);
2273
2295
  clearCooldown(key);
2274
2296
  log('info', `Cleared stale cooldown for ${key} — no matching dispatch history`);
2275
2297
  }
2276
2298
  if (alreadyDispatched || isOnCooldown(key, cooldownMs)) {
2277
2299
  // Coalesce: save feedback for next dispatch
2278
- if (pr.humanFeedback?.feedbackContent) {
2279
- setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
2280
- }
2300
+ coalesceCurrentHumanFeedback();
2281
2301
  continue;
2282
2302
  }
2283
2303
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
@@ -2285,11 +2305,11 @@ async function discoverFromPrs(config, project) {
2285
2305
  const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
2286
2306
  if (!prBranch) continue;
2287
2307
 
2288
- const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
2289
- let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
2308
+ const coalesced = [...staleCoalesced, ...drainCoalescedContexts(key)];
2309
+ let reviewNote = currentFeedback || 'See PR thread comments';
2290
2310
  if (coalesced.length > 0) {
2291
2311
  const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
2292
- if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
2312
+ if (earlier) reviewNote = currentFeedback ? earlier + '\n\n---\n\n' + currentFeedback : earlier;
2293
2313
  }
2294
2314
  reviewNote = `New PR comments were observed. Read the full PR thread, decide whether the comments require code/documentation/test changes, make only necessary changes, and push if action is needed.\n\n${reviewNote}`;
2295
2315
 
@@ -2305,13 +2325,13 @@ async function discoverFromPrs(config, project) {
2305
2325
  // or when no minions review has completed yet (e.g. human-feedback-only fix path).
2306
2326
  const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
2307
2327
  (!pr.lastReviewedAt || pr.minionsReview.fixedAt > pr.lastReviewedAt));
2308
- const needsReReview = reviewEnabled && reviewStatus === 'waiting' &&
2328
+ const needsReReview = reReviewEnabled && reviewStatus === 'waiting' &&
2309
2329
  fixedAfterReview && !fixDispatched;
2310
2330
  if (needsReReview) {
2311
2331
  const key = `rereview-${project?.name || 'default'}-${prDisplayId}`;
2312
2332
  // Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
2313
2333
  // completed-dispatch window would block legitimate re-reviews within the hour after a fix
2314
- if (isOnCooldown(key, cooldownMs)) continue;
2334
+ if (fixThrottled || isOnCooldown(key, cooldownMs)) continue;
2315
2335
 
2316
2336
  // Pre-dispatch live vote check — cached 'waiting' may be stale if reviewer already acted
2317
2337
  try {
@@ -2346,9 +2366,9 @@ async function discoverFromPrs(config, project) {
2346
2366
 
2347
2367
  // PRs with changes requested → route back to author for fix
2348
2368
  // Gate on evalLoopEnabled — the review→fix cycle is the eval loop
2349
- if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !fixDispatched) {
2369
+ if (evalLoopEnabled && autoFixReviewFeedback && reviewStatus === 'changes-requested' && !awaitingReReview && !fixDispatched) {
2350
2370
  const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
2351
- if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2371
+ if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2352
2372
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2353
2373
  if (!agentId) continue;
2354
2374
  const prBranch = ensurePrBranchForDispatch(project, pr, 'fix');
@@ -2371,7 +2391,6 @@ async function discoverFromPrs(config, project) {
2371
2391
  if (Date.now() - new Date(pr._buildFixPushedAt).getTime() < gracePeriodMs) continue;
2372
2392
  }
2373
2393
  const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
2374
- const fixThrottled = isAdoProject ? isAdoThrottled() : isGhThrottled();
2375
2394
  if (autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing') {
2376
2395
  const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
2377
2396
  if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
@@ -4082,7 +4101,7 @@ module.exports = {
4082
4101
  updateWorkItemStatus, handlePostMerge,
4083
4102
 
4084
4103
  // Cooldowns
4085
- loadCooldowns, setCooldownWithContext, getCoalescedContexts,
4104
+ loadCooldowns, setCooldownWithContext, drainCoalescedContexts,
4086
4105
 
4087
4106
  // Budget
4088
4107
  getMonthlySpend,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1653",
3
+ "version": "0.1.1655",
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"
@@ -55,10 +55,10 @@ Treat a Minions assignment like the user typed the same task directly into a cap
55
55
  The engine provides a completion report path in the prompt and in `MINIONS_COMPLETION_REPORT`. Before exiting, write JSON there with the actual outcome:
56
56
 
57
57
  ```json
58
- {"status":"success","summary":"what changed and how it was validated","verdict":null,"pr":"PR id/url or N/A","failure_class":"N/A","retryable":false,"needs_rerun":false}
58
+ {"status":"success","summary":"what changed and how it was validated","verdict":null,"pr":"PR id/url or N/A","failure_class":"N/A","retryable":false,"needs_rerun":false,"artifacts":[{"type":"note|plan|prd|pr|file","path":"relative/path/or/url","title":"short label"}]}
59
59
  ```
60
60
 
61
- Use `status: "failed"` plus an accurate `failure_class`, `retryable`, and `needs_rerun` when the task could not be completed. For PR reviews, set `verdict` to `approved` or `changes-requested`. Fenced `completion` blocks are still accepted as a fallback, but the JSON report is the primary signal.
61
+ Use `status: "failed"` plus an accurate `failure_class`, `retryable`, and `needs_rerun` when the task could not be completed. For PR reviews, set `verdict` to `approved` or `changes-requested`. Include every durable artifact you created or updated in `artifacts` (PRs, notes, plans, PRDs, important files) so the dashboard can display them. Fenced `completion` blocks are still accepted as a fallback, but the JSON report is the primary signal.
62
62
 
63
63
  ## Long-Running Commands
64
64