// Boot-time drift assertion against specialist `.md` frontmatter.
//
// Every specialist subagent declares a `tools:` line in its YAML front-
// matter (a comma-separated list of fully qualified tool names). The
// `--allowed-tools` argv that Claude Code resolves for that subagent is
// derived from this list, so a name that does not correspond to any tool
// the platform can actually serve becomes a silent "tool not found" the
// first time the specialist tries to use it — months after the typo or
// stale-removal landed.
//
// This module catches the gap at manager boot. Specialist `tools:` names are
// the canonical long prefix `mcp__plugin_<plugin>_<server>__<tool>` (Task 502).
// For every tool name a specialist `.md` declares we verify that it resolves
// to one of:
//
//   - the tool surface `all` set (every tool declared in any
//     `<plugin>/PLUGIN.md` — both `tools:` entries and `hidden:` names — as
//     the long canonical name),
//   - a Claude Code-native tool (Read, Glob, Grep, Write, Bash,
//     WebSearch, WebFetch, Task, etc.),
//   - or a third-party MCP-bridge: a `mcp__plugin_*` name whose plugin segment
//     is NOT a maxy platform plugin (Playwright, etc.) is served by an upstream
//     bridge whose tool names are upstream-owned and pass unconditionally.
//
// A `mcp__plugin_*` name whose plugin segment IS a maxy plugin is validated
// against `all` like any other maxy tool, so a typo or stale long-prefix name
// still refuses boot. Anything else is drift, and the manager refuses to boot.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, basename } from 'node:path';
import { MAXY_MCP_PLUGINS } from './canonical-tool-names.generated.js';
/** Claude Code-native tools an agent can call without any MCP server.
 *  Listed exhaustively rather than guessed — a new CC-native tool added
 *  upstream forces an explicit ledger update here. Exported so the admin
 *  operator spawn can union this set into its `--allowed-tools` argv at
 *  spawn time; Task 579 — without that union, the admin's explicit
 *  plugin-tool allowlist silently excluded built-in `LS` / `Glob` and
 *  the seat could not enumerate its own disk. */
export const CC_NATIVE_TOOLS = new Set([
    'Read',
    'Write',
    'Edit',
    'MultiEdit',
    'Glob',
    'Grep',
    'LS',
    'Bash',
    'BashOutput',
    'KillShell',
    'Skill',
    'WebSearch',
    'WebFetch',
    'Task',
    'TodoWrite',
    'NotebookEdit',
    'NotebookRead',
    'AskUserQuestion',
]);
/** Third-party MCP-bridge prefix. A `mcp__plugin_<plugin>_<server>__<tool>`
 *  name whose `<plugin>` segment is NOT a maxy platform plugin is served by an
 *  upstream bridge (Playwright, etc.) whose tool names are upstream-owned and
 *  not part of the maxy canonical set — those pass unconditionally. A
 *  `mcp__plugin_*` name whose `<plugin>` IS a maxy plugin (including the graph
 *  proxy, packaged as the `graph` plugin since Task 502) is validated against
 *  the `all` surface like any other maxy tool, so a typo or stale long-prefix
 *  name still refuses boot. */
const PLUGIN_BRIDGE_PREFIX = 'mcp__plugin_';
/** Maxy platform plugin directory basenames, derived from the canonical set.
 *  A long-prefix name `mcp__plugin_<p>_<p>__` is a maxy tool iff `<p>` is in
 *  this set; anything else under `mcp__plugin_` is a third-party bridge. */
const MAXY_PLUGIN_SET = new Set(MAXY_MCP_PLUGINS);
/** Extract the value of the `tools:` line from a YAML frontmatter block.
 *  Returns the raw value (whitespace-trimmed) or null when no `tools:`
 *  key appears. The value is the comma-separated tool list Claude Code
 *  resolves; this module is the single parser of that grammar. */
export function parseSpecialistToolsLine(frontmatter) {
    const m = frontmatter.match(/^tools:\s*(.+)$/m);
    if (!m)
        return null;
    return m[1].trim();
}
function splitToolList(raw) {
    return raw
        .split(',')
        .map((t) => t.trim())
        .filter((t) => t.length > 0);
}
function classifyTool(tool, allSet, excludedPlugins) {
    if (CC_NATIVE_TOOLS.has(tool))
        return 'ok';
    if (tool.startsWith(PLUGIN_BRIDGE_PREFIX)) {
        // mcp__plugin_<plugin>_<server>__<tool>. A maxy plugin (plugin === server)
        // must resolve in the live `all` set; a non-maxy segment is a third-party
        // bridge (Playwright, etc.) whose tool names are upstream-owned — pass.
        const m = tool.match(/^mcp__plugin_([A-Za-z0-9-]+)_([A-Za-z0-9-]+)__/);
        const plugin = m ? m[1] : null;
        if (plugin && MAXY_PLUGIN_SET.has(plugin)) {
            if (allSet.has(tool))
                return 'ok';
            const hasNamespace = Array.from(allSet).some((t) => t.startsWith(`mcp__plugin_${plugin}_${plugin}__`));
            if (hasNamespace)
                return 'unknown-tool-in-plugin';
            // Brand-excluded: the brand's `plugins.excluded` list names this plugin,
            // so the absence is intentional. The template that declared the tool is
            // brand-agnostic; the manager strips it from the allowlist at spawn time.
            if (excludedPlugins.has(plugin))
                return 'brand-excluded-plugin';
            return 'unknown-plugin-namespace';
        }
        return 'ok';
    }
    if (allSet.has(tool))
        return 'ok';
    // Remaining `mcp__<x>__` shapes are short-prefix names. Since Task 502 the
    // admin + specialist surfaces are long-prefix only, so a short maxy name is
    // drift (it must be the canonical long name); a non-maxy short namespace is
    // unknown. Brand-excluded short names are tolerated for the strip-list path.
    const mcpMatch = tool.match(/^mcp__([^_]+(?:_[^_]+)*)__/);
    if (!mcpMatch)
        return 'malformed-name';
    const ns = mcpMatch[1];
    if (excludedPlugins.has(ns))
        return 'brand-excluded-plugin';
    return 'unknown-plugin-namespace';
}
/** Walk one or more directories, classify every specialist `.md` file's
 *  `tools:` frontmatter, and return drift defects.
 *
 *  `excludedPlugins` carries the brand's `plugins.excluded` set (the
 *  plugin directory names brand.json excludes from this bundle). A tool
 *  whose namespace appears in that set is classified as
 *  `brand-excluded-plugin`: it does not refuse boot and lands in
 *  `stripList` for the spawn path to remove from `--allowed-tools`.
 *  Defaulted to the empty set so call-sites that don't know about brand
 *  exclusions keep the legacy strict-boot semantics. */
export function assertSpecialistDrift(agentDirs, toolSurface, excludedPlugins = new Set()) {
    const defects = [];
    const stripList = new Map();
    const surfaces = new Map();
    let inspected = 0;
    for (const dir of agentDirs) {
        let entries;
        try {
            entries = readdirSync(dir);
        }
        catch {
            continue;
        }
        for (const entry of entries.sort()) {
            const fullPath = join(dir, entry);
            let st;
            try {
                st = statSync(fullPath);
            }
            catch {
                continue;
            }
            if (!st.isFile())
                continue;
            if (!entry.endsWith('.md'))
                continue;
            // Convention: top-level agent files like `valuer.md` are specialists.
            // IDENTITY.md/SOUL.md/AGENTS.md/LEARNINGS.md are sub-agent prompts
            // for non-specialist subagent roles (buyer-enquiry, etc.) and live
            // inside a sub-directory — they are not specialists themselves and
            // the directory iteration above already skips them.
            const upperBaseName = basename(entry, '.md').toUpperCase();
            if (upperBaseName === 'IDENTITY' || upperBaseName === 'SOUL' || upperBaseName === 'AGENTS' || upperBaseName === 'LEARNINGS')
                continue;
            const raw = readFileSync(fullPath, 'utf-8');
            const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
            if (!fmMatch)
                continue;
            const toolsLine = parseSpecialistToolsLine(fmMatch[1]);
            if (!toolsLine)
                continue;
            inspected += 1;
            const specialistName = basename(entry, '.md');
            const declaredTools = splitToolList(toolsLine);
            surfaces.set(specialistName, new Set([...declaredTools].sort()));
            for (const tool of declaredTools) {
                const verdict = classifyTool(tool, toolSurface.all, excludedPlugins);
                if (verdict === 'ok')
                    continue;
                defects.push({ specialistPath: fullPath, specialistName, tool, reason: verdict });
                if (verdict === 'brand-excluded-plugin') {
                    let set = stripList.get(specialistName);
                    if (!set) {
                        set = new Set();
                        stripList.set(specialistName, set);
                    }
                    set.add(tool);
                }
            }
        }
    }
    // Boot proceeds when every defect is brand-excluded (or there are
    // none). A single non-brand-excluded defect refuses boot — the manager
    // logs each and exits non-zero, matching the prior strict contract for
    // typos / missing plugins / malformed names.
    const ok = defects.every((d) => d.reason === 'brand-excluded-plugin');
    return { ok, defects, inspected, stripList, surfaces };
}
//# sourceMappingURL=specialist-drift.js.map