The gateway routes every request on its own, so a catalog holds the work of several engines, while the mark apply() wrote was the constant translated-by: libretranslate and attributed all of it to one backend. The catalogs on disk show the drift: 1021537 entries carry that constant and 13 carry ollama, written once the gateway began answering from more than one place.
The constant becomes machine_translation(engine), which names what the gateway reported and writes unnamed where it reported nothing, plus is_machine_translation(), which recognises any of them by prefix so every mark already on disk keeps matching. Outcome carries an engines tuple aligned with its values, merge pads it, and _reask returns the name alongside the text: a successful re-ask records the engine that answered, not the one whose rejection it replaced, because the catalog does not carry that rejected text.
apply() now strips an earlier mark before stamping the current one. clear() removes only refusal prefixes, so an entry re-translated after i18n-prune would otherwise accumulate one mark per engine and the record would be ambiguous again, which is the defect this change exists to remove.
This is the half that makes the attribution true. Recording latency in the decider history is the other half; together they let the router weigh quality against speed per engine, which a catalog claiming one engine everywhere cannot support.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The project licence governs this repository's code and says nothing about the application a role installs, which is where a restriction on commercial use usually sits: a deployment cleared to use Infinito.Nexus commercially is still barred from NLLB-200's CC BY-NC weights.
Each invokable role names its licence in meta/<role>/license.yml with the url it was read from. The rights of a named licence live once in meta/licenses.yml, fifteen fields following the taxonomy choosealicense publishes. AND folds to the strictest combination, OR keeps the most favourable whole licence, and the seven contexts are derived from the rights rather than declared, so a heading cannot contradict the matrix under it.
The generated page renders one matrix per context, a lint refuses a role that declares nothing, and a Playwright spec asserts the page against the deployed docs. 21 of 22 deprecated SPDX ids were resolved against the upstream text; EPSON's remains unread because its AUR page blocks automated access.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Declare `__all__` in every package `__init__.py` whose `PROJECT_ROOT`
is imported by its modules (163 packages), so the constant counts as
exported; list every public name where the package defines more.
- Empty the 15 package `__init__.py` files whose `PROJECT_ROOT` no module
imports.
- Declare the public API of `utils.github.variant.axes` in `__all__`,
re-exports included, instead of silencing them with `noqa: F401`; the
list matches the one `feature/store-i18n` already carries.
- Use `import unittest.mock as mock` in `test_applications.py`, as the
rest of the suite does.
- Spell `_NON_PRINTABLE` in `utils/yaml_bootstrap.py` as the equivalent
BMP-only positive class; it matches exactly the same 2111 code points
as the old negated class across U+0000..U+10FFFF.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
reason() calls itself the single source of truth for both ends of the
pipeline, and the write path honoured that: client.py:228 passes the
directory code into unmask(). Only damaged() read catalog.locale, so
for 50 catalogs the two ends judged against different languages.
44 catalogs carry no locale header, 8 of them non-Latin and therefore
silently past the script check. 6 more name a code their directory
disagrees with, so tl was judged as fil, bh as bho, tw as ak.
The language becomes a required parameter; both production callers
already held the code. Measured over every catalog, header language
against path language, one moves: core/av from 0 to 29. Those 29 hold
pseudo-Norwegian and a degeneration loop in a Cyrillic catalog, and
make i18n-prune has since cleared them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every catalog got a lane derived from the CPU count, so twenty requests met one model instance. That is not throughput, it is self-inflicted saturation: a single catalog recorded 425 translated, 9 discarded and 40 requests refused with HTTP 503, and a discarded entry is simply left pending.
The work is now split by the backend that will answer it. The languages the engine serves keep the wide pool, because Argos answers in about 0.15s; the rest run after them with two lanes, one to occupy the model and one to refill it while the first writes back. Each pass builds its own client, so the chat pass does not inherit the worker count the engine pass was sized with.
Order is the larger half of this. The table is alphabetical, which is why the last run spent its first 45 minutes on af, a language with no Argos model, while fr and de sat untouched. Cost order delivers the engine languages first. For domain=docs that is 47 catalogs of 3963 entries each in roughly an hour; for domain=core it changes nothing today, because those catalogs are already translated.
The test picks aa against fr on purpose: both orders agree on every pair the fixtures held before, so a test built from them would have passed against the old single pass as well.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta/languages.yml carried `libretranslate: true|false` per entry, which
answered two different questions with one field: which languages the
repository translates, and which engine renders them. The first is the
table's business; the second is not. The gateway offers every backend
that serves a pair, falls through when one refuses or mangles the
answer, drops an engine whose failures pile up, and settles a request
class on the engine that wins its comparisons - and `signature()` is
"{source}:{target}:{length bucket}:{markup|plain}", so that learning is
already per target language. A static column beside it is a second
opinion that goes stale the moment the router learns something.
So the field is gone, 184 lines, and the table now holds name, endonym
and direction. Every language it names carries a catalog in every
domain.
What one engine can do is declared with that engine instead:
services.libretranslate-engine.serves, 47 codes. Not a detour - both
readers run before any engine answers. The lane prefetches one model per
code, and the readiness probe is itself the thing that would have asked.
`translatable()` split along that seam. Catalog-scoped callers take
`domain_languages()`, engine-scoped ones take the new
`served_languages()`: the CI matrix, extraction and translation the
first, the model prefetch and the tuning sweep the second. Collapsing
the two is what this change is guarding against - `baked_languages()`
renders identically before and after, verified by diffing both outputs.
`translate()` also stops dying on a language whose catalog has not been
extracted yet. It reads a missing catalog as nothing pending rather than
raising, because 136 docs catalogs do not exist until the next
extraction and refusing those languages would quietly shrink the set the
repository promises to translate.
The oracle suite follows: tests/oracle/test_translation_score.py asks
utils/i18n/judge.py about every language instead of routing a laya
checkpoint per language. It cannot run yet - the tools lane carries no
LiteLLM - and its skip message says exactly that.
test_catalogs_complete_on_gpu now measures against the table rather than
one engine's coverage. It is live on a GPU host and will report the 136
missing catalogs as the gap they are, which is the point: narrowing it
to the engine would hide exactly the languages worth watching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sphinx -b gettext -q -W` turns a warning into a failure, and
`make i18n-extract` had been dying on 512 of them. 505 were one defect
repeated: the README template emitted an Architecture section for every
role, while `generate_architecture_page` writes ARCHITECTURE.md only for
invokable ones. 101 roles therefore linked five times each to a page
that does not exist. The section now carries the same
`application_invokable` gate as the page, measured from 101 mismatched
roles to 0.
The remaining seven were separate markup defects, each fatal on its own
under -W. architecture.py's four-entry block was an indented paragraph
that docutils read as a definition list ending abruptly, so it becomes a
literal block. branding.py held a double-backtick literal spanning four
lines, which RST does not allow; it becomes a literal block too.
container_extra_hosts.py wrote `Kwargs:`, which napoleon does not know -
a blank line is measurably not enough, the section has to reach
napoleon, so it is `Keyword Args:`. postgres_libraries.py needed one
blank line before its bullet list, and the cleanup skill an H1 above its
first `##`.
conf.py gains two settings. autodoc_mock_imports covers
`ansible.module_utils.kcadm_json`: that import is correct for an Ansible
module, which resolves module_utils only inside a play, so autodoc on
the control host is what has to yield, not the module.
napoleon_use_ivar stops an `Attributes:` section from emitting
`.. attribute::` directives that collide with the dataclass fields
autodoc already documents - eight duplicate-object warnings, reproduced
and cleared on demand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Cosmos section drew one diagram inside the README and truncated its
dependents at twelve. It is replaced by an `ARCHITECTURE.md` sub-page per
invokable role with four sections, and the README keeps only a link table.
Compose lists the containers with image and published ports. Swarm shows the
replica count and the effective placement constraint per service. Volumes
draws the decision chain `swarm_nfs_backed` walks instead of its outcome,
because whether a volume is NFS-backed depends on the mode, the storage
backend, the entry's own `nfs:` flag and the role's placement at once, and a
diagram stating only the result hides the coupling that makes a placement
change move data silently. Dependencies is the cosmos graph with the cap
removed, so `_DEPENDENTS_CAP`, `shown`, `overflow` and the `dpt_more` node are
gone.
Which containers a role runs cannot be read off `meta/services.yml`: it mixes
the role's own services with the ones it merely consumes, and the entries that
do describe a container carry `name`, or only `image`, or neither when the
image is built, so no field discriminates. A heuristic over that file dropped
`pds` from web-app-bluesky, five of six services from web-app-funkwhale and
the only service of web-svc-cdn. The new `services` module reads the
assignments that actually render a container, `{% set service_name = ... %}`,
resolving literals directly and constants through `vars/main.yml`; every
template in the tree that includes the container base carries one.
The page is written by its own unconditional loop in `__main__.py`, not gated
behind `--update-architecture`, so the documentation build cannot emit the
link table without the page it points at. `--check` compares instead of
writing. A committed copy is refused by
`test_no_role_commits_the_architecture_sub_page`, the rule the managed README
sections already follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Of the 76 open code scanning alerts, 39 point at code that can change.
svc-ai-mcp-adapter:
- A path argument that is empty or made only of dots is refused.
Percent-encoding keeps a separator inside its segment, but "." and ".."
need none, so an upstream that resolves dot segments could answer from a
path the contract does not name.
- parse_duration splits on the units. The pattern it used before rescanned
a long run of digits from every start position.
Tests and the README generators:
- Every Jinja environment built with autoescape=False and a "noqa: S701"
uses select_autoescape now, as six test modules and
utils/templating/jinja.py already did. It escapes templates named
*.html, *.htm and *.xml, which none of these call sites loads.
- Dead code goes: a doubled "rel" block in the email lint, an unread
_SKIP_DIRS, and a module that _resolve returned to no caller.
- Two "except ...: pass" blocks become contextlib.suppress and
assertRaises.
- Nine test modules import unittest.mock one way instead of two, and one
lookup test imports its module once.
Closes#807
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
666312dfbd settled the catalog client on the dedicated LibreTranslate. One
engine cannot be compared against itself, so the direct path had no way to
improve on a language it translates badly: of the 2854 entries of the `ko`
catalog it produced 1 translated entry and 2704 `structure` rejections.
The client now asks the gateway, which fronts several engines and keeps a
history of which one survives a given signature. Protected spans are masked
as `<x id="N"></x>` before the request and declared per item, so the gateway
can reject an answer that altered them instead of passing it on. `_reask`
then retries each rejected entry with the engine that produced it in
`exclude`, which reaches a different backend rather than the same one. The
same catalog measured 17 translated entries with 2649 rejections once the
gateway checked the markup, and 331 with 796 once the retry could exclude.
The full picture a rejection needs is only available in the client: 2431 of
those 2649 structure rejections involve markup the masking hides from the
gateway, so the engine-switching decision belongs on this side of the call.
`server()` reuses a gateway that already answers and otherwise deploys the
bundle the gateway needs. It does not reintroduce the throwaway container
666312dfbd removed: there is no `docker run` here, the roles deploy through
the normal path and their models still come from the role images built
through the package cache. `redeploy=true` is offered because the reuse check
only asks whether a gateway answers, which cannot see that the gateway's own
code changed since it started. A run refuses outright while a deploy is in
progress, because both touch the same container stack, and leaves the deploy
alone.
`deployed()` additionally requires a non-empty catalogue, so a gateway that
answers while serving no pairs counts as not ready rather than as a backend
that refuses everything. `probe(url)` is split out of `deployed(root)` so one
readiness check serves a single engine and the gateway alike.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The published Production block hardcoded ghcr.io/infinito-nexus/core/debian, which duplicates the template meta/distros.yml declares and points every reader at :latest. That index carries linux/amd64 and an attestation, so the docs CLI test, which executes the published instructions verbatim, died on arm64 with `no matching manifest for linux/arm64/v8`.
deploy_image() prefers INFINITO_IMAGE, which scripts/tests/deploy/ci/one.sh already exports from resolve/image/ci.sh, and otherwise composes the reference through environment_image() from the distro and tag default.env declares and the owner and repository SOFTWARE_REPOSITORY carries. The default is byte-identical to the literal it replaces.
Nothing versioned becomes environment-dependent: the Quick Setup block is never committed, and roles/web-app-docs rebuilds it inside the container, where builder.py puts the snapshot first on PYTHONPATH and passes os.environ through. CI therefore tests the instructions against the image the run just built rather than against a release from months ago.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The report aggregated each role's meta/mcp.yml into one table, and its
freshness test failed on every image-version bump of an MCP-serving role,
because the updater edits services.yml and nothing regenerates the committed
report. The table added nothing the sources lack: scope and credential subject
were identical for every role, so it restated its inputs.
The generator, the report, its freshness test, make mcp-audit and the i8mcau
alias go, and make docs no longer calls the generator. The metadata stays in
each role's meta/mcp.yml, where test_mcp_schema.py enforces its shape, and
requirement 035 now says so. The classification and completeness lints stay,
because they check the lists in 035 against the roles, not the report.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The entity name keys a role's instance directory, its compose project and its container name, and was derived only by stripping the longest matching category prefix. Two roles under different categories whose names share a suffix therefore collapse onto one entity and overwrite each other's rendered stack, which no provides: can repair.
A literal entity_name in vars/main.yml now wins over the derivation. web-app-seaweedfs-console takes its shorter name back and declares the entity seaweedfs-console, which frees the seaweedfs key in its own services map for the engine consumer and removes the provides:, covers: and alias entries that stood in for it. A new lint pairs the declaration with the primary key it has to match, because a drift between them would drop the role from the provider set in silence.
Compared across all 288 roles, exactly one entity changes, and it restores what main already keeps on disk, so no instance directory moves.
The function and the filter lose their get_ prefix. Two lints read that prefix as the discriminator between the variable and the filter that fills it; both discriminate on the pipe now. Ansible's templar resolves a filter and a same-named variable independently, verified against ansible-core 2.20.2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cli/build/docs/integration_matrix was 1189 lines a human ran through
'make integration-matrix', and its output sat frozen as a 125-line grid in
requirement 027. Its ENTITIES and EDGES lists were hand-maintained, so the
grid could only drift: it covered 84 entities while the tree holds 98 roles,
named two roles that no longer exist, and omitted sixteen current ones -
api, checkmk, docs, hermes, homeassistant, jellyfin, meta, mirror, openbao,
openclaw, penpot, pihole, s1, seaweedfs, seaweedfs-console and semaphore.
infinito_docs/generators/integrations.py replaces it and joins the nine
sibling generators in that package, so the overview, services and addons
pages are written into generated/integrations/ on every docs build rather
than when someone remembers the target. index.rst takes the overview and the
new docs/architecture/integrations.rst references the other two.
Requirement 027 becomes prose. The make target and its i8inma alias are
removed as a pair, which keeps the alias lints satisfied.
Coverage is not strictly larger. The removed EDGES list carried a curated
upstream project URL per integration, 96 of them, and the new edges have no
such field; only 48 of 159 addon declarations hold update.upstream_id or
config.repository, so about a third is re-derivable and the rest existed only
in that list. Its fourth field is no real loss - 57 entries read 'check', so
it was a near-constant rather than an axis. In exchange the old module had
zero tests, not one file under tests/ referenced it, and the generator ships
310 lines including generate().
Residuals: the HTML build runs without -W, but i18n.py:57 runs the gettext
builder with -W and check=True, and that build has never seen
integrations.rst - no catalog mentions generated/integrations. And
docs/requirements/036:72 still carries a checked criterion asserting a table
in 027 that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
apply() already refuses a translation another entry holds, on the same floor the release gate reads, so within one process the second sharer is discarded. Four entries of docs/vi nevertheless shared one msgstr: one_catalog reads the catalog once, keeps what it has spent in memory and writes the whole file back after every chunk, so two concurrent runs each hold a private view and overwrite each other.
exclusive() takes an flock on build/i18n/<domain>-<code>.lock around the whole read-modify-write. It tries the lock non-blocking first so a second run can say it is waiting instead of looking hung. The lock files sit under build/, which .gitignore already excludes.
The damage could not be repaired either: prune ran damaged(), which reads one entry at a time, and a collapse is only visible across entries - each one is individually sound. over_shared() groups the machine translations by what they say and returns every sharer beyond MAX_SHARING_SOURCES. prune clears those first, so damaged() no longer sees them and the printed counts cannot overlap. Measured on the corpus: 10 in docs/vi, 132 overall.
MAX_SHARING_SOURCES lands in damage.py next to COLLAPSE_FLOOR and the gate imports both from there. fe80a23725 split a criterion across the two ends once already and the run never converged: every pass wrote what the gate condemned and prune emptied it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What is wrong with the answers the collapse check reports is not their
shape. A label is caught by its length, but `sq` shares a translation with
four sources at 0.84 of their length, `ga` at 0.73 and `he` at 0.62: full
renderings, spent twice. No length rule separates those from a language
that is simply terse, because the fault is that another entry already says
it, which a predicate reading one entry cannot see.
The writer now carries the translations a catalog already spends on a
source long enough to deserve its own, and turns down a repeat. It lives
where the whole catalog is in hand; `apply` alone sees one chunk. Both
arguments default to None, so a caller without the catalog behaves as
before.
The floor is now inclusive, matching the check it mirrors. `az` collapsed
on sources of exactly forty characters, which the check counts and the
first version of this guard skipped.
`criteria` is threaded through so the run names what it refused. A
duplicate is turned down here rather than in the client, and the summary
reported the discard with no reason for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
load_only reaches the image as a build ARG, and the RUN that fetches the
argos models is keyed on it. Pinning it to the languages a run happens to
translate therefore changed the ARG on every batch, invalidated that layer,
and refetched every model it names. Ten batches meant ten full rebuilds,
each one downloading what the previous had already downloaded.
Accumulating the languages instead would have been worse, not better: the
layer is all or nothing, so a growing set refetches the whole set each
time. What works is the opposite of following the batch. The lane now pins
load_only to every translatable code of both domains plus the pivot, 48 in
total, and asks for from_source, which drops the reverse pairs a lane that
only ever translates out of English never requests. The ARG is constant, so
the layer is built once and every later batch reuses it.
codes no longer reaches the rendered vars, which left it unused in deploy()
and server(); both lose it, along with the two call sites. That undoes
44260b8786, whose subject was passing it to a parameter this removes.
Correction to 4f2c9140fa: that message said an empty load_only makes the
image skip prefetch_models.py. It does not. The script always runs, and an
empty value only drops its --load_only_lang_codes filter, which is what
made it reach for every package. The consequence it described was real, the
mechanism was not.
Measured: after the one rebuild the server offers all 48 languages, and the
three batches since ran with no ansible task at all in their logs, going
straight to translating 12,518 entries across nineteen catalogs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4f2c9140fa made server() take the codes that decide which argos models the
deploy bakes, and updated the translate path. The tuning sweep calls the
same context manager and was left on the old one-argument form, so
`make i18n-tune` raised TypeError before it reached the server. It already
holds its target in `code`, the value it feeds to sample() and to the
readiness wait, so the sweep now deploys the pivot plus exactly the one
language it is about to measure instead of a server with nothing baked.
Nothing imports this module in tests and the repository runs no type
checker, so the arity drift passed ruff and the whole unit suite unseen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A batch that named its languages still deployed a server that knew none of
them. The role renders load_only from the inventory, the tracked i18n.yml
leaves it empty, and an empty load_only makes the image skip
prefetch_models.py. Nothing was baked behind the package cache, so the
server reached for the whole argos set at runtime over a path no cache
serves. The readiness wait then sat for its full hour and gave up with
"does not offer", which reads like a server that refuses the language
rather than one still downloading it. The lane now renders its own host
vars with load_only pinned to the pivot plus the batch, beside the tracked
file so the container sees it, and deploy and server carry the codes that
decide it.
The span guard could not see a hyphen. Its dotted-name alternative matched
[A-Za-z]\w*(\.[A-Za-z]\w+)+, and \w excludes -, so a host like argos-net.com
was never a protected span: a translation was free to mangle it and the
comparison still balanced. Widening the head to [\w-]* brings those names
under the same guard the undotted ones already had.
A refusal named its criterion and nothing else. "protected-span" says an
entry was turned down without saying which span moved, which is the one
fact that separates a bad translation from a criterion that is too strict,
so the marker now carries lost= and gained= and the per-catalog summary
counts the criteria it saw instead of reporting every rejection as a
damaged span.
Verified in the container against babel 2.18.0: 171 unit tests pass, and
the catalog lint holds across all 218 catalogs under the widened regex,
which is the gate that would have caught a span the old pattern let through.
The rendered vars file pins de, el, en, et, hi and lv, and those catalogs
carry their translations.
make i18n-tune still calls server with one argument and is fixed separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server() preferred the deployed service and fell back to a throwaway
container when it did not answer. That container ran the upstream image
directly, with LT_UPDATE_MODELS on and neither the package-cache CA nor
the argos-net.com hijack, so it refetched gigabytes from upstream on
every run while the deployed service already carries those models in its
image, installed through the cache at build time. The fallback is gone.
It was reached by accident rather than by failure. deploy() ended in
await_service(), which polled for 300 seconds; the first start of the
service installs its models and takes far longer, so the wait expired,
deploy() reported nothing running, and the caller spawned the throwaway
beside the service it had just deployed successfully. await_service and
SERVICE_TIMEOUT_SECONDS are removed, leaving the single readiness wait
the client already performs with the budget those models need.
Step 0 of the deploy also stopped forcing a recreate. It rebuilt a
healthy lane on every invocation and took whatever model install was in
flight with it; compose recreates on a changed configuration by itself.
The wait no longer looks like a hang: it reports how many of the wanted
languages the server offers, which ones are still missing, how long it
has waited against its budget, and what the lane has pulled. i18n-tune
waits the same way and gets the same report.
service_url() and lane_container() replace the two places that each
rebuilt one of those strings, and deploy() reads the environment once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A damaged protected span is a deterministic answer for an unchanged source,
so a run that asks again spends a masking pass, a request and an inference
to reach the same verdict. The refusal is now recorded as a
`translation-refused: <engine> damaged-span` annotation in the entry's
comments, naming the engine that produced it, and `pending` skips such an
entry. Gettext drops the annotation when the source changes, so the entry
comes back by itself; a change to the masking or to the damage predicate
does not, and `make i18n-retry` clears the marks so those entries are
offered again. The mark is matched by prefix, so a refusal from another
engine counts and clears too.
Measured on the `core` domain over 34 languages:
old code 266 requests, 0 translated, 266 discarded
first pass, new 266 requests, 266 annotations written
second pass, new 0 requests, nothing to translate
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README render fixture named Kevin Veen-Birkenbach as the author but
carried https://social.infinito.nexus/profile/example/profile as his profile
URL. No such profile exists, and tests/external/repository/test_urls_reachable.py
probes every URL literal in the tree, so test-external failed on a 404.
The address the fixture should carry is the one .mailmap already derives for
that author, which answers 200. Introduced in fa015ab432, which is also where
the os.environ default came from: after that commit only targeted tests ran,
so neither reached a gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The replay of a role's "### Production" block moves out of the CI guide
scripts and into the documentation role itself. The block is read off the
rendered page over HTTP, so what runs is what a reader copies, and the role
that publishes the page is also the role that proves it.
scripts/github/guide/{one,compose_deploy,host_deploy}.sh and
cli/build/docs/readme/production.py are gone with the workflow step that
called them. The instructions marker now only adds web-app-docs to the row's
apps; test-e2e-cli discovers the role through its new templates/test.env.j2
and runs files/test/test.sh.
The block runs in a throwaway machine, never on the stack host. compose.yml
starts one service built from files/test/Dockerfile, whose base is
INFINITO_PARENT_IMAGE out of the mounted checkout's .env, so the machine is
the stack host's own distribution and the replay exercises the installation
instead of a prebuilt image. files/test/init.sh adds an init when the base
carries none and keeps the full -Syu that ef0f03ea98 established for arch.
scripts/tests/workspace/base/01_install.sh then prepares the machine before
the block runs.
Nothing is skipped any more. The previous host-install branch exited 0 and
passed silently over 65 of the 178 invokable roles; a block without a
"docker run" is now replayed against systemd in that same machine, with the
git clone dropped in favour of the mounted tree under test.
The target role comes from the new guide_role lookup, which reads
list_invokable_app_ids() rather than matching role-name prefixes, and falls
back to the asking role when the round deploys nothing else. deployment.py
grows running_apps() so both lookups share one definition of what the round
deploys. required_by_coverage.role_is_invokable drops its own walk over
categories.yml and delegates to the same SPOT; measured equal across all 280
roles.
Carried in the same index, because the refactor pass ran over this change
set: library.py splits into sites.py and build_queue.py mixins, builder.py
hands translated_languages to catalogs.py and folds three repeated log-tail
and state writes, and placeholders.py becomes a facade over spans.py,
repairs.py and damage.py. No test changed for those splits, and the two
ref-resolution dicts builder.py carried are replaced by _wanted_ref, which
also stops hashing the deployed tree on every tag build.
Verified: 7132 unit and lint tests pass over the whole tree, the guide_role
lookup renders through a real Templar, the base image resolves from .env,
and compose config validates the service. The replay itself has never been
executed; that needs a deploy of web-app-docs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cosmos, Quick Setup and Credits follow from meta/services.yml, categories.yml
and galaxy_info.author, yet 279 role READMEs carried a copy that went stale
whenever the metadata moved and nobody re-ran make docs. The documentation
build now runs the generators itself, against its own scratch checkout, and
the committed READMEs keep only the prose a person writes.
utils.cache.PROJECT_ROOT derives from the module's location, so putting the
checkout first on PYTHONPATH makes the generator operate on the scratch tree.
i18n.py and builder.py add it; the one remaining coupling, a relative_to over
PROJECT_ROOT in a log line, is gone.
CI extracted the "### Production" block from the committed README with awk.
Stripping the section made guide_deployable() return "" for every role, which
selects no deploy row and turns nothing red: a fail-open regression. It now
asks _is_role_invokable, which matches the old condition exactly (178 roles
either way, both difference sets empty), and cli/build/docs/readme/production.py
renders the block for the two guide scripts. All 178 renders are byte-identical
to what HEAD served, and both scripts' extraction pipelines produce identical
output for a web app, a service and a desktop role.
test_readme.py inverts: the three sections are now a hard failure in a
committed README. readme_md.md documents what each is derived from.
Removed: the docs, cosmos, readme-generate, readme-index and readme-check
targets, the five aliases pointing at them, and docs from make quality.
Verified by running generate_commands() in series against a copy of the
checkout: every command exits 0, no README is left without Cosmos or Credits,
and exactly the 178 invokable roles get a Quick Setup. The ansible_roles step
needs pandoc, which only the docs image carries, so that one link of the chain
is unexercised here; it is unchanged by this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README generator read its author URL out of the ## Credits sections it
writes itself. .mailmap now folds every address a person commits under into
their @infinito.nexus one, whose local part names their social profile, and
that profile becomes the single point of truth for an author.
utils/meta/identity.py parses .mailmap rather than shelling out to
git check-mailmap, which refuses the worktree the test container mounts.
Cross-checked against git over all 14 identities in the history: no
disagreement.
A new env handler carries the committing identity into the container, which
mounts the checkout but not the contributor's git configuration. Without it
both new tests would skip exactly where they are meant to fire:
* tests/integration/meta/test_commit_identity.py fails when the configured
address does not fold, and prints the .mailmap line to add.
* tests/external/repository/test_author_profile.py probes that profile
live, behind a canary so an unreachable instance is not reported as a
missing profile.
Both look at the current address only. Probing every declared author would be
red for people who have not created a profile yet, and a suite that is red for
someone else's omission stops being read.
Marko Pjevac is declared as a role author but has no address here, so his
Credits line stays unlinked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three shapes of the same failure, taken from the build's own message list
rather than from a reading pass.
Eight unescaped inline characters in prose: a '*', '**', backtick or '|'
opens markup that never closes. 'roles/*/vars/main.yml', the regex
'^(web-|svc-).*', 'scripts/system/tls/trust/*.sh'. In ansible_log an inline
literal ended on a space, which reStructuredText forbids; the space carried
meaning, so it moved into the prose.
Thirteen example blocks after a single ':', which makes the indented block
a block quote instead of a literal one.
Three bullet lists with no blank line to the paragraph above, which
docutils reads as a continuation of that paragraph.
Two markdown files carried a '---' where reStructuredText allows no
transition: directly after a heading, and at the end of a document.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine package shims listed dir(__main__) and lifted every public name into
the package namespace, so each of them re-exported its imports. pathlib.Path
became a member of nine packages, and sphinx found five targets for a bare
Path annotation: 320 ambiguous cross-references in the documentation build,
down to 10 with this change.
public_names() honours a declared __all__ verbatim and otherwise keeps the
names whose __module__ is the module itself. Nine call sites shared the same
expression, so it lives in one place now.
This narrows a package's public surface: __all__ shrinks and the shim no
longer carries its imports. Every importer in the repository pulls a locally
defined symbol - EXIT_NO_SUCH_APPLICATION_ROLE, find_application_ids,
build_mappings, output_graph - and none pulls Path or Any.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--include filters the generated inventory to the ids it is given literally, so a provider pulled in through the dependency closure runs without joining group_names. svc-ai-agent-broker's README therefore documented a deploy that stops on its own assert: litellm comes along, nothing it can serve does, AI_AGENT_MODEL resolves to '' and 00_core.yml refuses the agents. Run 36064589578 shows it as 'Including only application_ids: svc-ai-agent-broker' followed by the assert.
meta/services.yml gains guide_companions, read through utils/roles/meta_lookup.py beside get_role_run_after, and the generator joins it onto the role id as application_deploy_ids for the three places the Quick Setup block spells the id out. A role without companions renders byte-identical, which the README lint confirms across every role.
This declares where e74a585402 derived: a role asking for networks.internet.dns names it in its own tasks, so the generator can read it. A companion cannot be read that way. The broker's tasks state only that the model must resolve, and deploying a backend is one of two remedies, the other being a provider key on svc-ai-litellm; which one the documented path takes is a decision rather than a derivation, so it is declared and the fail_msg names both.
Declaring services.ollama on the broker instead was tried and reverted: it pulls svc-ai-ollama into the closure, which a probe confirms, but the literal include filter leaves it out of group_names all the same, so the model server would run while the assert still fired.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cold LibreTranslate answers its opening requests well above the rate it
settles at. ac5e7d54c4 measured each pair once, so the bonus landed entirely on
the first cell and it won every sweep: the grid reported batch=10 lanes=10 at
8.45 entries/s and called it ahead of the field, while measuring that same pair
again right afterwards gave 5.7, 6.4 and 6.0.
A discarded warm-up run was not enough. Repeating a pair back to back keeps all
of its runs inside the same opening window, so the median could not see past
it. The grid is now walked once per pass and the passes repeat, which puts the
bonus on one run of one pair: the same cell reported 7.7 in pass 1 and 5.9 in
pass 3.
Each cell records the distance between its best and worst run, and the winner
counts as a real difference only when it beats the field's median by more than
the widest of those. The rebuilt sweep answers 5.91 entries/s, inside the
noise, which agrees with the independent re-measurement and is the honest
verdict for this host: the client settings are not what limits it.
The sample halves to 200 entries so three passes cost what one used to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The translator sized its batches and lanes from constants and the CPU count,
which is wrong on any host whose bottleneck is the GPU rather than the cores.
`make i18n-tune` sweeps batch sizes 10 to 80 against lane factors 0.5 to 2.0
over 400 real msgids, keeps the pair with the highest entries per second and
writes it to build/i18n-tuning.json, beside the history
cli/contributing/update/collection_source already keeps there.
`make dotenv` turns that file into INFINITO_I18N_BATCH_SIZE and
INFINITO_I18N_LANES, and a translation run prefers them over its own
derivation. With no sweep on disk the handler emits the shipped defaults, so
a checkout that never tunes behaves as before.
The handler had spelled the batch default out a second time; it now takes
BATCH_SIZE from the client that owns it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects in the client surfaced in one run. LibreTranslate serves Chinese as zh-Hans and zh-Hant, never as bare zh, so the readiness wait blocked on a target that was never coming and the run stood still for an hour without printing a line. A blanket except turned every transient refusal into a permanently discarded entry, and the fallback path answered a failed batch by firing twenty single-text requests at the same overloaded server; the same catalogs that lost 130 to 295 entries came back with 0 discarded once the batch is retried with backoff first.
The per-catalog count conflated a refusal with a damaged protected span, which is measurably wrong: of 228 entries left pending in core/sw only 36 carried a span at all. Outcome now carries values, refusals, damage and the last refusal message per call rather than on the client, where twenty lanes mixed their numbers into each other. That split is what surfaced the 502 the server had been answering with. The throwaway container also ran with check=True and capture_output=True, so docker error text died inside the swallowed exception and the log showed an argv list and rc 125.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server() now resolves a translation endpoint in three steps: use the running
service, rebuild when the host has a GPU but no accelerated deploy left its
stamp, otherwise deploy. A plain run therefore costs nothing, and the rebuild
fires once, on the move from a CPU-built container to an accelerated one.
deploy() brings the runner up, waits for State.Running and deploys the role
inside it. The exec steps address `docker compose exec i18n` directly:
cli.administration.deploy.development exec hardcodes the `infinito` service
(compose.py:261), so every deploy through it silently ran in the main
container, where mailu already holds port 25 and postfix cannot start.
check=True stops a failing step instead of carrying on against the wrong
target.
The deploy router writes its pid to build/deploy.pid, which survives the exec
into the subscripts, and deploying() refuses a translation while that process
lives. The running deploy is left untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
extract merged 47 catalogs of 25000 messages one after another, and handed
sphinx-build os.cpu_count() jobs. Neither matched the machine it runs on.
The merge moves into a process pool: 15.0s serial, 4.6s at four workers, 2.7s
at twenty. An initializer reads the template once per worker instead of once
per language. The worker functions live in catalog.py because a spawned child
cannot resolve a function of a `python -m` main module by name, which the first
attempt reported as AttributeError on __main__._adopt.
The job count now derives from the cores that are free rather than the cores
the machine owns: a worktree shares its host with the deployed stacks, and
sched_getaffinity respects a cgroup limit that cpu_count does not see. Measured
under that contention, twenty jobs took 134s against 97s at eight and swung
between 107s and 148s, so a floor of two and the free-core count replace the
flat cpu_count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LibreTranslate rewrote anything that read like prose: assets/img/ became
Vermoegenswerte/img/ and ](path) collapsed to )path). Mask multi-segment
repository paths, bare filenames and markdown link targets alongside the
spans that were already protected, so the translator never sees them.
The catalogs still carry the earlier damage, so add a prune subcommand. It
compares the protected spans of source and translation and empties every
entry whose translation altered them, which lets the next run refill it. A
pass over the German docs catalog cleared 85 entries without losing a single
intact translation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pull request against main extracts the core and docs domains, fans out
over the languages LibreTranslate supports, and opens one translation PR
per language against the branch that introduced the strings. Only the
entries a catalog leaves empty or fuzzy reach the translator.
The job matrix and the translator read the same list through
translatable(), so a code cannot enter the matrix and then be refused by
the run it starts.
The pipeline stays off until CI_ENABLE_AUTO_TRANSLATIONS is set, and
pull requests from a fork are skipped because the app token cannot push
to their head branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
https://github.com/infinito-nexus/core.git was written out in seven places:
the source_repository of web-app-docs and web-svc-api, both git clone lines
of the role README template, and three web-svc-api unit tests.
group_vars/all/00_general.yml now declares it once as SOFTWARE_REPOSITORY.
Ansible reads the group var, Python imports utils.software.SOFTWARE_REPOSITORY,
and the README generator passes it to the template as software_repository;
every generated README stays byte-identical.
tests/lint/repository/spot/test_software_repository_spot.py fails on any
other occurrence of the URL in a non-ignored file, tests included. Only
Markdown and its .po translations keep the literal, because they are the
text a reader copies from and cannot use a constant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Core and the documentation get gettext catalogs for all 184 ISO 639-1
languages listed in meta/languages.yml, 47 of which LibreTranslate can fill.
`make i18n-extract` merges the current sources into locale/<code>/
LC_MESSAGES/{core,docs}.po: role descriptions, category and menu titles and
the logout strings for the core domain, the Sphinx gettext output for the
docs domain. A changed source keeps its previous translation as a fuzzy
entry, and catalogs are written deterministically so an unchanged merge
leaves the files untouched.
`make i18n-translate domain=<core|docs> [languages=<codes>]` fills only the
empty and fuzzy entries with a short-lived LibreTranslate container (the
image web-svc-libretranslate pins) and removes it afterwards. It starts the
container, and loads models, only for languages that still have pending
entries. Code spans, placeholders, URLs, tags and dotted names such as
Infinito.Nexus are masked for the request and a translation that damages
one is discarded. An unreachable server stops the run instead of
discarding every remaining entry, so a rerun continues where it stopped;
machine translations carry the translator comment `libretranslate`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The report aggregated each role's meta/mcp.yml into one table, and its
freshness test failed on every image-version bump of an MCP-serving role,
because the updater edits services.yml and nothing regenerates the committed
report. The table added nothing the sources lack: scope and credential subject
were identical for every role, so it restated its inputs.
The generator, the report, its freshness test, make mcp-audit and the i8mcau
alias go. The metadata stays in each role's meta/mcp.yml, where
test_mcp_schema.py enforces its shape, and requirement 035 now says so. The
classification and completeness lints stay, because they check the lists in
035 against the roles, not the report.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
svc-net-tor aborts at roles/svc-net-tor/tasks/router.yml:2 when networks.internet.dns is empty, and rightly so: dnsmasq runs with no-resolv, so an empty upstream leaves the node answering .onion and nothing else. That value is set in exactly two places, both under inventories/development/, and the documented production path reaches neither - the provisioner writes no networks key at all. A reader following the role README verbatim therefore cannot deploy a Tor node.
CI has been proving this since 2026-08-31 without anyone seeing it: the README replay rides on the role's smallest variant and is skipped when the deploy is red, so the only row that ever reached it is one where svc-net-tor itself went green. That happened first on Ubuntu, where the local-service defect abb6f37297 fixes never fired.
The template now emits the DNS assignment, the -e passthrough and the networks entry in --vars for a role whose tasks name networks.internet.dns, which today is svc-net-tor alone; the generator derives that rather than taking a declaration, so a second role asking for the value documents it by doing so. scripts/github/guide/compose_deploy.sh substitutes the placeholder from INFINITO_DNS_IP, the same SPOT inventories/development/default.yml reads, next to the existing domain substitution.
This follows 972596a758, which added the administrator key to the same block for the same reason. No fallback inside the role: defaulting to NETWORK_PUBLIC_DNS_RESOLVERS would route a Tor node's clearnet queries to Cloudflare without the operator choosing it, which is what the assert exists to prevent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of them lived inside docs/ next to the pages they render, which is why both had grown a __file__ upward walk to find the repository root, a sys.path insert to import from utils, and a nocheck for each. A generator parked among documents reads as documentation and drops out of the checks that apply to code.
Moving them surfaced six real violations that the old location had hidden: yaml.safe_load instead of the shared cache, two uncached read_text calls, five hard-coded role paths instead of ROLE_FILE_META_SERVICES and ROLE_FILE_META_MCP, two make targets without an alias, and a requirement reference in code. All six are fixed at the root, none with a suppression, and both generators lose their project-root exemptions because a package supplies PROJECT_ROOT.
cli/build/readme joins them so the three generators that write into docs/ sit together. A new lint keeps docs/ to .md, .html, .rst and .nocheck; it enumerates through git ls-files so untracked __pycache__ is not its business. Checked against HEAD, it names exactly the three .py files that moved.
026-unified-addon-syntax stated that the matrix generator stays under docs/requirements/. The lint makes that impossible, so the requirement now records where the generator actually lives.
Verified: every one of the twelve modules under the new tree imports, no old module path survives anywhere in the repository, 482 lint tests and 1246 cli unit tests pass, and both generators run without drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
834 commits from the Tor onion-service branch against 299 of ours, from merge
base 7b646bdece: 127 conflicted files, 3506 files changed.
The two sides are largely orthogonal. Theirs adds svc-net-tor and a `tor` service
across the role tree, plus two sweeping refactors: shipped scripts sorted by
language (files/ruby/, files/python/, files/shell/, scripts/lint/*.sh) and the
unit suite moved under tests/unit/python/. Ours is requirement 036, the native AI
gateway, plus the MCP role work. Most conflicts were both sides appending to the
same service map, resolved as unions.
Resolutions that were not unions:
Credentials moved namespace. Theirs renamed meta/schema.yml to meta/secrets.yml
and re-addressed credentials as secrets.credentials.<name>. Git carried our
litellm_api_key blocks through the rename, but every consumer we added still read
'credentials.<name>', which no longer resolves. All 45 call sites are migrated.
plugins/filter/container_volumes.py is deleted by theirs and relocated to
plugins/lookup/container_volumes.py, where it carries the same tmpfs defect our
f85996b43 had just fixed: a hard-coded {"size": ...} that silently drops a per
mount `mode`. The fix is re-applied in the relocated file, as that commit's merge
note anticipated.
compose.yml keeps only INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR. Theirs also forwards
INFINITO_PLAYWRIGHT_STAGE_BASE_DIR; that line is deliberately dropped, per the
analysis recorded in 45804e9ad -- STAGE has a drift-proof role-vars fallback,
while forwarding it promotes a setdefault-pinnable .env value above that SPOT.
tasks/stages/01_constructor.yml takes their include, ./tasks/utils/setup/group.yml,
because their refactor removed tasks/groups/*-roles.yml, leaving our include
target nonexistent. Our task name is kept, since svc-virt is in the loop.
Makefile swarm-zombie is a union: their validate plus teardown, then our
lab_subnet cleanup, which their side dropped while leaving lab_subnet.sh as the
only orphaned reference in the file.
01_bootstrap.sh keeps our deterministic --ip pinning, which distinguishes an
already-attached node from a real failure, over their `|| true`.
Their language-sorted layout orphaned references our side still held: zammad's
two scripts/apply/*.rb, jenkins' java-with-ca-trust.sh, and the notify topics
their handlers renamed to hyphenated form. All corrected.
roles/sys-svc-compose/files/shell/swarm/stack_ready.sh gains our latched-update
block: paused, rollback_paused, rollback_started and rollback_completed exit 2
rather than polling a state that cannot resolve without another deploy.
config_from_run is unioned to (title, logged, jobs); the production caller in
trigger/__main__.py now passes jobs, without which our suite suppression would
have been inert.
READMEs are regenerated rather than hand-merged: 15 LiteLLM rows and 91 Tor rows
now derive from the merged metadata, which is itself evidence the meta merge is
coherent.
Verified: 5161 unit tests pass. The single failure, test_query.py TestSortSpec,
also fails on fork/feature/svc-net-tor at b51d2f9919, measured in a baseline
worktree -- so this merge introduces no new unit-test failure. Two modules are
excluded from that run because `baudolo`, a dependency their branch introduces,
is not installed on this host.
Not verified: no deploy has run against the merged tree, and make quality has not
been run end to end here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3529 invalid comments in 528 files, grandfathered in while the linter
only saw the working-tree diff. Removal ran through the linter's own
collectors, so detection and deletion cannot drift: a comment whose
opener line is blank before it and whose closer line is blank after it
loses all its lines, anything else loses only the comment region, and a
line left holding a bare delimiter goes with it.
Five files keep their comments behind a nocheck marker instead. Four are
upstream configs whose inline docs are upstream's own (mautrix, systemd
journald, nextcloud-oidc-login, MediaWiki). In littlejs the
commented-out entries ARE the deactivation mechanism for an example
tile, so deleting them would delete configuration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cosmos generator linked a consumer to its provider only through an
'<role>' in group_names reference or an explicit provides: name. A hard
dependency has neither -- pinning the provider on unconditionally is
exactly what removes the Jinja reference the generator was reading -- so
web-svc-asset's bond to web-svc-file vanished from the diagram the moment
it became binding. Match a service key against the provider's own primary
key from the registry, in both directions.
Services the role turns off drew a solid 1:1 edge, claiming a mandatory
relationship for something that carries nothing at deploy time. They now
render as a red 0..0 edge; edge merging gained a real precedence so a
provider consumed through two keys stays live when only one is off.
Named colour, not hex: a '#' opens an entity code in the mermaid lexer and
turns the whole diagram into a parse error on GitHub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cli build include` generated one tasks/groups/<group>-roles.yml per invokable category during setup, and the stage plays included those files. They were written by the in-container UID, which is why a dedicated clean-container-owned helper had to delete them from inside the container.
The stage plays now loop over lookup('group_roles', <group>) and include tasks/utils/setup/{group,role}.yml directly. ordered_roles() in utils/roles/order.py holds the run_after topological sort the generator used to perform at build time.
utils/roles/stage.py:_group_names() no longer globs the generated files; it reads get_invokable_paths(), the same categories.yml source those files were derived from.
Measured: handlers still flush after every role (ansible-core 2.20.2, three loop items, three separate handler runs), and resolving all 18 groups costs 0.60s cold against 1.4s for the generator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A person credited with a link in one place and a bare name in another
reads as two different people and drops the reader's path to them.
sys-svc-engine showed it in two adjacent lines: the same name once bare,
once linked.
The new credits-attribution lint requires a name linked in any Credits
section to carry that link everywhere, and rejects one name pointing at
two URLs. It found 13 offenders, including a second contributor whose
attribution had drifted in test-e2e-cli.
The generator produced the defect on every run: its context carried
application_author but never application_author_url, so the template
always took the unlinked branch. Existing READMEs kept their correct
Credits because the generator only fills missing sections, which is why
239 roles were right and 12 wrong.
The name to URL map is derived rather than declared: galaxy_info.company
names the project owner, not the role author (web-app-mattermost is
authored by Alejandro Roman Ibanez but lists Kevin Veen-Birkenbach as
company), so only the Credits lines themselves carry the mapping.
Generator and lint share that derivation and cannot drift apart, and a
new contributor needs one linked credit rather than a code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read INFINITO_LIFECYCLES from default.env via a new tested_lifecycles() helper; derive TESTED_LIFECYCLES, the README overview envelope, the guide-select role filter, and the invokable-lifecycle lint from it. Add "maintained" to the envelope and drop the redundant hardcoded "alpha beta rc stable" strings from workflows, CLI help, and docs.
Guide selection now skips pre-alpha/eol roles (153 -> 117 testable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A host role installs onto the machine itself, so its README Production block now shows the direct local-connection procedure (clone, scripts/install/package.sh, make install, source the env, then provision + deploy against localhost) instead of the container docker-run aimed at a remote server; stack roles keep the docker-run block. The mode is derived from role_has_stack. make docs now runs readme-generate quick_setup=true so the block stays in sync, and every host role README is regenerated accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>