mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
15c0607a5b
* feat!: eval lock (CI staleness gate) + per-adapter harness versionKey Real-model evals run on your subscription, so they only run locally — never in CI. The eval LOCK closes the resulting gap: a committed integrity stamp that lets CI verify the committed numbers still match the current inputs WITHOUT a model call (distinct from the gitignored, local-speed eval cache). - `vigiles eval --update` (local): records each NAMED eval's report to a committed `.vigiles/eval-locks/<name>.lock.json` + prints the per-number delta. - `vigiles eval --check` (CI): recompute the input hash, fail "stale" on a mismatch — binary-free, deterministic. Green no-op until the first lock is committed (smooth adoption). - The clean split: the lock stores only the model's observed behavior; the script's own assertions re-run live against the replayed report, so a threshold-only edit is a valid replay while an input change is stale. - Honest scope: the harness version is provenance, NOT a hash input (CI's pinned claude ≠ a dev's local claude would false-trip), and there is no nightly run — model/harness drift is a local `--update` concern. Also extracts the harness version reduction to a per-adapter port method (`HarnessRuntime.versionKey`): Claude Code keys major.minor (~quarterly), Codex opts out with "" (its minor is patch-cadence, ~weekly). This fixes the local cache churning weekly for Codex and is where the cadence decision belongs. Whole-package coherence: the GHA gains `command: eval-check`, `vigiles init` scaffolds the eval-check CI job, and this repo dogfoods it. Wired at both eval seams (runEvalWith + measureTriggerRateWith), 100%-covered, docs updated (internal research + public guides). BREAKING CHANGE: `HarnessRuntime` (exported on `vigiles/adapter`) gains a required `versionKey(raw: string): string` method. Third-party adapters must add it; return "" to opt out of version partitioning. First-party adapters (claude-code, codex) are updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WVmD8n7P62fuJ91aQh9yY9 * feat: eval-lock agent awareness (skill + nudge hook) + cohesive-delivery rule Close the loop on the eval lock so the AGENT knows it exists and maintains it — without the invasive option of editing the user's CLAUDE.md. - test-harness skill documents the lock lifecycle (--update/--check, commit the lock, re-run after editing a skill). - A PostToolUse nudge hook (hooks/eval-lock-nudge.sh → hook-runtime eval-lock-nudge): after editing a SKILL.md / *.eval.* with committed locks present, inject a NON-BLOCKING reminder to re-run `vigiles eval --update`. Self-gated (silent until a lock is committed), never blocks (the gate is CI's `eval --check`). Wired into the vigiles plugin; runHook + unit + .sh tests. - NO init edit to the user's CLAUDE.md — verified that's not how plugins deliver context (no plugin-level instructions file exists; the channel is a hook/skill). Docs + dedup: - research/agent-context-delivery.md captures the VERIFIED CC/Codex context-delivery mechanics ONCE (SessionStart additionalContext is the plugin channel; CLAUDE.md is the user's file; skill descriptions always-in-context; Codex AGENTS.md + hooks but inject deferred) so we stop re-researching it. - Per-adapter guides (harness-testing-claude-code/-codex.md) explain how the reminder reaches the agent per harness (CC injects; Codex inject is a documented follow-on), with the agnostic guide linking DOWN to them (no duplication). Cohesive-view rule: - New CLAUDE.md `cohesive-feature-delivery` rule — a definition-of-done CHECKLIST (CLI × GHA × init × agent-awareness × both adapters × per-adapter deduped docs × tests) so a feature's INTEGRATION across surfaces is checked as one thing, generalizing cohesive-cli-surface + harness-parity + great-agent-flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WVmD8n7P62fuJ91aQh9yY9 * test: CLI e2e for the eval-lock invalidation flow (update → check → stale) Closes the gap flagged in review: the joined CLI flow wasn't tested as one thing. A real *.eval.cjs fixture (model-free — drives runEvalWith from dist with a fake runner) run through the built CLI proves the whole loop: `eval --update` writes a committed lock, `eval --check` with the same inputs replays green (no model), and an input change makes `eval --check` go red ("stale"). Binary-free, no subscription — exactly how it runs in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WVmD8n7P62fuJ91aQh9yY9 * docs: scannable eval-lock docs + prose-clarity & easy-adoption rules Make the eval-lock docs readable at a glance and frame upkeep as agent-driven, not a manual chore. Docs (scannable rewrite — ASCII flow diagram, comparison tables, ✅/❌/⚠️ signposts, short sentences, no walls of text): - docs/harness-testing.md — the lock how-to now leads with "you mostly won't touch this by hand; the test-harness skill records it + a hook reminds you," then a diagram + tables. - docs/harness-testing-claude-code.md / -codex.md — the per-harness nudge delivery as tables; the Codex inject caveat is one clear ⚠️ row. - docs/eval-architecture.md — the "what's stale vs a free replay" split as ✅/❌ bullets. - skills/test-harness/SKILL.md — Step 5 now tells the agent to run `eval --update` + commit the lock as part of its job (so it IS done for you). - README — the Quick-start reassurance leads with "Adoption is smooth: one command, then your agent does the rest," names skills AND hooks, no chores. Two new CLAUDE.md rules: - prose-clarity — public prose must be clear + scannable (lead with the point, one idea per sentence, define/drop jargon, tables/bullets/ASCII/Mermaid over walls, emoji as signposts, one bold per point). Prefer ASCII/Mermaid; never commit generated raster images into docs. - lead-with-easy-adoption — docs (README first screen especially) must make it clear up front that adoption is low-friction because skills + hooks do the work; frame every feature's upkeep as agent/skill/hook-driven, not homework. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WVmD8n7P62fuJ91aQh9yY9 * docs: prose-clarity sweep across 13 user-facing guides Apply the new prose-clarity rule to the prose-heavy public guides: lead with the point, one idea per sentence, tables/bullets/ASCII over walls of text, ✅/❌/⚠️/ℹ️ signposts, one bold phrase per point, jargon glossed or dropped. Net −578 lines — shorter and far more scannable, with meaning unchanged. Swept: for-plugin-authors, what-vigiles-catches, measuring-skills, skills, harnesses, comparison, faq, sandboxing, safety, agent-setup, agent-workflows, verifying-instruction-files, compiled-hooks. The onboarding pair leads with the low-friction "one command, the agent does the rest" framing. Reference docs (cli, testing-api, spec-format, …) and the per-rule docs were deliberately left alone — they're already table-shaped, and churning them is high-risk / low-value. Verified: no heading text changed (anchors intact), no `research/` links added to public docs, the validation-rules matrix is complete (28 rule links → 28), safety claims retained, command refs resolve (self-command-refs), no orphan docs, fmt clean. Produced by parallel rewrite agents under strict preserve-anchors/links/ code constraints, then verified + spot-read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WVmD8n7P62fuJ91aQh9yY9 * feat!: encode hook inject support into HookProtocol.injectableEvents Codex inject was documented as "deferred" but the official Codex hooks docs confirm `additionalContext` is honored on SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / SubagentStart — the same shape as Claude Code. The gap existed because inject support was a prose deferral, not a tested contract: nothing in the adapter conformance kit asserted a shellHooks harness could actually deliver an inject hook. Encode it as a required port field so the gap can't recur silently: - HookProtocol gains `injectableEvents: readonly string[]` — the events that honor `additionalContext` injection, per-adapter. - claudeCodeHookProtocol: [SessionStart, UserPromptSubmit, PostToolUse]; codexHookProtocol: + [PreToolUse, SubagentStart]. - Conformance rejects a shellHooks adapter with an empty list; the adapter-contract suite asserts every shellHooks adapter declares the events vigiles' shipped hooks use (PostToolUse, SessionStart) — the test that would have caught the original miss. - `compile` warns on an inject hook only when its event isn't injectable on the target harness (react output stays CC-confirmed-only). - Correct every "Codex inject deferred" reference across docs, research, and CLAUDE.md; document injectableEvents in the adapter API + authoring guides. BREAKING CHANGE: HookProtocol (exported on vigiles/adapter) gains a required `injectableEvents` field — a third-party adapter must declare it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: refresh HANDOFF for the eval-lock branch + inject-port encoding Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: fan out hook install + the CI workflow to every declared harness A repo can enable BOTH harnesses (`harness: ["claude-code","codex"]`), and instruction files already mirror to both — but `compile` installed a hook into only ONE harness (`installHooks` ignored `config.harness` and auto-detected a single adapter), and the scaffolded CI workflow hardcoded the Claude Code binary. So an inject/gate hook that works on both could land in `.claude/settings.json` while silently missing from `.codex/config.toml` — the "works on CC, absent on Codex" failure the harness-parity rule exists to prevent. - `resolveHarnessAdapters` (adapter-registry.ts) returns the FULL declared set: `--harness=` flag → that one; else config `harness` list → all of them; else auto-detect → the detected one (de-duped by name). - `installHooks` loops over it, merging the SAME compiled hook into every enabled harness's native config (JSON / TOML), with per-harness warnings. - The `init` CI workflow is harness-aware: the deterministic harness job installs `@anthropic-ai/claude-code` and/or `@openai/codex` per declared harness (`harnessTestBinaries`). Skills compile byte-identical across harnesses and subagents are CC-only by design, so hooks + the CI binary were the real fan-out gaps. Tests: resolveHarnessAdapters units (fan-out / flag-singular / detect / alias-dedup), a both-harness hook-install e2e (the same hook in both configs), and harness-aware workflow e2es (codex-only, both). Documented as §4 in research/multi-harness-compile.md + the compiled-hooks guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: wire vigiles's nudge hooks into .codex/config.toml during init Closes the last Codex-parity gap in agent-awareness: Claude Code gets the eval-lock + refs nudge hooks from the global marketplace plugin, but Codex has no global plugin store, so it got skills only — the proactive nudges never reached a Codex user (the "works on CC, silently absent on Codex" failure). Codex config is repo-committed (the idiomatic place), so `init` now wires the two PostToolUse nudges into `.codex/config.toml`: - `codexPluginHooks` (pure) declares them as DIRECT `npx vigiles hook-runtime …` commands — no plugin root, no vendored bash script. The runtime entrypoints read the event JSON on stdin and emit the `additionalContext` shape Codex honors on PostToolUse (confirmed + encoded in HookProtocol.injectableEvents). - `applyCodexPluginHooks` (pure, unit-tested) merges them idempotently: re-runs replace in place, and the user's own Codex hooks + every other config key are preserved. `wireCodexHooks` in cli.ts does only the IO. - Safety: only an INTENTIONAL exit 2 blocks an edit (the refs nudge under `unmarked-refs: error`); an npx-resolution failure exits non-2, so a missing dep never blocks. Loud, documented deferral (no-silent-skips): the SessionStart lint summary (plain stdout, unconfirmed on Codex) and the compile-on-edit / pre-edit guards (filename-gated bash, no harness-neutral entrypoint yet) stay manual on Codex. Also: the remaining pre-release Codex item — running the eval tier against the real `codex` binary once — is added to the launch-readiness P0 list in the roadmap. Tests: codexPluginHooks shape + applyCodexPluginHooks (fresh / idempotent / preserves user config); the planPluginInstall codex notes/vendors updated. Verified end-to-end that `init --harness=codex` writes the config and a pre-existing `.codex/config.toml` (model + a user hook) is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: update codex install e2e for the new vendors:true (repo hooks) The codex install plan now writes nudge hooks into .codex/config.toml, so `vendors` flipped to true. Updated the unit assertion last commit but missed this e2e (run by both the `e2e` and `test` CI jobs). The skills-are-global checks below are unchanged and still stand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: cover the non-Actions lock message branch + address Codex review The `test` job's coverage gate failed: `emitLockMessage`'s console.warn/ console.log branches (the non-GITHUB_ACTIONS path) are unreachable in CI, where GITHUB_ACTIONS is globally set. Added a wiring test that DELETES the var to exercise both branches regardless of environment. Also addresses two Codex review findings on the eval-lock work: - P1 (soundness): `evalArmsInputs` omitted `spec.stubs` from the hash, though tool stubs are written onto PATH and are model-facing input — so a changed canned CLI output after `--update` left the same `inputsHash` and `--check` replayed stale results. Now folds a canonical (name-sorted) view of the stubs into the hash; a new wiring test proves a stub change → stale. - P2 (collision): two eval names normalizing to the same slug (`foo/bar` vs `foo bar` → `foo-bar.lock.json`) could let `--check` replay the WRONG eval's report. `readLock` now rejects a lock whose stored `name` differs from the requested one, degrading a collision to a safe miss (→ stale/re-run), never wrong data. Unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: two more eval-lock soundness gaps from Codex review - An unnamed eval in `--check` mode called `produce()` (ran the model), breaking the no-model-in-CI contract: in CI there's no subscription, so it would hit the model or fail for missing auth. Now `check` mode throws a clear, actionable error for an unnamed eval (never reaching the model); `off`/`update` keep producing (the eval just isn't gated locally). - `spec.ephemeralEnv` toggles the trial's run environment (scrubbed env + throwaway HOME vs the inherited process env) — a model-facing input that was omitted from the lock hash, so a report recorded under one mode could be replayed under the other with the same inputsHash. Now folded into the hashed inputs. Both proven by new wiring tests (unnamed-check throws without calling the runner; an ephemeralEnv toggle → stale). Coverage holds at 100% lines/ statements/functions (verified with GITHUB_ACTIONS=true). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: fold the harness identity into the trigger-rate lock hash Codex review (P2): a trigger-rate eval recorded with one harness driver and later switched via `measureTriggerRate(spec, { evalDriver: codexEvalDriver })` hashed identically (prompts/model/plugin unchanged), so `--check` could replay a Claude-recorded report for a Codex run — defeating the gate for the Codex eval path this PR adds. `EvalDriver` gains an optional `harness` identity (defaults to "claude-code", so existing single-harness locks are unaffected); `claudeEvalDriver` and `codexEvalDriver` declare theirs, and `measureTriggerRate`/`...With` thread it into the hashed trigger inputs. A new wiring test proves a harness switch → stale. API surface regenerated (additive `harness?` on EvalDriver). This closes the input-hash bug class on the trigger-rate seam (the runEval seam's stubs + ephemeralEnv were fixed in the prior two commits). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: refresh HANDOFF — PR #53 merge loop + the 5 review fixes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: three more Codex-review findings (matchers + plugin-root path hash) - Codex nudge hooks used Claude tool names (`Edit|Write`) in their matcher, but Codex's edit tool is `apply_patch` — so the eval-lock + refs nudges wired into `.codex/config.toml` would never fire. Matcher → `^apply_patch$`. - The compiled-hook FAN-OUT to Codex regex-wraps the raw matcher without translating tool names, so a CC-authored `tools("Edit","Write")` gate emits `^(Edit|Write)$` into Codex config and silently never fires though compile reports success. vigiles has no cross-dialect tool-name map yet, so install now WARNS LOUDLY when a tool-matched hook is fanned to a non-CC harness (no silent non-firing success); a real mapping is the follow-up. - For eval arms using `plugin`, `resolveHarness` expands `${PLUGIN_ROOT}` to the checkout's ABSOLUTE path before the settings are hashed, so a lock recorded at `/home/dev/...` was falsely STALE when `--check` recomputed it at `/home/runner/...` in CI. `stripPluginRoot` normalizes the prefix back to a token → location-independent hash. New wiring test: the same plugin at two different abs paths replays (not stale). Coverage holds at 100% lines/statements/functions (verified GITHUB_ACTIONS=true). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: guard stripPluginRoot against a hookless plugin's undefined settings A plugin that ships only instructions/skills (no hooks) resolves to settings:undefined; JSON.stringify(undefined) is undefined, so the prior .split() threw before the eval could run. Guard the non-string case (pass it through). Regression I introduced in the plugin-root normalization last commit; new test covers a hookless-plugin arm (update + check, no throw). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: add a lock-hash COMPLETENESS guard for both seams The Codex review kept finding the same class of bug: a model-facing input omitted from the lock hash, so `--check` replays stale results (stubs, ephemeralEnv, driver-harness, plugin-root all slipped through). Our tests proved the MECHANISM (record→replay, task-change→stale) but never asserted the input set was COMPLETE. This adds a structural guard: for each input that steers the run, mutate ONLY it and assert the lock inputsHash CHANGES — one parametrized test per seam (runEval's evalArmsInputs + trigger-rate's triggerInputs). A new input added to EvalSpec/TriggerRateSpec without hashing it now fails a row HERE in CI, instead of a reviewer finding it later. Covers task/model/tools/fixture/ arm.files/arm.settings/arm.model/interceptTools/stubs/ephemeralEnv and prompts/irrelevantPrompts/model/harness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import { test } from "vitest";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
evalInputsHash,
|
|
buildLock,
|
|
readLock,
|
|
writeLock,
|
|
lockPath,
|
|
lockSlug,
|
|
decideLock,
|
|
diffReportNumbers,
|
|
formatLockUpdate,
|
|
lockModeFromEnv,
|
|
evalApiVersionFromEnv,
|
|
anyLocksCommitted,
|
|
isEvalInputFile,
|
|
evalLockNudge,
|
|
LOCK_VERSION,
|
|
type EvalLock,
|
|
} from "./eval-lock.js";
|
|
|
|
function tmp(): string {
|
|
return mkdtempSync(join(tmpdir(), "vig-lock-"));
|
|
}
|
|
|
|
const BASE = {
|
|
model: "claude-sonnet-4-6",
|
|
evalApiVersion: 1,
|
|
inputs: { task: "do X", tools: ["Read", "Bash"], pluginDirHash: "abc" },
|
|
};
|
|
|
|
test("evalInputsHash is stable + order-independent", () => {
|
|
const a = evalInputsHash(BASE);
|
|
const b = evalInputsHash({
|
|
...BASE,
|
|
// reorder the inputs object keys — canonical sorts them
|
|
inputs: { pluginDirHash: "abc", tools: ["Read", "Bash"], task: "do X" },
|
|
});
|
|
assert.equal(a, b);
|
|
});
|
|
|
|
test("evalInputsHash changes when ANY model-affecting field changes", () => {
|
|
const base = evalInputsHash(BASE);
|
|
assert.notEqual(base, evalInputsHash({ ...BASE, model: "claude-opus-4-8" }));
|
|
assert.notEqual(base, evalInputsHash({ ...BASE, evalApiVersion: 2 }));
|
|
assert.notEqual(
|
|
base,
|
|
evalInputsHash({ ...BASE, inputs: { ...BASE.inputs, task: "do Y" } }),
|
|
);
|
|
assert.notEqual(
|
|
base,
|
|
evalInputsHash({
|
|
...BASE,
|
|
inputs: { ...BASE.inputs, pluginDirHash: "def" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("the harness version is NOT in the hash (CI claude is pinned ≠ a dev's local claude)", () => {
|
|
// harnessVersionKey is provenance on the lock, never a hash input — so `--check`
|
|
// stays binary-free and never false-trips on a pinned-CI-vs-local version gap.
|
|
// EvalLockInputs has no such field; passing one is simply ignored by the type.
|
|
const a = evalInputsHash(BASE);
|
|
const b = evalInputsHash({ ...BASE });
|
|
assert.equal(a, b);
|
|
});
|
|
|
|
test("lockSlug is filesystem-safe + never empty", () => {
|
|
assert.equal(lockSlug("Trigger Rate: my-skill"), "trigger-rate-my-skill");
|
|
assert.equal(lockSlug("!!!"), "eval");
|
|
assert.equal(lockSlug("AB/cd"), "ab-cd");
|
|
});
|
|
|
|
test("buildLock + writeLock + readLock round-trip", () => {
|
|
const dir = tmp();
|
|
try {
|
|
const lock = buildLock({
|
|
name: "my eval",
|
|
inputsHash: evalInputsHash(BASE),
|
|
model: BASE.model,
|
|
harnessVersionKey: "2.1", // provenance on the lock, not a hash input
|
|
evalApiVersion: BASE.evalApiVersion,
|
|
builtAt: "2026-06-30T00:00:00.000Z",
|
|
report: { rate: 0.9, n: 20 },
|
|
});
|
|
writeLock(dir, lock);
|
|
const read = readLock(dir, "my eval");
|
|
assert.deepEqual(read, lock);
|
|
assert.equal(read?.version, LOCK_VERSION);
|
|
// pretty-printed for a reviewable diff
|
|
assert.ok(readFileSync(lockPath(dir, "my eval"), "utf-8").includes("\n "));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("readLock returns null on a miss", () => {
|
|
const dir = tmp();
|
|
try {
|
|
assert.equal(readLock(dir, "absent"), null);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("readLock returns null on a slug COLLISION (a different eval's lock, not this one's)", () => {
|
|
// `foo bar` and `foo/bar` both normalize to `foo-bar.lock.json`. A lock whose
|
|
// stored name differs from the requested name must NOT be read as this eval's
|
|
// lock — else `--check` could replay the wrong eval's report. It degrades to a
|
|
// miss (→ "stale", re-run), never to wrong data.
|
|
const dir = tmp();
|
|
try {
|
|
assert.equal(lockSlug("foo bar"), lockSlug("foo/bar")); // precondition
|
|
writeLock(
|
|
dir,
|
|
buildLock({
|
|
name: "foo bar",
|
|
inputsHash: "h",
|
|
model: "m",
|
|
harnessVersionKey: "",
|
|
evalApiVersion: 1,
|
|
builtAt: "2026-01-01T00:00:00Z",
|
|
report: { ok: true },
|
|
}),
|
|
);
|
|
assert.ok(readLock(dir, "foo bar"), "the matching name reads its own lock");
|
|
assert.equal(
|
|
readLock(dir, "foo/bar"),
|
|
null,
|
|
"the colliding-but-different name reads as a miss, not the other lock",
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("readLock THROWS on corrupt JSON (a broken lock is not a silent 'no lock')", () => {
|
|
const dir = tmp();
|
|
try {
|
|
writeFileSync(lockPath(dir, "bad"), "{not json");
|
|
assert.throws(() => readLock(dir, "bad"), /corrupt lock/);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("readLock THROWS on an unsupported version (no silent stale-shape replay)", () => {
|
|
const dir = tmp();
|
|
try {
|
|
writeFileSync(
|
|
lockPath(dir, "old"),
|
|
JSON.stringify({ version: 999, name: "old", report: {} }),
|
|
);
|
|
assert.throws(() => readLock(dir, "old"), /unsupported version/);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("readLock THROWS when the lock JSON isn't an object", () => {
|
|
const dir = tmp();
|
|
try {
|
|
writeFileSync(lockPath(dir, "scalar"), "42"); // valid JSON, not an object
|
|
assert.throws(() => readLock(dir, "scalar"), /not a JSON object/);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("anyLocksCommitted: false for an absent/empty dir, true once a lock exists", () => {
|
|
const dir = tmp();
|
|
try {
|
|
assert.equal(anyLocksCommitted(join(dir, "absent")), false); // !existsSync
|
|
assert.equal(anyLocksCommitted(dir), false); // exists, empty
|
|
writeFileSync(join(dir, "note.txt"), "x"); // a non-lock file doesn't count
|
|
assert.equal(anyLocksCommitted(dir), false);
|
|
writeLock(
|
|
dir,
|
|
buildLock({
|
|
name: "e",
|
|
inputsHash: "h",
|
|
model: "m",
|
|
harnessVersionKey: "",
|
|
evalApiVersion: 1,
|
|
builtAt: "2026-06-30T00:00:00.000Z",
|
|
report: {},
|
|
}),
|
|
);
|
|
assert.equal(anyLocksCommitted(dir), true);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("isEvalInputFile: SKILL.md + *.eval.* only", () => {
|
|
assert.equal(isEvalInputFile("skills/foo/SKILL.md"), true);
|
|
assert.equal(isEvalInputFile("SKILL.md"), true);
|
|
assert.equal(isEvalInputFile("examples/x.eval.mjs"), true);
|
|
assert.equal(isEvalInputFile("a/b.eval.ts"), true);
|
|
assert.equal(isEvalInputFile("README.md"), false);
|
|
assert.equal(isEvalInputFile("src/eval.ts"), false); // not an .eval.* file
|
|
assert.equal(isEvalInputFile("notskill.md"), false);
|
|
});
|
|
|
|
test("evalLockNudge: self-gated on a committed lock + an eval-input edit", () => {
|
|
const dir = tmp();
|
|
try {
|
|
const locks = join(dir, "locks");
|
|
// No lock yet → silent even on a SKILL.md edit.
|
|
assert.equal(evalLockNudge("skills/foo/SKILL.md", locks), null);
|
|
// Commit a lock into the gated dir.
|
|
writeLock(
|
|
locks,
|
|
buildLock({
|
|
name: "e",
|
|
inputsHash: "h",
|
|
model: "m",
|
|
harnessVersionKey: "",
|
|
evalApiVersion: 1,
|
|
builtAt: "2026-06-30T00:00:00.000Z",
|
|
report: {},
|
|
}),
|
|
);
|
|
// A non-eval file never nudges, even with a lock present.
|
|
assert.equal(evalLockNudge("README.md", locks), null);
|
|
// A SKILL.md edit WITH a committed lock → a non-blocking reminder.
|
|
const msg = evalLockNudge("skills/foo/SKILL.md", locks);
|
|
assert.ok(msg && /eval --update/.test(msg));
|
|
assert.ok(/reminder, not a block/.test(msg ?? ""));
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("decideLock: off/update always run", () => {
|
|
assert.deepEqual(decideLock("off", "e", "h", null), { kind: "run" });
|
|
assert.deepEqual(decideLock("update", "e", "h", null), { kind: "run" });
|
|
});
|
|
|
|
test("decideLock check: missing lock → stale", () => {
|
|
const d = decideLock("check", "my-eval", "h1", null);
|
|
assert.equal(d.kind, "stale");
|
|
assert.match((d as { reason: string }).reason, /missing/);
|
|
});
|
|
|
|
test("decideLock check: hash mismatch → stale", () => {
|
|
const lock = {
|
|
inputsHash: "OLD",
|
|
report: { rate: 1 },
|
|
} as unknown as EvalLock;
|
|
const d = decideLock("check", "my-eval", "NEW", lock);
|
|
assert.equal(d.kind, "stale");
|
|
assert.match((d as { reason: string }).reason, /STALE|changed/);
|
|
});
|
|
|
|
test("decideLock check: hash match → replay the recorded report (no model)", () => {
|
|
const report = { rate: 0.85, n: 10 };
|
|
const lock = { inputsHash: "H", report } as unknown as EvalLock;
|
|
const d = decideLock("check", "my-eval", "H", lock);
|
|
assert.equal(d.kind, "replay");
|
|
assert.deepEqual((d as { report: unknown }).report, report);
|
|
});
|
|
|
|
test("diffReportNumbers walks numeric leaves by path (works across report shapes)", () => {
|
|
const before = {
|
|
rate: 0.9,
|
|
n: 20,
|
|
perPrompt: [{ rate: 1 }, { rate: 0.5 }],
|
|
precision: 1,
|
|
};
|
|
const after = {
|
|
rate: 0.65,
|
|
n: 20,
|
|
perPrompt: [{ rate: 1 }, { rate: 0.25 }],
|
|
precision: 1,
|
|
};
|
|
const deltas = diffReportNumbers(before, after);
|
|
// rate + perPrompt[1].rate moved; n + precision + perPrompt[0].rate did not.
|
|
assert.deepEqual(
|
|
deltas.sort((a, b) => a.path.localeCompare(b.path)),
|
|
[
|
|
{ path: "perPrompt[1].rate", before: 0.5, after: 0.25 },
|
|
{ path: "rate", before: 0.9, after: 0.65 },
|
|
],
|
|
);
|
|
});
|
|
|
|
test("diffReportNumbers: identical reports yield no deltas", () => {
|
|
const r = { rate: 0.9, arms: { run: { metrics: { ok: 1 } } } };
|
|
assert.deepEqual(diffReportNumbers(r, structuredClone(r)), []);
|
|
});
|
|
|
|
test("formatLockUpdate: new vs unchanged vs moved", () => {
|
|
assert.match(formatLockUpdate("e", [], true), /recorded NEW lock/);
|
|
assert.match(formatLockUpdate("e", [], false), /no numeric change/);
|
|
const moved = formatLockUpdate(
|
|
"e",
|
|
[{ path: "rate", before: 0.9, after: 0.65 }],
|
|
false,
|
|
);
|
|
assert.match(moved, /▼ rate: 0\.900 → 0\.650/);
|
|
assert.match(moved, /quality gate/);
|
|
});
|
|
|
|
test("lockModeFromEnv reads VIGILES_EVAL_LOCK; anything else → off", () => {
|
|
assert.equal(lockModeFromEnv({ VIGILES_EVAL_LOCK: "check" }), "check");
|
|
assert.equal(lockModeFromEnv({ VIGILES_EVAL_LOCK: "update" }), "update");
|
|
assert.equal(lockModeFromEnv({ VIGILES_EVAL_LOCK: "garbage" }), "off");
|
|
assert.equal(lockModeFromEnv({}), "off");
|
|
});
|
|
|
|
test("evalApiVersionFromEnv defaults to 1; tolerates a bad value", () => {
|
|
assert.equal(evalApiVersionFromEnv({}), 1);
|
|
assert.equal(evalApiVersionFromEnv({ VIGILES_EVAL_API_VERSION: "3" }), 3);
|
|
assert.equal(evalApiVersionFromEnv({ VIGILES_EVAL_API_VERSION: "nope" }), 1);
|
|
assert.equal(evalApiVersionFromEnv({ VIGILES_EVAL_API_VERSION: "-2" }), 1);
|
|
});
|