UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

1,659 lines • 97.8 kB
/**
 * skillGraph — a declarative, visualizable skill-dependency graph (proposal 002).
 *
 * The consumer declares skills + routing EDGES; `skillGraph()` compiles each edge
 * to the existing injection-engine TRIGGER on the target skill — so the dynamic,
 * token-efficient loading the engine already does becomes *declared* and *drawn*.
 *
 *   .entry(skill, { when? })              → trigger: `always` (or `rule` if when)
 *   .route(a, b, { onToolReturn | when }) → b compiles to a CURSOR-GATED `rule`
 *   (a skill with no declared incoming edge keeps its default `llm-activated`
 *    trigger — still reachable via `read_skill`, drawn as a dashed "model" edge)
 *
 * **v2 keystone — `from` IS enforced (a sticky cursor state machine).** A skill
 * graph is a state machine over skills; the engine tracks which node it is in via
 * `InjectionContext.currentSkillId` (the cursor). One pure resolver — `resolveCursor`
 * (see `makeResolveCursor`), of which `nextSkill(ctx)` is the `.to` projection — is the
 * single source of truth: each route target B
 * compiles to the trigger `nextSkill(ctx) === B`, which delivers `from`-gating
 * (an edge `A→B` fires only while the cursor is on A — no cross-skill edge bleed),
 * stickiness (the cursor stays on B until an edge leaves B), and a clean handoff
 * (B deactivates the same iteration C activates). The Injection Engine's Evaluate
 * stage advances the cursor with the SAME ctx (`currentSkillId = nextSkill(ctx)`),
 * so the active set and the persisted cursor never disagree. The DRAWN edge kind
 * (`on-tool-return` vs `predicate`) is preserved for rendering even though the
 * compiled trigger is always a `rule`. `toMermaid()` renders declared === drawn.
 *
 * A decision `tree()` routes per-iteration by stable `ctx` predicates (no cursor)
 * and is unaffected by `from`-gating. It has no cursor for `read_skill` to move
 * either, so `reachableSkills()` is EMPTY there and the gate refuses a leaf pick
 * (8.5.0) — see `reachableSkills` for why honouring it would break three of this
 * module's own invariants.
 *
 * **One skill's turn at a time (8.15.0).** The keystone's third property — "B
 * deactivates the same iteration C activates" — now holds for EVERY node in a flat
 * graph, including an entry that carries a `when`. Such an entry used to compile to
 * `when(ctx) || cursor === id`, and the leftover rule clause meant an entry that
 * routed onward stayed loaded beside its own successor, on that iteration and every
 * one after it. A conditional entry is active exactly while the cursor is on it;
 * `when` decides where a turn STARTS. The one declared way to be co-active beside
 * the cursor is an entry with no `when` at all (`{ kind: 'always' }`), which is
 * untouched. A rule that matched while the cursor was elsewhere is reported, not
 * swallowed — see `supersededEntries`.
 *
 * **The model moves too (`read_skill`).** Scoped `read_skill` bounds the model to
 * `reachableSkills(cursor)` — the declared successors of where it stands, plus the
 * entries. A pick the gate ACCEPTS moves the cursor exactly like a declared edge
 * does (`ctx.pendingSkillPick`, honoured in `makeResolveCursor`), so what the gate
 * allows and what actually takes effect are the same set. A declared edge that
 * fires the same turn wins (`D1 > D2`, docs/design/skill-graph.md §4A.1) and the
 * run emits `agentfootprint.skill.reroute_superseded` rather than leaving the
 * model's answered-"activated" claim quietly unmet.
 *
 * **The resolver says WHY, it is not asked to be guessed.** `explainNextSkill(ctx)`
 * returns the destination AND the winning clause (`CursorMove`), decided at the same
 * `return`. The agent stamps it on `context.evaluated` as `cursorMove`, which is how
 * `routeRecorder()` can tell a model pick from a declared edge that happens to point
 * at the same skill (8.5.0 — before, the drawn build-time provenance was read as the
 * per-hop cause, so a model pick was recorded under a declared edge's label).
 */
import { devMode, devWarn } from './devWarn.js';
import { isHiddenSkill, toolResultsOf } from './types.js';
import { embeddingScorer } from './entryScorer.js';
import { checkupGraph, formatCheckup, } from './skillGraphCheckup.js';
import { checkSkillContracts } from './skillContract.js';
import { checkArtifactVocabularies } from './skillVocabulary.js';
import { checkStartRuleExamples, validateStartRuleExamples } from './skillExamples.js';
import { checkNeverRoutes, neverRouteKey, validateNeverRoutes } from './skillNeverRoutes.js';
import { checkPartition } from './skillPartition.js';
import { checkEntryEvidence } from './skillEntryEvidence.js';
import { compileMatch, mermaidMatchCaption, } from './skillMatch.js';
import { compileGuard, mermaidGuardCaption, plainGuardCaption, } from './skillGuard.js';
import { resolveRoutingPolicy } from './routingPolicy.js';
import { buildTurnRoutingPlan, findDuplicateIntentExamples, runCheckupIntents, } from './skillIntent.js';
export { formatCheckup } from './skillGraphCheckup.js';
// The data-guard domain (`guard:` on route edges, 9.51.0) — one module owns the
// type, the compiler, the evidence shape and the caption; see ./skillGuard.ts.
export { GUARD_HOP_KEYS, plainGuardCaption, } from './skillGuard.js';
/** Build a decision node. Leaves are skills (an `Injection`); internal nodes are
 *  other `decideSkill(...)` results. (Renamed from `decide` in v7 to avoid
 *  colliding with footprintjs's `decide()`.) */
export function decideSkill(predicate, whenTrue, whenFalse, label) {
    return { kind: 'decision', predicate, whenTrue, whenFalse, label };
}
function isDecisionNode(n) {
    return n.kind === 'decision';
}
/** The metadata key carrying a skill's routing provenance. */
export const SKILL_GRAPH_METADATA_KEY = 'skillGraph';
/**
 * The metadata key carrying the {@link DeferredBodyContract} note on each COMPILED
 * skill. The graph-level `graph.deferredBodyContract` note names the deferral, but
 * skills reach an agent through more than one door — `.skillGraph(graph)` sees the
 * graph, `.skills({ list: () => graph.skills })` sees only the skills — so the note
 * also rides each skill's own metadata. Agent build collects it from the final
 * injection list, whichever door the skills came through, and runs the deferred
 * checks exactly once (skills found by BOTH the metadata and the graph note are
 * deduped by id).
 */
export const SKILL_GRAPH_DEFERRED_CONTRACT_KEY = 'skillGraphDeferredBodyContract';
/** A route edge that fires on its own evidence — `when` code, a tool-name
 *  match, a declared status, or a data guard. NOT a model edge. The ONE
 *  predicate every determinism filter shares (9.19.0 folded four replicas
 *  into it when `onToolStatus` joined the family; `guard` joined in 9.51.0). */
function isDeterministicRoute(r) {
    return (r.when !== undefined ||
        r.onToolReturn !== undefined ||
        r.onToolStatus !== undefined ||
        r.guard !== undefined);
}
/** The declared status set, normalized to an array. */
function statusesOf(onToolStatus) {
    return Array.isArray(onToolStatus) ? onToolStatus : [onToolStatus];
}
/** Mermaid node ids must be identifier-safe; keep the original id as the label. */
function nodeId(id) {
    return 'n_' + id.replace(/[^A-Za-z0-9_]/g, '_');
}
function toolMatcher(toolName) {
    return typeof toolName === 'string' ? (n) => n === toolName : (n) => toolName.test(n);
}
/** How a skill is identified back to its author in a refusal — the description is
 *  what tells two same-id skills apart (mirrors skillsFromDir naming both files). */
function describe(skill) {
    return skill.description ?? '(no description)';
}
function plural(n, one, many) {
    return `${n} ${n === 1 ? one : many}`;
}
export function skillGraph(config) {
    const skillsById = new Map();
    const entries = [];
    const routes = [];
    let treeRoot;
    let treeScopeTools = true;
    let entryScorer;
    let entryByReadFlag = false;
    let classifyScorer;
    let classifyPolicy;
    // The negative rows, in declaration order. A graph that never calls
    // `.neverRoutes()` keeps an empty array and pays one length check at
    // check-up time — zero cost when unused.
    const neverRoutePhrases = [];
    /**
     * Register a skill under its id, refusing a second DIFFERENT skill under an id
     * already taken (8.4.0). Until then this was a bare `Map.set` — last write won,
     * silently, and the loser vanished from the compiled graph entirely: the flat
     * form kept the LAST body under the FIRST declaration's routing, the config form
     * dropped the first skill outright, and a tree kept the FIRST body and discarded
     * the second. `skillsFromDir` has always refused a collision inside its own
     * directory and `Agent.injection()` has always refused one on the agent, so the
     * graph was the only place in the library where two skills could quietly claim
     * one id — the id `read_skill` dispatches by and every edge routes by.
     *
     * Identity-based, deliberately: re-registering the SAME object is how the fluent
     * form works (`.entry(a).route(a, b)` remembers `a` twice) and how one skill sits
     * at two tree leaves (the compiler merges those into one ORed trigger).
     */
    const remember = (skill) => {
        if (skill.flavor !== 'skill') {
            throw new Error(`skillGraph: "${skill.id}" is not a skill (flavor='${skill.flavor}').`);
        }
        const claimed = skillsById.get(skill.id);
        if (claimed && claimed !== skill) {
            throw new Error(`skillGraph: two different skills claim the id "${skill.id}" — ` +
                `"${describe(claimed)}" and "${describe(skill)}". Skill ids must be unique ` +
                `(read_skill dispatches by id, and every edge routes by id); rename one, or ` +
                `pass the SAME skill object to both places.`);
        }
        skillsById.set(skill.id, skill);
        return skill.id;
    };
    const builder = {
        entry(skill, opts) {
            const id = remember(skill);
            // Exactly one condition per entry: `match` (data) or `when` (code). Both set
            // is a contradiction — which one starts the turn? — refused rather than
            // silently ANDed or ORed. (Neither set stays the `always` entry it has
            // always been; the rules form additionally refuses "neither", because a
            // rule exists to be conditional — see the config translation below.)
            if (opts?.when !== undefined && opts?.match !== undefined) {
                throw new Error(`skillGraph: entry "${id}" sets both \`match\` and \`when\` — an entry takes ` +
                    `exactly one condition. Use \`match\` (a RegExp, { keywords: [...] } or ` +
                    `{ all: [...] } over the user message — comparable and drawable) OR \`when\` ` +
                    `(a predicate over the iteration context). To combine a pattern with extra ` +
                    `logic, fold the pattern into your \`when\` predicate.`);
            }
            const compiled = opts?.match !== undefined ? compileMatch(opts.match, `entry "${id}"`) : undefined;
            // The phrasings this rule claims — validated where they are declared, so
            // an unusable list is refused at the keystroke instead of quietly proving
            // nothing at check-up time (skillExamples.ts owns every refusal).
            const examples = validateStartRuleExamples(opts?.examples, `entry "${id}"`, {
                hasCondition: opts?.when !== undefined || opts?.match !== undefined,
                isIntent: compiled?.data.kind === 'intent',
            });
            entries.push({
                id,
                when: compiled ? compiled.predicate : opts?.when,
                ...(compiled && { match: compiled.data }),
                ...(compiled?.witness && { witness: compiled.witness }),
                ...(examples && { examples }),
                label: opts?.label,
            });
            return builder;
        },
        route(from, to, opts) {
            const fromId = remember(from);
            const toId = remember(to);
            if (opts?.when && opts?.onToolReturn) {
                throw new Error(`skillGraph: route ${fromId}→${toId} sets both 'when' and 'onToolReturn' — pick one.`);
            }
            // `when` is code, `onToolStatus` is data over the same result — both
            // set is a contradiction (which one fires the edge?). `onToolReturn`
            // COMPOSES with `onToolStatus` ("this tool, with this outcome"), so
            // that pair stays legal.
            if (opts?.when && opts?.onToolStatus) {
                throw new Error(`skillGraph: route ${fromId}→${toId} sets both 'when' and 'onToolStatus' — pick ` +
                    `one. 'onToolStatus' is the data form (drawable, comparable); to combine a ` +
                    `status with extra logic, read \`result.status\` inside your 'when' predicate.`);
            }
            if (opts?.onToolStatus !== undefined && statusesOf(opts.onToolStatus).length === 0) {
                throw new Error(`skillGraph: route ${fromId}→${toId} sets 'onToolStatus: []' — an empty status ` +
                    `set can never match, so the edge would be dead wiring. Name at least one ` +
                    `status, or drop the field.`);
            }
            // `when` is code, `guard` is data over the same hop — both set is a
            // contradiction (which one fires the edge?), refused exactly as
            // when+onToolStatus is. `guard` COMPOSES with onToolReturn/onToolStatus
            // ("this tool, this outcome, AND these conditions") — conditions AND
            // anyway, so the pair stays legal there.
            if (opts?.when && opts?.guard) {
                throw new Error(`skillGraph: route ${fromId}→${toId} sets both 'when' and 'guard' — pick one. ` +
                    `'guard' is the data form (drawable, comparable, evidence-recorded); to ` +
                    `combine declared conditions with extra logic, fold the checks into your ` +
                    `'when' predicate.`);
            }
            // ONE compilation (the compileMatch pattern): the predicate that routes,
            // the data the record carries, and the evidence evaluator — all from the
            // same conditions. Every malformed shape is refused here, by name.
            const guard = opts?.guard !== undefined ? compileGuard(opts.guard, `route ${fromId}→${toId}`) : undefined;
            routes.push({
                fromId,
                toId,
                when: opts?.when,
                onToolReturn: opts?.onToolReturn,
                ...(opts?.onToolStatus !== undefined && { onToolStatus: opts.onToolStatus }),
                ...(guard !== undefined && { guard }),
                label: opts?.label,
            });
            return builder;
        },
        tree(root, opts) {
            treeRoot = root;
            if (opts?.scopeTools === false)
                treeScopeTools = false;
            return builder;
        },
        entryBy(scorer) {
            entryScorer = scorer;
            return builder;
        },
        entryByRelevance(embedder) {
            entryScorer = embeddingScorer(embedder);
            return builder;
        },
        entryByRead() {
            entryByReadFlag = true;
            return builder;
        },
        classify(scorer, policy) {
            classifyScorer = scorer;
            classifyPolicy = policy;
            return builder;
        },
        neverRoutes(phrases) {
            // Validated at the keystroke, like a rule's `examples` — an unusable row
            // is refused where it was written instead of quietly asserting nothing at
            // check-up time. The already-declared set makes the duplicate refusal
            // work across calls, not just within one.
            neverRoutePhrases.push(...validateNeverRoutes(phrases, '.neverRoutes(...)', new Set(neverRoutePhrases.map(neverRouteKey))));
            return builder;
        },
        build(opts = {}) {
            // One entry router per graph — classify is a third machine beside the
            // scorer and the model-read menu, and two of them deciding one turn
            // start would let the record and the routing disagree.
            if (classifyScorer && (entryScorer || entryByReadFlag)) {
                throw new Error('skillGraph: pick ONE entry router — .classify(scorer) classifies the message ' +
                    'against declared intents; .entryBy()/.entryByRelevance() rank descriptions; ' +
                    '.entryByRead() lets the LLM read the menu. This graph configures more than one.');
            }
            if (classifyScorer && treeRoot) {
                throw new Error('skillGraph: .classify() is for flat entry/route graphs; a .tree() already routes ' +
                    'by predicate on every iteration (there is no turn start for a classifier to route).');
            }
            // Intent rules need their judge — an intent compiles to no message
            // predicate, so without a classifier the entry could never be chosen and
            // would silently never load. Refused with a stable code, like rule-id-exists.
            const intentEntries = entries.filter((e) => e.match?.kind === 'intent');
            if (intentEntries.length > 0 && !classifyScorer) {
                const problems = intentEntries.map((e) => ({
                    kind: 'error',
                    code: 'intent-without-classify',
                    message: `entry "${e.id}" declares match: { intent } and the graph configures no ` +
                        `classifier — intent rules need one to be judged. Add ` +
                        `\`classify: keywordScorer()\` (no dependency) or \`embeddingScorer(embedder)\`; ` +
                        `\`llmClassifier(provider)\` if you want the model to judge.`,
                    skill: e.id,
                }));
                throw new Error(`skillGraph: build-time check-up failed:\n${formatCheckup({ ok: false, problems })}`);
            }
            if (entryByReadFlag && entryScorer) {
                throw new Error('skillGraph: pick one of .entryByRead() or .entryBy()/.entryByRelevance() — not ' +
                    'both (the LLM reads the menu, OR a scorer ranks it).');
            }
            if (entryByReadFlag && treeRoot) {
                throw new Error('skillGraph: .entryByRead() is for flat entry/route graphs; a .tree() already ' +
                    'routes by predicate (no entry menu).');
            }
            if (entryScorer && treeRoot) {
                throw new Error('skillGraph: .entryBy()/.entryByRelevance() is for flat entry/route graphs; a ' +
                    '.tree() already routes by predicate (the scorer would be ignored).');
            }
            // One dial, one home: a tree declares tool scoping on `.tree(root, { scopeTools })`
            // (object form: the tree arm's own field). Accepting it here too would let the two
            // homes disagree, and only one of them would win — silently.
            if (treeRoot && opts.scopeTools !== undefined) {
                throw new Error("skillGraph: `scopeTools` on build() is the FLAT arm's dial. This graph is a " +
                    '.tree(), which declares tool scoping on .tree(root, { scopeTools }) (object ' +
                    "form: the tree arm's own `scopeTools` field) — set it there instead.");
            }
            // A negative row is judged against the START RULES, and a `.tree()` has
            // none — it routes by predicate on every iteration, with no turn start to
            // claim or decline a phrase. Accepting the rows here would run them
            // against an empty rule set and report a clean pass, which is the one
            // answer a negative assertion must never give by construction.
            if (treeRoot && neverRoutePhrases.length > 0) {
                throw new Error(`skillGraph: .neverRoutes(...) declares ${plural(neverRoutePhrases.length, 'phrase', 'phrases')} that must claim no skill, but this graph is a .tree() — which routes by predicate ` +
                    'on every iteration and has no start rules for a phrase to be judged against, so ' +
                    'the rows would pass in silence and you would read that as proof. Assert them on ' +
                    'a flat entry/route graph, or test the tree by running its predicates directly.');
            }
            // A tree and the flat entry/route wiring are two ways to declare the same
            // thing, and only ONE of them compiles: the tree branch below never reads
            // `entries` or `routes`. Until 8.4.0 that was silent — the tree won and every
            // declared entry and route was dropped without a word, `checkup()` answered
            // `{ ok: true, problems: [] }` because its tree arm short-circuits, and the
            // author's flat wiring simply never existed at runtime.
            if (treeRoot && (entries.length > 0 || routes.length > 0)) {
                const dropped = [
                    ...(entries.length > 0 ? [plural(entries.length, 'entry', 'entries')] : []),
                    ...(routes.length > 0 ? [plural(routes.length, 'route', 'routes')] : []),
                ].join(' and ');
                throw new Error('skillGraph: .tree() and .entry()/.route() both declare the routing and only one ' +
                    `can compile — the tree wins, so the ${dropped} declared here would be silently ` +
                    'dropped. tree() owns the graph: remove the .entry()/.route() calls, or drop ' +
                    '.tree() and route with the flat entry/route form.');
            }
            const skills = [];
            const nodes = [];
            const edges = [];
            // The build-time check-up — pure over the declared entries/routes/skills,
            // PLUS the proposal-009 Tier-1 skill-body ↔ tool-contract checks (warnings).
            //
            // `triggerKinds` reads the COMPILED `skills` array (filled below, before any
            // caller can reach this closure) rather than the declaration, because that is
            // where the truth is: `deriveTrigger` returns null for an unwired skill, so the
            // skill keeps whatever trigger it arrived with — and `unreachable-skill`'s
            // sentence about read_skill is only true for `llm-activated`.
            const checkup = (options = {}) => {
                const wiring = checkupGraph({
                    skillIds: new Set(skillsById.keys()),
                    // `conditional` counts a data `match` exactly like a `when` (it compiled
                    // to one — an INTENT match compiles to no predicate but is judged by the
                    // classifier, which is still a condition), so a rule-router built from
                    // matchers is never read as a fan-out. The match DATA rides along so the
                    // pairwise rule checks (`overlapping-rules` / `rules-shadowed-by-order`)
                    // have something they can honestly compare.
                    entries: entries.map((e) => ({
                        id: e.id,
                        conditional: e.when !== undefined || e.match?.kind === 'intent',
                        ...(e.match && { match: e.match }),
                    })),
                    // A guard rides as DATA so the check-up can prove contradictions
                    // (`guard-unsatisfiable`) — beside the only preconditions that are
                    // provably comparable: an exact-string onToolReturn (a RegExp is not
                    // decided) and the declared status set.
                    routes: routes.map((r) => ({
                        fromId: r.fromId,
                        toId: r.toId,
                        deterministic: isDeterministicRoute(r),
                        ...(r.guard !== undefined && { guard: r.guard.data }),
                        ...(typeof r.onToolReturn === 'string' && { onToolReturnExact: r.onToolReturn }),
                        ...(r.onToolStatus !== undefined && {
                            onToolStatuses: statusesOf(r.onToolStatus),
                        }),
                    })),
                    isTree: treeRoot !== undefined,
                    exclusiveEntries: entryScorer !== undefined || entryByReadFlag || classifyScorer !== undefined,
                    triggerKinds: new Map(skills.map((s) => [s.id, s.trigger.kind])),
                    hasClassifier: classifyScorer !== undefined,
                });
                const contract = checkSkillContracts([...skillsById.values()], {
                    ...(options.knownTools && { knownTools: options.knownTools }),
                });
                // The one PROVABLE intent-pair fact (SG-C) — the same example under two
                // intents. Composed here like the contract checks; the scorer-dependent
                // audit is the ASYNC `checkupIntents()`.
                const intentDuplicates = findDuplicateIntentExamples(entries);
                // The artifact vocabularies (SG-F, 9.25.0). Composed here like the
                // contract checks and NOT deferred: unlike a tool name, a `produces`
                // declaration cannot arrive later from `.tool()` — it is written on a
                // skill, and every skill of this graph is already in hand. Returns []
                // the moment nothing declares a vocabulary, so a graph that never
                // heard of the feature pays one `Array.some`.
                const vocabularies = checkArtifactVocabularies([...skillsById.values()]);
                // The declared phrasings (SG-G) — the three properties a witness phrase
                // makes provable. `orderDecides` is the SAME gate the pairwise rule
                // checks use: declaration order decides the turn start under the default
                // form and under a classifier's tier 1, and does not under a scorer /
                // `.entryByRead()` — where only the order-independent property is
                // claimed, and a note says which one was skipped. Returns frozen empties
                // the moment no rule declared examples, so a graph that never heard of
                // the feature pays one `Array.some` and reports the identical object.
                const examples = checkStartRuleExamples({
                    entries,
                    orderDecides: !(entryScorer !== undefined || entryByReadFlag) || classifyScorer !== undefined,
                    hasClassifier: classifyScorer !== undefined,
                });
                // The NEGATIVE rows — phrases this graph claims NOWHERE. No
                // `orderDecides` gate: "nobody claims it" is an assertion every rule
                // has to satisfy, so the order they are read in cannot change the
                // answer (skillNeverRoutes.ts's header has the whole argument).
                const negatives = checkNeverRoutes({ entries, phrases: neverRoutePhrases });
                // The PARTITION advisories — how this graph cut the world into skills,
                // read from names and structure alone. Warnings, always: every signal
                // has a legitimate design behind it, and each message says which.
                const partition = checkPartition({
                    skills: [...skillsById.values()],
                    routeCount: routes.length,
                    entryCount: entries.length,
                    isTree: treeRoot !== undefined,
                });
                // The entry-evidence rows (9.58.0) — the two ONE-ROW facts about
                // guessed entries (skillEntryEvidence.ts has the whole argument,
                // including why the intuitive per-node exit lint is dead).
                const entryEvidence = checkEntryEvidence({
                    entries: entries.map((e) => ({
                        id: e.id,
                        conditional: e.when !== undefined || e.match?.kind === 'intent',
                        hasExamples: (e.examples?.length ?? 0) > 0,
                    })),
                    routeFromIds: new Set(routes.map((r) => r.fromId)),
                    neverRoutesCount: neverRoutePhrases.length,
                    isTree: treeRoot !== undefined,
                });
                const problems = [
                    ...wiring.problems,
                    ...intentDuplicates,
                    ...contract,
                    ...vocabularies,
                    ...examples.problems,
                    ...negatives.problems,
                    ...partition,
                    ...entryEvidence,
                ];
                // Notes are statements about the report's own REACH, and each check
                // brings its own — a graph that declared both examples and negative
                // rows carries both boundaries, in declaration order of the checks.
                const notes = [...examples.notes, ...negatives.notes];
                return {
                    ok: !problems.some((p) => p.kind === 'error'),
                    problems,
                    ...(notes.length > 0 && { notes }),
                };
            };
            // The cursor resolver — the single source of truth for `from`-gated, sticky
            // routing. Flat mode wires it into each route target's trigger AND returns it
            // for the loop's cursor-update stage. Tree mode has no cursor (per-iteration
            // predicate routing), so it stays a no-op there.
            //
            // It resolves to a `CursorMove` — where AND by which clause — and `nextSkill`
            // is the `.to` projection of it (8.5.0). ONE resolver still: the cause is
            // decided at the same `return` that decides the destination, so an observer
            // can never disagree with the routing about why the cursor moved.
            let resolveCursor = (ctx) => ctx.currentSkillId === undefined
                ? { by: 'none' }
                : { from: ctx.currentSkillId, to: ctx.currentSkillId, by: 'stay' };
            // The reachable-set resolver — what `read_skill` may jump to from the cursor
            // (the runtime gate enforces it). Default empty; set per mode below.
            let reachableSkills = () => [];
            // The suppression reporter (8.15.0) — conditional entries whose rule matched
            // while the cursor was elsewhere. Nothing to suppress without a cursor, so tree
            // mode and cursor-less graphs keep the empty default.
            let supersededEntries = () => [];
            // The relevance entry scorer — present only with `.entryByRelevance()` (flat).
            let scoreEntries;
            // The turn-routing plan (SG-C) — flat graphs only; a tree has no turn start.
            let turnRouting;
            if (treeRoot) {
                // Decision-tree mode (v3): compile each leaf to a path-conjunction trigger.
                // Every leaf is REGISTERED as it compiles (8.4.0). Two things ride on that:
                // the duplicate-id refusal now covers leaves (before, two different skills
                // sharing an id as two leaves merged, first body wins, silently), and the
                // skill-CONTRACT checks finally run for a fluent `.tree()` graph — until
                // 8.4.0 `skillsById` stayed empty there, so `checkup()` answered
                // `{ ok: true, problems: [] }` for a tree whose body called a tool that
                // does not exist, while the byte-identical config-form graph reported it.
                compileTree(treeRoot, () => true, { skills, nodes, edges }, null, { n: 0 }, [], treeScopeTools, remember);
                attachExactlyOneLeafMonitor(skills);
                const leafIds = skills.map((s) => s.id);
                // A tree routes to its LEAVES and to nothing else, so a skill listed in
                // `skills[]` that is not a leaf is compiled out of the graph entirely — it
                // never reaches the agent, so no trigger, no `read_skill`, no body, ever.
                // Refused by name rather than dropped (8.4.0).
                const leafSet = new Set(leafIds);
                for (const id of skillsById.keys()) {
                    if (leafSet.has(id))
                        continue;
                    throw new Error(`skillGraph({ tree }): skill "${id}" is listed in skills[] but is not a leaf of ` +
                        'the tree, so it would never load — a tree routes only to its leaves. Add it ' +
                        `to the tree as a leaf, drop it from skills[], or register it on the agent ` +
                        `with .skill(${id}) to keep it read_skill-reachable.`);
                }
                // Tree mode has no cursor, so `read_skill` has nothing to jump — EMPTY (8.5.0).
                //
                // Until 8.5.0 this answered `leafIds`, which made the gate accept a leaf pick
                // that could never take effect: a leaf compiles to `{ kind: 'rule', activeWhen:
                // pathCond }`, and a `read_skill` call writes only `activatedInjectionIds`,
                // which no `rule` trigger reads. The pick was accepted, the tool answered
                // "Skill 'x' activated for the next iteration", the tree re-decided by
                // predicate, and `reroute_superseded` fired naming a winner that did not exist
                // (tree mode never writes a cursor at all). Three of the library's own
                // invariants say the pick cannot be honoured here instead: exactly ONE leaf
                // fires per iteration (`attachExactlyOneLeafMonitor` warns otherwise), each
                // leaf's tools are scoped on that basis (`TreeOptions.scopeTools`), and
                // `toMermaid()` draws what is declared — a model lever over predicate routing
                // is not on the drawing. So the gate refuses, in the same terms 8.4.0 already
                // uses for every other rule-triggered skill.
                //
                // `read_skill` is not dead under a tree: the OPEN skills (anything registered
                // beside the graph — `.skill(x)`, `.skills(reg)`, `.selfExplain()`) are still
                // admitted from any cursor by the agent's gate, which is the only place that
                // knows what else is registered.
                reachableSkills = () => [];
            }
            else {
                // Flat entry/route mode (v1 + v2 keystone). `from`-gating + sticky cursor
                // both derive from one pure resolver so they can never diverge.
                // `.entryByRead()` makes the entries EXCLUSIVE (like `.entryByRelevance()`),
                // but the cold-start pick is the model's: no entry auto-loads — the LLM picks
                // one via `read_skill`, and that choice becomes the cursor (see
                // makeResolveCursor). `.classify()` suppresses the cold walk the SAME way:
                // the cascade decides the start (or offers a menu) — never the
                // first-declared entry by declaration-order accident. Without the
                // suppression, a 'menu' verdict would fall into the cold walk and silently
                // enter the first no-`when` entry while the record claimed a menu was
                // outstanding. This flag is only the COMPILE-TIME half: an entry-scorer
                // graph opts into the cascade at MOUNT time (`continuity:
                // 'conversation'`), which no graph build can see — there the resolver
                // suppresses the walk on the VERDICT's presence (`ctx.turnRoute`), so
                // both arms obey one law: the cascade's verdict, never the
                // declaration-order accident.
                const llmReadEntry = entryByReadFlag || classifyScorer !== undefined;
                resolveCursor = makeResolveCursor(entries, routes, llmReadEntry);
                // Triggers share ONE memoized view of the resolver per evaluation pass —
                // see memoizePerPass. The public resolvers (one call per iteration) stay raw.
                const nextSkillForTriggers = memoizePerPass(resolveCursor);
                reachableSkills = makeReachableSkills(entries, routes);
                // Exclusive entries are cursor-gated with no rule clause at all, so nothing
                // there can be superseded by one — the reporter is for the DEFAULT form.
                if (!entryScorer && !llmReadEntry) {
                    supersededEntries = makeSupersededEntries(entries, resolveCursor);
                }
                if (entryScorer)
                    scoreEntries = makeScoreEntries(entries, skillsById, entryScorer);
                // The turn-routing plan (SG-C): candidates, tier-1 rules, tie policy —
                // projected ONCE, so the router, the menu and the audit read one shape.
                turnRouting = buildTurnRoutingPlan({
                    entries,
                    describeFor: (id) => skillsById.get(id)?.description,
                    policy: resolveRoutingPolicy(classifyPolicy),
                    ...(classifyScorer && { scorer: classifyScorer }),
                });
                // `scopeTools: true` (flat) — the WIRED skills, i.e. everything an entry or a
                // route mentions. Stamped exactly as the tree stamps its leaves: a graph-level
                // DEFAULT (`existingAuto ?? …`), never an override of a skill's own declaration.
                // A listed-but-unwired skill stays additive — the graph does not route it, so
                // the graph does not scope it (it remains an OPEN, read_skill-reachable skill).
                // Default `false` preserves additive behavior; scoping stays explicit.
                const scopedIds = opts.scopeTools === true
                    ? new Set([...entries.map((e) => e.id), ...routes.flatMap((r) => [r.fromId, r.toId])])
                    : undefined;
                for (const [id, skill] of skillsById) {
                    const trigger = deriveTrigger(id, skill, entries, routes, nextSkillForTriggers, entryScorer !== undefined || llmReadEntry);
                    const routing = routingFor(id, entries, routes);
                    const existingAuto = skill.metadata
                        ?.autoActivate;
                    const autoActivate = existingAuto ?? (scopedIds?.has(id) ? 'currentSkill' : undefined);
                    skills.push({
                        ...skill,
                        ...(trigger && { trigger }),
                        metadata: {
                            ...skill.metadata,
                            [SKILL_GRAPH_METADATA_KEY]: routing,
                            ...(autoActivate && { autoActivate }),
                        },
                    });
                    nodes.push({ id, kind: 'skill', label: id });
                }
                edges.push(...entries.map((e) => ({
                    from: null,
                    to: e.id,
                    kind: 'entry',
                    label: e.label,
                    ...(e.match && { match: e.match }),
                })), ...routes.map((r) => {
                    // A guard composed with onToolReturn/onToolStatus keeps that kind
                    // (the guard rides beside it as data, like `match` on an entry);
                    // a guard ALONE is its own kind — it is deterministic routing,
                    // and calling it 'model' (no trigger) or 'predicate' (opaque
                    // code) would both be false.
                    const kind = r.onToolStatus
                        ? 'on-tool-status'
                        : r.onToolReturn
                            ? 'on-tool-return'
                            : r.guard
                                ? 'guard'
                                : r.when
                                    ? 'predicate'
                                    : 'model';
                    // The derived caption folds the guard clause in ("… when
                    // riskLevel ≥ high") wherever a tool/status part is already
                    // derived here; a guard-ONLY edge stores no label and toMermaid
                    // captions it from the data (the entry-`match` precedent, which
                    // keeps the mermaid escaping in one place).
                    const guardSuffix = r.guard ? ` when ${plainGuardCaption(r.guard.data)}` : '';
                    return {
                        from: r.fromId,
                        to: r.toId,
                        kind,
                        label: r.label ??
                            (r.onToolStatus
                                ? `on ${r.onToolReturn !== undefined ? `${String(r.onToolReturn)} ` : ''}status=${statusesOf(r.onToolStatus).join('|')}${guardSuffix}`
                                : r.onToolReturn
                                    ? `on ${String(r.onToolReturn)}${guardSuffix}`
                                    : undefined),
                        ...(r.guard && { guard: r.guard.data }),
                    };
                }));
            }
            // Run the check-up per the `check` mode: 'throw' fails loud on an error; 'warn'
            // prints in dev mode only (quiet in prod / tests) and NEVER throws; 'off' skips.
            //
            // The default is `'throw'` since 8.7.0, matching the object-literal form. It was
            // `'warn'`, which meant a fluent graph with `no-entry` or `unknown-skill` — a
            // graph that cannot start a turn at all — built in silence outside dev mode and
            // surfaced as a run that entered no skill. An explicit `check: 'warn'` still
            // never throws, so the opt-down survives with its name intact.
            const check = opts.check ?? 'throw';
            // Without `knownTools`, the body-contract checks (`body-foreign-tool` /
            // `body-unknown-tool`) cannot tell a typo from a baseline tool the agent
            // registers later — the graph builds before `.tool()` runs. So this PASS
            // defers them (reports nothing it cannot prove) and leaves a
            // `deferredBodyContract` note in TWO places that say the same thing: on the
            // graph, and on each compiled skill's metadata — because skills reach an
            // agent through more than one door (`.skillGraph(graph)` sees the graph;
            // `.skills({ list: () => graph.skills })` sees only the skills). Agent build
            // collects the note from its final injection list and runs the checks exactly
            // once against the full tool registry, whichever door was used. With
            // `knownTools` given, the author supplied the full picture and the checks run
            // right here, as always — no note anywhere, and the agent never re-runs them
            // (one problem, one report). `graph.checkup()` is untouched either way: the
            // explicit lint call always runs every check over what it can see.
            const deferBodyContract = check !== 'off' && opts.knownTools === undefined;
            const deferredNote = deferBodyContract
                ? { mode: check }
                : undefined;
            if (check !== 'off') {
                const result = checkup({ ...(opts.knownTools && { knownTools: opts.knownTools }) });
                const reported = deferBodyContract
                    ? {
                        ok: result.ok, // body-contract problems are warnings — they never decide `ok`
                        problems: result.problems.filter((p) => p.code !== 'body-foreign-tool' && p.code !== 'body-unknown-tool'),
                        // A deferred pass filters PROBLEMS, never the report's statement
                        // about its own reach — dropping the notes here would make the
                        // build-time surface quieter than `graph.checkup()` about exactly
                        // the thing a clean report must not be read as.
                        ...(result.notes && { notes: result.notes }),
                    }
                    : result;
                if (check === 'throw' && !reported.ok) {
                    throw new Error(`skillGraph: build-time check-up failed:\n${formatCheckup(reported)}`);
                }
                if (reported.problems.length > 0) {
                    devWarn(`skillGraph: build-time check-up found problems:\n${formatCheckup(reported)}`);
                }
            }
            return {
                // Deferring? The note rides each skill too (dedup at agent build is by id,
                // so the double stamp can never double-report). No deferral → the exact
                // same objects as always: zero cost when unused.
                skills: deferredNote
                    ? skills.map((s) => ({
                        ...s,
                        metadata: { ...s.metadata, [SKILL_GRAPH_DEFERRED_CONTRACT_KEY]: deferredNote },
                    }))
                    : skills,
                edges,
                nodes,
                ...(deferredNote && { deferredBodyContract: deferredNote }),
                toMermaid: () => renderMermaid(nodes, edges),
                // `nextSkill` is the `.to` PROJECTION of the one resolver, not a second
                // implementation — the two cannot answer differently.
                nextSkill: (ctx) => resolveCursor(ctx).to,
                explainNextSkill: (ctx) => resolveCursor(ctx),
                supersededEntries: (ctx) => supersededEntries(ctx),
                reachableSkills: (currentSkillId) => reachableSkills(currentSkillId),
                checkup: (options) => checkup(options),
                ...(scoreEntries && { scoreEntries }),
                ...(turnRouting && { turnRouting }),
                ...(classifyScorer
                    ? { entrySelection: 'classify' }
                    : entryScorer
                        ? { entrySelection: 'scorer' }
                        : entryByReadFlag
                            ? { entrySelection: 'model-read' }
                            : {}),
                ...(classifyScorer && {
                    checkupIntents: (signal) => runCheckupIntents({
                        entries,
                        scorer: classifyScorer,
                        policy: resolveRoutingPolicy(classifyPolicy),
                        ...(signal && { signal }),
                    }),
                }),
            };
        },
    };
    // Object-literal form → translate to the fluent calls + build. Listing skills
    // independently of the wiring is what lets the check-up flag a listed-but-unwired
    // skill (every config skill is registered, even if no edge references it).
    if (config) {
        // `tree` and `start`/`steps` are two ways to declare the routing and only one
        // compiles. The TYPE already refuses the pair (SkillGraphConfig is a union with
        // `tree?: never` on the flat arm), so this is the runtime half of the same
        // refusal — for JavaScript callers and for a config assembled through `any`.
        // Until 8.4.0 `start` was skipped by an `else if` and `steps` were compiled into
        // routes the tree branch then ignored: both vanished without a word.
        const overruled = [
            ...(config.start !== undefined ? ['start'] : []),
            ...(config.steps !== undefined ? ['steps'] : []),
        ];
        if (config.tree !== undefined && overruled.length > 0) {
            const quoted = overruled.map((k) => `\`${k}\``).join(' and ');
            const one = overruled.length === 1;
            throw new Error(`skillGraph({ tree, ${overruled.join(', ')} }): ${quoted} declare${one ? 's' : ''} the ` +
                `routing that \`tree\` already owns, so ${one ? 'it' : 'they'} would be silently ` +
                'ignored — a tree routes by predicate on every iteration and has no entry menu ' +
                `and no cursor. Remove ${quoted}, or drop \`tree\` and route with the flat form.`);
        }
        for (const s of config.skills)
            remember(s);
        const resolve = (id) => {
            const s = skillsById.get(id);
            if (!s)
                throw new Error(`skillGraph: config references skill "${id}" not in skills[].`);
            return s;
        };
        if (config.tree) {
            // `scopeTools` reaches the compiler here since 8.7.0 — the fluent form's
            // `.tree(root, { scopeTools: false })` had no object-form twin, so this arm
            // silently forced every leaf to `autoActivate: 'currentSkill'`.
            builder.tree(config.tree, {
                ...(config.scopeTools !== undefined && { scopeTools: config.scopeTools }),
            });
        }
        else if (config.start !== undefined) {
            const start = config.start;
            if (typeof start === 'string')
                builder.entry(resolve(start));
            else if ('use' in start)
                builder.entry(resolve(start.use));
            else if ('rules' in start) {
                // (a) rule-id-exists — validate EVERY rule's target before compiling any of
                // them, so the refusal lists every bad id at once (not just the first) and
                // names what IS allowed. An unknown id cannot compile into an entry at all,
                // so this refuses under every `check` mode — a rule silently dropped under
                // `check: 'warn'` would be the accepted-and-silently-wrong build.
                const missing = start.rules
                    .map((r, i) => ({ use: r.use, i }))
                    .filter(({ use }) => !skillsById.has(use));
                if (missing.length > 0) {
                    const known = [...skillsById.keys()].map((k) => `"${k}"`).join(', ');
                    const problems = missing.map(({ use, i }) => ({
                        kind: 'error',
                        code: 'rule-id-exists',
                        message: `start.rules[${i}] routes to skill "${use}", which is not in skills[]. ` +
                            `Known skill ids: ${known || '(none)'}. Fix the rule's \`use\`, or add the ` +
                            `skill to skills[].`,
                        skill: use,
                    }));
                    throw new Error(`skillGraph: build-time check-up failed:\n${formatCheckup({ ok: false, problems })}`);
                }
                for (const [i, r] of start.rules.entries()) {
                    // Exactly one of `match`/`when` per rule. The type already says so
                    // (`SkillStartRule` is a union with `never` on the other arm); this is the
                    // runtime half, for JavaScript callers and configs assembled through `any`.
                    // "Neither" is refused too — a rule exists to be conditional; the
                    // unconditional start is `start: 'id'` / `{ use }`, and saying so beats
                    // compiling a rule that would swallow every turn.
                    const use = r.use; // read before the narrowing checks — TS collapses the
                    // union to `never` inside the refusal branch (the type says it can't happen;
                    // this is the runtime half for callers the compiler never saw).
                    const both = r.when !== undefined && r.match !== undefined;
                    const neither = r.when === undefined && r.match === undefined;
                    if (both || neither) {
                        throw new Error(`skillGraph: start.rules[${i}] (use: "${use}") ${both ? 'sets both `match` and `when`' : 'sets neither `match` nor `when`'} — a rule takes exactly one condition. Say when it applies with \`match\` ` +
                            `(a RegExp, { keywords: [...] } or { all: [...] } tested against the user ` +
                            `message) or \`when\` (a predicate over the iteration context). For an ` +
                            `unconditional start, use \`start: '${use}'\` instead of a rule.`);
                    }
                    builder.entry(resolve(r.use), {
                        ...(r.when && { when: r.when }),
                        ...(r.match !== undefined && { match: r.match }),
                        ...(r.examples !== undefined && { examples: r.examples }),
                    });
                }
                // The classifier + its tie policy (SG-C) — the rules form is where
                // intents live, so this is where the judge is declared.
                if (start.classify)
                    builder.classify(start.classify, start.routing);
                else if (start.routing !== undefined) {
                    throw new Error('skillGraph: start.routing is the tie policy of the classifier and means nothing ' +
                        'without one — declare `classify` beside it (the one-dial-one-home rule: the ' +
                        'policy lives where the scorer it governs is declared).');
                }
            }
            else {
                for (const id of start.entries)
                    builder.entry(resolve(id));
                // scoredBy (any scorer) > byRelevance (embedder sugar) > entryByRead (LLM picks).
                if (start.scoredBy)
                    builder.entryBy(start.scoredBy);
                else if (start.byRelevance)
                    builder.entryByRelevance(start.byRelevance);
                else
                    builder.entryByRead();
            }
        }
        // The negative rows (graph level, like the skills list itself — a phrase
        // that must route nowhere belongs to no skill and to no `start` arm).
        // Declared BEFORE `build()` so the tree refusal fires there, in one place,
        // for both forms.
        if (config.neverRoutes !== undefined)
            builder.neverRoutes(config.neverRoutes);
        for (const step of config.steps ?? []) {
            builder.route(resolve(step.from), resolve(step.to), {
                ...(step.when && { when: step.when }),
                ...(step.onToolReturn && { onToolReturn: step.onToolReturn }),
                ...(step.onToolStatus !== undefined && { onToolStatus: step.onToolStatus }),
                ...(step.guard !== undefined && { guard: step.guard }),
                ...(step.label && { label: step.label }),
            });
        }
        return builder.build({
            check: config.check ?? 'throw',
            ...(config.knownTools && { knownTools: config.knownTools }),
            // The FLAT arm's tool-scoping dial rides through to the one compiler below.
            // (The tree arm's `scopeTools` was already handed to `.tree()` above — passing
            // it here too would trip build()'s one-dial-one-home refusal.)
            ...(config.tree === undefined &&
                config.scopeTools !== undefined && { scopeTools: config.scopeTools }),
        });
    }
    return builder;
}
/**
 * The official vocabulary (9.51.0): you declare the **SkillMap**; the agent
 * is the **SkillWalker**; the recording carries both.
 *
 * `defineSkillMap` is a PERMANENT thin alias of {@link skillGraph} — the same
 * function object (reference-equal), same overloads, both names exported
 * forever; neither is a rename of the other. Use whichever reads better:
 * `defineSkillMap({...})` beside `defineSkill({...})` says what you are
 * doing; `skillGraph()` says what you get.
 *
 * There is deliberately NO `SkillWalker` export. The walker is not a thing
 * you construct — it is the agent itself (`.skillGraph(map)` mounts the map;
 * the agent walks it), moving the cursor by exactly three movers, every move
 * on the record as `cursorMove`:
 *
 *   • **llm**    — the model picks via `read_skill`, bounded by the gate
 *                  (`by: 'model-pick'`; refusals are `skill.rejected`);
 *   • **guard**  — your DATA decides: a `when`/`onToolReturn`/`onToolStatus`/
 *                  `guard:` edge fires, evidence recorded (`by: 'route'`,
 *                  with `cursorMove.guard` when a data guard judged it);
 *   • **linear** — no choice: a bare hand-off that fires every time its
 *                  source finishes (an `onToolReturn` edge with one target —
 *                  the degenerate guard).
 */
export const defineSkillMap = skillGraph;
// FOLD · the one owner of the skills reachable from where the cursor stands (declared successors ∪ entries, minus the cursor)
// consumers read this and never re-derive it: the read_skill offer builder (Agent.ts · readSkillOfferFor), the
// injection-engine config's `reachableSkills` and the read_skill gate's `allowedSkillIds` (both Agent.ts) — which
// toolCalls reads back as deps.allowedSkillIds at the propose-transition judge and at the gate itself
// (toolCalls.ts · "── Skill-graph read_skill GATE").
// detached: yes — dedupe builds a fresh array per call; the declarations never leave.
/**
 * The reachable-set resolver (the read_skill gate's allowed set). Pure +
 * deterministic over the build-time entries/routes:
 *   • cold start (cursor undefined) → the entry skills (you enter via entries);
 *   • otherwise → the cursor's direct successors (ANY declared edge out of it,
 *     deterministic OR bare/model) ∪ the entry skills, minus the cursor itself.
 *
 * That last exclusion is about MOVEMENT and nothing else: there is nowhere to
 * move to. It is NOT a statement that the cursor is unavailable, and five
 * consumers read it as one until {@link classifySkillTarget} was given the
 * question — read its doc comment before writing a `requested === cursor`
 * check anywhere. Declaration order preserved; ids de-duplicated.
 */
function makeReachableSkills(entries, routes) {
    const entryIds = entries.map((e) => e.id);
    return (cur) => {
        const ids = cur === undefined ? [...entryIds] : [...successorsOf(cur, routes), ...entryIds];
        return dedupe(cur === undefined ? ids : ids.filter((id) => id !== cur));
    };
}
// FOLD · the one owner of which class a read_skill target falls in from one cursor: 'self' | 'hop' | 'open' | 'unreachable'
// consumers read this and never re-derive it: skillToolDescriptors.ts · grantableRows and skillToolDescriptors.ts ·
// classOf · toolCalls.ts · atMountedCursor, the propose-transition judge (toolCalls.ts · "── A PROPOSAL TO THE
// CURSOR'S OWN SKILL IS A STAY") and the read_skill gate (toolCalls.ts · "── Skill-graph read_skill GATE") ·
// re-exported for consumers at doors/skill-graph.ts.
// detached: yes — a string literal over readonly inputs the caller already owns.
/**
 * Classify one `read_skill` target against one cursor. See
 * {@link SkillTargetClass} for the law this function owns.
 *
 * @example
 * ```ts
 * classifySkillTarget({ cursor: 'billing', target: 'billing' });               // 'self'
 * classifySkillTarget({ cursor: 'billing', target: 'refunds', hops: ['refunds'] }); // 'hop'
 * classifySkillTarget({ cursor: 'billing', target: 'debug', open: ['debug'] });     // 'open'
 * classifySkillTarget({ cursor: 'billing', target: 'vault' });                 // 'unreachable'
 * ```
 */
export function classifySkillTarget(args) {
    const { cursor, target, hops, open } = args;
    if (cursor !== undefined && target === cursor)
        return 'self';
    if (hops?.includes(target) === true)
        return 'hop';
    if (open?.includes(target) === true)
        return 'open';
    return 'unreachable';
}
/** Direct successors of `from` — every declared route edge out of it (any kind). */
function successorsOf(from, routes) {
    return routes.filter((r) => r.fromId === from).map((r) => r.toId);
}
function dedupe(ids) {
    return [...new Set(ids)];
}
/**
 * Bind the chosen `EntryScorer` strategy into `graph.scoreEntries`. The engine owns
 * the `when`-filtering (it needs `ctx` + the entry predicates); the scorer owns the
 * ranking. Filters the entries to the `when`-passing candidates, hands the scorer
 * `{ userMessage, candidates: { id, description } }`, and returns its `EntryScoring`.
 * Async wrapper so a sync scorer (`keywordScorer`) and an async one (`embeddingScorer`)
 * present identically; runs once per turn in the OFF-LOOP PickEntry stage, never in
 * the sync route triggers, so `nextSkill` stays synchronous. An empty candidate set
 * (or a throwing scorer, caught by PickEntry) falls back to the normal cold-start entry.
 */
function makeScoreEntries(entries, skillsById, scorer) {
    return async (ctx, signal) => {
        const candidates = entries
            .filter((e) => {
            if (!e.when)
                return true;
            try {
                return e.when(ctx);
            }
            catch (err) {
                warnMatcherThrew(`entry "${e.id}"`, err);
                return false;
            }
        })
            .map((e) => ({ id: e.id, description: skillsById.get(e.id)?.description ?? e.id }));
        return scorer.score({ userMessage: ctx.userMessage, candidates }, signal);
    };
}
/** Does a single route edge fire for ONE tool result of the batch?
 *  `onToolStatus` matches the result's DECLARED status (composed with the
 *  tool name when `onToolReturn` is also set — a result with no status can
 *  never match a status edge); `onToolReturn` alone matches the tool NAME;
 *  `when` runs the predicate over the result (same `{ toolName, result }`
 *  shape it has always received, plus `status` when declared). The caller
 *  walks the batch in call order (9.16.0) — before that, only
 *  `ctx.lastToolResult` was ever offered here. */
function routeMatches(r, res) {
    if (!edgePreconditionsPass(r, res))
        return false;
    if (r.onToolStatus !== undefined || r.onToolReturn !== undefined)
        return true;
    return r.when ? r.when(res) : false;
}
/** The tool/status PRECONDITIONS of one edge against one result — the part a
 *  `guard` is judged AFTER (9.51.0). True when the edge declares neither.
 *  Split out of `routeMatches` because the resolver dispatches guard edges
 *  itself: the guard's evidence must be collected at the very statement that
 *  decides the hop, and "the guard decided" is only true once these passed. */
function edgePreconditionsPass(r, res) {
    if (r.onToolStatus !== undefined) {
        if (res.status === undefined || !statusesOf(r.onToolStatus).includes(res.status)) {
            return false;
        }
        return r.onToolReturn ? toolMatcher(r.onToolReturn)(res.toolName) : true;
    }
    if (r.onToolReturn)
        return toolMatcher(r.onToolReturn)(res.toolName);
    return true;
}
/**
 * The cursor resolver (the keystone). Pure + deterministic. Given the iteration
 * context, returns the skill the graph should be *in* after this iteration AND the
 * clause that decided it (`CursorMove`; `nextSkill` is the `.to` projection):
 *   • cold start (`currentSkillId` unset) → first `entry` whose `when` passes
 *     (an `always`-entry — no `when` — matches unconditionally), else the entry
 *     the MODEL picked with `read_skill` (see "the model's pick" below);
 *   • a `from`-gated route (`fromId === currentSkillId`) whose predicate matches
 *     a result of the iteration's tool batch — results in call order, edges per
 *     result in declaration order; the first result with a match wins, later
 *     results matching OTHER targets are reported as `conflict` (9.16.0) →
 *     its target (the transition);
 *   • else the model's pick, when the read_skill gate accepted one this turn;
 *   • otherwise the current cursor unchanged (sticky stay).
 *
 * **The model's pick (`ctx.pendingSkillPick`) — the D2 "validated volunteer".**
 * `read_skill(id)` is already GATED to `reachableSkills(cursor)` (the model may
 * only name a skill the graph declares as reachable from where it stands), and
 * the tool answers "Skill 'id' activated for the next iteration." Until 8.3.0 the
 * cursor ignored that answer for every form except `.entryByRead()`'s cold start,
 * so the sentence was false wherever the skill's activation was cursor-gated or
 * rule-gated. It is honoured here, at ONE place, with the precedence the design
 * pinned (docs/design/skill-graph.md §4A.1): a declared edge that fired this hop
 * WINS over a same-turn pick (`D1 > D2`) — the author's determinism is never
 * overridden by a model guess. The pick is one-shot (the tool-calls stage rewrites
 * it every iteration), so a pick can never drag the cursor backwards after a later
 * edge has moved it.
 *
 * Each candidate predicate runs in its OWN try/catch so one throwing edge can't
 * block its siblings or crash the loop — a throw is treated as "no match" and,
 * in dev mode, warned. This is the design's `routeForResult` pin-table target.
 *
 * The `by` on each return is decided at the same statement as the destination
 * (8.5.0). That matters most in the case an outside observer cannot reconstruct:
 * a declared edge and a same-turn model pick naming the SAME target. `D1 > D2`
 * makes it a `'route'`, and only this function knows.
 */
/** The winning entry's recorded evidence, as a spreadable fragment: `{ witness }`
 *  when the rule was DATA and it yielded quotable text, `{}` otherwise (a `when`
 *  predicate, an unconditional entry, a match with nothing quotable). A throwing
 *  extractor is treated exactly as no evidence — a record is never worth a crash,
 *  and the hop itself already happened. */
function witnessOf(entry, ctx) {
    if (!entry.witness)
        return {};
    try {
        const found = entry.witness(ctx);
        return found === undefined ? {} : { witness: found };
    }
    catch {
        return {};
    }
}
// FOLD · the one owner of where the cursor goes next, and which clause decided it (CursorMove)
// consumers read this and never re-derive it: the `nextSkill` and `explainNextSkill` hooks the Evaluate stage asks;
// the memoized `nextSkillForTriggers` that `deriveTrigger` reads; and `makeSupersededEntries`.
// NOT skillToolDescriptors: the read_skill description reads the ALREADY-RESOLVED cursor off the offer (offer.cursorId).
// detached: yes — a POJO plus a string id, memoized per pass so two readers get one decided value.
function makeResolveCursor(entries, routes, llmReadEntry = false) {
    const isEntry = (id) => entries.some((e) => e.id === id);
    return (ctx) => {
        // ROLE VISIBILITY. The cursor never moves onto a skill this caller's role
        // may not read (`isHiddenSkill`, the same rule the evaluator refuses
        // activation by), so the stored cursor and the active set cannot
        // disagree. Every clause below asks `may` before it names a destination:
        // a keyword in the message, a declared edge, a tool proposal and a pick
        // are all untrusted routes to the same place. A cursor already standing
        // on a hidden skill (a restored conversation, a grant revoked mid-run) is
        // treated as no cursor, so the turn re-enters through the visible entries.
        const may = (id) => !isHiddenSkill(ctx, id);
        const cur = ctx.currentSkillId !== undefined && may(ctx.currentSkillId) ? ctx.currentSkillId : undefined;
        const from = cur !== undefined ? { from: cur } : {};
        // The turn-start cascade's verdict (SG-C) — a PRECOMPUTED input consumed
        // exactly as `pendingSkillPick` is, on iteration 1 only (the RouteTurn
        // stage decided it off the hot loop; `userMessage` is fixed for the run,
        // so iterations 2..N keep today's law byte-for-byte). Present only on
        // cascade graphs (`classify` / `continuity: 'conversation'`); absent, not
        // one line here behaves differently. A `menu`/`none` verdict carries no
        // `to` and falls through — the model resolves the menu via the ordinary
        // gated pick (D2), which is what keeps the offer and the verdict one
        // machinery; the cold-start walk below is suppressed by the verdict's
        // PRESENCE, so falling through can never enter an entry the record does
        // not claim.
        if (ctx.iteration === 1 && ctx.turnRoute?.to !== undefined && may(ctx.turnRoute.to)) {
            const by = ctx.turnRoute.by === 'intent'
                ? 'intent'
                : ctx.turnRoute.by === 'continuity'
                    ? 'continuity'
                    : ctx.turnRoute.by === 'decider'
                        ? 'decider'
                        : 'entry';
            return {
                ...(ctx.turnRoute.from !== undefined && { from: ctx.turnRoute.from }),
                to: ctx.turnRoute.to,
                by,
                // The cascade already extracted the tier-1 evidence (`turn_routed`
                // carries it); the hop repeats it rather than re-deriving it, so the
                // two records can never quote different text.
                ...(ctx.turnRoute.witness !== undefined && { witness: ctx.turnRoute.witness }),
            };
        }
        if (cur === undefined) {
            // Cold start: declaration-order first entry whose intent predicate passes.
            // `.entryByRead()` skips this — there the library deliberately does NOT
            // auto-pick; the model reads the menu and picks (below), so no entry body
            // loads until it does. A turn whose cascade left a VERDICT on scope
            // (`ctx.turnRoute`, SG-C) skips it the same way: suppression follows the
            // verdict, not only the compile-time flag, because an entry-scorer graph
            // opts into the cascade at MOUNT time (`continuity: 'conversation'`) —
            // it compiles with `llmReadEntry` false, yet its cold `menu`/`none`
            // verdict (a near-tie, or the scorer-throw fallback) must reach the
            // model, never be overridden by the declaration-order walk while the
            // record claims a menu is outstanding. RouteTurn writes the POJO only
            // when the cascade DECIDED something, so a graph whose cold start still
            // belongs to this walk (no rules, no tier 2 — e.g. after a dropped
            // resume) keeps it.
            if (!llmReadEntry && ctx.turnRoute === undefined) {
                for (const e of entries) {
                    if (!may(e.id))
                        continue;
                    if (!e.when)
                        return { to: e.id, by: 'entry' };
                    try {
                        // The witness is extracted HERE, on the winner only — the losing
                        // rules never pay for evidence, and an unconditional/`when` entry
                        // has none to give (`witnessOf` answers undefined).
                        if (e.when(ctx))
                            return { to: e.id, by: 'entry', ...witnessOf(e, ctx) };
                    }
                    catch (err) {
                        warnMatcherThrew(`entry "${e.id}"`, err);
                    }
                }
            }
            // A cold ACCEPTED tool proposal enters exactly where a cold model pick
            // may (an entry — the reachable-from-cold set), and OUTRANKS the pick:
            // deterministic tool evidence over a model guess, the same order the
            // mid-run walk applies below (9.19.0).
            const coldProposal = ctx.pendingToolTransition?.targetSkillId;
            if (coldProposal !== undefined && isEntry(coldProposal) && may(coldProposal)) {
                return { to: coldProposal, by: 'tool-proposal' };
            }
            // The model's pick becomes the starting cursor. This is what `.entryByRead()`
            // has always done; 8.3.0 makes it true for EVERY start form, which is what
            // makes the rules form's documented fallback ("no rule matched → the model
            // picks from the read_skill menu") actually work. An entry's `when` is the
            // AUTOMATIC pick's condition, not a lock on the manual one: the model asked
            // for this skill by name and the gate allowed it.
            if (ctx.pendingSkillPick !== undefined &&
                isEntry(ctx.pendingSkillPick) &&
                may(ctx.pendingSkillPick)) {
                return { to: ctx.pendingSkillPick, by: 'model-pick' };
            }
            for (const e of entries) {
                if (may(e.id) && ctx.activatedInjectionIds.includes(e.id)) {
                    return { to: e.id, by: 'model-pick' };
                }
            }
            return { by: 'none' };
        }
        // D1 — transition: the whole tool batch, in CALL order (9.16.0). Per
        // result, the first from-gated deterministic edge in DECLARATION order
        // that fires names that result's target; the first RESULT with a target
        // wins the cursor. Two orders, two jobs: declaration order is the
        // author's tiebreak when one result could match several edges (the D1
        // law, unchanged), call order is the run's tiebreak across results —
        // the tool that returned first routed first. Before 9.16.0 only the
        // LAST call of a batch was consulted, so identical batches routed
        // differently depending on call order (the 9.15.0 L1 probe). A later
        // result matching a DIFFERENT target is suppressed AND reported on the
        // move (`conflict`), never silently dropped; a batch of one is
        // byte-identical to the old singular walk. D1 still wins over a
        // same-turn model pick — INCLUDING when both name the same target,
        // which is why the cause is decided here and not inferred downstream
        // from the destination.
        let winner;
        let losers;
        // Guard evidence (9.51.0), collected at the very statements that decide:
        // the winning edge's evaluation (verdict true) and — per guarded edge —
        // the FIRST refusal whose preconditions a result met (verdict false).
        // A guarded edge whose tool/status preconditions never matched leaves no
        // record: its guard never decided anything.
        let winnerGuard;
        let guardsClosed;
        let closedEdges;
        for (const res of toolResultsOf(ctx)) {
            let target;
            let targetGuard;
            for (const r of routes) {
                if (r.fromId !== cur)
                    continue;
                if (!isDeterministicRoute(r))
                    continue; // model edges don't auto-fire
                if (!may(r.toId))
                    continue; // an edge into a hidden skill never fires
                try {
                    if (r.guard !== undefined) {
                        // The guard is judged AFTER the edge's own tool/status
                        // preconditions, over the hop view (the six hop keys + the
                        // result's JSON fields — see skillGuard.ts). `evaluate` and the
                        // routing predicate are one compilation, so the recorded
                        // conditions ARE the ones that routed.
                        if (!edgePreconditionsPass(r, res))
                            continue;
                        const evaluation = r.guard.evaluate({
                            toolName: res.toolName,
                            result: res.result,
                            ...(res.status !== undefined && { status: res.status }),
                            iteration: ctx.iteration,
                            userMessage: ctx.userMessage,
                            currentSkillId: cur,
                        });
                        const record = {
                            from: r.fromId,
                            to: r.toId,
                            toolName: res.toolName,
                            ...(res.toolCallId !== undefined && { toolCallId: res.toolCallId }),
                            verdict: evaluation.verdict,
                            conditions: evaluation.conditions,
                        };
                        if (evaluation.verdict) {
                            target = r.toId;
                            targetGuard = record;
                            break;
                        }
                        if (!(closedEdges ??= new Set()).has(r)) {
                            closedEdges.add(r);
                            (guardsClosed ??= []).push(record);
                        }
                        continue;
                    }
                    if (routeMatches(r, res)) {
                        target = r.toId;
                        break;
                    }
                }
                catch (err) {
                    warnMatcherThrew(`route ${r.fromId}→${r.toId}`, err);
                }
            }
            if (target === undefined)
                continue;
            const outcome = {
                ...(res.toolCallId !== undefined && { toolCallId: res.toolCallId }),
                toolName: res.toolName,
                target,
            };
            if (winner === undefined) {
                winner = outcome;
                winnerGuard = targetGuard;
            }
            else if (target !== winner.target)
                (losers ??= []).push(outcome);
        }
        // The refusals ride WHATEVER move resulted — a closed guard explains a
        // stay (or the hop that won instead) the same way `supersededEntries`
        // explains a suppressed entry.
        const closed = guardsClosed !== undefined ? { guardsClosed } : {};
        if (winner !== undefined) {
            return {
                ...from,
                to: winner.target,
                by: 'route',
                ...(winnerGuard !== undefined && { guard: winnerGuard }),
                ...closed,
                ...(losers !== undefined && { conflict: { winner, losers } }),
            };
        }
        // D1.5 — the ACCEPTED tool proposal (9.19.0): no declared edge fired, so
        // a `propose-transition` effect the gate validated moves the cursor. It
        // sits BETWEEN the edges and the pick on purpose: a declared edge is the
        // author's determinism (never overridden), a proposal is the tool
        // author's determinism (code that shipped), a pick is the model's guess.
        // A proposal of the CURRENT skill is a no-op stay, not a hop.
        const proposal = ctx.pendingToolTransition?.targetSkillId;
        if (proposal !== undefined && proposal !== cur && may(proposal)) {
            return { ...from, to: proposal, by: 'tool-proposal', ...closed };
        }
        // D2 — the validated volunteer: no declared edge fired, so the model's own
        // (already gated) pick moves the cursor. A pick of the CURRENT skill is a
        // no-op stay, not a hop.
        if (ctx.pendingSkillPick !== undefined &&
            ctx.pendingSkillPick !== cur &&
            may(ctx.pendingSkillPick)) {
            return { ...from, to: ctx.pendingSkillPick, by: 'model-pick', ...closed };
        }
        return { ...from, to: cur, by: 'stay', ...closed }; // sticky stay — no edge out of cur fired
    };
}
/**
 * The suppression reporter (8.15.0) — the honesty half of the one law.
 *
 * A conditional entry is active exactly while the cursor is on it, so an entry whose
 * own `when` matched while the graph is somewhere else is SUPPRESSED. Before 8.15.0
 * it would have loaded beside the cursor's skill, which is precisely the double
 * activation the law removes — so the run has to be able to SAY it happened, or an
 * author debugging "why isn't my entry loading?" has nothing to read.
 *
 * Pure: one resolver pass plus, at most, one predicate call per OTHER conditional
 * entry — O(E), inside the ceiling `memoizePerPass` sets for the whole evaluation.
 * The RAW resolver (not the memoized projection) is used deliberately, matching the
 * freshness policy of the other public resolvers: called once per iteration, always
 * recomputed.
 *
 * A throwing predicate is swallowed HERE and reported by the evaluator instead
 * (`skipped: 'predicate-threw'`) — one throw must not be told as two different
 * stories on the same event.
 */
function makeSupersededEntries(entries, resolveCursor) {
    const conditional = entries.filter((e) => e.when !== undefined);
    if (conditional.length === 0)
        return () => [];
    return (ctx) => {
        const to = resolveCursor(ctx).to;
        // No cursor at all → the trigger falls back to the rule alone, so nothing is
        // being superseded.
        if (to === undefined)
            return [];
        const out = [];
        for (const e of conditional) {
            if (e.id === to)
                continue; // this is the one that won
            try {
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                if (e.when(ctx))
                    out.push(e.id);
            }
            catch {
                // Reported by the evaluator as `predicate-threw`, not as a suppression.
            }
        }
        return out;
    };
}
/**
 * Memoize `nextSkill` per EVALUATION PASS.
 *
 * `evaluateInjections` hands ONE `ctx` object to every trigger in a pass, and
 * (since 8.3.0) every graph-compiled trigger — entry and route target alike —
 * asks `nextSkill(ctx)`. Without this, a graph with E entries and R routes pays
 * O(E × (E + R)) predicate calls per iteration instead of O(E + R): a 15-entry
 * regex router would re-run all 15 regexes 15 times per turn.
 *
 * Keyed on ctx IDENTITY in a WeakMap, so entries vanish with the context object
 * and a fresh iteration always recomputes. Safe because the resolver is pure over
 * `ctx` (route/entry predicates are documented as "pure + total"); the memo can
 * only make a pass MORE self-consistent, never less. `graph.nextSkill` and
 * `graph.explainNextSkill` are left un-memoized — they are public API called once
 * per iteration, and a consumer that mutates a ctx object between calls must still
 * get a fresh answer.
 *
 * Memoizes the whole `CursorMove` and projects `.to` for the triggers (8.5.0), so
 * one pass computes the destination and the cause together, exactly once.
 */
function memoizePerPass(resolveCursor) {
    const cache = new WeakMap();
    return (ctx) => {
        const hit = cache.get(ctx);
        if (hit)
            return hit.value;
        const value = resolveCursor(ctx).to;
        cache.set(ctx, { value });
        return value;
    };
}
function warnMatcherThrew(edge, err) {
    devWarn(`agentfootprint skillGraph: ${edge} predicate threw — treated as no-match. ` +
        `Predicates must be pure + total. ${err instanceof Error ? err.message : String(err)}`);
}
/** Compile a skill's incoming edges → one injection trigger (or null = keep the
 *  skill's default `llm-activated` trigger, i.e. model-reachable via read_skill).
 *
 *  A route target B is active iff `nextSkill(ctx) === B`. That single expression
 *  delivers all three keystone properties from ONE source of truth:
 *    • `from`-gating  — `nextSkill` only fires an edge `A→B` while the cursor is
 *      on A, so the edge no longer bleeds into an unrelated skill D (the v1 bug);
 *    • stickiness     — when the cursor is on B and no edge leaves B, `nextSkill`
 *      returns B (sticky stay), so B re-activates each iteration;
 *    • clean handoff  — the iteration a `B→C` edge fires, `nextSkill` returns C,
 *      so B deactivates the SAME step C activates — no double-active overlap.
 *  Because the loop's cursor-update stage is ALSO `currentSkillId = nextSkill(ctx)`,
 *  the trigger and the cursor can never disagree. */
function deriveTrigger(id, _skill, entries, routes, nextSkill, exclusiveEntries) {
    const entry = entries.find((e) => e.id === id);
    if (entry) {
        // `.entryByRelevance()` / `.entryByRead()` make the entries EXCLUSIVE candidates:
        // exactly ONE loads — the best match (embedder) or the model's pick (read_skill) —
        // as the cursor, so only that entry's body lands (token-efficient). The same
        // cursor-gated trigger as a route target delivers that for both modes.
        if (exclusiveEntries) {
            return { kind: 'rule', activeWhen: (ctx) => nextSkill(ctx) === id };
        }
        // Default (v1): a persistent base (always) or intent-conditional (rule).
        // An unconditional entry is `always` — the cursor can add nothing to it. This is
        // the ONE declared way to be co-active beside whatever the cursor is on, and it
        // is untouched by 8.15.0.
        if (!entry.when)
            return { kind: 'always' };
        // An intent-conditional entry is active EXACTLY WHILE THE CURSOR IS ON IT (8.15.0)
        // — the same expression the exclusive-entry arm above and the route-target arm
        // below already use. One law for a flat graph: a skill is active iff the cursor is
        // on it, or it declared itself unconditional.
        //
        // 8.3.0 added the cursor clause to a rule-only trigger and left the rule clause
        // standing as an OR. That leftover was the bug: an entry S with a `when` that
        // routes to T stayed active on the hop (its rule reads the user's message, which
        // does not change mid-turn), so S and T were both on the wire — two skill bodies,
        // two tool sets — for a graph drawn as a single-file state machine. And it was the
        // steady state, not a blip: every later iteration parked the cursor on T while S's
        // rule still matched, so S came back. This finishes 8.3.0 rather than reverting
        // it — both failures 8.3.0 names (a declared `step` INTO an entry skill; a
        // `read_skill` pick onto one) are carried by the CURSOR clause, which survives.
        //
        // The rule is still evaluated FIRST, and deliberately: a throwing entry predicate
        // must keep surfacing as the evaluator's `skipped: 'predicate-threw'`, which it
        // only can if the trigger calls it. Its answer is then used for exactly one thing
        // — the cold-start fallback below, when the graph has no cursor at all.
        //
        // A rule that matched while the cursor is elsewhere is a SUPPRESSION, and the run
        // says so: `supersededEntries(ctx)` reports it and the Injection Engine stamps it
        // on `agentfootprint.context.evaluated` as `supersededIds`.
        const activeWhen = entry.when;
        return {
            kind: 'rule',
            activeWhen: (ctx) => {
                const matched = activeWhen(ctx);
                const to = nextSkill(ctx);
                if (to === id)
                    return true; // the cursor lands here — my turn
                if (to !== undefined)
                    return false; // the graph is engaged elsewhere — not my turn
                return matched; // no cursor at all → the rule alone
            },
        };
    }
    // Deterministic incoming edges (when / onToolReturn / onToolStatus) →
    // cursor-gated + sticky.
    const incoming = routes.filter(isDeterministicRoute).filter((r) => r.toId === id);
    if (incoming.length === 0)
        return null; // model-reachable — keep default trigger
    return { kind: 'rule', activeWhen: (ctx) => nextSkill(ctx) === id };
}
/** Walk a decision tree → push each leaf skill (with its path-conjunction trigger,
 *  earlier-sibling negation baked into the path) plus predicate/skill nodes +
 *  branch edges for drawing. */
function compileTree(node, pathCond, out, parent, counter, path, scopeTools, 
/** Registers each leaf under its id — the duplicate-id refusal and the
 *  skill-contract checks both read that registry (8.4.0). */
register) {
    if (isDecisionNode(node)) {
        const id = `d${counter.n++}`;
        const label = node.label ?? 'decide';
        out.nodes.push({ id, kind: 'predicate', label });
        out.edges.push({
            from: parent ? parent.id : null,
            to: id,
            kind: 'predicate',
            label: parent?.branch,
        });
        compileTree(node.whenTrue, (ctx) => pathCond(ctx) && node.predicate(ctx), out, { id, branch: 'yes' }, counter, [...path, { label, branch: 'yes' }], scopeTools, register);
        compileTree(node.whenFalse, (ctx) => pathCond(ctx) && !node.predicate(ctx), out, { id, branch: 'no' }, counter, [...path, { label, branch: 'no' }], scopeTools, register);
    }
    else {
        if (node.flavor !== 'skill') {
            throw new Error(`skillGraph.tree: leaf "${node.id}" is not a skill (flavor='${node.flavor}').`);
        }
        // Claim the id. The SAME object at two leaves is the merge below; two DIFFERENT
        // skills under one id is refused here rather than merged (8.4.0) — the merge
        // kept the first body and dropped the second one's silently.
        register(node);
        // The SAME skill may be the leaf of several branches ("ESXi questions" and
        // "io questions" both route to the io-profile bundle). Compile it ONCE:
        // merge repeated leaves into one injection whose trigger ORs the path
        // predicates — pushing a second same-id injection would explode in
        // Agent.injection()'s duplicate-id guard.
        const existingIdx = out.skills.findIndex((skill) => skill.id === node.id);
        if (existingIdx >= 0) {
            const prev = out.skills[existingIdx];
            const prevWhen = prev.trigger
                .activeWhen;
            const prevRouting = prev.metadata[SKILL_GRAPH_METADATA_KEY];
            const allPaths = [
                ...(prevRouting.paths ?? (prevRouting.path ? [prevRouting.path] : [])),
                path,
            ];
            out.skills[existingIdx] = {
                ...prev,
                trigger: {
                    kind: 'rule',
                    activeWhen: (ctx) => prevWhen(ctx) || pathCond(ctx),
                },
                metadata: {
                    ...prev.metadata,
                    [SKILL_GRAPH_METADATA_KEY]: { ...prevRouting, paths: allPaths },
                },
            };
            // Node already exists — add only the second parent edge (the drawing
            // correctly shows two predicate diamonds converging on one leaf).
            out.edges.push({
                from: parent ? parent.id : null,
                to: node.id,
                kind: 'predicate',
                label: parent?.branch,
            });
            return;
        }
        const routing = { via: 'tree', path };
        // On-demand tools: a tree routes to exactly one leaf per iteration, so scope
        // each leaf's tools to itself (`autoActivate: 'currentSkill'`) unless the user
        // opted out (`scopeTools: false`) or the skill already declared its own mode.
        const existingAuto = node.metadata?.autoActivate;
        const autoActivate = existingAuto ?? (scopeTools ? 'currentSkill' : undefined);
        out.skills.push({
            ...node,
            trigger: { kind: 'rule', activeWhen: pathCond },
            metadata: {
                ...node.metadata,
                [SKILL_GRAPH_METADATA_KEY]: routing,
                ...(autoActivate && { autoActivate }),
            },
        });
        out.nodes.push({ id: node.id, kind: 'skill', label: node.id });
        out.edges.push({
            from: parent ? parent.id : null,
            to: node.id,
            kind: 'predicate',
            label: parent?.branch,
        });
    }
}
/**
 * Dev-mode "exactly one leaf fires" monitor (backlog B11).
 *
 * A binary decision tree is exhaustive and non-overlapping BY CONSTRUCTION
 * (each leaf's trigger conjoins its root→leaf predicates with earlier-sibling
 * negation), so static analysis has nothing to check. The invariant breaks at
 * RUNTIME only — when a predicate is impure/non-deterministic: the evaluator
 * re-runs each `decide(...)` predicate once per leaf trigger, so a predicate
 * that answers differently across those calls can fire 0 or ≥2 leaves.
 *
 * In dev mode (footprintjs `enableDevMode()`), each compiled leaf trigger is
 * wrapped to tally fires per evaluation pass (keyed on the shared `ctx`
 * identity — `evaluateInjections` passes one ctx object to every trigger in a
 * pass). When all leaves have been evaluated for one ctx and the fired count
 * is not exactly 1, a console.warn names the leaves. Production pays one
 * `devMode()` check per evaluation; a throwing predicate is excluded here
 * because the evaluator already reports it (`skipped: 'predicate-threw'`).
 */
function attachExactlyOneLeafMonitor(skills) {
    const total = skills.length;
    if (total < 2)
        return; // single leaf — trivially exactly-one
    const passes = new WeakMap();
    for (let i = 0; i < skills.length; i++) {
        const skill = skills[i];
        const inner = skill.trigger.activeWhen;
        skills[i] = {
            ...skill,
            trigger: {
                kind: 'rule',
                activeWhen: (ctx) => {
                    if (!devMode())
                        return inner(ctx);
                    const fired = inner(ctx); // may throw → evaluator reports 'predicate-threw'
                    let pass = passes.get(ctx);
                    if (!pass) {
                        pass = { evaluated: 0, fired: [] };
                        passes.set(ctx, pass);
                    }
                    pass.evaluated += 1;
                    if (fired)
                        pass.fired.push(skill.id);
                    if (pass.evaluated === total) {
                        passes.delete(ctx); // reset so a reused ctx object starts a fresh pass
                        if (pass.fired.length !== 1) {
                            devWarn(pass.fired.length === 0
                                ? `agentfootprint skillGraph.tree: NO leaf fired this iteration (expected exactly one). ` +
                                    `The tree is exhaustive by construction, so a decide() predicate likely returned ` +
                                    `different answers across leaf evaluations — predicates must be pure and deterministic. ` +
                                    `Leaves: ${skills.map((s) => s.id).join(', ')}.`
                                : `agentfootprint skillGraph.tree: ${pass.fired.length} leaves fired simultaneously ` +
                                    `(expected exactly one): ${pass.fired.join(', ')}. Each decide() predicate is ` +
                                    `re-evaluated per leaf, so impure/non-deterministic predicates break if/else exclusivity.`);
                        }
                    }
                    return fired;
                },
            },
        };
    }
}
/** Routing provenance for a flat entry/route skill (the v1 model). */
function routingFor(id, entries, routes) {
    const entry = entries.find((e) => e.id === id);
    if (entry)
        return {
            via: 'entry',
            ...(entry.label && { label: entry.label }),
            ...(entry.match && { match: entry.match }),
        };
    const incoming = routes.filter(isDeterministicRoute).filter((r) => r.toId === id);
    const first = incoming[0];
    if (first) {
        return {
            via: 'route',
            from: first.fromId,
            ...(first.label && { label: first.label }),
            triggerKind: first.onToolReturn && !first.onToolStatus ? 'on-tool-return' : 'rule',
            ...(first.guard && { guard: first.guard.data }),
        };
    }
    return { via: 'model' }; // model-reachable via read_skill
}
function renderMermaid(nodes, edges) {
    const kindById = new Map(nodes.map((n) => [n.id, n.kind]));
    const ref = (id) => (kindById.get(id) === 'predicate' ? id : nodeId(id));
    const lines = ['flowchart TD', '  __start__([▶ start])'];
    for (const n of nodes) {
        lines.push(n.kind === 'predicate'
            ? `  ${n.id}{"${n.label ?? n.id}"}` // predicate → diamond
            : `  ${nodeId(n.id)}["${n.label ?? n.id}"]`);
    }
    for (const e of edges) {
        const from = e.from === null ? '__start__' : ref(e.from);
        const arrow = e.kind === 'model' ? '-.->' : '-->'; // model edges dashed
        // An explicit label wins unchanged; a data matcher captions its entry edge
        // and a data guard captions its guard-only route edge (that is half the
        // point of declaring the condition as data — it can be drawn). A guard
        // composed with a tool/status part is already folded into that derived
        // label at build.
        const caption = e.label ??
            (e.match ? mermaidMatchCaption(e.match) : e.guard ? mermaidGuardCaption(e.guard) : undefined);
        const label = caption ? `|${caption}|` : '';
        lines.push(`  ${from} ${arrow}${label} ${ref(e.to)}`);
    }
    return lines.join('\n');
}
//# sourceMappingURL=skillGraph.js.map