All five gates now live in the check domain. canvas-version produces
byte-identical output to the original on the live tree.
It is the first real consumer of core/process.run. The git calls pass
check=False deliberately: a git failure here is not an error to report but a
signal that there is nothing to compare, since a fresh clone with no remote is
a legitimate state rather than a broken one. The argv-list and missing-binary
guards still apply.
Its two skips are kept distinct from its pass. NO_BASE and DIFF_FAILED exit 0,
as does CLEAN — but only CLEAN means the gate actually looked at something.
Collapsing them would hide a gate that had silently stopped running, which for
this check in particular is the exact failure it exists to prevent.
Found a second rich path while a NameError was rendering as a full-width
box-drawn traceback: typer's pretty-exception handler is a different mechanism
from rich_markup_mode, and setting one does nothing about the other. Same log
pollution T-1259 thought it had closed, arriving through another door and
landing in the worst place — a hook log at the moment something has already
gone wrong. pretty_exceptions_enable=False now on the root and on every domain
built by cli.domain().
test_canvas_version_check.py moves with the code it guards. It had been loading
the extensionless script through a SourceFileLoader and reaching canvas_sources
by sys.path insert, both only because tooling/ was not importable. Second
instance of that debt evaporating on contact. What it asserts is unchanged,
which is the point: diff_has_version_bump was kept pure in the port so its six
properties still hold without constructing git history.
Also restores an import the check router dropped in T-1267 when it moved to
cli.domain() — caught by running the command rather than by reading it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were already Python, so these are moves rather than rewrites, and both
produce byte-identical output to their originals on the live tree with the same
exit codes.
The E402 debt evaporated on contact, which is the first concrete evidence for
T-1274's premise. check-systems-db-stamp reached generator_sources through a
sys.path.insert and a noqa suppression, because tooling/ was not a package. It
now imports as `from tooling import generator_sources` — no hack, no
suppression.
The stamp gate's six failure modes are preserved as a StampState enum rather
than collapsed into pass/fail, because they carry different remedies and one
carries a different exit code: UNSTAMPED exits 2 while every other failure
exits 1, and the pre-push hook has relied on that distinction since T-857.
One deliberate behavioural difference, flagged rather than hidden: the old
stamp script was silent on success unless given --verbose, and the new one
always prints its verdict. No fact is lost, so parity holds, and it makes the
gate consistent with client-version and dataflow-graph which both always print
— the old script was the odd one out. Its per-command --verbose gives way to
the global one, which is the consolidation this initiative is for.
Also corrects a claim in the ticket itself: check-dataflow-graph.py does not
parse git output, it globs the filesystem. Only check-canvas-version parses
git, so only that fixture needs a real repo.
Still open and recorded as such: check-canvas-version, and parity tests for
these two — both were verified side by side on the live tree, which proves the
happy path and nothing else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clarifies the rewrite decision to what it actually meant: rewriting the bash in
Python does not mean reimplementing the operating system. A guarded exec is the
right answer for rustup, curl, unzip, git, godot, blender. What must become
Python is the LOGIC — which version is wanted, whether it is already present,
what the output means, what to do when it fails. The test of a correct port is
not whether it calls anything external, but whether the decisions can be
exercised without performing them.
Delivered ahead of the remaining ports because every one of them needs it.
core/process.run is the single sanctioned exec, and each of its guards exists
because a per-domain subprocess call is precisely where that guard goes
missing:
- An argv list, never a shell string. A string is rejected outright rather than
helpfully split, since the helpful split is the vulnerability.
- shell=False always.
- A non-zero exit becomes a ReachError naming the command, carrying its output,
and preserving its exit code — not a CalledProcessError traceback at someone
who wanted to know the next step.
- A missing binary reports what to install. FileNotFoundError names the path
that was not found, which is the less useful half of the answer.
All four verified against real commands, including a genuine git failure
relaying exit 128.
A conformance invariant keeps the door single: nothing outside core/process.py
may import subprocess or call os.system/popen/exec*. Proven to fail by
importing subprocess into a domain service.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
89 lines of grep/sed pipeline become a service returning a FactIdCheck and a
router that renders it. Parity on the live tree is exact: both implementations
print "check-fact-ids: OK — 6 references validated against 61 canonical facts"
and exit 0. The matching counts are the real evidence — a line-matching regex
that differed from the grep chain even slightly would move 6 or 61.
Kept line-matched rather than YAML-parsed on purpose. Parsing properly would
change which lines count: anchors, merge keys and multi-document files would
start contributing ids the old check never saw. That is a different check
wearing the same name, and a port is not the place to make it.
Three parity cases: ok, unknown fact_id, and the advisory mode where the
catalogs hold no definitions and the gate deliberately exits 0 — failing every
commit until they are populated would teach people to bypass the hook, and a
gate people route around protects nothing.
Proven to fail by removing the entity-attributes.yaml exclusion, and caught in
a way worth noting: not by the assertion aimed at it, but by the advisory case,
where including that file made the catalog non-empty so the new implementation
enforced while the old stayed advisory. A real behavioural divergence, surfaced
by exit code.
Retirement waits for the whole domain, per the per-domain rule — three gates
remain. It also resolves a tension: the parity test copies the old script into
its fixture, so deleting the script early would delete the test's own subject.
A parity test is scaffolding with a defined lifetime.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found on starting the first port: 17 of the 33 tooling executables are bash,
about 900 lines. Both this record and the domain map had assumed a Python tree,
so those are rewrites rather than moves — a materially larger epic than T-1250
was written for.
Decided: rewrite them, do not wrap them. Wrapping would achieve one door while
leaving half the CLI surface outside the contract — no @command, no remedy on
failure, no streaming, no testable service. reach --help would then list verbs
that behave differently from the ones beside them, which is worse than two
doors, because the inconsistency is invisible until something fails.
The cost lands unevenly and the record says where. The grep-pipeline scripts
compute verdicts and gain most from becoming services. The environment scripts
— install-godot, install-rust, worktree-setup — gain least and carry the most
regression risk, because downloading a specific Godot build or driving rustup
is awkward to exercise in a gate. For those, port the decision logic into a
testable service and keep the irreducible external calls behind core/process: a
rewrite that cannot be tested has to be trusted instead, and trusting an
installer is how a working environment becomes an unreproducible one.
The domain map gains the inventory by shape, and a rule that every per-domain
ticket states which of its sources are bash — since that is what turns a port
from mechanical into a rewrite needing its own parity evidence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pruning happens at spawn time rather than on a schedule: a retention pass that
depends on someone remembering to run it is one that silently never happens.
reach jobs prune is the explicit escape hatch for reclaiming space now.
The cap was measured rather than guessed, which is why this ticket ran last. A
chatty short job writes ~1.8 KB across its three files, so 100 jobs is
single-digit megabytes even if a generator emits per-body progress — inside
.cache/, where being wrong costs disk and never data. SR_JOB_KEEP overrides it.
The interesting part is what "a running job is never pruned" has to mean. Not
"the file says running" — a process killed outright never updates its own
status, so that reading would make every crashed job immortal. Those are
exactly the ones that accumulate, so the naive rule produces the opposite of
retention: the only logs that never go away are the ones nobody wants. The
check consults the process table instead.
Verified both directions. Live, a running 30-second job survived a prune to
--keep 1. Pinned with a fixture holding a finished job, a corpse (record says
running, pid gone), and a genuinely live one — asserting the live one survives
and the corpse does not. Proven to fail by dropping the liveness check.
One false alarm worth recording: my first live test looked exactly like the bug,
showing a running job pruned. It was not — my commands ran two minutes apart, so
the "20-second" job had finished long before. The test was invalid, not the
guard. A timing-sensitive check across separate shell turns proves nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The non-negotiable from D-263, pointed at its worst hiding place: a foreground
command that swallows a failure at least does it in front of someone, while a
background runner that reports "started" and loses the failure does it where
nothing is watching.
Testing the two timing cases the ticket names — fails before the parent exits,
fails long after — needs a command slow enough to tell them apart, and every
verb in reach finishes in milliseconds. So `reach dev selftest` exists: emits
progress for N seconds, then optionally fails with a chosen code. A genuine
diagnostic rather than a test hook, in the dev domain the map already planned,
and the only way to answer "does streaming work here, can I tail it, does a
failure survive detach" by observation instead of argument.
The slow case is the one that proves the design. --detach returned in 75ms
while the child ran six seconds, so the parent was demonstrably gone long
before the child failed — and wait still relayed exit 7. That is the half of
the recording path only this case reaches, and why T-1277 moved completion
recording into the child.
Also pinned: --detach exits 0 for starting and SAYS "not succeeded" in words,
which the test asserts on rather than trusting the code to be read correctly;
a failed job nobody waited on shows as failed in jobs list; and every event a
detached job emits carries its job id.
Closed T-1278's open gap in passing — jobs log --follow had never run against a
genuinely long job because none existed. It now has: attached mid-flight,
streamed the remaining steps live, and caught the final verdict after the job
ended.
Proven to fail by making effective_exit_code always return 0 — the trap itself.
Both timing cases failed by name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
reach jobs list / status / log --follow / wait. A domain rather than core/,
because these verbs carry logic and state: they reconcile recorded status
against process liveness, tail a file from an offset, and relay an exit code.
Found a latent bug in already-committed code before building on it. typer.Exit
is a RuntimeError, not a SystemExit, so @handle_errors caught it like any other
unexpected exception: `raise typer.Exit(3)` inside a decorated command printed
"unexpected Exit: 3" and exited 1, silently discarding the requested code.
Nothing hit it because the check router had been converted to ReachError — but
jobs wait needs exactly this and it is what anyone would naturally write. Added
core/errors.ReachExit as the sanctioned control-flow exit, passed straight
through with no verdict. ReachError would have been wrong twice: a failure
verdict for a command that worked, and a demand for a fix= where there is no
remedy.
Reconciliation proved out on a real corpse rather than a simulated one — the
job stranded by the T-1277 bug, status "running" with its process long gone,
now reports as died. DIED is derived, never recorded, because a process killed
outright cannot write its own ending. It relays 137, never 0: a died job has no
exit code of its own and borrowing success points the exit-0 trap straight at
whatever gated on the run.
Second UTC bug of the same family as T-1276's: jobs list reported a job started
minutes earlier as running for 133m, because _parse used mktime on a UTC stamp
and silently added the offset to every duration.
console.render() is public now, so jobs log replays stored events through the
same path a live run prints them — a second renderer would drift, and the
divergence would surface exactly when someone is reading a log to find out what
went wrong.
test_jobs.py closes the gap T-1257 named: D-263 claims services are callable
without a CLI round trip, and nothing had ever demonstrated it, which left the
layering as unverified decoration. Every test here calls the service directly.
Not yet exercised, and said plainly: log --follow against a genuinely
long-running job. Nothing in reach runs long enough to tail yet. The offset
mechanics underneath are tested; the live loop waits for a slow domain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
core/process.py spawns a child that outlives its parent: its own session, so a
signal to the parent's group or a timeout kill does not take the work with it;
re-execing reach by BARE NAME, because an absolute path would freeze the child
to whichever checkout was current at spawn time and silently run the wrong
source after a repoint; and streams kept separate exactly as in the foreground,
events to <id>.jsonl and real output to <id>.out.
Testing a case the ticket did not name found a real hole. Recording completion
inside @command looked right and was wrong: a child that fails BEFORE any
command runs — bad arguments, an unknown verb, an import error — never reaches
that decorator. `reach --detach check bogus` left its metadata reading
"running" forever with the process long gone. That is the exit-0 trap wearing a
new disguise and worse than the original, because a failed job that looks busy
sits somewhere nobody is watching, and a caller polling for completion would
wait indefinitely on something that failed in milliseconds.
So completion is recorded at the PROCESS's exit instead. main.py gains main(),
wrapping cli() in a single try/finally, and the entry point moves to
main:main. Every exit path now passes through one place. Removed from @command
rather than left in both — two writers of one field is how they drift.
Verified on three paths: success records done/0, a real drift failure records
failed/1, and the parse failure that exposed the hole now records failed/2.
One narrow conformance exemption, with its reason inline so it does not read as
an oversight: the no-domain-imports-core.jobs invariant fired on main.py,
correctly by its letter and wrongly by its purpose. main.py is not a command;
it is the entry point, and it already owns --detach.
Still open, and carried to T-1278: a child killed outright cannot record
anything, so jobs list must reconcile against process liveness rather than
trusting the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Streaming as a decorator, first half. Each invocation of reach gets an id and
every event it emits is tagged with it, which is what will let a detached run's
log be read back and what correlates the lines of a run that streamed for nine
minutes. No command signature changed and no command imports core.jobs — that
is the point, per the D-263 amendment: a command must not know jobs exist,
because the alternative is call-site discipline wearing a different hat.
A ContextVar rather than a module global. A global is correct only until
something runs two invocations in one process — which a test harness or a
future batch verb does immediately, and which would then interleave two jobs'
events under one id with nothing reporting an error.
The job context is the OUTERMOST wrapper, and it has to be. @logged emits from
its finally and @handle_errors emits its verdict while unwinding, so a context
established inside either would already be reset by the time the two most
important events are written — leaving them the only untagged lines in the log,
and they are precisely the ones a detached run gets read back for.
Fixed in passing: the job id used local time while every event's ts is UTC, so
an id read 155327 beside its own first log line reading 13:53:27. Two hours
apart reads as a logging bug every time someone correlates them by eye.
New conformance invariant — nothing outside core/ may import core.jobs. My
first version of it inspected only the module path, so it missed
`from tooling.core import jobs`, where the name is in the import LIST and which
is the form anyone would actually write. It passed while checking nothing.
Rewritten to catch all three reachable forms and then verified by committing a
real violation, which it named by file and line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bare `reach` and bare `reach <domain>` printed help and exited 2, Click's
usage-error convention. Running reach with no arguments is the DISCOVERY
action — it is how the tool gets learned from nothing — and a caller that
branches on exit status would read its own onboarding as a failure. Now they
exit 0.
D-263's exit-code contract is untouched: it governs failures, and printing a
command list is not one. Verified across the whole matrix, because this change
flirts with the exit-0 trap that record opens with — bare 0, bare domain 0,
--help 0, unknown domain 2, unknown verb 2, real failure 1. All five are now
pinned as a sixth conformance invariant, since an exit code regresses silently
and nothing else would notice. Proven to fail by putting the 2 back.
The implementation also collapses a duplicated class. core/cli.py holds
ReachGroup with both shared behaviours — no-args-prints-help-and-exits-0, and
unknown-name-enumerates — and LazyDomainGroup now extends it instead of
subclassing TyperGroup directly, keeping only the laziness and the
domain-specific wording. The enumeration logic previously existed twice in
slightly different forms, which is how the root and the domains would have
drifted into disagreeing about their own conventions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Python file and executable in tooling/ assigned to one of 15 domains,
with the ambiguous cases carrying their reasoning. The per-domain port tickets
are written from this rather than guessed, so their boundaries do not have to
be renegotiated halfway through a 160-file move.
Three things counting turned up that reading would not have.
The Blender carve-out is 35 files, not the 13 visible at top level — 22 more
are inside garment-fit/, which turns out to be a payload directory wearing a
domain's name. The epic said 35 and an earlier survey of mine said 14; the
epic was right. That is not cosmetic: `character` is a far smaller domain than
directory sizes imply, and a port ticket written from the listing would have
been wrong about both it and the carve-out.
The "28 singleton prefixes" were an artefact of splitting filenames on the
first token, which scattered coherent families — sculpt-star-map,
tune-star-map-topology and generate-star-map* are one group counted as three
orphans. Counting families instead, the genuinely ambiguous set is small
enough to enumerate with reasons.
And tooling/db/ is misnamed: it holds the audio/image/Trellis connectors and
wiki_sync, while the actual database work is in economy-db/. Naming a domain
after that directory would have carried the misnomer forward.
Judgment calls settled with reasons, since each sets a precedent. Registries
stay data rather than becoming verbs nobody would type. Gate tests do not
become a `test` domain implying a runner that does not exist. pql-migrate is
provenance — archived, not deleted and not importable. `pr` is a domain the
epic omitted, kept out of `dev` so dev does not become the drawer everything
ambiguous goes into. And `atlas` is overloaded across three unrelated places —
map data, terrain quality analysis, and systems.db index tables — which stay
with their owners rather than being collected into a domain whose only common
thread is a noun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every non-zero exit names the command that would fix it, and still exits
non-zero. Both halves matter; the second is the one that gets lost, because a
tool that explains itself beautifully and exits 0 looks MORE correct while
having silently disabled its own gate.
core/errors.py holds ReachError(message, fix=) and @handle_errors.
core/logging.py holds @logged, emitting through console rather than a second
sink — one output path, so there is nothing to drift. core/command.py composes
them, and the order is load-bearing: handle_errors wraps logged, so the logger
sees the original exception. Inverted, every failure would be recorded as
"SystemExit" and the log would say nothing about what went wrong while looking
like it worked.
core/ raises SystemExit, not typer.Exit. A service must be callable from a
test, another service, or a future second front end, and an exception type that
only makes sense inside a CLI leaks the transport into every layer.
The check router is retrofitted off its hand-rolled verdict-and-exit pattern —
exactly the boilerplate this removes — and test_check_parity.py passes
unchanged across the retrofit. That test predates the decorators and pins exit
codes against the old script, so it is independent evidence, not a test tuned
to match new behaviour.
Unknown domains and unknown verbs now enumerate what exists instead of only
saying no. That needed a shared group class, which collided with "no typer
outside main.py and router.py" — resolved by sharpening the invariant rather
than breaking it, since its purpose is that a SERVICE never knows it was called
from a CLI. Transport now lives in main.py, router.py and core/cli.py; never in
service.py, schemas.py or helpers.py. The upside is that cli.domain() carries
the settings that were previously per-router decisions, including the
load-bearing rich_markup_mode=None that one forgetful domain could have undone.
test_conformance.py makes five invariants executable, AST-based rather than
grep. Scoped to the package, not the 123 legacy scripts — and deliberately so:
as T-1250 moves each script into domains/, it lands inside the scope and the
rules start applying automatically, so the test's reach grows with the
migration.
Proven to fail before being trusted: removing @command and removing a fix= each
produced a failure naming the file, the line and the reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
schemas.py becomes pydantic, so the reference domain is the normal pattern
rather than an exception carrying a footnote. Frozen: a result is a statement
about what was found, and nothing downstream should edit the finding on its way
to being reported. pydantic stays off the --help path — test_lazy_domains still
passes, which is precisely the assertion that it loads with the domain and not
with the CLI.
The acceptance criterion could not be met as written, and that is the finding
worth keeping. It asked for byte-for-byte parity with the old script; D-263 was
amended after this ticket to give reach a streaming model that puts the verdict
on stderr, while the old script writes its success line to stdout. Measured:
the text is byte-identical in text mode, only the stream differs. Matching both
would mean abandoning streaming or special-casing every ported gate.
So parity is redefined, and it is stronger than bytes where it counts: exit
codes match exactly, no fact the old message carried is lost, and failures name
a remedy as a structured field. That governs every port in T-1251, not just
this one, so it is in D-263 rather than only here.
test_check_parity.py runs three paths — ok, drift, missing file — through both
implementations and compares. It builds a throwaway fixture repo and copies the
OLD script into it, because that script resolves its root from __file__ and has
no override; the new command just takes SR_REPO_ROOT. That asymmetry is part of
why the port earns its keep. It also asserts the failing paths actually exit
non-zero, without which "the exit codes matched" would be vacuous for two
checks that both silently pass.
Proven to fail twice before being trusted. Once by accident: the first version
asserted the yaml version appears on every failing path, which the old script
does not report when the client file is missing — the test was wrong, not the
code, and it now derives expected facts from what the old output actually
contains. Once on purpose: mutating the router to drop a version made it fail
and name the missing fact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`reach --help` renders from a declaration table and imports nothing. The cost
of help is now flat as the registry grows, which is the property that has to
hold going from one domain to a dozen.
The trap is real and was confirmed in typer's vendored source rather than
assumed from upstream Click: TyperGroup.format_commands loops over
list_commands calling get_command on each, purely to read a short help string
off the loaded command. With lazy loading underneath, that imports every
domain in the registry to render --help — while the output looks entirely
correct. Nothing observable changes; only the import graph does.
So the test asserts on sys.modules, and it was proven to fail before being
trusted. Disabling the format_commands override made it fail and name the
cause, listing all five leaked check modules. It also carries a positive
control — invoking a domain must import its service — because without one,
"nothing was imported" would pass equally for a loader that is simply broken,
and it fails on an empty registry, which would otherwise satisfy everything
vacuously.
The check domain is created here because the test needs a subject: a stub
raising NotImplementedError would have been committed dead code. That takes
the port out of T-1262, which is rescoped to what it still owns — pydantic
schemas, byte-for-byte output parity on the drift path, and the failure
tests. The old tooling/check-client-version script stays in place and stays
wired to the pre-push hook; the deprecation window is deliberate.
One Typer behaviour worth knowing before every future domain: a single-command
app collapses into a bare command, so `reach check client-version` failed with
"unexpected extra argument" until the router got a callback. Same mechanism as
the root callback, different symptom.
Help now works at every level, closing item 5 of T-1248.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`reach --help` runs from the console entrypoint in 80 ms. typer 0.27.1 and
pydantic 2.13.4 join the dependencies, both CVE-checked against NVD, OSV and
the GitHub Advisory Database.
The design in the ticket did not survive contact. It specified a click.Group
root, on the reasoning that it would keep typer off the --help path — but
typer vendors Click as of 0.26.0, so there is no top-level click package to
import and no supported way to extract typer's internal one. A click.Group
root hosting Typer sub-apps would put two Click implementations in one
process. The root is therefore a typer.Typer, and lazy registration will go
through the supported typer.Typer(cls=...) surface with a TyperGroup
subclass. T-1260 is corrected to match.
The callback is not decoration: a Typer root with no commands AND no callback
raises at build time, and lazy registration means no command is ever eager.
The ticket claimed a zero-command root always raises — half right, and the
half that matters is that a callback makes it legal.
rich_markup_mode=None is load-bearing rather than cosmetic. It takes an empty
--help from 168 ms to 74 ms, and keeps rich and pygments off the import path
entirely rather than merely skipping the render. It also stops typer drawing
box-art help, which it does even when stdout is a pipe — that would have put
box-drawing characters into every hook log and agent capture. typer-slim was
considered and rejected: deprecated since 0.22.0, now a shallow wrapper that
installs all of typer.
D-263 amended: the feels-instant ceiling goes from 250 ms to 500 ms. A ceiling
is not a typical and most invocations sit far below it; the tighter number was
buying discipline that the import-graph assertion enforces better. Stay smart
about what loads, stop worrying about tightness.
Security, checked 2026-08-23. typer has no advisories on record. pydantic
2.13.4 clears PYSEC-2026-1812 (email-regex ReDoS, fixed in 2.4.0) — and the
2026 SSRF advisories CVE-2026-25580 and CVE-2026-54249 are against
pydantic-ai, a different package that is not a dependency here, recorded in
pyproject so the next sweep does not re-panic. Transitively, pygments 2.21.0
clears CVE-2026-4539.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skeleton the reach CLI hangs off. Nothing moves yet: this adds the
package, the bounded core/, and explicit setuptools discovery.
core/console.py is the single output path, and the split it enforces is the
whole design — stdout carries the command's actual output so `reach ... | jq`
keeps working, stderr carries the event stream as JSONL. Rendering happens at
the sink: a terminal gets human text, anything else gets raw JSONL, so a live
view and a job log are one artefact in two presentations. Emitting is
optional — the gates emit nothing — and verdict() prints once, last, carrying
its remedy as a structured field.
core/config.py resolves the repo root from __file__ against a project.yaml
sentinel, with an SR_REPO_ROOT override. No subprocess and no git call: this
is on the gate path, and cwd is not a reliable signal anyway since a hook runs
from the root and an agent call may not. Both paths are validated, because a
silent fallback is how you end up editing one checkout and checking another.
Discovery is configured explicitly rather than left to flat-layout
auto-discovery, which would have had to choose between erroring on the
ambiguity and quietly shipping client/ or docs/. Verified: top_level.txt
contains exactly "tooling".
Verified beyond the happy path — the sentinel rejects SR_REPO_ROOT=/tmp and
names both remedies; debug events are suppressed at the default threshold
while the verdict is not; stdout stays clean with stderr redirected away; and
the three unconditional push-gate checks still pass now that tooling/ is a
package, which was the real regression risk.
Two findings recorded on the tickets. make setup-venv is stale — it calls
.venv/bin/pip, but the venv was created by uv and has no pip, so the recorded
procedure and the actual state have already diverged (T-1261 owns the fix).
And settled-reach-tooling had never actually been installed: site-packages
held the dependencies but no dist-info, which follows from there being no
__init__.py to expose. This is the first commit where `import tooling` means
anything.
.venv/ was only ignored via .git/info/exclude, which is machine-local, so a
fresh clone or a new worktree did not ignore it at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The relationship between wiki/, the generators, systems.db and the runtime is
a directed graph with two edges running opposite to the obvious direction and
one running backwards into its own producer. Prose renders that badly: every
document that has described it states a single ownership direction and is
therefore wrong about part of the tree. D-262 makes the diagram the source of
truth and points CLAUDE.md, Skill(wiki), project-structure.md and
wiki/GOVERNANCE.md at it.
The correction that matters most: body pages were described everywhere as
machine-owned and reverted on sync. They are not. scaffold_bodies.py writes
one once and never overwrites it, and import_economics then reads that
frontmatter directly as input — so a hand-edit is not reverted, it is obeyed,
and silently changes world generation. Worse than being overwritten, and the
actual reason GOVERNANCE.md forbids the edit.
New: tooling/check-dataflow-graph.py, wired into the Makefile and the pre-push
hook. It asserts every repo path named in a hand-authored diagram still
resolves — and its docstring states plainly what it cannot do: verify that an
edge still MEANS what it says. If wiki_sync.py stopped writing body pages
tomorrow, every path would still exist and the check would still pass. Edge
semantics stay a human check against the tool's source, so nobody reads a green
gate as a verified map.
Verified by breaking it: pointing one label at a moved path fails with exit 1
naming that path; restoring it passes. Building the checker also caught two
real vaguenesses in the diagram — "GJ-*/index.md" and "bodies/{id}/index.md"
were written without their wiki/star-systems/ prefix, which is precisely the
ambiguity this map exists to remove. Generated star-map .d2 files are excluded
by name; their correctness belongs to their generator under D-223.
Also files Q-124 + T-1246 (tooling): whether the 123 Python files under
tooling/ should become one Rust CLI of pql's calibre. The friction is real and
mostly not about the language — the permission gate prefix-matches whole
command strings and a blanket Bash(python3 *) grant is forbidden, so each tool
prompts near-individually, while a single binary is one allowlist entry. The
record requires pricing the cheap alternative (a Python dispatcher entrypoint)
before recommending Rust, and flags the hard constraint: import_economics is
stamped by source SHA, so any port must keep that contract intact through the
transition rather than disabled during it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`relief_q` is the one field with signal below District — elev_q's 80 m steps
quantise sub-district detail away, which is precisely why relief_q was invented.
The server has encoded it since 5eb394b36 and the terrain layer has asked for it
by name ever since. step_canvas_protocol.gd's decode dictionary never listed the
key, so `canvas.get("relief_q")` was always null and the plane arrived nowhere.
The server half of that change landed; the protocol half did not.
That is the whole reason Region and below rendered as a flat wash. Measured plane
variety at District before the fix:
{morphology: 1, elev_q: 11, relief_q: 0, moisture_q: 25, vegetation: 3}
A 0 there means ABSENT, not constant — a distinction the capture could not make
until this commit adds it, and the reason two earlier sessions read the flatness
as a missing generator rather than a missing key.
Also spends the field properly. It drove a stipple PROBABILITY only, so a ridge
and a plain differed in dot density, which at one pixel per cell reads as noise;
and `_ruggedness()` took absf(relief_q - 50), discarding the sign the server
deliberately preserved ("a hollow and a rise are different ground... the reverse
is not recoverable"). Relief now shades continuously and signed — rises lighten,
hollows darken — UNDER the stipple rather than instead of it. Ruggedness
(unsigned) and elevation (signed) are different questions and both are worth
asking.
Ladder, before -> after (tooling/atlas-flatness, lum p1-p99):
Global 145.69 -> 145.69 unchanged, correct: relief_q is flat 50 at
orbital rungs by construction
Region 33.59 -> 71.01 2.1x
District 13.72 -> 77.01 5.6x
Quarter 11.01 -> 42.56 3.9x
Structure retention Global->Quarter: 7.6% -> 29%.
NOT finished, and the ticket says so: Region now reads as heavy speckle, because
ruggedness is real data instead of an elev_q-gradient fallback and far more cells
earn a mark than the T-1194 tuning assumed; District reads as soft blobby relief,
form without directionality. Both are grammar/tuning follow-ups on a channel that
finally carries signal.
0.4.9 is a REQUIRED bump. The disk cache stores the DECODED canvas, so every
earlier entry physically lacks the field and would keep rendering flat against a
build that reads it — the first bump in this series where a warm cache is wrong
about CONTENT, not merely stale. tooling/canvas_sources.py gains
step_canvas_protocol.gd for the same reason: it decides which planes exist, the
cache stores its output, and the T-1242 gate would not have flagged this fix
while the registry stopped at ui/.../step_canvas/.
Regression cover: every protocol test passed throughout the weeks the plane was
missing, because each asserted a field it already knew about and none asserted
the SET. There is now a test walking all eight dense planes of EncodedStepCanvas,
verified by disabling the fix and watching it fail by name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
D-258 invariant 2 says descending the ladder must reveal COMPOSITION — a cell
reading forest must be able to contain the clearings and rock the vote
suppressed. One assertion stood behind that, and it read:
assert!(tally.len() > 1 || share == 1.0, ...)
A single-class tally has a 100% share by definition, so both branches are always
satisfiable: the check could never fail, including in the exact case its own
message names, "or nothing was composed". The invariant had a test and no gate.
Split into the two bounds the invariant actually has, because it is two-sided:
conservation caps how much may be invented (majority > 50%, already asserted) and
composition sets a floor on how little (minority >= 0.1%). Verified by raising
the floor to 2% and watching it fail on the measured 1.07%, then restoring it —
the floor is a tripwire for "did anything happen", deliberately far below the
measurement rather than tuned to it.
Measured at the descent ladder's own anchor on Ferrath:
conservation: majority class 3 at 98.9% across 2 classes {1: 175, 3: 16209}
So composition IS working in the data and conservation holds. The map is flat
anyway, and tooling/atlas-flatness (added here) says why the eye was not enough:
rung distinct lum p1-p99
Global 1581 145.69
Region 2923 33.59
District 53 13.72
Quarter 46 11.01
Region carries almost TWICE Global's distinct-colour count while holding a
quarter of its structure — the dither pass adds colour noise, not information, so
a colour-count metric would have called the flattest rung the richest. Structure
falls ~92% from Global to Quarter.
The cause is a channel mismatch rather than a missing generator: composition
perturbs moisture_q/slope_q, and the base map draws morphology hue x elev_q
lightness. The ladder scenarios pass no overlays deliberately, so the composed
fields are never rendered in the very shots that judge this work. Recorded on
T-1213 with the three ways forward; the choice touches D-258 and is Jeroen's.
The gate is still #[ignore]d — noted on the ticket as worth moving into a harness
that runs, since believability and window-derivation already load real bodies in
the normal cargo test path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
project.yaml's version is the Atlas disk cache's only invalidation signal, and
nothing enforced that changing canvas GENERATION also moved it. It broke five
times -- 0.4.2 lake_margin_q, 0.4.3 coast_warp_px, 0.4.4 the extent inversion,
0.4.5 the Global sentinel, 0.4.6 one-course-per-river -- each bumped only after
someone noticed a wrong map. The failure is invisible to its author: it needs a
warm cache to reproduce, so a cold checkout looks fine. T-1239 is the last one,
and it took eight days.
tooling/canvas_sources.py is the path registry; tooling/check-canvas-version
rejects a push that touches those paths without moving project.yaml's version
line. Wired into the pre-push hook, `make check-canvas-version`, and, for the
parsing units, `make test-tooling`.
Verified against real history rather than a synthetic branch: run over
4e503c356 -- the commit that actually caused T-1239 -- the gate rejects and names
the three files. Run over the commits that DID bump (bdea71953, 39f0fd8c5, and
T-1239's own fix), it passes.
The registry is globbed, not hand-listed. step_canvas.rs imports ten sibling
modules and those import more, so a traced closure would be stale within a month,
and stale here is silent. It over-includes on purpose: a false positive costs one
bump and one round of cache misses, a false negative costs another week of a
wrong map -- the ticket's own ruling.
Two deliberate calls worth naming. The registry includes ITSELF, which closes the
narrowing hole: remove a path and change that same path in one push, and the gate
still fires because the registry file is in the set. And there is no override
flag -- it would be reached for exactly when someone is certain their change is
harmless, which is the reasoning behind all five regressions.
Version bumped 0.4.6 -> 0.4.7 with NO canvas-generation change: self-inclusion
means adding the registry trips its own rule. Spent rather than special-cased,
because the first exception is how a rule like this dies.
The units cover the property no branch run can show -- that editing project.yaml's
comment block, which quotes old version NUMBERS directly above the field, is not
a bump -- plus a registry-coverage test naming the files each of the five known
regressions touched, so a future narrowing past them fails loudly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
current_schema_version() line-scanned res://../project.yaml at runtime. That
resolves to the repo root in a dev run and to nothing in an exported build, so a
shipped game got the "?.?.?" fallback every time. Since that tag is the Atlas
disk cache's ONLY invalidation signal, every exported build stamped and compared
the same sentinel: a canvas cached by one build would be served by every later
build, forever. T-1239 is what that failure looks like once it happens.
loading_screen.gd carried a byte-for-byte copy of the same function, so the
version shown to the player was "?.?.?" in exactly the builds where a version
string is worth showing. Both call sites now share client/scripts/build_version.gd,
which reads application/config/version out of ProjectSettings — a value Godot
bakes into the PCK, identical in the editor and in an export by construction
rather than by luck. No file IO, no fallback branch.
project.yaml stays the source of truth (CLAUDE.md); client/project.godot mirrors
it. A mirror nobody checks would be worse than the bug it replaces -- the old
code failed loudly everywhere, a stale mirror fails silently -- so
tooling/check-client-version compares the two and the pre-push hook runs it
unconditionally. Not gated on "were those files in this push": drift persists on
main once introduced, and gating would let an existing drift ride along.
The test this replaces asserted that current_schema_version() did not return its
fallback, and passed -- in the one environment where the code under test worked.
Three tests now pin the property that actually matters: a real version, sourced
from the baked setting, matching project.yaml.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run-visual verified only that the captured PNG was non-empty AS A FILE. A
blank screen is a perfectly valid ~19 KB PNG, so it passed — and once a blank
capture had been recorded as a golden, every later blank capture matched it at
0.0% and the scenario PASSED. atlas_GJ338Bd_Block and atlas_GJ445c-m1_Chunk
sat green against blank goldens while the suite's other 30 scenarios failed.
That is the worst kind of test result: indistinguishable from success, and
load-bearing for exactly the work it fails to cover. e024cfb3f recorded this
same failure once already ("the Atlas Global goldens have been measuring
nothing"); it recurred because nothing checked the property, only the file.
tooling/visual-blank-check measures the share of the frame taken by its single
most common colour. On this project's real captures the classes are far apart:
Global (real world map) 38.7% modal
Region (flat colour wash) 7.2% modal <- dither; least uniform of all
District 45.4% modal
Block / Chunk / Quarter 92.9-94.6% modal <- nothing drawn
Nothing falls between 45% and 93%, so the 0.85 default sits in open space
rather than being tuned against a boundary case. Deliberately NOT an aesthetic
judgement: the Region wash is a real product gap (T-1213) and scores 7.2%,
comfortably "content". The question is only whether a world reached the
screen.
Wired into both paths, and the update path is the one that matters — refusing
to RECORD a blank golden is what stops the trap being re-armed. Ad-hoc
--screenshot only warns, since capturing a rung that renders nothing is a
legitimate thing to want to do; that is how the empty deep rungs were found.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
godot-cold-parse only ever sees scripts on the STARTUP path: autoloads and
the main scene chain. That is the correct scope for the job it was built for
(Sprint 36's `Could not find base class "MetaScreen"`, a registration-ORDER
bug), but it is far narrower than the name suggests, and most of the codebase
is invisible to it. Verified by deliberately breaking a non-startup UI script
and a test file in turn: cold-parse reported "clean", exit 0, for both.
That is the second half of today's false green. A parse error in
test_step_canvas_annotation_layer.gd survived cold-parse AND survived
gdUnit4, which reports the suites that DID load as a clean pass. Two gates,
one blind spot: neither verified that a file it never opened was openable.
godot-parse-sweep opens every .gd in the project (226 today, addons and
.godot excluded) and fails on any that will not parse.
The split between the two halves is forced, not stylistic. No Godot API
reports GDScript parse failure reliably:
- ResourceLoader.load(path, "GDScript", CACHE_MODE_IGNORE) SEGFAULTS the
engine on a script that fails to parse — it dies on exactly the input the
tool exists to find.
- GDScript.new() + source_code + reload() returns a clean error code but
detaches the script from its resource_path, so class_name, preload() and
relative extends stop resolving: it reported 150 of 226 healthy scripts
as broken.
- Plain ResourceLoader.load() neither crashes nor false-positives, but
returns a NON-null object for a broken script, so its return value is
useless.
The engine's own stderr is the only honest signal. So the GDScript half just
opens files and makes no verdict; the wrapper scrapes the diagnosis. The
wrapper also refuses to pass unless the sweep reported completion, so a
future break in the walk cannot itself become a false green.
Unlike cold-parse, "Cannot infer the type" is NOT filtered. That filter is
precisely why cold-parse stayed silent about the file below.
First run found a real one: client/tests/util/scene_helper.gd has not parsed
since 2026-02-25 — five months — because `func(a := null, ...)` cannot infer
a type from null. Fixed with explicit `: Variant` params. Blast radius is
zero (the helper has no importers, so nothing else was taken out with it),
but it went unseen by two gates for five months, which is the point.
Full suite green at 3660.
Pair session with Jeroen, 2026-07-27.
Co-Authored-By: Claude <noreply@anthropic.com>
Guard becomes land_districts <= 1 (both reviewers converged — a lone
island definitionally cannot show two distinct directions; same
nothing-to-vary condition one value short), with a lone-island vacuous-
pass fixture; golden confirmed untouched. Oasis scaling adjudicated as
LIVE, not future — GRID_W is already 1024 on main, so ring iterations
change 2/4 -> 4/8 today: extracted a pure oasis_ring_iterations()
helper pinned by tests at both 512 and 1024, and traced exactly why the
determinism hash stayed green (it reads only elevation; the rings touch
only biome — a genuinely different array, not a coincidence). The
drainage merge-logic question answered byte-precisely: zero logic
changed vs main (comment-only diff) — and the deeper dig PROVED the
'isolated basin with another basin to escape to' branch is
mathematically unreachable for any connected grid (contracting vertex
groups of a connected graph cannot disconnect it), so the comment now
states that instead of narrating a divergence that never fires; two
direct merge-target tests added regardless. Wrap test renamed to what
it actually pins (non-wrap-awareness). D-010 docstring softened to
same-process purity, naming the cascade golden as the cross-run layer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verified-still-open coverage list: per-type attractor reachability
fixtures (LakeShore via enclosed depression, PassEntrance via crafted
saddle, PlainCenter via flat terrain, RiverCrossing via confluence) plus
thin_by_spacing behavior (collision, strict-< boundary, equirectangular
column wrap); heightmap 8-bit decode, sea_level passthrough, downsample
identity and zero-target early-return; drainage area_pct bit-for-bit
determinism plus the isolated-basin-fallback divergence comment (Tyre
N1, citing the pre-#953 behavior it deliberately departs from); the
layer1 mountain-branch pairing test (investigated first — the cascade
test supplies a mountain pool but only ever asserted river counts, a
genuine gap); an importer idempotency test covering atlas_city_names
AND atlas_feature_names plus the Sol exemption, wired into
make test-tooling; and the oasis_water dilation radius scaled by
GRID_W/512 (Tyre N2, hash-stable). One stale item dropped per the
refinement trim (test_sim_determinism wiring — already done).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One commit for two tickets whose changes share the bridge/plugin
plumbing files. T-1169 connects the three dormant feature-name pieces:
atlas_feature_names populated at regen (17,891 rows — 15,190 mountain,
2,701 river — via populate_atlas_feature_names mirroring the city-names
importer; systems.db regenerated, stamp fresh), attach_feature_names
wired into the cascade's Topography block with name pools threaded
DB-free through AnalyzeBody (D-225 pattern) and assignments stored on
Layer1Output/BodyWorldState for future consumers, and a
FeatureNamesRequest/Response read proxy as the bridge's 7th tagged
envelope (D-236 pattern, both SimBridge impls). Client label DRAW is
deliberately NOT here — implementation proved both river and mountain
labels need a wire-carried position (the pool is position-free; course
polylines aren't correlated with the named attractors by construction) —
deferred to T-1195's single design pass. cascade_layer1 golden re-pinned
(additive feature_names field).
T-1159 retires the legacy u32 granularity field fully shadowed by
window_granularity_v2: AtlasLayerRequest.window_granularity,
DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and
resolve_window_granularity are gone server-side; client encode paths and
the caller-less atlas_window_cache legacy key component dropped;
msgpack fixtures regenerated; the T-1150 aliasing regression test now
drives through the surviving enum field. The district_window carrier
itself survives byte-compatible per D-255(c).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The artifact tab is sandboxed (no phone-home), so Claude cannot query the
tab's live selection; the copy-ref button in the ticket header copies
'viewing T-NNN — title' for a click-paste handoff (clipboard API with
prompt fallback). Selection also remains in the URL hash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tooling/pql-board-html: self-contained interactive board snapshot from
pql-native JSON (ticket list --full + batched --with-blockers for non-terminal
tickets + plan status) — status-grouped rail with filter/type chips, ticket
dossier with status pills, dep/children chips, deep links, keyboard nav;
clide's visual identity (amber on warm near-black, mono data type). The
/pql-board skill regenerates and redeploys to the canonical artifact URL so
the user's open tab survives refreshes across sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Jeroen confirmed the .internal-to-.net proxy migration was intentional
(2026-07 weekend maintenance): git.schweitz.internal's vhost is gone,
git.schweitz.net is live with a LE cert and AdGuard LAN hairpin.
tea-cli.md + local-services.md repointed (tea's own config already
switched). tooling/db/config.json: the bare tower-of-joy hostname has
no DNS entry since the migration — Stable Audio/Trellis endpoints now
by IP. Workshop archives under docs/workshops/ keep their historical
.internal mentions (records, not operative config).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
H1+H2: headquarters_body is reset+derived every run (DB is never source); optional authored frontmatter override, hard-validated; tiebreak now population DESC -> city-bearing body -> type rank -> body_id, preserving belt tenancies and fixing GJ702B to GJ702Bb. T2: NULL-reset pass for corp_specialization/hq_placement before authored re-apply (poison-tested). T1: wiki/corporations/*.md globbed into IMPORT_ECONOMICS_SOURCES. M1: licensed_clinical_services vocabulary value (31st, NonPhysical->CityTenant) + somatic-futures retag. Fixpoint verified stable across 4 consecutive regens (0 diffs); regen systems.db.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fmt: atlas_data_proxy.rs test code. godot-cold-parse: the cold parse re-seeds the class cache WITHOUT addon classes (gdUnit4's GdUnitTestCIRunner missing), leaving tests/run-godot unable to start (0 tests / 355ms — caught by the pre-push gate running the suite right after this script). The script now restores a full cache via a final --import pass before exiting; the cold verdict is unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent shells often miss the brew shellenv (clide FR-1), so bare 'tea' 127s. The wrapper now falls back to /home/linuxbrew/.linuxbrew/bin/tea.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-command corrections (atlas corridor-status, real body-ID naming), failure-proofed glb-gen/sprite-gen render scripts, Trellis API reference extracted. image-gen: fixed the output-path bug and de-forked the local image_connector.py to the canonical tooling/db/ copy. ticket skill consolidated to point at ticket-cli.md (setparent-none fix applied there too). Part of T-1099.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tooling/worktree-setup <branch>: one command to create a usable worktree —
adds it under the gitignored .worktrees/, symlinks .venv (so make/python
tooling resolves .venv/bin/python), relies on the post-checkout hook for the
pql --vault rebuild, and prints the in-worktree reminders. Tested end-to-end
(venv linked, pql.db populated on create).
whats-next §3c now calls the helper and documents the three in-worktree
gotchas (pql --vault, tea-from-main, read-only content agents); tea-cli.md
notes tea must run from the main checkout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
H1: guard that each axis value is a {token=weight} table before .items() —
a scalar (wall = 15000) or the array shape (wall = ["steel_frame"], a
plausible copy-paste from the sibling catalog's visual_bundle) now yields a
clean V-TT-06 error instead of a bare AttributeError. Mirrors the isinstance
guards already on zone_map and axes.
H2: exclude bool from the positive-integer weight check (weight = true is an
int subclass, previously slipped through as 1) — matches the guard
populate_color_register_bands already applies to its own values.
Two new ZoneBiasValidationTests cover both branches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ratified content baked into systems.db as two new tables:
- architecture_zone_bias (57 rows): sparse per-template, per-zone_type
token-weight overrides (integer bps), Miri-authored — the D-235 step-2
zone bias. V-TT-06: every token must exist in that template's own
visual_bundle axis.
- color_register_bands (28 rows): per color_register integer HSV bands
(hue centidegrees, sat/val bps), Araminta-authored. V-TT-07: full
catalog coverage + valid integer bounds (min<max, in range).
Both TOMLs registered in generator_sources (stamp coverage); DDL in
systems-schema.sql + migration.py; validation wired into import_economics
steps 18/19 and test_traits.py failure-branch units (make test-tooling).
systems.db regenerated + re-stamped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
T1: heritage corridor_pool excluded from the ordinary phase-1 lottery and
coverage repair (D-232 reserves the heritage sub-pool for the remoteness
dial); reachable via hero pin, necessity swerve, and the T-1003 heritage
pool only. 3 new tests.
T2+H4: coverage repair no longer grows trait_selection past K when all
slots are pinned (phase-2 necessity swerve serves the type instead);
runtime warn when authored pins exceed K; V-TT-05 importer guardrail
bounds pins per body at 5 (max ComplexityTier K). 2 new tests + 2 python
tests.
H1: body-level dispatch aggregation extracted to pure
aggregate_body_dispatch_inputs + tested directly (union mix, MAX
prosperity/K); threading test asserts identical vocabulary/pools across
co-body settlements with per-settlement swerve rates. 2 new tests.
H2: tooling/economy-db/test_traits.py — 14 stdlib unittest cases over
V-TT-03/04/05 failure branches, wired into make test-tooling.
H3: hard-gate JSON parsers now tracing::warn on malformed blobs (silent
gate-widening) matching the sibling map parsers.
H5: catalog read memoized (OnceLock) — SQL+parse once per server run,
bias stays per-body. 1 new test.
T3: TraitDistrict seed-domain doc aligned with the two-level derive chain.
T4: D-225 misattribution dropped from the reader module doc.
systems.db regenerated + stamped (traits.py is a stamped source).
Gates: full cargo test 1638 green (goldens intact), clippy -D warnings,
ruff, make test-tooling (now incl. the traits units).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User review finding: sneakers/formal shoes/boots conformed to individual
toes (and toes poked the closed front). Root cause: the skin-conforming
clearance clamp ran AFTER toe smoothing and re-imprinted the original toe
bumps; boots also copied per-toe skin weights (ripple under flex). Fix:
shared base.convex_toe_box() — per-slice enclosing ellipse from the skin,
notch fill, projection onto the smooth cap (outside skin by construction),
extended rounded nose past the longest toe, uniform feathered ball-bone
binding so shoes flex rigidly at the ball joint. Style-parameterized
(sneakers roomy / formal sleek tapered / boots chunky). Re-authored x 11
bodies; QA all-green (worst 82px « 150 gate; residuals are collar/sole-edge
slivers, not toes). Lookbook shots re-rendered on the desktop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Twelve garments, per-body on all 11 bodies, chromakey-gated (worst clips:
parka 3px, boots 54, sweater/tank 83, sneakers 100 final-geometry, slides
123, swim trunks 132, cargo 138 — all under the 150px gate), previewed:
tank top, sweater (crew-neck via per-body ring-valley probe — first draft
read mock-neck, fixed by measured rim circularization), track jacket
(recolorable sleeve-stripe region), joggers (side-stripe region), cargo
pants, swim trunks, one-piece swimsuit, sneakers (prism-sole), formal
shoes, ankle boots (calf shaft), slides (open strap + sole), and the
hip-length thrds parka — the first canon-branded garment (Braemar
cold-weather cooperative), quilted, logo-capable.
Tops now hem into real hip geometry (the natural seg_torso bottom is a
9-14cm tooth ring — the sweater established the hem-into-hips practice).
Manifest merged by the lead: 24 clothing entries with region/default-tint
metadata. Full modern catalogue: 21 garments across tops/bottoms/feet/
full-body x casual/formal/sport/swim/outerwear.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Engine: tooling/garment-fit/blender_batch_fit_skinned.py (G1 — the skinned
Surface-Deform batch the old script couldn't produce; self-check green),
blender_author_offset_shell.py (route c: garment shells from OUR body
segments, weights inherited by construction, bone-plane cuts, procedural
RGBA region mask, UV2 chest channel), make_logo.py. Shader:
toon_garment.gdshader — channel-blended 4-region tint + UV2 logo composited
after tint / before toon shading. Proof: tshirt_modern fitted to the six
healthy bodies, manifest entry with style:modern + logo_capable, thrds
wordmark, 18-assertion test suite, 216-capture chromakey QA.
Key finding (Q-060 evidence): single-reference SD-fit of an offset-shell
degrades on girth-divergent bodies (muscular_m worst) — 24mm standoff
tripled headroom but the mechanism limits. Route guidance recorded on
T-1089: per-body shell authoring for offset-shell garments; SD-fit for
derived/hand-authored ones.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause was double: (1) segment_body's apply_scale scaled fork MESH
vertices but not each segment's embedded armature — the shared-skeleton
compositor relocates segments by bone name, so internally-inconsistent
segments exploded (child worst at 0.72x: head bone 0.35m above its mesh —
detached heads, spider arms); (2) thin/heavy were stale high-poly artifacts
from an older segmentation, missing seg_hips. Fix: apply_fork_scale bakes
mesh AND embedded armature via transform_apply (edit-bone poking shears
chains — first attempt proved it); new blender_rebuild_forks.py rebuilds
exactly the five from the owned UBC Source exports. All five now 19 low-poly
segments matching the healthy six.
QA on the real compositor (idle+walk, front+side): 5/5 coherent; healthy
controls unchanged. Q-060 answered at the extremes: 15/15 peasant-garment
Surface Deform binds on the forks, zero shrinkwrap fallbacks, no
bust-through — the 6-of-11 placeholder debt is paid (fork garment variants
included). Follow-up filed: T-1094 (child/teen composite at adult height —
pre-existing shared-skeleton normalization, not a regression).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pass-B garment shift is now a depth-only bias in the vertex shader (no
screen-space parallax), eliminating silhouette-growth false positives. This
supersedes the previous commit's mid-run numbers: final peasant run is
33/72 clip flags, ALL genuine tight-proximity findings — 0/18 on front
views (discriminator proof), sleeveless armhole seams on average_f (side),
deep-crouch waist gap (back, worst 150px), collar nape. Bare-arm-crossing-
torso cases correctly reclassed exposed_skin (non-gating). Sensitivity
knobs: clip_epsilon_m (3cm) + --min-pixels (8), tuned to surface tight
seams; calibrate against the first real modern garments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>