import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { GoogleMapsScrapperAdapter } from "./adapters/googleMapsScrapper.js";
import { ScraplingAdapter } from "./adapters/scrapling.js";
import { GitHubAdapter } from "./adapters/github.js";
import { ContactHuntingAdapter } from "./adapters/contactHunting.js";
import { OwnerRabbitHoleAdapter } from "./adapters/ownerRabbitHole.js";
import { LinkedInToolsAdapter } from "./adapters/linkedinTools.js";
import { OwnerEvidenceToolsAdapter } from "./adapters/ownerEvidenceTools.js";
import { DirectoryDiscoveryAdapter } from "./adapters/directoryDiscovery.js";
import { classifyMapsFailure } from "./adapters/mapsDiscovery.js";
import { MiniMaxClient } from "./minimax.js";
import { detectFailure, leadKey, normalizeLead, normalizePhone, normalizeUrl } from "./quality.js";
import { normalizeText, uniqueBy } from "./utils.js";
import { createRunWorkflow, workflowPhaseLabel, workflowStageForPhase } from "./workflow.js";
import { FlowWatchdog } from "./flowWatchdog.js";
import { OWNER_FIRST_STRATEGY_VERSION, buildOwnerFirstQueryVariants } from "./ownerFirstDiscovery.js";
import { processSupervisor } from "./processSupervisor.js";

const COUNTRY_SCOPE = "United States";
const COUNTRY_QUERY_SUFFIX = "USA";
const LEAD_SCRAPER_SOURCE = "lead-scraper";
const LEAD_SCRAPER_STRATEGY_VERSION = "business-contact-v2";
const INITIAL_SEARCH_STRATEGY_TIMEOUT_MS = Math.max(3000, Number(process.env.HERMES_MINIMAX_INITIAL_SEARCH_TIMEOUT_MS || 45000));
const DEFAULT_OWNER_HUNT_TIMEOUT_MS = Math.max(10000, Number(process.env.HERMES_OWNER_HUNT_TIMEOUT_MS || 90000));
const MIN_OWNER_HUNT_TIMEOUT_MS = 10;
const MAX_OWNER_HUNT_TIMEOUT_MS = 10 * 60 * 1000;
const DEFAULT_MAX_PLANNED_LOCATIONS = Math.max(20, Math.min(500, Number(process.env.HERMES_MAX_PLANNED_LOCATIONS || 100)));
const OWNER_ASSOCIATED_FALLBACK_ENABLED = process.env.HERMES_OWNER_ASSOCIATED_FALLBACK !== "0";
const MIN_OWNER_DIRECT_CONTACTS = Math.max(0, Number(process.env.HERMES_MIN_OWNER_DIRECT_CONTACTS || 1));
const REPLACEABLE_RUN_STATUSES = new Set(["queued", "running", "interrupted"]);

export class HermesAgent {
  constructor({ projectName, rootDir, storage }) {
    this.projectName = projectName;
    this.rootDir = rootDir;
    this.storage = storage;
    this.events = new EventEmitter();
    this.active = new Map();
    this.googleMaps = new GoogleMapsScrapperAdapter({ rootDir, storage });
    this.scrapling = new ScraplingAdapter({ rootDir });
    this.github = new GitHubAdapter({ rootDir });
    this.contactHunter = new ContactHuntingAdapter({ rootDir });
    this.minimax = new MiniMaxClient();
    this.linkedinTools = new LinkedInToolsAdapter({ rootDir, scrapling: this.scrapling });
    this.ownerEvidenceTools = new OwnerEvidenceToolsAdapter({ rootDir });
    this.directoryDiscovery = new DirectoryDiscoveryAdapter({ scrapling: this.scrapling });
    this.ownerRabbitHole = new OwnerRabbitHoleAdapter({
      scrapling: this.scrapling,
      minimax: this.minimax,
      linkedinTools: this.linkedinTools,
      ownerEvidenceTools: this.ownerEvidenceTools
    });
    this.flowWatchdog = new FlowWatchdog({ minimax: this.minimax });
    this.autoRepairTools = process.env.HERMES_AUTO_REPAIR_TOOLS !== "0";
    this.toolRepairTimeoutMs = Number(process.env.HERMES_TOOL_REPAIR_TIMEOUT_MS || 600000);
    this.stagnationPivotEnabled = process.env.HERMES_STAGNATION_PIVOT !== "0";
    this.stagnationWindow = Math.max(2, Number(process.env.HERMES_STAGNATION_WINDOW || 2));
    this.initialStrategyEnabled = process.env.HERMES_INITIAL_STRATEGY !== "0";
    this.finalRecoveryEnabled = process.env.HERMES_FINAL_RECOVERY !== "0";
    this.ownerHuntTimeoutMs = DEFAULT_OWNER_HUNT_TIMEOUT_MS;
  }

  activeRunCount() {
    return this.active.size;
  }

  async startRun(input) {
    await this.stopExistingRunsForNewQuery();
    const cleanInput = normalizeInput(input);
    const memory = await this.storage.getOperationalMemory({ niche: cleanInput.niche });
    const run = {
      id: randomUUID(),
      agent: `Hermes ${this.projectName}`,
      status: "queued",
      input: cleanInput,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      steps: [],
      failures: [],
      skillUpdates: [],
      agentDecisions: [],
      selfReviews: [],
      providerFallbackStages: {},
      workflow: createRunWorkflow(cleanInput),
      scaleAudit: {},
      flowWatchdog: {
        enabled: process.env.HERMES_FLOW_WATCHDOG !== "0",
        checks: 0,
        alerts: [],
        repairs: [],
        lastStepCount: 0,
        lastQualified: 0,
        lastOwnerContactsFound: 0
      },
      memory,
      discoveredLeads: [],
      qualifiedLeads: [],
      counters: {
        discovered: 0,
        enriched: 0,
        qualified: 0,
        retries: 0,
        searchAttempts: 0,
        rejected: 0,
        belowMinScore: 0,
        enrichmentFailures: 0,
        deepEnrichments: 0,
        providerFallbacks: 0,
        locationExpansions: 0,
        toolRepairs: 0,
        stagnationPivots: 0,
        initialStrategies: 0,
        finalRecoveryPasses: 0,
        queryDuplicates: 0,
        ownerRabbitHoles: 0,
        ownerProfilesFound: 0,
        ownerContactsFound: 0,
        ownerAgentsStarted: 0,
        ownerAgentsDeployed: 0,
        ownerAgentSearchHits: 0,
        ownerAgentPagesFetched: 0,
        ownerAgentContactPromotions: 0,
        ownerSeedRejections: 0,
        franchiseRejections: 0,
        ownerIdentityGateRejections: 0,
        llmCalls: 0,
        llmFallbacks: 0,
        llmTimeouts: 0,
        llmDurationMs: 0,
        reposProcessed: 0,
        reposAccepted: 0,
        reposFetchFailures: 0,
        repoFetchFailures: 0,
        ownerBundleFailures: 0,
        ownersHunted: 0,
        ownersKeptHunting: 0,
        ownersQualifiedCheck: 0,
        contactHuntFailures: 0
      },
      rejectedLeads: [],
      search: {
        attemptedQueries: [],
        exhaustedCities: []
      }
    };
    run.scaleAudit = buildScaleAudit(run);

    await this.storage.saveRun(run);
    this.active.set(run.id, { cancelled: false });
    this.execute(run.id).finally(() => this.active.delete(run.id));
    return run;
  }

  async resumeRun(id) {
    if (this.active.has(id)) return this.storage.getRun(id);
    const run = await this.storage.getRun(id);
    if (!run) return null;
    if (run.status === "complete") {
      const error = new Error("Run is already complete.");
      error.statusCode = 409;
      throw error;
    }

    hydrateRunState(run);
    const fromStatus = run.status;
    run.memory = await this.storage.getOperationalMemory({ niche: run.input.niche });
    run.status = "queued";
    run.resumes.push({ at: new Date().toISOString(), fromStatus });
    run.scaleAudit = buildScaleAudit(run);
    await this.storage.saveRun(run);
    this.active.set(run.id, { cancelled: false });
    this.execute(run.id, { resumed: true }).finally(() => this.active.delete(run.id));
    return run;
  }

  async selfHealRun(id, { reason = "checkpoint_without_worker" } = {}) {
    if (this.active.has(id)) return this.storage.getRun(id);
    const run = await this.storage.getRun(id);
    if (!run) return null;
    if (!["interrupted", "failed"].includes(run.status)) return run;

    hydrateRunState(run);
    run.selfHealing ||= [];
    const alert = {
      severity: "high",
      code: run.status === "interrupted" ? "no_worker_interrupted" : "terminal_failure",
      message: run.status === "interrupted"
        ? "Run has a saved checkpoint but no active worker."
        : isLeadScraperRun(run)
          ? "Run failed before completing the business-contact goal."
          : "Run failed before completing the legacy contact goal.",
      signal: reason
    };
    const repair = await this.flowWatchdog.repairAdvice(run, alert).catch(() => ({
      action: "Resume from saved checkpoint after local preflight.",
      reason: "Fallback recovery keeps durable work and relaunches the worker.",
      variable: "tool_repair"
    }));

    run.flowWatchdog ||= { enabled: true, checks: 0, alerts: [], repairs: [], lastStepCount: 0, lastQualified: 0, lastOwnerContactsFound: 0 };
    run.flowWatchdog.alerts.unshift({ ...alert, at: new Date().toISOString() });
    run.flowWatchdog.alerts = run.flowWatchdog.alerts.slice(0, 50);
    run.flowWatchdog.repairs.unshift({ ...repair, at: new Date().toISOString() });
    run.flowWatchdog.repairs = run.flowWatchdog.repairs.slice(0, 50);
    run.selfHealing.push({
      at: new Date().toISOString(),
      fromStatus: run.status,
      reason,
      repair
    });
    run.selfHealing = run.selfHealing.slice(-50);
    await this.recordDecision(run, {
      type: "self_heal",
      observation: alert.message,
      action: repair.tool
        ? `${repair.action || "Resume from saved checkpoint."} Tool: ${repair.tool}.`
        : repair.action || "Resume from saved checkpoint.",
      reason: repair.patchIdea
        ? `${repair.reason || reason} Patch idea: ${repair.patchIdea}`
        : repair.reason || reason
    });

    const toolFailures = this.toolAvailabilityChecks().filter((check) => !check.ok && check.repairable);
    if (toolFailures.length && this.autoRepairTools) {
      const toolRepair = await this.repairTools(run, toolFailures);
      run.counters.toolRepairs = (run.counters.toolRepairs || 0) + 1;
      run.flowWatchdog.repairs.unshift({ ...toolRepair, at: new Date().toISOString() });
      run.flowWatchdog.repairs = run.flowWatchdog.repairs.slice(0, 50);
      if (!toolRepair.ok) {
        await this.recordFailure(run, "self_heal_tool_repair", new Error(toolRepair.message), {
          command: toolRepair.command,
          code: toolRepair.code,
          output: toolRepair.output
        });
      }
    }

    await this.storage.saveRun(run);
    return this.resumeRun(id);
  }

  async recoverInterruptedRuns() {
    const runs = await this.storage.listRecoverableRuns();
    const latestRun = mostRecentlyStartedRun(runs);
    const recovered = [];
    for (const run of runs) {
      if (latestRun?.id && run.id !== latestRun.id) {
        await this.markRunReplaced(run, "Run skipped during recovery because a newer query is the latest run.");
        continue;
      }
      if (this.active.has(run.id)) continue;
      hydrateRunState(run);
      const previousStatus = run.status;
      run.memory = await this.storage.getOperationalMemory({ niche: run.input.niche });
      run.status = "queued";
      run.recoveries ||= [];
      run.recoveries.push({ at: new Date().toISOString(), fromStatus: previousStatus });
      run.scaleAudit = buildScaleAudit(run);
      await this.storage.saveRun(run);
      this.active.set(run.id, { cancelled: false });
      this.execute(run.id, { resumed: true, recovered: true }).finally(() => this.active.delete(run.id));
      recovered.push(run.id);
    }
    return recovered;
  }

  async markInterruptedRuns() {
    const runs = await this.storage.listRecoverableRuns();
    const interrupted = [];
    for (const run of runs) {
      if (this.active.has(run.id) || !["queued", "running"].includes(run.status)) continue;
      hydrateRunState(run);
      const previousStatus = run.status;
      run.status = "interrupted";
      run.updatedAt = new Date().toISOString();
      run.interruptions ||= [];
      run.interruptions.push({ at: run.updatedAt, fromStatus: previousStatus });
      run.steps.push({
        phase: "status",
        phaseLabel: workflowPhaseLabel("status"),
        workflowStage: workflowStageForPhase("status"),
        message: "Run interrupted while no worker was active. Resume to continue from the saved checkpoint.",
        at: run.updatedAt
      });
      run.scaleAudit = buildScaleAudit(run);
      await this.storage.saveRun(run);
      interrupted.push(run.id);
    }
    return interrupted;
  }

  async cancelRun(id) {
    const state = this.active.get(id);
    if (!state) return false;
    state.cancelled = true;
    this.minimax.cancelPending?.("Run cancelled; dropping pending MiniMax work.");
    await processSupervisor.killRun(id);
    await this.log(id, "cancel", "Cancellation requested.");
    return true;
  }

  async cancelActiveRuns({ exceptId = "", reason = "Cancellation requested." } = {}) {
    const ids = [...this.active.keys()].filter((id) => id !== exceptId);
    for (const id of ids) {
      const state = this.active.get(id);
      if (!state || state.cancelled) continue;
      state.cancelled = true;
      await processSupervisor.killRun(id);
      await this.log(id, "cancel", reason);
    }
    if (ids.length) this.minimax.cancelPending?.(`${reason} Dropping pending MiniMax work.`);
    return ids;
  }

  async stopExistingRunsForNewQuery({ reason = "Run killed because a newer query was started." } = {}) {
    const runs = await this.storage.listRecoverableRuns();
    const stopped = [];
    for (const run of runs) {
      if (!run?.id) continue;
      const state = this.active.get(run.id);
      if (state) {
        state.cancelled = true;
        state.killed = true;
        await processSupervisor.killRun(run.id);
      }
      const marked = await this.markRunReplaced(run, reason);
      if (marked) stopped.push(run.id);
    }
    if (stopped.length) this.minimax.cancelPending?.(`${reason} Dropping pending MiniMax work.`);
    return stopped;
  }

  async markRunReplaced(run, message) {
    hydrateRunState(run);
    if (!REPLACEABLE_RUN_STATUSES.has(run.status)) return false;
    run.status = "killed";
    run.replacements ||= [];
    run.replacements.push({ at: new Date().toISOString(), reason: message });
    await this.persist(run, "kill", message);
    return true;
  }

  async killRun(id) {
    const run = await this.storage.getRun(id);
    if (!run) return null;
    hydrateRunState(run);
    const state = this.active.get(id);
    if (state) {
      state.cancelled = true;
      state.killed = true;
      this.minimax.cancelPending?.("Run killed; dropping pending MiniMax work.");
      await processSupervisor.killRun(id);
    }
    if (["queued", "running", "interrupted"].includes(run.status)) {
      run.status = "killed";
      await this.persist(run, "kill", "Run killed by operator.");
    }
    return run;
  }

  async subscribe(id, req, res) {
    res.writeHead(200, {
      "content-type": "text/event-stream",
      "cache-control": "no-cache",
      "connection": "keep-alive"
    });
    const initial = await this.storage.getRun(id);
    if (initial) res.write(`event: snapshot\ndata: ${JSON.stringify(initial)}\n\n`);

    const listener = (event) => {
      if (event.runId === id) res.write(`event: update\ndata: ${JSON.stringify(event.run)}\n\n`);
    };
    this.events.on("run", listener);
    req.on("close", () => this.events.off("run", listener));
  }

  async execute(id, { resumed = false, recovered = false } = {}) {
    processSupervisor.enterRun(id);
    let run = await this.storage.getRun(id);
    hydrateRunState(run);
    await this.setStatus(run, "running");
    await this.persist(run, "client_input", `Normalized ${run.input.source || "google-maps"} input for ${run.input.target} qualified targets.`);
    await this.persist(run, "detect", `${resumed ? recovered ? "Recovered" : "Resumed" : "Started"} ${run.agent} for ${run.input.target} qualified targets.`);
    const preflight = await this.preflight(run);
    if (!preflight.ok) {
      run.diagnosis = preflight.message;
      await this.selfReviewRun(run).catch((reviewError) => this.recordFailure(run, "self_review", reviewError, {}));
      await this.setStatus(run, "failed");
      await this.persist(run, "deliver", `Preflight failed before discovery. ${preflight.message}`);
      return;
    }
    if (run.input.source === "github-owners") {
      await this.executeGitHubOwners(id, run, { resumed, recovered });
      return;
    }
    if (resumed) {
      await this.recordDecision(run, {
        type: recovered ? "recover_run" : "resume_run",
        observation: observeRun(run),
        action: `${recovered ? "Recover interrupted run" : "Resume from saved state"} without clearing attempted queries, leads, failures, or memory.`,
        reason: recovered
          ? "Server restart found an orphaned active run and continued from checkpoint."
          : "Continue solving from the last checkpoint instead of repeating completed work."
      });
    }
    if (hasOperationalMemory(run)) {
      await this.recordDecision(run, {
        type: "load_memory",
        observation: observeRun(run),
        action: `Loaded ${run.memory.skills.length} skills and ${run.memory.avoid.length} avoid lessons.`,
        reason: "Reuse previous successful repairs and avoid repeated failure patterns."
      });
    }
    if (run.input.locationPlan?.expanded) {
      await this.persist(run, "plan", `Expanded ${run.input.locationPlan.requested.join("; ")} into ${run.input.cities.length} USA search locations.`);
    }
    let ownerBacklogActive = false;
    let ownerBacklogPromise = Promise.resolve();
    const startOwnerBacklog = (reason) => {
      if (this.shouldStop(id, run)) return false;
      if (ownerBacklogActive) return true;
      ownerBacklogActive = true;
      ownerBacklogPromise = this.drainOwnerBacklog(run, { reason })
        .finally(() => {
          ownerBacklogActive = false;
        });
      return true;
    };
    const waitForOwnerBacklog = async (reason) => {
      startOwnerBacklog(reason);
      await ownerBacklogPromise;
      const pending = pendingDiscoveredLeads(run);
      if (pending.length && !this.shouldStop(id, run)) {
        await this.drainOwnerBacklog(run, { reason: `Final business-contact pass for ${pending.length} pending businesses.` });
      }
    };

    startOwnerBacklog("Processing previously discovered businesses while Google discovery continues.");

    try {
      let locationIndex = 0;
      const searchPlannedLocations = async () => {
        for (; locationIndex < run.input.cities.length; locationIndex += 1) {
          if (this.shouldStop(id, run)) break;
          const city = run.input.cities[locationIndex];
          const qualifiedBeforeCity = run.qualifiedLeads.length;
          await this.discoverCity(run, city, { startOwnerBacklog, isOwnerBacklogActive: () => ownerBacklogActive });
          const cityStagnated = run.search?.stagnatedCities?.includes(city) && run.qualifiedLeads.length === qualifiedBeforeCity;
          if (!this.shouldStop(id, run) && cityStagnated) {
            await this.pivotFromStagnation(run, city);
          } else if (!this.shouldStop(id, run) && shouldExpandLocations(run, locationIndex)) {
            await this.extendLocationPlan(run, city);
          }
        }
      };

      await searchPlannedLocations();

      if (!hasReachedRunGoal(run) && !this.active.get(id)?.cancelled) {
        await this.finalRecoveryPass(run, { startOwnerBacklog, isOwnerBacklogActive: () => ownerBacklogActive });
        locationIndex = run.input.cities.length;
      }

      if (!this.active.get(id)?.cancelled && !this.active.get(id)?.killed) {
        await waitForOwnerBacklog("Draining business-contact queue before delivery.");
      }

      while (
        !hasReachedRunGoal(run) &&
        run.input.cities.length < run.input.maxPlannedLocations &&
        !this.active.get(id)?.cancelled &&
        !this.active.get(id)?.killed
      ) {
        const anchor = run.search?.exhaustedCities?.at(-1) || run.input.cities.at(-1) || run.input.cities[0] || "United States";
        const added = await this.extendLocationPlan(run, anchor);
        if (!added) break;
        await searchPlannedLocations();
        if (!this.active.get(id)?.cancelled && !this.active.get(id)?.killed) {
          await waitForOwnerBacklog("Draining business-contact queue after continuing discovery.");
        }
      }

      if (hasReachedRunGoal(run)) {
        run.diagnosis = summarizeRunOutcome(run);
        await this.selfReviewRun(run);
        await this.setStatus(run, "complete");
        const contactSummary = isLeadScraperRun(run)
          ? `${run.qualifiedLeads.length} campaign-ready business contact(s)`
          : `${qualifiedOwnerDirectContactCount(run)} direct contact(s) and ${qualifiedOwnerAssociatedContactCount(run)} associated lead(s)`;
        await this.persist(run, "deliver", `Target reached with ${run.qualifiedLeads.length} qualified leads and ${contactSummary}. ${run.diagnosis}`);
      } else if (this.active.get(id)?.killed) {
        await this.setStatus(run, "killed");
      } else if (this.active.get(id)?.cancelled) {
        await this.setStatus(run, "cancelled");
      } else {
        run.diagnosis = summarizeRunOutcome(run);
        await this.selfReviewRun(run);
        await this.setStatus(run, "partial");
        await this.persist(run, "deliver", `Stopped with ${run.qualifiedLeads.length}/${run.input.target} qualified leads after exhausting the current plan. ${run.diagnosis}`);
      }
    } catch (error) {
      await this.recordFailure(run, "run", error, {});
      run.diagnosis = summarizeRunOutcome(run);
      await this.selfReviewRun(run).catch((reviewError) => this.recordFailure(run, "self_review", reviewError, {}));
      await this.setStatus(run, "failed");
    }
  }

  async executeGitHubOwners(id, run, { resumed = false, recovered = false } = {}) {
    if (resumed) {
      await this.recordDecision(run, {
        type: recovered ? "recover_run" : "resume_run",
        observation: observeRun(run),
        action: `${recovered ? "Recover interrupted legacy owner run" : "Resume from saved state"} without clearing attempted repos or leads.`,
        reason: recovered
          ? "Server restart found an orphaned active run and continued from checkpoint."
          : "Continue solving from the last checkpoint instead of repeating completed work."
      });
    }
    try {
      const platforms = run.input.platforms || run.input.cities;
      for (const platform of platforms) {
        if (this.shouldStop(id, run)) break;
        await this.discoverGitHubOwners(run, platform);
      }

      if (run.qualifiedLeads.length >= run.input.target) {
        run.diagnosis = summarizeRunOutcome(run);
        await this.selfReviewRun(run);
        await this.setStatus(run, "complete");
        await this.persist(run, "deliver", `Target reached with ${run.qualifiedLeads.length} qualified GitHub-owner leads. ${run.diagnosis}`);
      } else if (this.active.get(id)?.killed) {
        await this.setStatus(run, "killed");
      } else if (this.active.get(id)?.cancelled) {
        await this.setStatus(run, "cancelled");
      } else {
        run.diagnosis = summarizeRunOutcome(run);
        await this.selfReviewRun(run);
        await this.setStatus(run, "partial");
        await this.persist(run, "deliver", `Stopped with ${run.qualifiedLeads.length}/${run.input.target} qualified GitHub-owner leads. ${run.diagnosis}`);
      }
    } catch (error) {
      await this.recordFailure(run, "run", error, {});
      run.diagnosis = summarizeRunOutcome(run);
      await this.selfReviewRun(run).catch((reviewError) => this.recordFailure(run, "self_review", reviewError, {}));
      await this.setStatus(run, "failed");
    }
  }

  async discoverGitHubOwners(run, platform) {
    const config = this.github.loadPlatforms();
    const platformMeta = config.platforms[platform];
    if (!platformMeta) {
      await this.recordFailure(run, "platform_missing", new Error(`Platform ${platform} not in platform-repos.json`), { platform });
      return 0;
    }
    const repos = (platformMeta.repos || []).filter(Boolean);
    const reposToProcess = repos.slice(0, run.input.reposPerPlatform);
    if (!reposToProcess.length) {
      await this.recordFailure(run, "no_repos", new Error(`No repos configured for ${platform}`), { platform });
      return 0;
    }

    await this.recordDecision(run, {
      type: "github_owner_platform",
      observation: observeRun(run, { platform }),
      action: `Process ${reposToProcess.length} repos for platform=${platform} (${platformMeta.displayName || platform}).`,
      reason: "Iterate every configured repo for the platform and qualify its owner as a lead."
    });

    let qualifiedThisPlatform = 0;
    for (const fullName of reposToProcess) {
      if (this.shouldStop(run.id, run)) break;
      const qualified = await this.processOneRepo(run, platform, platformMeta, fullName);
      if (qualified) qualifiedThisPlatform += 1;
    }
    return qualifiedThisPlatform;
  }

  async processOneRepo(run, platform, platformMeta, fullName) {
    run.counters.reposProcessed = (run.counters.reposProcessed || 0) + 1;
    await this.persist(run, "maps_discovery", `Fetching repo ${fullName} for ${platform}.`);

    let repoMeta;
    try {
      repoMeta = await this.github.fetchRepo(fullName);
    } catch (error) {
      run.counters.repoFetchFailures = (run.counters.repoFetchFailures || 0) + 1;
      await this.recordFailure(run, "github_repo_fetch", error, { platform, repo: fullName });
      return false;
    }

    if ((repoMeta.stargazers_count || 0) < run.input.minStars) {
      await this.persist(run, "classify", `Skipped ${fullName} (${repoMeta.stargazers_count || 0} stars < ${run.input.minStars} threshold).`);
      return false;
    }
    if (repoMeta.archived) {
      await this.persist(run, "classify", `Skipped ${fullName} (archived).`);
      return false;
    }

    run.counters.reposAccepted = (run.counters.reposAccepted || 0) + 1;
    let bundle;
    try {
      bundle = await this.github.gatherOwnerBundle({ repo: repoMeta, platform, platformLabel: platformMeta.displayName || platform });
    } catch (error) {
      run.counters.ownerBundleFailures = (run.counters.ownerBundleFailures || 0) + 1;
      await this.recordFailure(run, "github_owner_bundle", error, { platform, repo: fullName });
      return false;
    }

    run.counters.ownersHunted = (run.counters.ownersHunted || 0) + 1;
    const sourceLog = [];
    let enriched;
    try {
      enriched = await this.contactHunter.enrichOwnerBundle(bundle, { run, sourceLog });
    } catch (error) {
      run.counters.contactHuntFailures = (run.counters.contactHuntFailures || 0) + 1;
      await this.recordFailure(run, "contact_hunt", error, { platform, repo: fullName, owner: bundle.owner?.login });
      enriched = { bundle, angles: [], emails: [], phones: [], summary: { summary: "", likelyBusiness: "", tractionSignal: "" } };
    }

    const hasContact = (enriched.emails?.length || 0) + (enriched.phones?.length || 0) > 0;
    const hasSummary = Boolean(enriched.summary?.summary);

    if (!hasContact || !hasSummary) {
      run.counters.ownersKeptHunting = (run.counters.ownersKeptHunting || 0) + 1;
      await this.persist(
        run,
        "classify",
        `Kept hunting ${bundle.owner?.login || fullName}: ${hasContact ? "no summary" : "no contact"} (angles tried: ${enriched.angles?.join(", ") || "none"}).`
      );
      run.huntLog ||= [];
      run.huntLog.unshift({
        at: new Date().toISOString(),
        platform,
        repo: fullName,
        owner: bundle.owner?.login || "",
        angles: enriched.angles || [],
        sourceLog,
        emails: enriched.emails || [],
        phones: enriched.phones || [],
        summary: enriched.summary || {},
        hasContact,
        hasSummary
      });
      run.huntLog = run.huntLog.slice(0, 200);
      return false;
    }

    const lead = this.normalizeOwnerLead(enriched, { platform, platformMeta });
    await this.persist(run, "lead_extraction", `Extracted GitHub-owner lead record for ${lead.name}.`);
    await this.persist(run, "quality_gate", `Scoring GitHub-owner lead ${lead.name}.`);
    const quality = await this.qualifyOwnerLead(run, lead, enriched);
    run.counters.ownersQualifiedCheck = (run.counters.ownersQualifiedCheck || 0) + 1;
    await this.recordProviderFallback(run, "owner_qualification", quality, { owner: lead.name, platform });

    if (quality.qualified && Number(quality.score || 0) >= run.input.minScore) {
      lead.id = randomUUID();
      lead.quality = quality;
      run.qualifiedLeads.push(lead);
      run.counters.qualified = run.qualifiedLeads.length;
      await this.persist(run, "evolve", `Qualified legacy owner ${lead.name} (${run.counters.qualified}/${run.input.target}).`);
      return true;
    }

    run.counters.rejected = (run.counters.rejected || 0) + 1;
    await this.persist(run, "classify", `Rejected ${lead.name}: score ${quality.score || 0}/${run.input.minScore}; ${quality.reason || "no reason"}`);
    return false;
  }

  normalizeOwnerLead(enriched, { platform, platformMeta }) {
    const owner = enriched.bundle?.owner || {};
    const repo = enriched.bundle?.repo || {};
    const summary = enriched.summary || {};
    return {
      id: "",
      name: owner.name || owner.login || repo.fullName || "Unknown",
      login: owner.login || "",
      category: `GitHub ${platformMeta?.displayName || platform} scraper owner`,
      platform,
      platformLabel: platformMeta?.displayName || platform,
      city: owner.location || "",
      country: "Global",
      address: "",
      phone: enriched.phones?.[0] || "",
      email: enriched.emails?.[0] || "",
      emails: enriched.emails || [],
      phones: enriched.phones || [],
      website: owner.website || owner.blog || "",
      twitter: owner.twitter || "",
      githubUrl: owner.htmlUrl || "",
      avatarUrl: owner.avatarUrl || "",
      followers: owner.followers || 0,
      publicRepos: owner.publicRepos || 0,
      hireable: Boolean(owner.hireable),
      repoFullName: repo.fullName || "",
      repoUrl: repo.htmlUrl || "",
      repoDescription: repo.description || "",
      repoStars: repo.stars || 0,
      repoForks: repo.forks || 0,
      repoLanguage: repo.language || "",
      repoPushedAt: repo.pushedAt || "",
      summary: summary.summary || "",
      likelyBusiness: summary.likelyBusiness || "",
      tractionSignal: summary.tractionSignal || "",
      angles: enriched.angles || [],
      sourceQuery: `${platform} legacy owner discovery`,
      source: "github-owners",
      enrichmentOk: Boolean(enriched.emails?.length || enriched.phones?.length)
    };
  }

  async qualifyOwnerLead(run, lead, enriched) {
    const fallback = fallbackOwnerQuality(lead, enriched);
    if (!this.minimax.configured()) return fallback;
    try {
      return await this.minimax.chatJson({
        fallback,
        system: "You are Hermes, a strict B2B lead qualification agent evaluating GitHub repo owners for an outreach campaign. Score based on: (1) the repo owner has shipped scraping code for the requested platform, (2) the contact channel is reachable, (3) the summary clearly describes their business, (4) there is a credible signal they might buy scraping tools/services. Score 100 when all hard criteria are satisfied.",
        user: JSON.stringify({
          task: "Decide whether this GitHub repo owner is a qualified outreach lead.",
          lead,
          summary: enriched.summary,
          criteria: run.input.criteria,
          scoringRubric: {
            "100": "All hard criteria met: scraping repo + reachable contact + clear summary + credible fit.",
            "70-99": "Likely fit but one criterion is weakly evidenced.",
            "1-69": "Partial fit with important missing evidence.",
            "0": "Not a fit."
          },
          outputSchema: {
            qualified: "boolean",
            score: "0-100 number",
            reason: "short string",
            missing: ["missing fields"]
          }
        })
      });
    } catch (error) {
      return fallback;
    }
  }

  async discoverCity(run, city, { startOwnerBacklog, isOwnerBacklogActive } = {}) {
    const search = ensureSearchState(run);
    const queuedKeys = new Set(search.attemptedQueries);
    const queuedFamilies = new Set();
    const queue = [];
    const useLocalOwnerPipeline = typeof startOwnerBacklog !== "function";
    let ownerPipelineActive = false;
    let ownerPipeline = Promise.resolve();
    const startOwnerPipeline = (reason) => {
      if (!useLocalOwnerPipeline) return startOwnerBacklog(reason);
      if (this.shouldStop(run.id, run)) return false;
      if (ownerPipelineActive) return true;
      ownerPipelineActive = true;
      ownerPipeline = this.drainOwnerBacklog(run, { reason })
        .finally(() => {
          ownerPipelineActive = false;
        });
      return true;
    };
    for (const query of memoryQueryHints(run, city)) {
      if (!memoryAvoidsQuery(run, query)) enqueueQuery(queue, queuedKeys, city, query, run, { queuedFamilies });
    }
    await this.planInitialSearchQueue(run, city, queue, queuedKeys, queuedFamilies);
    for (const query of buildQueryVariants(run.input.niche, city, run.input.countryScope, run.input.source)) {
      if (!memoryAvoidsQuery(run, query)) enqueueQuery(queue, queuedKeys, city, query, run, { queuedFamilies });
    }
    await this.recordQueryDiversity(run, city);
    await this.recordDecision(run, {
      type: "search_city",
      observation: observeRun(run, { city }),
      action: `Search ${city} with ${queue.length} initial query variants.`,
      reason: "Start each location with proven local discovery variants before adapting."
    });

    let cityAttempts = 0;
    let stagnantAttempts = 0;
    while (queue.length && cityAttempts < run.input.maxQueriesPerCity) {
      if (this.shouldStop(run.id, run)) return;
      const query = queue.shift();
      const attemptKey = queryAttemptKey(city, query);
      if (search.attemptedQueries.includes(attemptKey)) continue;

      search.attemptedQueries.push(attemptKey);
      rememberAttemptedQueryFamily(run, city, query);
      cityAttempts += 1;
      run.counters.searchAttempts += 1;
      await this.persist(run, "maps_discovery", `Searching Google Maps: ${query}`);

      let result;
      try {
        result = await this.googleMaps.discover({
          runId: run.id,
          query,
          total: discoveryTotal(run),
          attempt: run.counters.searchAttempts
        });
      } catch (error) {
        const mapsFailure = classifyMapsFailure(error?.message || error);
        rememberMapsFailure(run, city, mapsFailure);
        if (mapsFailure === "scraper_unhealthy" && mapsFailureCount(run, city, mapsFailure) >= 2) {
          const recovered = await this.tryDirectoryOfficialSiteDiscovery(run, city, {
            query,
            reason: `Google Maps scraper is unhealthy after ${mapsFailureCount(run, city, mapsFailure)} failures; switching to static directory and official-site seed discovery.`,
            startOwnerPipeline
          });
          if (recovered) {
            stagnantAttempts = 0;
            continue;
          }
        }
        const retry = await this.handleRetry(run, {
          stage: "discovery",
          city,
          query,
          error,
          attempt: cityAttempts
        });
        if (retry?.query) enqueueQuery(queue, queuedKeys, city, retry.query, run, { queuedFamilies });
        await this.extendSearchQueue(run, city, queue, queuedKeys, "Discovery tool failure needs an adaptive search path.");
        stagnantAttempts += 1;
        if (await this.stopStagnantCity(run, city, stagnantAttempts, "Discovery tool failures persisted.")) break;
        continue;
      }

      await this.persist(run, "detect", `Saved raw discovery output: ${result.rawPath}`);
      const failure = detectFailure({ stage: "discovery", rows: result.rows, target: run.input.target });
      if (failure.detected) {
        const mapsFailure = classifyMapsFailure(failure.message || failure.signal || "Google Maps discovery returned no rows.");
        rememberMapsFailure(run, city, mapsFailure);
        if (mapsFailure === "scraper_unhealthy" && mapsFailureCount(run, city, mapsFailure) >= 2) {
          const recovered = await this.tryDirectoryOfficialSiteDiscovery(run, city, {
            query,
            reason: `Google Maps transport stayed unhealthy after a saved result; switching to static directory and official-site seed discovery.`,
            startOwnerPipeline
          });
          if (recovered) {
            stagnantAttempts = 0;
            continue;
          }
        }
        const retry = await this.handleRetry(run, { stage: "discovery", city, query, failure, attempt: cityAttempts });
        if (retry?.query) enqueueQuery(queue, queuedKeys, city, retry.query, run, { queuedFamilies });
        await this.extendSearchQueue(run, city, queue, queuedKeys, failure.message);
        stagnantAttempts += 1;
        if (await this.stopStagnantCity(run, city, stagnantAttempts, failure.message)) break;
        continue;
      }

      const leads = uniqueBy(
        result.rows.map((row) => normalizeLead(row, {
          city,
          niche: run.input.niche,
          query,
          countryScope: run.input.countryScope
        })),
        leadKey
      ).filter((lead) => lead.name);

      const existingKeys = new Set(run.discoveredLeads.map(leadKey).filter(Boolean));
      const newLeads = leads.filter((lead) => !existingKeys.has(leadKey(lead)));
      if (!newLeads.length) {
        if ((typeof isOwnerBacklogActive === "function" && isOwnerBacklogActive()) || pendingDiscoveredLeads(run).length || this.shouldStop(run.id, run)) {
          await this.persist(run, "classify", `Discovery returned duplicate businesses for ${city}; business-contact enrichment is still processing, so Hermes continues without retrying the same failure.`);
          continue;
        }
        const retry = await this.handleRetry(run, {
          stage: "discovery",
          city,
          query,
          attempt: cityAttempts,
          failure: {
            detected: true,
            classification: "query issue",
            signal: "no new leads",
            message: "Discovery returned only leads that were already seen."
          }
        });
        if (retry?.query) enqueueQuery(queue, queuedKeys, city, retry.query, run, { queuedFamilies });
        await this.extendSearchQueue(run, city, queue, queuedKeys, "Discovery returned only duplicate leads.");
        stagnantAttempts += 1;
        if (await this.stopStagnantCity(run, city, stagnantAttempts, "Discovery returned only duplicate leads.")) break;
        continue;
      }

      run.discoveredLeads = uniqueBy([...run.discoveredLeads, ...newLeads], leadKey);
      run.counters.discovered = run.discoveredLeads.length;
      await this.persist(run, "classify", `${newLeads.length} new cleaned leads found for ${city} (${leads.length} returned).`);
      const delegatedToOwnerPipeline = startOwnerPipeline(`Latest Google Maps batch: ${newLeads.length} new businesses from ${query}.`);
      const retryMemory = search.retryQueries?.[attemptKey];
      if (retryMemory) {
        const skill = await this.minimax.proposeSkill({
          success: { change: `${retryMemory.variable}=${retryMemory.value}`, result: `${newLeads.length} new ${run.input.countryScope} leads` },
          failedStep: retryMemory.failure
        });
        await this.recordProviderFallback(run, "skill_learning", skill, { city, query });
        run.skillUpdates.push(skill);
        await this.storage.appendSkill(skill);
        await this.persist(run, "evolve", `Saved skill: ${skill.title}`);
      }
      const qualityStats = delegatedToOwnerPipeline ? null : await this.enrichAndQualify(run, newLeads);
      if (qualityStats?.processed && !qualityStats.qualified) {
        const retry = await this.handleRetry(run, {
          stage: "quality",
          city,
          query,
          attempt: cityAttempts,
          failure: {
            detected: true,
            classification: "qualification issue",
            signal: "threshold bottleneck",
            message: `No leads qualified from ${qualityStats.processed} enriched leads. Best score was ${qualityStats.bestScore}/${run.input.minScore}.`
          },
          qualityStats
        });
        if (retry?.query) enqueueQuery(queue, queuedKeys, city, retry.query, run, { queuedFamilies });
        await this.extendSearchQueue(run, city, queue, queuedKeys, "No leads qualified from the last batch.");
        stagnantAttempts += 1;
        if (await this.stopStagnantCity(run, city, stagnantAttempts, `No leads qualified after ${qualityStats.processed} processed leads.`)) break;
      } else if (qualityStats?.qualified) {
        stagnantAttempts = 0;
      }
    }

    if (useLocalOwnerPipeline) await ownerPipeline;
    if (useLocalOwnerPipeline && !this.shouldStop(run.id, run)) {
      const pending = pendingDiscoveredLeads(run);
      if (pending.length) {
        await this.persist(run, "lead_extraction", `Draining ${pending.length} remaining businesses after ${city} discovery.`);
        await this.enrichAndQualify(run, pending);
      }
    }

    if (!this.shouldStop(run.id, run) && !search.exhaustedCities.includes(city)) {
      search.exhaustedCities.push(city);
      await this.persist(run, "classify", `Exhausted ${city} after ${cityAttempts} distinct search attempts.`);
    }
  }

  async tryDirectoryOfficialSiteDiscovery(run, city, { query = "", reason = "", startOwnerPipeline } = {}) {
    const search = ensureSearchState(run);
    search.directoryFallbackCities ||= [];
    const cityKey = normalizeText(city).toLowerCase();
    if (search.directoryFallbackCities.includes(cityKey)) return false;
    search.directoryFallbackCities.push(cityKey);
    search.directoryFallbackCities = search.directoryFallbackCities.slice(-500);
    run.counters.directoryFallbacks = (run.counters.directoryFallbacks || 0) + 1;

    await this.recordDecision(run, {
      type: "directory_official_site",
      observation: observeRun(run, { city }),
      action: `Switch ${city} to directory and official-site seed discovery.`,
      reason
    });
    await this.persist(run, "maps_discovery", `Maps fallback: searching directories and official sites for ${run.input.niche} in ${city}.`);

    let result;
    try {
      result = await this.directoryDiscovery.searchDirectoryOfficialSite({
        city,
        niche: run.input.niche,
        countryScope: run.input.countryScope,
        limit: discoveryTotal(run)
      });
    } catch (error) {
      await this.recordFailure(run, "directory_discovery", error, { city, query });
      return false;
    }

    const rows = (Array.isArray(result?.seeds) ? result.seeds : [])
      .filter((row) => !hasExplicitDifferentCountry(`${row.address || ""} ${row.city || ""} ${row.country || ""}`, run.input.countryScope));
    await this.recordDecision(run, {
      type: "directory_official_site_result",
      observation: observeRun(run, { city }),
      action: `Directory fallback returned ${rows.length} seed rows for ${city}.`,
      reason: (result?.sourceBreakdown || []).map((entry) => `${entry.source}:${entry.ok ? entry.count : entry.error}`).join("; ") || "No source breakdown returned."
    });
    if (!rows.length) {
      await this.recordFailure(run, "directory_discovery", new Error("Directory fallback returned no USA seed rows."), { city, query });
      return false;
    }

    const leads = uniqueBy(
      rows.map((row) => ({
        ...normalizeLead(row, {
          city,
          niche: run.input.niche,
          query: row.sourceUrl || query || `directory official site ${city}`,
          countryScope: run.input.countryScope
        }),
        discoveryMode: row.discoveryMode || "directory_official_site",
        discoverySource: row.source || "directory_official_site",
        sourceUrl: row.sourceUrl || ""
      })),
      leadKey
    ).filter((lead) => lead.name);
    const existingKeys = new Set(run.discoveredLeads.map(leadKey).filter(Boolean));
    const newLeads = leads.filter((lead) => !existingKeys.has(leadKey(lead)));
    if (!newLeads.length) {
      await this.persist(run, "classify", `Directory fallback returned only duplicate businesses for ${city}.`);
      return false;
    }

    run.discoveredLeads = uniqueBy([...run.discoveredLeads, ...newLeads], leadKey);
    run.counters.discovered = run.discoveredLeads.length;
    await this.persist(run, "classify", `${newLeads.length} directory/official-site leads found for ${city} (${leads.length} returned).`);
    if (typeof startOwnerPipeline === "function") {
      startOwnerPipeline(`Directory fallback batch: ${newLeads.length} new businesses from ${city}.`);
    } else {
      await this.enrichAndQualify(run, newLeads);
    }
    return true;
  }

  async processPendingDiscoveredLeads(run) {
    run.processedLeadKeys = rebuildProcessedLeadKeys(run);
    return this.drainOwnerBacklog(run, {
      phase: "resume",
      reason: "Processing previously discovered businesses that were not enriched before interruption."
    });
  }

  async drainOwnerBacklog(run, { phase = "lead_extraction", reason = "" } = {}) {
    const totals = {
      processed: 0,
      qualified: 0,
      rejected: 0,
      belowMinScore: 0,
      bestScore: 0,
      bestLead: ""
    };
    while (!this.shouldStop(run.id, run)) {
      const pending = pendingDiscoveredLeads(run);
      if (!pending.length) break;
      const queueLabel = isLeadScraperRun(run) ? "Business-contact queue" : "Legacy contact queue";
      await this.persist(run, phase, `${queueLabel} processing ${pending.length} pending businesses. ${reason}`.trim());
      const stats = await this.enrichAndQualify(run, pending);
      totals.processed += stats.processed || 0;
      totals.qualified += stats.qualified || 0;
      totals.rejected += stats.rejected || 0;
      totals.belowMinScore += stats.belowMinScore || 0;
      if ((stats.bestScore || 0) > totals.bestScore) {
        totals.bestScore = stats.bestScore || 0;
        totals.bestLead = stats.bestLead || "";
      }
      if (!stats.processed) break;
    }
    return totals;
  }

  async stopStagnantCity(run, city, stagnantAttempts, reason) {
    if (!this.stagnationPivotEnabled || stagnantAttempts < this.stagnationWindow) return false;
    const search = ensureSearchState(run);
    if (!search.stagnatedCities.includes(city)) search.stagnatedCities.push(city);
    await this.recordDecision(run, {
      type: "stagnation_detected",
      observation: observeRun(run, { city }),
      action: `Stop repeating ${city} after ${stagnantAttempts} non-qualifying attempts and pivot strategy.`,
      reason
    });
    return true;
  }

  async recordQueryDiversity(run, city) {
    const search = ensureSearchState(run);
    const rejected = (search.rejectedQueryFamilies || [])
      .filter((entry) => entry.city === city && !entry.reported)
      .slice(0, 12);
    if (!rejected.length) return 0;
    for (const entry of rejected) entry.reported = true;
    await this.recordDecision(run, {
      type: "query_diversity",
      observation: observeRun(run, { city }),
      action: `Rejected ${rejected.length} repeated keyword variants before search in ${city}.`,
      reason: rejected.map((entry) => `${entry.query} repeats ${entry.family}`).join("; ")
    });
    return rejected.length;
  }

  async planInitialSearchQueue(run, city, queue, queuedKeys, queuedFamilies) {
    if (!this.initialStrategyEnabled || minimaxCircuitOpen(run) || typeof this.minimax.proposeSearchStrategy !== "function") return 0;
    const search = ensureSearchState(run);
    const strategy = await this.minimax.proposeSearchStrategy({
      timeoutMs: INITIAL_SEARCH_STRATEGY_TIMEOUT_MS,
      context: {
        niche: run.input.niche,
        city,
        countryScope: run.input.countryScope,
        criteria: run.input.criteria,
        target: run.input.target,
        qualified: run.qualifiedLeads.length,
        discovered: run.discoveredLeads.length,
        attemptedQueries: search.attemptedQueries
          .filter((key) => key.startsWith(`${normalizeText(city).toLowerCase()}::`))
          .map((key) => key.split("::").slice(1).join("::"))
          .slice(-20),
        existingInitialQueue: queue.slice(0, 8),
        operationalMemory: memoryForPrompt(run),
        reason: "Plan the first search pass from the user's niche, criteria, memory, and avoid rules before falling back to fixed templates."
      }
    });
    await this.recordProviderFallback(run, "initial_strategy", strategy, { city });

    const accepted = [];
    for (const query of normalizeStrategyQueries(strategy, run.input.niche, city, run.input.countryScope)) {
      if (accepted.length >= 6) break;
      if (memoryAvoidsQuery(run, query)) continue;
      if (enqueueQuery(queue, queuedKeys, city, query, run, { queuedFamilies })) accepted.push(ensureCountryQuery(query, run.input.countryScope));
    }
    run.counters.initialStrategies = (run.counters.initialStrategies || 0) + 1;

    await this.recordDecision(run, {
      type: "initial_strategy",
      observation: observeRun(run, { city }),
      action: accepted.length
        ? `Queued ${accepted.length} AI-planned search queries for ${city} before fixed templates.`
        : `AI initial strategy produced no unused queries for ${city}; fixed templates will backstop it.`,
      reason: strategy.reason || "Initial search should be planned from the problem context before automation."
    });
    return accepted.length;
  }

  async preflight(run) {
    let checks = [];
    const repairs = [];
    const rawChecks = [];

    try {
      await mkdir(this.storage.rawDir, { recursive: true });
      repairs.push({ type: "filesystem", ok: true, message: "Ensured raw data directory exists." });
    } catch (error) {
      rawChecks.push({ name: "rawDir", ok: false, repairable: false, message: error.message });
    }

    checks = [...rawChecks, ...this.toolAvailabilityChecks()];
    let failed = checks.filter((check) => !check.ok);
    if (failed.length && this.autoRepairTools && failed.some((check) => check.repairable)) {
      await this.recordDecision(run, {
        type: "repair_tools",
        observation: observeRun(run),
        action: `Attempt local tool bootstrap for ${failed.map((check) => check.name).join(", ")}.`,
        reason: failed.map((check) => check.message).join(" ")
      });

      const repair = await this.repairTools(run, failed.filter((check) => check.repairable));
      repairs.push(repair);
      run.counters.toolRepairs = (run.counters.toolRepairs || 0) + 1;
      if (!repair.ok) {
        await this.recordFailure(run, "tool_repair", new Error(repair.message), {
          command: repair.command,
          code: repair.code,
          output: repair.output
        });
      }

      const finalToolChecks = this.toolAvailabilityChecks().map((check) => ({ ...check, afterRepair: true }));
      checks = [...rawChecks, ...finalToolChecks];
      failed = checks.filter((check) => !check.ok);
    }

    run.preflight = {
      at: new Date().toISOString(),
      ok: failed.length === 0,
      checks,
      repairs
    };

    await this.recordDecision(run, {
      type: "preflight",
      observation: observeRun(run),
      action: failed.length
        ? `Blocked run before discovery: ${failed.map((check) => check.name).join(", ")} unavailable.`
        : `Validated ${checks.length} required tools before discovery.`,
      reason: failed.length
        ? failed.map((check) => check.message).join(" ")
        : repairs.map((repair) => repair.message).concat(checks.map((check) => check.message)).join(" ")
    });

    if (failed.length) {
      for (const failure of failed) {
        await this.recordFailure(run, "preflight", new Error(failure.message), { check: failure.name });
      }
    }

    return {
      ok: failed.length === 0,
      message: failed.length
        ? failed.map((check) => check.message).join(" ")
        : "Preflight passed."
    };
  }

  toolAvailabilityChecks() {
    const mapsAvailable = typeof this.googleMaps.available === "function" ? this.googleMaps.available() : true;
    const scraplingAvailable = typeof this.scrapling.available === "function" ? this.scrapling.available() : true;
    const ownerProfileToolsAvailable = this.linkedinTools?.available?.().ok || false;
    const ownerEvidenceToolsAvailable = this.ownerEvidenceTools?.available?.().ok || false;

    return [
      {
        name: "googleMapsScrapper",
        ok: mapsAvailable,
        repairable: true,
        message: mapsAvailable
          ? "Google Maps scraper is available."
          : "Google-Maps-Scrapper missing. Attempting local bootstrap via scripts/bootstrap_tools.sh."
      },
      {
        name: "scraplingEnricher",
        ok: scraplingAvailable,
        repairable: true,
        message: scraplingAvailable
          ? "Scrapling enrichment script is available."
          : "Scrapling enrichment script missing. Attempting local bootstrap via scripts/bootstrap_tools.sh."
      },
      {
        name: "ownerProfileTools",
        ok: ownerProfileToolsAvailable,
        repairable: true,
        message: ownerProfileToolsAvailable
          ? "Owner profile support tools are installed."
          : "Owner profile support tools missing. Attempting local bootstrap via scripts/bootstrap_tools.sh."
      },
      {
        name: "ownerEvidenceToolkit",
        ok: ownerEvidenceToolsAvailable,
        repairable: false,
        message: ownerEvidenceToolsAvailable
          ? "Optional OSINT owner evidence toolkit is installed."
          : "Optional OSINT owner evidence toolkit is missing; Hermes will continue with built-in public search and Scrapling."
      }
    ];
  }

  async repairTools(run, failedChecks) {
    const scriptPath = path.join(this.rootDir, "scripts", "bootstrap_tools.sh");
    const command = `bash ${path.relative(this.rootDir, scriptPath)}`;
    await this.persist(run, "retry", `Repairing missing tools: ${failedChecks.map((check) => check.name).join(", ")}.`);
    const result = await runCommand("bash", [scriptPath], {
      cwd: this.rootDir,
      timeoutMs: this.toolRepairTimeoutMs
    });
    const output = summarizeCommandOutput(result);
    return {
      type: "tool_bootstrap",
      ok: result.code === 0,
      command,
      code: result.code,
      output,
      message: result.code === 0
        ? "Tool bootstrap completed; availability will be rechecked."
        : `Tool bootstrap failed with code ${result.code}. ${output}`
    };
  }

  async extendLocationPlan(run, currentLocation) {
    if (run.input.cities.length >= run.input.maxPlannedLocations) return 0;
    const planned = new Set(run.input.cities.map((location) => normalizeText(location).toLowerCase()));
    const hints = relatedLocationHints(currentLocation, run.input.cities)
      .filter((location) => !planned.has(normalizeText(ensureCountryLocation(location, run.input.countryScope)).toLowerCase()))
      .slice(0, 12);
    const strategy = minimaxCircuitOpen(run) || typeof this.minimax?.proposeLocationStrategy !== "function" ? {
      locations: hints.slice(0, 4),
      reason: `MiniMax timeout circuit is open; using deterministic nearby ${run.input.countryScope} location hints.`
    } : await this.minimax.proposeLocationStrategy({
      context: {
        niche: run.input.niche,
        currentLocation,
        countryScope: run.input.countryScope,
        target: run.input.target,
        qualified: run.qualifiedLeads.length,
        discovered: run.discoveredLeads.length,
        rejected: run.counters.rejected || 0,
        exhaustedLocations: run.search?.exhaustedCities || [],
        plannedLocations: run.input.cities,
        untriedLocationHints: hints,
        remainingCapacity: run.input.maxPlannedLocations - run.input.cities.length,
        operationalMemory: memoryForPrompt(run)
      }
    });
    await this.recordProviderFallback(run, "location_strategy", strategy, { city: currentLocation });

    const locations = normalizeStrategyLocations(strategy, hints, run.input.countryScope)
      .filter((location) => !planned.has(normalizeText(location).toLowerCase()))
      .slice(0, Math.min(8, run.input.maxPlannedLocations - run.input.cities.length));

    if (locations.length) {
      run.input.cities.push(...locations);
      run.counters.locationExpansions = (run.counters.locationExpansions || 0) + locations.length;
      await this.recordDecision(run, {
        type: "expand_locations",
        observation: observeRun(run, { city: currentLocation }),
        action: `Added ${locations.length} new ${run.input.countryScope} search locations: ${locations.join("; ")}.`,
        reason: strategy.reason || "Current plan was not producing enough qualified leads."
      });
      return locations.length;
    }

    await this.recordDecision(run, {
      type: "expand_locations",
      observation: observeRun(run, { city: currentLocation }),
      action: `No unused ${run.input.countryScope} locations remained to add.`,
      reason: strategy.reason || "Location strategy produced no new locations."
    });
    return 0;
  }

  async pivotFromStagnation(run, currentLocation) {
    const addedLocations = await this.extendLocationPlan(run, currentLocation);
    run.counters.stagnationPivots = (run.counters.stagnationPivots || 0) + 1;
    await this.recordDecision(run, {
      type: "pivot_strategy",
      observation: observeRun(run, { city: currentLocation }),
      action: addedLocations
        ? `Pivoted away from stagnant ${currentLocation} by adding ${addedLocations} new search locations.`
        : `Pivot attempted after stagnant ${currentLocation}, but no unused location remained.`,
      reason: "Repeated search/enrichment attempts produced no qualified progress, so the agent changed the search dimension instead of continuing the same local loop."
    });
    return addedLocations;
  }

  async finalRecoveryPass(run, { startOwnerBacklog, isOwnerBacklogActive } = {}) {
    if (!this.finalRecoveryEnabled || run.finalRecoveryAttempted || this.shouldStop(run.id, run)) return 0;
    run.finalRecoveryAttempted = true;
    const anchor = run.search?.exhaustedCities?.at(-1) || run.input.cities.at(-1) || run.input.cities[0] || "United States";
    const plannedBefore = run.input.cities.length;
    const originalMax = run.input.maxPlannedLocations || plannedBefore;
    if (run.input.maxPlannedLocations < 100) {
      run.input.maxPlannedLocations = Math.min(100, Math.max(run.input.maxPlannedLocations, plannedBefore) + 2);
    }

    await this.recordDecision(run, {
      type: "final_recovery",
      observation: observeRun(run, { city: anchor }),
      action: `Before declaring partial, run one bounded recovery pass by adding up to two extra ${run.input.countryScope} locations and searching them.`,
      reason: summarizeRunOutcome(run)
    });

    const added = await this.extendLocationPlan(run, anchor);
    const recoveryLocations = run.input.cities.slice(plannedBefore);
    let searched = 0;
    for (const location of recoveryLocations) {
      if (this.shouldStop(run.id, run)) break;
      await this.discoverCity(run, location, { startOwnerBacklog, isOwnerBacklogActive });
      searched += 1;
    }

    run.counters.finalRecoveryPasses = (run.counters.finalRecoveryPasses || 0) + 1;
    run.finalRecovery = {
      at: new Date().toISOString(),
      anchor,
      originalMaxPlannedLocations: originalMax,
      maxPlannedLocations: run.input.maxPlannedLocations,
      addedLocations: recoveryLocations,
      searchedLocations: searched
    };
    await this.persist(run, "retry", `Final recovery added ${added} locations and searched ${searched}.`);
    return searched;
  }

  async extendSearchQueue(run, city, queue, queuedKeys, reason) {
    if (this.shouldStop(run.id, run)) return 0;
    const search = ensureSearchState(run);
    const strategy = minimaxCircuitOpen(run) ? {
      queries: buildQueryVariants(run.input.niche, city, run.input.countryScope),
      reason: "MiniMax timeout circuit is open; using deterministic search variants."
    } : await this.minimax.proposeSearchStrategy({
      context: {
        niche: run.input.niche,
        city,
        countryScope: run.input.countryScope,
        target: run.input.target,
        qualified: run.qualifiedLeads.length,
        discovered: run.discoveredLeads.length,
        rejected: run.counters.rejected || 0,
        belowMinScore: run.counters.belowMinScore || 0,
        enrichmentFailures: run.counters.enrichmentFailures || 0,
        attemptedQueries: search.attemptedQueries
          .filter((key) => key.startsWith(`${normalizeText(city).toLowerCase()}::`))
          .map((key) => key.split("::").slice(1).join("::"))
          .slice(-20),
        recentFailures: (run.failures || []).slice(-8).map((failure) => ({
          stage: failure.stage,
          message: failure.message
        })),
        operationalMemory: memoryForPrompt(run),
        reason
      }
    });
    await this.recordProviderFallback(run, "search_strategy", strategy, { city, reason });

    const queries = normalizeStrategyQueries(strategy, run.input.niche, city, run.input.countryScope);
    const queuedFamilies = queuedFamilySet(city, queue);
    const accepted = [];
    for (const query of queries) {
      if (accepted.length >= 6) break;
      if (memoryAvoidsQuery(run, query)) continue;
      if (enqueueQuery(queue, queuedKeys, city, query, run, { priority: true, queuedFamilies })) accepted.push(ensureCountryQuery(query, run.input.countryScope));
    }

    await this.recordDecision(run, {
      type: "adapt_search",
      observation: observeRun(run, { city, reason }),
      action: accepted.length ? `Queued ${accepted.length} priority adaptive search queries for ${city}.` : `No unused adaptive queries remained for ${city}.`,
      reason: strategy.reason || reason || "Search queue needed adaptation."
    });
    return accepted.length;
  }

  async enrichAndQualify(run, leads) {
    run.counters ||= {};
    run.counters.ownerRabbitHoles ||= 0;
    run.counters.ownerProfilesFound ||= 0;
    run.counters.ownerContactsFound ||= 0;
    run.processedLeadKeys ||= [];
    const processedKeys = new Set(run.processedLeadKeys);
    const stats = {
      processed: 0,
      qualified: 0,
      rejected: 0,
      belowMinScore: 0,
      bestScore: 0,
      bestLead: ""
    };

    const seenLeadKeys = new Set([...run.qualifiedLeads.map(leadKey).filter(Boolean), ...processedKeys]);
    const queuedLeads = [];
    for (const lead of leads) {
      const key = leadKey(lead);
      if (key && seenLeadKeys.has(key)) continue;
      if (key) seenLeadKeys.add(key);
      queuedLeads.push(lead);
    }

    const prioritizedLeads = prioritizeOwnerContactLeads(queuedLeads);
    await mapLimit(prioritizedLeads, leadWorkerConcurrency(run), async (lead) => {
      if (this.shouldStop(run.id, run)) return;

      const seedRejection = isLeadScraperRun(run) ? "" : ownerSeedRejectionReason(lead, run.input.niche);
      if (seedRejection) {
        const rejectedLead = {
          ...lead,
          contactTarget: "business_owner",
          email: "",
          phone: "",
          businessEmail: lead.email || "",
          businessPhone: lead.phone || "",
          quality: {
            qualified: false,
            score: 0,
            reason: seedRejection,
            missing: ["owner-first seed"]
          },
          ownerHuntStatus: "owner-seed-rejected",
          ownerHuntDecision: seedRejection,
          rabbitHoleStatus: "owner-seed-rejected"
        };
        stats.processed += 1;
        stats.rejected += 1;
        run.counters.rejected = (run.counters.rejected || 0) + 1;
        run.counters.ownerSeedRejections = (run.counters.ownerSeedRejections || 0) + 1;
        if (/chain|franchise|multi-location|corporate/i.test(seedRejection)) {
          run.counters.franchiseRejections = (run.counters.franchiseRejections || 0) + 1;
        }
        rememberRejectedLead(run, rejectedLead, 0);
        markLeadProcessed(run, rejectedLead);
        return;
      }

      let enrichment = { ok: false, emails: [], phones: [], text: "" };
      if (lead.website) {
        await this.persist(run, "website_enrichment", `Enriching website: ${lead.website}`);
        try {
          enrichment = await this.scrapling.enrich({ url: lead.website, mode: "simple" });
        } catch (error) {
          run.counters.enrichmentFailures = (run.counters.enrichmentFailures || 0) + 1;
          await this.recordFailure(run, "enrichment", error, { lead: lead.name, website: lead.website });
        }
        run.counters.enriched += enrichment.ok ? 1 : 0;
        if (!enrichment.ok && enrichment.error) {
          run.counters.enrichmentFailures = (run.counters.enrichmentFailures || 0) + 1;
          if (run.counters.enrichmentFailures <= 5) {
            await this.recordFailure(run, "enrichment", new Error(enrichment.error), { lead: lead.name, website: lead.website, mode: enrichment.mode || "simple" });
          }
        }
      }

      const ownerSwarmPlan = ownerAgentSwarmPlanForLead(run, lead, enrichment);
      let enrichedLead = {
        ...lead,
        ownerAgentSwarmSize: ownerSwarmPlan.size,
        ownerAgentPotentialScore: ownerSwarmPlan.score,
        ownerAgentEscalated: ownerSwarmPlan.escalated,
        ownerAgentEscalationReasons: ownerSwarmPlan.reasons,
        businessEmail: lead.email || enrichment.emails?.[0] || "",
        businessPhone: lead.phone || enrichment.phones?.[0] || "",
        email: "",
        phone: "",
        enrichmentMode: enrichment.mode || (lead.website ? "simple" : "none"),
        enrichmentOk: Boolean(enrichment.ok)
      };

      await this.persist(run, "lead_extraction", `Extracted lead record for ${enrichedLead.name}.`);
      if (isLeadScraperRun(run)) {
        enrichedLead = normalizeBusinessLead(enrichedLead, enrichment);
        await this.persist(run, "quality_gate", `Scoring business lead ${enrichedLead.name}.`);
        let quality;
        try {
          quality = minimaxCircuitOpen(run)
            ? fallbackQuality(enrichedLead, new Error("MiniMax timeout circuit is open; used local business lead qualification."))
            : await this.minimax.qualifyLead({
              lead: enrichedLead,
              pageText: enrichment.text,
              criteria: run.input.criteria
            });
        } catch (error) {
          quality = fallbackQuality(enrichedLead, error);
        }
        quality = enforceBusinessLeadQualityGate(enrichedLead, quality, run.input.minScore);
        await this.recordProviderFallback(run, "business_lead_qualification", quality, { lead: enrichedLead.name, city: enrichedLead.city });
        stats.processed += 1;
        enrichedLead.quality = quality;
        const score = Number(quality.score || 0);
        if (score > stats.bestScore) {
          stats.bestScore = score;
          stats.bestLead = enrichedLead.name;
        }
        if (quality.qualified && score >= run.input.minScore) {
          if (this.shouldStop(run.id, run)) return;
          enrichedLead.id = randomUUID();
          run.qualifiedLeads.push(enrichedLead);
          run.counters.qualified = run.qualifiedLeads.length;
          stats.qualified += 1;
          await this.persist(run, "evolve", `Qualified business lead ${enrichedLead.name} (${run.counters.qualified}/${run.input.target}).`);
        } else {
          stats.rejected += 1;
          run.counters.rejected = (run.counters.rejected || 0) + 1;
          if (quality.qualified && score < run.input.minScore) {
            stats.belowMinScore += 1;
            run.counters.belowMinScore = (run.counters.belowMinScore || 0) + 1;
          }
          rememberRejectedLead(run, enrichedLead, score);
          if (stats.rejected <= 3) {
            await this.persist(run, "classify", `Rejected ${enrichedLead.name}: score ${score}/${run.input.minScore}; ${quality.reason || "No reason returned."}`);
          }
        }
        markLeadProcessed(run, enrichedLead);
        return;
      }

      await this.persist(run, "website_enrichment", `Rabbit-hole owner search for ${enrichedLead.name}.`);
      let ownerContact = {};
      const ownerHuntTimeoutMs = ownerHuntTimeoutForRun(run, this.ownerHuntTimeoutMs);
      try {
        ownerContact = await withTimeout(
          this.ownerRabbitHole.findOwnerContact({
            lead: enrichedLead,
            initialEnrichment: enrichment,
            run
          }),
          ownerHuntTimeoutMs,
          `Owner rabbit-hole timed out after ${ownerHuntTimeoutMs}ms for ${enrichedLead.name}.`
        );
      } catch (error) {
        if (error.code === "HERMES_TIMEOUT") run.counters.ownerHuntTimeouts = (run.counters.ownerHuntTimeouts || 0) + 1;
        await this.recordFailure(run, "owner_rabbit_hole", error, { lead: enrichedLead.name, website: enrichedLead.website });
        ownerContact = timeoutOwnerContact({ lead: enrichedLead, error, timeoutMs: ownerHuntTimeoutMs });
      }
      countOwnerRabbitHole(run, ownerContact, { leadKey: leadKey(enrichedLead) });
      if (ownerContact.rabbitHoleStrategy?.searchQueries?.length) {
        await this.recordDecision(run, {
          type: "owner_rabbit_hole_strategy",
          observation: observeRun(run, { city: enrichedLead.city }),
          action: `Search owner trail for ${enrichedLead.name}: ${ownerContact.rabbitHoleStrategy.searchQueries.slice(0, 4).join("; ")}`,
          reason: ownerContact.rabbitHoleStrategy.strategyReason || "MiniMax/fallback strategy planned public owner-profile and article searches."
        });
      }

      enrichedLead = {
        ...enrichedLead,
        ...ownerContact,
        contactTarget: "business_owner",
        email: ownerContact.ownerEmail || "",
        phone: ownerContact.ownerPhone || ""
      };
      enrichedLead.contactConfidence = contactConfidence(enrichedLead);
      enrichedLead.ownerEvidenceGraph = buildOwnerEvidenceGraph(enrichedLead);

      if (ownerContact.rabbitHoleStatus === "owner-contact-exhausted" && !ownerContactProofPresent(enrichedLead) && !ownerAssociatedContactProofPresent(enrichedLead)) {
        const quality = {
          qualified: false,
          score: 0,
          reason: "Owner rabbit-hole research timed out or exhausted before owner-level email or phone proof.",
          missing: ["owner_contact"]
        };
        stats.processed += 1;
        stats.rejected += 1;
        run.counters.rejected = (run.counters.rejected || 0) + 1;
        enrichedLead.quality = quality;
        enrichedLead.ownerHuntStatus = "owner-contact-exhausted";
        enrichedLead.ownerHuntDecision = "Bounded owner rabbit-hole research timed out or exhausted without owner-level email or phone proof; Hermes moved to the next business.";
        rememberRejectedLead(run, enrichedLead, 0);
        if (stats.rejected <= 3) {
          await this.persist(run, "classify", `Rejected ${enrichedLead.name}: owner hunt exhausted; Hermes moved to the next business.`);
        }
        markLeadProcessed(run, enrichedLead);
        return;
      }

      let quality;
      try {
        await this.persist(run, "quality_gate", `Scoring ${enrichedLead.name}.`);
        quality = minimaxCircuitOpen(run)
          ? fallbackQuality(enrichedLead, new Error("MiniMax timeout circuit is open; used local qualification."))
          : await this.minimax.qualifyLead({
            lead: enrichedLead,
            pageText: enrichment.text,
            criteria: run.input.criteria
          });
      } catch (error) {
        quality = fallbackQuality(enrichedLead, error);
      }
      await this.recordProviderFallback(run, "qualification", quality, { lead: enrichedLead.name, city: enrichedLead.city });
      if (OWNER_ASSOCIATED_FALLBACK_ENABLED && ownerAssociatedContactProofPresent(enrichedLead) && !ownerContactProofPresent(enrichedLead)) {
        enrichedLead = normalizeOwnerAssociatedChannels(enrichedLead);
        quality = ownerAssociatedQuality(enrichedLead, quality, run.input.minScore);
      }
      quality = enforceOwnerContactQualityGate(enrichedLead, quality, run.input.minScore);

      if (shouldDeepEnrich({ lead: enrichedLead, enrichment, quality, minScore: run.input.minScore })) {
        await this.recordDecision(run, {
          type: "repair_enrichment",
          observation: observeRun(run, { city: lead.city }),
          action: `Retry ${lead.name} with dynamic website enrichment before final rejection.`,
          reason: quality.reason || "Initial evidence was too weak for qualification."
        });
        try {
          const deep = await this.scrapling.enrich({ url: lead.website, mode: "dynamic", timeout: 40 });
          run.counters.deepEnrichments = (run.counters.deepEnrichments || 0) + 1;
          if (deep.ok && !enrichment.ok) run.counters.enriched += 1;
          if (!deep.ok && deep.error) {
            run.counters.enrichmentFailures = (run.counters.enrichmentFailures || 0) + 1;
            await this.recordFailure(run, "enrichment", new Error(deep.error), { lead: lead.name, website: lead.website, mode: "dynamic" });
          } else {
            enrichment = mergeEnrichment(enrichment, deep);
            enrichedLead = {
              ...enrichedLead,
              businessEmail: enrichedLead.businessEmail || enrichment.emails?.[0] || "",
              businessPhone: enrichedLead.businessPhone || enrichment.phones?.[0] || "",
              enrichmentMode: "dynamic",
              enrichmentOk: Boolean(enrichment.ok)
            };
            if (!enrichedLead.ownerEmail && !enrichedLead.ownerPhone) {
              const ownerHuntTimeoutMs = ownerHuntTimeoutForRun(run, this.ownerHuntTimeoutMs);
              try {
                const repairedOwnerContact = await withTimeout(
                  this.ownerRabbitHole.findOwnerContact({
                    lead: enrichedLead,
                    initialEnrichment: enrichment,
                    run
                  }),
                  ownerHuntTimeoutMs,
                  `Owner rabbit-hole repair timed out after ${ownerHuntTimeoutMs}ms for ${enrichedLead.name}.`
                );
                countOwnerRabbitHole(run, repairedOwnerContact, { leadKey: leadKey(enrichedLead) });
                enrichedLead = {
                  ...enrichedLead,
                  ...repairedOwnerContact,
                  email: repairedOwnerContact.ownerEmail || "",
                  phone: repairedOwnerContact.ownerPhone || ""
                };
                enrichedLead.contactConfidence = contactConfidence(enrichedLead);
                enrichedLead.ownerEvidenceGraph = buildOwnerEvidenceGraph(enrichedLead);
              } catch (error) {
                if (error.code === "HERMES_TIMEOUT") run.counters.ownerHuntTimeouts = (run.counters.ownerHuntTimeouts || 0) + 1;
                await this.recordFailure(run, "owner_rabbit_hole", error, { lead: enrichedLead.name, website: enrichedLead.website, mode: "dynamic" });
                countOwnerRabbitHole(run, timeoutOwnerContact({ lead: enrichedLead, error, timeoutMs: ownerHuntTimeoutMs, mode: "dynamic" }), { leadKey: `${leadKey(enrichedLead)}::dynamic` });
              }
            }
            try {
              quality = minimaxCircuitOpen(run)
                ? fallbackQuality(enrichedLead, new Error("MiniMax timeout circuit is open; used local qualification repair."))
                : await this.minimax.qualifyLead({
                  lead: enrichedLead,
                  pageText: enrichment.text,
                  criteria: run.input.criteria
                });
            } catch (error) {
              quality = fallbackQuality(enrichedLead, error);
            }
            if (OWNER_ASSOCIATED_FALLBACK_ENABLED && ownerAssociatedContactProofPresent(enrichedLead) && !ownerContactProofPresent(enrichedLead)) {
              enrichedLead = normalizeOwnerAssociatedChannels(enrichedLead);
              quality = ownerAssociatedQuality(enrichedLead, quality, run.input.minScore);
            }
            quality = enforceOwnerContactQualityGate(enrichedLead, quality, run.input.minScore);
            await this.recordProviderFallback(run, "qualification_repair", quality, { lead: enrichedLead.name, city: enrichedLead.city });
          }
        } catch (error) {
          run.counters.enrichmentFailures = (run.counters.enrichmentFailures || 0) + 1;
          await this.recordFailure(run, "enrichment", error, { lead: lead.name, website: lead.website, mode: "dynamic" });
        }
      }

      stats.processed += 1;
      enrichedLead.quality = quality;
      const score = Number(quality.score || 0);
      if (score > stats.bestScore) {
        stats.bestScore = score;
        stats.bestLead = enrichedLead.name;
      }

      const hasOwnerContactProof = ownerContactProofPresent(enrichedLead);
      let hasOwnerAssociatedProof = OWNER_ASSOCIATED_FALLBACK_ENABLED && ownerAssociatedContactProofPresent(enrichedLead);
      if (!hasOwnerContactProof && hasOwnerAssociatedProof) {
        enrichedLead = normalizeOwnerAssociatedChannels(enrichedLead);
        hasOwnerAssociatedProof = ownerAssociatedContactProofPresent(enrichedLead);
      }
      enrichedLead.contactConfidence = contactConfidence(enrichedLead);
      enrichedLead.ownerEvidenceGraph = buildOwnerEvidenceGraph(enrichedLead);
      if (quality.qualified && score >= run.input.minScore && (hasOwnerContactProof || hasOwnerAssociatedProof)) {
        if (this.shouldStop(run.id, run)) return;
        enrichedLead.id = randomUUID();
        run.qualifiedLeads.push(enrichedLead);
        run.counters.qualified = run.qualifiedLeads.length;
        stats.qualified += 1;
        await this.persist(run, "evolve", `Qualified ${enrichedLead.name} (${run.counters.qualified}/${run.input.target}).`);
      } else {
        stats.rejected += 1;
        run.counters.rejected = (run.counters.rejected || 0) + 1;
        if (quality.qualified && score < run.input.minScore) {
          stats.belowMinScore += 1;
          run.counters.belowMinScore = (run.counters.belowMinScore || 0) + 1;
        }
        enrichedLead.ownerHuntStatus = hasOwnerContactProof ? "owner-contact-rejected" : "owner-contact-exhausted";
        enrichedLead.ownerHuntDecision = hasOwnerContactProof
          ? "Owner-level contact evidence was found but did not pass the quality threshold."
          : "Bounded owner rabbit-hole research ended without owner-level email or phone proof; Hermes moved to the next business.";
        rememberRejectedLead(run, enrichedLead, score);
        if (stats.rejected <= 3) {
          await this.persist(run, "classify", `Rejected ${enrichedLead.name}: score ${score}/${run.input.minScore}; ${quality.reason || "No reason returned."}`);
        }
        if (!enrichedLead.ownerEmail && !enrichedLead.ownerPhone) {
          await this.recordFailure(run, "quality", new Error("Missing owner email or owner phone after rabbit-hole enrichment."), { lead: enrichedLead.name, ownerName: enrichedLead.ownerName || "" });
        } else if (!hasOwnerContactProof) {
          await this.recordFailure(run, "quality", new Error("Owner email or phone was found without owner-level contact evidence."), { lead: enrichedLead.name, ownerName: enrichedLead.ownerName || "" });
        }
      }
      markLeadProcessed(run, enrichedLead);
    });

    run.scaleAudit = buildScaleAudit(run);
    await this.storage.saveRun(run);
    return stats;
  }

  async handleRetry(run, context) {
    const failure = context.failure || detectFailure({ stage: context.stage, error: context.error, target: run.input.target });
    await this.recordFailure(run, context.stage, failure.message || context.error, context);
    await this.log(run.id, "classify", `${failure.classification}: ${failure.signal}.`);

    const search = ensureSearchState(run);
    const retry = minimaxCircuitOpen(run) ? {
      variable: "query",
      value: nextFallbackQuery(run, context),
      reason: "MiniMax timeout circuit is open; using next deterministic fallback query."
    } : await this.minimax.proposeRetry({
      failure,
      context: {
        niche: run.input.niche,
        city: context.city,
        query: context.query,
        countryScope: run.input.countryScope,
        attempt: context.attempt,
        target: run.input.target,
        qualified: run.qualifiedLeads.length,
        discovered: run.discoveredLeads.length,
        attemptedQueries: search.attemptedQueries
          .filter((key) => key.startsWith(`${normalizeText(context.city).toLowerCase()}::`))
          .map((key) => key.split("::").slice(1).join("::"))
          .slice(-12),
        untriedQueryHints: buildQueryVariants(run.input.niche, context.city, run.input.countryScope)
          .filter((query) => !search.attemptedQueries.includes(queryAttemptKey(context.city, query)))
          .slice(0, 8),
        operationalMemory: memoryForPrompt(run)
      }
    });
    await this.recordProviderFallback(run, "retry_strategy", retry, { city: context.city, query: context.query, stage: context.stage });

    const retryValue = retry.variable === "query" ? ensureCountryQuery(retry.value, run.input.countryScope) : retry.value;
    let retryQuery = retryToQuery({
      retry: { ...retry, value: retryValue },
      context,
      niche: run.input.niche,
      countryScope: run.input.countryScope
    });

    if (!retryQuery || !isUsableSearchQuery(retryQuery) || search.attemptedQueries.includes(queryAttemptKey(context.city, retryQuery)) || queryFamilyAlreadySeen(run, context.city, retryQuery)) {
      retryQuery = nextFallbackQuery(run, context);
    }

    if (!retryQuery) {
      await this.persist(run, "retry", `No unused retry query remained for ${context.city}.`);
      await this.recordDecision(run, {
        type: "retry_exhausted",
        observation: observeRun(run, context),
        action: `No retry query available for ${context.city}.`,
        reason: failure.message || failure.signal || "Retry proposal repeated existing work."
      });
      return { ...retry, value: retryValue, query: "" };
    }

    run.counters.retries += 1;
    search.retryQueries[queryAttemptKey(context.city, retryQuery)] = {
      variable: retry.variable,
      value: retryValue,
      reason: retry.reason || "",
      failure
    };
    await this.recordDecision(run, {
      type: "retry",
      observation: observeRun(run, context),
      action: `Change ${retry.variable} to ${retryValue}; search ${retryQuery}.`,
      reason: retry.reason || failure.message || "Retry changed one variable."
    });
    await this.persist(run, "retry", `Retry change: ${retry.variable} -> ${retryValue}; next query: ${retryQuery}`);
    return { ...retry, value: retryValue, query: retryQuery };
  }

  async recordDecision(run, decision) {
    run.agentDecisions ||= [];
    const entry = {
      id: randomUUID(),
      at: new Date().toISOString(),
      ...decision
    };
    run.agentDecisions.push(entry);
    run.agentDecisions = run.agentDecisions.slice(-200);
    await this.persist(run, "plan", `${entry.type}: ${entry.action} Reason: ${entry.reason}`);
  }

  async recordProviderFallback(run, stage, result, context = {}) {
    recordProviderTelemetry(run, result);
    if (!result?.providerFallback) return false;
    run.counters.providerFallbacks = (run.counters.providerFallbacks || 0) + 1;
    run.counters.llmFallbacks = (run.counters.llmFallbacks || 0) + 1;
    run.providerFallbackStages ||= {};
    const message = result.providerError || `MiniMax provider fallback used during ${stage}.`;
    const firstForStage = !run.providerFallbackStages[stage];
    run.providerFallbackStages[stage] = true;

    if (firstForStage) {
      await this.recordFailure(run, "provider", new Error(message), {
        model: this.minimax.model,
        stage,
        providerMeta: result.providerMeta || {},
        ...context
      });
      await this.recordDecision(run, {
        type: "provider_fallback",
        observation: observeRun(run, { ...context, stage }),
        action: `Continue ${stage} with deterministic local fallback instead of stopping the run.`,
        reason: message
      });
    }
    return true;
  }

  async selfReviewRun(run) {
    const review = await this.minimax.proposeRunReview({ run: compactRunForReview(run) });
    await this.recordProviderFallback(run, "self_review", review, {});
    run.selfReviews ||= [];
    run.selfReviews.push({ ...review, at: new Date().toISOString() });
    run.selfReviews = run.selfReviews.slice(-20);

    for (const skill of review.skills || []) {
      if (!skill?.title || !skill?.do) continue;
      run.skillUpdates.push(skill);
      await this.storage.appendSkill(skill);
    }
    for (const entry of review.avoid || []) {
      if (!entry?.failure && !entry?.avoid) continue;
      await this.storage.appendAvoid({
        stage: entry.stage || "self_review",
        failure: entry.failure || entry.avoid,
        context: { runId: run.id, niche: run.input.niche },
        avoid: entry.avoid || "Avoid repeating this failure pattern."
      });
    }

    await this.recordDecision(run, {
      type: "self_review",
      observation: observeRun(run),
      action: `Stored ${(review.skills || []).length} skills and ${(review.avoid || []).length} avoid lessons from run review.`,
      reason: review.summary || "Reflect on the run to improve future behavior."
    });
  }

  async recordFailure(run, stage, error, context) {
    const entry = {
      id: randomUUID(),
      stage,
      message: typeof error === "string" ? error : error.message || String(error),
      context,
      createdAt: new Date().toISOString()
    };
    run.failures.push(entry);
    await this.storage.appendAvoid({
      stage,
      failure: entry.message,
      context,
      avoid: "Do not repeat the same variable set without changing exactly one input."
    });
    await this.persist(run, "classify", `Recorded failure: ${entry.message}`);
  }

  shouldStop(id, run) {
    return this.active.get(id)?.cancelled || hasReachedRunGoal(run);
  }

  async setStatus(run, status) {
    run.status = status;
    await this.persist(run, "status", status);
  }

  async log(id, phase, message) {
    const run = await this.storage.getRun(id);
    if (!run) return;
    await this.persist(run, phase, message);
  }

  async persist(run, phase, message) {
    const activeState = this.active.get(run.id);
    if (activeState?.killed) {
      run.status = "killed";
    } else if (activeState?.cancelled && !["killed", "complete"].includes(run.status)) {
      run.status = "cancelled";
    }
    run.updatedAt = new Date().toISOString();
    run.workflow ||= createRunWorkflow(run.input || {});
    run.minimaxQueue = this.minimax?.metrics?.() || {};
    run.scaleAudit = buildScaleAudit(run);
    run.steps.push({
      phase,
      phaseLabel: workflowPhaseLabel(phase),
      workflowStage: workflowStageForPhase(phase),
      message,
      at: run.updatedAt
    });
    await this.storage.saveRun(run);
    this.events.emit("run", { runId: run.id, run });
    if (this.flowWatchdog && run.flowWatchdog?.enabled !== false) {
      this.flowWatchdog.inspect(run, { phase, message })
        .then(async (repair) => {
          if (!repair) return;
          run.minimaxQueue = this.minimax?.metrics?.() || {};
          run.scaleAudit = buildScaleAudit(run);
          await this.storage.saveRun(run);
          this.events.emit("run", { runId: run.id, run });
        })
        .catch(() => {});
    }
  }
}

function runCommand(command, args, { cwd, timeoutMs }) {
  return new Promise((resolve) => {
    const child = processSupervisor.spawn(command, args, {
      cwd,
      stdio: ["ignore", "pipe", "pipe"],
      detached: process.platform !== "win32"
    }, { label: command, killGroup: true });
    let stdout = "";
    let stderr = "";
    let timedOut = false;
    const timer = setTimeout(() => {
      timedOut = true;
      processSupervisor.killChild(child, "SIGTERM");
      stderr += `\nTimed out after ${timeoutMs}ms`;
      setTimeout(() => processSupervisor.killChild(child, "SIGKILL"), 5000).unref?.();
    }, timeoutMs);

    child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
    child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
    child.on("close", (code) => {
      clearTimeout(timer);
      resolve({ code: timedOut ? 124 : code, stdout, stderr });
    });
    child.on("error", (error) => {
      clearTimeout(timer);
      resolve({ code: 1, stdout, stderr: `${stderr}\n${error.message}` });
    });
  });
}

function withTimeout(promise, timeoutMs, message) {
  if (!timeoutMs) return promise;
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => {
      const error = new Error(message || `Timed out after ${timeoutMs}ms`);
      error.code = "HERMES_TIMEOUT";
      reject(error);
    }, timeoutMs);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

function normalizeOwnerHuntTimeoutMs(value) {
  const timeoutMs = Number(value || DEFAULT_OWNER_HUNT_TIMEOUT_MS);
  if (!Number.isFinite(timeoutMs)) return DEFAULT_OWNER_HUNT_TIMEOUT_MS;
  return Math.max(MIN_OWNER_HUNT_TIMEOUT_MS, Math.min(MAX_OWNER_HUNT_TIMEOUT_MS, timeoutMs));
}

function autoLeadWorkerConcurrency(target) {
  const requested = Number(target || 0);
  if (requested <= 25) return 2;
  if (requested <= 100) return 3;
  if (requested <= 250) return 5;
  if (requested <= 500) return 8;
  return 12;
}

function autoOwnerAgentSwarmSize(target) {
  const requested = Number(target || 0);
  if (requested <= 25) return 6;
  if (requested <= 100) return 8;
  if (requested <= 250) return 10;
  return 12;
}

function ownerHuntTimeoutForRun(run = {}, fallback = DEFAULT_OWNER_HUNT_TIMEOUT_MS) {
  return normalizeOwnerHuntTimeoutMs(run.input?.ownerHuntTimeoutMs || fallback || DEFAULT_OWNER_HUNT_TIMEOUT_MS);
}

function timeoutOwnerContact({ lead = {}, error = {}, timeoutMs, mode = "initial" } = {}) {
  const requested = Math.max(1, Math.min(50, Number(lead.ownerAgentSwarmSize || 0) || 1));
  const timedOut = error.code === "HERMES_TIMEOUT";
  return {
    rabbitHoleStatus: timedOut ? "owner-contact-exhausted" : "owner-rabbit-hole-error",
    ownerSocialProfiles: [],
    ownerEmails: [],
    ownerPhones: [],
    ownerContactEvidence: [],
    ownerContactSource: "not-found",
    ownerAgentSwarm: {
      requested,
      requestedPerPass: requested,
      maxPerPass: 50,
      deployed: 0,
      deployedTotal: 0,
      timedOut,
      timeoutMs,
      mode,
      summary: {
        searchHits: 0,
        pagesFetched: 0,
        ownerContactPromotions: 0,
        ownerCandidatesFound: 0,
        socialLinksFound: 0,
        timedOut: timedOut ? 1 : 0
      },
      traces: []
    }
  };
}

function summarizeCommandOutput(result) {
  const text = normalizeText([result.stderr, result.stdout].filter(Boolean).join("\n"));
  if (!text) return "";
  return text.length > 1200 ? `${text.slice(0, 1200)}...` : text;
}

function mostRecentlyStartedRun(runs = []) {
  return runs
    .slice()
    .sort((a, b) => String(b.createdAt || b.updatedAt || "").localeCompare(String(a.createdAt || a.updatedAt || "")))[0] || null;
}

function normalizeInput(input) {
  const explicitLocation = normalizeText(input.location || "");
  const targetScope = normalizeCountryScope(input.country || input.countryScope || explicitLocation || COUNTRY_SCOPE);
  const requestedSource = normalizeText(input.source || LEAD_SCRAPER_SOURCE);
  const source = requestedSource === "multi-source-owner-evidence" ? "multi-source-owner-evidence" : LEAD_SCRAPER_SOURCE;
  const requestedCities = explicitLocation
    ? [explicitLocation]
    : wantsAllCountryLocations(input, targetScope)
      ? allCountryLocations(targetScope)
      : parseCountryLocations(input.cities || input.city || "", targetScope);
  const criteria = normalizeText(
    input.criteria ||
      (source === LEAD_SCRAPER_SOURCE
        ? "Collect public business contact information only: business name, website, public business email, phone number, contact form URL, social links, location, source/evidence URL, validation status, quality/contact confidence score, and campaign-ready export fields. Use Google Maps, business websites, directories, social pages, booking platforms, and public company pages. Do not look for owners, founders, managers, decision-makers, personal emails, personal phones, personal LinkedIn profiles, or owner proof."
        : "Use Google Maps, websites, directories, registries, booking platforms, social pages, and local articles only as seed/evidence sources. Identify a real person owner first, prove that owner with public evidence, search that person's public profile/contact trail, and return the best available way to reach that owner.")
  );
  const target = Math.max(1, Math.min(1000, Number(input.target || 5)));
  const locationPlan = expandLocationPlan(requestedCities.length ? requestedCities : [`local area, ${countryQuerySuffix(targetScope)}`], target, targetScope);
  const perLocationTarget = Math.ceil(target / Math.max(1, locationPlan.cities.length));
  const requestedMaxPlannedLocations = Number(input.maxPlannedLocations || 0);
  const maxPlannedLocations = requestedMaxPlannedLocations
    ? Math.max(locationPlan.cities.length, Math.min(500, requestedMaxPlannedLocations))
    : Math.max(locationPlan.cities.length, Math.min(500, Math.max(DEFAULT_MAX_PLANNED_LOCATIONS, Math.ceil(target / 2) + locationPlan.cities.length)));
  const requestedLeadWorkers = Number(input.leadWorkerConcurrency || 0);
  const requestedOwnerAgents = Number(input.ownerAgentSwarmSize || 0);

  return {
    source,
    strategyVersion: source === LEAD_SCRAPER_SOURCE ? LEAD_SCRAPER_STRATEGY_VERSION : OWNER_FIRST_STRATEGY_VERSION,
    niche: normalizeText(input.niche || "local businesses"),
    countryScope: targetScope,
    locationScope: explicitLocation || targetScope,
    cities: locationPlan.cities,
    locationPlan,
    target,
    minScore: Math.max(1, Math.min(100, Number(input.minScore || 60))),
    maxQueriesPerCity: Math.max(4, Math.min(16, Number(input.maxQueriesPerCity || Math.ceil(perLocationTarget / 10) + 4))),
    maxPlannedLocations,
    leadWorkerConcurrency: requestedLeadWorkers
      ? Math.max(1, Math.min(20, requestedLeadWorkers))
      : autoLeadWorkerConcurrency(target),
    ownerAgentSwarmSize: requestedOwnerAgents
      ? Math.max(1, Math.min(50, requestedOwnerAgents))
      : autoOwnerAgentSwarmSize(target),
    ownerHuntTimeoutMs: normalizeOwnerHuntTimeoutMs(input.ownerHuntTimeoutMs || process.env.HERMES_OWNER_HUNT_TIMEOUT_MS || DEFAULT_OWNER_HUNT_TIMEOUT_MS),
    criteria: `${criteria} Location lock: only qualify businesses physically located in ${explicitLocation || targetScope}. Reject leads outside ${explicitLocation || targetScope}.`
  };
}

function normalizeOwnerInput(input) {
  const platforms = Array.isArray(input.platforms)
    ? input.platforms.map((p) => String(p).trim().toLowerCase()).filter(Boolean)
    : parseUsLocations(input.platforms || "").map((p) => slugifyPlatform(p));
  const target = Math.max(1, Math.min(2000, Number(input.target || 50)));
  const minScore = Math.max(1, Math.min(100, Number(input.minScore || 60)));
  const reposPerPlatform = Math.max(1, Math.min(50, Number(input.reposPerPlatform || 5)));
  const minStars = Math.max(0, Math.min(100000, Number(input.minStars || 0)));

  return {
    source: "github-owners",
    niche: normalizeText(input.niche || "GitHub repo owners with scrapers"),
    countryScope: "Global (GitHub repo owners)",
    cities: platforms.length ? platforms : ["google_maps"],
    platforms,
    reposPerPlatform,
    minStars,
    target,
    minScore,
    criteria: normalizeText(
      input.criteria ||
        "Lead must be the maintainer of a public GitHub repository that scrapes the requested platform. " +
        "Lead qualifies only when at least one reachable contact (email or phone) AND a short business summary are present."
    ),
    maxQueriesPerCity: reposPerPlatform,
    maxPlannedLocations: platforms.length
  };
}

function slugifyPlatform(value) {
  return String(value || "")
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_|_$/g, "");
}

function wantsAllUsaLocations(input) {
  if (["1", "true", "on", "yes"].includes(String(input.allUsa || "").toLowerCase())) return true;
  return /^(all\s+usa|all\s+u\.s\.a\.|all\s+states|nationwide|united\s+states|usa)$/i.test(normalizeText(input.cities || input.city || ""));
}

function hydrateRunState(run) {
  run.steps ||= [];
  run.failures ||= [];
  run.skillUpdates ||= [];
  run.agentDecisions ||= [];
  run.selfReviews ||= [];
  run.providerFallbackStages ||= {};
  run.workflow ||= createRunWorkflow(run.input || {});
  run.flowWatchdog ||= { enabled: true, checks: 0, alerts: [], repairs: [], lastStepCount: 0, lastQualified: 0, lastOwnerContactsFound: 0 };
  run.discoveredLeads ||= [];
  run.qualifiedLeads ||= [];
  run.rejectedLeads ||= [];
  sanitizeQualifiedLeads(run);
  recomputeVerifiedOwnerContactCounters(run);
  run.processedLeadKeys = rebuildProcessedLeadKeys(run);
  run.ownerContactCountedLeadKeys ||= [];
  run.resumes ||= [];
  run.counters ||= {};
  run.counters.discovered ||= run.discoveredLeads.length;
  run.counters.enriched ||= 0;
  run.counters.qualified ||= run.qualifiedLeads.length;
  run.counters.retries ||= 0;
  run.counters.searchAttempts ||= 0;
  run.counters.rejected ||= 0;
  run.counters.belowMinScore ||= 0;
  run.counters.enrichmentFailures ||= 0;
  run.counters.deepEnrichments ||= 0;
  run.counters.providerFallbacks ||= 0;
  run.counters.locationExpansions ||= 0;
  run.counters.toolRepairs ||= 0;
  run.counters.stagnationPivots ||= 0;
  run.counters.initialStrategies ||= 0;
  run.counters.finalRecoveryPasses ||= 0;
  run.counters.queryDuplicates ||= 0;
  run.counters.ownerRabbitHoles ||= 0;
  run.counters.ownerHuntTimeouts ||= 0;
  run.counters.ownerProfilesFound ||= 0;
  run.counters.ownerContactsFound ||= 0;
  run.counters.ownerAgentsStarted ||= 0;
  run.counters.ownerAgentsDeployed ||= 0;
  run.counters.ownerAgentsTimedOut ||= 0;
  run.counters.ownerAgentSearchHits ||= 0;
  run.counters.ownerAgentPagesFetched ||= 0;
  run.counters.ownerAgentContactPromotions ||= 0;
  run.counters.ownerSeedRejections ||= 0;
  run.counters.franchiseRejections ||= 0;
  run.counters.ownerIdentityGateRejections ||= 0;
  run.counters.llmCalls ||= 0;
  run.counters.llmFallbacks ||= 0;
  run.counters.llmTimeouts ||= 0;
  run.counters.llmDurationMs ||= 0;
  run.search ||= {};
  run.search.attemptedQueries ||= [];
  run.search.exhaustedCities ||= [];
  run.search.retryQueries ||= {};
  run.search.queryFamilies = queryFamiliesFromAttempts(run);
  run.search.rejectedQueryFamilies ||= [];
  run.search.stagnatedCities ||= [];
  run.memory ||= { skills: [], avoid: [] };
  run.input ||= {};
  if (!run.input.source || run.input.source === "google-maps") run.input.source = LEAD_SCRAPER_SOURCE;
  run.input.strategyVersion ||= run.input.source === LEAD_SCRAPER_SOURCE ? LEAD_SCRAPER_STRATEGY_VERSION : OWNER_FIRST_STRATEGY_VERSION;
  if (!run.workflow || run.workflow.source === "google-maps") run.workflow = createRunWorkflow(run.input);
  run.input.cities ||= [];
  run.input.maxPlannedLocations ||= Math.max(run.input.cities.length, 20);
  run.input.ownerHuntTimeoutMs = normalizeOwnerHuntTimeoutMs(run.input.ownerHuntTimeoutMs || process.env.HERMES_OWNER_HUNT_TIMEOUT_MS || DEFAULT_OWNER_HUNT_TIMEOUT_MS);
  run.input.maxQueriesPerCity ||= 6;
  run.scaleAudit = buildScaleAudit(run);
}

function discoveryTotal(run) {
  const perLocationTarget = Math.ceil(run.input.target / Math.max(1, run.input.cities.length));
  return Math.max(20, Math.min(120, perLocationTarget * 3));
}

function buildQueryVariants(niche, city, countryScope = COUNTRY_SCOPE, source = "") {
  if (source === LEAD_SCRAPER_SOURCE) return buildLeadScraperQueryVariants(niche, city, countryScope);
  const location = ensureCountryLocation(city, countryScope);
  return buildOwnerFirstQueryVariants(niche, location, { maxQueries: 40 });
}

function buildLeadScraperQueryVariants(niche, city, countryScope = COUNTRY_SCOPE) {
  const location = ensureCountryLocation(city, countryScope);
  const suffix = countryQuerySuffix(countryScope);
  return uniqueBy([
    `${niche} in ${location}`,
    `${niche} ${location} email`,
    `${niche} ${location} phone website`,
    `${niche} ${location} contact page`,
    `${niche} ${location} official website`,
    `${niche} ${location} directory`,
    `site:linkedin.com/company ${niche} ${location}`,
    `${niche} ${location} Facebook Instagram`,
    `${niche} near ${location} ${suffix}`
  ], (query) => normalizeText(query).toLowerCase()).slice(0, 40);
}

function expandLocationPlan(locations, target, countryScope = COUNTRY_SCOPE) {
  const requested = locations.map((location) => ensureCountryLocation(location, countryScope));
  const desiredLocations = Math.max(requested.length, Math.min(60, Math.ceil(target / 20) + requested.length));
  const planned = [];

  for (const location of requested) {
    planned.push(location);
    const cityPlan = stateCitiesFor(location);
    for (const city of cityPlan) {
      if (planned.length >= desiredLocations) break;
      planned.push(ensureCountryLocation(city, countryScope));
    }
  }

  const cities = uniqueBy(planned, (location) => normalizeText(location).toLowerCase());
  return {
    requested,
    cities,
    expanded: cities.length > requested.length
  };
}

function stateCitiesFor(location) {
  const key = normalizeStateKey(location);
  return STATE_CITY_PLAN[key] || [];
}

function normalizeStateKey(location) {
  const cleaned = normalizeText(location)
    .replace(/\s*,?\s*(usa|u\.s\.a\.|united states|united states of america)$/i, "")
    .toLowerCase();
  return STATE_ALIASES[cleaned] || cleaned;
}

const STATE_ALIASES = {
  al: "alabama",
  ak: "alaska",
  az: "arizona",
  ar: "arkansas",
  ca: "california",
  co: "colorado",
  ct: "connecticut",
  de: "delaware",
  fl: "florida",
  ga: "georgia",
  hi: "hawaii",
  id: "idaho",
  il: "illinois",
  in: "indiana",
  ia: "iowa",
  ks: "kansas",
  ky: "kentucky",
  la: "louisiana",
  me: "maine",
  md: "maryland",
  ma: "massachusetts",
  mi: "michigan",
  mn: "minnesota",
  ms: "mississippi",
  mo: "missouri",
  mt: "montana",
  ne: "nebraska",
  nv: "nevada",
  nh: "new hampshire",
  nj: "new jersey",
  nm: "new mexico",
  ny: "new york",
  nc: "north carolina",
  nd: "north dakota",
  oh: "ohio",
  ok: "oklahoma",
  or: "oregon",
  pa: "pennsylvania",
  ri: "rhode island",
  sc: "south carolina",
  sd: "south dakota",
  tn: "tennessee",
  tx: "texas",
  ut: "utah",
  vt: "vermont",
  va: "virginia",
  wa: "washington",
  wv: "west virginia",
  wi: "wisconsin",
  wy: "wyoming",
  dc: "district of columbia"
};

const TOP_TIER_COUNTRY_LOCATIONS = {
  india: ["Mumbai", "Delhi", "Bengaluru", "Hyderabad", "Chennai", "Pune", "Ahmedabad", "Kolkata", "Gurugram", "Noida"],
  canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa", "Edmonton", "Mississauga", "Winnipeg"],
  "united kingdom": ["London", "Manchester", "Birmingham", "Leeds", "Glasgow", "Liverpool", "Bristol", "Edinburgh"],
  australia: ["Sydney", "Melbourne", "Brisbane", "Perth", "Adelaide", "Canberra", "Gold Coast"],
  germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne", "Stuttgart", "Dusseldorf"],
  france: ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Bordeaux"],
  netherlands: ["Amsterdam", "Rotterdam", "The Hague", "Utrecht", "Eindhoven"],
  ireland: ["Dublin", "Cork", "Galway", "Limerick", "Waterford"],
  singapore: ["Singapore"],
  "united arab emirates": ["Dubai", "Abu Dhabi", "Sharjah", "Ajman"]
};

const COUNTRY_ALIASES = {
  usa: "United States",
  "u.s.a.": "United States",
  us: "United States",
  "u.s.": "United States",
  america: "United States",
  uk: "United Kingdom",
  uae: "United Arab Emirates",
  emirates: "United Arab Emirates"
};

const ALL_USA_LOCATIONS = uniqueBy(Object.values(STATE_ALIASES), (state) => state).map(ensureUsLocation);

const STATE_CITY_PLAN = {
  alabama: ["Birmingham, AL", "Huntsville, AL", "Montgomery, AL", "Mobile, AL", "Tuscaloosa, AL"],
  alaska: ["Anchorage, AK", "Fairbanks, AK", "Juneau, AK", "Wasilla, AK"],
  arizona: ["Phoenix, AZ", "Tucson, AZ", "Mesa, AZ", "Scottsdale, AZ", "Chandler, AZ", "Glendale, AZ"],
  arkansas: ["Little Rock, AR", "Fayetteville, AR", "Fort Smith, AR", "Springdale, AR", "Jonesboro, AR"],
  california: ["Los Angeles, CA", "San Diego, CA", "San Jose, CA", "San Francisco, CA", "Fresno, CA", "Sacramento, CA", "Long Beach, CA", "Oakland, CA"],
  colorado: ["Denver, CO", "Colorado Springs, CO", "Aurora, CO", "Fort Collins, CO", "Lakewood, CO"],
  connecticut: ["Bridgeport, CT", "New Haven, CT", "Stamford, CT", "Hartford, CT", "Waterbury, CT"],
  delaware: ["Wilmington, DE", "Dover, DE", "Newark, DE", "Middletown, DE"],
  "district of columbia": ["Washington, DC"],
  florida: ["Miami, FL", "Orlando, FL", "Tampa, FL", "Jacksonville, FL", "St. Petersburg, FL", "Fort Lauderdale, FL", "Tallahassee, FL", "Sarasota, FL", "Naples, FL", "West Palm Beach, FL", "Gainesville, FL", "Pensacola, FL", "Cape Coral, FL", "Lakeland, FL", "Ocala, FL", "Daytona Beach, FL", "Port St. Lucie, FL", "Clearwater, FL", "Hollywood, FL", "Boca Raton, FL", "Bradenton, FL", "Melbourne, FL", "Fort Myers, FL", "Kissimmee, FL"],
  georgia: ["Atlanta, GA", "Augusta, GA", "Columbus, GA", "Savannah, GA", "Athens, GA", "Macon, GA"],
  hawaii: ["Honolulu, HI", "Hilo, HI", "Kailua, HI", "Kaneohe, HI"],
  idaho: ["Boise, ID", "Meridian, ID", "Nampa, ID", "Idaho Falls, ID", "Pocatello, ID"],
  illinois: ["Chicago, IL", "Aurora, IL", "Naperville, IL", "Joliet, IL", "Rockford, IL", "Springfield, IL"],
  indiana: ["Indianapolis, IN", "Fort Wayne, IN", "Evansville, IN", "South Bend, IN", "Carmel, IN"],
  iowa: ["Des Moines, IA", "Cedar Rapids, IA", "Davenport, IA", "Sioux City, IA", "Iowa City, IA"],
  kansas: ["Wichita, KS", "Overland Park, KS", "Kansas City, KS", "Olathe, KS", "Topeka, KS"],
  kentucky: ["Louisville, KY", "Lexington, KY", "Bowling Green, KY", "Owensboro, KY", "Covington, KY"],
  louisiana: ["New Orleans, LA", "Baton Rouge, LA", "Shreveport, LA", "Lafayette, LA", "Lake Charles, LA"],
  maine: ["Portland, ME", "Lewiston, ME", "Bangor, ME", "South Portland, ME"],
  maryland: ["Baltimore, MD", "Frederick, MD", "Rockville, MD", "Gaithersburg, MD", "Bowie, MD"],
  massachusetts: ["Boston, MA", "Worcester, MA", "Springfield, MA", "Cambridge, MA", "Lowell, MA"],
  michigan: ["Detroit, MI", "Grand Rapids, MI", "Warren, MI", "Ann Arbor, MI", "Lansing, MI"],
  minnesota: ["Minneapolis, MN", "St. Paul, MN", "Rochester, MN", "Duluth, MN", "Bloomington, MN"],
  mississippi: ["Jackson, MS", "Gulfport, MS", "Southaven, MS", "Biloxi, MS", "Hattiesburg, MS"],
  missouri: ["Kansas City, MO", "St. Louis, MO", "Springfield, MO", "Columbia, MO", "Independence, MO"],
  montana: ["Billings, MT", "Missoula, MT", "Great Falls, MT", "Bozeman, MT", "Helena, MT"],
  nebraska: ["Omaha, NE", "Lincoln, NE", "Bellevue, NE", "Grand Island, NE"],
  nevada: ["Las Vegas, NV", "Henderson, NV", "Reno, NV", "North Las Vegas, NV", "Sparks, NV"],
  "new hampshire": ["Manchester, NH", "Nashua, NH", "Concord, NH", "Derry, NH"],
  "new jersey": ["Newark, NJ", "Jersey City, NJ", "Paterson, NJ", "Elizabeth, NJ", "Edison, NJ"],
  "new mexico": ["Albuquerque, NM", "Las Cruces, NM", "Rio Rancho, NM", "Santa Fe, NM", "Roswell, NM"],
  "new york": ["New York, NY", "Buffalo, NY", "Rochester, NY", "Yonkers, NY", "Syracuse, NY", "Albany, NY"],
  "north carolina": ["Charlotte, NC", "Raleigh, NC", "Greensboro, NC", "Durham, NC", "Winston-Salem, NC"],
  "north dakota": ["Fargo, ND", "Bismarck, ND", "Grand Forks, ND", "Minot, ND"],
  ohio: ["Columbus, OH", "Cleveland, OH", "Cincinnati, OH", "Toledo, OH", "Akron, OH", "Dayton, OH"],
  oklahoma: ["Oklahoma City, OK", "Tulsa, OK", "Norman, OK", "Broken Arrow, OK", "Edmond, OK"],
  oregon: ["Portland, OR", "Eugene, OR", "Salem, OR", "Gresham, OR", "Hillsboro, OR"],
  pennsylvania: ["Philadelphia, PA", "Pittsburgh, PA", "Allentown, PA", "Erie, PA", "Reading, PA"],
  "rhode island": ["Providence, RI", "Warwick, RI", "Cranston, RI", "Pawtucket, RI"],
  "south carolina": ["Charleston, SC", "Columbia, SC", "North Charleston, SC", "Greenville, SC", "Myrtle Beach, SC"],
  "south dakota": ["Sioux Falls, SD", "Rapid City, SD", "Aberdeen, SD", "Brookings, SD"],
  tennessee: ["Nashville, TN", "Memphis, TN", "Knoxville, TN", "Chattanooga, TN", "Clarksville, TN"],
  texas: ["Houston, TX", "San Antonio, TX", "Dallas, TX", "Austin, TX", "Fort Worth, TX", "El Paso, TX", "Arlington, TX", "Corpus Christi, TX"],
  utah: ["Salt Lake City, UT", "West Valley City, UT", "Provo, UT", "West Jordan, UT", "Ogden, UT"],
  vermont: ["Burlington, VT", "South Burlington, VT", "Rutland, VT", "Barre, VT"],
  virginia: ["Virginia Beach, VA", "Chesapeake, VA", "Norfolk, VA", "Richmond, VA", "Arlington, VA", "Alexandria, VA"],
  washington: ["Seattle, WA", "Spokane, WA", "Tacoma, WA", "Vancouver, WA", "Bellevue, WA"],
  "west virginia": ["Charleston, WV", "Huntington, WV", "Morgantown, WV", "Parkersburg, WV"],
  wisconsin: ["Milwaukee, WI", "Madison, WI", "Green Bay, WI", "Kenosha, WI", "Racine, WI"],
  wyoming: ["Cheyenne, WY", "Casper, WY", "Laramie, WY", "Gillette, WY"]
};

function parseCountryLocations(value, countryScope = COUNTRY_SCOPE) {
  const raw = String(value || "")
    .split(/\n|;/)
    .map(normalizeText)
    .filter(Boolean);

  const locations = raw.flatMap((item) => {
    if (looksLikeSingleUsCityState(item)) return [item];
    return item.split(/\s*,\s*/).map(normalizeText).filter(Boolean);
  });

  const invalid = locations.find((location) => hasExplicitDifferentCountry(location, countryScope));
  if (invalid) {
    const error = new Error(`Country-targeted workflow rejected location outside ${countryScope}: ${invalid}`);
    error.statusCode = 400;
    throw error;
  }

  return locations.map((location) => ensureCountryLocation(location, countryScope));
}

function parseUsLocations(value) {
  return parseCountryLocations(value, "United States");
}

function looksLikeSingleUsCityState(value) {
  return /,\s*[A-Z]{2}(?:\s+USA|\s+United States)?$/i.test(value);
}

function ensureUsLocation(value) {
  return ensureCountryLocation(value, "United States");
}

function ensureCountryLocation(value, countryScope = COUNTRY_SCOPE) {
  const text = normalizeText(value).replace(/\s*,?\s*(usa|u\.s\.a\.|united states|united states of america)$/i, "");
  const country = normalizeCountryScope(countryScope);
  const suffix = countryQuerySuffix(country);
  const countryPattern = new RegExp(`\\b${escapeRegExp(country)}\\b`, "i");
  if (countryPattern.test(text)) return text;
  return `${text}, ${suffix}`;
}

function ensureUsQuery(value) {
  return ensureCountryQuery(value, "United States");
}

function ensureCountryQuery(value, countryScope = COUNTRY_SCOPE) {
  const text = normalizeText(value);
  if (!text) return text;
  const country = normalizeCountryScope(countryScope);
  const countryPattern = new RegExp(`\\b(${escapeRegExp(country)}|${escapeRegExp(countryQuerySuffix(country))})\\b`, "i");
  return countryPattern.test(text)
    ? text
    : `${text} ${countryQuerySuffix(country)}`;
}

function wantsAllCountryLocations(input, countryScope = COUNTRY_SCOPE) {
  if (normalizeCountryScope(countryScope) === "United States" && wantsAllUsaLocations(input)) return true;
  return /^(all|nationwide|all\s+country|countrywide|top\s+markets|major\s+cities)$/i.test(normalizeText(input.cities || input.city || ""));
}

function allCountryLocations(countryScope = COUNTRY_SCOPE) {
  const country = normalizeCountryScope(countryScope);
  if (country === "United States") return ALL_USA_LOCATIONS;
  const configured = TOP_TIER_COUNTRY_LOCATIONS[country.toLowerCase()] || ["local area"];
  return configured.map((location) => ensureCountryLocation(location, country));
}

function normalizeCountryScope(value) {
  const raw = normalizeText(value || COUNTRY_SCOPE);
  const lower = raw.toLowerCase();
  return COUNTRY_ALIASES[lower] || raw.replace(/\b\w/g, (char) => char.toUpperCase());
}

function countryQuerySuffix(countryScope = COUNTRY_SCOPE) {
  const country = normalizeCountryScope(countryScope);
  if (country === "United States") return COUNTRY_QUERY_SUFFIX;
  if (country === "United Arab Emirates") return "UAE";
  if (country === "United Kingdom") return "UK";
  return country;
}

function hasExplicitDifferentCountry(value, countryScope = COUNTRY_SCOPE) {
  if (!isRecognizedCountryScope(countryScope)) return false;
  const normalized = normalizeText(value).toLowerCase();
  const target = normalizeCountryScope(countryScope).toLowerCase();
  const aliases = Object.entries(COUNTRY_ALIASES)
    .filter(([, canonical]) => canonical.toLowerCase() === target)
    .map(([alias]) => alias);
  const targetNames = new Set([target, countryQuerySuffix(countryScope).toLowerCase(), ...aliases]);
  const mentioned = countryTokens(normalized);
  return mentioned.some((country) => !targetNames.has(country));
}

function isRecognizedCountryScope(value) {
  const target = normalizeCountryScope(value).toLowerCase();
  const aliases = Object.entries(COUNTRY_ALIASES)
    .filter(([, canonical]) => canonical.toLowerCase() === target)
    .map(([alias]) => alias);
  return countryTokens([target, ...aliases].join(" ")).length > 0;
}

function countryTokens(value) {
  const countries = [
    "united states",
    "usa",
    "u.s.a.",
    "india",
    "canada",
    "mexico",
    "united kingdom",
    "uk",
    "england",
    "australia",
    "united arab emirates",
    "uae",
    "dubai",
    "singapore",
    "germany",
    "france",
    "spain",
    "italy",
    "netherlands",
    "brazil",
    "ireland"
  ];
  return countries.filter((country) => new RegExp(`\\b${escapeRegExp(country)}\\b`, "i").test(value));
}

function ensureSearchState(run) {
  run.search ||= {};
  run.search.attemptedQueries ||= [];
  run.search.exhaustedCities ||= [];
  run.search.retryQueries ||= {};
  run.counters.searchAttempts ||= 0;
  run.counters.rejected ||= 0;
  run.counters.belowMinScore ||= 0;
  run.counters.enrichmentFailures ||= 0;
  run.counters.deepEnrichments ||= 0;
  run.counters.providerFallbacks ||= 0;
  run.counters.toolRepairs ||= 0;
  run.counters.stagnationPivots ||= 0;
  run.counters.initialStrategies ||= 0;
  run.counters.finalRecoveryPasses ||= 0;
  run.counters.queryDuplicates ||= 0;
  run.counters.ownerRabbitHoles ||= 0;
  run.counters.ownerProfilesFound ||= 0;
  run.counters.ownerContactsFound ||= 0;
  run.counters.ownerAgentsStarted ||= 0;
  run.counters.ownerAgentsDeployed ||= 0;
  run.counters.ownerAgentSearchHits ||= 0;
  run.counters.ownerAgentPagesFetched ||= 0;
  run.counters.ownerAgentContactPromotions ||= 0;
  run.counters.llmCalls ||= 0;
  run.counters.llmFallbacks ||= 0;
  run.counters.llmTimeouts ||= 0;
  run.counters.llmDurationMs ||= 0;
  run.rejectedLeads ||= [];
  run.search.stagnatedCities ||= [];
  run.search.mapsFailures ||= {};
  run.search.directoryFallbackCities ||= [];
  run.search.queryFamilies = queryFamiliesFromAttempts(run);
  run.search.rejectedQueryFamilies ||= [];
  return run.search;
}

function rememberMapsFailure(run, city, classification) {
  const search = ensureSearchState(run);
  const cityKey = normalizeText(city).toLowerCase();
  const key = classification || "unknown";
  search.mapsFailures[cityKey] ||= {};
  search.mapsFailures[cityKey][key] = (search.mapsFailures[cityKey][key] || 0) + 1;
}

function mapsFailureCount(run, city, classification) {
  const cityKey = normalizeText(city).toLowerCase();
  return Number(run.search?.mapsFailures?.[cityKey]?.[classification || "unknown"] || 0);
}

function fallbackQuality(lead, error) {
  const countryScope = lead.country || COUNTRY_SCOPE;
  const nonTargetLead = hasExplicitDifferentCountry(`${lead.address || ""} ${lead.city || ""} ${lead.country || ""}`, countryScope);
  const requiresOwnerContact = lead.contactTarget === "business_owner";
  const hasOwnerName = isLikelyOwnerPersonName(lead.ownerName) && !ownerNameLooksLikeBusinessContact(lead.ownerName, lead);
  const hasOwnerContact = ownerContactProofPresent(lead);
  const reachable = requiresOwnerContact ? hasOwnerName && hasOwnerContact : Boolean(lead.website || lead.email || lead.phone);
  const missing = [];
  if (nonTargetLead) missing.push(`${countryScope} location`);
  if (requiresOwnerContact && !hasOwnerName) missing.push("business owner identity");
  if (requiresOwnerContact && !hasOwnerContact) missing.push("owner email or owner phone");
  if (!requiresOwnerContact && !reachable) missing.push("reachable website, email, or phone");
  return {
    qualified: !nonTargetLead && reachable,
    score: !nonTargetLead && reachable ? lead.ownerEmail && lead.ownerPhone ? 100 : 90 : 0,
    reason: error
      ? `Local fallback qualification used after provider failure: ${error.message || error}`
      : requiresOwnerContact
        ? "Local fallback qualification: business owner identity and owner contact found."
        : "Local fallback qualification used.",
    missing,
    providerFallback: true,
    providerError: error?.message || String(error || "")
  };
}

function fallbackOwnerQuality(lead, enriched) {
  const hasContact = Boolean(lead.email || lead.phone);
  const hasSummary = Boolean(enriched.summary?.summary);
  const hasRepo = Boolean(lead.repoFullName);
  let score = 0;
  if (hasRepo) score += 30;
  if (hasContact) score += 35;
  if (hasSummary) score += 20;
  if (lead.repoStars >= 100) score += 10;
  if (lead.hireable) score += 5;
  const missing = [];
  if (!hasContact) missing.push("reachable contact (email or phone)");
  if (!hasSummary) missing.push("business summary");
  if (!hasRepo) missing.push("linked repository");
  return {
    qualified: hasContact && hasSummary && hasRepo,
    score,
    reason: missing.length
      ? `Local fallback: missing ${missing.join(", ")}.`
      : "Local fallback qualification: scraping repo + contact + summary present.",
    missing,
    providerFallback: true,
    providerError: "MiniMax provider unavailable; used local scoring."
  };
}

function shouldDeepEnrich({ lead, enrichment, quality, minScore }) {
  if (!lead.website || lead.enrichmentMode === "dynamic") return false;
  const score = Number(quality?.score || 0);
  if (quality?.qualified && score >= minScore) return false;
  const missing = (quality?.missing || []).join(" ").toLowerCase();
  const weakText = normalizeText(enrichment?.text || "").length < 600;
  const missingContact = lead.contactTarget === "business_owner"
    ? !lead.ownerEmail && !lead.ownerPhone
    : !lead.email && !lead.phone;
  return missingContact || weakText || missing.includes("contact") || missing.includes("owner") || missing.includes("website") || missing.includes("evidence") || score < minScore;
}

function mergeEnrichment(base = {}, next = {}) {
  return {
    ...base,
    ...next,
    ok: Boolean(base.ok || next.ok),
    mode: next.mode || "dynamic",
    emails: uniqueBy([...(base.emails || []), ...(next.emails || [])].filter(Boolean), (value) => String(value).toLowerCase()),
    phones: uniqueBy([...(base.phones || []), ...(next.phones || [])].filter(Boolean), (value) => String(value).replace(/\D/g, "")),
    text: normalizeText([base.text, next.text].filter(Boolean).join("\n"))
  };
}

function isLeadScraperRun(run = {}) {
  return run.input?.source === LEAD_SCRAPER_SOURCE;
}

function normalizeBusinessLead(lead = {}, enrichment = {}) {
  const email = firstUsableEmail([lead.email, lead.businessEmail, ...(enrichment.emails || [])]);
  const phone = lead.phone || lead.businessPhone || enrichment.phones?.[0] || "";
  const links = Array.isArray(enrichment.links) ? enrichment.links : [];
  const socialLinks = uniqueBy([
    ...(Array.isArray(enrichment.socialLinks) ? enrichment.socialLinks : []),
    ...links.filter((link) => /\b(linkedin\.com|facebook\.com|instagram\.com|x\.com|twitter\.com|tiktok\.com)\b/i.test(link))
  ], (link) => String(link).toLowerCase()).slice(0, 12);
  const contactForms = links.filter((link) => /\b(contact|quote|estimate|book|appointment|inquiry|enquir|schedule)\b/i.test(link)).slice(0, 8);
  const sourceUrl = lead.sourceUrl || lead.evidenceUrl || lead.placeUrl || lead.googleMapsUrl || lead.website || "";
  const contactConfidence = email || phone ? "business-direct" : contactForms.length || socialLinks.length ? "contact-form" : lead.website ? "website-only" : "no-contact";
  return {
    ...lead,
    contactTarget: "business",
    email,
    phone,
    businessEmail: email,
    businessPhone: phone,
    contactForms,
    socialLinks,
    sourceUrl,
    evidenceUrl: sourceUrl,
    validationStatus: email || phone || contactForms.length || socialLinks.length || lead.website ? "validated" : "needs-review",
    campaignReady: Boolean(email || phone || contactForms.length || socialLinks.length || lead.website),
    contactConfidence,
    enrichmentMode: enrichment.mode || lead.enrichmentMode || (lead.website ? "simple" : "none"),
    enrichmentOk: Boolean(enrichment.ok || lead.enrichmentOk)
  };
}

function enforceBusinessLeadQualityGate(lead = {}, quality = {}, minScore = 60) {
  const issues = [];
  if (!normalizeText(lead.name)) issues.push("business name");
  if (!businessLeadReachable(lead)) issues.push("public email, phone, contact form, social profile, or website");
  if (!businessLeadLocationMatches(lead)) issues.push("target location");
  if (issues.length) {
    return {
      ...quality,
      qualified: false,
      score: 0,
      reason: `Business lead gate rejected this row: missing ${issues.join(", ")}.`,
      missing: uniqueBy([...(quality?.missing || []), ...issues], (item) => item),
      minScore
    };
  }
  const score = Math.max(Number(quality?.score || 0), lead.email ? 86 : lead.phone ? 78 : lead.contactForms?.length ? 70 : 62);
  return {
    ...quality,
    qualified: Boolean(quality?.qualified !== false),
    score,
    reason: quality?.reason || "Business lead has public contact evidence and matches the requested location.",
    missing: quality?.missing || [],
    validationStatus: "validated",
    campaignReady: true
  };
}

function businessLeadReachable(lead = {}) {
  return Boolean(lead.email || lead.phone || lead.businessEmail || lead.businessPhone || lead.website || lead.contactForms?.length || lead.socialLinks?.length);
}

function businessLeadLocationMatches(lead = {}) {
  return !hasExplicitDifferentCountry(`${lead.address || ""} ${lead.city || ""} ${lead.country || ""}`, lead.country || COUNTRY_SCOPE);
}

function firstUsableEmail(values = []) {
  return values.map((value) => String(value || "").trim().toLowerCase()).find(isUsableEmail) || "";
}

function leadWorkerConcurrency(run = {}) {
  return Math.max(1, Math.min(20, Number(run.input?.leadWorkerConcurrency || process.env.HERMES_LEAD_WORKER_CONCURRENCY || 3)));
}

function ownerAgentSwarmPlanForLead(run = {}, lead = {}, enrichment = {}) {
  const base = Math.max(1, Math.min(20, Number(run.input?.ownerAgentSwarmSize || process.env.HERMES_OWNER_AGENT_SWARM_SIZE || 6)));
  const escalation = Math.max(base, Math.min(20, Number(process.env.HERMES_OWNER_AGENT_ESCALATION_SIZE || 14)));
  const text = normalizeText(enrichment.text || "");
  const emails = uniqueBy([lead.email, ...(enrichment.emails || [])].filter(Boolean), (value) => String(value).toLowerCase());
  const links = Array.isArray(enrichment.links) ? enrichment.links : [];
  const socialLinks = Array.isArray(enrichment.socialLinks) ? enrichment.socialLinks : [];
  const reasons = [];
  let score = 0;

  if (/\b(owner|founder|co-founder|ceo|president|principal|operator|managing partner)\b/i.test(text)) {
    score += 2;
    reasons.push("owner terms on business website");
  }
  if (emails.some(isPersonalSeedEmail)) {
    score += 2;
    reasons.push("personal-looking seed email");
  }
  if (socialLinks.some((link) => /linkedin\.com\/in|facebook\.com|instagram\.com|x\.com|twitter\.com|tiktok\.com/i.test(link))) {
    score += 2;
    reasons.push("public personal/social profile trail");
  }
  if (links.some((link) => /linkedin\.com\/in|facebook\.com|instagram\.com|x\.com|twitter\.com|tiktok\.com/i.test(link))) {
    score += 2;
    reasons.push("linked public personal profile");
  }
  if (links.some((link) => /about|team|staff|leadership|our-story|founder|owner/i.test(link))) {
    score += 1;
    reasons.push("leadership/about page trail");
  }
  const hasReviewSignal = Number(lead.reviews || 0) >= 20 || Number(lead.rating || 0) >= 4.5;
  if (lead.website && hasReviewSignal) {
    score += 1;
    reasons.push("strong local review signal");
  }
  const escalated = Boolean(lead.website && score >= 3);
  return {
    size: escalated ? escalation : base,
    base,
    escalation,
    score,
    escalated,
    reasons
  };
}

function ownerAgentSwarmSizeForLead(run = {}, lead = {}, enrichment = {}) {
  return ownerAgentSwarmPlanForLead(run, lead, enrichment).size;
}

function isPersonalSeedEmail(email) {
  const text = String(email || "").toLowerCase();
  if (!/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(text)) return false;
  const local = text.split("@")[0].replace(/[^a-z0-9]+/g, "");
  if (local.length < 5) return false;
  if (/^(info|contact|hello|support|sales|office|admin|appointments?|booking|book|service|services|customerservice|team|frontdesk|reception|careers?|jobs|marketing|editor|webmaster|developer|designer|wordpress|nails?|salon|spa)/.test(local)) return false;
  return /[a-z]/.test(local);
}

function isUsableEmail(email) {
  const text = String(email || "").trim().toLowerCase();
  if (!/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(text)) return false;
  if (/\.(png|jpe?g|gif|svg|webp|css|js|json|ico)$/i.test(text)) return false;
  return true;
}

function ownerContactProofPresent(lead = {}) {
  const evidence = Array.isArray(lead.ownerContactEvidence) ? lead.ownerContactEvidence : [];
  const ownerName = normalizeOwnerPersonName(lead.ownerName || "");
  if (!ownerName || !isLikelyOwnerPersonName(ownerName) || ownerNameLooksLikeBusinessContact(ownerName, lead)) return false;
  const contacts = new Set([
    lead.ownerEmail,
    lead.ownerPhone,
    lead.email,
    lead.phone
  ].filter(Boolean).flatMap(contactMatchKeys));
  return evidence.some((entry) => {
    const valueMatches = contactMatchKeys(entry?.value).some((value) => contacts.has(value));
    return valueMatches && (entry.url || entry.source) && ownerEvidenceHasPersonName(entry, lead);
  });
}

function contactConfidence(lead = {}) {
  const hasOwnerName = isLikelyOwnerPersonName(lead.ownerName) && !ownerNameLooksLikeBusinessContact(lead.ownerName, lead);
  if (hasOwnerName && ownerContactProofPresent(lead)) return "owner-direct";
  if (hasOwnerName && ownerAssociatedContactProofPresent(lead)) return "owner-associated";
  if (lead.businessEmail || lead.businessPhone || lead.email || lead.phone || lead.ownerEmail || lead.ownerPhone) return "business-only";
  return "no-contact";
}

function qualifiedOwnerDirectContactCount(run = {}) {
  return (run.qualifiedLeads || []).filter((lead) => ownerContactProofPresent(lead)).length;
}

function qualifiedOwnerAssociatedContactCount(run = {}) {
  return (run.qualifiedLeads || []).filter((lead) => !ownerContactProofPresent(lead) && ownerAssociatedContactProofPresent(lead)).length;
}

function hasReachedRunGoal(run = {}) {
  const target = Number(run.input?.target || 0);
  return target > 0 && (run.qualifiedLeads || []).length >= target;
}

function ownerAssociatedContactProofPresent(lead = {}) {
  const ownerName = normalizeOwnerPersonName(lead.ownerName || "");
  if (!strongOwnerPersonName(ownerName, lead)) return false;
  if (!ownerAssociatedEmail(lead) && !ownerAssociatedPhone(lead)) return false;
  if (!ownerIdentityProofPresent(lead)) return false;
  return ownerAssociatedQualityIssues(lead).length === 0;
}

function ownerAssociatedQualityIssues(lead = {}) {
  const issues = [];
  const ownerName = normalizeOwnerPersonName(lead.ownerName || "");
  const hasSafeEmail = Boolean(ownerAssociatedEmail(lead));
  const hasSafePhone = Boolean(ownerAssociatedPhone(lead));
  const rawEmail = String(lead.businessEmail || "").trim();
  const rawPhone = String(lead.businessPhone || "").trim();
  if (!strongOwnerPersonName(ownerName, lead)) issues.push("strong owner person name");
  if (!ownerIdentityProofPresent(lead)) issues.push("public owner identity proof");
  if (!ownerIdentityBusinessMatchPresent(lead)) issues.push("owner-to-business evidence match");
  if (!hasSafeEmail && !hasSafePhone) {
    if (rawEmail && !hasSafeEmail) issues.push("verified owner email channel");
    if (rawPhone && !hasSafePhone) issues.push("valid US phone");
    issues.push("clean owner reach channel");
  }
  return uniqueBy(issues, (issue) => issue);
}

function enforceOwnerContactQualityGate(lead = {}, quality = {}, minScore = 60) {
  if (ownerContactProofPresent(lead)) return quality;
  const issues = ownerAssociatedQualityIssues(lead);
  if (!issues.length) return quality;
  if (!quality?.qualified && !lead.ownerName && !lead.businessEmail && !lead.businessPhone) return quality;
  return {
    ...quality,
    qualified: false,
    score: 0,
    reason: `V4 owner-associated gate rejected this lead: missing ${issues.join(", ")}.`,
    missing: uniqueBy([...(quality?.missing || []), ...issues], (item) => item),
    contactConfidence: "business-only",
    ownerAssociatedRejected: true,
    minScore
  };
}

function normalizeOwnerAssociatedChannels(lead = {}) {
  if (ownerContactProofPresent(lead)) return lead;
  return {
    ...lead,
    businessEmail: ownerAssociatedEmail(lead),
    businessPhone: ownerAssociatedPhone(lead)
  };
}

function ownerIdentityProofPresent(lead = {}) {
  const ownerName = normalizeOwnerPersonName(lead.ownerName || "");
  if (!ownerName) return false;
  const sources = [
    lead.ownerEvidenceUrl,
    ...(Array.isArray(lead.ownerEvidence) ? lead.ownerEvidence.map((entry) => entry.url || entry.evidenceUrl || "") : []),
    ...(Array.isArray(lead.ownerContactEvidence) ? lead.ownerContactEvidence.map((entry) => entry.url || "") : [])
  ].filter(Boolean);
  if (sources.some((url) => officialBusinessUrl(url, lead) || ownerProfileUrl(url))) return true;
  const evidenceText = JSON.stringify(lead.ownerEvidence || []);
  return new RegExp(`\\b${escapeRegExp(ownerName)}\\b`, "i").test(evidenceText);
}

function ownerIdentityBusinessMatchPresent(lead = {}) {
  const sources = [
    lead.ownerEvidenceUrl,
    ...(Array.isArray(lead.ownerEvidence) ? lead.ownerEvidence.map((entry) => entry.url || entry.evidenceUrl || "") : []),
    ...(Array.isArray(lead.ownerContactEvidence) ? lead.ownerContactEvidence.map((entry) => entry.url || "") : [])
  ].filter(Boolean);
  if (sources.some((url) => officialBusinessUrl(url, lead))) return true;
  const text = normalizeText(JSON.stringify([
    lead.ownerEvidenceUrl || "",
    lead.ownerEvidence || [],
    lead.ownerContactEvidence || []
  ])).toLowerCase();
  const businessTokens = distinctiveBusinessTokens(lead.name || lead.category || "");
  if (!businessTokens.length) return true;
  const matches = businessTokens.filter((token) => text.includes(token));
  return matches.length >= Math.min(2, businessTokens.length);
}

function distinctiveBusinessTokens(value = "") {
  const stopWords = new Set([
    "and", "the", "inc", "llc", "co", "corp", "company", "services", "service", "group",
    "family", "local", "best", "usa", "us", "of", "at", "in", "for", "a", "an"
  ]);
  return uniqueBy(
    normalizeText(value)
      .toLowerCase()
      .split(/[^a-z0-9]+/)
      .filter((token) => token.length >= 4 && !stopWords.has(token)),
    (token) => token
  ).slice(0, 5);
}

function ownerAssociatedQuality(lead = {}, previous = {}, minScore = 60) {
  const score = Math.max(Number(previous?.score || 0), Number(minScore || 60), 72);
  const contact = [ownerAssociatedEmail(lead), ownerAssociatedPhone(lead)].filter(Boolean).join(" / ");
  return {
    ...previous,
    qualified: true,
    score,
    reason: `Owner reach fallback: ${lead.ownerName} is publicly tied to the company, and the best reachable owner channel is ${contact}.`,
    missing: [],
    contactConfidence: "owner-associated",
    ownerAssociatedFallback: true
  };
}

function buildOwnerEvidenceGraph(lead = {}) {
  const confidence = contactConfidence(lead);
  const ownerIdentityProven = strongOwnerPersonName(lead.ownerName || "", lead) && ownerIdentityProofPresent(lead);
  const proofUrls = uniqueBy([
    lead.ownerEvidenceUrl,
    ...(Array.isArray(lead.ownerEvidence) ? lead.ownerEvidence.map((entry) => entry.url || entry.evidenceUrl || "") : []),
    ...(Array.isArray(lead.ownerContactEvidence) ? lead.ownerContactEvidence.map((entry) => entry.url || entry.source || "") : [])
  ].filter(Boolean), (url) => String(url).toLowerCase()).slice(0, 12);
  const profileLinks = uniqueBy([
    lead.ownerLinkedIn,
    ...(Array.isArray(lead.ownerSocialProfiles) ? lead.ownerSocialProfiles : []),
    ...proofUrls.filter(ownerProfileUrl)
  ].filter(Boolean), (url) => String(url).toLowerCase()).slice(0, 12);
  const contactEvidence = (Array.isArray(lead.ownerContactEvidence) ? lead.ownerContactEvidence : [])
    .map((entry) => ({
      type: entry.type || "contact",
      value: entry.value || "",
      url: entry.url || entry.source || "",
      ownerName: entry.ownerName || lead.ownerName || "",
      reason: entry.reason || ""
    }))
    .slice(0, 12);
  return {
    strategyVersion: OWNER_FIRST_STRATEGY_VERSION,
    business: {
      name: lead.name || "",
      city: lead.city || "",
      website: lead.website || ""
    },
    owner: {
      name: lead.ownerName || "",
      title: lead.ownerTitle || ""
    },
    ownerIdentityProven,
    ownerDirectContactProven: ownerContactProofPresent(lead),
    ownerAssociatedContactProven: ownerAssociatedContactProofPresent(lead),
    contactConfidence: confidence,
    finalConfidence: confidence,
    qualityRedFlags: confidence === "owner-direct" ? [] : ownerAssociatedQualityIssues(lead),
    proofUrls,
    profileLinks,
    contactEvidence,
    businessChannels: {
      email: lead.businessEmail || "",
      phone: lead.businessPhone || ""
    },
    rabbitHoleStatus: lead.rabbitHoleStatus || lead.ownerHuntStatus || "",
    strategyReason: lead.rabbitHoleStrategy?.strategyReason || ""
  };
}

function strongOwnerPersonName(name = "", lead = {}) {
  const normalized = normalizeOwnerPersonName(name);
  if (!normalized || !isLikelyOwnerPersonName(normalized) || ownerNameLooksLikeBusinessContact(normalized, lead)) return false;
  const tokens = normalized.toLowerCase().split(/\s+/).filter(Boolean);
  if (tokens.length < 2 || tokens.length > 4) return false;
  const badTokens = new Set([
    "message", "from", "the", "home", "about", "team", "staff", "service", "services",
    "plumbing", "rooter", "drain", "mechanical", "heating", "cooling", "air", "llc",
    "inc", "company", "meet", "what", "protecting", "construction", "contractor",
    "roofing", "roofers", "waterproofing", "restoration", "exteriors", "dentist",
    "dentistry", "clinic", "customer", "customers", "care"
  ]);
  if (tokens.some((token) => badTokens.has(token))) return false;
  if (/\b(owner|manager|general|president|ceo|founder)\b/i.test(normalized) && tokens.length > 2) return false;
  return true;
}

function ownerAssociatedEmail(lead = {}) {
  const email = String(lead.businessEmail || "").trim().toLowerCase();
  if (!isUsableEmail(email)) return "";
  const domain = email.split("@")[1] || "";
  if (GENERIC_ASSOCIATED_EMAIL_DOMAINS.has(domain)) return "";
  if (SUSPICIOUS_ASSOCIATED_EMAIL_DOMAINS.has(domain) || /\b(xnxx|casino|bonus|payout|loan|crypto|pharma|adult)\b/i.test(domain)) return "";
  const websiteHost = hostFromText(lead.website || "");
  if (websiteHost && domainMatchesHost(domain, websiteHost)) return email;
  const evidenceHosts = [
    lead.ownerEvidenceUrl,
    ...(Array.isArray(lead.ownerEvidence) ? lead.ownerEvidence.map((entry) => entry.url || entry.evidenceUrl || "") : [])
  ].map(hostFromText).filter(Boolean);
  return evidenceHosts.some((host) => domainMatchesHost(domain, host)) ? email : "";
}

function ownerAssociatedPhone(lead = {}) {
  const phone = normalizeAssociatedPhone(lead.businessPhone || "");
  return phone || "";
}

function normalizeAssociatedPhone(value = "") {
  const normalized = normalizePhone(value);
  const digits = normalized.replace(/\D/g, "");
  const local = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits;
  if (local.length !== 10) return "";
  if (!/^[2-9]\d{2}[2-9]\d{6}$/.test(local)) return "";
  if (/^(\d)\1{9}$/.test(local)) return "";
  return normalized;
}

function domainMatchesHost(domain = "", host = "") {
  const cleanDomain = String(domain || "").replace(/^www\./, "").toLowerCase();
  const cleanHost = String(host || "").replace(/^www\./, "").toLowerCase();
  return Boolean(cleanDomain && cleanHost && (cleanHost === cleanDomain || cleanHost.endsWith(`.${cleanDomain}`) || cleanDomain.endsWith(`.${cleanHost}`)));
}

const GENERIC_ASSOCIATED_EMAIL_DOMAINS = new Set([
  "gmail.com",
  "yahoo.com",
  "hotmail.com",
  "outlook.com",
  "icloud.com",
  "aol.com",
  "proton.me",
  "zoo.org"
]);

const SUSPICIOUS_ASSOCIATED_EMAIL_DOMAINS = new Set([
  "xnxx.com",
  "markexpress.co.in"
]);

function officialBusinessUrl(url = "", lead = {}) {
  const sourceHost = hostFromText(url);
  const leadHost = hostFromText(lead.website || "");
  return Boolean(sourceHost && leadHost && sourceHost === leadHost);
}

function ownerProfileUrl(url = "") {
  return /\b(linkedin\.com\/in|facebook\.com\/[^/?#]+|instagram\.com\/[^/?#]+|x\.com\/[^/?#]+)\b/i.test(String(url || ""));
}

function hostFromText(value = "") {
  try {
    return new URL(String(value).startsWith("http") ? value : `https://${value}`).hostname.replace(/^www\./, "").toLowerCase();
  } catch {
    return "";
  }
}

function escapeRegExp(value = "") {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function sanitizeQualifiedLeads(run) {
  const qualified = Array.isArray(run.qualifiedLeads) ? run.qualifiedLeads : [];
  if (!qualified.length) return;
  if (isLeadScraperRun(run)) {
    run.qualifiedLeads = qualified.filter(businessLeadReachable);
    run.counters ||= {};
    run.counters.qualified = run.qualifiedLeads.length;
    return;
  }
  const kept = [];
  const removed = [];
  for (const lead of qualified) {
    if (ownerContactProofPresent(lead) || ownerAssociatedContactProofPresent(lead)) {
      kept.push(lead);
      continue;
    }
    const repaired = {
      ...lead,
      quality: {
        ...(lead.quality || {}),
        qualified: false,
        score: 0,
        reason: "Qualified lead removed because owner identity or owner-contact proof is invalid. Business-only contact cannot qualify.",
        missing: uniqueBy([...(lead.quality?.missing || []), "valid owner identity", "owner-level contact proof"], (value) => value)
      },
      ownerHuntStatus: "owner-contact-invalidated",
      ownerHuntDecision: "Saved-run sanitizer removed this row from qualified output because it lacked verified owner-level contact proof."
    };
    removed.push(repaired);
  }
  if (!removed.length) return;
  run.qualifiedLeads = kept;
  run.counters ||= {};
  run.counters.qualified = kept.length;
  run.counters.rejected = (run.counters.rejected || 0) + removed.length;
  run.qualityRepairs ||= [];
  for (const lead of removed) {
    rememberRejectedLead(run, lead, 0);
    run.qualityRepairs.push({
      at: new Date().toISOString(),
      leadKey: leadKey(lead),
      name: lead.name,
      reason: lead.quality.reason
    });
  }
  run.qualityRepairs = run.qualityRepairs.slice(-100);
}

function recomputeVerifiedOwnerContactCounters(run) {
  const leads = [...(run.qualifiedLeads || []), ...(run.rejectedLeads || [])];
  const keys = [];
  for (const lead of leads) {
    if (!ownerContactProofPresent(lead)) continue;
    const key = leadKey(lead) || [lead.ownerName, lead.ownerEmail, lead.ownerPhone].filter(Boolean).join("|").toLowerCase();
    if (key) keys.push(key);
  }
  const uniqueKeys = uniqueBy(keys, (key) => key).slice(-5000);
  run.ownerContactCountedLeadKeys = uniqueKeys;
  run.counters ||= {};
  run.counters.ownerContactsFound = uniqueKeys.length;
}

function ownerEvidenceHasPersonName(entry = {}, lead = {}) {
  const name = normalizeOwnerPersonName(entry.ownerName || lead.ownerName || "");
  return Boolean(name && isLikelyOwnerPersonName(name) && !ownerNameLooksLikeBusinessContact(name, lead));
}

function contactMatchKeys(value) {
  const text = String(value || "").trim().toLowerCase();
  const phone = normalizePhone(value);
  return uniqueBy([text, phone].filter(Boolean), (item) => item);
}

function normalizeOwnerPersonName(value) {
  return normalizeText(value)
    .replace(/[^A-Za-z' .-]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function isLikelyOwnerPersonName(value) {
  const text = normalizeOwnerPersonName(value);
  if (!text) return false;
  if (/\b(owner|founder|ceo|president|principal|operator|stylist|salon|spa|nail|nails|business|company|llc|inc|corp|team|staff|about|contact|booking|services?|texas|best|premium|official|meet|protecting|what|construction|contractor|roofing|roofers|waterproofing|restoration|exteriors|dentist|dentistry|clinic|customers?|care)\b/i.test(text)) return false;
  const parts = text.split(/\s+/).filter(Boolean);
  if (parts.length < 2 || parts.length > 4) return false;
  return parts.every((part) => /^[A-Z][A-Za-z'.-]*[A-Za-z]$/.test(part));
}

function ownerNameLooksLikeBusinessContact(name, lead = {}) {
  const personTokens = normalizeOwnerPersonName(name)
    .toLowerCase()
    .split(/\s+/)
    .filter(Boolean);
  if (!personTokens.length) return true;
  const businessTokens = new Set(
    normalizeText([lead.name, lead.category].filter(Boolean).join(" "))
      .toLowerCase()
      .split(/[^a-z0-9]+/)
      .filter((token) => token.length > 2)
  );
  const overlap = personTokens.filter((token) => businessTokens.has(token)).length;
  return overlap >= Math.min(personTokens.length, 2);
}

function countOwnerRabbitHole(run, ownerContact = {}, { leadKey = "" } = {}) {
  run.counters ||= {};
  run.counters.ownerRabbitHoles = (run.counters.ownerRabbitHoles || 0) + 1;
  const swarm = ownerContact.ownerAgentSwarm || {};
  const swarmSummary = swarm.summary || {};
  run.counters.ownerAgentsDeployed = (run.counters.ownerAgentsDeployed || 0) + Number(swarm.deployedTotal || swarm.deployed || 0);
  if (swarm.timedOut) run.counters.ownerAgentsTimedOut = (run.counters.ownerAgentsTimedOut || 0) + Number(swarm.requested || 0);
  run.counters.ownerAgentSearchHits = (run.counters.ownerAgentSearchHits || 0) + Number(swarmSummary.searchHits || 0);
  run.counters.ownerAgentPagesFetched = (run.counters.ownerAgentPagesFetched || 0) + Number(swarmSummary.pagesFetched || 0);
  run.counters.ownerAgentContactPromotions = (run.counters.ownerAgentContactPromotions || 0) + Number(swarmSummary.ownerContactPromotions || 0);
  if (ownerContact.ownerSocialProfiles?.length) run.counters.ownerProfilesFound = (run.counters.ownerProfilesFound || 0) + 1;
  if (!ownerContactProofPresent(ownerContact)) return;

  run.ownerContactCountedLeadKeys ||= [];
  const key = leadKey || [ownerContact.ownerName, ownerContact.ownerEmail, ownerContact.ownerPhone].filter(Boolean).join("|").toLowerCase();
  if (key && run.ownerContactCountedLeadKeys.includes(key)) return;
  if (key) run.ownerContactCountedLeadKeys.push(key);
  run.ownerContactCountedLeadKeys = run.ownerContactCountedLeadKeys.slice(-5000);
  run.counters.ownerContactsFound = (run.counters.ownerContactsFound || 0) + 1;
}

function recordProviderTelemetry(run, result = {}) {
  const meta = result?.providerMeta;
  if (!meta) return;
  run.counters ||= {};
  run.counters.llmCalls = (run.counters.llmCalls || 0) + 1;
  run.counters.llmDurationMs = (run.counters.llmDurationMs || 0) + Number(meta.durationMs || 0);
  const timeoutCount = Number(meta.timeoutCount || (meta.timedOut ? 1 : 0));
  run.counters.llmTimeouts = (run.counters.llmTimeouts || 0) + timeoutCount;
  run.counters.llmConsecutiveTimeoutFallbacks = result.providerFallback && timeoutCount
    ? (run.counters.llmConsecutiveTimeoutFallbacks || 0) + 1
    : 0;
}

function minimaxCircuitOpen(run = {}) {
  const threshold = Math.max(1, Number(process.env.HERMES_MINIMAX_TIMEOUT_CIRCUIT || 5));
  return Number(run?.counters?.llmConsecutiveTimeoutFallbacks || 0) >= threshold;
}

async function mapLimit(items, limit, worker) {
  const input = Array.from(items || []);
  if (!input.length) return [];
  const width = Math.max(1, Math.min(Number(limit) || 1, input.length));
  const results = new Array(input.length);
  let index = 0;
  await Promise.all(Array.from({ length: width }, async () => {
    while (index < input.length) {
      const current = index;
      index += 1;
      results[current] = await worker(input[current], current);
    }
  }));
  return results;
}

function rememberRejectedLead(run, lead, score) {
  run.rejectedLeads ||= [];
  run.rejectedLeads.unshift({
    leadKey: leadKey(lead),
    name: lead.name,
    category: lead.category,
    address: lead.address,
    website: lead.website,
    phone: lead.phone,
    email: lead.email,
    city: lead.city,
    sourceQuery: lead.sourceQuery,
    ownerName: lead.ownerName || "",
    ownerTitle: lead.ownerTitle || "",
    ownerEmail: lead.ownerEmail || "",
    ownerPhone: lead.ownerPhone || "",
    ownerLinkedIn: lead.ownerLinkedIn || "",
    ownerSocialProfiles: Array.isArray(lead.ownerSocialProfiles) ? lead.ownerSocialProfiles.slice(0, 10) : [],
    ownerEvidenceUrl: lead.ownerEvidenceUrl || "",
    ownerContactEvidence: Array.isArray(lead.ownerContactEvidence) ? lead.ownerContactEvidence.slice(0, 10) : [],
    contactConfidence: lead.contactConfidence || contactConfidence(lead),
    ownerEvidenceGraph: lead.ownerEvidenceGraph || buildOwnerEvidenceGraph(lead),
    ownerEvidence: Array.isArray(lead.ownerEvidence) ? lead.ownerEvidence.slice(0, 8) : [],
    ownerAgentSwarm: lead.ownerAgentSwarm ? {
      requested: lead.ownerAgentSwarm.requested,
      requestedPerPass: lead.ownerAgentSwarm.requestedPerPass,
      deployed: lead.ownerAgentSwarm.deployed,
      deployedTotal: lead.ownerAgentSwarm.deployedTotal,
      summary: lead.ownerAgentSwarm.summary,
      traces: Array.isArray(lead.ownerAgentSwarm.traces) ? lead.ownerAgentSwarm.traces.slice(0, 12) : []
    } : null,
    ownerHuntStatus: lead.ownerHuntStatus || "",
    ownerHuntDecision: lead.ownerHuntDecision || "",
    rabbitHoleStatus: lead.rabbitHoleStatus || "",
    rabbitHoleStrategy: lead.rabbitHoleStrategy || {},
    socialEvidence: Array.isArray(lead.socialEvidence) ? lead.socialEvidence.slice(0, 8) : [],
    score,
    requiredScore: run.input.minScore,
    qualified: Boolean(lead.quality?.qualified),
    reason: lead.quality?.reason || "",
    missing: lead.quality?.missing || []
  });
  run.rejectedLeads = run.rejectedLeads.slice(0, 100);
}

function markLeadProcessed(run, lead) {
  const key = leadKey(lead);
  if (!key) return;
  run.processedLeadKeys ||= [];
  if (!run.processedLeadKeys.includes(key)) run.processedLeadKeys.push(key);
  run.processedLeadKeys = run.processedLeadKeys.slice(-10000);
}

function pendingDiscoveredLeads(run) {
  const processed = new Set(run.processedLeadKeys || []);
  const qualified = new Set((run.qualifiedLeads || []).map(leadKey).filter(Boolean));
  return (run.discoveredLeads || []).filter((lead) => {
    const key = leadKey(lead);
    return key && !processed.has(key) && !qualified.has(key);
  });
}

function prioritizeOwnerContactLeads(leads = []) {
  return leads
    .map((lead, index) => ({ lead, index, score: ownerContactLeadPriority(lead) }))
    .sort((a, b) => b.score - a.score || a.index - b.index)
    .map((entry) => entry.lead);
}

function ownerContactLeadPriority(lead = {}) {
  const name = normalizeText(lead.name || "");
  const website = normalizeText(lead.website || "");
  const host = hostFromLeadWebsite(website).replace(/^www\./, "");
  const compactHost = host.replace(/[^a-z0-9]/gi, "").toLowerCase();
  const tokens = name.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 2 && !LOCAL_LEAD_PRIORITY_STOP_WORDS.has(token));
  const hostHits = tokens.filter((token) => compactHost.includes(token)).length;
  let score = 0;
  if (website) score += 40;
  else score -= 60;
  if (normalizeText(lead.phone || "")) score += 12;
  if (normalizeText(lead.address || "")) score += 8;
  if (hostHits >= 2) score += 24;
  else if (hostHits === 1) score += 10;
  if (/\b(franchise|franchising|nationwide|corporate|locations?|company)\b/i.test(`${name} ${host}`)) score -= 28;
  if (/\/|corporate|franchise/i.test(name)) score -= 10;
  return score;
}

function ownerSeedRejectionReason(lead = {}, niche = "") {
  const text = normalizeText([
    lead.name,
    lead.website,
    lead.category,
    lead.address,
    lead.sourceQuery
  ].filter(Boolean).join(" "));
  const website = normalizeText(lead.website || "");
  const host = hostFromLeadWebsite(website).replace(/^www\./, "").toLowerCase();
  const name = normalizeText(lead.name || "");
  if (!name) return "Missing business name; owner-first search cannot verify owner identity.";
  if (!website && !normalizeText(lead.address || "")) {
    return "Weak seed without website or address; owner-first search needs a verifiable business surface.";
  }
  if (genericSocialHomePage(website)) {
    return "Generic social homepage is not a business-owned seed URL; owner-first search needs the actual business page or website.";
  }
  if (knownChainOrFranchiseSeed({ name, host, text, niche })) {
    return "Likely chain/franchise/multi-location brand; owner-direct local contact is unlikely, so Hermes skips before expensive owner hunting.";
  }
  return "";
}

function genericSocialHomePage(website = "") {
  const host = hostFromLeadWebsite(website).replace(/^www\./, "").toLowerCase();
  if (!host) return false;
  try {
    const url = new URL(normalizeUrl(website));
    const path = url.pathname.replace(/^\/+|\/+$/g, "");
    return ["facebook.com", "instagram.com", "linkedin.com", "x.com", "twitter.com", "tiktok.com"].includes(host) && !path;
  } catch {
    return false;
  }
}

function knownChainOrFranchiseSeed({ name = "", host = "", text = "", niche = "" } = {}) {
  const combined = normalizeText(`${name} ${host} ${text}`).toLowerCase();
  if (/\b(franchise|franchising|corporate|all locations|find a location|locations nationwide)\b/i.test(combined)) return true;
  const compactName = name.toLowerCase().replace(/[^a-z0-9]+/g, "");
  const compactHost = host.toLowerCase().replace(/[^a-z0-9]+/g, "");
  const chainNames = [
    "firstwatch",
    "season52",
    "perryssteakhouse",
    "culinarydropout",
    "originalpancakehouse",
    "flyingbiscuit",
    "anotherbrokenegg",
    "bigbadbreakfast",
    "toastedyolk",
    "ruby sunshine",
    "rubysunshine",
    "greatclips",
    "sportclips",
    "supercuts",
    "massageenvy",
    "handandstone",
    "anytimefitness",
    "planetfitness",
    "orangetheory",
    "midas",
    "mrrooter",
    "rotorooter",
    "benjaminfranklinplumbing"
  ];
  if (chainNames.some((chain) => compactName.includes(chain.replace(/[^a-z0-9]+/g, "")) || compactHost.includes(chain.replace(/[^a-z0-9]+/g, "")))) return true;
  if (/\b(restaurants?|salons?|gyms?|plumbers?|roofers?)\b/i.test(niche) && /\b(locations|franchise|careers|gift cards|order online)\b/i.test(combined) && /\b(select a location|find a location|locations)\b/i.test(combined)) return true;
  return false;
}

const LOCAL_LEAD_PRIORITY_STOP_WORDS = new Set([
  "and",
  "the",
  "spa",
  "salon",
  "nail",
  "nails",
  "lash",
  "lashes",
  "lounge",
  "austin",
  "bee",
  "cave",
  "best",
  "modern",
  "company",
  "discount",
  "off",
  "mon",
  "thurs"
]);

function hostFromLeadWebsite(website = "") {
  try {
    return new URL(normalizeUrl(website)).hostname;
  } catch {
    return "";
  }
}

function rebuildProcessedLeadKeys(run) {
  return uniqueBy([
    ...(run.processedLeadKeys || []),
    ...(run.qualifiedLeads || []).map(leadKey).filter(Boolean),
    ...(run.rejectedLeads || []).flatMap((lead) => [lead.leadKey, leadKey(lead)].filter(Boolean))
  ].filter(Boolean), (key) => key).slice(-10000);
}

function compactRunForReview(run) {
  return {
    id: run.id,
    status: run.status,
    niche: run.input.niche,
    target: run.input.target,
    minScore: run.input.minScore,
    countryScope: run.input.countryScope,
    plannedLocations: run.input.cities.length,
    diagnosis: run.diagnosis,
    counters: run.counters,
    scaleAudit: run.scaleAudit || buildScaleAudit(run),
    qualifiedSamples: (run.qualifiedLeads || []).slice(0, 5).map((lead) => ({
      name: lead.name,
      city: lead.city,
      score: lead.quality?.score,
      sourceQuery: lead.sourceQuery
    })),
    rejectedSamples: (run.rejectedLeads || []).slice(0, 5),
    failures: (run.failures || []).slice(-20).map((failure) => ({
      stage: failure.stage,
      message: failure.message,
      context: summarizeMemoryContext(failure.context)
    })),
    decisions: (run.agentDecisions || []).slice(-30).map((decision) => ({
      type: decision.type,
      action: decision.action,
      reason: decision.reason
    }))
  };
}

function buildScaleAudit(run = {}) {
  const counters = run.counters || {};
  const input = run.input || {};
  const target = Number(input.target || 0);
  const qualified = run.qualifiedLeads?.length || counters.qualified || 0;
  const discovered = run.discoveredLeads?.length || counters.discovered || 0;
  const pendingDiscovered = pendingDiscoveredLeads(run).length;
  const processedDiscovered = (run.processedLeadKeys || []).length;
  const ownerHunts = counters.ownerRabbitHoles || 0;
  const ownerProfiles = counters.ownerProfilesFound || 0;
  const ownerContacts = counters.ownerContactsFound || 0;
  const ownerAgentsStarted = counters.ownerAgentsStarted || 0;
  const ownerAgentsDeployed = counters.ownerAgentsDeployed || 0;
  const ownerAgentsTimedOut = counters.ownerAgentsTimedOut || 0;
  const attemptedQueries = run.search?.attemptedQueries?.length || counters.searchAttempts || 0;
  const plannedLocations = input.cities?.length || 0;
  const exhaustedLocations = run.search?.exhaustedCities?.length || 0;
  const maxPlannedLocations = Number(input.maxPlannedLocations || plannedLocations || 0);
  const ownerDirectContacts = qualifiedOwnerDirectContactCount(run);
  const ownerAssociatedContacts = qualifiedOwnerAssociatedContactCount(run);
  const minOwnerDirectContacts = MIN_OWNER_DIRECT_CONTACTS;
  const ownerDirectRequirementMet = ownerDirectContacts >= minOwnerDirectContacts;
  const targetReached = target > 0 && qualified >= target;
  const ownerBacklogDrained = pendingDiscovered === 0;
  const locationPlanSearched = plannedLocations > 0 && exhaustedLocations >= plannedLocations;
  const locationCapacityExhausted = maxPlannedLocations > 0 && plannedLocations >= maxPlannedLocations;
  const portfolioCycleClosed = targetReached || (ownerBacklogDrained && locationPlanSearched && locationCapacityExhausted);
  return {
    updatedAt: run.updatedAt || new Date().toISOString(),
    target,
    qualified,
    targetReached,
    discovered,
    processedDiscovered,
    pendingDiscovered,
    ownerBacklogDrained,
    rejected: counters.rejected || 0,
    remainingToTarget: Math.max(0, target - qualified),
    progressPercent: percent(qualified, target),
    ownerHunts,
    ownerProfiles,
    ownerContacts,
    ownerDirectContacts,
    ownerAssociatedContacts,
    minOwnerDirectContacts,
    ownerDirectRequirementMet,
    ownerAgentsStarted,
    ownerAgentsDeployed,
    ownerAgentsTimedOut,
    ownerAgentsPerHunt: average(ownerAgentsDeployed, ownerHunts),
    ownerAgentSearchHits: counters.ownerAgentSearchHits || 0,
    ownerAgentPagesFetched: counters.ownerAgentPagesFetched || 0,
    ownerAgentContactPromotions: counters.ownerAgentContactPromotions || 0,
    ownerSeedRejections: counters.ownerSeedRejections || 0,
    franchiseRejections: counters.franchiseRejections || 0,
    ownerIdentityGateRejections: counters.ownerIdentityGateRejections || 0,
    llmCalls: counters.llmCalls || 0,
    llmFallbacks: counters.llmFallbacks || 0,
    llmTimeouts: counters.llmTimeouts || 0,
    llmDurationMs: counters.llmDurationMs || 0,
    ownerProfileYieldPercent: percent(ownerProfiles, ownerHunts),
    ownerContactYieldPercent: percent(ownerContacts, ownerHunts),
    qualificationYieldPercent: percent(qualified, Math.max(1, ownerHunts || discovered)),
    attemptedQueries,
    plannedLocations,
    exhaustedLocations,
    locationPlanSearched,
    locationCapacityExhausted,
    portfolioCycleClosed,
    portfolioStopReason: portfolioStopReason({
      run,
      targetReached,
      ownerBacklogDrained,
      locationPlanSearched,
      locationCapacityExhausted
    }),
    locationExhaustionPercent: percent(exhaustedLocations, plannedLocations),
    leadWorkerConcurrency: Number(input.leadWorkerConcurrency || process.env.HERMES_LEAD_WORKER_CONCURRENCY || 3),
    ownerAgentSwarmSize: Number(input.ownerAgentSwarmSize || process.env.HERMES_OWNER_AGENT_SWARM_SIZE || 20),
    ownerHuntTimeoutMs: ownerHuntTimeoutForRun(run),
    ownerHuntTimeouts: counters.ownerHuntTimeouts || 0,
    maxQueriesPerCity: Number(input.maxQueriesPerCity || 0),
    maxPlannedLocations,
    status: run.status || ""
  };
}

function portfolioStopReason({ run, targetReached, ownerBacklogDrained, locationPlanSearched, locationCapacityExhausted }) {
  if (targetReached) return "target_reached";
  if (run.status === "cancelled") return "cancelled";
  if (run.status === "killed") return "killed";
  if (run.status === "failed") return "failed";
  if (run.status === "interrupted") return "interrupted";
  if (!ownerBacklogDrained) return "owner_backlog_pending";
  if (!locationPlanSearched) return "planned_locations_remaining";
  if (!locationCapacityExhausted) return "location_capacity_remaining";
  return "plan_exhausted";
}

function percent(numerator, denominator) {
  const top = Number(numerator || 0);
  const bottom = Number(denominator || 0);
  if (!bottom) return 0;
  return Math.round((top / bottom) * 1000) / 10;
}

function average(numerator, denominator) {
  const top = Number(numerator || 0);
  const bottom = Number(denominator || 0);
  if (!bottom) return 0;
  return Math.round((top / bottom) * 10) / 10;
}

function observeRun(run, context = {}) {
  return {
    city: context.city || "",
    query: context.query || "",
    stage: context.stage || "",
    target: run.input.target,
    qualified: run.qualifiedLeads.length,
    discovered: run.counters.discovered || 0,
    rejected: run.counters.rejected || 0,
    belowMinScore: run.counters.belowMinScore || 0,
    retries: run.counters.retries || 0,
    failures: run.failures.length,
    enrichmentFailures: run.counters.enrichmentFailures || 0,
    deepEnrichments: run.counters.deepEnrichments || 0,
    providerFallbacks: run.counters.providerFallbacks || 0,
    toolRepairs: run.counters.toolRepairs || 0,
    stagnationPivots: run.counters.stagnationPivots || 0,
    initialStrategies: run.counters.initialStrategies || 0,
    finalRecoveryPasses: run.counters.finalRecoveryPasses || 0,
    queryDuplicates: run.counters.queryDuplicates || 0,
    exhaustedLocations: run.search?.exhaustedCities?.length || 0
  };
}

function normalizeStrategyQueries(strategy, niche, city, countryScope = COUNTRY_SCOPE) {
  const queries = [];
  if (Array.isArray(strategy?.queries)) {
    for (const query of strategy.queries) {
      const text = normalizeText(query);
      if (isUsableSearchQuery(text)) queries.push(text);
    }
  }
  if (Array.isArray(strategy?.terms)) {
    for (const term of strategy.terms) {
      const text = normalizeText(term);
      if (!text || !isUsableSearchQuery(text)) continue;
      queries.push(`${text} in ${ensureCountryLocation(city, countryScope)}`);
      queries.push(`${text} ${city} official website phone`);
    }
  }
  if (!queries.length) {
    queries.push(...buildQueryVariants(niche, city, countryScope));
  }
  return uniqueBy(queries.map((query) => ensureCountryQuery(query, countryScope)).filter(isUsableSearchQuery), (query) => query.toLowerCase());
}

function hasOperationalMemory(run) {
  return Boolean(run.memory?.skills?.length || run.memory?.avoid?.length);
}

function memoryForPrompt(run) {
  return {
    skills: (run.memory?.skills || []).slice(0, 8).map((skill) => ({
      title: skill.title,
      when: skill.when,
      do: skill.do,
      avoid: skill.avoid
    })),
    avoid: (run.memory?.avoid || []).slice(0, 8).map((entry) => ({
      stage: entry.stage,
      failure: entry.failure,
      avoid: entry.avoid,
      context: summarizeMemoryContext(entry.context)
    }))
  };
}

function summarizeMemoryContext(context = {}) {
  return {
    city: context.city,
    query: context.query,
    stage: context.stage,
    lead: context.lead,
    website: context.website
  };
}

function memoryQueryHints(run, city) {
  const out = [];
  for (const skill of run.memory?.skills || []) {
    const text = normalizeText([skill.do, skill.title, skill.when].filter(Boolean).join(" "));
    if (!text) continue;
    const quoted = [...text.matchAll(/["'`]([^"'`]{8,120})["'`]/g)].map((match) => match[1]);
    out.push(...quoted);
    if (/\bquery\b|\bsearch\b|\bgoogle maps\b/i.test(text)) {
      const cleaned = text
        .replace(/\b(use|try|search|query|google maps|with|for|when|do)\b/gi, " ")
        .replace(/\{city\}/gi, city)
        .replace(/\{niche\}/gi, run.input.niche);
      out.push(cleaned);
    }
  }
  return uniqueBy(
    out
      .map((query) => ensureCountryQuery(queryTemplate(query, run.input.niche, city), run.input.countryScope))
      .filter(isUsableSearchQuery),
    (query) => query.toLowerCase()
  ).slice(0, 8);
}

function memoryAvoidsQuery(run, query) {
  const normalized = normalizeText(query).toLowerCase();
  if (!normalized) return false;
  return (run.memory?.avoid || []).some((entry) => {
    const avoidedQuery = normalizeText(entry.context?.query || "").toLowerCase();
    if (avoidedQuery && normalized === avoidedQuery) return true;
    const failure = normalizeText(entry.failure || "").toLowerCase();
    return failure && failure.includes("duplicate") && avoidedQuery && normalized.includes(avoidedQuery);
  });
}

function queryTemplate(query, niche, city) {
  const text = normalizeText(query)
    .replace(/\{city\}/gi, city)
    .replace(/\{location\}/gi, city)
    .replace(/\{niche\}/gi, niche);
  if (!text) return "";
  if (!/\b(usa|u\.s\.a\.|united states|[A-Z]{2})\b/i.test(text)) return `${text} ${city}`;
  return text;
}

function shouldExpandLocations(run, locationIndex) {
  if (hasReachedRunGoal(run)) return false;
  if (run.input.cities.length >= run.input.maxPlannedLocations) return false;
  const remainingPlanned = run.input.cities.length - locationIndex - 1;
  const progressRatio = run.qualifiedLeads.length / Math.max(1, run.input.target);
  if (remainingPlanned <= 1) return true;
  if (locationIndex > 0 && progressRatio < 0.25 && run.search?.exhaustedCities?.length >= Math.ceil((locationIndex + 1) / 2)) return true;
  return false;
}

function relatedLocationHints(currentLocation, plannedLocations) {
  const planned = new Set(plannedLocations.map((location) => normalizeText(location).toLowerCase()));
  const stateKey = stateKeyFromLocation(currentLocation) || normalizeStateKey(currentLocation);
  const candidates = STATE_CITY_PLAN[stateKey] || [];
  return candidates
    .map(ensureUsLocation)
    .filter((location) => !planned.has(normalizeText(location).toLowerCase()));
}

function normalizeStrategyLocations(strategy, fallbackLocations = [], countryScope = COUNTRY_SCOPE) {
  const out = [];
  if (Array.isArray(strategy?.locations)) {
    for (const location of strategy.locations) {
      const text = normalizeText(location);
      if (text && !hasExplicitDifferentCountry(text, countryScope)) out.push(ensureCountryLocation(text, countryScope));
    }
  }
  if (!out.length) out.push(...fallbackLocations.map((location) => ensureCountryLocation(location, countryScope)));
  return uniqueBy(out, (location) => normalizeText(location).toLowerCase());
}

function stateKeyFromLocation(location) {
  const match = normalizeText(location).match(/,\s*([A-Z]{2})(?:\s*,?\s*(?:USA|United States))?$/i);
  if (!match) return "";
  return STATE_ALIASES[match[1].toLowerCase()] || "";
}

function summarizeRunOutcome(run) {
  const failureCounts = countBy(run.failures || [], (failure) => failure.stage || "unknown");
  const searched = run.search?.attemptedQueries?.length || run.counters.searchAttempts || 0;
  const exhausted = run.search?.exhaustedCities?.length || 0;
  const parts = [
    `Searched ${searched} query/location combinations across ${run.input.cities.length} planned locations`
  ];

  if (exhausted) parts.push(`${exhausted} locations exhausted`);
  if (run.counters.discovered) parts.push(`${run.counters.discovered} unique leads discovered`);
  if (run.counters.rejected) parts.push(`${run.counters.rejected} rejected after qualification`);
  if (run.counters.belowMinScore) parts.push(`${run.counters.belowMinScore} below min score ${run.input.minScore}`);
  if (run.counters.enrichmentFailures) parts.push(`${run.counters.enrichmentFailures} enrichment failures`);
  if (run.counters.deepEnrichments) parts.push(`${run.counters.deepEnrichments} dynamic enrichment repairs`);
  if (run.counters.providerFallbacks) parts.push(`${run.counters.providerFallbacks} provider fallbacks`);
  if (run.counters.locationExpansions) parts.push(`${run.counters.locationExpansions} locations added autonomously`);
  if (run.counters.toolRepairs) parts.push(`${run.counters.toolRepairs} tool repair attempts`);
  if (run.counters.stagnationPivots) parts.push(`${run.counters.stagnationPivots} stagnation pivots`);
  if (run.counters.initialStrategies) parts.push(`${run.counters.initialStrategies} AI initial search plans`);
  if (run.counters.finalRecoveryPasses) parts.push(`${run.counters.finalRecoveryPasses} final recovery passes`);
  if (run.counters.queryDuplicates) parts.push(`${run.counters.queryDuplicates} repeated query variants rejected`);
  if (run.input.source === "github-owners") {
    parts.push(`legacy owner pipeline`);
    if (run.counters.reposProcessed) parts.push(`${run.counters.reposProcessed} repos processed`);
    if (run.counters.reposAccepted) parts.push(`${run.counters.reposAccepted} repos accepted`);
    if (run.counters.ownersHunted) parts.push(`${run.counters.ownersHunted} owner contacts hunted`);
    if (run.counters.ownersKeptHunting) parts.push(`${run.counters.ownersKeptHunting} owners still hunting (missing contact or summary)`);
  }

  const failureSummary = Object.entries(failureCounts)
    .map(([stage, count]) => `${stage}:${count}`)
    .join(", ");
  if (failureSummary) parts.push(`failure stages ${failureSummary}`);

  return parts.join("; ") + ".";
}

function countBy(items, keyFn) {
  const counts = {};
  for (const item of items) {
    const key = keyFn(item);
    counts[key] = (counts[key] || 0) + 1;
  }
  return counts;
}

function enqueueQuery(queue, queuedKeys, city, query, run = null, options = {}) {
  const safeQuery = ensureCountryQuery(query, run?.input?.countryScope || COUNTRY_SCOPE);
  const key = queryAttemptKey(city, safeQuery);
  if (!safeQuery || !isUsableSearchQuery(safeQuery) || queuedKeys.has(key)) return false;
  if (run && !isRelevantSearchQuery(safeQuery, run.input?.niche || "", city)) return false;
  const family = queryFamilyKey(city, safeQuery);
  if (run) {
    const search = ensureSearchState(run);
    const cityKey = normalizeText(city).toLowerCase();
    const queuedFamilies = options.queuedFamilies || new Set();
    if (family && (search.queryFamilies?.[cityKey] || []).includes(family)) {
      run.counters.queryDuplicates = (run.counters.queryDuplicates || 0) + 1;
      search.rejectedQueryFamilies.push({ city, query: safeQuery, family });
      search.rejectedQueryFamilies = search.rejectedQueryFamilies.slice(-100);
      return false;
    }
    if (family && queuedFamilies.has(family)) {
      run.counters.queryDuplicates = (run.counters.queryDuplicates || 0) + 1;
      search.rejectedQueryFamilies.push({ city, query: safeQuery, family });
      search.rejectedQueryFamilies = search.rejectedQueryFamilies.slice(-100);
      return false;
    }
    if (family) queuedFamilies.add(family);
  }
  queuedKeys.add(key);
  if (options.priority) queue.unshift(safeQuery);
  else queue.push(safeQuery);
  return true;
}

function queryAttemptKey(city, query) {
  return `${normalizeText(city).toLowerCase()}::${normalizeText(query).toLowerCase()}`;
}

function queryFamiliesFromAttempts(run) {
  const families = {};
  const search = run.search || {};
  for (const attempt of search.attemptedQueries || []) {
    const separator = attempt.indexOf("::");
    if (separator < 0) continue;
    const city = attempt.slice(0, separator);
    const query = attempt.slice(separator + 2);
    const family = queryFamilyKey(city, query);
    if (!family) continue;
    families[city] ||= [];
    if (!families[city].includes(family)) families[city].push(family);
  }
  return families;
}

function rememberAttemptedQueryFamily(run, city, query) {
  const search = run.search || {};
  search.queryFamilies ||= {};
  const cityKey = normalizeText(city).toLowerCase();
  const family = queryFamilyKey(city, query);
  if (!family) return;
  search.queryFamilies[cityKey] ||= [];
  if (!search.queryFamilies[cityKey].includes(family)) search.queryFamilies[cityKey].push(family);
}

function queuedFamilySet(city, queue) {
  return new Set(queue.map((query) => queryFamilyKey(city, query)).filter(Boolean));
}

function queryFamilyAlreadySeen(run, city, query) {
  const search = ensureSearchState(run);
  const family = queryFamilyKey(city, query);
  if (!family) return false;
  const cityKey = normalizeText(city).toLowerCase();
  return (search.queryFamilies?.[cityKey] || []).includes(family);
}

function queryFamilyKey(city, query) {
  const cityTokens = normalizeText(city)
    .toLowerCase()
    .split(/[^a-z0-9]+/)
    .filter(Boolean);
  const stateTokens = new Set([
    ...Object.keys(STATE_ALIASES),
    ...Object.values(STATE_ALIASES).flatMap((value) => String(value).split(/\s+/))
  ]);
  const ignored = new Set([
    ...cityTokens,
    ...stateTokens,
    "usa", "u", "s", "a", "us", "united", "states", "america",
    "in", "near", "for", "the", "and", "or", "of",
    "local", "licensed", "official", "website", "websites", "phone", "phones", "contact", "contacts",
    "company", "companies", "contractor", "contractors", "service", "services", "business", "businesses",
    "directory", "nearby", "best", "top"
  ]);
  const tokens = normalizeText(query)
    .toLowerCase()
    .split(/[^a-z0-9]+/)
    .filter(Boolean)
    .map(canonicalQueryToken)
    .filter((token) => token && !ignored.has(token));
  return uniqueBy(tokens, (token) => token).join(" ");
}

function canonicalQueryToken(token) {
  if (/^roof(?:er|ers|ing)?$/.test(token)) return "roof";
  if (token.endsWith("ies") && token.length > 4) return `${token.slice(0, -3)}y`;
  if (token.endsWith("s") && token.length > 4) return token.slice(0, -1);
  return token;
}

function retryToQuery({ retry, context, niche, countryScope = COUNTRY_SCOPE }) {
  const value = normalizeText(retry.value);
  if (!value) return "";
  if (retry.variable === "query") return ensureCountryQuery(value, countryScope);
  if (retry.variable === "location_split") return `${niche} in ${ensureCountryLocation(value, countryScope)}`;
  if (retry.variable === "page_list") return `${niche} ${context.city} ${value}`;
  return `${niche} ${context.city} official website phone`;
}

function nextFallbackQuery(run, context) {
  const search = ensureSearchState(run);
  const candidates = buildQueryVariants(run.input.niche, context.city, run.input.countryScope)
    .filter((query) => isUsableSearchQuery(query) && isRelevantSearchQuery(query, run.input.niche, context.city));
  const cityKey = normalizeText(context.city).toLowerCase();
  return candidates.find((query) => {
    const family = queryFamilyKey(context.city, query);
    return !search.attemptedQueries.includes(queryAttemptKey(context.city, query))
      && !memoryAvoidsQuery(run, query)
      && (!family || !(search.queryFamilies?.[cityKey] || []).includes(family));
  }) || "";
}

function isUsableSearchQuery(query) {
  const text = normalizeText(query);
  if (!text) return false;
  if (text.length > 180) return false;
  if (/\b(github-owners|github owners|git hub owners|github scraper|repo owners?|google_maps)\b/i.test(text)) return false;
  if (/\b(linkedin|instagram|facebook|email)\b/i.test(text)) return false;
  if (/\bsite\s*:/i.test(text) || /\bgoogle\.com\/maps\b/i.test(text)) return false;
  if (/(traceback|playwright|wait_for_selector|timeout\s+\d+ms\s+exceeded|google-maps-scrapper failed|scraper log|currently found|total found|call log|locator\()/i.test(text)) return false;
  if (/\b(arrived at all available|timed out after|net::err_|fetch failed|error:|exception|stack trace)\b/i.test(text)) return false;
  return true;
}

function isRelevantSearchQuery(query, niche, city) {
  const text = normalizeText(query).toLowerCase();
  if (!text) return false;
  const queryTokens = new Set(text.split(/[^a-z0-9]+/).filter(Boolean).map(canonicalQueryToken));
  const nicheTokens = relevantNicheTokens(niche);
  const locationTokens = relevantLocationTokens(city);
  const hasNiche = !nicheTokens.length || nicheTokens.some((token) => queryTokens.has(token) || text.includes(token));
  const hasLocation = !locationTokens.length || locationTokens.some((token) => queryTokens.has(token) || text.includes(token));
  return hasNiche && hasLocation;
}

function relevantNicheTokens(niche) {
  return uniqueBy(expandNicheTerms(niche)
    .flatMap((term) => normalizeText(term).toLowerCase().split(/[^a-z0-9]+/))
    .map(canonicalQueryToken)
    .filter((token) => token.length > 2 && !["local", "service", "business"].includes(token)), (token) => token);
}

function relevantLocationTokens(city) {
  const primary = normalizeText(city).split(",")[0] || city;
  return uniqueBy(primary
    .toLowerCase()
    .split(/[^a-z0-9]+/)
    .filter((token) => token.length > 2 && !["usa", "united", "states"].includes(token)), (token) => token);
}

function expandNicheTerms(niche) {
  const text = normalizeText(niche);
  const terms = [text];
  if (/\bsalons?\b/i.test(text)) terms.push("hair salons", "beauty salons", "nail salons", "spa salons");
  if (/\broofers?\b|\broofing\b/i.test(text)) terms.push("roofing contractors", "licensed roofers", "roof repair", "residential roofing", "commercial roofing");
  if (text.endsWith("s")) terms.push(text.slice(0, -1));
  terms.push(`local ${text}`, `${text} services`);
  return uniqueBy(terms.filter(Boolean), (term) => term.toLowerCase());
}

function hasExplicitNonUsCountry(value) {
  return /\b(india|canada|mexico|united kingdom|uk|england|australia|uae|dubai|singapore|germany|france|spain|italy|netherlands|brazil)\b/i.test(value);
}
