// engine/skills-reconciler.ts — SPEC-189: Detect stack changes and reconcile skills
// SPEC-395: autoFixSkills — install missing, remove stale, repair corrupt paths.
// SPEC-607: Detect old-format skills and new agents, trigger regeneration.
// Compares recommended skills (based on current stack) with installed skills.
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { readSkillManifest, installSkill } from './skill-registry/installer.js';
import { fetchRecommendedSkills } from './skills/skills-fetcher.js';
import { parseAgentSkillFrontmatter } from './skill-generation/agent-skills-formatter.js';
/**
 * Scan the project's installed skills against the recommended set for its current stack.
 *
 * Algorithm:
 *   1. Load ProjectKnowledge to obtain the current stack + language.
 *   2. Read the manifest from `.claude/skills/manifest.json`.
 *   3. Also enumerate `.md` files in `.claude/skills/` to catch manually installed skills.
 *   4. Fetch recommended skills for the current stack (same logic as suggest_tooling).
 *   5. Diff: missing = recommended − installed; stale = installed − recommended.
 *   6. Compute health score and return the reconciliation report.
 */
export async function reconcileSkills(projectPath, knowledge) {
    if (!knowledge) {
        return buildEmptyReconciliation('Project knowledge not found — run init_project first.');
    }
    const [installedNames, recommendedSkills] = await Promise.all([
        listInstalledSkillNames(projectPath),
        fetchRecommendedSkills(knowledge),
    ]);
    const recommendedNames = new Set(recommendedSkills.map((s) => s.name));
    const installedSet = new Set(installedNames);
    const newSkillsDetected = recommendedSkills
        .filter((s) => !installedSet.has(s.name))
        .map((s) => s.name);
    const staleSkills = installedNames.filter((name) => !recommendedNames.has(name) && !isProtectedBuiltInSkill(name, knowledge));
    const totalRelevant = recommendedNames.size;
    const alreadyInSync = installedNames.filter((name) => recommendedNames.has(name)).length;
    const healthScore = computeHealthScore(totalRelevant, alreadyInSync, staleSkills.length);
    // SPEC-607: Detect skills with old format (no Agent Skills frontmatter)
    const oldFormatSkills = await detectOldFormatSkills(projectPath);
    // SPEC-607: Detect newly present AI agents not yet served skills
    const newAgentTargets = await detectNewAgentTargets(projectPath);
    return {
        skillsInSync: newSkillsDetected.length === 0 &&
            staleSkills.length === 0 &&
            oldFormatSkills.length === 0 &&
            newAgentTargets.length === 0,
        newSkillsDetected,
        staleSkills,
        updatedSkills: [...oldFormatSkills, ...newAgentTargets],
        healthScore,
    };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Collect all skill names present on disk:
 *   - From manifest.json (canonical source)
 *   - From .md files in .claude/skills/ (manually placed skills without manifest entry)
 *
 * Returns deduplicated list of skill names (without .md extension).
 */
async function listInstalledSkillNames(projectPath) {
    const skillsDir = path.join(projectPath, '.claude', 'skills');
    const names = new Set();
    // 1. Read manifest
    const manifest = await readSkillManifest(projectPath);
    for (const entry of manifest.skills) {
        names.add(entry.name);
    }
    // 2. Enumerate .md files (manually installed skills not in manifest)
    try {
        const entries = await fs.readdir(skillsDir);
        for (const entry of entries) {
            if (entry.endsWith('.md')) {
                names.add(entry.slice(0, -3)); // strip .md
            }
        }
    }
    catch {
        // Directory does not exist — no skills installed yet
    }
    return [...names];
}
// ---------------------------------------------------------------------------
// SPEC-607: Old-format and new-agent detection
// ---------------------------------------------------------------------------
/**
 * Detect .md skill files in .claude/skills/ that do NOT have Agent Skills
 * spec frontmatter (no `---` + `name:` block). These should be regenerated.
 */
async function detectOldFormatSkills(projectPath) {
    const skillsDir = path.join(projectPath, '.claude', 'skills');
    const oldFormat = [];
    try {
        const entries = await fs.readdir(skillsDir);
        const mdFiles = entries.filter((e) => e.endsWith('.md') && e !== 'manifest.json');
        await Promise.all(mdFiles.map(async (file) => {
            try {
                const content = await fs.readFile(path.join(skillsDir, file), 'utf-8');
                const parsed = parseAgentSkillFrontmatter(content);
                if (parsed === null) {
                    oldFormat.push(file.slice(0, -3)); // strip .md
                }
            }
            catch {
                /* skip unreadable files */
            }
        }));
    }
    catch {
        // Directory does not exist
    }
    return oldFormat;
}
/** Agent presence signals (directory or file that indicates an agent is used). */
const AGENT_PRESENCE_SIGNALS = {
    cursor: '.cursor',
    copilot: '.github',
    gemini: 'GEMINI.md',
};
/**
 * Detect AI agents that are present in the project but don't yet have skills
 * written in their format. Returns agent names that need skills generated.
 */
async function detectNewAgentTargets(projectPath) {
    const newTargets = [];
    await Promise.all(Object.entries(AGENT_PRESENCE_SIGNALS).map(async ([agentName, signal]) => {
        const signalPath = path.join(projectPath, signal);
        try {
            await fs.access(signalPath);
            // Agent is present — check if it has any Planu skills
            const hasSkills = await agentHasPlanuSkills(projectPath, agentName);
            if (!hasSkills) {
                newTargets.push(agentName);
            }
        }
        catch {
            // Agent not present
        }
    }));
    return newTargets;
}
/** Check if a specific agent already has Planu-generated skills. */
async function agentHasPlanuSkills(projectPath, agentName) {
    const checkPaths = [];
    if (agentName === 'cursor') {
        checkPaths.push(path.join(projectPath, '.cursor', 'rules'));
    }
    else if (agentName === 'copilot') {
        checkPaths.push(path.join(projectPath, '.github', 'copilot-instructions.md'));
    }
    else if (agentName === 'gemini') {
        checkPaths.push(path.join(projectPath, 'GEMINI.md'));
    }
    for (const p of checkPaths) {
        try {
            const stat = await fs.stat(p);
            if (stat.isDirectory()) {
                const entries = await fs.readdir(p);
                if (entries.some((e) => e.endsWith('.mdc') || e.endsWith('.md'))) {
                    return true;
                }
            }
            else if (stat.isFile()) {
                const content = await fs.readFile(p, 'utf-8');
                // Check if Planu has appended content
                if (content.includes('project-testing') || content.includes('project-deployment')) {
                    return true;
                }
            }
        }
        catch {
            /* not found */
        }
    }
    return false;
}
/**
 * Compute a 0–100 health score.
 *
 * Formula:
 *   base = alreadyInSync / totalRelevant * 100 (or 100 when totalRelevant === 0)
 *   penalty = staleCount * 5 (each stale skill costs 5 points)
 */
function computeHealthScore(totalRelevant, alreadyInSync, staleCount) {
    const base = totalRelevant === 0 ? 100 : Math.round((alreadyInSync / totalRelevant) * 100);
    const penalty = staleCount * 5;
    return Math.max(0, base - penalty);
}
function isProtectedBuiltInSkill(name, knowledge) {
    if (name !== 'typescript-patterns') {
        return false;
    }
    const language = knowledge.language.toLowerCase();
    const stack = knowledge.stack.map((item) => item.toLowerCase());
    return (language === 'typescript' ||
        language === 'javascript' ||
        stack.some((item) => item.includes('typescript') || item.includes('javascript')));
}
/** Return a neutral reconciliation when prerequisites are missing. */
function buildEmptyReconciliation(_reason) {
    return {
        skillsInSync: false,
        newSkillsDetected: [],
        staleSkills: [],
        updatedSkills: [],
        healthScore: 0,
    };
}
// ---------------------------------------------------------------------------
// SPEC-395: autoFixSkills — mutating reconciliation
// ---------------------------------------------------------------------------
/**
 * Auto-fix the skills installation to match the reconciliation report:
 *  1. Install each skill listed in `newSkillsDetected` (using the builtin source as fallback).
 *  2. Remove each skill listed in `staleSkills` from disk and the manifest.
 *  3. Detect and fix corrupt manifest entries (path does not exist on disk).
 *
 * Returns a summary of all actions taken plus the new health score.
 */
export async function autoFixSkills(reconciliation, projectPath) {
    const installed = await installMissingSkills(reconciliation.newSkillsDetected, projectPath);
    const removed = await removeStaleSkills(reconciliation.staleSkills, projectPath);
    const pathsFixed = await fixCorruptPaths(projectPath);
    // Re-read the manifest after all mutations to compute the new health score.
    const updatedManifest = await readSkillManifest(projectPath);
    const totalInstalled = updatedManifest.skills.length;
    const staleCount = 0; // All stale skills have been removed.
    const healthScore = computeHealthScore(totalInstalled + reconciliation.newSkillsDetected.length - installed.length, totalInstalled, staleCount);
    return { installed, removed, pathsFixed, healthScore };
}
/** Install each missing skill using the 'builtin' source as a local placeholder. */
async function installMissingSkills(skillNames, projectPath) {
    const successfullyInstalled = [];
    for (const skillName of skillNames) {
        try {
            await installSkill(skillName, 'builtin', projectPath);
            successfullyInstalled.push(skillName);
        }
        catch {
            // Non-blocking — skip skills that fail to install.
        }
    }
    return successfullyInstalled;
}
/** Remove stale skills from disk (directory) and from the manifest. */
async function removeStaleSkills(staleNames, projectPath) {
    const skillsDir = path.join(projectPath, '.claude', 'skills');
    const removed = [];
    for (const skillName of staleNames) {
        const skillDir = path.join(skillsDir, skillName);
        const skillFile = path.join(skillsDir, `${skillName}.md`);
        // Remove directory-based skill.
        try {
            await fs.rm(skillDir, { recursive: true, force: true });
        }
        catch {
            /* v8 ignore next 1 */
        }
        // Remove flat .md-based skill.
        try {
            await fs.rm(skillFile, { force: true });
        }
        catch {
            /* v8 ignore next 1 */
        }
        removed.push(skillName);
    }
    if (removed.length > 0) {
        await removeFromManifest(projectPath, new Set(removed));
    }
    return removed;
}
/** Remove entries from the manifest by name. */
async function removeFromManifest(projectPath, namesToRemove) {
    const manifest = await readSkillManifest(projectPath);
    const updated = {
        ...manifest,
        skills: manifest.skills.filter((s) => !namesToRemove.has(s.name)),
    };
    const manifestFile = path.join(projectPath, '.claude', 'skills', 'manifest.json');
    await fs.mkdir(path.dirname(manifestFile), { recursive: true });
    await fs.writeFile(manifestFile, JSON.stringify(updated, null, 2), 'utf-8');
}
/**
 * Detect manifest entries whose recorded `path` does not exist on disk and attempt to
 * find the skill by name inside `.claude/skills/`. Updates the manifest in place.
 */
async function fixCorruptPaths(projectPath) {
    const manifest = await readSkillManifest(projectPath);
    const skillsDir = path.join(projectPath, '.claude', 'skills');
    const fixed = [];
    const updatedSkills = [];
    for (const entry of manifest.skills) {
        const pathExists = await checkPathExists(entry.path);
        if (pathExists) {
            updatedSkills.push(entry);
            continue;
        }
        // Try to locate the skill by its name inside the skills directory.
        const candidateDir = path.join(skillsDir, entry.name);
        const candidateFile = path.join(skillsDir, `${entry.name}.md`);
        const dirExists = await checkPathExists(candidateDir);
        const fileExists = await checkPathExists(candidateFile);
        if (dirExists) {
            updatedSkills.push({ ...entry, path: candidateDir });
            fixed.push(entry.name);
        }
        else if (fileExists) {
            updatedSkills.push({ ...entry, path: candidateFile });
            fixed.push(entry.name);
        }
        else {
            // Cannot locate the skill — keep the entry as-is (do not silently remove).
            updatedSkills.push(entry);
        }
    }
    if (fixed.length > 0) {
        const updatedManifest = { ...manifest, skills: updatedSkills };
        const manifestFile = path.join(skillsDir, 'manifest.json');
        await fs.mkdir(skillsDir, { recursive: true });
        await fs.writeFile(manifestFile, JSON.stringify(updatedManifest, null, 2), 'utf-8');
    }
    return fixed;
}
/** Return true if the given path exists (file or directory). */
async function checkPathExists(targetPath) {
    try {
        await fs.access(targetPath);
        return true;
    }
    catch {
        return false;
    }
}
//# sourceMappingURL=skills-reconciler.js.map