feat: unified Trace + predicates, model-request capture, safe-by-default sandbox (#27)

* feat: unified Trace + predicate vocabulary

Both testing tiers now produce one Trace (toolCalls + output + turns + file):
a runHarnessTest result is a Trace, and runEval's measure ctx (RunContext
extends Trace) is too. Over it sits one set of bare predicates — usedTool,
toolCount, skillResolved, toolUsedWith — exported from harness-assert.ts; each
assertTool* is now a thin throw-wrapper over a predicate, and an eval measure
reuses the same bare predicates as metrics. Testing asserts, eval measures —
same vocabulary, separate consumers, never one dual-purpose function.

- harness-test.ts: Trace interface; parseResultEvent/parseOutput capture the
  final answer from the stream both tiers already produce; result.output added.
- eval.ts: ctx gains toolCalls + output (eval switched to stream-json so the
  per-turn tool_use events survive); pass^k (MetricStat.passK = succeeded every
  trial) alongside mean ± se in the report and formatEvalReport.
- harness-assert.ts: bare predicates + assertToolUsedWith (tool-argument
  assertion over toolCalls[].input); assert* re-typed to accept any Trace.

Deferred: trace.hooks (which hook fired + decision) — needs mock instrumentation
to record invocations rather than infer them from marker files.

* feat: output + hook + reliability verbs over Trace, native coverage

Completes the Trace vocabulary the unification opened and records hook firing.

- trace.hooks: parseHooks records which hook fired + its decision from the CLI's
  hook_response stream events (hook_name/hook_event/exit_code/outcome) — honest,
  not inferred from marker files. HookFire on Trace, populated by both tiers.
- predicates/asserts: hookFired/hookBlocked/assertHookFired (recorded hook
  firing), outputContains/assertOutputContains (the agent's final answer),
  reliable/assertReliable (pass^k gate — succeeded every trial).
- The two Edit/Write hook regression tests now also assert via trace.hooks
  (transcript:true), so firing is checked from the stream, not just markers.
- coverage: npm run coverage uses node 22 native V8 coverage (no dep); ~86%
  lines with claude present. npm test switched to glob discovery
  (node --test "dist/**/*.test.js") so a new *.test.js can't be left out.

Docs + research matrix updated: trace.hooks and coverage tooling marked shipped.

* test: migrate the suite to Vitest (primary runner)

node:test was the zero-dep dogfood, but its DX (noisy TAP, weak watch/filter, no
coverage thresholds or inline ignores) doesn't pay off. Vitest runs the TS
sources directly (no pre-build for the unit bits), gives a readable reporter,
watch/filter, and V8 coverage with thresholds.

- vitest.config.mjs: two projects — `unit` (src/**/*.test.ts, with .js→.ts
  extensionAlias so NodeNext imports resolve; 60s timeout to match node:test's
  no-timeout default for catalog/CLI-spawning suites) and `runners` (the existing
  cross-runner matcher constraint, loaded from dist as a user would).
- Mechanical per-file change: import { test|describe|it } from "node:test" →
  "vitest"; before/after → beforeAll/afterAll via import alias (call sites
  unchanged). node:assert/strict kept as-is (works under vitest).
- The runner-agnostic claim is still proven: harness-assert keeps its
  node:test/jest matchers, and test/runners/ exercises them under vitest + jest.
- scripts: test → vitest run; test:vitest → the runners project; coverage →
  vitest run --coverage (V8). Coverage is scoped/threshold-gated to the
  harness-testing pillar (driven to 100% next).

* test: 100% coverage gate on the harness-testing pillar

`npm run coverage` now enforces 100% lines/functions/statements (branches ≥ 90)
on the seven harness-testing modules, via Vitest V8 coverage.

- eval.ts: split the orchestration into a testable `runEvalWith(spec, runner)`
  seam (injectable agent runner); `runEval` is the thin real-spawn wrapper
  (v8-ignored). Lets the loop / measure-context / aggregation be tested with
  canned stream-json — no model.
- mock-model.test.ts (new): drives the scripted server directly (SSE + JSON,
  tool + text, count_tokens / HEAD / health, onTurn, repeat / empty-script).
- plugin-loader: a malformed plugin.json no longer crashes the loader
  (safeReadJson); covered by new malformed-manifest + manifest-mcpServers tests.
- Added tests for the bare predicates' output/hook verbs, the run-hook-tier
  assertions + matcher message closures, parse* defensive branches, judge's
  firstJsonObject catch, and parseHookOutput's catch.
- Subprocess wrappers that can't be unit-tested (real claude spawn in
  runHarnessTest/spawnClaude/judge) are v8-ignored with justification; they stay
  covered by the claude-backed suite.

* feat: capture model requests into the Trace (trace.modelRequests)

The scripted mock now records every /v1/messages request it receives —
system prompt + messages, flattened to text — exposed as handle.requests
and surfaced on the unified Trace as modelRequests. This closes the gap
where a SessionStart hook's injected additionalContext (or a slash
command's expansion) could be proven to *fire* but not to *reach the
model*.

- mock-model.ts: extractRequest (pure, exported) + per-request capture,
  MockHandle.requests
- harness-test.ts: Trace.modelRequests, populated by runHarnessTest
  (harness tier only — with or without transcript)
- eval.ts: modelRequests is [] (the eval tier drives the real API, no
  mock between claude and the model to capture requests)
- harness-assert.ts: requestContains predicate + assertRequestContains
- tests for extractRequest shapes, request capture, and the predicate;
  100% coverage gate still green

* feat: safe-by-default sandbox for executing untrusted plugin hooks

runHarnessTest runs the real claude CLI, which runs the real hooks of
whatever plugin you load — for an external plugin/pluginDir that means
executing third-party code with your privileges. Make that safe by
default.

Policy (src/sandbox.ts, pure + exhaustively unit-tested):
- specTrusted: inline settings/files = trusted; any external
  plugin/pluginDir = untrusted (provenance-based).
- decideSandbox: "auto" (default) runs trusted code directly and confines
  untrusted code; if no sandbox is available it REFUSES (throws) rather
  than running unconfined. sandbox:false is the explicit dangerous
  opt-out; "strict" forces confinement even for trusted code.

Confinement (runSandboxed, bubblewrap): the mock and claude are
co-launched inside ONE network namespace (--unshare-all). Loopback is
auto-up so the in-sandbox mock is reachable, but there is no external
route, so a malicious hook can't phone home. Filesystem is --ro-bind
read-only except the throwaway work dir, a fresh empty $HOME, and an IO
dir; captured requests stream out through the IO dir into
trace.modelRequests. mock-entry.ts is the in-sandbox mock runner.

The end-to-end test proves a sandboxed run blocks egress while the mock
stays reachable (gated on bwrap+claude, skips otherwise). Existing
pluginDir/plugin tests are marked sandbox:false (we trust the pinned
vendored fixtures), keeping them on the direct path.

Also: enforce the 100% coverage gate in CI (was never run — npm test !=
coverage) with bubblewrap + claude installed so the sandbox + claude-
gated tests actually execute; stop tracking coverage/ build artifacts.

* feat: harden sandbox (clearenv + Linux-only), dogfood it, document testing

Hardening (src/sandbox.ts):
- --clearenv: drop ALL inherited env so untrusted code can't even read host
  secrets (ANTHROPIC_API_KEY, cloud creds); only PATH/HOME/TMPDIR are set back.
  Verified the confined claude still resolves via the restored PATH.
- Linux-only: sandboxAvailable() short-circuits on non-Linux (bubblewrap is a
  Linux tool); decideSandbox messages and the spec JSDoc say so, so macOS/Windows
  get a clear "needs Linux + bwrap, or pass sandbox:false" instead of a confusing
  install hint.

Dogfood (src/sandbox.test.ts), both gated on bwrap+claude:
- injection test: a SessionStart hook (Claude Code's nested additionalContext
  form) runs confined and its context is found in trace.modelRequests — proving
  the capture chain end-to-end ("fired AND landed").
- superpowers dogfood: the REAL pinned obra/superpowers SessionStart hook runs
  CONFINED (untrusted → sandbox, no egress) and its genuine output is captured.
  It emits a TOP-LEVEL additionalContext, which Claude Code (nested form) never
  injects — so modelRequests shows the context did NOT reach the model. That
  "fired ≠ landed" gap is exactly what modelRequests surfaces, on real code.

Docs:
- README "Test your Claude Code harness": new subsections for trace.modelRequests
  ("did the injected context reach the model?") and the safe-by-default,
  Linux-only sandbox, plus two coverage-matrix rows.
- real-superpowers.harness.mjs: drop the stale "future bwrap/docker boundary"
  note — the boundary exists now; point at the confined dogfood.

* feat: close out testing pillar — measureTriggerRate + dangling-ref warning

Two remaining gaps from the roadmap, both small, both close real holes.

1. measureTriggerRate (src/eval.ts) — does a skill's description actually FIRE
   across varied prompts? Wiring (does the Skill tool resolve) is the
   deterministic tier's job; real activation is a property only the model can
   answer, and the #1 documented skill-authoring pain. Install a plugin natively
   (pluginDir), give varied prompts + a `fired` predicate over the run's Trace
   (reuse the bare predicates, e.g. skillResolved), get an overall + per-prompt
   rate. measureTriggerRateWith takes an injected runner so the loop is
   unit-tested with no model; assertTriggerRate gates on a minimum; canonical
   example examples/harness/skill-trigger-rate.eval.mjs.

2. loadPlugin dangling-intra-plugin-ref warning (src/plugin-loader.ts) — scans
   the plugin's own text files (hook scripts included — those aren't materialized
   into files) for root-relative refs to skills/hooks/commands/agents files that
   don't exist on disk. Catches the partial-vendor / broken-path class the
   dogfood hit twice: verified it flags real obra/superpowers'
   skills/using-superpowers/SKILL.md (omitted from the vendored slice).

100% coverage gate still green (603 tests). Spec Key Files updated + CLAUDE.md
recompiled.

* docs: research — eval-API landscape, summarized and scored against ours

Durable record of the eval-ecosystem comparison so it doesn't live only in chat.
Two halves, as requested: (1) the field summarized — per-tool capability profiles
for promptfoo, DeepEval, Braintrust, Inspect, LangSmith, OpenAI Evals, plus the
RAG/observability/academic tools we're orthogonal to; (2) scored against our eval
API on the dimensions that decide a *harness-eval* API — strengths (harness A/B
arms, pass^k, se/std, unified Trace predicates, runner-agnostic/zero-dep), gaps
(cost/concurrency/caching, significance testing, persisted reports + regression
gating, thin judge / no dataset primitive).

Records the strategic options A–D and the decision: pursue B→A→C, defer D, first
reviewable unit B1 (cost capture) + B2 (record/replay cache).

Wired into the link graph (Key Files + research/harness-testing.md See-also) so
the no-orphan-docs rule holds; CLAUDE.md recompiled.

* feat: eval Phase B1+B2 — cost/latency capture + record/replay cache

The first reviewable unit of the B→A→C roadmap to make the eval API world-class
(research/eval-api-landscape.md). Both pieces are pure + behind the injectable
AgentRunner seam, so the 100% statements/lines/functions gate holds.

B1 — cost/latency/token capture. The stream-json result event already carries
total_cost_usd / usage / duration_ms; we were dropping it. parseUsage extracts it,
RunContext.usage exposes it to measure (so `{ cost: ctx.usage.costUsd }` is a
metric), ArmReport.usage + EvalReport.totalCostUsd aggregate it, and
formatEvalReport prints `$… · …s/run · …k tok` when present (silent under the
mock, so existing output is unchanged).

B2 — record/replay cache (src/eval-cache.ts). cacheKey hashes everything that
determines the model's output — task, resolved fixture files + settings, model,
tools, trialIndex — but DELIBERATELY NOT measure, so editing your metric and
re-running re-scores the recorded runs for free; the model is re-called only when
a model-affecting input changes (or cache:"off"). Crucially it snapshots and
restores the post-run filesystem, so measure's ctx.file()/ctx.sh() stay sound on
replay — a stdout-only cache would silently mis-score. Opt in via cache /
cacheDir on EvalSpec; wired into runEvalWith through runWithCache + executeTrial.

Tests: eval-cache key stability/sensitivity, record round-trip + malformed
tolerance, fs snapshot/restore; parseUsage, aggregateUsage, and an end-to-end
record→replay through runEvalWith proving the model isn't re-called on a hit and
restored files re-score correctly. 614 tests, gate green. Cache dir gitignored.

* feat: eval Phase B3+B4 — bounded concurrency, rate-limit backoff, budget cap

Completes Phase B of the eval-API roadmap (research/eval-api-landscape.md).

B3 — concurrency + rate-limit backoff. runEvalWith now flattens arms × trials
into a flat unit list run through a generic bounded-concurrency pool (runPool,
order-preserving); `concurrency` defaults to 1 (the safe, no-surprise default —
raise it to cut wall-clock time). Each model call is wrapped in runWithRetry:
while isRateLimited matches the captured streams (rate limit / 429 / overloaded),
it backs off exponentially and retries, up to rateLimitRetries (default 3) with
retryBackoffMs base (default 1000). spawnAgent now captures stderr so the signal
is visible; RunOut gained an optional stderr.

B4 — budget cap. maxCostUsd stops launching new trials once measured cost crosses
the cap (in-flight trials finish, the rest are skipped); EvalReport.aborted flags
it. Uses the per-run cost from B1.

All pure/orchestration behind the injectable runner seam (runPool, isRateLimited
exported and unit-tested; retry/concurrency/budget driven through runEvalWith with
fake runners — pool concurrency bound, retry-then-succeed, give-up at retries 0,
and early-abort all asserted). 620 tests, gate green (100% stmts/lines/funcs).

* test: model-free conformance suite over real vendored plugins

Grounds the loader against reality instead of synthetic fixtures. A table-driven
suite runs loadPlugin over each pinned vendored plugin (obra/superpowers,
wshobson/accessibility) and asserts invariants that must hold for any well-formed
plugin: a real surface loads (never a silent empty machine), ${CLAUDE_PLUGIN_ROOT}
fully resolves, ≥1 skill materializes, and the surface + dangling-ref warnings are
accurate (the known superpowers partial-vendor ref is flagged, nothing spurious).

This is the committed form of the check that caught the dangling-ref bug. It's
model-free, offline, and pinned by SHA — deterministic, in the coverage gate, no
claude CLI or API key (tier 1 only; the real-model tier stays opt-in in bench/).

Assertions are invariants, not version trivia (">= 1 skill", not "exactly N"), so
a harmless re-pin won't break them while a real loader/detector regression will.
622 tests, gate green (100% stmts/lines/funcs).

* fix: recompile CLAUDE.md so its integrity hash matches (audit clean)

The previous commit formatted CLAUDE.md after compiling it, and a bare `*` in the
new vendor.test.ts Key Files description got prettier-escaped to `\*` — desyncing
the embedded hash (audit exit 2). Dropped the glob char from the prose and
recompiled last so the hash covers the final, prettier-clean bytes. audit exit 0.

* feat: eval Phase A1+A5 — significance testing for A/B arms

Pays down the debt that assertImproves(..., { by: se }) booked: that API admits
the "is this gap real or noise?" question but punts the noise floor to the user.
src/stats.ts answers it — a Welch's t-test over the per-arm summary stats
(mean/se/n, no raw rows, no EvalReport change) yielding a two-sided p-value and a
verdict, via a self-contained Numerical-Recipes incomplete beta. Pure + model-free,
validated against known t-table critical values (t_.975 at df 4/10/15) and the
arcsin closed form for the incomplete beta — grounded numerics, not just
internally consistent.

Surfaced as assertSignificant / significantlyBeats, and assertImproves now takes
{ significant: true } to demand a computed noise floor instead of a hand-fed `by`.

Deliberately scoped DOWN from the original Phase A: A2 (power analysis), A3
(pass@k at arbitrary k), and A4 (paired/blocked design) are deferred — each is
correct but not yet tied to an observed pain, and building rigor ahead of need is
the trap. They come back when a real eval demands them. The empirical cross-check
(running a real bench/ comparison through compareArms) is also deferred: no API
key here, so A1 is validated on known-answer distributions for now.

629 tests, gate green (100% stmts/lines/funcs, lint clean, audit 0).

* docs: README — reflect the eval tier's new capabilities

Level 3 (runEval) predated the eval-API work; it said "reports the gap … run it
now and then" with no mention of what shipped. Surgical update (no new sections,
per the readme-brevity rule):

- gap is reported as mean ± se; assertSignificant turns it into a CI gate via a
  computed (not hand-fed) Welch t-test noise floor
- the example shows the cache (cache:"readwrite") and the cost/latency/token line
- notes concurrency + maxCostUsd budget cap
- points at measureTriggerRate as the same-tier answer to skill activation

* docs: sync harness-testing guide with the eval tier's shipped capabilities

The README now advertises features the "full guide" it links to didn't cover.
Brought docs/harness-testing.md up to date:

- Significance: assertSignificant / significantlyBeats / compareArms — a Welch
  t-test with a computed (not hand-fed) noise floor; added as the third gating
  option alongside assertImproves / assertReliable, replacing the thin "raise
  trials" note. Re-exported compareArms + Comparison through vigiles/harness-assert
  so the whole eval-analysis surface lives behind one entry point (no ./stats).
- Cost / caching / concurrency: ctx.usage + report.usage + totalCostUsd, plus the
  concurrency / maxCostUsd / cache:"readwrite" record-replay knobs.
- measureTriggerRate: the trigger-rate (skill-activation) tier + its example.
- Fixed two stale claims: the suite runs under vitest (not node --test), and the
  eval orchestration is now fully unit-tested via an injected runner (only the
  real-claude spawnAgent is out of the gate) — the 100% gate holds.

No src behaviour change beyond the re-export. 629 tests, gate green, audit clean.

* docs: testing-matrix — add the eval-tier surfaces shipped in this PR

The matrix predated cache / significance / trigger-rate / usage / vendored
conformance. Added unit rows for: usage capture+aggregation, record/replay cache,
concurrency + rate-limit retry + maxCostUsd abort, measureTriggerRateWith,
significance stats (welchTTest/compareArms/tPValueTwoSided), vendored real-plugin
conformance, and the dangling-ref warning; broadened the assert-helpers row to
cover the significance/triggerRate/tool-sequence asserts. Added the
measureTriggerRate end-to-end integration row + skill-trigger-rate example.
Corrected the "not unit-tested" note: only the real-claude spawnAgent is out of
the gate — the eval orchestration is pinned via the injected runner.

* feat: spec compilation for subagents (agent() → agents/<name>.md)

A skill is reference material the model reads on activation; a subagent is a
delegated worker with a *contract* — a dispatch description, an allowed-tools
rail, a model, a system prompt, and the rules it follows. That contract is the
"railway" and it's exactly the compile-time-verifiable surface vigiles owns, so
subagents (more than skills) want spec compilation.

- spec.ts: `agent()` builder + AgentSpec (name, description, model?, tools?,
  body, rules?).
- compile.ts: `compileAgent` — YAML frontmatter (name/description/model/tools),
  system-prompt body with the same verified file()/cmd()/symbol()/ref() marks as
  any instruction file, an optional ## Rules section, and a SHA-256 integrity
  hash. Verifies the tool contract: built-in set + mcp__server__tool pattern,
  "did you mean" on a near-miss, and an error for tools that are *never* available
  to a subagent (Agent/AskUserQuestion/EnterPlanMode/ExitPlanMode/ScheduleWakeup/
  WaitForMcpServers). adoptDiff handles the new target. editDistance exported from
  linters.ts for reuse.
- agent.test.ts: frontmatter, tool verification (built-in/MCP/never-available/
  did-you-mean), body-ref validation, Rules section, spec-name check, adoptDiff
  round-trip.

Grounded by a background research pass (research/subagent-compilation.md): the
real Claude Code frontmatter, and the key finding that `tools:` is documentation,
not runtime enforcement (issue #54898) — the real rail is a generated PreToolUse
hook. That hook-emission layer (compile the contract into BOTH the allowlist and
an enforcing hook, and assert they agree) is the documented next step and the
differentiator. This commit is the compile+verify foundation.

638 tests, gate green (100% stmts/lines/funcs), lint/fmt/audit clean.

* feat: agent spec sections + dogfood on a real OSS subagent

Dogfooding the agent API against wshobson's real ui-visual-validator settled the
design: a subagent is a flat, multi-section role contract (10 `##` sections,
prose + `###` subs), NOT a step/gate pipeline — and it ships with NO tools line,
inheriting every tool (the #1 footgun, in the wild).

- spec.ts: AgentSpec gains `sections` (named `##` blocks) + a `body` intro,
  mirroring claude() rather than skill() — the shape real agents take. Steps/gates
  stay a skill concept; a subagent's "railway" is the tool-contract envelope, not
  a state machine.
- compile.ts: renderAgentSections verifies each section's refs and rejects nested
  `##` headers / a `rules` key clash, same rules as a CLAUDE.md spec's sections.
- agent.test.ts: a dogfood test reproduces the real agent's shape and ADDS the
  least-privilege rail it omits (Read/Grep/Glob/Bash, no Edit/Write), asserting it
  compiles clean — the value-add over the hand-written original. Plus section
  ref-verification + reserved-key tests.
- research/subagent-compilation.md: records the dogfood finding and the
  "flat flow yes / flow-generator no / tool-catalog generator optional" answer.

641 tests, gate green (100% stmts/lines/funcs), lint/fmt/audit clean.

* docs: empirical subagent survey — no iterator needed (saturated, ~100 agents)

Surveyed ~90–100 real subagents across 9+ repos and several ecosystems (haiku
agents) — wshobson, undeadlist, VoltAgent, 0xfurai, lst97, rshah515 — plus the
official Anthropic plugins (Ralph loop, PR-review-toolkit) and orchestration
plugins (barkain). The result saturated: every additional batch returned zero.

Findings recorded in research/subagent-compilation.md:
- Iterative SYNTAX in an agent file: 0 / ~100. (Iterative LANGUAGE in prose is
  common; structural loops/state machines: none.) No typed Iterator/Step/Phase/
  Flow primitive exists anywhere in the ecosystem — community or the official SDK.
- Where looping actually lives: a Stop hook + state file (Ralph re-feeds the
  prompt), or external scripts/commands — never the subagent .md. Architectural
  reason: markdown is declarative+stateless; a loop is imperative+stateful.
- Delegation graphs are linear/star and dynamic → even a typed handoff field is
  only weakly supported; prose + the Task/Agent allowlist covers them.
- Verdict: ship the flat model; do NOT add an iterator (solution seeking a
  problem). Reinforces the compile-to-hooks thesis — emit PreToolUse (tool rail)
  / Stop (loop) hooks, don't invent iterator types. handoffs/outputSchema = maybe.

No code change — research only. Confidence ~90%; gate untouched.

* docs: note in-repo prior art for the agent enforcement hook

A final survey straggler surfaced that vigiles already emits an enforcement hook:
the vigiles:result mark compiles to a Stop hook (skill-hook) that gates skill
completion (test/e2e/run.sh, research/runtime-enforcement.md). The planned agent
PreToolUse tool-rail is the same emit-a-hook pattern pointed at a different event,
so roadmap item #1 records it as prior art to mirror — not greenfield.

* docs: design — railway-style subagents (verified plan-as-code; Temporal analogy)

New research/railway-subagents.md exploring how to express a FLOW over flat
subagents without reinventing a workflow engine, prompted by two inputs:

- Plan-as-code ("ultra plan" / dynamic workflows): Anthropic's orchestrator
  generates an ephemeral, unverified JS orchestration script on the fly. vigiles'
  angle = the typed, verified, compiled counterpart (spec is source of truth →
  compile to a command .md + enforcing hooks + state schema; refs checked at
  compile time; regression-testable via runEval).
- Nested subagents (Claude Code, experimental, user-reported): a deeper delegation
  tree makes a typed depth/cycle/dangling-target graph check load-bearing.

Frames the Temporal analogy the user raised (workflow=deterministic railway;
delegate=activity; gate=signal; state-file+Stop-hook+eval-replay=durable replay;
"thinking happens in the activity, not the railway") and lays out three options:
(1) manual marks, (2) a workflow() TS-spec compiled via a limited vigiles API
(recommended core), (3) a thin Temporal-like deterministic driver over the
harness's native Task+hooks+state (north star; explicit boundary: don't build an
engine, delegate execution to the harness).

Honest verification note: plan-as-code is confirmed for Claude Code
(code.claude.com/docs/en/workflows, found earlier this session); the nested-
subagent cap is NOT yet verified — an automated check examined the wrong product
(the Developer Platform "Managed Agents" API, depth-1), so it's recorded as
user-reported/to-verify and the design is made robust to the exact cap.

Wired into Key Files + cross-linked with subagent-compilation.md. Docs only.

* docs: surface ultraplan as inspiration in discoverable index + ideas backlog

ultraplan / dynamic-workflows (plan-as-code) is the closest external thing to the
direction we're building, so make it easy to trip over:

- research/README.md: new "Subagents & orchestration" group indexing
  subagent-compilation.md + railway-subagents.md, with ultraplan called out by
  name and flagged "closest external thing to our direction — read for inspiration."
- research/feature-ideas.md: an "Inspiration to watch" callout framing ultraplan
  and pointing at railway-subagents.md for the full design (Temporal analogy +
  the marks / workflow()-spec / driver options).

Both spots are one click from README → research/. Docs only.

* feat(agent): PreToolUse tool-contract rail — enforce the declared tools allowlist

Closes the declared-vs-enforced gap for subagents (Claude Code #54898):
`tools:` frontmatter is documentation, not a runtime boundary. A subagent
inherits the session's grants, so the list only filters what's offered. The
deterministic layer that actually constrains it is a PreToolUse hook.

src/agent-runtime.ts (vigiles agent-hook): parse the active agent's compiled
.md frontmatter `tools:` (the single source of truth), and block (exit 2 + the
contract fed back to the model) any tool outside it. decidePreToolUse is the
pure allow/deny; .vigiles/active-agent.json records the dispatched agent —
mirroring the skill Stop-hook (src/skill-runtime.ts). No `tools:` line means
inherit-all, so the rail imposes no restriction.

The declared list and the enforced rail compile from the same source, so they
agree by construction — proven by a round-trip test (compile → parse the
frontmatter the hook reads → equals declared tools → allows exactly those).

Proven deterministically at the runHook unit tier against the real built CLI
process: a tool-event hook is reached cheaply there, where driving a live tool
call against a scripted mock is flaky (the e2e defers to it, with a note).

+22 tests; full suite 663 passing; 100% stmts/lines/funcs gate holds.

* test(agent): ground the PreToolUse rail on a real vendored subagent

The runtime suite proved the rail's logic with synthetic specs; add two tests
against the REAL pinned wshobson ui-visual-validator under examples/harness/vendor/.

It's the documented footgun in the wild: a rigorous visual validator that bases
judgments "solely on visual evidence" yet ships with NO tools: line, so it
inherits every tool incl. Edit/Write. parseAgentTools on the actual file returns
null (the rail honestly reports "no contract yet" rather than inventing one);
the spec form ADDS the least-privilege rail the original omits, compiles, and the
same rail the hook reads then blocks Write/Edit — the differentiator on a real
subagent, not a hand-built fixture.

Mirrors src/vendor.test.ts (pinned by SHA, offline, model-free).

* docs: refresh testing matrices for the subagent PreToolUse rail

Both matrices were stale on the Subagents surface. The new tool-contract rail
(agent-hook) is a runHook-testable hook, so the agents/ Hook-unit cell flips from
— to ; add a Coverage row for src/agent-runtime.test.ts. Kept honest about the
end-to-end gap: the deterministic/integration cell stays 🟡 ("rail not
live-armed") because Claude Code doesn't surface the active subagent to hooks, so
arming .vigiles/active-agent.json in a live session is still unsolved.

* feat(railway): railway-oriented subagents — typed Result contracts + finite composition

Flat subagents that return either success or error with rich detail on BOTH
tracks, composed on a sub-Turing railway. Railway-oriented programming (Wlaschin)
with a subagent as the step — uses the flat-worker finding, doesn't contradict it.

- result(ok, err) (spec.ts): a subagent's typed Result contract; compileAgent
  renders it as an ## Output contract section (the vigiles:ok / vigiles:err block
  the worker must emit).
- parseAgentResult (agent-result.ts): pure text -> Result<S,E> (ok | err |
  malformed), validated against the contract shape. The shared primitive.
- railway({ steps, onError, recover }) + delegate() (spec.ts): the composer is
  deliberately sub-Turing — a finite step list + bounded recovery, NO loop
  combinator, so termination is structural and every reference is statically
  checkable (the thing ultraplan's generated script can't be). compileRailway /
  validateRailway emit the orchestrator command and resolve every delegate target
  (stale-ref), reject an empty railway, and require recover.max >= 1.
- assertAgentOk / assertAgentErr / assertAgentResult (harness-assert.ts): test a
  subagent's outcome the way you assert a hook decision, reusing parseAgentResult
  — the testing-framework payoff of the result contract.

Deferred (the runtime half): vigiles emits + verifies, it does not drive the
railway (that's the engine, Option 3); runtime enforcement of "did the worker
emit a valid result block?" awaits what a SubagentStop hook can see.

+27 tests; full suite 690 passing; 100% stmts/lines/funcs gate holds.

* feat(cli): compile agent + railway specs; dogfood a ship-pr railway

Wire the railway surface end-to-end so it's reachable from `vigiles compile`,
then dogfood it on this repo.

CLI:
- loadSpec now returns agent/railway specs too; `vigiles compile` dispatches
  _specType "agent" → compileAgentToFile and "railway" → compileRailwayToFile.
- A railway's delegate() targets are resolved against every agent spec in the
  project (collectAgentNames), so an unknown target fails compile (stale-ref,
  exit 1) — verified by a CLI test.

Dogfood (examples/railway/): five flat agent() workers, each with a result()
contract — planner → implementer → reviewer on the success track, bounded fixer
recovery (max 2), reporter on the error track. Compiled THROUGH the real CLI to
one .md per agent (with vigiles:ok/err Output contracts) + ship-pr.md (the
orchestrator command). The artifacts are committed alongside their specs.

Also: compileRailway now emits a trailing newline (prettier-clean, matching the
other compilers).

+2 CLI tests; full suite 692 passing; 100% stmts/lines/funcs gate holds;
fmt + audit clean.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zernie
2026-06-11 22:50:45 +05:00
committed by GitHub
parent ceb810e881
commit 73a9dde310
82 changed files with 6721 additions and 337 deletions
+10 -2
View File
@@ -45,11 +45,19 @@ jobs:
gem install rubocop
rustup component add clippy
- name: Install claude CLI + bubblewrap (deterministic harness + sandbox tests)
# claude (no API key needed — the mock tier) lets the claude-gated tests
# run; bubblewrap lets the sandbox confinement test actually execute and
# prove egress is blocked, instead of skipping.
run: |
npm i -g @anthropic-ai/claude-code
sudo apt-get update && sudo apt-get install -y bubblewrap
- name: Build
run: npm run build
- name: Run tests
run: npm test
- name: Run tests + 100% coverage gate (harness-testing pillar)
run: npm run coverage
- name: Cross-runner matcher tests (vitest + jest)
run: npx vitest run && npx jest
+3
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
coverage/
.DS_Store
logo-v*.png
.idea
@@ -7,3 +8,5 @@ logo-v*.png
# vigiles transient skill-runtime state
.vigiles/active-skill.json
examples/demo/.vigiles/
# eval record/replay cache (recorded model runs)
.vigiles/eval-cache/
+3
View File
@@ -1,5 +1,8 @@
.vigiles/generated.d.ts
# Coverage artifacts (v8 / lcov output)
coverage/
# Vendored third-party plugin snapshots (dogfood fixtures) — formatted by their
# upstreams, not us. See examples/harness/vendor/*/SOURCE.
examples/harness/vendor/
+5 -1
View File
@@ -75,13 +75,15 @@ declare module "vigiles/generated" {
/** All enabled linter rules across all detected linters. */
export type LinterRule = EslintRule;
/** 11 npm scripts from package.json. */
/** 13 npm scripts from package.json. */
export type NpmScript =
| "build"
| "test"
| "coverage"
| "lint"
| "fmt"
| "fmt:check"
| "demo"
| "test:e2e"
| "test:harness"
| "test:eval"
@@ -306,9 +308,11 @@ declare module "vigiles/spec" {
scripts:
| "build"
| "test"
| "coverage"
| "lint"
| "fmt"
| "fmt:check"
| "demo"
| "test:e2e"
| "test:harness"
| "test:eval"
+28 -9
View File
@@ -1,4 +1,4 @@
<!-- vigiles:sha256:50302f93695fb02f compiled from CLAUDE.md.spec.ts -->
<!-- vigiles:sha256:0190c408ba68003b compiled from CLAUDE.md.spec.ts -->
# CLAUDE.md
@@ -34,8 +34,8 @@ Core modules: `src/spec.ts` (types + builders), `src/compile.ts` (compiler), `sr
## Key Files
- `src/spec.ts` — Type system and builder functions (enforce, guidance, claude, skill, file, cmd, ref)
- `src/compile.ts` — Compiler: spec → markdown with SHA-256 hash, linter verification, reference validation
- `src/spec.ts` — Type system and builder functions (enforce, guidance, claude, skill, agent, file, cmd, ref; result/railway/delegate for railway-oriented subagents)
- `src/compile.ts` — Compiler: spec → markdown with SHA-256 hash, linter verification, reference validation; compileClaude/compileSkill/compileAgent (subagents: frontmatter + verified tool contract + body marks + result-contract Output section) + compileRailway/validateRailway (orchestrator command over flat workers; delegate-target resolution + bounded recovery)
- `src/linters.ts` — Cross-referencing engine (ESLint, Stylelint, Ruff, Clippy, Pylint, RuboCop, Cedar)
- `src/cedar.test.ts` — Cedar policy resolution tests — filesystem-based @id() lookup with filename fallback
- `src/generate-types.ts` — Type generator: scans linters/package.json/filesystem → emits .d.ts
@@ -48,6 +48,12 @@ Core modules: `src/spec.ts` (types + builders), `src/compile.ts` (compiler), `sr
- `src/frontmatter.test.ts` — Frontmatter parser test suite (node:test)
- `src/action.ts` — GitHub Action wrapper
- `src/spec.test.ts` — Spec + compiler test suite (node:test)
- `src/agent.test.ts` — Subagent compilation test suite (node:test): agent() builder + compileAgent — frontmatter, tool-contract verification (built-in/MCP/never-available/did-you-mean), body-ref validation, Rules section, hash, adoptDiff round-trip
- `src/agent-runtime.ts` — Agent PreToolUse tool-contract rail — the differentiator that closes the declared-vs-enforced gap (#54898): tools: is documentation, so a PreToolUse hook (vigiles agent-hook) blocks any tool outside the active subagent's contract. parseAgentTools reads the compiled .md frontmatter (the single source of truth the hook enforces), decidePreToolUse is the pure allow/deny, and .vigiles/active-agent.json tracks the dispatched agent — mirrors the skill Stop-hook (src/skill-runtime.ts)
- `src/agent-runtime.test.ts` — Agent-runtime test suite: pure parse/decide logic, active-agent round-trip, hook ⇄ allowlist agree (the declared contract IS the enforced rail), the real built CLI hook driven deterministically via runHook (the unit tier reaches PreToolUse where a live tool call is flaky), and grounding on the REAL vendored wshobson ui-visual-validator (ships no tools: line → inherits all; the spec adds the rail it omits)
- `src/agent-result.ts` — Railway result parser: a subagent with a result() contract ends its turn with a vigiles:ok/err block; parseAgentResult turns that text into a discriminated outcome (ok | err | malformed) and validates it against the contract shape. Pure text→Result<S,E>, the primitive the orchestrator + the assertAgentOk/Err/Result test helpers both reuse
- `src/agent-result.test.ts` — Railway result-parser test suite: ok/err/malformed tracks, last-block-wins, JSON + shape validation across every field type (string/number/boolean/string[]), both success and error tracks
- `src/railway.test.ts` — Railway surface test suite: result() Output-contract rendering in compileAgent, delegate()/railway() builders, compileRailway orchestrator output, validateRailway static checks (unknown delegate target, empty railway, bounded recovery — the sub-Turing guarantees)
- `src/validate.test.ts` — Validation test suite (node:test)
- `src/cli.test.ts` — CLI integration + E2E test suite (node:test)
- `src/integrity.ts` — Integrity check: SHA-256 hash verification for compiled markdown (detects hand-edits)
@@ -66,16 +72,24 @@ Core modules: `src/spec.ts` (types + builders), `src/compile.ts` (compiler), `sr
- `src/symbols.test.ts` — Symbol extractor test suite (node:test)
- `src/refs.ts` — Symbol reference verification: the `vigiles:symbol path#name` mark (verify the named file defines the symbol) + unmarkedCodeRefs enforcement for the refs-hook
- `src/refs.test.ts` — Symbol reference verification test suite (node:test)
- `src/mock-model.ts` — Scriptable, dependency-free Anthropic Messages SSE mock (startMock/scriptModel) — point real claude at it via ANTHROPIC_BASE_URL for deterministic harness tests
- `src/harness-test.ts` — Deterministic Claude Code harness testing: runHarnessTest runs real claude + real hooks/settings against a scripted mock model (Stop-hooks reliable; tool-event hooks via the eval tier)
- `src/mock-model.ts` — Scriptable, dependency-free Anthropic Messages SSE mock (startMock/scriptModel) — point real claude at it via ANTHROPIC_BASE_URL for deterministic harness tests; extractRequest + onRequest capture each request into trace.modelRequests
- `src/harness-test.ts` — Deterministic Claude Code harness testing: runHarnessTest runs real claude + real hooks/settings against a scripted mock model (Stop-hooks reliable; tool-event hooks via the eval tier); safe-by-default — an external plugin/pluginDir is confined per src/sandbox.ts
- `src/harness-test.test.ts` — Harness-test suite (node:test, skips without claude)
- `src/sandbox.ts` — Safe-by-default confinement: decideSandbox is the pure policy (untrusted plugin code never runs unconfined unless sandbox:false), runSandboxed co-launches the mock + claude inside one bubblewrap network namespace (loopback-only — mock reachable, egress blocked); specTrusted/bwrapArgs/parseRequestLog are the pure, tested seams
- `src/sandbox.test.ts` — Sandbox test suite: pure policy/trust/args/log-parse coverage + a gated end-to-end test proving a sandboxed run blocks network egress while the in-sandbox mock stays reachable (skips without bwrap/claude)
- `src/mock-entry.ts` — In-sandbox mock entry: run as a subprocess inside the bwrap netns so the scripted mock lives on the isolated loopback; streams captured requests to a file the parent reads back for trace.modelRequests
- `src/run-hook.ts` — Hook unit tier: runHook pipes a synthesized event JSON to a hook process (no claude, no model) and reports exit code + normalized block/allow decision — the cheap base of the pyramid, and the only tier that reaches every event (Edit/Write, PreCompact, Notification, SessionEnd, SubagentStop); parseHookOutput/decideHook are the pure, testable decision logic
- `src/run-hook.test.ts` — Hook unit-tier test suite (node:test): pure decision logic + real shell hooks across exit codes, stdin event passthrough, env injection, JSON permission decisions
- `src/eval.ts` — Harness eval API: runEval drives the real claude CLI across arms x trials and aggregates mean ± se (variance) — the empirical half of testing your harness (generalizes bench/)
- `src/eval.test.ts` — Eval aggregation/formatting + variance test suite (node:test)
- `src/plugin-loader.ts` — Plugin/repo harness loader: loadPlugin reads real hooks (inline plugin.json, a hooks string path, the hooks/hooks.json convention e.g. obra/superpowers, or .claude/settings.json) with ${CLAUDE_PLUGIN_ROOT} resolved, plus CLAUDE.md + skills + agents + commands materialized; .warnings flags surfaces the deterministic tier can't drive (subagents/commands/MCP, or an empty machine) so a plugin load never silently tests nothing; resolveHarness layers inline settings/files on top so a test/eval runs the assembled machine
- `src/eval.ts` — Harness eval API: runEval drives the real claude CLI across arms x trials and aggregates mean ± se (variance) + cost/latency/token usage; bounded concurrency (runPool) + rate-limit backoff + maxCostUsd budget cap; record/replay cache (cache:readwrite) replays runs so editing measure re-scores for free; measureTriggerRate measures how reliably a skill's description FIRES across varied prompts (the #1 skill-authoring pain) — the empirical half of testing your harness (generalizes bench/)
- `src/eval.test.ts` — Eval aggregation/formatting + variance + usage/cache test suite (node:test)
- `src/eval-cache.ts` — Eval record/replay cache: cacheKey hashes the model-affecting inputs (task, resolved files+settings, model, tools, trialIndex) but NOT measure, so re-scoring replays for free; snapshotDir/restoreDir round-trip the post-run filesystem so ctx.file()/ctx.sh() stay sound on replay
- `src/eval-cache.test.ts` — Eval-cache test suite (node:test): key stability/sensitivity, record round-trip + malformed-record tolerance, filesystem snapshot/restore
- `src/stats.ts` — Significance testing for eval A/B arms: Welch's t-test over the per-arm summary stats (mean/se/n, no raw rows) → two-sided p-value + verdict (Numerical-Recipes incomplete beta); compareArms computes the noise floor instead of the assertImproves({by}) hand-fed gap — behind assertSignificant/significantlyBeats
- `src/stats.test.ts` — Stats test suite (node:test): incomplete-beta vs known closed forms, p-values vs t-table critical values, Welch significant/noise/deterministic cases
- `src/plugin-loader.ts` — Plugin/repo harness loader: loadPlugin reads real hooks (inline plugin.json, a hooks string path, the hooks/hooks.json convention e.g. obra/superpowers, or .claude/settings.json) with ${CLAUDE_PLUGIN_ROOT} resolved, plus CLAUDE.md + skills + agents + commands materialized; .warnings flags surfaces the deterministic tier can't drive (subagents/commands/MCP, an empty machine, or dangling intra-plugin file refs e.g. a partial vendor) so a plugin load never silently tests nothing; resolveHarness layers inline settings/files on top so a test/eval runs the assembled machine
- `src/plugin-loader.test.ts` — Plugin-loader test suite (node:test): CLAUDE_PLUGIN_ROOT resolution, CLAUDE.md/skills/agents/commands materialization, surface + empty-machine + MCP warnings, settings merge, in-repo dogfood
- `src/harness-assert.ts` — Runner-agnostic harness helpers: withHarness (auto-cleanup), throwing `assert*` helpers incl. assertHookBlocked/assertHookAllowed (node:test/any runner), and vigilesMatchers (toHaveCreated/toBlock/toBeatBaseline) for vitest/jest expect.extend
- `src/vendor.test.ts` — Conformance suite over REAL vendored plugins under examples/harness/vendor/: model-free, in-gate, table-driven loadPlugin invariants (loads a surface, ${CLAUDE_PLUGIN_ROOT} resolves, skills materialize, surface + dangling-ref warnings accurate) — grounded in reality (pinned by SHA, offline, no API key), the shape that caught the superpowers partial-vendor
- `src/harness-assert.ts` — Runner-agnostic harness helpers: withHarness (auto-cleanup), throwing `assert*` helpers incl. assertHookBlocked/assertHookAllowed and assertAgentOk/Err/Result (test a subagent's railway outcome via parseAgentResult — the testing-framework payoff of the result contract), and vigilesMatchers (toHaveCreated/toBlock/toBeatBaseline) for vitest/jest expect.extend
- `src/harness-assert.test.ts` — Harness-assert test suite (node:test): eval delta helpers + matcher pass/fail logic
- `src/judge.ts` — Thin LLM-as-judge for the eval tier: judge() grades an output against a rubric with a model (synchronous, for use inside measure); parseJudgeOutput is the pure, testable verdict parser
- `src/judge.test.ts` — Judge verdict-parsing test suite (node:test): result-field unwrap, prose-wrapped JSON, threshold, clamping, unparseable fallback
@@ -92,9 +106,11 @@ Core modules: `src/spec.ts` (types + builders), `src/compile.ts` (compiler), `sr
- `src/proofs.test.ts` — Proof system + evolution engine tests (node:test)
- `CLAUDE.md.spec.ts` — This file — the source of truth for CLAUDE.md
- `examples/SKILL.md.spec.ts` — Example SKILL.md spec
- `examples/railway/ship-pr.md.spec.ts` — Dogfood: a railway() over five flat agent() workers (planner→implementer→reviewer, bounded fixer recovery, reporter error track), each with a result() contract. Compiles via the real `vigiles compile` to ship-pr.md (orchestrator command) + one .md per agent (with vigiles:ok/err Output contracts); every delegate() target is resolved against the sibling agent specs at compile time
- `examples/harness/hook-unit.harness.mjs` — Canonical hook unit-tier example (runHook): test a hook's logic in isolation with no claude CLI — the cheap base of the pyramid; runs in CI for free
- `examples/harness/policy-gate.harness.mjs` — Canonical deterministic harness test (runHarnessTest): a PreToolUse Bash policy gate (block-no-verify shape) + a SessionStart setup hook (obra/superpowers shape)
- `examples/harness/skill-outcome.eval.mjs` — Canonical skill-outcome eval (runEval): does a skill change the agent's output? — the question you ask of any SKILL.md
- `examples/harness/skill-trigger-rate.eval.mjs` — Canonical trigger-rate eval (measureTriggerRate): does a skill's description actually FIRE across varied prompts? — installs a real pinned plugin via pluginDir and reuses the skillResolved predicate
- `examples/harness/plugin-cohesion.harness.mjs` — Canonical cohesion test (runHarnessTest with plugin:): load a whole plugin (.claude-plugin/plugin.json + CLAUDE.md) and assert multiple hooks fire together
- `bench/evals/refs-hook.eval.mjs` — Worked eval reproducing benchmark #4 (forcing symbol marks → verifiable references?) as a runEval library call
- `research/adoption-strategy.md` — Adoption strategy: zero-config setup, progressive enforcement, agent workflows
@@ -103,6 +119,8 @@ Core modules: `src/spec.ts` (types + builders), `src/compile.ts` (compiler), `sr
- `research/feature-ideas.md` — Feature ideas: plugin API, custom rules, exhaustive coverage
- `research/ai-code-quality.md` — Research: AI code quality patterns
- `research/self-evolving-specs.md` — Design doc: self-evolving spec system (proofs, Merkle history, evolution engine)
- `research/subagent-compilation.md` — Research + roadmap: compiling typed subagent definitions (agent() → agents/<name>.md) — the real Claude Code frontmatter, the declared-vs-enforced gap (tools: is documentation; PreToolUse hook is the rail, issue #54898), the empirical no-iterator survey (~100 subagents), prior-art agent contracts, and the prioritized next layers (generated enforcement hook, handoff resolution, trigger-rate for dispatch)
- `research/railway-subagents.md` — Design exploration: railway-style orchestration over flat subagents — verified plan-as-code as the counterpart to ultra-plan/dynamic-workflows, the Temporal analogy (workflow/activity/gate/durable-state), and three options (manual marks / workflow() TS-spec compilation / a thin Temporal-like deterministic driver over the harness's Task+hooks+state)
- `research/code-search-for-agents.md` — Research: code search approaches (grep vs embeddings vs AST-grep)
- `research/runtime-enforcement.md` — Research: spec-derived runtime enforcement via hooks, skill contracts, session audit
- `research/agent-integration.md` — Research: deterministic backstop for AI agents — hooks, proofs, static checks anchored at the spec
@@ -116,6 +134,7 @@ Core modules: `src/spec.ts` (types + builders), `src/compile.ts` (compiler), `sr
- `research/distribution-strategy.md` — Why nobody uses vigiles yet: funnel diagnosis + scan demo proposal as highest-leverage intervention
- `research/reference-verification-limits.md` — Synthesis: the conceptual boundary of reference verification — proxy-vs-judgment gap, prose undecidability (active mark vs passive symbol-table sweep), the doc-format landscape (explicit-link = marking; identity-based = the real fix), and the delegate/ignore/own rule for existing tools (Sphinx etc.)
- `research/harness-testing.md` — Testing the Claude Code harness — the three-tier design (unit runHook + deterministic runHarnessTest + real-model runEval), the assembled-machine plugin loader, and a coverage assessment against real plugins (protect-mcp, obra/superpowers, block-no-verify, wshobson agents/skills)
- `research/eval-api-landscape.md` — Eval-API landscape: the LLM/agent eval field (promptfoo, DeepEval, Braintrust, Inspect, LangSmith, OpenAI Evals) summarized then scored against our eval API — strengths (harness A/B arms, pass^k, se/std, unified Trace predicates), gaps (cost/concurrency/caching, significance testing, regression gating), and the B→A→C roadmap (defer D)
- `research/skill-authoring-pains.md` — Research: pains authoring agent skills (triggering, drift, testing, distribution) + strategic note on documentation-vs-procedure split and verifying SKILL.md references
- `docs/harness-testing.md` — Harness-testing guide: three layers (verify refs / deterministic / eval), test the whole machine via plugin:, runner-agnostic usage (node:test/vitest/jest) + matchers, variance, LLM-judge, CLI fallback
- `docs/testing-matrix.md` — Testing matrix: every harness-testing use case mapped to its test tier (unit / cross-runner / type / integration-CI) and file, plus why the CLI examples are .mjs and the API is TypeScript
+46 -8
View File
@@ -37,9 +37,9 @@ Core modules: \`src/spec.ts\` (types + builders), \`src/compile.ts\` (compiler),
keyFiles: {
"src/spec.ts":
"Type system and builder functions (enforce, guidance, claude, skill, file, cmd, ref)",
"Type system and builder functions (enforce, guidance, claude, skill, agent, file, cmd, ref; result/railway/delegate for railway-oriented subagents)",
"src/compile.ts":
"Compiler: spec → markdown with SHA-256 hash, linter verification, reference validation",
"Compiler: spec → markdown with SHA-256 hash, linter verification, reference validation; compileClaude/compileSkill/compileAgent (subagents: frontmatter + verified tool contract + body marks + result-contract Output section) + compileRailway/validateRailway (orchestrator command over flat workers; delegate-target resolution + bounded recovery)",
"src/linters.ts":
"Cross-referencing engine (ESLint, Stylelint, Ruff, Clippy, Pylint, RuboCop, Cedar)",
"src/cedar.test.ts":
@@ -61,6 +61,18 @@ Core modules: \`src/spec.ts\` (types + builders), \`src/compile.ts\` (compiler),
"src/frontmatter.test.ts": "Frontmatter parser test suite (node:test)",
"src/action.ts": "GitHub Action wrapper",
"src/spec.test.ts": "Spec + compiler test suite (node:test)",
"src/agent.test.ts":
"Subagent compilation test suite (node:test): agent() builder + compileAgent — frontmatter, tool-contract verification (built-in/MCP/never-available/did-you-mean), body-ref validation, Rules section, hash, adoptDiff round-trip",
"src/agent-runtime.ts":
"Agent PreToolUse tool-contract rail — the differentiator that closes the declared-vs-enforced gap (#54898): tools: is documentation, so a PreToolUse hook (vigiles agent-hook) blocks any tool outside the active subagent's contract. parseAgentTools reads the compiled .md frontmatter (the single source of truth the hook enforces), decidePreToolUse is the pure allow/deny, and .vigiles/active-agent.json tracks the dispatched agent — mirrors the skill Stop-hook (src/skill-runtime.ts)",
"src/agent-runtime.test.ts":
"Agent-runtime test suite: pure parse/decide logic, active-agent round-trip, hook ⇄ allowlist agree (the declared contract IS the enforced rail), the real built CLI hook driven deterministically via runHook (the unit tier reaches PreToolUse where a live tool call is flaky), and grounding on the REAL vendored wshobson ui-visual-validator (ships no tools: line → inherits all; the spec adds the rail it omits)",
"src/agent-result.ts":
"Railway result parser: a subagent with a result() contract ends its turn with a vigiles:ok/err block; parseAgentResult turns that text into a discriminated outcome (ok | err | malformed) and validates it against the contract shape. Pure text→Result<S,E>, the primitive the orchestrator + the assertAgentOk/Err/Result test helpers both reuse",
"src/agent-result.test.ts":
"Railway result-parser test suite: ok/err/malformed tracks, last-block-wins, JSON + shape validation across every field type (string/number/boolean/string[]), both success and error tracks",
"src/railway.test.ts":
"Railway surface test suite: result() Output-contract rendering in compileAgent, delegate()/railway() builders, compileRailway orchestrator output, validateRailway static checks (unknown delegate target, empty railway, bounded recovery — the sub-Turing guarantees)",
"src/validate.test.ts": "Validation test suite (node:test)",
"src/cli.test.ts": "CLI integration + E2E test suite (node:test)",
"src/integrity.ts":
@@ -90,25 +102,41 @@ Core modules: \`src/spec.ts\` (types + builders), \`src/compile.ts\` (compiler),
"Symbol reference verification: the `vigiles:symbol path#name` mark (verify the named file defines the symbol) + unmarkedCodeRefs enforcement for the refs-hook",
"src/refs.test.ts": "Symbol reference verification test suite (node:test)",
"src/mock-model.ts":
"Scriptable, dependency-free Anthropic Messages SSE mock (startMock/scriptModel) — point real claude at it via ANTHROPIC_BASE_URL for deterministic harness tests",
"Scriptable, dependency-free Anthropic Messages SSE mock (startMock/scriptModel) — point real claude at it via ANTHROPIC_BASE_URL for deterministic harness tests; extractRequest + onRequest capture each request into trace.modelRequests",
"src/harness-test.ts":
"Deterministic Claude Code harness testing: runHarnessTest runs real claude + real hooks/settings against a scripted mock model (Stop-hooks reliable; tool-event hooks via the eval tier)",
"Deterministic Claude Code harness testing: runHarnessTest runs real claude + real hooks/settings against a scripted mock model (Stop-hooks reliable; tool-event hooks via the eval tier); safe-by-default — an external plugin/pluginDir is confined per src/sandbox.ts",
"src/harness-test.test.ts":
"Harness-test suite (node:test, skips without claude)",
"src/sandbox.ts":
"Safe-by-default confinement: decideSandbox is the pure policy (untrusted plugin code never runs unconfined unless sandbox:false), runSandboxed co-launches the mock + claude inside one bubblewrap network namespace (loopback-only — mock reachable, egress blocked); specTrusted/bwrapArgs/parseRequestLog are the pure, tested seams",
"src/sandbox.test.ts":
"Sandbox test suite: pure policy/trust/args/log-parse coverage + a gated end-to-end test proving a sandboxed run blocks network egress while the in-sandbox mock stays reachable (skips without bwrap/claude)",
"src/mock-entry.ts":
"In-sandbox mock entry: run as a subprocess inside the bwrap netns so the scripted mock lives on the isolated loopback; streams captured requests to a file the parent reads back for trace.modelRequests",
"src/run-hook.ts":
"Hook unit tier: runHook pipes a synthesized event JSON to a hook process (no claude, no model) and reports exit code + normalized block/allow decision — the cheap base of the pyramid, and the only tier that reaches every event (Edit/Write, PreCompact, Notification, SessionEnd, SubagentStop); parseHookOutput/decideHook are the pure, testable decision logic",
"src/run-hook.test.ts":
"Hook unit-tier test suite (node:test): pure decision logic + real shell hooks across exit codes, stdin event passthrough, env injection, JSON permission decisions",
"src/eval.ts":
"Harness eval API: runEval drives the real claude CLI across arms x trials and aggregates mean ± se (variance) — the empirical half of testing your harness (generalizes bench/)",
"Harness eval API: runEval drives the real claude CLI across arms x trials and aggregates mean ± se (variance) + cost/latency/token usage; bounded concurrency (runPool) + rate-limit backoff + maxCostUsd budget cap; record/replay cache (cache:readwrite) replays runs so editing measure re-scores for free; measureTriggerRate measures how reliably a skill's description FIRES across varied prompts (the #1 skill-authoring pain) — the empirical half of testing your harness (generalizes bench/)",
"src/eval.test.ts":
"Eval aggregation/formatting + variance test suite (node:test)",
"Eval aggregation/formatting + variance + usage/cache test suite (node:test)",
"src/eval-cache.ts":
"Eval record/replay cache: cacheKey hashes the model-affecting inputs (task, resolved files+settings, model, tools, trialIndex) but NOT measure, so re-scoring replays for free; snapshotDir/restoreDir round-trip the post-run filesystem so ctx.file()/ctx.sh() stay sound on replay",
"src/eval-cache.test.ts":
"Eval-cache test suite (node:test): key stability/sensitivity, record round-trip + malformed-record tolerance, filesystem snapshot/restore",
"src/stats.ts":
"Significance testing for eval A/B arms: Welch's t-test over the per-arm summary stats (mean/se/n, no raw rows) → two-sided p-value + verdict (Numerical-Recipes incomplete beta); compareArms computes the noise floor instead of the assertImproves({by}) hand-fed gap — behind assertSignificant/significantlyBeats",
"src/stats.test.ts":
"Stats test suite (node:test): incomplete-beta vs known closed forms, p-values vs t-table critical values, Welch significant/noise/deterministic cases",
"src/plugin-loader.ts":
"Plugin/repo harness loader: loadPlugin reads real hooks (inline plugin.json, a hooks string path, the hooks/hooks.json convention e.g. obra/superpowers, or .claude/settings.json) with ${CLAUDE_PLUGIN_ROOT} resolved, plus CLAUDE.md + skills + agents + commands materialized; .warnings flags surfaces the deterministic tier can't drive (subagents/commands/MCP, or an empty machine) so a plugin load never silently tests nothing; resolveHarness layers inline settings/files on top so a test/eval runs the assembled machine",
"Plugin/repo harness loader: loadPlugin reads real hooks (inline plugin.json, a hooks string path, the hooks/hooks.json convention e.g. obra/superpowers, or .claude/settings.json) with ${CLAUDE_PLUGIN_ROOT} resolved, plus CLAUDE.md + skills + agents + commands materialized; .warnings flags surfaces the deterministic tier can't drive (subagents/commands/MCP, an empty machine, or dangling intra-plugin file refs e.g. a partial vendor) so a plugin load never silently tests nothing; resolveHarness layers inline settings/files on top so a test/eval runs the assembled machine",
"src/plugin-loader.test.ts":
"Plugin-loader test suite (node:test): CLAUDE_PLUGIN_ROOT resolution, CLAUDE.md/skills/agents/commands materialization, surface + empty-machine + MCP warnings, settings merge, in-repo dogfood",
"src/vendor.test.ts":
"Conformance suite over REAL vendored plugins under examples/harness/vendor/: model-free, in-gate, table-driven loadPlugin invariants (loads a surface, ${CLAUDE_PLUGIN_ROOT} resolves, skills materialize, surface + dangling-ref warnings accurate) — grounded in reality (pinned by SHA, offline, no API key), the shape that caught the superpowers partial-vendor",
"src/harness-assert.ts":
"Runner-agnostic harness helpers: withHarness (auto-cleanup), throwing `assert*` helpers incl. assertHookBlocked/assertHookAllowed (node:test/any runner), and vigilesMatchers (toHaveCreated/toBlock/toBeatBaseline) for vitest/jest expect.extend",
"Runner-agnostic harness helpers: withHarness (auto-cleanup), throwing `assert*` helpers incl. assertHookBlocked/assertHookAllowed and assertAgentOk/Err/Result (test a subagent's railway outcome via parseAgentResult — the testing-framework payoff of the result contract), and vigilesMatchers (toHaveCreated/toBlock/toBeatBaseline) for vitest/jest expect.extend",
"src/harness-assert.test.ts":
"Harness-assert test suite (node:test): eval delta helpers + matcher pass/fail logic",
"src/judge.ts":
@@ -138,12 +166,16 @@ Core modules: \`src/spec.ts\` (types + builders), \`src/compile.ts\` (compiler),
"src/proofs.test.ts": "Proof system + evolution engine tests (node:test)",
"CLAUDE.md.spec.ts": "This file — the source of truth for CLAUDE.md",
"examples/SKILL.md.spec.ts": "Example SKILL.md spec",
"examples/railway/ship-pr.md.spec.ts":
"Dogfood: a railway() over five flat agent() workers (planner→implementer→reviewer, bounded fixer recovery, reporter error track), each with a result() contract. Compiles via the real `vigiles compile` to ship-pr.md (orchestrator command) + one .md per agent (with vigiles:ok/err Output contracts); every delegate() target is resolved against the sibling agent specs at compile time",
"examples/harness/hook-unit.harness.mjs":
"Canonical hook unit-tier example (runHook): test a hook's logic in isolation with no claude CLI — the cheap base of the pyramid; runs in CI for free",
"examples/harness/policy-gate.harness.mjs":
"Canonical deterministic harness test (runHarnessTest): a PreToolUse Bash policy gate (block-no-verify shape) + a SessionStart setup hook (obra/superpowers shape)",
"examples/harness/skill-outcome.eval.mjs":
"Canonical skill-outcome eval (runEval): does a skill change the agent's output? — the question you ask of any SKILL.md",
"examples/harness/skill-trigger-rate.eval.mjs":
"Canonical trigger-rate eval (measureTriggerRate): does a skill's description actually FIRE across varied prompts? — installs a real pinned plugin via pluginDir and reuses the skillResolved predicate",
"examples/harness/plugin-cohesion.harness.mjs":
"Canonical cohesion test (runHarnessTest with plugin:): load a whole plugin (.claude-plugin/plugin.json + CLAUDE.md) and assert multiple hooks fire together",
"bench/evals/refs-hook.eval.mjs":
@@ -158,6 +190,10 @@ Core modules: \`src/spec.ts\` (types + builders), \`src/compile.ts\` (compiler),
"research/ai-code-quality.md": "Research: AI code quality patterns",
"research/self-evolving-specs.md":
"Design doc: self-evolving spec system (proofs, Merkle history, evolution engine)",
"research/subagent-compilation.md":
"Research + roadmap: compiling typed subagent definitions (agent() → agents/<name>.md) — the real Claude Code frontmatter, the declared-vs-enforced gap (tools: is documentation; PreToolUse hook is the rail, issue #54898), the empirical no-iterator survey (~100 subagents), prior-art agent contracts, and the prioritized next layers (generated enforcement hook, handoff resolution, trigger-rate for dispatch)",
"research/railway-subagents.md":
"Design exploration: railway-style orchestration over flat subagents — verified plan-as-code as the counterpart to ultra-plan/dynamic-workflows, the Temporal analogy (workflow/activity/gate/durable-state), and three options (manual marks / workflow() TS-spec compilation / a thin Temporal-like deterministic driver over the harness's Task+hooks+state)",
"research/code-search-for-agents.md":
"Research: code search approaches (grep vs embeddings vs AST-grep)",
"research/runtime-enforcement.md":
@@ -184,6 +220,8 @@ Core modules: \`src/spec.ts\` (types + builders), \`src/compile.ts\` (compiler),
"Synthesis: the conceptual boundary of reference verification — proxy-vs-judgment gap, prose undecidability (active mark vs passive symbol-table sweep), the doc-format landscape (explicit-link = marking; identity-based = the real fix), and the delegate/ignore/own rule for existing tools (Sphinx etc.)",
"research/harness-testing.md":
"Testing the Claude Code harness — the three-tier design (unit runHook + deterministic runHarnessTest + real-model runEval), the assembled-machine plugin loader, and a coverage assessment against real plugins (protect-mcp, obra/superpowers, block-no-verify, wshobson agents/skills)",
"research/eval-api-landscape.md":
"Eval-API landscape: the LLM/agent eval field (promptfoo, DeepEval, Braintrust, Inspect, LangSmith, OpenAI Evals) summarized then scored against our eval API — strengths (harness A/B arms, pass^k, se/std, unified Trace predicates), gaps (cost/concurrency/caching, significance testing, regression gating), and the B→A→C roadmap (defer D)",
"research/skill-authoring-pains.md":
"Research: pains authoring agent skills (triggering, drift, testing, distribution) + strategic note on documentation-vs-procedure split and verifying SKILL.md references",
"docs/harness-testing.md":
+54 -3
View File
@@ -313,7 +313,8 @@ assert(JSON.parse(r.stdout).num_turns > 1); // the Stop hook forced more work
### Level 3 — does it change what Claude does? (real AI, occasional)
`runEval` runs the **real** model N times with your change **on vs off** and
reports the gap. Costs tokens, so you run it now and then — not on every save:
reports the gap as **mean ± se** — so you can tell signal from noise instead of
eyeballing two averages:
```typescript
import { runEval, formatEvalReport } from "vigiles/eval";
@@ -325,10 +326,21 @@ const report = await runEval({
marked: ctx.sh("grep -c vigiles:symbol SKILL.md") !== "0",
}),
trials: 6,
cache: "readwrite", // replay past runs — editing `measure` re-scores for free
});
console.log(formatEvalReport(report)); // off marked=0.00 on marked=0.50
console.log(formatEvalReport(report));
// off marked=0.00 on marked=0.50±0.20 pass^k=0 ($0.07 · 1.2s/run · 4.1k tok)
```
`assertSignificant(report, { baseline: "off", arm: "on", metric: "marked" })`
turns the gap into a CI gate — a Welch t-test decides whether it cleared the
noise floor, **computed** from the arms' spread, not hand-fed. Runs go
**concurrently**, track **cost / latency / tokens** (cap them with `maxCostUsd`),
and the **record/replay cache** makes re-scoring after a `measure` edit free.
Same tier, different question: **`measureTriggerRate`** measures how reliably a
skill's _description fires_ across varied prompts — the #1 skill-authoring pain.
### Test your skills for real — and assert on what Claude _did_
Install a plugin the way Claude actually does (`pluginDir``--plugin-dir`) so
@@ -356,6 +368,43 @@ dangerous tool was never used, which "the file looks unchanged" can't. It works
on **real third-party plugins** too: the suite confirms real `obra/superpowers`
and `wshobson/agents` skills resolve this way, with no markers injected.
### Did the injected context actually reach the model?
A SessionStart hook or a slash command can _fire_ and still inject **nothing**
wrong output shape, wrong platform. `trace.modelRequests` records what the model
actually received (system + messages), so you assert it landed, not just that the
hook ran — **"fired ≠ landed"**:
```typescript
import { assertRequestContains } from "vigiles/harness-assert";
assertRequestContains(r, "You have superpowers"); // the additionalContext reached the model
```
(Dogfood: this is exactly how vigiles found that real `obra/superpowers` emits a
_top-level_ `additionalContext`, which Claude Code — reading the _nested_ form —
never injects. The hook fired; the context never landed.)
### Running an untrusted plugin? It's confined by default
Testing a third-party plugin means executing **its** hooks. `runHarnessTest` is
safe by default: code you wrote (inline `settings`/`files`) runs directly, but an
external `plugin` / `pluginDir` is **confined under bubblewrap** — a network
namespace with **no egress** (a malicious hook can't phone home), a read-only
filesystem, and a **cleared environment** (your `ANTHROPIC_API_KEY` and other
secrets aren't even visible). If no sandbox is available the run **refuses**
rather than executing unconfined:
```typescript
runHarnessTest({ pluginDir: "./vendor/some-plugin", model }); // confined, or refuses
runHarnessTest({ pluginDir: "./audited", model, sandbox: false }); // you vouch for it → direct
```
Confinement is **Linux-only** (bubblewrap); on macOS / Windows an untrusted run
refuses unless you pass `sandbox: false`. The suite dogfoods it on real
`obra/superpowers` — its `SessionStart` hook runs in a no-egress sandbox, and the
test proves egress is blocked while the scripted mock stays reachable.
### Run them in CI
`vigiles test` runs `*.harness.mjs` files (free, no key); `vigiles eval` runs
@@ -379,10 +428,12 @@ npx vigiles eval --trials=6 examples/harness/skill-outcome.eval.mjs
| Hooks — PreCompact / Notification / SessionEnd / SubagentStop | ✅ logic | — (mock can't trigger) | 🟡 |
| CLAUDE.md / instructions | ✅ refs | 🟡 present, not behaviour | ✅ behaviour |
| Skills | 🟡 refs | ✅ resolves via `pluginDir` | ✅ activation |
| Subagents (`agents/`) | 🟡 refs | 🔴 hard | ✅ via Task |
| Subagents (`agents/`) | ✅ tool rail · 🟡 refs | 🟡 rail not live-armed | ✅ via Task |
| Slash commands (`commands/`) | 🟡 refs | 🟡 needs prompt capture | ✅ via `/cmd` |
| MCP servers | ✅ tool refs (`vigiles:mcp`) | 🔴 | 🔴 |
| settings.json | 🟡 assert merged | ✅ applied | ✅ |
| Hook context injection (does it _land_?) | — n/a | ✅ `trace.modelRequests` | ✅ |
| Untrusted plugin execution | — n/a | ✅ confined (bwrap, Linux) | 🟡 outer sandbox |
✅ shipped · 🟡 partial · 🔴 gap · — n/a. Full detail + roadmap: [`research/harness-testing-coverage-matrix.md`](research/harness-testing-coverage-matrix.md).
+208 -23
View File
@@ -143,6 +143,80 @@ assertToolCalls(r, (calls) => /* any custom rule over the list */ true);
`assertToolCount` takes `{ min, max, exactly }`; `assertToolCalls` is the escape
hatch for a custom invariant like _"every Edit was preceded by a Read"_.
You can also assert on a tool's **arguments**, not just its name (DeepEval-style)
— e.g. the `Edit` targeted the right file, not just that _an_ Edit ran:
```ts
import { assertToolUsedWith } from "vigiles/harness-assert";
assertToolUsedWith(
r,
"Edit",
(input) => (input as { file_path?: string }).file_path === "src/billing.ts",
);
```
## One Trace, two consumers — predicates and assertions
Both tiers produce one **`Trace`**: the observable record of a run —
`toolCalls`, `hooks` (which fired + its decision), `output` (the final answer),
`turns`, and `file(p)`. A `runHarnessTest` result _is_ a `Trace`, and so is the
`ctx` handed to a `runEval` `measure`. Over that one shape there is one set of
**bare predicates** — pure functions returning a value, with **no `assert`
prefix and no throw**:
```ts
import {
usedTool,
toolCount,
skillResolved,
toolUsedWith,
outputContains,
hookFired,
hookBlocked,
} from "vigiles/harness-assert";
usedTool(trace, "Skill"); // boolean
usedTool(trace, /^mcp__github__merge/); // boolean (regex)
toolCount(trace, "Write"); // number
skillResolved(trace, "demo:greet"); // boolean
toolUsedWith(trace, "Edit", (i) => isRightFile(i)); // boolean (tool argument)
outputContains(trace, /done/i); // boolean (the agent's final answer)
hookFired(trace, "PreToolUse:Edit"); // boolean (recorded from the stream)
hookBlocked(trace, "PreToolUse"); // boolean (fired AND exit ≠ 0)
```
`trace.hooks` is **recorded**, not inferred: each `HookFire` (`name`, `event`,
`exitCode`, `blocked`, `output`) comes from the CLI's `hook_response` stream
events, so a test asserts a hook _actually_ fired and blocked — no marker file
the hook had to write. Capture it the same way as `toolCalls` (`transcript:
true` on the harness tier; always on at the eval tier). The throwing form is
`assertHookFired(trace, name, { blocked: true })`; `assertOutputContains(trace,
needle)` does the same for the final answer.
The two consumers stay **separate** — same vocabulary, never one dual-purpose
function:
- **Testing** asserts (pass/fail, every commit, free). Each `assert*` is just a
predicate wrapped in a throw: `assertToolUsed` is `usedTool` + throw,
`assertSkillResolved` is `skillResolved` + throw.
- **Eval** measures (mean ± se / pass^k, occasional, paid). A `measure` reuses
the **bare** predicates directly as metrics:
```ts
measure: (trace) => ({
usedSkill: skillResolved(trace, "demo:greet"), // bool → fraction-true + pass^k
safe: !usedTool(trace, /merge|delete/), // bool → fraction-true + pass^k
});
```
A test can then gate on the result three ways: `assertImproves` (the mean gap
beats a threshold), `assertSignificant(report, { baseline, arm, metric })` — a
Welch t-test decides whether the gap clears the noise floor (computed from the
arms' spread, not hand-fed) — or `assertReliable(report, { arm, metric })`, the
metric succeeded on **every** trial (pass^k = 1), the reliability bar for a
non-deterministic harness.
`runEval` arms take `pluginDir` too, so an A/B can be "skill installed" vs "off"
and measure **real** activation (the model triggering the skill by its
description), superseding the older "tell the agent to read a SKILL.md" trick:
@@ -173,21 +247,34 @@ upstream `LICENSE` and a `SOURCE` file recording repo and commit. There is no
clone at test time, so they run **offline and deterministically**. Refresh
deliberately with [`tools/refresh-vendor.sh`](../tools/refresh-vendor.sh).
**The safety line: `loadPlugin` parses, it never executes.** A hook is a real
child process with full `env``runHook`/`runHarnessTest` run the _actual_ hook,
not a reimplementation, and there is **no sandbox** beyond a temp cwd and a
timeout. That is fine for hooks _you_ wrote and for read-only governance hooks
(inspect the event → decide), but a third-party **setup** hook (superpowers'
`SessionStart`, say) can install, write, or call out. So the dogfood asserts such
a hook is correctly **wired** and stops there; it does not run it.
**Safe by default — untrusted hooks are confined, not trusted.** A hook is a
real child process: `runHarnessTest` runs the _actual_ hook, not a
reimplementation. Code _you_ authored (inline `settings`/`files`) is trusted and
runs directly. But an external `plugin` / `pluginDir` brings in **third-party
hooks**, and those are confined by default (`sandbox: "auto"`):
To actually _execute_ untrusted third-party hooks, put a real boundary around it:
the cheapest correct one is the **ephemeral CI container** (the runner is the
sandbox) — run that job only there, never in a plain local `npm test`. A
heavier-weight local sandbox (bubblewrap/`bwrap`, `sandbox-exec`, or Docker) is a
reasonable opt-in if you need it, but it is not built into the library today —
it's tracked as a potential improvement (an opt-in `sandbox:` option on the
execution tiers) in [`research/feature-ideas.md`](../research/feature-ideas.md) §13.
- **bubblewrap available** → the run is confined. The mock and `claude` are
co-launched inside **one network namespace** (`--unshare-all`): loopback is up
so the in-sandbox mock is reachable, but there is **no external route**, so a
malicious hook cannot phone home. The filesystem is read-only except the
throwaway work dir, a fresh empty `$HOME`, and an IO dir.
- **no bubblewrap** → the run **refuses** (throws) rather than executing an
untrusted hook unconfined. Install `bwrap`, or pass `sandbox: false` to opt out
if you trust the code / the outer container.
```ts
runHarnessTest({ pluginDir: "./vendor/some-plugin", model }); // auto: confined, or refuses
runHarnessTest({ pluginDir: "./vendor/audited", model, sandbox: false }); // you vouch for it → direct
runHarnessTest({ settings, model, sandbox: "strict" }); // force confinement even for inline
```
The policy lives in [`src/sandbox.ts`](../src/sandbox.ts) (`decideSandbox` is a
pure function — untrusted code never runs unconfined unless you typed
`sandbox: false`), and the end-to-end test proves egress is blocked while the
mock stays reachable. Network egress confinement on a bare laptop comes from the
netns; in CI the ephemeral container is an additional boundary. Subtlety: bwrap
confines filesystem + network here, not a kernel-exploit boundary — for that you
still want the outer container / a microVM.
## Deterministic tests in your runner
@@ -260,17 +347,27 @@ and the type augmentation is compile-checked in
jest uses the CommonJS dist natively (no ESM flags); the `vigiles/vitest` entry
is ESM because vitest is ESM-only.
**Reliable for:** SessionStart, Stop, UserPromptSubmit, and Bash
PreToolUse/PostToolUse — the governance/policy shapes most real plugins use.
**Not** for Edit/Write tool-event hooks (headless-gated — drive file actions via
Bash, or test those at the eval tier).
**Reliable for:** SessionStart, Stop, UserPromptSubmit, and Bash **and
Edit/Write** PreToolUse/PostToolUse — the governance/policy shapes most real
plugins use (`--allowedTools` allowlists the edit tools past the permission
prompt; verified on claude 2.1.169). The events the mock can't trigger —
PreCompact, Notification, SessionEnd, SubagentStop — belong to the `runHook`
unit tier.
## Evals — does the change move behaviour?
`runEval` drives the real model N trials × arm and aggregates: **mean** for
numbers, **fraction-true** for booleans, with **std / se** so you can tell a
real gap from noise (`formatEvalReport` prints `metric=mean±se`). An arm is a
fixture + settings, or a whole `plugin`.
real gap from noise, plus **pass^k** (τ-bench) — _did the metric succeed on
every trial?_ — the reliability question a non-deterministic harness needs
("worked every time" ≠ "worked on average"). `formatEvalReport` prints
`metric=mean±se pass^k=…`; each `stat` carries `passK`. An arm is a fixture +
settings, or a whole `plugin`.
The `measure` ctx is a full `Trace`, so a metric can read the agent's
**actions** (`ctx.toolCalls`) and its **final answer** (`ctx.output`), not just
end-state files — reuse the bare predicates above (`usedTool`, `skillResolved`,
…) to compute them.
```ts
import { runEval, formatEvalReport } from "vigiles/eval";
@@ -288,11 +385,81 @@ const report = await runEval({
}),
trials: 6,
});
console.log(formatEvalReport(report)); // vanilla marked=0.00 gated marked=0.50±0.20
console.log(formatEvalReport(report));
// vanilla marked=0.00 pass^k=0 gated marked=0.50±0.20 pass^k=0 ($0.07 · 1.2s/run · 4.1k tok)
```
A difference smaller than the combined `se` of the two arms is not yet
significant — raise `trials`.
(The cost/latency/token suffix and a `— $… total` header appear when the run
reports usage; they're silent under the scripted mock.)
### Significance — is the gap real?
`se` gives you the spread; **significance** tells you whether the gap clears it.
`assertSignificant` runs a Welch's t-test over the two arms' summary stats and
throws unless the arm beats the baseline at `alpha` (default 0.05) — the noise
floor is **computed**, not hand-fed via `assertImproves(..., { by })`:
```ts
import {
assertSignificant,
significantlyBeats,
compareArms,
} from "vigiles/harness-assert";
assertSignificant(report, {
baseline: "vanilla",
arm: "gated",
metric: "marked",
});
significantlyBeats(report, "vanilla", "gated", "marked"); // the bare predicate
// or: assertImproves(report, { baseline, arm, metric, significant: true });
const c = compareArms(report, "vanilla", "gated", "marked");
// → { delta, seDelta, t, df, pValue, significant } (reads mean/se/n, no raw rows)
```
For 0/1 metrics this is the t approximation to the two-proportion test — close at
eval trial counts. An insignificant gap means **raise `trials`** until the noise
floor drops below it.
### Cost, caching, concurrency
Every run captures **cost / latency / tokens** from the result event: `ctx.usage`
(`{ costUsd, durationMs, inputTokens, outputTokens }`) is on the `measure` ctx,
`report.arms[a].usage` aggregates per arm, and `report.totalCostUsd` sums the run.
Three knobs make a real-model eval cheap enough to run often:
- `concurrency: N` — run N trials at once (default 1). Rate-limit / overload
responses back off and retry automatically (`rateLimitRetries`, `retryBackoffMs`).
- `maxCostUsd: N` — stop launching trials once measured cost crosses the cap;
in-flight trials finish and `report.aborted` is set.
- `cache: "readwrite"`**record/replay**. Each trial's output _and_ post-run
filesystem are recorded under `cacheDir`; a matching re-run replays without
calling the model. The key excludes `measure`, so **editing your metric and
re-running re-scores for free** — the model is re-called only when a
model-affecting input (task, files, settings, model, tools) changes.
### Trigger rate — does the skill _fire_?
A skill's value is its description activating on the right task — the #1
skill-authoring pain, and a property only the real model decides (the
deterministic tier proves the _wiring_; this proves the _activation_).
`measureTriggerRate` installs a plugin natively and runs the model over a set of
varied prompts, reporting how often a `Trace` predicate holds:
```ts
import { measureTriggerRate, formatTriggerRateReport } from "vigiles/eval";
import { skillResolved, assertTriggerRate } from "vigiles/harness-assert";
const report = await measureTriggerRate({
pluginDir: "./my-plugin",
prompts: ["…varied tasks the skill should handle…"],
fired: (t) => skillResolved(t, "my-plugin:greet"),
trials: 2,
});
console.log(formatTriggerRateReport(report)); // trigger-rate: 80% (10 runs)
assertTriggerRate(report, { min: 0.6 }); // gate in CI
```
### LLM-as-judge for subjective outcomes
@@ -330,6 +497,23 @@ vigiles eval --trials=6 # discover & run *.eval.mjs (forwards VIGILES_TRIAL
`vigiles test` needs only the `claude` CLI (no API key) — so it runs the
deterministic tier in CI at zero cost. See the repo's `harness` CI job.
## Coverage
The suite runs under **vitest** (`npm test``vitest run`); `npm run coverage`
adds V8 coverage and prints per-file line/branch/function %:
```bash
npm run coverage # vitest run --coverage
```
The deterministic tiers (`runHook`, `runHarnessTest`) and **all the pure eval
orchestration** — the loop (`runEvalWith`), the record/replay cache, usage
aggregation, and the significance stats — are fully unit-tested via an
**injected runner** (canned stream-json, no model). Only the real-`claude`
subprocess (`spawnAgent`) is excluded from the gate (exercised by `bench/`);
everything around it is covered, so the statement/line/function gate holds at
100%.
## Canonical examples
- [`examples/harness/hook-unit.harness.mjs`](../examples/harness/hook-unit.harness.mjs) — unit-test a hook's logic with `runHook`, no `claude` CLI (the cheap base of the pyramid).
@@ -338,6 +522,7 @@ deterministic tier in CI at zero cost. See the repo's `harness` CI job.
- [`examples/harness/real-superpowers.harness.mjs`](../examples/harness/real-superpowers.harness.mjs) — dogfood `loadPlugin` on a real, pinned obra/superpowers snapshot (key-free, offline).
- [`examples/harness/real-wshobson.harness.mjs`](../examples/harness/real-wshobson.harness.mjs) — dogfood `loadPlugin` on a real wshobson/agents sub-plugin (the no-hooks marketplace shape).
- [`examples/harness/skill-outcome.eval.mjs`](../examples/harness/skill-outcome.eval.mjs) — does a skill change the agent's output?
- [`examples/harness/skill-trigger-rate.eval.mjs`](../examples/harness/skill-trigger-rate.eval.mjs) — does a skill's description _fire_ across varied prompts? (`measureTriggerRate`)
- [`bench/evals/refs-hook.eval.mjs`](../bench/evals/refs-hook.eval.mjs) — the refs-hook A/B (benchmark #4).
## See also
+65 -44
View File
@@ -11,64 +11,85 @@ the file that proves it. Two kinds of coverage:
## Coverage
| Use case | Tier | Where |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------- |
| Hook unit tier — `runHook` (exit codes / stdin event / env / JSON decision) | unit | `src/run-hook.test.ts` |
| Hook decision logic (`parseHookOutput` / `decideHook`) | unit | `src/run-hook.test.ts` |
| Plugin loader — inline `plugin.json` hooks | unit | `src/plugin-loader.test.ts` |
| Plugin loader — `hooks` string-path | unit | `src/plugin-loader.test.ts` |
| Plugin loader — `hooks/hooks.json` convention | unit | `src/plugin-loader.test.ts` |
| Plugin loader — repo `.claude/settings.json` | unit | `src/plugin-loader.test.ts` |
| Plugin loader — manifest-wins precedence | unit | `src/plugin-loader.test.ts` |
| Plugin loader — bare dir (no hooks) | unit | `src/plugin-loader.test.ts` |
| Plugin loader — `agents/` + `commands/` materialized + surface warnings | unit | `src/plugin-loader.test.ts` |
| Plugin loader — empty-machine + MCP warnings | unit | `src/plugin-loader.test.ts` |
| Plugin loader — in-repo dogfood | unit | `src/plugin-loader.test.ts` |
| `resolveHarness` — merge / passthrough / undefined | unit | `src/plugin-loader.test.ts` |
| Eval aggregation (mean, std, se, n) + report formatting | unit | `src/eval.test.ts` |
| Assert helpers (`assertCreated`/`assertNotCreated`/`assertServedTurns`/`assertHookBlocked`/`assertHookAllowed`/`improvement`/`assertImproves`) | unit | `src/harness-assert.test.ts` |
| Matchers register + pass under **vitest** (via the `vigiles/vitest` entry) | unit | `test/runners/matchers.vitest.mjs` |
| Matchers register + pass under **jest** (via the `vigiles/jest` entry) | unit | `test/runners/matchers.jest.cjs` |
| Matcher **types** augment vitest/jest `expect` | unit | `test/types/smoke.vitest.ts`, `smoke.jest.ts` |
| CLI runner (`discoverScripts`/`runScripts`/summary) | unit | `src/run-scripts.test.ts` |
| Judge verdict parsing (`parseJudgeOutput`) | unit | `src/judge.test.ts` |
| `runHarnessTest` end-to-end, incl. `plugin:` | integration (CI) | `examples/harness/policy-gate.harness.mjs`, `plugin-cohesion.harness.mjs` |
| `withHarness` (auto-cleanup wrapper) | integration (CI) | `examples/harness/plugin-cohesion.harness.mjs` |
| `runEval` end-to-end, incl. `plugin` arm | integration (real model) | `bench/evals/refs-hook.eval.mjs`, `examples/harness/skill-outcome.eval.mjs` |
| `judge()` model call | integration (real model) | (parsing is unit-tested; the spawn is not) |
| Use case | Tier | Where |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------- |
| Hook unit tier — `runHook` (exit codes / stdin event / env / JSON decision) | unit | `src/run-hook.test.ts` |
| Hook decision logic (`parseHookOutput` / `decideHook`) | unit | `src/run-hook.test.ts` |
| Subagent PreToolUse tool-contract rail — `agent-hook` (parse contract / allow-deny / hook ⇄ allowlist agree / real built-CLI hook via `runHook`; grounded on the vendored `ui-visual-validator`) | unit | `src/agent-runtime.test.ts` |
| Plugin loader — inline `plugin.json` hooks | unit | `src/plugin-loader.test.ts` |
| Plugin loader — `hooks` string-path | unit | `src/plugin-loader.test.ts` |
| Plugin loader — `hooks/hooks.json` convention | unit | `src/plugin-loader.test.ts` |
| Plugin loader — repo `.claude/settings.json` | unit | `src/plugin-loader.test.ts` |
| Plugin loader — manifest-wins precedence | unit | `src/plugin-loader.test.ts` |
| Plugin loader — bare dir (no hooks) | unit | `src/plugin-loader.test.ts` |
| Plugin loader — `agents/` + `commands/` materialized + surface warnings | unit | `src/plugin-loader.test.ts` |
| Plugin loader — empty-machine + MCP + dangling-intra-plugin-ref warnings | unit | `src/plugin-loader.test.ts` |
| Plugin loader — in-repo dogfood | unit | `src/plugin-loader.test.ts` |
| Vendored **real-plugin** conformance (`loadPlugin` invariants, pinned + offline) | unit | `src/vendor.test.ts` |
| `resolveHarness` — merge / passthrough / undefined | unit | `src/plugin-loader.test.ts` |
| Eval aggregation (mean / std / se / n / pass^k) + report formatting | unit | `src/eval.test.ts` |
| Eval usage capture + aggregation (`parseUsage` / `aggregateUsage`, cost/latency/tokens) | unit | `src/eval.test.ts` |
| Eval record/replay cache (`cacheKey` / snapshot+restore / replay skips the model) | unit | `src/eval-cache.test.ts`, `src/eval.test.ts` |
| Eval concurrency + rate-limit retry + `maxCostUsd` abort (`runPool` / `isRateLimited`) | unit | `src/eval.test.ts` |
| Trigger-rate orchestration (`measureTriggerRateWith`) | unit | `src/eval.test.ts` |
| Significance stats (`welchTTest` / `compareArms` / `tPValueTwoSided` / incomplete-beta vs t-table) | unit | `src/stats.test.ts` |
| Assert/predicate helpers (create/turns/hook-block; tool used/notUsed/count/sequence/with; skillResolved; output/request-contains; hookFired; improvement/improves; reliable; **significant**; **triggerRate**) | unit | `src/harness-assert.test.ts` |
| Matchers register + pass under **vitest** (via the `vigiles/vitest` entry) | unit | `test/runners/matchers.vitest.mjs` |
| Matchers register + pass under **jest** (via the `vigiles/jest` entry) | unit | `test/runners/matchers.jest.cjs` |
| Matcher **types** augment vitest/jest `expect` | unit | `test/types/smoke.vitest.ts`, `smoke.jest.ts` |
| CLI runner (`discoverScripts`/`runScripts`/summary) | unit | `src/run-scripts.test.ts` |
| Judge verdict parsing (`parseJudgeOutput`) | unit | `src/judge.test.ts` |
| `runHarnessTest` end-to-end, incl. `plugin:` | integration (CI) | `examples/harness/policy-gate.harness.mjs`, `plugin-cohesion.harness.mjs` |
| `withHarness` (auto-cleanup wrapper) | integration (CI) | `examples/harness/plugin-cohesion.harness.mjs` |
| `runEval` end-to-end, incl. `plugin` arm | integration (real model) | `bench/evals/refs-hook.eval.mjs`, `examples/harness/skill-outcome.eval.mjs` |
| `measureTriggerRate` end-to-end (skill activation via `pluginDir`) | integration (real model) | `examples/harness/skill-trigger-rate.eval.mjs` |
| `judge()` model call | integration (real model) | (parsing is unit-tested; the spawn is not) |
## Surface coverage — which plugin surface is reachable at which tier
A Claude Code plugin/repo has several surfaces. They are reachable at different
tiers, because some only do anything under a real model:
| Surface | Hook unit (`runHook`) | Deterministic (`runHarnessTest`) | Eval (`runEval`) |
| ------------------------------------------------------------- | --------------------- | --------------------------------- | ------------------ |
| Hooks — SessionStart / Stop / UserPromptSubmit | ✅ logic | ✅ fires in machine | ✅ |
| Hooks — Bash PreToolUse / PostToolUse | ✅ logic | ✅ fires in machine | ✅ |
| Hooks — Edit/Write PreToolUse / PostToolUse | ✅ logic | ⚠ headless-gated (drive via Bash) | ✅ |
| Hooks — PreCompact / Notification / SessionEnd / SubagentStop | ✅ logic | — (mock can't trigger) | partial |
| CLAUDE.md / instruction files | — | ✅ present in context | ✅ moves behaviour |
| Skills | — | ✅ body present (activation n/g) | ✅ |
| Subagents (`agents/`) | — | ⚠ materialized, not invoked | ✅ (Task) |
| Slash commands (`commands/`) | — | ⚠ materialized, not invoked | ✅ |
| MCP servers | — | — (not wired; warned) | bring-your-own |
| Surface | Hook unit (`runHook`) | Deterministic (`runHarnessTest`) | Eval (`runEval`) |
| ------------------------------------------------------------- | --------------------- | ----------------------------------- | ------------------------------------ |
| Hooks — SessionStart / Stop / UserPromptSubmit | ✅ logic | ✅ fires in machine | ✅ |
| Hooks — Bash PreToolUse / PostToolUse | ✅ logic | ✅ fires in machine | ✅ |
| Hooks — Edit/Write PreToolUse / PostToolUse | ✅ logic | ⚠ headless-gated (drive via Bash) | ✅ |
| Hooks — PreCompact / Notification / SessionEnd / SubagentStop | ✅ logic | — (mock can't trigger) | partial |
| CLAUDE.md / instruction files | — | ✅ present in context | ✅ moves behaviour |
| Skills | — | ✅ resolves via `pluginDir` | ✅ activation (`measureTriggerRate`) |
| Subagents (`agents/`) | ✅ tool-contract rail | ⚠ materialized; rail not live-armed | ✅ (Task) |
| Slash commands (`commands/`) | — | ⚠ materialized, not invoked | ✅ |
| MCP servers | — | — (not wired; warned) | bring-your-own |
`loadPlugin(...).warnings` surfaces the ⚠ rows for a given plugin, so a "load the
whole plugin" test never silently runs an empty machine (e.g. a subagents-only
plugin like wshobson/agents `tdd-workflows`, which ships 2 agents + 4 commands
and **no** hooks).
The **subagent tool-contract rail** (`agent-hook`) makes the `agents/` Hook-unit
cell `✅`: the generated `PreToolUse` hook that enforces an agent's declared
`tools:` is `runHook`-testable like any other hook, and the declared list and the
enforced rail are compiled from one source (proven by a round-trip test). The
deterministic cell stays ⚠ for an honest reason — Claude Code doesn't surface the
active subagent to hooks, so arming the rail in a live session
(`.vigiles/active-agent.json`) is still unsolved; the logic is proven at the unit
tier, not yet end-to-end in a real dispatch. See `research/subagent-compilation.md`.
## What is intentionally _not_ unit-tested
`runHarnessTest`, `runEval`, `withHarness`, and `judge()`'s model call all spawn
the real `claude` CLI (and, for evals, a real model). A unit test can't drive
that deterministically, so the seam we _can_ pin — the mock model
(`src/mock-model.ts`), the loader, the aggregation, the parsing, the matchers —
is unit-tested, and the end-to-end behaviour is exercised by the example suite
in CI. The deterministic examples (`*.harness.mjs`) run with **no API key** (real
`claude` + scripted mock model), so they are CI-affordable; the evals
(`*.eval.mjs`) cost real model calls and are run manually / in a keyed job.
Only the real-`claude` subprocess is out of the gate: `runHarnessTest`,
`withHarness`, `judge()`'s model call, and `runEval`/`measureTriggerRate`'s
default `spawnAgent` runner spawn the CLI (and, for evals, a real model). A unit
test can't drive that deterministically — but everything _around_ it is pinned.
The eval **orchestration** (`runEvalWith` / `measureTriggerRateWith`) takes an
**injected runner**, so the loop, cache, concurrency, budget, usage aggregation,
and significance run against canned stream-json with no model; the mock model
(`src/mock-model.ts`), the loader, the parsing, and the matchers are unit-tested
too. End-to-end behaviour is exercised by the example suite in CI: the
deterministic examples (`*.harness.mjs`) run with **no API key** (real `claude` +
scripted mock model), so they're CI-affordable; the evals (`*.eval.mjs`) cost
real model calls and run manually / in a keyed job.
## Why are the CLI examples `.mjs` (JavaScript), not TypeScript?
@@ -39,6 +39,7 @@ const plugin = fileURLToPath(new URL("./fixture-plugin", import.meta.url));
await withHarness(
{
plugin, // fixture-plugin's real hooks (SessionStart + PreToolUse) + CLAUDE.md
sandbox: false, // in-repo fixture we authored → trusted, run direct
model: scriptModel([
{ tool: "Bash", input: { command: "rm -rf /tmp/should-be-blocked" } },
{ tool: "Bash", input: { command: "echo ok > RESULT" } },
@@ -8,11 +8,13 @@
* commit, with upstream LICENSE + SOURCE). There is no clone at test time, so
* this runs offline and deterministically see ./vendor/<plugin>/SOURCE.
*
* Safety: `loadPlugin` only PARSES the harness it never runs a hook.
* superpowers' hook is a side-effectful SessionStart *setup* script, so this
* test asserts it is correctly WIRED and deliberately does NOT execute it.
* Executing third-party hooks belongs to a sandboxed tier (the ephemeral CI
* container, or a future bwrap/docker boundary), never to a bare `loadPlugin`.
* Safety: `loadPlugin` only PARSES the harness it never runs a hook. This
* example verifies superpowers' SessionStart hook is correctly WIRED, key-free.
* To actually EXECUTE that untrusted third-party hook, `runHarnessTest` now runs
* it CONFINED by default under bubblewrap (`src/sandbox.ts`) see the dogfood in
* `src/sandbox.test.ts`, which runs this same hook in a no-egress sandbox and
* checks its real output (and shows, via `trace.modelRequests`, that its
* top-level `additionalContext` does NOT reach Claude Code "fired ≠ landed").
*
* Pure + key-free: needs neither the `claude` CLI nor an API key, so it runs in
* CI for free. Run: `node examples/harness/real-superpowers.harness.mjs`.
@@ -59,7 +61,7 @@ console.log(
for (const w of loaded.warnings) console.log(`${w}`);
console.log(
"\nNote: loadPlugin parsed the harness; it did NOT execute the SessionStart\n" +
"setup hook (side-effectful). Wiring is verified here; execution is deferred\n" +
"to a sandboxed tier. No third-party code ran.",
"setup hook. Wiring is verified here; CONFINED execution of this same hook is\n" +
"dogfooded in src/sandbox.test.ts (bubblewrap, no egress). No code ran here.",
);
console.log("\n1 passed.");
@@ -0,0 +1,52 @@
/**
* Canonical example a skill *trigger-rate* eval (`measureTriggerRate`).
*
* A skill's value is its description firing on the right task the #1 documented
* skill-authoring pain. Wiring (does the Skill tool resolve it) is the
* deterministic tier's job; whether the *real model chooses* the skill across
* varied phrasings is a property only the model can answer. `measureTriggerRate`
* installs a plugin natively (`pluginDir`), runs a set of prompts, and reports
* how reliably your `fired` predicate holds.
*
* npx vigiles eval examples/harness/skill-trigger-rate.eval.mjs
* node examples/harness/skill-trigger-rate.eval.mjs 2 # trials per prompt
*
* Real model real cost. Needs the `claude` CLI + model auth and a built dist/.
* External users import from the package: `from "vigiles/eval"`.
*/
import {
measureTriggerRate,
formatTriggerRateReport,
} from "../../dist/eval.js";
import { skillResolved } from "../../dist/harness-assert.js";
import { fileURLToPath } from "node:url";
const trials = Number(process.env.VIGILES_TRIALS || process.argv[2] || 1);
// A real, pinned vendored plugin (no clone at test time). Its TDD skill should
// activate when a task is about writing/changing code with tests.
const pluginDir = fileURLToPath(
new URL("./vendor/superpowers@6fd4507", import.meta.url),
);
const skill = "superpowers:test-driven-development";
const report = await measureTriggerRate({
pluginDir,
prompts: [
"Add an `isEven(n)` function to utils.js — write it test-first.",
"Implement a stack class in stack.js. Use TDD.",
"Fix the off-by-one in paginate(); add a regression test first.",
],
// reuse a bare predicate: did the model activate the skill (no error)?
fired: (t) => skillResolved(t, skill),
trials,
});
console.log(formatTriggerRateReport(report));
// A trigger-rate eval is a measurement, not a hard gate by default — but you can
// gate in CI with assertTriggerRate(report, { min: 0.6 }) from vigiles/harness-assert.
if (report.n === 0) {
throw new Error("no runs executed");
}
console.log(`\n✓ measured ${report.n} run(s).`);
+30
View File
@@ -0,0 +1,30 @@
<!-- vigiles:sha256:4e15c156920f8c31 compiled from examples/railway/fixer.md.spec.ts -->
---
name: fixer
description: Address a failing step's findings, then re-verify. Dispatched by the railway's bounded recovery.
model: sonnet
tools: Read, Edit, Bash, Grep
---
You receive a failing step's error payload (findings or logs)
and fix the underlying issue. Re-run `npm test` before reporting success.
If you cannot fix it, return a reason so the railway falls to the error track.
## Output contract
Finish your turn with exactly one fenced block — success or error — matching one of these shapes.
On success:
```vigiles:ok
{ "files": string[], "summary": string }
```
On error:
```vigiles:err
{ "reason": string, "retryable": boolean }
```
+23
View File
@@ -0,0 +1,23 @@
/**
* Example subagent (recovery step): fixer.
*
* Source of truth for `agents/fixer.md`. The bounded-recovery worker: it takes a
* failing step's error payload and tries to address it. The railway caps how
* many times it runs (recover.max) the finite, sub-Turing guarantee.
*/
import { agent, result, instructions, cmd } from "../../src/spec.js";
export default agent({
name: "fixer",
description:
"Address a failing step's findings, then re-verify. Dispatched by the railway's bounded recovery.",
model: "sonnet",
tools: ["Read", "Edit", "Bash", "Grep"],
body: instructions`You receive a failing step's error payload (findings or logs)
and fix the underlying issue. Re-run ${cmd("npm test")} before reporting success.
If you cannot fix it, return a reason so the railway falls to the error track.`,
output: result(
{ files: "string[]", summary: "string" },
{ reason: "string", retryable: "boolean" },
),
});
+30
View File
@@ -0,0 +1,30 @@
<!-- vigiles:sha256:df16b875ba61702a compiled from examples/railway/implementer.md.spec.ts -->
---
name: implementer
description: Implement an approved plan: make the edits and prove the build passes. Dispatch after the planner.
model: sonnet
tools: Read, Edit, Write, Bash, Grep, Glob
---
You implement the plan handed to you, one step at a time. After
the edits, run `npm run build` and `npm test`; only report success
once both pass. On failure, report where you stopped so the fixer can recover.
## Output contract
Finish your turn with exactly one fenced block — success or error — matching one of these shapes.
On success:
```vigiles:ok
{ "files": string[], "summary": string }
```
On error:
```vigiles:err
{ "failedAt": string, "logs": string, "retryable": boolean }
```
+23
View File
@@ -0,0 +1,23 @@
/**
* Example subagent (railway step): implementer.
*
* Source of truth for `agents/implementer.md`. Receives the planner's success
* payload, makes the edits, and returns the files it changed or a structured
* failure (where it stopped + whether a retry could help).
*/
import { agent, result, instructions, cmd } from "../../src/spec.js";
export default agent({
name: "implementer",
description:
"Implement an approved plan: make the edits and prove the build passes. Dispatch after the planner.",
model: "sonnet",
tools: ["Read", "Edit", "Write", "Bash", "Grep", "Glob"],
body: instructions`You implement the plan handed to you, one step at a time. After
the edits, run ${cmd("npm run build")} and ${cmd("npm test")}; only report success
once both pass. On failure, report where you stopped so the fixer can recover.`,
output: result(
{ files: "string[]", summary: "string" },
{ failedAt: "string", logs: "string", retryable: "boolean" },
),
});
+30
View File
@@ -0,0 +1,30 @@
<!-- vigiles:sha256:29a509e62504dc31 compiled from examples/railway/planner.md.spec.ts -->
---
name: planner
description: Break a change request into an ordered, reviewable plan. Dispatch FIRST in the ship-pr railway.
model: sonnet
tools: Read, Grep, Glob
---
You turn a change request into a concrete, ordered plan. Read the
relevant code first; do not write any. Verify the build is green with `npm run build`
before planning around it.
## Output contract
Finish your turn with exactly one fenced block — success or error — matching one of these shapes.
On success:
```vigiles:ok
{ "steps": string[], "summary": string }
```
On error:
```vigiles:err
{ "reason": string, "retryable": boolean }
```
+23
View File
@@ -0,0 +1,23 @@
/**
* Example subagent (railway step): planner.
*
* The source of truth `agents/planner.md` is a compiled build artifact. A flat
* worker that returns a typed Result: a plan on success, a reason on failure.
* Run `vigiles compile` to regenerate the markdown.
*/
import { agent, result, instructions, cmd } from "../../src/spec.js";
export default agent({
name: "planner",
description:
"Break a change request into an ordered, reviewable plan. Dispatch FIRST in the ship-pr railway.",
model: "sonnet",
tools: ["Read", "Grep", "Glob"],
body: instructions`You turn a change request into a concrete, ordered plan. Read the
relevant code first; do not write any. Verify the build is green with ${cmd("npm run build")}
before planning around it.`,
output: result(
{ steps: "string[]", summary: "string" },
{ reason: "string", retryable: "boolean" },
),
});
+30
View File
@@ -0,0 +1,30 @@
<!-- vigiles:sha256:2dba1e6b88ef08b6 compiled from examples/railway/reporter.md.spec.ts -->
---
name: reporter
description: Summarize a railway failure for a human. Dispatched on the error track when recovery is exhausted.
model: haiku
tools: Read
---
You receive the error payload of the step that failed and the
recovery attempts that were exhausted. Write a concise, factual report: what was
attempted, where it failed, and what a human should look at next.
## Output contract
Finish your turn with exactly one fenced block — success or error — matching one of these shapes.
On success:
```vigiles:ok
{ "reported": boolean, "summary": string }
```
On error:
```vigiles:err
{ "reason": string, "retryable": boolean }
```
+23
View File
@@ -0,0 +1,23 @@
/**
* Example subagent (error track): reporter.
*
* Source of truth for `agents/reporter.md`. The railway's onError handler: it
* runs with the failing step's error payload and records the failure clearly so
* a human can pick it up.
*/
import { agent, result, instructions } from "../../src/spec.js";
export default agent({
name: "reporter",
description:
"Summarize a railway failure for a human. Dispatched on the error track when recovery is exhausted.",
model: "haiku",
tools: ["Read"],
body: instructions`You receive the error payload of the step that failed and the
recovery attempts that were exhausted. Write a concise, factual report: what was
attempted, where it failed, and what a human should look at next.`,
output: result(
{ reported: "boolean", summary: "string" },
{ reason: "string", retryable: "boolean" },
),
});
+30
View File
@@ -0,0 +1,30 @@
<!-- vigiles:sha256:a055143eb4a78162 compiled from examples/railway/reviewer.md.spec.ts -->
---
name: reviewer
description: Review the implemented diff for correctness. Dispatch LAST on the success track.
model: opus
tools: Read, Grep, Bash
---
You review the diff for correctness and regressions. Re-run
`npm test` yourself — do not trust the report. Approve only when the change
is correct; otherwise return concrete, actionable findings.
## Output contract
Finish your turn with exactly one fenced block — success or error — matching one of these shapes.
On success:
```vigiles:ok
{ "summary": string }
```
On error:
```vigiles:err
{ "findings": string[], "blocking": boolean }
```
+23
View File
@@ -0,0 +1,23 @@
/**
* Example subagent (railway step): reviewer.
*
* Source of truth for `agents/reviewer.md`. The last success-track step: it
* either approves (ok) or returns blocking findings (err) that route to the
* recovery / error track.
*/
import { agent, result, instructions, cmd } from "../../src/spec.js";
export default agent({
name: "reviewer",
description:
"Review the implemented diff for correctness. Dispatch LAST on the success track.",
model: "opus",
tools: ["Read", "Grep", "Bash"],
body: instructions`You review the diff for correctness and regressions. Re-run
${cmd("npm test")} yourself do not trust the report. Approve only when the change
is correct; otherwise return concrete, actionable findings.`,
output: result(
{ summary: "string" },
{ findings: "string[]", blocking: "boolean" },
),
});
+19
View File
@@ -0,0 +1,19 @@
<!-- vigiles:sha256:aacbda14c3ac1dd1 compiled from examples/railway/ship-pr.md.spec.ts -->
# Railway: ship-pr
Dispatch these subagents on the **success track**, in order. Each returns a result block (`vigiles:ok` / `vigiles:err`). If a step returns an error, stop the success track and run the error handler with that error payload.
## Success track
1. **planner** — break the request into an ordered plan
2. **implementer** — implement the plan; prove build + tests pass
3. **reviewer** — review the diff for correctness
## Recovery
If a step errors, retry it via **fixer** up to 2× before falling to the error track.
## On error
Run **reporter** with the failing step's error payload.
+29
View File
@@ -0,0 +1,29 @@
/**
* Example railway: ship-pr railway-oriented orchestration over flat subagents.
*
* Source of truth for `ship-pr.md` (the orchestrator command the lead agent
* reads). Each step is a flat worker that returns a typed Result (vigiles:ok /
* vigiles:err); the success track flows planner implementer reviewer, the
* first error short-circuits, bounded recovery retries via the fixer, and an
* exhausted failure routes to the reporter. No loop combinator the value is a
* finite tree, so it always terminates and every delegate() target is resolved
* against the real agent specs in this directory at compile time.
*/
import { railway, delegate } from "../../src/spec.js";
export default railway({
name: "ship-pr",
steps: [
delegate("planner", "break the request into an ordered plan"),
delegate("implementer", "implement the plan; prove build + tests pass"),
delegate("reviewer", "review the diff for correctness"),
],
recover: {
step: delegate(
"fixer",
"address the failing step's findings, then re-verify",
),
max: 2,
},
onError: delegate("reporter", "report the exhausted failure for a human"),
});
+73
View File
@@ -29,6 +29,7 @@
"@types/node": "^20.19.39",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.58.0",
"@vitest/coverage-v8": "^4.1.8",
"eslint": "^10.1.0",
"eslint-plugin-sonarjs": "^4.0.2",
"globals": "^17.4.0",
@@ -3333,6 +3334,47 @@
"win32"
]
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.8",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.8",
"vitest": "4.1.8"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
@@ -3571,6 +3613,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz",
"integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/babel-jest": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz",
@@ -6127,6 +6188,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+4 -2
View File
@@ -40,7 +40,8 @@
],
"scripts": {
"build": "tsc",
"test": "npm run build && node --test dist/spec.test.js dist/validate.test.js dist/cli.test.js dist/proofs.test.js dist/inline.test.js dist/sidecar.test.js dist/coverage.test.js dist/session.test.js dist/orphans.test.js dist/cedar.test.js dist/doc-refs.test.js dist/frontmatter.test.js dist/skill-pipeline.test.js dist/skill-runtime.test.js dist/skill-driver.test.js dist/skill-test.test.js dist/compile-generator.test.js dist/community-skills.test.js dist/action-gate.test.js dist/symbols.test.js dist/refs.test.js dist/harness-test.test.js dist/eval.test.js dist/run-scripts.test.js dist/plugin-loader.test.js dist/harness-assert.test.js dist/judge.test.js dist/run-hook.test.js dist/mcp.test.js",
"test": "npm run build && vitest run",
"coverage": "npm run build && vitest run --coverage",
"lint": "eslint src/",
"fmt": "prettier --write .",
"fmt:check": "prettier --check .",
@@ -48,7 +49,7 @@
"test:e2e": "bash test/e2e/run.sh",
"test:harness": "npm run build && node dist/cli.js test",
"test:eval": "npm run build && node dist/cli.js eval",
"test:vitest": "npm run build && vitest run",
"test:vitest": "npm run build && vitest run --project runners",
"test:jest": "npm run build && jest",
"test:types": "npm run build && tsc --noEmit -p test/types/tsconfig.json"
},
@@ -59,6 +60,7 @@
"@types/node": "^20.19.39",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.58.0",
"@vitest/coverage-v8": "^4.1.8",
"eslint": "^10.1.0",
"eslint-plugin-sonarjs": "^4.0.2",
"globals": "^17.4.0",
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests for action gates: deterministic checks bound to a tool action type,
* fired regardless of plan order (the dynamic-workflow reframe).
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { evaluateAction, type ActionGate } from "./action-gate.js";
+132
View File
@@ -0,0 +1,132 @@
/**
* Tests for parseAgentResult (src/agent-result.ts) the railway result parser.
* A subagent with a result() contract ends its turn with a vigiles:ok/err block;
* this turns that text into a discriminated outcome (ok | err | malformed). Pure,
* model-free the primitive both the orchestrator and the assert helpers reuse.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import { parseAgentResult } from "./agent-result.js";
import { result } from "./spec.js";
const okBlock = (json: string) => "Done.\n\n```vigiles:ok\n" + json + "\n```\n";
const errBlock = (json: string) =>
"Failed.\n\n```vigiles:err\n" + json + "\n```\n";
test("parses a success block (no contract)", () => {
const r = parseAgentResult(
okBlock('{ "files": ["a.ts"], "summary": "done" }'),
);
assert.equal(r.kind, "ok");
assert.deepEqual(r.kind === "ok" && r.value, {
files: ["a.ts"],
summary: "done",
});
});
test("parses an error block (no contract)", () => {
const r = parseAgentResult(
errBlock('{ "reason": "boom", "retryable": true }'),
);
assert.equal(r.kind, "err");
assert.deepEqual(r.kind === "err" && r.error, {
reason: "boom",
retryable: true,
});
});
test("malformed when no result block is present", () => {
const r = parseAgentResult("I finished the task, all good!");
assert.equal(r.kind, "malformed");
assert.match(
r.kind === "malformed" ? r.reason : "",
/no vigiles:ok\/vigiles:err/,
);
});
test("malformed on invalid JSON", () => {
const r = parseAgentResult(okBlock("{ not json"));
assert.equal(r.kind, "malformed");
assert.match(r.kind === "malformed" ? r.reason : "", /invalid JSON/);
});
test("malformed when the block is JSON but not an object", () => {
for (const body of ["[1, 2, 3]", "42", '"a string"']) {
const r = parseAgentResult(okBlock(body));
assert.equal(r.kind, "malformed", `body ${body}`);
assert.match(
r.kind === "malformed" ? r.reason : "",
/must be a JSON object/,
);
}
});
test("the LAST block wins (earlier illustrative blocks ignored)", () => {
const text =
'First I might do:\n```vigiles:ok\n{ "draft": true }\n```\n' +
'but actually:\n```vigiles:err\n{ "reason": "nope" }\n```\n';
const r = parseAgentResult(text);
assert.equal(r.kind, "err");
});
test("contract validation: missing required field → malformed", () => {
const c = result(
{ files: "string[]", summary: "string" },
{ reason: "string" },
);
const r = parseAgentResult(okBlock('{ "files": ["a.ts"] }'), c);
assert.equal(r.kind, "malformed");
assert.match(
r.kind === "malformed" ? r.reason : "",
/missing field "summary"/,
);
});
test("contract validation: wrong field type → malformed", () => {
const c = result({ summary: "string" }, { reason: "string" });
const r = parseAgentResult(okBlock('{ "summary": 123 }'), c);
assert.equal(r.kind, "malformed");
assert.match(
r.kind === "malformed" ? r.reason : "",
/"summary" should be string/,
);
});
test("contract validation: every field type matches → ok", () => {
const c = result(
{ s: "string", n: "number", b: "boolean", arr: "string[]" },
{ reason: "string" },
);
const r = parseAgentResult(
okBlock('{ "s": "x", "n": 1, "b": false, "arr": ["a", "b"] }'),
c,
);
assert.equal(r.kind, "ok");
});
test("contract validation: string[] rejects a non-array and an array with non-strings", () => {
const c = result({ arr: "string[]" }, { reason: "string" });
assert.equal(
parseAgentResult(okBlock('{ "arr": "x" }'), c).kind,
"malformed",
);
assert.equal(
parseAgentResult(okBlock('{ "arr": ["a", 2] }'), c).kind,
"malformed",
);
});
test("contract validation runs against the err track too", () => {
const c = result(
{ summary: "string" },
{ reason: "string", retryable: "boolean" },
);
const bad = parseAgentResult(errBlock('{ "reason": "x" }'), c); // missing retryable
assert.equal(bad.kind, "malformed");
const good = parseAgentResult(
errBlock('{ "reason": "x", "retryable": true }'),
c,
);
assert.equal(good.kind, "err");
});
+115
View File
@@ -0,0 +1,115 @@
/**
* vigiles parse a subagent's railway result.
*
* A subagent with a `result()` contract is told (in its compiled system prompt)
* to end its turn with exactly one fenced block:
*
* ```vigiles:ok
* { "files": ["a.ts"], "summary": "done" }
* ```
*
* or `vigiles:err` for the error track. This module extracts and validates that
* block the single primitive the railway orchestrator and the harness-test
* assertions (`assertAgentOk`/`assertAgentErr`) both build on. Pure and
* model-free: hand it the worker's text, get back a discriminated outcome.
*
* "Railway-oriented" is literal here: the parse is `text -> Result<S, E>` with a
* third `malformed` track for a worker that didn't honor its contract (no block,
* bad JSON, or a shape that doesn't match the declared schema).
*/
import type { OutputContract, OutputFieldType } from "./spec.js";
/** The outcome of parsing a worker's result block. */
export type ParsedAgentResult<
S = Record<string, unknown>,
E = Record<string, unknown>,
> =
| { readonly kind: "ok"; readonly value: S }
| { readonly kind: "err"; readonly error: E }
| { readonly kind: "malformed"; readonly reason: string };
// Capture every vigiles:ok / vigiles:err fenced block; the LAST one is the
// worker's final answer (earlier ones may be illustrative in its reasoning).
const BLOCK_RE = /```vigiles:(ok|err)[ \t]*\r?\n([\s\S]*?)```/g;
/** Does a runtime value match a declared field type? */
function fieldMatches(value: unknown, type: OutputFieldType): boolean {
switch (type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number";
case "boolean":
return typeof value === "boolean";
case "string[]":
return Array.isArray(value) && value.every((v) => typeof v === "string");
}
}
/** Validate a parsed object against a contract track; null when it conforms. */
function shapeError(
obj: Record<string, unknown>,
shape: Readonly<Record<string, OutputFieldType>>,
): string | null {
for (const [field, type] of Object.entries(shape)) {
if (!(field in obj)) return `missing field "${field}"`;
if (!fieldMatches(obj[field], type)) {
return `field "${field}" should be ${type}`;
}
}
return null;
}
/**
* Parse the last `vigiles:ok` / `vigiles:err` block from a worker's output.
*
* With a `contract`, the parsed object is validated against the matching track's
* shape a worker that emits the wrong shape is `malformed`, not a silent pass.
* Without one, any well-formed JSON block is accepted.
*/
export function parseAgentResult(
text: string,
contract?: OutputContract,
): ParsedAgentResult {
BLOCK_RE.lastIndex = 0;
let last: { track: "ok" | "err"; body: string } | null = null;
for (let m = BLOCK_RE.exec(text); m !== null; m = BLOCK_RE.exec(text)) {
last = { track: m[1] as "ok" | "err", body: m[2] };
}
if (!last) {
return {
kind: "malformed",
reason: "no vigiles:ok/vigiles:err block found",
};
}
let parsed: unknown;
try {
parsed = JSON.parse(last.body);
} catch {
return {
kind: "malformed",
reason: `invalid JSON in vigiles:${last.track} block`,
};
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return {
kind: "malformed",
reason: `vigiles:${last.track} block must be a JSON object`,
};
}
const obj = parsed as Record<string, unknown>;
if (contract) {
const shape = last.track === "ok" ? contract.ok : contract.err;
const err = shapeError(obj, shape);
if (err) {
return { kind: "malformed", reason: `${last.track} block: ${err}` };
}
}
return last.track === "ok"
? { kind: "ok", value: obj }
: { kind: "err", error: obj };
}
+415
View File
@@ -0,0 +1,415 @@
/**
* Tests for the agent runtime (src/agent-runtime.ts) the PreToolUse
* tool-contract rail. A subagent's `tools:` frontmatter is documentation, not a
* runtime boundary (Claude Code #54898); this hook turns it into enforcement by
* blocking any tool outside the active agent's compiled allowlist. Model-free:
* the decision logic is pure and the runtime ops are plain filesystem.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import { agent } from "./spec.js";
import { compileAgent } from "./compile.js";
import {
parseAgentTools,
decidePreToolUse,
setActiveAgent,
readActiveAgent,
clearActiveAgent,
evaluatePreToolUse,
} from "./agent-runtime.js";
import { makeTmpDir, cleanupTmpDir } from "./test-utils.js";
import { runHook } from "./run-hook.js";
import { writeFileSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
// ---------------------------------------------------------------------------
// parseAgentTools — read the contract back out of compiled markdown
// ---------------------------------------------------------------------------
test("parseAgentTools reads the tools list from compiled frontmatter", () => {
const { markdown } = compileAgent(
agent({
name: "reviewer",
description: "Review a diff.",
tools: ["Read", "Grep", "Bash"],
body: "b",
}),
{ specFile: "a.md.spec.ts" },
);
assert.deepEqual(parseAgentTools(markdown), ["Read", "Grep", "Bash"]);
});
test("parseAgentTools returns null when no tools: line (inherit-all)", () => {
const { markdown } = compileAgent(
agent({ name: "a", description: "d", body: "b" }),
{ specFile: "a.md.spec.ts" },
);
assert.equal(parseAgentTools(markdown), null);
});
test("parseAgentTools returns [] for an empty tools list", () => {
// Hand-built frontmatter: a `tools:` line with nothing after it.
const md = [
"---",
"",
"name: a",
"description: d",
"tools:",
"",
"---",
"",
"body",
].join("\n");
assert.deepEqual(parseAgentTools(md), []);
});
test("parseAgentTools returns null when there is no frontmatter", () => {
assert.equal(parseAgentTools("# just a heading\n\ntools: Read\n"), null);
});
test("parseAgentTools ignores a `tools:` line in the body, only reads frontmatter", () => {
const md = [
"---",
"name: a",
"description: d",
"tools: Read",
"---",
"",
"Here the prose mentions tools: Write, Edit which must be ignored.",
].join("\n");
assert.deepEqual(parseAgentTools(md), ["Read"]);
});
// ---------------------------------------------------------------------------
// decidePreToolUse — the pure rail
// ---------------------------------------------------------------------------
test("decidePreToolUse allows a tool inside the contract", () => {
const d = decidePreToolUse(["Read", "Grep"], "Read");
assert.equal(d.allow, true);
assert.equal(d.message, "");
});
test("decidePreToolUse blocks a tool outside the contract, naming the allowlist", () => {
const d = decidePreToolUse(["Read", "Grep"], "Write");
assert.equal(d.allow, false);
assert.match(d.message, /"Write" is not in this subagent's allowed-tools/);
assert.match(d.message, /Read, Grep/);
});
test("decidePreToolUse with null allowlist imposes no restriction (inherit-all)", () => {
assert.equal(decidePreToolUse(null, "Bash").allow, true);
});
test("decidePreToolUse with an empty allowlist denies everything", () => {
const d = decidePreToolUse([], "Read");
assert.equal(d.allow, false);
assert.match(d.message, /\(none\)/);
});
test("decidePreToolUse matches MCP tools exactly", () => {
const allowed = ["Read", "mcp__github__issue_write"];
assert.equal(
decidePreToolUse(allowed, "mcp__github__issue_write").allow,
true,
);
assert.equal(
decidePreToolUse(allowed, "mcp__github__delete_file").allow,
false,
);
});
// ---------------------------------------------------------------------------
// active-agent tracking
// ---------------------------------------------------------------------------
test("setActiveAgent / readActiveAgent / clearActiveAgent round-trip", () => {
const dir = makeTmpDir("agent-active");
try {
assert.equal(readActiveAgent(dir), null);
setActiveAgent(dir, "agents/reviewer.md");
assert.equal(readActiveAgent(dir), "agents/reviewer.md");
clearActiveAgent(dir);
assert.equal(readActiveAgent(dir), null);
// clearing again is a no-op, not an error
clearActiveAgent(dir);
} finally {
cleanupTmpDir(dir);
}
});
test("readActiveAgent tolerates a malformed marker file", () => {
const dir = makeTmpDir("agent-active-bad");
try {
setActiveAgent(dir, "x"); // creates .vigiles/active-agent.json
writeFileSync(join(dir, ".vigiles", "active-agent.json"), "{ not json");
assert.equal(readActiveAgent(dir), null);
} finally {
cleanupTmpDir(dir);
}
});
test("readActiveAgent returns null when the marker lacks a string agent field", () => {
const dir = makeTmpDir("agent-active-shape");
try {
setActiveAgent(dir, "x");
writeFileSync(
join(dir, ".vigiles", "active-agent.json"),
JSON.stringify({ agent: 42 }),
);
assert.equal(readActiveAgent(dir), null);
} finally {
cleanupTmpDir(dir);
}
});
// ---------------------------------------------------------------------------
// evaluatePreToolUse — the wired hook decision against the compiled .md
// ---------------------------------------------------------------------------
test("evaluatePreToolUse blocks an out-of-contract tool for the active agent", () => {
const dir = makeTmpDir("agent-eval");
try {
const { markdown } = compileAgent(
agent({
name: "reviewer",
description: "Review a diff.",
tools: ["Read", "Grep"], // no Write/Edit/Bash
body: "b",
}),
{ basePath: dir, specFile: "agents/reviewer.md.spec.ts" },
);
writeFileSync(join(dir, "reviewer.md"), markdown);
setActiveAgent(dir, "reviewer.md");
assert.equal(evaluatePreToolUse(dir, "Read").allow, true);
const blocked = evaluatePreToolUse(dir, "Write");
assert.equal(blocked.allow, false);
assert.match(blocked.message, /allowed-tools contract/);
} finally {
cleanupTmpDir(dir);
}
});
test("evaluatePreToolUse allows anything when no agent is active", () => {
const dir = makeTmpDir("agent-eval-none");
try {
assert.equal(evaluatePreToolUse(dir, "Bash").allow, true);
} finally {
cleanupTmpDir(dir);
}
});
test("evaluatePreToolUse allows when the active agent's .md is missing", () => {
const dir = makeTmpDir("agent-eval-missing");
try {
setActiveAgent(dir, "agents/ghost.md");
assert.equal(evaluatePreToolUse(dir, "Write").allow, true);
} finally {
cleanupTmpDir(dir);
}
});
test("evaluatePreToolUse allows everything for an inherit-all agent", () => {
const dir = makeTmpDir("agent-eval-inherit");
try {
const { markdown } = compileAgent(
agent({ name: "open", description: "d", body: "b" }), // no tools: line
{ basePath: dir, specFile: "agents/open.md.spec.ts" },
);
writeFileSync(join(dir, "open.md"), markdown);
setActiveAgent(dir, "open.md");
assert.equal(evaluatePreToolUse(dir, "Bash").allow, true);
} finally {
cleanupTmpDir(dir);
}
});
// ---------------------------------------------------------------------------
// The differentiator's invariant: hook ⇄ allowlist agree
// ---------------------------------------------------------------------------
test("the rail the hook enforces is exactly the declared contract (round-trip)", () => {
// The whole point of #54898: the `tools:` field documents intent but doesn't
// enforce it. vigiles compiles ONE source (spec.tools) into BOTH the
// frontmatter (intent) AND the list the PreToolUse hook reads (enforcement),
// so the two cannot drift. Prove it: compile → parse the frontmatter the hook
// will read → it equals the declared tools, and the hook allows exactly those.
const declared = ["Read", "Grep", "Glob", "Bash"];
const { markdown } = compileAgent(
agent({
name: "ui-visual-validator",
description: "Validate UI visually.",
tools: declared,
body: "b",
}),
{ specFile: "agents/ui-visual-validator.md.spec.ts" },
);
const enforced = parseAgentTools(markdown);
assert.deepEqual(enforced, declared); // hook reads exactly what was declared
for (const t of declared) {
assert.equal(decidePreToolUse(enforced, t).allow, true);
}
for (const t of ["Write", "Edit", "Task", "WebFetch"]) {
assert.equal(decidePreToolUse(enforced, t).allow, false); // the least-privilege rail
}
});
// ---------------------------------------------------------------------------
// agent-hook (CLI): the real PreToolUse rail process
//
// Tool-event hooks are best proven at the cheap unit tier (CLAUDE.md: runHook
// is "the only tier that reaches every event incl. ... PreToolUse"): pipe a
// real synthesized PreToolUse event to the BUILT CLI hook and assert the
// block/allow decision — deterministic, no model, no flaky live tool call.
// Requires the build (npm test / coverage build it first), like src/cli.test.ts.
// ---------------------------------------------------------------------------
const CLI = resolve(__dirname, "..", "dist", "cli.js");
/** Set up a temp project with a compiled agent and mark it active. */
function projectWithActiveAgent(tools: string[]): string {
const dir = makeTmpDir("agent-hook-cli");
const { markdown } = compileAgent(
agent({
name: "reader",
description: "read-only worker",
tools,
body: "b",
}),
{ basePath: dir, specFile: "agents/reader.md.spec.ts" },
);
writeFileSync(join(dir, "reader.md"), markdown);
setActiveAgent(dir, "reader.md");
return dir;
}
test("agent-hook CLI blocks (exit 2) an out-of-contract tool", () => {
const dir = projectWithActiveAgent(["Read", "Grep"]);
try {
const r = runHook(
`node ${CLI} agent-hook`,
{
hook_event_name: "PreToolUse",
tool_name: "Write",
tool_input: { file_path: "x.ts" },
},
{ cwd: dir },
);
assert.equal(r.blocked, true);
assert.equal(r.exitCode, 2);
assert.match(r.stderr, /allowed-tools contract/);
} finally {
cleanupTmpDir(dir);
}
});
test("agent-hook CLI allows (exit 0) an in-contract tool", () => {
const dir = projectWithActiveAgent(["Read", "Grep"]);
try {
const r = runHook(
`node ${CLI} agent-hook`,
{
hook_event_name: "PreToolUse",
tool_name: "Read",
tool_input: { file_path: "x.ts" },
},
{ cwd: dir },
);
assert.equal(r.blocked, false);
assert.equal(r.exitCode, 0);
} finally {
cleanupTmpDir(dir);
}
});
test("agent-hook CLI allows when no agent is active", () => {
const dir = makeTmpDir("agent-hook-none");
try {
const r = runHook(
`node ${CLI} agent-hook`,
{
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: { command: "rm -rf /" },
},
{ cwd: dir },
);
assert.equal(r.blocked, false);
} finally {
cleanupTmpDir(dir);
}
});
test("agent-hook CLI allows on a malformed/empty event (no tool name)", () => {
const dir = projectWithActiveAgent(["Read"]);
try {
const r = runHook(`node ${CLI} agent-hook`, {}, { cwd: dir });
assert.equal(r.blocked, false);
assert.equal(r.exitCode, 0);
} finally {
cleanupTmpDir(dir);
}
});
// ---------------------------------------------------------------------------
// Grounded in a REAL vendored subagent (not a synthetic fixture)
//
// wshobson's ui-visual-validator is pinned under examples/harness/vendor/. It
// is the documented footgun in the wild: a "rigorous visual validator" that
// "bases judgments solely on visual evidence" yet ships with NO `tools:` line —
// so it inherits EVERY tool, including Edit/Write it has no business holding.
// These assertions check the rail against that actual file, not a hand-built
// one. __dirname is src/ (unit run) or dist/ (built) — both one level under the
// repo root, so the relative vendor path resolves either way (matches
// src/vendor.test.ts).
// ---------------------------------------------------------------------------
const REAL_AGENT = join(
__dirname,
"../examples/harness/vendor/wshobson-accessibility@cf6059d/agents/ui-visual-validator.md",
);
test("real vendored subagent ships no tools: line — the rail correctly reports it inherits all", () => {
const md = readFileSync(REAL_AGENT, "utf-8");
// The wild footgun: no contract at all → parseAgentTools returns null →
// decidePreToolUse imposes no restriction. The rail honestly reports "there
// is no rail here yet" rather than inventing one — which is exactly why the
// omitted-tools authoring warning is the next roadmap item.
assert.equal(parseAgentTools(md), null);
assert.equal(decidePreToolUse(parseAgentTools(md), "Write").allow, true);
});
test("the spec form ADDS the rail the real subagent omits, and it parses + enforces", () => {
// Reconstruct the real agent AS a spec with the least-privilege contract its
// hand-written original lacks (read + run visual tests; never Edit/Write),
// compile it, then prove the SAME PreToolUse rail the hook reads now blocks
// the tools the original silently held. This is the differentiator on a real
// subagent: compile turns the missing contract into an enforced one.
const md = readFileSync(REAL_AGENT, "utf-8");
const nameLine = /^name:\s*(.+)$/m.exec(md);
const descLine = /^description:\s*(.+)$/m.exec(md);
assert.ok(nameLine && descLine); // sanity: we're reading the real frontmatter
const { markdown, errors } = compileAgent(
agent({
name: nameLine[1].trim(),
description: descLine[1].trim(),
model: "sonnet",
tools: ["Read", "Grep", "Glob", "Bash"], // the rail the original omits
body: "You are an experienced UI visual validation expert.",
}),
{ specFile: "agents/ui-visual-validator.md.spec.ts" },
);
assert.deepEqual(errors, []);
const enforced = parseAgentTools(markdown);
assert.deepEqual(enforced, ["Read", "Grep", "Glob", "Bash"]);
assert.equal(decidePreToolUse(enforced, "Bash").allow, true);
// the tools the wild original inherited but a visual validator must not hold:
assert.equal(decidePreToolUse(enforced, "Write").allow, false);
assert.equal(decidePreToolUse(enforced, "Edit").allow, false);
});
+163
View File
@@ -0,0 +1,163 @@
/**
* vigiles Agent runtime: the PreToolUse tool-contract rail.
*
* A subagent declares an allowed-tools contract in its frontmatter (`tools:`).
* But that field is documentation, not a hard runtime boundary (Claude Code
* issue #54898): permissions are session-wide, a subagent inherits the parent
* session's grants, and `tools:` only filters what's *offered* it can't deny
* what the session allows. The deterministic layer that actually closes the gap
* is a **PreToolUse hook** that blocks any tool the active agent's contract
* doesn't list.
*
* This is the same emit-a-hook pattern the skill runtime already ships
* (`src/skill-runtime.ts`): there a `Stop` hook reads the active skill's
* compiled SKILL.md and runs its result gate; here a `PreToolUse` hook reads
* the active agent's compiled `.md`, parses its `tools:` allowlist, and
* allows/denies the tool call. The compiled markdown's frontmatter is the
* single source of truth the same list that documents intent IS the list the
* hook enforces, so the two agree by construction (see `enforcedTools`).
*
* Which agent is active is recorded in `.vigiles/active-agent.json` Claude
* Code hooks don't surface the dispatched subagent, so vigiles records it
* (mirrors `.vigiles/active-skill.json`). The decision logic below is
* harness-agnostic and fully testable.
*/
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
rmSync,
} from "node:fs";
import { resolve, dirname } from "node:path";
// ---------------------------------------------------------------------------
// Parse the tool contract from a compiled agent .md
// ---------------------------------------------------------------------------
/** Extract the YAML frontmatter block (between the first pair of `---` fences). */
function extractFrontmatter(markdown: string): string | null {
const lines = markdown.split("\n");
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === "---") {
start = i;
break;
}
}
if (start === -1) return null;
for (let i = start + 1; i < lines.length; i++) {
if (lines[i].trim() === "---") {
return lines.slice(start + 1, i).join("\n");
}
}
return null;
}
/**
* Parse an agent's allowed-tools contract from its compiled markdown.
*
* Returns the list of allowed tool names, or `null` when the agent declares no
* `tools:` line at all which in Claude Code means it inherits EVERY tool (the
* #1 footgun). `null` is the "no restriction" signal the decision logic honors;
* an empty list (`tools:` with nothing after it) means "no tools allowed".
*/
export function parseAgentTools(markdown: string): string[] | null {
const fm = extractFrontmatter(markdown);
if (fm === null) return null;
const match = /^tools:[ \t]*(.*)$/m.exec(fm);
if (!match) return null;
return match[1]
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
// ---------------------------------------------------------------------------
// The pure decision: is this tool inside the contract?
// ---------------------------------------------------------------------------
export interface PreToolDecision {
/** Whether the tool call is allowed (true) or blocked (false). */
readonly allow: boolean;
/** Message fed back to the model on a block; empty on allow. */
readonly message: string;
}
/**
* Decide whether `tool` is allowed under an agent's tool contract. Pure, so the
* rail is unit-testable without spawning anything.
*
* - `allowed === null` the agent declared no `tools:` line, so it inherits
* everything and the rail imposes no restriction (allow).
* - otherwise allow iff the tool is in the allowlist; deny anything else,
* feeding the contract back to the model so it self-corrects.
*/
export function decidePreToolUse(
allowed: readonly string[] | null,
tool: string,
): PreToolDecision {
if (allowed === null) return { allow: true, message: "" };
if (allowed.includes(tool)) return { allow: true, message: "" };
const list = allowed.length > 0 ? allowed.join(", ") : "(none)";
return {
allow: false,
message:
`Tool "${tool}" is not in this subagent's allowed-tools contract ` +
`(${list}). Use only the listed tools, or widen the agent's \`tools\`.`,
};
}
// ---------------------------------------------------------------------------
// Active-agent tracking (mirrors .vigiles/active-skill.json)
// ---------------------------------------------------------------------------
const ACTIVE_PATH = ".vigiles/active-agent.json";
/** Record the subagent currently dispatched, so PreToolUse enforces its contract. */
export function setActiveAgent(cwd: string, agentPath: string): void {
const p = resolve(cwd, ACTIVE_PATH);
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, JSON.stringify({ agent: agentPath }) + "\n");
}
/** Clear the active-agent marker (the subagent finished). */
export function clearActiveAgent(cwd: string): void {
const p = resolve(cwd, ACTIVE_PATH);
if (existsSync(p)) rmSync(p);
}
/** The path of the active agent's compiled `.md`, or null when none is active. */
export function readActiveAgent(cwd: string): string | null {
const p = resolve(cwd, ACTIVE_PATH);
if (!existsSync(p)) return null;
try {
const parsed = JSON.parse(readFileSync(p, "utf-8")) as { agent?: unknown };
return typeof parsed.agent === "string" ? parsed.agent : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// PreToolUse-hook decision
// ---------------------------------------------------------------------------
/**
* PreToolUse-hook decision. If an agent is active, parse its compiled `.md`
* tool contract and allow the call only when the tool is in the allowlist;
* otherwise block and tell the model which tools it may use. With no active
* agent (or an agent that inherits all tools), always allow the rail only
* constrains agents that declared a contract.
*/
export function evaluatePreToolUse(cwd: string, tool: string): PreToolDecision {
const agentPath = readActiveAgent(cwd);
if (!agentPath) return { allow: true, message: "" };
const full = resolve(cwd, agentPath);
if (!existsSync(full)) return { allow: true, message: "" };
const allowed = parseAgentTools(readFileSync(full, "utf-8"));
return decidePreToolUse(allowed, tool);
}
+260
View File
@@ -0,0 +1,260 @@
/**
* Tests for subagent spec compilation (src/spec.ts `agent()` + src/compile.ts
* `compileAgent`). A subagent is a delegated worker with a contract a tool
* "rail" and rules so compilation verifies the tool list and the body's
* references, and emits frontmatter + an integrity hash. Model-free.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { agent, instructions, file, cmd, enforce, guidance } from "./spec.js";
import { compileAgent, adoptDiff } from "./compile.js";
import { makeTmpDir, cleanupTmpDir } from "./test-utils.js";
test("agent() sets the spec type", () => {
const a = agent({ name: "reviewer", description: "Review a diff." });
assert.equal(a._specType, "agent");
assert.equal(a.name, "reviewer");
});
test("compileAgent renders frontmatter (name/description/model/tools) + hash", () => {
const { markdown, errors } = compileAgent(
agent({
name: "reviewer",
description: "Review a diff for correctness.",
model: "sonnet",
tools: ["Read", "Grep", "Bash"],
body: "You are a careful code reviewer.",
}),
{ specFile: "agents/reviewer.md.spec.ts" },
);
assert.deepEqual(errors, []);
assert.match(markdown, /^<!-- vigiles:sha256:[a-f0-9]+ compiled from/);
assert.match(markdown, /\nname: reviewer\n/);
assert.match(markdown, /\ndescription: Review a diff for correctness\.\n/);
assert.match(markdown, /\nmodel: sonnet\n/);
assert.match(markdown, /\ntools: Read, Grep, Bash\n/);
assert.match(markdown, /You are a careful code reviewer\./);
});
test("compileAgent: minimal agent omits model/tools and has no rules section", () => {
const { markdown, errors } = compileAgent(
agent({ name: "echo", description: "Echo things.", body: "Just echo." }),
{ specFile: "agents/echo.md.spec.ts" },
);
assert.deepEqual(errors, []);
assert.doesNotMatch(markdown, /\nmodel:/);
assert.doesNotMatch(markdown, /\ntools:/);
assert.doesNotMatch(markdown, /## Rules/);
});
test("compileAgent accepts built-in and MCP tools, flags unknown with a hint", () => {
const ok = compileAgent(
agent({
name: "a",
description: "d",
tools: ["Read", "Task", "Skill", "mcp__github__issue_write"],
body: "b",
}),
{ specFile: "a.md.spec.ts" },
);
assert.deepEqual(ok.errors, []);
// a near-miss → "did you mean", and a far token → no hint
const bad = compileAgent(
agent({
name: "a",
description: "d",
tools: ["Reed", "xyzzy123"],
body: "b",
}),
{ specFile: "a.md.spec.ts" },
);
assert.equal(bad.errors.length, 2);
const reed = bad.errors.find((e) => e.message.includes('"Reed"'));
assert.ok(reed && reed.type === "unknown-tool");
assert.match(reed.message, /Did you mean "Read"\?/);
const far = bad.errors.find((e) => e.message.includes('"xyzzy123"'));
assert.ok(far && !/Did you mean/.test(far.message)); // no close match → no hint
});
test("compileAgent flags tools that are never available to a subagent", () => {
const { errors } = compileAgent(
agent({
name: "a",
description: "d",
tools: ["Read", "Agent", "ExitPlanMode"], // last two never reach a subagent
body: "b",
}),
{ specFile: "a.md.spec.ts" },
);
assert.equal(errors.length, 2);
assert.ok(
errors.every((e) => /never available to a subagent/.test(e.message)),
);
});
test("compileAgent verifies body references against the filesystem", () => {
const dir = makeTmpDir("agent");
try {
writeFileSync(join(dir, "real.ts"), "export const x = 1;\n");
const ok = compileAgent(
agent({
name: "a",
description: "d",
body: instructions`Read ${file("real.ts")}.`,
}),
{ basePath: dir, specFile: "a.md.spec.ts" },
);
assert.deepEqual(ok.errors, []);
const stale = compileAgent(
agent({
name: "a",
description: "d",
body: instructions`Read ${file("missing.ts")} and run ${cmd("npm run nope")}.`,
}),
{ basePath: dir, specFile: "a.md.spec.ts" },
);
assert.ok(stale.errors.some((e) => e.type === "stale-file"));
} finally {
cleanupTmpDir(dir);
}
});
test("compileAgent renders a Rules section the worker must follow", () => {
const { markdown, errors } = compileAgent(
agent({
name: "a",
description: "d",
rules: {
"no-floating": enforce(
"@typescript-eslint/no-floating-promises",
"Await promises.",
),
"research-first": guidance("Check the docs before guessing."),
},
}),
{ specFile: "a.md.spec.ts" },
);
assert.deepEqual(errors, []);
assert.match(markdown, /## Rules/);
assert.match(
markdown,
/\*\*Enforced by:\*\* `@typescript-eslint\/no-floating-promises`/,
);
assert.match(
markdown,
/\*\*Guidance only\*\* — Check the docs before guessing\./,
);
});
test("compileAgent flags a bad spec filename", () => {
const notSpec = compileAgent(agent({ name: "a", description: "d" }), {
specFile: "agents/reviewer.md",
});
assert.ok(notSpec.errors.some((e) => e.type === "spec-name-mismatch"));
const notMd = compileAgent(agent({ name: "a", description: "d" }), {
specFile: "reviewer.spec.ts",
});
assert.ok(notMd.errors.some((e) => e.type === "spec-name-mismatch"));
});
test("dogfood: a real OSS subagent as a spec, with the tool rail it shipped WITHOUT", () => {
// Reproduces the shape of wshobson's real `ui-visual-validator` subagent
// (examples/harness/vendor/wshobson-accessibility@.../agents/ui-visual-validator.md):
// model: sonnet, a multi-`##`-section role contract, and — critically — it
// ships with NO `tools:` line, so it inherits EVERY tool (the #1 footgun). A
// spec ADDS the least-privilege rail (read + run visual tests; never Edit/Write),
// which compile verifies. This is the value-add over the hand-written original.
const reviewer = agent({
name: "ui-visual-validator",
description:
"Rigorous visual validation expert. Use PROACTIVELY to verify UI modifications achieved their goals.",
model: "sonnet",
tools: ["Read", "Grep", "Glob", "Bash"], // the rail the original omits
body: "You are an experienced UI visual validation expert.",
sections: {
Purpose:
"Verify UI modifications, design-system compliance, and accessibility through systematic visual analysis.",
"Core Principles": [
"- Default assumption: the goal has NOT been achieved until proven.\n",
"- Base judgments solely on visual evidence, never code hints.",
],
"Forbidden Behaviors":
"- Assuming code changes automatically produce visual results.\n- Accepting 'looks different' as 'looks correct'.",
},
});
const { markdown, errors } = compileAgent(reviewer, {
specFile: "agents/ui-visual-validator.md.spec.ts",
});
assert.deepEqual(errors, []); // real content compiles clean; tools verified
assert.match(markdown, /\nname: ui-visual-validator\n/);
assert.match(markdown, /\nmodel: sonnet\n/);
assert.match(markdown, /\ntools: Read, Grep, Glob, Bash\n/); // the added rail
assert.doesNotMatch(markdown, /\btools:.*Edit/); // least-privilege: no Edit/Write
assert.match(markdown, /## Purpose/);
assert.match(markdown, /## Forbidden Behaviors/);
assert.match(
markdown,
/You are an experienced UI visual validation expert\./,
);
});
test("compileAgent rejects a section that clashes with the rules field", () => {
const { errors } = compileAgent(
agent({
name: "a",
description: "d",
sections: { rules: "this should be the rules field" },
}),
{ specFile: "a.md.spec.ts" },
);
assert.ok(errors.some((e) => e.type === "reserved-section-key"));
});
test("compileAgent verifies refs inside sections", () => {
const dir = makeTmpDir("agent-sections");
try {
const { errors } = compileAgent(
agent({
name: "a",
description: "d",
sections: {
Workflow: instructions`First read ${file("missing.ts")}.`, // stale file
},
}),
{ basePath: dir, specFile: "a.md.spec.ts" },
);
assert.ok(errors.some((e) => e.type === "stale-file"));
} finally {
cleanupTmpDir(dir);
}
});
test("adoptDiff round-trips a compiled agent (valid hash, no changes)", () => {
const dir = makeTmpDir("agent-adopt");
try {
const spec = agent({
name: "reviewer",
description: "Review a diff.",
tools: ["Read", "Grep"],
body: "Review carefully.",
});
const { markdown } = compileAgent(spec, {
basePath: dir,
specFile: "agents/reviewer.md.spec.ts",
});
writeFileSync(join(dir, "agents-reviewer.md"), markdown);
const res = adoptDiff("agents-reviewer.md", spec, dir);
assert.equal(res.changed, false);
assert.equal(res.hasHash, true);
} finally {
cleanupTmpDir(dir);
}
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it } from "node:test";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
+50 -1
View File
@@ -3,7 +3,7 @@
*
* These test the full flow: CLI init/compile/audit filesystem output.
*/
import { describe, it, before, after } from "node:test";
import { describe, it, beforeAll as before, afterAll as after } from "vitest";
import assert from "node:assert/strict";
import {
mkdtempSync,
@@ -126,6 +126,55 @@ describe("CLI: vigiles compile", () => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("should compile agent + railway specs and resolve delegate targets", () => {
const tmpDir = mkdtempSync(join(tmpdir(), "vigiles-compile-railway-"));
writeFileSync(
join(tmpDir, "package.json"),
JSON.stringify({ name: "test", scripts: { test: "echo ok" } }),
);
const specSrc = resolve(process.cwd(), "dist", "spec.js");
writeFileSync(
join(tmpDir, "worker.md.spec.ts"),
`import { agent, result } from "${specSrc}";\n` +
`export default agent({ name: "worker", description: "d", tools: ["Read"], body: "b", output: result({ summary: "string" }, { reason: "string" }) });\n`,
);
writeFileSync(
join(tmpDir, "flow.md.spec.ts"),
`import { railway, delegate } from "${specSrc}";\n` +
`export default railway({ name: "flow", steps: [delegate("worker")] });\n`,
);
const { stdout, exitCode } = run(
"compile worker.md.spec.ts flow.md.spec.ts",
tmpDir,
);
assert.equal(exitCode, 0, stdout);
const agentMd = readFileSync(join(tmpDir, "worker.md"), "utf8");
assert.ok(agentMd.includes("## Output contract"), agentMd);
assert.ok(agentMd.includes("```vigiles:ok"), agentMd);
const flowMd = readFileSync(join(tmpDir, "flow.md"), "utf8");
assert.ok(flowMd.includes("# Railway: flow"), flowMd);
assert.ok(flowMd.includes("**worker**"), flowMd);
rmSync(tmpDir, { recursive: true, force: true });
});
it("should fail when a railway delegates to an unknown agent", () => {
const tmpDir = mkdtempSync(join(tmpdir(), "vigiles-compile-railway-bad-"));
writeFileSync(
join(tmpDir, "package.json"),
JSON.stringify({ name: "test", scripts: { test: "echo ok" } }),
);
const specSrc = resolve(process.cwd(), "dist", "spec.js");
writeFileSync(
join(tmpDir, "flow.md.spec.ts"),
`import { railway, delegate } from "${specSrc}";\n` +
`export default railway({ name: "flow", steps: [delegate("ghost")] });\n`,
);
const { stdout, exitCode } = run("compile flow.md.spec.ts", tmpDir);
assert.equal(exitCode, 1, stdout);
assert.ok(stdout.includes("unknown agent"), stdout);
rmSync(tmpDir, { recursive: true, force: true });
});
it("should compile subdirectory spec to same directory", () => {
const tmpDir = mkdtempSync(join(tmpdir(), "vigiles-compile-subdir-"));
writeFileSync(
+120 -7
View File
@@ -27,19 +27,26 @@ import { ruleSeverity, ruleOptions } from "./types.js";
import {
compileClaude,
compileSkill,
compileAgent,
compileRailway,
checkFileHash,
addHash,
validateFileRef,
validateCommandRef,
} from "./compile.js";
import type { CompileError } from "./compile.js";
import type { ClaudeSpec, SkillSpec } from "./spec.js";
import type { ClaudeSpec, SkillSpec, AgentSpec, Railway } from "./spec.js";
import { findSimilarRules } from "./proofs.js";
import { parseInlineRules } from "./inline.js";
import { parseFrontmatterRules } from "./frontmatter.js";
import { generateSchema } from "./generate-schema.js";
import { compileGeneratorSkill } from "./compile-generator.js";
import { evaluateAction, loadActionGates } from "./action-gate.js";
import {
evaluatePreToolUse,
setActiveAgent,
clearActiveAgent,
} from "./agent-runtime.js";
import { verifySymbolRefs, unmarkedCodeRefs } from "./refs.js";
import { verifyMcpRefs, loadMcpServers, mcpRefMessage } from "./mcp.js";
import {
@@ -80,9 +87,9 @@ function findSpecs(pattern?: string): string[] {
});
}
async function loadSpec(
specPath: string,
): Promise<ClaudeSpec | SkillSpec | null> {
type AnySpec = ClaudeSpec | SkillSpec | AgentSpec | Railway;
async function loadSpec(specPath: string): Promise<AnySpec | null> {
const fullPath = resolve(process.cwd(), specPath);
// Try multiple dist/ path strategies
@@ -111,12 +118,12 @@ async function loadSpec(
if (existsSync(distPath)) {
try {
const mod = (await import(distPath)) as {
default: ClaudeSpec | SkillSpec | { default: ClaudeSpec | SkillSpec };
default: AnySpec | { default: AnySpec };
};
// CJS double-default: `{ default: { default: spec } }`.
const raw = mod.default;
if (raw && typeof raw === "object" && "default" in raw) {
return (raw as { default: ClaudeSpec | SkillSpec }).default;
return (raw as { default: AnySpec }).default;
}
return raw;
} catch {
@@ -137,7 +144,7 @@ async function loadSpec(
stdio: ["pipe", "pipe", "pipe"],
timeout: 15000,
});
return JSON.parse(output.trim()) as ClaudeSpec | SkillSpec;
return JSON.parse(output.trim()) as AnySpec;
} catch {
return null;
}
@@ -238,11 +245,66 @@ function compileSkillToFile(spec: SkillSpec, specPath: string): boolean {
return false;
}
/** Compile a subagent spec → agents/<name>.md (with its result-contract section). */
function compileAgentToFile(spec: AgentSpec, specPath: string): boolean {
const outputPath = specPath.replace(/\.spec\.ts$/, "");
const { markdown, errors } = compileAgent(spec, {
basePath: process.cwd(),
specFile: specPath,
});
writeFileSync(resolve(process.cwd(), outputPath), markdown);
if (errors.length === 0) {
console.log(`\n✓ ${specPath}${outputPath}`);
return true;
}
console.log(`\n✗ ${specPath}${String(errors.length)} error(s)`);
printErrors(specPath, errors);
return false;
}
/**
* Compile a railway spec the orchestrator command markdown. `knownAgents` is
* the set of compiled agent names in the project, so every `delegate()` target
* is resolved at compile time (an unknown target is a stale-ref error).
*/
function compileRailwayToFile(
spec: Railway,
specPath: string,
knownAgents: readonly string[],
): boolean {
const outputPath = specPath.replace(/\.spec\.ts$/, "");
const { markdown, errors } = compileRailway(spec, {
specFile: specPath,
knownAgents,
});
writeFileSync(resolve(process.cwd(), outputPath), markdown);
if (errors.length === 0) {
console.log(`\n✓ ${specPath}${outputPath}`);
return true;
}
console.log(`\n✗ ${specPath}${String(errors.length)} error(s)`);
printErrors(specPath, errors);
return false;
}
/** Names of every compiled agent spec in the project — resolves delegate() targets. */
async function collectAgentNames(): Promise<string[]> {
const names: string[] = [];
for (const p of findSpecs()) {
const s = await loadSpec(p);
if (s && s._specType === "agent") names.push(s.name);
}
return names;
}
async function compile(
specPaths: string[],
config: VigilesConfig,
): Promise<boolean> {
let allValid = true;
// Resolved lazily on the first railway spec — every delegate() target is
// checked against the agents defined anywhere in the project.
let knownAgents: string[] | null = null;
for (const specPath of specPaths) {
// Generator skills can't be executed to markdown — compile from source.
const source = readFileSync(resolve(process.cwd(), specPath), "utf-8");
@@ -263,6 +325,11 @@ async function compile(
if (!compileClaudeToFile(spec, specPath, config)) allValid = false;
} else if (spec._specType === "skill") {
if (!compileSkillToFile(spec, specPath)) allValid = false;
} else if (spec._specType === "agent") {
if (!compileAgentToFile(spec, specPath)) allValid = false;
} else if (spec._specType === "railway") {
knownAgents ??= await collectAgentNames();
if (!compileRailwayToFile(spec, specPath, knownAgents)) allValid = false;
}
}
return allValid;
@@ -2076,6 +2143,43 @@ function skillStartCommand(target: string | undefined): void {
console.log(`Active skill: ${target}`);
}
/**
* PreToolUse-hook entrypoint: enforce the active subagent's allowed-tools
* contract. Reads the tool event on stdin, parses the active agent's compiled
* `.md` tool rail, and blocks (exit 2 + reason on stderr) any tool outside it
* the deterministic boundary `tools:` alone can't provide (Claude Code #54898).
*/
function agentHookCommand(): void {
let raw = "";
try {
raw = readFileSync(0, "utf-8");
} catch {
/* no stdin */
}
let tool = "";
try {
tool = (JSON.parse(raw) as { tool_name?: string }).tool_name ?? "";
} catch {
/* malformed input → no tool, allow */
}
if (!tool) return;
const decision = evaluatePreToolUse(process.cwd(), tool);
if (!decision.allow) {
console.error(decision.message);
process.exit(2);
}
}
/** Mark a subagent active so the PreToolUse hook enforces its tool contract. */
function agentStartCommand(target: string | undefined): void {
if (!target) {
console.error("Usage: vigiles agent-start <agents/<name>.md>");
process.exit(2);
}
setActiveAgent(process.cwd(), target);
console.log(`Active agent: ${target}`);
}
/** Dispatch the skill-runtime subcommands. Returns false if unrecognized. */
function handleSkillCommand(command: string, restArgs: string[]): boolean {
switch (command) {
@@ -2091,6 +2195,15 @@ function handleSkillCommand(command: string, restArgs: string[]): boolean {
case "skill-hook":
skillHookCommand();
return true;
case "agent-start":
agentStartCommand(restArgs[0]);
return true;
case "agent-done":
clearActiveAgent(process.cwd());
return true;
case "agent-hook":
agentHookCommand();
return true;
case "action-hook":
actionHookCommand();
return true;
+1 -1
View File
@@ -3,7 +3,7 @@
* scripted model and assert their control flow. If these (esp. pr-review-loop)
* run and assert cleanly, the generator form + skill-test cover the deep tail.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
+1 -1
View File
@@ -3,7 +3,7 @@
* rendering steps / gates / branches / loops to markdown, plus verifying the
* gate references it carries.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import {
+332 -3
View File
@@ -14,14 +14,19 @@ import { fileDefinesSymbol, langForFile } from "./symbols.js";
import type {
ClaudeSpec,
SkillSpec,
AgentSpec,
SkillInput,
SkillStep,
Gate,
Rule,
InstructionFragment,
OutputContract,
OutputFieldType,
Railway,
RailwayStep,
} from "./spec.js";
import { checkLinterRule, extractLinterName } from "./linters.js";
import { checkLinterRule, extractLinterName, editDistance } from "./linters.js";
import type { LinterCheckResult } from "./linters.js";
// ---------------------------------------------------------------------------
@@ -85,7 +90,9 @@ export interface CompileError {
| "section-too-long"
| "section-has-header"
| "reserved-section-key"
| "spec-name-mismatch";
| "spec-name-mismatch"
| "unknown-tool"
| "invalid-railway";
message: string;
path?: string;
}
@@ -805,6 +812,325 @@ export function compileSkill(
return { markdown: addHash(content, specFile), errors };
}
// ---------------------------------------------------------------------------
// Compile a subagent spec → agents/<name>.md
// ---------------------------------------------------------------------------
// The tool contract a subagent may declare — the rails it runs on. Anything
// else must be an MCP tool (mcp__server__tool), else it's a typo / nonexistent
// tool the dispatched worker could never call.
const KNOWN_AGENT_TOOLS = [
"Read",
"Write",
"Edit",
"Bash",
"Grep",
"Glob",
"WebSearch",
"WebFetch",
"NotebookEdit",
"TodoWrite",
"Task",
"Skill",
] as const;
const MCP_TOOL_RE = /^mcp__[a-z0-9_-]+__[a-z0-9_-]+$/i;
// Tools the platform never exposes to a subagent, whatever the list says — so a
// subagent listing one is a guaranteed-dead reference only a compiler catches.
const NEVER_AVAILABLE_TOOLS = new Set([
"Agent",
"AskUserQuestion",
"EnterPlanMode",
"ExitPlanMode",
"ScheduleWakeup",
"WaitForMcpServers",
]);
/** Closest known tool by edit distance (≤ 3), for a "did you mean" hint. */
function closestTool(tool: string): string | null {
let best: string | null = null;
let bestDistance = Infinity;
for (const known of KNOWN_AGENT_TOOLS) {
const d = editDistance(tool.toLowerCase(), known.toLowerCase());
if (d < bestDistance) {
bestDistance = d;
best = known;
}
}
return bestDistance <= 3 ? best : null;
}
/** Verify a subagent's allowed-tools contract — the rails are real tools. */
function validateAgentTools(tools: readonly string[]): CompileError[] {
const errors: CompileError[] = [];
for (const tool of tools) {
if (NEVER_AVAILABLE_TOOLS.has(tool)) {
errors.push({
type: "unknown-tool",
message: `Tool "${tool}" is never available to a subagent — remove it from the tools list.`,
});
continue;
}
if ((KNOWN_AGENT_TOOLS as readonly string[]).includes(tool)) continue;
if (MCP_TOOL_RE.test(tool)) continue;
const near = closestTool(tool);
const hint = near ? ` Did you mean "${near}"?` : "";
errors.push({
type: "unknown-tool",
message: `Unknown tool "${tool}" in agent tools — use a built-in tool (${KNOWN_AGENT_TOOLS.join(", ")}) or an MCP tool (mcp__server__tool).${hint}`,
});
}
return errors;
}
/** Build the subagent YAML frontmatter (name / description / model / tools). */
function renderAgentFrontmatter(spec: AgentSpec): string {
const fm = [
"---",
"",
`name: ${spec.name}`,
`description: ${spec.description}`,
];
if (spec.model !== undefined) fm.push(`model: ${spec.model}`);
if (spec.tools && spec.tools.length > 0) {
fm.push(`tools: ${spec.tools.join(", ")}`);
}
fm.push("", "---");
return fm.join("\n");
}
/** Render the subagent's named `##` system-prompt sections (verified like CLAUDE.md). */
function renderAgentSections(
sections: Record<string, string | InstructionFragment[]>,
basePath: string,
): SectionResult {
const lines: string[] = [];
const errors: CompileError[] = [];
for (const [name, content] of Object.entries(sections)) {
if (name.toLowerCase() === "rules") {
errors.push({
type: "reserved-section-key",
message: `Section key "${name}" is reserved — use the \`rules\` field instead.`,
});
}
const heading = name.charAt(0).toUpperCase() + name.slice(1);
if (typeof content === "string") {
errors.push(...validateSectionContent(name, content));
lines.push(`## ${heading}\n\n${content.trim()}`);
} else {
errors.push(...validateRefs(content, basePath));
const rendered = content.map(renderFragment).join("");
errors.push(...validateSectionContent(name, rendered));
lines.push(`## ${heading}\n\n${rendered.trim()}`);
}
}
return { lines, errors };
}
/** Render a result-contract track shape as a compact `{ "f": type, … }` line. */
function renderShape(shape: Readonly<Record<string, OutputFieldType>>): string {
const fields = Object.entries(shape)
.map(([k, t]) => `"${k}": ${t}`)
.join(", ");
return fields ? `{ ${fields} }` : "{}";
}
/**
* Render the subagent's typed result contract the `## Output contract` section
* that turns a flat worker into a railway step: it must end its turn with a
* `vigiles:ok` / `vigiles:err` block matching one of these shapes, so its
* outcome is parseable (`parseAgentResult`) and testable (`assertAgentOk`).
*/
function renderOutputContract(contract: OutputContract): string {
return [
"## Output contract",
"",
"Finish your turn with exactly one fenced block — success or error — matching one of these shapes.",
"",
"On success:",
"",
"```vigiles:ok",
renderShape(contract.ok),
"```",
"",
"On error:",
"",
"```vigiles:err",
renderShape(contract.err),
"```",
].join("\n");
}
/** Render the rules a subagent must follow as a `## Rules` section. */
function renderAgentRules(rules: Record<string, Rule>): string {
const parts = ["## Rules", ""];
for (const [id, rule] of Object.entries(rules)) {
parts.push(compileRule(id, rule), "");
}
return parts.join("\n").trim();
}
export interface CompileAgentResult {
markdown: string;
errors: CompileError[];
}
/**
* Compile an AgentSpec into a subagent markdown file with YAML frontmatter.
* Verifies the tool contract and the body's references; the marks the body
* carries (`vigiles:symbol`, file/cmd refs) are the same ones `audit` re-checks.
*/
export function compileAgent(
spec: AgentSpec,
options: { basePath?: string; specFile?: string } = {},
): CompileAgentResult {
const basePath = options.basePath ?? process.cwd();
const specFile = options.specFile ?? "agent.md.spec.ts";
const errors: CompileError[] = [];
if (!specFile.endsWith(".spec.ts")) {
errors.push({
type: "spec-name-mismatch",
message: `Spec file "${specFile}" must end with .spec.ts`,
});
} else if (!/\.md$/i.test(basename(specFile, ".spec.ts"))) {
errors.push({
type: "spec-name-mismatch",
message: `Spec file "${specFile}" should be named <output>.spec.ts (e.g., agents/reviewer.md.spec.ts)`,
});
}
if (spec.tools) errors.push(...validateAgentTools(spec.tools));
if (Array.isArray(spec.body)) {
errors.push(...validateRefs(spec.body, basePath));
}
const sections: string[] = [];
if (spec.body !== undefined) sections.push(renderBody(spec.body).trim());
if (spec.sections) {
const result = renderAgentSections(spec.sections, basePath);
sections.push(...result.lines);
errors.push(...result.errors);
}
if (spec.rules && Object.keys(spec.rules).length > 0) {
sections.push(renderAgentRules(spec.rules));
}
if (spec.output) sections.push(renderOutputContract(spec.output));
const body = sections.join("\n\n");
errors.push(...checkInlineCode(body, DEFAULT_MAX_INLINE_CODE_LINES));
const content = renderAgentFrontmatter(spec) + "\n\n" + body.trim() + "\n";
return { markdown: addHash(content, specFile), errors };
}
// ---------------------------------------------------------------------------
// Compile a railway → an orchestrator command
//
// A railway composes flat subagents on a success/error track. It compiles to an
// orchestrator command the lead agent reads — NOT a runtime engine (vigiles
// verifies + emits; the agent executes; the per-step rails enforce). Every
// delegate target is resolved against the known agent set (stale-ref), the
// step list must be non-empty, and recovery must be bounded — the finite,
// sub-Turing guarantees that make the whole flow checkable.
// ---------------------------------------------------------------------------
export interface CompileRailwayOptions {
/** Names of compiled agents, to resolve `delegate` targets. Skipped if omitted. */
knownAgents?: readonly string[];
specFile?: string;
}
export interface CompileRailwayResult {
markdown: string;
errors: CompileError[];
}
/** Verify a railway: non-empty, bounded recovery, every delegate target real. */
export function validateRailway(
rw: Railway,
knownAgents?: readonly string[],
): CompileError[] {
const errors: CompileError[] = [];
if (rw.steps.length === 0) {
errors.push({
type: "invalid-railway",
message: `Railway "${rw.name}" has no steps.`,
});
}
if (rw.recover && rw.recover.max < 1) {
errors.push({
type: "invalid-railway",
message: `Railway "${rw.name}" recover.max must be ≥ 1 (got ${String(rw.recover.max)}).`,
});
}
if (knownAgents) {
const known = new Set(knownAgents);
const refs: RailwayStep[] = [...rw.steps];
if (rw.onError) refs.push(rw.onError);
if (rw.recover) refs.push(rw.recover.step);
for (const s of refs) {
if (!known.has(s.agent)) {
errors.push({
type: "stale-ref",
message: `Railway "${rw.name}" delegates to unknown agent "${s.agent}".`,
path: s.agent,
});
}
}
}
return errors;
}
/** Render the orchestrator command markdown for a railway. */
function renderRailwayMarkdown(rw: Railway): string {
const lines = [
`# Railway: ${rw.name}`,
"",
"Dispatch these subagents on the **success track**, in order. Each returns a " +
"result block (`vigiles:ok` / `vigiles:err`). If a step returns an error, " +
"stop the success track and run the error handler with that error payload.",
"",
"## Success track",
"",
];
rw.steps.forEach((s, i) => {
const task = s.task ? `${s.task}` : "";
lines.push(`${String(i + 1)}. **${s.agent}**${task}`);
});
if (rw.recover) {
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.`,
);
}
if (rw.onError) {
lines.push(
"",
"## On error",
"",
`Run **${rw.onError.agent}** with the failing step's error payload.`,
);
}
return lines.join("\n");
}
/**
* Compile a railway into an orchestrator command markdown (with integrity hash),
* resolving every delegate target against `knownAgents` when provided.
*/
export function compileRailway(
rw: Railway,
options: CompileRailwayOptions = {},
): CompileRailwayResult {
const errors = validateRailway(rw, options.knownAgents);
const specFile = options.specFile ?? `${rw.name}.railway.spec.ts`;
const content = renderRailwayMarkdown(rw) + "\n";
return { markdown: addHash(content, specFile), errors };
}
// ---------------------------------------------------------------------------
// Hash check for existing files
// ---------------------------------------------------------------------------
@@ -850,7 +1176,7 @@ export interface AdoptResult {
*/
export function adoptDiff(
filePath: string,
spec: ClaudeSpec | SkillSpec,
spec: ClaudeSpec | SkillSpec | AgentSpec,
basePath: string,
): AdoptResult {
const fullPath = resolve(basePath, filePath);
@@ -868,6 +1194,9 @@ export function adoptDiff(
} else if (spec._specType === "skill") {
const { markdown } = compileSkill(spec, { basePath, specFile: filePath });
compiledContent = markdown;
} else if (spec._specType === "agent") {
const { markdown } = compileAgent(spec, { basePath, specFile: filePath });
compiledContent = markdown;
}
// Simple line-based diff
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, before, after } from "node:test";
import { describe, it, beforeAll as before, afterAll as after } from "vitest";
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it } from "node:test";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
+107
View File
@@ -0,0 +1,107 @@
/**
* Tests for the eval record/replay cache (src/eval-cache.ts) the pure key,
* record I/O, and filesystem snapshot/restore. Model-free.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import {
cacheKey,
readCache,
writeCache,
snapshotDir,
restoreDir,
type CacheKeyInput,
} from "./eval-cache.js";
import { makeTmpDir, cleanupTmpDir } from "./test-utils.js";
const baseKey: CacheKeyInput = {
task: "do it",
model: "haiku",
tools: ["Read", "Edit"],
files: { "a.txt": "x" },
settings: { hooks: {} },
trialIndex: 0,
};
test("cacheKey is stable and order-independent for object inputs", () => {
const k1 = cacheKey(baseKey);
// same logical input, object keys in a different order → same key
const k2 = cacheKey({
trialIndex: 0,
settings: { hooks: {} },
files: { "a.txt": "x" },
tools: ["Read", "Edit"],
model: "haiku",
task: "do it",
});
assert.equal(k1, k2);
});
test("cacheKey changes when any model-affecting input changes", () => {
const base = cacheKey(baseKey);
assert.notEqual(base, cacheKey({ ...baseKey, trialIndex: 1 }));
assert.notEqual(base, cacheKey({ ...baseKey, task: "other" }));
assert.notEqual(base, cacheKey({ ...baseKey, files: { "a.txt": "y" } }));
// tool order is significant (arrays keep order)
assert.notEqual(base, cacheKey({ ...baseKey, tools: ["Edit", "Read"] }));
});
test("readCache returns null on miss and on malformed records", () => {
const dir = makeTmpDir("cache");
try {
const key = cacheKey(baseKey);
assert.equal(readCache(dir, key), null); // miss
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${key}.json`), "{ not json");
assert.equal(readCache(dir, key), null); // malformed
} finally {
cleanupTmpDir(dir);
}
});
test("writeCache then readCache round-trips a record", () => {
const dir = makeTmpDir("cache");
try {
const key = cacheKey(baseKey);
const record = {
out: { code: 0, stdout: "stream" },
files: { "OUT.txt": "result" },
};
writeCache(dir, key, record);
const got = readCache(dir, key);
assert.deepEqual(got, record);
} finally {
cleanupTmpDir(dir);
}
});
test("snapshotDir captures nested text files; restoreDir rebuilds them", () => {
const src = makeTmpDir("snap-src");
const dst = makeTmpDir("snap-dst");
try {
mkdirSync(join(src, "sub"), { recursive: true });
writeFileSync(join(src, "top.txt"), "top");
writeFileSync(join(src, "sub", "deep.txt"), "deep");
// node_modules is skipped
mkdirSync(join(src, "node_modules"), { recursive: true });
writeFileSync(join(src, "node_modules", "skip.txt"), "skip");
const snap = snapshotDir(src);
assert.equal(snap["top.txt"], "top");
assert.equal(snap[join("sub", "deep.txt")], "deep");
assert.ok(
!(join("node_modules", "skip.txt") in snap),
"node_modules skipped",
);
restoreDir(dst, snap);
assert.ok(existsSync(join(dst, "top.txt")));
assert.ok(existsSync(join(dst, "sub", "deep.txt")));
} finally {
cleanupTmpDir(src);
cleanupTmpDir(dst);
}
});
+123
View File
@@ -0,0 +1,123 @@
/**
* vigiles record/replay cache for the eval tier.
*
* A real-model eval is slow and costs money, yet most iteration is on the
* `measure` function, not the model call. This cache records each trial's raw
* output AND its post-run filesystem, keyed on everything that determines the
* model's behaviour `task`, the resolved fixture files + settings, model,
* tools, and the trial index but DELIBERATELY NOT the `measure` function. So
* editing your metric and re-running re-scores the captured runs for free; the
* model is only re-called when a model-affecting input changes (or `cache:"off"`,
* which always re-samples for a fresh statistic).
*
* Restoring the post-run filesystem is what makes replay *sound*: `measure`
* routinely reads agent-produced files via `ctx.file()` / `ctx.sh("grep …")`, so
* a stdout-only cache would silently mis-score on replay. We snapshot the cwd's
* text files after the run and restore them into a fresh dir before re-scoring.
*/
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
readdirSync,
statSync,
} from "node:fs";
import { join, relative, resolve, dirname } from "node:path";
import { sha256short, type SHA256Hash } from "./hash.js";
import type { RunOut } from "./eval.js";
/** Cache behaviour: never touch the cache / read-only / read-and-write. */
export type CacheMode = "off" | "read" | "readwrite";
/** Everything that determines a trial's model output (the cache key inputs). */
export interface CacheKeyInput {
readonly task: string;
readonly model: string;
readonly tools: readonly string[];
/** The resolved fixture + arm + plugin files written before the run. */
readonly files: Record<string, string>;
/** The resolved `.claude/settings.json` for the arm (or undefined). */
readonly settings: unknown;
/** Which trial this is — distinct trials are distinct samples, cached apart. */
readonly trialIndex: number;
}
/** A recorded trial: its raw output plus the post-run cwd snapshot. */
export interface CacheRecord {
readonly out: RunOut;
/** Text files present in the cwd after the run (relative path → contents). */
readonly files: Record<string, string>;
}
const MAX_SNAPSHOT_FILE_BYTES = 1024 * 1024;
const SKIP_DIRS = new Set(["node_modules", ".git"]);
/**
* Canonicalize a value so the key is stable regardless of object key order
* recursively sorts object keys. Arrays keep order (it's significant for tools).
*/
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value !== null && typeof value === "object") {
const obj = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const k of Object.keys(obj).sort()) out[k] = canonical(obj[k]);
return out;
}
return value;
}
/** Deterministic content hash of the key inputs (order-independent). */
export function cacheKey(input: CacheKeyInput): SHA256Hash {
return sha256short(JSON.stringify(canonical(input)));
}
/** Read a cached record by key, or null on miss / unreadable / malformed. */
export function readCache(dir: string, key: SHA256Hash): CacheRecord | null {
const path = join(dir, `${key}.json`);
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, "utf-8")) as CacheRecord;
} catch {
return null;
}
}
/** Write a cached record by key (creating the cache dir as needed). */
export function writeCache(
dir: string,
key: SHA256Hash,
record: CacheRecord,
): void {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${key}.json`), JSON.stringify(record));
}
/** Snapshot the text files under `cwd` as `relativePath → contents` (bounded). */
export function snapshotDir(cwd: string): Record<string, string> {
const out: Record<string, string> = {};
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry)) continue;
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full);
else if (st.isFile() && st.size <= MAX_SNAPSHOT_FILE_BYTES) {
out[relative(cwd, full)] = readFileSync(full, "utf-8");
}
}
};
walk(resolve(cwd));
return out;
}
/** Restore a snapshot into `cwd`, recreating directories as needed. */
export function restoreDir(cwd: string, files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
const full = resolve(cwd, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content);
}
}
+467 -9
View File
@@ -1,12 +1,34 @@
/**
* Tests for the eval aggregation/formatting (deterministic, no model). The full
* `runEval` drives the real `claude` CLI and is exercised by the `bench/`
* harness rather than the unit suite.
* Tests for the eval aggregation/formatting + orchestration (deterministic, no
* model). `runEval` itself spawns the real `claude` CLI (bench/ exercises that);
* `runEvalWith` takes an injected runner, so the loop / `measure` context /
* aggregation are tested here against canned stream-json no model.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { aggregate, aggregateStats, formatEvalReport } from "./eval.js";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import {
aggregate,
aggregateStats,
aggregateUsage,
parseUsage,
formatEvalReport,
runEvalWith,
runPool,
isRateLimited,
measureTriggerRateWith,
formatTriggerRateReport,
type AgentRunArgs,
} from "./eval.js";
import {
usedTool,
outputContains,
assertTriggerRate,
} from "./harness-assert.js";
import { makeTmpDir, cleanupTmpDir } from "./test-utils.js";
test("aggregateStats reports mean, sample std, se, and n", () => {
const s = aggregateStats([{ x: 2 }, { x: 4 }, { x: 6 }]);
@@ -23,6 +45,18 @@ test("aggregateStats gives std 0 for a single observation", () => {
assert.equal(s.x.se, 0);
});
test("aggregateStats reports pass^k: 1 only when every trial succeeds", () => {
// booleans: all true → passK 1; any false → 0
const all = aggregateStats([{ ok: true }, { ok: true }]);
assert.equal(all.ok.passK, 1);
const some = aggregateStats([{ ok: true }, { ok: false }]);
assert.equal(some.ok.passK, 0);
// counts: a trial succeeds when > 0
const counts = aggregateStats([{ marks: 2 }, { marks: 0 }]);
assert.equal(counts.marks.passK, 0);
assert.equal(aggregateStats([{ marks: 1 }, { marks: 3 }]).marks.passK, 1);
});
test("aggregate averages numbers and takes the true-fraction of booleans", () => {
const agg = aggregate([
{ marks: 2, caught: true },
@@ -39,13 +73,408 @@ test("aggregate tolerates missing keys across rows", () => {
assert.equal(agg.b, 1);
});
test("runEvalWith drives arms × trials via an injected runner (no model)", async () => {
// Canned stream-json: the `on` arm reports a Skill tool_use, a hook firing,
// and a result with num_turns + answer; the `off` arm reports a bare result
// (no tool / hook / num_turns / answer) — exercising both makeContext branches.
const onStream = [
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "tool_use", id: "t1", name: "Skill", input: {} }],
},
}),
JSON.stringify({
type: "system",
subtype: "hook_response",
hook_name: "Stop",
hook_event: "Stop",
exit_code: 0,
outcome: "success",
output: "",
}),
JSON.stringify({ type: "result", result: "answer is on", num_turns: 2 }),
].join("\n");
const offStream = JSON.stringify({ type: "result" }); // no num_turns/result
const seen: AgentRunArgs[] = [];
const fakeRunner = (
a: AgentRunArgs,
): Promise<{ code: number; stdout: string }> => {
seen.push(a);
return Promise.resolve({
code: 0,
stdout: a.hasSettings ? onStream : offStream,
});
};
const report = await runEvalWith(
{
fixture: { "a.txt": "hi" },
arms: {
off: {},
on: {
settings: {
hooks: {
Stop: [{ hooks: [{ type: "command", command: "true" }] }],
},
},
},
},
task: "do it",
trials: 2,
spacingSec: 0,
measure: (ctx) => ({
used: usedTool(ctx, "Skill"),
turns: ctx.turns,
sawFile: ctx.file("a.txt") !== null,
missing: ctx.file("nope.txt") === null,
shOk: ctx.sh("echo hi") === "hi",
// failing command WITH stdout → catch returns the partial stdout
shPartial: ctx.sh("echo part; exit 1") === "part",
// failing command WITHOUT stdout → catch returns ""
shEmpty: ctx.sh("exit 7") === "",
onArm: outputContains(ctx, "answer is on"),
}),
},
fakeRunner,
);
assert.equal(seen.length, 4); // 2 arms × 2 trials
assert.equal(report.arms.off?.runs, 2);
assert.equal(report.arms.on?.runs, 2);
// `on` arm: Skill used, 2 turns, answer present → all true (pass^k = 1)
assert.equal(report.arms.on?.metrics.used, 1);
assert.equal(report.arms.on?.metrics.turns, 2);
assert.equal(report.arms.on?.stats.used?.passK, 1);
// `off` arm: no Skill, 0 turns, no answer
assert.equal(report.arms.off?.metrics.used, 0);
assert.equal(report.arms.off?.metrics.turns, 0);
// both arms: the fixture file is present, sh try/catch all hold
assert.equal(report.arms.off?.metrics.sawFile, 1);
assert.equal(report.arms.off?.metrics.shOk, 1);
assert.equal(report.arms.off?.metrics.shPartial, 1);
assert.equal(report.arms.off?.metrics.shEmpty, 1);
});
test("runEvalWith honors provided optionals (name/model/tools/timeout, arm.files)", async () => {
const runner = (): Promise<{ code: number; stdout: string }> =>
Promise.resolve({
code: 0,
stdout: JSON.stringify({ type: "result", result: "r", num_turns: 1 }),
});
const report = await runEvalWith(
{
name: "custom",
fixture: { "base.txt": "b" },
arms: { a: { files: { "extra.txt": "e" } } }, // arm.files spread branch
task: "t",
trials: 1,
model: "sonnet",
allowedTools: ["Read"],
timeoutMs: 1000,
spacingSec: 0,
measure: (ctx) => ({
both: ctx.file("base.txt") !== null && ctx.file("extra.txt") !== null,
}),
},
runner,
);
assert.equal(report.name, "custom"); // spec.name provided
assert.equal(report.arms.a?.metrics.both, 1);
});
test("measureTriggerRateWith aggregates per-prompt and overall trigger rate", async () => {
const skillStream =
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "tool_use", id: "t1", name: "Skill", input: {} }],
},
}) +
"\n" +
JSON.stringify({ type: "result", num_turns: 1 });
const plainStream = JSON.stringify({ type: "result", num_turns: 1 });
const seen: AgentRunArgs[] = [];
const runner = (
a: AgentRunArgs,
): Promise<{ code: number; stdout: string }> => {
seen.push(a);
// prompts containing "fire" trigger the Skill; the rest don't
return Promise.resolve({
code: 0,
stdout: a.task.includes("fire") ? skillStream : plainStream,
});
};
const report = await measureTriggerRateWith(
{
pluginDir: "/some/plugin",
prompts: ["fire one", "ignore this", "fire two"],
fired: (t) => usedTool(t, "Skill"),
trials: 2,
spacingSec: 0,
},
runner,
);
assert.equal(report.n, 6); // 3 prompts × 2 trials
assert.equal(seen.length, 6);
assert.equal(seen[0]?.pluginDir, "/some/plugin"); // pluginDir forwarded
assert.ok(Math.abs(report.rate - 4 / 6) < 1e-9); // 2 firing prompts × 2 trials
const fireOne = report.perPrompt.find((p) => p.prompt === "fire one");
assert.equal(fireOne?.fired, 2);
assert.equal(fireOne?.rate, 1);
const ignore = report.perPrompt.find((p) => p.prompt === "ignore this");
assert.equal(ignore?.fired, 0);
assert.equal(ignore?.rate, 0);
assert.ok(formatTriggerRateReport(report).includes("trigger-rate: 67%"));
});
test("parseUsage pulls cost/latency/tokens from the result event", () => {
const stdout = JSON.stringify({
type: "result",
total_cost_usd: 0.02,
duration_ms: 900,
usage: { input_tokens: 120, output_tokens: 30 },
});
const u = parseUsage(stdout);
assert.equal(u.costUsd, 0.02);
assert.equal(u.durationMs, 900);
assert.equal(u.inputTokens, 120);
assert.equal(u.outputTokens, 30);
});
test("parseUsage is all-zero when no result/usage is present", () => {
const u = parseUsage("");
assert.equal(u.costUsd, 0);
assert.equal(u.durationMs, 0);
assert.equal(u.inputTokens, 0);
assert.equal(u.outputTokens, 0);
});
test("aggregateUsage totals and averages cost/latency/tokens", () => {
const u = aggregateUsage([
{ costUsd: 0.01, durationMs: 1000, inputTokens: 100, outputTokens: 50 },
{ costUsd: 0.03, durationMs: 2000, inputTokens: 200, outputTokens: 150 },
]);
assert.ok(Math.abs(u.totalCostUsd - 0.04) < 1e-9);
assert.ok(Math.abs(u.meanCostUsd - 0.02) < 1e-9);
assert.equal(u.meanDurationMs, 1500);
assert.equal(u.totalInputTokens, 300);
assert.equal(u.totalOutputTokens, 200);
});
test("aggregateUsage is all-zero for no runs", () => {
const u = aggregateUsage([]);
assert.equal(u.totalCostUsd, 0);
assert.equal(u.meanCostUsd, 0);
assert.equal(u.meanDurationMs, 0);
});
test("runEvalWith record/replay cache: replays without re-calling the model", async () => {
const cacheDir = makeTmpDir("eval-cache");
const resultStream = JSON.stringify({
type: "result",
num_turns: 1,
result: "done",
total_cost_usd: 0.01,
duration_ms: 1200,
usage: { input_tokens: 100, output_tokens: 50 },
});
let calls = 0;
const recordingRunner = (
a: AgentRunArgs,
): Promise<{ code: number; stdout: string }> => {
calls++;
writeFileSync(join(a.cwd, "OUT.txt"), "agent output"); // a side-effect to snapshot
return Promise.resolve({ code: 0, stdout: resultStream });
};
const spec = {
fixture: { "in.txt": "x" },
arms: { only: {} },
task: "do it",
trials: 2,
spacingSec: 0,
cacheDir,
measure: (ctx: { file: (p: string) => string | null }) => ({
created: ctx.file("OUT.txt") !== null,
}),
};
try {
const r1 = await runEvalWith(
{ ...spec, cache: "readwrite" as const },
recordingRunner,
);
assert.equal(calls, 2); // model called for both trials
assert.equal(r1.arms.only?.metrics.created, 1);
assert.ok(Math.abs(r1.totalCostUsd - 0.02) < 1e-9); // 2 × $0.01
assert.equal(r1.arms.only?.usage.totalInputTokens, 200);
// second run, read-only, with a runner that throws if called → must replay
const boom = (): Promise<{ code: number; stdout: string }> => {
throw new Error("runner should not be called on a cache hit");
};
const r2 = await runEvalWith({ ...spec, cache: "read" as const }, boom);
assert.equal(r2.arms.only?.metrics.created, 1); // OUT.txt restored → measure sees it
assert.ok(Math.abs(r2.totalCostUsd - 0.02) < 1e-9); // replayed usage
} finally {
cleanupTmpDir(cacheDir);
}
});
test("runPool maps with bounded concurrency, preserving order", async () => {
let inFlight = 0;
let maxInFlight = 0;
const worker = async (n: number): Promise<number> => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
return n * 2;
};
const out = await runPool([1, 2, 3, 4, 5], 2, worker);
assert.deepEqual(out, [2, 4, 6, 8, 10]); // order preserved
assert.equal(maxInFlight, 2); // never exceeded, did reach the limit
});
test("isRateLimited detects rate-limit / overload in either stream", () => {
assert.ok(
isRateLimited({ code: 1, stdout: "", stderr: "Error: 429 happened" }),
);
assert.ok(isRateLimited({ code: 1, stdout: "overloaded_error" }));
assert.ok(isRateLimited({ code: 1, stdout: "rate limit exceeded" }));
assert.ok(!isRateLimited({ code: 0, stdout: "all good" }));
});
test("runEvalWith retries a rate-limited run, then succeeds", async () => {
let calls = 0;
const runner = (): Promise<{ code: number; stdout: string }> => {
calls++;
const stdout =
calls === 1
? "rate limit exceeded"
: JSON.stringify({ type: "result", num_turns: 1, result: "ok" });
return Promise.resolve({ code: 0, stdout });
};
const report = await runEvalWith(
{
arms: { only: {} },
task: "t",
trials: 1,
spacingSec: 0,
retryBackoffMs: 0,
measure: (ctx) => ({ turns: ctx.turns }),
},
runner,
);
assert.equal(calls, 2); // retried once
assert.equal(report.arms.only?.metrics.turns, 1);
});
test("runEvalWith gives up after rateLimitRetries=0 (no retry)", async () => {
let calls = 0;
const runner = (): Promise<{ code: number; stdout: string }> => {
calls++;
return Promise.resolve({ code: 0, stdout: "rate limit" });
};
await runEvalWith(
{
arms: { only: {} },
task: "t",
trials: 1,
spacingSec: 0,
rateLimitRetries: 0,
retryBackoffMs: 0,
measure: () => ({ ok: true }),
},
runner,
);
assert.equal(calls, 1); // gave up immediately
});
test("runEvalWith honors concurrency and inter-run spacing", async () => {
let inFlight = 0;
let maxInFlight = 0;
const stream = JSON.stringify({ type: "result", num_turns: 1, result: "ok" });
const runner = async (): Promise<{ code: number; stdout: string }> => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
return { code: 0, stdout: stream };
};
await runEvalWith(
{
arms: { a: {}, b: {} },
task: "t",
trials: 3,
spacingSec: 0.001, // > 0 → exercises the spacing path
concurrency: 3,
measure: () => ({ ok: true }),
},
runner,
);
assert.equal(maxInFlight, 3); // 6 units, 3 in flight at once
});
test("runEvalWith aborts when maxCostUsd is exceeded", async () => {
const stream = JSON.stringify({
type: "result",
num_turns: 1,
result: "ok",
total_cost_usd: 0.1,
});
let calls = 0;
const runner = (): Promise<{ code: number; stdout: string }> => {
calls++;
return Promise.resolve({ code: 0, stdout: stream });
};
const report = await runEvalWith(
{
arms: { only: {} },
task: "t",
trials: 5,
spacingSec: 0,
maxCostUsd: 0.15, // exceeded after 2 trials ($0.20)
measure: () => ({ ok: true }),
},
runner,
);
assert.equal(report.aborted, true);
assert.equal(calls, 2); // stopped early
assert.equal(report.arms.only?.runs, 2); // only completed trials counted
assert.ok(Math.abs(report.totalCostUsd - 0.2) < 1e-9);
});
test("assertTriggerRate gates on the minimum rate", () => {
const report = { rate: 0.5, n: 4, perPrompt: [] };
assert.doesNotThrow(() => {
assertTriggerRate(report, { min: 0.5 });
});
assert.throws(() => {
assertTriggerRate(report, { min: 0.8 });
});
});
const NO_USAGE = {
totalCostUsd: 0,
meanCostUsd: 0,
meanDurationMs: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
} as const;
test("formatEvalReport renders one line per arm", () => {
const out = formatEvalReport({
name: "demo",
trials: 6,
totalCostUsd: 0,
aborted: false,
arms: {
vanilla: { runs: 6, metrics: { caught: 0 }, stats: {} },
gated: { runs: 6, metrics: { caught: 0.5 }, stats: {} },
vanilla: { runs: 6, metrics: { caught: 0 }, stats: {}, usage: NO_USAGE },
gated: { runs: 6, metrics: { caught: 0.5 }, stats: {}, usage: NO_USAGE },
},
});
assert.match(out, /demo \(6 trials\/arm\)/);
@@ -53,17 +482,46 @@ test("formatEvalReport renders one line per arm", () => {
assert.match(out, /gated\s+caught=0\.50/);
});
test("formatEvalReport shows ± se when stats are present", () => {
test("formatEvalReport shows ± se and pass^k when stats are present", () => {
const out = formatEvalReport({
name: "demo",
trials: 3,
totalCostUsd: 0,
aborted: false,
arms: {
gated: {
runs: 3,
metrics: { caught: 0.5 },
stats: { caught: { mean: 0.5, std: 0.5, se: 0.25, n: 3 } },
stats: { caught: { mean: 0.5, std: 0.5, se: 0.25, n: 3, passK: 0 } },
usage: NO_USAGE,
},
},
});
assert.match(out, /caught=0\.50±0\.25/);
assert.match(out, /pass\^k=0/);
});
test("formatEvalReport surfaces cost/latency/tokens when usage is present", () => {
const out = formatEvalReport({
name: "demo",
trials: 2,
totalCostUsd: 0.05,
aborted: false,
arms: {
gated: {
runs: 2,
metrics: { caught: 1 },
stats: {},
usage: {
totalCostUsd: 0.05,
meanCostUsd: 0.025,
meanDurationMs: 1500,
totalInputTokens: 2000,
totalOutputTokens: 1400,
},
},
},
});
assert.match(out, /\$0\.0500 total/);
assert.match(out, /\$0\.0500 · 1\.5s\/run · 3\.4k tok/);
});
+559 -77
View File
@@ -34,6 +34,21 @@ import { tmpdir } from "node:os";
import { resolve, join, dirname } from "node:path";
import { resolveHarness } from "./plugin-loader.js";
import {
parseToolCalls,
parseResultEvent,
parseHooks,
type ToolCall,
type Trace,
} from "./harness-test.js";
import {
cacheKey,
readCache,
writeCache,
snapshotDir,
restoreDir,
type CacheMode,
} from "./eval-cache.js";
/** One arm of the comparison: fixture overrides + settings (hooks) for this arm. */
export interface EvalArm {
@@ -57,15 +72,32 @@ export interface EvalArm {
readonly pluginDir?: string;
}
/** Context handed to `measure` after a run, to compute that run's metrics. */
export interface RunContext {
/** Per-run resource use, parsed from the terminal `result` event (0 when absent). */
export interface EvalUsage {
/** `total_cost_usd` reported by claude. */
readonly costUsd: number;
/** Wall-clock `duration_ms` of the run. */
readonly durationMs: number;
readonly inputTokens: number;
readonly outputTokens: number;
}
/**
* Context handed to `measure` after a run, to compute that run's metrics. It is
* a `Trace` (so the bare predicates `usedTool` / `skillResolved` / `toolCount` /
* `toolUsedWith` from `harness-assert.ts` run over it, the same as over a
* `runHarnessTest` result) plus the eval-only `sh` end-state probe and `usage`.
*/
export interface RunContext extends Trace {
readonly cwd: string;
readonly exitCode: number;
readonly stdout: string;
/** `num_turns` reported by claude, or 0. */
readonly turns: number;
/** Contents of a file under the working dir, or null if absent. */
file(path: string): string | null;
/** The tools the agent invoked, each paired with its result (parsed from the stream). */
readonly toolCalls: readonly ToolCall[];
/** Cost / latency / tokens for this run (use as metrics, e.g. `{ cost: ctx.usage.costUsd }`). */
readonly usage: EvalUsage;
/** Run a shell command in the working dir; returns trimmed stdout ("" on error). */
sh(command: string): string;
}
@@ -92,6 +124,32 @@ export interface EvalSpec<M extends Metrics> {
readonly timeoutMs?: number;
/** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
readonly spacingSec?: number;
/**
* Record/replay cache mode. Default `"off"` (always re-sample). `"readwrite"`
* records each trial (output + post-run files) and replays it on a matching
* re-run so editing `measure` re-scores for free; the model is re-called only
* when a model-affecting input changes. `"read"` replays but never records.
* The cache key excludes `measure`, so changing your metric still hits.
*/
readonly cache?: CacheMode;
/** Where cache records live. Default `.vigiles/eval-cache` under cwd. */
readonly cacheDir?: string;
/**
* How many trials to run at once (across all arms × trials). Default 1 (fully
* sequential the safe, no-surprise default). Raise it to cut wall-clock time;
* rate-limit bursts are absorbed by the retry/backoff below.
*/
readonly concurrency?: number;
/**
* Abort the run once measured cost reaches this many USD. In-flight trials
* finish; remaining ones are skipped and `report.aborted` is set. Needs the
* model to report `total_cost_usd` (the eval tier does).
*/
readonly maxCostUsd?: number;
/** Retries on a detected rate-limit/overload before giving up. Default 3. */
readonly rateLimitRetries?: number;
/** Base backoff ms (doubled each retry). Default 1000. */
readonly retryBackoffMs?: number;
}
/** Per-metric summary statistics across an arm's runs. */
@@ -104,6 +162,22 @@ export interface MetricStat {
readonly se: number;
/** Number of runs the metric was observed in. */
readonly n: number;
/**
* pass^k (τ-bench): 1 if the metric succeeded on EVERY trial, else 0. The
* reliability question a non-deterministic harness needs "worked every time"
* is not "worked on average". A trial counts as a success when its value is
* truthy (booleans true, counts > 0), so model your metric as success/fail.
*/
readonly passK: number;
}
/** Aggregated cost / latency / tokens across an arm's runs. */
export interface ArmUsage {
readonly totalCostUsd: number;
readonly meanCostUsd: number;
readonly meanDurationMs: number;
readonly totalInputTokens: number;
readonly totalOutputTokens: number;
}
export interface ArmReport {
@@ -112,12 +186,18 @@ export interface ArmReport {
readonly metrics: Record<string, number>;
/** Per-metric mean / std / se / n, so an A/B gap can be read for significance. */
readonly stats: Record<string, MetricStat>;
/** Cost / latency / token totals + means for this arm. */
readonly usage: ArmUsage;
}
export interface EvalReport {
readonly name: string;
readonly trials: number;
readonly arms: Record<string, ArmReport>;
/** Total measured cost across every arm × trial (0 when usage wasn't reported). */
readonly totalCostUsd: number;
/** True if a `maxCostUsd` budget cap stopped the run before all trials ran. */
readonly aborted: boolean;
}
function writeFiles(cwd: string, files: Record<string, string>): void {
@@ -128,65 +208,122 @@ function writeFiles(cwd: string, files: Record<string, string>): void {
}
}
interface RunOut {
/** The raw output of one trial: the agent's exit code + captured streams. */
export interface RunOut {
code: number;
stdout: string;
/** Captured stderr, when the runner provides it (used for rate-limit detection). */
stderr?: string;
}
function spawnAgent(
task: string,
cwd: string,
model: string,
tools: readonly string[],
hasSettings: boolean,
pluginDir: string | undefined,
timeoutMs: number,
): Promise<RunOut> {
/** The per-trial arguments handed to an {@link AgentRunner}. */
export interface AgentRunArgs {
readonly task: string;
readonly cwd: string;
readonly model: string;
readonly tools: readonly string[];
readonly hasSettings: boolean;
readonly pluginDir: string | undefined;
readonly timeoutMs: number;
}
/**
* Runs one trial and returns its raw output. The default ({@link spawnAgent})
* drives the real `claude` CLI; `runEvalWith` takes one explicitly, so the eval
* orchestration is testable without a model (pass a fake returning canned
* stream-json) and a custom runtime can be plugged in.
*/
export type AgentRunner = (args: AgentRunArgs) => Promise<RunOut>;
/* v8 ignore start -- real claude subprocess; exercised by bench/, not the unit gate */
function spawnAgent(a: AgentRunArgs): Promise<RunOut> {
return new Promise((resolvePromise) => {
const args = [
"-p",
task,
a.task,
// stream-json (+ --verbose, required with -p) so the per-turn tool_use
// events survive into `ctx.toolCalls` — the unified Trace, same as the
// harness tier. The terminal `result` event still carries num_turns/output.
"--output-format",
"json",
"stream-json",
"--verbose",
"--model",
model,
a.model,
"--permission-mode",
"acceptEdits",
...(pluginDir !== undefined ? ["--plugin-dir", resolve(pluginDir)] : []),
...(hasSettings ? ["--settings", "settings.json"] : []),
...(a.pluginDir !== undefined
? ["--plugin-dir", resolve(a.pluginDir)]
: []),
...(a.hasSettings ? ["--settings", "settings.json"] : []),
"--allowedTools",
...tools,
...a.tools,
];
const child = spawn("claude", args, {
cwd,
cwd: a.cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
const timer = setTimeout(() => child.kill("SIGKILL"), a.timeoutMs);
child.on("close", (code) => {
clearTimeout(timer);
resolvePromise({ code: code ?? 0, stdout });
resolvePromise({ code: code ?? 0, stdout, stderr });
});
});
}
/**
* Run the eval: every arm × every trial against the real `claude` CLI, with the
* metric computed per run and aggregated per arm. Requires `claude` on PATH and
* working model auth (e.g. `ANTHROPIC_API_KEY`). Thin wrapper over
* {@link runEvalWith} with the real agent runner.
*/
export async function runEval<M extends Metrics>(
spec: EvalSpec<M>,
): Promise<EvalReport> {
return runEvalWith(spec, spawnAgent);
}
/* v8 ignore stop */
const sleep = (ms: number): Promise<void> =>
new Promise((r) => setTimeout(r, ms));
/** Pull cost / latency / tokens out of a parsed `result` event (0 when absent). */
function usageFrom(result: Record<string, unknown> | null): EvalUsage {
const num = (v: unknown): number => (typeof v === "number" ? v : 0);
const usage = (result?.usage ?? {}) as Record<string, unknown>;
return {
costUsd: num(result?.total_cost_usd),
durationMs: num(result?.duration_ms),
inputTokens: num(usage.input_tokens),
outputTokens: num(usage.output_tokens),
};
}
/** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
export function parseUsage(stdout: string): EvalUsage {
return usageFrom(parseResultEvent(stdout));
}
function makeContext(cwd: string, out: RunOut): RunContext {
let turns = 0;
try {
turns = (JSON.parse(out.stdout) as { num_turns?: number }).num_turns ?? 0;
} catch {
/* non-JSON output */
}
const result = parseResultEvent(out.stdout);
const turns = typeof result?.num_turns === "number" ? result.num_turns : 0;
const output = typeof result?.result === "string" ? result.result : "";
return {
cwd,
exitCode: out.code,
stdout: out.stdout,
turns,
toolCalls: parseToolCalls(out.stdout),
hooks: parseHooks(out.stdout),
output,
usage: usageFrom(result),
// The eval tier drives the real API (no mock between claude and the model),
// so the requests can't be captured here — modelRequests is harness-tier only.
modelRequests: [],
file: (p) => {
const f = resolve(cwd, p);
return existsSync(f) ? readFileSync(f, "utf-8") : null;
@@ -246,81 +383,426 @@ export function aggregateStats(
n > 1
? Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1))
: 0;
out[k] = { mean, std, se: n > 0 ? std / Math.sqrt(n) : 0, n };
const passK = n > 0 && values.every((v) => v > 0) ? 1 : 0;
out[k] = { mean, std, se: n > 0 ? std / Math.sqrt(n) : 0, n, passK };
}
return out;
}
/** Aggregate per-run usage into an arm's cost / latency / token totals + means. */
export function aggregateUsage(usages: readonly EvalUsage[]): ArmUsage {
const n = usages.length;
const sum = (f: (u: EvalUsage) => number): number =>
usages.reduce((a, u) => a + f(u), 0);
const totalCostUsd = sum((u) => u.costUsd);
return {
totalCostUsd,
meanCostUsd: n > 0 ? totalCostUsd / n : 0,
meanDurationMs: n > 0 ? sum((u) => u.durationMs) / n : 0,
totalInputTokens: sum((u) => u.inputTokens),
totalOutputTokens: sum((u) => u.outputTokens),
};
}
/** Resolved per-run settings shared across an eval's trials. */
interface RunConfig {
readonly model: string;
readonly tools: readonly string[];
readonly timeoutMs: number;
readonly cache: CacheMode;
readonly cacheDir: string;
}
/**
* Run the eval: every arm × every trial against the real `claude` CLI, with the
* metric computed per run and aggregated per arm. Requires `claude` on PATH and
* working model auth (e.g. `ANTHROPIC_API_KEY`).
* Run one trial through the cache: on a hit, restore the recorded post-run
* filesystem into `cwd` and return the recorded output (no model call); on a
* miss, run the agent and (in `readwrite`) record output + cwd snapshot. The
* cache key excludes `measure`, so editing the metric still replays.
*/
export async function runEval<M extends Metrics>(
async function runWithCache(
runArgs: AgentRunArgs,
keyParts: {
files: Record<string, string>;
settings: unknown;
trialIndex: number;
},
runner: AgentRunner,
cfg: RunConfig,
): Promise<RunOut> {
if (cfg.cache === "off") return runner(runArgs);
const key = cacheKey({
task: runArgs.task,
model: runArgs.model,
tools: runArgs.tools,
files: keyParts.files,
settings: keyParts.settings,
trialIndex: keyParts.trialIndex,
});
const hit = readCache(cfg.cacheDir, key);
if (hit) {
restoreDir(runArgs.cwd, hit.files);
return hit.out;
}
const out = await runner(runArgs);
if (cfg.cache === "readwrite") {
writeCache(cfg.cacheDir, key, { out, files: snapshotDir(runArgs.cwd) });
}
return out;
}
/** Execute one trial in a fresh sandbox; returns its metric row + usage. */
async function executeTrial<M extends Metrics>(
spec: EvalSpec<M>,
arm: EvalArm,
trialIndex: number,
runner: AgentRunner,
cfg: RunConfig,
): Promise<{ row: M; usage: EvalUsage }> {
const cwd = mkdtempSync(join(tmpdir(), "vigiles-eval-"));
try {
const { files, settings } = resolveHarness({
plugin: arm.plugin,
settings: arm.settings,
files: { ...spec.fixture, ...arm.files },
});
writeFiles(cwd, files);
const hasSettings = settings !== undefined;
if (hasSettings) {
writeFileSync(
join(cwd, "settings.json"),
JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd),
);
}
const out = await runWithCache(
{
task: spec.task,
cwd,
model: cfg.model,
tools: cfg.tools,
hasSettings,
pluginDir: arm.pluginDir,
timeoutMs: cfg.timeoutMs,
},
{ files, settings, trialIndex },
runner,
cfg,
);
const ctx = makeContext(cwd, out);
return { row: spec.measure(ctx), usage: ctx.usage };
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}
// A signal in the captured streams that the model call was rate-limited /
// overloaded — worth a backoff + retry rather than counting as a real sample.
const RATE_LIMIT_RE = /rate.?limit|\b429\b|overloaded|too many requests/i;
/** Whether a run's captured output looks like a rate-limit / overload. Pure. */
export function isRateLimited(out: RunOut): boolean {
return RATE_LIMIT_RE.test(`${out.stderr ?? ""}\n${out.stdout}`);
}
/** Call `runner`, retrying with exponential backoff while it looks rate-limited. */
async function runWithRetry(
runArgs: AgentRunArgs,
runner: AgentRunner,
retries: number,
baseMs: number,
): Promise<RunOut> {
for (let attempt = 0; ; attempt++) {
const out = await runner(runArgs);
if (!isRateLimited(out) || attempt >= retries) return out;
await sleep(baseMs * 2 ** attempt);
}
}
/** Map `worker` over `items` with at most `concurrency` in flight, order preserved. */
export async function runPool<T, R>(
items: readonly T[],
concurrency: number,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
const drain = async (): Promise<void> => {
for (;;) {
const i = next++;
const item = items[i];
if (i >= items.length || item === undefined) return;
results[i] = await worker(item);
}
};
const workers = Math.max(1, Math.min(concurrency, items.length || 1));
await Promise.all(Array.from({ length: workers }, drain));
return results;
}
/** One unit of work: a single trial of a single arm. */
interface Unit {
readonly armName: string;
readonly arm: EvalArm;
readonly trialIndex: number;
}
/** Flatten arms × trials into a single work list (so concurrency spans both). */
function buildUnits(arms: Record<string, EvalArm>, trials: number): Unit[] {
const units: Unit[] = [];
for (const [armName, arm] of Object.entries(arms)) {
for (let t = 0; t < trials; t++)
units.push({ armName, arm, trialIndex: t });
}
return units;
}
type DoneResult<M extends Metrics> = {
readonly armName: string;
readonly skipped: false;
readonly row: M;
readonly usage: EvalUsage;
};
type UnitResult<M extends Metrics> =
| DoneResult<M>
| { readonly armName: string; readonly skipped: true };
/** Group completed (non-skipped) unit results by arm and aggregate each. */
function aggregateArms<M extends Metrics>(
armNames: readonly string[],
results: readonly UnitResult<M>[],
): { arms: Record<string, ArmReport>; totalCostUsd: number } {
const arms: Record<string, ArmReport> = {};
let totalCostUsd = 0;
for (const armName of armNames) {
const done = results.filter(
(r): r is DoneResult<M> => !r.skipped && r.armName === armName,
);
const rows = done.map((d) => d.row);
const usage = aggregateUsage(done.map((d) => d.usage));
totalCostUsd += usage.totalCostUsd;
arms[armName] = {
runs: rows.length,
metrics: aggregate(rows),
stats: aggregateStats(rows),
usage,
};
}
return { arms, totalCostUsd };
}
/**
* The eval orchestration every arm × trial via `runner`, run through the cache
* and a rate-limit retry, with at most `concurrency` in flight and an optional
* `maxCostUsd` budget cap; metric + usage computed per run and aggregated per
* arm. Exported with an injectable `runner` so the loop, `measure` context,
* caching, pooling, and aggregation are unit-testable without spawning a model
* (pass a fake returning canned stream-json). `runEval` is this with the real
* agent runner.
*/
export async function runEvalWith<M extends Metrics>(
spec: EvalSpec<M>,
runner: AgentRunner,
): Promise<EvalReport> {
const trials = spec.trials ?? 5;
const spacing = (spec.spacingSec ?? 4) * 1000;
const concurrency = spec.concurrency ?? 1;
const retries = spec.rateLimitRetries ?? 3;
const backoffMs = spec.retryBackoffMs ?? 1000;
const cfg: RunConfig = {
model: spec.model ?? "haiku",
tools: spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"],
timeoutMs: spec.timeoutMs ?? 240000,
cache: spec.cache ?? "off",
cacheDir: spec.cacheDir ?? resolve(process.cwd(), ".vigiles", "eval-cache"),
};
const retrying: AgentRunner = (a) =>
runWithRetry(a, runner, retries, backoffMs);
const units = buildUnits(spec.arms, trials);
let spent = 0;
let aborted = false;
const worker = async (unit: Unit): Promise<UnitResult<M>> => {
if (aborted) return { armName: unit.armName, skipped: true };
const { row, usage } = await executeTrial(
spec,
unit.arm,
unit.trialIndex,
retrying,
cfg,
);
spent += usage.costUsd;
if (spec.maxCostUsd !== undefined && spent >= spec.maxCostUsd) {
aborted = true;
}
if (spacing > 0) await sleep(spacing);
return { armName: unit.armName, skipped: false, row, usage };
};
const results = await runPool(units, concurrency, worker);
const { arms, totalCostUsd } = aggregateArms<M>(
Object.keys(spec.arms),
results,
);
return { name: spec.name ?? "eval", trials, arms, totalCostUsd, aborted };
}
/** Render one metric: `name=mean±se pass^k=…` (se/pass^k shown when measured). */
function formatMetric(
name: string,
mean: number,
stat: MetricStat | undefined,
): string {
const base =
stat && stat.se > 0
? `${name}=${mean.toFixed(2)}±${stat.se.toFixed(2)}`
: `${name}=${mean.toFixed(2)}`;
return stat && stat.n > 0 ? `${base} pass^k=${String(stat.passK)}` : base;
}
/** Compact tokens like `3.4k`; whole numbers under 1000 stay as-is. */
function fmtTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
/** A `($0.0123 · 1.2s/run · 3.4k tok)` suffix, or "" when no usage was reported. */
function formatUsage(u: ArmUsage): string {
if (u.totalCostUsd === 0 && u.totalInputTokens + u.totalOutputTokens === 0) {
return "";
}
const tok = fmtTokens(u.totalInputTokens + u.totalOutputTokens);
return ` ($${u.totalCostUsd.toFixed(4)} · ${(u.meanDurationMs / 1000).toFixed(1)}s/run · ${tok} tok)`;
}
/** Format an eval report as a compact table for the console (mean ± se, pass^k). */
export function formatEvalReport(report: EvalReport): string {
const header =
report.totalCostUsd > 0
? `${report.name} (${String(report.trials)} trials/arm) — $${report.totalCostUsd.toFixed(4)} total`
: `${report.name} (${String(report.trials)} trials/arm)`;
const lines = [header];
for (const [arm, r] of Object.entries(report.arms)) {
const parts = Object.entries(r.metrics)
.map(([k, v]) => formatMetric(k, v, r.stats[k]))
.join(" ");
lines.push(` ${arm.padEnd(10)} ${parts}${formatUsage(r.usage)}`);
}
return lines.join("\n");
}
// --- trigger-rate: does a skill/behaviour actually FIRE across varied prompts ---
/**
* Measure how reliably a skill/behaviour *triggers*. A skill's value is its
* description firing on the right task the #1 documented skill-authoring pain
* and that's a property of the real model, not the wiring (which the
* deterministic tier already proves). Install the plugin natively (`pluginDir`),
* give a set of varied `prompts`, and a `fired` predicate over the run's `Trace`
* (reuse the bare predicates, e.g. `(t) => skillResolved(t, "x:y")`).
*/
export interface TriggerRateSpec {
/** Plugin dir installed natively (`--plugin-dir`) so its skills/commands activate. */
readonly pluginDir: string;
/** The varied prompts to test the trigger against. */
readonly prompts: readonly string[];
/** Did the behaviour fire on this run? e.g. `(t) => skillResolved(t, "x:y")`. */
readonly fired: (trace: Trace) => boolean;
/** Trials per prompt. Default 1. */
readonly trials?: number;
/** Model alias. Default "haiku". */
readonly model?: string;
/** Tools the agent may use. Default: Read Edit Write Bash Skill. */
readonly allowedTools?: readonly string[];
/** Per-run timeout ms. Default 240000. */
readonly timeoutMs?: number;
/** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
readonly spacingSec?: number;
}
/** Per-prompt trigger result: how many of its trials fired. */
export interface PromptTriggerStat {
readonly prompt: string;
readonly fired: number;
readonly trials: number;
/** `fired / trials` (0 when no trials). */
readonly rate: number;
}
export interface TriggerRateReport {
/** Overall fraction of runs in which the behaviour fired (0..1). */
readonly rate: number;
/** Total runs (prompts × trials). */
readonly n: number;
readonly perPrompt: readonly PromptTriggerStat[];
}
/**
* Trigger-rate orchestration every prompt × trial via `runner`, the `fired`
* predicate evaluated per run and aggregated into an overall + per-prompt rate.
* Exported with an injectable `runner` so the loop is unit-testable without a
* model; `measureTriggerRate` is this with the real agent runner.
*/
export async function measureTriggerRateWith(
spec: TriggerRateSpec,
runner: AgentRunner,
): Promise<TriggerRateReport> {
const trials = spec.trials ?? 1;
const model = spec.model ?? "haiku";
const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash", "Skill"];
const timeoutMs = spec.timeoutMs ?? 240000;
const spacing = (spec.spacingSec ?? 4) * 1000;
const arms: Record<string, ArmReport> = {};
for (const [armName, arm] of Object.entries(spec.arms)) {
const rows: Metrics[] = [];
const perPrompt: PromptTriggerStat[] = [];
let firedTotal = 0;
let n = 0;
for (const prompt of spec.prompts) {
let fired = 0;
for (let t = 0; t < trials; t++) {
const cwd = mkdtempSync(join(tmpdir(), "vigiles-eval-"));
const cwd = mkdtempSync(join(tmpdir(), "vigiles-trigger-"));
try {
const { files, settings } = resolveHarness({
plugin: arm.plugin,
settings: arm.settings,
files: { ...spec.fixture, ...arm.files },
});
writeFiles(cwd, files);
const hasSettings = settings !== undefined;
if (hasSettings) {
writeFileSync(
join(cwd, "settings.json"),
JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd),
);
}
const out = await spawnAgent(
spec.task,
const out = await runner({
task: prompt,
cwd,
model,
tools,
hasSettings,
arm.pluginDir,
hasSettings: false,
pluginDir: spec.pluginDir,
timeoutMs,
);
rows.push(spec.measure(makeContext(cwd, out)));
});
if (spec.fired(makeContext(cwd, out))) fired++;
} finally {
rmSync(cwd, { recursive: true, force: true });
await sleep(spacing);
}
}
arms[armName] = {
runs: rows.length,
metrics: aggregate(rows),
stats: aggregateStats(rows),
};
perPrompt.push({
prompt,
fired,
trials,
rate: trials > 0 ? fired / trials : 0,
});
firedTotal += fired;
n += trials;
}
return { name: spec.name ?? "eval", trials, arms };
return { rate: n > 0 ? firedTotal / n : 0, n, perPrompt };
}
/** Format an eval report as a compact table for the console (mean ± se). */
export function formatEvalReport(report: EvalReport): string {
const lines = [`${report.name} (${String(report.trials)} trials/arm)`];
for (const [arm, r] of Object.entries(report.arms)) {
const parts = Object.entries(r.metrics)
.map(([k, v]) => {
const se = r.stats[k]?.se ?? 0;
return se > 0
? `${k}=${v.toFixed(2)}±${se.toFixed(2)}`
: `${k}=${v.toFixed(2)}`;
})
.join(" ");
lines.push(` ${arm.padEnd(10)} ${parts}`);
/* v8 ignore start -- real claude subprocess; thin wrapper over measureTriggerRateWith */
/**
* Measure a skill/behaviour's real trigger rate across prompts × trials against
* the real `claude` CLI. Requires `claude` + model auth.
*/
export async function measureTriggerRate(
spec: TriggerRateSpec,
): Promise<TriggerRateReport> {
return measureTriggerRateWith(spec, spawnAgent);
}
/* v8 ignore stop */
/** Format a trigger-rate report: overall %, then each prompt's rate. */
export function formatTriggerRateReport(report: TriggerRateReport): string {
const pct = (report.rate * 100).toFixed(0);
const lines = [`trigger-rate: ${pct}% (${String(report.n)} runs)`];
for (const p of report.perPrompt) {
lines.push(` ${p.rate.toFixed(2)} ${p.prompt.slice(0, 60)}`);
}
return lines.join("\n");
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests for the YAML frontmatter rule parser.
*/
import { describe, it } from "node:test";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { parseFrontmatterRules, hasFrontmatterRules } from "./frontmatter.js";
+384 -4
View File
@@ -3,32 +3,77 @@
* eval delta helpers and the jest/vitest matchers. The pure logic is exercised
* here with fake result/report objects (no model, no claude).
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import {
improvement,
assertImproves,
significantlyBeats,
assertSignificant,
reliable,
assertReliable,
assertCreated,
assertNotCreated,
assertServedTurns,
assertHookBlocked,
assertHookAllowed,
assertAgentOk,
assertAgentErr,
assertAgentResult,
usedTool,
toolCount,
skillResolved,
toolUsedWith,
outputContains,
requestContains,
assertRequestContains,
hookFired,
hookBlocked,
assertToolUsed,
assertToolNotUsed,
assertSkillResolved,
assertToolUsedWith,
assertOutputContains,
assertHookFired,
assertToolCount,
assertToolSequence,
assertToolCalls,
vigilesMatchers,
} from "./harness-assert.js";
import { result } from "./spec.js";
import type { EvalReport } from "./eval.js";
import type { HarnessTestResult } from "./harness-test.js";
import type { HarnessTestResult, HookFire } from "./harness-test.js";
import type { HookRunResult } from "./run-hook.js";
/** Minimal HookRunResult stand-in for the run-hook-tier assertions/matcher. */
function fakeHook(blocked: boolean): HookRunResult {
return {
exitCode: blocked ? 2 : 0,
stdout: "",
stderr: "",
json: null,
blocked,
decision: blocked ? "deny" : undefined,
};
}
const NO_USAGE = {
totalCostUsd: 0,
meanCostUsd: 0,
meanDurationMs: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
} as const;
const report: EvalReport = {
name: "demo",
trials: 6,
totalCostUsd: 0,
aborted: false,
arms: {
vanilla: { runs: 6, metrics: { caught: 0 }, stats: {} },
gated: { runs: 6, metrics: { caught: 0.5 }, stats: {} },
vanilla: { runs: 6, metrics: { caught: 0 }, stats: {}, usage: NO_USAGE },
gated: { runs: 6, metrics: { caught: 0.5 }, stats: {}, usage: NO_USAGE },
},
};
@@ -36,6 +81,11 @@ const report: EvalReport = {
function fakeResult(
present: string[],
toolCalls: HarnessTestResult["toolCalls"] = [],
extra: {
output?: string;
hooks?: readonly HookFire[];
modelRequests?: HarnessTestResult["modelRequests"];
} = {},
): HarnessTestResult {
return {
exitCode: 0,
@@ -44,6 +94,9 @@ function fakeResult(
cwd: "/tmp/x",
turns: 2,
toolCalls,
hooks: extra.hooks ?? [],
output: extra.output ?? "",
modelRequests: extra.modelRequests ?? [],
file: (p: string) => (present.includes(p) ? "content" : null),
cleanup: () => undefined,
};
@@ -72,6 +125,120 @@ test("assertImproves passes on a positive gap and throws otherwise", () => {
});
});
test("significantlyBeats / assertSignificant use a computed noise floor", () => {
const stat = (mean: number, se: number, n: number) => ({
mean,
se,
n,
std: se * Math.sqrt(n),
passK: 0,
});
const sigReport: EvalReport = {
name: "sig",
trials: 20,
totalCostUsd: 0,
aborted: false,
arms: {
base: {
runs: 20,
metrics: { caught: 0.1 },
stats: { caught: stat(0.1, 0.05, 20) },
usage: NO_USAGE,
},
// a real, tight separation → significant
good: {
runs: 20,
metrics: { caught: 0.6 },
stats: { caught: stat(0.6, 0.05, 20) },
usage: NO_USAGE,
},
// a small gap drowned in wide se → not significant
noisy: {
runs: 5,
metrics: { caught: 0.2 },
stats: { caught: stat(0.2, 0.2, 5) },
usage: NO_USAGE,
},
},
};
assert.equal(significantlyBeats(sigReport, "base", "good", "caught"), true);
assert.equal(significantlyBeats(sigReport, "base", "noisy", "caught"), false);
assert.equal(significantlyBeats(sigReport, "base", "good", "missing"), false);
assert.doesNotThrow(() => {
assertSignificant(sigReport, {
baseline: "base",
arm: "good",
metric: "caught",
});
});
// a real but not-significant gap throws
assert.throws(() => {
assertSignificant(sigReport, {
baseline: "base",
arm: "noisy",
metric: "caught",
});
});
// missing data throws with the no-data message
assert.throws(() => {
assertSignificant(sigReport, {
baseline: "base",
arm: "good",
metric: "missing",
});
}, /no data to compare/);
// assertImproves delegates to the significance test when asked
assert.doesNotThrow(() => {
assertImproves(sigReport, {
baseline: "base",
arm: "good",
metric: "caught",
significant: true,
});
});
assert.throws(() => {
assertImproves(sigReport, {
baseline: "base",
arm: "noisy",
metric: "caught",
significant: true,
});
});
});
test("reliable / assertReliable gate on pass^k (succeeded every trial)", () => {
const rep: EvalReport = {
name: "rel",
trials: 4,
totalCostUsd: 0,
aborted: false,
arms: {
flaky: {
runs: 4,
metrics: { safe: 0.75 },
stats: { safe: { mean: 0.75, std: 0.5, se: 0.25, n: 4, passK: 0 } },
usage: NO_USAGE,
},
solid: {
runs: 4,
metrics: { safe: 1 },
stats: { safe: { mean: 1, std: 0, se: 0, n: 4, passK: 1 } },
usage: NO_USAGE,
},
},
};
assert.equal(reliable(rep, "solid", "safe"), true);
assert.equal(reliable(rep, "flaky", "safe"), false);
assert.doesNotThrow(() => {
assertReliable(rep, { arm: "solid", metric: "safe" });
});
assert.throws(() => {
assertReliable(rep, { arm: "flaky", metric: "safe" });
});
});
test("assertCreated / assertNotCreated check the sandbox", () => {
const r = fakeResult(["RESULT"]);
assert.doesNotThrow(() => {
@@ -98,12 +265,84 @@ test("assertServedTurns checks the mock turn count", () => {
});
});
test("assertHookBlocked / assertHookAllowed (run-hook results)", () => {
assert.doesNotThrow(() => {
assertHookBlocked(fakeHook(true));
});
assert.throws(() => {
assertHookBlocked(fakeHook(false));
});
assert.doesNotThrow(() => {
assertHookAllowed(fakeHook(false));
});
assert.throws(() => {
assertHookAllowed(fakeHook(true));
});
});
test("assertAgentOk / assertAgentErr / assertAgentResult (subagent outcomes)", () => {
const ok = '```vigiles:ok\n{ "summary": "done" }\n```';
const err = '```vigiles:err\n{ "reason": "boom" }\n```';
const c = result({ summary: "string" }, { reason: "string" });
// assertAgentOk: returns the value on success; throws on err / malformed
assert.deepEqual(assertAgentOk(ok), { summary: "done" });
assert.deepEqual(assertAgentOk(ok, c), { summary: "done" });
assert.throws(() => assertAgentOk(err), /returned an error result/);
assert.throws(() => assertAgentOk("no block here"), /no vigiles/);
// assertAgentErr: returns the error on failure; throws on ok / malformed
assert.deepEqual(assertAgentErr(err), { reason: "boom" });
assert.throws(() => assertAgentErr(ok), /returned a success result/);
assert.throws(() => assertAgentErr("nope"), /expected an error result/);
// assertAgentResult: general predicate
assert.doesNotThrow(() => {
assertAgentResult(ok, (r) => r.kind === "ok" && r.value.summary === "done");
});
assert.throws(() => {
assertAgentResult(ok, (r) => r.kind === "err");
}, /did not satisfy the predicate: ok/);
// malformed path includes the reason in the message
assert.throws(() => {
assertAgentResult("plain", (r) => r.kind === "ok");
}, /malformed \(no vigiles/);
});
test("vigilesMatchers.toHaveCreated reports pass/fail", () => {
const r = fakeResult(["BLOCKED"]);
assert.equal(vigilesMatchers.toHaveCreated(r, "BLOCKED").pass, true);
assert.equal(vigilesMatchers.toHaveCreated(r, "nope").pass, false);
});
test("vigilesMatchers.toBlock + every matcher's message() render both states", () => {
assert.equal(vigilesMatchers.toBlock(fakeHook(true)).pass, true);
assert.equal(vigilesMatchers.toBlock(fakeHook(false)).pass, false);
// invoke .message() in pass and fail states to cover the message closures
assert.match(vigilesMatchers.toBlock(fakeHook(true)).message(), /to block/);
assert.match(vigilesMatchers.toBlock(fakeHook(false)).message(), /to block/);
assert.match(
vigilesMatchers.toHaveCreated(fakeResult(["X"]), "X").message(),
/to create/,
);
assert.match(
vigilesMatchers.toHaveCreated(fakeResult([]), "X").message(),
/to create/,
);
assert.match(
vigilesMatchers
.toBeatBaseline(report, "vanilla", "gated", "caught")
.message(),
/to beat/,
);
assert.match(
vigilesMatchers
.toBeatBaseline(report, "gated", "vanilla", "caught")
.message(),
/to beat/,
);
});
test("vigilesMatchers.toBeatBaseline respects the `by` threshold", () => {
assert.equal(
vigilesMatchers.toBeatBaseline(report, "vanilla", "gated", "caught").pass,
@@ -162,6 +401,147 @@ test("assertSkillResolved: needs a non-error Skill tool_use with that name", ()
});
});
// --- bare predicates (the shared vocabulary) -------------------------------
test("usedTool / toolCount: bare predicates return values, don't throw", () => {
const r = fakeResult([], [skillCall, bashCall, bashCall]);
assert.equal(usedTool(r, "Skill"), true);
assert.equal(usedTool(r, /^Bash$/), true);
assert.equal(usedTool(r, "Task"), false);
assert.equal(toolCount(r, "Bash"), 2);
assert.equal(toolCount(r, /^mcp__/), 0);
});
test("skillResolved: true only for a non-error Skill call by that name", () => {
assert.equal(skillResolved(fakeResult([], [skillCall]), "demo:greet"), true);
assert.equal(skillResolved(fakeResult([], [skillCall]), "demo:other"), false);
const errored = { ...skillCall, isError: true, resultText: "No such skill" };
assert.equal(skillResolved(fakeResult([], [errored]), "demo:greet"), false);
});
test("toolUsedWith: matches a tool by name AND its input", () => {
const editCall = {
name: "Edit",
input: { file_path: "src/x.ts", old_string: "a", new_string: "b" },
resultText: "",
isError: false,
};
const r = fakeResult([], [editCall]);
const targets = (p: string) => (i: unknown) =>
(i as { file_path?: string }).file_path === p;
assert.equal(toolUsedWith(r, "Edit", targets("src/x.ts")), true);
assert.equal(toolUsedWith(r, "Edit", targets("src/other.ts")), false);
assert.equal(toolUsedWith(r, "Write", targets("src/x.ts")), false);
});
test("assertToolUsedWith: tool-argument assertion (asserts on input, not name)", () => {
const editCall = {
name: "Edit",
input: { file_path: "note.txt" },
resultText: "",
isError: false,
};
const r = fakeResult([], [editCall]);
const targets = (p: string) => (i: unknown) =>
(i as { file_path?: string }).file_path === p;
assertToolUsedWith(r, "Edit", targets("note.txt"));
assert.throws(() => {
assertToolUsedWith(r, "Edit", targets("WRONG.txt"));
});
});
// --- output predicate (DeepEval-style "what did the agent say") ------------
test("outputContains / assertOutputContains check trace.output", () => {
const r = fakeResult([], [], { output: "All done: created RESULT.md" });
assert.equal(outputContains(r, "RESULT.md"), true);
assert.equal(outputContains(r, /created \w+/), true);
assert.equal(outputContains(r, "missing"), false);
assertOutputContains(r, "All done");
assert.throws(() => {
assertOutputContains(r, "nope");
});
});
// --- model-request predicate (did the injected context reach the model) ----
test("requestContains / assertRequestContains search system + messages", () => {
const r = fakeResult([], [], {
modelRequests: [
{
system: "You have superpowers. Use the using-superpowers skill.",
messages: [{ role: "user", text: "go" }],
},
{
system: "",
messages: [
{ role: "user", text: "/audit the repo" },
{ role: "assistant", text: "on it" },
],
},
],
});
// hits in the system prompt (SessionStart additionalContext shape)
assert.equal(requestContains(r, "You have superpowers"), true);
assert.equal(requestContains(r, /super\w+/), true);
// hits in a message (slash-command expansion shape)
assert.equal(requestContains(r, "/audit the repo"), true);
assert.equal(requestContains(r, "never sent"), false);
assertRequestContains(r, "superpowers");
assert.throws(() => {
assertRequestContains(r, "never sent");
});
});
test("assertRequestContains hints when no requests were captured (eval tier)", () => {
const r = fakeResult([], [], { modelRequests: [] });
assert.equal(requestContains(r, "anything"), false);
assert.throws(() => {
assertRequestContains(r, "anything");
}, /harness-tier only/);
});
// --- hook predicates (recorded from the stream, not marker files) ----------
const hookFires: readonly HookFire[] = [
{
name: "PreToolUse:Edit",
event: "PreToolUse",
exitCode: 2,
blocked: true,
output: "BLOCKED",
},
{
name: "PostToolUse:Bash",
event: "PostToolUse",
exitCode: 0,
blocked: false,
output: "ok",
},
];
test("hookFired / hookBlocked: match by label and by bare event", () => {
const r = fakeResult([], [], { hooks: hookFires });
assert.equal(hookFired(r, "PreToolUse:Edit"), true); // full label
assert.equal(hookFired(r, "PostToolUse"), true); // bare event
assert.equal(hookFired(r, /^PreToolUse/), true); // regex
assert.equal(hookFired(r, "SessionStart"), false);
assert.equal(hookBlocked(r, "PreToolUse"), true); // the Edit hook blocked
assert.equal(hookBlocked(r, "PostToolUse"), false); // the Bash hook didn't
});
test("assertHookFired: fires, and { blocked: true } demands a block", () => {
const r = fakeResult([], [], { hooks: hookFires });
assertHookFired(r, "PreToolUse:Edit");
assertHookFired(r, "PreToolUse", { blocked: true });
assert.throws(() => {
assertHookFired(r, "SessionStart"); // never fired
});
assert.throws(() => {
assertHookFired(r, "PostToolUse", { blocked: true }); // fired but didn't block
});
});
// --- sequence / budget invariants (idea 1) ---------------------------------
const call = (name: string) => ({
+381 -32
View File
@@ -18,15 +18,25 @@ import {
type HarnessTestSpec,
type HarnessTestResult,
type ToolCall,
type Trace,
} from "./harness-test.js";
import type { EvalReport } from "./eval.js";
import type { EvalReport, TriggerRateReport } from "./eval.js";
import type { HookRunResult } from "./run-hook.js";
import type { OutputContract } from "./spec.js";
import { parseAgentResult, type ParsedAgentResult } from "./agent-result.js";
import { compareArms } from "./stats.js";
// Re-export the significance primitives so the whole eval-analysis surface lives
// behind `vigiles/harness-assert` (no separate entry point).
export { compareArms } from "./stats.js";
export type { Comparison } from "./stats.js";
/**
* Run a harness test, hand the result to `fn`, and always clean up the sandbox.
* Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
* hand it survives assertion failures.
*/
/* v8 ignore start -- thin wrapper over runHarnessTest (spawns the real CLI) */
export async function withHarness<T>(
spec: HarnessTestSpec,
fn: (r: HarnessTestResult) => T | Promise<T>,
@@ -38,6 +48,7 @@ export async function withHarness<T>(
r.cleanup();
}
}
/* v8 ignore stop */
// --- Plain throwing assertions (any runner) --------------------------------
@@ -80,12 +91,164 @@ export function assertHookAllowed(r: HookRunResult): void {
}
}
// --- subagent railway outcome (parse the worker's result block) ------------
//
// A subagent with a result() contract ends its turn with a vigiles:ok/err block.
// These wrap parseAgentResult so a test can assert the worker's *outcome* the
// same way it asserts a hook decision — the testing-framework payoff of the
// railway contract: `assertAgentOk(r.output)` instead of substring-matching prose.
/**
* Assert the worker's output is a success result, and return its `value`. With a
* `contract`, the value is validated against the success shape (a wrong/missing
* field fails the assertion). A malformed or error result throws.
*/
export function assertAgentOk(
output: string,
contract?: OutputContract,
): Record<string, unknown> {
const r = parseAgentResult(output, contract);
if (r.kind === "ok") return r.value;
const why = r.kind === "err" ? "returned an error result" : r.reason;
return fail(`expected a success result from the subagent, but ${why}`);
}
/**
* Assert the worker's output is an error result, and return its `error`. The
* railway's error track proves the worker reported failure with rich detail
* (not that it crashed or returned prose). A malformed or success result throws.
*/
export function assertAgentErr(
output: string,
contract?: OutputContract,
): Record<string, unknown> {
const r = parseAgentResult(output, contract);
if (r.kind === "err") return r.error;
const why = r.kind === "ok" ? "returned a success result" : r.reason;
return fail(`expected an error result from the subagent, but ${why}`);
}
/**
* Assert the parsed result satisfies `predicate` the general form, for
* checking rich detail (e.g. `(r) => r.kind === "ok" && r.value.files.length > 0`).
*/
export function assertAgentResult(
output: string,
predicate: (r: ParsedAgentResult) => boolean,
contract?: OutputContract,
): void {
const r = parseAgentResult(output, contract);
if (!predicate(r)) {
const detail = r.kind === "malformed" ? ` (${r.reason})` : "";
fail(`subagent result did not satisfy the predicate: ${r.kind}${detail}`);
}
}
function nameMatches(name: string, pat: string | RegExp): boolean {
return typeof pat === "string" ? name === pat : pat.test(name);
}
function toolNames(r: HarnessTestResult): string {
return r.toolCalls.map((c) => c.name).join(", ") || "none";
function toolNames(trace: Trace): string {
return trace.toolCalls.map((c) => c.name).join(", ") || "none";
}
// --- bare predicates over a Trace (the shared vocabulary, no throw) ---------
//
// Pure fns returning a value, so the SAME vocabulary runs in both consumers:
// the throwing `assert*` helpers below wrap them for the testing tier, and an
// eval `measure` reuses them directly as metrics (`measure: (t) => ({ safe:
// !usedTool(t, /merge|delete/) })`). Never one dual-purpose function.
/**
* Did the agent invoke a tool whose name matches `name` (string = exact,
* RegExp = test)? The predicate behind `assertToolUsed` / `assertToolNotUsed`.
*/
export function usedTool(trace: Trace, name: string | RegExp): boolean {
return trace.toolCalls.some((c) => nameMatches(c.name, name));
}
/** How many tools matching `name` the agent invoked. Behind `assertToolCount`. */
export function toolCount(trace: Trace, name: string | RegExp): number {
return trace.toolCalls.filter((c) => nameMatches(c.name, name)).length;
}
/**
* Did the `Skill` tool resolve `skill` (e.g. `"superpowers:test-driven-development"`)
* without error? The skill-activation predicate behind `assertSkillResolved`.
*/
export function skillResolved(trace: Trace, skill: string): boolean {
const call = trace.toolCalls.find(
(c) =>
c.name === "Skill" && (c.input as { skill?: string })?.skill === skill,
);
return call !== undefined && !call.isError;
}
/**
* Did the agent invoke a tool matching `name` whose INPUT satisfies
* `inputMatcher` a tool-ARGUMENT predicate (DeepEval-style), e.g. an `Edit`
* that targeted the right file. The predicate behind `assertToolUsedWith`.
*/
export function toolUsedWith(
trace: Trace,
name: string | RegExp,
inputMatcher: (input: unknown) => boolean,
): boolean {
return trace.toolCalls.some(
(c) => nameMatches(c.name, name) && inputMatcher(c.input),
);
}
/**
* Does the agent's final answer (`trace.output`) contain `needle` (string =
* substring, RegExp = test)? The output predicate behind `assertOutputContains`
* the DeepEval-style "what did the agent actually say" check.
*/
export function outputContains(trace: Trace, needle: string | RegExp): boolean {
return typeof needle === "string"
? trace.output.includes(needle)
: needle.test(trace.output);
}
/** All text the model received across every request (system + every message). */
function requestText(trace: Trace): string {
return trace.modelRequests
.map((r) => [r.system, ...r.messages.map((m) => m.text)].join("\n"))
.join("\n");
}
/**
* Did ANY request the model received contain `needle` searching the system
* prompt and every message across all requests? The predicate that proves
* injected context *reached the model*: a SessionStart hook's `additionalContext`
* or a slash command's expansion. Harness tier only the eval tier drives the
* real API, so its `modelRequests` (and this) is empty. Behind `assertRequestContains`.
*/
export function requestContains(
trace: Trace,
needle: string | RegExp,
): boolean {
const text = requestText(trace);
return typeof needle === "string" ? text.includes(needle) : needle.test(text);
}
/**
* Did a hook matching `name` fire? Matches against both the hook label
* (`"PreToolUse:Edit"`) and the bare event (`"PreToolUse"`), so `/PreToolUse/`
* or `"PreToolUse:Edit"` both work. The predicate behind `assertHookFired`.
*/
export function hookFired(trace: Trace, name: string | RegExp): boolean {
return trace.hooks.some(
(h) => nameMatches(h.name, name) || nameMatches(h.event, name),
);
}
/** Did a hook matching `name` fire AND block (exit ≠ 0 / outcome "error")? */
export function hookBlocked(trace: Trace, name: string | RegExp): boolean {
return trace.hooks.some(
(h) =>
(nameMatches(h.name, name) || nameMatches(h.event, name)) && h.blocked,
);
}
/**
@@ -94,13 +257,10 @@ function toolNames(r: HarnessTestResult): string {
* a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
* skill/MCP/command surfaces are really about.
*/
export function assertToolUsed(
r: HarnessTestResult,
name: string | RegExp,
): void {
if (!r.toolCalls.some((c) => nameMatches(c.name, name))) {
export function assertToolUsed(trace: Trace, name: string | RegExp): void {
if (!usedTool(trace, name)) {
fail(
`expected a tool matching ${String(name)} to be used; tools used: [${toolNames(r)}] (did you set transcript:true?)`,
`expected a tool matching ${String(name)} to be used; tools used: [${toolNames(trace)}] (did you set transcript:true?)`,
);
}
}
@@ -110,11 +270,9 @@ export function assertToolUsed(
* (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
* accident; "the tool was never used" is the real invariant. Needs `transcript`.
*/
export function assertToolNotUsed(
r: HarnessTestResult,
name: string | RegExp,
): void {
const hit = r.toolCalls.find((c) => nameMatches(c.name, name));
export function assertToolNotUsed(trace: Trace, name: string | RegExp): void {
// `find` is the negative of `usedTool` and narrows the hit for the message.
const hit = trace.toolCalls.find((c) => nameMatches(c.name, name));
if (hit) {
fail(
`expected no tool matching ${String(name)} to be used, but ${hit.name} was`,
@@ -126,13 +284,16 @@ export function assertToolNotUsed(
* Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
* without error the correct skill-activation invariant, vs. grepping the body.
*/
export function assertSkillResolved(r: HarnessTestResult, skill: string): void {
const call = r.toolCalls.find(
export function assertSkillResolved(trace: Trace, skill: string): void {
if (skillResolved(trace, skill)) return;
// skillResolved is false → either no matching Skill call, or it errored.
// Reconstruct which, for a useful message.
const call = trace.toolCalls.find(
(c) =>
c.name === "Skill" && (c.input as { skill?: string })?.skill === skill,
);
if (!call) {
const seen = r.toolCalls
const seen = trace.toolCalls
.filter((c) => c.name === "Skill")
.map((c) => (c.input as { skill?: string })?.skill ?? "?")
.join(", ");
@@ -140,9 +301,93 @@ export function assertSkillResolved(r: HarnessTestResult, skill: string): void {
`expected the Skill tool to resolve "${skill}"; Skill calls: [${seen || "none"}]`,
);
}
if (call.isError) {
fail(
`the Skill "${skill}" was invoked but errored: ${call.resultText.slice(0, 200)}`,
);
}
/**
* Assert the agent invoked a tool matching `name` whose INPUT satisfies
* `inputMatcher` a tool-ARGUMENT invariant (DeepEval-style). Asserts not just
* *that* a tool ran but *with what args*, e.g. an `Edit` that targeted the right
* file: `assertToolUsedWith(r, "Edit", (i) => (i as { file_path?: string })
* .file_path === "src/x.ts")`. Needs `transcript`.
*/
export function assertToolUsedWith(
trace: Trace,
name: string | RegExp,
inputMatcher: (input: unknown) => boolean,
message?: string,
): void {
if (!toolUsedWith(trace, name, inputMatcher)) {
const seen = trace.toolCalls
.filter((c) => nameMatches(c.name, name))
.map((c) => JSON.stringify(c.input))
.join(", ");
fail(
`the Skill "${skill}" was invoked but errored: ${call.resultText.slice(0, 200)}`,
message ??
`expected a ${String(name)} call whose input matches; ${String(name)} inputs: [${seen || "none"}]`,
);
}
}
/** Assert the agent's final answer contains `needle` (string substring / RegExp). */
export function assertOutputContains(
trace: Trace,
needle: string | RegExp,
): void {
if (!outputContains(trace, needle)) {
const shown = trace.output.slice(0, 200) || "(empty)";
fail(
`expected the agent's final answer to contain ${String(needle)}; got: ${shown}`,
);
}
}
/**
* Assert some request the model received contained `needle` the "did the
* injected context land" invariant (SessionStart `additionalContext`, slash
* command expansion). Harness tier only; a zero-request trace fails with a hint
* that the eval tier can't capture requests.
*/
export function assertRequestContains(
trace: Trace,
needle: string | RegExp,
): void {
if (!requestContains(trace, needle)) {
const n = trace.modelRequests.length;
const hint =
n === 0
? " (no requests captured — modelRequests is harness-tier only)"
: "";
fail(
`expected a model request to contain ${String(needle)}; ${String(n)} request(s) captured${hint}`,
);
}
}
function hookNames(trace: Trace): string {
return trace.hooks.map((h) => h.name).join(", ") || "none";
}
/**
* Assert a hook matching `name` fired (and, with `{ blocked: true }`, that it
* blocked) the honest hook-firing check, recorded from the run's stream rather
* than inferred from a marker file the hook had to write. Needs `transcript`.
*/
export function assertHookFired(
trace: Trace,
name: string | RegExp,
opts: { blocked?: boolean } = {},
): void {
if (!hookFired(trace, name)) {
fail(
`expected a hook matching ${String(name)} to fire; hooks fired: [${hookNames(trace)}] (did you set transcript:true?)`,
);
}
if (opts.blocked === true && !hookBlocked(trace, name)) {
fail(
`expected a hook matching ${String(name)} to block, but none did; hooks fired: [${hookNames(trace)}]`,
);
}
}
@@ -155,18 +400,18 @@ export function assertSkillResolved(r: HarnessTestResult, skill: string): void {
* "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
*/
export function assertToolCount(
r: HarnessTestResult,
trace: Trace,
name: string | RegExp,
bounds: { min?: number; max?: number; exactly?: number },
): void {
const n = r.toolCalls.filter((c) => nameMatches(c.name, name)).length;
const n = toolCount(trace, name);
const ok =
(bounds.exactly === undefined || n === bounds.exactly) &&
(bounds.min === undefined || n >= bounds.min) &&
(bounds.max === undefined || n <= bounds.max);
if (!ok) {
fail(
`expected count of ${String(name)} to satisfy ${JSON.stringify(bounds)}, got ${String(n)} (tools: [${toolNames(r)}])`,
`expected count of ${String(name)} to satisfy ${JSON.stringify(bounds)}, got ${String(n)} (tools: [${toolNames(trace)}])`,
);
}
}
@@ -178,17 +423,17 @@ export function assertToolCount(
* Needs `transcript`.
*/
export function assertToolSequence(
r: HarnessTestResult,
trace: Trace,
names: ReadonlyArray<string | RegExp>,
): void {
let i = 0;
for (const c of r.toolCalls) {
for (const c of trace.toolCalls) {
const want = names[i];
if (want !== undefined && nameMatches(c.name, want)) i++;
}
if (i < names.length) {
fail(
`expected tools in order [${names.map((n) => String(n)).join(" → ")}]; got [${toolNames(r)}]`,
`expected tools in order [${names.map((n) => String(n)).join(" → ")}]; got [${toolNames(trace)}]`,
);
}
}
@@ -199,12 +444,41 @@ export function assertToolSequence(
* was preceded by a Read of that file". Needs `transcript`.
*/
export function assertToolCalls(
r: HarnessTestResult,
trace: Trace,
predicate: (calls: readonly ToolCall[]) => boolean,
message = "tool-call invariant failed",
): void {
if (!predicate(r.toolCalls)) {
fail(`${message}; tools used: [${toolNames(r)}]`);
if (!predicate(trace.toolCalls)) {
fail(`${message}; tools used: [${toolNames(trace)}]`);
}
}
/**
* Did `arm` succeed on EVERY trial for `metric` τ-bench pass^k = 1? The
* reliability predicate over an eval report (vs. `improvement`, which reads the
* mean gap). Reads `report.arms[arm].stats[metric].passK`.
*/
export function reliable(
report: EvalReport,
arm: string,
metric: string,
): boolean {
return report.arms[arm]?.stats[metric]?.passK === 1;
}
/**
* Assert `arm` passed `metric` on every trial (pass^k = 1) the reliability
* gate for a non-deterministic harness ("worked every time", not "on average").
*/
export function assertReliable(
report: EvalReport,
opts: { arm: string; metric: string },
): void {
if (!reliable(report, opts.arm, opts.metric)) {
const pk = report.arms[opts.arm]?.stats[opts.metric]?.passK;
fail(
`expected ${opts.arm} to pass ${opts.metric} on every trial (pass^k=1), got pass^k=${String(pk ?? "n/a")}`,
);
}
}
@@ -221,14 +495,73 @@ export function improvement(
}
/**
* Assert `arm` beats `baseline` on `metric` by more than `by`. With `by` left at
* 0 this just asserts a positive gap; pass the combined se to demand the gap
* clear the noise floor.
* Did `arm` *significantly* beat `baseline` on `metric` a positive gap whose
* two-sided Welch t-test p-value is below `alpha` (default 0.05)? The grounded
* upgrade over `improvement`: the noise floor is computed from the arms' spread,
* not hand-fed. False when either arm/metric is missing. See `src/stats.ts`.
*/
// eslint-disable-next-line max-params -- positional predicate mirrors `improvement` + alpha
export function significantlyBeats(
report: EvalReport,
baseline: string,
arm: string,
metric: string,
alpha = 0.05,
): boolean {
const c = compareArms(report, baseline, arm, metric, alpha);
return c !== null && c.delta > 0 && c.significant;
}
/**
* Assert `arm` significantly beats `baseline` on `metric` (positive gap, p < α).
* The statistical gate for a non-deterministic A/B "the gap clears the noise",
* with the noise floor computed, not supplied. The honest version of
* `assertImproves(..., { by: se })`.
*/
export function assertSignificant(
report: EvalReport,
opts: { baseline: string; arm: string; metric: string; alpha?: number },
): void {
const c = compareArms(
report,
opts.baseline,
opts.arm,
opts.metric,
opts.alpha,
);
if (c === null) {
fail(
`no data to compare ${opts.arm} vs ${opts.baseline} on ${opts.metric}`,
);
}
const alpha = opts.alpha ?? 0.05;
if (!(c.delta > 0 && c.significant)) {
fail(
`expected ${opts.arm} to significantly beat ${opts.baseline} on ${opts.metric} (α=${String(alpha)}); Δ=${c.delta.toFixed(3)}, p=${c.pValue.toFixed(3)}`,
);
}
}
/**
* Assert `arm` beats `baseline` on `metric`. By default just a positive gap > `by`
* (pass the combined se to clear the noise floor by hand). Pass `{ significant:
* true }` to demand a Welch t-test at `alpha` instead — the computed noise floor.
*/
export function assertImproves(
report: EvalReport,
opts: { baseline: string; arm: string; metric: string; by?: number },
opts: {
baseline: string;
arm: string;
metric: string;
by?: number;
significant?: boolean;
alpha?: number;
},
): void {
if (opts.significant === true) {
assertSignificant(report, opts);
return;
}
const by = opts.by ?? 0;
const delta = improvement(report, opts.baseline, opts.arm, opts.metric);
if (delta <= by) {
@@ -238,6 +571,22 @@ export function assertImproves(
}
}
/**
* Assert a skill/behaviour triggered on at least `min` (0..1) of its runs the
* reliability gate for a skill's *activation* (does its description fire on the
* task), over a {@link TriggerRateReport} from `measureTriggerRate`.
*/
export function assertTriggerRate(
report: TriggerRateReport,
opts: { min: number },
): void {
if (report.rate < opts.min) {
fail(
`expected a trigger rate ≥ ${String(opts.min)}, got ${report.rate.toFixed(2)} (${String(report.n)} runs)`,
);
}
}
// --- jest / vitest matchers (expect.extend) --------------------------------
interface MatcherOutput {
+187 -1
View File
@@ -8,7 +8,7 @@
* tests below. An earlier claude version gated them headlessly; the tests lock in
* that they work on current CLIs (verified on 2.1.169) and catch a re-gate.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { join } from "node:path";
@@ -17,18 +17,55 @@ import {
scriptModel,
claudeAvailable,
parseToolCalls,
parseOutput,
parseHooks,
buildClaudeArgs,
} from "./harness-test.js";
import {
assertToolUsed,
assertToolNotUsed,
assertSkillResolved,
assertToolUsedWith,
assertToolSequence,
assertToolCount,
assertToolCalls,
assertHookFired,
} from "./harness-assert.js";
const maybe = claudeAvailable() ? test : test.skip;
// Pure: the shared claude argv (no model, no claude). Covers the transcript /
// pluginDir / settings / default-tools branches.
test("buildClaudeArgs: transcript, pluginDir, settings, and tool defaults", () => {
const base = buildClaudeArgs({ model: scriptModel([]) }, false);
assert.deepEqual(base.slice(0, 2), ["-p", "go"]); // default prompt
assert.ok(base.includes("json") && !base.includes("stream-json"));
assert.ok(!base.includes("--plugin-dir") && !base.includes("--settings"));
// default allowed tools come last
assert.deepEqual(base.slice(-5), [
"--allowedTools",
"Read",
"Edit",
"Write",
"Bash",
]);
const full = buildClaudeArgs(
{
model: scriptModel([]),
prompt: "do it",
transcript: true,
pluginDir: "examples/harness/fixture-skill-plugin",
allowedTools: ["Bash"],
},
true,
);
assert.deepEqual(full.slice(0, 2), ["-p", "do it"]);
assert.ok(full.includes("stream-json") && full.includes("--verbose"));
assert.ok(full.includes("--plugin-dir") && full.includes("--settings"));
assert.deepEqual(full.slice(-2), ["--allowedTools", "Bash"]);
});
// Regression: a PostToolUse hook fires on an Edit/Write tool use. This is the
// deterministic-tier capability a stale comment once said was impossible; the
// 2026-06-09 spike showed it firing 3/3, so we lock it in.
@@ -51,6 +88,7 @@ maybe("a PostToolUse hook fires on an Edit/Write tool use", async () => {
],
},
},
transcript: true, // capture the stream so r.hooks records the firing
model: scriptModel([
{ tool: "Write", input: { file_path: "hello.txt", content: "banana" } },
{ text: "done" },
@@ -66,6 +104,9 @@ maybe("a PostToolUse hook fires on an Edit/Write tool use", async () => {
/FIRED/,
"the Write|Edit PostToolUse hook fired",
);
// Honest check: the hook firing is recorded in the stream, not just inferred
// from the marker file above.
assertHookFired(r, "PostToolUse");
} finally {
r.cleanup();
}
@@ -94,6 +135,7 @@ maybe("a PreToolUse hook blocks an Edit tool use", async () => {
],
},
},
transcript: true, // capture the stream so r.hooks records the block decision
model: scriptModel([
{ tool: "Read", input: { file_path: "note.txt" } },
{
@@ -115,6 +157,9 @@ maybe("a PreToolUse hook blocks an Edit tool use", async () => {
"old",
"the PreToolUse hook blocked the edit (file unchanged)",
);
// Honest check: the hook fired AND its decision was a block — recorded from
// the stream, not inferred from the marker file + "file unchanged" pair.
assertHookFired(r, "PreToolUse:Edit", { blocked: true });
} finally {
r.cleanup();
}
@@ -179,6 +224,7 @@ maybe(
);
const r = await runHarnessTest({
pluginDir,
sandbox: false, // in-repo fixture we authored → trusted, run direct
allowedTools: ["Read", "Edit", "Write", "Bash", "Skill"],
transcript: true, // populate r.toolCalls
model: scriptModel([
@@ -217,6 +263,7 @@ for (const [label, dir, skill] of [
maybe(`a real ${label} skill resolves via --plugin-dir`, async () => {
const r = await runHarnessTest({
pluginDir: join(__dirname, dir),
sandbox: false, // pinned vendored plugin we audited → trusted, run direct
allowedTools: ["Read", "Edit", "Write", "Bash", "Skill"],
transcript: true,
model: scriptModel([{ tool: "Skill", input: { skill } }, { text: "ok" }]),
@@ -252,6 +299,13 @@ maybe("tool-call sequence + budget invariants hold on a real run", async () => {
assertToolSequence(r, ["Read", "Edit"]); // ordering
assertToolCount(r, "Edit", { max: 1 }); // budget
assertToolCount(r, "Write", { exactly: 0 });
// tool-ARGUMENT invariant: the Edit targeted the right file (not just "an Edit ran")
assertToolUsedWith(
r,
"Edit",
(i) => (i as { file_path?: string }).file_path === "note.txt",
);
assert.equal(typeof r.output, "string"); // unified Trace: final answer captured
assertToolCalls(
r,
(calls) => {
@@ -301,3 +355,135 @@ test("parseToolCalls: pairs tool_use with tool_result from a stream-json transcr
assert.equal(calls[0]?.isError, false);
assert.equal(parseToolCalls("{not stream json}").length, 0);
});
test("parseToolCalls: covers id / content-shape / error / no-result branches", () => {
const stream = [
"", // blank line skipped
"not json — ignored", // parse error skipped
JSON.stringify({ type: "x", message: { content: "notarray" } }), // content not an array
JSON.stringify({
type: "a",
message: {
content: [
{ type: "text", text: "prose" }, // neither tool_use nor tool_result
{ type: "tool_use", id: "u1", name: "A", input: {} },
{ type: "tool_use", input: {} }, // tool_use with no name → skipped
{ type: "tool_use", name: "NoId", input: {} }, // tool_use with no id
{ type: "tool_use", id: "u3", name: "C", input: {} },
],
},
}),
JSON.stringify({
type: "u",
message: {
content: [
{
type: "tool_result",
tool_use_id: "u1",
content: "plain",
is_error: true,
},
{ type: "tool_result", content: ["x", { text: "y" }, { z: 1 }] }, // no id; array w/ string + text + no-text
{ type: "tool_result", tool_use_id: "u3", content: 42 }, // non-string, non-array content
],
},
}),
].join("\n");
const calls = parseToolCalls(stream);
assert.equal(calls.length, 3); // A, NoId, C (the no-name tool_use is skipped)
const a = calls.find((c) => c.name === "A");
assert.equal(a?.resultText, "plain"); // string content
assert.equal(a?.isError, true);
// The no-id tool_use and the no-id tool_result both key on "" and so pair up;
// the array content ["x", {text:"y"}, {no text}] joins to "xy".
const noid = calls.find((c) => c.name === "NoId");
assert.equal(noid?.resultText, "xy");
assert.equal(noid?.isError, false);
const c = calls.find((c) => c.name === "C");
assert.equal(c?.resultText, ""); // non-string/array content (42) → ""
});
test("parseOutput: returns the final answer from the terminal result event", () => {
const stream = [
"", // blank line → skipped
JSON.stringify({ type: "assistant", message: { content: [] } }),
"not json — ignored",
JSON.stringify({
type: "result",
subtype: "success",
result: "the answer",
}),
].join("\n");
assert.equal(parseOutput(stream), "the answer");
// a result event whose `result` is non-string → ""
assert.equal(parseOutput(JSON.stringify({ type: "result", result: 42 })), "");
// single-object `--output-format json` carries the same {type:"result"} shape
assert.equal(
parseOutput(JSON.stringify({ type: "result", result: "x" })),
"x",
);
assert.equal(parseOutput("no result event here"), "");
});
test("parseHooks: records hook firing + block decision from stream events", () => {
const stream = [
JSON.stringify({
type: "system",
subtype: "hook_response",
hook_name: "PostToolUse:Bash",
hook_event: "PostToolUse",
exit_code: 0,
outcome: "success",
output: "POST_OK\n",
}),
JSON.stringify({
type: "system",
subtype: "hook_response",
hook_name: "PreToolUse:Edit",
hook_event: "PreToolUse",
exit_code: 2,
outcome: "error",
output: "BLOCKED\n",
}),
JSON.stringify({ type: "assistant", message: { content: [] } }), // ignored
].join("\n");
const hooks = parseHooks(stream);
assert.equal(hooks.length, 2);
assert.equal(hooks[0]?.name, "PostToolUse:Bash");
assert.equal(hooks[0]?.blocked, false);
assert.equal(hooks[1]?.event, "PreToolUse");
assert.equal(hooks[1]?.exitCode, 2);
assert.equal(hooks[1]?.blocked, true);
assert.equal(parseHooks("{not stream json}").length, 0);
});
test("parseHooks: defensive field coercion + the block decision branches", () => {
const stream = [
// outcome success but a non-zero exit → blocked via the exit-code arm
JSON.stringify({
type: "system",
subtype: "hook_response",
hook_name: "Stop",
hook_event: "Stop",
exit_code: 1,
outcome: "success",
output: "x",
}),
// malformed: non-number exit_code, missing name/event/output → coerced
JSON.stringify({
type: "system",
subtype: "hook_response",
exit_code: "nope",
}),
// a non-hook_response system event → skipped
JSON.stringify({ type: "system", subtype: "init" }),
].join("\n");
const hooks = parseHooks(stream);
assert.equal(hooks.length, 2);
assert.equal(hooks[0]?.blocked, true); // success + exit 1 → blocked
assert.equal(hooks[1]?.exitCode, undefined); // non-number → undefined
assert.equal(hooks[1]?.name, ""); // missing → ""
assert.equal(hooks[1]?.event, ""); // missing → ""
assert.equal(hooks[1]?.output, ""); // missing → ""
assert.equal(hooks[1]?.blocked, false); // not error, no numeric exit
});
+244 -47
View File
@@ -22,10 +22,11 @@
* The "steps" are the scripted model turns their real home is deterministic
* harness testing, not production enforcement.
*
* Note: the simple mock drives the Bash tool and Stop hooks reliably; the
* Edit/Write tools are gated in headless mode and don't fire via the mock
* drive file actions through Bash, or use the real-model eval tier (`eval.ts`)
* for Edit/Write hooks.
* Note: the mock drives Bash and Stop hooks, and verified on claude 2.1.169
* the Edit/Write tools too (allowlisted past the permission prompt), so their
* PreToolUse/PostToolUse hooks fire in this tier. The events the mock can't
* trigger (PreCompact / Notification / SessionEnd / SubagentStop) belong to the
* `runHook` unit tier.
*/
import { spawn, spawnSync } from "node:child_process";
import {
@@ -39,11 +40,28 @@ import {
import { tmpdir } from "node:os";
import { resolve, join, dirname } from "node:path";
import { startMock, type ModelTurn } from "./mock-model.js";
import { startMock, type ModelTurn, type ModelRequest } from "./mock-model.js";
import { resolveHarness } from "./plugin-loader.js";
import {
decideSandbox,
specTrusted,
sandboxAvailable,
runSandboxed,
type SandboxMode,
} from "./sandbox.js";
export { scriptModel, type ModelTurn } from "./mock-model.js";
export {
scriptModel,
type ModelTurn,
type ModelRequest,
} from "./mock-model.js";
export { loadPlugin, resolveHarness } from "./plugin-loader.js";
export {
decideSandbox,
specTrusted,
sandboxAvailable,
type SandboxMode,
} from "./sandbox.js";
export interface HarnessTestSpec {
/** Fixture files to write in a fresh temp working dir (path → contents). */
@@ -81,9 +99,83 @@ export interface HarnessTestSpec {
readonly transcript?: boolean;
/** Per-run wall-clock timeout in ms. Default 60000. */
readonly timeoutMs?: number;
/**
* Confinement policy for the code this run executes (`src/sandbox.ts`).
* Default `"auto"` is safe-by-default: an inline-only spec (you authored it)
* runs directly, but an external `plugin` / `pluginDir` brings in untrusted
* third-party hooks and is run under bubblewrap or, if no sandbox is
* available, the run REFUSES rather than executing unconfined. Pass `false` to
* opt out and run unconfined (you audited the code, or trust the outer
* container); `"strict"` to force confinement even for trusted code.
*
* NOTE: confined execution is **Linux only** (bubblewrap is a Linux tool). On
* macOS / Windows no sandbox is available, so an untrusted run will REFUSE
* under `"auto"`/`"strict"` use `sandbox: false` there if you trust the code.
*/
readonly sandbox?: SandboxMode;
}
export interface HarnessTestResult {
/**
* A hook invocation observed during the run, recorded (not inferred) from the
* `hook_response` system events the CLI emits in the stream so a test can
* assert which hook fired and whether it blocked, instead of inferring it from a
* marker file the hook had to write.
*/
export interface HookFire {
/** The hook label, e.g. `"PreToolUse:Edit"` (`Event:Matcher`). */
readonly name: string;
/** The hook event, e.g. `"PreToolUse"`, `"PostToolUse"`, `"Stop"`. */
readonly event: string;
/** The hook process exit code (2 = block), or undefined if not reported. */
readonly exitCode: number | undefined;
/** Whether the hook blocked / errored (exit ≠ 0 or outcome "error"). */
readonly blocked: boolean;
/** What the hook printed (its block reason / diagnostic), or "". */
readonly output: string;
}
/**
* The observable record of ONE run the unified shape produced by BOTH testing
* tiers: `runHarnessTest`'s result and `runEval`'s `measure` ctx (`eval.ts`)
* both satisfy it. That's what lets the bare predicates in `harness-assert.ts`
* (`usedTool` / `skillResolved` / `toolCount` / `toolUsedWith` / `hookFired` /
* `outputContains`) run over either, with the testing helpers asserting and eval
* measuring over the same vocabulary.
*/
export interface Trace {
/**
* The tools the agent invoked, each paired with its result parsed from the
* transcript. Empty unless the run captured the stream (`transcript: true` on
* the harness tier; always on the eval tier). Lets a test assert on the
* agent's *actions* (skills, MCP tools, subagents) instead of grepping stdout.
*/
readonly toolCalls: readonly ToolCall[];
/**
* The hooks that fired during the run, each with its decision parsed from
* the CLI's `hook_response` stream events. Same capture requirement as
* `toolCalls` (empty without the stream). Lets a test assert hook firing
* honestly instead of via a marker file.
*/
readonly hooks: readonly HookFire[];
/** The agent's final answer text (the terminal `result` event), or "". */
readonly output: string;
/**
* The requests the model received, captured by the scripted mock each with
* its `system` prompt and `messages`, flattened to text. Lets a test assert
* what actually reached the model (a SessionStart hook's injected context, a
* slash command's expansion), not just that a hook fired. **Harness tier
* only**: the mock sees the requests, so this is populated by `runHarnessTest`
* (with or without `transcript`); the eval tier drives the real API, so its
* `modelRequests` is always empty.
*/
readonly modelRequests: readonly ModelRequest[];
/** Number of model turns. */
readonly turns: number;
/** Final contents of a file under the working dir, or null if absent. */
file(path: string): string | null;
}
export interface HarnessTestResult extends Trace {
readonly exitCode: number;
readonly stdout: string;
/** Hook block messages and diagnostics land here. */
@@ -92,14 +184,6 @@ export interface HarnessTestResult {
readonly cwd: string;
/** Number of model turns the agent took (mock turns served). */
readonly turns: number;
/**
* The tools the agent invoked, each paired with its result parsed from the
* transcript. Empty unless `transcript: true`. Lets a test assert on the
* agent's *actions* (skills, MCP tools, subagents) instead of grepping stdout.
*/
readonly toolCalls: readonly ToolCall[];
/** Final contents of a file under the working dir, or null if absent. */
file(path: string): string | null;
/** Remove the temp working dir. */
cleanup(): void;
}
@@ -166,6 +250,99 @@ export function parseToolCalls(streamJson: string): ToolCall[] {
}));
}
/**
* The terminal `result` event present in BOTH `--output-format` shapes (a
* `{type:"result", …}` line in stream-json, the single object in `json`), or
* null. The seam for the final answer + turn count without parsing twice.
*/
export function parseResultEvent(
stdout: string,
): Record<string, unknown> | null {
for (const line of stdout.split("\n")) {
if (!line.trim()) continue;
let evt: Record<string, unknown>;
try {
evt = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
if (evt.type === "result") return evt;
}
return null;
}
/** The agent's final answer text from a transcript / result object, or "". */
export function parseOutput(stdout: string): string {
const result = parseResultEvent(stdout)?.result;
return typeof result === "string" ? result : "";
}
/**
* The hooks that fired, recorded from the CLI's `hook_response` stream events
* (`--output-format stream-json`). Each carries the hook name/event, its exit
* code, and whether it blocked the honest record vs. inferring from marker
* files. Returns [] for the non-stream `json` output (no per-hook events).
*/
function toHookFire(evt: Record<string, unknown>): HookFire {
const exitCode =
typeof evt.exit_code === "number" ? evt.exit_code : undefined;
return {
name: typeof evt.hook_name === "string" ? evt.hook_name : "",
event: typeof evt.hook_event === "string" ? evt.hook_event : "",
exitCode,
blocked:
evt.outcome === "error" || (exitCode !== undefined && exitCode !== 0),
output: typeof evt.output === "string" ? evt.output : "",
};
}
export function parseHooks(stdout: string): HookFire[] {
const hooks: HookFire[] = [];
for (const line of stdout.split("\n")) {
if (!line.trim()) continue;
let evt: Record<string, unknown>;
try {
evt = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
if (evt.type === "system" && evt.subtype === "hook_response") {
hooks.push(toHookFire(evt));
}
}
return hooks;
}
/**
* The `claude` CLI argv for a harness run (shared by the direct and sandboxed
* paths). `ANTHROPIC_BASE_URL` is set by the caller's environment / wrapper, not
* here. Pure, so the arg shape is unit-tested.
*/
export function buildClaudeArgs(
spec: HarnessTestSpec,
hasSettings: boolean,
): string[] {
const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
return [
"-p",
spec.prompt ?? "go",
...(spec.transcript
? ["--output-format", "stream-json", "--verbose"]
: ["--output-format", "json"]),
"--model",
"claude-sonnet-4-5",
...(spec.pluginDir !== undefined
? ["--plugin-dir", resolve(spec.pluginDir)]
: []),
...(hasSettings ? ["--settings", "settings.json"] : []),
"--allowedTools",
...tools,
];
}
/* v8 ignore start -- spawns the real claude CLI + filesystem; exercised by the
claude-backed suite, excluded from the deterministic coverage gate (the parse
helpers above carry the testable logic). */
/** Whether the `claude` CLI is available — harness tests need it. */
export function claudeAvailable(): boolean {
try {
@@ -232,10 +409,22 @@ function spawnClaude(
/**
* Run the real `claude` CLI against a scripted mock model, with the given
* fixture and settings (hooks). Deterministic same script, same result.
*
* Safe by default: an external `plugin` / `pluginDir` brings in untrusted
* third-party hooks and is confined under bubblewrap (`spec.sandbox`, default
* `"auto"`); if no sandbox is available the run REFUSES rather than executing
* unconfined. See `src/sandbox.ts`.
*/
export async function runHarnessTest(
spec: HarnessTestSpec,
): Promise<HarnessTestResult> {
const decision = decideSandbox({
trusted: specTrusted(spec),
mode: spec.sandbox ?? "auto",
available: sandboxAvailable(),
});
if (decision.action === "throw") throw new Error(decision.reason);
const cwd = mkdtempSync(join(tmpdir(), "vigiles-harness-"));
const { files, settings } = resolveHarness({
plugin: spec.plugin,
@@ -243,42 +432,50 @@ export async function runHarnessTest(
files: spec.files,
});
writeFixture(cwd, files, settings);
const args = buildClaudeArgs(spec, settings !== undefined);
const timeoutMs = spec.timeoutMs ?? 60000;
const build = (
out: { code: number; stdout: string; stderr?: string },
turns: number,
modelRequests: readonly ModelRequest[],
): HarnessTestResult => ({
exitCode: out.code,
stdout: out.stdout,
stderr: out.stderr ?? "",
cwd,
turns,
toolCalls: parseToolCalls(out.stdout),
hooks: parseHooks(out.stdout),
output: parseOutput(out.stdout),
modelRequests,
file: (p: string): string | null => {
const f = resolve(cwd, p);
return existsSync(f) ? readFileSync(f, "utf-8") : null;
},
cleanup: (): void => {
rmSync(cwd, { recursive: true, force: true });
},
});
// Confined path: the mock is co-launched inside the sandbox's netns.
if (decision.action === "sandbox") {
const out = await runSandboxed({
cwd,
claudeArgs: args,
script: spec.model,
timeoutMs,
});
return build(out, out.requests.length, out.requests);
}
// Direct path: mock runs in this process; claude reaches it over localhost.
const mock = await startMock(spec.model);
try {
const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
const args = [
"-p",
spec.prompt ?? "go",
...(spec.transcript
? ["--output-format", "stream-json", "--verbose"]
: ["--output-format", "json"]),
"--model",
"claude-sonnet-4-5",
...(spec.pluginDir !== undefined
? ["--plugin-dir", resolve(spec.pluginDir)]
: []),
...(settings !== undefined ? ["--settings", "settings.json"] : []),
"--allowedTools",
...tools,
];
const out = await spawnClaude(args, cwd, mock.url, spec.timeoutMs ?? 60000);
return {
exitCode: out.code,
stdout: out.stdout,
stderr: out.stderr,
cwd,
turns: mock.count,
toolCalls: parseToolCalls(out.stdout),
file: (p: string): string | null => {
const f = resolve(cwd, p);
return existsSync(f) ? readFileSync(f, "utf-8") : null;
},
cleanup: (): void => {
rmSync(cwd, { recursive: true, force: true });
},
};
const out = await spawnClaude(args, cwd, mock.url, timeoutMs);
return build(out, mock.count, [...mock.requests]);
} finally {
mock.close();
}
}
/* v8 ignore stop */
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests for the inline-rule parser.
*/
import { describe, it } from "node:test";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { parseInlineRules, hasInlineRules } from "./inline.js";
+8 -1
View File
@@ -3,7 +3,7 @@
* itself needs claude + auth, but the verdict parsing is pure that's what we
* pin here (the part most likely to silently break).
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { parseJudgeOutput } from "./judge.js";
@@ -50,3 +50,10 @@ test("returns a safe verdict on unparseable / empty output", () => {
assert.equal(noScore.score, 0);
assert.match(noScore.reason, /unparseable/);
});
test("tolerates malformed JSON between braces (firstJsonObject catch)", () => {
// Has `{` and `}` but invalid JSON between → JSON.parse throws → null.
const v = parseJudgeOutput("prefix { score: nope, } suffix");
assert.equal(v.score, 0);
assert.match(v.reason, /unparseable/);
});
+2
View File
@@ -55,6 +55,7 @@ function firstJsonObject(s: string): unknown {
}
}
/* v8 ignore start -- spawns the real claude CLI; parseJudgeOutput holds the logic */
/** Grade `output` against `rubric` with a model. Synchronous (for `measure`). */
export function judge(opts: JudgeOptions): JudgeResult {
const threshold = opts.threshold ?? 0.5;
@@ -89,6 +90,7 @@ export function judge(opts: JudgeOptions): JudgeResult {
}
return parseJudgeOutput(res.stdout ?? "", threshold);
}
/* v8 ignore stop */
/**
* Parse a verdict out of the grader's stdout pure, so the parsing is testable
+1 -1
View File
@@ -462,7 +462,7 @@ function makeResult(
* short so edit distance is more appropriate than NCD (which is tuned
* for longer texts).
*/
function editDistance(a: string, b: string): number {
export function editDistance(a: string, b: string): number {
if (a === b) return 0;
const m = a.length;
const n = b.length;
+1 -1
View File
@@ -3,7 +3,7 @@
* REAL minimal MCP server (examples/harness/fixture-mcp-server.mjs) it speaks
* the actual stdio JSON-RPC protocol, so these are deterministic and offline.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { join } from "node:path";
+35
View File
@@ -0,0 +1,35 @@
/**
* vigiles the in-sandbox mock entry.
*
* Run as a subprocess INSIDE the bubblewrap network namespace (see
* `src/sandbox.ts`), so the scripted mock lives on the sandbox's isolated
* loopback reachable by the confined `claude`, unreachable from outside.
* Reads the model script from a file, streams each captured request to an ndjson
* file the parent reads back (for `trace.modelRequests`), and writes its chosen
* port so the wrapper can point `ANTHROPIC_BASE_URL` at it.
*
* node mock-entry.js <scriptFile> <requestsFile> <portFile>
*
* Not unit-tested directly (it's a daemon driven only through a live sandbox);
* exercised end-to-end by the bwrap-backed integration test.
*/
import { readFileSync, appendFileSync, writeFileSync } from "node:fs";
import { startMock, type ModelTurn } from "./mock-model.js";
void (async (): Promise<void> => {
const [scriptFile, requestsFile, portFile] = process.argv.slice(2);
if (!scriptFile || !requestsFile || !portFile) {
process.stderr.write("mock-entry: scriptFile requestsFile portFile\n");
process.exit(2);
}
const turns = JSON.parse(readFileSync(scriptFile, "utf-8")) as ModelTurn[];
const handle = await startMock(turns, {
onRequest: (req) => {
appendFileSync(requestsFile, JSON.stringify(req) + "\n");
},
});
// Signal readiness last: the wrapper waits for a non-empty port file.
writeFileSync(portFile, new URL(handle.url).port);
// Stay alive until the wrapper kills us once `claude` has finished.
})();
+229
View File
@@ -0,0 +1,229 @@
/**
* Tests for the scripted Anthropic mock (src/mock-model.ts). The mock is an
* in-process HTTP server, so its full behaviour SSE vs JSON turns, tool vs
* text turns, count_tokens / HEAD / health tolerance, the onTurn probe, and the
* last-turn-repeat / empty-script defaults is testable directly, no claude.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import {
startMock,
scriptModel,
extractRequest,
type TurnInfo,
} from "./mock-model.js";
const post = (url: string, body: unknown): Promise<Response> =>
fetch(`${url}/v1/messages`, { method: "POST", body: JSON.stringify(body) });
interface MsgBlock {
type?: string;
text?: string;
input?: unknown;
}
interface MsgResponse {
content?: MsgBlock[];
stop_reason?: string;
model?: string;
}
const readMsg = async (r: Response): Promise<MsgResponse> =>
(await r.json()) as MsgResponse;
test("startMock: SSE and JSON turns, tool and text, count/HEAD/health, onTurn", async () => {
const seen: TurnInfo[] = [];
// Two mocks so all four (stream|json) × (tool|text) combinations are exercised.
const streamMock = await startMock(
scriptModel([
{ tool: "Bash", input: { command: "ls" } },
{ text: "all done" },
{ tool: "NoInput" }, // tool turn with no input → `turn.input ?? {}`
{}, // turn with neither tool nor text → `turn.text ?? ""`
]),
{ onTurn: (info) => seen.push(info) },
);
try {
// turn 0: streaming tool turn → streamTurn (tool branch)
const sse = await (
await post(streamMock.url, {
stream: true,
model: "m",
messages: [{ content: "go" }],
})
).text();
assert.match(sse, /"type":"tool_use"/);
assert.match(sse, /"name":"Bash"/);
// turn 1: streaming text turn → streamTurn (text branch); a tool_result in
// the request exercises the onTurn hasToolResult probe.
const sse2 = await (
await post(streamMock.url, {
stream: true,
messages: [{ content: [{ type: "tool_result", content: "x" }] }],
})
).text();
assert.match(sse2, /"text_delta"/);
assert.match(sse2, /all done/);
// turns 2 & 3: streaming tool turn with no input, then a no-text turn —
// exercising the `turn.input ?? {}` / `turn.text ?? ""` defaults.
assert.match(
await (
await post(streamMock.url, {
stream: true,
messages: [{ content: "x" }],
})
).text(),
/"name":"NoInput"/,
);
assert.match(
await (
await post(streamMock.url, {
stream: true,
messages: [{ content: "x" }],
})
).text(),
/message_stop/,
);
// count_tokens (else Claude Code hangs)
const counted = (await (
await fetch(`${streamMock.url}/v1/messages/count_tokens`, {
method: "POST",
body: "{}",
})
).json()) as unknown;
assert.deepEqual(counted, { input_tokens: 10 });
// HEAD + a non-messages health GET both return {}
assert.equal(
(await fetch(`${streamMock.url}/v1/messages`, { method: "HEAD" })).status,
200,
);
assert.deepEqual(
(await (await fetch(`${streamMock.url}/health`)).json()) as unknown,
{},
);
assert.equal(seen[0]?.stream, true);
assert.equal(seen[1]?.hasToolResult, true);
assert.ok(streamMock.count >= 2);
} finally {
streamMock.close();
}
const jsonMock = await startMock(
scriptModel([
{ tool: "Bash", input: { command: "ls" } },
{ text: "ok" },
{ tool: "NoInput" }, // tool turn with no input → `turn.input ?? {}`
]),
);
try {
// turn 0: non-streaming tool turn → jsonTurn (tool branch)
const tool = await readMsg(
await post(jsonMock.url, { messages: [{ content: "go" }] }),
);
assert.equal(tool.content?.[0]?.type, "tool_use");
assert.equal(tool.stop_reason, "tool_use");
// turn 1: non-streaming text turn (no model → default echo) → jsonTurn (text)
const txt = await readMsg(
await post(jsonMock.url, { messages: [{ content: "go" }] }),
);
assert.equal(txt.content?.[0]?.text, "ok");
assert.equal(txt.model, "claude-mock");
// turn 2: non-streaming tool turn with no input → `turn.input ?? {}`
const noInput = await readMsg(
await post(jsonMock.url, { messages: [{ content: "go" }] }),
);
assert.deepEqual(noInput.content?.[0]?.input, {});
} finally {
jsonMock.close();
}
});
test("extractRequest: flattens system + messages, tolerates odd shapes", () => {
// system as a string; message content as a string
assert.deepEqual(
extractRequest({
system: "be brief",
messages: [{ role: "user", content: "go" }],
}),
{ system: "be brief", messages: [{ role: "user", text: "go" }] },
);
// system as a text-block array; content as a block array (text + non-text)
assert.deepEqual(
extractRequest({
system: [
{ type: "text", text: "A" },
{ type: "text", text: "B" },
],
messages: [
{
role: "user",
content: [
"raw",
{ type: "text", text: "C" },
{ type: "tool_result", content: "ignored" }, // no `text` → ""
],
},
],
}),
{ system: "AB", messages: [{ role: "user", text: "rawC" }] },
);
// missing system → ""; missing role → ""; non-array messages → []
assert.deepEqual(extractRequest({ messages: [{ content: "x" }] }), {
system: "",
messages: [{ role: "", text: "x" }],
});
assert.deepEqual(extractRequest({}), { system: "", messages: [] });
assert.deepEqual(extractRequest({ messages: "nope" as unknown }), {
system: "",
messages: [],
});
});
test("startMock: captures each request via handle.requests", async () => {
const mock = await startMock(scriptModel([{ text: "ok" }]));
try {
await post(mock.url, {
system: "You have superpowers",
messages: [{ role: "user", content: "go" }],
});
// count_tokens / HEAD must NOT be recorded as model requests
await fetch(`${mock.url}/v1/messages/count_tokens`, {
method: "POST",
body: "{}",
});
await fetch(`${mock.url}/v1/messages`, { method: "HEAD" });
await post(mock.url, { messages: [{ role: "user", content: "again" }] });
assert.equal(mock.requests.length, 2);
assert.equal(mock.requests[0]?.system, "You have superpowers");
assert.equal(mock.requests[0]?.messages[0]?.text, "go");
assert.equal(mock.requests[1]?.messages[0]?.text, "again");
} finally {
mock.close();
}
});
test("startMock: repeats the last turn and defaults an empty script", async () => {
const repeat = await startMock(scriptModel([{ text: "only" }]));
try {
for (let i = 0; i < 2; i++) {
const j = await readMsg(
await post(repeat.url, { messages: [{ content: "x" }] }),
);
assert.equal(j.content?.[0]?.text, "only"); // 2nd call repeats the last turn
}
} finally {
repeat.close();
}
const empty = await startMock(scriptModel([]));
try {
// empty script + no messages field → default { text: "" }
const j = await readMsg(await post(empty.url, {}));
assert.equal(j.content?.[0]?.text, "");
} finally {
empty.close();
}
});
+67 -2
View File
@@ -41,11 +41,29 @@ export interface TurnInfo {
readonly hasToolResult: boolean;
}
/**
* One `/v1/messages` request the mock received, flattened to text for
* assertions. This is the seam that lets a harness test prove what reached the
* model a SessionStart hook's injected `additionalContext`, or a slash
* command's expansion not just that a hook fired.
*/
export interface ModelRequest {
/** The system prompt, flattened to text (string or text-block array). */
readonly system: string;
/** The conversation messages, each flattened to `{ role, text }`. */
readonly messages: readonly {
readonly role: string;
readonly text: string;
}[];
}
export interface MockHandle {
readonly url: string;
close(): void;
/** Number of model turns served so far. */
readonly count: number;
/** Every `/v1/messages` request the mock received, in order. */
readonly requests: readonly ModelRequest[];
}
function writeEvent(res: ServerResponse, event: string, data: unknown): void {
@@ -156,7 +174,42 @@ function jsonTurn(res: ServerResponse, turn: ModelTurn, model: string): void {
interface ReqBody {
stream?: boolean;
model?: string;
messages?: { content?: unknown }[];
system?: unknown;
messages?: { role?: unknown; content?: unknown }[];
}
/** Flatten Anthropic content (string, or an array of text/other blocks) to text. */
function flattenContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((b) => {
if (typeof b === "string") return b;
const t = (b as { text?: unknown }).text;
return typeof t === "string" ? t : "";
})
.join("");
}
/**
* Extract a {@link ModelRequest} from a request body the `system` prompt and
* `messages`, each flattened to text. Pure and exported so the capture logic is
* testable without the HTTP server.
*/
export function extractRequest(body: {
system?: unknown;
messages?: unknown;
}): ModelRequest {
const messages = Array.isArray(body.messages)
? body.messages.map((m) => {
const msg = m as { role?: unknown; content?: unknown };
return {
role: typeof msg.role === "string" ? msg.role : "",
text: flattenContent(msg.content),
};
})
: [];
return { system: flattenContent(body.system), messages };
}
/**
@@ -166,9 +219,15 @@ interface ReqBody {
*/
export function startMock(
script: readonly ModelTurn[],
opts: { onTurn?: (info: TurnInfo) => void } = {},
opts: {
onTurn?: (info: TurnInfo) => void;
/** Called with each `/v1/messages` request as it arrives used by the
* in-sandbox mock entry to stream requests to a file for the parent. */
onRequest?: (req: ModelRequest) => void;
} = {},
): Promise<MockHandle> {
let i = 0;
const requests: ModelRequest[] = [];
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c as string));
@@ -192,6 +251,9 @@ export function startMock(
res.end(JSON.stringify({ input_tokens: 10 }));
return;
}
const request = extractRequest(reqBody);
requests.push(request);
opts.onRequest?.(request);
const last = JSON.stringify(reqBody.messages?.at(-1)?.content ?? "");
opts.onTurn?.({
n: i,
@@ -214,6 +276,9 @@ export function startMock(
get count() {
return i;
},
get requests() {
return requests;
},
});
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it } from "node:test";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
+71 -1
View File
@@ -3,7 +3,7 @@
* real assembled machine (hooks + CLAUDE.md + skills) so a test/eval runs
* against what ships, not a retyped subset. Model-free.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
@@ -54,6 +54,45 @@ test("loadPlugin resolves CLAUDE_PLUGIN_ROOT to the absolute plugin path", () =>
}
});
test("loadPlugin warns on dangling intra-plugin file references", () => {
const root = makeTmpDir("plugin-dangling");
try {
// a hook script that reads one skill that exists and one that doesn't
mkdirSync(join(root, "hooks"), { recursive: true });
writeFileSync(
join(root, "hooks", "session-start"),
// the missing ref appears twice → exercises the dedup (seen) path
'cat "$ROOT/skills/present/SKILL.md"\ncat "$ROOT/skills/missing/SKILL.md"\necho "$ROOT/skills/missing/SKILL.md"\n',
);
mkdirSync(join(root, "skills", "present"), { recursive: true });
writeFileSync(join(root, "skills", "present", "SKILL.md"), "# present\n");
const { warnings } = loadPlugin(root);
const dangling = warnings.find((w) => w.includes("intra-plugin"));
assert.ok(dangling, "expected a dangling-ref warning");
assert.ok(
dangling.includes("skills/missing/SKILL.md"),
"names the missing ref",
);
assert.ok(
!dangling.includes("skills/present/SKILL.md"),
"ignores the present ref",
);
} finally {
cleanupTmpDir(root);
}
});
test("loadPlugin: a complete plugin has no dangling-ref warning", () => {
const root = makePlugin(); // skills/foo/SKILL.md exists, no broken refs
try {
const { warnings } = loadPlugin(root);
assert.ok(!warnings.some((w) => w.includes("intra-plugin")));
} finally {
cleanupTmpDir(root);
}
});
test("loadPlugin materializes CLAUDE.md and skills into the sandbox", () => {
const root = makePlugin();
try {
@@ -292,6 +331,37 @@ test("loadPlugin warns when a plugin declares MCP servers", () => {
}
});
test("loadPlugin warns when the manifest declares mcpServers (no .mcp.json)", () => {
const root = makeTmpDir("mcpmanifest");
try {
mkdirSync(join(root, ".claude-plugin"), { recursive: true });
writeFileSync(join(root, "CLAUDE.md"), "# x\n");
writeFileSync(
join(root, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "m", mcpServers: { demo: { command: "x" } } }),
);
assert.ok(loadPlugin(root).warnings.some((w) => w.includes("MCP")));
} finally {
cleanupTmpDir(root);
}
});
test("loadPlugin tolerates a malformed plugin.json (does not crash)", () => {
const root = makeTmpDir("badmanifest");
try {
mkdirSync(join(root, ".claude-plugin"), { recursive: true });
writeFileSync(join(root, "CLAUDE.md"), "# x\n");
writeFileSync(join(root, ".claude-plugin", "plugin.json"), "{ not json");
const loaded = loadPlugin(root);
// malformed manifest → no hooks, no MCP warning; CLAUDE.md still loads.
assert.deepEqual(loaded.settings, {});
assert.ok(!loaded.warnings.some((w) => w.includes("MCP")));
assert.equal(loaded.files["CLAUDE.md"], "# x\n");
} finally {
cleanupTmpDir(root);
}
});
test("a fully-covered plugin (hooks + CLAUDE.md + skills) has no warnings", () => {
const root = makePlugin();
try {
+58 -22
View File
@@ -37,22 +37,20 @@ export interface LoadedPlugin {
readonly warnings: readonly string[];
}
interface PluginManifest {
hooks?: unknown;
skills?: string;
mcpServers?: unknown;
}
const MAX_SKILL_FILE_BYTES = 256 * 1024;
/** Parse a JSON file, or null on any error (missing / malformed). */
function safeReadJson(path: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
} catch {
return null;
}
}
/** Read and return the `.hooks` field of a JSON file, or undefined on any error. */
function readHooksFile(path: string): unknown {
try {
return (JSON.parse(readFileSync(path, "utf-8")) as { hooks?: unknown })
.hooks;
} catch {
return undefined;
}
return safeReadJson(path)?.hooks;
}
/**
@@ -63,9 +61,10 @@ function readHooksFile(path: string): unknown {
* 4. a plain repo's `.claude/settings.json`.
*/
function readHooks(root: string): unknown {
const manifestPath = join(root, ".claude-plugin", "plugin.json");
if (existsSync(manifestPath)) {
const m = JSON.parse(readFileSync(manifestPath, "utf-8")) as PluginManifest;
// A malformed plugin.json must not crash the loader — fall through to the
// other layouts (safeReadJson returns null on a parse error).
const m = safeReadJson(join(root, ".claude-plugin", "plugin.json"));
if (m) {
if (typeof m.hooks === "string") return readHooksFile(join(root, m.hooks));
if (m.hooks !== undefined) return m.hooks;
}
@@ -167,6 +166,15 @@ function pluginWarnings(
`plugin declares MCP server(s) (mcpServers / .mcp.json) — the loader does not wire MCP; bring the server up yourself if your test needs it.`,
);
}
const dangling = danglingRefs(root);
if (dangling.length) {
const shown = dangling.slice(0, 5).join(", ");
const more =
dangling.length > 5 ? `, … (+${String(dangling.length - 5)})` : "";
warnings.push(
`plugin references ${String(dangling.length)} intra-plugin file(s) that don't exist (broken path / partial vendor): ${shown}${more}`,
);
}
if (!hooks && Object.keys(files).length === 0) {
warnings.push(
`nothing was loaded (no hooks, CLAUDE.md, skills, agents, or commands) — the deterministic harness would run an effectively empty machine.`,
@@ -178,14 +186,42 @@ function pluginWarnings(
/** Whether the plugin declares any MCP servers (manifest field or .mcp.json). */
function hasMcp(root: string): boolean {
if (existsSync(join(root, ".mcp.json"))) return true;
const manifestPath = join(root, ".claude-plugin", "plugin.json");
if (!existsSync(manifestPath)) return false;
try {
const m = JSON.parse(readFileSync(manifestPath, "utf-8")) as PluginManifest;
return m.mcpServers !== undefined;
} catch {
return false;
return (
safeReadJson(join(root, ".claude-plugin", "plugin.json"))?.mcpServers !==
undefined
);
}
// A plugin-relative path reference to a file under a standard surface dir, with a
// known extension — e.g. a hook script that `cat`s `skills/using-superpowers/SKILL.md`.
const INTRA_REF_RE =
/(?:skills|hooks|commands|agents)\/[A-Za-z0-9._/-]+\.(?:md|sh|cmd|mjs|cjs|js|ts|py|rb|txt|json)/g;
/**
* Intra-plugin file references that don't resolve the partial-vendor / broken-
* path class (e.g. obra/superpowers' `SessionStart` reads
* `skills/using-superpowers/SKILL.md`, which a sliced vendor snapshot omits). We
* scan the plugin's own text files under the surface dirs (hooks scripts
* included those aren't materialized into `files`) for root-relative path refs
* and report the ones missing on disk. A static check that would have caught a
* bug the dogfood hit twice. Best-effort: a warning, not an error.
*/
function danglingRefs(root: string): string[] {
const missing = new Set<string>();
const seen = new Set<string>();
for (const surface of ["hooks", "skills", "agents", "commands"]) {
const dir = join(root, surface);
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
for (const content of Object.values(readTree(dir, root))) {
for (const m of content.matchAll(INTRA_REF_RE)) {
const ref = m[0];
if (seen.has(ref)) continue;
seen.add(ref);
if (!existsSync(join(root, ref))) missing.add(ref);
}
}
}
return [...missing];
}
type HooksObj = { hooks?: Record<string, unknown[]> };
+1 -1
View File
@@ -6,7 +6,7 @@
* evolution engine.
*/
import { describe, it } from "node:test";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import {
+178
View File
@@ -0,0 +1,178 @@
/**
* Tests for the railway-oriented subagent surface: the result() contract on an
* agent (compiled into a vigiles:ok/err output section) and railway()/delegate()
* composition over flat workers (compiled to an orchestrator command, with
* compile-time verification of delegate targets + bounded recovery). Model-free.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import { agent, result, railway, delegate } from "./spec.js";
import { compileAgent, compileRailway, validateRailway } from "./compile.js";
// --- result() contract on an agent -----------------------------------------
test("compileAgent renders the result contract as an Output contract section", () => {
const { markdown, errors } = compileAgent(
agent({
name: "coder",
description: "Write code.",
tools: ["Read", "Edit", "Bash"],
body: "You write code.",
output: result(
{ files: "string[]", summary: "string" },
{ reason: "string", retryable: "boolean" },
),
}),
{ specFile: "agents/coder.md.spec.ts" },
);
assert.deepEqual(errors, []);
assert.match(markdown, /## Output contract/);
assert.match(markdown, /```vigiles:ok/);
assert.match(markdown, /"files": string\[\], "summary": string/);
assert.match(markdown, /```vigiles:err/);
assert.match(markdown, /"reason": string, "retryable": boolean/);
});
test("an agent without a result contract has no Output contract section", () => {
const { markdown } = compileAgent(
agent({ name: "a", description: "d", body: "b" }),
{ specFile: "a.md.spec.ts" },
);
assert.doesNotMatch(markdown, /## Output contract/);
});
test("an empty contract track renders as {}", () => {
const { markdown } = compileAgent(
agent({
name: "a",
description: "d",
body: "b",
output: result({}, { reason: "string" }),
}),
{ specFile: "a.md.spec.ts" },
);
assert.match(markdown, /```vigiles:ok\n\{\}\n```/);
});
// --- delegate() / railway() builders ---------------------------------------
test("delegate() carries an optional task hint", () => {
assert.deepEqual(delegate("planner"), {
_step: "delegate",
agent: "planner",
});
assert.deepEqual(delegate("coder", "write it"), {
_step: "delegate",
agent: "coder",
task: "write it",
});
});
test("railway() sets the spec type and fields", () => {
const rw = railway({
name: "ship",
steps: [delegate("planner"), delegate("coder")],
onError: delegate("reporter"),
recover: { step: delegate("fixer"), max: 2 },
});
assert.equal(rw._specType, "railway");
assert.equal(rw.steps.length, 2);
});
// --- compileRailway --------------------------------------------------------
test("compileRailway renders an orchestrator command with hash + tracks", () => {
const { markdown, errors } = compileRailway(
railway({
name: "ship",
steps: [delegate("planner", "draft a plan"), delegate("coder")],
onError: delegate("reporter"),
recover: { step: delegate("fixer"), max: 2 },
}),
{ knownAgents: ["planner", "coder", "reporter", "fixer"] },
);
assert.deepEqual(errors, []);
assert.match(markdown, /^<!-- vigiles:sha256:[a-f0-9]+ compiled from/);
assert.match(markdown, /# Railway: ship/);
assert.match(markdown, /## Success track/);
assert.match(markdown, /1\. \*\*planner\*\* — draft a plan/);
assert.match(markdown, /2\. \*\*coder\*\*/);
assert.match(markdown, /## Recovery[\s\S]*\*\*fixer\*\* up to 2×/);
assert.match(markdown, /## On error[\s\S]*\*\*reporter\*\*/);
});
test("compileRailway omits Recovery / On error when not declared", () => {
const { markdown } = compileRailway(
railway({ name: "min", steps: [delegate("solo")] }),
{ knownAgents: ["solo"] },
);
assert.doesNotMatch(markdown, /## Recovery/);
assert.doesNotMatch(markdown, /## On error/);
});
// --- validateRailway (the static, sub-Turing guarantees) -------------------
test("flags a delegate to an unknown agent (stale-ref)", () => {
const errs = validateRailway(
railway({ name: "ship", steps: [delegate("planr")] }), // typo
["planner", "coder"],
);
assert.equal(errs.length, 1);
assert.equal(errs[0].type, "stale-ref");
assert.match(errs[0].message, /unknown agent "planr"/);
});
test("checks the onError and recover targets too", () => {
const errs = validateRailway(
railway({
name: "ship",
steps: [delegate("planner")],
onError: delegate("ghost"),
recover: { step: delegate("phantom"), max: 1 },
}),
["planner"],
);
assert.equal(errs.length, 2);
assert.ok(errs.every((e) => e.type === "stale-ref"));
});
test("flags an empty railway", () => {
const errs = validateRailway(railway({ name: "empty", steps: [] }));
assert.ok(
errs.some(
(e) => e.type === "invalid-railway" && /no steps/.test(e.message),
),
);
});
test("flags unbounded/zero recovery (must be ≥ 1 — the finite guarantee)", () => {
const errs = validateRailway(
railway({
name: "ship",
steps: [delegate("planner")],
recover: { step: delegate("fixer"), max: 0 },
}),
["planner", "fixer"],
);
assert.ok(
errs.some(
(e) => e.type === "invalid-railway" && /max must be ≥ 1/.test(e.message),
),
);
});
test("skips agent resolution when knownAgents is omitted", () => {
// no registry → don't flag delegate targets (mirrors linter verify modes)
const errs = validateRailway(
railway({ name: "ship", steps: [delegate("anything")] }),
);
assert.deepEqual(errs, []);
});
test("compileRailway defaults the spec filename from the railway name", () => {
const { markdown } = compileRailway(
railway({ name: "ship", steps: [delegate("a")] }),
);
assert.match(markdown, /compiled from ship\.railway\.spec\.ts/);
});
+1 -1
View File
@@ -3,7 +3,7 @@
* (fenced blocks skipped), the `vigiles:symbol path#symbol` mark, verifying the
* named file defines the symbol, and the unmarked-code enforcement.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
+3 -1
View File
@@ -4,7 +4,7 @@
* decision logic and real (tiny shell) hooks across exit codes / JSON output /
* stdin passthrough / env injection.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import {
@@ -21,6 +21,8 @@ test("parseHookOutput parses a JSON decision and ignores plain text", () => {
});
assert.equal(parseHookOutput("just a log line"), null);
assert.equal(parseHookOutput(" not json {x"), null);
// starts with "{" but invalid JSON → JSON.parse throws → caught → null
assert.equal(parseHookOutput('{"unterminated": '), null);
});
test("decideHook: exit 2 blocks regardless of stdout", () => {
+1 -1
View File
@@ -3,7 +3,7 @@
* Discovery and formatting are pure-ish; `runScripts` spawns trivial node
* scripts in a temp dir, so the whole suite stays fast and model-free.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
+232
View File
@@ -0,0 +1,232 @@
/**
* Tests for the safe-by-default confinement layer (src/sandbox.ts). The policy
* (`decideSandbox`), trust test (`specTrusted`), bwrap argv (`bwrapArgs`), and
* request-log parser are pure exercised here with no bwrap. The end-to-end
* confinement test (a sandboxed run can't reach the network) is gated on a real
* bwrap + claude and skips otherwise, the same pattern as the claude-backed suite.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import { join } from "node:path";
import {
decideSandbox,
specTrusted,
sandboxAvailable,
bwrapArgs,
parseRequestLog,
} from "./sandbox.js";
import {
runHarnessTest,
claudeAvailable,
scriptModel,
} from "./harness-test.js";
import { assertRequestContains, assertHookFired } from "./harness-assert.js";
test("specTrusted: inline-only is trusted, any external plugin is not", () => {
assert.equal(specTrusted({}), true);
assert.equal(specTrusted({ plugin: "./repo" }), false);
assert.equal(specTrusted({ pluginDir: "/some/plugin" }), false);
});
test("decideSandbox: false is the dangerous opt-out — always runs direct", () => {
for (const trusted of [true, false]) {
for (const available of [true, false]) {
assert.deepEqual(decideSandbox({ trusted, mode: false, available }), {
action: "direct",
});
}
}
});
test("decideSandbox: strict forces confinement, throws without a sandbox", () => {
assert.deepEqual(
decideSandbox({ trusted: true, mode: "strict", available: true }),
{ action: "sandbox" },
);
const refused = decideSandbox({
trusted: true,
mode: "strict",
available: false,
});
assert.equal(refused.action, "throw");
assert.match(refused.action === "throw" ? refused.reason : "", /bwrap/);
});
test("decideSandbox auto: trusted runs direct regardless of availability", () => {
assert.deepEqual(
decideSandbox({ trusted: true, mode: "auto", available: false }),
{ action: "direct" },
);
assert.deepEqual(
decideSandbox({ trusted: true, mode: "auto", available: true }),
{ action: "direct" },
);
});
test("decideSandbox auto: untrusted is sandboxed, or REFUSES if it can't be", () => {
assert.deepEqual(
decideSandbox({ trusted: false, mode: "auto", available: true }),
{ action: "sandbox" },
);
const refused = decideSandbox({
trusted: false,
mode: "auto",
available: false,
});
assert.equal(refused.action, "throw");
// safe-by-default: never silently runs untrusted code unconfined
assert.match(
refused.action === "throw" ? refused.reason : "",
/refusing to execute|sandbox: false/,
);
});
test("sandboxAvailable returns a boolean (covers the probe)", () => {
assert.equal(typeof sandboxAvailable(), "boolean");
});
test("bwrapArgs: isolated net, cleared env, ro root, writable work + io + home", () => {
const args = bwrapArgs({
cwd: "/work",
ioDir: "/io",
home: "/io/home",
path: "/usr/bin:/bin",
});
const joined = args.join(" ");
assert.ok(args.includes("--unshare-all")); // fresh net namespace, no egress
assert.ok(args.includes("--clearenv")); // drop host secrets from env
assert.ok(joined.includes("--ro-bind / /")); // system read-only
assert.ok(joined.includes("--bind /work /work")); // writable work dir
assert.ok(joined.includes("--bind /io /io")); // writable IO relay dir
assert.ok(joined.includes("--setenv HOME /io/home")); // fresh empty HOME
assert.ok(joined.includes("--setenv PATH /usr/bin:/bin")); // PATH set back
assert.ok(joined.includes("--chdir /work"));
assert.ok(args.includes("--die-with-parent"));
});
test("parseRequestLog: parses ndjson, skips blank and partial lines", () => {
const ndjson =
JSON.stringify({ system: "s1", messages: [{ role: "user", text: "a" }] }) +
"\n\n" +
JSON.stringify({ system: "s2", messages: [] }) +
"\n" +
'{"system":"partial' + // a half-written final line → skipped
"\n";
const reqs = parseRequestLog(ndjson);
assert.equal(reqs.length, 2);
assert.equal(reqs[0]?.system, "s1");
assert.equal(reqs[1]?.system, "s2");
assert.deepEqual(parseRequestLog(""), []);
});
// --- end-to-end confinement (needs a real bwrap + claude) ------------------
const sandboxRunnable = sandboxAvailable() && claudeAvailable();
// The security property, proven through the real stack: a sandboxed run's Bash
// can reach the in-sandbox mock (so turns are served + requests captured) but
// CANNOT reach the external network — egress is blocked by the netns.
test.skipIf(!sandboxRunnable)(
"a sandboxed run blocks network egress while the mock stays reachable",
async () => {
const probe =
"node -e \"fetch('https://example.com',{signal:AbortSignal.timeout(4000)})" +
".then(r=>require('fs').writeFileSync('NET','open:'+r.status))" +
".catch(()=>require('fs').writeFileSync('NET','blocked'))\"";
const r = await runHarnessTest({
sandbox: "strict", // force the sandbox path on a trusted inline spec
allowedTools: ["Bash", "Write"],
model: scriptModel([
{ tool: "Bash", input: { command: probe } },
{ text: "done" },
]),
timeoutMs: 120000,
});
try {
// mock was reachable inside the netns → turns served + requests relayed out
assert.ok(r.turns >= 1, "expected the in-sandbox mock to serve a turn");
assert.ok(
r.modelRequests.length >= 1,
"expected captured requests relayed out of the sandbox",
);
// the real payoff: external egress was blocked
assert.equal(
r.file("NET"),
"blocked",
"expected external network to be unreachable from the sandbox",
);
} finally {
r.cleanup();
}
},
130000,
);
// trace.modelRequests proves injected context actually REACHES the model — a
// SessionStart hook (emitting Claude Code's nested form) under the sandbox, and
// we find its additionalContext in the model's request. "fired" AND "landed".
test.skipIf(!sandboxRunnable)(
"a SessionStart hook's injected context reaches the model (trace.modelRequests)",
async () => {
const marker = "VIGILES_CTX_MARKER_42";
const hookCmd = `node -e "console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'SessionStart',additionalContext:'${marker}'}}))"`;
const r = await runHarnessTest({
settings: {
hooks: {
SessionStart: [
{
matcher: "startup",
hooks: [{ type: "command", command: hookCmd }],
},
],
},
},
sandbox: "strict", // force the sandbox path even though this is trusted
transcript: true,
model: scriptModel([{ text: "ok" }]),
timeoutMs: 120000,
});
try {
assertHookFired(r, "SessionStart");
assertRequestContains(r, marker); // the injected context landed in the model's request
} finally {
r.cleanup();
}
},
130000,
);
// Dogfood — the execute-and-verify payoff on a REAL pinned third-party plugin:
// obra/superpowers' SessionStart hook is UNTRUSTED, so it runs CONFINED (no
// sandbox:false). We assert the real hook FIRED inside the sandbox and produced
// its genuine output. (It emits a *top-level* additionalContext, which Claude
// Code — reading the *nested* form — does NOT inject; so trace.modelRequests
// shows the context did NOT reach the model. That "fired ≠ landed" gap is exactly
// what modelRequests exists to surface, proven against real third-party code.)
test.skipIf(!sandboxRunnable)(
"dogfood: superpowers' SessionStart runs confined and its real output is captured",
async () => {
const superpowers = join(
__dirname,
"../examples/harness/vendor/superpowers@6fd4507",
);
const r = await runHarnessTest({
plugin: superpowers, // external → untrusted → confined (no sandbox:false)
transcript: true,
model: scriptModel([{ text: "ok" }]),
timeoutMs: 120000,
});
try {
assertHookFired(r, "SessionStart"); // the real third-party hook ran, confined
const ctx = r.hooks.find((h) => h.event === "SessionStart")?.output ?? "";
assert.ok(
ctx.includes("You have superpowers"),
"expected superpowers' SessionStart to produce its real injected-context output",
);
} finally {
r.cleanup();
}
},
130000,
);
+286
View File
@@ -0,0 +1,286 @@
/**
* vigiles safe-by-default confinement for executing untrusted harness code.
*
* `runHarnessTest` runs the real `claude` CLI, which runs the real hooks of
* whatever plugin you load. For code YOU authored (inline `settings`/`files`)
* that's fine — trust is implicit. But pointing it at someone else's `plugin` /
* `pluginDir` executes THEIR hooks with your privileges. This module makes that
* safe by default: untrusted code is confined under bubblewrap, or if no
* sandbox is available the run refuses rather than executing unconfined.
*
* Confinement (proven on bwrap 0.9): `--unshare-all` gives a fresh network
* namespace whose loopback is auto-up but has NO external route so the
* scripted mock, co-launched INSIDE the namespace, is reachable over 127.0.0.1
* while a malicious hook cannot phone home. The filesystem is `--ro-bind`
* read-only except the throwaway work dir, a fresh empty `$HOME`, and an IO dir
* used to hand the script in and stream captured requests back out.
*
* The policy (`decideSandbox`), trust test (`specTrusted`), and bwrap argv
* (`bwrapArgs`) are pure and unit-tested; the executor (`runSandboxed`) needs a
* real bwrap and is covered by the integration test, which skips where bwrap is
* absent the same pattern as the real-`claude` paths.
*/
import { spawn, spawnSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
existsSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type ModelTurn, type ModelRequest } from "./mock-model.js";
/**
* How to treat code execution. `"auto"` (default) is safe-by-default: trusted
* code runs directly, untrusted code is sandboxed if possible and otherwise
* refuses. `false` is the dangerous opt-out run unconfined (you audited it, or
* you trust the outer container). `"strict"` forces confinement even for trusted
* code and throws if no sandbox is available.
*/
export type SandboxMode = "auto" | "strict" | false;
/**
* Whether bubblewrap is available to confine untrusted code. **Linux only**
* bubblewrap is a Linux tool, so this is always `false` on macOS / Windows,
* where confined execution isn't supported and untrusted code must instead be
* run via `sandbox: false` (trusting the outer container) or skipped.
*/
export function sandboxAvailable(): boolean {
/* v8 ignore next -- non-Linux has no bwrap; CI/coverage runs on Linux */
if (process.platform !== "linux") return false;
try {
return spawnSync("bwrap", ["--version"], { stdio: "ignore" }).status === 0;
} catch {
/* v8 ignore next -- defensive: spawnSync only throws on a fork failure */
return false;
}
}
/**
* Is this spec's executed code trusted? Inline `settings`/`files` you authored
* are trusted; any external `plugin` / `pluginDir` brings in third-party hooks
* and is NOT committing it to your repo is the same trust decision as a
* dependency, so the trust boundary follows provenance: foreign = confined.
*/
export function specTrusted(spec: {
plugin?: string;
pluginDir?: string;
}): boolean {
return spec.plugin === undefined && spec.pluginDir === undefined;
}
/** The chosen action for a run: execute directly, confine it, or refuse. */
export type SandboxDecision =
| { readonly action: "direct" }
| { readonly action: "sandbox" }
| { readonly action: "throw"; readonly reason: string };
/**
* The pure safe-by-default policy. Untrusted code NEVER runs unconfined unless
* the caller explicitly opted out (`mode: false`). This is the whole security
* contract, isolated as a pure function so it is exhaustively unit-tested.
*/
export function decideSandbox(opts: {
trusted: boolean;
mode: SandboxMode;
available: boolean;
}): SandboxDecision {
// Explicit dangerous opt-out: run unconfined, trusted or not.
if (opts.mode === false) return { action: "direct" };
// Force confinement regardless of trust; refuse if we can't.
if (opts.mode === "strict") {
return opts.available
? { action: "sandbox" }
: {
action: "throw",
reason:
"sandbox: 'strict' requires Linux + bubblewrap (bwrap), which was not available",
};
}
// auto: trusted code runs directly; untrusted must be confined or refused.
if (opts.trusted) return { action: "direct" };
return opts.available
? { action: "sandbox" }
: {
action: "throw",
reason:
"refusing to execute an untrusted plugin's hooks without a sandbox: " +
"the sandbox needs Linux + bubblewrap (bwrap) — install it to run " +
"confined, or pass sandbox: false to run unconfined if you trust this " +
"code / the outer container",
};
}
/**
* The bubblewrap confinement argv (everything before the command): a fresh
* network namespace (`--unshare-all`, loopback-only, no egress), a read-only
* system, writable mounts limited to the work dir, the IO dir, and a fresh empty
* HOME (inside the IO dir so it needs no mountpoint on the read-only root, and so
* no host credentials/config leak in), and a **cleared environment**
* `--clearenv` drops every host variable (API keys, cloud creds) and only PATH /
* HOME / TMPDIR are set back, so untrusted code can't even read your secrets.
* Pure, so the confinement shape is asserted in a unit test.
*/
export function bwrapArgs(opts: {
cwd: string;
ioDir: string;
home: string;
path: string;
}): string[] {
return [
// New user/net/pid/ipc/uts/cgroup namespaces. The net namespace has only a
// loopback route, so the in-sandbox mock is reachable but egress is blocked.
"--unshare-all",
// Drop ALL inherited env (host secrets); only the essentials are set back.
"--clearenv",
"--ro-bind",
"/",
"/",
"--dev",
"/dev",
"--proc",
"/proc",
// Writable: the work dir and the IO dir (later binds override the ro-bind).
"--bind",
opts.cwd,
opts.cwd,
"--bind",
opts.ioDir,
opts.ioDir,
// A fresh empty HOME so no host credentials/config are visible.
"--setenv",
"HOME",
opts.home,
"--setenv",
"TMPDIR",
opts.ioDir,
// PATH must be set back explicitly (cleared above) so node/claude resolve.
"--setenv",
"PATH",
opts.path,
"--chdir",
opts.cwd,
"--die-with-parent",
"--new-session",
];
}
/** Parse the in-sandbox mock's ndjson request log into {@link ModelRequest}s. */
export function parseRequestLog(ndjson: string): ModelRequest[] {
const out: ModelRequest[] = [];
for (const line of ndjson.split("\n")) {
if (!line.trim()) continue;
try {
out.push(JSON.parse(line) as ModelRequest);
} catch {
/* a partially-written final line — skip */
}
}
return out;
}
/** The raw output of a sandboxed run: exit code, captured stdout, and requests. */
export interface SandboxRunOut {
readonly code: number;
readonly stdout: string;
readonly requests: readonly ModelRequest[];
}
/**
* Co-launch the scripted mock and `claude` inside ONE bubblewrap network
* namespace: the mock serves on the sandbox's loopback (reachable), egress is
* blocked, and captured requests stream out through the bound IO dir. Paths come
* in via env so the wrapper needs no escaping; `claude`'s args are the wrapper's
* positional params (`"$@"`).
*/
const WRAPPER = [
// start the in-sandbox mock; it writes its port to $VIG_PORT when ready
'node "$VIG_MOCKENTRY" "$VIG_SCRIPT" "$VIG_REQS" "$VIG_PORT" &',
"MOCKPID=$!",
"i=0",
'while [ ! -s "$VIG_PORT" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i+1)); done',
'export ANTHROPIC_BASE_URL="http://127.0.0.1:$(cat "$VIG_PORT")"',
"export ANTHROPIC_API_KEY=sk-vigiles-mock",
'claude "$@"',
"code=$?",
'kill "$MOCKPID" 2>/dev/null',
'exit "$code"',
].join("\n");
/* v8 ignore start -- spawns bwrap + the real claude CLI; exercised by the
bwrap-backed integration test (skipped without bwrap), not the unit gate
the pure policy/args/parse helpers above carry the testable logic. */
export function runSandboxed(opts: {
cwd: string;
claudeArgs: readonly string[];
script: readonly ModelTurn[];
timeoutMs: number;
}): Promise<SandboxRunOut> {
const ioDir = mkdtempSync(join(tmpdir(), "vigiles-sbx-"));
const home = join(ioDir, "home");
mkdirSync(home);
const scriptF = join(ioDir, "script.json");
const reqsF = join(ioDir, "requests.ndjson");
const portF = join(ioDir, "port");
writeFileSync(scriptF, JSON.stringify(opts.script));
writeFileSync(reqsF, "");
// The mock entry is only runnable as built JS. In production __dirname is
// dist/ (sibling); under vitest the source runs from src/, so fall back to
// the built dist/ copy.
const mockEntry =
[
join(__dirname, "mock-entry.js"),
join(__dirname, "..", "dist", "mock-entry.js"),
].find((p) => existsSync(p)) ?? join(__dirname, "mock-entry.js");
const args = [
...bwrapArgs({
cwd: opts.cwd,
ioDir,
home,
path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
}),
"--setenv",
"VIG_MOCKENTRY",
mockEntry,
"--setenv",
"VIG_SCRIPT",
scriptF,
"--setenv",
"VIG_REQS",
reqsF,
"--setenv",
"VIG_PORT",
portF,
"sh",
"-c",
WRAPPER,
"sh",
...opts.claudeArgs,
];
return new Promise((resolvePromise) => {
const child = spawn("bwrap", args, {
cwd: opts.cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
child.stderr.on("data", () => {
/* hook diagnostics — not needed for the captured result */
});
const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
child.on("close", (code) => {
clearTimeout(timer);
const requests = parseRequestLog(
existsSync(reqsF) ? readFileSync(reqsF, "utf-8") : "",
);
rmSync(ioDir, { recursive: true, force: true });
resolvePromise({ code: code ?? 0, stdout, requests });
});
});
}
/* v8 ignore stop */
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, before, after } from "node:test";
import { describe, it, beforeAll as before, afterAll as after } from "vitest";
import assert from "node:assert/strict";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, before, after } from "node:test";
import { describe, it, beforeAll as before, afterAll as after } from "vitest";
import assert from "node:assert/strict";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
+1 -1
View File
@@ -3,7 +3,7 @@
* short-circuit the control flow the declarative steps form can't express.
* The model is a scripted mock; gates use `true`/`false` for determinism.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
+1 -1
View File
@@ -3,7 +3,7 @@
* steps vigiles:gate markers, and the result postcondition gate. Gate
* references are verified against the project at compile time.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { skill, step, input, cmd, file } from "./spec.js";
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests for the skill runtime: parsing vigiles:gate / vigiles:result markers
* out of a compiled SKILL.md and executing the gate ladder with short-circuit.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Tests for the skill-testing wrapper: scripting the model and asserting the
* deterministic action/gate sequence with plain node:test assertions.
* deterministic action/gate sequence with plain assertions.
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { cmd } from "./spec.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it } from "node:test";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import {
+167
View File
@@ -490,6 +490,173 @@ export function skill(spec: Omit<SkillSpec, "_specType">): SkillSpec {
return { _specType: "skill", ...spec };
}
// ---------------------------------------------------------------------------
// Subagent specs
// ---------------------------------------------------------------------------
/**
* A subagent definition (compiles to `agents/<name>.md`). Unlike a skill
* reference material the model reads on activation a subagent is a *delegated
* worker with a contract*: a dispatch `description`, an allowed-`tools` rail, an
* optional `model`, a system-prompt `body`, and the `rules` it must follow. That
* tool contract + those rules are the "railway" a subagent runs on, and they're
* exactly the compile-time-verifiable surface vigiles owns: the body's
* `file()`/`cmd()`/`symbol()` marks are checked like any instruction file, and
* the tools list is verified against the real tool set.
*/
export interface AgentSpec {
readonly _specType: "agent";
/** Subagent name (frontmatter + dispatch handle). */
readonly name: string;
/** When to dispatch this subagent — the trigger (frontmatter). */
readonly description: string;
/** Model alias (e.g. "sonnet", "opus", "haiku", "inherit"). Optional. */
readonly model?: string;
/**
* The allowed-tools contract the rails the worker runs on. Each entry must be
* a known built-in tool (Read/Write/Edit/Bash/Grep/Glob/WebSearch/WebFetch/
* NotebookEdit/TodoWrite/Task/Skill) or an MCP tool (`mcp__server__tool`).
* Omit to inherit all tools. Verified at compile time.
*/
readonly tools?: readonly string[];
/**
* The lead/intro prose of the system prompt (the "You are…" opener), before any
* sections. Carries verified `file()`/`cmd()`/`symbol()`/`ref()` marks. No
* markdown headers use `sections` for those.
*/
readonly body?: string | InstructionFragment[];
/**
* Named `##` sections of the system prompt (e.g. Purpose, Core Principles,
* Capabilities) the shape real subagents actually take. Same verified-ref +
* no-nested-`##` rules as a CLAUDE.md spec's sections. Use `body` for the intro
* and `sections` for the structured rest.
*/
readonly sections?: Record<string, string | InstructionFragment[]>;
/** Rules the worker must follow — rendered as a `## Rules` section. */
readonly rules?: Record<string, Rule>;
/**
* The typed result contract what this worker returns on success/error. When
* set, compiles to an `## Output contract` section instructing the worker to
* end with a `vigiles:ok` / `vigiles:err` block, so its outcome is parseable
* and testable (see `result()`, `parseAgentResult`, `assertAgentOk`).
*/
readonly output?: OutputContract;
}
/**
* Define a subagent specification (compiles to `agents/<name>.md`).
*
* // agents/reviewer.md.spec.ts
* export default agent({
* name: "reviewer",
* description: "Review a diff for correctness. Dispatch PROACTIVELY after edits.",
* model: "sonnet",
* tools: ["Read", "Grep", "Bash"],
* body: instructions`Review the diff. Run ${cmd("npm test")} first.`,
* rules: {
* "no-floating": enforce("@typescript-eslint/no-floating-promises", "Await promises."),
* },
* });
*/
export function agent(spec: Omit<AgentSpec, "_specType">): AgentSpec {
return { _specType: "agent", ...spec };
}
// ---------------------------------------------------------------------------
// Subagent result contract + railway composition (railway-oriented subagents)
//
// A subagent is a flat worker, but instead of returning prose it returns a
// typed Result — either success or error, with rich detail on BOTH tracks. A
// `railway()` then composes flat workers: the success track flows worker→worker,
// and the first error short-circuits to an error handler. This is Wlaschin's
// railway-oriented programming with a subagent as the step. It is deliberately
// sub-Turing — a finite list of steps + a bounded recovery, no loop/iterator
// combinator — so termination is readable off the value and every reference is
// statically checkable (the thing ultraplan's generated script can't be). See
// research/railway-subagents.md and research/subagent-compilation.md.
// ---------------------------------------------------------------------------
/** The field types a result contract can declare (kept tiny + dependency-free). */
export type OutputFieldType = "string" | "number" | "boolean" | "string[]";
/**
* A subagent's typed result contract: the shape it must return on success
* (`ok`) and on failure (`err`). Rich on both tracks an error is structured
* detail, not a bare pass/fail bit. Compiles into the worker's system prompt
* (the `vigiles:ok` / `vigiles:err` block it must emit) and is the schema the
* `parseAgentResult` parser + the `assertAgentOk/Err` test helpers validate.
*/
export interface OutputContract {
readonly _ref: "output";
readonly ok: Readonly<Record<string, OutputFieldType>>;
readonly err: Readonly<Record<string, OutputFieldType>>;
}
/**
* Declare a subagent's success/error result contract.
*
* result(
* { files: "string[]", summary: "string" }, // rich success
* { reason: "string", retryable: "boolean" }, // rich error
* )
*
* (Distinct from a skill's `result:` postcondition gate this types a
* subagent's *return value*, the success/error tracks of the railway.)
*/
export function result(
ok: Record<string, OutputFieldType>,
err: Record<string, OutputFieldType>,
): OutputContract {
return { _ref: "output", ok, err };
}
/** One step on a railway: dispatch a flat subagent (the "activity"). */
export interface RailwayStep {
readonly _step: "delegate";
/** The subagent to dispatch — resolved against compiled agent names. */
readonly agent: string;
/** Optional task hint passed to the worker. */
readonly task?: string;
}
/** Build a railway step that dispatches `agent` (optionally with a task hint). */
export function delegate(agent: string, task?: string): RailwayStep {
return task === undefined
? { _step: "delegate", agent }
: { _step: "delegate", agent, task };
}
/**
* A railway over flat subagents. `steps` run in order on the success track; the
* first step that returns an error short-circuits to `onError`. `recover`
* optionally retries the failing step a *bounded* number of times before the
* error track. There is intentionally no loop combinator the value is a finite
* tree, so it always terminates and is fully verifiable at compile time.
*/
export interface Railway {
readonly _specType: "railway";
readonly name: string;
readonly steps: readonly RailwayStep[];
/** Error track — runs with the failing step's error payload. */
readonly onError?: RailwayStep;
/** Bounded recovery: retry the failing step up to `max` times (finite). */
readonly recover?: { readonly step: RailwayStep; readonly max: number };
}
/**
* Compose flat subagents into a railway (compiles to an orchestrator command).
*
* railway({
* name: "ship",
* steps: [delegate("planner"), delegate("coder"), delegate("reviewer")],
* onError: delegate("reporter"),
* recover: { step: delegate("fixer"), max: 2 },
* })
*/
export function railway(spec: Omit<Railway, "_specType">): Railway {
return { _specType: "railway", ...spec };
}
// ---------------------------------------------------------------------------
// Spec file naming convention (#11)
//
+109
View File
@@ -0,0 +1,109 @@
/**
* Tests for the significance stats (src/stats.ts) pure, model-free. The
* numerics are checked against known closed forms and t-table values so the
* p-values are grounded, not just internally consistent.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import {
regularizedIncompleteBeta,
tPValueTwoSided,
welchTTest,
compareArms,
} from "./stats.js";
import type { EvalReport, MetricStat } from "./eval.js";
const close = (a: number, b: number, tol = 1e-3): boolean =>
Math.abs(a - b) < tol;
test("regularizedIncompleteBeta: endpoints and a known closed form", () => {
assert.equal(regularizedIncompleteBeta(2, 3, 0), 0);
assert.equal(regularizedIncompleteBeta(2, 3, 1), 1);
// I_0.5(0.5, 0.5) = (2/π)·arcsin(√0.5) = 0.5 (the arcsin distribution)
assert.ok(close(regularizedIncompleteBeta(0.5, 0.5, 0.5), 0.5));
// both code branches: x below and above (a+1)/(a+b+2)
assert.ok(close(regularizedIncompleteBeta(0.5, 0.5, 0.25), 1 / 3, 5e-3)); // arcsin: 2/π·asin(0.5)=1/3
assert.ok(close(regularizedIncompleteBeta(0.5, 0.5, 0.75), 2 / 3, 5e-3));
});
test("tPValueTwoSided matches t-table critical values (p≈0.05)", () => {
assert.equal(tPValueTwoSided(0, 10), 1); // no difference
assert.ok(close(tPValueTwoSided(2.131, 15), 0.05, 5e-3)); // t_.975,15 = 2.131
assert.ok(close(tPValueTwoSided(2.776, 4), 0.05, 5e-3)); // t_.975,4 = 2.776
assert.ok(close(tPValueTwoSided(2.228, 10), 0.05, 5e-3)); // t_.975,10 = 2.228
assert.ok(tPValueTwoSided(8, 30) < 1e-6); // huge t → ~0
assert.equal(tPValueTwoSided(2, 0), 1); // df ≤ 0 guard
});
test("welchTTest: clearly-significant vs clearly-noise gaps", () => {
// big separation, tight se → significant
const sig = welchTTest(
{ mean: 0.6, se: 0.05, n: 20 },
{ mean: 0.1, se: 0.05, n: 20 },
);
assert.ok(sig.delta > 0 && sig.significant && sig.pValue < 0.01);
// small gap, wide se → not significant
const noise = welchTTest(
{ mean: 0.5, se: 0.2, n: 5 },
{ mean: 0.4, se: 0.2, n: 5 },
);
assert.ok(!noise.significant && noise.pValue > 0.1);
});
test("welchTTest: deterministic arms (se = 0) decide by exact difference", () => {
// perfect separation, no variance → significant (p = 0)
const sep = welchTTest({ mean: 1, se: 0, n: 5 }, { mean: 0, se: 0, n: 5 });
assert.ok(sep.significant && sep.pValue === 0 && sep.df === 0);
// identical deterministic arms → not significant (p = 1)
const same = welchTTest({ mean: 1, se: 0, n: 5 }, { mean: 1, se: 0, n: 5 });
assert.ok(!same.significant && same.pValue === 1);
});
test("welchTTest: one deterministic arm, one varying (mixed df terms)", () => {
const c = welchTTest(
{ mean: 0.9, se: 0.1, n: 10 }, // varying arm contributes to df
{ mean: 0.2, se: 0, n: 10 }, // deterministic baseline contributes 0
);
assert.ok(c.delta > 0 && c.significant && c.df > 0);
});
function makeReport(
stats: Record<string, Record<string, MetricStat>>,
): EvalReport {
const arms: EvalReport["arms"] = {};
for (const [name, s] of Object.entries(stats)) {
arms[name] = {
runs: 0,
metrics: {},
stats: s,
usage: {
totalCostUsd: 0,
meanCostUsd: 0,
meanDurationMs: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
},
};
}
return { name: "r", trials: 0, totalCostUsd: 0, aborted: false, arms };
}
test("compareArms reads arm stats from a report, or null if absent", () => {
const stat = (mean: number, se: number, n: number): MetricStat => ({
mean,
se,
n,
std: se * Math.sqrt(n),
passK: 0,
});
const report = makeReport({
base: { caught: stat(0.1, 0.05, 20) },
arm: { caught: stat(0.6, 0.05, 20) },
});
const c = compareArms(report, "base", "arm", "caught");
assert.ok(c && c.delta > 0 && c.significant);
assert.equal(compareArms(report, "base", "arm", "missing"), null); // metric absent
assert.equal(compareArms(report, "nope", "arm", "caught"), null); // arm absent
});
+164
View File
@@ -0,0 +1,164 @@
/**
* vigiles significance testing for eval A/B arms.
*
* The eval tier already reports mean ± se per arm; this answers the question that
* `assertImproves(..., { by: se })` punted to the user: is the gap between two
* arms real, or noise? A Welch's t-test over the per-arm summary stats (mean, se,
* n) no raw rows needed yields a two-sided p-value and a significance verdict.
* Pure + model-free, so it's fully unit-tested against known t-table values.
*
* For 0/1 (proportion) metrics this is the t approximation to the two-proportion
* test close at the trial counts evals use, and one code path for any metric.
* The numerics (log-gamma, incomplete beta) are specialized to the argument range
* these tests produce (a, b 0.5; x (0,1)); they are not a general library.
*/
import type { EvalReport } from "./eval.js";
// Lanczos coefficients (g = 7) for log-gamma; sufficient for the beta args here.
const LANCZOS = [
676.5203681218851, -1259.1392167224028, 771.32342877765313,
-176.61502916214059, 12.507343278686905, -0.13857109526572012,
9.9843695780195716e-6, 1.5056327351493116e-7,
];
/** Log-gamma via Lanczos. Valid for x ≥ 0.5 (all args used below satisfy this). */
function lgamma(x: number): number {
const g = 7;
const xm1 = x - 1;
const base = LANCZOS.reduce(
(acc, c, i) => acc + c / (xm1 + i + 1),
0.99999999999980993,
);
const tt = xm1 + g + 0.5;
return (
0.5 * Math.log(2 * Math.PI) +
(xm1 + 0.5) * Math.log(tt) -
tt +
Math.log(base)
);
}
/** Continued fraction for the incomplete beta (Numerical Recipes betacf). */
function betacf(a: number, b: number, x: number): number {
const MAXIT = 200;
const EPS = 1e-12;
const qab = a + b;
const qap = a + 1;
const qam = a - 1;
let c = 1;
let d = 1 / (1 - (qab * x) / qap);
let h = d;
for (let m = 1; m <= MAXIT; m++) {
const m2 = 2 * m;
let aa = (m * (b - m) * x) / ((qam + m2) * (a + m2));
d = 1 / (1 + aa * d);
c = 1 + aa / c;
h *= d * c;
aa = (-(a + m) * (qab + m) * x) / ((a + m2) * (qap + m2));
d = 1 / (1 + aa * d);
c = 1 + aa / c;
const del = d * c;
h *= del;
if (Math.abs(del - 1) < EPS) break;
}
return h;
}
/** Regularized incomplete beta I_x(a, b) ∈ [0, 1]. */
export function regularizedIncompleteBeta(
a: number,
b: number,
x: number,
): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const front = Math.exp(
lgamma(a + b) -
lgamma(a) -
lgamma(b) +
a * Math.log(x) +
b * Math.log(1 - x),
);
return x < (a + 1) / (a + b + 2)
? (front * betacf(a, b, x)) / a
: 1 - (front * betacf(b, a, 1 - x)) / b;
}
/** Two-sided p-value for Student's t with `df` degrees of freedom. */
export function tPValueTwoSided(t: number, df: number): number {
if (df <= 0) return 1;
return regularizedIncompleteBeta(df / 2, 0.5, df / (df + t * t));
}
/** The verdict on one arm-vs-baseline comparison for a single metric. */
export interface Comparison {
/** mean(arm) mean(baseline). */
readonly delta: number;
/** Combined standard error of the difference. */
readonly seDelta: number;
/** Welch t statistic (delta / seDelta). */
readonly t: number;
/** WelchSatterthwaite degrees of freedom. */
readonly df: number;
/** Two-sided p-value for the difference. */
readonly pValue: number;
/** p < alpha — the difference is unlikely to be noise. */
readonly significant: boolean;
}
type Summary = {
readonly mean: number;
readonly se: number;
readonly n: number;
};
// Variance contribution of one arm to the Welch df denominator. Guarded by v > 0
// (se > 0 ⇒ n ≥ 2, so n 1 ≥ 1); a deterministic arm (se = 0) contributes 0.
const dfTerm = (v: number, n: number): number =>
v > 0 ? (v * v) / (n - 1) : 0;
/** Welch's unequal-variance t-test between two arms' summary stats. */
export function welchTTest(
arm: Summary,
baseline: Summary,
alpha = 0.05,
): Comparison {
const delta = arm.mean - baseline.mean;
const va = arm.se ** 2;
const vb = baseline.se ** 2;
const seDelta = Math.sqrt(va + vb);
if (seDelta === 0) {
// Both arms are deterministic: significant iff they differ at all.
const significant = delta !== 0;
return {
delta,
seDelta,
t: 0,
df: 0,
pValue: significant ? 0 : 1,
significant,
};
}
const t = delta / seDelta;
const df = (va + vb) ** 2 / (dfTerm(va, arm.n) + dfTerm(vb, baseline.n));
const pValue = tPValueTwoSided(t, df);
return { delta, seDelta, t, df, pValue, significant: pValue < alpha };
}
/**
* Compare two arms on a metric using their reported summary stats, or null if
* either arm/metric is absent. The grounded form of `assertImproves`'s `by`: it
* computes the noise floor instead of asking the caller to supply it.
*/
export function compareArms(
report: EvalReport,
baseline: string,
arm: string,
metric: string,
alpha = 0.05,
): Comparison | null {
const a = report.arms[arm]?.stats[metric];
const b = report.arms[baseline]?.stats[metric];
if (!a || !b) return null;
return welchTTest(a, b, alpha);
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests for the cross-language symbol index (ast-grep): per-file extraction,
* the project index, and bare/scoped resolution (unique/ambiguous/missing).
*/
import { test } from "node:test";
import { test } from "vitest";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, before, after } from "node:test";
import { describe, it, beforeAll as before, afterAll as after } from "vitest";
import assert from "node:assert/strict";
import {
mkdtempSync,
+111
View File
@@ -0,0 +1,111 @@
/**
* Conformance tests over REAL, vendored plugins (examples/harness/vendor/*).
*
* Model-free and in-gate: loadPlugin parses + materializes each plugin's ACTUAL
* shipped layout, and we assert invariants that must hold for any well-formed
* plugin it loads, `${CLAUDE_PLUGIN_ROOT}` resolves, skills materialize, and the
* warnings (surface + dangling-ref) are accurate. Grounded in reality rather than
* synthetic fixtures: this is the shape that caught the superpowers partial-vendor
* dangling ref. Each plugin is pinned by commit SHA, so the suite is deterministic
* and offline (no network, no model, no API key).
*
* Assertions are INVARIANTS, never version trivia we check "≥ 1 skill loaded"
* and "the known dangling ref is flagged, nothing spurious", not "exactly N
* skills" (which would break on a harmless re-pin and test nothing real).
*/
import { test } from "vitest";
import assert from "node:assert/strict";
import { join } from "node:path";
import { loadPlugin } from "./plugin-loader.js";
// __dirname is dist/ at runtime; the vendored plugins live at the repo root.
const VENDOR = "../examples/harness/vendor";
interface PluginCase {
readonly label: string;
readonly dir: string;
readonly expectHooks: boolean;
readonly minSkills: number;
readonly expectAgents: boolean;
readonly expectCommands: boolean;
/** Intra-plugin file refs knowingly absent in the snapshot (e.g. a partial vendor). */
readonly knownDangling: readonly string[];
}
const PLUGINS: readonly PluginCase[] = [
{
label: "obra/superpowers",
dir: "superpowers@6fd4507",
expectHooks: true,
minSkills: 1,
expectAgents: false,
expectCommands: false,
// the vendored slice omits skills/using-superpowers/, which SessionStart reads
knownDangling: ["skills/using-superpowers/SKILL.md"],
},
{
label: "wshobson/accessibility",
dir: "wshobson-accessibility@cf6059d",
expectHooks: false,
minSkills: 1,
expectAgents: true,
expectCommands: true,
knownDangling: [],
},
];
const hasWarning = (ws: readonly string[], sub: string): boolean =>
ws.some((w) => w.includes(sub));
/** How many intra-plugin dangling refs the loader flagged (parsed from the warning). */
const danglingCount = (ws: readonly string[]): number => {
const w = ws.find((x) => x.includes("intra-plugin"));
const m = w?.match(/references (\d+) intra-plugin/);
return m?.[1] ? Number(m[1]) : 0;
};
const skillCount = (files: Record<string, string>): number =>
Object.keys(files).filter((f) => /skills\/.*SKILL\.md$/.test(f)).length;
for (const p of PLUGINS) {
test(`${p.label}: loadPlugin parses the real shipped layout`, () => {
const loaded = loadPlugin(join(__dirname, VENDOR, p.dir));
// 1. a real surface loaded — never a silent empty machine
assert.ok(
loaded.settings.hooks || Object.keys(loaded.files).length > 0,
"expected hooks or files to load",
);
assert.ok(
!hasWarning(loaded.warnings, "nothing was loaded"),
"should not be an empty machine",
);
// 2. hooks presence matches, and ${CLAUDE_PLUGIN_ROOT} fully resolved
assert.equal(Boolean(loaded.settings.hooks), p.expectHooks);
assert.ok(
!JSON.stringify(loaded.settings).includes("CLAUDE_PLUGIN_ROOT"),
"no unresolved ${CLAUDE_PLUGIN_ROOT}",
);
// 3. skills materialized into the sandbox
assert.ok(
skillCount(loaded.files) >= p.minSkills,
`expected ≥ ${String(p.minSkills)} skill(s)`,
);
// 4. surface warnings are accurate (agents/commands belong to the eval tier)
assert.equal(hasWarning(loaded.warnings, "subagent file"), p.expectAgents);
assert.equal(
hasWarning(loaded.warnings, "slash-command file"),
p.expectCommands,
);
// 5. dangling-ref detector is accurate: exactly the known set, nothing spurious
assert.equal(danglingCount(loaded.warnings), p.knownDangling.length);
for (const ref of p.knownDangling) {
assert.ok(hasWarning(loaded.warnings, ref), `should flag ${ref}`);
}
});
}
+9
View File
@@ -82,6 +82,15 @@ echo " turns: passing-gate=$TP failing-gate=$TF"
if [ "$TP" = "1" ]; then ok "passing result gate → claude stops (1 turn)"; else bad "passing gate should stop in 1 turn (got $TP)"; fi
if [ "$TF" -gt "$TP" ] 2>/dev/null; then ok "failing result gate → Stop blocked, claude forced to continue ($TF turns)"; else bad "failing gate should block (turns=$TF)"; fi
# ---------------------------------------------------------------------------
# Note: the subagent PreToolUse tool-contract rail (`vigiles agent-hook`) is a
# *tool-event* hook. Driving a tool call deterministically needs the model to
# actually invoke the tool, which is flaky against a scripted mock — so the rail
# is proven at the cheap, deterministic unit tier instead: a real synthesized
# PreToolUse event piped straight to the built CLI hook process. See the
# "agent-hook (CLI): the real PreToolUse rail process" tests in
# src/agent-runtime.test.ts (runHook against `dist/cli.js agent-hook`).
# ---------------------------------------------------------------------------
echo ""
echo "E2E: $PASS passed, $FAIL failed"
+55 -6
View File
@@ -1,12 +1,61 @@
import { defineConfig } from "vitest/config";
// Scope vitest to the cross-runner integration tests only — the src/*.test.ts
// suites are node:test and must not be picked up here.
// Vitest is the primary runner. Two projects:
// - `unit` — the TypeScript source suites (`src/**/*.test.ts`), run directly
// (esbuild), with `.js` import specifiers resolved to their `.ts`
// source so NodeNext-style imports work without a build step.
// - `runners` — the cross-runner constraint: the same `vigilesMatchers` register
// and pass under vitest, loaded from the built `dist` the way a
// user would (`npm run test:vitest`). Proves runner-agnosticism.
export default defineConfig({
test: {
include: ["test/runners/**/*.vitest.mjs"],
// Load the opt-in entry the way a user would — this also tests that
// `vigiles/vitest` registers the matchers (auto-register).
setupFiles: ["./dist/vitest.mjs"],
projects: [
{
test: {
name: "unit",
include: ["src/**/*.test.ts"],
// node:test had no per-test timeout; some suites scan all 7 linter
// catalogs or spawn the built CLI and legitimately take several seconds.
testTimeout: 60000,
hookTimeout: 60000,
},
resolve: { extensionAlias: { ".js": [".ts", ".js"] } },
},
{
test: {
name: "runners",
include: ["test/runners/**/*.vitest.mjs"],
setupFiles: ["./dist/vitest.mjs"],
},
},
],
// 100% gate, scoped to the harness-testing pillar (the deterministic library
// this suite owns end-to-end). The eval path's real `spawn` boundary is the
// one thing a unit test can't reach — it carries a `v8 ignore` marker.
coverage: {
provider: "v8",
include: [
"src/harness-test.ts",
"src/harness-assert.ts",
"src/eval.ts",
"src/run-hook.ts",
"src/mock-model.ts",
"src/plugin-loader.ts",
"src/judge.ts",
"src/sandbox.ts",
],
// 100% lines/functions/statements. Branches floor at 90: the remainder
// are defensive fallbacks that can't be hit deterministically — `?? ""` on
// already-typed CLI output, `n > 0 ? … : 0` on non-empty arrays, a
// signal-kill exit code (`res.status ?? (res.signal ? 1 : 0)`). Gaming
// those with ignores would only lower the signal.
thresholds: {
lines: 100,
functions: 100,
statements: 100,
branches: 90,
},
reporter: ["text", "lcov"],
},
},
});