jev 0.2
September 18, 2026 · View on GitHub
Grounded in the first day of real use (52 logged decisions): 10 of 23 gate escalations were uncertain-only noise on ordinary in-project file edits made through Bash; the MCP tools made the calling LLM pay output tokens to feed Jev; the stop check could not challenge a false "tests pass".
All invariants in docs/PLUGIN_SPEC.md still hold: escalate-only (never emit "allow"), fail open,
stdout is protocol, code before model, no install step (bundled plugin/dist is committed),
hook.mjs carries no MCP SDK / zod.
A. Path-based MCP tools — Jev reads what the LLM has not
The point: the caller passes references, the server reads the content, and only
path:start-end + score comes back. The caller never emits or ingests rejected text.
Shared file access layer src/files/ (used by MCP tools only, never by hooks):
- Root =
CLAUDE_PROJECT_DIRif set, elseprocess.cwd(). Every path is resolved withrealpathand MUST stay inside root (symlink escapes rejected). Absolute paths inside root are fine. - Refuse sensitive paths using the SAME predicate the hook prefilter uses (move
isSensitivePathto a dependency-free shared module, e.g.src/util/sensitive-path.ts; both import it). - Skip: binary files (NUL byte in first 8 KB), files > 512 KB, and by default anything under
node_modules,.git,dist,build,.next,target,vendor, plus lockfiles and*.min.*. - Glob: any small, bundle-friendly implementation (mcp.mjs is bundled; hook.mjs must not grow). Cap 1,000 matched files; clear error beyond that telling the caller to narrow the glob.
- Chunking: line-based, ~60 lines per chunk with 5-line overlap, never splitting mid-line; each
chunk knows
path,start_line,end_line. Chunks longer than ~6,000 chars are split further. - Hard cost ceiling per call: estimated 3M input tokens (~$0.13). Over it -> error with the estimate and the number of files/chunks, nothing sent.
- Skipped files are reported by count and reason, never silently dropped. Six reasons as built, not
the four first sketched here:
skipped: {binary, too_large, sensitive, outside_root, not_found, ignored}.not_foundis a named path that is not there, andignoredis generated output — both are drops the original four could not express.outside_rootcounts a directory that resolves out of the project once, rather than each file inside it: asrc/etc -> /etcsymlink otherwise enumerated 233 entries to report 233 refusals, and a link to/would crawl the disk, because the match cap counts matches and a path outside the root never becomes one.
jev_rank — exactly one of candidates | paths | glob (zod refine; clear error otherwise).
- New optional
unit: "chunk" | "file"(defaultchunk) for paths/glob.filescores each file by its best chunk and returns one row per file with that chunk's line range. - Output rows for file sources:
{path, start_line, end_line, relevance, rank}(path relative to root). Still no text echoed. Addfiles_scanned,chunks_scored,skipped,est_cost_usd. - Raise default fan-out concurrency for file sources to 8 (config
JEV_MAX_CONCURRENCYstill wins). - Cap candidates per request at 16 (
MAX_CANDIDATES_PER_REQUEST), for every source, with the context budget still applying — whichever binds first. Packing to the budget alone put ~50 candidates in a call and the scores stopped discriminating: measured live over 159 chunks of this repo, 53-per-request scored everything between 0.84 and 0.87 and missed the true answer entirely, 16-per-request ranked it first. The payload is sent once either way, so the fix cost 2.5% more input tokens and no extra wall-clock time. This is the one placecandidatesoutput does change (chunksreports a larger number) — it had the same defect and it was worth fixing. - Add
score_spread(top relevance minus median, 2 dp) to the output for every source: the honest diagnostic for whether a ranking discriminated at all. Below 0.15 the ordering is not informative.any_relevantstays a maximum and is documented as biased upward on large sets, since more candidates means more requests and each contributes a sample. - Existing
candidatesbehaviour and output are otherwise unchanged.
jev_verify — exactly one of evidence | evidence_path (+ optional start_line/end_line).
- If the evidence exceeds the per-request budget, chunk it and verify every claim against every
chunk. Merge per claim: take the chunk whose max(supported, contradicted) is highest; the claim
is
not_addressedonly if it isnot_addressedin every chunk. If one chunk firmly supports and another firmly contradicts (both >= auto), verdictconflicting, gateescalate. - Add
where: {start_line, end_line}to each claim when evidence came from a file or was chunked.
Tool descriptions must lead with the economics: "pass paths/globs for anything you have not
already read — do not read files in order to pass their text". Keep each under 1,200 chars.
Update plugin/skills/jev-decisions to teach this (rank a glob BEFORE reading; verify a draft
against evidence_path).
B. Verification ledger — evidence for the stop check
Bookkeeping in code, judgment by Jev, decision by policy.
- In the async
Approvalhook path (this repo's own argv label, not a Claude Code event; it is registered under the real PostToolUse / PostToolUseFailure events withasync: true, already for Bash|Write|Edit|MultiEdit|NotebookEdit): maintain in the session fileverification: { last?: {kind, ok, ts, command(<=200, redacted)}, edits_since: number }.- A Bash command is a verification command when a pure classifier says so
(
verificationKind(command) -> "test"|"build"|"typecheck"|"lint"|undefined): test runners,npm|pnpm|yarn|bun (run)? test|build|lint|type-check|typecheck|check,tsc,cargo test|check| build|clippy,go test|build|vet,pytest,vitest,jest,ruff check,mypy,eslint, ... Compound commands count if any segment matches. ok: settled from the docs. A non-zero Bash exit arrives as PostToolUseFailure, which fires instead of PostToolUse, and carries top-levelerror(first lineExit code N),is_interrupt?andduration_ms?— there is notool_response, and no documentedexit_codeorisErrorfield. A successful Bash PostToolUse carries{stdout, stderr, interrupted, isImage}. Both are handled, plus the undocumentedexit_code/isError/successshapes defensively.stdoutis never scanned forExit code N— a test suite printing that string is not a failed command.is_interrupt/interruptedrecords nothing at all: an aborted run is not evidence about the code.- Write/Edit/MultiEdit/NotebookEdit success, and Bash in-project writes recognised by the
prefilter (section C), increment
edits_since; a verification command resets it to 0. - UserPromptSubmit does NOT reset the ledger (verification state outlives a prompt).
- A Bash command is a verification command when a pure classifier says so
(
- Stop handler: add Noul
claims_verified— "final_messagestates that tests, a build, a type-check or a lint run passed or succeeded" (true/false criteria; literal). - Policy (pure, truth-table tested). Existing stop-short rule unchanged. New rule:
block iff
claims_verified >= autoANDverification.lastexists ANDlast.ok === false. Reason to Claude: "[jev] Your final message says checks pass (p=0.xx), but the last {kind} command ({command}) failed {n} min ago and nothing has passed since. Re-run it, or correct the claim." (minutes computed in code.) NOT a block, log-only with decisionunverified-claim: claims_verified >= auto AND (no verification on record OR edits_since > 0). That is calibration data, not an accusation. - Same one-block-per-prompt cap,
stop_hook_active, background-task and question skips as today. - The async hook can lose a race with Stop; that only ever makes the check more lenient. Document it.
C. Noise
- Prefilter: Bash writes that are equivalent to an Edit are treated like one.
prefilterBash(command, {cwd, strict}): when every segment is allowlisted-read-only EXCEPT for (a) output redirects>/>>whose targets, and (b)sed -i/--in-placewhose file operands, (c)tee [-a] file, (d)cat > file <<'EOF'heredoc writes — all resolve insidecwd, are not sensitive, and the command has no substitution/expansion/grouping — return the same verdictprefilterFileWritewould (skip in standard, judge in strict). Anything else about redirects staysjudge. Interpreter heredocs (python3 - <<EOF) stayjudge— opaque is opaque. Hard patterns still run first. ExposewritesInProject: booleanon the verdict for the ledger. - Policy option
corroborateUncertain?: booleanongateActionPolicy(hook-only, on in standard, off in strict, default off so the MCP tool is unchanged): an uncertain risk signal (destructive/outward_facing/credential_exposure in the uncertain band) fires only when corroborated by blast_radius >= HIGH_BLAST_RADIUS, or a second risk signal >= 0.5, or in_scope leaning no. Log it inrecord.policyand replay it in/jev:calibrate. - ACCEPTANCE TEST against real data:
tests/fixtures/day1-decisions.jsonl(I will provide it — signals and policy only, subjects stripped). Replaying every judged PreToolUse record through the new standard-mode policy must (a) still escalate every record that has a firm signal (any risk signal >= 0.85, in_scope <= 0.15, or blast >= 2 withoutrequested), and (b) escalate at most 2 of the uncertain-only records. Print the before/after counts in the test name or a snapshot so the number is visible.
D. Production readiness
tests/versions.test.ts: package.json, plugin.json, marketplace.json,SERVER_VERSIONagree. Addnpm run bump -- <version>(scripts/bump.ts) that sets all four and the lockfile.- CI
.github/workflows/ci.yml: Node 20 + 22 matrix;npm ci, type-check, test, build, thengit diff --exit-code plugin/distso a stale committed bundle fails the build. (Confirm the esbuild output is deterministic across the two Node versions; if not, check freshness on one.) CHANGELOG.md(Keep a Changelog style) reconstructing 0.1.0–0.1.4 fromgit logand adding 0.2.0.SECURITY.md: what leaves the machine (redacted excerpts of tool inputs/results and the last few prompts go to api.typesafe.ai; file contents go only when an MCP tool is pointed at them), what never does (sensitive-path files, anything outside the project root), where the log lives and how to delete it, the advisory-not-a-boundary statement, and how to report a vulnerability (GitHub private advisory on Brainwires/jevwire).- README: restructure for a first-time reader — what it is in 5 lines; install (plugin first, then
bare MCP, then library); a "what you will see" section with real
[jev]lines and a real prompt screenshot description; settings table (incl.auto_mode, both API-key routes); the tools with the path-based examples first; commands; guarantees; limits & honest caveats (slower per judged call ~0.5 s; advisory; Jev's known failure modes; calibration is yours to measure); cost; FAQ (hooks silent? -> /jev:status; too many prompts? -> /jev:calibrate, gate_mode; key not picked up? -> /reload-plugins). State plainly that it has been exercised against the live API, with the date. package.json:engines,repository,bugs,homepage,keywords;npm pack --dry-runmust contain dist/, plugin/, .claude-plugin/, README, LICENSE, CHANGELOG and nothing from tests/.- Version 0.2.0.