feat(opencode): the OpenCode harness, checked against its goldens

The tool source generated from the schema, the home with its view, the
shared and per-process environments, the permission rules, the commands,
the warm-up under the supervisor, the event stream and the
classification, its patterns as hand-written scanners. The goldens record
the answer for each, a warm-up against a fake opencode included.

A timed-out supervised command now gets SIGTERM, so the supervisor takes
its tree down (SIGKILL 10 s later); any other child gets SIGKILL. :Json
gains DumpCompact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 08:32:51 +01:00
commit 5253fe3aff
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
10 changed files with 1946 additions and 7 deletions

View file

@ -26,6 +26,12 @@ export namespace Crafter::Review {
return value.dump(1, ' ', true, Json::error_handler_t::replace);
}
// No whitespace, ASCII only, the keys in their order. What the MCP config
// and OpenCode's inline agent config go to their programs as.
inline std::string DumpCompact(const Json& value) {
return value.dump(-1, ' ', true, Json::error_handler_t::replace);
}
// Keys in code-point order, no whitespace, ASCII only. The
// structured-output schema goes to claude in this form and its bytes are
// part of the prompt cache, so they must not move by a byte (the goldens

View file

@ -0,0 +1,78 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Review:OpenCode;
import std;
import :Json;
// The OpenCode harness (2026-10-12): a lens pass as an `opencode run`
// process instead of `claude --print`, for models that are not Anthropic's -
// the lane for the hours when every Anthropic seat is out. A lens whose
// environment needs an MCP server never runs here. The pass keeps its
// prompts byte for byte: the appended system prompt becomes the agent's
// prompt (`{file:...}` in an inline config), the stdin user message its
// positional message, the read-only tool allowlist its permission rules, and
// the structured output a custom tool with the same schema that writes the
// result to a file the driver reads.
//
// OpenCode runs in a HOME of its own under the scratch dir: config, the custom
// tool and the SDK packages it installs on first use (warmed once before the
// pool starts), one data dir per process (its sqlite session store is never
// shared), and a view directory as the project - symlinks to the repository's
// top-level entries, so no instruction file of the repository root reaches the
// model.
export namespace Crafter::Review::OpenCode {
// The view links every other top-level entry of the consumer's checkout.
constexpr std::array<std::string_view, 2> ViewSkip = {".git", "node_modules"};
constexpr std::string_view Agent = "lens";
constexpr std::string_view Tool = "StructuredOutput";
constexpr std::string_view SoEnv = "REVIEW_GATES_STRUCTURED_OUT";
// The model's reasoning variants are low/medium/high (OpenRouter
// `reasoning.effort`).
constexpr std::string_view DefaultVariant = "high";
// Provider routing for OpenRouter models (Jorijn, 2026-10-12): zero data
// retention only, fp8 the lowest precision, cheapest first.
Json Routing();
// REVIEW_GATES_OPENCODE_BIN, else `opencode` on PATH.
std::string DefaultBin();
// `openrouter/xiaomi/mimo-v2.6-pro` -> (openrouter, xiaomi/mimo-v2.6-pro); a
// bare id is OpenRouter's.
std::pair<std::string, std::string> ModelId(std::string_view model);
// StructuredOutput.ts: the lens result schema as the tool's zod arguments
// (one tool for block and whole passes: the whole-only fields optional).
std::string ToolSource();
// <scratch>/opencode: the XDG home (config + tool), cache and state dirs,
// the view dir. Idempotent.
std::filesystem::path PrepareHome(const std::filesystem::path& scratch, const std::filesystem::path& repo, std::span<const std::string> models, const Json& routing = Routing());
// The environment every OpenCode process of the run shares: its own XDG
// home, the OpenRouter key, no Anthropic credential or endpoint, no
// auto-update. nullopt: this process's environment.
std::map<std::string, std::string> BaseEnv(const std::filesystem::path& home, std::string_view key,
const std::optional<std::map<std::string, std::string>>& base = std::nullopt);
// The claude harness's --allowedTools entries as OpenCode permission rules:
// Read/Glob/Grep allowed, every `Bash(<prefix>:*)` the bash pattern
// `<prefix>*`, everything else denied (the last matching rule wins, so the
// catch-all comes first).
Json Permissions(std::span<const std::string> allowList);
// One process: the agent (its prompt the pass's system prompt file), its
// permissions, the structured-output file, a data dir of its own.
std::map<std::string, std::string> ChildEnv(const std::map<std::string, std::string>& base, const std::filesystem::path& systemPath, const std::filesystem::path& soPath,
std::span<const std::string> allowList, const std::filesystem::path& dataDir);
// An empty variant passes none.
std::vector<std::string> Cmd(std::string_view bin, std::string_view model, const std::filesystem::path& view, std::string_view title, std::string_view variant = {},
std::string_view prompt = {});
// One short run before the pool: OpenCode installs the provider SDK into
// the config dir on first use (minutes; a wave of concurrent first runs
// would all do it). (ok, the tail of its stderr or why it failed).
std::pair<bool, std::string> Warm(const std::string& bin, const std::filesystem::path& home, const std::map<std::string, std::string>& env, std::string_view model,
const std::function<void(const std::string&)>& log, std::int64_t timeoutS = 600);
// The `--format json` event stream as Stream::ParseStream gives the claude
// one: usage totals, steps as turns, tool calls and errors, OpenCode's own
// cost, and the structured output - the last StructuredOutput call's
// arguments, the file it wrote the fallback.
Json ParseEvents(std::string_view text, const std::optional<std::filesystem::path>& soPath = std::nullopt);
// (status, reason) as Stream::Classify: ok | limit | credits | auth |
// timeout | error.
std::pair<std::string, std::string> Classify(const Json& parsed, std::int64_t exitCode, std::string_view stderrText, bool timedOut = false);
}

View file

@ -81,7 +81,9 @@ export namespace Crafter::Review::Process {
// A command run to its end with both outputs captured: stdin from
// `input`, or /dev/null; both outputs read as they come, so neither can
// fill its pipe and stall the child. With a timeout the child is killed
// when it runs out, and timedOut is set.
// when it runs out, and timedOut is set; a supervised command (Supervised)
// gets SIGTERM, so the supervisor takes its tree down, and SIGKILL 10 s
// later if it is still there.
struct Captured {
std::int32_t returnCode = -1;
std::string out;

View file

@ -28,5 +28,6 @@ export import :Memory;
export import :Mcp;
export import :Seats;
export import :SeatPool;
export import :OpenCode;
export import :Freeze;
export import :Driver;