@yemi33/minions 0.1.1641 → 0.1.1643

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1643 (2026-04-30)
4
+
5
+ ### Features
6
+ - harden PR canonicalization
7
+ - fix PR comment auto-fix loop
8
+
9
+ ### Fixes
10
+ - yemi33/minions#1918
11
+ - require explicit PR-created evidence
12
+ - steering messages lost when session is winding down
13
+ - surface missing PR branches
14
+
3
15
  ## 0.1.1641 (2026-04-30)
4
16
 
5
17
  ### Features
package/dashboard.js CHANGED
@@ -28,6 +28,7 @@ const watchesMod = require('./engine/watches');
28
28
  const routing = require('./engine/routing');
29
29
  const playbook = require('./engine/playbook');
30
30
  const dispatchMod = require('./engine/dispatch');
31
+ const steering = require('./engine/steering');
31
32
  const os = require('os');
32
33
 
33
34
  const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
@@ -148,6 +149,21 @@ function _resolveSkillReadPath({ file, dir, source, config, skillFiles } = {}) {
148
149
  return null;
149
150
  }
150
151
 
152
+ function _agentSessionIsDraining(agentId) {
153
+ const activeForAgent = (getDispatchQueue().active || []).some(d => d.agent === agentId);
154
+ if (!activeForAgent) return false;
155
+ const liveLogPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
156
+ const tail = (safeRead(liveLogPath) || '').slice(-65536);
157
+ if (!tail) return false;
158
+ const lastSteer = tail.lastIndexOf('[human-steering]');
159
+ const terminalIdx = Math.max(
160
+ tail.lastIndexOf('[process-exit]'),
161
+ tail.lastIndexOf('"type":"session.task_complete"'),
162
+ tail.lastIndexOf('"type":"result"')
163
+ );
164
+ return terminalIdx >= 0 && terminalIdx > lastSteer;
165
+ }
166
+
151
167
  const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
152
168
  const TEAMS_INBOX_PATH = path.join(ENGINE_DIR, 'teams-inbox.json');
153
169
 
@@ -5924,7 +5940,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5924
5940
  if (!pr.branch && prData.branch) {
5925
5941
  pr.branch = prData.branch;
5926
5942
  if (pr._branchResolutionError) delete pr._branchResolutionError;
5927
- if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
5943
+ if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
5928
5944
  }
5929
5945
  if (pr.agent === 'human' && prData.author) pr.agent = prData.author;
5930
5946
  return prs;
@@ -6002,21 +6018,30 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6002
6018
  }},
6003
6019
  { method: 'POST', path: '/api/agents/steer', desc: 'Inject steering message into a running agent', params: 'agent, message', handler: async (req, res) => {
6004
6020
  const body = await readBody(req);
6005
- const { agent: agentId, message } = body;
6006
- if (!agentId || !message) return jsonReply(res, 400, { error: 'agent and message required' });
6021
+ const { agent, message } = body;
6022
+ if (!agent || !message) return jsonReply(res, 400, { error: 'agent and message required' });
6023
+ const agentId = String(agent).replace(/[^a-zA-Z0-9_-]/g, '');
6024
+ const text = String(message).trim();
6025
+ if (!agentId || !text) return jsonReply(res, 400, { error: 'agent and message required' });
6007
6026
 
6008
- const steerPath = path.join(MINIONS_DIR, 'agents', agentId, 'steer.md');
6009
6027
  const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
6010
6028
  if (!fs.existsSync(agentDir)) return jsonReply(res, 404, { error: 'Agent not found' });
6029
+ if (_agentSessionIsDraining(agentId)) {
6030
+ return jsonReply(res, 409, { error: 'Agent session is finishing; retry when the next session starts' });
6031
+ }
6011
6032
 
6012
- // Write steering file
6013
- safeWrite(steerPath, message);
6033
+ const entry = steering.writeSteeringMessage(agentId, text);
6014
6034
 
6015
6035
  // Also append to live-output.log so it shows in the chat view
6016
6036
  const liveLogPath = path.join(agentDir, 'live-output.log');
6017
- try { fs.appendFileSync(liveLogPath, '\n[human-steering] ' + message + '\n'); } catch { /* optional */ }
6037
+ try { fs.appendFileSync(liveLogPath, '\n[human-steering] ' + text + '\n'); } catch { /* optional */ }
6018
6038
 
6019
- return jsonReply(res, 200, { ok: true, message: 'Steering message sent' });
6039
+ return jsonReply(res, 200, {
6040
+ ok: true,
6041
+ message: 'Steering message queued',
6042
+ file: entry?.file || null,
6043
+ inboxCount: steering.listUnreadSteeringMessages(agentId).length,
6044
+ });
6020
6045
  }},
6021
6046
  { method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent?, task?', handler: handleAgentsCancel },
6022
6047
  { method: 'POST', path: /^\/api\/agent\/([\w-]+)\/kill$/, desc: 'Kill a running agent: stop process, clear dispatch, reset work items to pending', handler: handleAgentKill },
package/engine/ado.js CHANGED
@@ -268,7 +268,8 @@ async function forEachActivePr(config, token, callback) {
268
268
  if (!project.adoOrg || !project.adoProject) continue;
269
269
 
270
270
  const prs = getPrs(project);
271
- const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status));
271
+ const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status)
272
+ && shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
272
273
  if (activePrs.length === 0) continue;
273
274
 
274
275
  const adoRepositoryId = getAdoRepositoryId(project);
@@ -798,7 +799,7 @@ async function reconcilePrs(config) {
798
799
  if (existing && !existing.branch && branch) {
799
800
  existing.branch = branch;
800
801
  if (existing._branchResolutionError) delete existing._branchResolutionError;
801
- if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
802
+ if (existing._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete existing._pendingReason;
802
803
  metadataUpdated++;
803
804
  }
804
805
  // PR already tracked — write link to pr-links.json if we can extract an ID
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-30T17:23:35.113Z"
4
+ "cachedAt": "2026-04-30T20:24:54.414Z"
5
5
  }
package/engine/github.js CHANGED
@@ -206,7 +206,8 @@ async function forEachActiveGhPr(config, callback) {
206
206
  if (isSlugInBackoff(slug)) continue;
207
207
 
208
208
  const prs = getPrs(project);
209
- const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status));
209
+ const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status)
210
+ && shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
210
211
  if (activePrs.length === 0) continue;
211
212
 
212
213
  // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
@@ -285,7 +286,7 @@ async function forEachActiveGhPr(config, callback) {
285
286
  if (!pr.branch && prData.head?.ref) {
286
287
  pr.branch = prData.head.ref;
287
288
  if (pr._branchResolutionError) delete pr._branchResolutionError;
288
- if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
289
+ if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
289
290
  }
290
291
  }
291
292
  }
@@ -332,7 +333,7 @@ async function pollPrStatus(config) {
332
333
  if (headBranch && pr.branch !== headBranch) {
333
334
  pr.branch = headBranch;
334
335
  if (pr._branchResolutionError) delete pr._branchResolutionError;
335
- if (pr._pendingReason === 'missing_pr_branch') delete pr._pendingReason;
336
+ if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
336
337
  updated = true;
337
338
  }
338
339
 
@@ -713,7 +714,7 @@ async function reconcilePrs(config) {
713
714
  if (existing && !existing.branch && branch) {
714
715
  existing.branch = branch;
715
716
  if (existing._branchResolutionError) delete existing._branchResolutionError;
716
- if (existing._pendingReason === 'missing_pr_branch') delete existing._pendingReason;
717
+ if (existing._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete existing._pendingReason;
717
718
  metadataUpdated++;
718
719
  }
719
720
  if (confirmedItemId) {
@@ -707,11 +707,46 @@ function reconcilePrdStatuses(config) {
707
707
 
708
708
  function syncPrsFromOutput(output, agentId, meta, config) {
709
709
 
710
- const prMatches = new Set();
711
- const urlPattern = /(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+)/g;
712
- const textCreatedPattern = /(?:PR created|created PR|E2E PR)[:\s#-]*(\d{1,})/gi;
710
+ const prEvidence = new Map();
711
+ const trustedPrCreateToolIds = new Set();
712
+ const prUrlPattern = /(https?:\/\/github\.com\/[^\s"'\\)\]]+\/[^\s"'\\)\]]+\/pull\/(\d+)(?:[^\s"'\\)\]]*)?|https?:\/\/(?:dev\.azure\.com|[^/\s"'\\)\]]+\.visualstudio\.com)[^\s"'\\)\]]*?pullrequest\/(\d+)(?:[^\s"'\\)\]]*)?)/gi;
713
713
  let match;
714
714
 
715
+ function cleanPrUrl(url) {
716
+ return String(url || '').replace(/[.,;:]+$/, '');
717
+ }
718
+
719
+ function addPrUrlEvidence(text) {
720
+ if (!text) return;
721
+ prUrlPattern.lastIndex = 0;
722
+ while ((match = prUrlPattern.exec(String(text))) !== null) {
723
+ const prId = match[2] || match[3];
724
+ if (prId && !prEvidence.has(prId)) prEvidence.set(prId, cleanPrUrl(match[1]));
725
+ }
726
+ }
727
+
728
+ function addExplicitPrCreatedEvidence(text) {
729
+ if (!text) return;
730
+ const explicitPrCreatedPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request|E2E\s+PR)\s+(?:created|opened|submitted)\*{0,2}\s*[:\-]\s*([^\n]+)/gi;
731
+ let createdMatch;
732
+ while ((createdMatch = explicitPrCreatedPattern.exec(String(text))) !== null) {
733
+ addPrUrlEvidence(createdMatch[1]);
734
+ }
735
+ }
736
+
737
+ function isTrustedPrCreateToolUse(block) {
738
+ const name = String(block?.name || '');
739
+ if (/(?:create|open|submit)[_-]?(?:pull[_-]?request|pr)|(?:pull[_-]?request|pr)[_-]?(?:create|open|submit)/i.test(name)) {
740
+ return true;
741
+ }
742
+ const inputText = typeof block?.input === 'string' ? block.input : JSON.stringify(block?.input || {});
743
+ if (/\bgh(?:\.exe)?\s+pr\s+create\b/i.test(inputText)) return true;
744
+ if (/\baz(?:\.cmd|\.exe)?\s+repos\s+pr\s+create\b/i.test(inputText)) return true;
745
+ const callsAdoCreateApi = /_apis\/git\/repositories\/[^\s"'\\]+\/pullrequests\b/i.test(inputText);
746
+ const usesPost = /\bPOST\b|-X\s*POST|-Method\s+POST|method["']?\s*:\s*["']?POST/i.test(inputText);
747
+ return callsAdoCreateApi && usesPost;
748
+ }
749
+
715
750
  try {
716
751
  const lines = output.split('\n');
717
752
  for (const line of lines) {
@@ -720,60 +755,43 @@ function syncPrsFromOutput(output, agentId, meta, config) {
720
755
  const parsed = JSON.parse(line);
721
756
  const content = parsed.message?.content || [];
722
757
  for (const block of content) {
723
- // Scan tool_result blocks in user messages for PR URLs (gh pr create output lands here)
758
+ if (block.type === 'tool_use' && block.id && isTrustedPrCreateToolUse(block)) {
759
+ trustedPrCreateToolIds.add(block.id);
760
+ }
761
+ // Tool output is trusted only when tied to a known PR-create command/API call.
724
762
  if (block.type === 'tool_result' && block.content) {
725
- const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
726
- while ((match = urlPattern.exec(text)) !== null) prMatches.add(match[1] || match[2]);
763
+ if (trustedPrCreateToolIds.has(block.tool_use_id)) {
764
+ const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
765
+ addPrUrlEvidence(text);
766
+ }
727
767
  }
728
- // Also scan assistant text blocks for PR URLs and "PR created" patterns
768
+ // Assistant text must use the explicit Minions PR-created protocol line.
729
769
  if (block.type === 'text' && block.text) {
730
- while ((match = urlPattern.exec(block.text)) !== null) prMatches.add(match[1] || match[2]);
731
- textCreatedPattern.lastIndex = 0;
732
- let m2;
733
- while ((m2 = textCreatedPattern.exec(block.text)) !== null) prMatches.add(m2[1]);
770
+ addExplicitPrCreatedEvidence(block.text);
734
771
  }
735
772
  }
736
773
  if (parsed.type === 'result' && parsed.result) {
737
- const resultText = parsed.result;
738
- const createdPattern = /(?:created|opened|submitted|new PR|PR created)[^\n]*?(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
739
- while ((match = createdPattern.exec(resultText)) !== null) prMatches.add(match[1] || match[2]);
740
- const createdIdPattern = /(?:created|opened|submitted|new)\s+PR[# -]*(\d{1,})/gi;
741
- while ((match = createdIdPattern.exec(resultText)) !== null) prMatches.add(match[1]);
774
+ const resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
775
+ addExplicitPrCreatedEvidence(resultText);
742
776
  }
743
777
  } catch {}
744
778
  }
745
779
  } catch {}
746
780
 
747
- // prId URL captured from inbox notes. Populated alongside prMatches so
748
- // extractPrUrl below has a fallback when the agent's stdout doesn't contain
749
- // the URL (the W-moljyu60wuzr / #1902 case — gh pr create ran in a sibling
750
- // dispatch and only the inbox note carries the link).
751
- const inboxUrls = new Map();
781
+ // Accept inbox fallback only when the agent wrote the explicit PR-created
782
+ // protocol line; generic PR mentions in findings/review notes are not evidence.
752
783
  const today = dateStamp();
753
784
  const inboxFiles = getInboxFiles().filter(f => f.includes(agentId) && f.includes(today));
754
785
  for (const f of inboxFiles) {
755
786
  const content = safeRead(path.join(INBOX_DIR, f));
756
787
  if (!content) continue;
757
- // Match a PR declaration line in the agent's findings note: optional bold,
758
- // optional "Pull Request" spelling, line-anchored so "see PR https://..."
759
- // mid-paragraph mentions don't trigger a false-positive. The protocol
760
- // and host prefix is optional so "PR: https://github.com/..." ,
761
- // "**PR:** github.com/...", etc. all match.
762
- const prHeaderPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request)[:\*]*\*?\s*[#-]*\s*(?:https?:\/\/)?[^\s"]*?(?:(?:visualstudio\.com|dev\.azure\.com)[^\s"]*?pullrequest\/(\d+)|github\.com\/[^\s"]*?\/pull\/(\d+))/gi;
788
+ const prHeaderPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request|E2E\s+PR)\s+(?:created|opened|submitted)\*{0,2}\s*[:\-]\s*([^\n]+)/gi;
763
789
  while ((match = prHeaderPattern.exec(content)) !== null) {
764
- const prId = match[1] || match[2];
765
- prMatches.add(prId);
766
- // Pull the URL substring out of the matched chunk so we can hand it to
767
- // extractPrUrl as a fallback. Prefer the first inbox URL we see for a
768
- // given prId — later notes don't override the canonical record.
769
- if (!inboxUrls.has(prId)) {
770
- const urlMatch = match[0].match(/https?:\/\/[^\s"\\)]+/);
771
- if (urlMatch) inboxUrls.set(prId, urlMatch[0].replace(/[.,;:]+$/, ''));
772
- }
790
+ addPrUrlEvidence(match[1]);
773
791
  }
774
792
  }
775
793
 
776
- if (prMatches.size === 0) return 0;
794
+ if (prEvidence.size === 0) return 0;
777
795
 
778
796
  const projects = shared.getProjects(config);
779
797
  if (projects.length === 0 && !meta?.project?.name) return 0;
@@ -798,13 +816,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
798
816
  // doesn't contain the link (gh pr create may have run in a sibling dispatch
799
817
  // whose stdout was rotated; the inbox note is the durable artifact).
800
818
  function extractPrUrl(prId) {
801
- // Stop at backslash in addition to whitespace/quotes — raw JSONL encodes newlines as \n (literal
802
- // backslash-n), so without this the regex would capture e.g. "pull/1804\n/usr/bin/bash".
803
- const ghMatch = output.match(new RegExp(`https?://github\\.com/[^\\s"'\\)\\]\\\\]*?/pull/${prId}(?:[^\\s"'\\)\\]\\\\]*)`, 'i'));
804
- if (ghMatch) return ghMatch[0].replace(/[.,;:]+$/, '');
805
- const adoMatch = output.match(new RegExp(`https?://(?:dev\\.azure\\.com|[^/]+\\.visualstudio\\.com)[^\\s"'\\)\\]\\\\]*?pullrequest/${prId}(?:[^\\s"'\\)\\]\\\\]*)`, 'i'));
806
- if (adoMatch) return adoMatch[0].replace(/[.,;:]+$/, '');
807
- return inboxUrls.get(prId) || '';
819
+ return prEvidence.get(prId) || '';
808
820
  }
809
821
 
810
822
  const agentName = config.agents?.[agentId]?.name || agentId;
@@ -814,7 +826,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
814
826
  // Group new PRs by target file path
815
827
  const newPrsByPath = new Map(); // prPath -> [{ prId, newEntry }]
816
828
 
817
- for (const prId of prMatches) {
829
+ for (const prId of prEvidence.keys()) {
818
830
  const targetProject = useCentral ? null : resolveProjectForPr(prId);
819
831
  const targetName = targetProject ? targetProject.name : '_central';
820
832
  const prPath = targetProject ? shared.projectPrPath(targetProject) : centralPrPath;
package/engine/queries.js CHANGED
@@ -8,6 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
10
  const shared = require('./shared');
11
+ const steering = require('./steering');
11
12
 
12
13
  const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileLocked,
13
14
  projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
@@ -418,12 +419,18 @@ function getAgents(config) {
418
419
  // runtime tag next to the agent name.
419
420
  const runtime = shared.resolveAgentCli(a, config.engine || {});
420
421
  const inboxFiles = allInboxFiles.filter(f => f.includes(a.id));
422
+ let steeringInboxFiles = [];
423
+ try { steeringInboxFiles = steering.listUnreadSteeringMessages(a.id); } catch { steeringInboxFiles = []; }
421
424
  const s = getAgentStatus(a.id); // derives from dispatch.json
422
425
 
423
426
  let lastAction = 'Waiting for assignment';
424
427
  if (s.status === 'working') lastAction = s._runningToolDescription ? `Running: ${s._runningToolDescription}` : `Working: ${s.task}`;
425
428
  else if (s.status === 'done') lastAction = `Done: ${s.task}`;
426
429
  else if (s.status === 'error') lastAction = `Error: ${s.task}`;
430
+ else if (steeringInboxFiles.length > 0) {
431
+ const lastSteer = steeringInboxFiles[steeringInboxFiles.length - 1];
432
+ lastAction = `Pending steering: ${lastSteer.file} (${timeSince(lastSteer.createdAtMs)})`;
433
+ }
427
434
  else if (inboxFiles.length > 0) {
428
435
  const lastOutput = path.join(INBOX_DIR, inboxFiles[inboxFiles.length - 1]);
429
436
  try { lastAction = `Output: ${path.basename(lastOutput)} (${timeSince(fs.statSync(lastOutput).mtimeMs)})`; } catch { /* optional */ }
@@ -440,7 +447,7 @@ function getAgents(config) {
440
447
  _blockingToolCall: s._blockingToolCall || null,
441
448
  _warning: s._warning || null,
442
449
  _permissionMode: s._permissionMode || null,
443
- chartered, inboxCount: inboxFiles.length
450
+ chartered, inboxCount: inboxFiles.length + steeringInboxFiles.length
444
451
  };
445
452
  });
446
453
  }
@@ -458,6 +465,11 @@ function getAgentDetail(id) {
458
465
  const inboxContents = safeReadDir(INBOX_DIR)
459
466
  .filter(f => f.includes(id))
460
467
  .map(f => ({ name: f, content: safeRead(path.join(INBOX_DIR, f)) || '' }));
468
+ try {
469
+ for (const entry of steering.listUnreadSteeringMessages(id)) {
470
+ inboxContents.push({ name: entry.file, content: entry.raw || '', type: 'steering' });
471
+ }
472
+ } catch { /* optional */ }
461
473
 
462
474
  let recentDispatches = [];
463
475
  try {
package/engine/shared.js CHANGED
@@ -1073,6 +1073,9 @@ const PRD_MATERIALIZABLE = new Set([PRD_ITEM_STATUS.MISSING, PRD_ITEM_STATUS.UPD
1073
1073
  const PR_STATUS = { ACTIVE: 'active', MERGED: 'merged', ABANDONED: 'abandoned', CLOSED: 'closed', LINKED: 'linked' };
1074
1074
  // PRs eligible for polling (status/build/comment checks) — excludes terminal statuses
1075
1075
  const PR_POLLABLE_STATUSES = new Set([PR_STATUS.ACTIVE, PR_STATUS.LINKED]);
1076
+ const PR_PENDING_REASON = {
1077
+ MISSING_BRANCH: 'missing_pr_branch',
1078
+ };
1076
1079
 
1077
1080
  // Watch statuses — engine-level persistent watches that survive restarts
1078
1081
  const WATCH_STATUS = { ACTIVE: 'active', PAUSED: 'paused', TRIGGERED: 'triggered', EXPIRED: 'expired' };
@@ -1661,6 +1664,10 @@ function parseAdoPrUrl(url) {
1661
1664
  };
1662
1665
  }
1663
1666
 
1667
+ function parsePrUrl(url) {
1668
+ return parseGitHubPrUrl(url) || parseAdoPrUrl(url);
1669
+ }
1670
+
1664
1671
  function getProjectPrScope(project) {
1665
1672
  if (!project) return '';
1666
1673
  const host = String(project.repoHost || '').toLowerCase();
@@ -1705,16 +1712,47 @@ function getPrDisplayId(value, fallbackPrNumber = null) {
1705
1712
  return typeof value === 'object' ? String(value?.id || '') : String(value || '');
1706
1713
  }
1707
1714
 
1715
+ function getPrScopeInfo(prRef, url = '') {
1716
+ const isObjectRef = !!prRef && typeof prRef === 'object';
1717
+ const rawUrl = url || (isObjectRef ? prRef.url || '' : String(prRef || ''));
1718
+ const parsedUrl = parsePrUrl(rawUrl);
1719
+ if (parsedUrl) return { ...parsedUrl, source: 'url' };
1720
+ const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
1721
+ const canonical = parseCanonicalPrId(rawId);
1722
+ return canonical ? { ...canonical, source: 'id' } : null;
1723
+ }
1724
+
1725
+ function getPrProjectScopeMismatch(project, prRef, url = '') {
1726
+ const projectScope = getProjectPrScope(project);
1727
+ if (!projectScope) return null;
1728
+ const refScope = getPrScopeInfo(prRef, url)?.scope || '';
1729
+ if (!refScope) return null;
1730
+ if (refScope === projectScope) return null;
1731
+ const [projectHost, projectRest = ''] = projectScope.split(':');
1732
+ const [refHost, refRest = ''] = refScope.split(':');
1733
+ if (projectHost === refHost && projectHost === 'ado' && !project.prUrlBase) {
1734
+ const projectParts = projectRest.split('/');
1735
+ const refParts = refRest.split('/');
1736
+ if (projectParts[0] === refParts[0] && projectParts[1] === refParts[1]) return null;
1737
+ }
1738
+ return { reason: 'pr_scope_mismatch', projectScope, prScope: refScope };
1739
+ }
1740
+
1741
+ function isPrCompatibleWithProject(project, prRef, url = '') {
1742
+ return !getPrProjectScopeMismatch(project, prRef, url);
1743
+ }
1744
+
1708
1745
  function getCanonicalPrId(project, prRef, url = '') {
1709
1746
  const isObjectRef = !!prRef && typeof prRef === 'object';
1710
1747
  const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
1748
+ const rawUrl = url || (isObjectRef ? prRef.url || '' : String(prRef || ''));
1749
+ const parsedUrl = parsePrUrl(rawUrl);
1750
+ if (parsedUrl) return `${parsedUrl.scope}#${parsedUrl.prNumber}`;
1711
1751
  const canonical = parseCanonicalPrId(rawId);
1712
1752
  if (canonical) return `${canonical.scope}#${canonical.prNumber}`;
1713
- const parsedUrl = parseGitHubPrUrl(url || (isObjectRef ? prRef.url || '' : ''))
1714
- || parseAdoPrUrl(url || (isObjectRef ? prRef.url || '' : ''));
1715
1753
  const prNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
1716
1754
  if (prNumber == null) return rawId;
1717
- const scope = getProjectPrScope(project) || parsedUrl?.scope || '';
1755
+ const scope = getProjectPrScope(project) || '';
1718
1756
  return scope ? `${scope}#${prNumber}` : `PR-${prNumber}`;
1719
1757
  }
1720
1758
 
@@ -1755,6 +1793,17 @@ function normalizePrRecord(pr, project = null) {
1755
1793
  pr.id = canonicalId;
1756
1794
  changed = true;
1757
1795
  }
1796
+ const mismatch = getPrProjectScopeMismatch(project, pr, pr.url || '');
1797
+ if (mismatch) {
1798
+ const current = pr._invalidProjectScope || {};
1799
+ if (current.reason !== mismatch.reason || current.projectScope !== mismatch.projectScope || current.prScope !== mismatch.prScope) {
1800
+ pr._invalidProjectScope = mismatch;
1801
+ changed = true;
1802
+ }
1803
+ } else if (Object.prototype.hasOwnProperty.call(pr, '_invalidProjectScope')) {
1804
+ delete pr._invalidProjectScope;
1805
+ changed = true;
1806
+ }
1758
1807
  return changed;
1759
1808
  }
1760
1809
 
@@ -2255,7 +2304,7 @@ module.exports = {
2255
2304
  resolveAgentMaxBudget, resolveAgentBareMode,
2256
2305
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
2257
2306
  runtimeConfigWarnings,
2258
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
2307
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd,
2259
2308
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS,
2260
2309
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
2261
2310
  FAILURE_CLASS, ESCALATION_POLICY, COMPLETION_FIELDS,
@@ -2273,6 +2322,9 @@ module.exports = {
2273
2322
  getProjectPrScope,
2274
2323
  getPrNumber,
2275
2324
  getPrDisplayId,
2325
+ getPrScopeInfo,
2326
+ getPrProjectScopeMismatch,
2327
+ isPrCompatibleWithProject,
2276
2328
  getCanonicalPrId,
2277
2329
  findPrRecord,
2278
2330
  normalizePrRecord,
@@ -0,0 +1,187 @@
1
+ /**
2
+ * engine/steering.js — Durable agent-scoped steering inbox helpers.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const shared = require('./shared');
8
+
9
+ const AGENTS_DIR = path.join(shared.MINIONS_DIR, 'agents');
10
+
11
+ function agentInboxDir(agentId) {
12
+ return path.join(AGENTS_DIR, agentId, 'inbox');
13
+ }
14
+
15
+ function _createdAtFromPath(filePath, stat) {
16
+ const base = path.basename(filePath);
17
+ const m = base.match(/^steering-(\d+)/);
18
+ if (m) {
19
+ const n = Number(m[1]);
20
+ if (Number.isFinite(n) && n > 0) return n;
21
+ }
22
+ return stat?.mtimeMs || Date.now();
23
+ }
24
+
25
+ function _stripFrontmatter(raw) {
26
+ const text = String(raw || '');
27
+ if (!text.startsWith('---\n')) return text;
28
+ const end = text.indexOf('\n---\n', 4);
29
+ return end >= 0 ? text.slice(end + 5) : text;
30
+ }
31
+
32
+ function _frontmatterValue(raw, key) {
33
+ const text = String(raw || '');
34
+ if (!text.startsWith('---\n')) return null;
35
+ const end = text.indexOf('\n---\n', 4);
36
+ if (end < 0) return null;
37
+ const fm = text.slice(4, end).split(/\r?\n/);
38
+ const prefix = key + ':';
39
+ for (const line of fm) {
40
+ if (line.startsWith(prefix)) return line.slice(prefix.length).trim();
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function _messageFromRaw(raw) {
46
+ let body = _stripFrontmatter(raw).trim();
47
+ const forwarded = body.match(/Original steering from human:\s*([\s\S]*)$/i);
48
+ if (forwarded) body = forwarded[1].trim();
49
+ return body;
50
+ }
51
+
52
+ function _readEntry(filePath, legacy = false) {
53
+ let stat;
54
+ try { stat = fs.statSync(filePath); } catch { return null; }
55
+ const raw = shared.safeRead(filePath);
56
+ const fmCreatedAtMs = Number(_frontmatterValue(raw, 'createdAtMs'));
57
+ const createdAtMs = Number.isFinite(fmCreatedAtMs) && fmCreatedAtMs > 0
58
+ ? fmCreatedAtMs
59
+ : _createdAtFromPath(filePath, stat);
60
+ return {
61
+ path: filePath,
62
+ file: path.basename(filePath),
63
+ createdAtMs,
64
+ createdAt: new Date(createdAtMs).toISOString(),
65
+ raw,
66
+ message: _messageFromRaw(raw),
67
+ legacy,
68
+ };
69
+ }
70
+
71
+ function _uniqueSteeringPath(inboxDir, createdAtMs) {
72
+ let filePath = path.join(inboxDir, `steering-${createdAtMs}.md`);
73
+ for (let i = 1; fs.existsSync(filePath); i++) {
74
+ filePath = path.join(inboxDir, `steering-${createdAtMs}-${i}.md`);
75
+ }
76
+ return filePath;
77
+ }
78
+
79
+ function writeSteeringMessage(agentId, message, opts = {}) {
80
+ const createdAtMs = Number(opts.createdAtMs) || Date.now();
81
+ const createdAt = new Date(createdAtMs).toISOString();
82
+ const inboxDir = agentInboxDir(agentId);
83
+ fs.mkdirSync(inboxDir, { recursive: true });
84
+ const filePath = _uniqueSteeringPath(inboxDir, createdAtMs);
85
+ const body = [
86
+ '---',
87
+ `createdAt: ${createdAt}`,
88
+ `createdAtMs: ${createdAtMs}`,
89
+ `source: ${opts.source || 'human'}`,
90
+ '---',
91
+ '',
92
+ String(message || '').trim(),
93
+ '',
94
+ ].join('\n');
95
+ shared.safeWrite(filePath, body);
96
+ return _readEntry(filePath);
97
+ }
98
+
99
+ function listUnreadSteeringMessages(agentId, opts = {}) {
100
+ const includeLegacy = opts.includeLegacy !== false;
101
+ const entries = [];
102
+ const inboxDir = agentInboxDir(agentId);
103
+ for (const file of shared.safeReadDir(inboxDir)) {
104
+ if (!/^steering-.*\.md$/i.test(file)) continue;
105
+ const entry = _readEntry(path.join(inboxDir, file), false);
106
+ if (entry) entries.push(entry);
107
+ }
108
+
109
+ if (includeLegacy) {
110
+ const legacyPath = path.join(AGENTS_DIR, agentId, 'steer.md');
111
+ const legacy = _readEntry(legacyPath, true);
112
+ if (legacy) entries.push(legacy);
113
+ }
114
+
115
+ entries.sort((a, b) => (a.createdAtMs - b.createdAtMs) || a.file.localeCompare(b.file));
116
+ return entries;
117
+ }
118
+
119
+ function buildPendingSteeringPrompt(agentId) {
120
+ const entries = listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim());
121
+ if (entries.length === 0) return { entries, prompt: '' };
122
+
123
+ const sections = [
124
+ '## Pending instructions from prior session',
125
+ '',
126
+ 'These human steering messages were not confirmed processed before the previous session ended. Address them before continuing with the task.',
127
+ ];
128
+ entries.forEach((entry, idx) => {
129
+ sections.push('', `### Message ${idx + 1} — ${entry.createdAt}`, '', entry.message.trim());
130
+ });
131
+ return { entries, prompt: sections.join('\n') };
132
+ }
133
+
134
+ function _eventTimestampMs(obj, observedAtMs) {
135
+ const value = obj?.timestamp || obj?.createdAt || obj?.created_at || obj?.time || obj?.data?.timestamp;
136
+ const parsed = value ? Date.parse(value) : NaN;
137
+ if (Number.isFinite(parsed)) return parsed;
138
+ return Number(observedAtMs) || Date.now();
139
+ }
140
+
141
+ function _isProcessEvidenceEvent(obj) {
142
+ if (!obj || typeof obj !== 'object') return false;
143
+ const type = String(obj.type || '');
144
+ if (type === 'assistant' || type === 'tool_use') return true;
145
+ if (type.startsWith('assistant.') || type.startsWith('tool.')) return true;
146
+ if (Array.isArray(obj.message?.content)) {
147
+ return obj.message.content.some(block => block?.type === 'text' || block?.type === 'tool_use');
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function _processEvidenceTimes(rawOutput, observedAtMs) {
153
+ const times = [];
154
+ for (const line of String(rawOutput || '').split(/\r?\n/)) {
155
+ const trimmed = line.trim();
156
+ if (!trimmed.startsWith('{')) continue;
157
+ try {
158
+ const obj = JSON.parse(trimmed);
159
+ if (_isProcessEvidenceEvent(obj)) times.push(_eventTimestampMs(obj, observedAtMs));
160
+ } catch { /* ignore non-JSON output */ }
161
+ }
162
+ return times;
163
+ }
164
+
165
+ function ackProcessedSteeringMessages(agentId, pendingEntries, rawOutput, opts = {}) {
166
+ const entries = Array.isArray(pendingEntries) ? pendingEntries : [];
167
+ if (entries.length === 0) return [];
168
+ const times = _processEvidenceTimes(rawOutput, opts.observedAtMs);
169
+ if (times.length === 0) return [];
170
+
171
+ const acked = [];
172
+ for (const entry of entries) {
173
+ if (!entry?.path) continue;
174
+ if (!times.some(t => t > entry.createdAtMs)) continue;
175
+ shared.safeUnlink(entry.path);
176
+ acked.push(entry);
177
+ }
178
+ return acked;
179
+ }
180
+
181
+ module.exports = {
182
+ agentInboxDir,
183
+ writeSteeringMessage,
184
+ listUnreadSteeringMessages,
185
+ buildPendingSteeringPrompt,
186
+ ackProcessedSteeringMessages,
187
+ };
package/engine/timeout.js CHANGED
@@ -6,6 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const shared = require('./shared');
8
8
  const queries = require('./queries');
9
+ const steering = require('./steering');
9
10
 
10
11
  const { safeRead, safeWrite, safeJson, mutateJsonFileLocked, getProjects, projectWorkItemsPath, log, ts,
11
12
  ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, DISPATCH_RESULT, AGENT_STATUS } = shared;
@@ -78,25 +79,20 @@ function checkSteering(config) {
78
79
  // Skip if already being steered (prevents double-kill race)
79
80
  if (info._steeringMessage || info._steeringAt) continue;
80
81
 
81
- const steerPath = path.join(AGENTS_DIR, info.agentId, 'steer.md');
82
- let steerMtime;
83
- try { steerMtime = fs.statSync(steerPath).mtimeMs; } catch { continue; } // ENOENT = no steering message
84
-
85
- // Read and consume the message immediately — always delete to prevent stale messages
86
- const message = safeRead(steerPath);
87
- try { fs.unlinkSync(steerPath); } catch { /* cleanup */ }
88
- if (!message) continue;
82
+ const alreadyPending = new Set((info._pendingSteeringFiles || []).map(entry => entry.path || entry));
83
+ const unread = steering.listUnreadSteeringMessages(info.agentId);
84
+ for (const empty of unread.filter(entry => !entry.message.trim())) {
85
+ shared.safeUnlink(empty.path);
86
+ }
87
+ const steerEntry = unread.find(entry => entry.message.trim() && !alreadyPending.has(entry.path));
88
+ if (!steerEntry) continue; // ENOENT/no agents/<id>/inbox/steering-*.md message
89
+ const message = steerEntry.message.trim();
89
90
 
90
91
  const sessionId = info.sessionId;
91
92
  if (!sessionId) {
92
- // No session to resume — kill agent and deliver message via inbox for retry.
93
+ // No session to resume — kill agent and leave message unread in inbox for retry.
93
94
  // Previously this silently skipped for up to 5m then deleted the message (#627).
94
- log('info', `Steering: no sessionId for ${info.agentId} (${id}) — killing and forwarding message to inbox`);
95
-
96
- // Write steering message to agent inbox so it survives the retry
97
- const inboxDir = path.join(AGENTS_DIR, info.agentId, 'inbox');
98
- try { fs.mkdirSync(inboxDir, { recursive: true }); } catch {}
99
- safeWrite(path.join(inboxDir, `steering-${Date.now()}.md`), `# Steering Message (Forwarded)\n\nOriginal steering from human:\n\n${message}\n`);
95
+ log('info', `Steering: no sessionId for ${info.agentId} (${id}) — killing and keeping unread message in inbox`);
100
96
 
101
97
  // Append to live output so user sees confirmation in the dashboard
102
98
  try {
@@ -115,6 +111,7 @@ function checkSteering(config) {
115
111
  // Set steering state BEFORE kill — close event may fire synchronously on some platforms
116
112
  info._steeringMessage = message;
117
113
  info._steeringSessionId = sessionId;
114
+ info._steeringEntry = steerEntry;
118
115
  info._steeringAt = Date.now();
119
116
 
120
117
  shared.killImmediate(info.proc);
package/engine.js CHANGED
@@ -107,6 +107,7 @@ const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatc
107
107
  // ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
108
108
 
109
109
  const { checkTimeouts, checkSteering, checkIdleThreshold } = require('./engine/timeout');
110
+ const steering = require('./engine/steering');
110
111
 
111
112
  // ─── Cleanup (extracted to engine/cleanup.js) ────────────────────────────────
112
113
 
@@ -295,6 +296,17 @@ function _buildAgentSpawnFlags(runtime, opts = {}) {
295
296
  return flags;
296
297
  }
297
298
 
299
+ function ackPendingSteeringFiles(agentId, procInfo, rawOutput, observedAtMs = Date.now()) {
300
+ if (!procInfo?._pendingSteeringFiles?.length || !rawOutput) return;
301
+ const acked = steering.ackProcessedSteeringMessages(agentId, procInfo._pendingSteeringFiles, rawOutput, { observedAtMs });
302
+ if (acked.length === 0) return;
303
+
304
+ const ackedPaths = new Set(acked.map(entry => entry.path));
305
+ procInfo._pendingSteeringFiles = procInfo._pendingSteeringFiles.filter(entry => !ackedPaths.has(entry.path));
306
+ if (procInfo._pendingSteeringFiles.length === 0) delete procInfo._pendingSteeringFiles;
307
+ log('info', `Steering: ACKed ${acked.length} processed message(s) for ${agentId}`);
308
+ }
309
+
298
310
  // Resolve dependency plan item IDs to their PR branches
299
311
  function resolveDependencyBranches(depIds, sourcePlan, project, config) {
300
312
  const results = []; // [{ branch, prId }]
@@ -436,9 +448,13 @@ async function spawnAgent(dispatchItem, config) {
436
448
  // and this avoids blocking 200ms of file reads behind 20-60s of git operations
437
449
  const systemPrompt = buildSystemPrompt(agentId, config, project);
438
450
  const agentContext = buildAgentContext(agentId, config, project);
439
- const fullTaskPrompt = agentContext
440
- ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPrompt}`
451
+ const pendingSteering = steering.buildPendingSteeringPrompt(agentId);
452
+ const taskPromptWithSteering = pendingSteering.prompt
453
+ ? `${pendingSteering.prompt}\n\n---\n\n${taskPrompt}`
441
454
  : taskPrompt;
455
+ const fullTaskPrompt = agentContext
456
+ ? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPromptWithSteering}`
457
+ : taskPromptWithSteering;
442
458
  const tmpDir = path.join(ENGINE_DIR, 'tmp');
443
459
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
444
460
  const safeId = id.replace(/[:\\/*?"<>|]/g, '-');
@@ -1036,6 +1052,8 @@ async function spawnAgent(dispatchItem, config) {
1036
1052
  }
1037
1053
  } catch { /* JSON parse — output may not be valid JSON */ }
1038
1054
  }
1055
+
1056
+ ackPendingSteeringFiles(agentId, procInfo, chunk);
1039
1057
  });
1040
1058
 
1041
1059
  proc.stderr.on('data', (data) => {
@@ -1058,13 +1076,17 @@ async function spawnAgent(dispatchItem, config) {
1058
1076
  try { shared.safeUnlink(path.join(AGENTS_DIR, agentId, 'session.json')); } catch {}
1059
1077
  }
1060
1078
 
1061
- // Check if this was a steering kill — re-spawn with resume
1062
1079
  const procInfo = activeProcesses.get(id);
1080
+ ackPendingSteeringFiles(agentId, procInfo, stdout);
1081
+
1082
+ // Check if this was a steering kill — re-spawn with resume
1063
1083
  if (procInfo?._steeringMessage) {
1064
1084
  const steerMsg = procInfo._steeringMessage;
1065
1085
  const steerSessionId = procInfo._steeringSessionId;
1086
+ const steerEntry = procInfo._steeringEntry;
1066
1087
  delete procInfo._steeringMessage;
1067
1088
  delete procInfo._steeringSessionId;
1089
+ delete procInfo._steeringEntry;
1068
1090
 
1069
1091
  // Guard: can't resume without a session
1070
1092
  if (!steerSessionId) {
@@ -1156,7 +1178,14 @@ async function spawnAgent(dispatchItem, config) {
1156
1178
  // into the resumed process, it kills the resumed session. The kill watcher only exists
1157
1179
  // to handle cases where the original kill didn't take effect — once the process has
1158
1180
  // exited and the resume is spawned, _steeringAt must not be present.
1159
- activeProcesses.set(id, { proc: resumeProc, agentId, startedAt: procInfo.startedAt, sessionId: steerSessionId, lastRealOutputAt: Date.now() });
1181
+ activeProcesses.set(id, {
1182
+ proc: resumeProc,
1183
+ agentId,
1184
+ startedAt: procInfo.startedAt,
1185
+ sessionId: steerSessionId,
1186
+ lastRealOutputAt: Date.now(),
1187
+ _pendingSteeringFiles: steerEntry ? [steerEntry] : (procInfo._pendingSteeringFiles || []),
1188
+ });
1160
1189
 
1161
1190
  // Reset output buffers so post-completion parsing only sees the resumed session
1162
1191
  stdout = '';
@@ -1167,6 +1196,7 @@ async function spawnAgent(dispatchItem, config) {
1167
1196
  realActivityMap.set(id, Date.now());
1168
1197
  if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
1169
1198
  try { fs.appendFileSync(liveOutputPath, chunk); } catch { /* optional */ }
1199
+ ackPendingSteeringFiles(agentId, activeProcesses.get(id), chunk);
1170
1200
  });
1171
1201
  resumeProc.stderr.on('data', (data) => {
1172
1202
  const chunk = data.toString();
@@ -1370,7 +1400,13 @@ async function spawnAgent(dispatchItem, config) {
1370
1400
  // realActivityMap was already seeded immediately after runFile() returned (#W-mo25loq8kjer);
1371
1401
  // don't re-seed here — the stdout/stderr handlers above can already have updated it with
1372
1402
  // a fresher timestamp, and overwriting would clobber the real "last activity" signal.
1373
- activeProcesses.set(id, { proc, agentId, startedAt, sessionId: cachedSessionId });
1403
+ activeProcesses.set(id, {
1404
+ proc,
1405
+ agentId,
1406
+ startedAt,
1407
+ sessionId: cachedSessionId,
1408
+ _pendingSteeringFiles: pendingSteering.entries,
1409
+ });
1374
1410
 
1375
1411
  updateAgentStatus(id, AGENT_STATUS.RUNNING, `Process spawned for ${agentId}`);
1376
1412
 
@@ -1984,7 +2020,7 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
1984
2020
  } catch (e) { log('warn', 'clear pending human feedback flag: ' + e.message); }
1985
2021
  }
1986
2022
 
1987
- const PR_PENDING_MISSING_BRANCH = 'missing_pr_branch';
2023
+ const PR_PENDING_MISSING_BRANCH = shared.PR_PENDING_REASON.MISSING_BRANCH;
1988
2024
 
1989
2025
  function normalizePrBranch(value) {
1990
2026
  const raw = value == null ? '' : String(value).trim();
@@ -2117,6 +2153,7 @@ async function discoverFromPrs(config, project) {
2117
2153
  const knownAgents = new Set(Object.keys(config.agents || {}));
2118
2154
  for (const pr of prs) {
2119
2155
  if (pr.status !== PR_STATUS.ACTIVE || pr._contextOnly) continue;
2156
+ if (!shared.isPrCompatibleWithProject(project, pr, pr.url || '')) continue;
2120
2157
  const prDisplayId = shared.getPrDisplayId(pr);
2121
2158
  const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
2122
2159
  if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
@@ -2195,12 +2232,55 @@ async function discoverFromPrs(config, project) {
2195
2232
  if (item) { newWork.push(item); }
2196
2233
  }
2197
2234
 
2235
+ let fixDispatched = false;
2236
+
2237
+ // Fresh reviewer comments are actionable fixes, even while the PR is otherwise
2238
+ // awaiting a stale-vote re-review or has build-fix retries escalated.
2239
+ const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
2240
+ const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
2241
+ if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !fixDispatched) {
2242
+ const key = humanFixKey;
2243
+ let staleCoalesced = [];
2244
+ const alreadyDispatched = isAlreadyDispatched(key);
2245
+ const blockedByCooldown = isOnCooldown(key, cooldownMs);
2246
+ if (blockedByCooldown && !alreadyDispatched) {
2247
+ staleCoalesced = getCoalescedContexts(key);
2248
+ clearCooldown(key);
2249
+ log('info', `Cleared stale cooldown for ${key} — no matching dispatch history`);
2250
+ }
2251
+ if (alreadyDispatched || isOnCooldown(key, cooldownMs)) {
2252
+ // Coalesce: save feedback for next dispatch
2253
+ if (pr.humanFeedback?.feedbackContent) {
2254
+ setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
2255
+ }
2256
+ continue;
2257
+ }
2258
+ const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2259
+ if (!agentId) continue;
2260
+ const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
2261
+ if (!prBranch) continue;
2262
+
2263
+ const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
2264
+ let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
2265
+ if (coalesced.length > 0) {
2266
+ const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
2267
+ if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
2268
+ }
2269
+
2270
+ const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2271
+ pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
2272
+ reviewer: 'Human Reviewer',
2273
+ review_note: reviewNote,
2274
+ }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
2275
+ if (item) { newWork.push(item); fixDispatched = true; }
2276
+ }
2277
+
2198
2278
  // Re-review after fix: trigger when a fix was pushed after the last minions review,
2199
2279
  // or when no minions review has completed yet (e.g. human-feedback-only fix path).
2200
2280
  const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
2201
2281
  (!pr.lastReviewedAt || pr.minionsReview.fixedAt > pr.lastReviewedAt));
2202
2282
  const needsReReview = reviewEnabled && reviewStatus === 'waiting' &&
2203
- fixedAfterReview && !evalEscalated;
2283
+ fixedAfterReview && !evalEscalated && !fixDispatched;
2204
2284
  if (needsReReview) {
2205
2285
  const key = `rereview-${project?.name || 'default'}-${prDisplayId}`;
2206
2286
  // Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
@@ -2240,8 +2320,7 @@ async function discoverFromPrs(config, project) {
2240
2320
 
2241
2321
  // PRs with changes requested → route back to author for fix
2242
2322
  // Gate on evalLoopEnabled — the review→fix cycle is the eval loop
2243
- let fixDispatched = false;
2244
- if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
2323
+ if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated && !fixDispatched) {
2245
2324
  const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
2246
2325
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2247
2326
  const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
@@ -2265,46 +2344,6 @@ async function discoverFromPrs(config, project) {
2265
2344
  }
2266
2345
  }
2267
2346
 
2268
- // PRs with pending human feedback (skip if review-fix already dispatched above)
2269
- const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
2270
- const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
2271
- if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !awaitingReReview && !fixDispatched) {
2272
- const key = humanFixKey;
2273
- let staleCoalesced = [];
2274
- const alreadyDispatched = isAlreadyDispatched(key);
2275
- const blockedByCooldown = isOnCooldown(key, cooldownMs);
2276
- if (blockedByCooldown && !alreadyDispatched) {
2277
- staleCoalesced = getCoalescedContexts(key);
2278
- clearCooldown(key);
2279
- log('info', `Cleared stale cooldown for ${key} — no matching dispatch history`);
2280
- }
2281
- if (alreadyDispatched || isOnCooldown(key, cooldownMs)) {
2282
- // Coalesce: save feedback for next dispatch
2283
- if (pr.humanFeedback?.feedbackContent) {
2284
- setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: ts() });
2285
- }
2286
- continue;
2287
- }
2288
- const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
2289
- if (!agentId) continue;
2290
- const prBranch = ensurePrBranchForDispatch(project, pr, 'human-feedback fix');
2291
- if (!prBranch) continue;
2292
-
2293
- const coalesced = [...staleCoalesced, ...getCoalescedContexts(key)];
2294
- let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
2295
- if (coalesced.length > 0) {
2296
- const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
2297
- if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
2298
- }
2299
-
2300
- const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2301
- pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
2302
- reviewer: 'Human Reviewer',
2303
- review_note: reviewNote,
2304
- }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
2305
- if (item) { newWork.push(item); fixDispatched = true; }
2306
- }
2307
-
2308
2347
  // PRs with build failures — route to author (has session context from implementing)
2309
2348
  // Grace period: after a build fix push, wait for CI to run before re-dispatching
2310
2349
  // Skip if build hasn't transitioned since last fix (still showing the old failure)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1641",
3
+ "version": "0.1.1643",
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"