Crafter.Review/implementations/Crafter.Review-Config.cpp

522 lines
28 KiB
C++
Raw Normal View History

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
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Review:Config_impl;
import :Config;
import :Json;
import :Text;
import :Core;
import :Plan;
import :Tools;
import :PgWire;
import :Process;
import :Platform;
feat(config): lens sections - a repository's own text appended to a shipped charter A lens has one charter, the one this pipeline ships; what a repository knows that is not general to the lens - its platform and layers, the record it keeps its decisions in, what its users already have, the calibration entries that are its own - goes in a file the config names for the lens (lenses.<name>.section), and Charter appends it after one blank line. The charter sha in every reuse key covers the combined text. The config refuses a path outside the repository - absolute, or climbing out with `..` - by the check slots.dir had, now one both settings share; a file missing or one the run could not use (a dangling link, unreadable, not UTF-8) is a problem too, checked through the reader Charter uses (Prompt::ReadSection), so a section is never dropped in silence, and a name that is no lens is the unknown key it always was. Nothing lists a directory, so what lies beside the files is no concern of the config's: a directory listed would have to tolerate or refuse every foreign entry. The run logs which charters got one. The better-solution charter gains a consumer's general entry (2026-10-31: a mechanism reworked a second time in one submission is the signal to derive the fix at every layer the data crosses). The procedure names the section wherever it names the charter: among the config's keys, in the lens inputs, in the reuse rule (an edit to either re-runs the lens) and in the feedback loop, which routes a repository's own lesson to its section and a general one to the shipped charter.
2026-11-01 01:30:22 +01:00
import :Prompt;
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 std;
namespace fs = std::filesystem;
namespace Crafter::Review::Config {
namespace {
// Keys a consumer's config may still carry, with what replaced them.
struct Retired {
std::string_view section;
std::string_view key;
std::string why;
};
std::vector<Retired> RetiredKeys() {
return {{
"functional", "concurrency",
std::format("passes run as many at once as free memory allows, each at its environment's measured cost, and a pass that drives "
"the application holds a stack of its own (2026-10-22); for more of them at once bring up more stacks "
"(slots.*, `{} slots`)", Invocation()),
}};
}
std::string EnvironmentNames(std::string_view separator) {
std::vector<std::string> names;
for (const Plan::Environment& environment : Plan::Environments) names.emplace_back(environment.name);
return Dyn::Join(names, separator);
}
Json EnvironmentTable() {
Json table = Json::object();
for (const Plan::Environment& environment : Plan::Environments) table[std::string(environment.name)] = true;
return table;
}
bool AllStrings(const Json& list) {
return list.is_array() && std::ranges::all_of(list, [](const Json& item) { return item.is_string(); });
}
// ^(Bash\([^()]+:\*\)|mcp__[A-Za-z0-9_-]+(__[A-Za-z0-9_-]+)?)$; `$`
// also matches before a final newline.
bool IsToolEntry(std::string_view tool) {
std::string_view body = tool;
if (body.ends_with('\n')) body.remove_suffix(1);
if (body.starts_with("Bash(") && body.ends_with(":*)") && body.size() > 8) {
const std::string_view prefix = body.substr(5, body.size() - 8);
return !prefix.contains('(') && !prefix.contains(')');
}
if (body.starts_with("mcp__") && body.size() > 5) {
return std::ranges::all_of(body.substr(5), [](char c) {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-';
});
}
return false;
}
// re.match(r'^https?://', text)
bool IsHttpUrl(std::string_view text) {
return text.starts_with("http://") || text.starts_with("https://");
}
// An integer or a float, not a bool.
bool IsRealNumber(const Json& value) {
return value.is_number();
}
bool Positive(const Json& value) {
return value.is_number_float() ? value.get<double>() > 0.0 : (value.is_number_unsigned() ? value.get<std::uint64_t>() > 0 : value.get<std::int64_t>() > 0);
}
std::vector<std::string> Merge(Json& destination, const Json& source, const std::string& path) {
std::vector<std::string> problems;
for (auto it = source.begin(); it != source.end(); ++it) {
const std::string& key = it.key();
if (!Dyn::Has(destination, key)) {
problems.push_back(std::format("unknown key {}{}", path, key));
continue;
}
Json& target = destination[key];
if (target.is_object() && it.value().is_object()) {
std::ranges::move(Merge(target, it.value(), std::format("{}{}.", path, key)), std::back_inserter(problems));
} else {
target = it.value();
}
}
return problems;
}
// The config's entry for the lens, else its default one.
const Json& LensEntry(const Json& config, std::string_view lensFile) {
static const Json defaults = Defaults()["lenses"];
const Json& lenses = Dyn::Get(config, "lenses");
const std::string name = Plan::LensName(lensFile);
const Json* entry = lenses.is_object() ? &Dyn::Get(lenses, name) : nullptr;
return entry && entry->is_object() ? *entry : defaults.at(name);
}
// A config or a slot file as JSON: the value, or the text that goes
// into the "not JSON (...)" problem.
std::variant<Json, std::string> LoadJson(const fs::path& path) {
try {
return Dyn::LoadJsonFile(path);
} catch (const std::exception& error) {
return std::string(error.what());
}
}
}
Json Defaults() {
Json lenses = Json::object();
// Every lens is off until the repository's config turns it on
feat(config): lens sections - a repository's own text appended to a shipped charter A lens has one charter, the one this pipeline ships; what a repository knows that is not general to the lens - its platform and layers, the record it keeps its decisions in, what its users already have, the calibration entries that are its own - goes in a file the config names for the lens (lenses.<name>.section), and Charter appends it after one blank line. The charter sha in every reuse key covers the combined text. The config refuses a path outside the repository - absolute, or climbing out with `..` - by the check slots.dir had, now one both settings share; a file missing or one the run could not use (a dangling link, unreadable, not UTF-8) is a problem too, checked through the reader Charter uses (Prompt::ReadSection), so a section is never dropped in silence, and a name that is no lens is the unknown key it always was. Nothing lists a directory, so what lies beside the files is no concern of the config's: a directory listed would have to tolerate or refuse every foreign entry. The run logs which charters got one. The better-solution charter gains a consumer's general entry (2026-10-31: a mechanism reworked a second time in one submission is the signal to derive the fix at every layer the data crosses). The procedure names the section wherever it names the charter: among the config's keys, in the lens inputs, in the reuse rule (an edit to either re-runs the lens) and in the feedback loop, which routes a repository's own lesson to its section and a general one to the shipped charter.
2026-11-01 01:30:22 +01:00
// (2026-10-31, Jorijn: "all lenses off by default and opt in only");
// its section is a file of the repository's own appended to its
// shipped charter (null: none).
for (const Plan::Lens& lens : Plan::Lenses) lenses[Plan::LensName(lens.file)] = {{"run", false}, {"environment", lens.env}, {"section", nullptr}};
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 {
// The integration branch a range is measured against (e.g.
// origin/dev): merge-base HEAD <base>.
{"base", nullptr},
// upstream: the push delta @{upstream}..HEAD, the base branch only
// when HEAD tracks nothing; base: always merge-base HEAD
// <base>..HEAD, the whole pull request.
{"rangeFrom", "upstream"},
// Author and committer emails the freeze accepts; null: the
// repository's `git config user.email`.
{"authors", nullptr},
// forbid | allow: Assisted-by / Co-Authored-By trailers in the
// range.
{"aiTrailers", "forbid"},
// An unsigned commit fails the freeze (else a warning).
{"requireSigned", false},
// Tracked paths the local stack rewrites in place while the lenses
// run.
{"allowDirty", Json::array()},
// The commands Gate 0 runs by hand, in order (documentation for the
// skill; the driver runs none of them).
{"gate0", Json::array()},
// Per lens: run (off until true: every lens is opt-in) and
// environment (Plan::Environments).
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
{"lenses", lenses},
// The database under test, for every lens whose environment
// reaches it. The URL reaches a db pass's broker, never a prompt,
// and the dump and the log show it with the password masked.
{"supabase", {{"dbUrl", "postgresql://postgres:postgres@127.0.0.1:54322/postgres"}, {"start", nullptr}, {"stop", nullptr}, {"notes", ""}}},
// Stacks side by side (2026-10-22): a pass that drives an
// application holds one of its own.
{"slots", {{"dir", nullptr}, {"start", nullptr}, {"stop", nullptr}, {"memMb", nullptr}, {"startMb", nullptr}}},
// The application under test, for every lens whose environment
// reaches one.
{"functional", {
{"appUrl", "http://localhost:8080"},
{"readyPath", "/"},
{"apiUrls", Json::array()},
{"start", nullptr},
{"stop", nullptr},
{"login", {{"fixtures", Json::array()}, {"notes", ""}}},
{"exclude", Json::array()},
{"extraTools", Json::array()},
{"mcp", {{"command", "npx"}, {"args", nullptr}}},
{"notes", ""},
}},
};
}
Loaded Load(const fs::path& repo, const fs::path& path) {
Loaded loaded{Defaults(), {}};
Json& config = loaded.config;
const fs::path p = path.empty() ? repo / ConfigFile : path;
std::vector<std::string> warnings;
if (fs::exists(p)) {
std::variant<Json, std::string> read = LoadJson(p);
if (std::holds_alternative<std::string>(read)) {
loaded.problems.push_back(std::format("{}: not JSON ({})", p.string(), std::get<std::string>(read)));
return loaded;
}
Json data = std::move(std::get<Json>(read));
if (!data.is_object()) {
loaded.problems.push_back(std::format("{}: the top level must be an object", p.string()));
return loaded;
}
for (const Retired& retired : RetiredKeys()) {
const Json& section = Dyn::Get(data, retired.section);
if (section.is_object() && Dyn::Has(section, retired.key)) {
warnings.push_back(std::format("{}.{} is no longer read - remove it from {}: {}", retired.section, retired.key, p.filename().string(), retired.why));
data[std::string(retired.section)].erase(std::string(retired.key));
}
}
loaded.problems = Merge(config, data, "");
} else if (!path.empty()) {
loaded.problems.push_back(std::format("{}: no such file", p.string()));
}
config["_path"] = fs::exists(p) ? Json(p.string()) : Json();
Json warningList = Json::array();
for (const std::string& warning : warnings) warningList.push_back(warning);
config["_warnings"] = std::move(warningList);
std::ranges::move(Validate(config), std::back_inserter(loaded.problems));
feat(config): lens sections - a repository's own text appended to a shipped charter A lens has one charter, the one this pipeline ships; what a repository knows that is not general to the lens - its platform and layers, the record it keeps its decisions in, what its users already have, the calibration entries that are its own - goes in a file the config names for the lens (lenses.<name>.section), and Charter appends it after one blank line. The charter sha in every reuse key covers the combined text. The config refuses a path outside the repository - absolute, or climbing out with `..` - by the check slots.dir had, now one both settings share; a file missing or one the run could not use (a dangling link, unreadable, not UTF-8) is a problem too, checked through the reader Charter uses (Prompt::ReadSection), so a section is never dropped in silence, and a name that is no lens is the unknown key it always was. Nothing lists a directory, so what lies beside the files is no concern of the config's: a directory listed would have to tolerate or refuse every foreign entry. The run logs which charters got one. The better-solution charter gains a consumer's general entry (2026-10-31: a mechanism reworked a second time in one submission is the signal to derive the fix at every layer the data crosses). The procedure names the section wherever it names the charter: among the config's keys, in the lens inputs, in the reuse rule (an edit to either re-runs the lens) and in the feedback loop, which routes a repository's own lesson to its section and a general one to the shipped charter.
2026-11-01 01:30:22 +01:00
std::ranges::move(SectionProblems(config, repo), std::back_inserter(loaded.problems));
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 loaded;
}
feat(config): lens sections - a repository's own text appended to a shipped charter A lens has one charter, the one this pipeline ships; what a repository knows that is not general to the lens - its platform and layers, the record it keeps its decisions in, what its users already have, the calibration entries that are its own - goes in a file the config names for the lens (lenses.<name>.section), and Charter appends it after one blank line. The charter sha in every reuse key covers the combined text. The config refuses a path outside the repository - absolute, or climbing out with `..` - by the check slots.dir had, now one both settings share; a file missing or one the run could not use (a dangling link, unreadable, not UTF-8) is a problem too, checked through the reader Charter uses (Prompt::ReadSection), so a section is never dropped in silence, and a name that is no lens is the unknown key it always was. Nothing lists a directory, so what lies beside the files is no concern of the config's: a directory listed would have to tolerate or refuse every foreign entry. The run logs which charters got one. The better-solution charter gains a consumer's general entry (2026-10-31: a mechanism reworked a second time in one submission is the signal to derive the fix at every layer the data crosses). The procedure names the section wherever it names the charter: among the config's keys, in the lens inputs, in the reuse rule (an edit to either re-runs the lens) and in the feedback loop, which routes a repository's own lesson to its section and a general one to the shipped charter.
2026-11-01 01:30:22 +01:00
namespace {
// A path setting resolved against the repository root (slots.dir,
// lenses.<name>.section): a relative path that stays inside it - not
// blank, no leading `/`, no `..` part (2026-11-01: one check, so the
// settings cannot drift apart).
bool InsideRepository(const Json& value) {
if (!value.is_string()) return false;
const std::string& dir = value.get_ref<const std::string&>();
const std::vector<std::string> parts = Dyn::Split(Dyn::Replace(dir, "\\", "/"), "/");
return !Dyn::Strip(dir).empty() && !dir.starts_with('/') && !std::ranges::contains(parts, std::string(".."));
}
}
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
std::vector<std::string> Validate(const Json& config) {
std::vector<std::string> out;
const Json& trailers = Dyn::At(config, "aiTrailers");
if (trailers != "forbid" && trailers != "allow") out.emplace_back("aiTrailers must be 'forbid' or 'allow'");
const Json& rangeFrom = Dyn::At(config, "rangeFrom");
if (rangeFrom != "upstream" && rangeFrom != "base") out.emplace_back("rangeFrom must be 'upstream' or 'base'");
if (rangeFrom == "base" && !Dyn::Truthy(Dyn::At(config, "base"))) out.emplace_back("rangeFrom 'base' needs a base branch");
const Json& authors = Dyn::At(config, "authors");
if (!authors.is_null() && (!authors.is_array() || !std::ranges::all_of(authors, [](const Json& a) {
return a.is_string() && a.get_ref<const std::string&>().contains('@');
}))) {
out.emplace_back("authors must be null or a list of email addresses");
}
for (const std::string_view key : {"allowDirty", "gate0"}) {
if (!AllStrings(Dyn::At(config, key))) out.push_back(std::format("{} must be a list of strings", key));
}
const Json environments = EnvironmentTable();
Json lenses = Dyn::At(config, "lenses");
if (!lenses.is_object()) {
out.push_back(std::format("lenses must be an object: {{<lens name>: {{run: bool, environment: {}}}}}", EnvironmentNames("|")));
lenses = Json::object();
}
for (auto it = lenses.begin(); it != lenses.end(); ++it) {
const Json& entry = it.value();
if (!entry.is_object()) {
out.push_back(std::format("lenses.{} must be an object {{run: bool, environment: {}}}", it.key(), EnvironmentNames("|")));
continue;
}
if (!Dyn::Get(entry, "run").is_boolean()) out.push_back(std::format("lenses.{}.run must be true or false", it.key()));
if (!Dyn::In(Dyn::Get(entry, "environment"), environments)) out.push_back(std::format("lenses.{}.environment must be one of {}", it.key(), EnvironmentNames(", ")));
feat(config): lens sections - a repository's own text appended to a shipped charter A lens has one charter, the one this pipeline ships; what a repository knows that is not general to the lens - its platform and layers, the record it keeps its decisions in, what its users already have, the calibration entries that are its own - goes in a file the config names for the lens (lenses.<name>.section), and Charter appends it after one blank line. The charter sha in every reuse key covers the combined text. The config refuses a path outside the repository - absolute, or climbing out with `..` - by the check slots.dir had, now one both settings share; a file missing or one the run could not use (a dangling link, unreadable, not UTF-8) is a problem too, checked through the reader Charter uses (Prompt::ReadSection), so a section is never dropped in silence, and a name that is no lens is the unknown key it always was. Nothing lists a directory, so what lies beside the files is no concern of the config's: a directory listed would have to tolerate or refuse every foreign entry. The run logs which charters got one. The better-solution charter gains a consumer's general entry (2026-10-31: a mechanism reworked a second time in one submission is the signal to derive the fix at every layer the data crosses). The procedure names the section wherever it names the charter: among the config's keys, in the lens inputs, in the reuse rule (an edit to either re-runs the lens) and in the feedback loop, which routes a repository's own lesson to its section and a general one to the shipped charter.
2026-11-01 01:30:22 +01:00
const Json& section = Dyn::Get(entry, "section");
if (!section.is_null() && !InsideRepository(section)) out.push_back(std::format("lenses.{}.section must be null or a file inside the repository (a relative path)", it.key()));
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
}
auto runs = [](const Json& entry) { return entry.is_object() && Dyn::Get(entry, "run") == true; };
// Every lens is off until the repository turns it on: a config that
// turns on none has nothing to run.
if (std::ranges::none_of(Plan::Lenses, [&](const Plan::Lens& lens) { return runs(Dyn::Get(lenses, Plan::LensName(lens.file))); })) {
std::vector<std::string> names;
for (const Plan::Lens& lens : Plan::Lenses) names.push_back(Plan::LensName(lens.file));
out.push_back(std::format("lenses: no lens is on - every lens is off until lenses.<name>.run = true turns it on for this repository ({})", Dyn::Join(names, ", ")));
}
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
for (const Plan::Lens& lens : Plan::Lenses) {
const Json& entry = Dyn::Get(lenses, Plan::LensName(lens.file));
if (!lens.empirical || !runs(entry)) continue;
const Json& name = Dyn::Get(entry, "environment");
if (!Dyn::In(name, environments)) continue;
if (!Plan::FindEnvironment(name.get_ref<const std::string&>())->app) {
std::vector<std::string> withApp;
for (const Plan::Environment& environment : Plan::Environments) {
if (environment.app) withApp.emplace_back(environment.name);
}
out.push_back(std::format("lenses.{}: an empirical lens needs an environment with an application under test ({}) - or turn it off", Plan::LensName(lens.file),
Dyn::Join(withApp, ", ")));
}
}
const Json& supabase = Dyn::At(config, "supabase");
if (!supabase.is_object()) {
out.emplace_back("supabase must be an object {dbUrl, start, stop, notes}");
} else {
if (const std::optional<std::string> problem = PgWire::CheckUrl(Dyn::Get(supabase, "dbUrl"))) out.push_back(std::format("supabase.dbUrl: {}", *problem));
for (const std::string_view key : {"start", "stop"}) {
const Json& value = Dyn::Get(supabase, key);
if (!value.is_null() && !value.is_string()) out.push_back(std::format("supabase.{} must be null or a command line", key));
}
if (!Dyn::Get(supabase, "notes").is_string()) out.emplace_back("supabase.notes must be text");
}
const Json& functional = Dyn::At(config, "functional");
if (!functional.is_object()) {
out.emplace_back("functional must be an object");
return out;
}
// The application's URL matters only to a lens that drives it.
const bool drivesApp = std::ranges::any_of(lenses, [&](const Json& entry) {
if (!runs(entry)) return false;
const Json& name = Dyn::Get(entry, "environment");
if (name.is_array() || name.is_object()) throw Dyn::Error("TypeError", std::format("unhashable type: '{}'", Dyn::TypeName(name)));
const Plan::Environment* environment = name.is_string() ? Plan::FindEnvironment(name.get_ref<const std::string&>()) : nullptr;
return environment && environment->app;
});
if (drivesApp && !IsHttpUrl(Dyn::StrOr(Dyn::At(functional, "appUrl"), ""))) out.emplace_back("functional.appUrl must be an http(s) URL");
if (!AllStrings(Dyn::At(functional, "exclude"))) out.emplace_back("functional.exclude must be a list of globs");
const Json& extraTools = Dyn::At(functional, "extraTools");
if (!extraTools.is_array() || !std::ranges::all_of(extraTools, [](const Json& x) { return x.is_string() && IsToolEntry(x.get_ref<const std::string&>()); })) {
out.emplace_back("functional.extraTools entries must be Bash(<prefix>:*) or mcp__<server>[__<tool>]");
} else {
std::vector<std::string> dead;
for (const Json& tool : extraTools) {
if (Tools::Denied(tool.get_ref<const std::string&>())) dead.push_back(tool.get<std::string>());
}
if (!dead.empty()) {
out.push_back(std::format("functional.extraTools: {} - denied to every pass (pool.DENY wins over an allowlist); "
"a lens reaches the database through the supabase environment, every call rolled back", Dyn::Join(dead, ", ")));
}
}
const Json& login = Dyn::At(functional, "login");
if (!login.is_object() || !(Dyn::Has(login, "fixtures") ? Dyn::At(login, "fixtures").is_array() : true)) out.emplace_back("functional.login must be {fixtures: [paths], notes: text}");
const Json& slots = Dyn::At(config, "slots");
if (!slots.is_object()) {
out.emplace_back("slots must be an object {dir, start, stop, memMb, startMb}");
} else if (!Dyn::Get(slots, "dir").is_null()) {
feat(config): lens sections - a repository's own text appended to a shipped charter A lens has one charter, the one this pipeline ships; what a repository knows that is not general to the lens - its platform and layers, the record it keeps its decisions in, what its users already have, the calibration entries that are its own - goes in a file the config names for the lens (lenses.<name>.section), and Charter appends it after one blank line. The charter sha in every reuse key covers the combined text. The config refuses a path outside the repository - absolute, or climbing out with `..` - by the check slots.dir had, now one both settings share; a file missing or one the run could not use (a dangling link, unreadable, not UTF-8) is a problem too, checked through the reader Charter uses (Prompt::ReadSection), so a section is never dropped in silence, and a name that is no lens is the unknown key it always was. Nothing lists a directory, so what lies beside the files is no concern of the config's: a directory listed would have to tolerate or refuse every foreign entry. The run logs which charters got one. The better-solution charter gains a consumer's general entry (2026-10-31: a mechanism reworked a second time in one submission is the signal to derive the fix at every layer the data crosses). The procedure names the section wherever it names the charter: among the config's keys, in the lens inputs, in the reuse rule (an edit to either re-runs the lens) and in the feedback loop, which routes a repository's own lesson to its section and a general one to the shipped charter.
2026-11-01 01:30:22 +01:00
if (!InsideRepository(Dyn::At(slots, "dir"))) out.emplace_back("slots.dir must be null or a directory inside the repository (a relative path)");
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
for (const std::string_view key : {"start", "stop"}) {
const Json& value = Dyn::Get(slots, key);
if (!value.is_null() && (!value.is_string() || !value.get_ref<const std::string&>().contains("{n}"))) {
out.push_back(std::format("slots.{} must be null or a command line with {{n}}, the slot number", key));
}
}
const Json& memMb = Dyn::Get(slots, "memMb");
if (!IsRealNumber(memMb) || !Positive(memMb)) {
out.push_back(std::format("slots.memMb must be the measured memory of one slot in MB (a number > 0): `{} slots` sizes the slots by it", Invocation()));
}
const Json& startMb = Dyn::Get(slots, "startMb");
if (!startMb.is_null() && (!IsRealNumber(startMb) || !Positive(startMb))) {
out.emplace_back("slots.startMb must be null or the measured peak of one slot coming up in MB (a number > 0)");
}
}
const Json& args = Dyn::Get(Dyn::At(functional, "mcp"), "args");
if (!args.is_null() && !AllStrings(args)) out.emplace_back("functional.mcp.args must be null or a list of strings");
// A path found on the host, such as /usr/bin/npx, is not there inside
// the Flatpak, whose /usr is the Sdk's.
const Json& command = Dyn::Get(Dyn::At(functional, "mcp"), "command");
if (command.is_string() && command.get<std::string>().starts_with('/')) {
std::error_code error;
const fs::path file(command.get<std::string>());
const bool executable = fs::is_regular_file(file, error) && (fs::status(file, error).permissions() & (fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec)) != fs::perms::none;
if (!executable) {
out.emplace_back(std::format("functional.mcp.command {} is no program here{}", Dyn::Repr(command.get<std::string>()),
Platform::InSandbox() ? " (inside the Flatpak /usr is the Sdk's: name it `npx`, the bundled one)" : ""));
}
}
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 out;
}
Json Redacted(const Json& config) {
Json copy = Json::object();
for (auto it = config.begin(); it != config.end(); ++it) {
if (!it.key().starts_with('_')) copy[it.key()] = it.value();
}
if (Dyn::Get(copy, "supabase").is_object() && Dyn::Get(copy["supabase"], "dbUrl").is_string()) {
copy["supabase"]["dbUrl"] = PgWire::Redact(copy["supabase"]["dbUrl"].get<std::string>());
}
return copy;
}
std::string GitUserEmail(const fs::path& repo) {
const Process::Captured run = Process::Run({"git", "-C", repo.string(), "config", "user.email"});
return run.returnCode == 0 ? Dyn::Strip(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out))) : std::string();
}
std::vector<std::string> Authors(const Json& config, const fs::path& repo) {
const Json& authors = Dyn::Get(config, "authors");
if (Dyn::Truthy(authors)) {
std::vector<std::string> out;
for (const Json& author : authors) out.push_back(Dyn::Str(author));
return out;
}
const std::string email = GitUserEmail(repo);
if (email.empty()) return {};
return {email};
}
feat(config): lens sections - a repository's own text appended to a shipped charter A lens has one charter, the one this pipeline ships; what a repository knows that is not general to the lens - its platform and layers, the record it keeps its decisions in, what its users already have, the calibration entries that are its own - goes in a file the config names for the lens (lenses.<name>.section), and Charter appends it after one blank line. The charter sha in every reuse key covers the combined text. The config refuses a path outside the repository - absolute, or climbing out with `..` - by the check slots.dir had, now one both settings share; a file missing or one the run could not use (a dangling link, unreadable, not UTF-8) is a problem too, checked through the reader Charter uses (Prompt::ReadSection), so a section is never dropped in silence, and a name that is no lens is the unknown key it always was. Nothing lists a directory, so what lies beside the files is no concern of the config's: a directory listed would have to tolerate or refuse every foreign entry. The run logs which charters got one. The better-solution charter gains a consumer's general entry (2026-10-31: a mechanism reworked a second time in one submission is the signal to derive the fix at every layer the data crosses). The procedure names the section wherever it names the charter: among the config's keys, in the lens inputs, in the reuse rule (an edit to either re-runs the lens) and in the feedback loop, which routes a repository's own lesson to its section and a general one to the shipped charter.
2026-11-01 01:30:22 +01:00
std::map<std::string, fs::path> LensSectionsOf(const Json& config, const fs::path& repo) {
// Only a file inside the repository, by the rule Validate refuses the
// rest by: fs::path's `/` would let an absolute one replace the root.
std::map<std::string, fs::path> out;
for (const Plan::Lens& lens : Plan::Lenses) {
const Json& section = Dyn::Get(LensEntry(config, lens.file), "section");
if (InsideRepository(section)) out.emplace(std::string(lens.file), repo / section.get<std::string>());
}
return out;
}
std::vector<std::string> SectionProblems(const Json& config, const fs::path& repo) {
// Through the reader the run itself uses (Prompt::ReadSection): a
// section the run could not use is a problem here, never a section
// left out in silence or an error in the run's Gate 0.
std::vector<std::string> out;
for (const auto& [lensFile, file] : LensSectionsOf(config, repo)) {
const std::string where = std::format("lenses.{}.section: {}", Plan::LensName(lensFile), Dyn::Str(Dyn::Get(LensEntry(config, lensFile), "section")));
const Prompt::Section section = Prompt::ReadSection(file);
if (!section.problem.empty()) out.push_back(std::format("{} {}", where, section.problem));
else if (!section.present) out.push_back(std::format("{} does not exist", where));
}
return out;
}
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
std::vector<std::string> LensesOff(const Json& config) {
std::vector<std::string> out;
for (const Plan::Lens& lens : Plan::Lenses) {
if (Dyn::Get(LensEntry(config, lens.file), "run") == false) out.emplace_back(lens.file);
}
return out;
}
Json Environments(const Json& config) {
Json out = Json::object();
for (const Plan::Lens& lens : Plan::Lenses) {
const Json& entry = LensEntry(config, lens.file);
out[std::string(lens.file)] = Dyn::Has(entry, "environment") ? Dyn::At(entry, "environment") : Json("none");
}
return out;
}
Slots SlotStacks(const Json& config, const fs::path& repo) {
Slots slots;
const Json& dir = Dyn::Get(Dyn::Or(Dyn::Get(config, "slots"), Dyn::EmptyDict()), "dir");
if (!Dyn::Truthy(dir) || !fs::is_directory(repo / dir.get<std::string>())) return slots;
const std::string d = dir.get<std::string>();
std::vector<std::string> names;
for (const fs::directory_entry& entry : fs::directory_iterator(repo / d)) names.push_back(entry.path().filename().string());
std::ranges::stable_sort(names, {}, [](const std::string& name) { return Dyn::Natural(name); });
for (const std::string& n : names) {
const fs::path file = repo / d / n / SlotFile;
if (!fs::is_regular_file(file)) continue;
const std::string rel = Dyn::JoinPath(Dyn::JoinPath(d, n), SlotFile);
std::variant<Json, std::string> read = LoadJson(file);
if (std::holds_alternative<std::string>(read)) {
slots.problems.push_back(std::format("{}: not JSON ({})", rel, std::get<std::string>(read)));
continue;
}
const Json& s = std::get<Json>(read);
if (!s.is_object() || !IsHttpUrl(Dyn::StrOr(Dyn::Get(s, "appUrl"), ""))) {
slots.problems.push_back(std::format("{}: no appUrl", rel));
continue;
}
const Json& api = Dyn::Or(Dyn::Get(s, "apiUrls"), Dyn::EmptyList());
if (!AllStrings(api)) {
slots.problems.push_back(std::format("{}: apiUrls must be a list of URLs", rel));
continue;
}
const Json& db = Dyn::Get(s, "dbUrl");
if (!db.is_null()) {
if (const std::optional<std::string> problem = PgWire::CheckUrl(db)) {
slots.problems.push_back(std::format("{}: dbUrl: {}", rel, *problem));
continue;
}
}
const Json& login = Dyn::Get(s, "login");
if (!login.is_null() && (!login.is_string() || !fs::exists(repo / login.get<std::string>()))) {
slots.problems.push_back(std::format("{}: login {} is not a file", rel, Dyn::ReprValue(login)));
continue;
}
slots.stacks.push_back({
{"id", std::format("slot {}", n)},
{"appUrl", Dyn::RStrip(Dyn::At(s, "appUrl").get<std::string>(), "/")},
{"apiUrls", api},
{"dbUrl", db},
{"login", login},
{"slot", true},
});
}
return slots;
}
std::vector<std::string> SlotCommands(const Json& config, std::string_view which, std::span<const std::int64_t> slots) {
const Json& command = Dyn::Get(Dyn::Or(Dyn::Get(config, "slots"), Dyn::EmptyDict()), which);
if (!Dyn::Truthy(command)) return {};
std::vector<std::string> out;
for (const std::int64_t n : slots) out.push_back(Dyn::Replace(command.get<std::string>(), "{n}", std::to_string(n)));
return out;
}
std::optional<std::string> LoginFixture(const Json& config, const fs::path& repo) {
const Json& login = Dyn::Or(Dyn::Get(Dyn::At(config, "functional"), "login"), Dyn::EmptyDict());
for (const Json& fixture : Dyn::Or(Dyn::Get(login, "fixtures"), Dyn::EmptyList())) {
if (fs::exists(repo / fixture.get<std::string>())) return fixture.get<std::string>();
}
return std::nullopt;
}
Json Example() {
Json example = Defaults();
example["base"] = "origin/main";
example["authors"] = {"you@example.com"};
example["aiTrailers"] = "forbid";
example["gate0"] = {"make check"};
Json& functional = example["functional"];
functional["appUrl"] = "http://localhost:8080";
functional["start"] = "make run";
functional["stop"] = "make stop";
functional["login"] = {{"fixtures", {".local-stack/test-user.json"}}, {"notes", "the fixture holds a TOTP-verified login"}};
functional["exclude"] = {"**/*.test.*", "**/*.spec.*", "docs/**", ".github/**", "*.md"};
return example;
}
std::int32_t Main(const fs::path& repo, const fs::path& path) {
const Loaded loaded = Load(repo, path);
Json shown = Redacted(loaded.config);
// The database password masked, as in the dump.
shown["_path"] = Dyn::Get(loaded.config, "_path");
std::println("{}", DumpFile(shown));
for (const Json& warning : Dyn::Or(Dyn::Get(loaded.config, "_warnings"), Dyn::EmptyList())) std::println("warning: {}", Dyn::Str(warning));
for (const std::string& problem : loaded.problems) std::println("problem: {}", problem);
return loaded.problems.empty() ? 0 : 1;
}
}