// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include module Crafter.Review:Driver_impl; import :Driver; import :Core; import :Json; import :Text; import :Platform; import :Process; import :Inventory; import :Config; import :Freeze; import :PgWire; import :Totp; import :Plan; import :Mcp; import :Seats; import :SeatPool; 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; std::vector positionals; 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); } }; // A positional as the usage line names it: its choices in braces, else // its name. std::string PositionalName(const OptionSpec& positional) { if (positional.choices.empty()) return std::string(positional.name); const std::vector names(positional.choices.begin(), positional.choices.end()); return std::format("{{{}}}", Dyn::Join(names, ",")); } std::string UsageLine(std::string_view prog, std::span specs, std::span positionals = {}) { 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); } for (const OptionSpec& positional : positionals) { line += positional.required ? std::format(" {}", PositionalName(positional)) : std::format(" [{}]", PositionalName(positional)); } return line; } std::string HelpRow(std::string_view left, std::string_view help) { return left.size() < 20 ? std::format(" {:<20} {}\n", left, help) : std::format(" {}\n {}\n", left, help); } std::string Help(std::string_view prog, std::string_view description, std::span specs, std::span positionals = {}) { std::string text = std::format("{}\n\n{}\n\n", UsageLine(prog, specs, positionals), description); if (!positionals.empty()) { text += "positional arguments:\n"; for (const OptionSpec& positional : positionals) text += HelpRow(PositionalName(positional), positional.help); text += "\n"; } text += "options:\n -h, --help show this help message and exit\n"; for (const OptionSpec& spec : specs) { if (spec.hidden) continue; text += HelpRow(spec.takesValue ? std::format("{} {}", spec.name, spec.metavar) : std::string(spec.name), 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; positionals in their order, each one word, optional unless // required. Parsed ParseOptions(std::span args, std::span specs, std::span positionals = {}) { 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 == "--") { if (arg != "--" && !(arg.starts_with('-') && arg.size() > 1) && parsed.positionals.size() < positionals.size()) { const OptionSpec& positional = positionals[parsed.positionals.size()]; if (!positional.choices.empty() && !std::ranges::contains(positional.choices, arg)) { std::vector quoted; for (const std::string_view c : positional.choices) quoted.push_back(Dyn::Repr(c)); parsed.error = std::format("argument {}: invalid choice: {} (choose from {})", positional.name, Dyn::Repr(arg), Dyn::Join(quoted, ", ")); return parsed; } parsed.positionals.emplace_back(arg); } else { 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); } for (std::size_t i = parsed.positionals.size(); i < positionals.size(); ++i) { if (positionals[i].required) missing.emplace_back(positionals[i].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 Harnesses = {"claude", "opencode"}; constexpr std::array PersonaBlocks = {"pruned", "all"}; const std::vector& RunOptions() { static const std::string effort = std::format("reasoning effort of every lens process (default: the mode's, else {} for a reply or a series and {} for a commit range)", Plan::KernelEffort, 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", "a mode of the config (modes.: its artifact kind, the lenses it runs, its effort; default commits)"}, {"--subsystem", true, "SUBSYSTEM", "journal subsystem: required for reply and series; optional in commits mode (names the ledger and captures the lenses may read)"}, {"--artifact", true, "ARTIFACT", "the frozen artifact: a dir of *.patch (.review-gates/push//, a series), or the reply draft file"}, {"--hash", true, "HASH", ""}, {"--reviewers", true, "REVIEWERS", "reply and series: the reviewers a persona reads as, comma-separated (the persona gate runs after the others)"}, {"--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; reply and series default work/linux,work/milos-linux"}, {"--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", "a cap on the lens processes in flight on this machine, every driver's counted (default none: each OAuth account takes at most 128 - " "REVIEW_GATES_OAUTH_CONCURRENCY - and an API seat REVIEW_GATES_API_CONCURRENCY; the pool still places each pass by seat headroom, launches it 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"}, {"--persona-blocks", true, "{pruned,all}", "reply and series: pruned (default) skips the block passes of the units a persona's whole pass marked not-mine", Kind::String, PersonaBlocks}, {"--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 and the placement settings (REVIEW_GATES_SEAT_ORDER, the budgets, the concurrency): default $SEATS_ENV_FILE, else the working directory's .env when it holds a seat or a placement setting, else $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env); an empty value: none"}, {"--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"); const std::optional mode = Config::ModeOf(*config, a.mode); if (!mode) { return Die(std::format("--mode {}: no such mode in the config (modes: {}) - a mode is an artifact kind and the lenses it runs, modes. in .review-gates/config.json", a.mode, Dyn::Join(Config::ModeNames(*config), ", "))); } a.kind = mode->kind; a.subsystem = parsed.Value("subsystem"); a.artifact = parsed.Value("artifact").value_or(""); if (a.artifact.empty()) return UsageError(UsageLine(Prog, specs), Prog, "the following arguments are required: --artifact"); a.hash = parsed.Value("hash"); a.reviewers = parsed.Value("reviewers").value_or(""); a.personaBlocks = parsed.Value("persona-blocks").value_or("pruned"); 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)); } } // Empty items are skipped, so a typo such as `--units ,` must not // leave no filter at all: that is a full run. auto namesNothing = [](const std::optional& list) { return list && !list->empty() && std::ranges::all_of(Dyn::Split(*list, ","), [](const std::string& part) { return Dyn::Strip(part).empty(); }); }; if (namesNothing(a.only)) return UsageError(UsageLine(Prog, specs), Prog, std::format("argument --only: names no lens file ({})", Dyn::Repr(*a.only))); if (namesNothing(parsed.Value("units"))) return UsageError(UsageLine(Prog, specs), Prog, std::format("argument --units: names no unit id ({})", Dyn::Repr(*parsed.Value("units")))); a.warm = parsed.Flag("warm"); if (const std::optional n = parsed.Value("concurrency")) { a.concurrency = *Dyn::ParseInt(*n); if (*a.concurrency < 1) return UsageError(UsageLine(Prog, specs), Prog, "argument --concurrency: a number from 1 up (leave it out for no cap)"); } a.effort = parsed.Value("effort").value_or(""); if (a.effort.empty()) a.effort = mode->effort.value_or(std::string(Plan::DefaultEffort(a.kind))); if (a.kind == "commits") { if (!a.reviewers.empty()) return Die("commits mode has no persona gate (the owner is the last gate): drop --reviewers"); } else { if (!a.subsystem || a.subsystem->empty()) return Die(std::format("{} mode: --subsystem is required", a.mode)); if (!a.trees || a.trees->empty()) a.trees = "work/linux,work/milos-linux"; } 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"); a.reads = Plan::ReadsFor(a.kind); if (const std::optional reads = parsed.Value("reads")) { const std::int64_t n = *Dyn::ParseInt(*reads); if (n < 1 || (a.kind == "reply" && n != 1)) return Die("--reads: a number from 1 up; a reply has no code units and is read once"); 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(); struct Sweep { ~Sweep() { Process::KillDescendants(); } } const sweep; return Freeze::Main(arguments, *config); } // ---- seats: the report, the ledger and the board beside it ------------------ constexpr std::array SeatsOptions = {{ {"--env-file", true, "ENV_FILE", "dotenv file holding the seat tokens and the placement settings: default $SEATS_ENV_FILE, else the working directory's .env when it holds a seat or a placement setting, else $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env); an empty value: none"}, {"--json", false, "", "the probe results as JSON instead of the report"}, {"--no-fable", false, "", "probe on Haiku instead of Fable (cheaper; the Fable weekly window stays unread)"}, {"--local", false, "", "also probe the CLI's own login, shown as the seat `local`"}, {"--timeout", true, "TIMEOUT", "seconds per probe (default 30)", Kind::Float}, }}; // datetime.fromtimestamp(t, timezone.utc).isoformat(timespec='milliseconds') // with Z for +00:00. std::string IsoUtcMillis(double epoch) { double whole = std::floor(epoch); auto micros = static_cast(std::nearbyint((epoch - whole) * 1e6)); if (micros >= 1'000'000) { whole += 1; micros -= 1'000'000; } const std::chrono::sys_seconds at {std::chrono::seconds(static_cast(whole))}; return std::format("{:%Y-%m-%dT%H:%M:%S}.{:03d}Z", at, micros / 1000); } struct SeatJob { std::int32_t seat = 0; std::string token; bool local = false; }; // Every seat probed, eight at once at most; a probe that throws ends // the command. std::vector ProbeAll(const std::vector& todo, const Seats::ProbeOptions& base) { std::vector out(todo.size()); std::atomic next = 0; std::mutex lock; std::exception_ptr failed; std::vector workers; for (std::size_t w = 0; w < std::min(8, todo.size()); ++w) { workers.emplace_back([&] { for (std::size_t i = next++; i < todo.size(); i = next++) { try { Seats::ProbeOptions options = base; options.local = todo[i].local; out[i] = Seats::ProbeSeat(todo[i].seat, todo[i].token, options); } catch (const std::exception&) { const std::lock_guard guard(lock); if (!failed) failed = std::current_exception(); } } }); } for (std::thread& worker : workers) worker.join(); if (failed) std::rethrow_exception(failed); return out; } std::int32_t SeatsCommand(std::span args) { constexpr std::string_view Prog = "crafter-review seats"; const Parsed parsed = ParseOptions(args, SeatsOptions); if (parsed.help) { std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 from the environment and/or the dotenv file. Never prints a token.\n", Help(Prog, "How much usage each Claude seat has left: every seat probed, both windows and their resets.", SeatsOptions)); return 0; } if (!parsed.error.empty()) return UsageError(UsageLine(Prog, SeatsOptions), Prog, parsed.error); const std::optional timeoutText = parsed.Value("timeout"); const double timeout = timeoutText ? Dyn::ParseFloat(*timeoutText).value_or(Seats::ProbeTimeoutS) : Seats::ProbeTimeoutS; // The seats file, read once: its placement settings, under the // process environment, for the placement readers alone // (SeatPool::SettingsOf; any other REVIEW_GATES_* line of it is // named on stderr), its tokens over the process environment for // the probes (SeatPool::WithFileTokens). const SeatPool::SeatsFile seatsFile = SeatPool::ResolveSeatsFile(parsed.Value("env-file"), fs::current_path()); const std::optional envPath = seatsFile.path; const std::map process = Platform::Environment(); const SeatPool::Settings fileSettings = SeatPool::SettingsOf(seatsFile); const std::map& settings = fileSettings.env; if (!fileSettings.ignored.empty() && envPath) { std::println(std::cerr, "seats file: {} in {} - no placement setting, read from the process environment alone", Dyn::Join(fileSettings.ignored, ", "), envPath->string()); } const Json cfg = Seats::ConfigFromEnv(settings); std::string envFile = envPath ? envPath->string() : std::string(); // The file's one read: one that could not be read, or is not UTF-8, // ends the command with the read's error. std::map env = SeatPool::WithFileTokens(process, seatsFile); std::vector jobs; for (const auto& [seat, token] : Seats::SeatTokensFromEnv(env)) jobs.push_back({seat, token, false}); if (parsed.Flag("local")) jobs.insert(jobs.begin(), SeatJob {0, "", true}); const std::vector> apiJobs = Seats::ApiKeysFromEnv(env); if (jobs.empty() && apiJobs.empty()) { std::println("no seat configured (CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6{}; --local probes your own login)", envFile.empty() ? std::string() : std::format(" in {}", envFile)); return 1; } const std::string claudeBin = Seats::FindClaudeBin(); const double now = Platform::Now(); Seats::ProbeOptions haiku; haiku.timeoutS = timeout; haiku.claudeBin = claudeBin; std::vector seats; if (!jobs.empty() && parsed.Flag("no-fable")) { seats = ProbeAll(jobs, haiku); } else if (!jobs.empty()) { // One Fable probe per seat carries all three windows and is the // seat; only a seat whose Fable probe could not be read, or was // refused for the Fable window alone, is probed again on Haiku // for its own state. Seats::ProbeOptions fable = haiku; fable.model = std::string(Seats::FableProbeModel); const std::vector first = ProbeAll(jobs, fable); std::vector again; for (std::size_t i = 0; i < jobs.size(); ++i) { if (Seats::NeedsHaiku(first[i])) again.push_back(jobs[i]); } std::map standIns; const std::vector got = again.empty() ? std::vector() : ProbeAll(again, haiku); for (std::size_t i = 0; i < again.size(); ++i) standIns[again[i].seat] = got[i]; for (const Json& s : first) { const auto found = standIns.find(s.at("seat").get()); seats.push_back(Seats::MergeFableProbe(found != standIns.end() ? found->second : s, s)); } } const Json apiConfig = SeatPool::ApiConfig(settings); std::vector apiSeats; if (!apiJobs.empty()) { std::vector todo; for (const auto& [n, key] : apiJobs) todo.push_back({n, key, false}); Seats::ProbeOptions api = haiku; api.api = true; apiSeats = ProbeAll(todo, api); // The driver's ledger: what it spent on each key this cycle. SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig); for (Json& s : apiSeats) s["ledger"] = ledger.Entry(std::format("api{}", s.at("seat").get())); } // The board: the drivers running now and what they hold on each // account - read, never written. const Json board = SeatPool::Board(SeatPool::BoardPath()).View(now); std::map keys; for (const SeatJob& j : jobs) keys[j.seat] = SeatPool::SeatKey(j.local ? "local" : "oauth", j.token); std::map apiKeys; for (const auto& [n, key] : apiJobs) apiKeys[n] = SeatPool::SeatKey("api", key); const Seats::Zone zone = Seats::LocalZone(); if (parsed.Flag("json")) { Json eligible = Json::array(); for (const Json& s : seats) { if (Seats::IsEligible(s, cfg)) eligible.push_back(s.at("seat")); } const Json out = {{"probedAt", IsoUtcMillis(now)}, {"config", cfg}, {"seats", seats}, {"apiSeats", apiSeats}, {"eligible", eligible}, {"next", seats.empty() ? Json() : Seats::PickNext(seats, cfg)}, {"board", board}}; std::println("{}", out.dump(2, ' ', false, Json::error_handler_t::replace)); return 0; } const std::string source = std::format("{}{}", envFile.empty() ? std::string("the environment") : envFile, parsed.Flag("local") ? " and your own login" : ""); std::vector ids; for (const Json& s : seats) ids.push_back(s.at("seat") == 0 ? std::string("local") : std::to_string(s.at("seat").get())); for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get())); std::println("seat pool: {} seat(s) ({}) from {}, probed {} {}, claude: {}\n", ids.size(), Dyn::Join(ids, ", "), source, Seats::Clock(now, zone), zone.label, claudeBin); if (!seats.empty()) std::println("{}", Seats::RenderStatusReport(seats, cfg, now, zone, board, keys)); if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, board, apiKeys)); std::println("\n{}", Seats::RenderDrivers(board, now, zone)); // The pids above are no use from inside the sandbox: the way to // stop a run instead. if (Platform::InSandbox() && !Dyn::Get(board, "drivers").empty()) { std::println("stop a run with `{} stop --hash ` or `{} stop `", Invocation(), Invocation()); } return 0; } // ---- stop: the file a running driver looks for every 2 s ------------------------ constexpr std::array StopOptions = {{ {"--hash", true, "HASH8", "every live run of this artifact hash on the board (its first 8 characters)"}, }}; constexpr std::array StopPositionals = {{ {"scratch", false, "", "the run's scratch dir (the last word of its gate-0 log)"}, }}; std::int32_t StopCommand(std::span args) { constexpr std::string_view Prog = "crafter-review stop"; const Parsed parsed = ParseOptions(args, StopOptions, StopPositionals); if (parsed.help) { std::print("{}", Help(Prog, "stop a running review as SIGTERM would - its running passes killed, no later gate started, the dump written - from any sandbox", StopOptions, StopPositionals)); return 0; } if (!parsed.error.empty()) return UsageError(UsageLine(Prog, StopOptions, StopPositionals), Prog, parsed.error); const std::optional hash = parsed.Value("hash"); if (hash.has_value() == !parsed.positionals.empty()) return UsageError(UsageLine(Prog, StopOptions, StopPositionals), Prog, "a scratch dir or --hash, one of the two"); const fs::path boardPath = SeatPool::BoardPath(); std::vector> targets; if (!hash) { const fs::path dir = fs::absolute(parsed.positionals.front()); const std::string key = Dyn::Strip(Platform::ReadFile(dir / ".driver").value_or("")); if (key.empty()) { std::println(std::cerr, "stop: {} is no run's scratch dir (it has no .driver marker)", dir.string()); return 1; } if (!SeatPool::DriverAlive(key, boardPath.parent_path(), false)) { std::println(std::cerr, "stop: the driver of {} ({}) is no longer running", dir.string(), key); return 1; } targets.emplace_back(key, dir); } else { const std::string h8 = hash->substr(0, 8); const Json board = SeatPool::Board(boardPath).View(Platform::Now()); const Json& drivers = Dyn::Get(board, "drivers"); for (auto it = drivers.begin(); it != drivers.end(); ++it) { const Json& d = it.value(); if (Dyn::StrOr(Dyn::Get(d, "run"), "").ends_with(std::format("@ {}", h8)) && Dyn::Truthy(Dyn::Get(d, "scratch"))) targets.emplace_back(it.key(), Dyn::Str(d.at("scratch"))); } if (targets.empty()) { std::println(std::cerr, "stop: no live run of {} on the board ({})", h8, boardPath.string()); return 1; } } for (const auto& [key, dir] : targets) { std::ofstream file(dir / "stop", std::ios::binary); file << std::format("asked by `{} stop` at {}\n", Invocation(), Platform::LocalTime("%Y-%m-%dT%H:%M:%S")); if (!file) { std::println(std::cerr, "stop: cannot write {}", (dir / "stop").string()); return 1; } std::println("stop: driver {} ({}) - asked to stop; a run stops within 2 s, else SIGTERM its pid", key, dir.string()); } return 0; } // ---- doc: the texts the Flatpak ships ------------------------------------------------- constexpr std::array DocNames = {"prepush", "manifest", "config"}; constexpr std::array DocOptions = {{ {"--path", false, "", "where the file is, as the host sees it, instead of the file (the procedure is 35 KB: the skill reads it from there)"}, }}; constexpr std::array DocPositionals = {{ {"name", false, "", "prepush: the procedure; manifest: the manifest template; config: the config template", Kind::String, DocNames, true}, }}; // Each text under process/. std::string_view DocFile(std::string_view name) { if (name == "manifest") return "templates/manifest.md"; if (name == "config") return "templates/config.json"; return "prepush.md"; } // A file under the home as the host sees it. In the Flatpak that is // under /.flatpak-info's app-path, with the deployment's `active` link // in place of its commit, so the path outlives an update. fs::path HostPath(const fs::path& path) { if (!Platform::InSandbox()) return path; const std::string info = Platform::ReadFile(Platform::Paths().flatpakInfo).value_or(""); for (const std::string& line : Dyn::Split(info, "\n")) { if (!line.starts_with("app-path=")) continue; const fs::path files(line.substr(9)); std::error_code error; const fs::path inside = fs::relative(path, "/app", error); if (error || inside.empty() || inside.string().starts_with("..")) return path; return files.parent_path().parent_path() / "active" / files.filename() / inside; } return path; } std::int32_t DocCommand(std::span args) { constexpr std::string_view Prog = "crafter-review doc"; const Parsed parsed = ParseOptions(args, DocOptions, DocPositionals); if (parsed.help) { std::print("{}", Help(Prog, "print a text the pipeline ships, or with --path where it is", DocOptions, DocPositionals)); return 0; } if (!parsed.error.empty()) return UsageError(UsageLine(Prog, DocOptions, DocPositionals), Prog, parsed.error); const fs::path file = Home() / "process" / DocFile(parsed.positionals.front()); if (parsed.Flag("path")) { std::println("{}", HostPath(file).string()); return 0; } const std::optional text = Platform::ReadFile(file); if (!text) { std::println(std::cerr, "doc: cannot read {}", file.string()); return 1; } std::print("{}", *text); return 0; } // ---- browser install: the Chromium the MCP server drives ---------------------------- constexpr std::array BrowserActions = {"install"}; constexpr std::array BrowserPositionals = {{ {"action", false, "", "install: the Chromium build the pinned Playwright MCP server drives, where it looks for it", Kind::String, BrowserActions, true}, }}; // Where Playwright keeps its browsers on Linux: PLAYWRIGHT_BROWSERS_PATH, // else $XDG_CACHE_HOME/ms-playwright, else ~/.cache/ms-playwright. In // the Flatpak XDG_CACHE_HOME is the host's again (Platform::Init). fs::path PlaywrightBrowsers() { const std::string set = Platform::GetEnv("PLAYWRIGHT_BROWSERS_PATH").value_or(""); if (!set.empty() && set != "0") return set; const std::string cache = Platform::GetEnv("XDG_CACHE_HOME").value_or(""); return fs::path(cache.empty() ? std::format("{}/.cache", Platform::Home()) : cache) / "ms-playwright"; } std::int32_t BrowserCommand(std::span args) { constexpr std::string_view Prog = "crafter-review browser"; const Parsed parsed = ParseOptions(args, {}, BrowserPositionals); if (parsed.help) { std::print("{}", Help(Prog, "the browser a lens with the browser environment drives", {}, BrowserPositionals)); return 0; } if (!parsed.error.empty()) return UsageError(UsageLine(Prog, {}, BrowserPositionals), Prog, parsed.error); const std::vector argv = {"npx", "-y", std::format("playwright@{}", Mcp::PlaywrightVersion), "install", "chromium"}; std::println("browser: {} (the Playwright of @playwright/mcp@{})", Dyn::Join(argv, " "), Mcp::PlaywrightMcpVersion); std::fflush(stdout); std::optional code; try { code = Process::Wait(Process::Start({.argv = argv}).pid); } catch (const std::exception& e) { std::println(std::cerr, "browser: {}", e.what()); return 1; } if (code.value_or(1) != 0) { std::println(std::cerr, "browser: the install failed (exit {})", code.value_or(1)); return 1; } const fs::path dir = PlaywrightBrowsers() / std::format("chromium-{}", Mcp::ChromiumRevision); std::error_code error; if (!fs::is_directory(dir, error)) { std::println(std::cerr, "browser: installed, but {} is not there - the pinned Playwright and Chromium revision disagree", dir.string()); return 1; } std::println("browser: chromium-{} in {}", Mcp::ChromiumRevision, dir.string()); return 0; } // ---- init: a repository set up for the gates --------------------------------------------- constexpr std::array InitOptions = {{ {"--force", false, "", "replace a SKILL.md, the .gitignore beside it or a link init did not make, and run from your home directory"}, }}; // After the skill's frontmatter: what tells a later `init` the file is // its own to refresh. constexpr std::string_view InitMarker = "\n", InitMarker, Version()); if (skill.starts_with("---\n")) { const std::size_t end = skill.find("\n---\n", 3); if (end != std::string::npos) return std::format("{}{}{}", skill.substr(0, end + 5), marker, skill.substr(end + 5)); } return std::format("{}{}", marker, skill); } // The first line of the .gitignore init writes beside the skill it // owns: what tells a later `init` that file is its own. constexpr std::string_view IgnoreMarker = "# written by crafter-review init"; std::int32_t InitCommand(std::span args) { constexpr std::string_view Prog = "crafter-review init"; const Parsed parsed = ParseOptions(args, InitOptions); if (parsed.help) { std::print("{}", Help(Prog, "set this repository up for the pre-push gates: .review-gates/config.json from the template, the artifact dirs ignored, the prepush skill with the .gitignore that keeps it out of git", InitOptions)); return 0; } if (!parsed.error.empty()) return UsageError(UsageLine(Prog, InitOptions), Prog, parsed.error); const bool force = parsed.Flag("force"); auto refuse = [](const std::string& message) { std::println(std::cerr, "init: {}", message); return 3; }; // A directory the Flatpak cannot see becomes $HOME inside it, PWD // included: the toplevel check and the home check catch that. const fs::path cwd = fs::current_path(); const Process::Captured top = Process::Run({"git", "rev-parse", "--show-toplevel"}); if (top.returnCode != 0) return refuse(std::format("{} is not in a git checkout: run init from the repository's toplevel", cwd.string())); std::error_code error; const fs::path toplevel = Dyn::Strip(top.out); if (!fs::equivalent(toplevel, cwd, error)) return refuse(std::format("run it from the repository's toplevel, {}, not {}", toplevel.string(), cwd.string())); if (!force && fs::equivalent(cwd, Platform::Home(), error)) { return refuse(std::format("{} is your home directory, which is where the Flatpak lands in a directory it cannot see: cd into the repository (--force if it is the repository)", cwd.string())); } // The skill's place. The old setup linked .claude/skills/prepush // into the review-gates submodule: the link goes, never written // through, which would change the submodule's tracked file and // fail the next freeze on a dirty tree. Any other link - the // directory, SKILL.md or the .gitignore init writes beside it, // dangling or not - stays unless --force, and nothing is written // through it: checked before anything is written. const fs::path skillDir = cwd / ".claude" / "skills" / "prepush"; const fs::path skill = skillDir / "SKILL.md"; const fs::path skillIgnore = skillDir / ".gitignore"; std::vector> links; for (const fs::path& link : {skillDir, skill, skillIgnore}) { if (!fs::is_symlink(fs::symlink_status(link, error))) continue; const fs::path target = fs::read_symlink(link, error); // The old setup linked the skill's directory or its SKILL.md - // never the .gitignore - into the review-gates submodule at the // toplevel: a link that resolves under it, not one that merely // names it (the pipeline's own .review-gates/ does too). const fs::path resolved = (link.parent_path() / target).lexically_normal(); const fs::path submodule = (cwd / "review-gates").lexically_normal(); const bool oldSetup = link != skillIgnore && std::mismatch(submodule.begin(), submodule.end(), resolved.begin(), resolved.end()).first == submodule.end(); if (!force && !oldSetup) { return refuse(std::format("{} is a link to {}, not into the review-gates submodule: --force replaces it", link.lexically_relative(cwd).string(), target.string())); } links.emplace_back(link, target); } std::println("init: {}", cwd.string()); // The links go first, before anything is written: one init cannot // remove refuses, and nothing is written through it. for (const auto& [link, target] : links) { fs::remove(link, error); if (error) return refuse(std::format("cannot remove the link {}: {}", link.lexically_relative(cwd).string(), error.message())); std::println(" removed the link {} -> {}", link.lexically_relative(cwd).string(), target.string()); } // The config, from the template, only when there is none. Written // fresh, not copied: the Flatpak's files are read-only. const fs::path config = cwd / ".review-gates" / "config.json"; if (fs::exists(config, error)) { std::println(" kept .review-gates/config.json"); } else { fs::create_directories(config.parent_path()); std::ofstream(config, std::ios::binary) << Platform::ReadFile(Home() / "process" / "templates" / "config.json").value_or("{}\n"); std::println(" wrote .review-gates/config.json (the template: the three kinds as modes with every lens of their kind - trim each to what this repository runs, set base, then what the lenses' environments need)"); } // The artifacts and the worktrees stay out of git. const fs::path ignore = cwd / ".gitignore"; const std::string ignored = Platform::ReadFile(ignore).value_or(""); std::vector present; for (const std::string& line : Dyn::Split(ignored, "\n")) present.push_back(Dyn::Strip(Dyn::Strip(line), "/")); std::vector added; for (const std::string_view entry : {std::string_view(".review-gates/push/"), std::string_view(".review-gates/trees/")}) { if (!std::ranges::contains(present, Dyn::Strip(entry, "/")) && !std::ranges::contains(present, std::string(".review-gates"))) added.emplace_back(entry); } if (added.empty()) { std::println(" kept .gitignore"); } else { std::string text = ignored; if (!text.empty() && !text.ends_with('\n')) text += '\n'; for (const std::string& entry : added) text += std::format("{}\n", entry); std::ofstream(ignore, std::ios::binary) << text; std::println(" added {} to .gitignore", Dyn::Join(added, " and ")); } // The skill. fs::create_directories(skillDir); const std::optional shipped = Platform::ReadFile(Home() / "process" / "skill" / "prepush" / "SKILL.md"); if (!shipped) return refuse(std::format("cannot read the skill under {}", Home().string())); const std::optional existing = Platform::ReadFile(skill); const bool owned = !existing || existing->contains(InitMarker) || force; if (!owned) { std::println(" kept .claude/skills/prepush/SKILL.md (init did not write it: --force replaces it)"); } else { std::ofstream(skill, std::ios::binary) << WithMarker(*shipped); std::println(" {} .claude/skills/prepush/SKILL.md", existing ? "refreshed" : "wrote "); } // The skill init owns stays out of git (Jorijn, 2026-11-01: every // consumer leaves it untracked, one init per checkout; generated // and refreshed by every init, a tracked copy lags the installed // binary). Its directory ignores itself, as a generated directory // does - Python's venv, pytest's cache: a .gitignore of `*` beside // the skill, that file included, on every checkout that runs // init, whatever the repository's own rules or one machine's say, // and nothing of the repository's is edited for it. A tracked // copy init wrote gets the step that untracks it. A skill the // repository wrote itself is its own and stays visible: the // ignore file an earlier init wrote beside it goes. An ignore // file init did not write is kept, unless --force. const std::optional ignoring = Platform::ReadFile(skillIgnore); const bool initIgnores = ignoring && ignoring->contains(IgnoreMarker); if (owned) { if (ignoring && !initIgnores && !force) { std::println(" kept .claude/skills/prepush/.gitignore (init did not write it: --force replaces it)"); } else { std::ofstream(skillIgnore, std::ios::binary) << std::format("{}: the generated /prepush skill stays out of git; `init` refreshes this file while this line is in it\n*\n", IgnoreMarker); std::println(" {} .claude/skills/prepush/.gitignore (the generated skill ignores itself)", ignoring ? "refreshed" : "wrote "); } if (Process::Run({"git", "-C", cwd.string(), "ls-files", "--error-unmatch", ".claude/skills/prepush/SKILL.md"}).returnCode == 0) { std::println(" note .claude/skills/prepush/SKILL.md is tracked - `git rm --cached .claude/skills/prepush/SKILL.md` untracks it (the skill is generated; init refreshes it)"); } } else if (initIgnores) { fs::remove(skillIgnore, error); std::println(" removed .claude/skills/prepush/.gitignore (an earlier init's: the skill is the repository's own)"); } // What comes next. const Config::Loaded loaded = Config::Load(cwd); const std::vector off = Config::LensesOff(loaded.config); const Json environments = Config::Environments(loaded.config); bool browser = false; for (auto it = environments.begin(); it != environments.end(); ++it) { const Plan::Environment* environment = Plan::FindEnvironment(Dyn::Str(it.value())); if (!std::ranges::contains(off, it.key()) && environment && !environment->mcp.empty()) browser = true; } std::println("\nnext:\n {} config - until it reports no problem", Invocation()); // Only a config that runs no lens has a lens step left: the // template init wrote says to trim itself, on its own line, and // a kept config that lists lenses is the repository's own. if (off.size() == Plan::Lenses.size()) std::println(" list the lenses each mode runs in .review-gates/config.json (modes..lenses) - every lens is off until a mode lists it"); if (browser) std::println(" {} browser install - a lens drives the application in Chromium", Invocation()); return 0; } } 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") { const Parsed parsed = ParseOptions(after, {}); if (parsed.help) { std::print("{}", Help("crafter-review config", "print the merged consumer config and its problems", {})); return 0; } if (!parsed.error.empty()) return UsageError(Usage, "crafter-review", parsed.error); 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); if (command == "seats") return SeatsCommand(after); if (command == "stop") return StopCommand(after); if (command == "doc") return DocCommand(after); if (command == "browser") return BrowserCommand(after); if (command == "init") return InitCommand(after); } 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; } catch (const std::exception& error) { // Anything else that escapes a command, as a traceback would. std::println(std::cerr, "crafter-review: {}", error.what()); return 1; } return UsageError(Usage, "crafter-review", std::format("argument cmd: invalid choice: '{}'", command)); } }