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:
parent
c63c898833
commit
5253fe3aff
10 changed files with 1946 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
78
interfaces/Crafter.Review-OpenCode.cppm
Normal file
78
interfaces/Crafter.Review-OpenCode.cppm
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue