/**
 * Persona loading and translation.
 *
 * A persona is a JSON file that describes a pre-configured agent: which
 * harness (CLI) to use, which model, what system prompt to inject, plus
 * optional MCP servers and permission flags. Personas live in
 * `<cwd>/agentworkforce/personas`, the AgentWorkforce home directory, or
 * any directory the caller passes explicitly.
 *
 * Translation from a resolved persona to `{bin, args}` delegates to
 * `@agentworkforce/harness-kit#buildInteractiveSpec`, so relay always
 * produces the same launch args the AgentWorkforce CLI does.
 *
 * The schema mirrors the AgentWorkforce persona format
 * (see https://github.com/AgentWorkforce/workforce). Skills installation,
 * mount policy, sidecar markdown, input rendering, and routing profiles
 * are deliberately not handled here — callers needing those should use
 * the `agentworkforce` CLI directly.
 */
import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path';
import { buildInteractiveSpec, } from '@agentworkforce/harness-kit';
import { HARNESS_VALUES, PERSONA_TIERS, } from '@agentworkforce/workload-router';
export { HARNESS_VALUES, PERSONA_TIERS };
// ── Default search dirs ────────────────────────────────────────────────────
function expandHome(p) {
    if (p === '~')
        return homedir();
    if (p.startsWith('~/') || p.startsWith('~\\'))
        return join(homedir(), p.slice(2));
    return p;
}
/**
 * The default cascade. First match wins for a given persona id.
 *
 * 1. `<cwd>/agentworkforce/personas`            (the path most projects pick)
 * 2. `<cwd>/.agentworkforce/workforce/personas` (workforce CLI's project default)
 * 3. `<cwd>/agentworkforce/workforce/personas`  (alt workforce layout)
 * 4. `$AGENT_WORKFORCE_HOME/personas` if set, else `~/.agentworkforce/workforce/personas`
 */
export function defaultPersonaSearchDirs(cwd = process.cwd()) {
    const dirs = [
        join(cwd, 'agentworkforce', 'personas'),
        join(cwd, '.agentworkforce', 'workforce', 'personas'),
        join(cwd, 'agentworkforce', 'workforce', 'personas'),
    ];
    const home = process.env.AGENT_WORKFORCE_HOME?.trim();
    if (home) {
        dirs.push(join(expandHome(home), 'personas'));
    }
    else {
        dirs.push(join(homedir(), '.agentworkforce', 'workforce', 'personas'));
    }
    return dirs;
}
function effectiveSearchDirs(options) {
    const cwd = options.cwd ?? process.cwd();
    const base = options.searchDirs
        ? options.searchDirs.map((d) => normalizeDir(d, cwd))
        : defaultPersonaSearchDirs(cwd);
    const extras = (options.extraDirs ?? []).map((d) => normalizeDir(d, cwd));
    return dedupe([...base, ...extras]);
}
function normalizeDir(input, cwd) {
    const expanded = expandHome(input.trim());
    return isAbsolute(expanded) ? resolvePath(expanded) : resolvePath(cwd, expanded);
}
function dedupe(items) {
    const seen = new Set();
    const out = [];
    for (const item of items) {
        if (seen.has(item))
            continue;
        seen.add(item);
        out.push(item);
    }
    return out;
}
// ── Discovery ──────────────────────────────────────────────────────────────
/**
 * List every persona discoverable across the search-dir cascade. When the
 * same id appears in multiple dirs, only the first match (by cascade order)
 * is returned.
 */
export function listPersonas(options = {}) {
    const dirs = effectiveSearchDirs(options);
    const byId = new Map();
    for (const dir of dirs) {
        if (!existsSync(dir))
            continue;
        let entries;
        try {
            entries = readdirSync(dir);
        }
        catch {
            continue;
        }
        for (const file of entries) {
            if (!file.endsWith('.json'))
                continue;
            const path = join(dir, file);
            let spec;
            try {
                // Single read avoids a TOCTOU between stat and readFileSync — if the
                // entry is a directory, vanished, or unreadable we skip it.
                spec = parsePersonaFile(JSON.parse(readFileSync(path, 'utf8')), path);
            }
            catch {
                continue;
            }
            if (!byId.has(spec.id)) {
                byId.set(spec.id, { id: spec.id, path, spec });
            }
        }
    }
    return [...byId.values()];
}
/**
 * Find a persona file by id across the search-dir cascade.
 * Returns undefined if not found.
 */
export function findPersona(id, options = {}) {
    const dirs = effectiveSearchDirs(options);
    for (const dir of dirs) {
        if (!existsSync(dir))
            continue;
        const candidate = join(dir, `${id}.json`);
        let candidateBytes;
        try {
            // Single read avoids a stat/read TOCTOU. ENOENT (file missing) falls
            // through to a directory scan for personas with mismatched filenames;
            // any other read failure or parse failure on a convention-named file
            // surfaces directly so a typo in the JSON isn't silently treated as
            // "persona not found".
            candidateBytes = readFileSync(candidate, 'utf8');
        }
        catch (err) {
            if (err.code !== 'ENOENT')
                throw err;
        }
        if (candidateBytes !== undefined) {
            const spec = parsePersonaFile(JSON.parse(candidateBytes), candidate);
            if (spec.id === id)
                return { id, path: candidate, spec };
        }
        let entries;
        try {
            entries = readdirSync(dir);
        }
        catch {
            continue;
        }
        for (const file of entries) {
            if (!file.endsWith('.json'))
                continue;
            const path = join(dir, file);
            try {
                const spec = parsePersonaFile(JSON.parse(readFileSync(path, 'utf8')), path);
                if (spec.id === id)
                    return { id, path, spec };
            }
            catch {
                continue;
            }
        }
    }
    return undefined;
}
// ── Resolution ─────────────────────────────────────────────────────────────
/**
 * Load and resolve a persona by id. Searches the cascade, applies the
 * chosen tier, and resolves a single level of `extends` against the same
 * cascade. Throws if the persona is missing required fields for the tier.
 */
export function loadPersona(id, options = {}) {
    const discovered = findPersona(id, options);
    if (!discovered) {
        const dirs = effectiveSearchDirs(options);
        throw new Error(`Persona "${id}" not found. Searched:\n  ${dirs.join('\n  ')}\n` +
            'Set searchDirs / extraDirs to include the directory containing the persona file.');
    }
    const tier = options.tier ?? 'best';
    let spec = discovered.spec;
    if (spec.extends) {
        const base = findPersona(spec.extends, options);
        if (!base) {
            throw new Error(`Persona "${id}" extends "${spec.extends}" but the base could not be found in the search cascade.`);
        }
        spec = mergeSpecs(base.spec, spec);
    }
    const tierSpec = spec.tiers?.[tier];
    const harness = (tierSpec?.harness ?? spec.harness);
    const model = tierSpec?.model ?? spec.model;
    const systemPrompt = tierSpec?.systemPrompt ?? spec.systemPrompt;
    if (!harness) {
        throw new Error(`Persona "${id}" tier "${tier}" has no harness; set tiers.${tier}.harness or top-level harness.`);
    }
    if (!HARNESS_VALUES.includes(harness)) {
        throw new Error(`Persona "${id}" tier "${tier}" uses unsupported harness "${String(harness)}". ` +
            `Supported: ${HARNESS_VALUES.join(', ')}.`);
    }
    if (!model) {
        throw new Error(`Persona "${id}" tier "${tier}" has no model; set tiers.${tier}.model or top-level model.`);
    }
    if (!systemPrompt) {
        throw new Error(`Persona "${id}" tier "${tier}" has no systemPrompt; set tiers.${tier}.systemPrompt or top-level systemPrompt.`);
    }
    return {
        id: spec.id,
        source: discovered.path,
        tier,
        harness,
        model,
        systemPrompt,
        description: spec.description,
        permissions: spec.permissions,
        mcpServers: spec.mcpServers,
    };
}
// ── Merge (extends) ────────────────────────────────────────────────────────
function mergeSpecs(base, override) {
    const tiers = {};
    for (const tier of PERSONA_TIERS) {
        const baseTier = base.tiers?.[tier];
        const overrideTier = override.tiers?.[tier];
        if (overrideTier || baseTier) {
            tiers[tier] = { ...(baseTier ?? {}), ...(overrideTier ?? {}) };
        }
    }
    return {
        id: override.id,
        intent: override.intent ?? base.intent,
        description: override.description ?? base.description,
        tags: override.tags ?? base.tags,
        systemPrompt: override.systemPrompt ?? base.systemPrompt,
        harness: override.harness ?? base.harness,
        model: override.model ?? base.model,
        permissions: mergePermissions(base.permissions, override.permissions),
        mcpServers: { ...(base.mcpServers ?? {}), ...(override.mcpServers ?? {}) },
        tiers: Object.keys(tiers).length > 0 ? tiers : undefined,
    };
}
function mergePermissions(base, override) {
    if (!base && !override)
        return undefined;
    const allow = dedupe([...(base?.allow ?? []), ...(override?.allow ?? [])]);
    const deny = dedupe([...(base?.deny ?? []), ...(override?.deny ?? [])]);
    return {
        ...(allow.length > 0 ? { allow } : {}),
        ...(deny.length > 0 ? { deny } : {}),
        ...(override?.mode ?? base?.mode ? { mode: override?.mode ?? base?.mode } : {}),
    };
}
// ── Validation ─────────────────────────────────────────────────────────────
function isPlainObject(value) {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parsePersonaFile(value, source) {
    if (!isPlainObject(value)) {
        throw new Error(`${source}: persona must be a JSON object`);
    }
    if (typeof value.id !== 'string' || !value.id.trim()) {
        throw new Error(`${source}: persona.id must be a non-empty string`);
    }
    // Validate harness values up front so a typo in the file fails at load time
    // rather than at spawn — the runtime check in loadPersona stays as a
    // defense-in-depth guard for callers that bypass parsing.
    const topHarness = value.harness;
    if (topHarness !== undefined && !isValidHarness(topHarness)) {
        throw new Error(`${source}: persona.harness must be one of: ${HARNESS_VALUES.join(', ')} (got ${JSON.stringify(topHarness)})`);
    }
    const tiers = value.tiers;
    if (tiers !== undefined) {
        if (!isPlainObject(tiers)) {
            throw new Error(`${source}: persona.tiers must be an object if provided`);
        }
        for (const [tierName, tierSpec] of Object.entries(tiers)) {
            if (!PERSONA_TIERS.includes(tierName))
                continue; // unknown tier names are ignored
            if (!isPlainObject(tierSpec))
                continue;
            const harness = tierSpec.harness;
            if (harness !== undefined && !isValidHarness(harness)) {
                throw new Error(`${source}: persona.tiers.${tierName}.harness must be one of: ${HARNESS_VALUES.join(', ')} (got ${JSON.stringify(harness)})`);
            }
        }
    }
    return value;
}
function isValidHarness(value) {
    return typeof value === 'string' && HARNESS_VALUES.includes(value);
}
// ── Translation: persona → spawn args ──────────────────────────────────────
/**
 * Translate a resolved persona into the bin/args spawnPty needs. Delegates
 * to {@link buildInteractiveSpec} from `@agentworkforce/harness-kit` so
 * relay produces the same launch shape the AgentWorkforce CLI does.
 */
export function buildPersonaSpawnSpec(persona) {
    const input = {
        harness: persona.harness,
        personaId: persona.id,
        model: persona.model,
        systemPrompt: persona.systemPrompt,
        ...(persona.mcpServers ? { mcpServers: persona.mcpServers } : {}),
        ...(persona.permissions ? { permissions: persona.permissions } : {}),
    };
    const spec = buildInteractiveSpec(input);
    return {
        cli: spec.bin,
        model: persona.model,
        args: [...spec.args],
        initialPrompt: spec.initialPrompt,
        configFiles: [...spec.configFiles],
        warnings: [...spec.warnings],
    };
}
/**
 * Codex has no system-prompt flag, so the persona's instructions must ride
 * on the task. Combines them in the same shape the agentworkforce
 * harness-kit uses for non-interactive codex runs.
 */
export function composePersonaTask(spec, userTask) {
    if (!spec.initialPrompt)
        return userTask;
    if (!userTask)
        return spec.initialPrompt;
    return `${spec.initialPrompt}\n\nUser task:\n${userTask}`;
}
/**
 * Write each persona config file into `cwd`. Refuses absolute paths or
 * paths that escape `cwd`. Returns handles the caller can pass to
 * {@link restorePersonaConfigFiles}.
 */
export function materializePersonaConfigFiles(cwd, files) {
    const out = [];
    const cwdAbs = resolvePath(cwd);
    for (const file of files) {
        if (!file.path)
            throw new Error('persona config file path must be non-empty');
        if (isAbsolute(file.path)) {
            throw new Error(`persona config file path must be relative: ${file.path}`);
        }
        const target = resolvePath(cwd, file.path);
        // Use path.relative for separator-agnostic containment so Windows paths
        // (`C:\proj\opencode.json`) aren't falsely rejected by a hardcoded '/' check.
        const rel = relative(cwdAbs, target);
        if (rel.startsWith('..') || (isAbsolute(rel) && rel !== '')) {
            throw new Error(`persona config file path escapes cwd: ${file.path}`);
        }
        if (rel.split(sep).some((segment) => segment === '..')) {
            throw new Error(`persona config file path escapes cwd: ${file.path}`);
        }
        // Single read with ENOENT detection avoids a TOCTOU between `existsSync`
        // and `readFileSync`. Any other read error (permissions, EISDIR) bubbles up
        // — the caller can decide whether to retry or surface to the user.
        let existed = true;
        let previous;
        try {
            previous = readFileSync(target, 'utf8');
        }
        catch (err) {
            if (err.code === 'ENOENT') {
                existed = false;
            }
            else {
                throw err;
            }
        }
        mkdirSync(dirname(target), { recursive: true });
        writeFileSync(target, file.contents, 'utf8');
        out.push({ path: target, existed, ...(previous !== undefined ? { previous } : {}) });
    }
    return out;
}
/**
 * Restore the original state of files written by
 * {@link materializePersonaConfigFiles}. Files that did not exist before
 * are removed; files that did exist are written back to their prior
 * contents. Errors are swallowed — restore is best-effort cleanup.
 */
export function restorePersonaConfigFiles(writes) {
    for (const write of [...writes].reverse()) {
        try {
            if (write.existed) {
                writeFileSync(write.path, write.previous ?? '', 'utf8');
            }
            else {
                rmSync(write.path, { force: true });
            }
        }
        catch (err) {
            // Best-effort: a failed restore shouldn't break the spawn lifecycle, but
            // it can leave a stale opencode.json behind, so surface the failure.
            const msg = err?.message ?? String(err);
            console.warn(`[personas] failed to restore ${write.path}: ${msg}`);
        }
    }
}
//# sourceMappingURL=personas.js.map