An OpenCode plugin that wires System One (Jev)
judgments into OpenCode automatically: tool results are checked for junk and
prompt injection before they enter context (webfetch by default, every tool
optional), known secret formats are redacted from tool inputs and user
prompts, the agent gets jev_decide / jev_judge_many tools with caching,
each turn can have its model and reasoning effort picked by System One from
the prompt (opt-in), a completion probe can run when the session goes idle
(opt-in), jev_find offers jev-backed semantic code search (opt-in),
permission requests can be vetted by System One (opt-in), and context
compaction can be decided by System One instead of a lossy summary
(opt-in).
- screen - tool results are judged (junk? prompt injection?) before they
enter context:
webfetchby default, every tool with"screen": { "tools": "all" }. An unsafe verdict replaces the content fail-closed (defaultmode: "redact") — fetched web pages note why they were withheld and how to override; local tool output is never withheld, only annotated. Set"mode": "annotate"to keep all content. The verdict lands in a separate[jev-screen]timeline row by default ("annotation": "notice"— result text stays clean; the model learns of a verdict late). Set"annotation": "inline"to prefix the result instead, so the model sees the verdict where it reads the content (plus⚠ do-not-relyover threshold). Screened state is regex-scrubbed and sampled to 12 kB; both paths share the judgment cache (same content costs nothing on repeat). The plugin's own judgment tools are never screened. A third question rates residual credential material against ordered levels, normalized to the 0-1 band ("screen": { "secretThreshold": 0.6 }- at/above the threshold the verdict rises to flagged, advisory only: it never withholds or downgrades a higher band). For content that is not a tool result, the agent can calljev_screenitself — same judgment, its own tool row, a pass/flagged/unsafe verdict. - scrub - deterministic redaction (pure regex, never a model call) of
known secret formats in tool inputs and user prompts: PEM private
keys,
sk-…API keys, GitHub tokens (ghp_/github_pat_), AWSAKIA…, Slackxox…, GitLabglpat-…, age keys,npm_…, bearer tokens, JWTs. Replaced with[REDACTED:<label>]. - tools -
jev_decide: one request = one state + typed questions (noul/choice/score — score questions carry their ordered levels ascriteria, a 2-10 item array);jev_judge_many: one judgment per item, launched in parallel and aggregated in code (weighted mean + low-confidence flags);jev_screen: on-demand junk/prompt-injection/secret-material screening of pasted or suspect content, back to back with the automatic fetched-result screen. Every judgment shows as its own tool row (Running jev_decide…) in the timeline, and the tool descriptions carry the when-to-call guidance - screening, claim verification, ranking, bounded choices, scoring, and self-contained classification questions (nothing to read first) - plus the skip rules and confidence thresholds. No companion skill is needed. - cache - replies for identical (endpoint, model, state, questions) are served from durable plugin storage; only answers are persisted, never the state text.
- commands -
/jev-screen <url | file>gives a one-shot junk/injection/ secret verdict for any URL or local file;/jev-done-checkruns a completion self-check over the recent session transcript;/jev-compactcompacts the session now with jev decisions only, never falling back to the built-in summarizer;/jev-approval-statslists which command patterns trigger approvals and how they resolve, so recurring asks can become explicit permissions rules. All answer without loading a skill. - route (opt-in) - before each turn, jev picks which model runs the turn and/or how much reasoning effort it gets, in one judgment, applied through the same switch mechanism as the UI's model/effort picker. Fail closed to the base model on judge failure, and a manual pick always wins. See Auto model routing.
- done-check (opt-in) - after each turn goes idle, the completion probe
of
/jev-done-checkruns on its own; it posts one notice per user message only when completion looks unlikely, and reports a repeating (looping) tail as such. See Auto done-check. - find (opt-in) -
jev_find, a jev-backed semantic code-search cascade: lexical prefilter → path judgments → windowed passage judgments, ranked hits with line ranges. See jev_find. - approvals (opt-in) - permission requests are vetted by jev with three
scored questions (
reversible/serves_task/harmful) before they reach you: with the default delegation, approve verdicts auto-run routine operations and refuse verdicts reject clearly harmful ones, so prompts stay for genuinely uncertain cases - and either verdict can be held at the prompt instead of enacted. Configured deny rules always win. See jev-assisted approvals. - compaction (opt-in) - replaces the lossy compaction summarizer with System One decisions: tool calls and results are dropped or truncated by jev verdicts, everything kept stays verbatim, no summarizing model call runs. See System One compaction.
- OpenCode 2
- Access to a System One deployment. With the default OpenCode Console
endpoint, credentials resolve per call:
TYPESAFE_API_KEY/OPENCODE_API_KEYenv → the CLI's own login (opencode auth login- an API key or your Console OAuth session) → the legacy auth store. Other deployments are resolved from your OpenCode provider config at startup (see Provider); keys and endpoints must still come from the same deployment. Paid models bill the responsible deployment's account - a call without funds comes back as 402 and is never silently retried elsewhere.
Nothing here depends on a specific model provider; the plugin only adds tools and guards around your existing setup.
Judged deployments are verified at plugin start: the configured model is
ping-checked against the resolved endpoint once, and any fault (unregistered
provider, unknown model, missing/wrong credential, outage) fails every judged
feature closed until the plugin restarts with a working config. A changed
fault (new fingerprint) surfaces one [jev-config-error] toast once per
config change; per-session learning stays with the regular rows below.
User-actionable failures - no credential, 401 key rejection, 402 insufficient
funds, 403 endpoint gate, wrong model at the deployment, free-tier restriction
("only from within OpenCode") - surface as [jev-error] … timeline rows
rendered by the bundled TUI half (./tui export) as top-corner error popups,
exactly like OpenCode's own failures: at most one row per session per failure
class per deployment config, so repeated tool failures never toast-spam and a
new session (or config change) informs once again. Screening failures on
local tools surface the same way instead of inlining anything into the tool
output; fetched web content keeps the fail-closed inline annotation. Pure
transport noise (5xx, refused connections, opaque diagnostics) stays
console-only.
Entries go into plugins in opencode.json(c) - global (~/.config/opencode/opencode.jsonc)
or per-project (opencode.json(c) / .opencode/opencode.json(c)).
$ opencode plugin add github:atvari-eu/opencode-system-oneInstalls the plugin globally under CLI management: opencode plugin list to
inspect, opencode plugin update to pull new commits (a bare spec tracks the
default branch). Pin a fixed revision by appending a full commit hash:
$ opencode plugin add github:atvari-eu/opencode-system-one#<full-commit-hash>If OpenCode was running, restart it (opencode service restart) so the
shared service picks the plugin up.
Point package at the checkout root (the directory containing
package.json); OpenCode loads the entrypoint from its exports. If changes
to the checkout are not picked up, restart with opencode service restart.
Pin the repository and copy the entrypoint to a stable path, then configure OpenCode to load that path:
# in your home-manager configuration (e.g. home.nix)
opencode-system-one = pkgs.fetchFromGitHub {
owner = "atvari-eu";
repo = "opencode-system-one";
rev = "<pin>";
hash = "<pin-hash>";
};
home.file.".config/opencode/jev-plugin/index.ts".source =
"${opencode-system-one}/src/index.ts";// opencode.json(c)
{
"plugins": [
{ "package": "~/.config/opencode/jev-plugin" }
],
}Everything is on by default except route, effort.auto, doneCheck,
find, approvals, and compaction (all opt-in); turn parts off with the
object form. The judgment-model override is the string form of "model";
model-routing options live under their own "route" key so the two
meanings never share one ambiguous key:
{
"plugins": [
{
"package": "github:atvari-eu/opencode-system-one",
"options": {
"screen": { "enabled": true, "annotation": "notice", "tools": "all" },
"model": "jev-1.13", // optional; overrides JEV_MODEL and the default
"scrub": { "enabled": true },
"tools": true, // jev_decide + jev_judge_many
"commands": true, // /jev-screen + /jev-done-check + /jev-compact + /jev-approval-stats
"cache": true,
"auto": { // the "Auto" entry + per-turn routing; see "Auto model routing"
"candidates": ["openrouter/anthropic/claude-3.5-haiku", "anthropic/claude-sonnet-4"],
"minConfidence": 0.6,
"fallback": "openrouter/anthropic/claude-3.5-haiku"
},
"effort": { "auto": true }, // per-prompt thinking level (pairs with route)
"doneCheck": { "auto": true }, // see "Auto done-check"
"find": { "enabled": true }, // see "jev_find"
"approvals": true, // see "jev-assisted approvals"
"compaction": { // see "System One compaction"
"enabled": true,
"mode": "replace" // or "clean"
}
},
},
],
}OpenCode always runs the primary model on a prompt - a plugin cannot skip
that turn. When latency matters more than flexibility, use the zero-model
paths instead: /jev-screen answers with no model turn at all, and a
dedicated agent in opencode.json with a tool-first prompt can host
judgment-only turns.
Before every turn, jev can pick how the turn runs: the thinking level and, optionally, the model. Two toggles, one judgment path:
"effort": { "auto": true }routes only the reasoning variant (none,minimal,low,medium,high,xhigh,max- the levels the UI's effort picker offers) of the session's current model."auto"(enabled by default) routes the model itself from the candidate list you name - typically a cheap fast model alongside the expensive one. Candidates may be bare ids asprovider/modelstrings, or{ providerID, id }objects.- Instead of naming candidates, or on top of them, you can let the pool come
from OpenCode's own state via
"sources"(see below). - Auto only ever routes a session parked on the
opencode-system-one/autoentry. PickAutoin the model picker and every turn from there is routed to a real(model, level)pair, returning toautoat idle. Pick any other model and auto never touches the session at all — no jev call, no switching — until you selectAutoagain. - With effort routing also on, an auto-entry turn runs as one combined
decision per prompt (a single
(model, level)question over the candidates that support variants), not two competing ones.
How a turn is routed:
- Candidates are validated against OpenCode's catalog before they are ever offered; unusable ones are dropped whole (up to 12 options), and routing stays off when nothing usable remains. Price hints from the catalog are woven into the option criteria.
- Fail-safe: jev unreachable, timeout (8 s), or an uninterpretable answer reverts to the session's base model - a no-op unless a routed switch is already active. Low confidence keeps the current model, routed switch included (no revert).
- Between two plausible picks the question prefers the lower/cheaper one: a cheap turn can escalate later, wasted thinking cannot be taken back.
- Each real switch appends OpenCode's native
model-switchedmessage (a small status line in the timeline), so routing decisions are visible and auditable.
Manual picks win. Auto only routes a session parked on the auto entry,
so a manual pick needs no detection at all: selecting any real model in
the picker takes the session out of auto's reach until you (re-)select
Auto. Inside the auto loop the judged picks are all the plugin's own;
their pin records exist only to identify the loop for the resting reset.
Judge failure reverts. If routing picked a candidate but the model cannot be resolved at turn time (bad catalog name, provider outage), the session is switched back to the auto entry, and the candidate is not offered again for that prompt.
Resting point. A routed turn parks the session on the judged pick. At
session idle the plugin pulls it back to the auto entry, so the picker
shows Auto again. Only plugin-made switches are reset: a manual pick of
another model stays selected (it is out of auto's reach anyway), and the
loop resumes the moment you select Auto again. Resets are best effort -
without a usable idle event subscription the session simply stays parked
until the next prompt re-routes it.
auto is on by default. It registers the pseudo-model
opencode-system-one/auto in the model picker as a normal entry. Selecting
it is the only way auto engages: the plugin is its only controller, the
entry is never actually invoked - each prompt while the session sits on it
is routed to a real (model, level) pair by the normal routing path, and
at idle the session returns to the auto entry, so the picker keeps showing
auto. On any other model the plugin leaves turns untouched. Set
"auto": { "enable": false } to drop the entry (and all routing).
The picker's search matches the model's display name and provider name
only (the model schema has no alias/tag field), so you can give the entry
search aliases through the name: "auto": { "aliases": ["Jev"] }
shows the entry as Auto (Jev) so typing jev finds it too; the default
displays just Auto (findable via auto and the opencode-system-one
provider name). "auto": { "name": "Autopilot" } selects a custom
display name.
- Mid-loop (session on a judged pick) the normal path applies: jev may keep or change the pick; judge failures keep the current pick rather than reverting to the not-runnable entry.
auto.fallbacknames a real model (provider/model) switched to when an auto-entry turn cannot be routed (judge failure, no usable candidates) - the switch is pinned, so the loop continues from there.- Fail-safes make a turn on the pseudo-entry effectively unreachable,
config or not: at the entry every prompt first drops the session onto
the last real model it ran (its own pin's parked-from model, else the
newest
model-switchedrow) before the judge refines the pick, and withoutauto.fallbackthat same last-used model closes the routing chain. Idle notices (done-check) and idle-triggered compaction move a resting session off the entry before posting or summarizing, so their wake turns run real too - those admissions never pass the prompt hook, only this head start protects them. The pseudo-entry itself fails fast when hit (closed-port base URL makes the failure immediate rather than hanging). - Re-engaging is simply selecting
autoin the picker again.
The judgment is cached per prompt - repeating the same prompt costs nothing. Each new prompt costs one jev round-trip (capped by the 8 s timeout); the payoff is that trivial prompts get a cheaper model and level than OpenCode's static selection would give them.
Candidate sources. Besides the static candidates list, the pool may
draw from what the session itself knows:
"sources": { "favorites": true }merges the models you starred in OpenCode's model picker (the TUI favorites it keeps in~/.local/state/opencode/model.json)."sources": { "opencode": true }merges OpenCode's own models, ranked by the last 30 days of your own session usage ("popular" - the default mode; top 10). Theplankey picks which plan's models to rank:"plan": "zen"(default, theopencodeprovider) or"plan": "go"(the OpenCode Go subscription), and"popular": <count>sets how many (min 1, max 40). Note this ranks by your usage - OpenCode publishes no global most-used leaderboard, so that is the nearest ground truth a plugin can stand on.
Sources are off unless enabled, are additive to candidates (explicit
entries keep their position ahead of sourced ones, duplicates drop out),
and fail open: a missing favorites file or an unreadable database simply
yields no candidates from that source. Sources are fetched once per
server process (the usage ranking moves on a 30-day horizon), so freshly
starred favorites or new usage show up after a restart - each routed
prompt after that costs zero extra I/O. Everything sourced still goes
through the same pre-offer checks as a hand-named candidate: it must
exist in this session's catalog (provider connected, workspace-enabled)
and carry no disabled/deprecated marker - unknown catalog statuses fail
open, and a pick that still fails to resolve at turn time reverts rather
than failing the turn.
Options:
| Option | Default | Meaning |
|---|---|---|
auto.enable |
true |
Register the opencode-system-one/auto pseudo-model entry and enable per-turn routing. Auto engages only while the session sits on the entry; set false to drop the entry and all routing. |
auto.candidates |
[] |
Models jev may route to (provider/model strings or { providerID, id }). |
auto.sources.favorites |
false |
Merge your TUI-favorite models into the routing pool. |
auto.sources.opencode |
off |
Merge OpenCode's most-used models (30 d local usage, "popular" default): true or { "plan": "zen" | "go", "popular": true | count }; plan defaults to zen, count to 10 (min 1, max 40). |
auto.minConfidence |
0.45 |
Minimum jev answer confidence required to switch model; clamped 0.25-0.99. Pair mode splits probability mass across many (model, level) options, so a pick needs a lower bar than you might expect. |
auto.fallback |
— | Real model (provider/model) switched to when an auto-entry turn cannot be routed; without it the session's last-used model is the fallback. |
auto.name |
Auto |
Picker display name of the auto entry. |
auto.aliases |
[] |
Extra search aliases, shown as a name suffix (Auto (Jev)). |
effort.auto |
false |
Off unless explicitly enabled; routes the plain per-variant thinking level of any current model, and pairs with auto inside the loop. |
effort.minConfidence |
0.45 |
Minimum jev answer confidence required to switch the reasoning variant. |
The routing options live under auto instead of model because the
judgment-model override is the string form of "model" (see Options
above); sharing one key for a string and a routing object would be
ambiguous.
"doneCheck": { "auto": true } runs the completion self-check when your
session goes idle: was the last turn genuinely done, or did the agent
stop early? It is opt-in because it is the one place where the plugin
notices you unprompted - rules that keep it polite:
- Triggered by the session-idle event, never by model turns; the check runs while nothing else is executing.
- At most one notice per transcript change: the judged transcript tail is hashed, and an unchanged transcript is never judged twice. The tail excludes the plugin's own notices, so a notice can never re-trigger the check.
- At most one notice per user message: the agent's reply to a notice changes the tail hash, so the hash gate alone cannot stop a nudge loop. After a notice is posted, the checker stands down until a new user message arrives.
- Loop-aware: a run of identical rows in the tail (whitespace-normalized, own notices excluded) is reported as a loop - the notice says the session is repeating itself and asks you to interrupt or send a new instruction, instead of re-asking for "the remaining work".
- Simultaneous end-of-turn events judge once: one probe per session runs at a time, and the idle subscriptions a reload leaves behind are handed back (setup returns a cleanup), so stacked copies never each post their own notice.
- jev answers the same
noulquestion as/jev-done-check(p(task complete), 0-1). One synthetic notice is posted only when completion looks unlikely - probability at or belownotifyBelow(default0.35): a prompt to continue the remaining work or list it. When jev says done, you hear nothing. - The judged tail is scrubbed (secret redaction) before leaving the process, and a notification is posted, not a model turn - no read receipt, no comment storm.
Options:
| Option | Default | Meaning |
|---|---|---|
auto |
false |
Off unless explicitly enabled. |
notifyBelow |
0.35 |
Post the incomplete-notice at or below this p(complete). |
Registered with "tools": true (alongside jev_decide) — no separate
option. The agent calls it on untrusted text that automatic screening does
not reach: pasted blocks, excerpts, snippets, suspicious tool output.
Input:
| Field | Meaning |
|---|---|
summary |
One line naming what is screened (shows as the tool row). |
content |
The untrusted text, verbatim — sampled to 12 kB like automatic screening. |
source |
Optional origin (URL, tool name) echoed in the report. |
Output: junk and injection noul probabilities, a verdict
(pass / flagged / unsafe, bands 0.5 / 0.85–0.7 matching the automatic
screen), a plain-language verdictText, and the redaction hit count
(scrubbedHits) when the content contained secret-like tokens before
judging. Same state cache as automatic screening: the same content costs
nothing on repeat.
"find": { "enabled": true } registers a jev_find tool: semantic
codebase search for questions grep cannot phrase - "where is this
discussed?", "which file decides how config X behaves?". The cascade:
- Walk the workspace tree (respects .gitignore trimming and a secret-path skip list); a lexical prefilter ranks candidate files cheaply.
- jev judges the ranked shortlist by name (one
noulcall) - which files could plausibly relate to the question. - Promising files are cut into windows; jev judges the windows by
content (batched
noulcalls). - A final jev pass judges the surviving passages and the report ranks files with heat, line ranges and excerpts.
The report is a routing signal, not proof: it tells you which files to read, not what they say - excerpts are cut mid-function and ranked by relevance to the question, not by correctness. It costs several jev calls and takes seconds, so it is a last resort after a failed grep, not a default first try. Results reuse the normal cache key (same question on the same tree costs nothing on repeat).
Options:
| Option | Default | Meaning |
|---|---|---|
enabled |
false |
Off unless explicitly enabled. |
Permission requests (tool executions that would otherwise interrupt you) can
be vetted by jev before they reach you. The feature is opt-in: omit
approvals (or set it false) and nothing changes; true, {} or any
object value runs. One judged request asks three scored questions -
reversible, serves_task, harmful - and their scores resolve against
configurable bars into the same effects the permission flow already speaks:
allow (auto-run), ask (prompt you), deny (refuse; the reason rides
back to the model so it can pick a different approach). What each verdict
enacts is your explicit delegation choice:
- Full delegation (the default:
true,{}, or an object that omits the keys): an approve above its bars auto-runs, a refuse at or above its bar refuses - prompts stay for the genuinely uncertain band. - Hold approves (
"onApprove": "ask"): every approve presents the annotated prompt instead of auto-running - the "vetting opinions only, no automatic yes" posture ([jev] approves (0.93) - held for you), useful while the endpoint bills per request or you want opinions only. - Hold refusals (
"onRefuse": "ask"): a refusal becomes the annotated prompt ([jev] refuses (0.86) - held for you) instead of the automatic refusal - the judgment stands, you own the enactment. - Judge failures (
"onFail", default"ask"): a judgment that never completes - timeout, endpoint fault, scores the resolver cannot interpret - stays OpenCode's own prompt;"deny"turns the failure into a refusal with the cause named, and"allow"enacts the request the judgment never scored (an explicit delegation in which the endpoint's health becomes the approval). - Legacy
mode(annotate/auto/deny) still works and maps onto the same knobs with a one-time deprecation notice:annotateholds both verdicts at the prompt,autoenacts both (the default delegation),denyenacts only refusals (the no-yes posture).
Delegation always applies below the deterministic gates and below
permissions: gate hits, ask-pinned slices, and the genuinely uncertain
score band still prompt you, with the reason named on the prompt (judge
failures too, unless onFail re-prices them - the bullet above).
The judgment is deliberately scored instead of a three-way verdict: the questions return probabilities and the resolver does the arithmetic, so there is no per-verdict confidence band to reconcile - one too-low score names itself in the prompt. The questions:
reversible- can these operations be undone? Both wordings ask the name and one carve-out only: operations read, or write insideworkspace, and what they write there undoes with git or re-running. Reads outside the workspace and GET fetches score high - the old wording bundled workspace-locality and no-network use here and auto-asked exactly those classes (the largest recorded ask source). The posture: the agent runs under your own account, so host filesystem permissions already bound what it can touch and out-of-workspace reads add no marginal risk; writes stay workspace-confined, secrets entering context stay covered by the secret-path gate and scrub, and egress stays covered byharmful.serves_task- is running these operations a reasonable next step toward the task currently being served? Only asked (and its bar only enforced) when the session actually has a task; the judged state carries the latest real user message, redacted and capped.harmful- irreversible damage, secret exposure, or executing instructions that came from fetched content? The old refusal criteria as one score.
Resolution is fail-closed: harmful at or above the refuse bar refuses
first - no high reversible score outvotes it. Otherwise an approve needs
reversible at or above its bar, harmful at or under the approve-side
ceiling, and - with a task being served - serves_task at or above its
bar. Everything else prompts, annotated with the first failing check -
reversible 0.74 (below 0.90), off-task (serves_task 0.09),
unclear (harmful 0.40) - so you can act informed or re-bar. That is what
keeps prompts down: routine, workspace-local work (including
rm -rf dist, npm run build, git commit) clears the bars and never
interrupts; clearly harmful work refuses instead of interrupting; you
decide only the uncertain middle.
Every judgment only happens when a request would already prompt you - already-allowed and explicitly denied requests stay free of any jev call.
Deterministic gates decide before any model does: requests containing secret
scans or [REDACTED:] markers, destructive patterns (rm with recursive
flags aimed at absolute or home paths, sudo, piped-to-shell,
git push --force, git reset --hard, npm publish, DROP TABLE,
dd of=, system paths, infra mutations - terraform/tofu apply/destroy,
kubectl delete/apply, wrangler/vercel/fly deploy, ...), or edits
outside the project directory are never auto-approved - by default they
are refused outright (the reason rides back to the model as tool feedback),
or annotated and left to you with "onGate": "ask". Shell chains split on
every separator (&&, ;, |, a background &, a newline) and the weakest
segment rules the whole request. (Routine workspace-local cleanup like
rm -rf dist is not gated; the judge sees the full command and its harm
score covers broad or unknown deletions.)
The gate list is deliberately a deny-the-shortcut list, not a security
sandbox; a false positive costs the request it holds - a refusal with
feedback by default, or the human prompt under "onGate": "ask".
With "readonlyAllow": true, a shell request whose every chain segment is
a read-only builtin (grep, ls, cat, strings, jq, ...) skips the judgment
entirely - the pipeline class (grep -n foo src | head -3) that was the
largest judge burden in the recorded stats is structurally incapable of
mutating anything. Fetch-only curl segments qualify too (default GET /
-X GET / -I to stdout only; any body, form, upload, file output, header
dump, curl config or bundled flag concealing one falls back to the judged
path), and find qualifies only while it cannot mutate (-delete and the
-exec family judge like any other command). Redirects (>, >>, &)
count as writes and still
judge, and the check runs after the gates, so secret paths and destructive
strings hold exactly as they would without the option. When the matched
permission rule carries no operator context, the judged state also gains
a one-line default context for prefixes the scored review keeps misreading
(curl: GET requests read data vs state-changing methods and bodies;
find: read-only without -delete or state-changing -exec; sed:
without -i it never edits files in place).
The fail-closed ladder: jev unreachable, a timeout ("jevTimeout" sets
the per-judgment signal budget, clamped 2-120 s, default 8 s; transient
failures - timeouts and endpoint faults - retry once inside the budget, and
the retry re-asks on a lean judged state so a bigger payload never buys a
longer wait), or scores the resolver cannot interpret leave the request exactly as OpenCode built it
(annotation aside), with the first failing check named on the prompt
(verdict unavailable, unclear (harmful 0.40)) - a sub-bar score never
silently enacts anything unless you lowered the bar itself below it.
Configured deny rules and every "always allow"
memory you made in the UI always rank above the feature.
One framing matters: automatic decisions are an explicit delegation you
opt into, bounded by the deterministic gates above. The defaults enact
approve into a run and refuse into a refusal, and a gate hold into a
refusal; each can be held at the prompt with "ask" instead. Every
automatic decision is logged.
The feature does not replace OpenCode's permissions - it sits one layer
below, seeing only requests the host rules resolve to ask. That split is
most useful when it stays deliberately asymmetric:
-
Host config: coarse trust boundary. Keep it short and stable. The minimal pattern is a single catch-all
askper reviewed action, and the whole policy then lives in the plugin:"permissions": [ { "action": "shell", "resource": "*", "effect": "ask" }, { "action": "edit", "resource": "*", "effect": "ask" }, { "action": "external_directory", "resource": "*", "effect": "ask" }, ],
Every
shellandeditrequest now flows into the plugin instead of whenever-triggered prompts, andapprovals.permissions(next option table) does the fine-grained shaping -allow,ask,system-onewithcontext, per slice. Hard red lines - requests you never want to see, not even as a question with operator context - still belong here as host-configureddenyrules, since a configureddenyis final and theevaluatehook never runs for one. -
Plugin
permissions: the actual policy. Same{ action, resource, effect }entries, wildcard matching and last-match-wins as the host. Safe commands getallow, mapped slices getsystem-onewithcontextfor the judgment, rarely-needed things stayask. Anedit *rule witheffect: "system-one"is a typical shape: the host one-lineaskhands edits to the plugin, the gate holdssecrets/paths before any judgment,contexttells the judge routine local edits are fine, and only the genuinely uncertain band still prompts you. -
Cost note: an
alloweffect saves more than a prompt - no jev call, no annotation, no gate. Hostallowis even cheaper because the hook never runs. So actions that never benefit from judgment can skip it at the host level too; put judgment where the judgment adds something. -
Escape hatch note: "Allow always" memories created in the UI save host-level
allowpatterns, and they rank above both this feature and itspermissions. Long-running setups therefore tend to keep the host rules coarse: the recurring commands that deserve a standing allow are captured by memories over time, andpermissionscarries everything else as policy.
The same file shows both halves together. Host permissions stays a
coarse boundary; every judgment-shaped decision lives in the plugin
options:
{
"$schema": "https://opencode.ai/config.json",
// HOST: coarse boundary + final red lines. Judged stuff routes to the plugin.
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" },
{ "action": "edit", "resource": "*", "effect": "ask" },
{ "action": "external_directory", "resource": "*", "effect": "ask" },
],
"plugins": [
{
"package": "github:atvari-eu/opencode-system-one",
"options": {
"provider": "opencode",
"model": "jev-1.13",
"approvals": {
// full delegation is the default; explicit keys are the knobs:
"onGate": "deny", // "ask" holds gate hits at the prompt instead
"onApprove": "allow", // "ask" holds approves at the prompt
"onRefuse": "deny", // "ask" holds refusals at the prompt
"onNoVerdict": "allow", // "ask" re-prompts the unclear band, "deny" refuses it
"minConfidence": { "approve": 0.60, "refuse": 0.70 },
// PLUGIN: the policy. Same shape, plus "system-one" and "context".
// Ordered; the LAST matching rule wins.
"permissions": [
{ "action": "shell", "resource": "git status *", "effect": "allow" }, // + git log/diff/branch/show, ls, ...
{ "action": "shell", "resource": "git push *", "effect": "ask" },
{ "action": "shell", "resource": "sudo *", "effect": "deny" },
{
"action": "edit",
"resource": "*",
"effect": "system-one",
"context": "Workspace-local edits are routine and fine. Refuse or hand to the operator any edit under secrets/ or of .env, *.key, *token* files - do not judge those yourself."
},
],
},
},
},
],
}Notes on the split:
- Host
denyrules (and UI "Allow always" memories) outrank the plugin ruleset entirely; the example'ssudo *deny line could equally live at the host for a boundary the plugin cannot even soften. contextis injected into the judged state after the deterministic gates run, so asecrets/edit is held before the remark matters.system-oneentries only fire for requests the host sends toask- a hostallownever reaches the plugin for that request at all.- Resource paths inside the workspace directory are normalized to
workspace-relative before rules, gates, and the judged state see them, so
read src/index.tsandread /home/me/proj/src/index.tsresolve the same way and land in one/jev-approval-statsslice. Outside-workspace absolutes pass through unchanged.
Options:
| Option | Default | Meaning |
|---|---|---|
stats |
true |
Records one aggregate in plugin storage (jev-approval-stats): every approval event folds into a per (action, command-prefix) entry with outcome counts (allow / deny / ask / gate / fail), the binding-confidence band of each outcome, a confidence band per judged question score (where the scores actually land - the calibration data for moving a bar off its default), a reason/... band naming the check that resolved the outcome (reversible below its bar, serves_task off-task, harmful over the ceiling, policy-held verdicts, refused-over-bar), asked-approve / asked-refuse counters, judged token totals, judge-failure cause classes (timeout / http / error) with the per-onFail enacted counts, and judged latency sums. /jev-approval-stats prints event totals across all recorded slices plus the top 20, with per-row fail causes, average judge latency and a suggested minConfidence bar pair (the 0.95-mass floors over the recorded verdict bands, shown when either direction carries at least 50 samples; a fully sub-0.5 verdict band proposes the 0 floor); hot ask slices become candidates for explicit permissions rules. Set false to skip recording. |
gates |
{ secrets: true, readonlyBypass: true } |
The two deterministic-gate toggles, as one object. gates.secrets (default true): the built-in secret-path gate matches credential-ish local paths case-insensitively — secrets/…/secret.* files, .env/.env.local/.envrc, .npmrc/.netrc machine credentials, .pem/.key/.age/.kdbx/.p12/.pfx/.gpg/.asc containers, .ssh, .gnupg, .aws directories, id_rsa/id_ed25519-style keys, credential/token basenames, .npmrc/.netrc (registry and machine credential files) — for the local path tools (read, grep, shell/bash segments, edit/write); matching requests resolve through onGate - refused outright by default with the reason as tool feedback, or held at your prompt with \"onGate\": \"ask\" - in both cases even when an explicit allow rule would cover them (webfetch URLs are deliberately excluded - token-ish doc URLs would false-positive, and token VALUES there are caught by the value-level check). Matching is per chain segment, so a mid-chain .ssh reference (`ls ~/.ssh |
onGate |
"deny" |
What a deterministic-gate hold enacts (secret paths, destructive patterns, [REDACTED:]/secret-marker usage, edits outside the project directory): "deny" refuses the request with the reason as tool feedback, so the model re-plans without the prompt; "ask" leaves the hold at your prompt ([jev] gate: secret path - review required), the pre-onGate behavior - the middle ground when you want to see every gate-touching request instead of letting the model route around it. |
onApprove |
"allow" |
What an approve verdict enacts: "allow" auto-runs the request; "ask" holds it at the prompt with [jev] approves (0.93) - held for you - the vetting-only, no-automatic-yes posture. |
onRefuse |
"deny" |
What a refuse verdict enacts: "deny" refuses the request and the reason rides back to the model as tool feedback so it picks another approach; "ask" holds refusals at the prompt ([jev] refuses (0.86) - held for you) and leaves every enactment to you. |
onNoVerdict |
"allow" |
What the unclear band enacts - neither verdict resolved (a score unavailable or below its bar, harmful over the ceiling, or a reply that parsed with no usable answers): "allow" auto-runs whatever the bars left undecided - with it the bars only separate refuse from allow, the full-delegation posture; "ask" holds the band at the prompt with the first failing check named ([jev] reversible 0.41 (below 0.50)) - the pre-onNoVerdict behavior; "deny" refuses the request with the reason as tool feedback so the model picks another approach. |
onFail |
"ask" |
What a judgment that never completes enacts (endpoint unreachable, timeout after the retry, a reply that cannot be parsed): "ask" keeps OpenCode's own prompt unchanged (the fail-closed default); "deny" refuses the request with the cause as tool feedback ([jev] approval: denied - judgment failed (http)); "allow" enacts the request the judgment never scored - an explicit delegation in which the endpoint's health becomes the approval, so it belongs to trusted-until-verified setups. Unusable score values inside an otherwise-parsed reply are not an onFail case - the resolver leaves them no verdict, priced by onNoVerdict. |
jevTimeout |
8000 |
The per-judgment signal budget in milliseconds, clamped 2000-120000. Each judgment gets the budget once and retries once inside it on transient causes (timeout, endpoint fault) before degrading to the fail path; the retry re-asks the same questions on a lean state (first three operations at 300 chars, middle-elided so each keeps its 60-char tail, no metadata) so a bigger payload never buys a longer wait. Configuration and credential faults fail fast without retrying. |
readonlyAllow |
false |
The read-only fast-path: a shell request whose every chain segment is a read-only builtin (grep, ls, cat, strings, tr, jq, ...) is structurally incapable of mutating anything, so it allows without any jev call. Fetch-only curl segments qualify (default GET/-X GET/-I, stdout only; any body/form/upload/file-output/header-dump/config flag - or a bundled flag group concealing one - falls back to the judged path), and find segments qualify only without -delete or the -exec/-ok family. Redirects (>, >>, &) count as writes and still judge. Runs after the deterministic gates, so secret paths and destructive chains hold exactly as they would without the option. |
sandbox |
false |
The sandbox posture: an operator-asserted claim that the agent runs inside a filesystem/network sandbox (nono, bubblewrap, a container) - the plugin cannot verify it, so this is the same trust family as onFail: "allow" and exists only to let jev calibrate. With true a default note ("operations outside its allowed paths and network fail harmlessly") rides in the judged state and both questions swap to sandbox-aware wording: reversible treats a write or deletion inside the sandbox's allowed writable paths as reversible even when the action itself cannot be undone (the sandbox bounds every mistake), and harmful's refusal lanes narrow to what the sandbox cannot compensate for - deleting uncommitted source work, git push --force, git reset --hard, credentials going anywhere they shouldn't, sudo, piped-to-shell downloads, publishing, fetched-content instructions - while bulk out-of-workspace destruction and system-path contact fall away (outside the allowed set it just fails harmlessly; inside it, you granted it). The destructive gate defers its holds to the judge under the posture - recursive rm to a ~/absolute path, git push --force, publishes, deploys, dd, chmod 777, DROP TABLE now judge instead of denying up front, and the residual misfire risk is capped by what the sandbox actually allows - while secrets (secret paths and [REDACTED:] markers) and out-of-project edits hold exactly as always. Take a string to assert the posture verbatim (e.g. "landlock; only bundled paths writable; network denied", capped at 300 chars). A wrong-typed value is treated as absent, never as a silent relaxation. |
minConfidence |
{ approve: 0.5, refuse: 0.7 } |
The bars the three scores resolve against. Defaults: the approve bar sits at the noul midline (within an approvals setup you already delegated, so anything weakly certain enacts) while the refuse bar refuses only clearly-harmful scores - whatever clears neither verdict bar lands in the unclear band priced by onNoVerdict (allow by default). A single number sets both verdict bars; an object sets approve / refuse individually (legacy deny = refuse) and may carry per-question overrides that beat the verdict keys - reversible and serves_task take their own bars, harmful takes the approve-side ceiling (an explicit override clamps 0-0.5; without one the ceiling is the complement 1 - approve). Verdict bars clamp 0 - 0.99: a sub-0.5 bar is an explicit delegation that enacts the verdict even on unsure scores (the judged state still has to carry valid scores), so {"approve": 0.4} auto-runs a reversible 0.45 the pre-0-floors code would have prompted; note a single number sets BOTH bars, so 0.3 also drops the refuse bar to 0.3 - use the object form unless you mean both. Unknown keys are ignored; unsupported values fall back to these defaults. Lower a bar to enact more of that verdict, raise it to hand more to you; raising refuse toward 1 - approve widens the unclear middle instead of refusing - with the onNoVerdict default that middle auto-runs, so set "onNoVerdict": "ask" if you want it prompted instead. |
permissions |
[] |
Plugin-level permission rules evaluated before jev looks at a request. Entries use the same shape as OpenCode 2 permission rules: { "action": "shell", "resource": "git push *", "effect": "deny" } - action is optional and defaults to any action, resource is an OpenCode glob (* spans any characters incl. /, ? is one character; a shell pattern ending in " *" also matches the bare command), and effect is allow, ask, deny or system-one. Rules apply in order with the last match winning; allow and deny are enacted directly, ask leaves the request exactly as configured (plain prompt, no jev verdict, no annotation), and system-one falls through to the regular jev judgment with the entry's optional context remark (max 400 chars) injected verbatim into the judged state - use it to steer the verdict for a specific slice, e.g. { "action": "edit", "resource": "*", "effect": "system-one", "context": "Workspace edits are routine; edits under secrets/ are operator territory, never judge those yourself - ask." }. Entries may also carry a hint: a note posted once per session to the agent context when a matching request resolves - a plain string applies to every outcome, an object { "allow": …, "ask": …, "deny": … } keys the note by the resolved outcome (the deterministic gate counts as the outcome onGate enacts - deny by default, or ask when held), e.g. { "action": "external_directory", "resource": "*", "effect": "ask", "hint": "for more than occasional access, prefer /cd to that directory" }. Host-configured deny rules and saved approvals still rank above these. Since enactment applies to every judged action, ask rules are the way to pin a slice that must always prompt (e.g. cross-workspace access, MCP tools you do not trust). |
Default OpenCode compaction asks a model to summarize older turns; a summary
is lossy. The compaction option swaps that for Jev decisions, porting the
approach of tamaratran/fast-jev-compaction
from Claude Code to the OpenCode plugin hook:
- Tool calls are paired with their results by id. Calls in the first message
of the compacted range (usually the previous checkpoint) or in the newest
preserveRecentMessagesmessages are pinned and never touched. User and assistant text is never rewritten. - The judgment state is the whole transcript - oldest first, tool outputs
replaced by short size notes (
ok, 4213 chars (omitted)), inputs included- regex-scrubbed first, then fitted into
maxStateTokensin stages (inputs truncated 1000→200→60 chars, old texts abridged, collapsed, one-lined, dropped, merged).
- regex-scrubbed first, then fitted into
- Every non-pinned call gets two
noulquestions: does the call still matter, does its result still need to stay verbatim? Questions are batched undermaxRequestTokensand at mostmaxBatchCallscalls per request (each call carries two questions, so the default 32 loads up to 64 questions - the largest documented System One deployment cap, Munito's; stricter deployments need a lower value). Batches run concurrently and are cached like every other jev reply. - Decisions: result ≥
keepThreshold→ keep both; else call ≥keepThreshold→ keep the call, truncate the result totruncateHeadCharsplus a note; else drop call and result together. - The hook then either supplies the compaction summary itself - the
pruned transcript rendered verbatim, so no summarizer model call runs
(
mode: "replace", the reference behavior) - or, when the guards don't allow that yet (mode"clean", insufficient reduction, oversized summary), applies the decisions to the transcript and lets the built-in summarizer run over the cleaned history.
Host-injected system blocks (Code Mode tool-catalog updates and friends)
are excluded from both the judged state and the hook-supplied summary: the
host regenerates them every turn, so replaying them would only duplicate
what arrives fresh after compaction.
Being handed a block that is still too much? Run /compact again within
one minute of a previous compaction: the second pass yields to OpenCode's
built-in summarizer, which produces the default lossy summary instead. The
jev summary ends with a note reminding you of this.
Are those fallbacks too quiet for you? /jev-compact (requires
compaction.enabled) is the explicit ask: it triggers the same compaction
request, but marks it so the jev pass is the whole story - "replace" mode
applies the pruned summary with no summarizer call at all; "clean" mode
announces the kept message counts as a [jev-compact] row before the
summarizer step your config chose; and "nothing stale", duplicate-pass and
jev-failure cases abort visibly (compaction failed in the transcript plus
an explaining row), never rerouting to the built-in summarizer the way a
plain /compact can. Every trigger still funnels through the same jev hook;
the command only changes what the fallbacks do.
Fail-closed: a missing key, a jev failure, a malformed answer, or a history that cannot be fitted leaves compaction entirely to OpenCode's built-in summarizer, on the unmodified transcript. Nothing is half-applied.
Privacy note: judging needs to see the conversation, so enabling compaction sends the older transcript (scrubbed of known secret formats) to your Jev endpoint - the same trade the reference makes. Verdicts are cached; the state text is never persisted.
Options (all defaults mirror the reference):
| Option | Default | Meaning |
|---|---|---|
enabled |
false |
Off unless explicitly enabled. |
mode |
"replace" |
"replace" supplies the pruned transcript as the summary; "clean" only rewrites messages and keeps OpenCode's summarizer. |
summary |
"report" |
Shape of the hook-supplied summary. "report" collapses it to a five-line audit note - counts only: judged calls dropped vs kept-with-head, dropped calls grouped by tool type, nothing per-call and no text replay. "transcript" replays user/assistant text verbatim (plus per-call Jev verdicts) instead. |
keepThreshold |
0.5 |
Verdict probability at which call/result survive. |
preserveRecentMessages |
6 |
Newest messages pinned untouched. |
maxStateTokens |
25000 |
Judgment state budget before staged fitting gives up. |
maxRequestTokens |
30000 |
Per-request budget (state + questions); splits into batches. |
maxBatchCalls |
32 |
Tool calls judged per request (two questions each = 64 questions - the maximum System One deployments document, Munito's 64-question cap; lower it for stricter deployments). |
truncateHeadChars |
300 |
Head kept when a result is pruned. |
minReduction |
0.25 |
Required relative shrink before a hook-supplied summary replaces the built-in one. |
maxSummaryTokens |
half the model window | Cap on the hook-supplied summary (estimated tokens). |
auto |
off | Idle auto-compaction outside OpenCode's near-full trigger: at session idle, if the transcript (estimated ~chars/4) passes the target, the plugin posts the same compaction request /compact uses - which runs through the jev pass. Give the target as an object: "auto": { "share": 0.5 } compacts at that fraction of the session model's context window (follows model routing); "auto": { "tokens": 60000 } compacts at an absolute token count (wins over share). "auto": true is shorthand for { "share": 0.5 }. Hysteresis is inherent: after a pass the transcript sits well below the target until the conversation regrows. |
None needed, and none should be loaded on OpenCode with this plugin - the
tool descriptions, hooks, and commands carry the whole job. For other
agents (no plugin), the standalone jev CLI is the path, and the portable
skills in atvari-eu/skills can
drive it from any agent:
$ skills add atvari-eu/skills@system-one -g
$ skills add atvari-eu/skills@auto-system-one -gThe provider plugin option selects the System One deployment jev judges
against. Three forms, all resolved once at plugin start:
"opencode"(default) - the OpenCode Console pay-as-you-go System One deployment. Models:jev-1.13(bills Console credits) andjev-1.13-free(zero-cost, restricted by the Console to OpenCode clients). The endpoint is OpenCode's System One product route; the Console chat baseURL does not serve System One itself."opencode-go"- the OpenCode Go subscription deployment;endpoint unchanged, credential chain switches toOPENCODE_GO_API_KEYenv and the legacy store's."opencode-go".keyentry.- Any other id - a provider registered in your OpenCode config,
including local
providers.<id>blocks inopencode.json(c). At startup the plugin reads the OpenCode provider registry and derives the deployment endpoint from the provider's configuredsettings.baseURL, joined with the System One route:<baseURL>/systemone, appended unless the base already ends in/systemone. Change the provider config and the next plugin start follows it - no plugin option to keep in sync. On some 2.0.x hosts locally configured providers register shortly after plugin setup; if the id is missing then, judged calls re-check shortly after boot before failing closed. Credential chain:TYPESAFE_API_KEYenv → the CLI credential store entry for that integration (opencode auth loginpicks the key the registry resolves with) → a legacyauth.jsonentry of the same id.
Example: point jev at an inference provider you already registered for chat, so judged calls run on the same deployment:
{
"providers": {
"acme": {
"name": "Acme",
"package": "@opencode/ai/providers/openai-compatible",
"settings": { "baseURL": "https://api.acme.dev/inference/v1" },
"models": { "acme-ai/decider-v1.2": { "name": "decider-v1.2" } }
}
},
"plugins": [
{
"package": "github:atvari-eu/opencode-system-one",
"options": { "provider": "acme", "model": "acme-ai/decider-v1.2" }
}
]
}- Console-managed connection providers (the
console-*entries OpenCode adds when you connect an integration, e.g."console-acme-eu") work the same way and need no provider key of their own: their proxied System One route authenticates with your Console login, which the default chain already resolves. - An explicit URL form (
"https://host/…"or loopback"http://127.0.0.1/…") bypasses the registry and points the deployment endpoint directly; keys pair viaTYPESAFE_API_KEYenv only. JEV_ENDPOINTenv still overrides everything endpoint-related (and then skips the registry entirely).
Anything else is a fault: unregistered provider, unreachable endpoint or an unverifiable model fail all judged features closed with the deployment's own diagnostic (and a toast once per config change) - never a silently guessed endpoint, never a fallback to another provider. The one exception: an id left out of the registry at startup re-checks after boot (see DESIGN) before failing closed.
Resolved once per plugin start together with the deployment (the key
is never logged or persisted; JEV_ENDPOINT skips the registry):
TYPESAFE_API_KEY/OPENCODE_API_KEYenv (Console deployments) orOPENCODE_GO_API_KEY(opencode-go),TYPESAFE_API_KEYfor host-registered providers - explicit override. TheTYPESAFEkey is the deployment-agnostic one: it must pair with whatever endpoint is resolved.- The OpenCode CLI's credential store, read from
opencode auth export: an API key or the active OAuth session, which the CLI keeps refreshed. Console deployments read the integrationopencode(the single Console integration the V2 CLI exposes); host-registered providers read their own integration entry (opencode auth login <provider-id>adds one). Skipped whileJEV_ENDPOINTis set, so an exported key never pairs with a foreign endpoint. Cached per process and re-read once when the endpoint rejects a cached key as worn - a 401, or a diagnostic naming the key/credential as expired/revoked/invalid. (The Console's 422 "upstream unavailable" responses are a deployment outage, not a credential signal.) - Legacy opencode auth store (
~/.local/share/opencode/auth.json) - the entry matching the resolved provider id (."opencode".key,."opencode-go".key, or the provider's own id for hand-planted keys); V1 installs and hand-planted keys; the V2 CLI no longer updates this file.
JEV_MODEL(default:jev-1.13-free; the plugin'smodeloption wins over this env, which wins over the default)
A missing key or failed call never produces a "successful" judgment: screen
marks unavailable, tools throw. Failures surface the endpoint's own
diagnostic: 401 reads as credential rejection (expired key or wrong
deployment), 402 as lack of funds on the responsible account, 403 as the
pairing or free-tier gate. There is no provider degradation chain.
- The deployment is verified at init: no judged feature ever consults an endpoint or model that has not answered a minimal live ping this process; faults fail closed and toast once per config change.
- Secret scrubbing is always deterministic regex - never a Jev call - and applies to screening, tool inputs, prompts, and the compaction judgment state alike.
- Cached entries store answers + usage only, never the state text (compaction judgments included).
- Auto done-check runs only at session idle, judges a scrubbed transcript
tail, dedupes by tail hash, and posts at most one notice per change -
and only when completion looks unlikely (
notifyBelow). The only other notices the plugin posts on its own are screen verdicts and routing stand-down/re-arm lines. - Compaction never rewrites user/assistant text and never applies partial decisions; any failure falls back to the untouched built-in flow.
- User prompt text is never edited for instructions (scrub redaction aside): first-action guidance lives in the tool descriptions.
- Approvals never override configured deny rules; delegation is explicit
(
onGate/onApprove/onRefuse/onNoVerdict), a judgment failure keeps the human prompt by default (onFail), and the deterministic gate list (destructive/secret patterns) is checked before any jev call.
Architecture and decision-history notes for contributors live in DESIGN.md.