vigiles 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/compile.js CHANGED
@@ -16,6 +16,9 @@ exports.validateCommandRef = validateCommandRef;
16
16
  exports.validateSymbolRef = validateSymbolRef;
17
17
  exports.compileClaude = compileClaude;
18
18
  exports.compileSkill = compileSkill;
19
+ exports.compileAgent = compileAgent;
20
+ exports.validateRailway = validateRailway;
21
+ exports.compileRailway = compileRailway;
19
22
  exports.checkFileHash = checkFileHash;
20
23
  exports.adoptDiff = adoptDiff;
21
24
  const node_fs_1 = require("node:fs");
@@ -641,6 +644,267 @@ function compileSkill(spec, options = {}) {
641
644
  const content = renderSkillFrontmatter(spec) + "\n\n" + sections.trim() + "\n";
642
645
  return { markdown: addHash(content, specFile), errors };
643
646
  }
647
+ // ---------------------------------------------------------------------------
648
+ // Compile a subagent spec → agents/<name>.md
649
+ // ---------------------------------------------------------------------------
650
+ // The tool contract a subagent may declare — the rails it runs on. Anything
651
+ // else must be an MCP tool (mcp__server__tool), else it's a typo / nonexistent
652
+ // tool the dispatched worker could never call.
653
+ const KNOWN_AGENT_TOOLS = [
654
+ "Read",
655
+ "Write",
656
+ "Edit",
657
+ "Bash",
658
+ "Grep",
659
+ "Glob",
660
+ "WebSearch",
661
+ "WebFetch",
662
+ "NotebookEdit",
663
+ "TodoWrite",
664
+ "Task",
665
+ "Skill",
666
+ ];
667
+ const MCP_TOOL_RE = /^mcp__[a-z0-9_-]+__[a-z0-9_-]+$/i;
668
+ // Tools the platform never exposes to a subagent, whatever the list says — so a
669
+ // subagent listing one is a guaranteed-dead reference only a compiler catches.
670
+ const NEVER_AVAILABLE_TOOLS = new Set([
671
+ "Agent",
672
+ "AskUserQuestion",
673
+ "EnterPlanMode",
674
+ "ExitPlanMode",
675
+ "ScheduleWakeup",
676
+ "WaitForMcpServers",
677
+ ]);
678
+ /** Closest known tool by edit distance (≤ 3), for a "did you mean" hint. */
679
+ function closestTool(tool) {
680
+ let best = null;
681
+ let bestDistance = Infinity;
682
+ for (const known of KNOWN_AGENT_TOOLS) {
683
+ const d = (0, linters_js_1.editDistance)(tool.toLowerCase(), known.toLowerCase());
684
+ if (d < bestDistance) {
685
+ bestDistance = d;
686
+ best = known;
687
+ }
688
+ }
689
+ return bestDistance <= 3 ? best : null;
690
+ }
691
+ /** Verify a subagent's allowed-tools contract — the rails are real tools. */
692
+ function validateAgentTools(tools) {
693
+ const errors = [];
694
+ for (const tool of tools) {
695
+ if (NEVER_AVAILABLE_TOOLS.has(tool)) {
696
+ errors.push({
697
+ type: "unknown-tool",
698
+ message: `Tool "${tool}" is never available to a subagent — remove it from the tools list.`,
699
+ });
700
+ continue;
701
+ }
702
+ if (KNOWN_AGENT_TOOLS.includes(tool))
703
+ continue;
704
+ if (MCP_TOOL_RE.test(tool))
705
+ continue;
706
+ const near = closestTool(tool);
707
+ const hint = near ? ` Did you mean "${near}"?` : "";
708
+ errors.push({
709
+ type: "unknown-tool",
710
+ message: `Unknown tool "${tool}" in agent tools — use a built-in tool (${KNOWN_AGENT_TOOLS.join(", ")}) or an MCP tool (mcp__server__tool).${hint}`,
711
+ });
712
+ }
713
+ return errors;
714
+ }
715
+ /** Build the subagent YAML frontmatter (name / description / model / tools). */
716
+ function renderAgentFrontmatter(spec) {
717
+ const fm = [
718
+ "---",
719
+ "",
720
+ `name: ${spec.name}`,
721
+ `description: ${spec.description}`,
722
+ ];
723
+ if (spec.model !== undefined)
724
+ fm.push(`model: ${spec.model}`);
725
+ if (spec.tools && spec.tools.length > 0) {
726
+ fm.push(`tools: ${spec.tools.join(", ")}`);
727
+ }
728
+ fm.push("", "---");
729
+ return fm.join("\n");
730
+ }
731
+ /** Render the subagent's named `##` system-prompt sections (verified like CLAUDE.md). */
732
+ function renderAgentSections(sections, basePath) {
733
+ const lines = [];
734
+ const errors = [];
735
+ for (const [name, content] of Object.entries(sections)) {
736
+ if (name.toLowerCase() === "rules") {
737
+ errors.push({
738
+ type: "reserved-section-key",
739
+ message: `Section key "${name}" is reserved — use the \`rules\` field instead.`,
740
+ });
741
+ }
742
+ const heading = name.charAt(0).toUpperCase() + name.slice(1);
743
+ if (typeof content === "string") {
744
+ errors.push(...validateSectionContent(name, content));
745
+ lines.push(`## ${heading}\n\n${content.trim()}`);
746
+ }
747
+ else {
748
+ errors.push(...validateRefs(content, basePath));
749
+ const rendered = content.map(renderFragment).join("");
750
+ errors.push(...validateSectionContent(name, rendered));
751
+ lines.push(`## ${heading}\n\n${rendered.trim()}`);
752
+ }
753
+ }
754
+ return { lines, errors };
755
+ }
756
+ /** Render a result-contract track shape as a compact `{ "f": type, … }` line. */
757
+ function renderShape(shape) {
758
+ const fields = Object.entries(shape)
759
+ .map(([k, t]) => `"${k}": ${t}`)
760
+ .join(", ");
761
+ return fields ? `{ ${fields} }` : "{}";
762
+ }
763
+ /**
764
+ * Render the subagent's typed result contract — the `## Output contract` section
765
+ * that turns a flat worker into a railway step: it must end its turn with a
766
+ * `vigiles:ok` / `vigiles:err` block matching one of these shapes, so its
767
+ * outcome is parseable (`parseAgentResult`) and testable (`assertAgentOk`).
768
+ */
769
+ function renderOutputContract(contract) {
770
+ return [
771
+ "## Output contract",
772
+ "",
773
+ "Finish your turn with exactly one fenced block — success or error — matching one of these shapes.",
774
+ "",
775
+ "On success:",
776
+ "",
777
+ "```vigiles:ok",
778
+ renderShape(contract.ok),
779
+ "```",
780
+ "",
781
+ "On error:",
782
+ "",
783
+ "```vigiles:err",
784
+ renderShape(contract.err),
785
+ "```",
786
+ ].join("\n");
787
+ }
788
+ /** Render the rules a subagent must follow as a `## Rules` section. */
789
+ function renderAgentRules(rules) {
790
+ const parts = ["## Rules", ""];
791
+ for (const [id, rule] of Object.entries(rules)) {
792
+ parts.push(compileRule(id, rule), "");
793
+ }
794
+ return parts.join("\n").trim();
795
+ }
796
+ /**
797
+ * Compile an AgentSpec into a subagent markdown file with YAML frontmatter.
798
+ * Verifies the tool contract and the body's references; the marks the body
799
+ * carries (`vigiles:symbol`, file/cmd refs) are the same ones `audit` re-checks.
800
+ */
801
+ function compileAgent(spec, options = {}) {
802
+ const basePath = options.basePath ?? process.cwd();
803
+ const specFile = options.specFile ?? "agent.md.spec.ts";
804
+ const errors = [];
805
+ if (!specFile.endsWith(".spec.ts")) {
806
+ errors.push({
807
+ type: "spec-name-mismatch",
808
+ message: `Spec file "${specFile}" must end with .spec.ts`,
809
+ });
810
+ }
811
+ else if (!/\.md$/i.test((0, node_path_1.basename)(specFile, ".spec.ts"))) {
812
+ errors.push({
813
+ type: "spec-name-mismatch",
814
+ message: `Spec file "${specFile}" should be named <output>.spec.ts (e.g., agents/reviewer.md.spec.ts)`,
815
+ });
816
+ }
817
+ if (spec.tools)
818
+ errors.push(...validateAgentTools(spec.tools));
819
+ if (Array.isArray(spec.body)) {
820
+ errors.push(...validateRefs(spec.body, basePath));
821
+ }
822
+ const sections = [];
823
+ if (spec.body !== undefined)
824
+ sections.push(renderBody(spec.body).trim());
825
+ if (spec.sections) {
826
+ const result = renderAgentSections(spec.sections, basePath);
827
+ sections.push(...result.lines);
828
+ errors.push(...result.errors);
829
+ }
830
+ if (spec.rules && Object.keys(spec.rules).length > 0) {
831
+ sections.push(renderAgentRules(spec.rules));
832
+ }
833
+ if (spec.output)
834
+ sections.push(renderOutputContract(spec.output));
835
+ const body = sections.join("\n\n");
836
+ errors.push(...checkInlineCode(body, DEFAULT_MAX_INLINE_CODE_LINES));
837
+ const content = renderAgentFrontmatter(spec) + "\n\n" + body.trim() + "\n";
838
+ return { markdown: addHash(content, specFile), errors };
839
+ }
840
+ /** Verify a railway: non-empty, bounded recovery, every delegate target real. */
841
+ function validateRailway(rw, knownAgents) {
842
+ const errors = [];
843
+ if (rw.steps.length === 0) {
844
+ errors.push({
845
+ type: "invalid-railway",
846
+ message: `Railway "${rw.name}" has no steps.`,
847
+ });
848
+ }
849
+ if (rw.recover && rw.recover.max < 1) {
850
+ errors.push({
851
+ type: "invalid-railway",
852
+ message: `Railway "${rw.name}" recover.max must be ≥ 1 (got ${String(rw.recover.max)}).`,
853
+ });
854
+ }
855
+ if (knownAgents) {
856
+ const known = new Set(knownAgents);
857
+ const refs = [...rw.steps];
858
+ if (rw.onError)
859
+ refs.push(rw.onError);
860
+ if (rw.recover)
861
+ refs.push(rw.recover.step);
862
+ for (const s of refs) {
863
+ if (!known.has(s.agent)) {
864
+ errors.push({
865
+ type: "stale-ref",
866
+ message: `Railway "${rw.name}" delegates to unknown agent "${s.agent}".`,
867
+ path: s.agent,
868
+ });
869
+ }
870
+ }
871
+ }
872
+ return errors;
873
+ }
874
+ /** Render the orchestrator command markdown for a railway. */
875
+ function renderRailwayMarkdown(rw) {
876
+ const lines = [
877
+ `# Railway: ${rw.name}`,
878
+ "",
879
+ "Dispatch these subagents on the **success track**, in order. Each returns a " +
880
+ "result block (`vigiles:ok` / `vigiles:err`). If a step returns an error, " +
881
+ "stop the success track and run the error handler with that error payload.",
882
+ "",
883
+ "## Success track",
884
+ "",
885
+ ];
886
+ rw.steps.forEach((s, i) => {
887
+ const task = s.task ? ` — ${s.task}` : "";
888
+ lines.push(`${String(i + 1)}. **${s.agent}**${task}`);
889
+ });
890
+ if (rw.recover) {
891
+ lines.push("", "## Recovery", "", `If a step errors, retry it via **${rw.recover.step.agent}** up to ${String(rw.recover.max)}× before falling to the error track.`);
892
+ }
893
+ if (rw.onError) {
894
+ lines.push("", "## On error", "", `Run **${rw.onError.agent}** with the failing step's error payload.`);
895
+ }
896
+ return lines.join("\n");
897
+ }
898
+ /**
899
+ * Compile a railway into an orchestrator command markdown (with integrity hash),
900
+ * resolving every delegate target against `knownAgents` when provided.
901
+ */
902
+ function compileRailway(rw, options = {}) {
903
+ const errors = validateRailway(rw, options.knownAgents);
904
+ const specFile = options.specFile ?? `${rw.name}.railway.spec.ts`;
905
+ const content = renderRailwayMarkdown(rw) + "\n";
906
+ return { markdown: addHash(content, specFile), errors };
907
+ }
644
908
  /** Check if a generated file's hash is intact. */
645
909
  function checkFileHash(filePath) {
646
910
  if (!(0, node_fs_1.existsSync)(filePath)) {
@@ -673,6 +937,10 @@ function adoptDiff(filePath, spec, basePath) {
673
937
  const { markdown } = compileSkill(spec, { basePath, specFile: filePath });
674
938
  compiledContent = markdown;
675
939
  }
940
+ else if (spec._specType === "agent") {
941
+ const { markdown } = compileAgent(spec, { basePath, specFile: filePath });
942
+ compiledContent = markdown;
943
+ }
676
944
  // Simple line-based diff
677
945
  const currentLines = currentContent.replace(HASH_RE, "").split("\n");
678
946
  const compiledLines = (compiledContent ?? "")
@@ -0,0 +1,33 @@
1
+ import { type SHA256Hash } from "./hash.js";
2
+ import type { RunOut } from "./eval.js";
3
+ /** Cache behaviour: never touch the cache / read-only / read-and-write. */
4
+ export type CacheMode = "off" | "read" | "readwrite";
5
+ /** Everything that determines a trial's model output (the cache key inputs). */
6
+ export interface CacheKeyInput {
7
+ readonly task: string;
8
+ readonly model: string;
9
+ readonly tools: readonly string[];
10
+ /** The resolved fixture + arm + plugin files written before the run. */
11
+ readonly files: Record<string, string>;
12
+ /** The resolved `.claude/settings.json` for the arm (or undefined). */
13
+ readonly settings: unknown;
14
+ /** Which trial this is — distinct trials are distinct samples, cached apart. */
15
+ readonly trialIndex: number;
16
+ }
17
+ /** A recorded trial: its raw output plus the post-run cwd snapshot. */
18
+ export interface CacheRecord {
19
+ readonly out: RunOut;
20
+ /** Text files present in the cwd after the run (relative path → contents). */
21
+ readonly files: Record<string, string>;
22
+ }
23
+ /** Deterministic content hash of the key inputs (order-independent). */
24
+ export declare function cacheKey(input: CacheKeyInput): SHA256Hash;
25
+ /** Read a cached record by key, or null on miss / unreadable / malformed. */
26
+ export declare function readCache(dir: string, key: SHA256Hash): CacheRecord | null;
27
+ /** Write a cached record by key (creating the cache dir as needed). */
28
+ export declare function writeCache(dir: string, key: SHA256Hash, record: CacheRecord): void;
29
+ /** Snapshot the text files under `cwd` as `relativePath → contents` (bounded). */
30
+ export declare function snapshotDir(cwd: string): Record<string, string>;
31
+ /** Restore a snapshot into `cwd`, recreating directories as needed. */
32
+ export declare function restoreDir(cwd: string, files: Record<string, string>): void;
33
+ //# sourceMappingURL=eval-cache.d.ts.map
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cacheKey = cacheKey;
4
+ exports.readCache = readCache;
5
+ exports.writeCache = writeCache;
6
+ exports.snapshotDir = snapshotDir;
7
+ exports.restoreDir = restoreDir;
8
+ /**
9
+ * vigiles — record/replay cache for the eval tier.
10
+ *
11
+ * A real-model eval is slow and costs money, yet most iteration is on the
12
+ * `measure` function, not the model call. This cache records each trial's raw
13
+ * output AND its post-run filesystem, keyed on everything that determines the
14
+ * model's behaviour — `task`, the resolved fixture files + settings, model,
15
+ * tools, and the trial index — but DELIBERATELY NOT the `measure` function. So
16
+ * editing your metric and re-running re-scores the captured runs for free; the
17
+ * model is only re-called when a model-affecting input changes (or `cache:"off"`,
18
+ * which always re-samples for a fresh statistic).
19
+ *
20
+ * Restoring the post-run filesystem is what makes replay *sound*: `measure`
21
+ * routinely reads agent-produced files via `ctx.file()` / `ctx.sh("grep …")`, so
22
+ * a stdout-only cache would silently mis-score on replay. We snapshot the cwd's
23
+ * text files after the run and restore them into a fresh dir before re-scoring.
24
+ */
25
+ const node_fs_1 = require("node:fs");
26
+ const node_path_1 = require("node:path");
27
+ const hash_js_1 = require("./hash.js");
28
+ const MAX_SNAPSHOT_FILE_BYTES = 1024 * 1024;
29
+ const SKIP_DIRS = new Set(["node_modules", ".git"]);
30
+ /**
31
+ * Canonicalize a value so the key is stable regardless of object key order —
32
+ * recursively sorts object keys. Arrays keep order (it's significant for tools).
33
+ */
34
+ function canonical(value) {
35
+ if (Array.isArray(value))
36
+ return value.map(canonical);
37
+ if (value !== null && typeof value === "object") {
38
+ const obj = value;
39
+ const out = {};
40
+ for (const k of Object.keys(obj).sort())
41
+ out[k] = canonical(obj[k]);
42
+ return out;
43
+ }
44
+ return value;
45
+ }
46
+ /** Deterministic content hash of the key inputs (order-independent). */
47
+ function cacheKey(input) {
48
+ return (0, hash_js_1.sha256short)(JSON.stringify(canonical(input)));
49
+ }
50
+ /** Read a cached record by key, or null on miss / unreadable / malformed. */
51
+ function readCache(dir, key) {
52
+ const path = (0, node_path_1.join)(dir, `${key}.json`);
53
+ if (!(0, node_fs_1.existsSync)(path))
54
+ return null;
55
+ try {
56
+ return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** Write a cached record by key (creating the cache dir as needed). */
63
+ function writeCache(dir, key, record) {
64
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
65
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${key}.json`), JSON.stringify(record));
66
+ }
67
+ /** Snapshot the text files under `cwd` as `relativePath → contents` (bounded). */
68
+ function snapshotDir(cwd) {
69
+ const out = {};
70
+ const walk = (dir) => {
71
+ for (const entry of (0, node_fs_1.readdirSync)(dir)) {
72
+ if (SKIP_DIRS.has(entry))
73
+ continue;
74
+ const full = (0, node_path_1.join)(dir, entry);
75
+ const st = (0, node_fs_1.statSync)(full);
76
+ if (st.isDirectory())
77
+ walk(full);
78
+ else if (st.isFile() && st.size <= MAX_SNAPSHOT_FILE_BYTES) {
79
+ out[(0, node_path_1.relative)(cwd, full)] = (0, node_fs_1.readFileSync)(full, "utf-8");
80
+ }
81
+ }
82
+ };
83
+ walk((0, node_path_1.resolve)(cwd));
84
+ return out;
85
+ }
86
+ /** Restore a snapshot into `cwd`, recreating directories as needed. */
87
+ function restoreDir(cwd, files) {
88
+ for (const [rel, content] of Object.entries(files)) {
89
+ const full = (0, node_path_1.resolve)(cwd, rel);
90
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(full), { recursive: true });
91
+ (0, node_fs_1.writeFileSync)(full, content);
92
+ }
93
+ }
94
+ //# sourceMappingURL=eval-cache.js.map
package/dist/eval.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type ToolCall, type Trace } from "./harness-test.js";
2
+ import { type CacheMode } from "./eval-cache.js";
1
3
  /** One arm of the comparison: fixture overrides + settings (hooks) for this arm. */
2
4
  export interface EvalArm {
3
5
  /** Files written on top of the base fixture for this arm. */
@@ -10,16 +12,40 @@ export interface EvalArm {
10
12
  * src/plugin-loader.ts.
11
13
  */
12
14
  readonly plugin?: string;
15
+ /**
16
+ * Path to a plugin dir to install NATIVELY (`claude --plugin-dir`) for this
17
+ * arm, so its skills/commands/agents activate the real way — the real model
18
+ * can trigger a skill by its description (vs. `plugin`, which materializes a
19
+ * file subset that does not register skills). Point at a COMPLETE plugin. Lets
20
+ * an arm be "skill installed" vs "off" to measure real activation.
21
+ */
22
+ readonly pluginDir?: string;
23
+ }
24
+ /** Per-run resource use, parsed from the terminal `result` event (0 when absent). */
25
+ export interface EvalUsage {
26
+ /** `total_cost_usd` reported by claude. */
27
+ readonly costUsd: number;
28
+ /** Wall-clock `duration_ms` of the run. */
29
+ readonly durationMs: number;
30
+ readonly inputTokens: number;
31
+ readonly outputTokens: number;
13
32
  }
14
- /** Context handed to `measure` after a run, to compute that run's metrics. */
15
- export interface RunContext {
33
+ /**
34
+ * Context handed to `measure` after a run, to compute that run's metrics. It is
35
+ * a `Trace` (so the bare predicates `usedTool` / `skillResolved` / `toolCount` /
36
+ * `toolUsedWith` from `harness-assert.ts` run over it, the same as over a
37
+ * `runHarnessTest` result) plus the eval-only `sh` end-state probe and `usage`.
38
+ */
39
+ export interface RunContext extends Trace {
16
40
  readonly cwd: string;
17
41
  readonly exitCode: number;
18
42
  readonly stdout: string;
19
43
  /** `num_turns` reported by claude, or 0. */
20
44
  readonly turns: number;
21
- /** Contents of a file under the working dir, or null if absent. */
22
- file(path: string): string | null;
45
+ /** The tools the agent invoked, each paired with its result (parsed from the stream). */
46
+ readonly toolCalls: readonly ToolCall[];
47
+ /** Cost / latency / tokens for this run (use as metrics, e.g. `{ cost: ctx.usage.costUsd }`). */
48
+ readonly usage: EvalUsage;
23
49
  /** Run a shell command in the working dir; returns trimmed stdout ("" on error). */
24
50
  sh(command: string): string;
25
51
  }
@@ -44,6 +70,32 @@ export interface EvalSpec<M extends Metrics> {
44
70
  readonly timeoutMs?: number;
45
71
  /** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
46
72
  readonly spacingSec?: number;
73
+ /**
74
+ * Record/replay cache mode. Default `"off"` (always re-sample). `"readwrite"`
75
+ * records each trial (output + post-run files) and replays it on a matching
76
+ * re-run — so editing `measure` re-scores for free; the model is re-called only
77
+ * when a model-affecting input changes. `"read"` replays but never records.
78
+ * The cache key excludes `measure`, so changing your metric still hits.
79
+ */
80
+ readonly cache?: CacheMode;
81
+ /** Where cache records live. Default `.vigiles/eval-cache` under cwd. */
82
+ readonly cacheDir?: string;
83
+ /**
84
+ * How many trials to run at once (across all arms × trials). Default 1 (fully
85
+ * sequential — the safe, no-surprise default). Raise it to cut wall-clock time;
86
+ * rate-limit bursts are absorbed by the retry/backoff below.
87
+ */
88
+ readonly concurrency?: number;
89
+ /**
90
+ * Abort the run once measured cost reaches this many USD. In-flight trials
91
+ * finish; remaining ones are skipped and `report.aborted` is set. Needs the
92
+ * model to report `total_cost_usd` (the eval tier does).
93
+ */
94
+ readonly maxCostUsd?: number;
95
+ /** Retries on a detected rate-limit/overload before giving up. Default 3. */
96
+ readonly rateLimitRetries?: number;
97
+ /** Base backoff ms (doubled each retry). Default 1000. */
98
+ readonly retryBackoffMs?: number;
47
99
  }
48
100
  /** Per-metric summary statistics across an arm's runs. */
49
101
  export interface MetricStat {
@@ -55,6 +107,21 @@ export interface MetricStat {
55
107
  readonly se: number;
56
108
  /** Number of runs the metric was observed in. */
57
109
  readonly n: number;
110
+ /**
111
+ * pass^k (τ-bench): 1 if the metric succeeded on EVERY trial, else 0. The
112
+ * reliability question a non-deterministic harness needs — "worked every time"
113
+ * is not "worked on average". A trial counts as a success when its value is
114
+ * truthy (booleans true, counts > 0), so model your metric as success/fail.
115
+ */
116
+ readonly passK: number;
117
+ }
118
+ /** Aggregated cost / latency / tokens across an arm's runs. */
119
+ export interface ArmUsage {
120
+ readonly totalCostUsd: number;
121
+ readonly meanCostUsd: number;
122
+ readonly meanDurationMs: number;
123
+ readonly totalInputTokens: number;
124
+ readonly totalOutputTokens: number;
58
125
  }
59
126
  export interface ArmReport {
60
127
  readonly runs: number;
@@ -62,12 +129,51 @@ export interface ArmReport {
62
129
  readonly metrics: Record<string, number>;
63
130
  /** Per-metric mean / std / se / n, so an A/B gap can be read for significance. */
64
131
  readonly stats: Record<string, MetricStat>;
132
+ /** Cost / latency / token totals + means for this arm. */
133
+ readonly usage: ArmUsage;
65
134
  }
66
135
  export interface EvalReport {
67
136
  readonly name: string;
68
137
  readonly trials: number;
69
138
  readonly arms: Record<string, ArmReport>;
139
+ /** Total measured cost across every arm × trial (0 when usage wasn't reported). */
140
+ readonly totalCostUsd: number;
141
+ /** True if a `maxCostUsd` budget cap stopped the run before all trials ran. */
142
+ readonly aborted: boolean;
143
+ }
144
+ /** The raw output of one trial: the agent's exit code + captured streams. */
145
+ export interface RunOut {
146
+ code: number;
147
+ stdout: string;
148
+ /** Captured stderr, when the runner provides it (used for rate-limit detection). */
149
+ stderr?: string;
150
+ }
151
+ /** The per-trial arguments handed to an {@link AgentRunner}. */
152
+ export interface AgentRunArgs {
153
+ readonly task: string;
154
+ readonly cwd: string;
155
+ readonly model: string;
156
+ readonly tools: readonly string[];
157
+ readonly hasSettings: boolean;
158
+ readonly pluginDir: string | undefined;
159
+ readonly timeoutMs: number;
70
160
  }
161
+ /**
162
+ * Runs one trial and returns its raw output. The default ({@link spawnAgent})
163
+ * drives the real `claude` CLI; `runEvalWith` takes one explicitly, so the eval
164
+ * orchestration is testable without a model (pass a fake returning canned
165
+ * stream-json) and a custom runtime can be plugged in.
166
+ */
167
+ export type AgentRunner = (args: AgentRunArgs) => Promise<RunOut>;
168
+ /**
169
+ * Run the eval: every arm × every trial against the real `claude` CLI, with the
170
+ * metric computed per run and aggregated per arm. Requires `claude` on PATH and
171
+ * working model auth (e.g. `ANTHROPIC_API_KEY`). Thin wrapper over
172
+ * {@link runEvalWith} with the real agent runner.
173
+ */
174
+ export declare function runEval<M extends Metrics>(spec: EvalSpec<M>): Promise<EvalReport>;
175
+ /** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
176
+ export declare function parseUsage(stdout: string): EvalUsage;
71
177
  /** Aggregate per-run metrics: mean for numbers, fraction-true (0..1) for booleans. */
72
178
  export declare function aggregate(rows: readonly Metrics[]): Record<string, number>;
73
179
  /**
@@ -76,12 +182,77 @@ export declare function aggregate(rows: readonly Metrics[]): Record<string, numb
76
182
  * a difference smaller than the combined se is not yet significant.
77
183
  */
78
184
  export declare function aggregateStats(rows: readonly Metrics[]): Record<string, MetricStat>;
185
+ /** Aggregate per-run usage into an arm's cost / latency / token totals + means. */
186
+ export declare function aggregateUsage(usages: readonly EvalUsage[]): ArmUsage;
187
+ /** Whether a run's captured output looks like a rate-limit / overload. Pure. */
188
+ export declare function isRateLimited(out: RunOut): boolean;
189
+ /** Map `worker` over `items` with at most `concurrency` in flight, order preserved. */
190
+ export declare function runPool<T, R>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<R>): Promise<R[]>;
79
191
  /**
80
- * Run the eval: every arm × every trial against the real `claude` CLI, with the
81
- * metric computed per run and aggregated per arm. Requires `claude` on PATH and
82
- * working model auth (e.g. `ANTHROPIC_API_KEY`).
192
+ * The eval orchestration — every arm × trial via `runner`, run through the cache
193
+ * and a rate-limit retry, with at most `concurrency` in flight and an optional
194
+ * `maxCostUsd` budget cap; metric + usage computed per run and aggregated per
195
+ * arm. Exported with an injectable `runner` so the loop, `measure` context,
196
+ * caching, pooling, and aggregation are unit-testable without spawning a model
197
+ * (pass a fake returning canned stream-json). `runEval` is this with the real
198
+ * agent runner.
83
199
  */
84
- export declare function runEval<M extends Metrics>(spec: EvalSpec<M>): Promise<EvalReport>;
85
- /** Format an eval report as a compact table for the console (mean ± se). */
200
+ export declare function runEvalWith<M extends Metrics>(spec: EvalSpec<M>, runner: AgentRunner): Promise<EvalReport>;
201
+ /** Format an eval report as a compact table for the console (mean ± se, pass^k). */
86
202
  export declare function formatEvalReport(report: EvalReport): string;
203
+ /**
204
+ * Measure how reliably a skill/behaviour *triggers*. A skill's value is its
205
+ * description firing on the right task — the #1 documented skill-authoring pain —
206
+ * and that's a property of the real model, not the wiring (which the
207
+ * deterministic tier already proves). Install the plugin natively (`pluginDir`),
208
+ * give a set of varied `prompts`, and a `fired` predicate over the run's `Trace`
209
+ * (reuse the bare predicates, e.g. `(t) => skillResolved(t, "x:y")`).
210
+ */
211
+ export interface TriggerRateSpec {
212
+ /** Plugin dir installed natively (`--plugin-dir`) so its skills/commands activate. */
213
+ readonly pluginDir: string;
214
+ /** The varied prompts to test the trigger against. */
215
+ readonly prompts: readonly string[];
216
+ /** Did the behaviour fire on this run? e.g. `(t) => skillResolved(t, "x:y")`. */
217
+ readonly fired: (trace: Trace) => boolean;
218
+ /** Trials per prompt. Default 1. */
219
+ readonly trials?: number;
220
+ /** Model alias. Default "haiku". */
221
+ readonly model?: string;
222
+ /** Tools the agent may use. Default: Read Edit Write Bash Skill. */
223
+ readonly allowedTools?: readonly string[];
224
+ /** Per-run timeout ms. Default 240000. */
225
+ readonly timeoutMs?: number;
226
+ /** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
227
+ readonly spacingSec?: number;
228
+ }
229
+ /** Per-prompt trigger result: how many of its trials fired. */
230
+ export interface PromptTriggerStat {
231
+ readonly prompt: string;
232
+ readonly fired: number;
233
+ readonly trials: number;
234
+ /** `fired / trials` (0 when no trials). */
235
+ readonly rate: number;
236
+ }
237
+ export interface TriggerRateReport {
238
+ /** Overall fraction of runs in which the behaviour fired (0..1). */
239
+ readonly rate: number;
240
+ /** Total runs (prompts × trials). */
241
+ readonly n: number;
242
+ readonly perPrompt: readonly PromptTriggerStat[];
243
+ }
244
+ /**
245
+ * Trigger-rate orchestration — every prompt × trial via `runner`, the `fired`
246
+ * predicate evaluated per run and aggregated into an overall + per-prompt rate.
247
+ * Exported with an injectable `runner` so the loop is unit-testable without a
248
+ * model; `measureTriggerRate` is this with the real agent runner.
249
+ */
250
+ export declare function measureTriggerRateWith(spec: TriggerRateSpec, runner: AgentRunner): Promise<TriggerRateReport>;
251
+ /**
252
+ * Measure a skill/behaviour's real trigger rate across prompts × trials against
253
+ * the real `claude` CLI. Requires `claude` + model auth.
254
+ */
255
+ export declare function measureTriggerRate(spec: TriggerRateSpec): Promise<TriggerRateReport>;
256
+ /** Format a trigger-rate report: overall %, then each prompt's rate. */
257
+ export declare function formatTriggerRateReport(report: TriggerRateReport): string;
87
258
  //# sourceMappingURL=eval.d.ts.map