@yemi33/minions 0.1.2177 → 0.1.2179

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.
Files changed (44) hide show
  1. package/bin/minions.js +24 -11
  2. package/dashboard/js/command-parser.js +1 -1
  3. package/dashboard/js/memory-panel.js +262 -0
  4. package/dashboard/js/qa.js +2 -2
  5. package/dashboard/js/refresh.js +9 -1
  6. package/dashboard/js/render-dispatch.js +92 -0
  7. package/dashboard/js/render-other.js +1 -1
  8. package/dashboard/js/render-plans.js +82 -13
  9. package/dashboard/js/render-prs.js +2 -1
  10. package/dashboard/js/render-schedules.js +1 -1
  11. package/dashboard/js/render-watches.js +1 -1
  12. package/dashboard/js/settings.js +100 -11
  13. package/dashboard/layout.html +6 -0
  14. package/dashboard/pages/engine-memory-panel.html +49 -0
  15. package/dashboard/pages/engine.html +1 -0
  16. package/dashboard/slim/js/link-pr.js +5 -5
  17. package/dashboard/slim/js/modals-tiles.js +44 -3
  18. package/dashboard/slim/js/projects.js +8 -6
  19. package/dashboard/slim/styles.css +20 -0
  20. package/dashboard/styles.css +39 -0
  21. package/dashboard-build.js +17 -2
  22. package/dashboard.js +469 -21
  23. package/docs/README.md +8 -1
  24. package/docs/auto-discovery.md +40 -0
  25. package/docs/branch-derivation.md +13 -1
  26. package/docs/cross-repo-plans.md +292 -0
  27. package/docs/deprecated.json +4 -4
  28. package/docs/pr-auto-fix-dispatch.md +64 -0
  29. package/docs/pr-review-fix-loop.md +1 -1
  30. package/docs/watches.md +1 -0
  31. package/engine/ado.js +1 -10
  32. package/engine/diagnostics-memory.js +190 -0
  33. package/engine/dispatch.js +53 -0
  34. package/engine/lifecycle.js +155 -191
  35. package/engine/meeting.js +30 -0
  36. package/engine/playbook.js +15 -0
  37. package/engine/queries.js +165 -5
  38. package/engine/runtimes/copilot.js +19 -0
  39. package/engine/shared.js +303 -3
  40. package/engine/watchdog.js +6 -0
  41. package/engine.js +576 -113
  42. package/package.json +2 -2
  43. package/playbooks/plan-to-prd.md +25 -2
  44. package/playbooks/plan.md +4 -2
package/engine/shared.js CHANGED
@@ -484,18 +484,99 @@ function resolveEngineCacheDir(fallbackEngineDir) {
484
484
  // Cross-platform URL opener. Uses execSync so failures fall through the
485
485
  // try/catch and the caller sees them. Dashboard self-open and `minions dash`
486
486
  // / `minions restart` post-health open all funnel through here.
487
- function openUrlInBrowser(url) {
487
+ //
488
+ // Every call writes a structured `event: 'browser-open'` entry to log.json
489
+ // (via shared.log) and bumps a per-reason counter in `_engine.browserOpens`
490
+ // in metrics.json. Callers MUST pass `opts.reason` so the log/metric is
491
+ // attributable; a missing reason is logged at `warn` level with
492
+ // `reason: 'unknown'` to make a regression loud rather than silent.
493
+ //
494
+ // `MINIONS_NO_AUTO_OPEN=1` short-circuits the open and writes a debug-level
495
+ // SUPPRESSED entry instead — this lets us prove the kill-switch is firing
496
+ // in production (absence of opens otherwise looks identical whether the
497
+ // guard worked, nobody tried to open, or the guard regressed).
498
+ //
499
+ // Note: browser windows opened by external MCP servers (e.g. @playwright/mcp)
500
+ // do NOT funnel through this primitive and are therefore NOT logged. See
501
+ // MTG-mqam2mjs000e39d7.
502
+ //
503
+ // @param {string} url - URL to open in the user's default browser.
504
+ // @param {Object} [opts]
505
+ // @param {string} [opts.reason] - Why we are opening (e.g. 'cli-dash-warm',
506
+ // 'cli-start-force-open', 'dashboard-self-open'). Required-in-practice;
507
+ // missing values are logged at warn level.
508
+ // @param {string} [opts.callerHint] - Optional `file:line` to disambiguate
509
+ // when one reason has multiple call sites.
510
+ // @returns {{ok: boolean, error?: string, suppressed?: boolean}}
511
+ function openUrlInBrowser(url, opts = {}) {
512
+ const reason = (opts && typeof opts.reason === 'string' && opts.reason) ? opts.reason : 'unknown';
513
+ const callerHint = (opts && typeof opts.callerHint === 'string') ? opts.callerHint : null;
514
+ // Heuristic-driven opens (no explicit user action this dispatch) log at warn
515
+ // so a leak shows up as a warn cluster in the dashboard log filter.
516
+ const HEURISTIC_REASONS = new Set(['cli-restart-no-beacon', 'dashboard-self-open']);
517
+ let level = HEURISTIC_REASONS.has(reason) ? 'warn' : 'info';
518
+ if (reason === 'unknown') level = 'warn';
519
+ const callerStack = (new Error()).stack.split('\n').slice(2, 5).map(s => s.trim()).join(' | ');
520
+ const baseMeta = {
521
+ event: 'browser-open',
522
+ url,
523
+ reason,
524
+ callerHint,
525
+ callerStack,
526
+ pid: process.pid,
527
+ platform: process.platform,
528
+ };
529
+
530
+ // Kill-switch: log SUPPRESSED at debug level and skip execSync. Centralizing
531
+ // the check here means every caller (existing + future) is observable, and
532
+ // outer guards (e.g. ralph's W-mqb9y83o000le41e leak fixes) become belt-and-
533
+ // suspenders without breaking the log audit trail.
534
+ if (process.env.MINIONS_NO_AUTO_OPEN) {
535
+ log('debug', `[browser-open] SUPPRESSED reason=${reason} url=${url} (MINIONS_NO_AUTO_OPEN=1)`, {
536
+ ...baseMeta,
537
+ suppressed: true,
538
+ });
539
+ _bumpBrowserOpenMetric(reason, { suppressed: true });
540
+ return { ok: false, suppressed: true };
541
+ }
542
+
543
+ log(level, `[browser-open] reason=${reason} url=${url}`, baseMeta);
544
+ _bumpBrowserOpenMetric(reason);
545
+
488
546
  const { execSync } = require('child_process');
489
547
  try {
490
548
  if (process.platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore', windowsHide: true });
491
549
  else if (process.platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
492
550
  else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
551
+ log(level, `[browser-open] ok reason=${reason}`, { ...baseMeta, ok: true });
493
552
  return { ok: true };
494
553
  } catch (e) {
495
- return { ok: false, error: e && e.message ? e.message : String(e) };
554
+ const errMsg = e && e.message ? e.message : String(e);
555
+ log(level, `[browser-open] failed reason=${reason} error=${errMsg}`, { ...baseMeta, ok: false, error: errMsg });
556
+ return { ok: false, error: errMsg };
496
557
  }
497
558
  }
498
559
 
560
+ // Bump per-reason counter under metrics._engine.browserOpens. Best-effort —
561
+ // metrics.json writes must never throw out of openUrlInBrowser. Schema:
562
+ // _engine.browserOpens.<reason> = { count, suppressedCount, lastAt }
563
+ function _bumpBrowserOpenMetric(reason, { suppressed = false } = {}) {
564
+ try {
565
+ mutateMetrics((metrics) => {
566
+ if (!metrics._engine || typeof metrics._engine !== 'object') metrics._engine = {};
567
+ if (!metrics._engine.browserOpens || typeof metrics._engine.browserOpens !== 'object') {
568
+ metrics._engine.browserOpens = {};
569
+ }
570
+ const slot = metrics._engine.browserOpens[reason] || { count: 0, suppressedCount: 0, lastAt: null };
571
+ if (suppressed) slot.suppressedCount = (slot.suppressedCount || 0) + 1;
572
+ else slot.count = (slot.count || 0) + 1;
573
+ slot.lastAt = new Date().toISOString();
574
+ metrics._engine.browserOpens[reason] = slot;
575
+ return metrics;
576
+ });
577
+ } catch { /* metric best-effort — never throw out of openUrlInBrowser */ }
578
+ }
579
+
499
580
  function _flushLogBuffer() {
500
581
  if (_logBuffer.length === 0) return;
501
582
  const drained = _logBuffer.splice(0);
@@ -2369,9 +2450,31 @@ const ENGINE_DEFAULTS = {
2369
2450
  autoFixConflicts: true, // auto-dispatch fix agents when a PR has merge conflicts
2370
2451
  autoFixBuilds: true, // auto-dispatch fix agents when a PR build fails
2371
2452
  autoReviewPrs: true, // auto-dispatch review agents for newly opened agent PRs
2453
+ // P-a1f3c2d4 — hard-stop polling kill-switch / master override. When true,
2454
+ // section 2.6 (pollPrStatus) and 2.7 (pollPrHumanComments) skip both
2455
+ // providers regardless of adoPollEnabled / ghPollEnabled, and
2456
+ // discoverFromPrs treats pollEnabled as false for every project so the
2457
+ // downstream auto-dispatch gates (autoFixBuilds / autoFixConflicts /
2458
+ // autoReviewPrs / autoFixReviewFeedback / autoFixHumanComments) all
2459
+ // become inert. Default false so a fresh install behaves identically to
2460
+ // before this knob existed. Flip via Dashboard → Settings → Polling, or
2461
+ // set `engine.pollingPaused: true` in config.json. Reconciliation still
2462
+ // runs (it's a recovery sweep, not a convenience poll) — see section 2.7.
2463
+ pollingPaused: false, // hard-stop kill-switch / master override (see comment above)
2372
2464
  autoReReviewPrs: true, // auto-dispatch review agents after a PR fix is pushed
2373
2465
  autoFixReviewFeedback: true, // auto-dispatch fix agents for minions review changes-requested verdicts
2374
2466
  autoFixHumanComments: true, // auto-dispatch fix agents for actionable human PR comments
2467
+ // P-b2e5d8c7 — hard-stop auto-fix kill-switch / master override. When true,
2468
+ // discoverFromPrs treats every auto-fix gate (autoFixBuilds /
2469
+ // autoFixConflicts / autoFixReviewFeedback / autoFixHumanComments) as false
2470
+ // for every project, so no fix agent is auto-dispatched against any PR.
2471
+ // Review polling, review dispatch (autoReviewPrs / autoReReviewPrs), and
2472
+ // reconciliation are intentionally NOT gated — operators can pause the
2473
+ // fix-storm during an incident while still seeing fresh PR status and
2474
+ // review verdicts. Default false so a fresh install behaves identically
2475
+ // to before this knob existed. Flip via Dashboard → Settings → Auto-fix
2476
+ // & Review Loop, or set `engine.autoFixPaused: true` in config.json.
2477
+ autoFixPaused: false, // hard-stop kill-switch / master override (see comment above)
2375
2478
  autoConsolidateMemory: false, // opt-in: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Inbox→notes consolidation already runs every tick via consolidateInbox; this flag only controls the KB sweep.
2376
2479
  prNoOpFixPauseAttempts: 2, // pause one PR automation cause after repeated no-op fixes for unchanged evidence
2377
2480
  quarantineAutoRecoveryMax: 2, // #2996 follow-up: cap on auto-flipping WORKTREE_DIRTY/WORKTREE_DIVERGENT failures back to pending (the quarantine is self-healing so the next dispatch starts clean; the cap prevents infinite loops if quarantine itself keeps failing).
@@ -2468,6 +2571,39 @@ const ENGINE_DEFAULTS = {
2468
2571
  maxBuildFixRetries: 3,
2469
2572
  adoPollEnabled: true, // poll ADO PR status, comments, and reconciliation on each tick cycle
2470
2573
  ghPollEnabled: true, // poll GitHub PR status, comments, and reconciliation on each tick cycle
2574
+ // P-c4d8e1a3 — granular per-poller flags for the 6 PR-poll axes + rebase
2575
+ // processor. Each one defaults true and silently inherits a `false` from
2576
+ // its legacy bundle macro (adoPollEnabled / ghPollEnabled) for backward
2577
+ // compat, so existing config.json knobs keep working. New behavior:
2578
+ // setting `adoPollEnabled: false` (or `ghPollEnabled: false`) alone now
2579
+ // also silences the matching reconcile sweep — operators who want the
2580
+ // recovery sweep back must explicitly set `adoPrReconcileEnabled: true`
2581
+ // (or `ghPrReconcileEnabled: true`). processPendingRebasesEnabled has no
2582
+ // legacy counterpart; the rebase processor was previously ungated. See
2583
+ // shared.resolvePollFlag for the resolution order and the per-axis
2584
+ // surfaces in engine.js section 2.6 / 2.7.
2585
+ adoPrStatusPollEnabled: true,
2586
+ adoPrCommentsPollEnabled: true,
2587
+ adoPrReconcileEnabled: true,
2588
+ ghPrStatusPollEnabled: true,
2589
+ ghPrCommentsPollEnabled: true,
2590
+ ghPrReconcileEnabled: true,
2591
+ processPendingRebasesEnabled: true,
2592
+ // P-d6f0a2b5 — granular work-discovery flags. Each one gates a single
2593
+ // discovery phase inside `engine.discoverWork()`. Default true so the
2594
+ // out-of-box tick behaviour is unchanged; flip OFF via
2595
+ // `config.engine.<flag>` to silence a single discovery source without
2596
+ // touching the per-project `project.workSources.*.enabled` toggles
2597
+ // (those keep composing independently inside the matching discoverFrom*
2598
+ // helper — a `false` at either level skips the call). Wired inline as
2599
+ // `config.engine?.<flag> !== false` at each call site —
2600
+ // shared.resolvePollFlag is intentionally NOT used here because no
2601
+ // legacy macro bundle ever covered these phases.
2602
+ prDiscoveryEnabled: true, // discoverFromPrs (per-project PR-driven fix/review/test discovery)
2603
+ workItemsDiscoveryEnabled: true, // discoverFromWorkItems (per-project work-items.json scan)
2604
+ centralWorkDiscoveryEnabled: true, // discoverCentralWorkItems (top-level work-items.json scan)
2605
+ scheduledWorkDiscoveryEnabled: true, // discoverScheduledWork (cron-style scheduled tasks + meetings)
2606
+ planMaterializationEnabled: true, // reconcilePrdStatuses + materializePlansAsWorkItems pair
2471
2607
  prPollStatusEvery: 72, // poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default 10s tick)
2472
2608
  prPollCommentsEvery: 72, // poll PR human comments every N ticks for both ADO and GitHub (~12 min at default 10s tick)
2473
2609
  // W-mpxpckey000laa4f: lifted inline tick-counter literals to named constants
@@ -2478,6 +2614,11 @@ const ENGINE_DEFAULTS = {
2478
2614
  cleanupEvery: 60, // runCleanup + MCP sync every N ticks (~10 min at default 10s tick)
2479
2615
  planCompletionScanEvery: 60, // periodic PRD completion sweep (~10 min at default 10s tick) — catches plans completed while engine was down
2480
2616
  watchPollEvery: 18, // checkWatches every N ticks (~3 min at default 10s tick)
2617
+ // P-b2c3d4e5: cadence for MEMORY_BASELINE log line + diagnostics-memory.json
2618
+ // sidecar write driven from engine.js tickInner. Defaults to 6 ticks ≈ 60s
2619
+ // at the default 10s tickInterval. Set to 0 (or any non-positive integer)
2620
+ // to disable both the log emission and sidecar write cleanly (operator opt-out).
2621
+ memoryBaselineEveryTicks: 6,
2481
2622
  stalledDispatchSweepEvery: 120, // stalled-dispatch retry sweep (~20 min at default 10s tick) — only fires when all agents idle
2482
2623
  // W-mp5trwh60008386d: per-PR 404 must repeat across N consecutive successful base-repo probes
2483
2624
  // before flipping a PR to `abandoned`. A single 404 on `repos/{slug}/pulls/{n}` can be a transient
@@ -2522,6 +2663,26 @@ const ENGINE_DEFAULTS = {
2522
2663
  agentBusyReassignMs: 600000, // 10min — reassign work item to another agent if preferred agent is busy beyond this threshold
2523
2664
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
2524
2665
  enablePreDispatchEval: true, // P-d2a9f6e5: cheap LLM gate before queueing — on by default. See engine/pre-dispatch-eval.js (Ripley §3 recommendation, 2026-05-11 architecture review). Validates from acceptance_criteria when present, falls back to description when criteria are absent but description is rich (≥80 chars). Fail-open on any validator error.
2666
+ // W-mq9acoo800177bcb — bounded-concurrency for the pre-dispatch validator. The
2667
+ // discoverWork loop in engine.js used to run validateAcceptanceCriteria
2668
+ // sequentially, costing ~25-30s per item × N items per tick (an 11-item
2669
+ // approved PRD took ~5 min to queue). This caps the parallel LLM calls per
2670
+ // tick — 6 is a balance between LLM-provider concurrency tolerance and not
2671
+ // starving the rest of the tick budget. Clamped to [1, 20] in
2672
+ // dashboard.js#handleSettingsUpdate; engine.js#resolvePreDispatchEvalConcurrency
2673
+ // applies the same clamp at read time so a hand-edited config can't bypass it.
2674
+ preDispatchEvalConcurrency: 6,
2675
+ // W-mq9acoo800177bcb — short-circuit the validator for items materialized
2676
+ // from an approved/active PRD. plan-to-prd already LLM-vets every item that
2677
+ // lands in a PRD, so re-validating each item per discovery tick is the
2678
+ // dominant cost of the workflow that triggered this optimization (user-
2679
+ // observed: 2 PRDs / 11 items → ~5 min queueing). Items keep the
2680
+ // `_preDispatchEvalSkipped: 'prd-sourced'` marker on their dispatch entry for
2681
+ // observability. The bypass fires only when item.meta.item.sourcePlan is set
2682
+ // AND the parent PRD's status is approved/active; rejected/paused/revision-
2683
+ // requested/completed PRDs still flow through the normal validator path so a
2684
+ // stale PRD reopen can't smuggle in unvetted items.
2685
+ preDispatchEvalSkipPrdSourced: true,
2525
2686
  completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
2526
2687
  autoApplyReviewVote: false, // W-mpea9fyb0010febf / P-f7splitgate: gates POSITIVE auto-actions only — when true, the engine mirrors live platform vote state into local pull-requests.json reviewStatus (so platform-side approves auto-promote local status). When false (default), the verdict is recorded in pull-requests.json reviewStatus from the agent's completion only — informational. NEGATIVE-correction (dismissing the engine's own prior REQUEST_CHANGES / resetting its own prior negative ADO vote on a verdict flip to APPROVE) is correctness and runs UNCONDITIONALLY regardless of this flag.
2527
2688
 
@@ -2541,6 +2702,20 @@ const ENGINE_DEFAULTS = {
2541
2702
  copilotSuppressAgentsMd: true, // Copilot --no-custom-instructions: stop AGENTS.md auto-load from fighting Minions playbook prompts
2542
2703
  copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
2543
2704
  copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
2705
+ // P-mcp-storm — Copilot loads `~/.copilot/mcp-config.json` (the operator's
2706
+ // user-scope MCP servers) UNCONDITIONALLY on every process start: it is NOT
2707
+ // gated by `--add-dir`, cwd, or `hermeticHarness` (proven empirically — a
2708
+ // copilot spawned from a neutral dir with no `--add-dir ~/.copilot` still
2709
+ // connects every server). So every spawned Copilot agent boots the operator's
2710
+ // full interactive MCP stack; on Windows each `type:local`/`stdio` server
2711
+ // (e.g. playwright, maestro) launches its own visible console window and
2712
+ // playwright opens browser tabs. The only lever Copilot exposes is
2713
+ // `--disable-mcp-server <name>` (repeatable). This list names the servers the
2714
+ // engine disables for autonomous agent dispatches. Default [] = inherit ALL
2715
+ // (no behavior change). Per-agent override `agent.copilotAgentDisabledMcpServers`.
2716
+ // Resolved through `shared.resolveCopilotAgentDisabledMcpServers(agent, engine)`.
2717
+ // NOTE: `hermeticHarness` does NOT suppress these (it only strips `--add-dir`).
2718
+ copilotAgentDisabledMcpServers: [],
2544
2719
  ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
2545
2720
  maxBudgetUsd: undefined, // fleet USD ceiling for --max-budget-usd (per-agent override: agents.<id>.maxBudgetUsd). Honors 0 via ?? so a literal cap of $0 works
2546
2721
  disableModelDiscovery: false, // skip runtime.listModels() REST calls fleet-wide (settings UI falls back to free-text)
@@ -2770,6 +2945,31 @@ const ENGINE_DEFAULTS = {
2770
2945
  operatorLogin: null,
2771
2946
  };
2772
2947
 
2948
+ // ── P-c4d8e1a3: granular per-poller flag resolution ──────────────────────────
2949
+ //
2950
+ // Helper for the 6 PR-poll axes + processPendingRebases. Each axis has a
2951
+ // granular `*Enabled` flag in ENGINE_DEFAULTS (default true) plus an
2952
+ // optional legacy bundle macro (`adoPollEnabled` / `ghPollEnabled`) that
2953
+ // silences the matching ADO/GitHub trio when set to false. Resolution
2954
+ // order, in priority:
2955
+ //
2956
+ // 1. granular key explicitly present on engineCfg → use it
2957
+ // 2. legacyMacroKey is set on engineCfg AND === false → false
2958
+ // (legacy users keep working — and now reconcile is silenced too)
2959
+ // 3. fall through to ENGINE_DEFAULTS[granularKey] (default true)
2960
+ //
2961
+ // legacyMacroKey may be `null` for axes that have no legacy counterpart
2962
+ // (today: processPendingRebasesEnabled).
2963
+ function resolvePollFlag(engineCfg, granularKey, legacyMacroKey) {
2964
+ if (engineCfg && Object.prototype.hasOwnProperty.call(engineCfg, granularKey)) {
2965
+ return engineCfg[granularKey] !== false;
2966
+ }
2967
+ if (legacyMacroKey && engineCfg && engineCfg[legacyMacroKey] === false) {
2968
+ return false;
2969
+ }
2970
+ return ENGINE_DEFAULTS[granularKey] !== false;
2971
+ }
2972
+
2773
2973
  // ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
2774
2974
  //
2775
2975
  // Six helpers that are the single source of truth for "which CLI runtime + model
@@ -2921,6 +3121,36 @@ function resolveAgentBareMode(agent, engine) {
2921
3121
  return false;
2922
3122
  }
2923
3123
 
3124
+ // P-mcp-storm — Resolve the list of MCP server names to disable for spawned
3125
+ // Copilot agents (emitted as `--disable-mcp-server <name>`). Chain: per-agent
3126
+ // `agent.copilotAgentDisabledMcpServers` → `engine.copilotAgentDisabledMcpServers`
3127
+ // → `[]` (inherit all). Tolerant of either an array OR a comma/whitespace-
3128
+ // separated string (Settings UI / `MINIONS_*` env deliver strings). Always
3129
+ // returns a deduped array of trimmed, non-empty strings. See ENGINE_DEFAULTS
3130
+ // for why this exists (Copilot loads ~/.copilot MCP config unconditionally;
3131
+ // `hermeticHarness` does NOT suppress it).
3132
+ function _normalizeMcpServerList(v) {
3133
+ if (v == null) return null;
3134
+ const raw = Array.isArray(v) ? v : String(v).split(/[\s,]+/);
3135
+ const out = [];
3136
+ const seen = new Set();
3137
+ for (const item of raw) {
3138
+ const name = String(item == null ? '' : item).trim();
3139
+ if (!name || seen.has(name)) continue;
3140
+ seen.add(name);
3141
+ out.push(name);
3142
+ }
3143
+ return out;
3144
+ }
3145
+
3146
+ function resolveCopilotAgentDisabledMcpServers(agent, engine) {
3147
+ const a = agent ? _normalizeMcpServerList(agent.copilotAgentDisabledMcpServers) : null;
3148
+ if (a) return a;
3149
+ const e = engine ? _normalizeMcpServerList(engine.copilotAgentDisabledMcpServers) : null;
3150
+ if (e) return e;
3151
+ return [];
3152
+ }
3153
+
2924
3154
  // ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
2925
3155
  //
2926
3156
  // Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
@@ -3704,6 +3934,49 @@ function extractPlanDeclaredProject(planContent) {
3704
3934
  }
3705
3935
  return '';
3706
3936
  }
3937
+
3938
+ // P-7a3f1c08: Parse a cross-repo plan's list of target projects.
3939
+ // Primary signal: `<!-- minions:targetProjects=a, b, c -->` HTML comment.
3940
+ // Fallback (human): `**Projects:** a, b | c` markdown line in the first 80 lines.
3941
+ // Both forms accept `,`, `|`, or `;` as separators with surrounding whitespace.
3942
+ // Returns string[] of trimmed project names (empty array when nothing parseable).
3943
+ function extractPlanTargetProjects(planContent) {
3944
+ if (typeof planContent !== 'string' || !planContent.trim()) return [];
3945
+
3946
+ const split = (raw) => {
3947
+ if (typeof raw !== 'string') return [];
3948
+ return raw
3949
+ .split(/[,|;]/)
3950
+ .map(s => s.trim().replace(/^["'`]+|["'`]+$/g, '').trim())
3951
+ .filter(Boolean);
3952
+ };
3953
+
3954
+ // Primary signal: structured HTML comment marker (case-insensitive on the key).
3955
+ const markerMatch = planContent.match(/<!--\s*minions:targetProjects\s*=\s*([^>]*?)\s*-->/i);
3956
+ if (markerMatch) {
3957
+ const parsed = split(markerMatch[1]);
3958
+ if (parsed.length > 0) return parsed;
3959
+ }
3960
+
3961
+ // Fallback signal: human-readable plural `Projects:` line — distinct from the
3962
+ // singular `Project:` line consumed by extractPlanDeclaredProject. Scan the
3963
+ // first 80 lines, matching the same leading-bullet / bold-markdown tolerance.
3964
+ const lines = planContent.split(/\r?\n/).slice(0, 80);
3965
+ for (const rawLine of lines) {
3966
+ const line = rawLine
3967
+ .replace(/^\s*[-*]\s+/, '')
3968
+ .replace(/\*\*/g, '')
3969
+ .trim();
3970
+ const match = line.match(/^Projects\s*:\s*(.+)$/i);
3971
+ if (!match) continue;
3972
+ let value = match[1].trim();
3973
+ value = value.replace(/\s+#.*$/, '').trim();
3974
+ const parsed = split(value);
3975
+ if (parsed.length > 0) return parsed;
3976
+ }
3977
+
3978
+ return [];
3979
+ }
3707
3980
  const DISPATCH_RESULT = { SUCCESS: 'success', ERROR: 'error', TIMEOUT: 'timeout' };
3708
3981
  const PIPELINE_STATUS = {
3709
3982
  PENDING: 'pending', RUNNING: 'running', COMPLETED: 'completed',
@@ -4097,6 +4370,30 @@ function getProjects(config) {
4097
4370
  return [];
4098
4371
  }
4099
4372
 
4373
+ // Generic, ambiguous project-name tokens that read poorly as a bare label in
4374
+ // the dashboard. For these, projectDisplayName derives a `<parentFolder>/<name>`
4375
+ // label from the project's localPath. `src` is the required case. DISPLAY ONLY —
4376
+ // the project `name` field is a load-bearing identifier and is never mutated.
4377
+ const GENERIC_PROJECT_NAME_TOKENS = new Set(['src', 'source', 'repo']);
4378
+
4379
+ // Human-facing label for a project. Returns `project.name` unchanged for normal
4380
+ // names. When the name is a generic token (e.g. `src`), derives the parent
4381
+ // folder one level up from localPath and returns `<parentBasename>/<name>` —
4382
+ // e.g. name `src` at localPath `C:/office/src` → `office/src`. Falls back to the
4383
+ // bare name when localPath is missing or the parent can't be derived. Never
4384
+ // mutates the project; preserves the original name casing.
4385
+ function projectDisplayName(project) {
4386
+ if (!project || typeof project !== 'object') return '';
4387
+ const name = project.name == null ? '' : String(project.name);
4388
+ if (!GENERIC_PROJECT_NAME_TOKENS.has(name.trim().toLowerCase())) return name;
4389
+ const localPath = project.localPath == null ? '' : String(project.localPath);
4390
+ const normalized = localPath.replace(/\\/g, '/').replace(/\/+$/, '');
4391
+ if (!normalized) return name;
4392
+ const parent = path.basename(path.dirname(normalized));
4393
+ if (!parent || parent === '.') return name;
4394
+ return `${parent}/${name}`;
4395
+ }
4396
+
4100
4397
  function formatUnknownProjectError(projectName, projects = []) {
4101
4398
  const known = projects.map(p => p.name).filter(Boolean).join(', ') || '(none configured)';
4102
4399
  return `Project "${projectName}" not found. Known projects: ${known}`;
@@ -7504,14 +7801,16 @@ module.exports = {
7504
7801
  KB_READABLE_CATEGORIES,
7505
7802
  classifyInboxItem,
7506
7803
  ENGINE_DEFAULTS,
7804
+ resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
7507
7805
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
7508
7806
  resolveAgentMaxBudget, resolveAgentBareMode,
7807
+ resolveCopilotAgentDisabledMcpServers,
7509
7808
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
7510
7809
  applyCcWorkerPoolForceOnMigration,
7511
7810
  runtimeConfigWarnings,
7512
7811
  projectWorkSourceWarnings,
7513
7812
  backfillProjectWorkSourceDefaults,
7514
- WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
7813
+ WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, VALID_WORK_TYPES, resolveWorkItemTypeFromPrdItem, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, RETRY_DELAY_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, DISPATCH_RESULT, mutateMetrics, mutateWatches, mutateScheduleRuns, mutatePipelineRuns, mutateManagedProcesses, mutateWorktreePool, mutateQaRuns, mutateQaSessions, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject, extractPlanTargetProjects,
7515
7814
  WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
7516
7815
  WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
7517
7816
  PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
@@ -7532,6 +7831,7 @@ module.exports = {
7532
7831
  mergeManifestAllowedTools,
7533
7832
  formatManifestRejection,
7534
7833
  getProjects,
7834
+ projectDisplayName,
7535
7835
  formatUnknownProjectError,
7536
7836
  findProjectByName,
7537
7837
  findProjectByNameOrPath,
@@ -146,6 +146,12 @@ async function tick(opts) {
146
146
  detached: true,
147
147
  stdio: 'ignore',
148
148
  windowsHide: true,
149
+ // W-mqb9y83o — suppress auto-open in the respawned dashboard. The
150
+ // watchdog has no user-intent signal (it fires on health failure, not
151
+ // user action), so the post-restart hook MUST NOT pop a browser tab.
152
+ // The CLI's spawnFullStackAndVerify reads this env var as a hard
153
+ // kill-switch via MINIONS_NO_AUTO_OPEN=1.
154
+ env: { ...process.env, MINIONS_NO_AUTO_OPEN: '1' },
149
155
  });
150
156
  if (child && typeof child.unref === 'function') child.unref();
151
157
  logLine(minionsHome, `spawned minions ${action} pid=${child && child.pid} (detached)`);