@yemi33/minions 0.1.2364 → 0.1.2366

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.
@@ -6695,6 +6695,7 @@ function pruneScopeMismatchDuplicatePrs(config) {
6695
6695
  config = config || getConfig();
6696
6696
  const projects = shared.getProjects(config) || [];
6697
6697
  if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
6698
+ const projectByName = new Map(projects.filter(project => project?.name).map(project => [project.name, project]));
6698
6699
 
6699
6700
  // Map project name -> canonical scope(s) so we can find which project IS
6700
6701
  // the correctly-scoped owner for a given mismatch record. Includes
@@ -6717,14 +6718,19 @@ function pruneScopeMismatchDuplicatePrs(config) {
6717
6718
  }
6718
6719
  }
6719
6720
 
6720
- // Index all PRs by canonical id across all projects (post-_scope decoration).
6721
+ // Index all PRs by repository-scoped identity across all projects.
6721
6722
  const store = require('./pull-requests-store');
6722
6723
  const allPrs = store.readAllPullRequests() || [];
6723
6724
  const byId = new Map();
6724
6725
  for (const pr of allPrs) {
6725
6726
  if (!pr || !pr.id) continue;
6726
- if (!byId.has(pr.id)) byId.set(pr.id, []);
6727
- byId.get(pr.id).push(pr);
6727
+ const ownerProject = pr._scope && pr._scope !== 'central'
6728
+ ? projectByName.get(pr._scope) || null
6729
+ : null;
6730
+ const identity = shared.getPrIdentityKey(pr, ownerProject);
6731
+ if (!identity) continue;
6732
+ if (!byId.has(identity)) byId.set(identity, []);
6733
+ byId.get(identity).push(pr);
6728
6734
  }
6729
6735
 
6730
6736
  // Build per-project delete sets keyed by id, so we batch one mutation per
@@ -6741,6 +6747,9 @@ function pruneScopeMismatchDuplicatePrs(config) {
6741
6747
  for (const records of byId.values()) {
6742
6748
  for (const rec of records) {
6743
6749
  scanned++;
6750
+ // A tombstone is the durable record of explicit user intent. Never prune
6751
+ // it in favor of an active sibling, or the sibling becomes visible again.
6752
+ if (rec.userDeleted === true) continue;
6744
6753
  if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
6745
6754
  const correctScope = rec._invalidProjectScope.prScope;
6746
6755
  if (!correctScope) continue;
@@ -6831,8 +6840,8 @@ function pruneScopeMismatchDuplicatePrs(config) {
6831
6840
  return { pruned, relocated, scanned };
6832
6841
  }
6833
6842
 
6834
- // Repair helper: collapse prNumber duplicates across all project-scoped and
6835
- // the central pull-requests file. Called once per reconcile cycle alongside
6843
+ // Repair helper: collapse duplicate repository-scoped PR identities across all
6844
+ // project-scoped and central tracker data. Called once per reconcile cycle alongside
6836
6845
  // pruneScopeMismatchDuplicatePrs so accumulated duplicates are cleaned up
6837
6846
  // without requiring a one-off migration.
6838
6847
  function collapseAllDuplicatePrRecords(config) {
package/engine/queries.js CHANGED
@@ -1001,25 +1001,34 @@ function getPullRequests(config) {
1001
1001
  // only tombstones one scope's copy would still have its untouched
1002
1002
  // cross-scope duplicate survive grouping below and get picked as the
1003
1003
  // dedupe "winner", silently resurrecting a PR the user just deleted.
1004
- const tombstonedIds = new Set();
1004
+ const identityOf = (pr) => {
1005
+ const ownerProject = pr?._scope && pr._scope !== 'central'
1006
+ ? projectByName.get(pr._scope) || null
1007
+ : null;
1008
+ return shared.getPrIdentityKey(pr, ownerProject);
1009
+ };
1010
+ const tombstonedIdentities = new Set();
1005
1011
  for (const pr of sqlPrs) {
1006
- if (pr && pr.id && pr.userDeleted === true) tombstonedIds.add(pr.id);
1012
+ const identity = identityOf(pr);
1013
+ if (identity && pr.userDeleted === true) tombstonedIdentities.add(identity);
1007
1014
  }
1008
1015
 
1009
1016
  const groups = [];
1010
- const groupsById = new Map();
1017
+ const groupsByIdentity = new Map();
1011
1018
  for (const pr of sqlPrs) {
1012
1019
  if (!pr || !pr.id) continue;
1020
+ const identity = identityOf(pr);
1021
+ if (!identity) continue;
1013
1022
  // Issue #384/#803: skip tombstoned records (user explicitly deleted), and
1014
- // skip ANY record sharing an id that has a tombstoned copy elsewhere.
1023
+ // skip ANY alias of an identity that has a tombstoned copy elsewhere.
1015
1024
  if (pr.userDeleted === true) continue;
1016
- if (tombstonedIds.has(pr.id)) continue;
1017
- if (!groupsById.has(pr.id)) {
1025
+ if (tombstonedIdentities.has(identity)) continue;
1026
+ if (!groupsByIdentity.has(identity)) {
1018
1027
  const g = [];
1019
- groupsById.set(pr.id, g);
1028
+ groupsByIdentity.set(identity, g);
1020
1029
  groups.push(g);
1021
1030
  }
1022
- groupsById.get(pr.id).push(pr);
1031
+ groupsByIdentity.get(identity).push(pr);
1023
1032
  }
1024
1033
  for (const group of groups) {
1025
1034
  const { winner, dropped } = _pickPrDedupeWinner(group, projects);
package/engine/shared.js CHANGED
@@ -6327,16 +6327,18 @@ function resolveAgentTempBaseDir(project, engine, minionsDir) {
6327
6327
 
6328
6328
  // Seed the agent COPILOT_HOME's mcp-config (copy of `~/.copilot/mcp-config.json`
6329
6329
  // MINUS `engine.copilotAgentDisabledMcpServers`) and return the home path. This
6330
- // is the single source of truth for "what MCP servers may a minions-spawned
6331
- // copilot load." It is applied to EVERY copilot spawn path — agent dispatches
6332
- // (engine), internal `llm.callLLM` calls (engine), and CC / doc-chat / the CC
6333
- // worker pool (dashboard) — by setting `process.env.COPILOT_HOME` at the boot of
6334
- // both processes, so every copilot CHILD inherits the filtered config. copilot
6335
- // SPAWNS every server in its config and authenticates it (`--disable-mcp-server`
6336
- // only hides tools), so any auth-requiring server pops a Microsoft window per
6337
- // spawn filtering the config is the only effective control. Idempotent: writes
6338
- // only when the content changes (safe under concurrent callers). The operator's
6339
- // real `~/.copilot` (interactive copilot) is never touched. Fail-open.
6330
+ // is the source of truth for "what MCP servers may an agent-dispatch copilot
6331
+ // load." It is applied to the engine-process copilot spawn paths ONLY — agent
6332
+ // dispatches (`_applyAgentCopilotHome`) and the engine process's own internal
6333
+ // `llm.callLLM` calls — by setting `process.env.COPILOT_HOME` at engine boot
6334
+ // and re-stamping it per dispatch, so those copilot CHILDREN inherit the
6335
+ // filtered config. copilot SPAWNS every server in its config and
6336
+ // authenticates it (`--disable-mcp-server` only hides tools), so any
6337
+ // auth-requiring server pops a Microsoft window per spawn filtering the
6338
+ // config is the only effective control. Idempotent: writes only when the
6339
+ // content changes (safe under concurrent callers). The operator's real
6340
+ // `~/.copilot` (interactive copilot, and now also Command Center / doc-chat /
6341
+ // the CC worker pool, per W-mre75l6x00024f49) is never touched. Fail-open.
6340
6342
  function ensureAgentCopilotHome(minionsDir, engine) {
6341
6343
  const home = resolveAgentCopilotHome(minionsDir);
6342
6344
  try {
@@ -7517,6 +7519,19 @@ function getCanonicalPrId(project, prRef, url = '') {
7517
7519
  return scope ? `${scope}#${prNumber}` : `PR-${prNumber}`;
7518
7520
  }
7519
7521
 
7522
+ function getPrIdentityKey(prRef, project = null) {
7523
+ if (!prRef) return '';
7524
+ const url = typeof prRef === 'object' ? String(prRef.url || '') : '';
7525
+ const rawId = typeof prRef === 'object' ? prRef.id : prRef;
7526
+ const identity = getCanonicalPrId(project, prRef, url) || String(rawId || '');
7527
+ const parsed = parseCanonicalPrId(identity);
7528
+ if (parsed && project && isAdoPrScopeCompatible(parsed.scope, project)) {
7529
+ const projectScope = getProjectPrScope(project);
7530
+ if (projectScope?.startsWith('ado:')) return `${projectScope}#${parsed.prNumber}`;
7531
+ }
7532
+ return identity;
7533
+ }
7534
+
7520
7535
  function findPrRecord(prs, prRef, project = null) {
7521
7536
  if (!Array.isArray(prs) || !prRef) return null;
7522
7537
  const isObjectRef = typeof prRef === 'object';
@@ -7812,14 +7827,19 @@ function _mergeDuplicatePrInto(winner, loser) {
7812
7827
  }
7813
7828
  }
7814
7829
  winner.prdItems = normalizePrLinkItems([...(winner.prdItems || []), ...(loser.prdItems || [])]);
7830
+ if (winner.userDeleted === true || loser.userDeleted === true) {
7831
+ winner.userDeleted = true;
7832
+ if (winner.userDeletedAt == null && loser.userDeletedAt != null) {
7833
+ winner.userDeletedAt = loser.userDeletedAt;
7834
+ }
7835
+ }
7815
7836
  }
7816
7837
 
7817
7838
  /**
7818
- * P-e9f0a2b4 — One-time repair helper: collapse duplicate PR records that share
7819
- * the same `prNumber` within a single `pull-requests.json` file.
7839
+ * P-e9f0a2b4 — Repair helper: collapse duplicate records for the same
7840
+ * repository-scoped PR identity within a single tracker scope.
7820
7841
  *
7821
- * Call this at engine start for every project's PR file (and the central file).
7822
- * Idempotent — no-op when no duplicates exist.
7842
+ * Called from the reconciliation sweep for every tracker scope. Idempotent.
7823
7843
  *
7824
7844
  * Selection rule (matching the task spec):
7825
7845
  * 1. Prefer `merged` status over any other status.
@@ -7837,19 +7857,20 @@ function collapseDuplicatePrRecords(prPath, { project = null } = {}) {
7837
7857
  let collapsed = 0;
7838
7858
  mutatePullRequests(prPath, (prs) => {
7839
7859
  normalizePrRecords(prs, project);
7840
- // Group by prNumber.
7841
- const byNumber = new Map();
7860
+ // PR numbers are only unique within a repository. Grouping by number alone
7861
+ // merges unrelated GitHub/ADO repositories and can discard a tombstone.
7862
+ const byIdentity = new Map();
7842
7863
  for (const pr of prs) {
7843
7864
  if (!pr) continue;
7844
- const num = getPrNumber(pr);
7845
- if (num == null) continue;
7846
- if (!byNumber.has(num)) byNumber.set(num, []);
7847
- byNumber.get(num).push(pr);
7865
+ const identity = getPrIdentityKey(pr, project);
7866
+ if (!identity) continue;
7867
+ if (!byIdentity.has(identity)) byIdentity.set(identity, []);
7868
+ byIdentity.get(identity).push(pr);
7848
7869
  }
7849
7870
  // Identify loser records to remove (by object reference, not id, because
7850
7871
  // normalization may have changed multiple records to the same canonical id).
7851
7872
  const toRemove = new Set();
7852
- for (const [, group] of byNumber) {
7873
+ for (const [, group] of byIdentity) {
7853
7874
  if (group.length <= 1) continue;
7854
7875
  const best = _pickBestPrRecord(group);
7855
7876
  for (const dup of group) {
@@ -7865,6 +7886,35 @@ function collapseDuplicatePrRecords(prPath, { project = null } = {}) {
7865
7886
  return { collapsed };
7866
7887
  }
7867
7888
 
7889
+ function _clearPrTombstonesForIdentity(identity) {
7890
+ if (!identity) return 0;
7891
+ const projectsByName = new Map(getProjects().map(project => [project.name, project]));
7892
+ const scopes = new Map();
7893
+ const store = require('./pull-requests-store');
7894
+ for (const pr of store.readAllPullRequests() || []) {
7895
+ if (pr?.userDeleted !== true) continue;
7896
+ const project = pr._scope && pr._scope !== 'central'
7897
+ ? projectsByName.get(pr._scope) || null
7898
+ : null;
7899
+ if (getPrIdentityKey(pr, project) !== identity) continue;
7900
+ const prPath = store._filePathForScope(pr._scope || 'central');
7901
+ scopes.set(path.resolve(prPath), { prPath, project });
7902
+ }
7903
+ let cleared = 0;
7904
+ for (const { prPath, project } of scopes.values()) {
7905
+ mutatePullRequests(prPath, (prs) => {
7906
+ for (const pr of prs) {
7907
+ if (pr?.userDeleted !== true || getPrIdentityKey(pr, project) !== identity) continue;
7908
+ delete pr.userDeleted;
7909
+ delete pr.userDeletedAt;
7910
+ cleared++;
7911
+ }
7912
+ return prs;
7913
+ });
7914
+ }
7915
+ return cleared;
7916
+ }
7917
+
7868
7918
  /**
7869
7919
  * Canonical PR-producing work contract helper.
7870
7920
  *
@@ -7882,17 +7932,15 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7882
7932
  ...(Array.isArray(itemIds) ? itemIds : [itemId]),
7883
7933
  ]);
7884
7934
  const prNumber = getPrNumber(entry.prNumber ?? entry.id ?? entry.url);
7885
- // Issue #802: when the caller already resolved a scope-qualified canonical
7886
- // id (e.g. `ado:office/office/office#5370216`, as enrollPrFromCanonicalId
7887
- // does before calling here), it must win over recomputing from a bare
7888
- // entry.prNumber + project. Recomputing silently downgrades to a legacy
7889
- // `PR-<n>` stub whenever project is null/unresolvable (getCanonicalPrId
7890
- // can't derive a scope), discarding the good id even though it was passed
7891
- // in. Prefer entry.id whenever it already parses as canonical.
7892
- const hasCanonicalEntryId = typeof entry.id === 'string' && parseCanonicalPrId(entry.id) != null;
7893
- const canonicalId = hasCanonicalEntryId
7894
- ? getCanonicalPrId(project, entry.id, entry.url || '')
7895
- : getCanonicalPrId(project, entry.prNumber ?? entry.id ?? entry.url ?? prNumber, entry.url || '');
7935
+ // getPrIdentityKey(entry, project) passes the whole entry object through to
7936
+ // getCanonicalPrId, which (for object refs) reads `entry.id` directly rather
7937
+ // than the `entry.prNumber ?? entry.id ?? ...` coalesce used for bare refs.
7938
+ // That already preserves an already-resolved scope-qualified `entry.id`
7939
+ // (e.g. `ado:office/office/office#5370216`, as enrollPrFromCanonicalId
7940
+ // produces) instead of silently downgrading to a legacy `PR-<n>` stub when
7941
+ // `project` is null/unresolvable see issue #802 and
7942
+ // test/unit/pr-number-dedup.test.js's "(#802)" case.
7943
+ const canonicalId = getPrIdentityKey(entry, project);
7896
7944
  if (!canonicalId) throw new Error('PR id required');
7897
7945
  const normalizedEntry = {
7898
7946
  ...entry,
@@ -7916,12 +7964,19 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7916
7964
  let created = false;
7917
7965
  let linked = false;
7918
7966
  let skipped = false;
7919
- let resurrected = false;
7967
+ let resurrected = allowResurrect && _clearPrTombstonesForIdentity(canonicalId) > 0;
7920
7968
  let record = null;
7921
7969
 
7922
7970
  mutatePullRequests(prPath, (prs) => {
7923
7971
  normalizePrRecords(prs, project);
7924
- let target = findPrRecord(prs, normalizedEntry, project);
7972
+ const identityMatches = prs.filter(pr => getPrIdentityKey(pr, project) === canonicalId);
7973
+ let target = identityMatches.length > 0 ? _pickBestPrRecord(identityMatches) : null;
7974
+ if (target && identityMatches.length > 1) {
7975
+ const duplicates = new Set(identityMatches.filter(pr => pr !== target));
7976
+ for (const duplicate of duplicates) _mergeDuplicatePrInto(target, duplicate);
7977
+ prs = prs.filter(pr => !duplicates.has(pr));
7978
+ }
7979
+ const tombstonedMatches = target?.userDeleted === true ? [target] : [];
7925
7980
  // Issue #384: tombstoned records (userDeleted:true) must not be re-activated
7926
7981
  // by any poller path. Return skipped so reconcilePrs / shared-branch-reconcile
7927
7982
  // cannot resurrect a PR the user explicitly removed.
@@ -7931,13 +7986,15 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7931
7986
  // via POST /api/pull-requests/link is explicit intent to bring the PR back.
7932
7987
  // Callers that want that behavior opt in with `allowResurrect: true`, which
7933
7988
  // clears the tombstone here and lets the normal field-merge logic below run.
7934
- if (target && target.userDeleted === true) {
7989
+ if (tombstonedMatches.length > 0) {
7935
7990
  if (!allowResurrect) {
7936
7991
  skipped = true;
7937
7992
  return prs;
7938
7993
  }
7939
- delete target.userDeleted;
7940
- delete target.userDeletedAt;
7994
+ for (const match of tombstonedMatches) {
7995
+ delete match.userDeleted;
7996
+ delete match.userDeletedAt;
7997
+ }
7941
7998
  resurrected = true;
7942
7999
  }
7943
8000
  // W-mrduef3q000m2172 — defense-in-depth: `prs` here is scoped to a single
@@ -7946,37 +8003,23 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7946
8003
  // record actually lives in a DIFFERENT scope's file), the above check
7947
8004
  // never sees it and would otherwise fall through to insert-as-new,
7948
8005
  // silently clearing the tombstone. Check globally before inserting.
7949
- if (!target) {
8006
+ if (!target && !allowResurrect) {
7950
8007
  try {
7951
8008
  const store = require('./pull-requests-store');
7952
- const globalMatch = (store.readAllPullRequests() || [])
7953
- .find(pr => pr && pr.id === canonicalId && pr.userDeleted === true);
8009
+ const projectsByName = new Map(getProjects().map(candidate => [candidate.name, candidate]));
8010
+ const globalMatch = (store.readAllPullRequests() || []).find(pr => {
8011
+ if (!pr || pr.userDeleted !== true) return false;
8012
+ const ownerProject = pr._scope && pr._scope !== 'central'
8013
+ ? projectsByName.get(pr._scope) || null
8014
+ : null;
8015
+ return getPrIdentityKey(pr, ownerProject) === canonicalId;
8016
+ });
7954
8017
  if (globalMatch) {
7955
8018
  skipped = true;
7956
8019
  return prs;
7957
8020
  }
7958
8021
  } catch { /* best-effort global tombstone check — fall through on failure */ }
7959
8022
  }
7960
- // P-e9f0a2b4 — When findPrRecord returns null but multiple records share the
7961
- // same prNumber (e.g. cross-scope contamination or a project-config change
7962
- // that shifted canonical IDs), collapse the duplicates before inserting.
7963
- // Without this check, every subsequent upsert would append yet another record
7964
- // because numberMatches.length > 1 forces findPrRecord to return null.
7965
- if (!target && prNumber != null) {
7966
- const numberMatches = prs.filter(pr => pr && !pr.userDeleted && getPrNumber(pr) === prNumber);
7967
- if (numberMatches.length > 1) {
7968
- const best = _pickBestPrRecord(numberMatches);
7969
- const toRemoveIds = new Set(numberMatches.filter(p => p !== best).map(p => p.id));
7970
- for (const dup of numberMatches) {
7971
- if (dup === best) continue;
7972
- _mergeDuplicatePrInto(best, dup);
7973
- }
7974
- for (let i = prs.length - 1; i >= 0; i--) {
7975
- if (prs[i] && toRemoveIds.has(prs[i].id)) prs.splice(i, 1);
7976
- }
7977
- target = best; // update the surviving record instead of inserting new
7978
- }
7979
- }
7980
8023
  if (!target && typeof beforeInsert === 'function' && beforeInsert(prs, normalizedEntry) === false) {
7981
8024
  skipped = true;
7982
8025
  return prs;
@@ -9361,6 +9404,7 @@ module.exports = {
9361
9404
  isPrCompatibleWithProject,
9362
9405
  isAdoPrScopeCompatible,
9363
9406
  getCanonicalPrId,
9407
+ getPrIdentityKey,
9364
9408
  findPrRecord,
9365
9409
  isPlaceholderPrTitle,
9366
9410
  snapshotPrRecord,
package/engine.js CHANGED
@@ -11811,8 +11811,11 @@ if (require.main === module) {
11811
11811
  // `llm.callLLM` calls (consolidation, classification, meetings, …) — inherits
11812
11812
  // this process's COPILOT_HOME, so point it at the seeded, MCP-filtered home.
11813
11813
  // Without this, those `direct:true` copilot calls load the operator's full
11814
- // `~/.copilot` stack and pop a Microsoft auth window per call. The dashboard
11815
- // sets the same at its own boot for CC / doc-chat / the CC worker pool.
11814
+ // `~/.copilot` stack and pop a Microsoft auth window per call. This isolation
11815
+ // is engine-process-only: the dashboard process deliberately does NOT set
11816
+ // COPILOT_HOME at its own boot, so Command Center / doc-chat / the CC worker
11817
+ // pool inherit the operator's real `~/.copilot` home instead (reverted in
11818
+ // W-mre75l6x00024f49; see dashboard.js boot for rationale).
11816
11819
  // See shared.ensureAgentCopilotHome. Fail-open.
11817
11820
  try { process.env.COPILOT_HOME = shared.ensureAgentCopilotHome(MINIONS_DIR, getConfig()?.engine); } catch {}
11818
11821
  const { handleCommand } = require('./engine/cli');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2364",
3
+ "version": "0.1.2366",
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"