// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module Crafter.Review:Driver_impl; import :Driver; import :Core; import :Text; import :Platform; import :Process; import :Inventory; import :Config; import :Freeze; import :PgWire; import :Totp; import :Plan; import :OpenCode; import :Run; import std; namespace fs = std::filesystem; namespace Crafter::Review { namespace { constexpr std::string_view Usage = R"(usage: crafter-review [--config FILE] {run,freeze,slots,config} ... crafter-review {seats,inventory,sql,totp,init,doc,browser,stop} ... crafter-review --version The pre-push review pipeline: adversarial lenses, one claude process per inventory unit, against a frozen commit range.)"; std::int32_t PrintVersion() { std::println("{}", DriverLine()); try { std::println("home {}", Home().string()); for (const auto& [name, sha] : CharterHashes()) std::println("charter {} {}", name, sha); } catch (const std::exception& e) { std::println("home not found: {}", e.what()); } return 0; } // A usage error: exit 3, the preflight's code, for every command that // cannot start. std::int32_t UsageError(std::string_view usage, std::string_view prog, std::string_view message) { std::println(std::cerr, "{}\n{}: error: {}", usage, prog, message); return 3; } // die(): the preflight's message and exit 3. std::int32_t Die(std::string_view message) { std::println(std::cerr, "preflight: {}", message); return 3; } // ---- the option parser these commands use --------------------------------- enum class Kind { String, Int, Float, }; struct OptionSpec { std::string_view name; // A value follows (`--x V` or `--x=V`); else a store_true flag. bool takesValue; std::string_view metavar; std::string_view help; // An integer or a float option: the value must parse as // Dyn::ParseInt / Dyn::ParseFloat takes it. Kind kind = Kind::String; std::span choices = {}; bool required = false; // Left out of --help. bool hidden = false; }; struct Parsed { std::map> values; std::set> flags; bool help = false; std::string error; std::optional Value(std::string_view name) const { const auto found = values.find(name); if (found == values.end()) return std::nullopt; return found->second; } bool Flag(std::string_view name) const { return flags.contains(name); } }; std::string UsageLine(std::string_view prog, std::span specs) { std::string line = std::format("usage: {} [-h]", prog); for (const OptionSpec& spec : specs) { if (spec.hidden) continue; const std::string text = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name); line += spec.required ? std::format(" {}", text) : std::format(" [{}]", text); } return line; } std::string Help(std::string_view prog, std::string_view description, std::span specs) { std::string text = std::format("{}\n\n{}\n\noptions:\n -h, --help show this help message and exit\n", UsageLine(prog, specs), description); for (const OptionSpec& spec : specs) { if (spec.hidden) continue; const std::string left = spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name); text += left.size() < 20 ? std::format(" {:<20} {}\n", left, spec.help) : std::format(" {}\n {}\n", left, spec.help); } return text; } // `--opt value`, `--opt=value`, any unique prefix of a long option, the // last of a repeated option, every unrecognized argument named at the // end. Parsed ParseOptions(std::span args, std::span specs) { Parsed parsed; std::vector unrecognized; for (std::size_t i = 0; i < args.size(); ++i) { const std::string_view arg = args[i]; if (arg == "-h") { parsed.help = true; continue; } if (!arg.starts_with("--") || arg == "--") { unrecognized.emplace_back(arg); continue; } const std::size_t equals = arg.find('='); const std::string_view name = arg.substr(0, equals); if (name.size() > 2 && std::string_view("--help").starts_with(name) && std::ranges::none_of(specs, [&](const OptionSpec& s) { return s.name.starts_with(name); })) { parsed.help = true; continue; } std::vector matches; for (const OptionSpec& spec : specs) { if (spec.name == name) { matches = {&spec}; break; } if (spec.name.starts_with(name)) matches.push_back(&spec); } if (matches.empty()) { unrecognized.emplace_back(arg); continue; } if (matches.size() > 1) { std::vector names; for (const OptionSpec* spec : matches) names.emplace_back(spec->name); parsed.error = std::format("ambiguous option: {} could match {}", name, Dyn::Join(names, ", ")); return parsed; } const OptionSpec& spec = *matches.front(); const std::string key(spec.name.substr(2)); if (!spec.takesValue) { if (equals != std::string_view::npos) { parsed.error = std::format("argument {}: ignored explicit argument {}", spec.name, Dyn::Repr(arg.substr(equals + 1))); return parsed; } parsed.flags.insert(key); continue; } std::string value; if (equals != std::string_view::npos) { value = std::string(arg.substr(equals + 1)); } else if (i + 1 < args.size() && !(args[i + 1].starts_with('-') && args[i + 1].size() > 1)) { value = std::string(args[++i]); } else { parsed.error = std::format("argument {}: expected one argument", spec.name); return parsed; } if (spec.kind == Kind::Int && !Dyn::ParseInt(value)) { parsed.error = std::format("argument {}: invalid int value: {}", spec.name, Dyn::Repr(value)); return parsed; } if (spec.kind == Kind::Float && !Dyn::ParseFloat(value)) { parsed.error = std::format("argument {}: invalid float value: {}", spec.name, Dyn::Repr(value)); return parsed; } if (!spec.choices.empty() && !std::ranges::contains(spec.choices, value)) { std::vector quoted; for (const std::string_view c : spec.choices) quoted.push_back(Dyn::Repr(c)); parsed.error = std::format("argument {}: invalid choice: {} (choose from {})", spec.name, Dyn::Repr(value), Dyn::Join(quoted, ", ")); return parsed; } parsed.values[key] = std::move(value); } if (!unrecognized.empty()) { parsed.error = std::format("unrecognized arguments: {}", Dyn::Join(unrecognized, " ")); return parsed; } std::vector missing; for (const OptionSpec& spec : specs) { if (spec.required && !parsed.values.contains(spec.name.substr(2))) missing.emplace_back(spec.name); } if (!missing.empty()) parsed.error = std::format("the following arguments are required: {}", Dyn::Join(missing, ", ")); return parsed; } // The consumer config, loaded before every command but `config`: // warnings on stderr (`run` logs them with its other lines), a problem // stops it. std::optional LoadConfig(const std::optional& configPath, bool warn = true) { Config::Loaded loaded = Config::Load(fs::current_path(), configPath ? fs::path(*configPath) : fs::path()); if (warn) { for (const Json& warning : Dyn::Or(Dyn::Get(loaded.config, "_warnings"), Dyn::EmptyList())) std::println(std::cerr, "config: warning: {}", warning.get()); } if (!loaded.problems.empty()) { Die(std::format("config: {}", Dyn::Join(loaded.problems, "; "))); return std::nullopt; } return std::move(loaded.config); } constexpr std::array FreezeOptions = {{ {"--repo", true, "REPO", "the repository (toplevel of a checkout; default: the working directory)"}, {"--slug", true, "SLUG", "the artifact dir name: .review-gates/push//"}, {"--out", true, "OUT", "the artifact dir (overrides --slug)"}, {"--base", true, "BASE", "the range base; default @{upstream}, else merge-base with the configured base branch"}, {"--replace", false, "", "re-freeze into an out dir that already holds patches (gate-state/ is kept) and supersede this repo's worktrees"}, {"--worktree", false, "", "check out a detached worktree of HEAD under .review-gates/trees/ for the lenses (printed as --trees)"}, {"--require-signed", false, "", "an unsigned commit fails instead of warning"}, {"--date", true, "DATE", "the run date (default today)"}, {"--verify", false, "", "push-time check: HEAD, a clean tree and a fresh export still match freeze.json"}, }}; constexpr std::array Modes = {"commits"}; constexpr std::array Harnesses = {"claude", "opencode"}; const std::vector& RunOptions() { static const std::string effort = std::format("reasoning effort of every lens process (default {})", Plan::Effort); static const std::string reads = std::format("clean reads a code unit of the static code lenses needs before its verdict is reused, one per round (default {})", Plan::Reads); static const std::string models = [] { std::vector keys; for (const Plan::ModelTier& m : Plan::Models()) keys.push_back(m.tier); return std::format("override the lens model table, e.g. fable=claude-fable-5-1 (keys: {})", Dyn::Join(keys, ",")); }(); static const std::string variant = std::format("opencode harness: the model's reasoning variant (default {})", OpenCode::DefaultVariant); static const std::vector specs = { {"--mode", true, "MODE", "", Kind::String, Modes, false, true}, {"--artifact", true, "ARTIFACT", "the frozen artifact dir (.review-gates/push//)", Kind::String, {}, true}, {"--hash", true, "HASH", ""}, {"--dispositions", true, "DISPOSITIONS", ""}, {"--base", true, "BASE", ""}, {"--date", true, "DATE", ""}, {"--trees", true, "TREES", "the checkout the lenses read, at the range HEAD: the repository itself (`.`, default) or the frozen worktree"}, {"--context-paths", true, "CONTEXT_PATHS", "comma-separated paths of material the artifact discusses (captures, evidence) - mechanical pointers, no narrative"}, {"--only", true, "ONLY", "comma-separated lens files (test only)"}, {"--without", true, "WITHOUT", "comma-separated lens files to leave out of this run by owner ruling (logged; the run stays a full run)"}, {"--units", true, "UNITS", "comma-separated unit ids (test only)"}, {"--warm", false, "", "test runs only: let one pass per prompt prefix go first, as a full run does"}, {"--concurrency", true, "CONCURRENCY", "lens processes in flight at once across all seats (default 128, 2026-10-15; the pool still places each one by seat headroom, launches a pass only when it fits in free " "memory at its environment's measured cost, and gives a pass with the browser a stack of its own)", Kind::Int}, {"--effort", true, "EFFORT", effort}, {"--reads", true, "READS", reads, Kind::Int}, {"--no-reuse", false, "", ""}, {"--no-salvage", false, "", "do not reuse finished process outputs of an earlier run on this hash"}, {"--dry-run", false, "", ""}, {"--claude-bin", true, "CLAUDE_BIN", ""}, {"--models", true, "MODELS", models}, {"--scratch", true, "SCRATCH", ""}, {"--seats", true, "SEATS", "the seats the lenses run on: auto (every Anthropic seat in the env file, else local), local, api, oauth, openrouter (never in auto), or a list like api1,1,2,local"}, {"--env-file", true, "ENV_FILE", "dotenv file with CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6 (default $SEATS_ENV_FILE, else the repository .env)"}, {"--no-probe", false, "", "skip the one-request probe of every seat at the start"}, {"--api-budget", true, "API_BUDGET", "USD an API-credit seat may spend per cycle (default REVIEW_GATES_API_BUDGET_USD or 200)", Kind::Float}, {"--harness", true, "{claude,opencode}", "the agent harness of every lens process: claude (default) or opencode (OpenRouter seats only; no lens whose environment needs an MCP server)", Kind::String, Harnesses}, {"--variant", true, "VARIANT", variant}, {"--opencode-bin", true, "OPENCODE_BIN", ""}, }; return specs; } std::int32_t RunCommand(std::span args, const std::optional& configPath) { constexpr std::string_view Prog = "crafter-review run"; const std::vector& specs = RunOptions(); const Parsed parsed = ParseOptions(args, specs); if (parsed.help) { std::print("{}", Help(Prog, "one claude process per inventory unit per lens, the gates, the dump (process/prepush.md)", specs)); return 0; } if (!parsed.error.empty()) return UsageError(UsageLine(Prog, specs), Prog, parsed.error); const std::optional config = LoadConfig(configPath, false); if (!config) return 3; Run::Arguments a; a.config = configPath; a.mode = parsed.Value("mode").value_or("commits"); a.artifact = parsed.Value("artifact").value_or(""); a.hash = parsed.Value("hash"); a.dispositions = parsed.Value("dispositions"); a.base = parsed.Value("base").value_or(""); a.date = parsed.Value("date").value_or(Platform::TodayIso()); a.trees = parsed.Value("trees"); a.contextPaths = parsed.Value("context-paths").value_or(""); a.only = parsed.Value("only"); a.without = parsed.Value("without"); if (const std::optional units = parsed.Value("units"); units && !units->empty()) { // A trailing comma names no unit. a.units = std::set(); for (const std::string& part : Dyn::Split(*units, ",")) { if (std::string id = Dyn::Strip(part); !id.empty()) a.units->insert(std::move(id)); } } a.warm = parsed.Flag("warm"); if (const std::optional n = parsed.Value("concurrency")) a.concurrency = *Dyn::ParseInt(*n); a.effort = parsed.Value("effort").value_or(""); if (a.effort.empty()) a.effort = std::string(Plan::DefaultEffort()); a.noReuse = parsed.Flag("no-reuse"); a.noSalvage = parsed.Flag("no-salvage"); a.dryRun = parsed.Flag("dry-run"); a.claudeBin = parsed.Value("claude-bin"); a.models = parsed.Value("models").value_or(""); a.scratch = parsed.Value("scratch"); a.seats = parsed.Value("seats"); if (!a.seats) a.seats = Platform::GetEnv("REVIEW_GATES_SEATS"); a.envFile = parsed.Value("env-file"); a.noProbe = parsed.Flag("no-probe"); if (const std::optional budget = parsed.Value("api-budget")) a.apiBudget = *Dyn::ParseFloat(*budget); a.harness = parsed.Value("harness").value_or("claude"); a.variant = parsed.Value("variant"); if (!a.variant || a.variant->empty()) a.variant = a.harness == "opencode" ? std::optional(std::string(OpenCode::DefaultVariant)) : std::nullopt; a.opencodeBin = parsed.Value("opencode-bin"); if (const std::optional reads = parsed.Value("reads")) { const std::int64_t n = *Dyn::ParseInt(*reads); if (n < 1) return Die("--reads: a number from 1 up"); a.reads = n; } for (const std::string& part : Dyn::Split(a.models, ",")) { const std::string kv = Dyn::Strip(part); if (kv.empty()) continue; const std::size_t equals = kv.find('='); if (equals == std::string::npos) throw Dyn::Error("ValueError", "not enough values to unpack (expected 2, got 1)"); const std::string key = kv.substr(0, equals); std::vector& tiers = Plan::Models(); const auto tier = std::ranges::find_if(tiers, [&](const Plan::ModelTier& m) { return m.tier == key; }); if (tier == tiers.end()) { std::vector known; for (const Plan::ModelTier& m : tiers) known.push_back(m.tier); std::println(std::cerr, "--models: unknown key {} (known: {})", Dyn::Repr(key), Dyn::Join(known, ", ")); return 1; } tier->id = kv.substr(equals + 1); } return Run::Main(std::move(a), *config); } constexpr std::array SlotsOptions = {{ {"--artifact", true, "ARTIFACT", "a frozen artifact dir: no more slots than its browser passes can use at once"}, }}; std::int32_t SlotsCommand(std::span args, const std::optional& configPath) { constexpr std::string_view Prog = "crafter-review slots"; const Parsed parsed = ParseOptions(args, SlotsOptions); if (parsed.help) { std::print("{}", Help(Prog, "how many stacks (slots.*) fit in free memory, and the commands that bring up the missing ones", SlotsOptions)); return 0; } if (!parsed.error.empty()) return UsageError(UsageLine(Prog, SlotsOptions), Prog, parsed.error); const std::optional config = LoadConfig(configPath); if (!config) return 3; const std::optional artifact = parsed.Value("artifact"); try { Run::SlotsPlan(*config, fs::current_path(), artifact ? std::optional(*artifact) : std::nullopt); } catch (const Inventory::InvariantError& e) { return Die(e.what()); } return 0; } std::int32_t FreezeCommand(std::span args, const std::optional& configPath) { constexpr std::string_view Prog = "crafter-review freeze"; const Parsed parsed = ParseOptions(args, FreezeOptions); if (parsed.help) { std::print("{}", Help(Prog, "cut the frozen commit-range artifact for the push gates (process/prepush.md Gate 0)", FreezeOptions)); return 0; } if (!parsed.error.empty()) return UsageError(UsageLine(Prog, FreezeOptions), Prog, parsed.error); const std::optional config = LoadConfig(configPath); if (!config) return 3; Freeze::Arguments arguments; arguments.repo = parsed.Value("repo"); arguments.slug = parsed.Value("slug"); arguments.out = parsed.Value("out"); arguments.base = parsed.Value("base"); arguments.replace = parsed.Flag("replace"); arguments.worktree = parsed.Flag("worktree"); arguments.requireSigned = parsed.Flag("require-signed"); arguments.date = parsed.Value("date").value_or(Platform::TodayIso()); arguments.verify = parsed.Flag("verify"); // `%G?` starts gpg, which can leave a daemon behind: as a subreaper // this process inherits it, and takes it down on the way out. Process::BecomeSubreaper(); const std::int32_t code = Freeze::Main(arguments, *config); Process::KillDescendants(); return code; } } std::int32_t Main(std::span args) { const std::span rest = args.empty() ? args : args.subspan(1); // The supervisor first and alone: it is single-threaded and has to // stay so, and it runs between the driver and claude, not as a // command anyone types. if (!rest.empty() && rest.front() == Process::SuperviseVerb) return Process::SuperviseMain(rest.subspan(1)); Platform::Init(); // Before any other thread: SIGINT, SIGTERM and SIGHUP go to a thread of // their own, which stops a run once it is under way and otherwise ends // the process. Process::InstallSignalThread(Run::OnSignal); // The options before the command: --config FILE or --config=FILE, and // any unique prefix of a long option. std::optional configPath; std::size_t at = 0; while (at < rest.size() && rest[at].starts_with("--") && rest[at] != "--") { const std::string_view arg = rest[at]; const std::size_t equals = arg.find('='); const std::string_view name = arg.substr(0, equals); if (name.size() > 2 && std::string_view("--config").starts_with(name)) { if (equals != std::string_view::npos) { configPath = std::string(arg.substr(equals + 1)); } else if (at + 1 < rest.size()) { configPath = std::string(rest[++at]); } else { return UsageError(Usage, "crafter-review", "argument --config: expected one argument"); } } else if (name.size() > 2 && std::string_view("--version").starts_with(name)) { return PrintVersion(); } else if (name.size() > 2 && std::string_view("--help").starts_with(name)) { std::println("{}", Usage); return 0; } else { return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", arg)); } ++at; } if (at < rest.size() && rest[at] == "-h") { std::println("{}", Usage); return 0; } if (at >= rest.size()) return UsageError(Usage, "crafter-review", "a command is required"); const std::string_view command = rest[at]; const std::span after = rest.subspan(at + 1); try { // The helpers a lens runs: they read neither the home nor the // config, only their arguments, stdin and REVIEW_GATES_DB. if (command == "sql") return PgWire::SqlMain(after); if (command == "totp") return Totp::Main(after); if (command == "inventory" && !configPath) return Inventory::Main(after); if (command == "config") { if (!after.empty()) return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", after.front())); return Config::Main(fs::current_path(), configPath ? fs::path(*configPath) : fs::path()); } if (command == "freeze") return FreezeCommand(after, configPath); if (command == "run") return RunCommand(after, configPath); if (command == "slots") return SlotsCommand(after, configPath); } catch (const Dyn::Error& error) { // An error no command catches: its message, exit 1. std::println(std::cerr, "crafter-review: {}: {}", error.Kind(), error.what()); return 1; } return UsageError(Usage, "crafter-review", std::format("argument cmd: invalid choice: '{}'", command)); } }