@yemi33/minions 0.1.2355 → 0.1.2356

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.
@@ -342,17 +342,31 @@ async function openSettings() {
342
342
  '<option value="off"' + (autoStashRaw === 'off' ? ' selected' : '') + '>Off — fail on dirty tree</option>' +
343
343
  '</select>' +
344
344
  '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:2px;line-height:1.4">Live mode only. On = `git stash push --include-untracked` a dirty tree before dispatch instead of failing; the engine never pops the stash (run `git stash pop` manually). Overrides the fleet-wide setting under Worktrees.</div>' +
345
- '</div>';
345
+ '</div>';
346
+ // W-mrchrfe100067d3a — per-project explicit allowlist of secondary repo
347
+ // scopes this project's checkout is also allowed to track PRs for (e.g.
348
+ // minions-opg's checkout has both an `origin` remote → opg-microsoft/minions
349
+ // and a `yemi33` remote → yemi33/minions for backport work — see
350
+ // CLAUDE.md "Remotes"). Comma-separated canonical scope strings
351
+ // (`github:owner/repo` or `ado:org/project/repo`); parsed + validated
352
+ // server-side by shared.validateAdditionalRepoScopes on save.
353
+ var additionalScopesSearch = 'additional repo scopes multi-scope dual-remote backport yemi33';
354
+ var additionalScopesBlock = '<div data-search="' + escHtml(additionalScopesSearch) + '" style="margin-bottom:6px">' +
355
+ settingsField('Additional repo scopes (secondary remotes)', 'set-additionalRepoScopes-' + p.name,
356
+ (Array.isArray(p.additionalRepoScopes) ? p.additionalRepoScopes : []).join(', '), '',
357
+ 'Comma-separated canonical scopes this project\'s PRs may ALSO belong to (e.g. "github:yemi33/minions"). Explicit opt-in allowlist only — PRs matching one of these scopes are treated as compatible for auto-review/auto-fix without being flagged pr_scope_mismatch. Leave empty for single-scope projects.') +
358
+ '</div>';
346
359
  return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
347
- '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
348
- '<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
349
- '<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:var(--text-xs);padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
350
- '</div>' +
351
- pathRow +
352
- branchGrid +
353
- worktreeModeBlock +
354
- autoStashBlock +
355
- driftNote +
360
+ '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
361
+ '<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
362
+ '<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:var(--text-xs);padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
363
+ '</div>' +
364
+ pathRow +
365
+ branchGrid +
366
+ worktreeModeBlock +
367
+ autoStashBlock +
368
+ additionalScopesBlock +
369
+ driftNote +
356
370
  '<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
357
371
  settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
358
372
  settingsToggle('Discover from Work Items', 'set-ws-wi-' + p.name, p.workSources.workItems.enabled, 'Auto-discover work from ADO/GitHub work items') +
@@ -1257,12 +1271,18 @@ async function saveSettings() {
1257
1271
  const autoStashInput = document.getElementById('set-liveCheckoutAutoStash-' + p.name);
1258
1272
  const autoStashRaw = autoStashInput ? autoStashInput.value : '';
1259
1273
  const autoStashValue = autoStashRaw === 'on' ? true : autoStashRaw === 'off' ? false : null;
1274
+ // W-mrchrfe100067d3a — parse the comma-separated scope list back into an
1275
+ // array; empty string → null (clears the override server-side).
1276
+ const additionalScopesInput = document.getElementById('set-additionalRepoScopes-' + p.name);
1277
+ const additionalScopesRaw = additionalScopesInput ? additionalScopesInput.value : '';
1278
+ const additionalRepoScopes = additionalScopesRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
1260
1279
  return {
1261
1280
  name: p.name,
1262
1281
  mainBranch: mainBranchValue || null,
1263
1282
  checkoutMode: checkoutMode,
1264
1283
  liveValidation: liveValidation,
1265
1284
  liveCheckoutAutoStash: autoStashValue,
1285
+ additionalRepoScopes: additionalRepoScopes.length > 0 ? additionalRepoScopes : null,
1266
1286
  workSources: {
1267
1287
  pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
1268
1288
  workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
package/dashboard.js CHANGED
@@ -11184,6 +11184,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11184
11184
  // tri-state dropdown. Undefined means "use fleet default"
11185
11185
  // (engine.liveCheckoutAutoStash); only an explicit boolean overrides.
11186
11186
  liveCheckoutAutoStash: (typeof p.liveCheckoutAutoStash === 'boolean') ? p.liveCheckoutAutoStash : undefined,
11187
+ // W-mrchrfe100067d3a — surface the per-project secondary-repo
11188
+ // allowlist so the Settings → Projects UI can pre-fill / edit it.
11189
+ // Empty array when unset (never null) so the UI can render a
11190
+ // simple add/remove list without an extra null-check.
11191
+ additionalRepoScopes: shared.getProjectAdditionalPrScopes(p),
11187
11192
  workSources: {
11188
11193
  pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
11189
11194
  workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
@@ -11668,6 +11673,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11668
11673
  proj.liveCheckoutAutoStash = !!rawStash;
11669
11674
  }
11670
11675
  }
11676
+ // W-mrchrfe100067d3a — per-project explicit allowlist of secondary
11677
+ // repo scopes (e.g. minions-opg's checkout also legitimately opens
11678
+ // PRs against yemi33/minions for backport work — see CLAUDE.md
11679
+ // "Remotes"). Empty array / null / '' clears the override (falls
11680
+ // back to single-scope matching); any other value flows through
11681
+ // shared.validateAdditionalRepoScopes, which throws HTTP 400 on a
11682
+ // non-array or malformed scope string (errors propagate to the
11683
+ // outer catch below via e.statusCode/e.message).
11684
+ if (Object.prototype.hasOwnProperty.call(update, 'additionalRepoScopes')) {
11685
+ const rawScopes = update.additionalRepoScopes;
11686
+ if (rawScopes === '' || rawScopes === null || (Array.isArray(rawScopes) && rawScopes.length === 0)) {
11687
+ delete proj.additionalRepoScopes;
11688
+ } else {
11689
+ const validatedScopes = shared.validateAdditionalRepoScopes(rawScopes);
11690
+ if (validatedScopes === undefined) delete proj.additionalRepoScopes;
11691
+ else proj.additionalRepoScopes = validatedScopes;
11692
+ }
11693
+ }
11671
11694
  }
11672
11695
  }
11673
11696
 
@@ -14707,7 +14730,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14707
14730
 
14708
14731
  // Settings
14709
14732
  { method: 'GET', path: '/api/settings', desc: 'Return current engine + claude + routing config', handler: handleSettingsRead },
14710
- { method: 'POST', path: '/api/settings', desc: 'Update engine + claude + agent + projects config', params: 'engine?, claude?, agents?, projects? (per-project: checkoutMode, liveValidation, mainBranch, workSources)', handler: handleSettingsUpdate },
14733
+ { method: 'POST', path: '/api/settings', desc: 'Update engine + claude + agent + projects config', params: 'engine?, claude?, agents?, projects? (per-project: checkoutMode, liveValidation, mainBranch, workSources, additionalRepoScopes)', handler: handleSettingsUpdate },
14711
14734
  { method: 'POST', path: '/api/settings/routing', desc: 'Update routing.md', params: 'content', handler: handleSettingsRouting },
14712
14735
  { method: 'POST', path: '/api/settings/reset', desc: 'Reset engine + claude + agent settings to defaults', handler: handleSettingsReset },
14713
14736
 
@@ -0,0 +1,31 @@
1
+ // engine/db/migrations/016-pr-mirror-hash.js
2
+ //
3
+ // Fix for opg-microsoft/minions#759: pull-requests-store.js tracked its
4
+ // JSON-mirror "last known hash" per scope in an in-memory-only Map
5
+ // (`_lastMirrorHashByScope`). That state resets to empty on every engine
6
+ // restart, so the very first touch of a scope after a restart has no way to
7
+ // tell "SQL and JSON already agree" from "JSON just changed out from under
8
+ // us" — it falls back to a lastHash==null heuristic that only partially
9
+ // covers the case. Combined with a restart landing mid-flight of another
10
+ // process's mirror write, a stale JSON snapshot could get treated as
11
+ // canonical and used to rebuild (blindly DELETE + reinsert) the SQL table
12
+ // for that scope, permanently dropping any rows not present in the stale
13
+ // file.
14
+ //
15
+ // This table makes the mirror-hash durable across restarts: it is now the
16
+ // single, persisted cross-process source of truth for "what mirror state
17
+ // did the store last agree with", instead of a per-process cache that goes
18
+ // blank on every restart.
19
+ module.exports = {
20
+ version: 16,
21
+ description: 'pr_mirror_hashes — persist pull-requests.json mirror-hash across restarts (#759)',
22
+ up(db) {
23
+ db.exec(`
24
+ CREATE TABLE pr_mirror_hashes (
25
+ scope TEXT PRIMARY KEY,
26
+ hash TEXT NOT NULL,
27
+ updated_at INTEGER NOT NULL
28
+ );
29
+ `);
30
+ },
31
+ };
@@ -1122,6 +1122,13 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1122
1122
  const projScope = shared.getProjectPrScope(p);
1123
1123
  if (projScope && projScope === urlScope) return p;
1124
1124
  }
1125
+ // W-mrchrfe100067d3a — a PR scoped to one of a project's allowlisted
1126
+ // additionalRepoScopes (e.g. a backport PR opened against
1127
+ // yemi33/minions from the minions-opg checkout) belongs to that
1128
+ // project too, even though it isn't the project's primary scope.
1129
+ for (const p of projects) {
1130
+ if (shared.getProjectAdditionalPrScopes(p).includes(urlScope)) return p;
1131
+ }
1125
1132
  }
1126
1133
  const evidenceText = `${outputText}\n${evidenceUrl}`;
1127
1134
  for (const p of projects) {
@@ -6611,16 +6618,25 @@ function pruneScopeMismatchDuplicatePrs(config) {
6611
6618
  const projects = shared.getProjects(config) || [];
6612
6619
  if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
6613
6620
 
6614
- // Map project name -> canonical scope so we can find which project IS the
6615
- // correctly-scoped owner for a given mismatch record.
6621
+ // Map project name -> canonical scope(s) so we can find which project IS
6622
+ // the correctly-scoped owner for a given mismatch record. Includes
6623
+ // additionalRepoScopes (W-mrchrfe100067d3a) so a stale `_invalidProjectScope`
6624
+ // stamp left over from before a project opted into a secondary scope (or
6625
+ // any record from another host that hasn't been re-normalized yet) maps
6626
+ // back to the project that now allowlists it, instead of relocating to a
6627
+ // nonexistent project or getting stuck unresolved.
6616
6628
  const scopeByProject = new Map();
6617
6629
  const projectByScope = new Map();
6618
6630
  for (const p of projects) {
6619
6631
  if (!p || !p.name) continue;
6620
6632
  const sc = shared.getProjectPrScope(p);
6621
- if (!sc) continue;
6622
- scopeByProject.set(p.name, sc);
6623
- projectByScope.set(sc, p);
6633
+ if (sc) {
6634
+ scopeByProject.set(p.name, sc);
6635
+ if (!projectByScope.has(sc)) projectByScope.set(sc, p);
6636
+ }
6637
+ for (const additionalScope of shared.getProjectAdditionalPrScopes(p)) {
6638
+ if (!projectByScope.has(additionalScope)) projectByScope.set(additionalScope, p);
6639
+ }
6624
6640
  }
6625
6641
 
6626
6642
  // Index all PRs by canonical id across all projects (post-_scope decoration).
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
11
+ const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { shouldRunNow } = require('./scheduler');
@@ -537,8 +537,26 @@ function _canonicalPlanName(planFile) {
537
537
  return String(planFile || '').replace(/^(.*-\d{4}-\d{2}-\d{2})-\d+(\.md)$/i, '$1$2');
538
538
  }
539
539
 
540
+ // PRD top-level statuses that indicate a genuine, materialized plan-to-prd
541
+ // conversion (mirrors PLAN_STATUS — a PRD's status field is drawn from the
542
+ // same enum as a plan's). 'archived' is intentionally NOT in PLAN_STATUS (it's
543
+ // an in-place-archive-only status stamped by dashboard.js's _archivePrdPostProcess,
544
+ // not a plan lifecycle state) but is a real status a converted-then-archived PRD
545
+ // can carry — checked separately below via isPrdArchived so an already-archived
546
+ // conversion isn't mistaken for a stub and re-triggered.
547
+ const _VALID_EXISTING_PRD_STATUSES = new Set(Object.values(PLAN_STATUS));
548
+
540
549
  // Check if a PRD already exists for a given plan file (plan already converted).
541
550
  // Matches by canonical name so a collision-bumped plan still finds its PRD.
551
+ //
552
+ // A matching `source_plan` alone is not proof of a successful conversion — a
553
+ // crashed/interrupted plan-to-prd write, or a fragment-looking LLM response
554
+ // (the same garbage-content failure mode fixed for plan stages in PR #750),
555
+ // can leave a stub PRD JSON with `source_plan` set but no recognized `status`
556
+ // and no `missing_features`. Treating that stub as "already converted" would
557
+ // permanently skip re-running plan-to-prd for a plan that never actually got
558
+ // a usable PRD. Require both a recognized top-level status AND an array
559
+ // `missing_features` field before counting the PRD as an existing conversion.
542
560
  function _findExistingPrdForPlan(planFile, prdDir) {
543
561
  if (!fs.existsSync(prdDir)) return null;
544
562
  const prdFiles = safeReadDir(prdDir).filter(f => f.endsWith('.json'));
@@ -547,7 +565,12 @@ function _findExistingPrdForPlan(planFile, prdDir) {
547
565
  // safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
548
566
  // PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
549
567
  const prd = safeJsonNoRestore(path.join(prdDir, pf));
550
- if (prd?.source_plan && _canonicalPlanName(path.basename(String(prd.source_plan))) === planKey) return pf;
568
+ if (!prd?.source_plan || _canonicalPlanName(path.basename(String(prd.source_plan))) !== planKey) continue;
569
+ if (!(_VALID_EXISTING_PRD_STATUSES.has(prd.status) || isPrdArchived(prd)) || !Array.isArray(prd.missing_features)) {
570
+ log('warn', `Pipeline plan-to-prd: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — treating plan-to-prd as not yet done`);
571
+ continue;
572
+ }
573
+ return pf;
551
574
  }
552
575
  return null;
553
576
  }
@@ -64,7 +64,33 @@ function _readJsonArrayFallback(scope) {
64
64
 
65
65
  // Content-hash fingerprint per scope: same-length swaps (timestamps,
66
66
  // reviewStatus enums) collide on (mtime, size) but never on SHA-1.
67
- const _lastMirrorHashByScope = new Map();
67
+ //
68
+ // W-mrcg7j9k000ja233 (#759): this used to be an in-memory-only Map, which
69
+ // resets to empty on every process restart. Persist it in SQL instead
70
+ // (`pr_mirror_hashes`) so a restart can't blank out the "does JSON already
71
+ // agree with SQL" trust state — that reset, combined with a restart landing
72
+ // mid-flight of another process's mirror write, is exactly what let a stale
73
+ // JSON snapshot get treated as canonical and silently wipe SQL rows for a
74
+ // scope (see _resyncScopeIfJsonDiverged / _hydrateScopeFromJson below).
75
+ function _getLastMirrorHash(db, scope) {
76
+ try {
77
+ const row = db.prepare('SELECT hash FROM pr_mirror_hashes WHERE scope = ?').get(scope);
78
+ return row ? row.hash : null;
79
+ } catch { return null; }
80
+ }
81
+
82
+ function _setLastMirrorHash(db, scope, hash) {
83
+ try {
84
+ db.prepare(`
85
+ INSERT INTO pr_mirror_hashes (scope, hash, updated_at) VALUES (?, ?, ?)
86
+ ON CONFLICT(scope) DO UPDATE SET hash = excluded.hash, updated_at = excluded.updated_at
87
+ `).run(scope, hash, Date.now());
88
+ } catch { /* best-effort — a missed persisted-hash write just means the next resync re-checks */ }
89
+ }
90
+
91
+ function _forgetLastMirrorHash(db, scope) {
92
+ try { db.prepare('DELETE FROM pr_mirror_hashes WHERE scope = ?').run(scope); } catch { /* best-effort */ }
93
+ }
68
94
 
69
95
  const crypto = require('crypto');
70
96
 
@@ -78,7 +104,24 @@ function _fileContentHash(filePath) {
78
104
 
79
105
  function _hydrateScopeFromJson(db, scope) {
80
106
  const jsonItems = _readJsonArrayFallback(scope);
107
+ const beforeCount = db.prepare('SELECT COUNT(*) AS n FROM pull_requests WHERE scope = ?').get(scope).n;
108
+ const beforeIds = db.prepare('SELECT id FROM pull_requests WHERE scope = ?').all(scope).map(r => r.id);
81
109
  db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
110
+ // #759 — this DELETE used to be completely silent. Log every hydrate at
111
+ // warn level with before/after counts + the ids that are about to be
112
+ // dropped, so a JSON<->SQL resync that removes rows the operator didn't
113
+ // expect leaves a trace in engine-stdio.log instead of vanishing without
114
+ // explanation.
115
+ try {
116
+ const afterIds = new Set(jsonItems.filter(pr => pr && pr.id).map(pr => String(pr.id)));
117
+ const droppedIds = beforeIds.filter(id => !afterIds.has(id));
118
+ // eslint-disable-next-line no-console
119
+ console.warn(
120
+ `[pull-requests-store] _hydrateScopeFromJson(scope=${scope}): rebuilding SQL from JSON mirror — `
121
+ + `before=${beforeCount} row(s), after=${jsonItems.length} row(s)`
122
+ + (droppedIds.length ? `, dropping id(s): ${droppedIds.join(', ')}` : ''),
123
+ );
124
+ } catch { /* logging must never block the resync */ }
82
125
  if (jsonItems.length === 0) return;
83
126
  const now = Date.now();
84
127
  const ins = db.prepare(`
@@ -107,18 +150,18 @@ function _hydrateScopeFromJson(db, scope) {
107
150
  function _resyncScopeIfJsonDiverged(db, scope) {
108
151
  const jsonPath = _filePathForScope(scope);
109
152
  const currentHash = _fileContentHash(jsonPath);
110
- const lastHash = _lastMirrorHashByScope.get(scope);
153
+ const lastHash = _getLastMirrorHash(db, scope);
111
154
  if (currentHash == null) return;
112
155
  if (lastHash != null && currentHash === lastHash) return;
113
156
  if (lastHash == null) {
114
157
  const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
115
158
  if (sqlHas) {
116
- _lastMirrorHashByScope.set(scope, currentHash);
159
+ _setLastMirrorHash(db, scope, currentHash);
117
160
  return;
118
161
  }
119
162
  }
120
163
  _hydrateScopeFromJson(db, scope);
121
- _lastMirrorHashByScope.set(scope, currentHash);
164
+ _setLastMirrorHash(db, scope, currentHash);
122
165
  }
123
166
 
124
167
  function readPullRequestsForScope(scope) {
@@ -253,7 +296,7 @@ function _applyPullRequestsDiff(db, diff) {
253
296
  return true;
254
297
  }
255
298
 
256
- function applyPullRequestsMutation(scope, mutator) {
299
+ function applyPullRequestsMutation(scope, mutator, options = {}) {
257
300
  const { getDb, withTransaction } = require('./db');
258
301
  const db = getDb();
259
302
 
@@ -270,6 +313,23 @@ function applyPullRequestsMutation(scope, mutator) {
270
313
  : (Array.isArray(next) ? next : before);
271
314
  const diff = _computePullRequestsDiff(scope, beforeSnapshot, after);
272
315
  const wrote = _applyPullRequestsDiff(db, diff);
316
+ // W-mrcg7j9k000ja233 (#759) — mirror the JSON sidecar INSIDE this same
317
+ // SQL write transaction, BEFORE it commits, instead of after (as the
318
+ // caller in shared.js used to do post-return). This mirrors the fix
319
+ // already applied to work-items-store.js (W-mr9vcxvy000cdb4e): SQLite
320
+ // only allows one writer transaction at a time, so mirroring here
321
+ // guarantees the on-disk JSON file reflects this commit by the time it
322
+ // becomes visible to any other reader/writer. Without this ordering, a
323
+ // sibling process (dashboard vs. engine — separate Node processes, each
324
+ // with its own resync state) could observe the freshly committed SQL
325
+ // rows before the mirror file caught up, decide its own cached hash no
326
+ // longer matched the (still-stale) JSON file, and call
327
+ // _hydrateScopeFromJson — which DELETEs + reinserts the whole scope
328
+ // from that stale file, silently dropping the row this transaction just
329
+ // committed.
330
+ if (wrote) {
331
+ try { _mirrorJsonFromSql(scope, options.mirrorPath); } catch { /* best-effort — mirror failure must not block the SQL write */ }
332
+ }
273
333
  return { wrote, result: after };
274
334
  });
275
335
  }
@@ -293,7 +353,7 @@ function _mirrorJsonFromSql(scope, filePath) {
293
353
  const target = filePath || _filePathForScope(scope);
294
354
  shared.safeWrite(target, items);
295
355
  const h = _fileContentHash(target);
296
- if (h != null) _lastMirrorHashByScope.set(scope, h);
356
+ if (h != null) _setLastMirrorHash(db, scope, h);
297
357
  } catch {
298
358
  // Mirror failures are non-fatal — SQL has already committed.
299
359
  }
@@ -312,7 +372,8 @@ function _mirrorJsonFromSql(scope, filePath) {
312
372
  function dropScope(scope) {
313
373
  try {
314
374
  const { getDb } = require('./db');
315
- const result = getDb().prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
375
+ const db = getDb();
376
+ const result = db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
316
377
  // Mirror the work-items-store.js fix (W-mr96m3hm000fd5d7): don't blindly
317
378
  // forget the mirror-hash. _resyncScopeIfJsonDiverged treats "no recorded
318
379
  // hash" + "zero SQL rows" as a first-install hydrate and rebuilds SQL from
@@ -323,8 +384,8 @@ function dropScope(scope) {
323
384
  // actually gone, e.g. dataMode: 'purge'/'archive').
324
385
  const jsonPath = _filePathForScope(scope);
325
386
  const currentHash = _fileContentHash(jsonPath);
326
- if (currentHash == null) _lastMirrorHashByScope.delete(scope);
327
- else _lastMirrorHashByScope.set(scope, currentHash);
387
+ if (currentHash == null) _forgetLastMirrorHash(db, scope);
388
+ else _setLastMirrorHash(db, scope, currentHash);
328
389
  if (result && result.changes > 0) {
329
390
  try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
330
391
  }
package/engine/queries.js CHANGED
@@ -883,13 +883,16 @@ function _prFreshnessMs(pr) {
883
883
  function _pickPrDedupeWinner(group, projects) {
884
884
  if (group.length === 1) return { winner: group[0], dropped: [] };
885
885
 
886
- // Map each project name -> its canonical scope, so we can detect when a
887
- // record's _scope matches the PR url-derived scope (preference #2 below).
888
- const projectScopeByName = new Map();
886
+ // Map each project name -> its set of canonical scopes (primary +
887
+ // additionalRepoScopes, W-mrchrfe100067d3a), so we can detect when a
888
+ // record's _scope matches the PR url-derived scope (preference #2 below)
889
+ // even when the match is via an allowlisted secondary scope rather than
890
+ // the project's primary repo.
891
+ const projectScopesByName = new Map();
889
892
  for (const p of projects || []) {
890
893
  if (!p || !p.name) continue;
891
- const sc = shared.getProjectPrScope(p);
892
- if (sc) projectScopeByName.set(p.name, sc);
894
+ const scopes = shared.getProjectAllPrScopes(p);
895
+ if (scopes.length > 0) projectScopesByName.set(p.name, scopes);
893
896
  }
894
897
 
895
898
  // Resolve the PR's "correct" scope from its URL (independent of _scope).
@@ -907,8 +910,8 @@ function _pickPrDedupeWinner(group, projects) {
907
910
  // bit 0: freshness rank (broken into a separate tiebreak after)
908
911
  function scoreOf(pr) {
909
912
  const scopeOk = !pr._invalidProjectScope ? 1 : 0;
910
- const projectScope = projectScopeByName.get(pr._scope) || '';
911
- const matchesUrl = urlScope && projectScope && projectScope === urlScope ? 1 : 0;
913
+ const projectScopes = projectScopesByName.get(pr._scope) || [];
914
+ const matchesUrl = urlScope && projectScopes.includes(urlScope) ? 1 : 0;
912
915
  return (scopeOk << 1) | matchesUrl;
913
916
  }
914
917
 
package/engine/shared.js CHANGED
@@ -7227,6 +7227,74 @@ function getPrScopeInfo(prRef, url = '') {
7227
7227
  return canonical ? { ...canonical, source: 'id' } : null;
7228
7228
  }
7229
7229
 
7230
+ // W-mrchrfe100067d3a — validate + normalize `project.additionalRepoScopes`,
7231
+ // an explicit per-project opt-in allowlist of secondary canonical PR scopes
7232
+ // (e.g. `["github:yemi33/minions"]` for the minions-opg project's live
7233
+ // checkout, which legitimately carries a second git remote for backport
7234
+ // work — see CLAUDE.md "Remotes"). Deliberately NOT a wildcard: a scope must
7235
+ // be an exact `host:owner/repo` (or ADO `host:org/project/repo`) match to be
7236
+ // treated as compatible, so a typo'd or malicious PR URL can't slip into
7237
+ // auto-management just because *some* additional scope is configured.
7238
+ // Returns a deduped, normalized (lowercase, trimmed) array of scope strings;
7239
+ // malformed / empty entries are silently dropped (read-side leniency — write
7240
+ // paths should validate with validateAdditionalRepoScopes instead).
7241
+ function getProjectAdditionalPrScopes(project) {
7242
+ const raw = project?.additionalRepoScopes;
7243
+ if (!Array.isArray(raw) || raw.length === 0) return [];
7244
+ const out = [];
7245
+ const seen = new Set();
7246
+ for (const entry of raw) {
7247
+ const scope = _normalizeScopeString(entry);
7248
+ if (scope && !seen.has(scope)) {
7249
+ seen.add(scope);
7250
+ out.push(scope);
7251
+ }
7252
+ }
7253
+ return out;
7254
+ }
7255
+
7256
+ // Normalizes+validates a single scope string of the shape `github:owner/repo`
7257
+ // or `ado:org/project/repo`. Returns '' for anything malformed.
7258
+ function _normalizeScopeString(value) {
7259
+ const match = String(value || '').trim().match(/^(github|ado):(.+)$/i);
7260
+ if (!match) return '';
7261
+ const host = match[1].toLowerCase();
7262
+ const parts = match[2].split('/').map(normalizePrScopeSegment).filter(Boolean);
7263
+ const expectedParts = host === 'github' ? 2 : 3;
7264
+ if (parts.length !== expectedParts) return '';
7265
+ return `${host}:${parts.join('/')}`;
7266
+ }
7267
+
7268
+ // Validates a project-settings-update payload for `additionalRepoScopes`.
7269
+ // Mirrors the validateCheckoutMode/validateLiveValidation contract: returns
7270
+ // undefined when the caller should clear the field (empty/null/[] input);
7271
+ // otherwise returns the normalized array, or throws HTTP 400 on malformed
7272
+ // input (non-array, non-string entries, or entries that don't match the
7273
+ // canonical `host:owner/repo[/repo]` scope grammar).
7274
+ function validateAdditionalRepoScopes(value) {
7275
+ if (value === undefined || value === null) return undefined;
7276
+ if (!Array.isArray(value)) {
7277
+ throw _httpError(400, `Invalid additionalRepoScopes: must be an array of scope strings (got ${typeof value}).`);
7278
+ }
7279
+ if (value.length === 0) return undefined;
7280
+ const out = [];
7281
+ const seen = new Set();
7282
+ for (const entry of value) {
7283
+ if (typeof entry !== 'string' || !entry.trim()) {
7284
+ throw _httpError(400, `Invalid additionalRepoScopes entry: "${entry}" — must be a non-empty string.`);
7285
+ }
7286
+ const scope = _normalizeScopeString(entry);
7287
+ if (!scope) {
7288
+ throw _httpError(400, `Invalid additionalRepoScopes entry: "${entry}" — expected "github:owner/repo" or "ado:org/project/repo".`);
7289
+ }
7290
+ if (!seen.has(scope)) {
7291
+ seen.add(scope);
7292
+ out.push(scope);
7293
+ }
7294
+ }
7295
+ return out.length > 0 ? out : undefined;
7296
+ }
7297
+
7230
7298
  function getPrProjectScopeMismatch(project, prRef, url = '') {
7231
7299
  const projectScope = getProjectPrScope(project);
7232
7300
  if (!projectScope) return null;
@@ -7240,6 +7308,11 @@ function getPrProjectScopeMismatch(project, prRef, url = '') {
7240
7308
  const refParts = refRest.split('/');
7241
7309
  if (projectParts[0] === refParts[0] && projectParts[1] === refParts[1]) return null;
7242
7310
  }
7311
+ // W-mrchrfe100067d3a — explicit opt-in allowlist: a PR scoped to one of
7312
+ // this project's configured additionalRepoScopes is NOT a mismatch. This
7313
+ // is intentionally an exact-match allowlist (not "any scope compatible")
7314
+ // so an unrelated/typo'd repo URL still gets flagged.
7315
+ if (getProjectAdditionalPrScopes(project).includes(refScope)) return null;
7243
7316
  return { reason: 'pr_scope_mismatch', projectScope, prScope: refScope };
7244
7317
  }
7245
7318
 
@@ -7247,6 +7320,85 @@ function isPrCompatibleWithProject(project, prRef, url = '') {
7247
7320
  return !getPrProjectScopeMismatch(project, prRef, url);
7248
7321
  }
7249
7322
 
7323
+ // All canonical scopes this project is configured to own: its primary scope
7324
+ // plus any additionalRepoScopes. Used by cross-project PR dedupe scoring
7325
+ // (queries.js) so a record scoped to a secondary allowlisted repo isn't
7326
+ // penalized relative to records in projects whose PRIMARY scope matches.
7327
+ function getProjectAllPrScopes(project) {
7328
+ const primary = getProjectPrScope(project);
7329
+ const additional = getProjectAdditionalPrScopes(project);
7330
+ const out = [];
7331
+ if (primary) out.push(primary);
7332
+ for (const s of additional) if (!out.includes(s)) out.push(s);
7333
+ return out;
7334
+ }
7335
+
7336
+ // W-mrchrfe100067d3a — cache of `localPath -> Map(scope -> remoteName)`
7337
+ // derived from `git remote -v`, so repeated dependency-fetch resolution
7338
+ // within a tick (or across nearby ticks) doesn't re-shell-out per branch.
7339
+ const _gitRemoteScopeCache = new Map();
7340
+ const GIT_REMOTE_SCOPE_CACHE_TTL_MS = 5 * 60 * 1000;
7341
+
7342
+ // Best-effort parse of a git remote fetch URL into a canonical scope string.
7343
+ // Handles the URL shapes git itself produces for `git remote -v` output:
7344
+ // SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`),
7345
+ // and the two common ADO host forms (`dev.azure.com/org/project/_git/repo`,
7346
+ // `org.visualstudio.com/project/_git/repo`). Returns null when unrecognized.
7347
+ function _parseRemoteUrlToScope(url) {
7348
+ const cleaned = String(url || '').trim().replace(/\.git$/i, '');
7349
+ if (!cleaned) return null;
7350
+ let m = cleaned.match(/github\.com[:/]+([^/]+)\/([^/]+)\/?$/i);
7351
+ if (m) return `github:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}`;
7352
+ m = cleaned.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/?$/i);
7353
+ if (m) return `ado:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}/${normalizePrScopeSegment(m[3])}`;
7354
+ m = cleaned.match(/([^/@:]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/?$/i);
7355
+ if (m) return `ado:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}/${normalizePrScopeSegment(m[3])}`;
7356
+ return null;
7357
+ }
7358
+
7359
+ function _listGitRemoteScopes(localPath) {
7360
+ if (!localPath) return new Map();
7361
+ const now = Date.now();
7362
+ const cached = _gitRemoteScopeCache.get(localPath);
7363
+ if (cached && (now - cached.at) < GIT_REMOTE_SCOPE_CACHE_TTL_MS) return cached.remotes;
7364
+ const remotes = new Map();
7365
+ try {
7366
+ const out = shellSafeGitSync(['remote', '-v'], { cwd: localPath, timeout: 10000 });
7367
+ for (const line of String(out || '').split('\n')) {
7368
+ const m = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)/);
7369
+ if (!m) continue;
7370
+ const scope = _parseRemoteUrlToScope(m[2]);
7371
+ if (scope && !remotes.has(scope)) remotes.set(scope, m[1]);
7372
+ }
7373
+ } catch { /* best effort — caller falls back to 'origin' */ }
7374
+ _gitRemoteScopeCache.set(localPath, { at: now, remotes });
7375
+ return remotes;
7376
+ }
7377
+
7378
+ // W-mrchrfe100067d3a — resolve which git remote name owns a PR's branch when
7379
+ // that PR is scoped to one of the project's additionalRepoScopes entries
7380
+ // (e.g. a backport PR opened against `yemi33/minions` from the minions-opg
7381
+ // checkout, which has both `origin` -> opg-microsoft/minions and `yemi33` ->
7382
+ // yemi33/minions configured). For the project's own primary scope, or any
7383
+ // scope NOT in additionalRepoScopes, this always returns 'origin' —
7384
+ // preserving today's universal default for every project that doesn't opt
7385
+ // into multi-scope tracking. Only when the scope is allowlisted AND a local
7386
+ // remote's fetch URL actually resolves to that scope do we return the
7387
+ // non-origin remote name; otherwise 'origin' remains the safe fallback (the
7388
+ // caller's existing fetch-failure handling is unchanged).
7389
+ function resolveGitRemoteForPrScope(project, prScopeOrRef, url = '') {
7390
+ if (!project) return 'origin';
7391
+ const scope = (typeof prScopeOrRef === 'string' && /^(github|ado):/i.test(prScopeOrRef))
7392
+ ? prScopeOrRef
7393
+ : (getPrScopeInfo(prScopeOrRef, url)?.scope || '');
7394
+ if (!scope) return 'origin';
7395
+ const projectScope = getProjectPrScope(project);
7396
+ if (scope === projectScope) return 'origin';
7397
+ if (!getProjectAdditionalPrScopes(project).includes(scope)) return 'origin';
7398
+ const remotes = _listGitRemoteScopes(project.localPath);
7399
+ return remotes.get(scope) || 'origin';
7400
+ }
7401
+
7250
7402
  /**
7251
7403
  * Check if a parsed ADO PR scope is compatible with a project config,
7252
7404
  * considering that the repo segment in the URL might be either the friendly
@@ -8012,12 +8164,21 @@ function mutatePullRequests(filePath, mutator) {
8012
8164
  if (insideMinionsDir) {
8013
8165
  const store = require('./pull-requests-store');
8014
8166
  const scope = store.scopeForFilePath(filePath);
8167
+ // W-mrcg7j9k000ja233 (#759) — the JSON mirror is now written INSIDE
8168
+ // applyPullRequestsMutation's SQL transaction (before commit), not here
8169
+ // after the fact. Mirroring post-commit left a cross-process window
8170
+ // where a sibling process (dashboard vs. engine — separate Node
8171
+ // processes) could observe the new SQL row before the JSON file caught
8172
+ // up and destructively rehydrate the scope from that stale file,
8173
+ // dropping the just-written row. See
8174
+ // pull-requests-store.js#applyPullRequestsMutation for the full
8175
+ // rationale (mirrors the identical work-items-store.js fix,
8176
+ // W-mr9vcxvy000cdb4e).
8015
8177
  const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
8016
8178
  if (!Array.isArray(prs)) prs = [];
8017
8179
  return mutator(prs) || prs;
8018
- });
8180
+ }, { mirrorPath: filePath });
8019
8181
  if (wrote) {
8020
- try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
8021
8182
  try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
8022
8183
  }
8023
8184
  return result;
@@ -9098,6 +9259,10 @@ module.exports = {
9098
9259
  rewriteInboxRefsAcrossProjects,
9099
9260
  _artifactNoteFileToken, // exported so the one-time note-link backfill shares the token grammar
9100
9261
  getProjectPrScope,
9262
+ getProjectAdditionalPrScopes,
9263
+ getProjectAllPrScopes,
9264
+ validateAdditionalRepoScopes,
9265
+ resolveGitRemoteForPrScope,
9101
9266
  getPrNumber,
9102
9267
  getPrDisplayId,
9103
9268
  getPrScopeInfo,
package/engine.js CHANGED
@@ -710,6 +710,12 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
710
710
  projectName: p.name || null,
711
711
  projectRoot: depRoot,
712
712
  isCrossRepo: !!currentProjectName && p.name !== currentProjectName,
713
+ // W-mrchrfe100067d3a — same-project deps whose PR is scoped to one
714
+ // of `p`'s additionalRepoScopes entries (e.g. a backport PR opened
715
+ // against yemi33/minions from the minions-opg checkout) live on a
716
+ // different git remote than 'origin'. Resolved once here so the
717
+ // fetch/push loop below doesn't hardcode 'origin' for those.
718
+ remote: shared.resolveGitRemoteForPrScope(p, pr),
713
719
  });
714
720
  }
715
721
  }
@@ -3758,12 +3764,26 @@ async function spawnAgent(dispatchItem, config) {
3758
3764
  depMergeFailed = true;
3759
3765
  }
3760
3766
  const fetchResults = await Promise.allSettled(
3761
- fetchable.map(({ branch: depBranch }) =>
3767
+ fetchable.map(({ branch: depBranch, remote: depRemote }) => {
3762
3768
  // runAdoGit for ADO projects auto-retries once on auth failure
3763
3769
  // with a refreshed bearer token (handles mid-dispatch expiry).
3764
3770
  // For non-ADO projects it's a thin pass-through to shellSafeGit.
3765
- adoGitAuth.runAdoGit(project, ['fetch', 'origin', depBranch], { ..._gitOpts, cwd: rootDir }).then(() => depBranch)
3766
- )
3771
+ // W-mrchrfe100067d3a depRemote resolves to a non-'origin'
3772
+ // name only when this dep's PR is scoped to one of the
3773
+ // project's allowlisted additionalRepoScopes AND a local git
3774
+ // remote actually maps to it. In that case fetch with an
3775
+ // explicit refspec that writes into refs/remotes/origin/* —
3776
+ // an aliased local ref path — so every downstream merge-base /
3777
+ // merge-tree / merge step that reads `origin/<depBranch>`
3778
+ // (below, and in pruneAncestorDeps / preflightMergeSimulation)
3779
+ // keeps working unchanged, regardless of which real remote the
3780
+ // branch actually lives on.
3781
+ const remote = depRemote || 'origin';
3782
+ const fetchArgs = remote === 'origin'
3783
+ ? ['fetch', 'origin', depBranch]
3784
+ : ['fetch', remote, `+${depBranch}:refs/remotes/origin/${depBranch}`];
3785
+ return adoGitAuth.runAdoGit(project, fetchArgs, { ..._gitOpts, cwd: rootDir }).then(() => depBranch);
3786
+ })
3767
3787
  );
3768
3788
  const hasFetchFailures = fetchResults.some(r => r.status === 'rejected');
3769
3789
  const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
@@ -3773,6 +3793,7 @@ async function spawnAgent(dispatchItem, config) {
3773
3793
  if (fetchResults[i].status === 'rejected') {
3774
3794
  const failedBranch = fetchable[i].branch;
3775
3795
  const failedPrId = fetchable[i].prId;
3796
+ const failedRemote = fetchable[i].remote || 'origin';
3776
3797
  const errMsg = fetchResults[i].reason?.message || '';
3777
3798
  const pr = allPrsForFetch.find(p => p.id === failedPrId);
3778
3799
  if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
@@ -3783,10 +3804,15 @@ async function spawnAgent(dispatchItem, config) {
3783
3804
  if (errMsg.includes('couldn\'t find remote ref') || errMsg.includes('not found in upstream')) {
3784
3805
  try {
3785
3806
  await shared.shellSafeGit(['rev-parse', '--verify', `refs/heads/${failedBranch}`], { ..._gitOpts, cwd: rootDir });
3786
- // Branch exists locally — push it to origin
3787
- log('info', `Dependency ${failedBranch} exists locally but not on remote — pushing to origin`);
3788
- await shared.shellSafeGit(['push', 'origin', failedBranch], { ..._gitOpts, cwd: rootDir, timeout: 60000 });
3789
- log('info', `Successfully pushed local-only dependency branch ${failedBranch} to origin`);
3807
+ // Branch exists locally — push it to its resolved remote
3808
+ log('info', `Dependency ${failedBranch} exists locally but not on remote — pushing to ${failedRemote}`);
3809
+ await shared.shellSafeGit(['push', failedRemote, failedBranch], { ..._gitOpts, cwd: rootDir, timeout: 60000 });
3810
+ // Keep the refs/remotes/origin/* alias in sync with the
3811
+ // local branch so downstream `origin/<branch>` reads
3812
+ // (merge-base/merge-tree/merge) resolve correctly even
3813
+ // though the push landed on a non-origin remote.
3814
+ await shared.shellSafeGit(['update-ref', `refs/remotes/origin/${failedBranch}`, `refs/heads/${failedBranch}`], { ..._gitOpts, cwd: rootDir });
3815
+ log('info', `Successfully pushed local-only dependency branch ${failedBranch} to ${failedRemote}`);
3790
3816
  recoveredBranches.add(failedBranch);
3791
3817
  continue;
3792
3818
  } catch (localErr) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2355",
3
+ "version": "0.1.2356",
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"