Audience: developers working on eval-magic's OpenCode support. Runtime usage lives in the README,
--help, and the generatedRUNBOOK.md; the enhancement model is in progressive-enhancements.md.
The declarative half — including the <available_skills> XML block templates and the stage-name
rules (a regex + length cap) — is the descriptor file harnesses/opencode.toml;
src/adapters/opencode/ keeps only the code capabilities the descriptor references:
| File | What's in it |
|---|---|
harnesses/opencode.toml |
the descriptor — every declarative value + the opencode slug, opencode-events parser, and opencode-skills shadow-preflight references |
harnesses/opencode-guard-plugin.js |
the embedded write-guard project plugin staged by the opencode-plugin guard engine ({exe}/{marker} substituted; byte-pinned in src/adapters/guard.rs) |
mod.rs |
slug sanitization/truncation (the opencode slug capability) |
transcript.rs |
opencode run --format json event-stream parsing (the opencode-events transcript capability) |
skill_shadow.rs |
project/global skill collision scan (opencode-skills) + reporting |
- Native staging:
--harness opencodestages under.opencode/skills/, rewrites the staged skill-under-test's frontmattername:to a sanitized slug, and renders the<available_skills>XML block in dispatch prompts, advertising the skill-under-test under its staged slug (matching what OpenCode's skill tool lists, since it keys on the frontmatter name). - Transcript ingest:
opencode run --format jsonemits a JSONL envelope stream ({type, timestamp (epoch ms), sessionID, ...}). Theopencode-eventsparser normalizestool_useparts (name atpart.tool, args atpart.state.input, outcome atpart.state.output/part.state.error), sumsstep_finishpart.tokens(cache reads excluded, matching codex accounting), takes the final message from the lasttextpart, and measures duration from the envelope timestamps. The declarative extract tier was not sufficient here: tool args nest understate.inputand timestamps are numbers, not RFC 3339 strings. The nativeskilltool (input{name}) is a deterministic skill-invocation event, sosurfaces_skill_invocation = truewithskill_tool = "skill"/skill_arg = "name"and the__skill_invokedmeta-check grades from the transcript.[tools]declares the verified tool ids (bash= the shell tool id;edit/writetakefilePath;apply_patchtakespatchText— the shared boundary policy reads all three spellings). - Dispatch recipes + model flag: the
[dispatch]templates runopencode run --dir <eval-root> --format json --auto(plus[model] flag = "-m"; they landed together because descriptor validation ties the judge template's$model_argto a declared model flag). Verified against opencode v1.18.3 (opencode run --helpandpackages/opencode/src/cli/cmd/run.ts):--dir <dir>resolves the project instance against<dir>andchdirs there, so the staged.opencode/skillsin the env are discovered.- Piped stdin is appended to the message (
Bun.stdin.text()when stdin is not a TTY), so every recipe detaches with</dev/null>, same as codex. - Headless permission asks are auto-rejected unless
--autois passed; explicitdenyrules are still enforced under--auto. --format jsonstreams raw JSON events (tool_use/text/step_finish/error) to stdout; there is no--output-last-message, so the final message comes from thetextevents via transcript ingest (the dispatch prompt still asks foroutputs/final-message.md, which wins when the agent writes it).-mtakes models inprovider/modelformat; the value passes through verbatim.- Live-verified on v1.18.3 (one
opencode runper the recipe +ingest, #153): the staged skill is discovered and invoked under its slug via theskilltool, and the events file ingests to a full run record. An operator config with an explicitedit: denyrule blocks thefinal-message.mdwrite even under--auto(deny rules stay enforced) — record-runs then falls back to the transcript's lasttextpart, so the run still records cleanly.
- Shadow preflight: the
opencode-skillscapability scans every root OpenCode discovers skills from and warns at build time when a staged logical skill is also live there — see "Isolating from live skills" below. - Write guard: a project plugin staged at
.opencode/plugins/slow-powers-eval-guard.js— see "Write guard" below.
A guarded run (the guard auto-arms; --guard/--no-guard make it explicit) stages a project
plugin at .opencode/plugins/slow-powers-eval-guard.js from the embedded template
harnesses/opencode-guard-plugin.js ({exe}/{marker} substituted as JSON string literals).
OpenCode auto-loads project plugins by directory convention — no trust prompt, no dispatch flag
(in contrast to codex's --dangerously-bypass-hook-trust). The plugin's tool.execute.before
hook forwards every tool call as {"tool_name", "tool_input"} on stdin to the generic
eval-magic guard-hook --harness opencode <marker> entry point and throws the verdict's
reason to block; the shared arbiter stays the single classification point (the plugin is
deliberately dumb). The deny verdict rides the shared renderer with the codex-style
{"decision":"block","reason":"..."} shape. Teardown deletes the plugin it created (restoring a
pre-existing file verbatim) and prunes .opencode/plugins/ when restoring leaves it empty
(guard_hook_cleanup_dir).
Live-verified on v1.18.4 (#155):
- A staged
.opencode/plugins/*.jsauto-loads under headlessopencode run --dir <env> --format json --autowith no trust prompt (a debug plugin loggedplugin initialized). tool.execute.beforefires under--auto— i.e., after permission auto-approval (every tool call logged, includingreadandskill).- A thrown
Erroraborts the tool call and surfaces in the events stream as a tool part with"status":"error"carrying the message ("error":"eval guard: canary deny"); the blocked file was never created. - In an armed env: an in-bounds
writecompleted; an out-of-boundswriteto/tmp/...was blocked with"error":"eval guard: write to /tmp/... is outside the eval sandbox (allowed: ...)"; an out-of-bounds bash redirect (echo hi > /tmp/...) was blocked with"error":"eval guard: blocked bash (output redirection to a file) — runs outside the eval sandbox"; andeval-magic teardown-guardprinted🛡 Write guard removed., deleted the plugin, pruned.opencode/plugins/, and swept the marker.
Two boundary notes, shared with the other harnesses' guards:
- The marker's allowed roots are the env root and the OS temp dir — a write under
$TMPDIRis in-bounds by design. - Bash coverage is the shared heuristic denylist (installs, git mutations, redirects, config-dir
tampering): a bare
touch /abs/outside/pathmatches no pattern and is allowed — after-the-fact detection of those isdetect-stray-writes' job, same as claude/codex.
One hook-shape caveat: tool.execute.before fires for every tool (OpenCode has no matcher
surface), so each tool call spawns one eval-magic guard-hook. Classification stays in the
arbiter, and tools outside the write/patch/shell vocabulary fall through to allow in
milliseconds — the same fail-open-per-call posture as the other engines.
Every opencode run discovers skills well beyond the eval env. Before dispatch, the
opencode-skills preflight compares each logical eval skill name against every root OpenCode
loads from (verified against the v1.18.3 skills doc and packages/opencode/src/skill/index.ts):
.opencode/skills,.claude/skills, and.agents/skillsat each directory level walked up from the dispatch cwd to the git worktree (the staged env's own dirs are excluded — they hold the intentional staged copies);$XDG_CONFIG_HOME/opencode/skills(default~/.config/opencode/skills);$OPENCODE_CONFIG_DIR/skillswhen set — additive, not a replacement for the xdg default;- the legacy
~/.opencode/skills(still scanned by OpenCode, though the docs omit it); ~/.claude/skillsand~/.agents/skills.
The .claude/.agents roots are a cross-harness contamination vector: a skill installed for
Claude Code or Codex is visible to OpenCode sessions by default. Direct skill directories are
matched by the name: in SKILL.md frontmatter, not the folder name. Missing directories and
malformed skills are ignored; a failure in one root never suppresses findings from the others.
Findings produce a build-time OpenCode banner and the backward-compatible plugin-shadow.json
artifact; aggregate turns the same report into OpenCode-specific benchmark.json validity
warnings.
eval-magic detects but cannot unload these sources, and the generated dispatch recipes never set the kill switches below on the operator's behalf — parity with a real user session matters. Before dispatch, move or rename the conflicting skill directory; or, for a cross-harness root, hide it with the session environment:
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1hides the.clauderoots only (global + project);OPENCODE_DISABLE_EXTERNAL_SKILLS=1hides both.claudeand.agentsroots.
Known limits: OpenCode also loads skills from config-declared skills.paths directories
and skills.urls (remote-pulled), and matches a singular .opencode/skill/ directory; the
preflight does not scan those sources. Verify those cases manually when relevant.
OpenCode skill names must be 1–64 characters, lowercase alphanumeric with single-hyphen separators
(no leading/trailing/consecutive hyphens), and match the containing directory name. staged_slug
sanitizes the generated slug while preserving the slow-powers-eval- cleanup prefix (truncating
the skill portion if the combination exceeds 64 chars); validate_stage_name applies the same
rules to --stage-name overrides. Sibling skills stage at their natural names and must already
satisfy the rules.