/** * G4 — trigger eval: does an authored skill fire on the situations it is for, * and stay quiet otherwise? * * G1–G3 ask whether a plugin is well-formed, safe, and runnable. None of them * ask whether it is any *good*, and for a passive capability the whole of "good" * is the `description`: it is the only thing the model sees when deciding * whether to reach for the skill. A skill that parses, validates, and never * triggers is dead weight that costs context on every request. * * ## The circularity trap * * The tempting design is to generate test prompts from the description and then * check that the description matches them. That measures self-consistency and * always passes. So gold prompts are **supplied**, not generated (`eval/triggers.json` * in the plugin, or authored alongside it), and the negatives largely come for * free: a prompt whose right answer is a *different* capability is a negative for * this one. Discriminating against real siblings is the question that matters — * "does it fire" is easy, "does it fire instead of the wrong thing" is not. * * ## Reuse of the search eval harness * * §4.2 asks G4 to reuse `core/search/eval-harness.ts`. Its three *rules* are * adopted verbatim below and are the reason this module looks the way it does: * pin what you measured, record it machine-readably, and never let a degraded * run read like a real one. Its *machinery* could not be called: that harness * pins a corpus by checking out a git worktree and stamps provenance with * embsearch daemon state, and G4's corpus is a handful of description strings * with no repo and no embedder. Calling it would have meant a git worktree per * skill eval. The pinning here is therefore a content hash of exactly what was * judged — same guarantee, appropriate mechanism. * * See docs/plugin-system-architecture.md §4.2. */ import type { CapabilityDoc } from "../../capabilities/registry.js"; import type { GateFinding } from "./gates.js"; import type { NormalizedPlugin } from "./manifest.js"; /** Where a plugin declares the prompts its capabilities should (and should not) fire on. */ export declare const TRIGGER_GOLD_FILE: string; export interface TriggerCase { prompt: string; /** Capability name this prompt should select, or null when nothing should fire. */ expect: string | null; } /** One capability offered to the judge as a candidate. */ export interface TriggerCandidate { name: string; description: string; /** False for the distractors drawn from other plugins. */ own: boolean; } export interface TriggerJudgeRequest { candidates: TriggerCandidate[]; prompts: string[]; } /** The judge's answer per prompt: the selected candidate name, or null for none. */ export type TriggerJudgeVerdict = string | null; /** * The single model call, injected. * * Injected rather than imported so the scoring, the gold-set handling and the * gate mapping are testable without a model — and so a caller with no model * configured gets an honest `not-run` instead of a gate that quietly passes. */ export type TriggerJudge = (request: TriggerJudgeRequest) => Promise; export interface TriggerCaseResult { prompt: string; expected: string | null; actual: string | null; correct: boolean; } export interface TriggerEvalRecord { /** Content hash of the candidates and cases judged — the pin. */ corpusHash: string; timestampMs: number; pluginId: string; candidateCount: number; caseCount: number; /** Fires when it should: TP / (TP + FN). Undefined when there were no positives. */ recall?: number; /** Quiet when it should be: TN / (TN + FP). Undefined when there were no negatives. */ specificity?: number; results: TriggerCaseResult[]; } export type TriggerEvalOutcome = { status: "not-run"; reason: string; } | { status: "ran"; record: TriggerEvalRecord; }; /** Read the plugin's declared gold set, or undefined when it has none. */ export declare function loadTriggerCases(root: string): TriggerCase[] | undefined; /** * The plugin's own passive capabilities, as judge candidates. * * Skills only. Commands are invoked by name (`/foo`) rather than selected from a * description, so "does the description fire" is not a question about them, and * scoring them would dilute the metric with cases that cannot fail. */ export declare function ownCandidates(plugin: NormalizedPlugin): TriggerCandidate[]; /** * Sibling capabilities to put in front of the judge alongside the plugin's own. * * Without them the judge picks from a list of one and "does it fire" is nearly * free. The interesting failure is a description broad enough to win prompts * that belong to something else, and that only shows up against real competition. */ export declare function distractorCandidates(exclude: string, docs: readonly CapabilityDoc[]): TriggerCandidate[]; /** * Pin: a hash of exactly what was judged. * * Over candidates *and* cases, because both move the number. A rerun after a * description edit is measuring something else, and a record that cannot say so * is the failure mode the search harness was built to prevent. */ export declare function triggerCorpusHash(candidates: readonly TriggerCandidate[], cases: readonly TriggerCase[]): string; /** * Score a plugin's passive capabilities against its gold set. * * Never throws and never fabricates: no judge, no gold set, or no candidates all * return `not-run` with the reason. A judge that answers the wrong number of * prompts is also `not-run` — a partial alignment between prompts and verdicts * would silently score the wrong pairs. */ export declare function runTriggerEval(pluginId: string, candidates: readonly TriggerCandidate[], cases: readonly TriggerCase[] | undefined, judge: TriggerJudge | undefined): Promise; /** * Map an outcome onto gate findings. * * `not-run` is `info`, never a pass and never a failure: G4 is opt-in and needs a * model, so a machine without one must not be blocked from publishing — but the * record has to say the check did not happen rather than leaving a silence that * reads like a green. */ export declare function triggerFindings(outcome: TriggerEvalOutcome): GateFinding[]; //# sourceMappingURL=trigger-eval.d.ts.map