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/README.md +72 -327
- package/dist/agent-result.d.ts +40 -0
- package/dist/agent-result.js +97 -0
- package/dist/agent-runtime.d.ts +64 -0
- package/dist/agent-runtime.js +147 -0
- package/dist/claude-code.d.ts +10 -0
- package/dist/claude-code.js +26 -0
- package/dist/cli.js +106 -0
- package/dist/compile.d.ts +32 -3
- package/dist/compile.js +268 -0
- package/dist/egress-proxy.d.ts +2 -0
- package/dist/egress-proxy.js +60 -0
- package/dist/eval-baseline.d.ts +68 -0
- package/dist/eval-baseline.js +173 -0
- package/dist/eval-cache.d.ts +33 -0
- package/dist/eval-cache.js +94 -0
- package/dist/eval.d.ts +172 -9
- package/dist/eval.js +319 -58
- package/dist/harness-assert.d.ts +175 -13
- package/dist/harness-assert.js +358 -25
- package/dist/harness-test.d.ts +97 -11
- package/dist/harness-test.js +147 -37
- package/dist/judge.js +2 -0
- package/dist/linters.d.ts +6 -0
- package/dist/linters.js +1 -0
- package/dist/linting.d.ts +9 -0
- package/dist/linting.js +25 -0
- package/dist/mock-entry.d.ts +2 -0
- package/dist/mock-entry.js +36 -0
- package/dist/mock-model.d.ts +29 -0
- package/dist/mock-model.js +40 -0
- package/dist/plugin-loader.js +51 -17
- package/dist/run-hook.d.ts +81 -1
- package/dist/run-hook.js +189 -11
- package/dist/sandbox.d.ts +107 -0
- package/dist/sandbox.js +307 -0
- package/dist/spec.d.ts +130 -0
- package/dist/spec.js +55 -0
- package/dist/stats.d.ts +49 -0
- package/dist/stats.js +109 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +28 -0
- package/package.json +10 -4
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,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("&", "&")
|
|
135
|
+
.replaceAll("<", "<")
|
|
136
|
+
.replaceAll(">", ">")
|
|
137
|
+
.replaceAll('"', """);
|
|
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
|