/**
 * Persona override layer + resolver (North Star H4).
 *
 * Baseline personas are markdown role contracts seeded by the framework into
 *   <mosaicHome>/fleet/roles/*.md
 * They are RESEEDED on every `mosaic update` (so new baseline personas ship to
 * existing installs). That reseed is exactly what would clobber any local edit,
 * so user customizations must NOT live in roles/.
 *
 * The override layer is a sibling directory:
 *   <mosaicHome>/fleet/roles.local/*.md
 * It is PRESERVE-protected in install.sh (see PRESERVE_PATHS "fleet/roles.local"),
 * so `mosaic update` never deletes it while roles/ keeps reseeding. An override
 * file WINS over the baseline of the same class, and an override file may ADD an
 * entirely new class that has no baseline at all. This delivers AC-NS-7: a
 * user-customized persona survives `mosaic update`.
 *
 * Class identity is encoded INLINE in the role prose, not as YAML frontmatter:
 *   (`class: ceo`, `domain: executive`)
 * The marker value may wrap across a newline. A few engineering personas carry
 * no marker at all and are identified by filename (e.g. planner -> orchestrator).
 *
 * The class-extraction logic here is the SINGLE SOURCE OF TRUTH for "what
 * persona classes exist"; fleet-profiles.ts imports it (DRY) so a profile roster
 * can reference a customized or user-added persona.
 */

import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, isAbsolute, join, sep } from 'node:path';
import type { Command } from 'commander';

function defaultMosaicHome(): string {
  return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}

/** Baseline persona role contracts (reseeded on update). */
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
  return join(mosaicHome, 'fleet', 'roles');
}

/** PRESERVE-protected override layer (survives update; wins on merge). */
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
  return join(mosaicHome, 'fleet', 'roles.local');
}

/**
 * Match a `class: X` marker even when the value wrapped onto the next line.
 * Allow surrounding backtick(s); the value is a single kebab-case token.
 * Shared by every caller so the definition of "a class marker" lives once.
 */
const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
/** Optional `domain: Y` marker that travels alongside the class in the prose. */
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;

/** Where a resolved persona's definition came from. */
export type PersonaLayer = 'baseline' | 'override';

export const ROLE_CLASS_ALIASES = Object.freeze({
  implementer: 'code',
  reviewer: 'review',
  'operator-interaction': 'interaction',
} as const);

export interface CanonicalRoleClass {
  readonly requestedClass: string;
  readonly canonicalClass: string;
}

/** Canonicalize only the three explicitly approved compatibility aliases. */
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
  const requested = requestedClass.trim();
  const canonical = Object.hasOwn(ROLE_CLASS_ALIASES, requested)
    ? ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES]
    : requested;
  return Object.freeze({ requestedClass: requested, canonicalClass: canonical });
}

export interface RoleAuthority {
  readonly mayMerge: boolean;
  readonly mayIssueValidationCertificate: boolean;
  readonly mayOrchestrate: boolean;
  readonly mayManageTopology: boolean;
  readonly mayIssueLeases: boolean;
  readonly leasedCapacityOnly: boolean;
  readonly requestStatusOnly: boolean;
  readonly mayMutateRoster: boolean;
  readonly mayMutateConfiguration: boolean;
  readonly mayAccessCredentials: boolean;
}

const NO_PROTECTED_AUTHORITY: RoleAuthority = Object.freeze({
  mayMerge: false,
  mayIssueValidationCertificate: false,
  mayOrchestrate: false,
  mayManageTopology: false,
  mayIssueLeases: false,
  leasedCapacityOnly: false,
  requestStatusOnly: false,
  mayMutateRoster: false,
  mayMutateConfiguration: false,
  mayAccessCredentials: false,
});

/** Immutable authority contracts keyed only by canonical class identity. */
export const ROLE_AUTHORITY_BY_CANONICAL_CLASS: Readonly<Record<string, RoleAuthority>> =
  Object.freeze({
    'merge-gate': Object.freeze({ ...NO_PROTECTED_AUTHORITY, mayMerge: true }),
    validator: Object.freeze({
      ...NO_PROTECTED_AUTHORITY,
      mayIssueValidationCertificate: true,
    }),
    orchestrator: Object.freeze({
      ...NO_PROTECTED_AUTHORITY,
      mayOrchestrate: true,
      mayManageTopology: true,
      mayIssueLeases: true,
    }),
    'team-leader': Object.freeze({ ...NO_PROTECTED_AUTHORITY, leasedCapacityOnly: true }),
    interaction: Object.freeze({ ...NO_PROTECTED_AUTHORITY, requestStatusOnly: true }),
  });

/** Return protected authority for an already-canonical class; custom classes get none. */
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
  return Object.hasOwn(ROLE_AUTHORITY_BY_CANONICAL_CLASS, canonicalClass)
    ? ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass]!
    : NO_PROTECTED_AUTHORITY;
}

/** One discovered persona file (a single role contract on disk). */
export interface PersonaFile {
  klass: string;
  /** The markdown file the class was found in. */
  file: string;
  /** True when the first class marker, rather than filename, defined this mapping. */
  markerDefined?: boolean;
  domain?: string;
}

export type DirScanState = 'scanned' | 'missing' | 'error';

/** The set of persona classes a directory of role contracts defines. */
export interface DirClasses {
  /** Whether the directory was scanned, absent, or present-but-unscannable. */
  scanState: DirScanState;
  /** Every readable class name the directory contributes by filename or first marker. */
  classes: Set<string>;
  /** Filename stems present on disk, retained even when a markdown entry is unreadable. */
  fileStems: Set<string>;
  /** For classes whose file carries a marker, the file + domain that defined it. */
  byClass: Map<string, PersonaFile>;
}

/**
 * Scan one directory of role contracts and extract readable persona identities.
 * Valid identities come only from a successfully read non-LIBRARY filename and
 * that file's first `class:` marker. LIBRARY rows and later prose mentions are
 * index/reference data, not independently readable role contracts.
 *
 * Missing directories are distinguished from present-but-unscannable paths.
 * `byClass` records the first marker-defined file+domain so the resolver can map
 * a class back to its contract; marker-less readable files map by filename.
 */
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
  const acc: DirClasses = {
    scanState: 'scanned',
    classes: new Set<string>(),
    fileStems: new Set<string>(),
    byClass: new Map<string, PersonaFile>(),
  };
  let entries: string[];
  try {
    entries = await readdir(dir);
  } catch (error) {
    acc.scanState = await classifyScanFailure(dir, error);
    return acc;
  }

  for (const entry of entries) {
    if (!entry.endsWith('.md')) continue;
    // Preserve entry presence separately from valid readable classes. Resolvers
    // use it to fail closed on an explicit unreadable canonical override without
    // advertising that override through class listing/status APIs.
    if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md'));
    let text: string;
    try {
      text = await readFile(join(dir, entry), 'utf8');
    } catch {
      continue;
    }
    accumulateEntry(acc, dir, entry, text);
  }
  return acc;
}

/**
 * Synchronous twin of {@link extractClassesFromDir}. Identical extraction
 * semantics (same markers, same union of marker/filename/LIBRARY sources) on
 * sync fs, for the synchronous launch-time prompt path (composeContract) which
 * cannot await. Missing dir / unreadable files degrade gracefully.
 */
export function extractClassesFromDirSync(dir: string): DirClasses {
  const acc: DirClasses = {
    scanState: 'scanned',
    classes: new Set<string>(),
    fileStems: new Set<string>(),
    byClass: new Map<string, PersonaFile>(),
  };
  let entries: string[];
  try {
    entries = readdirSync(dir);
  } catch (error) {
    acc.scanState = classifyScanFailureSync(dir, error);
    return acc;
  }

  for (const entry of entries) {
    if (!entry.endsWith('.md')) continue;
    // Keep sync extraction equivalent to the async scanner, including unreadable
    // filename presence kept separate from valid readable classes.
    if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md'));
    let text: string;
    try {
      text = readFileSync(join(dir, entry), 'utf8');
    } catch {
      continue;
    }
    accumulateEntry(acc, dir, entry, text);
  }
  return acc;
}

async function classifyScanFailure(dir: string, error: unknown): Promise<DirScanState> {
  if (!isMissingPathError(error)) return 'error';
  return (await hasBrokenSymlinkInPath(dir)) ? 'error' : 'missing';
}

function classifyScanFailureSync(dir: string, error: unknown): DirScanState {
  if (!isMissingPathError(error)) return 'error';
  return hasBrokenSymlinkInPathSync(dir) ? 'error' : 'missing';
}

function traversalPrefixes(path: string): string[] {
  const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
  const parts = absolute.split(sep);
  const prefixes: string[] = [];
  let current: string = sep;
  for (const part of parts) {
    if (!part) continue;
    current = current === sep ? `${sep}${part}` : `${current}${sep}${part}`;
    prefixes.push(current);
  }
  return prefixes;
}

async function hasBrokenSymlinkInPath(path: string): Promise<boolean> {
  for (const current of traversalPrefixes(path)) {
    try {
      const entry = await lstat(current);
      if (entry.isSymbolicLink()) {
        try {
          await stat(current);
        } catch {
          return true;
        }
      }
    } catch (error) {
      if (!isMissingPathError(error)) return true;
    }
  }
  return false;
}

function hasBrokenSymlinkInPathSync(path: string): boolean {
  for (const current of traversalPrefixes(path)) {
    try {
      const entry = lstatSync(current);
      if (entry.isSymbolicLink()) {
        try {
          statSync(current);
        } catch {
          return true;
        }
      }
    } catch (error) {
      if (!isMissingPathError(error)) return true;
    }
  }
  return false;
}

function isMissingPathError(error: unknown): boolean {
  return (
    typeof error === 'object' &&
    error !== null &&
    'code' in error &&
    (error as { code?: unknown }).code === 'ENOENT'
  );
}

/**
 * Apply the class-extraction rules for ONE role file's text into `acc`. Pure
 * over already-read content, so the async and sync directory scanners share a
 * single definition of "what classes a file contributes" (DRY — no semantic
 * drift between the launch-time and command-time paths).
 */
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
  const { classes, byClass } = acc;
  if (entry === 'LIBRARY.md') return;

  // An explicit first marker owns identity. Filename identity is a fallback only
  // for markerless compatibility contracts such as planner.md.
  const stem = basename(entry, '.md');
  const domainMatch = DOMAIN_MARKER.exec(text);
  const domain = domainMatch?.[1];
  const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined;
  const markedClassForFile = firstMarker?.[1];
  if (markedClassForFile) {
    classes.add(markedClassForFile);
    byClass.set(markedClassForFile, {
      klass: markedClassForFile,
      file: join(dir, entry),
      markerDefined: true,
      ...(domain ? { domain } : {}),
    });
  } else {
    classes.add(stem);
    if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) });
  }
}

export interface PersonaDirs {
  /** Baseline roles dir. Defaults to <mosaicHome>/fleet/roles. */
  rolesDir?: string;
  /** Override dir. Defaults to <mosaicHome>/fleet/roles.local. */
  overrideDir?: string;
  mosaicHome?: string;
}

function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string } {
  const mosaicHome = opts.mosaicHome ?? defaultMosaicHome();
  return {
    rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome),
    overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome),
  };
}

/**
 * Every class with an actually readable winning contract. Discovery applies the
 * same override shadow/fail-closed semantics as resolution, so callers never
 * advertise a class that cannot be used.
 */
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
  const { personas } = await collectUsablePersonas(opts);
  return new Set(personas.keys());
}

export type PersonaStatus = 'baseline' | 'overridden' | 'custom';

export interface PersonaResolution extends CanonicalRoleClass {
  /** Compatibility name for the canonical class. */
  klass: string;
  layer: PersonaLayer;
  /** The file the resolved persona was read from (override wins). */
  file: string;
  content: string;
  domain?: string;
}

async function readPersonaFromLayer(
  requestedClass: string,
  canonicalClass: string,
  dir: string,
  extracted: DirClasses,
  layer: PersonaLayer,
): Promise<PersonaResolution | null> {
  const pf = extracted.byClass.get(canonicalClass);
  if (!pf) {
    if (!extracted.classes.has(canonicalClass)) return null;
    const byName = join(dir, `${canonicalClass}.md`);
    try {
      const content = await readFile(byName, 'utf8');
      const currentMarker = content.matchAll(CLASS_MARKER).next().value as
        | RegExpExecArray
        | undefined;
      if (currentMarker && currentMarker[1] !== canonicalClass) return null;
      const dm = DOMAIN_MARKER.exec(content);
      return {
        requestedClass,
        canonicalClass,
        klass: canonicalClass,
        layer,
        file: byName,
        content,
        ...(dm?.[1] ? { domain: dm[1] } : {}),
      };
    } catch {
      return null;
    }
  }
  try {
    const content = await readFile(pf.file, 'utf8');
    const currentMarker = content.matchAll(CLASS_MARKER).next().value as
      | RegExpExecArray
      | undefined;
    if (currentMarker && currentMarker[1] !== canonicalClass) return null;
    const dm = DOMAIN_MARKER.exec(content);
    return {
      requestedClass,
      canonicalClass,
      klass: canonicalClass,
      layer,
      file: pf.file,
      content,
      ...(dm?.[1] ? { domain: dm[1] } : {}),
    };
  } catch {
    return null;
  }
}

function overrideShadowsBaseline(over: DirClasses, canonicalClass: string): boolean {
  return (
    over.scanState === 'error' ||
    over.fileStems.has(canonicalClass) ||
    over.byClass.has(canonicalClass)
  );
}

function readPersonaFromLayerSync(
  requestedClass: string,
  canonicalClass: string,
  dir: string,
  extracted: DirClasses,
  layer: PersonaLayer,
): PersonaResolution | null {
  const pf = extracted.byClass.get(canonicalClass);
  if (!pf) {
    if (!extracted.classes.has(canonicalClass)) return null;
    const byName = join(dir, `${canonicalClass}.md`);
    try {
      const content = readFileSync(byName, 'utf8');
      const currentMarker = content.matchAll(CLASS_MARKER).next().value as
        | RegExpExecArray
        | undefined;
      if (currentMarker && currentMarker[1] !== canonicalClass) return null;
      const dm = DOMAIN_MARKER.exec(content);
      return {
        requestedClass,
        canonicalClass,
        klass: canonicalClass,
        layer,
        file: byName,
        content,
        ...(dm?.[1] ? { domain: dm[1] } : {}),
      };
    } catch {
      return null;
    }
  }
  try {
    const content = readFileSync(pf.file, 'utf8');
    const currentMarker = content.matchAll(CLASS_MARKER).next().value as
      | RegExpExecArray
      | undefined;
    if (currentMarker && currentMarker[1] !== canonicalClass) return null;
    const dm = DOMAIN_MARKER.exec(content);
    return {
      requestedClass,
      canonicalClass,
      klass: canonicalClass,
      layer,
      file: pf.file,
      content,
      ...(dm?.[1] ? { domain: dm[1] } : {}),
    };
  } catch {
    return null;
  }
}

/**
 * Resolve a persona class to its winning definition: the override file if
 * roles.local/ defines that class, else the baseline. Match by inline `class:`
 * marker first, then by filename stem (roles.local/<klass>.md) as a fallback.
 * Returns null if neither layer defines the class.
 */
export async function resolvePersona(
  klass: string,
  opts: PersonaDirs = {},
): Promise<PersonaResolution | null> {
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const [base, over] = await Promise.all([
    extractClassesFromDir(rolesDir),
    extractClassesFromDir(overrideDir),
  ]);
  return resolvePersonaFrom(klass, { rolesDir, overrideDir, base, over });
}

/**
 * Resolve a single class against ALREADY-EXTRACTED layer maps. Callers that
 * resolve many classes against the same two directories (e.g. provisioning a
 * full roster) should {@link extractClassesFromDir} each dir ONCE and reuse the
 * result here, rather than paying a full directory re-scan per class. Precedence
 * is identical to {@link resolvePersona}: override layer wins, then baseline.
 */
export async function resolvePersonaFrom(
  requestedClass: string,
  layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
): Promise<PersonaResolution | null> {
  const { requestedClass: requested, canonicalClass: klass } =
    canonicalizeRoleClass(requestedClass);
  const { rolesDir, overrideDir, base, over } = layers;

  const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override');
  if (override) return override;
  // An observed explicit override that cannot resolve/read shadows the baseline.
  if (overrideShadowsBaseline(over, klass)) return null;

  // Cached scans are only snapshots. Re-scan immediately before baseline fallback
  // so a newly created canonical or marker-defined override cannot be skipped.
  const currentOver = await extractClassesFromDir(overrideDir);
  const currentOverride = await readPersonaFromLayer(
    requested,
    klass,
    overrideDir,
    currentOver,
    'override',
  );
  if (currentOverride) return currentOverride;
  if (overrideShadowsBaseline(currentOver, klass)) return null;
  if (currentOver.scanState === 'error') return null;
  return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline');
}

/**
 * Synchronous twin of {@link resolvePersona} — same override-wins precedence
 * (roles.local/ beats roles/, by marker first then filename stem), returning
 * null if neither layer defines the class. Exists for the synchronous launch
 * prompt path (composeContract → readPersonaContractBlock) which cannot await.
 * Keeping it here, beside the async resolver, keeps the resolution semantics in
 * one module so the launch-time and command-time resolutions never diverge.
 */
export function resolvePersonaSync(
  requestedClass: string,
  opts: PersonaDirs = {},
): PersonaResolution | null {
  const { requestedClass: requested, canonicalClass: klass } =
    canonicalizeRoleClass(requestedClass);
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const base = extractClassesFromDirSync(rolesDir);
  const over = extractClassesFromDirSync(overrideDir);

  const override = readPersonaFromLayerSync(requested, klass, overrideDir, over, 'override');
  if (override) return override;
  if (overrideShadowsBaseline(over, klass)) return null;
  return readPersonaFromLayerSync(requested, klass, rolesDir, base, 'baseline');
}

interface UsablePersonaIndex {
  personas: Map<string, PersonaResolution>;
  readableBaseline: Set<string>;
}

async function collectUsablePersonas(opts: PersonaDirs): Promise<UsablePersonaIndex> {
  const { rolesDir, overrideDir } = resolveDirs(opts);
  const [base, over] = await Promise.all([
    extractClassesFromDir(rolesDir),
    extractClassesFromDir(overrideDir),
  ]);
  if (over.scanState === 'error') {
    return { personas: new Map(), readableBaseline: new Set() };
  }

  const candidates = new Set<string>([...base.classes, ...over.classes]);
  const checked = await Promise.all(
    [...candidates].map(async (requestedClass) => {
      const { canonicalClass } = canonicalizeRoleClass(requestedClass);
      const [persona, baseline] = await Promise.all([
        resolvePersonaFrom(requestedClass, { rolesDir, overrideDir, base, over }),
        readPersonaFromLayer(requestedClass, canonicalClass, rolesDir, base, 'baseline'),
      ]);
      return { requestedClass, persona, baseline };
    }),
  );

  const personas = new Map<string, PersonaResolution>();
  const readableBaseline = new Set<string>();
  for (const { requestedClass, persona, baseline } of checked) {
    if (persona) personas.set(requestedClass, persona);
    if (baseline) readableBaseline.add(requestedClass);
  }
  return { personas, readableBaseline };
}

export interface PersonaStatusEntry {
  klass: string;
  status: PersonaStatus;
  domain?: string;
}

/**
 * Classify every known class:
 *   - baseline   — present only in roles/
 *   - overridden — present in BOTH roles/ and roles.local/ (override wins)
 *   - custom     — present only in roles.local/ (user-added)
 * Domain is taken from the WINNING layer (override domain wins if present).
 */
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
  const { personas, readableBaseline } = await collectUsablePersonas(opts);
  const entries = [...personas.entries()].map(([klass, persona]): PersonaStatusEntry => {
    const status: PersonaStatus =
      persona.layer === 'baseline'
        ? 'baseline'
        : readableBaseline.has(klass)
          ? 'overridden'
          : 'custom';
    return {
      klass,
      status,
      ...(persona.domain ? { domain: persona.domain } : {}),
    };
  });
  entries.sort((a, b) => a.klass.localeCompare(b.klass));
  return entries;
}

// ─── CLI: `mosaic fleet persona <list|show|customize>` ───────────────────────

function printPersonaList(entries: PersonaStatusEntry[]): void {
  if (entries.length === 0) {
    console.log('(no personas)');
    return;
  }
  for (const e of entries) {
    console.log(`${e.klass}\t[${e.status}]\tdomain=${e.domain ?? '-'}`);
  }
}

/** Minimal override scaffold for a brand-new (no-baseline) class. */
function scaffoldOverride(klass: string): string {
  return `# ${klass} — fleet role definition (override)

The **${klass}** persona (\`class: ${klass}\`) is a user-defined override that
lives in the PRESERVE-protected \`fleet/roles.local/\` layer and survives
\`mosaic update\`. Edit this file to define the persona's mandate and boundaries.

## Mandate

1. (describe what this persona owns)

## Boundaries

- (describe what this persona does NOT do)
`;
}

/**
 * Register `persona` under an existing `fleet` command. `mosaicHomeFor` resolves
 * the active --mosaic-home (parent flag) at call time, mirroring the backlog and
 * profile subcommand wiring.
 */
export function registerFleetPersonaCommand(
  fleetCmd: Command,
  mosaicHomeFor: () => string,
): Command {
  const personaCmd = fleetCmd
    .command('persona')
    .description('Update-surviving persona overrides: baseline ⊕ roles.local layer (H4)');

  personaCmd
    .command('list')
    .description('List every persona class with its status (baseline/overridden/custom) and domain')
    .option('--json', 'Print JSON')
    .action(async (opts: { json?: boolean }) => {
      try {
        const entries = await personaStatus({ mosaicHome: mosaicHomeFor() });
        if (opts.json) {
          console.log(JSON.stringify(entries));
          return;
        }
        printPersonaList(entries);
      } catch (err) {
        process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
        process.exitCode = 1;
      }
    });

  personaCmd
    .command('show <class>')
    .description('Show the RESOLVED persona (override wins) and which layer it came from')
    .option('--json', 'Print JSON')
    .action(async (klass: string, opts: { json?: boolean }) => {
      try {
        const resolved = await resolvePersona(klass, { mosaicHome: mosaicHomeFor() });
        if (!resolved) {
          process.stderr.write(`Unknown persona class "${klass}"\n`);
          process.exitCode = 1;
          return;
        }
        if (opts.json) {
          console.log(JSON.stringify(resolved));
          return;
        }
        console.log(`# class: ${resolved.klass}`);
        console.log(`# layer: ${resolved.layer}`);
        console.log(`# domain: ${resolved.domain ?? '-'}`);
        console.log(`# file: ${resolved.file}`);
        console.log('');
        console.log(resolved.content);
      } catch (err) {
        process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
        process.exitCode = 1;
      }
    });

  personaCmd
    .command('customize <class>')
    .description(
      'Copy the baseline persona into fleet/roles.local/ to edit (override layer). ' +
        '--new scaffolds a brand-new persona with no baseline.',
    )
    .option('--new', 'Scaffold a minimal override for a brand-new class (no baseline required)')
    .action(async (klass: string, opts: { new?: boolean }) => {
      try {
        const { mkdir, writeFile, copyFile, access } = await import('node:fs/promises');
        const { constants } = await import('node:fs');
        const mosaicHome = mosaicHomeFor();
        const rolesDir = defaultRolesDir(mosaicHome);
        const overrideDir = defaultOverrideDir(mosaicHome);
        const target = join(overrideDir, `${klass}.md`);

        await mkdir(overrideDir, { recursive: true });

        // Do not clobber an existing override.
        try {
          await access(target, constants.F_OK);
          console.log(`Override already exists, not clobbering: ${target}`);
          return;
        } catch {
          // not present — proceed
        }

        if (opts.new) {
          await writeFile(target, scaffoldOverride(klass), 'utf8');
          console.log(`Scaffolded new persona override: ${target}`);
          return;
        }

        // Copy the baseline. Prefer the marker-defining file; fall back to stem.
        const base = await extractClassesFromDir(rolesDir);
        const pf = base.byClass.get(klass);
        const source = pf?.file ?? join(rolesDir, `${klass}.md`);
        try {
          await access(source, constants.F_OK);
        } catch {
          process.stderr.write(
            `No baseline persona "${klass}" to copy. Use --new to scaffold one.\n`,
          );
          process.exitCode = 1;
          return;
        }
        await copyFile(source, target);
        console.log(`Copied baseline persona to override layer: ${target}`);
        console.log('Edit it there; it wins over the baseline and survives `mosaic update`.');
      } catch (err) {
        process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
        process.exitCode = 1;
      }
    });

  return personaCmd;
}
