Crafter.Review/implementations/Crafter.Review-Driver.cpp
Jorijn van der Graaf ddf32a8c02
fix(init): the skill it writes ignores itself, as a generated directory
A repository that ignores .claude wholesale, as Crafter.Build does,
never sees in git the prepush skill init writes, and the question was
whether it should: it should not. The skill is generated
from the shipped text with init's marker and refreshed by every init, a
tracked copy lags the installed binary whenever the shipped skill
changes, and every refresh would be a diff in the consumer's own ranges.
Jorijn's ruling: every consumer leaves it untracked, one init per
checkout.

When init owns the skill - wrote or refreshed it - it writes a
.gitignore of `*` beside it, as Python's venv and pytest's cache do for
theirs: the directory ignores itself, 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 copy
init wrote that the repository tracks gets the step that untracks it, in
the output and in the README's migration. A skill the repository wrote
itself is kept and not ignored - the ignore file an earlier init wrote
beside it goes - and an ignore file init did not write is kept unless
--force; a link there, dangling or not, is refused like the skill's
other links unless --force, which replaces it (the old setup's exemption
- a link that resolves into the review-gates submodule at the toplevel,
not one that merely names it - is the skill directory's and SKILL.md's
alone), and nothing is written through it: the links go before anything
is written, and one init cannot remove refuses. The procedure's log
records the ruling.
2026-11-02 00:31:47 +01:00

1048 lines
64 KiB
C++

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <stdio.h>
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<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;
std::vector<std::string> positionals;
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);
}
};
// 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<std::string> names(positional.choices.begin(), positional.choices.end());
return std::format("{{{}}}", Dyn::Join(names, ","));
}
std::string UsageLine(std::string_view prog, std::span<const OptionSpec> specs, std::span<const OptionSpec> 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<const OptionSpec> specs, std::span<const OptionSpec> 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<const std::string_view> args, std::span<const OptionSpec> specs, std::span<const OptionSpec> positionals = {}) {
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 == "--") {
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<std::string> 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<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);
}
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<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, 2> Harnesses = {"claude", "opencode"};
constexpr std::array<std::string_view, 2> PersonaBlocks = {"pruned", "all"};
const std::vector<OptionSpec>& 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<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", "a mode of the config (modes.<name>: 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/<slug>/, 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<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");
const std::optional<Config::Mode> 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.<name> 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<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));
}
}
// 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<std::string>& 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<std::string> 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<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");
a.reads = Plan::ReadsFor(a.kind);
if (const std::optional<std::string> 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<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();
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<OptionSpec, 5> 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::int64_t>(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<std::int64_t>(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<Json> ProbeAll(const std::vector<SeatJob>& todo, const Seats::ProbeOptions& base) {
std::vector<Json> out(todo.size());
std::atomic<std::size_t> next = 0;
std::mutex lock;
std::exception_ptr failed;
std::vector<std::thread> workers;
for (std::size_t w = 0; w < std::min<std::size_t>(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<const std::string_view> 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<std::string> 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<fs::path> envPath = seatsFile.path;
const std::map<std::string, std::string> process = Platform::Environment();
const SeatPool::Settings fileSettings = SeatPool::SettingsOf(seatsFile);
const std::map<std::string, std::string>& 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<std::string, std::string> env = SeatPool::WithFileTokens(process, seatsFile);
std::vector<SeatJob> 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<std::pair<std::int32_t, std::string>> 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<Json> 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<Json> first = ProbeAll(jobs, fable);
std::vector<SeatJob> again;
for (std::size_t i = 0; i < jobs.size(); ++i) {
if (Seats::NeedsHaiku(first[i])) again.push_back(jobs[i]);
}
std::map<std::int64_t, Json> standIns;
const std::vector<Json> got = again.empty() ? std::vector<Json>() : 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<std::int64_t>());
seats.push_back(Seats::MergeFableProbe(found != standIns.end() ? found->second : s, s));
}
}
const Json apiConfig = SeatPool::ApiConfig(settings);
std::vector<Json> apiSeats;
if (!apiJobs.empty()) {
std::vector<SeatJob> 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<std::int64_t>()));
}
// 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<std::int32_t, std::string> keys;
for (const SeatJob& j : jobs) keys[j.seat] = SeatPool::SeatKey(j.local ? "local" : "oauth", j.token);
std::map<std::int32_t, std::string> 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<std::string> ids;
for (const Json& s : seats) ids.push_back(s.at("seat") == 0 ? std::string("local") : std::to_string(s.at("seat").get<std::int64_t>()));
for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get<std::int64_t>()));
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 <hash8>` or `{} stop <its scratch dir>`", Invocation(), Invocation());
}
return 0;
}
// ---- stop: the file a running driver looks for every 2 s ------------------------
constexpr std::array<OptionSpec, 1> StopOptions = {{
{"--hash", true, "HASH8", "every live run of this artifact hash on the board (its first 8 characters)"},
}};
constexpr std::array<OptionSpec, 1> StopPositionals = {{
{"scratch", false, "", "the run's scratch dir (the last word of its gate-0 log)"},
}};
std::int32_t StopCommand(std::span<const std::string_view> 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<std::string> 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<std::pair<std::string, fs::path>> 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<std::string_view, 3> DocNames = {"prepush", "manifest", "config"};
constexpr std::array<OptionSpec, 1> 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<OptionSpec, 1> 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<const std::string_view> 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<std::string> 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<std::string_view, 1> BrowserActions = {"install"};
constexpr std::array<OptionSpec, 1> 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<const std::string_view> 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<std::string> 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<std::int32_t> 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<OptionSpec, 1> 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 = "<!-- written by crafter-review init";
std::string WithMarker(const std::string& skill) {
const std::string marker = std::format("{} {}: `init` refreshes this file while this line is in it -->\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<const std::string_view> 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<std::pair<fs::path, fs::path>> 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<std::string> present;
for (const std::string& line : Dyn::Split(ignored, "\n")) present.push_back(Dyn::Strip(Dyn::Strip(line), "/"));
std::vector<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string> 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.<mode>.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<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);
// 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");
}
} 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<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") {
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));
}
}