vigiles 2.4.0 → 2.6.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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=egress-proxy.d.ts.map
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Recording egress proxy — runs INSIDE the sandbox netns (on loopback), so a
5
+ * confined hook configured with `HTTP(S)_PROXY` routes its network attempts here.
6
+ * It RECORDS each target (`host:port`) to an ndjson log and BLOCKS it (responds
7
+ * 502 / closes) — the netns already has no external route, so nothing actually
8
+ * leaves; this just turns "silently blocked" into "blocked AND recorded", so a
9
+ * test can assert what a hook/skill tried to reach (phone-home / which registry
10
+ * an install would hit).
11
+ *
12
+ * Honest limit: this records what PROXY-honoring tools (npm, pip, curl, fetch)
13
+ * attempt. Raw-socket egress bypasses the proxy — but the netns still blocks it
14
+ * hard, so it can't get out; it just won't appear in the record. The block is the
15
+ * boundary; the record is best-effort observability over it.
16
+ *
17
+ * Run as: `node dist/egress-proxy.js <egress-log-path> <port-file-path>`.
18
+ */
19
+ /* v8 ignore start -- a standalone subprocess run only inside the sandbox netns;
20
+ exercised by the bwrap-gated end-to-end test, not the unit gate. The pure
21
+ parser (parseEgressLog) carries the testable logic. */
22
+ const node_http_1 = require("node:http");
23
+ const node_fs_1 = require("node:fs");
24
+ const [, , logPath, portPath] = process.argv;
25
+ function record(host, port) {
26
+ try {
27
+ (0, node_fs_1.appendFileSync)(logPath, JSON.stringify({ host, port, ts: Date.now() }) + "\n");
28
+ }
29
+ catch {
30
+ /* best-effort: a recording failure must not crash the hook under test */
31
+ }
32
+ }
33
+ const server = (0, node_http_1.createServer)((req, res) => {
34
+ // Plain HTTP via a proxy: req.url is absolute, e.g. http://host:port/path.
35
+ try {
36
+ const u = new URL(req.url ?? "");
37
+ record(u.hostname, Number(u.port) || 80);
38
+ }
39
+ catch {
40
+ /* unparseable target — skip */
41
+ }
42
+ res.writeHead(502, { "content-type": "text/plain" });
43
+ res.end("blocked by vigiles egress recorder\n");
44
+ });
45
+ // HTTPS via a proxy: the client sends `CONNECT host:port`. Record + refuse.
46
+ server.on("connect", (req, socket) => {
47
+ const [host, port] = (req.url ?? "").split(":");
48
+ record(host, Number(port) || 443);
49
+ socket.write("HTTP/1.1 502 Blocked\r\n\r\n");
50
+ socket.end();
51
+ });
52
+ server.on("clientError", (_e, socket) => socket.destroy());
53
+ server.listen(0, "127.0.0.1", () => {
54
+ const addr = server.address();
55
+ const port = typeof addr === "object" && addr ? addr.port : 0;
56
+ // Hand the chosen port back to the wrapper, which exports HTTP(S)_PROXY.
57
+ (0, node_fs_1.writeFileSync)(portPath, String(port));
58
+ });
59
+ /* v8 ignore stop */
60
+ //# sourceMappingURL=egress-proxy.js.map
@@ -0,0 +1,68 @@
1
+ import type { EvalReport } from "./eval.js";
2
+ import { type Comparison } from "./stats.js";
3
+ /** Bumped only on a breaking change to the on-disk shape. */
4
+ export declare const BASELINE_VERSION = 1;
5
+ /** The committed baseline: the recorded `EvalReport`s, keyed by report name. */
6
+ export interface BaselineFile {
7
+ readonly version: number;
8
+ /** ISO-8601 timestamp the baseline was recorded (provenance / future trend). */
9
+ readonly recordedAt: string;
10
+ /** Recorded reports, keyed by `report.name` (so multiple eval files coexist). */
11
+ readonly reports: Record<string, EvalReport>;
12
+ }
13
+ /** How a metric moved between baseline and current run. */
14
+ export type DiffStatus = "regressed" | "improved" | "unchanged";
15
+ /** One arm×metric comparison of a current run against the baseline. */
16
+ export interface MetricDiff {
17
+ /** The `report.name` this entry belongs to. */
18
+ readonly report: string;
19
+ readonly arm: string;
20
+ readonly metric: string;
21
+ readonly status: DiffStatus;
22
+ /** Welch comparison, current vs. baseline (`delta = current − baseline`). */
23
+ readonly comparison: Comparison;
24
+ }
25
+ export interface BaselineDiff {
26
+ /** Every arm×metric present in BOTH the baseline and the current run. */
27
+ readonly entries: readonly MetricDiff[];
28
+ /** The subset that regressed (significant move in the bad direction). */
29
+ readonly regressions: readonly MetricDiff[];
30
+ /** The subset that improved (significant move in the good direction). */
31
+ readonly improvements: readonly MetricDiff[];
32
+ /** True when there are no regressions — the gate. */
33
+ readonly passed: boolean;
34
+ }
35
+ export interface DiffOptions {
36
+ /** Significance level for the Welch test. Default 0.05. */
37
+ readonly alpha?: number;
38
+ /**
39
+ * Metrics where a DECREASE is the improvement (e.g. `cost`, `latency`,
40
+ * `turns`). For these, a significant increase is the regression. Everything
41
+ * else is treated as higher-is-better.
42
+ */
43
+ readonly lowerIsBetter?: readonly string[];
44
+ }
45
+ /** Build a `BaselineFile` envelope from a run's reports (keyed by name). */
46
+ export declare function toBaselineFile(reports: readonly EvalReport[], recordedAt?: string): BaselineFile;
47
+ /** Parse + validate a baseline JSON string (throws on a bad version/shape). */
48
+ export declare function parseBaselineFile(json: string): BaselineFile;
49
+ /**
50
+ * Diff a current run against a committed baseline. Compares every arm×metric
51
+ * present in both (by report name), flagging a *significant* move in the
52
+ * undesired direction as a regression. Metrics absent from one side are skipped
53
+ * (a new arm/metric is not a regression).
54
+ */
55
+ export declare function diffReports(baseline: BaselineFile, current: readonly EvalReport[], opts?: DiffOptions): BaselineDiff;
56
+ /** Format a baseline diff as a compact console report. */
57
+ export declare function formatBaselineDiff(diff: BaselineDiff): string;
58
+ /**
59
+ * Render a baseline diff as JUnit XML — one `<testcase>` per arm×metric, a
60
+ * `<failure>` for each regression. Lets a CI provider show eval regressions in
61
+ * the same place as unit-test failures.
62
+ */
63
+ export declare function diffToJUnit(diff: BaselineDiff): string;
64
+ /** Read + parse a baseline file, or null if it doesn't exist yet. */
65
+ export declare function readBaseline(path: string): BaselineFile | null;
66
+ /** Write reports as the committed baseline (pretty JSON, parent dirs created). */
67
+ export declare function writeBaseline(path: string, reports: readonly EvalReport[]): void;
68
+ //# sourceMappingURL=eval-baseline.d.ts.map
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BASELINE_VERSION = void 0;
4
+ exports.toBaselineFile = toBaselineFile;
5
+ exports.parseBaselineFile = parseBaselineFile;
6
+ exports.diffReports = diffReports;
7
+ exports.formatBaselineDiff = formatBaselineDiff;
8
+ exports.diffToJUnit = diffToJUnit;
9
+ exports.readBaseline = readBaseline;
10
+ exports.writeBaseline = writeBaseline;
11
+ /**
12
+ * vigiles — eval regression gating (Phase C).
13
+ *
14
+ * The eval tier reports mean ± se per arm; `src/stats.ts` turns a gap into a
15
+ * significance verdict. This module points that machinery at a *committed
16
+ * baseline*: record one run's `EvalReport`s to `.vigiles/eval-baseline.json`,
17
+ * then on a later run flag any arm×metric that moved **significantly in the bad
18
+ * direction** vs. that baseline. "jest snapshots for agent behaviour, with a real
19
+ * noise floor" — a bare pass-rate can't tell a true regression from sampling
20
+ * noise, but a Welch t-test over the two runs' summary stats can.
21
+ *
22
+ * Pure + model-free (the diff/serialize/JUnit are fully unit-tested); the only
23
+ * side effects are the two small fs helpers (`readBaseline` / `writeBaseline`).
24
+ * Reuses `welchTTest` from `src/stats.ts` — the current run is the "arm", the
25
+ * baseline is the "baseline", so `delta = current − baseline`.
26
+ */
27
+ const node_fs_1 = require("node:fs");
28
+ const node_path_1 = require("node:path");
29
+ const stats_js_1 = require("./stats.js");
30
+ /** Bumped only on a breaking change to the on-disk shape. */
31
+ exports.BASELINE_VERSION = 1;
32
+ /** Build a `BaselineFile` envelope from a run's reports (keyed by name). */
33
+ function toBaselineFile(reports, recordedAt = new Date().toISOString()) {
34
+ const byName = {};
35
+ for (const r of reports)
36
+ byName[r.name] = r;
37
+ return { version: exports.BASELINE_VERSION, recordedAt, reports: byName };
38
+ }
39
+ /** Parse + validate a baseline JSON string (throws on a bad version/shape). */
40
+ function parseBaselineFile(json) {
41
+ const data = JSON.parse(json);
42
+ if (typeof data !== "object" || data === null) {
43
+ throw new Error("baseline: expected a JSON object");
44
+ }
45
+ const obj = data;
46
+ if (obj.version !== exports.BASELINE_VERSION) {
47
+ throw new Error(`baseline: unsupported version ${String(obj.version)} (expected ${String(exports.BASELINE_VERSION)})`);
48
+ }
49
+ if (typeof obj.reports !== "object" || obj.reports === null) {
50
+ throw new Error("baseline: missing `reports`");
51
+ }
52
+ return {
53
+ version: exports.BASELINE_VERSION,
54
+ recordedAt: typeof obj.recordedAt === "string" ? obj.recordedAt : "",
55
+ reports: obj.reports,
56
+ };
57
+ }
58
+ /** Classify one comparison given the metric's direction. */
59
+ function classify(cmp, lowerIsBetter) {
60
+ if (!cmp.significant || cmp.delta === 0)
61
+ return "unchanged";
62
+ const improved = lowerIsBetter ? cmp.delta < 0 : cmp.delta > 0;
63
+ return improved ? "improved" : "regressed";
64
+ }
65
+ /** Append a diff entry for every arm×metric common to both reports. */
66
+ function collectReportDiffs(baseline, current, cfg, out) {
67
+ for (const [arm, curArm] of Object.entries(current.arms)) {
68
+ const baseArm = baseline.arms[arm];
69
+ if (!baseArm)
70
+ continue;
71
+ for (const [metric, curStat] of Object.entries(curArm.stats)) {
72
+ const baseStat = baseArm.stats[metric];
73
+ if (!baseStat)
74
+ continue;
75
+ const comparison = (0, stats_js_1.welchTTest)(curStat, baseStat, cfg.alpha);
76
+ out.push({
77
+ report: current.name,
78
+ arm,
79
+ metric,
80
+ status: classify(comparison, cfg.lower.has(metric)),
81
+ comparison,
82
+ });
83
+ }
84
+ }
85
+ }
86
+ /**
87
+ * Diff a current run against a committed baseline. Compares every arm×metric
88
+ * present in both (by report name), flagging a *significant* move in the
89
+ * undesired direction as a regression. Metrics absent from one side are skipped
90
+ * (a new arm/metric is not a regression).
91
+ */
92
+ function diffReports(baseline, current, opts = {}) {
93
+ const cfg = {
94
+ alpha: opts.alpha ?? 0.05,
95
+ lower: new Set(opts.lowerIsBetter ?? []),
96
+ };
97
+ const entries = [];
98
+ for (const cur of current) {
99
+ const base = baseline.reports[cur.name];
100
+ if (base)
101
+ collectReportDiffs(base, cur, cfg, entries);
102
+ }
103
+ const regressions = entries.filter((e) => e.status === "regressed");
104
+ const improvements = entries.filter((e) => e.status === "improved");
105
+ return {
106
+ entries,
107
+ regressions,
108
+ improvements,
109
+ passed: regressions.length === 0,
110
+ };
111
+ }
112
+ const STATUS_MARK = {
113
+ regressed: "✗",
114
+ improved: "✓",
115
+ unchanged: "·",
116
+ };
117
+ function formatDelta(c) {
118
+ const sign = c.delta >= 0 ? "+" : "";
119
+ return `Δ=${sign}${c.delta.toFixed(3)} p=${c.pValue.toFixed(3)}`;
120
+ }
121
+ /** Format a baseline diff as a compact console report. */
122
+ function formatBaselineDiff(diff) {
123
+ const head = diff.passed
124
+ ? "baseline OK — no significant regressions"
125
+ : `baseline FAIL — ${String(diff.regressions.length)} regression(s)`;
126
+ const lines = [head];
127
+ for (const e of diff.entries) {
128
+ lines.push(` ${STATUS_MARK[e.status]} ${e.report}/${e.arm}/${e.metric} ${formatDelta(e.comparison)}`);
129
+ }
130
+ return lines.join("\n");
131
+ }
132
+ function xmlEscape(s) {
133
+ return s
134
+ .replaceAll("&", "&amp;")
135
+ .replaceAll("<", "&lt;")
136
+ .replaceAll(">", "&gt;")
137
+ .replaceAll('"', "&quot;");
138
+ }
139
+ function junitCase(e) {
140
+ const name = xmlEscape(`${e.report}.${e.arm}.${e.metric}`);
141
+ const open = ` <testcase classname="${xmlEscape(e.report)}" name="${name}">`;
142
+ if (e.status !== "regressed")
143
+ return `${open}</testcase>`;
144
+ const msg = xmlEscape(`regression: ${formatDelta(e.comparison)}`);
145
+ return `${open}\n <failure message="${msg}"/>\n </testcase>`;
146
+ }
147
+ /**
148
+ * Render a baseline diff as JUnit XML — one `<testcase>` per arm×metric, a
149
+ * `<failure>` for each regression. Lets a CI provider show eval regressions in
150
+ * the same place as unit-test failures.
151
+ */
152
+ function diffToJUnit(diff) {
153
+ const cases = diff.entries.map(junitCase).join("\n");
154
+ return [
155
+ '<?xml version="1.0" encoding="UTF-8"?>',
156
+ `<testsuite name="vigiles-eval" tests="${String(diff.entries.length)}" failures="${String(diff.regressions.length)}">`,
157
+ cases,
158
+ "</testsuite>",
159
+ "",
160
+ ].join("\n");
161
+ }
162
+ /** Read + parse a baseline file, or null if it doesn't exist yet. */
163
+ function readBaseline(path) {
164
+ if (!(0, node_fs_1.existsSync)(path))
165
+ return null;
166
+ return parseBaselineFile((0, node_fs_1.readFileSync)(path, "utf-8"));
167
+ }
168
+ /** Write reports as the committed baseline (pretty JSON, parent dirs created). */
169
+ function writeBaseline(path, reports) {
170
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
171
+ (0, node_fs_1.writeFileSync)(path, JSON.stringify(toBaselineFile(reports), null, 2) + "\n");
172
+ }
173
+ //# sourceMappingURL=eval-baseline.js.map
@@ -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