@riposta/pi-jev
A Jev classification layer for the Pi coding agent: typed routing, permission gating, context shielding, pruning and loop detection.
Package details
Install @riposta/pi-jev from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@riposta/pi-jev- Package
@riposta/pi-jev- Version
0.2.3- Published
- Sep 24, 2026
- Downloads
- 818/mo · 818/wk
- Author
- riposta
- License
- MIT
- Types
- extension
- Size
- 968.4 KB
- Dependencies
- 0 dependencies · 1 peer
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-jev
A typed classification layer for the Pi coding agent, backed by
TypeSafe's jev-latest System One model.
Coding agents make dozens of implicit decisions per session that nobody classifies:
which model tier a prompt deserves, whether a shell command is recoverable, whether
a 4,000-line build log is worth a place in the context window, whether the agent is
still working on what was asked. pi-jev turns each of those into an explicit Jev
question with a calibrated probability, a confidence value, and a threshold that is
reviewable in one file.
Five independently switchable modules, one request per hook, shadow mode by default.
Status
| Phase | Scope | State |
|---|---|---|
| 0 | config, client, cache, budget, telemetry, /jev |
implemented |
| 1 | router |
implemented, shadow |
| 2 | gate |
implemented, shadow; block opt-in |
| 3 | shield |
implemented, shadow |
| 4 | prune + watchdog |
implemented, disabled by default |
| 5 | release + measured numbers | fixtures measured; published on npm as @riposta/pi-jev (Trusted Publishing from GitHub Actions) and installable from git |
Every module ships in shadow mode: it classifies, logs the counterfactual decision, and changes nothing until you promote it. See Promoting a module.
Verified end-to-end against Pi 0.85.1: test/pi/run-e2e.mjs drives a real pi
session offline with a scripted model and asserts that the router classifies, a
live gate blocks, a live shield withholds, and a pi installed package
auto-loads (see Testing).
Measured results (v0 fixtures, tuned defaults)
Produced by tools/evaluate.ts against the labelled fixtures in this repository
(real api.typesafe.ai, jev-latest). These are the only numbers this project
quotes.
| Module | Metric | Target | Measured |
|---|---|---|---|
| router | tier accuracy vs labels | ≥ 80% | 90–92% across runs |
| router | added latency p50 | ≤ 600 ms | 288–673 ms (load-dependent) |
| gate | false negatives on dangerous | 0 | 0/19 |
| gate | false positives on safe | ≤ 10% | 5.0% (1/20) |
| shield | injection detection | ≥ 90% | 93.3% (14/15) |
| all | failures that block Pi | 0 | 0 |
Not yet measured: router net cost change (needs model pricing), gate disk-cache hit rate over real sessions (needs a warm log), prune token saving. See Calibration notes for what tuning the first run required.
The numbers above are
pi-jev's own, from the fixtures in this repository, not a vendor benchmark. The fixtures are a v0 seed (see Evaluation and calibration); re-runtools/evaluate.tsyourself to reproduce or refute them.
Install
pi-jev is a Pi extension, so Pi comes first.
1. Install Pi
Pi requires Node >= 22.19.
# installer (recommended)
curl -fsSL https://pi.dev/install.sh | sh
# or npm
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
Verify with pi --version. Pi also needs its own model provider — an API key
(for example ANTHROPIC_API_KEY) or /login inside Pi. That is separate from
the TypeSafe key pi-jev uses. See the Pi docs.
2. Set the TypeSafe API key
pi-jev reads the key from the environment of the process that starts Pi:
export TYPESAFE_API_KEY=...
Or, instead of an env var, log in through Pi's provider system — the key is entered in a masked prompt and stored in Pi's auth store:
/login typesafe
The env var wins when present; the stored key is the fallback. Check the state
with /jev auth. The variable name is configurable (apiKeyEnv), and there is
no phone-home: the key is only used for requests to api.typesafe.ai.
3. Install pi-jev
Pi >= 0.85.0 is required.
# from npm
pi install npm:@riposta/pi-jev
# or from git
pi install git:github.com/riposta/pi-jev
Use the scoped
@riposta/pi-jev, notpi-jev. The unscoped npm namepi-jevbelongs to an unrelated project (github.com/TheoOliveira/pi-jev), sopi install npm:pi-jevwould install someone else's code.
For local development, run Pi with the extension directly:
npm install
pi -e ./src/index.ts
# or install this checkout as a package (no publish needed)
pi install /absolute/path/to/pi-jev
Update a published install later with pi update npm:@riposta/pi-jev.
Every module starts in shadow mode, so installing it changes nothing until you promote one (see Promoting a module).
pi-jev sends metadata and sampled tool output to api.typesafe.ai. Read
Privacy before enabling it on a repository you care about.
Commands
Everything lives under /jev. Run it with no arguments for the status summary.
| Command | Scope | Does |
|---|---|---|
/jev |
— | Status: the status line, active model and QUESTIONS_VERSION, each module's off / shadow / live state, and request/token/cost usage. An unknown subcommand prints the same summary. |
/jev auth |
— | Shows whether a TypeSafe key is available and where it came from (env or /login typesafe). |
/jev explain [hook] |
— | The most recent classification — overall, or for a specific router/gate/shield/prune/watchdog: every answer with its probability or score, the decision, the threshold/reason that fired, latency and token usage. |
/jev shadow <module> on|off |
session | Toggles shadow for one module: router, gate, shield, prune or watchdog. In shadow, the module classifies and logs but changes nothing ([shadow] + wouldHaveBeen in the log). |
/jev off |
session | Whole-layer kill switch: every hook becomes a no-op until the session ends. |
/jev on |
session | Re-enables the layer for the session. |
/jev stats [days] |
— | Reads <telemetry.dir>/*.jsonl for the last days (default 1) and prints the decision mix, gate confirm labels (allow/deny/unknown) and cache hits. |
/jev trace [n] |
— | Prints the last n decisions (default 5), one line each. |
/jev recommend |
— | Reads the gate labels from the log and suggests whether to relax the confirm thresholds. |
With telemetry.traceWidget (or PI_JEV_TRACE_WIDGET=1), the interactive UI
shows a live widget of the last eight decisions under the editor — the same
one-liners /jev trace prints.
shadow and off/on are session-only; they are not written to config. To
make a change permanent, set modules.<name>.shadow (or enabled) in
~/.pi/agent/jev.json, or .pi/jev.json in a trusted project. The status line
shows jev <tier> · <n> req · <cost|tokens> and degrades to jev off — <reason>
on failure.
/jev # status + usage
/jev explain # why the last decision happened
/jev shadow gate off # promote the gate for this session
/jev stats 7 # decision mix from the last week
How it works
user prompt ─► router (before_agent_start) ─► model tier, thinking level, tool loadout
│
agent loop ─┬─► gate (tool_call) ─────────────┤ allow / confirm / block
│ │
├─► shield + prune (tool_result) │ one shared request
│ │
└─► watchdog (turn_end, every N) ─┘ loop / false-completion advice
index.ts contains no decision logic. It loads config, builds the client,
redactor and telemetry singletons, and calls each module's register(pi, deps).
Modules never import each other; shared state goes through deps.
The two files that matter
src/questions.ts— every question, with its rationale.src/config.ts— every default and threshold.
A QUESTIONS_VERSION hash is computed from questions.ts; editing it invalidates
the in-memory cache, the on-disk gate cache, and marks log records as a different
generation so calibration never mixes generations.
Configuration
Resolution order: built-in defaults → ~/.pi/agent/jev.json → .pi/jev.json
(only when ctx.isProjectTrusted()) → environment overrides. An invalid file
disables the extension with a clear message rather than falling back silently.
A complete annotated example lives in examples/jev.json.
Frequently changed values:
{
"model": "jev-latest",
"modules": {
"router": { "shadow": true, "tiers": { "cheap": { "provider": "anthropic", "model": "claude-haiku-4-5", "thinking": "off" } } },
"gate": { "shadow": true, "allowBlock": false, "onFailure": "allow" },
"prune": { "enabled": false, "minLines": 150 }
},
"residency": { "enabled": false, "allowedModels": [], "allowedRepos": [] },
"telemetry": { "logStateContent": false }
}
Environment overrides: PI_JEV_MODEL, PI_JEV_BASE_URL, PI_JEV_API_KEY_ENV,
PI_JEV_LOG_DIR, PI_JEV_LOG_STATE_CONTENT, PI_JEV_OFF,
PI_JEV_<MODULE>_ENABLED, PI_JEV_<MODULE>_SHADOW, PI_JEV_<MODULE>_TIMEOUT_MS,
PI_JEV_GATE_FASTPATH_ENABLED, PI_JEV_GATE_RULES_ENABLED,
PI_JEV_WATCHDOG_REQUIRE_EVIDENCE, PI_JEV_TRACE_WIDGET,
PI_JEV_MAX_REQUESTS, PI_JEV_MAX_TOKENS.
Timeouts, budget and failure
Timeouts are per request class and configurable
(PI_JEV_<MODULE>_TIMEOUT_MS): router 2500 ms, gate 2000 ms,
shield+prune 3000 ms, watchdog 2000 ms. These are the calibrated defaults;
the initial plan's original 800/400/1500/1000 ms measured below the real p50/p95 and made
the hooks fail open (see Calibration notes). On expiry, a
missing key, a 401, or a budget breach the client returns null and the module
fails open — Pi behaves exactly as if the extension were not installed.
401disables the layer for the session.422disables the offending request class and logs the question id (a bug inquestions.ts).- three consecutive timeouts or network failures disable that class.
- budget breach disables or warns per
budget.onBreach.
When the gate is live and non-shadow, a classification failure falls back to
allow by default; set gate.onFailure: "deny" for fail-closed.
Modules
| Module | Hook | Default | What it does |
|---|---|---|---|
router |
before_agent_start |
enabled, shadow | picks a model from the models Pi reports available (configured tiers are the fallback), thinking level and tool loadout; nudges on underspecification |
gate |
tool_call |
enabled, shadow | deterministic fast path for obvious commands (works offline), then allow / confirm / block based on blast radius, reversibility, regenerable artefacts, intent drift, secrets, exfiltration, unverified code, installs and privilege/remote execution; optional semantic lint of writes/edits against project rules |
shield |
tool_result |
enabled, shadow | withholds prompt-injected output, masks secrets and personal data |
prune |
tool_result |
disabled | replaces low-relevance output with a summary and a temp-file pointer |
watchdog |
turn_end |
disabled | detects looping, runaway (near-identical consecutive replies), references to non-existent files, and unverified completion (a "done" claim with no test/build/lint and no read-back counts as false-done); injects advice, never aborts |
shield and prune share one Jev request on tool_result and split the
answers in code. That orchestration lives in index.ts so the two modules stay
independent and the "one request per hook" rule holds.
The router asks Pi for the models it can actually use
(modelRegistry.getAvailable()) and sends that list to Jev, which picks one
directly; the configured tiers stay as the fallback when the list is empty or
the answer is missing. A configured tier whose provider/model is not installed
in Pi is reported once instead of failing silently.
When the router judges a prompt read-only it narrows the loadout to read,
bash, ls, grep and find — bash stays because shell is how read-only
work gets done (printenv, git log, a test run), and the gate still
classifies every shell command. The full loadout is restored on the next
write-capable prompt. Set modules.router.readOnlyThreshold to 0 to disable
the narrowing entirely.
Deterministic checks run before any network call. The gate decides obvious
commands locally (force push, recursive delete of /, DROP, curl | sh), so
it protects a session with no API key and does not spend tokens recognizing
them. The shield pattern-matches classic injection phrasing for the same
reason. Jev is asked only for the judgment code cannot express.
gate.rules optionally sends each write/edit plus your project Markdown
rules (AGENTS.md, CLAUDE.md, .pi/rules.md, pi-jev.md) in the gate's
single request, one Noul per rule. A violated rule is fed back to the agent
(onViolation: "steer", the default) instead of interrupting you. Enable with
{"modules":{"gate":{"rules":{"enabled":true}}}}.
The gate's confirmMode: "steer" does the same for ordinary confirms: the
concern goes back to the agent rather than to a prompt. Blocks and
withoutUi: "deny" are unaffected.
Promoting a module
- Run in shadow long enough to collect data (target: 200+ router decisions, 300+ gate decisions).
- Sweep the thresholds:
node --experimental-strip-types tools/calibrate.ts .pi/jev-log --question confirmBlastRadius --from 1 --to 3 --step 0.25 - Adjust
config.ts/.pi/jev.json. No code changes. - For the gate, the
userChoicerecorded on every confirm prompt is a free label. - Flip one module out of shadow (
/jev shadow gate off, then set it in config). Watch for a week. Repeat.
For the gate specifically: confirm first; leave allowBlock: false until the
log shows zero false negatives on fixtures/commands.jsonl and the disk-cache hit
rate clears 50 %.
Collecting real labels
Every confirm answer is a supervised label. The gate's userChoice records it
alongside the full probability vector for free. To collect them, make confirm
live (block stays off) and use Pi normally. Put this in ~/.pi/agent/jev.json:
{ "modules": { "gate": { "enabled": true, "shadow": false, "allowBlock": false } } }
Then, in a project where you have been working:
node --experimental-strip-types tools/labels.ts .pi/jev-log
node --experimental-strip-types tools/labels.ts .pi/jev-log --sweep confirmBlastRadius --from 1 --to 3 --step 0.25
labels prints allow/deny totals, the deny rate per decision-table rule and the
commands you denied. With --sweep it replays the recorded answers and shows how
the deny rate moves as a threshold changes. Target 300+ gate decisions before
touching the numbers (initial_plan.md §13.3). Nothing is blocked while allowBlock is false.
Privacy
What leaves the machine. Prompts, shell commands, file paths, diff summaries
and sampled tool output are sent to api.typesafe.ai. That is the whole point of
the layer and it is not a footnote. Do not enable it where repository content may
not leave.
Redaction. src/redact.ts runs deterministic, pattern-based scrubbing before
any request leaves: credential shapes, bearer tokens, private key blocks,
connection strings, .env assignments, JWTs, emails, and absolute paths reduced
to basenames. It is deliberately over-eager. redaction.patterns: "strict" adds
cards, national identifiers, IPs and phone numbers; a custom JSON array of regex
sources is also accepted, either as a file path or inline as
{ "patterns": { "custom": ["…"] } }.
The known limitation. shield exists to catch secrets patterns miss, but
content must reach Jev to be classified. Pattern redaction reduces exposure; it
does not eliminate it. A deployment where no repository content may leave should
run router and gate on redacted metadata only, with shield and prune
disabled.
Controls. Opt-in per project (residency.enabled + residency.allowedRepos),
project trust for project-local config, hash-only telemetry by default
(logStateContent: false), and a kill switch (/jev off or unset API key).
baseUrl is configurable for a self-hosted proxy that terminates TLS and applies
organisational redaction. It can also point at Vercel AI Gateway
(model: "typesafe-ai/jev", a gateway baseUrl) where a zero-data-retention
setting is available for the TypeSafe side of the traffic.
Testing
npm test # 183 unit + integration tests, no network
npm run typecheck
npm run test:coverage
npm run test:pi # end-to-end against the real `pi` CLI (needs pi >= 0.85 on PATH)
The client and the shell wiring are tested against a real local HTTP server
speaking the TypeSafe wire shape (test/helpers.ts), not a stubbed fetch. That
covers timeout, abort, 401/422/429, cache hit/miss/versioning, disk cache,
budget breach and redaction. Decision tables (including boundaries) are tested
with stubbed answers. test/index.test.ts embeds a fake Pi harness and asserts
real hook effects: model switched, tool call confirmed/blocked, result content
replaced.
End-to-end against real Pi
test/pi/run-e2e.mjs runs the actual pi binary offline against local mocks —
an OpenAI-compatible model server and a TypeSafe /v1/systemone server — with a
scripted model. It verifies, three times:
- dev load (
pi -e ./src/index.ts): router classifies the prompt, a livegateblocksgit push --forcebecause there is no UI to confirm, and a liveshieldreplaces injectedreadoutput with the withheld notice. - package install (
pi install /absolute/path/to/pi-jev, then a normal run with no-e): the installed plugin auto-loads from settings and classifies. - interactive confirm (
pi --mode rpc): the harness answers theextension_ui_requestconfirm dialog twice — deny then allow — and checks that the gate blocks then allows, that the allowed command actually ran, and that bothuserChoicelabels land in the log.
npm run test:pi
# KEEP_TMP=1 npm run test:pi # keep the temp workspace for inspection
Requires Pi's own engine: Node >= 22.19. Set PI_BIN to point at a specific
pi binary.
A live smoke test runs only when TYPESAFE_API_KEY is set:
TYPESAFE_API_KEY=... npm test -- test/live.test.ts
There is also a full live Pi run — a real pi session whose classification
calls go to the real api.typesafe.ai (only the LLM is scripted):
TYPESAFE_API_KEY=... npm run test:pi:live
And a fully live run with a real Anthropic-compatible model provider and the router live, so it verifies that the router actually switches the model:
ANTHROPIC_API_KEY=... BASE_URL=https://api.deepseek.com/anthropic \
TYPESAFE_API_KEY=... npm run test:pi:live-model
The last one was verified with pi 0.85.1 + deepseek-flash → deepseek-v4-pro
(a standard classification) + real Jev. Note that Pi does not emit a
model_select event for a model set by an extension during before_agent_start;
the assertion uses the model on the assistant messages instead.
Evaluation and calibration
# acceptance metrics from the labelled fixtures (needs a key)
node --experimental-strip-types tools/evaluate.ts --json
# threshold sweep over a log (no key needed)
node --experimental-strip-types tools/calibrate.ts <file-or-dir> --question confirmBlastRadius --from 1 --to 3 --step 0.25
# re-issue recorded states against edited questions (needs a key + logStateContent)
node --experimental-strip-types tools/replay.ts <file-or-dir> --limit 50
# offline threshold sweep over a saved evaluate report (no key needed)
node --experimental-strip-types tools/sweep.ts /tmp/jev-eval.json
# offline: score the gate against expert labels and sweep a threshold
node --experimental-strip-types tools/score-labels.ts fixtures/gate-real.jsonl
Fixtures live in fixtures/: 100 labelled prompts, 61 labelled
commands weighted toward the grey zone, 24 synthetic injection cases, and 141
real commands labelled by the maintainer agent with the answers Jev produced.
The fixture sets are a v0 seed except
gate-real.jsonl. The initial plan calls for prompts drawn from public issue trackers; that sourcing is still pending. Treat the numbers as a smoke signal, not the published figure.
Calibration notes
The first real run against jev-latest required three changes, all applied to
the defaults in this repository:
- Confidence floors were miscalibrated. The initial plan applies one floor to every
answer, but a multi-level Score spreads probability, so
reasoning_neededconfidence is naturally much lower than a Choice's. With the original 0.55 floor, 40 of 48standardprompts were escalated tostrong. Added a separaterouter.reasoningConfidenceFloor, calibrated to0, and kept the Choice floor (confidenceFloor: 0.4). Router accuracy went from 47% to 90%. - The gate missed one irreversible local command.
git reset --hardscored as low blast radius and reversible enough. Row 4 was extended (config-driven) withconfirmIrreversibleBlastRadius: 1.0+confirmReversibleFloor: 0.7: not cleanly reversible and beyond scratch files → confirm. On the fixtures this catches all 19 dangerous commands and 0 safe ones. - The initial plan timeouts were below real latency. Measured p50 673 ms / p95
1752 ms for a single
api.typesafe.aicall, against budgets of 400–800 ms. The three-strike rule then disabled hooks, making the layer inert. Defaults raised to p95 + margin. - The speculative
domainquestion was pure cost by default. It is only read whenrouter.skillRoutingis on, so it is now only sent in that case (initial_plan.md §8.2); this cuts ~18% of router input tokens. Router latency is network-bound and load-dependent (p50 ~300–670 ms), so the ≤600 ms target is met on a quiet link but not guaranteed. - Drift confirmed on read-only exploration. In a real-session sample, three
ordinary exploration commands (
cat package.json,find …) scoredmatches_intent0.23–0.29 and so triggered the drift confirm, even though their blast radius was 0. The initial plan's drift example is mutating, so rule 3 is now gated onblast_radius ≥ 1.0(confirmDriftBlastRadius): read-only detours no longer confirm, mutating ones still do. Safe-local false confirms went from 3/9 to 0/9, and to 0/24 on a 141-decision sample. That larger sample also showed build commands (npm run build,make,docker build) triggering the irreversible confirm;docs/calibration.mdrecords it as friction to re-validate once labelled data exists. - Build friction needed a new question, not a number.
npm run buildandgit reset --hardscored almost the sameblast_radiusandreversible, so no threshold could separate a rebuild from a lost-work reset. Added a gate question,regenerable, and made the irreversible confirm requireregenerable < 0.6. On the 141-decision sample confirms went 18 → 14 with the four build/repack confirms suppressed and every destructive/secret confirm kept. This is an addition to the initial_plan.md §9.3 question list, made per §7.2 (split an ambiguous judgment into atomic questions); acceptance was unchanged. - Expert labels over the real commands. The 141 captured commands were
labelled
safe/confirm/dangerousand stored with Jev's answers asfixtures/gate-real.jsonl. At the previous floor the gate had 2 false positives (bothrm -rfin a scratch dir, flagged by the uncertainty rule) and 5 missed confirms. Withregenerablein place the reversibility floor could rise from 0.70 to 0.75, catchinggit reset --hard origin/mainwithout re-flagging builds: precision 86.7%, recall 76.5%, false positives 2/124 (1.6%), zero missed dangerous. The four remaining missed confirms (ssh,sudo, global installs) needed two more questions, not a threshold:installs_softwareandprivileged_or_remotemeasured 0.99 and 0.98/0.89 with no safe command above 0.22/0.05, lifting recall to 100% at 89.5% precision. The gate now asks nine questions whereinitial_plan.md§9.3 listed six, each addition forced by labelled evidence.
Each is a number change or a config-driven rule; none rewrote a prompt. Re-run
sweep.ts and evaluate.ts after any questions.ts or threshold edit.
Repository layout
src/ index, config, questions, client, redact, telemetry, types, modules/
tools/ calibrate.ts, replay.ts, evaluate.ts, sweep.ts, labels.ts, score-labels.ts
fixtures/ prompts.jsonl, commands.jsonl, injections.jsonl
examples/ jev.json
docs/ initial_plan.md, calibration.md
test/ unit, client integration, and index wiring tests
test/pi/ real-Pi end-to-end harness (mock model + mock Jev servers)
Known limitations
- The fixtures are not yet sourced from public trackers (see above).
matches_intentandis_underspecifiedare unproven; watch them in shadow. In the fully live run, ordinary exploratory commands (find,ls) scoredmatches_intentbetween 0.11 and 0.47 even though their blast radius was 0, so a liveconfirmwould prompt often until this is calibrated on real sessions.- TypeSafe has cold-start outliers: the first call after idle can take several
seconds, well past the router budget, and the router then fails open. Warm the
connection or raise
PI_JEV_ROUTER_TIMEOUT_MSfor latency-sensitive setups. watchdogturn summaries are built locally from tool names and error lines; no second model call. Whether they are good enough as state is an open question.- Where TypeSafe processes and retains request data is not documented publicly and
must be established before a regulated deployment.
shieldsends the most sensitive content and is gated on that answer. - The extension is Pi-only. The question catalogue is portable; the hook bindings are not.
- No subagent/background-task triage: Pi's extension API (as of 0.85) does not expose subagent or background-task events, so there is no hook to judge child-task reports. Runaway detection and the evidence-based done-check cover the observable symptoms; a real subagent guard needs a Pi hook that does not exist yet.
Licence
MIT, matching Pi.