/** * skillGraph check-up — build-time validation of a declared graph. * * Pure + side-effect-free. Catches wiring mistakes at authoring time instead of * mid-run: a skill nobody can reach, an edge to a skill that isn't in the graph, * two un-prioritized edges leaving one skill, a graph with no start, a self-loop, * an entry menu with no way to choose from it, a transition the cursor can never take. * * Surfaced two ways: * • `graph.checkup()` → `{ ok, problems }` — always available, call it whenever. * • `.build({ check: 'throw' | 'warn' | 'off' })` — run it at build time. * * **Only `unknown-skill` and `no-entry` are ERRORS.** Everything else is a WARNING, * and deliberately so: this file reports what it can PROVE from the declaration, and * a graph the model can still navigate is not a broken graph. `unreachable-skill`, * `model-edge-only` and `dead-entry-step` all describe skills or transitions that * deterministic routing cannot reach — but `read_skill` can, so calling them errors * would claim more than the declaration supports. * * ## What "reachable" means here (8.7.0) * * The BFS walks **deterministic** edges only — an edge with a `when` or an * `onToolReturn`. A bare `.route(a, b)` compiles to no trigger at all (`b` keeps its * `llm-activated` default), so counting it as reachability answered a question nobody * asked: the graph does not route there, the model does. Bare edges get their own * code, `model-edge-only`, which says exactly that and names the one cursor position * the gate will grant the jump from. */ import { type SkillMatchData } from './skillMatch.js'; import { type SkillGuardData } from './skillGuard.js'; /** * The compiled trigger kinds this file needs to tell apart. Mirrors * `InjectionTrigger['kind']` without importing it — the check-up is pure over * strings and must not depend on the engine's types (`skillMatch.ts` is itself * engine-type-free, which is what keeps that law intact here). */ export type CheckupTriggerKind = 'always' | 'rule' | 'on-tool-return' | 'llm-activated'; export type GraphProblemCode = 'unusable-tool-name' | 'unknown-skill' | 'no-entry' | 'unreachable-skill' | 'model-edge-only' | 'multi-entry-fanout' | 'dead-entry-step' | 'ambiguous-routes' | 'self-loop' | 'guard-unsatisfiable' | 'rule-id-exists' | 'overlapping-rules' | 'rules-shadowed-by-order' | 'intent-without-classify' | 'duplicate-intent-example' | 'overlapping-intents' | 'body-foreign-tool' | 'body-unknown-tool' | 'example-misses-own-rule' | 'example-shadowed-by-earlier' | 'example-shadowed-by-default' | 'example-unclaimed' | 'never-routes-claimed' | 'never-routes-by-default' | 'never-routes-contradicts-example' | 'tools-share-prefix' | 'few-declared-edges' | 'one-way-entries' | 'no-negative-evidence' | 'skill-wraps-one-tool' | 'artifact-kind-unsatisfied'; /** One issue found by the check-up. `kind: 'error'` fails `ok` (and `'throw'`). */ export interface GraphProblem { readonly kind: 'error' | 'warning'; readonly code: GraphProblemCode; readonly message: string; /** The skill the problem is about (unreachable/ambiguous source). */ readonly skill?: string; readonly from?: string; readonly to?: string; /** * The declared phrase this problem is about — present on the `example-*` * codes and on the `never-routes-*` ones, the checks that reason about a * concrete phrase (in the positive and the negative direction respectively). * The message always quotes it too; this is the same string as data, so a * tool can group by phrase without parsing prose. */ readonly example?: string; } /** Result of `graph.checkup()`. `ok` is false iff there is ≥1 `error`. */ export interface GraphCheckup { readonly ok: boolean; readonly problems: readonly GraphProblem[]; /** * What this report does NOT cover — present only when a check ran whose * SILENCE could be misread as proof. Today that is the start-rule examples * (skillExamples.ts): they prove things about the phrases the author * declared and nothing about phrases nobody wrote, so a clean report is not * proof of coverage — and the report says so itself rather than leaving it * to prose docs. Absent (not empty) when nothing needed saying, so a graph * that never heard of examples reports the exact same object as always. */ readonly notes?: readonly string[]; } /** One declared entry, in declaration order. */ export interface CheckupEntry { readonly id: string; /** Has a `when` predicate or a `match` data matcher — i.e. it does NOT * unconditionally win the cold-start cursor. */ readonly conditional: boolean; /** The DATA matcher behind the condition, when the rule was declared as data * (`match:`) rather than code (`when:`). Only rules that carry it can be * compared (`overlapping-rules` / `rules-shadowed-by-order`) — a `when` * predicate is opaque, and this file never claims to have checked one. */ readonly match?: SkillMatchData; } export interface CheckupInput { /** Every skill id IN the graph. */ readonly skillIds: ReadonlySet; /** Declared entries, in declaration order (the order the cursor resolver reads). */ readonly entries: readonly CheckupEntry[]; /** Declared edges; `deterministic` = has a `when`/`onToolReturn`/ * `onToolStatus`/`guard` condition. A `guard` rides as DATA so this file * can prove contradictions; `onToolReturnExact`/`onToolStatuses` are the * only preconditions that are provably comparable with it (a RegExp * `onToolReturn` is not decided, so it never arrives here). */ readonly routes: ReadonlyArray<{ fromId: string; toId: string; deterministic: boolean; guard?: SkillGuardData; onToolReturnExact?: string; onToolStatuses?: readonly string[]; }>; /** Decision-`tree()` graphs are exhaustive by construction — only id checks apply. */ readonly isTree: boolean; /** * The entries are EXCLUSIVE — a scorer (`.entryBy()`/`.entryByRelevance()`) or * `.entryByRead()` picks exactly one. When true, the fan-out checks do not apply: * choosing among the entries is precisely what those strategies do. */ readonly exclusiveEntries: boolean; /** * The COMPILED trigger kind per skill id. Read only to keep `unreachable-skill`'s * sentence true: "the model can still open it with read_skill" holds for an * `llm-activated` trigger and for no other kind — the agent's gate admits an open * pick only for that one (`Agent.openSkillIds`). A skill that arrived carrying a * hand-authored `rule` trigger keeps it (`deriveTrigger` returns null for an * unwired skill), and for that skill the old sentence was false. */ readonly triggerKinds: ReadonlyMap; /** * A classifier is configured (`.classify()` / `start.classify`, SG-C). Two * consequences here: an intent entry is judgeable (no * `intent-without-classify`), and the tier-1 pairwise rule checks RUN even * though the entries are exclusive — the cold cascade reads rule entries in * declaration order again (first match wins), so the checks' premise holds, * unlike under a pure scorer (which ranks ALL matching candidates) or * `.entryByRead()` (the model picks). */ readonly hasClassifier?: boolean; } /** Run the check-up. Pure. */ export declare function checkupGraph(input: CheckupInput): GraphCheckup; /** * Format a check-up for a thrown error / console warning. * * Notes render AFTER the problems, tagged `[note]`: they are statements about * the report's own reach, not findings. A check-up with no notes formats * byte-identically to every version before them. */ export declare function formatCheckup(checkup: GraphCheckup): string;