@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.js CHANGED
@@ -32,7 +32,7 @@ const crypto = require('crypto');
32
32
  const shared = require('./engine/shared');
33
33
  const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
34
34
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
35
- FAILURE_CLASS } = shared;
35
+ FAILURE_CLASS, resolvePollFlag } = shared;
36
36
  const { resolveRuntime } = require('./engine/runtimes');
37
37
  const { assertStaleHeadOk } = require('./engine/spawn-agent');
38
38
  const adoGitAuth = require('./engine/ado-git-auth');
@@ -168,7 +168,12 @@ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconci
168
168
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
169
169
  updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs,
170
170
  isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemPath,
171
- mergeArtifactNotes, promoteCompletionArtifacts } = require('./engine/lifecycle');
171
+ mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs } = require('./engine/lifecycle');
172
+
173
+ // ─── Diagnostics: memory + event-loop + GC sampler (P-a1b2c3d4 / P-b2c3d4e5) ─
174
+
175
+ const diagnosticsMemory = require('./engine/diagnostics-memory');
176
+ const DIAGNOSTICS_MEMORY_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
172
177
 
173
178
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
174
179
 
@@ -617,7 +622,11 @@ function promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutpu
617
622
 
618
623
  // Resolve dependency plan item IDs to their PR branches
619
624
  function resolveDependencyBranches(depIds, sourcePlan, project, config) {
620
- const results = []; // [{ branch, prId }]
625
+ // P-faea3206: each entry now carries projectName + projectRoot + isCrossRepo
626
+ // so spawnAgent's dep-fetch loop can route same-project deps through the
627
+ // existing fetch+merge path and cross-project deps through the advisory
628
+ // transfer-fetch path (no auto-merge, no escalation).
629
+ const results = []; // [{ branch, prId, projectName, projectRoot, isCrossRepo }]
621
630
  if (!depIds?.length) return results;
622
631
 
623
632
  const projects = shared.getProjects(config);
@@ -626,6 +635,8 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
626
635
  const allItems = queries.getWorkItems(config);
627
636
  const depWorkItems = allItems.filter(wi => depIds.includes(wi.id));
628
637
 
638
+ const currentProjectName = project?.name || null;
639
+
629
640
  // Find PR branches for each dependency work item
630
641
  for (const p of projects) {
631
642
  const prPath = shared.projectPrPath(p);
@@ -636,7 +647,16 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
636
647
  depWorkItems.find(w => w.id === id)
637
648
  );
638
649
  if (linked && !results.find(r => r.branch === pr.branch)) {
639
- results.push({ branch: pr.branch, prId: pr.id });
650
+ let depRoot = null;
651
+ try { depRoot = shared.resolveProjectRootDir(p.localPath, MINIONS_DIR); }
652
+ catch { depRoot = null; /* drive-root or missing localPath — caller falls back gracefully */ }
653
+ results.push({
654
+ branch: pr.branch,
655
+ prId: pr.id,
656
+ projectName: p.name || null,
657
+ projectRoot: depRoot,
658
+ isCrossRepo: !!currentProjectName && p.name !== currentProjectName,
659
+ });
640
660
  }
641
661
  }
642
662
  }
@@ -644,6 +664,48 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
644
664
  return results;
645
665
  }
646
666
 
667
+ // P-faea3206: render the "## Cross-repo dependencies" prompt section that
668
+ // gets appended to the prompt file when a dispatch has at least one
669
+ // cross-repo (advisory) dep. The section enumerates each dep's repo, branch,
670
+ // short SHA, and the changed-file list, plus the local ref
671
+ // (`refs/cross-repo-deps/<branch>`) the agent can `git show` / `git log` to
672
+ // inspect the contract without merging unrelated repo content.
673
+ //
674
+ // Returns "" when `deps` is empty/missing so callers can unconditionally
675
+ // concat the result without a separate guard.
676
+ function buildCrossRepoDepsSection(deps) {
677
+ if (!Array.isArray(deps) || deps.length === 0) return '';
678
+ const lines = [
679
+ '',
680
+ '## Cross-repo dependencies',
681
+ '',
682
+ 'The work items below are dependencies in OTHER repos. They are surfaced here as',
683
+ '*advisory* context only — the engine intentionally does NOT merge cross-repo dep',
684
+ 'branches into your worktree (that would commingle unrelated repo content). Use the',
685
+ 'local refs below to read the contract (`git show <ref>`, `git log <ref> -- <files>`)',
686
+ 'and update your code in this repo to honor it.',
687
+ '',
688
+ ];
689
+ for (const dep of deps) {
690
+ const branch = dep?.branch || '(unknown-branch)';
691
+ const repo = dep?.projectName || '(unknown-repo)';
692
+ const sha = typeof dep?.sha === 'string' && dep.sha ? dep.sha.slice(0, 12) : '';
693
+ const files = Array.isArray(dep?.files) ? dep.files.filter(Boolean) : [];
694
+ lines.push(`### ${repo} — \`${branch}\``);
695
+ if (sha) lines.push(`- Tip SHA: \`${sha}\``);
696
+ lines.push(`- Local ref: \`refs/cross-repo-deps/${branch}\``);
697
+ if (files.length > 0) {
698
+ lines.push('- Changed files:');
699
+ for (const f of files.slice(0, 50)) lines.push(` - \`${f}\``);
700
+ if (files.length > 50) lines.push(` - …and ${files.length - 50} more`);
701
+ } else {
702
+ lines.push('- Changed files: (not computed — inspect via `git log --stat`)');
703
+ }
704
+ lines.push('');
705
+ }
706
+ return lines.join('\n');
707
+ }
708
+
647
709
  /**
648
710
  * Sync an existing worktree from origin on reuse: probe with
649
711
  * `git ls-remote --exit-code --heads origin <branch>` first so that locally
@@ -1572,6 +1634,11 @@ async function spawnAgent(dispatchItem, config) {
1572
1634
  // milliseconds. Keys are stamped at phase boundaries below.
1573
1635
  const _phaseT = { start: Date.now() };
1574
1636
  let _depCountForLog = 0;
1637
+ // P-faea3206: collected info for the prompt-file "## Cross-repo dependencies"
1638
+ // section. Populated during the dep-fetch phase below; consumed once the
1639
+ // worktree is ready and the initial prompt has been written (alongside the
1640
+ // dirty-files inject). Stays an empty array on single-repo / no-dep dispatches.
1641
+ let _crossRepoDepsInfo = [];
1575
1642
 
1576
1643
  updateAgentStatus(id, AGENT_STATUS.SPAWNING, `Preparing ${type} task for ${agentId}`);
1577
1644
 
@@ -2438,7 +2505,26 @@ async function spawnAgent(dispatchItem, config) {
2438
2505
  if (depIds.length > 0) {
2439
2506
  _phaseT.depFetchStart = Date.now();
2440
2507
  try {
2441
- const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
2508
+ const depBranchesAll = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
2509
+ // P-faea3206: partition deps into same-project (fetch + merge as
2510
+ // today) and cross-project (fetch into the dep's project rootDir,
2511
+ // transfer-fetch as refs/cross-repo-deps/<branch>, NEVER auto-merge,
2512
+ // NEVER escalate). Cross-repo deps without a resolvable projectRoot
2513
+ // (missing localPath on dep project) are dropped with a warn — the
2514
+ // engine can't honor them safely.
2515
+ const crossRepoDepsRaw = depBranchesAll.filter(d => d.isCrossRepo);
2516
+ const crossRepoDepsBranches = [];
2517
+ for (const dep of crossRepoDepsRaw) {
2518
+ if (!dep.projectRoot) {
2519
+ log('warn', `Skipping cross-repo dep ${dep.branch} (${dep.prId}) — dep project "${dep.projectName}" has no resolvable rootDir`);
2520
+ continue;
2521
+ }
2522
+ crossRepoDepsBranches.push(dep);
2523
+ }
2524
+ // The historical loop below operates ONLY on same-project deps.
2525
+ // Cross-repo deps are handled in their own block immediately after
2526
+ // the per-branch fetch loop.
2527
+ const depBranches = depBranchesAll.filter(d => !d.isCrossRepo);
2442
2528
  let depMergeFailed = false;
2443
2529
  let depConflictBranch = null; // track which dep branch caused the conflict
2444
2530
  let depConflictFiles = []; // conflicting file names parsed from git output
@@ -2542,6 +2628,69 @@ async function spawnAgent(dispatchItem, config) {
2542
2628
  depMergeFailed = true;
2543
2629
  }
2544
2630
  }
2631
+ // P-faea3206: Cross-repo dep fetch + transfer-fetch into the
2632
+ // current worktree. These deps are ADVISORY — we never auto-merge
2633
+ // them (cross-repo merges would commingle unrelated repo content),
2634
+ // never trigger the local-only push recovery, and never escalate
2635
+ // via depMergeFailed (the dispatch proceeds regardless of cross-
2636
+ // repo dep fetch outcome). The local ref `refs/cross-repo-deps/
2637
+ // <branch>` lets the agent inspect the contract via git show /
2638
+ // git log without touching the working tree.
2639
+ for (const dep of crossRepoDepsBranches) {
2640
+ const depBranch = dep.branch;
2641
+ const depProjectRoot = dep.projectRoot;
2642
+ try {
2643
+ // 1. Fetch the dep branch into the DEP project's rootDir
2644
+ // (the current project's origin doesn't carry that ref).
2645
+ await shared.shellSafeGit(['fetch', 'origin', depBranch], { ..._gitOpts, cwd: depProjectRoot });
2646
+ // 2. Transfer the tip into the current worktree via a
2647
+ // local-path fetch — git supports filesystem-path remotes.
2648
+ // Stamp it under refs/cross-repo-deps/<branch> so it's
2649
+ // namespaced away from refs/heads / refs/remotes and
2650
+ // cannot accidentally be checked out / pushed.
2651
+ const refSpec = `${depBranch}:refs/cross-repo-deps/${depBranch}`;
2652
+ await shared.shellSafeGit(['fetch', depProjectRoot, refSpec], { ..._gitOpts, cwd: worktreePath });
2653
+ // 3. Resolve the tip SHA + a short changed-file list (vs the
2654
+ // dep project's main) for the prompt section. These are
2655
+ // best-effort — failure here downgrades to an empty list
2656
+ // rather than failing the whole advisory block.
2657
+ let depSha = '';
2658
+ try {
2659
+ depSha = gitOutputToString(await shared.shellSafeGit(
2660
+ ['rev-parse', `refs/cross-repo-deps/${depBranch}`],
2661
+ { ..._gitOpts, cwd: worktreePath }
2662
+ )).trim();
2663
+ } catch (_) { /* best-effort */ }
2664
+ let depFiles = [];
2665
+ try {
2666
+ const depMainRef = sanitizeBranch(shared.resolveMainBranch(depProjectRoot, null));
2667
+ const diffOut = gitOutputToString(await shared.shellSafeGit(
2668
+ ['diff', '--name-only', `origin/${depMainRef}...${depBranch}`],
2669
+ { ..._gitOpts, cwd: depProjectRoot }
2670
+ ));
2671
+ depFiles = diffOut.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
2672
+ } catch (_) { /* best-effort */ }
2673
+ _crossRepoDepsInfo.push({
2674
+ branch: depBranch,
2675
+ projectName: dep.projectName,
2676
+ sha: depSha,
2677
+ files: depFiles,
2678
+ });
2679
+ log('info', `Cross-repo dep ${dep.projectName}:${depBranch} transferred as refs/cross-repo-deps/${depBranch}${depFiles.length ? ` (${depFiles.length} changed file(s))` : ''}`);
2680
+ } catch (crossErr) {
2681
+ // ADVISORY: log and move on. No depMergeFailed flip, no
2682
+ // recovery push, no failure-class escalation. Surface the
2683
+ // miss to the agent prompt anyway so it knows the dep was
2684
+ // attempted (with files: []) — better than silent omission.
2685
+ log('warn', `Cross-repo dep ${dep.projectName}:${depBranch} fetch failed (advisory — dispatch proceeds): ${crossErr.message}`);
2686
+ _crossRepoDepsInfo.push({
2687
+ branch: depBranch,
2688
+ projectName: dep.projectName,
2689
+ sha: '',
2690
+ files: [],
2691
+ });
2692
+ }
2693
+ }
2545
2694
  _phaseT.depFetchEnd = Date.now();
2546
2695
  _phaseT.depPreflightStart = _phaseT.depFetchEnd;
2547
2696
  // Merge successfully-fetched + recovered (local-only pushed) branches sequentially
@@ -2864,6 +3013,20 @@ async function spawnAgent(dispatchItem, config) {
2864
3013
  _phaseT.dirtyProbeEnd = Date.now();
2865
3014
  }
2866
3015
 
3016
+ // P-faea3206: Append a "## Cross-repo dependencies" section to the prompt
3017
+ // file once the dep-fetch phase has populated _crossRepoDepsInfo. Lands
3018
+ // AFTER the shared-branch prompt refresh and the dirty-files inject so the
3019
+ // section is preserved regardless of which earlier write path ran. The
3020
+ // section is advisory: cross-repo deps were fetched into refs/cross-repo-
3021
+ // deps/<branch> but NOT merged into the working tree.
3022
+ if (_crossRepoDepsInfo.length > 0) {
3023
+ const crossSection = buildCrossRepoDepsSection(_crossRepoDepsInfo);
3024
+ if (crossSection) {
3025
+ try { fs.appendFileSync(promptPath, crossSection); } catch (e) { log('warn', `cross-repo deps inject: ${e.message}`); }
3026
+ log('info', `Injected ${_crossRepoDepsInfo.length} cross-repo dep(s) into prompt for ${id}`);
3027
+ }
3028
+ }
3029
+
2867
3030
  // Safety check: warn if a write-capable task is running in the main repo without a worktree
2868
3031
  if (cwd === rootDir && ['implement', 'implement:large', 'fix', 'test', 'verify', 'plan-to-prd'].includes(type)) {
2869
3032
  log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
@@ -2917,6 +3080,12 @@ async function spawnAgent(dispatchItem, config) {
2917
3080
  const resolvedModel = runtime.resolveModel(shared.resolveAgentModel(agentConfig, engineConfig));
2918
3081
  const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
2919
3082
  const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
3083
+ // P-mcp-storm (opg#134 backport) — user-scope MCP servers to keep out of
3084
+ // this agent dispatch. Copilot loads ~/.copilot/mcp-config.json
3085
+ // unconditionally; the adapter re-emits these as --disable-mcp-server <name>.
3086
+ // Other runtimes ignore the opt (their buildSpawnFlags don't read it), so no
3087
+ // runtime.name branch here.
3088
+ const resolvedDisabledMcpServers = shared.resolveCopilotAgentDisabledMcpServers(agentConfig, engineConfig);
2920
3089
 
2921
3090
  // W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
2922
3091
  // to the runtime-appropriate fallback model. Two paths gated on
@@ -2971,6 +3140,7 @@ async function spawnAgent(dispatchItem, config) {
2971
3140
  disableBuiltinMcps: engineConfig.copilotDisableBuiltinMcps,
2972
3141
  suppressAgentsMd: engineConfig.copilotSuppressAgentsMd,
2973
3142
  reasoningSummaries: engineConfig.copilotReasoningSummaries,
3143
+ disabledMcpServers: resolvedDisabledMcpServers,
2974
3144
  });
2975
3145
 
2976
3146
  // MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
@@ -3365,6 +3535,7 @@ async function spawnAgent(dispatchItem, config) {
3365
3535
  disableBuiltinMcps: engineConfig?.copilotDisableBuiltinMcps,
3366
3536
  suppressAgentsMd: engineConfig?.copilotSuppressAgentsMd,
3367
3537
  reasoningSummaries: engineConfig?.copilotReasoningSummaries,
3538
+ disabledMcpServers: resolvedDisabledMcpServers,
3368
3539
  });
3369
3540
  if (!resumeArgs.includes('--resume')) {
3370
3541
  log('warn', `Steering: runtime ${runtime.name} did not accept session resume — skipping for ${agentId}`);
@@ -5163,21 +5334,43 @@ function materializePlansAsWorkItems(config) {
5163
5334
  if (totalCreated > 0) {
5164
5335
  log('info', `Plan discovery: ${totalCreated} total item(s) from ${file} across ${itemsByProject.size} project(s)`);
5165
5336
 
5166
- // Pre-create shared feature branch if branch_strategy is shared-branch
5337
+ // Pre-create shared feature branch in every touched project (P-1c0f5e84).
5338
+ // Cross-repo plans materialize work items into multiple projects; each
5339
+ // one needs the shared branch created off its own main and pushed to
5340
+ // its own origin so the dispatcher can later `git worktree add ... origin/<branch>`
5341
+ // per project. Idempotent — `git rev-parse --verify` skips the create
5342
+ // when the branch already exists locally, and `git push -u origin` is
5343
+ // a no-op when the remote already has the same tip.
5167
5344
  if (plan.branch_strategy === 'shared-branch' && plan.feature_branch) {
5168
- try {
5169
- const firstProject = itemsByProject.values().next().value?.project;
5170
- if (!firstProject?.localPath) throw new Error('no project with localPath');
5171
- const root = path.resolve(firstProject.localPath);
5172
- const mainBranch = shared.resolveMainBranch(root, firstProject.mainBranch);
5173
- const branch = sanitizeBranch(plan.feature_branch);
5174
- // Create branch from main (idempotent — ignores if exists)
5175
- // P-a7c4d2e8 (F3): argv-form (shell:false) replaces shell-piped exec.
5176
- try { shared.shellSafeGitSync(['branch', branch, mainBranch], { cwd: root }); } catch { /* idempotent — branch may already exist */ }
5177
- try { shared.shellSafeGitSync(['push', '-u', 'origin', branch], { cwd: root }); } catch (e) { log('warn', `git push -u origin ${branch} (pre-create): ${e.message?.split('\n')[0]}`); }
5178
- log('info', `Shared branch pre-created: ${branch} for plan ${file}`);
5179
- } catch (err) {
5180
- log('warn', `Failed to pre-create shared branch for ${file}: ${err.message}`);
5345
+ const branch = sanitizeBranch(plan.feature_branch);
5346
+ for (const [projName, { project: itProject }] of itemsByProject) {
5347
+ // Skip the _central bucket — those items have no project root.
5348
+ if (!itProject?.localPath) continue;
5349
+ try {
5350
+ const root = path.resolve(itProject.localPath);
5351
+ const mainBranch = shared.resolveMainBranch(root, itProject.mainBranch);
5352
+ // Idempotency guard: rev-parse --verify exits 0 when the branch
5353
+ // already exists locally. Only attempt the branch-create when
5354
+ // it returns a nonzero exit (caught below).
5355
+ let branchExists = false;
5356
+ try {
5357
+ shared.shellSafeGitSync(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: root });
5358
+ branchExists = true;
5359
+ } catch { /* branch does not exist — fall through to create */ }
5360
+ if (!branchExists) {
5361
+ try { shared.shellSafeGitSync(['branch', branch, mainBranch], { cwd: root }); }
5362
+ catch (e) { log('warn', `Shared branch ${branch} create in ${projName} skipped: ${e.message?.split('\n')[0]}`); }
5363
+ }
5364
+ // Always push — no-op when origin already has the tip; surfaces
5365
+ // the new branch when it was just created.
5366
+ try { shared.shellSafeGitSync(['push', '-u', 'origin', branch], { cwd: root }); }
5367
+ catch (e) { log('warn', `git push -u origin ${branch} (pre-create in ${projName}): ${e.message?.split('\n')[0]}`); }
5368
+ log('info', `Shared branch pre-created: ${branch} in ${projName} for plan ${file}`);
5369
+ } catch (err) {
5370
+ // Per-project failure isolation — one project's broken
5371
+ // localPath / git config must not block the others.
5372
+ log('warn', `Failed to pre-create shared branch ${branch} in ${projName} for ${file}: ${err.message}`);
5373
+ }
5181
5374
  }
5182
5375
  }
5183
5376
 
@@ -5415,6 +5608,44 @@ function isPrAutomationCauseHandledOrPending(project, pr, causeKey) {
5415
5608
  }
5416
5609
 
5417
5610
 
5611
+ /**
5612
+ * P-a1f3c2d4 — `engine.pollingPaused` is a hard-stop kill-switch that fires
5613
+ * every tick when ON. Without this guard the engine would emit an info log on
5614
+ * every tick (~6/min) while paused, drowning the rest of the log. We log once
5615
+ * on the transition `unpaused → paused`, then stay quiet until the operator
5616
+ * flips it back off (transition `paused → unpaused` is silent — the engine's
5617
+ * routine "PR status poll" log entries resume on their own and visibly signal
5618
+ * the unpause).
5619
+ */
5620
+ let _pollingPausedLastState = false;
5621
+ function _shouldLogPollingPausedOnce() {
5622
+ if (_pollingPausedLastState) return false;
5623
+ _pollingPausedLastState = true;
5624
+ return true;
5625
+ }
5626
+ function _resetPollingPausedLogState() {
5627
+ // Only flips back to "log next time we see paused=true" when we observe
5628
+ // paused=false. Called from the polling-gate resolution site below
5629
+ // whenever pollingPaused is currently false.
5630
+ _pollingPausedLastState = false;
5631
+ }
5632
+
5633
+ /**
5634
+ * P-b2e5d8c7 — `engine.autoFixPaused` mirrors the pollingPaused log-throttle
5635
+ * pattern above. Same once-per-transition contract: log on unpaused → paused,
5636
+ * stay quiet while paused, silent on paused → unpaused (routine discovery
5637
+ * resumes and signals the unpause).
5638
+ */
5639
+ let _autoFixPausedLastState = false;
5640
+ function _shouldLogAutoFixPausedOnce() {
5641
+ if (_autoFixPausedLastState) return false;
5642
+ _autoFixPausedLastState = true;
5643
+ return true;
5644
+ }
5645
+ function _resetAutoFixPausedLogState() {
5646
+ _autoFixPausedLastState = false;
5647
+ }
5648
+
5418
5649
  // Tracks per-process which silent-discovery warnings have already been logged
5419
5650
  // so we don't spam the log every tick. Cleared on process exit (no need to
5420
5651
  // persist — the warning is for the operator at engine startup/run time).
@@ -5467,17 +5698,31 @@ async function discoverFromPrs(config, project) {
5467
5698
  const newWork = [];
5468
5699
 
5469
5700
  const projMeta = { name: project?.name, localPath: project?.localPath };
5470
- // Resolve poll-enabled per project — stale reviewStatus is untrustworthy without poller
5701
+ // Resolve poll-enabled per project — stale reviewStatus is untrustworthy without poller.
5702
+ // P-a1f3c2d4: engine.pollingPaused is a hard-stop master switch that wins over
5703
+ // both adoPollEnabled and ghPollEnabled. Forcing pollEnabled=false here
5704
+ // suppresses every per-PR auto-dispatch gate below (reviewEnabled,
5705
+ // autoFixHumanComments, autoFixReviewFeedback, autoFixBuilds, autoFixConflicts)
5706
+ // without us having to add a check at each call site.
5707
+ const pollingPaused = config.engine?.pollingPaused === true;
5471
5708
  const isAdoProject = project?.repoHost !== 'github';
5472
- const pollEnabled = isAdoProject
5709
+ const pollEnabled = !pollingPaused && (isAdoProject
5473
5710
  ? (config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled)
5474
- : (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled);
5711
+ : (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled));
5475
5712
  const evalLoopEnabled = config.engine?.evalLoop !== false;
5476
5713
  const fixThrottled = isAdoProject ? isAdoThrottled() : isGhThrottled();
5477
5714
  const autoReviewPrs = config.engine?.autoReviewPrs ?? ENGINE_DEFAULTS.autoReviewPrs;
5478
5715
  const autoReReviewPrs = config.engine?.autoReReviewPrs ?? ENGINE_DEFAULTS.autoReReviewPrs;
5479
- const autoFixReviewFeedback = config.engine?.autoFixReviewFeedback ?? ENGINE_DEFAULTS.autoFixReviewFeedback;
5480
- const autoFixHumanComments = config.engine?.autoFixHumanComments ?? ENGINE_DEFAULTS.autoFixHumanComments;
5716
+ // P-b2e5d8c7: engine.autoFixPaused is a hard-stop master switch over every
5717
+ // auto-fix gate. Composing `!autoFixPaused && …` into each autoFix* gate
5718
+ // here makes the kill-switch single-point: every downstream
5719
+ // `pollEnabled && autoFix* && …` site inherits the gate without us touching
5720
+ // the per-cause dispatch blocks. Review / re-review dispatch (autoReviewPrs
5721
+ // / autoReReviewPrs) is intentionally NOT gated — operators can pause the
5722
+ // fix-storm during an incident while still seeing fresh review verdicts.
5723
+ const autoFixPaused = config.engine?.autoFixPaused === true;
5724
+ const autoFixReviewFeedback = !autoFixPaused && (config.engine?.autoFixReviewFeedback ?? ENGINE_DEFAULTS.autoFixReviewFeedback);
5725
+ const autoFixHumanComments = !autoFixPaused && (config.engine?.autoFixHumanComments ?? ENGINE_DEFAULTS.autoFixHumanComments);
5481
5726
 
5482
5727
  // Collect active PR dispatches to prevent simultaneous review+fix on same PR
5483
5728
  const dispatch = getDispatch();
@@ -5861,7 +6106,7 @@ async function discoverFromPrs(config, project) {
5861
6106
  const gracePeriodMs = config.engine?.buildFixGracePeriod ?? ENGINE_DEFAULTS.buildFixGracePeriod;
5862
6107
  if (Date.now() - new Date(pr._buildFixPushedAt).getTime() < gracePeriodMs) continue;
5863
6108
  }
5864
- const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
6109
+ const autoFixBuilds = !autoFixPaused && (config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds);
5865
6110
  if (pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing'
5866
6111
  && !fixDispatched
5867
6112
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.BUILD_FAILURE)) {
@@ -6022,7 +6267,7 @@ async function discoverFromPrs(config, project) {
6022
6267
  }
6023
6268
 
6024
6269
  // PRs with merge conflicts — dispatch fix to resolve (gated by provider polling + autoFixConflicts)
6025
- const autoFixConflicts = config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts;
6270
+ const autoFixConflicts = !autoFixPaused && (config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts);
6026
6271
  if (pollEnabled && autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched
6027
6272
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.MERGE_CONFLICT)) {
6028
6273
  // W-mpritzcr0004afc5 (#2955): "don't fan out a parallel conflict-fix
@@ -7342,15 +7587,42 @@ function discoverCentralWorkItems(config) {
7342
7587
  let planFileContent = null;
7343
7588
  let planReadError = null;
7344
7589
  let declaredPlanProject = '';
7590
+ let planTargetProjects = [];
7345
7591
  if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile) {
7346
7592
  const planPath = path.join(PLANS_DIR, item.planFile);
7347
7593
  try {
7348
7594
  planFileContent = fs.readFileSync(planPath, 'utf8');
7349
7595
  declaredPlanProject = shared.extractPlanDeclaredProject(planFileContent);
7596
+ // P-9af2eb37 — also parse the cross-repo target list from the same
7597
+ // plan content. extractPlanTargetProjects (P-7a3f1c08) returns
7598
+ // [] when the plan has no <!-- minions:targetProjects=… --> marker
7599
+ // and no **Projects:** line, so single-project plans are a no-op.
7600
+ planTargetProjects = shared.extractPlanTargetProjects(planFileContent);
7350
7601
  } catch (e) {
7351
7602
  planReadError = e;
7352
7603
  }
7353
7604
  }
7605
+ // P-9af2eb37 — a cross-repo plan-to-prd has NO singular **Project:**
7606
+ // header (declaredPlanProject === '') but ≥2 entries in the parsed
7607
+ // target list. When that happens, attach the dispatch to the first
7608
+ // listed project (read-only worktree, no mutation, no collision risk)
7609
+ // so vars.project_path resolves and worktree-requiring carve-outs
7610
+ // behave. The cross-repo case is signaled to the playbook via
7611
+ // vars.target_projects (set further down in the var-injection block).
7612
+ const isCrossRepoPlan = workType === WORK_TYPE.PLAN_TO_PRD
7613
+ && !declaredPlanProject
7614
+ && Array.isArray(planTargetProjects)
7615
+ && planTargetProjects.length >= 2;
7616
+ let crossRepoFallbackProject = null;
7617
+ let crossRepoFallbackError = null;
7618
+ if (isCrossRepoPlan) {
7619
+ const firstResolution = shared.resolveConfiguredProject(planTargetProjects[0], projects);
7620
+ if (firstResolution.project) {
7621
+ crossRepoFallbackProject = firstResolution.project;
7622
+ } else if (firstResolution.error) {
7623
+ crossRepoFallbackError = firstResolution.error;
7624
+ }
7625
+ }
7354
7626
  const requestedProjectResolution = declaredPlanProject
7355
7627
  ? shared.resolveConfiguredProject(declaredPlanProject, projects)
7356
7628
  : itemProjectResolution;
@@ -7365,7 +7637,22 @@ function discoverCentralWorkItems(config) {
7365
7637
  log('warn', `central work item ${item.id}: ${error}`);
7366
7638
  continue;
7367
7639
  }
7368
- const targetProject = requestedProjectResolution.project || (projects.length === 1 ? projects[0] : null);
7640
+ if (isCrossRepoPlan && crossRepoFallbackError && !crossRepoFallbackProject) {
7641
+ // First listed cross-repo target doesn't match any configured project
7642
+ // — fail with a clear reason instead of silently degrading to a
7643
+ // null targetProject and an empty vars.project_path.
7644
+ mutations.set(item.id, {
7645
+ status: WI_STATUS.FAILED,
7646
+ failReason: crossRepoFallbackError,
7647
+ failedAt: ts(),
7648
+ _crossRepoTargetProjects: planTargetProjects.slice(),
7649
+ });
7650
+ log('warn', `central work item ${item.id} (cross-repo plan-to-prd): ${crossRepoFallbackError}`);
7651
+ continue;
7652
+ }
7653
+ const targetProject = requestedProjectResolution.project
7654
+ || crossRepoFallbackProject
7655
+ || (projects.length === 1 ? projects[0] : null);
7369
7656
  if (declaredPlanProject) {
7370
7657
  const projectMutation = { project: targetProject.name, _declaredPlanProject: declaredPlanProject };
7371
7658
  mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, projectMutation));
@@ -7442,6 +7729,16 @@ function discoverCentralWorkItems(config) {
7442
7729
  // Notes already populated by buildWorkItemDispatchVars — no need to re-read
7443
7730
  // Track expected plan filename for artifacts and follow-up plan-to-prd prompts.
7444
7731
  mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _planFileName: planFileName }));
7732
+ // P-4d6e2af3 — surface cross-repo intent to the plan playbook.
7733
+ // dashboard.js#buildPlanWorkItem (~line 759) sets item._targetProjects
7734
+ // to a string[] when the operator picked ≥2 projects in the Create-
7735
+ // Plan modal. The playbook ({{target_projects}}, gated by P-c1f87a92)
7736
+ // renders a plural "Projects:" header + minions:targetProjects=… marker
7737
+ // so plan-to-prd can route each PRD item per-project. Single-project
7738
+ // plans leave the var unset; PLAYBOOK_OPTIONAL_VARS defaults it to ''.
7739
+ if (Array.isArray(item._targetProjects) && item._targetProjects.length > 0) {
7740
+ vars.target_projects = item._targetProjects.join(', ');
7741
+ }
7445
7742
  }
7446
7743
 
7447
7744
  // Inject plan-to-prd variables — read the plan file content for the playbook
@@ -7458,6 +7755,23 @@ function discoverCentralWorkItems(config) {
7458
7755
  vars.plan_file = item.planFile || '';
7459
7756
  vars.project_name_lower = (targetProject?.name || 'project').toLowerCase();
7460
7757
  vars.project_filename_slug = safePrdProjectSlug(targetProject?.name || 'project');
7758
+ // P-9af2eb37 — cross-repo override. When the plan has NO singular
7759
+ // **Project:** header but ≥2 entries in the parsed target list,
7760
+ // (a) the per-project slug from safePrdProjectSlug would collide
7761
+ // across sibling cross-repo PRDs dispatched on the same day
7762
+ // (every cross-repo PRD on `minions-opg`-as-fallback would
7763
+ // resolve to `minions-opg-<date>.json`); use `cross-<uid>-…`
7764
+ // instead so each cross-repo PRD gets a unique filename and
7765
+ // the disambiguation in the existing prdExisting loop below
7766
+ // never has to disambiguate cross-repo siblings.
7767
+ // (b) surface the parsed list to the plan-to-prd.md playbook via
7768
+ // vars.target_projects so the agent can route each PRD item to
7769
+ // the right repo (PLAYBOOK_OPTIONAL_VARS already includes
7770
+ // target_projects — set in P-4d6e2af3 for the plan playbook).
7771
+ if (isCrossRepoPlan) {
7772
+ vars.project_filename_slug = 'cross-' + shared.uid();
7773
+ vars.target_projects = planTargetProjects.join(', ');
7774
+ }
7461
7775
  // Default empty string so the {{existing_prd_json}} token always resolves —
7462
7776
  // playbook treats empty as "no existing PRD, fresh run". Without this default
7463
7777
  // the renderPlaybook pass logs an "unresolved template variables" warning
@@ -7603,91 +7917,116 @@ async function discoverWork(config) {
7603
7917
 
7604
7918
  // Side-effect passes: materialize plans and design docs into work-items.json
7605
7919
  // These write to project work queues — picked up by discoverFromWorkItems below.
7606
- reconcilePrdStatuses(config); // Backward-scan: correct "missing" PRD items that have done work items (#929)
7607
- materializePlansAsWorkItems(config);
7920
+ // Gated by config.engine?.planMaterializationEnabled !== false (P-d6f0a2b5) —
7921
+ // operators can suppress the PRD reconcile + plan materialization pair
7922
+ // without disabling the rest of discoverWork (e.g. when running a long
7923
+ // migration that must not auto-create work items).
7924
+ if (config.engine?.planMaterializationEnabled !== false) {
7925
+ reconcilePrdStatuses(config); // Backward-scan: correct "missing" PRD items that have done work items (#929)
7926
+ materializePlansAsWorkItems(config);
7927
+ }
7608
7928
 
7609
7929
  for (const project of projects) {
7610
7930
  const root = project.localPath ? path.resolve(project.localPath) : null;
7611
7931
  if (!root || !fs.existsSync(root)) continue;
7612
7932
 
7613
7933
  // Source 1: Pull Requests → fixes, reviews, build-test
7614
- const prWork = await discoverFromPrs(config, project);
7615
- allFixes.push(...prWork.filter(w => w.type === WORK_TYPE.FIX));
7616
- allReviews.push(...prWork.filter(w => w.type === WORK_TYPE.REVIEW));
7617
- allWorkItems.push(...prWork.filter(w => w.type === WORK_TYPE.TEST));
7934
+ // Gated by config.engine?.prDiscoveryEnabled !== false (P-d6f0a2b5) —
7935
+ // per-project project.workSources.pullRequests.enabled still composes
7936
+ // inside discoverFromPrs; a `false` at either level skips the call.
7937
+ if (config.engine?.prDiscoveryEnabled !== false) {
7938
+ const prWork = await discoverFromPrs(config, project);
7939
+ allFixes.push(...prWork.filter(w => w.type === WORK_TYPE.FIX));
7940
+ allReviews.push(...prWork.filter(w => w.type === WORK_TYPE.REVIEW));
7941
+ allWorkItems.push(...prWork.filter(w => w.type === WORK_TYPE.TEST));
7942
+ }
7618
7943
 
7619
7944
  // Side-effect: specs → work items (picked up below)
7620
7945
  materializeSpecsAsWorkItems(config, project);
7621
7946
 
7622
7947
  // Source 3: Work items (includes auto-filed from plans, design docs, build failures)
7623
- allWorkItems.push(...discoverFromWorkItems(config, project));
7948
+ // Gated by config.engine?.workItemsDiscoveryEnabled !== false (P-d6f0a2b5) —
7949
+ // per-project project.workSources.workItems.enabled still composes
7950
+ // inside discoverFromWorkItems; a `false` at either level skips the call.
7951
+ if (config.engine?.workItemsDiscoveryEnabled !== false) {
7952
+ allWorkItems.push(...discoverFromWorkItems(config, project));
7953
+ }
7624
7954
  }
7625
7955
 
7626
7956
  // Source 2: Minions-level PRD → implements (multi-project, called once outside project loop)
7627
7957
  // PRD items (prd/*.json), materialized from plans/*.md, flow through materializePlansAsWorkItems → discoverFromWorkItems
7628
7958
 
7629
7959
  // Central work items (project-agnostic — agent decides where to work)
7630
- const centralWork = discoverCentralWorkItems(config);
7960
+ // Gated by config.engine?.centralWorkDiscoveryEnabled !== false (P-d6f0a2b5) —
7961
+ // `let centralWork = []` keeps the downstream `...centralWork` spread
7962
+ // iterable when the gate is off.
7963
+ let centralWork = [];
7964
+ if (config.engine?.centralWorkDiscoveryEnabled !== false) {
7965
+ centralWork = discoverCentralWorkItems(config);
7966
+ }
7631
7967
 
7632
7968
  // Scheduled tasks (cron-style recurring work)
7633
- try {
7634
- const { discoverScheduledWork } = require('./engine/scheduler');
7635
- const scheduledWork = discoverScheduledWork(config);
7636
- if (scheduledWork.length > 0) {
7637
- const { createMeeting, getMeetings } = require('./engine/meeting');
7638
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
7639
- // Separate meetings (no work-items write) from task items
7640
- const taskItems = [];
7641
- for (const item of scheduledWork) {
7642
- if (item.type === WORK_TYPE.MEETING) {
7643
- const sched = (config.schedules || []).find(s => s.id === item._scheduleId);
7644
- const participants = (sched && sched.participants) || [];
7645
- const meeting = createMeeting({ title: item.title, agenda: item.description, participants });
7646
- log('info', `Scheduled meeting created: ${item._scheduleId} → ${meeting.id} (${participants.length} participants)`);
7647
- } else {
7648
- taskItems.push(item);
7649
- }
7650
- }
7651
- if (taskItems.length > 0) {
7652
- // Atomic write — prevents race with dispatch status updates on central work-items.json
7653
- mutateJsonFileLocked(centralPath, (items) => {
7654
- if (!Array.isArray(items)) items = [];
7655
- let added = 0;
7656
- // Snapshot active dedup keys BEFORE the loop so multiple items in the
7657
- // same harness mission (same _missionId) all land in one tick. Without
7658
- // this snapshot, the first item's push would block subsequent items
7659
- // in the same mission from joining (W-mq07a9gf000jbc2b — tri-agent
7660
- // harness mode requires Planner+Generator+Evaluator to land together).
7661
- const activeMissionIds = new Set();
7662
- const activeScheduleIds = new Set();
7663
- for (const existing of items) {
7664
- if (existing.status === WI_STATUS.DONE || existing.status === WI_STATUS.FAILED) continue;
7665
- if (existing._missionId) activeMissionIds.add(existing._missionId);
7666
- if (existing._scheduleId) activeScheduleIds.add(existing._scheduleId);
7969
+ // Gated by config.engine?.scheduledWorkDiscoveryEnabled !== false (P-d6f0a2b5).
7970
+ if (config.engine?.scheduledWorkDiscoveryEnabled !== false) {
7971
+ try {
7972
+ const { discoverScheduledWork } = require('./engine/scheduler');
7973
+ const scheduledWork = discoverScheduledWork(config);
7974
+ if (scheduledWork.length > 0) {
7975
+ const { createMeeting, getMeetings } = require('./engine/meeting');
7976
+ const centralPath = path.join(MINIONS_DIR, 'work-items.json');
7977
+ // Separate meetings (no work-items write) from task items
7978
+ const taskItems = [];
7979
+ for (const item of scheduledWork) {
7980
+ if (item.type === WORK_TYPE.MEETING) {
7981
+ const sched = (config.schedules || []).find(s => s.id === item._scheduleId);
7982
+ const participants = (sched && sched.participants) || [];
7983
+ const meeting = createMeeting({ title: item.title, agenda: item.description, participants });
7984
+ log('info', `Scheduled meeting created: ${item._scheduleId} → ${meeting.id} (${participants.length} participants)`);
7985
+ } else {
7986
+ taskItems.push(item);
7667
7987
  }
7668
- const addedScheduleIdsThisTick = new Set();
7669
- for (const item of taskItems) {
7670
- // Mission items dedup by _missionId against pre-existing rows only
7671
- // (the trio's other items added later in this loop must not block
7672
- // each other). Plain scheduled items keep the original scheduleId
7673
- // dedup AND skip if a sibling item from the same tick already
7674
- // claimed the schedule slot.
7675
- if (item._missionId) {
7676
- if (activeMissionIds.has(item._missionId)) continue;
7677
- } else {
7678
- if (activeScheduleIds.has(item._scheduleId)) continue;
7679
- if (addedScheduleIdsThisTick.has(item._scheduleId)) continue;
7988
+ }
7989
+ if (taskItems.length > 0) {
7990
+ // Atomic write — prevents race with dispatch status updates on central work-items.json
7991
+ mutateJsonFileLocked(centralPath, (items) => {
7992
+ if (!Array.isArray(items)) items = [];
7993
+ let added = 0;
7994
+ // Snapshot active dedup keys BEFORE the loop so multiple items in the
7995
+ // same harness mission (same _missionId) all land in one tick. Without
7996
+ // this snapshot, the first item's push would block subsequent items
7997
+ // in the same mission from joining (W-mq07a9gf000jbc2b — tri-agent
7998
+ // harness mode requires Planner+Generator+Evaluator to land together).
7999
+ const activeMissionIds = new Set();
8000
+ const activeScheduleIds = new Set();
8001
+ for (const existing of items) {
8002
+ if (existing.status === WI_STATUS.DONE || existing.status === WI_STATUS.FAILED) continue;
8003
+ if (existing._missionId) activeMissionIds.add(existing._missionId);
8004
+ if (existing._scheduleId) activeScheduleIds.add(existing._scheduleId);
7680
8005
  }
7681
- items.push(item);
7682
- if (!item._missionId && item._scheduleId) addedScheduleIdsThisTick.add(item._scheduleId);
7683
- added++;
7684
- log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
7685
- }
7686
- return items;
7687
- }, { defaultValue: [] });
8006
+ const addedScheduleIdsThisTick = new Set();
8007
+ for (const item of taskItems) {
8008
+ // Mission items dedup by _missionId against pre-existing rows only
8009
+ // (the trio's other items added later in this loop must not block
8010
+ // each other). Plain scheduled items keep the original scheduleId
8011
+ // dedup AND skip if a sibling item from the same tick already
8012
+ // claimed the schedule slot.
8013
+ if (item._missionId) {
8014
+ if (activeMissionIds.has(item._missionId)) continue;
8015
+ } else {
8016
+ if (activeScheduleIds.has(item._scheduleId)) continue;
8017
+ if (addedScheduleIdsThisTick.has(item._scheduleId)) continue;
8018
+ }
8019
+ items.push(item);
8020
+ if (!item._missionId && item._scheduleId) addedScheduleIdsThisTick.add(item._scheduleId);
8021
+ added++;
8022
+ log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
8023
+ }
8024
+ return items;
8025
+ }, { defaultValue: [] });
8026
+ }
7688
8027
  }
7689
- }
7690
- } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
8028
+ } catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
8029
+ }
7691
8030
 
7692
8031
  // Meeting work (multi-round team discussions)
7693
8032
  try {
@@ -7768,15 +8107,37 @@ async function discoverWork(config) {
7768
8107
 
7769
8108
  const allWork = [...allFixes, ...allReviews, ...allWorkItems, ...centralWork];
7770
8109
 
7771
- for (const item of allWork) {
7772
- await addToDispatchWithValidation(item, { config });
7773
- // W-mph8xt88000ke0fc — Cooldowns are stamped by addToDispatch ONLY on
7774
- // successful append (post-dedup). Stamping unconditionally here used to
7775
- // leave orphan cooldowns for items collapsed by prDedupeKey / workItem-id
7776
- // / dispatchKey dedup or routed to the pre-dispatch review queue.
7777
- if (item.meta?.source === 'pr-human-feedback') {
7778
- clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
7779
- }
8110
+ // W-mq9acoo800177bcb — bounded-concurrency pre-dispatch eval.
8111
+ // The validator inside addToDispatchWithValidation runs a ~25-30s LLM
8112
+ // call per item; an approved 11-item PRD used to take ~5 min to queue
8113
+ // because we awaited each item in series. Process items in chunks of
8114
+ // `preDispatchEvalConcurrency` (default 6, clamp 1-20) via
8115
+ // Promise.allSettled so a rejected branch in one item still fails open
8116
+ // and the rest of the chunk continues. Iteration order within a chunk
8117
+ // is irrelevant: addToDispatch's dedup gates are mutateDispatch-locked,
8118
+ // and the only per-item side effect (`clearPendingHumanFeedbackFlag`)
8119
+ // is an idempotent flag-clear on a single PR record.
8120
+ const evalConcurrency = resolvePreDispatchEvalConcurrency(config);
8121
+ for (let i = 0; i < allWork.length; i += evalConcurrency) {
8122
+ const chunk = allWork.slice(i, i + evalConcurrency);
8123
+ await Promise.allSettled(chunk.map(async (item) => {
8124
+ try {
8125
+ await addToDispatchWithValidation(item, { config });
8126
+ } catch (e) {
8127
+ // Fail-open contract preserved: any validator throw here means the
8128
+ // item is dropped from this discovery tick rather than wedging the
8129
+ // whole chunk. The item stays `pending` in work-items.json and the
8130
+ // next tick re-discovers it.
8131
+ log('warn', `pre-dispatch-eval: addToDispatchWithValidation threw for ${item?.meta?.item?.id || item?.id || 'unknown'}: ${e?.message || e}`);
8132
+ }
8133
+ // W-mph8xt88000ke0fc — Cooldowns are stamped by addToDispatch ONLY on
8134
+ // successful append (post-dedup). Stamping unconditionally here used to
8135
+ // leave orphan cooldowns for items collapsed by prDedupeKey / workItem-id
8136
+ // / dispatchKey dedup or routed to the pre-dispatch review queue.
8137
+ if (item.meta?.source === 'pr-human-feedback') {
8138
+ clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
8139
+ }
8140
+ }));
7780
8141
  }
7781
8142
 
7782
8143
  if (allWork.length > 0) {
@@ -7873,6 +8234,20 @@ function resolveMaxConcurrent(config) {
7873
8234
  return Number.isFinite(value) && value >= 0 ? value : ENGINE_DEFAULTS.maxConcurrent;
7874
8235
  }
7875
8236
 
8237
+ // W-mq9acoo800177bcb — clamp the per-tick pre-dispatch validator concurrency.
8238
+ // Read-side mirror of the [1, 20] clamp dashboard.js#handleSettingsUpdate
8239
+ // applies on writes, so a hand-edited config.json or an engine-only test
8240
+ // fixture can't smuggle in a value that DOSes the LLM provider or starves
8241
+ // the rest of the tick. Falls back to ENGINE_DEFAULTS.preDispatchEvalConcurrency
8242
+ // for unset / non-finite / out-of-range values.
8243
+ function resolvePreDispatchEvalConcurrency(config) {
8244
+ const raw = config?.engine?.preDispatchEvalConcurrency;
8245
+ if (raw === undefined || raw === null || raw === '') return ENGINE_DEFAULTS.preDispatchEvalConcurrency;
8246
+ const value = Number(raw);
8247
+ if (!Number.isFinite(value)) return ENGINE_DEFAULTS.preDispatchEvalConcurrency;
8248
+ return Math.max(1, Math.min(20, Math.floor(value)));
8249
+ }
8250
+
7876
8251
  // ─── Main Tick ──────────────────────────────────────────────────────────────
7877
8252
 
7878
8253
  let tickCount = 0;
@@ -8152,8 +8527,45 @@ async function tickInner() {
8152
8527
  });
8153
8528
  }
8154
8529
 
8155
- const adoPollEnabled = config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled;
8156
- const ghPollEnabled = config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled;
8530
+ // P-a1f3c2d4: engine.pollingPaused is a hard-stop master switch — it overrides
8531
+ // both per-provider toggles. When ON, adoPollEnabled and ghPollEnabled are
8532
+ // forced to false so the status-poll and human-comment-poll gates below
8533
+ // short-circuit without editing every per-call gate. Reconciliation (the
8534
+ // final block below) is NOT gated — it's a recovery sweep, not a
8535
+ // convenience poll. The same gate is mirrored inside discoverFromPrs.
8536
+ const pollingPaused = config.engine?.pollingPaused === true;
8537
+ if (pollingPaused && _shouldLogPollingPausedOnce()) {
8538
+ log('info', '[engine] PR polling paused — engine.pollingPaused=true overrides adoPollEnabled/ghPollEnabled');
8539
+ } else if (!pollingPaused) {
8540
+ _resetPollingPausedLogState();
8541
+ }
8542
+ // P-b2e5d8c7: log once on the transition unpaused → paused for autoFixPaused
8543
+ // too. The gate itself is enforced inside discoverFromPrs (composed into
8544
+ // autoFixBuilds / autoFixConflicts / autoFixReviewFeedback /
8545
+ // autoFixHumanComments). Same throttling contract as pollingPaused above.
8546
+ const autoFixPaused = config.engine?.autoFixPaused === true;
8547
+ if (autoFixPaused && _shouldLogAutoFixPausedOnce()) {
8548
+ log('info', '[engine] auto-fix paused — engine.autoFixPaused=true inerts autoFixBuilds/autoFixConflicts/autoFixReviewFeedback/autoFixHumanComments');
8549
+ } else if (!autoFixPaused) {
8550
+ _resetAutoFixPausedLogState();
8551
+ }
8552
+ const adoPollEnabled = !pollingPaused && (config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled);
8553
+ const ghPollEnabled = !pollingPaused && (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled);
8554
+ // P-c4d8e1a3 — granular per-poller flags. Each of the 6 PR-poll axes
8555
+ // (ADO/GH × status/comments/reconcile) plus processPendingRebases gets
8556
+ // its own resolution via shared.resolvePollFlag(...). Explicit granular
8557
+ // values win; the legacy `adoPollEnabled` / `ghPollEnabled` macros
8558
+ // silence their whole bundle as a fallback (including reconcile — a
8559
+ // documented breaking change). Defaults: all true. pollingPaused (master
8560
+ // killswitch) still composes with status + comments gates; reconcile and
8561
+ // the rebase processor are recovery-sweep paths and are NOT gated by it.
8562
+ const adoStatusPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'adoPrStatusPollEnabled', 'adoPollEnabled');
8563
+ const ghStatusPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'ghPrStatusPollEnabled', 'ghPollEnabled');
8564
+ const adoCommentsPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'adoPrCommentsPollEnabled', 'adoPollEnabled');
8565
+ const ghCommentsPollEnabled = !pollingPaused && resolvePollFlag(config.engine, 'ghPrCommentsPollEnabled', 'ghPollEnabled');
8566
+ const adoReconcileEnabled = resolvePollFlag(config.engine, 'adoPrReconcileEnabled', 'adoPollEnabled');
8567
+ const ghReconcileEnabled = resolvePollFlag(config.engine, 'ghPrReconcileEnabled', 'ghPollEnabled');
8568
+ const rebaseProcessorEnabled = resolvePollFlag(config.engine, 'processPendingRebasesEnabled', null);
8157
8569
  const prPollStatusEvery = Math.max(
8158
8570
  1,
8159
8571
  Number(config.engine?.prPollStatusEvery) || ENGINE_DEFAULTS.prPollStatusEvery
@@ -8173,7 +8585,7 @@ async function tickInner() {
8173
8585
  lastPrStatusPollAt = now;
8174
8586
  // Build promise array — enabled+unthrottled polls run concurrently via Promise.allSettled
8175
8587
  const statusPolls = [];
8176
- if (adoPollEnabled) {
8588
+ if (adoStatusPollEnabled) {
8177
8589
  // Per-org throttle skip happens inside forEachActivePr (one log line per skipped project).
8178
8590
  // Top-level short-circuit: when every known ADO org is throttled, skip the whole phase
8179
8591
  // with one log line to avoid the per-project iteration cost.
@@ -8186,14 +8598,16 @@ async function tickInner() {
8186
8598
  statusPolls.push(pollPrStatus(config).catch(err => { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8187
8599
  }
8188
8600
  }
8189
- if (ghPollEnabled && !isGhThrottled()) {
8601
+ if (ghStatusPollEnabled && !isGhThrottled()) {
8190
8602
  statusPolls.push(ghPollPrStatus(config).catch(err => { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8191
- } else if (ghPollEnabled && isGhThrottled()) {
8603
+ } else if (ghStatusPollEnabled && isGhThrottled()) {
8192
8604
  log('info', '[gh] PR status poll skipped — throttled');
8193
8605
  }
8194
8606
  if (statusPolls.length) await Promise.allSettled(statusPolls);
8195
8607
  if (_isTickStale(myGeneration)) return;
8196
- try { await processPendingRebases(config); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
8608
+ if (rebaseProcessorEnabled) {
8609
+ try { await processPendingRebases(config); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
8610
+ }
8197
8611
  if (_isTickStale(myGeneration)) return;
8198
8612
  // Sync PR status back to PRD items (missing → done when active PR exists)
8199
8613
  try { syncPrdFromPrs(config); } catch (err) { log('warn', `PRD sync error: ${err?.message || err}`); }
@@ -8227,7 +8641,7 @@ async function tickInner() {
8227
8641
  lastPrCommentsPollAt = now;
8228
8642
  // Build promise array — enabled+unthrottled comment polls run concurrently via Promise.allSettled
8229
8643
  const commentPolls = [];
8230
- if (adoPollEnabled) {
8644
+ if (adoCommentsPollEnabled) {
8231
8645
  // Per-org throttle skip happens inside forEachActivePr (one log line per skipped project).
8232
8646
  // Top-level short-circuit: when every known ADO org is throttled, skip the whole phase
8233
8647
  // with one log line to avoid the per-project iteration cost.
@@ -8240,20 +8654,41 @@ async function tickInner() {
8240
8654
  commentPolls.push(pollPrHumanComments(config).catch(err => { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8241
8655
  }
8242
8656
  }
8243
- if (ghPollEnabled && !isGhThrottled()) {
8657
+ if (ghCommentsPollEnabled && !isGhThrottled()) {
8244
8658
  commentPolls.push(ghPollPrHumanComments(config).catch(err => { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8245
- } else if (ghPollEnabled && isGhThrottled()) {
8659
+ } else if (ghCommentsPollEnabled && isGhThrottled()) {
8246
8660
  log('info', '[gh] PR comment poll skipped — throttled');
8247
8661
  }
8248
8662
  if (commentPolls.length) await Promise.allSettled(commentPolls);
8249
8663
  if (_isTickStale(myGeneration)) return;
8250
- // Reconciliation runs regardless of poll flags — it's a recovery sweep, not a convenience poll
8251
- // Reconciliation also parallelized — ADO and GitHub reconciliation are independent
8664
+ // P-c4d8e1a3: reconcile gated by *PrReconcileEnabled (default true) — ungated
8665
+ // by ADO/GH throttle trackers (recovery-sweep contract intact). Legacy
8666
+ // adoPollEnabled/ghPollEnabled=false now also silences reconcile via
8667
+ // resolvePollFlag's legacy-macro fallback (documented breaking change).
8668
+ // ADO and GitHub reconciliation are independent and run in parallel.
8252
8669
  const reconcilePolls = [];
8253
- reconcilePolls.push(reconcilePrs(config).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8254
- reconcilePolls.push(ghReconcilePrs(config).catch(err => { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8255
- await Promise.allSettled(reconcilePolls);
8670
+ if (adoReconcileEnabled) {
8671
+ reconcilePolls.push(reconcilePrs(config).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8672
+ }
8673
+ if (ghReconcileEnabled) {
8674
+ reconcilePolls.push(ghReconcilePrs(config).catch(err => { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
8675
+ }
8676
+ if (reconcilePolls.length) await Promise.allSettled(reconcilePolls);
8256
8677
  if (_isTickStale(myGeneration)) return;
8678
+
8679
+ // W-mqba5ulq000nd255 — Cross-project PR scope-mismatch sweep. Cleans up
8680
+ // stale records that were stamped with `_invalidProjectScope` and have a
8681
+ // sibling in the correctly-scoped project. Cheap (a single read of all
8682
+ // PRs + a per-affected-project mutation). Runs once per reconcile tick;
8683
+ // sibling-less stamps are preserved as tracking signals.
8684
+ try {
8685
+ const result = pruneScopeMismatchDuplicatePrs(config);
8686
+ if (result?.pruned > 0) {
8687
+ log('info', `[pull-requests] scope-mismatch sweep pruned ${result.pruned} duplicate record(s) across ${result.scanned} scanned`);
8688
+ }
8689
+ } catch (err) {
8690
+ log('warn', `[pull-requests] scope-mismatch sweep error: ${err?.message || err}`);
8691
+ }
8257
8692
  }
8258
8693
 
8259
8694
  // 2.9. Stalled dispatch detection — auto-retry failed items blocking the graph (~20 min — cadence in ENGINE_DEFAULTS.stalledDispatchSweepEvery)
@@ -8793,6 +9228,29 @@ async function tickInner() {
8793
9228
  if (!discoveryOk) {
8794
9229
  log('warn', 'Discovery failed after pending dispatch pass — new work may be stale until next tick');
8795
9230
  }
9231
+
9232
+ // 6. Periodic memory baseline (P-b2c3d4e5).
9233
+ // Sample once every ENGINE_DEFAULTS.memoryBaselineEveryTicks ticks (default
9234
+ // 6 ≈ 60s at the default 10s tickInterval). When the cadence is <= 0,
9235
+ // sampling is disabled cleanly — no log emission, no sidecar write
9236
+ // (operator opt-out). The diagnostics-memory.json sidecar is a passive
9237
+ // cache (single-object latest sample) — gitignored and exempt from the
9238
+ // SQL-first state rule, same as engine/dashboard-port.json.
9239
+ safe('memoryBaseline', () => emitMemoryBaseline(tickCount));
9240
+ }
9241
+
9242
+ function emitMemoryBaseline(tickN) {
9243
+ const every = Number(ENGINE_DEFAULTS.memoryBaselineEveryTicks);
9244
+ if (!Number.isFinite(every) || every <= 0) return;
9245
+ if ((tickN % every) !== 0) return;
9246
+ const sample = diagnosticsMemory.sampleSelf({ label: 'engine' });
9247
+ diagnosticsMemory.recordSample(sample);
9248
+ try { safeWrite(DIAGNOSTICS_MEMORY_PATH, sample); }
9249
+ catch (e) { log('warn', `memoryBaseline sidecar write: ${e.message}`); }
9250
+ log('info',
9251
+ `MEMORY_BASELINE engine rss=${sample.rss} heapUsed=${sample.heapUsed} ` +
9252
+ `eventLoopLagP99=${sample.eventLoopLagP99.toFixed(2)}ms ` +
9253
+ `gcPauses=${sample.gcCount}/${sample.gcPausesTotalMs.toFixed(2)}ms tickN=${tickN}`);
8796
9254
  }
8797
9255
 
8798
9256
  // ─── Exports (for engine/cli.js and other modules) ──────────────────────────
@@ -8832,6 +9290,7 @@ module.exports = {
8832
9290
  reconcileItemsWithPrs, detectDependencyCycles,
8833
9291
  areDependenciesMet, // exported for testing (P-bf04-decompose-zero-children)
8834
9292
  parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
9293
+ resolveDependencyBranches, buildCrossRepoDepsSection, // exported for testing (P-faea3206)
8835
9294
  gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
8836
9295
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
8837
9296
  isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
@@ -8866,8 +9325,12 @@ module.exports = {
8866
9325
  // Tick
8867
9326
  tick,
8868
9327
  resolveMaxConcurrent, _pollIntervalMsFromTicks, _shouldRunPeriodicPhase, // exported for testing
9328
+ // W-mq9acoo800177bcb — exported for testing the clamped concurrency resolver
9329
+ resolvePreDispatchEvalConcurrency,
8869
9330
  // P-c2e5a1d9-a — exported for testing the tick-generation force-release path
8870
9331
  _isTickStale,
9332
+ // P-b2c3d4e5 — exported for testing the memory baseline emitter + sidecar path
9333
+ emitMemoryBaseline, DIAGNOSTICS_MEMORY_PATH,
8871
9334
  get tickGeneration() { return tickGeneration; },
8872
9335
  set tickGeneration(v) { tickGeneration = v; },
8873
9336
  get tickRunning() { return tickRunning; },