agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
1,042 lines • 59.8 kB
TypeScript
/**
* 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 type { ToolResultStatus } from '../semantics/toolOutcome.js';
import type { Injection, InjectionContext } from './types.js';
import type { Embedder } from '../memory/embedding/types.js';
import type { EntryScore, EntryScoring, EntryScorer } from './entryScorer.js';
import { type GraphCheckup } from './skillGraphCheckup.js';
import { type RouteWitness, type SkillMatch, type SkillMatchData } from './skillMatch.js';
import { type GuardConditionEvidence, type SkillGuard, type SkillGuardData } from './skillGuard.js';
import type { IntentScorer } from './intentScorer.js';
import { type RoutingPolicy } from './routingPolicy.js';
import { type TurnRoutingPlan } from './skillIntent.js';
export { formatCheckup } from './skillGraphCheckup.js';
export type { GraphCheckup, GraphProblem, GraphProblemCode } from './skillGraphCheckup.js';
export type { SkillMatch, SkillMatchData, RouteWitness } from './skillMatch.js';
export { GUARD_HOP_KEYS, plainGuardCaption, type GuardConditionData, type GuardConditionEvidence, type GuardOperator, type GuardValue, type SkillGuard, type SkillGuardData, type SkillGuardOps, } from './skillGuard.js';
export type { TurnRoutingPlan } from './skillIntent.js';
export type { TurnRoute } from './routingPolicy.js';
/** How `.build({ check })` reacts to the graph check-up. */
export type GraphCheckMode = 'throw' | 'warn' | 'off';
/** Options for `.build()`. */
export interface BuildOptions {
/**
* Run the build-time check-up (see `graph.checkup()`):
* • `'throw'` — throw if any ERROR-level problem (unknown-skill / no-entry);
* • `'warn'` — console.warn every problem in dev mode (`enableDevMode()`), silent otherwise;
* • `'off'` — skip it entirely.
*
* **Default `'throw'` since 8.7.0** (was `'warn'`), matching the object-literal
* form. A graph with an error-level problem cannot start a turn at all, and under
* the old default it built silently outside dev mode and failed at run time
* instead. `'warn'` still means exactly what it says — never throws — so an
* explicit `check: 'warn'` keeps the old soft behavior.
*
* `graph.checkup()` is always available regardless.
*/
readonly check?: GraphCheckMode;
/**
* Tool names the AGENT exposes to every skill (`.tool()` / `.tools()` / a baseline
* `ToolProvider`). Without them the skill-body contract check reads a body's
* `lookup_order(id)` as a typo, because the graph only knows the tools its own
* skills carry. Same field as `checkup({ knownTools })` — see `SkillGraph.checkup`.
*
* Omitted, the body-contract checks (`body-foreign-tool` / `body-unknown-tool`)
* are DEFERRED out of this build pass and run once at Agent build instead, where
* the full tool registry exists — see {@link SkillGraph.deferredBodyContract}.
*/
readonly knownTools?: readonly string[];
/**
* FLAT graphs: stamp `autoActivate: 'currentSkill'` on every WIRED skill (every
* skill an entry or a route mentions), exactly as a decision `.tree()` stamps its
* leaves — so a skill's tools reach the LLM only while the graph is on it, instead
* of every skill's tools landing in the always-on registry from iteration 1.
*
* Default `false` — additive behavior. Set `true` to scope tools explicitly.
* A skill that declared its OWN `autoActivate` in `defineSkill(...)` always keeps
* it: this fills the default, it never overrides. A listed-but-unwired skill is
* not stamped — the graph does not route it, so the graph does not scope it.
*
* A decision `.tree()` takes this dial on `.tree(root, { scopeTools })` (object
* form: the tree arm's own `scopeTools` field); setting it here alongside a tree
* is refused so one dial cannot live in two homes.
*/
readonly scopeTools?: boolean;
}
/** Options for `graph.checkup()`. */
export interface CheckupOptions {
/**
* Tool names the AGENT exposes to every skill — `.tool()` / `.tools()`
* registrations and any always-on `ToolProvider`. A skill body may name them
* freely: they are callable from every skill, so they are neither `body-unknown-tool`
* (they exist) nor `body-foreign-tool` (they are not somebody else's).
*
* @example
* graph.checkup({ knownTools: ['lookup_order', 'list_skills'] });
*/
readonly knownTools?: readonly string[];
}
/**
* One start rule: route the turn's start to `use` when the rule matches.
* Exactly ONE of `match` (data) / `when` (code) per rule — the union enforces it
* at the keystroke and the build refuses it for everyone else. A rule exists to
* be conditional; for an unconditional start use `start: 'id'` / `{ use }`.
*/
export type SkillStartRule = {
readonly use: string;
/** The code form — an opaque predicate over the iteration context. */
readonly when: (ctx: InjectionContext) => boolean;
readonly match?: never;
/** The phrasings this rule claims — build-time TEST material. See
* {@link SkillEntryOptions.examples}. */
readonly examples?: readonly string[];
} | {
readonly use: string;
/** The data form — comparable, drawable, stored. See {@link SkillMatch}. */
readonly match: SkillMatch;
readonly when?: never;
/** The phrasings this rule claims — build-time TEST material. See
* {@link SkillEntryOptions.examples}. */
readonly examples?: readonly string[];
};
/** Where a turn starts, in the object-literal (flat) form. */
export type SkillGraphStart = string | {
readonly use: string;
} | {
readonly rules: ReadonlyArray<SkillStartRule>;
/** The intent classifier (SG-C) — REQUIRED iff any rule declares
* `match: { intent }`. Same machine as `.classify(scorer)`. */
readonly classify?: IntentScorer;
/** Tie-policy override — the ONE override home, beside the scorer it
* governs. Same machine as `.classify(scorer, policy)`. */
readonly routing?: Partial<RoutingPolicy>;
} | {
readonly entries: readonly string[];
/** Rank the entries with a scorer strategy (`keywordScorer()`,
* `embeddingScorer(e)`, or your own). Takes precedence over `byRelevance`. */
readonly scoredBy?: EntryScorer;
/** Sugar: rank the entries with an embedder (cosine/softmax). Omit both → the
* LLM reads the menu and picks (`.entryByRead()`) — no model call. */
readonly byRelevance?: Embedder;
};
/** One tool-result transition in the object-literal (flat) form. */
export interface SkillGraphStep {
readonly from: string;
readonly to: string;
readonly when?: SkillRouteOptions['when'];
readonly onToolReturn?: string | RegExp;
/** Route on the result's declared outcome status (9.19.0) — see
* {@link SkillRouteOptions.onToolStatus}. */
readonly onToolStatus?: SkillRouteOptions['onToolStatus'];
/** The edge's condition as DATA (9.51.0) — see
* {@link SkillRouteOptions.guard}. */
readonly guard?: SkillRouteOptions['guard'];
readonly label?: string;
}
/**
* Object-literal form, FLAT arm — `start` + `steps` declare the routing.
* `tree` is typed `never` here so `{ tree, start }` is a COMPILE error, not just
* a build-time refusal (see `SkillGraphConfig`).
*/
export interface SkillGraphFlatConfig {
/** Every skill in the graph (wired or not). */
readonly skills: readonly Injection[];
/** Where a turn starts. */
readonly start?: SkillGraphStart;
/** Tool-result transitions; `from`/`to` are skill ids resolved against `skills`. */
readonly steps?: readonly SkillGraphStep[];
readonly tree?: never;
/**
* Stamp `autoActivate: 'currentSkill'` on every WIRED skill (every skill an
* entry or a step mentions), exactly as the tree arm already stamps its leaves —
* a skill's tools reach the LLM only while the graph is on it. **Default `false`**
* (today's additive behavior — every skill's tools visible from iteration 1);
* A skill whose author set its own
* `autoActivate` keeps it: the graph level is a default, never an override.
* See {@link BuildOptions.scopeTools}.
*/
readonly scopeTools?: boolean;
/**
* Phrasings this graph must claim NOWHERE — the negative routing rows. Same
* machine as `.neverRoutes([...])`; see that method for what the check
* proves and what it deliberately does not. A row a declared start rule
* claims is an ERROR (`never-routes-claimed`), naming the rule.
*
* @example
* skillGraph({ skills, start: { rules }, neverRoutes: ['what is the weather'] });
*/
readonly neverRoutes?: readonly string[];
readonly check?: GraphCheckMode;
/** Baseline agent tool names — see {@link BuildOptions.knownTools}. */
readonly knownTools?: readonly string[];
}
/**
* Object-literal form, TREE arm — a decision tree owns the routing, so there is
* no entry menu and no cursor for `start`/`steps` to describe. Both are typed
* `never` so the contradiction is a compile error.
*/
export interface SkillGraphTreeConfig {
/** Every skill in the graph. Under `tree` this must be exactly the leaf set —
* a listed skill that is not a leaf would never load, and is refused. */
readonly skills: readonly Injection[];
/** A decision tree (instead of `start` + `steps`). */
readonly tree: DecisionNode | Injection;
/**
* Scope each leaf's tools to the routed leaf. The object form's half of
* `.tree(root, { scopeTools })` — added in 8.7.0, because until then this form
* hard-coded `true` and the fluent form's only opt-out had no object-form twin.
* Default `true`. See {@link TreeOptions.scopeTools}.
*/
readonly scopeTools?: boolean;
readonly start?: never;
readonly steps?: never;
/** A tree has no start RULES for a negative row to be judged against, so the
* type refuses it here and `build()` refuses it at runtime (see
* {@link SkillGraphBuilder.neverRoutes}). */
readonly neverRoutes?: never;
readonly check?: GraphCheckMode;
/** Baseline agent tool names — see {@link BuildOptions.knownTools}. */
readonly knownTools?: readonly string[];
}
/**
* Object-literal form of a skill graph — an alternative to the fluent builder.
* Listing `skills` INDEPENDENTLY of the wiring is the point: the check-up can then
* flag a skill that was listed but never wired (the fluent builder only ever sees
* skills that appear in an edge). Compiles to the SAME `SkillGraph`. `check`
* defaults to `'throw'` here (a new surface, fail-loud).
*
* A UNION of two arms, because `tree` and `start`/`steps` are two ways to declare
* the same thing and only one of them compiles: `{ tree, start }` is a type error
* for a TypeScript consumer and a build-time refusal for everyone else (8.4.0 —
* before, the tree silently won and the flat wiring was discarded). Valid tree-only
* and flat-only configs typecheck exactly as they did.
*/
export type SkillGraphConfig = SkillGraphFlatConfig | SkillGraphTreeConfig;
export type { EntryScore, EntryScoring };
/** Deterministic routing into a skill, keyed on the last tool result. */
export interface SkillRouteOptions {
/** Predicate on the previous iteration's tool result → activate the target
* on the next iteration. The common, controllable edge. `status` is
* present when the tool declared one on its result envelope (9.19.0).
*
* `result` is the string the MODEL read — which artifact placement can
* replace with a claim ticket, so an operator raising or lowering
* `artifacts.placement.maxInlineChars` can change whether a text-matching
* edge fires. See {@link InjectionContext.lastToolResult}; route on
* `onToolReturn` / `onToolStatus` for a guard placement cannot move. */
readonly when?: (result: {
readonly toolName: string;
readonly result: string;
readonly status?: ToolResultStatus;
}) => boolean;
/** Sugar for "activate whenever this tool returns (any result)". String is an
* exact match; RegExp is tested against the tool name. */
readonly onToolReturn?: string | RegExp;
/**
* Route on the result's declared OUTCOME, not its prose (9.19.0) — the
* data half of the outcome-status normalization: a `'denied'` call must
* never route like a `'success'`. Matches when a tool result of the batch
* carries one of the named statuses on its envelope; a result with NO
* declared status can never match (an undeclared outcome is not
* evidence). Compose with `onToolReturn` to pin the tool too ("when
* `refund` returns `'denied'`"); alone, any tool's matching status fires.
* Data — comparable, drawable (`toMermaid()` captions it), stored. At
* most one of `when` / `onToolStatus`: code or data, never both.
*/
readonly onToolStatus?: ToolResultStatus | ReadonlyArray<ToolResultStatus>;
/**
* The DATA form of `when` (9.51.0) — the edge's condition declared as
* comparable, drawable, recordable conditions instead of an opaque
* predicate: `{ key: { eq | ne | gt | gte | lt | lte | in | notIn: value } }`,
* every condition ANDed (the operator grammar deliberately mirrors
* footprintjs's `WhereFilter` — see {@link SkillGuard}). This is the
* SkillWalker's guard mover as data: the map is data, entry matchers are
* data, `onToolStatus` arms are data — the guard was the last opaque
* function on a route edge.
*
* Judged per tool result of the previous iteration's batch, exactly where a
* `when` runs. Six hop keys read the hop directly (`toolName`, `result`,
* `status`, `iteration`, `userMessage`, `currentSkillId`); any OTHER key
* reads the top-level field of that name from the RESULT parsed as JSON —
* `guard: { riskLevel: { gte: 'high' } }` routes on a tool that returned
* `{"riskLevel":"high"}`. Being data buys four things a `when` can never
* have: the check-up proves contradictions (`guard-unsatisfiable`),
* `toMermaid()` captions the edge ("when riskLevel ≥ high"),
* `skill.graph_declared` carries it into every recording, and each
* evaluation that DECIDES a hop — taken or refused — leaves per-condition
* evidence on `cursorMove.guard` / `cursorMove.guardsClosed`.
*
* COMPOSES with `onToolReturn` / `onToolStatus` ("this tool, this outcome,
* AND these conditions"). At most one of `when` / `guard`: code or data,
* never both — to combine declared conditions with extra logic, fold the
* checks into your `when` predicate.
*/
readonly guard?: SkillGuard;
/** Caption rendered on the edge. Defaults to a derived label. */
readonly label?: string;
}
/** Where a turn starts. `when` (optional) makes entry intent-conditional. */
export interface SkillEntryOptions {
/**
* Which entry the turn STARTS on — a predicate over the iteration context
* (e.g. `ctx.userMessage`). The first entry whose `when` passes wins the
* cold-start cursor.
*
* **It decides the start; it does not keep the skill on the wire (8.15.0).**
* A conditional entry is active exactly while the cursor is on it — a route out
* of it ends its turn, and its rule cannot bring it back while the graph is
* somewhere else. Before 8.15.0 the rule re-activated it on every iteration, so an
* entry that routed onward stayed loaded beside its own successor.
*
* Omit `when` → the skill is `always` active: a persistent base procedure, on
* beside whatever the cursor is on. That is the declared way to ask for an
* always-on skill; `when: () => true` is NOT the same thing any more. For "on
* whenever this matches, wherever the graph is", use the flavor built for it —
* `.steering(...)` / `.skill(...)` with its own `rule` trigger — rather than an
* entry, which is a position in a state machine.
*/
readonly when?: (ctx: InjectionContext) => boolean;
/**
* The DATA form of `when` — a declared matcher over the user's message instead
* of a predicate (see {@link SkillMatch}): comparable by the check-up, captioned
* by `toMermaid()`, stored on the compiled skill's provenance. Same start
* semantics as `when` in every other way. At most ONE of `match`/`when` — both
* set is refused at build time. Omitting both keeps this an `always` entry,
* exactly as before.
*/
readonly match?: SkillMatch;
/**
* The phrasings this rule CLAIMS — real messages a user would type that
* should start the turn here. Optional, additive, and **fed to nothing at
* run time**: the check-up reads them at build time and the routing never
* sees them, so a rule with examples routes byte-identically to the same
* rule without them.
*
* Things become PROVABLE once a phrase is declared, by RUNNING the compiled
* matchers rather than comparing them:
*
* • `example-misses-own-rule` — this rule does not claim its own example.
* An ERROR for a data `match` (it reads the user message and nothing
* else, so the no-match holds under every context) or for a predicate
* that THREW; a WARNING for an opaque `when` that returned false, which
* may be gated on conversation state and claim the phrase on a later
* turn (see the context note below);
* • `example-shadowed-by-earlier` (error) — an EARLIER rule claims the
* phrase first, so the turn starts somewhere your own example denies.
* This is the one `rules-shadowed-by-order` must stay silent about when
* the two rules use different regexes (intersection is not decided
* anywhere in this library) — a witness phrase decides it instead;
* • `example-shadowed-by-default` (warning) — the earlier claimant is an
* UNCONDITIONAL entry. The declaration-order cold start stops at a
* default; the turn-start cascade (a classifier, or `continuity:
* 'conversation'`) reads the conditional rules only and skips it — and
* which one applies is decided at AGENT MOUNT, so the report names both
* readings instead of asserting one against the router;
* • `example-unclaimed` (warning) — NO rule claims the phrase, so the turn
* falls through to the model tier. Absence, which no matcher-vs-matcher
* analysis can catch.
*
* **The context they are judged on.** Every condition here runs on ONE
* context — iteration 1, the phrase as `userMessage`, empty `history`, no
* cursor — the context a turn's FIRST iteration hands a start rule. Turn 2 of
* a conversation also starts cold in cursor terms while CARRYING history, so
* a `when` gated on conversation state may claim the phrase on a turn this
* check cannot run. That is why its no-match is a warning, and why every
* message names the context it judged under.
*
* **Tier difference, and it matters.** In `match: { intent, examples }`
* (tier 2) the examples are SCORING material: the classifier reads them at
* RUN time to judge new messages. Here (tier 1) they are TEST material only.
* The author-facing meaning is the same — "the phrasings this rule claims" —
* the runtime role is not, and a rule may not carry both lists (refused at
* build, naming the difference).
*
* The boundary: these checks prove things about the phrases you declared and
* nothing about phrases nobody wrote. No warning is not proof of coverage —
* `graph.checkup().notes` says so on the report itself.
*
* @example
* .entry(arrayInventory, {
* match: /\b(array|volume|pool)\b/i,
* examples: ["what's running on shpstrprncl101"],
* })
*/
readonly examples?: readonly string[];
readonly label?: string;
}
/** Options for a decision `tree()`. */
export interface TreeOptions {
/**
* Scope the tool list to the routed leaf (the on-demand-tools default).
*
* A decision tree routes to EXACTLY ONE skill per iteration, so each leaf is
* stamped `autoActivate: 'currentSkill'` — its `inject.tools` reach the LLM
* ONLY when the tree routes there, instead of every skill's tools landing in
* the always-on static registry on every call.
*
* `read_skill` cannot reach another LEAF mid-run (8.5.0): a tree has no cursor to
* move, and this "exactly one leaf" property is one of the reasons — admitting a
* second leaf would put two leaves' tools on the wire and make the dev-mode
* exactly-one monitor warn. It said otherwise until 8.5.0, and the pick was
* accepted and then silently dropped. The escape hatch is a skill registered
* BESIDE the graph (`.skill(x)`, `.selfExplain()`), which really does activate by
* `read_skill` and is admitted from anywhere.
*
* Default `true`. Set `false` for the legacy additive behavior (all leaves'
* tools always visible). A leaf that sets its OWN `autoActivate` in
* `defineSkill(...)` is always respected — this only fills the default.
*/
readonly scopeTools?: boolean;
}
export type SkillEdgeKind = 'entry' | 'predicate' | 'on-tool-return' | 'on-tool-status' | 'guard' | 'model';
export interface SkillEdge {
/** Source skill id, or `null` for the synthetic START (an entry edge). */
readonly from: string | null;
readonly to: string;
readonly kind: SkillEdgeKind;
readonly label?: string;
/** The DATA matcher on an entry edge, when the rule was declared as data
* (`match:`). `toMermaid()` captions the edge with it when no explicit
* `label` was given. */
readonly match?: SkillMatchData;
/** The DATA guard on a route edge, when one was declared (9.51.0) — rides
* additively beside the kind exactly as `match` rides entry edges (a
* guard composed with `onToolReturn`/`onToolStatus` keeps that kind; a
* guard alone is kind `'guard'`). `toMermaid()` captions a guard-only
* edge with it when no explicit `label` was given, and
* `skill.graph_declared` carries it into every recording. */
readonly guard?: SkillGuardData;
}
/**
* A decision-tree node (v3): a predicate that branches to a subtree (or a skill
* LEAF) on each side. The tree compiles to per-skill triggers — each leaf's
* trigger is the conjunction of the predicates on its root→leaf path (with
* earlier-sibling negation for if/else exclusivity), evaluated per iteration. So
* "predicate nodes that route" needs NO engine change — same evaluator.
*/
export interface DecisionNode {
readonly kind: 'decision';
readonly predicate: (ctx: InjectionContext) => boolean;
readonly whenTrue: DecisionNode | Injection;
readonly whenFalse: DecisionNode | Injection;
/** Caption for the predicate node when drawn (e.g. "io intent?"). */
readonly label?: string;
}
/** 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 declare function decideSkill(predicate: (ctx: InjectionContext) => boolean, whenTrue: DecisionNode | Injection, whenFalse: DecisionNode | Injection, label?: string): DecisionNode;
/**
* WHY the cursor landed where it did on one iteration — the winning clause of the
* one cursor resolver, reported rather than guessed (8.5.0).
*
* • `'entry'` — cold start: the first entry whose `when` passed (or, on a
* cascade graph, a tier-1 start rule won the turn);
* • `'route'` — a declared, `from`-gated edge fired (D1);
* • `'model-pick'` — no declared edge fired, so the model's gate-accepted
* `read_skill` pick moved the cursor (D2), at cold start or mid-run;
* • `'tool-proposal'` — no declared edge fired and an ACCEPTED
* `propose-transition` tool effect moved the cursor
* (9.19.0) — deterministic tool evidence, ranked between
* the author's edges (D1 still wins) and the model's
* pick (a tool outranks a guess);
* • `'intent'` — the turn-start cascade's tier-2 scorer decisively routed
* the turn (SG-C; `turn_routed` carries the numbers);
* • `'continuity'` — the inherited conversation cursor held the turn's start
* (SG-C, `continuity: 'conversation'`);
* • `'decider'` — the configured tier-3 decider resolved an outstanding
* menu out-of-band and the turn starts on its pick
* (9.19.0);
* • `'stay'` — nothing fired; the cursor is sticky and stayed put;
* • `'none'` — no cursor at all (cold start with nothing to enter, or a
* decision `tree()`, which routes by predicate and has no cursor).
*
* This exists because the DRAWN provenance on a skill (`metadata.skillGraph`) answers
* "how is this skill reachable" — a build-time fact — and was being read as "how did
* we get here this turn". A model pick into a skill that also has a declared edge was
* therefore attributed to that edge, label and all, in the recorded route.
*/
export type CursorMoveCause = 'entry' | 'route' | 'model-pick' | 'tool-proposal' | 'intent' | 'continuity' | 'decider' | 'stay' | 'none';
/**
* One tool result's routing implication inside a parallel batch (9.16.0) —
* which call it was, and the edge target it matched. Named by
* `skill.route_conflict` as the winner or a suppressed loser.
*/
export interface RouteBatchOutcome {
/** The provider's tool_use id for the call. Absent only for a context that
* supplied the singular `lastToolResult` (which can never conflict). */
readonly toolCallId?: string;
/** The tool whose result matched an edge. */
readonly toolName: string;
/** The edge target that result routed to. */
readonly target: string;
}
/**
* Two or more results of ONE parallel batch matched edges to DIFFERENT
* targets (9.16.0). The first in call order wins the cursor; the rest are
* suppressed — and reported here rather than silently dropped, so the record
* explains the hop the run did NOT take. Same-target matches are not a
* conflict (they all asked for the move that happened).
*/
export interface RouteBatchConflict {
/** The call-order-first match — the one that moved the cursor. */
readonly winner: RouteBatchOutcome;
/** Later matches to other targets, in call order, that did not move it. */
readonly losers: readonly RouteBatchOutcome[];
}
/**
* One guard's evaluation against one tool result (9.51.0) — the evidence a
* data guard leaves whenever it DECIDES a hop: which edge, which result it
* judged, and every condition with the value it saw. Two homes on the move:
* `CursorMove.guard` (the taken hop's evaluation, verdict `true`) and
* `CursorMove.guardsClosed` (the refusals, verdict `false`) — both ride
* `context.evaluated.cursorMove` onto the record.
*/
export interface GuardEvaluation {
/** The guarded edge. */
readonly from: string;
readonly to: string;
/** The tool result this evaluation judged. */
readonly toolName: string;
/** The provider's tool_use id for that call, when the batch carried one. */
readonly toolCallId?: string;
/** `true` — the guard passed and the edge fired; `false` — the guard
* refused a hop whose other declared conditions were already met. */
readonly verdict: boolean;
/** Per-condition evidence, in declaration order — every condition, the
* summarized value it was judged against, and whether it passed. */
readonly conditions: readonly GuardConditionEvidence[];
}
/** The cursor resolver's full answer: where, and by which clause. */
export interface CursorMove {
/** The cursor after this iteration (what `nextSkill` returns). */
readonly to?: string;
/** The cursor before it. */
readonly from?: string;
/** The winning clause. */
readonly by: CursorMoveCause;
/** Present only when `by: 'route'` resolved a parallel batch whose results
* matched edges to different targets — the suppression, on the record.
* The Evaluate stage emits it as `agentfootprint.skill.route_conflict`. */
readonly conflict?: RouteBatchConflict;
/**
* The EVIDENCE a tier-1 DATA matcher routed on (9.28.0) — the text out of the
* user message that made the entry's rule true, bounded (see
* {@link RouteWitness}). Present only for `by: 'entry'` moves decided by a
* `match:` (RegExp / `{ keywords }` / `{ all }`) rule; a `when` predicate is
* opaque code, an unconditional entry matched nothing, and a scorer's
* evidence is its scores — all three record no witness.
*/
readonly witness?: RouteWitness;
/**
* The EVIDENCE the winning guard routed on (9.51.0) — present only for a
* `by: 'route'` move whose firing edge declared a `guard:`. The full
* per-condition evaluation (verdict `true`), decided at the same return as
* the destination, so the record can never quote a different judgment than
* the one that routed.
*/
readonly guard?: GuardEvaluation;
/**
* The guards that REFUSED this iteration (9.51.0) — every guarded edge out
* of the cursor whose OTHER declared conditions a result met and whose
* guard said no (verdict `false`; at most one record per edge, the first
* refusal in call order). Present on whatever move resulted — including a
* `'stay'`, where it answers "why didn't my guarded edge fire?" with the
* conditions and the values that closed it, the same honesty
* `supersededEntries` gives suppressed entries. An edge whose
* `onToolReturn`/`onToolStatus` preconditions never matched is NOT here:
* its guard never decided anything.
*/
readonly guardsClosed?: readonly GuardEvaluation[];
}
/** A node in the drawn graph — a `predicate` diamond or a `skill` box. */
export interface SkillNode {
readonly id: string;
readonly kind: 'predicate' | 'skill';
readonly label?: string;
}
/** One predicate on a skill's root→leaf decision path, and the branch taken. */
export interface SkillRoutingStep {
/** The predicate's caption (the `decide(...)` label). */
readonly label: string;
/** Which side of the predicate leads to this skill. */
readonly branch: 'yes' | 'no';
}
/**
* The routing PROVENANCE stamped onto a compiled skill's `metadata.skillGraph`
* — *why* this skill is reachable. It rides through to the `context.evaluated`
* event when the skill activates, so commentary + the lens can narrate the real
* routing (not just "a skill activated"). Observability only; the trigger logic
* is unchanged.
*/
export interface SkillRouting {
/** How the skill is reached: a decision `tree` leaf, a flat `entry`, a
* deterministic `route` edge, or `model` (read_skill-reachable). */
readonly via: 'tree' | 'entry' | 'route' | 'model';
/** Decision path (tree only): the predicates from root→leaf + branch taken.
* For a skill used as MULTIPLE tree leaves this is the FIRST path; all
* paths are in `paths`. */
readonly path?: readonly SkillRoutingStep[];
/** All decision paths reaching this skill (tree only; present when the same
* skill is the leaf of more than one branch — the compiler merges repeated
* leaves into ONE injection whose trigger ORs the path predicates). */
readonly paths?: ReadonlyArray<readonly SkillRoutingStep[]>;
/** Entry/route edge caption. */
readonly label?: string;
/** Source skill id (route only). */
readonly from?: string;
/** The compiled trigger kind for a route (`rule` / `on-tool-return`). */
readonly triggerKind?: string;
/** The DATA matcher that routes here (entry only, when declared as `match:`) —
* serializable, so commentary/lens can say WHICH pattern chose the skill. */
readonly match?: SkillMatchData;
/** The DATA guard on the first deterministic edge in (route only, when
* declared as `guard:`, 9.51.0) — serializable, the `match` twin. */
readonly guard?: SkillGuardData;
}
/** The metadata key carrying a skill's routing provenance. */
export declare const SKILL_GRAPH_METADATA_KEY: "skillGraph";
/**
* The note a graph leaves for Agent build when its own build pass DEFERRED the
* skill-body ↔ tool-contract checks (`body-foreign-tool` / `body-unknown-tool`) —
* see {@link SkillGraph.deferredBodyContract}.
*/
export interface DeferredBodyContract {
/** The graph's `check` mode — the severity the deferred run respects
* (`'off'` never defers: it stays off at agent build too). */
readonly mode: 'throw' | 'warn';
}
/**
* 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 declare const SKILL_GRAPH_DEFERRED_CONTRACT_KEY: "skillGraphDeferredBodyContract";
export interface SkillGraph {
/** Skills with graph-derived triggers — feed to the Agent (`.skillGraph()` or
* `.skills({ list: () => graph.skills })`). */
readonly skills: readonly Injection[];
/** The declared edges (for tooling, overlays, tests). */
readonly edges: readonly SkillEdge[];
/** Drawn nodes: skill boxes for the flat entry/route model; predicate diamonds
* + skill leaves for a decision `tree`. Always present. */
readonly nodes: readonly SkillNode[];
/** A Mermaid flowchart of the declared graph — declared === drawn. */
toMermaid(): string;
/**
* The CURSOR resolver — given an iteration context, where is the graph next?
* Returns the skill id the graph should be *in* after this iteration:
* • cold start (`ctx.currentSkillId` unset) → the first matching `entry`,
* else the entry the model picked with `read_skill`;
* • a `from`-gated route whose predicate matches a result of the previous
* iteration's tool batch (`ctx.toolResults`, in call order; falls back to
* the singular `ctx.lastToolResult`) → its target;
* • else the model's `read_skill` pick (`ctx.pendingSkillPick`), which the
* runtime sets only after the reachability gate accepted it;
* • otherwise the current cursor unchanged (sticky stay).
* A declared edge that fires always beats a same-turn model pick.
* Pure + deterministic — the single source of truth shared by the compiled
* route triggers and the agent loop's cursor-update stage, so the two can never
* disagree. Flat entry/route graphs only; a decision `tree()` routes per-iteration
* by predicate (no cursor) and returns the unchanged `ctx.currentSkillId`.
*
* **The cursor is PER RUN.** It describes where the graph is inside ONE
* `agent.run()`, across that run's iterations. A second `run()` on the same agent
* starts cold — at the entry — whatever skill the first run ended on: a skill graph
* is a per-turn state machine, not conversation memory, and `currentSkillId` lives
* in the run's own state. To resume where the last turn stopped, persist the id
* yourself and start the next turn's graph from it (`start: { rules: [...] }`);
* nothing in the graph will carry it across for you.
*/
nextSkill(ctx: InjectionContext): string | undefined;
/**
* The same answer as `nextSkill`, plus WHICH CLAUSE produced it — the resolver
* reporting its own reasoning instead of a consumer inferring it from the drawn
* provenance (8.5.0). `explainNextSkill(ctx).to === nextSkill(ctx)`, always: there
* is one resolver and `nextSkill` is a thin projection of this one, so the two can
* never drift.
*
* The agent threads this into the injection engine, which stamps the result on
* `agentfootprint.context.evaluated` as `cursorMove` — that is what lets
* `routeRecorder()` mark a model-pick hop as a model pick instead of borrowing the
* label of a declared edge that never fired.
*/
explainNextSkill(ctx: InjectionContext): CursorMove;
/**
* The entries whose OWN `when` matched this iteration but which the cursor
* SUPERSEDED — declaration order, ids only (8.15.0). Empty for a graph with no
* conditional entries, for a decision `tree()`, and whenever the graph has no
* cursor at all.
*
* A conditional entry is active exactly while the cursor is on it. That is a
* suppression, and a suppression the run must not swallow: the agent threads this
* into the injection engine, which stamps it on `agentfootprint.context.evaluated`
* as `supersededIds`, beside the `cursorMove` that says where the graph went
* instead. Reading the two together answers "why isn't my entry loading?" without
* anyone having to re-run a predicate to guess.
*
* It rides the per-iteration event rather than `skill.reroute_superseded` on
* purpose: this is a CONTINUOUS condition (an entry whose rule stays true while
* the cursor is parked elsewhere is suppressed every iteration), while that event
* means a discrete broken promise — a `read_skill` pick the gate accepted and
* something else outranked. Per-iteration state belongs on the per-iteration event.
*
* Pure + deterministic. A predicate that throws is reported by the evaluator as
* `skipped: 'predicate-threw'`, not here.
*/
supersededEntries(ctx: InjectionContext): readonly string[];
/**
* The REACHABLE set — which skills the model may `read_skill`-jump to from the
* current cursor. The agent's runtime gate rejects any `read_skill('id')` whose
* `id` is not in this set (so the model can't leave the graph mid-run).
* • cold start (`currentSkillId` undefined) → the entry skills;
* • otherwise → the current skill's direct successors ∪ the entry skills, minus
* the current skill itself (deliberate "stay" is the no-tool-call ReAct stop).
*
* A decision `tree()` returns EMPTY (8.5.0). A tree routes by predicate on every
* iteration and has no cursor to jump: its leaves compile to `rule` triggers, and a
* `read_skill` call writes only `activatedInjectionIds`, which a `rule` trigger does
* not read. Until 8.5.0 this returned all the leaves, so the gate accepted a leaf
* pick, the tool answered "activated for the next iteration", and the leaf never
* activated. That is the same clause 8.4.0 already applies everywhere else — a skill
* is open only when its trigger is `llm-activated` — reaching the one set that had
* escaped it. The escape hatch under a tree is the OPEN skills (anything registered
* beside the graph: `.skill(x)`, `.selfExplain()`), which the agent's gate still
* admits from any cursor.
*
* Pure + deterministic.
*/
reachableSkills(currentSkillId?: string): readonly string[];
/**
* Score the entry candidates by relevance to the user's message — present ONLY
* when the graph was built with `.entryByRelevance(embedder)`. Embeds
* `ctx.userMessage` and each `when`-passing entry's `description`, cosine-scores
* them, and softmaxes into a `relevance` share. The agent's PickEntry stage uses
* `chosen` as the starting cursor (LLM-free, off the hot loop). Flat graphs only.
*/
scoreEntries?(ctx: InjectionContext, signal?: AbortSignal): Promise<EntryScoring>;
/**
* Build-time check-up — inspect the declared graph for wiring mistakes (a skill
* nobody can reach, an edge to an unknown skill, two un-prioritized edges from one
* skill, no entry, a self-loop, an entry menu with no way to choose from it, a
* transition the cold-start cursor can never take). Pure + side-effect-free; call
* it whenever. `ok` is false iff there's an error-level problem (`unknown-skill` /
* `no-entry`) — everything else is a warning, because a graph the model can still
* navigate is not a broken graph.
*
* Pass `knownTools` when the agent registers baseline tools with `.tool()`: the
* graph only knows the tools its own skills carry, so without them a body that
* says `lookup_order(id)` is reported as naming a tool that exists nowhere.
*
* @example
* const report = graph.checkup({ knownTools: ['lookup_order'] });
* if (!report.ok) throw new Error(formatCheckup(report));
*/
checkup(options?: CheckupOptions): GraphCheckup;
/**
* The turn-routing plan (SG-C) — what the agent's RouteTurn stage consumes:
* the configured classifier (if any), the resolved tie policy, tier-1 rule
* evaluation and the intent-candidate projection. Present on every FLAT
* graph (a decision `tree()` routes by predicate and has no turn start to
* route). Consumers never call it directly; `.skillGraph()` threads it.
*/
readonly turnRouting?: TurnRoutingPlan;
/**
* How a turn's starting entry is chosen (SG-C) — `'scorer'`
* (`.entryBy()`/`.entryByRelevance()`), `'model-read'` (`.entryByRead()`),
* `'classify'` (`.classify()`), absent = the declaration-order cold walk.
* Read by the agent mount for exactly one refusal: `strictness: 'rails'`
* cannot honor `'model-read'` (that mode's entire entry mechanism is a
* model pick).
*/
readonly entrySelection?: 'scorer' | 'model-read' | 'classify';
/**
* The ASYNC intent audit (SG-C) — present ONLY when a classifier is
* configured. Leave-one-out over the declared example corpus with the
* CONFIGURED scorer (the router that will actually run — auditing with a
* different scorer would prove nothing about production): each example is
* scored against every intent, its own intent represented by its remaining
* examples; a cross-match or near-tie is an `overlapping-intents` warning
* naming the example, both intents and both numbers.
*
* Costs one scorer call per declared example — on `llmClassifier` that is
* one MODEL call per example, so run it in CI, not per request. Honesty
* boundaries ride every message: only the configured scorer's view is
* checked, `when` predicates are opaque and never claimed checked, and
* tier-1 rules that fire before the classifier are named as unaudited
* shadowers. Absent without a classifier rather than answering
* `{ ok: true }` for a question it cannot ask.
*/
checkupIntents?(signal?: AbortSignal): Promise<GraphCheckup>;
/**
* Present when this graph's own build pass DEFERRED the skill-body ↔
* tool-contract checks (`body-foreign-tool` / `body-unknown-tool`): built
* WITHOUT `knownTools`, the graph cannot tell a typo from a baseline tool the
* agent registers later — `lookup_order(id)` in a body and `lokup_order(id)`
* look identical to it — so instead of reporting what it cannot prove, it
* leaves this note and Agent build runs those checks exactly once, against the
* agent's full tool registry. The note also rides each compiled skill's own
* metadata ({@link SKILL_GRAPH_DEFERRED_CONTRACT_KEY}), so it is honored
* whichever way the skills arrive — `.skillGraph(graph)` or
* `.skills({ list: () => graph.skills })`.
*
* Absent when the checks already ran at graph build (`knownTools` was given —
* the manual override stays an override) or were switched off (`check: 'off'`);
* the agent then never re-runs them, so one problem is reported at one build
* point, never both. `graph.checkup()` is unaffected — it always runs every
* check over what it can see, and remains the graph-only lint surface.
*/
readonly deferredBodyContract?: DeferredBodyContract;
}
export interface SkillGraphBuilder {
/** Mark a skill as reachable at turn start (optionally intent-conditional). */
entry(skill: Injection, opts?: SkillEntryOptions): SkillGraphBuilder;
/** Declare an edge: after `from`'s work, `to` activates when the edge fires. */
route(from: Injection, to: Injection, opts?: SkillRouteOptions): SkillGraphBuilder;
/** Declare a decision TREE (v3): predicate nodes → skill leaves. Compiles each
* leaf to a path-conjunction trigger; renders as diamonds → boxes. By default
* each leaf is tool-scoped (`autoActivate: 'currentSkill'`) so only the routed
* skill's tools reach the LLM — opt out with `{ scopeTools: false }`. */
tree(root: DecisionNode | Injection, opts?: TreeOptions): SkillGraphBuilder;
/**
* Pick the STARTING entry with a pluggable scorer STRATEGY — `keywordScorer()`
* (no dependency, word overlap), `embeddingScorer(embedder)` (semantic), or your
* own `EntryScorer`. The agent's PickEntry stage runs it ONCE per turn off the
* hot loop and starts the cursor at the winner. Like `.entryByRead()`, this makes
* the entries EXCLUSIVE (only the chosen one loads, token-efficient). The surfaced
* `relevance` % powers the "Why this skill?" panel. Flat graphs only (a decision
* `tree()` already routes by predicate). Mutually exclusive with `.entryByRead()`.
*/
entryBy(scorer: EntryScorer): SkillGraphBuilder;
/**
* Sugar for `.entryBy(embeddingScorer(embedder))` — pick the starting entry by
* SEMANTIC relevance (embed the message + each entry's `description`, cosine-score,
* softmax → best match). LLM-free (an embedder, no extra model call), reproducible.
* For a no-embedder router, use `.entryBy(keywordScorer())`.
*/
entryByRelevance(embedder: Embedder): SkillGraphBuilder;
/**
* Let the LLM pick the STARTING entry by reading the menu — no embedder, no extra
* model call. Like `.entryByRelevance()`, the entries become EXCLUSIVE (only the
* chosen one loads, token-efficient), but the choice is the model's: on the first
* turn no entry auto-loads, the agent is offered the entries via `read_skill`, and
* its pick becomes the cursor. Use this when you have NO embedder (or embeddings
* route poorly for your domain) — the agent's own LLM understands the request.
* Flat graphs only; mutually exclusive with `.entryByRelevance()`.
*
* A `when` on an entry gates the AUTOMATIC pick only: if the model explicitly
* picks a `when`-gated entry from the menu, it loads (8.3.0 — before, the pick
* was accepted, reported as activated, and then silently dropped). Use `when`
* here to say "don't route here on your own", never as a lock.
*/
entryByRead(): SkillGraphBuilder;
/**
* Configure the turn-start INTENT CLASSIFIER (SG-C) — the tier-2 judge of
* `match: { intent, examples }` entries. `classify` is the honest verb: it
* classifies the NEW message against declared intents, where `.entryBy()`
* means "rank descriptions and pick" — a different machine (menu-exclusive
* winner, no floor, no stay). The entries become EXCLUSIVE (only the routed
* one loads); the cold-start declaration-order walk is suppressed (the
* cascade decides, or offers a menu — never the first-declared entry by
* accident).
*
* The cascade, per turn: tier 1 — regex/keyword/`when` rules in declaration
* order (binary, decisive); tier 2 — `scorer` over the declared intents,
* judged under `policy` (defaults: `NEAR_TIE_MARGIN`/`MENU_SIZE`); tier 3 —
* a near-tie or unmatched verdict offers a MENU through `read_skill`'s own
* description, and the model picks (or stays). Every verdict is recorded on
* `agentfootprint.skill.turn_routed` with the losers and the thresholds.
*
* `policy` here is the ONE override home for the tie policy (mirrors the
* one-dial-one-home law `scopeTools` follows). Mutually exclusive with
* `.entryBy()` / `.entryByRelevance()` / `.entryByRead()` — one entry
* router per graph. Flat graphs only.
*/
classify(scorer: IntentScorer, policy?: Partial<RoutingPolicy>): SkillGraphBuilder;
/**
* Declare phrasings this graph must claim **NOWHERE** — the negative form of
* a rule's `examples`, and the one that catches the expensive failure.
*
* An under-triggering skill costs a turn (the model tier picks instead). An
* OVER-triggering skill costs the answer: the wrong body enters the system
* prompt and the wrong tools enter the tools slot, and everything said after
* that is shaped by a skill with no business in the turn.
*
* It is declared on the GRAPH, not on a skill, because a phrase that must
* route nowhere belongs to no skill: the assertion is satisfied only when
* EVERY rule declines, and a row hung on one skill would vanish the day that
* skill was deleted — exactly when a graph is re-partitioned, which is
* exactly when over-triggering appears.
*
* `graph.checkup()` runs every declared start condition over the phrase on a
* cold-start context and reports the rule that claims it, BY NAME, as an
* ERROR (`never-routes-claimed`) — so a default `.build()` refuses.
*
* **What it proves, exactly:** no declared start RULE claims the phrase. Not
* "no routing at all": an intent rule is judged by a classifier at run time, a
* scorer / `.entryByRead()` menu ranks descriptions, and `read_skill` can
* always open an open skill by name. That statement rides the report itself
* (`checkup().notes`).
*
* Accepts one phrase or a list; call it as often as you like (rows
* accumulate). A duplicate row is refused — one row already asserts it
* against every rule.
*
* @example
* skillGraph()
* .entry(billing, { match: { keywords: ['refund', 'charge'] } })
* .neverRoutes(["what's the weather in Berlin"])
* .build();
*/
neverRoutes(phrases: string | readonly string[]): SkillGraphBuilder;
build(opts?: BuildOptions): SkillGraph;
}
export declare function skillGraph(): SkillGraphBuilder;
export declare function skillGraph(config: SkillGraphConfig): SkillGraph;
/**
* 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 declare const defineSkillMap: typeof skillGraph;
/** The declared graph a SkillWalker walks — a permanent alias of
* {@link SkillGraph}, exported forever beside it (9.51.0). */
export type SkillMap = SkillGraph;
/**
* WHICH CLASS OF TARGET a `read_skill` id is, from one cursor — the ONE owner
* of the question every gate, description and refusal used to answer for
* itself (9.86.0).
*
* ── THE LAW ───────────────────────────────────────────────────────────────
*
* THE CURSOR IS A LEGITIMATE TARGET OF A READ (a stay), NEVER OF A MOVE.
*
* {@link makeReachableSkills} filters the cursor out of its own successor set,
* and that is correct for a MOVE: there is nowhere to move to. It was then read
* by five different consumers as "not available", which is a different claim and
* a false one — the cursor's body is in the prompt and its tools are on the wire.
* Each consumer that noticed wrote its own `requested === cursor` check; the two
* that did not (the tool-effects `propose-transition` judge and the `skill_read`
* permission gate) refused a stay as if it were a move.
*
* Four classes, in the order the gate must consider them:
*
* • `'self'` — the cursor itself. A stay: nothing activates, nothing
* moves, nothing is read that was not already sent.
* • `'hop'` — a declared successor of the cursor. The one class that
* MOVES the cursor.
* • `'open'` — a skill the graph wires no edge into (a `.selfExplain()`
* skill, a `.skill()` registered beside the graph). It
* activates and never moves the cursor.
* • `'unreachable'` — everything else.
*
* `'hop'` beats `'open'` when an id is in both sets: the move is the stronger
* fact, and a caller that reports the id as merely open would lose it.
*
* Pure and total: no cursor (cold start) simply means nothing can be `'self'`.
*/
export type SkillTargetClass = 'self' | 'hop' | 'open' | 'unreachable';
/**
* 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 declare function classifySkillTarget(args: {
/** Where the cursor stands. `undefined` = cold start (nothing is a stay). */
readonly cursor?: string;
/** The id the model asked for. */
readonly target: string;
/** Declared successors of the cursor — `reachableSkills(cursor)`. */
readonly hops?: readonly string[];
/** Skills the graph wires no edge into. */
readonly open?: readonly string[];
}): SkillTargetClass;