@yemi33/minions 0.1.2336 → 0.1.2338

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/bin/minions.js CHANGED
@@ -794,6 +794,13 @@ function getPkgVersion() {
794
794
  try { return require(path.join(PKG_ROOT, 'package.json')).version; } catch { return '0.0.0'; }
795
795
  }
796
796
 
797
+ // Same as getPkgVersion() but bypasses require()'s module cache — needed to
798
+ // see what `npm install -g` actually wrote to disk *within the same process*
799
+ // (require() would keep returning the version captured at process start).
800
+ function getPkgVersionFresh() {
801
+ try { return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version; } catch { return null; }
802
+ }
803
+
797
804
  function getInstalledVersion() {
798
805
  try {
799
806
  const vFile = path.join(MINIONS_HOME, '.minions-version');
@@ -1279,8 +1286,30 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1279
1286
  saveInstalledVersion(getPkgVersion());
1280
1287
  console.log(` Version synced to ${getPkgVersion()} (dev/symlink install — pull from git to update code)`);
1281
1288
  } else {
1289
+ const preVersion = getPkgVersionFresh();
1290
+ // Resolve the target version explicitly BEFORE installing, and pin the
1291
+ // install to that exact version instead of a floating `@latest` tag. This
1292
+ // avoids a race where `npm view` (used here and by `minions version`) and
1293
+ // a separate `npm install -g pkg@latest` resolve `latest` against npm's
1294
+ // registry-metadata cache at different times and land on different
1295
+ // versions — the metadata cache has its own TTL, independent of tarball
1296
+ // integrity checks, so a stale local packument can make `npm install -g
1297
+ // pkg@latest` silently reinstall an already-cached older version even
1298
+ // though `npm view pkg version` reports a newer one (W-mr9k4a5x00090184).
1299
+ let targetVersion;
1300
+ try {
1301
+ targetVersion = execSync('npm view @yemi33/minions version', { encoding: 'utf8', timeout: 15000, windowsHide: true }).trim();
1302
+ } catch (e) {
1303
+ console.error(' Failed to resolve the latest version from the npm registry:', e.message);
1304
+ process.exit(1);
1305
+ }
1306
+ if (!targetVersion) {
1307
+ console.error(' Failed to resolve the latest version from the npm registry (empty response).');
1308
+ process.exit(1);
1309
+ }
1310
+
1282
1311
  try {
1283
- execSync('npm install -g @yemi33/minions@latest', { stdio: 'inherit', timeout: 120000 });
1312
+ execSync(`npm install -g @yemi33/minions@${targetVersion}`, { stdio: 'inherit', timeout: 120000 });
1284
1313
  } catch (e) {
1285
1314
  console.error(' npm update failed:', e.message);
1286
1315
  process.exit(1);
@@ -1288,6 +1317,24 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1288
1317
  // Equivalent to `minions init --force --skip-start`, but avoids recursing through
1289
1318
  // the global shim while npm is still settling the updated install.
1290
1319
  runPostUpdateInit();
1320
+
1321
+ // Verify the install actually advanced to the resolved target version. A
1322
+ // zero exit from `npm install` only means npm performed SOME install/relink
1323
+ // action — it does NOT guarantee the resolved version moved forward. Re-read
1324
+ // package.json fresh from disk (bypassing require()'s cache, same reasoning
1325
+ // as getPkgVersionFresh()) so we see exactly what npm wrote, and fail loudly
1326
+ // instead of restarting the engine/dashboard on stale code.
1327
+ const installedVersion = getPkgVersionFresh();
1328
+ if (installedVersion !== targetVersion) {
1329
+ console.error(`\n ERROR: Update did not advance as expected.`);
1330
+ console.error(` Before update: ${preVersion || 'unknown'}`);
1331
+ console.error(` Resolved target: ${targetVersion} (from npm registry, resolved before install)`);
1332
+ console.error(` On disk after npm: ${installedVersion || 'unknown'} (${PKG_ROOT})`);
1333
+ console.error(` This usually means npm resolved a stale local registry-metadata cache.`);
1334
+ console.error(` Try: npm cache clean --force`);
1335
+ console.error(` Then: minions update\n`);
1336
+ process.exit(1);
1337
+ }
1291
1338
  }
1292
1339
  // Restart engine + dashboard so they pick up the new code. With --no-wait the
1293
1340
  // restart is spawned detached so the caller (e.g. a DevBox maintenance task)
package/engine/ado.js CHANGED
@@ -2623,6 +2623,40 @@ async function prExists(adoOrg, adoProject, adoRepo, prNumber) {
2623
2623
  }
2624
2624
  }
2625
2625
 
2626
+ /**
2627
+ * W-mr9jo2n2000667ad (bug B) — fetch the list of file paths an ADO PR
2628
+ * touched, so the stale-PR-dispatch pruner can verify a fix WI's scope
2629
+ * actually overlaps the merged/abandoned PR before cancelling it on status
2630
+ * alone. Reads the latest iteration's changes. Returns null on any failure
2631
+ * (no token / network / missing iterations) — callers must treat null as
2632
+ * "unknown", not "no files changed".
2633
+ */
2634
+ async function getPrChangedFiles(project, prNumber) {
2635
+ const n = parseInt(prNumber, 10);
2636
+ if (!project || !Number.isInteger(n) || n <= 0) return null;
2637
+ const token = await getAdoToken();
2638
+ if (!token) return null;
2639
+ const orgBase = getAdoOrgBase(project);
2640
+ const adoProj = project.adoProject;
2641
+ const adoRepo = getAdoRepositoryLookupKey(project);
2642
+ if (!orgBase || !adoProj || !adoRepo) return null;
2643
+ try {
2644
+ const itersUrl = `${orgBase}/${encodeURIComponent(adoProj)}/_apis/git/repositories/${encodeURIComponent(adoRepo)}/pullrequests/${encodeURIComponent(String(n))}/iterations?api-version=7.1`;
2645
+ const iters = await adoFetch(itersUrl, token);
2646
+ const iterList = iters?.value;
2647
+ if (!Array.isArray(iterList) || !iterList.length) return null;
2648
+ const latestId = iterList[iterList.length - 1]?.id;
2649
+ if (!latestId) return null;
2650
+ const changesUrl = `${orgBase}/${encodeURIComponent(adoProj)}/_apis/git/repositories/${encodeURIComponent(adoRepo)}/pullrequests/${encodeURIComponent(String(n))}/iterations/${latestId}/changes?api-version=7.1`;
2651
+ const changes = await adoFetch(changesUrl, token);
2652
+ const entries = changes?.changeEntries;
2653
+ if (!Array.isArray(entries)) return null;
2654
+ return entries.map(e => e?.item?.path).filter(Boolean);
2655
+ } catch {
2656
+ return null;
2657
+ }
2658
+ }
2659
+
2626
2660
  /**
2627
2661
  * Fetch live PR and build status for a single PR number.
2628
2662
  * Used by engine/ado-status.js so agents can check CI without raw curl calls.
@@ -3017,6 +3051,7 @@ module.exports = {
3017
3051
  getAdoThrottleState,
3018
3052
  fetchAdoPrMetadata,
3019
3053
  prExists, // issue #246 — confirm a loose ref points at a PR (not a work item) before stamping
3054
+ getPrChangedFiles, // W-mr9jo2n2000667ad — stale-PR-pruner scope-overlap verification
3020
3055
  fetchSinglePrBuildStatus,
3021
3056
  findOpenPrOnBranch,
3022
3057
  applyAdoPrMetadata, // #3079 — exported for unit tests of targetRefName + retarget reset
@@ -573,6 +573,50 @@ function cancelSourceWorkItemForPrunedDispatch(entry, reason) {
573
573
  return cancelled;
574
574
  }
575
575
 
576
+ /**
577
+ * W-mr9jo2n2000667ad (bug B) — best-effort resolution of the work item behind
578
+ * a pending dispatch entry, used only by the scope-overlap gate below.
579
+ */
580
+ function _resolveWorkItemForPrunedEntry(entry) {
581
+ const itemId = entry?.meta?.item?.id;
582
+ if (!itemId) return null;
583
+ try {
584
+ const wiPath = lifecycle().resolveWorkItemPath(entry.meta);
585
+ if (!wiPath) return null;
586
+ const items = safeJsonArr(wiPath);
587
+ return items.find(w => w && w.id === itemId) || null;
588
+ } catch {
589
+ return null;
590
+ }
591
+ }
592
+
593
+ /**
594
+ * W-mr9jo2n2000667ad (bug B) — fetch the changed-file list for the dispatch's
595
+ * targeted PR from the live host API. Returns null (unknown) on any failure
596
+ * or unsupported host so callers fail open to the pre-existing cancel
597
+ * behavior rather than getting stuck.
598
+ */
599
+ async function _fetchChangedFilesForPrunedEntry(entry, project) {
600
+ const prNumber = shared.getPrNumber(entry?.meta?.pr);
601
+ if (prNumber == null || !project) return null;
602
+ const host = String(project.repoHost || '').toLowerCase();
603
+ try {
604
+ if (host === 'github') {
605
+ const github = require('./github');
606
+ const slug = github.getRepoSlug(project);
607
+ if (!slug) return null;
608
+ return await github.getPrChangedFiles(slug, prNumber);
609
+ }
610
+ if (host === 'ado' || !host) {
611
+ const ado = require('./ado');
612
+ return await ado.getPrChangedFiles(project, prNumber);
613
+ }
614
+ } catch (e) {
615
+ log('warn', `pruneStalePrDispatches: failed fetching changed files for ${entry.id}: ${e.message}`);
616
+ }
617
+ return null;
618
+ }
619
+
576
620
  function pruneStalePrDispatches(config = queries.getConfig()) {
577
621
  const removed = [];
578
622
  mutateDispatch((dispatch) => {
@@ -593,6 +637,96 @@ function pruneStalePrDispatches(config = queries.getConfig()) {
593
637
  return removed.length;
594
638
  }
595
639
 
640
+ // Reasons produced by getStalePrDispatchReason's `tracked.status !== ACTIVE`
641
+ // branch (`PR <id> is <merged|abandoned|closed>`) are the ONLY ones gated by
642
+ // the scope-overlap check below — context-only and branch-mismatch reasons
643
+ // are independent of file scope and prune exactly as before.
644
+ const _MERGED_OR_ABANDONED_REASON_RE = /\bis (merged|abandoned|closed)$/i;
645
+
646
+ /**
647
+ * Async variant of `pruneStalePrDispatches` (bug B, W-mr9jo2n2000667ad):
648
+ * before hard-cancelling a `fix`-type work item purely because its targeted
649
+ * PR is now merged/abandoned/closed, verify the WI's own scope (file paths
650
+ * mentioned in its title/description, or an explicit `files`/`scope.files`
651
+ * field) actually appears in that PR's changed-file diff. If the WI has
652
+ * discoverable scope hints AND the PR's changed files can be fetched AND
653
+ * the two sets are disjoint, the WI is almost certainly unrelated to the PR
654
+ * (e.g. a false-positive `targetPr` stamp from a scratch-filename substring
655
+ * — see extractPrRefFromText hardening) — leave the dispatch pending and
656
+ * write an inbox alert for human review instead of silently cancelling it.
657
+ *
658
+ * Falls back to the original unconditional-cancel behavior when scope hints
659
+ * are absent, the PR's changed files can't be fetched, or the overlap check
660
+ * finds a genuine match — this only tightens the false-positive path, it
661
+ * never blocks a legitimate "PR merged, WI resolved" cancellation.
662
+ *
663
+ * Engine callers should await this; the sync `pruneStalePrDispatches` above
664
+ * is kept for callers/tests that don't need the network-backed scope check.
665
+ */
666
+ async function pruneStalePrDispatchesAsync(config = queries.getConfig()) {
667
+ const snapshot = queries.getDispatch();
668
+ const pending = Array.isArray(snapshot?.pending) ? snapshot.pending : [];
669
+
670
+ const reasonsById = new Map();
671
+ for (const entry of pending) {
672
+ const reason = getStalePrDispatchReason(entry, config);
673
+ if (reason) reasonsById.set(entry.id, reason);
674
+ }
675
+
676
+ const flaggedForReview = [];
677
+ for (const entry of pending) {
678
+ const reason = reasonsById.get(entry.id);
679
+ if (!reason) continue;
680
+ if (entry.type !== WORK_TYPE.FIX || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
681
+ try {
682
+ const wi = _resolveWorkItemForPrunedEntry(entry);
683
+ const scopePaths = shared.extractScopeFilePathsFromWorkItem(wi);
684
+ if (!scopePaths.length) continue; // unknown scope — keep legacy cancel behavior
685
+
686
+ const project = _resolveDispatchProject(entry.meta.project, config);
687
+ const changedFiles = await _fetchChangedFilesForPrunedEntry(entry, project);
688
+ const overlap = shared.scopeOverlapsPrChangedFiles(scopePaths, changedFiles);
689
+ if (overlap === false) {
690
+ reasonsById.delete(entry.id);
691
+ flaggedForReview.push({ entry, reason, scopePaths, changedFiles });
692
+ }
693
+ // overlap === true → genuine match, fall through to cancel as before.
694
+ // overlap === null → changed files unavailable, fail open to legacy cancel.
695
+ } catch (e) {
696
+ log('warn', `pruneStalePrDispatches: scope-overlap check failed for ${entry.id}: ${e.message}`);
697
+ // best-effort — fall through to legacy cancel behavior
698
+ }
699
+ }
700
+
701
+ const removed = [];
702
+ mutateDispatch((dispatch) => {
703
+ dispatch.pending = (dispatch.pending || []).filter(entry => {
704
+ const reason = reasonsById.get(entry.id);
705
+ if (!reason) return true;
706
+ removed.push({ entry, reason });
707
+ return false;
708
+ });
709
+ return dispatch;
710
+ });
711
+
712
+ for (const { entry, reason } of removed) {
713
+ try { deleteDispatchPromptSidecar(entry); } catch { /* cleanup best-effort */ }
714
+ const cancelled = cancelSourceWorkItemForPrunedDispatch(entry, reason);
715
+ log('info', `Dropped stale PR dispatch ${entry.id}: ${reason}${cancelled ? ' (source work item cancelled)' : ''}`);
716
+ }
717
+
718
+ for (const { entry, reason, scopePaths } of flaggedForReview) {
719
+ const itemId = entry?.meta?.item?.id || entry.id;
720
+ const msg = `Stale-PR-dispatch pruner kept ${itemId} pending: targetPr ${reason}, but the WI's ` +
721
+ `scope (${scopePaths.join(', ')}) does not appear in that PR's diff. Not auto-cancelling — ` +
722
+ `flagging for human review.`;
723
+ log('warn', `pruneStalePrDispatches: ${msg}`);
724
+ try { writeInboxAlert(`stale-pr-scope-mismatch-${itemId}`, msg); } catch { /* best-effort */ }
725
+ }
726
+
727
+ return removed.length;
728
+ }
729
+
596
730
  // ─── Retryable Failure Classification ────────────────────────────────────────
597
731
 
598
732
  function isRetryableFailureReason(reason = '', failureClass = '') {
@@ -1315,6 +1449,7 @@ module.exports = {
1315
1449
  updateAgentStatus,
1316
1450
  getStalePrDispatchReason,
1317
1451
  pruneStalePrDispatches,
1452
+ pruneStalePrDispatchesAsync, // W-mr9jo2n2000667ad — scope-overlap-verified variant; engine.js tick should await this
1318
1453
  cancelPendingDispatchesForPr,
1319
1454
  cleanDispatchEntries,
1320
1455
  cancelPendingWorkItems,
package/engine/github.js CHANGED
@@ -420,6 +420,22 @@ async function prExists(slug, prNumber, opts = {}) {
420
420
  return null;
421
421
  }
422
422
 
423
+ /**
424
+ * W-mr9jo2n2000667ad (bug B) — fetch the list of file paths a GitHub PR
425
+ * touched, so the stale-PR-dispatch pruner can verify a fix WI's scope
426
+ * actually overlaps the merged/abandoned PR before cancelling it on status
427
+ * alone. Paginated (a PR can touch more than the default 30-file page).
428
+ * Returns null on any failure (network / auth / 404) — callers must treat
429
+ * null as "unknown", not "no files changed".
430
+ */
431
+ async function getPrChangedFiles(slug, prNumber) {
432
+ const n = parseInt(prNumber, 10);
433
+ if (!slug || !Number.isInteger(n) || n <= 0) return null;
434
+ const result = await ghApi(`/pulls/${n}/files`, slug, { paginate: true });
435
+ if (!Array.isArray(result)) return null;
436
+ return result.map(f => f && f.filename).filter(Boolean);
437
+ }
438
+
423
439
  const BUILD_ERROR_LOG_MAX_LINES = 150;
424
440
 
425
441
  /**
@@ -1961,6 +1977,7 @@ module.exports = {
1961
1977
  getGhThrottleState,
1962
1978
  ghApi, // P-8c1b6e45 — used by engine/shared-branch-pr-reconcile.js to list open PRs on a feature branch
1963
1979
  prExists, // issue #246 — confirm a loose ref points at a PR (not an issue) before stamping
1980
+ getPrChangedFiles, // W-mr9jo2n2000667ad — stale-PR-pruner scope-overlap verification
1964
1981
  // Exported for testing
1965
1982
  isGitHub,
1966
1983
  getRepoSlug,
@@ -652,8 +652,26 @@ function createStreamConsumer(ctx) {
652
652
  let claudeJoinedText = '';
653
653
  const claudeStreamBlocks = new Map();
654
654
 
655
+ // Only the TRAILING run of text blocks (since the last non-text block)
656
+ // contributes to claudeJoinedText — a tool_use/thinking block closes the
657
+ // run so an earlier text block never welds onto a later one within the
658
+ // SAME message (W-mr9jj2vx000 — CC response duplicates/welds the final
659
+ // answer after ~15 tool calls). Claude can emit content as
660
+ // [text, tool_use, tool_use, ..., text] in one message when
661
+ // --include-partial-messages streams multiple parallel tool calls before
662
+ // a trailing answer; without this, content_block_delta blindly appended
663
+ // every text delta onto the same buffer regardless of the tool_use block
664
+ // in between, producing "<narration><tool icon><narration><answer>" with
665
+ // no paragraph break and no new segment (segmentSeq never bumped because
666
+ // stream-event tool_use blocks don't call ctx.pushToolUse).
655
667
  function _rebuildClaudeJoinedText() {
656
- claudeJoinedText = Array.from(claudeStreamBlocks.keys()).sort((a, b) => a - b)
668
+ const indices = Array.from(claudeStreamBlocks.keys()).sort((a, b) => a - b);
669
+ let start = 0;
670
+ for (let i = indices.length - 1; i >= 0; i--) {
671
+ const block = claudeStreamBlocks.get(indices[i]);
672
+ if (!block || block.type !== 'text') { start = i + 1; break; }
673
+ }
674
+ claudeJoinedText = indices.slice(start)
657
675
  .map(index => claudeStreamBlocks.get(index))
658
676
  .filter(block => block && block.type === 'text' && block.text)
659
677
  .map(block => block.text)
@@ -673,6 +691,13 @@ function createStreamConsumer(ctx) {
673
691
  const block = event.content_block || {};
674
692
  claudeStreamBlocks.set(index, { type: block.type || '', text: block.text || '' });
675
693
  if (THINKING_BLOCK_TYPES.has(block.type)) ctx.notifyThinking();
694
+ // A non-text block (tool_use/thinking) closes the current text run —
695
+ // reset the hot-path accumulator so the NEXT text block starts fresh
696
+ // instead of welding onto text from before this block. `ctx.pushText`
697
+ // already opens a new segment when the pushed value doesn't extend
698
+ // `lastTextSent` (see engine/llm.js), so this reset is sufficient to
699
+ // both stop the weld and trigger the segment boundary.
700
+ if (block.type !== 'text') claudeJoinedText = '';
676
701
  // Out-of-order block landing: rebuild from the Map. Common case is
677
702
  // monotonic in-order arrival, where the trailing-append branch wins.
678
703
  const indices = Array.from(claudeStreamBlocks.keys());
@@ -735,21 +760,27 @@ function createStreamConsumer(ctx) {
735
760
 
736
761
  if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
737
762
  // Claude assistant turn: content blocks (text + tool_use).
738
- // Multi-text-block messages (with --include-partial-messages) need their
739
- // text JOINED before pushText, otherwise each block overwrites the prior.
763
+ // Multi-text-block messages (with --include-partial-messages) need
764
+ // consecutive text blocks JOINED before pushText, otherwise each block
765
+ // overwrites the prior. But a tool_use block closes the current text
766
+ // run — reset the accumulator on tool_use so trailing text starts a
767
+ // fresh segment instead of welding onto text from before the tool
768
+ // call(s) (W-mr9jj2vx0004bbb9 follow-up: this full-envelope path hits
769
+ // the same weld bug as the stream_event path above).
740
770
  let assistantText = '';
741
771
  for (const block of obj.message.content) {
742
772
  if (block?.type === 'text' && block.text) {
743
773
  assistantText += block.text;
774
+ ctx.pushText(assistantText);
744
775
  } else if (THINKING_BLOCK_TYPES.has(block?.type)) {
745
776
  ctx.notifyThinking();
746
777
  } else if (block?.type === 'tool_use' && block.name) {
747
778
  // Thread the tool_use id so the dashboard can flip this chip from
748
779
  // pending → completed/failed when the matching tool_result lands.
749
780
  ctx.pushToolUse(block.name, block.input || {}, block.id);
781
+ assistantText = '';
750
782
  }
751
783
  }
752
- if (assistantText) ctx.pushText(assistantText);
753
784
  }
754
785
 
755
786
  if (obj.type === 'user' && Array.isArray(obj.message?.content)) {
package/engine/shared.js CHANGED
@@ -6777,6 +6777,12 @@ function parsePrUrl(url) {
6777
6777
  // `isPrTargeted` paths intact and prevents PR-feedback fix WIs from being
6778
6778
  // dispatched on a fresh `work/<wi-id>` branch when only the description /
6779
6779
  // references[] carried the PR pointer (issue #2999).
6780
+ // Reject a digit-string match when it's immediately followed by a
6781
+ // file-extension-like suffix (".diff", ".json", ".patch", ...) — a strong
6782
+ // signal the match is actually a scratch/diff filename fragment (e.g.
6783
+ // "lambert-pr685.diff") rather than a genuine PR reference.
6784
+ const _FOLLOWED_BY_FILE_EXT_RE = /^\.[A-Za-z0-9]{1,8}\b/;
6785
+
6780
6786
  function extractPrRefFromText(value) {
6781
6787
  const text = String(value || '');
6782
6788
  if (!text.trim()) return null;
@@ -6784,10 +6790,23 @@ function extractPrRefFromText(value) {
6784
6790
  if (urlMatch) return String(urlMatch[0] || '').replace(/[),.;:]+$/g, '');
6785
6791
  const canonicalMatch = text.match(/\b(?:github|ado):[^\s#]+#\d+\b/i);
6786
6792
  if (canonicalMatch) return canonicalMatch[0];
6793
+ // W-mr9jo2n2000667ad (bug A): require a clear delimiter — "#", "-", or
6794
+ // whitespace — between "PR"/"pull request"/"pullrequest" and the digits.
6795
+ // A bare digit-adjacent "pr" substring (e.g. "pr685" inside
6796
+ // "lambert-pr685.diff") is very likely part of a scratch/diff filename, not
6797
+ // a genuine "see PR #685" reference, and must NOT match. Also reject when
6798
+ // the digits are immediately followed by a file extension, even when a
6799
+ // delimiter IS present (guards "PR-685.diff"-style scratch filenames too).
6787
6800
  const legacyMatch = text.match(/\bPR-(\d+)\b/i);
6788
- if (legacyMatch) return legacyMatch[1];
6789
- const numberMatch = text.match(/\b(?:pr|pull\s+request|pullrequest)\s*#?\s*(\d+)\b/i);
6790
- return numberMatch ? numberMatch[1] : null;
6801
+ if (legacyMatch && !_FOLLOWED_BY_FILE_EXT_RE.test(text.slice(legacyMatch.index + legacyMatch[0].length))) {
6802
+ return legacyMatch[1];
6803
+ }
6804
+ const numberMatch = text.match(/\b(?:pr|pull\s+request|pullrequest)\s*[#-]\s*(\d+)\b/i)
6805
+ || text.match(/\b(?:pr|pull\s+request|pullrequest)\s+(\d+)\b/i);
6806
+ if (numberMatch && !_FOLLOWED_BY_FILE_EXT_RE.test(text.slice(numberMatch.index + numberMatch[0].length))) {
6807
+ return numberMatch[1];
6808
+ }
6809
+ return null;
6791
6810
  }
6792
6811
 
6793
6812
  // Cap for the description-text fallback scan in `extractWorkItemPrRef`.
@@ -7002,6 +7021,86 @@ function extractWorkItemPrRef(item) {
7002
7021
  return extractPrRefFromText(item.title) || null;
7003
7022
  }
7004
7023
 
7024
+ // ─── Work-item scope vs PR-diff overlap (bug B, W-mr9jo2n2000667ad) ─────────
7025
+ //
7026
+ // `pruneStalePrDispatches` used to cancel a fix-type WI the moment its
7027
+ // `targetPr` pointed at a merged/abandoned PR, with no check that the PR
7028
+ // actually touched the files the WI was about. A cleanup WI whose targetPr
7029
+ // was itself a false-positive stamp (see extractPrRefFromText hardening
7030
+ // above) got silently cancelled even though the merged PR never touched the
7031
+ // WI's actual scope. `extractScopeFilePathsFromWorkItem` + `scopeOverlapsPrChangedFiles`
7032
+ // let the pruner verify overlap before treating "PR merged" as "WI resolved".
7033
+
7034
+ /**
7035
+ * Best-effort extraction of file-path-like scope hints from a work item.
7036
+ * Prefers an explicit structured scope (`item.files` or `item.scope.files`
7037
+ * array — either may be populated by a caller/agent that already knows the
7038
+ * exact touched paths). Falls back to scanning title + description for
7039
+ * backtick-quoted code spans (the common "delete `foo/bar.js`" convention)
7040
+ * and bare path-like / dotted-filename tokens. Returns [] when nothing
7041
+ * scope-like is found — callers must treat [] as "unknown scope", not "no
7042
+ * overlap", and fall back to their pre-existing behavior.
7043
+ */
7044
+ function extractScopeFilePathsFromWorkItem(item) {
7045
+ if (!item || typeof item !== 'object') return [];
7046
+ const explicit = Array.isArray(item.files) ? item.files
7047
+ : Array.isArray(item?.scope?.files) ? item.scope.files
7048
+ : null;
7049
+ if (explicit && explicit.length) {
7050
+ return explicit.map(f => String(f || '').trim()).filter(Boolean);
7051
+ }
7052
+ const text = `${item.title || ''}\n${item.description || ''}`;
7053
+ if (!text.trim()) return [];
7054
+ const paths = new Set();
7055
+ // Backtick-quoted code spans: `path/to/file.ext` or `bare-filename`.
7056
+ const codeSpanRe = /`([^`\n]{1,200})`/g;
7057
+ let m;
7058
+ while ((m = codeSpanRe.exec(text))) {
7059
+ const candidate = m[1].trim();
7060
+ if (candidate && !/\s/.test(candidate)) paths.add(candidate);
7061
+ }
7062
+ // Bare path-like tokens: contain a '/' (directory-qualified) or end in a
7063
+ // dotted extension (bare filename mentioned in prose).
7064
+ const pathTokenRe = /\b[\w.-]+\/[\w./-]+\b|\b[\w-]+\.[A-Za-z0-9]{1,12}\b/g;
7065
+ while ((m = pathTokenRe.exec(text))) {
7066
+ paths.add(m[0]);
7067
+ }
7068
+ return Array.from(paths);
7069
+ }
7070
+
7071
+ /**
7072
+ * Does `scopePaths` (from extractScopeFilePathsFromWorkItem) intersect
7073
+ * `changedFiles` (a PR's changed-file paths)? Matches on exact path,
7074
+ * suffix (directory-qualified scope hint vs a deeper repo path), or bare
7075
+ * filename (basename) equality so "lambert-pr685.diff" mentioned in prose
7076
+ * matches a changed path like "scratch/lambert-pr685.diff".
7077
+ *
7078
+ * Returns:
7079
+ * true — at least one scope hint matches a changed file
7080
+ * false — scope hints and changed files are both known and disjoint
7081
+ * null — unknown (no scope hints extracted, or changedFiles unavailable)
7082
+ * — callers must treat null as "can't verify", not "no overlap".
7083
+ */
7084
+ function scopeOverlapsPrChangedFiles(scopePaths, changedFiles) {
7085
+ if (!Array.isArray(scopePaths) || !scopePaths.length) return null;
7086
+ if (!Array.isArray(changedFiles) || !changedFiles.length) return null;
7087
+ const normalize = p => String(p || '').replace(/^\.\//, '').replace(/\\/g, '/').toLowerCase();
7088
+ const changedNorm = changedFiles.map(normalize).filter(Boolean);
7089
+ const changedBase = new Set(changedNorm.map(p => p.split('/').pop()));
7090
+ const changedSet = new Set(changedNorm);
7091
+ for (const raw of scopePaths) {
7092
+ const p = normalize(raw);
7093
+ if (!p) continue;
7094
+ if (changedSet.has(p)) return true;
7095
+ const base = p.split('/').pop();
7096
+ if (base && changedBase.has(base)) return true;
7097
+ for (const cf of changedNorm) {
7098
+ if (cf.endsWith('/' + p) || p.endsWith('/' + cf)) return true;
7099
+ }
7100
+ }
7101
+ return false;
7102
+ }
7103
+
7005
7104
  function getProjectPrScope(project) {
7006
7105
  if (!project) return '';
7007
7106
  const host = String(project.repoHost || '').toLowerCase();
@@ -9531,6 +9630,8 @@ module.exports = {
9531
9630
  extractPrRefFromText,
9532
9631
  extractWorkItemPrRef,
9533
9632
  extractStructuredWorkItemPrRef,
9633
+ extractScopeFilePathsFromWorkItem,
9634
+ scopeOverlapsPrChangedFiles,
9534
9635
  rewriteInboxRefsAcrossProjects,
9535
9636
  _artifactNoteFileToken, // exported so the one-time note-link backfill shares the token grammar
9536
9637
  getProjectPrScope,
package/engine.js CHANGED
@@ -120,7 +120,7 @@ const READ_ONLY_ROOT_TASK_TYPES = shared.READ_ONLY_ROOT_TASK_TYPES;
120
120
  // ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
121
121
 
122
122
  const { mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch,
123
- writeInboxAlert, updateAgentStatus, pruneStalePrDispatches } = require('./engine/dispatch');
123
+ writeInboxAlert, updateAgentStatus, pruneStalePrDispatches, pruneStalePrDispatchesAsync } = require('./engine/dispatch');
124
124
 
125
125
  // ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
126
126
 
@@ -10391,7 +10391,8 @@ async function tickInner() {
10391
10391
  } catch (err) { log('warn', `Stall detection error: ${err?.message || err}`); }
10392
10392
  }
10393
10393
 
10394
- try { pruneStalePrDispatches(config); } catch (e) { log('warn', 'prune stale PR dispatches: ' + e.message); }
10394
+ try { await pruneStalePrDispatchesAsync(config); } catch (e) { log('warn', 'prune stale PR dispatches: ' + e.message); }
10395
+ if (_isTickStale(myGeneration)) return;
10395
10396
 
10396
10397
  // Process pending dispatches before discovery. discoverWork() runs
10397
10398
  // pre-dispatch LLM validation for newly found work; a slow validator must not
@@ -10914,7 +10915,7 @@ module.exports = {
10914
10915
  validateConfig,
10915
10916
 
10916
10917
  // Dispatch management (re-exported from engine/dispatch.js)
10917
- mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches,
10918
+ mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches, pruneStalePrDispatchesAsync,
10918
10919
  activeProcesses, realActivityMap, engineRestartGraceExempt,
10919
10920
  get engineRestartGraceUntil() { return engineRestartGraceUntil; },
10920
10921
  set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2336",
3
+ "version": "0.1.2338",
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"