"use strict";
// Skill Curator.
//
// Phase 1 (deterministic): identifies skills as "stale" based on usage
// metadata (last use, view count, age) and archives unpinned ones by moving
// the skill folder to .harness/skills/_archive/<name>/. Archive is reversible
// via restoreSkill.
//
// Phase 2 (LLM): asks the configured model to identify clusters of related
// skills and propose merges into umbrella skills. The first version writes
// proposals to .harness/curator/proposals.md for human review rather than
// auto-merging — gives the user a chance to see what the model suggests
// before granting it autonomous skill rewrite power.
//
// Both phases honor:
//   - Pinned skills are never touched.
//   - The global kill switch (passed in via deps).
//   - A maximum-actions-per-run cap so a misbehaving curator can't nuke the
//     library in one go.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.saveSkillUsage = exports.loadSkillUsage = exports.DEFAULT_CURATOR_CONFIG = void 0;
exports.findStaleSkills = findStaleSkills;
exports.archiveSkill = archiveSkill;
exports.restoreSkill = restoreSkill;
exports.runDeterministicPhase = runDeterministicPhase;
exports.runLlmPhase = runLlmPhase;
exports.runCurator = runCurator;
exports.readCuratorLog = readCuratorLog;
exports.readCuratorProposals = readCuratorProposals;
exports.parseMergeProposals = parseMergeProposals;
exports.applyMergeProposal = applyMergeProposal;
exports.clearCuratorProposals = clearCuratorProposals;
const fs = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const atomicFile_1 = require("../persistence/atomicFile");
const logger_1 = require("../core/logger");
const tracing_1 = require("../core/tracing");
const skillLoader_1 = require("../extensibility/skillLoader");
const skillUsage_1 = require("../extensibility/skillUsage");
const promotionGate_1 = require("../learning/promotionGate");
exports.DEFAULT_CURATOR_CONFIG = {
    staleDays: 60,
    minViewsBeforeArchive: 1,
    maxArchivePerRun: 5,
    enableLlmPhase: false,
};
const ARCHIVE_DIR_NAME = '_archive';
function curatorDir(projectDir) {
    return path.join(projectDir, '.harness', 'curator');
}
function logFile(projectDir) {
    return path.join(curatorDir(projectDir), 'log.jsonl');
}
function proposalsFile(projectDir) {
    return path.join(curatorDir(projectDir), 'proposals.md');
}
function skillsDir(projectDir) {
    return path.join(projectDir, '.harness', 'skills');
}
function archiveDir(projectDir) {
    return path.join(skillsDir(projectDir), ARCHIVE_DIR_NAME);
}
async function appendAuditLog(projectDir, entry) {
    await fs.mkdir(curatorDir(projectDir), { recursive: true });
    const line = JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + '\n';
    const filePath = logFile(projectDir);
    await (0, atomicFile_1.withFileLock)(filePath, () => fs.appendFile(filePath, line, 'utf-8'));
}
async function fileExists(filePath) {
    try {
        await fs.access(filePath);
        return true;
    }
    catch {
        return false;
    }
}
/**
 * Phase 1: identify skills the curator considers stale. Pure function over
 * loaded definitions and usage records. Always safe to call.
 */
function findStaleSkills(skills, usage, config, now = new Date()) {
    const actions = [];
    const cutoffMs = now.getTime() - config.staleDays * 24 * 60 * 60 * 1000;
    for (const skill of skills) {
        const record = usage.records[skill.name];
        if (record?.pinned) {
            actions.push({ kind: 'skip-pinned', skill: skill.name, reason: 'Pinned by user.' });
            continue;
        }
        if (record?.archived) {
            // Already archived; skip silently.
            continue;
        }
        const lastTouch = record?.lastUsedAt ?? record?.lastViewedAt ?? record?.firstSeenAt;
        if (!lastTouch) {
            // No usage record yet — too fresh to judge.
            actions.push({ kind: 'skip-active', skill: skill.name, reason: 'No usage record yet.' });
            continue;
        }
        const lastTouchMs = Date.parse(lastTouch);
        if (!Number.isFinite(lastTouchMs))
            continue;
        if (lastTouchMs >= cutoffMs) {
            actions.push({ kind: 'skip-active', skill: skill.name, reason: `Used within the last ${config.staleDays} days.` });
            continue;
        }
        const viewCount = record?.viewCount ?? 0;
        if (viewCount < config.minViewsBeforeArchive) {
            actions.push({ kind: 'skip-active', skill: skill.name, reason: `Below view threshold (${viewCount}/${config.minViewsBeforeArchive}).` });
            continue;
        }
        const ageDays = Math.round((now.getTime() - Date.parse(record?.firstSeenAt ?? lastTouch)) / (24 * 60 * 60 * 1000));
        const daysSinceUse = Math.round((now.getTime() - lastTouchMs) / (24 * 60 * 60 * 1000));
        actions.push({
            kind: 'archive',
            skill: skill.name,
            reason: `Stale: ${daysSinceUse} days since last touch (threshold ${config.staleDays}).`,
            ageDays,
            daysSinceUse,
            viewCount,
            useCount: record?.useCount ?? 0,
        });
    }
    return actions;
}
/**
 * Move a skill folder into the archive subdirectory and update the usage record.
 * Reversible via restoreSkill.
 */
async function archiveSkill(projectDir, name) {
    const sourceDir = path.join(skillsDir(projectDir), name);
    const destDir = path.join(archiveDir(projectDir), name);
    await fs.mkdir(archiveDir(projectDir), { recursive: true });
    await fs.rename(sourceDir, destDir);
    await (0, skillUsage_1.setSkillArchived)(projectDir, name, true);
    return { from: sourceDir, to: destDir };
}
async function restoreSkill(projectDir, name) {
    const sourceDir = path.join(archiveDir(projectDir), name);
    const destDir = path.join(skillsDir(projectDir), name);
    await fs.rename(sourceDir, destDir);
    await (0, skillUsage_1.setSkillArchived)(projectDir, name, false);
    return { from: sourceDir, to: destDir };
}
async function runDeterministicPhase(projectDir, config, deps, options = {}) {
    const startedAt = new Date().toISOString();
    if (deps.isKillSwitchActive()) {
        const skipped = {
            startedAt,
            finishedAt: new Date().toISOString(),
            dryRun: Boolean(options.dryRun),
            staleCandidates: [],
            archived: [],
            llmSkipped: 'kill switch active',
        };
        await appendAuditLog(projectDir, { phase: 'deterministic', skipped: 'kill switch active' });
        return skipped;
    }
    const scan = await (0, skillLoader_1.scanSkillsDir)(skillsDir(projectDir));
    const usage = await (0, skillUsage_1.loadSkillUsage)(projectDir);
    const candidates = findStaleSkills(scan.skills, usage, config);
    const archiveCandidates = candidates.filter((action) => action.kind === 'archive');
    const archived = [];
    // Per-skill safety pre-check. Built-in + any custom rules in
    // .harness/safety-rules.json. A high-severity match blocks archive
    // (we'd rather surface the skill than silently rotate it) and is
    // recorded as `skip-safety`. Off-by-default via env so the existing
    // archive flow stays unchanged unless the operator opts in.
    const safetyEnabled = process.env.HARNESS_CURATOR_SAFETY_GATE === '1';
    const safetyRules = safetyEnabled ? await (0, promotionGate_1.loadSafetyRules)(projectDir).catch(() => undefined) : undefined;
    const skillByName = new Map();
    for (const skill of scan.skills)
        skillByName.set(skill.name, skill);
    if (!options.dryRun) {
        let count = 0;
        for (const candidate of archiveCandidates) {
            if (count >= config.maxArchivePerRun) {
                archived.push({ kind: 'skip-cap', skill: candidate.skill, reason: `Per-run cap (${config.maxArchivePerRun}) reached.` });
                continue;
            }
            if (safetyEnabled && safetyRules) {
                const skill = skillByName.get(candidate.skill);
                const violations = skill ? (0, promotionGate_1.scanSafetyText)(skill.content, 'outcome', safetyRules) : [];
                const blocking = violations.find((violation) => violation.severity === 'high');
                if (blocking) {
                    archived.push({ kind: 'skip-safety', skill: candidate.skill, reason: `Safety rule fired: ${blocking.ruleLabel} (${blocking.severity})`, safetyViolations: violations });
                    await appendAuditLog(projectDir, { phase: 'deterministic', action: 'skip-safety', skill: candidate.skill, ruleId: blocking.ruleId });
                    tracing_1.runtimeTracer.recordEvent('curator.skip_safety', { skill: candidate.skill, ruleId: blocking.ruleId, severity: blocking.severity });
                    continue;
                }
            }
            try {
                await archiveSkill(projectDir, candidate.skill);
                archived.push({ ...candidate });
                await appendAuditLog(projectDir, { phase: 'deterministic', action: 'archive', skill: candidate.skill, reason: candidate.reason });
                tracing_1.runtimeTracer.recordEvent('curator.archive', { skill: candidate.skill, reason: candidate.reason });
                count++;
            }
            catch (error) {
                const msg = error instanceof Error ? error.message : String(error);
                logger_1.logger.warn('Curator', 'Failed to archive skill', { skill: candidate.skill, error: msg });
                await appendAuditLog(projectDir, { phase: 'deterministic', action: 'archive-failed', skill: candidate.skill, error: msg });
            }
        }
    }
    return {
        startedAt,
        finishedAt: new Date().toISOString(),
        dryRun: Boolean(options.dryRun),
        staleCandidates: candidates,
        archived,
    };
}
/**
 * Phase 2: ask the LLM to cluster related skills and propose merges into
 * umbrella skills. Writes proposals to .harness/curator/proposals.md for
 * human review. Does not auto-apply changes in this version.
 */
async function runLlmPhase(projectDir, config, deps) {
    if (deps.isKillSwitchActive()) {
        return { skipped: 'kill switch active' };
    }
    if (!config.enableLlmPhase) {
        return { skipped: 'LLM phase disabled in config' };
    }
    if (!deps.callModel) {
        return { skipped: 'No model adapter wired into curator' };
    }
    const scan = await (0, skillLoader_1.scanSkillsDir)(skillsDir(projectDir));
    const usage = await (0, skillUsage_1.loadSkillUsage)(projectDir);
    const active = scan.skills.filter((skill) => !usage.records[skill.name]?.archived);
    if (active.length < 3) {
        return { skipped: `Need at least 3 active skills, have ${active.length}` };
    }
    const summary = active.map((skill) => `- ${skill.name}: ${skill.description}`).join('\n');
    const prompt = [
        'You are reviewing a local agent harness skill library to suggest cleanup.',
        'Skills are markdown files; each one teaches the agent a repeatable workflow.',
        '',
        'For the skills below, identify clusters of 2-4 skills that overlap enough to merge into a single umbrella skill.',
        'Only propose a merge when the combined skill would be clearly more useful than its parts.',
        'Do NOT propose changes for skills that look unique or unrelated.',
        'Format your answer as a Markdown report with one section per proposed cluster:',
        '',
        '### Cluster: <umbrella name>',
        '- merge: skill-a, skill-b, skill-c',
        '- rationale: <one sentence>',
        '- proposed description: <one sentence>',
        '',
        'If no clusters are worth proposing, say "No merges proposed." and nothing else.',
        '',
        'Skills:',
        summary,
    ].join('\n');
    let proposals;
    try {
        proposals = await deps.callModel(prompt);
    }
    catch (error) {
        const msg = error instanceof Error ? error.message : String(error);
        logger_1.logger.warn('Curator', 'LLM phase failed', { error: msg });
        await appendAuditLog(projectDir, { phase: 'llm', error: msg });
        return { skipped: `LLM call failed: ${msg}` };
    }
    await fs.mkdir(curatorDir(projectDir), { recursive: true });
    const header = `<!-- markdownlint-disable-file -->\n# Curator merge proposals\n\nGenerated: ${new Date().toISOString()}\nActive skills considered: ${active.length}\n\n`;
    const body = header + proposals.trim() + '\n';
    await (0, atomicFile_1.atomicWriteFile)(proposalsFile(projectDir), body);
    await appendAuditLog(projectDir, { phase: 'llm', wrote: proposalsFile(projectDir), activeSkillCount: active.length });
    return { proposals, proposalsPath: proposalsFile(projectDir) };
}
async function runCurator(projectDir, config, deps, options = {}) {
    const phase1 = await runDeterministicPhase(projectDir, config, deps, options);
    if (options.dryRun || deps.isKillSwitchActive())
        return phase1;
    const phase2 = await runLlmPhase(projectDir, config, deps);
    return { ...phase1, proposals: phase2.proposals, proposalsPath: phase2.proposalsPath, llmSkipped: phase2.skipped };
}
async function readCuratorLog(projectDir, limit = 50) {
    try {
        const raw = await fs.readFile(logFile(projectDir), 'utf-8');
        const lines = raw.split('\n').filter(Boolean);
        return lines.slice(-limit).map((line) => {
            try {
                return JSON.parse(line);
            }
            catch {
                return { raw: line };
            }
        });
    }
    catch {
        return [];
    }
}
async function readCuratorProposals(projectDir) {
    try {
        return await fs.readFile(proposalsFile(projectDir), 'utf-8');
    }
    catch {
        return null;
    }
}
/**
 * Parse the proposals markdown into a structured list. The format the LLM is
 * asked to follow is:
 *   ### Cluster: <umbrella name>
 *   - merge: a, b, c
 *   - rationale: ...
 *   - proposed description: ...
 * This parser is intentionally lenient: missing fields are tolerated.
 */
function parseMergeProposals(content) {
    if (!content)
        return [];
    const proposals = [];
    const lines = content.split('\n');
    let current = null;
    for (const rawLine of lines) {
        const line = rawLine.trim();
        const heading = line.match(/^#{2,4}\s*Cluster\s*:\s*(.+)$/i);
        if (heading) {
            if (current && current.mergeSkills.length > 0)
                proposals.push(current);
            const headingText = heading[1].trim();
            current = {
                umbrellaName: slugifySkillName(headingText),
                heading: headingText,
                mergeSkills: [],
            };
            continue;
        }
        if (!current)
            continue;
        const mergeMatch = line.match(/^[-*]\s*merge\s*:\s*(.+)$/i);
        if (mergeMatch) {
            current.mergeSkills = mergeMatch[1].split(',').map((item) => item.trim()).filter(Boolean);
            continue;
        }
        const rationaleMatch = line.match(/^[-*]\s*rationale\s*:\s*(.+)$/i);
        if (rationaleMatch) {
            current.rationale = rationaleMatch[1].trim();
            continue;
        }
        const descMatch = line.match(/^[-*]\s*proposed\s+description\s*:\s*(.+)$/i);
        if (descMatch) {
            current.proposedDescription = descMatch[1].trim();
            continue;
        }
    }
    if (current && current.mergeSkills.length > 0)
        proposals.push(current);
    return proposals.filter((p) => p.mergeSkills.length >= 2);
}
function slugifySkillName(text) {
    return text.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'umbrella';
}
/**
 * Apply a single LLM merge proposal: writes a new umbrella SKILL.md that
 * concatenates the source skill bodies, then archives the originals.
 * Pinned skills are not archived (the umbrella still references them in its
 * body for transparency).
 */
async function applyMergeProposal(projectDir, proposal, options = {}) {
    const umbrellaName = (options.umbrellaName && slugifySkillName(options.umbrellaName)) || proposal.umbrellaName;
    const umbrellaDir = path.join(skillsDir(projectDir), umbrellaName);
    const umbrellaPath = path.join(umbrellaDir, 'SKILL.md');
    if (await fileExists(umbrellaPath)) {
        throw new Error(`Umbrella skill '${umbrellaName}' already exists. Choose a different name.`);
    }
    const scan = await (0, skillLoader_1.scanSkillsDir)(skillsDir(projectDir));
    const usage = await (0, skillUsage_1.loadSkillUsage)(projectDir);
    const sourceSkills = proposal.mergeSkills
        .map((name) => scan.skills.find((skill) => skill.name === name))
        .filter((skill) => Boolean(skill));
    if (sourceSkills.length < 2) {
        throw new Error('Need at least 2 source skills to merge; some referenced skills were not found.');
    }
    const description = options.description?.trim() || proposal.proposedDescription?.trim() || `Umbrella skill merged from: ${sourceSkills.map((s) => s.name).join(', ')}.`;
    const archivedNames = [];
    const skipped = [];
    if (!options.dryRun) {
        await fs.mkdir(umbrellaDir, { recursive: true });
        const body = [
            `---`,
            `name: ${umbrellaName}`,
            `description: ${description}`,
            `domain: umbrella`,
            `triggers: []`,
            `---`,
            ``,
            `# ${umbrellaName}`,
            ``,
            proposal.rationale ? `> ${proposal.rationale}` : '',
            proposal.rationale ? `` : '',
            `Merged from ${sourceSkills.length} skill(s) by the curator on ${new Date().toISOString()}.`,
            ``,
            ...sourceSkills.flatMap((skill) => [
                `## ${skill.name}`,
                ``,
                skill.description,
                ``,
                skill.content,
                ``,
            ]),
        ].filter((line) => line !== null && line !== undefined).join('\n');
        await (0, atomicFile_1.atomicWriteFile)(umbrellaPath, body);
        for (const skill of sourceSkills) {
            const record = usage.records[skill.name];
            if (record?.pinned) {
                skipped.push(skill.name);
                continue;
            }
            try {
                await archiveSkill(projectDir, skill.name);
                archivedNames.push(skill.name);
                await appendAuditLog(projectDir, { phase: 'merge-applied', umbrella: umbrellaName, archived: skill.name });
            }
            catch (error) {
                skipped.push(skill.name);
                await appendAuditLog(projectDir, { phase: 'merge-applied', umbrella: umbrellaName, error: error instanceof Error ? error.message : String(error) });
            }
        }
        tracing_1.runtimeTracer.recordEvent('curator.merge_applied', { umbrella: umbrellaName, archived: archivedNames, skipped });
    }
    return { umbrellaName, umbrellaPath, archived: archivedNames, skipped, dryRun: Boolean(options.dryRun) };
}
async function clearCuratorProposals(projectDir) {
    try {
        await fs.rm(proposalsFile(projectDir), { force: true });
    }
    catch { /* nothing to clear */ }
}
// Re-export for tests / convenience
var skillUsage_2 = require("../extensibility/skillUsage");
Object.defineProperty(exports, "loadSkillUsage", { enumerable: true, get: function () { return skillUsage_2.loadSkillUsage; } });
Object.defineProperty(exports, "saveSkillUsage", { enumerable: true, get: function () { return skillUsage_2.saveSkillUsage; } });
//# sourceMappingURL=curator.js.map