mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
73a9dde310
* 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>
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
/**
|
|
* 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 "vitest";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
parseSkillGates,
|
|
runGate,
|
|
runSkillGates,
|
|
detectProjectCommand,
|
|
setActiveSkill,
|
|
clearActiveSkill,
|
|
readActiveSkill,
|
|
evaluateStopHook,
|
|
} from "./skill-runtime.js";
|
|
|
|
const SAMPLE = `# skill
|
|
|
|
### Step 1
|
|
do a thing
|
|
<!-- vigiles:gate "true" -->
|
|
|
|
### Step 2
|
|
do another
|
|
<!-- vigiles:gate "false" retry:2 -->
|
|
|
|
## Result
|
|
<!-- vigiles:result "true" -->
|
|
`;
|
|
|
|
test("parseSkillGates extracts step gates (with retry) and the result gate", () => {
|
|
const g = parseSkillGates(SAMPLE);
|
|
assert.equal(g.steps.length, 2);
|
|
assert.deepEqual(g.steps[0], {
|
|
step: 1,
|
|
gate: { kind: "cmd", command: "true", retry: 1 },
|
|
});
|
|
assert.deepEqual(g.steps[1], {
|
|
step: 2,
|
|
gate: { kind: "cmd", command: "false", retry: 2 },
|
|
});
|
|
assert.deepEqual(g.result, { kind: "cmd", command: "true", retry: 1 });
|
|
});
|
|
|
|
test("parseSkillGates parses file gates", () => {
|
|
const g = parseSkillGates(
|
|
"### Step 1\n<!-- vigiles:gate file:package.json -->\n",
|
|
);
|
|
assert.deepEqual(g.steps[0].gate, {
|
|
kind: "file",
|
|
path: "package.json",
|
|
retry: 1,
|
|
});
|
|
});
|
|
|
|
test("runGate: command exit 0 passes, non-zero fails", () => {
|
|
assert.equal(
|
|
runGate({ kind: "cmd", command: "true", retry: 1 }, process.cwd()).ok,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
runGate({ kind: "cmd", command: "false", retry: 1 }, process.cwd()).ok,
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("runGate: file existence", () => {
|
|
assert.equal(
|
|
runGate({ kind: "file", path: "package.json", retry: 1 }, process.cwd()).ok,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
runGate({ kind: "file", path: "nope.nonexistent", retry: 1 }, process.cwd())
|
|
.ok,
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("runSkillGates short-circuits at the first failing gate", () => {
|
|
const report = runSkillGates(parseSkillGates(SAMPLE), process.cwd());
|
|
assert.equal(report.ok, false);
|
|
assert.equal(report.blockedAt, 2);
|
|
// step 1 ran (passed), step 2 ran (failed), result NOT reached.
|
|
assert.equal(report.results.length, 2);
|
|
assert.equal(report.results[0].ok, true);
|
|
assert.equal(report.results[1].ok, false);
|
|
});
|
|
|
|
test("runSkillGates runs the result gate when all steps pass", () => {
|
|
const md = `### Step 1
|
|
<!-- vigiles:gate "true" -->
|
|
|
|
## Result
|
|
<!-- vigiles:result "true" -->
|
|
`;
|
|
const report = runSkillGates(parseSkillGates(md), process.cwd());
|
|
assert.equal(report.ok, true);
|
|
assert.equal(report.blockedAt, null);
|
|
assert.equal(report.results.length, 2);
|
|
assert.equal(report.results[1].at, "result");
|
|
});
|
|
|
|
// --- Project-role gates (cross-repo portability) ---
|
|
|
|
test("parseSkillGates parses role gates", () => {
|
|
const g = parseSkillGates(
|
|
`### Step 1\n<!-- vigiles:gate role:test retry:2 -->\n\n## Result\n<!-- vigiles:result role:build -->\n`,
|
|
);
|
|
assert.deepEqual(g.steps[0].gate, { kind: "role", role: "test", retry: 2 });
|
|
assert.deepEqual(g.result, { kind: "role", role: "build", retry: 1 });
|
|
});
|
|
|
|
test("detectProjectCommand resolves a role to the host ecosystem's command", () => {
|
|
const js = mkdtempSync(join(tmpdir(), "vigiles-js-"));
|
|
writeFileSync(
|
|
join(js, "package.json"),
|
|
JSON.stringify({ scripts: { test: "vitest", build: "tsc" } }),
|
|
);
|
|
assert.equal(detectProjectCommand("test", js), "npm test");
|
|
assert.equal(detectProjectCommand("build", js), "npm run build");
|
|
assert.equal(detectProjectCommand("lint", js), null); // no lint script
|
|
rmSync(js, { recursive: true, force: true });
|
|
|
|
const py = mkdtempSync(join(tmpdir(), "vigiles-py-"));
|
|
writeFileSync(join(py, "pyproject.toml"), "[tool.pytest.ini_options]\n");
|
|
assert.equal(detectProjectCommand("test", py), "pytest");
|
|
rmSync(py, { recursive: true, force: true });
|
|
|
|
const rs = mkdtempSync(join(tmpdir(), "vigiles-rs-"));
|
|
writeFileSync(join(rs, "Cargo.toml"), "[package]\n");
|
|
assert.equal(detectProjectCommand("test", rs), "cargo test");
|
|
rmSync(rs, { recursive: true, force: true });
|
|
|
|
const empty = mkdtempSync(join(tmpdir(), "vigiles-empty-"));
|
|
assert.equal(detectProjectCommand("test", empty), null);
|
|
rmSync(empty, { recursive: true, force: true });
|
|
});
|
|
|
|
test("runGate role fails (not silently passes) when no command is detected", () => {
|
|
const empty = mkdtempSync(join(tmpdir(), "vigiles-norole-"));
|
|
const r = runGate({ kind: "role", role: "test", retry: 1 }, empty);
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.output, /No test command detected/);
|
|
rmSync(empty, { recursive: true, force: true });
|
|
});
|
|
|
|
// --- Stop-hook enforcement ---
|
|
|
|
function tmpSkill(resultGate: string): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "vigiles-skill-"));
|
|
writeFileSync(
|
|
join(dir, "SKILL.md"),
|
|
`### Step 1\n<!-- vigiles:gate "true" -->\n\n## Result\n<!-- vigiles:result "${resultGate}" -->\n`,
|
|
);
|
|
return dir;
|
|
}
|
|
|
|
test("active-skill marker roundtrips and clears", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "vigiles-active-"));
|
|
assert.equal(readActiveSkill(dir), null);
|
|
setActiveSkill(dir, "SKILL.md");
|
|
assert.equal(readActiveSkill(dir), "SKILL.md");
|
|
clearActiveSkill(dir);
|
|
assert.equal(readActiveSkill(dir), null);
|
|
assert.equal(existsSync(join(dir, ".vigiles/active-skill.json")), false);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("evaluateStopHook allows when no skill is active", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "vigiles-hook-"));
|
|
const d = evaluateStopHook(dir);
|
|
assert.equal(d.allow, true);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("evaluateStopHook allows when the result gate passes", () => {
|
|
const dir = tmpSkill("true");
|
|
setActiveSkill(dir, "SKILL.md");
|
|
const d = evaluateStopHook(dir);
|
|
assert.equal(d.allow, true);
|
|
assert.match(d.message, /result gate .* passed/);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("evaluateStopHook blocks when the result gate fails", () => {
|
|
const dir = tmpSkill("false");
|
|
setActiveSkill(dir, "SKILL.md");
|
|
const d = evaluateStopHook(dir);
|
|
assert.equal(d.allow, false);
|
|
assert.match(d.message, /is not done: result gate `false` failed/);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|