Ctrl K
ComunidadGuías…

doctor.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

/**
 * doctor.mjs — Setup validation for career-ops
 * Checks all prerequisites and prints a pass/fail checklist.
 */

import { accessSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
import { execFileSync } from 'child_process';
import { homedir } from 'os';
import { delimiter, join, dirname, isAbsolute, relative, resolve, sep } from 'path';
import { fileURLToPath } from 'url';
import { findTitleFilterConflicts } from './lib/title-filter-conflicts.mjs';
import { sectionState, scanHeadings, SECTIONS } from './lib/personalization.mjs';
import { discoverPlugins, pluginRoots, pluginStatus, resolveSuccessorIds } from './plugins/_engine.mjs';
import { getCareerOpsRoot, getProfilePath, hasProfileOverride, isPluginInstallCopy } from './path-resolver.mjs';
import { parseConfigByExtension } from './jsonc-parse.mjs';
import { validateFlags, flagValue } from './lib/cli-flags.mjs';
import { geminiNodeFloor } from './lib/gemini-node-floor.mjs';
import { SKILL_ENTRYPOINTS } from './scaffolder/bin/skill-entrypoints.mjs';
import { nodeFloor } from './lib/node-floor.mjs';
import { classifyChromiumLaunchError } from './lib/chromium-launch-error.mjs';
import { findExperienceSections, parseCompanyHeading, EXPERIENCE_HEADING_NAMES } from './lib/cv-markdown.mjs';
import { LINKEDIN_DOCTOR_LABEL, linkedInPluginFix } from './lib/linkedin-import.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);

// path-resolver.mjs refuses an explicit data root inside a plugin install copy,
// and some modules loaded below resolve the root as they load. Ask once, first,
// so the refusal reaches the user as a report instead of a stack trace.
try {
  getCareerOpsRoot();
} catch (err) {
  if (err?.code !== 'CAREER_OPS_UNSAFE_DATA_ROOT') throw err;
  if (argv.includes('--json')) {
    // Same keys as a normal --json run, so an agent reading `warnings` sees it;
    // onboardingNeeded stays true so nothing proceeds on this root.
    console.log(JSON.stringify({
      onboardingNeeded: true,
      missing: [],
      unpersonalized: [],
      warnings: [err.message],
      autoCopied: [],
      dataRootError: { code: err.code, source: err.source, root: err.root },
    }));
  } else {
    console.log(`\ncareer-ops doctor\n================\n\n✗ ${err.message}\n`);
  }
  process.exit(1);
}

// Modules that import an npm package (directly or through their own imports)
// load here, after the fact, instead of as static imports. A static import of
// a missing package fails while Node links the module graph, before a single
// line of this file runs, so a copy with no node_modules (a ZIP download, a
// copy made without `npm install`) died with a raw ERR_MODULE_NOT_FOUND stack
// from the very check that exists to report missing dependencies. Loading them
// dynamically lets that failure be caught and reported with the fix.
let hasYamlContent, loadYaml, dotenv, AGENT_CLIS, validateProfile, EXAMPLE_PATH;
let resolveExtractorMode, auditUserTemplates, browserLaunchOptions, resolveBrowserExecutable, snapshotBrowserProcessEnv;
try {
  [
    { hasYamlContent, loadYaml },
    { default: dotenv },
    { AGENT_CLIS },
    { validateProfile, EXAMPLE_PATH },
    { resolveExtractorMode },
    { auditUserTemplates },
    { browserLaunchOptions, resolveBrowserExecutable, snapshotBrowserProcessEnv },
  ] = await Promise.all([
    import('./lib/yaml-document.mjs'),
    import('dotenv'),
    import('./lib/agent-cli.mjs'),
    import('./validate-profile.mjs'),
    import('./browser-extract.mjs'),
    import('./cv-templates.mjs'),
    import('./lib/browser-launch.mjs'),
  ]);
} catch (err) {
  let missing = [];
  try { missing = findMissingDependencies(__dirname); } catch { /* no readable package.json: the error still names the package */ }
  const name = /Cannot find package '((?:@[^/'\\:]+\/)?[^/'\\:]+)'/.exec(err?.message)?.[1];
  if (name && !missing.includes(name)) missing.unshift(name);
  if (err?.code !== 'ERR_MODULE_NOT_FOUND' || missing.length === 0) throw err;
  console.error(`career-ops dependencies are not installed in ${__dirname} (missing: ${missing.join(', ')}), so doctor cannot run its checks.\nFix: run \`npm install\` in ${__dirname}, then run doctor again.`);
  process.exit(1);
}

// CLIs the doctor recognises.

// --help ran the full diagnostic and printed the report at exit 0 (#2856), so
// a mistyped flag was indistinguishable from a clean run — and --targe
// silently diagnosed THIS checkout instead of the one asked for. Handled via
// lib/cli-flags.mjs's validateFlags() (#2775), which rejects unrecognized
// flags before --help so `--help --bogus` still errors.
const KNOWN_FLAGS = ['--target', '--json', '--init-templates', '--strict', '--cli', '--help', '-h'];

// Both take a value, as the next argv token or as `--flag=value`. validateFlags
// accepts either form, so both are read with flagValue(): an indexOf() lookup
// cannot see `--target=<path>` and would diagnose this checkout instead.
const VALUE_FLAGS = ['--target', '--cli'];

const USAGE = `Usage:
  node doctor.mjs                    # run the setup diagnostic
  node doctor.mjs --json             # read-only machine-readable onboarding state
  node doctor.mjs --json --init-templates # create missing personalization files for onboarding
  node doctor.mjs --strict           # also probe portals.yml entries (network)
  node doctor.mjs --target <path>    # diagnose another career-ops checkout
  node doctor.mjs --cli <name>       # check a specific CLI's integration
  node doctor.mjs --help             # show this message

CLIs: ${AGENT_CLIS.join(', ')}`;

// requireOperand: without it, `--target --json` reads --json as the target
// path (flagValue() below takes the next token as-is), and the
// doctor silently diagnoses a directory literally named "--json" at exit 0
// (#3087) — this is the onboarding entrypoint, so that's a user told to
// create files that already exist. Nothing more specific to say than the
// shared message.
validateFlags(argv, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });

const explicitTarget = flagValue(argv, '--target') || null;
const projectRoot = explicitTarget || getCareerOpsRoot();
// --target diagnoses a whole other checkout, so that checkout's profile is the
// one that counts; otherwise CAREER_OPS_PROFILE applies as it does everywhere.
// With no override the path is the root the doctor started from: the human run
// loads the root's whole .env later, and a CAREER_OPS_ROOT in it must not move
// this one check to another root.
const profilePathFor = (root) => (explicitTarget || !hasProfileOverride() ? getProfilePath(root) : getProfilePath());
// Only scan.mjs, the evaluators, openai-tailor.mjs and openrouter-runner load a .env; most readers
// of the profile see the process environment alone. Doctor reports what they see,
// so an override set only in the root's .env is looked up for the advice below
// but never counted as a found profile.
const profileFromEnvFile = (root) => {
  try {
    const value = dotenv.parse(readFileSync(join(root, '.env'))).CAREER_OPS_PROFILE;
    return value?.trim() ? value.trim() : null;
  } catch {
    return null; // no .env, or unreadable
  }
};
// node_modules and .git belong to the CODE checkout, not the resolved data
// root — under a split checkout (CAREER_OPS_ROOT/CAREER_OPS_DATA_DIR or the
// .career-ops-data marker) those are two different directories, and neither
// ever holds the other's artifacts (career-ops#3867 finding 6). --target is
// the one case that means "diagnose this whole other checkout" — code layer
// included — so it keeps pointing both roots at the same place, matching how
// tests/doctor-tracked-bak-files.test.mjs already exercises it.
const codeRoot = explicitTarget || __dirname;
const JSON_OUT = argv.includes('--json');
const INIT_TEMPLATES = argv.includes('--init-templates');
if (INIT_TEMPLATES && !JSON_OUT) {
  console.error('Error: --init-templates requires --json');
  process.exit(1);
}
// --strict adds a live reachability probe of every portals.yml entry (network).
// Opt-in so the default `npm run doctor` stays fast and fully offline.
const STRICT = argv.includes('--strict');

const cliFlag = flagValue(argv, '--cli') ?? null;

// ANSI colors (only on TTY)
const isTTY = process.stdout.isTTY;
const green = (s) => isTTY ? `\x1b[32m${s}\x1b[0m` : s;
const red = (s) => isTTY ? `\x1b[31m${s}\x1b[0m` : s;
const yellow = (s) => isTTY ? `\x1b[33m${s}\x1b[0m` : s;
const dim = (s) => isTTY ? `\x1b[2m${s}\x1b[0m` : s;

function checkNodeVersion() {
  return nodeFloor(process.versions.node);
}

// El check mas frecuente de la comunidad, medido: 8 personas en 4 semanas
// preguntando por cuota y coste, y la causa mas repetida es esta. Aviso, nunca
// fallo: tener una clave puesta es una eleccion legitima, lo que no es legitimo
// es que el usuario no sepa que la esta usando en vez del plan que ya paga.
function checkBillingSource() {
  const key = process.env.ANTHROPIC_API_KEY;
  const authToken = process.env.ANTHROPIC_AUTH_TOKEN;
  // Enabled means SET TO A TRUTHY VALUE, not merely present. These switches are
  // documented as `=1`, so `CLAUDE_CODE_USE_BEDROCK=0` is how someone turns one
  // off — and mere presence would then report "requests bill to your cloud
  // account" at exactly the user who just said they don't. A billing check that
  // misreads an explicit opt-out causes the confusion it exists to remove.
  // Matches the repo's own env-flag convention (=== '1' in merge-tracker.mjs
  // and update-system.mjs), while also accepting `true` since these are
  // third-party switches users copy from assorted docs.
  const cloud = ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY']
    .filter((v) => /^(1|true|yes|on)$/i.test(String(process.env[v] ?? '').trim()));

  if (cloud.length) {
    return {
      warn: true,
      label: `${cloud[0]} is set, so requests bill to your cloud account, not to a Claude subscription.`,
      fix: [
        'Intentional? Nothing to do.',
        `Not intentional: unset ${cloud[0]} and restart your terminal.`,
      ],
    };
  }

  const which = key ? 'ANTHROPIC_API_KEY' : (authToken ? 'ANTHROPIC_AUTH_TOKEN' : null);
  if (!which) {
    return { pass: true, label: 'Billing source: no API key in the environment (a Claude subscription will be used if you are logged in)' };
  }

  return {
    warn: true,
    label: `${which} is set, so it takes precedence over any Claude subscription: this session bills per token even if you pay for Pro or Max.`,
    fix: [
      'Intentional (you meant to use API credits)? Nothing to do.',
      `Not intentional: remove the export of ${which} from ~/.zshrc, ~/.bashrc, ~/.profile or a project .env, restart your terminal, and run /login.`,
      'Batch runs are the exception: `claude -p` workers do not use the interactive login, so they need `claude setup-token` exported as CLAUDE_CODE_OAUTH_TOKEN.',
      'Details: docs/RUNNING_ON_A_BUDGET.md section 2b.',
    ],
  };
}

// Whether each package.json dependency resolves from the code checkout the way
// Node will at run time — not whether a node_modules directory exists. That
// was wrong both ways: a git worktree with no node_modules of its own resolves
// through the main checkout's (Node walks up parent directories), and a
// node_modules installed before a dependency was added (undici, #4445) still
// exists while the import fails. Every script loads its dependencies with ESM
// `import`, whose bare-specifier lookup is node_modules in the code root and
// each ancestor directory — and nothing else: unlike require(), it ignores
// NODE_PATH and the global folders, so a package found only there would pass
// here and still fail to import. A package counts as installed when one of
// those node_modules holds it. Checking for its package.json, rather than
// resolving the package, never consults an `exports` map that refuses the
// lookup and never loads the package — doctor runs on every session's first
// message.
function findMissingDependencies(root) {
  const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
  const lookupDirs = [];
  for (let dir = resolve(root); ; dir = dirname(dir)) {
    lookupDirs.push(join(dir, 'node_modules'));
    if (dirname(dir) === dir) break;
  }
  return Object.keys(manifest.dependencies || {}).filter((name) =>
    !lookupDirs.some((dir) => existsSync(join(dir, name, 'package.json'))));
}

function checkDependencies() {
  let missing;
  try {
    missing = findMissingDependencies(codeRoot);
  } catch (err) {
    return {
      pass: false,
      label: `Dependencies could not be checked: package.json unreadable (${err.message})`,
      fix: 'Run doctor from a career-ops checkout, or pass --target <checkout>',
    };
  }
  if (missing.length === 0) {
    return { pass: true, label: 'Dependencies installed' };
  }
  return {
    pass: false,
    label: `Dependencies missing: ${missing.join(', ')}`,
    fix: isPluginInstallCopy(codeRoot)
      ? 'Start a new Claude Code session: the plugin\'s SessionStart hook installs them and reports any npm error'
      : 'Run: npm install',
  };
}

// A Claude Code plugin install runs from a copy under ~/.claude/plugins/ that
// Claude Code replaces on every plugin update, so the data root must live
// outside it. Null for a checkout, which is not a copy.
function checkPluginInstall(root) {
  if (explicitTarget || !isPluginInstallCopy(codeRoot)) return null;
  if (isPluginInstallCopy(root)) {
    return {
      pass: false,
      label: `Data root ${root} is inside the Claude Code plugin install, which is replaced on every plugin update`,
      fix: 'Set CAREER_OPS_ROOT to an absolute path outside ~/.claude/plugins, or unset it to use ~/career-ops-data',
    };
  }
  return { pass: true, label: `Claude Code plugin install: your files live in ${root}` };
}

// The web UI is optional, so missing web/ dependencies are a warning: only
// `npm run ui` needs them, and a CLI-only install never does.
function checkWebDependencies() {
  const webDir = join(codeRoot, 'web');
  if (!existsSync(join(webDir, 'package.json'))) return null;
  let missing;
  try {
    missing = findMissingDependencies(webDir);
  } catch (err) {
    return { warn: true, label: `Web UI dependencies could not be checked: web/package.json unreadable (${err.message})` };
  }
  if (missing.length === 0) return { pass: true, label: 'Web UI dependencies installed' };
  return {
    warn: true,
    label: `Web UI dependencies are not installed (${missing.length} missing, including ${missing[0]}), so npm run ui cannot start`,
    fix: 'Run: npm --prefix web ci',
  };
}

// update-system.mjs versions before #2857 could commit cascading .bak backups
// when a checkout write failed. #2857 stops that for NEW installs, but an
// install that already `git add`ed one is stuck: nothing in the update path
// untracks a path git already has, so the .bak files persist forever and the
// next update looks like it silently did nothing (career-ops#2881) — nothing
// points at .bak unless something checks for it. Kept to a single cheap
// `git ls-files` call so it costs nothing on the common case of zero matches.
function checkTrackedBakFiles(root) {
  let raw;
  try {
    raw = execFileSync('git', ['ls-files', '-z', '--', '*.bak*'], {
      cwd: root,
      encoding: 'utf-8',
      timeout: 5000,
      // The non-checkout classification below reads Git's diagnostic. Keep
      // this subprocess deterministic without changing the user's locale.
      env: { ...process.env, LC_ALL: 'C', LANGUAGE: 'C' },
      // stderr PIPED, not inherited. execFileSync's default hands the child our
      // own stderr, so outside a checkout git printed
      //   fatal: not a git repository (or any of the parent directories): .git
      // before this catch ever ran — ahead of the JSON on `doctor --json`, which
      // AGENTS.md has every agent run on the first message of every session. The
      // catch below already handles that case; git's own message added nothing
      // but the appearance of something being broken.
      stdio: ['ignore', 'pipe', 'pipe'],
    });
  } catch (err) {
    // Piping means the reason is ours to read rather than the terminal's. Not
    // being a checkout is the expected case and stays a clean skip; anything
    // else — git missing, a permissions problem, a timeout — is reported, since
    // silently skipping those would claim a check ran that did not.
    const stderr = String(err?.stderr ?? '');
    if (/not a git repository/i.test(stderr) || err?.code === 'ENOENT') {
      return { pass: true, label: 'Tracked .bak files: skipped (not a git checkout)' };
    }
    const detail = stderr.trim().split('\n')[0] || err?.message || 'unknown error';
    return { warn: true, label: `Tracked .bak files: check could not run (${detail})` };
  }
  const paths = raw.split('\0').filter(Boolean);
  if (paths.length === 0) {
    return { pass: true, label: 'No tracked .bak backup files' };
  }
  const preview = paths.slice(0, 8);
  const rest = paths.length - preview.length;
  return {
    warn: true,
    label: `${paths.length} tracked .bak backup file${paths.length === 1 ? '' : 's'} found — an old update-system.mjs bug committed these, and no update untracks them on its own`,
    fix: [
      ...preview,
      ...(rest > 0 ? [`...and ${rest} more`] : []),
      "git ls-files '*.bak*'            # find them",
      'git rm --cached <each path>      # untrack, leaving the file on disk',
      'git commit -m "chore: untrack .bak backups"',
    ],
  };
}

// A checkout made without symlink support (Windows without Developer Mode,
// core.symlinks=false) writes each per-CLI skill entrypoint as a regular file
// holding only the symlink target text, so that CLI loads a ~43-byte skill with
// no router in it (career-ops#4589). update-system.mjs apply repairs these via
// ensureSkillEntrypoints, but apply returns early on an install that is already
// up to date, so a fresh clone never reaches it and nothing else says why
// /career-ops does nothing. Read-only on purpose (doctor must not write): it
// names the stubs and the one command that materializes them.
// One script for every shell, deliberately free of quote characters so each
// shell can wrap it in its own: backticks for the one string, and both paths
// arrive as separate argv words, never interpolated into the script. (Read back
// with argv.at(-n): tests/main-guard-convention.test.mjs bans the literal
// entry-path index in source files, printed strings included.)
const MATERIALIZE_SCRIPT = 'import(require(`url`).pathToFileURL(process.argv.at(-2)).href).then(m=>console.log(m.materializeSkillEntrypoints(process.argv.at(-1))))';

// --target accepts any path, so what is printed has to be literal when pasted.
// Each shell expands something different inside the quoting it prefers:
//   sh          single quotes are fully literal; a quote is closed, escaped, reopened
//   PowerShell  single quotes are literal ($(...), $HOME stay text); a quote is
//               escaped by doubling it, and PowerShell also treats the curly
//               and low quotes as quote characters, so those double too
//   cmd         double quotes are the only form, and %VAR% is expanded inside
//               them with no escape on an interactive line (a Windows path may
//               contain %, and a double quote cannot occur in one)
const quoteSh = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
const quotePowerShell = (v) => `'${v.replace(/['\u2018\u2019\u201A\u201B]/g, (c) => c + c)}'`;
const quoteCmd = (v) => `"${v}"`;

function repairCommands(root) {
  const mod = join(root, 'scaffolder', 'bin', 'skill-entrypoints.mjs');
  const updater = join(root, 'update-system.mjs');
  const forms = (quote) => ({
    materialize: `node -e ${quote(MATERIALIZE_SCRIPT)} ${quote(mod)} ${quote(root)}`,
    update: `node ${quote(updater)} apply --confirm`,
  });
  const lines = [];
  const section = (heading, f) => lines.push(heading.materialize, f.materialize, heading.update, f.update);
  const heading = {
    materialize: 'Repair them now, no update needed:',
    update: 'Or update (this only repairs them when an update is actually applied):',
  };
  if (process.platform !== 'win32') {
    section(heading, forms(quoteSh));
    return lines;
  }
  const label = (shell) => ({
    materialize: `${heading.materialize} (${shell})`,
    update: `${heading.update} (${shell})`,
  });
  section(label('PowerShell'), forms(quotePowerShell));
  if (root.includes('%')) {
    lines.push('cmd.exe form not shown: this path contains %, which cmd always expands. Use the PowerShell commands above.');
  } else {
    section(label('cmd.exe'), forms(quoteCmd));
  }
  return lines;
}

function checkSkillEntrypoints(root) {
  const stubs = [];
  for (const entry of SKILL_ENTRYPOINTS) {
    const entryPath = join(root, ...entry.path.split('/'));
    try {
      const stat = lstatSync(entryPath);
      if (stat.isSymbolicLink() || !stat.isFile()) continue;
      if (readFileSync(entryPath, 'utf-8').trim() === entry.pointer) stubs.push(entry.path);
    } catch {
      // Missing or unreadable: not a stub. A CLI the user never installed is
      // not worth a warning, and ensureSkillEntrypoints creates absent ones.
    }
  }
  if (stubs.length === 0) {
    return { pass: true, label: 'CLI skill entrypoints are real files or symlinks' };
  }
  return {
    warn: true,
    label: `${stubs.length} CLI skill entrypoint${stubs.length === 1 ? ' is' : 's are'} a symlink-target stub, not the skill — this checkout has no symlink support, so that CLI loads an empty /career-ops`,
    // Materializing first: it is the repair that works on the clone this
    // warning is most likely for, one that is already up to date, where apply
    // returns before reaching ensureSkillEntrypoints. Both are built from the
    // root the check just inspected, so they act on that checkout from whatever
    // directory they are pasted into.
    fix: [
      ...stubs,
      ...repairCommands(root),
    ],
  };
}

// The browsers `npx playwright install` downloads are dynamically linked against
// FHS library paths, so on NixOS they fail to start (libglib-2.0.so.0 not
// found). The flake dev shell points Playwright at nixpkgs' patched browsers
// instead, and is the only fix that works there.
function playwrightInstallFix() {
  if (existsSync('/etc/NIXOS')) {
    return 'On NixOS, run career-ops inside the flake dev shell (`nix develop`, or `direnv allow`) and re-enter it after `npm install`. Do not run `npx playwright install`: NixOS cannot run the browsers it downloads. See docs/NIXOS.md.';
  }
  return 'Run: npx playwright install chromium';
}

// Missing shared libraries are a host problem, not a download problem: the
// browser is there and `npx playwright install` skips it. Playwright's own
// remedy is install-deps, which installs system packages and so needs root.
// It only knows apt (#4223), so on any other Linux it would fail with
// "apt-get: command not found"; there the distro's own package manager is the
// fix.
function isDebianLike() {
  try {
    const osRelease = readFileSync('/etc/os-release', 'utf-8');
    return /^(?:ID|ID_LIKE)=.*\b(?:debian|ubuntu)\b/m.test(osRelease);
  } catch {
    return false;
  }
}

function playwrightDepsFix() {
  if (existsSync('/etc/NIXOS')) return playwrightInstallFix();
  // On Windows the missing pieces are Media Foundation DLLs. install-deps
  // installs them through Playwright's install_media_pack.ps1, which enables a
  // Windows Server feature and so needs an administrator shell; Windows N
  // editions lack them by design and need Microsoft's Media Feature Pack.
  if (process.platform === 'win32') {
    return [
      'Run in an administrator terminal: npx playwright install-deps chromium',
      'On a Windows N edition, install the Media Feature Pack from Microsoft instead (Settings → Apps → Optional features).',
    ];
  }
  if (process.platform === 'linux' && !isDebianLike()) {
    return 'Install the packages that provide these libraries with your distribution\'s package manager. `npx playwright install-deps` uses apt-get, so it only works on Debian and Ubuntu.';
  }
  const sudo = process.platform !== 'win32' && process.getuid?.() ? 'sudo ' : '';
  return `Run: ${sudo}npx playwright install-deps chromium`;
}

async function checkPlaywright(browserProcessEnv) {
  let chromium;
  try {
    ({ chromium } = await import('playwright'));
  } catch (err) {
    // The package itself is missing or broken, so there is no browser to
    // install yet: `npx playwright install` would fetch a CLI whose version
    // need not match the one package.json pins.
    const notFound = err?.code === 'ERR_MODULE_NOT_FOUND';
    return {
      pass: false,
      label: notFound
        ? 'Playwright chromium could not be checked: the playwright package is not installed'
        : `Playwright chromium could not be checked: the playwright package failed to load (${String(err?.message ?? err).split('\n')[0]})`,
      fix: 'Run: npm install',
    };
  }
  // Validate by launching — chromium.executablePath() points at Chrome for Testing
  // (full binary) but chromium.launch() may use the headless-shell binary, which
  // lives at a different path and requires a separate install. Launching directly
  // tests the exact binary the runtime uses and catches stub-installs (directory
  // present but no binary — just ABOUT + LICENSE files).
  let browser;
  // The configured browser, so the check tests what the rest of the repo will
  // actually launch rather than always probing the bundled Chromium.
  const override = resolveBrowserExecutable({ projectRoot, processEnv: browserProcessEnv });
  try {
    browser = await chromium.launch(browserLaunchOptions(
      { headless: true },
      { projectRoot, processEnv: browserProcessEnv },
    ));
    return {
      pass: true,
      label: override
        ? `Playwright chromium installed (using the ${override.source === 'env' ? 'configured' : 'auto-detected'} browser at ${override.path})`
        : 'Playwright chromium installed',
    };
  } catch (err) {
    // Only a missing binary is fixed by installing it. Everything else means
    // the binary is present and failed to start, so the advice follows the
    // error Playwright reported instead of repeating a no-op install.
    const cause = classifyChromiumLaunchError(err);
    // Either cause can be sidestepped by pointing career-ops at a
    // Chromium-family browser the machine already has.
    const overrideHint = override
      ? ` The browser it tried is the one configured for career-ops (${override.path}); clear that setting to fall back to the bundled Chromium.`
      : ' Or, to use a browser this machine already has instead of the bundled'
        + ' Chromium (e.g. on an OS too old for it), set'
        + ' PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/browser, or'
        + ' CAREER_OPS_BROWSER_AUTODETECT=true to detect an installed'
        + ' Brave/Chrome/Edge, in your shell or the data-root .env.';
    if (cause.kind === 'missing-executable') {
      return {
        pass: false,
        label: 'Playwright chromium not installed',
        fix: playwrightInstallFix() + overrideHint,
      };
    }
    if (cause.kind === 'missing-libraries') {
      const which = cause.libraries.length ? ` (${cause.libraries.join(', ')})` : '';
      return {
        pass: false,
        label: `Playwright chromium is installed but cannot start: the system libraries it needs are missing${which}`,
        fix: playwrightDepsFix(),
      };
    }
    return {
      pass: false,
      label: `Playwright chromium is installed but failed to launch: ${cause.detail}`,
      fix: 'Reinstalling will not fix this, because the browser is already installed. If doctor runs inside a sandbox or container, check that it allows a browser process to start.' + overrideHint,
    };
  } finally {
    try { await browser?.close(); } catch { /* ignore */ }
  }
}

// Per-CLI MCP config registry. `plugins: true` marks a CLI whose MCP servers
// can also arrive from outside the project root: from .claude.json or from an
// installed plugin (see isPlaywrightMcpFromClaudeJson, isPlaywrightMcpFromPlugin).
// `userFiles` marks a CLI whose USER-level config can also carry servers
// (see opencodeUserConfigFiles).
const MCP_CONFIGS = [
  { cli: 'claude',   files: ['.mcp.json', '.claude/settings.json', '.claude/settings.local.json'], plugins: true },
  // opencode.jsonc is JSONC: OpenCode accepts comments and trailing commas
  // there, and JSON.parse throwing on them used to read as "no MCP server
  // configured" (#2252).
  { cli: 'opencode', files: ['opencode.json', 'opencode.jsonc'], userFiles: () => opencodeUserConfigFiles() },
  { cli: 'omp',      files: ['.omp/mcp.json', '.omp/.mcp.json', 'mcp.json', '.mcp.json'] },
];

// Server qualifies if its definition references the @playwright/mcp package,
// or the `mcp-server-playwright` binary that package installs. nixpkgs ships
// the binary on its own (pkgs.playwright-mcp, in the flake dev shell), so a
// NixOS config names the command and never mentions the npm package.
function isPlaywrightServer(server, { allowDisabled = false } = {}) {
  if (!server || typeof server !== 'object') return false;
  if (!allowDisabled && (server.enabled === false || server.enabled === 'false')) return false;
  const blob = JSON.stringify(server).toLowerCase();
  return blob.includes('@playwright/mcp') || blob.includes('mcp-server-playwright');
}

// Any bucket shape that can hold a server map. A plugin's own .mcp.json is a
// BARE map ({ "playwright": {...} }) rather than the mcpServers/mcp wrapper the
// project-root configs use, so `cfg` itself is a candidate bucket (#2752).
function hasPlaywrightIn(cfg, { bare = false } = {}) {
  if (!cfg || typeof cfg !== 'object') return false;
  const buckets = [cfg.mcpServers, cfg.mcp, ...(bare ? [cfg] : [])]
    .filter((b) => b && typeof b === 'object');
  return buckets.some((servers) => Object.values(servers).some(isPlaywrightServer));
}

// Missing or malformed file reads as "not configured", never as a crash -
// matches the pre-existing swallow-and-continue behavior of the project scan.
function readConfigIfPresent(file) {
  if (!existsSync(file)) return null;
  try {
    return parseConfigByExtension(file, readFileSync(file, 'utf8')) ?? null;
  } catch {
    return null;
  }
}

// Claude Code's user config dir. CLAUDE_CONFIG_DIR is the documented override;
// the tests point it at a tmpdir so this never reads the real machine.
function claudeConfigDir() {
  return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
}

// OpenCode's USER-level config sources, in ASCENDING precedence order
// (opencode.ai/docs/config#precedence-order: global → $OPENCODE_CONFIG →
// project): ~/.config/opencode/opencode.json[c] (XDG_CONFIG_HOME honored),
// then $OPENCODE_CONFIG_DIR as a second config-directory candidate, then the
// $OPENCODE_CONFIG file. Config sources MERGE — the custom directory is
// loaded after the global config and overrides it, it never replaces it — so
// setting $OPENCODE_CONFIG_DIR must not take the default global dir out of
// the scan. Same reasoning as isPlaywrightMcpFromClaudeJson: a server
// installed at user level is installed and working, so a project-only scan
// would report a machine that has Playwright MCP as unconfigured. Missing or
// malformed files read as null.
function opencodeUserConfigFiles() {
  const names = ['opencode.json', 'opencode.jsonc'];
  const files = [];
  const globalDir = process.env.XDG_CONFIG_HOME
    ? join(process.env.XDG_CONFIG_HOME, 'opencode')
    : join(homedir(), '.config', 'opencode');
  for (const name of names) files.push(join(globalDir, name));
  const customDir = process.env.OPENCODE_CONFIG_DIR;
  if (customDir && customDir !== globalDir) {
    for (const name of names) files.push(join(customDir, name));
  }
  if (process.env.OPENCODE_CONFIG) files.push(process.env.OPENCODE_CONFIG);
  return files;
}

// The MCP server map one OpenCode config source contributes: the documented
// flat shape (`mcp: { name: {...} }`), the `mcp: { servers: {...} }` wrapper
// seen in the wild, and — for configs copied between CLIs — Claude's
// `mcpServers` bucket.
function opencodeServerMap(cfg) {
  if (!cfg || typeof cfg !== 'object') return {};
  const mcp = (cfg.mcp && typeof cfg.mcp === 'object') ? cfg.mcp : {};
  const { servers, ...flat } = mcp;
  return {
    ...(cfg.mcpServers && typeof cfg.mcpServers === 'object' ? cfg.mcpServers : {}),
    ...(servers && typeof servers === 'object' ? servers : {}),
    ...flat,
  };
}

// OpenCode MERGES config sources — "Configuration files are merged together,
// not replaced", later sources overriding earlier ones per SERVER NAME, with
// `enabled: false` keeping a server from starting (opencode.ai/docs/config,
// opencode.ai/docs/mcp-servers). So an any-file-contains scan is wrong in
// both directions: a project file that redefines the inherited `playwright`
// name — as a disable or as a replacement — must win over a user-level
// server, and a user-level server must still count when the project file
// never mentions it. Configs arrive in ASCENDING precedence order; the
// answer is about the EFFECTIVE entry.
function hasPlaywrightInMergedOpenCode(configs) {
  const effective = {};
  for (const cfg of configs) Object.assign(effective, opencodeServerMap(cfg));
  return Object.values(effective)
    .some((def) => def && def.enabled !== false && isPlaywrightServer(def));
}

// Resolve OMP's active agent directory using supported selectors:
// named profile ($OMP_PROFILE / $PI_PROFILE), explicit $PI_CODING_AGENT_DIR
// override, or the default ~/.omp/agent directory (docs/settings.md).
// Honors $PI_CONFIG_DIR when set, and treats 'default' as the default profile.
function ompAgentDir() {
  const profile = (process.env.OMP_PROFILE || process.env.PI_PROFILE || '').trim();
  const configDirName = process.env.PI_CONFIG_DIR || '.omp';
  if (profile && profile !== 'default') {
    return join(homedir(), configDirName, 'profiles', profile, 'agent');
  }
  if (process.env.PI_CODING_AGENT_DIR) {
    return process.env.PI_CODING_AGENT_DIR;
  }
  return join(homedir(), configDirName, 'agent');
}

// OMP's USER-level config sources for settings (docs/settings.md):
// active agent directory (config.yml > config.yaml > settings.json).
function ompUserConfigFiles() {
  const dir = ompAgentDir();
  const configYml = join(dir, 'config.yml');
  const configYaml = join(dir, 'config.yaml');
  if (existsSync(configYml)) return [configYml];
  if (existsSync(configYaml)) return [configYaml];
  return [join(dir, 'settings.json')];
}

function ompConfigEnableProjectState(cfg) {
  if (!cfg || typeof cfg !== 'object') return undefined;
  if (isExplicitlyDisabled(cfg['mcp.enableProjectConfig'])) return false;
  if (isExplicitlyEnabled(cfg['mcp.enableProjectConfig'])) return true;
  if (cfg.mcp && typeof cfg.mcp === 'object') {
    if (isExplicitlyDisabled(cfg.mcp.enableProjectConfig)) return false;
    if (isExplicitlyEnabled(cfg.mcp.enableProjectConfig)) return true;
  }
  return undefined;
}

// OMP allows disabling project MCP configuration via mcp.enableProjectConfig: false
// in project settings (.omp/config.yml, .omp/settings.json) or user settings
// (~/.omp/agent/config.yml, ~/.omp/settings.json). When false, project-level MCP
// Active project-level settings files for OMP (docs/settings.md).
// Excludes inactive variants like .omp/config.yaml that OMP does not load.
function ompProjectConfigFiles(root) {
  if (!root) return [];
  return [
    join(root, '.omp', 'config.yml'),
    join(root, '.omp', 'settings.json'),
  ];
}

// OMP honors mcp.enableProjectConfig (boolean)
// in project settings (.omp/config.yml, .omp/settings.json) or user settings
// (~/.omp/agent/config.yml, ~/.omp/settings.json). When false, project-level MCP
// sources are excluded from discovery (docs/mcp-config.md). Defaults to true.
function isOmpProjectMcpEnabled(root) {
  for (const file of ompProjectConfigFiles(root)) {
    const state = ompConfigEnableProjectState(readOmpConfigIfPresent(file));
    if (state !== undefined) return state;
  }
  for (const file of ompUserConfigFiles()) {
    const state = ompConfigEnableProjectState(readOmpConfigIfPresent(file));
    if (state !== undefined) return state;
  }
  return true;
}

// OMP gates user-level Claude Code MCP discovery behind enabledProviders ('claude'),
// but setting CLAUDE_CONFIG_DIR implicitly enables it unless disabledProviders lists it.
function isOmpClaudeUserProviderEnabled(root) {
  const configs = [
    ...ompProjectConfigFiles(root),
    ...ompUserConfigFiles(),
  ].map(readOmpConfigIfPresent).filter(Boolean);

  const listFor = (key) => {
    const cfg = configs.find((entry) => Array.isArray(entry[key]));
    return cfg ? cfg[key].map((p) => String(p).toLowerCase()) : [];
  };
  if (listFor('disabledProviders').includes('claude')) return false;
  if (listFor('enabledProviders').includes('claude')) return true;

  return Boolean(process.env.CLAUDE_CONFIG_DIR);
}

// OMP merges MCP definitions across user agent directory and project files.
// Project definitions override/shadow user definitions with the same name.
// User-level disabledServers acts as an explicit denylist across all sources (docs/mcp-config.md).
// User-level enabledServers force-enables a server definition unless disabledServers also lists it.
function getOmpEffectivePlaywright(root) {
  const projectEnabled = isOmpProjectMcpEnabled(root);
  const claudeUserEnabled = isOmpClaudeUserProviderEnabled(root);

  const claudeHome = process.env.CLAUDE_CONFIG_DIR ? process.env.CLAUDE_CONFIG_DIR : homedir();

  const sources = [
    // 1. Imported Claude user configs (gated on effective Claude user provider)
    ...(claudeUserEnabled ? [
      { file: join(claudeConfigDir(), 'mcp.json'), source: 'the Claude user mcp.json' },
      { file: join(claudeHome, '.claude.json'), source: 'the Claude user .claude.json' },
    ] : []),
    // 2. Project root fallback files (ascending precedence: .mcp.json, then mcp.json)
    ...(projectEnabled && root ? [
      { file: join(root, '.mcp.json'), source: '.mcp.json' },
      { file: join(root, 'mcp.json'), source: 'mcp.json' },
    ] : (projectEnabled ? [
      { file: '.mcp.json', source: '.mcp.json' },
      { file: 'mcp.json', source: 'mcp.json' },
    ] : [])),
    // 3. Imported Claude project configs (.claude/.mcp.json, then .claude/mcp.json)
    ...(projectEnabled && root ? [
      { file: join(root, '.claude', '.mcp.json'), source: '.claude/.mcp.json' },
      { file: join(root, '.claude', 'mcp.json'), source: '.claude/mcp.json' },
    ] : []),
    // 4. Native OMP user files (ascending precedence: .mcp.json, then mcp.json)
    { file: join(ompAgentDir(), '.mcp.json'), source: 'the OMP user mcp.json' },
    { file: join(ompAgentDir(), 'mcp.json'), source: 'the OMP user mcp.json', userOverrides: true },
    // 5. Native OMP project files (ascending precedence: .omp/.mcp.json, then .omp/mcp.json)
    ...(projectEnabled && root ? [
      { file: join(root, '.omp', '.mcp.json'), source: '.omp/.mcp.json' },
      { file: join(root, '.omp', 'mcp.json'), source: '.omp/mcp.json' },
    ] : (projectEnabled ? [
      { file: join('.omp', '.mcp.json'), source: '.omp/.mcp.json' },
      { file: join('.omp', 'mcp.json'), source: '.omp/mcp.json' },
    ] : [])),
  ];

  const disabledServers = new Set();
  const forcedEnabled = new Set();
  const effectiveServers = new Map();

  for (const { file, cfg: directCfg, source, userOverrides } of sources) {
    const cfg = directCfg ?? (file ? readConfigIfPresent(file) : null);
    if (!cfg || typeof cfg !== 'object') continue;

    if (userOverrides && Array.isArray(cfg.disabledServers)) {
      for (const s of cfg.disabledServers) {
        if (typeof s === 'string' && s.trim()) {
          disabledServers.add(s.trim().toLowerCase());
        }
      }
    }
    if (userOverrides && Array.isArray(cfg.enabledServers)) {
      for (const s of cfg.enabledServers) {
        if (typeof s === 'string' && s.trim()) {
          forcedEnabled.add(s.trim().toLowerCase());
        }
      }
    }

    const buckets = [cfg.mcpServers, cfg.mcp].filter((b) => b && typeof b === 'object');
    for (const bucket of buckets) {
      for (const [name, def] of Object.entries(bucket)) {
        if (def && typeof def === 'object') {
          effectiveServers.set(name.toLowerCase(), { name, def, source });
        }
      }
    }
  }

  for (const [name, entry] of effectiveServers.entries()) {
    if (disabledServers.has(name)) continue;
    const forced = forcedEnabled.has(name);
    if (!forced && (entry.def.enabled === false || entry.def.enabled === 'false')) continue;
    if (isPlaywrightServer(entry.def, { allowDisabled: forced })) {
      return entry;
    }
  }

  return null;
}


function readOmpConfigIfPresent(file) {
  if (!existsSync(file)) return null;
  try {
    const content = readFileSync(file, 'utf8');
    if (file.endsWith('.json') || file.endsWith('.jsonc')) {
      try {
        return parseConfigByExtension(file, content);
      } catch {
        return loadYaml(content);
      }
    }
    return loadYaml(content) ?? null;
  } catch {
    return null;
  }
}

function isExplicitlyDisabled(val) {
  if (val === false) return true;
  if (typeof val === 'string') {
    const s = val.trim().toLowerCase();
    return s === 'false' || s === 'off' || s === 'disabled' || s === 'no';
  }
  return false;
}

function isExplicitlyEnabled(val) {
  if (val === true) return true;
  if (typeof val === 'string') {
    const s = val.trim().toLowerCase();
    return s === 'true' || s === 'on' || s === 'enabled' || s === 'yes';
  }
  return false;
}

// Inspect a parsed OMP config object for its native browser setting.
// Returns false if browser is explicitly disabled, true if explicitly enabled,
// or undefined if unspecified.
function ompConfigBrowserState(cfg) {
  if (!cfg || typeof cfg !== 'object') return undefined;
  if (isExplicitlyDisabled(cfg.browser)) return false;
  if (isExplicitlyEnabled(cfg.browser)) return true;
  if (isExplicitlyDisabled(cfg['browser.enabled'])) return false;
  if (isExplicitlyEnabled(cfg['browser.enabled'])) return true;
  if (cfg.browser && typeof cfg.browser === 'object') {
    if (isExplicitlyDisabled(cfg.browser.enabled)) return false;
    if (isExplicitlyEnabled(cfg.browser.enabled)) return true;
  }
  return undefined;
}

// OMP drops recognized browser MCP servers (playwright, puppeteer, etc.) by
// default when its native browser prelude is active (browser.enabled defaults
// to true). A Playwright MCP definition is only usable if the native browser
// is explicitly disabled in project settings (.omp/config.yml, .omp/settings.json)
// or user settings (~/.omp/agent/config.yml, ~/.omp/settings.json).
function isOmpBrowserDisabled(root) {
  for (const file of ompProjectConfigFiles(root)) {
    const state = ompConfigBrowserState(readOmpConfigIfPresent(file));
    if (state !== undefined) return state === false;
  }
  for (const file of ompUserConfigFiles()) {
    const state = ompConfigBrowserState(readOmpConfigIfPresent(file));
    if (state !== undefined) return state === false;
  }
  return false;
}

// A Claude Code plugin declares its MCP servers in its own .mcp.json, which
// lives under the user's config dir - never in the project root. The
// project-root scan below therefore reports "not detected" on a machine where
// Playwright MCP is installed and working, which reads as though the MANDATORY
// offer-liveness verification in AGENTS.md cannot be met (#2752).
//
// Only ENABLED plugins count: an installed-but-disabled plugin still ships its
// manifest on disk, but registers no server. Claude Code merges enabledPlugins
// across user config (~/.claude/settings.json), project config
// (.claude/settings.json), and project-local config
// (.claude/settings.local.json), with project settings taking precedence over
// user settings (#3698). Enumeration is driven by the manifests rather than by
// walking plugins/cache, so a large cache costs nothing and disabled plugins
// are never read.
function isPlaywrightMcpFromPlugin(root) {
  const configDir = claudeConfigDir();

  const enabled = [
    readConfigIfPresent(join(configDir, 'settings.json'))?.enabledPlugins,
    root ? readConfigIfPresent(join(root, '.claude', 'settings.json'))?.enabledPlugins : null,
    root ? readConfigIfPresent(join(root, '.claude', 'settings.local.json'))?.enabledPlugins : null,
  ].filter((e) => e && typeof e === 'object')
    .reduce((acc, e) => Object.assign(acc, e), {});
  if (!Object.keys(enabled).length) return false;

  const installed = readConfigIfPresent(join(configDir, 'plugins', 'installed_plugins.json'))?.plugins;
  if (!installed || typeof installed !== 'object') return false;

  return Object.entries(enabled).some(([key, on]) => {
    if (on !== true) return false;
    const entries = Array.isArray(installed[key]) ? installed[key] : [];
    // Read installPath off the entry rather than destructuring it: the `= {}`
    // default only covers `undefined`, so a manifest carrying a literal `null`
    // entry - valid JSON, and what a half-written install leaves behind - threw
    // a TypeError here. That is worse than the miss this function was added to
    // fix (#2752): doctor dies before printing, so every other check vanishes
    // with it and the output looks like a broken install rather than one
    // unconfigured plugin.
    return entries.some((entry) => {
      const installPath = entry?.installPath;
      if (typeof installPath !== 'string' || !installPath) return false;
      return hasPlaywrightIn(readConfigIfPresent(join(installPath, '.mcp.json')), { bare: true });
    });
  });
}

// `claude mcp add` writes to .claude.json, not to any file in the checkout
// (#4392): user scope under the top-level `mcpServers`, local scope (the
// default) under `projects[<dir>].mcpServers`. The file lives in
// CLAUDE_CONFIG_DIR when that is set, otherwise directly in the home dir -
// beside ~/.claude/, not inside it.
//
// Claude Code on Windows can store one directory under both C:\... and C:/...
// with the server under only one of them, while process.cwd() returns the
// backslash form. So keys are compared with slashes and case folded, and every
// matching key counts, not just the first.
function isPlaywrightMcpFromClaudeJson(root) {
  const dir = process.env.CLAUDE_CONFIG_DIR || homedir();
  const cfg = readConfigIfPresent(join(dir, '.claude.json'));
  if (!cfg || typeof cfg !== 'object') return false;
  if (hasPlaywrightIn(cfg)) return true;
  if (!root || !cfg.projects || typeof cfg.projects !== 'object') return false;
  const norm = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
  const target = norm(root);
  return Object.entries(cfg.projects)
    .some(([key, project]) => norm(key) === target && hasPlaywrightIn(project));
}

function isPlaywrightMcpConfigured(root, activeCli) {
  const entry = MCP_CONFIGS.find((c) => c.cli === activeCli);
  if (!entry) return false; // known CLI but no MCP file mapping; caller warns
  // OpenCode reads every source below (user-level configs, then the project)
  // and MERGES them, so the question is about the effective server map — not
  // whether any one file mentions the package (hasPlaywrightInMergedOpenCode).
  if (entry.userFiles) {
    const configs = [
      ...entry.userFiles(),
      ...entry.files.map((rel) => join(root, ...rel.split('/'))),
    ].map(readConfigIfPresent).filter(Boolean);
    return hasPlaywrightInMergedOpenCode(configs);
  }
  if (activeCli === 'omp') {
    const effective = getOmpEffectivePlaywright(root);
    return effective !== null && isOmpBrowserDisabled(root);
  }
  const inProject = entry.files.some((rel) => {
    const file = join(root, ...rel.split('/'));
    return hasPlaywrightIn(readConfigIfPresent(file));
  });
  if (inProject) return true;
  // Gated behind the project scan, so an already-configured project pays no
  // extra I/O and non-plugin CLIs never touch the user config dir.
  return entry.plugins === true
    && (isPlaywrightMcpFromClaudeJson(root) || isPlaywrightMcpFromPlugin(root));
}

// CLI resolution: --cli flag > $CAREER_OPS_CLI > .env (CAREER_OPS_CLI=...) >
// runtime marker (OPENCODE=1 → opencode) > checkout hint (an opencode.json
// with no Claude MCP config beside it) > one other CLI executable on PATH >
// several other CLIs on PATH (unset, so the user picks) > default ('claude').
// An unknown value at ANY explicit level returns the sentinel 'unknown' and
// produces no MCP output — CLI-dependent checks are silently skipped. .env
// parsing is best-effort: missing file is normal, malformed values are caught
// per call below.
//
// The binary name is the id, except Antigravity, which installs as `agy`.
// Same mapping the web UI uses. A new CLI in AGENT_CLIS is found under its id.
const CLI_BIN = Object.fromEntries(AGENT_CLIS.map((id) => [id, id === 'antigravity' ? 'agy' : id]));

// Executable file on PATH. stat + X_OK, never a --version spawn: doctor stays
// offline, and a CLI that prints to the terminal on startup must not run just
// because setup is being checked. A directory, or a file without the execute
// bit, is not an install.
function cliExecutableOnPath(bin) {
  const pathEnv = process.env.PATH;
  // Unset or empty PATH means nothing is installed. A hermetic run keeps the
  // claude default. A set PATH can still contain an empty component.
  if (!pathEnv) return false;
  const exts = process.platform === 'win32'
    ? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map((ext) => ext.trim()).filter(Boolean)]
    : [''];
  for (const dir of pathEnv.split(delimiter)) {
    // POSIX searches the current directory for an empty component (a leading,
    // trailing, or doubled separator). Windows does not.
    const searchDir = dir || (process.platform === 'win32' ? '' : '.');
    if (!searchDir) continue;
    for (const ext of exts) {
      const candidate = join(searchDir, bin + ext);
      try {
        if (!statSync(candidate).isFile()) continue;
        accessSync(candidate, constants.X_OK);
        return true;
      } catch {
        // Absent, or present but not executable.
      }
    }
  }
  return false;
}

// PATH is only consulted when nothing else named a CLI. claude on PATH, or no
// known CLI at all, keeps the historical default so a hermetic run (CI, a
// machine with nothing installed) does not change. One other CLI is unambiguous.
// Two or more, with claude absent, is a guess — report them and ask for
// remember-cli instead of checking Claude's plugin.
function resolveCliFromPath() {
  const present = AGENT_CLIS.filter((id) => cliExecutableOnPath(CLI_BIN[id]));
  if (present.includes('claude') || present.length === 0) {
    return { cli: 'claude', source: 'default' };
  }
  if (present.length === 1) {
    return { cli: present[0], source: 'path' };
  }
  return {
    cli: 'unset',
    source: 'path',
    warning: `Several agent CLIs are on PATH (${present.join(', ')}) and none is selected. Pick one with \`node remember-cli.mjs <cli>\`.`,
  };
}

function resolveActiveCli() {
  if (cliFlag !== undefined && cliFlag !== null) {
    if (!AGENT_CLIS.includes(cliFlag)) {
      return { cli: 'unknown', source: 'flag', warning: `Unknown --cli "${cliFlag}". Valid: ${AGENT_CLIS.join(', ')}.` };
    }
    return { cli: cliFlag, source: 'flag' };
  }
  if (process.env.CAREER_OPS_CLI) {
    if (!AGENT_CLIS.includes(process.env.CAREER_OPS_CLI)) {
      return { cli: 'unknown', source: 'env', warning: `CAREER_OPS_CLI="${process.env.CAREER_OPS_CLI}" is not a recognized CLI. Valid: ${AGENT_CLIS.join(', ')}.` };
    }
    return { cli: process.env.CAREER_OPS_CLI, source: 'env' };
  }
  // .env is best-effort: missing file → fall through to default. dotenv does
  // not throw on a missing path when `quiet: true`, so no try/catch is needed.
  // The load must not make a .env-only CAREER_OPS_PROFILE look like a shell one.
  const hadProfileOverride = hasProfileOverride();
  dotenv.config({ path: join(projectRoot, '.env'), quiet: true });
  if (!hadProfileOverride) delete process.env.CAREER_OPS_PROFILE;
  if (process.env.CAREER_OPS_CLI) {
    if (!AGENT_CLIS.includes(process.env.CAREER_OPS_CLI)) {
      return { cli: 'unknown', source: '.env', warning: `CAREER_OPS_CLI in .env is not a recognized CLI. Valid: ${AGENT_CLIS.join(', ')}.` };
    }
    return { cli: process.env.CAREER_OPS_CLI, source: '.env' };
  }
  // Runtime marker: OpenCode stamps OPENCODE=1 on every process it runs.
  // Without this, doctor started from inside OpenCode falls through to the
  // claude default and then scans Claude's MCP configs — reporting "Playwright
  // MCP not detected" on a machine where it is configured in opencode.json.
  // Exact match: a user who sets OPENCODE=0 to opt out keeps the default.
  if (process.env.OPENCODE === '1') {
    return { cli: 'opencode', source: 'auto' };
  }
  // Checkout hint: an opencode.json/opencode.jsonc in the launch directory
  // with NO Claude MCP config beside it means this checkout is driven from
  // OpenCode (shell runs inside OpenCode are already caught by the marker
  // above; a checkout carrying .mcp.json/.claude config stays on the claude
  // default, so dual-config checkouts behave exactly as before).
  const cwd = process.cwd();
  const hasOpenCodeCfg = ['opencode.json', 'opencode.jsonc'].some((f) => existsSync(join(cwd, f)));
  const hasClaudeCfg = ['.mcp.json', '.claude/settings.json', '.claude/settings.local.json']
    .some((f) => existsSync(join(cwd, f)));
  if (hasOpenCodeCfg && !hasClaudeCfg) {
    return { cli: 'opencode', source: 'auto' };
  }
  return resolveCliFromPath();
}

function checkPlaywrightMcp(root, activeCli) {
  // Unknown CLI (typo / not in AGENT_CLIS), or several CLIs on PATH and no
  // selection yet. Neither has a config to scan, and neither should be handed
  // another CLI's install command.
  if (activeCli === 'unknown' || activeCli === 'unset') return null;

  // Known CLI without an MCP file mapping.
  const entry = MCP_CONFIGS.find((c) => c.cli === activeCli);
  if (!entry) {
    return {
      warn: true,
      label: `Playwright MCP check skipped for CLI: ${activeCli}`,
      fix: [
        `doctor doesn't scan MCP configs for "${activeCli}". Verify your Playwright MCP setup manually for that CLI.`,
        `CLIs with scanning today: ${MCP_CONFIGS.map((c) => c.cli).join(', ')}.`,
      ],
    };
  }
  if (isPlaywrightMcpConfigured(root, activeCli)) {
    return { pass: true, label: `Playwright MCP server configured (${activeCli})` };
  }
  // Active CLI is known (flag/env/.env) but its MCP isn't configured or is filtered.
  if (activeCli === 'omp') {
    const effective = getOmpEffectivePlaywright(root);
    if (effective) {
      return {
        warn: true,
        label: `Playwright MCP server defined in ${effective.source}, but filtered by OMP native browser`,
        fix: [
          'OMP drops browser MCP servers by default when its native browser is active.',
          'To use Playwright MCP in OMP, disable the built-in browser by setting "browser": { "enabled": false } in .omp/settings.json or .omp/config.yml (or run "omp config set browser.enabled false").',
        ],
      };
    }
  }
  return {
    warn: true,
    label: `Playwright MCP tools not detected (active CLI: ${activeCli})`,
    fix: [
      entry.plugins
        ? `No project-level MCP config, no server in .claude.json (~/.claude.json, or $CLAUDE_CONFIG_DIR/.claude.json when set), and no enabled plugin providing one, was detected for ${activeCli}.`
        : `No project-level MCP config was detected for ${activeCli}.`,
      activeCli === 'opencode'
        ? 'Add the Playwright MCP server to opencode.json (project; see opencode.example.json) or to the global ~/.config/opencode/opencode.json[c] (or $OPENCODE_CONFIG), or pass --cli <name> if you actually run a different CLI.'
        : activeCli === 'omp'
          ? 'Add the Playwright MCP server to .omp/mcp.json, or pass --cli <name> if you actually run a different CLI.'
          : `Add the Playwright MCP server to your ${activeCli} config, or install a plugin that provides it (e.g. /plugin install playwright@claude-plugins-official).`,
    ],
  };
}

// Report which scan/JD extractor is active (config/profile.yml → scan.extractor).
// `mcp` (default) uses the browser MCP; `cli` uses browser-extract.mjs. When cli
// is selected but the helper is missing, the modes fall back to MCP — surface
// that as a warning, never a failure.
function checkScanExtractor(root) {
  const mode = resolveExtractorMode(profilePathFor(root));
  if (mode === 'cli') {
    if (existsSync(join(__dirname, 'browser-extract.mjs'))) {
      return { pass: true, label: 'Scan extractor: cli (browser-extract.mjs)' };
    }
    return {
      warn: true,
      label: 'Scan extractor: cli set, but browser-extract.mjs is missing — falls back to MCP',
      fix: ['Restore browser-extract.mjs, or set `scan.extractor: mcp` in config/profile.yml.'],
    };
  }
  return { pass: true, label: 'Scan extractor: mcp (default)' };
}

// Single source of truth for the four user-layer prerequisites (the list
// AGENTS.md "First Run" documents). BOTH the human checklist (`checkPrereq`)
// and the machine-readable cold-start state (`onboardingState`) derive from
// THIS array, so they cannot drift. Paths use "/" and are split for join().
const USER_LAYER_PREREQS = [
  {
    path: 'cv.md',
    blocksExit: true,
    fix: [
      'Create cv.md in the project root with your CV in markdown',
      'See examples/cv-example.md for the format',
    ],
  },
  {
    path: 'config/profile.yml',
    blocksExit: true,
    emptyImpact: 'evaluations and scans have none of your targeting',
    fix: [
      'Run: node onboard-write.mjs --answers <answers.json> [--target <data-root>]',
      'Run: cp config/profile.example.yml config/profile.yml',
      'Then edit it with your details',
    ],
  },
  {
    path: 'modes/_profile.md',
    fix: [
      'Run: cp modes/_profile.template.md modes/_profile.md',
      'Then customize your archetypes / targeting narrative',
    ],
  },
  {
    path: 'portals.yml',
    env: 'CAREER_OPS_PORTALS',
    emptyImpact: 'scans have no companies or filters',
    fix: [
      'Run: cp templates/portals.example.yml portals.yml',
      'Then customize with your target companies',
    ],
  },
];

const CLI_NAMES = 'claude, codex, opencode, qwen, agy or grok';

// The lane variables (CAREER_OPS_PORTALS, CAREER_OPS_PIPELINE) move one file
// out of the data root, and the scanner reads them (path-resolver.mjs), so
// doctor has to look where a scan would.
const PORTALS_PREREQ = USER_LAYER_PREREQS.find(({ path }) => path === 'portals.yml');

function prereqFile(root, { path, env }) {
  if (path === 'config/profile.yml') return profilePathFor(root);
  return (env && process.env[env]) || join(root, ...path.split('/'));
}

function prereqPresent(root, prereq) {
  return existsSync(prereqFile(root, prereq));
}

const EMPTY_YAML_REASON = 'has no settings (empty or only comments)';

function emptyYamlPrereqs(root) {
  return USER_LAYER_PREREQS.filter((prereq) => {
    if (!prereq.emptyImpact || !prereqPresent(root, prereq)) return false;
    try {
      return !hasYamlContent(readFileSync(prereqFile(root, prereq), 'utf-8'));
    } catch {
      return false;
    }
  });
}

// The root's .env names a profile. scan.mjs, the evaluators, openai-tailor.mjs and
// openrouter-runner.mjs load a .env, and all four follow CAREER_OPS_PROFILE once
// it is loaded (scan.mjs from the data root, the evaluators and openai-tailor.mjs
// from a working-directory config(), openrouter-runner.mjs from the code root).
// The other readers load none. That is a split whether or not a data-root profile exists, so it is
// reported either way. A shell value shadows the .env, so it is not a split then.
function checkEnvFileProfile(root) {
  const fromEnvFile = explicitTarget || hasProfileOverride() ? null : profileFromEnvFile(root);
  if (!fromEnvFile) return null;
  return {
    warn: true,
    label: `.env sets CAREER_OPS_PROFILE=${fromEnvFile}, but of the profile readers, only scan.mjs, the evaluators, openai-tailor.mjs and openrouter-runner.mjs load .env; cv-sync-check, generate-pdf, upskill and the other readers use ${profilePathFor(root)}`,
    fix: [`Export CAREER_OPS_PROFILE=${fromEnvFile} in your shell so every command reads the same file, or remove it from .env`],
  };
}

function checkPrereq(prereq) {
  const { path, fix } = prereq;
  if (emptyYamlPrereqs(projectRoot).includes(prereq)) {
    return { warn: true, label: `${path} ${EMPTY_YAML_REASON}; ${prereq.emptyImpact}`, fix };
  }
  if (prereqPresent(projectRoot, prereq)) {
    return { pass: true, label: `${path} found` };
  }
  if (path === 'config/profile.yml' && !explicitTarget && hasProfileOverride()) {
    const named = profilePathFor(projectRoot);
    return {
      pass: false,
      label: `${path} not found (CAREER_OPS_PROFILE names ${named})`,
      fix: [`Create ${named}, or unset CAREER_OPS_PROFILE to use the profile in the data root`],
    };
  }
  const fromEnvFile = path === 'config/profile.yml' && !explicitTarget ? profileFromEnvFile(projectRoot) : null;
  if (fromEnvFile) {
    return {
      pass: false,
      label: `${path} not found (.env sets CAREER_OPS_PROFILE=${fromEnvFile}, but most commands never load .env)`,
      fix: [`Export CAREER_OPS_PROFILE=${fromEnvFile} in your shell so every command sees it, or remove it from .env and put the profile at ${profilePathFor(projectRoot)}`],
    };
  }
  // cv.md and config/profile.yml are the files a run cannot score without.
  // The other two stay warnings: a checkout can be healthy and still unfinished.
  if (prereq.blocksExit) {
    return { pass: false, label: `${path} not found (user setup required)`, fix };
  }
  return { warn: true, label: `${path} not found (user setup required)`, fix };
}

function checkFonts() {
  const fontsDir = join(__dirname, 'fonts');
  if (!existsSync(fontsDir)) {
    return {
      pass: false,
      label: 'fonts/ directory not found',
      fix: 'The fonts/ directory is required for PDF generation',
    };
  }
  try {
    const files = readdirSync(fontsDir);
    if (files.length === 0) {
      return {
        pass: false,
        label: 'fonts/ directory is empty',
        fix: 'The fonts/ directory must contain font files for PDF generation',
      };
    }
  } catch {
    return {
      pass: false,
      label: 'fonts/ directory not readable',
      fix: 'Check permissions on the fonts/ directory',
    };
  }
  return { pass: true, label: 'Fonts directory ready' };
}

function checkAutoDir(name) {
  const dirPath = join(projectRoot, name);
  if (existsSync(dirPath)) {
    return { pass: true, label: `${name}/ directory ready` };
  }
  try {
    mkdirSync(dirPath, { recursive: true });
    return { pass: true, label: `${name}/ directory ready (auto-created)` };
  } catch {
    return {
      pass: false,
      label: `${name}/ directory could not be created`,
      fix: `Run: mkdir ${name}`,
    };
  }
}

// --strict only: probe every portals.yml entry (tracked_companies and job_boards)
// so a typo'd slug or a dead board — either of which 404s silently on scans —
// surfaces here. Skipped gracefully when portals.yml is absent. Delegates to
// verify-portals.mjs so there is one probing implementation. Network-bound,
// hence opt-in.
async function checkPortalSlugs(root) {
  const portalsPath = prereqFile(root, PORTALS_PREREQ);
  if (!existsSync(portalsPath)) {
    return { pass: true, label: 'Portal entries: no portals.yml yet (skipped)' };
  }
  try {
    const { verifyPortalsFile } = await import('./verify-portals.mjs');
    const { loadProviders } = await import('./providers/_registry.mjs');
    const { mergeProviderPlugins } = await import('./plugins/_engine.mjs');
    const { makeHttpCtx } = await import('./providers/_http.mjs');
    // Load the same provider plugins the scanner uses so tier 2 runs: without
    // them every non-ATS entry (all job_boards, plus any Workday/Avature/…
    // tracked_companies) resolves to an un-actionable "skipped" and a broken
    // one would never reach "missing" — a false pass.
    const providers = await loadProviders(join(__dirname, 'providers'));
    // verify-portals.mjs's own CLI folds in enabled provider plugins (#4026);
    // this caller builds the map itself, so it must too, or a plugin-provider
    // entry is probed as unclaimed. No-op when config/plugins.yml is absent.
    await mergeProviderPlugins(providers, { root: __dirname, dataRoot: root });
    const { results } = await verifyPortalsFile(portalsPath, {
      providers,
      httpCtx: makeHttpCtx(),
    });
    // Only 'missing' is a failure — a live probe that 404'd. 'skipped' (no
    // provider claimed the entry) is left informational here, matching
    // `verify-portals --strict`, which also fails only on 'missing'; an entry
    // that resolves solely through a local parser is skipped by this path by
    // design and must not fail the check.
    const unresolved = results.filter((r) => r.status === 'missing');
    if (unresolved.length === 0) {
      return { pass: true, label: 'All portals.yml entries resolve' };
    }
    return {
      pass: false,
      label: `${unresolved.length} portals.yml entr${unresolved.length === 1 ? 'y' : 'ies'} do not resolve`,
      fix: [
        ...unresolved.map((r) => {
          const src = r.ats ? `${r.ats}/${r.slug}` : (r.provider || '?');
          let line = `${r.name}: ${src} — ${r.reason || 'unresolved'}`;
          if (r.suggested) line += ` → try ${r.suggested.ats}/${r.suggested.slug}`;
          return line;
        }),
        'Probe variants with: node verify-portals.mjs --add "<company>"',
      ],
    };
  } catch (err) {
    return { warn: true, label: `Portal entry check skipped: ${err.message}` };
  }
}

const PIPELINE_SKELETON = `# Pipeline — Pending URLs

Paste job URLs below as \`- [ ] {url}\` then run \`/career-ops pipeline\`.

## Pending

## Processed
`;

function checkPipelineFile() {
  const filePath = process.env.CAREER_OPS_PIPELINE || join(projectRoot, 'data', 'pipeline.md');
  const shown = process.env.CAREER_OPS_PIPELINE ? filePath : 'data/pipeline.md';
  if (existsSync(filePath)) {
    return { pass: true, label: `${shown} ready` };
  }
  try {
    mkdirSync(dirname(filePath), { recursive: true });
    writeFileSync(filePath, PIPELINE_SKELETON, 'utf-8');
    return { pass: true, label: `${shown} ready (auto-created)` };
  } catch {
    return {
      pass: false,
      label: `${shown} could not be created`,
      fix: process.env.CAREER_OPS_PIPELINE
        ? `Run: mkdir -p "$(dirname "$CAREER_OPS_PIPELINE")" && touch "$CAREER_OPS_PIPELINE"`
        : 'Run: mkdir -p data && touch data/pipeline.md',
    };
  }
}

// Discover plugins + their non-secret config block, synchronously. Used by both
// the human check and the --json onboarding state.
// A parse failure is REPORTED, not folded into {}. An unreadable config and a
// config with nothing enabled produced the same empty object, so doctor — the
// one tool whose job is to say what is wrong — answered "off" for every plugin
// the user had actually switched on, and said nothing about why.
//
// Returns the config plus the parse error, so callers can tell the two apart.
function readPluginConfigSync(root) {
  const cfgPath = join(root, 'config', 'plugins.yml');
  if (!existsSync(cfgPath)) return { cfg: {}, error: null };
  try {
    return { cfg: loadYaml(readFileSync(cfgPath, 'utf8')) || {}, error: null };
  } catch (err) {
    return { cfg: {}, error: String(err.message).split('\n')[0] };
  }
}

// Plugin layer health: list discovered plugins + whether each enabled one's keys
// are present. WARN-not-FAIL so a half-configured plugin never blocks setup.
// plugins.mjs loads the data root .env before it checks plugin keys. Doctor
// skips that load when --cli or CAREER_OPS_CLI picks the CLI, so the file is
// read here without touching process.env.
function pluginState(manifest, cfg) {
  const s = pluginStatus(manifest, cfg);
  let fileEnv = {};
  try { fileEnv = dotenv.parse(readFileSync(join(projectRoot, '.env'))); } catch { /* no .env */ }
  const missingEnv = s.missingEnv.filter((name) => !fileEnv[name]);
  return { ...s, missingEnv, enabled: s.configured && missingEnv.length === 0 };
}

function activePlugins(root) {
  return discoverPlugins(pluginRoots(root), resolveSuccessorIds(root));
}

function checkPlugins(root) {
  let manifests;
  // Plugin files live in the code checkout. config/plugins.yml lives in the
  // data root. Reading plugins from the data root reports "none installed"
  // while `node plugins.mjs list` on the same install lists them. --target
  // sets codeRoot to the directory it was given.
  try { manifests = activePlugins(codeRoot); } catch { return { pass: true, label: 'Plugins: none' }; }
  if (manifests.length === 0) return { pass: true, label: 'Plugins: none installed' };
  const { cfg, error: cfgError } = readPluginConfigSync(root);
  // Reported before the per-plugin lines, because when the config did not parse
  // every one of those lines is derived from an empty object and says "off"
  // regardless of what the user configured.
  if (cfgError) {
    return {
      warn: true,
      label: `Plugins: config/plugins.yml did not parse (${cfgError}) — every plugin below reads as off`,
      fix: ['Fix the YAML in config/plugins.yml. Until then `plugins.mjs enable/disable` will refuse to write to it.'],
    };
  }
  const lines = [];
  const fixes = [];
  for (const m of manifests) {
    const s = pluginState(m, cfg);
    lines.push(`${m.id} (${s.enabled ? 'enabled' : s.configured ? `missing ${s.missingEnv.join(', ')}` : 'off'})`);
    if (s.configured && s.missingEnv.length) {
      const paste = linkedInPluginFix(m.id);
      if (paste) fixes.push(paste);
      fixes.push(`${m.id}: add ${s.missingEnv.join(', ')} to .env`);
    }
  }
  const label = `Plugins: ${lines.join(', ')}`;
  return fixes.length ? { warn: true, label, fix: fixes } : { pass: true, label };
}

// profile.yml steers scoring targets, output language, spend tier, CV format and
// location policy — and the existence check above is all that ever looked at it.
// Every reader does `profile?.language?.output` and takes the fallback when the
// key is missing, which is indistinguishable from the key being MISSPELLED. So
// `langauge: {output: ja}` produces English output and no signal anywhere.
//
// WARN, never FAIL, like the plugin check below it: an unknown key is a typo,
// not a broken install, and refusing to run would be a worse answer than naming
// it.
// cv.md exists but in a shape the CV checks cannot read (#4879). The prereq
// check only proves the file is there; cv-title-check.mjs and
// verify-cv-structure.mjs locate jobs through lib/cv-markdown.mjs, and a cv.md
// whose Experience section they cannot find makes both run against nothing.
// They do say so per run, but only once a tailored CV is already being built;
// this says it at setup time. A warning, never a failure: cv.md is the user's
// file and career-ops still works with it.
function checkCvShape(root) {
  const cvPath = join(root, 'cv.md');
  if (!existsSync(cvPath)) return null;   // the prereq check owns "absent"
  let text;
  try {
    text = readFileSync(cvPath, 'utf-8');
  } catch (err) {
    return { warn: true, label: `cv.md could not be read (${err.message})` };
  }
  const sections = findExperienceSections(text);
  // Same entry rule verify-cv-structure.mjs parses with, so a stray
  // "### Notes" under Experience is not counted as a job.
  const entries = sections.flat().filter((line) => parseCompanyHeading(line) !== null).length;
  if (entries > 0) {
    return { pass: true, label: `cv.md: Experience section recognized (${entries} entr${entries === 1 ? 'y' : 'ies'})` };
  }
  return {
    warn: true,
    label: sections.length === 0
      ? 'cv.md: no Experience section the CV checks recognize — title and structure checks will not run'
      : 'cv.md: Experience section has no "### Company — Location" entries — title and structure checks will not run',
    fix: [
      ...(sections.length === 0 ? [`Name the section ${EXPERIENCE_HEADING_NAMES.map((n) => `"## ${n}"`).join(', ')}`] : []),
      'Start each job with "### Company — Location", then a bold **Title** line, then a dates line (see examples/cv-example.md)',
    ],
  };
}

// The user's own templates under data/templates/ (#5777). Advisory: a template
// that stopped resolving, or lost a slot every shipped variant keeps, is named
// here instead of surfacing at render time.
function checkUserTemplates(root) {
  let findings;
  try {
    // The shipped baseline is the checkout being diagnosed, when it has one. A
    // --target that holds only user data has no templates/, so the executing
    // checkout's own default stays the baseline there.
    const shipped = join(codeRoot, 'templates');
    findings = auditUserTemplates({
      userDir: join(root, 'data', 'templates'),
      ...(existsSync(join(shipped, 'cv-template.html')) ? { dir: shipped } : {}),
    });
  } catch (err) {
    return { warn: true, label: `data/templates: could not be audited (${err.message})` };
  }
  if (findings.length === 0) return null;
  return {
    warn: true,
    label: `data/templates: ${findings.length} template${findings.length === 1 ? '' : 's'} need${findings.length === 1 ? 's' : ''} attention`,
    fix: findings.flatMap((f) => f.problems.map((p) => {
      const file = f.path.split(/[\\/]/).pop();
      return p.type === 'invalid' || p.type === 'name'
        ? `${file}: ${p.detail}`
        : `${file}: lacks ${p.missing.map((m) => `{{${m}}}`).join(', ')}, which every shipped ${f.kind === 'cv' ? 'CV' : 'cover-letter'} variant keeps`;
    })),
  };
}

function checkProfileShape(root) {
  const profilePath = profilePathFor(root);
  if (!existsSync(profilePath)) return null;   // the prereq check owns "absent"
  // Name the file actually read. With --target that is the target's
  // profile, not the CAREER_OPS_PROFILE path the default label comes from.
  // A CAREER_OPS_PROFILE outside the root keeps its absolute path, since a
  // ../../ path says less than the one the user exported.
  const rel = relative(root, profilePath);
  const label = rel && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) ? rel.split(sep).join('/') : profilePath;
  let findings;
  try {
    const example = existsSync(EXAMPLE_PATH) ? readFileSync(EXAMPLE_PATH, 'utf-8') : '';
    findings = validateProfile(readFileSync(profilePath, 'utf-8'), example, label).findings;
  } catch (err) {
    return { warn: true, label: `${label} could not be read (${err.message})` };
  }
  const actionable = findings.filter((f) => f.level !== 'info');
  if (actionable.length === 0) return { pass: true, label: `${label}: shape OK` };
  // A bad value sits under a key the readers know. Calling it an unrecognized
  // key would send the user to delete a setting they only need to correct.
  const badValues = actionable.some((f) => f.code === 'bad-value');
  const otherIssues = actionable.some((f) => f.code !== 'bad-value');
  const why = badValues && otherIssues
    ? 'unrecognized keys and invalid values have no effect'
    : badValues
      ? 'known settings hold values the readers ignore'
      : 'settings under an unrecognized key have no effect';
  return {
    warn: true,
    label: `${label}: ${actionable.length} issue${actionable.length === 1 ? '' : 's'} — ${why}`,
    fix: actionable.map((f) => f.message),
  };
}

async function main() {
  console.log('\ncareer-ops doctor');
  console.log('================\n');

  const browserProcessEnv = snapshotBrowserProcessEnv();
  const { cli: activeCli, source: cliSource, warning: cliWarning } = resolveActiveCli();
  // A data root inside the plugin copy is deleted on the next plugin update,
  // so the checks that create User Layer folders and files skip it.
  const pluginInstall = checkPluginInstall(projectRoot);
  const userLayerWritable = !pluginInstall || pluginInstall.pass;

  const checks = [
    checkNodeVersion(),
    // Devuelve null salvo que el CLI activo sea Gemini: el filter(Boolean) de
    // abajo lo descarta, así que ningún otro usuario ve un check que no le toca.
    geminiNodeFloor(activeCli, process.versions.node),
    checkBillingSource(),
    checkDependencies(),
    pluginInstall,
    checkWebDependencies(),
    checkTrackedBakFiles(codeRoot),
    checkSkillEntrypoints(codeRoot),
    await checkPlaywright(browserProcessEnv),
    checkPlaywrightMcp(process.cwd(), activeCli),
    checkScanExtractor(projectRoot),
    ...USER_LAYER_PREREQS.map(checkPrereq),
    checkFonts(),
    checkPersonalization(projectRoot),
    checkProfileShape(projectRoot),
    checkTitleFilterConflicts(projectRoot),
    checkCvShape(projectRoot),
    checkUserTemplates(projectRoot),
    ...(userLayerWritable
      ? [checkAutoDir('data'), checkPipelineFile(), checkAutoDir('output'), checkAutoDir('reports')]
      : []),
    checkPlugins(projectRoot),
    { pass: true, label: LINKEDIN_DOCTOR_LABEL },
    // checkPrereq already explains the .env case when the profile is missing.
    prereqPresent(projectRoot, { path: 'config/profile.yml' }) ? checkEnvFileProfile(projectRoot) : null,
  ].filter(Boolean);

  // Network-bound portals.yml reachability probe — only under --strict.
  if (STRICT) {
    checks.push(await checkPortalSlugs(projectRoot));
  }

  let failures = 0;
  let warnings = 0;

  if (cliWarning) {
    warnings++;
    console.log(`${yellow('⚠')} ${cliWarning}`);
  }

  for (const result of checks) {
    const fixes = Array.isArray(result.fix) ? result.fix : result.fix ? [result.fix] : [];
    if (result.warn) {
      warnings++;
      console.log(`${yellow('⚠')} ${result.label}`);
      for (const hint of fixes) {
        console.log(`  ${dim('→ ' + hint)}`);
      }
    } else if (result.pass) {
      console.log(`${green('✓')} ${result.label}`);
    } else {
      failures++;
      console.log(`${red('✗')} ${result.label}`);
      for (const hint of fixes) {
        console.log(`  ${dim('→ ' + hint)}`);
      }
    }
  }

  console.log('');
  const setupMissing = USER_LAYER_PREREQS.filter((prereq) => !prereqPresent(projectRoot, prereq));
  const setupNote = setupMissing.length > 0
    ? `Setup is not finished (${setupMissing.length} file${setupMissing.length === 1 ? '' : 's'} missing: ${setupMissing.map(({ path }) => path).join(', ')})`
    : null;
  // --target forces codeRoot and projectRoot to the same path, so equality
  // alone would drop --target and let onboard-write follow getCareerOpsRoot().
  const quoteOnboard = process.platform === 'win32' ? quotePowerShell : quoteSh;
  const onboardCmd = !explicitTarget && projectRoot === codeRoot
    ? 'node onboard-write.mjs --answers <answers.json>'
    : `node ${quoteOnboard(join(codeRoot, 'onboard-write.mjs'))} --answers <answers.json> --target ${quoteOnboard(projectRoot)}`;
  // onboard-write only writes config/profile.yml and portals.yml, so cv.md
  // gets its own step instead of a command that would not create it.
  const setupSteps = [];
  if (setupMissing.some(({ path }) => path === 'config/profile.yml' || path === 'portals.yml')) {
    setupSteps.push(`Run \`${onboardCmd}\` from the career-ops checkout.`);
  }
  if (setupMissing.some(({ path }) => path === 'cv.md')) {
    setupSteps.push(`Add your CV as cv.md in ${projectRoot} (examples/cv-example.md shows the format).`);
  }
  setupSteps.push(`Open your AI CLI in the checkout (${CLI_NAMES}) and it walks you through setup by chatting.`);
  const setupHint = setupSteps.join(' ');
  if (failures > 0) {
    console.log(`Result: ${failures} issue${failures === 1 ? '' : 's'} found. Fix them and run \`npm run doctor\` again.`);
    if (setupNote) {
      console.log(`${setupNote}.`);
      console.log(setupHint);
    }
    process.exit(1);
  } else {
    const warnNote = warnings > 0 ? ` (${warnings} warning${warnings === 1 ? '' : 's'} — see above)` : '';
    if (setupNote) {
      console.log(`Result: ${setupNote}${warnNote}.`);
      console.log(setupHint);
    } else {
      console.log(`Result: All checks passed${warnNote}. You're ready to go! Open your AI CLI in this folder (${CLI_NAMES}) to start.`);
    }
    console.log('');
    console.log('Join the community: https://discord.gg/8pRpHETxa4');
    process.exit(0);
  }
}

// Personalization files that silently degrade output while still passing the
// existence check. `modes/_custom.md` is deliberately absent: it holds optional
// procedural house rules, so shipping it unedited is a valid end state. These
// two are not —
//   _profile.md unedited feeds the TEMPLATE AUTHOR's archetypes and North Star
//     into every A-F evaluation, so offers are scored against a stranger.
//   _brief.md unedited hands the triage first pass literal `{placeholders}`
//     instead of the candidate's archetypes, comp floor and hard DQ criteria.
// Explicit onboarding copies both from their templates, so existence alone
// tells us nothing about personalization — only the CONTENT does.
const PERSONALIZATION_FILES = [
  {
    path: 'modes/_profile.md',
    template: 'modes/_profile.template.md',
    impact: 'evaluations score against the template author\'s targeting, not yours',
  },
  {
    path: 'modes/_brief.md',
    template: 'modes/_brief.template.md',
    impact: 'triage reads literal {placeholders} instead of your archetypes',
  },
];

// Placeholder tokens the template itself ships, e.g. `{Your Name}`. Comparing
// against the template's own set (rather than any `{...}` run) keeps braces the
// user legitimately wrote — a code snippet, a JSON example — from false-firing.
function templatePlaceholders(text) {
  return new Set(text.match(/\{[^{}\n]{2,60}\}/g) || []);
}

// Returns [{ path, reason }] for personalization files still carrying template
// content. Missing files are NOT reported here — that is `missing`'s job.
function unpersonalizedFiles(root) {
  const out = [];
  for (const { path, template, impact } of PERSONALIZATION_FILES) {
    const targetPath = join(root, ...path.split('/'));
    // The checkout's template first, as the web reads it, so doctor and the web
    // agree on which sections are still template text even when a separate data
    // root holds an older copy. The data root's copy only covers a checkout
    // that ships none (#5340).
    const checkoutTemplatePath = join(__dirname, ...template.split('/'));
    const templatePath = existsSync(checkoutTemplatePath) ? checkoutTemplatePath : join(root, ...template.split('/'));
    if (!existsSync(targetPath) || !existsSync(templatePath)) continue;
    let target, tpl;
    try {
      target = readFileSync(targetPath, 'utf-8');
      tpl = readFileSync(templatePath, 'utf-8');
    } catch {
      continue; // unreadable → let the existence checks speak
    }
    // An empty file is neither identical to the template nor carries a
    // placeholder, so it would pass as personalized while the web asks for
    // every section (#5340).
    if (!target.trim()) {
      out.push({ path, reason: 'is empty', impact });
      continue;
    }
    if (path === 'modes/_profile.md') {
      // A _profile.md written in the user's own structure, without any of the
      // eight ## Your … headings, is quiet here rather than reported as missing
      // every section (#5126). Missing prerequisites speak for absent files.
      const userHeadings = new Set(scanHeadings(target).heads.filter((h) => h.level === 2).map((h) => h.heading));
      const hasAnyTemplateHeading = SECTIONS.some((s) => userHeadings.has(s.heading));
      if (!hasAnyTemplateHeading) {
        const left = [...templatePlaceholders(tpl)].filter((p) => target.includes(p));
        if (left.length > 0) {
          out.push({
            path,
            reason: `still has ${left.length} unfilled placeholder${left.length === 1 ? '' : 's'} (e.g. ${left[0]})`,
            impact,
          });
        }
        continue;
      }
      const states = sectionState(target, tpl);
      const unfilled = states.filter((s) => s.state !== 'filled');
      if (unfilled.length > 0) {
        const n = unfilled.length;
        const templateCount = unfilled.filter((s) => s.state === 'template').length;
        const missingCount = unfilled.filter((s) => s.state === 'missing').length;
        const ids = unfilled.map((s) => s.id).join(', ');
        out.push({
          path,
          reason: `still has ${n} unfilled section${n === 1 ? '' : 's'}: ${templateCount} on template, ${missingCount} missing (${ids})`,
          impact,
        });
      }
      continue;
    }
    if (target === tpl) {
      out.push({ path, reason: 'still identical to the shipped template', impact });
      continue;
    }
    const left = [...templatePlaceholders(tpl)].filter((p) => target.includes(p));
    if (left.length > 0) {
      out.push({
        path,
        reason: `still has ${left.length} unfilled placeholder${left.length === 1 ? '' : 's'} (e.g. ${left[0]})`,
        impact,
      });
    }
  }
  return out;
}

function checkPersonalization(root) {
  const absent = PERSONALIZATION_FILES.filter(({ path }) => !existsSync(join(root, ...path.split('/'))));
  if (absent.length === PERSONALIZATION_FILES.length) return null;
  const stale = unpersonalizedFiles(root);
  if (stale.length === 0) {
    return { label: 'Personalization files customized', pass: true };
  }
  return {
    label: `Personalization incomplete: ${stale.map((s) => s.path).join(', ')}`,
    warn: true,
    fix: stale.flatMap((s) => [`${s.path} — ${s.reason}; ${s.impact}`,
      `  ask your agent: "personalize ${s.path} from my CV"`]),
  };
}

// Single source of truth for the cold-start state: the same four user-layer
// prerequisites that AGENTS.md "First Run" lists. `--json` turns the trigger into
// a deterministic mechanism the agent runs (instead of re-deriving it from prose),
// and `--target <dir>` lets the test suite point it at a simulated virgin env.
function titleFilterConflicts(root) {
  // Same override the scanner honours (scan.mjs PORTALS_PATH), so a diagnosis
  // describes the file a scan would actually read, not the default one.
  const portalsPath = prereqFile(root, PORTALS_PREREQ);
  if (!existsSync(portalsPath)) return null;
  let config;
  try {
    config = loadYaml(readFileSync(portalsPath, 'utf-8'));
  } catch {
    // A portals.yml that does not parse is a different check's problem; this
    // one only has something to say about a file it could actually read.
    return null;
  }
  const { conflicts } = findTitleFilterConflicts(config?.title_filter);
  return conflicts.length > 0 ? conflicts : null;
}

// `main()` feeds the ordinary human-readable run; onboardingState() feeds
// `--json`. Both need this check, and it is non-blocking in both: a positive
// its own negatives veto is a configuration smell to fix, not a reason to
// refuse to start, so it reports as a warning rather than a failure.
function checkTitleFilterConflicts(root) {
  const conflicts = titleFilterConflicts(root);
  if (!conflicts) return { label: 'title_filter positives all reachable', pass: true };
  const n = conflicts.length;
  return {
    label: `title_filter: ${n} positive${n === 1 ? '' : 's'} never keeps a title`,
    warn: true,
    fix: conflicts.flatMap((c) => [
      `"${c.positive}" stands for "${c.title}", which negative "${c.negative}" vetoes`,
      '  ask your agent: "which title_filter entries contradict each other?"',
    ]),
  };
}

function onboardingState(root) {
  const autoCopied = [];
  // Same rule as main(): never seed User Layer files inside a plugin copy.
  const pluginInstall = checkPluginInstall(root);
  const userLayerWritable = !pluginInstall || pluginInstall.pass;
  const templates = [
    { target: 'modes/_profile.md', template: 'modes/_profile.template.md' },
    { target: 'modes/_custom.md', template: 'modes/_custom.template.md' },
    { target: 'modes/_brief.md', template: 'modes/_brief.template.md' },
    { target: 'voice-dna.md', template: 'voice-dna.template.md' },
  ];
  for (const { target, template } of templates) {
    const targetPath = join(root, ...target.split('/'));
    const rootTemplatePath = join(root, ...template.split('/'));
    const templatePath = existsSync(rootTemplatePath) ? rootTemplatePath : join(__dirname, ...template.split('/'));
    // Diagnosis must not create user files. Copy only during explicit onboarding.
    if (INIT_TEMPLATES && userLayerWritable && !existsSync(targetPath) && existsSync(templatePath)) {
      try {
        mkdirSync(dirname(targetPath), { recursive: true });
        copyFileSync(templatePath, targetPath, constants.COPYFILE_EXCL);
        autoCopied.push(target);
      } catch {
        // Gracefully handle read-only filesystems (e.g., CI/CD or containerized environments)
        // by leaving the file uncopied and letting onboardingNeeded/prereq checks handle it.
      }
    }
  }
  const missing = USER_LAYER_PREREQS
    .filter((prereq) => !prereqPresent(root, prereq))
    .map(({ path }) => path);

  const { cli: activeCli, source: cliSource, warning: cliWarning } = resolveActiveCli();

  // MCP project configuration belongs to the launch checkout. `root` is the
  // user-data layer and may point elsewhere under split-checkout installs.
  const mcpCheck = checkPlaywrightMcp(process.cwd(), activeCli);
  const unpersonalized = [
    ...unpersonalizedFiles(root),
    ...emptyYamlPrereqs(root).map(({ path, emptyImpact }) => ({ path, reason: EMPTY_YAML_REASON, impact: emptyImpact })),
  ];
  const titleFilterIssues = titleFilterConflicts(root);
  // Every other check in this function is data-layer and correctly uses this
  // function's own `root` parameter. The tracked-.bak check is the one
  // code-layer exception (#3867 finding 6) — it must read the module-level
  // codeRoot (the code checkout), which only differs from `root` when a real
  // split-checkout data root is in play and no --target was given.
  const bakCheck = checkTrackedBakFiles(codeRoot);
  const skillCheck = checkSkillEntrypoints(codeRoot);
  const cvShape = checkCvShape(root);
  const userTemplates = checkUserTemplates(root);
  const envFileProfile = checkEnvFileProfile(root);
  const warnings = [
    ...(cliWarning ? [cliWarning] : []),
    ...(pluginInstall && !pluginInstall.pass ? [`${pluginInstall.label}\n→ ${pluginInstall.fix}`] : []),
    ...(mcpCheck?.warn ? [`${mcpCheck.label}\n→ ${[].concat(mcpCheck.fix || []).join('\n  ')}`] : []),
    ...(bakCheck.warn ? [`${bakCheck.label}\n→ ${[].concat(bakCheck.fix || []).join('\n  ')}`] : []),
    ...(skillCheck.warn ? [`${skillCheck.label}\n→ ${[].concat(skillCheck.fix || []).join('\n  ')}`] : []),
    ...(cvShape?.warn ? [`${cvShape.label}\n→ ${[].concat(cvShape.fix || []).join('\n  ')}`] : []),
    ...(userTemplates?.warn ? [`${userTemplates.label}\n→ ${[].concat(userTemplates.fix || []).join('\n  ')}`] : []),
    ...(envFileProfile ? [`${envFileProfile.label}\n→ ${envFileProfile.fix.join('\n  ')}`] : []),
    ...unpersonalized.map((u) => `${u.path} ${u.reason} — ${u.impact}\n→ Personalize it from cv.md before running evaluations.`),
  ];

  const playwrightMcp = activeCli !== 'unknown' && activeCli !== 'unset' && MCP_CONFIGS.find((c) => c.cli === activeCli)
    ? { [activeCli]: mcpCheck?.pass === true }
    : {};

  let plugins = [];
  let pluginConfigError = null;
  try {
    const { cfg, error } = readPluginConfigSync(root);
    // Travels in the JSON so a consumer can tell "nothing enabled" from "the
    // config did not parse" — the two used to be the same empty list.
    pluginConfigError = error;
    plugins = activePlugins(codeRoot).map((m) => {
      const s = pluginState(m, cfg);
      return { id: m.id, hooks: m.hooks, enabled: s.enabled, missingEnv: s.missingEnv };
    });
  } catch { plugins = []; }
  return {
    onboardingNeeded: missing.length > 0,
    missing,
    // Non-blocking by design: career-ops is meant to work out of the box, so an
    // unedited personalization file must not gate the whole system. It DOES have
    // to be visible — surfaced as its own field the agent can branch on rather
    // than a string it has to pattern-match out of `warnings`.
    unpersonalized,
    // Present only when it fired: a coherent title_filter adds no key.
    ...(titleFilterIssues ? { titleFilterConflicts: titleFilterIssues } : {}),
    warnings,
    autoCopied,
    plugins,
    // Only present when it happened, so existing consumers see no new key on a
    // healthy run and a broken config is impossible to read as "none enabled".
    ...(pluginConfigError ? { pluginConfigError } : {}),
    playwright_mcp: playwrightMcp,
    active_cli: activeCli,
    cli_source: cliSource,
  };
}

if (JSON_OUT) {
  console.log(JSON.stringify(onboardingState(projectRoot)));
  process.exit(0);
} else {
  main().catch((err) => {
    console.error('doctor.mjs failed:', err.message);
    process.exit(1);
  });
}