/**
 * engine/cli.js — CLI command handlers for Minions engine.
 * Extracted from engine.js to reduce monolith size.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
const queries = require('./queries');
const { getConfig, getControl, getDispatch, getAgentStatus,
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;

// Lazy require — only for engine-specific functions (log, ts, tick, addToDispatch, etc.)
let _engine = null;
function engine() {
  if (!_engine) _engine = require('../engine');
  return _engine;
}

let _dispatchModule = null;
function dispatchModule() { if (!_dispatchModule) _dispatchModule = require('./dispatch'); return _dispatchModule; }

function normalizeSessionBranch(branch) {
  if (!branch) return null;
  return String(branch).replace(/^refs\/heads\//, '');
}

function dispatchSessionBranch(item) {
  return normalizeSessionBranch(
    item?.meta?.branch || item?.branch || item?.meta?.item?.branch || item?.meta?.item?.featureBranch
  );
}

function isEngineProcessAlive(control) {
  if (!control?.pid) return false;
  if (control.pid === process.pid) return true;
  try {
    if (process.platform === 'win32') {
      const { execSync } = require('child_process');
      const out = execSync(`tasklist /FI "PID eq ${control.pid}" /NH`, {
        encoding: 'utf8',
        windowsHide: true,
        timeout: 3000,
      });
      return new RegExp(`\\b${control.pid}\\b`).test(out) && out.toLowerCase().includes('node');
    }
    process.kill(control.pid, 0);
    return true;
  } catch {
    return false;
  }
}

function dispatchSafeId(itemId) {
  return String(itemId || '').replace(/[:\\/*?"<>|]/g, '-');
}

function readDispatchPid(itemId) {
  // P-f6-tmp-toctou: prefer per-dispatch dir layout, fall back to legacy flat.
  const pidFile = shared.findDispatchPidFile(itemId);
  if (!pidFile) return null;
  let raw;
  try { raw = fs.readFileSync(pidFile, 'utf8').trim(); }
  catch { return null; }
  if (!raw) return null;
  try { return shared.validatePid(raw); }
  catch { return null; }
}

function isPidAlive(pid) {
  try {
    process.kill(shared.validatePid(pid), 0);
    return true;
  } catch {
    return false;
  }
}

function summarizeActiveDispatchPids(activeItems = []) {
  const summary = { live: 0, dead: 0, missing: 0 };
  for (const item of activeItems || []) {
    const pid = readDispatchPid(item.id);
    if (!pid) {
      summary.missing++;
    } else if (isPidAlive(pid)) {
      summary.live++;
    } else {
      summary.dead++;
    }
  }
  return summary;
}

// W-mpcyvff6000pf828 (#2653) — Heartbeat writer decoupled from tickInner.
// `writeHeartbeatNow` mirrors the legacy in-tick write (synchronous, fast,
// non-throwing) but is driven from the main start loop on a 15s setInterval
// instead. See engine.js#tickInner for the load-bearing rationale comment.
const HEARTBEAT_INTERVAL_MS = 15000;

function writeHeartbeatNow() {
  try {
    // Synchronous callback inside the lock — just `s.heartbeat = Date.now()`,
    // no awaits, no other state mutation. Mirrors mutateControl's contract.
    shared.mutateControl(c => { c.heartbeat = Date.now(); return c; });
  } catch (err) {
    try { engine().log('warn', `write heartbeat: ${err.message}`); }
    catch { /* during shutdown logger can be torn down — silent */ }
  }
}

// Factory used by tests to drive the heartbeat interval without spinning up
// the full engine start loop. Returns the underlying timer so callers (the
// real start handler in production, tests in unit/engine-heartbeat.test.js)
// can clearInterval(...) on shutdown. Default to the production cadence.
function createHeartbeatInterval(intervalMs = HEARTBEAT_INTERVAL_MS, writer = writeHeartbeatNow) {
  return setInterval(writer, intervalMs);
}


function createControlOwner(pid = process.pid) {
  return { pid, ownerToken: `${pid}-${shared.uid()}` };
}

function controlBelongsToOwner(control, owner) {
  return !!(
    control &&
    owner &&
    owner.ownerToken &&
    control.ownerToken === owner.ownerToken &&
    control.pid === owner.pid
  );
}

function mutateControlForOwner(owner, mutator) {
  let changed = false;
  const control = mutateControl(current => {
    if (!controlBelongsToOwner(current, owner)) return current;
    changed = true;
    return mutator(current);
  });
  return { changed, control };
}

function markControlStoppingForOwner(owner, stoppingAt) {
  return mutateControlForOwner(owner, current => ({
    ...current,
    state: 'stopping',
    pid: owner.pid,
    stopping_at: stoppingAt,
  }));
}

function markControlStoppedForOwner(owner, stoppedAt) {
  return mutateControlForOwner(owner, () => ({ state: 'stopped', stopped_at: stoppedAt }));
}

function handleCommand(cmd, args) {
  if (!cmd) {
    return commands.start();
  } else if (commands[cmd]) {
    // W-mq07mjzi000s1cc9 + W-mq0770160001ee50: Help-flag interception.
    //
    // `minions work --help` used to create ghost work items with title='--help'
    // because the bare-string `title` was truthy and bypassed the `!title`
    // usage check. The fix lives in per-command guards (`_isHelpArg` /
    // `looksLikeFlagOrHelp`) on `work`/`spawn`/`plan`/`complete`, which print
    // command-specific `Usage:` output. `pr` and `bridge` handle help inline.
    //
    // Intercept here so a single guard covers the whole command set. `pr` and
    // `bridge` already handle `help`/`--help`/`-h` inline (see their own
    // first-arg branches), so let them route through unchanged.
    //
    // W-mq60aco9: print the *per-command* usage (matching the convention from
    // PR #3054 which added per-positional `Usage:` for work/spawn/plan/complete).
    // Previously this branch printed the global `Commands:` list and short-
    // circuited the per-command guards — so `minions work --help` advertised
    // the wrong help text. Fall back to the global list only for commands
    // missing from CLI_COMMAND_DOCS. The per-command `_isHelpArg` guards in
    // work/spawn/plan/complete remain as defense-in-depth for any caller that
    // bypasses handleCommand.
    if (cmd !== 'pr' && cmd !== 'bridge' && isHelpToken(args && args[0])) {
      const doc = CLI_COMMAND_DOCS[cmd];
      if (doc) {
        console.log(`Usage: minions ${cmd}${doc.args ? ' ' + doc.args : ''}`);
        if (doc.summary) console.log(`  ${doc.summary}`);
      } else {
        console.log('Commands:');
        for (const line of formatCliCommandHelpLines()) console.log(line);
      }
      return;
    }
    return commands[cmd](...args);
  } else {
    console.log(`Unknown command: ${cmd}`);
    console.log('Commands:');
    for (const line of formatCliCommandHelpLines()) console.log(line);
    process.exit(1);
  }
}

// W-mq07mjzi000s1cc9: Help-flag token recognition.
//
// Matches the exact tokens the user typed on the CLI (`--help`, `-h`, `help`).
// Used by handleCommand's centralized guard and by per-command defensive
// checks (work/spawn/plan/complete) for defense-in-depth.
function isHelpToken(arg) {
  return arg === '--help' || arg === '-h' || arg === 'help';
}

// W-mq07mjzi000s1cc9 + W-mq5hk4u200081043: Stricter guard for command first-positionals.
//
// Real work-item titles, agent ids, plan source paths, and dispatch ids never
// start with `-` and are never bare `?`/`help`. Rejecting these catches the
// exact bug reported (`--help`) plus single-dash typos (`-X`, `-quiet`),
// question-mark probing (`?`), case-variant help (`Help`, `HELP`), and
// whitespace-only arguments that would otherwise create ghost WIs.
//
// handleCommand's narrow `isHelpToken` interceptor still handles the canonical
// `--help`/`-h`/`help` triplet by printing the global command list; anything
// else falls through to this defensive per-command guard which prints
// command-specific usage and exits 2.
function looksLikeFlagOrHelp(arg) {
  if (typeof arg !== 'string') return false;
  const t = arg.trim();
  if (!t) return true;
  if (t.startsWith('-')) return true;
  if (/^(help|\?)$/i.test(t)) return true;
  return false;
}

// SoT for engine-CLI metadata: drives handleCommand's help text and the
// CC preamble's CLI index in dashboard.js. Drift-checked against `commands`.
const CLI_COMMAND_DOCS = Object.freeze({
  start:    { args: '[--cli R] [--model M]', summary: 'Start engine daemon (R = registered runtime)' },
  stop:     { args: '',                      summary: 'Stop engine' },
  pause:    { args: '',                      summary: 'Pause dispatching' },
  resume:   { args: '',                      summary: 'Resume dispatching' },
  status:   { args: '',                      summary: 'Show engine + agent state + fleet config' },
  queue:    { args: '',                      summary: 'Show dispatch queue' },
  sources:  { args: '',                      summary: 'Show work source status per project' },
  discover: { args: '',                      summary: 'Dry-run work discovery' },
  dispatch: { args: '',                      summary: 'Force a dispatch cycle' },
  spawn:    { args: '<agent> <prompt>',      summary: 'Manually spawn an agent with prompt' },
  work:     { args: '<title> [opts]',        summary: 'Add to work-items.json queue' },
  plan:     { args: '<file|text> [project]', summary: 'Generate PRD from a plan (file or text)' },
  kill:       { args: '',                      summary: 'Kill all active agents, reset to pending' },
  complete:   { args: '<dispatch-id>',         summary: 'Mark a dispatch as done' },
  cleanup:    { args: '',                      summary: 'Clean temp files, worktrees, zombies' },
  'mcp-sync': { args: '',                      summary: 'Sync MCP servers from ~/.claude.json' },
  doctor:     { args: '',                      summary: 'Check prerequisites and runtime health' },
  config:     { args: 'set-cli <R> [--model M]', summary: 'Persist defaultCli/defaultModel without starting' },
  pr:         { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
  bridge:     { args: 'status|health|enable|disable',         summary: 'Constellation bridge: toggle and inspect the read-only cross-repo feed' },
});

function formatCliCommandHelpLines() {
  const entries = Object.entries(CLI_COMMAND_DOCS);
  const lefts = entries.map(([name, { args }]) => '  ' + name + (args ? ' ' + args : ''));
  const padTo = Math.max(...lefts.map(s => s.length)) + 2;
  return entries.map(([, { summary }], i) => lefts[i].padEnd(padTo) + summary);
}

// W-mq0770160001ee50 — `minions work --help` used to swallow `--help` as the
// work-item title and queue a literal junk item titled "--help"; `minions plan
// --help` dispatched a plan-to-prd agent against the inline text "--help".
// Every handler that takes a free-form positional arg must call this BEFORE
// using it as data so users get usage text instead of a state mutation.
function _isHelpArg(arg) {
  return arg === '--help' || arg === '-h' || arg === 'help';
}

// ─── Runtime fleet flags (--cli / --model / --effort) ────────────────────────
//
// Shared by `start`, `restart`, and `config set-cli`. Single source of truth
// for: flag parsing, runtime validation, incompatibility heuristics, and the
// atomic config write. AC: "All config writes use mutateJsonFileLocked on
// config.json" — the helper below is the only caller that mutates fleet keys.

/**
 * Strip `--cli <name>` / `--model <value>` / `--effort <level>` from `args`
 * (in-place), including `--flag=value` forms. Returns
 * `{ cli, model, effort, modelExplicit, errors }`. `modelExplicit` distinguishes
 * "user passed --model with empty string" (clear) from "no flag" (no-op).
 *
 * Errors (e.g. `--cli` with no follow-up token) are collected for the caller
 * to print + exit-non-zero, instead of throwing — matches existing CLI flow.
 */
function _parseRuntimeFlags(args) {
  const out = { cli: undefined, model: undefined, effort: undefined, modelExplicit: false, errors: [] };
  let i = 0;
  while (i < args.length) {
    const a = args[i];
    const eq = typeof a === 'string' ? a.indexOf('=') : -1;
    const flag = eq > 0 ? a.slice(0, eq) : a;
    const inlineValue = eq > 0 ? a.slice(eq + 1) : undefined;
    const readValue = (name, { allowEmpty = false, hint = '' } = {}) => {
      if (inlineValue !== undefined) {
        if (!allowEmpty && inlineValue === '') {
          out.errors.push(`${name} requires a value${hint}`);
          args.splice(i, 1);
          return { ok: false };
        }
        args.splice(i, 1);
        return { ok: true, value: inlineValue };
      }
      if (i + 1 >= args.length || String(args[i + 1]).startsWith('--')) {
        out.errors.push(`${name} requires a value${hint}`);
        args.splice(i, 1);
        return { ok: false };
      }
      const value = String(args[i + 1]);
      args.splice(i, 2);
      return { ok: true, value };
    };
    if (flag === '--cli') {
      const parsed = readValue('--cli');
      if (parsed.ok) out.cli = parsed.value;
    } else if (flag === '--model') {
      const parsed = readValue('--model', { allowEmpty: true, hint: ' (use --model "" to clear)' });
      if (!parsed.ok) continue;
      out.model = parsed.value;
      out.modelExplicit = true;
    } else if (flag === '--effort') {
      const parsed = readValue('--effort');
      if (parsed.ok) out.effort = parsed.value;
    } else {
      i++;
    }
  }
  return out;
}

/**
 * Heuristic flag for "this model is obviously wrong for this runtime". Used
 * to surface the "pass --model '' to clear" hint when a user switches CLIs
 * but leaves a stale model behind. Errs on the side of false-negatives —
 * unknown runtime → no opinion, runtime adapter without `modelLooksFamiliar`
 * → no opinion. Runtime-specific knowledge lives in the adapter (see
 * claude.js#modelLooksFamiliar) so adding a new runtime never requires
 * editing cli.js.
 */
function _modelLooksIncompatible(runtime, model) {
  if (!model) return false;
  let adapter;
  try { adapter = require('./runtimes').resolveRuntime(runtime); }
  catch { return false; } // unknown runtime → no opinion
  if (typeof adapter.modelLooksFamiliar !== 'function') return false; // adapter doesn't claim a model namespace → no opinion
  return !adapter.modelLooksFamiliar(model);
}

/**
 * Apply parsed `--cli`/`--model` flags to `config.json`. Returns
 * `{ warnings, applied }`. Throws when the runtime name is unknown — caller
 * decides whether to exit (start/restart/config-set-cli all do).
 *
 * Per-agent `cli` / `model` overrides under `config.agents.*` are NEVER
 * touched. Only `engine.defaultCli`, `engine.defaultModel`, `engine.ccCli`,
 * `engine.ccModel` are written. CC overrides are cleared on every fleet
 * change so the user's intent ("switch the whole fleet to X") is honored.
 */
function _applyRuntimeFlags({ cli, model, modelExplicit }) {
  const warnings = [];
  if (cli === undefined && !modelExplicit) {
    return { warnings, applied: false };
  }

  // Validate the runtime name BEFORE touching disk so typos fail loudly with
  // a list of registered runtimes — same UX shape as resolveRuntime() throws.
  let registered = [];
  try { registered = require('./runtimes').listRuntimes(); }
  catch { /* registry missing during partial install — skip validation */ }
  if (cli !== undefined && registered.length > 0 && !registered.includes(cli)) {
    const err = new Error(`Unknown CLI runtime "${cli}". Registered runtimes: ${registered.join(', ')}`);
    err._unknownCli = true;
    throw err;
  }

  const CONFIG_PATH = path.join(shared.MINIONS_DIR, 'config.json');
  shared.mutateJsonFileLocked(CONFIG_PATH, (cfg) => {
    if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return cfg;
    cfg.engine = cfg.engine || {};
    const e = cfg.engine;

    if (cli !== undefined) {
      // Effective post-write defaultModel: if the same call also passed --model,
      // use that value; otherwise the existing on-disk defaultModel wins.
      let effectiveModel;
      if (modelExplicit) effectiveModel = (model === '' ? undefined : model);
      else effectiveModel = e.defaultModel;
      if (_modelLooksIncompatible(cli, effectiveModel)) {
        warnings.push(`defaultModel "${effectiveModel}" appears incompatible with --cli ${cli}. Pass --model '' to clear it.`);
      }

      // Surface the implicit clear of an explicitly-set ccCli before doing it,
      // so users discover that fleet flags reset CC overrides.
      if (e.ccCli !== undefined && e.ccCli !== null && e.ccCli !== '') {
        warnings.push(`Clearing engine.ccCli (was "${e.ccCli}") so CC inherits the new defaultCli.`);
      }

      e.defaultCli = cli;
      delete e.ccCli;
    }

    if (modelExplicit) {
      if (model === '') delete e.defaultModel;
      else e.defaultModel = model;
      delete e.ccModel;
    }
    return cfg;
  });

  return { warnings, applied: true };
}

const commands = {
  start(...startArgs) {
    // Apply --cli / --model fleet flags before any engine wiring touches
    // config — the rest of start() reads config.engine assuming it's already
    // been mutated. Unknown runtime exits non-zero with the registered list.
    const flags = _parseRuntimeFlags(startArgs);
    if (flags.errors.length > 0) {
      for (const msg of flags.errors) console.error(`error: ${msg}`);
      process.exit(2); // 2 = misuse of shell builtins / bad CLI args
    }
    try {
      const { warnings } = _applyRuntimeFlags(flags);
      for (const w of warnings) console.log(`  ! ${w}`);
    } catch (err) {
      if (err && err._unknownCli) {
        console.error(`error: ${err.message}`);
        process.exit(2);
      }
      throw err;
    }

    // Startup state-file size guard (#1167): dispatch.json / cooldowns.json
    // bloated past 100 MB silently OOMed V8 on JSON.parse at startup. Fail fast
    // with an actionable message instead.
    try {
      for (const fp of [DISPATCH_PATH, path.join(ENGINE_DIR, 'cooldowns.json')]) {
        shared.assertStateFileSize(fp);
      }
    } catch (stateErr) {
      console.error('\n[engine] STARTUP ABORTED — ' + stateErr.message + '\n');
      process.exit(78); // 78 = configuration error
    }

    // Run preflight checks (warn but don't block — engine may still be useful)
    try {
      const { runPreflight, printPreflight } = require('./preflight');
      // Pass config so runtime-fleet warnings (P-3b8e5f1d) can fire pre-start.
      // getConfig() is cheap (cached); failure here is non-fatal — preflight
      // simply skips the runtime-config warnings when config is missing.
      let preflightConfig = null;
      try { preflightConfig = getConfig(); } catch { /* missing config handled below */ }
      const { results } = runPreflight({ config: preflightConfig });
      const hasFatal = results.some(r => r.ok === false);
      if (hasFatal) {
        printPreflight(results, { label: 'Preflight checks' });
        console.log('  Some checks failed — agents may not work. Run `minions doctor` for details.\n');
      } else {
        // Even on no-fatal startup, surface fleet-config warnings so users see
        // them inline during `minions start` (rather than only via doctor).
        const warns = results.filter(r => r.ok === 'warn');
        for (const w of warns) console.log(`  ! ${w.name}: ${w.message}`);
      }
    } catch (e) { console.error('preflight:', e.message); }

    const e = engine();
    const control = getControl();
    if (control.state === 'running') {
      let alive = false;
      if (control.pid) {
        try {
          if (process.platform === 'win32') {
            // On Windows, process.kill(pid, 0) can false-positive if the PID was recycled.
            // Use tasklist and verify the process is actually node.
            const { execSync } = require('child_process');
            const out = execSync(`tasklist /FI "PID eq ${control.pid}" /NH`, { encoding: 'utf8', windowsHide: true, timeout: 3000 });
            alive = out.includes(String(control.pid)) && out.toLowerCase().includes('node');
          } else {
            process.kill(control.pid, 0);
            alive = true;
          }
        } catch { /* process may be dead */ }
      }
      if (alive) {
        console.log(`Engine is already running (PID ${control.pid}).`);
        return;
      }
      console.log(`Engine was running (PID ${control.pid}) but process is dead — restarting.`);
    }

    // Record version + git commit so dashboard can detect stale engine code
    // Bust require cache so npm updates are detected after minions restart
    let codeVersion = null;
    try {
      const pkgPath = require.resolve('../package.json');
      delete require.cache[pkgPath];
      codeVersion = require('../package.json').version;
    } catch {}
    if (!codeVersion) {
      try {
        const pkgPath = require.resolve('@yemi33/minions/package.json');
        delete require.cache[pkgPath];
        codeVersion = require('@yemi33/minions/package.json').version;
      } catch {}
    }
    let codeCommit = null;
    try { codeCommit = require('child_process').execSync('git rev-parse --short HEAD', { cwd: path.resolve(__dirname, '..'), encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } catch {}
    const controlOwner = createControlOwner();
    mutateControl(() => ({
      state: 'running',
      pid: controlOwner.pid,
      ownerToken: controlOwner.ownerToken,
      started_at: e.ts(),
      codeVersion,
      codeCommit
    }));
    // W-mpg3bcp800075d4f — Prime control.heartbeat the moment this process
    // owns control.json, BEFORE the dispatch-recovery / startupReconcile*
    // chain. Those reconciles are synchronous and on a slow Windows host with
    // many projects/worktrees can run for 15-40s; without an early prime the
    // dashboard's 30s restart-grace window (dashboard/js/render-dispatch.js
    // → renderEngineAlert) expires against the *previous* engine's heartbeat
    // and the 'Engine heartbeat is stale' banner snaps back even though the
    // new engine is healthy. The 15s setInterval below keeps it fresh.
    writeHeartbeatNow();
    // Keep .minions-version in sync so `minions version` stays accurate after git pulls
    if (codeVersion) {
      try { fs.writeFileSync(path.join(shared.MINIONS_DIR, '.minions-version'), codeVersion); } catch {}
    }
    e.log('info', 'Engine started');
    // F1: clear stop-intent on every successful engine boot. Covers `node
    // engine.js start` (direct CLI), `minions start` (delegates to engine.js
    // start via engineCmds), and the dashboard's restartEngine path — all of
    // which would otherwise leave a prior `minions stop`'s stop-intent on
    // disk and silently stand down the external supervisor.
    try { shared.clearStopIntent(); } catch { /* best effort */ }
    console.log(`Engine started (PID: ${process.pid})`);

    const config = getConfig();
    // P-3b8e5f1d: promote legacy `engine.ccModel` to `engine.defaultModel` in
    // memory so single-model installs keep working after the runtime fleet
    // refactor. No disk write — the on-disk config still carries `ccModel`.
    try { shared.applyLegacyCcModelMigration(config, { logger: e.log }); }
    catch (err) { e.log('warn', `legacy ccModel migration failed: ${err.message}`); }

    // W-mq5s5ttx000j7ab8-a — One-shot canonical-gate migration. Project the
    // legacy `_contextOnly` / `_autoObserve` / `_manual` keys onto the
    // canonical `contextOnly` field on every `projects/<name>/pull-
    // requests.json` record BEFORE the first tick fires. `isAutoManagedPrRecord`
    // now reads `contextOnly` only, so any record the migration doesn't reach
    // before tick #1 would flip its auto-managed verdict. Idempotent.
    try {
      const projectsRoot = path.join(shared.MINIONS_DIR, 'projects');
      shared.migratePrGateFlags(projectsRoot);
    } catch (err) { e.log('warn', `pr-gate-migration failed: ${err.message}`); }

    // One-time force-on of the CC worker pool. The pool has been the resolved
    // default for copilot CC since PR #2492, but configs still carrying an
    // explicit `ccUseWorkerPool: false` (set before the default flipped) stay
    // opted out forever. Flip those to ON once, persisting the
    // `engine._ccPoolForcedOnV1` marker so a deliberate later opt-out sticks.
    // Disk-side re-derives from the on-disk copy so a concurrent dashboard
    // write isn't clobbered; skipWriteIfUnchanged makes it a no-op once marked.
    try {
      const forced = shared.applyCcWorkerPoolForceOnMigration(config);
      if (forced.changed) {
        const configPath = path.join(shared.MINIONS_DIR, 'config.json');
        shared.mutateJsonFileLocked(configPath, (onDisk) => {
          shared.applyCcWorkerPoolForceOnMigration(onDisk);
          return onDisk;
        }, { defaultValue: {}, skipWriteIfUnchanged: true });
        if (forced.flipped.length) {
          e.log('info', `Forced CC worker pool ON — flipped explicit opt-out in: ${forced.flipped.join(', ')}`);
          console.log(`  Forced CC worker pool ON (was explicitly off in: ${forced.flipped.join(', ')}).`);
        }
      }
    } catch (err) { e.log('warn', `cc worker pool force-on migration failed: ${err.message}`); }

    // Repair work-item note links that broke before the rewriter covered
    // completion-report artifact links (W-mq1j85cj00055a8f). Notes the engine
    // auto-archived back then left the WI pill pointing at the gone
    // notes/inbox/<name> path; this repoints them to the archived/KB location.
    // Idempotent (only touches provably-broken + resolvable links) and routed
    // through mutateWorkItems so SQL + JSON mirror stay consistent — a no-op
    // once healed, so it's safe to run on every boot.
    try {
      const res = require('./note-link-backfill').runNoteLinkBackfill({ config });
      if (res.fixedLinks > 0) {
        e.log('info', `Repointed ${res.fixedLinks} broken note link(s) across ${res.fixedItems} work item(s)`);
        console.log(`  Repaired ${res.fixedLinks} broken note link(s) in ${res.fixedItems} work item(s).`);
      }
    } catch (err) { e.log('warn', `note-link backfill failed: ${err.message}`); }

    // Auto-heal projects missing workSources (cloned-repo / hand-rolled-config
    // footgun): without this block, discoverFromWorkItems / discoverFromPrs
    // bail silently and the engine looks healthy but never dispatches. The
    // disk-side mutation re-derives heal state from the on-disk copy so we
    // don't clobber a concurrent dashboard write between the in-memory check
    // and the lock acquire. skipWriteIfUnchanged makes the write a no-op when
    // nothing needed healing (e.g. dashboard already fixed it).
    try {
      const heal = shared.backfillProjectWorkSourceDefaults(config);
      if (heal.changed) {
        const configPath = path.join(shared.MINIONS_DIR, 'config.json');
        shared.mutateJsonFileLocked(configPath, (onDisk) => {
          shared.backfillProjectWorkSourceDefaults(onDisk);
          return onDisk;
        }, { defaultValue: {}, skipWriteIfUnchanged: true });
        for (const h of heal.healed) {
          e.log('info', `Auto-healed project "${h.project}" — backfilled missing workSources: ${h.sources.join(', ')}`);
        }
        console.log(`  Auto-healed ${heal.healed.length} project(s) with missing workSources defaults — engine will now dispatch their work.`);
      }
    } catch (err) { e.log('warn', `workSources auto-heal failed: ${err.message}`); }
    const interval = config.engine?.tickInterval || shared.ENGINE_DEFAULTS.tickInterval;

    const { getProjects } = require('./shared');
    const projects = getProjects(config);
    if (projects.length === 0) {
      console.log('  \x1b[33mNo projects configured.\x1b[0m Link one with: minions add <path-to-repo>');
      console.log('  Agents can still work on tasks via the Command Center without a project.');
    }
    for (const p of projects) {
      const root = p.localPath ? path.resolve(p.localPath) : null;
      if (!root || !fs.existsSync(root)) {
        e.log('warn', `Project "${p.name}" path not found: ${p.localPath} — skipping`);
        console.log(`  WARNING: ${p.name} path not found: ${p.localPath}`);
      } else {
        try {
          shared.ensureProjectStateFiles(p);
        } catch (err) {
          e.log('warn', `Project state setup failed for "${p.name}": ${err.message}`);
        }
        console.log(`  Project: ${p.name} (${root})`);
      }
    }

    e.validateConfig(config);
    e.loadCooldowns();

    // Re-attach to surviving agent processes from previous session
    const dispatch = getDispatch();
    const activeOnStart = (dispatch.active || []);
    if (activeOnStart.length > 0) {
      let reattached = 0;
      for (const item of activeOnStart) {
        const agentId = item.agent;
        let agentPid = readDispatchPid(item.id);

        if (!agentPid) {
          const status = getAgentStatus(agentId);
          if (status.dispatch_id === item.id) {
            const liveLog = path.join(AGENTS_DIR, agentId, 'live-output.log');
            try {
              const stat = fs.statSync(liveLog);
              const ageMs = Date.now() - stat.mtimeMs;
              if (ageMs < 300000) {
                agentPid = -1;
              }
            } catch { /* optional */ }
          }
        }

        const hadPid = agentPid && agentPid > 0; // track before liveness check
        if (agentPid && agentPid > 0 && !isPidAlive(agentPid)) agentPid = null;

        // PID was found but confirmed dead — exempt from restart grace period (#869)
        if (hadPid && !agentPid) {
          e.engineRestartGraceExempt.add(item.id);
        }

        if (agentPid) {
          // Load sessionId from session.json for steering support, but only
          // when its saved branch still matches the active dispatch branch.
          let sessionId = null;
          try {
            const sj = safeJson(path.join(AGENTS_DIR, agentId, 'session.json'));
            const expectedBranch = dispatchSessionBranch(item);
            const savedBranch = normalizeSessionBranch(sj?.branch);
            if (sj?.sessionId && (!expectedBranch || savedBranch === expectedBranch)) {
              sessionId = sj.sessionId;
            } else if (sj?.sessionId && expectedBranch && sj?.dispatchId === item.id) {
              // Only warn when the saved session is for THIS dispatch but on the
              // wrong branch — that's a true anomaly worth flagging. The common
              // case — leftover session.json from a previous (now-completed)
              // dispatch on a different branch — is expected and silent, since
              // the engine writes session.json on completion of each dispatch
              // and a fresh dispatch may run on a different branch before
              // saveSession overwrites it (W-mpbn93ou000611b3).
              shared.log('warn', `Reattach: ignoring session for ${agentId} on branch ${savedBranch || 'unknown'}; expected ${expectedBranch}`);
            }
          } catch {}
          const runtimeName = item.runtimeName || item.runtime || item.meta?.runtimeName || item.meta?.runtime || null;
          e.activeProcesses.set(item.id, { proc: { pid: agentPid > 0 ? agentPid : null }, agentId, startedAt: item.created_at, reattached: true, sessionId, runtimeName });
          // Sync work item status to dispatched — atomic write to avoid lifecycle lazy init issues
          if (item.meta?.item?.id && item.meta?.project?.localPath) {
            try {
              const wiPath = path.join(MINIONS_DIR, "projects", item.meta.project.name, "work-items.json");
              mutateWorkItems(wiPath, items => {
                const wi = items.find(w => w.id === item.meta.item.id);
                if (wi && wi.status !== WI_STATUS.DISPATCHED) {
                  wi.status = WI_STATUS.DISPATCHED;
                  wi.dispatched_to = wi.dispatched_to || agentId;
                  wi.dispatched_at = wi.dispatched_at || ts();
                }
              });
            } catch (err) { console.log(`    Warning: failed to sync work item status: ${err.message}`); }
          }
          reattached++;
          e.log('info', `Re-attached to ${agentId} (${item.id}) — PID ${agentPid > 0 ? agentPid : 'unknown (active output)'}`);
        }
      }

      const unattached = activeOnStart.length - reattached;
      if (unattached > 0) {
        const gracePeriod = config.engine?.restartGracePeriod || shared.ENGINE_DEFAULTS.restartGracePeriod;
        e.engineRestartGraceUntil = Date.now() + gracePeriod;
        console.log(`  ${unattached} unattached dispatch(es) — ${gracePeriod / 60000}min grace period`);
      }
      if (reattached > 0) {
        console.log(`  Re-attached to ${reattached} surviving agent(s)`);
      }
      for (const item of activeOnStart) {
        const attached = e.activeProcesses.has(item.id);
        console.log(`    ${attached ? '\u2713' : '?'} ${item.agentName || item.agent}: ${(item.task || '').slice(0, 70)}`);
      }
    }

    // Orphan completion detection: for dispatch entries that couldn't re-attach,
    // check if the agent actually completed by scanning its output file.
    // If it did, run the post-completion hooks now so work items get updated.
    (function detectOrphanCompletions() {
      const shared = require('./shared');
      const lifecycle = require('./lifecycle');
      let recovered = 0;

      for (const item of activeOnStart) {
        if (e.activeProcesses.has(item.id)) continue; // re-attached, skip

        const agentId = item.agent;
        const outputPath = path.join(MINIONS_DIR, 'agents', agentId, 'live-output.log');
        try {
          const output = fs.readFileSync(outputPath, 'utf8');

          // Only process if the session actually emitted a result line — no result means the
          // session was still running when the engine died and should be requeued, not failed.
          // Tool-level is_error:true (e.g. a Read on a missing file) must not be confused with
          // a session-level error, so we scope the is_error check to the result line only.
          const resultIdx = output.search(/"type"\s*:\s*"result"/);
          if (resultIdx === -1) continue;

          const resultLineEnd = output.indexOf('\n', resultIdx);
          const resultLine = output.slice(resultIdx, resultLineEnd === -1 ? output.length : resultLineEnd);
          const hasError = resultLine.includes('"is_error":true') || resultLine.includes('"is_error": true');

          let isSuccess = !hasError;

          // Extract PRs from output first — if PRs were created, the agent succeeded
          // regardless of intermediate error lines in the log
          let prsCreated = 0;
          try {
            prsCreated = lifecycle.syncPrsFromOutput(output, agentId, item.meta, config);
          } catch (err) { e.log('warn', `Orphan PR sync: ${err.message}`); }

          // If PRs were created or a matching PR exists, treat as success
          if (!isSuccess && prsCreated > 0) {
            e.log('info', `Orphan recovery: ${agentId} (${item.id}) has ${prsCreated} PR(s) — overriding to success`);
            isSuccess = true;
          }

          // Fallback: check pull-requests.json for a matching PR by work item ID
          if (!isSuccess && item.meta?.item?.id) {
            try {
              const projName = item.meta.project?.name;
              if (projName) {
                const prPath = path.join(MINIONS_DIR, 'projects', projName, 'pull-requests.json');
                const prs = safeJson(prPath) || [];
                const matchingPr = prs.find(pr =>
                  (pr.prdItems || []).includes(item.meta.item.id) &&
                  pr.status !== 'abandoned' && pr.status !== 'closed'
                );
                if (matchingPr) {
                  e.log('info', `Orphan recovery: ${agentId} (${item.id}) has matching PR ${matchingPr.id} — overriding to success`);
                  isSuccess = true;
                }
              }
            } catch (err) { e.log('warn', `Orphan PR lookup: ${err.message}`); }
          }

          const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
          e.log('info', `Orphan recovery: ${agentId} (${item.id}) completed while engine was down — result: ${result}`);

          // Update work item status
          if (item.meta?.item?.id) {
            const status = isSuccess ? WI_STATUS.DONE : WI_STATUS.FAILED;
            try {
              lifecycle.updateWorkItemStatus(item.meta, status, isSuccess ? '' : 'Completed while engine was down');
            } catch {
              // Atomic file write fallback
              try {
                const projName = item.meta.project?.name;
                if (projName) {
                  const wiPath = path.join(MINIONS_DIR, 'projects', projName, 'work-items.json');
                  mutateWorkItems(wiPath, items => {
                    const wi = items.find(w => w.id === item.meta.item.id);
                    if (wi) {
                      wi.status = status;
                      if (isSuccess) { wi.completedAt = ts(); delete wi.failReason; }
                      else { wi.failedAt = ts(); wi.failReason = 'Completed while engine was down'; }
                    }
                  });
                }
              } catch (err) { e.log('warn', `Orphan WI fallback: ${err.message}`); }
            }
          }

          // Move from active to completed in dispatch
          try {
            e.completeDispatch(
              item.id,
              result,
              isSuccess ? 'Completed (orphan recovery)' : 'Failed (orphan recovery)',
              '',
              { processWorkItemFailure: false }
            );
          } catch (err) { e.log('warn', `Orphan dispatch complete: ${err.message}`); }

          // Check plan completion
          if (isSuccess && item.meta?.item?.sourcePlan) {
            try { lifecycle.checkPlanCompletion(item.meta, config); } catch (err) { e.log('warn', `Orphan plan completion: ${err.message}`); }
          }

          recovered++;
          console.log(`    ✓ Recovered ${agentId}: ${(item.task || '').slice(0, 60)} → ${result}${prsCreated ? ' (' + prsCreated + ' PR)' : ''}`);
        } catch (err) { e.log('warn', `Orphan recovery: ${err.message}`); }
      }
      if (recovered > 0) {
        e.log('info', `Orphan recovery: processed ${recovered} completion(s) from previous session`);
        console.log(`  Recovered ${recovered} orphaned completion(s)`);
      }
    })();

    // Recovery sweep
    (function recoverBrokenState() {
      const shared = require('./shared');
      const projects = shared.getProjects(config);
      let fixes = 0;

      const activeIds = new Set((dispatch.active || []).map(d => d.meta?.item?.id).filter(Boolean));
      const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
      for (const p of projects) {
        allWiPaths.push(path.join(MINIONS_DIR, "projects", p.name, "work-items.json"));
      }
      for (const wiPath of allWiPaths) {
        try {
          mutateWorkItems(wiPath, items => {
            for (const item of items) {
              if (item.status === WI_STATUS.DISPATCHED && !activeIds.has(item.id)) {
                item.status = WI_STATUS.PENDING;
                delete item.dispatched_at;
                delete item.dispatched_to;
                fixes++;
                e.log('info', `Recovery: reset stuck item ${item.id} from dispatched → pending`);
              }
            }
          });
        } catch (err) { e.log('warn', `Recovery WI reset: ${err.message}`); }
      }

      // Plan chain recovery removed — plans require explicit user execution via dashboard

      if (fixes > 0) {
        console.log(`  Recovery: fixed ${fixes} broken state issue(s)`);
      }
    })();

    // W-mp60tw0u000j3931: One-shot startup reconciliation for `abandoned` PRs.
    // Pairs with W-mp5trwh60008386d's forward-looking 404 hardening — that
    // fix prevents future false-flips, this pass un-flips PRs that were
    // already wrongly marked `abandoned` before the hardening shipped (e.g.
    // the 16 PRs hit by the 2026-05-14 gh-auth-flip incident). Version-gated
    // via engine/state.json so it runs once per ENGINE_DEFAULTS.abandonedReconciliationVersion
    // bump and is otherwise a no-op. Fire-and-forget — boot must not block on
    // network calls; the pass operates on `abandoned` PRs while pollPrStatus
    // operates on `active`/`linked`, so there is no per-record race.
    (function startupReconcileAbandonedPrs() {
      try {
        const reconciler = require('./abandoned-pr-reconciliation');
        Promise.resolve(reconciler.runStartupReconciliation(config))
          .then(result => {
            if (!result.skipped && result.totals.flipped > 0) {
              console.log(`  Reconciled ${result.totals.flipped} abandoned PR(s) → active/merged/closed`);
            }
          })
          .catch(err => {
            e.log('warn', `Abandoned PR reconciliation failed at boot: ${err.message}`);
          });
      } catch (err) {
        e.log('warn', `Abandoned PR reconciliation failed to start at boot: ${err.message}`);
      }
    })();

    // W-mp68q6ke0010de68 — Boot reconcile for keep_processes: clear out any
    // stale agents/<id>/keep-pids.json files left over from a prior engine
    // process (TTL expired while engine was down, or all declared PIDs are
    // dead). Run once at boot before the first tick. Idempotent.
    (function startupReconcileKeepProcesses() {
      try {
        const { sweepKeepProcesses } = require('./keep-process-sweep');
        const stats = sweepKeepProcesses();
        if (stats.scanned > 0 && (stats.expiredFiles || stats.deadFiles)) {
          console.log(`  Keep-processes boot reconcile: ${stats.expiredFiles} expired, ${stats.deadFiles} dead, ${stats.killedPids} killed`);
        }
      } catch (err) {
        e.log('warn', `Keep-processes boot reconcile failed: ${err.message}`);
      }
    })();

    // P-8a4d6f29 — Boot reconcile for managed-spawn: drop dead-PID rows from
    // engine/managed-processes.json, kill TTL-expired specs, re-probe
    // survivors once. Wrapped in Promise.race against
    // ENGINE_DEFAULTS.managedSpawn.bootReconcileMaxMs (2s default) so a
    // malformed or oversized state file cannot block engine boot. Async +
    // fire-and-forget — the first tick is allowed to start regardless.
    (function startupReconcileManagedSpawn() {
      try {
        const managedSpawn = require('./managed-spawn');
        const limits = (require('./shared').ENGINE_DEFAULTS.managedSpawn || {});
        if (limits.enabled === false) return;
        const maxMs = Math.max(1, Number(limits.bootReconcileMaxMs) || 2000);
        let settled = false;
        const timeoutPromise = new Promise((resolve) => {
          setTimeout(() => { if (!settled) { settled = true; resolve({ timedOut: true }); } }, maxMs).unref?.();
        });
        const reconcilePromise = Promise.resolve()
          .then(() => managedSpawn.bootReconcileManagedSpawn())
          .then((r) => { settled = true; return { timedOut: false, result: r }; })
          .catch((err) => { settled = true; return { timedOut: false, error: err }; });
        Promise.race([reconcilePromise, timeoutPromise]).then((outcome) => {
          if (outcome.timedOut) {
            e.log('warn', `Managed-spawn boot reconcile exceeded ${maxMs}ms; continuing without it`);
            return;
          }
          if (outcome.error) {
            e.log('warn', `Managed-spawn boot reconcile failed: ${outcome.error.message}`);
            return;
          }
          const s = outcome.result && outcome.result.stats;
          if (s && s.scanned > 0 && (s.ttlExpired || s.deadDropped || s.rotatedLogs)) {
            console.log(`  Managed-spawn boot reconcile: scanned=${s.scanned} ttl=${s.ttlExpired} dead=${s.deadDropped} killed=${s.killedPids} rotated=${s.rotatedLogs}`);
          }
        });
      } catch (err) {
        e.log('warn', `Managed-spawn boot reconcile failed to start: ${err.message}`);
      }
    })();

    // W-mp73ya3e000me6c5 — Boot reconcile for the worktree pool: drop entries
    // whose path is gone (manual rm), whose borrower crashed before
    // returning, or whose idle TTL expired while the engine was down. The
    // tick-time prune in cleanup.js will catch anything that piles up later;
    // boot prune just keeps re-attach sane.
    (function startupReconcileWorktreePool() {
      try {
        const worktreePool = require('./worktree-pool');
        const dispatchSnap = require('./queries').getDispatch();
        const activeIds = new Set((dispatchSnap.active || []).map(d => d.id));
        const cfg = require('./queries').getConfig();
        const idleTtlMs = cfg?.engine?.worktreePoolIdleTtlMs ?? require('./shared').ENGINE_DEFAULTS.worktreePoolIdleTtlMs;
        const result = worktreePool.pruneStale({ activeDispatchIds: activeIds, idleTtlMs });
        if (result.evicted > 0) {
          console.log(`  Worktree-pool boot reconcile: evicted ${result.evicted} stale entr${result.evicted === 1 ? 'y' : 'ies'}`);
        }
      } catch (err) {
        e.log('warn', `Worktree-pool boot reconcile failed: ${err.message}`);
      }
    })();

    // W-mpbqhstz001lf518 — Boot reconcile for orphan worktrees (closes #2627).
    // Walks each project's `<root>/<worktreeRoot>/` dir and removes any
    // `W-*` (or posix `<proj>-<branch>-<hash>`) subdir whose dispatch id
    // is NOT in dispatch.active/pending AND that is NOT a pool member.
    // Such dirs are left over from a prior crash and block retries with
    // `fatal: 'work/W-<id>' is already used by worktree at …`. Runs AFTER
    // worktreePool.pruneStale so the pool snapshot we cross-reference is
    // already settled (otherwise we could evict a dir whose pool entry was
    // about to be pruned but still appeared as a pool member).
    (function startupReconcileOrphanWorktrees() {
      try {
        const sharedMod = require('./shared');
        const worktreeGc = require('./worktree-gc');
        const dispatchSnap = require('./queries').getDispatch();
        const cfg = require('./queries').getConfig();
        const projects = sharedMod.getProjects(cfg);
        if (projects.length === 0) return;
        const worktreeRootRel = cfg?.engine?.worktreeRoot || sharedMod.ENGINE_DEFAULTS.worktreeRoot;
        const result = worktreeGc.pruneOrphanWorktrees({
          projects,
          dispatchSnap,
          worktreeRootRel,
          log: (lvl, msg) => e.log(lvl, msg),
          config: cfg,
          writeToInbox: (a, s, c) => { try { return sharedMod.writeToInbox(a, s, c); } catch (_e) { return false; } },
        });
        if (result.evicted > 0 || result.failed > 0) {
          console.log(`  Worktree boot reconcile: evicted ${result.evicted} orphan worktree(s)${result.failed ? `, ${result.failed} failed` : ''} (scanned ${result.scanned}, kept ${result.kept})`);
        }
        // W-mq5o6bvy000x7191 (Layer 3): out-of-root scan — catches worktrees
        // registered in `git worktree list` but living outside the configured
        // `worktreeRoot` (e.g. ad-hoc `D:/tmp-*/` dirs left by previous
        // experiments). Same dispatch snapshot so branch protection is
        // consistent. Also runs `git worktree prune --expire=now` per project
        // so manually-removed dirs drop out of the registry immediately.
        const regResult = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
          projects,
          dispatchSnap,
          worktreeRootRel,
          log: (lvl, msg) => e.log(lvl, msg),
          config: cfg,
          writeToInbox: (a, s, c) => { try { return sharedMod.writeToInbox(a, s, c); } catch (_e) { return false; } },
        });
        if (regResult.evicted > 0 || regResult.failed > 0 || regResult.prunedRegistry > 0) {
          console.log(`  Worktree registry reconcile: scanned ${regResult.scanned}, evicted ${regResult.evicted}, kept ${regResult.kept}, failed ${regResult.failed}, registry-pruned ${regResult.prunedRegistry}`);
        }
      } catch (err) {
        e.log('warn', `Worktree boot reconcile failed: ${err.message}`);
      }
    })();

    // W-mp7gox8w000n8936 — Boot reconcile for kb-sweep state: clear stale
    // `in-flight`/`starting` records left over from a crashed runner (or a
    // legacy pre-pid runner). Without this, the record sits there clogging
    // /api/knowledge/sweep/status until someone POSTs a new sweep. Idempotent.
    (function startupReconcileKbSweep() {
      try {
        const { reconcileSweepStateOnBoot } = require('./kb-sweep');
        const stats = reconcileSweepStateOnBoot();
        if (stats.released > 0) {
          console.log(`  KB-sweep boot reconcile: released stale ${stats.prevStatus} record (pid=${stats.prevPid}, reason=${stats.reason})`);
        }
      } catch (err) {
        e.log('warn', `KB-sweep boot reconcile failed: ${err.message}`);
      }
    })();

    // W-mpetru8a000s123a — warm the async KB cache so synchronous callers
    // (getWorkItems, getKnowledgeBaseIndex, playbook render) see real data
    // on first read instead of the empty snapshot. Fire-and-forget; the cache
    // updates as soon as the scan resolves and any inflight async caller
    // shares the same promise.
    (function warmKnowledgeBaseCache() {
      try {
        const queries = require('./queries');
        Promise.resolve(queries.getKnowledgeBaseEntries())
          .catch(err => e.log('warn', `KB cache warm failed: ${err && err.message}`));
      } catch (err) {
        e.log('warn', `KB cache warm setup failed: ${err.message}`);
      }
    })();

    // Initial tick
    e.tick();

    // Start tick loop
    const tickTimer = setInterval(() => e.tick(), interval);

    // W-mpcyvff6000pf828 (#2653) — Heartbeat decoupled from tickInner.
    // The dashboard flips the engine badge to STALE when
    // `Date.now() - control.heartbeat > 120000`; tying the write to tickInner
    // meant any legitimately slow tick (cold runtime spawn, sequential
    // ADO/gh polls, slow worktree create) blocked the next heartbeat and
    // surfaced a healthy engine as crashed. We now write every 15s on a
    // dedicated interval — 8× headroom vs the 120s threshold even under
    // event-loop pressure, and orders of magnitude under TICK_TIMEOUT_MS so
    // a hung tick still looks distinct from a wedged event loop. The first
    // (priming) heartbeat write lives near the top of this handler — see the
    // W-mpg3bcp800075d4f comment above mutateControl — so that the dashboard's
    // 30s restart grace window sees a fresh heartbeat regardless of how long
    // the boot reconcile chain takes.
    const heartbeatTimer = setInterval(writeHeartbeatNow, HEARTBEAT_INTERVAL_MS);

    // Fast poll: check steering every 1s (lightweight — just fs.stat per agent)
    // and wakeup signals every 1s (control.json read)
    const { checkSteering } = require('./timeout');
    const fastPollTimer = setInterval(() => {
      try { checkSteering(); } catch {}
      const ctrl = getControl();
      if (ctrl._wakeupAt && Date.now() - ctrl._wakeupAt < 5000) {
        delete ctrl._wakeupAt;
        mutateControl((control) => {
          delete control._wakeupAt;
          return control;
        });
        e.tick();
      }
    }, 1000);

    console.log(`Tick interval: ${interval / 1000}s | Max concurrent: ${config.engine?.maxConcurrent || 5}`);
    console.log('Press Ctrl+C to stop');

    // File-change-driven work discovery — trigger tick when work-items or PRDs change
    const _watchedFiles = new Set();
    let _globalDebounce = null;
    function watchForWorkChanges() {
      const filesToWatch = [
        path.join(MINIONS_DIR, 'work-items.json'),
        // dispatch.json excluded — it changes every tick, causing a feedback loop
      ];
      // Watch project-specific work-items.json
      const { getProjects } = require('./shared');
      for (const p of getProjects(config)) {
        filesToWatch.push(shared.projectWorkItemsPath(p));
      }
      // Watch PRD files
      const prdDir = path.join(MINIONS_DIR, 'prd');
      try {
        for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
          filesToWatch.push(path.join(prdDir, f));
        }
      } catch { /* optional */ }

      for (const filePath of filesToWatch) {
        if (_watchedFiles.has(filePath)) continue;
        _watchedFiles.add(filePath);
        try {
          fs.watchFile(filePath, { interval: 2000 }, () => {
            // Global debounce — coalesce rapid multi-file changes into one tick
            if (_globalDebounce) clearTimeout(_globalDebounce);
            _globalDebounce = setTimeout(() => {
              _globalDebounce = null;
              e.log('info', `File change detected — triggering tick`);
              e.tick();
            }, 5000);
          });
        } catch { /* optional */ }
      }
    }
    watchForWorkChanges();

    // Drain in-memory buffers (metrics, logs) before any process.exit. Logs
    // a stderr line if a flush fails so a refactor mistake (typo, missing
    // export) doesn't go silent.
    function drainBuffers() {
      try { require('./llm').flushMetricsBuffer(); }
      catch (err) { try { console.error(`[shutdown] flushMetricsBuffer failed: ${err.message}`); } catch {} }
      try { shared.flushLogs(); }
      catch (err) { try { console.error(`[shutdown] flushLogs failed: ${err.message}`); } catch {} }
    }

    // Graceful shutdown — wait for active agents before exiting
    let shuttingDown = false;
    function gracefulShutdown(signal) {
      if (shuttingDown) return;
      shuttingDown = true;
      console.log(`\n${signal} received — initiating graceful shutdown...`);
      clearInterval(tickTimer);
      clearInterval(fastPollTimer);
      clearInterval(heartbeatTimer);
      for (const f of _watchedFiles) { try { fs.unwatchFile(f); } catch { /* cleanup */ } }
      const stoppingAt = e.ts();
      const stoppingWrite = markControlStoppingForOwner(controlOwner, stoppingAt);
      if (!stoppingWrite.changed) {
        e.log('warn', 'Graceful shutdown skipped control.json stopping transition; control file is owned by a different engine process');
      }
      e.log('info', `Graceful shutdown initiated (${signal})`);

      if (e.activeProcesses.size === 0) {
        const stoppedWrite = markControlStoppedForOwner(controlOwner, e.ts());
        if (!stoppedWrite.changed) {
          e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
        }
        e.log('info', 'Graceful shutdown complete (no active agents)');
        drainBuffers();
        console.log('No active agents — stopped.');
        process.exit(0);
      }

      console.log(`Waiting for ${e.activeProcesses.size} active agent(s) to finish...`);
      const timeout = config.engine?.shutdownTimeout || shared.ENGINE_DEFAULTS.shutdownTimeout;
      const deadline = Date.now() + timeout;

      const poll = setInterval(() => {
        if (e.activeProcesses.size === 0) {
          clearInterval(poll);
          const stoppedWrite = markControlStoppedForOwner(controlOwner, e.ts());
          if (!stoppedWrite.changed) {
            e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
          }
          e.log('info', 'Graceful shutdown complete (all agents finished)');
          drainBuffers();
          console.log('All agents finished — stopped.');
          process.exit(0);
        }
        if (Date.now() >= deadline) {
          clearInterval(poll);
          const stoppedWrite = markControlStoppedForOwner(controlOwner, e.ts());
          if (!stoppedWrite.changed) {
            e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
          }
          e.log('warn', `Graceful shutdown timed out after ${timeout / 1000}s with ${e.activeProcesses.size} agent(s) still active`);
          drainBuffers();
          console.log(`Shutdown timeout (${timeout / 1000}s) — force exiting with ${e.activeProcesses.size} agent(s) still running.`);
          process.exit(1);
        }
      }, 2000);
    }

    process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
    process.on('SIGINT', () => gracefulShutdown('SIGINT'));

    // Crash handlers — log the error and exit cleanly so the process is detectable as crashed
    process.on('unhandledRejection', (reason) => {
      const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
      console.error(`[FATAL] Unhandled promise rejection: ${msg}`);
      try { shared.log('fatal', `Unhandled promise rejection: ${msg}`); } catch { /* best effort */ }
      drainBuffers();
      process.exit(1);
    });

    process.on('uncaughtException', (err) => {
      const msg = err instanceof Error ? err.stack || err.message : String(err);
      console.error(`[FATAL] Uncaught exception: ${msg}`);
      try { shared.log('fatal', `Uncaught exception: ${msg}`); } catch { /* best effort */ }
      drainBuffers();
      process.exit(1);
    });
  },

  stop() {
    const e = engine();
    const dispatch = getDispatch();
    const active = (dispatch.active || []);
    if (active.length > 0) {
      console.log(`\n  WARNING: ${active.length} agent(s) are still working:`);
      for (const item of active) {
        console.log(`    - ${item.agentName || item.agent}: ${(item.task || '').slice(0, 80)}`);
      }
      console.log('\n  These agents will continue running but the engine won\'t monitor them.');
      console.log('  On next start, they\'ll get a 20-min grace period before being marked as orphans.');
      console.log('  To kill them now, run: node engine.js kill\n');
    }
    // Write stop-intent BEFORE killing so the external supervisor (engine/
    // supervisor.js) sees the flag on its next tick and stands down instead
    // of respawning. Cleared by `minions start`/`restart` AND by every
    // successful engine boot (engine/cli.js#start) — so a direct
    // `node engine.js start` after this stop still clears the flag.
    if (!shared.writeStopIntent('engine.js stop')) {
      e.log('warn', 'Failed to write stop-intent');
    }
    const control = getControl();
    if (control.pid && control.pid !== process.pid) {
      try { process.kill(control.pid); } catch { /* process may be dead */ }
    }
    mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
    e.log('info', 'Engine stopped');
    console.log('Engine stopped.');
  },

  pause() {
    const e = engine();
    mutateControl(() => ({ state: 'paused', paused_at: e.ts() }));
    e.log('info', 'Engine paused');
    console.log('Engine paused. Run `node .minions/engine.js resume` to resume.');
  },

  resume() {
    const e = engine();
    const control = getControl();
    if (control.state === 'running') {
      console.log('Engine is already running.');
      return;
    }
    mutateControl(() => ({ state: 'running', resumed_at: e.ts() }));
    e.log('info', 'Engine resumed');
    console.log('Engine resumed.');
  },

  status() {
    const e = engine();
    const config = getConfig();
    const control = getControl();
    const dispatch = getDispatch();
    const agents = config.agents || {};

    const { getProjects } = require('./shared');
    const projects = getProjects(config);

    // Version info
    let version = '?';
    try {
      const vFile = path.join(MINIONS_DIR, '.minions-version');
      version = fs.readFileSync(vFile, 'utf8').trim();
    } catch { /* optional */ }

    console.log('\n=== Minions Engine ===\n');
    console.log(`Version: ${version}`);

    // Engine state with liveness check
    let engineAlive = false;
    if (control.state === 'running' && control.pid) {
      try {
        if (process.platform === 'win32') {
          const { execSync } = require('child_process');
          const out = execSync(`tasklist /FI "PID eq ${control.pid}" /NH`, { encoding: 'utf8', windowsHide: true, timeout: 3000 });
          engineAlive = out.includes(String(control.pid)) && out.toLowerCase().includes('node');
        } else {
          process.kill(control.pid, 0);
          engineAlive = true;
        }
      } catch { /* process may be dead */ }
    }
    if (control.state === 'running' && !engineAlive) {
      console.log(`Engine: stale (PID ${control.pid} is dead) — run: minions start`);
    } else {
      console.log(`Engine: ${control.state} (PID ${control.pid || 'N/A'})`);
    }

    // Dashboard check. Honors MINIONS_PORT so `minions --dev status` probes the
    // dev dashboard (7332 by default) rather than the binary install's 7331.
    const http = require('http');
    const dashPort = Number(process.env.MINIONS_PORT) || 7331;
    const dashCheck = new Promise(resolve => {
      const req = http.get(`http://localhost:${dashPort}/api/health`, { timeout: 2000 }, () => resolve(true));
      req.on('error', () => resolve(false));
      req.on('timeout', () => { req.destroy(); resolve(false); });
    });
    dashCheck.then(dashUp => {
      if (dashUp) console.log(`Dashboard: running (http://localhost:${dashPort})`);
      else console.log('Dashboard: not running — start with: minions dash');
    }).catch(() => {});

    // Projects with health
    const healthyProjects = projects.filter(p => p.localPath && fs.existsSync(path.resolve(p.localPath)));
    const missingProjects = projects.filter(p => !p.localPath || !fs.existsSync(path.resolve(p.localPath)));
    console.log(`Projects: ${healthyProjects.length} linked${missingProjects.length ? ` (${missingProjects.length} path missing)` : ''}`);

    // Fleet runtime config — what every spawn defaults to + every override.
    // Only fields that diverge from ENGINE_DEFAULTS are listed under "Flags",
    // so a clean install prints nothing extra and a tweaked install reads at
    // a glance. Per AC: defaultCli/defaultModel always shown, non-default
    // flags + CC overrides shown when set.
    const eng = config.engine || {};
    const defaultCli = shared.resolveAgentCli(null, eng);
    const defaultModelResolved = shared.resolveAgentModel(null, eng);
    console.log(`Default CLI: ${defaultCli}${eng.defaultCli ? '' : ' (default)'}`);
    console.log(`Default model: ${defaultModelResolved || '(runtime default)'}`);
    const ccCliOverride = (eng.ccCli !== undefined && eng.ccCli !== null && eng.ccCli !== '') ? String(eng.ccCli) : null;
    const ccModelOverride = (eng.ccModel !== undefined && eng.ccModel !== null && eng.ccModel !== '') ? String(eng.ccModel) : null;
    if (ccCliOverride || ccModelOverride) {
      console.log(`CC overrides: cli=${ccCliOverride || '(inherit)'}, model=${ccModelOverride || '(inherit)'}`);
    }
    const flagFields = [
      'claudeBareMode', 'claudeFallbackModel',
      'copilotDisableBuiltinMcps', 'copilotSuppressAgentsMd', 'copilotStreamMode', 'copilotReasoningSummaries',
      'maxBudgetUsd', 'disableModelDiscovery',
      'ccUseWorkerPool',
    ];
    const activeFlags = [];
    for (const f of flagFields) {
      if (eng[f] === undefined || eng[f] === null || eng[f] === '') continue;
      if (eng[f] === shared.ENGINE_DEFAULTS[f]) continue;
      activeFlags.push(`${f}=${JSON.stringify(eng[f])}`);
    }
    if (activeFlags.length > 0) console.log(`Flags: ${activeFlags.join(', ')}`);
    console.log('');

    console.log('Agents:');
    console.log(`  ${'ID'.padEnd(12)} ${'Name (Role)'.padEnd(30)} ${'Status'.padEnd(10)} Task`);
    console.log('  ' + '-'.repeat(70));
    for (const [id, agent] of Object.entries(agents)) {
      const status = getAgentStatus(id);
      console.log(`  ${id.padEnd(12)} ${`${agent.emoji} ${agent.name} (${agent.role})`.padEnd(30)} ${(status.status || 'idle').padEnd(10)} ${status.task || '-'}`);
    }

    console.log('');
    const metrics = shared.safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
    const lifetimeCompleted = Object.entries(metrics).filter(([k]) => !k.startsWith('_')).reduce((sum, [, m]) => sum + (m.tasksCompleted || 0) + (m.tasksErrored || 0), 0);
    console.log(`Dispatch: ${dispatch.pending.length} pending | ${(dispatch.active || []).length} active | ${lifetimeCompleted} completed`);
    const pidSummary = summarizeActiveDispatchPids(dispatch.active || []);
    console.log(`Active dispatch PID files: ${pidSummary.live} live | ${pidSummary.dead} dead | ${pidSummary.missing} missing`);

    const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
    const metricsData = safeJson(metricsPath);
    if (metricsData && Object.keys(metricsData).length > 0) {
      console.log('\nMetrics:');
      console.log(`  ${'Agent'.padEnd(12)} ${'Done'.padEnd(6)} ${'Err'.padEnd(6)} ${'PRs'.padEnd(6)} ${'Appr'.padEnd(6)} ${'Rej'.padEnd(6)} ${'Reviews'.padEnd(8)} ${'Cost'.padEnd(8)}`);
      console.log('  ' + '-'.repeat(64));
      for (const [id, m] of Object.entries(metricsData)) {
        if (id.startsWith('_')) continue;
        const cost = m.totalCostUsd ? '$' + m.totalCostUsd.toFixed(1) : '-';
        console.log(`  ${id.padEnd(12)} ${String(m.tasksCompleted || 0).padEnd(6)} ${String(m.tasksErrored || 0).padEnd(6)} ${String(m.prsCreated || 0).padEnd(6)} ${String(m.prsApproved || 0).padEnd(6)} ${String(m.prsRejected || 0).padEnd(6)} ${String(m.reviewsDone || 0).padEnd(8)} ${cost.padEnd(8)}`);
      }
    }
    console.log('');
  },

  queue() {
    const e = engine();
    const dispatch = getDispatch();

    console.log('\n=== Dispatch Queue ===\n');

    if (dispatch.pending.length) {
      console.log('PENDING:');
      for (const d of dispatch.pending) {
        console.log(`  [${d.id}] ${d.type} → ${d.agent}: ${d.task}`);
      }
    } else {
      console.log('No pending dispatches.');
    }

    if ((dispatch.active || []).length) {
      console.log('\nACTIVE:');
      for (const d of dispatch.active) {
        console.log(`  [${d.id}] ${d.type} → ${d.agent}: ${d.task} (since ${d.started_at})`);
      }
    }

    if ((dispatch.completed || []).length) {
      console.log(`\nCOMPLETED (last 5):`);
      for (const d of dispatch.completed.slice(-5)) {
        console.log(`  [${d.id}] ${d.type} → ${d.agent}: ${d.result} (${d.completed_at})`);
      }
    }
    console.log('');
  },

  complete(id) {
    if (!id || _isHelpArg(id)) {
      console.log('Usage: minions complete <dispatch-id>');
      return;
    }
    // W-mq07mjzi000s1cc9 — defensive guard mirrors work/spawn/plan.
    if (looksLikeFlagOrHelp(id)) {
      console.log('Usage: minions complete <dispatch-id>');
      process.exit(2);
    }
    const dispatch = getDispatch();
    const item = (dispatch.active || []).find(d => d.id === id);
    if (!item) {
      console.log(`  Dispatch "${id}" not found in active queue.`);
      const pending = (dispatch.pending || []).some(d => d.id === id);
      if (pending) console.log('  (It is in pending — it hasn\'t started yet.)');
      return;
    }
    engine().completeDispatch(id, DISPATCH_RESULT.SUCCESS, '', '', { processWorkItemSuccess: true });
    console.log(`  Marked ${id} as completed.`);
  },

  dispatch() {
    const control = getControl();
    if (control.state === 'running' && isEngineProcessAlive(control)) {
      mutateControl((c) => ({ ...c, _wakeupAt: Date.now() }));
      console.log(`Dispatch wakeup requested from running engine (PID ${control.pid}).`);
      return;
    }

    const activeCount = (getDispatch().active || []).length;
    if (activeCount > 0) {
      console.log(`Engine is not running, but ${activeCount} dispatch(es) are active.`);
      console.log('Refusing to run a local dispatch tick because it cannot track live agent processes.');
      console.log('Start the engine to re-attach or recover: node engine.js start');
      return;
    }

    console.log('Engine is not running. Start it to dispatch work: node engine.js start');
  },

  spawn(agentId, ...promptParts) {
    const e = engine();
    const prompt = promptParts.join(' ');
    if (!agentId || _isHelpArg(agentId) || !prompt) {
      console.log('Usage: node .minions/engine.js spawn <agent-id> "<prompt>"');
      return;
    }
    // W-mq07mjzi000s1cc9 — defensive guard mirrors work/plan/complete.
    if (looksLikeFlagOrHelp(agentId)) {
      console.log('Usage: node .minions/engine.js spawn <agent-id> "<prompt>"');
      process.exit(2);
    }

    const config = getConfig();
    if (!config.agents[agentId]) {
      console.log(`Unknown agent: ${agentId}. Available: ${Object.keys(config.agents).join(', ')}`);
      return;
    }

    const id = e.addToDispatch({
      type: 'manual',
      agent: agentId,
      agentName: config.agents[agentId].name,
      agentRole: config.agents[agentId].role,
      task: prompt.substring(0, 100),
      prompt: prompt,
      meta: {}
    });

    const dispatch = getDispatch();
    const item = dispatch.pending.find(d => d.id === id);
    if (item) {
      e.spawnAgent(item, config);
    }
  },

  work(title, ...rest) {
    const e = engine();
    if (!title || _isHelpArg(title)) {
      console.log('Usage: node .minions/engine.js work "<title>" [options-json]');
      console.log('Options: {"id":"W-customid","type":"implement","priority":"high","agent":"dallas","description":"...","branch":"feature/...","project":"minions"}');
      console.log('  id  Optional caller-supplied work item ID. Defaults to a cuid-style W-<id>.');
      return;
    }
    // W-mq07mjzi000s1cc9 — Defense-in-depth: reject `--help`/`-h`/`help` or any
    // leading-`--` title even if a future caller bypasses handleCommand. The
    // original bug created ghost WIs with title='--help' because the truthy
    // check above let the flag through.
    if (looksLikeFlagOrHelp(title)) {
      console.log('Usage: node .minions/engine.js work "<title>" [options-json]');
      console.log('Options: {"id":"W-customid","type":"implement","priority":"high","agent":"dallas","description":"...","branch":"feature/...","project":"minions"}');
      console.log('  id  Optional caller-supplied work item ID. Defaults to a cuid-style W-<id>.');
      process.exit(2);
    }

    let opts = {};
    const optStr = rest.join(' ');
    if (optStr) {
      try { opts = JSON.parse(optStr); } catch {
        console.log('Warning: Could not parse options JSON, using defaults');
      }
    }

    const config = getConfig();
    const { getProjects, projectWorkItemsPath, resolveProjectSource, uid, safeJsonArr } = require('./shared');
    const projects = getProjects(config);
    const target = opts.project ? resolveProjectSource(opts.project, projects, { allowCentral: false }) : null;
    if (target?.error) {
      console.log(target.error);
      return;
    }
    const targetProject = target?.project || (projects.length > 0 ? projects[0] : null);
    const wiPath = projectWorkItemsPath(targetProject);
    const archivePath = wiPath.replace(/\.json$/, '-archive.json');

    // Caller-supplied id: validate and pre-check live + archive for collisions
    // BEFORE acquiring the work-items lock, so a rejection never mutates state.
    let requestedId = null;
    if (opts.id != null) {
      if (typeof opts.id !== 'string' || !opts.id.trim()) {
        console.log('Error: opts.id must be a non-empty string');
        process.exit(1);
      }
      requestedId = opts.id.trim();
      const liveItems = safeJsonArr(wiPath);
      const archivedItems = safeJsonArr(archivePath);
      if (liveItems.some(w => w?.id === requestedId) || archivedItems.some(w => w?.id === requestedId)) {
        console.log(`Error: work item id "${requestedId}" already exists in ${path.basename(wiPath)} or its archive`);
        process.exit(1);
      }
    }

    let item;
    let collision = false;
    mutateWorkItems(wiPath, items => {
      const id = requestedId || ('W-' + uid());
      // Re-check inside the lock to close the TOCTOU race against other writers.
      if (items.some(w => w?.id === id)) {
        collision = true;
        return items;
      }
      item = {
        id,
        title: title,
        type: opts.type || 'implement',
        status: WI_STATUS.QUEUED,
        priority: opts.priority || 'medium',
        complexity: opts.complexity || 'medium',
        description: opts.description || title,
        agent: opts.agent || null,
        branch: opts.branch || null,
        prompt: opts.prompt || null,
        created_at: e.ts()
      };
      items.push(item);
    });

    if (collision || !item) {
      console.log(`Error: work item id collision detected for "${requestedId || 'auto-generated'}"`);
      process.exit(1);
    }

    console.log(`Queued work item: ${item.id} — ${item.title} (project: ${targetProject?.name || 'default'})`);
    console.log(`  Type: ${item.type} | Priority: ${item.priority} | Agent: ${item.agent || 'auto'}`);
  },

  plan(source, projectName) {
    const e = engine();
    if (!source || _isHelpArg(source)) {
      console.log('Usage: node .minions/engine.js plan <source> [project]');
      console.log('');
      console.log('Source can be:');
      console.log('  - A file path (markdown, txt, or json)');
      console.log('  - Inline text wrapped in quotes');
      console.log('');
      console.log('Examples:');
      console.log('  node engine.js plan ./my-plan.md');
      console.log('  node engine.js plan ./my-plan.md MyProject');
      console.log('  node engine.js plan "Add auth middleware with JWT tokens and role-based access"');
      return;
    }
    // W-mq07mjzi000s1cc9 — defensive guard mirrors work/spawn/complete.
    if (looksLikeFlagOrHelp(source)) {
      console.log('Usage: node .minions/engine.js plan <source> [project]');
      console.log('');
      console.log('Source can be:');
      console.log('  - A file path (markdown, txt, or json)');
      console.log('  - Inline text wrapped in quotes');
      process.exit(2);
    }

    const config = getConfig();
    const { getProjects, resolveProjectSource } = require('./shared');
    const projects = getProjects(config);
    const target = projectName ? resolveProjectSource(projectName, projects, { allowCentral: false }) : null;
    if (target?.error) {
      console.log(target.error);
      return;
    }
    const targetProject = target?.project || (projects.length > 0 ? projects[0] : null);

    if (!targetProject) {
      console.log('No projects configured. Run: minions add <dir>');
      return;
    }

    let planContent;
    let planSummary;
    const sourcePath = path.resolve(source);
    if (fs.existsSync(sourcePath)) {
      planContent = fs.readFileSync(sourcePath, 'utf8');
      planSummary = path.basename(sourcePath, path.extname(sourcePath));
      console.log(`Reading plan from: ${sourcePath}`);
    } else {
      planContent = source;
      planSummary = source.substring(0, 60).replace(/[^a-zA-Z0-9 -]/g, '').trim();
      console.log('Using inline plan text.');
    }

    console.log(`Target project: ${targetProject.name}`);
    console.log(`Plan summary: ${planSummary}`);
    console.log('');

    const agentId = e.resolveAgent('analyze', config) || e.resolveAgent('explore', config);
    if (!agentId) {
      console.log('No agents available. All agents are busy.');
      return;
    }

    const vars = {
      agent_id: agentId,
      agent_name: config.agents[agentId]?.name,
      agent_role: config.agents[agentId]?.role,
      project_name: targetProject.name || 'Unknown',
      project_path: targetProject.localPath || '',
      main_branch: targetProject.localPath ? shared.resolveMainBranch(targetProject.localPath, targetProject.mainBranch) : (targetProject.mainBranch || 'main'),
      ado_org: targetProject.repoHost === 'ado' ? (targetProject.adoOrg || 'Unknown') : '',
      ado_project: targetProject.repoHost === 'ado' ? (targetProject.adoProject || 'Unknown') : '',
      github_org: targetProject.repoHost === 'github' ? (targetProject.githubOrg || targetProject.adoOrg || '') : '',
      repo_name: targetProject.repoName || 'Unknown',
      team_root: MINIONS_DIR,
      date: e.dateStamp(),
      plan_content: planContent,
      plan_summary: planSummary,
      project_name_lower: (targetProject.name || 'project').toLowerCase()
    };

    if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });

    const prompt = e.renderPlaybook('plan-to-prd', vars);
    if (!prompt) {
      console.log('Error: Could not render plan-to-prd playbook.');
      return;
    }

    const id = e.addToDispatch({
      type: WORK_TYPE.PLAN_TO_PRD,
      agent: agentId,
      agentName: config.agents[agentId]?.name,
      agentRole: config.agents[agentId]?.role,
      task: `[${targetProject.name}] Generate PRD from plan: ${planSummary}`,
      prompt,
      meta: {
        source: 'plan',
        project: { name: targetProject.name, localPath: targetProject.localPath },
        planSummary
      }
    });

    console.log(`Dispatched: ${id} → ${config.agents[agentId]?.name} (${agentId})`);
    console.log('The agent will analyze your plan and generate a PRD in prd/.');

    const control = getControl();
    if (control.state === 'running') {
      const dispatch = getDispatch();
      const item = dispatch.pending.find(d => d.id === id);
      if (item) {
        e.spawnAgent(item, config);
        console.log('Agent spawned immediately.');
      }
    } else {
      console.log('Engine is not running — dispatch will happen on next tick after start.');
    }
  },

  sources() {
    const e = engine();
    const config = getConfig();
    const shared = require('./shared');
    const projects = shared.getProjects(config);

    console.log('\n=== Work Sources ===\n');

    for (const project of projects) {
      const root = shared.projectRoot(project);
      console.log(`── ${project.name || 'Project'} (${root}) ──\n`);

      const sources = project.workSources || config.workSources || {};
      for (const [name, src] of Object.entries(sources)) {
        const status = src.enabled ? 'ENABLED' : 'DISABLED';
        console.log(`  ${name}: ${status}`);

        let filePath = null;
        if (name === 'workItems') filePath = shared.projectWorkItemsPath(project);
        else if (name === 'pullRequests') filePath = shared.projectPrPath(project);
        else if (src.path) filePath = path.resolve(root, src.path);
        const exists = filePath && fs.existsSync(filePath);
        if (filePath) {
          console.log(`    Path: ${filePath} ${exists ? '(found)' : '(NOT FOUND)'}`);
        }
        console.log(`    Cooldown: ${src.cooldownMinutes || 0}m`);

        if (exists && name === 'prd') {
          const prd = safeJson(filePath);
          if (prd) {
            const missing = (prd.missing_features || []).filter(f => f.status === 'missing' || !f.status);
            console.log(`    Items: ${missing.length} missing features`);
          }
        }
        if (exists && name === 'pullRequests') {
          const prs = safeJson(filePath) || [];
          const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
          const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
          console.log(`    PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
        }
        if (exists && name === 'workItems') {
          const items = safeJson(filePath) || [];
          const queued = items.filter(i => i.status === WI_STATUS.QUEUED);
          console.log(`    Items: ${queued.length} queued`);
        }
        if (name === 'specs' || name === 'mergedDesignDocs') {
          const trackerFile = path.join(shared.projectStateDir(project), 'spec-tracker.json');
          const tracker = safeJson(trackerFile) || { processedPrs: {} };
          const processed = Object.keys(tracker.processedPrs).length;
          const matched = Object.values(tracker.processedPrs).filter(p => p.matched).length;
          console.log(`    Processed: ${processed} merged PRs (${matched} had specs)`);
        }
        console.log('');
      }
    }
  },

  kill() {
    const e = engine();
    console.log('\n=== Kill All Active Work ===\n');
    const config = getConfig();
    const shared = require('./shared');

    // Kill processes via PID files (expensive — outside dispatch lock).
    // PID files live in engine/tmp/ — both legacy flat layout
    // (`pid-<id>.pid`) and per-dispatch dirs (`dispatch-<id>-XXX/pid-<id>.pid`)
    // post-P-f6-tmp-toctou. shared.forEachPidFile visits both.
    shared.forEachPidFile((pidPath, fileName, layout) => {
      const raw = safeRead(pidPath).trim();
      // Guard against falsy/zero/NaN PIDs. Empty pid files would resolve to
      // Number('') === 0, and process.kill(0) on POSIX targets the entire
      // calling process group — which would kill the engine itself.
      let pidNum = NaN;
      try { pidNum = shared.validatePid(raw); } catch { /* invalid — skip */ }
      if (pidNum > 0) {
        try { process.kill(pidNum); console.log(`Killed process ${pidNum} (${fileName})`); }
        catch { console.log(`Process ${pidNum} already dead`); }
      } else {
        console.log(`Skipping ${fileName}: invalid or empty PID`);
      }
      if (layout === 'dispatch-dir') {
        try { shared.removeDispatchTmpDir(path.dirname(pidPath)); } catch { /* may not exist */ }
      } else {
        try { fs.unlinkSync(pidPath); } catch { /* may not exist */ }
      }
    });

    // Atomically read and clear dispatch.active (locked read-modify-write)
    let killed = [];
    e.mutateDispatch((dispatch) => {
      killed = dispatch.active || [];
      dispatch.active = [];
      return dispatch;
    });

    // Reset work items outside the dispatch lock (work-items.json has its own lock)
    for (const item of killed) {
      if (item.meta) {
        e.updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
        const itemId = item.meta.item?.id;
        if (itemId) {
          const wiPath = (item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout')
            ? path.join(MINIONS_DIR, 'work-items.json')
            : item.meta.project?.localPath
              ? shared.projectWorkItemsPath(shared.resolveProjectSource(item.meta.project, config.projects || [], { allowCentral: false }).project || item.meta.project)
              : null;
          if (wiPath) {
            mutateWorkItems(wiPath, items => {
              const target = items.find(i => i.id === itemId);
              if (target) {
                target.status = WI_STATUS.PENDING;
                delete target.dispatched_at;
                delete target.dispatched_to;
                delete target.failReason;
                delete target.failedAt;
              }
            });
          }
        }
      }

      console.log(`Killed dispatch: ${item.id} (${item.agent}) — work item reset to pending`);
    }

    // Agent status derived from dispatch.json — clearing dispatch.active is sufficient.
    console.log('All agents reset to idle (dispatch cleared)');

    console.log(`\nDone: ${killed.length} dispatches killed, agents reset.`);
  },

  async cleanup() {
    const e = engine();
    const config = getConfig();
    console.log('\n=== Cleanup ===\n');
    const result = await e.runCleanup(config, true);
    console.log(`\nDone: ${result.tempFiles} temp files, ${result.liveOutputs} live outputs, ${result.worktrees} worktrees, ${result.zombies} zombies cleaned.`);
  },

  'mcp-sync'() {
    console.log('MCP servers are read directly from ~/.claude.json — no sync needed.');
  },

  doctor() {
    const { doctor } = require('./preflight');
    return doctor(MINIONS_DIR).then(ok => {
      if (!ok) process.exit(1);
    });
  },

  config(...configArgs) {
    const sub = configArgs[0];
    const rest = configArgs.slice(1);
    if (sub === 'set-cli') {
      const cliName = rest[0];
      if (!cliName || cliName.startsWith('--')) {
        console.error('Usage: minions config set-cli <runtime> [--model <model>]');
        let registered = [];
        try { registered = require('./runtimes').listRuntimes(); } catch {}
        if (registered.length > 0) console.error(`Registered runtimes: ${registered.join(', ')}`);
        process.exit(2);
      }
      // Drop the positional <runtime> token; whatever's left is parsed as flags.
      const flagArgs = rest.slice(1);
      const parsed = _parseRuntimeFlags(flagArgs);
      if (parsed.errors.length > 0) {
        for (const msg of parsed.errors) console.error(`error: ${msg}`);
        process.exit(2);
      }
      // Reject any leftover non-flag args — keeps the surface tight + catches typos.
      if (flagArgs.length > 0) {
        console.error(`error: unexpected arguments after set-cli: ${flagArgs.join(' ')}`);
        process.exit(2);
      }
      try {
        const { warnings } = _applyRuntimeFlags({
          cli: cliName,
          model: parsed.model,
          modelExplicit: parsed.modelExplicit,
        });
        for (const w of warnings) console.log(`  ! ${w}`);
        const modelDesc = parsed.modelExplicit
          ? (parsed.model === '' ? '(cleared)' : `"${parsed.model}"`)
          : '(unchanged)';
        console.log(`config: defaultCli="${cliName}", defaultModel=${modelDesc}, ccCli/ccModel cleared.`);
      } catch (err) {
        if (err && err._unknownCli) {
          console.error(`error: ${err.message}`);
          process.exit(2);
        }
        throw err;
      }
      return;
    }
    console.error('Usage: minions config set-cli <runtime> [--model <model>]');
    process.exit(2);
  },

  discover() {
    const e = engine();
    const config = getConfig();
    console.log('\n=== Work Discovery (dry run) ===\n');

    e.materializePlansAsWorkItems(config);
    const prWork = e.discoverFromPrs(config);
    const workItemWork = e.discoverFromWorkItems(config);

    const all = [...prWork, ...workItemWork];

    if (all.length === 0) {
      console.log('No new work discovered from any source.');
    } else {
      console.log(`Found ${all.length} items:\n`);
      for (const w of all) {
        console.log(`  [${w.meta?.source}] ${w.type} → ${w.agent}: ${w.task}`);
      }
    }
    console.log('');
  },

  // `minions pr <subcommand> ...` — wraps engine/gh-comment.js so playbook-
  // driven shells have a single command that automatically prepends the hidden
  // minions marker (so engine classifiers can identify agent-authored PR
  // comments by structure, not body shape).
  //
  // Currently supports:
  //   minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>]
  //                       (--body-file <path> | --body <inline-text>)
  //
  // Reserved for future subcommands: `review`, `review-comment`. Adding them
  // here is a one-line dispatch change — no shell wiring needed.
  pr(subcmd, ...rest) {
    const ghComment = require('./gh-comment');

    if (!subcmd || subcmd === 'help' || subcmd === '--help' || subcmd === '-h') {
      console.log('Usage:');
      console.log('  minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
      console.log('');
      console.log('Posts a PR comment via `gh pr comment`, prepending the hidden minions');
      console.log('marker so engine classifiers can identify agent-authored comments by');
      console.log('structure rather than body shape.');
      return;
    }

    if (subcmd !== 'comment') {
      console.error(`Unknown pr subcommand: ${subcmd}`);
      console.error('Run: minions pr help');
      process.exit(2);
    }

    // Positional: <repo> <prNumber>
    const positional = [];
    const flags = {};
    let i = 0;
    while (i < rest.length) {
      const a = rest[i];
      if (typeof a === 'string' && a.startsWith('--')) {
        const key = a.slice(2);
        const val = rest[i + 1];
        if (val === undefined || (typeof val === 'string' && val.startsWith('--'))) {
          console.error(`error: --${key} requires a value`);
          process.exit(2);
        }
        flags[key] = val;
        i += 2;
      } else {
        positional.push(a);
        i++;
      }
    }

    const [repo, prNumberRaw] = positional;
    if (!repo || prNumberRaw === undefined) {
      console.error('Usage: minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
      process.exit(2);
    }

    const prNumber = Number(prNumberRaw);
    if (!Number.isInteger(prNumber) || prNumber <= 0) {
      console.error(`error: invalid prNumber: ${JSON.stringify(prNumberRaw)} (expected positive integer)`);
      process.exit(2);
    }

    const agentId = flags.agent;
    const kind = flags.kind;
    const workItemId = flags.wi;
    if (!agentId || !kind) {
      console.error('error: --agent and --kind are required');
      process.exit(2);
    }

    let body;
    if (flags['body-file']) {
      try { body = fs.readFileSync(flags['body-file'], 'utf8'); }
      catch (e) {
        console.error(`error: could not read --body-file ${flags['body-file']}: ${e.message}`);
        process.exit(2);
      }
    } else if (flags.body !== undefined) {
      body = String(flags.body);
    } else {
      console.error('error: must supply either --body-file <path> or --body <text>');
      process.exit(2);
    }

    try {
      const result = ghComment.postPrComment({
        repo, prNumber, body, agentId, kind, workItemId,
      });
      if (result.output) console.log(result.output);
    } catch (e) {
      console.error(`error: ${e.message}`);
      process.exit(1);
    }
  },

  // `minions bridge <subcommand>` — Constellation read-only bridge surface.
  // Owns the on/off flag and the marker-file projection used by the
  // Constellation agent. Bridge polling logic itself lives in the
  // Constellation repo (P-wi1-bridge-readonly).
  //
  // Subcommands:
  //   status   Print enabled flag + last-seen Constellation agent timestamp.
  //   health   Probe http://127.0.0.1:7331/api/status and print the same
  //            curated projection the Constellation bridge would consume.
  //   enable   Atomically set engine.constellationBridge.enabled = true.
  //   disable  Atomically set engine.constellationBridge.enabled = false.
  bridge(subcmd, ...rest) {
    const bridge = require('./bridge');
    const BRIDGE_USAGE = 'Usage: minions bridge <status|health|enable|disable>';

    if (!subcmd || subcmd === 'help' || subcmd === '--help' || subcmd === '-h') {
      console.log(BRIDGE_USAGE);
      console.log('');
      console.log('  status   Show enabled flag + last-seen Constellation agent timestamp');
      console.log('  health   Probe http://127.0.0.1:7331/api/status and print bridge projection');
      console.log('  enable   Set engine.constellationBridge.enabled = true');
      console.log('  disable  Set engine.constellationBridge.enabled = false');
      return;
    }

    if (rest.length > 0) {
      console.error(`error: unexpected arguments after bridge ${subcmd}: ${rest.join(' ')}`);
      process.exit(2);
    }

    if (subcmd === 'enable' || subcmd === 'disable') {
      const enabled = subcmd === 'enable';
      const { previous, current } = bridge.setBridgeEnabled(enabled);
      const verb = previous === current ? 'already' : 'now';
      console.log(`bridge: ${verb} ${current ? 'enabled' : 'disabled'}`);
      console.log('  config: engine.constellationBridge.enabled = ' + current);
      if (!previous && current) {
        console.log('  note: Constellation-side bridge must also be enabled to project state.');
      }
      return;
    }

    if (subcmd === 'status') {
      const config = getConfig();
      const enabled = bridge.isBridgeEnabled(config);
      console.log(`bridge: ${enabled ? 'enabled' : 'disabled'}`);
      console.log('  config: engine.constellationBridge.enabled = ' + enabled);
      const marker = bridge.readBridgeMarker();
      if (!marker) {
        console.log('  marker: no Constellation agent has registered yet');
        console.log(`  (expected at ${bridge.CONSTELLATION_BRIDGE_MARKER_PATH})`);
      } else {
        console.log(`  last seen: ${bridge.formatRelativeAge(marker.lastSeenAt)} (${marker.lastSeenAt})`);
        if (marker.agentVersion) console.log(`  agent version: ${marker.agentVersion}`);
        if (marker.source) console.log(`  source: ${marker.source}`);
      }
      return;
    }

    if (subcmd === 'health') {
      const http = require('http');
      const req = http.get(
        { hostname: '127.0.0.1', port: 7331, path: '/api/status', timeout: 5000 },
        (res) => {
          let body = '';
          res.setEncoding('utf8');
          res.on('data', (chunk) => { body += chunk; });
          res.on('end', () => {
            if (res.statusCode !== 200) {
              console.error(`error: dashboard returned HTTP ${res.statusCode}`);
              process.exit(1);
            }
            let parsed;
            try { parsed = JSON.parse(body); }
            catch (e) {
              console.error(`error: dashboard response was not JSON: ${e.message}`);
              process.exit(1);
            }
            const projection = bridge.projectStatusForBridge(parsed);
            if (!projection) {
              console.error('error: dashboard /api/status returned an unexpected shape');
              process.exit(1);
            }
            console.log('bridge: dashboard reachable on http://127.0.0.1:7331');
            console.log('  projection (same fields the Constellation bridge would read):');
            for (const [k, v] of Object.entries(projection)) {
              console.log(`    ${k}: ${v === null ? '(unknown)' : v}`);
            }
          });
        }
      );
      req.on('timeout', () => {
        req.destroy(new Error('connect timeout'));
      });
      req.on('error', (err) => {
        if (err.code === 'ECONNREFUSED') {
          console.error('error: dashboard not running on :7331 — start it with `minions dash`');
        } else {
          console.error(`error: ${err.message}`);
        }
        process.exit(1);
      });
      return;
    }

    console.error(`Unknown bridge subcommand: ${subcmd}`);
    console.error(BRIDGE_USAGE);
    process.exit(2);
  }
};

module.exports = {
  handleCommand,
  // exported for downstream indexing (dashboard CC preamble)
  CLI_COMMAND_DOCS,
  formatCliCommandHelpLines,
  // exported for testing
  _listCommandKeys: () => Object.keys(commands),
  _parseRuntimeFlags,
  _modelLooksIncompatible,
  _applyRuntimeFlags,
  _createControlOwner: createControlOwner,
  _controlBelongsToOwner: controlBelongsToOwner,
  _markControlStoppingForOwner: markControlStoppingForOwner,
  _markControlStoppedForOwner: markControlStoppedForOwner,
  _isPidAlive: isPidAlive,
  _summarizeActiveDispatchPids: summarizeActiveDispatchPids,
  _dispatchSafeId: dispatchSafeId,
  _readDispatchPid: readDispatchPid,
  _normalizeSessionBranch: normalizeSessionBranch,
  _dispatchSessionBranch: dispatchSessionBranch,
  // W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
  _writeHeartbeatNow: writeHeartbeatNow,
  _createHeartbeatInterval: createHeartbeatInterval,
  _HEARTBEAT_INTERVAL_MS: HEARTBEAT_INTERVAL_MS,
};
