@yemi33/minions 0.1.2176 → 0.1.2178

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.
@@ -1472,7 +1472,14 @@ async function findOpenPrForBranch(meta, config) {
1472
1472
  if (!meta?.branch) return null;
1473
1473
  const projectObj = resolvePrFallbackProject(meta, config);
1474
1474
  if (!projectObj) return null;
1475
- const host = projectObj.repoHost || 'ado';
1475
+ // BUG-H14 (P-h14-repohost): no silent 'ado' default — if the resolved
1476
+ // project doesn't declare repoHost, bail rather than mis-routing a GitHub
1477
+ // project's branch lookup through the ADO PR-search path.
1478
+ const host = projectObj.repoHost || null;
1479
+ if (!host) {
1480
+ log('debug', `Skipping branch PR lookup: project ${projectObj.name} has no repoHost configured`);
1481
+ return null;
1482
+ }
1476
1483
  if (host === 'github') {
1477
1484
  const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
1478
1485
  if (!ghSlug) return null;
@@ -1981,13 +1988,26 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
1981
1988
  // The poller will pick up the real status on the next cycle (~3 min).
1982
1989
  let postReviewStatus = null; // null = don't change
1983
1990
  let liveStatus = null;
1991
+ // BUG-H14 (P-h14-repohost): resolve repoHost from the PR's canonical id
1992
+ // FIRST. The previous code derived hostForChecks from the fallback project
1993
+ // (`reviewProject || getProjects(config)[0]`) and silently defaulted to
1994
+ // 'ado' on miss — so a github:* PR found in the central pull-requests.json
1995
+ // (reviewProject=null) with an ADO project as projects[0] was mis-routed to
1996
+ // ado.checkLiveReviewStatus. The fallback project is still consulted when
1997
+ // the id is unparseable, but there is no silent 'ado' default — an
1998
+ // unresolved host is observable (no live check fires).
1984
1999
  const projectObjForChecks = reviewProject || shared.getProjects(config)[0];
1985
- const hostForChecks = projectObjForChecks?.repoHost || 'ado';
1986
- const checkFn = hostForChecks === 'github'
1987
- ? require('./github').checkLiveReviewStatus
1988
- : require('./ado').checkLiveReviewStatus;
2000
+ const parsedCanonicalReviewId = shared.parseCanonicalPrId(reviewPr.id);
2001
+ const hostFromCanonical = parsedCanonicalReviewId
2002
+ ? parsedCanonicalReviewId.scope.split(':')[0]
2003
+ : null;
2004
+ const hostForChecks = hostFromCanonical || projectObjForChecks?.repoHost || null;
2005
+ let checkFn = null;
2006
+ if (hostForChecks === 'github') checkFn = require('./github').checkLiveReviewStatus;
2007
+ else if (hostForChecks === 'ado') checkFn = require('./ado').checkLiveReviewStatus;
2008
+ else log('warn', `Cannot resolve repoHost for review of ${reviewPr.id} (unparseable canonical id and no project config); skipping live review check.`);
1989
2009
  try {
1990
- if (projectObjForChecks) {
2010
+ if (projectObjForChecks && checkFn) {
1991
2011
  liveStatus = await checkFn(reviewPr, projectObjForChecks);
1992
2012
  }
1993
2013
  } catch (e) { log('warn', `Post-review status check for ${reviewPr.id}: ${e.message}`); }
@@ -2025,7 +2045,9 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2025
2045
  try {
2026
2046
  const reconcileFn = hostForChecks === 'github'
2027
2047
  ? require('./github').dismissPriorViewerChangesRequestedReviews
2028
- : require('./ado').resetReviewerNegativeVote;
2048
+ : hostForChecks === 'ado'
2049
+ ? require('./ado').resetReviewerNegativeVote
2050
+ : null;
2029
2051
  if (typeof reconcileFn === 'function') {
2030
2052
  const result = await reconcileFn(reviewPr, projectObjForChecks);
2031
2053
  const cleared = !!(result && (result.changed || result.dismissed > 0));
@@ -3395,85 +3417,6 @@ function parseAgentOutput(stdout, runtimeName) {
3395
3417
  return { resultSummary: text, taskUsage: usage, sessionId, model };
3396
3418
  }
3397
3419
 
3398
- /**
3399
- * Parse structured completion block from agent output.
3400
- * Agents produce a ```completion fenced block with key: value pairs.
3401
- * Returns parsed object or null if not found / malformed.
3402
- * If multiple blocks exist, the last one wins (agent may retry).
3403
- *
3404
- * DEPRECATED — slated for removal once telemetry shows zero hits over a 14-day
3405
- * window. Telemetry: see _engine.completionFallbacks in metrics.json (the JSON
3406
- * sidecar at MINIONS_COMPLETION_REPORT is the documented contract; this fenced
3407
- * fallback exists only to support runtimes/agents that have not yet adopted it).
3408
- * Removal is tracked by the P-c8f5e1b3 follow-up plan.
3409
- */
3410
- function parseStructuredCompletion(stdout, runtimeName) {
3411
- if (!stdout || typeof stdout !== 'string') return null;
3412
-
3413
- // Extract text from stream-json output if needed
3414
- const text = extractCompletionText(stdout, runtimeName);
3415
-
3416
- // Find all ```completion blocks, take the last one
3417
- const blockPattern = /```completion\s*\n([\s\S]*?)```/g;
3418
- let lastMatch = null;
3419
- let m;
3420
- while ((m = blockPattern.exec(text)) !== null) {
3421
- lastMatch = m[1];
3422
- }
3423
- if (!lastMatch) {
3424
- const taskCompleteSummary = extractTaskCompleteSummary(stdout);
3425
- return taskCompleteSummary ? parseCompletionKeyValues(taskCompleteSummary) : null;
3426
- }
3427
-
3428
- return parseCompletionKeyValues(lastMatch);
3429
- }
3430
-
3431
- function extractCompletionText(stdout, runtimeName) {
3432
- let text = stdout;
3433
- if (typeof stdout === 'string' && stdout.includes('"type":')) {
3434
- try {
3435
- const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
3436
- if (parsed.text) text = parsed.text;
3437
- } catch {}
3438
- }
3439
- return text;
3440
- }
3441
-
3442
- function hasCompletionFence(stdout, runtimeName) {
3443
- const text = extractCompletionText(stdout, runtimeName);
3444
- return /```completion\s*\n[\s\S]*?```/.test(text);
3445
- }
3446
-
3447
- function extractTaskCompleteSummary(stdout) {
3448
- if (!stdout || typeof stdout !== 'string') return '';
3449
- let summary = '';
3450
- for (const rawLine of stdout.split('\n')) {
3451
- const line = rawLine.trim();
3452
- if (!line || !line.startsWith('{')) continue;
3453
- let obj;
3454
- try { obj = JSON.parse(line); } catch { continue; }
3455
- if (!obj || typeof obj !== 'object') continue;
3456
- if (obj.type === 'session.task_complete') {
3457
- const value = obj.data?.summary;
3458
- if (typeof value === 'string' && value.trim()) summary = value;
3459
- continue;
3460
- }
3461
- if (obj.type === 'tool.execution_start' && obj.data?.toolName === 'task_complete') {
3462
- const value = obj.data?.arguments?.summary;
3463
- if (typeof value === 'string' && value.trim()) summary = value;
3464
- continue;
3465
- }
3466
- if (obj.type === 'assistant.message' && Array.isArray(obj.data?.toolRequests)) {
3467
- for (const tr of obj.data.toolRequests) {
3468
- if (tr?.name !== 'task_complete') continue;
3469
- const value = tr.arguments?.summary || tr.intentionSummary;
3470
- if (typeof value === 'string' && value.trim()) summary = value;
3471
- }
3472
- }
3473
- }
3474
- return summary;
3475
- }
3476
-
3477
3420
  function hasActionableFailureClass(value) {
3478
3421
  const normalized = String(value || '').trim().toLowerCase();
3479
3422
  if (!normalized) return false;
@@ -3577,57 +3520,6 @@ function handleInjectionFlag(dispatchItem, agentId, structuredCompletion, config
3577
3520
  return { description, sources, at };
3578
3521
  }
3579
3522
 
3580
- function parseCompletionKeyValues(text) {
3581
- if (!text || typeof text !== 'string') return null;
3582
- const result = {};
3583
- const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
3584
- const lines = text.trim().split('\n');
3585
- for (const line of lines) {
3586
- const normalizedLine = line.trim().replace(/^[-*]\s+/, '');
3587
- const colonIdx = normalizedLine.indexOf(':');
3588
- if (colonIdx < 1) continue;
3589
- const key = normalizedLine.slice(0, colonIdx).trim().toLowerCase();
3590
- if (allowedFields.size > 0 && !allowedFields.has(key)) continue;
3591
- const value = normalizedLine.slice(colonIdx + 1).trim();
3592
- if (key && value) result[key] = value;
3593
- }
3594
-
3595
- // Must have at least status, or an actionable failure_class that implies failure.
3596
- if (!result.status && hasActionableFailureClass(result.failure_class)) result.status = 'failed';
3597
- if (!result.status) return null;
3598
- return result;
3599
- }
3600
-
3601
- /**
3602
- * DEPRECATED — slated for removal once telemetry shows zero hits over a 14-day
3603
- * window. Telemetry: see _engine.completionFallbacks in metrics.json (the JSON
3604
- * sidecar at MINIONS_COMPLETION_REPORT is the documented contract; this prose
3605
- * fallback only fires when both the sidecar AND the fenced ```completion block
3606
- * are missing). Removal is tracked by the P-c8f5e1b3 follow-up plan.
3607
- */
3608
- function parseCompletionFieldSummary(text) {
3609
- if (!text || typeof text !== 'string') return null;
3610
-
3611
- const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
3612
- const result = {};
3613
- for (const rawLine of text.split(/\r?\n/)) {
3614
- const line = rawLine.trim().replace(/^[-*]\s+/, '');
3615
- const colonIdx = line.indexOf(':');
3616
- if (colonIdx < 1) continue;
3617
- const key = line.slice(0, colonIdx).trim().toLowerCase().replace(/[\s-]+/g, '_');
3618
- if (!allowedFields.has(key)) continue;
3619
- const value = line.slice(colonIdx + 1).trim().replace(/^["'`]+|["'`]+$/g, '');
3620
- if (value) result[key] = value;
3621
- }
3622
-
3623
- if (!result.status) return null;
3624
- const fieldCount = Object.keys(result).length;
3625
- const status = normalizeCompletionStatus(result.status);
3626
- const explicitlyFailed = status.startsWith('fail') || status === 'error';
3627
- if (fieldCount < 2 && !explicitlyFailed) return null;
3628
- return result;
3629
- }
3630
-
3631
3523
  function parseCompletionReportFile(dispatchItem, opts = {}) {
3632
3524
  const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
3633
3525
  if (!reportPath || !fs.existsSync(reportPath)) {
@@ -3820,29 +3712,6 @@ function promoteCompletionArtifacts(meta, agentId, dispatchId, structuredComplet
3820
3712
  return { artifacts, notes, agentId, dispatchId };
3821
3713
  }
3822
3714
 
3823
- function persistCompletionReport(dispatchItem, completion, source = 'fallback') {
3824
- if (!dispatchItem?.id || !completion || typeof completion !== 'object') return completion;
3825
- const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem.id);
3826
- if (!reportPath) return completion;
3827
- const report = {
3828
- ...completion,
3829
- status: completion.status || completion.outcome || 'unknown',
3830
- _source: source,
3831
- _path: reportPath,
3832
- dispatchId: dispatchItem.id,
3833
- agent: dispatchItem.agent || null,
3834
- type: dispatchItem.type || null,
3835
- completedAt: ts(),
3836
- };
3837
- try {
3838
- safeWrite(reportPath, report);
3839
- log('info', `Persisted ${source} completion report for ${dispatchItem.id}: ${reportPath}`);
3840
- } catch (err) {
3841
- log('warn', `Persist fallback completion report for ${dispatchItem.id}: ${err.message}`);
3842
- }
3843
- return report;
3844
- }
3845
-
3846
3715
  function normalizeCompletionStatus(status) {
3847
3716
  return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
3848
3717
  }
@@ -4487,6 +4356,12 @@ function handleHarnessIterationResult(stdout, structuredCompletion, meta, config
4487
4356
  * - Returns silently when the PR is no longer active, or when its current
4488
4357
  * reviewStatus is not in {changes-requested, waiting}. An already-approved
4489
4358
  * PR doesn't need a re-review.
4359
+ * - P-e8b1c4d2: Returns silently when engine.autoReReviewPrs === false. This
4360
+ * mirrors the discovery-driven gate in engine.js:discoverFromPrs so the
4361
+ * toggle is a single source of truth across BOTH re-review paths
4362
+ * (open-loop / closure-loop). Without this gate the dashboard switch only
4363
+ * muted the discovery path, leaving the closure-loop free to keep
4364
+ * dispatching re-reviews — defeating the operator's intent.
4490
4365
  * - Idempotent: getPrDispatchDedupeKey + addToDispatch dedup already skips a
4491
4366
  * second review dispatch for the same (PR, type). No second WI ever lands.
4492
4367
  * - Soft agent preference: agents whose charter advertises code-review or
@@ -4504,6 +4379,14 @@ function dispatchReReviewForFix(fixDispatchItem, meta, config) {
4504
4379
  if (!addressesReviewWi) return null;
4505
4380
  const pr = meta?.pr;
4506
4381
  if (!pr?.id) return null;
4382
+ // P-e8b1c4d2 — honor the autoReReviewPrs kill-switch in the closure-loop too.
4383
+ // The open-loop discovery path in engine.js gates on the same flag; without
4384
+ // this guard the closure-loop silently bypassed the operator's toggle.
4385
+ const autoReReviewPrs = config?.engine?.autoReReviewPrs ?? shared.ENGINE_DEFAULTS.autoReReviewPrs;
4386
+ if (autoReReviewPrs === false) {
4387
+ log('info', `Re-review skipped for ${pr.id}: engine.autoReReviewPrs is off (closure-loop kill-switch)`);
4388
+ return null;
4389
+ }
4507
4390
  // Re-read live PR state so we don't queue a re-review against an already
4508
4391
  // merged/abandoned PR. The post-completion fix-update may have just flipped
4509
4392
  // reviewStatus to 'waiting'; consult that fresh state via the central read.
@@ -4672,33 +4555,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
4672
4555
  reportCompletion = null;
4673
4556
  }
4674
4557
 
4675
- const fencedCompletion = (nonceMismatch || reportCompletion) ? null : parseStructuredCompletion(stdout, runtimeName);
4676
- const summaryCompletion = (nonceMismatch || reportCompletion || fencedCompletion) ? null : parseCompletionFieldSummary(resultSummary);
4677
- const fallbackCompletion = fencedCompletion || summaryCompletion;
4678
- const fallbackSource = fencedCompletion && hasCompletionFence(stdout, runtimeName) ? 'fenced-completion' : 'summary-completion';
4679
- // P-c8f5e1b3 — telemetry: completion-fallback. Emit a grep-able log + bump a
4680
- // counter in metrics.json whenever the fenced or summary fallback fires (i.e.
4681
- // the documented sidecar contract was missing). Used to confirm zero adoption
4682
- // gaps before removing parseStructuredCompletion / parseCompletionFieldSummary
4683
- // in a follow-up. Sidecar (happy) path is silent — no log, no increment.
4684
- if (!reportCompletion && fallbackCompletion) {
4685
- const counterKey = fallbackSource === 'fenced-completion' ? 'fenced' : 'summary';
4686
- const wiId = dispatchItem.meta?.item?.id || null;
4687
- log('info', `telemetry: completion-fallback agent=${agentId || 'unknown'} wi=${wiId || 'N/A'} runtime=${runtimeName} source=${fallbackSource}`);
4688
- try {
4689
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
4690
- mutateJsonFileLocked(metricsPath, (metrics) => {
4691
- metrics = metrics || {};
4692
- if (!metrics._engine) metrics._engine = {};
4693
- if (!metrics._engine.completionFallbacks) metrics._engine.completionFallbacks = { fenced: 0, summary: 0 };
4694
- metrics._engine.completionFallbacks[counterKey] = (metrics._engine.completionFallbacks[counterKey] || 0) + 1;
4695
- return metrics;
4696
- });
4697
- } catch (err) {
4698
- log('warn', `telemetry: completion-fallback metrics write failed: ${err.message}`);
4699
- }
4700
- }
4701
- const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, fallbackSource);
4558
+ const structuredCompletion = reportCompletion;
4702
4559
  if (structuredCompletion) {
4703
4560
  if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
4704
4561
  log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
@@ -5638,8 +5495,6 @@ module.exports = {
5638
5495
  parseAgentOutput,
5639
5496
  parseReviewVerdict,
5640
5497
  isReviewBailout,
5641
- parseStructuredCompletion,
5642
- parseCompletionFieldSummary,
5643
5498
  parseCompletionNoop,
5644
5499
  detectNonTerminalResultSummary,
5645
5500
  deferNonTerminalCompletion,
@@ -5651,7 +5506,6 @@ module.exports = {
5651
5506
  completionArtifactToNoteEntry,
5652
5507
  mergeArtifactNotes,
5653
5508
  promoteCompletionArtifacts,
5654
- persistCompletionReport,
5655
5509
  runPostCompletionHooks,
5656
5510
  syncPrdFromPrs,
5657
5511
  resolveWorkItemPath,
package/engine/meeting.js CHANGED
@@ -916,6 +916,36 @@ function checkMeetingTimeouts(config) {
916
916
  const elapsed = Date.now() - roundStartedMs;
917
917
  if (elapsed < timeout) continue;
918
918
 
919
+ // BUG-H11: when a hard timeout will fire, kill in-flight meeting
920
+ // dispatches BEFORE entering the meeting lock so they stop consuming
921
+ // worker slots / LLM tokens immediately rather than draining over the
922
+ // next tens of minutes. _killMeetingDispatches takes the dispatch.json
923
+ // lock and shells out to kill processes — per CLAUDE.md and the
924
+ // advanceMeetingRound/endMeeting/deleteMeeting pattern at L834, that
925
+ // MUST run outside the meeting lock. We pre-evaluate eligibility from
926
+ // the snapshot using the same predicate the mutate callback below
927
+ // re-checks under lock; if the timeout has already advanced/cleared,
928
+ // the kill is a no-op on dispatch.json (no matching meetingId entries).
929
+ const snapshotRoundName = snapshot.status === 'investigating'
930
+ ? 'investigate'
931
+ : snapshot.status === 'debating'
932
+ ? 'debate'
933
+ : snapshot.status === 'concluding'
934
+ ? 'conclude'
935
+ : null;
936
+ const snapshotHardTimeoutWillFire = elapsed >= hardTimeout && (
937
+ // Round-timeout branch: non-conclude round + not all participants done
938
+ (
939
+ (snapshotRoundName === 'investigate' || snapshotRoundName === 'debate')
940
+ && !allParticipantsFinishedRound(snapshot, snapshotRoundName, snapshot.round)
941
+ )
942
+ // Conclusion-timeout branch: concluding status
943
+ || snapshot.status === 'concluding'
944
+ );
945
+ if (snapshotHardTimeoutWillFire) {
946
+ _killMeetingDispatches(snapshot.id);
947
+ }
948
+
919
949
  // Re-evaluate the timeout transition under the file lock to avoid lost
920
950
  // updates if an agent finalised mid-tick. Helpers (advanceMeetingIfRoundComplete
921
951
  // etc.) operate on the locked-and-rehydrated meeting object.
@@ -298,6 +298,11 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
298
298
  'existing_prd_json', // only set when re-running plan-to-prd over an existing PRD
299
299
  'branch_strategy_hint', // only set for shared-branch plans
300
300
  'review_note', // only set on fix/review tasks tied to a comment
301
+ // P-4d6e2af3 — comma-separated list of target projects for cross-repo
302
+ // plans. Only set in engine.js's WORK_TYPE.PLAN branch when the WI carries
303
+ // item._targetProjects (≥2 entries from dashboard.js#buildPlanWorkItem).
304
+ // Single-project plans legitimately resolve to ''.
305
+ 'target_projects',
301
306
  // PR-context vars on non-PR tasks (implement/explore/etc.)
302
307
  'pr_id', 'pr_number', 'pr_title', 'pr_branch', 'pr_author', 'pr_url',
303
308
  'reviewer',
@@ -868,6 +873,16 @@ function renderPlaybook(type, vars) {
868
873
  return (val && String(val).trim()) ? block : '';
869
874
  });
870
875
 
876
+ // P-c1f87a92 — negative conditional blocks: {{^key}}...{{/key}} — include
877
+ // block only if key is FALSY / absent / whitespace-only. Mirror the
878
+ // positive-block trim() check above so single-value semantics line up
879
+ // (vars.target_projects = '' or undefined renders the {{^}} branch, the
880
+ // same way it omits the {{#}} branch).
881
+ content = content.replace(/\{\{\^(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_, key, block) => {
882
+ const val = allVars[key];
883
+ return (val && String(val).trim()) ? '' : block;
884
+ });
885
+
871
886
  // Substitute variables
872
887
  for (const [key, val] of Object.entries(allVars)) {
873
888
  content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
package/engine/queries.js CHANGED
@@ -1521,6 +1521,21 @@ function _getPrdInputHash(projects) {
1521
1521
  }
1522
1522
  // Static pr-links.json overrides (affect shared.getPrLinks(); missing project mtimes otherwise)
1523
1523
  try { mtimes.push(fs.statSync(path.join(MINIONS_DIR, 'engine', 'pr-links.json')).mtimeMs); } catch { mtimes.push(0); }
1524
+ // W-mqacrzis0003df4a — Source-plan markdown mtimes. Without this, the
1525
+ // per-PRD planStale fresh-computation at lines ~1568-1576 is never reached
1526
+ // after the user revises plans/<x>.md until the engine tick (~10s) flips
1527
+ // the persisted plan.planStale flag. The cached `_prdResultCache` hits the
1528
+ // early-return and serves the prior (stale) `planStale: false` to readers
1529
+ // long enough for a fast user to Approve and silently bypass diff-aware
1530
+ // regen. Push the PLANS_DIR directory mtime first (catches add/delete on
1531
+ // POSIX) then every .md file's mtime in sorted order for determinism.
1532
+ try { mtimes.push(fs.statSync(PLANS_DIR).mtimeMs); } catch { mtimes.push(0); }
1533
+ try {
1534
+ const planFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md')).sort();
1535
+ for (const f of planFiles) {
1536
+ try { mtimes.push(fs.statSync(path.join(PLANS_DIR, f)).mtimeMs); } catch { mtimes.push(0); }
1537
+ }
1538
+ } catch { /* plans/ may not exist in some test/init states */ }
1524
1539
  return { hash: mtimes.join(','), prdDirMtime, archiveDirMtime };
1525
1540
  }
1526
1541
 
@@ -1770,7 +1785,17 @@ function getPrdInfo(config) {
1770
1785
  items: items.map(i => ({
1771
1786
  id: i.id, name: i.name || i.title, priority: i.priority,
1772
1787
  complexity: i.estimated_complexity || i.size, status: i.status || 'missing',
1773
- description: i.description || '', projects: i.projects || [],
1788
+ description: i.description || '',
1789
+ // P-e8d49105 — projects[] feeds the per-item badge in
1790
+ // dashboard/js/render-prd.js:250-252 + the plan-group rollup at :243.
1791
+ // Honor a pre-populated projects[] (forward-compat for any future
1792
+ // multi-repo single item); otherwise derive a single-element array
1793
+ // from i.project so cross-repo PRDs (every item.project may differ)
1794
+ // light up the dormant badge code without changing single-project
1795
+ // behavior — i.project keeps being emitted unchanged below.
1796
+ projects: (Array.isArray(i.projects) && i.projects.length > 0)
1797
+ ? i.projects
1798
+ : (i.project ? [i.project] : []),
1774
1799
  prs: prdToPr[i.id] || [], depends_on: i.depends_on || [],
1775
1800
  project: i.project || '', source: i._source || '', planSummary: i._planSummary || '', planProject: i._planProject || '', planStatus: i._planStatus || 'active', _archived: i._archived || false, sourcePlan: i._sourcePlan || '',
1776
1801
  branchStrategy: i._branchStrategy || 'parallel',
@@ -444,6 +444,7 @@ function buildArgs(opts = {}) {
444
444
  disableBuiltinMcps,
445
445
  suppressAgentsMd,
446
446
  reasoningSummaries,
447
+ disabledMcpServers,
447
448
  } = opts;
448
449
 
449
450
  const args = [
@@ -478,6 +479,16 @@ function buildArgs(opts = {}) {
478
479
  if (suppressAgentsMd === true) args.push('--no-custom-instructions');
479
480
  if (reasoningSummaries === true) args.push('--enable-reasoning-summaries');
480
481
 
482
+ // P-mcp-storm — Disable named user-scope MCP servers per spawn. Copilot loads
483
+ // ~/.copilot/mcp-config.json unconditionally (not gated by --add-dir/cwd), so
484
+ // this --disable-mcp-server <name> (repeatable) is the only lever to keep an
485
+ // operator's interactive MCP servers out of autonomous agent dispatches.
486
+ if (Array.isArray(disabledMcpServers)) {
487
+ for (const name of disabledMcpServers) {
488
+ if (name) args.push('--disable-mcp-server', String(name));
489
+ }
490
+ }
491
+
481
492
  // --stream takes a value: 'on' or 'off'. Caller passes that exact value.
482
493
  if (stream === 'on' || stream === 'off') {
483
494
  args.push('--stream', stream);
@@ -504,6 +515,14 @@ function buildSpawnFlags(opts = {}) {
504
515
  if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
505
516
  if (opts.suppressAgentsMd === true) flags.push('--no-custom-instructions');
506
517
  if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
518
+ // P-mcp-storm — forward each disabled user MCP server so spawn-agent.js can
519
+ // re-emit it on the copilot CLI (parseSpawnArgs accumulates these into
520
+ // opts.disabledMcpServers, which buildArgs turns back into --disable-mcp-server).
521
+ if (Array.isArray(opts.disabledMcpServers)) {
522
+ for (const name of opts.disabledMcpServers) {
523
+ if (name) flags.push('--disable-mcp-server', String(name));
524
+ }
525
+ }
507
526
  return flags;
508
527
  }
509
528