QwenPaw middleware plugin that puts a mu-style judgment kernel on the tool
seam. At each decision point (tool.risk, tool.approval) a small fast
judge answers one bounded typed question — noul (yes/no), choice, or score
— about a small state, with a calibrated probability. Verdicts cascade
through judge tiers (later judges only see the questions earlier ones left
uncertain), land in a ledger, and in active mode can deny a tool call or
escalate it to the human. shadow mode records without enforcing, so you can
read the ledger before handing a point to a judge. A port of
qybaihe/mu's jev integration
(docs/judges.md, docs/reference/decision-points.md); the wire format was
verified against mu-agent 0.1.8. Stdlib-only, no pip dependencies, no
telemetry.
The same kernel also runs as a PreToolUse hook in other agent hosts — ZCode, Qoder, Claude Code and Codex share one verdict dialect — see Other agent hosts.
Defaults are inert: tool.risk starts in shadow, tool.approval in off,
and tiers empty — no judge is contacted until you configure one. See
Quick start.
qwenpaw plugin install ~/.qwenpaw/plugins/jev-kernel # or a local checkout / release zip, see belowContents
- Why
- Quick start — install · configure · read the ledger · go active
- Other agent hosts — ZCode · Qoder · Claude Code · Codex
- How it works — decision points, three modes, the cascade, thresholds
- Judges and transports —
typesafe·http·llm·systemone· built-injev/sessiontiers - Configuration —
jev.json - Ledger
- Tools —
jev_ask,jev_ledger - Does it work?
- Development · Privacy · License
"Think before you act" lives in prompts, and prompts decay: the more rules you stack, the more the model learns to skim them. mu's answer is to move the judgment out of the prompt and onto the tool seam, and to make it small and measurable: one bounded question per decision point, answered with a probability a threshold can act on, cascaded across judges so a cheap local model can answer the easy calls and an uncertain one falls through to a stronger tier, and ledgered either way so the kernel's own calibration is something you can read instead of trust. This plugin ports that pattern to QwenPaw's AgentScope middleware seam, the same seam dispatch-guard uses.
It exists to fix a specific failure pattern: agents approving their own destructive tool calls because nothing between the decision and the execution ever asked a bounded question with a number attached.
-
Install:
qwenpaw plugin install https://github.com/CallMeHFK/jev-kernel/releases/latest/download/jev-kernel.zip # or from a checkout: qwenpaw plugin install /path/to/jev-kernel -
Configure. The durable home is
<host>/plugin-data/jev-kernel/jev.json(survives reinstalls); ajev.jsonnext toplugin.jsonoverrides it, and$JEV_KERNEL_CONFIGoverrides both (tests use it). Start fromjev.example.json. The smallest useful config exports one provider key and names the built-in tier:{ "tiers": ["jev"], "points": { "tool.risk": "shadow" } }with e.g.
TYPESAFE_API_KEYexported — or point a named judge at anything you already run, including a local decision model (see Judges and transports). -
Read the ledger at
<host>/plugin-data/jev-kernel/ledger.jsonl: one line per judged question, with the verdict, its probability, the judge that answered, latency, and — when nobody answered — the fallback reason. -
When the shadow ledger looks calibrated, flip the point:
"points": { "tool.risk": "active" }. From then on a confidentyesontool.riskdenies the call.tool.approvalescalates a call to human confirmation only when it is itselfactiveand the tool is listed inapprovalTools.
ZCode, Qoder, Claude Code and Codex all expose the tool seam as a
PreToolUse hook: the host pipes one JSON event to a command's stdin and
reads the verdict off stdout. All four read the same envelope —
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow|deny|ask", "permissionDecisionReason": …}} (Codex additionally
honours a top-level {"decision": "block", "reason": …}, which the hook
emits for denials) — verified against the qodercli 1.1.64 binary, ZCode's
app.asar, and codex 0.159.2's wire schema. backend/hook.py is that command:
python3 scripts/install_hook.py --host zcode # or: qoder | claude | codexThe installer gives each host a self-contained copy in its own
dot-folder — ~/.zcode/jev-kernel/, ~/.qoder-cn/jev-kernel/,
~/.claude/jev-kernel/, ~/.codex/jev-kernel/ — holding backend/,
jev.json and ledger.jsonl together (nothing rebuilds those folders, so
the dev repo can move or be deleted afterwards; QwenPaw's own install path
~/.qwenpaw/plugins/jev-kernel/ already works this way). It merges one hook
entry into the host's hook config (~/.zcode/hooks.json,
~/.qoder-cn/settings.json, ~/.claude/settings.json,
~/.codex/hooks.json), preserves hooks already there, backs the file up to
<path>.jev-bak on the first write, and is idempotent — re-running it is also the upgrade path
(re-deploys the code, rewrites a drifted command). --uninstall removes only
jev-kernel's hook entry and keeps the deployed folder (it holds the ledger);
--dry-run prints without writing. ZCode asks you to trust new hooks on
first run (workspace_hooks_execute_code) — approve it there.
Native plugin format (zcode pilot). All four hosts cloned the same
claude-derived plugin store (~/.<host>/plugins/, .<host>-plugin/plugin.json
manifest, enabledPlugins gating). --format plugin turns the deploy dir
into that package — .zcode-plugin/plugin.json + hooks/hooks.json inside
~/.zcode/jev-kernel/ — and registers it via plugins.dirs +
enabledPlugins["jev-kernel@inline"] in ~/.zcode/cli/config.json, removing
the legacy manual hook entry so the call is judged once. Verified for zcode
only so far; the other hosts stay on --format hook until piloted. Note:
ZCode has no ${PLUGIN_ROOT}-style variable in hook commands, so the
packaged hooks.json carries absolute paths — fine for local installs, a
known limitation for marketplace distribution.
Hook mode differences from the QwenPaw plugin:
- Config and ledger are per host:
<host folder>/jev-kernel/jev.jsonandledger.jsonl.$JEV_KERNEL_CONFIG/$JEV_KERNEL_DATA_DIRstill override; a first install seedsjev.jsonfrom a legacy shared~/.jev-kernel/jev.jsonif one exists. - The
sessiontier is QwenPaw-only — elsewhere it resolves to nothing and the cascade recordsunresolved:session. Use named judges orjev. tool.approvalinactivemode maps topermissionDecision: "ask", i.e. the host's own human confirmation flow, instead of a denied tool response.- Empty stdout means "no opinion": shadow mode, fallback verdicts, skipTools and every error path all exit 0 silently, so a dead kernel never blocks a tool call.
Two decision points sit on the tool seam, each with mu's three modes:
| Point | Question | active behaviour on a confident verdict |
|---|---|---|
tool.risk |
will this call destroy or irreversibly change user data or system state? | deny the tool call |
tool.approval |
should this call wait for the human? | escalate to the human approval flow |
Modes: active (verdicts can change what happens), shadow (verdicts are
ledgered, behaviour unchanged), off (point not evaluated).
Every question is one of three kinds — noul (yes/no with P(yes)),
choice (one option with per-option probabilities), score (index into
criteria with confidence) — and every answer carries a probability. Thresholds
(yes, no, minProbability, risk) turn probabilities into verdicts and
decide what "uncertain" means; uncertain questions fall through to the next
judge in the tier list, and a question nobody resolves gets a fallback verdict
whose reason is ledgered (mu's rule: a dead judge never wedges the agent and
never silently passes as a no).
Four wire transports, same bounded questions:
type |
Speaks |
|---|---|
typesafe |
TypeSafe's System One protocol: POST {model, state, questions} → {answers} (mu's baseUrl normalisation: / → +/v1/systemone, /v1 → +/systemone) |
http |
any endpoint taking {state, questions} → {answers} (Cloudflare Workers AI's {result: …} envelope is unwrapped) |
llm |
any OpenAI-compatible chat completions endpoint, prompted to answer as JSON; slower and every decision costs tokens, but needs nothing beyond a model you already have a key for |
systemone |
a raw System One server: ollama ≥ v0.35 decision models (nimble, tev1, clef, clef-flash) at POST /v1/systemone. The dialect is translated both ways — boolean goes out as noul, choice options become a criteria object, and answers come back as gateway shapes (noul → probability, a score's continuous expected value → the argmax level index) |
Two built-in tier names need no judges entry: jev resolves mu's provider
order from environment keys (TYPESAFE_API_KEY, MU_JUDGE_OPENROUTER_API_KEY,
AI_GATEWAY_API_KEY, OPENCODE_API_KEY, CLOUDFLARE_API_KEY +
CLOUDFLARE_ACCOUNT_ID) and falls back to the free OpenCode Zen tier when no
key is set; session inherits the QwenPaw session's own model (OpenAI-compatible
providers only, thinking disabled — thinking models blow the judge latency
budget), mu's mu setup --judge model equivalent.
Local decision models. ollama's decision models answer System One
natively but in the raw dialect (noul, criteria objects, expected-value
scores). Either use type: systemone directly, or run the bundled bridge
scripts/systemone_adapter.py, which translates gateway ↔ raw dialect on
loopback so any gateway-dialect client (including older kernel versions) can
use them:
OLLAMA_SYSTEMONE_URL=http://127.0.0.1:11434/v1/systemone python3 scripts/systemone_adapter.py --port 47824{ "judges": { "clef": { "type": "systemone", "baseUrl": "http://127.0.0.1:11434",
"model": "clef-flash:latest", "timeoutMs": 120000 } },
"tiers": ["clef", "session"] }A cheap local judge first, the session model catching what it leaves uncertain — the cascade is where the tiering pays for itself.
jev.json mirrors mu's judge section: points (point → mode), tiers
(default judge order), routes (per-point override), judges (name →
{type, baseUrl, model, apiKeyEnv, timeoutMs}), thresholds
(yes/no/minProbability/risk), approvalTools, skipTools,
timeoutMs, recordState, stateChars. It is hot-reloaded by mtime — edit
and the next decision point picks it up, no restart. jev.example.json
documents every knob with placeholder endpoints; the repo never ships a real
config.
<host>/plugin-data/jev-kernel/ledger.jsonl, one JSON line per judged
question: timestamp, point, mode, tool, the question asked, the verdict
(kind, value, probability, uncertain, judge, latency) or the fallback reason,
and whether it was enforced. Rotation is single-generation (mu's rule: the
ledger is evidence, not a database). In shadow mode this file is the whole
point — read it before you flip a point to active.
jev_ask— ask the kernel one bounded typed question about a given state and get the calibrated verdict back (agents and scripts can use the same seam the middleware uses).jev_ledger— read recent ledger records.
python -m unittest discover -s tests -v # 46 tests, bare interpreter
python packaging/build_plugin_zip.py # dist/jev-kernel.zipThe suite covers the wire shapes, uncertainty margins, cascade fall-through,
all four transports (against a local stub server speaking each dialect,
including ollama's raw System One), dead-judge fallback, active denial,
shadow pass-through, approval escalation, session-tier model inheritance,
publish hygiene, and the hook front end: each host's payload spelling and
verdict dialect, ZCode's strict-schema key set, and fail-open on garbage
stdin — including the installed invocation shape (python3 backend/hook.py --host …) run as a real subprocess. A shadow-mode session against a local clef-flash judged
get_current_time at P(yes)=0.016 and a self-created scratch-dir cleanup at
P(no)=0.99, while probes of rm -rf ~/Documents and git push --force
scored 0.96 and 0.89 — the calibration curve is the artefact to check in
your own ledger.
Prerequisites: Python ≥ 3.10, no runtime or test dependencies —
backend/main.py degrades its AgentScope/QwenPaw imports to local stand-ins,
so the suite runs on a bare interpreter.
git clone https://github.com/CallMeHFK/jev-kernel.git
cd jev-kernel
python -m unittest discover -s tests -v
ruff check .
python packaging/build_plugin_zip.pyOne host quirk worth knowing: QwenPaw hot reload re-executes the plugin entry
module but keeps the package's cached sibling submodules, so main.py purges
its own package's submodules from sys.modules before importing them —
without that, a reinstall keeps judging with the previous code. See
docs/DESIGN.md.
The kernel sends one small state and one question per decision point to the
judges you configure, and nothing else — no telemetry, no phone-home, nothing
beyond the judges your jev.json names. The judged state is the tool name
plus a truncated input snippet (stateChars, default 4000), and it is always
sent to the configured judge — the judge cannot answer "is this call
destructive?" without seeing the call. recordState (default false) only
controls whether that state is additionally written into the local ledger.
Your jev.json names your judge endpoints and is gitignored; the ledger
records your tool calls and lives outside the repo. Sanitize ledger excerpts
before pasting them anywhere public.
MIT — see LICENSE.