/** * `vigiles/check` — the declarative check vocabulary (Phase 0 of the testing-API * revamp; see `research/testing-api-design.md`). * * A **check** is *data*, not a throwing assertion: a small object that knows how * to `eval` itself against a result and how to `toJSON`. One vocabulary, evaluated * two ways downstream — strict (`expect`, throws on first fail) for the * deterministic tiers, and scored (`measure`, 0–1 across trials) for evals — so * `tool("Bash")` reads as pass/fail on one run AND as a rate across many. Because * a check serializes, JSON/JUnit/baseline output and a promptfoo bridge fall out * for free. * * Pure + model-free: every check is fully unit-testable without a `claude` * subprocess. Checks over a {@link Trace} (the agent-run shape, from * `runHarness`/`runEval`) and checks over a {@link HookRunResult} (the hook * decision shape, from `runHook`) are distinguished at the type level, so * `expect(result, checks)` only accepts checks that match the result. */ import type { Trace } from "./harness-test.js"; import type { HookRunResult } from "./run-hook.js"; import { type ArgMatcher } from "./arg-match.js"; export type { ArgMatcher }; /** The outcome of evaluating one check against one result. */ export interface CheckResult { /** Did the check hold? */ readonly pass: boolean; /** 0..1 — `pass ? 1 : 0` for a boolean check; a fraction for graded ones. */ readonly score: number; /** A human message: confirming on pass, actionable on failure. */ readonly message: string; } /** A check's serialized form (for JSON/JUnit output and the promptfoo bridge). */ export interface CheckJSON { readonly kind: string; readonly [field: string]: unknown; } /** A declarative check over a result of type `T` ({@link Trace} or {@link HookRunResult}). */ export interface Check { /** Discriminator, e.g. `"tool"`, `"skill"`, `"blocked"`. */ readonly kind: string; /** Evaluate this check against a result. Pure. */ eval(target: T): CheckResult; /** Serialize to a plain object — `expect`/`measure` use this for reports. */ toJSON(): CheckJSON; } /** Evaluate every check against a target. Pure — the shared core of `expect` * (strict) and `measure` (scored). */ export declare function evalChecks(target: T, checks: readonly Check[]): CheckResult[]; /** * Strict evaluator (Phase 1): throw if any check fails, collecting **all** * failures into one actionable message (Validation-applicative, not * short-circuit). The deterministic-tier verdict — use it in `node:test`, or any * runner, over a `runHook` / `runHarness` result. `measure` (Phase 3) is the * scored counterpart over trials. */ export declare function assertChecks(target: T, checks: readonly Check[]): void; /** The agent invoked a tool by this name (regardless of result). */ export declare function tool(name: string): Check; /** * The agent used tool `name` with at least one call whose `input` matches `args` * — the **argument** half of the tool-call spy. Asserts not just *that* a tool was * reached but *how* it was called (the image-request body carried the style suffix, * the panel spawn requested a non-expert), which a completion grader can't see. * * Cross-reference: ≈ promptfoo `is-valid-function-call` / DeepEval `ToolCorrectnessMetric`. */ export declare function toolWith(name: string, args: ArgMatcher): Check; /** * The agent did NOT use tool `name` — the **safety / negative** assertion. With * `args`, only calls whose `input` matches are forbidden (so "did not push to * `main`" still allows pushing elsewhere; "no paid API call" forbids it outright). * This is the highest-value, most-overlooked check, and the one a * completion-grading eval structurally cannot make: it sees the agent's *decision* * to act, not just its final text. * * Cross-reference: this negative/safety assertion of a *decision not to act* has * no promptfoo / DeepEval / Inspect equivalent — they grade what the agent DID, * not what it correctly refrained from doing. */ export declare function notTool(name: string, args?: ArgMatcher): Check; /** * The agent used **only** tools drawn from `allowed` — the white-list, and the * missing half of a symmetry the file surface already has: * * | | "not this one" | "nothing but these" | * | ----- | --------------- | ---------------------- | * | files | `assertNoWrite` | `assertWroteOnly` | * | tools | {@link notTool} | **`onlyTools`** (this) | * * Why the asymmetry mattered: `notTool` can only forbid the calls the test * author thought of, and the set of undeclared tools is unbounded — so "this * skill stayed inside the tools it declares" was not expressible, which is * exactly the claim a `SKILL.md` frontmatter makes in prose. See * `skillContract`, which builds this check from that declaration. * * **Fails closed on an empty trace.** A `Trace` records tool calls only when the * run captured the stream (`transcript: true` on the harness tier; always on the * eval tier), so an uncaptured run is indistinguishable from a tool-free one — * and passing it would assert nothing. Same discipline as `assertWroteOnly` * refusing a result that never recorded writes: "we didn't look" is not "it was * clean". * * 🔴 A `Tool(restriction)` DECLARATION IS MATCHED BY ITS BASE NAME. `allowed-tools: * Bash(git:*)` is the ordinary way to write a narrow grant, and `skillContract` * hands that literal string here — while a trace only ever carries the base name * `Bash`, because that is the tool the harness reports. Set membership therefore * missed, and every legitimate `Bash` call was reported as outside the declared * surface, against a declaration that literally lists Bash (measured 2026-08-11: * `agent used tool(s) outside its declared set: Bash (declared: Bash(git:*), * Read, Skill)`). A check that fires on its own happy path gets deleted, not * debugged — and it would fire on exactly the authors who narrowed their grant. * * ⚠️ AND THE RESTRICTION ITSELF IS NOT VERIFIED HERE, deliberately. A trace names * the tool, not the grant it was matched against, so `Bash(git:*)` can be held to * "no tool outside the declared set" and no further; whether the command really * was a `git` one is a claim this layer has no evidence for. Narrowing beyond the * base name is `disallowed-tools:` + `lethal-trifecta`'s job (see * `skill-contract.ts`), which reads the grant instead of the run. The same * `split("(")` normalization the rest of the codebase already applies to tool * declarations — `core/tool-contract.ts`, `core/lethal-trifecta.ts`, * `core/delegation-trifecta.ts`. * * ⚠️ SPANS THE WHOLE TRACE, deliberately. It answers "did this RUN stay inside * these tools?", which is the right question for a harness that drives one thing. * It is the wrong question when the trace has setup calls before the surface * under test activated, drives several skills, or dispatches a subagent whose * nested calls the parser folds into the same flat list — and reporting those as * a violation is an accusation about the wrong code. * * `skillContract` therefore does NOT use this directly: it wraps it in a window * bounded by its own `Skill` activation (see `duringSkill` in * `src/skill-contract.ts`). If you are checking one skill, prefer the contract; * reach for this when the claim really is about the whole run. */ export declare function onlyTools(allowed: readonly string[]): Check; /** A skill resolved to this id (`:`) without erroring. */ export declare function skill(id: string): Check; /** The agent's final output contains a substring / matches a RegExp. */ export declare function output(matcher: string | RegExp): Check; /** * EVERY reply of the run contains a substring / matches a RegExp — for a rule * about each reply ("end with a status block"), where `output` sees only the * last one. A run with no replies fails: there is nothing the rule held for. * * @experimental The matcher may grow a function form, and a check may come to * be attached to a spec rule (vigiles#323). */ export declare function experimental_eachReply(matcher: string | Readonly): Readonly>; /** * How many replies of the run match, within bounds — `{ max: 1 }` is "at most * one status block for one user message", which a Stop hook that makes the * agent reply again can break while `output` still shows one. * * @experimental See {@link experimental_eachReply}. */ export declare function experimental_replyCount(matcher: string | Readonly, opts: { readonly min?: number; readonly max?: number; }): Readonly>; /** A hook fired for this event (e.g. `"PreToolUse"`, `"Stop"`). */ export declare function hookFired(event: string): Check; /** * The model RECEIVED text matching `matcher` in some request — i.e. it actually * reached the model. Covers **slash-command expansion** (a `commands/` file * expands into the user prompt) and **injected context** (a SessionStart hook's * text). Reads `modelRequests`, which the harness/mock tier captures; the eval * tier drives the real API and captures none, so use this on `runHarness`. */ export declare function received(matcher: string | RegExp): Check; /** * The agent took a number of model turns in range — a **multi-turn** observable * (`{ min: 2 }` asserts a back-and-forth happened, not a one-shot answer; `{ max }` * caps runaway loops). The deterministic harness scripts the model turns; this * checks how many the agent actually took. */ export declare function turns(opts: { min?: number; max?: number; }): Check; /** The agent wrote (or left) a file at this path in the work dir. */ export declare function wrote(path: string): Check; /** * The agent did NOT leave a file at this path — the **side-effect boundary** * negative: a skill that declares it writes only `out.txt` should leave nothing * at `secrets.env`. The symmetric sibling of `wrote()`; pairs with * `notTool(...)` to assert a unit stayed inside its declared write surface * deterministically (no model judge). */ export declare function didNotWrite(path: string): Check; /** The named subagent (`Task` `subagent_type`) ran and passed every nested check. */ export declare function subagent(name: string, checks: readonly Check[]): Check; /** The hook blocked the event (exit 2 / deny / block). */ export declare function blocked(): Check; /** The hook allowed the event through. */ export declare function allowed(): Check; /** The agent used an MCP tool `/` (CC names it `mcp__server__tool`). */ export declare function mcp(server: string, toolName: string): Check; /** A model-graded judge: grades `output` against a `rubric` → score in [0,1]. */ export type JudgeFn = (opts: { output: string; rubric: string; threshold?: number; }) => { score: number; pass: boolean; reason?: string; }; /** * A model-graded check: the agent's `output` scores ≥ `min` against `rubric`, * judged by a model. Unlike the deterministic checks this one calls a model * (cost), so it's for the scored `measure` tier; the `judge` fn is injectable * (default: the real `judge()`), so the logic is unit-testable with a fake. */ export declare function judged(rubric: string, opts?: { min?: number; judge?: JudgeFn; }): Check; interface UsageTrace { readonly usage: { readonly costUsd: number; readonly durationMs: number; /** Fresh (uncached) input tokens, billed at full input price. */ readonly inputTokens: number; readonly outputTokens: number; /** Tokens written to the prompt cache this run (~1.25× input price). */ readonly cacheCreationTokens: number; /** Tokens served from the prompt cache this run (~0.1× input price). */ readonly cacheReadTokens: number; }; } /** The run cost at most `maxUsd`. */ export declare function cost(opts: { maxUsd: number; }): Check; /** The run took at most `maxMs` of wall-clock time. */ export declare function latency(opts: { maxMs: number; }): Check; /** The run used at most `max` total (input + output) tokens. */ export declare function tokens(opts: { max: number; }): Check; /** * The run used at most `max` **fresh (uncached) input** tokens. The honest input * side of a cost claim: a skill or CLAUDE.md injection adds input every turn, so * a "compression" win on output can be erased here. (Cache reads are separate — * see `cacheTokens`.) */ export declare function inputTokens(opts: { max: number; }): Check; /** The run used at most `max` **output** tokens — the generation side. */ export declare function outputTokens(opts: { max: number; }): Check; /** * Bound the prompt-cache token classes a run uses. `maxCreation` caps tokens * **written** to the cache (the ~1.25× write premium — a fresh/cold prompt); * `maxRead` caps tokens **served** from cache (~0.1× input). Each constraint is * checked only when provided; the check passes when every provided bound holds. */ export declare function cacheTokens(opts: { maxCreation?: number; maxRead?: number; }): Check; //# sourceMappingURL=check.d.ts.map