Crafter.Review/implementations/Crafter.Review-Driver.cpp

467 lines
26 KiB
C++
Raw Normal View History

// 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;
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
import :Inventory;
import :Config;
import :Freeze;
import :PgWire;
import :Totp;
import :Plan;
import :OpenCode;
import :Run;
import std;
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
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;
}
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
// 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);
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
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<const std::string_view> choices = {};
bool required = false;
// Left out of --help.
bool hidden = false;
};
struct Parsed {
std::map<std::string, std::string, std::less<>> values;
std::set<std::string, std::less<>> flags;
bool help = false;
std::string error;
std::optional<std::string> 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<const OptionSpec> 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<const OptionSpec> 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<const std::string_view> args, std::span<const OptionSpec> specs) {
Parsed parsed;
std::vector<std::string> 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<const OptionSpec*> 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<std::string> 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<std::string> 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<std::string> 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<Json> LoadConfig(const std::optional<std::string>& 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<std::string>());
}
if (!loaded.problems.empty()) {
Die(std::format("config: {}", Dyn::Join(loaded.problems, "; ")));
return std::nullopt;
}
return std::move(loaded.config);
}
constexpr std::array<OptionSpec, 9> FreezeOptions = {{
{"--repo", true, "REPO", "the repository (toplevel of a checkout; default: the working directory)"},
{"--slug", true, "SLUG", "the artifact dir name: .review-gates/push/<slug>/"},
{"--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<std::string_view, 1> Modes = {"commits"};
constexpr std::array<std::string_view, 2> Harnesses = {"claude", "opencode"};
const std::vector<OptionSpec>& 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<std::string> 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<OptionSpec> specs = {
{"--mode", true, "MODE", "", Kind::String, Modes, false, true},
{"--artifact", true, "ARTIFACT", "the frozen artifact dir (.review-gates/push/<slug>/)", 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<const std::string_view> args, const std::optional<std::string>& configPath) {
constexpr std::string_view Prog = "crafter-review run";
const std::vector<OptionSpec>& 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<Json> 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<std::string> units = parsed.Value("units"); units && !units->empty()) {
// A trailing comma names no unit.
a.units = std::set<std::string>();
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<std::string> 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<std::string> 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>(std::string(OpenCode::DefaultVariant)) : std::nullopt;
a.opencodeBin = parsed.Value("opencode-bin");
if (const std::optional<std::string> 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<Plan::ModelTier>& 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<std::string> 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<OptionSpec, 1> 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<const std::string_view> args, const std::optional<std::string>& 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<Json> config = LoadConfig(configPath);
if (!config) return 3;
const std::optional<std::string> artifact = parsed.Value("artifact");
try {
Run::SlotsPlan(*config, fs::current_path(), artifact ? std::optional<fs::path>(*artifact) : std::nullopt);
} catch (const Inventory::InvariantError& e) {
return Die(e.what());
}
return 0;
}
std::int32_t FreezeCommand(std::span<const std::string_view> args, const std::optional<std::string>& 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<Json> 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<const std::string_view> args) {
const std::span<const std::string_view> 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);
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
// The options before the command: --config FILE or --config=FILE, and
// any unique prefix of a long option.
std::optional<std::string> 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");
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
}
} 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));
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
}
++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");
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
const std::string_view command = rest[at];
const std::span<const std::string_view> 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;
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
}
return UsageError(Usage, "crafter-review", std::format("argument cmd: invalid choice: '{}'", command));
}
}