/**
 * engine/cleanup.js — Periodic cleanup: temp files, worktrees, zombies, migrations.
 * Extracted from engine.js for modularity. No logic changes.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const queries = require('./queries');

const { exec, execAsync, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
const { safeJson, safeJsonNoRestore, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
  sanitizeBranch, KB_CATEGORIES } = shared;
const { getDispatch, getAgentStatus } = queries;

const MINIONS_DIR = shared.MINIONS_DIR;
const AGENTS_DIR = queries.AGENTS_DIR;
const ENGINE_DIR = queries.ENGINE_DIR;
const PRD_DIR = queries.PRD_DIR;
const PLANS_DIR = queries.PLANS_DIR;

// Lazy require to break circular dependency with engine.js
// Only needed for engine().activeProcesses — log/ts come from shared.js
let _engine = null;
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }

// Lazy require for dispatch module
let _dispatch = null;
function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }

// Lazy require for worktree-pool — used to protect pool entries from age/cap
// sweeps (W-mp73ya3e000me6c5). Borrowed entries with live dispatches are
// always protected; idle entries survive the age sweep but stay eligible for
// the cap sweep so MAX_WORKTREES is still honored.
let _worktreePool = null;
function worktreePool() {
  if (!_worktreePool) _worktreePool = require('./worktree-pool');
  return _worktreePool;
}

// ─── Helpers ────────────────────────────────────────────────────────────────

/**
 * Check if a worktree directory name matches a branch via sanitized slug comparison.
 * Eliminates 3x duplication of the branch matching logic (review feedback: Rebecca).
 */
function worktreeDirMatchesBranch(dirLower, branch) {
  const branchSlug = sanitizeBranch(branch).toLowerCase();
  return dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug);
}

function worktreeBranchMatches(actualBranch, branch) {
  if (!actualBranch || !branch) return false;
  return sanitizeBranch(actualBranch).toLowerCase() === sanitizeBranch(branch).toLowerCase();
}

function worktreeMatchesBranch(dirLower, branch, actualBranch = '') {
  return worktreeBranchMatches(actualBranch, branch) || worktreeDirMatchesBranch(dirLower, branch);
}

function normalizeLocalBranchName(branch) {
  return String(branch || '').trim().replace(/^refs\/heads\//i, '');
}

function isSafeLocalBranchName(branch) {
  if (!branch || branch !== sanitizeBranch(branch)) return false;
  if (branch.startsWith('-') || branch.includes('..') || branch.includes('@{')) return false;
  if (branch.endsWith('/') || branch.endsWith('.lock')) return false;
  return branch.split('/').every(part => part && part !== '.' && part !== '..' && !part.endsWith('.lock'));
}

function isProtectedLocalBranch(branch, project = {}) {
  const protectedBranches = new Set(['main', 'master', 'trunk', 'develop', 'development', 'head']);
  const configuredMain = normalizeLocalBranchName(project.mainBranch);
  if (configuredMain) protectedBranches.add(configuredMain.toLowerCase());
  return protectedBranches.has(branch.toLowerCase());
}

function localBranchWorktreeInUse(root, branch) {
  try {
    const raw = String(shared.execSilent('git worktree list --porcelain', {
      cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
    }) || '');
    return shared.parseWorktreePorcelain(raw).some(w => w.branch === branch);
  } catch {
    // Defensive: assume "in use" so the caller doesn't proceed to delete a
    // branch when worktree state can't be enumerated.
    return true;
  }
}

// P-e0b4f7a5 — collect branches of work items currently in the
// phantom-completion retry state for a given project. Returns a Set of
// branch strings. Used by the worktree cleanup loop to protect worktrees
// belonging to in-flight phantom retries from the 2-hour age sweep —
// without this protection the agent's pushed branch reference could be
// destroyed alongside the worktree before the retry runs.
function collectPhantomBranchesForProject(project) {
  const branches = new Set();
  try {
    const items = safeJson(projectWorkItemsPath(project)) || [];
    if (!Array.isArray(items)) return branches;
    for (const w of items) {
      if (w && w._phantomCompletion === true && w._phantomBranch) {
        branches.add(String(w._phantomBranch));
      }
    }
  } catch { /* best-effort — never let cleanup crash on a missing/corrupt WI file */ }
  return branches;
}

function cleanupMergedPrLocalBranch(root, project, pr) {
  const branch = normalizeLocalBranchName(pr?.branch);
  const result = { deleted: false, forced: false, skipped: null };
  if (pr?.status !== shared.PR_STATUS.MERGED) { result.skipped = 'not-merged'; return result; }
  if (!root || !branch) { result.skipped = 'missing-branch'; return result; }
  if (!isSafeLocalBranchName(branch)) { result.skipped = 'unsafe-branch-name'; return result; }
  if (isProtectedLocalBranch(branch, project)) { result.skipped = 'protected-branch'; return result; }

  try {
    const current = String(shared.execSilent('git branch --show-current', {
      cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
    }) || '').trim();
    if (current === branch) { result.skipped = 'current-branch'; return result; }
  } catch {
    result.skipped = 'current-branch-unknown';
    return result;
  }

  if (localBranchWorktreeInUse(root, branch)) { result.skipped = 'branch-in-worktree'; return result; }

  let localHead = '';
  try {
    localHead = String(shared.execSilent(`git rev-parse --verify "refs/heads/${branch}"`, {
      cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
    }) || '').trim();
  } catch {
    result.skipped = 'missing-local-branch';
    return result;
  }

  try {
    shared.execSilent(`git branch -d -- "${branch}"`, {
      cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 15000, windowsHide: true,
    });
    log('info', `Post-merge cleanup: deleted local branch ${branch}`);
    return { deleted: true, forced: false, skipped: null };
  } catch (deleteErr) {
    const localHeadLower = localHead.toLowerCase();
    // Only use -D when the local tip still matches the merged PR head (or its remote-tracking ref);
    // otherwise a reused local branch could lose unrelated work after the PR merged.
    const proofHeads = [pr.headSha, pr._adoSourceCommit, pr.sourceCommit]
      .map(v => String(v || '').trim().toLowerCase())
      .filter(Boolean);
    let safeToForce = proofHeads.includes(localHeadLower);
    if (!safeToForce) {
      try {
        const remoteHead = String(shared.execSilent(`git rev-parse --verify "refs/remotes/origin/${branch}"`, {
          cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 10000, windowsHide: true,
        }) || '').trim().toLowerCase();
        safeToForce = !!remoteHead && remoteHead === localHeadLower;
      } catch { /* no matching remote-tracking branch */ }
    }
    if (!safeToForce) {
      result.skipped = 'unproven-force-delete';
      return result;
    }
    try {
      shared.execSilent(`git branch -D -- "${branch}"`, {
        cwd: root, encoding: 'utf8', stdio: 'pipe', timeout: 15000, windowsHide: true,
      });
      log('info', `Post-merge cleanup: force-deleted local branch ${branch} after merged PR confirmation`);
      return { deleted: true, forced: true, skipped: null };
    } catch (forceErr) {
      log('warn', `Post-merge cleanup: failed to delete local branch ${branch}: ${forceErr.message || deleteErr.message}`);
      result.skipped = 'delete-failed';
      return result;
    }
  }
}

/**
 * Sweep leaked test-fixture meetings from a `meetings/` directory.
 *
 * Meeting unit/integration tests don't all honor MINIONS_TEST_DIR
 * (engine/meeting.js is in ISOLATED_MODULES, but tests that load it after the
 * env var is unset, or that fail before cleanup, leave `.json` + `.json.backup`
 * sidecars behind). The engine's tick loop then re-discovers these fixtures
 * every tick, fails playbook validation ("missing required template variables:
 * agenda"), and spams log.json with the same error.
 *
 * Filter is conservative: only files whose ID begins with `TEST-` AND whose
 * JSON either lacks an `agenda` or has an empty/whitespace-only one. Both the
 * live `.json` and any `.json.backup` sidecar (which would otherwise
 * auto-restore the file via safeJson on next read) are removed together.
 *
 * Returns the number of files unlinked.
 */
// ─── Stale .backup sidecar prune (P-c91d4f76) ──────────────────────────────
//
// safeWrite() writes a `.backup` next to every JSON state file it touches,
// and safeJsonNoRestore (W-bfa1d) stopped the read path from resurrecting
// them after the SQL Phase 0–7 migration shipped. Nothing else prunes
// them, so they accumulate. This step runs in the existing 10-tick
// cleanup phase and deletes only `.backup` sidecars — never the live
// `.json` — and only when SQLite is healthy so backups remain the legacy
// fallback for users on Node < 22.5.
//
// SQL_BACKED maps every engine-dir state file with a known SQL backing
// table to that table; safe-by-age files have no SQL backing but their
// .backup sidecars are equally useless once SQLite is the authority.

const ENGINE_SQL_BACKED_BACKUPS = [
  { file: 'dispatch.json', table: 'dispatches' },
  { file: 'metrics.json', table: 'metrics' },
  { file: 'watches.json', table: 'watches' },
  { file: 'schedule-runs.json', table: 'schedule_runs' },
  { file: 'pipeline-runs.json', table: 'pipeline_runs' },
  { file: 'managed-processes.json', table: 'managed_processes' },
  { file: 'log.json', table: 'logs' },
];

const ENGINE_SAFE_BY_AGE_BACKUPS = [
  'cc-sessions.json',
  'control.json',
  'cooldowns.json',
  'dashboard-browser.json',
  'doc-sessions.json',
  'kb-pins.json',
  'pending-rebases.json',
  'pr-links.json',
  'qa-sessions.json',
];

const MEETINGS_BACKUP_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000;

function _tableHasRow(db, table) {
  try {
    const row = db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get();
    return !!row;
  } catch { return false; }
}

function _tryPruneBackup(liveFile, pruned) {
  const backupPath = liveFile + '.backup';
  if (!fs.existsSync(backupPath)) return;
  if (!fs.existsSync(liveFile)) return; // never prune .backup when live is missing
  try {
    fs.unlinkSync(backupPath);
    pruned.count += 1;
  } catch { /* per-file failures must not abort the sweep */ }
}

function pruneStaleBackupSidecars(config) {
  const pruned = { count: 0 };
  let db;
  try { db = require('./db').getDb(); }
  catch { return pruned; } // SQLite unavailable — keep backups as the legacy fallback

  // (1a) SQL-backed engine state files — require the table to be populated.
  for (const { file, table } of ENGINE_SQL_BACKED_BACKUPS) {
    if (!_tableHasRow(db, table)) continue;
    _tryPruneBackup(path.join(ENGINE_DIR, file), pruned);
  }

  // (1b) Per-project work-items.json + pull-requests.json — both share
  // global SQL tables, so populate-check those tables once.
  const wiHasRow = _tableHasRow(db, 'work_items');
  const prHasRow = _tableHasRow(db, 'pull_requests');
  if (wiHasRow || prHasRow) {
    for (const project of getProjects(config)) {
      try {
        if (wiHasRow) _tryPruneBackup(projectWorkItemsPath(project), pruned);
        if (prHasRow) _tryPruneBackup(projectPrPath(project), pruned);
      } catch { /* malformed project entry — skip */ }
    }
  }

  // (1c) Safe-by-age set — no SQL table backing, but the .backup sidecars
  // are equally useless once SQLite is healthy.
  for (const file of ENGINE_SAFE_BY_AGE_BACKUPS) {
    _tryPruneBackup(path.join(ENGINE_DIR, file), pruned);
  }

  // (2) Meetings: prune MTG-*.json.backup only if the live sibling exists
  // AND was modified within the last 24h. Skip otherwise — possible mid-
  // rename / archival state where the sidecar is still load-bearing.
  const meetingsDir = path.join(MINIONS_DIR, 'meetings');
  if (fs.existsSync(meetingsDir)) {
    const freshCutoff = Date.now() - MEETINGS_BACKUP_FRESH_WINDOW_MS;
    let entries = [];
    try { entries = fs.readdirSync(meetingsDir); } catch { entries = []; }
    for (const name of entries) {
      if (!name.startsWith('MTG-') || !name.endsWith('.json.backup')) continue;
      const backupPath = path.join(meetingsDir, name);
      const livePath = backupPath.slice(0, -'.backup'.length);
      let liveStat;
      try { liveStat = fs.statSync(livePath); }
      catch { continue; } // no live sibling — leave .backup alone
      if (!liveStat.isFile()) continue;
      if (liveStat.mtimeMs < freshCutoff) continue;
      try {
        fs.unlinkSync(backupPath);
        pruned.count += 1;
      } catch { /* per-file failures must not abort the sweep */ }
    }
  }

  return pruned.count;
}

function sweepLeakedTestMeetings(meetingsDir) {
  let cleaned = 0;
  try {
    if (!fs.existsSync(meetingsDir)) return 0;
    const candidates = new Set();
    for (const f of fs.readdirSync(meetingsDir)) {
      if (!f.startsWith('TEST-')) continue;
      // Match "<id>.json" or "<id>.json.backup" — both belong to the same fixture
      const idMatch = f.match(/^(TEST-[^/]+?)\.json(\.backup)?$/);
      if (!idMatch) continue;
      candidates.add(idMatch[1]);
    }
    for (const id of candidates) {
      const livePath = path.join(meetingsDir, `${id}.json`);
      const backupPath = `${livePath}.backup`;
      // Decide whether to delete based on the live file's contents, falling
      // back to the .backup sidecar if the live file is missing/corrupt.
      let agendaPresent = false;
      let saw = false;
      for (const candidatePath of [livePath, backupPath]) {
        try {
          const data = JSON.parse(fs.readFileSync(candidatePath, 'utf8'));
          saw = true;
          if (data && typeof data.agenda === 'string' && data.agenda.trim()) {
            agendaPresent = true;
            break;
          }
        } catch { /* missing or corrupt — try next candidate */ }
      }
      // Delete only when we confirmed the fixture exists AND has no usable
      // agenda. If neither file parses (saw=false), the directory entry is
      // already useless; unlink both so safeJson can't resurrect it.
      if (saw && agendaPresent) continue;
      for (const target of [livePath, backupPath]) {
        try { fs.unlinkSync(target); cleaned++; } catch { /* not present */ }
      }
    }
    if (cleaned > 0) {
      log('info', `Cleaned ${cleaned} leaked test-fixture meeting file(s) from ${meetingsDir}`);
    }
  } catch (e) { log('warn', 'cleanup leaked test meetings: ' + e.message); }
  return cleaned;
}

function getWorktreeBranch(wtPath) {
  try {
    return exec(`git -C "${wtPath}" branch --show-current`, { encoding: 'utf8', stdio: 'pipe', timeout: 5000, windowsHide: true }).trim();
  } catch {
    return '';
  }
}

async function getWorktreeBranchAsync(wtPath) {
  try {
    const out = await execAsync(`git -C "${wtPath}" branch --show-current`, { encoding: 'utf8', timeout: 5000 });
    return (out || '').toString().trim();
  } catch {
    return '';
  }
}

/**
 * Kill orphaned processes whose dispatch ID appears in the worktree dir name.
 * Only kills processes NOT in the active dispatch queue — never kills live agents.
 */
function _killProcessInWorktree(dir, activeProcesses, activeIds) {
  const dirLower = dir.toLowerCase();

  // Check tracked in-memory processes — only kill if dispatch is no longer active
  for (const [id, info] of activeProcesses.entries()) {
    if (!dirLower.includes(id.toLowerCase().slice(-8))) continue;
    if (activeIds.has(id)) continue; // still active — do not kill
    try { shared.killImmediate(info.proc); } catch {}
    activeProcesses.delete(id);
    log('info', `Killed orphaned process for dispatch ${id} before worktree removal`);
  }

  // Check PID files in engine/tmp/ — both legacy flat layout and per-dispatch
  // dirs (P-f6-tmp-toctou). Only kill if no active dispatch matches.
  try {
    shared.forEachPidFile((pidFilePath, fileName, layout) => {
      const pidFileName = fileName.replace(/^pid-/, '').replace(/\.pid$/, '');
      if (!dirLower.includes(pidFileName.slice(-8))) return;
      // Verify this PID file's dispatch is not active
      let isActive = false;
      for (const id of activeIds) { if (pidFileName.includes(id.slice(-8))) { isActive = true; break; } }
      if (isActive) return; // still active — do not kill
      let pid;
      try { pid = parseInt(fs.readFileSync(pidFilePath, 'utf8').trim(), 10); }
      catch { return; }
      if (pid > 0) {
        // Verify the PID still belongs to a Minions runtime process before killing.
        // The shared helper inspects the PID's full command line for `claude` /
        // `copilot` so a recycled PID running an unrelated process is skipped.
        try {
          if (process.platform === 'win32') {
            if (!shared.isProcessCommandLineMatchingAgent(pid)) return;
            exec(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe', timeout: 5000, windowsHide: true });
          } else {
            if (!shared.isProcessCommandLineMatchingAgent(pid)) return;
            try { process.kill(-pid, 'SIGKILL'); } catch { process.kill(pid, 'SIGKILL'); }
          }
          log('info', `Killed orphaned PID ${pid} (${fileName}, ${layout}) before worktree removal`);
        } catch {} // process may already be dead
      }
      if (layout === 'dispatch-dir') {
        // Remove the entire per-dispatch dir — its remaining sidecars are
        // orphans of the same dead process.
        try { shared.removeDispatchTmpDir(path.dirname(pidFilePath)); } catch {}
      } else {
        try { fs.unlinkSync(pidFilePath); } catch {}
      }
    });
  } catch {} // tmp dir may not exist
}

// ─── Cleanup Orchestrator ────────────────────────────────────────────────────

async function runCleanup(config, verbose = false) {
  const activeProcesses = engine().activeProcesses;
  const projects = getProjects(config);
  let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };

  // 1. Clean stale temp prompt/sysprompt files and orphaned safeWrite .tmp.* files (older than 1 hour)
  // P-f6-tmp-toctou: also sweep abandoned per-dispatch dirs (engine/tmp/dispatch-*),
  // and recurse into them so leftover prompt/sysprompt sidecars from crashed
  // dispatches don't accumulate.
  const oneHourAgo = Date.now() - 3600000;
  const tmpDir = path.join(ENGINE_DIR, 'tmp');
  const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
  // Discover dispatch-* dirs under engine/tmp/ and scan their contents too.
  if (fs.existsSync(tmpDir)) {
    try {
      for (const entry of fs.readdirSync(tmpDir, { withFileTypes: true })) {
        if (!entry.isDirectory()) continue;
        if (!entry.name.startsWith('dispatch-')) continue;
        const full = path.join(tmpDir, entry.name);
        if (!shared.validateDispatchTmpDir(full)) continue;
        scanDirs.push(full);
      }
    } catch { /* tmp dir may be empty/missing */ }
  }
  // Track which dispatch dirs we touch so we can rm empty ones whose owning
  // dispatch is no longer in the active set.
  const activeDispatchTmpDirs = new Set();
  try {
    const dispatch = getDispatch();
    for (const queue of ['pending', 'active']) {
      for (const e of dispatch[queue] || []) {
        if (e?.tmpDir) activeDispatchTmpDirs.add(path.resolve(e.tmpDir));
      }
    }
  } catch { /* dispatch.json may be empty */ }
  for (const dir of scanDirs) {
    // Each directory gets its own try-catch so one failure doesn't abort other directories (Bug #27)
    let dirEntries;
    try {
      dirEntries = fs.readdirSync(dir);
    } catch (e) {
      log('warn', `cleanup temp files: failed to read ${dir} — ${e.message}`);
      continue;
    }
    for (const f of dirEntries) {
      const isPromptTemp = f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-');
      const isSafeWriteTemp = /\.tmp\.\d+\.\d+$/.test(f);
      if (isPromptTemp || isSafeWriteTemp) {
        const fp = path.join(dir, f);
        try {
          const stat = fs.statSync(fp);
          if (stat.mtimeMs < oneHourAgo) {
            fs.unlinkSync(fp);
            cleaned.tempFiles++;
            if (isSafeWriteTemp) log('info', `Cleaned orphaned temp file: ${f}`);
          }
        } catch { /* cleanup */ }
      }
    }
  }
  // Reap empty/stale per-dispatch tmp dirs not referenced by an active entry.
  cleaned.dispatchDirs = 0;
  if (fs.existsSync(tmpDir)) {
    try {
      for (const entry of fs.readdirSync(tmpDir, { withFileTypes: true })) {
        if (!entry.isDirectory() || !entry.name.startsWith('dispatch-')) continue;
        const full = path.join(tmpDir, entry.name);
        if (!shared.validateDispatchTmpDir(full)) continue;
        if (activeDispatchTmpDirs.has(path.resolve(full))) continue;
        let stat;
        try { stat = fs.statSync(full); } catch { continue; }
        if (stat.mtimeMs >= oneHourAgo) continue;
        if (shared.removeDispatchTmpDir(full)) cleaned.dispatchDirs++;
      }
    } catch { /* sweep is best-effort */ }
  }

  // 2. Clean live-output.log and live-output-prev.log for idle agents (not currently working)
  for (const [agentId] of Object.entries(config.agents || {})) {
    const status = getAgentStatus(agentId);
    if (status.status !== 'working') {
      const livePath = path.join(AGENTS_DIR, agentId, 'live-output.log');
      if (fs.existsSync(livePath)) {
        try {
          const stat = fs.statSync(livePath);
          if (stat.mtimeMs < oneHourAgo) {
            fs.unlinkSync(livePath);
            cleaned.liveOutputs++;
          }
        } catch { /* cleanup */ }
      }
      // Also clean rotated previous session log
      const prevPath = path.join(AGENTS_DIR, agentId, 'live-output-prev.log');
      if (fs.existsSync(prevPath)) {
        try {
          const stat = fs.statSync(prevPath);
          if (stat.mtimeMs < oneHourAgo) {
            fs.unlinkSync(prevPath);
            cleaned.liveOutputs++;
          }
        } catch { /* cleanup */ }
      }
    }
  }

  // 2c. Reap orphan agents/temp-* dirs whose dispatch is no longer referenced
  // anywhere in dispatch.json. Temp-agent dirs are created by the engine for
  // ephemeral temp-<uid> agents; once the dispatch ages out of dispatch
  // history they're never touched again, accumulating MB of live-output.log
  // tails over weeks. 1h mtime gate prevents reaping a still-spawning temp
  // agent that races dispatch.json visibility.
  cleaned.orphanTempAgentDirs = 0;
  try {
    const dispatch = getDispatch();
    const referencedAgents = new Set();
    for (const seg of [dispatch.pending || [], dispatch.active || [], dispatch.completed || [], dispatch.history || []]) {
      for (const e of seg) if (e?.agent) referencedAgents.add(String(e.agent));
    }
    let entries;
    try { entries = fs.readdirSync(AGENTS_DIR, { withFileTypes: true }); } catch { entries = []; }
    for (const entry of entries) {
      if (!entry.isDirectory()) continue;
      if (!entry.name.startsWith('temp-')) continue;
      if (referencedAgents.has(entry.name)) continue;
      const full = path.join(AGENTS_DIR, entry.name);
      let stat; try { stat = fs.statSync(full); } catch { continue; }
      if (stat.mtimeMs >= oneHourAgo) continue;
      try {
        fs.rmSync(full, { recursive: true, force: true });
        cleaned.orphanTempAgentDirs++;
      } catch (err) { log('warn', `orphan temp agent dir ${entry.name}: ${err.message}`); }
    }
  } catch (e) { log('warn', `orphan temp agent sweep: ${e.message}`); }

  // 2b. Detect git worktrees registered inside any linked project's working tree.
  // Nested worktrees cause glob/grep tools running with cwd=projectRoot to match
  // BOTH copies of every file; a single Edit/MultiEdit then writes the same
  // change to both locations, producing mirror-write leaks.
  // We only WARN here — removing someone else's worktree without consent could
  // destroy in-flight work. The operator runs `git worktree remove <path>`.
  // P-a3f9b206: live-mode projects (worktreeMode === 'live') intentionally
  // never create linked worktrees — `git worktree list` returns only the
  // operator's main checkout, which `shared.isPathInside` correctly excludes
  // (equal-path is not "inside"). An empty/main-only nested-worktree scan
  // result is by-design for live mode; do not "fix" it by adding the main
  // worktree to the warnings.
  cleaned.nestedWorktrees = 0;
  const _scannedRoots = new Set(); // dedup projects sharing localPath
  for (const project of projects) {
    if (!project.localPath) continue;
    const root = path.resolve(project.localPath);
    if (_scannedRoots.has(root)) continue;
    _scannedRoots.add(root);
    if (!fs.existsSync(root)) {
      // Configured project whose checkout has been moved or deleted — surface
      // it so the operator knows their config is out of sync. A missing root
      // means we cannot scan it, leaving nested worktrees there undetectable.
      log('warn', `Project "${project.name || root}" has localPath "${root}" which does not exist on disk — skipping worktree scan`);
      continue;
    }
    let trees;
    try {
      const raw = String(shared.execSilent('git worktree list --porcelain', { cwd: root, timeout: 10000, windowsHide: true }) || '');
      trees = shared.parseWorktreePorcelain(raw);
    } catch (e) { log('warn', `nested-worktree scan for ${project.name || root}: ${e.message}`); continue; }
    for (const wt of trees) {
      // Strict — main worktree (equal path) is expected, descendants are the leak.
      if (!shared.isPathInside(wt.path, root)) continue;
      cleaned.nestedWorktrees++;
      log('warn', `Nested worktree in project "${project.name || root}": "${wt.path}" is inside "${root}". This causes glob tools to match both copies and produces mirror writes. Run: git worktree remove "${wt.path}"`);
    }
  }

  // 2d. Reap on-disk worktree dirs not registered in `git worktree list`. Can
  // be left behind when removeWorktree fails mid-way, when `git worktree prune`
  // ran without a follow-up rm -rf, or after manual `git worktree remove
  // --force` leaves an empty dir. Phase 3 below only walks dirs already in
  // git's list, so these are invisible to it. 2h mtime gate matches the
  // existing age sweep further down.
  cleaned.orphanWorktreeDirs = 0;
  const _twoHoursAgo = Date.now() - 7200000;
  const _scannedWtRoots = new Set();
  for (const project of projects) {
    const root = project.localPath ? path.resolve(project.localPath) : null;
    if (!root || !fs.existsSync(root)) continue;
    const wtRoots = new Set();
    const configuredRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
    if (fs.existsSync(configuredRoot)) wtRoots.add(configuredRoot);
    for (const d of ['worktrees', '.claude/worktrees'].map(d => path.join(root, d))) {
      if (fs.existsSync(d)) wtRoots.add(d);
    }
    let registered = null;
    for (const wtRoot of wtRoots) {
      if (_scannedWtRoots.has(wtRoot)) continue;
      _scannedWtRoots.add(wtRoot);
      // Resolve `git worktree list` once per project; reused across its roots.
      if (registered === null) {
        try {
          const raw = String(shared.execSilent('git worktree list --porcelain', { cwd: root, timeout: 10000, windowsHide: true }) || '');
          registered = new Set(shared.parseWorktreePorcelain(raw).map(wt => path.resolve(wt.path)));
        } catch (e) {
          log('warn', `orphan worktree dir scan for ${project.name || root}: ${e.message}`);
          registered = new Set();
        }
      }
      let entries;
      try { entries = fs.readdirSync(wtRoot, { withFileTypes: true }); } catch { continue; }
      for (const entry of entries) {
        if (!entry.isDirectory()) continue;
        const full = path.resolve(wtRoot, entry.name);
        if (registered.has(full)) continue;
        let stat; try { stat = fs.statSync(full); } catch { continue; }
        if (stat.mtimeMs >= _twoHoursAgo) continue;
        // W-mq5rwwss000f30a7 — even an "orphan" dir (one git doesn't know
        // about) may still host a live agent if dispatch persistence ran
        // before `git worktree add` finished. Skip when a live dispatch
        // claims it.
        if (shared.isWorktreePathLive(full)) {
          log('info', `Cleanup: skip orphan worktree dir ${full} — live dispatch claims it`);
          shared._writeWorktreeSkipLiveInboxNote(full, 'cleanup.orphanWorktreeDirSweep');
          continue;
        }
        try {
          fs.rmSync(full, { recursive: true, force: true });
          cleaned.orphanWorktreeDirs++;
          log('info', `Cleanup: removed orphan worktree dir ${full} (not registered in git)`);
        } catch (err) {
          log('warn', `orphan worktree dir ${full}: ${err.message}`);
        }
      }
    }
  }

  // 3. Clean git worktrees for merged/abandoned PRs
  const _attemptedWorktreePaths = new Set(); // dedup across projects sharing a worktreeRoot
  for (const project of projects) {
    const root = project.localPath ? path.resolve(project.localPath) : null;
    if (!root || !fs.existsSync(root)) continue;

    // Scan all potential worktree locations: configured root + common project-local dirs
    const worktreeRoots = new Set();
    const configuredRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
    if (fs.existsSync(configuredRoot)) worktreeRoots.add(configuredRoot);
    const localDirs = ['worktrees', '.claude/worktrees'].map(d => path.join(root, d));
    for (const d of localDirs) { if (fs.existsSync(d)) worktreeRoots.add(d); }

    for (const worktreeRoot of worktreeRoots) {

    // Get PRs for this project
    const prs = safeJson(projectPrPath(project)) || [];
    const mergedBranches = new Set();
    for (const pr of prs) {
      if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
        if (pr.branch) mergedBranches.add(pr.branch);
      }
    }

    // List worktrees — collect info for age-based + cap-based cleanup
    const MAX_WORKTREES = 10;
    try {
      // Collect all worktree directories (including nested ones like minions-work/P-xxx)
      const allDirs = [];
      const topDirs = fs.readdirSync(worktreeRoot);
      for (const dir of topDirs) {
        const dirPath = path.join(worktreeRoot, dir);
        try { if (!fs.statSync(dirPath).isDirectory()) continue; } catch { continue; }
        // Check if this is a git worktree (has .git file) or a parent directory
        if (fs.existsSync(path.join(dirPath, '.git'))) {
          allDirs.push({ dir, wtPath: dirPath });
        } else {
          // Scan subdirectories for worktrees
          try {
            for (const sub of fs.readdirSync(dirPath)) {
              const subPath = path.join(dirPath, sub);
              try { if (fs.statSync(subPath).isDirectory()) allDirs.push({ dir: dir + '/' + sub, wtPath: subPath }); } catch { /* skip */ }
            }
          } catch { /* skip */ }
        }
      }

      const wtEntries = []; // { dir, wtPath, mtime, shouldClean, isProtected }
      const dispatch = getDispatch();
      const activeDispatchIds = new Set((dispatch.active || []).map(d => d.id));
      // P-e0b4f7a5 — branches whose work item is mid-phantom-retry. Their
      // worktrees must survive the age/cap sweep until the retry completes
      // (or exhausts its budget) so the agent's already-pushed branch ref
      // isn't destroyed alongside the worktree.
      const phantomBranches = collectPhantomBranchesForProject(project);

      // W-mp73ya3e000me6c5 — worktree pool protection sets. Borrowed entries
      // with live dispatches are ALWAYS protected (no age, no cap). Idle
      // entries survive the age sweep but stay eligible for the cap sweep so
      // MAX_WORKTREES is still honored.
      let _activeBorrowedPaths = new Set();
      let _idlePaths = new Set();
      try {
        _activeBorrowedPaths = worktreePool().getActiveBorrowedPaths(activeDispatchIds);
        _idlePaths = worktreePool().getIdlePaths();
      } catch (e) { log('warn', `worktree-pool: cleanup lookup failed: ${e.message}`); }
      const _normalizePoolPath = worktreePool()._normalizePath;

      // W-mpbinmrh001907e9 — managed-spawn cwd anchor protection. After a
      // managed_spawn dispatch succeeds, engine-owned services run with
      // `cwd` inside the dispatch's worktree; the agent is gone so no
      // active dispatch references the branch and the >2h age sweep would
      // reap the worktree from under the live services. Build a list of
      // normalized cwds from engine/managed-processes.json and protect any
      // worktree dir that contains one. Cwd is optional per the schema, so
      // entries without cwd contribute nothing.
      const _managedSpawnCwds = [];
      try {
        const _ms = require('./managed-spawn');
        for (const rec of _ms.listManagedSpecs()) {
          if (rec && typeof rec.cwd === 'string' && rec.cwd.length > 0) {
            try { _managedSpawnCwds.push(path.resolve(rec.cwd)); }
            catch (_e) { /* malformed cwd — skip */ }
          }
        }
      } catch (e) { log('warn', `managed-spawn cwd anchor lookup failed: ${e.message}`); }

      // Probe `git branch --show-current` for every worktree in chunks of 5.
      // Sequential probing was the dominant cost in the cleanup phase
      // (5–15s tick stall every 10 ticks at 50+ worktrees), but unbounded
      // Promise.all would spawn 50+ concurrent git children — bad on Windows
      // where each fork pays AV-scan overhead. Mirrors engine/ado.js:611.
      const BRANCH_PROBE_CONCURRENCY = 5;
      const branchMap = new Map();
      for (let i = 0; i < allDirs.length; i += BRANCH_PROBE_CONCURRENCY) {
        const batch = allDirs.slice(i, i + BRANCH_PROBE_CONCURRENCY);
        const pairs = await Promise.all(
          batch.map(async ({ wtPath }) => [wtPath, await getWorktreeBranchAsync(wtPath)])
        );
        for (const [wtPath, branch] of pairs) branchMap.set(wtPath, branch);
      }

      for (const { dir, wtPath } of allDirs) {

        let shouldClean = false;
        let isProtected = false;
        const actualBranch = branchMap.get(wtPath) || '';

        // Check if this worktree's branch is merged/abandoned
        // Prefer actual git branch metadata; compact Windows dirs intentionally omit branch names.
        const dirLower = dir.toLowerCase();
        let matchedMergedBranch = '';
        for (const branch of mergedBranches) {
          if (worktreeMatchesBranch(dirLower, branch, actualBranch)) {
            shouldClean = true;
            matchedMergedBranch = branch;
            break;
          }
        }

        // Check if referenced by active/pending dispatch (use sanitized branch comparison)
        const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
          if (!d.meta?.branch) return false;
          return worktreeMatchesBranch(dirLower, d.meta.branch, actualBranch);
        });
        if (isReferenced) isProtected = true;

        // P-e0b4f7a5 — protect worktrees whose branch matches a work item in
        // the phantom-completion retry state. The dispatch may have already
        // moved to dispatch.completed (so isReferenced is false) but the
        // retry will re-dispatch on the same branch shortly.
        if (!isProtected && phantomBranches.size > 0) {
          for (const branch of phantomBranches) {
            if (worktreeMatchesBranch(dirLower, branch, actualBranch)) {
              isProtected = true;
              if (verbose) console.log(`  Skipping worktree ${dir}: phantom-completion retry pending`);
              break;
            }
          }
        }

        // W-mp73ya3e000me6c5 — pool-membership protection.
        const _normWt = _normalizePoolPath ? _normalizePoolPath(wtPath) : wtPath;
        const _isActiveBorrowed = _activeBorrowedPaths.has(_normWt);
        const _isIdlePool = _idlePaths.has(_normWt);
        if (_isActiveBorrowed) {
          // Borrowed by a live dispatch — full protection (overrides any
          // earlier shouldClean from merged-branch match too: the borrow
          // owner is responsible, not stale PR data).
          isProtected = true;
          shouldClean = false;
          if (verbose) console.log(`  Skipping worktree ${dir}: pool-borrowed by active dispatch`);
        }

        // W-mpbinmrh001907e9 — managed-spawn cwd anchor protection.
        // Worktrees backing live managed-spawn cwds must outlive the
        // originating dispatch; the engine-owned services run inside them.
        // Overrides merged-branch and age sweeps because the cwd record is
        // the authoritative signal that something live still uses the dir.
        if (_managedSpawnCwds.length > 0) {
          const _wtPathNorm = path.resolve(wtPath);
          const _wtPathPrefix = _wtPathNorm + path.sep;
          for (const cwd of _managedSpawnCwds) {
            if (cwd === _wtPathNorm || cwd.startsWith(_wtPathPrefix)) {
              isProtected = true;
              if (shouldClean) {
                shouldClean = false;
                if (verbose) console.log(`  Skipping worktree ${dir}: managed-spawn cwd anchor`);
              }
              break;
            }
          }
        }

        // Also clean worktrees older than 2 hours with no active dispatch referencing them
        let mtime = Date.now();
        if (!shouldClean) {
          try {
            const stat = fs.statSync(wtPath);
            mtime = stat.mtimeMs;
            const ageMs = Date.now() - mtime;
            if (ageMs > 7200000 && !isReferenced && !isProtected && !_isIdlePool) { // 2 hours — P-e0b4f7a5: phantom-protected worktrees survive the age sweep too. W-mp73ya3e000me6c5: idle pool entries also survive (TTL eviction handled by worktree-pool.pruneStale).
              shouldClean = true;
            }
          } catch { /* optional */ }
        }

        // Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
        if (shouldClean || !isProtected) {
          try {
            for (const checkDir of [PRD_DIR, path.join(MINIONS_DIR, 'plans')]) {
              if (!fs.existsSync(checkDir)) continue;
              for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
                const plan = safeJson(path.join(checkDir, pf));
                if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
                  if (worktreeMatchesBranch(dirLower, plan.feature_branch, actualBranch)) {
                    isProtected = true;
                    if (shouldClean) {
                      shouldClean = false;
                      if (verbose) console.log(`  Skipping worktree ${dir}: active shared-branch plan`);
                    }
                    break;
                  }
                }
              }
              if (isProtected) break;
            }
          } catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
        }

        wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch, matchedMergedBranch });
      }

      // Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
      const surviving = wtEntries.filter(e => !e.shouldClean && !e.isProtected);
      if (surviving.length + wtEntries.filter(e => e.isProtected).length > MAX_WORKTREES) {
        // Sort oldest first
        surviving.sort((a, b) => a.mtime - b.mtime);
        const excess = surviving.length + wtEntries.filter(e => e.isProtected).length - MAX_WORKTREES;
        for (let i = 0; i < Math.min(excess, surviving.length); i++) {
          surviving[i].shouldClean = true;
          if (verbose) console.log(`  Marking worktree ${surviving[i].dir} for cap cleanup (${MAX_WORKTREES} max)`);
        }
      }

      // Remove all marked worktrees
      // Re-read PR status immediately before deletion — a PR can be reopened between
      // the initial status check and the actual deletion (Bug #15: TOCTOU race)
      const freshPrs = safeJson(projectPrPath(project)) || [];
      const freshMergedBranches = new Set();
      const freshMergedPrByBranch = new Map();
      for (const pr of freshPrs) {
        if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
          if (pr.branch) freshMergedBranches.add(pr.branch);
        }
        if (pr.status === shared.PR_STATUS.MERGED && pr.branch) {
          freshMergedPrByBranch.set(sanitizeBranch(normalizeLocalBranchName(pr.branch)).toLowerCase(), pr);
        }
      }

      for (const entry of wtEntries) {
        if (entry.shouldClean) {
          // Verify the branch is still merged/closed — skip if PR was reopened since initial check
          const entryDirLower = entry.dir.toLowerCase();
          let stillMerged = false;
          for (const branch of freshMergedBranches) {
            if (worktreeMatchesBranch(entryDirLower, branch, entry.actualBranch)) {
              stillMerged = true;
              break;
            }
          }
          // If originally marked due to merged branch but PR was reopened, skip deletion
          if (!stillMerged) {
            // Check if it was marked for age/cap cleanup (not branch-based) — those are still valid
            const wasMarkedByBranch = [...mergedBranches].some(branch => worktreeMatchesBranch(entryDirLower, branch, entry.actualBranch));
            if (wasMarkedByBranch) {
              if (verbose) console.log(`  Skipping worktree ${entry.dir}: PR was reopened since initial check`);
              log('info', `Worktree deletion skipped — PR reopened: ${entry.dir}`);
              continue;
            }
          }

          if (_attemptedWorktreePaths.has(entry.wtPath)) continue;
          _attemptedWorktreePaths.add(entry.wtPath);
          // Kill any process still running in this worktree before removal
          _killProcessInWorktree(entry.dir, activeProcesses, activeDispatchIds);
          if (shared.removeWorktree(entry.wtPath, root, worktreeRoot)) {
            cleaned.worktrees++;
            // W-mp73ya3e000me6c5 — keep the pool file in sync when an idle
            // pool entry is reaped by the cap sweep (or any other reason).
            try { worktreePool().evictEntry(entry.wtPath, 'cleanup-removed'); } catch (_e) { /* optional */ }
            const mergedPr = entry.matchedMergedBranch
              ? freshMergedPrByBranch.get(sanitizeBranch(normalizeLocalBranchName(entry.matchedMergedBranch)).toLowerCase())
              : null;
            if (mergedPr) cleanupMergedPrLocalBranch(root, project, mergedPr);
            if (verbose) console.log(`  Removed worktree: ${entry.wtPath}`);
          } else {
            if (verbose) console.log(`  Failed to remove worktree ${entry.wtPath}`);
          }
        }
      }
    } catch (e) { log('warn', 'cleanup worktrees: ' + e.message); }
    } // end worktreeRoots loop
  }

  // W-mp73ya3e000me6c5 — prune the worktree pool: drop entries whose path is
  // gone, whose borrower is no longer in dispatch.active, or whose idle TTL
  // has expired. Runs after the per-project worktree sweeps so we operate on
  // the freshest dispatch state. The pool file is only mutated when entries
  // change; default-off projects (no entries) are a no-op.
  try {
    const _dispatchForPrune = getDispatch();
    const _activeIdsForPrune = new Set((_dispatchForPrune.active || []).map(d => d.id));
    const _config = queries.getConfig();
    const _idleTtlMs = _config?.engine?.worktreePoolIdleTtlMs ?? ENGINE_DEFAULTS.worktreePoolIdleTtlMs;
    const _result = worktreePool().pruneStale({
      activeDispatchIds: _activeIdsForPrune,
      idleTtlMs: _idleTtlMs,
    });
    if (_result.evicted > 0) {
      cleaned.worktreePoolEvicted = _result.evicted;
      if (verbose) console.log(`  worktree-pool: evicted ${_result.evicted} stale entr${_result.evicted === 1 ? 'y' : 'ies'}`);
    }
  } catch (e) { log('warn', 'worktree-pool prune: ' + e.message); }

  // 4. Kill zombie agent processes not tracked by the engine
  // List all node processes, check if any are running spawn-agent.js for our minions
  try {
    const dispatch = getDispatch();
    const activePids = new Set();
    for (const [, info] of activeProcesses.entries()) {
      if (info.proc?.pid) activePids.add(info.proc.pid);
    }

    // Clean individual orphaned processes — no matching active dispatch
    const activeIds = new Set((dispatch.active || []).map(d => d.id));
    for (const [id, info] of activeProcesses.entries()) {
      if (!activeIds.has(id)) {
        shared.killImmediate(info.proc);
        activeProcesses.delete(id);
        cleaned.zombies++;
      }
    }
  } catch (e) { log('warn', 'cleanup zombie processes: ' + e.message); }

  // 4b. Sweep stray MCP descendants. Spawn-agent.js reaps its descendants on
  // close, but a hard kill (engine restart, OS crash) can skip that path and
  // leave orphan node.exe processes running MCP servers (Azure, Playwright,
  // Loop, etc.) reparented away from the Minions tree. This sweep walks
  // descendants from every anchor we know (engine, dashboard, active spawn-
  // agents) and kills any node MCP processes outside that set.
  cleaned.strayMcps = 0;
  try {
    const allProcs = shared.listAllProcesses();
    if (allProcs.length) {
      const anchorPids = [process.pid];
      for (const info of activeProcesses.values()) {
        if (info?.proc?.pid) anchorPids.push(info.proc.pid);
      }
      // Dashboard runs as a sibling process (not parented to engine on Windows)
      // and hosts Command Center + doc-chat sessions whose Claude CLI children
      // spawn their own MCP servers. Without anchoring it, the sweep would
      // classify those MCP grandchildren as stray and kill live CC state on
      // every cleanup tick. control.json doesn't carry a dashboardPid field,
      // so we anchor by scanning the snapshot we already have for any node
      // running dashboard.js.
      for (const p of allProcs) {
        if (p.cmd && /[\\/]dashboard\.js(?![\w.-])/i.test(p.cmd)) anchorPids.push(p.pid);
      }
      // W-mp68q6ke0010de68 — opt-in keep_processes flag: PIDs declared in
      // any agents/<id>/keep-pids.json file are anchors, so the reachability
      // walk treats them (and their MCP grandchildren) as legitimate.
      try {
        const keepProcessSweep = require('./keep-process-sweep');
        for (const pid of keepProcessSweep.getActiveAnchorPids()) anchorPids.push(pid);
      } catch (e) { log('warn', `cleanup: keep-process anchor lookup failed: ${e.message}`); }
      const reach = shared.listProcessReachable(anchorPids, allProcs);
      // MCP server commandlines. Matches scoped (@modelcontextprotocol/*,
      // @<scope>/mcp[-*]) and flat (mcp-server-*, *-mcp-server,
      // loop-mcp-server) package paths. Specifically does NOT match
      // @github/copilot (the Copilot CLI itself) — that would false-positive
      // and kill a user's interactive `copilot` in another terminal, since
      // its parent chain has no Minions ancestor and so isn't in `reach`.
      const mcpRe = /[\\/](?:@modelcontextprotocol[\\/]|@[\w.-]+[\\/]mcp(?:[\\/]|-[\w-]*[\\/])|mcp-server-[\w-]+|[\w-]+-mcp-server|(?:ms-)?loop-mcp-server)[\\/]?/i;
      const strayPids = [];
      for (const p of allProcs) {
        if (!p.cmd || !/node(?:\.exe)?\b/i.test(p.name)) continue;
        if (!mcpRe.test(p.cmd)) continue;
        if (reach.has(p.pid)) continue;
        strayPids.push(p.pid);
      }
      if (strayPids.length) {
        cleaned.strayMcps = shared.killByPidsImmediate(strayPids);
        log('info', `Cleanup: ${cleaned.strayMcps}/${strayPids.length} stray MCP descendants reaped`);
      }
    }
  } catch (e) { log('warn', 'cleanup stray MCPs: ' + e.message); }

  // 5. Clean spawn-debug.log
  try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch { /* cleanup */ }

  // 5b. Sweep leaked test-fixture meetings from the live `meetings/` directory.
  cleaned.leakedTestMeetings = sweepLeakedTestMeetings(path.join(MINIONS_DIR, 'meetings'));

  // 6. Prune old output archive files (keep last 30 per agent)
  for (const agentId of Object.keys(config.agents || {})) {
    const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
    if (!fs.existsSync(agentDir)) continue;
    try {
      const outputFiles = fs.readdirSync(agentDir)
        .filter(f => f.startsWith('output-') && f.endsWith('.log') && f !== 'output.log')
        .map(f => ({ name: f, mtime: fs.statSync(path.join(agentDir, f)).mtimeMs }))
        .sort((a, b) => b.mtime - a.mtime);
      for (const old of outputFiles.slice(30)) {
        try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch { /* cleanup */ }
      }
    } catch (e) { log('warn', 'prune output archives: ' + e.message); }
  }

  // 6b. Prune notes/archive — keep the most recent bounded set
  cleaned.notesArchive = 0;
  try {
    const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
    if (fs.existsSync(archiveDir)) {
      const archiveFiles = fs.readdirSync(archiveDir)
        .map(name => ({ name, mtime: fs.statSync(path.join(archiveDir, name)).mtimeMs }))
        .sort((a, b) => b.mtime - a.mtime);
      if (archiveFiles.length > ENGINE_DEFAULTS.notesArchiveMaxFiles) {
        for (const old of archiveFiles.slice(ENGINE_DEFAULTS.notesArchiveMaxFiles)) {
          try { fs.unlinkSync(path.join(archiveDir, old.name)); cleaned.notesArchive++; } catch { /* cleanup */ }
        }
      }
    }
  } catch (e) { log('warn', 'prune notes archive: ' + e.message); }

  // 7. Prune orphaned dispatch entries — items whose source work item no longer exists
  cleaned.orphanedDispatches = 0;
  try {
    const dispatch = getDispatch();
    // Collect all work item IDs across all sources
    const allWiIds = new Set();
    try {
      const allItems = queries.getWorkItems();
      allItems.forEach(w => allWiIds.add(w.id));
    } catch (e) { log('warn', 'read work items for orphan check: ' + e.message); }

    let changed = false;
    for (const queue of ['pending', 'active']) {
      if (!dispatch[queue]) continue;
      const before = dispatch[queue].length;
      dispatch[queue] = dispatch[queue].filter(d => {
        const itemId = d.meta?.item?.id;
        if (!itemId) return true; // keep entries without item tracking
        return allWiIds.has(itemId);
      });
      const removed = before - dispatch[queue].length;
      if (removed > 0) {
        cleaned.orphanedDispatches += removed;
        changed = true;
      }
    }
    if (changed) {
      const { mutateDispatch } = dispatchModule();
      mutateDispatch((dp) => {
        for (const queue of ['pending', 'active']) {
          if (!dp[queue]) continue;
          dp[queue] = dp[queue].filter(d => {
            const itemId = d.meta?.item?.id;
            if (!itemId) return true;
            return allWiIds.has(itemId);
          });
        }
        return dp;
      });
    }
  } catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }

  const _orphanTemp = cleaned.orphanTempAgentDirs || 0;
  const _orphanWt = cleaned.orphanWorktreeDirs || 0;
  if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches + (cleaned.nestedWorktrees || 0) + _orphanTemp + _orphanWt > 0) {
    log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches, ${cleaned.nestedWorktrees || 0} nested worktrees flagged, ${_orphanTemp} orphan temp dirs, ${_orphanWt} orphan worktree dirs`);
  }

  // 8. Clean swept KB files older than 7 days
  try {
    const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
    if (fs.existsSync(sweptDir)) {
      const sevenDaysAgo = Date.now() - 7 * 86400000;
      let sweptEntries;
      try {
        sweptEntries = fs.readdirSync(sweptDir);
      } catch (e) {
        log('warn', `cleanup swept KB: failed to read ${sweptDir} — ${e.message}`);
        sweptEntries = [];
      }
      for (const f of sweptEntries) {
        try {
          const fp = path.join(sweptDir, f);
          if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
            fs.unlinkSync(fp);
            if (!cleaned.sweptKb) cleaned.sweptKb = 0;
            cleaned.sweptKb++;
          }
        } catch { /* cleanup */ }
      }
    }
  } catch (e) { log('warn', 'cleanup swept KB files: ' + e.message); }

  // 6a. Reconcile failed work items that have an attached PR (#407)
  // If a work item is 'failed' but already has _pr, it should be 'done'.
  // Uses mutateWorkItems() for locked atomic read-modify-write — prevents
  // race conditions with concurrent engine/dashboard/lifecycle writers.
  for (const project of projects) {
    try {
      const wiPath = projectWorkItemsPath(project);
      let reconciled = 0;
        mutateWorkItems(wiPath, items => {
          for (const item of items) {
            if (item.status === shared.WI_STATUS.FAILED && item._pr) {
              item.status = shared.WI_STATUS.DONE;
              if (item.failReason) delete item.failReason;
              if (item.failedAt) delete item.failedAt;
              delete item._retryCount;
              delete item._pendingReason;
              if (!item.completedAt) item.completedAt = shared.ts();
              reconciled++;
            }
          }
        });
      if (reconciled > 0) {
        log('info', `Reconciled ${reconciled} failed-with-PR item(s) → done in ${project.name}`);
      }
    } catch (e) { log('warn', 'reconcile failed-with-PR: ' + e.message); }
  }

  // 6b. Migrate legacy work-item statuses to canonical replacements
  // in-pr, implemented, complete → done; needs-human-review → failed
  const LEGACY_DONE_ALIASES = new Set(['in-pr', 'implemented', 'complete']);
  const LEGACY_NEEDS_REVIEW_STATUS = 'needs-human-review';
  const LEGACY_NEEDS_REVIEW_FAIL_REASON = 'Manual intervention required (migrated from needs-human-review)';
  function _migrateLegacyItem(item) {
    if (LEGACY_DONE_ALIASES.has(item.status)) {
      item.status = shared.WI_STATUS.DONE;
      delete item._retryCount;
      delete item._pendingReason;
      if (!item.completedAt) item.completedAt = shared.ts();
      return true;
    }
    if (item.status === LEGACY_NEEDS_REVIEW_STATUS) {
      item.status = shared.WI_STATUS.FAILED;
      if (!item.failReason) item.failReason = LEGACY_NEEDS_REVIEW_FAIL_REASON;
      if (!item.failedAt) item.failedAt = shared.ts();
      delete item.completedAt;
      return true;
    }
    return false;
  }
  function _migrateLegacyItemsAt(wiPath, label) {
    try {
      let migrated = 0;
      mutateWorkItems(wiPath, items => {
        for (const item of items) if (_migrateLegacyItem(item)) migrated++;
      });
      if (migrated > 0) {
        log('info', `Migrated ${migrated} legacy status(es) in ${label}`);
        // P-dcc27d-legacy-status-counter — make the docs/deprecated.json
        // gate ("legacy-done-aliases" removal after 30 consecutive days zero
        // migrations) measurable by bumping a rolling-daily counter under
        // `_engine.legacyStatusMigrations[YYYY-MM-DD]`. Parallel to
        // `_engine.completionFallbacks` at engine/lifecycle.js:3870-3871.
        // Inline mutateJsonFileLocked by design — promote to a shared
        // `mutateMetrics(key, date)` helper if more N-day-silence gates land.
        try {
          const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
          const dateKey = new Date().toISOString().slice(0, 10);
          mutateJsonFileLocked(metricsPath, (metrics) => {
            metrics = metrics || {};
            if (!metrics._engine) metrics._engine = {};
            if (!metrics._engine.legacyStatusMigrations) metrics._engine.legacyStatusMigrations = {};
            metrics._engine.legacyStatusMigrations[dateKey] =
              (metrics._engine.legacyStatusMigrations[dateKey] || 0) + migrated;
            return metrics;
          });
        } catch (err) {
          log('warn', `telemetry: legacy-status-migration metrics write failed: ${err.message}`);
        }
      }
    } catch (e) { log('warn', `migrate legacy statuses (${label}): ${e.message}`); }
  }
  for (const project of projects) _migrateLegacyItemsAt(projectWorkItemsPath(project), `${project.name} work items`);
  _migrateLegacyItemsAt(path.join(MINIONS_DIR, 'work-items.json'), 'central work items');

  // 6c. Strip stale retry metadata from completed work items
  cleaned.doneRetryCounts = 0;
  for (const project of projects) {
    try {
      const wiPath = projectWorkItemsPath(project);
      mutateWorkItems(wiPath, items => {
        for (const item of items) {
          if (item.status === shared.WI_STATUS.DONE && item._retryCount !== undefined) {
            delete item._retryCount;
            cleaned.doneRetryCounts++;
          }
        }
      });
    } catch (e) { log('warn', 'cleanup done retry metadata: ' + e.message); }
  }
  try {
    const centralPath = path.join(MINIONS_DIR, 'work-items.json');
    mutateWorkItems(centralPath, items => {
      for (const item of items) {
        if (item.status === shared.WI_STATUS.DONE && item._retryCount !== undefined) {
          delete item._retryCount;
          cleaned.doneRetryCounts++;
        }
      }
    });
  } catch (e) { log('warn', 'cleanup central done retry metadata: ' + e.message); }
  if (cleaned.doneRetryCounts > 0) {
    log('info', `Cleanup: cleared ${cleaned.doneRetryCounts} stale retry count(s) from done work items`);
  }
  // PRD items (missing_features[].status)
  try {
    let prdDirEntries;
    try {
      prdDirEntries = fs.readdirSync(PRD_DIR);
    } catch (e) {
      log('warn', `migrate PRD statuses: failed to read ${PRD_DIR} — ${e.message}`);
      prdDirEntries = [];
    }
    const prdFiles = prdDirEntries.filter(f => f.endsWith('.json'));
    for (const pf of prdFiles) {
      const prdPath = path.join(PRD_DIR, pf);
      let migrated = 0;
      shared.withFileLock(`${prdPath}.lock`, () => {
        // safeJsonNoRestore: PRDs are terminal artifacts. If the active PRD
        // was archived but a stale .backup sidecar remains, never resurrect
        // it during a routine migration sweep (W-mouptdh1000h9f39).
        const prd = safeJsonNoRestore(prdPath);
        if (!prd?.missing_features) return;
        for (const feat of prd.missing_features) {
          if (LEGACY_DONE_ALIASES.has(feat.status)) {
            feat.status = shared.WI_STATUS.DONE;
            migrated++;
          } else if (feat.status === LEGACY_NEEDS_REVIEW_STATUS) {
            feat.status = shared.WI_STATUS.FAILED;
            migrated++;
          }
        }
        if (migrated > 0) safeWrite(prdPath, prd);
      });
      if (migrated > 0) {
        log('info', `Migrated ${migrated} legacy PRD item status(es) in ${pf}`);
      }
    }
  } catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }

  // Reset orphaned PRD item statuses — dispatched/failed with no matching work item (#779)
  cleaned.orphanedPrdStatuses = 0;
  try {
    const wiIds = new Set();
    for (const project of projects) {
      const items = safeJson(projectWorkItemsPath(project)) || [];
      for (const wi of items) { if (wi?.id) wiIds.add(wi.id); }
    }
    try {
      const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
      for (const wi of centralWi) { if (wi?.id) wiIds.add(wi.id); }
    } catch { /* optional */ }

    let orphanPrdEntries;
    try { orphanPrdEntries = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json')); }
    catch { orphanPrdEntries = []; }
    for (const pf of orphanPrdEntries) {
      const prdPath = path.join(PRD_DIR, pf);
      // safeJsonNoRestore — peek must not auto-restore archived PRDs
      // from a stale .backup sidecar (W-mouptdh1000h9f39).
      const peek = safeJsonNoRestore(prdPath);
      if (!peek?.missing_features) continue;
      let reset = 0;
      mutateJsonFileLocked(prdPath, (prd) => {
        if (!prd?.missing_features) return prd;
        for (const feat of prd.missing_features) {
          if ((feat.status === shared.WI_STATUS.DISPATCHED || feat.status === shared.WI_STATUS.FAILED) && !wiIds.has(feat.id)) {
            feat.status = shared.WI_STATUS.PENDING;
            reset++;
          }
        }
        return prd;
      }, { skipWriteIfUnchanged: true });
      if (reset > 0) {
        log('info', `Reset ${reset} orphaned PRD item status(es) → pending in ${pf}`);
        cleaned.orphanedPrdStatuses += reset;
      }
    }
  } catch (e) { log('warn', 'orphan PRD status reset: ' + e.message); }

  // 10. CC tab sessions are non-expiring by design — they persist until the
  // user explicitly closes the tab (which fires DELETE /api/cc-sessions/:id).
  // Cleanup intentionally does NOT prune cc-sessions.json; doing so would
  // silently invalidate live chat tabs the user expects to keep.
  cleaned.ccSessions = 0;

  // 10b. Prune doc-chat sessions — cap at 100 entries, remove oldest beyond cap.
  // P-c2sess-1d8e: read+sort+write must run atomically under the file lock so a
  // concurrent dashboard persistDocSessions can't race the cap-trim.
  cleaned.docSessions = 0;
  try {
    const docSessionsPath = path.join(ENGINE_DIR, 'doc-sessions.json');
    const DOC_SESSIONS_CAP = 100;
    mutateJsonFileLocked(docSessionsPath, (docSessions) => {
      if (!docSessions || typeof docSessions !== 'object' || Array.isArray(docSessions)) return docSessions;
      const entries = Object.entries(docSessions);
      if (entries.length <= DOC_SESSIONS_CAP) return docSessions;
      entries.sort((a, b) => new Date(b.lastActiveAt || 0) - new Date(a.lastActiveAt || 0));
      cleaned.docSessions = entries.length - DOC_SESSIONS_CAP;
      return Object.fromEntries(entries.slice(0, DOC_SESSIONS_CAP));
    }, { defaultValue: {}, skipWriteIfUnchanged: true });
  } catch (e) { log('warn', 'prune doc-sessions: ' + e.message); }

  // 11. Cap cooldowns.json — keep at most 500 entries (on top of 24h TTL in cooldown.js)
  //     Also trim pendingContexts arrays to ENGINE_DEFAULTS.maxPendingContexts to prevent bloat.
  cleaned.cooldowns = 0;
  cleaned.pendingContextsTrimmed = 0;
  try {
    const cooldownPath = path.join(ENGINE_DIR, 'cooldowns.json');
    // safeJsonNoRestore — same rationale as engine/cooldown.js loadCooldowns:
    // resurrecting a stale .backup at cleanup time could re-introduce expired
    // entries the active code already pruned (P-bfa1d-safejson-no-restore).
    const cooldowns = safeJsonNoRestore(cooldownPath);
    if (cooldowns && typeof cooldowns === 'object') {
      let dirty = false;
      // Trim oversized pendingContexts arrays (one-time migration + ongoing cap)
      const pendingCtxCap = ENGINE_DEFAULTS.maxPendingContexts;
      for (const v of Object.values(cooldowns)) {
        if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > pendingCtxCap) {
          v.pendingContexts = v.pendingContexts.slice(-pendingCtxCap);
          cleaned.pendingContextsTrimmed++;
          dirty = true;
        }
      }
      const entries = Object.entries(cooldowns);
      const COOLDOWN_CAP = 500;
      if (entries.length > COOLDOWN_CAP) {
        // Keep most recent by timestamp
        entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0));
        const keep = Object.fromEntries(entries.slice(0, COOLDOWN_CAP));
        cleaned.cooldowns = entries.length - COOLDOWN_CAP;
        mutateCooldowns(() => keep);
      } else if (dirty) {
        mutateCooldowns(() => cooldowns);
      }
    }
  } catch (e) { log('warn', 'cap cooldowns: ' + e.message); }

  // 12. Clean stale PID files — remove PID files whose process is no longer running
  // P-f6-tmp-toctou: walks BOTH legacy flat layout and per-dispatch-dir layout
  // via shared.forEachPidFile.
  cleaned.pidFiles = 0;
  try {
    const tmpDir = path.join(ENGINE_DIR, 'tmp');
    if (fs.existsSync(tmpDir)) {
      const activePids = new Set();
      for (const [, info] of activeProcesses) {
        if (info.proc?.pid) activePids.add(String(info.proc.pid));
      }
      shared.forEachPidFile((pidFilePath, fileName, layout) => {
        try {
          const pidStr = fs.readFileSync(pidFilePath, 'utf8').trim();
          // Skip if actively tracked
          if (activePids.has(pidStr)) return;
          // Check if file is stale (>1 hour old)
          const stat = fs.statSync(pidFilePath);
          if (stat.mtimeMs < oneHourAgo) {
            fs.unlinkSync(pidFilePath);
            cleaned.pidFiles++;
            // For dispatch-dir layout, the empty/stale dispatch dir gets reaped
            // by the stale-dispatch-dir sweep in step 1.
          }
        } catch { /* cleanup */ }
      });
    }
  } catch (e) { log('warn', 'clean stale PID files: ' + e.message); }

  // 13. Prune test-results.json — keep last 200 entries
  // Phase 9.3: route through shared.mutateTestResults so the read+trim+write
  // is atomic under the file lock (previously a safeJsonArr → safeWrite race).
  try {
    const testResultsPath = path.join(ENGINE_DIR, 'test-results.json');
    const TEST_RESULTS_CAP = 200;
    // Fast path: skip lock acquisition entirely when the file is already
    // under cap. The atomic re-check inside the mutator handles the race.
    const probe = shared.safeJsonArr(testResultsPath);
    if (probe.length > TEST_RESULTS_CAP) {
      shared.mutateTestResults((curr) => {
        if (curr.length > TEST_RESULTS_CAP) return curr.slice(-TEST_RESULTS_CAP);
        return undefined; // already trimmed by a concurrent writer
      });
    }
  } catch { /* optional — file may not exist */ }

  // 14. Scrub stale temp agent keys from metrics.json
  try { scrubStaleMetrics(); } catch { /* best-effort cleanup */ }

  // 15. Evict old completion reports — keep reports durable beyond the capped
  // dispatch history, but bound disk growth by age/count.
  cleaned.completionReports = 0;
  try {
    const dispatch = getDispatch();
    const protectedReportFiles = new Set();
    for (const queue of ['pending', 'active', 'completed']) {
      for (const entry of dispatch[queue] || []) {
        if (!entry?.id) continue;
        const reportPath = shared.dispatchCompletionReportPath(entry.id);
        if (reportPath) protectedReportFiles.add(path.basename(reportPath));
      }
    }
    const configuredRetentionDays = Number(config?.engine?.completionReportRetentionDays ?? ENGINE_DEFAULTS.completionReportRetentionDays);
    const configuredMaxReports = Number(config?.engine?.completionReportMaxFiles ?? ENGINE_DEFAULTS.completionReportMaxFiles);
    const retentionDays = Number.isFinite(configuredRetentionDays) ? configuredRetentionDays : ENGINE_DEFAULTS.completionReportRetentionDays;
    const maxReports = Number.isFinite(configuredMaxReports) ? configuredMaxReports : ENGINE_DEFAULTS.completionReportMaxFiles;
    const retentionMs = retentionDays > 0 ? retentionDays * 24 * 60 * 60 * 1000 : 0;
    const cutoffMs = retentionMs > 0 ? Date.now() - retentionMs : 0;
    const completionsDir = path.join(ENGINE_DIR, 'completions');
    if (fs.existsSync(completionsDir)) {
      const reports = fs.readdirSync(completionsDir)
        .filter(f => f.endsWith('.json'))
        .map(f => {
          const fp = path.join(completionsDir, f);
          let mtimeMs = 0;
          try { mtimeMs = fs.statSync(fp).mtimeMs; } catch {}
          return { file: f, path: fp, mtimeMs, protected: protectedReportFiles.has(f) };
        })
        .filter(r => r.mtimeMs > 0)
        .sort((a, b) => a.mtimeMs - b.mtimeMs);
      const removeReport = (report) => {
        try { fs.unlinkSync(report.path); cleaned.completionReports++; return true; } catch { return false; }
      };
      for (const report of reports) {
        if (report.protected) continue;
        if (cutoffMs > 0 && report.mtimeMs < cutoffMs) {
          removeReport(report);
        }
      }
      if (maxReports > 0) {
        const remaining = reports.filter(r => fs.existsSync(r.path));
        let overflow = remaining.length - maxReports;
        for (const report of remaining) {
          if (overflow <= 0) break;
          if (report.protected) continue;
          if (removeReport(report)) overflow--;
        }
      }
      if (cleaned.completionReports > 0) {
        log('info', `Cleanup: removed ${cleaned.completionReports} old completion report(s)`);
      }
    }
  } catch (e) { log('warn', `cleanupCompletionReports: ${e.message}`); }

  if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed + cleaned.notesArchive + cleaned.completionReports > 0) {
    log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.notesArchive} archived notes, ${cleaned.pidFiles} PID files, ${cleaned.completionReports} completion reports`);
  }

  // 16. Prune stale safeWrite .backup sidecars (P-c91d4f76) — SQL is the
  // authority post-Phase-0–7, so the .backup sidecars never get read again
  // and just accumulate. One summary line per sweep, no per-file spam.
  cleaned.backupSidecars = 0;
  try {
    cleaned.backupSidecars = pruneStaleBackupSidecars(config);
    if (cleaned.backupSidecars > 0) {
      log('info', `cleanup: pruned ${cleaned.backupSidecars} stale .backup sidecars (engine/, meetings/)`);
    }
  } catch (e) { log('warn', `pruneStaleBackupSidecars: ${e.message}`); }

  return cleaned;
}

// ─── Metrics Scrub ──────────────────────────────────────────────────────────

/** Remove stale temp-*, agent1, and _test-* keys from metrics.json.
 *  Mirrors the guard in lifecycle.js updateMetrics() — cleans up keys
 *  that were written before that guard existed. */
function scrubStaleMetrics() {
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
  if (!fs.existsSync(metricsPath)) return;
  mutateJsonFileLocked(metricsPath, metrics => {
    for (const key of Object.keys(metrics)) {
      if (key.startsWith('temp-') || key === 'agent1' || key.startsWith('_test')) {
        delete metrics[key];
      }
    }
  });
}

// ─── Periodic worktree GC sweep (W-mq5o6bvy000x7191 Layer 2 + 3) ────────────

/**
 * Periodic per-tick worktree sweep. Aggregates the two pruners in
 * `engine/worktree-gc.js` so a long-lived engine catches orphans the
 * boot-only reconcile would miss:
 *
 *   - `pruneOrphanWorktrees`: walks `<localPath>/<worktreeRoot>` and reaps
 *     orphan W-* dirs with no matching dispatch.
 *   - `pruneOrphanWorktreesFromGitRegistry`: walks `git worktree list` per
 *     project and reaps registered worktrees outside the configured
 *     `worktreeRoot` (covers `D:/tmp-*`, `D:/squad-worktrees/*`, etc.).
 *
 * Both pruners share the SAME `dispatchSnap` from `queries.getDispatch()`
 * so live work isn't yanked between scans. Returns aggregate stats for
 * tick-level logging.
 */
function runPeriodicWorktreeSweep(config) {
  const worktreeGc = require('./worktree-gc');
  const projects = getProjects(config);
  if (projects.length === 0) {
    return { scanned: 0, kept: 0, evicted: 0, failed: 0, outOfRootEvicted: 0, prunedRegistry: 0 };
  }
  const dispatchSnap = getDispatch();
  const worktreeRootRel = config?.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot;
  const _log = (lvl, msg) => log(lvl, msg);

  let scanned = 0, kept = 0, evicted = 0, failed = 0, outOfRootEvicted = 0, prunedRegistry = 0;
  const _writeToInbox = (a, s, c) => { try { return shared.writeToInbox(a, s, c); } catch (_e) { return false; } };
  try {
    const r1 = worktreeGc.pruneOrphanWorktrees({
      projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
    });
    scanned += r1.scanned || 0;
    kept += r1.kept || 0;
    evicted += r1.evicted || 0;
    failed += r1.failed || 0;
  } catch (e) { log('warn', `worktree-gc periodic in-root: ${e.message}`); }

  try {
    const r2 = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
      projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
    });
    scanned += r2.scanned || 0;
    kept += r2.kept || 0;
    outOfRootEvicted += r2.evicted || 0;
    failed += r2.failed || 0;
    prunedRegistry += r2.prunedRegistry || 0;
  } catch (e) { log('warn', `worktree-gc periodic out-of-root: ${e.message}`); }

  return { scanned, kept, evicted, failed, outOfRootEvicted, prunedRegistry };
}

// ─── Exports ─────────────────────────────────────────────────────────────────

module.exports = {
  runCleanup,
  runPeriodicWorktreeSweep, // W-mq5o6bvy000x7191 — exported for engine.js tick wiring + testing
  scrubStaleMetrics,
  sweepLeakedTestMeetings,   // exported for testing
  pruneStaleBackupSidecars,  // P-c91d4f76 — exported for testing
  worktreeDirMatchesBranch,  // exported for testing
  worktreeMatchesBranch,     // exported for testing
  getWorktreeBranch,         // exported for lifecycle cleanup
  cleanupMergedPrLocalBranch, // exported for lifecycle cleanup and testing
  collectPhantomBranchesForProject, // P-e0b4f7a5 — exported for testing
};
